217 lines
6.2 KiB
Rust
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());
|
|
}
|
|
}
|