From 582b0505cb33498bedf108e450142e6f78aee692 Mon Sep 17 00:00:00 2001 From: randogoth Date: Wed, 24 Jun 2026 11:34:35 +0300 Subject: [PATCH] Add NIP-19 (npub/nsec), NIP-05 verification, and arbitrary-pubkey profile fetch - nip19.dart: bech32 encodeNpub/encodeNsec for raw keys (no TLV entities), verified against the canonical NIP-19 test vectors. - nip05.dart: verifyNip05 with an injectable http.Client plus a pure nip05MatchesPubkey helper for the .well-known/nostr.json check. - fetchProfileFor(fetcher, pubkey): fetches and parses any user's kind-0 into a typed NostrProfile (newest-wins), generalizing the own-key FetchProfile. - Export all three from swarm.dart; unit tests for each (bech32 vectors, MockClient HTTP cases, fake-relay profile parsing). Co-Authored-By: Claude Opus 4.8 --- lib/src/nip05.dart | 45 +++++++++++++++ lib/src/nip19.dart | 82 +++++++++++++++++++++++++++ lib/src/nostr_account_actor.dart | 38 +++++++++++++ lib/swarm.dart | 2 + test/nip05_test.dart | 89 ++++++++++++++++++++++++++++++ test/nip19_test.dart | 41 ++++++++++++++ test/nostr_profile_fetch_test.dart | 79 ++++++++++++++++++++++++++ 7 files changed, 376 insertions(+) create mode 100644 lib/src/nip05.dart create mode 100644 lib/src/nip19.dart create mode 100644 test/nip05_test.dart create mode 100644 test/nip19_test.dart create mode 100644 test/nostr_profile_fetch_test.dart diff --git a/lib/src/nip05.dart b/lib/src/nip05.dart new file mode 100644 index 0000000..9a5aa8d --- /dev/null +++ b/lib/src/nip05.dart @@ -0,0 +1,45 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +/// Verifies a NIP-05 identifier (`name@domain`) by fetching the domain's +/// `.well-known/nostr.json?name=` and checking it maps `name` to +/// [pubkeyHex]. Returns false on any malformed input, network error, or +/// mismatch. [client] is injectable for testing. +Future verifyNip05( + String nip05, + String pubkeyHex, { + http.Client? client, +}) async { + final parts = nip05.split('@'); + if (parts.length != 2 || parts[0].isEmpty || parts[1].isEmpty) return false; + final name = parts[0]; + final domain = parts[1]; + + final c = client ?? http.Client(); + try { + final url = Uri.https(domain, '/.well-known/nostr.json', {'name': name}); + final resp = await c.get(url); + if (resp.statusCode != 200) return false; + return nip05MatchesPubkey(resp.body, name, pubkeyHex); + } catch (_) { + return false; + } finally { + if (client == null) c.close(); + } +} + +/// Pure check: does the `.well-known/nostr.json` document [body] map [name] to +/// [pubkeyHex] under its `names` object? Pubkey comparison is case-insensitive. +bool nip05MatchesPubkey(String body, String name, String pubkeyHex) { + try { + final json = jsonDecode(body); + if (json is! Map) return false; + final names = json['names']; + if (names is! Map) return false; + final mapped = names[name]; + return mapped is String && mapped.toLowerCase() == pubkeyHex.toLowerCase(); + } catch (_) { + return false; + } +} diff --git a/lib/src/nip19.dart b/lib/src/nip19.dart new file mode 100644 index 0000000..26294a5 --- /dev/null +++ b/lib/src/nip19.dart @@ -0,0 +1,82 @@ +// NIP-19 bech32 entity encoding for raw keys (`npub` / `nsec`). Pure and +// dependency-free. Only the bare key entities are supported (no TLV types like +// `nprofile`/`nevent`), which is all the app needs for copy/export. + +const _charset = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'; + +int _polymod(List values) { + const gen = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]; + var chk = 1; + for (final v in values) { + final top = chk >> 25; + chk = ((chk & 0x1ffffff) << 5) ^ v; + for (var i = 0; i < 5; i++) { + if (((top >> i) & 1) == 1) chk ^= gen[i]; + } + } + return chk; +} + +List _hrpExpand(String hrp) { + final out = []; + for (final c in hrp.codeUnits) { + out.add(c >> 5); + } + out.add(0); + for (final c in hrp.codeUnits) { + out.add(c & 31); + } + return out; +} + +List _createChecksum(String hrp, List data) { + final values = [..._hrpExpand(hrp), ...data, 0, 0, 0, 0, 0, 0]; + final mod = _polymod(values) ^ 1; + return List.generate(6, (i) => (mod >> (5 * (5 - i))) & 31); +} + +String _bech32Encode(String hrp, List data) { + final combined = [...data, ..._createChecksum(hrp, data)]; + final sb = StringBuffer(hrp)..write('1'); + for (final d in combined) { + sb.write(_charset[d]); + } + return sb.toString(); +} + +/// Regroups 8-bit bytes into 5-bit groups (with zero padding) for bech32. +List _to5Bit(List bytes) { + var acc = 0; + var bits = 0; + final out = []; + for (final b in bytes) { + acc = (acc << 8) | b; + bits += 8; + while (bits >= 5) { + bits -= 5; + out.add((acc >> bits) & 31); + } + } + if (bits > 0) out.add((acc << (5 - bits)) & 31); + return out; +} + +List _hexToBytes(String hex) { + if (hex.length != 64) { + throw ArgumentError( + 'expected a 32-byte key (64 hex chars), got ${hex.length}', + ); + } + return [ + for (var i = 0; i < 64; i += 2) int.parse(hex.substring(i, i + 2), radix: 16), + ]; +} + +String _encodeKey(String hrp, String hex) => + _bech32Encode(hrp, _to5Bit(_hexToBytes(hex))); + +/// Encodes a 32-byte hex public key as a NIP-19 `npub1…` string. +String encodeNpub(String pubkeyHex) => _encodeKey('npub', pubkeyHex); + +/// Encodes a 32-byte hex private key as a NIP-19 `nsec1…` string. +String encodeNsec(String privkeyHex) => _encodeKey('nsec', privkeyHex); diff --git a/lib/src/nostr_account_actor.dart b/lib/src/nostr_account_actor.dart index 0257c6c..50a9daf 100644 --- a/lib/src/nostr_account_actor.dart +++ b/lib/src/nostr_account_actor.dart @@ -57,6 +57,44 @@ class NostrProfile { }; } +/// Fetches and parses the kind-0 profile for an arbitrary [pubkeyHex] via +/// [fetcher], returning the newest one (or null if none is found or it can't be +/// parsed). Unlike [NostrAccountActor]'s [FetchProfile], this works for any +/// user, not just the loaded account. +Future fetchProfileFor( + NostrFetchActor fetcher, + String pubkeyHex, +) async { + final filter = jsonEncode({ + 'kinds': [0], + 'authors': [pubkeyHex], + 'limit': 5, + }); + final result = await fetcher.handle(FetchEvents(filter)); + final events = (result as EventsFetched).events; + + Map? newest; + for (final raw in events) { + try { + final ev = jsonDecode(raw) as Map; + final ts = ev['created_at']; + if (ts is! int) continue; + if (newest == null || ts > (newest['created_at'] as int)) newest = ev; + } catch (_) { + // skip malformed events + } + } + if (newest == null) return null; + + try { + return NostrProfile.fromJson( + jsonDecode(newest['content'] as String) as Map, + ); + } catch (_) { + return null; + } +} + // =============== COMMANDS =============== sealed class AccountMessage { diff --git a/lib/swarm.dart b/lib/swarm.dart index b052d87..7ef71c6 100644 --- a/lib/swarm.dart +++ b/lib/swarm.dart @@ -8,3 +8,5 @@ export 'src/file_upload_actor.dart'; export 'src/file_download_actor.dart'; export 'src/nostr_account_actor.dart'; export 'src/nip46_signer_actor.dart'; +export 'src/nip19.dart'; +export 'src/nip05.dart'; diff --git a/test/nip05_test.dart b/test/nip05_test.dart new file mode 100644 index 0000000..aedb6d1 --- /dev/null +++ b/test/nip05_test.dart @@ -0,0 +1,89 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:swarm/swarm.dart'; +import 'package:test/test.dart'; + +const _pubkey = + '7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e'; + +void main() { + group('nip05MatchesPubkey (pure)', () { + test('true when names maps the name to the pubkey', () { + final body = jsonEncode({ + 'names': {'alice': _pubkey}, + }); + expect(nip05MatchesPubkey(body, 'alice', _pubkey), isTrue); + }); + + test('case-insensitive on the pubkey', () { + final body = jsonEncode({ + 'names': {'alice': _pubkey.toUpperCase()}, + }); + expect(nip05MatchesPubkey(body, 'alice', _pubkey), isTrue); + }); + + test('false on name mismatch, missing name, or junk', () { + final body = jsonEncode({ + 'names': {'alice': _pubkey}, + }); + expect(nip05MatchesPubkey(body, 'bob', _pubkey), isFalse); + expect( + nip05MatchesPubkey(jsonEncode({'names': {}}), 'alice', _pubkey), + isFalse, + ); + expect(nip05MatchesPubkey('not json', 'alice', _pubkey), isFalse); + }); + }); + + group('verifyNip05 (mocked HTTP)', () { + test('verifies a well-formed match and queries the right URL', () async { + late Uri requested; + final client = MockClient((req) async { + requested = req.url; + return http.Response( + jsonEncode({ + 'names': {'alice': _pubkey}, + }), + 200, + ); + }); + + final ok = await verifyNip05('alice@example.com', _pubkey, + client: client); + expect(ok, isTrue); + expect(requested.host, equals('example.com')); + expect(requested.path, equals('/.well-known/nostr.json')); + expect(requested.queryParameters['name'], equals('alice')); + }); + + test('false on name mismatch', () async { + final client = MockClient((req) async => http.Response( + jsonEncode({ + 'names': {'alice': 'deadbeef'}, + }), + 200, + )); + expect( + await verifyNip05('alice@example.com', _pubkey, client: client), + isFalse, + ); + }); + + test('false on non-200', () async { + final client = MockClient((req) async => http.Response('', 404)); + expect( + await verifyNip05('alice@example.com', _pubkey, client: client), + isFalse, + ); + }); + + test('false on malformed identifier', () async { + final client = MockClient((req) async => http.Response('', 200)); + expect(await verifyNip05('no-at-sign', _pubkey, client: client), isFalse); + expect(await verifyNip05('@example.com', _pubkey, client: client), + isFalse); + }); + }); +} diff --git a/test/nip19_test.dart b/test/nip19_test.dart new file mode 100644 index 0000000..3490f85 --- /dev/null +++ b/test/nip19_test.dart @@ -0,0 +1,41 @@ +import 'package:swarm/swarm.dart'; +import 'package:test/test.dart'; + +void main() { + group('NIP-19 encoding', () { + // Canonical test vectors from the NIP-19 specification. + test('encodeNpub matches the spec vector', () { + expect( + encodeNpub( + '7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e', + ), + equals( + 'npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg', + ), + ); + }); + + test('encodeNsec matches the spec vector', () { + expect( + encodeNsec( + '67dea2ed018072d675f5415ecfaed7d2597555e202d85b3d65ea4e58d2d92ffa', + ), + equals( + 'nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5', + ), + ); + }); + + test('npub/nsec carry the right human-readable prefix', () { + const hex = + '0000000000000000000000000000000000000000000000000000000000000001'; + expect(encodeNpub(hex), startsWith('npub1')); + expect(encodeNsec(hex), startsWith('nsec1')); + }); + + test('rejects wrong-length hex', () { + expect(() => encodeNpub('abcd'), throwsArgumentError); + expect(() => encodeNsec(''), throwsArgumentError); + }); + }); +} diff --git a/test/nostr_profile_fetch_test.dart b/test/nostr_profile_fetch_test.dart new file mode 100644 index 0000000..b2fb7bf --- /dev/null +++ b/test/nostr_profile_fetch_test.dart @@ -0,0 +1,79 @@ +import 'dart:convert'; + +import 'package:swarm/swarm.dart'; +import 'package:test/test.dart'; + +import 'fake_relay_connection.dart'; + +Map _kind0(String id, int createdAt, Map profile) => { + 'id': id, + 'pubkey': 'pub', + 'created_at': createdAt, + 'kind': 0, + 'tags': [], + 'content': jsonEncode(profile), + 'sig': 'sig', + }; + +void main() { + group('fetchProfileFor', () { + test('parses the kind-0 content into a NostrProfile (incl. nip05)', () async { + final fake = FakeRelayConnection(); + addTearDown(fake.dispose); + fake.onReq = (subId) { + fake.push(jsonEncode([ + 'EVENT', + subId, + _kind0('e1', 100, { + 'display_name': 'Alice', + 'picture': 'https://cdn/x.jpg', + 'nip05': 'alice@example.com', + }), + ])); + fake.push(jsonEncode(['EOSE', subId])); + }; + final fetcher = NostrFetchActor( + relayUrls: ['ws://relay1'], + connectionFactory: (_) => fake, + ); + + final profile = await fetchProfileFor(fetcher, 'pub'); + + expect(profile, isNotNull); + expect(profile!.displayName, equals('Alice')); + expect(profile.picture, equals('https://cdn/x.jpg')); + expect(profile.nip05, equals('alice@example.com')); + }); + + test('picks the newest profile when several are returned', () async { + final fake = FakeRelayConnection(); + addTearDown(fake.dispose); + fake.onReq = (subId) { + fake.push(jsonEncode( + ['EVENT', subId, _kind0('old', 100, {'display_name': 'Old'})])); + fake.push(jsonEncode( + ['EVENT', subId, _kind0('new', 200, {'display_name': 'New'})])); + fake.push(jsonEncode(['EOSE', subId])); + }; + final fetcher = NostrFetchActor( + relayUrls: ['ws://relay1'], + connectionFactory: (_) => fake, + ); + + final profile = await fetchProfileFor(fetcher, 'pub'); + expect(profile!.displayName, equals('New')); + }); + + test('returns null when no profile exists', () async { + final fake = FakeRelayConnection(); + addTearDown(fake.dispose); + fake.onReq = (subId) => fake.push(jsonEncode(['EOSE', subId])); + final fetcher = NostrFetchActor( + relayUrls: ['ws://relay1'], + connectionFactory: (_) => fake, + ); + + expect(await fetchProfileFor(fetcher, 'pub'), isNull); + }); + }); +}