Skip to main content

acme_proxy_store/
admin_recovery_code.rs

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