torrust_tracker_deployer_lib/adapters/ssh/
public_key.rs1use serde::{Deserialize, Serialize};
7use std::fmt;
8use std::str::FromStr;
9use thiserror::Error;
10
11#[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
36pub struct SshPublicKey(String);
37
38impl SshPublicKey {
39 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 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 fn is_valid_format(key: &str) -> bool {
74 let valid_prefixes = [
75 "ssh-rsa",
77 "ssh-dss",
78 "ssh-ed25519",
79 "ssh-ed448",
80 "rsa-sha2-256",
82 "rsa-sha2-512",
83 "ssh-ecdsa",
85 "ecdsa-sha2-nistp256",
86 "ecdsa-sha2-nistp384",
87 "ecdsa-sha2-nistp521",
88 "spki-sign-rsa",
90 "spki-sign-dss",
91 "pgp-sign-rsa",
93 "pgp-sign-dss",
94 "x509v3-ssh-dss",
96 "x509v3-ssh-rsa",
97 "x509v3-rsa2048-sha256",
98 "null",
102 ];
103
104 let has_valid_prefix = valid_prefixes.iter().any(|prefix| key.starts_with(prefix));
106
107 if has_valid_prefix {
108 let parts: Vec<&str> = key.split_whitespace().collect();
111 return parts.len() >= 2 || key.starts_with("null"); }
113
114 let wildcard_patterns = [
116 "ecdsa-sha2-", "x509v3-ecdsa-sha2-", ];
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 #[must_use]
131 pub fn as_str(&self) -> &str {
132 &self.0
133 }
134
135 #[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 #[test]
209 fn it_should_support_all_iana_registered_ssh_key_types() {
210 let supported_prefixes = [
212 ("ssh-rsa", "RSA keys"),
214 ("ssh-dss", "DSS/DSA keys"),
215 ("ssh-ed25519", "Ed25519 keys"),
216 ("ssh-ed448", "Ed448 keys"),
217 ("rsa-sha2-256", "RSA with SHA-256"),
219 ("rsa-sha2-512", "RSA with SHA-512"),
220 ("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-sign-rsa", "SPKI RSA signatures"),
227 ("spki-sign-dss", "SPKI DSS signatures"),
228 ("pgp-sign-rsa", "PGP RSA signatures"),
230 ("pgp-sign-dss", "PGP DSS signatures"),
231 ("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", "Null key type"),
237 ];
238
239 for (prefix, description) in supported_prefixes {
240 let test_key = if prefix == "null" {
241 prefix.to_string()
243 } else {
244 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]
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}