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

nordisk/gunnar-ui

crates/gunnar-ui-server/src/signer.rs

//! The control-plane credential: one single-use `sshsig` per gRPC call.
//!
//! gunnar has no sessions, no cookies and no bearer tokens on its control
//! plane. Every call carries a fresh signature over a challenge naming the key,
//! the moment and a nonce, and the server refuses a `(fingerprint, nonce)` pair
//! it has already seen. This module is the client half.
//!
//! # The format, exactly
//!
//! ```text
//! signed bytes : "gunnar-grpc-auth-v2\n<SHA256:fp>\n<issued_at_ms>\n<nonce>\n<Service.Method>\n"
//! sshsig ns    : "gunnar-grpc"
//! armour       : SshSig PEM, then '\n' -> '|' and ' ' -> '~'
//! header       : "gunnar-sshsig-v2 <fp> <ms> <nonce> <Service.Method> <armour>"
//! metadata key : "gunnar-authorization"
//! ```
//!
//! # Why the method is in the signed bytes (GUN-001)
//!
//! The v1 credential signed `fingerprint‖timestamp‖nonce` and nothing else. An
//! on-path attacker who took that header off any call could replay it, inside
//! its five-minute window, at `Access.AddKey` — and hold a permanent admin key
//! signed by the console's own identity. v2 puts the method the credential is
//! about to be spent on into the challenge, so a credential minted for
//! `Refs.Log` verifies for `Refs.Log` and for nothing else. The method comes
//! from `tonic::GrpcMethod`, which every generated client puts in the request
//! extensions — the one place that knows the key and the method at once is the
//! interceptor, and that is where the credential is minted. gunnar refuses v1
//! when started with `--grpc-require-bound-credentials` (on from 5.3.3); this
//! console cannot mint it outside its tests, and never falls back to it.
//!
//! The v1 shape is kept for `verify_header` and the tests, because a verifier
//! that understands only one shape cannot prove the other is refused.
//!
//! The armour mangling is the part that bites. A PEM blob cannot travel in an
//! HTTP/2 header value, and gunnar parses the credential by splitting on
//! whitespace, so both newlines and the space inside `-----BEGIN SSH
//! SIGNATURE-----` are substituted. Send the PEM verbatim and every call is
//! refused with "trailing data after the signature" — a message that reads like
//! a key problem and is not.
//!
//! These constants are gunnar's, mirrored here because the crate that owns them
//! cannot be depended on (it pulls in `gunnar-proto`, whose build script
//! requires `protoc` and pins tonic 0.13). [`crate::signer`]'s tests assert the
//! wire bytes, and the contract test asserts the constants have not drifted.

use std::fmt;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};

use rand::Rng;

/// The gRPC metadata key a credential travels in. Not `authorization`.
pub const CREDENTIAL_METADATA_KEY: &str = "gunnar-authorization";

/// The credential scheme token this console SENDS: method-bound.
pub const CREDENTIAL_SCHEME: &str = "gunnar-sshsig-v2";

/// The method-blind scheme. Refused by gunnar with require_bound on; never sent from here.
pub const CREDENTIAL_SCHEME_V1: &str = "gunnar-sshsig-v1";

/// **The `sshsig` namespace a key attestation is signed under**, gunnar's own
/// (`gunnar_core::key_attestation::KEY_ATTESTATION_NAMESPACE`). Its own, so an
/// attestation can never be replayed as a membership change, a credential or a
/// forge approval.
pub const KEY_ATTESTATION_NAMESPACE: &str = "gunnar-key-attestation";

/// **The exact bytes gunnar verifies an attestation over.**
///
/// Mirrored from `gunnar_core::key_attestation::KeyAttestation::signing_bytes`
/// rather than shared, because this crate vendors gunnar's PROTOS and not its
/// core. Line-oriented, fixed order, every value length-prefixed — JSON has no
/// canonical form, and a newline inside a value must not be able to look like a
/// second field.
///
/// A drift here is not a compile error on either side; it is a signature that
/// verifies nowhere. `the_attestation_bytes_are_gunnars` pins the exact byte
/// string, so a change to the format fails a test here rather than a customer's
/// first push.
fn key_attestation_signing_bytes(principal_id: &str, fingerprint: &str, at_unix_ms: i64) -> Vec<u8> {
    let mut out = String::new();
    out.push_str("gunnar-key-attestation-v1\n");
    out.push_str(&format!("at {at_unix_ms}\n"));
    let mut field = |name: &str, value: &str| {
        out.push_str(&format!("{name} {} {value}\n", value.len()));
    };
    field("principal", principal_id);
    field("key", fingerprint);
    out.into_bytes()
}

