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

nordisk/gunnar

crates/gunnar-cli/tests/login.rs

//! **`gunnar login`: the CLI's browser device flow** (5.4 §3b passkey-cli,
//! owner 2026-10-10: "CLI uses a FIDO2 sk-ssh key or a `gunnar login` browser
//! device flow").
//!
//! The CLI mints its OWN ed25519 key, asks the console
//! (`POST /auth/device/start`), shows the person the code and the page, and
//! polls. The person types the code at the console and touches their passkey;
//! gunnar enrols the CLI's key on them, expiring. The private half never
//! leaves this machine: it is written 0600 beside its `.pub`, and the CLI
//! keeps it only when the console's answer names THAT key.
//!
//! The console here is a fake speaking the console's JSON over plain HTTP on
//! loopback.

#![cfg(feature = "control")]

use std::io::{BufRead as _, BufReader, Read as _, Write as _};
use std::net::TcpListener;
use std::sync::{Arc, Mutex};

#[derive(Clone, Copy, PartialEq, Eq)]
enum Verdict {
    Approve,
    Deny,
    /// Approve, naming a key that is not the one the CLI sent.
    ApproveAnother,
}

#[derive(Default)]
struct Heard {
    public_key: Option<String>,
    label: Option<String>,
    ttl_secs: Option<u64>,
    polls: u32,
}

fn fingerprint(line: &str) -> String {
    ssh_key::PublicKey::from_openssh(line.trim())
        .expect("an OpenSSH key")
        .fingerprint(ssh_key::HashAlg::Sha256)
        .to_string()
}

/// A console on loopback that approves (or not) on the second poll.
fn fake_console(verdict: Verdict) -> (String, Arc<Mutex<Heard>>) {
    let listener = TcpListener::bind("127.0.0.1:0").expect("a port");
    let url = format!("http://{}", listener.local_addr().expect("an address"));
    let heard = Arc::new(Mutex::new(Heard::default()));
    let seen = heard.clone();
    let origin = url.clone();
    std::thread::spawn(move || {
        for stream in listener.incoming() {
            let Ok(stream) = stream else { return };
            let mut reader = BufReader::new(stream);
            let mut line = String::new();
            if reader.read_line(&mut line).is_err() {
                continue;
            }
            let path = line
                .split_whitespace()
                .nth(1)
                .unwrap_or_default()
                .to_owned();
            let mut length = 0usize;
            loop {
                let mut header = String::new();
                reader.read_line(&mut header).expect("a header");
                if header.trim().is_empty() {
                    break;
                }
                if let Some((name, value)) = header.split_once(':') {
                    if name.eq_ignore_ascii_case("content-length") {
                        length = value.trim().parse().unwrap_or(0);
                    }
                }
            }
            let mut body = vec![0u8; length];
            reader.read_exact(&mut body).expect("a body");
            let body: serde_json::Value =
                serde_json::from_slice(&body).unwrap_or(serde_json::Value::Null);
            let answer = {
                let mut heard = seen.lock().expect("heard");
                match path.as_str() {
                    "/auth/device/start" => {
                        heard.public_key = body["public_key"].as_str().map(str::to_owned);
                        heard.label = body["label"].as_str().map(str::to_owned);
                        heard.ttl_secs = body["ttl_secs"].as_u64();
                        serde_json::json!({
                            "device_code": "d".repeat(64),
                            "user_code": "BCDF-GHJK",
                            "verification_uri": format!("{origin}/login/device"),
                            "expires_in": 30,
                            "interval": 0,
                        })
                    }
                    "/auth/device/poll" => {
                        assert_eq!(
                            body["device_code"],
                            "d".repeat(64),
                            "the CLI polls with the device code"
                        );
                        heard.polls += 1;
                        if heard.polls < 2 {
                            serde_json::json!({ "state": "pending" })
                        } else {
                            match verdict {
                                Verdict::Deny => serde_json::json!({ "state": "denied" }),
                                Verdict::Approve | Verdict::ApproveAnother => {
                                    let fp = match verdict {
                                        Verdict::Approve => {
                                            fingerprint(heard.public_key.as_deref().expect("a key"))
                                        }
                                        _ => "SHA256:somebody-elses-key".to_owned(),
                                    };
                                    serde_json::json!({
                                        "state": "approved",
                                        "principal": "anna",
                                        "fingerprint": fp,
                                        "expires_at_unix_ms": 4_000_000_000_000i64,
                                    })
                                }
                            }
                        }
                    }
                    _ => serde_json::Value::Null,
                }
            };
            let text = answer.to_string();
            let status = if answer.is_null() {
                "404 Not Found"
            } else {
                "200 OK"
            };
            let mut stream = reader.into_inner();
            let _ = write!(
                stream,
                "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{text}",
                text.len()
            );
        }
    });
    (url, heard)
}

