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

nordisk/edda

crates/nornir-auth-sshsig/src/lib.rs

//! # nornir-auth-sshsig — per-call SSH-signature credentials, product-neutral
//!
//! A caller proves, on EVERY gRPC call, that it holds the private half of an
//! OpenSSH key the server already knows, by signing a short single-use
//! challenge with it. The signature is an **SSHSIG** (the format
//! `ssh-keygen -Y sign` produces), so the credential can come from
//! `ssh-agent`, `ssh-keygen` or `ssh-key` in-process — no new key material, no
//! shared secret, and revoking a caller is removing its public key.
//!
//! ```text
//! metadata:  <METADATA_KEY>: <SCHEME> <fingerprint> <issued_at_ms> <nonce> <method> <armor>
//! signed:    <PREAMBLE>\n<fingerprint>\n<issued_at_ms>\n<nonce>\n<method>\n
//! ```
//!
//! This crate is the scheme and nothing product-specific. It was gunnar's
//! (`gunnar-grpc/src/auth.rs`, gunnar's wire format byte for byte — see
//! [`Scheme`]) and moved to edda (workspace RULE 2: common code lives in
//! edda/Skidbladnir) when monetize's control API needed the same check for the
//! shared operator console (owner 2026-09-28: "SSHSIG per call, exactly
//! gunnar's model"). Two copies of a verifier is the twin the house forbids:
//! the day one copy gains a check the other misses, a credential refused by
//! one product is accepted by the other.
//!
//! What a PRODUCT supplies ([`Scheme`]): its metadata key, its scheme token
//! and its **sshsig namespace**. The namespace is bound into what is signed, so
//! a credential minted for gunnar never verifies at monetize, a signed commit
//! (`git`) never verifies as either, and the reverse.
//!
//! What the crate supplies: the [`Credential`] (parse / render / the bytes it
//! signs), [`sign`], the one [`verify_signature`], the [`ReplayGuard`], and
//! [`check`] — the whole five-step verification for a product that needs no
//! more than "which of these keys signed this call for this method, now, once".
//! gunnar keeps its own gate (its ACL table, audit trail, unbound-credential
//! log) on top of the primitives; monetize uses [`check`].

/// The key types a caller signs with and a server verifies against, so a
/// consumer needs no second `ssh-key` dependency to name them.
pub use ssh_key;

use std::cmp::Reverse;
use std::collections::hash_map::RandomState;
use std::collections::{BinaryHeap, HashMap, HashSet};
use std::hash::{BuildHasher, BuildHasherDefault, Hasher};
use std::marker::PhantomData;
use std::sync::Mutex;

/// **A product's credential scheme** — the constants that make its
/// credentials its own. Implemented by a unit type per product.
pub trait Scheme:
    Clone + Copy + std::fmt::Debug + PartialEq + Eq + Default + Send + Sync + 'static
{
    /// The metadata key the credential travels in (never `authorization`:
    /// that name belongs to bearer/basic schemes and proxies rewrite it).
    const METADATA_KEY: &'static str;
    /// The scheme token of a METHOD-BOUND credential.
    const SCHEME: &'static str;
    /// The scheme token of a method-blind credential, when the product still
    /// parses one (gunnar's v1); `None` = never accepted.
    const SCHEME_UNBOUND: Option<&'static str>;
    /// The sshsig namespace signatures are made and checked under.
    const NAMESPACE: &'static str;
    /// The first line of the method-bound challenge.
    const PREAMBLE: &'static str;
    /// The first line of the method-blind challenge (unused when
    /// [`Scheme::SCHEME_UNBOUND`] is `None`).
    const PREAMBLE_UNBOUND: &'static str;
    /// The error a parse answers; a product maps [`CredentialError`] into its own.
    type Error: From<CredentialError>;
}

/// How far a credential's `issued_at` may be from the server's clock, either
/// way: enough for an unsynchronised laptop, short enough that the
/// [`ReplayGuard`] stays small.
pub const MAX_CLOCK_SKEW_MS: i64 = 5 * 60 * 1000;

/// The shortest nonce accepted: a nonce is what makes a credential single-use.
pub const MIN_NONCE_LEN: usize = 16;

