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:
parent
719aaca022
commit
582b0505cb
7 changed files with 376 additions and 0 deletions
45
lib/src/nip05.dart
Normal file
45
lib/src/nip05.dart
Normal file
|
|
@ -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=<name>` and checking it maps `name` to
|
||||
/// [pubkeyHex]. Returns false on any malformed input, network error, or
|
||||
/// mismatch. [client] is injectable for testing.
|
||||
Future<bool> 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;
|
||||
}
|
||||
}
|
||||
82
lib/src/nip19.dart
Normal file
82
lib/src/nip19.dart
Normal file
|
|
@ -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<int> 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<int> _hrpExpand(String hrp) {
|
||||
final out = <int>[];
|
||||
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<int> _createChecksum(String hrp, List<int> 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<int> 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<int> _to5Bit(List<int> bytes) {
|
||||
var acc = 0;
|
||||
var bits = 0;
|
||||
final out = <int>[];
|
||||
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<int> _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);
|
||||
|
|
@ -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<NostrProfile?> 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<String, dynamic>? newest;
|
||||
for (final raw in events) {
|
||||
try {
|
||||
final ev = jsonDecode(raw) as Map<String, dynamic>;
|
||||
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<String, dynamic>,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// =============== COMMANDS ===============
|
||||
|
||||
sealed class AccountMessage {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
89
test/nip05_test.dart
Normal file
89
test/nip05_test.dart
Normal 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
41
test/nip19_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
79
test/nostr_profile_fetch_test.dart
Normal file
79
test/nostr_profile_fetch_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue