Skip to main content

koi_common/
peer.rs

1//! Typed peer view of a discovered service — the fleet-legibility primitive
2//! (ADR-020 §8).
3//!
4//! `discover` yields [`Peer`]s instead of raw [`ServiceRecord`]s so a consumer
5//! reads a peer's advertised trust posture, mesh anchor (`fp=`), and identity
6//! expiry directly, without re-parsing TXT keys at every call site. This is what
7//! turns the posture oracle into a network-wide trust map (ADR-020 §13:
8//! "fleet-wide trust legibility — Tailscale's biggest gap").
9//!
10//! **These are untrusted hints.** A peer's advertised posture is advisory only
11//! (ADR-016 §2: "ask Koi, don't trust the wire") — authority comes from
12//! `verify`/mTLS against the pinned CA, never from a TXT record. The hints make
13//! the LAN's trust state *visible*; they never *grant* trust.
14//!
15//! Neutral vocabulary only (STACK-0001 K2): the keys and posture levels are
16//! standard security terms, never a consumer codename.
17
18use std::collections::HashMap;
19
20use serde::{Deserialize, Serialize};
21
22use crate::posture::{Posture, PostureLevel};
23use crate::types::ServiceRecord;
24
25/// mDNS TXT key: the CA fingerprint (SHA-256 hex) the node anchors to. Already
26/// advertised on the CA's `_certmesh._tcp` record (ADR-017 F12); a node stamps it
27/// so peers can confirm "same mesh" before dialing mTLS.
28pub const TXT_FP: &str = "fp";
29
30/// mDNS TXT key: the node's advertised [`PostureLevel`] as its wire string
31/// (`open` / `authenticated` / `confidential`).
32pub const TXT_POSTURE: &str = "posture";
33
34/// mDNS TXT key: when the node's identity expires, as an **absolute** RFC 3339
35/// timestamp. Absolute (not "days left") so a cached mDNS record never reports a
36/// stale countdown — readers compute the remaining time themselves.
37pub const TXT_EXPIRES: &str = "expires";
38
39/// mDNS TXT key: the node's identity Common Name. Optional/reserved — not stamped
40/// by default; the **authoritative** CN comes from `verify`/mTLS, never the wire.
41/// Parsed when present so a node that chooses to advertise it is surfaced.
42pub const TXT_CN: &str = "cn";
43
44/// A discovered peer enriched with its advertised trust state (ADR-020 §8).
45///
46/// Built from a [`ServiceRecord`] via [`Peer::from_record`]; the trust fields are
47/// parsed from the record's TXT map. All trust fields are *hints* — see the module
48/// docs.
49#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
50pub struct Peer {
51    /// The underlying mDNS service record (name, type, host/ip, port, full TXT).
52    pub record: ServiceRecord,
53    /// The peer's advertised posture (a hint; `verify` adjudicates).
54    pub posture: Posture,
55    /// The CA fingerprint the peer anchors to (`fp=`), if advertised.
56    pub fp: Option<String>,
57    /// The peer's identity CN (`cn=`), if it chose to advertise one. The trusted
58    /// CN comes from `verify`/mTLS, not this field.
59    pub cn: Option<String>,
60    /// When the peer's identity expires (`expires=`, absolute), if advertised.
61    pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
62}
63
64impl Peer {
65    /// Build a typed peer from a discovered [`ServiceRecord`], parsing the trust
66    /// hints from its TXT map.
67    ///
68    /// Posture resolution: an explicit `posture=` wins; otherwise a record that
69    /// carries a CA fingerprint (`fp=`) is treated as `authenticated` (a node only
70    /// advertises an anchor it holds an identity for); otherwise `open`.
71    pub fn from_record(record: ServiceRecord) -> Self {
72        let fp = non_empty(record.txt.get(TXT_FP));
73        let cn = non_empty(record.txt.get(TXT_CN));
74        let expires_at = record
75            .txt
76            .get(TXT_EXPIRES)
77            .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
78            .map(|dt| dt.with_timezone(&chrono::Utc));
79        let posture = parse_posture(&record.txt, fp.is_some());
80        Self {
81            record,
82            posture,
83            fp,
84            cn,
85            expires_at,
86        }
87    }
88
89    /// The peer's named posture level (`Open` / `Authenticated` / `Confidential`).
90    pub fn level(&self) -> PostureLevel {
91        self.posture.level()
92    }
93
94    /// Whether the peer advertises a usable cryptographic identity (`signed`).
95    pub fn is_secure(&self) -> bool {
96        self.posture.is_secure()
97    }
98
99    /// The peer's dialable `(host, port)`: its IP if known, else its hostname,
100    /// paired with its advertised port. `None` if either is missing.
101    pub fn addr(&self) -> Option<(String, u16)> {
102        let host = self
103            .record
104            .ip
105            .clone()
106            .or_else(|| self.record.host.clone())?;
107        let port = self.record.port?;
108        Some((host, port))
109    }
110
111    /// Time remaining until the peer's identity expires, computed against `now`.
112    /// Negative once expired. `None` if the peer advertised no expiry.
113    ///
114    /// Takes `now` explicitly so callers control the clock (and tests stay
115    /// deterministic); for the wall clock pass `chrono::Utc::now()`.
116    pub fn expires_in(&self, now: chrono::DateTime<chrono::Utc>) -> Option<chrono::Duration> {
117        self.expires_at.map(|exp| exp - now)
118    }
119}
120
121/// Stamp a node's own trust state into an mDNS TXT map (ADR-020 §8).
122///
123/// Idempotent — overwrites the trust keys. Always writes `posture=`; writes `fp=`
124/// only when a non-empty fingerprint is supplied and `expires=` only when an
125/// expiry is supplied (`expires` is written as an absolute RFC 3339 timestamp so
126/// the hint never goes stale). Shared by every announce site so the wire contract
127/// stays one vocabulary.
128pub fn stamp(
129    txt: &mut HashMap<String, String>,
130    posture: Posture,
131    ca_fp: Option<&str>,
132    expires_at: Option<chrono::DateTime<chrono::Utc>>,
133) {
134    txt.insert(
135        TXT_POSTURE.to_string(),
136        posture.level().as_wire().to_string(),
137    );
138    if let Some(fp) = ca_fp.filter(|f| !f.is_empty()) {
139        txt.insert(TXT_FP.to_string(), fp.to_string());
140    }
141    if let Some(exp) = expires_at {
142        txt.insert(TXT_EXPIRES.to_string(), exp.to_rfc3339());
143    }
144}
145
146/// `Some(non-empty owned)` for a present, non-blank TXT value, else `None`.
147fn non_empty(v: Option<&String>) -> Option<String> {
148    v.filter(|s| !s.is_empty()).cloned()
149}
150
151/// Resolve a posture from a TXT map: explicit `posture=` first, else infer
152/// `authenticated` from the presence of a CA fingerprint, else `open`.
153fn parse_posture(txt: &HashMap<String, String>, has_fp: bool) -> Posture {
154    if let Some(level) = txt
155        .get(TXT_POSTURE)
156        .and_then(|s| PostureLevel::from_wire(s))
157    {
158        return level.to_posture();
159    }
160    if has_fp {
161        Posture::new(true, false)
162    } else {
163        Posture::OPEN
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    fn record_with(txt: &[(&str, &str)]) -> ServiceRecord {
172        ServiceRecord {
173            name: "peer-01".to_string(),
174            service_type: "_http._tcp".to_string(),
175            host: Some("peer-01.local".to_string()),
176            ip: Some("192.168.1.10".to_string()),
177            port: Some(8443),
178            txt: txt
179                .iter()
180                .map(|(k, v)| (k.to_string(), v.to_string()))
181                .collect(),
182        }
183    }
184
185    #[test]
186    fn open_when_no_trust_hints() {
187        let p = Peer::from_record(record_with(&[]));
188        assert_eq!(p.posture, Posture::OPEN);
189        assert_eq!(p.level(), PostureLevel::Open);
190        assert!(!p.is_secure());
191        assert!(p.fp.is_none());
192        assert!(p.expires_at.is_none());
193    }
194
195    #[test]
196    fn fp_without_posture_infers_authenticated() {
197        let p = Peer::from_record(record_with(&[("fp", "ABC123")]));
198        assert_eq!(p.level(), PostureLevel::Authenticated);
199        assert!(p.is_secure());
200        assert_eq!(p.fp.as_deref(), Some("ABC123"));
201    }
202
203    #[test]
204    fn explicit_posture_wins_over_fp_inference() {
205        // posture=open with an fp present → respect the explicit declaration.
206        let p = Peer::from_record(record_with(&[("fp", "ABC123"), ("posture", "open")]));
207        assert_eq!(p.level(), PostureLevel::Open);
208        // The fp is still surfaced even though posture is open.
209        assert_eq!(p.fp.as_deref(), Some("ABC123"));
210    }
211
212    #[test]
213    fn confidential_posture_parsed() {
214        let p = Peer::from_record(record_with(&[("posture", "confidential")]));
215        assert_eq!(p.level(), PostureLevel::Confidential);
216        assert_eq!(p.posture, Posture::new(true, true));
217    }
218
219    #[test]
220    fn unknown_posture_token_falls_back_to_inference() {
221        // A garbage token is ignored; with no fp it resolves Open.
222        let p = Peer::from_record(record_with(&[("posture", "supersecure")]));
223        assert_eq!(p.level(), PostureLevel::Open);
224    }
225
226    #[test]
227    fn blank_fp_is_treated_as_absent() {
228        let p = Peer::from_record(record_with(&[("fp", "")]));
229        assert!(p.fp.is_none());
230        assert_eq!(p.level(), PostureLevel::Open);
231    }
232
233    #[test]
234    fn expires_parsed_and_remaining_computed() {
235        let exp = "2030-01-01T00:00:00Z";
236        let p = Peer::from_record(record_with(&[
237            ("posture", "authenticated"),
238            ("expires", exp),
239        ]));
240        assert!(p.expires_at.is_some());
241        let now = chrono::DateTime::parse_from_rfc3339("2029-01-01T00:00:00Z")
242            .unwrap()
243            .with_timezone(&chrono::Utc);
244        let remaining = p.expires_in(now).unwrap();
245        assert!(remaining.num_days() >= 364 && remaining.num_days() <= 366);
246    }
247
248    #[test]
249    fn expired_identity_reports_negative_remaining() {
250        let p = Peer::from_record(record_with(&[("expires", "2020-01-01T00:00:00Z")]));
251        let now = chrono::DateTime::parse_from_rfc3339("2021-01-01T00:00:00Z")
252            .unwrap()
253            .with_timezone(&chrono::Utc);
254        assert!(p.expires_in(now).unwrap() < chrono::Duration::zero());
255    }
256
257    #[test]
258    fn malformed_expires_is_ignored() {
259        let p = Peer::from_record(record_with(&[("expires", "not-a-timestamp")]));
260        assert!(p.expires_at.is_none());
261    }
262
263    #[test]
264    fn cn_parsed_when_present() {
265        let p = Peer::from_record(record_with(&[("cn", "peer-01")]));
266        assert_eq!(p.cn.as_deref(), Some("peer-01"));
267    }
268
269    #[test]
270    fn addr_prefers_ip_then_falls_back_to_host() {
271        let p = Peer::from_record(record_with(&[]));
272        assert_eq!(p.addr(), Some(("192.168.1.10".to_string(), 8443)));
273
274        let mut rec = record_with(&[]);
275        rec.ip = None;
276        let p = Peer::from_record(rec);
277        assert_eq!(p.addr(), Some(("peer-01.local".to_string(), 8443)));
278    }
279
280    #[test]
281    fn addr_none_without_port() {
282        let mut rec = record_with(&[]);
283        rec.port = None;
284        let p = Peer::from_record(rec);
285        assert_eq!(p.addr(), None);
286    }
287
288    #[test]
289    fn stamp_then_parse_round_trips() {
290        let mut txt = HashMap::new();
291        let exp = chrono::DateTime::parse_from_rfc3339("2031-06-01T12:00:00Z")
292            .unwrap()
293            .with_timezone(&chrono::Utc);
294        stamp(
295            &mut txt,
296            Posture::new(true, false),
297            Some("FP-XYZ"),
298            Some(exp),
299        );
300
301        let rec = ServiceRecord {
302            name: "n".into(),
303            service_type: "_http._tcp".into(),
304            host: None,
305            ip: Some("10.0.0.1".into()),
306            port: Some(443),
307            txt,
308        };
309        let p = Peer::from_record(rec);
310        assert_eq!(p.level(), PostureLevel::Authenticated);
311        assert_eq!(p.fp.as_deref(), Some("FP-XYZ"));
312        assert_eq!(p.expires_at, Some(exp));
313    }
314
315    #[test]
316    fn stamp_open_writes_posture_only() {
317        let mut txt = HashMap::new();
318        stamp(&mut txt, Posture::OPEN, None, None);
319        assert_eq!(txt.get(TXT_POSTURE).map(String::as_str), Some("open"));
320        assert!(!txt.contains_key(TXT_FP));
321        assert!(!txt.contains_key(TXT_EXPIRES));
322    }
323
324    #[test]
325    fn stamp_skips_empty_fp() {
326        let mut txt = HashMap::new();
327        stamp(&mut txt, Posture::new(true, false), Some(""), None);
328        assert!(!txt.contains_key(TXT_FP));
329    }
330
331    #[test]
332    fn peer_serde_round_trips() {
333        let p = Peer::from_record(record_with(&[("fp", "ABC"), ("posture", "authenticated")]));
334        let json = serde_json::to_string(&p).unwrap();
335        let back: Peer = serde_json::from_str(&json).unwrap();
336        assert_eq!(p, back);
337    }
338}