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

//! `gunnar crypt key …`: the person's ONE encryption key (CRYPT-YUBI,
//! 2026-10-26), kept at the forge as its public half and one wrap per unlock
//! method (`gunnar_crypt::userkey`), over the forge's SSH door
//! (`gunnar-user-key`, `gunnar_crypt::door`).
//!
//! ```text
//! gunnar crypt key status                    your key and its unlock methods
//! gunnar crypt key new [--classic]           the first time: a key (quantum-safe unless
//!                                            --classic), wrapped to your SSH key
//! gunnar crypt key add-ssh [PUB]             one more SSH key opens it
//! gunnar crypt key add-pq [PUB]              your `gunnar crypt keygen` key opens it
//! gunnar crypt key add-security-key --rp-id HOST   a FIDO2 security key opens it (CLI and browser)
//! gunnar crypt key add-recovery-code         a printed code opens it
//! gunnar crypt key remove ID                 delete one method's wrap
//! gunnar crypt key repin --fingerprint FP    trust the key the forge holds now (after checking it)
//! ```
//!
//! Adding or removing a method changes this one wrap at the forge and no
//! repository. Every repository you are a member of is wrapped to the public
//! half, so any one method opens all of them.

use std::path::PathBuf;

use anyhow::{anyhow, bail, Result};
use gunnar_crypt::door::{Answer, MethodLine, Request};
use gunnar_crypt::members::Level;
use gunnar_crypt::userkey::{self, recovery_code, security_key, UserKey, UserKeyError};

use crate::cli::CryptKeyCommand;
use crate::crypt::{door_exec_seen, identities, ssh_door, Repo};

/// How to open the key: the key files to try, and the other methods asked for.
#[derive(Debug, Default, Clone)]
pub struct Openers {
    /// `-i`: SSH key files or a `gunnar crypt keygen` file (default: the usual ones).
    pub identity: Vec<PathBuf>,
    /// `--security-key`: a FIDO2 security key, over USB.
    pub security_key: bool,
    /// `--recovery-code`: asked on the terminal.
    pub recovery_code: bool,
}

/// Ask the forge's door about the caller's own key. The SSH host key the door
/// answered with must be the one this machine pinned for that forge (RA5-4).
pub(crate) fn door(local: &Repo, request: &Request) -> Result<Answer> {
    let (answer, host_key) = door_seen(local, request)?;
    pins::host_key_is_pinned(local, &host_key)?;
    Ok(answer)
}

/// [`door`] without the host key check: the answer, and the host key it came with.
fn door_seen(local: &Repo, request: &Request) -> Result<(Answer, String)> {
    let origin = local.git(&["remote", "get-url", "origin"]).map_err(|e| {
        anyhow!(
            "your encryption key is kept at the forge, and this clone has no origin to ask ({e:#}): \
             add the forge as origin first (git remote add origin <ssh url>)"
        )
    })?;
    let (target, port) = ssh_door(&origin).ok_or_else(|| {
        anyhow!("your encryption key is kept at the forge, and origin is not an SSH URL ({}); set origin to the forge's SSH address", origin.trim())
    })?;
    let (said, host_key) = door_exec_seen(local, &target, port, None, &request.line())?;
    match said {
        Ok(said) => Ok((
            Answer::parse(&said).ok_or_else(|| {
                anyhow!("the forge at {target} answered something that is no encryption key")
            })?,
            host_key,
        )),
        Err(why) => bail!("the forge at {target}: {why}"),
    }
}

/// The caller's key at the forge, or the sentence that says how to make one.
/// The public half is checked against this machine's pin (RA4-1).
pub(crate) fn mine(local: &Repo) -> Result<Answer> {
    let (a, host_key) = door_seen(local, &Request::Get)?;
    let Some(public) = a.public_key.as_deref() else {
        bail!(
            "you have no encryption key yet: make one with `gunnar crypt key new` (it is wrapped to \
             your SSH key), or add a security key on your Account page in the console"
        );
    };
    pins::check_own(local, public, &host_key)?;
    Ok(a)
}

/// **Pinned encryption keys** (RA4-1, trust on first use): this machine
/// remembers the person's OWN encryption public key the first time it makes or
/// sees it, and every member key it was told to trust, per forge host, in
/// `$XDG_CONFIG_HOME/gunnar/encryption-pins` (else `~/.config/gunnar/…`, mode
/// 0600). A forge answer that differs is refused by name: a server that swapped
/// a person's key and its wrap would otherwise get every new ring wrapped to its
/// own key.
///
/// **The own pin is ONE per forge host, never looked up by the forge's word**
/// (RA5-4). It holds the forge's SSH host key, the account name the forge said
/// the first time, and the key: a forge that later signs in with another host
/// key, answers `gunnar-whoami` with another name, or hands out another key is
/// refused by name, and so is a name that is not the operator's own
/// `gunnar.account` (git config) when one is set. Only `gunnar crypt key repin
/// --fingerprint <the new key's>` takes a changed one.
pub mod pins {
    use std::path::PathBuf;

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

