Skip to main content

treeship_core/verify/
resolution.rs

1//! Card resolution verification: the certificate-chain walk that backs
2//! `resolve --hub` and `verify-presentation`.
3//!
4//! Lifted verbatim from the CLI (`packages/cli/src/commands/resolve.rs`) so the
5//! same code path runs in the CLI, the WASM verifier, and every SDK. Pure and
6//! time-injected: the caller supplies `now` and the trust roots. No I/O, no
7//! system clock.
8
9use std::collections::HashMap;
10
11use ed25519_dalek::VerifyingKey;
12
13use crate::attestation::{Envelope, Verifier};
14use crate::capability::is_key_bound;
15use crate::statements::ReceiptStatement;
16use crate::trust::{decode_ed25519_pubkey, TrustRootKind, TrustRootStore};
17
18/// Build an offline Verifier from the client's pinned trust roots. An agent
19/// whose key the client has not pinned simply will not verify, which is the
20/// honest answer, not an error.
21pub fn verifier_from_trust(trust: &TrustRootStore) -> Verifier {
22    let mut map: HashMap<String, VerifyingKey> = HashMap::new();
23    for r in trust.roots() {
24        if let Ok(vk) = decode_ed25519_pubkey(&r.public_key) {
25            map.insert(r.key_id.clone(), vk);
26        }
27    }
28    Verifier::new(map)
29}
30
31/// A card verified through the certificate chain rather than a direct leaf
32/// pin: which cert artifact vouched, and the subject key it certified.
33pub struct ChainVerdict {
34    pub cert_id: String,
35    pub subject_key: VerifyingKey,
36}
37
38/// Walk the certificate chain for a card whose signer key is NOT directly
39/// pinned: find a served `agent_cert.v1` that (in this order, fail-closed at
40/// every step):
41///
42///   1. is signed by a key pinned under `CertIssuer` in MY trust roots — the
43///      cert envelope signature is verified with the PINNED pubkey, never the
44///      wire's, before any payload field is believed;
45///   2. binds THIS agent URI to THIS card signer (`agent` + `subject_key_id`
46///      match, and the card's own `keyid` claim equals its envelope signer,
47///      mirroring `is_key_bound`);
48///   3. is within its validity window at `now` (expired certs reject);
49///   4. certifies a subject key that actually verifies the card envelope.
50///
51/// This is the TLS chain: pin the ship (the CA), verify its agents' leaves
52/// through the cert, no per-leaf pinning. See registry-topology spec slice 1.
53pub fn chain_verify_card(
54    card_env: &Envelope,
55    card_keyid: &str,
56    agent: &str,
57    certs: &[(String, Envelope)],
58    trust: &TrustRootStore,
59    now: &str,
60) -> Option<ChainVerdict> {
61    // The card must claim the key that signed it (same rule as is_key_bound):
62    // a chain-verified signer vouches only for cards that bind themselves to
63    // that exact key.
64    let card_signer = card_env.signatures.first().map(|s| s.keyid.as_str())?;
65    if card_keyid.is_empty() || card_keyid != card_signer {
66        return None;
67    }
68
69    for (cert_id, cert_env) in certs {
70        // 1. Cert envelope must verify against a PINNED CertIssuer root. The
71        //    pubkey comes from my trust store, never from the wire.
72        let cert_signer = match cert_env.signatures.first() {
73            Some(s) => s.keyid.as_str(),
74            None => continue,
75        };
76        let Some(ship_root) = trust
77            .roots()
78            .iter()
79            .find(|r| r.key_id == cert_signer && r.kind == TrustRootKind::CertIssuer)
80        else {
81            continue;
82        };
83        let Ok(ship_vk) = decode_ed25519_pubkey(&ship_root.public_key) else {
84            continue;
85        };
86        let mut cert_verifier = Verifier::new(HashMap::new());
87        cert_verifier.add_key(cert_signer.to_string(), ship_vk);
88        if cert_verifier.verify_any(cert_env).is_err() {
89            continue;
90        }
91
92        // Only now are the payload fields issuer-attested and believable.
93        let Ok(stmt) = cert_env.unmarshal_statement::<ReceiptStatement>() else {
94            continue;
95        };
96        if stmt.kind != "agent_cert.v1" {
97            continue;
98        }
99        let Some(p) = stmt.payload else { continue };
100
101        // 2. Binds this agent to this signer.
102        if p.get("agent").and_then(|v| v.as_str()) != Some(agent)
103            || p.get("subject_key_id").and_then(|v| v.as_str()) != Some(card_signer)
104        {
105            continue;
106        }
107
108        // 3. Validity window. Both bounds required — a cert missing either
109        //    field fails closed. RFC 3339 UTC strings from the same generator
110        //    compare lexicographically.
111        let (Some(issued), Some(until)) = (
112            p.get("issued_at").and_then(|v| v.as_str()),
113            p.get("valid_until").and_then(|v| v.as_str()),
114        ) else {
115            continue;
116        };
117        if now < issued || now > until {
118            continue;
119        }
120
121        // 4. The certified subject key must verify the card envelope itself.
122        let Some(subject_b64) = p.get("subject_public_key").and_then(|v| v.as_str()) else {
123            continue;
124        };
125        let Ok(subject_vk) = decode_ed25519_pubkey(&format!("ed25519:{subject_b64}")) else {
126            continue;
127        };
128        let mut card_verifier = Verifier::new(HashMap::new());
129        card_verifier.add_key(card_signer.to_string(), subject_vk);
130        if card_verifier.verify_any(card_env).is_err() {
131            continue;
132        }
133
134        return Some(ChainVerdict {
135            cert_id: cert_id.clone(),
136            subject_key: subject_vk,
137        });
138    }
139    None
140}
141
142/// A resolution bundle: exactly the signed bytes a verifier needs to decide
143/// whether an agent's current card is trustworthy. No I/O — the caller (the
144/// CLI over the network, the WASM verifier / SDKs in a browser) assembles it
145/// and supplies `now`.
146pub struct ResolutionBundle {
147    pub agent: String,
148    /// The agent's current `agent_card.v1` envelope.
149    pub card: Envelope,
150    /// `(artifact_id, agent_cert.v1 envelope)` served for the certificate chain.
151    pub certs: Vec<(String, Envelope)>,
152    /// Any `agent_card_revocation.v1` envelopes the bundle carries.
153    pub revocations: Vec<Envelope>,
154}
155
156/// The trust verdict for a resolution bundle: is the card authentic (directly
157/// pinned or chain-certified), and is it revoked? Capability grading and
158/// provenance are the caller's concern; this is only the trust decision.
159#[derive(serde::Serialize)]
160pub struct ResolutionVerdict {
161    /// The card envelope signature verified against the caller's roots
162    /// (directly, or via a chain-certified subject key).
163    pub sig_ok: bool,
164    /// The card is key-bound: its signer is pinned under `AgentCert`, or a
165    /// pinned `CertIssuer` chain-certified it.
166    pub key_bound: bool,
167    /// If verified through the certificate chain, the cert artifact that vouched.
168    pub chain_cert_id: Option<String>,
169    /// An authorized, verifying revocation was found.
170    pub revoked: bool,
171    /// The revocation reason, when revoked.
172    pub revocation_reason: Option<String>,
173}
174
175/// Decide whether a resolution bundle's card is trustworthy against the
176/// caller's pinned trust roots at `now`. Mirrors the CLI `resolve --hub`
177/// decision exactly: verify the card signature (direct leaf pin), else walk
178/// the certificate chain; then honor an authorized, verifying revocation
179/// (self-revocation by the card key, or an issuer pinned under `Revoker`).
180pub fn verify_resolution(
181    bundle: &ResolutionBundle,
182    trust: &TrustRootStore,
183    now: &str,
184) -> Result<ResolutionVerdict, String> {
185    let verifier = verifier_from_trust(trust);
186    let mut sig_ok = verifier.verify_any(&bundle.card).is_ok();
187
188    let stmt: ReceiptStatement = bundle
189        .card
190        .unmarshal_statement()
191        .map_err(|e| format!("unparseable card: {e}"))?;
192    if stmt.kind != "agent_card.v1" {
193        return Err(format!(
194            "bundle card is a `{}`, not an agent_card.v1",
195            stmt.kind
196        ));
197    }
198    let card = stmt.payload.unwrap_or(serde_json::Value::Null);
199    let card_keyid = card.get("keyid").and_then(|v| v.as_str()).unwrap_or("");
200    let signer = bundle
201        .card
202        .signatures
203        .first()
204        .map(|s| s.keyid.as_str())
205        .unwrap_or("");
206    let mut key_bound = sig_ok && is_key_bound(card_keyid, signer, trust);
207
208    // Chain walk when the leaf key is not directly pinned. The revocation
209    // verifier gains the chain-certified subject key so a self-revocation
210    // signed by the agent's own key still counts.
211    let mut chain_cert_id: Option<String> = None;
212    let mut rev_verifier = verifier;
213    if !key_bound {
214        if let Some(verdict) = chain_verify_card(
215            &bundle.card,
216            card_keyid,
217            &bundle.agent,
218            &bundle.certs,
219            trust,
220            now,
221        ) {
222            sig_ok = true;
223            key_bound = true;
224            rev_verifier.add_key(signer.to_string(), verdict.subject_key);
225            chain_cert_id = Some(verdict.cert_id);
226        }
227    }
228    let verifier = rev_verifier;
229
230    // Honor an authorized, verifying revocation.
231    let mut revocation_reason: Option<String> = None;
232    for rev_env in &bundle.revocations {
233        if verifier.verify_any(rev_env).is_err() {
234            continue; // unverified revocation -> ignored
235        }
236        let Ok(rev_stmt) = rev_env.unmarshal_statement::<ReceiptStatement>() else {
237            continue;
238        };
239        if rev_stmt.kind != "agent_card_revocation.v1" {
240            continue;
241        }
242        let rev_signer = rev_env
243            .signatures
244            .first()
245            .map(|s| s.keyid.as_str())
246            .unwrap_or("");
247        let self_revoke = !card_keyid.is_empty() && rev_signer == card_keyid;
248        // Issuer revocation is scoped to the `Revoker` kind.
249        let issuer = trust
250            .roots()
251            .iter()
252            .any(|r| r.key_id == rev_signer && r.kind == TrustRootKind::Revoker);
253        if self_revoke || issuer {
254            revocation_reason = Some(
255                rev_stmt
256                    .payload
257                    .as_ref()
258                    .and_then(|p| p.get("reason"))
259                    .and_then(|v| v.as_str())
260                    .unwrap_or("(no reason given)")
261                    .to_string(),
262            );
263            break;
264        }
265    }
266
267    Ok(ResolutionVerdict {
268        sig_ok,
269        key_bound,
270        chain_cert_id,
271        revoked: revocation_reason.is_some(),
272        revocation_reason,
273    })
274}
275
276#[cfg(test)]
277mod chain_tests {
278    use super::*;
279    use crate::attestation::{sign, Ed25519Signer, Signer};
280    use crate::statements::payload_type;
281    use crate::trust::TrustRoot;
282    use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
283
284    const NOW: &str = "2026-07-06T12:00:00Z";
285
286    fn cert_payload(agent: &str, subject: &Ed25519Signer) -> serde_json::Value {
287        serde_json::json!({
288            "agent": agent,
289            "subject_key_id": subject.key_id(),
290            "subject_public_key": URL_SAFE_NO_PAD.encode(subject.public_key_bytes()),
291            "issuer": "ship://ship_test",
292            "issued_at": "2026-01-01T00:00:00Z",
293            "valid_until": "2027-01-01T00:00:00Z",
294        })
295    }
296
297    fn signed_receipt(kind: &str, payload: serde_json::Value, signer: &Ed25519Signer) -> Envelope {
298        let mut stmt = ReceiptStatement::new("ship://ship_test", kind);
299        stmt.payload = Some(payload);
300        sign(&payload_type("receipt"), &stmt, signer)
301            .unwrap()
302            .envelope
303    }
304
305    fn signed_card(agent: &str, keyid_claim: &str, signer: &Ed25519Signer) -> Envelope {
306        let mut stmt = ReceiptStatement::new("ship://ship_test", "agent_card.v1");
307        stmt.payload = Some(serde_json::json!({ "agent": agent, "keyid": keyid_claim }));
308        sign(&payload_type("receipt"), &stmt, signer)
309            .unwrap()
310            .envelope
311    }
312
313    fn ship_pinned(ship: &Ed25519Signer, kind: TrustRootKind) -> TrustRootStore {
314        TrustRootStore::with_roots(vec![TrustRoot {
315            key_id: ship.key_id().to_string(),
316            public_key: format!(
317                "ed25519:{}",
318                URL_SAFE_NO_PAD.encode(ship.public_key_bytes())
319            ),
320            kind,
321            label: "test ship".into(),
322            added_at: String::new(),
323        }])
324    }
325
326    #[test]
327    fn chain_verifies_card_through_pinned_ship_root() {
328        let ship = Ed25519Signer::generate("key_ship").unwrap();
329        let agent_key = Ed25519Signer::generate("key_agent").unwrap();
330        let cert = signed_receipt(
331            "agent_cert.v1",
332            cert_payload("agent://a", &agent_key),
333            &ship,
334        );
335        let card = signed_card("agent://a", "key_agent", &agent_key);
336        let trust = ship_pinned(&ship, TrustRootKind::CertIssuer);
337
338        let verdict = chain_verify_card(
339            &card,
340            "key_agent",
341            "agent://a",
342            &[("art_cert".into(), cert)],
343            &trust,
344            NOW,
345        );
346        assert!(verdict.is_some(), "valid chain must verify");
347        assert_eq!(verdict.unwrap().cert_id, "art_cert");
348    }
349
350    #[test]
351    fn chain_rejects_unpinned_ship() {
352        let ship = Ed25519Signer::generate("key_ship").unwrap();
353        let agent_key = Ed25519Signer::generate("key_agent").unwrap();
354        let cert = signed_receipt(
355            "agent_cert.v1",
356            cert_payload("agent://a", &agent_key),
357            &ship,
358        );
359        let card = signed_card("agent://a", "key_agent", &agent_key);
360
361        // Empty roots: a self-signed forgery chain must not verify.
362        let empty = TrustRootStore::with_roots(vec![]);
363        assert!(chain_verify_card(
364            &card,
365            "key_agent",
366            "agent://a",
367            &[("art_cert".into(), cert.clone())],
368            &empty,
369            NOW
370        )
371        .is_none());
372
373        // Pinned under the WRONG kind (agent_cert, not ship) also rejects:
374        // certifying agents is the Ship role, not a leaf role.
375        let wrong_kind = ship_pinned(&ship, TrustRootKind::AgentCert);
376        assert!(chain_verify_card(
377            &card,
378            "key_agent",
379            "agent://a",
380            &[("art_cert".into(), cert)],
381            &wrong_kind,
382            NOW
383        )
384        .is_none());
385    }
386
387    #[test]
388    fn chain_rejects_expired_and_not_yet_valid_certs() {
389        let ship = Ed25519Signer::generate("key_ship").unwrap();
390        let agent_key = Ed25519Signer::generate("key_agent").unwrap();
391        let card = signed_card("agent://a", "key_agent", &agent_key);
392        let trust = ship_pinned(&ship, TrustRootKind::CertIssuer);
393
394        let mut expired = cert_payload("agent://a", &agent_key);
395        expired["valid_until"] = serde_json::json!("2026-01-02T00:00:00Z");
396        let cert = signed_receipt("agent_cert.v1", expired, &ship);
397        assert!(
398            chain_verify_card(
399                &card,
400                "key_agent",
401                "agent://a",
402                &[("c".into(), cert)],
403                &trust,
404                NOW
405            )
406            .is_none(),
407            "expired cert must reject"
408        );
409
410        let mut future = cert_payload("agent://a", &agent_key);
411        future["issued_at"] = serde_json::json!("2026-12-01T00:00:00Z");
412        let cert = signed_receipt("agent_cert.v1", future, &ship);
413        assert!(
414            chain_verify_card(
415                &card,
416                "key_agent",
417                "agent://a",
418                &[("c".into(), cert)],
419                &trust,
420                NOW
421            )
422            .is_none(),
423            "not-yet-valid cert must reject"
424        );
425
426        let mut missing = cert_payload("agent://a", &agent_key);
427        missing.as_object_mut().unwrap().remove("valid_until");
428        let cert = signed_receipt("agent_cert.v1", missing, &ship);
429        assert!(
430            chain_verify_card(
431                &card,
432                "key_agent",
433                "agent://a",
434                &[("c".into(), cert)],
435                &trust,
436                NOW
437            )
438            .is_none(),
439            "missing window must fail closed"
440        );
441    }
442
443    #[test]
444    fn chain_rejects_subject_and_agent_mismatches() {
445        let ship = Ed25519Signer::generate("key_ship").unwrap();
446        let agent_key = Ed25519Signer::generate("key_agent").unwrap();
447        let other_key = Ed25519Signer::generate("key_other").unwrap();
448        let trust = ship_pinned(&ship, TrustRootKind::CertIssuer);
449
450        // Cert certifies a DIFFERENT key than the card's signer.
451        let cert = signed_receipt(
452            "agent_cert.v1",
453            cert_payload("agent://a", &other_key),
454            &ship,
455        );
456        let card = signed_card("agent://a", "key_agent", &agent_key);
457        assert!(
458            chain_verify_card(
459                &card,
460                "key_agent",
461                "agent://a",
462                &[("c".into(), cert)],
463                &trust,
464                NOW
465            )
466            .is_none(),
467            "subject mismatch must reject"
468        );
469
470        // Cert for a DIFFERENT agent URI: key_agent certified for agent://b
471        // must not vouch for a card claiming agent://a.
472        let cert = signed_receipt(
473            "agent_cert.v1",
474            cert_payload("agent://b", &agent_key),
475            &ship,
476        );
477        let card = signed_card("agent://a", "key_agent", &agent_key);
478        assert!(
479            chain_verify_card(
480                &card,
481                "key_agent",
482                "agent://a",
483                &[("c".into(), cert)],
484                &trust,
485                NOW
486            )
487            .is_none(),
488            "agent URI mismatch must reject"
489        );
490
491        // Card signed by a key that is NOT the certified subject (stolen
492        // cert, attacker's card): the subject-key check must catch it.
493        let cert = signed_receipt(
494            "agent_cert.v1",
495            cert_payload("agent://a", &agent_key),
496            &ship,
497        );
498        let card = signed_card("agent://a", "key_agent", &other_key);
499        assert!(
500            chain_verify_card(
501                &card,
502                "key_agent",
503                "agent://a",
504                &[("c".into(), cert)],
505                &trust,
506                NOW
507            )
508            .is_none(),
509            "wrong card signer must reject"
510        );
511
512        // Card whose keyid claim differs from its envelope signer.
513        let cert = signed_receipt(
514            "agent_cert.v1",
515            cert_payload("agent://a", &agent_key),
516            &ship,
517        );
518        let card = signed_card("agent://a", "key_someone_else", &agent_key);
519        assert!(
520            chain_verify_card(
521                &card,
522                "key_someone_else",
523                "agent://a",
524                &[("c".into(), cert)],
525                &trust,
526                NOW
527            )
528            .is_none(),
529            "keyid/signer mismatch must reject"
530        );
531    }
532
533    #[test]
534    fn verify_resolution_chains_and_honors_revocation() {
535        let ship = Ed25519Signer::generate("key_ship").unwrap();
536        let agent_key = Ed25519Signer::generate("key_agent").unwrap();
537        let cert = signed_receipt(
538            "agent_cert.v1",
539            cert_payload("agent://a", &agent_key),
540            &ship,
541        );
542        let card = signed_card("agent://a", "key_agent", &agent_key);
543        let trust = ship_pinned(&ship, TrustRootKind::CertIssuer);
544
545        // Chain-verified, not revoked.
546        let bundle = ResolutionBundle {
547            agent: "agent://a".into(),
548            card: card.clone(),
549            certs: vec![("art_cert".into(), cert.clone())],
550            revocations: vec![],
551        };
552        let v = verify_resolution(&bundle, &trust, NOW).unwrap();
553        assert!(v.key_bound && v.sig_ok, "valid chain must be key-bound");
554        assert_eq!(v.chain_cert_id.as_deref(), Some("art_cert"));
555        assert!(!v.revoked);
556
557        // A self-revocation signed by the agent's own (chain-certified) key is
558        // honored even though that key is not directly pinned.
559        let mut rev_stmt = ReceiptStatement::new("ship://ship_test", "agent_card_revocation.v1");
560        rev_stmt.payload =
561            Some(serde_json::json!({ "card_ref": "card_x", "reason": "key-rotation" }));
562        let rev = sign(&payload_type("receipt"), &rev_stmt, &agent_key)
563            .unwrap()
564            .envelope;
565        let bundle = ResolutionBundle {
566            agent: "agent://a".into(),
567            card,
568            certs: vec![("art_cert".into(), cert)],
569            revocations: vec![rev],
570        };
571        let v = verify_resolution(&bundle, &trust, NOW).unwrap();
572        assert!(v.revoked, "self-revocation must be honored");
573        assert_eq!(v.revocation_reason.as_deref(), Some("key-rotation"));
574    }
575}