gunnar-ssh 3.0.0

gunnar's SSH server: a russh listener with publickey identity, a strict git exec dispatch, and the repository path sandbox.
Documentation
//! Identity and grant levels as the SSH front end names them (the ACL model).
//!
//! # There is nothing defined here any more, and that is the point
//!
//! This module used to carry its own `KeyFingerprint`, `Principal`,
//! `PrincipalKind` and `Access`: a second definition of identity, sitting
//! beside `gunnar-core`'s, written at the same time. One implementation forbids exactly that,
//! and the `KeyFingerprint` copy was the dangerous one: it was the *value the
//! auth decision is keyed on*, so two spellings of it meant the SSH layer could
//! compute a fingerprint the identity table would never match.
//!
//! Everything here is now a re-export of the one definition in `gunnar-core`.
//! The module survives as a name (`gunnar_ssh::access::Access` reads better at
//! a call site than `gunnar_core::Level` does inside an SSH dispatch), but there
//! is no second type behind it.
//!
//! | this crate says | it is |
//! |---|---|
//! | [`Access`] | [`gunnar_core::Level`] |
//! | [`Principal`], [`PrincipalKind`] | [`gunnar_core::Principal`], [`gunnar_core::PrincipalKind`] |
//! | [`Fingerprint`] | [`gunnar_core::Fingerprint`] |
//!
//! # Fingerprinting a key that arrived through russh
//!
//! [`fingerprint_of_key`] is the only function this module still contains, and
//! it is four lines. It exists because russh hands over an
//! `ssh-key 0.7.0-rc.10` `PublicKey` and `gunnar-core` links `ssh-key 0.6.7`
//! The key itself cannot cross, but its wire blob can. The blob goes
//! to [`gunnar_core::fingerprint_of_blob`], which is where the definition lives
//! and where the known-answer tests against real `ssh-keygen -lf` output are.

use russh::keys::ssh_key;

/// A grant level, ordered. `Admin` implies `Write` implies `Read`.
///
/// The ordering is what lets [`command::SERVICES`](crate::command::SERVICES)
/// record the level each verb requires and the dispatch *compare* rather than
/// match, so adding a third verb (LFS over SSH) cannot forget a case. See
/// [`gunnar_core::Level::permits`].
pub type Access = gunnar_core::Level;

/// Who the session belongs to.
///
/// The SSH username is deliberately not in here: it is decorative and must
/// never reach an authorisation decision (the ACL model).
pub type Principal = gunnar_core::Principal;

/// What kind of thing holds a key: a human, a machine account, a deploy key.
pub type PrincipalKind = gunnar_core::PrincipalKind;

/// A public key's SHA-256 fingerprint in OpenSSH's printed form,
/// `SHA256:<unpadded-base64>`, the value the identity table is keyed on.
pub type Fingerprint = gunnar_core::Fingerprint;

/// Fingerprint a key russh handed over, as [`gunnar-core`](gunnar_core) would.
///
/// # Why this is not `Fingerprint::of_key`
///
/// It cannot be. `russh 0.61.2` pins `ssh-key = "=0.7.0-rc.10"` and
/// `Handler::auth_publickey` gives us *that* crate's `PublicKey`;
/// `gunnar-core` links the workspace's `ssh-key 0.6.7`. The two are
/// semver-incompatible and both are in this crate's graph, so no key value can
/// be passed from one to the other, and the workspace is deliberately not
/// bumped to a release candidate to hide it.
///
/// The SSH wire blob is bytes, and bytes cross. `to_bytes()` on ssh-key 0.7
/// produces the same encoding `to_bytes()` on ssh-key 0.6 does. That encoding
/// is the `string` from RFC 4253, the base64 payload of an `authorized_keys`
/// line, so the fingerprint this returns is the fingerprint the
/// [`AuthTable`](gunnar_core::AuthTable) stored when the key was registered.
/// `tests/auth.rs` asserts that on a key that made a real handshake, which is
/// the integration the whole merge exists to make possible.
/// # `None` is a refusal, not a panic
///
/// This runs in `auth_publickey`, on every SSH connection, over bytes the
/// remote supplied. It used to be
/// `key.to_bytes().expect("… always re-encodes")`. The reasoning behind that
/// `expect` is probably sound for a key russh has already parsed and verified
/// a signature against, but "probably sound" is not a thing to abort an auth
/// handler on, and the version split puts **two incompatible `ssh-key` copies** in this
/// graph, which is exactly the situation where a future point release changes
/// an edge-case encoding.
///
/// So a key that will not re-encode is **rejected and logged**, never accepted
/// and never panicked on. The caller maps `None` to `Auth::reject()`.
pub fn fingerprint_of_key(key: &ssh_key::PublicKey) -> Option<Fingerprint> {
    fingerprint_of_encoding(key.to_bytes())
}

