acme-proxy-protocol 0.6.0

The ACME (RFC 8555) services, extractors, handlers and routers of acme-proxy (internal crate, no semver promise)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
//! `verify_jws`, the checks every signed ACME request passes before a handler
//! runs, and the three extractors built on it.
//!
//! The order is load-bearing:
//!
//! 1. **`Content-Type: application/jose+json`** (RFC 8555 §6.2), checked before
//!    the body is read — a `415` never burns a nonce.
//! 2. The flattened JWS and its protected header are decoded, and **any `crit`
//!    is refused** (RFC 7515 §4.1.11): this server implements no critical
//!    extension, so every value is unrecognised.
//! 3. The JWS **`url`** against the route actually reached (§6.4).
//! 4. Exactly one of **`jwk`** or **`kid`** (§6.2; both or neither is
//!    `malformed`), and a `jwk` only on `newAccount` and `revokeCert` — §6.2
//!    requires every other request to name an existing account with `kid`.
//!    A `kid` is verified against the account's **stored** SPKI,
//!    after that SPKI's own algorithm OID is checked against the claimed `alg`,
//!    so verification never rests on `alg` alone. An unknown `kid` is
//!    `accountDoesNotExist`.
//! 5. The **signature**, with `ring` (ES256 or RS256). EC coordinates must be
//!    exactly 32 octets (RFC 7518 §6.2.1.2); a short or long one parses as a
//!    different point, which would register one key as two accounts.
//! 6. The **nonce** is consumed (§6.5).
//!
//! The account's `last_seen_*` stamp is written here too, as the one place every
//! `kid` request funnels through, and only **after** the nonce, so a replayed
//! request never moves it. `SignatureError` maps to `malformed`,
//! `badSignatureAlgorithm` (carrying the `algorithms` list §6.2 requires),
//! `unauthorized` or a `500`.
//!
//! This file carries `#[instrument]` and so reports little coverage of its own;
//! its branches are driven by `tests/jws_rejections.rs` and
//! `tests/db_failures.rs`.

use std::time::Duration;

use axum::extract::{FromRef, FromRequest, Request};
use axum::http::StatusCode;
use base64::prelude::*;
use serde::de::DeserializeOwned;
use tracing::{Span, debug, error, instrument, warn};

use crate::router::AppState;
use acme_proxy_core::error::Problem;
use acme_proxy_store::account::Account;
use acme_proxy_store::nonce::Nonce;

use acme_proxy_core::jws::AcmeJwsRequest;
use acme_proxy_core::jws::ProtectedHeader;
use acme_proxy_core::jws::signature::SignatureError;
use acme_proxy_core::jws::signature::verify_signature_and_get_der;
use acme_proxy_core::jws::signature::verify_signature_with_spki;

/// ACME request wrapper containing validated JWS data.
pub struct AcmeRequest<T> {
    pub header: ProtectedHeader,
    pub payload: T,
    pub pubkey: Vec<u8>,
    pub account: Option<Account>,
}

/// The media type RFC 8555 §6.2 requires on every ACME request body.
const JOSE_JSON: &str = "application/jose+json";

/// Whether a `Content-Type` header value names [`JOSE_JSON`].
///
/// Compares the *essence* only: parameters (`; charset=utf-8`) are tolerated,
/// since a client is free to send them, and the type/subtype are matched
/// case-insensitively per RFC 9110 §8.3.1. A missing header is not a match —
/// §6.2 requires the type to be present, not merely not-contradicted.
fn is_jose_json(content_type: Option<&str>) -> bool {
    content_type.is_some_and(|value| {
        let essence = value.split(';').next().unwrap_or_default().trim();
        essence.eq_ignore_ascii_case(JOSE_JSON)
    })
}

