gunnar-grpc 2.0.0

The tonic service implementations and the event bus behind gunnar's gRPC control plane.
Documentation
//! **The first admin, on gunnar's passkeys** (Stage L, 2026-10-04).
//!
//! test2.gunnar.rs that morning: the owner registered his YubiKey with the
//! installer's break-glass token, the console asked gunnar `AddPrincipal
//! rickard`, gunnar refused it (a first push had conjured `rickard/`), and the
//! passkey bound to nothing. Owner: "a YubiKey is used to register me as an
//! admin user. I can THEN add ssh keys to it"; "the UI never creates users or
//! decides anything — it CALLS gunnar-server".
//!
//! With passkeys at gunnar (Stage L) the whole door is gunnar's:
//!
//! 1. `Accounts.ClaimOperator{name, bootstrap_invite_sha256}` — the
//!    installer's word (the primary does the same from its feed): the operator
//!    is made (or adopted over a conjured namespace), gunnar-admin, and the
//!    break-glass token's HASH is recorded as the invite for his first passkey;
//! 2. the console relays the creation ceremony: `Access.WebChallenge`, then
//!    `Access.WebRegister` with the token as `invite_secret` — gunnar decides
//!    whose passkey it is;
//! 3. he logs in: `Access.WebLogin` enrols the console's session key on HIS
//!    principal;
//! 4. and adds his SSH key, signed as him: no attestation, no operator step.
//!
//! Every assertion is a real ES256 ceremony and a real signed call through the
//! gate.

use std::sync::Arc;

use gunnar_grpc::auth::AuthGate;
use gunnar_grpc::Identity as _;
use gunnar_grpc::{
    sign_credential_for, AccessService, AccountsService, AuthMode, Caller, ControlPlane, EventBus,
    MemoryControlPlane, ServerConfig, ServiceContext, CREDENTIAL_METADATA_KEY,
};
use gunnar_proto::access_server::Access as _;
use gunnar_proto::accounts_server::Accounts as _;
use gunnar_proto::*;
use p256::ecdsa::signature::Signer as _;
use sha2::Digest as _;
use tonic::{Code, Request};

const RP_ID: &str = "test2.example";
const ORIGIN: &str = "https://test2.example";
const DAY_MS: i64 = 86_400_000;
/// The break-glass token the installer hands the owner (and feeds vm1).
const BREAK_GLASS: &str = "break-glass-of-the-installer-0000000000000000000001";

fn now_ms() -> i64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .expect("after 1970")
        .as_millis() as i64
}

fn mint() -> ssh_key::PrivateKey {
    ssh_key::PrivateKey::random(
        &mut rand::rand_core::UnwrapErr(rand::rngs::SysRng),
        ssh_key::Algorithm::Ed25519,
    )
    .expect("an ed25519 key")
}

fn b64url(bytes: &[u8]) -> String {
    gunnar_grpc::webauthn::b64url(bytes)
}

fn hash_of(secret: &str) -> String {
    gunnar_grpc::webauthn::sha256_hex(secret.as_bytes())
}

/// The owner's YubiKey.
struct Yubikey {
    key: p256::ecdsa::SigningKey,
    id: Vec<u8>,
}

impl Yubikey {
    fn new(seed: u8, id: &[u8]) -> Yubikey {
        Yubikey {
            key: p256::ecdsa::SigningKey::from_slice(&[seed; 32]).expect("a scalar"),
            id: id.to_vec(),
        }
    }

    fn public(&self) -> Vec<u8> {
        self.key.verifying_key().to_sec1_point(false).as_bytes().to_vec()
    }

    /// `navigator.credentials.create`, relayed: what `Access.WebRegister` takes.
    fn register(&self, challenge: &[u8], invite_secret: &str) -> WebRegisterRequest {
        WebRegisterRequest {
            invite_secret: invite_secret.into(),
            principal_id: String::new(),
            credential_id: self.id.clone(),
            public_key: self.public(),
            label: "YubiKey".into(),
            client_data_json: format!(
                r#"{{"type":"webauthn.create","challenge":"{}","origin":"{ORIGIN}"}}"#,
                b64url(challenge)
            )
            .into_bytes(),
            rp_id: RP_ID.into(),
            origin: ORIGIN.into(),
            sign_count: 0,
            attestation_object: gunnar_grpc::webauthn::attestation_none(RP_ID, &self.id, &self.public(), 0),
        }
    }

