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

nordisk/gunnar

crates/gunnar-grpc/tests/machine_enrolment_common/mod.rs

//! The harness both machine-enrolment test binaries share: a memory plane,
//! the console (the relay), kim (a person, server admin, with a passkey), lee
//! (a person, no admin, with a passkey) and P-256 authenticators that sign
//! what a browser or the kit signs. A login is the identity core's
//! (IDENTITY-HOST): the kit logs in through it, and an approval reads the
//! approver's core session ([`gunnar_grpc::auth::PersonSession`]).
#![allow(dead_code)]

use std::sync::Arc;

use gunnar_grpc::auth::{AuthGate, PersonSession, SESSION_METADATA_KEY};
use gunnar_grpc::identity::{
    system_clock, GunnarHost, GunnarIdentity, GunnarStore, IdentityTables,
};
use gunnar_grpc::{
    sign_credential_for, AccessService, Caller, ControlPlane, EventBus, MemoryControlPlane,
    ServerConfig, ServiceContext, CREDENTIAL_METADATA_KEY,
};
use gunnar_proto::access_server::Access as _;
use gunnar_proto::*;
use nornir_identity::{Assertion, Ceremony, Identity, LoggedIn, NoMail, Refused, Relay, Site};
use p256::ecdsa::signature::Signer as _;
use sha2::Digest as _;
use tonic::Request;

pub const RP_ID: &str = "console.example";
pub const ORIGIN: &str = "https://console.example";
pub const UP: u8 = 0x01;
pub const UV: u8 = 0x04;
pub const TOKEN: &str = "the-installers-break-glass-token";

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

pub 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")
}

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

/// One authenticator: a P-256 key and its credential id.
pub struct Authenticator {
    pub key: p256::ecdsa::SigningKey,
    pub id: Vec<u8>,
}

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

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

    /// A `navigator.credentials.get` over `challenge`.
    pub fn assert(
        &self,
        ceremony_id: &str,
        challenge: &[u8],
        flags: u8,
        counter: u32,
    ) -> Assertion {
        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(flags);
        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);
        Assertion {
            ceremony_id: ceremony_id.to_owned(),
            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.to_owned(),
            origin: ORIGIN.to_owned(),
        }
    }

    /// A login with this key through the identity core: the session, or the
    /// core's refusal.
    pub fn login(&self, identity: &GunnarIdentity, counter: u32) -> Result<LoggedIn, Refused> {
        let c: Ceremony = identity.login_start(&relay())?;
        let challenge = gunnar_grpc::webauthn::from_b64url(&c.challenge).expect("b64url");
        identity.login_finish(self.assert(&c.id, &challenge, UP | UV, counter))
    }
}

/// The relay's relying party, as the core is configured with it.
pub fn relay() -> Relay {
    Relay {
        rp_id: RP_ID.to_owned(),
        origin: ORIGIN.to_owned(),
        client: None,
    }
}

/// **A fresh step-up of the approver's own session** (the core's): the
/// approval carries the session the gate found, stepped up.
pub struct Fresh;

/// `clientDataJSON` for a creation ceremony.
pub fn created(challenge: &[u8], kind: &str, origin: &str) -> Vec<u8> {
    format!(
        r#"{{"type":"{kind}","challenge":"{}","origin":"{origin}","crossOrigin":false}}"#,
        b64url(challenge)
    )
    .into_bytes()
}

pub struct Harness {
    pub plane: Arc<MemoryControlPlane>,
    pub identity: Arc<GunnarIdentity>,
    pub ctx: ServiceContext,
    /// The console: server Admin, the relay.
    pub console: Caller,
    /// A person who is a server admin, with a passkey.
    pub kim: Caller,
    pub kim_key: Authenticator,
    /// A person who is NOT a server admin, with a passkey of her own.
    pub lee: Caller,
    pub lee_key: Authenticator,
}

impl Harness {
    pub fn new() -> Harness {
        Harness::with_ttl(gunnar_grpc::machine_enrolment::REQUEST_TTL_MS)
    }