/// Parses a flattened JWS request, verifies its signature, and returns the
/// verified `(header, pubkey, account, payload_b64)`.
// `alg` and `account_id` are declared empty and filled in below, once the
// protected header has parsed and the `kid` has resolved. Without the
// declaration `Span::current().record(…)` writes to a field this span does not
// have and is silently dropped — which is what used to happen, leaving a
// `kid`-authenticated request unattributable to any account.
#[instrument(
    name = "verify_jws",
    skip_all,
    fields(
        path = %req.uri().path(),
        alg = tracing::field::Empty,
        account_id = tracing::field::Empty,
    )
)]
async fn verify_jws<S>(
    req: Request,
    state: &S,
) -> Result<(ProtectedHeader, Vec<u8>, Option<Account>, String), Problem>
where
    S: Send + Sync,
    AppState: FromRef<S>,
{
    let app = AppState::from_ref(state);
    let request_path = req.uri().path().to_string();
    // Read before the body is consumed below, and kept for the `last_seen_*`
    // stamp at the very end of this function.
    let request_context = acme_proxy_core::audit::RequestContext::from_request(&req);
    debug!(event = "jws_request_received", outcome = "progress", path = %request_path);

    // RFC 8555 §6.2: an ACME request body is a flattened JWS and so "must have
    // the Content-Type header field set to `application/jose+json`. If a
    // request does not meet this requirement, then the server MUST return a
    // response with status code 415". Checking it here rather than in each
    // handler is the same reasoning as the `url` and nonce checks below: a new
    // signed route cannot forget it.
    let content_type = req
        .headers()
        .get(axum::http::header::CONTENT_TYPE)
        .and_then(|value| value.to_str().ok());
    if !is_jose_json(content_type) {
        warn!(event = "jws_bad_content_type", outcome = "failure", content_type = ?content_type, path = %request_path);
        return Err(Problem::unsupported_media_type(
            "Content-Type must be application/jose+json",
        ));
    }

    let body_str = String::from_request(req, state)
        .await
        .map_err(|rejection| {
            // A body over `server.max_body_bytes` is not a malformed JWS — it is a
            // request the server declined to read at all, and a client told "your
            // JWS is malformed" would rebuild a JWS that is not the problem. axum
            // reports the limit through the rejection's own status.
            if rejection.status() == StatusCode::PAYLOAD_TOO_LARGE {
                warn!(event = "jws_body_too_large", outcome = "failure", path = %request_path);
                return Problem::payload_too_large("Request body exceeds the configured limit");
            }
            warn!(event = "jws_body_read_failed", outcome = "failure", path = %request_path);
            Problem::malformed("Cannot read HTTP Body")
        })?;

    let jws: AcmeJwsRequest = serde_json::from_str(&body_str).map_err(|_| {
        warn!(event = "jws_json_parse_failed", outcome = "failure", body_bytes = body_str.len(), path = %request_path);
        Problem::malformed("JSON JWS format invalid")
    })?;

    let protected_bytes = BASE64_URL_SAFE_NO_PAD.decode(&jws.protected).map_err(|_| {
        warn!(event = "jws_protected_decode_failed", outcome = "failure", protected_b64_chars = jws.protected.len(), path = %request_path);
        Problem::malformed("Base64 protected invalid")
    })?;

    let header: ProtectedHeader = serde_json::from_slice(&protected_bytes).map_err(|_| {
        warn!(event = "jws_header_parse_failed", outcome = "failure", protected_bytes = protected_bytes.len(), path = %request_path);
        Problem::malformed("JSON protected invalid")
    })?;

    Span::current().record("alg", header.alg.as_str());

    // RFC 7515 §4.1.11: a recipient that does not understand every extension
    // named in `crit` MUST reject the JWS. This server understands none, so
    // any `crit` at all — even an empty array, which §4.1.11 also forbids — is
    // a rejection.
    if let Some(crit) = &header.crit {
        warn!(event = "jws_crit_header_present", outcome = "failure", extensions = ?crit, url = %header.url);
        return Err(Problem::malformed(
            "Unsupported critical JWS header extension",
        ));
    }

    // §6.4, checked here rather than after the signature: it needs nothing but
    // the header, and doing it first means a JWS not addressed to this endpoint
    // never reaches the database at all. The signature still has to verify
    // before anything is *acted* on — this only reorders two refusals.
    //
    // `request_path` is what the profile's own router saw, i.e. with the
    // `/profile/<name>` mount point already stripped by `Router::nest` — so
    // pairing it with the profile's base URL reconstructs exactly the URL the
    // client was given, and a JWS signed for another endpoint fails here.
    let expected_url = format!("{}{request_path}", app.profile.base_url);
    if header.url != expected_url {
        warn!(event = "jws_url_mismatch", outcome = "failure", url = %header.url, expected = %expected_url);
        return Err(Problem::malformed("URL invalid"));
    }

    let signing_input = format!("{}.{}", jws.protected, jws.payload);

    let (pubkey, mut signer_account) =
        resolve_signing_key(&app, &header, &request_path, &signing_input, &jws.signature).await?;
    consume_nonce(&app, &header, &request_path).await?;

    // The account's `last_seen_*` stamp, here rather than in a handler because
    // this is the one place every `kid`-authenticated request funnels through —
    // the same reasoning that hoisted the media-type, `crit`, `url` and nonce
    // checks into this function. After the nonce check, so a replayed or
    // expired request never moves the mark: "last used" has to mean a request
    // the server actually accepted.
    if let Some(account) = signer_account.as_mut() {
        touch_account(account, &request_context, &app).await;
    }

    debug!(
        event = "jws_request_validated",
        outcome = "success",
        algorithm = %header.alg,
        url = %header.url,
        signature_type = match (&header.jwk, &header.kid) {
            (Some(_), None) => "jwk",
            (None, Some(_)) => "kid",
            _ => "unknown",
        },
        signature_b64_chars = jws.signature.len()
    );

    Ok((header, pubkey, signer_account, jws.payload))
}