/// The `sshsig` namespace. A signature made under `git` must never verify here.
pub const SSHSIG_NAMESPACE: &str = "gunnar-grpc";

/// First line of the method-blind signed challenge — domain separation.
pub const CHALLENGE_PREAMBLE: &str = "gunnar-grpc-auth-v1";

/// First line of the method-bound challenge. A different first line, not a
/// fifth line under the old one, so a v1 verifier handed v2 bytes fails
/// outright instead of verifying a prefix.
pub const CHALLENGE_PREAMBLE_V2: &str = "gunnar-grpc-auth-v2";

/// Shortest nonce gunnar accepts. Below this it refuses the credential outright.
pub const MIN_NONCE_LEN: usize = 16;

/// How far `issued_at` may sit from gunnar's clock, either direction.
pub const MAX_CLOCK_SKEW_MS: i64 = 5 * 60 * 1000;

/// Bytes of entropy per nonce. Rendered as hex, so 32 characters — comfortably
/// past [`MIN_NONCE_LEN`], and far past collision range within one skew window.
const NONCE_BYTES: usize = 16;

/// Something went wrong producing a credential.
#[derive(Debug)]
pub enum SignerError {
    /// The key file could not be read.
    Read {
        path: String,
        source: std::io::Error,
    },
    /// The key is not an OpenSSH private key, or is passphrase-protected.
    ///
    /// gunnar cannot prompt, so an encrypted key is a startup failure.
    Decode(ssh_key::Error),
    /// The key is not ed25519.
    ///
    /// gunnar builds `ssh-key` with only the `ed25519` feature, so it physically
    /// cannot decode an ECDSA or RSA public key — the resulting refusal names
    /// neither the algorithm nor the reason.
    Algorithm { found: String },
    /// Signing failed.
    Sign(ssh_key::Error),
    /// The signature did not verify against our own public key.
    ///
    /// A bug rather than a condition, but checked at startup so it surfaces once
    /// instead of as a stream of authentication failures.
    SelfCheck,
}

impl SignerError {
    /// True when the fault is the key itself, so startup should stop.
    pub fn is_fatal(&self) -> bool {
        matches!(
            self,
            SignerError::Read { .. }
                | SignerError::Decode(_)
                | SignerError::Algorithm { .. }
                | SignerError::SelfCheck
        )
    }
}

impl fmt::Display for SignerError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            SignerError::Read { path, source } => {
                write!(f, "cannot read the identity key at {path}: {source}")
            }
            SignerError::Decode(e) => write!(
                f,
                "the identity key is not a usable OpenSSH private key ({e}); \
                 note that gunnar cannot prompt, so it must be unencrypted"
            ),
            SignerError::Algorithm { found } => write!(
                f,
                "the identity key is {found}; gunnar only understands ed25519 \
                 (generate one with `ssh-keygen -t ed25519`)"
            ),
            SignerError::Sign(e) => write!(f, "signing the challenge failed: {e}"),
            SignerError::SelfCheck => write!(
                f,
                "the credential this key produces does not verify against its \
                 own public key"
            ),
        }
    }
}

impl std::error::Error for SignerError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            SignerError::Read { source, .. } => Some(source),
            SignerError::Decode(e) | SignerError::Sign(e) => Some(e),
            _ => None,
        }
    }
}

/// Holds the BFF's identity key and mints one credential per call.
pub struct Signer {
    key: ssh_key::PrivateKey,
    fingerprint: String,
}

impl fmt::Debug for Signer {
    /// Never renders the private key.
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("Signer")
            .field("fingerprint", &self.fingerprint)
            .finish_non_exhaustive()
    }
}

impl Signer {
    /// Load an identity from an unencrypted OpenSSH ed25519 private key file.
    ///
    /// # Errors
    ///
    /// Fails if the file cannot be read, is not an OpenSSH private key, is
    /// passphrase-protected, is not ed25519, or produces a credential that does
    /// not verify against its own public key.
    pub fn load(path: impl AsRef<Path>) -> Result<Signer, SignerError> {
        let path = path.as_ref();
        let text = std::fs::read_to_string(path).map_err(|source| SignerError::Read {
            path: path.display().to_string(),
            source,
        })?;
        Signer::from_openssh(&text)
    }

