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

//! **A web login is the identity core's, over Gunnar's people** (IDENTITY-HOST,
//! 2026-10-27; Stage L before it, 2026-10-04: "passkeys + invites +
//! passkey↔principal pin → gunnar-server").
//!
//! Gunnar's own `Access.WebChallenge/WebLogin/WebRegister` are gone: the
//! console relays `nornir.identity.v1.IdentityService` (LoginStart/Finish,
//! RedeemStart/Finish) and the core verifies the ceremony. What stays Gunnar's
//! is WHERE the passkey and the person live — the control plane's principals
//! and web credentials — and what a session then is: a credential the real gate
//! takes as the person (`nornir-identity-session`). These tests hold that
//! half, through the core over Gunnar's plane:
//!
//! * a passkey Gunnar holds logs its person in, and its counter and last use move;
//! * a broken or foreign assertion mints nothing;
//! * an invite's groups are Gunnar's server grant, applied once;
//! * a registration whose attestation names another key stores nothing;
//! * one browser's flood of logins leaves everybody else's alone;
//! * the server decides user verification.
//!
//! The ceremony's own checks (each field of the client data, the counter) are
//! the core's conformance suite, run against Gunnar in `identity_conformance`.

use std::sync::Arc;

use gunnar_grpc::identity::{
    system_clock, GunnarHost, GunnarIdentity, GunnarStore, IdentityTables, SERVER_ADMIN,
    SERVER_WRITE,
};
use gunnar_grpc::Identity as _;
use gunnar_grpc::{ControlPlane, MemoryControlPlane};
use gunnar_proto::*;
use nornir_identity::conformance::{config, first_admin_stepped, relay, Harness, ORIGIN};
use nornir_identity::test_host::{Kit, SoftPasskey};
use nornir_identity::{
    ClientKey, Code, Config, Identity, InviteAsk, NoMail, Relay, Site, UserVerification,
};

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![SERVER_WRITE.to_owned()]
    }
}

struct Rig {
    plane: Arc<MemoryControlPlane>,
    identity: GunnarIdentity,
}

impl Rig {
    fn new() -> Rig {
        Rig::with(config())
    }

    fn with(core: Config) -> Rig {
        let plane = Arc::new(MemoryControlPlane::new());
        plane
            .add_principal("kim", "kim", PrincipalKind::User)
            .expect("kim");
        let control: Arc<dyn ControlPlane> = plane.clone();
        let store = GunnarStore::new(control, IdentityTables::default(), None, system_clock());
        let identity = Identity::new(
            GunnarHost::new(Site::Private, store, Arc::new(NoMail), system_clock()),
            core,
        );
        Rig { plane, identity }
    }

    /// kim's passkey, held at Gunnar (as the console's first-login link left it).
    fn passkey(&self, seed: u8, count: u32) -> SoftPasskey {
        let key = SoftPasskey::new(seed);
        self.plane
            .add_credential("kim", &key.id, &key.public_key(), "kim's YubiKey", count)
            .expect("kim's passkey");
        key
    }

    fn login(
        &self,
        key: &mut SoftPasskey,
        uv: bool,
    ) -> Result<nornir_identity::LoggedIn, nornir_identity::Refused> {
        let c = self.identity.login_start(&relay())?;
        self.identity.login_finish(key.login(&c, ORIGIN, uv))
    }

    fn sessions(&self) -> usize {
        self.identity.host().gunnar_store().tables().sessions.len()
    }
}

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

/// ★ **A person logs in with the passkey Gunnar holds**: the session is kim's,
/// and the passkey's counter and last use move at Gunnar.
#[test]
fn a_passkey_login_is_verified_by_the_core_over_gunnars_passkey() {
    let rig = Rig::new();
    let mut key = rig.passkey(1, 0);
    let kim = rig
        .login(&mut key, true)
        .expect("a genuine assertion logs kim in");
    assert_eq!(kim.session.user_id, "kim");
    assert!(
        kim.session.stepped_up,
        "a user-verified login is a fresh touch"
    );
    assert!(!kim.session.admin);
    let stored = rig.plane.credential(&key.id).expect("read").expect("there");
    assert_eq!(stored.credential.sign_count, 1, "the counter moved");
    assert!(
        stored.credential.last_used_unix_ms.is_some(),
        "the last use is recorded"
    );
    assert_eq!(
        rig.identity
            .session(&kim.token)
            .expect("the session")
            .user_id,
        "kim"
    );
}

