import 'dart:typed_data'; import 'package:bip340/bip340.dart' as bip340; import 'bech32.dart'; import 'hkdf.dart'; /// A Nostr identity (secp256k1 / BIP340 x-only) deterministically derived from /// the Duniter/Ğ1-style root seed. This is Spike Question 1: prove we can keep /// "one identity" (user backs up ONE seed) while still having a viable Nostr /// transport key. /// /// Derivation is one-way by construction: the root seed feeds HKDF, and HKDF's /// PRF (HMAC-SHA256) is not invertible, so neither the private nor the public /// Nostr key leaks the root seed. See spike-block2-findings.md §1. class NostrKey { NostrKey._({required this.privateKeyHex, required this.publicKeyHex}); /// 32-byte secp256k1 scalar, lower-case hex (64 chars). final String privateKeyHex; /// 32-byte BIP340 x-only public key, lower-case hex (64 chars). This is the /// Nostr identity that appears on events and in `npub`. final String publicKeyHex; /// NIP-19 `npub…` encoding of [publicKeyHex] — what a user would share. String get npub => bech32Encode('npub', _hexToBytes(publicKeyHex)); /// NIP-19 `nsec…` encoding of [privateKeyHex] — secret, never shared. String get nsec => bech32Encode('nsec', _hexToBytes(privateKeyHex)); /// Domain-separated derivation label. Versioned so a future scheme can coexist /// with old backups (same discipline as BackupBox's HKDF info string). static const derivationInfo = 'org.comunes.tane/nostr/secp256k1/v1'; /// secp256k1 group order n. static final BigInt _n = BigInt.parse( 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141', radix: 16, ); /// Deterministically derives the Nostr key from a root [seed] (the 32 bytes /// [IdentityService.generateRootSeed] produces). /// /// Reduces to a valid scalar in `[1, n-1]` by rejection: on the (astronomically /// rare) miss it bumps a counter in the HKDF info, so the result stays a pure /// function of the seed. factory NostrKey.deriveFromSeed(List seed) { var counter = 0; while (true) { final okm = hkdfSha256(ikm: seed, info: '$derivationInfo/$counter'); final d = _bytesToBigInt(okm); if (d != BigInt.zero && d < _n) { final privHex = d.toRadixString(16).padLeft(64, '0'); final pubHex = bip340.getPublicKey(privHex); return NostrKey._(privateKeyHex: privHex, publicKeyHex: pubHex); } counter++; } } } BigInt _bytesToBigInt(List bytes) { var result = BigInt.zero; for (final b in bytes) { result = (result << 8) | BigInt.from(b); } return result; } Uint8List _hexToBytes(String hex) { final out = Uint8List(hex.length ~/ 2); for (var i = 0; i < out.length; i++) { out[i] = int.parse(hex.substring(i * 2, i * 2 + 2), radix: 16); } return out; }