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

            
8
use chrono::{DateTime, Utc};
9
use serde::Serialize;
10
use sqlx::types::Uuid;
11
use std::fmt::Debug;
12
use supp_macro::command;
13

            
14
use super::{CmdError, CmdResult};
15
use 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)]
21
pub 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.
37
command! {
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
45
}
71

            
72
// List all keys for `user_id`, sorted by creation time (oldest
73
// first).
74
command! {
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
2
                id: r.id,
93
2
                user_id: r.user_id,
94
2
                key_type: r.key_type,
95
2
                key_blob: r.key_blob,
96
2
                fingerprint: r.fingerprint,
97
2
                annotation: r.annotation,
98
2
                created_at: r.created_at,
99
2
                last_used_at: r.last_used_at,
100
2
            })
101
            .collect();
102
        Ok(Some(CmdResult::SshKeys(keys)))
103
    }
104
6
}
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.
110
command! {
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
8
}
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.
149
command! {
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.
174
command! {
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
12
}
201

            
202
#[cfg(test)]
203
mod 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
6
    async fn setup() {
215
6
        CONTEXT
216
6
            .get_or_init(|| async {
217
                #[cfg(feature = "testlog")]
218
1
                let _ = env_logger::builder()
219
1
                    .is_test(true)
220
1
                    .filter_level(log::LevelFilter::Trace)
221
1
                    .try_init();
222
2
            })
223
6
            .await;
224
6
        USER.get_or_init(|| async { User { id: Uuid::new_v4() } })
225
6
            .await;
226
6
    }
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
}