import test from 'node:test'; import assert from 'node:assert/strict'; import { bytesToHex } from '@noble/hashes/utils'; import { sha256 } from '@noble/hashes/sha2'; import { ripemd160 } from '@noble/hashes/legacy'; import { schnorr, secp256k1 } from '@noble/curves/secp256k1'; import { bech32, bech32m, createBase58check } from '@scure/base'; import { Tap } from '@cmdcode/tapscript'; import { KEYS, decodeAddress, keyringWhy, messageHash, signBip322, signEip191, signKeyring, signNostr, slots, statement, toSpendTxid, verifyBip322, verifyEip191, verifyNostr } from '../src/orgchain/keyring.js'; import { childMnemonic, ethAddress, ethAddressOf, ethSecret } from '../src/adapters/lineage-keys.js'; import { PROTOCOL, reduce, summary } from '../src/orgchain/lineage.js'; import { emptyLab, setTempo } from '../src/orgchain/store.js'; import { act, lineageState, send } from '../src/orgchain/actions.js'; import { ringOf } from '../src/orgchain/keys.js'; import { SAT } from '../src/orgchain/money.js'; const utf8 = text => new TextEncoder().encode(text); // BIP-322's test vectors (bip-0322.mediawiki): one key, as a P2WPKH address and with the BIP-86 tweak as Taproot. const VECTOR = { wif: 'L3VFeEujGtevx9w18HD1fhRbCH67Az2dpCymeRE1SoPK6XQtaN2k', segwit: 'bc1q9vza2e8x573nczrlzms0wvx3gsqjx7vavgkx0l', taproot: 'bc1ppv609nr0vr25u07u95waq5lucwfm6tde4nydujnu8npg4q75mr5sxq8lt3' }; const vectorSecret = bytesToHex(createBase58check(sha256).decode(VECTOR.wif).slice(1, 33)); const toSpend = (address, message) => bytesToHex(toSpendTxid(decodeAddress(address), message).reverse()); test('BIP-322: message hashes, to_spend and signatures match the BIP', () => { assert.equal(bytesToHex(messageHash('')), 'c90c269c4f8fcbe6880f72a721ddfbf1914268a794cbb21cfafee13770ae19f1'); assert.equal(bytesToHex(messageHash('Hello World')), 'f0eb03b1a75ac6d9847f55c624a99169b5dccba2a31f5b23bea77ba270de0a7a'); assert.equal(toSpend(VECTOR.segwit, ''), 'c5680aa69bb8d860bf82d4e9cd3504b55dde018de765a91bb566283c545a99a7'); assert.equal(toSpend(VECTOR.segwit, 'Hello World'), 'b79d196740ad5217771c1098fc4a4b51e0535c32236c71f1ea4d61a2d603352b'); const empty = 'AkcwRAIgM2gBAQqvZX15ZiysmKmQpDrG83avLIT492QBzLnQIxYCIBaTpOaD20qRlEylyxFSeEA2ba9YOixpX8z46TSDtS40ASECx/EgAxlkQpQ9hYjgGu6EBCPMVPwVIVJqO4XCsMvViHI='; const hello = 'AkcwRAIgZRfIY3p7/DoVTty6YZbWS71bc5Vct9p9Fia83eRmw2QCICK/ENGfwLtptFluMGs2KsqoNSk89pO7F29zJLUx9a/sASECx/EgAxlkQpQ9hYjgGu6EBCPMVPwVIVJqO4XCsMvViHI='; const taproot = 'AUHd69PrJQEv+oKTfZ8l+WROBHuy9HKrbFCJu7U1iK2iiEy1vMU5EfMtjc+VSHM7aU0SDbak5IUZRVno2P5mjSafAQ=='; assert.ok(verifyBip322(VECTOR.segwit, '', empty)); assert.ok(verifyBip322(VECTOR.segwit, 'Hello World', hello)); assert.ok(verifyBip322(VECTOR.taproot, 'Hello World', taproot), 'a 65-byte Taproot signature carries SIGHASH_ALL'); assert.ok(!verifyBip322(VECTOR.segwit, 'Hello World', empty)); assert.ok(!verifyBip322(VECTOR.taproot, 'Hello world', taproot)); // RFC 6979 without low-R grinding gives the BIP's second "Hello World" signature, byte for byte. assert.equal(signBip322(VECTOR.segwit, 'Hello World', vectorSecret), 'AkgwRQIhAOzyynlqt93lOKJr+wmmxIens//zPzl9tqIOua93wO6MAiBi5n5EyAcPScOjf1lAqIUIQtr3zKNeavYabHyR8eGhowEhAsfxIAMZZEKUPYWI4BruhAQjzFT8FSFSajuFwrDL1Yhy'); // A Taproot signature with zero aux randomness (SIGHASH_DEFAULT, 64 bytes); bip322-js 3.0.0 accepts it. const mine = signBip322(VECTOR.taproot, 'Hello World', Tap.getSecKey(vectorSecret)[0]); assert.equal(mine, 'AUDjpClYFHngjnqQ3F0/3dyrLsOHFNEm4rKaaAc9GsfhC5+DngPJmXTeAmz+yfsVRa61PD2k9/CEQnLDvNUn9Qug'); assert.ok(verifyBip322(VECTOR.taproot, 'Hello World', mine)); for (const junk of ['', 'AA==', 'not base64', mine.slice(0, -8), null]) assert.ok(!verifyBip322(VECTOR.taproot, 'Hello World', junk)); assert.ok(!verifyBip322(VECTOR.taproot.toUpperCase(), 'Hello World', mine), 'keyring addresses are lowercase'); }); test('Ethereum: addresses and EIP-191 signatures match ethers and the key tree page', () => { const m = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; // Account 0 is the key tree page's legal-control key, its published vector; ethers 6 derives the same for account 1. assert.equal(ethAddress(m, 0), '0x9858EfFD232B4033E47d90003D41EC34EcaEda94'); assert.equal(ethAddress(m, 1), '0x78839F6054d7ed13918bAe0473BA31b1Ca9D7265'); const sig = signEip191('lineage/v0 keyring', ethSecret(m, 0)); assert.equal(sig, '0x9548689d750b83e33f4c07dd68dbc620f4733ac43fc623bebaef530d0f0dd055559d1a619c750b307a09d7051c03b9732f03e87b9505915d1d7d08edf2fffebe1b', 'what ethers 6 signMessage gives'); assert.ok(verifyEip191(ethAddress(m, 0), 'lineage/v0 keyring', sig)); assert.ok(!verifyEip191(ethAddress(m, 1), 'lineage/v0 keyring', sig)); assert.ok(!verifyEip191(ethAddress(m, 0).toLowerCase(), 'lineage/v0 keyring', sig), 'addresses are written in EIP-55 case'); assert.ok(!verifyEip191(ethAddress(m, 0), 'lineage/v0 keyring', sig.toUpperCase())); }); test('nostr: the proof signs a kind-1 note at created_at 0, an event nostr-tools checks', () => { const secret = bytesToHex(sha256(utf8('nostr'))), pubkey = bytesToHex(schnorr.getPublicKey(secret)); const npub = 'npub1kr98zklf2z4ywcfk97puw99dd3afyweelfz2leff8cjndn0qkquse7kw5l'; assert.equal(bech32.encode('npub', bech32.toWords(schnorr.getPublicKey(secret))), npub); // The event's id, as NIP-01 serializes it; nostr-tools 2 computes the same id and accepts the signature. assert.equal(bytesToHex(sha256(utf8(JSON.stringify([0, pubkey, 0, 1, [], 'lineage/v0 keyring'])))), '5d8dc7040e8128f597c0184f7bf347831e91c0051457c0811fae09ceb66200c5'); const sig = signNostr('lineage/v0 keyring', secret); assert.equal(sig, '393b22965c0ea7cde5cfa67c4bc231a97b68d0441dfe4f99b123af45dc8add7a87ce9b52c50e46741d938277cf1fd55673442b9209054c329e325ba4530844d1'); assert.ok(verifyNostr(npub, 'lineage/v0 keyring', sig)); assert.ok(!verifyNostr(npub, 'lineage/v0 keyring\n', sig)); assert.ok(!verifyNostr(npub.toUpperCase(), 'lineage/v0 keyring', sig)); }); // Four keys from a label, for events that need no lab: Taproot and SegWit on regtest, an npub, an Ethereum address. function ring(label) { const secrets = Object.fromEntries(KEYS.map(k => [k, bytesToHex(sha256(utf8(`${label}/${k}`)))])); const keys = { taproot: bech32m.encode('bcrt', [1, ...bech32m.toWords(schnorr.getPublicKey(secrets.taproot))]), segwit: bech32.encode('bcrt', [0, ...bech32.toWords(ripemd160(sha256(secp256k1.getPublicKey(secrets.segwit, true))))]), nostr: bech32.encode('npub', bech32.toWords(schnorr.getPublicKey(secrets.nostr))), ethereum: ethAddressOf(secp256k1.getPublicKey(secrets.ethereum)), }; return { keys, secrets }; } test('a keyring binds four keys, each signing the statement; any change breaks it', () => { const node = `${'a'.repeat(64)}i0`, { keys, secrets } = ring('a'), m = signKeyring(node, keys, secrets), text = statement(node, keys); assert.equal(text, `lineage/v0 keyring\nnode: ${node}\ntaproot: ${keys.taproot}\nsegwit: ${keys.segwit}\nnostr: ${keys.nostr}\nethereum: ${keys.ethereum}`); assert.deepEqual(Object.keys(m), [...KEYS, 'proof']); assert.equal(keyringWhy(node, m), ''); assert.equal(keyringWhy(`${'b'.repeat(64)}i0`, m), 'the taproot key did not sign this keyring', 'proofs are for one node'); const b = ring('b'); for (const k of KEYS) assert.match(keyringWhy(node, { ...m, [k]: b.keys[k] }), /did not sign this keyring/); const forged = { taproot: signBip322(keys.taproot, text, b.secrets.taproot), segwit: signBip322(keys.segwit, text, b.secrets.segwit), nostr: signNostr(text, b.secrets.nostr), ethereum: signEip191(text, b.secrets.ethereum) }; for (const k of KEYS) assert.equal(keyringWhy(node, { ...m, proof: { ...m.proof, [k]: forged[k] } }), `the ${k} key did not sign this keyring`); assert.equal(keyringWhy(node, { ...m, proof: undefined }), 'the taproot key did not sign this keyring'); assert.equal(keyringWhy(node, { ...m, taproot: keys.segwit }), 'taproot must be a Taproot address'); assert.equal(keyringWhy(node, { ...m, segwit: keys.taproot }), 'segwit must be a P2WPKH address'); const testnet = address => bech32.encode('tb', bech32.decode(address).words); assert.equal(keyringWhy(node, { ...m, segwit: testnet(keys.segwit) }), 'its Taproot and SegWit addresses must be on one network'); assert.equal(keyringWhy(node, { ...m, nostr: keys.nostr.toUpperCase() }), 'nostr must be an npub'); assert.notEqual(keys.ethereum, keys.ethereum.toLowerCase()); assert.equal(keyringWhy(node, { ...m, ethereum: keys.ethereum.toLowerCase() }), 'ethereum must be an EIP-55 address'); assert.throws(() => signKeyring(node, { ...keys, nostr: 'npub1' }, secrets), /^Error: Nostr must be an npub\.$/); // A key binds one slot, whichever network its address is written for. assert.deepEqual(slots({ ...keys, segwit: testnet(keys.segwit), taproot: bech32m.encode('tb', bech32m.decode(keys.taproot).words) }), slots(keys)); }); let n = 0; const ev = (op, parent = null, meta = {}, height = 1) => ({ id: `${String(++n).padStart(64, '0')}i0`, status: height === null ? 'pending' : 'confirmed', height, metadata: { op, ...meta }, parent, protocol: PROTOCOL }); const keyring = (node, label, height, parent = node.id) => ev('keyring', parent, signKeyring(node.id, ring(label).keys, ring(label).secrets), height); test('the reducer: a node binds its own keys, the newest keyring counts, and a graduation or close retires it', () => { const root = ev('found', null, { name: 'acme' }), ops = ev('birth', root.id, { name: 'ops' }, 2), bot = ev('birth', ops.id, { name: 'bot', kind: 'agent' }, 2), lead = ev('birth', root.id, { name: 'lead', kind: 'agent' }, 2); const events = [root, ops, bot, lead, keyring(ops, 'ops', 3), keyring(bot, 'bot', 3), keyring(lead, 'lead', 3), keyring(lead, 'bot', 4), // bot's keys, with good proofs, but bot has them keyring(ops, 'other', 4, root.id), // signed for ops, published by acme keyring(ops, 'ops2', 5), // replaces ops's keyring and frees its first keys keyring(lead, 'ops', 6), // so another node with those secrets can bind them ev('graduate', root.id, { of: ops.id }, 7), // ops and bot get new keys: their keyrings retire keyring(bot, 'bot', 8), // and bot's old keys are free again ev('close', lead.id, {}, 9), keyring(lead, 'lead', 10)]; const s = reduce(events), log = s.log.slice(4).map(e => [e.op, e.ok ? 'ok' : e.why]); assert.deepEqual(log, [['keyring', 'ok'], ['keyring', 'ok'], ['keyring', 'ok'], ['keyring', 'its taproot key is already bound to bot'], ['keyring', 'the taproot key did not sign this keyring'], ['keyring', 'ok'], ['keyring', 'ok'], ['graduate', 'ok'], ['keyring', 'ok'], ['close', 'ok'], ['keyring', 'the node is closed']]); const node = e => s.nodes.get(e.id); assert.deepEqual(node(ops).keyring, { id: events[9].id, beat: 5, keys: ring('ops2').keys, retired: { cause: 'graduate', by: ops.id, beat: 7 } }); assert.deepEqual(node(bot).keyring, { id: events[12].id, beat: 8, keys: ring('bot').keys, retired: null }); assert.deepEqual(node(lead).keyring.retired, { cause: 'close', by: lead.id, beat: 9 }); assert.deepEqual(node(lead).keyring.keys, ring('ops').keys); assert.deepEqual([...s.bound.values()], [bot.id, bot.id, bot.id, bot.id], 'only current keyrings hold keys'); assert.equal(summary(s.log[4], s), 'keyring ยท ops'); assert.equal(reduce(events, { upTo: 6 }).nodes.get(ops.id).keyring.retired, null); }); test('in the lab a node publishes its own keyring, a beat away from any graduation that retires it', () => { const lab = emptyLab(); const root = act(lab, 'found', null, { name: 'acme' }), ops = act(lab, 'birth', root, { name: 'ops' }), two = act(lab, 'birth', root, { name: 'two' }); send(lab, root, ops, SAT); send(lab, root, two, SAT); const bot = act(lab, 'birth', ops, { name: 'bot', kind: 'agent' }); send(lab, ops, bot, 0.1 * SAT); const id = act(lab, 'keyring', bot, {}), s = lineageState(lab), ring = ringOf(lab.keys, s, s.nodes.get(bot)); assert.deepEqual(s.nodes.get(bot).keyring.keys, ring.keys); assert.equal(ring.keys.ethereum, ethAddress(childMnemonic(lab.keys.roots[root], 0), 1), "account 1 of ops's seed"); const record = lab.chain.transactions.get(id.slice(0, -2)); assert.equal(record.inscription.parent, bot, 'bot acts as itself'); assert.equal(record.inscription.text, statement(bot, ring.keys), 'the body is what every key signed'); setTempo(lab, 'manual'); act(lab, 'graduate', root, { of: ops }); assert.throws(() => act(lab, 'keyring', bot, {}), /^Error: ops's graduation is cued, and it changes bot's keys\. Publish the keyring once it lands\.$/); assert.throws(() => act(lab, 'keyring', ops, {}), /ops's graduation is cued, and it changes its keys/); act(lab, 'keyring', two, {}); assert.throws(() => act(lab, 'graduate', root, { of: two }), /^Error: two's keyring is cued\. Graduate two once it lands, so the graduation retires it\.$/); setTempo(lab, 'each'); const after = lineageState(lab), beat = lab.chain.blocks.length; assert.deepEqual(after.nodes.get(bot).keyring.retired, { cause: 'graduate', by: ops, beat }); act(lab, 'keyring', bot, {}); const fresh = lineageState(lab).nodes.get(bot).keyring; assert.equal(fresh.retired, null); assert.notEqual(fresh.keys.taproot, ring.keys.taproot, 'new keys under its new root'); act(lab, 'graduate', root, { of: two }); assert.equal(lineageState(lab).nodes.get(two).keyring.retired.cause, 'graduate'); });