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/src/login.rs

//! **`gunnar login`: the CLI's browser device flow** (5.4 §3b, owner
//! 2026-10-10: humans use passkeys; the CLI "a FIDO2 sk-ssh key or a
//! `gunnar login` browser device flow"; machine to machine stays tokens).
//!
//! 1. Mint a fresh ed25519 key HERE.
//! 2. `POST <console>/auth/device/start` with its public half: the console
//!    answers a secret `device_code` and a short `user_code`.
//! 3. Show the person the code and `<console>/login/device`; they type the
//!    code there and touch their passkey (user verification required). The
//!    console relays that to gunnar (`Access.WebLogin`), which enrols THIS
//!    key on them, expiring.
//! 4. Poll `POST <console>/auth/device/poll` until it says approved, denied
//!    or expired. Approved names the key's fingerprint — the key is kept only
//!    if that is THIS key.
//!
//! The private half never leaves this machine: it is written 0600 to
//! `~/.config/gunnar/login/<host>` beside its `.pub` and a `.json` saying who
//! and until when, and is used like any key, `-i <path>`. The default identity
//! ladder is NOT changed: which key signs is always the one the operator
//! named (see `control::private_key`).

use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};

use anyhow::{anyhow, bail, Context as _, Result};
use serde_json::{json, Value};

use crate::cli::LoginArgs;

/// What `login` is asked to do.
#[derive(Debug, Clone)]
pub struct Login {
    /// `https://console.example` (or `http://` to this machine).
    pub console: String,
    pub ttl_hours: u64,
    pub label: Option<String>,
    /// Where the key is written.
    pub dir: PathBuf,
}

/// A login that completed.
#[derive(Debug, Clone)]
pub struct LoggedIn {
    pub principal: String,
    pub fingerprint: String,
    pub expires_at_unix_ms: i64,
    /// The private key; `.pub` and `.json` beside it.
    pub key_path: PathBuf,
}

/// `gunnar login <console>`.
pub fn run(args: LoginArgs) -> Result<()> {
    let dir = match args.dir {
        Some(dir) => dir,
        None => default_dir()?,
    };
    let login = Login {
        console: args.console,
        ttl_hours: args.ttl,
        label: args.label,
        dir,
    };
    let logged = self::login(&login, &mut std::io::stderr())?;
    let hours = (logged.expires_at_unix_ms - now_ms()).max(0) / 3_600_000;
    println!("{}", logged.key_path.display());
    eprintln!(
        "gunnar: logged in as {} with {} for about {hours} h. Use it with `-i {}`; \
         it stops working when it expires — run `gunnar login` again then.",
        logged.principal,
        logged.fingerprint,
        logged.key_path.display()
    );
    Ok(())
}

fn default_dir() -> Result<PathBuf> {
    let base = match std::env::var_os("XDG_CONFIG_HOME").filter(|v| !v.is_empty()) {
        Some(config) => PathBuf::from(config),
        None => PathBuf::from(
            std::env::var_os("HOME")
                .filter(|v| !v.is_empty())
                .ok_or_else(|| anyhow!("HOME is not set; pass --dir"))?,
        )
        .join(".config"),
    };
    Ok(base.join("gunnar").join("login"))
}

fn now_ms() -> i64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| i64::try_from(d.as_millis()).unwrap_or(i64::MAX))
        .unwrap_or_default()
}

/// The console's origin, and a file name for it. `https://`, or `http://` to
/// this machine only: the device code collects a key, and on plain HTTP
/// anybody on the path could collect it first.
fn console_origin(raw: &str) -> Result<(String, String)> {
    let raw = raw.trim().trim_end_matches('/');
    let (scheme, rest) = raw
        .split_once("://")
        .ok_or_else(|| anyhow!("{raw:?} is not a URL; give the console as https://<host>"))?;
    let authority = rest.split('/').next().unwrap_or_default();
    if authority.is_empty() || authority.contains('@') || rest.contains('/') {
        bail!("{raw:?} is not a console origin; give it as https://<host>[:port]");
    }
    let host = match authority.rsplit_once(':') {
        Some((host, port)) if port.parse::<u16>().is_ok() => host,
        _ => authority,
    };
    let loopback = matches!(host, "127.0.0.1" | "localhost" | "[::1]");
    match scheme {
        "https" => {}
        "http" if loopback => {}
        _ => bail!("the console must be reached over https (http only to this machine): {raw:?}"),
    }
    let file: String = authority
        .chars()
        .map(|c| {
            if c.is_ascii_alphanumeric() || matches!(c, '.' | '-') {
                c
            } else {
                '_'
            }
        })
        .collect();
    Ok((format!("{scheme}://{authority}"), file))
}