    use crate::crypt::{ssh_door, Repo};

    /// Where the pins live.
    pub fn path() -> Option<PathBuf> {
        let base = std::env::var_os("XDG_CONFIG_HOME")
            .filter(|d| !d.is_empty())
            .map(PathBuf::from)
            .or_else(|| {
                std::env::var_os("HOME")
                    .filter(|h| !h.is_empty())
                    .map(|h| PathBuf::from(h).join(".config"))
            })?;
        Some(base.join("gunnar").join("encryption-pins"))
    }

    /// The forge host this clone's origin names (without a user or a port).
    pub(crate) fn forge(local: &Repo) -> Result<String> {
        let origin = local
            .git(&["remote", "get-url", "origin"])
            .map_err(|_| anyhow!("this clone has no origin"))?;
        let (target, _) = ssh_door(&origin)
            .ok_or_else(|| anyhow!("origin is not an SSH URL ({})", origin.trim()))?;
        Ok(target.rsplit('@').next().unwrap_or(&target).to_string())
    }

    /// The whole pin file; empty when there is none yet. Any other read error
    /// is an error: an unread file is never written over.
    fn text() -> Result<String> {
        let Some(p) = path() else {
            return Ok(String::new());
        };
        match std::fs::read_to_string(&p) {
            Ok(t) => Ok(t),
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
            Err(e) => {
                Err(e).with_context(|| format!("read {} (the pinned encryption keys)", p.display()))
            }
        }
    }

    /// The pinned key of `(forge, who)`, `who` a member name.
    fn read(forge: &str, who: &str) -> Result<Option<String>> {
        let text = text()?;
        Ok(text.lines().find_map(|l| {
            let mut w = l.split_ascii_whitespace();
            match (w.next(), w.next(), w.next()) {
                (Some(f), Some(n), Some(k)) if f == forge && n == who => Some(k.to_string()),
                _ => None,
            }
        }))
    }

    /// Write the pin file with `keep` lines kept and `line` added.
    fn rewrite(keep: impl Fn(&str) -> bool, line: &str) -> Result<()> {
        let p = path().ok_or_else(|| anyhow!("no HOME to keep pinned keys in"))?;
        let mut text: String = text()?
            .lines()
            .filter(|l| keep(l))
            .map(|l| format!("{l}\n"))
            .collect();
        if let Some(dir) = p.parent() {
            std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
        }
        text.push_str(line);
        text.push('\n');
        let tmp = p.with_extension("tmp");
        let _ = std::fs::remove_file(&tmp);
        crate::crypt::write_private(&tmp, text.as_bytes())?;
        std::fs::rename(&tmp, &p).with_context(|| format!("write {}", p.display()))
    }

    fn write(forge: &str, who: &str, public: &str) -> Result<()> {
        rewrite(|_| true, &format!("{forge} {who} {}", public.trim()))
    }

    /// **The own pin of one forge host**: its SSH host key, the account the forge
    /// named, the encryption key. `host_key` is empty for a pin made before RA5-4
    /// (`<forge> self:<name> <key>`), taken over the first time it is checked.
    #[derive(Debug, Clone, PartialEq, Eq)]
    struct Own {
        host_key: String,
        name: String,
        public: String,
    }

    const OWN: &str = "self";

    fn read_own(forge: &str) -> Result<Option<Own>> {
        let text = text()?;
        let mut legacy = None;
        for l in text.lines() {
            let w: Vec<&str> = l.split_ascii_whitespace().collect();
            match w.as_slice() {
                [f, OWN, algo, fp, name, key] if *f == forge => {
                    return Ok(Some(Own {
                        host_key: format!("{algo} {fp}"),
                        name: name.to_string(),
                        public: key.to_string(),
                    }))
                }
                [f, who, key] if *f == forge && legacy.is_none() => {
                    if let Some(name) = who.strip_prefix("self:") {
                        legacy = Some(Own {
                            host_key: String::new(),
                            name: name.to_string(),
                            public: key.to_string(),
                        });
                    }
                }
                _ => {}
            }
        }
        Ok(legacy)
    }

    fn write_own(forge: &str, own: &Own) -> Result<()> {
        rewrite(
            |l| {
                let w: Vec<&str> = l.split_ascii_whitespace().collect();
                !matches!(w.as_slice(), [f, OWN, ..] if *f == forge)
                    && !matches!(w.as_slice(), [f, who, _] if *f == forge && who.starts_with("self:"))
            },
            &format!(
                "{forge} {OWN} {} {} {}",
                own.host_key,
                own.name,
                own.public.trim()
            ),
        )
    }

    /// Who the forge says you are, and the host key it said it with.
    fn whoami(local: &Repo) -> Result<(String, String)> {
        crate::crypt::door_whoami_seen(local)
            .map_err(|why| anyhow!("the forge could not say who you are: {why}"))
    }

    /// The operator's own word for who they are at the forge: `git config gunnar.account`.
    fn configured(local: &Repo) -> Option<String> {
        local
            .git(&["config", "gunnar.account"])
            .ok()
            .map(|n| n.trim().to_string())
            .filter(|n| !n.is_empty())
    }

