/** * What a node identity signs, built here from named fields — never bytes the * page chose. * * The page parses content from nodes, which is attacker-controlled input * (docs/MESHBAY_DESIGN.md §8.2). Were it able to hand this process bytes to * sign, a script there would get a signature over anything: the approval of a * device key of its own, which outlives every session, or an operation this * application would otherwise have asked the person about. So the page names a * kind and gives the fields, the bytes are built here — with this identity's * own public keys wherever a transcript names them — and a kind outside this * list is not signed at all. * * Byte for byte the transcripts of meshbay_common (join.py, device.py, * adminop.py, chatbox.py) and of the page (crypto.js, transcriptFor); * test_desktop_keyring.py holds the three together. */ 'use strict'; const enc = (s) => Buffer.from(String(s), 'utf8'); function lenPrefixed(prefix, parts) { const chunks = [Buffer.from(prefix)]; for (const p of parts) { const len = Buffer.alloc(4); len.writeUInt32BE(p.length, 0); chunks.push(len, Buffer.from(p)); } return Buffer.concat(chunks); } // ── Field checks ────────────────────────────────────────────────────────── // // Shapes, not trust: what is checked here is that a field is what its name // says, so that nothing unexpected reaches a transcript or a dialog. function refuse(what) { throw new Error(`Refused: ${what}`); } function bytes(v, what, { min = 1, max = 64 } = {}) { const s = String(v ?? ''); if (!/^[A-Za-z0-9+/]*={0,2}$/.test(s)) refuse(`${what} is not base64`); const b = Buffer.from(s, 'base64'); if (b.length < min || b.length > max) refuse(`${what} has the wrong length`); return b; } function key32(v, what) { bytes(v, what, { min: 32, max: 32 }); return String(v); } function text(v, what, max = 256) { const s = String(v ?? ''); if (s.length > max) refuse(`${what} is too long`); return s; } function groupId(v) { const s = String(v ?? ''); if (s && !/^[A-Za-z0-9_-]{1,64}$/.test(s)) refuse('not a group id'); return s; } // The node's clock and ours: a signature for a moment far from now is one to // keep for later. const TS_SLACK_S = 600; function timestamp(v) { const n = Number(v); if (!Number.isInteger(n) || Math.abs(n - Date.now() / 1000) > TS_SLACK_S) { refuse('the timestamp is not now'); } return n; } // ── Transcripts ─────────────────────────────────────────────────────────── const PREFIX = { join: 'meshbay:join:v1', device_request: 'meshbay:device_req:v1', device_add: 'meshbay:device_add:v1', device_revoke: 'meshbay:device_revoke:v1', device_hello: 'meshbay:device_hello:v1', chat: 'meshbay:chat:v1', admin: 'meshbay:admin:v1', }; /** * `ctx`: what this process knows and the page does not get to say — the * account (`userId`), the node (`nodePk`) and this identity's public keys * (`pkEdB64`, `pkXB64`). A field naming another account or another node is * refused rather than signed. */ function transcriptFor(kind, f, ctx) { const fields = f && typeof f === 'object' ? f : {}; const sameNode = () => { if (String(fields.nodePk ?? '') !== ctx.nodePk) refuse('another node'); return ctx.nodePk; }; const sameUser = () => { if (String(fields.userId ?? '') !== ctx.userId) refuse('another account'); return ctx.userId; }; const nonceNode = () => bytes(fields.nonceNode, 'the node nonce', { min: 16, max: 64 }); switch (kind) { case 'join': return lenPrefixed(PREFIX.join, [ enc(sameNode()), enc(groupId(fields.groupId)), enc(sameUser()), enc(ctx.pkEdB64), enc(ctx.pkXB64), nonceNode(), enc(timestamp(fields.ts)), ]); case 'device_hello': return lenPrefixed(PREFIX.device_hello, [ enc(sameNode()), enc(groupId(fields.groupId)), enc(sameUser()), enc(ctx.pkEdB64), nonceNode(), enc(timestamp(fields.ts)), ]); case 'device_request': { const codeHash = String(fields.codeHash ?? ''); if (!/^[0-9a-f]{64}$/.test(codeHash)) refuse('not a request hash'); return lenPrefixed(PREFIX.device_request, [ enc(sameNode()), enc(sameUser()), enc(ctx.pkEdB64), enc(ctx.pkXB64), enc(codeHash), nonceNode(), enc(timestamp(fields.ts)), ]); } case 'device_add': return lenPrefixed(PREFIX.device_add, [ enc(sameNode()), enc(sameUser()), enc(key32(fields.pkEd, 'the device key')), enc(key32(fields.pkX, 'the device key')), nonceNode(), enc(timestamp(fields.ts)), ]); case 'device_revoke': return lenPrefixed(PREFIX.device_revoke, [ enc(sameNode()), enc(sameUser()), enc(key32(fields.pkEd, 'the device key')), nonceNode(), enc(timestamp(fields.ts)), ]); case 'chat': { const epoch = Number(fields.epoch); if (!Number.isInteger(epoch) || epoch < 0) refuse('not an epoch'); return lenPrefixed(PREFIX.chat, [ enc(groupId(fields.groupId)), enc(epoch), Buffer.from(ctx.pkEdB64, 'base64'), bytes(fields.nonce, 'the message nonce', { min: 12, max: 24 }), bytes(fields.ct, 'the message', { min: 1, max: 8 * 1024 * 1024 }), ]); } case 'admin': { const op = String(fields.op ?? ''); if (!/^[a-z_]{1,32}$/.test(op)) refuse('not an operation'); return lenPrefixed(PREFIX.admin, [ enc(op), enc(sameNode()), enc(groupId(fields.groupId)), enc(text(fields.subject, 'the subject', 16384)), bytes(fields.nonce, 'the challenge nonce', { min: 16, max: 64 }), enc(timestamp(fields.ts)), ]); } default: return refuse(`nothing is signed as "${String(kind).slice(0, 32)}"`); } } module.exports = { transcriptFor };