acme-proxy 0.2.0

An ACME (RFC 8555) server that issues from a local CA, relays to an upstream CA, or delegates to a script
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
use axum::{
    Extension, Json,
    extract::{Path, State},
    http::{HeaderValue, StatusCode, header},
    response::{IntoResponse, Response},
};
use serde::Deserialize;
use serde_json::Value;
use tracing::{error, info, instrument, warn};

use crate::AppState;
use crate::challenge::ValidationContext;
use crate::error::Problem;
use crate::extractors::acme::{AcmeOptionalPayload, AcmeRequest, jwk_thumbprint};
use crate::filter::ClientIp;
use crate::handlers::helpers::{
    challenge_problem, load_owned_authz, load_owned_challenge, signer_account,
};
use crate::notify::{ChallengeFailedData, NotifyEvent};
use crate::sqlite::{
    authz::{Authorization, Challenge},
    db::Database,
    nonce::now_secs,
    order::Order,
    status::{AuthzStatus, ChallengeStatus, OrderStatus},
};
use std::sync::Arc;

/// The one payload RFC 8555 §7.5.2 defines for the authorization resource:
/// "sending POST requests with the static object `{"status": "deactivated"}`".
#[derive(Debug, Deserialize)]
pub struct AuthzUpdatePayload {
    pub status: Option<String>,
}

/// Reads an authorization via POST-as-GET (RFC 8555 §7.5), or deactivates it
/// (§7.5.2) — one URL, two operations, told apart by whether a payload arrived.
///
/// Both answer with the authorization object and its challenges, so the client
/// sees the state it just read or just caused.
#[instrument(name = "post_authz", skip_all, fields(authz_id = %id))]
pub async fn post_authz(
    State(state): State<AppState>,
    Path(id): Path<String>,
    AcmeOptionalPayload {
        payload,
        pubkey,
        account,
        ..
    }: AcmeOptionalPayload<AuthzUpdatePayload>,
) -> Result<Response, Problem> {
    info!(
        event = "authz_lookup_requested",
        outcome = "progress",
        authz_id = %id,
        deactivating = payload.is_some(),
    );
    let AppState {
        database, profile, ..
    } = state;
    let base = &profile.base_url;

    // `load_owned_authz` walks up to the order and checks `account_id`, which is
    // §7.5.2's "the server MUST verify that the request is signed by the account
    // key corresponding to the account that owns the authorization".
    let account = signer_account(account, &profile.name, &pubkey, &database).await?;
    let (mut authz, mut order) = load_owned_authz(&id, &account, &database).await?;

    if let Some(update) = payload {
        // §7.5.2 defines exactly one payload; anything else is a client sending
        // us something we would otherwise silently ignore.
        if update.status.as_deref() != Some("deactivated") {
            warn!(event = "authz_update_unsupported", outcome = "failure", authz_id = %id, status = ?update.status);
            return Err(Problem::malformed(
                "Only {\"status\": \"deactivated\"} is supported on an authorization",
            ));
        }
        deactivate_authz(&mut authz, &mut order, &database).await?;
    }

    let challenges = Challenge::find_by_authz(&authz.id, &database)
        .await
        .map_err(|error| {
            error!(
                event = "challenge_list_failed",
                outcome = "failure",
                authz_id = %id,
                error = %error
            );
            Problem::server_internal("Challenge lookup failed")
        })?;

    let mut response = Json(authz.to_json(base, &challenges)).into_response();
    add_pending_retry_after(&mut response, authz.status.as_str());
    Ok(response)
}

/// How long a client is asked to wait before polling a still-`pending`
/// authorization or challenge again (RFC 8555 §7.5.1).
///
/// Deliberately small, and for the same reason as `PROCESSING_RETRY_AFTER` on
/// orders: validation here is inline and bounded by `challenge.timeout_ms`, so
/// the answer is usually already decided by the time a client asks — a long
/// hint would stall a client that could have finished immediately.
const PENDING_RETRY_AFTER: &str = "5";

