Skip to main content

acme_proxy_protocol/extractors/
acme.rs

1//! `verify_jws`, the checks every signed ACME request passes before a handler
2//! runs, and the three extractors built on it.
3//!
4//! The order is load-bearing:
5//!
6//! 1. **`Content-Type: application/jose+json`** (RFC 8555 §6.2), checked before
7//!    the body is read — a `415` never burns a nonce.
8//! 2. The flattened JWS and its protected header are decoded, and **any `crit`
9//!    is refused** (RFC 7515 §4.1.11): this server implements no critical
10//!    extension, so every value is unrecognised.
11//! 3. The JWS **`url`** against the route actually reached (§6.4).
12//! 4. Exactly one of **`jwk`** or **`kid`** (§6.2; both or neither is
13//!    `malformed`), and a `jwk` only on `newAccount` and `revokeCert` — §6.2
14//!    requires every other request to name an existing account with `kid`.
15//!    A `kid` is verified against the account's **stored** SPKI,
16//!    after that SPKI's own algorithm OID is checked against the claimed `alg`,
17//!    so verification never rests on `alg` alone. An unknown `kid` is
18//!    `accountDoesNotExist`.
19//! 5. The **signature**, with `ring` (ES256 or RS256). EC coordinates must be
20//!    exactly 32 octets (RFC 7518 §6.2.1.2); a short or long one parses as a
21//!    different point, which would register one key as two accounts.
22//! 6. The **nonce** is consumed (§6.5).
23//!
24//! The account's `last_seen_*` stamp is written here too, as the one place every
25//! `kid` request funnels through, and only **after** the nonce, so a replayed
26//! request never moves it. `SignatureError` maps to `malformed`,
27//! `badSignatureAlgorithm` (carrying the `algorithms` list §6.2 requires),
28//! `unauthorized` or a `500`.
29//!
30//! This file carries `#[instrument]` and so reports little coverage of its own;
31//! its branches are driven by `tests/jws_rejections.rs` and
32//! `tests/db_failures.rs`.
33
34use std::time::Duration;
35
36use axum::extract::{FromRef, FromRequest, Request};
37use axum::http::StatusCode;
38use base64::prelude::*;
39use serde::de::DeserializeOwned;
40use tracing::{Span, debug, error, instrument, warn};
41
42use crate::router::AppState;
43use acme_proxy_core::error::Problem;
44use acme_proxy_store::account::Account;
45use acme_proxy_store::nonce::Nonce;
46
47use acme_proxy_core::jws::AcmeJwsRequest;
48use acme_proxy_core::jws::ProtectedHeader;
49use acme_proxy_core::jws::signature::SignatureError;
50use acme_proxy_core::jws::signature::verify_signature_and_get_der;
51use acme_proxy_core::jws::signature::verify_signature_with_spki;
52
53/// ACME request wrapper containing validated JWS data.
54pub struct AcmeRequest<T> {
55    pub header: ProtectedHeader,
56    pub payload: T,
57    pub pubkey: Vec<u8>,
58    pub account: Option<Account>,
59}
60
61/// The media type RFC 8555 §6.2 requires on every ACME request body.
62const JOSE_JSON: &str = "application/jose+json";
63
64/// Whether a `Content-Type` header value names [`JOSE_JSON`].
65///
66/// Compares the *essence* only: parameters (`; charset=utf-8`) are tolerated,
67/// since a client is free to send them, and the type/subtype are matched
68/// case-insensitively per RFC 9110 §8.3.1. A missing header is not a match —
69/// §6.2 requires the type to be present, not merely not-contradicted.
70fn is_jose_json(content_type: Option<&str>) -> bool {
71    content_type.is_some_and(|value| {
72        let essence = value.split(';').next().unwrap_or_default().trim();
73        essence.eq_ignore_ascii_case(JOSE_JSON)
74    })
75}
76
77/// Parses a flattened JWS request, verifies its signature, and returns the
78/// verified `(header, pubkey, account, payload_b64)`.
79// `alg` and `account_id` are declared empty and filled in below, once the
80// protected header has parsed and the `kid` has resolved. Without the
81// declaration `Span::current().record(…)` writes to a field this span does not
82// have and is silently dropped — which is what used to happen, leaving a
83// `kid`-authenticated request unattributable to any account.
84#[instrument(
85    name = "verify_jws",
86    skip_all,
87    fields(
88        path = %req.uri().path(),
89        alg = tracing::field::Empty,
90        account_id = tracing::field::Empty,
91    )
92)]
93async fn verify_jws<S>(
94    req: Request,
95    state: &S,
96) -> Result<(ProtectedHeader, Vec<u8>, Option<Account>, String), Problem>
97where
98    S: Send + Sync,
99    AppState: FromRef<S>,
100{
101    let app = AppState::from_ref(state);
102    let request_path = req.uri().path().to_string();
103    // Read before the body is consumed below, and kept for the `last_seen_*`
104    // stamp at the very end of this function.
105    let request_context = acme_proxy_core::audit::RequestContext::from_request(&req);
106    debug!(event = "jws_request_received", outcome = "progress", path = %request_path);
107
108    // RFC 8555 §6.2: an ACME request body is a flattened JWS and so "must have
109    // the Content-Type header field set to `application/jose+json`. If a
110    // request does not meet this requirement, then the server MUST return a
111    // response with status code 415". Checking it here rather than in each
112    // handler is the same reasoning as the `url` and nonce checks below: a new
113    // signed route cannot forget it.
114    let content_type = req
115        .headers()
116        .get(axum::http::header::CONTENT_TYPE)
117        .and_then(|value| value.to_str().ok());
118    if !is_jose_json(content_type) {
119        warn!(event = "jws_bad_content_type", outcome = "failure", content_type = ?content_type, path = %request_path);
120        return Err(Problem::unsupported_media_type(
121            "Content-Type must be application/jose+json",
122        ));
123    }
124
125    let body_str = String::from_request(req, state)
126        .await
127        .map_err(|rejection| {
128            // A body over `server.max_body_bytes` is not a malformed JWS — it is a
129            // request the server declined to read at all, and a client told "your
130            // JWS is malformed" would rebuild a JWS that is not the problem. axum
131            // reports the limit through the rejection's own status.
132            if rejection.status() == StatusCode::PAYLOAD_TOO_LARGE {
133                warn!(event = "jws_body_too_large", outcome = "failure", path = %request_path);
134                return Problem::payload_too_large("Request body exceeds the configured limit");
135            }
136            warn!(event = "jws_body_read_failed", outcome = "failure", path = %request_path);
137            Problem::malformed("Cannot read HTTP Body")
138        })?;
139
140    let jws: AcmeJwsRequest = serde_json::from_str(&body_str).map_err(|_| {
141        warn!(event = "jws_json_parse_failed", outcome = "failure", body_bytes = body_str.len(), path = %request_path);
142        Problem::malformed("JSON JWS format invalid")
143    })?;
144
145    let protected_bytes = BASE64_URL_SAFE_NO_PAD.decode(&jws.protected).map_err(|_| {
146        warn!(event = "jws_protected_decode_failed", outcome = "failure", protected_b64_chars = jws.protected.len(), path = %request_path);
147        Problem::malformed("Base64 protected invalid")
148    })?;
149
150    let header: ProtectedHeader = serde_json::from_slice(&protected_bytes).map_err(|_| {
151        warn!(event = "jws_header_parse_failed", outcome = "failure", protected_bytes = protected_bytes.len(), path = %request_path);
152        Problem::malformed("JSON protected invalid")
153    })?;
154
155    Span::current().record("alg", header.alg.as_str());
156
157    // RFC 7515 §4.1.11: a recipient that does not understand every extension
158    // named in `crit` MUST reject the JWS. This server understands none, so
159    // any `crit` at all — even an empty array, which §4.1.11 also forbids — is
160    // a rejection.
161    if let Some(crit) = &header.crit {
162        warn!(event = "jws_crit_header_present", outcome = "failure", extensions = ?crit, url = %header.url);
163        return Err(Problem::malformed(
164            "Unsupported critical JWS header extension",
165        ));
166    }
167
168    // §6.4, checked here rather than after the signature: it needs nothing but
169    // the header, and doing it first means a JWS not addressed to this endpoint
170    // never reaches the database at all. The signature still has to verify
171    // before anything is *acted* on — this only reorders two refusals.
172    //
173    // `request_path` is what the profile's own router saw, i.e. with the
174    // `/profile/<name>` mount point already stripped by `Router::nest` — so
175    // pairing it with the profile's base URL reconstructs exactly the URL the
176    // client was given, and a JWS signed for another endpoint fails here.
177    let expected_url = format!("{}{request_path}", app.profile.base_url);
178    if header.url != expected_url {
179        warn!(event = "jws_url_mismatch", outcome = "failure", url = %header.url, expected = %expected_url);
180        return Err(Problem::malformed("URL invalid"));
181    }
182
183    let signing_input = format!("{}.{}", jws.protected, jws.payload);
184
185    let (pubkey, mut signer_account) =
186        resolve_signing_key(&app, &header, &request_path, &signing_input, &jws.signature).await?;
187    consume_nonce(&app, &header, &request_path).await?;
188
189    // The account's `last_seen_*` stamp, here rather than in a handler because
190    // this is the one place every `kid`-authenticated request funnels through —
191    // the same reasoning that hoisted the media-type, `crit`, `url` and nonce
192    // checks into this function. After the nonce check, so a replayed or
193    // expired request never moves the mark: "last used" has to mean a request
194    // the server actually accepted.
195    if let Some(account) = signer_account.as_mut() {
196        touch_account(account, &request_context, &app).await;
197    }
198
199    debug!(
200        event = "jws_request_validated",
201        outcome = "success",
202        algorithm = %header.alg,
203        url = %header.url,
204        signature_type = match (&header.jwk, &header.kid) {
205            (Some(_), None) => "jwk",
206            (None, Some(_)) => "kid",
207            _ => "unknown",
208        },
209        signature_b64_chars = jws.signature.len()
210    );
211
212    Ok((header, pubkey, signer_account, jws.payload))
213}
214
215/// The key the JWS was signed with, verified: an embedded `jwk` where §6.2
216/// allows one, otherwise the key of the account `kid` names — and that account.
217///
218/// Called by [`verify_jws`] after the header checks and before the nonce, so a
219/// request refused here never spends one. Inside its span: the `account_id`
220/// field is recorded here.
221async fn resolve_signing_key(
222    app: &AppState,
223    header: &ProtectedHeader,
224    request_path: &str,
225    signing_input: &str,
226    signature: &str,
227) -> Result<(Vec<u8>, Option<Account>), Problem> {
228    let mut signer_account: Option<Account> = None;
229    let pubkey = match (&header.jwk, &header.kid) {
230        (Some(_), Some(_)) => {
231            warn!(event = "jws_jwk_and_kid_both_present", outcome = "failure", url = %header.url, algorithm = %header.alg);
232            return Err(Problem::malformed("jwk and kid are mutually exclusive"));
233        }
234        (Some(_), None) => {
235            // §6.2: "For all other requests, the request is signed using an
236            // existing account, and there MUST be a `kid` field." Only
237            // newAccount, where no account exists yet, and revokeCert, which
238            // §7.6 also lets a certificate's own key sign, may carry a `jwk`.
239            // Everywhere else an embedded key would have the server find the
240            // account by public key — the same account a `kid` names, reached
241            // by a path the RFC does not define.
242            // The two unauthenticated resources a client may also POST-as-GET
243            // (§7.1, §7.2) are here too: neither names an account, and
244            // requiring one to read the directory would leave a client unable
245            // to find `newAccount` without already having an account.
246            if !matches!(
247                request_path,
248                acme_proxy_core::routes::NEW_ACCOUNT
249                    | acme_proxy_core::routes::REVOKE_CERT
250                    | acme_proxy_core::routes::DIRECTORY
251                    | acme_proxy_core::routes::NEW_NONCE
252            ) {
253                warn!(event = "jws_jwk_not_allowed_here", outcome = "failure", url = %header.url, path = %request_path);
254                return Err(Problem::malformed(
255                    "This request must be signed with kid, not an embedded jwk",
256                ));
257            }
258            debug!(event = "jws_jwk_verification_started", outcome = "progress", algorithm = %header.alg, url = %header.url);
259            verify_signature_and_get_der(header, signing_input, signature)
260                .map_err(map_signature_error)?
261        }
262        (None, Some(kid)) => {
263            debug!(event = "jws_kid_verification_started", outcome = "progress", algorithm = %header.alg, kid = %kid);
264            // The account URL is the one *this* endpoint minted, prefix and
265            // all: a `kid` naming another profile does not match here, and the
266            // lookup below is scoped to this profile besides.
267            let base = &app.profile.base_url;
268            let id = kid
269                .strip_prefix(&format!("{base}/acct/"))
270                .ok_or_else(|| {
271                    warn!(event = "jws_kid_prefix_mismatch", outcome = "failure", kid = %kid, expected_prefix = %format!("{base}/acct/"));
272                    Problem::malformed("kid invalid")
273                })?;
274
275            Span::current().record("account_id", id);
276
277            let account = Account::find_by_id(&app.profile.name, id, &app.database)
278                .await
279                .map_err(|error| {
280                    error!(event = "jws_kid_account_lookup_failed", outcome = "failure", account_id = %id, error = %error);
281                    Problem::server_internal("Account lookup failed")
282                })?
283                .ok_or_else(|| Problem::account_does_not_exist("Unknown account"))?;
284
285            verify_signature_with_spki(&header.alg, &account.pubkey, signing_input, signature)
286                .map_err(map_signature_error)?;
287
288            let pubkey = account.pubkey.clone();
289            signer_account = Some(account);
290            pubkey
291        }
292        (None, None) => {
293            warn!(event = "jws_jwk_and_kid_missing", outcome = "failure", url = %header.url, algorithm = %header.alg);
294            return Err(Problem::malformed("missing jwk or kid"));
295        }
296    };
297    Ok((pubkey, signer_account))
298}
299
300/// Consumes the request's nonce (§6.5): one atomic delete, so a replay finds
301/// nothing. After the signature, so an unauthenticated request never spends a
302/// nonce it did not own.
303async fn consume_nonce(
304    app: &AppState,
305    header: &ProtectedHeader,
306    request_path: &str,
307) -> Result<(), Problem> {
308    let ttl = Duration::from_secs(app.config.nonce.ttl_seconds);
309    match Nonce::verify(&header.nonce, &app.database, ttl).await {
310        Ok(true) => Ok(()),
311        Ok(false) => {
312            // Unknown, already consumed or past `nonce.ttl_seconds` — the three
313            // are indistinguishable by design, since a consumed nonce is
314            // deleted. This is the anti-replay refusal, so it is worth a line:
315            // a client stuck replaying is a client that will never issue.
316            warn!(
317                event = "nonce_replayed",
318                outcome = "failure",
319                nonce_fp = %acme_proxy_store::nonce::fingerprint(&header.nonce),
320                path = %request_path
321            );
322            Err(Problem::bad_nonce("Nonce invalid"))
323        }
324        Err(error) => {
325            // The nonce itself never reaches a log: until it is consumed it is
326            // a bearer credential, and this arm is the one where it was *not*
327            // consumed. See `Nonce::fingerprint`.
328            error!(
329                event = "nonce_verification_failed",
330                outcome = "failure",
331                nonce_fp = %acme_proxy_store::nonce::fingerprint(&header.nonce),
332                error = %error
333            );
334            Err(Problem::server_internal("Nonce verification failed"))
335        }
336    }
337}
338
339/// Advances `account.last_seen_*`, if the throttle says it is worth a write.
340///
341/// Three deliberate properties:
342///
343/// - **The address is decided before the lookup.** `needs_touch` takes the
344///   canonicalized address and nothing else, so a request that will be
345///   throttled never pays for a PTR query — which is most of them.
346/// - **A failure is a warning, not a rejection.** These columns are
347///   traceability; a client whose issuance failed because the server could not
348///   write down where it came from would be a worse server, not a safer one.
349/// - **Canonicalized first**, like every other address this crate stores: the
350///   dual-stack `[::]:3000` bind sees an IPv4 client as `::ffff:…`, and the same
351///   client arriving over IPv4 and IPv6 must not read as two addresses.
352async fn touch_account(
353    account: &mut Account,
354    request: &acme_proxy_core::audit::RequestContext,
355    app: &AppState,
356) {
357    let ip = request
358        .ip
359        .map(acme_proxy_core::client::canonical)
360        .map(|ip| ip.to_string());
361    if !account.needs_touch(acme_proxy_store::nonce::now_secs(), ip.as_deref()) {
362        return;
363    }
364    let client = app.audit.client(request).await;
365    if let Err(error) = account.touch(&client, &app.database).await {
366        warn!(
367            event = "account_touch_failed",
368            outcome = "failure",
369            account_id = %account.id,
370            error = %error
371        );
372    }
373}
374
375/// The JWS payload, decoded and parsed as `T`.
376///
377/// One definition for the two extractors that take a payload: they differ in
378/// whether an empty one is allowed, not in how a present one is read, and two
379/// copies would let the refusals drift apart — a client reading `malformed`
380/// would then be told different things by two endpoints about the same
381/// mistake.
382fn decode_payload<T: DeserializeOwned>(payload_b64: &str) -> Result<T, Problem> {
383    let payload_bytes = BASE64_URL_SAFE_NO_PAD.decode(payload_b64).map_err(|_| {
384        warn!(
385            event = "jws_payload_decode_failed",
386            outcome = "failure",
387            payload_b64_chars = payload_b64.len()
388        );
389        Problem::malformed("Base64 payload invalid")
390    })?;
391
392    serde_json::from_slice(&payload_bytes).map_err(|_| {
393        warn!(
394            event = "jws_payload_parse_failed",
395            outcome = "failure",
396            payload_bytes = payload_bytes.len()
397        );
398        Problem::malformed("Payload JSON invalid for this endpoint")
399    })
400}
401
402impl<S, T> FromRequest<S> for AcmeRequest<T>
403where
404    S: Send + Sync,
405    AppState: FromRef<S>,
406    T: DeserializeOwned,
407{
408    type Rejection = Problem;
409
410    async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
411        let (header, pubkey, account, payload_b64) = verify_jws(req, state).await?;
412
413        let payload: T = decode_payload(&payload_b64)?;
414
415        Ok(AcmeRequest {
416            header,
417            payload,
418            pubkey,
419            account,
420        })
421    }
422}
423
424/// A verified ACME **POST-as-GET** request (RFC 8555 §6.3).
425pub struct AcmePostAsGet {
426    pub header: ProtectedHeader,
427    pub pubkey: Vec<u8>,
428    pub account: Option<Account>,
429}
430
431impl<S> FromRequest<S> for AcmePostAsGet
432where
433    S: Send + Sync,
434    AppState: FromRef<S>,
435{
436    type Rejection = Problem;
437
438    async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
439        let (header, pubkey, account, payload_b64) = verify_jws(req, state).await?;
440
441        if !payload_b64.is_empty() {
442            warn!(
443                event = "jws_post_as_get_payload_not_empty",
444                outcome = "failure",
445                payload_b64_chars = payload_b64.len(),
446                url = %header.url
447            );
448            return Err(Problem::malformed("POST-as-GET payload must be empty"));
449        }
450
451        Ok(AcmePostAsGet {
452            header,
453            pubkey,
454            account,
455        })
456    }
457}
458
459/// A verified ACME POST whose payload may be **either** a document or empty.
460///
461/// The shape RFC 8555 forces on two resources, where one URL answers both a
462/// POST-as-GET (§6.3, read the object) and a payload-carrying POST: the
463/// authorization's `{"status": "deactivated"}` (§7.5.2, relinquish it) and the
464/// challenge's `{}` (§7.5.1, trigger its validation). [`AcmeRequest<T>`] cannot
465/// serve either — an empty payload is not valid JSON for any `T` — and
466/// [`AcmePostAsGet`] rejects the payload outright, so the handler needs to see
467/// which of the two arrived. Both handlers read `None` as "do nothing but
468/// answer"; a change to how a payload is told apart changes both.
469pub struct AcmeOptionalPayload<T> {
470    pub header: ProtectedHeader,
471    /// `None` for a POST-as-GET, `Some` for a payload-carrying POST.
472    pub payload: Option<T>,
473    pub pubkey: Vec<u8>,
474    pub account: Option<Account>,
475}
476
477impl<S, T> FromRequest<S> for AcmeOptionalPayload<T>
478where
479    S: Send + Sync,
480    AppState: FromRef<S>,
481    T: DeserializeOwned,
482{
483    type Rejection = Problem;
484
485    async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
486        let (header, pubkey, account, payload_b64) = verify_jws(req, state).await?;
487
488        let payload = match payload_b64.is_empty() {
489            true => None,
490            false => Some(decode_payload(&payload_b64)?),
491        };
492
493        Ok(AcmeOptionalPayload {
494            header,
495            payload,
496            pubkey,
497            account,
498        })
499    }
500}
501
502/// Maps a `SignatureError` to the `Problem` the extractor rejects with.
503fn map_signature_error(error: SignatureError) -> Problem {
504    match error {
505        SignatureError::Malformed(detail) => {
506            warn!(event = "jws_signature_malformed", outcome = "failure", detail = %detail);
507            Problem::malformed(detail)
508        }
509        SignatureError::BadAlgorithm(detail) => {
510            warn!(event = "jws_signature_algorithm_unsupported", outcome = "failure", detail = %detail);
511            Problem::bad_signature_algorithm(detail)
512        }
513        SignatureError::BadSignature(_) => {
514            warn!(
515                event = "jws_signature_verification_failed",
516                outcome = "failure"
517            );
518            Problem::unauthorized("Signature JWS invalid")
519        }
520        // The detail describes this server's own key handling — which `ring`
521        // call refused what shape — so it goes to the operator's log, not to
522        // the client that could only learn about the internals from it.
523        SignatureError::Encoding(detail) => {
524            error!(event = "jws_signature_encoding_failed", outcome = "failure", detail = %detail);
525            Problem::server_internal("Signature could not be verified")
526        }
527    }
528}