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 <noreply@anthropic.com>
This commit is contained in:
randogoth 2026-06-24 11:34:35 +03:00
parent 719aaca022
commit 582b0505cb
7 changed files with 376 additions and 0 deletions

89
test/nip05_test.dart Normal file
View file

@ -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);
});
});
}

41
test/nip19_test.dart Normal file
View file

@ -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);
});
});
}

View file

@ -0,0 +1,79 @@
import 'dart:convert';
import 'package:swarm/swarm.dart';
import 'package:test/test.dart';
import 'fake_relay_connection.dart';
Map<String, dynamic> _kind0(String id, int createdAt, Map<String, dynamic> 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);
});
});
}