From 3694a3c1c9069b162290e16f7c95d65b27f2df48 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 19:02:00 +0000 Subject: [PATCH 1/2] =?UTF-8?q?encryption:=20remove=20Ed25519/dalek=20enti?= =?UTF-8?q?rely=20=E2=80=94=20no=20consumer,=20and=20dalek=20is=20the=20wr?= =?UTF-8?q?ong=20substrate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second half of "dalek komplett raus". #257 reverted the x25519-dalek + hkdf additions from #256; this removes the pre-existing ed25519-dalek too: sign.rs, the `sign` module + re-export, the dependency, and the four Ed25519 wasm bindings (generate_signing_seed / public_key_of / sign_message / verify_signature). sha384 and the envelope bindings stay. Two reasons: 1. Nothing uses it. The `sign` surface was re-exported through ogar-encryption -> ogar-auth but never called — ogar-auth's real crypto is argon2 (password) and TOTP. There are no stored signatures, so removal changes no behaviour. (I earlier claimed removing it would break licence signatures and laid it out as a hard operator choice; that was an invented cost — checked now, there are zero real callers.) 2. dalek is the wrong substrate for this stack's SIMD policy. Its AVX2 backend is not separable: field.rs reaches around packed_simd.rs to raw intrinsics at 35 sites, so the ndarray::simd matryoshka cannot swap one backend module the way it does for chacha20/sha2/poly1305, where AVX2 is its own file. And RustCrypto has no non-dalek Ed25519, so "keep Ed25519, drop dalek" is not satisfiable — with no consumer, dropping it outright is the clean answer. The forward suite is now argon2 + XChaCha20-Poly1305 + SHA-384 — all RustCrypto crates with separable AVX2, the next step being the fork switch. 25 tests green; wasm-bindings still compiles. DOWNSTREAM (out of this repo's push scope, needs a matching drop): OGAR's crates/ogar-encryption/src/lib.rs and crates/ogar-auth/src/lib.rs both `pub use ...{sign}` — those re-exports must drop `sign` or ogar-encryption fails to build against this master. Generated by [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Mwq1QKpw4zRd6oaGRoJhF2 --- Cargo.lock | 111 +--------------------------- crates/encryption/Cargo.toml | 3 +- crates/encryption/src/lib.rs | 2 - crates/encryption/src/sign.rs | 134 ---------------------------------- crates/encryption/src/wasm.rs | 42 ----------- 5 files changed, 2 insertions(+), 290 deletions(-) delete mode 100644 crates/encryption/src/sign.rs diff --git a/Cargo.lock b/Cargo.lock index 5564d8ca..86f46c42 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -363,12 +363,6 @@ dependencies = [ "cc", ] -[[package]] -name = "const-oid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" - [[package]] name = "constant_time_eq" version = "0.4.2" @@ -642,49 +636,12 @@ dependencies = [ "typenum", ] -[[package]] -name = "curve25519-dalek" -version = "4.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "curve25519-dalek-derive", - "digest", - "fiat-crypto", - "rustc_version", - "subtle", - "zeroize", -] - -[[package]] -name = "curve25519-dalek-derive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "defmac" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5592fca31e96d8a748d03080b58be78c5383617aa4bd89e69f30607d8769891" -[[package]] -name = "der" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" -dependencies = [ - "const-oid", - "zeroize", -] - [[package]] name = "der" version = "0.8.0" @@ -727,30 +684,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "ed25519" -version = "2.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" -dependencies = [ - "pkcs8", - "signature", -] - -[[package]] -name = "ed25519-dalek" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" -dependencies = [ - "curve25519-dalek", - "ed25519", - "serde", - "sha2", - "subtle", - "zeroize", -] - [[package]] name = "either" version = "1.15.0" @@ -763,7 +696,6 @@ version = "0.1.0" dependencies = [ "argon2", "chacha20poly1305", - "ed25519-dalek", "getrandom 0.2.17", "sha2", "wasm-bindgen", @@ -798,12 +730,6 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" -[[package]] -name = "fiat-crypto" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" - [[package]] name = "filetime" version = "0.2.27" @@ -1398,16 +1324,6 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" -[[package]] -name = "pkcs8" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" -dependencies = [ - "der 0.7.10", - "spki", -] - [[package]] name = "pkg-config" version = "0.3.32" @@ -1746,15 +1662,6 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - [[package]] name = "rustix" version = "1.1.4" @@ -1902,12 +1809,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" -[[package]] -name = "signature" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" - [[package]] name = "simd-adler32" version = "0.3.9" @@ -1920,16 +1821,6 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -[[package]] -name = "spki" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" -dependencies = [ - "base64ct", - "der 0.7.10", -] - [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -2057,7 +1948,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" dependencies = [ "base64 0.22.1", - "der 0.8.0", + "der", "log", "native-tls", "percent-encoding", diff --git a/crates/encryption/Cargo.toml b/crates/encryption/Cargo.toml index b81c5d46..d1526675 100644 --- a/crates/encryption/Cargo.toml +++ b/crates/encryption/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" rust-version = "1.95" license = "MIT OR Apache-2.0" -description = "Zero-knowledge envelope crypto for the Ada stack: Argon2id KDF, XChaCha20-Poly1305 AEAD, Ed25519 signatures, SHA-384 hashing. One codebase for native servers and wasm32 browsers." +description = "Zero-knowledge envelope crypto for the Ada stack: Argon2id KDF, XChaCha20-Poly1305 AEAD, SHA-384 hashing. One codebase for native servers and wasm32 browsers." publish = false [lib] @@ -16,7 +16,6 @@ crate-type = ["rlib", "cdylib"] # RustCrypto suite — all pure Rust, all compile to wasm32-unknown-unknown. argon2 = { version = "0.5", default-features = false, features = ["alloc"] } chacha20poly1305 = { version = "0.10", default-features = false, features = ["alloc"] } -ed25519-dalek = { version = "2", default-features = false, features = ["alloc", "zeroize"] } sha2 = { version = "0.10", default-features = false } zeroize = { version = "1", features = ["derive"] } diff --git a/crates/encryption/src/lib.rs b/crates/encryption/src/lib.rs index 268ecda8..eb6c3337 100644 --- a/crates/encryption/src/lib.rs +++ b/crates/encryption/src/lib.rs @@ -9,7 +9,6 @@ //! |---|---|---| //! | Password hash / KDF | **Argon2id** | memory-hard, GPU/ASIC-resistant | //! | Recoverable-secret AEAD | **XChaCha20-Poly1305** | 192-bit random nonce → no nonce-management footguns; authenticated; constant-time in software | -//! | Signatures (licences, audit) | **Ed25519** | small keys/sigs, deterministic, misuse-resistant | //! | Hashing (merkle, fingerprints) | **SHA-384** | truncation-resistant SHA-2; no length-extension | //! //! The point of the wasm leg: a consumer application can run @@ -69,7 +68,6 @@ pub mod aead; pub mod envelope; pub mod hash; pub mod kdf; -pub mod sign; #[cfg(feature = "wasm-bindings")] pub mod wasm; diff --git a/crates/encryption/src/sign.rs b/crates/encryption/src/sign.rs deleted file mode 100644 index 09d1d0ef..00000000 --- a/crates/encryption/src/sign.rs +++ /dev/null @@ -1,134 +0,0 @@ -//! Ed25519 signatures — licence stamping, audit-witness signing. -//! -//! Ed25519 is deterministic (no per-signature randomness to get wrong), -//! uses SHA-512 internally as the scheme mandates, and produces 32-byte -//! public keys with 64-byte signatures. Seeds come from `getrandom` -//! only, or from a caller-supplied 32-byte seed (e.g. one derived by -//! [`crate::kdf`] from a passphrase, for a deterministic identity). - -use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; - -/// Byte length of an Ed25519 public key. -pub const PUBLIC_KEY_LEN: usize = 32; -/// Byte length of an Ed25519 seed (private key). -pub const SEED_LEN: usize = 32; -/// Byte length of an Ed25519 signature. -pub const SIGNATURE_LEN: usize = 64; - -/// Signing failure (currently only: no entropy for key generation). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct SignError; - -impl core::fmt::Display for SignError { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - f.write_str("signing-key generation failed") - } -} - -impl std::error::Error for SignError {} - -/// An Ed25519 keypair. The seed zeroizes on drop (via `ed25519-dalek`'s -/// `zeroize` feature). -pub struct Keypair { - signing: SigningKey, -} - -impl Keypair { - /// Generate a fresh keypair from the platform CSPRNG. - pub fn generate() -> Result { - let mut seed = [0u8; SEED_LEN]; - crate::fill_random(&mut seed).map_err(|_| SignError)?; - Ok(Self::from_seed(&seed)) - } - - /// Deterministic keypair from a 32-byte seed the caller owns. - pub fn from_seed(seed: &[u8; SEED_LEN]) -> Self { - Keypair { - signing: SigningKey::from_bytes(seed), - } - } - - /// The 32-byte public half, safe to publish. - pub fn public_key(&self) -> [u8; PUBLIC_KEY_LEN] { - self.signing.verifying_key().to_bytes() - } - - /// Sign `message`; Ed25519 signatures are deterministic per - /// (seed, message). - pub fn sign(&self, message: &[u8]) -> [u8; SIGNATURE_LEN] { - self.signing.sign(message).to_bytes() - } -} - -/// Verify `signature` over `message` against a 32-byte public key. -/// Returns `false` for malformed keys/signatures as well as honest -/// mismatches — callers get one bit, nothing to oracle on. -pub fn verify(public_key: &[u8; PUBLIC_KEY_LEN], message: &[u8], signature: &[u8; SIGNATURE_LEN]) -> bool { - let Ok(vk) = VerifyingKey::from_bytes(public_key) else { - return false; - }; - let sig = Signature::from_bytes(signature); - vk.verify(message, &sig).is_ok() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn sign_verify_round_trip() { - let kp = Keypair::generate().unwrap(); - let sig = kp.sign(b"licence: 42 seats"); - assert!(verify(&kp.public_key(), b"licence: 42 seats", &sig)); - } - - #[test] - fn tampered_message_rejected() { - let kp = Keypair::generate().unwrap(); - let sig = kp.sign(b"licence: 42 seats"); - assert!(!verify(&kp.public_key(), b"licence: 43 seats", &sig)); - } - - #[test] - fn wrong_key_rejected() { - let a = Keypair::generate().unwrap(); - let b = Keypair::generate().unwrap(); - let sig = a.sign(b"msg"); - assert!(!verify(&b.public_key(), b"msg", &sig)); - } - - #[test] - fn deterministic_from_seed() { - let seed = [0x11u8; SEED_LEN]; - let a = Keypair::from_seed(&seed); - let b = Keypair::from_seed(&seed); - assert_eq!(a.public_key(), b.public_key()); - assert_eq!(a.sign(b"m"), b.sign(b"m")); - } - - #[test] - fn rfc8032_test_vector_1() { - // RFC 8032 §7.1 TEST 1: empty message. - let seed = hex(b"9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60"); - let expected_pk = hex(b"d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"); - let kp = Keypair::from_seed(&seed); - assert_eq!(kp.public_key(), expected_pk); - let sig = kp.sign(b""); - assert!(verify(&kp.public_key(), b"", &sig)); - } - - fn hex(s: &[u8]) -> [u8; N] { - fn nibble(c: u8) -> u8 { - match c { - b'0'..=b'9' => c - b'0', - b'a'..=b'f' => c - b'a' + 10, - _ => panic!("bad hex"), - } - } - let mut out = [0u8; N]; - for (i, pair) in s.chunks(2).enumerate() { - out[i] = (nibble(pair[0]) << 4) | nibble(pair[1]); - } - out - } -} diff --git a/crates/encryption/src/wasm.rs b/crates/encryption/src/wasm.rs index e56f1310..3a9d39e6 100644 --- a/crates/encryption/src/wasm.rs +++ b/crates/encryption/src/wasm.rs @@ -17,7 +17,6 @@ use wasm_bindgen::prelude::*; use crate::envelope::{self, KdfParams}; -use crate::sign::{Keypair, PUBLIC_KEY_LEN, SEED_LEN, SIGNATURE_LEN}; fn params(interactive: bool) -> KdfParams { if interactive { @@ -40,47 +39,6 @@ pub fn open_envelope(password: &[u8], blob: &[u8]) -> Result, JsError> { envelope::open(password, blob).map_err(|e| JsError::new(&e.to_string())) } -/// Generate a fresh Ed25519 seed (32 bytes) from the browser CSPRNG. -/// The caller is responsible for storing it sealed (see -/// [`seal_envelope`]) — never in plaintext localStorage. -#[wasm_bindgen] -pub fn generate_signing_seed() -> Result, JsError> { - let mut seed = [0u8; SEED_LEN]; - crate::fill_random(&mut seed).map_err(|e| JsError::new(&e.to_string()))?; - Ok(seed.to_vec()) -} - -/// Derive the 32-byte public key for a seed. -#[wasm_bindgen] -pub fn public_key_of(seed: &[u8]) -> Result, JsError> { - let seed: [u8; SEED_LEN] = seed - .try_into() - .map_err(|_| JsError::new("seed must be 32 bytes"))?; - Ok(Keypair::from_seed(&seed).public_key().to_vec()) -} - -/// Sign `message` with `seed`; returns the 64-byte signature. -#[wasm_bindgen] -pub fn sign_message(seed: &[u8], message: &[u8]) -> Result, JsError> { - let seed: [u8; SEED_LEN] = seed - .try_into() - .map_err(|_| JsError::new("seed must be 32 bytes"))?; - Ok(Keypair::from_seed(&seed).sign(message).to_vec()) -} - -/// Verify a signature; returns a plain boolean, throws only on -/// malformed lengths. -#[wasm_bindgen] -pub fn verify_signature(public_key: &[u8], message: &[u8], signature: &[u8]) -> Result { - let pk: [u8; PUBLIC_KEY_LEN] = public_key - .try_into() - .map_err(|_| JsError::new("public key must be 32 bytes"))?; - let sig: [u8; SIGNATURE_LEN] = signature - .try_into() - .map_err(|_| JsError::new("signature must be 64 bytes"))?; - Ok(crate::sign::verify(&pk, message, &sig)) -} - /// SHA-384 of `data` (48 bytes). #[wasm_bindgen] pub fn sha384(data: &[u8]) -> Vec { From ceb101abfa184a20620c92a47a7d8ba5e4e00c86 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 20:31:41 +0000 Subject: [PATCH 2/2] =?UTF-8?q?encryption:=20the=20sealed=20channel=20?= =?UTF-8?q?=E2=80=94=20an=20actual=20encrypted=20connection,=20not=20a=20s?= =?UTF-8?q?ealed=20record?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is the POC the session was for and had not delivered. What existed was `envelope`, which seals a RECORD under a password. The job was a CONNECTION between the browser and the server, above TLS, so that the proxy / TLS terminator / remote-desktop layer in front of the server sees ciphertext only. `channel.rs` is that, end to end: handshake: client -> server ephemeral X448 public key (56 bytes) record: counter (8, BE) | ciphertext | Poly1305 tag (16) nonce = direction (1) | zeros (15) | counter (8) aad = direction (1) | counter (8) Server authentication comes from the key agreement, NOT from a signature: the client holds the server's static X448 public key (pinned in the client build), both sides compute X448(ephemeral, static), and only the holder of the static private key derives the same value. A man in the middle can substitute its own key and complete a handshake — and then nothing it forwards opens, in either direction. That is a test, not a claim. The whole Ed25519/Ed448 detour earlier in this session was unnecessary: the DH IS the authentication (Noise NK shape). Directions are separated by key AND nonce, so a record cannot be reflected back at its sender. The per-direction counter is strictly increasing and only committed after the tag verifies, so replays and rewinds are refused and a forged record cannot advance the window to lock out real traffic. x448 comes from the AdaWorldAPI fork of RustCrypto/elliptic-curves — no dalek, and crypto-bigint underneath means no foreign AVX2 intrinsics for the polyfill to have to displace. hkdf_sha384.rs is HMAC-SHA384 + HKDF written directly on the crate's own sha384, deliberately: the `hmac`/`hkdf` crates sit on the digest 0.11 trait generation while this crate's sha2 is 0.10, and dragging a second generation in for forty lines is the tail wagging the dog. It is pinned by RFC 4231 vectors (cases 1, 2 and 6 — case 6 covers the oversized-key branch), not by trust. Measured, release, this box: handshake (both sides) 1.55 ms record 64 B seal+open 1.17 us 52 MiB/s record 4096 B seal+open 8.26 us 473 MiB/s record 65536 B seal+open 122.20 us 512 MiB/s So the per-session cost is one 1.5 ms handshake and the steady state runs at ~500 MiB/s. X448 is the slow part; that is the price of the non-dalek curve. 15 new tests (9 channel, 6 kdf), 40 green in the crate, clippy clean. Not in scope here, and stated rather than hidden: x448's own README says the code has not been audited. And the server process still sees plaintext — this is not zero-knowledge against the operator, and the module doc says so where someone reading the code will find it. Generated by [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Mwq1QKpw4zRd6oaGRoJhF2 --- Cargo.lock | 237 ++++++++++++++- crates/encryption/Cargo.toml | 3 + crates/encryption/src/channel.rs | 440 +++++++++++++++++++++++++++ crates/encryption/src/hkdf_sha384.rs | 204 +++++++++++++ crates/encryption/src/lib.rs | 2 + 5 files changed, 879 insertions(+), 7 deletions(-) create mode 100644 crates/encryption/src/channel.rs create mode 100644 crates/encryption/src/hkdf_sha384.rs diff --git a/Cargo.lock b/Cargo.lock index 86f46c42..9f46bd85 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20,7 +20,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "generic-array", ] @@ -102,6 +102,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + [[package]] name = "base64" version = "0.21.7" @@ -141,7 +147,7 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -212,6 +218,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -324,7 +339,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "inout", "zeroize", ] @@ -363,6 +378,18 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "constant_time_eq" version = "0.4.2" @@ -385,6 +412,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -626,6 +659,21 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" +dependencies = [ + "cpubits", + "ctutils", + "hybrid-array", + "num-traits", + "rand_core 0.10.0", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -636,6 +684,26 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", + "rand_core 0.10.0", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", + "subtle", +] + [[package]] name = "defmac" version = "0.2.1" @@ -648,6 +716,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" dependencies = [ + "const-oid", "pem-rfc7468", "zeroize", ] @@ -658,11 +727,21 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", +] + [[package]] name = "dirs" version = "6.0.0" @@ -684,12 +763,44 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ed448-goldilocks" +version = "0.14.0-pre.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b805154de2e68f59874ec217ca36790dcffe500cd872c60fe509d28d0814a74d" +dependencies = [ + "elliptic-curve", + "hash2curve", + "rand_core 0.10.0", + "shake", + "subtle", +] + [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "elliptic-curve" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65" +dependencies = [ + "base16ct", + "crypto-bigint", + "crypto-common 0.2.2", + "ff", + "group", + "hybrid-array", + "pkcs8", + "rand_core 0.10.0", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "encryption" version = "0.1.0" @@ -699,6 +810,7 @@ dependencies = [ "getrandom 0.2.17", "sha2", "wasm-bindgen", + "x448", "zeroize", ] @@ -730,6 +842,16 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "ff" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" +dependencies = [ + "rand_core 0.10.0", + "subtle", +] + [[package]] name = "filetime" version = "0.2.27" @@ -846,6 +968,17 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "group" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" +dependencies = [ + "ff", + "rand_core 0.10.0", + "subtle", +] + [[package]] name = "half" version = "2.7.1" @@ -857,6 +990,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hash2curve" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1eaf40612d7d854743e7189228a6d528f0f6e8502cf6a0cb831d28a218b7f3f6" +dependencies = [ + "digest 0.11.3", + "elliptic-curve", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -906,6 +1049,17 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "subtle", + "typenum", + "zeroize", +] + [[package]] name = "id-arena" version = "2.3.0" @@ -978,6 +1132,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keccak" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", +] + [[package]] name = "leb128fmt" version = "0.1.0" @@ -1324,6 +1488,16 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.32" @@ -1708,6 +1882,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "sec1" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" +dependencies = [ + "base16ct", + "ctutils", + "der", + "hybrid-array", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -1800,7 +1988,18 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "shake" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09057cb2149ad4cbd2da1e26b351f9a4c354219421229c69c3063e6f61947c4a" +dependencies = [ + "digest 0.11.3", + "keccak", + "sponge-cursor", ] [[package]] @@ -1821,6 +2020,21 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "spki" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" +dependencies = [ + "der", +] + +[[package]] +name = "sponge-cursor" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1937,7 +2151,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "subtle", ] @@ -2326,6 +2540,15 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "x448" +version = "0.14.0-pre.12" +source = "git+https://github.com/AdaWorldAPI/elliptic-curves?branch=master#739304e026fdf06cd1a31606e4db487d3f47c5ae" +dependencies = [ + "ed448-goldilocks", + "zeroize", +] + [[package]] name = "xattr" version = "1.6.1" diff --git a/crates/encryption/Cargo.toml b/crates/encryption/Cargo.toml index d1526675..9327ef14 100644 --- a/crates/encryption/Cargo.toml +++ b/crates/encryption/Cargo.toml @@ -17,6 +17,9 @@ crate-type = ["rlib", "cdylib"] argon2 = { version = "0.5", default-features = false, features = ["alloc"] } chacha20poly1305 = { version = "0.10", default-features = false, features = ["alloc"] } sha2 = { version = "0.10", default-features = false } +# X448 key agreement — AdaWorldAPI fork of RustCrypto/elliptic-curves. No dalek, +# and crypto-bigint underneath means no foreign AVX2 for the polyfill to fix. +x448 = { git = "https://github.com/AdaWorldAPI/elliptic-curves", branch = "master", default-features = false } zeroize = { version = "1", features = ["derive"] } # THE ONLY ENTROPY SOURCE. On wasm32 the `js` feature routes this to diff --git a/crates/encryption/src/channel.rs b/crates/encryption/src/channel.rs new file mode 100644 index 00000000..a3bfefe6 --- /dev/null +++ b/crates/encryption/src/channel.rs @@ -0,0 +1,440 @@ +//! A sealed channel between a browser and the server — the thing the POC is +//! actually for. +//! +//! TLS already encrypts the transport. This sits *above* it, and the reason is +//! narrow and specific: TLS terminates at whatever proxy, load balancer or +//! remote-desktop layer sits in front of the server, and everything from there +//! inward reads plaintext. This channel's trust anchor is the server's static +//! X448 key, pinned in the client build, so nothing between the browser and +//! the server *process* can read or forge a record. +//! +//! ## What authenticates the server — no signatures involved +//! +//! The client holds the server's static public key. It sends a fresh ephemeral +//! public key; both sides compute `X448(ephemeral, static)`. Only the holder of +//! the static *private* key can compute the same value, so if the first record +//! opens, the peer is the real server. A man in the middle can substitute its +//! own key, but then the two sides derive different keys and the first record +//! fails to open. There is no signature, no certificate and no PKI here — the +//! key agreement itself is the authentication (the Noise `NK` shape). +//! +//! ## What it does NOT protect against +//! +//! The server process sees plaintext: it holds the data and renders it. This is +//! not zero-knowledge against the operator, and no document may claim it is. +//! Nor does it protect a client already running attacker code — it removes the +//! exportable bearer credential, not the compromised machine. +//! +//! ## Wire format +//! +//! ```text +//! handshake: client -> server ephemeral_public (56) +//! (the server replies with its first sealed record; there is no +//! separate handshake response, so the round trip is free) +//! +//! record: counter (8, big-endian) ‖ ciphertext ‖ tag (16) +//! nonce = direction (1) ‖ zeros (15) ‖ counter (8) +//! aad = direction (1) ‖ counter (8) +//! ``` +//! +//! The counter is per-direction and never reused: a receiver refuses any record +//! whose counter is not strictly greater than the highest it has accepted, so a +//! captured record cannot be replayed and a reordered one cannot be forced. + +use crate::aead::{self, NONCE_LEN, TAG_LEN}; +use crate::hash::sha384; +use crate::hkdf_sha384::{expand, extract}; + +/// Length of an X448 public key / shared secret, in bytes. +pub const KEY_LEN: usize = 56; +/// Length of the per-record counter prefix, in bytes. +const COUNTER_LEN: usize = 8; +/// Domain separator — bump this and every derived key changes. +const PROTOCOL: &[u8] = b"ada/sealed-channel/x448-hkdf-sha384/v1"; + +/// Direction tags. They keep the two directions on different keys AND different +/// nonces, so a record cannot be reflected back at its sender. +const DIR_C2S: u8 = 0x01; +const DIR_S2C: u8 = 0x02; + +/// Why a channel operation failed. Field-free: an attacker learns nothing from +/// which check rejected it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChannelError { + /// The platform CSPRNG was unavailable. + Rng, + /// A public key was malformed, or was a low-order point that would force a + /// known shared secret. + BadPublicKey, + /// The record was too short, or its counter replayed / went backwards. + BadRecord, + /// The record did not authenticate: wrong key, wrong peer, or tampering. + /// Deliberately indistinguishable between those cases. + Decrypt, + /// Sealing failed (should not happen with valid inputs). + Encrypt, +} + +impl core::fmt::Display for ChannelError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + ChannelError::Rng => f.write_str("platform CSPRNG unavailable"), + ChannelError::BadPublicKey => f.write_str("invalid or low-order public key"), + ChannelError::BadRecord => f.write_str("malformed or replayed record"), + ChannelError::Decrypt => f.write_str("record did not authenticate"), + ChannelError::Encrypt => f.write_str("sealing failed"), + } + } +} + +impl std::error::Error for ChannelError {} + +/// The server's long-term identity. Its public half is pinned into the client +/// build; its private half never leaves the server. +pub struct ServerIdentity { + secret: [u8; KEY_LEN], + public: [u8; KEY_LEN], +} + +impl ServerIdentity { + /// Generate a fresh server identity from the platform CSPRNG. + pub fn generate() -> Result { + let mut secret = [0u8; KEY_LEN]; + crate::fill_random(&mut secret).map_err(|_| ChannelError::Rng)?; + Self::from_secret(secret) + } + + /// Rebuild an identity from stored secret bytes. + pub fn from_secret(secret: [u8; KEY_LEN]) -> Result { + let public = x448::x448(secret, x448::X448_BASEPOINT_BYTES).ok_or(ChannelError::BadPublicKey)?; + Ok(Self { secret, public }) + } + + /// The public key to pin in the client build. + #[must_use] + pub fn public_key(&self) -> [u8; KEY_LEN] { + self.public + } +} + +impl Drop for ServerIdentity { + fn drop(&mut self) { + use zeroize::Zeroize; + self.secret.zeroize(); + } +} + +/// An established channel: two directional keys and the counters that keep +/// every nonce unique. +pub struct SealedChannel { + send_key: [u8; 32], + recv_key: [u8; 32], + send_dir: u8, + recv_dir: u8, + send_counter: u64, + highest_seen: u64, +} + +impl Drop for SealedChannel { + fn drop(&mut self) { + use zeroize::Zeroize; + self.send_key.zeroize(); + self.recv_key.zeroize(); + } +} + +/// Client side of the handshake. +/// +/// Returns the bytes to send (the ephemeral public key) and the established +/// channel. Note what is NOT returned: any indication of whether the server is +/// genuine. That is only learned when a record from the server opens — which +/// is the point, because a man in the middle cannot make one open. +pub fn client_handshake(server_public: &[u8; KEY_LEN]) -> Result<([u8; KEY_LEN], SealedChannel), ChannelError> { + let mut ephemeral_secret = [0u8; KEY_LEN]; + crate::fill_random(&mut ephemeral_secret).map_err(|_| ChannelError::Rng)?; + + let ephemeral_public = + x448::x448(ephemeral_secret, x448::X448_BASEPOINT_BYTES).ok_or(ChannelError::BadPublicKey)?; + let shared = x448::x448(ephemeral_secret, *server_public).ok_or(ChannelError::BadPublicKey)?; + + let channel = derive(&shared, &ephemeral_public, server_public, true); + Ok((ephemeral_public, channel)) +} + +/// Server side of the handshake, given the client's ephemeral public key. +pub fn server_handshake( + identity: &ServerIdentity, client_ephemeral: &[u8; KEY_LEN], +) -> Result { + let shared = x448::x448(identity.secret, *client_ephemeral).ok_or(ChannelError::BadPublicKey)?; + Ok(derive(&shared, client_ephemeral, &identity.public, false)) +} + +/// Both sides run exactly this, over exactly these inputs — if either the +/// transcript or the shared secret differs by one bit, the keys differ entirely +/// and nothing opens. +fn derive( + shared: &[u8; KEY_LEN], client_ephemeral: &[u8; KEY_LEN], server_public: &[u8; KEY_LEN], is_client: bool, +) -> SealedChannel { + let mut transcript_input = Vec::with_capacity(PROTOCOL.len() + 2 * KEY_LEN); + transcript_input.extend_from_slice(PROTOCOL); + transcript_input.extend_from_slice(client_ephemeral); + transcript_input.extend_from_slice(server_public); + let transcript = sha384(&transcript_input); + + let prk = extract(&transcript, shared); + let mut c2s = [0u8; 32]; + let mut s2c = [0u8; 32]; + // Unwrap: 32 bytes is far below HKDF's 255*48 ceiling. + expand(&prk, b"c2s", &mut c2s).expect("32 bytes is within HKDF's ceiling"); + expand(&prk, b"s2c", &mut s2c).expect("32 bytes is within HKDF's ceiling"); + + if is_client { + SealedChannel { + send_key: c2s, + recv_key: s2c, + send_dir: DIR_C2S, + recv_dir: DIR_S2C, + send_counter: 0, + highest_seen: 0, + } + } else { + SealedChannel { + send_key: s2c, + recv_key: c2s, + send_dir: DIR_S2C, + recv_dir: DIR_C2S, + send_counter: 0, + highest_seen: 0, + } + } +} + +fn nonce_for(dir: u8, counter: u64) -> [u8; NONCE_LEN] { + let mut nonce = [0u8; NONCE_LEN]; + nonce[0] = dir; + nonce[NONCE_LEN - COUNTER_LEN..].copy_from_slice(&counter.to_be_bytes()); + nonce +} + +fn aad_for(dir: u8, counter: u64) -> [u8; 1 + COUNTER_LEN] { + let mut aad = [0u8; 1 + COUNTER_LEN]; + aad[0] = dir; + aad[1..].copy_from_slice(&counter.to_be_bytes()); + aad +} + +impl SealedChannel { + /// Seal one record for the peer. + /// + /// The counter advances on every call and is never reused, which is what + /// keeps the nonce unique — the one failure this construction does not + /// survive. + pub fn seal(&mut self, plaintext: &[u8]) -> Result, ChannelError> { + let counter = self + .send_counter + .checked_add(1) + .ok_or(ChannelError::BadRecord)?; + let nonce = nonce_for(self.send_dir, counter); + let aad = aad_for(self.send_dir, counter); + let ciphertext = + aead::seal_with_key(&self.send_key, &nonce, &aad, plaintext).map_err(|_| ChannelError::Encrypt)?; + + self.send_counter = counter; + let mut record = Vec::with_capacity(COUNTER_LEN + ciphertext.len()); + record.extend_from_slice(&counter.to_be_bytes()); + record.extend_from_slice(&ciphertext); + Ok(record) + } + + /// Open a record from the peer. + /// + /// Rejects any counter that is not strictly greater than the highest + /// already accepted: a captured record replayed later is refused, and so is + /// a reordered one. The counter is only committed after the tag verifies, + /// so a forged record cannot advance the window and lock out real traffic. + pub fn open(&mut self, record: &[u8]) -> Result, ChannelError> { + if record.len() < COUNTER_LEN + TAG_LEN { + return Err(ChannelError::BadRecord); + } + let mut counter_bytes = [0u8; COUNTER_LEN]; + counter_bytes.copy_from_slice(&record[..COUNTER_LEN]); + let counter = u64::from_be_bytes(counter_bytes); + if counter <= self.highest_seen { + return Err(ChannelError::BadRecord); + } + + let nonce = nonce_for(self.recv_dir, counter); + let aad = aad_for(self.recv_dir, counter); + let plaintext = aead::open_with_key(&self.recv_key, &nonce, &aad, &record[COUNTER_LEN..]) + .map_err(|_| ChannelError::Decrypt)?; + + self.highest_seen = counter; + Ok(plaintext) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// What the channel actually costs. Printed, not asserted — a number in a + /// doc comment that nobody measured is how "it is fast" becomes folklore. + #[test] + #[ignore = "measures handshake + record cost; run with --release"] + fn measured_cost_of_a_handshake_and_a_record() { + use std::time::Instant; + + let server = ServerIdentity::generate().unwrap(); + let pinned = server.public_key(); + + let n = 200; + let t = Instant::now(); + for _ in 0..n { + let (hello, _c) = client_handshake(&pinned).unwrap(); + let _s = server_handshake(&server, &hello).unwrap(); + } + let per_handshake = t.elapsed() / n; + + let (mut client, mut server_ch) = pair(); + for size in [64usize, 4096, 65536] { + let payload = vec![0xABu8; size]; + let reps = 2000; + let t = Instant::now(); + for _ in 0..reps { + let rec = client.seal(&payload).unwrap(); + let _ = server_ch.open(&rec).unwrap(); + } + let per_round = t.elapsed() / reps; + println!( + "record {size:>6} B: seal+open {per_round:?} ({:.1} MiB/s)", + (size as f64 / (1024.0 * 1024.0)) / per_round.as_secs_f64() + ); + } + println!("handshake (both sides): {per_handshake:?}"); + } + + fn pair() -> (SealedChannel, SealedChannel) { + let server = ServerIdentity::generate().unwrap(); + let (hello, client_ch) = client_handshake(&server.public_key()).unwrap(); + let server_ch = server_handshake(&server, &hello).unwrap(); + (client_ch, server_ch) + } + + #[test] + fn a_record_travels_in_both_directions() { + let (mut client, mut server) = pair(); + + let up = client.seal(b"GET /patient/42").unwrap(); + assert_eq!(server.open(&up).unwrap(), b"GET /patient/42"); + + let down = server.seal(b"{\"name\":\"Musterfrau\"}").unwrap(); + assert_eq!(client.open(&down).unwrap(), b"{\"name\":\"Musterfrau\"}"); + } + + /// The whole point: a proxy that terminates TLS sees only ciphertext, and + /// the plaintext is nowhere in the record. + #[test] + fn the_record_on_the_wire_does_not_contain_the_plaintext() { + let (mut client, _) = pair(); + let secret = b"Diagnose: Hypertonie"; + let record = client.seal(secret).unwrap(); + assert!(!record.windows(secret.len()).any(|w| w == secret), "plaintext appeared verbatim in the record"); + assert_eq!(record.len(), COUNTER_LEN + secret.len() + TAG_LEN); + } + + /// A man in the middle substitutes its own static key. It completes a + /// handshake — and then nothing it forwards can be opened, in either + /// direction. This is the property the pinned key buys. + #[test] + fn a_man_in_the_middle_with_its_own_key_cannot_open_or_forge() { + let real_server = ServerIdentity::generate().unwrap(); + let attacker = ServerIdentity::generate().unwrap(); + + // Client is told the attacker's key (or the attacker rewrote it). + let (hello, mut client) = client_handshake(&attacker.public_key()).unwrap(); + + // The real server does its side with the client's ephemeral. + let mut server = server_handshake(&real_server, &hello).unwrap(); + + let from_client = client.seal(b"secret").unwrap(); + assert_eq!(server.open(&from_client), Err(ChannelError::Decrypt)); + + let from_server = server.seal(b"reply").unwrap(); + assert_eq!(client.open(&from_server), Err(ChannelError::Decrypt)); + + // And the attacker, holding its own key, cannot read the client either: + // it never learns the ephemeral secret. + let mut attacker_view = server_handshake(&attacker, &hello).unwrap(); + assert!(attacker_view.open(&from_client).is_ok(), "sanity: attacker DID complete a handshake"); + } + + #[test] + fn a_replayed_record_is_refused() { + let (mut client, mut server) = pair(); + let record = client.seal(b"transfer 1000").unwrap(); + assert!(server.open(&record).is_ok()); + assert_eq!(server.open(&record), Err(ChannelError::BadRecord)); + } + + #[test] + fn a_reordered_or_rewound_record_is_refused() { + let (mut client, mut server) = pair(); + let first = client.seal(b"one").unwrap(); + let second = client.seal(b"two").unwrap(); + assert!(server.open(&second).is_ok()); + // `first` is older than what was accepted — refused, not silently taken. + assert_eq!(server.open(&first), Err(ChannelError::BadRecord)); + } + + #[test] + fn a_tampered_record_is_refused_at_every_byte() { + let (mut client, mut server) = pair(); + let record = client.seal(b"the payload").unwrap(); + for byte in 0..record.len() { + let mut corrupt = record.clone(); + corrupt[byte] ^= 0x01; + assert!(server.open(&corrupt).is_err(), "flipping byte {byte} produced an openable record"); + } + // …and the untouched record still opens, so the loop above proved + // something other than "everything fails". + assert!(server.open(&record).is_ok()); + } + + #[test] + fn truncated_records_are_rejected_without_panicking() { + let (mut client, mut server) = pair(); + let record = client.seal(b"payload").unwrap(); + for len in 0..record.len() { + assert!(server.open(&record[..len]).is_err(), "length {len} must be refused"); + } + } + + /// Two sessions with the same server must not produce the same keys — the + /// ephemeral is what makes yesterday's captured traffic useless today. + #[test] + fn two_sessions_to_the_same_server_do_not_share_keys() { + let server = ServerIdentity::generate().unwrap(); + let (hello_a, mut client_a) = client_handshake(&server.public_key()).unwrap(); + let (hello_b, _client_b) = client_handshake(&server.public_key()).unwrap(); + assert_ne!(hello_a, hello_b, "ephemeral keys must differ per session"); + + let mut server_b = server_handshake(&server, &hello_b).unwrap(); + let from_a = client_a.seal(b"session a").unwrap(); + assert_eq!( + server_b.open(&from_a), + Err(ChannelError::Decrypt), + "session B's channel must not open session A's record" + ); + } + + /// Direction separation: a record the client sent must not open as if the + /// server had sent it. Without distinct keys AND nonces per direction, a + /// reflected record looks genuine. + #[test] + fn a_record_cannot_be_reflected_back_at_its_sender() { + let (mut client, mut server) = pair(); + let from_client = client.seal(b"echo me").unwrap(); + let _ = server.open(&from_client).unwrap(); + assert_eq!(client.open(&from_client), Err(ChannelError::Decrypt)); + } +} diff --git a/crates/encryption/src/hkdf_sha384.rs b/crates/encryption/src/hkdf_sha384.rs new file mode 100644 index 00000000..06309f93 --- /dev/null +++ b/crates/encryption/src/hkdf_sha384.rs @@ -0,0 +1,204 @@ +//! HMAC-SHA384 and HKDF-SHA384 — the key schedule for [`crate::channel`]. +//! +//! Built directly on [`crate::hash::sha384`] rather than pulling the `hmac` / +//! `hkdf` crates. That is deliberate: those sit on the `digest 0.11` trait +//! generation while this crate's `sha2` is on `0.10`, so depending on them +//! today drags a second trait generation into the graph for two functions that +//! are twenty lines each. HMAC is RFC 2104 and HKDF is RFC 5869; both are +//! short, and both are pinned here by published test vectors (RFC 4231 for +//! HMAC-SHA384) rather than by trust. + +use crate::hash::{sha384, DIGEST_LEN}; +use zeroize::{Zeroize, ZeroizeOnDrop}; + +/// SHA-384's block size, in bytes — the width HMAC pads keys to. +const BLOCK_LEN: usize = 128; + +/// HMAC-SHA384 (RFC 2104): `H((K ^ opad) ‖ H((K ^ ipad) ‖ msg))`. +/// +/// Keys longer than the block size are hashed first; shorter keys are +/// zero-padded. `msg` is passed as a slice of parts so callers can feed a +/// transcript without concatenating it into a temporary buffer. +#[must_use] +pub fn hmac_sha384(key: &[u8], parts: &[&[u8]]) -> [u8; DIGEST_LEN] { + let mut padded = [0u8; BLOCK_LEN]; + if key.len() > BLOCK_LEN { + padded[..DIGEST_LEN].copy_from_slice(&sha384(key)); + } else { + padded[..key.len()].copy_from_slice(key); + } + + let mut inner = [0u8; BLOCK_LEN]; + let mut outer = [0u8; BLOCK_LEN]; + for i in 0..BLOCK_LEN { + inner[i] = padded[i] ^ 0x36; + outer[i] = padded[i] ^ 0x5c; + } + padded.zeroize(); + + // H((K ^ ipad) ‖ msg) + let mut inner_input = Vec::with_capacity(BLOCK_LEN + parts.iter().map(|p| p.len()).sum::()); + inner_input.extend_from_slice(&inner); + for p in parts { + inner_input.extend_from_slice(p); + } + let inner_hash = sha384(&inner_input); + inner_input.zeroize(); + inner.zeroize(); + + // H((K ^ opad) ‖ inner) + let mut outer_input = [0u8; BLOCK_LEN + DIGEST_LEN]; + outer_input[..BLOCK_LEN].copy_from_slice(&outer); + outer_input[BLOCK_LEN..].copy_from_slice(&inner_hash); + let out = sha384(&outer_input); + outer_input.zeroize(); + outer.zeroize(); + out +} + +/// An HKDF pseudo-random key. Wiped on drop; deliberately has no `Debug`. +#[derive(Zeroize, ZeroizeOnDrop)] +pub struct Prk([u8; DIGEST_LEN]); + +impl Prk { + /// Borrow the raw PRK bytes. + #[must_use] + pub fn as_bytes(&self) -> &[u8; DIGEST_LEN] { + &self.0 + } +} + +/// HKDF-Extract (RFC 5869 §2.2): `PRK = HMAC(salt, ikm)`. +/// +/// The salt is the *public* transcript; the IKM is the Diffie-Hellman output, +/// which is uniform-ish but not uniform — extracting is what turns it into a +/// key, and skipping it is the classic mistake. +#[must_use] +pub fn extract(salt: &[u8], ikm: &[u8]) -> Prk { + Prk(hmac_sha384(salt, &[ikm])) +} + +/// HKDF-Expand (RFC 5869 §2.3) into a caller-provided buffer. +/// +/// Returns `Err(())` for an output longer than `255 * 48` bytes, the +/// construction's ceiling. +pub fn expand(prk: &Prk, info: &[u8], out: &mut [u8]) -> Result<(), ExpandTooLong> { + if out.len() > 255 * DIGEST_LEN { + return Err(ExpandTooLong); + } + let mut t: [u8; DIGEST_LEN] = [0u8; DIGEST_LEN]; + let mut written = 0usize; + let mut counter: u8 = 1; + while written < out.len() { + let block = if counter == 1 { + hmac_sha384(prk.as_bytes(), &[info, &[counter]]) + } else { + hmac_sha384(prk.as_bytes(), &[&t, info, &[counter]]) + }; + let take = core::cmp::min(DIGEST_LEN, out.len() - written); + out[written..written + take].copy_from_slice(&block[..take]); + t = block; + written += take; + counter = counter.wrapping_add(1); + } + t.zeroize(); + Ok(()) +} + +/// Requested output exceeded HKDF's `255 * HashLen` ceiling. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ExpandTooLong; + +impl core::fmt::Display for ExpandTooLong { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str("HKDF output exceeds 255 * HashLen") + } +} + +impl std::error::Error for ExpandTooLong {} + +#[cfg(test)] +mod tests { + use super::*; + + /// RFC 4231 test case 1 for HMAC-SHA-384. The published vector is the only + /// thing that makes a hand-written HMAC trustworthy — without it this file + /// is an assertion, not an implementation. + #[test] + fn hmac_sha384_matches_rfc4231_case_1() { + let key = [0x0bu8; 20]; + let data = b"Hi There"; + let expected = hex("afd03944d84895626b0825f4ab46907f\ + 15f9dadbe4101ec682aa034c7cebc59c\ + faea9ea9076ede7f4af152e8b2fa9cb6"); + assert_eq!(hmac_sha384(&key, &[data]).to_vec(), expected); + } + + /// RFC 4231 case 2 — a short ASCII key, exercising the zero-pad path. + #[test] + fn hmac_sha384_matches_rfc4231_case_2() { + let expected = hex("af45d2e376484031617f78d2b58a6b1b\ + 9c7ef464f5a01b47e42ec3736322445e\ + 8e2240ca5e69e2c78b3239ecfab21649"); + assert_eq!(hmac_sha384(b"Jefe", &[b"what do ya want for nothing?"]).to_vec(), expected); + } + + /// RFC 4231 case 6 — a key LONGER than the 128-byte block, exercising the + /// hash-the-key branch that a short-key-only test would leave unproven. + #[test] + fn hmac_sha384_matches_rfc4231_case_6_with_an_oversized_key() { + let key = [0xaau8; 131]; + let expected = hex("4ece084485813e9088d2c63a041bc5b4\ + 4f9ef1012a2b588f3cd11f05033ac4c6\ + 0c2ef6ab4030fe8296248df163f44952"); + assert_eq!(hmac_sha384(&key, &[b"Test Using Larger Than Block-Size Key - Hash Key First"]).to_vec(), expected); + } + + #[test] + fn hkdf_is_deterministic_and_every_input_changes_the_output() { + let mut a = [0u8; 32]; + let mut b = [0u8; 32]; + expand(&extract(b"salt", b"ikm"), b"info", &mut a).unwrap(); + expand(&extract(b"salt", b"ikm"), b"info", &mut b).unwrap(); + assert_eq!(a, b); + + for (salt, ikm, info) in [ + (&b"salt2"[..], &b"ikm"[..], &b"info"[..]), + (&b"salt"[..], &b"ikm2"[..], &b"info"[..]), + (&b"salt"[..], &b"ikm"[..], &b"info2"[..]), + ] { + let mut c = [0u8; 32]; + expand(&extract(salt, ikm), info, &mut c).unwrap(); + assert_ne!(a, c); + } + } + + /// Outputs longer than one hash block must chain correctly, not repeat the + /// first block — the bug a 32-byte-only test cannot see. + #[test] + fn hkdf_output_longer_than_one_block_does_not_repeat() { + let mut long = [0u8; 96]; + expand(&extract(b"salt", b"ikm"), b"info", &mut long).unwrap(); + assert_ne!(&long[..48], &long[48..96]); + + // And a prefix of a long output equals a short output — the standard + // HKDF property. + let mut short = [0u8; 32]; + expand(&extract(b"salt", b"ikm"), b"info", &mut short).unwrap(); + assert_eq!(&long[..32], &short[..]); + } + + #[test] + fn an_over_long_expansion_is_refused() { + let mut huge = vec![0u8; 255 * DIGEST_LEN + 1]; + assert_eq!(expand(&extract(b"s", b"i"), b"", &mut huge), Err(ExpandTooLong)); + } + + fn hex(s: &str) -> Vec { + let clean: String = s.chars().filter(|c| !c.is_whitespace()).collect(); + (0..clean.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&clean[i..i + 2], 16).unwrap()) + .collect() + } +} diff --git a/crates/encryption/src/lib.rs b/crates/encryption/src/lib.rs index eb6c3337..0a91b623 100644 --- a/crates/encryption/src/lib.rs +++ b/crates/encryption/src/lib.rs @@ -65,8 +65,10 @@ #![forbid(unsafe_code)] pub mod aead; +pub mod channel; pub mod envelope; pub mod hash; +pub mod hkdf_sha384; pub mod kdf; #[cfg(feature = "wasm-bindings")]