From 0378e8e0912a1a7e6cea4424e69d524e7afecbf8 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Wed, 30 Sep 2026 21:04:39 +0200 Subject: fix: an identity signs a named kind, and a device approval answers a request The desktop main process builds every transcript itself from fields (transcripts.js) and signs no raw bytes; the page's identity has the same contract (crypto.js transcriptFor). The keyring seals no bundle while browser access is off. On the node, device_add must redeem a pending request filed by the same keys, and device_revoke is signed under its own prefix (meshbay:device_revoke:v1), so a retirement signature admits nothing. Co-Authored-By: Claude Opus 5.5 --- packages/meshbay-client/src/transcripts.js | 159 +++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 packages/meshbay-client/src/transcripts.js (limited to 'packages/meshbay-client/src/transcripts.js') diff --git a/packages/meshbay-client/src/transcripts.js b/packages/meshbay-client/src/transcripts.js new file mode 100644 index 0000000..0b6d0c0 --- /dev/null +++ b/packages/meshbay-client/src/transcripts.js @@ -0,0 +1,159 @@ +/** + * 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 }; -- cgit v1.2.3