    /// Build an identity from the text of an OpenSSH private key.
    ///
    /// # Errors
    ///
    /// See [`Signer::load`].
    pub fn from_openssh(text: impl AsRef<str>) -> Result<Signer, SignerError> {
        let key = ssh_key::PrivateKey::from_openssh(text.as_ref()).map_err(SignerError::Decode)?;
        Signer::new(key)
    }

    /// Wrap an already-decoded key.
    ///
    /// # Errors
    ///
    /// See [`Signer::load`].
    pub fn new(key: ssh_key::PrivateKey) -> Result<Signer, SignerError> {
        if !key.algorithm().is_ed25519() {
            return Err(SignerError::Algorithm {
                found: key.algorithm().to_string(),
            });
        }
        let fingerprint = key
            .public_key()
            .fingerprint(ssh_key::HashAlg::Sha256)
            .to_string();
        let signer = Signer { key, fingerprint };

        // Prove the whole chain once, here, rather than discovering it as a
        // stream of "did not verify against any registered key" refusals that
        // name neither the key nor the encoding.
        let header = signer.credential_for(0, "self-check-nonce-0000", "Admin.Health")?;
        if !verify_header(&signer.public_key_openssh(), &header) {
            return Err(SignerError::SelfCheck);
        }
        Ok(signer)
    }

    /// The `SHA256:…` fingerprint gunnar knows this identity by.
    pub fn fingerprint(&self) -> &str {
        &self.fingerprint
    }

    /// The public key line to register with `gunnar key add`.
    pub fn public_key_openssh(&self) -> String {
        self.key
            .public_key()
            .to_openssh()
            .unwrap_or_else(|_| String::new())
    }

    /// **Vouch for a key being added to the principal this key belongs to.**
    ///
    /// The THIRD and last thing a key here signs, and the one with the
    /// narrowest use: gunnar refuses `Access.AddKey` when the target principal
    /// already holds a signing key and the caller is not the target
    /// (`gunnar_core::key_attestation::Requirement::of`), and this is the
    /// target's own word that the new key may act as them.
    ///
    /// # Why the console can make one at all, and why that is not a hole
    ///
    /// Only a key the TARGET holds can sign this. The console holds exactly one
    /// such key per person — the tenant credential
    /// [`crate::tenant::TenantKeys`] enrolled on their principal, which is the
    /// credential their reads are already answered with. Signing here is
    /// therefore the same act the console already performs on every read: it
    /// acts as that tenant, at gunnar, with a key gunnar knows is theirs.
    ///
    /// What makes it *right* rather than merely possible is WHERE it is
    /// allowed: `api::account::add`, where the target is the name in the
    /// SESSION and cannot be spelled by any path segment, query parameter or
    /// body field. A live passkey session is the consent. It is NOT offered on
    /// `/api/keys`, the admin surface that names any principal on the box —
    /// there the console has no business vouching for anybody, and gunnar's
    /// refusal stands and is passed through in its own words.
    ///
    /// # The namespace is its own
    ///
    /// `gunnar-key-attestation`, a third namespace beside the credential's and
    /// the forge's, so an attestation can never be replayed as a credential or
    /// as a review — `sshsig` binds the namespace into what is signed, which is
    /// what makes that structural rather than a convention.
    ///
    /// Returns the PEM armour verbatim, like [`Signer::sign_forge_event`] and
    /// unlike [`Signer::credential_for`]: this travels in a protobuf string field.
    ///
    /// # Errors
    ///
    /// Fails only if signing or armouring itself fails.
    pub fn sign_key_attestation(
        &self,
        principal_id: &str,
        fingerprint: &str,
        at_unix_ms: i64,
    ) -> Result<(String, String), SignerError> {
        let signature = self
            .key
            .sign(
                KEY_ATTESTATION_NAMESPACE,
                ssh_key::HashAlg::Sha512,
                &key_attestation_signing_bytes(principal_id, fingerprint, at_unix_ms),
            )
            .map_err(SignerError::Sign)?;
        let armor = signature
            .to_pem(ssh_key::LineEnding::LF)
            .map_err(SignerError::Sign)?;
        Ok((self.fingerprint.clone(), armor))
    }

