Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions relay/crypto/impls.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions relay/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

32 changes: 24 additions & 8 deletions sdk-js/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});

Expand Down Expand Up @@ -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(
Expand Down
62 changes: 62 additions & 0 deletions sdk-js/test/crypto.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]) {
Expand Down
Loading