From 60673c1859de7f38c8adac2a53d004030f6be710 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Apr 2026 16:56:23 +0000 Subject: [PATCH 1/2] fix(sdk-js): verify ML-DSA-65 signatures and align senderPub with spec Two related post-quantum bugs in GhostPipe: 1. _decrypt() built the message-to-verify but never called sig.verify(). Every signed blob was accepted regardless of signature validity, so the post-quantum authentication claim was effectively bypassed. 2. _encrypt() wrote the KEM public key into the wire-format senderPub field for signed blobs. The wire-format-v1 spec test vectors and the Python reference SDK both place the *signing* public key there so the receiver can verify the embedded signature without an out-of-band lookup. With the KEM pubkey there, no receiver could ever verify authenticity. Fix: - _encrypt now writes sig_pub into senderPub when sigId != 0x0000 (and keeps kem_pub for anonymous blobs as a stable opaque sender id). - _decrypt now actually calls sig.verify() and throws SignatureError on any failure or exception inside the verifier. Regression tests: - tampered-signature blob is rejected with SignatureError. - signed blob's senderPub is exactly 1952 bytes (ML-DSA-65 pubkey size), not 1184 (ML-KEM-768 pubkey size). The relay/ package-lock change is the automatic version-field sync npm performed on install; no dependency graph change. --- relay/package-lock.json | 4 +-- sdk-js/index.js | 32 +++++++++++++++----- sdk-js/test/crypto.test.js | 62 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 10 deletions(-) diff --git a/relay/package-lock.json b/relay/package-lock.json index 7e8960be..b0f3ac19 100644 --- a/relay/package-lock.json +++ b/relay/package-lock.json @@ -1,12 +1,12 @@ { "name": "paramant-relay", - "version": "2.4.5", + "version": "2.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paramant-relay", - "version": "2.4.5", + "version": "2.5.0", "dependencies": { "@noble/post-quantum": "^0.6.1", "argon2": "^0.44.0", diff --git a/sdk-js/index.js b/sdk-js/index.js index ddcc1a65..d05ce3c9 100644 --- a/sdk-js/index.js +++ b/sdk-js/index.js @@ -542,22 +542,28 @@ export class GhostPipe { )); // Sender key material and signature. + // Per docs/wire-format-v1.md: when sigId != 0x0000, senderPub is the + // sender's *signing* public key so the receiver can verify the embedded + // signature without an out-of-band lookup. For anonymous blobs we keep + // the KEM pubkey there as a stable, opaque sender identifier. const kp = await this._loadKeypair(); - const senderKemPub = hexToU8(kp.kem_pub); + const senderPubBytes = sigId !== SIG.NONE + ? hexToU8(kp.sig_pub) + : hexToU8(kp.kem_pub); let signature; if (sigId !== SIG.NONE) { const sig = sigEngine(sigId); if (!kp.sig_priv) throw new SignatureError('sigId != 0x0000 requires a device signing keypair'); const sigPriv = hexToU8(kp.sig_priv); - // Sign ctKem || senderKemPub || nonce || ct || aad - const msg = concat(ctKem, senderKemPub, nonce, ct, aad); + // Sign ctKem || senderPub || nonce || ct || aad — must match _decrypt. + const msg = concat(ctKem, senderPubBytes, nonce, ct, aad); signature = sig.sign(msg, sigPriv); } const core = wireEncode({ kemId, sigId, flags: 0x00, - ctKem, senderPub: senderKemPub, signature, + ctKem, senderPub: senderPubBytes, signature, nonce, ciphertext: ct, }); @@ -591,13 +597,23 @@ export class GhostPipe { ); const aad = buildAAD({ kemId: parsed.kemId, sigId: parsed.sigId, flags: parsed.flags, chunkIndex: 0 }); - // Verify signature if present. + // Verify signature if present. Sender-key pinning (TOFU) is enforced + // separately on send() via _tofuCheck — here we only enforce that the + // signature is cryptographically valid for the senderPub carried in + // the blob. Pre-fix this branch silently accepted any bytes in the + // signature field. if (parsed.sigId !== SIG.NONE) { const sig = sigEngine(parsed.sigId); const msg = concat(parsed.ctKem, parsed.senderPub, parsed.nonce, parsed.ciphertext, aad); - // Caller is responsible for checking senderPub vs expected sender's on-file signing pubkey. - // Here we only verify the signature is valid for the senderPub carried in the blob. - // Sender authenticity (pinning) is enforced via TOFU on the send() path's fetched pubkey. + let valid = false; + try { + valid = sig.verify(parsed.signature, msg, parsed.senderPub); + } catch (e) { + throw new SignatureError(`${sig.name} signature verification raised: ${e.message}`); + } + if (!valid) { + throw new SignatureError(`${sig.name} signature did not verify against senderPub`); + } } return new Uint8Array(await crypto.subtle.decrypt( diff --git a/sdk-js/test/crypto.test.js b/sdk-js/test/crypto.test.js index 984afdd9..392ab1f5 100644 --- a/sdk-js/test/crypto.test.js +++ b/sdk-js/test/crypto.test.js @@ -107,6 +107,68 @@ test('GhostPipe stores v3 keypair with real ML-KEM-768 sizes', async () => { assert.equal(kp.sig_pub.length, 1952 * 2); }); +// Pre-fix, signed blobs were accepted on _decrypt without ever calling +// sig.verify — every "signature" was a no-op. This regression test flips a +// bit in the signature region and asserts decryption now fails loudly. +test('GhostPipe._decrypt rejects a tampered ML-DSA-65 signature (post-quantum auth)', async () => { + const { decode: wireDecode } = await import('../src/wire-format.js'); + const { publicKey, secretKey } = ml_kem768.keygen(); + const pubHex = Buffer.from(publicKey).toString('hex'); + + const sender = new GhostPipe({ + apiKey: 'pgp_test', device: 'sender-tamper', + relay: 'http://x', checkCapabilities: false, + }); + await sender._loadKeypair(); + const plaintext = new TextEncoder().encode('do not accept forged sigs'); + const { blob } = await sender._encrypt(plaintext, pubHex, { padBlock: 16384 }); + + // Locate the signature region inside the (unpadded prefix of the) blob and + // flip the first byte. wireDecode walks the same bytes the receiver does. + const parsed = wireDecode(blob); + // sig sits after: header(10) + 4+ctKem + 4+senderPub + 4 (sigLen) — flip + // the first signature byte in-place. + const sigOffset = 10 + 4 + parsed.ctKem.length + 4 + parsed.senderPub.length + 4; + blob[sigOffset] ^= 0xFF; + + const receiver = new GhostPipe({ + apiKey: 'pgp_test', device: 'receiver-tamper', + relay: 'http://x', checkCapabilities: false, + }); + receiver._keypair = { + version: 3, device: 'receiver-tamper', + kemId: KEM.ML_KEM_768, sigId: SIG.ML_DSA_65, + kem_pub: pubHex, kem_priv: Buffer.from(secretKey).toString('hex'), + sig_pub: '', sig_priv: '', + }; + await assert.rejects( + () => receiver._decrypt(blob), + (err) => err.name === 'SignatureError' + ); +}); + +// Wire-format-v1 + Python SDK interop: for sigId != 0x0000 the senderPub +// field MUST carry the sender's signing public key (1952 B for ML-DSA-65), +// not the KEM public key (1184 B). Pre-fix this was the KEM pubkey, which +// made every signed blob un-verifiable. +test('GhostPipe._encrypt: signed blob senderPub is the ML-DSA-65 signing pubkey (1952 bytes)', async () => { + const { decode: wireDecode } = await import('../src/wire-format.js'); + const { publicKey } = ml_kem768.keygen(); + const pubHex = Buffer.from(publicKey).toString('hex'); + + const sender = new GhostPipe({ + apiKey: 'pgp_test', device: 'sender-pubsize', + relay: 'http://x', checkCapabilities: false, + }); + await sender._loadKeypair(); + const { blob } = await sender._encrypt( + new TextEncoder().encode('size check'), pubHex, { padBlock: 16384 } + ); + const parsed = wireDecode(blob); + assert.equal(parsed.sigId, SIG.ML_DSA_65); + assert.equal(parsed.senderPub.length, 1952, 'senderPub must be the ML-DSA-65 signing pubkey'); +}); + // Pre-fix, _encrypt with padBlock > 65536 threw QuotaExceededError because // crypto.getRandomValues caps at 65536 bytes per call. fillRandom now chunks. for (const padBlock of [65536, 131072, 5 * 1024 * 1024]) { From 4209dc8806061f08eb4ccda5412a67a9bb32c3b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Apr 2026 17:26:50 +0000 Subject: [PATCH 2/2] test(relay): close ML-KEM-768 coverage gap for wrong-ciphertext-size ML-KEM-512 and ML-KEM-1024 each have a "rejects wrong ciphertext size" assertion in their standalone *.test.js files. ML-KEM-768 is tested in the consolidated impls.test.js, which had every other parity test but was missing this one. Add it for symmetry; the impl already validates. --- relay/crypto/impls.test.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/relay/crypto/impls.test.js b/relay/crypto/impls.test.js index 5e566ecf..10e02430 100644 --- a/relay/crypto/impls.test.js +++ b/relay/crypto/impls.test.js @@ -23,6 +23,11 @@ test("ML-KEM-768 rejects wrong public key size", () => { assert.throws(() => mlkem768.encapsulate(Buffer.alloc(100))); }); +test("ML-KEM-768 rejects wrong ciphertext size", () => { + const { secretKey } = mlkem768.generateKeyPair(); + assert.throws(() => mlkem768.decapsulate(Buffer.alloc(100), secretKey)); +}); + test("ML-DSA-65 keygen produces expected sizes", () => { const { publicKey, secretKey } = mldsa65.generateKeyPair(); assert.strictEqual(publicKey.length, 1952);