Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

crates/gunnar-ui-server/src/api/encryption_key.rs

//! **The person's own encryption key** (CRYPT-YUBI, owner 2026-10-26): one key
//! pair per person for every encrypted repository they are a member of.
//! Gunnar keeps its public half and its private half only as ciphertext, one
//! age file per UNLOCK METHOD (a security key, a printed recovery code, an SSH
//! key from the CLI). This console relays, signed AS THE PERSON, and holds
//! nothing: the wraps are made and opened in the person's own tab.
//!
//! * `GET    /api/account/encryption-key` — the public key and the methods.
//! * `POST   /api/account/encryption-key` — the first time: the public half
//!   and the first method (gunnar refuses a second key by name).
//! * `POST   /api/account/encryption-key/methods` — one more method.
//! * `DELETE /api/account/encryption-key/methods/{id}` — delete one (gunnar
//!   refuses the last by name).
//! * `POST   /api/account/encryption-key/replace` — a NEW key in place of the
//!   old one and all its methods, for a lost or leaked key (CRYPT-PQ-REKEY);
//!   gunnar retires the old public half for good and audits it. Members then
//!   re-key each repository (`gunnar crypt rekey`).
//!
//! Under `/api/account/` for the reason `api::account` gives: the target is
//! the name in the SESSION ([`Me`]), which no path segment or body field can
//! change, and a body that names anybody is refused rather than ignored.
//! Adding or removing a method touches no repository.

use axum::extract::{Path, State};
use axum::Json;
use serde::{Deserialize, Serialize};

use super::account::Me;
use super::{ApiError, ApiResult};
use crate::auth::AUDIT_TARGET;
use crate::grpc::Fail;
use crate::pb;
use crate::app::AppState;

/// The kinds of method gunnar takes.
pub const KINDS: &[&str] = &["ssh", "security-key", "age-pq", "recovery-code"];

/// One method, as the page reads and writes it.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Method {
    #[serde(default)]
    pub id: String,
    pub kind: String,
    #[serde(default)]
    pub label: String,
    #[serde(default)]
    pub method_ref: String,
    #[serde(default)]
    pub rp_id: String,
    pub wrap: String,
    #[serde(default)]
    pub added_ms: u64,
}

/// A method in a request: [`Method`], plus the names that are REFUSED.
#[derive(Debug, Clone, Deserialize)]
pub struct NewMethod {
    pub kind: String,
    #[serde(default)]
    pub label: String,
    #[serde(default)]
    pub method_ref: String,
    #[serde(default)]
    pub rp_id: String,
    pub wrap: String,
    #[serde(default)]
    pub principal_id: Option<String>,
    #[serde(default)]
    pub user: Option<String>,
}

/// The first time: the public half and the first method.
#[derive(Debug, Clone, Deserialize)]
pub struct NewKey {
    pub public_key: String,
    pub first: NewMethod,
    #[serde(default)]
    pub principal_id: Option<String>,
    #[serde(default)]
    pub user: Option<String>,
}

/// What every route answers: the key as gunnar holds it now.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct Key {
    pub exists: bool,
    pub public_key: String,
    pub methods: Vec<Method>,
    /// The person's retired public halves, oldest first (CRYPT-PQ-REKEY).
    #[serde(default)]
    pub retired_public_keys: Vec<String>,
    /// The encrypted repositories (`namespace/name`) gunnar says still name a retired
    /// key: each takes new content only after a member re-keys it. Gunnar's list.
    #[serde(default)]
    pub repos_naming_retired: Vec<String>,
}

fn refuse(code: &str, message: &str) -> ApiError {
    ApiError(Fail { code: code.to_owned(), message: message.to_owned() })
}