/// The hash the SSHSIG is made with.
pub const HASH_ALG: ssh_key::HashAlg = ssh_key::HashAlg::Sha512;

/// Why a credential could not even be read. Products map it into their own
/// refusal type ([`Scheme::Error`]).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CredentialError {
    /// No credential in the request.
    Missing,
    /// Present but unparseable.
    Malformed(String),
}

impl std::fmt::Display for CredentialError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            CredentialError::Missing => write!(f, "no credential"),
            CredentialError::Malformed(why) => write!(f, "malformed credential: {why}"),
        }
    }
}

/// A parsed, not-yet-verified credential of scheme `S`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Credential<S: Scheme> {
    /// `SHA256:<base64>` of the signing key.
    pub fingerprint: String,
    pub issued_at_unix_ms: i64,
    pub nonce: String,
    /// The method signed FOR, as `Service.Method`; `None` = a method-blind
    /// credential ([`Scheme::SCHEME_UNBOUND`]).
    pub method: Option<String>,
    /// The sshsig armour on one line ([`armor_to_line`]).
    pub signature_line: String,
    scheme: PhantomData<S>,
}

/// **The one writer of the method-blind challenge bytes.**
pub fn challenge<S: Scheme>(fingerprint: &str, issued_at_unix_ms: i64, nonce: &str) -> String {
    format!(
        "{}\n{fingerprint}\n{issued_at_unix_ms}\n{nonce}\n",
        S::PREAMBLE_UNBOUND
    )
}

/// **The one writer of the method-bound challenge bytes.** The method is a
/// whole line ending in `\n`, so no method name can be a prefix of another's
/// bytes.
pub fn challenge_bound<S: Scheme>(
    fingerprint: &str,
    issued_at_unix_ms: i64,
    nonce: &str,
    method: &str,
) -> String {
    format!(
        "{}\n{fingerprint}\n{issued_at_unix_ms}\n{nonce}\n{method}\n",
        S::PREAMBLE
    )
}

/// `/pkg.v1.Service/Method` → `Service.Method`; `None` for anything that is
/// not a two-segment gRPC path. The proto package is dropped: it carries no
/// information inside one product and keeps the header short.
pub fn method_name_from_path(path: &str) -> Option<String> {
    let mut parts = path.trim_start_matches('/').split('/');
    let service = parts.next().filter(|s| !s.is_empty())?;
    let method = parts.next().filter(|s| !s.is_empty())?;
    if parts.next().is_some() {
        return None;
    }
    let service = service.rsplit('.').next().unwrap_or(service);
    Some(format!("{service}.{method}"))
}

/// PEM armour → one whitespace-free header field (`\n` → `|`, ` ` → `~`;
/// neither occurs in base64 or a PEM label, so the mapping is a bijection).
pub fn armor_to_line(armor: &str) -> String {
    armor.trim_end().replace('\n', "|").replace(' ', "~")
}

/// The inverse of [`armor_to_line`].
pub fn line_to_armor(line: &str) -> String {
    let mut armor = line.replace('~', " ").replace('|', "\n");
    if !armor.ends_with('\n') {
        armor.push('\n');
    }
    armor
}

impl<S: Scheme> Credential<S> {
    /// A credential from its parts (tests, and a signer that already holds them).
    pub fn new(
        fingerprint: String,
        issued_at_unix_ms: i64,
        nonce: String,
        method: Option<String>,
        signature_line: String,
    ) -> Self {
        Credential {
            fingerprint,
            issued_at_unix_ms,
            nonce,
            method,
            signature_line,
            scheme: PhantomData,
        }
    }

    /// Render the metadata value a client sends. A bound credential renders
    /// [`Scheme::SCHEME`], a blind one the unbound token.
    pub fn to_header_value(&self) -> String {
        match &self.method {
            Some(method) => format!(
                "{} {} {} {} {} {}",
                S::SCHEME,
                self.fingerprint,
                self.issued_at_unix_ms,
                self.nonce,
                method,
                self.signature_line
            ),
            None => format!(
                "{} {} {} {} {}",
                S::SCHEME_UNBOUND.unwrap_or(S::SCHEME),
                self.fingerprint,
                self.issued_at_unix_ms,
                self.nonce,
                self.signature_line
            ),
        }
    }

