Skip to main content

gunnar_ssh/
access.rs

1//! Identity and grant levels as the SSH front end names them (the ACL model).
2//!
3//! # There is nothing defined here any more, and that is the point
4//!
5//! This module used to carry its own `KeyFingerprint`, `Principal`,
6//! `PrincipalKind` and `Access`: a second definition of identity, sitting
7//! beside `gunnar-core`'s, written at the same time. One implementation forbids exactly that,
8//! and the `KeyFingerprint` copy was the dangerous one: it was the *value the
9//! auth decision is keyed on*, so two spellings of it meant the SSH layer could
10//! compute a fingerprint the identity table would never match.
11//!
12//! Everything here is now a re-export of the one definition in `gunnar-core`.
13//! The module survives as a name (`gunnar_ssh::access::Access` reads better at
14//! a call site than `gunnar_core::Level` does inside an SSH dispatch), but there
15//! is no second type behind it.
16//!
17//! | this crate says | it is |
18//! |---|---|
19//! | [`Access`] | [`gunnar_core::Level`] |
20//! | [`Principal`], [`PrincipalKind`] | [`gunnar_core::Principal`], [`gunnar_core::PrincipalKind`] |
21//! | [`Fingerprint`] | [`gunnar_core::Fingerprint`] |
22//!
23//! # Fingerprinting a key that arrived through russh
24//!
25//! [`fingerprint_of_key`] is the only function this module still contains, and
26//! it is four lines. It exists because russh hands over an
27//! `ssh-key 0.7.0-rc.10` `PublicKey` and `gunnar-core` links `ssh-key 0.6.7`
28//! The key itself cannot cross, but its wire blob can. The blob goes
29//! to [`gunnar_core::fingerprint_of_blob`], which is where the definition lives
30//! and where the known-answer tests against real `ssh-keygen -lf` output are.
31
32use russh::keys::ssh_key;
33
34/// A grant level, ordered. `Admin` implies `Write` implies `Read`.
35///
36/// The ordering is what lets [`command::SERVICES`](crate::command::SERVICES)
37/// record the level each verb requires and the dispatch *compare* rather than
38/// match, so adding a third verb (LFS over SSH) cannot forget a case. See
39/// [`gunnar_core::Level::permits`].
40pub type Access = gunnar_core::Level;
41
42/// Who the session belongs to.
43///
44/// The SSH username is deliberately not in here: it is decorative and must
45/// never reach an authorisation decision (the ACL model).
46pub type Principal = gunnar_core::Principal;
47
48/// What kind of thing holds a key: a human, a machine account, a deploy key.
49pub type PrincipalKind = gunnar_core::PrincipalKind;
50
51/// A public key's SHA-256 fingerprint in OpenSSH's printed form,
52/// `SHA256:<unpadded-base64>`, the value the identity table is keyed on.
53pub type Fingerprint = gunnar_core::Fingerprint;
54
55/// Fingerprint a key russh handed over, as [`gunnar-core`](gunnar_core) would.
56///
57/// # Why this is not `Fingerprint::of_key`
58///
59/// It cannot be. `russh 0.61.2` pins `ssh-key = "=0.7.0-rc.10"` and
60/// `Handler::auth_publickey` gives us *that* crate's `PublicKey`;
61/// `gunnar-core` links the workspace's `ssh-key 0.6.7`. The two are
62/// semver-incompatible and both are in this crate's graph, so no key value can
63/// be passed from one to the other, and the workspace is deliberately not
64/// bumped to a release candidate to hide it.
65///
66/// The SSH wire blob is bytes, and bytes cross. `to_bytes()` on ssh-key 0.7
67/// produces the same encoding `to_bytes()` on ssh-key 0.6 does. That encoding
68/// is the `string` from RFC 4253, the base64 payload of an `authorized_keys`
69/// line, so the fingerprint this returns is the fingerprint the
70/// [`AuthTable`](gunnar_core::AuthTable) stored when the key was registered.
71/// `tests/auth.rs` asserts that on a key that made a real handshake, which is
72/// the integration the whole merge exists to make possible.
73/// # `None` is a refusal, not a panic
74///
75/// This runs in `auth_publickey`, on every SSH connection, over bytes the
76/// remote supplied. It used to be
77/// `key.to_bytes().expect("… always re-encodes")`. The reasoning behind that
78/// `expect` is probably sound for a key russh has already parsed and verified
79/// a signature against, but "probably sound" is not a thing to abort an auth
80/// handler on, and the version split puts **two incompatible `ssh-key` copies** in this
81/// graph, which is exactly the situation where a future point release changes
82/// an edge-case encoding.
83///
84/// So a key that will not re-encode is **rejected and logged**, never accepted
85/// and never panicked on. The caller maps `None` to `Auth::reject()`.
86pub fn fingerprint_of_key(key: &ssh_key::PublicKey) -> Option<Fingerprint> {
87    fingerprint_of_encoding(key.to_bytes())
88}
89
90/// The seam the no-panic guard needs.
91///
92/// `to_bytes()` cannot be made to fail on a key russh has verified, so the
93/// error arm is unreachable from any test that goes through
94/// [`fingerprint_of_key`]. Taking the `Result` instead of the key lets a test
95/// drive that arm directly; without it the `None` branch is dead code and the
96/// guard could never go red, which is the whole reason this entry stayed open.
97pub(crate) fn fingerprint_of_encoding<E: std::fmt::Display>(
98    encoded: Result<Vec<u8>, E>,
99) -> Option<Fingerprint> {
100    match encoded {
101        Ok(blob) => Some(gunnar_core::fingerprint_of_blob(&blob)),
102        Err(err) => {
103            tracing::warn!(
104                target: gunnar_core::observe::TARGET_SSH,
105                event = "ssh.auth.refused",
106                reason = "key-does-not-re-encode",
107                %err,
108                "a public key russh authenticated with did not re-encode; rejecting"
109            );
110            None
111        }
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    #[test]
120    fn access_is_ordered_and_the_order_is_the_right_way_round() {
121        assert!(Access::Admin.permits(Access::Read));
122        assert!(Access::Admin.permits(Access::Write));
123        assert!(Access::Write.permits(Access::Read));
124        assert!(Access::Read.permits(Access::Read));
125        // The direction that must NOT hold. A `permits` that answered `true`
126        // here would let every reader push, and every "read works" test would
127        // still be green.
128        assert!(!Access::Read.permits(Access::Write));
129        assert!(!Access::Read.permits(Access::Admin));
130        assert!(!Access::Write.permits(Access::Admin));
131    }
132
133    /// **The seam the version split is about**: a key carrying russh's `ssh-key 0.7-rc` types
134    /// produces the fingerprint `gunnar-core` computes with its `ssh-key 0.6`.
135    ///
136    /// The known answer is `ssh-keygen -lf` output (OpenSSH_10.2p1, 2026-08-03),
137    /// pasted as a literal, not recomputed here; recomputing would only prove
138    /// gunnar agrees with itself.
139    #[test]
140    fn a_russh_key_fingerprints_to_what_ssh_keygen_prints() {
141        const KAT: &str = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMW4aqVr75w+3DWorOCbfwl5XCDCq1yCQ6puzU4qzol2 gunnar-kat";
142        const KAT_FINGERPRINT: &str = "SHA256:mRq7xzwk8TswSflB9kTXW/SLFDtAsgf5ksW8h6skax4";
143
144        // Parsed by russh's ssh-key, which is the 0.7 release candidate.
145        let key = ssh_key::PublicKey::from_openssh(KAT).expect("a real OpenSSH public key");
146        let fingerprint = fingerprint_of_key(&key).expect("a real key re-encodes");
147        assert_eq!(fingerprint.as_str(), KAT_FINGERPRINT);
148
149        // The red half: a different key must not produce it. Without this, a
150        // `fingerprint_of_key` that returned a constant would pass above.
151        const OTHER: &str =
152            "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICoNhstm/zoeaAvHBeLWH192X+HjvMcX7NLMGq98FL0B other";
153        let other = ssh_key::PublicKey::from_openssh(OTHER).expect("key");
154        let other = fingerprint_of_key(&other).expect("a real key re-encodes");
155        assert_ne!(other.as_str(), KAT_FINGERPRINT);
156        assert_eq!(
157            other.as_str(),
158            "SHA256:BT8pCbOuAqBeX1M+Ep3hZMawlY3h74H9/91esEqk3fw"
159        );
160    }
161
162    /// A key that will not re-encode is **refused**, not panicked on.
163    ///
164    /// Driven through [`fingerprint_of_encoding`] rather than through
165    /// [`fingerprint_of_key`], because `to_bytes()` cannot be made to fail on a
166    /// key russh has verified. That seam is the only thing that makes this arm
167    /// reachable; without it the `None` branch would be dead code and this
168    /// guard could not go red.
169    ///
170    /// **Seen RED** against the pre-fix body (`.expect("… always re-encodes")`):
171    /// the test aborts with `panicked at 'a public key russh authenticated with
172    /// always re-encodes: the encoding failed'`, the production failure this
173    /// entry is about, reproduced in a test.
174    #[test]
175    fn a_key_that_does_not_re_encode_is_refused_rather_than_panicked_on() {
176        let refused = fingerprint_of_encoding::<String>(Err("the encoding failed".into()));
177        assert!(
178            refused.is_none(),
179            "an unencodable key must be refused, not fingerprinted"
180        );
181
182        // The other half, so a `fingerprint_of_encoding` that answered `None`
183        // to everything — which would refuse every login on the server — cannot
184        // pass this test.
185        const KAT: &str = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMW4aqVr75w+3DWorOCbfwl5XCDCq1yCQ6puzU4qzol2 gunnar-kat";
186        let key = ssh_key::PublicKey::from_openssh(KAT).expect("a real OpenSSH public key");
187        let accepted = fingerprint_of_encoding(key.to_bytes()).expect("a real key re-encodes");
188        assert_eq!(
189            accepted.as_str(),
190            "SHA256:mRq7xzwk8TswSflB9kTXW/SLFDtAsgf5ksW8h6skax4"
191        );
192    }
193}