Menu
Public repository / main
AkurAI-Build / src/ssh_keys.rs
Latest change e8aa34a9be50d7d7ea72f66991ff82dfca7d23e1 - OLI-276: Git over SSH, forks and mirrors, code search through akurai-index, repository insights by Ólafur Búi Ólafsson
//! SSH public keys per user (OLI-276). Keys are stored once (by body), listed
//! and removed by their owner, and rendered into an `authorized_keys` file whose
//! every line forces the forge's `git-shell`, so the SSH account can only ever
//! run `git upload-pack` / `git receive-pack` on hosted repositories.
use std::{fs, path::Path};
use anyhow::{Context, Result, bail, ensure};
use rusqlite::{OptionalExtension, params};
use serde::Serialize;
use sha2::{Digest, Sha256};
use crate::{base64, db::Database};
const KEY_TYPES: &[&str] = &[
"ssh-ed25519",
"ssh-rsa",
"ecdsa-sha2-nistp256",
"ecdsa-sha2-nistp384",
"ecdsa-sha2-nistp521",
"sk-ssh-ed25519@openssh.com",
"sk-ecdsa-sha2-nistp256@openssh.com",
];
const MIN_RSA_BITS: usize = 2048;
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct SshKey {
pub id: i64,
pub subject: String,
pub name: String,
pub key_type: String,
pub fingerprint: String,
pub created_at: i64,
pub last_used_at: Option<i64>,
}
/// A validated `type base64 [comment]` line.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ParsedKey {
pub key_type: String,
pub body: String,
pub fingerprint: String,
}
/// Parse one OpenSSH public key line: a known type, a base64 blob whose
/// embedded type matches, and (for RSA) a modulus of at least 2048 bits.
pub fn parse_public_key(text: &str) -> Result<ParsedKey> {
let mut parts = text.split_whitespace();
let (Some(key_type), Some(body)) = (parts.next(), parts.next()) else {
bail!("an SSH public key looks like `ssh-ed25519 AAAA... comment`");
};
ensure!(
KEY_TYPES.contains(&key_type),
"unsupported key type {key_type}; use ed25519, ecdsa or rsa (2048 bits or more)"
);
ensure!(body.len() <= 4096, "SSH public key is too long");
let blob = base64::decode(body).context("SSH public key is not valid base64")?;
let fields = blob_fields(&blob).context("SSH public key blob is malformed")?;
ensure!(
fields
.first()
.is_some_and(|first| first == key_type.as_bytes()),
"SSH public key blob does not match its type"
);
if key_type == "ssh-rsa" {
let modulus = fields.get(2).context("RSA key without a modulus")?;
let bits = modulus
.iter()
.skip_while(|byte| **byte == 0)
.count()
.saturating_mul(8);
ensure!(bits >= MIN_RSA_BITS, "RSA keys need at least 2048 bits");
}
Ok(ParsedKey {
key_type: key_type.to_owned(),
body: body.to_owned(),
fingerprint: format!("SHA256:{}", base64::encode_unpadded(&Sha256::digest(&blob))),
})
}
/// The length-prefixed strings an OpenSSH key blob is made of.
fn blob_fields(blob: &[u8]) -> Option<Vec<Vec<u8>>> {
let mut fields = Vec::new();
let mut rest = blob;
while !rest.is_empty() {
let (len, tail) = rest.split_first_chunk::<4>()?;
let len = u32::from_be_bytes(*len) as usize;
if len > tail.len() {
return None;
}
fields.push(tail[..len].to_vec());
rest = &tail[len..];
}
(!fields.is_empty()).then_some(fields)
}
pub fn add_key(database: &Database, subject: &str, name: &str, text: &str) -> Result<SshKey> {
ensure!(
!subject.trim().is_empty() && subject.len() <= 256,
"subject must be 1 to 256 bytes"
);
ensure!(
!name.trim().is_empty() && name.len() <= 128,
"key name must be 1 to 128 bytes"
);
let parsed = parse_public_key(text)?;
let connection = database.connection()?;
let existing: Option<i64> = connection
.query_row(
"SELECT id FROM ssh_keys WHERE key_body = ?1",
[&parsed.body],
|row| row.get(0),
)
.optional()?;
ensure!(existing.is_none(), "this SSH key is already registered");
connection.execute(
"INSERT INTO ssh_keys(subject, name, key_type, key_body, fingerprint) VALUES (?1, ?2, ?3, ?4, ?5)",
params![subject, name.trim(), parsed.key_type, parsed.body, parsed.fingerprint],
)?;
let id = connection.last_insert_rowid();
drop(connection);
key(database, id)?.context("SSH key vanished after insert")
}
pub fn key(database: &Database, id: i64) -> Result<Option<SshKey>> {
Ok(database
.connection()?
.query_row(
"SELECT id, subject, name, key_type, fingerprint, created_at, last_used_at
FROM ssh_keys WHERE id = ?1",
[id],
map_key,
)
.optional()?)
}
pub fn keys_of(database: &Database, subject: &str) -> Result<Vec<SshKey>> {
let connection = database.connection()?;
let mut statement = connection.prepare(
"SELECT id, subject, name, key_type, fingerprint, created_at, last_used_at
FROM ssh_keys WHERE subject = ?1 ORDER BY id",
)?;
Ok(statement
.query_map([subject], map_key)?
.collect::<rusqlite::Result<_>>()?)
}
/// Remove `id` when `subject` owns it; `Ok(false)` when it is not theirs.
pub fn remove_key(database: &Database, subject: &str, id: i64) -> Result<bool> {
Ok(database.connection()?.execute(
"DELETE FROM ssh_keys WHERE id = ?1 AND subject = ?2",
params![id, subject],
)? == 1)
}
pub fn touch(database: &Database, id: i64, now: i64) -> Result<()> {
database.connection()?.execute(
"UPDATE ssh_keys SET last_used_at = ?2 WHERE id = ?1",
params![id, now],
)?;
Ok(())
}
fn map_key(row: &rusqlite::Row<'_>) -> rusqlite::Result<SshKey> {
Ok(SshKey {
id: row.get(0)?,
subject: row.get(1)?,
name: row.get(2)?,
key_type: row.get(3)?,
fingerprint: row.get(4)?,
created_at: row.get(5)?,
last_used_at: row.get(6)?,
})
}
/// How every authorized key is forced into the forge's shell.
#[derive(Clone, Debug)]
pub struct ShellCommand {
/// The forge binary and its global arguments, e.g. `/usr/lib/akurai --data D --key-file K`.
pub prefix: Vec<String>,
/// Arguments after `git-shell --key-id N`, e.g. `--token-file T --forge-url U`.
pub suffix: Vec<String>,
}
fn quote(argument: &str) -> String {
// authorized_keys commands sit inside double quotes; keep them shell-safe.
if argument
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"/._-:=@,".contains(&b))
{
argument.to_owned()
} else {
format!("'{}'", argument.replace('\'', "'\\''"))
}
}
/// Render the whole `authorized_keys` content: one `restrict`ed line per key.
pub fn authorized_keys(database: &Database, shell: &ShellCommand) -> Result<String> {
let connection = database.connection()?;
let mut statement =
connection.prepare("SELECT id, subject, key_type, key_body FROM ssh_keys ORDER BY id")?;
let rows = statement
.query_map([], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
))
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
let mut out = String::from(
"# Managed by AkurAI Build: every key is forced into `akurai git-shell`. Do not edit.\n",
);
for (id, subject, key_type, body) in rows {
let mut command: Vec<String> = shell.prefix.iter().map(|a| quote(a)).collect();
command.push("git-shell".into());
command.push("--key-id".into());
command.push(id.to_string());
command.extend(shell.suffix.iter().map(|a| quote(a)));
let comment: String = subject
.chars()
.filter(|c| !c.is_whitespace() && !c.is_control())
.collect();
out.push_str(&format!(
"command=\"{}\",restrict {key_type} {body} akurai-build:{comment}\n",
command.join(" ").replace('"', "\\\"")
));
}
Ok(out)
}
/// Write the file atomically with owner-only permissions.
pub fn write_authorized_keys(path: &Path, content: &str) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let temp = path.with_extension("tmp");
fs::write(&temp, content)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&temp, fs::Permissions::from_mode(0o600))?;
}
fs::rename(&temp, path)?;
Ok(())
}
#[cfg(test)]
pub(crate) mod testing {
/// A real ed25519 public key, generated for the tests only.
pub const ED25519: &str = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGtBqWmJ1v+1AbrlAt4w7EqzPgM4tMUgwFvYZdbQ6N2G test@example";
}
#[cfg(test)]
mod tests {
use super::*;
const KEY: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
fn database() -> Database {
Database::memory(KEY).expect("db")
}
#[test]
fn parses_ed25519_and_computes_the_openssh_fingerprint() {
let parsed = parse_public_key(testing::ED25519).expect("parse");
assert_eq!(parsed.key_type, "ssh-ed25519");
assert!(parsed.fingerprint.starts_with("SHA256:"));
assert_eq!(parsed.fingerprint.len(), "SHA256:".len() + 43);
// The same key with a different comment is the same key.
let again = parse_public_key(
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGtBqWmJ1v+1AbrlAt4w7EqzPgM4tMUgwFvYZdbQ6N2G",
)
.expect("parse");
assert_eq!(again.fingerprint, parsed.fingerprint);
}
#[test]
fn rejects_unknown_types_bad_blobs_and_short_rsa() {
assert!(parse_public_key("ssh-dss AAAAB3NzaC1kc3MAAACBAP").is_err());
assert!(parse_public_key("ssh-ed25519 not*base64").is_err());
assert!(parse_public_key("ssh-ed25519").is_err());
// type says rsa, blob says ed25519
let swapped = testing::ED25519.replacen("ssh-ed25519", "ssh-rsa", 1);
assert!(parse_public_key(&swapped).is_err());
// a 1024-bit RSA key: blob = "ssh-rsa", e=65537, 128-byte modulus
let mut blob = Vec::new();
for field in [&b"ssh-rsa"[..], &[1, 0, 1][..], &[0x80; 128][..]] {
blob.extend((field.len() as u32).to_be_bytes());
blob.extend(field);
}
let weak = format!("ssh-rsa {}", base64::encode(&blob));
let error = parse_public_key(&weak).expect_err("weak rsa");
assert!(error.to_string().contains("2048"), "{error}");
}
#[test]
fn keys_are_owned_listed_deduplicated_and_removed_by_their_owner() {
let db = database();
let key = add_key(&db, "ada@example", "laptop", testing::ED25519).expect("add");
assert_eq!(key.subject, "ada@example");
assert_eq!(key.name, "laptop");
assert_eq!(
keys_of(&db, "ada@example").expect("list"),
vec![key.clone()]
);
assert!(keys_of(&db, "bob@example").expect("list").is_empty());
let duplicate = add_key(&db, "bob@example", "copy", testing::ED25519).expect_err("dup");
assert!(duplicate.to_string().contains("already registered"));
assert!(!remove_key(&db, "bob@example", key.id).expect("remove"));
touch(&db, key.id, 42).expect("touch");
assert_eq!(
super::key(&db, key.id)
.expect("get")
.and_then(|k| k.last_used_at),
Some(42)
);
assert!(remove_key(&db, "ada@example", key.id).expect("remove"));
assert!(super::key(&db, key.id).expect("get").is_none());
}
#[test]
fn authorized_keys_force_the_git_shell_for_every_key() {
let db = database();
let key = add_key(&db, "ada@example", "laptop", testing::ED25519).expect("add");
let shell = ShellCommand {
prefix: vec!["/opt/akurai".into(), "--data".into(), "/var/lib/a b".into()],
suffix: vec!["--token-file".into(), "/etc/t".into()],
};
let text = authorized_keys(&db, &shell).expect("render");
let line = text.lines().nth(1).expect("one key line");
assert_eq!(
line,
format!(
"command=\"/opt/akurai --data '/var/lib/a b' git-shell --key-id {} --token-file /etc/t\",restrict ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGtBqWmJ1v+1AbrlAt4w7EqzPgM4tMUgwFvYZdbQ6N2G akurai-build:ada@example",
key.id
)
);
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("keys").join("akurai_build_keys");
write_authorized_keys(&path, &text).expect("write");
assert_eq!(fs::read_to_string(&path).expect("read"), text);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(
fs::metadata(&path).expect("meta").permissions().mode() & 0o777,
0o600
);
}
}
}