    const REPIN: &str = "If it was changed on purpose, check the new key's fingerprint on your Account page, then run \
                         `gunnar crypt key repin --fingerprint <it>`";

    /// **The forge signed in with the host key this machine pinned for it**, or
    /// there is no own pin for it yet. Refused by name otherwise.
    pub(crate) fn host_key_is_pinned(local: &Repo, host_key: &str) -> Result<()> {
        let forge = forge(local)?;
        match read_own(&forge)? {
            Some(p) if !p.host_key.is_empty() && p.host_key != host_key => bail!(
                "the forge at {forge} signed in with another SSH host key than the one this machine pinned \
                 (pinned {}, now {host_key}): refused, it may not be your forge. Nothing was wrapped to \
                 it. {REPIN}",
                p.host_key
            ),
            _ => Ok(()),
        }
    }

    /// The name the forge gave, checked against `gunnar.account` and against the
    /// host key the first answer came with.
    fn named(local: &Repo, forge: &str, host_key: &str) -> Result<String> {
        let (name, again) = whoami(local)?;
        if again != host_key {
            bail!(
                "the forge at {forge} answered with two SSH host keys ({host_key}, then {again}): refused. \
                 Nothing was wrapped"
            );
        }
        if let Some(mine) = configured(local) {
            if mine != name {
                bail!(
                    "the forge at {forge} says you are {name}, and gunnar.account says {mine}: refused, \
                     nothing was pinned or wrapped. Fix gunnar.account, or ask the forge's admin who \
                     your SSH key signs in as"
                );
            }
        }
        Ok(name)
    }

    /// The caller's OWN key, as `host_key`'s forge answered it: refused when the
    /// host key, the forge's name for the caller, or the key differs from the pin;
    /// pinned the first time (said, with its fingerprint).
    pub(crate) fn check_own(local: &Repo, public: &str, host_key: &str) -> Result<()> {
        own(local, public, host_key, true)
    }

    /// **The caller's REPLACED key** (CRYPT-PQ-REKEY): the pin of the old one
    /// gives way to the key this machine just made and the forge took.
    pub(crate) fn replace_own(local: &Repo, public: &str, host_key: &str) -> Result<()> {
        let forge = forge(local)?;
        let name = named(local, &forge, host_key)?;
        write_own(
            &forge,
            &Own {
                host_key: host_key.to_string(),
                name,
                public: public.trim().to_string(),
            },
        )
    }

    /// Is `public` the caller's own key, as this machine pinned it?
    pub(crate) fn is_own(local: &Repo, public: &str) -> bool {
        forge(local)
            .ok()
            .and_then(|f| read_own(&f).ok().flatten())
            .is_some_and(|o| o.public == public.trim())
    }

    /// The caller's own NEW key, pinned at once.
    pub(crate) fn pin_own(local: &Repo, public: &str, host_key: &str) -> Result<()> {
        own(local, public, host_key, false)
    }

    fn own(local: &Repo, public: &str, host_key: &str, say_first: bool) -> Result<()> {
        let forge = forge(local)?;
        let name = named(local, &forge, host_key)?;
        let now = Own {
            host_key: host_key.to_string(),
            name,
            public: public.trim().to_string(),
        };
        let Some(pinned) = read_own(&forge)? else {
            write_own(&forge, &now)?;
            if say_first {
                eprintln!(
                    "pinned your encryption key on this machine: {} for {} at {forge} (host key {host_key}; \
                     the first time this machine sees it; compare it with your Account page)",
                    gunnar_crypt::door::fingerprint(public),
                    now.name
                );
            }
            return Ok(());
        };
        if !pinned.host_key.is_empty() && pinned.host_key != now.host_key {
            host_key_is_pinned(local, host_key)?;
        }
        if pinned.name != now.name {
            bail!(
                "the forge at {forge} now says you are {}, and this machine pinned you as {}: refused, \
                 the forge does not pick whose key this machine trusts. Nothing was wrapped to it. {REPIN}",
                now.name,
                pinned.name
            );
        }
        if pinned.public != now.public {
            bail!(
                "the forge at {forge} answered an encryption key that is NOT the one this machine \
                 pinned for you (pinned {}, answered {}): the key at the server was changed. Nothing \
                 was wrapped to it. {REPIN}",
                gunnar_crypt::door::fingerprint(&pinned.public),
                gunnar_crypt::door::fingerprint(public)
            );
        }
        if pinned.host_key.is_empty() {
            write_own(&forge, &now)?;
        }
        Ok(())
    }

