AkurAI Build
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
            );
        }
    }
}