aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-client/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-client/src')
-rw-r--r--packages/meshbay-client/src/keyring.js20
-rw-r--r--packages/meshbay-client/src/main.js4
-rw-r--r--packages/meshbay-client/src/preload.js4
-rw-r--r--packages/meshbay-client/src/transcripts.js159
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 };