Skip to main content

acme_proxy_core/
eab.rs

1//! External Account Binding (RFC 8555 §7.3.4): verification of the inner HMAC
2//! JWS a `newAccount` payload may carry, proving the client holds a
3//! pre-shared credential an operator issued out-of-band.
4//!
5//! Pure verification logic only -- no database access. `acme_proxy_store::eab`
6//! is the persistence layer for the credentials themselves (create/find/list/
7//! revoke); this module only checks an already-looked-up secret against an
8//! already-parsed request. The same split `dns` and
9//! [`crate::pemfile`] draw between "how" and "where from".
10//!
11//! ## Shape
12//!
13//! The inner object is itself a flattened JWS (RFC 7515), reusing
14//! [`crate::jws::AcmeJwsRequest`]'s `{protected, payload,
15//! signature}` shape as [`EabJws`] -- the same three-field envelope,
16//! HMAC-signed rather than public-key-signed.
17//!
18//! Its protected header ([`EabHeader`]) is deliberately its own type rather
19//! than a reuse of [`crate::jws::ProtectedHeader`]: `kid` is
20//! required here (a distinct namespace from the account `kid`), there is no
21//! `jwk` member, and there is no `nonce` -- this is a credential bound into a
22//! request the outer JWS already authenticates and replay-protects, not a
23//! second signed request in its own right. A client that sends a `nonce`
24//! anyway has it silently ignored, like any other unknown field the payload
25//! types in this codebase accept.
26//!
27//! Its payload is the account's public key, as a plain JWK JSON object -- the
28//! same [`Jwk`] shape the outer JWS embeds -- and verification requires the
29//! two to be structurally equal (`Jwk` derives `PartialEq`), which is what
30//! binds the credential to *this* account key rather than any other.
31
32use base64::prelude::*;
33use ring::hmac;
34use serde::Deserialize;
35
36use crate::error::Problem;
37use crate::jws::Jwk;
38
39/// The inner EAB JWS: the same flattened `{protected, payload, signature}`
40/// shape as the outer request JWS.
41pub type EabJws = crate::jws::AcmeJwsRequest;
42
43/// The inner EAB JWS's protected header. See the [module docs](self) for why
44/// this is its own type rather than a reuse of `ProtectedHeader`.
45#[derive(Debug, Deserialize)]
46pub struct EabHeader {
47    pub alg: String,
48    pub kid: String,
49    pub url: String,
50}
51
52/// Why EAB verification failed, in the two buckets [`eab_problem`] renders as
53/// HTTP status: a shape problem the client can fix by resending correctly
54/// formed EAB (`Malformed`, 400), or a signature that plainly does not verify
55/// against the looked-up secret (`BadSignature`, 401). An unknown or revoked
56/// `kid` is not represented here at all: that decision needs the database, so
57/// the caller (`verify_eab` in `lib.rs`) makes it directly.
58#[derive(Debug)]
59pub enum EabError {
60    Malformed(&'static str),
61    BadSignature,
62}
63
64/// Only supported inner MAC algorithm, per RFC 8555's own recommendation. Not
65/// configurable: a knob with exactly one safe value is not a knob.
66const SUPPORTED_ALG: &str = "HS256";
67
68/// Decodes and validates the inner EAB JWS's protected header: `alg` must be
69/// `HS256`, and `url` must equal `expected_url` -- the same URL the outer
70/// JWS's own `header.url` was already checked against (RFC 8555 §6.4), i.e.
71/// this exact `newAccount` request.
72///
73/// Returns the header so the caller can look up `kid`'s HMAC secret (a DB
74/// operation this module does not perform) before finishing verification with
75/// [`verify_payload_and_signature`].
76pub fn parse_header(eab: &EabJws, expected_url: &str) -> Result<EabHeader, EabError> {
77    let protected_bytes = BASE64_URL_SAFE_NO_PAD
78        .decode(&eab.protected)
79        .map_err(|_| EabError::Malformed("EAB protected base64 invalid"))?;
80
81    let header: EabHeader = serde_json::from_slice(&protected_bytes)
82        .map_err(|_| EabError::Malformed("EAB protected JSON invalid"))?;
83
84    if header.alg != SUPPORTED_ALG {
85        return Err(EabError::Malformed("EAB alg must be HS256"));
86    }
87    if header.url != expected_url {
88        return Err(EabError::Malformed("EAB url does not match the request"));
89    }
90
91    Ok(header)
92}
93
94/// Completes EAB verification once `kid`'s HMAC secret has been looked up:
95/// the inner payload must decode to a [`Jwk`] structurally equal to the
96/// account's own embedded JWK (`outer_jwk`), and the HS256 signature over
97/// `protected_b64.payload_b64` must verify against `hmac_secret`.
98pub fn verify_payload_and_signature(
99    eab: &EabJws,
100    hmac_secret: &[u8],
101    outer_jwk: &Jwk,
102) -> Result<(), EabError> {
103    let payload_bytes = BASE64_URL_SAFE_NO_PAD
104        .decode(&eab.payload)
105        .map_err(|_| EabError::Malformed("EAB payload base64 invalid"))?;
106    let inner_jwk: Jwk = serde_json::from_slice(&payload_bytes)
107        .map_err(|_| EabError::Malformed("EAB payload is not a JWK"))?;
108    if &inner_jwk != outer_jwk {
109        return Err(EabError::Malformed(
110            "EAB payload JWK does not match the account key",
111        ));
112    }
113
114    let sig_bytes = BASE64_URL_SAFE_NO_PAD
115        .decode(&eab.signature)
116        .map_err(|_| EabError::Malformed("EAB signature base64 invalid"))?;
117
118    let signing_input = format!("{}.{}", eab.protected, eab.payload);
119    let key = hmac::Key::new(hmac::HMAC_SHA256, hmac_secret);
120    hmac::verify(&key, signing_input.as_bytes(), &sig_bytes).map_err(|_| EabError::BadSignature)
121}
122
123/// Maps an [`EabError`] to the `Problem` the caller rejects with. Structural
124/// failures -- malformed base64/JSON, an unsupported `alg`, a `url` mismatch,
125/// or a payload JWK that does not match the account's own -- are `malformed`
126/// (400), mirroring how the outer JWS's own `SignatureError` splits shape
127/// problems from signature-validity ones. A signature that simply does not
128/// verify is `unauthorized` (401), the same as a bad outer JWS signature.
129pub fn eab_problem(error: EabError) -> Problem {
130    match error {
131        EabError::Malformed(detail) => Problem::malformed(detail),
132        EabError::BadSignature => {
133            Problem::unauthorized("External Account Binding signature invalid")
134        }
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use serde_json::json;
142
143    fn jwk_a() -> Jwk {
144        Jwk::EC {
145            crv: "P-256".to_string(),
146            x: "x-a".to_string(),
147            y: "y-a".to_string(),
148        }
149    }
150
151    fn jwk_b() -> Jwk {
152        Jwk::EC {
153            crv: "P-256".to_string(),
154            x: "x-b".to_string(),
155            y: "y-b".to_string(),
156        }
157    }
158
159    fn b64_json(value: &serde_json::Value) -> String {
160        BASE64_URL_SAFE_NO_PAD.encode(serde_json::to_vec(value).unwrap())
161    }
162
163    fn jwk_json(jwk: &Jwk) -> serde_json::Value {
164        match jwk {
165            Jwk::EC { crv, x, y } => json!({ "kty": "EC", "crv": crv, "x": x, "y": y }),
166            Jwk::RSA { n, e } => json!({ "kty": "RSA", "n": n, "e": e }),
167        }
168    }
169
170    /// Builds a well-formed EAB JWS signed with `secret`, embedding `jwk` as
171    /// its payload.
172    fn build(secret: &[u8], alg: &str, kid: &str, url: &str, jwk: &Jwk) -> EabJws {
173        let protected = json!({ "alg": alg, "kid": kid, "url": url });
174        let protected_b64 = b64_json(&protected);
175        let payload_b64 = b64_json(&jwk_json(jwk));
176        let signing_input = format!("{protected_b64}.{payload_b64}");
177        let key = hmac::Key::new(hmac::HMAC_SHA256, secret);
178        let signature = hmac::sign(&key, signing_input.as_bytes());
179        EabJws {
180            protected: protected_b64,
181            payload: payload_b64,
182            signature: BASE64_URL_SAFE_NO_PAD.encode(signature.as_ref()),
183        }
184    }
185
186    const SECRET: &[u8] = b"01234567890123456789012345678901";
187    const URL: &str = "http://localhost:3000/newAccount";
188
189    #[test]
190    fn parse_header_accepts_hs256_and_matching_url() {
191        let eab = build(SECRET, "HS256", "kid-1", URL, &jwk_a());
192        let header = parse_header(&eab, URL).unwrap();
193        assert_eq!(header.kid, "kid-1");
194    }
195
196    #[test]
197    fn parse_header_rejects_unsupported_alg() {
198        let eab = build(SECRET, "HS384", "kid-1", URL, &jwk_a());
199        assert!(matches!(
200            parse_header(&eab, URL),
201            Err(EabError::Malformed(_))
202        ));
203    }
204
205    #[test]
206    fn parse_header_rejects_url_mismatch() {
207        let eab = build(
208            SECRET,
209            "HS256",
210            "kid-1",
211            "http://localhost:3000/other",
212            &jwk_a(),
213        );
214        assert!(matches!(
215            parse_header(&eab, URL),
216            Err(EabError::Malformed(_))
217        ));
218    }
219
220    #[test]
221    fn parse_header_rejects_malformed_base64_and_json() {
222        let mut eab = build(SECRET, "HS256", "kid-1", URL, &jwk_a());
223        eab.protected = "!!!not-base64!!!".to_string();
224        assert!(matches!(
225            parse_header(&eab, URL),
226            Err(EabError::Malformed(_))
227        ));
228
229        let mut eab = build(SECRET, "HS256", "kid-1", URL, &jwk_a());
230        eab.protected = BASE64_URL_SAFE_NO_PAD.encode(b"not json");
231        assert!(matches!(
232            parse_header(&eab, URL),
233            Err(EabError::Malformed(_))
234        ));
235    }
236
237    /// A client-sent `nonce` in the inner header is silently ignored, since
238    /// `EabHeader` has no such field.
239    #[test]
240    fn parse_header_ignores_an_extra_nonce_field() {
241        let protected = json!({ "alg": "HS256", "kid": "kid-1", "url": URL, "nonce": "n" });
242        let protected_b64 = b64_json(&protected);
243        let eab = EabJws {
244            protected: protected_b64,
245            payload: String::new(),
246            signature: String::new(),
247        };
248        assert!(parse_header(&eab, URL).is_ok());
249    }
250
251    #[test]
252    fn verify_payload_and_signature_accepts_correct_hmac() {
253        let eab = build(SECRET, "HS256", "kid-1", URL, &jwk_a());
254        assert!(verify_payload_and_signature(&eab, SECRET, &jwk_a()).is_ok());
255    }
256
257    #[test]
258    fn verify_payload_and_signature_rejects_wrong_secret() {
259        let eab = build(SECRET, "HS256", "kid-1", URL, &jwk_a());
260        assert!(matches!(
261            verify_payload_and_signature(&eab, b"a-completely-different-secret!!", &jwk_a()),
262            Err(EabError::BadSignature)
263        ));
264    }
265
266    #[test]
267    fn verify_payload_and_signature_rejects_a_tampered_signature() {
268        let mut eab = build(SECRET, "HS256", "kid-1", URL, &jwk_a());
269        eab.signature = BASE64_URL_SAFE_NO_PAD.encode([0u8; 32]);
270        assert!(matches!(
271            verify_payload_and_signature(&eab, SECRET, &jwk_a()),
272            Err(EabError::BadSignature)
273        ));
274    }
275
276    #[test]
277    fn verify_payload_and_signature_rejects_a_jwk_payload_mismatch() {
278        // Signed correctly, but the account key it names differs from the
279        // outer JWS's own key -- must be rejected as `Malformed`, checked
280        // *before* HMAC verification would even matter.
281        let eab = build(SECRET, "HS256", "kid-1", URL, &jwk_a());
282        assert!(matches!(
283            verify_payload_and_signature(&eab, SECRET, &jwk_b()),
284            Err(EabError::Malformed(_))
285        ));
286    }
287
288    #[test]
289    fn verify_payload_and_signature_rejects_malformed_payload_json() {
290        let mut eab = build(SECRET, "HS256", "kid-1", URL, &jwk_a());
291        eab.payload = BASE64_URL_SAFE_NO_PAD.encode(b"not a jwk");
292        assert!(matches!(
293            verify_payload_and_signature(&eab, SECRET, &jwk_a()),
294            Err(EabError::Malformed(_))
295        ));
296    }
297}