    /// **`gunnar crypt key repin --fingerprint FP`**: trust the key the forge
    /// holds for you now, under the host key and the name it answers with now,
    /// when `confirmed` is that key's fingerprint. What changed is said.
    pub(crate) fn repin(
        local: &Repo,
        public: &str,
        host_key: &str,
        confirmed: &str,
    ) -> Result<String> {
        let forge = forge(local)?;
        let fp = gunnar_crypt::door::fingerprint(public);
        if confirmed.trim() != fp {
            bail!(
                "the fingerprint you gave is not the one of the key the forge at {forge} holds for you \
                 ({fp}): nothing was re-pinned"
            );
        }
        let name = named(local, &forge, host_key)?;
        let now = Own {
            host_key: host_key.to_string(),
            name,
            public: public.trim().to_string(),
        };
        let before = read_own(&forge)?;
        write_own(&forge, &now)?;
        let mut changed = Vec::new();
        if let Some(b) = &before {
            if b.host_key != now.host_key {
                changed.push(format!(
                    "host key {} → {}",
                    if b.host_key.is_empty() {
                        "(none)"
                    } else {
                        b.host_key.as_str()
                    },
                    now.host_key
                ));
            }
            if b.name != now.name {
                changed.push(format!("account {} → {}", b.name, now.name));
            }
            if b.public != now.public {
                changed.push(format!(
                    "key {} → {fp}",
                    gunnar_crypt::door::fingerprint(&b.public)
                ));
            }
        }
        Ok(format!(
            "re-pinned your encryption key at {forge}: {fp}, account {}, host key {}{}",
            now.name,
            now.host_key,
            if changed.is_empty() {
                " (nothing changed)".to_string()
            } else {
                format!(" ({})", changed.join("; "))
            }
        ))
    }

    /// **Another member's key, as the forge handed it out.** Taken when it is
    /// the pinned one, or when `confirmed` is its fingerprint (the member read
    /// it out); a pinned key that differs is refused by name.
    pub(crate) fn check_member(
        local: &Repo,
        name: &str,
        public: &str,
        confirmed: Option<&str>,
    ) -> Result<()> {
        let forge = forge(local)?;
        let fp = gunnar_crypt::door::fingerprint(public);
        match read(&forge, name)? {
            Some(pinned) if pinned == public.trim() => Ok(()),
            Some(pinned) => bail!(
                "the forge at {forge} answered an encryption key for {name} that is NOT the one this \
                 machine pinned (pinned {}, answered {fp}): refused. Ask {name} which key is theirs",
                gunnar_crypt::door::fingerprint(&pinned)
            ),
            None if confirmed.map(str::trim) == Some(fp.as_str()) => write(&forge, name, public),
            None if confirmed.is_some() => bail!(
                "the fingerprint you gave is not the one of the key the forge holds for {name} ({fp}): refused"
            ),
            None => bail!(
                "the forge at {forge} holds this encryption key for {name}: {fp}. Check it with {name} \
                 (their `gunnar crypt key status` or Account page shows it), then run again with \
                 --fingerprint {fp}"
            ),
        }
    }

    /// **A member's quantum-safe key handed over directly** (`gunnar crypt level
    /// quantum-safe --key NAME=…`): it replaces the pin of the key they had in
    /// the classic repository. The key it replaced, when there was another.
    pub(crate) fn replace_member(local: &Repo, name: &str, public: &str) -> Result<Option<String>> {
        let forge = match forge(local) {
            Ok(f) => f,
            Err(_) => return Ok(None),
        };
        let before = read(&forge, name)?;
        if before.as_deref() == Some(public.trim()) {
            return Ok(None);
        }
        rewrite(
            |l| {
                let w: Vec<&str> = l.split_ascii_whitespace().collect();
                !matches!(w.as_slice(), [f, n, _] if *f == forge && *n == name)
            },
            &format!("{forge} {name} {}", public.trim()),
        )?;
        Ok(before)
    }

    /// A member key handed over directly (`--key`): pinned as theirs.
    pub(crate) fn pin_member(local: &Repo, name: &str, public: &str) -> Result<()> {
        let forge = match forge(local) {
            Ok(f) => f,
            Err(_) => return Ok(()),
        };
        match read(&forge, name)? {
            Some(p) if p == public.trim() => Ok(()),
            Some(p) => bail!(
                "this machine pinned another encryption key for {name} ({}); the one given is {}",
                gunnar_crypt::door::fingerprint(&p),
                gunnar_crypt::door::fingerprint(public)
            ),
            None => write(&forge, name, public),
        }
    }
}

/// Where a security key's secret comes from: the USB key, or (in tests) a
/// software authenticator through the same CTAP2 client.
pub trait SecurityKeys {
    /// The `hmac-secret` output of `credential` (bound to `rp_id`) for gunnar's
    /// fixed salt: the browser's WebAuthn `prf` output for the same credential.
    fn prf(&mut self, rp_id: &str, credential: &[u8]) -> Result<[u8; 32]>;
}

/// The first FIDO2 key on USB.
pub struct UsbKeys;

impl SecurityKeys for UsbKeys {
    fn prf(&mut self, rp_id: &str, credential: &[u8]) -> Result<[u8; 32]> {
        let p = crate::fido2::hmac_secret(
            rp_id,
            &[credential.to_vec()],
            &security_key::hmac_secret_salt(),
        )?;
        Ok(p.output)
    }
}

