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]>
This commit is contained in:
2026-10-15 21:22:03 +09:00
co-authored by Claude Opus 5.5
parent 4a152bde11
commit 48c7e32d33
5 changed files with 355 additions and 0 deletions
+116
View File
@@ -0,0 +1,116 @@
//! 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\""));
}
}