/// The key the JWS was signed with, verified: an embedded `jwk` where §6.2
/// allows one, otherwise the key of the account `kid` names — and that account.
///
/// Called by [`verify_jws`] after the header checks and before the nonce, so a
/// request refused here never spends one. Inside its span: the `account_id`
/// field is recorded here.
async fn resolve_signing_key(
    app: &AppState,
    header: &ProtectedHeader,
    request_path: &str,
    signing_input: &str,
    signature: &str,
) -> Result<(Vec<u8>, Option<Account>), Problem> {
    let mut signer_account: Option<Account> = None;
    let pubkey = match (&header.jwk, &header.kid) {
        (Some(_), Some(_)) => {
            warn!(event = "jws_jwk_and_kid_both_present", outcome = "failure", url = %header.url, algorithm = %header.alg);
            return Err(Problem::malformed("jwk and kid are mutually exclusive"));
        }
        (Some(_), None) => {
            // §6.2: "For all other requests, the request is signed using an
            // existing account, and there MUST be a `kid` field." Only
            // newAccount, where no account exists yet, and revokeCert, which
            // §7.6 also lets a certificate's own key sign, may carry a `jwk`.
            // Everywhere else an embedded key would have the server find the
            // account by public key — the same account a `kid` names, reached
            // by a path the RFC does not define.
            // The two unauthenticated resources a client may also POST-as-GET
            // (§7.1, §7.2) are here too: neither names an account, and
            // requiring one to read the directory would leave a client unable
            // to find `newAccount` without already having an account.
            if !matches!(
                request_path,
                acme_proxy_core::routes::NEW_ACCOUNT
                    | acme_proxy_core::routes::REVOKE_CERT
                    | acme_proxy_core::routes::DIRECTORY
                    | acme_proxy_core::routes::NEW_NONCE
            ) {
                warn!(event = "jws_jwk_not_allowed_here", outcome = "failure", url = %header.url, path = %request_path);
                return Err(Problem::malformed(
                    "This request must be signed with kid, not an embedded jwk",
                ));
            }
            debug!(event = "jws_jwk_verification_started", outcome = "progress", algorithm = %header.alg, url = %header.url);
            verify_signature_and_get_der(header, signing_input, signature)
                .map_err(map_signature_error)?
        }
        (None, Some(kid)) => {
            debug!(event = "jws_kid_verification_started", outcome = "progress", algorithm = %header.alg, kid = %kid);
            // The account URL is the one *this* endpoint minted, prefix and
            // all: a `kid` naming another profile does not match here, and the
            // lookup below is scoped to this profile besides.
            let base = &app.profile.base_url;
            let id = kid
                .strip_prefix(&format!("{base}/acct/"))
                .ok_or_else(|| {
                    warn!(event = "jws_kid_prefix_mismatch", outcome = "failure", kid = %kid, expected_prefix = %format!("{base}/acct/"));
                    Problem::malformed("kid invalid")
                })?;

            Span::current().record("account_id", id);

            let account = Account::find_by_id(&app.profile.name, id, &app.database)
                .await
                .map_err(|error| {
                    error!(event = "jws_kid_account_lookup_failed", outcome = "failure", account_id = %id, error = %error);
                    Problem::server_internal("Account lookup failed")
                })?
                .ok_or_else(|| Problem::account_does_not_exist("Unknown account"))?;

            verify_signature_with_spki(&header.alg, &account.pubkey, signing_input, signature)
                .map_err(map_signature_error)?;

            let pubkey = account.pubkey.clone();
            signer_account = Some(account);
            pubkey
        }
        (None, None) => {
            warn!(event = "jws_jwk_and_kid_missing", outcome = "failure", url = %header.url, algorithm = %header.alg);
            return Err(Problem::malformed("missing jwk or kid"));
        }
    };
    Ok((pubkey, signer_account))
}

