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/identity_console_doors.rs

//! **The doors the console's move onto the identity core needs** (IDENTITY-HOST,
//! gunnar-ui onto `nornir.identity.v1.IdentityService`), through the real gate:
//!
//! * `Access.EnrolmentLink`: a server admin's (the console relaying a paid
//!   sign-up, an operator's CLI) first-login link onto an EXISTING person,
//!   minted by the core and redeemed through it;
//! * a revoked passkey ends its person's core sessions;
//! * a machine's enrolment is asked over `Access.MachineChallenge` and approved
//!   from a person's core session that STEPPED UP (no assertion in the call);
//! * the approved kit then logs in through the core like a person.

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

use gunnar_grpc::auth::SESSION_METADATA_KEY;
use gunnar_grpc::identity::{
    system_clock, GunnarHost, GunnarIdentity, GunnarStore, IdentityTables, SERVER_ADMIN,
};
use gunnar_grpc::{
    router, sign_credential_for, ControlPlane, EventBus, MemoryControlPlane, ServerConfig,
    ServiceContext, CREDENTIAL_METADATA_KEY,
};
use gunnar_proto::access_client::AccessClient;
use gunnar_proto::*;
use nornir_identity::conformance::{
    config, first_admin, login, relay, step_up, Harness, ORIGIN, RP_ID,
};
use nornir_identity::test_host::{Kit, SoftPasskey};
use nornir_identity::{Ceremony, Code, Identity, NoMail, Site};
use tonic::transport::Channel;
use tonic::Request;

struct Plain;

impl Harness for Plain {
    type Host = GunnarHost;
    fn host(&self, _: Site, _: &Kit) -> GunnarHost {
        unreachable!("the identity here is built by hand")
    }
    fn admin_groups(&self) -> Vec<String> {
        vec![SERVER_ADMIN.to_owned()]
    }
    fn member_groups(&self) -> Vec<String> {
        Vec::new()
    }
}

fn now_ms() -> i64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap()
        .as_millis() as i64
}

struct Box_ {
    plane: Arc<MemoryControlPlane>,
    identity: Arc<GunnarIdentity>,
    console: ssh_key::PrivateKey,
    addr: SocketAddr,
    _runtime: tokio::runtime::Runtime,
}

impl Box_ {
    fn up() -> Box_ {
        let addr: SocketAddr = TcpListener::bind("127.0.0.1:0")
            .unwrap()
            .local_addr()
            .unwrap();
        let plane = Arc::new(MemoryControlPlane::new());
        let control: Arc<dyn ControlPlane> = plane.clone();
        let console = ssh_key::PrivateKey::random(
            &mut rand::rand_core::UnwrapErr(rand::rngs::SysRng),
            ssh_key::Algorithm::Ed25519,
        )
        .unwrap();
        plane
            .add_principal("gunnar-ui", "gunnar-ui", PrincipalKind::Machine)
            .unwrap();
        plane
            .add_key(
                "gunnar-ui",
                &console.public_key().to_openssh().unwrap(),
                "the console",
                None,
                "",
                None,
            )
            .unwrap();
        plane.grant_server("gunnar-ui", GrantLevel::Admin);
        let store = GunnarStore::new(
            Arc::clone(&control),
            IdentityTables::default(),
            None,
            system_clock(),
        );
        let identity = Arc::new(Identity::new(
            GunnarHost::new(Site::Private, store, Arc::new(NoMail), system_clock()),
            config(),
        ));
        let server = ServerConfig {
            web_relying_parties: config().parties,
            ..ServerConfig::default()
        };
        let ctx = ServiceContext::new(control, Arc::new(EventBus::new(16)), server)
            .with_identity(Arc::clone(&identity));
        let runtime = tokio::runtime::Builder::new_multi_thread()
            .enable_all()
            .build()
            .unwrap();
        runtime.spawn(async move {
            let _ = router(ctx, || 0).serve(addr).await;
        });
        let deadline = Instant::now() + Duration::from_secs(10);
        while std::net::TcpStream::connect_timeout(&addr, Duration::from_millis(200)).is_err() {
            assert!(Instant::now() < deadline, "the router never bound {addr}");
            std::thread::sleep(Duration::from_millis(20));
        }
        Box_ {
            plane,
            identity,
            console,
            addr,
            _runtime: runtime,
        }
    }

    async fn access(&self) -> AccessClient<Channel> {
        AccessClient::connect(format!("http://{}", self.addr))
            .await
            .unwrap()
    }

    /// The console's own signed call to `method`.
    fn as_console<T>(&self, method: &str, message: T) -> Request<T> {
        use std::sync::atomic::{AtomicU64, Ordering};
        static SEQ: AtomicU64 = AtomicU64::new(0);
        let nonce = format!(
            "nonce-console-doors-{:08}",
            SEQ.fetch_add(1, Ordering::Relaxed)
        );
        let credential = sign_credential_for(&self.console, now_ms(), &nonce, method).unwrap();
        let mut request = Request::new(message);
        request.metadata_mut().insert(
            CREDENTIAL_METADATA_KEY,
            credential.to_header_value().parse().unwrap(),
        );
        request
    }
}

fn with_session<T>(message: T, token: &str) -> Request<T> {
    let mut request = Request::new(message);
    request
        .metadata_mut()
        .insert(SESSION_METADATA_KEY, token.parse().unwrap());
    request
}

fn code<T: std::fmt::Debug>(r: Result<T, nornir_identity::Refused>) -> Code {
    r.expect_err("a refusal").code
}

