use std::io::Read;
use ssh_key::{HashAlg, LineEnding, PrivateKey, PublicKey, SshSig};
use crate::agent::client::Agent;
use crate::allowed_signers::AllowedSigners;
use crate::AnvilError;
#[derive(Debug, Clone)]
pub struct Verified {
pub principal: String,
pub fingerprint: String,
}
pub fn sign<R: Read>(
data: &mut R,
key: &PrivateKey,
namespace: &str,
hash: HashAlg,
) -> Result<String, AnvilError> {
let mut buf = Vec::new();
data.read_to_end(&mut buf)?;
let sig = SshSig::sign(key, namespace, hash, &buf)
.map_err(|e| AnvilError::signing(format!("sshsig sign failed: {e}")))?;
sig.to_pem(LineEnding::LF)
.map_err(|e| AnvilError::signing(format!("sshsig armor failed: {e}")))
}
pub fn sign_with_agent<R: Read>(
data: &mut R,
agent: &mut Agent,
public_key: &PublicKey,
namespace: &str,
hash: HashAlg,
) -> Result<String, AnvilError> {
let mut buf = Vec::new();
data.read_to_end(&mut buf)?;
let signed_blob = SshSig::signed_data(namespace, hash, &buf)
.map_err(|e| AnvilError::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| AnvilError::signing(format!("sshsig wrap failed: {e}")))?;
sig.to_pem(LineEnding::LF)
.map_err(|e| AnvilError::signing(format!("sshsig armor failed: {e}")))
}
pub fn verify<R: Read>(
data: &mut R,
armored_sig: &str,
signer_identity: &str,
namespace: &str,
allowed: &AllowedSigners,
) -> Result<Verified, AnvilError> {
let sig = SshSig::from_pem(armored_sig)
.map_err(|e| AnvilError::signature_invalid(format!("malformed signature: {e}")))?;
if sig.namespace() != namespace {
return Err(AnvilError::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| AnvilError::signature_invalid(format!("cryptographic check failed: {e}")))?;
if !allowed.is_authorized(signer_identity, &public_key, namespace) {
return Err(AnvilError::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(),
})
}
pub fn check_novalidate<R: Read>(
data: &mut R,
armored_sig: &str,
namespace: &str,
) -> Result<(), AnvilError> {
let sig = SshSig::from_pem(armored_sig)
.map_err(|e| AnvilError::signature_invalid(format!("malformed signature: {e}")))?;
if sig.namespace() != namespace {
return Err(AnvilError::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| AnvilError::signature_invalid(format!("cryptographic check failed: {e}")))?;
Ok(())
}
pub fn find_principals(
armored_sig: &str,
allowed: &AllowedSigners,
namespace: &str,
) -> Result<Vec<String>, AnvilError> {
let sig = SshSig::from_pem(armored_sig)
.map_err(|e| AnvilError::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())
}
pub fn find_principals_any_ns(
armored_sig: &str,
allowed: &AllowedSigners,
) -> Result<Vec<String>, AnvilError> {
let sig = SshSig::from_pem(armored_sig)
.map_err(|e| AnvilError::signature_invalid(format!("malformed signature: {e}")))?;
let public_key = PublicKey::from(sig.public_key().clone());
Ok(allowed
.find_principals_any_ns(&public_key)
.iter()
.map(|s| (*s).to_owned())
.collect())
}
#[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"));
check_novalidate(&mut Cursor::new(payload), &armored, "git").unwrap();
let err = check_novalidate(&mut Cursor::new(payload), &armored, "file").unwrap_err();
assert!(err.to_string().contains("namespace"));
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);
}
#[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"));
}
#[test]
fn find_principals_any_ns_ignores_namespace_restriction() {
let key = generate(KeyType::Ed25519, None, "erin@test").unwrap();
let pubkey_line = key.public_key().to_openssh().unwrap();
let allowed_text =
format!("erin@example.com namespaces=\"signing\" {pubkey_line}");
let allowed = AllowedSigners::parse(&allowed_text).unwrap();
let armored = sign(&mut Cursor::new(b"x"), &key, "git", HashAlg::Sha512).unwrap();
let strict = find_principals(&armored, &allowed, "git").unwrap();
assert!(
strict.is_empty(),
"namespace-aware lookup must skip entries restricted to a different namespace, got {strict:?}"
);
let any = find_principals_any_ns(&armored, &allowed).unwrap();
assert!(
any.iter().any(|p| p == "erin@example.com"),
"any-ns lookup must return the principal regardless of namespace restriction, got {any:?}"
);
}
}