diff options
Diffstat (limited to 'packages/meshbay-client')
| -rw-r--r-- | packages/meshbay-client/src/keyring.js | 20 | ||||
| -rw-r--r-- | packages/meshbay-client/src/main.js | 4 | ||||
| -rw-r--r-- | packages/meshbay-client/src/preload.js | 4 | ||||
| -rw-r--r-- | packages/meshbay-client/src/transcripts.js | 159 |
4 files changed, 182 insertions, 5 deletions
diff --git a/packages/meshbay-client/src/keyring.js b/packages/meshbay-client/src/keyring.js index 1df2860..4fb6eac 100644 --- a/packages/meshbay-client/src/keyring.js +++ b/packages/meshbay-client/src/keyring.js @@ -19,6 +19,7 @@ 'use strict'; const crypto = require('node:crypto'); +const { transcriptFor } = require('./transcripts.js'); // keyderive.js: the same numbers, or no bundle opens across the two. const ARGON2 = { memory: 131072, passes: 3, parallelism: 1, tagLength: 32 }; @@ -110,6 +111,14 @@ function createKeyring({ load, save, argon2 }) { pkEdB64: b64(rawPublic(privateFrom(id.ed))), pkXB64: b64(rawPublic(privateFrom(id.x))), }); + // A bundle is a copy of an identity for a browser to open with the + // passphrase. With browser access off none may exist, whatever the page asks: + // the page is where hostile content is parsed, and one bundle left on a node + // is all a passphrase-only sign-in on the web needs. + const accessOn = (userId) => state().access[userId] !== false; + const needAccess = (userId) => { + if (!accessOn(userId)) throw new Error('Refused: browser access is off for this account'); + }; const keep = (userId, nodePk, id) => { const s = state(); s.identities[userId] = s.identities[userId] || {}; @@ -195,6 +204,7 @@ function createKeyring({ load, save, argon2 }) { /** The identity sealed for its node, under `M` (or the pending one). */ sealBundle(userId, nodePk, { pending: usePending = false } = {}) { + needAccess(userId); const { m, v } = master(userId, { usePending }); const bundle = seal(stored(userId, nodePk), hkdf(m, `meshbay:bundle:v3|node|${nodePk}`), userId, nodePk, v); @@ -202,6 +212,7 @@ function createKeyring({ load, save, argon2 }) { }, /** The recovery copy: sealed under the recovery key, owing nothing to `M`. */ sealRecovery(userId, nodePk, mnemonic, username) { + needAccess(userId); const rk = hkdf(fromMnemonic(mnemonic), `meshbay:recovery:v1:${username}`); return seal(stored(userId, nodePk), rk, userId, nodePk, 0); }, @@ -212,8 +223,11 @@ function createKeyring({ load, save, argon2 }) { }, currentFingerprint: (userId) => fingerprint(master(userId).m), - sign(userId, nodePk, bytesB64) { - return b64(crypto.sign(null, unb64(bytesB64), privateFrom(stored(userId, nodePk).ed))); + /** Sign what `kind` names, built from `fields` (transcripts.js). */ + signAs(userId, nodePk, kind, fields) { + const id = stored(userId, nodePk); + const transcript = transcriptFor(kind, fields, { userId, nodePk, ...publicOf(id) }); + return b64(crypto.sign(null, transcript, privateFrom(id.ed))); }, shared(userId, nodePk, peerPkB64) { const publicKey = crypto.createPublicKey({ @@ -228,7 +242,7 @@ function createKeyring({ load, save, argon2 }) { // Whether this account leaves bundles on nodes for a browser to open. An // account created here says no until the person says yes (natively, in // main.js); any other account keeps what it always had. - browserAccess: (userId) => state().access[userId] !== false, + browserAccess: accessOn, setBrowserAccess(userId, on) { const s = state(); s.access[userId] = Boolean(on); diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js index 9a08e96..309d5f6 100644 --- a/packages/meshbay-client/src/main.js +++ b/packages/meshbay-client/src/main.js @@ -1037,7 +1037,9 @@ function registerBridge() { keyring.sealRecovery(uid(u), npk(n), String(mnemonic || ''), String(username || ''))); handle('keys:mark-sealed', (_e, u, n, fp) => keyring.markSealed(uid(u), npk(n), String(fp || ''))); handle('keys:fingerprint', (_e, u) => keyring.currentFingerprint(uid(u))); - handle('keys:sign', (_e, u, n, bytes) => keyring.sign(uid(u), npk(n), String(bytes || ''))); + // By kind and fields: the page never names the bytes (transcripts.js). + handle('keys:sign', (_e, u, n, kind, fields) => keyring.signAs( + uid(u), npk(n), String(kind || ''), fields && typeof fields === 'object' ? fields : {})); handle('keys:shared', (_e, u, n, peer) => keyring.shared(uid(u), npk(n), String(peer || ''))); handle('keys:playlist-key', (_e, u) => keyring.playlistKey(uid(u))); handle('keys:browser-access', (_e, u) => keyring.browserAccess(uid(u))); diff --git a/packages/meshbay-client/src/preload.js b/packages/meshbay-client/src/preload.js index 469bc48..e9c34d2 100644 --- a/packages/meshbay-client/src/preload.js +++ b/packages/meshbay-client/src/preload.js @@ -98,7 +98,9 @@ contextBridge.exposeInMainWorld('meshbay', { sealRecovery: (u, n, m, name) => ipcRenderer.invoke('keys:seal-recovery', u, n, m, name), markSealed: (u, n, fp) => ipcRenderer.invoke('keys:mark-sealed', u, n, fp), fingerprint: (u) => ipcRenderer.invoke('keys:fingerprint', u), - sign: (u, n, bytes) => ipcRenderer.invoke('keys:sign', u, n, bytes), + // A kind and its fields, never bytes: the main process builds what it + // signs (transcripts.js). + sign: (u, n, kind, fields) => ipcRenderer.invoke('keys:sign', u, n, kind, fields), shared: (u, n, peer) => ipcRenderer.invoke('keys:shared', u, n, peer), playlistKey: (u) => ipcRenderer.invoke('keys:playlist-key', u), browserAccess: (u) => ipcRenderer.invoke('keys:browser-access', u), 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 }; |