/// The seam the no-panic guard needs.
///
/// `to_bytes()` cannot be made to fail on a key russh has verified, so the
/// error arm is unreachable from any test that goes through
/// [`fingerprint_of_key`]. Taking the `Result` instead of the key lets a test
/// drive that arm directly; without it the `None` branch is dead code and the
/// guard could never go red, which is the whole reason this entry stayed open.
pub(crate) fn fingerprint_of_encoding<E: std::fmt::Display>(
    encoded: Result<Vec<u8>, E>,
) -> Option<Fingerprint> {
    match encoded {
        Ok(blob) => Some(gunnar_core::fingerprint_of_blob(&blob)),
        Err(err) => {
            tracing::warn!(
                target: gunnar_core::observe::TARGET_SSH,
                event = "ssh.auth.refused",
                reason = "key-does-not-re-encode",
                %err,
                "a public key russh authenticated with did not re-encode; rejecting"
            );
            None
        }
    }
}

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

    #[test]
    fn access_is_ordered_and_the_order_is_the_right_way_round() {
        assert!(Access::Admin.permits(Access::Read));
        assert!(Access::Admin.permits(Access::Write));
        assert!(Access::Write.permits(Access::Read));
        assert!(Access::Read.permits(Access::Read));
        // The direction that must NOT hold. A `permits` that answered `true`
        // here would let every reader push, and every "read works" test would
        // still be green.
        assert!(!Access::Read.permits(Access::Write));
        assert!(!Access::Read.permits(Access::Admin));
        assert!(!Access::Write.permits(Access::Admin));
    }

    /// **The seam the version split is about**: a key carrying russh's `ssh-key 0.7-rc` types
    /// produces the fingerprint `gunnar-core` computes with its `ssh-key 0.6`.
    ///
    /// The known answer is `ssh-keygen -lf` output (OpenSSH_10.2p1, 2026-08-03),
    /// pasted as a literal, not recomputed here; recomputing would only prove
    /// gunnar agrees with itself.
    #[test]
    fn a_russh_key_fingerprints_to_what_ssh_keygen_prints() {
        const KAT: &str = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMW4aqVr75w+3DWorOCbfwl5XCDCq1yCQ6puzU4qzol2 gunnar-kat";
        const KAT_FINGERPRINT: &str = "SHA256:mRq7xzwk8TswSflB9kTXW/SLFDtAsgf5ksW8h6skax4";

        // Parsed by russh's ssh-key, which is the 0.7 release candidate.
        let key = ssh_key::PublicKey::from_openssh(KAT).expect("a real OpenSSH public key");
        let fingerprint = fingerprint_of_key(&key).expect("a real key re-encodes");
        assert_eq!(fingerprint.as_str(), KAT_FINGERPRINT);

        // The red half: a different key must not produce it. Without this, a
        // `fingerprint_of_key` that returned a constant would pass above.
        const OTHER: &str =
            "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICoNhstm/zoeaAvHBeLWH192X+HjvMcX7NLMGq98FL0B other";
        let other = ssh_key::PublicKey::from_openssh(OTHER).expect("key");
        let other = fingerprint_of_key(&other).expect("a real key re-encodes");
        assert_ne!(other.as_str(), KAT_FINGERPRINT);
        assert_eq!(
            other.as_str(),
            "SHA256:BT8pCbOuAqBeX1M+Ep3hZMawlY3h74H9/91esEqk3fw"
        );
    }

    /// A key that will not re-encode is **refused**, not panicked on.
    ///
    /// Driven through [`fingerprint_of_encoding`] rather than through
    /// [`fingerprint_of_key`], because `to_bytes()` cannot be made to fail on a
    /// key russh has verified. That seam is the only thing that makes this arm
    /// reachable; without it the `None` branch would be dead code and this
    /// guard could not go red.
    ///
    /// **Seen RED** against the pre-fix body (`.expect("… always re-encodes")`):
    /// the test aborts with `panicked at 'a public key russh authenticated with
    /// always re-encodes: the encoding failed'`, the production failure this
    /// entry is about, reproduced in a test.
    #[test]
    fn a_key_that_does_not_re_encode_is_refused_rather_than_panicked_on() {
        let refused = fingerprint_of_encoding::<String>(Err("the encoding failed".into()));
        assert!(
            refused.is_none(),
            "an unencodable key must be refused, not fingerprinted"
        );

        // The other half, so a `fingerprint_of_encoding` that answered `None`
        // to everything — which would refuse every login on the server — cannot
        // pass this test.
        const KAT: &str = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMW4aqVr75w+3DWorOCbfwl5XCDCq1yCQ6puzU4qzol2 gunnar-kat";
        let key = ssh_key::PublicKey::from_openssh(KAT).expect("a real OpenSSH public key");
        let accepted = fingerprint_of_encoding(key.to_bytes()).expect("a real key re-encodes");
        assert_eq!(
            accepted.as_str(),
            "SHA256:mRq7xzwk8TswSflB9kTXW/SLFDtAsgf5ksW8h6skax4"
        );
    }
}