Files
kestrove-core/crates/kv-proto/src/httpsig.rs
T
samiandClaude Opus 5.5 48c7e32d33 kv-proto: OPAQUE suite, RFC 9421 signing profile and M1 auth wire types
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]>
2026-10-15 21:22:03 +09:00

117 lines
4.4 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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", &params);
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(&params), 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\""));
}
}