    /// Parse a metadata value. Shape only; nothing here is trusted.
    pub fn parse(value: &str) -> Result<Self, S::Error> {
        Self::parse_plain(value).map_err(S::Error::from)
    }

    fn parse_plain(value: &str) -> Result<Self, CredentialError> {
        let malformed = |m: String| CredentialError::Malformed(m);
        let usage = || {
            malformed(format!(
                "expected `{} <fingerprint> <issued_at_ms> <nonce> <method> <signature>`",
                S::SCHEME
            ))
        };
        let mut fields = value.split_whitespace();
        let scheme = fields.next().unwrap_or_default();
        let bound = if scheme == S::SCHEME {
            true
        } else if S::SCHEME_UNBOUND == Some(scheme) {
            false
        } else {
            return Err(malformed(match S::SCHEME_UNBOUND {
                Some(v1) => format!(
                    "credential scheme {scheme:?} is neither {:?} nor {v1:?}",
                    S::SCHEME
                ),
                None => format!("credential scheme {scheme:?} is not {:?}", S::SCHEME),
            }));
        };
        let (Some(fingerprint), Some(issued_at), Some(nonce)) =
            (fields.next(), fields.next(), fields.next())
        else {
            return Err(usage());
        };
        let method = match bound {
            true => Some(fields.next().ok_or_else(|| {
                malformed(format!(
                    "a {} credential must name the method it was signed for",
                    S::SCHEME
                ))
            })?),
            false => None,
        };
        let Some(signature) = fields.next() else {
            return Err(usage());
        };
        if fields.next().is_some() {
            return Err(malformed("trailing data after the signature".to_owned()));
        }
        if !fingerprint.starts_with("SHA256:") {
            return Err(malformed(format!(
                "{fingerprint:?} is not a SHA256: fingerprint"
            )));
        }
        let issued_at_unix_ms = issued_at
            .parse::<i64>()
            .map_err(|_| malformed(format!("{issued_at:?} is not a timestamp")))?;
        if nonce.len() < MIN_NONCE_LEN {
            return Err(malformed(format!(
                "the nonce is {} characters; at least {MIN_NONCE_LEN} are required, or two honest callers collide",
                nonce.len()
            )));
        }
        Ok(Credential::new(
            fingerprint.to_owned(),
            issued_at_unix_ms,
            nonce.to_owned(),
            method.map(str::to_owned),
            signature.to_owned(),
        ))
    }

    /// Pull the credential out of a request's metadata ([`Scheme::METADATA_KEY`]).
    #[cfg(feature = "tonic")]
    pub fn from_metadata(metadata: &tonic::metadata::MetadataMap) -> Result<Self, S::Error> {
        let raw = metadata
            .get(S::METADATA_KEY)
            .ok_or(S::Error::from(CredentialError::Missing))?;
        let text = raw.to_str().map_err(|_| {
            S::Error::from(CredentialError::Malformed(
                "the credential is not ASCII".to_owned(),
            ))
        })?;
        Self::parse(text)
    }

    /// The bytes this credential claims a signature over.
    pub fn challenge(&self) -> String {
        match &self.method {
            Some(method) => challenge_bound::<S>(
                &self.fingerprint,
                self.issued_at_unix_ms,
                &self.nonce,
                method,
            ),
            None => challenge::<S>(&self.fingerprint, self.issued_at_unix_ms, &self.nonce),
        }
    }
}

/// **Sign** a credential with an OpenSSH private key: bound to `method`
/// (`Service.Method`) when given, method-blind otherwise.
pub fn sign<S: Scheme>(
    key: &ssh_key::PrivateKey,
    issued_at_unix_ms: i64,
    nonce: &str,
    method: Option<&str>,
) -> Result<Credential<S>, ssh_key::Error> {
    let fingerprint = key
        .public_key()
        .fingerprint(ssh_key::HashAlg::Sha256)
        .to_string();
    let message = match method {
        Some(method) => challenge_bound::<S>(&fingerprint, issued_at_unix_ms, nonce, method),
        None => challenge::<S>(&fingerprint, issued_at_unix_ms, nonce),
    };
    let signature = key.sign(S::NAMESPACE, HASH_ALG, message.as_bytes())?;
    let armor = signature.to_pem(ssh_key::LineEnding::LF)?;
    Ok(Credential::new(
        fingerprint,
        issued_at_unix_ms,
        nonce.to_owned(),
        method.map(str::to_owned),
        armor_to_line(&armor),
    ))
}

