From 17b8692174b0559bbd48cae4b5815b1a022f63eb Mon Sep 17 00:00:00 2001 From: teahubxyz Date: Wed, 14 Oct 2026 20:14:11 -0700 Subject: [PATCH] Orgchain v1.4: the tapkey returns, and every node's secrets behind Reveal Each node lists its Tapkey again beside Taproot and SegWit: the npub as a Taproot address (its nostr key with the BIP-86 tweak), so the npub alone finds it. Its inscription stays at the standard BIP-86 key, since nostr secrets get pasted into clients. Inspect gains a Reveal panel, as on lineage's key tree page: the seed (holdings and orgs), the nsec, Taproot, SegWit and Tapkey descriptors with test-network WIF and BIP-380 checksums, and the leaves' private keys (OpenSSH, DKIM, WireGuard, PKCS#8, Headscale), each with Copy. Tests pin hashes of the key tree page's own output for the NIP-06 vector, BIP-84's WIF and BIP-380's checksum; ssh-keygen reads the keys. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01H4upF5LVwmPDDqrhdUfnXh --- AGENTS.md | 2 ++ README.md | 7 ++++- src/adapters/lineage-keys.js | 3 +++ src/main.js | 37 +++++++++++++++++++++----- src/orgchain.css | 11 ++++++++ src/orgchain/keys.js | 51 +++++++++++++++++++++++++++++++++--- src/orgchain/leaves.js | 27 ++++++++++++++++++- tests/keys.test.js | 44 ++++++++++++++++++++++++++++--- tests/orgchain.spec.js | 16 +++++++++++ 9 files changed, 183 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e1ae0f3..cd927d5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,5 +12,7 @@ Orgchain is lineage's app for creating and operating lineage holdings on Super T - A node's chain key is its account's BIP-86 Taproot key (`m/86'/1'/account'/0/0`); SegWit is BIP-84 at the same account. Leaves are HKDF-SHA256 (salt `lineage/v0`) and must stay byte for byte with lineage's key tree page; `tests/keys.test.js` holds values read off that page. +- Secrets show only behind Reveal, reset on every selection, and match the key tree page byte for byte (the tests pin + hashes of the page's own output). - The page makes no network requests; keep it that way (the deployed CSP sets `connect-src 'none'`). - Before shipping: `npm test`, `npm run build`, `npm run test:e2e`. diff --git a/README.md b/README.md index 9dea163..c3c0998 100644 --- a/README.md +++ b/README.md @@ -18,13 +18,18 @@ chain itself records who did what, and when. (`src/orgchain/keys.js`): an org's seed is BIP-85 from its parent's, or its own root once it graduates; an org is account 0 of its seed and its agents are accounts 1..n. Every account lists its addresses, so no one has to derive them: **Taproot** (BIP-86, `m/86'/1'/account'/0/0`), which holds the node's inscription, signs its acts and receives - inscriptions; **SegWit** (BIP-84, `m/84'/1'/account'/0/0`) for payments; and its **npub** (NIP-06). Its **leaves** + inscriptions; **SegWit** (BIP-84, `m/84'/1'/account'/0/0`) for payments; its **Tapkey**, the npub as a Taproot + address (its nostr key with the BIP-86 tweak), which the npub alone finds; and its **npub** (NIP-06). Its **leaves** hang off the same tree, byte for byte as on lineage's key tree page (`src/orgchain/leaves.js`): 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, each with the public line to paste. Every act is signed by the Taproot key that holds the authorizing inscription, and a birth hands the child its own. Graduation is a claim by the org's fresh root, countersigned by its derived key, and the derived subtree re-keys under the new root, so the parent's line can no longer sign for it. Labs keyed by v1.1 or v1.2 keep their chain keys until they are reset. +- **Secrets.** Each node's Inspect has a Reveal panel, as on lineage's key tree page: its seed (holdings and orgs), + its nsec, its Taproot, SegWit and Tapkey keys as descriptors Bitcoin Core imports as they are (test-network WIF, + BIP-380 checksums), and its leaves' private keys (OpenSSH, DKIM, WireGuard, PKCS#8, Headscale). Hidden again on + every selection; lab keys only. Test coins and test keys only: a local chain, the lab wallet (the faucet that funds every key) and each holding's lab root, kept in this browser (`localStorage`, key `orgchain:lab:v1`). Nothing here is a real key; minting a real root is diff --git a/src/adapters/lineage-keys.js b/src/adapters/lineage-keys.js index e287678..451b047 100644 --- a/src/adapters/lineage-keys.js +++ b/src/adapters/lineage-keys.js @@ -56,6 +56,9 @@ export function childMnemonic(parentMnemonic, index) { export function walletSecret(mnemonic, account = 0, coin = 1) { return bytesToHex(master(mnemonic).derive(PATHS.wallet(assertIndex(account, 'account'), coin)).privateKey); } +export function segwitSecret(mnemonic, account = 0, coin = 1) { + return bytesToHex(master(mnemonic).derive(PATHS.segwit(assertIndex(account, 'account'), coin)).privateKey); +} export function segwitPubkey(mnemonic, account = 0, coin = 1) { return bytesToHex(master(mnemonic).derive(PATHS.segwit(assertIndex(account, 'account'), coin)).publicKey); } diff --git a/src/main.js b/src/main.js index 7912c65..58790c1 100644 --- a/src/main.js +++ b/src/main.js @@ -5,7 +5,7 @@ import { MetadataIndex } from './adapters/metadata-index.js'; import { census, lineageEvents, reduce, rows, slug, summary, trail } from './orgchain/lineage.js'; import { LAB_KEY, beat, decodeLab, emptyLab, encodeLab, mutate, readLab, setTempo, tick } from './orgchain/store.js'; import { SAMPLE, act, seedSample } from './orgchain/actions.js'; -import { addressesOf, holderOf, keyOf, leavesOf } from './orgchain/keys.js'; +import { addressesOf, holderOf, keyOf, leavesOf, secretsOf } from './orgchain/keys.js'; import { readEnvelope } from './adapters/inscription.js'; const $ = id => document.getElementById(id); @@ -27,12 +27,12 @@ const clockTime = ms => new Date(ms).toLocaleTimeString([], { hour: '2-digit', m const json = value => JSON.stringify(value, (k, v) => (typeof v === 'bigint' ? String(v) : v), 2); // ui.beat: null means now (cued acts included); a number is that past beat, read-only. -const ui = { holding: null, node: null, event: null, beat: null, verb: 'birth', busy: false, replay: null, closing: null }; +const ui = { holding: null, node: null, event: null, beat: null, verb: 'birth', busy: false, replay: null, closing: null, reveal: null }; let lab = emptyLab(), index = new MetadataIndex(lab.chain), events = [], live = reduce([]), view = live; const tip = () => lab.chain.blocks.length; const cued = () => events.filter(e => e.status === 'pending').length; const node = (id, state = live) => (id ? state.nodes.get(id) ?? null : null); -const select = next => Object.assign(ui, { event: null, closing: null }, next); +const select = next => Object.assign(ui, { event: null, closing: null, reveal: null }, next); const pick = next => { select(next); render(); }; function say(message, bad = false) { const el = $('say'); el.textContent = message; el.classList.toggle('bad', bad); } @@ -234,13 +234,14 @@ function keysView(n) { const k = keyOf(lab.keys, view, n), holder = holderOf(lab, live, n.id); const held = ['Held now by', holder ? HELD[holder.who] : 'nobody: its inscription has been spent']; if (!k) return h('div', { class: 'keys' }, h('p', { class: 'lab', text: 'Keys' }), - h('dl', { class: 'fl' }, [['Signs with', 'the lab wallet: this holding predates lab keys, so it has no addresses or leaves'], held].map(fact))); + h('dl', { class: 'fl' }, [['Signs with', 'the lab wallet: this holding predates lab keys, so it has no addresses, leaves or secrets of its own'], held].map(fact))); const agent = n.kind === 'agent', a = addressesOf(lab.keys, view, n), leaves = leavesOf(lab.keys, view, n); const lines = leaves.map(([, , line]) => line).filter(Boolean), reset = 'Reset the lab for standard BIP-86 keys.'; const holds = agent ? "Holds this seat's inscription, and inscriptions sent to it land here." : "Holds this org's inscription and signs its acts. Inscriptions sent to it land here."; const taproot = { bip86: `BIP-86 at ${a.taproot.path}. ${holds}`, tapkey: `Its npub's key with the BIP-86 tweak, the way v1.2 keyed this lab. ${holds} ${reset}`, wallet: `An untweaked key at ${a.taproot.path}, the way v1.1 keyed this lab. ${holds} ${reset}` }[k.scheme]; const facts = [['Taproot', a.taproot.address, taproot], ['SegWit', a.segwit.address, `BIP-84 at ${a.segwit.path}. For payments; this toy chain only moves Taproot.`], + ['Tapkey', a.tapkey.address, 'Its npub as a Taproot address (its nostr key with the BIP-86 tweak), so the npub alone finds it. Nostr secrets get pasted into clients, so treat it as an inbox.'], ['npub', a.npub.address, `NIP-06 at ${a.npub.path}. Its nostr name; the leaves below hang off it.`], ['From', `${k.from} · account ${k.account}`], held]; return h('div', { class: 'keys' }, h('p', { class: 'lab', text: 'Keys' }), @@ -248,8 +249,32 @@ function keysView(n) { h('p', { class: 'lab' }, 'Leaves', h('span', { text: ` · HKDF-SHA256 from its ${agent ? 'nostr secret' : 'seed'}` })), h('ul', { class: 'list leaves' }, leaves.map(([label, value]) => h('li', {}, h('b', { text: label }), h('span', { class: 'when', text: ` ${LEAF[label]}` }), h('code', { text: value })))), - h('div', { class: 'code' }, h('p', { class: 'code-h', text: agent ? 'Public lines to paste' : 'CA key for TrustedUserCAKeys' }), - h('pre', { text: lines.join('\n\n') }))); + copyBlock(agent ? 'Public lines to paste' : 'CA key for TrustedUserCAKeys', lines.join('\n\n')), + secretsView(n, k)); +} +// A code block with a Copy button; shown can lay the text out differently from what is copied. +function copyBlock(label, text, shown = text) { + const pre = h('pre', { text: shown }); + const button = h('button', { type: 'button', class: 'cp', 'aria-label': `Copy ${label}`, onclick: () => copy(text, button, pre) }, 'Copy'); + return h('div', { class: 'code' }, h('div', { class: 'code-h cph' }, h('span', { text: label }), button), pre); +} +async function copy(text, button, pre) { + const done = label => { button.textContent = label; setTimeout(() => { button.textContent = 'Copy'; }, 1400); }; + try { await navigator.clipboard.writeText(text); done('Copied'); } catch { getSelection().selectAllChildren(pre); done('Selected'); } +} +// A node's secrets behind Reveal, as on the lineage key tree page: hidden again whenever the selection changes. +function secretsView(n, k) { + const open = ui.reveal === n.id, s = open && secretsOf(lab.keys, view, n), org = node(n.parent, view); + const toggle = () => { ui.reveal = open ? null : n.id; renderInspect(); $('reveal')?.focus(); }; + const words = s?.seed?.mnemonic.split(' '); + return h('div', { class: 'secret' }, + h('div', { class: 'hz' }, h('span', { class: 'hz-l', text: 'Secret · stays in this tab' }), + h('button', { type: 'button', class: 'btn sm', id: 'reveal', 'aria-expanded': String(open), onclick: toggle }, open ? 'Hide' : 'Reveal')), + s && h('div', { class: 'sb' }, + s.seed ? copyBlock(`Seed · ${s.seed.from}`, s.seed.mnemonic, words.map((w, i) => `${String(i + 1).padStart(2, '0')} ${w}`).reduce((rows, w, i) => (i % 3 ? rows[rows.length - 1].push(w) : rows.push([w]), rows), []).map(r => r.join(' ')).join('\n')) + : h('p', { class: 'note', text: `A seat has no seed of its own: these are account ${k.account} of ${org?.name ?? 'its org'}'s seed.` }), + s.account.map(([label, kind, text]) => copyBlock(`${label} · ${kind}`, text)), + s.leaves.map(([label, title, text]) => copyBlock(`${label} · ${title}`, text)))); } // The key that signed an event's reveal, named when it belongs to the node that authorized it. function signerOf(e) { diff --git a/src/orgchain.css b/src/orgchain.css index 0b798ce..31134cc 100644 --- a/src/orgchain.css +++ b/src/orgchain.css @@ -217,6 +217,17 @@ textarea { resize: vertical; line-height: 1.5; } .keys .lab:not(:first-child) { margin-top: 6px; } .leaves { margin: 0; } .leaves code { display: block; margin-top: 2px; font: var(--t-xs)/1.5 var(--mono); overflow-wrap: anywhere; } +.cph { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.cph span { min-width: 0; overflow-wrap: anywhere; } +.cp { flex: none; font: 700 var(--t-xs)/1 var(--mono); letter-spacing: 0.06em; text-transform: uppercase; padding: 5px 8px 4px; + border: 2px solid var(--ink); border-radius: 0; background: var(--slab); color: var(--ink); cursor: pointer; } +.cp:hover { background: var(--signal); color: var(--on-signal); } +/* Secrets sit behind hazard tape, as on the lineage key tree page. */ +.secret { border: 2px solid var(--ink); } +.hz { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 8px; + background: repeating-linear-gradient(-45deg, var(--signal) 0 12px, #141414 12px 24px); } +.hz-l { background: #141414; color: var(--signal); font: 700 var(--t-xs)/1 var(--mono); letter-spacing: 0.12em; text-transform: uppercase; padding: 6px 8px 5px; } +.sb { display: grid; gap: 10px; padding: 12px; } /* timeline */ .scrub { flex: 1 1 400px; display: flex; flex-wrap: wrap; gap: 8px 12px; align-items: center; } diff --git a/src/orgchain/keys.js b/src/orgchain/keys.js index 05408a0..cc61eae 100644 --- a/src/orgchain/keys.js +++ b/src/orgchain/keys.js @@ -5,10 +5,13 @@ // for payments), a NIP-06 npub, and leaves that hang off the same tree (ssh, git, dkim, wg for agents; ssh-ca, mtls, // noise for orgs). Lab only: nothing here is a real key. import { Address, Script, Tap } from '@cmdcode/tapscript'; -import { PATHS, childMnemonic, nostrKey, nostrSecret, seedBytes, segwitPubkey, walletSecret } from '../adapters/lineage-keys.js'; +import { bech32, createBase58check } from '@scure/base'; +import { sha256 } from '@noble/hashes/sha2'; +import { concatBytes, hexToBytes } from '@noble/hashes/utils'; +import { PATHS, childMnemonic, nostrKey, nostrSecret, seedBytes, segwitPubkey, segwitSecret, walletSecret } from '../adapters/lineage-keys.js'; import { holderCoin } from '../adapters/handoff.js'; import { TestWallet } from '../adapters/wallet.js'; -import { agentLeaves, agentRecords, nodeLeaves, nodeRecords } from './leaves.js'; +import { agentLeaves, agentRecords, agentSecrets, nodeLeaves, nodeRecords, nodeSecrets } from './leaves.js'; // 'bip86' (from v1.3): the chain key is the account's BIP-86 Taproot key. Labs keyed earlier keep their scheme until // they are reset: 'tapkey' (v1.2) tweaked the npub's key instead, and 'wallet' (v1.1) used an untweaked key at m/86'. @@ -48,7 +51,8 @@ export function keyOf(keys, state, node) { } // A node's public addresses, listed so no one has to derive them: Taproot holds its inscription and takes -// inscriptions, SegWit takes payments (this toy chain only moves Taproot), and the npub is its nostr name. +// inscriptions, SegWit takes payments (this toy chain only moves Taproot), the tapkey is its npub as a Taproot +// address (its nostr key with the BIP-86 tweak, so the npub alone finds it), and the npub is its nostr name. export function addressesOf(keys, state, node) { const seed = seedOf(keys, state, node); if (!seed) return null; @@ -56,6 +60,7 @@ export function addressesOf(keys, state, node) { return { taproot: { address: k.wallet.address, path: pathAt(keys, seed.account) }, segwit: { address: Address.p2wpkh.fromPubKey(segwit, 'regtest'), path: PATHS.segwit(seed.account) }, + tapkey: { address: Address.p2tr.fromPubKey(tapkeyOf(k.pubkey), 'regtest'), path: PATHS.nostr(seed.account) }, npub: { address: k.npub, path: PATHS.nostr(seed.account) }, }; } @@ -96,3 +101,43 @@ export function leavesOf(keys, state, node) { const domain = domainOf(state, state.nodes.get(node.parent)); return agentRecords(agentLeaves(nostrSecret(seed.mnemonic, seed.account)), `${node.name}@${domain}`, node.name, domain); } + +// Encodings for the Reveal panel. WIF defaults to test networks (version 0xef); mainnet's is 0x80. +const b58 = createBase58check(sha256); +export const wif = (secretHex, version = 0xef) => b58.encode(concatBytes(Uint8Array.of(version), hexToBytes(secretHex), Uint8Array.of(1))); +export const nsecOf = secretHex => bech32.encode('nsec', bech32.toWords(hexToBytes(secretHex))); +// BIP-380 checksum, so a descriptor goes straight into Bitcoin Core's importdescriptors. +const DESC_IN = "0123456789()[],'/*abcdefgh@:$%{}IJKLMNOPQRSTUVWXYZ&+-.;<=>?!^_|~ijklmnopqrstuvwxyzABCDEFGH`#\"\\ "; +const DESC_GEN = [0xf5dee51989n, 0xa9fdca3312n, 0x1bab10e32dn, 0x3706b1677an, 0x644d626ffdn]; +export function descsum(desc) { + const poly = (c, v) => { const top = c >> 35n; c = ((c & 0x7ffffffffn) << 5n) ^ BigInt(v); DESC_GEN.forEach((g, i) => { if ((top >> BigInt(i)) & 1n) c ^= g; }); return c; }; + let c = 1n, cls = 0, n = 0; + for (const ch of desc) { + const v = DESC_IN.indexOf(ch); + if (v < 0) throw new Error('Invalid descriptor character.'); + c = poly(c, v & 31); cls = cls * 3 + (v >> 5); + if (++n === 3) { c = poly(c, cls); cls = 0; n = 0; } + } + if (n) c = poly(c, cls); + for (let j = 0; j < 8; j++) c = poly(c, 0); + c ^= 1n; + let out = ''; + for (let j = 0; j < 8; j++) out += 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'[Number((c >> BigInt(5 * (7 - j))) & 31n)]; + return `${desc}#${out}`; +} + +// The secret half of a node, for the Reveal panel: its seed if it has one of its own, its account keys as descriptors +// Bitcoin Core imports as they are, and its leaves' private keys. Lab only; nothing here leaves the tab. +export function secretsOf(keys, state, node) { + const seed = seedOf(keys, state, node); + if (!seed) return null; + const nostr = nostrSecret(seed.mnemonic, seed.account), m86 = walletSecret(seed.mnemonic, seed.account); + const taproot = { bip86: `tr(${wif(m86)})`, tapkey: `tr(${wif(nostr)})`, wallet: `rawtr(${wif(m86)})` }[keys.scheme]; + const agent = node.kind === 'agent', domain = domainOf(state, agent ? state.nodes.get(node.parent) : node); + return { + seed: agent ? null : { from: [seed.origin, ...seed.steps].join(' › '), mnemonic: seed.mnemonic }, + account: [['nsec', 'NIP-06', nsecOf(nostr)], ['taproot', 'descriptor', descsum(taproot)], + ['segwit', 'descriptor', descsum(`wpkh(${wif(segwitSecret(seed.mnemonic, seed.account))})`)], ['tapkey', 'descriptor', descsum(`tr(${wif(nostr)})`)]], + leaves: agent ? agentSecrets(nostr, `${node.name}@${domain}`) : nodeSecrets(seedBytes(seed.mnemonic), domain), + }; +} diff --git a/src/orgchain/leaves.js b/src/orgchain/leaves.js index dafa1da..d3906cf 100644 --- a/src/orgchain/leaves.js +++ b/src/orgchain/leaves.js @@ -1,6 +1,6 @@ // Lineage's leaf keys, derived as the key tree page at lineage.yolopreneur.lol does: HKDF-SHA256, salt 'lineage/v0'. // An agent's ssh, git and dkim keys (Ed25519) and wg key (X25519, clamped) come from its nostr secret; an org's -// ssh-ca and mtls (Ed25519) and noise (X25519) keys come from its BIP-39 seed. Only public halves leave this module. +// ssh-ca and mtls (Ed25519) and noise (X25519) keys come from its BIP-39 seed. Secret halves are for the Reveal panel. import { hkdf } from '@noble/hashes/hkdf'; import { sha256 } from '@noble/hashes/sha2'; import { bytesToHex, concatBytes, hexToBytes, utf8ToBytes } from '@noble/hashes/utils'; @@ -40,3 +40,28 @@ export function nodeRecords(leaves, domain) { ['noise', `mkey:${bytesToHex(leaves.noise)}`, null], ]; } + +// The secret half of the leaves, in the forms the lineage key tree page shows (byte for byte): OpenSSH private keys +// with a deterministic check, the DKIM seed, a WireGuard [Interface] entry, a PKCS#8 PEM and Headscale's privkey: form. +const PKCS8_ED = hexToBytes('302e020100300506032b657004220420'); +export function opensshPrivate(seed, pub, comment) { + const check = sha256(seed).slice(0, 4); + let priv = concatBytes(check, check, sshStr(utf8ToBytes('ssh-ed25519')), sshStr(pub), sshStr(concatBytes(seed, pub)), sshStr(utf8ToBytes(comment))); + priv = concatBytes(priv, new Uint8Array((8 - (priv.length % 8)) % 8).map((_, i) => i + 1)); + const body = concatBytes(utf8ToBytes('openssh-key-v1\0'), sshStr(utf8ToBytes('none')), sshStr(utf8ToBytes('none')), sshStr(new Uint8Array(0)), u32(1), + sshStr(sshBlob(pub)), sshStr(priv)); + return `-----BEGIN OPENSSH PRIVATE KEY-----\n${base64.encode(body).match(/.{1,70}/g).join('\n')}\n-----END OPENSSH PRIVATE KEY-----\n`; +} +export const pem = (label, bytes) => `-----BEGIN ${label}-----\n${base64.encode(bytes).match(/.{1,64}/g).join('\n')}\n-----END ${label}-----\n`; +// [label, title, text] for each leaf's secret. +export function agentSecrets(nostrSecretHex, handle) { + const sk = hexToBytes(nostrSecretHex), ed = info => { const seed = leafKey(sk, info); return opensshPrivate(seed, ed25519.getPublicKey(seed), handle); }; + return [['ssh', 'OpenSSH private key', ed('ssh')], ['git', 'OpenSSH private key', ed('git')], + ['dkim', 'Ed25519 private key (base64)', base64.encode(leafKey(sk, 'dkim'))], ['wg', 'Interface entry', `[Interface]\nPrivateKey = ${base64.encode(clamp(leafKey(sk, 'wg')))}`]]; +} +export function nodeSecrets(seed, domain) { + const ca = leafKey(seed, 'node/ssh-ca'); + return [['ssh-ca', 'OpenSSH private key', opensshPrivate(ca, ed25519.getPublicKey(ca), `lineage-ca@${domain}`)], + ['mtls', 'Private key (PKCS#8 PEM)', pem('PRIVATE KEY', concatBytes(PKCS8_ED, leafKey(seed, 'node/mtls')))], + ['noise', 'noise_private.key', `privkey:${bytesToHex(clamp(leafKey(seed, 'node/noise')))}`]]; +} diff --git a/tests/keys.test.js b/tests/keys.test.js index 753845d..1123996 100644 --- a/tests/keys.test.js +++ b/tests/keys.test.js @@ -3,19 +3,20 @@ 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 { bytesToHex, utf8ToBytes } from '@noble/hashes/utils'; +import { sha256 } from '@noble/hashes/sha2'; import { schnorr } from '@noble/curves/secp256k1'; import { base64 } from '@scure/base'; import { Address, Tap } from '@cmdcode/tapscript'; -import { PATHS, bip85Entropy, childMnemonic, nostrKey, nostrSecret, seedBytes, segwitPubkey, walletSecret } from '../src/adapters/lineage-keys.js'; +import { PATHS, bip85Entropy, childMnemonic, nostrKey, nostrSecret, seedBytes, segwitPubkey, segwitSecret, 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 { addressesOf, keyOf, leavesOf, rootWallet, scriptHex, tapkeyOf } from '../src/orgchain/keys.js'; -import { agentLeaves, agentRecords, nodeLeaves, nodeRecords, sshFingerprint } from '../src/orgchain/leaves.js'; +import { addressesOf, descsum, keyOf, leavesOf, nsecOf, rootWallet, scriptHex, secretsOf, tapkeyOf, wif } from '../src/orgchain/keys.js'; +import { agentLeaves, agentRecords, agentSecrets, nodeLeaves, nodeRecords, nodeSecrets, 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'; @@ -84,6 +85,19 @@ test('leaves match the lineage key tree page byte for byte', () => { assert.equal(lines.wg, '[Peer]\n# sol@lineage.llc\nPublicKey = 0HVH6534UBMEVWDz8BA+NkI73Z28bHBBQNFEONCkmWI='); }); +const sha16 = text => bytesToHex(sha256(utf8ToBytes(text))).slice(0, 16); +test('secrets match the lineage key tree page byte for byte', () => { + const page = { ssh: '39b2f5573e3bce0b', git: 'dc59220f725526ac', dkim: 'd3bd4ee4fab19a5e', wg: 'def16367a9dcc19a', 'ssh-ca': 'ac0e7b5448ba3818', mtls: '9ef175861cefe59d', noise: 'bea4ba0c87cc7707' }; + const ours = [...agentSecrets(nostrSecret(LEADER, 1), 'agent-a@lineage.llc'), ...nodeSecrets(seedBytes(LEADER), 'lineage.llc')]; + assert.deepEqual(Object.fromEntries(ours.map(([label, , text]) => [label, sha16(text)])), page); + // The page shows mainnet forms (WIF version 0x80); the lab's own descriptors use test networks. + assert.deepEqual([nsecOf(nostrSecret(LEADER, 1)), descsum(`tr(${wif(nostrSecret(LEADER, 1), 0x80)})`), descsum(`tr(${wif(walletSecret(LEADER, 1, 0), 0x80)})`), + descsum(`wpkh(${wif(segwitSecret(LEADER, 1, 0), 0x80)})`)].map(sha16), ['8d07c2e20021a973', '4b4d32db9ac69c8d', '1908b363e70a2120', '5caff55640aca16d']); + assert.equal(wif(segwitSecret(ABANDON, 0, 0), 0x80), 'KyZpNDKnfs94vbrwhJneDi77V6jF64PWPF8x5cdJb8ifgg2DUc9d', 'BIP-84 vector'); + assert.equal(descsum('raw(deadbeef)'), 'raw(deadbeef)#89f8spxm', 'BIP-380 vector'); + assert.match(ours[0][2], /^-----BEGIN OPENSSH PRIVATE KEY-----\n[A-Za-z0-9+/=\n]+\n-----END OPENSSH PRIVATE KEY-----\n$/); +}); + const at = (lab, id) => scriptHex(holderCoin(lab.chain, id).scriptPubKey); const key = (mnemonic, account) => `5120${new TestWallet(Tap.getSecKey(walletSecret(mnemonic, account))[0]).pubkey}`; @@ -100,6 +114,7 @@ test('each holding has its own root, and every node holds its inscription at its assert.deepEqual(a, { taproot: { address: Address.p2tr.fromPubKey(key(opsSeed, 1).slice(4), 'regtest'), path: "m/86'/1'/1'/0/0" }, segwit: { address: Address.p2wpkh.fromPubKey(segwitPubkey(opsSeed, 1), 'regtest'), path: "m/84'/1'/1'/0/0" }, + tapkey: { address: Address.p2tr.fromPubKey(tapkeyOf(nostrKey(opsSeed, 1).pubkey), 'regtest'), path: "m/44'/1237'/1'/0/0" }, npub: { address: nostrKey(opsSeed, 1).npub, path: "m/44'/1237'/1'/0/0" }, }); assert.match(a.taproot.address, /^bcrt1p/); @@ -121,6 +136,24 @@ test('leaves hang off the same tree: agents from their nostr secret, orgs from t assert.match(leavesOf(lab.keys, s, node(root))[0][2], / lineage-ca@acme$/); }); +test('secrets: an org shows its seed, a seat only its account, and every descriptor imports as it is', () => { + 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 m = lab.keys.roots[root], opsSeed = childMnemonic(m, 0), s = lineageState(lab), node = id => s.nodes.get(id); + assert.deepEqual(secretsOf(lab.keys, s, node(root)).seed, { from: 'acme root', mnemonic: m }); + assert.deepEqual(secretsOf(lab.keys, s, node(ops)).seed, { from: "acme root › 0'", mnemonic: opsSeed }); + const seat = secretsOf(lab.keys, s, node(bot)); + assert.equal(seat.seed, null); + assert.deepEqual(seat.account.map(([label]) => label), ['nsec', 'taproot', 'segwit', 'tapkey']); + assert.deepEqual(seat.account.slice(1).map(([, , d]) => d), [descsum(`tr(${wif(walletSecret(opsSeed, 1))})`), + descsum(`wpkh(${wif(segwitSecret(opsSeed, 1))})`), descsum(`tr(${wif(nostrSecret(opsSeed, 1))})`)]); + assert.match(seat.account[1][2], /^tr\(c[1-9A-HJ-NP-Za-km-z]{51}\)#[a-z0-9]{8}$/, 'test-network WIF'); + assert.deepEqual(seat.leaves, agentSecrets(nostrSecret(opsSeed, 1), 'bot@ops.acme')); + assert.deepEqual(secretsOf(lab.keys, s, node(ops)).leaves, nodeSecrets(seedBytes(opsSeed), 'ops.acme')); + assert.equal(addressesOf(lab.keys, s, node(bot)).tapkey.address, Address.p2tr.fromPubKey(tapkeyOf(nostrKey(opsSeed, 1).pubkey), 'regtest'), 'the npub alone finds the tapkey'); +}); + 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' }); @@ -153,6 +186,8 @@ test('a lab keyed by v1.2 keeps its npub-tweaked chain keys', () => { const s = lineageState(lab), k = keyOf(lab.keys, s, s.nodes.get(seat)); assert.equal(k.path, "mid root · m/44'/1237'/1'/0/0"); assert.equal(addressesOf(lab.keys, s, s.nodes.get(seat)).segwit.path, "m/84'/1'/1'/0/0", 'every lab lists SegWit'); + const v12 = secretsOf(lab.keys, s, s.nodes.get(seat)).account; + assert.equal(v12[1][2], v12[3][2], 'in a v1.2 lab the Taproot key is the tapkey'); assert.equal(decodeLab(encodeLab(lab)).keys.scheme, 'tapkey'); }); @@ -165,6 +200,7 @@ test('a lab keyed by v1.1 keeps its separate, untweaked chain keys', () => { 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.scheme, 'wallet'); + assert.match(secretsOf(lab.keys, s, s.nodes.get(seat)).account[1][2], /^rawtr\(c/, 'an untweaked key imports as rawtr()'); 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'); diff --git a/tests/orgchain.spec.js b/tests/orgchain.spec.js index f6c5216..492c1b3 100644 --- a/tests/orgchain.spec.js +++ b/tests/orgchain.spec.js @@ -115,6 +115,22 @@ test('the sample replays, any beat can be inspected, and the lab survives a relo for (const text of ['ssh-ed25519 AAAAC3NzaC1lZDI1NTE5', 'sol@time.yolopreneur namespaces="git"', 'sol._domainkey.time.yolopreneur. IN TXT "v=DKIM1; k=ed25519; p=', 'PublicKey = ']) { await expect(inspect.locator('pre')).toContainText(text); } + await expect(fact('Tapkey')).toContainText('bcrt1p'); + await expect(page.locator('#reveal')).toHaveAttribute('aria-expanded', 'false'); + await page.locator('#reveal').click(); + const secrets = inspect.locator('.sb'); + for (const text of ['nsec1', 'BEGIN OPENSSH PRIVATE KEY', 'PrivateKey = ', 'account 2 of time']) await expect(secrets).toContainText(text); + await expect(secrets).toContainText(/tr\(c[1-9A-HJ-NP-Za-km-z]{51}\)#[a-z0-9]{8}/); + await secrets.locator('.cp').first().click(); + await expect(secrets.locator('.cp').first()).toHaveText(/Copied|Selected/); + await row(page, 'time').click(); + await expect(page.locator('#reveal')).toHaveAttribute('aria-expanded', 'false'); + await page.locator('#reveal').click(); + await expect(secrets).toContainText("Seed · yolopreneur root › 1'"); + await expect(secrets).toContainText('BEGIN PRIVATE KEY'); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + await page.locator('#reveal').click(); + await expect(secrets).toHaveCount(0); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); await page.reload(); await expect(page.locator('#tree-rows .sel')).toHaveCount(12);