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:
teahubxyzandClaude Opus 5.5 committed 2026-10-14 20:14:11 -07:00
1 parent 0af51dc9cd
commit 17b8692174
9 files changed
+183 -15

No files matched your search

+2
View File
@@ -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`.
+6 -1
View File
@@ -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
+3
View File
@@ -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
View File
@@ -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) {
+11
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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');
+16
View File
@@ -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);