From 8218fd584e4053848738a310258893e14c4ee10e Mon Sep 17 00:00:00 2001 From: salmonik Date: Mon, 24 Aug 2026 15:27:06 +0200 Subject: TOFU host trust: SPKI fingerprint pinning, tests, forget-host --- src/tofu.rs | 114 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 src/tofu.rs (limited to 'src/tofu.rs') 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 @@ +//! Trust-On-First-Use host verification. +//! +//! Model (SSH known_hosts style): +//! - First contact with a host: record the SHA-256 fingerprint of the +//! certificate's SubjectPublicKeyInfo (SPKI, not the whole certificate, so +//! routine cert renewal with the same key does not break clients). +//! - Later contacts must present the same key or the connection is rejected. + +use sha2::{Digest, Sha256}; +use std::ops::Range; + +/// Outcome of comparing an observed fingerprint against stored trust state. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TrustDecision { + /// Host never seen: accept and store `fingerprint`. + TrustNew, + /// Stored fingerprint matches: proceed. + Trusted, + /// Key changed since first use: reject with details. + Mismatch { + expected: String, + actual: String, + }, +} + +/// Hex-encoded SHA-256 of the SPKI inside a DER-encoded X.509 certificate. +/// +/// Fails if `cert_der` is not parseable as a certificate. +pub fn spki_fingerprint(cert_der: &[u8]) -> Result { + let spki = der_spki(cert_der)?; + Ok(hex_encode(&Sha256::digest(&cert_der[spki]))) +} + +/// Decide what to do with an observed fingerprint given stored state +/// (`None` when the host has never been trusted). +pub fn decide(stored: Option<&str>, observed: &str) -> TrustDecision { + match stored { + None => TrustDecision::TrustNew, + Some(expected) if expected == observed => TrustDecision::Trusted, + Some(expected) => TrustDecision::Mismatch { + expected: expected.to_string(), + actual: observed.to_string(), + }, + } +} + +fn hex_encode(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +/// Locate the complete DER encoding of the certificate's SubjectPublicKeyInfo +/// (TLV including tag and length bytes — the same bytes +/// `openssl pkey -pubin -outform DER` produces). +fn der_spki(der: &[u8]) -> Result, String> { + let mut pos = 0; + + // Certificate ::= SEQUENCE { tbsCertificate, ... } + let (_, _cert_full, cert_body) = read_tlv(der, &mut pos)?; + + // TBSCertificate ::= SEQUENCE { version [0] OPTIONAL, serial, signature, + // issuer, validity, subject, subjectPublicKeyInfo, ... } + let mut pos = cert_body.start; + let (_, _tbs_full, tbs) = read_tlv(der, &mut pos)?; + + let mut pos = tbs.start; + // Slots before SPKI: [0] version (optional), serial, signature, + let mut next = || read_tlv(der, &mut pos); + let (first_tag, _, _) = next()?; + let skip = if first_tag == 0xA0 { 5 } else { 4 }; + for _ in 0..skip { + next()?; + } + let (spki_tag, spki_full, _) = next()?; + if spki_tag != 0x30 { + return Err(format!("expected SPKI SEQUENCE, got tag {spki_tag:#x}")); + } + Ok(spki_full) +} + +/// Read one DER TLV at *pos. Returns (tag, full range incl. header, +/// content range) and advances pos past the element. +fn read_tlv(der: &[u8], pos: &mut usize) -> Result<(u8, Range, Range), String> { + let elem_start = *pos; + if *pos + 2 > der.len() { + return Err("truncated DER".into()); + } + let tag = der[*pos]; + *pos += 1; + let first = der[*pos]; + *pos += 1; + let content_len = if first < 0x80 { + first as usize + } else { + let n = (first & 0x7F) as usize; + if n == 0 || n > 4 || *pos + n > der.len() { + return Err("unsupported DER length".into()); + } + let mut v = 0usize; + for b in &der[*pos..*pos + n] { + v = (v << 8) | *b as usize; + } + *pos += n; + v + }; + let content_start = *pos; + let end = content_start + .checked_add(content_len) + .ok_or("DER length overflow")?; + if end > der.len() { + return Err("truncated DER element".into()); + } + *pos = end; + Ok((tag, elem_start..end, content_start..end)) +} -- cgit v1.2.3