diff --git a/README.md b/README.md index fde8b9b5..5f7f2622 100644 --- a/README.md +++ b/README.md @@ -387,16 +387,18 @@ The relay is **untrusted by design** — it never holds a decryption key. | What a compromised relay can do | What it cannot do | |---------------------------------|-------------------| | Deny service | Read file contents | -| Learn transfer timing | Substitute a registered public key | -| See blob sizes (fixed 5 MB) | Decrypt any stored ciphertext | +| Learn transfer timing | Decrypt any stored ciphertext | +| See blob sizes (fixed 5 MB) | Substitute a registered public key once a fingerprint has been verified out-of-band (TOFU on first contact — verify the recipient's fingerprint via a separate channel before sending) | **Crypto stack:** | Layer | Standard | |-------|----------| | Key encapsulation | ML-KEM-768 · NIST FIPS 203 | +| Hybrid KEM (browser path) | ML-KEM-768 + ECDH P-256, combined via HKDF-SHA256 | | Symmetric | AES-256-GCM · NIST SP 800-38D | -| Signatures | ML-DSA-65 · NIST FIPS 204 | +| Signatures (relay STH / receipts) | ML-DSA-65 · NIST FIPS 204 | +| Signatures (client, SDK only) | ML-DSA-65 over `ctKem ‖ senderPub ‖ nonce ‖ ct ‖ aad` (Node/Python SDK; browser ParaShare path does not yet sign client-side) | | Key derivation | HKDF-SHA256 · RFC 5869 | | Password blobs | Argon2id · RFC 9106 | | Crypto runtime | Rust/WASM — browser-side encryption runs in native code | diff --git a/crypto-wasm/Cargo.toml b/crypto-wasm/Cargo.toml index 5b3cff55..cf278b04 100644 --- a/crypto-wasm/Cargo.toml +++ b/crypto-wasm/Cargo.toml @@ -25,3 +25,12 @@ zeroize = "1" [profile.release] opt-level = "s" lto = true + +# Disable wasm-pack's default wasm-opt step. wasm-opt requires downloading the +# binaryen release tarball at build time, which fails in sandboxed/offline +# environments and breaks reproducibility of the SHA-256 pin in +# frontend/crypto-bridge.js. Code-size cost is ~50 KB on top of the LTO'd +# release binary; that is an acceptable trade for "builds match the pin +# anywhere with just stable Rust + wasm-pack". +[package.metadata.wasm-pack.profile.release] +wasm-opt = false diff --git a/crypto-wasm/src/lib.rs b/crypto-wasm/src/lib.rs index 5545674e..0b3da0ef 100644 --- a/crypto-wasm/src/lib.rs +++ b/crypto-wasm/src/lib.rs @@ -13,7 +13,7 @@ use p256::{ PublicKey as P256PublicKey, SecretKey as P256SecretKey, elliptic_curve::sec1::ToEncodedPoint, }; -use aes_gcm::{Aes256Gcm, Key as AesKey, Nonce, aead::{Aead, KeyInit as AesKeyInit}}; +use aes_gcm::{Aes256Gcm, Key as AesKey, Nonce, aead::{Aead, KeyInit as AesKeyInit, Payload}}; use hkdf::Hkdf; use sha2::Sha256; use rand_core::OsRng; @@ -21,6 +21,11 @@ use rand_core::OsRng; const INFO: &[u8] = b"paramant-v2"; const BLOCK: usize = 5 * 1024 * 1024; +// Wire-format magic byte. 0x02 = legacy v0 (no AAD). 0x03 = current (AAD-bound). +// decrypt_blob accepts both. encrypt_blob always produces 0x03. +const MAGIC_LEGACY: u8 = 0x02; +const MAGIC_AAD: u8 = 0x03; + fn hkdf_aes_key(ss_kem: &[u8], ss_ecdh: &[u8], salt: &[u8]) -> Result<[u8; 32], JsValue> { let mut ikm = Vec::with_capacity(ss_kem.len() + ss_ecdh.len()); ikm.extend_from_slice(ss_kem); @@ -33,8 +38,10 @@ fn hkdf_aes_key(ss_kem: &[u8], ss_ecdh: &[u8], salt: &[u8]) -> Result<[u8; 32], /// Encrypt plaintext for a receiver identified by kem_pub (1184 B) and ecdh_pub (65 B). /// Returns a 5 MB padded blob in wire format: -/// 0x02 | u32be(ctKemLen) | ctKem | u32be(senderPubLen) | senderPub | nonce(12) | u32be(ctLen) | ct -/// Matches the JS hybrid-KEM construction in parashare.html exactly. +/// 0x03 | u32be(ctKemLen) | ctKem | u32be(senderPubLen) | senderPub | nonce(12) | u32be(ctLen) | ct +/// All bytes from offset 0 through and including u32be(ctLen) are passed to AES-256-GCM +/// as Associated Data so any tampering with wire metadata fails verification explicitly, +/// rather than relying on cascade failure via HKDF salt = ctKem[..32]. #[wasm_bindgen] pub fn encrypt_blob(plaintext: &[u8], kem_pub: &[u8], ecdh_pub: &[u8]) -> Result, JsValue> { // ML-KEM-768 encapsulate @@ -60,25 +67,36 @@ pub fn encrypt_blob(plaintext: &[u8], kem_pub: &[u8], ecdh_pub: &[u8]) -> Result // HKDF-SHA256 → AES-256-GCM key (salt = ctKem[..32], matches JS) let aes_key = hkdf_aes_key(ss_kem_bytes, ss_ecdh_bytes.as_ref(), &ct_kem_bytes[..32])?; - // AES-256-GCM encrypt + // Build the wire-format prelude (everything from magic byte through u32be(ctLen)). + // This is the AES-GCM Associated Data: it commits the AEAD tag to every structural + // field of the blob, so any in-flight mutation fails with a clean auth error. + let ct_len_u32 = u32::try_from(plaintext.len() + 16) // +16 for AES-GCM tag + .map_err(|_| JsValue::from_str("plaintext too large for u32 length prefix"))?; + let mut aad = Vec::with_capacity( + 1 + 4 + ct_kem_bytes.len() + 4 + sender_pub_raw.len() + 12 + 4, + ); + aad.push(MAGIC_AAD); + aad.extend_from_slice(&(ct_kem_bytes.len() as u32).to_be_bytes()); + aad.extend_from_slice(ct_kem_bytes); + aad.extend_from_slice(&(sender_pub_raw.len() as u32).to_be_bytes()); + aad.extend_from_slice(sender_pub_raw); let mut nonce_buf = [0u8; 12]; getrandom::getrandom(&mut nonce_buf).map_err(|e| JsValue::from_str(&e.to_string()))?; + aad.extend_from_slice(&nonce_buf); + aad.extend_from_slice(&ct_len_u32.to_be_bytes()); + + // AES-256-GCM encrypt with AAD let cipher = Aes256Gcm::new(AesKey::::from_slice(&aes_key)); let ct = cipher - .encrypt(Nonce::from_slice(&nonce_buf), plaintext) + .encrypt(Nonce::from_slice(&nonce_buf), Payload { msg: plaintext, aad: &aad }) .map_err(|_| JsValue::from_str("AES-GCM encrypt failed"))?; + if ct.len() as u32 != ct_len_u32 { + return Err(JsValue::from_str("AES-GCM produced unexpected ciphertext length")); + } - // Wire packet - let mut pkt = Vec::with_capacity( - 1 + 4 + ct_kem_bytes.len() + 4 + sender_pub_raw.len() + 12 + 4 + ct.len(), - ); - pkt.push(0x02); - pkt.extend_from_slice(&(ct_kem_bytes.len() as u32).to_be_bytes()); - pkt.extend_from_slice(ct_kem_bytes); - pkt.extend_from_slice(&(sender_pub_raw.len() as u32).to_be_bytes()); - pkt.extend_from_slice(sender_pub_raw); - pkt.extend_from_slice(&nonce_buf); - pkt.extend_from_slice(&(ct.len() as u32).to_be_bytes()); + // Wire packet — prelude (== AAD) followed by the AEAD ciphertext+tag + let mut pkt = Vec::with_capacity(aad.len() + ct.len()); + pkt.extend_from_slice(&aad); pkt.extend_from_slice(&ct); // Pad to 5 MB with random bytes @@ -98,9 +116,15 @@ pub fn encrypt_blob(plaintext: &[u8], kem_pub: &[u8], ecdh_pub: &[u8]) -> Result /// ecdh_priv : 32-byte P-256 scalar (big-endian, raw private key bytes) #[wasm_bindgen] pub fn decrypt_blob(ciphertext: &[u8], kem_priv: &[u8], ecdh_priv: &[u8]) -> Result, JsValue> { - if ciphertext.is_empty() || ciphertext[0] != 0x02 { - return Err(JsValue::from_str("unexpected packet magic (expected 0x02)")); + if ciphertext.is_empty() { + return Err(JsValue::from_str("empty ciphertext")); } + let magic = ciphertext[0]; + let aad_bound = match magic { + MAGIC_AAD => true, + MAGIC_LEGACY => false, + _ => return Err(JsValue::from_str("unexpected packet magic (expected 0x02 or 0x03)")), + }; let mut off = 1usize; macro_rules! rd_u32 { @@ -121,6 +145,7 @@ pub fn decrypt_blob(ciphertext: &[u8], kem_priv: &[u8], ecdh_priv: &[u8]) -> Res let ct_kem_len = rd_u32!(); let ct_kem = rd_slice!(ct_kem_len); let sender_len = rd_u32!(); let sender_raw = rd_slice!(sender_len); let nonce = rd_slice!(12); + let ct_off_before_len = off; let ct_len = rd_u32!(); let ct = rd_slice!(ct_len); // ML-KEM-768 decapsulate — load noble's 2400-byte NIST expanded dk @@ -142,9 +167,17 @@ pub fn decrypt_blob(ciphertext: &[u8], kem_priv: &[u8], ecdh_priv: &[u8]) -> Res // HKDF-SHA256 → AES-256-GCM key let aes_key = hkdf_aes_key(ss_kem_arr.as_slice(), ss_ecdh.raw_secret_bytes().as_ref(), &ct_kem[..32])?; - // AES-256-GCM decrypt + // AES-256-GCM decrypt. For 0x03 blobs the AAD covers the entire wire prelude + // (magic .. ctLen); for legacy 0x02 blobs no AAD is used. let cipher = Aes256Gcm::new(AesKey::::from_slice(&aes_key)); - cipher - .decrypt(Nonce::from_slice(nonce), ct) - .map_err(|_| JsValue::from_str("AES-GCM decrypt failed — wrong key or corrupted data")) + if aad_bound { + let aad = &ciphertext[..ct_off_before_len + 4]; + cipher + .decrypt(Nonce::from_slice(nonce), Payload { msg: ct, aad }) + .map_err(|_| JsValue::from_str("AES-GCM decrypt failed — wrong key or corrupted data")) + } else { + cipher + .decrypt(Nonce::from_slice(nonce), ct) + .map_err(|_| JsValue::from_str("AES-GCM decrypt failed — wrong key or corrupted data")) + } } diff --git a/crypto-wasm/test.js b/crypto-wasm/test.js index 889cf9e8..7ebf55b6 100644 --- a/crypto-wasm/test.js +++ b/crypto-wasm/test.js @@ -13,32 +13,28 @@ * - Node.js >= 18 (has globalThis.crypto with getRandomValues + subtle) */ -import { createRequire } from 'module'; import { readFileSync } from 'fs'; -import { fileURLToPath } from 'url'; +import { fileURLToPath, pathToFileURL } from 'url'; import path from 'path'; const __dir = path.dirname(fileURLToPath(import.meta.url)); // ── Load noble ML-KEM-768 for keygen ──────────────────────────────────────── -// noble-mlkem-bundle.js is a browser bundle — we load it via dynamic eval so -// it can use globalThis.crypto which Node 18+ exposes. -const bundleSrc = readFileSync(path.join(__dir, '../frontend/noble-mlkem-bundle.js'), 'utf8'); -const bundleExports = {}; -const mod = new Function('exports', bundleSrc + '; Object.assign(exports, { ml_kem768 });'); -mod(bundleExports); -const { ml_kem768 } = bundleExports; +// Use the package installed under relay/node_modules so we don't need a +// separate npm install in this directory. +const noblePath = path.join(__dir, '../relay/node_modules/@noble/post-quantum/ml-kem.js'); +const { ml_kem768 } = await import(pathToFileURL(noblePath).href); if (!ml_kem768?.keygen) { - console.error('FAIL: noble-mlkem-bundle.js did not export ml_kem768.keygen'); + console.error('FAIL: @noble/post-quantum/ml-kem.js did not export ml_kem768.keygen'); process.exit(1); } // ── Load WASM ──────────────────────────────────────────────────────────────── // wasm-pack --target web generates an init() that accepts a WASM buffer. // We read the binary manually and pass it to init(). -const wasmJsPath = path.join(__dir, 'pkg/paramant_crypto.js'); -const wasmBinPath = path.join(__dir, 'pkg/paramant_crypto_bg.wasm'); +const wasmJsPath = path.join(__dir, '../frontend/pkg/paramant_crypto.js'); +const wasmBinPath = path.join(__dir, '../frontend/pkg/paramant_crypto_bg.wasm'); // Patch import.meta.url before importing the generated glue code // (the glue code uses import.meta.url to locate the WASM file when no arg is given) @@ -75,8 +71,8 @@ if (encrypted.length !== 5 * 1024 * 1024) { console.error('FAIL: unexpected encrypted blob size', encrypted.length); process.exit(1); } -if (encrypted[0] !== 0x02) { - console.error('FAIL: unexpected magic byte', encrypted[0].toString(16)); +if (encrypted[0] !== 0x03) { + console.error('FAIL: unexpected magic byte (expected 0x03 AAD-bound)', encrypted[0].toString(16)); process.exit(1); } @@ -99,7 +95,22 @@ if (decryptedText !== original) { console.log(''); console.log('✓ WASM roundtrip OK'); console.log(' plaintext :', original); -console.log(' encrypted : 5 MB padded blob, magic=0x02'); +console.log(' encrypted : 5 MB padded blob, magic=0x03 (AAD-bound)'); console.log(' decrypted : matches original'); + +// ── AAD tampering negative test — flip a byte in the wire prelude ──────────── +{ + const tampered = encrypted.slice(); + tampered[5] ^= 0x01; // flip a bit in u32be(ctKemLen) + let threw = false; + try { decrypt_blob(tampered, kemSec, ecdhPrivRaw); } + catch (_) { threw = true; } + if (!threw) { + console.error('FAIL: tampered prelude was accepted by decrypt_blob'); + process.exit(1); + } + console.log('✓ AAD tamper-detection OK — prelude bit-flip rejected'); +} + console.log(''); console.log('All checks passed.'); diff --git a/frontend/crypto-bridge.js b/frontend/crypto-bridge.js index ddfae5ed..bc631b9f 100644 --- a/frontend/crypto-bridge.js +++ b/frontend/crypto-bridge.js @@ -2,8 +2,11 @@ * crypto-bridge.js — wraps the Rust/WASM hybrid KEM (ML-KEM-768 + ECDH P-256 + AES-256-GCM) * and re-exports the same API that parashare.html, drop.html, and ontvang.html use. * - * Wire format (produced by WASM, no AAD): - * 0x02 | u32be(ctKemLen) | ctKem | u32be(senderPubLen) | senderPub | nonce(12) | u32be(ctLen) | ct + * Wire format produced by WASM (current — magic 0x03, AAD-bound): + * 0x03 | u32be(ctKemLen) | ctKem | u32be(senderPubLen) | senderPub | nonce(12) | u32be(ctLen) | ct + * The bytes from offset 0 through u32be(ctLen) are passed to AES-256-GCM as Associated + * Data, so any in-flight mutation of the wire prelude fails authentication explicitly. + * decrypt_blob also still accepts the legacy 0x02 layout (no AAD) for backward compat. * Padded to 5 MB with random bytes. * * Self-integrity: on first init, the WASM binary is fetched and its SHA-256 is checked @@ -13,7 +16,8 @@ import init, { encrypt_blob, decrypt_blob } from './pkg/paramant_crypto.js'; // SHA-256 of frontend/pkg/paramant_crypto_bg.wasm — update after each wasm-pack build. -const WASM_SHA256 = 'd009869a8e5eb64e8926a7c8527b15964eac2f075f3707504c596fb65067cc2a'; +// Reproducible without binaryen: see [package.metadata.wasm-pack] in crypto-wasm/Cargo.toml. +const WASM_SHA256 = '8e9aca293143d0271cf2134f26c998933c2006099c13da9ae81c86f6940e782b'; let _ready = null; diff --git a/frontend/pkg/paramant_crypto.d.ts b/frontend/pkg/paramant_crypto.d.ts index f99437cc..e38379bd 100644 --- a/frontend/pkg/paramant_crypto.d.ts +++ b/frontend/pkg/paramant_crypto.d.ts @@ -13,8 +13,10 @@ export function decrypt_blob(ciphertext: Uint8Array, kem_priv: Uint8Array, ecdh_ /** * Encrypt plaintext for a receiver identified by kem_pub (1184 B) and ecdh_pub (65 B). * Returns a 5 MB padded blob in wire format: - * 0x02 | u32be(ctKemLen) | ctKem | u32be(senderPubLen) | senderPub | nonce(12) | u32be(ctLen) | ct - * Matches the JS hybrid-KEM construction in parashare.html exactly. + * 0x03 | u32be(ctKemLen) | ctKem | u32be(senderPubLen) | senderPub | nonce(12) | u32be(ctLen) | ct + * All bytes from offset 0 through and including u32be(ctLen) are passed to AES-256-GCM + * as Associated Data so any tampering with wire metadata fails verification explicitly, + * rather than relying on cascade failure via HKDF salt = ctKem[..32]. */ export function encrypt_blob(plaintext: Uint8Array, kem_pub: Uint8Array, ecdh_pub: Uint8Array): Uint8Array; diff --git a/frontend/pkg/paramant_crypto.js b/frontend/pkg/paramant_crypto.js index 685d6de9..dc1c0692 100644 --- a/frontend/pkg/paramant_crypto.js +++ b/frontend/pkg/paramant_crypto.js @@ -30,8 +30,10 @@ export function decrypt_blob(ciphertext, kem_priv, ecdh_priv) { /** * Encrypt plaintext for a receiver identified by kem_pub (1184 B) and ecdh_pub (65 B). * Returns a 5 MB padded blob in wire format: - * 0x02 | u32be(ctKemLen) | ctKem | u32be(senderPubLen) | senderPub | nonce(12) | u32be(ctLen) | ct - * Matches the JS hybrid-KEM construction in parashare.html exactly. + * 0x03 | u32be(ctKemLen) | ctKem | u32be(senderPubLen) | senderPub | nonce(12) | u32be(ctLen) | ct + * All bytes from offset 0 through and including u32be(ctLen) are passed to AES-256-GCM + * as Associated Data so any tampering with wire metadata fails verification explicitly, + * rather than relying on cascade failure via HKDF salt = ctKem[..32]. * @param {Uint8Array} plaintext * @param {Uint8Array} kem_pub * @param {Uint8Array} ecdh_pub diff --git a/frontend/pkg/paramant_crypto_bg.wasm b/frontend/pkg/paramant_crypto_bg.wasm index 65749f7a..71a742a5 100644 Binary files a/frontend/pkg/paramant_crypto_bg.wasm and b/frontend/pkg/paramant_crypto_bg.wasm differ