/// ★ **A broken or foreign assertion mints nothing**: another origin, a
/// passkey Gunnar does not hold, a ceremony answered twice.
#[test]
fn a_broken_assertion_is_refused_and_no_session_lands() {
    let rig = Rig::new();
    let mut key = rig.passkey(2, 0);
    let c = rig.identity.login_start(&relay()).unwrap();
    assert_eq!(
        code(
            rig.identity
                .login_finish(key.login(&c, "https://evil.example", true))
        ),
        Code::Ceremony,
        "another origin"
    );
    let mut stranger = SoftPasskey::new(3);
    assert_eq!(
        code(rig.login(&mut stranger, true)),
        Code::Ceremony,
        "a passkey Gunnar does not hold"
    );
    let c = rig.identity.login_start(&relay()).unwrap();
    let answer = key.login(&c, ORIGIN, true);
    rig.identity
        .login_finish(answer.clone())
        .expect("the genuine one");
    assert_eq!(
        code(rig.identity.login_finish(answer)),
        Code::Ceremony,
        "a ceremony is single use"
    );
    assert_eq!(rig.sessions(), 1, "only the genuine login minted a session");
}

/// ★ **An invite's groups are Gunnar's server grant**: an admin's invite for
/// an operator (server Write) makes a new person holding Write once redeemed,
/// the invite is spent, and the new passkey logs in.
#[test]
fn an_invite_registers_a_passkey_once_applies_its_grant_and_the_passkey_logs_in() {
    let rig = Rig::new();
    // RA5-2 (nornir-identity 0.2.2): an invite needs a fresh step-up.
    let (admin, _) = first_admin_stepped(&Plain, &rig.identity);
    let invited = rig
        .identity
        .invite(
            &admin.token,
            InviteAsk {
                display: "Ola".into(),
                groups: vec![SERVER_WRITE.into()],
                ..InviteAsk::default()
            },
        )
        .expect("an operator's invite");
    let c = rig
        .identity
        .redeem_start(&invited.secret, "", &relay())
        .expect("the ceremony");
    let mut key = SoftPasskey::new(5);
    let ola = rig
        .identity
        .redeem_finish(key.register(&c, ORIGIN))
        .expect("ola's passkey");
    assert_eq!(ola.session.display, "Ola");
    assert!(!ola.session.admin, "an invite never makes an admin");
    let id = ola.session.user_id.clone();
    assert!(
        rig.plane
            .may(&id, &gunnar_grpc::auth::Access::Server, GrantLevel::Write)
            .unwrap(),
        "Write applied at Gunnar"
    );
    assert!(!rig
        .plane
        .may(&id, &gunnar_grpc::auth::Access::Server, GrantLevel::Admin)
        .unwrap());
    assert_eq!(
        rig.plane.credentials(Some(&id)).unwrap().len(),
        1,
        "the passkey is Gunnar's"
    );
    assert_eq!(
        code(rig.identity.redeem_start(&invited.secret, "", &relay())),
        Code::InviteNotOpen,
        "spent"
    );
    let again = rig.login(&mut key, false).expect("the new passkey logs in");
    assert_eq!(again.session.user_id, id);
}

/// A registration whose attestation names another key than the one relayed
/// is refused, and Gunnar stores nothing.
#[test]
fn a_registration_whose_attestation_names_another_key_is_refused() {
    let rig = Rig::new();
    let (admin, _) = first_admin_stepped(&Plain, &rig.identity);
    let link = rig
        .identity
        .invite(
            &admin.token,
            InviteAsk {
                person: Some("kim".into()),
                ..InviteAsk::default()
            },
        )
        .expect("kim's link");
    let c = rig
        .identity
        .redeem_start(&link.secret, "", &relay())
        .unwrap();
    let mut made = SoftPasskey::new(6).register(&c, ORIGIN);
    made.public_key = SoftPasskey::new(7).public_key();
    assert_eq!(code(rig.identity.redeem_finish(made)), Code::Ceremony);
    assert!(
        rig.plane.credentials(Some("kim")).unwrap().is_empty(),
        "a refused key was stored"
    );
}

