crates/gunnar-ssh/tests/user_key_verb.rs
//! **`gunnar-user-key`: a person's own encryption key over the forge's SSH
//! door** (CRYPT-YUBI 2026-10-26). The CLI keeps a person's ONE encryption key
//! here: its public half and one wrap per unlock method, ciphertext to the
//! server. The caller is the session's own principal; there is no name to give.
mod common;
use std::sync::Arc;
use common::*;
use gunnar_crypt::door::{Answer, MethodLine, Request};
use gunnar_crypt::members::Level;
use gunnar_crypt::userkey::{recovery_code, security_key, UserKey};
use gunnar_ssh::seam::BannerTransport;
use gunnar_ssh::{Access, GunnarSsh, SshConfig, RECIPIENT_KEYS_VERB};
async fn serve(plane: Plane) -> gunnar_ssh::BoundServer {
let transport = Arc::new(BannerTransport::new());
GunnarSsh::new(SshConfig::ephemeral_loopback(), plane.control, transport)
.bind(mint_key())
.await
.expect("bind")
}
fn sk(user: &UserKey, fill: u8, cred: &str) -> MethodLine {
MethodLine {
kind: "security-key".into(),
label: format!("key {fill}"),
method_ref: cred.into(),
rp_id: "console.mock.invalid".into(),
wrap: user
.wrap_to_security_key(&security_key::recipient(&[fill; 32], cred.as_bytes()))
.unwrap(),
..MethodLine::default()
}
}
async fn said(
session: &russh::client::Handle<TrustingClient>,
line: &str,
) -> (Option<u32>, String) {
match exec(session, line).await.unwrap() {
ExecOutcome::Accepted {
stdout,
exit_status,
} => (exit_status, stdout),
other => panic!("{line:.60}: {other:?}"),
}
}
/// ★ Create once, add a method, refuse the last removal, and a second member
/// is handed the PUBLIC half only — the method wraps stay the owner's.
#[tokio::test]
async fn a_person_keeps_one_key_and_its_methods_and_others_see_only_the_public_half() {
let (alice, bob) = (mint_key(), mint_key());
let plane = Plane::empty()
.with_the_repo()
.with_principal("alice")
.with_key("alice", &alice)
.with_grant("alice", Access::Write)
.with_principal("bob")
.with_key("bob", &bob)
.with_grant("bob", Access::Read);
let server = serve(plane).await;
let addr = server.local_addr();
let b = authenticate(addr, &bob, "git")
.await
.unwrap()
.expect("bob's key");
// No key yet: an empty answer, exit 0.
assert_eq!(
said(&b, &Request::Get.line()).await,
(Some(0), String::new())
);
let user = UserKey::generate(Level::Classic).unwrap();
let create = Request::Create {
public_key: user.public(),
method: sk(&user, 1, "cred-one"),
};
let (exit, out) = said(&b, &create.line()).await;
assert_eq!(exit, Some(0), "{out}");
let a = Answer::parse(&out).expect("an answer");
assert_eq!(a.public_key.as_deref(), Some(user.public().as_str()));
assert_eq!((a.methods.len(), a.methods[0].id.len()), (1, 16));
// A second create is refused by name.
let (exit, out) = said(&b, &create.line()).await;
assert_eq!(exit, Some(gunnar_ssh::EXIT_DENIED));
assert!(out.contains("orphan"), "{out}");
// The last method is never removed.
let (exit, out) = said(&b, &Request::Remove(a.methods[0].id.clone()).line()).await;
assert_eq!(exit, Some(gunnar_ssh::EXIT_DENIED));
assert!(out.contains("last unlock method"), "{out}");
// A printed recovery code as a second method.
let code = MethodLine {
kind: "recovery-code".into(),
label: "printed".into(),
wrap: user
.wrap_to_recovery_code(&recovery_code::from_bytes(&[4; 20]))
.unwrap(),
..MethodLine::default()
};
let (exit, out) = said(&b, &Request::Add(code).line()).await;
assert_eq!(exit, Some(0), "{out}");
assert_eq!(Answer::parse(&out).unwrap().methods.len(), 2);
// alice asks for bob's recipient (she adds him to a ring): the public half only.
let a_session = authenticate(addr, &alice, "git")
.await
.unwrap()
.expect("alice's key");
let repo = the_repo();
let path = format!("'{}/{}.git'", repo.namespace(), repo.name());
let (exit, out) = said(&a_session, &format!("{RECIPIENT_KEYS_VERB} {path} bob")).await;
assert_eq!((exit, out.trim()), (Some(0), user.public().as_str()));
assert!(!out.contains("AGE ENCRYPTED FILE"), "never a wrap: {out}");
// And alice's own `gunnar-user-key` is hers: she has none.
assert_eq!(
said(&a_session, &Request::Get.line()).await,
(Some(0), String::new())
);
// A member without an encryption key is told how to make one.
let (exit, out) = said(&b, &format!("{RECIPIENT_KEYS_VERB} {path} alice")).await;
assert_eq!(exit, Some(gunnar_ssh::EXIT_DENIED));
assert!(out.contains("alice has no encryption key yet"), "{out}");
// A malformed line is this verb's usage.
let (exit, out) = said(&b, "gunnar-user-key add 1 2 3").await;
assert_eq!(exit, Some(1));
assert!(out.contains("gunnar-user-key"), "{out}");
}
/// ★ RA4-3: a change to a person's unlock methods over the SSH door (the path
/// the CLI always takes) writes the same account audit rows the gRPC door
/// writes: encryption-key.created, .method-added, .method-removed; a refused
/// change writes nothing that says it happened.
#[tokio::test]
async fn method_changes_over_the_door_are_in_the_account_audit() {
use gunnar_core::account_audit::{AccountAuditLog, AuditQuery};
let bob = mint_key();
let mut plane = Plane::empty()
.with_the_repo()
.with_principal("bob")
.with_key("bob", &bob)
.with_grant("bob", Access::Read);
let audit = Arc::new(AccountAuditLog::new(Arc::clone(&plane.store)).unwrap());
let names = gunnar_core::name_filter::NameFilterStore::new(Arc::clone(&plane.store)).unwrap();
plane.control = plane
.control
.clone()
.with_enterprise(names, Arc::clone(&audit));
let server = serve(plane).await;
let b = authenticate(server.local_addr(), &bob, "git")
.await
.unwrap()
.expect("bob's key");
let user = UserKey::generate(Level::Classic).unwrap();
let (exit, out) = said(
&b,
&Request::Create {
public_key: user.public(),
method: sk(&user, 1, "cred-one"),
}
.line(),
)
.await;
assert_eq!(exit, Some(0), "{out}");
let code = MethodLine {
kind: "recovery-code".into(),
label: "printed".into(),
wrap: user
.wrap_to_recovery_code(&recovery_code::from_bytes(&[4; 20]))
.unwrap(),
..MethodLine::default()
};
let (exit, out) = said(&b, &Request::Add(code).line()).await;
assert_eq!(exit, Some(0), "{out}");
let id = Answer::parse(&out).unwrap().methods[1].id.clone();
let (exit, _) = said(&b, &Request::Remove(id).line()).await;
assert_eq!(exit, Some(0));
let kinds: Vec<String> = audit
.all("bob", &AuditQuery::default())
.unwrap()
.into_iter()
.map(|e| e.kind)
.collect();
for want in [
"encryption-key.created",
"encryption-key.method-added",
"encryption-key.method-removed",
] {
assert!(
kinds.iter().any(|k| k == want),
"{want} is not in bob's audit: {kinds:?}"
);
}
}