    pub fn with_ttl(ttl_ms: i64) -> Harness {
        let plane = Arc::new(MemoryControlPlane::new());
        let control: Arc<dyn ControlPlane> = plane.clone();
        let store = GunnarStore::new(
            Arc::clone(&control),
            IdentityTables::default(),
            None,
            system_clock(),
        );
        let core = nornir_identity::Config {
            parties: vec![format!("{RP_ID}={ORIGIN}")
                .parse()
                .expect("the relying party")],
            ..nornir_identity::Config::default()
        };
        let identity = Arc::new(Identity::new(
            GunnarHost::new(Site::Private, store, Arc::new(NoMail), system_clock()),
            core,
        ));
        let server = ServerConfig {
            web_relying_parties: identity.config().parties.clone(),
            ..ServerConfig::default()
        };
        let mut ctx = ServiceContext::new(control, Arc::new(EventBus::new(16)), server)
            .with_identity(Arc::clone(&identity));
        ctx.machine_enrolments =
            Arc::new(gunnar_grpc::machine_enrolment::Requests::with_ttl(ttl_ms));
        let nobody = Caller {
            principal_id: String::new(),
            fingerprint: String::new(),
            machine: true,
            authenticated: true,
        };
        let mut h = Harness {
            plane,
            identity,
            ctx,
            console: nobody.clone(),
            kim: nobody.clone(),
            kim_key: Authenticator::new(1, b"kim-yubikey"),
            lee: nobody,
            lee_key: Authenticator::new(2, b"lee-yubikey"),
        };
        h.console = h.enrolled("gunnar-ui", PrincipalKind::Machine);
        h.plane.grant_server("gunnar-ui", GrantLevel::Admin);
        h.kim = h.enrolled("kim", PrincipalKind::User);
        h.plane.grant_server("kim", GrantLevel::Admin);
        h.plane
            .add_credential(
                "kim",
                &h.kim_key.id,
                &h.kim_key.public(),
                "kim's YubiKey",
                0,
            )
            .expect("kim's passkey");
        h.lee = h.enrolled("lee", PrincipalKind::User);
        h.plane
            .add_credential(
                "lee",
                &h.lee_key.id,
                &h.lee_key.public(),
                "lee's YubiKey",
                0,
            )
            .expect("lee's passkey");
        h
    }

    /// A principal with a key, signed in through the real gate.
    pub fn enrolled(&self, id: &str, kind: PrincipalKind) -> Caller {
        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")
    }

