gitway-lib 0.8.1

Core SSH transport library for Git hosting services (GitHub, GitLab, Codeberg, and self-hosted).
Documentation
// SPDX-License-Identifier: GPL-3.0-or-later
// Rust guideline compliant 2026-04-21
//! SSHSIG (OpenSSH file-signature) sign/verify.
//!
//! Implements the wire format documented in [`PROTOCOL.sshsig`]: a
//! PEM-armored blob bracketed by `-----BEGIN SSH SIGNATURE-----` /
//! `-----END SSH SIGNATURE-----` carrying an algorithm, namespace, and
//! the signed digest.
//!
//! This is the same format git consumes when `gpg.format = ssh`, and what
//! `ssh-keygen -Y sign` / `ssh-keygen -Y verify` emit and accept.
//!
//! # Examples
//!
//! ```no_run
//! use std::io::Cursor;
//! use gitway_lib::keygen::{generate, KeyType};
//! use gitway_lib::sshsig::{sign, check_novalidate};
//! use ssh_key::HashAlg;
//!
//! let key = generate(KeyType::Ed25519, None, "me@host").unwrap();
//! let mut msg = Cursor::new(b"hello world");
//! let armored = sign(&mut msg, &key, "git", HashAlg::Sha512).unwrap();
//!
//! let mut verify_msg = Cursor::new(b"hello world");
//! check_novalidate(&mut verify_msg, &armored, "git").unwrap();
//! ```
//!
//! [`PROTOCOL.sshsig`]: https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.sshsig

use std::io::Read;

use ssh_key::{HashAlg, LineEnding, PrivateKey, PublicKey, SshSig};

use crate::agent::client::Agent;
use crate::allowed_signers::AllowedSigners;
use crate::GitwayError;

// ── Public types ──────────────────────────────────────────────────────────────

/// Result of a successful [`verify`] call.
#[derive(Debug, Clone)]
pub struct Verified {
    /// A principal pattern from the allowed-signers file that matched the
    /// signer identity.
    pub principal: String,
    /// The fingerprint of the signing public key, in `SHA256:<base64>` form.
    pub fingerprint: String,
}

// ── Sign ──────────────────────────────────────────────────────────────────────

/// Signs the bytes read from `data` using `key` under `namespace`, returning
/// the PEM-armored signature string ready to write to stdout or a file.
///
/// The armored output begins with `-----BEGIN SSH SIGNATURE-----` and ends
/// with `-----END SSH SIGNATURE-----\n` — byte-compatible with
/// `ssh-keygen -Y sign`.
///
/// # Errors
///
/// Returns [`GitwayError::signing`] on I/O or cryptographic failure. If `key`
/// is encrypted, decrypt it before calling this function.
pub fn sign<R: Read>(
    data: &mut R,
    key: &PrivateKey,
    namespace: &str,
    hash: HashAlg,
) -> Result<String, GitwayError> {
    let mut buf = Vec::new();
    data.read_to_end(&mut buf)?;
    let sig = SshSig::sign(key, namespace, hash, &buf)
        .map_err(|e| GitwayError::signing(format!("sshsig sign failed: {e}")))?;
    sig.to_pem(LineEnding::LF)
        .map_err(|e| GitwayError::signing(format!("sshsig armor failed: {e}")))
}

/// Signs via an SSH agent, producing the same armored SSHSIG string as
/// [`sign`] but without ever reading the private-key material.
///
/// Computes the SSHSIG inner blob (`SshSig::signed_data`), hands it to
/// `agent.sign(public_key, ...)`, then wraps the returned raw signature
/// into an `SshSig` and PEM-armors it.  End-to-end indistinguishable
/// from the direct-read path — `ssh-keygen -Y verify` accepts both.
///
/// # Errors
///
/// Returns [`GitwayError::signing`] on agent or cryptographic failure.
/// If the agent does not hold the matching private key, the error comes
/// from the agent side and callers typically want to fall back to the
/// [`sign`] path.
pub fn sign_with_agent<R: Read>(
    data: &mut R,
    agent: &mut Agent,
    public_key: &PublicKey,
    namespace: &str,
    hash: HashAlg,
) -> Result<String, GitwayError> {
    let mut buf = Vec::new();
    data.read_to_end(&mut buf)?;
    let signed_blob = SshSig::signed_data(namespace, hash, &buf)
        .map_err(|e| GitwayError::signing(format!("sshsig signed_data failed: {e}")))?;
    let signature = agent.sign(public_key, &signed_blob)?;
    let sig = SshSig::new(public_key.key_data().clone(), namespace, hash, signature)
        .map_err(|e| GitwayError::signing(format!("sshsig wrap failed: {e}")))?;
    sig.to_pem(LineEnding::LF)
        .map_err(|e| GitwayError::signing(format!("sshsig armor failed: {e}")))
}

