15 changed files with 1250 additions and 15 deletions
@ -0,0 +1,29 @@
@@ -0,0 +1,29 @@
|
||||
/* NIP-05 Badge Component Styles */ |
||||
|
||||
.nip05-badge { |
||||
display: inline-flex; |
||||
align-items: center; |
||||
gap: 0.25rem; |
||||
padding: 0.125rem 0.5rem; |
||||
background-color: rgba(34, 197, 94, 0.1); |
||||
border: 1px solid rgba(34, 197, 94, 0.3); |
||||
border-radius: 0.375rem; |
||||
font-size: 0.875rem; |
||||
color: rgb(22, 163, 74); |
||||
font-weight: 500; |
||||
} |
||||
|
||||
.nip05-badge.verified { |
||||
cursor: help; |
||||
} |
||||
|
||||
.nip05-identifier { |
||||
white-space: nowrap; |
||||
} |
||||
|
||||
@media (max-width: 640px) { |
||||
.nip05-identifier { |
||||
max-width: 150px; |
||||
} |
||||
} |
||||
|
||||
@ -0,0 +1,197 @@
@@ -0,0 +1,197 @@
|
||||
# NIP-05 Badge Component |
||||
|
||||
## Overview |
||||
|
||||
The NIP-05 Badge component is a live Twig component that verifies NIP-05 identifiers (DNS-based internet identifiers for Nostr keys) and displays them as a verified badge when the verification succeeds. |
||||
|
||||
## Features |
||||
|
||||
- ✅ **Automatic verification**: Fetches and validates `.well-known/nostr.json` from the specified domain |
||||
- ✅ **Security compliant**: Follows NIP-05 security constraints (no redirect following) |
||||
- ✅ **Cached results**: Verification results are cached for 1 hour to reduce network requests |
||||
- ✅ **Relay discovery**: Extracts and stores relay information when available |
||||
- ✅ **Display formatting**: Automatically formats root identifiers (`_@domain.com` → `domain.com`) |
||||
- ✅ **Graceful failures**: Shows nothing when verification fails (no badge displayed) |
||||
- ✅ **Character validation**: Only accepts valid NIP-05 local-part characters (a-z0-9-_.) |
||||
|
||||
## Usage |
||||
|
||||
### Basic Usage |
||||
|
||||
```twig |
||||
{# In any Twig template #} |
||||
<twig:Atoms:Nip05Badge |
||||
nip05="{{ author.nip05 }}" |
||||
pubkeyHex="{{ author.pubkey }}" |
||||
/> |
||||
``` |
||||
|
||||
### With Author Metadata |
||||
|
||||
```twig |
||||
<div class="author-info"> |
||||
<strong>{{ author.name ?? 'Anonymous' }}</strong> |
||||
{% if author.nip05 is defined and author.pubkey is defined %} |
||||
<twig:Atoms:Nip05Badge |
||||
nip05="{{ author.nip05 }}" |
||||
pubkeyHex="{{ author.pubkey }}" |
||||
/> |
||||
{% endif %} |
||||
</div> |
||||
``` |
||||
|
||||
### In a Card Component |
||||
|
||||
```twig |
||||
<div class="profile-card"> |
||||
<img src="{{ author.image }}" alt="{{ author.name }}" /> |
||||
<div class="profile-info"> |
||||
<h3>{{ author.name }}</h3> |
||||
<twig:Atoms:Nip05Badge |
||||
nip05="{{ author.nip05 }}" |
||||
pubkeyHex="{{ author.pubkey }}" |
||||
/> |
||||
</div> |
||||
</div> |
||||
``` |
||||
|
||||
## Props |
||||
|
||||
| Prop | Type | Required | Description | |
||||
|------|------|----------|-------------| |
||||
| `nip05` | `string` | Yes | The NIP-05 identifier (e.g., "bob@example.com") | |
||||
| `pubkeyHex` | `string` | Yes | The public key in hex format (64 characters) | |
||||
|
||||
## Verification Process |
||||
|
||||
The component performs the following verification steps: |
||||
|
||||
1. **Validates identifier format**: Checks that the local part contains only `a-z0-9-_.` |
||||
2. **Splits identifier**: Extracts local part and domain from the identifier |
||||
3. **Fetches well-known document**: Makes a GET request to `https://<domain>/.well-known/nostr.json?name=<local-part>` |
||||
4. **Rejects redirects**: Any HTTP redirect causes verification to fail (NIP-05 security requirement) |
||||
5. **Validates response**: Checks for valid JSON with required `names` field |
||||
6. **Matches public key**: Compares the returned pubkey with the expected pubkey |
||||
7. **Validates hex format**: Ensures pubkey is in hex format (not npub) |
||||
8. **Extracts relays**: Optionally retrieves relay information if present |
||||
|
||||
## Display Behavior |
||||
|
||||
### When Verified ✅ |
||||
Shows a green badge with a checkmark icon and the identifier: |
||||
- Regular identifier: `bob@example.com` |
||||
- Root identifier: `_@example.com` displays as `example.com` |
||||
- Tooltip shows relay count when available |
||||
|
||||
### When Not Verified ❌ |
||||
Shows nothing (no badge rendered) |
||||
|
||||
## Cache Behavior |
||||
|
||||
- Verification results are cached for **1 hour** in Redis |
||||
- Cache key format: `nip05_{md5(identifier)}` |
||||
- Failed verifications are also cached to prevent repeated failed requests |
||||
|
||||
## Security Features |
||||
|
||||
- ✅ **No redirect following**: HTTP redirects are blocked per NIP-05 spec |
||||
- ✅ **Timeout protection**: 5-second timeout on HTTP requests |
||||
- ✅ **Format validation**: Strict validation of identifier format |
||||
- ✅ **Hex-only pubkeys**: Rejects npub format keys |
||||
- ✅ **Case-insensitive matching**: Handles case variations properly |
||||
|
||||
## Styling |
||||
|
||||
The component includes built-in styles that can be customized: |
||||
|
||||
```css |
||||
.nip05-badge { |
||||
/* Green badge with checkmark */ |
||||
background-color: rgba(34, 197, 94, 0.1); |
||||
border: 1px solid rgba(34, 197, 94, 0.3); |
||||
color: rgb(22, 163, 74); |
||||
} |
||||
|
||||
.nip05-identifier { |
||||
/* Truncates long identifiers */ |
||||
max-width: 200px; |
||||
text-overflow: ellipsis; |
||||
} |
||||
``` |
||||
|
||||
## Service Layer |
||||
|
||||
The `Nip05VerificationService` can also be used independently: |
||||
|
||||
```php |
||||
use App\Service\Nip05VerificationService; |
||||
|
||||
class MyController |
||||
{ |
||||
public function __construct( |
||||
private Nip05VerificationService $nip05Service |
||||
) {} |
||||
|
||||
public function verifyIdentifier(string $nip05, string $pubkey): void |
||||
{ |
||||
$result = $this->nip05Service->verify($nip05, $pubkey); |
||||
|
||||
if ($result['verified']) { |
||||
// Identifier is verified! |
||||
$relays = $result['relays']; // Available relay URLs |
||||
} |
||||
} |
||||
} |
||||
``` |
||||
|
||||
## Testing |
||||
|
||||
Comprehensive test scenarios are defined in `tests/NIPs/NIP-05.feature` covering: |
||||
- Successful verification flows |
||||
- User discovery |
||||
- Validation rules |
||||
- Security constraints |
||||
- Edge cases and error handling |
||||
|
||||
## Examples |
||||
|
||||
### Valid Identifiers |
||||
- `bob@example.com` |
||||
- `alice_123@example.com` |
||||
- `user-name@example.com` |
||||
- `test.user@example.com` |
||||
- `_@example.com` (root identifier) |
||||
|
||||
### Invalid Identifiers |
||||
- `bob+tag@example.com` (+ not allowed) |
||||
- `bob space@example.com` (spaces not allowed) |
||||
- `bob#hash@example.com` (# not allowed) |
||||
- `npub1...` (not an identifier format) |
||||
|
||||
## Troubleshooting |
||||
|
||||
### Badge not showing |
||||
- Check that both `nip05` and `pubkeyHex` props are provided |
||||
- Verify the pubkey is in **hex format** (64 characters), not npub format |
||||
- Check logs for verification failures |
||||
- Ensure the domain serves `.well-known/nostr.json` with CORS headers |
||||
|
||||
### CORS Issues |
||||
The server must return: |
||||
``` |
||||
Access-Control-Allow-Origin: * |
||||
``` |
||||
|
||||
### Verification failures |
||||
Check application logs for specific error messages: |
||||
- Invalid identifier format |
||||
- Missing names field |
||||
- Pubkey mismatch |
||||
- Invalid hex format |
||||
- Network timeout |
||||
|
||||
## Related Documentation |
||||
|
||||
- [NIP-05 Specification](https://github.com/nostr-protocol/nips/blob/master/05.md) |
||||
- [Test Definitions](../../../tests/NIPs/NIP-05.feature) |
||||
|
||||
@ -0,0 +1,215 @@
@@ -0,0 +1,215 @@
|
||||
<?php |
||||
|
||||
namespace App\Service; |
||||
|
||||
use Psr\Log\LoggerInterface; |
||||
use Symfony\Contracts\Cache\CacheInterface; |
||||
use Symfony\Contracts\Cache\ItemInterface; |
||||
|
||||
readonly class Nip05VerificationService |
||||
{ |
||||
private const int CACHE_TTL = 3600; // 1 hour |
||||
private const int REQUEST_TIMEOUT = 5; // 5 seconds |
||||
|
||||
public function __construct( |
||||
private CacheInterface $redisCache, |
||||
private LoggerInterface $logger |
||||
) { |
||||
} |
||||
|
||||
/** |
||||
* Verify a NIP-05 identifier against a public key |
||||
* |
||||
* @param string $nip05 The NIP-05 identifier (e.g., "bob@example.com") |
||||
* @param string $pubkeyHex The public key in hex format |
||||
* @return array{verified: bool, relays: array<string>} |
||||
*/ |
||||
public function verify(string $nip05, string $pubkeyHex): array |
||||
{ |
||||
// Validate the identifier format |
||||
if (!$this->isValidIdentifier($nip05)) { |
||||
$this->logger->warning('Invalid NIP-05 identifier format', ['nip05' => $nip05]); |
||||
return ['verified' => false, 'relays' => []]; |
||||
} |
||||
|
||||
// Split identifier into local part and domain |
||||
[$localPart, $domain] = $this->splitIdentifier($nip05); |
||||
|
||||
// Create cache key |
||||
$cacheKey = 'nip05_' . md5($nip05); |
||||
|
||||
try { |
||||
return $this->redisCache->get($cacheKey, function (ItemInterface $item) use ($localPart, $domain, $pubkeyHex, $nip05) { |
||||
$item->expiresAfter(self::CACHE_TTL); |
||||
|
||||
$wellKnownUrl = "https://{$domain}/.well-known/nostr.json?name=" . urlencode(strtolower($localPart)); |
||||
|
||||
// Fetch the well-known document |
||||
$result = $this->fetchWellKnown($wellKnownUrl); |
||||
|
||||
if (!$result['success']) { |
||||
return ['verified' => false, 'relays' => []]; |
||||
} |
||||
|
||||
$data = $result['data']; |
||||
|
||||
// Check if names field exists |
||||
if (!isset($data['names'])) { |
||||
$this->logger->warning('Missing names field in well-known response', ['nip05' => $nip05]); |
||||
return ['verified' => false, 'relays' => []]; |
||||
} |
||||
|
||||
// Check if the name exists and matches |
||||
$normalizedLocalPart = strtolower($localPart); |
||||
if (!isset($data['names'][$normalizedLocalPart])) { |
||||
$this->logger->info('Name not found in well-known response', [ |
||||
'nip05' => $nip05, |
||||
'localPart' => $normalizedLocalPart |
||||
]); |
||||
return ['verified' => false, 'relays' => []]; |
||||
} |
||||
|
||||
$returnedPubkey = $data['names'][$normalizedLocalPart]; |
||||
|
||||
// Validate hex format |
||||
if (!$this->isValidHexPubkey($returnedPubkey)) { |
||||
$this->logger->warning('Invalid pubkey format in well-known response', [ |
||||
'nip05' => $nip05, |
||||
'pubkey' => $returnedPubkey |
||||
]); |
||||
return ['verified' => false, 'relays' => []]; |
||||
} |
||||
|
||||
// Check if pubkeys match |
||||
if (strtolower($returnedPubkey) !== strtolower($pubkeyHex)) { |
||||
$this->logger->info('Pubkey mismatch in NIP-05 verification', [ |
||||
'nip05' => $nip05, |
||||
'expected' => $pubkeyHex, |
||||
'received' => $returnedPubkey |
||||
]); |
||||
return ['verified' => false, 'relays' => []]; |
||||
} |
||||
|
||||
// Extract relay information if available |
||||
$relays = []; |
||||
if (isset($data['relays'][$returnedPubkey]) && is_array($data['relays'][$returnedPubkey])) { |
||||
$relays = $data['relays'][$returnedPubkey]; |
||||
} |
||||
|
||||
$this->logger->info('NIP-05 verification successful', [ |
||||
'nip05' => $nip05, |
||||
'pubkey' => $pubkeyHex, |
||||
'relays' => count($relays) |
||||
]); |
||||
|
||||
return ['verified' => true, 'relays' => $relays]; |
||||
}); |
||||
} catch (\Exception $e) { |
||||
$this->logger->error('Error during NIP-05 verification', [ |
||||
'nip05' => $nip05, |
||||
'error' => $e->getMessage() |
||||
]); |
||||
return ['verified' => false, 'relays' => []]; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Fetch and parse the well-known nostr.json document |
||||
*/ |
||||
private function fetchWellKnown(string $url): array |
||||
{ |
||||
$context = stream_context_create([ |
||||
'http' => [ |
||||
'timeout' => self::REQUEST_TIMEOUT, |
||||
'follow_location' => 0, // Do NOT follow redirects (NIP-05 security requirement) |
||||
'ignore_errors' => true, |
||||
'header' => 'Accept: application/json' |
||||
] |
||||
]); |
||||
|
||||
$response = @file_get_contents($url, false, $context); |
||||
|
||||
// Check for redirects in response headers |
||||
if (isset($http_response_header)) { |
||||
foreach ($http_response_header as $header) { |
||||
if (preg_match('/^HTTP\/\d\.\d\s+(301|302|303|307|308)/', $header)) { |
||||
$this->logger->warning('NIP-05 verification rejected due to redirect', ['url' => $url]); |
||||
return ['success' => false, 'data' => null]; |
||||
} |
||||
} |
||||
} |
||||
|
||||
if ($response === false) { |
||||
$this->logger->warning('Failed to fetch well-known document', ['url' => $url]); |
||||
return ['success' => false, 'data' => null]; |
||||
} |
||||
|
||||
$data = json_decode($response, true); |
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) { |
||||
$this->logger->warning('Invalid JSON in well-known response', [ |
||||
'url' => $url, |
||||
'error' => json_last_error_msg() |
||||
]); |
||||
return ['success' => false, 'data' => null]; |
||||
} |
||||
|
||||
return ['success' => true, 'data' => $data]; |
||||
} |
||||
|
||||
/** |
||||
* Validate NIP-05 identifier format |
||||
* Local part must contain only: a-z0-9-_. |
||||
*/ |
||||
private function isValidIdentifier(string $identifier): bool |
||||
{ |
||||
if (!str_contains($identifier, '@')) { |
||||
return false; |
||||
} |
||||
|
||||
[$localPart, $domain] = explode('@', $identifier, 2); |
||||
|
||||
// Validate local part (case-insensitive a-z0-9-_.) |
||||
if (!preg_match('/^[a-zA-Z0-9\-_.]+$/', $localPart)) { |
||||
return false; |
||||
} |
||||
|
||||
// Validate domain (basic check) |
||||
if (empty($domain) || !str_contains($domain, '.')) { |
||||
return false; |
||||
} |
||||
|
||||
return true; |
||||
} |
||||
|
||||
/** |
||||
* Split identifier into local part and domain |
||||
*/ |
||||
private function splitIdentifier(string $identifier): array |
||||
{ |
||||
return explode('@', $identifier, 2); |
||||
} |
||||
|
||||
/** |
||||
* Validate that the pubkey is in hex format (not npub) |
||||
*/ |
||||
private function isValidHexPubkey(string $pubkey): bool |
||||
{ |
||||
// Must be 64 character hex string |
||||
return preg_match('/^[0-9a-fA-F]{64}$/', $pubkey) === 1; |
||||
} |
||||
|
||||
/** |
||||
* Format identifier for display |
||||
* "_@domain.com" should be displayed as "domain.com" |
||||
*/ |
||||
public function formatForDisplay(string $nip05): string |
||||
{ |
||||
if (str_starts_with($nip05, '_@')) { |
||||
return substr($nip05, 2); |
||||
} |
||||
|
||||
return $nip05; |
||||
} |
||||
} |
||||
|
||||
@ -0,0 +1,76 @@
@@ -0,0 +1,76 @@
|
||||
<?php |
||||
|
||||
namespace App\Twig\Components\Atoms; |
||||
|
||||
use App\Service\Nip05VerificationService; |
||||
use Psr\Log\LoggerInterface; |
||||
use swentel\nostr\Key\Key; |
||||
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; |
||||
use Symfony\UX\LiveComponent\Attribute\LiveProp; |
||||
use Symfony\UX\LiveComponent\DefaultActionTrait; |
||||
|
||||
#[AsLiveComponent] |
||||
final class Nip05Badge |
||||
{ |
||||
use DefaultActionTrait; |
||||
|
||||
#[LiveProp] |
||||
public string $nip05; |
||||
|
||||
#[LiveProp] |
||||
public string $npub; |
||||
|
||||
private bool $verified = false; |
||||
private array $relays = []; |
||||
private ?string $displayIdentifier = null; |
||||
|
||||
public function __construct( |
||||
private readonly Nip05VerificationService $nip05Service, |
||||
private readonly LoggerInterface $logger |
||||
) { |
||||
} |
||||
|
||||
public function mount($nip05, $npub): void |
||||
{ |
||||
$this->nip05 = $nip05; |
||||
$this->npub = $npub; |
||||
// Only verify if both nip05 and pubkey are provided |
||||
if ($this->nip05 && $this->npub) { |
||||
$key = new Key(); |
||||
try { |
||||
$result = $this->nip05Service->verify($this->nip05, $key->convertToHex($this->npub)); |
||||
$this->verified = $result['verified']; |
||||
$this->relays = $result['relays']; |
||||
} catch (\Exception $e) { |
||||
$this->logger->error('Error verifying NIP-05 identifier', ['exception' => $e, 'nip05' => $this->nip05, 'npub' => $this->npub]); |
||||
$this->verified = false; |
||||
$this->relays = []; |
||||
} |
||||
|
||||
if ($this->verified) { |
||||
$this->displayIdentifier = $this->nip05Service->formatForDisplay($this->nip05); |
||||
} |
||||
} |
||||
} |
||||
|
||||
public function isVerified(): bool |
||||
{ |
||||
return $this->verified; |
||||
} |
||||
|
||||
public function getDisplayIdentifier(): ?string |
||||
{ |
||||
return $this->displayIdentifier; |
||||
} |
||||
|
||||
public function getRelays(): array |
||||
{ |
||||
return $this->relays; |
||||
} |
||||
|
||||
public function hasRelays(): bool |
||||
{ |
||||
return !empty($this->relays); |
||||
} |
||||
} |
||||
|
||||
@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
|
||||
<div {{ attributes }}> |
||||
{% if this.isVerified %} |
||||
<span class="nip05-badge verified" |
||||
title="Verified NIP-05 identifier{% if this.hasRelays() %} ({{ this.relays|length }} relay{{ this.relays|length > 1 ? 's' : '' }}){% endif %}"> |
||||
<span class="nip05-identifier">{{ this.displayIdentifier }}</span> |
||||
</span> |
||||
{% endif %} |
||||
</div> |
||||
@ -0,0 +1,197 @@
@@ -0,0 +1,197 @@
|
||||
{% extends 'layout.html.twig' %} |
||||
|
||||
{% block body %} |
||||
|
||||
{% if author.image is defined %} |
||||
<img src="{{ author.image }}" class="avatar" alt="{{ author.name }}" onerror="this.style.display = 'none'" /> |
||||
{% endif %} |
||||
|
||||
<h1><twig:Atoms:NameOrNpub :author="author" :npub="npub"></twig:Atoms:NameOrNpub></h1> |
||||
{% if author.nip05 is defined %} |
||||
{% if author.nip05 is iterable %} |
||||
{% for nip05Value in author.nip05 %} |
||||
<twig:Atoms:Nip05Badge |
||||
nip05="{{ nip05Value }}" |
||||
:npub="npub" |
||||
/> |
||||
{% endfor %} |
||||
{% else %} |
||||
<twig:Atoms:Nip05Badge |
||||
nip05="{{ author.nip05 }}" |
||||
:npub="npub" |
||||
/> |
||||
{% endif %} |
||||
{% endif %} |
||||
|
||||
<div class="profile-tabs"> |
||||
<a href="{{ path('author-profile', {'npub': npub}) }}" class="tab-link">Articles</a> |
||||
<a href="{{ path('author-media', {'npub': npub}) }}" class="tab-link">Media</a> |
||||
<a href="{{ path('author-about', {'npub': npub}) }}" class="tab-link active">About</a> |
||||
</div> |
||||
|
||||
<div class="w-container mt-4"> |
||||
<div class="profile-details"> |
||||
<h2>Profile Information</h2> |
||||
|
||||
{% if author.about is defined %} |
||||
<div class="profile-field"> |
||||
<h3>About</h3> |
||||
<div class="profile-value"> |
||||
{{ author.about|markdown_to_html|mentionify|linkify }} |
||||
</div> |
||||
</div> |
||||
{% endif %} |
||||
|
||||
{% if author.banner is defined %} |
||||
<div class="profile-field"> |
||||
<h3>Banner</h3> |
||||
<div class="profile-value"> |
||||
<img src="{{ author.banner }}" alt="Profile banner" style="max-width: 100%; height: auto;" onerror="this.style.display = 'none'" /> |
||||
</div> |
||||
</div> |
||||
{% endif %} |
||||
|
||||
{% if author.website is defined %} |
||||
<div class="profile-field"> |
||||
<h3>Website</h3> |
||||
<div class="profile-value"> |
||||
<a href="{{ author.website }}" target="_blank" rel="noopener noreferrer">{{ author.website }}</a> |
||||
</div> |
||||
</div> |
||||
{% endif %} |
||||
|
||||
{% if author.lud16 is defined %} |
||||
<div class="profile-field"> |
||||
<h3>Lightning Address{{ author.lud16 is iterable and author.lud16|length > 1 ? 'es' : '' }}</h3> |
||||
<div class="profile-value"> |
||||
{% if author.lud16 is iterable %} |
||||
{% for address in author.lud16 %} |
||||
<div class="mb-1"> |
||||
<code>{{ address }}</code> |
||||
</div> |
||||
{% endfor %} |
||||
{% else %} |
||||
<code>{{ author.lud16 }}</code> |
||||
{% endif %} |
||||
</div> |
||||
</div> |
||||
{% endif %} |
||||
|
||||
{% if author.lud06 is defined %} |
||||
<div class="profile-field"> |
||||
<h3>LNURL{{ author.lud06 is iterable and author.lud06|length > 1 ? 's' : '' }}</h3> |
||||
<div class="profile-value"> |
||||
{% if author.lud06 is iterable %} |
||||
{% for lnurl in author.lud06 %} |
||||
<div class="mb-1"> |
||||
<code style="word-break: break-all;">{{ lnurl }}</code> |
||||
</div> |
||||
{% endfor %} |
||||
{% else %} |
||||
<code style="word-break: break-all;">{{ author.lud06 }}</code> |
||||
{% endif %} |
||||
</div> |
||||
</div> |
||||
{% endif %} |
||||
|
||||
<div class="profile-field"> |
||||
<h3>Public Key (hex)</h3> |
||||
<div class="profile-value"> |
||||
<code style="word-break: break-all;">{{ pubkey }}</code> |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="profile-field"> |
||||
<h3>Public Key (npub)</h3> |
||||
<div class="profile-value"> |
||||
<code style="word-break: break-all;">{{ npub }}</code> |
||||
</div> |
||||
</div> |
||||
|
||||
{# Display any additional fields that might be present #} |
||||
{% set standardFields = ['name', 'display_name', 'about', 'picture', 'banner', 'nip05', 'website', 'lud16', 'lud06', 'image'] %} |
||||
{% for key, value in author %} |
||||
{% if key not in standardFields and value is not empty %} |
||||
<div class="profile-field"> |
||||
<h3>{{ key|title }}</h3> |
||||
<div class="profile-value"> |
||||
{% if value starts with 'http://' or value starts with 'https://' %} |
||||
<a href="{{ value }}" target="_blank" rel="noopener noreferrer">{{ value }}</a> |
||||
{% else %} |
||||
{{ value }} |
||||
{% endif %} |
||||
</div> |
||||
</div> |
||||
{% endif %} |
||||
{% endfor %} |
||||
</div> |
||||
|
||||
{# Raw Event Debug Section #} |
||||
<div class="mt-6 p-4 bg-gray-100 rounded"> |
||||
<details> |
||||
<summary class="cursor-pointer font-semibold text-lg mb-2">Raw Profile Event (Debug)</summary> |
||||
<div class="mt-2"> |
||||
<h4 class="font-semibold">Event ID:</h4> |
||||
<pre class="bg-white p-2 rounded overflow-x-auto"><code>{{ rawEvent.id ?? 'N/A' }}</code></pre> |
||||
|
||||
<h4 class="font-semibold mt-3">Created At:</h4> |
||||
<pre class="bg-white p-2 rounded overflow-x-auto"><code>{{ rawEvent.created_at ?? 'N/A' }} ({{ rawEvent.created_at is defined ? rawEvent.created_at|date('Y-m-d H:i:s') : 'N/A' }})</code></pre> |
||||
|
||||
<h4 class="font-semibold mt-3">Tags:</h4> |
||||
<pre class="bg-white p-2 rounded overflow-x-auto"><code>{{ rawEvent.tags is defined ? rawEvent.tags|json_encode(constant('JSON_PRETTY_PRINT')) : '[]' }}</code></pre> |
||||
|
||||
<h4 class="font-semibold mt-3">Content (JSON):</h4> |
||||
<pre class="bg-white p-2 rounded overflow-x-auto"><code>{{ rawEvent.content ?? '{}' }}</code></pre> |
||||
|
||||
<h4 class="font-semibold mt-3">Signature:</h4> |
||||
<pre class="bg-white p-2 rounded overflow-x-auto text-xs"><code>{{ rawEvent.sig ?? 'N/A' }}</code></pre> |
||||
|
||||
<h4 class="font-semibold mt-3">Full Event Object:</h4> |
||||
<pre class="bg-white p-2 rounded overflow-x-auto text-xs"><code>{{ rawEvent|json_encode(constant('JSON_PRETTY_PRINT')) }}</code></pre> |
||||
</div> |
||||
</details> |
||||
</div> |
||||
</div> |
||||
|
||||
<style> |
||||
.profile-details { |
||||
max-width: 800px; |
||||
} |
||||
.profile-field { |
||||
margin-bottom: 1.5rem; |
||||
padding-bottom: 1rem; |
||||
border-bottom: 1px solid #e5e7eb; |
||||
} |
||||
.profile-field:last-child { |
||||
border-bottom: none; |
||||
} |
||||
.profile-field h3 { |
||||
font-size: 0.875rem; |
||||
font-weight: 600; |
||||
color: #6b7280; |
||||
margin-bottom: 0.5rem; |
||||
text-transform: uppercase; |
||||
letter-spacing: 0.05em; |
||||
} |
||||
.profile-value { |
||||
color: #1f2937; |
||||
word-wrap: break-word; |
||||
} |
||||
.profile-value code { |
||||
background-color: #f3f4f6; |
||||
padding: 0.25rem 0.5rem; |
||||
border-radius: 0.25rem; |
||||
font-size: 0.875rem; |
||||
} |
||||
details summary { |
||||
user-select: none; |
||||
} |
||||
details[open] summary { |
||||
margin-bottom: 1rem; |
||||
} |
||||
pre { |
||||
white-space: pre-wrap; |
||||
word-wrap: break-word; |
||||
} |
||||
</style> |
||||
{% endblock %} |
||||
@ -0,0 +1,300 @@
@@ -0,0 +1,300 @@
|
||||
Feature: NIP-05 Mapping Nostr Keys to DNS-based Internet Identifiers |
||||
As a Nostr client |
||||
I want to map Nostr public keys to DNS-based internet identifiers |
||||
So that users can be identified by human-readable email-like addresses |
||||
|
||||
Background: |
||||
Given the newsroom application is running |
||||
And I have a valid Nostr keypair |
||||
|
||||
# ===== Verification Flow ===== |
||||
|
||||
Scenario: Successful verification of NIP-05 identifier |
||||
Given I see a kind 0 event with pubkey "b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9" |
||||
And the event content includes nip05 identifier "bob@example.com" |
||||
When I split the identifier into local part "bob" and domain "example.com" |
||||
And I make a GET request to "https://example.com/.well-known/nostr.json?name=bob" |
||||
And the response contains: |
||||
"""json |
||||
{ |
||||
"names": { |
||||
"bob": "b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9" |
||||
} |
||||
} |
||||
""" |
||||
Then the pubkey should match the one in the names mapping |
||||
And the NIP-05 identifier should be marked as valid |
||||
And the identifier should be displayed to the user |
||||
|
||||
Scenario: Verification with relay information |
||||
Given I see a kind 0 event with pubkey "b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9" |
||||
And the event content includes nip05 identifier "bob@example.com" |
||||
When I make a GET request to "https://example.com/.well-known/nostr.json?name=bob" |
||||
And the response contains relay information: |
||||
"""json |
||||
{ |
||||
"names": { |
||||
"bob": "b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9" |
||||
}, |
||||
"relays": { |
||||
"b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9": [ |
||||
"wss://relay.example.com", |
||||
"wss://relay2.example.com" |
||||
] |
||||
} |
||||
} |
||||
""" |
||||
Then the pubkey should match the one in the names mapping |
||||
And the client should learn the user's preferred relays |
||||
And the relay list should be saved for future connections |
||||
|
||||
# ===== User Discovery ===== |
||||
|
||||
Scenario: Finding a user by NIP-05 identifier |
||||
Given a user wants to find "bob@example.com" |
||||
When I fetch "https://example.com/.well-known/nostr.json?name=bob" |
||||
And the response contains pubkey "b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9" |
||||
Then I should fetch the kind 0 event for that pubkey |
||||
And verify it has a matching nip05 field "bob@example.com" |
||||
And suggest the user profile to the searcher |
||||
|
||||
Scenario: User search box implementation |
||||
Given a search box is available in the client |
||||
When a user types "bob@example.com" |
||||
And the client recognizes the email-like format |
||||
Then the client should perform NIP-05 lookup |
||||
And retrieve the associated pubkey |
||||
And display the user profile in search results |
||||
|
||||
# ===== Validation Rules ===== |
||||
|
||||
Scenario: Valid local-part characters |
||||
Given I have identifiers with various local parts |
||||
When I validate "<identifier>" |
||||
Then it should be "<valid_or_invalid>" |
||||
|
||||
Examples: |
||||
| identifier | valid_or_invalid | |
||||
| bob@example.com | valid | |
||||
| alice_123@example.com | valid | |
||||
| user-name@example.com | valid | |
||||
| test.user@example.com | valid | |
||||
| User@Example.com | valid | |
||||
| bob+tag@example.com | invalid | |
||||
| bob space@example.com | invalid | |
||||
| bob#hash@example.com | invalid | |
||||
|
||||
Scenario: Case-insensitive local-part matching |
||||
Given I have identifier "Bob@Example.com" |
||||
When I make a request to the well-known endpoint |
||||
Then the query should be normalized to lowercase |
||||
And "bob" should match "Bob" in the response |
||||
|
||||
# ===== Public Key Format ===== |
||||
|
||||
Scenario: Public keys must be in hex format |
||||
Given I fetch "https://example.com/.well-known/nostr.json?name=bob" |
||||
When the response contains pubkey "b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9" |
||||
Then the pubkey should be in hex format |
||||
And the pubkey should NOT be in npub format |
||||
And the client should accept it for verification |
||||
|
||||
Scenario: Rejecting npub format in well-known response |
||||
Given I fetch "https://example.com/.well-known/nostr.json?name=bob" |
||||
When the response contains pubkey starting with "npub1" |
||||
Then the client should reject the identifier as invalid |
||||
And display an error about incorrect key format |
||||
|
||||
# ===== Root Domain Identifier ===== |
||||
|
||||
Scenario: Displaying root identifier without redundancy |
||||
Given I see a kind 0 event with nip05 identifier "_@bob.com" |
||||
When the client processes the identifier |
||||
Then it should display as "bob.com" only |
||||
And treat it as the root identifier for the domain |
||||
|
||||
Scenario: Regular identifier display |
||||
Given I see a kind 0 event with nip05 identifier "bob@bob.com" |
||||
When the client processes the identifier |
||||
Then it should display as "bob@bob.com" |
||||
And not apply root identifier special handling |
||||
|
||||
# ===== Multiple NIP-05 Identifiers ===== |
||||
|
||||
Scenario: User with multiple NIP-05 identifiers in tags |
||||
Given I see a kind 0 event with pubkey "b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9" |
||||
And the event has a tag ["nip05", "bob@example.com", "bob@business.com", "bob@personal.org"] |
||||
When the client processes the metadata |
||||
Then all three identifiers should be collected into an array |
||||
And each identifier should be verified independently |
||||
And all verified identifiers should be displayed as badges |
||||
|
||||
Scenario: Displaying multiple verified NIP-05 badges |
||||
Given I see a kind 0 event with multiple nip05 identifiers |
||||
And "bob@example.com" verification succeeds |
||||
And "bob@business.com" verification succeeds |
||||
And "bob@personal.org" verification fails |
||||
When the profile is displayed |
||||
Then I should see a badge for "bob@example.com" |
||||
And I should see a badge for "bob@business.com" |
||||
And I should NOT see a badge for "bob@personal.org" |
||||
|
||||
Scenario: Multiple NIP-05 from tags override content field |
||||
Given I see a kind 0 event with content field nip05 "old@example.com" |
||||
And the event has a tag ["nip05", "bob@example.com", "bob@business.com"] |
||||
When the client processes the metadata |
||||
Then the nip05 field should be ["bob@example.com", "bob@business.com"] |
||||
And "old@example.com" should be ignored |
||||
And tags take priority over content |
||||
|
||||
Scenario: Single NIP-05 in content is converted to array |
||||
Given I see a kind 0 event with only content field nip05 "bob@example.com" |
||||
And the event has no nip05 tags |
||||
When the client processes the metadata |
||||
Then the nip05 field should be converted to array ["bob@example.com"] |
||||
And it should be displayed consistently with multi-value cases |
||||
|
||||
Scenario: Duplicate NIP-05 values are removed |
||||
Given I see a kind 0 event with tag ["nip05", "bob@example.com", "bob@example.com", "bob@business.com"] |
||||
When the client processes the metadata |
||||
Then duplicates should be removed |
||||
And the nip05 field should contain ["bob@example.com", "bob@business.com"] |
||||
And each identifier should only be verified once |
||||
|
||||
Scenario: Multiple lightning addresses in tags |
||||
Given I see a kind 0 event with tag ["lud16", "bob@getalby.com", "bob@primal.net", "bob@wallet.com"] |
||||
When the client processes the metadata |
||||
Then all three addresses should be collected into an array |
||||
And the profile about page should display "Lightning Addresses" (plural) |
||||
And all addresses should be listed |
||||
|
||||
Scenario: Single lightning address displays singular form |
||||
Given I see a kind 0 event with tag ["lud16", "bob@getalby.com"] |
||||
When the client processes the metadata |
||||
Then the lud16 field should be array ["bob@getalby.com"] |
||||
And the profile about page should display "Lightning Address" (singular) |
||||
|
||||
# ===== Following and Key Management ===== |
||||
|
||||
Scenario: Client must follow public keys, not identifiers |
||||
Given I find that "bob@bob.com" has pubkey "abc...def" |
||||
When the user follows this profile |
||||
Then the client should store a reference to pubkey "abc...def" |
||||
And NOT store a primary reference to "bob@bob.com" |
||||
And use the NIP-05 identifier only for display |
||||
|
||||
Scenario: Handling identifier changes |
||||
Given I am following pubkey "abc...def" displayed as "bob@bob.com" |
||||
When "https://bob.com/.well-known/nostr.json?name=bob" starts returning pubkey "1d2...e3f" |
||||
Then the client should continue following "abc...def" |
||||
And stop displaying "bob@bob.com" for that user |
||||
And mark the NIP-05 identifier as invalid |
||||
|
||||
# ===== Verification Failures ===== |
||||
|
||||
Scenario: Pubkey mismatch in verification |
||||
Given I see a kind 0 event with pubkey "abc...def" |
||||
And the event content includes nip05 identifier "bob@example.com" |
||||
When I fetch "https://example.com/.well-known/nostr.json?name=bob" |
||||
And the response contains a different pubkey "xyz...123" |
||||
Then the verification should fail |
||||
And the NIP-05 identifier should not be displayed |
||||
And the user should be shown without verification |
||||
|
||||
Scenario: Well-known endpoint not found |
||||
Given I see a kind 0 event with nip05 identifier "bob@example.com" |
||||
When I fetch "https://example.com/.well-known/nostr.json?name=bob" |
||||
And the response is 404 Not Found |
||||
Then the verification should fail |
||||
And the identifier should be marked as unverified |
||||
|
||||
Scenario: Network timeout during verification |
||||
Given I see a kind 0 event with nip05 identifier "bob@example.com" |
||||
When I fetch "https://example.com/.well-known/nostr.json?name=bob" |
||||
And the request times out |
||||
Then the verification should fail gracefully |
||||
And the client should retry after a reasonable delay |
||||
And display the user without verification in the meantime |
||||
|
||||
# ===== CORS Support ===== |
||||
|
||||
Scenario: Successful CORS-enabled request from JavaScript app |
||||
Given a JavaScript Nostr app is running in a browser |
||||
When I fetch "https://example.com/.well-known/nostr.json?name=bob" |
||||
And the response includes header "Access-Control-Allow-Origin: *" |
||||
Then the JavaScript app should successfully receive the response |
||||
And complete the verification process |
||||
|
||||
Scenario: CORS policy blocking JavaScript request |
||||
Given a JavaScript Nostr app is running in a browser |
||||
When I fetch "https://example.com/.well-known/nostr.json?name=bob" |
||||
And the server does not include CORS headers |
||||
Then the browser should block the request |
||||
And the app should see it as a network failure |
||||
And recommend the user check their server's CORS policy |
||||
|
||||
# ===== Security Constraints ===== |
||||
|
||||
Scenario: Rejecting HTTP redirects |
||||
Given I fetch "https://example.com/.well-known/nostr.json?name=bob" |
||||
When the server responds with a 301 or 302 redirect |
||||
Then the client MUST ignore the redirect |
||||
And the verification should fail |
||||
And the identifier should be marked as invalid |
||||
|
||||
Scenario: Following redirect attempt should be blocked |
||||
Given I fetch "https://example.com/.well-known/nostr.json?name=bob" |
||||
When the server responds with redirect to "https://malicious.com/nostr.json" |
||||
Then the client MUST NOT follow the redirect |
||||
And MUST NOT make a request to the redirected URL |
||||
And treat this as a verification failure |
||||
|
||||
# ===== Dynamic vs Static Server Support ===== |
||||
|
||||
Scenario: Dynamic server with query string support |
||||
Given the server generates JSON on-demand |
||||
When I request "https://example.com/.well-known/nostr.json?name=bob" |
||||
Then the server should return data specific to "bob" |
||||
And optionally include relay information |
||||
When I request "https://example.com/.well-known/nostr.json?name=alice" |
||||
Then the server should return different data for "alice" |
||||
|
||||
Scenario: Static server with multiple names |
||||
Given the server has a static nostr.json file |
||||
When I request "https://example.com/.well-known/nostr.json?name=bob" |
||||
Then the response should contain multiple names: |
||||
"""json |
||||
{ |
||||
"names": { |
||||
"bob": "b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9", |
||||
"alice": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" |
||||
} |
||||
} |
||||
""" |
||||
And the client should extract only the "bob" mapping |
||||
|
||||
# ===== Edge Cases ===== |
||||
|
||||
Scenario: Empty response from well-known endpoint |
||||
Given I fetch "https://example.com/.well-known/nostr.json?name=bob" |
||||
When the response is empty or malformed JSON |
||||
Then the verification should fail |
||||
And log the error for debugging |
||||
|
||||
Scenario: Missing names field in response |
||||
Given I fetch "https://example.com/.well-known/nostr.json?name=bob" |
||||
When the response is valid JSON but missing the "names" field |
||||
Then the verification should fail |
||||
And the identifier should not be displayed |
||||
|
||||
Scenario: Name not found in response |
||||
Given I fetch "https://example.com/.well-known/nostr.json?name=bob" |
||||
When the response contains names but not "bob" |
||||
Then the verification should fail |
||||
And the identifier should be marked as invalid |
||||
|
||||
Scenario: Invalid hex format in response |
||||
Given I fetch "https://example.com/.well-known/nostr.json?name=bob" |
||||
When the response contains pubkey with invalid hex characters |
||||
Then the verification should fail |
||||
And log a format error |
||||
Loading…
Reference in new issue