gunnar-server 3.0.0

The gunnar git server: SSH and smart-HTTP git transports and the gRPC control plane. The appliance half, extracted from gunnar-cli so the server code lives in its own crate.
Documentation
//! **A person's encryption key over the real wire** (highlight 10, CRYPT-YUBI): one
//! key pair per person, its private half kept only as wraps, one per unlock method.
//!
//! The doors `Access.CreateEncryptionKey`, `AddUnlockMethod`, `GetEncryptionKey` and
//! `RemoveUnlockMethod` are driven through the real gRPC router (the signed-request
//! gate, no insecure mode) over the real `CoreControlPlane` on a real store, with real
//! age wraps:
//!
//! * alice creates her key with an SSH method, adds a security key (PRF), reads both
//!   back, removes one, and is refused removing the last;
//! * the key is the caller's own: bob, signed in as himself, sees no key, cannot remove
//!   alice's method by its id and cannot add a method to a key he does not have; his
//!   own key, once made, leaves alice's untouched.

use std::net::SocketAddr;
use std::sync::Arc;
use std::time::{Duration, Instant};

use gunnar_core::ssh_key::PrivateKey;
use gunnar_core::{Principal, PrincipalKind};
use gunnar_crypt::members::Level;
use gunnar_crypt::userkey::{security_key, UserKey};
use gunnar_grpc::client_signer::{authed, plain_channel};
use gunnar_grpc::{EventBus, ServerConfig, ServiceContext};
use gunnar_proto::access_client::AccessClient;
use gunnar_proto::{
    AddUnlockMethodRequest, CreateEncryptionKeyRequest, GetEncryptionKeyRequest,
    RemoveUnlockMethodRequest, UnlockMethod,
};
use gunnar_server::stores::ServerStores;
use gunnar_server::CoreControlPlane;
use tonic::Code;

fn free_addr() -> SocketAddr {
    let l = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
    l.local_addr().expect("addr")
}

fn mint() -> (PrivateKey, String) {
    let pem = gunnar_core::mint_ed25519().expect("mint");
    let k = PrivateKey::from_openssh(std::str::from_utf8(&pem).unwrap()).expect("parse");
    let line = k.public_key().to_openssh().expect("line");
    (k, line)
}

fn person(stores: &ServerStores, id: &str) -> PrivateKey {
    stores
        .auth()
        .add_principal(Principal::new(id, id, PrincipalKind::User).expect("legal id"))
        .expect("principal");
    let (key, line) = mint();
    stores
        .auth()
        .register_key(id, &line, None)
        .expect("the person's SSH key");
    key
}

fn ssh_method(u: &UserKey, line: &str) -> UnlockMethod {
    UnlockMethod {
        kind: "ssh".into(),
        label: "laptop".into(),
        method_ref: line.trim().to_owned(),
        wrap: u.wrap_to_ssh(line).expect("an ssh wrap"),
        ..Default::default()
    }
}

/// A security key's method: `cred` is its credential id, `cred_b64` the same bytes in
/// base64url without padding (what the console sends as the method ref).
fn yubikey_method(u: &UserKey, cred: &[u8], cred_b64: &str) -> UnlockMethod {
    UnlockMethod {
        kind: "security-key".into(),
        label: "YubiKey 5C, keyring".into(),
        method_ref: cred_b64.into(),
        rp_id: "console.mock.invalid".into(),
        wrap: u
            .wrap_to_security_key(&security_key::recipient(&[7; 32], cred))
            .expect("a security-key wrap"),
        ..Default::default()
    }
}

