Lines
55.1 %
Functions
24.71 %
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::Connection;
use sqlx::types::Uuid;
use std::fmt::Debug;
use supp_macro::command;
use super::{CmdError, CmdResult};
use crate::{config::ConfigError, db::get_connection};
// Adding a key flips `ssh_enabled` back to TRUE, so a switched-off account must
// not reach this write. Gating at the write rather than in the daemon covers
// ssh-copy-id, the TUI's `CliSshKeyAdd`/`CliSshKeyRemove` and the web page
// alike. Locks the row, so it must run inside the writing transaction.
async fn require_verified(conn: &mut sqlx::PgConnection, user_id: Uuid) -> Result<(), CmdError> {
match sqlx::query_file_scalar!("sql/select/users/verified.sql", user_id)
.fetch_optional(conn)
.await?
{
Some(true) => Ok(()),
_ => Err(CmdError::Disabled),
}
/// 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 mut tx = conn.begin().await?;
require_verified(&mut tx, user_id).await?;
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 *tx)
.await
.map_err(refuse_a_key_held_elsewhere)?;
sqlx::query_file!("sql/update/users/set_ssh_enabled.sql", user_id, true)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(Some(CmdResult::Uuid(row.id)))
// List all keys for `user_id`, sorted by creation time (oldest
// first).
/// A public key names exactly one account, so adding one that another account
/// already holds is the caller's mistake, not a database fault.
fn refuse_a_key_held_elsewhere(error: sqlx::Error) -> CmdError {
if let sqlx::Error::Database(db) = &error
&& db.is_unique_violation()
&& db.constraint() == Some("user_ssh_keys_fingerprint_key")
return CmdError::Args(
t!("this SSH key is already registered to another account").to_string(),
);
CmdError::DB(error)
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",
.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!(
.fetch_one(&mut *conn)
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 {
// The fingerprint is unique (migration 0007), so this is at most one row.
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 || !row.verified {
"sql/update/ssh_keys/touch_last_used.sql",
row.user_id
.execute(&mut *conn)
Ok(Some(CmdResult::Uuid(row.user_id)))
#[cfg(test)]
mod command_tests;