diff --git a/AGENTS.md b/AGENTS.md index 11eee89..0ed0004 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,5 +9,7 @@ Orgchain is lineage's app for creating and operating lineage holdings on Super T rather than rewriting it, and keep its tests passing. Preserve upstream licenses and provenance. - Keys follow the tree (`src/orgchain/keys.js`): store roots only (one per holding, one per graduated org) and derive every other key from its position. Every act is signed by whichever key holds the authorizing inscription. +- A node's chain key is its tapkey: its npub with the BIP-86 tweak. 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. - 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/PROVENANCE.md b/PROVENANCE.md index b4107db..28effeb 100644 --- a/PROVENANCE.md +++ b/PROVENANCE.md @@ -14,8 +14,11 @@ The upstream CC0 legal text is retained unchanged at [`public/licenses/supertest Runtime dependencies are pinned in `package.json` and `package-lock.json`: - **@cmdcode/tapscript 1.5.3**, Christopher Scott, [upstream](https://github.com/cmdruid/tapscript), CC-BY-1.0. Used for transaction encoding, signing/verification, Taproot commitments and addresses. Installed package license retained unchanged at [`public/licenses/tapscript.txt`](public/licenses/tapscript.txt). Library source is unmodified; Vite bundles/minifies it for the browser. -- **@noble/curves 1.9.7**, Paul Miller and contributors, [upstream](https://github.com/paulmillr/noble-curves), MIT. Used for key generation/validation and public keys. License retained at [`public/licenses/noble-curves.txt`](public/licenses/noble-curves.txt). -- **@noble/hashes 1.8.0**, Paul Miller and contributors, [upstream](https://github.com/paulmillr/noble-hashes), MIT. Used for synthetic block hashes. License retained at [`public/licenses/noble-hashes.txt`](public/licenses/noble-hashes.txt). +- **@noble/curves 1.9.7**, Paul Miller and contributors, [upstream](https://github.com/paulmillr/noble-curves), MIT. Used for key generation/validation and public keys: secp256k1 for chain and nostr keys, Ed25519 and X25519 for lineage's leaves. License retained at [`public/licenses/noble-curves.txt`](public/licenses/noble-curves.txt). +- **@noble/hashes 1.8.0**, Paul Miller and contributors, [upstream](https://github.com/paulmillr/noble-hashes), MIT. Used for synthetic block hashes, BIP-85's HMAC and the leaves' HKDF-SHA256. License retained at [`public/licenses/noble-hashes.txt`](public/licenses/noble-hashes.txt). +- **@scure/base 1.2.6**, Paul Miller, [upstream](https://github.com/paulmillr/scure-base), MIT. Used for bech32 npubs and base64 leaf keys. License retained at [`public/licenses/scure-base.txt`](public/licenses/scure-base.txt). +- **@scure/bip32 1.7.0**, Patricio Palladino and Paul Miller, [upstream](https://github.com/paulmillr/scure-bip32), MIT. Used for HD derivation under the BIP-85, BIP-86 and NIP-06 paths. License retained at [`public/licenses/scure-bip32.txt`](public/licenses/scure-bip32.txt). +- **@scure/bip39 1.6.0**, Patricio Palladino and Paul Miller, [upstream](https://github.com/paulmillr/scure-bip39), MIT. Used for the 24-word lab roots and their seeds. License retained at [`public/licenses/scure-bip39.txt`](public/licenses/scure-bip39.txt). Vite and Playwright are build/test dependencies, not runtime network services. Their installed package notices remain in the locked dependency installation. No unlicensed upstream code was copied. diff --git a/README.md b/README.md index e9ab8d3..2260c42 100644 --- a/README.md +++ b/README.md @@ -16,10 +16,14 @@ chain itself records who did what, and when. anything is signed. Events it rejects stay on the chain and show as rejected; they just don't change the tree. - **Keys.** Each holding gets its own 24-word lab root, and every node has lineage's keys at its place in the tree (`src/orgchain/keys.js`): an org's seed is BIP-85 from its parent's, or its own root once it graduates; agents are - accounts 1..n of their org's seed; each key is BIP-86 (the wallet that holds the node's inscription and signs for it) - and NIP-06 (its npub). Every act is signed by whichever key 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. + accounts 1..n of their org's seed. A node's **tapkey** is its NIP-06 npub with the BIP-86 tweak: one secret signs its + nostr events and its chain spends, and anyone with the npub can find what it holds. 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 tapkey 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 keep their separate m/86' chain keys until + they are reset. 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/public/licenses/scure-base.txt b/public/licenses/scure-base.txt new file mode 100644 index 0000000..5b91e4c --- /dev/null +++ b/public/licenses/scure-base.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2022 Paul Miller (https://paulmillr.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/public/licenses/scure-bip32.txt b/public/licenses/scure-bip32.txt new file mode 100644 index 0000000..68222b8 --- /dev/null +++ b/public/licenses/scure-bip32.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2022 Patricio Palladino, Paul Miller (paulmillr.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/public/licenses/scure-bip39.txt b/public/licenses/scure-bip39.txt new file mode 100644 index 0000000..68222b8 --- /dev/null +++ b/public/licenses/scure-bip39.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2022 Patricio Palladino, Paul Miller (paulmillr.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/adapters/lineage-keys.js b/src/adapters/lineage-keys.js index 1554aee..4758214 100644 --- a/src/adapters/lineage-keys.js +++ b/src/adapters/lineage-keys.js @@ -30,9 +30,14 @@ function assertIndex(value, what) { if (!Number.isSafeInteger(value) || value < 0 || value > MAX_INDEX) throw new Error(`Invalid ${what}.`); return value; } -// PBKDF2 is the slow step, so each mnemonic's master key is computed once per page. +// PBKDF2 is the slow step, so each mnemonic's seed and master key are computed once per page. +const seeds = new Map(); +export function seedBytes(mnemonic) { + if (!seeds.has(mnemonic)) seeds.set(mnemonic, mnemonicToSeedSync(assertMnemonic(mnemonic))); + return seeds.get(mnemonic); +} function master(mnemonic) { - if (!masters.has(mnemonic)) masters.set(mnemonic, HDKey.fromMasterSeed(mnemonicToSeedSync(assertMnemonic(mnemonic)))); + if (!masters.has(mnemonic)) masters.set(mnemonic, HDKey.fromMasterSeed(seedBytes(mnemonic))); return masters.get(mnemonic); } // BIP-85: entropy = HMAC-SHA512(key = "bip-entropy-from-k", msg = k) for the hardened child key k. @@ -49,6 +54,10 @@ 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); } +// The NIP-06 secret itself: Orgchain tweaks it into the node's tapkey (BIP-86), so one secret signs nostr and chain. +export function nostrSecret(mnemonic, account = 0) { + return bytesToHex(master(mnemonic).derive(PATHS.nostr(assertIndex(account, 'account'))).privateKey); +} export function nostrKey(mnemonic, account = 0) { const pubkey = schnorr.getPublicKey(master(mnemonic).derive(PATHS.nostr(assertIndex(account, 'account'))).privateKey); return { pubkey: bytesToHex(pubkey), npub: bech32.encode('npub', bech32.toWords(pubkey)) }; diff --git a/src/main.js b/src/main.js index 6bb2e15..56bdd8e 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 { holderOf, keyOf } from './orgchain/keys.js'; +import { holderOf, keyOf, leavesOf } from './orgchain/keys.js'; import { readEnvelope } from './adapters/inscription.js'; const $ = id => document.getElementById(id); @@ -202,7 +202,7 @@ function renderInspect() { if (!n) return box.replaceChildren(h('p', { class: 'note pad', text: `${known.name} is not born yet at beat ${ui.beat}.` })); box.replaceChildren(...nodeView(n)); } -const fact = ([k, v]) => h('div', { class: 'f' }, h('dt', { text: k }), h('dd', { text: v })); +const fact = ([k, v, note]) => h('div', { class: 'f' }, h('dt', { text: k }), h('dd', {}, v, note && h('span', { class: 'fn', text: note }))); function nodeView(n) { const parent = node(n.parent, view); const facts = [ @@ -211,7 +211,6 @@ function nodeView(n) { parent && ['Edge', n.edge === 'adopted' ? `adopted at ${at(n.graduated)}: it holds its own root` : `derived: ${parent.name} holds its seed`], n.closed !== null && ['Closed', at(n.closed)], ['Children', n.children.length ? n.children.map(id => node(id, view)?.name).join(', ') : 'none yet'], - ...keyFacts(n), ['Inscription', n.id], ].filter(Boolean); return [ @@ -222,15 +221,31 @@ function nodeView(n) { n.badges.length > 0 && h('div', {}, h('p', { class: 'lab', text: 'Belts' }), h('ul', { class: 'list' }, n.badges.map(b => h('li', {}, belt(b), h('span', { class: 'when', text: ` from ${node(b.by)?.name ?? '?'}, ${at(b.beat)}` }))))), n.decisions.length > 0 && h('div', {}, h('p', { class: 'lab', text: 'Decisions' }), h('ul', { class: 'list' }, n.decisions.map(d => - h('li', {}, h('b', { text: d.title }), d.why && ` · ${d.why}`, h('span', { class: 'when', text: ` (${at(d.beat)})` })))))), + h('li', {}, h('b', { text: d.title }), d.why && ` · ${d.why}`, h('span', { class: 'when', text: ` (${at(d.beat)})` }))))), + keysView(n)), ]; } -// Lineage's keys for a node: its derivation, npub and address as of the viewed beat, and who holds its inscription now. +// Lineage's keys for a node as of the viewed beat: its tapkey and npub, where they come from, who holds its inscription +// now, and its leaves with the public lines to paste. The leaves are the ones the lineage key tree page derives. const HELD = { own: 'its own key', lab: 'the lab wallet', parent: "its parent's key (its handoff is cued)", other: 'a key outside this lab' }; -function keyFacts(n) { +const LEAF = { ssh: 'shell access, certified by the CA', git: 'commit signing', dkim: 'mail signing', wg: 'WireGuard peer key', + 'ssh-ca': 'SSH certificate authority', mtls: 'TLS identity between nodes', noise: 'Headscale noise key' }; +function keysView(n) { const k = keyOf(lab.keys, view, n), holder = holderOf(lab, live, n.id); - return [['Key', k ? k.path : 'the lab wallet: this holding predates lab keys'], k && ['npub', k.npub], k && ['Address', k.wallet.address], - ['Held now by', holder ? HELD[holder.who] : 'nobody: its inscription has been spent']]; + 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 tapkey or leaves'], held].map(fact))); + const agent = n.kind === 'agent', leaves = leavesOf(lab.keys, view, n), lines = leaves.map(([, , line]) => line).filter(Boolean); + const chain = k.tapkey ? ['Tapkey', k.wallet.address, 'Its npub with the BIP-86 tweak: one secret signs its nostr events and its chain spends.'] + : ['Chain key', k.wallet.address, "A separate key at m/86', the way v1.1 keyed this lab. Reset the lab to key it by tapkey."]; + return h('div', { class: 'keys' }, + h('p', { class: 'lab', text: 'Keys' }), + h('dl', { class: 'fl' }, [chain, ['npub', k.npub], ['Derived', k.path], held].map(fact)), + 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') }))); } // 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 ecf22af..0b798ce 100644 --- a/src/orgchain.css +++ b/src/orgchain.css @@ -210,6 +210,13 @@ textarea { resize: vertical; line-height: 1.5; } .code { border: 2px solid var(--ink); min-width: 0; } .code-h { padding: 6px 10px; border-bottom: 2px solid var(--ink); background: var(--concrete); font: 700 var(--t-xs)/1.3 var(--mono); letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-2); } .code pre { margin: 0; padding: 10px; font-size: var(--t-xs); line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; } +.fn { display: block; margin-top: 4px; font-size: var(--t-xs); color: var(--ink-2); } +.keys { display: grid; gap: 10px; min-width: 0; } +.keys .fl { margin-top: -4px; } +.keys .f:first-child { margin-top: 0; } +.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; } /* timeline */ .scrub { flex: 1 1 400px; display: flex; flex-wrap: wrap; gap: 8px 12px; align-items: center; } diff --git a/src/orgchain/actions.js b/src/orgchain/actions.js index ea3b0ec..5086418 100644 --- a/src/orgchain/actions.js +++ b/src/orgchain/actions.js @@ -6,11 +6,11 @@ import { InscriptionAdapter, POSTAGE, FEE } from '../adapters/inscription.js'; import { MetadataIndex } from '../adapters/metadata-index.js'; import { TestWallet } from '../adapters/wallet.js'; import { getVin, getVout } from '../upstream/primitives.js'; -import { newMnemonic, walletSecret } from '../adapters/lineage-keys.js'; +import { newMnemonic } from '../adapters/lineage-keys.js'; import { holderCoin, makeClaim, countersign } from '../adapters/handoff.js'; import { PROTOCOL, lineageEvents, reduce } from './lineage.js'; import { armClock, beat } from './store.js'; -import { derivedSubtree, emptyKeys, holderOf, keyOf } from './keys.js'; +import { derivedSubtree, emptyKeys, holderOf, keyOf, rootWallet } from './keys.js'; const FIELDS = { found: ['name'], birth: ['name', 'kind'], award: ['to', 'skill', 'belt'], decide: ['title', 'why'], graduate: ['of'], close: [] }; const BODY = { found: m => `found ${m.name}`, birth: m => `birth ${m.name}`, award: m => `award ${m.skill} ${m.belt}`, @@ -75,7 +75,7 @@ function signerFor(lab, state, id) { // and its derived subtree re-keys under that root, so the parent's line can no longer sign for it. function perform(lab, op, by, metadata) { if (op === 'found') { - const mnemonic = newMnemonic(), root = new TestWallet(walletSecret(mnemonic, 0)); + const mnemonic = newMnemonic(), root = rootWallet(lab.keys, mnemonic); supply(lab, root); const id = inscribe(lab, root, op, null, metadata); lab.keys.roots[id] = mnemonic; diff --git a/src/orgchain/keys.js b/src/orgchain/keys.js index cc589b9..b1df61c 100644 --- a/src/orgchain/keys.js +++ b/src/orgchain/keys.js @@ -1,13 +1,25 @@ // Lineage's key tree over Orgchain's holdings (docs/lineage.md), using the tree branch's lineage-keys.js. // Each holding has its own 24-word lab root. An org's seed is BIP-85 from its parent's seed at its index, or its own -// root once it graduates. Agents (seats) are accounts 1..n of their org's seed. Every key is BIP-86 (the wallet that -// holds the node's inscription and signs for it) and NIP-06 (its npub). Lab only: nothing here is a real key. -import { Script } from '@cmdcode/tapscript'; -import { PATHS, childMnemonic, nostrKey, walletSecret } from '../adapters/lineage-keys.js'; +// root once it graduates. Agents (seats) are accounts 1..n of their org's seed. A node's tapkey is its NIP-06 npub with +// the BIP-86 tweak: one secret signs its nostr events and its chain spends, and its address follows from its npub. Its +// leaves (ssh, git, dkim, wg for agents; ssh-ca, mtls, noise for orgs) hang off the same tree. Lab only: nothing here +// is a real key. +import { Script, Tap } from '@cmdcode/tapscript'; +import { PATHS, childMnemonic, nostrKey, nostrSecret, seedBytes, 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'; -export const emptyKeys = () => ({ roots: {}, adopted: {} }); +// 'tapkey' (from v1.2): the chain key is the npub, tweaked. 'wallet': labs keyed by v1.1, whose chain key was a +// separate, untweaked key at m/86'. A lab keeps its scheme until it is reset. +export const SCHEMES = ['tapkey', 'wallet']; +export const emptyKeys = () => ({ scheme: 'tapkey', roots: {}, adopted: {} }); +// BIP-86: the output key commits to the internal key and no scripts, so anyone with an npub can find its coins. +export const tapkeyOf = pubkey => Tap.getPubKey(pubkey)[0]; +const chainSecret = (keys, mnemonic, account) => + (keys.scheme === 'wallet' ? walletSecret(mnemonic, account) : Tap.getSecKey(nostrSecret(mnemonic, account))[0]); +const pathAt = (keys, account) => (keys.scheme === 'wallet' ? PATHS.wallet(account) : PATHS.nostr(account)); +export const rootWallet = (keys, mnemonic) => new TestWallet(chainSecret(keys, mnemonic, 0)); export const scriptHex = script => Script.encode(script, false).hex; // Where a node's keys come from, or null for a holding that predates lab keys (one test wallet signs for it). @@ -28,8 +40,9 @@ export function seedOf(keys, state, node) { export function keyOf(keys, state, node) { const seed = seedOf(keys, state, node); if (!seed) return null; - const wallet = new TestWallet(walletSecret(seed.mnemonic, seed.account)); - return { wallet, npub: nostrKey(seed.mnemonic, seed.account).npub, path: `${[seed.origin, ...seed.steps].join(' › ')} · ${PATHS.wallet(seed.account)}` }; + const { npub, pubkey } = nostrKey(seed.mnemonic, seed.account); + return { wallet: new TestWallet(chainSecret(keys, seed.mnemonic, seed.account)), npub, pubkey, tapkey: keys.scheme === 'tapkey', + path: `${[seed.origin, ...seed.steps].join(' › ')} · ${pathAt(keys, seed.account)}` }; } // The wallet that holds an inscription now: the node's own key, its parent's (a handoff still cued), or the lab wallet. @@ -53,3 +66,18 @@ export function derivedSubtree(state, id) { } return out; } + +// Names nest as domains: time.yolopreneur; an agent's handle is sol@time.yolopreneur. +export function domainOf(state, node) { + const names = []; + for (let n = node; n; n = state.nodes.get(n.parent)) names.push(n.name); + return names.join('.'); +} +// A node's leaves as [label, short value, line to paste]: an agent's from its nostr secret, an org's from its seed. +export function leavesOf(keys, state, node) { + const seed = seedOf(keys, state, node); + if (!seed) return null; + if (node.kind !== 'agent') return nodeRecords(nodeLeaves(seedBytes(seed.mnemonic)), domainOf(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); +} diff --git a/src/orgchain/leaves.js b/src/orgchain/leaves.js new file mode 100644 index 0000000..dafa1da --- /dev/null +++ b/src/orgchain/leaves.js @@ -0,0 +1,42 @@ +// 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. +import { hkdf } from '@noble/hashes/hkdf'; +import { sha256 } from '@noble/hashes/sha2'; +import { bytesToHex, concatBytes, hexToBytes, utf8ToBytes } from '@noble/hashes/utils'; +import { ed25519, x25519 } from '@noble/curves/ed25519'; +import { base64 } from '@scure/base'; + +const SALT = utf8ToBytes('lineage/v0'); +export const leafKey = (ikm, info) => hkdf(sha256, ikm, SALT, utf8ToBytes(info), 32); +const clamp = k => { const c = Uint8Array.from(k); c[0] &= 248; c[31] &= 127; c[31] |= 64; return c; }; +const u32 = n => { const b = new Uint8Array(4); new DataView(b.buffer).setUint32(0, n); return b; }; +const sshStr = bytes => concatBytes(u32(bytes.length), bytes); +const sshBlob = pub => concatBytes(sshStr(utf8ToBytes('ssh-ed25519')), sshStr(pub)); +export const sshLine = (pub, comment) => `ssh-ed25519 ${base64.encode(sshBlob(pub))}${comment ? ` ${comment}` : ''}`; +export const sshFingerprint = pub => `SHA256:${base64.encode(sha256(sshBlob(pub))).replace(/=+$/, '')}`; + +export function agentLeaves(nostrSecretHex) { + const sk = hexToBytes(nostrSecretHex), ed = info => ed25519.getPublicKey(leafKey(sk, info)); + return { ssh: ed('ssh'), git: ed('git'), dkim: ed('dkim'), wg: x25519.getPublicKey(clamp(leafKey(sk, 'wg'))) }; +} +export function nodeLeaves(seed) { + const ed = info => ed25519.getPublicKey(leafKey(seed, info)); + return { 'ssh-ca': ed('node/ssh-ca'), mtls: ed('node/mtls'), noise: x25519.getPublicKey(clamp(leafKey(seed, 'node/noise'))) }; +} +// What each leaf publishes, in the key tree page's forms: a label, a short value, and the line you'd paste. +export function agentRecords(leaves, handle, selector, domain) { + return [ + ['ssh', sshFingerprint(leaves.ssh), sshLine(leaves.ssh, handle)], + ['git', sshFingerprint(leaves.git), `${handle} namespaces="git" ${sshLine(leaves.git, handle)}`], + ['dkim', `${selector}._domainkey.${domain}`, `${selector}._domainkey.${domain}. IN TXT "v=DKIM1; k=ed25519; p=${base64.encode(leaves.dkim)}"`], + ['wg', base64.encode(leaves.wg), `[Peer]\n# ${handle}\nPublicKey = ${base64.encode(leaves.wg)}`], + ]; +} +export function nodeRecords(leaves, domain) { + return [ + ['ssh-ca', sshFingerprint(leaves['ssh-ca']), sshLine(leaves['ssh-ca'], `lineage-ca@${domain}`)], + ['mtls', base64.encode(leaves.mtls), null], + ['noise', `mkey:${bytesToHex(leaves.noise)}`, null], + ]; +} diff --git a/src/orgchain/store.js b/src/orgchain/store.js index e5fcaf3..408994f 100644 --- a/src/orgchain/store.js +++ b/src/orgchain/store.js @@ -2,7 +2,7 @@ import { LocalChain } from '../adapters/chain.js'; import { NETWORK, TestWallet } from '../adapters/wallet.js'; import { assertMnemonic } from '../adapters/lineage-keys.js'; -import { emptyKeys } from './keys.js'; +import { SCHEMES, emptyKeys } from './keys.js'; export const LAB_KEY = 'orgchain:lab:v1'; // 'each': every act is its own beat. 10/30/60: acts are cued and land together on the next beat. 'manual': they wait for Beat now. @@ -22,6 +22,8 @@ export function keysPreference(value) { if (!/^[0-9a-f]{64}i0$/.test(id)) throw new Error('Invalid lab keys.'); keys[part][id] = assertMnemonic(mnemonic, 24); } + keys.scheme = value?.scheme ?? (Object.keys(keys.roots).length ? 'wallet' : 'tapkey'); // v1.1 labs had roots and no scheme + if (!SCHEMES.includes(keys.scheme)) throw new Error('Invalid lab keys.'); return keys; } export const emptyLab = () => ({ wallet: null, chain: new LocalChain(), clock: clockPreference(), keys: emptyKeys() }); diff --git a/tests/keys.test.js b/tests/keys.test.js index 319d141..cb43015 100644 --- a/tests/keys.test.js +++ b/tests/keys.test.js @@ -5,14 +5,17 @@ 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 { PATHS, bip85Entropy, childMnemonic, nostrKey, walletSecret } from '../src/adapters/lineage-keys.js'; +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, scriptHex } from '../src/orgchain/keys.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'; @@ -53,10 +56,35 @@ test('handoff: the recipient claims, the holder countersigns, the inscription mo assert.throws(() => countersign(chain, claim, holder), /spent/); }); -const at = (lab, id) => scriptHex(holderCoin(lab.chain, id).scriptPubKey); -const key = (mnemonic, account) => `5120${new TestWallet(walletSecret(mnemonic, account)).pubkey}`; -test('each holding has its own root, and every node holds its inscription at its lineage key', () => { +// 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' }); @@ -64,14 +92,28 @@ test('each holding has its own root, and every node holds its inscription at its 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/86'/1'/1'/0/0"); - assert.match(k.npub, /^npub1[02-9ac-hj-np-z]{58}$/); + 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' }); @@ -85,15 +127,31 @@ test('graduation: a fresh root claims the org, the derived key countersigns, and 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(new TestWallet(walletSecret(old, 0)).script).flatMap(c => (c.inscriptions ?? []).map(i => i.id)); + 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/86'/1'/1'/0/0"); + 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); @@ -105,9 +163,10 @@ test('holdings from before lab keys still act, with the one lab wallet', () => { 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, { roots: {}, adopted: {} }); + 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/); }); diff --git a/tests/orgchain.spec.js b/tests/orgchain.spec.js index 54d5b1a..5b4d80b 100644 --- a/tests/orgchain.spec.js +++ b/tests/orgchain.spec.js @@ -101,10 +101,20 @@ test('the sample replays, any beat can be inspected, and the lab survives a relo await expect(page.locator('#inspect-body')).toContainText('"op": "graduate"'); await expect(page.locator('#inspect-body')).toContainText("well-known's key"); await row(page, 'lineage').click(); - await expect(page.locator('#inspect-body')).toContainText("lineage root · m/86'/1'/0'/0/0"); + await expect(page.locator('#inspect-body')).toContainText("lineage root · m/44'/1237'/0'/0/0"); await expect(page.locator('#inspect-body')).toContainText('its own key'); await row(page, 'time').click(); - await expect(page.locator('#act-key')).toHaveText("Signs as time: yolopreneur root › 1' · m/86'/1'/0'/0/0"); + await expect(page.locator('#act-key')).toHaveText("Signs as time: yolopreneur root › 1' · m/44'/1237'/0'/0/0"); + const inspect = page.locator('#inspect-body'); + for (const text of ['ssh-ca', 'lineage-ca@time.yolopreneur', 'mkey:']) await expect(inspect).toContainText(text); + await row(page, 'sol').click(); + const fact = name => inspect.locator('.f').filter({ has: page.locator('dt', { hasText: new RegExp(`^${name}$`) }) }); + await expect(fact('Tapkey')).toContainText('bcrt1p'); + await expect(fact('npub')).toContainText('npub1'); + await expect(fact('Derived')).toContainText("yolopreneur root › 1' · m/44'/1237'/2'/0/0"); + 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); + } expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); await page.reload(); await expect(page.locator('#tree-rows .sel')).toHaveCount(12);