/// Adds `Retry-After` while a resource is still `pending`.
///
/// RFC 8555 §7.5.1: "The server SHOULD provide information about its retry
/// state to the client via the `Retry-After` HTTP header field" — the same
/// pacing courtesy `order_response` extends to a `processing` order. Any other
/// status is decided, so there is nothing to come back for.
fn add_pending_retry_after(response: &mut Response, status: &str) {
    if status == "pending" {
        response.headers_mut().insert(
            header::RETRY_AFTER,
            HeaderValue::from_static(PENDING_RETRY_AFTER),
        );
    }
}

/// Deactivates `authz` and re-derives its order's status (RFC 8555 §7.5.2).
///
/// Already-`deactivated` is a no-op rather than an error: §7.5.2 describes the
/// client sending the same static object to *each* authorization of an
/// identifier, and a retry after a partial failure must not start reporting
/// errors halfway through.
async fn deactivate_authz(
    authz: &mut Authorization,
    order: &mut Order,
    database: &Arc<Database>,
) -> Result<(), Problem> {
    if authz.status == AuthzStatus::Deactivated {
        return Ok(());
    }

    // A certificate already exists for this order, so relinquishing the
    // authorization it was issued under would claim something untrue. §7.5.2 is
    // about giving up the *ability* to issue, not about undoing issuance —
    // that is what revocation (§7.6) is for.
    if order.status == OrderStatus::Valid {
        warn!(event = "authz_deactivate_refused_order_valid", outcome = "failure", authz_id = %authz.id, order_id = %order.id);
        return Err(Problem::malformed(
            "Cannot deactivate an authorization whose order has already been issued; revoke the certificate instead",
        ));
    }

    if authz.status != AuthzStatus::Pending && authz.status != AuthzStatus::Valid {
        warn!(event = "authz_deactivate_refused_terminal", outcome = "failure", authz_id = %authz.id, status = %authz.status);
        return Err(Problem::malformed(
            "Authorization is in a terminal state and cannot be deactivated",
        ));
    }

    // §7.5.2: "The server MUST NOT treat deactivated authorization objects as
    // sufficient for issuing certificates." For a `pending` order that falls
    // out of the readiness check on its own, but an order already promoted to
    // `ready` would still finalize — so demote it.
    //
    // Both in one transaction. Between them, an order sits `ready` with a
    // deactivated authorization under it: finalizable for a name the client has
    // just given up, which is exactly what §7.5.2 forbids.
    let demote = order.status == OrderStatus::Ready;
    let outcome = async {
        let mut tx = database.pool.begin().await?;
        Authorization::set_deactivated(&authz.id, &mut *tx).await?;
        if demote {
            Order::set_pending(&order.id, &mut *tx).await?;
        }
        tx.commit().await
    }
    .await;

    outcome.map_err(|error| {
        error!(event = "authz_deactivate_failed", outcome = "failure", authz_id = %authz.id, error = %error);
        Problem::server_internal("Authorization deactivation failed")
    })?;

    // Only once the transaction has committed: a rollback must not leave these
    // objects claiming a status the database never took.
    authz.status = AuthzStatus::Deactivated;
    if demote {
        order.status = OrderStatus::Pending;
    }

    info!(event = "authz_deactivated", outcome = "success", authz_id = %authz.id, order_id = %order.id);
    Ok(())
}

