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());
}
}