fn options(console: &str, dir: &std::path::Path) -> gunnar_cli::login::Login {
    gunnar_cli::login::Login {
        console: console.to_owned(),
        ttl_hours: 8,
        label: Some("anna's laptop".to_owned()),
        dir: dir.to_path_buf(),
    }
}

#[test]
fn gunnar_login_keeps_its_own_key_once_the_console_names_it() {
    let (console, heard) = fake_console(Verdict::Approve);
    let dir = tempfile::tempdir().expect("a dir");
    let mut told = Vec::new();
    let logged = gunnar_cli::login::login(&options(&console, dir.path()), &mut told)
        .expect("the login completes");

    let told = String::from_utf8(told).expect("utf-8");
    assert!(
        told.contains("BCDF-GHJK"),
        "the person is shown the code: {told}"
    );
    assert!(
        told.contains(&format!("{console}/login/device")),
        "and where to type it: {told}"
    );

    let heard = heard.lock().expect("heard");
    let sent = heard.public_key.clone().expect("the CLI sent a key");
    assert!(sent.starts_with("ssh-ed25519 "), "an ed25519 key: {sent}");
    assert_eq!(heard.label.as_deref(), Some("anna's laptop"));
    assert_eq!(heard.ttl_secs, Some(8 * 3600));

    assert_eq!(logged.principal, "anna");
    assert_eq!(logged.fingerprint, fingerprint(&sent));
    let private = ssh_key::PrivateKey::read_openssh_file(&logged.key_path)
        .expect("the private key was written");
    assert_eq!(
        private
            .public_key()
            .fingerprint(ssh_key::HashAlg::Sha256)
            .to_string(),
        logged.fingerprint,
        "the key on disk is the key enrolled"
    );
    {
        use std::os::unix::fs::PermissionsExt as _;
        let mode = std::fs::metadata(&logged.key_path)
            .expect("a file")
            .permissions()
            .mode()
            & 0o777;
        assert_eq!(mode, 0o600, "the private key is the owner's alone");
    }
    let public =
        std::fs::read_to_string(logged.key_path.with_extension("pub")).expect("a .pub beside it");
    assert_eq!(fingerprint(&public), logged.fingerprint);
    let meta: serde_json::Value = serde_json::from_str(
        &std::fs::read_to_string(logged.key_path.with_extension("json"))
            .expect("a .json beside it"),
    )
    .expect("json");
    assert_eq!(meta["principal"], "anna");
    assert_eq!(meta["expires_at_unix_ms"], 4_000_000_000_000i64);
    assert_eq!(meta["console"], console);
}

#[test]
fn a_denied_login_keeps_no_key() {
    let (console, _heard) = fake_console(Verdict::Deny);
    let dir = tempfile::tempdir().expect("a dir");
    let why = gunnar_cli::login::login(&options(&console, dir.path()), &mut Vec::new())
        .expect_err("denied");
    assert!(why.to_string().contains("denied"), "{why}");
    assert_eq!(
        std::fs::read_dir(dir.path()).expect("the dir").count(),
        0,
        "no key is kept for a login nobody approved"
    );
}

#[test]
fn an_approval_for_another_key_is_not_this_login() {
    let (console, _heard) = fake_console(Verdict::ApproveAnother);
    let dir = tempfile::tempdir().expect("a dir");
    let why = gunnar_cli::login::login(&options(&console, dir.path()), &mut Vec::new())
        .expect_err("another key");
    assert!(why.to_string().contains("key"), "{why}");
    assert_eq!(std::fs::read_dir(dir.path()).expect("the dir").count(), 0);
}

#[test]
fn a_console_is_reached_over_https_unless_it_is_this_machine() {
    let dir = tempfile::tempdir().expect("a dir");
    let why = gunnar_cli::login::login(
        &options("http://console.example", dir.path()),
        &mut Vec::new(),
    )
    .expect_err("plain http");
    assert!(why.to_string().contains("https"), "{why}");
}

#[test]
fn gunnar_login_is_a_verb() {
    use clap::Parser as _;
    let cli = gunnar_common::cli::Cli::try_parse_from([
        "gunnar",
        "login",
        "https://console.example",
        "--ttl",
        "4",
    ])
    .expect("parses");
    match cli.command {
        gunnar_common::cli::Command::Login(args) => {
            assert_eq!(args.console, "https://console.example");
            assert_eq!(args.ttl, 4);
        }
        other => panic!("not a login: {other:?}"),
    }
}