Skip to main content

ipfrs_network/
cert_pin.rs

1//! Certificate fingerprint pinning for QUIC/TLS connections.
2//!
3//! This module provides `CertPinStore`, which supports three pin policies:
4//! - **TrustOnFirstUse (TOFU)**: Accept and pin unknown peers on first contact.
5//! - **Strict**: Reject peers whose certificates are not pre-registered.
6//! - **Observe**: Always accept but count mismatches for monitoring.
7
8use std::collections::HashMap;
9use std::sync::atomic::{AtomicU64, Ordering};
10use std::sync::Arc;
11
12use parking_lot::RwLock;
13
14// ─── Fingerprint ─────────────────────────────────────────────────────────────
15
16/// SHA-256 fingerprint of a peer's TLS certificate (32 bytes, hex-encoded).
17///
18/// Internally stored as a 64-character lowercase hex string.
19#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
20pub struct CertFingerprint(pub String);
21
22impl CertFingerprint {
23    /// Create a fingerprint from raw bytes.
24    ///
25    /// Uses FNV-1a to derive a deterministic 64-character hex string, which
26    /// mimics the length and format of a real SHA-256 fingerprint without
27    /// requiring an external crypto crate.
28    pub fn from_bytes(bytes: &[u8]) -> Self {
29        let hash = fnv1a_hash_bytes(bytes);
30        Self(format!(
31            "{:016x}{:016x}{:016x}{:016x}",
32            hash,
33            hash ^ 0xDEAD_BEEF_DEAD_BEEF_u64,
34            hash ^ 0xBEEF_CAFE_BEEF_CAFE_u64,
35            hash ^ 0xCAFE_DEAD_CAFE_DEAD_u64,
36        ))
37    }
38
39    /// Return the hex string representation.
40    pub fn as_str(&self) -> &str {
41        &self.0
42    }
43
44    /// Length of the hex string (always 64 for well-formed fingerprints).
45    pub fn len(&self) -> usize {
46        self.0.len()
47    }
48
49    /// Returns `true` when the fingerprint has a valid 64-character hex format.
50    pub fn is_valid_format(&self) -> bool {
51        self.0.len() == 64 && self.0.chars().all(|c| c.is_ascii_hexdigit())
52    }
53
54    /// Returns `true` when the fingerprint string is empty.
55    pub fn is_empty(&self) -> bool {
56        self.0.is_empty()
57    }
58}
59
60/// FNV-1a 64-bit hash over a byte slice.
61fn fnv1a_hash_bytes(data: &[u8]) -> u64 {
62    let mut hash: u64 = 14_695_981_039_346_656_037;
63    for &byte in data {
64        hash ^= byte as u64;
65        hash = hash.wrapping_mul(1_099_511_628_211);
66    }
67    hash
68}
69
70// ─── PinPolicy ───────────────────────────────────────────────────────────────
71
72/// Pin policy for certificate verification.
73#[derive(Debug, Clone, PartialEq, Eq, Default)]
74pub enum PinPolicy {
75    /// Trust any certificate on first connection; pin thereafter (TOFU).
76    #[default]
77    TrustOnFirstUse,
78    /// Require the fingerprint to be pre-registered; reject unknown certs.
79    Strict,
80    /// Log mismatches but never reject (monitoring only).
81    Observe,
82}
83
84// ─── PeerCertPin ─────────────────────────────────────────────────────────────
85
86/// A pinned certificate entry for one peer.
87#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
88pub struct PeerCertPin {
89    /// Peer identifier (e.g. libp2p PeerId string).
90    pub peer_id: String,
91    /// Pinned certificate fingerprint.
92    pub fingerprint: CertFingerprint,
93    /// Unix timestamp (milliseconds) when this pin was first recorded.
94    pub pinned_at_ms: u64,
95    /// Unix timestamp (milliseconds) of the most recent successful verification.
96    pub last_seen_ms: u64,
97    /// Total number of successful verifications against this pin.
98    pub connection_count: u64,
99    /// `true` when the pin was learned via TOFU rather than pre-registered.
100    pub tofu: bool,
101}
102
103impl PeerCertPin {
104    /// Create a new pin entry.
105    pub fn new(
106        peer_id: impl Into<String>,
107        fingerprint: CertFingerprint,
108        now_ms: u64,
109        tofu: bool,
110    ) -> Self {
111        Self {
112            peer_id: peer_id.into(),
113            fingerprint,
114            pinned_at_ms: now_ms,
115            last_seen_ms: now_ms,
116            connection_count: 0,
117            tofu,
118        }
119    }
120
121    /// Update the last-seen timestamp and increment the connection counter.
122    pub fn record_connection(&mut self, now_ms: u64) {
123        self.last_seen_ms = now_ms;
124        self.connection_count += 1;
125    }
126}
127
128// ─── VerificationResult ──────────────────────────────────────────────────────
129
130/// Result of a certificate verification attempt.
131#[derive(Debug, Clone, PartialEq, Eq)]
132pub enum VerificationResult {
133    /// The presented fingerprint matches the pinned fingerprint (or policy allows).
134    Accepted,
135    /// TOFU: first time seeing this peer — accepted and pinned.
136    PinnedNew { peer_id: String },
137    /// Fingerprint mismatch detected.
138    Mismatch { expected: String, got: String },
139    /// Strict policy: peer has no pre-registered pin.
140    UnknownPeer { peer_id: String },
141}
142
143impl VerificationResult {
144    /// Returns `true` when the connection should be allowed to proceed.
145    pub fn is_accepted(&self) -> bool {
146        matches!(
147            self,
148            VerificationResult::Accepted | VerificationResult::PinnedNew { .. }
149        )
150    }
151}
152
153// ─── CertPinStore ────────────────────────────────────────────────────────────
154
155/// Thread-safe store for peer certificate fingerprint pins.
156///
157/// # Verification Semantics
158///
159/// | Policy            | Unknown peer            | Fingerprint matches | Fingerprint mismatch |
160/// |-------------------|-------------------------|---------------------|----------------------|
161/// | TrustOnFirstUse   | `PinnedNew` (pin + accept) | `Accepted`       | `Mismatch`           |
162/// | Strict            | `UnknownPeer` (reject)  | `Accepted`          | `Mismatch`           |
163/// | Observe           | `Accepted`              | `Accepted`          | `Accepted` (+counter)|
164pub struct CertPinStore {
165    pins: RwLock<HashMap<String, PeerCertPin>>,
166    policy: PinPolicy,
167    mismatch_count: AtomicU64,
168    tofu_count: AtomicU64,
169}
170
171impl CertPinStore {
172    /// Create a new store wrapped in an `Arc`.
173    pub fn new(policy: PinPolicy) -> Arc<Self> {
174        Arc::new(Self {
175            pins: RwLock::new(HashMap::new()),
176            policy,
177            mismatch_count: AtomicU64::new(0),
178            tofu_count: AtomicU64::new(0),
179        })
180    }
181
182    /// Pre-register a trusted fingerprint for a peer.
183    ///
184    /// If the peer is already pinned this call is a no-op (existing pin preserved).
185    pub fn pin(&self, peer_id: impl Into<String>, fingerprint: CertFingerprint, now_ms: u64) {
186        let peer_id = peer_id.into();
187        let mut guard = self.pins.write();
188        guard
189            .entry(peer_id.clone())
190            .or_insert_with(|| PeerCertPin::new(peer_id, fingerprint, now_ms, false));
191    }
192
193    /// Remove a pinned peer.
194    ///
195    /// Returns `true` if a pin was present and removed.
196    pub fn unpin(&self, peer_id: &str) -> bool {
197        self.pins.write().remove(peer_id).is_some()
198    }
199
200    /// Verify a peer's certificate fingerprint against the current policy.
201    pub fn verify(
202        &self,
203        peer_id: &str,
204        fingerprint: &CertFingerprint,
205        now_ms: u64,
206    ) -> VerificationResult {
207        match self.policy {
208            PinPolicy::TrustOnFirstUse => self.verify_tofu(peer_id, fingerprint, now_ms),
209            PinPolicy::Strict => self.verify_strict(peer_id, fingerprint, now_ms),
210            PinPolicy::Observe => self.verify_observe(peer_id, fingerprint, now_ms),
211        }
212    }
213
214    fn verify_tofu(
215        &self,
216        peer_id: &str,
217        fingerprint: &CertFingerprint,
218        now_ms: u64,
219    ) -> VerificationResult {
220        // Fast path: try read lock first.
221        {
222            let guard = self.pins.read();
223            if let Some(pin) = guard.get(peer_id) {
224                if pin.fingerprint == *fingerprint {
225                    drop(guard);
226                    // Upgrade to write to record the connection.
227                    let mut wguard = self.pins.write();
228                    if let Some(p) = wguard.get_mut(peer_id) {
229                        p.record_connection(now_ms);
230                    }
231                    return VerificationResult::Accepted;
232                } else {
233                    self.mismatch_count.fetch_add(1, Ordering::Relaxed);
234                    return VerificationResult::Mismatch {
235                        expected: pin.fingerprint.as_str().to_owned(),
236                        got: fingerprint.as_str().to_owned(),
237                    };
238                }
239            }
240        }
241        // Unknown peer — TOFU: pin and accept.
242        let mut new_pin = PeerCertPin::new(peer_id, fingerprint.clone(), now_ms, true);
243        new_pin.record_connection(now_ms);
244        self.pins.write().insert(peer_id.to_owned(), new_pin);
245        self.tofu_count.fetch_add(1, Ordering::Relaxed);
246        VerificationResult::PinnedNew {
247            peer_id: peer_id.to_owned(),
248        }
249    }
250
251    fn verify_strict(
252        &self,
253        peer_id: &str,
254        fingerprint: &CertFingerprint,
255        now_ms: u64,
256    ) -> VerificationResult {
257        let guard = self.pins.read();
258        if let Some(pin) = guard.get(peer_id) {
259            if pin.fingerprint == *fingerprint {
260                drop(guard);
261                let mut wguard = self.pins.write();
262                if let Some(p) = wguard.get_mut(peer_id) {
263                    p.record_connection(now_ms);
264                }
265                VerificationResult::Accepted
266            } else {
267                self.mismatch_count.fetch_add(1, Ordering::Relaxed);
268                VerificationResult::Mismatch {
269                    expected: pin.fingerprint.as_str().to_owned(),
270                    got: fingerprint.as_str().to_owned(),
271                }
272            }
273        } else {
274            VerificationResult::UnknownPeer {
275                peer_id: peer_id.to_owned(),
276            }
277        }
278    }
279
280    fn verify_observe(
281        &self,
282        peer_id: &str,
283        fingerprint: &CertFingerprint,
284        _now_ms: u64,
285    ) -> VerificationResult {
286        let guard = self.pins.read();
287        if let Some(pin) = guard.get(peer_id) {
288            if pin.fingerprint != *fingerprint {
289                self.mismatch_count.fetch_add(1, Ordering::Relaxed);
290            }
291        }
292        VerificationResult::Accepted
293    }
294
295    /// Retrieve the pin info for a peer, if any.
296    pub fn get_pin(&self, peer_id: &str) -> Option<PeerCertPin> {
297        self.pins.read().get(peer_id).cloned()
298    }
299
300    /// Return a list of all pinned peer IDs.
301    pub fn pinned_peers(&self) -> Vec<String> {
302        self.pins.read().keys().cloned().collect()
303    }
304
305    /// Number of currently pinned peers.
306    pub fn pin_count(&self) -> usize {
307        self.pins.read().len()
308    }
309
310    /// Total fingerprint mismatches detected across all verifications.
311    pub fn mismatch_count(&self) -> u64 {
312        self.mismatch_count.load(Ordering::Relaxed)
313    }
314
315    /// Total TOFU pins created.
316    pub fn tofu_count(&self) -> u64 {
317        self.tofu_count.load(Ordering::Relaxed)
318    }
319
320    /// Export all pins as a serializable vector.
321    pub fn export_pins(&self) -> Vec<PeerCertPin> {
322        self.pins.read().values().cloned().collect()
323    }
324
325    /// Import pins (merge).  Existing pins are **not** overwritten.
326    pub fn import_pins(&self, pins: Vec<PeerCertPin>) {
327        let mut guard = self.pins.write();
328        for pin in pins {
329            guard.entry(pin.peer_id.clone()).or_insert(pin);
330        }
331    }
332}
333
334// ─── Tests ───────────────────────────────────────────────────────────────────
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339
340    fn now_ms() -> u64 {
341        1_700_000_000_000
342    }
343
344    fn fp(seed: &[u8]) -> CertFingerprint {
345        CertFingerprint::from_bytes(seed)
346    }
347
348    // ── CertFingerprint ──────────────────────────────────────────────────────
349
350    #[test]
351    fn test_fingerprint_from_bytes_format() {
352        let f = fp(b"hello world");
353        assert_eq!(f.len(), 64, "fingerprint must be 64 hex chars");
354        assert!(f.is_valid_format());
355    }
356
357    #[test]
358    fn test_fingerprint_is_valid_format() {
359        // manually constructed valid fingerprint
360        let valid = CertFingerprint("a".repeat(64));
361        assert!(valid.is_valid_format());
362
363        let too_short = CertFingerprint("abc".to_owned());
364        assert!(!too_short.is_valid_format());
365
366        let non_hex = CertFingerprint("z".repeat(64));
367        assert!(!non_hex.is_valid_format());
368    }
369
370    #[test]
371    fn test_fingerprint_deterministic() {
372        let a = fp(b"deterministic input");
373        let b = fp(b"deterministic input");
374        assert_eq!(a, b);
375
376        let c = fp(b"different input");
377        assert_ne!(a, c);
378    }
379
380    // ── pin / unpin / get_pin ────────────────────────────────────────────────
381
382    #[test]
383    fn test_pin_and_get() {
384        let store = CertPinStore::new(PinPolicy::Strict);
385        let f = fp(b"peer-cert");
386
387        store.pin("peer1", f.clone(), now_ms());
388
389        let pin = store.get_pin("peer1").expect("pin should exist");
390        assert_eq!(pin.peer_id, "peer1");
391        assert_eq!(pin.fingerprint, f);
392        assert!(!pin.tofu);
393    }
394
395    #[test]
396    fn test_unpin() {
397        let store = CertPinStore::new(PinPolicy::Strict);
398        store.pin("peer1", fp(b"x"), now_ms());
399
400        assert!(store.unpin("peer1"));
401        assert!(!store.unpin("peer1")); // already removed
402        assert!(store.get_pin("peer1").is_none());
403    }
404
405    // ── TrustOnFirstUse ──────────────────────────────────────────────────────
406
407    #[test]
408    fn test_verify_tofu_new_peer() {
409        let store = CertPinStore::new(PinPolicy::TrustOnFirstUse);
410        let f = fp(b"new-peer-cert");
411
412        let result = store.verify("peer1", &f, now_ms());
413        assert!(
414            matches!(result, VerificationResult::PinnedNew { ref peer_id } if peer_id == "peer1")
415        );
416        assert!(result.is_accepted());
417        assert_eq!(store.pin_count(), 1);
418    }
419
420    #[test]
421    fn test_verify_tofu_known_peer_match() {
422        let store = CertPinStore::new(PinPolicy::TrustOnFirstUse);
423        let f = fp(b"known-cert");
424
425        // Pre-pin (not TOFU)
426        store.pin("peer1", f.clone(), now_ms());
427
428        let result = store.verify("peer1", &f, now_ms() + 1);
429        assert_eq!(result, VerificationResult::Accepted);
430    }
431
432    #[test]
433    fn test_verify_tofu_known_peer_mismatch() {
434        let store = CertPinStore::new(PinPolicy::TrustOnFirstUse);
435        store.pin("peer1", fp(b"original"), now_ms());
436
437        let result = store.verify("peer1", &fp(b"different"), now_ms() + 1);
438        assert!(matches!(result, VerificationResult::Mismatch { .. }));
439        assert!(!result.is_accepted());
440        assert_eq!(store.mismatch_count(), 1);
441    }
442
443    // ── Strict ───────────────────────────────────────────────────────────────
444
445    #[test]
446    fn test_verify_strict_unknown_peer() {
447        let store = CertPinStore::new(PinPolicy::Strict);
448
449        let result = store.verify("unknown", &fp(b"anything"), now_ms());
450        assert!(
451            matches!(result, VerificationResult::UnknownPeer { ref peer_id } if peer_id == "unknown")
452        );
453        assert!(!result.is_accepted());
454    }
455
456    #[test]
457    fn test_verify_strict_known_peer() {
458        let store = CertPinStore::new(PinPolicy::Strict);
459        let f = fp(b"strict-cert");
460        store.pin("peer2", f.clone(), now_ms());
461
462        let result = store.verify("peer2", &f, now_ms() + 1);
463        assert_eq!(result, VerificationResult::Accepted);
464    }
465
466    // ── Observe ──────────────────────────────────────────────────────────────
467
468    #[test]
469    fn test_verify_observe_always_accepted() {
470        let store = CertPinStore::new(PinPolicy::Observe);
471
472        // Unknown peer — still accepted.
473        let r1 = store.verify("anyone", &fp(b"cert"), now_ms());
474        assert_eq!(r1, VerificationResult::Accepted);
475
476        // Known peer, mismatched fingerprint — still accepted.
477        store.pin("peer3", fp(b"original"), now_ms());
478        let r2 = store.verify("peer3", &fp(b"changed"), now_ms() + 1);
479        assert_eq!(r2, VerificationResult::Accepted);
480    }
481
482    // ── Counters ─────────────────────────────────────────────────────────────
483
484    #[test]
485    fn test_mismatch_count_increments() {
486        let store = CertPinStore::new(PinPolicy::TrustOnFirstUse);
487        store.pin("peer1", fp(b"a"), now_ms());
488
489        store.verify("peer1", &fp(b"b"), now_ms() + 1);
490        store.verify("peer1", &fp(b"c"), now_ms() + 2);
491        assert_eq!(store.mismatch_count(), 2);
492    }
493
494    #[test]
495    fn test_tofu_count_increments() {
496        let store = CertPinStore::new(PinPolicy::TrustOnFirstUse);
497
498        store.verify("peer1", &fp(b"cert1"), now_ms());
499        store.verify("peer2", &fp(b"cert2"), now_ms());
500        assert_eq!(store.tofu_count(), 2);
501    }
502
503    // ── Export / Import ──────────────────────────────────────────────────────
504
505    #[test]
506    fn test_export_import_roundtrip() {
507        let store_a = CertPinStore::new(PinPolicy::Strict);
508        store_a.pin("peer1", fp(b"alpha"), now_ms());
509        store_a.pin("peer2", fp(b"beta"), now_ms());
510
511        let exported = store_a.export_pins();
512        assert_eq!(exported.len(), 2);
513
514        let store_b = CertPinStore::new(PinPolicy::Strict);
515        store_b.import_pins(exported);
516        assert_eq!(store_b.pin_count(), 2);
517
518        let pin = store_b.get_pin("peer1").expect("peer1 must be present");
519        assert_eq!(pin.fingerprint, fp(b"alpha"));
520
521        // Import again — existing pins must not be overwritten.
522        let mut altered = store_b.export_pins();
523        for p in &mut altered {
524            p.fingerprint = fp(b"tampered");
525        }
526        store_b.import_pins(altered);
527        let pin_after = store_b
528            .get_pin("peer1")
529            .expect("peer1 must still be present");
530        assert_eq!(
531            pin_after.fingerprint,
532            fp(b"alpha"),
533            "existing pin must not be overwritten"
534        );
535    }
536
537    // ── VerificationResult::is_accepted ─────────────────────────────────────
538
539    #[test]
540    fn test_verification_result_is_accepted() {
541        assert!(VerificationResult::Accepted.is_accepted());
542        assert!(VerificationResult::PinnedNew {
543            peer_id: "x".into()
544        }
545        .is_accepted());
546        assert!(!VerificationResult::Mismatch {
547            expected: "a".into(),
548            got: "b".into()
549        }
550        .is_accepted());
551        assert!(!VerificationResult::UnknownPeer {
552            peer_id: "y".into()
553        }
554        .is_accepted());
555    }
556
557    // ── pinned_peers ─────────────────────────────────────────────────────────
558
559    #[test]
560    fn test_pinned_peers_list() {
561        let store = CertPinStore::new(PinPolicy::Strict);
562        store.pin("alice", fp(b"a"), now_ms());
563        store.pin("bob", fp(b"b"), now_ms());
564
565        let mut peers = store.pinned_peers();
566        peers.sort();
567        assert_eq!(peers, vec!["alice", "bob"]);
568    }
569}