Skip to main content

max / makenotwork

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