Lines
54.35 %
Functions
9.68 %
Branches
100 %
//! SSH key management commands.
//!
//! The SSH daemon uses these to look up and authorise incoming
//! connections; the automation CLI uses them to add / list / remove
//! keys for a user. Keeping them all server-side means both surfaces
//! share one source of truth and one set of SQL queries.
use chrono::{DateTime, Utc};
use serde::Serialize;
use sqlx::types::Uuid;
use std::fmt::Debug;
use supp_macro::command;
use super::{CmdError, CmdResult};
use crate::{config::ConfigError, db::get_connection};
/// Wire-format record of an SSH public key. Lives in
/// `server::command` so consumers don't reach into the DB layer
/// directly.
#[derive(Debug, Clone, Serialize)]
pub struct SshKeyRecord {
pub id: Uuid,
pub user_id: Uuid,
pub key_type: String,
pub key_blob: Vec<u8>,
pub fingerprint: String,
pub annotation: String,
pub created_at: DateTime<Utc>,
pub last_used_at: Option<DateTime<Utc>>,
}
// Add a key for `user_id`. Idempotent on `(user_id, fingerprint)`:
// re-adding the same key updates its annotation and leaves the
// original creation timestamp intact. Flips `users.ssh_enabled` to
// `TRUE` as a side effect so a fresh key immediately permits SSH
// login.
command! {
AddSshKey {
#[required]
user_id: Uuid,
key_type: String,
key_blob: Vec<u8>,
fingerprint: String,
#[optional]
annotation: String,
} => {
let mut conn = get_connection().await.map_err(|err| {
log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
ConfigError::DB
})?;
let annotation = annotation.unwrap_or_default();
let row = sqlx::query_file!(
"sql/insert/ssh_keys/add.sql",
user_id,
key_type,
key_blob,
fingerprint,
annotation,
)
.fetch_one(&mut *conn)
.await?;
sqlx::query_file!("sql/update/users/set_ssh_enabled.sql", user_id, true)
.execute(&mut *conn)
Ok(Some(CmdResult::Uuid(row.id)))
// List all keys for `user_id`, sorted by creation time (oldest
// first).
ListSshKeys {
let rows = sqlx::query_file!(
"sql/select/ssh_keys/list_for_user.sql",
user_id
.fetch_all(&mut *conn)
let keys = rows
.into_iter()
.map(|r| SshKeyRecord {
id: r.id,
user_id: r.user_id,
key_type: r.key_type,
key_blob: r.key_blob,
fingerprint: r.fingerprint,
annotation: r.annotation,
created_at: r.created_at,
last_used_at: r.last_used_at,
})
.collect();
Ok(Some(CmdResult::SshKeys(keys)))
// Remove a key by `(user_id, fingerprint)`. When the user has no
// remaining keys, flips `ssh_enabled` back to `FALSE` so the account
// doesn't retain the password-auth bypass left over from adding a
// key.
RemoveSshKey {
sqlx::query_file!(
"sql/delete/ssh_keys/by_fingerprint.sql",
fingerprint
let remaining = sqlx::query_file!(
"sql/select/ssh_keys/count_for_user.sql",
.await?
.count
.unwrap_or(0);
if remaining == 0 {
sqlx::query_file!("sql/update/users/set_ssh_enabled.sql", user_id, false)
Ok(Some(CmdResult::Bool(true)))
// Returns `Bool(true)` when `user_id` has at least one registered
// public key. Drives the password-once gate at the SSH daemon: once
// any key is on file, password auth for that user is permanently
// rejected.
UserHasSshKey {
let count = sqlx::query_file!(
Ok(Some(CmdResult::Bool(count > 0)))
// Resolve a fingerprint to a user id. Returns `None` when the
// fingerprint is unknown or the user has `ssh_enabled = FALSE`.
// Used by the SSH daemon's publickey auth callback. Stamps
// `last_used_at` on success so operators can spot unused keys.
LookupUserBySshKey {
let Some(row) = sqlx::query_file!(
"sql/select/ssh_keys/lookup_user.sql",
.fetch_optional(&mut *conn)
else {
return Ok(None);
};
if !row.ssh_enabled {
sqlx::query_file!("sql/update/ssh_keys/touch_last_used.sql", fingerprint)
Ok(Some(CmdResult::Uuid(row.user_id)))
#[cfg(test)]
mod command_tests {
use super::*;
use crate::db::DB_POOL;
use crate::user::User;
use sqlx::PgPool;
use supp_macro::local_db_sqlx_test;
use tokio::sync::OnceCell;
static CONTEXT: OnceCell<()> = OnceCell::const_new();
static USER: OnceCell<User> = OnceCell::const_new();
async fn setup() {
CONTEXT
.get_or_init(|| async {
#[cfg(feature = "testlog")]
let _ = env_logger::builder()
.is_test(true)
.filter_level(log::LevelFilter::Trace)
.try_init();
.await;
USER.get_or_init(|| async { User { id: Uuid::new_v4() } })
const TEST_KEY_TYPE: &str = "ssh-ed25519";
const TEST_FP_ALICE: &str = "SHA256:alicefingerprint000000000000000000000000000";
const TEST_FP_BOB: &str = "SHA256:bobfingerprint00000000000000000000000000000";
#[local_db_sqlx_test]
async fn adds_and_lists_a_key(pool: PgPool) -> anyhow::Result<()> {
let user = USER.get().unwrap();
user.commit().await.unwrap();
AddSshKey::new()
.user_id(user.id)
.key_type(TEST_KEY_TYPE.to_string())
.key_blob(vec![1, 2, 3, 4])
.fingerprint(TEST_FP_ALICE.to_string())
.annotation("alice laptop".to_string())
.run()
let Some(CmdResult::SshKeys(keys)) = ListSshKeys::new().user_id(user.id).run().await?
panic!("expected SshKeys");
assert_eq!(keys.len(), 1);
assert_eq!(keys[0].fingerprint, TEST_FP_ALICE);
assert_eq!(keys[0].annotation, "alice laptop");
assert_eq!(keys[0].key_blob, vec![1, 2, 3, 4]);
async fn add_is_idempotent_on_fingerprint(pool: PgPool) -> anyhow::Result<()> {
.key_blob(vec![1, 2, 3])
.annotation("first".to_string())
.annotation("second".to_string())
assert_eq!(keys.len(), 1, "second add should update in place");
assert_eq!(keys[0].annotation, "second");
async fn add_flips_ssh_enabled(pool: PgPool) -> anyhow::Result<()> {
.key_blob(vec![7])
let Some(CmdResult::Uuid(uid)) = LookupUserBySshKey::new()
panic!("expected Uuid");
assert_eq!(uid, user.id);
async fn remove_flips_ssh_enabled_when_last_key_goes(pool: PgPool) -> anyhow::Result<()> {
.key_blob(vec![1])
RemoveSshKey::new()
let lookup = LookupUserBySshKey::new()
assert!(lookup.is_none(), "deleted key should not resolve");
async fn remove_keeps_ssh_enabled_when_other_keys_remain(pool: PgPool) -> anyhow::Result<()> {
.key_blob(vec![2])
.fingerprint(TEST_FP_BOB.to_string())
// Bob still resolves — ssh_enabled must still be true.
assert!(matches!(lookup, Some(CmdResult::Uuid(_))));
async fn lookup_unknown_fingerprint_returns_none(pool: PgPool) -> anyhow::Result<()> {
let _ = USER.get().unwrap();
.fingerprint("SHA256:ghost-never-registered".to_string())
assert!(lookup.is_none());