chie_crypto/
openssh.rs

1//! OpenSSH Key Format Support
2//!
3//! This module provides functionality to import and export Ed25519 keys in OpenSSH formats.
4//! Supports both public key format (ssh-ed25519) and private key format (OpenSSH private key).
5//!
6//! # Examples
7//!
8//! ```
9//! use chie_crypto::openssh::{SshPublicKey, SshPrivateKey};
10//! use chie_crypto::signing::KeyPair;
11//!
12//! // Generate a keypair
13//! let keypair = KeyPair::generate();
14//!
15//! // Export as SSH public key
16//! let ssh_pub = SshPublicKey::from_ed25519(&keypair.public_key());
17//! let ssh_pub_str = ssh_pub.to_string_with_comment("user@host");
18//!
19//! // Parse SSH public key
20//! let parsed = SshPublicKey::parse(&ssh_pub_str).unwrap();
21//! assert_eq!(parsed.key_data, ssh_pub.key_data);
22//!
23//! // Export as SSH private key
24//! let ssh_priv = SshPrivateKey::from_ed25519(&keypair);
25//! let pem = ssh_priv.to_pem();
26//! ```
27
28use crate::signing::{KeyPair, PublicKey};
29use base64::{Engine, engine::general_purpose::STANDARD};
30use serde::{Deserialize, Serialize};
31use std::fmt;
32use thiserror::Error;
33
34/// SSH key format errors
35#[derive(Debug, Error, Clone, PartialEq, Eq)]
36pub enum SshKeyError {
37    /// Invalid SSH key format
38    #[error("Invalid SSH key format: {0}")]
39    InvalidFormat(String),
40
41    /// Invalid key length
42    #[error("Invalid key length: expected {expected}, got {actual}")]
43    InvalidLength { expected: usize, actual: usize },
44
45    /// Unsupported algorithm
46    #[error("Unsupported algorithm: {0}")]
47    UnsupportedAlgorithm(String),
48
49    /// Base64 decode error
50    #[error("Base64 decode error: {0}")]
51    Base64Error(String),
52
53    /// Unexpected end of data
54    #[error("Unexpected end of data")]
55    UnexpectedEof,
56
57    /// UTF-8 decode error
58    #[error("UTF-8 decode error: {0}")]
59    Utf8Error(String),
60
61    /// Invalid secret key
62    #[error("Invalid secret key")]
63    InvalidSecretKey,
64}
65
66/// Result type for SSH key operations
67pub type SshKeyResult<T> = Result<T, SshKeyError>;
68
69const SSH_ED25519_KEY_TYPE: &str = "ssh-ed25519";
70const OPENSSH_PRIVATE_KEY_HEADER: &str = "-----BEGIN OPENSSH PRIVATE KEY-----";
71const OPENSSH_PRIVATE_KEY_FOOTER: &str = "-----END OPENSSH PRIVATE KEY-----";
72const OPENSSH_AUTH_MAGIC: &[u8] = b"openssh-key-v1\0";
73
74/// SSH public key in OpenSSH format
75#[derive(Clone, Debug, Serialize, Deserialize)]
76pub struct SshPublicKey {
77    /// Algorithm identifier (e.g., "ssh-ed25519")
78    pub algorithm: String,
79    /// Raw key data (32 bytes for Ed25519)
80    pub key_data: Vec<u8>,
81    /// Optional comment
82    pub comment: Option<String>,
83}
84
85impl SshPublicKey {
86    /// Create SSH public key from Ed25519 public key
87    pub fn from_ed25519(public_key: &PublicKey) -> Self {
88        Self {
89            algorithm: SSH_ED25519_KEY_TYPE.to_string(),
90            key_data: public_key.to_vec(),
91            comment: None,
92        }
93    }
94
95    /// Set comment for the SSH key
96    pub fn with_comment(mut self, comment: impl Into<String>) -> Self {
97        self.comment = Some(comment.into());
98        self
99    }
100
101    /// Convert to OpenSSH public key format
102    ///
103    /// Format: `ssh-ed25519 <base64-encoded-key> [optional-comment]`
104    pub fn to_openssh_format(&self) -> Vec<u8> {
105        let mut buf = Vec::new();
106
107        // Write algorithm length and data
108        write_string(&mut buf, &self.algorithm);
109
110        // Write key data length and data
111        write_bytes(&mut buf, &self.key_data);
112
113        buf
114    }
115
116    /// Encode as OpenSSH public key string
117    pub fn to_string_with_comment(&self, comment: &str) -> String {
118        let encoded = STANDARD.encode(self.to_openssh_format());
119        format!("{} {} {}", self.algorithm, encoded, comment)
120    }
121
122    /// Encode as OpenSSH public key string without comment
123    pub fn to_string_no_comment(&self) -> String {
124        let encoded = STANDARD.encode(self.to_openssh_format());
125        format!("{} {}", self.algorithm, encoded)
126    }
127
128    /// Parse OpenSSH public key from string
129    ///
130    /// Format: `ssh-ed25519 <base64-encoded-key> [optional-comment]`
131    pub fn parse(s: &str) -> SshKeyResult<Self> {
132        let parts: Vec<&str> = s.split_whitespace().collect();
133
134        if parts.len() < 2 {
135            return Err(SshKeyError::InvalidFormat(
136                "Invalid SSH public key format".to_string(),
137            ));
138        }
139
140        let algorithm = parts[0].to_string();
141        let encoded = parts[1];
142        let comment = if parts.len() > 2 {
143            Some(parts[2..].join(" "))
144        } else {
145            None
146        };
147
148        // Decode base64
149        let data = STANDARD
150            .decode(encoded)
151            .map_err(|e| SshKeyError::Base64Error(e.to_string()))?;
152
153        // Parse the binary format
154        let mut offset = 0;
155        let parsed_algo = read_string(&data, &mut offset)?;
156
157        if parsed_algo != algorithm {
158            return Err(SshKeyError::InvalidFormat(format!(
159                "Algorithm mismatch: {} vs {}",
160                parsed_algo, algorithm
161            )));
162        }
163
164        let key_data = read_bytes(&data, &mut offset)?;
165
166        if algorithm == SSH_ED25519_KEY_TYPE && key_data.len() != 32 {
167            return Err(SshKeyError::InvalidLength {
168                expected: 32,
169                actual: key_data.len(),
170            });
171        }
172
173        Ok(Self {
174            algorithm,
175            key_data,
176            comment,
177        })
178    }
179
180    /// Convert to Ed25519 public key
181    pub fn to_ed25519(&self) -> SshKeyResult<PublicKey> {
182        if self.algorithm != SSH_ED25519_KEY_TYPE {
183            return Err(SshKeyError::UnsupportedAlgorithm(self.algorithm.clone()));
184        }
185
186        if self.key_data.len() != 32 {
187            return Err(SshKeyError::InvalidLength {
188                expected: 32,
189                actual: self.key_data.len(),
190            });
191        }
192
193        let mut bytes = [0u8; 32];
194        bytes.copy_from_slice(&self.key_data);
195        Ok(bytes)
196    }
197}
198
199impl fmt::Display for SshPublicKey {
200    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
201        if let Some(comment) = &self.comment {
202            write!(f, "{}", self.to_string_with_comment(comment))
203        } else {
204            write!(f, "{}", self.to_string_no_comment())
205        }
206    }
207}
208
209/// SSH private key in OpenSSH format
210#[derive(Clone, Serialize, Deserialize)]
211pub struct SshPrivateKey {
212    /// Public key data
213    pub public_key: Vec<u8>,
214    /// Private key data (64 bytes for Ed25519: 32 bytes seed + 32 bytes public)
215    pub private_key: Vec<u8>,
216    /// Optional comment
217    pub comment: Option<String>,
218}
219
220impl SshPrivateKey {
221    /// Create SSH private key from Ed25519 keypair
222    pub fn from_ed25519(keypair: &KeyPair) -> Self {
223        // OpenSSH format stores 64 bytes: 32-byte seed + 32-byte public key
224        let secret = keypair.secret_key();
225        let public = keypair.public_key();
226        let mut private_key = Vec::with_capacity(64);
227        private_key.extend_from_slice(&secret);
228        private_key.extend_from_slice(&public);
229
230        Self {
231            public_key: public.to_vec(),
232            private_key,
233            comment: None,
234        }
235    }
236
237    /// Set comment for the SSH key
238    pub fn with_comment(mut self, comment: impl Into<String>) -> Self {
239        self.comment = Some(comment.into());
240        self
241    }
242
243    /// Convert to OpenSSH private key format (binary)
244    pub fn to_openssh_format(&self) -> Vec<u8> {
245        let mut buf = Vec::new();
246
247        // Magic header
248        buf.extend_from_slice(OPENSSH_AUTH_MAGIC);
249
250        // Cipher name (none - unencrypted)
251        write_string(&mut buf, "none");
252
253        // KDF name (none)
254        write_string(&mut buf, "none");
255
256        // KDF options (empty)
257        write_string(&mut buf, "");
258
259        // Number of keys (1)
260        buf.extend_from_slice(&1u32.to_be_bytes());
261
262        // Public key section
263        let mut public_section = Vec::new();
264        write_string(&mut public_section, SSH_ED25519_KEY_TYPE);
265        write_bytes(&mut public_section, &self.public_key);
266        write_bytes(&mut buf, &public_section);
267
268        // Private key section
269        let mut private_section = Vec::new();
270
271        // Check integers (for encryption detection, both should be same)
272        let check = rand::random::<u32>();
273        private_section.extend_from_slice(&check.to_be_bytes());
274        private_section.extend_from_slice(&check.to_be_bytes());
275
276        // Algorithm
277        write_string(&mut private_section, SSH_ED25519_KEY_TYPE);
278
279        // Public key
280        write_bytes(&mut private_section, &self.public_key);
281
282        // Private key (Ed25519: 64 bytes)
283        write_bytes(&mut private_section, &self.private_key);
284
285        // Comment
286        write_string(&mut private_section, self.comment.as_deref().unwrap_or(""));
287
288        // Padding to block size (8 bytes)
289        let padding_len = 8 - (private_section.len() % 8);
290        for i in 1..=padding_len {
291            private_section.push(i as u8);
292        }
293
294        write_bytes(&mut buf, &private_section);
295
296        buf
297    }
298
299    /// Encode as PEM format
300    pub fn to_pem(&self) -> String {
301        let data = self.to_openssh_format();
302        let encoded = STANDARD.encode(&data);
303
304        // Split into 70-character lines
305        let mut result = String::new();
306        result.push_str(OPENSSH_PRIVATE_KEY_HEADER);
307        result.push('\n');
308
309        for chunk in encoded.as_bytes().chunks(70) {
310            result.push_str(std::str::from_utf8(chunk).unwrap());
311            result.push('\n');
312        }
313
314        result.push_str(OPENSSH_PRIVATE_KEY_FOOTER);
315        result.push('\n');
316
317        result
318    }
319
320    /// Parse OpenSSH private key from PEM format
321    pub fn from_pem(pem: &str) -> SshKeyResult<Self> {
322        // Remove headers and whitespace
323        let pem = pem
324            .lines()
325            .filter(|line| {
326                !line.contains("BEGIN OPENSSH PRIVATE KEY")
327                    && !line.contains("END OPENSSH PRIVATE KEY")
328            })
329            .collect::<String>();
330
331        let data = STANDARD
332            .decode(pem.trim())
333            .map_err(|e| SshKeyError::Base64Error(e.to_string()))?;
334
335        Self::parse_binary(&data)
336    }
337
338    /// Parse binary OpenSSH private key format
339    pub fn parse_binary(data: &[u8]) -> SshKeyResult<Self> {
340        let mut offset = 0;
341
342        // Check magic
343        if data.len() < OPENSSH_AUTH_MAGIC.len()
344            || &data[..OPENSSH_AUTH_MAGIC.len()] != OPENSSH_AUTH_MAGIC
345        {
346            return Err(SshKeyError::InvalidFormat(
347                "Invalid OpenSSH private key magic".to_string(),
348            ));
349        }
350        offset += OPENSSH_AUTH_MAGIC.len();
351
352        // Read cipher (should be "none" for unencrypted)
353        let cipher = read_string(data, &mut offset)?;
354        if cipher != "none" {
355            return Err(SshKeyError::InvalidFormat(
356                "Encrypted SSH keys not supported yet".to_string(),
357            ));
358        }
359
360        // Read KDF (should be "none")
361        let _kdf = read_string(data, &mut offset)?;
362
363        // Read KDF options (should be empty)
364        let _kdf_options = read_bytes(data, &mut offset)?;
365
366        // Read number of keys
367        let num_keys = read_u32(data, &mut offset)?;
368        if num_keys != 1 {
369            return Err(SshKeyError::InvalidFormat(format!(
370                "Expected 1 key, found {}",
371                num_keys
372            )));
373        }
374
375        // Read public key section
376        let _public_section = read_bytes(data, &mut offset)?;
377
378        // Read private key section
379        let private_section = read_bytes(data, &mut offset)?;
380        let mut priv_offset = 0;
381
382        // Check integers
383        let check1 = read_u32(&private_section, &mut priv_offset)?;
384        let check2 = read_u32(&private_section, &mut priv_offset)?;
385        if check1 != check2 {
386            return Err(SshKeyError::InvalidFormat(
387                "Checksum mismatch (possibly encrypted)".to_string(),
388            ));
389        }
390
391        // Read algorithm
392        let algorithm = read_string(&private_section, &mut priv_offset)?;
393        if algorithm != SSH_ED25519_KEY_TYPE {
394            return Err(SshKeyError::UnsupportedAlgorithm(algorithm));
395        }
396
397        // Read public key
398        let public_key = read_bytes(&private_section, &mut priv_offset)?;
399
400        // Read private key
401        let private_key = read_bytes(&private_section, &mut priv_offset)?;
402
403        // Read comment
404        let comment = read_string(&private_section, &mut priv_offset)?;
405        let comment = if comment.is_empty() {
406            None
407        } else {
408            Some(comment)
409        };
410
411        Ok(Self {
412            public_key,
413            private_key,
414            comment,
415        })
416    }
417
418    /// Convert to Ed25519 keypair
419    pub fn to_ed25519(&self) -> SshKeyResult<KeyPair> {
420        if self.private_key.len() != 64 {
421            return Err(SshKeyError::InvalidLength {
422                expected: 64,
423                actual: self.private_key.len(),
424            });
425        }
426
427        // Extract the first 32 bytes (the seed/secret key)
428        let mut secret = [0u8; 32];
429        secret.copy_from_slice(&self.private_key[..32]);
430
431        KeyPair::from_secret_key(&secret).map_err(|_| SshKeyError::InvalidSecretKey)
432    }
433}
434
435// Helper functions for SSH binary format
436
437fn write_string(buf: &mut Vec<u8>, s: &str) {
438    let bytes = s.as_bytes();
439    buf.extend_from_slice(&(bytes.len() as u32).to_be_bytes());
440    buf.extend_from_slice(bytes);
441}
442
443fn write_bytes(buf: &mut Vec<u8>, data: &[u8]) {
444    buf.extend_from_slice(&(data.len() as u32).to_be_bytes());
445    buf.extend_from_slice(data);
446}
447
448fn read_u32(data: &[u8], offset: &mut usize) -> SshKeyResult<u32> {
449    if *offset + 4 > data.len() {
450        return Err(SshKeyError::UnexpectedEof);
451    }
452
453    let mut bytes = [0u8; 4];
454    bytes.copy_from_slice(&data[*offset..*offset + 4]);
455    *offset += 4;
456
457    Ok(u32::from_be_bytes(bytes))
458}
459
460fn read_string(data: &[u8], offset: &mut usize) -> SshKeyResult<String> {
461    let bytes = read_bytes(data, offset)?;
462    String::from_utf8(bytes).map_err(|e| SshKeyError::Utf8Error(e.to_string()))
463}
464
465fn read_bytes(data: &[u8], offset: &mut usize) -> SshKeyResult<Vec<u8>> {
466    let len = read_u32(data, offset)? as usize;
467
468    if *offset + len > data.len() {
469        return Err(SshKeyError::UnexpectedEof);
470    }
471
472    let bytes = data[*offset..*offset + len].to_vec();
473    *offset += len;
474
475    Ok(bytes)
476}
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481
482    #[test]
483    fn test_ssh_public_key_roundtrip() {
484        let keypair = KeyPair::generate();
485        let ssh_pub = SshPublicKey::from_ed25519(&keypair.public_key());
486
487        let formatted = ssh_pub.to_string_with_comment("test@host");
488        let parsed = SshPublicKey::parse(&formatted).unwrap();
489
490        assert_eq!(parsed.algorithm, SSH_ED25519_KEY_TYPE);
491        assert_eq!(parsed.key_data, ssh_pub.key_data);
492        assert_eq!(parsed.comment, Some("test@host".to_string()));
493    }
494
495    #[test]
496    fn test_ssh_public_key_no_comment() {
497        let keypair = KeyPair::generate();
498        let ssh_pub = SshPublicKey::from_ed25519(&keypair.public_key());
499
500        let formatted = ssh_pub.to_string_no_comment();
501        let parsed = SshPublicKey::parse(&formatted).unwrap();
502
503        assert_eq!(parsed.key_data, ssh_pub.key_data);
504        assert_eq!(parsed.comment, None);
505    }
506
507    #[test]
508    fn test_ssh_public_key_to_ed25519() {
509        let keypair = KeyPair::generate();
510        let ssh_pub = SshPublicKey::from_ed25519(&keypair.public_key());
511
512        let ed25519_pub = ssh_pub.to_ed25519().unwrap();
513        assert_eq!(&ed25519_pub, &keypair.public_key());
514    }
515
516    #[test]
517    fn test_ssh_private_key_pem_roundtrip() {
518        let keypair = KeyPair::generate();
519        let ssh_priv = SshPrivateKey::from_ed25519(&keypair).with_comment("test@host");
520
521        let pem = ssh_priv.to_pem();
522        assert!(pem.contains(OPENSSH_PRIVATE_KEY_HEADER));
523        assert!(pem.contains(OPENSSH_PRIVATE_KEY_FOOTER));
524
525        let parsed = SshPrivateKey::from_pem(&pem).unwrap();
526        assert_eq!(parsed.public_key, ssh_priv.public_key);
527        assert_eq!(parsed.private_key, ssh_priv.private_key);
528        assert_eq!(parsed.comment, ssh_priv.comment);
529    }
530
531    #[test]
532    fn test_ssh_private_key_to_ed25519() {
533        let keypair = KeyPair::generate();
534        let ssh_priv = SshPrivateKey::from_ed25519(&keypair);
535
536        let recovered = ssh_priv.to_ed25519().unwrap();
537        assert_eq!(&recovered.public_key(), &keypair.public_key());
538        assert_eq!(recovered.secret_key(), keypair.secret_key());
539    }
540
541    #[test]
542    fn test_ssh_keys_compatibility() {
543        let keypair = KeyPair::generate();
544
545        // Export both public and private
546        let ssh_pub = SshPublicKey::from_ed25519(&keypair.public_key());
547        let ssh_priv = SshPrivateKey::from_ed25519(&keypair);
548
549        // Ensure public keys match
550        assert_eq!(ssh_pub.key_data, ssh_priv.public_key);
551
552        // Recover keypair from private key
553        let recovered = ssh_priv.to_ed25519().unwrap();
554        assert_eq!(&recovered.public_key(), &keypair.public_key());
555    }
556
557    #[test]
558    fn test_invalid_ssh_public_key() {
559        assert!(SshPublicKey::parse("invalid").is_err());
560        assert!(SshPublicKey::parse("ssh-ed25519").is_err());
561        assert!(SshPublicKey::parse("ssh-ed25519 !!!invalid-base64!!!").is_err());
562    }
563
564    #[test]
565    fn test_ssh_public_key_with_multiword_comment() {
566        let keypair = KeyPair::generate();
567        let ssh_pub = SshPublicKey::from_ed25519(&keypair.public_key());
568
569        let formatted = ssh_pub.to_string_with_comment("user@host with spaces");
570        let parsed = SshPublicKey::parse(&formatted).unwrap();
571
572        assert_eq!(parsed.comment, Some("user@host with spaces".to_string()));
573    }
574
575    #[test]
576    fn test_openssh_format_structure() {
577        let keypair = KeyPair::generate();
578        let ssh_priv = SshPrivateKey::from_ed25519(&keypair);
579
580        let binary = ssh_priv.to_openssh_format();
581
582        // Check magic
583        assert_eq!(&binary[..OPENSSH_AUTH_MAGIC.len()], OPENSSH_AUTH_MAGIC);
584    }
585
586    #[test]
587    fn test_write_read_string() {
588        let mut buf = Vec::new();
589        write_string(&mut buf, "test");
590
591        let mut offset = 0;
592        let s = read_string(&buf, &mut offset).unwrap();
593        assert_eq!(s, "test");
594        assert_eq!(offset, buf.len());
595    }
596
597    #[test]
598    fn test_write_read_bytes() {
599        let mut buf = Vec::new();
600        let data = vec![1, 2, 3, 4, 5];
601        write_bytes(&mut buf, &data);
602
603        let mut offset = 0;
604        let read = read_bytes(&buf, &mut offset).unwrap();
605        assert_eq!(read, data);
606        assert_eq!(offset, buf.len());
607    }
608
609    #[test]
610    fn test_pem_line_wrapping() {
611        let keypair = KeyPair::generate();
612        let ssh_priv = SshPrivateKey::from_ed25519(&keypair);
613        let pem = ssh_priv.to_pem();
614
615        // Check that lines are wrapped (no line should be > 71 chars including newline)
616        for line in pem.lines() {
617            if !line.contains("BEGIN") && !line.contains("END") {
618                assert!(line.len() <= 70, "Line too long: {}", line.len());
619            }
620        }
621    }
622
623    #[test]
624    fn test_serialization() {
625        let keypair = KeyPair::generate();
626        let ssh_pub = SshPublicKey::from_ed25519(&keypair.public_key()).with_comment("test@host");
627
628        let serialized = crate::codec::encode(&ssh_pub).unwrap();
629        let deserialized: SshPublicKey = crate::codec::decode(&serialized).unwrap();
630
631        assert_eq!(deserialized.algorithm, ssh_pub.algorithm);
632        assert_eq!(deserialized.key_data, ssh_pub.key_data);
633        assert_eq!(deserialized.comment, ssh_pub.comment);
634    }
635}