/// Consumes the request's nonce (§6.5): one atomic delete, so a replay finds
/// nothing. After the signature, so an unauthenticated request never spends a
/// nonce it did not own.
async fn consume_nonce(
    app: &AppState,
    header: &ProtectedHeader,
    request_path: &str,
) -> Result<(), Problem> {
    let ttl = Duration::from_secs(app.config.nonce.ttl_seconds);
    match Nonce::verify(&header.nonce, &app.database, ttl).await {
        Ok(true) => Ok(()),
        Ok(false) => {
            // Unknown, already consumed or past `nonce.ttl_seconds` — the three
            // are indistinguishable by design, since a consumed nonce is
            // deleted. This is the anti-replay refusal, so it is worth a line:
            // a client stuck replaying is a client that will never issue.
            warn!(
                event = "nonce_replayed",
                outcome = "failure",
                nonce_fp = %acme_proxy_store::nonce::fingerprint(&header.nonce),
                path = %request_path
            );
            Err(Problem::bad_nonce("Nonce invalid"))
        }
        Err(error) => {
            // The nonce itself never reaches a log: until it is consumed it is
            // a bearer credential, and this arm is the one where it was *not*
            // consumed. See `Nonce::fingerprint`.
            error!(
                event = "nonce_verification_failed",
                outcome = "failure",
                nonce_fp = %acme_proxy_store::nonce::fingerprint(&header.nonce),
                error = %error
            );
            Err(Problem::server_internal("Nonce verification failed"))
        }
    }
}

/// Advances `account.last_seen_*`, if the throttle says it is worth a write.
///
/// Three deliberate properties:
///
/// - **The address is decided before the lookup.** `needs_touch` takes the
///   canonicalized address and nothing else, so a request that will be
///   throttled never pays for a PTR query — which is most of them.
/// - **A failure is a warning, not a rejection.** These columns are
///   traceability; a client whose issuance failed because the server could not
///   write down where it came from would be a worse server, not a safer one.
/// - **Canonicalized first**, like every other address this crate stores: the
///   dual-stack `[::]:3000` bind sees an IPv4 client as `::ffff:…`, and the same
///   client arriving over IPv4 and IPv6 must not read as two addresses.
async fn touch_account(
    account: &mut Account,
    request: &acme_proxy_core::audit::RequestContext,
    app: &AppState,
) {
    let ip = request
        .ip
        .map(acme_proxy_core::client::canonical)
        .map(|ip| ip.to_string());
    if !account.needs_touch(acme_proxy_store::nonce::now_secs(), ip.as_deref()) {
        return;
    }
    let client = app.audit.client(request).await;
    if let Err(error) = account.touch(&client, &app.database).await {
        warn!(
            event = "account_touch_failed",
            outcome = "failure",
            account_id = %account.id,
            error = %error
        );
    }
}

/// The JWS payload, decoded and parsed as `T`.
///
/// One definition for the two extractors that take a payload: they differ in
/// whether an empty one is allowed, not in how a present one is read, and two
/// copies would let the refusals drift apart — a client reading `malformed`
/// would then be told different things by two endpoints about the same
/// mistake.
fn decode_payload<T: DeserializeOwned>(payload_b64: &str) -> Result<T, Problem> {
    let payload_bytes = BASE64_URL_SAFE_NO_PAD.decode(payload_b64).map_err(|_| {
        warn!(
            event = "jws_payload_decode_failed",
            outcome = "failure",
            payload_b64_chars = payload_b64.len()
        );
        Problem::malformed("Base64 payload invalid")
    })?;

    serde_json::from_slice(&payload_bytes).map_err(|_| {
        warn!(
            event = "jws_payload_parse_failed",
            outcome = "failure",
            payload_bytes = payload_bytes.len()
        );
        Problem::malformed("Payload JSON invalid for this endpoint")
    })
}

