Skip to main content

acme_proxy/sqlite/
admin_recovery_code.rs

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