Skip to main content

vgi_core/
sshsig.rs

1//! PROTOCOL.sshsig encoding for Ed25519 keys.
2//!
3//! The signer produces armored SSH signatures with [`create_ssh_signature`];
4//! the verifier decodes them with the `ssh-key` crate. Keeping the encoder
5//! here means the format the signer writes and the format the verifier expects
6//! are defined against one another in a single crate.
7
8use anyhow::Result;
9use ed25519_dalek::SigningKey;
10use sha2::{Digest, Sha512};
11
12/// The sshsig namespace git uses for commit and tag signatures.
13pub const GIT_SSHSIG_NAMESPACE: &str = "git";
14
15/// Magic preamble for SSH signatures (PROTOCOL.sshsig).
16const SSHSIG_MAGIC: &[u8; 6] = b"SSHSIG";
17
18/// Create an armored SSH signature following the PROTOCOL.sshsig format.
19///
20/// The signed data structure is:
21///   MAGIC_PREAMBLE (6 bytes: "SSHSIG")
22///   namespace (string)
23///   reserved (empty string)
24///   hash_algorithm (string: "sha512")
25///   H(message) (string: SHA-512 hash of the message)
26///
27/// The signature blob structure is:
28///   MAGIC_PREAMBLE
29///   version (uint32: 1)
30///   publickey (SSH wire format)
31///   namespace (string)
32///   reserved (empty string)
33///   hash_algorithm (string)
34///   signature (SSH wire format)
35pub fn create_ssh_signature(
36    signing_key: &SigningKey,
37    verifying_key: &ed25519_dalek::VerifyingKey,
38    namespace: &str,
39    message: &[u8],
40) -> Result<String> {
41    use ed25519_dalek::Signer;
42
43    // Hash the message with SHA-512
44    let message_hash = Sha512::digest(message);
45
46    // Build the data to sign (PROTOCOL.sshsig §4)
47    let mut signed_data = Vec::new();
48    signed_data.extend_from_slice(SSHSIG_MAGIC);
49    write_ssh_string(&mut signed_data, namespace.as_bytes());
50    write_ssh_string(&mut signed_data, b""); // reserved
51    write_ssh_string(&mut signed_data, b"sha512");
52    write_ssh_string(&mut signed_data, &message_hash);
53
54    // Sign the structured data
55    let sig = signing_key.sign(&signed_data);
56
57    // Build the public key in SSH wire format
58    let pubkey_blob = encode_ssh_ed25519_pubkey(verifying_key);
59
60    // Build the signature blob in SSH wire format
61    let sig_blob = encode_ssh_ed25519_signature(&sig);
62
63    // Build the full SSHSIG blob
64    let mut sshsig_blob = Vec::new();
65    sshsig_blob.extend_from_slice(SSHSIG_MAGIC);
66    write_u32(&mut sshsig_blob, 1); // version
67    write_ssh_string(&mut sshsig_blob, &pubkey_blob); // publickey
68    write_ssh_string(&mut sshsig_blob, namespace.as_bytes()); // namespace
69    write_ssh_string(&mut sshsig_blob, b""); // reserved
70    write_ssh_string(&mut sshsig_blob, b"sha512"); // hash algorithm
71    write_ssh_string(&mut sshsig_blob, &sig_blob); // signature
72
73    // Armor with PEM-style headers
74    // Note: base64 output is always valid ASCII/UTF-8, so from_utf8 cannot fail here.
75    let b64 = base64_encode(&sshsig_blob);
76    let mut armored = String::new();
77    armored.push_str("-----BEGIN SSH SIGNATURE-----\n");
78    // OpenSSH wraps sshsig base64 at 70 columns (sshbuf_dtob64). Match it
79    // exactly: RustCrypto's ssh-encoding PEM parser rejects other widths, so
80    // any deviation makes our signatures unreadable to non-OpenSSH verifiers.
81    for chunk in b64.as_bytes().chunks(70) {
82        armored.push_str(std::str::from_utf8(chunk).expect("base64 output is always valid UTF-8"));
83        armored.push('\n');
84    }
85    armored.push_str("-----END SSH SIGNATURE-----\n");
86
87    Ok(armored)
88}
89
90/// Encode an Ed25519 public key in SSH wire format:
91///   string "ssh-ed25519"
92///   string <32-byte public key>
93fn encode_ssh_ed25519_pubkey(key: &ed25519_dalek::VerifyingKey) -> Vec<u8> {
94    let mut buf = Vec::new();
95    write_ssh_string(&mut buf, b"ssh-ed25519");
96    write_ssh_string(&mut buf, key.as_bytes());
97    buf
98}
99
100/// Encode an Ed25519 signature in SSH wire format:
101///   string "ssh-ed25519"
102///   string <64-byte signature>
103fn encode_ssh_ed25519_signature(sig: &ed25519_dalek::Signature) -> Vec<u8> {
104    let mut buf = Vec::new();
105    write_ssh_string(&mut buf, b"ssh-ed25519");
106    write_ssh_string(&mut buf, &sig.to_bytes());
107    buf
108}
109
110/// Write a uint32 in big-endian.
111fn write_u32(buf: &mut Vec<u8>, val: u32) {
112    buf.extend_from_slice(&val.to_be_bytes());
113}
114
115/// Write an SSH "string" (uint32 length prefix + raw bytes).
116fn write_ssh_string(buf: &mut Vec<u8>, data: &[u8]) {
117    write_u32(buf, data.len() as u32);
118    buf.extend_from_slice(data);
119}
120
121/// Base64-encode without line wrapping (we handle wrapping separately).
122fn base64_encode(data: &[u8]) -> String {
123    use base64::Engine;
124    base64::engine::general_purpose::STANDARD.encode(data)
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn test_ssh_string_encoding() {
133        let mut buf = Vec::new();
134        write_ssh_string(&mut buf, b"ssh-ed25519");
135        assert_eq!(buf.len(), 4 + 11);
136        assert_eq!(&buf[..4], &[0, 0, 0, 11]);
137        assert_eq!(&buf[4..], b"ssh-ed25519");
138    }
139
140    #[test]
141    fn test_pubkey_blob_format() {
142        let seed = [0u8; 32];
143        let signing_key = SigningKey::from_bytes(&seed);
144        let verifying_key = signing_key.verifying_key();
145        let blob = encode_ssh_ed25519_pubkey(&verifying_key);
146        // "ssh-ed25519" (4+11) + pubkey (4+32) = 51 bytes
147        assert_eq!(blob.len(), 51);
148    }
149
150    #[test]
151    fn test_signature_is_valid_sshsig() {
152        let seed = [42u8; 32];
153        let signing_key = SigningKey::from_bytes(&seed);
154        let verifying_key = signing_key.verifying_key();
155        let result = create_ssh_signature(&signing_key, &verifying_key, "git", b"test commit data");
156        assert!(result.is_ok());
157        let armored = result.unwrap();
158        assert!(armored.starts_with("-----BEGIN SSH SIGNATURE-----\n"));
159        assert!(armored.ends_with("-----END SSH SIGNATURE-----\n"));
160    }
161
162    #[test]
163    fn test_sshsig_blob_contains_magic_and_version() {
164        let seed = [7u8; 32];
165        let signing_key = SigningKey::from_bytes(&seed);
166        let verifying_key = signing_key.verifying_key();
167        let armored = create_ssh_signature(&signing_key, &verifying_key, "git", b"hello").unwrap();
168
169        // Extract base64 content between the armor headers
170        let b64: String = armored
171            .lines()
172            .filter(|l| !l.starts_with("-----"))
173            .collect();
174        let blob =
175            base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &b64).unwrap();
176
177        // First 6 bytes must be "SSHSIG" magic
178        assert_eq!(&blob[..6], b"SSHSIG");
179        // Next 4 bytes must be version 1 (big-endian u32)
180        assert_eq!(&blob[6..10], &[0, 0, 0, 1]);
181    }
182
183    #[test]
184    fn test_signature_deterministic_for_same_inputs() {
185        let seed = [99u8; 32];
186        let signing_key = SigningKey::from_bytes(&seed);
187        let verifying_key = signing_key.verifying_key();
188        let msg = b"same message";
189
190        let sig1 = create_ssh_signature(&signing_key, &verifying_key, "git", msg).unwrap();
191        let sig2 = create_ssh_signature(&signing_key, &verifying_key, "git", msg).unwrap();
192        // Ed25519 signatures are deterministic
193        assert_eq!(sig1, sig2);
194    }
195
196    #[test]
197    fn test_signature_differs_for_different_messages() {
198        let seed = [55u8; 32];
199        let signing_key = SigningKey::from_bytes(&seed);
200        let verifying_key = signing_key.verifying_key();
201
202        let sig1 = create_ssh_signature(&signing_key, &verifying_key, "git", b"msg A").unwrap();
203        let sig2 = create_ssh_signature(&signing_key, &verifying_key, "git", b"msg B").unwrap();
204        assert_ne!(sig1, sig2);
205    }
206
207    #[test]
208    fn test_signature_differs_for_different_namespaces() {
209        let seed = [88u8; 32];
210        let signing_key = SigningKey::from_bytes(&seed);
211        let verifying_key = signing_key.verifying_key();
212        let msg = b"same data";
213
214        let sig1 = create_ssh_signature(&signing_key, &verifying_key, "git", msg).unwrap();
215        let sig2 = create_ssh_signature(&signing_key, &verifying_key, "file", msg).unwrap();
216        assert_ne!(sig1, sig2);
217    }
218
219    #[test]
220    fn test_signature_blob_wraps_at_70_like_openssh() {
221        let seed = [1u8; 32];
222        let signing_key = SigningKey::from_bytes(&seed);
223        let verifying_key = signing_key.verifying_key();
224        let armored =
225            create_ssh_signature(&signing_key, &verifying_key, "git", b"check line wrap").unwrap();
226
227        let body: Vec<&str> = armored
228            .lines()
229            .filter(|line| !line.starts_with("-----"))
230            .collect();
231        // Every full line is exactly 70 columns (only the last may be
232        // shorter) — the width ssh-keygen emits and strict PEM parsers
233        // (RustCrypto ssh-encoding) require.
234        for line in &body[..body.len() - 1] {
235            assert_eq!(line.len(), 70, "base64 line is {} chars", line.len());
236        }
237        assert!(body[body.len() - 1].len() <= 70);
238    }
239
240    #[test]
241    fn test_write_u32_big_endian() {
242        let mut buf = Vec::new();
243        write_u32(&mut buf, 0x01020304);
244        assert_eq!(buf, vec![0x01, 0x02, 0x03, 0x04]);
245    }
246
247    #[test]
248    fn test_signature_blob_encoding() {
249        use ed25519_dalek::Signer;
250        let seed = [0xBB; 32];
251        let signing_key = SigningKey::from_bytes(&seed);
252        let sig = signing_key.sign(b"test");
253        let blob = encode_ssh_ed25519_signature(&sig);
254        // "ssh-ed25519" (4+11) + signature (4+64) = 83 bytes
255        assert_eq!(blob.len(), 83);
256        // Type string is "ssh-ed25519"
257        assert_eq!(&blob[4..15], b"ssh-ed25519");
258    }
259}