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

45
lib/src/nip05.dart Normal file
View 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
View 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);

View file

@ -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 {

View file

@ -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';