gunnar-ssh 3.0.0

gunnar's SSH server: a russh listener with publickey identity, a strict git exec dispatch, and the repository path sandbox.
Documentation
//! **`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:?}"
        );
    }
}