/// **The one verifier**: does `credential`'s signature verify against this
/// OpenSSH public key line, under the scheme's namespace? The fingerprint is
/// re-derived from the key handed in, so a signature by key A is never
/// accepted as key B even when a table hands back the wrong row.
pub fn verify_signature<S: Scheme>(openssh_public_key: &str, credential: &Credential<S>) -> bool {
    let Ok(key) = ssh_key::PublicKey::from_openssh(openssh_public_key) else {
        return false;
    };
    if key.fingerprint(ssh_key::HashAlg::Sha256).to_string() != credential.fingerprint {
        return false;
    }
    let armor = line_to_armor(&credential.signature_line);
    let Ok(signature) = ssh_key::SshSig::from_pem(armor.as_bytes()) else {
        return false;
    };
    // Checked by `verify` too; visible here because a `git` signature must
    // never pass as a control-plane credential.
    if signature.namespace() != S::NAMESPACE {
        return false;
    }
    key.verify(S::NAMESPACE, credential.challenge().as_bytes(), &signature)
        .is_ok()
}

/// What [`ReplayGuard::accept`] decided — three answers, because "I will not
/// remember this" and "I have seen this" are opposite facts.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Accepted {
    /// Never seen, and now remembered.
    Fresh,
    /// Seen before, inside the freshness window.
    Replayed,
    /// Refused rather than remembered: THIS key already holds as many
    /// still-presentable credentials as one key may, or the whole guard is
    /// full. Never answered by forgetting one (a forgotten credential can be
    /// replayed).
    Full,
}

/// How many single-use credentials one server remembers at a time, all keys
/// together, before only the per-key floor ([`REPLAY_KEY_FLOOR`]) is still
/// let in from a reserve of an eighth more. About 40 bytes per credential (a
/// keyed 64-bit hash in a set, and its expiry in a heap): measured 46 MiB of
/// RSS for the 1 179 648 a full guard holds, 346 ns per accept, and handed
/// back once they expire. Only authenticated keys flooding for minutes get
/// there; nothing is allocated until used.
///
/// 0.1.1 held 65 536 strings in one pool, about 218 signed calls per second
/// from all keys together: GUN-PERF's 10 s console burst filled it and every
/// caller got `Full` for five minutes.
pub const DEFAULT_REPLAY_CAPACITY: usize = 1 << 20;

/// How many still-presentable credentials ONE key may hold in a
/// [`ReplayGuard::default`]: 524 288, which is GUN-PERF's measured 10 s
/// console burst (171 086 calls) followed by the console's own throttle
/// (500/s) for the rest of the window, with room to spare, or about 1 700
/// calls per second sustained. A key past it is refused on its own.
pub const DEFAULT_REPLAY_PER_KEY: usize = 1 << 19;

/// The credentials every key may still hold when the guard's main pool is
/// full, from a reserve of an eighth of the capacity: a flood by a few keys
/// leaves the rest working.
pub const REPLAY_KEY_FLOOR: usize = 4_096;

