Skip to main content

acme_proxy_core/
key_change.rs

1//! Account Key Rollover (RFC 8555 §7.3.5): verification of the nested inner
2//! JWS a `keyChange` request's payload carries, proving simultaneous
3//! possession of both the old and new account keys.
4//!
5//! Pure verification logic only -- no database access. This mirrors the
6//! split [`crate::eab`] draws for the same reason: this module only checks
7//! an already-parsed request against values the caller (`post_key_change` in
8//! `lib.rs`) already looked up.
9//!
10//! ## Shape
11//!
12//! RFC 8555 §7.3.5's inner object is itself a flattened JWS (RFC 7515),
13//! reusing [`crate::jws::AcmeJwsRequest`]'s `{protected,
14//! payload, signature}` shape as [`KeyChangeJws`] -- the same convention
15//! [`crate::eab::EabJws`] uses.
16//!
17//! Its protected header ([`InnerHeader`]) is its own type rather than a reuse
18//! of [`crate::jws::ProtectedHeader`], for the same reason
19//! EAB's is: RFC 8555 §7.3.5 requires the inner JWS to **omit `nonce`**
20//! entirely (verified against the RFC text directly) -- this is a request
21//! the outer JWS already replay-protects, not a second signed request in its
22//! own right -- and it must carry `jwk`, never `kid`, since the whole point
23//! is proving possession of a *new*, not-yet-registered key.
24//!
25//! Its payload ([`InnerPayload`]) names the account being rolled (`account`,
26//! checked against the outer JWS's own `kid`) and restates the *old* key as
27//! a JWK (`oldKey`, checked against the account's stored key). Accounts are
28//! keyed by DER SPKI rather than by JWK (see
29//! [`crate::jws::signature::jwk_thumbprint`]'s doc comment), so the
30//! caller reconstructs a [`Jwk`] from storage via
31//! [`crate::jws::signature::spki_to_jwk`] and this module compares the two
32//! structurally (`Jwk` derives `PartialEq`) -- mirroring exactly how
33//! [`crate::eab::verify_payload_and_signature`] compares its own embedded
34//! JWK against the account's. Both checks exist so a captured inner JWS
35//! cannot be replayed onto a different account or a different old key than
36//! the one that produced it.
37
38use base64::prelude::*;
39use serde::Deserialize;
40use tracing::warn;
41
42use crate::error::Problem;
43use crate::jws::Jwk;
44use crate::jws::signature::{SignatureError, verify_jwk_signature_and_get_der};
45
46/// The inner keyChange JWS: the same flattened `{protected, payload,
47/// signature}` shape as the outer request JWS.
48pub type KeyChangeJws = crate::jws::AcmeJwsRequest;
49
50/// The inner keyChange JWS's protected header. See the [module docs](self)
51/// for why this is its own type rather than a reuse of `ProtectedHeader`.
52#[derive(Debug, Deserialize)]
53pub struct InnerHeader {
54    pub alg: String,
55    pub jwk: Jwk,
56    pub url: String,
57}
58
59/// The inner keyChange JWS's payload -- RFC 8555 §7.3.5's `keyChange` object.
60#[derive(Debug, Deserialize)]
61pub struct InnerPayload {
62    pub account: String,
63    #[serde(rename = "oldKey")]
64    pub old_key: Jwk,
65}
66
67/// Why keyChange verification failed, in the three buckets [`key_change_problem`]
68/// renders as HTTP status: a shape problem the client can fix by resending
69/// correctly formed inner JWS (`Malformed`, 400), a signature that plainly
70/// does not verify against its own embedded key (`BadSignature`, 401), or a
71/// stored account key this server itself cannot decode (`Internal`, 500 --
72/// our bug, not the client's).
73#[derive(Debug)]
74pub enum KeyChangeError {
75    Malformed(&'static str),
76    BadSignature,
77    Internal(&'static str),
78}
79
80impl From<SignatureError> for KeyChangeError {
81    fn from(error: SignatureError) -> Self {
82        match error {
83            SignatureError::Malformed(detail) => KeyChangeError::Malformed(detail),
84            // An unsupported `alg` on the *inner* JWS stays `malformed` rather
85            // than becoming `badSignatureAlgorithm`: RFC 8555 §6.2 is about the
86            // request's own signature, and the inner JWS is payload here — a
87            // client renegotiating on the strength of that error would change
88            // the wrong thing.
89            SignatureError::BadAlgorithm(detail) => KeyChangeError::Malformed(detail),
90            SignatureError::BadSignature(_) => KeyChangeError::BadSignature,
91            SignatureError::Encoding(detail) => KeyChangeError::Internal(detail),
92        }
93    }
94}
95
96/// Decodes and validates the inner JWS's protected header (RFC 8555 §7.3.5
97/// checks #3/#6): `jwk` must be present (guaranteed by `InnerHeader`'s shape
98/// once it deserializes at all), and `url` must equal `expected_url` -- the
99/// *outer* JWS's own already-verified `header.url` (RFC 8555 §6.4), i.e.
100/// this exact `keyChange` request. No `nonce` check is made: RFC 8555
101/// §7.3.5 requires the inner JWS to omit it entirely, and an extra field a
102/// client sends anyway is silently ignored, like any unknown field the
103/// payload types in this codebase accept.
104pub fn parse_header(
105    inner: &KeyChangeJws,
106    expected_url: &str,
107) -> Result<InnerHeader, KeyChangeError> {
108    let protected_bytes = BASE64_URL_SAFE_NO_PAD
109        .decode(&inner.protected)
110        .map_err(|_| KeyChangeError::Malformed("inner protected base64 invalid"))?;
111
112    let header: InnerHeader = serde_json::from_slice(&protected_bytes)
113        .map_err(|_| KeyChangeError::Malformed("inner protected JSON invalid"))?;
114
115    if header.url != expected_url {
116        return Err(KeyChangeError::Malformed(
117            "inner url does not match the request",
118        ));
119    }
120
121    Ok(header)
122}
123
124/// Verifies the inner JWS's self-signature (RFC 8555 §7.3.5 check #4 -- the
125/// new key signs its own request) and returns that key as DER SPKI, the form
126/// accounts are keyed by.
127pub fn verify_signature(
128    inner: &KeyChangeJws,
129    header: &InnerHeader,
130) -> Result<Vec<u8>, KeyChangeError> {
131    let signing_input = format!("{}.{}", inner.protected, inner.payload);
132    Ok(verify_jwk_signature_and_get_der(
133        &header.alg,
134        &header.jwk,
135        &signing_input,
136        &inner.signature,
137    )?)
138}
139
140/// Decodes and validates the inner JWS's payload (RFC 8555 §7.3.5 checks
141/// #5/#7/#8): well-formed `keyChange` object, `account` naming the account
142/// that signed the outer JWS, and `oldKey` structurally equal to that
143/// account's current key. Both comparisons are checked here, not left to the
144/// caller, mirroring [`crate::eab::verify_payload_and_signature`]'s own
145/// embedded-JWK check -- a mismatch is `Malformed`, not `BadSignature`: it is
146/// a claim about identity, not a cryptographic failure.
147pub fn verify_payload(
148    inner: &KeyChangeJws,
149    expected_account_url: &str,
150    expected_old_key: &Jwk,
151) -> Result<InnerPayload, KeyChangeError> {
152    let payload_bytes = BASE64_URL_SAFE_NO_PAD
153        .decode(&inner.payload)
154        .map_err(|_| KeyChangeError::Malformed("inner payload base64 invalid"))?;
155
156    let payload: InnerPayload = serde_json::from_slice(&payload_bytes)
157        .map_err(|_| KeyChangeError::Malformed("inner payload is not a keyChange object"))?;
158
159    if payload.account != expected_account_url {
160        return Err(KeyChangeError::Malformed(
161            "inner account does not match the signer",
162        ));
163    }
164    if &payload.old_key != expected_old_key {
165        return Err(KeyChangeError::Malformed(
166            "inner oldKey does not match the account key",
167        ));
168    }
169
170    Ok(payload)
171}
172
173/// Maps a [`KeyChangeError`] to the `Problem` the caller rejects with.
174///
175/// Also the one place a rejection here is logged. Every `Err` in this module
176/// funnels through it, so one event covers all eight of them — and until it
177/// existed, a forged inner JWS on `POST /keyChange` (a bid to take over
178/// somebody else's account) left no trace at all: the handler sees only the
179/// `Problem`, never which check refused it.
180pub fn key_change_problem(error: KeyChangeError) -> Problem {
181    let (reason, detail) = match &error {
182        KeyChangeError::Malformed(detail) => ("malformed", *detail),
183        KeyChangeError::BadSignature => ("bad_signature", "inner JWS signature invalid"),
184        KeyChangeError::Internal(detail) => ("internal", *detail),
185    };
186    warn!(
187        event = "key_change_rejected",
188        outcome = "failure",
189        reason,
190        detail
191    );
192
193    match error {
194        KeyChangeError::Malformed(detail) => Problem::malformed(detail),
195        KeyChangeError::BadSignature => Problem::unauthorized("Inner JWS signature invalid"),
196        KeyChangeError::Internal(detail) => Problem::server_internal(detail),
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use ring::rand::SystemRandom;
204    use ring::signature::{EcdsaKeyPair, KeyPair};
205    use serde_json::json;
206
207    const URL: &str = "http://localhost:3000/keyChange";
208
209    fn b64(data: &[u8]) -> String {
210        BASE64_URL_SAFE_NO_PAD.encode(data)
211    }
212
213    fn b64_json(value: &serde_json::Value) -> String {
214        b64(&serde_json::to_vec(value).unwrap())
215    }
216
217    fn generate_ec_key() -> EcdsaKeyPair {
218        let rng = SystemRandom::new();
219        let doc =
220            EcdsaKeyPair::generate_pkcs8(&ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING, &rng)
221                .unwrap();
222        EcdsaKeyPair::from_pkcs8(
223            &ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING,
224            doc.as_ref(),
225            &rng,
226        )
227        .unwrap()
228    }
229
230    fn ec_jwk(key_pair: &EcdsaKeyPair) -> Jwk {
231        let point = key_pair.public_key().as_ref();
232        Jwk::EC {
233            crv: "P-256".to_string(),
234            x: b64(&point[1..33]),
235            y: b64(&point[33..65]),
236        }
237    }
238
239    /// Builds a well-formed inner keyChange JWS, self-signed by `new_key`,
240    /// embedding `new_key`'s own JWK.
241    fn build(new_key: &EcdsaKeyPair, url: &str, payload: &serde_json::Value) -> KeyChangeJws {
242        let protected = json!({ "alg": "ES256", "jwk": ec_jwk_value(new_key), "url": url });
243        let protected_b64 = b64_json(&protected);
244        let payload_b64 = b64_json(payload);
245        let signing_input = format!("{protected_b64}.{payload_b64}");
246        let rng = SystemRandom::new();
247        let sig = new_key.sign(&rng, signing_input.as_bytes()).unwrap();
248        KeyChangeJws {
249            protected: protected_b64,
250            payload: payload_b64,
251            signature: b64(sig.as_ref()),
252        }
253    }
254
255    fn ec_jwk_value(key_pair: &EcdsaKeyPair) -> serde_json::Value {
256        let point = key_pair.public_key().as_ref();
257        json!({ "kty": "EC", "crv": "P-256", "x": b64(&point[1..33]), "y": b64(&point[33..65]) })
258    }
259
260    fn account_url() -> &'static str {
261        "http://localhost:3000/acct/1"
262    }
263
264    fn payload_for(old_key: &Jwk) -> serde_json::Value {
265        let old_key_json = match old_key {
266            Jwk::EC { crv, x, y } => json!({ "kty": "EC", "crv": crv, "x": x, "y": y }),
267            Jwk::RSA { n, e } => json!({ "kty": "RSA", "n": n, "e": e }),
268        };
269        json!({ "account": account_url(), "oldKey": old_key_json })
270    }
271
272    #[test]
273    fn parse_header_accepts_well_formed_and_matching_url() {
274        let new_key = generate_ec_key();
275        let old_key = ec_jwk(&generate_ec_key());
276        let inner = build(&new_key, URL, &payload_for(&old_key));
277
278        let header = parse_header(&inner, URL).unwrap();
279        assert_eq!(header.alg, "ES256");
280    }
281
282    #[test]
283    fn parse_header_rejects_url_mismatch() {
284        let new_key = generate_ec_key();
285        let old_key = ec_jwk(&generate_ec_key());
286        let inner = build(
287            &new_key,
288            "http://localhost:3000/other",
289            &payload_for(&old_key),
290        );
291
292        assert!(matches!(
293            parse_header(&inner, URL),
294            Err(KeyChangeError::Malformed(_))
295        ));
296    }
297
298    #[test]
299    fn parse_header_rejects_malformed_base64_and_json() {
300        let new_key = generate_ec_key();
301        let old_key = ec_jwk(&generate_ec_key());
302
303        let mut inner = build(&new_key, URL, &payload_for(&old_key));
304        inner.protected = "!!!not-base64!!!".to_string();
305        assert!(matches!(
306            parse_header(&inner, URL),
307            Err(KeyChangeError::Malformed(_))
308        ));
309
310        let mut inner = build(&new_key, URL, &payload_for(&old_key));
311        inner.protected = b64(b"not json");
312        assert!(matches!(
313            parse_header(&inner, URL),
314            Err(KeyChangeError::Malformed(_))
315        ));
316    }
317
318    #[test]
319    fn parse_header_rejects_missing_jwk() {
320        let protected_b64 = b64_json(&json!({ "alg": "ES256", "url": URL }));
321        let inner = KeyChangeJws {
322            protected: protected_b64,
323            payload: String::new(),
324            signature: String::new(),
325        };
326        assert!(matches!(
327            parse_header(&inner, URL),
328            Err(KeyChangeError::Malformed(_))
329        ));
330    }
331
332    #[test]
333    fn verify_signature_accepts_a_correctly_self_signed_inner_jws() {
334        let new_key = generate_ec_key();
335        let old_key = ec_jwk(&generate_ec_key());
336        let inner = build(&new_key, URL, &payload_for(&old_key));
337        let header = parse_header(&inner, URL).unwrap();
338
339        let der = verify_signature(&inner, &header).unwrap();
340        assert!(!der.is_empty());
341    }
342
343    #[test]
344    fn verify_signature_rejects_a_tampered_signature() {
345        let new_key = generate_ec_key();
346        let old_key = ec_jwk(&generate_ec_key());
347        let mut inner = build(&new_key, URL, &payload_for(&old_key));
348        let header = parse_header(&inner, URL).unwrap();
349        inner.signature = b64(&[0u8; 64]);
350
351        assert!(matches!(
352            verify_signature(&inner, &header),
353            Err(KeyChangeError::BadSignature)
354        ));
355    }
356
357    #[test]
358    fn verify_signature_rejects_a_tampered_payload() {
359        let new_key = generate_ec_key();
360        let old_key = ec_jwk(&generate_ec_key());
361        let mut inner = build(&new_key, URL, &payload_for(&old_key));
362        let header = parse_header(&inner, URL).unwrap();
363        // A different, still well-formed payload -- the signature no longer
364        // covers this content.
365        inner.payload = b64_json(&payload_for(&ec_jwk(&generate_ec_key())));
366
367        assert!(matches!(
368            verify_signature(&inner, &header),
369            Err(KeyChangeError::BadSignature)
370        ));
371    }
372
373    #[test]
374    fn verify_payload_accepts_correct_account_and_old_key() {
375        let new_key = generate_ec_key();
376        let old_key = ec_jwk(&generate_ec_key());
377        let inner = build(&new_key, URL, &payload_for(&old_key));
378
379        let payload = verify_payload(&inner, account_url(), &old_key).unwrap();
380        assert_eq!(payload.account, account_url());
381    }
382
383    #[test]
384    fn verify_payload_rejects_account_mismatch() {
385        let new_key = generate_ec_key();
386        let old_key = ec_jwk(&generate_ec_key());
387        let inner = build(&new_key, URL, &payload_for(&old_key));
388
389        assert!(matches!(
390            verify_payload(&inner, "http://localhost:3000/acct/999", &old_key),
391            Err(KeyChangeError::Malformed(_))
392        ));
393    }
394
395    #[test]
396    fn verify_payload_rejects_old_key_mismatch() {
397        let new_key = generate_ec_key();
398        let old_key = ec_jwk(&generate_ec_key());
399        let unrelated_key = ec_jwk(&generate_ec_key());
400        let inner = build(&new_key, URL, &payload_for(&old_key));
401
402        assert!(matches!(
403            verify_payload(&inner, account_url(), &unrelated_key),
404            Err(KeyChangeError::Malformed(_))
405        ));
406    }
407
408    #[test]
409    fn verify_payload_rejects_malformed_payload_json() {
410        let new_key = generate_ec_key();
411        let old_key = ec_jwk(&generate_ec_key());
412        let mut inner = build(&new_key, URL, &payload_for(&old_key));
413        inner.payload = b64(b"not a keyChange object");
414
415        assert!(matches!(
416            verify_payload(&inner, account_url(), &old_key),
417            Err(KeyChangeError::Malformed(_))
418        ));
419    }
420
421    #[test]
422    fn verify_payload_rejects_malformed_payload_base64() {
423        let new_key = generate_ec_key();
424        let old_key = ec_jwk(&generate_ec_key());
425        let mut inner = build(&new_key, URL, &payload_for(&old_key));
426        inner.payload = "!!!not-base64!!!".to_string();
427
428        assert!(matches!(
429            verify_payload(&inner, account_url(), &old_key),
430            Err(KeyChangeError::Malformed(_))
431        ));
432    }
433}