crates/gunnar-ssh/src/access.rs
//! 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"
);
}
}