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

nordisk/holger

server/cli/src/passkey_cli.rs

//! **`holger-server passkey …` — the people, from a shell.**
//!
//! holger owns its people: their passkeys, the invites that let them in, their
//! sessions and the role each one holds (`server_lib::passkey`, served as
//! `/-/auth/passkey/*` on the HTTP gateway). The console used to be the only
//! place an invite could be minted or a key revoked; that made those acts
//! UI-only, and a box with no console (or a console that is down) had no way to
//! do them. These verbs speak the same door the console does, with whatever
//! credential the operator holds — the box's static bearer (`--token-file
//! /var/lib/holger/admin-token`) or a passkey session token — and holger
//! decides exactly as it does for the console: admin for the administration,
//! step-up for a passkey session, never an admin invite.
//!
//! The door is plain HTTP (`ron_http_url`, on the estate's private network or
//! loopback), so this is a small HTTP/1.1 client over `std::net` — no TLS, no
//! new dependency.

use std::io::{Read, Write};
use std::net::TcpStream;
use std::path::PathBuf;
use std::time::Duration;

use anyhow::{anyhow, bail, Context, Result};
use clap::{Args, Subcommand};
use serde_json::{json, Value};

#[derive(Args)]
pub struct PasskeyArgs {
    /// holger's HTTP door, `host:port` or `http://host:port` (the RON's
    /// `ron_http_url`; on the box itself `127.0.0.1:8443`).
    #[arg(long, default_value = "127.0.0.1:8443")]
    door: String,
    /// A file holding the credential to act with: the box's static bearer, or
    /// a passkey session token. Else `$HOLGER_TOKEN`.
    #[arg(long)]
    token_file: Option<PathBuf>,
    #[command(subcommand)]
    verb: PasskeyVerb,
}

#[derive(Subcommand)]
enum PasskeyVerb {
    /// Who the credential is, and the role holger gives it.
    Whoami,
    /// Mint a single-use invite (24 h). Prints the secret ONCE — carry it out of
    /// band; the console's link is `https://<web host>/login?invite=<secret>`.
    Invite {
        /// reader or writer (an invite never grants admin).
        #[arg(long, default_value = "reader")]
        role: String,
        /// Pin the person's name; omitted, the invitee chooses one.
        #[arg(long)]
        name: Option<String>,
    },
    /// Every invite, open and spent.
    Invites,
    /// Revoke an open invite by its id.
    RevokeInvite {
        #[arg(long)]
        id: String,
    },
    /// Every registered passkey, with the role holger gives it.
    Credentials,
    /// Revoke a passkey (never the last admin) and end its person's sessions.
    RevokeCredential {
        #[arg(long)]
        id: String,
    },
    /// End every session on this server (nobody's key is touched).
    RevokeSessions,
}

pub fn run(a: PasskeyArgs) -> Result<()> {
    let token = match &a.token_file {
        Some(f) => std::fs::read_to_string(f)
            .with_context(|| format!("reading {}", f.display()))?
            .trim()
            .to_owned(),
        None => std::env::var("HOLGER_TOKEN")
            .unwrap_or_default()
            .trim()
            .to_owned(),
    };
    if token.is_empty() {
        bail!("no credential: --token-file FILE (the box's static bearer or a passkey session token) or $HOLGER_TOKEN");
    }
    let (path, body) = match &a.verb {
        PasskeyVerb::Whoami => ("session", json!({})),
        PasskeyVerb::Invite { role, name } => {
            let mut b = json!({ "role": role });
            if let Some(n) = name {
                b["display"] = json!(n);
            }
            ("invites", b)
        }
        PasskeyVerb::Invites => ("invites/list", json!({})),
        PasskeyVerb::RevokeInvite { id } => ("invites/revoke", json!({ "id": id })),
        PasskeyVerb::Credentials => ("credentials/list", json!({})),
        PasskeyVerb::RevokeCredential { id } => ("credentials/revoke", json!({ "id": id })),
        PasskeyVerb::RevokeSessions => ("sessions/revoke-all", json!({})),
    };
    let (status, answer) = post(&a.door, &format!("/-/auth/passkey/{path}"), &token, &body)?;
    if !(200..300).contains(&status) {
        let why = answer
            .get("error")
            .and_then(Value::as_str)
            .map(str::to_owned)
            .unwrap_or_else(|| answer.to_string());
        bail!("holger refused (HTTP {status}): {why}");
    }
    println!("{}", serde_json::to_string_pretty(&answer)?);
    Ok(())
}

