diff --git a/.cargo/config.toml b/.cargo/config.toml index ba6378ad..53700ac2 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -22,5 +22,31 @@ # silicon) is a fifth opt-in mode — see § 7.1 of # .claude/knowledge/simd-dispatch-architecture.md. Reserved for the # release-binary distribution path; never the dev / CI default. +# curve25519-dalek backend: SERIAL, deliberately. +# +# `ed25519-dalek` pulls in `curve25519-dalek`, whose build.rs AUTO-SELECTS its +# own AVX2 backend whenever the target is x86_64 + 64-bit — which the +# `-Ctarget-cpu=x86-64-v3` line below guarantees. That backend carries its own +# raw intrinsics and its own `unsafe`: 57 `_mm*` intrinsic calls (35 in +# `backend/vector/avx2/field.rs`, 22 in `backend/vector/packed_simd.rs`) under +# 52 `unsafe` occurrences in the same two files — a SECOND unaudited SIMD +# surface beside `ndarray::simd`, which is exactly what the matryoshka pattern +# exists to prevent (cf. `vendor/chacha20`: "no raw intrinsics and no `unsafe` +# here — all of that lives once, audited, inside `ndarray::simd`"). +# +# Note this is a REACHABILITY question, not a porting one. Neutralizing that +# surface does not require porting 57 call sites onto `ndarray::simd`; the whole +# `vector` module is gated behind one cfg — `backend/mod.rs:42`, +# `#[cfg(curve25519_dalek_backend = "simd")] pub mod vector;` — so the one line +# below compiles the whole surface out. +# +# `serial` costs nothing we use: X25519's Montgomery ladder +# (`montgomery.rs`) never references the vector backend at all, and Ed25519 +# signing is not on a hot path here. The vector backend serves ONLY Edwards +# multi-scalar / variable-base multiplication (batch signature verification, +# Ristretto protocols) — neither of which this crate performs. +# +# Read by curve25519-dalek's build.rs via CARGO_CFG_CURVE25519_DALEK_BACKEND. +# Verify: cargo build -p encryption -v 2>&1 | grep curve25519_dalek_backend [target.'cfg(target_arch = "x86_64")'] -rustflags = ["-Ctarget-cpu=x86-64-v3"] +rustflags = ["-Ctarget-cpu=x86-64-v3", "--cfg", "curve25519_dalek_backend=\"serial\""] diff --git a/Cargo.lock b/Cargo.lock index 5564d8ca..378af986 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,12 +378,24 @@ 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.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[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" @@ -391,6 +418,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" @@ -632,6 +665,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" @@ -642,6 +690,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 = "curve25519-dalek" version = "4.1.3" @@ -651,7 +719,7 @@ dependencies = [ "cfg-if", "cpufeatures 0.2.17", "curve25519-dalek-derive", - "digest", + "digest 0.10.7", "fiat-crypto", "rustc_version", "subtle", @@ -681,7 +749,7 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid", + "const-oid 0.9.6", "zeroize", ] @@ -691,6 +759,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" dependencies = [ + "const-oid 0.10.2", "pem-rfc7468", "zeroize", ] @@ -701,11 +770,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" @@ -733,7 +812,7 @@ version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ - "pkcs8", + "pkcs8 0.10.2", "signature", ] @@ -751,12 +830,44 @@ dependencies = [ "zeroize", ] +[[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 0.11.0", + "rand_core 0.10.0", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "encryption" version = "0.1.0" @@ -767,6 +878,7 @@ dependencies = [ "getrandom 0.2.17", "sha2", "wasm-bindgen", + "x448", "zeroize", ] @@ -798,6 +910,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 = "fiat-crypto" version = "0.2.9" @@ -920,6 +1042,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" @@ -931,6 +1064,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" @@ -980,6 +1123,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" @@ -1052,6 +1206,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" @@ -1405,7 +1569,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ "der 0.7.10", - "spki", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der 0.8.0", + "spki 0.8.0", ] [[package]] @@ -1801,6 +1975,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 0.8.0", + "hybrid-array", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -1893,7 +2081,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]] @@ -1930,6 +2129,21 @@ dependencies = [ "der 0.7.10", ] +[[package]] +name = "spki" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" +dependencies = [ + "der 0.8.0", +] + +[[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" @@ -2046,7 +2260,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", ] @@ -2435,6 +2649,15 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "x448" +version = "0.14.0-pre.12" +source = "git+https://github.com/AdaWorldAPI/elliptic-curves?rev=739304e026fdf06cd1a31606e4db487d3f47c5ae#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 b81c5d46..b3c6b1e0 100644 --- a/crates/encryption/Cargo.toml +++ b/crates/encryption/Cargo.toml @@ -18,6 +18,9 @@ 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 } +# 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", rev = "739304e026fdf06cd1a31606e4db487d3f47c5ae", 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..43619f56 --- /dev/null +++ b/crates/encryption/src/channel.rs @@ -0,0 +1,631 @@ +//! 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. +//! +//! ## The whole protocol +//! +//! ``` +//! use encryption::channel::{client_handshake, server_handshake, ServerIdentity}; +//! +//! // Server side, once: the public half is pinned into the client build. +//! let identity = ServerIdentity::generate().unwrap(); +//! let pinned = identity.public_key(); +//! +//! // Client sends its ephemeral public key; that is the entire handshake. +//! let (ephemeral, mut client) = client_handshake(&pinned).unwrap(); +//! let mut server = server_handshake(&identity, &ephemeral).unwrap(); +//! +//! let record = client.seal(b"the query").unwrap(); +//! assert_eq!(server.open(&record).unwrap(), b"the query"); +//! +//! // Replaying that record is refused, not merely noticed. +//! assert!(server.open(&record).is_err()); +//! ``` + +use crate::aead::{self, NONCE_LEN, TAG_LEN}; +use crate::hash::sha384; +use crate::hkdf_sha384::{expand, extract}; +use zeroize::Zeroize; + +/// 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. + /// + /// ``` + /// use encryption::channel::ServerIdentity; + /// + /// let a = ServerIdentity::generate().unwrap(); + /// let b = ServerIdentity::generate().unwrap(); + /// assert_ne!(a.public_key(), b.public_key()); + /// ``` + /// + /// # Errors + /// + /// [`ChannelError::Rng`] if the platform CSPRNG is unavailable. + 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. + /// + /// The public half is *recomputed*, never stored alongside — a restart + /// cannot come back with a mismatched pair. + /// + /// ``` + /// use encryption::channel::ServerIdentity; + /// + /// let secret = [7u8; encryption::channel::KEY_LEN]; + /// let first = ServerIdentity::from_secret(secret).unwrap(); + /// let after_restart = ServerIdentity::from_secret(secret).unwrap(); + /// assert_eq!(first.public_key(), after_restart.public_key()); + /// ``` + /// + /// # Errors + /// + /// [`ChannelError::BadPublicKey`] if the secret yields a degenerate point. + 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. + /// + /// ``` + /// use encryption::channel::{client_handshake, ServerIdentity}; + /// + /// let identity = ServerIdentity::generate().unwrap(); + /// // This is the only thing that ships to the client. + /// assert!(client_handshake(&identity.public_key()).is_ok()); + /// ``` + #[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. +/// +/// ``` +/// use encryption::channel::{client_handshake, server_handshake, ServerIdentity}; +/// +/// let real = ServerIdentity::generate().unwrap(); +/// let impostor = ServerIdentity::generate().unwrap(); +/// +/// // The client handshakes against a substituted key and gets no error — +/// // there is nothing to check yet. +/// let (ephemeral, mut client) = client_handshake(&impostor.public_key()).unwrap(); +/// let mut real_server = server_handshake(&real, &ephemeral).unwrap(); +/// +/// // The substitution surfaces here, and only here: nothing opens. +/// let record = client.seal(b"secret").unwrap(); +/// assert!(real_server.open(&record).is_err()); +/// ``` +/// +/// # Errors +/// +/// [`ChannelError::Rng`] if the CSPRNG is unavailable, or +/// [`ChannelError::BadPublicKey`] if `server_public` is a low-order point that +/// would force a known shared secret. +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 mut shared = x448::x448(ephemeral_secret, *server_public).ok_or(ChannelError::BadPublicKey)?; + // The ephemeral scalar has done its two jobs; nothing below reads it again. + ephemeral_secret.zeroize(); + + let channel = derive(&shared, &ephemeral_public, server_public, true); + // `derive` has folded the DH output into the directional keys; the raw + // shared secret must not outlive that fold on the stack. + shared.zeroize(); + Ok((ephemeral_public, channel)) +} + +/// Server side of the handshake, given the client's ephemeral public key. +/// +/// ``` +/// use encryption::channel::{client_handshake, server_handshake, ServerIdentity}; +/// +/// let identity = ServerIdentity::generate().unwrap(); +/// let (ephemeral, mut client) = client_handshake(&identity.public_key()).unwrap(); +/// let mut server = server_handshake(&identity, &ephemeral).unwrap(); +/// +/// // Two directions, two keys: the server's reply is not something the +/// // client's own send key could have produced. +/// let up = client.seal(b"request").unwrap(); +/// assert_eq!(server.open(&up).unwrap(), b"request"); +/// let down = server.seal(b"response").unwrap(); +/// assert_eq!(client.open(&down).unwrap(), b"response"); +/// ``` +/// +/// # Errors +/// +/// [`ChannelError::BadPublicKey`] if `client_ephemeral` is a low-order point. +pub fn server_handshake( + identity: &ServerIdentity, client_ephemeral: &[u8; KEY_LEN], +) -> Result { + let mut shared = x448::x448(identity.secret, *client_ephemeral).ok_or(ChannelError::BadPublicKey)?; + let channel = derive(&shared, client_ephemeral, &identity.public, false); + shared.zeroize(); + Ok(channel) +} + +/// 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. + /// + /// ``` + /// use encryption::channel::{client_handshake, ServerIdentity}; + /// + /// let identity = ServerIdentity::generate().unwrap(); + /// let (_, mut client) = client_handshake(&identity.public_key()).unwrap(); + /// + /// // The same plaintext twice produces two different records — the + /// // counter is in the nonce, so nothing repeats on the wire. + /// let first = client.seal(b"same").unwrap(); + /// let second = client.seal(b"same").unwrap(); + /// assert_ne!(first, second); + /// ``` + /// + /// # Errors + /// + /// [`ChannelError::BadRecord`] if the send counter would overflow, or + /// [`ChannelError::Encrypt`] if sealing fails. + 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. + /// + /// ``` + /// use encryption::channel::{client_handshake, server_handshake, ServerIdentity}; + /// + /// let identity = ServerIdentity::generate().unwrap(); + /// let (ephemeral, mut client) = client_handshake(&identity.public_key()).unwrap(); + /// let mut server = server_handshake(&identity, &ephemeral).unwrap(); + /// + /// let first = client.seal(b"one").unwrap(); + /// let second = client.seal(b"two").unwrap(); + /// + /// // Delivering out of order accepts the newer record and then refuses + /// // the older one — the window only moves forward. + /// assert_eq!(server.open(&second).unwrap(), b"two"); + /// assert!(server.open(&first).is_err()); + /// ``` + /// + /// # Errors + /// + /// [`ChannelError::BadRecord`] if the record is truncated or its counter + /// replayed, or [`ChannelError::Decrypt`] if the tag does not verify. + 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)); + } + + /// **The contributory-behaviour guard, both halves.** + /// + /// RFC 7748 §6.2 makes rejecting an all-zero shared secret OPTIONAL, so it + /// is a property of *this* code that we reject — `x448()` returns `None` + /// for a low-order peer key and `.ok_or(BadPublicKey)?` propagates it. That + /// is the entire check, it is one `?`, and nothing else in the suite proves + /// it is still wired. + /// + /// Both directions are asserted deliberately: a guard that fires on every + /// input carries exactly as much information as one that never fires, so + /// the accept case uses a real basepoint-derived key, not a trivial input. + /// + /// This is also the tripwire for a future curve swap. `x25519_dalek::x25519` + /// returns a bare `[u8; 32]` — no `Option`, nothing for `?` to attach to — + /// so a mechanical port of this module would compile, pass every other test + /// here, and silently drop this rejection. If that port ever happens, this + /// test must be made to fail first. + #[test] + fn low_order_peer_keys_are_refused_and_honest_ones_are_not() { + let server = ServerIdentity::generate().unwrap(); + + // FIRES: the canonical low-order points yield an all-zero DH output. + for (name, bad) in [ + ("all-zero", [0u8; KEY_LEN]), + ("u=1", { + let mut u = [0u8; KEY_LEN]; + u[0] = 1; + u + }), + ] { + assert_eq!( + client_handshake(&bad).err(), + Some(ChannelError::BadPublicKey), + "a {name} peer key must be refused, not silently keyed" + ); + assert_eq!( + server_handshake(&server, &bad).err(), + Some(ChannelError::BadPublicKey), + "the server side must refuse a {name} client ephemeral too" + ); + } + + // STAYS SILENT: an honest, basepoint-derived key must go through. + let (hello, _client) = client_handshake(&server.public_key()).expect("an honest server key must be accepted"); + server_handshake(&server, &hello).expect("an honest client ephemeral must be accepted"); + } +} diff --git a/crates/encryption/src/hkdf_sha384.rs b/crates/encryption/src/hkdf_sha384.rs new file mode 100644 index 00000000..dc8baba5 --- /dev/null +++ b/crates/encryption/src/hkdf_sha384.rs @@ -0,0 +1,245 @@ +//! 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. +/// +/// ``` +/// use encryption::hkdf_sha384::hmac_sha384; +/// +/// // The parts are concatenated, not hashed independently — these agree. +/// let split = hmac_sha384(b"key", &[b"hello ", b"world"]); +/// let whole = hmac_sha384(b"key", &[b"hello world"]); +/// assert_eq!(split, whole); +/// ``` +#[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, o), p) in inner.iter_mut().zip(outer.iter_mut()).zip(padded.iter()) { + *i = p ^ 0x36; + *o = p ^ 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. + /// + /// ``` + /// use encryption::hkdf_sha384::extract; + /// + /// let prk = extract(b"salt", b"input keying material"); + /// assert_eq!(prk.as_bytes().len(), 48); // SHA-384 output width + /// ``` + #[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. +/// +/// ``` +/// use encryption::hkdf_sha384::extract; +/// +/// // Deterministic in both inputs, and the salt is not decorative. +/// assert_eq!(extract(b"salt", b"ikm").as_bytes(), extract(b"salt", b"ikm").as_bytes()); +/// assert_ne!(extract(b"salt", b"ikm").as_bytes(), extract(b"other", b"ikm").as_bytes()); +/// ``` +#[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. +/// +/// ``` +/// use encryption::hkdf_sha384::{expand, extract}; +/// +/// let prk = extract(b"salt", b"ikm"); +/// let mut enc_key = [0u8; 32]; +/// let mut mac_key = [0u8; 32]; +/// expand(&prk, b"encryption", &mut enc_key).unwrap(); +/// expand(&prk, b"authentication", &mut mac_key).unwrap(); +/// +/// // One PRK, two independent keys — that is what `info` is for. +/// assert_ne!(enc_key, mac_key); +/// ``` +/// +/// # Errors +/// +/// [`ExpandTooLong`] if `out` is longer than `255 * 48` bytes. +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 268ecda8..6bdd64bc 100644 --- a/crates/encryption/src/lib.rs +++ b/crates/encryption/src/lib.rs @@ -66,8 +66,10 @@ #![forbid(unsafe_code)] pub mod aead; +pub mod channel; pub mod envelope; pub mod hash; +pub mod hkdf_sha384; pub mod kdf; pub mod sign;