acme-proxy 0.3.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
use serde_json::Value;
use sqlx::Row;
use sqlx::sqlite::SqliteRow;
use tracing::{debug, info};
use uuid::Uuid;

use crate::random::random_bytes;
use crate::sqlite::db::Database;
use crate::sqlite::nonce::now_secs;
use crate::sqlite::order::rfc3339;

/// An External Account Binding credential (RFC 8555 §7.3.4): a pre-shared
/// `kid` + HMAC secret an operator issues out-of-band, presented by a client
/// at `newAccount` to prove it was authorized to register.
///
/// Reusable: the same key can bind more than one account, until revoked (see
/// the migration). There is therefore no "used" status, only
/// `active`/`revoked`.
///
/// ## Methods
///
/// - `create`: generate a fresh key and persist it, `active`
/// - `find_by_kid`: lookup by kid (the request-time verification path)
/// - `list_all`: list every key, oldest first (admin CLI, `/ui/eab`)
/// - `search`: one page of the same listing plus the unpaged total (`/api/eab`)
/// - `revoke`: move to the terminal `revoked` state
/// - `to_json`: admin-facing rendering (never includes the secret)
#[derive(Debug)]
pub struct Eab {
    pub kid: String,
    pub secret: Vec<u8>,
    pub label: Option<String>,
    /// Which ACME endpoint the credential is good for. `None` means every
    /// profile -- the default, for an operator who does not care to scope it.
    pub profile: Option<String>,
    pub status: String,
    pub created_at: i64,
}

/// Length, in bytes, of a freshly generated HMAC secret: 32 (256 bits),
/// matching HS256's key size.
const SECRET_LEN: usize = 32;

impl Eab {
    fn from_row(row: SqliteRow) -> Result<Self, sqlx::Error> {
        Ok(Eab {
            kid: row.try_get("kid")?,
            secret: row.try_get("secret")?,
            label: row.try_get("label")?,
            profile: row.try_get("profile")?,
            status: row.try_get("status")?,
            created_at: row.try_get("created_at")?,
        })
    }

    /// Generates a fresh key (random UUID `kid` + random 32-byte secret) and
    /// persists it `active`. Returns the created row so the caller (the
    /// `eab create` admin command) can print the secret **once** -- this is
    /// the only time it is meant to leave the database in plaintext form.
    pub async fn create(
        label: Option<String>,
        profile: Option<String>,
        database: &Database,
    ) -> Result<Eab, sqlx::Error> {
        let eab = Eab {
            kid: Uuid::new_v4().to_string(),
            secret: random_bytes::<SECRET_LEN>().to_vec(),
            label,
            profile,
            status: "active".to_string(),
            created_at: now_secs(),
        };

        debug!(event = "db_eab_create_started", outcome = "progress", kid = ?eab.kid, profile = ?eab.profile);
        sqlx::query(
            "INSERT INTO eab_keys (kid, secret, label, profile, status, created_at) \
             VALUES (?, ?, ?, ?, ?, ?);",
        )
        .bind(&eab.kid)
        .bind(&eab.secret)
        .bind(&eab.label)
        .bind(&eab.profile)
        .bind(&eab.status)
        .bind(eab.created_at)
        .execute(&database.pool)
        .await?;

        info!(event = "db_eab_created", outcome = "success", kid = ?eab.kid);
        Ok(eab)
    }

    /// Looks a credential up for use at `profile`. A row scoped to another
    /// profile is *not* returned: to the endpoint asking, it does not exist.
    /// A row with no profile at all matches everywhere.
    pub async fn find_by_kid(
        kid: &str,
        profile: &str,
        database: &Database,
    ) -> Result<Option<Eab>, sqlx::Error> {
        debug!(event = "db_eab_find_by_kid_started", outcome = "progress", kid = ?kid, profile = %profile);
        let row = sqlx::query(
            "SELECT kid, secret, label, profile, status, created_at FROM eab_keys \
             WHERE kid = ? AND (profile IS NULL OR profile = ?);",
        )
        .bind(kid)
        .bind(profile)
        .fetch_optional(&database.pool)
        .await?;

        row.map(Eab::from_row).transpose()
    }

    /// Looks a credential up by kid regardless of the profile it is scoped to
    /// -- the admin CLI's `eab show`/`eab revoke`, where the operator holds the
    /// kid and wants to see it whatever it is bound to. Never the request path,
    /// which must use [`Eab::find_by_kid`].
    pub async fn find_any_by_kid(
        kid: &str,
        database: &Database,
    ) -> Result<Option<Eab>, sqlx::Error> {
        debug!(event = "db_eab_find_any_by_kid_started", outcome = "progress", kid = ?kid);
        let row = sqlx::query(
            "SELECT kid, secret, label, profile, status, created_at FROM eab_keys WHERE kid = ?;",
        )
        .bind(kid)
        .fetch_optional(&database.pool)
        .await?;

        row.map(Eab::from_row).transpose()
    }