/// **Remembers accepted `(fingerprint, nonce)` pairs so each credential is
/// used once.**
///
/// - **Exactly as long as it can be presented.** A credential is presentable
///   while `|now − issued_at| ≤` [`MAX_CLOCK_SKEW_MS`], so it is held until
///   `issued_at + MAX_CLOCK_SKEW_MS` has passed, to the millisecond, by a
///   min-heap of expiries (0.1.1 held it one skew from when it was ACCEPTED: a future-dated one was
///   forgotten while still presentable, a past-dated one held for nothing).
///   The caller checks the window before the guard ([`check`] does); a stale
///   credential handed in anyway is held until the next call that is later.
///   Exact under clock steps: an entry goes when ITS expiry has passed.
/// - **Per key.** Each fingerprint may hold up to its own cap; past it, that
///   key gets [`Accepted::Full`] and no other does. When the whole pool is
///   full, every key may still hold [`REPLAY_KEY_FLOOR`] from a reserve.
/// - **Bounded.** At most `capacity` plus an eighth, entries of a keyed 64-bit
///   hash (SipHash with a per-guard random key): a collision can only refuse
///   an honest call as a replay, never accept one, and at a million entries
///   it is a 1 in 10^13 event.
/// - **Never evicts** a still-presentable entry to make room.
#[derive(Debug)]
pub struct ReplayGuard {
    inner: Mutex<ReplayInner>,
    capacity: usize,
    per_key: usize,
    reserve: usize,
    floor: usize,
    hasher: RandomState,
}

#[derive(Debug, Default)]
struct ReplayInner {
    /// The keyed hash of every held `(fingerprint, nonce)`.
    seen: HashSet<u64, BuildHasherDefault<Prehashed>>,
    /// How many each key (by the keyed hash of its fingerprint) holds.
    per_key: HashMap<u64, usize, BuildHasherDefault<Prehashed>>,
    /// `(expires_ms, nonce, key)`, soonest first.
    expiry: BinaryHeap<Reverse<(i64, u64, u64)>>,
}

/// The set's hasher for values that are already keyed SipHash outputs.
#[derive(Debug, Default, Clone, Copy)]
struct Prehashed(u64);

impl Hasher for Prehashed {
    fn finish(&self) -> u64 {
        self.0
    }
    fn write(&mut self, bytes: &[u8]) {
        for &b in bytes {
            self.0 = self.0.rotate_left(8) ^ u64::from(b);
        }
    }
    fn write_u64(&mut self, n: u64) {
        self.0 = n;
    }
}

impl ReplayInner {
    /// Forget everything whose last presentable instant is before `now`.
    fn sweep(&mut self, now_unix_ms: i64) {
        let mut dropped = false;
        while let Some(Reverse((expires_ms, nonce, key))) = self.expiry.peek().copied() {
            if expires_ms >= now_unix_ms {
                break;
            }
            self.expiry.pop();
            self.seen.remove(&nonce);
            if let Some(held) = self.per_key.get_mut(&key) {
                *held -= 1;
                if *held == 0 {
                    self.per_key.remove(&key);
                }
            }
            dropped = true;
        }
        // Hand the memory of a drained burst back.
        if dropped && self.seen.capacity() > 4096 && self.seen.len() < self.seen.capacity() / 4 {
            self.seen.shrink_to(self.seen.len() * 2);
            self.expiry.shrink_to(self.expiry.len() * 2);
            self.per_key.shrink_to_fit();
        }
    }
}

impl Default for ReplayGuard {
    fn default() -> Self {
        ReplayGuard::with_limits(DEFAULT_REPLAY_CAPACITY, DEFAULT_REPLAY_PER_KEY)
    }
}

impl ReplayGuard {
    /// A guard of `capacity` credentials, of which one key may hold
    /// `capacity` or [`DEFAULT_REPLAY_PER_KEY`], whichever is smaller.
    pub fn new(capacity: usize) -> ReplayGuard {
        ReplayGuard::with_limits(capacity, capacity.min(DEFAULT_REPLAY_PER_KEY))
    }

    /// A guard of `capacity` credentials in all, `per_key` for any one key,
    /// and a reserve of an eighth of `capacity` from which every key may
    /// still hold [`REPLAY_KEY_FLOOR`] once the main pool is full.
    pub fn with_limits(capacity: usize, per_key: usize) -> ReplayGuard {
        let capacity = capacity.max(1);
        let reserve = capacity / 8;
        ReplayGuard {
            inner: Mutex::new(ReplayInner::default()),
            capacity,
            per_key: per_key.clamp(1, capacity),
            reserve,
            floor: REPLAY_KEY_FLOOR.min(reserve),
            hasher: RandomState::new(),
        }
    }

