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

nordisk/gunnar

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