Skip to main content

server/command/
ssh_key.rs

1//! SSH key management commands.
2//!
3//! The SSH daemon uses these to look up and authorise incoming
4//! connections; the automation CLI uses them to add / list / remove
5//! keys for a user. Keeping them all server-side means both surfaces
6//! share one source of truth and one set of SQL queries.
7
8use 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/// Wire-format record of an SSH public key. Lives in
18/// `server::command` so consumers don't reach into the DB layer
19/// directly.
20#[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
32// Add a key for `user_id`. Idempotent on `(user_id, fingerprint)`:
33// re-adding the same key updates its annotation and leaves the
34// original creation timestamp intact. Flips `users.ssh_enabled` to
35// `TRUE` as a side effect so a fresh key immediately permits SSH
36// login.
37command! {
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
72// List all keys for `user_id`, sorted by creation time (oldest
73// first).
74command! {
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
106// Remove a key by `(user_id, fingerprint)`. When the user has no
107// remaining keys, flips `ssh_enabled` back to `FALSE` so the account
108// doesn't retain the password-auth bypass left over from adding a
109// key.
110command! {
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
145// Returns `Bool(true)` when `user_id` has at least one registered
146// public key. Drives the password-once gate at the SSH daemon: once
147// any key is on file, password auth for that user is permanently
148// rejected.
149command! {
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
170// Resolve a fingerprint to a user id. Returns `None` when the
171// fingerprint is unknown or the user has `ssh_enabled = FALSE`.
172// Used by the SSH daemon's publickey auth callback. Stamps
173// `last_used_at` on success so operators can spot unused keys.
174command! {
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        // Bob still resolves — ssh_enabled must still be true.
352        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}