1use std::collections::BTreeMap;
2use std::net::IpAddr;
3
4use chrono::{DateTime, Local, Utc};
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
8pub struct Origins(u8);
9
10impl Origins {
11 pub const NONE: Self = Self(0);
12 pub const CT: Self = Self(1);
13 pub const DNS: Self = Self(2);
14 pub const APEX: Self = Self(4);
15
16 pub fn insert(&mut self, other: Self) {
17 self.0 |= other.0;
18 }
19
20 pub fn contains(self, other: Self) -> bool {
21 self.0 & other.0 != 0
22 }
23
24 fn labels(self) -> Vec<&'static str> {
25 let mut out = Vec::new();
26 if self.contains(Self::CT) {
27 out.push("ct");
28 }
29 if self.contains(Self::DNS) {
30 out.push("dns");
31 }
32 if self.contains(Self::APEX) {
33 out.push("apex");
34 }
35 out
36 }
37}
38
39impl Serialize for Origins {
40 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
41 serializer.collect_seq(self.labels())
42 }
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
46#[serde(rename_all = "snake_case")]
47pub enum MatchKind {
48 Exact,
49 Wildcard,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(rename_all = "snake_case", tag = "source")]
54pub enum ProbeStatus {
55 Ok,
56 DnsFailure,
57 TcpRefused,
58 TcpUnreachable,
59 Timeout { secs: u64 },
60 TlsHandshakeFailed,
61 ConnectedNoCert,
62 Unknown { reason: String },
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct ChainEntry {
67 pub depth: u32,
68 pub certificate_id: u64,
69 pub common_name: Option<String>,
70 pub ski: Option<String>,
71 pub aki: Option<String>,
72 pub not_before: Option<DateTime<Utc>>,
73 pub not_after: Option<DateTime<Utc>>,
74 pub key_algorithm: Option<String>,
75 pub key_size: Option<i64>,
76 pub signature_key_algorithm: Option<String>,
77 pub signature_hash_algorithm: Option<String>,
78 pub issuer_ca_name: Option<String>,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct Certificate {
83 pub serial: String,
84 pub in_ct: bool,
85 pub served_live: bool,
86 pub currently_valid: bool,
87 pub ct_ids: Vec<u64>,
88 pub common_name: Option<String>,
89 pub identities: Vec<String>,
90 pub not_before: Option<DateTime<Utc>>,
91 pub not_after: Option<DateTime<Utc>>,
92 pub pubkey_alg: Option<String>,
93 pub sig_alg: Option<String>,
94 pub issuer: Option<String>,
95 pub key_algorithm: Option<String>,
96 pub key_size: Option<i64>,
97 pub sig_key_algorithm: Option<String>,
98 pub sig_hash_algorithm: Option<String>,
99 pub cert_signature: Option<String>,
100 #[serde(default)]
101 pub chain: Vec<ChainEntry>,
102}
103
104#[derive(Debug, Clone, Serialize)]
105pub struct CertRef {
106 pub serial: String,
107 pub kind: MatchKind,
108}
109
110#[derive(Debug, Clone, Serialize)]
111#[serde(rename_all = "snake_case", tag = "source")]
112pub enum DnsRef {
113 Srv { service: String },
114 Https,
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
119#[serde(rename_all = "snake_case")]
120pub enum DnsStatus {
121 Ok,
123 NoData,
125 NxDomain,
127 ServFail,
128 Refused,
129 Timeout,
131 Other,
133}
134
135impl DnsStatus {
136 pub fn label(&self) -> &'static str {
137 match self {
138 Self::Ok => "resolves",
139 Self::NoData => "no data",
140 Self::NxDomain => "nxdomain",
141 Self::ServFail => "servfail",
142 Self::Refused => "refused",
143 Self::Timeout => "timeout",
144 Self::Other => "unknown",
145 }
146 }
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct ServedCert {
151 pub serial: String,
152 pub common_name: Option<String>,
153 pub identities: Vec<String>,
154 pub pubkey_alg: Option<String>,
155 pub sig_alg: Option<String>,
156 pub issuer: Option<String>,
157 pub not_before: Option<DateTime<Utc>>,
158 pub not_after: Option<DateTime<Utc>>,
159 pub chain_sigs: Vec<String>,
161 pub pubkey_curve: Option<String>,
162 pub cert_signature: Option<String>,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct Negotiated {
168 pub tls_version: Option<String>,
169 pub kx_group: Option<String>,
170 pub cipher: Option<String>,
171}
172
173impl ServedCert {
174 pub fn into_certificate(self) -> Certificate {
175 let currently_valid =
176 crate::discover::ct::currently_valid(self.not_before, self.not_after, Utc::now());
177 Certificate {
178 serial: self.serial,
179 in_ct: false,
180 served_live: true,
181 currently_valid,
182 ct_ids: Vec::new(),
183 common_name: self.common_name,
184 identities: self.identities,
185 not_before: self.not_before,
186 not_after: self.not_after,
187 pubkey_alg: self.pubkey_alg,
188 sig_alg: self.sig_alg,
189 issuer: self.issuer,
190 key_algorithm: None,
191 key_size: None,
192 sig_key_algorithm: None,
193 sig_hash_algorithm: None,
194 cert_signature: self.cert_signature,
195 chain: Vec::new(),
196 }
197 }
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct Endpoint {
202 pub status: ProbeStatus,
203 pub ip: Option<IpAddr>,
204 pub served: Option<ServedCert>,
205 pub served_not_in_ct: bool,
206 pub negotiated: Option<Negotiated>,
207 pub observed_at: Option<DateTime<Utc>>,
208}
209
210impl Endpoint {
211 pub fn failed(status: ProbeStatus) -> Self {
212 Self {
213 status,
214 ip: None,
215 served: None,
216 served_not_in_ct: false,
217 negotiated: None,
218 observed_at: None,
219 }
220 }
221}
222
223#[derive(Debug, Clone, Default, Serialize)]
224pub struct Host {
225 pub name: String,
226 pub origins: Origins,
227 pub resolves: Option<bool>,
228 pub dns_status: Option<DnsStatus>,
229 pub ct_refs: Vec<CertRef>,
230 pub dns_refs: Vec<DnsRef>,
231 pub endpoint: Option<Endpoint>,
232 pub ports: Vec<PortScan>,
233}
234
235#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
236#[serde(rename_all = "snake_case")]
237pub enum ScanState {
238 Open,
239 Closed,
240 Filtered,
241}
242
243#[derive(Debug, Clone, Serialize, Deserialize)]
244pub struct PortScan {
245 pub port: u16,
246 pub state: ScanState,
247 pub served: Option<ServedCert>,
248 pub negotiated: Option<Negotiated>,
249 pub observed_at: Option<DateTime<Utc>>,
250}
251
252#[derive(Debug, Clone, Serialize)]
253pub struct SrvRecord {
254 pub service: String,
255 pub fqdn: String,
256 pub priority: u32,
257 pub weight: u32,
258 pub port: u16,
259 pub target: String,
260}
261#[derive(Debug, Clone, Serialize)]
262pub struct MxRecord {
263 pub priority: u32,
264 pub target: String,
265}
266
267#[derive(Debug, Clone, Serialize)]
268pub struct HttpsRecord {
269 pub rr_type: String,
270 pub priority: u16,
271 pub target: String,
272 pub params: String,
273}
274
275#[derive(Debug, Clone, Serialize)]
276#[serde(rename_all = "snake_case")]
277pub enum Provenance {
278 Fresh,
279 Cached { age_secs: u64 },
280 Stale { age_secs: Option<u64> },
281}
282
283fn hash_norm(raw: &str) -> String {
284 raw.trim().to_ascii_uppercase().replace('-', "")
285}
286
287pub fn alg_class_of(raw: &str) -> String {
288 let lower = raw.trim().to_ascii_lowercase();
289 if lower.contains("ed25519") {
290 "Ed25519".to_string()
291 } else if lower.contains("ed448") {
292 "Ed448".to_string()
293 } else if lower.starts_with("ml-dsa") || lower.contains("mldsa") {
294 raw.trim().to_string()
295 } else if lower.contains("ecdsa") || lower.contains("ecpublickey") || lower == "ec" {
296 "ECDSA".to_string()
297 } else if lower.contains("rsa") {
298 "RSA".to_string()
299 } else {
300 raw.trim().to_string()
301 }
302}
303
304pub fn curve_from_key_size(size: i64) -> Option<&'static str> {
305 match size {
306 256 => Some("P256"),
307 384 => Some("P384"),
308 521 => Some("P521"),
309 _ => None,
310 }
311}
312
313pub fn cert_key_name(
314 alg: Option<&str>,
315 key_size: Option<i64>,
316 curve: Option<&str>,
317) -> Option<String> {
318 let class = alg_class_of(alg?);
319 if class == "ECDSA" {
320 let bits = key_size.or_else(|| {
321 curve.and_then(|c| c.trim().trim_start_matches(['P', 'p', '-']).parse().ok())
322 });
323 return Some(match bits {
324 Some(b) => format!("EC-{b}"),
325 None => "EC".to_string(),
326 });
327 }
328 Some(match key_size {
329 Some(b) => format!("{class}-{b}"),
330 None => class,
331 })
332}
333
334pub fn cert_signature_name(class: &str, hash: Option<&str>, curve: Option<&str>) -> Option<String> {
335 let class = alg_class_of(class);
336 match class.as_str() {
337 "RSA" => Some(match hash.map(hash_norm) {
338 Some(h) => format!("RSA-{h}"),
339 None => "RSA".to_string(),
340 }),
341 "ECDSA" => Some(match curve {
342 Some(c) => format!("ECDSA-{c}"),
343 None => "ECDSA".to_string(),
344 }),
345 other => Some(other.to_string()),
346 }
347}
348
349pub fn cert_signature_from_db(
350 key_algorithm: Option<&str>,
351 key_size: Option<i64>,
352 sig_key_algorithm: Option<&str>,
353 sig_hash_algorithm: Option<&str>,
354) -> Option<String> {
355 let class = sig_key_algorithm.or(key_algorithm)?;
356 let curve = if alg_class_of(class) == "ECDSA" {
357 key_size.and_then(curve_from_key_size)
358 } else {
359 None
360 };
361 cert_signature_name(class, sig_hash_algorithm, curve)
362}
363
364pub fn port_service(port: u16) -> &'static str {
365 match port {
366 25 | 587 => "SMTP",
367 465 => "SMTPS",
368 993 => "IMAPS",
369 995 => "POP3S",
370 636 => "LDAPS",
371 5061 => "SIPS",
372 8883 => "MQTTS",
373 5222 => "XMPP",
374 5223 => "XMPPS",
375 _ => "TLS",
376 }
377}
378
379pub fn symmetric_name(cipher: &str) -> Option<String> {
380 let upper = cipher.to_ascii_uppercase();
381 if upper.contains("CHACHA20") {
382 return Some("CHACHA20-POLY1305".to_string());
383 }
384 let bits = if upper.contains("AES_128") {
385 "AES128"
386 } else if upper.contains("AES_256") {
387 "AES256"
388 } else {
389 return None;
390 };
391 let mode = if upper.contains("GCM") {
392 "GCM"
393 } else if upper.contains("CBC") {
394 "CBC"
395 } else {
396 return None;
397 };
398 Some(format!("{bits}-{mode}"))
399}
400
401#[derive(Debug, Clone, Copy, Default, Serialize)]
402pub struct CtStats {
403 pub rows: usize,
405 pub valid: usize,
407 pub future: usize,
409}
410
411#[derive(Debug, Serialize)]
412pub struct DomainState {
413 pub domain: String,
414 pub checked_at: DateTime<Local>,
415 pub provenance: Provenance,
416 pub ct_stats: CtStats,
417 pub certs: BTreeMap<String, Certificate>,
418 pub hosts: BTreeMap<String, Host>,
419 pub srv: Vec<SrvRecord>,
420 pub https: Vec<HttpsRecord>,
421 pub mx: Vec<MxRecord>,
422}
423
424impl DomainState {
425 pub fn host_names(&self) -> Vec<String> {
426 self.hosts.keys().cloned().collect()
427 }
428}
429
430#[cfg(test)]
431pub fn fixture() -> DomainState {
432 use chrono::TimeZone;
433
434 let now = Utc::now();
435 let nb = now - chrono::Duration::days(30);
436 let na = now + chrono::Duration::days(30);
437 let observed = Utc.with_ymd_and_hms(2026, 9, 21, 12, 0, 0).unwrap();
438
439 let mut certs = BTreeMap::new();
440 certs.insert(
441 "aa11".to_string(),
442 Certificate {
443 serial: "aa11".to_string(),
444 in_ct: true,
445 served_live: true,
446 currently_valid: true,
447 ct_ids: vec![9612296601],
448 common_name: Some("www.example.com".to_string()),
449 identities: vec!["example.com".to_string(), "www.example.com".to_string()],
450 not_before: Some(nb),
451 not_after: Some(na),
452 pubkey_alg: Some("RSA".to_string()),
453 sig_alg: Some("SHA256/RSA".to_string()),
454 issuer: Some("CN=DigiCert TLS RSA SHA256 2020 CA-1".to_string()),
455 key_algorithm: Some("RSA".to_string()),
456 key_size: Some(2048),
457 sig_key_algorithm: Some("RSA".to_string()),
458 sig_hash_algorithm: Some("SHA-256".to_string()),
459 cert_signature: Some("RSA-SHA256".to_string()),
460 chain: vec![
461 ChainEntry {
462 depth: 0,
463 certificate_id: 9612296601,
464 common_name: Some("www.example.com".to_string()),
465 ski: Some("aabb".to_string()),
466 aki: None,
467 not_before: Some(nb),
468 not_after: Some(na),
469 key_algorithm: Some("RSA".to_string()),
470 key_size: Some(2048),
471 signature_key_algorithm: Some("RSA".to_string()),
472 signature_hash_algorithm: Some("SHA-256".to_string()),
473 issuer_ca_name: Some("CN=DigiCert TLS RSA SHA256 2020 CA-1".to_string()),
474 },
475 ChainEntry {
476 depth: 1,
477 certificate_id: 90000000001,
478 common_name: Some("DigiCert TLS RSA SHA256 2020 CA-1".to_string()),
479 ski: Some("bbcc".to_string()),
480 aki: Some("aabb".to_string()),
481 not_before: Some(nb),
482 not_after: Some(na),
483 key_algorithm: Some("RSA".to_string()),
484 key_size: Some(2048),
485 signature_key_algorithm: Some("RSA".to_string()),
486 signature_hash_algorithm: Some("SHA-256".to_string()),
487 issuer_ca_name: Some("CN=DigiCert Global Root CA".to_string()),
488 },
489 ChainEntry {
490 depth: 2,
491 certificate_id: 90000000002,
492 common_name: Some("DigiCert Global Root CA".to_string()),
493 ski: Some("ccdd".to_string()),
494 aki: Some("bbcc".to_string()),
495 not_before: Some(nb),
496 not_after: Some(na),
497 key_algorithm: Some("RSA".to_string()),
498 key_size: Some(2048),
499 signature_key_algorithm: Some("RSA".to_string()),
500 signature_hash_algorithm: Some("SHA-256".to_string()),
501 issuer_ca_name: Some("CN=DigiCert Global Root CA".to_string()),
502 },
503 ],
504 },
505 );
506 certs.insert(
507 "cc33".to_string(),
508 Certificate {
509 serial: "cc33".to_string(),
510 in_ct: true,
511 served_live: false,
512 currently_valid: true,
513 ct_ids: vec![9612300001],
514 common_name: Some("eas.example.com".to_string()),
515 identities: vec!["eas.example.com".to_string()],
516 not_before: Some(nb),
517 not_after: Some(na - chrono::Duration::days(10)),
518 pubkey_alg: None,
519 sig_alg: None,
520 issuer: Some("CN=Certum OV TLS G2 R39 CA".to_string()),
521 key_algorithm: Some("RSA".to_string()),
522 key_size: Some(2048),
523 sig_key_algorithm: Some("RSA".to_string()),
524 sig_hash_algorithm: Some("SHA-256".to_string()),
525 cert_signature: Some("RSA-SHA256".to_string()),
526 chain: Vec::new(),
527 },
528 );
529 certs.insert(
530 "bb22".to_string(),
531 Certificate {
532 serial: "bb22".to_string(),
533 in_ct: true,
534 served_live: false,
535 currently_valid: true,
536 ct_ids: vec![9612297416, 9612300000],
537 common_name: Some("*.example.com".to_string()),
538 identities: vec!["*.example.com".to_string()],
539 not_before: Some(nb),
540 not_after: Some(na),
541 pubkey_alg: None,
542 sig_alg: None,
543 issuer: None,
544 key_algorithm: Some("EC".to_string()),
545 key_size: Some(256),
546 sig_key_algorithm: Some("ECDSA".to_string()),
547 sig_hash_algorithm: Some("SHA-256".to_string()),
548 cert_signature: Some("ECDSA-P256".to_string()),
549 chain: Vec::new(),
550 },
551 );
552
553 let mut hosts = BTreeMap::new();
554 let mut www = Host {
555 name: "www.example.com".to_string(),
556 origins: Origins::CT,
557 resolves: Some(true),
558 dns_status: Some(DnsStatus::Ok),
559 ct_refs: vec![CertRef {
560 serial: "aa11".to_string(),
561 kind: MatchKind::Exact,
562 }],
563 dns_refs: Vec::new(),
564 ports: Vec::new(),
565 endpoint: Some(Endpoint {
566 status: ProbeStatus::Ok,
567 negotiated: Some(Negotiated {
568 tls_version: Some("TLS1.3".to_string()),
569 kx_group: Some("X25519MLKEM768".to_string()),
570 cipher: Some("TLS13_AES_256_GCM_SHA384".to_string()),
571 }),
572 ip: Some("104.94.222.171".parse().unwrap()),
573 served: Some(ServedCert {
574 serial: "aa11".to_string(),
575 common_name: Some("www.example.com".to_string()),
576 identities: vec!["example.com".to_string(), "www.example.com".to_string()],
577 pubkey_alg: Some("RSA".to_string()),
578 sig_alg: Some("SHA256/RSA".to_string()),
579 issuer: Some("CN=DigiCert TLS RSA SHA256 2020 CA-1".to_string()),
580 not_before: Some(nb),
581 not_after: Some(na),
582 chain_sigs: vec!["SHA256/RSA".to_string()],
583 pubkey_curve: None,
584 cert_signature: Some("RSA-SHA256".to_string()),
585 }),
586 served_not_in_ct: false,
587 observed_at: Some(observed),
588 }),
589 };
590 www.origins.insert(Origins::DNS);
591 hosts.insert("www.example.com".to_string(), www);
592 hosts.insert(
593 "example.com".to_string(),
594 Host {
595 name: "example.com".to_string(),
596 origins: Origins::APEX,
597 resolves: Some(true),
598 dns_status: Some(DnsStatus::Ok),
599 ct_refs: vec![CertRef {
600 serial: "aa11".to_string(),
601 kind: MatchKind::Exact,
602 }],
603 dns_refs: Vec::new(),
604 ports: Vec::new(),
605 endpoint: Some(Endpoint {
606 status: ProbeStatus::Ok,
607 ip: Some("104.94.222.1".parse().unwrap()),
608 served: Some(ServedCert {
609 serial: "aa11".to_string(),
610 common_name: Some("www.example.com".to_string()),
611 identities: vec!["example.com".to_string(), "www.example.com".to_string()],
612 pubkey_alg: Some("RSA".to_string()),
613 sig_alg: Some("SHA256/RSA".to_string()),
614 issuer: Some("CN=DigiCert TLS RSA SHA256 2020 CA-1".to_string()),
615 not_before: Some(nb),
616 not_after: Some(na),
617 chain_sigs: vec!["SHA256/RSA".to_string()],
618 pubkey_curve: None,
619 cert_signature: Some("RSA-SHA256".to_string()),
620 }),
621 served_not_in_ct: false,
622 observed_at: Some(observed),
623 negotiated: Some(Negotiated {
624 tls_version: Some("TLS1.3".to_string()),
625 kx_group: Some("X25519MLKEM768".to_string()),
626 cipher: Some("TLS13_AES_256_GCM_SHA384".to_string()),
627 }),
628 }),
629 },
630 );
631 hosts.insert(
632 "old.example.com".to_string(),
633 Host {
634 name: "old.example.com".to_string(),
635 origins: Origins::CT,
636 resolves: Some(false),
637 dns_status: Some(DnsStatus::NxDomain),
638 ct_refs: vec![CertRef {
639 serial: "bb22".to_string(),
640 kind: MatchKind::Wildcard,
641 }],
642 dns_refs: Vec::new(),
643 ports: Vec::new(),
644 endpoint: Some(Endpoint::failed(ProbeStatus::DnsFailure)),
645 },
646 );
647 hosts.insert(
648 "vpn.example.com".to_string(),
649 Host {
650 name: "vpn.example.com".to_string(),
651 origins: Origins::CT,
652 resolves: Some(true),
653 dns_status: Some(DnsStatus::Ok),
654 ct_refs: vec![CertRef {
655 serial: "bb22".to_string(),
656 kind: MatchKind::Wildcard,
657 }],
658 dns_refs: Vec::new(),
659 ports: Vec::new(),
660 endpoint: Some(Endpoint::failed(ProbeStatus::TcpRefused)),
661 },
662 );
663 hosts.insert(
664 "eas.example.com".to_string(),
665 Host {
666 name: "eas.example.com".to_string(),
667 origins: Origins::CT,
668 resolves: Some(true),
669 dns_status: Some(DnsStatus::Ok),
670 ct_refs: vec![
671 CertRef {
672 serial: "bb22".to_string(),
673 kind: MatchKind::Wildcard,
674 },
675 CertRef {
676 serial: "cc33".to_string(),
677 kind: MatchKind::Exact,
678 },
679 ],
680 dns_refs: Vec::new(),
681 ports: vec![
682 PortScan {
683 port: 465,
684 state: ScanState::Open,
685 served: Some(ServedCert {
686 serial: "cc33".to_string(),
687 common_name: Some("eas.example.com".to_string()),
688 identities: vec!["eas.example.com".to_string()],
689 pubkey_alg: Some("RSA".to_string()),
690 sig_alg: Some("SHA256/RSA".to_string()),
691 issuer: Some("CN=Certum OV TLS G2 R39 CA".to_string()),
692 not_before: Some(nb),
693 not_after: Some(na - chrono::Duration::days(10)),
694 chain_sigs: vec!["SHA256/RSA".to_string()],
695 pubkey_curve: None,
696 cert_signature: Some("RSA-SHA256".to_string()),
697 }),
698 negotiated: Some(Negotiated {
699 tls_version: Some("TLS1.3".to_string()),
700 kx_group: Some("X25519".to_string()),
701 cipher: Some("TLS_AES_256_GCM_SHA384".to_string()),
702 }),
703 observed_at: Some(observed),
704 },
705 PortScan {
706 port: 25,
707 state: ScanState::Open,
708 served: Some(ServedCert {
709 serial: "cc33".to_string(),
710 common_name: Some("eas.example.com".to_string()),
711 identities: vec!["eas.example.com".to_string()],
712 pubkey_alg: Some("RSA".to_string()),
713 sig_alg: Some("SHA256/RSA".to_string()),
714 issuer: Some("CN=Certum OV TLS G2 R39 CA".to_string()),
715 not_before: Some(nb),
716 not_after: Some(na - chrono::Duration::days(10)),
717 chain_sigs: vec!["SHA256/RSA".to_string()],
718 pubkey_curve: None,
719 cert_signature: Some("RSA-SHA256".to_string()),
720 }),
721 negotiated: Some(Negotiated {
722 tls_version: Some("TLS1.3".to_string()),
723 kx_group: Some("X25519".to_string()),
724 cipher: Some("TLS_AES_256_GCM_SHA384".to_string()),
725 }),
726 observed_at: Some(observed),
727 },
728 ],
729 endpoint: Some(Endpoint::failed(ProbeStatus::TcpRefused)),
730 },
731 );
732 let mut apex = hosts.get("example.com").cloned().unwrap();
733 apex.origins.insert(Origins::CT);
734 hosts.insert("example.com".to_string(), apex);
735
736 DomainState {
737 domain: "example.com".to_string(),
738 checked_at: Local.with_ymd_and_hms(2026, 9, 21, 12, 0, 0).unwrap(),
739 provenance: Provenance::Cached { age_secs: 2520 },
740 certs,
741 hosts,
742 mx: vec![MxRecord {
743 priority: 10,
744 target: "eas.example.com".to_string(),
745 }],
746 srv: vec![SrvRecord {
747 service: "_sip._tls".to_string(),
748 fqdn: "_sip._tls.example.com".to_string(),
749 priority: 1,
750 weight: 100,
751 port: 443,
752 target: "sipdir.online.lync.com".to_string(),
753 }],
754 https: vec![HttpsRecord {
755 rr_type: "HTTPS".to_string(),
756 priority: 1,
757 target: ".".to_string(),
758 params: "alpn=h2,h3".to_string(),
759 }],
760 ct_stats: CtStats {
761 rows: 2,
762 valid: 1,
763 future: 1,
764 },
765 }
766}
767
768#[cfg(test)]
769mod tests {
770 use super::*;
771
772 #[test]
773 fn cert_signature_names_are_canonical() {
774 assert_eq!(
775 cert_signature_from_db(Some("RSA"), Some(2048), Some("RSA"), Some("SHA-256")),
776 Some("RSA-SHA256".to_string())
777 );
778 assert_eq!(
779 cert_signature_from_db(Some("EC"), Some(256), Some("ECDSA"), Some("SHA-256")),
780 Some("ECDSA-P256".to_string())
781 );
782 assert_eq!(
783 cert_signature_from_db(Some("EC"), Some(384), Some("ECDSA"), Some("SHA-384")),
784 Some("ECDSA-P384".to_string())
785 );
786 assert_eq!(
787 cert_signature_name("rsaEncryption", Some("sha256"), None),
788 Some("RSA-SHA256".to_string())
789 );
790 assert_eq!(
791 cert_signature_name("id-ecPublicKey", Some("ecdsa-with-SHA384"), Some("P384")),
792 Some("ECDSA-P384".to_string())
793 );
794 assert_eq!(
795 cert_signature_name("Ed25519", None, None),
796 Some("Ed25519".to_string())
797 );
798 assert_eq!(
799 cert_signature_name("ML-DSA-65", None, None),
800 Some("ML-DSA-65".to_string())
801 );
802 assert_eq!(
803 cert_signature_name("1.2.3.4.5", None, None),
804 Some("1.2.3.4.5".to_string())
805 );
806 assert_eq!(cert_signature_from_db(None, None, None, None), None);
807 }
808
809 #[test]
810 fn curve_from_key_size_maps_nist_curves() {
811 assert_eq!(curve_from_key_size(256), Some("P256"));
812 assert_eq!(curve_from_key_size(384), Some("P384"));
813 assert_eq!(curve_from_key_size(521), Some("P521"));
814 assert_eq!(curve_from_key_size(2048), None);
815 assert_eq!(curve_from_key_size(512), None);
816 }
817
818 #[test]
819 fn cert_key_names_are_canonical() {
820 assert_eq!(
821 cert_key_name(Some("RSA"), Some(2048), None),
822 Some("RSA-2048".to_string())
823 );
824 assert_eq!(
825 cert_key_name(Some("EC"), Some(256), None),
826 Some("EC-256".to_string())
827 );
828 assert_eq!(
829 cert_key_name(Some("EC"), Some(384), None),
830 Some("EC-384".to_string())
831 );
832 assert_eq!(
833 cert_key_name(Some("ECDSA"), None, Some("P256")),
834 Some("EC-256".to_string())
835 );
836 assert_eq!(
837 cert_key_name(Some("id-ecPublicKey"), None, Some("P521")),
838 Some("EC-521".to_string())
839 );
840 assert_eq!(
841 cert_key_name(Some("RSA"), None, None),
842 Some("RSA".to_string())
843 );
844 assert_eq!(
845 cert_key_name(Some("Ed25519"), None, None),
846 Some("Ed25519".to_string())
847 );
848 assert_eq!(cert_key_name(None, None, None), None);
849 }
850
851 #[test]
852 fn symmetric_names_from_suite_names() {
853 assert_eq!(
854 symmetric_name("TLS13_AES_256_GCM_SHA384"),
855 Some("AES256-GCM".to_string())
856 );
857 assert_eq!(
858 symmetric_name("TLS_AES_128_GCM_SHA256"),
859 Some("AES128-GCM".to_string())
860 );
861 assert_eq!(
862 symmetric_name("TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256"),
863 Some("AES128-GCM".to_string())
864 );
865 assert_eq!(
866 symmetric_name("TLS13_CHACHA20_POLY1305_SHA256"),
867 Some("CHACHA20-POLY1305".to_string())
868 );
869 assert_eq!(
870 symmetric_name("TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384"),
871 Some("AES256-CBC".to_string())
872 );
873 assert_eq!(symmetric_name("TLS_UNKNOWN"), None);
874 }
875
876 #[test]
877 fn fixture_facts_are_consistent() {
878 let state = fixture();
879 let aa = &state.certs["aa11"];
880 assert_eq!(aa.cert_signature.as_deref(), Some("RSA-SHA256"));
881 assert_eq!(aa.key_size, Some(2048));
882 let bb = &state.certs["bb22"];
883 assert_eq!(bb.cert_signature.as_deref(), Some("ECDSA-P256"));
884 }
885}