/// The page's words for a method, checked before anything is signed: a kind
/// gunnar takes, a wrap that is an armored age file (or a security key's wrap
/// v2, CRYPT-PQ-SK), and nobody else's name.
pub fn check(new: &NewMethod) -> Result<pb::UnlockMethod, ApiError> {
    if new.principal_id.is_some() || new.user.is_some() {
        return Err(refuse(
            "invalidargument",
            "this route changes the session's own encryption key and takes no other name",
        ));
    }
    if !KINDS.contains(&new.kind.as_str()) {
        return Err(refuse(
            "invalidargument",
            &format!("{:?} is not an unlock method (a security key, a recovery code, an SSH key or an age1pq1… key)", new.kind),
        ));
    }
    // A security key's wrap v2 (CRYPT-PQ-SK): symmetric, quantum-safe, not an age file.
    if gunnar_crypt::skwrap::is_v2(&new.wrap) {
        if new.kind != "security-key" {
            return Err(refuse("invalidargument", "a wrap v2 is a security key's only"));
        }
        if gunnar_crypt::skwrap::parse(&new.wrap).is_none() {
            return Err(refuse("invalidargument", "the security key's wrap v2 is not well formed"));
        }
    } else if !new.wrap.trim_start().starts_with("-----BEGIN AGE ENCRYPTED FILE-----") {
        return Err(refuse("invalidargument", "the wrap is not an armored age file"));
    }
    if new.kind == "security-key" && (new.method_ref.trim().is_empty() || new.rp_id.trim().is_empty()) {
        return Err(refuse(
            "invalidargument",
            "a security key needs its credential id and the relying party it is bound to",
        ));
    }
    Ok(pb::UnlockMethod {
        id: String::new(),
        kind: new.kind.clone(),
        label: new.label.trim().to_owned(),
        method_ref: new.method_ref.trim().to_owned(),
        rp_id: new.rp_id.trim().to_owned(),
        wrap: new.wrap.clone(),
        added_ms: 0,
    })
}

fn method_out(m: pb::UnlockMethod) -> Method {
    Method { id: m.id, kind: m.kind, label: m.label, method_ref: m.method_ref, rp_id: m.rp_id, wrap: m.wrap, added_ms: m.added_ms }
}

async fn as_the_person(state: &AppState, user: &str) -> Result<crate::grpc::Gunnar, ApiError> {
    match crate::api::person_identity(state, user).await {
        Ok(Some(identity)) => Ok(identity.gunnar),
        Ok(None) => Err(refuse("failedprecondition", "this sign-in holds no key at Gunnar any more; log in again")),
        Err(why) => Err(refuse("failedprecondition", &why.to_string())),
    }
}

/// `GET /api/account/encryption-key`.
pub async fn get(Me(user): Me, State(state): State<AppState>) -> ApiResult<Key> {
    let gunnar = as_the_person(&state, &user).await?;
    let got = gunnar
        .access()
        .get_encryption_key(pb::GetEncryptionKeyRequest {})
        .await
        .map_err(ApiError::from)?
        .into_inner();
    Ok(Json(Key {
        exists: got.exists,
        public_key: got.public_key,
        methods: got.methods.into_iter().map(method_out).collect(),
        retired_public_keys: got.retired_public_keys,
        repos_naming_retired: got.repos_naming_retired,
    }))
}

/// `POST /api/account/encryption-key`.
pub async fn create(Me(user): Me, State(state): State<AppState>, Json(new): Json<NewKey>) -> ApiResult<Key> {
    if new.principal_id.is_some() || new.user.is_some() {
        return Err(refuse("invalidargument", "this route makes the session's own encryption key and takes no other name"));
    }
    let public = new.public_key.trim().to_owned();
    if !public.starts_with("age1") {
        return Err(refuse("invalidargument", "the public key is not an age1… key"));
    }
    let first = check(&new.first)?;
    let gunnar = as_the_person(&state, &user).await?;
    let made = gunnar
        .access()
        .create_encryption_key(pb::CreateEncryptionKeyRequest { public_key: public, first: Some(first) })
        .await
        .map_err(ApiError::from)?
        .into_inner();
    tracing::info!(target: AUDIT_TARGET, event = "encryption_key.create", by = %user, "somebody made their encryption key");
    Ok(Json(Key { exists: true, public_key: made.public_key, methods: made.methods.into_iter().map(method_out).collect(), retired_public_keys: Vec::new(), repos_naming_retired: Vec::new() }))
}

/// `POST /api/account/encryption-key/replace` (CRYPT-PQ-REKEY): the same body as the
/// first time; gunnar retires the old key. The answer is the key as it is now.
pub async fn replace(Me(user): Me, State(state): State<AppState>, Json(new): Json<NewKey>) -> ApiResult<Key> {
    if new.principal_id.is_some() || new.user.is_some() {
        return Err(refuse("invalidargument", "this route replaces the session's own encryption key and takes no other name"));
    }
    let public = new.public_key.trim().to_owned();
    if !public.starts_with("age1") {
        return Err(refuse("invalidargument", "the public key is not an age1… key"));
    }
    let first = check(&new.first)?;
    let gunnar = as_the_person(&state, &user).await?;
    gunnar
        .access()
        .replace_encryption_key(pb::ReplaceEncryptionKeyRequest { public_key: public, first: Some(first) })
        .await
        .map_err(ApiError::from)?;
    tracing::warn!(target: AUDIT_TARGET, event = "encryption_key.replace", by = %user, "somebody replaced their encryption key; the old one is retired");
    get(Me(user), State(state)).await
}