    /// Sign one forge event with this identity's key.
    ///
    /// The **second** thing this key signs, and the only other one: a
    /// credential proves the console is on the connection, and this is the
    /// console putting its name to a review event. Different `sshsig`
    /// namespaces ([`SSHSIG_NAMESPACE`] against
    /// [`crate::forge::FORGE_NAMESPACE`]) keep the two from ever being
    /// mistaken for each other — a credential must never be presentable as an
    /// approval.
    ///
    /// Returns the PEM armour **verbatim**: this travels in a protobuf string
    /// field, not an HTTP/2 header, so the mangling
    /// [`credential_for`](Signer::credential_for) does would be wrong here and gunnar
    /// would answer "the event's signature is not a well-formed sshsig".
    ///
    /// # Errors
    ///
    /// Fails only if signing or armouring itself fails.
    pub fn sign_forge_event(
        &self,
        event: &crate::forge::ForgeEvent,
    ) -> Result<(String, String), SignerError> {
        let signature = self
            .key
            .sign(
                crate::forge::FORGE_NAMESPACE,
                ssh_key::HashAlg::Sha512,
                &event.signing_bytes(),
            )
            .map_err(SignerError::Sign)?;
        let armor = signature
            .to_pem(ssh_key::LineEnding::LF)
            .map_err(SignerError::Sign)?;
        Ok((self.fingerprint.clone(), armor))
    }

    /// Mint a credential for right now, with a fresh nonce, **bound to
    /// `method`** (`Service.Method`, the spelling gunnar's `auth::METHODS`
    /// uses and its `method_name_from_path` derives from the `:path`).
    ///
    /// # Errors
    ///
    /// Fails only if signing itself fails.
    pub fn credential_for_now(&self, method: &str) -> Result<String, SignerError> {
        self.credential_for(now_unix_ms(), &fresh_nonce(), method)
    }

    /// Mint a method-bound credential for an explicit moment and nonce.
    ///
    /// The clock and the entropy are parameters so the wire format can be
    /// asserted byte for byte in a test.
    ///
    /// # Errors
    ///
    /// Fails only if signing itself fails.
    pub fn credential_for(
        &self,
        issued_at_unix_ms: i64,
        nonce: &str,
        method: &str,
    ) -> Result<String, SignerError> {
        debug_assert!(
            nonce.len() >= MIN_NONCE_LEN,
            "gunnar refuses a nonce shorter than {MIN_NONCE_LEN}"
        );
        let message = challenge_bound(&self.fingerprint, issued_at_unix_ms, nonce, method);
        let line = self.sign_line(&message)?;
        Ok(format!(
            "{CREDENTIAL_SCHEME} {} {issued_at_unix_ms} {nonce} {method} {line}",
            self.fingerprint
        ))
    }

    /// The METHOD-BLIND v1 credential, for the tests alone: they prove
    /// `verify_header` tells the two shapes apart and that the fake gunnar
    /// refuses this one, as gunnar does with require_bound on. This console
    /// cannot mint it (stinky #24).
    #[cfg(test)]
    pub fn credential(&self, issued_at_unix_ms: i64, nonce: &str) -> Result<String, SignerError> {
        debug_assert!(
            nonce.len() >= MIN_NONCE_LEN,
            "gunnar refuses a nonce shorter than {MIN_NONCE_LEN}"
        );
        let message = challenge(&self.fingerprint, issued_at_unix_ms, nonce);
        let line = self.sign_line(&message)?;
        Ok(format!(
            "{CREDENTIAL_SCHEME_V1} {} {issued_at_unix_ms} {nonce} {line}",
            self.fingerprint
        ))
    }

    /// Sign `message` under the gRPC namespace and pack the armour for a header.
    fn sign_line(&self, message: &str) -> Result<String, SignerError> {
        let signature = self
            .key
            .sign(
                SSHSIG_NAMESPACE,
                ssh_key::HashAlg::Sha512,
                message.as_bytes(),
            )
            .map_err(SignerError::Sign)?;
        let armor = signature
            .to_pem(ssh_key::LineEnding::LF)
            .map_err(SignerError::Sign)?;
        Ok(armor_to_line(&armor))
    }
}

