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::Connection;
11use sqlx::types::Uuid;
12use std::fmt::Debug;
13use supp_macro::command;
14
15use super::{CmdError, CmdResult};
16use 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.
22async fn require_verified(conn: &mut sqlx::PgConnection, user_id: Uuid) -> Result<(), CmdError> {
23    match sqlx::query_file_scalar!("sql/select/users/verified.sql", user_id)
24        .fetch_optional(conn)
25        .await?
26    {
27        Some(true) => Ok(()),
28        _ => Err(CmdError::Disabled),
29    }
30}
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)]
36pub 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.
52command! {
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}
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.
95fn refuse_a_key_held_elsewhere(error: sqlx::Error) -> CmdError {
96    if let sqlx::Error::Database(db) = &error
97        && db.is_unique_violation()
98        && db.constraint() == Some("user_ssh_keys_fingerprint_key")
99    {
100        return CmdError::Args(
101            t!("this SSH key is already registered to another account").to_string(),
102        );
103    }
104    CmdError::DB(error)
105}
106
107command! {
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                id: r.id,
126                user_id: r.user_id,
127                key_type: r.key_type,
128                key_blob: r.key_blob,
129                fingerprint: r.fingerprint,
130                annotation: r.annotation,
131                created_at: r.created_at,
132                last_used_at: r.last_used_at,
133            })
134            .collect();
135        Ok(Some(CmdResult::SshKeys(keys)))
136    }
137}
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.
143command! {
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}
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.
185command! {
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}
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.
210command! {
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}
242
243#[cfg(test)]
244mod command_tests;