diff --git a/.claude/blackboard.md b/.claude/blackboard.md index f4e502aa..ae9596a8 100644 --- a/.claude/blackboard.md +++ b/.claude/blackboard.md @@ -873,3 +873,34 @@ running multi-hundred-MiB derivations. break cost bumps in the other direction: a reader shipped before the writer would reject the new profile. A bounded budget keeps the forward compatibility the header format exists for. +## 2026-07-25 — Do NOT run git surgery in the shared checkout while the fleet is live + +**Status:** FINDING (orchestrator error, no damage, caught by an agent's own report) + +`agent-cargo-hygiene.md` says to fan the Sonnet fleet out in the SHARED +checkout so twelve agents don't materialise twelve 7 GB `target/`s. Correct — +but it leaves out the consequence: the orchestrator's own git operations hit +the same working tree the fleet is writing into. + +This session: while an X25519 agent was still working, the orchestrator +cherry-picked its branch and then ran `git reset --hard origin/` on +the branch the agent was standing in. The reset deleted the agent's +in-progress file from disk. The agent noticed (its file had vanished), +rewrote it from what it had already verified, and reported the reflog entry — +which is how this was caught at all. + +**No work was lost:** the file had already been committed and pushed to a +separate branch minutes earlier, and the agent's reconstruction turned out +byte-for-byte identical to the pushed version. The exposure was real anyway; +the outcome was luck plus an agent that reported an anomaly instead of +silently continuing. + +**The rule:** while any fleet agent is live in the shared checkout, the +orchestrator may `add`/`commit`/`push` (append-only, non-destructive) but must +NOT `reset --hard`, `checkout` another branch, `stash`, or `clean`. Branch +surgery waits until every agent has reported. If it cannot wait, do it in a +throwaway clone, not the shared tree. + +**Corollary worth keeping:** an agent that reports "my file disappeared and +here is the reflog entry that explains it" is doing its job. Brief the fleet +so anomalies get reported rather than worked around. diff --git a/Cargo.lock b/Cargo.lock index 5564d8ca..c62b3e6b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -765,8 +765,10 @@ dependencies = [ "chacha20poly1305", "ed25519-dalek", "getrandom 0.2.17", + "hkdf", "sha2", "wasm-bindgen", + "x25519-dalek", "zeroize", ] @@ -964,6 +966,24 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "http" version = "1.4.0" @@ -2435,6 +2455,17 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "zeroize", +] + [[package]] name = "xattr" version = "1.6.1" diff --git a/crates/encryption/Cargo.toml b/crates/encryption/Cargo.toml index b81c5d46..b592c192 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 } +# Key agreement + key schedule for the sealed channel (kx.rs / hkdf.rs). +x25519-dalek = { version = "2", default-features = false, features = ["static_secrets", "zeroize"] } +hkdf = { version = "0.12", 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/bundle.rs b/crates/encryption/src/bundle.rs new file mode 100644 index 00000000..80d9f05a --- /dev/null +++ b/crates/encryption/src/bundle.rs @@ -0,0 +1,475 @@ +//! Signed content bundles — a distributable, verifiable unit of UI +//! projection (a view, a mask, a render adapter, or a recipe set). +//! +//! A bundle is minted once, offline, by a publisher's [`crate::sign::Keypair`] +//! and then handed to clients however content normally travels (a CDN, a +//! sync channel, a USB stick — the transport is not this module's concern). +//! The client's only obligation is: **refuse to render anything that does +//! not verify.** [`verify`] is the single gate between "bytes arrived" and +//! "bytes are trusted", and it is the only supported way to read a bundle's +//! body. +//! +//! ## Byte layout (fixed, little-endian, parsed by hand — no serde) +//! +//! ```text +//! offset len field +//! 0 4 magic = b"ADAB" +//! 4 1 version = 1 +//! 5 1 kind 1 = view, 2 = mask, 3 = adapter, 4 = recipe-set +//! 6 2 flags bit0 = revocable, bit1 = requires-session +//! 8 4 classid u32, the concept this projection targets +//! 12 8 bundle_id u64, monotonic per publisher +//! 20 8 issued_at u64, unix seconds +//! 28 32 publisher Ed25519 public key of the signer +//! 60 4 body_len u32 +//! 64 … body +//! … 64 signature Ed25519 over bytes [0 .. 64 + body_len) +//! ``` +//! +//! `HEADER_LEN` (64) is everything up to and including `body_len`; the +//! body follows immediately, and the signature is always the trailing 64 +//! bytes of the blob. +//! +//! ## The four things that make this a security boundary, not a format +//! +//! 1. **The signature covers the header *and* the body.** Every header +//! field — `kind`, `classid`, `version`, `flags`, all of it — is inside +//! the signed range `[0 .. 64 + body_len)`. That means a `kind = Mask` +//! header can never be spliced onto a `kind = View` body signed by the +//! same publisher, and a `classid` cannot be swapped between two +//! otherwise-legitimate bundles: any such splice changes the signed +//! bytes, and [`verify`] rejects it. The header is not a label glued to +//! the payload; it is part of what got signed. +//! 2. **`publisher` is carried in the blob, but it is not the trust +//! anchor.** [`verify`] takes a caller-supplied `allowed_publishers` +//! list and rejects any bundle whose embedded key is not on it — +//! including a bundle that is perfectly, validly self-signed. Carrying +//! the key in the blob exists so a rejection can say "unknown +//! publisher" instead of failing opaquely; trust still comes entirely +//! from the caller's allowlist, never from the blob asserting its own +//! legitimacy. (As a consequence of point 1, an attacker also cannot +//! forge the `publisher` field to impersonate an allowed key: the +//! signature only validates under the key that actually produced it, so +//! claiming a different `publisher` than the true signer just makes the +//! signature check fail.) +//! 3. **Verification happens before any use of the body.** [`verify`] +//! checks the signature and only *then* returns `&[u8]` for the body — +//! there is no separate "peek at the body" accessor and no path that +//! parses fields out of an unverified blob. Parse-then-verify (read the +//! header, act on it, check the signature as an afterthought) is the +//! bug class this API is shaped to make impossible: verification is not +//! a step you can accidentally do second. +//! 4. **Why a byte layout and not signed JSON.** JSON has more than one +//! valid serialization of the same logical document (key order, +//! whitespace, numeric formatting, duplicate-key handling), so "the +//! bytes that were signed" and "the bytes a second parser reads" can +//! disagree — a documented class of signature-bypass bugs. A fixed +//! little-endian layout has exactly one reading: byte 8 is always the +//! low byte of `classid`, on every implementation, forever. There is no +//! canonicalization step to get wrong because there is no alternate +//! form to canonicalize from. +//! +//! ## Example +//! +//! ``` +//! use encryption::bundle::{sign, verify, BundleFlags, BundleHeader, BundleKind}; +//! use encryption::sign::Keypair; +//! +//! let publisher = Keypair::generate().unwrap(); +//! +//! let header = BundleHeader { +//! kind: BundleKind::View, +//! flags: BundleFlags { +//! revocable: true, +//! requires_session: false, +//! }, +//! classid: 0x00A0_0042, +//! bundle_id: 7, +//! issued_at: 1_753_000_000, +//! publisher: [0u8; 32], // overwritten by `sign` with the real key +//! }; +//! +//! let blob = sign(header, b"...", &publisher); +//! +//! let allowed = [publisher.public_key()]; +//! let (verified, body) = verify(&blob, &allowed).unwrap(); +//! assert_eq!(body, b"..."); +//! assert_eq!(verified.classid, 0x00A0_0042); +//! assert_eq!(verified.kind, BundleKind::View); +//! ``` + +use crate::sign::{Keypair, PUBLIC_KEY_LEN, SIGNATURE_LEN}; + +/// Bundle magic: "ADAB" (Ada bundle). +pub const MAGIC: [u8; 4] = *b"ADAB"; +/// Current bundle layout version. +pub const VERSION: u8 = 1; +/// Length of the fixed header preceding the body (through `body_len`). +pub const HEADER_LEN: usize = 64; + +/// What kind of projection a bundle carries. Part of the signed header — +/// see module note #1: a `kind` cannot be swapped onto a different body +/// without breaking the signature. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BundleKind { + /// A UI view. + View = 1, + /// A visibility/redaction mask. + Mask = 2, + /// A render adapter. + Adapter = 3, + /// A named set of recipes. + RecipeSet = 4, +} + +impl BundleKind { + fn to_u8(self) -> u8 { + self as u8 + } + + fn from_u8(byte: u8) -> Option { + match byte { + 1 => Some(Self::View), + 2 => Some(Self::Mask), + 3 => Some(Self::Adapter), + 4 => Some(Self::RecipeSet), + _ => None, + } + } +} + +/// The two-bit flag field, decoded into named booleans. No external +/// bitflags dependency — this crate hand-rolls its layouts, flags +/// included. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct BundleFlags { + /// The publisher may revoke this bundle after issue (advisory to the + /// client; revocation itself is out of scope for this module). + pub revocable: bool, + /// The client must have an authenticated session before rendering + /// this bundle's body. + pub requires_session: bool, +} + +impl BundleFlags { + const REVOCABLE_BIT: u16 = 1 << 0; + const REQUIRES_SESSION_BIT: u16 = 1 << 1; + + fn to_bits(self) -> u16 { + (if self.revocable { Self::REVOCABLE_BIT } else { 0 }) + | (if self.requires_session { + Self::REQUIRES_SESSION_BIT + } else { + 0 + }) + } + + fn from_bits(bits: u16) -> Self { + Self { + revocable: bits & Self::REVOCABLE_BIT != 0, + requires_session: bits & Self::REQUIRES_SESSION_BIT != 0, + } + } +} + +/// The bundle header, typed. Every field here is inside the signed range — +/// see module note #1. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BundleHeader { + /// What kind of projection this is. + pub kind: BundleKind, + /// Publisher-set behavior flags. + pub flags: BundleFlags, + /// The concept (classid) this projection targets. + pub classid: u32, + /// Monotonic id, scoped to the publisher. + pub bundle_id: u64, + /// Unix seconds at signing time. + pub issued_at: u64, + /// The signer's Ed25519 public key. Set by [`sign`] from the actual + /// signing key — whatever value the caller puts here before calling + /// [`sign`] is discarded, so it is not possible to mint a bundle that + /// misrepresents its own signer (see module note #2). + pub publisher: [u8; PUBLIC_KEY_LEN], +} + +/// Why a bundle could not be minted or verified. Field-free — a rejection +/// never says which byte looked wrong, only which class of problem it was. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BundleError { + /// The blob is not a bundle: too short, bad magic, bad `kind`, or its + /// declared `body_len` does not match the buffer it was found in. + Malformed, + /// The blob's layout version is newer than this code understands. + UnsupportedVersion, + /// The embedded `publisher` key is not in the caller's allowlist. + /// This includes a bundle that is validly self-signed — an allowlist + /// hit is required regardless of how good the signature is. + UnknownPublisher, + /// The Ed25519 signature does not validate over the header+body. + BadSignature, +} + +impl core::fmt::Display for BundleError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + BundleError::Malformed => f.write_str("not a well-formed bundle"), + BundleError::UnsupportedVersion => f.write_str("unsupported bundle version"), + BundleError::UnknownPublisher => f.write_str("publisher is not in the allowlist"), + BundleError::BadSignature => f.write_str("signature does not validate"), + } + } +} + +impl std::error::Error for BundleError {} + +/// Sign `body` under `header`, producing a distributable bundle blob. +/// +/// `header.publisher` is overwritten with `signing_key.public_key()` before +/// signing — the caller does not need to (and cannot usefully) set it +/// themselves; see the field doc on [`BundleHeader::publisher`]. +/// +/// # Panics +/// +/// Panics if `body.len()` exceeds `u32::MAX`. This is a local encoding +/// precondition on the signer's own input, not a check against untrusted +/// data — nothing this crate distributes is anywhere near 4 GiB. +pub fn sign(mut header: BundleHeader, body: &[u8], signing_key: &Keypair) -> Vec { + header.publisher = signing_key.public_key(); + let body_len: u32 = body + .len() + .try_into() + .expect("bundle body exceeds u32::MAX bytes"); + + let head = encode_header(&header, body_len); + let mut signed_region = Vec::with_capacity(HEADER_LEN + body.len()); + signed_region.extend_from_slice(&head); + signed_region.extend_from_slice(body); + + let signature = signing_key.sign(&signed_region); + let mut blob = signed_region; + blob.extend_from_slice(&signature); + blob +} + +/// Verify `blob` against `allowed_publishers`, returning the parsed header +/// and a borrowed slice of the body — never a copy. +/// +/// The body is returned *only* if every check passes: well-formed layout, +/// a supported version, a `publisher` present in `allowed_publishers`, and +/// a valid Ed25519 signature over the header+body. There is no way to +/// reach the body bytes through this module without passing all four (see +/// module note #3). +pub fn verify<'b>( + blob: &'b [u8], allowed_publishers: &[[u8; PUBLIC_KEY_LEN]], +) -> Result<(BundleHeader, &'b [u8]), BundleError> { + if blob.len() < HEADER_LEN + SIGNATURE_LEN { + return Err(BundleError::Malformed); + } + + let (header, body_len) = decode_header(blob)?; + let body_len = body_len as usize; + + // The length field is untrusted input: it is checked against the + // buffer it actually arrived in before it is used for any slicing. + // Trusting a declared length over the real buffer size is the classic + // parser bug this guards against. + let expected_len = HEADER_LEN + .checked_add(body_len) + .and_then(|v| v.checked_add(SIGNATURE_LEN)) + .ok_or(BundleError::Malformed)?; + if blob.len() != expected_len { + return Err(BundleError::Malformed); + } + + // A publisher key is public material, so an early-out comparison leaks + // nothing worth having; the signature check below is the one that must + // stay constant-time, and ed25519-dalek owns that. + if !allowed_publishers.contains(&header.publisher) { + return Err(BundleError::UnknownPublisher); + } + + let signed_region = &blob[..HEADER_LEN + body_len]; + let mut signature = [0u8; SIGNATURE_LEN]; + signature.copy_from_slice(&blob[HEADER_LEN + body_len..expected_len]); + + if !crate::sign::verify(&header.publisher, signed_region, &signature) { + return Err(BundleError::BadSignature); + } + + let body = &blob[HEADER_LEN..HEADER_LEN + body_len]; + Ok((header, body)) +} + +fn encode_header(header: &BundleHeader, body_len: u32) -> [u8; HEADER_LEN] { + let mut h = [0u8; HEADER_LEN]; + h[0..4].copy_from_slice(&MAGIC); + h[4] = VERSION; + h[5] = header.kind.to_u8(); + h[6..8].copy_from_slice(&header.flags.to_bits().to_le_bytes()); + h[8..12].copy_from_slice(&header.classid.to_le_bytes()); + h[12..20].copy_from_slice(&header.bundle_id.to_le_bytes()); + h[20..28].copy_from_slice(&header.issued_at.to_le_bytes()); + h[28..28 + PUBLIC_KEY_LEN].copy_from_slice(&header.publisher); + h[60..64].copy_from_slice(&body_len.to_le_bytes()); + h +} + +fn decode_header(blob: &[u8]) -> Result<(BundleHeader, u32), BundleError> { + if blob.len() < HEADER_LEN || blob[0..4] != MAGIC { + return Err(BundleError::Malformed); + } + if blob[4] != VERSION { + return Err(BundleError::UnsupportedVersion); + } + let kind = BundleKind::from_u8(blob[5]).ok_or(BundleError::Malformed)?; + let flags = BundleFlags::from_bits(u16::from_le_bytes([blob[6], blob[7]])); + let classid = u32::from_le_bytes([blob[8], blob[9], blob[10], blob[11]]); + let bundle_id = u64::from_le_bytes(blob[12..20].try_into().unwrap()); + let issued_at = u64::from_le_bytes(blob[20..28].try_into().unwrap()); + let mut publisher = [0u8; PUBLIC_KEY_LEN]; + publisher.copy_from_slice(&blob[28..28 + PUBLIC_KEY_LEN]); + let body_len = u32::from_le_bytes(blob[60..64].try_into().unwrap()); + + Ok(( + BundleHeader { + kind, + flags, + classid, + bundle_id, + issued_at, + publisher, + }, + body_len, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_header() -> BundleHeader { + BundleHeader { + kind: BundleKind::View, + flags: BundleFlags { + revocable: true, + requires_session: false, + }, + classid: 0x1234_5678, + bundle_id: 99, + issued_at: 1_753_000_000, + publisher: [0u8; PUBLIC_KEY_LEN], // sign() overwrites this + } + } + + #[test] + fn round_trip_returns_the_exact_body_bytes() { + let kp = Keypair::generate().unwrap(); + let blob = sign(sample_header(), b"the projection body", &kp); + let (header, body) = verify(&blob, &[kp.public_key()]).unwrap(); + assert_eq!(body, b"the projection body"); + assert_eq!(header.classid, 0x1234_5678); + assert_eq!(header.bundle_id, 99); + assert_eq!(header.kind, BundleKind::View); + assert!(header.flags.revocable); + assert!(!header.flags.requires_session); + assert_eq!(header.publisher, kp.public_key()); + } + + #[test] + fn a_mutated_body_byte_fails_to_verify() { + let kp = Keypair::generate().unwrap(); + let mut blob = sign(sample_header(), b"the projection body", &kp); + let body_start = HEADER_LEN; + blob[body_start] ^= 0x01; + assert_eq!(verify(&blob, &[kp.public_key()]).unwrap_err(), BundleError::BadSignature); + } + + /// Finding #1, proven: the signature covers the header, so `classid` + /// cannot be swapped onto a different, otherwise-validly-signed body. + #[test] + fn a_mutated_classid_bit_fails_to_verify() { + let kp = Keypair::generate().unwrap(); + let mut blob = sign(sample_header(), b"body", &kp); + blob[8] ^= 0x01; // low byte of classid, inside the signed header + assert_eq!(verify(&blob, &[kp.public_key()]).unwrap_err(), BundleError::BadSignature); + } + + /// Same finding, different field: flipping `kind` from `View` (1) to + /// `Adapter` (3) is still a structurally valid header — the signature, + /// not the parser, is what catches the swap. + #[test] + fn a_mutated_kind_byte_fails_to_verify() { + let kp = Keypair::generate().unwrap(); + let mut blob = sign(sample_header(), b"body", &kp); + assert_eq!(blob[5], BundleKind::View.to_u8()); + blob[5] ^= 0b10; // View(1) -> Adapter(3), still a valid kind byte + assert_eq!(verify(&blob, &[kp.public_key()]).unwrap_err(), BundleError::BadSignature); + } + + #[test] + fn an_unlisted_publisher_is_rejected() { + let kp = Keypair::generate().unwrap(); + let other = Keypair::generate().unwrap(); + let blob = sign(sample_header(), b"body", &kp); + assert_eq!(verify(&blob, &[other.public_key()]).unwrap_err(), BundleError::UnknownPublisher); + } + + #[test] + fn an_empty_allowlist_rejects_everything() { + let kp = Keypair::generate().unwrap(); + let blob = sign(sample_header(), b"body", &kp); + assert_eq!(verify(&blob, &[]).unwrap_err(), BundleError::UnknownPublisher); + } + + #[test] + fn every_truncation_length_is_rejected_without_panicking() { + let kp = Keypair::generate().unwrap(); + let blob = sign(sample_header(), b"the projection body", &kp); + for len in 0..blob.len() { + let truncated = &blob[..len]; + assert!( + verify(truncated, &[kp.public_key()]).is_err(), + "truncation to {len} bytes must be rejected, not accepted" + ); + } + // The full, untruncated blob is the one length that must succeed. + assert!(verify(&blob, &[kp.public_key()]).is_ok()); + } + + /// The classic parser bug: trust the length field instead of the + /// buffer. Here `body_len` still says what it said at signing time, + /// but the buffer has grown past it. + #[test] + fn a_body_len_that_disagrees_with_the_buffer_is_rejected() { + let kp = Keypair::generate().unwrap(); + let mut blob = sign(sample_header(), b"body", &kp); + blob.push(0xAA); // buffer is now longer than body_len promises + assert_eq!(verify(&blob, &[kp.public_key()]).unwrap_err(), BundleError::Malformed); + } + + #[test] + fn wrong_magic_is_rejected() { + let kp = Keypair::generate().unwrap(); + let mut blob = sign(sample_header(), b"body", &kp); + blob[0] = b'X'; + assert_eq!(verify(&blob, &[kp.public_key()]).unwrap_err(), BundleError::Malformed); + } + + #[test] + fn wrong_version_is_rejected() { + let kp = Keypair::generate().unwrap(); + let mut blob = sign(sample_header(), b"body", &kp); + blob[4] = 99; + assert_eq!(verify(&blob, &[kp.public_key()]).unwrap_err(), BundleError::UnsupportedVersion); + } + + #[test] + fn empty_body_round_trips() { + let kp = Keypair::generate().unwrap(); + let blob = sign(sample_header(), b"", &kp); + assert_eq!(blob.len(), HEADER_LEN + SIGNATURE_LEN); + let (_, body) = verify(&blob, &[kp.public_key()]).unwrap(); + assert!(body.is_empty()); + } +} diff --git a/crates/encryption/src/hkdf_sha384.rs b/crates/encryption/src/hkdf_sha384.rs new file mode 100644 index 00000000..76b90cdb --- /dev/null +++ b/crates/encryption/src/hkdf_sha384.rs @@ -0,0 +1,299 @@ +//! HKDF-SHA-384 key schedule for the sealed session channel. +//! +//! This is a thin, typed wrapper over the RustCrypto [`hkdf`] crate, pinned +//! to SHA-384 (see [`crate::hash`] for why SHA-384 is this workspace's +//! truncation-resistant hash of choice). +//! +//! ## Intended use +//! +//! This module is the key schedule for a Noise_NK-shaped handshake: +//! +//! - `ikm` (input key material) is the **concatenation of the X25519 shared +//! secrets** produced by the key-exchange step (see `crate::kx`, which +//! supplies the Diffie-Hellman output this module consumes — it does not +//! do key agreement itself). +//! - `salt` is the **handshake transcript hash** — binding the derived keys +//! to the exact sequence of messages that produced them, so a +//! transcript substitution changes every downstream key. +//! - `info` is a **domain separator**. Callers MUST use a distinct `info` +//! per protocol version (and per intended use of the output, e.g. +//! "record key" vs "rekey seed"). HKDF-Expand treats `info` as part of +//! the label that binds output bytes to a specific context; reusing one +//! `info` across two protocol versions means a key derived under version +//! 1 and a key derived under version 2 can collide if the rest of the +//! transcript ever coincides. A version byte (or string) folded into +//! `info` keeps the two derivations in disjoint output spaces even if +//! every other input matches. +//! +//! ## Extract / Expand / one-shot +//! +//! [`extract`] and [`expand`] expose the two RFC 5869 steps separately for +//! callers that need to run one Extract and many Expands from the same +//! [`Prk`] (e.g. deriving several sub-keys, as [`session_keys`] does). +//! [`derive`] is the one-shot convenience for callers who only need a +//! single Expand and don't want to hold a [`Prk`] around. + +use hkdf::Hkdf; +use sha2::Sha384; +use zeroize::{Zeroize, ZeroizeOnDrop}; + +/// Byte length of the SHA-384 HKDF pseudorandom key (== HMAC-SHA-384's +/// output size). +pub const PRK_LEN: usize = 48; + +/// The largest output HKDF-Expand can produce for any hash: `255` times +/// the hash's output length. For SHA-384 that is `255 * 48 = 12_240` +/// bytes — this is the only way [`expand`] or [`derive`] can fail. +pub const MAX_OKM_LEN: usize = 255 * PRK_LEN; + +/// The HKDF-Extract output (the "pseudorandom key", PRK). Wiped from +/// memory on drop; deliberately has no `Debug` impl — a key must never be +/// printable. +#[derive(Zeroize, ZeroizeOnDrop)] +pub struct Prk([u8; PRK_LEN]); + +impl Prk { + /// Borrow the raw PRK bytes (for handing to [`expand`] or an + /// alternative HKDF-Expand implementation). + pub fn as_bytes(&self) -> &[u8; PRK_LEN] { + &self.0 + } +} + +/// HKDF failure. Field-free on purpose — no secret material, no +/// parameter echo. +/// +/// The only way HKDF-Expand fails per RFC 5869 is an output request past +/// its length ceiling ([`MAX_OKM_LEN`]); there is no other failure mode in +/// this module. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HkdfError { + /// The requested output (`out.len()` / the `L` parameter in RFC 5869 + /// terms) exceeds [`MAX_OKM_LEN`]. + OutputTooLong, +} + +impl core::fmt::Display for HkdfError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + HkdfError::OutputTooLong => f.write_str("HKDF output length exceeds 255 * hash length"), + } + } +} + +impl std::error::Error for HkdfError {} + +/// HKDF-Extract: condense `salt` and `ikm` into a uniformly-random, +/// fixed-length [`Prk`]. Cannot fail — Extract is a single HMAC call. +/// +/// ``` +/// use encryption::hkdf_sha384::extract; +/// +/// let prk = extract(b"transcript-hash", b"x25519-shared-secret"); +/// assert_eq!(prk.as_bytes().len(), 48); +/// ``` +pub fn extract(salt: &[u8], ikm: &[u8]) -> Prk { + let (prk, _hkdf) = Hkdf::::extract(Some(salt), ikm); + let mut out = [0u8; PRK_LEN]; + out.copy_from_slice(&prk); + Prk(out) +} + +/// HKDF-Expand: stretch a [`Prk`] into `out.len()` bytes of output key +/// material, bound to `info`. +/// +/// ``` +/// use encryption::hkdf_sha384::{extract, expand}; +/// +/// let prk = extract(b"salt", b"ikm"); +/// let mut key = [0u8; 32]; +/// expand(&prk, b"medcare-rs/v1/record-key", &mut key).unwrap(); +/// ``` +pub fn expand(prk: &Prk, info: &[u8], out: &mut [u8]) -> Result<(), HkdfError> { + // A `Prk` is always exactly `PRK_LEN` bytes — the length `Hkdf::extract` + // for SHA-384 always produces — so `from_prk`'s "at least HashLen" + // check can never reject it. + let hk = Hkdf::::from_prk(prk.as_bytes()) + .expect("Prk is always PRK_LEN (48) bytes, exactly SHA-384's output size"); + hk.expand(info, out).map_err(|_| HkdfError::OutputTooLong) +} + +/// One-shot HKDF-Extract-then-Expand: derive `out.len()` bytes directly +/// from `salt`, `ikm`, and `info` without exposing an intermediate +/// [`Prk`]. Equivalent to `expand(&extract(salt, ikm), info, out)`, at the +/// cost of one fewer HMAC pass since the PRK is consumed internally +/// instead of round-tripped through a byte array. +/// +/// ``` +/// use encryption::hkdf_sha384::derive; +/// +/// let mut key = [0u8; 32]; +/// derive(b"transcript-hash", b"shared-secret", b"medcare-rs/v1", &mut key).unwrap(); +/// ``` +pub fn derive(salt: &[u8], ikm: &[u8], info: &[u8], out: &mut [u8]) -> Result<(), HkdfError> { + let hk = Hkdf::::new(Some(salt), ikm); + hk.expand(info, out).map_err(|_| HkdfError::OutputTooLong) +} + +/// The two directional keys of a sealed session channel: client-to-server +/// and server-to-client. Wiped from memory on drop; deliberately has no +/// `Debug` impl. +/// +/// **Why two keys, not one:** if both directions of a channel used the +/// same key, an attacker could take a genuine record sent in one +/// direction and replay it back in the other — reflection — and it would +/// authenticate, because the same key produced a valid tag either way. +/// Deriving `c2s` and `s2c` from independent HKDF-Expand labels means a +/// record authenticated under one direction's key is simply not valid +/// under the other's; a reflected record fails to authenticate instead of +/// silently passing. +#[derive(Zeroize, ZeroizeOnDrop)] +pub struct SessionKeys { + /// Client-to-server key. + pub c2s: [u8; 32], + /// Server-to-client key. + pub s2c: [u8; 32], +} + +/// Derive both directional session keys from a single handshake output. +/// +/// Runs one HKDF-Extract over `(salt, ikm)`, then two HKDF-Expand calls +/// against that shared [`Prk`] — one labeled for the client-to-server +/// direction, one for server-to-client — so the two keys are +/// cryptographically independent even though they share a PRK. See +/// [`SessionKeys`] for why the directions must not share a key. +/// +/// `info` is still the caller's domain separator (see the module docs on +/// `info` / protocol versioning); the directional label is appended after +/// it, not folded into it, so callers keep control of the version tag. +/// +/// Infallible: each directional output is a fixed 32 bytes, far below +/// [`MAX_OKM_LEN`], so the only failure mode [`expand`] has can never +/// trigger here. +/// +/// ``` +/// use encryption::hkdf_sha384::session_keys; +/// +/// let keys = session_keys(b"transcript-hash", b"shared-secret", b"medcare-rs/v1"); +/// assert_ne!(keys.c2s, keys.s2c); +/// ``` +pub fn session_keys(salt: &[u8], ikm: &[u8], info: &[u8]) -> SessionKeys { + let prk = extract(salt, ikm); + let hk = Hkdf::::from_prk(prk.as_bytes()) + .expect("Prk is always PRK_LEN (48) bytes, exactly SHA-384's output size"); + + let c2s_label: &[u8] = b"|c2s"; + let s2c_label: &[u8] = b"|s2c"; + let mut c2s = [0u8; 32]; + let mut s2c = [0u8; 32]; + hk.expand_multi_info(&[info, c2s_label], &mut c2s) + .expect("32 bytes is far below MAX_OKM_LEN for SHA-384"); + hk.expand_multi_info(&[info, s2c_label], &mut s2c) + .expect("32 bytes is far below MAX_OKM_LEN for SHA-384"); + SessionKeys { c2s, s2c } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deterministic_for_same_inputs() { + let mut a = [0u8; 32]; + let mut b = [0u8; 32]; + derive(b"salt", b"ikm", b"info", &mut a).unwrap(); + derive(b"salt", b"ikm", b"info", &mut b).unwrap(); + assert_eq!(a, b); + } + + #[test] + fn different_salt_changes_the_output() { + let mut a = [0u8; 32]; + let mut b = [0u8; 32]; + derive(b"salt-a", b"ikm", b"info", &mut a).unwrap(); + derive(b"salt-b", b"ikm", b"info", &mut b).unwrap(); + assert_ne!(a, b); + } + + #[test] + fn different_ikm_changes_the_output() { + let mut a = [0u8; 32]; + let mut b = [0u8; 32]; + derive(b"salt", b"ikm-a", b"info", &mut a).unwrap(); + derive(b"salt", b"ikm-b", b"info", &mut b).unwrap(); + assert_ne!(a, b); + } + + #[test] + fn different_info_changes_the_output() { + let mut a = [0u8; 32]; + let mut b = [0u8; 32]; + derive(b"salt", b"ikm", b"info-a", &mut a).unwrap(); + derive(b"salt", b"ikm", b"info-b", &mut b).unwrap(); + assert_ne!(a, b); + } + + #[test] + fn the_two_directional_keys_differ_from_each_other() { + let keys = session_keys(b"transcript-hash", b"shared-secret", b"medcare-rs/v1"); + assert_ne!(keys.c2s, keys.s2c); + } + + #[test] + fn output_lengths_other_than_32_bytes_work() { + for len in [16usize, 64, 96] { + let mut out = vec![0u8; len]; + derive(b"salt", b"ikm", b"info", &mut out).unwrap(); + assert!(out.iter().any(|&b| b != 0), "derived output of length {len} was all-zero"); + } + } + + /// HKDF-Expand refuses an output request past `255 * hash_len` bytes + /// (RFC 5869 §2.3) instead of silently truncating it. + #[test] + fn an_over_long_output_is_rejected_rather_than_truncated() { + let mut out = vec![0u8; MAX_OKM_LEN + 1]; + assert_eq!(derive(b"salt", b"ikm", b"info", &mut out), Err(HkdfError::OutputTooLong)); + + // The ceiling itself is still admitted. + let mut at_ceiling = vec![0u8; MAX_OKM_LEN]; + assert_eq!(derive(b"salt", b"ikm", b"info", &mut at_ceiling), Ok(())); + } + + /// RFC 5869 §A.1 (Test Case 1), the canonical published HKDF vector — + /// SHA-256, not SHA-384; no SHA-384 vector has ever been published by + /// the RFC or a widely-cited independent source. This exercises the + /// same `hkdf` crate machinery this module's SHA-384 wrapper sits on + /// top of (same `Hkdf` type, `H` swapped for `Sha256`), as a wiring + /// sanity check: it proves this crate's dependency wiring (feature + /// flags, no_std posture) reproduces a real, citable vector, which is + /// the closest available evidence that the SHA-384 instantiation + /// alongside it is wired the same way. + // + // TODO(orchestrator): no published SHA-384 vector; consider + // cross-checking against an independent implementation. + #[test] + fn rfc5869_test_case_1_sha256_wiring_check() { + use sha2::Sha256; + + let ikm = [0x0bu8; 22]; + #[rustfmt::skip] + let salt: [u8; 13] = [ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, + ]; + #[rustfmt::skip] + let info: [u8; 10] = [0xf0, 0xf1, 0xf2, 0xf3, 0xf4, 0xf5, 0xf6, 0xf7, 0xf8, 0xf9]; + #[rustfmt::skip] + let expected_okm: [u8; 42] = [ + 0x3c, 0xb2, 0x5f, 0x25, 0xfa, 0xac, 0xd5, 0x7a, 0x90, 0x43, 0x4f, 0x64, 0xd0, 0x36, 0x2f, 0x2a, + 0x2d, 0x2d, 0x0a, 0x90, 0xcf, 0x1a, 0x5a, 0x4c, 0x5d, 0xb0, 0x2d, 0x56, 0xec, 0xc4, 0xc5, 0xbf, + 0x34, 0x00, 0x72, 0x08, 0xd5, 0xb8, 0x87, 0x18, 0x58, 0x65, + ]; + + let hk = Hkdf::::new(Some(&salt[..]), &ikm); + let mut okm = [0u8; 42]; + hk.expand(&info, &mut okm) + .expect("42 is a valid length for SHA-256 to output"); + assert_eq!(okm, expected_okm); + } +} diff --git a/crates/encryption/src/kx.rs b/crates/encryption/src/kx.rs new file mode 100644 index 00000000..3552c934 --- /dev/null +++ b/crates/encryption/src/kx.rs @@ -0,0 +1,295 @@ +//! X25519 Diffie-Hellman key agreement — the shared secret behind the +//! sealed channel (`kdf.rs` derives the AEAD key from a password; this +//! module derives it from a key exchange instead). +//! +//! Two secret shapes, matching the upstream `x25519-dalek` distinction: +//! +//! - [`EphemeralSecret`] — fresh per exchange, consumed by +//! [`EphemeralSecret::diffie_hellman`]. Cannot be reused: the method +//! takes `self`, not `&self`, so the compiler refuses a second call. +//! - [`StaticSecret`] — long-lived, constructible from 32 bytes (e.g. a +//! passphrase-derived key from [`crate::kdf`]), reusable across many +//! exchanges via `&self`. +//! +//! ## Why `EphemeralSecret` is built on `x25519_dalek::StaticSecret` +//! +//! `x25519_dalek::EphemeralSecret` only offers `random_from_rng` (needs a +//! `rand_core::RngCore + CryptoRng`, and `rand_core` is not a dependency +//! of this crate — [`crate::fill_random`] is the only entropy chokepoint) +//! and `random()` (gated behind the `getrandom` feature, which this +//! crate's `x25519-dalek` dependency does not enable). Neither path is +//! reachable without adding a dependency this crate deliberately does +//! not carry. `x25519_dalek::StaticSecret` has no such restriction — it +//! implements `From<[u8; 32]>` — so [`EphemeralSecret`] wraps one, +//! filled from [`crate::fill_random`], and recovers the "used at most +//! once" guarantee at the type level instead: its +//! [`diffie_hellman`](EphemeralSecret::diffie_hellman) takes `self` by +//! value, so the secret is moved-from and unusable after the first call, +//! exactly as the upstream ephemeral type enforces it. +//! +//! ## Low-order peer keys +//! +//! X25519 does not validate that a peer's public key is a full-order +//! point. A small number of "low-order" byte strings — the all-zero +//! string among them — multiply out to the same shared secret +//! (the curve identity) no matter what secret key is on the other side. +//! An attacker able to substitute a peer's public key in transit can +//! hand both ends one of these points and force them onto a shared +//! secret the attacker already knows in advance, without either side +//! detecting anything wrong (they still agree with each other). This is +//! exactly the attack [`x25519_dalek::SharedSecret::was_contributory`] +//! exists to catch, and both [`EphemeralSecret::diffie_hellman`] and +//! [`StaticSecret::diffie_hellman`] call it on every agreement, rejecting +//! the result with [`KxError::LowOrderPeerKey`] instead of silently +//! handing back a secret an attacker already holds. +//! +//! ``` +//! use encryption::kx::{EphemeralSecret, StaticSecret}; +//! +//! // Alice is ephemeral (fresh per session); Bob is static (a long-lived +//! // identity key, e.g. loaded from storage via `StaticSecret::from_bytes`). +//! let alice = EphemeralSecret::generate().unwrap(); +//! let bob = StaticSecret::generate().unwrap(); +//! +//! let alice_public = alice.public_key(); +//! let bob_public = bob.public_key(); +//! +//! let alice_shared = alice.diffie_hellman(&bob_public).unwrap(); +//! let bob_shared = bob.diffie_hellman(&alice_public).unwrap(); +//! +//! assert_eq!(alice_shared.as_bytes(), bob_shared.as_bytes()); +//! ``` + +use zeroize::Zeroize; + +/// Byte length of an X25519 public key. +pub const PUBLIC_KEY_LEN: usize = 32; +/// Byte length of an X25519 shared secret. +pub const SHARED_SECRET_LEN: usize = 32; + +/// Key-agreement failure. Field-free on purpose — no secret material, +/// no key echo. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KxError { + /// The platform CSPRNG was unavailable while generating a secret. + Rng, + /// The agreed-upon shared secret was the curve identity (all-zero) — + /// the peer's public key was a low-order point. See the module docs' + /// "Low-order peer keys" section for why this is rejected rather than + /// silently returned. + LowOrderPeerKey, +} + +impl core::fmt::Display for KxError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + KxError::Rng => f.write_str("platform CSPRNG unavailable"), + KxError::LowOrderPeerKey => f.write_str("peer public key is a low-order point; shared secret rejected"), + } + } +} + +impl std::error::Error for KxError {} + +/// Fill a fresh 32-byte scalar from the platform CSPRNG. The one place +/// this module touches entropy. +fn random_scalar_bytes() -> Result<[u8; 32], KxError> { + let mut bytes = [0u8; 32]; + crate::fill_random(&mut bytes).map_err(|_| KxError::Rng)?; + Ok(bytes) +} + +/// A public X25519 key: the 32-byte Montgomery `u`-coordinate. +/// +/// Not secret — safe to log, store, and send over the wire in the clear. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PublicKey(x25519_dalek::PublicKey); + +impl PublicKey { + /// Wrap a raw 32-byte public key (e.g. received from a peer). + pub fn from_bytes(bytes: [u8; PUBLIC_KEY_LEN]) -> Self { + PublicKey(x25519_dalek::PublicKey::from(bytes)) + } + + /// Borrow the raw 32 bytes (e.g. to send to a peer). + pub fn as_bytes(&self) -> &[u8; PUBLIC_KEY_LEN] { + self.0.as_bytes() + } +} + +/// The result of a Diffie-Hellman agreement. +/// +/// Re-exported directly from `x25519-dalek`: it already zeroizes on drop +/// (this crate's `x25519-dalek` dependency enables the `zeroize` +/// feature) and deliberately has **no `Debug` impl** — the same choice +/// [`crate::kdf::DerivedKey`] makes. A shared secret must never be +/// printable; logging one is a key-disclosure bug waiting to happen. +pub use x25519_dalek::SharedSecret; + +/// Reject a shared secret computed from a low-order peer public key. +/// Shared by both [`EphemeralSecret::diffie_hellman`] and +/// [`StaticSecret::diffie_hellman`]. +fn checked(shared: SharedSecret) -> Result { + if shared.was_contributory() { + Ok(shared) + } else { + Err(KxError::LowOrderPeerKey) + } +} + +/// A fresh, single-use X25519 secret. +/// +/// Generated from the platform CSPRNG; consumed by +/// [`diffie_hellman`](Self::diffie_hellman), which takes `self` so the +/// compiler refuses a second agreement with the same secret. See the +/// module docs for why this wraps `x25519_dalek::StaticSecret` rather +/// than `x25519_dalek::EphemeralSecret`. +pub struct EphemeralSecret(x25519_dalek::StaticSecret); + +impl EphemeralSecret { + /// Generate a fresh secret from the platform CSPRNG. + pub fn generate() -> Result { + let mut bytes = random_scalar_bytes()?; + let secret = x25519_dalek::StaticSecret::from(bytes); + bytes.zeroize(); + Ok(EphemeralSecret(secret)) + } + + /// The public key corresponding to this secret, to send to the peer. + pub fn public_key(&self) -> PublicKey { + PublicKey(x25519_dalek::PublicKey::from(&self.0)) + } + + /// Perform the agreement against a peer's public key, consuming this + /// secret so it cannot be reused. Rejects a low-order `their_public` + /// (see the module docs). + pub fn diffie_hellman(self, their_public: &PublicKey) -> Result { + checked(self.0.diffie_hellman(&their_public.0)) + } +} + +/// A long-lived X25519 secret, reusable across many agreements. +/// +/// Construct from the CSPRNG ([`StaticSecret::generate`]) or from 32 +/// bytes the caller already owns ([`StaticSecret::from_bytes`] — e.g. a +/// key loaded from storage, or derived by [`crate::kdf`] from a +/// passphrase). +pub struct StaticSecret(x25519_dalek::StaticSecret); + +impl StaticSecret { + /// Generate a fresh secret from the platform CSPRNG. + pub fn generate() -> Result { + let mut bytes = random_scalar_bytes()?; + let secret = x25519_dalek::StaticSecret::from(bytes); + bytes.zeroize(); + Ok(StaticSecret(secret)) + } + + /// Load a secret from 32 bytes the caller already owns. + pub fn from_bytes(bytes: [u8; 32]) -> Self { + StaticSecret(x25519_dalek::StaticSecret::from(bytes)) + } + + /// The public key corresponding to this secret. + pub fn public_key(&self) -> PublicKey { + PublicKey(x25519_dalek::PublicKey::from(&self.0)) + } + + /// Perform the agreement against a peer's public key. Does not + /// consume `self` — the same secret may agree with many peers. + /// Rejects a low-order `their_public` (see the module docs). + pub fn diffie_hellman(&self, their_public: &PublicKey) -> Result { + checked(self.0.diffie_hellman(&their_public.0)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// RFC 7748 §5.2 X25519 ladder test vector ("vectorset1"), copied + /// verbatim from the vendored `x25519-dalek` 2.0.1 test suite + /// (`tests/x25519_tests.rs::rfc7748_ladder_test1_vectorset1`) rather + /// than transcribed from memory, so the bytes are known-good rather + /// than guessed. Routed through this module's typed API + /// (`StaticSecret::from_bytes` + `diffie_hellman`) rather than the + /// bare `x25519()` function, since that typed path is what this + /// module actually exposes and it calls the identical + /// `mul_clamped` internally. + #[test] + fn rfc7748_ladder_test_vector() { + let scalar: [u8; 32] = [ + 0xa5, 0x46, 0xe3, 0x6b, 0xf0, 0x52, 0x7c, 0x9d, 0x3b, 0x16, 0x15, 0x4b, 0x82, 0x46, 0x5e, 0xdd, 0x62, 0x14, + 0x4c, 0x0a, 0xc1, 0xfc, 0x5a, 0x18, 0x50, 0x6a, 0x22, 0x44, 0xba, 0x44, 0x9a, 0xc4, + ]; + let point: [u8; 32] = [ + 0xe6, 0xdb, 0x68, 0x67, 0x58, 0x30, 0x30, 0xdb, 0x35, 0x94, 0xc1, 0xa4, 0x24, 0xb1, 0x5f, 0x7c, 0x72, 0x66, + 0x24, 0xec, 0x26, 0xb3, 0x35, 0x3b, 0x10, 0xa9, 0x03, 0xa6, 0xd0, 0xab, 0x1c, 0x4c, + ]; + let expected: [u8; 32] = [ + 0xc3, 0xda, 0x55, 0x37, 0x9d, 0xe9, 0xc6, 0x90, 0x8e, 0x94, 0xea, 0x4d, 0xf2, 0x8d, 0x08, 0x4f, 0x32, 0xec, + 0xcf, 0x03, 0x49, 0x1c, 0x71, 0xf7, 0x54, 0xb4, 0x07, 0x55, 0x77, 0xa2, 0x85, 0x52, + ]; + + let secret = StaticSecret::from_bytes(scalar); + let peer = PublicKey::from_bytes(point); + let shared = secret + .diffie_hellman(&peer) + .expect("known-good ladder vector must be contributory"); + assert_eq!(shared.as_bytes(), &expected); + } + + #[test] + fn alice_and_bob_derive_the_same_shared_secret() { + let alice = EphemeralSecret::generate().unwrap(); + let bob = StaticSecret::generate().unwrap(); + + let alice_public = alice.public_key(); + let bob_public = bob.public_key(); + + let alice_shared = alice.diffie_hellman(&bob_public).unwrap(); + let bob_shared = bob.diffie_hellman(&alice_public).unwrap(); + + assert_eq!(alice_shared.as_bytes(), bob_shared.as_bytes()); + } + + #[test] + fn a_different_peer_key_gives_a_different_shared_secret() { + let alice = StaticSecret::generate().unwrap(); + let bob = StaticSecret::generate().unwrap(); + let carol = StaticSecret::generate().unwrap(); + + let shared_with_bob = alice.diffie_hellman(&bob.public_key()).unwrap(); + let shared_with_carol = alice.diffie_hellman(&carol.public_key()).unwrap(); + + assert_ne!(shared_with_bob.as_bytes(), shared_with_carol.as_bytes()); + } + + /// An all-zero peer public key is a low-order point: X25519 would + /// otherwise happily compute a shared secret from it, and that + /// secret is the curve identity regardless of our own secret key — + /// exactly the "Mallory replaces both public keys with zero" attack + /// described in the module docs. This must be refused, not returned. + #[test] + fn an_all_zero_peer_public_key_is_rejected() { + let alice = StaticSecret::generate().unwrap(); + let low_order_peer = PublicKey::from_bytes([0u8; 32]); + + match alice.diffie_hellman(&low_order_peer) { + Err(e) => assert_eq!(e, KxError::LowOrderPeerKey), + Ok(_) => panic!("a low-order (all-zero) peer public key must be rejected"), + } + } + + #[test] + fn generated_secrets_differ_across_calls() { + // The raw secret is never exposed for comparison (by design — see + // `EphemeralSecret`/`StaticSecret`), so the CSPRNG-is-actually-used + // check compares the public keys derived from two independently + // generated secrets instead. Identical public keys would mean + // identical secrets, which would mean the CSPRNG was not consulted. + let a = EphemeralSecret::generate().unwrap(); + let b = EphemeralSecret::generate().unwrap(); + assert_ne!(a.public_key().as_bytes(), b.public_key().as_bytes()); + } +} diff --git a/crates/encryption/src/lib.rs b/crates/encryption/src/lib.rs index 268ecda8..3adfc494 100644 --- a/crates/encryption/src/lib.rs +++ b/crates/encryption/src/lib.rs @@ -66,9 +66,12 @@ #![forbid(unsafe_code)] pub mod aead; +pub mod bundle; pub mod envelope; pub mod hash; +pub mod hkdf_sha384; pub mod kdf; +pub mod kx; pub mod sign; #[cfg(feature = "wasm-bindings")] diff --git a/crates/encryption/src/wasm.rs b/crates/encryption/src/wasm.rs index e56f1310..a2ab702d 100644 --- a/crates/encryption/src/wasm.rs +++ b/crates/encryption/src/wasm.rs @@ -81,6 +81,97 @@ pub fn verify_signature(public_key: &[u8], message: &[u8], signature: &[u8]) -> Ok(crate::sign::verify(&pk, message, &sig)) } +/// A bundle that has already passed [`crate::bundle::verify`]. +/// +/// There is no constructor and no way to build one from JavaScript: the only +/// route to a `VerifiedBundle` is through [`verify_bundle`]. That is the +/// point — a browser cannot hold a bundle's body without having verified it +/// first, so "render whatever arrived" is not an expressible mistake. +#[wasm_bindgen] +pub struct VerifiedBundle { + kind: u8, + classid: u32, + bundle_id: u64, + issued_at: u64, + publisher: Vec, + body: Vec, +} + +#[wasm_bindgen] +impl VerifiedBundle { + /// 1 = view, 2 = mask, 3 = adapter, 4 = recipe-set. + #[wasm_bindgen(getter)] + pub fn kind(&self) -> u8 { + self.kind + } + /// The concept this projection targets. + #[wasm_bindgen(getter)] + pub fn classid(&self) -> u32 { + self.classid + } + /// Monotonic per publisher — the value a revocation list keys on. + #[wasm_bindgen(getter)] + pub fn bundle_id(&self) -> u64 { + self.bundle_id + } + /// Unix seconds at mint time. + #[wasm_bindgen(getter)] + pub fn issued_at(&self) -> u64 { + self.issued_at + } + /// The 32-byte publisher key, already checked against the allowlist. + #[wasm_bindgen(getter)] + pub fn publisher(&self) -> Vec { + self.publisher.clone() + } + /// The IR payload. Safe to render — the signature covered these bytes + /// together with every header field above. + #[wasm_bindgen(getter)] + pub fn body(&self) -> Vec { + self.body.clone() + } +} + +/// Verify a signed bundle in the browser before anything renders it. +/// +/// `allowed_publishers` is a flat concatenation of 32-byte Ed25519 public +/// keys — the keys this client is willing to trust, which in a shipped +/// deployment are compiled into the wasm bundle rather than fetched. Passing +/// an empty allowlist throws: a client that trusts nobody must fail loudly, +/// not accidentally accept a self-signed bundle. +/// +/// Throws on a malformed blob, an unknown publisher, or a bad signature. The +/// three are deliberately distinct messages — a publisher you have not +/// allowlisted is an operator mistake worth naming, while a bad signature is +/// an attack worth naming differently. +#[wasm_bindgen] +pub fn verify_bundle(blob: &[u8], allowed_publishers: &[u8]) -> Result { + if allowed_publishers.is_empty() { + return Err(JsError::new("no allowed publishers configured")); + } + if allowed_publishers.len() % PUBLIC_KEY_LEN != 0 { + return Err(JsError::new("allowed publishers must be a multiple of 32 bytes")); + } + let keys: Vec<[u8; PUBLIC_KEY_LEN]> = allowed_publishers + .chunks_exact(PUBLIC_KEY_LEN) + .map(|c| { + let mut k = [0u8; PUBLIC_KEY_LEN]; + k.copy_from_slice(c); + k + }) + .collect(); + + let (header, body) = crate::bundle::verify(blob, &keys).map_err(|e| JsError::new(&e.to_string()))?; + Ok(VerifiedBundle { + kind: header.kind as u8, + classid: header.classid, + bundle_id: header.bundle_id, + issued_at: header.issued_at, + publisher: header.publisher.to_vec(), + body: body.to_vec(), + }) +} + /// SHA-384 of `data` (48 bytes). #[wasm_bindgen] pub fn sha384(data: &[u8]) -> Vec {