    pub 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-machine-enrol-{: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:?}"))
    }

    /// The caller the real gate makes of a core session, or why it refuses.
    pub fn gate_session(&self, token: &str) -> Result<Caller, String> {
        let mut request = Request::new(());
        request
            .metadata_mut()
            .insert(SESSION_METADATA_KEY, token.parse().expect("ascii"));
        AuthGate::new(Arc::clone(&self.ctx.control), self.ctx.config.auth)
            .with_identity(Some(Arc::clone(&self.identity)))
            .authenticate_request(&request, now_ms())
            .map_err(|e| format!("{e:?}"))
    }

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

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

    /// A machine's creation challenge, as the console relays it.
    pub async fn challenge(&self) -> Vec<u8> {
        self.access()
            .machine_challenge(Harness::authed(
                &self.console,
                MachineChallengeRequest::default(),
            ))
            .await
            .expect("the relay gets a machine's challenge")
            .into_inner()
            .challenge
    }

    /// The kit's request, as the console relays it.
    pub async fn ask_as(
        &self,
        relay: &Caller,
        kit: &Authenticator,
        machine: &str,
        level: GrantLevel,
    ) -> Result<MachineEnrolment, tonic::Status> {
        self.ask_raw(relay, kit, machine, level as i32).await
    }

    /// The kit's request with a raw `server_level` (one gunnar may not know).
    pub async fn ask_level(
        &self,
        kit: &Authenticator,
        machine: &str,
        level: i32,
    ) -> Result<MachineEnrolment, tonic::Status> {
        self.ask_raw(&self.console, kit, machine, level).await
    }

    async fn ask_raw(
        &self,
        relay: &Caller,
        kit: &Authenticator,
        machine: &str,
        level: i32,
    ) -> Result<MachineEnrolment, tonic::Status> {
        let challenge = self.challenge().await;
        self.access()
            .request_machine_enrolment(Harness::authed(
                relay,
                RequestMachineEnrolmentRequest {
                    machine: machine.to_owned(),
                    label: format!("installer kit on {}", machine.trim_start_matches("kit-")),
                    server_level: level,
                    credential_id: kit.id.clone(),
                    public_key: kit.public(),
                    client_data_json: created(&challenge, "webauthn.create", ORIGIN),
                    rp_id: RP_ID.to_owned(),
                    origin: ORIGIN.to_owned(),
                    sign_count: 0,
                    attestation_object: gunnar_grpc::webauthn::attestation_none(
                        RP_ID,
                        &kit.id,
                        &kit.public(),
                        0,
                    ),
                },
            ))
            .await
            .map(|r| r.into_inner().request.expect("the request"))
    }

    pub async fn ask(
        &self,
        kit: &Authenticator,
        machine: &str,
        level: GrantLevel,
    ) -> MachineEnrolment {
        self.ask_as(&self.console, kit, machine, level)
            .await
            .expect("the request is held")
    }

    pub async fn status(&self, id: &str) -> MachineEnrolment {
        self.access()
            .machine_enrolment_status(Harness::authed(
                &self.console,
                MachineEnrolmentStatusRequest {
                    request_id: id.to_owned(),
                },
            ))
            .await
            .expect("the relay reads a request's state")
            .into_inner()
            .request
            .expect("the request")
    }

    pub async fn approve(
        &self,
        caller: &Caller,
        request: &MachineEnrolment,
        step_up: Option<Fresh>,
    ) -> Result<ApproveMachineEnrolmentResponse, tonic::Status> {
        self.approve_at(caller, request, step_up, GrantLevel::Unspecified)
            .await
    }

    /// The approval, the approver GIVING `level` (UNSPECIFIED: the asked one).
    pub async fn approve_at(
        &self,
        caller: &Caller,
        request: &MachineEnrolment,
        step_up: Option<Fresh>,
        level: GrantLevel,
    ) -> Result<ApproveMachineEnrolmentResponse, tonic::Status> {
        let mut call = Harness::authed(
            caller,
            ApproveMachineEnrolmentRequest {
                request_id: request.request_id.clone(),
                code: request.code.clone(),
                server_level: level as i32,
            },
        );
        if step_up.is_some() {
            call.extensions_mut().insert(PersonSession {
                stepped_up: true,
                stepped_up_at_unix_ms: None,
            });
        }
        self.access()
            .approve_machine_enrolment(call)
            .await
            .map(|r| r.into_inner())
    }

    /// The approval, from a real core session through the real gate: the
    /// step-up is whatever the core says of `token`.
    pub async fn approve_with_session(
        &self,
        token: &str,
        request: &MachineEnrolment,
    ) -> Result<ApproveMachineEnrolmentResponse, tonic::Status> {
        let caller = self
            .gate_session(token)
            .map_err(tonic::Status::unauthenticated)?;
        let stepped_up = self
            .identity
            .session(token)
            .map(|v| v.stepped_up)
            .unwrap_or(false);
        let mut call = Harness::authed(
            &caller,
            ApproveMachineEnrolmentRequest {
                request_id: request.request_id.clone(),
                code: request.code.clone(),
                server_level: GrantLevel::Unspecified as i32,
            },
        );
        call.extensions_mut().insert(PersonSession {
            stepped_up,
            stepped_up_at_unix_ms: None,
        });
        self.access()
            .approve_machine_enrolment(call)
            .await
            .map(|r| r.into_inner())
    }

    pub async fn kims_step_up(&self) -> Fresh {
        Fresh
    }

    /// Nothing landed for `machine`: no principal, no credential.
    pub fn nothing_for(&self, machine: &str, kit: &Authenticator) {
        assert!(
            self.plane.principal(machine).expect("read").is_none(),
            "{machine} was made"
        );
        assert!(
            self.plane.credential(&kit.id).expect("read").is_none(),
            "the kit's credential landed"
        );
    }
}

pub fn state(r: &MachineEnrolment) -> MachineEnrolmentState {
    MachineEnrolmentState::try_from(r.state).expect("a state")
}