    /// Lists every key, oldest first -- `eab list` and `/ui/eab`.
    ///
    /// Tie-broken on `kid` so it reads the table in the *same* order
    /// [`Eab::search`] pages it in. `created_at` is a whole second, and an
    /// operator minting a handful of credentials in one go lands them all in
    /// one -- so without the tie-break these two orderings were free to
    /// disagree, and the unpaged one was not even stable between calls.
    pub async fn list_all(database: &Database) -> Result<Vec<Eab>, sqlx::Error> {
        debug!(event = "db_eab_list_all_started", outcome = "progress");
        let rows = sqlx::query(
            "SELECT kid, secret, label, profile, status, created_at FROM eab_keys \
             ORDER BY created_at ASC, kid ASC;",
        )
        .fetch_all(&database.pool)
        .await?;

        rows.into_iter().map(Eab::from_row).collect()
    }

    /// One page of the same listing, plus the total the table holds unpaged.
    ///
    /// `GET /api/eab`'s window. Ordering stays **oldest first**, unlike
    /// `Account::search` and `Order::search`: this is a table an operator mints
    /// by hand a few rows at a time, so a fresh head buys nothing, and flipping
    /// it would make the API disagree with `/ui/eab` and `eab list`, both of
    /// which still read [`Eab::list_all`]. `kid` breaks the `created_at` tie for
    /// `Account::search`'s reason — whole-second timestamps would otherwise let
    /// two rows swap between pages, and one of them would never be seen.
    pub async fn search(
        limit: i64,
        offset: i64,
        database: &Database,
    ) -> Result<(Vec<Eab>, i64), sqlx::Error> {
        debug!(
            event = "db_eab_search_started",
            outcome = "progress",
            limit = limit,
            offset = offset
        );
        let rows = sqlx::query(
            "SELECT kid, secret, label, profile, status, created_at FROM eab_keys \
             ORDER BY created_at ASC, kid ASC LIMIT ? OFFSET ?;",
        )
        .bind(limit)
        .bind(offset)
        .fetch_all(&database.pool)
        .await?;
        let total: i64 = sqlx::query("SELECT COUNT(*) FROM eab_keys;")
            .fetch_one(&database.pool)
            .await?
            .try_get(0)?;

        let keys = rows
            .into_iter()
            .map(Eab::from_row)
            .collect::<Result<_, _>>()?;
        Ok((keys, total))
    }

    /// Moves the key to the terminal-for-new-use `revoked` state. Existing
    /// accounts bound under it are unaffected (see the migration's note on
    /// `accounts.eab_kid`). Idempotent: revoking an already-revoked key still
    /// matches the row and reports `true`. Returns whether a row existed.
    pub async fn revoke(kid: &str, database: &Database) -> Result<bool, sqlx::Error> {
        debug!(event = "db_eab_revoke_started", outcome = "progress", kid = ?kid);
        let result = sqlx::query("UPDATE eab_keys SET status = 'revoked' WHERE kid = ?;")
            .bind(kid)
            .execute(&database.pool)
            .await?;

        let updated = result.rows_affected() > 0;
        if updated {
            info!(event = "db_eab_revoked", outcome = "success", kid = ?kid);
        } else {
            debug!(event = "db_eab_revoke_missing", outcome = "success", kid = ?kid);
        }
        Ok(updated)
    }

    /// Whether this key may still be used to bind a new account.
    #[must_use]
    pub fn is_active(&self) -> bool {
        self.status == "active"
    }

