diff options
| author | salmonik <salmonik@tuta.io> | 2026-08-24 15:27:06 +0200 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-08-24 15:27:06 +0200 |
| commit | 8218fd584e4053848738a310258893e14c4ee10e (patch) | |
| tree | 2c92997ba0b6aa260fe72c88190f5f8b590457cb /src/tofu.rs | |
| parent | TLS transport and games framework on client and server (diff) | |
| download | zagan-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.rs | 114 |
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 | |||
| 9 | use sha2::{Digest, Sha256}; | ||
| 10 | use std::ops::Range; | ||
| 11 | |||
| 12 | /// Outcome of comparing an observed fingerprint against stored trust state. | ||
| 13 | #[derive(Debug, Clone, PartialEq, Eq)] | ||
| 14 | pub 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. | ||
| 29 | pub 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). | ||
| 36 | pub 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 | |||
| 47 | fn 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). | ||
| 54 | fn 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. | ||
| 82 | fn 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 | } | ||
