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(
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 let message_hash = Sha512::digest(message);
45
46 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""); write_ssh_string(&mut signed_data, b"sha512");
52 write_ssh_string(&mut signed_data, &message_hash);
53
54 let sig = signing_key.sign(&signed_data);
56
57 let pubkey_blob = encode_ssh_ed25519_pubkey(verifying_key);
59
60 let sig_blob = encode_ssh_ed25519_signature(&sig);
62
63 let mut sshsig_blob = Vec::new();
65 sshsig_blob.extend_from_slice(SSHSIG_MAGIC);
66 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);
76 let mut armored = String::new();
77 armored.push_str("-----BEGIN SSH SIGNATURE-----\n");
78 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
90fn 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
100fn 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
110fn write_u32(buf: &mut Vec<u8>, val: u32) {
112 buf.extend_from_slice(&val.to_be_bytes());
113}
114
115fn 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
121fn 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 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 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 assert_eq!(&blob[..6], b"SSHSIG");
179 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 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 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 assert_eq!(blob.len(), 83);
256 assert_eq!(&blob[4..15], b"ssh-ed25519");
258 }
259}