#[test]
fn the_console_mints_a_first_login_link_onto_an_existing_person_through_the_core() {
    let b = Box_::up();
    b.plane
        .add_principal("ann", "ann", PrincipalKind::User)
        .unwrap();
    b._runtime.block_on(async {
        let mut access = b.access().await;
        let link = access
            .enrolment_link(b.as_console(
                "Access.EnrolmentLink",
                EnrolmentLinkRequest {
                    principal_id: "ann".into(),
                    first_only: true,
                    expires_at_unix_ms: 0,
                },
            ))
            .await
            .expect("the console mints ann's first-login link")
            .into_inner();
        assert!(
            link.expires_at_unix_ms > now_ms() + 6 * 86_400_000,
            "seven days by default: {}",
            link.expires_at_unix_ms
        );
        let c = b
            .identity
            .redeem_start(&link.secret, "", &relay())
            .expect("the core opens the ceremony");
        assert_eq!(c.user_id, "ann", "onto ann herself");
        let mut key = SoftPasskey::new(4);
        let ann = b
            .identity
            .redeem_finish(key.register(&c, ORIGIN))
            .expect("ann's first passkey");
        assert_eq!(ann.session.user_id, "ann");

        let again = access
            .enrolment_link(b.as_console(
                "Access.EnrolmentLink",
                EnrolmentLinkRequest {
                    principal_id: "ann".into(),
                    first_only: true,
                    expires_at_unix_ms: 0,
                },
            ))
            .await
            .unwrap_err();
        assert_eq!(
            again.code(),
            tonic::Code::FailedPrecondition,
            "a first-only link for a person who holds a passkey: {again:?}"
        );
        access
            .enrolment_link(b.as_console(
                "Access.EnrolmentLink",
                EnrolmentLinkRequest {
                    principal_id: "ann".into(),
                    first_only: false,
                    expires_at_unix_ms: 0,
                },
            ))
            .await
            .expect("another device's link");
        let machine = access
            .enrolment_link(b.as_console(
                "Access.EnrolmentLink",
                EnrolmentLinkRequest {
                    principal_id: "gunnar-ui".into(),
                    first_only: false,
                    expires_at_unix_ms: 0,
                },
            ))
            .await
            .unwrap_err();
        assert_eq!(
            machine.code(),
            tonic::Code::FailedPrecondition,
            "only a person: {machine:?}"
        );
        let theirs = access
            .enrolment_link(with_session(
                EnrolmentLinkRequest {
                    principal_id: "ann".into(),
                    first_only: false,
                    expires_at_unix_ms: 0,
                },
                &ann.token,
            ))
            .await
            .unwrap_err();
        assert_eq!(
            theirs.code(),
            tonic::Code::PermissionDenied,
            "a server admin's door: {theirs:?}"
        );

        // **A revoked passkey ends its person's sessions.**
        access
            .revoke_credential(with_session(
                RevokeCredentialRequest {
                    credential_id: key.id.clone(),
                },
                &ann.token,
            ))
            .await
            .expect("ann revokes her own passkey");
        assert_eq!(
            code(b.identity.session(&ann.token)),
            Code::NoSession,
            "her sessions end with it"
        );
    });
}

#[test]
fn a_machine_is_approved_from_a_stepped_up_session_and_logs_in_through_the_core() {
    let b = Box_::up();
    let (admin, mut admin_key) = first_admin(&Plain, &b.identity);
    b._runtime.block_on(async {
        let mut access = b.access().await;
        let challenge = access
            .machine_challenge(b.as_console(
                "Access.MachineChallenge",
                MachineChallengeRequest {
                    client: "198.51.100.7:443".into(),
                },
            ))
            .await
            .expect("the relay gets a machine's challenge")
            .into_inner();
        let mut kit = SoftPasskey::new(9);
        let ceremony = Ceremony {
            id: String::new(),
            challenge: gunnar_grpc::webauthn::b64url(&challenge.challenge),
            rp_id: RP_ID.into(),
            user_id: "kit-ops".into(),
            user_name: "kit-ops".into(),
            display: "kit-ops".into(),
            user_verification_required: false,
            expires_at_ms: challenge.expires_at_unix_ms,
            exclude: Vec::new(),
        };
        let made = kit.register(&ceremony, ORIGIN);
        let asked = access
            .request_machine_enrolment(b.as_console(
                "Access.RequestMachineEnrolment",
                RequestMachineEnrolmentRequest {
                    machine: "kit-ops".into(),
                    label: "the ops kit".into(),
                    server_level: GrantLevel::Admin as i32,
                    credential_id: made.credential_id.clone(),
                    public_key: made.public_key.clone(),
                    client_data_json: made.client_data_json.clone(),
                    rp_id: RP_ID.into(),
                    origin: ORIGIN.into(),
                    sign_count: 0,
                    attestation_object: made.attestation_object.clone(),
                },
            ))
            .await
            .expect("the kit's request is held")
            .into_inner()
            .request
            .unwrap();
        let approve = |token: &str| {
            with_session(
                ApproveMachineEnrolmentRequest {
                    request_id: asked.request_id.clone(),
                    code: asked.code.clone(),
                    server_level: GrantLevel::Unspecified as i32,
                },
                token,
            )
        };
        let unstepped = access
            .approve_machine_enrolment(approve(&admin.token))
            .await
            .unwrap_err();
        assert_eq!(
            unstepped.code(),
            tonic::Code::PermissionDenied,
            "{unstepped:?}"
        );
        assert!(
            unstepped.message().contains("[stepup]"),
            "the console answers it with a touch: {unstepped:?}"
        );
        step_up(&b.identity, &admin.token, &mut admin_key).expect("the admin steps up");
        access
            .approve_machine_enrolment(approve(&admin.token))
            .await
            .expect("a stepped-up admin approves");
        let mut kit = kit;
        let session = login(&b.identity, &mut kit, true).expect("the kit logs in through the core");
        assert_eq!(session.session.user_id, "kit-ops");
        assert!(session.session.admin, "as the role it was approved with");
    });
}