impl<S, T> FromRequest<S> for AcmeRequest<T>
where
    S: Send + Sync,
    AppState: FromRef<S>,
    T: DeserializeOwned,
{
    type Rejection = Problem;

    async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
        let (header, pubkey, account, payload_b64) = verify_jws(req, state).await?;

        let payload: T = decode_payload(&payload_b64)?;

        Ok(AcmeRequest {
            header,
            payload,
            pubkey,
            account,
        })
    }
}

/// A verified ACME **POST-as-GET** request (RFC 8555 §6.3).
pub struct AcmePostAsGet {
    pub header: ProtectedHeader,
    pub pubkey: Vec<u8>,
    pub account: Option<Account>,
}

impl<S> FromRequest<S> for AcmePostAsGet
where
    S: Send + Sync,
    AppState: FromRef<S>,
{
    type Rejection = Problem;

    async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
        let (header, pubkey, account, payload_b64) = verify_jws(req, state).await?;

        if !payload_b64.is_empty() {
            warn!(
                event = "jws_post_as_get_payload_not_empty",
                outcome = "failure",
                payload_b64_chars = payload_b64.len(),
                url = %header.url
            );
            return Err(Problem::malformed("POST-as-GET payload must be empty"));
        }

        Ok(AcmePostAsGet {
            header,
            pubkey,
            account,
        })
    }
}

/// A verified ACME POST whose payload may be **either** a document or empty.
///
/// The shape RFC 8555 §7.5.2 forces on the authorization resource: one URL
/// answers both a POST-as-GET (read the authorization) and a
/// `{"status": "deactivated"}` POST (relinquish it). [`AcmeRequest<T>`] cannot
/// serve it — an empty payload is not valid JSON for any `T` — and
/// [`AcmePostAsGet`] rejects the deactivation outright, so the handler needs to
/// see which of the two arrived.
pub struct AcmeOptionalPayload<T> {
    pub header: ProtectedHeader,
    /// `None` for a POST-as-GET, `Some` for a payload-carrying POST.
    pub payload: Option<T>,
    pub pubkey: Vec<u8>,
    pub account: Option<Account>,
}

impl<S, T> FromRequest<S> for AcmeOptionalPayload<T>
where
    S: Send + Sync,
    AppState: FromRef<S>,
    T: DeserializeOwned,
{
    type Rejection = Problem;

    async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
        let (header, pubkey, account, payload_b64) = verify_jws(req, state).await?;

        let payload = match payload_b64.is_empty() {
            true => None,
            false => Some(decode_payload(&payload_b64)?),
        };

        Ok(AcmeOptionalPayload {
            header,
            payload,
            pubkey,
            account,
        })
    }
}

/// Maps a `SignatureError` to the `Problem` the extractor rejects with.
fn map_signature_error(error: SignatureError) -> Problem {
    match error {
        SignatureError::Malformed(detail) => {
            warn!(event = "jws_signature_malformed", outcome = "failure", detail = %detail);
            Problem::malformed(detail)
        }
        SignatureError::BadAlgorithm(detail) => {
            warn!(event = "jws_signature_algorithm_unsupported", outcome = "failure", detail = %detail);
            Problem::bad_signature_algorithm(detail)
        }
        SignatureError::BadSignature(_) => {
            warn!(
                event = "jws_signature_verification_failed",
                outcome = "failure"
            );
            Problem::unauthorized("Signature JWS invalid")
        }
        // The detail describes this server's own key handling — which `ring`
        // call refused what shape — so it goes to the operator's log, not to
        // the client that could only learn about the internals from it.
        SignatureError::Encoding(detail) => {
            error!(event = "jws_signature_encoding_failed", outcome = "failure", detail = %detail);
            Problem::server_internal("Signature could not be verified")
        }
    }
}