/// `POST /api/account/encryption-key/methods`.
pub async fn add(Me(user): Me, State(state): State<AppState>, Json(new): Json<NewMethod>) -> ApiResult<Key> {
    let method = check(&new)?;
    let gunnar = as_the_person(&state, &user).await?;
    let kind = method.kind.clone();
    gunnar
        .access()
        .add_unlock_method(pb::AddUnlockMethodRequest { method: Some(method) })
        .await
        .map_err(ApiError::from)?;
    tracing::info!(target: AUDIT_TARGET, event = "encryption_key.method_added", by = %user, kind = %kind, "somebody added an unlock method");
    get(Me(user), State(state)).await
}

/// `DELETE /api/account/encryption-key/methods/{id}`.
pub async fn remove(Me(user): Me, State(state): State<AppState>, Path(id): Path<String>) -> ApiResult<Key> {
    if id.is_empty() || id.len() > 64 || !id.bytes().all(|b| b.is_ascii_hexdigit()) {
        return Err(refuse("invalidargument", "that is not a method id"));
    }
    let gunnar = as_the_person(&state, &user).await?;
    gunnar
        .access()
        .remove_unlock_method(pb::RemoveUnlockMethodRequest { id: id.clone() })
        .await
        .map_err(ApiError::from)?;
    tracing::warn!(target: AUDIT_TARGET, event = "encryption_key.method_removed", by = %user, id = %id, "somebody removed an unlock method");
    get(Me(user), State(state)).await
}

#[cfg(test)]
mod tests {
    use super::*;

    fn method(kind: &str) -> NewMethod {
        NewMethod {
            kind: kind.into(),
            label: " my key ".into(),
            method_ref: "AQID".into(),
            rp_id: "console.example".into(),
            wrap: "-----BEGIN AGE ENCRYPTED FILE-----\nx\n-----END AGE ENCRYPTED FILE-----\n".into(),
            principal_id: None,
            user: None,
        }
    }

    /// ★ A method is checked before anything is signed: its kind, its wrap's
    /// shape, a security key's credential and relying party, and no name.
    #[test]
    fn a_method_is_checked_before_it_is_relayed() {
        let ok = check(&method("security-key")).expect("a security key");
        assert_eq!((ok.kind.as_str(), ok.label.as_str(), ok.id.as_str()), ("security-key", "my key", ""));
        assert!(check(&method("recovery-code")).is_ok());
        let words = |m: NewMethod| check(&m).unwrap_err().0.message;
        assert!(words(method("password")).contains("not an unlock method"));
        assert!(words(NewMethod { wrap: "plain".into(), ..method("ssh") }).contains("armored age"));
        assert!(words(NewMethod { rp_id: String::new(), ..method("security-key") }).contains("relying party"));
        assert!(words(NewMethod { user: Some("boss".into()), ..method("ssh") }).contains("no other name"));
        assert!(words(NewMethod { principal_id: Some("boss".into()), ..method("ssh") }).contains("no other name"));
    }

    /// ★ CRYPT-PQ-SK: a security key's wrap v2 (symmetric, quantum-safe) is relayed;
    /// it is a security key's only, and a malformed one is refused here.
    #[test]
    fn a_security_keys_wrap_v2_is_relayed_and_no_other_methods() {
        let v2 = format!("{} {} {} {}\n", gunnar_crypt::skwrap::TAG, "A".repeat(43), "A".repeat(32), "A".repeat(64));
        assert!(gunnar_crypt::skwrap::parse(&v2).is_some(), "{v2}");
        let ok = check(&NewMethod { wrap: v2.clone(), ..method("security-key") }).expect("a v2 security key");
        assert_eq!(ok.wrap, v2);
        let words = |m: NewMethod| check(&m).unwrap_err().0.message;
        assert!(words(NewMethod { wrap: v2.clone(), ..method("ssh") }).contains("security key"));
        assert!(words(NewMethod { wrap: format!("{} x y z", gunnar_crypt::skwrap::TAG), ..method("security-key") }).contains("v2"));
    }
}