Skip to main content

max / makenotwork

7.4 KB · 221 lines History Blame Raw
1 //! DB-layer contract tests for `db::ssh_keys`, git-over-SSH key CRUD + lookup.
2 //!
3 //! Audit Run 16 flagged `db/ssh_keys.rs:98` (`lookup_user_by_fingerprint`) and
4 //! the module's thin coverage. These pin the ownership-scoped deletes (an IDOR
5 //! guard on the git surface), the per-user duplicate rejection, and the global
6 //! fingerprint uniqueness (migration 161) that keeps `lookup_user_by_fingerprint`
7 //! unambiguous, one account can't register another's key fingerprint.
8
9 use crate::harness::db::TestDb;
10 use crate::harness::seed_user;
11 use makenotwork::db::ssh_keys;
12
13 #[tokio::test]
14 async fn add_then_lookup_resolves_the_owner() {
15 let db = TestDb::new().await;
16 let user = seed_user(&db.pool, "ssh_lookup").await;
17
18 ssh_keys::add_key(&db.pool, user, "ssh-ed25519 AAAA", "SHA256:aaa", "laptop")
19 .await
20 .unwrap();
21
22 let found = ssh_keys::lookup_user_by_fingerprint(&db.pool, "SHA256:aaa")
23 .await
24 .unwrap()
25 .expect("known fingerprint resolves");
26 assert_eq!(found.user_id, user);
27 // The CLI takes its display currency from this one lookup and has no other
28 // source for it, so dropping the column here renders every amount in the
29 // TUI as USD whoever the creator is.
30 assert_eq!(
31 found.settlement_currency,
32 makenotwork::currency::SettlementCurrency::Usd,
33 "a seeded user settles in the column default"
34 );
35
36 // An unknown fingerprint authenticates nobody.
37 assert!(
38 ssh_keys::lookup_user_by_fingerprint(&db.pool, "SHA256:unknown")
39 .await
40 .unwrap()
41 .is_none()
42 );
43 }
44
45 #[tokio::test]
46 async fn lookup_carries_the_console_theme_selection() {
47 let db = TestDb::new().await;
48 let user = seed_user(&db.pool, "ssh_console_theme").await;
49
50 ssh_keys::add_key(&db.pool, user, "ssh-ed25519 AAAA", "SHA256:theme", "laptop")
51 .await
52 .unwrap();
53
54 // Never chosen. Absent here becomes an absent `theme_id` on the wire, which
55 // the CLI reads as `ThemeSelection::Follow` — today's behaviour, and what a
56 // CLI talking to a server that predates the column already does.
57 let found = ssh_keys::lookup_user_by_fingerprint(&db.pool, "SHA256:theme")
58 .await
59 .unwrap()
60 .expect("known fingerprint resolves");
61 assert_eq!(found.console_theme, None);
62
63 // Following the terminal is a stored choice, not an absence: the row has to
64 // be able to say it, or a creator who pins a theme can never go back.
65 makenotwork::db::users::update_user_console_theme(&db.pool, user, makeover::FOLLOW)
66 .await
67 .unwrap();
68 let found = ssh_keys::lookup_user_by_fingerprint(&db.pool, "SHA256:theme")
69 .await
70 .unwrap()
71 .unwrap();
72 assert_eq!(found.console_theme.as_deref(), Some(makeover::FOLLOW));
73
74 // A pinned theme reaches the lookup verbatim. This is the whole point of the
75 // column: the CLI has no other source for the creator's choice.
76 makenotwork::db::users::update_user_console_theme(&db.pool, user, "nord")
77 .await
78 .unwrap();
79 let found = ssh_keys::lookup_user_by_fingerprint(&db.pool, "SHA256:theme")
80 .await
81 .unwrap()
82 .unwrap();
83 assert_eq!(found.console_theme.as_deref(), Some("nord"));
84
85 // The public-profile theme is a different question and must not move with it.
86 assert_eq!(
87 makenotwork::db::users::get_user_by_id(&db.pool, user)
88 .await
89 .unwrap()
90 .unwrap()
91 .theme_id,
92 None,
93 "the console choice must not write the profile palette visitors see"
94 );
95 }
96
97 #[tokio::test]
98 async fn duplicate_fingerprint_for_same_user_is_rejected() {
99 let db = TestDb::new().await;
100 let user = seed_user(&db.pool, "ssh_dup").await;
101
102 ssh_keys::add_key(&db.pool, user, "ssh-ed25519 AAAA", "SHA256:dup", "one")
103 .await
104 .unwrap();
105 // UNIQUE (user_id, fingerprint), re-registering the same key fails.
106 let dup = ssh_keys::add_key(&db.pool, user, "ssh-ed25519 AAAA", "SHA256:dup", "two").await;
107 assert!(
108 dup.is_err(),
109 "the same fingerprint cannot be registered twice by one user"
110 );
111 }
112
113 /// A fingerprint maps to exactly ONE identity, globally. Migration 161 added a
114 /// global `UNIQUE (fingerprint)` on top of the original `(user_id, fingerprint)`
115 /// specifically so one account can't register a public key already tied to
116 /// another account and thereby authenticate as it (Run 15 Auth C1). This guards
117 /// that fix: a second user registering the same fingerprint is rejected, so
118 /// `lookup_user_by_fingerprint` can never be ambiguous.
119 #[tokio::test]
120 async fn fingerprint_is_globally_unique_across_users() {
121 let db = TestDb::new().await;
122 let first = seed_user(&db.pool, "ssh_first").await;
123 let second = seed_user(&db.pool, "ssh_second").await;
124
125 ssh_keys::add_key(
126 &db.pool,
127 first,
128 "ssh-ed25519 AAAA",
129 "SHA256:shared",
130 "first",
131 )
132 .await
133 .unwrap();
134 // A different account cannot claim the same fingerprint.
135 let stolen = ssh_keys::add_key(
136 &db.pool,
137 second,
138 "ssh-ed25519 AAAA",
139 "SHA256:shared",
140 "second",
141 )
142 .await;
143 assert!(
144 stolen.is_err(),
145 "a fingerprint already registered to one account must not be registrable by another"
146 );
147
148 // The lookup remains unambiguous, it resolves to the sole registrant.
149 let resolved = ssh_keys::lookup_user_by_fingerprint(&db.pool, "SHA256:shared")
150 .await
151 .unwrap()
152 .expect("fingerprint resolves");
153 assert_eq!(resolved.user_id, first);
154 }
155
156 /// IDOR guard: `delete_key` scopes on `(id, user_id)`, so one user cannot delete
157 /// another's key by id.
158 #[tokio::test]
159 async fn delete_by_id_is_scoped_to_the_owner() {
160 let db = TestDb::new().await;
161 let owner = seed_user(&db.pool, "ssh_owner").await;
162 let attacker = seed_user(&db.pool, "ssh_attacker").await;
163 let key = ssh_keys::add_key(&db.pool, owner, "ssh-ed25519 AAAA", "SHA256:own", "k")
164 .await
165 .unwrap();
166
167 assert!(
168 !ssh_keys::delete_key(&db.pool, key.id, attacker)
169 .await
170 .unwrap(),
171 "a non-owner delete-by-id must return false"
172 );
173 assert_eq!(
174 ssh_keys::list_keys_by_user(&db.pool, owner)
175 .await
176 .unwrap()
177 .len(),
178 1,
179 "the key survives"
180 );
181
182 assert!(ssh_keys::delete_key(&db.pool, key.id, owner).await.unwrap());
183 assert!(
184 ssh_keys::list_keys_by_user(&db.pool, owner)
185 .await
186 .unwrap()
187 .is_empty()
188 );
189 }
190
191 #[tokio::test]
192 async fn delete_by_fingerprint_is_scoped_to_the_owner() {
193 let db = TestDb::new().await;
194 let owner = seed_user(&db.pool, "ssh_fp_owner").await;
195 let attacker = seed_user(&db.pool, "ssh_fp_attacker").await;
196 ssh_keys::add_key(&db.pool, owner, "ssh-ed25519 AAAA", "SHA256:fp", "k")
197 .await
198 .unwrap();
199
200 // The attacker knows the fingerprint but not the owner scope: no-op.
201 assert!(
202 !ssh_keys::delete_key_by_fingerprint(&db.pool, attacker, "SHA256:fp")
203 .await
204 .unwrap(),
205 "a non-owner delete-by-fingerprint must return false"
206 );
207 assert_eq!(
208 ssh_keys::list_keys_by_user(&db.pool, owner)
209 .await
210 .unwrap()
211 .len(),
212 1
213 );
214
215 assert!(
216 ssh_keys::delete_key_by_fingerprint(&db.pool, owner, "SHA256:fp")
217 .await
218 .unwrap()
219 );
220 }
221