/// **Open the caller's key** with whatever `how` allows: every SSH or keygen
/// key file against the SSH and age-pq wraps, a security key against the
/// security-key wraps, a recovery code against the recovery wraps.
///
/// A security key's v1 (X25519) wrap that opens is re-wrapped to v2 at the
/// forge on the way (CRYPT-PQ-SK): the same method, now quantum-safe. A forge
/// that refuses the re-wrap does not stop the unlock; it is said.
pub(crate) fn open(local: &Repo, answer: &Answer, how: &Openers) -> Result<UserKey> {
    let (key, rewrap) = open_with_rewrap(answer, how, &mut UsbKeys)?;
    if let Some(line) = rewrap {
        match door(local, &Request::Add(line)) {
            Ok(_) => eprintln!(
                "re-wrapped your security key's copy of your encryption key to v2 (symmetric, quantum-safe)"
            ),
            Err(e) => eprintln!(
                "warning: your security key's copy is still the X25519 (v1) wrap; the forge did not take its v2 re-wrap: {e:#}"
            ),
        }
    }
    Ok(key)
}

/// [`open`], with the security keys `keys` gives.
pub fn open_with(answer: &Answer, how: &Openers, keys: &mut dyn SecurityKeys) -> Result<UserKey> {
    open_with_rewrap(answer, how, keys).map(|(key, _)| key)
}

/// [`open_with`], and the v2 re-wrap of the security-key method that opened
/// when its wrap was v1 (CRYPT-PQ-SK): the line to add at the forge, which
/// replaces the v1 one.
pub fn open_with_rewrap(
    answer: &Answer,
    how: &Openers,
    keys: &mut dyn SecurityKeys,
) -> Result<(UserKey, Option<MethodLine>)> {
    let public = answer.public_key.clone().unwrap_or_default();
    // A wrap the server holds may be bogus (RA4-14): it is skipped, named, and the rest tried.
    let mut skipped: Vec<String> = Vec::new();
    let skip = |m: &MethodLine, why: &dyn std::fmt::Display, skipped: &mut Vec<String>| {
        skipped.push(format!(
            "unlock method {} ({}, {:?}): {why}",
            m.id, m.kind, m.label
        ));
    };
    let said = |base: String, skipped: &[String]| -> anyhow::Error {
        match skipped.is_empty() {
            true => anyhow!("{base}"),
            false => anyhow!(
                "{base}. Skipped, because they do not hold your key: {}",
                skipped.join("; ")
            ),
        }
    };
    if how.security_key {
        let sks: Vec<&MethodLine> = answer
            .methods
            .iter()
            .filter(|m| m.kind == "security-key")
            .collect();
        if sks.is_empty() {
            bail!("no security key opens your encryption key yet: add one with `gunnar crypt key add-security-key` or on your Account page");
        }
        let mut last_device = None;
        for m in sks {
            let Some(cred) = b64url_decode(&m.method_ref) else {
                skip(m, &"it names no credential", &mut skipped);
                continue;
            };
            let prf = match keys.prf(&m.rp_id, &cred) {
                Ok(p) => gunnar_crypt::zeroize::Zeroizing::new(p),
                Err(e) => {
                    last_device = Some(format!("{e:#}"));
                    continue;
                }
            };
            match userkey::open_with_security_key(m.wrap.as_bytes(), &prf, &cred, &public) {
                Ok((k, userkey::SecurityKeyWrap::V2)) => return Ok((k, None)),
                Ok((k, userkey::SecurityKeyWrap::V1)) => {
                    let wrap = k
                        .wrap_to_security_key_v2(&prf, &cred)
                        .map_err(|e| anyhow!("{e}"))?;
                    let line = MethodLine {
                        id: String::new(),
                        added_ms: 0,
                        wrap,
                        ..m.clone()
                    };
                    return Ok((k, Some(line)));
                }
                Err(UserKeyError::WrongKey) => {}
                Err(e) => skip(m, &e, &mut skipped),
            }
        }
        let base = match last_device {
            Some(why) => format!("{} (the security key said: {why})", UserKeyError::WrongKey),
            None => UserKeyError::WrongKey.to_string(),
        };
        return Err(said(base, &skipped));
    }
    if how.recovery_code {
        let codes: Vec<&MethodLine> = answer
            .methods
            .iter()
            .filter(|m| m.kind == "recovery-code")
            .collect();
        if codes.is_empty() {
            bail!("no recovery code opens your encryption key");
        }
        let typed = gunnar_crypt::zeroize::Zeroizing::new(
            rpassword::prompt_password("Recovery code: ")
                .map_err(|e| anyhow!("read the recovery code: {e}"))?,
        );
        for m in codes {
            match userkey::open_with_recovery_code(m.wrap.as_bytes(), &typed, &public) {
                Ok(k) => return Ok((k, None)),
                Err(UserKeyError::WrongKey) => {}
                Err(e @ UserKeyError::BadRecoveryCode) => bail!("{e}"),
                Err(e) => skip(m, &e, &mut skipped),
            }
        }
        return Err(said(UserKeyError::WrongKey.to_string(), &skipped));
    }
    let ids = identities(&how.identity)?;
    let mut tried: Vec<String> = Vec::new();
    for m in answer
        .methods
        .iter()
        .filter(|m| m.kind == "ssh" || m.kind == "age-pq")
    {
        for (path, id) in &ids {
            match userkey::open(m.wrap.as_bytes(), id.as_ref(), &public) {
                Ok(k) => return Ok((k, None)),
                Err(UserKeyError::WrongKey) => tried.push(path.display().to_string()),
                Err(e) => {
                    skip(m, &e, &mut skipped);
                    break;
                }
            }
        }
    }
    tried.sort();
    tried.dedup();
    if tried.is_empty() && skipped.is_empty() {
        bail!(
            "no SSH key opens your encryption key: open it with your security key (`--security-key`) \
             or your recovery code (`--recovery-code`), then `gunnar crypt key add-ssh`"
        );
    }
    Err(said(
        format!(
            "{} (tried {}). Pass -i <the SSH key file you added>, `--security-key`, or `--recovery-code`",
            UserKeyError::WrongKey,
            tried.join(", ")
        ),
        &skipped,
    ))
}

