aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-client/src/transcripts.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-30 21:04:39 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-30 21:04:39 +0200
commit0378e8e0912a1a7e6cea4424e69d524e7afecbf8 (patch)
tree4ae94e32d6638b4c2cc1ae4f74cbe5d00c940636 /packages/meshbay-client/src/transcripts.js
parent0ed56d3a1b4f71cf622d3e27edc87a15ef33c185 (diff)
downloadmeshbay-0378e8e0912a1a7e6cea4424e69d524e7afecbf8.tar.gz
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 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-client/src/transcripts.js')
-rw-r--r--packages/meshbay-client/src/transcripts.js159
1 files changed, 159 insertions, 0 deletions
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 };