Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 9 additions & 10 deletions crates/ogar-auth/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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"] }
6 changes: 2 additions & 4 deletions crates/ogar-auth/src/password.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand All @@ -27,10 +27,8 @@ use crate::{AuthError, AuthResult};
pub fn hash_password(password: &str) -> AuthResult<String> {
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"))
}
Expand Down
22 changes: 15 additions & 7 deletions crates/ogar-encryption/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
301 changes: 301 additions & 0 deletions crates/ogar-encryption/src/envelope.rs
Original file line number Diff line number Diff line change
@@ -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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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, &params, 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(&params.m_cost_kib.to_le_bytes());
h[9..13].copy_from_slice(&params.t_cost.to_le_bytes());
h[13..17].copy_from_slice(&params.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"");
}
}
Loading
Loading