/// The caller's PUBLIC half, making the key first when they have none: a new
/// key, wrapped to `ssh_public` (default `~/.ssh/id_ed25519.pub`).
pub(crate) fn public_or_new(
    local: &Repo,
    level: Level,
    ssh_public: Option<&str>,
) -> Result<String> {
    let (a, host_key) = door_seen(local, &Request::Get)?;
    pins::host_key_is_pinned(local, &host_key)?;
    if let Some(public) = a.public_key {
        pins::check_own(local, &public, &host_key)?;
        return Ok(public);
    }
    let made = create(local, level, ssh_public)?;
    eprintln!(
        "made your encryption key ({made}); your SSH key opens it. Add a second way to open it — a \
         security key, or `gunnar crypt key add-recovery-code` — so losing one key loses nothing"
    );
    Ok(made)
}

/// A new key at `level` and its first method: a wrap to `ssh_public` (default
/// `~/.ssh/id_ed25519.pub`).
fn new_key_for_ssh(level: Level, ssh_public: Option<&str>) -> Result<(UserKey, MethodLine)> {
    let ssh = match ssh_public {
        Some(k) => crate::crypt::read_key(k)?,
        None => crate::crypt::default_public_key()?,
    };
    let key = UserKey::generate(level).map_err(|e| anyhow!("{e}"))?;
    if key.level() == Level::QuantumSafe {
        // The SSH key is the first method; its wrap of a quantum-safe key is classical, said
        // plainly, and the key itself stays quantum-safe (CRYPT-PQ-DEFAULT).
        eprintln!("note: {}", userkey::SSH_WRAP_IS_CLASSICAL);
    }
    let method = MethodLine {
        kind: "ssh".into(),
        label: ssh
            .split_ascii_whitespace()
            .nth(2)
            .unwrap_or("ssh key")
            .to_string(),
        method_ref: key_id(&ssh),
        wrap: key.wrap_to_ssh(&ssh).map_err(|e| anyhow!("{e}"))?,
        ..MethodLine::default()
    };
    Ok((key, method))
}

fn create(local: &Repo, level: Level, ssh_public: Option<&str>) -> Result<String> {
    let (key, method) = new_key_for_ssh(level, ssh_public)?;
    let (a, host_key) = door_seen(
        local,
        &Request::Create {
            public_key: key.public(),
            method,
        },
    )?;
    pins::host_key_is_pinned(local, &host_key)?;
    if a.public_key.as_deref() != Some(key.public().as_str()) {
        bail!("the forge kept another encryption key than the one made here: refused");
    }
    pins::pin_own(local, &key.public(), &host_key)?;
    Ok(key.public())
}

/// `ssh-ed25519 AAAA…` without the comment.
fn key_id(line: &str) -> String {
    line.split_ascii_whitespace()
        .take(2)
        .collect::<Vec<_>>()
        .join(" ")
}

