diff --git a/README.md b/README.md index ae5c6b0..69c6edf 100644 --- a/README.md +++ b/README.md @@ -265,3 +265,74 @@ final result = await actor.handle( final url = (result as FileUploaded).cdnUrl; // https://cdn.otherwhere.app///.jpg ``` + +--- + +## NostrAccountActor + +Manages a Nostr user identity: keypair lifecycle, BIP-39 mnemonic support (NIP-06 +derivation), and kind-0 profile fetch/publish via injected helper actors. + +```dart +final actor = NostrAccountActor( + fetcher: NostrFetchActor(relayUrls: ['wss://relay.example.com']), + publisher: NostrPublishActor(relayUrls: ['wss://relay.example.com']), +); +``` + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `fetcher` | `NostrFetchActor?` | null | Required for `FetchProfile` | +| `publisher` | `NostrPublishActor?` | null | Required for `UpdateProfile` | + +Both helpers are optional at construction; passing neither is valid when only using +key management messages. + +| Message | Result | Description | +|---|---|---| +| `CreateAccount()` | `AccountCreated(publicKeyHex, mnemonic)` | Generates a new keypair from 128-bit entropy via BIP-39 + NIP-06 derivation | +| `ImportAccount(privateKeyHex)` | `AccountImported(publicKeyHex)` | Loads an existing raw hex private key. `ExportMnemonic` returns null afterwards. | +| `ImportMnemonic(mnemonic)` | `AccountImported(publicKeyHex)` | Derives keypair from a 12-word BIP-39 mnemonic via NIP-06 path `m/44'/1237'/0'/0/0` | +| `GetAccountPublicKey()` | `AccountPublicKeyResult(publicKeyHex)` | Returns the loaded account's public key | +| `ExportPrivateKey()` | `PrivateKeyExported(privateKeyHex)` | Returns the raw private key | +| `ExportMnemonic()` | `MnemonicExported(mnemonic?)` | Returns the BIP-39 mnemonic, or null if the account was imported via raw private key | +| `FetchProfile()` | `ProfileFetched(profile?)` | Fetches kind-0 from relay; caches result for `GetPublicData` | +| `UpdateProfile(profile)` | `ProfileUpdated()` | Publishes a kind-0 event; caches profile for `GetPublicData` | +| `GetPublicData()` | `PublicData(data)` | Returns a `Map` with `pubkey` plus any cached profile fields | + +**NostrProfile** fields (all optional): `name`, `displayName`, `about`, `picture`, +`banner`, `website`, `nip05`, `lud16`. + +All messages except Create/Import throw `StateError` if called before the account is +loaded. `FetchProfile` throws if no `fetcher` was provided; `UpdateProfile` throws if +no `publisher` was provided. + +```dart +final fetcher = NostrFetchActor(relayUrls: ['wss://relay.otherwhere.app/']); +final publisher = NostrPublishActor(relayUrls: ['wss://relay.otherwhere.app/']); +final actor = NostrAccountActor(fetcher: fetcher, publisher: publisher); + +// New user +final created = await actor.handle(const CreateAccount()) as AccountCreated; +print(created.mnemonic); // 12-word backup phrase +print(created.publicKeyHex); // hex pubkey + +// Returning user — restore from mnemonic +await actor.handle(ImportMnemonic(savedMnemonic)); + +// Or restore from raw key +await actor.handle(ImportAccount(savedPrivkeyHex)); + +// Update profile +await actor.handle( + UpdateProfile(const NostrProfile(name: 'alice', nip05: 'alice@example.com')), +); + +// Fetch latest profile from relay +final fetched = await actor.handle(const FetchProfile()) as ProfileFetched; +print(fetched.profile?.name); + +// Public-facing data (pubkey + cached profile) +final pub = await actor.handle(const GetPublicData()) as PublicData; +print(pub.data); // {'pubkey': '...', 'name': 'alice', 'nip05': 'alice@example.com'} +``` diff --git a/lib/src/bip39.dart b/lib/src/bip39.dart new file mode 100644 index 0000000..8cd87e5 --- /dev/null +++ b/lib/src/bip39.dart @@ -0,0 +1,181 @@ +import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; +import 'package:pointycastle/export.dart'; + +import 'bip39_wordlist.dart'; + +final _secp256k1 = ECCurve_secp256k1(); + +/// Generates a new 12-word BIP-39 mnemonic from 128 bits of secure entropy. +String generateMnemonic() { + final rng = Random.secure(); + final entropy = + Uint8List.fromList(List.generate(16, (_) => rng.nextInt(256))); + return _entropyToMnemonic(entropy); +} + +/// Returns true if [mnemonic] is a valid BIP-39 mnemonic with correct checksum. +bool validateMnemonic(String mnemonic) { + final words = mnemonic.trim().split(RegExp(r'\s+')); + if (words.length != 12) return false; + final indices = []; + for (final word in words) { + final i = kBip39Words.indexOf(word); + if (i == -1) return false; + indices.add(i); + } + // 12 × 11 bits = 132 bits = 128 entropy + 4 checksum + final bits = + indices.map((i) => i.toRadixString(2).padLeft(11, '0')).join(); + final entropyBits = bits.substring(0, 128); + final checksumBits = bits.substring(128); + final entropy = Uint8List(16); + for (var i = 0; i < 16; i++) { + entropy[i] = + int.parse(entropyBits.substring(i * 8, i * 8 + 8), radix: 2); + } + final hash = sha256.convert(entropy).bytes; + final expected = + hash[0].toRadixString(2).padLeft(8, '0').substring(0, 4); + return checksumBits == expected; +} + +/// Derives a 64-byte seed from [mnemonic] using PBKDF2-HMAC-SHA512 +/// (2048 iterations, salt = "mnemonic" + passphrase). +Uint8List mnemonicToSeed(String mnemonic, {String passphrase = ''}) { + final password = utf8.encode(mnemonic.trim()); + final salt = utf8.encode('mnemonic$passphrase'); + return _pbkdf2HmacSha512(password, salt, 2048, 64); +} + +/// Derives a secp256k1 private key from [seed] using NIP-06 BIP-32 path +/// m/44'/1237'/0'/0/0. +Uint8List deriveNostrPrivkey(Uint8List seed) { + var (key, chain) = _masterKey(seed); + for (final index in [ + 0x8000002C, // 44' + 0x800004D5, // 1237' + 0x80000000, // 0' + 0, // 0 + 0, // 0 + ]) { + (key, chain) = _childKey(key, chain, index); + } + return key; +} + +// --------------------------------------------------------------------------- + +String _entropyToMnemonic(Uint8List entropy) { + final hash = sha256.convert(entropy).bytes; + final entropyBits = + entropy.map((b) => b.toRadixString(2).padLeft(8, '0')).join(); + final checksumBits = + hash[0].toRadixString(2).padLeft(8, '0').substring(0, 4); + final allBits = entropyBits + checksumBits; // 132 bits + final words = []; + for (var i = 0; i < 12; i++) { + final index = + int.parse(allBits.substring(i * 11, i * 11 + 11), radix: 2); + words.add(kBip39Words[index]); + } + return words.join(' '); +} + +(Uint8List, Uint8List) _masterKey(Uint8List seed) { + final hmac = Hmac(sha512, utf8.encode('Bitcoin seed')); + final digest = hmac.convert(seed).bytes; + return ( + Uint8List.fromList(digest.sublist(0, 32)), + Uint8List.fromList(digest.sublist(32)), + ); +} + +(Uint8List, Uint8List) _childKey( + Uint8List parentKey, + Uint8List parentChain, + int index, +) { + final List data; + if (index >= 0x80000000) { + // hardened: 0x00 || parent_key || index + data = [0x00, ...parentKey, ..._int32ToBytes(index)]; + } else { + // normal: compressed_pubkey(parent_key) || index + data = [..._compressedPubkey(parentKey), ..._int32ToBytes(index)]; + } + final hmac = Hmac(sha512, parentChain); + final digest = hmac.convert(data).bytes; + final il = Uint8List.fromList(digest.sublist(0, 32)); + final ir = Uint8List.fromList(digest.sublist(32)); + // child_key = (il + parent_key) mod n + final n = _secp256k1.n; + final child = (_bytesToBigInt(il) + _bytesToBigInt(parentKey)) % n; + return (_bigIntToBytes32(child), ir); +} + +Uint8List _compressedPubkey(Uint8List privBytes) { + final k = _bytesToBigInt(privBytes); + final point = (_secp256k1.G * k)!; + final x = point.x!.toBigInteger()!; + final y = point.y!.toBigInteger()!; + return Uint8List.fromList([y.isOdd ? 0x03 : 0x02, ..._bigIntToBytes32(x)]); +} + +Uint8List _pbkdf2HmacSha512( + List password, + List salt, + int iterations, + int keyLen, +) { + final hmac = Hmac(sha512, password); + final blockCount = (keyLen / 64).ceil(); + final result = []; + for (var block = 1; block <= blockCount; block++) { + final saltBlock = [ + ...salt, + (block >> 24) & 0xff, + (block >> 16) & 0xff, + (block >> 8) & 0xff, + block & 0xff, + ]; + List u = hmac.convert(saltBlock).bytes; + final xor = List.from(u); + for (var iter = 1; iter < iterations; iter++) { + u = hmac.convert(u).bytes; + for (var j = 0; j < xor.length; j++) { + xor[j] ^= u[j]; + } + } + result.addAll(xor); + } + return Uint8List.fromList(result.sublist(0, keyLen)); +} + +BigInt _bytesToBigInt(Uint8List bytes) { + var result = BigInt.zero; + for (final b in bytes) { + result = (result << 8) | BigInt.from(b); + } + return result; +} + +Uint8List _bigIntToBytes32(BigInt value) { + final bytes = Uint8List(32); + var v = value; + for (var i = 31; i >= 0; i--) { + bytes[i] = (v & BigInt.from(0xff)).toInt(); + v >>= 8; + } + return bytes; +} + +List _int32ToBytes(int i) => [ + (i >> 24) & 0xff, + (i >> 16) & 0xff, + (i >> 8) & 0xff, + i & 0xff, + ]; diff --git a/lib/src/bip39_wordlist.dart b/lib/src/bip39_wordlist.dart new file mode 100644 index 0000000..b5a6ca2 --- /dev/null +++ b/lib/src/bip39_wordlist.dart @@ -0,0 +1,260 @@ +// BIP-39 English wordlist — 2048 words, alphabetically sorted. +// Source: https://github.com/bitcoin/bips/blob/master/bip-0039/english.txt +const List kBip39Words = [ + 'abandon', 'ability', 'able', 'about', 'above', 'absent', 'absorb', 'abstract', + 'absurd', 'abuse', 'access', 'accident', 'account', 'accuse', 'achieve', 'acid', + 'acoustic', 'acquire', 'across', 'act', 'action', 'actor', 'actress', 'actual', + 'adapt', 'add', 'addict', 'address', 'adjust', 'admit', 'adult', 'advance', + 'advice', 'aerobic', 'affair', 'afford', 'afraid', 'again', 'age', 'agent', + 'agree', 'ahead', 'aim', 'air', 'airport', 'aisle', 'alarm', 'album', + 'alcohol', 'alert', 'alien', 'all', 'alley', 'allow', 'almost', 'alone', + 'alpha', 'already', 'also', 'alter', 'always', 'amateur', 'amazing', 'among', + 'amount', 'amused', 'analyst', 'anchor', 'ancient', 'anger', 'angle', 'angry', + 'animal', 'ankle', 'announce', 'annual', 'another', 'answer', 'antenna', 'antique', + 'anxiety', 'any', 'apart', 'apology', 'appear', 'apple', 'approve', 'april', + 'arch', 'arctic', 'area', 'arena', 'argue', 'arm', 'armed', 'armor', + 'army', 'around', 'arrange', 'arrest', 'arrive', 'arrow', 'art', 'artefact', + 'artist', 'artwork', 'ask', 'aspect', 'assault', 'asset', 'assist', 'assume', + 'asthma', 'athlete', 'atom', 'attack', 'attend', 'attitude', 'attract', 'auction', + 'audit', 'august', 'aunt', 'author', 'auto', 'autumn', 'average', 'avocado', + 'avoid', 'awake', 'aware', 'away', 'awesome', 'awful', 'awkward', 'axis', + 'baby', 'bachelor', 'bacon', 'badge', 'bag', 'balance', 'balcony', 'ball', + 'bamboo', 'banana', 'banner', 'bar', 'barely', 'bargain', 'barrel', 'base', + 'basic', 'basket', 'battle', 'beach', 'bean', 'beauty', 'because', 'become', + 'beef', 'before', 'begin', 'behave', 'behind', 'believe', 'below', 'belt', + 'bench', 'benefit', 'best', 'betray', 'better', 'between', 'beyond', 'bicycle', + 'bid', 'bike', 'bind', 'biology', 'bird', 'birth', 'bitter', 'black', + 'blade', 'blame', 'blanket', 'blast', 'bleak', 'bless', 'blind', 'blood', + 'blossom', 'blouse', 'blue', 'blur', 'blush', 'board', 'boat', 'body', + 'boil', 'bomb', 'bone', 'bonus', 'book', 'boost', 'border', 'boring', + 'borrow', 'boss', 'bottom', 'bounce', 'box', 'boy', 'bracket', 'brain', + 'brand', 'brass', 'brave', 'bread', 'breeze', 'brick', 'bridge', 'brief', + 'bright', 'bring', 'brisk', 'broccoli', 'broken', 'bronze', 'broom', 'brother', + 'brown', 'brush', 'bubble', 'buddy', 'budget', 'buffalo', 'build', 'bulb', + 'bulk', 'bullet', 'bundle', 'bunker', 'burden', 'burger', 'burst', 'bus', + 'business', 'busy', 'butter', 'buyer', 'buzz', 'cabbage', 'cabin', 'cable', + 'cactus', 'cage', 'cake', 'call', 'calm', 'camera', 'camp', 'can', + 'canal', 'cancel', 'candy', 'cannon', 'canoe', 'canvas', 'canyon', 'capable', + 'capital', 'captain', 'car', 'carbon', 'card', 'cargo', 'carpet', 'carry', + 'cart', 'case', 'cash', 'casino', 'castle', 'casual', 'cat', 'catalog', + 'catch', 'category', 'cattle', 'caught', 'cause', 'caution', 'cave', 'ceiling', + 'celery', 'cement', 'census', 'century', 'cereal', 'certain', 'chair', 'chalk', + 'champion', 'change', 'chaos', 'chapter', 'charge', 'chase', 'chat', 'cheap', + 'check', 'cheese', 'chef', 'cherry', 'chest', 'chicken', 'chief', 'child', + 'chimney', 'choice', 'choose', 'chronic', 'chuckle', 'chunk', 'churn', 'cigar', + 'cinnamon', 'circle', 'citizen', 'city', 'civil', 'claim', 'clap', 'clarify', + 'claw', 'clay', 'clean', 'clerk', 'clever', 'click', 'client', 'cliff', + 'climb', 'clinic', 'clip', 'clock', 'clog', 'close', 'cloth', 'cloud', + 'clown', 'club', 'clump', 'cluster', 'clutch', 'coach', 'coast', 'coconut', + 'code', 'coffee', 'coil', 'coin', 'collect', 'color', 'column', 'combine', + 'come', 'comfort', 'comic', 'common', 'company', 'concert', 'conduct', 'confirm', + 'congress', 'connect', 'consider', 'control', 'convince', 'cook', 'cool', 'copper', + 'copy', 'coral', 'core', 'corn', 'correct', 'cost', 'cotton', 'couch', + 'country', 'couple', 'course', 'cousin', 'cover', 'coyote', 'crack', 'cradle', + 'craft', 'cram', 'crane', 'crash', 'crater', 'crawl', 'crazy', 'cream', + 'credit', 'creek', 'crew', 'cricket', 'crime', 'crisp', 'critic', 'crop', + 'cross', 'crouch', 'crowd', 'crucial', 'cruel', 'cruise', 'crumble', 'crunch', + 'crush', 'cry', 'crystal', 'cube', 'culture', 'cup', 'cupboard', 'curious', + 'current', 'curtain', 'curve', 'cushion', 'custom', 'cute', 'cycle', 'dad', + 'damage', 'damp', 'dance', 'danger', 'daring', 'dash', 'daughter', 'dawn', + 'day', 'deal', 'debate', 'debris', 'decade', 'december', 'decide', 'decline', + 'decorate', 'decrease', 'deer', 'defense', 'define', 'defy', 'degree', 'delay', + 'deliver', 'demand', 'demise', 'denial', 'dentist', 'deny', 'depart', 'depend', + 'deposit', 'depth', 'deputy', 'derive', 'describe', 'desert', 'design', 'desk', + 'despair', 'destroy', 'detail', 'detect', 'develop', 'device', 'devote', 'diagram', + 'dial', 'diamond', 'diary', 'dice', 'diesel', 'diet', 'differ', 'digital', + 'dignity', 'dilemma', 'dinner', 'dinosaur', 'direct', 'dirt', 'disagree', 'discover', + 'disease', 'dish', 'dismiss', 'disorder', 'display', 'distance', 'divert', 'divide', + 'divorce', 'dizzy', 'doctor', 'document', 'dog', 'doll', 'dolphin', 'domain', + 'donate', 'donkey', 'donor', 'door', 'dose', 'double', 'dove', 'draft', + 'dragon', 'drama', 'drastic', 'draw', 'dream', 'dress', 'drift', 'drill', + 'drink', 'drip', 'drive', 'drop', 'drum', 'dry', 'duck', 'dumb', + 'dune', 'during', 'dust', 'dutch', 'duty', 'dwarf', 'dynamic', 'eager', + 'eagle', 'early', 'earn', 'earth', 'easily', 'east', 'easy', 'echo', + 'ecology', 'economy', 'edge', 'edit', 'educate', 'effort', 'egg', 'eight', + 'either', 'elbow', 'elder', 'electric', 'elegant', 'element', 'elephant', 'elevator', + 'elite', 'else', 'embark', 'embody', 'embrace', 'emerge', 'emotion', 'employ', + 'empower', 'empty', 'enable', 'enact', 'end', 'endless', 'endorse', 'enemy', + 'energy', 'enforce', 'engage', 'engine', 'enhance', 'enjoy', 'enlist', 'enough', + 'enrich', 'enroll', 'ensure', 'enter', 'entire', 'entry', 'envelope', 'episode', + 'equal', 'equip', 'era', 'erase', 'erode', 'erosion', 'error', 'erupt', + 'escape', 'essay', 'essence', 'estate', 'eternal', 'ethics', 'evidence', 'evil', + 'evoke', 'evolve', 'exact', 'example', 'excess', 'exchange', 'excite', 'exclude', + 'excuse', 'execute', 'exercise', 'exhaust', 'exhibit', 'exile', 'exist', 'exit', + 'exotic', 'expand', 'expect', 'expire', 'explain', 'expose', 'express', 'extend', + 'extra', 'eye', 'eyebrow', 'fabric', 'face', 'faculty', 'fade', 'faint', + 'faith', 'fall', 'false', 'fame', 'family', 'famous', 'fan', 'fancy', + 'fantasy', 'farm', 'fashion', 'fat', 'fatal', 'father', 'fatigue', 'fault', + 'favorite', 'feature', 'february', 'federal', 'fee', 'feed', 'feel', 'female', + 'fence', 'festival', 'fetch', 'fever', 'few', 'fiber', 'fiction', 'field', + 'figure', 'file', 'film', 'filter', 'final', 'find', 'fine', 'finger', + 'finish', 'fire', 'firm', 'first', 'fiscal', 'fish', 'fit', 'fitness', + 'fix', 'flag', 'flame', 'flash', 'flat', 'flavor', 'flee', 'flight', + 'flip', 'float', 'flock', 'floor', 'flower', 'fluid', 'flush', 'fly', + 'foam', 'focus', 'fog', 'foil', 'fold', 'follow', 'food', 'foot', + 'force', 'forest', 'forget', 'fork', 'fortune', 'forum', 'forward', 'fossil', + 'foster', 'found', 'fox', 'fragile', 'frame', 'frequent', 'fresh', 'friend', + 'fringe', 'frog', 'front', 'frost', 'frown', 'frozen', 'fruit', 'fuel', + 'fun', 'funny', 'furnace', 'fury', 'future', 'gadget', 'gain', 'galaxy', + 'gallery', 'game', 'gap', 'garage', 'garbage', 'garden', 'garlic', 'garment', + 'gas', 'gasp', 'gate', 'gather', 'gauge', 'gaze', 'general', 'genius', + 'genre', 'gentle', 'genuine', 'gesture', 'ghost', 'giant', 'gift', 'giggle', + 'ginger', 'giraffe', 'girl', 'give', 'glad', 'glance', 'glare', 'glass', + 'glide', 'glimpse', 'globe', 'gloom', 'glory', 'glove', 'glow', 'glue', + 'goat', 'goddess', 'gold', 'good', 'goose', 'gorilla', 'gospel', 'gossip', + 'govern', 'gown', 'grab', 'grace', 'grain', 'grant', 'grape', 'grass', + 'gravity', 'great', 'green', 'grid', 'grief', 'grit', 'grocery', 'group', + 'grow', 'grunt', 'guard', 'guess', 'guide', 'guilt', 'guitar', 'gun', + 'gym', 'habit', 'hair', 'half', 'hammer', 'hamster', 'hand', 'happy', + 'harbor', 'hard', 'harsh', 'harvest', 'hat', 'have', 'hawk', 'hazard', + 'head', 'health', 'heart', 'heavy', 'hedgehog', 'height', 'hello', 'helmet', + 'help', 'hen', 'hero', 'hidden', 'high', 'hill', 'hint', 'hip', + 'hire', 'history', 'hobby', 'hockey', 'hold', 'hole', 'holiday', 'hollow', + 'home', 'honey', 'hood', 'hope', 'horn', 'horror', 'horse', 'hospital', + 'host', 'hotel', 'hour', 'hover', 'hub', 'huge', 'human', 'humble', + 'humor', 'hundred', 'hungry', 'hunt', 'hurdle', 'hurry', 'hurt', 'husband', + 'hybrid', 'ice', 'icon', 'idea', 'identify', 'idle', 'ignore', 'ill', + 'illegal', 'illness', 'image', 'imitate', 'immense', 'immune', 'impact', 'impose', + 'improve', 'impulse', 'inch', 'include', 'income', 'increase', 'index', 'indicate', + 'indoor', 'industry', 'infant', 'inflict', 'inform', 'inhale', 'inherit', 'initial', + 'inject', 'injury', 'inmate', 'inner', 'innocent', 'input', 'inquiry', 'insane', + 'insect', 'inside', 'inspire', 'install', 'intact', 'interest', 'into', 'invest', + 'invite', 'involve', 'iron', 'island', 'isolate', 'issue', 'item', 'ivory', + 'jacket', 'jaguar', 'jar', 'jazz', 'jealous', 'jeans', 'jelly', 'jewel', + 'job', 'join', 'joke', 'journey', 'joy', 'judge', 'juice', 'jump', + 'jungle', 'junior', 'junk', 'just', 'kangaroo', 'keen', 'keep', 'ketchup', + 'key', 'kick', 'kid', 'kidney', 'kind', 'kingdom', 'kiss', 'kit', + 'kitchen', 'kite', 'kitten', 'kiwi', 'knee', 'knife', 'knock', 'know', + 'lab', 'label', 'labor', 'ladder', 'lady', 'lake', 'lamp', 'language', + 'laptop', 'large', 'later', 'latin', 'laugh', 'laundry', 'lava', 'law', + 'lawn', 'lawsuit', 'layer', 'lazy', 'leader', 'leaf', 'learn', 'leave', + 'lecture', 'left', 'leg', 'legal', 'legend', 'leisure', 'lemon', 'lend', + 'length', 'lens', 'leopard', 'lesson', 'letter', 'level', 'liar', 'liberty', + 'library', 'license', 'life', 'lift', 'light', 'like', 'limb', 'limit', + 'link', 'lion', 'liquid', 'list', 'little', 'live', 'lizard', 'load', + 'loan', 'lobster', 'local', 'lock', 'logic', 'lonely', 'long', 'loop', + 'lottery', 'loud', 'lounge', 'love', 'loyal', 'lucky', 'luggage', 'lumber', + 'lunar', 'lunch', 'luxury', 'lyrics', 'machine', 'mad', 'magic', 'magnet', + 'maid', 'mail', 'main', 'major', 'make', 'mammal', 'man', 'manage', + 'mandate', 'mango', 'mansion', 'manual', 'maple', 'marble', 'march', 'margin', + 'marine', 'market', 'marriage', 'mask', 'mass', 'master', 'match', 'material', + 'math', 'matrix', 'matter', 'maximum', 'maze', 'meadow', 'mean', 'measure', + 'meat', 'mechanic', 'medal', 'media', 'melody', 'melt', 'member', 'memory', + 'mention', 'menu', 'mercy', 'merge', 'merit', 'merry', 'mesh', 'message', + 'metal', 'method', 'middle', 'midnight', 'milk', 'million', 'mimic', 'mind', + 'minimum', 'minor', 'minute', 'miracle', 'mirror', 'misery', 'miss', 'mistake', + 'mix', 'mixed', 'mixture', 'mobile', 'model', 'modify', 'mom', 'moment', + 'monitor', 'monkey', 'monster', 'month', 'moon', 'moral', 'more', 'morning', + 'mosquito', 'mother', 'motion', 'motor', 'mountain', 'mouse', 'move', 'movie', + 'much', 'muffin', 'mule', 'multiply', 'muscle', 'museum', 'mushroom', 'music', + 'must', 'mutual', 'myself', 'mystery', 'myth', 'naive', 'name', 'napkin', + 'narrow', 'nasty', 'nation', 'nature', 'near', 'neck', 'need', 'negative', + 'neglect', 'neither', 'nephew', 'nerve', 'nest', 'net', 'network', 'neutral', + 'never', 'news', 'next', 'nice', 'night', 'noble', 'noise', 'nominee', + 'noodle', 'normal', 'north', 'nose', 'notable', 'note', 'nothing', 'notice', + 'novel', 'now', 'nuclear', 'number', 'nurse', 'nut', 'oak', 'obey', + 'object', 'oblige', 'obscure', 'observe', 'obtain', 'obvious', 'occur', 'ocean', + 'october', 'odor', 'off', 'offer', 'office', 'often', 'oil', 'okay', + 'old', 'olive', 'olympic', 'omit', 'once', 'one', 'onion', 'online', + 'only', 'open', 'opera', 'opinion', 'oppose', 'option', 'orange', 'orbit', + 'orchard', 'order', 'ordinary', 'organ', 'orient', 'original', 'orphan', 'ostrich', + 'other', 'outdoor', 'outer', 'output', 'outside', 'oval', 'oven', 'over', + 'own', 'owner', 'oxygen', 'oyster', 'ozone', 'pact', 'paddle', 'page', + 'pair', 'palace', 'palm', 'panda', 'panel', 'panic', 'panther', 'paper', + 'parade', 'parent', 'park', 'parrot', 'party', 'pass', 'patch', 'path', + 'patient', 'patrol', 'pattern', 'pause', 'pave', 'payment', 'peace', 'peanut', + 'pear', 'peasant', 'pelican', 'pen', 'penalty', 'pencil', 'people', 'pepper', + 'perfect', 'permit', 'person', 'pet', 'phone', 'photo', 'phrase', 'physical', + 'piano', 'picnic', 'picture', 'piece', 'pig', 'pigeon', 'pill', 'pilot', + 'pink', 'pioneer', 'pipe', 'pistol', 'pitch', 'pizza', 'place', 'planet', + 'plastic', 'plate', 'play', 'please', 'pledge', 'pluck', 'plug', 'plunge', + 'poem', 'poet', 'point', 'polar', 'pole', 'police', 'pond', 'pony', + 'pool', 'popular', 'portion', 'position', 'possible', 'post', 'potato', 'pottery', + 'poverty', 'powder', 'power', 'practice', 'praise', 'predict', 'prefer', 'prepare', + 'present', 'pretty', 'prevent', 'price', 'pride', 'primary', 'print', 'priority', + 'prison', 'private', 'prize', 'problem', 'process', 'produce', 'profit', 'program', + 'project', 'promote', 'proof', 'property', 'prosper', 'protect', 'proud', 'provide', + 'public', 'pudding', 'pull', 'pulp', 'pulse', 'pumpkin', 'punch', 'pupil', + 'puppy', 'purchase', 'purity', 'purpose', 'purse', 'push', 'put', 'puzzle', + 'pyramid', 'quality', 'quantum', 'quarter', 'question', 'quick', 'quit', 'quiz', + 'quote', 'rabbit', 'raccoon', 'race', 'rack', 'radar', 'radio', 'rail', + 'rain', 'raise', 'rally', 'ramp', 'ranch', 'random', 'range', 'rapid', + 'rare', 'rate', 'rather', 'raven', 'raw', 'razor', 'ready', 'real', + 'reason', 'rebel', 'rebuild', 'recall', 'receive', 'recipe', 'record', 'recycle', + 'reduce', 'reflect', 'reform', 'refuse', 'region', 'regret', 'regular', 'reject', + 'relax', 'release', 'relief', 'rely', 'remain', 'remember', 'remind', 'remove', + 'render', 'renew', 'rent', 'reopen', 'repair', 'repeat', 'replace', 'report', + 'require', 'rescue', 'resemble', 'resist', 'resource', 'response', 'result', 'retire', + 'retreat', 'return', 'reunion', 'reveal', 'review', 'reward', 'rhythm', 'rib', + 'ribbon', 'rice', 'rich', 'ride', 'ridge', 'rifle', 'right', 'rigid', + 'ring', 'riot', 'ripple', 'risk', 'ritual', 'rival', 'river', 'road', + 'roast', 'robot', 'robust', 'rocket', 'romance', 'roof', 'rookie', 'room', + 'rose', 'rotate', 'rough', 'round', 'route', 'royal', 'rubber', 'rude', + 'rug', 'rule', 'run', 'runway', 'rural', 'sad', 'saddle', 'sadness', + 'safe', 'sail', 'salad', 'salmon', 'salon', 'salt', 'salute', 'same', + 'sample', 'sand', 'satisfy', 'satoshi', 'sauce', 'sausage', 'save', 'say', + 'scale', 'scan', 'scare', 'scatter', 'scene', 'scheme', 'school', 'science', + 'scissors', 'scorpion', 'scout', 'scrap', 'screen', 'script', 'scrub', 'sea', + 'search', 'season', 'seat', 'second', 'secret', 'section', 'security', 'seed', + 'seek', 'segment', 'select', 'sell', 'seminar', 'senior', 'sense', 'sentence', + 'series', 'service', 'session', 'settle', 'setup', 'seven', 'shadow', 'shaft', + 'shallow', 'share', 'shed', 'shell', 'sheriff', 'shield', 'shift', 'shine', + 'ship', 'shiver', 'shock', 'shoe', 'shoot', 'shop', 'short', 'shoulder', + 'shove', 'shrimp', 'shrug', 'shuffle', 'shy', 'sibling', 'sick', 'side', + 'siege', 'sight', 'sign', 'silent', 'silk', 'silly', 'silver', 'similar', + 'simple', 'since', 'sing', 'siren', 'sister', 'situate', 'six', 'size', + 'skate', 'sketch', 'ski', 'skill', 'skin', 'skirt', 'skull', 'slab', + 'slam', 'sleep', 'slender', 'slice', 'slide', 'slight', 'slim', 'slogan', + 'slot', 'slow', 'slush', 'small', 'smart', 'smile', 'smoke', 'smooth', + 'snack', 'snake', 'snap', 'sniff', 'snow', 'soap', 'soccer', 'social', + 'sock', 'soda', 'soft', 'solar', 'soldier', 'solid', 'solution', 'solve', + 'someone', 'song', 'soon', 'sorry', 'sort', 'soul', 'sound', 'soup', + 'source', 'south', 'space', 'spare', 'spatial', 'spawn', 'speak', 'special', + 'speed', 'spell', 'spend', 'sphere', 'spice', 'spider', 'spike', 'spin', + 'spirit', 'split', 'spoil', 'sponsor', 'spoon', 'sport', 'spot', 'spray', + 'spread', 'spring', 'spy', 'square', 'squeeze', 'squirrel', 'stable', 'stadium', + 'staff', 'stage', 'stairs', 'stamp', 'stand', 'start', 'state', 'stay', + 'steak', 'steel', 'stem', 'step', 'stereo', 'stick', 'still', 'sting', + 'stock', 'stomach', 'stone', 'stool', 'story', 'stove', 'strategy', 'street', + 'strike', 'strong', 'struggle', 'student', 'stuff', 'stumble', 'style', 'subject', + 'submit', 'subway', 'success', 'such', 'sudden', 'suffer', 'sugar', 'suggest', + 'suit', 'summer', 'sun', 'sunny', 'sunset', 'super', 'supply', 'supreme', + 'sure', 'surface', 'surge', 'surprise', 'surround', 'survey', 'suspect', 'sustain', + 'swallow', 'swamp', 'swap', 'swarm', 'swear', 'sweet', 'swift', 'swim', + 'swing', 'switch', 'sword', 'symbol', 'symptom', 'syrup', 'system', 'table', + 'tackle', 'tag', 'tail', 'talent', 'talk', 'tank', 'tape', 'target', + 'task', 'taste', 'tattoo', 'taxi', 'teach', 'team', 'tell', 'ten', + 'tenant', 'tennis', 'tent', 'term', 'test', 'text', 'thank', 'that', + 'theme', 'then', 'theory', 'there', 'they', 'thing', 'this', 'thought', + 'three', 'thrive', 'throw', 'thumb', 'thunder', 'ticket', 'tide', 'tiger', + 'tilt', 'timber', 'time', 'tiny', 'tip', 'tired', 'tissue', 'title', + 'toast', 'tobacco', 'today', 'toddler', 'toe', 'together', 'toilet', 'token', + 'tomato', 'tomorrow', 'tone', 'tongue', 'tonight', 'tool', 'tooth', 'top', + 'topic', 'topple', 'torch', 'tornado', 'tortoise', 'toss', 'total', 'tourist', + 'toward', 'tower', 'town', 'toy', 'track', 'trade', 'traffic', 'tragic', + 'train', 'transfer', 'trap', 'trash', 'travel', 'tray', 'treat', 'tree', + 'trend', 'trial', 'tribe', 'trick', 'trigger', 'trim', 'trip', 'trophy', + 'trouble', 'truck', 'true', 'truly', 'trumpet', 'trust', 'truth', 'try', + 'tube', 'tuition', 'tumble', 'tuna', 'tunnel', 'turkey', 'turn', 'turtle', + 'twelve', 'twenty', 'twice', 'twin', 'twist', 'two', 'type', 'typical', + 'ugly', 'umbrella', 'unable', 'unaware', 'uncle', 'uncover', 'under', 'undo', + 'unfair', 'unfold', 'unhappy', 'uniform', 'unique', 'unit', 'universe', 'unknown', + 'unlock', 'until', 'unusual', 'unveil', 'update', 'upgrade', 'uphold', 'upon', + 'upper', 'upset', 'urban', 'urge', 'usage', 'use', 'used', 'useful', + 'useless', 'usual', 'utility', 'vacant', 'vacuum', 'vague', 'valid', 'valley', + 'valve', 'van', 'vanish', 'vapor', 'various', 'vast', 'vault', 'vehicle', + 'velvet', 'vendor', 'venture', 'venue', 'verb', 'verify', 'version', 'very', + 'vessel', 'veteran', 'viable', 'vibrant', 'vicious', 'victory', 'video', 'view', + 'village', 'vintage', 'violin', 'virtual', 'virus', 'visa', 'visit', 'visual', + 'vital', 'vivid', 'vocal', 'voice', 'void', 'volcano', 'volume', 'vote', + 'voyage', 'wage', 'wagon', 'wait', 'walk', 'wall', 'walnut', 'want', + 'warfare', 'warm', 'warrior', 'wash', 'wasp', 'waste', 'water', 'wave', + 'way', 'wealth', 'weapon', 'wear', 'weasel', 'weather', 'web', 'wedding', + 'weekend', 'weird', 'welcome', 'west', 'wet', 'whale', 'what', 'wheat', + 'wheel', 'when', 'where', 'whip', 'whisper', 'wide', 'width', 'wife', + 'wild', 'will', 'win', 'window', 'wine', 'wing', 'wink', 'winner', + 'winter', 'wire', 'wisdom', 'wise', 'wish', 'witness', 'wolf', 'woman', + 'wonder', 'wood', 'wool', 'word', 'work', 'world', 'worry', 'worth', + 'wrap', 'wreck', 'wrestle', 'wrist', 'write', 'wrong', 'yard', 'year', + 'yellow', 'you', 'young', 'youth', 'zebra', 'zero', 'zone', 'zoo', +]; diff --git a/lib/src/nostr_account_actor.dart b/lib/src/nostr_account_actor.dart new file mode 100644 index 0000000..25ac9e8 --- /dev/null +++ b/lib/src/nostr_account_actor.dart @@ -0,0 +1,340 @@ +import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:actors/actors.dart'; +import 'package:bip340/bip340.dart' as bip340; +import 'package:crypto/crypto.dart'; + +import 'bip39.dart'; +import 'nostr_fetch_actor.dart'; +import 'nostr_publish_actor.dart'; + +/// Nostr user profile (NIP-01 kind 0 content fields). +class NostrProfile { + final String? name; + final String? displayName; + final String? about; + final String? picture; + final String? banner; + final String? website; + final String? nip05; + final String? lud16; + + const NostrProfile({ + this.name, + this.displayName, + this.about, + this.picture, + this.banner, + this.website, + this.nip05, + this.lud16, + }); + + factory NostrProfile.fromJson(Map json) => NostrProfile( + name: json['name'] as String?, + displayName: json['display_name'] as String?, + about: json['about'] as String?, + picture: json['picture'] as String?, + banner: json['banner'] as String?, + website: json['website'] as String?, + nip05: json['nip05'] as String?, + lud16: json['lud16'] as String?, + ); + + Map toJson() => { + if (name != null) 'name': name, + if (displayName != null) 'display_name': displayName, + if (about != null) 'about': about, + if (picture != null) 'picture': picture, + if (banner != null) 'banner': banner, + if (website != null) 'website': website, + if (nip05 != null) 'nip05': nip05, + if (lud16 != null) 'lud16': lud16, + }; +} + +// --------------------------------------------------------------------------- +// Messages + +sealed class AccountMessage { + const AccountMessage(); +} + +/// Generates a new keypair via BIP-39 mnemonic + NIP-06 derivation. +final class CreateAccount extends AccountMessage { + const CreateAccount(); +} + +/// Imports an existing account from a raw hex private key. +/// [ExportMnemonic] will return null for accounts imported this way. +final class ImportAccount extends AccountMessage { + final String privateKeyHex; + const ImportAccount(this.privateKeyHex); +} + +/// Imports an existing account from a BIP-39 mnemonic (NIP-06 derivation). +final class ImportMnemonic extends AccountMessage { + final String mnemonic; + const ImportMnemonic(this.mnemonic); +} + +/// Returns the public key of the loaded account. +final class GetAccountPublicKey extends AccountMessage { + const GetAccountPublicKey(); +} + +/// Returns the private key of the loaded account. +final class ExportPrivateKey extends AccountMessage { + const ExportPrivateKey(); +} + +/// Returns the BIP-39 mnemonic, or null if the account was imported via raw +/// private key. +final class ExportMnemonic extends AccountMessage { + const ExportMnemonic(); +} + +/// Fetches the kind-0 profile from the relays provided by the injected +/// [NostrFetchActor]. Caches the result for [GetPublicData]. +final class FetchProfile extends AccountMessage { + const FetchProfile(); +} + +/// Publishes a kind-0 profile update via the injected [NostrPublishActor]. +final class UpdateProfile extends AccountMessage { + final NostrProfile profile; + const UpdateProfile(this.profile); +} + +/// Returns a map of all public account data (pubkey + cached profile fields). +final class GetPublicData extends AccountMessage { + const GetPublicData(); +} + +// --------------------------------------------------------------------------- +// Results + +sealed class AccountResult { + const AccountResult(); +} + +final class AccountCreated extends AccountResult { + final String publicKeyHex; + final String mnemonic; + const AccountCreated({required this.publicKeyHex, required this.mnemonic}); +} + +final class AccountImported extends AccountResult { + final String publicKeyHex; + const AccountImported(this.publicKeyHex); +} + +final class AccountPublicKeyResult extends AccountResult { + final String publicKeyHex; + const AccountPublicKeyResult(this.publicKeyHex); +} + +final class PrivateKeyExported extends AccountResult { + final String privateKeyHex; + const PrivateKeyExported(this.privateKeyHex); +} + +final class MnemonicExported extends AccountResult { + /// null when the account was imported from a raw private key. + final String? mnemonic; + const MnemonicExported(this.mnemonic); +} + +final class ProfileFetched extends AccountResult { + final NostrProfile? profile; + const ProfileFetched(this.profile); +} + +final class ProfileUpdated extends AccountResult { + const ProfileUpdated(); +} + +final class PublicData extends AccountResult { + final Map data; + const PublicData(this.data); +} + +// --------------------------------------------------------------------------- +// Actor + +/// Manages a Nostr user identity: key lifecycle, BIP-39 mnemonic support, and +/// profile (kind 0) fetch / publish via injected helper actors. +class NostrAccountActor with Handler { + String? _privateKey; + String? _publicKey; + String? _mnemonic; + NostrProfile? _cachedProfile; + + final NostrFetchActor? _fetcher; + final NostrPublishActor? _publisher; + + NostrAccountActor({ + NostrFetchActor? fetcher, + NostrPublishActor? publisher, + }) : _fetcher = fetcher, + _publisher = publisher; + + @override + Future handle(AccountMessage message) => switch (message) { + CreateAccount() => _create(), + ImportAccount(:final privateKeyHex) => _importRaw(privateKeyHex), + ImportMnemonic(:final mnemonic) => _importMnemonic(mnemonic), + GetAccountPublicKey() => _getPublicKey(), + ExportPrivateKey() => _exportPrivateKey(), + ExportMnemonic() => _exportMnemonic(), + FetchProfile() => _fetchProfile(), + UpdateProfile(:final profile) => _updateProfile(profile), + GetPublicData() => _getPublicData(), + }; + + Future _create() async { + final mnemonic = generateMnemonic(); + final seed = mnemonicToSeed(mnemonic); + final privBytes = deriveNostrPrivkey(seed); + final privHex = _bytesToHex(privBytes); + _privateKey = privHex; + _publicKey = bip340.getPublicKey(privHex); + _mnemonic = mnemonic; + return AccountCreated(publicKeyHex: _publicKey!, mnemonic: mnemonic); + } + + Future _importRaw(String privateKeyHex) async { + _privateKey = privateKeyHex; + _publicKey = bip340.getPublicKey(privateKeyHex); + _mnemonic = null; + _cachedProfile = null; + return AccountImported(_publicKey!); + } + + Future _importMnemonic(String mnemonic) async { + if (!validateMnemonic(mnemonic)) { + throw Exception('Invalid BIP-39 mnemonic'); + } + final seed = mnemonicToSeed(mnemonic); + final privBytes = deriveNostrPrivkey(seed); + final privHex = _bytesToHex(privBytes); + _privateKey = privHex; + _publicKey = bip340.getPublicKey(privHex); + _mnemonic = mnemonic; + _cachedProfile = null; + return AccountImported(_publicKey!); + } + + Future _getPublicKey() async { + _requireKey(); + return AccountPublicKeyResult(_publicKey!); + } + + Future _exportPrivateKey() async { + _requireKey(); + return PrivateKeyExported(_privateKey!); + } + + Future _exportMnemonic() async { + _requireKey(); + return MnemonicExported(_mnemonic); + } + + Future _fetchProfile() async { + _requireKey(); + _requireFetcher(); + final filter = jsonEncode({ + 'kinds': [0], + 'authors': [_publicKey], + 'limit': 1, + }); + final result = await _fetcher!.handle(FetchEvents(filter)); + final events = (result as EventsFetched).events; + if (events.isEmpty) { + return const ProfileFetched(null); + } + final event = + jsonDecode(events.first) as Map; + final profile = NostrProfile.fromJson( + jsonDecode(event['content'] as String) as Map, + ); + _cachedProfile = profile; + return ProfileFetched(profile); + } + + Future _updateProfile(NostrProfile profile) async { + _requireKey(); + _requirePublisher(); + final eventJson = _signEvent( + kind: 0, + tags: [], + content: jsonEncode(profile.toJson()), + ); + await _publisher!.handle(PublishBatch([eventJson])); + _cachedProfile = profile; + return const ProfileUpdated(); + } + + Future _getPublicData() async { + _requireKey(); + return PublicData({ + 'pubkey': _publicKey, + ...?_cachedProfile?.toJson(), + }); + } + + String _signEvent({ + required int kind, + required List> tags, + required String content, + }) { + final ts = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final ser = jsonEncode([0, _publicKey, ts, kind, tags, content]); + final idBytes = sha256.convert(utf8.encode(ser)).bytes; + final id = + idBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + final rng = Random.secure(); + final aux = List.generate(32, (_) => rng.nextInt(256)) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); + final sig = bip340.sign(_privateKey!, id, aux); + return jsonEncode({ + 'id': id, + 'pubkey': _publicKey, + 'created_at': ts, + 'kind': kind, + 'tags': tags, + 'content': content, + 'sig': sig, + }); + } + + void _requireKey() { + if (_privateKey == null) { + throw StateError( + 'No account loaded. Call CreateAccount, ImportAccount, or ImportMnemonic first.', + ); + } + } + + void _requireFetcher() { + if (_fetcher == null) { + throw StateError( + 'No NostrFetchActor provided. Pass fetcher: to the constructor.', + ); + } + } + + void _requirePublisher() { + if (_publisher == null) { + throw StateError( + 'No NostrPublishActor provided. Pass publisher: to the constructor.', + ); + } + } + + static String _bytesToHex(Uint8List bytes) => + bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); +} diff --git a/lib/swarm.dart b/lib/swarm.dart index 35def4a..5210d99 100644 --- a/lib/swarm.dart +++ b/lib/swarm.dart @@ -5,3 +5,4 @@ export 'src/nostr_fetch_actor.dart'; export 'src/nostr_signer_actor.dart'; export 'src/geo_actor.dart'; export 'src/file_upload_actor.dart'; +export 'src/nostr_account_actor.dart'; diff --git a/test/nostr_account_actor_test.dart b/test/nostr_account_actor_test.dart new file mode 100644 index 0000000..8665ce7 --- /dev/null +++ b/test/nostr_account_actor_test.dart @@ -0,0 +1,330 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:bip340/bip340.dart' as bip340; +import 'package:swarm/src/bip39.dart'; +import 'package:swarm/src/bip39_wordlist.dart'; +import 'package:swarm/swarm.dart'; +import 'package:test/test.dart'; + +import 'fake_relay_connection.dart'; + +// NIP-06 test vectors from https://github.com/nostr-protocol/nips/blob/master/06.md +const _kMnemonic1 = + 'leader monkey parrot ring guide accident before fence cannon height naive bean'; +const _kPrivkey1 = + '7f7ff03d123792d6ac594bfa67bf6d0c0ab55b6b1fdb6249303fe861f1ccba9a'; +const _kPubkey1 = + '17162c921dc4d2518f9a101db33695df1afb56ab82f5ff3e5da6eec3ca5cd917'; + +// Vector 2 is a 24-word mnemonic — mnemonicToSeed/deriveNostrPrivkey work for +// any BIP-39 word count; validateMnemonic is 12-word only (matches generateMnemonic). +const _kMnemonic2 = + 'what bleak badge arrange retreat wolf trade produce cricket blur garlic valid ' + 'proud rude strong choose busy staff weather area salt hollow arm fade'; +const _kPrivkey2 = + 'c15d739894c81a2fcfd3a2df85a0d2c0dbc47a280d092799f144d73d7ae78add'; +const _kPubkey2 = + 'd41b22899549e1f3d335a31002cfd382174006e166d3e658e3a5eecdb6463573'; + +void main() { + // ----------------------------------------------------------------------- + group('BIP-39 wordlist', () { + test('contains 2048 words', () => expect(kBip39Words, hasLength(2048))); + test('first word is abandon', () => expect(kBip39Words.first, 'abandon')); + test('last word is zoo', () => expect(kBip39Words.last, 'zoo')); + }); + + // ----------------------------------------------------------------------- + group('BIP-39', () { + test('generateMnemonic returns 12 words', () { + expect(generateMnemonic().split(' '), hasLength(12)); + }); + + test('generateMnemonic passes validateMnemonic', () { + expect(validateMnemonic(generateMnemonic()), isTrue); + }); + + test('validateMnemonic accepts known-valid mnemonic', () { + expect(validateMnemonic(_kMnemonic1), isTrue); + }); + + test('validateMnemonic rejects wrong word count', () { + expect(validateMnemonic('abandon'), isFalse); + }); + + test('validateMnemonic rejects unknown word', () { + expect(validateMnemonic('notaword ' * 12), isFalse); + }); + + test('validateMnemonic rejects bad checksum', () { + // Replace first word to corrupt the checksum. + final words = _kMnemonic1.split(' ') + ..[0] = 'legal'; + expect(validateMnemonic(words.join(' ')), isFalse); + }); + }); + + // ----------------------------------------------------------------------- + group('NIP-06 key derivation', () { + test('vector 1: private key', () { + final seed = mnemonicToSeed(_kMnemonic1); + expect(_hex(deriveNostrPrivkey(seed)), equals(_kPrivkey1)); + }); + + test('vector 1: public key derived from private key', () { + final seed = mnemonicToSeed(_kMnemonic1); + expect(bip340.getPublicKey(_hex(deriveNostrPrivkey(seed))), + equals(_kPubkey1)); + }); + + test('vector 2: private key', () { + final seed = mnemonicToSeed(_kMnemonic2); + expect(_hex(deriveNostrPrivkey(seed)), equals(_kPrivkey2)); + }); + + test('vector 2: public key derived from private key', () { + final seed = mnemonicToSeed(_kMnemonic2); + expect(bip340.getPublicKey(_hex(deriveNostrPrivkey(seed))), + equals(_kPubkey2)); + }); + }); + + // ----------------------------------------------------------------------- + group('NostrAccountActor — identity lifecycle', () { + test('GetPublicKey before import throws StateError', () async { + await expectLater( + NostrAccountActor().handle(const GetAccountPublicKey()), + throwsStateError, + ); + }); + + test('CreateAccount returns AccountCreated with 12-word mnemonic', () async { + final result = await NostrAccountActor().handle(const CreateAccount()); + expect(result, isA()); + final created = result as AccountCreated; + expect(created.mnemonic.split(' '), hasLength(12)); + expect(validateMnemonic(created.mnemonic), isTrue); + }); + + test('CreateAccount pubkey matches re-derived key', () async { + final created = + await NostrAccountActor().handle(const CreateAccount()) + as AccountCreated; + final priv = deriveNostrPrivkey(mnemonicToSeed(created.mnemonic)); + expect(created.publicKeyHex, equals(bip340.getPublicKey(_hex(priv)))); + }); + + test('ImportAccount returns correct pubkey', () async { + final result = + await NostrAccountActor().handle(ImportAccount(_kPrivkey1)); + expect(result, isA()); + expect((result as AccountImported).publicKeyHex, equals(_kPubkey1)); + }); + + test('ImportMnemonic returns correct pubkey', () async { + final result = + await NostrAccountActor().handle(ImportMnemonic(_kMnemonic1)); + expect(result, isA()); + expect((result as AccountImported).publicKeyHex, equals(_kPubkey1)); + }); + + test('ImportMnemonic with invalid mnemonic throws', () async { + await expectLater( + NostrAccountActor() + .handle(const ImportMnemonic('bad mnemonic not valid here')), + throwsException, + ); + }); + + test('ExportPrivateKey returns stored key', () async { + final actor = NostrAccountActor(); + await actor.handle(ImportAccount(_kPrivkey1)); + final result = + await actor.handle(const ExportPrivateKey()) as PrivateKeyExported; + expect(result.privateKeyHex, equals(_kPrivkey1)); + }); + + test('ExportMnemonic is null after ImportAccount', () async { + final actor = NostrAccountActor(); + await actor.handle(ImportAccount(_kPrivkey1)); + final result = + await actor.handle(const ExportMnemonic()) as MnemonicExported; + expect(result.mnemonic, isNull); + }); + + test('ExportMnemonic returns mnemonic after ImportMnemonic', () async { + final actor = NostrAccountActor(); + await actor.handle(ImportMnemonic(_kMnemonic1)); + final result = + await actor.handle(const ExportMnemonic()) as MnemonicExported; + expect(result.mnemonic, equals(_kMnemonic1)); + }); + + test('ExportMnemonic returns mnemonic after CreateAccount', () async { + final actor = NostrAccountActor(); + final created = + await actor.handle(const CreateAccount()) as AccountCreated; + final result = + await actor.handle(const ExportMnemonic()) as MnemonicExported; + expect(result.mnemonic, equals(created.mnemonic)); + }); + + test('GetPublicKey returns current pubkey', () async { + final actor = NostrAccountActor(); + await actor.handle(ImportAccount(_kPrivkey1)); + final result = + await actor.handle(const GetAccountPublicKey()) as AccountPublicKeyResult; + expect(result.publicKeyHex, equals(_kPubkey1)); + }); + }); + + // ----------------------------------------------------------------------- + group('NostrAccountActor — profile', () { + test('FetchProfile without fetcher throws StateError', () async { + final actor = NostrAccountActor(); + await actor.handle(ImportAccount(_kPrivkey1)); + await expectLater( + actor.handle(const FetchProfile()), + throwsStateError, + ); + }); + + test('UpdateProfile without publisher throws StateError', () async { + final actor = NostrAccountActor(); + await actor.handle(ImportAccount(_kPrivkey1)); + await expectLater( + actor.handle(UpdateProfile(const NostrProfile())), + throwsStateError, + ); + }); + + test('UpdateProfile publishes kind-0 event with correct fields', () async { + final fakeRelay = FakeRelayConnection(); + final publisher = NostrPublishActor( + relayUrls: ['ws://test'], + connectionFactory: (_) => fakeRelay, + ); + final actor = NostrAccountActor(publisher: publisher); + await actor.handle(ImportAccount(_kPrivkey1)); + + const profile = NostrProfile(name: 'alice', displayName: 'Alice'); + final result = await actor.handle(UpdateProfile(profile)); + expect(result, isA()); + + final eventMsgs = fakeRelay.sentMessages + .where((m) => (jsonDecode(m) as List)[0] == 'EVENT') + .toList(); + expect(eventMsgs, hasLength(1)); + final event = + (jsonDecode(eventMsgs.first) as List)[1] as Map; + expect(event['kind'], equals(0)); + expect(event['pubkey'], equals(_kPubkey1)); + final content = + jsonDecode(event['content'] as String) as Map; + expect(content['name'], equals('alice')); + expect(content['display_name'], equals('Alice')); + }); + + test('UpdateProfile event signature verifies', () async { + final fakeRelay = FakeRelayConnection(); + final publisher = NostrPublishActor( + relayUrls: ['ws://test'], + connectionFactory: (_) => fakeRelay, + ); + final actor = NostrAccountActor(publisher: publisher); + await actor.handle(ImportAccount(_kPrivkey1)); + await actor.handle(UpdateProfile(const NostrProfile(name: 'alice'))); + + final eventMsg = fakeRelay.sentMessages + .firstWhere((m) => (jsonDecode(m) as List)[0] == 'EVENT'); + final event = + (jsonDecode(eventMsg) as List)[1] as Map; + expect( + bip340.verify( + event['pubkey'] as String, + event['id'] as String, + event['sig'] as String, + ), + isTrue, + ); + }); + + test('FetchProfile returns profile from relay', () async { + final fakeRelay = FakeRelayConnection(); + fakeRelay.onReq = (subId) { + fakeRelay.push(jsonEncode([ + 'EVENT', + subId, + { + 'id': 'a' * 64, + 'pubkey': _kPubkey1, + 'created_at': 1000000, + 'kind': 0, + 'tags': [], + 'content': jsonEncode({'name': 'bob', 'about': 'hello world'}), + 'sig': 'a' * 128, + }, + ])); + fakeRelay.push(jsonEncode(['EOSE', subId])); + }; + final fetcher = NostrFetchActor( + relayUrls: ['ws://test'], + connectionFactory: (_) => fakeRelay, + ); + + final actor = NostrAccountActor(fetcher: fetcher); + await actor.handle(ImportAccount(_kPrivkey1)); + final result = + await actor.handle(const FetchProfile()) as ProfileFetched; + expect(result.profile, isNotNull); + expect(result.profile!.name, equals('bob')); + expect(result.profile!.about, equals('hello world')); + }); + + test('FetchProfile returns null when no events', () async { + final fakeRelay = FakeRelayConnection(); + fakeRelay.onReq = (subId) { + fakeRelay.push(jsonEncode(['EOSE', subId])); + }; + final fetcher = NostrFetchActor( + relayUrls: ['ws://test'], + connectionFactory: (_) => fakeRelay, + ); + + final actor = NostrAccountActor(fetcher: fetcher); + await actor.handle(ImportAccount(_kPrivkey1)); + final result = + await actor.handle(const FetchProfile()) as ProfileFetched; + expect(result.profile, isNull); + }); + + test('GetPublicData includes pubkey and cached profile', () async { + final fakeRelay = FakeRelayConnection(); + final publisher = NostrPublishActor( + relayUrls: ['ws://test'], + connectionFactory: (_) => fakeRelay, + ); + final actor = NostrAccountActor(publisher: publisher); + await actor.handle(ImportAccount(_kPrivkey1)); + + // Before UpdateProfile, only pubkey + var data = + (await actor.handle(const GetPublicData()) as PublicData).data; + expect(data['pubkey'], equals(_kPubkey1)); + expect(data.containsKey('name'), isFalse); + + // After UpdateProfile, profile fields included + await actor.handle( + UpdateProfile(const NostrProfile(name: 'carol', lud16: 'carol@ln.tip')), + ); + data = (await actor.handle(const GetPublicData()) as PublicData).data; + expect(data['pubkey'], equals(_kPubkey1)); + expect(data['name'], equals('carol')); + expect(data['lud16'], equals('carol@ln.tip')); + }); + }); +} + +String _hex(Uint8List bytes) => + bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();