//! The RFC 9421 request-signing profile for every authenticated request (docs/03). //! //! A client sends these headers on each call after login: //! //! ```text //! Content-Digest: sha-256=:: //! KV-Session: //! KV-Ts: //! Signature-Input: kv= //! Signature: kv=:: //! ``` //! //! The server rebuilds the base from the request it received and checks it with the //! device's enrolled P-256 key. It rejects a `KV-Ts` more than [`MAX_SKEW_SECS`] off, //! a reused nonce, and any deviation from this exact profile (label, components, order). use base64::{engine::general_purpose::STANDARD, Engine}; use sha2::{Digest, Sha256}; use uuid::Uuid; pub const HEADER_SESSION: &str = "kv-session"; pub const HEADER_TS: &str = "kv-ts"; pub const HEADER_CONTENT_DIGEST: &str = "content-digest"; pub const HEADER_SIGNATURE_INPUT: &str = "signature-input"; pub const HEADER_SIGNATURE: &str = "signature"; /// The only accepted signature label. pub const LABEL: &str = "kv"; pub const ALG: &str = "ecdsa-p256-sha256"; /// Covered components, in this order. pub const COVERED_COMPONENTS: &str = r#"("@method" "@target-uri" "content-digest" "kv-session" "kv-ts")"#; pub const MAX_SKEW_SECS: i64 = 120; /// Nonces are 16–64 characters of base64url (`A-Z a-z 0-9 - _`), e.g. 16 random bytes encoded. pub const NONCE_MIN_LEN: usize = 16; pub const NONCE_MAX_LEN: usize = 64; /// `Content-Digest` value (RFC 9530) for a request body. pub fn content_digest(body: &[u8]) -> String { format!("sha-256=:{}:", STANDARD.encode(Sha256::digest(body))) } /// The `@signature-params` value. `created` must equal the `KV-Ts` header and `keyid` is the device id. pub fn signature_params(created: i64, keyid: Uuid, nonce: &str) -> String { format!(r#"{COVERED_COMPONENTS};created={created};keyid="{keyid}";alg="{ALG}";nonce="{nonce}""#) } /// The RFC 9421 signature base the device key signs. /// /// `target_uri` is the absolute URI the client requested, e.g. /// `https://vault.example.com/v1/vaults/…/changes?since=3`. Header values are passed exactly as sent. pub fn signature_base( method: &str, target_uri: &str, content_digest: &str, session: &str, ts: &str, params: &str, ) -> String { format!( "\"@method\": {method}\n\"@target-uri\": {target_uri}\n\"content-digest\": {content_digest}\n\ \"kv-session\": {session}\n\"kv-ts\": {ts}\n\"@signature-params\": {params}" ) } /// `Signature-Input` header value. pub fn signature_input_header(params: &str) -> String { format!("{LABEL}={params}") } /// `Signature` header value for a raw 64-byte `r||s` signature. pub fn signature_header(sig: &[u8]) -> String { format!("{LABEL}=:{}:", STANDARD.encode(sig)) } /// True if `nonce` fits the allowed alphabet and length. pub fn valid_nonce(nonce: &str) -> bool { (NONCE_MIN_LEN..=NONCE_MAX_LEN).contains(&nonce.len()) && nonce.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') } #[cfg(test)] mod tests { use super::*; #[test] fn empty_body_digest() { assert_eq!(content_digest(b""), "sha-256=:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=:"); } #[test] fn base_matches_rfc9421_layout() { let kid = Uuid::nil(); let params = signature_params(1_700_000_000, kid, "AAAAAAAAAAAAAAAAAAAAAA"); assert_eq!( params, r#"("@method" "@target-uri" "content-digest" "kv-session" "kv-ts");created=1700000000;keyid="00000000-0000-0000-0000-000000000000";alg="ecdsa-p256-sha256";nonce="AAAAAAAAAAAAAAAAAAAAAA""# ); let base = signature_base("GET", "https://v.example/v1/vaults", "sha-256=:x:", "s", "1700000000", ¶ms); assert_eq!( base, format!( "\"@method\": GET\n\"@target-uri\": https://v.example/v1/vaults\n\"content-digest\": sha-256=:x:\n\ \"kv-session\": s\n\"kv-ts\": 1700000000\n\"@signature-params\": {params}" ) ); assert_eq!(signature_input_header(¶ms), format!("kv={params}")); assert_eq!(signature_header(&[0u8; 3]), "kv=:AAAA:"); } #[test] fn nonce_rules() { assert!(valid_nonce("abcdefghijklmnop")); assert!(!valid_nonce("short")); assert!(!valid_nonce("abcdefghijklmno\"")); } }