use anyhow::Result;
use ed25519_dalek::SigningKey;
use sha2::{Digest, Sha512};
pub const GIT_SSHSIG_NAMESPACE: &str = "git";
const SSHSIG_MAGIC: &[u8; 6] = b"SSHSIG";
pub fn create_ssh_signature(
signing_key: &SigningKey,
verifying_key: &ed25519_dalek::VerifyingKey,
namespace: &str,
message: &[u8],
) -> Result<String> {
use ed25519_dalek::Signer;
let message_hash = Sha512::digest(message);
let mut signed_data = Vec::new();
signed_data.extend_from_slice(SSHSIG_MAGIC);
write_ssh_string(&mut signed_data, namespace.as_bytes());
write_ssh_string(&mut signed_data, b""); write_ssh_string(&mut signed_data, b"sha512");
write_ssh_string(&mut signed_data, &message_hash);
let sig = signing_key.sign(&signed_data);
let pubkey_blob = encode_ssh_ed25519_pubkey(verifying_key);
let sig_blob = encode_ssh_ed25519_signature(&sig);
let mut sshsig_blob = Vec::new();
sshsig_blob.extend_from_slice(SSHSIG_MAGIC);
write_u32(&mut sshsig_blob, 1); write_ssh_string(&mut sshsig_blob, &pubkey_blob); write_ssh_string(&mut sshsig_blob, namespace.as_bytes()); write_ssh_string(&mut sshsig_blob, b""); write_ssh_string(&mut sshsig_blob, b"sha512"); write_ssh_string(&mut sshsig_blob, &sig_blob);
let b64 = base64_encode(&sshsig_blob);
let mut armored = String::new();
armored.push_str("-----BEGIN SSH SIGNATURE-----\n");
for chunk in b64.as_bytes().chunks(70) {
armored.push_str(std::str::from_utf8(chunk).expect("base64 output is always valid UTF-8"));
armored.push('\n');
}
armored.push_str("-----END SSH SIGNATURE-----\n");
Ok(armored)
}
fn encode_ssh_ed25519_pubkey(key: &ed25519_dalek::VerifyingKey) -> Vec<u8> {
let mut buf = Vec::new();
write_ssh_string(&mut buf, b"ssh-ed25519");
write_ssh_string(&mut buf, key.as_bytes());
buf
}
fn encode_ssh_ed25519_signature(sig: &ed25519_dalek::Signature) -> Vec<u8> {
let mut buf = Vec::new();
write_ssh_string(&mut buf, b"ssh-ed25519");
write_ssh_string(&mut buf, &sig.to_bytes());
buf
}
fn write_u32(buf: &mut Vec<u8>, val: u32) {
buf.extend_from_slice(&val.to_be_bytes());
}
fn write_ssh_string(buf: &mut Vec<u8>, data: &[u8]) {
write_u32(buf, data.len() as u32);
buf.extend_from_slice(data);
}
fn base64_encode(data: &[u8]) -> String {
use base64::Engine;
base64::engine::general_purpose::STANDARD.encode(data)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ssh_string_encoding() {
let mut buf = Vec::new();
write_ssh_string(&mut buf, b"ssh-ed25519");
assert_eq!(buf.len(), 4 + 11);
assert_eq!(&buf[..4], &[0, 0, 0, 11]);
assert_eq!(&buf[4..], b"ssh-ed25519");
}
#[test]
fn test_pubkey_blob_format() {
let seed = [0u8; 32];
let signing_key = SigningKey::from_bytes(&seed);
let verifying_key = signing_key.verifying_key();
let blob = encode_ssh_ed25519_pubkey(&verifying_key);
assert_eq!(blob.len(), 51);
}
#[test]
fn test_signature_is_valid_sshsig() {
let seed = [42u8; 32];
let signing_key = SigningKey::from_bytes(&seed);
let verifying_key = signing_key.verifying_key();
let result = create_ssh_signature(&signing_key, &verifying_key, "git", b"test commit data");
assert!(result.is_ok());
let armored = result.unwrap();
assert!(armored.starts_with("-----BEGIN SSH SIGNATURE-----\n"));
assert!(armored.ends_with("-----END SSH SIGNATURE-----\n"));
}
#[test]
fn test_sshsig_blob_contains_magic_and_version() {
let seed = [7u8; 32];
let signing_key = SigningKey::from_bytes(&seed);
let verifying_key = signing_key.verifying_key();
let armored = create_ssh_signature(&signing_key, &verifying_key, "git", b"hello").unwrap();
let b64: String = armored
.lines()
.filter(|l| !l.starts_with("-----"))
.collect();
let blob =
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &b64).unwrap();
assert_eq!(&blob[..6], b"SSHSIG");
assert_eq!(&blob[6..10], &[0, 0, 0, 1]);
}
#[test]
fn test_signature_deterministic_for_same_inputs() {
let seed = [99u8; 32];
let signing_key = SigningKey::from_bytes(&seed);
let verifying_key = signing_key.verifying_key();
let msg = b"same message";
let sig1 = create_ssh_signature(&signing_key, &verifying_key, "git", msg).unwrap();
let sig2 = create_ssh_signature(&signing_key, &verifying_key, "git", msg).unwrap();
assert_eq!(sig1, sig2);
}
#[test]
fn test_signature_differs_for_different_messages() {
let seed = [55u8; 32];
let signing_key = SigningKey::from_bytes(&seed);
let verifying_key = signing_key.verifying_key();
let sig1 = create_ssh_signature(&signing_key, &verifying_key, "git", b"msg A").unwrap();
let sig2 = create_ssh_signature(&signing_key, &verifying_key, "git", b"msg B").unwrap();
assert_ne!(sig1, sig2);
}
#[test]
fn test_signature_differs_for_different_namespaces() {
let seed = [88u8; 32];
let signing_key = SigningKey::from_bytes(&seed);
let verifying_key = signing_key.verifying_key();
let msg = b"same data";
let sig1 = create_ssh_signature(&signing_key, &verifying_key, "git", msg).unwrap();
let sig2 = create_ssh_signature(&signing_key, &verifying_key, "file", msg).unwrap();
assert_ne!(sig1, sig2);
}
#[test]
fn test_signature_blob_wraps_at_70_like_openssh() {
let seed = [1u8; 32];
let signing_key = SigningKey::from_bytes(&seed);
let verifying_key = signing_key.verifying_key();
let armored =
create_ssh_signature(&signing_key, &verifying_key, "git", b"check line wrap").unwrap();
let body: Vec<&str> = armored
.lines()
.filter(|line| !line.starts_with("-----"))
.collect();
for line in &body[..body.len() - 1] {
assert_eq!(line.len(), 70, "base64 line is {} chars", line.len());
}
assert!(body[body.len() - 1].len() <= 70);
}
#[test]
fn test_write_u32_big_endian() {
let mut buf = Vec::new();
write_u32(&mut buf, 0x01020304);
assert_eq!(buf, vec![0x01, 0x02, 0x03, 0x04]);
}
#[test]
fn test_signature_blob_encoding() {
use ed25519_dalek::Signer;
let seed = [0xBB; 32];
let signing_key = SigningKey::from_bytes(&seed);
let sig = signing_key.sign(b"test");
let blob = encode_ssh_ed25519_signature(&sig);
assert_eq!(blob.len(), 83);
assert_eq!(&blob[4..15], b"ssh-ed25519");
}
}