/// Run one `gunnar crypt key` verb.
pub fn run(command: CryptKeyCommand) -> Result<()> {
    let local = Repo::discover()?;
    match command {
        CryptKeyCommand::Status => {
            let (a, host_key) = door_seen(&local, &Request::Get)?;
            pins::host_key_is_pinned(&local, &host_key)?;
            match &a.public_key {
                None => println!("no encryption key yet: `gunnar crypt key new` makes one."),
                Some(p) => {
                    pins::check_own(&local, p, &host_key)?;
                    let quantum_safe = gunnar_crypt::members::classify(p)
                        == Ok(gunnar_crypt::members::KeyKind::AgePq);
                    println!(
                        "encryption key {p} ({})\nfingerprint {}",
                        if quantum_safe {
                            "quantum-safe key"
                        } else {
                            "classic key: for classic repositories only"
                        },
                        gunnar_crypt::door::fingerprint(p)
                    );
                    for m in &a.methods {
                        // A quantum-safe key's methods say how strong their own wrap is.
                        let strength = match (
                            quantum_safe,
                            userkey::wrap_is_quantum_safe(&m.kind, &m.wrap),
                        ) {
                            (false, _) => "",
                            (true, true) => "  (wrap: quantum-safe)",
                            (true, false) => "  (wrap: not quantum-safe)",
                        };
                        println!("  {}  {:<13} {}{strength}", m.id, m.kind, m.label);
                    }
                    for r in &a.retired {
                        println!(
                            "  retired {}  (replaced: it opens only what was wrapped to it before; Gunnar never takes it again)",
                            gunnar_crypt::door::fingerprint(r)
                        );
                    }
                    if a.methods.len() < 2 {
                        println!("Only one way opens it. Add another — a second security key or a printed recovery code — so losing one loses nothing.");
                    }
                }
            }
            Ok(())
        }
        CryptKeyCommand::New { classic, ssh_key } => {
            let level = if classic { Level::Classic } else { Level::NEW };
            if door(&local, &Request::Get)?.public_key.is_some() {
                bail!("you have an encryption key already; `gunnar crypt key status` shows it (a new one would orphan every repository wrapped to it)");
            }
            let made = create(&local, level, ssh_key.as_deref())?;
            println!("made your encryption key {made}; your SSH key opens it.");
            Ok(())
        }
        CryptKeyCommand::AddSsh { key, open } => {
            let ssh = match key.as_deref() {
                Some(k) => crate::crypt::read_key(k)?,
                None => crate::crypt::default_public_key()?,
            };
            add(&local, &open.into(), |k| {
                if k.level() == Level::QuantumSafe {
                    eprintln!("note: {}", userkey::SSH_WRAP_IS_CLASSICAL);
                }
                Ok(MethodLine {
                    kind: "ssh".into(),
                    label: ssh
                        .split_ascii_whitespace()
                        .nth(2)
                        .unwrap_or("ssh key")
                        .to_string(),
                    method_ref: key_id(&ssh),
                    wrap: k.wrap_to_ssh(&ssh).map_err(|e| anyhow!("{e}"))?,
                    ..MethodLine::default()
                })
            })
        }
        CryptKeyCommand::AddPq { key, open } => {
            let pq = match key.as_deref() {
                Some(k) => crate::crypt::read_key(k)?,
                None => crate::crypt::default_pq_public_key()?,
            };
            add(&local, &open.into(), |k| {
                Ok(MethodLine {
                    kind: "age-pq".into(),
                    label: "gunnar crypt keygen".into(),
                    method_ref: pq.trim().to_string(),
                    wrap: k.wrap_to_pq(&pq).map_err(|e| anyhow!("{e}"))?,
                    ..MethodLine::default()
                })
            })
        }
        CryptKeyCommand::AddSecurityKey { rp_id, label, open } => {
            let made = crate::fido2::make_credential(&rp_id)?;
            eprintln!("touch the security key again to read its secret");
            let prf = crate::fido2::hmac_secret(
                &rp_id,
                std::slice::from_ref(&made.credential_id),
                &security_key::hmac_secret_salt(),
            )?;
            add(&local, &open.into(), |k| {
                Ok(MethodLine {
                    kind: "security-key".into(),
                    label: label.clone().unwrap_or_else(|| "security key".into()),
                    method_ref: b64url_encode(&made.credential_id),
                    rp_id: rp_id.clone(),
                    // v2 (CRYPT-PQ-SK): symmetric under HKDF of the PRF output, quantum-safe.
                    wrap: k
                        .wrap_to_security_key_v2(&prf.output, &made.credential_id)
                        .map_err(|e| anyhow!("{e}"))?,
                    ..MethodLine::default()
                })
            })
        }
        CryptKeyCommand::AddRecoveryCode { open } => {
            let code = recovery_code::generate();
            add(&local, &open.into(), |k| {
                Ok(MethodLine {
                    kind: "recovery-code".into(),
                    label: "printed recovery code".into(),
                    wrap: k.wrap_to_recovery_code(&code).map_err(|e| anyhow!("{e}"))?,
                    ..MethodLine::default()
                })
            })?;
            println!(
                "Your recovery code (shown once; print it or write it down, and keep it apart from your keys):\n\n    {}\n\nIt opens your encryption key with `gunnar crypt unlock --recovery-code`.",
                *code
            );
            Ok(())
        }
        CryptKeyCommand::Repin { fingerprint } => {
            // The host key is NOT checked here: a changed one is what a re-pin takes.
            let (a, host_key) = door_seen(&local, &Request::Get)?;
            let Some(public) = a.public_key else {
                bail!("the forge holds no encryption key for you: nothing to pin (`gunnar crypt key new` makes one)");
            };
            println!("{}", pins::repin(&local, &public, &host_key, &fingerprint)?);
            Ok(())
        }
        CryptKeyCommand::Replace {
            classic,
            ssh_key,
            yes,
        } => replace(
            &local,
            if classic { Level::Classic } else { Level::NEW },
            ssh_key.as_deref(),
            yes,
        ),
        CryptKeyCommand::Remove { id } => {
            let a = door(&local, &Request::Remove(id.clone()))?;
            println!(
                "removed unlock method {id}; {} left. No repository changed.",
                a.methods.len()
            );
            Ok(())
        }
    }
}

