use base64::prelude::*;
use serde::Deserialize;
use tracing::warn;
use crate::error::Problem;
use crate::extractors::acme::{Jwk, SignatureError, verify_jwk_signature_and_get_der};
pub(crate) type KeyChangeJws = crate::extractors::acme::AcmeJwsRequest;
#[derive(Debug, Deserialize)]
pub(crate) struct InnerHeader {
pub alg: String,
pub jwk: Jwk,
pub url: String,
}
#[derive(Debug, Deserialize)]
pub(crate) struct InnerPayload {
pub account: String,
#[serde(rename = "oldKey")]
pub old_key: Jwk,
}
#[derive(Debug)]
pub(crate) enum KeyChangeError {
Malformed(&'static str),
BadSignature,
Internal(&'static str),
}
impl From<SignatureError> for KeyChangeError {
fn from(error: SignatureError) -> Self {
match error {
SignatureError::Malformed(detail) => KeyChangeError::Malformed(detail),
SignatureError::BadAlgorithm(detail) => KeyChangeError::Malformed(detail),
SignatureError::BadSignature(_) => KeyChangeError::BadSignature,
SignatureError::Encoding(detail) => KeyChangeError::Internal(detail),
}
}
}
pub(crate) fn parse_header(
inner: &KeyChangeJws,
expected_url: &str,
) -> Result<InnerHeader, KeyChangeError> {
let protected_bytes = BASE64_URL_SAFE_NO_PAD
.decode(&inner.protected)
.map_err(|_| KeyChangeError::Malformed("inner protected base64 invalid"))?;
let header: InnerHeader = serde_json::from_slice(&protected_bytes)
.map_err(|_| KeyChangeError::Malformed("inner protected JSON invalid"))?;
if header.url != expected_url {
return Err(KeyChangeError::Malformed(
"inner url does not match the request",
));
}
Ok(header)
}
pub(crate) fn verify_signature(
inner: &KeyChangeJws,
header: &InnerHeader,
) -> Result<Vec<u8>, KeyChangeError> {
let signing_input = format!("{}.{}", inner.protected, inner.payload);
Ok(verify_jwk_signature_and_get_der(
&header.alg,
&header.jwk,
&signing_input,
&inner.signature,
)?)
}
pub(crate) fn verify_payload(
inner: &KeyChangeJws,
expected_account_url: &str,
expected_old_key: &Jwk,
) -> Result<InnerPayload, KeyChangeError> {
let payload_bytes = BASE64_URL_SAFE_NO_PAD
.decode(&inner.payload)
.map_err(|_| KeyChangeError::Malformed("inner payload base64 invalid"))?;
let payload: InnerPayload = serde_json::from_slice(&payload_bytes)
.map_err(|_| KeyChangeError::Malformed("inner payload is not a keyChange object"))?;
if payload.account != expected_account_url {
return Err(KeyChangeError::Malformed(
"inner account does not match the signer",
));
}
if &payload.old_key != expected_old_key {
return Err(KeyChangeError::Malformed(
"inner oldKey does not match the account key",
));
}
Ok(payload)
}
pub(crate) fn key_change_problem(error: KeyChangeError) -> Problem {
let (reason, detail) = match &error {
KeyChangeError::Malformed(detail) => ("malformed", *detail),
KeyChangeError::BadSignature => ("bad_signature", "inner JWS signature invalid"),
KeyChangeError::Internal(detail) => ("internal", *detail),
};
warn!(
event = "key_change_rejected",
outcome = "failure",
reason,
detail
);
match error {
KeyChangeError::Malformed(detail) => Problem::malformed(detail),
KeyChangeError::BadSignature => Problem::unauthorized("Inner JWS signature invalid"),
KeyChangeError::Internal(detail) => Problem::server_internal(detail),
}
}
#[cfg(test)]
mod tests {
use super::*;
use ring::rand::SystemRandom;
use ring::signature::{EcdsaKeyPair, KeyPair};
use serde_json::json;
const URL: &str = "http://localhost:3000/keyChange";
fn b64(data: &[u8]) -> String {
BASE64_URL_SAFE_NO_PAD.encode(data)
}
fn b64_json(value: &serde_json::Value) -> String {
b64(&serde_json::to_vec(value).unwrap())
}
fn generate_ec_key() -> EcdsaKeyPair {
let rng = SystemRandom::new();
let doc =
EcdsaKeyPair::generate_pkcs8(&ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING, &rng)
.unwrap();
EcdsaKeyPair::from_pkcs8(
&ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING,
doc.as_ref(),
&rng,
)
.unwrap()
}
fn ec_jwk(key_pair: &EcdsaKeyPair) -> Jwk {
let point = key_pair.public_key().as_ref();
Jwk::EC {
crv: "P-256".to_string(),
x: b64(&point[1..33]),
y: b64(&point[33..65]),
}
}
fn build(new_key: &EcdsaKeyPair, url: &str, payload: &serde_json::Value) -> KeyChangeJws {
let protected = json!({ "alg": "ES256", "jwk": ec_jwk_value(new_key), "url": url });
let protected_b64 = b64_json(&protected);
let payload_b64 = b64_json(payload);
let signing_input = format!("{protected_b64}.{payload_b64}");
let rng = SystemRandom::new();
let sig = new_key.sign(&rng, signing_input.as_bytes()).unwrap();
KeyChangeJws {
protected: protected_b64,
payload: payload_b64,
signature: b64(sig.as_ref()),
}
}
fn ec_jwk_value(key_pair: &EcdsaKeyPair) -> serde_json::Value {
let point = key_pair.public_key().as_ref();
json!({ "kty": "EC", "crv": "P-256", "x": b64(&point[1..33]), "y": b64(&point[33..65]) })
}
fn account_url() -> &'static str {
"http://localhost:3000/acct/1"
}
fn payload_for(old_key: &Jwk) -> serde_json::Value {
let old_key_json = match old_key {
Jwk::EC { crv, x, y } => json!({ "kty": "EC", "crv": crv, "x": x, "y": y }),
Jwk::RSA { n, e } => json!({ "kty": "RSA", "n": n, "e": e }),
};
json!({ "account": account_url(), "oldKey": old_key_json })
}
#[test]
fn parse_header_accepts_well_formed_and_matching_url() {
let new_key = generate_ec_key();
let old_key = ec_jwk(&generate_ec_key());
let inner = build(&new_key, URL, &payload_for(&old_key));
let header = parse_header(&inner, URL).unwrap();
assert_eq!(header.alg, "ES256");
}
#[test]
fn parse_header_rejects_url_mismatch() {
let new_key = generate_ec_key();
let old_key = ec_jwk(&generate_ec_key());
let inner = build(
&new_key,
"http://localhost:3000/other",
&payload_for(&old_key),
);
assert!(matches!(
parse_header(&inner, URL),
Err(KeyChangeError::Malformed(_))
));
}
#[test]
fn parse_header_rejects_malformed_base64_and_json() {
let new_key = generate_ec_key();
let old_key = ec_jwk(&generate_ec_key());
let mut inner = build(&new_key, URL, &payload_for(&old_key));
inner.protected = "!!!not-base64!!!".to_string();
assert!(matches!(
parse_header(&inner, URL),
Err(KeyChangeError::Malformed(_))
));
let mut inner = build(&new_key, URL, &payload_for(&old_key));
inner.protected = b64(b"not json");
assert!(matches!(
parse_header(&inner, URL),
Err(KeyChangeError::Malformed(_))
));
}
#[test]
fn parse_header_rejects_missing_jwk() {
let protected_b64 = b64_json(&json!({ "alg": "ES256", "url": URL }));
let inner = KeyChangeJws {
protected: protected_b64,
payload: String::new(),
signature: String::new(),
};
assert!(matches!(
parse_header(&inner, URL),
Err(KeyChangeError::Malformed(_))
));
}
#[test]
fn verify_signature_accepts_a_correctly_self_signed_inner_jws() {
let new_key = generate_ec_key();
let old_key = ec_jwk(&generate_ec_key());
let inner = build(&new_key, URL, &payload_for(&old_key));
let header = parse_header(&inner, URL).unwrap();
let der = verify_signature(&inner, &header).unwrap();
assert!(!der.is_empty());
}
#[test]
fn verify_signature_rejects_a_tampered_signature() {
let new_key = generate_ec_key();
let old_key = ec_jwk(&generate_ec_key());
let mut inner = build(&new_key, URL, &payload_for(&old_key));
let header = parse_header(&inner, URL).unwrap();
inner.signature = b64(&[0u8; 64]);
assert!(matches!(
verify_signature(&inner, &header),
Err(KeyChangeError::BadSignature)
));
}
#[test]
fn verify_signature_rejects_a_tampered_payload() {
let new_key = generate_ec_key();
let old_key = ec_jwk(&generate_ec_key());
let mut inner = build(&new_key, URL, &payload_for(&old_key));
let header = parse_header(&inner, URL).unwrap();
inner.payload = b64_json(&payload_for(&ec_jwk(&generate_ec_key())));
assert!(matches!(
verify_signature(&inner, &header),
Err(KeyChangeError::BadSignature)
));
}
#[test]
fn verify_payload_accepts_correct_account_and_old_key() {
let new_key = generate_ec_key();
let old_key = ec_jwk(&generate_ec_key());
let inner = build(&new_key, URL, &payload_for(&old_key));
let payload = verify_payload(&inner, account_url(), &old_key).unwrap();
assert_eq!(payload.account, account_url());
}
#[test]
fn verify_payload_rejects_account_mismatch() {
let new_key = generate_ec_key();
let old_key = ec_jwk(&generate_ec_key());
let inner = build(&new_key, URL, &payload_for(&old_key));
assert!(matches!(
verify_payload(&inner, "http://localhost:3000/acct/999", &old_key),
Err(KeyChangeError::Malformed(_))
));
}
#[test]
fn verify_payload_rejects_old_key_mismatch() {
let new_key = generate_ec_key();
let old_key = ec_jwk(&generate_ec_key());
let unrelated_key = ec_jwk(&generate_ec_key());
let inner = build(&new_key, URL, &payload_for(&old_key));
assert!(matches!(
verify_payload(&inner, account_url(), &unrelated_key),
Err(KeyChangeError::Malformed(_))
));
}
#[test]
fn verify_payload_rejects_malformed_payload_json() {
let new_key = generate_ec_key();
let old_key = ec_jwk(&generate_ec_key());
let mut inner = build(&new_key, URL, &payload_for(&old_key));
inner.payload = b64(b"not a keyChange object");
assert!(matches!(
verify_payload(&inner, account_url(), &old_key),
Err(KeyChangeError::Malformed(_))
));
}
#[test]
fn verify_payload_rejects_malformed_payload_base64() {
let new_key = generate_ec_key();
let old_key = ec_jwk(&generate_ec_key());
let mut inner = build(&new_key, URL, &payload_for(&old_key));
inner.payload = "!!!not-base64!!!".to_string();
assert!(matches!(
verify_payload(&inner, account_url(), &old_key),
Err(KeyChangeError::Malformed(_))
));
}
}