    fn lock(&self) -> std::sync::MutexGuard<'_, ReplayInner> {
        // A poisoned lock never becomes "allow".
        match self.inner.lock() {
            Ok(inner) => inner,
            Err(poisoned) => poisoned.into_inner(),
        }
    }

    /// Accept this credential once.
    pub fn accept<S: Scheme>(&self, credential: &Credential<S>, now_unix_ms: i64) -> Accepted {
        let nonce = self
            .hasher
            .hash_one((1u8, &credential.fingerprint, &credential.nonce));
        let key = self.hasher.hash_one((0u8, &credential.fingerprint));
        let mut inner = self.lock();
        inner.sweep(now_unix_ms);
        // Replay before capacity: a replay must never be reported as load.
        if inner.seen.contains(&nonce) {
            return Accepted::Replayed;
        }
        let held = inner.per_key.get(&key).copied().unwrap_or(0);
        if held >= self.per_key {
            return Accepted::Full;
        }
        let total = inner.seen.len();
        if total >= self.capacity && !(held < self.floor && total < self.capacity + self.reserve) {
            return Accepted::Full;
        }
        // Held until the last instant it can be presented has passed.
        let expires_ms = credential
            .issued_at_unix_ms
            .saturating_add(MAX_CLOCK_SKEW_MS)
            .max(now_unix_ms);
        inner.expiry.push(Reverse((expires_ms, nonce, key)));
        *inner.per_key.entry(key).or_insert(0) += 1;
        inner.seen.insert(nonce);
        Accepted::Fresh
    }

    /// How many credentials are remembered now.
    pub fn len(&self) -> usize {
        self.lock().seen.len()
    }

    /// How many credentials signed by this fingerprint are remembered now.
    pub fn held_for(&self, fingerprint: &str) -> usize {
        let key = self.hasher.hash_one((0u8, fingerprint));
        self.lock().per_key.get(&key).copied().unwrap_or(0)
    }

    pub fn is_empty(&self) -> bool {
        self.len() == 0
    }
}

/// Why [`check`] refused a call. The wire answer should stay coarse (never
/// say whether a fingerprint is known); the variant is for the audit line.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Refusal {
    Missing,
    Malformed(String),
    /// Outside [`MAX_CLOCK_SKEW_MS`].
    Stale {
        skew_ms: i64,
    },
    /// A method-blind credential where only bound ones are taken.
    Unbound,
    /// Signed for one method, presented at another.
    MethodMismatch {
        signed: String,
        called: String,
    },
    /// The request's path is not a gRPC method.
    NoMethod,
    /// No key this server knows, or the signature did not verify.
    NotVerified,
    Replayed,
    ReplayGuardFull,
}

impl Refusal {
    /// The short reason for an audit record.
    pub fn reason(&self) -> &'static str {
        match self {
            Refusal::Missing => "missing",
            Refusal::Malformed(_) => "malformed",
            Refusal::Stale { .. } => "stale",
            Refusal::Unbound => "unbound",
            Refusal::MethodMismatch { .. } => "method-mismatch",
            Refusal::NoMethod => "no-method",
            Refusal::NotVerified => "not-verified",
            Refusal::Replayed => "replayed",
            Refusal::ReplayGuardFull => "replay-guard-full",
        }
    }
}

impl From<CredentialError> for Refusal {
    fn from(e: CredentialError) -> Refusal {
        match e {
            CredentialError::Missing => Refusal::Missing,
            CredentialError::Malformed(m) => Refusal::Malformed(m),
        }
    }
}

