Skip to main content

max / makenotwork

5.9 KB · 175 lines History Blame Raw
1 //! DB-layer contract tests for `db::passkeys`, WebAuthn credential storage.
2 //!
3 //! Audit Run 16 graded `db/passkeys.rs` A- with "thin module tests": the
4 //! ownership-scoped mutations (rename/delete return false for a non-owner) and
5 //! the discoverable-login lookup were exercised only through the HTTP passkey
6 //! flow. These pin the invariants directly, most importantly that rename and
7 //! delete are user-scoped (an IDOR guard), the credential-id lookup resolves the
8 //! owner, and the post-auth update bumps the stored counter.
9
10 use crate::harness::db::TestDb;
11 use makenotwork::db::{UserId, passkeys};
12 use serde_json::json;
13
14 async fn seed_user(pool: &sqlx::PgPool, username: &str) -> UserId {
15 let hash = makenotwork::auth::hash_password("password123").expect("hash");
16 sqlx::query_scalar::<_, UserId>(
17 "INSERT INTO users (username, email, password_hash, email_verified)
18 VALUES ($1, $2, $3, true) RETURNING id",
19 )
20 .bind(username)
21 .bind(format!("{username}@test.com"))
22 .bind(&hash)
23 .fetch_one(pool)
24 .await
25 .expect("seed user")
26 }
27
28 #[tokio::test]
29 async fn create_then_find_by_credential_id_roundtrips_the_owner() {
30 let db = TestDb::new().await;
31 let user = seed_user(&db.pool, "pk_roundtrip").await;
32 let cred = b"cred-roundtrip".as_slice();
33
34 passkeys::create_passkey(&db.pool, user, "Yubikey", &json!({"counter": 0}), cred)
35 .await
36 .unwrap();
37
38 let found = passkeys::find_user_by_credential_id(&db.pool, cred)
39 .await
40 .unwrap();
41 let (owner, stored) = found.expect("a stored credential resolves to its owner");
42 assert_eq!(owner, user);
43 assert_eq!(stored, json!({"counter": 0}));
44
45 // An unknown credential id resolves to nobody (no discoverable-login match).
46 assert!(
47 passkeys::find_user_by_credential_id(&db.pool, b"nope".as_slice())
48 .await
49 .unwrap()
50 .is_none()
51 );
52 }
53
54 #[tokio::test]
55 async fn update_after_auth_bumps_the_counter_and_stamps_last_used() {
56 let db = TestDb::new().await;
57 let user = seed_user(&db.pool, "pk_counter").await;
58 let cred = b"cred-counter".as_slice();
59 passkeys::create_passkey(&db.pool, user, "Key", &json!({"counter": 4}), cred)
60 .await
61 .unwrap();
62
63 // Pre-auth: last_used_at is NULL.
64 let before = passkeys::list_passkeys(&db.pool, user).await.unwrap();
65 assert_eq!(before.len(), 1);
66 assert!(before[0].last_used_at.is_none());
67
68 passkeys::update_passkey_after_auth(&db.pool, cred, &json!({"counter": 5}))
69 .await
70 .unwrap();
71
72 let (_, stored) = passkeys::find_user_by_credential_id(&db.pool, cred)
73 .await
74 .unwrap()
75 .unwrap();
76 assert_eq!(
77 stored,
78 json!({"counter": 5}),
79 "the signature counter is advanced"
80 );
81 let after = passkeys::list_passkeys(&db.pool, user).await.unwrap();
82 assert!(
83 after[0].last_used_at.is_some(),
84 "last_used_at is stamped on auth"
85 );
86 }
87
88 /// The IDOR guard: one user must not be able to delete another user's passkey
89 /// by id. `delete_passkey` scopes on `(id, user_id)`, so a non-owner's delete is
90 /// a no-op that returns false, the credential survives.
91 #[tokio::test]
92 async fn delete_is_scoped_to_the_owner() {
93 let db = TestDb::new().await;
94 let owner = seed_user(&db.pool, "pk_owner").await;
95 let attacker = seed_user(&db.pool, "pk_attacker").await;
96 let pk = passkeys::create_passkey(&db.pool, owner, "Key", &json!({}), b"c-own".as_slice())
97 .await
98 .unwrap();
99
100 // Attacker tries to delete the owner's passkey by id -> no-op.
101 assert!(
102 !passkeys::delete_passkey(&db.pool, pk, attacker)
103 .await
104 .unwrap(),
105 "a non-owner delete must return false"
106 );
107 assert_eq!(
108 passkeys::count_passkeys(&db.pool, owner).await.unwrap(),
109 1,
110 "the credential survives"
111 );
112
113 // The owner can delete it.
114 assert!(passkeys::delete_passkey(&db.pool, pk, owner).await.unwrap());
115 assert_eq!(passkeys::count_passkeys(&db.pool, owner).await.unwrap(), 0);
116 }
117
118 #[tokio::test]
119 async fn rename_is_scoped_to_the_owner() {
120 let db = TestDb::new().await;
121 let owner = seed_user(&db.pool, "pk_rn_owner").await;
122 let attacker = seed_user(&db.pool, "pk_rn_attacker").await;
123 let pk = passkeys::create_passkey(&db.pool, owner, "Original", &json!({}), b"c-rn".as_slice())
124 .await
125 .unwrap();
126
127 assert!(
128 !passkeys::rename_passkey(&db.pool, pk, attacker, "Pwned")
129 .await
130 .unwrap(),
131 "a non-owner rename must return false"
132 );
133 let names: Vec<_> = passkeys::list_passkeys(&db.pool, owner).await.unwrap();
134 assert_eq!(
135 names[0].name, "Original",
136 "the name is unchanged by a non-owner"
137 );
138
139 assert!(
140 passkeys::rename_passkey(&db.pool, pk, owner, "Renamed")
141 .await
142 .unwrap()
143 );
144 let names: Vec<_> = passkeys::list_passkeys(&db.pool, owner).await.unwrap();
145 assert_eq!(names[0].name, "Renamed");
146 }
147
148 #[tokio::test]
149 async fn credential_exclusion_list_is_per_user() {
150 let db = TestDb::new().await;
151 let a = seed_user(&db.pool, "pk_excl_a").await;
152 let b = seed_user(&db.pool, "pk_excl_b").await;
153 passkeys::create_passkey(&db.pool, a, "A1", &json!({"id": "a1"}), b"a1".as_slice())
154 .await
155 .unwrap();
156 passkeys::create_passkey(&db.pool, a, "A2", &json!({"id": "a2"}), b"a2".as_slice())
157 .await
158 .unwrap();
159 passkeys::create_passkey(&db.pool, b, "B1", &json!({"id": "b1"}), b"b1".as_slice())
160 .await
161 .unwrap();
162
163 // The registration exclusion list must contain only the caller's credentials.
164 let a_creds = passkeys::get_passkey_credentials(&db.pool, a)
165 .await
166 .unwrap();
167 assert_eq!(a_creds.len(), 2);
168 assert!(
169 a_creds
170 .iter()
171 .all(|c| c["id"].as_str().unwrap().starts_with('a'))
172 );
173 assert_eq!(passkeys::count_passkeys(&db.pool, b).await.unwrap(), 1);
174 }
175