Adds the OpaqueSuite (Ristretto255 OPRF, TripleDH, SHA-512, identity KSF) shared by client and server, the httpsig signature-base builders, and the setup/credentials/account/device/SSE types for milestone M1. Cargo.lock changes only for kv-proto's new dependencies. Co-Authored-By: Claude Opus 5.5 <[email protected]>
117 lines
4.4 KiB
Rust
117 lines
4.4 KiB
Rust
//! 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=:<base64 SHA-256 of the body, also for an empty body>:
|
||
//! KV-Session: <session id from login>
|
||
//! KV-Ts: <unix seconds>
|
||
//! Signature-Input: kv=<signature_params(...)>
|
||
//! Signature: kv=:<base64 of the 64-byte P-256 ECDSA signature r||s over signature_base(...)>:
|
||
//! ```
|
||
//!
|
||
//! 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\""));
|
||
}
|
||
}
|