/// **The whole verification, for a product with a flat key set.**
///
/// `header` is the raw metadata value (or `None`), `path` the request's
/// `:path`, `keys` answers the OpenSSH public key line registered for a
/// fingerprint. In order: parse; bound only (a blind credential is refused);
/// inside the clock window; signed for THIS method; a known key whose
/// signature verifies; not seen before. Answers the verified fingerprint.
///
/// The replay guard is consulted LAST, so a forged or misdirected credential
/// never occupies a slot.
pub fn check<S: Scheme>(
    header: Option<&str>,
    path: &str,
    now_unix_ms: i64,
    keys: impl Fn(&str) -> Option<String>,
    guard: &ReplayGuard,
) -> Result<String, Refusal>
where
    S::Error: Into<Refusal>,
{
    let raw = header.ok_or(Refusal::Missing)?;
    let cred = Credential::<S>::parse(raw).map_err(Into::into)?;
    let Some(signed) = cred.method.clone() else {
        return Err(Refusal::Unbound);
    };
    let skew_ms = now_unix_ms - cred.issued_at_unix_ms;
    if skew_ms.abs() > MAX_CLOCK_SKEW_MS {
        return Err(Refusal::Stale { skew_ms });
    }
    let called = method_name_from_path(path).ok_or(Refusal::NoMethod)?;
    if signed != called {
        return Err(Refusal::MethodMismatch { signed, called });
    }
    let key = keys(&cred.fingerprint).ok_or(Refusal::NotVerified)?;
    if !verify_signature(&key, &cred) {
        return Err(Refusal::NotVerified);
    }
    match guard.accept(&cred, now_unix_ms) {
        Accepted::Fresh => Ok(cred.fingerprint),
        Accepted::Replayed => Err(Refusal::Replayed),
        Accepted::Full => Err(Refusal::ReplayGuardFull),
    }
}

