server/command/
ssh_key.rs1use chrono::{DateTime, Utc};
9use serde::Serialize;
10use sqlx::types::Uuid;
11use std::fmt::Debug;
12use supp_macro::command;
13
14use super::{CmdError, CmdResult};
15use crate::{config::ConfigError, db::get_connection};
16
17#[derive(Debug, Clone, Serialize)]
21pub struct SshKeyRecord {
22 pub id: Uuid,
23 pub user_id: Uuid,
24 pub key_type: String,
25 pub key_blob: Vec<u8>,
26 pub fingerprint: String,
27 pub annotation: String,
28 pub created_at: DateTime<Utc>,
29 pub last_used_at: Option<DateTime<Utc>>,
30}
31
32command! {
38 AddSshKey {
39 #[required]
40 user_id: Uuid,
41 #[required]
42 key_type: String,
43 #[required]
44 key_blob: Vec<u8>,
45 #[required]
46 fingerprint: String,
47 #[optional]
48 annotation: String,
49 } => {
50 let mut conn = get_connection().await.map_err(|err| {
51 log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
52 ConfigError::DB
53 })?;
54 let annotation = annotation.unwrap_or_default();
55 let row = sqlx::query_file!(
56 "sql/insert/ssh_keys/add.sql",
57 user_id,
58 key_type,
59 key_blob,
60 fingerprint,
61 annotation,
62 )
63 .fetch_one(&mut *conn)
64 .await?;
65 sqlx::query_file!("sql/update/users/set_ssh_enabled.sql", user_id, true)
66 .execute(&mut *conn)
67 .await?;
68 Ok(Some(CmdResult::Uuid(row.id)))
69 }
70}
71
72command! {
75 ListSshKeys {
76 #[required]
77 user_id: Uuid,
78 } => {
79 let mut conn = get_connection().await.map_err(|err| {
80 log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
81 ConfigError::DB
82 })?;
83 let rows = sqlx::query_file!(
84 "sql/select/ssh_keys/list_for_user.sql",
85 user_id
86 )
87 .fetch_all(&mut *conn)
88 .await?;
89 let keys = rows
90 .into_iter()
91 .map(|r| SshKeyRecord {
92 id: r.id,
93 user_id: r.user_id,
94 key_type: r.key_type,
95 key_blob: r.key_blob,
96 fingerprint: r.fingerprint,
97 annotation: r.annotation,
98 created_at: r.created_at,
99 last_used_at: r.last_used_at,
100 })
101 .collect();
102 Ok(Some(CmdResult::SshKeys(keys)))
103 }
104}
105
106command! {
111 RemoveSshKey {
112 #[required]
113 user_id: Uuid,
114 #[required]
115 fingerprint: String,
116 } => {
117 let mut conn = get_connection().await.map_err(|err| {
118 log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
119 ConfigError::DB
120 })?;
121 sqlx::query_file!(
122 "sql/delete/ssh_keys/by_fingerprint.sql",
123 user_id,
124 fingerprint
125 )
126 .execute(&mut *conn)
127 .await?;
128 let remaining = sqlx::query_file!(
129 "sql/select/ssh_keys/count_for_user.sql",
130 user_id
131 )
132 .fetch_one(&mut *conn)
133 .await?
134 .count
135 .unwrap_or(0);
136 if remaining == 0 {
137 sqlx::query_file!("sql/update/users/set_ssh_enabled.sql", user_id, false)
138 .execute(&mut *conn)
139 .await?;
140 }
141 Ok(Some(CmdResult::Bool(true)))
142 }
143}
144
145command! {
150 UserHasSshKey {
151 #[required]
152 user_id: Uuid,
153 } => {
154 let mut conn = get_connection().await.map_err(|err| {
155 log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
156 ConfigError::DB
157 })?;
158 let count = sqlx::query_file!(
159 "sql/select/ssh_keys/count_for_user.sql",
160 user_id
161 )
162 .fetch_one(&mut *conn)
163 .await?
164 .count
165 .unwrap_or(0);
166 Ok(Some(CmdResult::Bool(count > 0)))
167 }
168}
169
170command! {
175 LookupUserBySshKey {
176 #[required]
177 fingerprint: String,
178 } => {
179 let mut conn = get_connection().await.map_err(|err| {
180 log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
181 ConfigError::DB
182 })?;
183 let Some(row) = sqlx::query_file!(
184 "sql/select/ssh_keys/lookup_user.sql",
185 fingerprint
186 )
187 .fetch_optional(&mut *conn)
188 .await?
189 else {
190 return Ok(None);
191 };
192 if !row.ssh_enabled {
193 return Ok(None);
194 }
195 sqlx::query_file!("sql/update/ssh_keys/touch_last_used.sql", fingerprint)
196 .execute(&mut *conn)
197 .await?;
198 Ok(Some(CmdResult::Uuid(row.user_id)))
199 }
200}
201
202#[cfg(test)]
203mod command_tests {
204 use super::*;
205 use crate::db::DB_POOL;
206 use crate::user::User;
207 use sqlx::PgPool;
208 use supp_macro::local_db_sqlx_test;
209 use tokio::sync::OnceCell;
210
211 static CONTEXT: OnceCell<()> = OnceCell::const_new();
212 static USER: OnceCell<User> = OnceCell::const_new();
213
214 async fn setup() {
215 CONTEXT
216 .get_or_init(|| async {
217 #[cfg(feature = "testlog")]
218 let _ = env_logger::builder()
219 .is_test(true)
220 .filter_level(log::LevelFilter::Trace)
221 .try_init();
222 })
223 .await;
224 USER.get_or_init(|| async { User { id: Uuid::new_v4() } })
225 .await;
226 }
227
228 const TEST_KEY_TYPE: &str = "ssh-ed25519";
229 const TEST_FP_ALICE: &str = "SHA256:alicefingerprint000000000000000000000000000";
230 const TEST_FP_BOB: &str = "SHA256:bobfingerprint00000000000000000000000000000";
231
232 #[local_db_sqlx_test]
233 async fn adds_and_lists_a_key(pool: PgPool) -> anyhow::Result<()> {
234 let user = USER.get().unwrap();
235 user.commit().await.unwrap();
236 AddSshKey::new()
237 .user_id(user.id)
238 .key_type(TEST_KEY_TYPE.to_string())
239 .key_blob(vec![1, 2, 3, 4])
240 .fingerprint(TEST_FP_ALICE.to_string())
241 .annotation("alice laptop".to_string())
242 .run()
243 .await?;
244
245 let Some(CmdResult::SshKeys(keys)) = ListSshKeys::new().user_id(user.id).run().await?
246 else {
247 panic!("expected SshKeys");
248 };
249 assert_eq!(keys.len(), 1);
250 assert_eq!(keys[0].fingerprint, TEST_FP_ALICE);
251 assert_eq!(keys[0].annotation, "alice laptop");
252 assert_eq!(keys[0].key_blob, vec![1, 2, 3, 4]);
253 }
254
255 #[local_db_sqlx_test]
256 async fn add_is_idempotent_on_fingerprint(pool: PgPool) -> anyhow::Result<()> {
257 let user = USER.get().unwrap();
258 user.commit().await.unwrap();
259 AddSshKey::new()
260 .user_id(user.id)
261 .key_type(TEST_KEY_TYPE.to_string())
262 .key_blob(vec![1, 2, 3])
263 .fingerprint(TEST_FP_ALICE.to_string())
264 .annotation("first".to_string())
265 .run()
266 .await?;
267 AddSshKey::new()
268 .user_id(user.id)
269 .key_type(TEST_KEY_TYPE.to_string())
270 .key_blob(vec![1, 2, 3])
271 .fingerprint(TEST_FP_ALICE.to_string())
272 .annotation("second".to_string())
273 .run()
274 .await?;
275
276 let Some(CmdResult::SshKeys(keys)) = ListSshKeys::new().user_id(user.id).run().await?
277 else {
278 panic!("expected SshKeys");
279 };
280 assert_eq!(keys.len(), 1, "second add should update in place");
281 assert_eq!(keys[0].annotation, "second");
282 }
283
284 #[local_db_sqlx_test]
285 async fn add_flips_ssh_enabled(pool: PgPool) -> anyhow::Result<()> {
286 let user = USER.get().unwrap();
287 user.commit().await.unwrap();
288 AddSshKey::new()
289 .user_id(user.id)
290 .key_type(TEST_KEY_TYPE.to_string())
291 .key_blob(vec![7])
292 .fingerprint(TEST_FP_ALICE.to_string())
293 .run()
294 .await?;
295 let Some(CmdResult::Uuid(uid)) = LookupUserBySshKey::new()
296 .fingerprint(TEST_FP_ALICE.to_string())
297 .run()
298 .await?
299 else {
300 panic!("expected Uuid");
301 };
302 assert_eq!(uid, user.id);
303 }
304
305 #[local_db_sqlx_test]
306 async fn remove_flips_ssh_enabled_when_last_key_goes(pool: PgPool) -> anyhow::Result<()> {
307 let user = USER.get().unwrap();
308 user.commit().await.unwrap();
309 AddSshKey::new()
310 .user_id(user.id)
311 .key_type(TEST_KEY_TYPE.to_string())
312 .key_blob(vec![1])
313 .fingerprint(TEST_FP_ALICE.to_string())
314 .run()
315 .await?;
316 RemoveSshKey::new()
317 .user_id(user.id)
318 .fingerprint(TEST_FP_ALICE.to_string())
319 .run()
320 .await?;
321 let lookup = LookupUserBySshKey::new()
322 .fingerprint(TEST_FP_ALICE.to_string())
323 .run()
324 .await?;
325 assert!(lookup.is_none(), "deleted key should not resolve");
326 }
327
328 #[local_db_sqlx_test]
329 async fn remove_keeps_ssh_enabled_when_other_keys_remain(pool: PgPool) -> anyhow::Result<()> {
330 let user = USER.get().unwrap();
331 user.commit().await.unwrap();
332 AddSshKey::new()
333 .user_id(user.id)
334 .key_type(TEST_KEY_TYPE.to_string())
335 .key_blob(vec![1])
336 .fingerprint(TEST_FP_ALICE.to_string())
337 .run()
338 .await?;
339 AddSshKey::new()
340 .user_id(user.id)
341 .key_type(TEST_KEY_TYPE.to_string())
342 .key_blob(vec![2])
343 .fingerprint(TEST_FP_BOB.to_string())
344 .run()
345 .await?;
346 RemoveSshKey::new()
347 .user_id(user.id)
348 .fingerprint(TEST_FP_ALICE.to_string())
349 .run()
350 .await?;
351 let lookup = LookupUserBySshKey::new()
353 .fingerprint(TEST_FP_BOB.to_string())
354 .run()
355 .await?;
356 assert!(matches!(lookup, Some(CmdResult::Uuid(_))));
357 }
358
359 #[local_db_sqlx_test]
360 async fn lookup_unknown_fingerprint_returns_none(pool: PgPool) -> anyhow::Result<()> {
361 let _ = USER.get().unwrap();
362 let lookup = LookupUserBySshKey::new()
363 .fingerprint("SHA256:ghost-never-registered".to_string())
364 .run()
365 .await?;
366 assert!(lookup.is_none());
367 }
368}