//! Request authentication per ยง5.3. //! //! Signed requests carry these headers: //! //! ```text //! LeVCS-Key: ed25519: //! LeVCS-Timestamp: //! LeVCS-Nonce: <16 random bytes, base64> //! LeVCS-Signature: ed25519: //! ``` //! //! The signature is computed over the canonical request string: //! //! ```text //! \n //! \n //! \n //! \n //! //! ``` use base64::{engine::general_purpose::STANDARD as B64, Engine as _}; use thiserror::Error; use levcs_identity::keys::{PublicKey, SecretKey}; pub const NONCE_TTL_SECS: i64 = 600; pub const DEFAULT_CLOCK_SKEW: i64 = 5 * 60; // 5 minutes #[derive(Debug, Error)] pub enum AuthError { #[error("missing header: {0}")] MissingHeader(&'static str), #[error("invalid header value for {name}: {detail}")] InvalidHeader { name: &'static str, detail: String }, #[error("timestamp out of allowed window: {0} seconds skew")] Skew(i64), #[error("replay detected: nonce already seen")] Replay, #[error("signature verify failed: {0}")] BadSignature(String), #[error("identity error: {0}")] Identity(#[from] levcs_identity::IdentityError), #[error("base64 decode: {0}")] Base64(String), #[error("hex decode: {0}")] Hex(#[from] hex::FromHexError), } #[derive(Clone, Debug)] pub struct AuthHeaders { pub key: PublicKey, pub timestamp_micros: i64, pub nonce: [u8; 16], pub signature: [u8; 64], } #[derive(Clone, Debug)] pub struct AuthRequest<'a> { pub method: &'a str, pub path_with_query: &'a str, pub body: &'a [u8], } pub fn build_canonical(req: &AuthRequest, timestamp_micros: i64, nonce_b64: &str) -> String { let body_hash = hex::encode(blake3::hash(req.body).as_bytes()); format!( "{}\n{}\n{}\n{}\n{}", req.method, req.path_with_query, timestamp_micros, nonce_b64, body_hash ) } /// Sign a request, returning the four header values. pub fn sign_request( sk: &SecretKey, req: &AuthRequest, ) -> Result<(String, String, String, String), AuthError> { let mut nonce = [0u8; 16]; getrandom_bytes(&mut nonce)?; let timestamp = current_micros(); let nonce_b64 = B64.encode(nonce); let canonical = build_canonical(req, timestamp, &nonce_b64); let sig = sk.sign(canonical.as_bytes()); Ok(( sk.public().to_levcs(), timestamp.to_string(), nonce_b64, format!("ed25519:{}", B64.encode(sig)), )) } /// Verify the four headers of an authenticated request, given the request's /// method/path/body. Returns the verified `AuthHeaders` on success. pub fn verify_request( req: &AuthRequest, key_header: &str, timestamp_header: &str, nonce_header: &str, signature_header: &str, now_micros: i64, skew_seconds: i64, ) -> Result { let pk = PublicKey::parse_levcs(key_header).map_err(AuthError::Identity)?; let timestamp_micros: i64 = timestamp_header .parse() .map_err(|e: std::num::ParseIntError| AuthError::InvalidHeader { name: "LeVCS-Timestamp", detail: e.to_string(), })?; let skew = (now_micros - timestamp_micros) / 1_000_000; if skew.abs() > skew_seconds { return Err(AuthError::Skew(skew)); } let nonce_bytes = B64 .decode(nonce_header.as_bytes()) .map_err(|e| AuthError::Base64(e.to_string()))?; if nonce_bytes.len() != 16 { return Err(AuthError::InvalidHeader { name: "LeVCS-Nonce", detail: format!("expected 16 bytes, got {}", nonce_bytes.len()), }); } let mut nonce = [0u8; 16]; nonce.copy_from_slice(&nonce_bytes); let sig_b64 = signature_header .strip_prefix("ed25519:") .ok_or(AuthError::InvalidHeader { name: "LeVCS-Signature", detail: "missing ed25519: prefix".into(), })?; let sig_bytes = B64 .decode(sig_b64.as_bytes()) .map_err(|e| AuthError::Base64(e.to_string()))?; if sig_bytes.len() != 64 { return Err(AuthError::InvalidHeader { name: "LeVCS-Signature", detail: format!("expected 64 bytes, got {}", sig_bytes.len()), }); } let mut sig = [0u8; 64]; sig.copy_from_slice(&sig_bytes); let canonical = build_canonical(req, timestamp_micros, nonce_header); pk.verify(canonical.as_bytes(), &sig) .map_err(|e| AuthError::BadSignature(e.to_string()))?; Ok(AuthHeaders { key: pk, timestamp_micros, nonce, signature: sig, }) } fn getrandom_bytes(buf: &mut [u8]) -> Result<(), AuthError> { getrandom::getrandom(buf).map_err(|e| AuthError::InvalidHeader { name: "LeVCS-Nonce", detail: e.to_string(), }) } pub fn current_micros() -> i64 { use std::time::{SystemTime, UNIX_EPOCH}; SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_micros() as i64) .unwrap_or(0) } #[cfg(test)] mod tests { use super::*; #[test] fn sign_and_verify_roundtrip() { let sk = SecretKey::generate(); let body = b"{\"updates\":[]}"; let req = AuthRequest { method: "POST", path_with_query: "/levcs/v1/repos/abc/push", body, }; let (key, ts, nonce, sig) = sign_request(&sk, &req).unwrap(); let now = current_micros(); let h = verify_request(&req, &key, &ts, &nonce, &sig, now, DEFAULT_CLOCK_SKEW).unwrap(); assert_eq!(h.key, sk.public()); } #[test] fn tampered_body_fails() { let sk = SecretKey::generate(); let body = b"{\"updates\":[]}"; let req = AuthRequest { method: "POST", path_with_query: "/levcs/v1/repos/abc/push", body, }; let (key, ts, nonce, sig) = sign_request(&sk, &req).unwrap(); let req2 = AuthRequest { method: "POST", path_with_query: "/levcs/v1/repos/abc/push", body: b"bogus", }; let now = current_micros(); assert!(verify_request(&req2, &key, &ts, &nonce, &sig, now, DEFAULT_CLOCK_SKEW).is_err()); } }