Skip to main content

auths_verifier/
ssh_sig.rs

1//! SSHSIG envelope parsing for SSH signature verification.
2//!
3//! Parses `-----BEGIN SSH SIGNATURE-----` PEM blocks into their binary
4//! components per the [SSHSIG protocol](https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.sshsig).
5//!
6//! Supports both `ssh-ed25519` and `ecdsa-sha2-nistp256` key types.
7
8use 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/// SSH key algorithm detected from the SSHSIG envelope.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum SshKeyType {
23    /// Ed25519 (32-byte key, 64-byte signature).
24    Ed25519,
25    /// ECDSA with NIST P-256 (uncompressed 65-byte key, 64-byte r||s signature).
26    EcdsaP256,
27}
28
29/// Parsed SSHSIG envelope fields.
30///
31/// Usage:
32/// ```ignore
33/// let envelope = parse_sshsig_pem(pem_text)?;
34/// assert_eq!(envelope.namespace, "git");
35/// ```
36#[derive(Debug)]
37pub struct SshSigEnvelope {
38    /// The namespace the signature was created for (e.g. "git").
39    pub namespace: String,
40    /// The hash algorithm used (e.g. "sha512" or "sha256").
41    pub hash_algorithm: String,
42    /// The SSH key algorithm used.
43    pub key_type: SshKeyType,
44    /// Public key extracted from the envelope (32 bytes Ed25519, or 65 bytes P-256 uncompressed).
45    pub public_key: DevicePublicKey,
46    /// Raw signature bytes (64 bytes for both Ed25519 and P-256 r||s).
47    pub signature: Vec<u8>,
48}
49
50/// Parse an SSH signature PEM block into its components.
51///
52/// Args:
53/// * `pem`: The full PEM text including BEGIN/END markers.
54///
55/// Usage:
56/// ```ignore
57/// let envelope = parse_sshsig_pem(pem_text)?;
58/// ```
59pub 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    // Magic preamble: raw "SSHSIG" (6 bytes, NOT length-prefixed)
97    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    // Version: u32 BE
105    let version = cursor.read_u32()?;
106    if version != SSHSIG_VERSION {
107        return Err(CommitVerificationError::SshSigParseFailed(format!(
108            "unsupported version: {version}"
109        )));
110    }
111
112    // Public key blob (SSH wire format, length-prefixed)
113    let pubkey_blob = cursor.read_string()?;
114    let (key_type, public_key) = parse_pubkey_blob(&pubkey_blob)?;
115
116    // Namespace
117    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    // Reserved (ignored)
128    let _reserved = cursor.read_string()?;
129
130    // Hash algorithm
131    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    // Signature blob (SSH wire format, length-prefixed)
137    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
238/// Parse ECDSA signature inner blob (mpint r + mpint s) into raw 64-byte r||s.
239fn 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
249/// Read an SSH mpint and convert to a fixed-width big-endian byte array.
250fn 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
272// Minimal binary cursor for SSH wire format parsing.
273struct 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        // Magic
320        blob.extend_from_slice(b"SSHSIG");
321        // Version
322        blob.extend_from_slice(&1u32.to_be_bytes());
323
324        // Public key blob
325        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        // Namespace
335        blob.extend_from_slice(&(namespace.len() as u32).to_be_bytes());
336        blob.extend_from_slice(namespace.as_bytes());
337
338        // Reserved
339        blob.extend_from_slice(&0u32.to_be_bytes());
340
341        // Hash algorithm
342        blob.extend_from_slice(&(hash_algo.len() as u32).to_be_bytes());
343        blob.extend_from_slice(hash_algo.as_bytes());
344
345        // Signature blob
346        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'; // corrupt magic
392        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        // Version is at offset 6..10, set to 2
402        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        // RSA public key blob
425        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]); // truncate
455        let err = parse_sshsig_pem(&pem).unwrap_err();
456        assert!(err.to_string().contains("unexpected EOF"));
457    }
458}