Skip to main content

max / makenotwork

5.5 KB · 162 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 crate::harness::seed_user;
12 use makenotwork::db::passkeys;
13 use serde_json::json;
14
15 #[tokio::test]
16 async fn create_then_find_by_credential_id_roundtrips_the_owner() {
17 let db = TestDb::new().await;
18 let user = seed_user(&db.pool, "pk_roundtrip").await;
19 let cred = b"cred-roundtrip".as_slice();
20
21 passkeys::create_passkey(&db.pool, user, "Yubikey", &json!({"counter": 0}), cred)
22 .await
23 .unwrap();
24
25 let found = passkeys::find_user_by_credential_id(&db.pool, cred)
26 .await
27 .unwrap();
28 let (owner, stored) = found.expect("a stored credential resolves to its owner");
29 assert_eq!(owner, user);
30 assert_eq!(stored, json!({"counter": 0}));
31
32 // An unknown credential id resolves to nobody (no discoverable-login match).
33 assert!(
34 passkeys::find_user_by_credential_id(&db.pool, b"nope".as_slice())
35 .await
36 .unwrap()
37 .is_none()
38 );
39 }
40
41 #[tokio::test]
42 async fn update_after_auth_bumps_the_counter_and_stamps_last_used() {
43 let db = TestDb::new().await;
44 let user = seed_user(&db.pool, "pk_counter").await;
45 let cred = b"cred-counter".as_slice();
46 passkeys::create_passkey(&db.pool, user, "Key", &json!({"counter": 4}), cred)
47 .await
48 .unwrap();
49
50 // Pre-auth: last_used_at is NULL.
51 let before = passkeys::list_passkeys(&db.pool, user).await.unwrap();
52 assert_eq!(before.len(), 1);
53 assert!(before[0].last_used_at.is_none());
54
55 passkeys::update_passkey_after_auth(&db.pool, cred, &json!({"counter": 5}))
56 .await
57 .unwrap();
58
59 let (_, stored) = passkeys::find_user_by_credential_id(&db.pool, cred)
60 .await
61 .unwrap()
62 .unwrap();
63 assert_eq!(
64 stored,
65 json!({"counter": 5}),
66 "the signature counter is advanced"
67 );
68 let after = passkeys::list_passkeys(&db.pool, user).await.unwrap();
69 assert!(
70 after[0].last_used_at.is_some(),
71 "last_used_at is stamped on auth"
72 );
73 }
74
75 /// The IDOR guard: one user must not be able to delete another user's passkey
76 /// by id. `delete_passkey` scopes on `(id, user_id)`, so a non-owner's delete is
77 /// a no-op that returns false, the credential survives.
78 #[tokio::test]
79 async fn delete_is_scoped_to_the_owner() {
80 let db = TestDb::new().await;
81 let owner = seed_user(&db.pool, "pk_owner").await;
82 let attacker = seed_user(&db.pool, "pk_attacker").await;
83 let pk = passkeys::create_passkey(&db.pool, owner, "Key", &json!({}), b"c-own".as_slice())
84 .await
85 .unwrap();
86
87 // Attacker tries to delete the owner's passkey by id -> no-op.
88 assert!(
89 !passkeys::delete_passkey(&db.pool, pk, attacker)
90 .await
91 .unwrap(),
92 "a non-owner delete must return false"
93 );
94 assert_eq!(
95 passkeys::count_passkeys(&db.pool, owner).await.unwrap(),
96 1,
97 "the credential survives"
98 );
99
100 // The owner can delete it.
101 assert!(passkeys::delete_passkey(&db.pool, pk, owner).await.unwrap());
102 assert_eq!(passkeys::count_passkeys(&db.pool, owner).await.unwrap(), 0);
103 }
104
105 #[tokio::test]
106 async fn rename_is_scoped_to_the_owner() {
107 let db = TestDb::new().await;
108 let owner = seed_user(&db.pool, "pk_rn_owner").await;
109 let attacker = seed_user(&db.pool, "pk_rn_attacker").await;
110 let pk = passkeys::create_passkey(&db.pool, owner, "Original", &json!({}), b"c-rn".as_slice())
111 .await
112 .unwrap();
113
114 assert!(
115 !passkeys::rename_passkey(&db.pool, pk, attacker, "Pwned")
116 .await
117 .unwrap(),
118 "a non-owner rename must return false"
119 );
120 let names: Vec<_> = passkeys::list_passkeys(&db.pool, owner).await.unwrap();
121 assert_eq!(
122 names[0].name, "Original",
123 "the name is unchanged by a non-owner"
124 );
125
126 assert!(
127 passkeys::rename_passkey(&db.pool, pk, owner, "Renamed")
128 .await
129 .unwrap()
130 );
131 let names: Vec<_> = passkeys::list_passkeys(&db.pool, owner).await.unwrap();
132 assert_eq!(names[0].name, "Renamed");
133 }
134
135 #[tokio::test]
136 async fn credential_exclusion_list_is_per_user() {
137 let db = TestDb::new().await;
138 let a = seed_user(&db.pool, "pk_excl_a").await;
139 let b = seed_user(&db.pool, "pk_excl_b").await;
140 passkeys::create_passkey(&db.pool, a, "A1", &json!({"id": "a1"}), b"a1".as_slice())
141 .await
142 .unwrap();
143 passkeys::create_passkey(&db.pool, a, "A2", &json!({"id": "a2"}), b"a2".as_slice())
144 .await
145 .unwrap();
146 passkeys::create_passkey(&db.pool, b, "B1", &json!({"id": "b1"}), b"b1".as_slice())
147 .await
148 .unwrap();
149
150 // The registration exclusion list must contain only the caller's credentials.
151 let a_creds = passkeys::get_passkey_credentials(&db.pool, a)
152 .await
153 .unwrap();
154 assert_eq!(a_creds.len(), 2);
155 assert!(
156 a_creds
157 .iter()
158 .all(|c| c["id"].as_str().unwrap().starts_with('a'))
159 );
160 assert_eq!(passkeys::count_passkeys(&db.pool, b).await.unwrap(), 1);
161 }
162