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