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