LeVCS/crates/levcs-protocol/src/auth.rs

217 lines
6.2 KiB
Rust

//! Request authentication per §5.3.
//!
//! Signed requests carry these headers:
//!
//! ```text
//! LeVCS-Key: ed25519:<base64 public key>
//! LeVCS-Timestamp: <Unix microseconds>
//! LeVCS-Nonce: <16 random bytes, base64>
//! LeVCS-Signature: ed25519:<base64 signature>
//! ```
//!
//! The signature is computed over the canonical request string:
//!
//! ```text
//! <HTTP method>\n
//! <request path including query string>\n
//! <timestamp>\n
//! <nonce>\n
//! <BLAKE3 hash of request body, hex-encoded>
//! ```
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<AuthHeaders, AuthError> {
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());
}
}