Skip to main content

acme_proxy/sqlite/
eab.rs

1use serde_json::Value;
2use sqlx::Row;
3use sqlx::sqlite::SqliteRow;
4use tracing::{debug, info};
5use uuid::Uuid;
6
7use crate::random::random_bytes;
8use crate::sqlite::db::Database;
9use crate::sqlite::nonce::now_secs;
10use crate::sqlite::order::rfc3339;
11
12/// An External Account Binding credential (RFC 8555 §7.3.4): a pre-shared
13/// `kid` + HMAC secret an operator issues out-of-band, presented by a client
14/// at `newAccount` to prove it was authorized to register.
15///
16/// Reusable: the same key can bind more than one account, until revoked (see
17/// the migration). There is therefore no "used" status, only
18/// `active`/`revoked`.
19///
20/// ## Methods
21///
22/// - `create`: generate a fresh key and persist it, `active`
23/// - `find_by_kid`: lookup by kid (the request-time verification path)
24/// - `list_all`: list every key, oldest first (admin CLI, `/ui/eab`)
25/// - `search`: one page of the same listing plus the unpaged total (`/api/eab`)
26/// - `revoke`: move to the terminal `revoked` state
27/// - `to_json`: admin-facing rendering (never includes the secret)
28#[derive(Debug)]
29pub struct Eab {
30    pub kid: String,
31    pub secret: Vec<u8>,
32    pub label: Option<String>,
33    /// Which ACME endpoint the credential is good for. `None` means every
34    /// profile -- the default, for an operator who does not care to scope it.
35    pub profile: Option<String>,
36    pub status: String,
37    pub created_at: i64,
38}
39
40/// Length, in bytes, of a freshly generated HMAC secret: 32 (256 bits),
41/// matching HS256's key size.
42const SECRET_LEN: usize = 32;
43
44impl Eab {
45    fn from_row(row: SqliteRow) -> Result<Self, sqlx::Error> {
46        Ok(Eab {
47            kid: row.try_get("kid")?,
48            secret: row.try_get("secret")?,
49            label: row.try_get("label")?,
50            profile: row.try_get("profile")?,
51            status: row.try_get("status")?,
52            created_at: row.try_get("created_at")?,
53        })
54    }
55
56    /// Generates a fresh key (random UUID `kid` + random 32-byte secret) and
57    /// persists it `active`. Returns the created row so the caller (the
58    /// `eab create` admin command) can print the secret **once** -- this is
59    /// the only time it is meant to leave the database in plaintext form.
60    pub async fn create(
61        label: Option<String>,
62        profile: Option<String>,
63        database: &Database,
64    ) -> Result<Eab, sqlx::Error> {
65        let eab = Eab {
66            kid: Uuid::new_v4().to_string(),
67            secret: random_bytes::<SECRET_LEN>().to_vec(),
68            label,
69            profile,
70            status: "active".to_string(),
71            created_at: now_secs(),
72        };
73
74        debug!(event = "db_eab_create_started", outcome = "progress", kid = ?eab.kid, profile = ?eab.profile);
75        sqlx::query(
76            "INSERT INTO eab_keys (kid, secret, label, profile, status, created_at) \
77             VALUES (?, ?, ?, ?, ?, ?);",
78        )
79        .bind(&eab.kid)
80        .bind(&eab.secret)
81        .bind(&eab.label)
82        .bind(&eab.profile)
83        .bind(&eab.status)
84        .bind(eab.created_at)
85        .execute(&database.pool)
86        .await?;
87
88        info!(event = "db_eab_created", outcome = "success", kid = ?eab.kid);
89        Ok(eab)
90    }
91
92    /// Looks a credential up for use at `profile`. A row scoped to another
93    /// profile is *not* returned: to the endpoint asking, it does not exist.
94    /// A row with no profile at all matches everywhere.
95    pub async fn find_by_kid(
96        kid: &str,
97        profile: &str,
98        database: &Database,
99    ) -> Result<Option<Eab>, sqlx::Error> {
100        debug!(event = "db_eab_find_by_kid_started", outcome = "progress", kid = ?kid, profile = %profile);
101        let row = sqlx::query(
102            "SELECT kid, secret, label, profile, status, created_at FROM eab_keys \
103             WHERE kid = ? AND (profile IS NULL OR profile = ?);",
104        )
105        .bind(kid)
106        .bind(profile)
107        .fetch_optional(&database.pool)
108        .await?;
109
110        row.map(Eab::from_row).transpose()
111    }
112
113    /// Looks a credential up by kid regardless of the profile it is scoped to
114    /// -- the admin CLI's `eab show`/`eab revoke`, where the operator holds the
115    /// kid and wants to see it whatever it is bound to. Never the request path,
116    /// which must use [`Eab::find_by_kid`].
117    pub async fn find_any_by_kid(
118        kid: &str,
119        database: &Database,
120    ) -> Result<Option<Eab>, sqlx::Error> {
121        debug!(event = "db_eab_find_any_by_kid_started", outcome = "progress", kid = ?kid);
122        let row = sqlx::query(
123            "SELECT kid, secret, label, profile, status, created_at FROM eab_keys WHERE kid = ?;",
124        )
125        .bind(kid)
126        .fetch_optional(&database.pool)
127        .await?;
128
129        row.map(Eab::from_row).transpose()
130    }
131
132    /// Lists every key, oldest first -- `eab list` and `/ui/eab`.
133    ///
134    /// Tie-broken on `kid` so it reads the table in the *same* order
135    /// [`Eab::search`] pages it in. `created_at` is a whole second, and an
136    /// operator minting a handful of credentials in one go lands them all in
137    /// one -- so without the tie-break these two orderings were free to
138    /// disagree, and the unpaged one was not even stable between calls.
139    pub async fn list_all(database: &Database) -> Result<Vec<Eab>, sqlx::Error> {
140        debug!(event = "db_eab_list_all_started", outcome = "progress");
141        let rows = sqlx::query(
142            "SELECT kid, secret, label, profile, status, created_at FROM eab_keys \
143             ORDER BY created_at ASC, kid ASC;",
144        )
145        .fetch_all(&database.pool)
146        .await?;
147
148        rows.into_iter().map(Eab::from_row).collect()
149    }
150
151    /// One page of the same listing, plus the total the table holds unpaged.
152    ///
153    /// `GET /api/eab`'s window. Ordering stays **oldest first**, unlike
154    /// `Account::search` and `Order::search`: this is a table an operator mints
155    /// by hand a few rows at a time, so a fresh head buys nothing, and flipping
156    /// it would make the API disagree with `/ui/eab` and `eab list`, both of
157    /// which still read [`Eab::list_all`]. `kid` breaks the `created_at` tie for
158    /// `Account::search`'s reason — whole-second timestamps would otherwise let
159    /// two rows swap between pages, and one of them would never be seen.
160    pub async fn search(
161        limit: i64,
162        offset: i64,
163        database: &Database,
164    ) -> Result<(Vec<Eab>, i64), sqlx::Error> {
165        debug!(
166            event = "db_eab_search_started",
167            outcome = "progress",
168            limit = limit,
169            offset = offset
170        );
171        let rows = sqlx::query(
172            "SELECT kid, secret, label, profile, status, created_at FROM eab_keys \
173             ORDER BY created_at ASC, kid ASC LIMIT ? OFFSET ?;",
174        )
175        .bind(limit)
176        .bind(offset)
177        .fetch_all(&database.pool)
178        .await?;
179        let total: i64 = sqlx::query("SELECT COUNT(*) FROM eab_keys;")
180            .fetch_one(&database.pool)
181            .await?
182            .try_get(0)?;
183
184        let keys = rows
185            .into_iter()
186            .map(Eab::from_row)
187            .collect::<Result<_, _>>()?;
188        Ok((keys, total))
189    }
190
191    /// Moves the key to the terminal-for-new-use `revoked` state. Existing
192    /// accounts bound under it are unaffected (see the migration's note on
193    /// `accounts.eab_kid`). Idempotent: revoking an already-revoked key still
194    /// matches the row and reports `true`. Returns whether a row existed.
195    pub async fn revoke(kid: &str, database: &Database) -> Result<bool, sqlx::Error> {
196        debug!(event = "db_eab_revoke_started", outcome = "progress", kid = ?kid);
197        let result = sqlx::query("UPDATE eab_keys SET status = 'revoked' WHERE kid = ?;")
198            .bind(kid)
199            .execute(&database.pool)
200            .await?;
201
202        let updated = result.rows_affected() > 0;
203        if updated {
204            info!(event = "db_eab_revoked", outcome = "success", kid = ?kid);
205        } else {
206            debug!(event = "db_eab_revoke_missing", outcome = "success", kid = ?kid);
207        }
208        Ok(updated)
209    }
210
211    /// Whether this key may still be used to bind a new account.
212    #[must_use]
213    pub fn is_active(&self) -> bool {
214        self.status == "active"
215    }
216
217    /// The admin-facing rendering: `kid`, `label`, `profile`, `status`,
218    /// `createdAt`.
219    /// **Never** includes the secret -- that is shown once, by `eab create`,
220    /// via `admin::render_eab_created_json`/`render_eab_created_text`, never
221    /// again from `show`/`list`.
222    #[must_use]
223    pub fn to_json(&self) -> Value {
224        serde_json::json!({
225            "kid": self.kid,
226            "label": self.label,
227            "profile": self.profile,
228            "status": self.status,
229            "createdAt": rfc3339(self.created_at),
230        })
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237    use std::sync::Arc;
238
239    #[tokio::test]
240    async fn create_persists_an_active_key_with_a_32_byte_secret() {
241        let db = Arc::new(Database::connect_in_memory().await.unwrap());
242        let eab = Eab::create(Some("team-a".to_string()), None, &db)
243            .await
244            .unwrap();
245        assert_eq!(eab.status, "active");
246        assert_eq!(eab.secret.len(), 32);
247        assert_eq!(eab.label.as_deref(), Some("team-a"));
248    }
249
250    #[tokio::test]
251    async fn find_by_kid_round_trip() {
252        let db = Arc::new(Database::connect_in_memory().await.unwrap());
253        let created = Eab::create(None, None, &db).await.unwrap();
254        let found = Eab::find_by_kid(&created.kid, "default", &db)
255            .await
256            .unwrap()
257            .unwrap();
258        assert_eq!(found.secret, created.secret);
259        assert!(found.label.is_none());
260    }
261
262    #[tokio::test]
263    async fn find_by_kid_of_unknown_returns_none() {
264        let db = Arc::new(Database::connect_in_memory().await.unwrap());
265        assert!(
266            Eab::find_by_kid("nope", "default", &db)
267                .await
268                .unwrap()
269                .is_none()
270        );
271    }
272
273    /// Every key comes back, and an empty table is empty rather than an error.
274    ///
275    /// Deliberately **not** an assertion on insertion order: `created_at` is a
276    /// whole second, so two keys minted in one test share it, and the row that
277    /// then comes first is decided by the `kid` tie-break -- a random uuid.
278    /// That the two listings agree on an order is
279    /// `search_reads_the_table_in_the_same_order_as_list_all`'s to say; that
280    /// the order is *oldest first* needs rows a second apart, which is
281    /// `search_pages_without_overlap_and_reports_the_unpaged_total`'s
282    /// stability guarantee rather than this test's.
283    #[tokio::test]
284    async fn list_all_returns_every_key_and_empty_is_empty() {
285        let db = Arc::new(Database::connect_in_memory().await.unwrap());
286        assert!(Eab::list_all(&db).await.unwrap().is_empty());
287
288        let first = Eab::create(None, None, &db).await.unwrap();
289        let second = Eab::create(None, None, &db).await.unwrap();
290        let all = Eab::list_all(&db).await.unwrap();
291        assert_eq!(all.len(), 2);
292        for expected in [&first.kid, &second.kid] {
293            assert!(
294                all.iter().any(|eab| eab.kid == *expected),
295                "{expected} was not listed"
296            );
297        }
298    }
299
300    /// The window `GET /api/eab` hands down. Every row created inside one
301    /// second here, which is exactly the case the `kid` tie-break exists for:
302    /// without it two rows could swap between pages and one would never be
303    /// seen.
304    #[tokio::test]
305    async fn search_pages_without_overlap_and_reports_the_unpaged_total() {
306        let db = Arc::new(Database::connect_in_memory().await.unwrap());
307        assert_eq!(Eab::search(50, 0, &db).await.unwrap().1, 0);
308
309        let created: Vec<String> = {
310            let mut kids = Vec::new();
311            for _ in 0..5 {
312                kids.push(Eab::create(None, None, &db).await.unwrap().kid);
313            }
314            kids
315        };
316
317        let (first, total) = Eab::search(2, 0, &db).await.unwrap();
318        let (second, also_total) = Eab::search(2, 2, &db).await.unwrap();
319        let (third, _) = Eab::search(2, 4, &db).await.unwrap();
320
321        assert_eq!(total, 5);
322        assert_eq!(also_total, 5, "the total is the table, not the page");
323        assert_eq!((first.len(), second.len(), third.len()), (2, 2, 1));
324
325        // Walked end to end, the pages are the table exactly once — which is
326        // both "no overlap" and "nothing skipped" in one assertion.
327        let walked: Vec<String> = first
328            .iter()
329            .chain(second.iter())
330            .chain(third.iter())
331            .map(|eab| eab.kid.clone())
332            .collect();
333        assert_eq!(walked.len(), created.len());
334        for kid in &created {
335            assert_eq!(
336                walked.iter().filter(|seen| *seen == kid).count(),
337                1,
338                "{kid} was not on exactly one page"
339            );
340        }
341    }
342
343    /// Oldest first, deliberately unlike `Account::search`/`Order::search`:
344    /// flipping it would make `/api/eab` disagree with `/ui/eab` and
345    /// `eab list`, which still read `list_all`.
346    #[tokio::test]
347    async fn search_reads_the_table_in_the_same_order_as_list_all() {
348        let db = Arc::new(Database::connect_in_memory().await.unwrap());
349        for _ in 0..3 {
350            Eab::create(None, None, &db).await.unwrap();
351        }
352
353        let unpaged: Vec<String> = Eab::list_all(&db)
354            .await
355            .unwrap()
356            .into_iter()
357            .map(|eab| eab.kid)
358            .collect();
359        let paged: Vec<String> = Eab::search(50, 0, &db)
360            .await
361            .unwrap()
362            .0
363            .into_iter()
364            .map(|eab| eab.kid)
365            .collect();
366
367        assert_eq!(paged, unpaged);
368    }
369
370    #[tokio::test]
371    async fn revoke_marks_revoked_reports_true_and_is_idempotent() {
372        let db = Arc::new(Database::connect_in_memory().await.unwrap());
373        let eab = Eab::create(None, None, &db).await.unwrap();
374        assert!(Eab::revoke(&eab.kid, &db).await.unwrap());
375        assert!(
376            !Eab::find_by_kid(&eab.kid, "default", &db)
377                .await
378                .unwrap()
379                .unwrap()
380                .is_active()
381        );
382        // Revoking again still matches the row.
383        assert!(Eab::revoke(&eab.kid, &db).await.unwrap());
384    }
385
386    #[tokio::test]
387    async fn revoke_of_unknown_kid_reports_false() {
388        let db = Arc::new(Database::connect_in_memory().await.unwrap());
389        assert!(!Eab::revoke("nope", &db).await.unwrap());
390    }
391
392    #[tokio::test]
393    async fn to_json_never_includes_the_secret() {
394        let db = Arc::new(Database::connect_in_memory().await.unwrap());
395        let eab = Eab::create(Some("x".to_string()), None, &db).await.unwrap();
396        let json = eab.to_json();
397        assert!(json.get("secret").is_none());
398        assert!(json.get("hmacKey").is_none());
399        assert_eq!(json["kid"], eab.kid);
400        assert_eq!(json["status"], "active");
401        assert_eq!(json["label"], "x");
402    }
403}