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::Connection;
11
use sqlx::types::Uuid;
12
use std::fmt::Debug;
13
use supp_macro::command;
14

            
15
use super::{CmdError, CmdResult};
16
use crate::{config::ConfigError, db::get_connection};
17

            
18
// Adding a key flips `ssh_enabled` back to TRUE, so a switched-off account must
19
// not reach this write. Gating at the write rather than in the daemon covers
20
// ssh-copy-id, the TUI's `CliSshKeyAdd`/`CliSshKeyRemove` and the web page
21
// alike. Locks the row, so it must run inside the writing transaction.
22
94
async fn require_verified(conn: &mut sqlx::PgConnection, user_id: Uuid) -> Result<(), CmdError> {
23
68
    match sqlx::query_file_scalar!("sql/select/users/verified.sql", user_id)
24
68
        .fetch_optional(conn)
25
68
        .await?
26
    {
27
66
        Some(true) => Ok(()),
28
2
        _ => Err(CmdError::Disabled),
29
    }
30
68
}
31

            
32
/// Wire-format record of an SSH public key. Lives in
33
/// `server::command` so consumers don't reach into the DB layer
34
/// directly.
35
#[derive(Debug, Clone, Serialize)]
36
pub struct SshKeyRecord {
37
    pub id: Uuid,
38
    pub user_id: Uuid,
39
    pub key_type: String,
40
    pub key_blob: Vec<u8>,
41
    pub fingerprint: String,
42
    pub annotation: String,
43
    pub created_at: DateTime<Utc>,
44
    pub last_used_at: Option<DateTime<Utc>>,
45
}
46

            
47
// Add a key for `user_id`. Idempotent on `(user_id, fingerprint)`:
48
// re-adding the same key updates its annotation and leaves the
49
// original creation timestamp intact. Flips `users.ssh_enabled` to
50
// `TRUE` as a side effect so a fresh key immediately permits SSH
51
// login.
52
command! {
53
    AddSshKey {
54
        #[required]
55
        user_id: Uuid,
56
        #[required]
57
        key_type: String,
58
        #[required]
59
        key_blob: Vec<u8>,
60
        #[required]
61
        fingerprint: String,
62
        #[optional]
63
        annotation: String,
64
    } => {
65
        let mut conn = get_connection().await.map_err(|err| {
66
            log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
67
            ConfigError::DB
68
        })?;
69
        let mut tx = conn.begin().await?;
70
        require_verified(&mut tx, user_id).await?;
71
        let annotation = annotation.unwrap_or_default();
72
        let row = sqlx::query_file!(
73
            "sql/insert/ssh_keys/add.sql",
74
            user_id,
75
            key_type,
76
            key_blob,
77
            fingerprint,
78
            annotation,
79
        )
80
        .fetch_one(&mut *tx)
81
        .await
82
        .map_err(refuse_a_key_held_elsewhere)?;
83
        sqlx::query_file!("sql/update/users/set_ssh_enabled.sql", user_id, true)
84
            .execute(&mut *tx)
85
            .await?;
86
        tx.commit().await?;
87
        Ok(Some(CmdResult::Uuid(row.id)))
88
    }
89
291
}
90

            
91
// List all keys for `user_id`, sorted by creation time (oldest
92
// first).
93
/// A public key names exactly one account, so adding one that another account
94
/// already holds is the caller's mistake, not a database fault.
95
1
fn refuse_a_key_held_elsewhere(error: sqlx::Error) -> CmdError {
96
1
    if let sqlx::Error::Database(db) = &error
97
1
        && db.is_unique_violation()
98
1
        && db.constraint() == Some("user_ssh_keys_fingerprint_key")
99
    {
100
        return CmdError::Args(
101
1
            t!("this SSH key is already registered to another account").to_string(),
102
        );
103
    }
104
    CmdError::DB(error)
105
1
}
106

            
107
command! {
108
    ListSshKeys {
109
        #[required]
110
        user_id: Uuid,
111
    } => {
112
        let mut conn = get_connection().await.map_err(|err| {
113
            log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
114
            ConfigError::DB
115
        })?;
116
        let rows = sqlx::query_file!(
117
            "sql/select/ssh_keys/list_for_user.sql",
118
            user_id
119
        )
120
        .fetch_all(&mut *conn)
121
        .await?;
122
        let keys = rows
123
            .into_iter()
124
            .map(|r| SshKeyRecord {
125
3
                id: r.id,
126
3
                user_id: r.user_id,
127
3
                key_type: r.key_type,
128
3
                key_blob: r.key_blob,
129
3
                fingerprint: r.fingerprint,
130
3
                annotation: r.annotation,
131
3
                created_at: r.created_at,
132
3
                last_used_at: r.last_used_at,
133
3
            })
134
            .collect();
135
        Ok(Some(CmdResult::SshKeys(keys)))
136
    }
137
113
}
138

            
139
// Remove a key by `(user_id, fingerprint)`. When the user has no
140
// remaining keys, flips `ssh_enabled` back to `FALSE` so the account
141
// doesn't retain the password-auth bypass left over from adding a
142
// key.
143
command! {
144
    RemoveSshKey {
145
        #[required]
146
        user_id: Uuid,
147
        #[required]
148
        fingerprint: String,
149
    } => {
150
        let mut conn = get_connection().await.map_err(|err| {
151
            log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
152
            ConfigError::DB
153
        })?;
154
        let mut tx = conn.begin().await?;
155
        require_verified(&mut tx, user_id).await?;
156
        sqlx::query_file!(
157
            "sql/delete/ssh_keys/by_fingerprint.sql",
158
            user_id,
159
            fingerprint
160
        )
161
        .execute(&mut *tx)
162
        .await?;
163
        let remaining = sqlx::query_file!(
164
            "sql/select/ssh_keys/count_for_user.sql",
165
            user_id
166
        )
167
        .fetch_one(&mut *tx)
168
        .await?
169
        .count
170
        .unwrap_or(0);
171
        if remaining == 0 {
172
            sqlx::query_file!("sql/update/users/set_ssh_enabled.sql", user_id, false)
173
                .execute(&mut *tx)
174
                .await?;
175
        }
176
        tx.commit().await?;
177
        Ok(Some(CmdResult::Bool(true)))
178
    }
179
155
}
180

            
181
// Returns `Bool(true)` when `user_id` has at least one registered
182
// public key. Drives the password-once gate at the SSH daemon: once
183
// any key is on file, password auth for that user is permanently
184
// rejected.
185
command! {
186
    UserHasSshKey {
187
        #[required]
188
        user_id: Uuid,
189
    } => {
190
        let mut conn = get_connection().await.map_err(|err| {
191
            log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
192
            ConfigError::DB
193
        })?;
194
        let count = sqlx::query_file!(
195
            "sql/select/ssh_keys/count_for_user.sql",
196
            user_id
197
        )
198
        .fetch_one(&mut *conn)
199
        .await?
200
        .count
201
        .unwrap_or(0);
202
        Ok(Some(CmdResult::Bool(count > 0)))
203
    }
204
104
}
205

            
206
// Resolve a fingerprint to a user id. Returns `None` when the
207
// fingerprint is unknown or the user has `ssh_enabled = FALSE`.
208
// Used by the SSH daemon's publickey auth callback. Stamps
209
// `last_used_at` on success so operators can spot unused keys.
210
command! {
211
    LookupUserBySshKey {
212
        #[required]
213
        fingerprint: String,
214
    } => {
215
        let mut conn = get_connection().await.map_err(|err| {
216
            log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
217
            ConfigError::DB
218
        })?;
219
        // The fingerprint is unique (migration 0007), so this is at most one row.
220
        let Some(row) = sqlx::query_file!(
221
            "sql/select/ssh_keys/lookup_user.sql",
222
            fingerprint
223
        )
224
        .fetch_optional(&mut *conn)
225
        .await?
226
        else {
227
            return Ok(None);
228
        };
229
        if !row.ssh_enabled || !row.verified {
230
            return Ok(None);
231
        }
232
        sqlx::query_file!(
233
            "sql/update/ssh_keys/touch_last_used.sql",
234
            fingerprint,
235
            row.user_id
236
        )
237
            .execute(&mut *conn)
238
            .await?;
239
        Ok(Some(CmdResult::Uuid(row.user_id)))
240
    }
241
229
}
242

            
243
#[cfg(test)]
244
mod command_tests;