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/// signing locally with `signing_key`: [`sshsig_signed_data`] over the SHA-512
20/// of `message`, wrapped as [`armor_ssh_signature`] describes.
21pub fn create_ssh_signature(
22    signing_key: &SigningKey,
23    verifying_key: &ed25519_dalek::VerifyingKey,
24    namespace: &str,
25    message: &[u8],
26) -> Result<String> {
27    use ed25519_dalek::Signer;
28
29    let message_hash = sshsig_message_hash(message);
30    let sig = signing_key.sign(&sshsig_signed_data(namespace, &message_hash));
31    Ok(armor_ssh_signature(verifying_key, namespace, &sig))
32}
33
34/// `H(message)` for an SSHSIG signature: SHA-512, the hash git and
35/// `ssh-keygen -Y sign` use.
36pub fn sshsig_message_hash(message: &[u8]) -> [u8; 64] {
37    Sha512::digest(message).into()
38}
39
40/// The bytes an SSHSIG signature is made over, for a SHA-512 `message_hash`
41/// (PROTOCOL.sshsig §4):
42///
43///   MAGIC_PREAMBLE ("SSHSIG")
44///   namespace (string)
45///   reserved (empty string)
46///   hash_algorithm (string: "sha512")
47///   H(message) (string)
48///
49/// A VTA serving `keys/sign-sshsig/0.1` builds the same bytes from the digest
50/// and signs them; [`assemble_ssh_signature`] checks the answer against them.
51pub fn sshsig_signed_data(namespace: &str, message_hash: &[u8; 64]) -> Vec<u8> {
52    let mut signed_data = Vec::new();
53    signed_data.extend_from_slice(SSHSIG_MAGIC);
54    write_ssh_string(&mut signed_data, namespace.as_bytes());
55    write_ssh_string(&mut signed_data, b""); // reserved
56    write_ssh_string(&mut signed_data, b"sha512");
57    write_ssh_string(&mut signed_data, message_hash);
58    signed_data
59}
60
61/// Armor a signature made elsewhere — by a VTA that holds the key — as an
62/// SSHSIG signature over `message`.
63///
64/// The signature is verified against `verifying_key` over the SSHSIG signed
65/// data before it is armored, so a wrong key, a wrong namespace or a signature
66/// over anything else is refused here rather than written into a commit that
67/// would fail verification later.
68pub fn assemble_ssh_signature(
69    verifying_key: &ed25519_dalek::VerifyingKey,
70    namespace: &str,
71    message: &[u8],
72    signature: &[u8],
73) -> Result<String> {
74    use ed25519_dalek::Verifier;
75
76    let sig = ed25519_dalek::Signature::from_slice(signature)
77        .map_err(|e| anyhow::anyhow!("remote signature is not an Ed25519 signature: {e}"))?;
78    let signed_data = sshsig_signed_data(namespace, &sshsig_message_hash(message));
79    verifying_key.verify(&signed_data, &sig).map_err(|_| {
80        anyhow::anyhow!(
81            "remote signature does not verify as an SSHSIG signature by this key in namespace \
82             {namespace:?}"
83        )
84    })?;
85    Ok(armor_ssh_signature(verifying_key, namespace, &sig))
86}
87
88/// Wrap a signature over [`sshsig_signed_data`] in the SSHSIG blob and its
89/// armor.
90///
91/// The signature blob structure is:
92///   MAGIC_PREAMBLE
93///   version (uint32: 1)
94///   publickey (SSH wire format)
95///   namespace (string)
96///   reserved (empty string)
97///   hash_algorithm (string)
98///   signature (SSH wire format)
99fn armor_ssh_signature(
100    verifying_key: &ed25519_dalek::VerifyingKey,
101    namespace: &str,
102    sig: &ed25519_dalek::Signature,
103) -> String {
104    let pubkey_blob = encode_ssh_ed25519_pubkey(verifying_key);
105    let sig_blob = encode_ssh_ed25519_signature(sig);
106
107    let mut sshsig_blob = Vec::new();
108    sshsig_blob.extend_from_slice(SSHSIG_MAGIC);
109    write_u32(&mut sshsig_blob, 1); // version
110    write_ssh_string(&mut sshsig_blob, &pubkey_blob); // publickey
111    write_ssh_string(&mut sshsig_blob, namespace.as_bytes()); // namespace
112    write_ssh_string(&mut sshsig_blob, b""); // reserved
113    write_ssh_string(&mut sshsig_blob, b"sha512"); // hash algorithm
114    write_ssh_string(&mut sshsig_blob, &sig_blob); // signature
115
116    // Armor with PEM-style headers
117    // Note: base64 output is always valid ASCII/UTF-8, so from_utf8 cannot fail here.
118    let b64 = base64_encode(&sshsig_blob);
119    let mut armored = String::new();
120    armored.push_str("-----BEGIN SSH SIGNATURE-----\n");
121    // OpenSSH wraps sshsig base64 at 70 columns (sshbuf_dtob64). Match it
122    // exactly: RustCrypto's ssh-encoding PEM parser rejects other widths, so
123    // any deviation makes our signatures unreadable to non-OpenSSH verifiers.
124    for chunk in b64.as_bytes().chunks(70) {
125        armored.push_str(std::str::from_utf8(chunk).expect("base64 output is always valid UTF-8"));
126        armored.push('\n');
127    }
128    armored.push_str("-----END SSH SIGNATURE-----\n");
129    armored
130}
131
132/// Encode an Ed25519 public key in SSH wire format:
133///   string "ssh-ed25519"
134///   string <32-byte public key>
135fn encode_ssh_ed25519_pubkey(key: &ed25519_dalek::VerifyingKey) -> Vec<u8> {
136    let mut buf = Vec::new();
137    write_ssh_string(&mut buf, b"ssh-ed25519");
138    write_ssh_string(&mut buf, key.as_bytes());
139    buf
140}
141
142/// Encode an Ed25519 signature in SSH wire format:
143///   string "ssh-ed25519"
144///   string <64-byte signature>
145fn encode_ssh_ed25519_signature(sig: &ed25519_dalek::Signature) -> Vec<u8> {
146    let mut buf = Vec::new();
147    write_ssh_string(&mut buf, b"ssh-ed25519");
148    write_ssh_string(&mut buf, &sig.to_bytes());
149    buf
150}
151
152/// Write a uint32 in big-endian.
153fn write_u32(buf: &mut Vec<u8>, val: u32) {
154    buf.extend_from_slice(&val.to_be_bytes());
155}
156
157/// Write an SSH "string" (uint32 length prefix + raw bytes).
158fn write_ssh_string(buf: &mut Vec<u8>, data: &[u8]) {
159    write_u32(buf, data.len() as u32);
160    buf.extend_from_slice(data);
161}
162
163/// Base64-encode without line wrapping (we handle wrapping separately).
164fn base64_encode(data: &[u8]) -> String {
165    use base64::Engine;
166    base64::engine::general_purpose::STANDARD.encode(data)
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    /// A signature made over [`sshsig_signed_data`] by whoever holds the key —
174    /// a VTA serving `keys/sign-sshsig` — armors to exactly what signing
175    /// locally produces. Ed25519 is deterministic, so the two are byte-equal.
176    #[test]
177    fn a_remote_signature_assembles_to_the_local_one() {
178        use ed25519_dalek::Signer;
179        let signing_key = SigningKey::from_bytes(&[42u8; 32]);
180        let verifying_key = signing_key.verifying_key();
181        let message = b"tree 4b825dc6\nauthor A <a@x> 1 +0000\n\nmsg\n";
182
183        // What the VTA signs, from the digest alone.
184        let remote = signing_key.sign(&sshsig_signed_data("git", &sshsig_message_hash(message)));
185        let assembled =
186            assemble_ssh_signature(&verifying_key, "git", message, &remote.to_bytes()).unwrap();
187        let local = create_ssh_signature(&signing_key, &verifying_key, "git", message).unwrap();
188        assert_eq!(assembled, local);
189    }
190
191    /// Anything but an SSHSIG signature by this key, in this namespace, over
192    /// this message is refused before it is armored.
193    #[test]
194    fn a_remote_signature_that_does_not_verify_is_refused() {
195        use ed25519_dalek::Signer;
196        let signing_key = SigningKey::from_bytes(&[42u8; 32]);
197        let verifying_key = signing_key.verifying_key();
198        let message = b"a commit";
199        let hash = sshsig_message_hash(message);
200
201        let other_namespace = signing_key.sign(&sshsig_signed_data("file", &hash));
202        let raw_digest = signing_key.sign(&hash);
203        let other_key = SigningKey::from_bytes(&[7u8; 32]).sign(&sshsig_signed_data("git", &hash));
204        for sig in [other_namespace, raw_digest, other_key] {
205            assert!(
206                assemble_ssh_signature(&verifying_key, "git", message, &sig.to_bytes()).is_err()
207            );
208        }
209        assert!(assemble_ssh_signature(&verifying_key, "git", message, &[0u8; 12]).is_err());
210    }
211
212    #[test]
213    fn test_ssh_string_encoding() {
214        let mut buf = Vec::new();
215        write_ssh_string(&mut buf, b"ssh-ed25519");
216        assert_eq!(buf.len(), 4 + 11);
217        assert_eq!(&buf[..4], &[0, 0, 0, 11]);
218        assert_eq!(&buf[4..], b"ssh-ed25519");
219    }
220
221    #[test]
222    fn test_pubkey_blob_format() {
223        let seed = [0u8; 32];
224        let signing_key = SigningKey::from_bytes(&seed);
225        let verifying_key = signing_key.verifying_key();
226        let blob = encode_ssh_ed25519_pubkey(&verifying_key);
227        // "ssh-ed25519" (4+11) + pubkey (4+32) = 51 bytes
228        assert_eq!(blob.len(), 51);
229    }
230
231    #[test]
232    fn test_signature_is_valid_sshsig() {
233        let seed = [42u8; 32];
234        let signing_key = SigningKey::from_bytes(&seed);
235        let verifying_key = signing_key.verifying_key();
236        let result = create_ssh_signature(&signing_key, &verifying_key, "git", b"test commit data");
237        assert!(result.is_ok());
238        let armored = result.unwrap();
239        assert!(armored.starts_with("-----BEGIN SSH SIGNATURE-----\n"));
240        assert!(armored.ends_with("-----END SSH SIGNATURE-----\n"));
241    }
242
243    #[test]
244    fn test_sshsig_blob_contains_magic_and_version() {
245        let seed = [7u8; 32];
246        let signing_key = SigningKey::from_bytes(&seed);
247        let verifying_key = signing_key.verifying_key();
248        let armored = create_ssh_signature(&signing_key, &verifying_key, "git", b"hello").unwrap();
249
250        // Extract base64 content between the armor headers
251        let b64: String = armored
252            .lines()
253            .filter(|l| !l.starts_with("-----"))
254            .collect();
255        let blob =
256            base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &b64).unwrap();
257
258        // First 6 bytes must be "SSHSIG" magic
259        assert_eq!(&blob[..6], b"SSHSIG");
260        // Next 4 bytes must be version 1 (big-endian u32)
261        assert_eq!(&blob[6..10], &[0, 0, 0, 1]);
262    }
263
264    #[test]
265    fn test_signature_deterministic_for_same_inputs() {
266        let seed = [99u8; 32];
267        let signing_key = SigningKey::from_bytes(&seed);
268        let verifying_key = signing_key.verifying_key();
269        let msg = b"same message";
270
271        let sig1 = create_ssh_signature(&signing_key, &verifying_key, "git", msg).unwrap();
272        let sig2 = create_ssh_signature(&signing_key, &verifying_key, "git", msg).unwrap();
273        // Ed25519 signatures are deterministic
274        assert_eq!(sig1, sig2);
275    }
276
277    #[test]
278    fn test_signature_differs_for_different_messages() {
279        let seed = [55u8; 32];
280        let signing_key = SigningKey::from_bytes(&seed);
281        let verifying_key = signing_key.verifying_key();
282
283        let sig1 = create_ssh_signature(&signing_key, &verifying_key, "git", b"msg A").unwrap();
284        let sig2 = create_ssh_signature(&signing_key, &verifying_key, "git", b"msg B").unwrap();
285        assert_ne!(sig1, sig2);
286    }
287
288    #[test]
289    fn test_signature_differs_for_different_namespaces() {
290        let seed = [88u8; 32];
291        let signing_key = SigningKey::from_bytes(&seed);
292        let verifying_key = signing_key.verifying_key();
293        let msg = b"same data";
294
295        let sig1 = create_ssh_signature(&signing_key, &verifying_key, "git", msg).unwrap();
296        let sig2 = create_ssh_signature(&signing_key, &verifying_key, "file", msg).unwrap();
297        assert_ne!(sig1, sig2);
298    }
299
300    #[test]
301    fn test_signature_blob_wraps_at_70_like_openssh() {
302        let seed = [1u8; 32];
303        let signing_key = SigningKey::from_bytes(&seed);
304        let verifying_key = signing_key.verifying_key();
305        let armored =
306            create_ssh_signature(&signing_key, &verifying_key, "git", b"check line wrap").unwrap();
307
308        let body: Vec<&str> = armored
309            .lines()
310            .filter(|line| !line.starts_with("-----"))
311            .collect();
312        // Every full line is exactly 70 columns (only the last may be
313        // shorter) — the width ssh-keygen emits and strict PEM parsers
314        // (RustCrypto ssh-encoding) require.
315        for line in &body[..body.len() - 1] {
316            assert_eq!(line.len(), 70, "base64 line is {} chars", line.len());
317        }
318        assert!(body[body.len() - 1].len() <= 70);
319    }
320
321    #[test]
322    fn test_write_u32_big_endian() {
323        let mut buf = Vec::new();
324        write_u32(&mut buf, 0x01020304);
325        assert_eq!(buf, vec![0x01, 0x02, 0x03, 0x04]);
326    }
327
328    #[test]
329    fn test_signature_blob_encoding() {
330        use ed25519_dalek::Signer;
331        let seed = [0xBB; 32];
332        let signing_key = SigningKey::from_bytes(&seed);
333        let sig = signing_key.sign(b"test");
334        let blob = encode_ssh_ed25519_signature(&sig);
335        // "ssh-ed25519" (4+11) + signature (4+64) = 83 bytes
336        assert_eq!(blob.len(), 83);
337        // Type string is "ssh-ed25519"
338        assert_eq!(&blob[4..15], b"ssh-ed25519");
339    }
340}