// ── Verify ────────────────────────────────────────────────────────────────────

/// Verifies that `armored_sig` is a valid SSHSIG over the bytes read from
/// `data`, in `namespace`, and that `allowed` authorizes `signer_identity`
/// to sign with the embedded public key.
///
/// This is the full `ssh-keygen -Y verify` equivalent: three independent
/// checks — cryptographic signature, namespace match, and principal
/// authorization.
///
/// # Errors
///
/// Returns [`GitwayError::signature_invalid`] on any failed check.
pub fn verify<R: Read>(
    data: &mut R,
    armored_sig: &str,
    signer_identity: &str,
    namespace: &str,
    allowed: &AllowedSigners,
) -> Result<Verified, GitwayError> {
    let sig = SshSig::from_pem(armored_sig)
        .map_err(|e| GitwayError::signature_invalid(format!("malformed signature: {e}")))?;

    if sig.namespace() != namespace {
        return Err(GitwayError::signature_invalid(format!(
            "namespace mismatch: signature is {:?}, expected {namespace:?}",
            sig.namespace()
        )));
    }

    let mut buf = Vec::new();
    data.read_to_end(&mut buf)?;

    let public_key = PublicKey::from(sig.public_key().clone());
    public_key
        .verify(namespace, &buf, &sig)
        .map_err(|e| GitwayError::signature_invalid(format!("cryptographic check failed: {e}")))?;

    if !allowed.is_authorized(signer_identity, &public_key, namespace) {
        return Err(GitwayError::signature_invalid(format!(
            "signer {signer_identity:?} is not authorized for namespace {namespace:?} \
             with key {}",
            public_key.fingerprint(HashAlg::Sha256)
        )));
    }

    Ok(Verified {
        principal: signer_identity.to_owned(),
        fingerprint: public_key.fingerprint(HashAlg::Sha256).to_string(),
    })
}

// ── Check only (no allowed-signers) ───────────────────────────────────────────

/// Verifies the cryptographic signature and namespace, but not the signer
/// identity. This matches `ssh-keygen -Y check-novalidate`.
///
/// # Errors
///
/// Returns [`GitwayError::signature_invalid`] on malformed armor, namespace
/// mismatch, or failed cryptographic check.
pub fn check_novalidate<R: Read>(
    data: &mut R,
    armored_sig: &str,
    namespace: &str,
) -> Result<(), GitwayError> {
    let sig = SshSig::from_pem(armored_sig)
        .map_err(|e| GitwayError::signature_invalid(format!("malformed signature: {e}")))?;

    if sig.namespace() != namespace {
        return Err(GitwayError::signature_invalid(format!(
            "namespace mismatch: signature is {:?}, expected {namespace:?}",
            sig.namespace()
        )));
    }

    let mut buf = Vec::new();
    data.read_to_end(&mut buf)?;

    let public_key = PublicKey::from(sig.public_key().clone());
    public_key
        .verify(namespace, &buf, &sig)
        .map_err(|e| GitwayError::signature_invalid(format!("cryptographic check failed: {e}")))?;

    Ok(())
}

// ── find-principals ───────────────────────────────────────────────────────────

/// Returns the principals in `allowed` that are authorized to sign with the
/// public key embedded in `armored_sig` under `namespace`.
///
/// Matches `ssh-keygen -Y find-principals` — it does not verify the
/// signature, only reads the embedded public key.
///
/// # Errors
///
/// Returns [`GitwayError::signature_invalid`] if `armored_sig` is malformed.
pub fn find_principals(
    armored_sig: &str,
    allowed: &AllowedSigners,
    namespace: &str,
) -> Result<Vec<String>, GitwayError> {
    let sig = SshSig::from_pem(armored_sig)
        .map_err(|e| GitwayError::signature_invalid(format!("malformed signature: {e}")))?;
    let public_key = PublicKey::from(sig.public_key().clone());
    Ok(allowed
        .find_principals(&public_key, namespace)
        .iter()
        .map(|s| (*s).to_owned())
        .collect())
}

