A node's chain key is now its tapkey: its NIP-06 npub with the BIP-86 tweak, so one secret signs its nostr events and its chain spends, and its address follows from its npub. Its leaves hang off the same tree, byte for byte as on lineage's key tree page: an agent's ssh, git, dkim and wg keys from its nostr secret, an org's ssh-ca, mtls and noise keys from its seed. Inspect shows the tapkey, npub, derivation and leaves, with the public lines to paste. Labs keyed by v1.1 keep their separate m/86' chain keys until they are reset. PROVENANCE.md and public/licenses now carry @scure/base, bip32 and bip39, which came in with v1.1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4upF5LVwmPDDqrhdUfnXh
173 lines
12 KiB
JavaScript
173 lines
12 KiB
JavaScript
import test from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { HDKey } from '@scure/bip32';
|
||
import { entropyToMnemonic } from '@scure/bip39';
|
||
import { wordlist } from '@scure/bip39/wordlists/english';
|
||
import { bytesToHex } from '@noble/hashes/utils';
|
||
import { schnorr } from '@noble/curves/secp256k1';
|
||
import { base64, bech32 } from '@scure/base';
|
||
import { Address } from '@cmdcode/tapscript';
|
||
import { PATHS, bip85Entropy, childMnemonic, nostrKey, nostrSecret, seedBytes, walletSecret } from '../src/adapters/lineage-keys.js';
|
||
import { claimStatus, countersign, decodeClaim, holderCoin, makeClaim } from '../src/adapters/handoff.js';
|
||
import { LocalChain } from '../src/adapters/chain.js';
|
||
import { InscriptionAdapter } from '../src/adapters/inscription.js';
|
||
import { TestWallet } from '../src/adapters/wallet.js';
|
||
import { beat, decodeLab, emptyLab, encodeLab, keysPreference } from '../src/orgchain/store.js';
|
||
import { act, fund, lineageState } from '../src/orgchain/actions.js';
|
||
import { keyOf, leavesOf, rootWallet, scriptHex, tapkeyOf } from '../src/orgchain/keys.js';
|
||
import { agentLeaves, agentRecords, nodeLeaves, nodeRecords, sshFingerprint } from '../src/orgchain/leaves.js';
|
||
|
||
// Spec vectors and the handoff test come from the tree branch (2ea7884), which wrote lineage-keys.js and handoff.js.
|
||
const XPRV = 'xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb';
|
||
test('BIP-85 spec vectors: core entropy and the 24-word BIP-39 application', () => {
|
||
const root = HDKey.fromExtendedKey(XPRV);
|
||
assert.equal(bytesToHex(root.derive("m/83696968'/0'/0'").privateKey), 'cca20ccb0e9a90feb0912870c3323b24874b0ca3d8018c4b96d0b97c0e82ded0');
|
||
assert.equal(bytesToHex(bip85Entropy(root, "m/83696968'/0'/0'")), 'efecfbccffea313214232d29e71563d941229afb4338c21f9517c41aaa0d16f00b83d2a09ef747e7a64e8e2bd5a14869e693da66ce94ac2da570ab7ee48618f7');
|
||
const entropy = bip85Entropy(root, PATHS.orgSeed(0)).slice(0, 32);
|
||
assert.equal(bytesToHex(entropy), 'ae131e2312cdc61331542efe0d1077bac5ea803adf24b313a4f0e48e9c51f37f');
|
||
assert.equal(entropyToMnemonic(entropy, wordlist), 'puppy ocean match cereal symbol another shed magic wrap hammer bulb intact gadget divorce twin tonight reason outdoor destroy simple truth cigar social volcano');
|
||
});
|
||
test('BIP-86 and NIP-06 spec vectors', () => {
|
||
const abandon = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
||
assert.equal(bytesToHex(schnorr.getPublicKey(walletSecret(abandon, 0, 0))), 'cc8a4bc64d897bddc5fbc2f670f7a8ba0b386779106cf1223c6fc5d7cd6fc115');
|
||
assert.deepEqual(nostrKey('leader monkey parrot ring guide accident before fence cannon height naive bean', 0), {
|
||
pubkey: '17162c921dc4d2518f9a101db33695df1afb56ab82f5ff3e5da6eec3ca5cd917', npub: 'npub1zutzeysacnf9rru6zqwmxd54mud0k44tst6l70ja5mhv8jjumytsd2x7nu' });
|
||
});
|
||
test('handoff: the recipient claims, the holder countersigns, the inscription moves', () => {
|
||
const chain = new LocalChain(), holder = TestWallet.create(), own = TestWallet.create();
|
||
chain.mine(holder.script);
|
||
const ins = new InscriptionAdapter(chain, holder);
|
||
const top = ins.inscribe('time', null, { op: 'found', name: 'time' }, { metaprotocol: 'lineage/v0' });
|
||
const doc = ins.inscribe('docscope', top, { op: 'birth', name: 'docscope' }, { metaprotocol: 'lineage/v0' });
|
||
chain.mine(holder.script);
|
||
assert.throws(() => makeClaim(chain, doc, own), /plain coin/);
|
||
chain.mine(own.script);
|
||
assert.throws(() => makeClaim(chain, doc, holder), /already holds/);
|
||
const claim = makeClaim(chain, doc, own);
|
||
assert.equal(claimStatus(chain, claim), 'open');
|
||
assert.deepEqual(decodeClaim(JSON.parse(JSON.stringify(claim))), claim);
|
||
assert.throws(() => countersign(chain, claim, own), /current holder/);
|
||
assert.throws(() => countersign(chain, { ...claim, to: holder.pubkey }, holder), /signature/i, 'a claim edited after signing fails');
|
||
assert.equal(countersign(chain, claim, holder), claim.txid);
|
||
chain.mine(holder.script);
|
||
assert.ok(new InscriptionAdapter(chain, own).list().some(i => i.id === doc && i.parent === top));
|
||
assert.ok(new InscriptionAdapter(chain, holder).list().some(i => i.id === top), 'the parent keeps its own inscription');
|
||
assert.equal(claimStatus(chain, claim), 'stale');
|
||
assert.throws(() => countersign(chain, claim, holder), /spent/);
|
||
});
|
||
|
||
|
||
// Values read off the lineage key tree page (lineage.yolopreneur.lol) for this mnemonic: agent account 1, lineage.llc.
|
||
const LEADER = 'leader monkey parrot ring guide accident before fence cannon height naive bean';
|
||
test('tapkey: the BIP-86 tweak turns the spec internal key into the spec output key', () => {
|
||
const tapkey = tapkeyOf('cc8a4bc64d897bddc5fbc2f670f7a8ba0b386779106cf1223c6fc5d7cd6fc115');
|
||
assert.equal(tapkey, 'a60869f0dbcf1dc659c9cecbaf8050135ea9e8cdc487053f1dc6880949dc684c');
|
||
assert.equal(Address.p2tr.fromPubKey(tapkey, 'main'), 'bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr');
|
||
});
|
||
test('leaves match the lineage key tree page byte for byte', () => {
|
||
const agent = agentLeaves(nostrSecret(LEADER, 1));
|
||
assert.equal(nostrKey(LEADER, 1).npub, 'npub1m9m6dnc0svwugus8sz6l29rqatmdegywxtgld6ymvq6y6ca0fczq88tsjr');
|
||
assert.deepEqual([sshFingerprint(agent.ssh), sshFingerprint(agent.git), base64.encode(agent.dkim), base64.encode(agent.wg)], [
|
||
'SHA256:Svt6sRSfHEYSQf9+QX+XDPg9eXlEsgcy+OAcJIzeyQU', 'SHA256:fBH0g7huqXVWQHa6GiDsMMzg2PTnWwyTgJdrH3L5bo4',
|
||
'A7j292wHd4jUXhs4dMJcKh0nDG1/cwoo4aG1NUn/x2g=', '0HVH6534UBMEVWDz8BA+NkI73Z28bHBBQNFEONCkmWI=']);
|
||
assert.deepEqual(nodeRecords(nodeLeaves(seedBytes(LEADER)), 'lineage.llc').map(([, value]) => value), [
|
||
'SHA256:XOvpZ24mpcsFhi0mDxyZDqSEpkVKsQkhnLCOR5mlxY4', 'ObnNHsWk0TI5evUH+wbuMXtyPtEXInA6Y0KJbFu8S7A=',
|
||
'mkey:e35077cb9d3e78ad67898407caa1a059453fcec698f00f3585c6d87e7950e676']);
|
||
const lines = Object.fromEntries(agentRecords(agent, 'sol@lineage.llc', 'sol', 'lineage.llc').map(([label, , line]) => [label, line]));
|
||
assert.match(lines.ssh, /^ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI[A-Za-z0-9+/]{43} sol@lineage\.llc$/);
|
||
assert.match(lines.git, /^sol@lineage\.llc namespaces="git" ssh-ed25519 \S+ sol@lineage\.llc$/);
|
||
assert.equal(lines.dkim, 'sol._domainkey.lineage.llc. IN TXT "v=DKIM1; k=ed25519; p=A7j292wHd4jUXhs4dMJcKh0nDG1/cwoo4aG1NUn/x2g="');
|
||
assert.equal(lines.wg, '[Peer]\n# sol@lineage.llc\nPublicKey = 0HVH6534UBMEVWDz8BA+NkI73Z28bHBBQNFEONCkmWI=');
|
||
});
|
||
|
||
const at = (lab, id) => scriptHex(holderCoin(lab.chain, id).scriptPubKey);
|
||
const key = (mnemonic, account) => `5120${tapkeyOf(nostrKey(mnemonic, account).pubkey)}`;
|
||
const npubHex = npub => bytesToHex(Uint8Array.from(bech32.fromWords(bech32.decode(npub).words)));
|
||
|
||
test('each holding has its own root, and every node holds its inscription at its tapkey', () => {
|
||
const lab = emptyLab();
|
||
const root = act(lab, 'found', null, { name: 'acme' }), ops = act(lab, 'birth', root, { name: 'ops' });
|
||
const bot = act(lab, 'birth', ops, { name: 'bot', kind: 'agent' }), lead = act(lab, 'birth', root, { name: 'lead', kind: 'agent' });
|
||
const m = lab.keys.roots[root], opsSeed = childMnemonic(m, 0);
|
||
assert.equal(m.split(' ').length, 24);
|
||
assert.deepEqual([root, ops, bot, lead].map(id => at(lab, id)), [key(m, 0), key(opsSeed, 0), key(opsSeed, 1), key(m, 1)]);
|
||
const s = lineageState(lab), k = keyOf(lab.keys, s, s.nodes.get(bot));
|
||
assert.equal(k.path, "acme root › 0' · m/44'/1237'/1'/0/0");
|
||
assert.equal(k.npub, nostrKey(opsSeed, 1).npub);
|
||
assert.equal(k.wallet.pubkey, tapkeyOf(npubHex(k.npub)), 'the tapkey is the npub, tweaked: anyone with the npub finds the coins');
|
||
assert.equal(k.wallet.address, Address.p2tr.fromPubKey(k.wallet.pubkey, 'regtest'));
|
||
assert.ok(k.tapkey);
|
||
const award = act(lab, 'award', ops, { to: bot, skill: 'launch', belt: 'green' });
|
||
assert.equal(at(lab, award), key(opsSeed, 0), 'an act is signed by its authorizer, and its record stays with that key');
|
||
const other = act(lab, 'found', null, { name: 'other' });
|
||
assert.notEqual(lab.keys.roots[other], m);
|
||
});
|
||
|
||
test('leaves hang off the same tree: agents from their nostr secret, orgs from their seed', () => {
|
||
const lab = emptyLab();
|
||
const root = act(lab, 'found', null, { name: 'acme' }), ops = act(lab, 'birth', root, { name: 'ops' });
|
||
const bot = act(lab, 'birth', ops, { name: 'bot', kind: 'agent' });
|
||
const opsSeed = childMnemonic(lab.keys.roots[root], 0), s = lineageState(lab), node = id => s.nodes.get(id);
|
||
assert.deepEqual(leavesOf(lab.keys, s, node(bot)), agentRecords(agentLeaves(nostrSecret(opsSeed, 1)), 'bot@ops.acme', 'bot', 'ops.acme'));
|
||
assert.deepEqual(leavesOf(lab.keys, s, node(ops)), nodeRecords(nodeLeaves(seedBytes(opsSeed)), 'ops.acme'));
|
||
assert.deepEqual(leavesOf(lab.keys, s, node(root)).map(([label]) => label), ['ssh-ca', 'mtls', 'noise']);
|
||
assert.match(leavesOf(lab.keys, s, node(root))[0][2], / lineage-ca@acme$/);
|
||
});
|
||
|
||
test('graduation: a fresh root claims the org, the derived key countersigns, and the subtree re-keys', () => {
|
||
const lab = emptyLab();
|
||
const root = act(lab, 'found', null, { name: 'acme' }), studio = act(lab, 'birth', root, { name: 'studio' });
|
||
const kid = act(lab, 'birth', studio, { name: 'kid' }), deep = act(lab, 'birth', kid, { name: 'deep' });
|
||
const seat = act(lab, 'birth', studio, { name: 'seat', kind: 'agent' }), solo = act(lab, 'birth', studio, { name: 'solo' });
|
||
const old = childMnemonic(lab.keys.roots[root], 0);
|
||
act(lab, 'graduate', studio, { of: solo }); // solo graduates first and keeps its own root through what follows
|
||
const soloRoot = lab.keys.adopted[solo];
|
||
act(lab, 'graduate', root, { of: studio });
|
||
const fresh = lab.keys.adopted[studio];
|
||
assert.ok(fresh && fresh !== old);
|
||
assert.deepEqual([studio, kid, deep, seat, solo].map(id => at(lab, id)),
|
||
[key(fresh, 0), key(childMnemonic(fresh, 0), 0), key(childMnemonic(childMnemonic(fresh, 0), 0), 0), key(fresh, 1), key(soloRoot, 0)]);
|
||
const leftovers = lab.chain.coins(rootWallet(lab.keys, old).script).flatMap(c => (c.inscriptions ?? []).map(i => i.id));
|
||
assert.deepEqual(leftovers.filter(id => [studio, kid, deep, seat].includes(id)), []);
|
||
const decision = act(lab, 'decide', studio, { title: 'Our own root now' });
|
||
assert.equal(at(lab, decision), key(fresh, 0), 'the org now signs with its own root');
|
||
const s = lineageState(lab);
|
||
assert.equal(keyOf(lab.keys, s, s.nodes.get(seat)).path, "studio root · m/44'/1237'/1'/0/0");
|
||
assert.match(leavesOf(lab.keys, s, s.nodes.get(seat))[0][2], / seat@studio\.acme$/, 'names still nest after graduation');
|
||
assert.deepEqual(decodeLab(encodeLab(lab)).keys, lab.keys);
|
||
});
|
||
|
||
test('a lab keyed by v1.1 keeps its separate, untweaked chain keys', () => {
|
||
const lab = emptyLab();
|
||
lab.keys.scheme = 'wallet';
|
||
const root = act(lab, 'found', null, { name: 'old' }), seat = act(lab, 'birth', root, { name: 'seat', kind: 'agent' });
|
||
const m = lab.keys.roots[root];
|
||
assert.deepEqual([root, seat].map(id => at(lab, id)), [0, 1].map(a => `5120${new TestWallet(walletSecret(m, a)).pubkey}`));
|
||
const s = lineageState(lab), k = keyOf(lab.keys, s, s.nodes.get(seat));
|
||
assert.equal(k.path, "old root · m/86'/1'/1'/0/0");
|
||
assert.equal(k.tapkey, false);
|
||
const saved = JSON.parse(encodeLab(lab));
|
||
delete saved.keys.scheme; // v1.1 saved roots and no scheme
|
||
assert.equal(decodeLab(JSON.stringify(saved)).keys.scheme, 'wallet');
|
||
assert.throws(() => keysPreference({ scheme: 'nope' }), /Invalid lab keys/);
|
||
});
|
||
|
||
test('holdings from before lab keys still act, with the one lab wallet', () => {
|
||
const lab = emptyLab();
|
||
fund(lab);
|
||
const legacy = new InscriptionAdapter(lab.chain, lab.wallet).inscribe('found old', null, { op: 'found', name: 'old' }, { metaprotocol: 'lineage/v0' });
|
||
beat(lab);
|
||
const kid = act(lab, 'birth', legacy, { name: 'kid' });
|
||
assert.equal(at(lab, kid), `5120${lab.wallet.pubkey}`);
|
||
act(lab, 'graduate', legacy, { of: kid });
|
||
assert.equal(at(lab, kid), `5120${lab.wallet.pubkey}`, 'without a root there is nothing to re-key; the event is still recorded');
|
||
const s = lineageState(lab);
|
||
assert.equal(keyOf(lab.keys, s, s.nodes.get(kid)), null);
|
||
assert.equal(leavesOf(lab.keys, s, s.nodes.get(kid)), null);
|
||
assert.equal(s.nodes.get(kid).edge, 'adopted');
|
||
const saved = JSON.parse(encodeLab(lab));
|
||
delete saved.keys;
|
||
assert.deepEqual(decodeLab(JSON.stringify(saved)).keys, { scheme: 'tapkey', roots: {}, adopted: {} });
|
||
assert.throws(() => keysPreference({ roots: { [`${'a'.repeat(64)}i0`]: 'not a mnemonic' } }), /Invalid lab mnemonic/);
|
||
});
|