#[test]
fn a_person_makes_reads_and_trims_their_own_encryption_key_over_the_real_router() {
    let d = tempfile::tempdir().unwrap();
    let stores = Arc::new(ServerStores::open(d.path()).expect("stores"));
    let (_admin, admin_line) = mint();
    stores
        .auth()
        .bootstrap_admin(&admin_line)
        .expect("the first admin");
    let alice = person(&stores, "alice");
    let alice_line = alice.public_key().to_openssh().unwrap();
    let bob = person(&stores, "bob");
    let bob_line = bob.public_key().to_openssh().unwrap();

    let rt = tokio::runtime::Builder::new_multi_thread()
        .worker_threads(2)
        .enable_all()
        .build()
        .expect("rt");
    let addr = free_addr();
    let bus = Arc::new(EventBus::new(16));
    let plane: Arc<dyn gunnar_grpc::ControlPlane> =
        Arc::new(CoreControlPlane::new(Arc::clone(&stores)));
    let ctx = ServiceContext::new(plane, bus, ServerConfig::default());
    let (tx, rx) = tokio::sync::oneshot::channel::<()>();
    rt.spawn(async move {
        let _ = gunnar_grpc::router(ctx, || 0)
            .serve_with_shutdown(addr, async {
                let _ = rx.await;
            })
            .await;
    });
    let t0 = Instant::now();
    while std::net::TcpStream::connect(addr).is_err() {
        assert!(t0.elapsed() < Duration::from_secs(10), "the box listens");
        std::thread::sleep(Duration::from_millis(25));
    }
    let at = addr.to_string();
    let alice_key = UserKey::generate(Level::Classic).expect("alice's key pair");
    let bob_key = UserKey::generate(Level::Classic).expect("bob's key pair");

    rt.block_on(async {
        let mut access = AccessClient::new(plain_channel(&at).expect("a channel"));

        // Nothing yet, and an unsigned call is refused.
        let none = access
            .get_encryption_key(authed(&alice, GetEncryptionKeyRequest {}))
            .await
            .expect("Get")
            .into_inner();
        assert!(
            !none.exists && none.public_key.is_empty() && none.methods.is_empty(),
            "{none:?}"
        );
        let anon = access
            .get_encryption_key(tonic::Request::new(GetEncryptionKeyRequest {}))
            .await
            .unwrap_err();
        assert_eq!(anon.code(), Code::Unauthenticated, "{anon:?}");

        // Created with its first method: an SSH key.
        let made = access
            .create_encryption_key(authed(
                &alice,
                CreateEncryptionKeyRequest {
                    public_key: alice_key.public(),
                    first: Some(ssh_method(&alice_key, &alice_line)),
                },
            ))
            .await
            .expect("Access.CreateEncryptionKey")
            .into_inner();
        assert_eq!(made.public_key, alice_key.public());
        assert_eq!(made.methods.len(), 1, "{made:?}");
        assert_eq!(
            (made.methods[0].kind.as_str(), made.methods[0].id.len()),
            ("ssh", 16)
        );
        // A second key would orphan every ring wrapped to the first.
        let again = access
            .create_encryption_key(authed(
                &alice,
                CreateEncryptionKeyRequest {
                    public_key: alice_key.public(),
                    first: Some(ssh_method(&alice_key, &alice_line)),
                },
            ))
            .await
            .unwrap_err();
        assert_eq!(again.code(), Code::AlreadyExists, "{again:?}");

        // One more method: a YubiKey (WebAuthn PRF).
        let added = access
            .add_unlock_method(authed(
                &alice,
                AddUnlockMethodRequest {
                    method: Some(yubikey_method(&alice_key, b"yubi-one", "eXViaS1vbmU")),
                },
            ))
            .await
            .expect("Access.AddUnlockMethod")
            .into_inner();
        assert_eq!(added.methods.len(), 2, "{added:?}");
        let held = access
            .add_unlock_method(authed(
                &alice,
                AddUnlockMethodRequest {
                    method: Some(yubikey_method(&alice_key, b"yubi-one", "eXViaS1vbmU")),
                },
            ))
            .await
            .unwrap_err();
        assert_eq!(
            held.code(),
            Code::AlreadyExists,
            "the same security key twice: {held:?}"
        );

        // Read back: the same public half, both methods, the wraps as given.
        let read = access
            .get_encryption_key(authed(&alice, GetEncryptionKeyRequest {}))
            .await
            .expect("Get")
            .into_inner();
        assert!(read.exists);
        assert_eq!(read.public_key, alice_key.public());
        let kinds: Vec<&str> = read.methods.iter().map(|m| m.kind.as_str()).collect();
        assert_eq!(kinds, ["ssh", "security-key"], "{read:?}");
        let yubi = read
            .methods
            .iter()
            .find(|m| m.kind == "security-key")
            .unwrap()
            .clone();
        assert_eq!(
            (yubi.rp_id.as_str(), yubi.label.as_str()),
            ("console.mock.invalid", "YubiKey 5C, keyring")
        );
        assert!(yubi.added_ms > 0, "gunnar stamps when it took the method");
        let ssh_id = read
            .methods
            .iter()
            .find(|m| m.kind == "ssh")
            .unwrap()
            .id
            .clone();

        // The person-scope: bob is signed in as himself and reaches only his own key.
        let bobs = access
            .get_encryption_key(authed(&bob, GetEncryptionKeyRequest {}))
            .await
            .expect("bob's Get")
            .into_inner();
        assert!(
            !bobs.exists && bobs.methods.is_empty(),
            "bob never sees alice's key: {bobs:?}"
        );
        let steal = access
            .remove_unlock_method(authed(
                &bob,
                RemoveUnlockMethodRequest {
                    id: yubi.id.clone(),
                },
            ))
            .await
            .unwrap_err();
        assert_ne!(steal.code(), Code::Ok);
        let plant = access
            .add_unlock_method(authed(
                &bob,
                AddUnlockMethodRequest {
                    method: Some(yubikey_method(&alice_key, b"bobs-key", "Ym9icy1rZXk")),
                },
            ))
            .await
            .unwrap_err();
        assert_eq!(
            plant.code(),
            Code::FailedPrecondition,
            "bob has no key to add to: {plant:?}"
        );
        let bob_made = access
            .create_encryption_key(authed(
                &bob,
                CreateEncryptionKeyRequest {
                    public_key: bob_key.public(),
                    first: Some(ssh_method(&bob_key, &bob_line)),
                },
            ))
            .await
            .expect("bob's own key")
            .into_inner();
        assert_eq!(bob_made.public_key, bob_key.public());
        let steal = access
            .remove_unlock_method(authed(
                &bob,
                RemoveUnlockMethodRequest {
                    id: yubi.id.clone(),
                },
            ))
            .await
            .unwrap_err();
        assert_eq!(
            steal.code(),
            Code::NotFound,
            "alice's method is not bob's: {steal:?}"
        );
        let still = access
            .get_encryption_key(authed(&alice, GetEncryptionKeyRequest {}))
            .await
            .expect("Get")
            .into_inner();
        assert_eq!(
            (still.public_key.as_str(), still.methods.len()),
            (alice_key.public().as_str(), 2),
            "{still:?}"
        );

        // Removed: one method left, and the last one stays.
        let trimmed = access
            .remove_unlock_method(authed(&alice, RemoveUnlockMethodRequest { id: ssh_id }))
            .await
            .expect("Access.RemoveUnlockMethod")
            .into_inner();
        assert_eq!(trimmed.methods.len(), 1);
        assert_eq!(trimmed.methods[0].id, yubi.id);
        let last = access
            .remove_unlock_method(authed(
                &alice,
                RemoveUnlockMethodRequest {
                    id: yubi.id.clone(),
                },
            ))
            .await
            .unwrap_err();
        assert_eq!(
            last.code(),
            Code::FailedPrecondition,
            "the last method is kept: {last:?}"
        );
    });

    // What the store holds is what the wire said.
    let keys = stores.encryption_keys();
    let a = keys.get("alice").unwrap().expect("alice's key");
    assert_eq!(
        (a.public_key.as_str(), a.methods.len()),
        (alice_key.public().as_str(), 1)
    );
    let b = keys.get("bob").unwrap().expect("bob's key");
    assert_eq!(
        (b.public_key.as_str(), b.methods.len()),
        (bob_key.public().as_str(), 1)
    );
    let _ = tx.send(());
}