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"));
}
}