/// Records a successful validation as **one** transaction: the challenge becomes
/// `valid`, its authorization becomes `valid`, and the order is promoted to
/// `ready` if that was the last one outstanding.
///
/// Three separate statements — which is what this was — can stop between any
/// two. The gap that matters is the last one: an order left `pending` with every
/// authorization already `valid` can never be finalized and nothing re-derives
/// readiness, because the check only ever ran from here and the client has no
/// challenge left to answer to make it run again. The order is stuck until it
/// expires. `post_new_order` has always used one transaction for the same
/// reason.
///
/// It also fixes a second, quieter bug. The readiness check used to re-read the
/// authorizations *from the pool* after the write above had committed, so two
/// concurrent validations of two authorizations of one order could each read
/// before the other's write landed: neither would see a complete set, and
/// neither would promote. Reading inside the transaction that just wrote means
/// SQLite serializes the two writers, and whichever commits second is the one
/// that sees them all `valid`.
async fn commit_validation(
    challenge: &mut Challenge,
    authz: &mut Authorization,
    order: &mut Order,
    database: &Arc<Database>,
) -> Result<(), Problem> {
    let validated = now_secs();
    let outcome = async {
        let mut tx = database.pool.begin().await?;
        Challenge::set_valid(&challenge.id, validated, &mut *tx).await?;
        Authorization::set_valid(&authz.id, &mut *tx).await?;

        // `pool.begin()` issues a deferred BEGIN, but the two writes above have
        // already taken the RESERVED lock by the time this reads — so this sees
        // its own write and no other writer can interleave. Putting a read
        // first here would break that.
        let promote = order.status == OrderStatus::Pending && {
            let authzs = Authorization::find_by_order_with(&order.id, &mut *tx).await?;
            authzs.len() == order.identifiers.len()
                && authzs
                    .iter()
                    .all(|authz| authz.status == AuthzStatus::Valid)
        };
        if promote {
            Order::set_ready(&order.id, &mut *tx).await?;
        }
        tx.commit().await?;
        Ok::<bool, sqlx::Error>(promote)
    }
    .await;

    match outcome {
        Ok(promoted) => {
            // In-memory sync only after the commit; see `Authorization::set_valid`.
            challenge.status = ChallengeStatus::Valid;
            challenge.validated = Some(validated);
            authz.status = AuthzStatus::Valid;
            if promoted {
                order.status = OrderStatus::Ready;
            }
            Ok(())
        }
        Err(error) => {
            error!(
                event = "challenge_validation_persist_failed",
                outcome = "failure",
                challenge_id = %challenge.id,
                authz_id = %authz.id,
                order_id = %order.id,
                error = %error
            );
            Err(Problem::server_internal("Challenge validation failed"))
        }
    }
}

/// The failure arm of [`commit_validation`], same shape: the challenge takes the
/// problem document explaining why, and its authorization and order both become
/// `invalid`, in one transaction.
async fn commit_validation_failure(
    challenge: &mut Challenge,
    authz: &mut Authorization,
    order: &mut Order,
    problem: &Value,
    database: &Arc<Database>,
) -> Result<(), Problem> {
    let outcome = async {
        let mut tx = database.pool.begin().await?;
        Challenge::set_invalid(&challenge.id, problem, &mut *tx).await?;
        Authorization::set_invalid(&authz.id, &mut *tx).await?;
        Order::set_invalid(&order.id, problem, &mut *tx).await?;
        tx.commit().await
    }
    .await;

    match outcome {
        Ok(()) => {
            challenge.status = ChallengeStatus::Invalid;
            challenge.error = Some(problem.clone());
            authz.status = AuthzStatus::Invalid;
            order.status = OrderStatus::Invalid;
            order.error = Some(problem.clone());
            Ok(())
        }
        Err(error) => {
            error!(
                event = "challenge_failure_persist_failed",
                outcome = "failure",
                challenge_id = %challenge.id,
                authz_id = %authz.id,
                order_id = %order.id,
                error = %error
            );
            Err(Problem::server_internal("Challenge validation failed"))
        }
    }
}