/// **`gunnar crypt key replace --yes`** (CRYPT-PQ-REKEY): a new key, wrapped to the
/// SSH key, in place of the old key and all its methods; the forge retires the old one.
fn replace(local: &Repo, level: Level, ssh_public: Option<&str>, yes: bool) -> Result<()> {
    if !yes {
        bail!(
            "`gunnar crypt key replace` makes a NEW encryption key and retires yours for good: every \
             unlock method of the old key goes, and each encrypted repository you are a member of takes \
             new content again only after a member re-keys it to your new key (`gunnar crypt rekey \
             <your name> --from-server`). Run it again with --yes"
        );
    }
    let (a, host_key) = door_seen(local, &Request::Get)?;
    pins::host_key_is_pinned(local, &host_key)?;
    let Some(old) = a.public_key else {
        bail!("you have no encryption key to replace: `gunnar crypt key new` makes one");
    };
    let (key, method) = new_key_for_ssh(level, ssh_public)?;
    let (after, host_key) = door_seen(
        local,
        &Request::Replace {
            public_key: key.public(),
            method,
        },
    )?;
    pins::host_key_is_pinned(local, &host_key)?;
    if after.public_key.as_deref() != Some(key.public().as_str()) {
        bail!("the forge kept another encryption key than the one made here: refused");
    }
    pins::replace_own(local, &key.public(), &host_key)?;
    let me = crate::crypt::door_whoami(local).unwrap_or_else(|_| "<your name>".into());
    println!(
        "replaced your encryption key: {} is retired, {} is yours now ({}), opened by your SSH key.\n\
         The retired key opens only what was wrapped to it before; Gunnar never takes it again, and \
         refuses a push to a repository whose members file still names it.\n\
         Next, in each encrypted repository you are a member of, a member with an unlocked clone (you \
         too, in a clone that is still unlocked) runs\n\n    gunnar crypt rekey {me} --from-server \
         --fingerprint {}\n\nand pushes. Add a second way to open the new key (a security key, or \
         `gunnar crypt key add-recovery-code`).",
        gunnar_crypt::door::fingerprint(&old),
        gunnar_crypt::door::fingerprint(&key.public()),
        if key.level() == Level::QuantumSafe { "quantum-safe" } else { "classic" },
        gunnar_crypt::door::fingerprint(&key.public()),
    );
    Ok(())
}

/// Open the key, wrap it to one more method, add it at the forge.
fn add(
    local: &Repo,
    how: &Openers,
    method: impl FnOnce(&UserKey) -> Result<MethodLine>,
) -> Result<()> {
    let a = mine(local)?;
    let key = open(local, &a, how)?;
    let line = method(&key)?;
    let kind = line.kind.clone();
    let after = door(local, &Request::Add(line))?;
    println!("added a {kind} unlock method; {} in all. No repository changed: every one opens with any of them.", after.methods.len());
    Ok(())
}

impl From<crate::cli::KeyOpenArgs> for Openers {
    fn from(a: crate::cli::KeyOpenArgs) -> Openers {
        Openers {
            identity: a.identity,
            security_key: a.security_key,
            recovery_code: a.recovery_code,
        }
    }
}

const B64URL: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";

/// base64url without padding (WebAuthn's credential id encoding).
pub fn b64url_encode(bytes: &[u8]) -> String {
    let mut out = String::new();
    for chunk in bytes.chunks(3) {
        let n = chunk
            .iter()
            .enumerate()
            .fold(0u32, |acc, (i, b)| acc | (u32::from(*b) << (16 - 8 * i)));
        for i in 0..=chunk.len() {
            out.push(B64URL[((n >> (18 - 6 * i)) & 63) as usize] as char);
        }
    }
    out
}

/// The inverse of [`b64url_encode`]; `None` for anything else.
pub(crate) fn b64url_decode(text: &str) -> Option<Vec<u8>> {
    let mut out = Vec::new();
    let (mut acc, mut bits) = (0u32, 0u32);
    for c in text.trim().bytes() {
        let v = B64URL.iter().position(|x| *x == c)? as u32;
        acc = (acc << 6) | v;
        bits += 6;
        if bits >= 8 {
            bits -= 8;
            out.push((acc >> bits) as u8);
        }
    }
    Some(out)
}

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

    #[test]
    fn base64url_round_trips_every_length() {
        for n in 0..40u8 {
            let bytes: Vec<u8> = (0..n).map(|i| i.wrapping_mul(37)).collect();
            assert_eq!(b64url_decode(&b64url_encode(&bytes)).unwrap(), bytes);
        }
        assert_eq!(b64url_encode(b"\xfb\xff"), "-_8");
        assert_eq!(b64url_decode("a+b"), None);
    }
}