stem_rs/descriptor/
key_cert.rs

1//! Key certificate parsing for Tor directory authorities.
2//!
3//! This module provides parsing for directory key certificates used by
4//! v3 network status documents to authenticate directory authorities.
5//! Key certificates bind a directory authority's long-term identity key
6//! to a medium-term signing key used to sign consensus documents.
7//!
8//! # Overview
9//!
10//! Directory authorities use a two-tier key system for security:
11//!
12//! - **Identity Key**: A long-term RSA key that identifies the authority.
13//!   This key is kept offline and used only to sign key certificates.
14//! - **Signing Key**: A medium-term RSA key used to sign votes and
15//!   consensus documents. This key is rotated periodically.
16//!
17//! Key certificates establish the binding between these keys, allowing
18//! clients to verify that a signing key is authorized by a known authority.
19//!
20//! # Certificate Format
21//!
22//! Key certificates follow the Tor directory specification format:
23//!
24//! ```text
25//! dir-key-certificate-version 3
26//! dir-address <IP>:<port>
27//! fingerprint <40 hex chars>
28//! dir-key-published <YYYY-MM-DD HH:MM:SS>
29//! dir-key-expires <YYYY-MM-DD HH:MM:SS>
30//! dir-identity-key
31//! -----BEGIN RSA PUBLIC KEY-----
32//! <base64 encoded key>
33//! -----END RSA PUBLIC KEY-----
34//! dir-signing-key
35//! -----BEGIN RSA PUBLIC KEY-----
36//! <base64 encoded key>
37//! -----END RSA PUBLIC KEY-----
38//! dir-key-crosscert
39//! -----BEGIN ID SIGNATURE-----
40//! <signature>
41//! -----END ID SIGNATURE-----
42//! dir-key-certification
43//! -----BEGIN SIGNATURE-----
44//! <signature>
45//! -----END SIGNATURE-----
46//! ```
47//!
48//! # Example
49//!
50//! ```rust
51//! use stem_rs::descriptor::KeyCertificate;
52//!
53//! let cert_content = r#"dir-key-certificate-version 3
54//! fingerprint BCB380A633592C218757BEE11E630511A485658A
55//! dir-key-published 2024-01-01 00:00:00
56//! dir-key-expires 2025-01-01 00:00:00
57//! dir-identity-key
58//! -----BEGIN RSA PUBLIC KEY-----
59//! MIIBCgKCAQEA
60//! -----END RSA PUBLIC KEY-----
61//! dir-signing-key
62//! -----BEGIN RSA PUBLIC KEY-----
63//! MIIBCgKCAQEA
64//! -----END RSA PUBLIC KEY-----
65//! dir-key-certification
66//! -----BEGIN SIGNATURE-----
67//! AAAA
68//! -----END SIGNATURE-----
69//! "#;
70//!
71//! let cert = KeyCertificate::parse(cert_content).unwrap();
72//! assert_eq!(cert.version, Some(3));
73//! assert_eq!(cert.fingerprint.as_deref(), Some("BCB380A633592C218757BEE11E630511A485658A"));
74//! ```
75//!
76//! # Security Considerations
77//!
78//! - Always check [`is_expired()`](KeyCertificate::is_expired) before trusting a certificate
79//! - The crosscert signature proves the signing key holder authorized the binding
80//! - The certification signature proves the identity key holder authorized the binding
81//! - Both signatures should be verified for full security (not implemented in this module)
82//!
83//! # See Also
84//!
85//! - [`consensus`](super::consensus): Network status documents that reference key certificates
86//! - [`authority`](super::authority): Directory authority information
87//! - [`certificate`](super::certificate): Ed25519 certificates (different from key certificates)
88
89use chrono::{DateTime, NaiveDateTime, Utc};
90use std::fmt;
91use std::net::IpAddr;
92use std::str::FromStr;
93
94use crate::Error;
95
96/// A directory authority key certificate.
97///
98/// Key certificates are used in Tor's v3 directory protocol to bind a
99/// directory authority's long-term identity key to a medium-term signing
100/// key. This allows authorities to rotate their signing keys without
101/// changing their identity.
102///
103/// # Structure
104///
105/// A key certificate contains:
106/// - Version information (currently version 3)
107/// - Authority network address and port
108/// - Authority fingerprint (SHA-1 hash of identity key)
109/// - Validity period (published and expiration times)
110/// - The identity key (long-term, kept offline)
111/// - The signing key (medium-term, used for votes/consensus)
112/// - Cross-certification signatures proving key binding
113///
114/// # Mandatory Fields
115///
116/// The following fields are required for a valid certificate:
117/// - `version`
118/// - `fingerprint`
119/// - `published`
120/// - `expires`
121/// - `identity_key`
122/// - `signing_key`
123/// - `certification`
124///
125/// # Example
126///
127/// ```rust
128/// use stem_rs::descriptor::KeyCertificate;
129///
130/// let cert_content = r#"dir-key-certificate-version 3
131/// fingerprint BCB380A633592C218757BEE11E630511A485658A
132/// dir-key-published 2024-01-01 00:00:00
133/// dir-key-expires 2025-01-01 00:00:00
134/// dir-identity-key
135/// -----BEGIN RSA PUBLIC KEY-----
136/// MIIBCgKCAQEA
137/// -----END RSA PUBLIC KEY-----
138/// dir-signing-key
139/// -----BEGIN RSA PUBLIC KEY-----
140/// MIIBCgKCAQEA
141/// -----END RSA PUBLIC KEY-----
142/// dir-key-certification
143/// -----BEGIN SIGNATURE-----
144/// AAAA
145/// -----END SIGNATURE-----
146/// "#;
147///
148/// let cert = KeyCertificate::parse(cert_content)?;
149/// println!("Authority fingerprint: {:?}", cert.fingerprint);
150/// println!("Certificate expired: {}", cert.is_expired());
151/// # Ok::<(), stem_rs::Error>(())
152/// ```
153///
154/// # Security
155///
156/// - Check [`is_expired()`](Self::is_expired) before trusting a certificate
157/// - Certificates should be obtained from trusted sources
158/// - The signatures should be cryptographically verified (not done by this parser)
159#[derive(Debug, Clone, PartialEq)]
160pub struct KeyCertificate {
161    /// Certificate format version (currently 3).
162    ///
163    /// Version 3 is the only version currently in use. This field
164    /// indicates the format of the certificate and which fields
165    /// are expected.
166    pub version: Option<u32>,
167
168    /// IP address where the authority's directory service is available.
169    ///
170    /// This is the address clients can use to fetch directory information
171    /// directly from this authority. May be IPv4 or IPv6.
172    pub address: Option<IpAddr>,
173
174    /// Port number for the authority's directory service.
175    ///
176    /// Combined with [`address`](Self::address), this forms the complete
177    /// endpoint for directory requests.
178    pub dir_port: Option<u16>,
179
180    /// SHA-1 fingerprint of the authority's identity key.
181    ///
182    /// This is a 40-character hexadecimal string representing the
183    /// SHA-1 hash of the authority's long-term identity key. It
184    /// uniquely identifies the authority across the Tor network.
185    pub fingerprint: Option<String>,
186
187    /// The authority's long-term identity key in PEM format.
188    ///
189    /// This RSA public key is the authority's permanent identifier.
190    /// It is kept offline and used only to sign key certificates.
191    /// The key is encoded as a PEM block with type "RSA PUBLIC KEY".
192    pub identity_key: Option<String>,
193
194    /// Time when this certificate was generated.
195    ///
196    /// Certificates should not be used before their published time.
197    /// This timestamp is in UTC.
198    pub published: Option<DateTime<Utc>>,
199
200    /// Time after which this certificate is no longer valid.
201    ///
202    /// Certificates should not be trusted after their expiration time.
203    /// Use [`is_expired()`](Self::is_expired) to check validity.
204    /// This timestamp is in UTC.
205    pub expires: Option<DateTime<Utc>>,
206
207    /// The authority's medium-term signing key in PEM format.
208    ///
209    /// This RSA public key is used to sign votes and consensus documents.
210    /// It is rotated periodically (typically every few months) and a new
211    /// key certificate is issued for each rotation.
212    pub signing_key: Option<String>,
213
214    /// Cross-certification signature from the signing key.
215    ///
216    /// This signature, made with the signing key, proves that the
217    /// signing key holder authorized the binding to the identity key.
218    /// Encoded as a PEM block with type "ID SIGNATURE".
219    pub crosscert: Option<String>,
220
221    /// Certification signature from the identity key.
222    ///
223    /// This signature, made with the identity key, proves that the
224    /// identity key holder authorized the signing key. This is the
225    /// primary authentication of the certificate.
226    /// Encoded as a PEM block with type "SIGNATURE".
227    pub certification: Option<String>,
228
229    /// Raw bytes of the original certificate content.
230    raw_content: Vec<u8>,
231
232    /// Lines in the certificate that were not recognized.
233    ///
234    /// These are preserved for debugging and forward compatibility
235    /// with future certificate extensions.
236    unrecognized_lines: Vec<String>,
237}
238
239impl KeyCertificate {
240    /// Parses a key certificate from its string representation.
241    ///
242    /// This method parses the certificate with full validation enabled,
243    /// ensuring all mandatory fields are present and correctly formatted.
244    ///
245    /// # Arguments
246    ///
247    /// * `content` - The certificate content as a string
248    ///
249    /// # Returns
250    ///
251    /// A parsed `KeyCertificate` on success.
252    ///
253    /// # Errors
254    ///
255    /// Returns [`Error::Parse`] if:
256    /// - The certificate doesn't start with `dir-key-certificate-version`
257    /// - The certificate doesn't end with `dir-key-certification`
258    /// - Any mandatory field is missing
259    /// - Any field has an invalid format (e.g., invalid fingerprint, datetime)
260    /// - Key blocks are malformed or incomplete
261    ///
262    /// # Example
263    ///
264    /// ```rust
265    /// use stem_rs::descriptor::KeyCertificate;
266    ///
267    /// let content = r#"dir-key-certificate-version 3
268    /// fingerprint BCB380A633592C218757BEE11E630511A485658A
269    /// dir-key-published 2024-01-01 00:00:00
270    /// dir-key-expires 2025-01-01 00:00:00
271    /// dir-identity-key
272    /// -----BEGIN RSA PUBLIC KEY-----
273    /// MIIBCgKCAQEA
274    /// -----END RSA PUBLIC KEY-----
275    /// dir-signing-key
276    /// -----BEGIN RSA PUBLIC KEY-----
277    /// MIIBCgKCAQEA
278    /// -----END RSA PUBLIC KEY-----
279    /// dir-key-certification
280    /// -----BEGIN SIGNATURE-----
281    /// AAAA
282    /// -----END SIGNATURE-----
283    /// "#;
284    ///
285    /// let cert = KeyCertificate::parse(content)?;
286    /// assert_eq!(cert.version, Some(3));
287    /// # Ok::<(), stem_rs::Error>(())
288    /// ```
289    pub fn parse(content: &str) -> Result<Self, Error> {
290        Self::parse_with_validation(content, true)
291    }
292
293    /// Parses a key certificate with optional validation.
294    ///
295    /// This method allows parsing certificates that may be incomplete
296    /// or malformed by disabling validation. This is useful for:
297    /// - Parsing partial certificates for debugging
298    /// - Handling certificates from untrusted sources gracefully
299    /// - Testing and development
300    ///
301    /// # Arguments
302    ///
303    /// * `content` - The certificate content as a string
304    /// * `validate` - If `true`, validates all fields and structure;
305    ///   if `false`, parses what it can without errors
306    ///
307    /// # Returns
308    ///
309    /// A parsed `KeyCertificate` on success. With validation disabled,
310    /// many fields may be `None` even if they would normally be required.
311    ///
312    /// # Errors
313    ///
314    /// With `validate = true`, returns the same errors as [`parse()`](Self::parse).
315    /// With `validate = false`, only returns errors for fundamental parsing
316    /// failures (e.g., completely unparseable content).
317    ///
318    /// # Example
319    ///
320    /// ```rust
321    /// use stem_rs::descriptor::KeyCertificate;
322    ///
323    /// // Parse incomplete certificate without validation
324    /// let partial = "dir-key-certificate-version 3\nfingerprint BCB380A633592C218757BEE11E630511A485658A\n";
325    /// let cert = KeyCertificate::parse_with_validation(partial, false)?;
326    /// assert_eq!(cert.version, Some(3));
327    /// assert!(cert.identity_key.is_none()); // Missing but no error
328    /// # Ok::<(), stem_rs::Error>(())
329    /// ```
330    pub fn parse_with_validation(content: &str, validate: bool) -> Result<Self, Error> {
331        let raw_content = content.as_bytes().to_vec();
332        let lines: Vec<&str> = content.lines().collect();
333
334        let mut version: Option<u32> = None;
335        let mut address: Option<IpAddr> = None;
336        let mut dir_port: Option<u16> = None;
337        let mut fingerprint: Option<String> = None;
338        let mut identity_key: Option<String> = None;
339        let mut published: Option<DateTime<Utc>> = None;
340        let mut expires: Option<DateTime<Utc>> = None;
341        let mut signing_key: Option<String> = None;
342        let mut crosscert: Option<String> = None;
343        let mut certification: Option<String> = None;
344        let mut unrecognized_lines: Vec<String> = Vec::new();
345
346        let mut idx = 0;
347        let mut first_keyword: Option<&str> = None;
348        let mut last_keyword: Option<&str> = None;
349
350        while idx < lines.len() {
351            let line = lines[idx];
352
353            if line.trim().is_empty() {
354                idx += 1;
355                continue;
356            }
357
358            if line.starts_with("@type ") {
359                idx += 1;
360                continue;
361            }
362
363            let (keyword, value) = if let Some(space_pos) = line.find(' ') {
364                (&line[..space_pos], line[space_pos + 1..].trim())
365            } else {
366                (line, "")
367            };
368
369            if first_keyword.is_none() {
370                first_keyword = Some(keyword);
371            }
372            last_keyword = Some(keyword);
373
374            match keyword {
375                "dir-key-certificate-version" => {
376                    version = value.parse().ok();
377                    if validate && version.is_none() {
378                        return Err(Error::Parse {
379                            location: "dir-key-certificate-version".to_string(),
380                            reason: format!("invalid version: {}", value),
381                        });
382                    }
383                }
384                "dir-address" => {
385                    if let Some((addr_str, port_str)) = value.split_once(':') {
386                        if let Ok(addr) = addr_str.parse::<IpAddr>() {
387                            if let Ok(port) = port_str.parse::<u16>() {
388                                address = Some(addr);
389                                dir_port = Some(port);
390                            } else if validate {
391                                return Err(Error::Parse {
392                                    location: "dir-address".to_string(),
393                                    reason: format!("invalid port: {}", port_str),
394                                });
395                            }
396                        } else if validate {
397                            return Err(Error::Parse {
398                                location: "dir-address".to_string(),
399                                reason: format!("invalid address: {}", addr_str),
400                            });
401                        }
402                    } else if validate && !value.is_empty() {
403                        return Err(Error::Parse {
404                            location: "dir-address".to_string(),
405                            reason: format!("invalid dir-address format: {}", value),
406                        });
407                    }
408                }
409                "fingerprint" => {
410                    if is_valid_fingerprint(value) {
411                        fingerprint = Some(value.to_string());
412                    } else if validate {
413                        return Err(Error::Parse {
414                            location: "fingerprint".to_string(),
415                            reason: format!("invalid fingerprint: {}", value),
416                        });
417                    }
418                }
419                "dir-key-published" => {
420                    published = parse_datetime(value);
421                    if validate && published.is_none() {
422                        return Err(Error::Parse {
423                            location: "dir-key-published".to_string(),
424                            reason: format!("invalid datetime: {}", value),
425                        });
426                    }
427                }
428                "dir-key-expires" => {
429                    expires = parse_datetime(value);
430                    if validate && expires.is_none() {
431                        return Err(Error::Parse {
432                            location: "dir-key-expires".to_string(),
433                            reason: format!("invalid datetime: {}", value),
434                        });
435                    }
436                }
437                "dir-identity-key" => {
438                    let (block, end_idx) =
439                        extract_key_block(&lines, idx + 1, "RSA PUBLIC KEY", validate)?;
440                    identity_key = block;
441                    idx = end_idx;
442                }
443                "dir-signing-key" => {
444                    let (block, end_idx) =
445                        extract_key_block(&lines, idx + 1, "RSA PUBLIC KEY", validate)?;
446                    signing_key = block;
447                    idx = end_idx;
448                }
449                "dir-key-crosscert" => {
450                    let (block, end_idx) =
451                        extract_key_block(&lines, idx + 1, "ID SIGNATURE", validate)?;
452                    crosscert = block;
453                    idx = end_idx;
454                }
455                "dir-key-certification" => {
456                    let (block, end_idx) =
457                        extract_key_block(&lines, idx + 1, "SIGNATURE", validate)?;
458                    certification = block;
459                    idx = end_idx;
460                }
461                _ => {
462                    if !line.is_empty() && !line.starts_with("-----") {
463                        unrecognized_lines.push(line.to_string());
464                    }
465                }
466            }
467            idx += 1;
468        }
469
470        if validate {
471            if first_keyword != Some("dir-key-certificate-version") {
472                return Err(Error::Parse {
473                    location: "KeyCertificate".to_string(),
474                    reason: "Key certificates must start with a 'dir-key-certificate-version' line"
475                        .to_string(),
476                });
477            }
478
479            if last_keyword != Some("dir-key-certification") {
480                return Err(Error::Parse {
481                    location: "KeyCertificate".to_string(),
482                    reason: "Key certificates must end with a 'dir-key-certification' line"
483                        .to_string(),
484                });
485            }
486
487            if version.is_none() {
488                return Err(Error::Parse {
489                    location: "KeyCertificate".to_string(),
490                    reason: "Key certificates must have a 'dir-key-certificate-version' line"
491                        .to_string(),
492                });
493            }
494            if fingerprint.is_none() {
495                return Err(Error::Parse {
496                    location: "KeyCertificate".to_string(),
497                    reason: "Key certificates must have a 'fingerprint' line".to_string(),
498                });
499            }
500            if published.is_none() {
501                return Err(Error::Parse {
502                    location: "KeyCertificate".to_string(),
503                    reason: "Key certificates must have a 'dir-key-published' line".to_string(),
504                });
505            }
506            if expires.is_none() {
507                return Err(Error::Parse {
508                    location: "KeyCertificate".to_string(),
509                    reason: "Key certificates must have a 'dir-key-expires' line".to_string(),
510                });
511            }
512            if identity_key.is_none() {
513                return Err(Error::Parse {
514                    location: "KeyCertificate".to_string(),
515                    reason: "Key certificates must have a 'dir-identity-key' line".to_string(),
516                });
517            }
518            if signing_key.is_none() {
519                return Err(Error::Parse {
520                    location: "KeyCertificate".to_string(),
521                    reason: "Key certificates must have a 'dir-signing-key' line".to_string(),
522                });
523            }
524            if certification.is_none() {
525                return Err(Error::Parse {
526                    location: "KeyCertificate".to_string(),
527                    reason: "Key certificates must have a 'dir-key-certification' line".to_string(),
528                });
529            }
530        }
531
532        Ok(KeyCertificate {
533            version,
534            address,
535            dir_port,
536            fingerprint,
537            identity_key,
538            published,
539            expires,
540            signing_key,
541            crosscert,
542            certification,
543            raw_content,
544            unrecognized_lines,
545        })
546    }
547
548    /// Returns the raw bytes of the original certificate content.
549    ///
550    /// This provides access to the exact bytes that were parsed,
551    /// which is useful for:
552    /// - Computing digests for signature verification
553    /// - Storing certificates in their original format
554    /// - Debugging parsing issues
555    ///
556    /// # Returns
557    ///
558    /// A byte slice containing the original certificate content.
559    pub fn raw_content(&self) -> &[u8] {
560        &self.raw_content
561    }
562
563    /// Returns lines that were not recognized during parsing.
564    ///
565    /// Unrecognized lines are preserved for forward compatibility
566    /// with future certificate extensions. This allows newer
567    /// certificate formats to be partially parsed by older code.
568    ///
569    /// # Returns
570    ///
571    /// A slice of strings, each representing an unrecognized line.
572    /// Empty if all lines were recognized.
573    pub fn unrecognized_lines(&self) -> &[String] {
574        &self.unrecognized_lines
575    }
576
577    /// Checks if this certificate has expired.
578    ///
579    /// A certificate is considered expired if the current time is
580    /// past the certificate's expiration time. Expired certificates
581    /// should not be trusted for signature verification.
582    ///
583    /// # Returns
584    ///
585    /// - `true` if the certificate has expired
586    /// - `false` if the certificate is still valid or has no expiration time
587    ///
588    /// # Example
589    ///
590    /// ```rust
591    /// use stem_rs::descriptor::KeyCertificate;
592    ///
593    /// // Certificate with past expiration date
594    /// let old_cert_content = r#"dir-key-certificate-version 3
595    /// fingerprint BCB380A633592C218757BEE11E630511A485658A
596    /// dir-key-published 2017-01-01 00:00:00
597    /// dir-key-expires 2018-01-01 00:00:00
598    /// dir-identity-key
599    /// -----BEGIN RSA PUBLIC KEY-----
600    /// MIIBCgKCAQEA
601    /// -----END RSA PUBLIC KEY-----
602    /// dir-signing-key
603    /// -----BEGIN RSA PUBLIC KEY-----
604    /// MIIBCgKCAQEA
605    /// -----END RSA PUBLIC KEY-----
606    /// dir-key-certification
607    /// -----BEGIN SIGNATURE-----
608    /// AAAA
609    /// -----END SIGNATURE-----
610    /// "#;
611    ///
612    /// let cert = KeyCertificate::parse(old_cert_content)?;
613    /// assert!(cert.is_expired());
614    /// # Ok::<(), stem_rs::Error>(())
615    /// ```
616    pub fn is_expired(&self) -> bool {
617        match self.expires {
618            Some(exp) => Utc::now() > exp,
619            None => false,
620        }
621    }
622
623    /// Converts the certificate back to its string representation.
624    ///
625    /// This produces a string in the standard key certificate format
626    /// that can be parsed again or written to a file. The output
627    /// follows the same format as the original certificate.
628    ///
629    /// # Returns
630    ///
631    /// A string containing the certificate in standard format.
632    ///
633    /// # Note
634    ///
635    /// The output may not be byte-for-byte identical to the original
636    /// input due to whitespace normalization, but it will be
637    /// semantically equivalent.
638    ///
639    /// # Example
640    ///
641    /// ```rust
642    /// use stem_rs::descriptor::KeyCertificate;
643    ///
644    /// let content = r#"dir-key-certificate-version 3
645    /// fingerprint BCB380A633592C218757BEE11E630511A485658A
646    /// dir-key-published 2024-01-01 00:00:00
647    /// dir-key-expires 2025-01-01 00:00:00
648    /// dir-identity-key
649    /// -----BEGIN RSA PUBLIC KEY-----
650    /// MIIBCgKCAQEA
651    /// -----END RSA PUBLIC KEY-----
652    /// dir-signing-key
653    /// -----BEGIN RSA PUBLIC KEY-----
654    /// MIIBCgKCAQEA
655    /// -----END RSA PUBLIC KEY-----
656    /// dir-key-certification
657    /// -----BEGIN SIGNATURE-----
658    /// AAAA
659    /// -----END SIGNATURE-----
660    /// "#;
661    ///
662    /// let cert = KeyCertificate::parse(content)?;
663    /// let output = cert.to_descriptor_string();
664    /// assert!(output.contains("dir-key-certificate-version 3"));
665    /// assert!(output.contains("fingerprint BCB380A633592C218757BEE11E630511A485658A"));
666    /// # Ok::<(), stem_rs::Error>(())
667    /// ```
668    pub fn to_descriptor_string(&self) -> String {
669        let mut result = String::new();
670
671        if let Some(v) = self.version {
672            result.push_str(&format!("dir-key-certificate-version {}\n", v));
673        }
674
675        if let (Some(addr), Some(port)) = (&self.address, self.dir_port) {
676            result.push_str(&format!("dir-address {}:{}\n", addr, port));
677        }
678
679        if let Some(ref fp) = self.fingerprint {
680            result.push_str(&format!("fingerprint {}\n", fp));
681        }
682
683        if let Some(ref dt) = self.published {
684            result.push_str(&format!(
685                "dir-key-published {}\n",
686                dt.format("%Y-%m-%d %H:%M:%S")
687            ));
688        }
689
690        if let Some(ref dt) = self.expires {
691            result.push_str(&format!(
692                "dir-key-expires {}\n",
693                dt.format("%Y-%m-%d %H:%M:%S")
694            ));
695        }
696
697        if let Some(ref key) = self.identity_key {
698            result.push_str("dir-identity-key\n");
699            result.push_str(key);
700            result.push('\n');
701        }
702
703        if let Some(ref key) = self.signing_key {
704            result.push_str("dir-signing-key\n");
705            result.push_str(key);
706            result.push('\n');
707        }
708
709        if let Some(ref sig) = self.crosscert {
710            result.push_str("dir-key-crosscert\n");
711            result.push_str(sig);
712            result.push('\n');
713        }
714
715        if let Some(ref sig) = self.certification {
716            result.push_str("dir-key-certification\n");
717            result.push_str(sig);
718            result.push('\n');
719        }
720
721        result
722    }
723}
724
725impl FromStr for KeyCertificate {
726    type Err = Error;
727
728    fn from_str(s: &str) -> Result<Self, Self::Err> {
729        Self::parse(s)
730    }
731}
732
733impl fmt::Display for KeyCertificate {
734    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
735        write!(f, "{}", self.to_descriptor_string())
736    }
737}
738
739/// Validates a fingerprint string.
740///
741/// A valid fingerprint is exactly 40 hexadecimal characters (case-insensitive),
742/// representing a 160-bit SHA-1 hash.
743fn is_valid_fingerprint(s: &str) -> bool {
744    s.len() == 40 && s.chars().all(|c| c.is_ascii_hexdigit())
745}
746
747/// Parses a datetime string in Tor's standard format.
748///
749/// Expected format: "YYYY-MM-DD HH:MM:SS"
750fn parse_datetime(s: &str) -> Option<DateTime<Utc>> {
751    let s = s.trim();
752    NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S")
753        .ok()
754        .map(|dt| dt.and_utc())
755}
756
757/// Extracts a PEM-encoded key block from certificate lines.
758///
759/// Reads lines starting from `start_idx` until finding a complete
760/// PEM block of the expected type.
761fn extract_key_block(
762    lines: &[&str],
763    start_idx: usize,
764    expected_type: &str,
765    validate: bool,
766) -> Result<(Option<String>, usize), Error> {
767    let mut block = String::new();
768    let mut idx = start_idx;
769    let begin_marker = format!("-----BEGIN {}-----", expected_type);
770    let end_marker = format!("-----END {}-----", expected_type);
771    let mut found_begin = false;
772    let mut found_end = false;
773
774    while idx < lines.len() {
775        let line = lines[idx];
776        block.push_str(line);
777        block.push('\n');
778
779        if line.contains(&begin_marker) {
780            found_begin = true;
781        }
782
783        if line.contains(&end_marker) {
784            found_end = true;
785            break;
786        }
787
788        if line.starts_with("-----END ") && !line.contains(&end_marker) {
789            if validate {
790                return Err(Error::Parse {
791                    location: "key_block".to_string(),
792                    reason: format!("Expected {} block but found: {}", expected_type, line),
793                });
794            }
795            return Ok((None, idx));
796        }
797
798        idx += 1;
799    }
800
801    if validate && (!found_begin || !found_end) {
802        return Err(Error::Parse {
803            location: "key_block".to_string(),
804            reason: format!("Incomplete {} block", expected_type),
805        });
806    }
807
808    if found_begin && found_end {
809        Ok((Some(block.trim_end().to_string()), idx))
810    } else {
811        Ok((None, idx))
812    }
813}
814
815#[cfg(test)]
816mod tests {
817    use super::*;
818    use chrono::TimeZone;
819
820    const SAMPLE_CERT: &str = r#"dir-key-certificate-version 3
821dir-address 127.0.0.1:7000
822fingerprint BCB380A633592C218757BEE11E630511A485658A
823dir-key-published 2017-05-25 04:45:52
824dir-key-expires 2018-05-25 04:45:52
825dir-identity-key
826-----BEGIN RSA PUBLIC KEY-----
827MIIBigKCAYEAxfTHG1b3Sxe8n3JQ/nIk4+1/chj7+jAyLLK+WrEBiP1vnDxTXMuo
828x26ntWEjOaxjtKB12k5wMQW94/KvE754Gn98uQRFBHqLkrS4hUnn4/MqiBQVd2y3
829UtE6KDSRhJZ5LfFH+dCKwu5+695PyJp/pfCUSOyPj0HQbFOnAOqdHPok8dtdfsy0
830LaI7ycpzqAalzgrlwFP5KwwLtL+VapUGN4QOZlIXgL4W5e7OAG42lZhHt0b7/zdt
831oIegZM1y8tK2l75ijqsvbetddQcFlnVaYzNwlQAUIZuxJOGfnPfTo+WrjCgrK2ur
832ed5NiQMrEbZn5uCUscs+xLlKl4uKW0XXo1EIL45yBrVbmlP6V3/9diTHk64W9+m8
8332G4ToDyH8J7LvnYPsmD0cCaQEceebxYVlmmwgqdORH/ixbeGF7JalTwtWBQYo2r0
834VZAqjRwxR9dri6m1MIpzmzWmrbXghZ1IzJEL1rpB0okA/bE8AUGRx61eKnbI415O
835PmO06JMpvkxxAgMBAAE=
836-----END RSA PUBLIC KEY-----
837dir-signing-key
838-----BEGIN RSA PUBLIC KEY-----
839MIIBCgKCAQEAvzugxJl1gc7BgXarBO5IWejNZC30U1xVjZ/myQTzxtiKkPU0agQh
840sPqn4vVsaW6ZnWjJ2pSOq0/jg8WgFyGHGQ9cG8tv2TlpObeb/tI7iANxWx+MXJAh
841/CnFDBQ1ifKntJrs2IcRKMivfobqaHAL3Pz93noLWOTQunWjZ8D6kovYvUXe+yUQ
842tZEROrmXJx7ZIIJF6BNKYBTc+iEkYtkWlJVs0my7yP/bbS075QyBsr6CfT+O2yU4
843mgIg43QuqcFRbjyUvGI/gap06QNlB6yj8pqeE5rWo++5EpEvMK76fK6ymYuTN2SN
844Oil+Fo7qgG8UP/fv0GelSz6Tk7pBoeHJlQIDAQAB
845-----END RSA PUBLIC KEY-----
846dir-key-crosscert
847-----BEGIN ID SIGNATURE-----
848Oz+rvXDzlxLgQSb3nS5/4hrHVWgGCy0OnuNmFsyw8bi2eBst5Yj79dQ+D25giZke
84981FRGIFU4eS6dshB+pJ+z0hc9ozlRTYh/qevY6l6o0amvuhHyk/cQXrh8oYU9Ihe
850XQ1yVItvxC24HENsoGIGbr5uxc85FOcNs+R9qTLYA/56TjvAU4WUje3nTZE1awml
851lj/Y6DM7ruMF6UoYJZPTklukZ+XHZg4Z2eE55e/oIaD7bfU/lFWU/alMyTV/J5oT
852sxaD2XBLBScYiKypUmgrZ50W4ZqsXaYk76ClrudZnDbce+FuugVxok+jKYGjMu75
8532es2ucuik7iuO7QPdPIXfg==
854-----END ID SIGNATURE-----
855dir-key-certification
856-----BEGIN SIGNATURE-----
857I86FTQ5ZyCZUzm19HVAQWByrrRgUmddoRBfNiCj0iTGN3kdIq9OfuNLhWAqz71xP
8588Nn0Vun8Uj3/vBq/odIFpnngL3mKI6OEKcNDr0D5hEV9Yjrxe8msMoaUZT+LHzUW
8591q3pzxfMx6EmlSilMhuzSsa4YEbXMZzMqASKANSJHo2fzUkzQOpPw2SlWSTIgyqw
860wAOB6QOvFfP3c0NTwxXrYE/iT+r90wZBuzS+v7r9B94alNAkE1KZQKnq2QTTIznP
861iF9LWMsZcMHCjoTxszK4jF4MRMN/S4Xl8yQo0/z6FoqBz4RIXzFtJoG/rbXdKfkE
862nJK9iEhaZbS1IN0o+uIGtvOm2rQSu9gS8merurr5GDSK3szjesPVJuF00mCNgOx4
863hAYPN9N8HAL4zGE/l1UM7BGg3L84A0RMpDxnpXePd9mlHLhl4UV2lrkkf8S9Z6fX
864PPc3r7zKlL/jEGHwz+C7kE88HIvkVnKLLn
865-----END SIGNATURE-----
866"#;
867
868    const MINIMAL_CERT: &str = r#"dir-key-certificate-version 3
869fingerprint BCB380A633592C218757BEE11E630511A485658A
870dir-key-published 2017-05-25 04:45:52
871dir-key-expires 2018-05-25 04:45:52
872dir-identity-key
873-----BEGIN RSA PUBLIC KEY-----
874MIIBCgKCAQEA
875-----END RSA PUBLIC KEY-----
876dir-signing-key
877-----BEGIN RSA PUBLIC KEY-----
878MIIBCgKCAQEA
879-----END RSA PUBLIC KEY-----
880dir-key-certification
881-----BEGIN SIGNATURE-----
882AAAA
883-----END SIGNATURE-----
884"#;
885
886    #[test]
887    fn test_parse_full_certificate() {
888        let cert = KeyCertificate::parse(SAMPLE_CERT).unwrap();
889
890        assert_eq!(Some(3), cert.version);
891        assert_eq!(Some("127.0.0.1".parse().unwrap()), cert.address);
892        assert_eq!(Some(7000), cert.dir_port);
893        assert_eq!(
894            Some("BCB380A633592C218757BEE11E630511A485658A".to_string()),
895            cert.fingerprint
896        );
897        assert!(cert.identity_key.is_some());
898        assert!(cert
899            .identity_key
900            .as_ref()
901            .unwrap()
902            .contains("RSA PUBLIC KEY"));
903        assert_eq!(
904            Some(Utc.with_ymd_and_hms(2017, 5, 25, 4, 45, 52).unwrap()),
905            cert.published
906        );
907        assert_eq!(
908            Some(Utc.with_ymd_and_hms(2018, 5, 25, 4, 45, 52).unwrap()),
909            cert.expires
910        );
911        assert!(cert.signing_key.is_some());
912        assert!(cert.crosscert.is_some());
913        assert!(cert.crosscert.as_ref().unwrap().contains("ID SIGNATURE"));
914        assert!(cert.certification.is_some());
915        assert!(cert.certification.as_ref().unwrap().contains("SIGNATURE"));
916        assert!(cert.unrecognized_lines().is_empty());
917    }
918
919    #[test]
920    fn test_parse_minimal_certificate() {
921        let cert = KeyCertificate::parse(MINIMAL_CERT).unwrap();
922
923        assert_eq!(Some(3), cert.version);
924        assert_eq!(None, cert.address);
925        assert_eq!(None, cert.dir_port);
926        assert_eq!(
927            Some("BCB380A633592C218757BEE11E630511A485658A".to_string()),
928            cert.fingerprint
929        );
930        assert!(cert.identity_key.is_some());
931        assert!(cert.signing_key.is_some());
932        assert_eq!(None, cert.crosscert);
933        assert!(cert.certification.is_some());
934    }
935
936    #[test]
937    fn test_missing_version() {
938        let content = r#"fingerprint BCB380A633592C218757BEE11E630511A485658A
939dir-key-published 2017-05-25 04:45:52
940dir-key-expires 2018-05-25 04:45:52
941dir-identity-key
942-----BEGIN RSA PUBLIC KEY-----
943MIIBCgKCAQEA
944-----END RSA PUBLIC KEY-----
945dir-signing-key
946-----BEGIN RSA PUBLIC KEY-----
947MIIBCgKCAQEA
948-----END RSA PUBLIC KEY-----
949dir-key-certification
950-----BEGIN SIGNATURE-----
951AAAA
952-----END SIGNATURE-----
953"#;
954        let result = KeyCertificate::parse(content);
955        assert!(result.is_err());
956        assert!(result
957            .unwrap_err()
958            .to_string()
959            .contains("dir-key-certificate-version"));
960    }
961
962    #[test]
963    fn test_missing_fingerprint() {
964        let content = r#"dir-key-certificate-version 3
965dir-key-published 2017-05-25 04:45:52
966dir-key-expires 2018-05-25 04:45:52
967dir-identity-key
968-----BEGIN RSA PUBLIC KEY-----
969MIIBCgKCAQEA
970-----END RSA PUBLIC KEY-----
971dir-signing-key
972-----BEGIN RSA PUBLIC KEY-----
973MIIBCgKCAQEA
974-----END RSA PUBLIC KEY-----
975dir-key-certification
976-----BEGIN SIGNATURE-----
977AAAA
978-----END SIGNATURE-----
979"#;
980        let result = KeyCertificate::parse(content);
981        assert!(result.is_err());
982        assert!(result.unwrap_err().to_string().contains("fingerprint"));
983    }
984
985    #[test]
986    fn test_invalid_fingerprint() {
987        let content = r#"dir-key-certificate-version 3
988fingerprint INVALID
989dir-key-published 2017-05-25 04:45:52
990dir-key-expires 2018-05-25 04:45:52
991dir-identity-key
992-----BEGIN RSA PUBLIC KEY-----
993MIIBCgKCAQEA
994-----END RSA PUBLIC KEY-----
995dir-signing-key
996-----BEGIN RSA PUBLIC KEY-----
997MIIBCgKCAQEA
998-----END RSA PUBLIC KEY-----
999dir-key-certification
1000-----BEGIN SIGNATURE-----
1001AAAA
1002-----END SIGNATURE-----
1003"#;
1004        let result = KeyCertificate::parse(content);
1005        assert!(result.is_err());
1006        assert!(result.unwrap_err().to_string().contains("fingerprint"));
1007    }
1008
1009    #[test]
1010    fn test_invalid_datetime() {
1011        let content = r#"dir-key-certificate-version 3
1012fingerprint BCB380A633592C218757BEE11E630511A485658A
1013dir-key-published invalid-date
1014dir-key-expires 2018-05-25 04:45:52
1015dir-identity-key
1016-----BEGIN RSA PUBLIC KEY-----
1017MIIBCgKCAQEA
1018-----END RSA PUBLIC KEY-----
1019dir-signing-key
1020-----BEGIN RSA PUBLIC KEY-----
1021MIIBCgKCAQEA
1022-----END RSA PUBLIC KEY-----
1023dir-key-certification
1024-----BEGIN SIGNATURE-----
1025AAAA
1026-----END SIGNATURE-----
1027"#;
1028        let result = KeyCertificate::parse(content);
1029        assert!(result.is_err());
1030        assert!(result.unwrap_err().to_string().contains("datetime"));
1031    }
1032
1033    #[test]
1034    fn test_unrecognized_lines() {
1035        let content = r#"dir-key-certificate-version 3
1036fingerprint BCB380A633592C218757BEE11E630511A485658A
1037pepperjack is oh so tasty!
1038dir-key-published 2017-05-25 04:45:52
1039dir-key-expires 2018-05-25 04:45:52
1040dir-identity-key
1041-----BEGIN RSA PUBLIC KEY-----
1042MIIBCgKCAQEA
1043-----END RSA PUBLIC KEY-----
1044dir-signing-key
1045-----BEGIN RSA PUBLIC KEY-----
1046MIIBCgKCAQEA
1047-----END RSA PUBLIC KEY-----
1048dir-key-certification
1049-----BEGIN SIGNATURE-----
1050AAAA
1051-----END SIGNATURE-----
1052"#;
1053        let cert = KeyCertificate::parse(content).unwrap();
1054        assert_eq!(
1055            vec!["pepperjack is oh so tasty!"],
1056            cert.unrecognized_lines()
1057        );
1058    }
1059
1060    #[test]
1061    fn test_parse_without_validation() {
1062        let content = r#"dir-key-certificate-version 3
1063fingerprint BCB380A633592C218757BEE11E630511A485658A
1064"#;
1065        let cert = KeyCertificate::parse_with_validation(content, false).unwrap();
1066        assert_eq!(Some(3), cert.version);
1067        assert_eq!(
1068            Some("BCB380A633592C218757BEE11E630511A485658A".to_string()),
1069            cert.fingerprint
1070        );
1071        assert_eq!(None, cert.identity_key);
1072    }
1073
1074    #[test]
1075    fn test_is_expired() {
1076        let cert = KeyCertificate::parse(SAMPLE_CERT).unwrap();
1077        assert!(cert.is_expired());
1078    }
1079
1080    #[test]
1081    fn test_to_descriptor_string() {
1082        let cert = KeyCertificate::parse(SAMPLE_CERT).unwrap();
1083        let output = cert.to_descriptor_string();
1084
1085        assert!(output.contains("dir-key-certificate-version 3"));
1086        assert!(output.contains("dir-address 127.0.0.1:7000"));
1087        assert!(output.contains("fingerprint BCB380A633592C218757BEE11E630511A485658A"));
1088        assert!(output.contains("dir-key-published 2017-05-25 04:45:52"));
1089        assert!(output.contains("dir-key-expires 2018-05-25 04:45:52"));
1090        assert!(output.contains("dir-identity-key"));
1091        assert!(output.contains("dir-signing-key"));
1092        assert!(output.contains("dir-key-crosscert"));
1093        assert!(output.contains("dir-key-certification"));
1094    }
1095
1096    #[test]
1097    fn test_from_str() {
1098        let cert: KeyCertificate = MINIMAL_CERT.parse().unwrap();
1099        assert_eq!(Some(3), cert.version);
1100    }
1101
1102    #[test]
1103    fn test_display() {
1104        let cert = KeyCertificate::parse(MINIMAL_CERT).unwrap();
1105        let display = format!("{}", cert);
1106        assert!(display.contains("dir-key-certificate-version 3"));
1107    }
1108
1109    #[test]
1110    fn test_type_annotation() {
1111        let content = r#"@type dir-key-certificate-3 1.0
1112dir-key-certificate-version 3
1113fingerprint BCB380A633592C218757BEE11E630511A485658A
1114dir-key-published 2017-05-25 04:45:52
1115dir-key-expires 2018-05-25 04:45:52
1116dir-identity-key
1117-----BEGIN RSA PUBLIC KEY-----
1118MIIBCgKCAQEA
1119-----END RSA PUBLIC KEY-----
1120dir-signing-key
1121-----BEGIN RSA PUBLIC KEY-----
1122MIIBCgKCAQEA
1123-----END RSA PUBLIC KEY-----
1124dir-key-certification
1125-----BEGIN SIGNATURE-----
1126AAAA
1127-----END SIGNATURE-----
1128"#;
1129        let cert = KeyCertificate::parse(content).unwrap();
1130        assert_eq!(Some(3), cert.version);
1131    }
1132
1133    #[test]
1134    fn test_blank_lines() {
1135        let content = r#"dir-key-certificate-version 3
1136fingerprint BCB380A633592C218757BEE11E630511A485658A
1137
1138dir-key-published 2017-05-25 04:45:52
1139
1140dir-key-expires 2018-05-25 04:45:52
1141dir-identity-key
1142-----BEGIN RSA PUBLIC KEY-----
1143MIIBCgKCAQEA
1144-----END RSA PUBLIC KEY-----
1145dir-signing-key
1146-----BEGIN RSA PUBLIC KEY-----
1147MIIBCgKCAQEA
1148-----END RSA PUBLIC KEY-----
1149dir-key-certification
1150-----BEGIN SIGNATURE-----
1151AAAA
1152-----END SIGNATURE-----
1153"#;
1154        let cert = KeyCertificate::parse(content).unwrap();
1155        assert_eq!(Some(3), cert.version);
1156    }
1157}