    /// `navigator.credentials.get`, relayed: what `Access.WebLogin` takes.
    fn login(&self, challenge: &[u8], counter: u32, session: &ssh_key::PrivateKey) -> WebLoginRequest {
        let client_data = format!(
            r#"{{"type":"webauthn.get","challenge":"{}","origin":"{ORIGIN}","crossOrigin":false}}"#,
            b64url(challenge)
        )
        .into_bytes();
        let mut auth_data = sha2::Sha256::digest(RP_ID.as_bytes()).to_vec();
        auth_data.push(0x01 | 0x04);
        auth_data.extend_from_slice(&counter.to_be_bytes());
        let mut message = auth_data.clone();
        message.extend_from_slice(&sha2::Sha256::digest(&client_data));
        let signature: p256::ecdsa::Signature = self.key.sign(&message);
        WebLoginRequest {
            credential_id: self.id.clone(),
            client_data_json: client_data,
            authenticator_data: auth_data,
            signature: signature.to_der().as_bytes().to_vec(),
            rp_id: RP_ID.into(),
            origin: ORIGIN.into(),
            session_public_key: session.public_key().to_openssh().expect("openssh"),
            session_expires_at_unix_ms: now_ms() + DAY_MS,
            require_user_verification: false,
        }
    }
}

/// test2's gunnar: the installer's admin key (`admin`), the console
/// (`gunnar-ui`, server Admin: the relay), and — on the morning — a namespace
/// `rickard` a first push conjured.
struct Estate {
    plane: Arc<MemoryControlPlane>,
    ctx: ServiceContext,
    console: Caller,
}

impl Estate {
    fn new(conjured: bool) -> Estate {
        let plane = Arc::new(MemoryControlPlane::new());
        let control: Arc<dyn ControlPlane> = plane.clone();
        let ctx = ServiceContext::new(control, Arc::new(EventBus::new(16)), ServerConfig::default());
        assert_eq!(ctx.config.auth, AuthMode::Required);
        let mut estate = Estate {
            plane,
            ctx,
            console: Caller {
                principal_id: String::new(),
                fingerprint: String::new(),
                machine: true,
                authenticated: true,
            },
        };
        let (console, _) = estate.enrolled("gunnar-ui", PrincipalKind::Machine);
        estate.console = console;
        estate.plane.grant_server("gunnar-ui", GrantLevel::Admin);
        if conjured {
            estate
                .plane
                .create_namespace("rickard", "")
                .expect("the namespace a first push conjured");
        }
        estate
    }

    fn enrolled(&self, id: &str, kind: PrincipalKind) -> (Caller, ssh_key::PrivateKey) {
        let key = mint();
        self.plane.add_principal(id, id, kind).expect("principal");
        self.plane
            .add_key(id, &key.public_key().to_openssh().expect("openssh"), id, None, "", None)
            .expect("key");
        (self.gate(&key).expect("a registered key verifies"), key)
    }

    /// The caller the real gate makes of `key`, or why it refuses.
    fn gate(&self, key: &ssh_key::PrivateKey) -> Result<Caller, String> {
        use std::sync::atomic::{AtomicU64, Ordering};
        static SEQ: AtomicU64 = AtomicU64::new(0);
        let nonce = format!(
            "nonce-first-admin-{:08}",
            SEQ.fetch_add(1, Ordering::Relaxed)
        );
        let credential = sign_credential_for(key, now_ms(), &nonce, "Admin.Health").expect("sign");
        let mut request = Request::new(());
        request.metadata_mut().insert(
            CREDENTIAL_METADATA_KEY,
            credential.to_header_value().parse().expect("ascii"),
        );
        AuthGate::new(Arc::clone(&self.ctx.control), self.ctx.config.auth)
            .authenticate_bound(request.metadata(), now_ms(), Some("Admin.Health"))
            .map_err(|e| format!("{e:?}"))
    }

    fn authed<T>(caller: &Caller, body: T) -> Request<T> {
        let mut request = Request::new(body);
        request.extensions_mut().insert(caller.clone());
        request
    }

    fn access(&self) -> AccessService {
        AccessService::new(self.ctx.clone())
    }

    fn accounts(&self) -> AccountsService {
        AccountsService::new(self.ctx.clone())
    }

    /// The installer's word: the operator, and the token's hash as his first invite.
    async fn claim(&self, hash: &str) -> Result<ClaimOperatorResponse, tonic::Status> {
        self.accounts()
            .claim_operator(Estate::authed(
                &self.console,
                ClaimOperatorRequest {
                    name: "rickard".into(),
                    bootstrap_invite_sha256: hash.into(),
                },
            ))
            .await
            .map(|r| r.into_inner())
    }

    async fn challenge(&self) -> Vec<u8> {
        self.access()
            .web_challenge(Estate::authed(&self.console, WebChallengeRequest::default()))
            .await
            .expect("the relay gets a challenge")
            .into_inner()
            .challenge
    }

    async fn register(&self, by: &Yubikey, secret: &str) -> Result<WebRegisterResponse, tonic::Status> {
        let challenge = self.challenge().await;
        self.access()
            .web_register(Estate::authed(&self.console, by.register(&challenge, secret)))
            .await
            .map(|r| r.into_inner())
    }
}

