diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-30 21:04:39 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-30 21:04:39 +0200 |
| commit | 0378e8e0912a1a7e6cea4424e69d524e7afecbf8 (patch) | |
| tree | 4ae94e32d6638b4c2cc1ae4f74cbe5d00c940636 | |
| parent | 0ed56d3a1b4f71cf622d3e27edc87a15ef33c185 (diff) | |
| download | meshbay-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>
17 files changed, 636 insertions, 66 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 }; diff --git a/packages/meshbay-common/src/meshbay_common/device.py b/packages/meshbay-common/src/meshbay_common/device.py index 3a598d0..2d6747f 100644 --- a/packages/meshbay-common/src/meshbay_common/device.py +++ b/packages/meshbay-common/src/meshbay_common/device.py @@ -37,6 +37,7 @@ import hashlib DEVICE_REQUEST_PREFIX = b"meshbay:device_req:v1" DEVICE_ADD_PREFIX = b"meshbay:device_add:v1" DEVICE_HELLO_PREFIX = b"meshbay:device_hello:v1" +DEVICE_REVOKE_PREFIX = b"meshbay:device_revoke:v1" # Same as the join and admin transcripts: interactive exchanges that complete in # milliseconds, so anything older is a replay. @@ -126,6 +127,30 @@ def device_add_transcript( ]) +def device_revoke_transcript( + node_pk_b64: str, + user_id: str, + pk_ed25519_b64: str, + nonce_node: bytes, + ts: int, +) -> bytes: + """ + Signed by a pinned device, retiring one of the account's devices. + + A prefix of its own, never the admission transcript: a signature given to + retire a key would otherwise admit that same key on a node where it is not + pinned yet, and whoever asks a device to sign a retirement could turn it + into an addition. + """ + return _pack(DEVICE_REVOKE_PREFIX, [ + node_pk_b64.encode(), + user_id.encode(), + pk_ed25519_b64.encode(), + nonce_node, + str(ts).encode(), + ]) + + def device_hello_transcript( node_pk_b64: str, group_id: str, diff --git a/packages/meshbay-common/tests/test_js_python_parity.py b/packages/meshbay-common/tests/test_js_python_parity.py index 5bf34aa..f75d60a 100644 --- a/packages/meshbay-common/tests/test_js_python_parity.py +++ b/packages/meshbay-common/tests/test_js_python_parity.py @@ -625,3 +625,104 @@ def test_a_message_does_not_open_under_another_devices_key(chatbox_js): open_message(CHAT_EPOCH_KEY, group_id, epoch, vectors[1]["device_b64"], bytes.fromhex(vectors[0]["nonce"]), bytes.fromhex(vectors[0]["ct"])) + + +# ── Every kind an identity signs, through `transcriptFor` ─────────────────── +# +# The page signs with an identity only by kind (`transcriptFor`), and the +# desktop application builds the same bytes in its main process. The device +# transcripts had no parity check of their own; the retirement one differs +# from the admission one only by its prefix, which is the whole point of it. + +_KINDS_HARNESS = r""" +const fs = require('fs'); +globalThis.window = {}; +const src = fs.readFileSync(process.argv[2], 'utf8'); +const M = new Function(src + '\nreturn { transcriptFor };')(); +const input = JSON.parse(fs.readFileSync(process.argv[3], 'utf8')); +const toHex = (u8) => Array.from(u8).map(b => b.toString(16).padStart(2, '0')).join(''); +const out = {}; +for (const [kind, f] of Object.entries(input.fields)) { + out[kind] = toHex(M.transcriptFor(kind, f, input.own)); +} +try { M.transcriptFor('raw', {}, input.own); out.raw = 'signed'; } +catch (e) { out.raw = String(e.message); } +process.stdout.write(JSON.stringify(out)); +""" + +_OWN = {"pkEdB64": base64.b64encode(b"E" * 32).decode(), + "pkXB64": base64.b64encode(b"X" * 32).decode()} +_OTHER = base64.b64encode(b"O" * 32).decode() +_NONCE = base64.b64encode(b"\x07" * 32).decode() +_KIND_FIELDS = { + "join": {"nodePk": "Tk9ERVBL", "groupId": "g" * 32, "userId": "grenet", + "nonceNode": _NONCE, "ts": 1_700_000_000}, + "device_hello": {"nodePk": "Tk9ERVBL", "groupId": "g" * 32, "userId": "grenet", + "nonceNode": _NONCE, "ts": 1_700_000_000}, + "device_request": {"nodePk": "Tk9ERVBL", "userId": "grenet", "codeHash": "ab" * 32, + "nonceNode": _NONCE, "ts": 1_700_000_000}, + "device_add": {"nodePk": "Tk9ERVBL", "userId": "grenet", "pkEd": _OTHER, + "pkX": _OTHER, "nonceNode": _NONCE, "ts": 1_700_000_000}, + "device_revoke": {"nodePk": "Tk9ERVBL", "userId": "grenet", "pkEd": _OTHER, + "nonceNode": _NONCE, "ts": 1_700_000_000}, + "chat": {"groupId": "g" * 32, "epoch": 4, + "nonce": base64.b64encode(b"\x01" * 12).decode(), + "ct": base64.b64encode(b"ciphertext").decode()}, + "admin": {"op": "root_add", "nodePk": "Tk9ERVBL", "groupId": "g" * 32, + "subject": '{"path":"/café"}', "nonce": _NONCE, "ts": 1_700_000_000}, +} + + +@pytest.fixture(scope="module") +def kinds_js(tmp_path_factory): + d = tmp_path_factory.mktemp("kinds") + (d / "harness.js").write_text(_KINDS_HARNESS) + (d / "input.json").write_text(json.dumps({"fields": _KIND_FIELDS, "own": _OWN})) + proc = subprocess.run(["node", str(d / "harness.js"), str(CRYPTO_JS), str(d / "input.json")], + capture_output=True, text=True, timeout=60) + if proc.returncode != 0: + pytest.fail(f"node harness failed:\n{proc.stderr}") + return json.loads(proc.stdout) + + +def _python_kind(kind): + from meshbay_common.chatbox import signing_transcript + from meshbay_common.device import ( + device_add_transcript, + device_hello_transcript, + device_request_transcript, + device_revoke_transcript, + ) + f = _KIND_FIELDS[kind] + nonce = base64.b64decode(f.get("nonceNode", "")) + ed, x = _OWN["pkEdB64"], _OWN["pkXB64"] + return { + "join": lambda: join_transcript(f["nodePk"], f["groupId"], f["userId"], ed, x, + nonce, f["ts"]), + "device_hello": lambda: device_hello_transcript(f["nodePk"], f["groupId"], f["userId"], + ed, nonce, f["ts"]), + "device_request": lambda: device_request_transcript(f["nodePk"], f["userId"], ed, x, + f["codeHash"], nonce, f["ts"]), + "device_add": lambda: device_add_transcript(f["nodePk"], f["userId"], f["pkEd"], + f["pkX"], nonce, f["ts"]), + "device_revoke": lambda: device_revoke_transcript(f["nodePk"], f["userId"], f["pkEd"], + nonce, f["ts"]), + "chat": lambda: signing_transcript(f["groupId"], f["epoch"], base64.b64decode(ed), + base64.b64decode(f["nonce"]), + base64.b64decode(f["ct"])), + "admin": lambda: admin_transcript(f["op"], f["nodePk"], f["groupId"], f["subject"], + base64.b64decode(f["nonce"]), f["ts"]), + }[kind]() + + +@pytest.mark.parametrize("kind", sorted(_KIND_FIELDS)) +def test_every_signed_kind_is_byte_identical(kind, kinds_js): + assert kinds_js[kind] == _python_kind(kind).hex(), kind + + +def test_a_retirement_is_not_an_admission(kinds_js): + assert kinds_js["device_revoke"] != kinds_js["device_add"] + + +def test_an_unknown_kind_is_not_signed(kinds_js): + assert "nothing is signed as" in kinds_js["raw"] diff --git a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js index 27e97d3..c239cc8 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js @@ -451,6 +451,7 @@ const JOIN_PREFIX = new TextEncoder().encode('meshbay:join:v1'); const DEVICE_REQ_PREFIX = new TextEncoder().encode('meshbay:device_req:v1'); const DEVICE_ADD_PREFIX = new TextEncoder().encode('meshbay:device_add:v1'); const DEVICE_HELLO_PREFIX = new TextEncoder().encode('meshbay:device_hello:v1'); +const DEVICE_REVOKE_PREFIX = new TextEncoder().encode('meshbay:device_revoke:v1'); function joinTranscript(nodePkB64, groupId, userId, pkEdB64, pkXB64, nonceNode, ts) { const enc = new TextEncoder(); @@ -503,6 +504,22 @@ function deviceAddTranscript(nodePkB64, userId, pkEdB64, pkXB64, nonceNode, ts) } /** + * Retiring one of the account's devices. A prefix of its own: were it the + * admission transcript, a signature given to retire a key would admit it. + */ +function deviceRevokeTranscript(nodePkB64, userId, pkEdB64, nonceNode, ts) { + const enc = new TextEncoder(); + const body = _lenPrefixed([ + enc.encode(nodePkB64), enc.encode(userId), enc.encode(pkEdB64), + nonceNode, enc.encode(String(ts)), + ]); + const out = new Uint8Array(DEVICE_REVOKE_PREFIX.length + body.length); + out.set(DEVICE_REVOKE_PREFIX, 0); + out.set(body, DEVICE_REVOKE_PREFIX.length); + return out; +} + +/** * "Which of this account's devices am I?", mirroring * `meshbay_common/device.py:device_hello_transcript`. * @@ -560,6 +577,42 @@ function constantTimeEqual(a, b) { return diff === 0; } +/** + * What an identity signs, by kind. The only way anything here gets signed with + * an identity: a caller names what it is signing and gives the fields, and the + * bytes are built from them — with the identity's own public keys wherever a + * transcript names them. The desktop application builds the same bytes in its + * main process (meshbay-client/src/transcripts.js) and signs nothing else, + * which is what makes a signature from it mean what its kind says. + * + * Bytes cross as base64: `nonceNode`, `nonce`, `ct`. + */ +function transcriptFor(kind, f, own) { + const nonceNode = () => b64decode(f.nonceNode); + switch (kind) { + case 'join': + return joinTranscript(f.nodePk, f.groupId || '', f.userId, own.pkEdB64, own.pkXB64, + nonceNode(), f.ts); + case 'device_hello': + return deviceHelloTranscript(f.nodePk, f.groupId || '', f.userId, own.pkEdB64, + nonceNode(), f.ts); + case 'device_request': + return deviceRequestTranscript(f.nodePk, f.userId, own.pkEdB64, own.pkXB64, + f.codeHash, nonceNode(), f.ts); + case 'device_add': + return deviceAddTranscript(f.nodePk, f.userId, f.pkEd, f.pkX, nonceNode(), f.ts); + case 'device_revoke': + return deviceRevokeTranscript(f.nodePk, f.userId, f.pkEd, nonceNode(), f.ts); + case 'chat': + return chatSigningTranscript(f.groupId || '', f.epoch, b64decode(own.pkEdB64), + b64decode(f.nonce), b64decode(f.ct)); + case 'admin': + return adminTranscript(f.op, f.nodePk, f.groupId || '', f.subject, f.nonce, f.ts); + default: + throw new Error(`Refused: nothing is signed as "${kind}"`); + } +} + /** Verify the node's Ed25519 signature over the handshake transcript (C3). */ async function verifyNodeSignature(nodePkB64, sigB64, transcript) { const raw = b64decode(nodePkB64); @@ -576,6 +629,7 @@ window.MeshBayCrypto = { inviteCreateSubject, tmdbConfigSubject, handshakeTranscript, handshakeProof, webrtcBinding, challengeTranscript, joinTranscript, verifyNodeSignature, constantTimeEqual, deviceRequestTranscript, deviceAddTranscript, deviceHelloTranscript, + deviceRevokeTranscript, transcriptFor, deviceCodeHash, sealChat, openChat, chatSigningTranscript, verifyChatSignature, normalizeCode, diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-admin.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-admin.js index d5226fc..1a5a4c0 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport-admin.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-admin.js @@ -32,9 +32,10 @@ extendTransport(class { const ts = Math.floor(Date.now() / 1000); // group_id is empty: operator authority is node-wide, not per group. - const transcript = C.joinTranscript( - this.nodePk, '', userId, pkEdB64, pkXB64, this._nonceNode, ts); - const sig = await this._identity.sign(transcript); + const sig = await this._identity.signAs('join', { + nodePk: this.nodePk, groupId: '', userId, + nonceNode: C.b64encode(this._nonceNode), ts, + }); const resp = await this._sendAndWait({ type: 'join_request', @@ -107,11 +108,12 @@ extendTransport(class { } if (!signFn) throw new Error('Admin challenge received but no signing key available'); - const transcript = window.MeshBayCrypto.adminTranscript( - challenge.op, challenge.node_pk, challenge.group_id, - challenge.subject, challenge.nonce, challenge.ts); - - const signature = await signFn(transcript); + // The fields, not the bytes: whatever holds the key builds the transcript + // from them (crypto.js, transcriptFor). + const signature = await signFn({ + op: challenge.op, nodePk: challenge.node_pk, groupId: challenge.group_id, + subject: challenge.subject, nonce: challenge.nonce, ts: challenge.ts, + }); console.log('[MeshBay] _authorizeAdminOp: signed', challenge.op, 'op_id=', challenge.op_id, '— sending admin_response'); const ack = await this._sendAndWait({ diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js index f0604ce..e49eb4c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js @@ -172,8 +172,11 @@ extendTransport(class { const { nonce, ct } = await C.sealChat( epochKey, gid, epoch, this.devicePk, plaintext); const device = C.b64decode(this.devicePk); - const sig = C.b64decode(await this._identity.sign( - C.chatSigningTranscript(gid, epoch, device, nonce, ct))); + // The transcript names the signing device as this identity's own key, + // which is what `devicePk` is once the node has been told (device_hello). + const sig = C.b64decode(await this._identity.signAs('chat', { + groupId: gid, epoch, nonce: C.b64encode(nonce), ct: C.b64encode(ct), + })); const msg = await this._sendAndWait({ type: 'chat_msg', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-devices.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-devices.js index 1cb248a..1c6fc78 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport-devices.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-devices.js @@ -105,9 +105,10 @@ extendTransport(class { const { pkEdB64, pkXB64 } = this._identity; const ts = Math.floor(Date.now() / 1000); - const transcript = C.joinTranscript( - this.nodePk, groupId || '', userId, pkEdB64, pkXB64, this._nonceNode, ts); - const sig = await this._identity.sign(transcript); + const sig = await this._identity.signAs('join', { + nodePk: this.nodePk, groupId: groupId || '', userId, + nonceNode: C.b64encode(this._nonceNode), ts, + }); const resp = await this._sendAndWait({ type: 'join_request', @@ -171,9 +172,10 @@ extendTransport(class { const codeHash = await C.deviceCodeHash( C.normalizeCode(code), pkEdB64, pkXB64); const ts = Math.floor(Date.now() / 1000); - const transcript = C.deviceRequestTranscript( - this.nodePk, userId, pkEdB64, pkXB64, codeHash, this._nonceNode, ts); - const sig = await this._identity.sign(transcript); + const sig = await this._identity.signAs('device_request', { + nodePk: this.nodePk, userId, codeHash, + nonceNode: C.b64encode(this._nonceNode), ts, + }); const resp = await this._sendAndWait({ type: 'device_add_request', v: '0.1', @@ -225,9 +227,10 @@ extendTransport(class { async _countersign(userId, codeHash, pkEdB64, pkXB64) { const C = window.MeshBayCrypto; const ts = Math.floor(Date.now() / 1000); - const transcript = C.deviceAddTranscript( - this.nodePk, userId, pkEdB64, pkXB64, this._nonceNode, ts); - const sig = await this._identity.sign(transcript); + const sig = await this._identity.signAs('device_add', { + nodePk: this.nodePk, userId, pkEd: pkEdB64, pkX: pkXB64, + nonceNode: C.b64encode(this._nonceNode), ts, + }); const resp = await this._sendAndWait({ type: 'device_add', v: '0.1', pk_ed25519: pkEdB64, pk_x25519: pkXB64, code_hash: codeHash, ts, sig, @@ -246,9 +249,10 @@ extendTransport(class { async revokeDevice(userId, pkEdB64, pkXB64) { const C = window.MeshBayCrypto; const ts = Math.floor(Date.now() / 1000); - const transcript = C.deviceAddTranscript( - this.nodePk, userId, pkEdB64, pkXB64, this._nonceNode, ts); - const sig = await this._identity.sign(transcript); + const sig = await this._identity.signAs('device_revoke', { + nodePk: this.nodePk, userId, pkEd: pkEdB64, + nonceNode: C.b64encode(this._nonceNode), ts, + }); const resp = await this._sendAndWait({ type: 'device_revoke', v: '0.1', pk_ed25519: pkEdB64, ts, sig, }); diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 17a8e5d..9b86921 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -52,10 +52,13 @@ async function _pkEdFromSk(skPkcs8B64) { */ async function _identityFromKeys(skEdB64, skXB64) { const skXRaw = Uint8Array.from(atob(skXB64), c => c.charCodeAt(0)); + const own = { pkEdB64: await _pkEdFromSk(skEdB64), pkXB64: await _pkFromSk(skXB64) }; return { - pkEdB64: await _pkEdFromSk(skEdB64), - pkXB64: await _pkFromSk(skXB64), - sign: (bytes) => window.MeshBayKeys.signBytes(skEdB64, bytes), + ...own, + // By kind and fields, never over bytes a caller chose (crypto.js, + // transcriptFor) — the same contract the desktop's main process keeps. + signAs: (kind, fields) => window.MeshBayKeys.signBytes( + skEdB64, window.MeshBayCrypto.transcriptFor(kind, fields, own)), async shared(peerPkRaw) { const sk = await crypto.subtle.importKey( 'pkcs8', skXRaw, { name: 'X25519' }, false, ['deriveBits']); @@ -77,7 +80,8 @@ function _nativeIdentityHandle(keys, userId, nodePk, pub) { pkXB64: pub.pkXB64, sealedWith: pub.sealedWith || null, native: true, - sign: (bytes) => keys.sign(userId, nodePk, b64(bytes)), + // The main process builds the bytes from the kind and the fields. + signAs: (kind, fields) => keys.sign(userId, nodePk, kind, fields), async shared(peerPkRaw) { const out = await keys.shared(userId, nodePk, b64(peerPkRaw)); return Uint8Array.from(atob(out), c => c.charCodeAt(0)).buffer; @@ -672,10 +676,10 @@ class MeshBayTransport { set onNeedToken(fn) { this._onNeedToken = fn; } get identity() { return this._identity; } - /** Signs with this node's identity, or null when there is none yet. */ + /** Signs an admin operation with this node's identity, or null when there is none yet. */ get signFn() { const id = this._identity; - return id ? (transcript) => id.sign(transcript) : null; + return id ? (fields) => id.signAs('admin', fields) : null; } /** Set on a first join: the identity created for this node, still to be left with it. */ @@ -1343,10 +1347,10 @@ class MeshBayTransport { // substitution this mechanism exists to close. const pkEdB64 = this._identity.pkEdB64; const ts = Math.floor(Date.now() / 1000); - const transcript = C.deviceHelloTranscript( - this.nodePk, this._groupId || '', this._userId, pkEdB64, - this._nonceNode, ts); - const sig = await this._identity.sign(transcript); + const sig = await this._identity.signAs('device_hello', { + nodePk: this.nodePk, groupId: this._groupId || '', userId: this._userId, + nonceNode: C.b64encode(this._nonceNode), ts, + }); const resp = await this._sendAndWait({ type: 'device_hello', v: '2.0', pk_ed25519: pkEdB64, ts, sig, diff --git a/packages/meshbay-hub/tests/harness/chat_send_probe.py b/packages/meshbay-hub/tests/harness/chat_send_probe.py index e5cfc8b..e7f1dae 100644 --- a/packages/meshbay-hub/tests/harness/chat_send_probe.py +++ b/packages/meshbay-hub/tests/harness/chat_send_probe.py @@ -172,7 +172,9 @@ function makeTransport(name, chatReply) { tp._gekRaw = hex('__GEK_HEX__'); tp.chatEpoch = 1; tp._identity = { pkEdB64: DEVICE_PK_B64, - sign: (bytes) => window.MeshBayKeys.signBytes(SK_ED_B64, bytes) }; + signAs: (kind, fields) => window.MeshBayKeys.signBytes(SK_ED_B64, + window.MeshBayCrypto.transcriptFor(kind, fields, + { pkEdB64: DEVICE_PK_B64 })) }; tp.devicePk = DEVICE_PK_B64; tp._send = (obj) => { log.push('sent ' + obj.type); diff --git a/packages/meshbay-hub/tests/test_desktop_keyring.py b/packages/meshbay-hub/tests/test_desktop_keyring.py index 673a00e..963ee55 100644 --- a/packages/meshbay-hub/tests/test_desktop_keyring.py +++ b/packages/meshbay-hub/tests/test_desktop_keyring.py @@ -62,9 +62,39 @@ const v = JSON.parse(fs.readFileSync(input, 'utf8')); out.sealed_here = ring.sealBundle(v.userId, v.node).bundle; out.playlist_key = ring.playlistKey(v.userId); - // 2. a signature and an X25519 agreement that the other side can check. - const msg = Buffer.from('a transcript'); - out.sig = ring.sign(v.userId, v.node, msg.toString('base64')); + // 2. signatures by kind, built here from fields, for the reference to check; + // and an X25519 agreement that the other side can check. + const ts = Math.floor(Date.now() / 1000); + const nonceNode = Buffer.alloc(32, 7).toString('base64'); + const other = require('crypto').generateKeyPairSync('ed25519').publicKey + .export({ format: 'der', type: 'spki' }).subarray(12).toString('base64'); + const F = { + join: { nodePk: v.node, groupId: 'grp-1', userId: v.userId, nonceNode, ts }, + device_hello: { nodePk: v.node, groupId: 'grp-1', userId: v.userId, nonceNode, ts }, + device_request: { nodePk: v.node, userId: v.userId, codeHash: 'ab'.repeat(32), nonceNode, ts }, + device_add: { nodePk: v.node, userId: v.userId, pkEd: other, pkX: other, nonceNode, ts }, + device_revoke: { nodePk: v.node, userId: v.userId, pkEd: other, nonceNode, ts }, + chat: { groupId: 'grp-1', epoch: 3, nonce: Buffer.alloc(12, 1).toString('base64'), + ct: Buffer.from('ciphertext').toString('base64') }, + admin: { op: 'file_delete', nodePk: v.node, groupId: 'grp-1', subject: 'file-9', + nonce: Buffer.alloc(32, 2).toString('base64'), ts }, + }; + out.fields = F; out.signed = {}; + for (const [kind, f] of Object.entries(F)) { + out.signed[kind] = ring.signAs(v.userId, v.node, kind, f); + } + + const refusal = async (kind, f) => { + try { await ring.signAs(v.userId, v.node, kind, f); return null; } + catch (e) { return e.code || e.message; } + }; + out.refused = { + bytes: await refusal('raw', { bytes: 'YQ==' }), + other_node: await refusal('join', { ...F.join, nodePk: 'T3RoZXJOb2Rl' }), + other_account: await refusal('join', { ...F.join, userId: 'someone-else' }), + stale: await refusal('join', { ...F.join, ts: ts - 3600 }), + }; + const eph = require('crypto').generateKeyPairSync('x25519'); const ephPub = eph.publicKey.export({ format: 'der', type: 'spki' }).subarray(12); out.shared_here = ring.shared(v.userId, v.node, ephPub.toString('base64')); @@ -94,10 +124,16 @@ const v = JSON.parse(fs.readFileSync(input, 'utf8')); out.retired = (() => { try { ring.openBundle(v.userId, 'NODE-R', { bundleEnc: Buffer.from('MBK2' + 'x'.repeat(40)).toString('base64') }); } catch (e) { return e.code; } })(); + out.access_default = ring.browserAccess('someone-else'); + ring.setBrowserAccess(v.userId, false); + const refusedSeal = (fn) => { try { fn(); return null; } catch (e) { return e.message; } }; + out.sealing_while_access_off = { + bundle: refusedSeal(() => ring.sealBundle(v.userId, v.node)), + recovery: refusedSeal(() => ring.sealRecovery(v.userId, v.node, 'A'.repeat(56), v.user)), + }; ring.forgetSession(v.userId); out.after_sign_out = { session: ring.hasSession(v.userId), identity_kept: !!ring.identity(v.userId, v.node) }; - out.access_default = ring.browserAccess('someone-else'); ring.setBrowserAccess(v.userId, false); out.access_after_off = ring.browserAccess(v.userId); out.stored_json = JSON.stringify(store); @@ -155,10 +191,66 @@ def test_the_playlist_key_is_the_pages(out): _reference_master(), "meshbay:playlists:v2") -def test_signatures_and_agreements_check_out_with_the_public_keys(out): +def _reference_transcript(kind, f, pub): + from meshbay_common.adminop import admin_transcript + from meshbay_common.chatbox import signing_transcript + from meshbay_common.device import ( + device_add_transcript, + device_hello_transcript, + device_request_transcript, + device_revoke_transcript, + ) + from meshbay_common.join import join_transcript + nonce_node = base64.b64decode(f.get("nonceNode", "")) + ed, x = pub["pkEdB64"], pub["pkXB64"] + return { + "join": lambda: join_transcript(f["nodePk"], f["groupId"], f["userId"], ed, x, + nonce_node, f["ts"]), + "device_hello": lambda: device_hello_transcript(f["nodePk"], f["groupId"], + f["userId"], ed, nonce_node, f["ts"]), + "device_request": lambda: device_request_transcript( + f["nodePk"], f["userId"], ed, x, f["codeHash"], nonce_node, f["ts"]), + "device_add": lambda: device_add_transcript(f["nodePk"], f["userId"], f["pkEd"], + f["pkX"], nonce_node, f["ts"]), + "device_revoke": lambda: device_revoke_transcript(f["nodePk"], f["userId"], f["pkEd"], + nonce_node, f["ts"]), + "chat": lambda: signing_transcript(f["groupId"], f["epoch"], base64.b64decode(ed), + base64.b64decode(f["nonce"]), + base64.b64decode(f["ct"])), + "admin": lambda: admin_transcript(f["op"], f["nodePk"], f["groupId"], f["subject"], + base64.b64decode(f["nonce"]), f["ts"]), + }[kind]() + + +def test_every_kind_signs_the_specifications_bytes(out): + """ + The keyring builds the transcript itself from fields; what it signs must be + exactly what meshbay_common builds, or the node refuses every join, chat + line and admin operation from the desktop application. + """ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey - Ed25519PublicKey.from_public_bytes(base64.b64decode(out["minted_pub"]["pkEdB64"])).verify( - base64.b64decode(out["sig"]), b"a transcript") + pub = out["minted_pub"] + key = Ed25519PublicKey.from_public_bytes(base64.b64decode(pub["pkEdB64"])) + assert set(out["signed"]) == {"join", "device_hello", "device_request", "device_add", + "device_revoke", "chat", "admin"} + for kind, sig in out["signed"].items(): + key.verify(base64.b64decode(sig), _reference_transcript(kind, out["fields"][kind], pub)) + + +def test_the_page_names_a_kind_and_never_the_bytes(out): + r = out["refused"] + assert "nothing is signed as" in r["bytes"] + assert "another node" in r["other_node"] + assert "another account" in r["other_account"] + assert "not now" in r["stale"] + + +def test_nothing_is_sealed_for_a_browser_while_browser_access_is_off(out): + for what, err in out["sealing_while_access_off"].items(): + assert err and "browser access is off" in err, what + + +def test_agreements_check_out_with_the_public_keys(out): assert out["shared_here"] == out["shared_there"] diff --git a/packages/meshbay-node/src/meshbay_node/roster.py b/packages/meshbay-node/src/meshbay_node/roster.py index 8144373..0116aaa 100644 --- a/packages/meshbay-node/src/meshbay_node/roster.py +++ b/packages/meshbay-node/src/meshbay_node/roster.py @@ -512,19 +512,22 @@ class Roster: await self._db.commit() return expires - async def take_device_request(self, code_hash: str, - user_id: str) -> dict | None: + async def take_device_request(self, code_hash: str, user_id: str, + pk_ed25519: str, pk_x25519: str) -> dict | None: """ - Claim a pending request by its hash, for this account only. + Claim a pending request by its hash, for this account and these keys. Single use and scoped to the account: a request filed for one person cannot be redeemed by another even with the code, and a code that has - been spent is gone. + been spent is gone. Scoped to the keys too: the request is what the new + device filed and signed, so an approval redeeming it admits those keys + and no others. """ assert self._db async with self._db.execute( "SELECT * FROM device_requests WHERE code_hash = ? AND user_id = ? " - "AND expires_at > ?", (code_hash, user_id, _now()) + "AND pk_ed25519 = ? AND pk_x25519 = ? AND expires_at > ?", + (code_hash, user_id, pk_ed25519, pk_x25519, _now()) ) as cur: row = await cur.fetchone() if row is None: diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc/admission.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc/admission.py index f94cade..20ebc79 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc/admission.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc/admission.py @@ -20,6 +20,7 @@ from meshbay_common.device import ( device_add_transcript, device_hello_transcript, device_request_transcript, + device_revoke_transcript, ) from meshbay_common.join import JOIN_TTL, ROLE_MEMBER, ROLE_OPERATOR, join_transcript from meshbay_common.protocol import MNP @@ -516,10 +517,17 @@ class AdmissionMixin: pk_ed_b64 = str(msg.get("pk_ed25519", "")) pk_x_b64 = str(msg.get("pk_x25519", "")) + code_hash = str(msg.get("code_hash", "")) ts = int(msg.get("ts", 0)) if not (pk_ed_b64 and pk_x_b64): self._send({"type": "error", "detail": "Missing device keys"}) return + # An approval answers a request the new device filed and signed with + # these keys. Without one, a countersignature alone — obtained however + # it was — would admit any key its holder chose. + if not code_hash: + self._send({"type": "error", "detail": "No pending request named"}) + return if abs(time.time() - ts) > DEVICE_TTL: self._send({"type": "error", "detail": "Approval expired"}) return @@ -543,9 +551,8 @@ class AdmissionMixin: # Spend the request. Single use: an approval cannot be replayed, and a # code that was used is gone whatever else happens next. - code_hash = str(msg.get("code_hash", "")) - if code_hash and not await roster.take_device_request( - code_hash, self._user_id): + if not await roster.take_device_request( + code_hash, self._user_id, pk_ed_b64, pk_x_b64): self._send({"type": "error", "detail": "That request is no longer pending"}) return @@ -696,10 +703,9 @@ class AdmissionMixin: self._send({"type": "error", "detail": "No such device"}) return - transcript = device_add_transcript( + transcript = device_revoke_transcript( node_pk_b64=self._node_pk_b64(), user_id=self._user_id, - pk_ed25519_b64=target, pk_x25519_b64=victim["pk_x25519"], - nonce_node=self._nonce_node, ts=ts) + pk_ed25519_b64=target, nonce_node=self._nonce_node, ts=ts) signer = await self._verify_device_signer(roster, transcript, msg.get("sig", "")) if signer is None: diff --git a/packages/meshbay-node/tests/test_device_linking.py b/packages/meshbay-node/tests/test_device_linking.py index 53387ca..344c252 100644 --- a/packages/meshbay-node/tests/test_device_linking.py +++ b/packages/meshbay-node/tests/test_device_linking.py @@ -30,6 +30,7 @@ from meshbay_common.device import ( device_add_transcript, device_code_hash, device_request_transcript, + device_revoke_transcript, ) from meshbay_common.join import ROLE_MEMBER from meshbay_common.protocol import MNP @@ -227,8 +228,8 @@ async def test_the_new_device_cannot_approve_itself(tmp_path, roster): sk_new, pk_new_ed, pk_new_x = _keys() session = await _session(tmp_path, roster) - await _file_request(session, sk_new, pk_new_ed, pk_new_x, generate_code()) - await _approve(session, sk_new, pk_new_ed, pk_new_x) + code_hash = await _file_request(session, sk_new, pk_new_ed, pk_new_x, generate_code()) + await _approve(session, sk_new, pk_new_ed, pk_new_x, code_hash=code_hash) assert _last(session)["type"] == "error" assert await roster.find_device("alice", pk_new_ed) is None @@ -240,10 +241,11 @@ async def test_a_stranger_cannot_approve(tmp_path, roster): await roster.pin_identity("alice", "alice", pk_old_ed, pk_old_x, "code") sk_bob, pk_bob_ed, pk_bob_x = _keys() await roster.pin_identity("bob", "bob", pk_bob_ed, pk_bob_x, "code") - _, pk_new_ed, pk_new_x = _keys() + sk_new, pk_new_ed, pk_new_x = _keys() session = await _session(tmp_path, roster) - await _approve(session, sk_bob, pk_new_ed, pk_new_x) + code_hash = await _file_request(session, sk_new, pk_new_ed, pk_new_x, generate_code()) + await _approve(session, sk_bob, pk_new_ed, pk_new_x, code_hash=code_hash) assert _last(session)["type"] == "error" assert await roster.find_device("alice", pk_new_ed) is None @@ -260,9 +262,10 @@ async def test_a_revoked_device_cannot_admit_its_replacement(tmp_path, roster): await roster.pin_identity("alice", "alice", pk_keep_ed, pk_keep_x, "device") await roster.revoke_device("alice", pk_lost_ed) - _, pk_new_ed, pk_new_x = _keys() + sk_new, pk_new_ed, pk_new_x = _keys() session = await _session(tmp_path, roster) - await _approve(session, sk_lost, pk_new_ed, pk_new_x) + code_hash = await _file_request(session, sk_new, pk_new_ed, pk_new_x, generate_code()) + await _approve(session, sk_lost, pk_new_ed, pk_new_x, code_hash=code_hash) assert _last(session)["type"] == "error" assert await roster.find_device("alice", pk_new_ed) is None @@ -416,10 +419,9 @@ async def test_your_last_device_cannot_be_revoked(tmp_path, roster): session = await _session(tmp_path, roster) ts = int(time.time()) - transcript = device_add_transcript( + transcript = device_revoke_transcript( node_pk_b64=session._node_pk_b64(), user_id="alice", - pk_ed25519_b64=pk_only_ed, pk_x25519_b64=pk_only_x, - nonce_node=NONCE, ts=ts) + pk_ed25519_b64=pk_only_ed, nonce_node=NONCE, ts=ts) await session._do_device_revoke({ "pk_ed25519": pk_only_ed, "ts": ts, "sig": base64.b64encode(sk_only.sign(transcript)).decode()}) @@ -438,3 +440,93 @@ async def test_unpinning_an_account_takes_every_device(tmp_path, roster): assert len(await roster.list_devices("alice")) == 3 await roster.unpin("alice") assert await roster.list_devices("alice") == [] + + +# ── An approval is an answer to a request, and nothing else ───────────────── + +async def test_a_countersignature_without_a_request_admits_nothing(tmp_path, roster): + """ + A pinned device's signature over keys nobody asked to add. Whoever obtained + it — a page that got a device to sign — must not be able to admit a key of + their choosing with it. + """ + sk_old, pk_old_ed, pk_old_x = _keys() + await roster.pin_identity("alice", "alice", pk_old_ed, pk_old_x, "code") + _, pk_new_ed, pk_new_x = _keys() + + session = await _session(tmp_path, roster) + await _approve(session, sk_old, pk_new_ed, pk_new_x) + + assert _last(session)["type"] == "error" + assert await roster.find_device("alice", pk_new_ed) is None + + +async def test_a_request_admits_only_the_keys_that_filed_it(tmp_path, roster): + """One device's pending request is not a ticket for another key.""" + sk_old, pk_old_ed, pk_old_x = _keys() + await roster.pin_identity("alice", "alice", pk_old_ed, pk_old_x, "code") + sk_asking, pk_asking_ed, pk_asking_x = _keys() + _, pk_other_ed, pk_other_x = _keys() + + session = await _session(tmp_path, roster) + code_hash = await _file_request(session, sk_asking, pk_asking_ed, pk_asking_x, + generate_code()) + await _approve(session, sk_old, pk_other_ed, pk_other_x, code_hash=code_hash) + + assert _last(session)["type"] == "error" + assert await roster.find_device("alice", pk_other_ed) is None + # And the request was not spent by the attempt. + await _approve(session, sk_old, pk_asking_ed, pk_asking_x, code_hash=code_hash) + assert _last(session)["type"] == MNP.DEVICE_ADD_ACK + + +async def test_a_retirement_signature_admits_nothing(tmp_path, roster): + """ + Retiring and admitting are signed under different prefixes: a signature + given to retire a key cannot be presented as the approval of that key. + """ + sk_old, pk_old_ed, pk_old_x = _keys() + await roster.pin_identity("alice", "alice", pk_old_ed, pk_old_x, "code") + sk_new, pk_new_ed, pk_new_x = _keys() + + session = await _session(tmp_path, roster) + code_hash = await _file_request(session, sk_new, pk_new_ed, pk_new_x, generate_code()) + ts = int(time.time()) + retire = device_revoke_transcript( + node_pk_b64=session._node_pk_b64(), user_id="alice", + pk_ed25519_b64=pk_new_ed, nonce_node=NONCE, ts=ts) + await session._do_device_add({ + "pk_ed25519": pk_new_ed, "pk_x25519": pk_new_x, "ts": ts, + "code_hash": code_hash, + "sig": base64.b64encode(sk_old.sign(retire)).decode(), + }) + + assert _last(session)["type"] == "error" + assert await roster.find_device("alice", pk_new_ed) is None + + +async def test_a_device_is_retired_with_the_retirement_signature(tmp_path, roster): + sk_a, pk_a_ed, pk_a_x = _keys() + _, pk_b_ed, pk_b_x = _keys() + await roster.pin_identity("alice", "alice", pk_a_ed, pk_a_x, "code") + await roster.pin_identity("alice", "alice", pk_b_ed, pk_b_x, "device") + session = await _session(tmp_path, roster) + + ts = int(time.time()) + admit = device_add_transcript( + node_pk_b64=session._node_pk_b64(), user_id="alice", + pk_ed25519_b64=pk_b_ed, pk_x25519_b64=pk_b_x, nonce_node=NONCE, ts=ts) + await session._do_device_revoke({ + "pk_ed25519": pk_b_ed, "ts": ts, + "sig": base64.b64encode(sk_a.sign(admit)).decode()}) + assert _last(session)["type"] == "error", "an admission signature retired a device" + assert await roster.find_device("alice", pk_b_ed) is not None + + retire = device_revoke_transcript( + node_pk_b64=session._node_pk_b64(), user_id="alice", + pk_ed25519_b64=pk_b_ed, nonce_node=NONCE, ts=ts) + await session._do_device_revoke({ + "pk_ed25519": pk_b_ed, "ts": ts, + "sig": base64.b64encode(sk_a.sign(retire)).decode()}) + assert _last(session)["type"] != "error", _last(session) + assert await roster.find_device("alice", pk_b_ed) is None diff --git a/packages/meshbay-node/tests/test_group_roster.py b/packages/meshbay-node/tests/test_group_roster.py index 74908e7..8028bed 100644 --- a/packages/meshbay-node/tests/test_group_roster.py +++ b/packages/meshbay-node/tests/test_group_roster.py @@ -81,8 +81,13 @@ async def _add_device(session, roster, approver_sk, approver_pk, new_pk, new_px, node_pk_b64=session._node_pk_b64(), user_id=user_id, pk_ed25519_b64=new_pk, pk_x25519_b64=new_px, nonce_node=NONCE, ts=ts) session._user_id = user_id + # The request the new device files first; an approval answers one. + code_hash = "c" * 64 + await roster.file_device_request(user_id=user_id, username=user_id, + pk_ed25519=new_pk, pk_x25519=new_px, + code_hash=code_hash, ttl=600) await session._do_device_add({ - "pk_ed25519": new_pk, "pk_x25519": new_px, "ts": ts, + "pk_ed25519": new_pk, "pk_x25519": new_px, "ts": ts, "code_hash": code_hash, "sig": base64.b64encode(approver_sk.sign(transcript)).decode(), }) return ts |