1use anyhow::Result;
9use ed25519_dalek::SigningKey;
10use sha2::{Digest, Sha512};
11
12pub const GIT_SSHSIG_NAMESPACE: &str = "git";
14
15const SSHSIG_MAGIC: &[u8; 6] = b"SSHSIG";
17
18pub 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
34pub fn sshsig_message_hash(message: &[u8]) -> [u8; 64] {
37 Sha512::digest(message).into()
38}
39
40pub 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""); write_ssh_string(&mut signed_data, b"sha512");
57 write_ssh_string(&mut signed_data, message_hash);
58 signed_data
59}
60
61pub 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
88fn 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); 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);
119 let mut armored = String::new();
120 armored.push_str("-----BEGIN SSH SIGNATURE-----\n");
121 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
132fn 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
142fn 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
152fn write_u32(buf: &mut Vec<u8>, val: u32) {
154 buf.extend_from_slice(&val.to_be_bytes());
155}
156
157fn 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
163fn 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 #[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 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 #[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 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 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 assert_eq!(&blob[..6], b"SSHSIG");
260 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 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 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 assert_eq!(blob.len(), 83);
337 assert_eq!(&blob[4..15], b"ssh-ed25519");
339 }
340}