diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-02 11:56:03 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-03 14:24:54 +0200 |
| commit | 4e33fca55e48bbf6233e950355d31c603d88a6e6 (patch) | |
| tree | a1b64cf627b0e49bfd14a7357f282f661566413e /packages/meshbay-hub/tests/vectors/gen_keyring_vectors.js | |
| parent | 71af64ec3e393219895e47b35582d4310ffc6cd3 (diff) | |
| download | meshbay-4e33fca55e48bbf6233e950355d31c603d88a6e6.tar.gz | |
test(hub): shared keyring and transcript vectors
One file every bundle/transcript implementation must reproduce, generated
from the desktop keyring; checked against it and against the specification.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/tests/vectors/gen_keyring_vectors.js')
| -rw-r--r-- | packages/meshbay-hub/tests/vectors/gen_keyring_vectors.js | 232 |
1 files changed, 232 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/vectors/gen_keyring_vectors.js b/packages/meshbay-hub/tests/vectors/gen_keyring_vectors.js new file mode 100644 index 0000000..055070f --- /dev/null +++ b/packages/meshbay-hub/tests/vectors/gen_keyring_vectors.js @@ -0,0 +1,232 @@ +// Golden vectors for the keypair bundle and the transcripts, produced by the +// code the desktop application ships: meshbay-client's keyring.js and +// transcripts.js, with the vendored Argon2 the page also runs. +// +// node gen_keyring_vectors.js > keyring.json +// +// Every implementation of the bundle format and of the transcripts — the page, +// the desktop keyring, the Python reference, the Android keyring — must +// reproduce this file (test_keyring_vectors.py, and the Android unit tests). +// It is regenerated deliberately, for a format change, never by a test. The +// output is deterministic: nonces and the clock are pinned. + +'use strict'; + +const path = require('node:path'); +const crypto = require('node:crypto'); + +const REPO = path.resolve(__dirname, '..', '..', '..', '..'); +const CLIENT = path.join(REPO, 'packages/meshbay-client/src'); +const VENDOR = path.join(REPO, 'packages/meshbay-hub/src/meshbay_hub/static/vendor'); + +const { createKeyring } = require(path.join(CLIENT, 'keyring.js')); +const { transcriptFor } = require(path.join(CLIENT, 'transcripts.js')); +const { wasmArgon2 } = require(path.join(CLIENT, 'argon2-wasm.js')); + +const b64 = (b) => Buffer.from(b).toString('base64'); +const hex = (b) => Buffer.from(b).toString('hex'); +const hkdf = (ikm, info) => Buffer.from( + crypto.hkdfSync('sha256', ikm, Buffer.alloc(0), Buffer.from(info), 32)); + +// Fixed inputs. Invented values only. +const NOW = 1790000000; // a fixed "now" for transcript timestamps +const INPUT = { + username: 'vector-user', + userId: '0f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0', + password: 'a passphrase used only for vectors', + pepperB64: b64(Buffer.alloc(32, 7)), + pepperVersion: 3, + nodePk: b64(Buffer.alloc(32, 0x11)), + otherNodePk: b64(Buffer.alloc(32, 0x22)), + groupId: 'grp_vector-01', + mnemonic: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVWXYZ234567', + edSeedHex: '9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60', + xSeedHex: '77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a', + peerXPubHex: 'de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f', + fixedNonceHex: '000102030405060708090a0b', + legacyNonceHex: '0b0a09080706050403020100', + now: NOW, +}; + +const pkcs8 = (prefixHex, seedHex) => + Buffer.concat([Buffer.from(prefixHex, 'hex'), Buffer.from(seedHex, 'hex')]); +const ED_PKCS8 = pkcs8('302e020100300506032b657004220420', INPUT.edSeedHex); +const X_PKCS8 = pkcs8('302e020100300506032b656e04220420', INPUT.xSeedHex); + +function withFixedRandom(bytesHex, fn) { + const real = crypto.randomBytes; + crypto.randomBytes = (n) => { + const b = Buffer.from(bytesHex, 'hex'); + if (b.length !== n) throw new Error(`fixed random of ${b.length}, asked ${n}`); + return b; + }; + try { return fn(); } finally { crypto.randomBytes = real; } +} + +function withNow(seconds, fn) { + const real = Date.now; + Date.now = () => seconds * 1000; + try { return fn(); } finally { Date.now = real; } +} + +(async () => { + const argon2 = wasmArgon2(VENDOR); + let store = {}; + const ring = createKeyring({ + argon2, + load: () => JSON.parse(JSON.stringify(store)), + save: (o) => { store = JSON.parse(JSON.stringify(o)); }, + }); + + // 1. KDF chain. + const salt = crypto.createHash('sha256') + .update(`meshbay:bundle:v2:${INPUT.username}`).digest().subarray(0, 16); + const ARGON2 = { memory: 131072, passes: 3, parallelism: 1, tagLength: 32 }; + await ring.deriveSession({ + password: INPUT.password, username: INPUT.username, userId: INPUT.userId, + pepperB64: INPUT.pepperB64, pepperVersion: INPUT.pepperVersion, + }); + const m = Buffer.from(store.masters[INPUT.userId].m, 'base64'); + const a = Buffer.from(store.masters[INPUT.userId].legacy, 'base64'); + const kdf = { + argon2_params: ARGON2, + salt_hex: hex(salt), + argon2_hex: hex(a), + master_hex: hex(m), + master_fingerprint: ring.currentFingerprint(INPUT.userId), + node_key_hex: hex(hkdf(m, `meshbay:bundle:v3|node|${INPUT.nodePk}`)), + playlist_key_b64: ring.playlistKey(INPUT.userId), + recovery_key_hex: null, // filled below + }; + + // 2. A fixed identity, placed in the store as mint() would leave it. + store.identities[INPUT.userId] = { + [INPUT.nodePk]: { ed: b64(ED_PKCS8), x: b64(X_PKCS8), sealedWith: null }, + }; + const identity = { + ed_pkcs8_b64: b64(ED_PKCS8), + x_pkcs8_b64: b64(X_PKCS8), + public: ring.identity(INPUT.userId, INPUT.nodePk), + }; + + // 3. Bundles. + const fixed = withFixedRandom(INPUT.fixedNonceHex, + () => ring.sealBundle(INPUT.userId, INPUT.nodePk)); + const recovery = withFixedRandom(INPUT.fixedNonceHex, + () => ring.sealRecovery(INPUT.userId, INPUT.nodePk, INPUT.mnemonic, INPUT.username)); + + // The recovery key, re-derived the way keyring.js does (fromMnemonic + hkdf). + const B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + let bits = 0; let value = 0; const raw = []; + for (const ch of INPUT.mnemonic.replace(/[^A-Za-z2-7]/g, '').toUpperCase()) { + value = (value << 5) | B32.indexOf(ch); bits += 5; + if (bits >= 8) { raw.push((value >>> (bits - 8)) & 0xff); bits -= 8; } + } + kdf.mnemonic_bytes_hex = hex(Buffer.from(raw.slice(0, 32))); + kdf.recovery_key_hex = hex(hkdf(Buffer.from(raw.slice(0, 32)), + `meshbay:recovery:v1:${INPUT.username}`)); + + // MBK2 (TRANSITIONAL): "MBK2" ‖ nonce ‖ AES-GCM(JSON) under the Argon2 key, no AAD. + const legacyNonce = Buffer.from(INPUT.legacyNonceHex, 'hex'); + const c = crypto.createCipheriv('aes-256-gcm', a, legacyNonce); + const plain = JSON.stringify({ skEd: b64(ED_PKCS8), skX: b64(X_PKCS8) }); + const legacy = b64(Buffer.concat([ + Buffer.from('MBK2'), legacyNonce, c.update(plain), c.final(), c.getAuthTag()])); + + // Each must open through the shipped keyring, into a fresh store. + const check = async (label, bundleEnc, extra = {}) => { + let s2 = {}; + const r2 = createKeyring({ argon2, load: () => JSON.parse(JSON.stringify(s2)), + save: (o) => { s2 = JSON.parse(JSON.stringify(o)); } }); + await r2.deriveSession({ password: INPUT.password, username: INPUT.username, + userId: INPUT.userId, pepperB64: INPUT.pepperB64, pepperVersion: INPUT.pepperVersion }); + const pub = r2.openBundle(INPUT.userId, INPUT.nodePk, { bundleEnc, ...extra }); + if (pub.pkEdB64 !== identity.public.pkEdB64 || pub.pkXB64 !== identity.public.pkXB64) { + throw new Error(`${label} opened to another identity`); + } + return true; + }; + await check('fixed', fixed.bundle); + await check('legacy', legacy); + // The recovery copy, through the fallback: a passphrase copy that does not open. + await check('recovery', b64(Buffer.concat([Buffer.from('MBK3'), Buffer.alloc(30, 1)])), { + recoveryEnc: recovery, recoveryMnemonic: INPUT.mnemonic, username: INPUT.username }); + + const bundles = { + sealed_json_plaintext: plain, + aad: `meshbay:bundle:v3|${INPUT.userId}|${INPUT.nodePk}`, + fixed_nonce_bundle_b64: fixed.bundle, + fixed_nonce_fingerprint: fixed.fingerprint, + recovery_fixed_nonce_b64: recovery, + legacy_mbk2_b64: legacy, + }; + + // 4. Agreement. + const agreement = { + peer_x_pub_b64: b64(Buffer.from(INPUT.peerXPubHex, 'hex')), + shared_b64: ring.shared(INPUT.userId, INPUT.nodePk, b64(Buffer.from(INPUT.peerXPubHex, 'hex'))), + }; + + // 5. Transcripts: every kind, then refusals. + const ctx = { userId: INPUT.userId, nodePk: INPUT.nodePk, ...identity.public }; + delete ctx.sealedWith; + const nonceNode = b64(Buffer.alloc(32, 0x33)); + const kinds = { + join: { nodePk: INPUT.nodePk, groupId: INPUT.groupId, userId: INPUT.userId, nonceNode, ts: NOW }, + device_hello: { nodePk: INPUT.nodePk, groupId: INPUT.groupId, userId: INPUT.userId, nonceNode, ts: NOW - 30 }, + device_request: { nodePk: INPUT.nodePk, userId: INPUT.userId, + codeHash: 'ab'.repeat(32), nonceNode, ts: NOW + 30 }, + device_add: { nodePk: INPUT.nodePk, userId: INPUT.userId, + pkEd: b64(Buffer.alloc(32, 0x44)), pkX: b64(Buffer.alloc(32, 0x55)), nonceNode, ts: NOW }, + device_revoke: { nodePk: INPUT.nodePk, userId: INPUT.userId, + pkEd: b64(Buffer.alloc(32, 0x44)), nonceNode, ts: NOW }, + chat: { groupId: INPUT.groupId, epoch: 4, nonce: b64(Buffer.alloc(24, 0x66)), + ct: b64(Buffer.from('ciphertext of a chat line, opaque here')) }, + admin: { op: 'apps_enabled', nodePk: INPUT.nodePk, groupId: INPUT.groupId, + subject: '{"apps":["chat","videos"],"note":"é ü 漢"}', + nonce: b64(Buffer.alloc(16, 0x77)), ts: NOW }, + }; + const transcripts = {}; + for (const [kind, fields] of Object.entries(kinds)) { + const bytes = withNow(NOW, () => transcriptFor(kind, fields, ctx)); + const sig = withNow(NOW, () => ring.signAs(INPUT.userId, INPUT.nodePk, kind, fields)); + transcripts[kind] = { fields, transcript_hex: hex(bytes), signature_b64: sig }; + } + + const refusals = []; + const refuse = (label, kind, fields) => { + try { + withNow(NOW, () => transcriptFor(kind, fields, ctx)); + throw new Error(`vector "${label}" was NOT refused by transcripts.js`); + } catch (e) { + if (/NOT refused/.test(e.message)) throw e; + refusals.push({ label, kind, fields, error: e.message }); + } + }; + refuse('another node', 'join', { ...kinds.join, nodePk: INPUT.otherNodePk }); + refuse('another account', 'join', { ...kinds.join, userId: '00000000-0000-4000-8000-000000000000' }); + refuse('stale timestamp', 'join', { ...kinds.join, ts: NOW - 601 }); + refuse('future timestamp', 'device_hello', { ...kinds.device_hello, ts: NOW + 601 }); + refuse('fractional timestamp', 'join', { ...kinds.join, ts: NOW + 0.5 }); + refuse('bad group id', 'join', { ...kinds.join, groupId: 'a/b' }); + refuse('short node nonce', 'join', { ...kinds.join, nonceNode: b64(Buffer.alloc(15)) }); + refuse('not base64', 'join', { ...kinds.join, nonceNode: 'not*base64' }); + refuse('bad request hash', 'device_request', { ...kinds.device_request, codeHash: 'AB'.repeat(32) }); + refuse('device key wrong length', 'device_add', { ...kinds.device_add, pkEd: b64(Buffer.alloc(31)) }); + refuse('negative epoch', 'chat', { ...kinds.chat, epoch: -1 }); + refuse('chat nonce too short', 'chat', { ...kinds.chat, nonce: b64(Buffer.alloc(11)) }); + refuse('bad op', 'admin', { ...kinds.admin, op: 'Drop-Table' }); + refuse('an op that widens sharing (gone from MNP 6.0)', 'admin', { ...kinds.admin, op: 'root_add' }); + refuse('a well-formed op not on the list', 'admin', { ...kinds.admin, op: 'set_app_setting' }); + refuse('subject too long', 'admin', { ...kinds.admin, subject: 'x'.repeat(16385) }); + refuse('unknown kind', 'sign_anything', { bytes: 'AAAA' }); + + const out = { + _about: 'Generated by gen_keyring_vectors.js from meshbay-client keyring.js and ' + + 'transcripts.js with the vendored Argon2. Every implementation of the bundle ' + + 'format and the transcripts must reproduce every field.', + input: INPUT, + kdf, identity, bundles, agreement, transcripts, refusals, + }; + process.stdout.write(JSON.stringify(out, null, 2) + '\n'); +})().catch((e) => { console.error(e); process.exit(1); }); |