summaryrefslogtreecommitdiffstats
path: root/src/tofu.rs
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-08-24 15:27:06 +0200
committersalmonik <salmonik@tuta.io>2026-08-24 15:27:06 +0200
commit8218fd584e4053848738a310258893e14c4ee10e (patch)
tree2c92997ba0b6aa260fe72c88190f5f8b590457cb /src/tofu.rs
parentTLS transport and games framework on client and server (diff)
downloadzagan-bet-8218fd584e4053848738a310258893e14c4ee10e.tar.gz
zagan-bet-8218fd584e4053848738a310258893e14c4ee10e.zip
TOFU host trust: SPKI fingerprint pinning, tests, forget-host
Diffstat (limited to 'src/tofu.rs')
-rw-r--r--src/tofu.rs114
1 files changed, 114 insertions, 0 deletions
diff --git a/src/tofu.rs b/src/tofu.rs
new file mode 100644
index 0000000..6308813
--- /dev/null
+++ b/src/tofu.rs
@@ -0,0 +1,114 @@
1//! Trust-On-First-Use host verification.
2//!
3//! Model (SSH known_hosts style):
4//! - First contact with a host: record the SHA-256 fingerprint of the
5//! certificate's SubjectPublicKeyInfo (SPKI, not the whole certificate, so
6//! routine cert renewal with the same key does not break clients).
7//! - Later contacts must present the same key or the connection is rejected.
8
9use sha2::{Digest, Sha256};
10use std::ops::Range;
11
12/// Outcome of comparing an observed fingerprint against stored trust state.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum TrustDecision {
15 /// Host never seen: accept and store `fingerprint`.
16 TrustNew,
17 /// Stored fingerprint matches: proceed.
18 Trusted,
19 /// Key changed since first use: reject with details.
20 Mismatch {
21 expected: String,
22 actual: String,
23 },
24}
25
26/// Hex-encoded SHA-256 of the SPKI inside a DER-encoded X.509 certificate.
27///
28/// Fails if `cert_der` is not parseable as a certificate.
29pub fn spki_fingerprint(cert_der: &[u8]) -> Result<String, String> {
30 let spki = der_spki(cert_der)?;
31 Ok(hex_encode(&Sha256::digest(&cert_der[spki])))
32}
33
34/// Decide what to do with an observed fingerprint given stored state
35/// (`None` when the host has never been trusted).
36pub fn decide(stored: Option<&str>, observed: &str) -> TrustDecision {
37 match stored {
38 None => TrustDecision::TrustNew,
39 Some(expected) if expected == observed => TrustDecision::Trusted,
40 Some(expected) => TrustDecision::Mismatch {
41 expected: expected.to_string(),
42 actual: observed.to_string(),
43 },
44 }
45}
46
47fn hex_encode(bytes: &[u8]) -> String {
48 bytes.iter().map(|b| format!("{b:02x}")).collect()
49}
50
51/// Locate the complete DER encoding of the certificate's SubjectPublicKeyInfo
52/// (TLV including tag and length bytes — the same bytes
53/// `openssl pkey -pubin -outform DER` produces).
54fn der_spki(der: &[u8]) -> Result<Range<usize>, String> {
55 let mut pos = 0;
56
57 // Certificate ::= SEQUENCE { tbsCertificate, ... }
58 let (_, _cert_full, cert_body) = read_tlv(der, &mut pos)?;
59
60 // TBSCertificate ::= SEQUENCE { version [0] OPTIONAL, serial, signature,
61 // issuer, validity, subject, subjectPublicKeyInfo, ... }
62 let mut pos = cert_body.start;
63 let (_, _tbs_full, tbs) = read_tlv(der, &mut pos)?;
64
65 let mut pos = tbs.start;
66 // Slots before SPKI: [0] version (optional), serial, signature,
67 let mut next = || read_tlv(der, &mut pos);
68 let (first_tag, _, _) = next()?;
69 let skip = if first_tag == 0xA0 { 5 } else { 4 };
70 for _ in 0..skip {
71 next()?;
72 }
73 let (spki_tag, spki_full, _) = next()?;
74 if spki_tag != 0x30 {
75 return Err(format!("expected SPKI SEQUENCE, got tag {spki_tag:#x}"));
76 }
77 Ok(spki_full)
78}
79
80/// Read one DER TLV at *pos. Returns (tag, full range incl. header,
81/// content range) and advances pos past the element.
82fn read_tlv(der: &[u8], pos: &mut usize) -> Result<(u8, Range<usize>, Range<usize>), String> {
83 let elem_start = *pos;
84 if *pos + 2 > der.len() {
85 return Err("truncated DER".into());
86 }
87 let tag = der[*pos];
88 *pos += 1;
89 let first = der[*pos];
90 *pos += 1;
91 let content_len = if first < 0x80 {
92 first as usize
93 } else {
94 let n = (first & 0x7F) as usize;
95 if n == 0 || n > 4 || *pos + n > der.len() {
96 return Err("unsupported DER length".into());
97 }
98 let mut v = 0usize;
99 for b in &der[*pos..*pos + n] {
100 v = (v << 8) | *b as usize;
101 }
102 *pos += n;
103 v
104 };
105 let content_start = *pos;
106 let end = content_start
107 .checked_add(content_len)
108 .ok_or("DER length overflow")?;
109 if end > der.len() {
110 return Err("truncated DER element".into());
111 }
112 *pos = end;
113 Ok((tag, elem_start..end, content_start..end))
114}