summaryrefslogtreecommitdiffstats
path: root/src/tofu.rs
blob: 15f38d1ca5cf210410803cf3214d17bdb6c38369 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
//! 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<String, String> {
    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<Range<usize>, 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<usize>, Range<usize>), 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))
}