Skip to main content

acme_proxy/sqlite/
eab.rs

1use ring::rand::{SecureRandom, SystemRandom};
2use serde_json::Value;
3use sqlx::Row;
4use sqlx::sqlite::SqliteRow;
5use tracing::{debug, info};
6use uuid::Uuid;
7
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)
25/// - `revoke`: move to the terminal `revoked` state
26/// - `to_json`: admin-facing rendering (never includes the secret)
27#[derive(Debug)]
28pub struct Eab {
29    pub kid: String,
30    pub secret: Vec<u8>,
31    pub label: Option<String>,
32    /// Which ACME endpoint the credential is good for. `None` means every
33    /// profile -- the default, for an operator who does not care to scope it.
34    pub profile: Option<String>,
35    pub status: String,
36    pub created_at: i64,
37}
38
39/// Length, in bytes, of a freshly generated HMAC secret: 32 (256 bits),
40/// matching HS256's key size.
41const SECRET_LEN: usize = 32;
42
43/// A fresh high-entropy HMAC secret. RNG failure is unrecoverable, so this
44/// panics rather than threading an error through -- the same trade-off
45/// `authz::generate_token` makes for challenge tokens.
46fn generate_secret() -> Vec<u8> {
47    let rng = SystemRandom::new();
48    let mut bytes = vec![0u8; SECRET_LEN];
49    rng.fill(&mut bytes).expect("system RNG unavailable");
50    bytes
51}
52
53impl Eab {
54    fn from_row(row: SqliteRow) -> Result<Self, sqlx::Error> {
55        Ok(Eab {
56            kid: row.try_get("kid")?,
57            secret: row.try_get("secret")?,
58            label: row.try_get("label")?,
59            profile: row.try_get("profile")?,
60            status: row.try_get("status")?,
61            created_at: row.try_get("created_at")?,
62        })
63    }
64
65    /// Generates a fresh key (random UUID `kid` + random 32-byte secret) and
66    /// persists it `active`. Returns the created row so the caller (the
67    /// `eab create` admin command) can print the secret **once** -- this is
68    /// the only time it is meant to leave the database in plaintext form.
69    pub async fn create(
70        label: Option<String>,
71        profile: Option<String>,
72        database: &Database,
73    ) -> Result<Eab, sqlx::Error> {
74        let eab = Eab {
75            kid: Uuid::new_v4().to_string(),
76            secret: generate_secret(),
77            label,
78            profile,
79            status: "active".to_string(),
80            created_at: now_secs(),
81        };
82
83        debug!(event = "db_eab_create_started", outcome = "progress", kid = ?eab.kid, profile = ?eab.profile);
84        sqlx::query(
85            "INSERT INTO eab_keys (kid, secret, label, profile, status, created_at) \
86             VALUES (?, ?, ?, ?, ?, ?);",
87        )
88        .bind(&eab.kid)
89        .bind(&eab.secret)
90        .bind(&eab.label)
91        .bind(&eab.profile)
92        .bind(&eab.status)
93        .bind(eab.created_at)
94        .execute(&database.pool)
95        .await?;
96
97        info!(event = "db_eab_created", outcome = "success", kid = ?eab.kid);
98        Ok(eab)
99    }
100
101    /// Looks a credential up for use at `profile`. A row scoped to another
102    /// profile is *not* returned: to the endpoint asking, it does not exist.
103    /// A row with no profile at all matches everywhere.
104    pub async fn find_by_kid(
105        kid: &str,
106        profile: &str,
107        database: &Database,
108    ) -> Result<Option<Eab>, sqlx::Error> {
109        debug!(event = "db_eab_find_by_kid_started", outcome = "progress", kid = ?kid, profile = %profile);
110        let row = sqlx::query(
111            "SELECT kid, secret, label, profile, status, created_at FROM eab_keys \
112             WHERE kid = ? AND (profile IS NULL OR profile = ?);",
113        )
114        .bind(kid)
115        .bind(profile)
116        .fetch_optional(&database.pool)
117        .await?;
118
119        row.map(Eab::from_row).transpose()
120    }
121
122    /// Looks a credential up by kid regardless of the profile it is scoped to
123    /// -- the admin CLI's `eab show`/`eab revoke`, where the operator holds the
124    /// kid and wants to see it whatever it is bound to. Never the request path,
125    /// which must use [`Eab::find_by_kid`].
126    pub async fn find_any_by_kid(
127        kid: &str,
128        database: &Database,
129    ) -> Result<Option<Eab>, sqlx::Error> {
130        debug!(event = "db_eab_find_any_by_kid_started", outcome = "progress", kid = ?kid);
131        let row = sqlx::query(
132            "SELECT kid, secret, label, profile, status, created_at FROM eab_keys WHERE kid = ?;",
133        )
134        .bind(kid)
135        .fetch_optional(&database.pool)
136        .await?;
137
138        row.map(Eab::from_row).transpose()
139    }
140
141    /// Lists every key, oldest first -- the admin CLI's `eab list`.
142    pub async fn list_all(database: &Database) -> Result<Vec<Eab>, sqlx::Error> {
143        debug!(event = "db_eab_list_all_started", outcome = "progress");
144        let rows = sqlx::query(
145            "SELECT kid, secret, label, profile, status, created_at FROM eab_keys ORDER BY created_at ASC;",
146        )
147        .fetch_all(&database.pool)
148        .await?;
149
150        rows.into_iter().map(Eab::from_row).collect()
151    }
152
153    /// Moves the key to the terminal-for-new-use `revoked` state. Existing
154    /// accounts bound under it are unaffected (see the migration's note on
155    /// `accounts.eab_kid`). Idempotent: revoking an already-revoked key still
156    /// matches the row and reports `true`. Returns whether a row existed.
157    pub async fn revoke(kid: &str, database: &Database) -> Result<bool, sqlx::Error> {
158        debug!(event = "db_eab_revoke_started", outcome = "progress", kid = ?kid);
159        let result = sqlx::query("UPDATE eab_keys SET status = 'revoked' WHERE kid = ?;")
160            .bind(kid)
161            .execute(&database.pool)
162            .await?;
163
164        let updated = result.rows_affected() > 0;
165        if updated {
166            info!(event = "db_eab_revoked", outcome = "success", kid = ?kid);
167        } else {
168            debug!(event = "db_eab_revoke_missing", outcome = "success", kid = ?kid);
169        }
170        Ok(updated)
171    }
172
173    /// Whether this key may still be used to bind a new account.
174    #[must_use]
175    pub fn is_active(&self) -> bool {
176        self.status == "active"
177    }
178
179    /// The admin-facing rendering: `kid`, `label`, `profile`, `status`,
180    /// `createdAt`.
181    /// **Never** includes the secret -- that is shown once, by `eab create`,
182    /// via `admin::render_eab_created_json`/`render_eab_created_text`, never
183    /// again from `show`/`list`.
184    #[must_use]
185    pub fn to_json(&self) -> Value {
186        serde_json::json!({
187            "kid": self.kid,
188            "label": self.label,
189            "profile": self.profile,
190            "status": self.status,
191            "createdAt": rfc3339(self.created_at),
192        })
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use std::sync::Arc;
200
201    #[tokio::test]
202    async fn create_persists_an_active_key_with_a_32_byte_secret() {
203        let db = Arc::new(Database::connect_in_memory().await.unwrap());
204        let eab = Eab::create(Some("team-a".to_string()), None, &db)
205            .await
206            .unwrap();
207        assert_eq!(eab.status, "active");
208        assert_eq!(eab.secret.len(), 32);
209        assert_eq!(eab.label.as_deref(), Some("team-a"));
210    }
211
212    #[tokio::test]
213    async fn find_by_kid_round_trip() {
214        let db = Arc::new(Database::connect_in_memory().await.unwrap());
215        let created = Eab::create(None, None, &db).await.unwrap();
216        let found = Eab::find_by_kid(&created.kid, "default", &db)
217            .await
218            .unwrap()
219            .unwrap();
220        assert_eq!(found.secret, created.secret);
221        assert!(found.label.is_none());
222    }
223
224    #[tokio::test]
225    async fn find_by_kid_of_unknown_returns_none() {
226        let db = Arc::new(Database::connect_in_memory().await.unwrap());
227        assert!(
228            Eab::find_by_kid("nope", "default", &db)
229                .await
230                .unwrap()
231                .is_none()
232        );
233    }
234
235    #[tokio::test]
236    async fn list_all_orders_oldest_first_and_empty_is_empty() {
237        let db = Arc::new(Database::connect_in_memory().await.unwrap());
238        assert!(Eab::list_all(&db).await.unwrap().is_empty());
239
240        let first = Eab::create(None, None, &db).await.unwrap();
241        let second = Eab::create(None, None, &db).await.unwrap();
242        let all = Eab::list_all(&db).await.unwrap();
243        assert_eq!(all.len(), 2);
244        assert_eq!(all[0].kid, first.kid);
245        assert_eq!(all[1].kid, second.kid);
246    }
247
248    #[tokio::test]
249    async fn revoke_marks_revoked_reports_true_and_is_idempotent() {
250        let db = Arc::new(Database::connect_in_memory().await.unwrap());
251        let eab = Eab::create(None, None, &db).await.unwrap();
252        assert!(Eab::revoke(&eab.kid, &db).await.unwrap());
253        assert!(
254            !Eab::find_by_kid(&eab.kid, "default", &db)
255                .await
256                .unwrap()
257                .unwrap()
258                .is_active()
259        );
260        // Revoking again still matches the row.
261        assert!(Eab::revoke(&eab.kid, &db).await.unwrap());
262    }
263
264    #[tokio::test]
265    async fn revoke_of_unknown_kid_reports_false() {
266        let db = Arc::new(Database::connect_in_memory().await.unwrap());
267        assert!(!Eab::revoke("nope", &db).await.unwrap());
268    }
269
270    #[tokio::test]
271    async fn to_json_never_includes_the_secret() {
272        let db = Arc::new(Database::connect_in_memory().await.unwrap());
273        let eab = Eab::create(Some("x".to_string()), None, &db).await.unwrap();
274        let json = eab.to_json();
275        assert!(json.get("secret").is_none());
276        assert!(json.get("hmacKey").is_none());
277        assert_eq!(json["kid"], eab.kid);
278        assert_eq!(json["status"], "active");
279        assert_eq!(json["label"], "x");
280    }
281}