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 --- .../meshbay-hub/src/meshbay_hub/static/crypto.js | 54 +++++++++++ .../src/meshbay_hub/static/transport-admin.js | 18 ++-- .../src/meshbay_hub/static/transport-chat.js | 7 +- .../src/meshbay_hub/static/transport-devices.js | 28 +++--- .../src/meshbay_hub/static/transport.js | 24 +++-- .../meshbay-hub/tests/harness/chat_send_probe.py | 4 +- packages/meshbay-hub/tests/test_desktop_keyring.py | 106 +++++++++++++++++++-- 7 files changed, 201 insertions(+), 40 deletions(-) (limited to 'packages/meshbay-hub') 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(); @@ -502,6 +503,22 @@ function deviceAddTranscript(nodePkB64, userId, pkEdB64, pkXB64, nonceNode, ts) return out; } +/** + * 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"] -- cgit v1.2.3