Skip to main content

torrust_tracker_deployer_lib/adapters/ssh/
public_key.rs

1//! SSH public key representation and validation
2//!
3//! This module provides the `SshPublicKey` type for handling SSH public key values
4//! with proper validation and serialization support.
5
6use serde::{Deserialize, Serialize};
7use std::fmt;
8use std::str::FromStr;
9use thiserror::Error;
10
11/// Errors that can occur when working with SSH public keys
12#[derive(Error, Debug, Clone)]
13pub enum SshPublicKeyError {
14    #[error("SSH public key cannot be empty")]
15    Empty,
16
17    #[error("SSH public key format is invalid: {0}")]
18    InvalidFormat(String),
19}
20
21/// SSH public key representation using the newtype pattern
22///
23/// This type wraps a string containing a valid SSH public key and provides
24/// validation to ensure the key follows basic SSH public key format requirements.
25///
26/// # Example
27///
28/// ```rust
29/// use torrust_tracker_deployer_lib::adapters::ssh::SshPublicKey;
30///
31/// let key_str = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCw16sai+XVnawp/P/Q23kcXKekygZ6ALmQAyslREo6kbG8s5RScsmbQqOQEcIwnV2Vo88eeWVzX0N0H1dIczRa/ezijBEsGefthzmz9Ix/vM4lodzTPQFtW8c2eYw7ESy12/2x5//UQQ3mxawEWsz5Ri8XuyBEy/Xh7xH/KpoektaocIOt2/WdCe8CvZdMLd7AviGcTdHFWRiOVrmHM1Pd8znqeA3/1KQP/M4Ae5q21oPjchGjVfPkGh/e62Wt+Wo/2lT30AyMO7JHA1tB1W4xANRQkOd1Kb/TrDLXfg0PaHQ+Irmycjp/H4KkcdB06nzYawXMN5csd/5TWKwkb9/vofp6GQNP731U8+JR4cxRfD107KoHroDSJpG2Fanb2PVBkSXAiJl29YrtoP9vUtSIemQCD/aXFtTcpSv7Y16bdp7v+0adCEHwBmodm9GzLL808FpI2ZCzCi+Ae98P3z+yPCxbrnVAahU8AM2NSbrfyH1w2eb4hJ22oPjdd//tBYtkE1TZBw+i3n0vRn04s5BfPRwwj5GISxacTOZm/YWvoE4UU9axtFXOtMUniVKL3ycA+LEfK7C4velOKbluyL8fYYu4pUxHnYOOkYYeRoi2jf3oagbABOpznloPd93wYP3NoUpIdtMZW+iCF0NnZkVLC9lm1FbTcnmrfNzFtGVKCQ== testing@torrust-testing-infra";
32/// let public_key = SshPublicKey::new(key_str).unwrap();
33/// println!("{}", public_key.as_str());
34/// ```
35#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
36pub struct SshPublicKey(String);
37
38impl SshPublicKey {
39    /// Creates a new `SshPublicKey` from a string
40    ///
41    /// # Arguments
42    /// * `key` - The SSH public key string
43    ///
44    /// # Errors
45    /// Returns an error if the key is empty or has an invalid format
46    pub fn new<S: Into<String>>(key: S) -> Result<Self, SshPublicKeyError> {
47        let key = key.into();
48
49        if key.trim().is_empty() {
50            return Err(SshPublicKeyError::Empty);
51        }
52
53        // Basic SSH public key format validation
54        // SSH public keys typically start with the key type (ssh-rsa, ssh-ed25519, etc.)
55        let trimmed = key.trim();
56        if !Self::is_valid_format(trimmed) {
57            return Err(SshPublicKeyError::InvalidFormat(
58                "SSH public key must start with a valid key type (ssh-rsa, ssh-dss, ssh-ed25519, ssh-ed448, rsa-sha2-256, rsa-sha2-512, ecdsa-sha2-*, etc.)".to_string()
59            ));
60        }
61
62        Ok(Self(trimmed.to_string()))
63    }
64
65    /// Basic format validation for SSH public keys
66    ///
67    /// Checks if the key starts with a recognized SSH key type and has the basic structure
68    /// Based on IANA SSH Parameters registry: <https://www.iana.org/assignments/ssh-parameters/ssh-parameters.xhtml#ssh-parameters-19>
69    ///
70    /// **Note for maintainers**: When new SSH key types are added to the IANA registry,
71    /// update the `valid_prefixes` array below to include them. Always check the official
72    /// IANA SSH Parameters document for the most current list of registered key types.
73    fn is_valid_format(key: &str) -> bool {
74        let valid_prefixes = [
75            // Standard SSH key types
76            "ssh-rsa",
77            "ssh-dss",
78            "ssh-ed25519",
79            "ssh-ed448",
80            // RSA with specific hash algorithms
81            "rsa-sha2-256",
82            "rsa-sha2-512",
83            // ECDSA variants
84            "ssh-ecdsa",
85            "ecdsa-sha2-nistp256",
86            "ecdsa-sha2-nistp384",
87            "ecdsa-sha2-nistp521",
88            // SPKI signatures
89            "spki-sign-rsa",
90            "spki-sign-dss",
91            // PGP signatures
92            "pgp-sign-rsa",
93            "pgp-sign-dss",
94            // X.509 certificate types
95            "x509v3-ssh-dss",
96            "x509v3-ssh-rsa",
97            "x509v3-rsa2048-sha256",
98            // Note: x509v3-ecdsa-sha2-* handled by prefix matching below
99            // Note: ecdsa-sha2-* handled by prefix matching below
100            // Null key for testing
101            "null",
102        ];
103
104        // Check if the key starts with a valid prefix
105        let has_valid_prefix = valid_prefixes.iter().any(|prefix| key.starts_with(prefix));
106
107        if has_valid_prefix {
108            // Basic structure check: should have at least 2 space-separated parts for most keys
109            // Format: <type> <key-data> [comment]
110            let parts: Vec<&str> = key.split_whitespace().collect();
111            return parts.len() >= 2 || key.starts_with("null"); // null key might be standalone
112        }
113
114        // Check for wildcard patterns not covered by exact prefixes
115        let wildcard_patterns = [
116            "ecdsa-sha2-",        // Matches ecdsa-sha2-* variants
117            "x509v3-ecdsa-sha2-", // Matches x509v3-ecdsa-sha2-* variants
118        ];
119
120        for pattern in &wildcard_patterns {
121            if key.starts_with(pattern) {
122                let parts: Vec<&str> = key.split_whitespace().collect();
123                return parts.len() >= 2;
124            }
125        }
126
127        false
128    }
129    /// Returns the SSH public key as a string slice
130    #[must_use]
131    pub fn as_str(&self) -> &str {
132        &self.0
133    }
134
135    /// Consumes the `SshPublicKey` and returns the inner string
136    #[must_use]
137    pub fn into_string(self) -> String {
138        self.0
139    }
140}
141
142impl FromStr for SshPublicKey {
143    type Err = SshPublicKeyError;
144
145    fn from_str(s: &str) -> Result<Self, Self::Err> {
146        Self::new(s)
147    }
148}
149
150impl fmt::Display for SshPublicKey {
151    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
152        write!(f, "{}", self.0)
153    }
154}
155
156impl From<SshPublicKey> for String {
157    fn from(key: SshPublicKey) -> String {
158        key.0
159    }
160}
161
162impl AsRef<str> for SshPublicKey {
163    fn as_ref(&self) -> &str {
164        &self.0
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    const VALID_RSA_KEY: &str = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCw16sai+XVnawp/P/Q23kcXKekygZ6ALmQAyslREo6kbG8s5RScsmbQqOQEcIwnV2Vo88eeWVzX0N0H1dIczRa/ezijBEsGefthzmz9Ix/vM4lodzTPQFtW8c2eYw7ESy12/2x5//UQQ3mxawEWsz5Ri8XuyBEy/Xh7xH/KpoektaocIOt2/WdCe8CvZdMLd7AviGcTdHFWRiOVrmHM1Pd8znqeA3/1KQP/M4Ae5q21oPjchGjVfPkGh/e62Wt+Wo/2lT30AyMO7JHA1tB1W4xANRQkOd1Kb/TrDLXfg0PaHQ+Irmycjp/H4KkcdB06nzYawXMN5csd/5TWKwkb9/vofp6GQNP731U8+JR4cxRfD107KoHroDSJpG2Fanb2PVBkSXAiJl29YrtoP9vUtSIemQCD/aXFtTcpSv7Y16bdp7v+0adCEHwBmodm9GzLL808FpI2ZCzCi+Ae98P3z+yPCxbrnVAahU8AM2NSbrfyH1w2eb4hJ22oPjdd//tBYtkE1TZBw+i3n0vRn04s5BfPRwwj5GISxacTOZm/YWvoE4UU9axtFXOtMUniVKL3ycA+LEfK7C4velOKbluyL8fYYu4pUxHnYOOkYYeRoi2jf3oagbABOpznloPd93wYP3NoUpIdtMZW+iCF0NnZkVLC9lm1FbTcnmrfNzFtGVKCQ== testing@torrust-testing-infra";
173    const VALID_ED25519_KEY: &str = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIG4rT3vTt99Ox5kndS4HmgTrKBT8tOajsHpzHtRG testing@example.com";
174    const VALID_RSA_SHA2_256_KEY: &str =
175        "rsa-sha2-256 AAAAB3NzaC1yc2EAAAADAQABAAABAQC7vbqajnc testing@example.com";
176    const VALID_ECDSA_KEY: &str =
177        "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTY testing@example.com";
178
179    #[test]
180    fn it_should_create_ssh_public_key_with_valid_rsa_key() {
181        let key = SshPublicKey::new(VALID_RSA_KEY).unwrap();
182        assert_eq!(key.as_str(), VALID_RSA_KEY);
183    }
184
185    #[test]
186    fn it_should_create_ssh_public_key_with_valid_ed25519_key() {
187        let key = SshPublicKey::new(VALID_ED25519_KEY).unwrap();
188        assert_eq!(key.as_str(), VALID_ED25519_KEY);
189    }
190
191    #[test]
192    fn it_should_create_ssh_public_key_with_rsa_sha2_256_key() {
193        let key = SshPublicKey::new(VALID_RSA_SHA2_256_KEY).unwrap();
194        assert_eq!(key.as_str(), VALID_RSA_SHA2_256_KEY);
195    }
196
197    #[test]
198    fn it_should_create_ssh_public_key_with_ecdsa_key() {
199        let key = SshPublicKey::new(VALID_ECDSA_KEY).unwrap();
200        assert_eq!(key.as_str(), VALID_ECDSA_KEY);
201    }
202
203    /// Parameterized test for all supported SSH key prefixes
204    ///
205    /// This test validates that all SSH key types defined in the IANA SSH Parameters registry
206    /// are properly recognized by our validation logic. When new key types are added to the
207    /// registry, add them to this test data to ensure they're supported.
208    #[test]
209    fn it_should_support_all_iana_registered_ssh_key_types() {
210        // Test data: (prefix, description)
211        let supported_prefixes = [
212            // Standard SSH key types
213            ("ssh-rsa", "RSA keys"),
214            ("ssh-dss", "DSS/DSA keys"),
215            ("ssh-ed25519", "Ed25519 keys"),
216            ("ssh-ed448", "Ed448 keys"),
217            // RSA with specific hash algorithms
218            ("rsa-sha2-256", "RSA with SHA-256"),
219            ("rsa-sha2-512", "RSA with SHA-512"),
220            // ECDSA variants
221            ("ssh-ecdsa", "ECDSA keys (generic)"),
222            ("ecdsa-sha2-nistp256", "ECDSA P-256"),
223            ("ecdsa-sha2-nistp384", "ECDSA P-384"),
224            ("ecdsa-sha2-nistp521", "ECDSA P-521"),
225            // SPKI signatures
226            ("spki-sign-rsa", "SPKI RSA signatures"),
227            ("spki-sign-dss", "SPKI DSS signatures"),
228            // PGP signatures
229            ("pgp-sign-rsa", "PGP RSA signatures"),
230            ("pgp-sign-dss", "PGP DSS signatures"),
231            // X.509 certificate types
232            ("x509v3-ssh-dss", "X.509v3 DSS certificates"),
233            ("x509v3-ssh-rsa", "X.509v3 RSA certificates"),
234            ("x509v3-rsa2048-sha256", "X.509v3 RSA 2048 SHA-256"),
235            // Null key for testing
236            ("null", "Null key type"),
237        ];
238
239        for (prefix, description) in supported_prefixes {
240            let test_key = if prefix == "null" {
241                // Null key might be standalone
242                prefix.to_string()
243            } else {
244                // Standard format: <type> <key-data> [comment]
245                format!("{prefix} AAAAB3NzaC1example_key_data test@example.com")
246            };
247
248            let result = SshPublicKey::new(&test_key);
249            assert!(
250                result.is_ok(),
251                "Failed to validate {description} with prefix '{prefix}': {test_key}"
252            );
253
254            let key = result.unwrap();
255            assert_eq!(key.as_str(), test_key);
256        }
257    }
258
259    /// Test wildcard ECDSA variants that use pattern matching
260    #[test]
261    fn it_should_support_wildcard_ecdsa_variants() {
262        let wildcard_variants = [
263            ("ecdsa-sha2-custom", "Custom ECDSA SHA-2 variant"),
264            ("ecdsa-sha2-nistp192", "ECDSA P-192"),
265            ("x509v3-ecdsa-sha2-nistp256", "X.509v3 ECDSA P-256"),
266            ("x509v3-ecdsa-sha2-custom", "X.509v3 ECDSA custom variant"),
267        ];
268
269        for (prefix, description) in wildcard_variants {
270            let test_key = format!("{prefix} AAAAB3NzaC1example_key_data test@example.com");
271            let result = SshPublicKey::new(&test_key);
272            assert!(
273                result.is_ok(),
274                "Failed to validate {description} with prefix '{prefix}': {test_key}"
275            );
276
277            let key = result.unwrap();
278            assert_eq!(key.as_str(), test_key);
279        }
280    }
281
282    #[test]
283    fn it_should_support_ssh_dss_keys() {
284        let dss_key = "ssh-dss AAAAB3NzaC1kc3MAAACBAIr9... test@example.com";
285        let key = SshPublicKey::new(dss_key).unwrap();
286        assert_eq!(key.as_str(), dss_key);
287    }
288
289    #[test]
290    fn it_should_support_ssh_ed448_keys() {
291        let ed448_key = "ssh-ed448 AAAAGnNzaC1lZDQ0OAAAANLamVx1... test@example.com";
292        let key = SshPublicKey::new(ed448_key).unwrap();
293        assert_eq!(key.as_str(), ed448_key);
294    }
295
296    #[test]
297    fn it_should_support_rsa_sha2_512_keys() {
298        let rsa_sha2_512_key =
299            "rsa-sha2-512 AAAAB3NzaC1yc2EAAAADAQABAAABAQC7vbqajnc test@example.com";
300        let key = SshPublicKey::new(rsa_sha2_512_key).unwrap();
301        assert_eq!(key.as_str(), rsa_sha2_512_key);
302    }
303
304    #[test]
305    fn it_should_support_x509v3_keys() {
306        let x509_key = "x509v3-ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC7vbqajnc test@example.com";
307        let key = SshPublicKey::new(x509_key).unwrap();
308        assert_eq!(key.as_str(), x509_key);
309    }
310
311    #[test]
312    fn it_should_fail_with_empty_key() {
313        let result = SshPublicKey::new("");
314        assert!(result.is_err());
315        assert!(matches!(result.unwrap_err(), SshPublicKeyError::Empty));
316    }
317
318    #[test]
319    fn it_should_fail_with_whitespace_only_key() {
320        let result = SshPublicKey::new("   \n  \t  ");
321        assert!(result.is_err());
322        assert!(matches!(result.unwrap_err(), SshPublicKeyError::Empty));
323    }
324
325    #[test]
326    fn it_should_fail_with_invalid_format() {
327        let result = SshPublicKey::new("invalid-key-format");
328        assert!(result.is_err());
329        assert!(matches!(
330            result.unwrap_err(),
331            SshPublicKeyError::InvalidFormat(_)
332        ));
333    }
334
335    #[test]
336    fn it_should_fail_with_incomplete_key() {
337        let result = SshPublicKey::new("ssh-rsa");
338        assert!(result.is_err());
339        assert!(matches!(
340            result.unwrap_err(),
341            SshPublicKeyError::InvalidFormat(_)
342        ));
343    }
344
345    #[test]
346    fn it_should_trim_whitespace() {
347        let key_with_whitespace = format!("  \n{VALID_RSA_KEY}\n  ");
348        let key = SshPublicKey::new(key_with_whitespace).unwrap();
349        assert_eq!(key.as_str(), VALID_RSA_KEY);
350    }
351
352    #[test]
353    fn it_should_convert_from_str() {
354        let key: SshPublicKey = VALID_RSA_KEY.parse().unwrap();
355        assert_eq!(key.as_str(), VALID_RSA_KEY);
356    }
357
358    #[test]
359    fn it_should_display_correctly() {
360        let key = SshPublicKey::new(VALID_RSA_KEY).unwrap();
361        assert_eq!(format!("{key}"), VALID_RSA_KEY);
362    }
363
364    #[test]
365    fn it_should_convert_to_string() {
366        let key = SshPublicKey::new(VALID_RSA_KEY).unwrap();
367        let string_key: String = key.into();
368        assert_eq!(string_key, VALID_RSA_KEY);
369    }
370
371    #[test]
372    fn it_should_serialize_to_json() {
373        let key = SshPublicKey::new(VALID_RSA_KEY).unwrap();
374        let json = serde_json::to_string(&key).unwrap();
375        assert_eq!(json, format!("\"{VALID_RSA_KEY}\""));
376    }
377
378    #[test]
379    fn it_should_deserialize_from_json() {
380        let json = format!("\"{VALID_RSA_KEY}\"");
381        let key: SshPublicKey = serde_json::from_str(&json).unwrap();
382        assert_eq!(key.as_str(), VALID_RSA_KEY);
383    }
384
385    #[test]
386    fn it_should_be_equal_when_same_key() {
387        let key1 = SshPublicKey::new(VALID_RSA_KEY).unwrap();
388        let key2 = SshPublicKey::new(VALID_RSA_KEY).unwrap();
389        assert_eq!(key1, key2);
390    }
391
392    #[test]
393    fn it_should_work_as_reference() {
394        let key = SshPublicKey::new(VALID_RSA_KEY).unwrap();
395        let key_ref: &str = key.as_ref();
396        assert_eq!(key_ref, VALID_RSA_KEY);
397    }
398}