fn agent() -> ureq::Agent {
    ureq::Agent::config_builder()
        .timeout_connect(Some(Duration::from_secs(10)))
        .timeout_global(Some(Duration::from_secs(30)))
        .http_status_as_error(false)
        .proxy(None)
        .max_redirects(0)
        .user_agent(concat!("gunnar-login/", env!("CARGO_PKG_VERSION")))
        .build()
        .new_agent()
}

fn post(agent: &ureq::Agent, url: &str, body: &Value) -> Result<Value> {
    let mut response = agent
        .post(url)
        .header("Content-Type", "application/json")
        .send(body.to_string())
        .with_context(|| format!("reaching {url}"))?;
    let status = response.status().as_u16();
    let text = response
        .body_mut()
        .with_config()
        .limit(64 * 1024)
        .read_to_string()
        .with_context(|| format!("reading {url}'s answer"))?;
    if !(200..300).contains(&status) {
        bail!("{url} answered {status}: {}", text.trim());
    }
    serde_json::from_str(&text).with_context(|| format!("{url} did not answer JSON"))
}

fn default_label() -> String {
    let user = std::env::var("USER").unwrap_or_else(|_| "someone".to_owned());
    let host = std::fs::read_to_string("/proc/sys/kernel/hostname")
        .ok()
        .or_else(|| std::env::var("HOSTNAME").ok())
        .map(|h| h.trim().to_owned())
        .filter(|h| !h.is_empty())
        .unwrap_or_else(|| "this machine".to_owned());
    format!("gunnar login: {user}@{host}")
}

