crates/gunnar-ssh/tests/recipient_keys_verb.rs
//! **`gunnar-recipient-keys`: a member's encryption key for an encrypted
//! repository's key ring, over the forge's SSH door** (2026-10-06; since
//! CRYPT-YUBI 2026-10-26 the person's ONE encryption public key, never an SSH
//! key).
//!
//! On an installed estate the control plane is bound to the WireGuard overlay,
//! so `gunnar crypt add-member NAME --from-server` asks the forge it pushes
//! to. The rule is `Repos.RecipientKeys`'s: the caller reads the repository
//! (otherwise the one "not found, or no access" sentence a stranger gets), and
//! the person named reads it too — never a directory of everybody's keys.
//! Each refusal sits beside its allowed twin.
mod common;
use std::sync::Arc;
use common::*;
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")
}
#[tokio::test]
async fn a_reader_gets_a_readers_keys_and_nobody_gets_more() {
let (alice, bob, frank) = (mint_key(), 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)
.with_principal("frank")
.with_key("frank", &frank);
let bob_body = give_an_encryption_key(&plane, "bob");
give_an_encryption_key(&plane, "frank");
let server = serve(plane).await;
let addr = server.local_addr();
let repo = the_repo();
let path = format!("'{}/{}.git'", repo.namespace(), repo.name());
// alice reads the repository and so does bob: bob's encryption key, exit 0.
let a = authenticate(addr, &alice, "git")
.await
.unwrap()
.expect("alice's key");
match exec(&a, &format!("{RECIPIENT_KEYS_VERB} {path} 'bob'"))
.await
.unwrap()
{
ExecOutcome::Accepted {
stdout,
exit_status,
} => {
assert_eq!(exit_status, Some(0), "{stdout}");
assert!(stdout.contains(&bob_body), "{stdout}");
assert_eq!(stdout.lines().count(), 1, "bob's one key: {stdout}");
}
other => panic!("alice must get bob's key: {other:?}"),
}
// frank cannot read it: none of his keys, in gunnar's words.
match exec(&a, &format!("{RECIPIENT_KEYS_VERB} {path} frank"))
.await
.unwrap()
{
ExecOutcome::Accepted {
stdout,
exit_status,
} => {
assert_eq!(exit_status, Some(gunnar_ssh::EXIT_DENIED), "{stdout}");
assert!(
stdout.starts_with("error: frank cannot read nordisk/osm-katana"),
"{stdout}"
);
assert!(!stdout.contains("age1"), "{stdout}");
}
other => panic!("{other:?}"),
}
// A repository that does not exist and one alice cannot see read alike.
let missing = match exec(&a, &format!("{RECIPIENT_KEYS_VERB} 'nordisk/nope.git' bob"))
.await
.unwrap()
{
ExecOutcome::Accepted {
stdout,
exit_status,
} => {
assert_eq!(exit_status, Some(gunnar_ssh::EXIT_DENIED), "{stdout}");
stdout
}
other => panic!("{other:?}"),
};
assert!(
missing.contains("repository not found, or you do not have access"),
"{missing}"
);
// frank asks for bob's keys: he cannot read the repository himself, and
// learns exactly what he would about a repository that does not exist.
let f = authenticate(addr, &frank, "git")
.await
.unwrap()
.expect("frank's key");
match exec(&f, &format!("{RECIPIENT_KEYS_VERB} {path} bob"))
.await
.unwrap()
{
ExecOutcome::Accepted {
stdout,
exit_status,
} => {
assert_eq!(exit_status, Some(gunnar_ssh::EXIT_DENIED), "{stdout}");
assert!(!stdout.contains(&bob_body), "{stdout}");
assert_eq!(
stdout.replace("osm-katana", "X"),
missing.replace("nope", "X"),
"the stranger's sentence, word for word"
);
}
other => panic!("{other:?}"),
}
// A malformed line is this verb's usage, not a git refusal.
match exec(&a, &format!("{RECIPIENT_KEYS_VERB} {path}"))
.await
.unwrap()
{
ExecOutcome::Accepted {
stdout,
exit_status,
} => {
assert_eq!(exit_status, Some(1), "{stdout}");
assert!(stdout.contains(RECIPIENT_KEYS_VERB), "{stdout}");
}
other => panic!("{other:?}"),
}
}
/// **An SSH key is never a recipient** (CRYPT-YUBI 2026-10-26): a member with
/// SSH keys but no encryption key is told to make one; no SSH line is handed out.
#[tokio::test]
async fn an_ssh_key_is_never_a_recipient() {
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 repo = the_repo();
let path = format!("'{}/{}.git'", repo.namespace(), repo.name());
let a = authenticate(server.local_addr(), &alice, "git")
.await
.unwrap()
.expect("alice's key");
match exec(&a, &format!("{RECIPIENT_KEYS_VERB} {path} bob"))
.await
.unwrap()
{
ExecOutcome::Accepted {
stdout,
exit_status,
} => {
assert_eq!(exit_status, Some(gunnar_ssh::EXIT_DENIED), "{stdout}");
assert!(stdout.contains("bob has no encryption key yet"), "{stdout}");
assert!(!stdout.contains("ssh-ed25519"), "{stdout}");
}
other => panic!("{other:?}"),
}
}
/// Give `who` an encryption key in the plane's store; its public half.
fn give_an_encryption_key(plane: &Plane, who: &str) -> String {
use gunnar_core::encryption_key::{MethodKind, UnlockMethod};
let user =
gunnar_crypt::userkey::UserKey::generate(gunnar_crypt::members::Level::Classic).unwrap();
let wrap = user
.wrap_to_recovery_code(&gunnar_crypt::userkey::recovery_code::from_bytes(&[9; 20]))
.unwrap();
let method = UnlockMethod {
id: String::new(),
kind: MethodKind::RecoveryCode,
label: "printed".into(),
method_ref: String::new(),
rp_id: String::new(),
wrap,
added_ms: 0,
};
plane
.control
.encryption_keys()
.expect("the door keeps encryption keys")
.create(who, &user.public(), method, 1)
.unwrap();
user.public()
}
/// **A fault at the door is told in plain words** (wiring tracer B
/// 2026-10-08): the member at the terminal is told Gunnar could not answer
/// and to try again; what broke (the identity table, the catalog, the raw
/// error) is the operator's and goes to the journal.
#[tokio::test]
async fn a_fault_at_the_door_names_no_internal_part() {
let alice = mint_key();
let plane = Plane::empty()
.with_the_repo()
.with_principal("alice")
.with_key("alice", &alice)
.with_grant("alice", Access::Write);
plane.corrupt_the_catalog();
let server = serve(plane).await;
let repo = the_repo();
let a = authenticate(server.local_addr(), &alice, "git")
.await
.unwrap()
.expect("alice's key");
match exec(
&a,
&format!(
"{RECIPIENT_KEYS_VERB} '{}/{}.git' bob",
repo.namespace(),
repo.name()
),
)
.await
.unwrap()
{
ExecOutcome::Accepted {
stdout,
exit_status,
} => {
assert_eq!(exit_status, Some(gunnar_ssh::EXIT_DENIED), "{stdout}");
assert!(stdout.contains("try again"), "{stdout}");
for internal in [
"identity table",
"catalog",
"json",
"JSON",
"control document",
"decode",
] {
assert!(
!stdout.contains(internal),
"{internal:?} reaches the member: {stdout}"
);
}
}
other => panic!("{other:?}"),
}
}
/// **`gunnar-whoami` names the account this key is** (wiring tracer B
/// 2026-10-08): what `gunnar crypt init` asks for the member's name, instead
/// of guessing it from `$USER` on the member's own machine. An anonymous
/// session is refused the channel.
#[tokio::test]
async fn whoami_names_the_account_this_key_is() {
let alice = mint_key();
let plane = Plane::empty()
.with_the_repo()
.with_principal("alice")
.with_key("alice", &alice);
let server = serve(plane).await;
let a = authenticate(server.local_addr(), &alice, "git")
.await
.unwrap()
.expect("alice's key");
match exec(&a, "gunnar-whoami").await.unwrap() {
ExecOutcome::Accepted {
stdout,
exit_status,
} => {
assert_eq!(exit_status, Some(0), "{stdout}");
assert_eq!(stdout.trim(), "alice");
}
other => panic!("{other:?}"),
}
}