//! Adding and removing the SSH keys a user connects with.
//!
//! A key is stored in the canonical OpenSSH one-line form, not as it was
//! pasted, so that what the server shows back is exactly what the server
//! holds, and the fingerprint - which is what a connection is looked up by -
//! is computed here rather than trusted from the request.
use std::sync::Arc;
use actix_web::{HttpResponse, get, post, web};
use russh::keys::HashAlg;
use russh::keys::ssh_key::PublicKey;
use sea_orm::ActiveValue::Set;
use sea_orm::{ActiveModelTrait, ColumnTrait, DbErr, EntityTrait, QueryFilter};
use serde::Deserialize;
use tokio::sync::RwLock;
use tracing::error;
use crate::AppState;
use crate::auth::CurrentUser;
use crate::entities::ssh_keys;
use crate::state_read;
use crate::views::{self, SshKeyForm, SshKeySummary};
use crate::webui::repositories::acting_user;
/// What the add-key form posts.
#[derive(Debug, Deserialize)]
pub struct NewSshKey {
key: String,
}
/// The longest a key's name (its comment) is kept. The column allows 255; a
/// comment longer than this is trimmed rather than refused.
const MAX_KEY_NAME: usize = 255;
#[get("/~{user_or_team}/ssh-keys")]
#[tracing::instrument(skip_all, fields(user_or_team = %user_or_team.as_str()))]
async fn get_ssh_keys(
data: web::Data<Arc<RwLock<AppState>>>,
current: CurrentUser,
user_or_team: web::Path<String>,
) -> HttpResponse {
let user = match acting_user(&data, ¤t, user_or_team.as_str()).await {
Ok(user) => user,
Err(response) => return *response,
};
let keys = {
let state = state_read(&data).await;
match list_keys(&state, user.id).await {
Ok(keys) => keys,
Err(error) => {
error!(%error, owner = %user.name, "listing SSH keys failed");
return HttpResponse::InternalServerError().finish();
}
}
};
views::ssh_keys(
Some(¤t.name),
&user.name,
&keys,
&SshKeyForm::default(),
)
}
#[post("/~{user_or_team}/ssh-keys")]
#[tracing::instrument(skip_all, fields(user_or_team = %user_or_team.as_str()))]
async fn post_ssh_key(
data: web::Data<Arc<RwLock<AppState>>>,
current: CurrentUser,
user_or_team: web::Path<String>,
form: web::Form<NewSshKey>,
) -> HttpResponse {
let user = match acting_user(&data, ¤t, user_or_team.as_str()).await {
Ok(user) => user,
Err(response) => return *response,
};
let pasted = form.into_inner().key;
let trimmed = pasted.trim();
// Parsing here rather than storing the text as given: a key that does not
// parse would sit in the table looking fine while never authenticating
// anything, which is the worst of both.
let key = match PublicKey::from_openssh(trimmed) {
Ok(key) => key,
Err(error) => {
return views::ssh_keys(
Some(¤t.name),
&user.name,
&list_or_empty(&data, user.id).await,
&SshKeyForm {
key: Some(pasted),
error: Some(format!(
"That is not a public key this understands ({error}). Paste the \
contents of a `.pub` file, or the output of `ssh-add -L`."
)),
},
);
}
};
let fingerprint = key.fingerprint(HashAlg::Sha256).to_string();
let algorithm = key.algorithm().to_string();
// The comment is what the user called the key, and is optional. Falling
// back to the fingerprint keeps the list readable when it is empty.
let name = {
let comment = key.comment().to_string();
let comment = comment.trim();
if comment.is_empty() {
fingerprint.clone()
} else {
comment.chars().take(MAX_KEY_NAME).collect()
}
};
let stored = key.to_openssh().unwrap_or_else(|_| trimmed.to_string());
let state = state_read(&data).await;
let existing = match ssh_keys::Entity::find()
.filter(ssh_keys::Column::Fingerprint.eq(&fingerprint))
.one(&state.db)
.await
{
Ok(existing) => existing,
Err(error) => {
error!(%error, user = %user.name, "checking for an existing SSH key failed");
drop(state);
return HttpResponse::InternalServerError().finish();
}
};
if let Some(existing) = existing {
let message = if existing.user_id == user.id {
"That key is already on your account.".to_string()
} else {
// Deliberately vague about whose it is, but not silent: the person
// pasting it should know it will not work for them.
"That key is already registered to another account.".to_string()
};
drop(state);
return views::ssh_keys(
Some(¤t.name),
&user.name,
&list_or_empty(&data, user.id).await,
&SshKeyForm {
key: Some(pasted),
error: Some(message),
},
);
}
let inserted = ssh_keys::ActiveModel {
user_id: Set(user.id),
name: Set(name),
algorithm: Set(algorithm),
fingerprint: Set(fingerprint),
public_key: Set(stored),
..Default::default()
}
.insert(&state.db)
.await;
drop(state);
match inserted {
Ok(_) => HttpResponse::SeeOther()
.append_header(("Location", format!("/~{}/ssh-keys", user.name)))
.finish(),
Err(error) => {
error!(%error, user = %user.name, "storing an SSH key failed");
HttpResponse::InternalServerError().finish()
}
}
}
#[post("/~{user_or_team}/ssh-keys/{id}/delete")]
#[tracing::instrument(
skip_all,
fields(owner = tracing::field::Empty, key_id = tracing::field::Empty)
)]
async fn post_delete_ssh_key(
data: web::Data<Arc<RwLock<AppState>>>,
current: CurrentUser,
path: web::Path<(String, i64)>,
) -> HttpResponse {
let (user_or_team, id) = path.into_inner();
let span = tracing::Span::current();
span.record("owner", user_or_team.as_str());
span.record("key_id", id);
let user = match acting_user(&data, ¤t, &user_or_team).await {
Ok(user) => user,
Err(response) => return *response,
};
let state = state_read(&data).await;
// Scoped by the owner as well as the id: the id alone would let a signed-in
// user delete somebody else's key by guessing a number.
let deleted = ssh_keys::Entity::delete_many()
.filter(ssh_keys::Column::Id.eq(id))
.filter(ssh_keys::Column::UserId.eq(user.id))
.exec(&state.db)
.await;
drop(state);
match deleted {
// No rows removed is not an error worth showing: the key is not there,
// which is what the visitor asked for.
Ok(_) => HttpResponse::SeeOther()
.append_header(("Location", format!("/~{}/ssh-keys", user.name)))
.finish(),
Err(error) => {
error!(%error, user = %user.name, id, "deleting an SSH key failed");
HttpResponse::InternalServerError().finish()
}
}
}
/// `owner_id`'s keys, as the list renders them.
///
/// A database failure is handed back as itself rather than as a response: the
/// caller decides what a page should say about it, and this is a data
/// question, not a presentation one.
async fn list_keys(state: &AppState, owner_id: i64) -> Result<Vec<SshKeySummary>, DbErr> {
let keys = ssh_keys::Entity::find()
.filter(ssh_keys::Column::UserId.eq(owner_id))
.all(&state.db)
.await?;
Ok(keys
.into_iter()
.map(|key| SshKeySummary {
id: key.id,
name: key.name,
algorithm: key.algorithm,
fingerprint: key.fingerprint,
last_used: key
.last_used_at
.map(|at| at.format("%Y-%m-%d %H:%M UTC").to_string()),
})
.collect())
}
/// The list to render a form back with, degraded to empty when it cannot be
/// read: a failure to list is worth a page without the list, not a page
/// without the form the visitor was trying to fill in.
async fn list_or_empty(
data: &web::Data<Arc<RwLock<AppState>>>,
owner_id: i64,
) -> Vec<SshKeySummary> {
let state = state_read(data).await;
list_keys(&state, owner_id).await.unwrap_or_default()
}