/// Pure: `host:port` out of `host:port` or `http://host:port[/]`.
fn host_port(door: &str) -> Result<String> {
    if door.starts_with("https://") {
        bail!(
            "{door}: the passkey door is plain http:// on the estate's private network or loopback"
        );
    }
    let hp = door.trim_start_matches("http://").trim_end_matches('/');
    if hp.is_empty() || hp.contains('/') {
        bail!("{door:?} is not host:port");
    }
    Ok(hp.to_owned())
}

/// One HTTP/1.1 POST, `Connection: close`, JSON in and out.
fn post(door: &str, path: &str, token: &str, body: &Value) -> Result<(u16, Value)> {
    let hp = host_port(door)?;
    let mut stream = TcpStream::connect(&hp)
        .with_context(|| format!("holger's door at {hp} was not reached"))?;
    stream.set_read_timeout(Some(Duration::from_secs(30)))?;
    let payload = body.to_string();
    write!(
        stream,
        "POST {path} HTTP/1.1\r\nHost: {hp}\r\nAuthorization: Bearer {token}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{payload}",
        payload.len()
    )?;
    let mut raw = Vec::new();
    stream.read_to_end(&mut raw)?;
    parse_response(&raw)
}

/// Pure: an HTTP/1.1 response's status and JSON body (`Content-Length` or
/// chunked, the two shapes hyper answers with).
fn parse_response(raw: &[u8]) -> Result<(u16, Value)> {
    let split = raw
        .windows(4)
        .position(|w| w == b"\r\n\r\n")
        .ok_or_else(|| anyhow!("holger's answer has no header end"))?;
    let head = String::from_utf8_lossy(&raw[..split]);
    let status: u16 = head
        .lines()
        .next()
        .and_then(|l| l.split_whitespace().nth(1))
        .and_then(|s| s.parse().ok())
        .ok_or_else(|| anyhow!("holger's answer has no status line"))?;
    let mut body = raw[split + 4..].to_vec();
    if head.lines().any(|l| {
        l.to_ascii_lowercase().starts_with("transfer-encoding:")
            && l.to_ascii_lowercase().contains("chunked")
    }) {
        body = dechunk(&body)?;
    }
    let value = serde_json::from_slice(&body)
        .unwrap_or_else(|_| Value::String(String::from_utf8_lossy(&body).into_owned()));
    Ok((status, value))
}

fn dechunk(mut b: &[u8]) -> Result<Vec<u8>> {
    let mut out = Vec::new();
    loop {
        let line_end = b
            .windows(2)
            .position(|w| w == b"\r\n")
            .ok_or_else(|| anyhow!("a chunk without its size line"))?;
        let size = usize::from_str_radix(
            String::from_utf8_lossy(&b[..line_end])
                .split(';')
                .next()
                .unwrap_or("")
                .trim(),
            16,
        )?;
        b = &b[line_end + 2..];
        if size == 0 {
            return Ok(out);
        }
        if b.len() < size + 2 {
            bail!("a chunk shorter than it said");
        }
        out.extend_from_slice(&b[..size]);
        b = &b[size + 2..];
    }
}

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

    #[test]
    fn a_door_is_host_port_over_plain_http() {
        assert_eq!(host_port("127.0.0.1:8443").unwrap(), "127.0.0.1:8443");
        assert_eq!(
            host_port("http://10.13.8.2:8443/").unwrap(),
            "10.13.8.2:8443"
        );
        assert!(host_port("https://box:8443").is_err());
        assert!(host_port("http://box:8443/-/auth").is_err());
    }

    #[test]
    fn an_answer_is_read_whole_or_chunked() {
        let (s, v) = parse_response(
            b"HTTP/1.1 403 Forbidden\r\ncontent-length: 21\r\n\r\n{\"error\":\"no admin\"}\n",
        )
        .unwrap();
        assert_eq!((s, v["error"].as_str()), (403, Some("no admin")));
        let (s, v) = parse_response(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n6\r\n{\"ok\":\r\n5\r\ntrue}\r\n0\r\n\r\n").unwrap();
        assert_eq!((s, v["ok"].as_bool()), (200, Some(true)));
    }
}