// ── Tests ─────────────────────────────────────────────────────────────────────

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

    use crate::keygen::{generate, KeyType};

    fn roundtrip(kind: KeyType, hash: HashAlg) {
        let key = generate(kind, None, "sign@test").unwrap();
        let payload = b"the quick brown fox jumps over the lazy dog";
        let armored = sign(&mut Cursor::new(payload), &key, "git", hash).unwrap();
        assert!(armored.contains("BEGIN SSH SIGNATURE"));

        // Namespace match, correct payload.
        check_novalidate(&mut Cursor::new(payload), &armored, "git").unwrap();

        // Wrong namespace rejected.
        let err = check_novalidate(&mut Cursor::new(payload), &armored, "file").unwrap_err();
        assert!(err.to_string().contains("namespace"));

        // Tampered payload rejected.
        let err = check_novalidate(&mut Cursor::new(b"tampered"), &armored, "git").unwrap_err();
        assert!(err.to_string().contains("cryptographic"));
    }

    #[test]
    fn ed25519_sign_verify_roundtrip() {
        roundtrip(KeyType::Ed25519, HashAlg::Sha512);
    }

    #[test]
    fn ecdsa_p256_sign_verify_roundtrip() {
        roundtrip(KeyType::EcdsaP256, HashAlg::Sha512);
    }

    // RSA SSHSIG signing via `ssh-key` 0.6.7 fails with an opaque
    // `cryptographic error`. Ed25519 and ECDSA are the dominant choices
    // for git SSH signing in 2026, and `ssh-keygen -Y sign` itself
    // recommends Ed25519. Keep the test skeleton for a future fix.
    #[test]
    #[ignore = "RSA SSHSIG path not yet wired up in ssh-key 0.6.7"]
    fn rsa_sign_verify_roundtrip() {
        let key = generate(KeyType::Rsa, Some(2048), "rsa-sign@test").unwrap();
        let payload = b"hello rsa";
        let armored = sign(&mut Cursor::new(payload), &key, "git", HashAlg::Sha512).unwrap();
        check_novalidate(&mut Cursor::new(payload), &armored, "git").unwrap();
    }

    #[test]
    fn verify_against_allowed_signers_success() {
        let key = generate(KeyType::Ed25519, None, "alice@test").unwrap();
        let pubkey_line = key.public_key().to_openssh().unwrap();
        let allowed_text = format!("alice@example.com {pubkey_line}");
        let allowed = AllowedSigners::parse(&allowed_text).unwrap();

        let payload = b"signed content";
        let armored = sign(&mut Cursor::new(payload), &key, "git", HashAlg::Sha512).unwrap();

        let verified = verify(
            &mut Cursor::new(payload),
            &armored,
            "alice@example.com",
            "git",
            &allowed,
        )
        .unwrap();
        assert_eq!(verified.principal, "alice@example.com");
        assert!(verified.fingerprint.starts_with("SHA256:"));
    }

    #[test]
    fn verify_against_allowed_signers_rejects_unknown_identity() {
        let key = generate(KeyType::Ed25519, None, "bob@test").unwrap();
        let pubkey_line = key.public_key().to_openssh().unwrap();
        let allowed_text = format!("alice@example.com {pubkey_line}");
        let allowed = AllowedSigners::parse(&allowed_text).unwrap();

        let payload = b"signed content";
        let armored = sign(&mut Cursor::new(payload), &key, "git", HashAlg::Sha512).unwrap();

        let err = verify(
            &mut Cursor::new(payload),
            &armored,
            "mallory@example.com",
            "git",
            &allowed,
        )
        .unwrap_err();
        assert!(err.to_string().contains("not authorized"));
    }

    #[test]
    fn find_principals_returns_matching_entries() {
        let key = generate(KeyType::Ed25519, None, "carol@test").unwrap();
        let pubkey_line = key.public_key().to_openssh().unwrap();
        let allowed_text = format!("carol@example.com,dave@example.com {pubkey_line}");
        let allowed = AllowedSigners::parse(&allowed_text).unwrap();

        let armored = sign(&mut Cursor::new(b"x"), &key, "git", HashAlg::Sha512).unwrap();
        let principals = find_principals(&armored, &allowed, "git").unwrap();
        assert!(principals.iter().any(|p| p == "carol@example.com"));
        assert!(principals.iter().any(|p| p == "dave@example.com"));
    }
}