use std::time::{Duration, SystemTime, UNIX_EPOCH};
use serde_json::json;
use super::*;
use crate::jwt::{compact_jws, hmac_sha256, EcdsaP256Key};
const VERIFIER: &crate::jwt::P256Verifier = &crate::jwt::P256Verifier;
fn now() -> SystemTime {
UNIX_EPOCH + Duration::from_secs(1_700_000_000)
}
fn secs(t: SystemTime) -> u64 {
t.duration_since(UNIX_EPOCH).unwrap().as_secs()
}
const HTM: &str = "POST";
const HTU: &str = "https://as.example/token";
fn claims() -> serde_json::Value {
json!({ "jti": "proof-0001", "htm": HTM, "htu": HTU, "iat": secs(now()) })
}
fn header(key: &EcdsaP256Key) -> serde_json::Value {
json!({
"typ": "dpop+jwt",
"alg": "ES256",
"jwk": serde_json::to_value(key.to_public_jwk()).unwrap(),
})
}
fn proof_with(
key: &EcdsaP256Key,
header: &serde_json::Value,
claims: &serde_json::Value,
) -> String {
compact_jws(
&serde_json::to_vec(header).unwrap(),
&serde_json::to_vec(claims).unwrap(),
|input| key.sign_signing_input(input).unwrap(),
)
}
fn verify(proof: &str) -> Result<VerifiedProof, DpopFailure> {
verify_proof(VERIFIER, proof, HTM, HTU, now())
}
#[test]
fn a_conforming_proof_verifies_and_yields_the_key_thumbprint() {
let key = EcdsaP256Key::generate("device-key");
let proof = proof_with(&key, &header(&key), &claims());
let verified = verify(&proof).expect("a conforming proof verifies");
assert_eq!(verified.jti, "proof-0001");
assert_eq!(verified.jkt, key.to_public_jwk().thumbprint());
}
#[test]
fn the_request_uri_is_compared_without_its_query_or_fragment() {
let key = EcdsaP256Key::generate("k");
let proof = proof_with(&key, &header(&key), &claims());
assert!(verify_proof(
VERIFIER,
&proof,
HTM,
"https://as.example/token?x=1#f",
now()
)
.is_ok());
let mut c = claims();
c["htu"] = json!("https://as.example/token?x=1");
let proof = proof_with(&key, &header(&key), &c);
assert!(verify(&proof).is_ok());
}
#[test]
fn a_proof_within_the_acceptance_window_verifies() {
let key = EcdsaP256Key::generate("k");
let mut c = claims();
c["iat"] = json!(secs(now() - MAX_PROOF_AGE) + 1);
let proof = proof_with(&key, &header(&key), &c);
assert!(verify(&proof).is_ok());
}
#[test]
fn a_jwt_that_is_not_typed_as_a_proof_is_refused() {
let key = EcdsaP256Key::generate("k");
for typ in [json!("JWT"), json!("at+jwt"), json!("DPOP+JWT")] {
let mut h = header(&key);
h["typ"] = typ.clone();
let proof = proof_with(&key, &h, &claims());
assert_eq!(verify(&proof), Err(DpopFailure::NotAProof), "typ {typ}");
}
}
#[test]
fn a_proof_with_no_typ_at_all_is_refused() {
let key = EcdsaP256Key::generate("k");
let mut h = header(&key);
h.as_object_mut().unwrap().remove("typ");
let proof = proof_with(&key, &h, &claims());
assert_eq!(verify(&proof), Err(DpopFailure::NotAProof));
}
#[test]
fn alg_none_is_refused() {
let key = EcdsaP256Key::generate("k");
let mut h = header(&key);
h["alg"] = json!("none");
let header_bytes = serde_json::to_vec(&h).unwrap();
let payload = serde_json::to_vec(&claims()).unwrap();
let proof = compact_jws(&header_bytes, &payload, |_| Vec::new());
assert_eq!(verify(&proof), Err(DpopFailure::UnsupportedAlgorithm));
}
#[test]
fn a_symmetric_alg_is_refused() {
let key = EcdsaP256Key::generate("k");
let mut h = header(&key);
h["alg"] = json!("HS256");
let secret = serde_json::to_string(&key.to_public_jwk()).unwrap();
let proof = compact_jws(
&serde_json::to_vec(&h).unwrap(),
&serde_json::to_vec(&claims()).unwrap(),
|input| hmac_sha256(secret.as_bytes(), input.as_bytes()).to_vec(),
);
assert_eq!(verify(&proof), Err(DpopFailure::UnsupportedAlgorithm));
}
#[test]
fn a_proof_whose_jwk_carries_the_private_key_is_refused() {
let key = EcdsaP256Key::generate("k");
let mut h = header(&key);
h["jwk"]["d"] = json!("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA");
let proof = proof_with(&key, &h, &claims());
assert_eq!(verify(&proof), Err(DpopFailure::BadProofKey));
}
#[test]
fn a_proof_with_no_jwk_is_refused() {
let key = EcdsaP256Key::generate("k");
let mut h = header(&key);
h.as_object_mut().unwrap().remove("jwk");
let proof = proof_with(&key, &h, &claims());
assert_eq!(verify(&proof), Err(DpopFailure::BadProofKey));
}
#[test]
fn a_proof_signed_by_a_key_other_than_the_one_it_advertises_is_refused() {
let key = EcdsaP256Key::generate("k");
let attacker = EcdsaP256Key::generate("attacker");
let proof = proof_with(&key, &header(&attacker), &claims());
assert_eq!(verify(&proof), Err(DpopFailure::BadSignature));
}
#[test]
fn a_tampered_proof_payload_is_refused() {
let key = EcdsaP256Key::generate("k");
let proof = proof_with(&key, &header(&key), &claims());
let mut parts: Vec<String> = proof.split('.').map(str::to_string).collect();
let mut c = claims();
c["htu"] = json!("https://as.example/other");
parts[1] = {
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
URL_SAFE_NO_PAD.encode(serde_json::to_vec(&c).unwrap())
};
assert_eq!(verify(&parts.join(".")), Err(DpopFailure::BadSignature));
}
#[test]
fn a_proof_made_for_another_uri_is_refused() {
let key = EcdsaP256Key::generate("k");
let mut c = claims();
c["htu"] = json!("https://rs.example/resource");
let proof = proof_with(&key, &header(&key), &c);
assert_eq!(verify(&proof), Err(DpopFailure::WrongUri));
}
#[test]
fn a_proof_made_for_another_method_is_refused() {
let key = EcdsaP256Key::generate("k");
let mut c = claims();
c["htm"] = json!("GET");
let proof = proof_with(&key, &header(&key), &c);
assert_eq!(verify(&proof), Err(DpopFailure::WrongMethod));
}
#[test]
fn htm_is_compared_case_sensitively() {
let key = EcdsaP256Key::generate("k");
let mut c = claims();
c["htm"] = json!("post");
let proof = proof_with(&key, &header(&key), &c);
assert_eq!(verify(&proof), Err(DpopFailure::WrongMethod));
}
#[test]
fn a_proof_missing_htm_or_htu_is_refused() {
let key = EcdsaP256Key::generate("k");
for missing in ["htm", "htu"] {
let mut c = claims();
c.as_object_mut().unwrap().remove(missing);
let proof = proof_with(&key, &header(&key), &c);
assert!(verify(&proof).is_err(), "a proof with no {missing}");
}
}
#[test]
fn a_stale_proof_is_refused() {
let key = EcdsaP256Key::generate("k");
let mut c = claims();
c["iat"] = json!(secs(now() - MAX_PROOF_AGE) - 1);
let proof = proof_with(&key, &header(&key), &c);
assert_eq!(verify(&proof), Err(DpopFailure::StaleProof));
}
#[test]
fn a_proof_from_the_future_is_refused() {
let key = EcdsaP256Key::generate("k");
let mut c = claims();
c["iat"] = json!(secs(now() + CLOCK_SKEW_LEEWAY) + 1);
let proof = proof_with(&key, &header(&key), &c);
assert_eq!(verify(&proof), Err(DpopFailure::StaleProof));
}
#[test]
fn a_proof_with_no_iat_is_refused() {
let key = EcdsaP256Key::generate("k");
let mut c = claims();
c.as_object_mut().unwrap().remove("iat");
let proof = proof_with(&key, &header(&key), &c);
assert_eq!(verify(&proof), Err(DpopFailure::StaleProof));
}
#[test]
fn a_proof_with_no_jti_is_refused() {
let key = EcdsaP256Key::generate("k");
for jti in [json!(""), json!(null)] {
let mut c = claims();
c["jti"] = jti.clone();
let proof = proof_with(&key, &header(&key), &c);
assert_eq!(verify(&proof), Err(DpopFailure::MissingJti), "jti {jti}");
}
}
#[test]
fn the_replay_deadline_is_exactly_the_window_this_proof_stays_acceptable_in() {
let key = EcdsaP256Key::generate("k");
for age in [0u64, 60, 299] {
let mut c = claims();
let iat = secs(now()) - age;
c["iat"] = json!(iat);
let proof = proof_with(&key, &header(&key), &c);
let verified = verify(&proof).unwrap();
assert_eq!(
verified.replay_until,
UNIX_EPOCH + Duration::from_secs(iat) + MAX_PROOF_AGE,
"a proof issued {age}s ago is remembered until its own iat plus the window"
);
}
}
#[test]
fn a_proof_is_refused_at_the_exact_instant_its_jti_stops_being_remembered() {
let key = EcdsaP256Key::generate("k");
let mut c = claims();
let iat = secs(now()) - MAX_PROOF_AGE.as_secs();
c["iat"] = json!(iat);
let proof = proof_with(&key, &header(&key), &c);
assert_eq!(
verify(&proof),
Err(DpopFailure::StaleProof),
"a proof is still acceptable at the instant its jti has already been swept"
);
}
#[test]
fn no_proof_outlives_the_deadline_it_asked_to_be_remembered_until() {
let key = EcdsaP256Key::generate("k");
for age in [0u64, 1, 60, 299] {
let mut c = claims();
c["iat"] = json!(secs(now()) - age);
let proof = proof_with(&key, &header(&key), &c);
let verified = verify(&proof).unwrap();
assert_eq!(
verify_proof(VERIFIER, &proof, HTM, HTU, verified.replay_until),
Err(DpopFailure::StaleProof),
"a proof issued {age}s ago is still accepted at its own replay_until"
);
assert!(
verify_proof(
VERIFIER,
&proof,
HTM,
HTU,
verified.replay_until - Duration::from_secs(1)
)
.is_ok(),
"a proof issued {age}s ago was refused before its window ran out"
);
}
}
#[test]
fn the_thumbprint_is_the_rfc_7638_construction_and_nothing_else() {
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use sha2::{Digest as _, Sha256};
let key = EcdsaP256Key::generate("some-kid");
let jwk = key.to_public_jwk();
let canonical = format!(
r#"{{"crv":"{}","kty":"{}","x":"{}","y":"{}"}}"#,
jwk.crv(),
jwk.kty(),
jwk.x(),
jwk.y()
);
let expected = URL_SAFE_NO_PAD.encode(Sha256::digest(canonical.as_bytes()));
assert_eq!(jwk.thumbprint(), expected);
}
#[test]
fn the_thumbprint_ignores_kid_and_any_other_optional_member() {
let key = EcdsaP256Key::generate("kid-one");
let relabelled = key.to_public_jwk();
let original = relabelled.thumbprint();
assert_eq!(
relabelled.clone().with_kid("kid-two").thumbprint(),
original
);
let unnamed = PublicJwk::from_coordinates(relabelled.x(), relabelled.y())
.expect("the same point, with no kid at all");
assert_eq!(unnamed.thumbprint(), original);
}
#[test]
fn garbage_is_refused_rather_than_panicking() {
for input in ["", ".", "..", "a.b", "a.b.c.d", "not base64.at all.here"] {
assert!(verify(input).is_err(), "{input:?} must be refused");
}
}
#[test]
fn htu_of_strips_the_query_and_the_fragment() {
assert_eq!(
htu_of("https://as.example/token"),
"https://as.example/token"
);
assert_eq!(
htu_of("https://as.example/token?a=b"),
"https://as.example/token"
);
assert_eq!(
htu_of("https://as.example/token#f"),
"https://as.example/token"
);
assert_eq!(
htu_of("https://as.example/token#f?a=b"),
"https://as.example/token"
);
}