Skip to main content

wire/
trust.rs

1//! Trust state machine — v0.1 minimal subset, extended in v3.2 (RFC-001).
2//!
3//! Tier semantics:
4//!   - UNTRUSTED: card pinned, no claim verified yet; messages ignored.
5//!   - ORG_VERIFIED: (v3.2 / RFC-001 §5) peer shares a verified `org_did`
6//!     with us — *organisational* trust, NOT personal. Bilateral SAS is
7//!     still required to cross into VERIFIED. Promotion from UNTRUSTED is
8//!     one-way.
9//!   - VERIFIED: SAS confirmed bilateral; messages accepted. Promotion
10//!     accepts UNTRUSTED-or-ORG_VERIFIED as source (RFC-001 §5: "a
11//!     SAS-paired peer that happens to share our org is recorded at
12//!     VERIFIED, not downgraded").
13//!   - ATTESTED: reserved (v0.2+) — used today only for self-attest.
14//!   - TRUSTED: reserved (v0.2+).
15//!
16//! Promotion is one-way. Demotion would be ambiguous in a bilateral setting
17//! and is deliberately not modeled. RFC-001 §5 invariant:
18//!   "ORG_VERIFIED never satisfies a `>= VERIFIED` policy check."
19//! That invariant is captured by `tier_order` (ORG_VERIFIED=1 < VERIFIED=2)
20//! and by AC2 property test (tests/trust_ceiling_prop.rs) asserting no
21//! claim-event walk reaches VERIFIED without a SasConfirmed step.
22
23use serde_json::{Value, json};
24use std::collections::BTreeMap;
25use time::OffsetDateTime;
26use time::format_description::well_known::Rfc3339;
27
28use crate::signing::{b64encode, make_key_id};
29
30/// Tier ranking — higher is more trusted. Useful for `>=` gating.
31///
32/// RFC-001 §5 invariant: ORG_VERIFIED sits strictly between UNTRUSTED and
33/// VERIFIED. A policy check of `tier >= VERIFIED` MUST NOT pass for an
34/// ORG_VERIFIED peer — only an explicit SAS-confirmation can cross that line.
35pub fn tier_order() -> BTreeMap<&'static str, u32> {
36    [
37        ("UNTRUSTED", 0u32),
38        ("ORG_VERIFIED", 1),
39        ("VERIFIED", 2),
40        ("ATTESTED", 3),
41        ("TRUSTED", 4),
42    ]
43    .into_iter()
44    .collect()
45}
46
47#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
48pub enum Tier {
49    Untrusted,
50    OrgVerified,
51    Verified,
52    Attested,
53    Trusted,
54}
55
56impl Tier {
57    pub fn as_str(self) -> &'static str {
58        match self {
59            Tier::Untrusted => "UNTRUSTED",
60            Tier::OrgVerified => "ORG_VERIFIED",
61            Tier::Verified => "VERIFIED",
62            Tier::Attested => "ATTESTED",
63            Tier::Trusted => "TRUSTED",
64        }
65    }
66}
67
68/// Trust state — kept as a free-form JSON Value so we can persist + read with
69/// any conforming impl. v0.2+ may swap this for a typed struct.
70pub type Trust = Value;
71
72pub fn empty_trust() -> Trust {
73    json!({"version": 1, "agents": {}})
74}
75
76pub fn get_tier(trust: &Trust, peer_handle: &str) -> String {
77    trust
78        .get("agents")
79        .and_then(|a| a.get(peer_handle))
80        .and_then(|a| a.get("tier"))
81        .and_then(Value::as_str)
82        .unwrap_or("UNTRUSTED")
83        .to_string()
84}
85
86/// Effective trust tier — what the daemon can ACT on, not just what
87/// trust.json was promoted to.
88///
89/// Surface-honest. trust.json may say VERIFIED, but if relay_state
90/// has no `bilateral_completed_at` AND no `slot_token`, the daemon
91/// literally cannot push to that peer. Showing the operator a
92/// VERIFIED tag in that case is a lie about capability — fall back
93/// to PENDING_ACK so the diagnosis line + pending-push attribution
94/// agree.
95///
96/// Originally lived in `cli.rs::effective_peer_tier`. Moved to
97/// `trust.rs` 2026-06-01 so `config::compute_pending_push_breakdown`
98/// can call it without a circular dep, and so any future surface
99/// (web doctor, MCP wire_status, etc.) gets the same canonical
100/// answer.
101///
102/// History: v0.14.2 (#162 fix #5) introduced the
103/// `bilateral_completed_at` durable signal — pre-#162 peers fall
104/// back to `slot_token` presence as a legacy probe so already-paired
105/// peers keep reporting VERIFIED instead of regressing the moment
106/// they're upgraded.
107pub fn effective_tier(trust: &Value, relay_state: &Value, handle: &str) -> String {
108    let raw = get_tier(trust, handle);
109    if raw != "VERIFIED" {
110        return raw;
111    }
112    let peer_obj = relay_state.get("peers").and_then(|p| p.get(handle));
113    let bilateral_at = peer_obj
114        .and_then(|p| p.get("bilateral_completed_at"))
115        .and_then(Value::as_str);
116    if bilateral_at.is_some() {
117        return raw;
118    }
119    let token = peer_obj
120        .and_then(|p| p.get("slot_token"))
121        .and_then(Value::as_str)
122        .unwrap_or("");
123    if token.is_empty() {
124        "PENDING_ACK".to_string()
125    } else {
126        raw
127    }
128}
129
130/// Resolve a bare peer handle to the full DID stored in trust. Falls back
131/// to `did:wire:<peer_handle>` (the bare-handle form) when the peer isn't
132/// pinned — preserves pre-pair best-effort routing for unknown peers.
133///
134/// v0.14.2 (#162 fix #4): without this, send paths (`cmd_send` /
135/// `tool_send`) built `to: did:wire:sunlit-aurora`, but pinned peers'
136/// real DIDs carry the long fingerprint suffix
137/// (`did:wire:sunlit-aurora-ec6f890d`). A bare-handle `to:` mismatches
138/// the receiver's self-DID and risks rejection at canonical / cursor
139/// check time (honey-pine's report observed this on the first queued
140/// event). Use this helper at every send-build site to canonicalize
141/// against the pinned peer's actual DID.
142pub fn resolve_peer_did(trust: &Value, peer_handle: &str) -> String {
143    trust
144        .get("agents")
145        .and_then(|a| a.get(peer_handle))
146        .and_then(|p| p.get("did"))
147        .and_then(Value::as_str)
148        .map(str::to_string)
149        .unwrap_or_else(|| format!("did:wire:{peer_handle}"))
150}
151
152/// Pin a peer's card into our trust at the given tier (default UNTRUSTED).
153///
154/// The caller must independently run SAS confirmation (via `compute_sas`)
155/// before calling `promote_to_verified`. Pinning alone DOES NOT verify.
156pub fn add_agent_card_pin(trust: &mut Trust, card: &Value, tier: Option<&str>) {
157    let did = card.get("did").and_then(Value::as_str).unwrap_or_default();
158    // v0.5.7+: prefer the explicit `handle` field on the card (display name).
159    // Fall back to stripping the DID prefix for legacy cards. For v0.5.7+
160    // pubkey-suffixed DIDs (`did:wire:paul-abc12345`), the display_handle
161    // helper strips the pubkey suffix back off.
162    let handle = card
163        .get("handle")
164        .and_then(Value::as_str)
165        .map(str::to_string)
166        .unwrap_or_else(|| crate::agent_card::display_handle_from_did(did).to_string());
167    if handle.is_empty() {
168        panic!("card has no resolvable handle (did={did:?})");
169    }
170    let tier = tier.unwrap_or("UNTRUSTED");
171    let now = now_iso();
172
173    let mut public_keys = Vec::new();
174    if let Some(vks) = card.get("verify_keys").and_then(Value::as_object) {
175        for (key_id_full, key_record) in vks {
176            // Strip the `ed25519:` algorithm prefix to match v3.1 trust.json shape.
177            let key_id = key_id_full.strip_prefix("ed25519:").unwrap_or(key_id_full);
178            public_keys.push(json!({
179                "key_id": key_id,
180                "key": key_record.get("key").cloned().unwrap_or(Value::Null),
181                "added_at": now,
182                "active": true,
183            }));
184        }
185    }
186
187    let agents = trust
188        .as_object_mut()
189        .expect("trust must be an object")
190        .entry("agents")
191        .or_insert_with(|| json!({}));
192
193    agents[handle] = json!({
194        "tier": tier,
195        "did": did,
196        "public_keys": public_keys,
197        "card": card.clone(),
198        "pinned_at": now,
199    });
200}
201
202/// Promote UNTRUSTED or ORG_VERIFIED → VERIFIED. Returns `Err(reason)` if
203/// not pinned or already past VERIFIED.
204///
205/// RFC-001 §5: a SAS-confirmed peer that happens to share our org is
206/// recorded at VERIFIED, not downgraded — so ORG_VERIFIED is an accepted
207/// source for VERIFIED promotion. ATTESTED and TRUSTED are above VERIFIED
208/// and would be a downgrade; we refuse.
209pub fn promote_to_verified(trust: &mut Trust, peer_handle: &str) -> Result<(), String> {
210    let agents = trust
211        .as_object_mut()
212        .ok_or("trust is not an object")?
213        .get_mut("agents")
214        .and_then(Value::as_object_mut)
215        .ok_or_else(|| format!("peer {peer_handle:?} not pinned"))?;
216
217    let agent = agents
218        .get_mut(peer_handle)
219        .ok_or_else(|| format!("peer {peer_handle:?} not pinned"))?;
220
221    let current = agent
222        .get("tier")
223        .and_then(Value::as_str)
224        .unwrap_or("UNTRUSTED")
225        .to_string();
226    if current != "UNTRUSTED" && current != "ORG_VERIFIED" {
227        return Err(format!(
228            "peer {peer_handle:?} already at tier {current:?} — promotion is one-way"
229        ));
230    }
231    agent["tier"] = json!("VERIFIED");
232    agent["verified_at"] = json!(now_iso());
233    Ok(())
234}
235
236/// Promote UNTRUSTED → ORG_VERIFIED. Returns `Err(reason)` if not pinned or
237/// already past UNTRUSTED.
238///
239/// RFC-001 §5: ORG_VERIFIED is granted on cryptographic + policy grounds
240/// (the peer's `member_cert` for an org we accept verifies against that
241/// org's pubkey) but DOES NOT satisfy the SAS-confirmation ceremony that
242/// VERIFIED requires. It is a one-way intermediate step a peer may cross
243/// before or after VERIFIED, but never *instead of* VERIFIED.
244///
245/// This function does NOT perform the cryptographic verification of
246/// `member_cert` — that lives in [`crate::identity::verify_member_cert`]
247/// and the caller must run it first. The trust mutation here is the policy
248/// recording: "we accept this peer as ORG_VERIFIED under our active org
249/// policy."
250pub fn promote_to_org_verified(trust: &mut Trust, peer_handle: &str) -> Result<(), String> {
251    let agents = trust
252        .as_object_mut()
253        .ok_or("trust is not an object")?
254        .get_mut("agents")
255        .and_then(Value::as_object_mut)
256        .ok_or_else(|| format!("peer {peer_handle:?} not pinned"))?;
257
258    let agent = agents
259        .get_mut(peer_handle)
260        .ok_or_else(|| format!("peer {peer_handle:?} not pinned"))?;
261
262    let current = agent
263        .get("tier")
264        .and_then(Value::as_str)
265        .unwrap_or("UNTRUSTED")
266        .to_string();
267    if current != "UNTRUSTED" {
268        return Err(format!(
269            "peer {peer_handle:?} already at tier {current:?} — \
270             org_verified promotion fires from UNTRUSTED only"
271        ));
272    }
273    agent["tier"] = json!("ORG_VERIFIED");
274    agent["org_verified_at"] = json!(now_iso());
275    Ok(())
276}
277
278/// Self-pin our own keypair into trust at ATTESTED. Convenience for `wire init`.
279pub fn add_self_to_trust(trust: &mut Trust, handle: &str, public_key: &[u8]) {
280    let agents = trust
281        .as_object_mut()
282        .expect("trust must be an object")
283        .entry("agents")
284        .or_insert_with(|| json!({}));
285    let key_id = make_key_id(handle, public_key);
286    agents[handle] = json!({
287        "tier": "ATTESTED",
288        "did": crate::agent_card::did_for_with_key(handle, public_key),
289        "public_keys": [{
290            "key_id": key_id,
291            "key": b64encode(public_key),
292            "added_at": now_iso(),
293            "active": true,
294        }],
295    });
296}
297
298fn now_iso() -> String {
299    let now = OffsetDateTime::now_utc();
300    now.format(&Rfc3339)
301        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307    use crate::agent_card::{build_agent_card, sign_agent_card};
308    use crate::signing::generate_keypair;
309
310    #[test]
311    fn empty_trust_shape() {
312        let t = empty_trust();
313        assert_eq!(t["version"], 1);
314        assert!(t["agents"].is_object());
315        assert_eq!(t["agents"].as_object().unwrap().len(), 0);
316    }
317
318    #[test]
319    fn get_tier_unknown_returns_untrusted() {
320        assert_eq!(get_tier(&empty_trust(), "ghost"), "UNTRUSTED");
321    }
322
323    #[test]
324    fn resolve_peer_did_returns_pinned_did_with_full_suffix() {
325        // v0.14.2 (#162 fix #4): a pinned peer's full DID includes the
326        // long-fingerprint suffix; a bare-handle DID would mismatch the
327        // receiver's self-DID and risk rejection at canonical/cursor
328        // verification.
329        let (sk, pk) = generate_keypair();
330        let card = sign_agent_card(
331            &build_agent_card("sunlit-aurora", &pk, None, None, None),
332            &sk,
333        );
334        let pinned_did = card.get("did").and_then(Value::as_str).unwrap();
335        assert!(
336            pinned_did.starts_with("did:wire:sunlit-aurora-"),
337            "test setup: card DID should carry long-hex suffix"
338        );
339        let mut t = empty_trust();
340        add_agent_card_pin(&mut t, &card, Some("VERIFIED"));
341
342        let resolved = resolve_peer_did(&t, "sunlit-aurora");
343        assert_eq!(
344            resolved, pinned_did,
345            "pinned peer must resolve to its full DID, not the bare handle"
346        );
347    }
348
349    #[test]
350    fn resolve_peer_did_falls_back_to_bare_for_unknown_peer() {
351        // Pre-pair best-effort: an unknown peer canonicalizes to the
352        // bare-handle DID. cmd_send / tool_send keep working pre-pair;
353        // post-pair the resolve path takes over.
354        let t = empty_trust();
355        assert_eq!(
356            resolve_peer_did(&t, "ghost-peer"),
357            "did:wire:ghost-peer",
358            "unknown peer falls back to bare-handle DID"
359        );
360    }
361
362    #[test]
363    fn add_agent_card_pin_defaults_untrusted() {
364        let (sk, pk) = generate_keypair();
365        let card = sign_agent_card(&build_agent_card("paul", &pk, None, None, None), &sk);
366        let mut t = empty_trust();
367        add_agent_card_pin(&mut t, &card, None);
368        assert_eq!(get_tier(&t, "paul"), "UNTRUSTED");
369        // v0.5.7+: DID is pubkey-suffixed.
370        let did = t["agents"]["paul"]["did"].as_str().unwrap();
371        assert!(did.starts_with("did:wire:paul-"), "got: {did}");
372    }
373
374    #[test]
375    fn add_pin_strips_ed25519_prefix_from_key_id() {
376        let (sk, pk) = generate_keypair();
377        let card = sign_agent_card(&build_agent_card("paul", &pk, None, None, None), &sk);
378        let mut t = empty_trust();
379        add_agent_card_pin(&mut t, &card, None);
380        let kid = t["agents"]["paul"]["public_keys"][0]["key_id"]
381            .as_str()
382            .unwrap();
383        assert!(kid.contains(':'));
384        assert!(!kid.starts_with("ed25519:"));
385    }
386
387    #[test]
388    fn promote_to_verified_one_way() {
389        let (sk, pk) = generate_keypair();
390        let card = sign_agent_card(&build_agent_card("paul", &pk, None, None, None), &sk);
391        let mut t = empty_trust();
392        add_agent_card_pin(&mut t, &card, None);
393        promote_to_verified(&mut t, "paul").unwrap();
394        assert_eq!(get_tier(&t, "paul"), "VERIFIED");
395        assert!(t["agents"]["paul"]["verified_at"].is_string());
396    }
397
398    #[test]
399    fn promote_to_verified_idempotent_block() {
400        let (sk, pk) = generate_keypair();
401        let card = sign_agent_card(&build_agent_card("paul", &pk, None, None, None), &sk);
402        let mut t = empty_trust();
403        add_agent_card_pin(&mut t, &card, None);
404        promote_to_verified(&mut t, "paul").unwrap();
405        let err = promote_to_verified(&mut t, "paul").unwrap_err();
406        assert!(err.contains("VERIFIED"), "got: {err}");
407    }
408
409    #[test]
410    fn promote_unknown_peer_fails() {
411        let mut t = empty_trust();
412        let err = promote_to_verified(&mut t, "ghost").unwrap_err();
413        assert!(err.contains("not pinned"), "got: {err}");
414    }
415
416    #[test]
417    fn add_self_to_trust_attests() {
418        let (_, pk) = generate_keypair();
419        let mut t = empty_trust();
420        add_self_to_trust(&mut t, "paul", &pk);
421        assert_eq!(get_tier(&t, "paul"), "ATTESTED");
422        let did = t["agents"]["paul"]["did"].as_str().unwrap();
423        assert!(did.starts_with("did:wire:paul-"), "got: {did}");
424    }
425
426    #[test]
427    fn tier_order_matches_promotion_semantics() {
428        let order = tier_order();
429        assert!(order["UNTRUSTED"] < order["ORG_VERIFIED"]);
430        assert!(order["ORG_VERIFIED"] < order["VERIFIED"]);
431        assert!(order["VERIFIED"] < order["ATTESTED"]);
432        assert!(order["ATTESTED"] < order["TRUSTED"]);
433    }
434
435    // ─── RFC-001 §5: Tier::OrgVerified ────────────────────────────────────
436
437    #[test]
438    fn tier_as_str_covers_org_verified() {
439        assert_eq!(Tier::OrgVerified.as_str(), "ORG_VERIFIED");
440    }
441
442    #[test]
443    fn promote_to_org_verified_one_way() {
444        let (sk, pk) = generate_keypair();
445        let card = sign_agent_card(&build_agent_card("paul", &pk, None, None, None), &sk);
446        let mut t = empty_trust();
447        add_agent_card_pin(&mut t, &card, None);
448        promote_to_org_verified(&mut t, "paul").unwrap();
449        assert_eq!(get_tier(&t, "paul"), "ORG_VERIFIED");
450        assert!(t["agents"]["paul"]["org_verified_at"].is_string());
451    }
452
453    #[test]
454    fn promote_to_org_verified_refuses_already_verified() {
455        // Once a peer is VERIFIED (bilateral SAS), regressing them to
456        // ORG_VERIFIED would be a downgrade. Refuse.
457        let (sk, pk) = generate_keypair();
458        let card = sign_agent_card(&build_agent_card("paul", &pk, None, None, None), &sk);
459        let mut t = empty_trust();
460        add_agent_card_pin(&mut t, &card, None);
461        promote_to_verified(&mut t, "paul").unwrap();
462        let err = promote_to_org_verified(&mut t, "paul").unwrap_err();
463        assert!(err.contains("VERIFIED"), "got: {err}");
464        assert_eq!(get_tier(&t, "paul"), "VERIFIED");
465    }
466
467    #[test]
468    fn promote_to_org_verified_refuses_self_idempotent() {
469        // Twice-applied org promotion is a no-op error, not a silent reset
470        // of `org_verified_at` — keeps the audit trail intact.
471        let (sk, pk) = generate_keypair();
472        let card = sign_agent_card(&build_agent_card("paul", &pk, None, None, None), &sk);
473        let mut t = empty_trust();
474        add_agent_card_pin(&mut t, &card, None);
475        promote_to_org_verified(&mut t, "paul").unwrap();
476        let err = promote_to_org_verified(&mut t, "paul").unwrap_err();
477        assert!(err.contains("ORG_VERIFIED"), "got: {err}");
478    }
479
480    #[test]
481    fn promote_to_verified_accepts_org_verified_source() {
482        // RFC-001 §5: a peer can be ORG_VERIFIED then later cross the SAS
483        // ceremony into VERIFIED — without losing the cryptographic
484        // membership claim. We preserve `org_verified_at` for audit.
485        let (sk, pk) = generate_keypair();
486        let card = sign_agent_card(&build_agent_card("paul", &pk, None, None, None), &sk);
487        let mut t = empty_trust();
488        add_agent_card_pin(&mut t, &card, None);
489        promote_to_org_verified(&mut t, "paul").unwrap();
490        promote_to_verified(&mut t, "paul").unwrap();
491        assert_eq!(get_tier(&t, "paul"), "VERIFIED");
492        assert!(t["agents"]["paul"]["org_verified_at"].is_string());
493        assert!(t["agents"]["paul"]["verified_at"].is_string());
494    }
495
496    #[test]
497    fn promote_to_verified_refuses_attested_source() {
498        // ATTESTED is reserved-but-above VERIFIED; a downgrade would lose
499        // information. Refuse.
500        let (_, pk) = generate_keypair();
501        let mut t = empty_trust();
502        add_self_to_trust(&mut t, "self", &pk);
503        let err = promote_to_verified(&mut t, "self").unwrap_err();
504        assert!(err.contains("ATTESTED"), "got: {err}");
505    }
506
507    #[test]
508    fn effective_tier_matrix() {
509        use serde_json::json;
510        // VERIFIED in trust + bilateral_completed_at present → stays VERIFIED.
511        let trust = json!({"agents": {"a": {"tier": "VERIFIED"}}});
512        let relay = json!({"peers": {"a": {"bilateral_completed_at": "t"}}});
513        assert_eq!(effective_tier(&trust, &relay, "a"), "VERIFIED");
514        // VERIFIED in trust + slot_token non-empty (back-compat path) → VERIFIED.
515        let relay = json!({"peers": {"a": {"slot_token": "tok"}}});
516        assert_eq!(effective_tier(&trust, &relay, "a"), "VERIFIED");
517        // VERIFIED in trust + no bilateral_at + empty slot_token → PENDING_ACK.
518        let relay = json!({"peers": {"a": {"slot_token": ""}}});
519        assert_eq!(effective_tier(&trust, &relay, "a"), "PENDING_ACK");
520        // VERIFIED in trust + peer missing from relay.peers entirely → PENDING_ACK.
521        let relay = json!({"peers": {}});
522        assert_eq!(effective_tier(&trust, &relay, "a"), "PENDING_ACK");
523        // Non-VERIFIED trust tiers pass through unchanged.
524        let trust = json!({"agents": {"a": {"tier": "UNTRUSTED"}}});
525        assert_eq!(effective_tier(&trust, &relay, "a"), "UNTRUSTED");
526        let trust = json!({"agents": {"a": {"tier": "ORG_VERIFIED"}}});
527        assert_eq!(effective_tier(&trust, &relay, "a"), "ORG_VERIFIED");
528    }
529
530    #[test]
531    fn org_verified_does_not_satisfy_verified_policy_check() {
532        // The load-bearing RFC-001 invariant: a policy gate of
533        // `tier >= VERIFIED` MUST refuse an ORG_VERIFIED peer.
534        let order = tier_order();
535        let verified_rank = order["VERIFIED"];
536        let org_rank = order["ORG_VERIFIED"];
537        assert!(
538            org_rank < verified_rank,
539            "ORG_VERIFIED ({org_rank}) must rank strictly below VERIFIED ({verified_rank})"
540        );
541    }
542}