/// **Log in through `login.console`**, telling the person what to do on
/// `tell`. Writes the key only once the console's approval names it.
///
/// # Errors
///
/// The console cannot be reached or refuses, the person denies it, the code
/// expires, or the approval names another key.
pub fn login(login: &Login, tell: &mut dyn Write) -> Result<LoggedIn> {
    let (origin, file) = console_origin(&login.console)?;
    let key = ssh_key::PrivateKey::random(
        &mut rand::rand_core::UnwrapErr(rand::rngs::SysRng),
        ssh_key::Algorithm::Ed25519,
    )
    .map_err(|e| anyhow!("minting a key: {e}"))?;
    let public = key
        .public_key()
        .to_openssh()
        .map_err(|e| anyhow!("encoding the key: {e}"))?;
    let fingerprint = key
        .public_key()
        .fingerprint(ssh_key::HashAlg::Sha256)
        .to_string();
    let agent = agent();
    let started = post(
        &agent,
        &format!("{origin}/auth/device/start"),
        &json!({
            "public_key": public,
            "label": login.label.clone().unwrap_or_else(default_label),
            "ttl_secs": login.ttl_hours.saturating_mul(3600),
        }),
    )?;
    let field = |name: &str| {
        started
            .get(name)
            .and_then(Value::as_str)
            .map(str::to_owned)
            .ok_or_else(|| anyhow!("the console's answer has no {name}"))
    };
    let device_code = field("device_code")?;
    let user_code = field("user_code")?;
    let page = field("verification_uri")?;
    // The page the person is sent to is the console's own — never a link the
    // console could point elsewhere.
    if !page.starts_with(&format!("{origin}/")) {
        bail!("the console sent the person to {page:?}, which is not {origin}");
    }
    let expires_in = started
        .get("expires_in")
        .and_then(Value::as_u64)
        .unwrap_or(600)
        .min(3600);
    let interval = Duration::from_secs(
        started
            .get("interval")
            .and_then(Value::as_u64)
            .unwrap_or(2)
            .clamp(1, 30),
    );
    writeln!(tell, "\nTo log in, open\n\n    {page}\n\nand type the code\n\n    {user_code}\n\nthen touch your passkey. Key: {fingerprint}\nWaiting …")?;
    tell.flush()?;

    let deadline = Instant::now() + Duration::from_secs(expires_in);
    let poll_url = format!("{origin}/auth/device/poll");
    loop {
        let answer = post(&agent, &poll_url, &json!({ "device_code": device_code }))?;
        match answer
            .get("state")
            .and_then(Value::as_str)
            .unwrap_or_default()
        {
            "pending" => {}
            "approved" => {
                let named = answer
                    .get("fingerprint")
                    .and_then(Value::as_str)
                    .unwrap_or_default();
                if named != fingerprint {
                    bail!("the console approved key {named:?}, not this login's key {fingerprint}; nothing was kept");
                }
                let principal = answer
                    .get("principal")
                    .and_then(Value::as_str)
                    .filter(|p| !p.is_empty())
                    .ok_or_else(|| anyhow!("the console approved the key and named nobody"))?
                    .to_owned();
                let expires_at_unix_ms = answer
                    .get("expires_at_unix_ms")
                    .and_then(Value::as_i64)
                    .unwrap_or_default();
                let key_path = keep(
                    &login.dir,
                    &file,
                    &key,
                    &public,
                    &json!({
                        "principal": principal,
                        "fingerprint": fingerprint,
                        "expires_at_unix_ms": expires_at_unix_ms,
                        "console": origin,
                    }),
                )?;
                return Ok(LoggedIn {
                    principal,
                    fingerprint,
                    expires_at_unix_ms,
                    key_path,
                });
            }
            "denied" => bail!("the login was denied at the console; nothing was kept"),
            "expired" => bail!("the code expired before it was approved; run `gunnar login` again"),
            other => bail!("the console answered a state this CLI does not know: {other:?}"),
        }
        if Instant::now() >= deadline {
            bail!("the code expired before it was approved; run `gunnar login` again");
        }
        std::thread::sleep(interval);
    }
}

/// Write the key 0600 (dir 0700), with its `.pub` and `.json` beside it.
fn keep(
    dir: &Path,
    file: &str,
    key: &ssh_key::PrivateKey,
    public: &str,
    meta: &Value,
) -> Result<PathBuf> {
    use std::os::unix::fs::{DirBuilderExt as _, OpenOptionsExt as _};
    std::fs::DirBuilder::new()
        .recursive(true)
        .mode(0o700)
        .create(dir)
        .with_context(|| format!("creating {}", dir.display()))?;
    let path = dir.join(file);
    let pem = key
        .to_openssh(ssh_key::LineEnding::LF)
        .map_err(|e| anyhow!("encoding the key: {e}"))?;
    // A key from an earlier login is replaced, never appended to or shared.
    let _ = std::fs::remove_file(&path);
    let mut out = std::fs::OpenOptions::new()
        .write(true)
        .create_new(true)
        .mode(0o600)
        .open(&path)
        .with_context(|| format!("writing {}", path.display()))?;
    out.write_all(pem.as_bytes())?;
    out.sync_all()?;
    std::fs::write(path.with_extension("pub"), format!("{}\n", public.trim()))?;
    std::fs::write(path.with_extension("json"), format!("{meta:#}\n"))?;
    Ok(path)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_console_origin_is_https_or_this_machine() {
        assert_eq!(
            console_origin("https://console.example/").expect("https"),
            (
                "https://console.example".to_owned(),
                "console.example".to_owned()
            )
        );
        assert_eq!(
            console_origin("http://127.0.0.1:8080").expect("loopback").1,
            "127.0.0.1_8080"
        );
        assert_eq!(
            console_origin("http://[::1]:8080").expect("loopback").0,
            "http://[::1]:8080"
        );
        assert!(console_origin("http://console.example").is_err());
        assert!(console_origin("https://user@console.example").is_err());
        assert!(console_origin("https://console.example/login").is_err());
        assert!(console_origin("console.example").is_err());
        assert!(console_origin("http://localhost.evil.example").is_err());
    }
}