diff --git a/crates/ogar-auth/Cargo.toml b/crates/ogar-auth/Cargo.toml index ebb33eb6..06b9f7da 100644 --- a/crates/ogar-auth/Cargo.toml +++ b/crates/ogar-auth/Cargo.toml @@ -8,12 +8,8 @@ description = "OGAR auth arm: the reusable authentication SDK for OGAR consumers [dependencies] # Forward suite — REUSED via OGAR's own generic encryption surface, never -# re-implemented here. Argon2id KDF, XChaCha20-Poly1305 AEAD, Ed25519 -# signatures, SHA-384, and the wasm-capable zero-knowledge envelope all live -# in `encryption` (the ndarray fork); `ogar-encryption` is the single -# classid-agnostic re-export every Ada consumer pulls, and ogar-auth builds -# its auth-specific flows (password/totp/legacy) on top of it. Path dep, -# in-workspace sibling. +# re-implemented here. `ogar-encryption` owns the KDF + envelope and re-exports +# XChaCha20-Poly1305, Ed25519 and SHA-384. Path dep, in-workspace sibling. ogar-encryption = { path = "../ogar-encryption" } # The canonical identity envelope (`auth::ActorContext`) this crate PRODUCES. @@ -23,10 +19,6 @@ ogar-encryption = { path = "../ogar-encryption" } # pattern as `ogar-class-view`. lance-graph-contract = { git = "https://github.com/AdaWorldAPI/lance-graph", branch = "main" } -# Argon2id PHC hash+verify (the login-credential path — distinct from the -# envelope KDF). Default features carry `password-hash` + `rand` (OsRng salt). -argon2 = "0.5" - # RFC 6238 TOTP: HMAC-SHA1 over the time-step counter. SHA1 here is # interop-mandated by the authenticator-app ecosystem and MAC-keyed (HMAC), # never a password or integrity hash. @@ -45,5 +37,12 @@ base64 = "0.22" # feature routes to crypto.getRandomValues; mirrors the encryption crate. getrandom = "0.2" +# Argon2id PHC hash+verify — the login-credential path. Password hashing, not +# encryption, so this crate pins argon2 itself rather than going through +# `ogar-encryption`. argon2 0.6 from the AdaWorldAPI/password-hashes fork +# (`ndarray-simd` = block compression on `ndarray::simd::U64x8`); branch pin +# until password-hashes#1 merges. +argon2 = { git = "https://github.com/AdaWorldAPI/password-hashes", branch = "claude/argon2-ndarray-simd", default-features = false, features = ["alloc", "password-hash", "ndarray-simd"] } + [target.'cfg(target_arch = "wasm32")'.dependencies] getrandom = { version = "0.2", features = ["js"] } diff --git a/crates/ogar-auth/src/password.rs b/crates/ogar-auth/src/password.rs index 45e0b8e8..592aa0e0 100644 --- a/crates/ogar-auth/src/password.rs +++ b/crates/ogar-auth/src/password.rs @@ -13,7 +13,7 @@ //! old hashes verify against their own embedded parameters. use argon2::Argon2; -use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}; +use argon2::password_hash::{PasswordHasher, PasswordVerifier, phc::PasswordHash}; use crate::{AuthError, AuthResult}; @@ -27,10 +27,8 @@ use crate::{AuthError, AuthResult}; pub fn hash_password(password: &str) -> AuthResult { let mut salt_bytes = [0u8; 16]; getrandom::getrandom(&mut salt_bytes).map_err(|_| AuthError::Password("CSPRNG unavailable"))?; - let salt = SaltString::encode_b64(&salt_bytes) - .map_err(|_| AuthError::Password("salt encode failed"))?; Argon2::default() - .hash_password(password.as_bytes(), &salt) + .hash_password_with_salt(password.as_bytes(), &salt_bytes) .map(|h| h.to_string()) .map_err(|_| AuthError::Password("argon2 hashing failed")) } diff --git a/crates/ogar-encryption/Cargo.toml b/crates/ogar-encryption/Cargo.toml index a4128e5b..34ea44a0 100644 --- a/crates/ogar-encryption/Cargo.toml +++ b/crates/ogar-encryption/Cargo.toml @@ -4,17 +4,25 @@ version.workspace = true edition.workspace = true license.workspace = true repository.workspace = true -description = "OGAR's single, generic, classid-agnostic encryption surface: a thin re-export of the ndarray `encryption` crate (Argon2id KDF, XChaCha20-Poly1305 AEAD, Ed25519 signatures, SHA-384 hash, the wasm-capable zero-knowledge seal/open envelope). Every Ada consumer that needs raw forward crypto pulls THIS crate rather than depping the ndarray fork directly or hand-rolling argon2/chacha/ed25519. Carries no secrets and no consumer specifics; ogar-auth builds its auth-specific flows (password/totp/legacy) on top of this." +description = "OGAR's single, generic, classid-agnostic encryption surface: the Argon2id KDF + zero-knowledge seal/open envelope (argon2 0.6 from the AdaWorldAPI/password-hashes fork) plus a re-export of the ndarray `encryption` crate for XChaCha20-Poly1305 AEAD, Ed25519 signatures and SHA-384 hash. Every Ada consumer that needs raw forward crypto pulls THIS crate rather than depping the ndarray fork directly or hand-rolling argon2/chacha/ed25519. Carries no secrets and no consumer specifics; ogar-auth builds its auth-specific flows (password/totp/legacy) on top of this." [dependencies] -# The forward crypto suite — REUSED from the ndarray fork, never re-implemented -# here. Argon2id KDF, XChaCha20-Poly1305 AEAD, Ed25519 signatures, SHA-384, and -# the wasm-capable zero-knowledge envelope all live in `encryption`; this crate -# re-exports them verbatim (see lib.rs) so every consumer imports ONE crate. -# Git dep, matching the ogar-auth precedent (ndarray's default branch is -# `master`). +# XChaCha20-Poly1305 AEAD, Ed25519 signatures and SHA-384 are REUSED from the +# ndarray fork's `encryption` crate and re-exported verbatim (see lib.rs). The +# Argon2 half (kdf + envelope) is owned here — see below. encryption = { git = "https://github.com/AdaWorldAPI/ndarray", branch = "master" } +# Not in ndarray: `kdf` and `envelope` run here on argon2 0.6 +# from the AdaWorldAPI/password-hashes fork. `ndarray-simd` puts the block +# compression on `ndarray::simd::U64x8` (compile-time dispatch, no `unsafe`). +# Branch pin until password-hashes#1 merges. +argon2 = { git = "https://github.com/AdaWorldAPI/password-hashes", branch = "claude/argon2-ndarray-simd", default-features = false, features = ["alloc", "ndarray-simd"] } +zeroize = { version = "1", features = ["derive"] } +getrandom = "0.2" + +[target.'cfg(target_arch = "wasm32")'.dependencies] +getrandom = { version = "0.2", features = ["js"] } + [features] # Forward to the ndarray crate's wasm bindings, so browser consumers (e.g. a # hub-client-style SPA that wants client-side `envelope::seal` before secrets diff --git a/crates/ogar-encryption/src/envelope.rs b/crates/ogar-encryption/src/envelope.rs new file mode 100644 index 00000000..a13c7f6c --- /dev/null +++ b/crates/ogar-encryption/src/envelope.rs @@ -0,0 +1,301 @@ +//! The sealed envelope — password-locked, self-describing, zero-knowledge. +//! +//! [`seal`] runs client-side (browser wasm or native): it derives a key +//! from the password with Argon2id, draws a fresh salt + XChaCha20 +//! nonce from the CSPRNG, and returns one opaque blob. A server that +//! stores the blob learns **nothing** — the password never leaves the +//! client, and the blob authenticates its own header, so any tampering +//! (including with the cost parameters) makes [`open`] fail. +//! +//! ## Byte layout (fixed, little-endian, parsed by hand — no serde) +//! +//! ```text +//! offset len field +//! 0 4 magic = b"ADAC" +//! 4 1 version = 1 +//! 5 4 m_cost_kib (u32 LE) Argon2id memory cost +//! 9 4 t_cost (u32 LE) Argon2id passes +//! 13 4 p_cost (u32 LE) Argon2id lanes +//! 17 16 salt fresh CSPRNG bytes per seal +//! 33 24 nonce fresh CSPRNG bytes per seal +//! 57 … ciphertext ‖ 16-byte Poly1305 tag +//! ``` +//! +//! The first 57 bytes (the header) are the AEAD's associated data, so +//! they are integrity-bound to the ciphertext: an attacker cannot, for +//! example, lower `m_cost_kib` to cheapen an offline guess and still +//! have the blob open. +//! +//! That binding is checked *after* the key exists, though, and the key +//! comes from running Argon2id with the very parameters the blob supplied. +//! The cost fields are therefore acted upon before they are authenticated, +//! which is why [`KdfParams::validate`] gates them on the way in — see the +//! note there. Authenticated-but-only-later is not the same as trusted. + +use crate::kdf::{self, KdfError}; +pub use crate::kdf::{CostLimits, KdfParams}; +use encryption::aead::{self, NONCE_LEN}; + +/// Envelope magic: "ADAC" (Ada crypto). +pub const MAGIC: [u8; 4] = *b"ADAC"; +/// Current envelope layout version. +pub const VERSION: u8 = 1; +/// Salt length stored in the header. +pub const SALT_LEN: usize = 16; +/// Total header length preceding the ciphertext. +pub const HEADER_LEN: usize = 4 + 1 + 4 + 4 + 4 + SALT_LEN + NONCE_LEN; // 57 + +/// Why an envelope could not be sealed or opened. Field-free — an +/// `open` failure never says *which* part was wrong. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EnvelopeError { + /// The platform CSPRNG was unavailable (seal only). + Rng, + /// The Argon2id parameters were rejected. + Kdf(KdfError), + /// The blob is not an envelope (bad magic, short buffer). + Malformed, + /// The blob's layout version is newer than this code understands. + UnsupportedVersion, + /// Wrong password, or the blob was tampered with. Indistinguishable + /// by design. + Decrypt, + /// Encryption failed (should not happen with valid inputs). + Encrypt, +} + +impl core::fmt::Display for EnvelopeError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + EnvelopeError::Rng => f.write_str("platform CSPRNG unavailable"), + EnvelopeError::Kdf(e) => write!(f, "key derivation failed: {e}"), + EnvelopeError::Malformed => f.write_str("not a sealed envelope"), + EnvelopeError::UnsupportedVersion => f.write_str("unsupported envelope version"), + EnvelopeError::Decrypt => f.write_str("wrong password or tampered envelope"), + EnvelopeError::Encrypt => f.write_str("encryption failed"), + } + } +} + +impl std::error::Error for EnvelopeError {} + +/// Seal `plaintext` under `password`. Draws salt + nonce from the +/// CSPRNG, so sealing the same input twice yields different blobs. +pub fn seal( + password: &[u8], + plaintext: &[u8], + params: &KdfParams, +) -> Result, EnvelopeError> { + let mut salt = [0u8; SALT_LEN]; + crate::fill_random(&mut salt).map_err(|_| EnvelopeError::Rng)?; + let mut nonce = [0u8; NONCE_LEN]; + crate::fill_random(&mut nonce).map_err(|_| EnvelopeError::Rng)?; + + let header = encode_header(params, &salt, &nonce); + let key = kdf::derive_key(password, &salt, params).map_err(EnvelopeError::Kdf)?; + let ciphertext = aead::seal_with_key(key.as_bytes(), &nonce, &header, plaintext) + .map_err(|_| EnvelopeError::Encrypt)?; + + let mut blob = Vec::with_capacity(HEADER_LEN + ciphertext.len()); + blob.extend_from_slice(&header); + blob.extend_from_slice(&ciphertext); + Ok(blob) +} + +/// Open a sealed envelope with `password`. The KDF parameters are read +/// from the (authenticated) header, so cost bumps never orphan old blobs. +pub fn open(password: &[u8], blob: &[u8]) -> Result, EnvelopeError> { + open_within(password, blob, &CostLimits::DEFAULT) +} + +/// Open a sealed envelope, checking the header's cost parameters against a +/// caller-supplied budget instead of [`CostLimits::DEFAULT`]. +/// +/// The blob is untrusted input and its cost fields are acted upon before they +/// are authenticated (see the module note), so this budget is the only thing +/// standing between a forged header and the allocator. Tighten it on any +/// service that opens blobs it did not mint. +pub fn open_within( + password: &[u8], + blob: &[u8], + limits: &CostLimits, +) -> Result, EnvelopeError> { + let (params, salt, nonce) = decode_header_within(blob, limits)?; + let header = &blob[..HEADER_LEN]; + let ciphertext = &blob[HEADER_LEN..]; + + let key = + kdf::derive_key_within(password, &salt, ¶ms, limits).map_err(EnvelopeError::Kdf)?; + aead::open_with_key(key.as_bytes(), &nonce, header, ciphertext) + .map_err(|_| EnvelopeError::Decrypt) +} + +fn encode_header( + params: &KdfParams, + salt: &[u8; SALT_LEN], + nonce: &[u8; NONCE_LEN], +) -> [u8; HEADER_LEN] { + let mut h = [0u8; HEADER_LEN]; + h[0..4].copy_from_slice(&MAGIC); + h[4] = VERSION; + h[5..9].copy_from_slice(¶ms.m_cost_kib.to_le_bytes()); + h[9..13].copy_from_slice(¶ms.t_cost.to_le_bytes()); + h[13..17].copy_from_slice(¶ms.p_cost.to_le_bytes()); + h[17..17 + SALT_LEN].copy_from_slice(salt); + h[33..33 + NONCE_LEN].copy_from_slice(nonce); + h +} + +fn decode_header_within( + blob: &[u8], + limits: &CostLimits, +) -> Result<(KdfParams, [u8; SALT_LEN], [u8; NONCE_LEN]), EnvelopeError> { + if blob.len() < HEADER_LEN || blob[0..4] != MAGIC { + return Err(EnvelopeError::Malformed); + } + if blob[4] != VERSION { + return Err(EnvelopeError::UnsupportedVersion); + } + let le_u32 = + |at: usize| u32::from_le_bytes([blob[at], blob[at + 1], blob[at + 2], blob[at + 3]]); + let params = KdfParams { + m_cost_kib: le_u32(5), + t_cost: le_u32(9), + p_cost: le_u32(13), + }; + // Refused here, before the derivation this header would otherwise drive. + params.validate_within(limits).map_err(EnvelopeError::Kdf)?; + let mut salt = [0u8; SALT_LEN]; + salt.copy_from_slice(&blob[17..17 + SALT_LEN]); + let mut nonce = [0u8; NONCE_LEN]; + nonce.copy_from_slice(&blob[33..33 + NONCE_LEN]); + Ok((params, salt, nonce)) +} + +#[cfg(test)] +mod tests { + use super::*; + + const FAST: KdfParams = KdfParams { + m_cost_kib: 32, + t_cost: 1, + p_cost: 1, + }; + + #[test] + fn round_trip() { + let blob = seal(b"hunter2", b"the secret", &FAST).unwrap(); + assert_eq!(open(b"hunter2", &blob).unwrap(), b"the secret"); + } + + #[test] + fn wrong_password_fails_opaquely() { + let blob = seal(b"right", b"s", &FAST).unwrap(); + assert_eq!(open(b"wrong", &blob).unwrap_err(), EnvelopeError::Decrypt); + } + + #[test] + fn same_input_two_seals_differ() { + let a = seal(b"pw", b"s", &FAST).unwrap(); + let b = seal(b"pw", b"s", &FAST).unwrap(); + assert_ne!(a, b, "salt+nonce must be fresh per seal"); + } + + #[test] + fn header_tamper_fails() { + let mut blob = seal(b"pw", b"s", &FAST).unwrap(); + // Attacker tries to cheapen the KDF cost in the header. + blob[5] = 1; // m_cost_kib low byte + assert!(open(b"pw", &blob).is_err()); + } + + #[test] + fn ciphertext_tamper_fails() { + let mut blob = seal(b"pw", b"s", &FAST).unwrap(); + let last = blob.len() - 1; + blob[last] ^= 0x80; + assert_eq!(open(b"pw", &blob).unwrap_err(), EnvelopeError::Decrypt); + } + + #[test] + fn malformed_inputs_rejected() { + assert_eq!(open(b"pw", b"").unwrap_err(), EnvelopeError::Malformed); + assert_eq!( + open(b"pw", &[0u8; HEADER_LEN]).unwrap_err(), + EnvelopeError::Malformed + ); + let mut blob = seal(b"pw", b"s", &FAST).unwrap(); + blob[4] = 99; + assert_eq!( + open(b"pw", &blob).unwrap_err(), + EnvelopeError::UnsupportedVersion + ); + } + + /// The test that found it. Flipping one bit in the memory-cost field + /// asks for a 4 TiB allocation, and a failed allocation aborts the + /// process — so before the ceiling landed this did not report a failure, + /// it killed the test binary partway through the sweep. + /// + /// Every single-bit corruption of a sealed blob must now come back as an + /// error, and the whole sweep must stay fast: an expensive rejection is + /// itself the attack. + #[test] + fn every_single_bit_flip_is_refused_and_none_of_them_are_expensive() { + let blob = seal(b"pw", b"the secret", &FAST).unwrap(); + let started = std::time::Instant::now(); + for byte in 0..blob.len() { + for bit in 0..8 { + let mut corrupt = blob.clone(); + corrupt[byte] ^= 1 << bit; + assert!( + open(b"pw", &corrupt).is_err(), + "flipping bit {bit} of byte {byte} produced an openable blob" + ); + } + } + assert!( + started.elapsed() < std::time::Duration::from_secs(30), + "a corrupt header must be refused, not honoured" + ); + } + + /// A budget below what the blob was sealed with refuses it — the knob is + /// real, and a service can shrink its pre-authentication surface to the + /// presets it actually mints. + #[test] + fn a_tighter_budget_refuses_a_blob_it_would_otherwise_open() { + let blob = seal(b"pw", b"s", &KdfParams::INTERACTIVE).unwrap(); + assert_eq!(open(b"pw", &blob).unwrap(), b"s"); + let tiny = CostLimits { + max_m_cost_kib: 1024, + ..CostLimits::DEFAULT + }; + assert_eq!( + open_within(b"pw", &blob, &tiny).unwrap_err(), + EnvelopeError::Kdf(crate::kdf::KdfError::CostOutOfPolicy) + ); + } + + /// The refusal is a header check, so it must not depend on the password. + #[test] + fn an_absurd_cost_header_is_refused_before_the_password_matters() { + let mut blob = seal(b"pw", b"s", &FAST).unwrap(); + blob[8] = 0xFF; // top byte of m_cost_kib → far past any budget + assert_eq!( + open(b"pw", &blob).unwrap_err(), + EnvelopeError::Kdf(crate::kdf::KdfError::CostOutOfPolicy) + ); + assert_eq!( + open(b"wrong-password-entirely", &blob).unwrap_err(), + EnvelopeError::Kdf(crate::kdf::KdfError::CostOutOfPolicy) + ); + } + + #[test] + fn empty_plaintext_round_trips() { + let blob = seal(b"pw", b"", &FAST).unwrap(); + assert_eq!(blob.len(), HEADER_LEN + encryption::aead::TAG_LEN); + assert_eq!(open(b"pw", &blob).unwrap(), b""); + } +} diff --git a/crates/ogar-encryption/src/kdf.rs b/crates/ogar-encryption/src/kdf.rs new file mode 100644 index 00000000..a6519e5a --- /dev/null +++ b/crates/ogar-encryption/src/kdf.rs @@ -0,0 +1,362 @@ +//! Argon2id key derivation — password → 256-bit AEAD key. +//! +//! The parameters travel inside the sealed envelope header (see +//! [`crate::envelope`]) so old blobs stay openable after a cost bump. + +use argon2::{Algorithm, Argon2, Params, Version}; +use zeroize::{Zeroize, ZeroizeOnDrop}; + +/// Argon2id cost parameters. Stored verbatim (little-endian) in the +/// envelope header, so they are part of the authenticated data. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct KdfParams { + /// Memory cost in KiB. + pub m_cost_kib: u32, + /// Iteration count (passes over memory). + pub t_cost: u32, + /// Parallelism (lanes). + pub p_cost: u32, +} + +/// Smallest memory cost Argon2id accepts (8 KiB per lane). +pub const MIN_M_COST_KIB: u32 = 8; + +/// The resource budget a caller is willing to spend on **unauthenticated** +/// parameters. +/// +/// Argon2's own maximum is `u32::MAX` KiB — 4 TiB. Refusing only that is +/// not a limit, it is a rounding error: a cost of 1 GiB × 64 passes is +/// equally fatal on a browser tab or a small container, and a handful of +/// concurrent requests carrying it exhausts the host without ever being +/// authenticated. The ceiling therefore has to be a budget the platform can +/// actually absorb, not merely a number below Argon2's roof. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CostLimits { + /// Largest accepted memory cost, in KiB. + pub max_m_cost_kib: u32, + /// Largest accepted pass count. + pub max_t_cost: u32, + /// Largest accepted lane count. + pub max_p_cost: u32, +} + +impl CostLimits { + /// 128 MiB, 4 passes, 2 lanes. + /// + /// Twice the memory and one pass more than [`KdfParams::DEFAULT`], the + /// most expensive preset this crate emits — enough headroom for a future + /// cost bump to open old and new blobs alike, while keeping the worst + /// case one attacker-supplied header can buy at roughly a third of a + /// second and 128 MiB. + /// + /// A service that opens blobs from the public internet should pass + /// something tighter (and rate-limit); a batch tool on a big host can + /// pass something looser. The default errs toward the smallest plausible + /// deployment, because that is the one that falls over. + pub const DEFAULT: CostLimits = CostLimits { + max_m_cost_kib: 128 * 1024, + max_t_cost: 4, + max_p_cost: 2, + }; + + /// Exactly the presets this crate ships ([`KdfParams::DEFAULT`] and + /// [`KdfParams::INTERACTIVE`]), with no headroom at all. + /// + /// For a deployment that mints every blob it opens and wants the + /// narrowest possible pre-authentication surface. The trade is explicit: + /// raising a cost later means shipping the reader before the writer. + pub const SHIPPED_PRESETS_ONLY: CostLimits = CostLimits { + max_m_cost_kib: 64 * 1024, + max_t_cost: 3, + max_p_cost: 1, + }; +} + +impl Default for CostLimits { + fn default() -> Self { + Self::DEFAULT + } +} + +impl KdfParams { + /// Check the cost parameters against [`CostLimits::DEFAULT`] **before** + /// any memory is reserved. See [`KdfParams::validate_within`] to supply + /// a budget of your own. + /// + /// The ceiling exists because of where these parameters come from. In + /// [`crate::envelope`] they are read out of the blob's header, and the + /// header is authenticated — but verifying that authentication requires + /// the key, and deriving the key means first running Argon2id with the + /// parameters the blob just supplied. So there is a window, before + /// anything is proven, where an attacker-chosen `m_cost_kib` decides how + /// much memory this process asks for. One flipped bit in a stored blob + /// turns 19 MiB into 4 TiB; the allocation fails, and a failed allocation + /// in Rust aborts the process rather than unwinding. Tamper detection + /// works perfectly and the process still dies before reaching it. + /// + /// Rejecting must therefore be *cheap* — a comparison, not an attempt. + /// + /// ``` + /// use encryption::kdf::{CostLimits, KdfParams, KdfError}; + /// + /// assert!(KdfParams::INTERACTIVE.validate().is_ok()); + /// + /// // Not merely absurd values — anything past the platform budget. + /// let heavy = KdfParams { m_cost_kib: 512 * 1024, ..KdfParams::DEFAULT }; + /// assert_eq!(heavy.validate(), Err(KdfError::CostOutOfPolicy)); + /// + /// // And a caller with a different budget says so explicitly. + /// assert!(heavy.validate_within(&CostLimits { max_m_cost_kib: 1024 * 1024, ..CostLimits::DEFAULT }).is_ok()); + /// ``` + pub const fn validate(&self) -> Result<(), KdfError> { + self.validate_within(&CostLimits::DEFAULT) + } + + /// Check the cost parameters against a caller-supplied budget, before any + /// memory is reserved. + pub const fn validate_within(&self, limits: &CostLimits) -> Result<(), KdfError> { + if self.m_cost_kib < MIN_M_COST_KIB || self.m_cost_kib > limits.max_m_cost_kib { + return Err(KdfError::CostOutOfPolicy); + } + if self.t_cost == 0 || self.t_cost > limits.max_t_cost { + return Err(KdfError::CostOutOfPolicy); + } + if self.p_cost == 0 || self.p_cost > limits.max_p_cost { + return Err(KdfError::CostOutOfPolicy); + } + Ok(()) + } + + /// Server-grade default: 64 MiB, 3 passes, 1 lane. + pub const DEFAULT: KdfParams = KdfParams { + m_cost_kib: 64 * 1024, + t_cost: 3, + p_cost: 1, + }; + + /// Interactive / browser-grade: 19 MiB, 2 passes, 1 lane + /// (the OWASP first-recommended Argon2id configuration). + /// Use when the derivation runs on every login inside wasm. + pub const INTERACTIVE: KdfParams = KdfParams { + m_cost_kib: 19 * 1024, + t_cost: 2, + p_cost: 1, + }; +} + +impl Default for KdfParams { + fn default() -> Self { + Self::DEFAULT + } +} + +/// A derived 256-bit key. Wiped from memory on drop. +#[derive(Zeroize, ZeroizeOnDrop)] +pub struct DerivedKey(pub(crate) [u8; 32]); + +impl DerivedKey { + /// Borrow the raw key bytes (for handing to the AEAD). + pub fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } +} + +/// Key-derivation failure. Field-free on purpose — no secret material, +/// no parameter echo. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KdfError { + /// The cost parameters are outside Argon2's accepted range. + InvalidParams, + /// The cost parameters are inside Argon2's range but outside this + /// crate's policy ceiling — see [`KdfParams::validate`]. Rejected + /// before any memory is reserved. + CostOutOfPolicy, + /// The derivation itself failed (allocation, internal error). + DerivationFailed, +} + +impl core::fmt::Display for KdfError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + KdfError::InvalidParams => f.write_str("invalid Argon2id parameters"), + KdfError::CostOutOfPolicy => { + f.write_str("Argon2id cost parameters exceed policy limits") + } + KdfError::DerivationFailed => f.write_str("Argon2id derivation failed"), + } + } +} + +impl std::error::Error for KdfError {} + +/// Derive a 256-bit key from `password` and a 16-byte `salt` with +/// Argon2id (v1.3). Deterministic: same inputs → same key. +pub fn derive_key( + password: &[u8], + salt: &[u8; 16], + params: &KdfParams, +) -> Result { + derive_key_within(password, salt, params, &CostLimits::DEFAULT) +} + +/// Derive a key, checking `params` against a caller-supplied budget rather +/// than [`CostLimits::DEFAULT`]. +pub fn derive_key_within( + password: &[u8], + salt: &[u8; 16], + params: &KdfParams, + limits: &CostLimits, +) -> Result { + // Before Argon2 sees them, and therefore before anything is allocated. + params.validate_within(limits)?; + let argon_params = Params::new(params.m_cost_kib, params.t_cost, params.p_cost, Some(32)) + .map_err(|_| KdfError::InvalidParams)?; + let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon_params); + let mut key = [0u8; 32]; + argon + .hash_password_into(password, salt, &mut key) + .map_err(|_| KdfError::DerivationFailed)?; + Ok(DerivedKey(key)) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Small params so tests stay fast; correctness is parameter-independent. + const TEST_PARAMS: KdfParams = KdfParams { + m_cost_kib: 32, + t_cost: 1, + p_cost: 1, + }; + + #[test] + fn deterministic_for_same_inputs() { + let salt = [7u8; 16]; + let a = derive_key(b"correct horse", &salt, &TEST_PARAMS).unwrap(); + let b = derive_key(b"correct horse", &salt, &TEST_PARAMS).unwrap(); + assert_eq!(a.as_bytes(), b.as_bytes()); + } + + #[test] + fn different_salt_different_key() { + let a = derive_key(b"pw", &[1u8; 16], &TEST_PARAMS).unwrap(); + let b = derive_key(b"pw", &[2u8; 16], &TEST_PARAMS).unwrap(); + assert_ne!(a.as_bytes(), b.as_bytes()); + } + + #[test] + fn different_password_different_key() { + let salt = [9u8; 16]; + let a = derive_key(b"pw-a", &salt, &TEST_PARAMS).unwrap(); + let b = derive_key(b"pw-b", &salt, &TEST_PARAMS).unwrap(); + assert_ne!(a.as_bytes(), b.as_bytes()); + } + + /// The parameters that reach [`derive_key`] on the open path come out of + /// an untrusted blob header. This asserts the refusal is cheap — if the + /// ceiling were missing, this test would not fail, it would abort the + /// whole test process trying to reserve 4 TiB. + #[test] + fn a_cost_beyond_the_platform_budget_is_refused_without_attempting_it() { + // Not u32::MAX — a value Argon2 would happily accept and run. + let absurd = KdfParams { + m_cost_kib: 512 * 1024, + t_cost: 4, + p_cost: 1, + }; + let started = std::time::Instant::now(); + match derive_key(b"pw", &[0u8; 16], &absurd) { + Err(e) => assert_eq!(e, KdfError::CostOutOfPolicy), + Ok(_) => panic!("512 MiB of unauthenticated memory cost must be refused"), + } + assert!( + started.elapsed() < std::time::Duration::from_millis(50), + "the refusal must be a comparison, not an attempt" + ); + } + + #[test] + fn the_ceiling_admits_every_shipped_preset() { + assert_eq!(KdfParams::DEFAULT.validate(), Ok(())); + assert_eq!(KdfParams::INTERACTIVE.validate(), Ok(())); + assert_eq!(TEST_PARAMS.validate(), Ok(())); + // …including under the tightest limits this crate offers. + let only = CostLimits::SHIPPED_PRESETS_ONLY; + assert_eq!(KdfParams::DEFAULT.validate_within(&only), Ok(())); + assert_eq!(KdfParams::INTERACTIVE.validate_within(&only), Ok(())); + } + + /// The budget is a real bound on work, not a slogan. This measures what + /// the worst header the default limits admit actually costs, so the + /// number in the docs is observed rather than asserted. + #[test] + #[ignore = "measures the worst admitted cost; run explicitly"] + fn worst_admitted_cost_is_within_the_documented_budget() { + let l = CostLimits::DEFAULT; + let worst = KdfParams { + m_cost_kib: l.max_m_cost_kib, + t_cost: l.max_t_cost, + p_cost: l.max_p_cost, + }; + let started = std::time::Instant::now(); + assert!(derive_key(b"pw", &[0u8; 16], &worst).is_ok()); + let took = started.elapsed(); + println!( + "worst admitted: {} MiB x {} passes x {} lanes -> {:?}", + l.max_m_cost_kib / 1024, + l.max_t_cost, + l.max_p_cost, + took + ); + assert!( + took < std::time::Duration::from_secs(2), + "budget too generous: {took:?}" + ); + } + + /// A caller that knows its host can afford more says so, explicitly. + #[test] + fn a_wider_budget_is_opt_in_not_the_default() { + let heavy = KdfParams { + m_cost_kib: 512 * 1024, + t_cost: 1, + p_cost: 1, + }; + assert_eq!(heavy.validate(), Err(KdfError::CostOutOfPolicy)); + let wide = CostLimits { + max_m_cost_kib: 1024 * 1024, + ..CostLimits::DEFAULT + }; + assert_eq!(heavy.validate_within(&wide), Ok(())); + } + + #[test] + fn zero_passes_and_zero_lanes_are_refused() { + let no_passes = KdfParams { + t_cost: 0, + ..TEST_PARAMS + }; + let no_lanes = KdfParams { + p_cost: 0, + ..TEST_PARAMS + }; + assert_eq!(no_passes.validate(), Err(KdfError::CostOutOfPolicy)); + assert_eq!(no_lanes.validate(), Err(KdfError::CostOutOfPolicy)); + } + + #[test] + fn rejects_zero_memory() { + let bad = KdfParams { + m_cost_kib: 0, + t_cost: 1, + p_cost: 1, + }; + // No `unwrap_err()` here: DerivedKey deliberately has no Debug + // impl (a key must never be printable). + match derive_key(b"pw", &[0u8; 16], &bad) { + Err(e) => assert_eq!(e, KdfError::CostOutOfPolicy), + Ok(_) => panic!("zero-memory params must be rejected"), + } + } +} diff --git a/crates/ogar-encryption/src/lib.rs b/crates/ogar-encryption/src/lib.rs index ebebb1ef..d5ec7fe0 100644 --- a/crates/ogar-encryption/src/lib.rs +++ b/crates/ogar-encryption/src/lib.rs @@ -7,17 +7,19 @@ //! //! ## What lives here //! -//! Nothing is implemented in this crate. It is a thin, documented re-export -//! of [`encryption`] (the ndarray fork's audited, wasm-capable crypto -//! module): +//! [`kdf`] and [`envelope`] are implemented here on `argon2` 0.6 from the +//! `AdaWorldAPI/password-hashes` fork (feature `ndarray-simd`: the +//! compression function runs on `ndarray::simd::U64x8`). The rest of the +//! forward suite is a documented re-export of [`encryption`] (the ndarray +//! fork's wasm-capable crypto module): //! //! | Module / item | Primitive | Role | //! |---|---|---| -//! | [`kdf`] | Argon2id | password/secret → raw key derivation | +//! | [`kdf`] | Argon2id (0.6, local) | password/secret → raw key derivation | //! | [`aead`] | XChaCha20-Poly1305 | authenticated encryption | //! | [`hash`] | SHA-384 | merkle / fingerprint hashing | //! | [`sign`] | Ed25519 | licence / audit signatures | -//! | [`envelope`] | seal / open | wasm-capable zero-knowledge envelope | +//! | [`envelope`] | seal / open (local) | zero-knowledge envelope, `ADAC` v1 byte layout | //! | [`seal`], [`open`] | — | root-level aliases for `envelope::seal` / `envelope::open` | //! | [`EnvelopeError`], [`KdfParams`] | — | root-level aliases for the envelope's error + parameter types | //! | [`RngError`] | — | the platform-CSPRNG-unavailable error | @@ -26,10 +28,9 @@ //! ## Generic, classid-agnostic, no secrets — by construction //! //! This crate carries **no consumer specifics**: no classid, no tenant, no -//! key material, no wire DTO. It is pure re-export surface — every symbol -//! here is exactly what [`encryption`] exports, unmodified. That is the -//! point: the forward suite must never diverge into per-consumer copies, and -//! a crate that re-exports and adds nothing cannot dilute it. +//! key material, no wire DTO. [`kdf`] and [`envelope`] keep the API of +//! [`encryption`]'s modules of the same name; everything else is re-exported +//! unmodified. //! //! ## Who builds on this //! @@ -54,21 +55,32 @@ #![forbid(unsafe_code)] -// ── The forward suite: re-exported wholesale from the ndarray `encryption` -// crate. Reused, never re-implemented (see crate docs). A consumer that pulls -// `ogar-encryption` gets the entire forward crypto surface under one import. -pub use encryption::{aead, envelope, hash, kdf, sign}; +// ── KDF + envelope: implemented here on argon2 0.6 (see crate docs). +pub mod envelope; +pub mod kdf; -// ── Root-level convenience aliases, mirrored from `encryption`'s own root -// re-exports (`envelope::{seal, open}` plus the envelope's error/parameter -// types), so callers that used the upstream crate's short paths keep them. -pub use encryption::{EnvelopeError, KdfParams, open, seal}; +// ── The rest of the forward suite: re-exported from the ndarray `encryption` +// crate, unmodified. +pub use encryption::{aead, hash, sign}; + +// ── Root-level convenience aliases, so callers that used the upstream +// crate's short paths keep them. +pub use envelope::{EnvelopeError, KdfParams, open, seal}; // ── The platform-CSPRNG-unavailable error, mirrored from `encryption`'s // crate root. pub use encryption::RngError; +/// Fill `buf` from the platform CSPRNG (`getrandom`; on wasm32 this is +/// `crypto.getRandomValues`). The single entropy chokepoint of this crate. +pub(crate) fn fill_random(buf: &mut [u8]) -> Result<(), RngError> { + getrandom::getrandom(buf).map_err(|_| RngError) +} + /// wasm-bindgen bindings for browser consumers (forwarded from /// [`encryption`]'s `wasm-bindings` feature via this crate's `wasm` feature). +/// +/// NOTE: these bindings still run [`encryption`]'s own envelope (argon2 0.5); +/// moving them here is a follow-up. #[cfg(feature = "wasm")] pub use encryption::wasm;