/// Triggers validation of a challenge (RFC 8555 §7.5.1).
#[instrument(name = "post_challenge", skip_all, fields(challenge_id = %id))]
pub async fn post_challenge(
    State(state): State<AppState>,
    Path(id): Path<String>,
    Extension(ClientIp(client_ip)): Extension<ClientIp>,
    AcmeRequest {
        pubkey, account, ..
    }: AcmeRequest<Value>,
) -> Result<Response, Problem> {
    info!(
        event = "challenge_trigger_requested",
        outcome = "progress",
        challenge_id = %id
    );
    let AppState {
        database, profile, ..
    } = state;
    let base = &profile.base_url;
    let challenges = &profile.challenges;

    let account = signer_account(account, &profile.name, &pubkey, &database).await?;
    let (mut challenge, mut authz, mut order) =
        load_owned_challenge(&id, &account, &database).await?;

    if authz.status != AuthzStatus::Valid && authz.expires <= now_secs() {
        warn!(event = "authz_expired", outcome = "failure", authz_id = %authz.id, expires = authz.expires);
        return Err(Problem::malformed("Authorization has expired"));
    }

    // The client gave this authorization up (RFC 8555 §7.5.2). Validating a
    // challenge under it would walk it straight back to `valid` — which §7.5.2
    // forbids being sufficient for issuance — so refuse before doing any work.
    if authz.status == AuthzStatus::Deactivated {
        warn!(event = "authz_already_deactivated", outcome = "failure", authz_id = %authz.id);
        return Err(Problem::malformed("Authorization has been deactivated"));
    }

    let decided = challenge.status == ChallengeStatus::Valid
        || challenge.status == ChallengeStatus::Invalid
        || authz.status == AuthzStatus::Valid;

    if !decided {
        let thumbprint = jwk_thumbprint(&account.pubkey).map_err(|error| {
            error!(event = "authz_thumbprint_failed", outcome = "failure", account_id = %account.id, error = %error);
            Problem::server_internal("Key authorization could not be computed")
        })?;
        let key_authorization = format!("{}.{}", challenge.token, thumbprint);

        let context = ValidationContext {
            identifier: authz.base_identifier(),
            wildcard: authz.is_wildcard(),
            token: &challenge.token,
            key_authorization: &key_authorization,
            challenge_id: &challenge.id,
        };

        match challenges.validate(&challenge.typ, &context).await {
            Ok(()) => {
                commit_validation(&mut challenge, &mut authz, &mut order, &database).await?;
            }
            Err(error) => {
                let problem = challenge_problem(&error).to_value();
                warn!(
                    event = "challenge_failed",
                    outcome = "failure",
                    challenge_id = %id,
                    typ = %challenge.typ,
                    kind = error.kind()
                );

                commit_validation_failure(
                    &mut challenge,
                    &mut authz,
                    &mut order,
                    &problem,
                    &database,
                )
                .await?;

                // After the commit, not before. Dispatched first, a persistence
                // failure would have notified an operator about a failure that
                // was never recorded — and the client, which gets a 500, would
                // see the challenge still `pending`.
                profile
                    .notify
                    .dispatch(NotifyEvent::ChallengeFailed(ChallengeFailedData {
                        profile: profile.name.clone(),
                        order_id: order.id.clone(),
                        account_id: account.id.clone(),
                        authz_id: authz.id.clone(),
                        challenge_id: challenge.id.clone(),
                        challenge_type: challenge.typ.clone(),
                        identifier: authz.base_identifier().to_string(),
                        error: error.kind().to_string(),
                        client_ip: client_ip.map(|ip| crate::filter::canonical(ip).to_string()),
                    }))
                    .await;
            }
        }
    }

    info!(
        event = "challenge_answered",
        outcome = "success",
        challenge_id = %id,
        authz_id = %authz.id,
        order_id = %order.id,
        status = %challenge.status
    );
    let up_link = format!("<{base}/authz/{}>;rel=\"up\"", authz.id);
    let mut response = (
        StatusCode::OK,
        [(header::LINK, up_link)],
        Json(challenge.to_json(base)),
    )
        .into_response();
    add_pending_retry_after(&mut response, challenge.status.as_str());
    Ok(response)
}