1use base64::Engine;
9
10use crate::commit_error::CommitVerificationError;
11use crate::core::DevicePublicKey;
12
13const SSHSIG_MAGIC: &[u8] = b"SSHSIG";
14const SSHSIG_VERSION: u32 = 1;
15const ED25519_KEY_TYPE: &str = "ssh-ed25519";
16const ECDSA_P256_KEY_TYPE: &str = "ecdsa-sha2-nistp256";
17const PEM_BEGIN: &str = "-----BEGIN SSH SIGNATURE-----";
18const PEM_END: &str = "-----END SSH SIGNATURE-----";
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum SshKeyType {
23 Ed25519,
25 EcdsaP256,
27}
28
29#[derive(Debug)]
37pub struct SshSigEnvelope {
38 pub namespace: String,
40 pub hash_algorithm: String,
42 pub key_type: SshKeyType,
44 pub public_key: DevicePublicKey,
46 pub signature: Vec<u8>,
48}
49
50pub fn parse_sshsig_pem(pem: &str) -> Result<SshSigEnvelope, CommitVerificationError> {
60 let b64_body = extract_pem_body(pem)?;
61 let der = base64::engine::general_purpose::STANDARD
62 .decode(&b64_body)
63 .map_err(|e| CommitVerificationError::SshSigParseFailed(format!("base64 decode: {e}")))?;
64 parse_sshsig_binary(&der)
65}
66
67fn extract_pem_body(pem: &str) -> Result<String, CommitVerificationError> {
68 let mut in_body = false;
69 let mut body = String::new();
70
71 for line in pem.lines() {
72 let trimmed = line.trim();
73 if trimmed == PEM_BEGIN {
74 in_body = true;
75 continue;
76 }
77 if trimmed == PEM_END {
78 break;
79 }
80 if in_body {
81 body.push_str(trimmed);
82 }
83 }
84
85 if body.is_empty() {
86 return Err(CommitVerificationError::SshSigParseFailed(
87 "no PEM body found".into(),
88 ));
89 }
90 Ok(body)
91}
92
93fn parse_sshsig_binary(data: &[u8]) -> Result<SshSigEnvelope, CommitVerificationError> {
94 let mut cursor = Cursor::new(data);
95
96 let magic = cursor.read_raw(6)?;
98 if magic != SSHSIG_MAGIC {
99 return Err(CommitVerificationError::SshSigParseFailed(
100 "invalid magic bytes".into(),
101 ));
102 }
103
104 let version = cursor.read_u32()?;
106 if version != SSHSIG_VERSION {
107 return Err(CommitVerificationError::SshSigParseFailed(format!(
108 "unsupported version: {version}"
109 )));
110 }
111
112 let pubkey_blob = cursor.read_string()?;
114 let (key_type, public_key) = parse_pubkey_blob(&pubkey_blob)?;
115
116 let namespace_bytes = cursor.read_string()?;
118 let namespace = String::from_utf8(namespace_bytes).map_err(|e| {
119 CommitVerificationError::SshSigParseFailed(format!("invalid namespace UTF-8: {e}"))
120 })?;
121 if namespace.is_empty() {
122 return Err(CommitVerificationError::SshSigParseFailed(
123 "empty namespace".into(),
124 ));
125 }
126
127 let _reserved = cursor.read_string()?;
129
130 let hash_algo_bytes = cursor.read_string()?;
132 let hash_algorithm = String::from_utf8(hash_algo_bytes).map_err(|e| {
133 CommitVerificationError::SshSigParseFailed(format!("invalid hash algorithm UTF-8: {e}"))
134 })?;
135
136 let sig_blob = cursor.read_string()?;
138 let signature = parse_sig_blob(&sig_blob, key_type)?;
139
140 Ok(SshSigEnvelope {
141 namespace,
142 hash_algorithm,
143 key_type,
144 public_key,
145 signature,
146 })
147}
148
149fn parse_pubkey_blob(
150 blob: &[u8],
151) -> Result<(SshKeyType, DevicePublicKey), CommitVerificationError> {
152 let mut cursor = Cursor::new(blob);
153
154 let key_type_bytes = cursor.read_string()?;
155 let key_type_str = String::from_utf8(key_type_bytes).map_err(|e| {
156 CommitVerificationError::SshSigParseFailed(format!("invalid key type UTF-8: {e}"))
157 })?;
158
159 match key_type_str.as_str() {
160 ED25519_KEY_TYPE => {
161 let raw_key = cursor.read_string()?;
162 if raw_key.len() != 32 {
163 return Err(CommitVerificationError::SshSigParseFailed(format!(
164 "invalid Ed25519 key length: expected 32, got {}",
165 raw_key.len()
166 )));
167 }
168 Ok((
169 SshKeyType::Ed25519,
170 DevicePublicKey::try_new(auths_crypto::CurveType::Ed25519, &raw_key)
171 .map_err(|e| CommitVerificationError::SshSigParseFailed(e.to_string()))?,
172 ))
173 }
174 ECDSA_P256_KEY_TYPE => {
175 let curve_name_bytes = cursor.read_string()?;
176 let curve_name = String::from_utf8(curve_name_bytes).map_err(|e| {
177 CommitVerificationError::SshSigParseFailed(format!("invalid curve name UTF-8: {e}"))
178 })?;
179 if curve_name != "nistp256" {
180 return Err(CommitVerificationError::SshSigParseFailed(format!(
181 "unexpected curve name: {curve_name}"
182 )));
183 }
184 let ec_point = cursor.read_string()?;
185 if ec_point.len() != 65 || ec_point[0] != 0x04 {
186 return Err(CommitVerificationError::SshSigParseFailed(format!(
187 "invalid P-256 EC point: expected 65-byte uncompressed (0x04 prefix), got {} bytes",
188 ec_point.len()
189 )));
190 }
191 Ok((
192 SshKeyType::EcdsaP256,
193 DevicePublicKey::try_new(auths_crypto::CurveType::P256, &ec_point)
194 .map_err(|e| CommitVerificationError::SshSigParseFailed(e.to_string()))?,
195 ))
196 }
197 other => Err(CommitVerificationError::UnsupportedKeyType {
198 found: other.to_string(),
199 }),
200 }
201}
202
203fn parse_sig_blob(
204 blob: &[u8],
205 expected_key_type: SshKeyType,
206) -> Result<Vec<u8>, CommitVerificationError> {
207 let mut cursor = Cursor::new(blob);
208
209 let sig_type_bytes = cursor.read_string()?;
210 let sig_type = String::from_utf8(sig_type_bytes).map_err(|e| {
211 CommitVerificationError::SshSigParseFailed(format!("invalid sig type UTF-8: {e}"))
212 })?;
213
214 match expected_key_type {
215 SshKeyType::Ed25519 => {
216 if sig_type != ED25519_KEY_TYPE {
217 return Err(CommitVerificationError::UnsupportedKeyType { found: sig_type });
218 }
219 let raw_sig = cursor.read_string()?;
220 if raw_sig.len() != 64 {
221 return Err(CommitVerificationError::SshSigParseFailed(format!(
222 "invalid Ed25519 signature length: expected 64, got {}",
223 raw_sig.len()
224 )));
225 }
226 Ok(raw_sig)
227 }
228 SshKeyType::EcdsaP256 => {
229 if sig_type != ECDSA_P256_KEY_TYPE {
230 return Err(CommitVerificationError::UnsupportedKeyType { found: sig_type });
231 }
232 let inner_blob = cursor.read_string()?;
233 parse_ecdsa_sig_inner(&inner_blob)
234 }
235 }
236}
237
238fn parse_ecdsa_sig_inner(blob: &[u8]) -> Result<Vec<u8>, CommitVerificationError> {
240 let mut cursor = Cursor::new(blob);
241 let r = mpint_to_fixed(&mut cursor, 32)?;
242 let s = mpint_to_fixed(&mut cursor, 32)?;
243 let mut raw = Vec::with_capacity(64);
244 raw.extend_from_slice(&r);
245 raw.extend_from_slice(&s);
246 Ok(raw)
247}
248
249fn mpint_to_fixed(
251 cursor: &mut Cursor<'_>,
252 size: usize,
253) -> Result<Vec<u8>, CommitVerificationError> {
254 let bytes = cursor.read_string()?;
255 let trimmed = if !bytes.is_empty() && bytes[0] == 0x00 {
256 &bytes[1..]
257 } else {
258 &bytes[..]
259 };
260 if trimmed.len() > size {
261 return Err(CommitVerificationError::SshSigParseFailed(format!(
262 "mpint too large: {} bytes, max {size}",
263 trimmed.len()
264 )));
265 }
266 let mut fixed = vec![0u8; size];
267 let offset = size - trimmed.len();
268 fixed[offset..].copy_from_slice(trimmed);
269 Ok(fixed)
270}
271
272struct Cursor<'a> {
274 data: &'a [u8],
275 pos: usize,
276}
277
278impl<'a> Cursor<'a> {
279 fn new(data: &'a [u8]) -> Self {
280 Self { data, pos: 0 }
281 }
282
283 fn read_raw(&mut self, n: usize) -> Result<&'a [u8], CommitVerificationError> {
284 if self.pos + n > self.data.len() {
285 return Err(CommitVerificationError::SshSigParseFailed(format!(
286 "unexpected EOF at offset {} (need {n} bytes, have {})",
287 self.pos,
288 self.data.len() - self.pos
289 )));
290 }
291 let slice = &self.data[self.pos..self.pos + n];
292 self.pos += n;
293 Ok(slice)
294 }
295
296 fn read_u32(&mut self) -> Result<u32, CommitVerificationError> {
297 let bytes = self.read_raw(4)?;
298 Ok(u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
299 }
300
301 fn read_string(&mut self) -> Result<Vec<u8>, CommitVerificationError> {
302 let len = self.read_u32()? as usize;
303 Ok(self.read_raw(len)?.to_vec())
304 }
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310
311 fn build_test_sshsig(
312 key: &[u8; 32],
313 sig: &[u8; 64],
314 namespace: &str,
315 hash_algo: &str,
316 ) -> Vec<u8> {
317 let mut blob = Vec::new();
318
319 blob.extend_from_slice(b"SSHSIG");
321 blob.extend_from_slice(&1u32.to_be_bytes());
323
324 let mut pk_blob = Vec::new();
326 let kt = b"ssh-ed25519";
327 pk_blob.extend_from_slice(&(kt.len() as u32).to_be_bytes());
328 pk_blob.extend_from_slice(kt);
329 pk_blob.extend_from_slice(&(key.len() as u32).to_be_bytes());
330 pk_blob.extend_from_slice(key);
331 blob.extend_from_slice(&(pk_blob.len() as u32).to_be_bytes());
332 blob.extend_from_slice(&pk_blob);
333
334 blob.extend_from_slice(&(namespace.len() as u32).to_be_bytes());
336 blob.extend_from_slice(namespace.as_bytes());
337
338 blob.extend_from_slice(&0u32.to_be_bytes());
340
341 blob.extend_from_slice(&(hash_algo.len() as u32).to_be_bytes());
343 blob.extend_from_slice(hash_algo.as_bytes());
344
345 let mut sig_blob = Vec::new();
347 let st = b"ssh-ed25519";
348 sig_blob.extend_from_slice(&(st.len() as u32).to_be_bytes());
349 sig_blob.extend_from_slice(st);
350 sig_blob.extend_from_slice(&(sig.len() as u32).to_be_bytes());
351 sig_blob.extend_from_slice(sig);
352 blob.extend_from_slice(&(sig_blob.len() as u32).to_be_bytes());
353 blob.extend_from_slice(&sig_blob);
354
355 blob
356 }
357
358 fn wrap_pem(binary: &[u8]) -> String {
359 let b64 = base64::engine::general_purpose::STANDARD.encode(binary);
360 let wrapped: String = b64
361 .chars()
362 .collect::<Vec<_>>()
363 .chunks(70)
364 .map(|c| c.iter().collect::<String>())
365 .collect::<Vec<_>>()
366 .join("\n");
367 format!("-----BEGIN SSH SIGNATURE-----\n{wrapped}\n-----END SSH SIGNATURE-----\n")
368 }
369
370 #[test]
371 fn parse_valid_envelope() {
372 let key = [0x42u8; 32];
373 let sig = [0xABu8; 64];
374 let binary = build_test_sshsig(&key, &sig, "git", "sha512");
375 let pem = wrap_pem(&binary);
376
377 let envelope = parse_sshsig_pem(&pem).unwrap();
378 assert_eq!(envelope.namespace, "git");
379 assert_eq!(envelope.hash_algorithm, "sha512");
380 assert_eq!(
381 envelope.public_key.curve(),
382 auths_crypto::CurveType::Ed25519
383 );
384 assert_eq!(envelope.public_key.as_bytes(), &key);
385 assert_eq!(envelope.signature, sig);
386 }
387
388 #[test]
389 fn rejects_invalid_magic() {
390 let mut binary = build_test_sshsig(&[0; 32], &[0; 64], "git", "sha512");
391 binary[0] = b'X'; let pem = wrap_pem(&binary);
393
394 let err = parse_sshsig_pem(&pem).unwrap_err();
395 assert!(err.to_string().contains("invalid magic"));
396 }
397
398 #[test]
399 fn rejects_unsupported_version() {
400 let mut binary = build_test_sshsig(&[0; 32], &[0; 64], "git", "sha512");
401 binary[6..10].copy_from_slice(&2u32.to_be_bytes());
403 let pem = wrap_pem(&binary);
404
405 let err = parse_sshsig_pem(&pem).unwrap_err();
406 assert!(err.to_string().contains("unsupported version"));
407 }
408
409 #[test]
410 fn rejects_empty_namespace() {
411 let binary = build_test_sshsig(&[0; 32], &[0; 64], "", "sha512");
412 let pem = wrap_pem(&binary);
413
414 let err = parse_sshsig_pem(&pem).unwrap_err();
415 assert!(err.to_string().contains("empty namespace"));
416 }
417
418 #[test]
419 fn rejects_non_ed25519_key_type() {
420 let mut blob = Vec::new();
421 blob.extend_from_slice(b"SSHSIG");
422 blob.extend_from_slice(&1u32.to_be_bytes());
423
424 let mut pk_blob = Vec::new();
426 let kt = b"ssh-rsa";
427 pk_blob.extend_from_slice(&(kt.len() as u32).to_be_bytes());
428 pk_blob.extend_from_slice(kt);
429 let fake_key = [0u8; 32];
430 pk_blob.extend_from_slice(&(fake_key.len() as u32).to_be_bytes());
431 pk_blob.extend_from_slice(&fake_key);
432 blob.extend_from_slice(&(pk_blob.len() as u32).to_be_bytes());
433 blob.extend_from_slice(&pk_blob);
434
435 let pem = wrap_pem(&blob);
436 let err = parse_sshsig_pem(&pem).unwrap_err();
437 match err {
438 CommitVerificationError::UnsupportedKeyType { found } => {
439 assert_eq!(found, "ssh-rsa");
440 }
441 other => panic!("expected UnsupportedKeyType, got: {other}"),
442 }
443 }
444
445 #[test]
446 fn rejects_empty_pem() {
447 let err = parse_sshsig_pem("").unwrap_err();
448 assert!(err.to_string().contains("no PEM body"));
449 }
450
451 #[test]
452 fn rejects_truncated_data() {
453 let binary = build_test_sshsig(&[0; 32], &[0; 64], "git", "sha512");
454 let pem = wrap_pem(&binary[..20]); let err = parse_sshsig_pem(&pem).unwrap_err();
456 assert!(err.to_string().contains("unexpected EOF"));
457 }
458}