/// The exact bytes a v1 credential signs. Note the trailing newline.
pub fn challenge(fingerprint: &str, issued_at_unix_ms: i64, nonce: &str) -> String {
    format!("{CHALLENGE_PREAMBLE}\n{fingerprint}\n{issued_at_unix_ms}\n{nonce}\n")
}

/// The exact bytes a v2 credential signs: the v1 lines under the v2 preamble,
/// then the method. Byte for byte gunnar's `auth::challenge_bound`.
pub fn challenge_bound(
    fingerprint: &str,
    issued_at_unix_ms: i64,
    nonce: &str,
    method: &str,
) -> String {
    format!("{CHALLENGE_PREAMBLE_V2}\n{fingerprint}\n{issued_at_unix_ms}\n{nonce}\n{method}\n")
}

/// PEM armour to one whitespace-free header field.
///
/// `\n` becomes `|` and the space in the PEM label becomes `~`. Neither occurs
/// in base64 or in a PEM label, so the mapping round-trips.
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
}

/// Verify a rendered credential header against an OpenSSH public key line.
///
/// gunnar's verifier, ported, so a signature can be checked without a running
/// server. Used for the startup self-check and by the round-trip tests.
pub fn verify_header(openssh_public_key: &str, header: &str) -> bool {
    let mut fields = header.split_whitespace();
    let (Some(scheme), Some(fingerprint), Some(issued_at), Some(nonce)) =
        (fields.next(), fields.next(), fields.next(), fields.next())
    else {
        return false;
    };
    // The scheme decides the shape: v2 carries the method before the armour,
    // v1 does not. Anything after the armour is a malformed header on both.
    let (message, line) = match scheme {
        s if s == CREDENTIAL_SCHEME => {
            let (Some(method), Some(line)) = (fields.next(), fields.next()) else {
                return false;
            };
            let Ok(issued_at_unix_ms) = issued_at.parse::<i64>() else {
                return false;
            };
            (
                challenge_bound(fingerprint, issued_at_unix_ms, nonce, method),
                line,
            )
        }
        s if s == CREDENTIAL_SCHEME_V1 => {
            let Some(line) = fields.next() else {
                return false;
            };
            let Ok(issued_at_unix_ms) = issued_at.parse::<i64>() else {
                return false;
            };
            (challenge(fingerprint, issued_at_unix_ms, nonce), line)
        }
        _ => return false,
    };
    if fields.next().is_some() {
        return false;
    }
    let Ok(key) = ssh_key::PublicKey::from_openssh(openssh_public_key) else {
        return false;
    };
    if key.fingerprint(ssh_key::HashAlg::Sha256).to_string() != fingerprint {
        return false;
    }
    let Ok(signature) = ssh_key::SshSig::from_pem(line_to_armor(line).as_bytes()) else {
        return false;
    };
    if signature.namespace() != SSHSIG_NAMESPACE {
        return false;
    }
    key.verify(SSHSIG_NAMESPACE, message.as_bytes(), &signature)
        .is_ok()
}

fn now_unix_ms() -> i64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_millis() as i64)
        .unwrap_or_default()
}

