Skip to main content

acme_proxy/sqlite/
admin_recovery_code.rs

1use sqlx::Row;
2use sqlx::sqlite::SqliteRow;
3use tracing::{info, warn};
4use uuid::Uuid;
5
6use crate::sqlite::db::Database;
7use crate::sqlite::nonce::now_secs;
8use crate::sqlite::order::rfc3339;
9
10/// One single-use recovery code of an [`crate::sqlite::admin_user::AdminUser`].
11///
12/// **This layer never sees a plaintext code.** `code_hash` arrives already
13/// hashed from `admin::mfa`, through the very same
14/// [`crate::admin::password`] this crate hashes operator passwords with -- a
15/// recovery code is only ever compared, never needed back, so a read of this
16/// table yields nothing replayable. That is the opposite of
17/// `admin_users.totp_secret`, which verification needs in the clear.
18///
19/// ## Methods
20///
21/// - `replace_all`: mint a set, superseding the previous one, in one transaction
22/// - `list_unused` / `count_unused`: what verification walks, and what the panel shows
23/// - `consume`: spend one, single-use decided by the database
24/// - `delete_for_user`: what removing the factor takes with it
25#[derive(Debug, Clone)]
26pub struct AdminRecoveryCode {
27    pub id: Uuid,
28    pub user_id: Uuid,
29    /// `<algo>$<params>$<salt>$<hash>`, exactly a `password_hash`.
30    pub code_hash: String,
31    pub created_at: i64,
32    /// When it was spent. `None` is the only usable state.
33    pub used_at: Option<i64>,
34}
35
36impl AdminRecoveryCode {
37    fn from_row(row: SqliteRow) -> Result<Self, sqlx::Error> {
38        Ok(AdminRecoveryCode {
39            id: row.try_get("id")?,
40            user_id: row.try_get("user_id")?,
41            code_hash: row.try_get("code_hash")?,
42            created_at: row.try_get("created_at")?,
43            used_at: row.try_get("used_at")?,
44        })
45    }
46
47    /// Replaces this user's whole set with `hashes`.
48    ///
49    /// One transaction, and that is the point: a regeneration that failed
50    /// halfway between the DELETE and the INSERTs would leave the operator
51    /// holding a printed set that no longer works, or -- worse -- half the old
52    /// set still live alongside the new one.
53    ///
54    /// Deletes the *used* rows too. They are an audit trail of the set being
55    /// superseded, not of anything still reachable, and keeping them would make
56    /// "10 minted, 7 remaining" a sum over sets.
57    pub async fn replace_all(
58        user_id: Uuid,
59        hashes: &[String],
60        database: &Database,
61    ) -> Result<(), sqlx::Error> {
62        let now = now_secs();
63        let mut tx = database.pool.begin().await?;
64
65        sqlx::query("DELETE FROM admin_recovery_codes WHERE user_id = ?;")
66            .bind(user_id)
67            .execute(&mut *tx)
68            .await?;
69
70        for hash in hashes {
71            sqlx::query(
72                "INSERT INTO admin_recovery_codes (id, user_id, code_hash, created_at, used_at) \
73                 VALUES (?, ?, ?, ?, NULL);",
74            )
75            .bind(crate::sqlite::id::mint())
76            .bind(user_id)
77            .bind(hash)
78            .bind(now)
79            .execute(&mut *tx)
80            .await?;
81        }
82
83        tx.commit().await?;
84
85        info!(
86            event = "db_admin_recovery_codes_replaced",
87            outcome = "success",
88            user_id = %user_id,
89            minted = hashes.len()
90        );
91        Ok(())
92    }
93
94    /// The unspent codes, oldest first -- what `admin::mfa` walks one PBKDF2
95    /// run at a time when a submission is not a TOTP code.
96    pub async fn list_unused(
97        user_id: Uuid,
98        database: &Database,
99    ) -> Result<Vec<AdminRecoveryCode>, sqlx::Error> {
100        let rows = sqlx::query(
101            "SELECT id, user_id, code_hash, created_at, used_at \
102             FROM admin_recovery_codes WHERE user_id = ? AND used_at IS NULL \
103             ORDER BY created_at ASC, id ASC;",
104        )
105        .bind(user_id)
106        .fetch_all(&database.pool)
107        .await?;
108
109        rows.into_iter().map(AdminRecoveryCode::from_row).collect()
110    }
111
112    /// How many are left -- the "7 of 10 remaining" the panel and
113    /// `admin user totp status` both show.
114    pub async fn count_unused(user_id: Uuid, database: &Database) -> Result<i64, sqlx::Error> {
115        let row = sqlx::query(
116            "SELECT COUNT(*) AS total FROM admin_recovery_codes \
117             WHERE user_id = ? AND used_at IS NULL;",
118        )
119        .bind(user_id)
120        .fetch_one(&database.pool)
121        .await?;
122
123        row.try_get("total")
124    }
125
126    /// Spends one code. `false` means it was already spent, or gone.
127    ///
128    /// The `used_at IS NULL` test lives in the `WHERE` clause rather than in a
129    /// read the caller does first: two submissions of one code racing must not
130    /// both succeed, and `rows_affected` is what decides which one did. Same
131    /// primitive as `Nonce::verify`.
132    ///
133    /// Stamps rather than deletes -- see the migration's comment: "this code was
134    /// spent, at T" is the audit trail a recovery-code use exists to leave.
135    pub async fn consume(id: Uuid, database: &Database) -> Result<bool, sqlx::Error> {
136        let result = sqlx::query(
137            "UPDATE admin_recovery_codes SET used_at = ? WHERE id = ? AND used_at IS NULL;",
138        )
139        .bind(now_secs())
140        .bind(id)
141        .execute(&database.pool)
142        .await?;
143
144        let consumed = result.rows_affected() == 1;
145        if !consumed {
146            // Only reachable from a race or a double-submit, both of which are
147            // worth seeing: the caller already matched the hash, so this is a
148            // correct code arriving a second time.
149            warn!(event = "db_admin_recovery_code_already_used", outcome = "failure", code_id = %id);
150        }
151        Ok(consumed)
152    }
153
154    /// Drops every code of one user -- what removing the second factor takes
155    /// with it, since a recovery code recovers access to a factor that no
156    /// longer exists.
157    pub async fn delete_for_user(user_id: Uuid, database: &Database) -> Result<u64, sqlx::Error> {
158        let result = sqlx::query("DELETE FROM admin_recovery_codes WHERE user_id = ?;")
159            .bind(user_id)
160            .execute(&database.pool)
161            .await?;
162
163        Ok(result.rows_affected())
164    }
165
166    /// The admin-facing rendering. **Never** `code_hash`: it is a password
167    /// hash, and no front end has a reason to see one.
168    #[must_use]
169    pub fn to_json(&self) -> serde_json::Value {
170        serde_json::json!({
171            "id": self.id,
172            "createdAt": rfc3339(self.created_at),
173            "usedAt": self.used_at.map(rfc3339),
174        })
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use crate::sqlite::admin_user::AdminUser;
182    use std::sync::Arc;
183
184    async fn db_with_user() -> (Arc<Database>, AdminUser) {
185        let db = Arc::new(Database::connect_in_memory().await.unwrap());
186        let user = AdminUser::create("alice", "hash", &db).await.unwrap();
187        (db, user)
188    }
189
190    fn hashes(count: usize) -> Vec<String> {
191        (0..count).map(|index| format!("hash-{index}")).collect()
192    }
193
194    #[tokio::test]
195    async fn replace_all_mints_a_set_and_supersedes_the_previous_one() {
196        let (db, user) = db_with_user().await;
197
198        AdminRecoveryCode::replace_all(user.id, &hashes(10), &db)
199            .await
200            .unwrap();
201        assert_eq!(
202            AdminRecoveryCode::count_unused(user.id, &db).await.unwrap(),
203            10
204        );
205
206        // Spend one, then regenerate: the old set must be gone entirely --
207        // both the eight still live and the one already spent, or "remaining"
208        // would be a sum over sets.
209        let first = AdminRecoveryCode::list_unused(user.id, &db).await.unwrap()[0].id;
210        assert!(AdminRecoveryCode::consume(first, &db).await.unwrap());
211        assert_eq!(
212            AdminRecoveryCode::count_unused(user.id, &db).await.unwrap(),
213            9
214        );
215
216        AdminRecoveryCode::replace_all(user.id, &hashes(10), &db)
217            .await
218            .unwrap();
219        let after = AdminRecoveryCode::list_unused(user.id, &db).await.unwrap();
220        assert_eq!(after.len(), 10);
221        assert!(
222            after.iter().all(|code| code.id != first),
223            "no row of the superseded set may survive"
224        );
225    }
226
227    /// The single-use guard, which is the whole reason this is a table and not
228    /// a JSON column.
229    #[tokio::test]
230    async fn a_code_can_be_consumed_exactly_once() {
231        let (db, user) = db_with_user().await;
232        AdminRecoveryCode::replace_all(user.id, &hashes(3), &db)
233            .await
234            .unwrap();
235
236        let code = AdminRecoveryCode::list_unused(user.id, &db).await.unwrap()[0].clone();
237
238        assert!(AdminRecoveryCode::consume(code.id, &db).await.unwrap());
239        assert!(
240            !AdminRecoveryCode::consume(code.id, &db).await.unwrap(),
241            "a second consumption of one code must fail, whatever raced it"
242        );
243        assert!(
244            !AdminRecoveryCode::consume(crate::sqlite::id::mint(), &db)
245                .await
246                .unwrap()
247        );
248
249        // And it leaves the audit stamp rather than the row.
250        let remaining = AdminRecoveryCode::list_unused(user.id, &db).await.unwrap();
251        assert_eq!(remaining.len(), 2);
252        assert!(remaining.iter().all(|other| other.id != code.id));
253    }
254
255    #[tokio::test]
256    async fn deleting_the_operator_cascades_to_their_codes() {
257        let (db, user) = db_with_user().await;
258        AdminRecoveryCode::replace_all(user.id, &hashes(10), &db)
259            .await
260            .unwrap();
261
262        assert!(AdminUser::delete(user.id, &db).await.unwrap());
263        assert_eq!(
264            AdminRecoveryCode::count_unused(user.id, &db).await.unwrap(),
265            0
266        );
267    }
268
269    #[tokio::test]
270    async fn delete_for_user_removes_the_whole_set() {
271        let (db, user) = db_with_user().await;
272        AdminRecoveryCode::replace_all(user.id, &hashes(10), &db)
273            .await
274            .unwrap();
275
276        assert_eq!(
277            AdminRecoveryCode::delete_for_user(user.id, &db)
278                .await
279                .unwrap(),
280            10
281        );
282        assert_eq!(
283            AdminRecoveryCode::count_unused(user.id, &db).await.unwrap(),
284            0
285        );
286    }
287
288    #[tokio::test]
289    async fn to_json_leaks_no_hash() {
290        let (db, user) = db_with_user().await;
291        AdminRecoveryCode::replace_all(user.id, &["a-secret-hash".to_string()], &db)
292            .await
293            .unwrap();
294
295        let code = AdminRecoveryCode::list_unused(user.id, &db).await.unwrap()[0].clone();
296        let rendered = code.to_json().to_string();
297
298        assert!(!rendered.contains("a-secret-hash"));
299        assert!(!rendered.contains("codeHash"));
300        assert!(rendered.contains(&code.id.to_string()));
301        assert!(rendered.contains("\"usedAt\":null"));
302    }
303}