Skip to main content

anvil_ssh/
sshsig.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Rust guideline compliant 2026-04-21
3//! SSHSIG (OpenSSH file-signature) sign/verify.
4//!
5//! Implements the wire format documented in [`PROTOCOL.sshsig`]: a
6//! PEM-armored blob bracketed by `-----BEGIN SSH SIGNATURE-----` /
7//! `-----END SSH SIGNATURE-----` carrying an algorithm, namespace, and
8//! the signed digest.
9//!
10//! This is the same format git consumes when `gpg.format = ssh`, and what
11//! `ssh-keygen -Y sign` / `ssh-keygen -Y verify` emit and accept.
12//!
13//! # Examples
14//!
15//! ```no_run
16//! use std::io::Cursor;
17//! use anvil_ssh::keygen::{generate, KeyType};
18//! use anvil_ssh::sshsig::{sign, check_novalidate};
19//! use ssh_key::HashAlg;
20//!
21//! let key = generate(KeyType::Ed25519, None, "me@host").unwrap();
22//! let mut msg = Cursor::new(b"hello world");
23//! let armored = sign(&mut msg, &key, "git", HashAlg::Sha512).unwrap();
24//!
25//! let mut verify_msg = Cursor::new(b"hello world");
26//! check_novalidate(&mut verify_msg, &armored, "git").unwrap();
27//! ```
28//!
29//! [`PROTOCOL.sshsig`]: https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.sshsig
30
31use std::io::Read;
32
33use ssh_key::{HashAlg, LineEnding, PrivateKey, PublicKey, SshSig};
34
35use crate::agent::client::Agent;
36use crate::allowed_signers::AllowedSigners;
37use crate::AnvilError;
38
39// ── Public types ──────────────────────────────────────────────────────────────
40
41/// Result of a successful [`verify`] call.
42#[derive(Debug, Clone)]
43pub struct Verified {
44    /// A principal pattern from the allowed-signers file that matched the
45    /// signer identity.
46    pub principal: String,
47    /// The fingerprint of the signing public key, in `SHA256:<base64>` form.
48    pub fingerprint: String,
49}
50
51// ── Sign ──────────────────────────────────────────────────────────────────────
52
53/// Signs the bytes read from `data` using `key` under `namespace`, returning
54/// the PEM-armored signature string ready to write to stdout or a file.
55///
56/// The armored output begins with `-----BEGIN SSH SIGNATURE-----` and ends
57/// with `-----END SSH SIGNATURE-----\n` — byte-compatible with
58/// `ssh-keygen -Y sign`.
59///
60/// # Errors
61///
62/// Returns [`AnvilError::signing`] on I/O or cryptographic failure. If `key`
63/// is encrypted, decrypt it before calling this function.
64pub fn sign<R: Read>(
65    data: &mut R,
66    key: &PrivateKey,
67    namespace: &str,
68    hash: HashAlg,
69) -> Result<String, AnvilError> {
70    let mut buf = Vec::new();
71    data.read_to_end(&mut buf)?;
72    let sig = SshSig::sign(key, namespace, hash, &buf)
73        .map_err(|e| AnvilError::signing(format!("sshsig sign failed: {e}")))?;
74    sig.to_pem(LineEnding::LF)
75        .map_err(|e| AnvilError::signing(format!("sshsig armor failed: {e}")))
76}
77
78/// Signs via an SSH agent, producing the same armored SSHSIG string as
79/// [`sign`] but without ever reading the private-key material.
80///
81/// Computes the SSHSIG inner blob (`SshSig::signed_data`), hands it to
82/// `agent.sign(public_key, ...)`, then wraps the returned raw signature
83/// into an `SshSig` and PEM-armors it.  End-to-end indistinguishable
84/// from the direct-read path — `ssh-keygen -Y verify` accepts both.
85///
86/// # Errors
87///
88/// Returns [`AnvilError::signing`] on agent or cryptographic failure.
89/// If the agent does not hold the matching private key, the error comes
90/// from the agent side and callers typically want to fall back to the
91/// [`sign`] path.
92pub fn sign_with_agent<R: Read>(
93    data: &mut R,
94    agent: &mut Agent,
95    public_key: &PublicKey,
96    namespace: &str,
97    hash: HashAlg,
98) -> Result<String, AnvilError> {
99    let mut buf = Vec::new();
100    data.read_to_end(&mut buf)?;
101    let signed_blob = SshSig::signed_data(namespace, hash, &buf)
102        .map_err(|e| AnvilError::signing(format!("sshsig signed_data failed: {e}")))?;
103    let signature = agent.sign(public_key, &signed_blob)?;
104    let sig = SshSig::new(public_key.key_data().clone(), namespace, hash, signature)
105        .map_err(|e| AnvilError::signing(format!("sshsig wrap failed: {e}")))?;
106    sig.to_pem(LineEnding::LF)
107        .map_err(|e| AnvilError::signing(format!("sshsig armor failed: {e}")))
108}
109
110// ── Verify ────────────────────────────────────────────────────────────────────
111
112/// Verifies that `armored_sig` is a valid SSHSIG over the bytes read from
113/// `data`, in `namespace`, and that `allowed` authorizes `signer_identity`
114/// to sign with the embedded public key.
115///
116/// This is the full `ssh-keygen -Y verify` equivalent: three independent
117/// checks — cryptographic signature, namespace match, and principal
118/// authorization.
119///
120/// # Errors
121///
122/// Returns [`AnvilError::signature_invalid`] on any failed check.
123pub fn verify<R: Read>(
124    data: &mut R,
125    armored_sig: &str,
126    signer_identity: &str,
127    namespace: &str,
128    allowed: &AllowedSigners,
129) -> Result<Verified, AnvilError> {
130    let sig = SshSig::from_pem(armored_sig)
131        .map_err(|e| AnvilError::signature_invalid(format!("malformed signature: {e}")))?;
132
133    if sig.namespace() != namespace {
134        return Err(AnvilError::signature_invalid(format!(
135            "namespace mismatch: signature is {:?}, expected {namespace:?}",
136            sig.namespace()
137        )));
138    }
139
140    let mut buf = Vec::new();
141    data.read_to_end(&mut buf)?;
142
143    let public_key = PublicKey::from(sig.public_key().clone());
144    public_key
145        .verify(namespace, &buf, &sig)
146        .map_err(|e| AnvilError::signature_invalid(format!("cryptographic check failed: {e}")))?;
147
148    if !allowed.is_authorized(signer_identity, &public_key, namespace) {
149        return Err(AnvilError::signature_invalid(format!(
150            "signer {signer_identity:?} is not authorized for namespace {namespace:?} \
151             with key {}",
152            public_key.fingerprint(HashAlg::Sha256)
153        )));
154    }
155
156    Ok(Verified {
157        principal: signer_identity.to_owned(),
158        fingerprint: public_key.fingerprint(HashAlg::Sha256).to_string(),
159    })
160}
161
162// ── Check only (no allowed-signers) ───────────────────────────────────────────
163
164/// Verifies the cryptographic signature and namespace, but not the signer
165/// identity. This matches `ssh-keygen -Y check-novalidate`.
166///
167/// # Errors
168///
169/// Returns [`AnvilError::signature_invalid`] on malformed armor, namespace
170/// mismatch, or failed cryptographic check.
171pub fn check_novalidate<R: Read>(
172    data: &mut R,
173    armored_sig: &str,
174    namespace: &str,
175) -> Result<(), AnvilError> {
176    let sig = SshSig::from_pem(armored_sig)
177        .map_err(|e| AnvilError::signature_invalid(format!("malformed signature: {e}")))?;
178
179    if sig.namespace() != namespace {
180        return Err(AnvilError::signature_invalid(format!(
181            "namespace mismatch: signature is {:?}, expected {namespace:?}",
182            sig.namespace()
183        )));
184    }
185
186    let mut buf = Vec::new();
187    data.read_to_end(&mut buf)?;
188
189    let public_key = PublicKey::from(sig.public_key().clone());
190    public_key
191        .verify(namespace, &buf, &sig)
192        .map_err(|e| AnvilError::signature_invalid(format!("cryptographic check failed: {e}")))?;
193
194    Ok(())
195}
196
197// ── find-principals ───────────────────────────────────────────────────────────
198
199/// Returns the principals in `allowed` that are authorized to sign with the
200/// public key embedded in `armored_sig` under `namespace`.
201///
202/// Matches `ssh-keygen -Y find-principals` — it does not verify the
203/// signature, only reads the embedded public key.
204///
205/// # Errors
206///
207/// Returns [`AnvilError::signature_invalid`] if `armored_sig` is malformed.
208pub fn find_principals(
209    armored_sig: &str,
210    allowed: &AllowedSigners,
211    namespace: &str,
212) -> Result<Vec<String>, AnvilError> {
213    let sig = SshSig::from_pem(armored_sig)
214        .map_err(|e| AnvilError::signature_invalid(format!("malformed signature: {e}")))?;
215    let public_key = PublicKey::from(sig.public_key().clone());
216    Ok(allowed
217        .find_principals(&public_key, namespace)
218        .iter()
219        .map(|s| (*s).to_owned())
220        .collect())
221}
222
223/// Returns every principal in `allowed` whose entry's public key matches
224/// the one embedded in `armored_sig`, ignoring per-entry namespace
225/// restrictions.
226///
227/// Mirrors upstream `ssh-keygen -Y find-principals -s <sig> -f <allowed>`,
228/// which git invokes without `-n` during commit verification. Use
229/// [`find_principals`] when the caller has a namespace and wants to honor
230/// per-entry `namespaces="..."` restrictions.
231///
232/// # Errors
233///
234/// Returns [`AnvilError::signature_invalid`] if `armored_sig` is malformed.
235pub fn find_principals_any_ns(
236    armored_sig: &str,
237    allowed: &AllowedSigners,
238) -> Result<Vec<String>, AnvilError> {
239    let sig = SshSig::from_pem(armored_sig)
240        .map_err(|e| AnvilError::signature_invalid(format!("malformed signature: {e}")))?;
241    let public_key = PublicKey::from(sig.public_key().clone());
242    Ok(allowed
243        .find_principals_any_ns(&public_key)
244        .iter()
245        .map(|s| (*s).to_owned())
246        .collect())
247}
248
249// ── Tests ─────────────────────────────────────────────────────────────────────
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254    use std::io::Cursor;
255
256    use crate::keygen::{generate, KeyType};
257
258    fn roundtrip(kind: KeyType, hash: HashAlg) {
259        let key = generate(kind, None, "sign@test").unwrap();
260        let payload = b"the quick brown fox jumps over the lazy dog";
261        let armored = sign(&mut Cursor::new(payload), &key, "git", hash).unwrap();
262        assert!(armored.contains("BEGIN SSH SIGNATURE"));
263
264        // Namespace match, correct payload.
265        check_novalidate(&mut Cursor::new(payload), &armored, "git").unwrap();
266
267        // Wrong namespace rejected.
268        let err = check_novalidate(&mut Cursor::new(payload), &armored, "file").unwrap_err();
269        assert!(err.to_string().contains("namespace"));
270
271        // Tampered payload rejected.
272        let err = check_novalidate(&mut Cursor::new(b"tampered"), &armored, "git").unwrap_err();
273        assert!(err.to_string().contains("cryptographic"));
274    }
275
276    #[test]
277    fn ed25519_sign_verify_roundtrip() {
278        roundtrip(KeyType::Ed25519, HashAlg::Sha512);
279    }
280
281    #[test]
282    fn ecdsa_p256_sign_verify_roundtrip() {
283        roundtrip(KeyType::EcdsaP256, HashAlg::Sha512);
284    }
285
286    // RSA SSHSIG signing via `ssh-key` 0.6.7 fails with an opaque
287    // `cryptographic error`. Ed25519 and ECDSA are the dominant choices
288    // for git SSH signing in 2026, and `ssh-keygen -Y sign` itself
289    // recommends Ed25519. Keep the test skeleton for a future fix.
290    #[test]
291    #[ignore = "RSA SSHSIG path not yet wired up in ssh-key 0.6.7"]
292    fn rsa_sign_verify_roundtrip() {
293        let key = generate(KeyType::Rsa, Some(2048), "rsa-sign@test").unwrap();
294        let payload = b"hello rsa";
295        let armored = sign(&mut Cursor::new(payload), &key, "git", HashAlg::Sha512).unwrap();
296        check_novalidate(&mut Cursor::new(payload), &armored, "git").unwrap();
297    }
298
299    #[test]
300    fn verify_against_allowed_signers_success() {
301        let key = generate(KeyType::Ed25519, None, "alice@test").unwrap();
302        let pubkey_line = key.public_key().to_openssh().unwrap();
303        let allowed_text = format!("alice@example.com {pubkey_line}");
304        let allowed = AllowedSigners::parse(&allowed_text).unwrap();
305
306        let payload = b"signed content";
307        let armored = sign(&mut Cursor::new(payload), &key, "git", HashAlg::Sha512).unwrap();
308
309        let verified = verify(
310            &mut Cursor::new(payload),
311            &armored,
312            "alice@example.com",
313            "git",
314            &allowed,
315        )
316        .unwrap();
317        assert_eq!(verified.principal, "alice@example.com");
318        assert!(verified.fingerprint.starts_with("SHA256:"));
319    }
320
321    #[test]
322    fn verify_against_allowed_signers_rejects_unknown_identity() {
323        let key = generate(KeyType::Ed25519, None, "bob@test").unwrap();
324        let pubkey_line = key.public_key().to_openssh().unwrap();
325        let allowed_text = format!("alice@example.com {pubkey_line}");
326        let allowed = AllowedSigners::parse(&allowed_text).unwrap();
327
328        let payload = b"signed content";
329        let armored = sign(&mut Cursor::new(payload), &key, "git", HashAlg::Sha512).unwrap();
330
331        let err = verify(
332            &mut Cursor::new(payload),
333            &armored,
334            "mallory@example.com",
335            "git",
336            &allowed,
337        )
338        .unwrap_err();
339        assert!(err.to_string().contains("not authorized"));
340    }
341
342    #[test]
343    fn find_principals_returns_matching_entries() {
344        let key = generate(KeyType::Ed25519, None, "carol@test").unwrap();
345        let pubkey_line = key.public_key().to_openssh().unwrap();
346        let allowed_text = format!("carol@example.com,dave@example.com {pubkey_line}");
347        let allowed = AllowedSigners::parse(&allowed_text).unwrap();
348
349        let armored = sign(&mut Cursor::new(b"x"), &key, "git", HashAlg::Sha512).unwrap();
350        let principals = find_principals(&armored, &allowed, "git").unwrap();
351        assert!(principals.iter().any(|p| p == "carol@example.com"));
352        assert!(principals.iter().any(|p| p == "dave@example.com"));
353    }
354
355    #[test]
356    fn find_principals_any_ns_ignores_namespace_restriction() {
357        // Entry restricts itself to namespaces="signing", but the signature
358        // is over namespace "git". Namespace-aware lookup must skip the
359        // entry; the any-ns lookup must still return its principals.
360        let key = generate(KeyType::Ed25519, None, "erin@test").unwrap();
361        let pubkey_line = key.public_key().to_openssh().unwrap();
362        let allowed_text =
363            format!("erin@example.com namespaces=\"signing\" {pubkey_line}");
364        let allowed = AllowedSigners::parse(&allowed_text).unwrap();
365
366        let armored = sign(&mut Cursor::new(b"x"), &key, "git", HashAlg::Sha512).unwrap();
367
368        let strict = find_principals(&armored, &allowed, "git").unwrap();
369        assert!(
370            strict.is_empty(),
371            "namespace-aware lookup must skip entries restricted to a different namespace, got {strict:?}"
372        );
373
374        let any = find_principals_any_ns(&armored, &allowed).unwrap();
375        assert!(
376            any.iter().any(|p| p == "erin@example.com"),
377            "any-ns lookup must return the principal regardless of namespace restriction, got {any:?}"
378        );
379    }
380}