/// ★ **A34-7: a flood of login starts from one browser leaves every other
/// person's login alone.**
#[test]
fn a_flood_of_challenges_for_one_browser_leaves_every_other_persons_login_alone() {
    let rig = Rig::new();
    let flood = Relay {
        client: Some(ClientKey::of_peer("203.0.113.9")),
        ..relay()
    };
    let refused = (0..200)
        .find_map(|_| rig.identity.login_start(&flood).err())
        .expect("one browser is bounded");
    assert_eq!(refused.code, Code::Busy, "{refused:?}");
    let mut key = rig.passkey(8, 0);
    let elsewhere = Relay {
        client: Some(ClientKey::of_peer("198.51.100.4")),
        ..relay()
    };
    let c = rig
        .identity
        .login_start(&elsewhere)
        .expect("kim elsewhere gets a challenge");
    rig.identity
        .login_finish(key.login(&c, ORIGIN, true))
        .expect("and logs in");
}

/// ★ **A34-12: the server decides user verification** (`--web-require-user-verification`):
/// a touch without a PIN or biometric is refused.
#[test]
fn the_server_decides_user_verification() {
    let strict = Rig::with(Config {
        user_verification: UserVerification::Required,
        ..config()
    });
    let mut key = strict.passkey(9, 0);
    assert!(
        strict
            .identity
            .login_start(&relay())
            .unwrap()
            .user_verification_required
    );
    assert_eq!(
        code(strict.login(&mut key, false)),
        Code::Ceremony,
        "no UV, refused"
    );
    assert!(strict.login(&mut key, true).is_ok(), "UV, in");
    let lenient = Rig::new();
    let mut key = lenient.passkey(10, 0);
    assert!(
        !lenient.login(&mut key, false).unwrap().session.stepped_up,
        "a plain touch is no step-up"
    );
}

/// ★ WWW-CANON (owner 2026-10-30: the site is www.gunnar.rs): **a passkey registered with RP ID
/// `gunnar.rs` at the bare origin signs in at `https://www.gunnar.rs`** — the relying party stays
/// the registrable domain, so the passkeys made on the bare host keep working on www. The server's
/// allowed origins take www (and the bare origin during the change); another site is refused,
/// at the core's origin check and at the server's relying-party list.
#[test]
fn a_passkey_registered_at_the_bare_site_signs_in_at_www() {
    use nornir_identity::RelyingParty;
    let bare = Relay {
        rp_id: "gunnar.rs".into(),
        origin: "https://gunnar.rs".into(),
        client: None,
    };
    let www = Relay {
        origin: "https://www.gunnar.rs".into(),
        ..bare.clone()
    };
    let mut parties = config().parties;
    parties.push(
        "gunnar.rs=https://www.gunnar.rs,https://gunnar.rs"
            .parse::<RelyingParty>()
            .expect("www and the bare origin"),
    );
    let rig = Rig::with(Config {
        parties,
        ..config()
    });
    let (admin, _) = first_admin_stepped(&Plain, &rig.identity);
    let invited = rig
        .identity
        .invite(
            &admin.token,
            InviteAsk {
                display: "Ola".into(),
                groups: vec![SERVER_WRITE.into()],
                ..InviteAsk::default()
            },
        )
        .expect("an invite");
    // Registered on the bare host, as the owner's passkeys were.
    let c = rig
        .identity
        .redeem_start(&invited.secret, "", &bare)
        .expect("the ceremony on the bare host");
    let mut key = SoftPasskey::new(9);
    let ola = rig
        .identity
        .redeem_finish(key.register(&c, "https://gunnar.rs"))
        .expect("the passkey, RP ID gunnar.rs");
    // Signs in on www.
    let c = rig.identity.login_start(&www).expect("a ceremony at www");
    let at_www = rig
        .identity
        .login_finish(key.login(&c, "https://www.gunnar.rs", true))
        .expect("the bare host's passkey signs in at https://www.gunnar.rs");
    assert_eq!(at_www.session.user_id, ola.session.user_id);
    // ...and still on the bare origin while the change rolls out.
    let c = rig
        .identity
        .login_start(&bare)
        .expect("a ceremony at the bare origin");
    rig.identity
        .login_finish(key.login(&c, "https://gunnar.rs", true))
        .expect("the bare origin, kept during the change");
    // A browser elsewhere: its client data names another origin.
    let c = rig.identity.login_start(&www).unwrap();
    assert_eq!(
        code(
            rig.identity
                .login_finish(key.login(&c, "https://evil.example", true))
        ),
        Code::Ceremony,
        "another origin's assertion"
    );
    // A relay stating another site is not one this server answers for.
    let evil = Relay {
        origin: "https://evil.example".into(),
        ..bare.clone()
    };
    assert!(
        rig.identity.login_start(&evil).is_err(),
        "an origin outside the relying party's list"
    );
}