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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4upF5LVwmPDDqrhdUfnXh
This commit is contained in:
1 parent
0af51dc9cd
commit
17b8692174
9 files changed
+183
-15
No files matched your search
@@ -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`.
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+31
-6
@@ -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) {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
+48
-3
@@ -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),
|
||||
};
|
||||
}
|
||||
+26
-1
@@ -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')))}`]];
|
||||
}
|
||||
+40
-4
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user