crates/gunnar-core/tests/fingerprint.rs
//! Key fingerprints, against known answers from the real OpenSSH binary.
//!
//! Measure, never assert: a fingerprint test that hashes a key with gunnar and
//! then compares it
//! against gunnar hashing the same key proves only that gunnar is
//! self-consistent. Every expected value here came out of
//! `ssh-keygen -lf` (OpenSSH_10.2p1, 2026-08-03) and is pasted in as a literal,
//! so if gunnar's fingerprint ever stops being OpenSSH's fingerprint (a
//! different hash, padded base64, the comment folded in) this goes red.
mod common;
use common::{
Fixture, KAT_ED25519, KAT_ED25519_FINGERPRINT, KAT_RSA, KAT_RSA_FINGERPRINT, OTHER_ED25519,
OTHER_ED25519_FINGERPRINT,
};
use gunnar_core::model::{now_ms, PrincipalKind};
use gunnar_core::{fingerprint_of_blob, Fingerprint, Principal};
/// The SSH wire-format blob of an `authorized_keys` line: the bytes both
/// `ssh-key` versions in gunnar's graph can produce, and the only thing
/// [`fingerprint_of_blob`] is defined over.
fn blob_of(openssh: &str) -> Vec<u8> {
gunnar_core::ssh_key::PublicKey::from_openssh(openssh)
.expect("a real OpenSSH public key")
.to_bytes()
.expect("re-encode")
}
/// **The known-answer test.** `fingerprint_of_blob` is handed nothing but
/// bytes and must produce exactly what `ssh-keygen -lf` printed.
///
/// This is what makes the version split survivable: `gunnar-ssh` links
/// `ssh-key 0.7.0-rc.10` and cannot call anything in this crate that takes an
/// `ssh_key::PublicKey`, but it can hand over `to_bytes()`, and gets this
/// string back.
#[test]
fn fingerprint_of_blob_matches_ssh_keygen() {
for (key, expected) in [
(KAT_ED25519, KAT_ED25519_FINGERPRINT),
(KAT_RSA, KAT_RSA_FINGERPRINT),
(OTHER_ED25519, OTHER_ED25519_FINGERPRINT),
] {
assert_eq!(
fingerprint_of_blob(&blob_of(key)).as_str(),
expected,
"the blob fingerprint is not OpenSSH's fingerprint for {}",
&key[..20]
);
}
}
/// The red half. Two independent ways for `fingerprint_of_blob` to be a
/// constant-shaped lie, both of which would leave the test above green if the
/// literals ever drifted:
///
/// * it could ignore its input, so one flipped bit must change the answer;
/// * it could be a different function from the one the [`Fingerprint`] type and
/// the [`AuthTable`](gunnar_core::AuthTable) use, so the two must agree, on
/// every known-answer key.
#[test]
fn the_blob_fingerprint_is_a_function_of_the_blob_and_is_the_only_one() {
for key in [KAT_ED25519, KAT_RSA, OTHER_ED25519] {
let blob = blob_of(key);
assert_eq!(
fingerprint_of_blob(&blob),
Fingerprint::of_openssh(key).expect("key"),
"the byte path and the type path disagree — there are two fingerprints again"
);
for at in [0, blob.len() / 2, blob.len() - 1] {
let mut tampered = blob.clone();
tampered[at] ^= 0x01;
assert_ne!(
fingerprint_of_blob(&tampered),
fingerprint_of_blob(&blob),
"flipping a bit at offset {at} did not change the fingerprint"
);
}
// Truncation must not collide either: a hash over a prefix is a
// different hash, and a fingerprint that ignored trailing bytes would
// let a key be forged by padding.
assert_ne!(
fingerprint_of_blob(&blob[..blob.len() - 1]),
fingerprint_of_blob(&blob)
);
}
// Distinct keys, distinct blobs, distinct fingerprints.
assert_ne!(
fingerprint_of_blob(&blob_of(KAT_ED25519)),
fingerprint_of_blob(&blob_of(OTHER_ED25519))
);
// And it hashes arbitrary bytes rather than parsing: this is a hash, not a
// second key parser with its own opinion about what a key is.
assert!(fingerprint_of_blob(b"").as_str().starts_with("SHA256:"));
assert_ne!(fingerprint_of_blob(b""), fingerprint_of_blob(b"x"));
}
#[test]
fn fingerprints_match_ssh_keygen() {
for (key, expected) in [
(KAT_ED25519, KAT_ED25519_FINGERPRINT),
(KAT_RSA, KAT_RSA_FINGERPRINT),
(OTHER_ED25519, OTHER_ED25519_FINGERPRINT),
] {
let computed = Fingerprint::of_openssh(key).expect("a real OpenSSH public key");
assert_eq!(
computed.as_str(),
expected,
"gunnar's fingerprint is not OpenSSH's fingerprint for {}",
&key[..20]
);
}
}
/// The red half: the known answers must actually discriminate. If the same
/// fingerprint came back for two different keys, the test above would pass with
/// a constant.
#[test]
fn different_keys_have_different_fingerprints() {
let a = Fingerprint::of_openssh(KAT_ED25519).expect("key");
let b = Fingerprint::of_openssh(OTHER_ED25519).expect("key");
let c = Fingerprint::of_openssh(KAT_RSA).expect("key");
assert_ne!(a, b);
assert_ne!(a, c);
assert_ne!(b, c);
}
/// The comment is not part of the key, and OpenSSH's fingerprint does not
/// include it. A fingerprint that changed when a user renamed their key would
/// lock them out.
#[test]
fn the_comment_does_not_change_the_fingerprint() {
let renamed = KAT_ED25519.replace("gunnar-kat", "a completely different comment");
assert_eq!(
Fingerprint::of_openssh(&renamed).expect("key").as_str(),
KAT_ED25519_FINGERPRINT
);
// …and neither does surrounding whitespace, which an authorized_keys line
// pasted through a config file will have.
let padded = format!(" {KAT_ED25519}\n");
assert_eq!(
Fingerprint::of_openssh(&padded).expect("key").as_str(),
KAT_ED25519_FINGERPRINT
);
}
#[test]
fn a_fingerprint_parsed_from_text_is_validated() {
assert_eq!(
Fingerprint::parse(KAT_ED25519_FINGERPRINT)
.expect("well-formed")
.as_str(),
KAT_ED25519_FINGERPRINT
);
// The forms an operator or a bad client actually produces.
for bad in [
"",
"mRq7xzwk8TswSflB9kTXW/SLFDtAsgf5ksW8h6skax4", // no prefix
"MD5:16:27:ac:a5:76:28:2d:36:63:1b:56:4d:eb:df:a6:48", // the old form
"SHA256:",
"SHA256:tooshort",
"SHA256:mRq7xzwk8TswSflB9kTXW/SLFDtAsgf5ksW8h6skax4=", // padded
"SHA256:mRq7xzwk8TswSflB9kTXW SLFDtAsgf5ksW8h6skax4", // space
] {
assert!(
Fingerprint::parse(bad).is_err(),
"{bad:?} should not parse as a fingerprint"
);
}
}
#[test]
fn not_a_key_is_refused() {
for bad in [
"",
" ",
"ssh-ed25519",
"ssh-ed25519 not-base64!!",
"-----BEGIN OPENSSH PRIVATE KEY-----",
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMW4aqVr75w",
] {
assert!(
Fingerprint::of_openssh(bad).is_err(),
"{bad:?} should not parse as a public key"
);
}
}
/// The whole SSH auth path in one test: register a key, look the principal up
/// by the fingerprint OpenSSH would print, revoke it, and be refused.
#[test]
fn a_registered_key_identifies_its_principal_until_it_is_revoked() {
let fx = Fixture::new();
fx.auth
.add_principal(Principal::new("ada", "Ada", PrincipalKind::User).expect("legal id"))
.expect("add principal");
let record = fx
.auth
.register_key("ada", KAT_ED25519, None)
.expect("register");
assert_eq!(record.fingerprint_sha256, KAT_ED25519_FINGERPRINT);
assert_eq!(record.algo, "ssh-ed25519");
assert_eq!(record.principal_id, "ada");
assert!(record.last_used.is_none());
let offered = Fingerprint::parse(KAT_ED25519_FINGERPRINT).expect("well-formed");
let found = fx
.auth
.principal_by_fingerprint(&offered)
.expect("lookup")
.expect("the key is registered");
assert_eq!(found.id, "ada");
// A key nobody registered identifies nobody.
let stranger = Fingerprint::of_openssh(OTHER_ED25519).expect("key");
assert!(fx
.auth
.principal_by_fingerprint(&stranger)
.expect("lookup")
.is_none());
// last_used is recorded separately from the lookup.
let now = now_ms();
fx.auth.touch_key(&offered, now).expect("touch");
assert_eq!(fx.auth.keys().expect("keys")[0].last_used, Some(now));
assert!(fx.auth.revoke_key(&offered).expect("revoke").is_some());
assert!(fx
.auth
.principal_by_fingerprint(&offered)
.expect("lookup")
.is_none());
assert!(fx
.auth
.revoke_key(&offered)
.expect("revoke again")
.is_none());
}
#[test]
fn a_key_cannot_be_registered_twice_or_to_a_stranger() {
let fx = Fixture::new();
fx.auth
.add_principal(Principal::new("ada", "Ada", PrincipalKind::User).expect("id"))
.expect("add");
fx.auth
.add_principal(Principal::new("bob", "Bob", PrincipalKind::User).expect("id"))
.expect("add");
fx.auth
.register_key("ada", KAT_ED25519, None)
.expect("register");
// Not to a second principal: an SSH handshake would then have two answers.
let err = fx
.auth
.register_key("bob", KAT_ED25519, None)
.expect_err("must refuse");
assert!(format!("{err}").contains("already registered"), "{err}");
// Not to an unknown principal at all.
assert!(fx.auth.register_key("nobody", OTHER_ED25519, None).is_err());
// And the key still belongs to whoever had it.
let fp = Fingerprint::of_openssh(KAT_ED25519).expect("key");
assert_eq!(
fx.auth
.principal_by_fingerprint(&fp)
.expect("lookup")
.expect("still there")
.id,
"ada"
);
}
#[test]
fn an_expired_key_identifies_nobody() {
let fx = Fixture::new();
fx.auth
.add_principal(Principal::new("ci", "CI runner", PrincipalKind::Machine).expect("id"))
.expect("add");
let expires = 1_000_000;
fx.auth
.register_key("ci", KAT_ED25519, Some(expires))
.expect("register");
let fp = Fingerprint::of_openssh(KAT_ED25519).expect("key");
// Before: identifies the runner. This is the red half; without it, an
// always-`None` lookup would pass the assertion below.
assert!(fx
.auth
.principal_by_fingerprint_at(&fp, expires - 1)
.expect("lookup")
.is_some());
// On the boundary and after: nobody.
assert!(fx
.auth
.principal_by_fingerprint_at(&fp, expires)
.expect("lookup")
.is_none());
assert!(fx
.auth
.principal_by_fingerprint_at(&fp, expires + 1)
.expect("lookup")
.is_none());
}
/// An expired key must not be able to **sign** either, and the listing must
/// still show it.
///
/// The defect, found 2026-08-05: `gunnar-cli`'s `key_directory` built the ssh-sig
/// gate's whole key set from `AuthTable::keys()`, which filters nothing. So a key
/// past its `expires` could no longer open a session
/// (`an_expired_key_identifies_nobody`, above) and could still satisfy
/// `require_signature` on a push. One credential, two lifetimes, and only the
/// short one was written down anywhere.
///
/// Both halves are asserted here on purpose. Fixing it by filtering `keys()`
/// itself would have hidden an expired key from `gunnar key list`, and an
/// operator who cannot see the expired row cannot work out why authentication
/// stopped — so the split is the fix, and a test that only checked the filter
/// would have accepted the wrong one.
///
/// # Seen red
///
/// With `signing_keys_at` returning `self.keys()` unfiltered, the `expires` and
/// `expires + 1` assertions fail. With it filtering `keys()` instead, the
/// listing assertion fails. The `expires - 1` case is the control: a
/// `signing_keys_at` that always returned empty would satisfy every other line.
#[test]
fn an_expired_key_signs_nothing_and_is_still_listed() {
let fx = Fixture::new();
fx.auth
.add_principal(Principal::new("ci", "CI runner", PrincipalKind::Machine).expect("id"))
.expect("add");
let expires = 1_000_000;
fx.auth
.register_key("ci", KAT_ED25519, Some(expires))
.expect("register");
// The control: while it is valid it IS offered for verification. Without
// this line an always-empty `signing_keys_at` would be green below.
assert_eq!(
fx.auth.signing_keys_at(expires - 1).expect("keys").len(),
1,
"a key that has not expired must be able to verify a signature"
);
// On the boundary and after: it verifies nothing. Same boundary as
// `PublicKey::is_valid_at`, which is the point — one predicate, both paths.
assert!(
fx.auth.signing_keys_at(expires).expect("keys").is_empty(),
"an expired key must not be offered to the signature gate"
);
assert!(
fx.auth
.signing_keys_at(expires + 1)
.expect("keys")
.is_empty(),
"an expired key must not be offered to the signature gate"
);
// And it is still in the listing, because an operator has to be able to see
// the row that stopped working.
assert_eq!(
fx.auth.keys().expect("keys").len(),
1,
"`keys()` is the listing path and must still show an expired key"
);
}
/// Removing a principal must take its keys with it. A key that outlives its
/// principal is an account that still authenticates.
#[test]
fn removing_a_principal_removes_its_keys() {
let fx = Fixture::new();
fx.auth
.add_principal(Principal::new("temp", "Temp", PrincipalKind::Deploy).expect("id"))
.expect("add");
fx.auth
.register_key("temp", KAT_ED25519, None)
.expect("register");
let fp = Fingerprint::of_openssh(KAT_ED25519).expect("key");
assert!(fx
.auth
.principal_by_fingerprint(&fp)
.expect("lookup")
.is_some());
fx.auth.remove_principal("temp").expect("remove");
assert!(fx.auth.keys().expect("keys").is_empty());
assert!(fx
.auth
.principal_by_fingerprint(&fp)
.expect("lookup")
.is_none());
}