    /// The admin-facing rendering: `kid`, `label`, `profile`, `status`,
    /// `createdAt`.
    /// **Never** includes the secret -- that is shown once, by `eab create`,
    /// via `admin::render_eab_created_json`/`render_eab_created_text`, never
    /// again from `show`/`list`.
    #[must_use]
    pub fn to_json(&self) -> Value {
        serde_json::json!({
            "kid": self.kid,
            "label": self.label,
            "profile": self.profile,
            "status": self.status,
            "createdAt": rfc3339(self.created_at),
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::Arc;

    #[tokio::test]
    async fn create_persists_an_active_key_with_a_32_byte_secret() {
        let db = Arc::new(Database::connect_in_memory().await.unwrap());
        let eab = Eab::create(Some("team-a".to_string()), None, &db)
            .await
            .unwrap();
        assert_eq!(eab.status, "active");
        assert_eq!(eab.secret.len(), 32);
        assert_eq!(eab.label.as_deref(), Some("team-a"));
    }

    #[tokio::test]
    async fn find_by_kid_round_trip() {
        let db = Arc::new(Database::connect_in_memory().await.unwrap());
        let created = Eab::create(None, None, &db).await.unwrap();
        let found = Eab::find_by_kid(&created.kid, "default", &db)
            .await
            .unwrap()
            .unwrap();
        assert_eq!(found.secret, created.secret);
        assert!(found.label.is_none());
    }

    #[tokio::test]
    async fn find_by_kid_of_unknown_returns_none() {
        let db = Arc::new(Database::connect_in_memory().await.unwrap());
        assert!(
            Eab::find_by_kid("nope", "default", &db)
                .await
                .unwrap()
                .is_none()
        );
    }

    /// Every key comes back, and an empty table is empty rather than an error.
    ///
    /// Deliberately **not** an assertion on insertion order: `created_at` is a
    /// whole second, so two keys minted in one test share it, and the row that
    /// then comes first is decided by the `kid` tie-break -- a random uuid.
    /// That the two listings agree on an order is
    /// `search_reads_the_table_in_the_same_order_as_list_all`'s to say; that
    /// the order is *oldest first* needs rows a second apart, which is
    /// `search_pages_without_overlap_and_reports_the_unpaged_total`'s
    /// stability guarantee rather than this test's.
    #[tokio::test]
    async fn list_all_returns_every_key_and_empty_is_empty() {
        let db = Arc::new(Database::connect_in_memory().await.unwrap());
        assert!(Eab::list_all(&db).await.unwrap().is_empty());

        let first = Eab::create(None, None, &db).await.unwrap();
        let second = Eab::create(None, None, &db).await.unwrap();
        let all = Eab::list_all(&db).await.unwrap();
        assert_eq!(all.len(), 2);
        for expected in [&first.kid, &second.kid] {
            assert!(
                all.iter().any(|eab| eab.kid == *expected),
                "{expected} was not listed"
            );
        }
    }

    /// The window `GET /api/eab` hands down. Every row created inside one
    /// second here, which is exactly the case the `kid` tie-break exists for:
    /// without it two rows could swap between pages and one would never be
    /// seen.
    #[tokio::test]
    async fn search_pages_without_overlap_and_reports_the_unpaged_total() {
        let db = Arc::new(Database::connect_in_memory().await.unwrap());
        assert_eq!(Eab::search(50, 0, &db).await.unwrap().1, 0);

        let created: Vec<String> = {
            let mut kids = Vec::new();
            for _ in 0..5 {
                kids.push(Eab::create(None, None, &db).await.unwrap().kid);
            }
            kids
        };

        let (first, total) = Eab::search(2, 0, &db).await.unwrap();
        let (second, also_total) = Eab::search(2, 2, &db).await.unwrap();
        let (third, _) = Eab::search(2, 4, &db).await.unwrap();

        assert_eq!(total, 5);
        assert_eq!(also_total, 5, "the total is the table, not the page");
        assert_eq!((first.len(), second.len(), third.len()), (2, 2, 1));

        // Walked end to end, the pages are the table exactly once — which is
        // both "no overlap" and "nothing skipped" in one assertion.
        let walked: Vec<String> = first
            .iter()
            .chain(second.iter())
            .chain(third.iter())
            .map(|eab| eab.kid.clone())
            .collect();
        assert_eq!(walked.len(), created.len());
        for kid in &created {
            assert_eq!(
                walked.iter().filter(|seen| *seen == kid).count(),
                1,
                "{kid} was not on exactly one page"
            );
        }
    }

    /// Oldest first, deliberately unlike `Account::search`/`Order::search`:
    /// flipping it would make `/api/eab` disagree with `/ui/eab` and
    /// `eab list`, which still read `list_all`.
    #[tokio::test]
    async fn search_reads_the_table_in_the_same_order_as_list_all() {
        let db = Arc::new(Database::connect_in_memory().await.unwrap());
        for _ in 0..3 {
            Eab::create(None, None, &db).await.unwrap();
        }

        let unpaged: Vec<String> = Eab::list_all(&db)
            .await
            .unwrap()
            .into_iter()
            .map(|eab| eab.kid)
            .collect();
        let paged: Vec<String> = Eab::search(50, 0, &db)
            .await
            .unwrap()
            .0
            .into_iter()
            .map(|eab| eab.kid)
            .collect();

        assert_eq!(paged, unpaged);
    }

    #[tokio::test]
    async fn revoke_marks_revoked_reports_true_and_is_idempotent() {
        let db = Arc::new(Database::connect_in_memory().await.unwrap());
        let eab = Eab::create(None, None, &db).await.unwrap();
        assert!(Eab::revoke(&eab.kid, &db).await.unwrap());
        assert!(
            !Eab::find_by_kid(&eab.kid, "default", &db)
                .await
                .unwrap()
                .unwrap()
                .is_active()
        );
        // Revoking again still matches the row.
        assert!(Eab::revoke(&eab.kid, &db).await.unwrap());
    }

    #[tokio::test]
    async fn revoke_of_unknown_kid_reports_false() {
        let db = Arc::new(Database::connect_in_memory().await.unwrap());
        assert!(!Eab::revoke("nope", &db).await.unwrap());
    }

    #[tokio::test]
    async fn to_json_never_includes_the_secret() {
        let db = Arc::new(Database::connect_in_memory().await.unwrap());
        let eab = Eab::create(Some("x".to_string()), None, &db).await.unwrap();
        let json = eab.to_json();
        assert!(json.get("secret").is_none());
        assert!(json.get("hmacKey").is_none());
        assert_eq!(json["kid"], eab.kid);
        assert_eq!(json["status"], "active");
        assert_eq!(json["label"], "x");
    }
}