// 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); });