fn fresh_nonce() -> String {
    let mut bytes = [0u8; NONCE_BYTES];
    rand::rand_core::UnwrapErr(rand::rngs::SysRng).fill_bytes(&mut bytes);
    let mut hex = String::with_capacity(NONCE_BYTES * 2);
    for byte in bytes {
        use fmt::Write as _;
        let _ = write!(hex, "{byte:02x}");
    }
    hex
}

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

    /// A fixed key, so the assertions below are about the format and not about
    /// whatever `ssh-keygen` produced this morning.
    fn test_signer() -> Signer {
        let seed = [7u8; 32];
        let keypair = ssh_key::private::Ed25519Keypair::from_seed(&seed);
        let key = ssh_key::PrivateKey::from(keypair);
        Signer::new(key).expect("a fixed ed25519 seed makes a usable signer")
    }

    /// **The method is in the signed bytes** (GUN-001). gunnar:
    /// `format!("{PREAMBLE_V2}\n{fp}\n{ms}\n{nonce}\n{method}\n")`.
    #[test]
    fn bound_challenge_bytes_match_gunnars_writer() {
        assert_eq!(
            challenge_bound(
                "SHA256:abc",
                1_700_000_000_000,
                "nonce-0123456789ab",
                "Access.AddKey"
            ),
            "gunnar-grpc-auth-v2\nSHA256:abc\n1700000000000\nnonce-0123456789ab\nAccess.AddKey\n"
        );
    }

    /// The v2 header has six fields with the method fifth, and it verifies —
    /// for its own method only. RED before v2 existed: `credential_for` did
    /// not; and with the method left out of the challenge, the tampered header
    /// below verified.
    #[test]
    fn a_bound_credential_verifies_for_its_method_and_no_other() {
        let signer = test_signer();
        let header = signer
            .credential_for(1_700_000_000_000, "nonce-0123456789ab", "Refs.Log")
            .expect("signs");
        let fields: Vec<&str> = header.split_whitespace().collect();
        assert_eq!(fields.len(), 6, "{header}");
        assert_eq!(fields[0], CREDENTIAL_SCHEME);
        assert_eq!(fields[0], "gunnar-sshsig-v2");
        assert_eq!(fields[1], signer.fingerprint());
        assert_eq!(fields[4], "Refs.Log");
        assert!(verify_header(&signer.public_key_openssh(), &header));

        // The relay the finding describes: the same header, spent elsewhere.
        let relayed = header.replace(" Refs.Log ", " Access.AddKey ");
        assert_ne!(relayed, header);
        assert!(
            !verify_header(&signer.public_key_openssh(), &relayed),
            "a credential minted for Refs.Log must not open Access.AddKey"
        );
        // A v1 header is still recognised as v1 — five fields, method-blind —
        // so the verifier can tell the two shapes apart rather than guess.
        let v1 = signer
            .credential(1_700_000_000_000, "nonce-0123456789ab")
            .expect("signs");
        assert!(v1.starts_with("gunnar-sshsig-v1 "));
        assert_eq!(v1.split_whitespace().count(), 5);
        assert!(verify_header(&signer.public_key_openssh(), &v1));
        // And a v2 header relabelled as v1 does not verify: the preambles differ.
        let relabelled = header.replacen("gunnar-sshsig-v2", "gunnar-sshsig-v1", 1);
        assert!(!verify_header(&signer.public_key_openssh(), &relabelled));
    }

    #[test]
    fn challenge_bytes_match_gunnars_writer() {
        // gunnar: format!("{PREAMBLE}\n{fp}\n{ms}\n{nonce}\n") — the trailing
        // newline is part of the signed message.
        assert_eq!(
            challenge("SHA256:abc", 1_700_000_000_000, "nonce-0123456789ab"),
            "gunnar-grpc-auth-v1\nSHA256:abc\n1700000000000\nnonce-0123456789ab\n"
        );
    }

    #[test]
    fn armour_loses_every_whitespace_character() {
        let header = test_signer()
            .credential(1_700_000_000_000, "nonce-0123456789ab")
            .expect("signing a fixed challenge succeeds");
        let armour = header.split_whitespace().nth(4).expect("five fields");

        assert!(
            !armour.contains(char::is_whitespace),
            "gunnar splits the credential on whitespace; the armour must not contain any"
        );
        assert!(armour.starts_with("-----BEGIN~SSH~SIGNATURE-----|"));
        assert_eq!(header.split_whitespace().count(), 5);
    }

    #[test]
    fn armour_mangling_round_trips() {
        let pem = "-----BEGIN SSH SIGNATURE-----\nAAAA\nBBBB\n-----END SSH SIGNATURE-----\n";
        assert_eq!(line_to_armor(&armor_to_line(pem)), pem);
    }

    #[test]
    fn a_minted_credential_verifies() {
        let signer = test_signer();
        let header = signer
            .credential(1_700_000_000_000, "nonce-0123456789ab")
            .expect("signing succeeds");

        assert!(verify_header(&signer.public_key_openssh(), &header));
    }

    #[test]
    fn the_pem_verbatim_is_refused() {
        // The regression this module exists for: sending the unmangled PEM
        // gives gunnar six-plus whitespace-separated fields, and it answers
        // "trailing data after the signature".
        let signer = test_signer();
        let header = signer
            .credential(1_700_000_000_000, "nonce-0123456789ab")
            .expect("signing succeeds");
        let fields: Vec<&str> = header.splitn(5, ' ').collect();
        let verbatim = format!(
            "{} {} {} {} {}",
            fields[0],
            fields[1],
            fields[2],
            fields[3],
            line_to_armor(fields[4])
        );

        assert!(verbatim.split_whitespace().count() > 5);
        assert!(!verify_header(&signer.public_key_openssh(), &verbatim));
    }

    #[test]
    fn another_keys_signature_is_not_accepted() {
        let signer = test_signer();
        let other = Signer::new(ssh_key::PrivateKey::from(
            ssh_key::private::Ed25519Keypair::from_seed(&[9u8; 32]),
        ))
        .expect("a second fixed seed makes a usable signer");
        let header = signer
            .credential(1_700_000_000_000, "nonce-0123456789ab")
            .expect("signing succeeds");

        assert!(!verify_header(&other.public_key_openssh(), &header));
    }

    /// **A forge event this key signs verifies the way gunnar verifies it** —
    /// right namespace, right bytes, right key — and the red is all three:
    /// another key, a mutated event, and the credential namespace.
    ///
    /// The last one is the one that matters. `gunnar-grpc` and `gunnar-forge`
    /// are separate namespaces so a credential can never be replayed as an
    /// approval, and a test that only checked the happy path would not notice
    /// them collapsing into one.
    #[test]
    fn a_signed_forge_event_verifies_and_a_credential_is_not_one() {
        use crate::forge::{Event, ForgeEvent, FORGE_NAMESPACE};

        let signer = test_signer();
        let event = ForgeEvent {
            repo: "team/proposed".into(),
            pull: 7,
            actor: "console".into(),
            at_unix_ms: 1_700_000_000_000,
            event: Event::Approved {
                commit: "a".repeat(40),
            },
        };
        let (fingerprint, pem) = signer.sign_forge_event(&event).expect("sign");
        assert_eq!(fingerprint, signer.fingerprint());

        let key = ssh_key::PublicKey::from_openssh(&signer.public_key_openssh()).expect("key");
        let signature = ssh_key::SshSig::from_pem(pem.as_bytes()).expect("armour");
        assert_eq!(signature.namespace(), FORGE_NAMESPACE);
        assert!(key
            .verify(FORGE_NAMESPACE, &event.signing_bytes(), &signature)
            .is_ok());

        // Red 1: another key does not verify it.
        let other = Signer::new(ssh_key::PrivateKey::from(
            ssh_key::private::Ed25519Keypair::from_seed(&[9u8; 32]),
        ))
        .expect("a second fixed seed");
        let stranger =
            ssh_key::PublicKey::from_openssh(&other.public_key_openssh()).expect("their key");
        assert!(stranger
            .verify(FORGE_NAMESPACE, &event.signing_bytes(), &signature)
            .is_err());

        // Red 2: a commit the reviewer never named does not verify. This is
        // the whole point of the gate, one layer down.
        let mut elsewhere = event.clone();
        elsewhere.event = Event::Approved {
            commit: "b".repeat(40),
        };
        assert!(key
            .verify(FORGE_NAMESPACE, &elsewhere.signing_bytes(), &signature)
            .is_err());

        // Red 3: the same bytes checked under the CREDENTIAL namespace do not
        // verify, so the two signatures can never be traded for one another.
        assert!(key
            .verify(SSHSIG_NAMESPACE, &event.signing_bytes(), &signature)
            .is_err());
    }

    /// The forge armour must stay a real PEM: it travels in a protobuf string,
    /// and the header mangling would make gunnar reject it as malformed.
    #[test]
    fn a_forge_signature_is_not_mangled_the_way_a_credential_is() {
        use crate::forge::{Event, ForgeEvent};

        let (_, pem) = test_signer()
            .sign_forge_event(&ForgeEvent {
                repo: "team/proposed".into(),
                pull: 1,
                actor: "console".into(),
                at_unix_ms: 1,
                event: Event::Commented {
                    body: "ok".into(),
                    path: String::new(),
                    line: 0,
                    blob_oid: String::new(),
                },
            })
            .expect("sign");

        assert!(pem.starts_with("-----BEGIN SSH SIGNATURE-----\n"));
        assert!(!pem.contains('|') && !pem.contains('~'));
    }

    #[test]
    fn nonces_are_long_enough_and_unique() {
        let a = fresh_nonce();
        let b = fresh_nonce();

        assert!(a.len() >= MIN_NONCE_LEN, "gunnar refuses a short nonce");
        assert_ne!(a, b, "a repeated nonce locks the caller out");
        assert!(!a.contains(char::is_whitespace));
    }

    #[test]
    fn a_non_ed25519_key_is_refused_at_load() {
        // gunnar cannot even decode such a public key, so catching it here is
        // the difference between one clear message and endless 401s.
        let rsa =
            "-----BEGIN OPENSSH PRIVATE KEY-----\nnot a key\n-----END OPENSSH PRIVATE KEY-----\n";
        let err = Signer::from_openssh(rsa).expect_err("garbage is not a key");

        assert!(err.is_fatal());
    }
    /// ★ **The bytes gunnar verifies over, pinned exactly.**
    ///
    /// `key_attestation_signing_bytes` mirrors
    /// `gunnar_core::key_attestation::KeyAttestation::signing_bytes`, and the
    /// mirror is not checked by any compiler: this crate vendors gunnar's
    /// protos, not its core. A drift is not a build error on either side — it
    /// is a signature that verifies nowhere, discovered by a customer whose
    /// first push is refused. So the byte string itself is written out here.
    #[test]
    fn the_attestation_bytes_are_gunnars() {
        let got = super::key_attestation_signing_bytes("p-anna", "SHA256:abc", 1_788_000_000_000);
        assert_eq!(
            String::from_utf8(got).expect("ascii"),
            "gunnar-key-attestation-v1\nat 1788000000000\nprincipal 6 p-anna\nkey 10 SHA256:abc\n"
        );
    }

    /// **A length prefix is what stops a newline inside a value looking like a
    /// second field.** The format's whole reason, so a value carrying one is
    /// the row that proves it.
    #[test]
    fn a_newline_in_a_value_cannot_forge_a_field() {
        let sneaky = super::key_attestation_signing_bytes("p-a\nkey 99 SHA256:evil", "SHA256:ok", 1);
        let text = String::from_utf8(sneaky).expect("ascii");
        assert!(
            text.contains("principal 22 p-a\nkey 99 SHA256:evil\n"),
            "the value was not length-prefixed: {text:?}"
        );
        // And the real key field is still the last line, unshadowed.
        assert!(text.ends_with("key 9 SHA256:ok\n"), "{text:?}");
    }

    /// An attestation must not verify as a credential or as a forge approval.
    /// `sshsig` binds the namespace into what is signed, which is what makes
    /// that structural; this asserts the three words are actually different.
    #[test]
    fn the_three_namespaces_are_three() {
        let mut all = [
            super::SSHSIG_NAMESPACE,
            crate::forge::FORGE_NAMESPACE,
            super::KEY_ATTESTATION_NAMESPACE,
        ];
        all.sort_unstable();
        let mut unique = all;
        unique.sort_unstable();
        unique.reverse();
        unique.reverse();
        assert_eq!(all.len(), 3);
        assert!(all.windows(2).all(|w| w[0] != w[1]), "two namespaces share a word: {all:?}");
        assert_eq!(super::KEY_ATTESTATION_NAMESPACE, "gunnar-key-attestation");
    }

    /// The signature is a real `sshsig` under the attestation namespace, made
    /// by the key this signer holds — verified the way gunnar verifies it,
    /// rather than by asserting the armour is non-empty.
    #[test]
    fn an_attestation_verifies_under_its_own_namespace_and_nowhere_else() {
        let key = ssh_key::PrivateKey::from(ssh_key::private::Ed25519Keypair::from_seed(&[7u8; 32]));
        let signer = Signer::new(key.clone()).expect("a signer");
        let (fp, armor) = signer
            .sign_key_attestation("p-anna", "SHA256:newkey", 1_788_000_000_000)
            .expect("it signs");
        assert_eq!(fp, signer.fingerprint());

        let sig = ssh_key::SshSig::from_pem(armor.as_bytes()).expect("well-formed sshsig");
        assert_eq!(sig.namespace(), super::KEY_ATTESTATION_NAMESPACE);
        let bytes = super::key_attestation_signing_bytes("p-anna", "SHA256:newkey", 1_788_000_000_000);
        key.public_key()
            .verify(super::KEY_ATTESTATION_NAMESPACE, &bytes, &sig)
            .expect("gunnar's own check passes");
        // Red: the same signature under the credential namespace must not.
        assert!(
            key.public_key()
                .verify(super::SSHSIG_NAMESPACE, &bytes, &sig)
                .is_err(),
            "an attestation verified as a credential"
        );
    }

}