/// ★ **Fresh estate and test2's morning alike: the break-glass token registers
/// the owner's YubiKey ON THE OPERATOR'S ACCOUNT, he logs in as himself, and
/// adds his SSH key — gunnar decided every step, the console only relayed.**
#[tokio::test]
async fn the_break_glass_token_is_the_operators_first_invite_and_his_passkey_logs_in_as_him() {
    for conjured in [false, true] {
        let estate = Estate::new(conjured);
        let claimed = estate.claim(&hash_of(BREAK_GLASS)).await.expect("the installer's claim");
        let operator = claimed.principal.clone().expect("the operator");
        assert_eq!(operator.id, "rickard", "conjured={conjured}");
        assert_eq!(claimed.adopted_namespace, conjured, "the conjured namespace is ADOPTED, never a refusal");
        assert!(claimed.bootstrap_invite, "the token's hash is pending as his first invite");
        let pending = estate
            .access()
            .list_invites(Estate::authed(&estate.console, ListInvitesRequest::default()))
            .await
            .expect("listed")
            .into_inner()
            .invites;
        assert_eq!(pending.len(), 1, "{pending:?}");
        assert_eq!(pending[0].invite_id, gunnar_grpc::seam::OPERATOR_INVITE_ID);
        assert_eq!(pending[0].principal_id, "rickard");
        assert_eq!(pending[0].server_level, GrantLevel::Unspecified as i32, "no admin by invite");

        // 2. His YubiKey, with the token: gunnar says whose it is.
        let yubikey = Yubikey::new(0x37, b"the-owners-yubikey");
        let registered = estate.register(&yubikey, BREAK_GLASS).await.expect("the first admin passkey registers");
        assert_eq!(registered.principal.as_ref().map(|p| p.id.as_str()), Some("rickard"));
        assert_eq!(registered.server_level, GrantLevel::Unspecified as i32);

        // 3. He logs in: the console's session key is HIS.
        let session = mint();
        let challenge = estate.challenge().await;
        let login = estate
            .access()
            .web_login(Estate::authed(&estate.console, yubikey.login(&challenge, 1, &session)))
            .await
            .expect("his passkey logs in")
            .into_inner();
        assert_eq!(login.principal.as_ref().map(|p| p.id.as_str()), Some("rickard"));
        let him = estate.gate(&session).expect("the session key authenticates");
        assert_eq!(him.principal_id, "rickard");
        assert!(
            estate.plane.may("rickard", &gunnar_grpc::Access::Server, GrantLevel::Admin).expect("asked"),
            "the operator is the estate's admin"
        );

        // 4. "I can THEN add ssh keys to it": signed as him, no attestation owed.
        let laptop = mint().public_key().to_openssh().expect("openssh");
        estate
            .access()
            .add_key(Estate::authed(
                &him,
                AddKeyRequest {
                    principal_id: "rickard".into(),
                    openssh_public_key: laptop.clone(),
                    comment: "laptop".into(),
                    attestation: None,
                    expires_at_unix_ms: None,
                },
            ))
            .await
            .expect("his own SSH key, added as him");
        let fingerprint = estate
            .plane
            .list_keys(Some("rickard"))
            .expect("keys")
            .into_iter()
            .any(|k| k.comment == "laptop");
        assert!(fingerprint, "gunnar holds his laptop key on rickard");
    }
}

/// **Once he holds a passkey the bootstrap is spent**: the token registers
/// nothing more, and a later claim with the same hash records nothing.
#[tokio::test]
async fn the_bootstrap_is_spent_by_the_first_passkey() {
    let estate = Estate::new(true);
    estate.claim(&hash_of(BREAK_GLASS)).await.expect("claimed");
    estate
        .register(&Yubikey::new(0x37, b"the-owners-yubikey"), BREAK_GLASS)
        .await
        .expect("the first passkey");
    let copied = estate
        .register(&Yubikey::new(0x38, b"somebody-with-the-token"), BREAK_GLASS)
        .await
        .expect_err("the token is spent");
    assert_eq!(copied.code(), Code::PermissionDenied, "{copied:?}");
    let again = estate.claim(&hash_of(BREAK_GLASS)).await.expect("a later feed's claim");
    assert!(!again.bootstrap_invite, "no second bootstrap once he holds a passkey");
    assert!(estate
        .access()
        .list_invites(Estate::authed(&estate.console, ListInvitesRequest::default()))
        .await
        .expect("listed")
        .into_inner()
        .invites
        .is_empty());
}

/// The token itself where its hash belongs is refused by name, before
/// anything is claimed or recorded.
#[tokio::test]
async fn a_token_in_place_of_its_hash_is_refused_and_nothing_is_claimed() {
    let estate = Estate::new(false);
    let refused = estate.claim(BREAK_GLASS).await.expect_err("never the token");
    assert_eq!(refused.code(), Code::InvalidArgument, "{refused:?}");
    assert!(refused.message().contains("never the token itself"), "{}", refused.message());
    assert!(estate.plane.principal("rickard").expect("asked").is_none(), "nothing was claimed");
}