/// The `SHA256:…` fingerprint of an OpenSSH public key line, or `None` when
/// the line is not a key.
pub fn fingerprint_of(openssh_public_key: &str) -> Option<String> {
    ssh_key::PublicKey::from_openssh(openssh_public_key.trim())
        .ok()
        .map(|k| k.fingerprint(ssh_key::HashAlg::Sha256).to_string())
}

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

    #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
    struct A;
    impl Scheme for A {
        const METADATA_KEY: &'static str = "a-authorization";
        const SCHEME: &'static str = "a-sshsig-v2";
        const SCHEME_UNBOUND: Option<&'static str> = Some("a-sshsig-v1");
        const NAMESPACE: &'static str = "a-grpc";
        const PREAMBLE: &'static str = "a-grpc-auth-v2";
        const PREAMBLE_UNBOUND: &'static str = "a-grpc-auth-v1";
        type Error = Refusal;
    }
    #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
    struct B;
    impl Scheme for B {
        const METADATA_KEY: &'static str = "b-authorization";
        const SCHEME: &'static str = "b-sshsig-v1";
        const SCHEME_UNBOUND: Option<&'static str> = None;
        const NAMESPACE: &'static str = "b-grpc";
        const PREAMBLE: &'static str = "b-grpc-auth-v1";
        const PREAMBLE_UNBOUND: &'static str = "";
        type Error = Refusal;
    }

    fn key(seed: u8) -> ssh_key::PrivateKey {
        let kp = ssh_key::private::Ed25519Keypair::from_seed(&[seed; 32]);
        ssh_key::PrivateKey::from(kp)
    }
    fn line(k: &ssh_key::PrivateKey) -> String {
        k.public_key().to_openssh().unwrap()
    }
    const NOW: i64 = 1_790_000_000_000;
    const NONCE: &str = "0123456789abcdef0123";
    const PATH: &str = "/monetize.v1.Products/List";

    #[test]
    fn a_signed_call_verifies_once_for_its_method_only() {
        let k = key(1);
        let c = sign::<B>(&k, NOW, NONCE, Some("Products.List")).unwrap();
        let header = c.to_header_value();
        assert_eq!(
            Credential::<B>::parse(&header).unwrap(),
            c,
            "render/parse round-trips"
        );
        let keys = |fp: &str| (fp == c.fingerprint).then(|| line(&k));
        let g = ReplayGuard::default();
        assert_eq!(
            check::<B>(Some(&header), PATH, NOW, keys, &g),
            Ok(c.fingerprint.clone())
        );
        assert_eq!(
            check::<B>(Some(&header), PATH, NOW, keys, &g),
            Err(Refusal::Replayed),
            "single use"
        );
        let c2 = sign::<B>(&k, NOW, "another-nonce-0000", Some("Products.List")).unwrap();
        assert_eq!(
            check::<B>(
                Some(&c2.to_header_value()),
                "/monetize.v1.Purchase/Start",
                NOW,
                keys,
                &g
            ),
            Err(Refusal::MethodMismatch {
                signed: "Products.List".into(),
                called: "Purchase.Start".into()
            })
        );
        assert_eq!(g.len(), 1, "a refused call never occupies a replay slot");
    }

    #[test]
    fn a_stranger_a_stale_and_a_blind_credential_are_refused() {
        let (k, other) = (key(1), key(2));
        let g = ReplayGuard::default();
        let c = sign::<B>(&k, NOW, NONCE, Some("Products.List")).unwrap();
        let only_other = |_: &str| Some(line(&other));
        assert_eq!(
            check::<B>(Some(&c.to_header_value()), PATH, NOW, only_other, &g),
            Err(Refusal::NotVerified),
            "another key's line never verifies it"
        );
        assert_eq!(
            check::<B>(Some(&c.to_header_value()), PATH, NOW, |_| None, &g),
            Err(Refusal::NotVerified),
            "an unknown key"
        );
        let keys = |_: &str| Some(line(&k));
        let late = NOW + MAX_CLOCK_SKEW_MS + 1;
        assert!(matches!(
            check::<B>(Some(&c.to_header_value()), PATH, late, keys, &g),
            Err(Refusal::Stale { .. })
        ));
        let blind = sign::<A>(&k, NOW, NONCE, None).unwrap();
        assert_eq!(
            check::<A>(Some(&blind.to_header_value()), PATH, NOW, keys, &g),
            Err(Refusal::Unbound),
            "check takes bound credentials only"
        );
        assert_eq!(check::<B>(None, PATH, NOW, keys, &g), Err(Refusal::Missing));
    }

    /// The namespace is the product: a credential of scheme A, even rendered
    /// under B's scheme token, does not verify under B.
    #[test]
    fn one_products_credential_never_verifies_at_another() {
        let k = key(3);
        let a = sign::<A>(&k, NOW, NONCE, Some("Products.List")).unwrap();
        let smuggled = Credential::<B>::new(
            a.fingerprint.clone(),
            a.issued_at_unix_ms,
            a.nonce.clone(),
            a.method.clone(),
            a.signature_line.clone(),
        );
        assert!(verify_signature(&line(&k), &a));
        assert!(
            !verify_signature(&line(&k), &smuggled),
            "A's signature is not B's"
        );
        assert!(
            Credential::<B>::parse(&a.to_header_value()).is_err(),
            "A's scheme token is not B's"
        );
    }

    #[test]
    fn the_armor_mapping_is_a_bijection_and_paths_name_methods() {
        let armor = "-----BEGIN SSH SIGNATURE-----\nU1NIU0lH\n-----END SSH SIGNATURE-----\n";
        assert_eq!(line_to_armor(&armor_to_line(armor)), armor);
        assert!(!armor_to_line(armor).contains(char::is_whitespace));
        assert_eq!(
            method_name_from_path("/gunnar.v1.Access/AddKey").as_deref(),
            Some("Access.AddKey")
        );
        assert_eq!(method_name_from_path("/a/b/c"), None);
        assert_eq!(
            fingerprint_of(&line(&key(4))),
            Some(
                key(4)
                    .public_key()
                    .fingerprint(ssh_key::HashAlg::Sha256)
                    .to_string()
            )
        );
        assert_eq!(fingerprint_of("not a key"), None);
    }

    #[test]
    fn a_full_guard_refuses_and_forgets_only_the_expired() {
        let k = key(5);
        let g = ReplayGuard::new(1);
        let c1 = sign::<B>(&k, NOW, "nonce-number-one-000", Some("X.Y")).unwrap();
        let c2 = sign::<B>(&k, NOW, "nonce-number-two-000", Some("X.Y")).unwrap();
        assert_eq!(g.accept(&c1, NOW), Accepted::Fresh);
        assert_eq!(g.accept(&c2, NOW), Accepted::Full);
        assert_eq!(
            g.accept(&c1, NOW),
            Accepted::Replayed,
            "replay before capacity"
        );
        assert_eq!(
            g.accept(&c2, NOW + MAX_CLOCK_SKEW_MS + 1),
            Accepted::Fresh,
            "the expired one is forgotten"
        );
    }
}