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

nordisk/gunnar

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:?}"),
    }
}