Skip to main content

treeship_core/verify/
presentation.rs

1//! Presentation verification primitives: the challenge-response canonical and
2//! its check, shared by the CLI (`present` / `verify-presentation`), the WASM
3//! verifier, and the SDKs so all agree by construction.
4//!
5//! Lifted verbatim from packages/cli/src/commands/present.rs. Pure, no I/O.
6//! `challenge_canonical` is byte-critical: a single-byte change to its domain
7//! separation would silently break every previously signed challenge.
8
9use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
10use ed25519_dalek::{Signature, VerifyingKey};
11use sha2::{Digest, Sha256};
12
13use super::resolution::{chain_verify_card, verifier_from_trust};
14use crate::attestation::Envelope;
15use crate::capability::is_key_bound;
16use crate::merkle::{Checkpoint, InclusionProof, MerkleTree};
17use crate::statements::{parse_rfc3339_to_unix, unix_to_rfc3339, ReceiptStatement};
18use crate::trust::{decode_ed25519_pubkey, TrustRootKind, TrustRootStore};
19
20/// The canonical bytes a challenge response signs (the handshake).
21///
22/// Domain-separated and pipe-delimited: every variable-length,
23/// externally-supplied field is folded into a sha256 digest so no field can
24/// inject separators and shift the others (the verifier's nonce is arbitrary
25/// text). Binding all four fields means a challenge signature cannot be
26/// replayed across protocols (domain tag), across agents or cards (their
27/// digests), or across challenges (the nonce digest); `signed_at` is bound so
28/// the reported freshness is bearer-signed, not bearer-editable.
29pub fn challenge_canonical(agent: &str, card_id: &str, nonce: &str, signed_at: &str) -> Vec<u8> {
30    let d = |s: &str| hex::encode(Sha256::digest(s.as_bytes()));
31    format!(
32        "v1|presentation-challenge|{}|{}|{}|{signed_at}",
33        d(agent),
34        d(card_id),
35        d(nonce)
36    )
37    .into_bytes()
38}
39
40/// Verify a presentation's challenge block against the nonce THIS verifier
41/// issued and the subject key the card verification established. Returns the
42/// bearer-signed `signed_at` on success; a specific, honest reason on failure.
43/// Pure — unit-tested against real keys.
44pub fn check_challenge(
45    challenge: &serde_json::Value,
46    agent: &str,
47    card_id: &str,
48    expected_nonce: &str,
49    card_keyid: &str,
50    subject: &VerifyingKey,
51) -> Result<String, String> {
52    let nonce = challenge
53        .get("nonce")
54        .and_then(|v| v.as_str())
55        .ok_or("challenge block carries no nonce")?;
56    if nonce != expected_nonce {
57        return Err(
58            "challenge nonce does not match the one you issued — this response answers a DIFFERENT challenge (replay?)"
59                .into(),
60        );
61    }
62    let key_id = challenge
63        .get("key_id")
64        .and_then(|v| v.as_str())
65        .ok_or("challenge block carries no key_id")?;
66    if key_id != card_keyid {
67        return Err(format!(
68            "challenge signed by {key_id}, but the card is bound to {card_keyid}"
69        ));
70    }
71    let signed_at = challenge
72        .get("signed_at")
73        .and_then(|v| v.as_str())
74        .ok_or("challenge block carries no signed_at")?;
75    let sig_b64 = challenge
76        .get("signature")
77        .and_then(|v| v.as_str())
78        .ok_or("challenge block carries no signature")?;
79    let sig_bytes = URL_SAFE_NO_PAD
80        .decode(sig_b64)
81        .map_err(|_| "challenge signature is not valid base64url")?;
82    let sig_arr: [u8; 64] = sig_bytes
83        .as_slice()
84        .try_into()
85        .map_err(|_| "challenge signature is not 64 bytes")?;
86    let canonical = challenge_canonical(agent, card_id, expected_nonce, signed_at);
87    subject
88        .verify_strict(&canonical, &Signature::from_bytes(&sig_arr))
89        .map_err(|_| "challenge signature INVALID for the card's key".to_string())?;
90    Ok(signed_at.to_string())
91}
92
93/// Why a presentation's staple did or didn't verify. Core stays free of
94/// CLI-specific text; the caller formats a human message from this.
95#[derive(Debug, PartialEq, Eq)]
96pub enum StapleStatus {
97    /// No staple included in the presentation.
98    NoStaple,
99    /// The checkpoint or the inclusion proof did not parse.
100    Unparseable,
101    /// Checkpoint signer is not a pinned `hub_checkpoint` root, or the
102    /// checkpoint signature is invalid.
103    SignerNotTrusted,
104    /// Checkpoint verified, but this card's inclusion proof is invalid.
105    InclusionInvalid,
106    /// Fully verified: checkpoint signature + card inclusion.
107    Verified,
108}
109
110/// The outcome of verifying a presentation's staple (a checkpoint plus this
111/// card's Merkle inclusion proof).
112pub struct StapleVerdict {
113    pub verified: bool,
114    /// The checkpoint index, when a checkpoint was present and parsed.
115    pub checkpoint_index: Option<u64>,
116    /// The checkpoint signer's public key — surfaced so a caller can suggest
117    /// pinning it when the status is `SignerNotTrusted`.
118    pub checkpoint_public_key: Option<String>,
119    /// Age of the checkpoint at `now_unix`, in seconds.
120    pub age_secs: Option<u64>,
121    pub status: StapleStatus,
122}
123
124/// Verify a presentation's staple against pinned trust roots at `now_unix`.
125/// Pure and time-injected: the caller supplies the current time (used only to
126/// report the checkpoint's age; verification itself does not depend on it).
127pub fn verify_staple(
128    pres: &serde_json::Value,
129    card_id: &str,
130    trust: &TrustRootStore,
131    now_unix: u64,
132) -> StapleVerdict {
133    let Some(staple) = pres.get("staple").filter(|v| !v.is_null()) else {
134        return StapleVerdict {
135            verified: false,
136            checkpoint_index: None,
137            checkpoint_public_key: None,
138            age_secs: None,
139            status: StapleStatus::NoStaple,
140        };
141    };
142    let (Ok(checkpoint), Ok(proof)) = (
143        serde_json::from_value::<Checkpoint>(staple.get("checkpoint").cloned().unwrap_or_default()),
144        serde_json::from_value::<InclusionProof>(
145            staple.get("inclusion_proof").cloned().unwrap_or_default(),
146        ),
147    ) else {
148        return StapleVerdict {
149            verified: false,
150            checkpoint_index: None,
151            checkpoint_public_key: None,
152            age_secs: None,
153            status: StapleStatus::Unparseable,
154        };
155    };
156
157    let age = parse_rfc3339_to_unix(&checkpoint.signed_at).map(|t| now_unix.saturating_sub(t));
158    let index = Some(checkpoint.index);
159    let public_key = Some(checkpoint.public_key.clone());
160
161    if !checkpoint.verify(trust) {
162        return StapleVerdict {
163            verified: false,
164            checkpoint_index: index,
165            checkpoint_public_key: public_key,
166            age_secs: age,
167            status: StapleStatus::SignerNotTrusted,
168        };
169    }
170    let root_hex = checkpoint
171        .root
172        .strip_prefix("sha256:")
173        .unwrap_or(&checkpoint.root);
174    if !MerkleTree::verify_proof(checkpoint.merkle_version, root_hex, card_id, &proof) {
175        return StapleVerdict {
176            verified: false,
177            checkpoint_index: index,
178            checkpoint_public_key: public_key,
179            age_secs: age,
180            status: StapleStatus::InclusionInvalid,
181        };
182    }
183    StapleVerdict {
184        verified: true,
185        checkpoint_index: index,
186        checkpoint_public_key: public_key,
187        age_secs: age,
188        status: StapleStatus::Verified,
189    }
190}
191
192/// The outcome of the challenge-response handshake within a presentation.
193#[derive(Debug)]
194pub enum ChallengeOutcome {
195    /// The verifier passed no nonce, so no liveness was checked.
196    NotRequested,
197    /// A response is present but the verifier passed no nonce to check it.
198    PresentButUnchecked,
199    /// A nonce was requested but the presentation carries no response.
200    NoResponse,
201    /// A response is present but the card did not verify key-bound, so there
202    /// is no established key to check the response against.
203    NoEstablishedKey,
204    /// The bearer proved live control of the card key; carries the response's
205    /// bearer-signed `signed_at`.
206    Verified { signed_at: String },
207    /// The response was checked against the established key and failed.
208    Failed { reason: String },
209}
210
211impl ChallengeOutcome {
212    /// Vacuously true unless a nonce was requested and the check did not
213    /// succeed — i.e. a signature from an unverified key never counts as a
214    /// live-control success.
215    pub fn is_ok(&self) -> bool {
216        matches!(
217            self,
218            Self::NotRequested | Self::PresentButUnchecked | Self::Verified { .. }
219        )
220    }
221}
222
223/// The full trust verdict for a presentation: card authenticity, revocation,
224/// challenge liveness, and staple anchoring. Freshness policy
225/// (`--max-staple-age`) and rendering stay with the caller.
226pub struct PresentationVerdict {
227    pub agent: String,
228    pub card_id: String,
229    /// The card envelope verified against the caller's roots (direct pin or
230    /// via the certificate chain).
231    pub sig_ok: bool,
232    pub key_bound: bool,
233    pub via_chain: bool,
234    pub revoked: Option<String>,
235    pub challenge: ChallengeOutcome,
236    pub staple: StapleVerdict,
237}
238
239/// Verify a presentation against the caller's pinned trust roots at `now_unix`.
240/// Composes the resolution chain walk, staple verification, and the challenge
241/// check into one decision — the same code path the CLI, WASM, and SDKs run.
242/// `expected_nonce` is the nonce THIS verifier issued (None when not
243/// challenging). Pure — no I/O, no system clock. The caller has already parsed
244/// the presentation JSON and validated its envelope type.
245pub fn verify_presentation(
246    pres: &serde_json::Value,
247    trust: &TrustRootStore,
248    expected_nonce: Option<&str>,
249    now_unix: u64,
250) -> Result<PresentationVerdict, String> {
251    let agent = pres
252        .get("agent")
253        .and_then(|v| v.as_str())
254        .ok_or("presentation carries no agent URI")?;
255
256    // ── Card: direct pin, or chain walk to a pinned CertIssuer root ──────────
257    let card_env_json = pres
258        .get("card")
259        .and_then(|c| c.get("envelope_json"))
260        .and_then(|v| v.as_str())
261        .ok_or("presentation carries no card envelope")?;
262    let card_id = pres
263        .get("card")
264        .and_then(|c| c.get("artifact_id"))
265        .and_then(|v| v.as_str())
266        .unwrap_or("");
267    let env: Envelope = serde_json::from_str(card_env_json)
268        .map_err(|e| format!("unparseable card envelope: {e}"))?;
269    let mut verifier = verifier_from_trust(trust);
270    let mut sig_ok = verifier.verify_any(&env).is_ok();
271    let stmt: ReceiptStatement = env
272        .unmarshal_statement()
273        .map_err(|e| format!("unparseable card statement: {e}"))?;
274    if stmt.kind != "agent_card.v1" {
275        return Err(format!(
276            "presentation card is a `{}`, not an agent_card.v1",
277            stmt.kind
278        ));
279    }
280    let card = stmt.payload.unwrap_or(serde_json::Value::Null);
281    if card.get("agent").and_then(|v| v.as_str()) != Some(agent) {
282        return Err("card's agent URI does not match the presentation's".into());
283    }
284    let card_keyid = card.get("keyid").and_then(|v| v.as_str()).unwrap_or("");
285    let signer = env
286        .signatures
287        .first()
288        .map(|s| s.keyid.as_str())
289        .unwrap_or("")
290        .to_string();
291    let mut key_bound = sig_ok && is_key_bound(card_keyid, &signer, trust);
292
293    let served_certs: Vec<(String, Envelope)> = pres
294        .get("certs")
295        .and_then(|v| v.as_array())
296        .map(|arr| {
297            arr.iter()
298                .filter_map(|c| {
299                    let id = c.get("artifact_id").and_then(|v| v.as_str())?;
300                    let ej = c.get("envelope_json").and_then(|v| v.as_str())?;
301                    Some((id.to_string(), serde_json::from_str::<Envelope>(ej).ok()?))
302                })
303                .collect()
304        })
305        .unwrap_or_default();
306    let now_rfc = unix_to_rfc3339(now_unix);
307    let mut via_chain = false;
308    // The subject key the verification establishes — the ONLY key a challenge
309    // response may be checked against. From the chain verdict, or (direct-pin
310    // path) decoded from the verifier's own AgentCert root.
311    let mut subject_vk: Option<VerifyingKey> = None;
312    if !key_bound {
313        if let Some(verdict) =
314            chain_verify_card(&env, card_keyid, agent, &served_certs, trust, &now_rfc)
315        {
316            sig_ok = true;
317            key_bound = true;
318            via_chain = true;
319            subject_vk = Some(verdict.subject_key);
320            verifier.add_key(signer.clone(), verdict.subject_key);
321        }
322    } else {
323        subject_vk = trust
324            .roots()
325            .iter()
326            .find(|r| r.key_id == card_keyid && r.kind == TrustRootKind::AgentCert)
327            .and_then(|r| decode_ed25519_pubkey(&r.public_key).ok());
328    }
329
330    // ── Revocations: honored when authorized, exactly as resolve does ───────
331    let mut revoked: Option<String> = None;
332    if let Some(revs) = pres.get("revocations").and_then(|v| v.as_array()) {
333        for rev in revs {
334            let rev_json = rev
335                .get("envelope_json")
336                .and_then(|v| v.as_str())
337                .unwrap_or("");
338            let Ok(rev_env) = serde_json::from_str::<Envelope>(rev_json) else {
339                continue;
340            };
341            if verifier.verify_any(&rev_env).is_err() {
342                continue;
343            }
344            let Ok(rev_stmt) = rev_env.unmarshal_statement::<ReceiptStatement>() else {
345                continue;
346            };
347            if rev_stmt.kind != "agent_card_revocation.v1" {
348                continue;
349            }
350            if rev_stmt
351                .payload
352                .as_ref()
353                .and_then(|p| p.get("card"))
354                .and_then(|v| v.as_str())
355                != Some(card_id)
356            {
357                continue;
358            }
359            let rev_signer = rev_env
360                .signatures
361                .first()
362                .map(|s| s.keyid.as_str())
363                .unwrap_or("");
364            let self_revoke = !card_keyid.is_empty() && rev_signer == card_keyid;
365            let issuer = trust
366                .roots()
367                .iter()
368                .any(|r| r.key_id == rev_signer && r.kind == TrustRootKind::Revoker);
369            if self_revoke || issuer {
370                revoked = Some(
371                    rev_stmt
372                        .payload
373                        .as_ref()
374                        .and_then(|p| p.get("reason"))
375                        .and_then(|v| v.as_str())
376                        .unwrap_or("(no reason given)")
377                        .to_string(),
378                );
379                break;
380            }
381        }
382    }
383
384    // ── Challenge: the handshake — live key control, checked ONLY against the
385    // subject key card verification established (fail closed otherwise). ─────
386    let challenge = match (
387        expected_nonce,
388        pres.get("challenge").filter(|v| !v.is_null()),
389    ) {
390        (None, None) => ChallengeOutcome::NotRequested,
391        (None, Some(_)) => ChallengeOutcome::PresentButUnchecked,
392        (Some(_), None) => ChallengeOutcome::NoResponse,
393        (Some(nonce), Some(block)) => match &subject_vk {
394            None => ChallengeOutcome::NoEstablishedKey,
395            Some(vk) => match check_challenge(block, agent, card_id, nonce, card_keyid, vk) {
396                Ok(signed_at) => ChallengeOutcome::Verified { signed_at },
397                Err(reason) => ChallengeOutcome::Failed { reason },
398            },
399        },
400    };
401
402    let staple = verify_staple(pres, card_id, trust, now_unix);
403
404    Ok(PresentationVerdict {
405        agent: agent.to_string(),
406        card_id: card_id.to_string(),
407        sig_ok,
408        key_bound,
409        via_chain,
410        revoked,
411        challenge,
412        staple,
413    })
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419
420    #[test]
421    fn challenge_canonical_resists_separator_injection() {
422        // A nonce containing pipes and field-lookalikes must not collide
423        // with a differently-split canonical — every variable field is
424        // digest-folded.
425        let a = challenge_canonical("agent://a", "art_1", "n|art_2|x", "2026-07-06T12:00:00Z");
426        let b = challenge_canonical("agent://a", "art_1|n", "art_2|x", "2026-07-06T12:00:00Z");
427        assert_ne!(a, b);
428        // And it is deterministic.
429        assert_eq!(
430            challenge_canonical("agent://a", "art_1", "n", "2026-07-06T12:00:00Z"),
431            challenge_canonical("agent://a", "art_1", "n", "2026-07-06T12:00:00Z"),
432        );
433    }
434
435    fn signed_challenge_block(
436        signer: &crate::attestation::Ed25519Signer,
437        agent: &str,
438        card_id: &str,
439        nonce: &str,
440    ) -> serde_json::Value {
441        use crate::attestation::Signer;
442        let signed_at = "2026-07-06T12:00:00Z";
443        let sig = signer
444            .sign(&challenge_canonical(agent, card_id, nonce, signed_at))
445            .unwrap();
446        serde_json::json!({
447            "nonce": nonce,
448            "key_id": signer.key_id(),
449            "signed_at": signed_at,
450            "signature": URL_SAFE_NO_PAD.encode(sig),
451        })
452    }
453
454    fn vk_of(signer: &crate::attestation::Ed25519Signer) -> VerifyingKey {
455        use crate::attestation::Signer;
456        VerifyingKey::from_bytes(&signer.public_key_bytes().try_into().unwrap()).unwrap()
457    }
458
459    #[test]
460    fn challenge_verifies_and_rejects_all_substitutions() {
461        use crate::attestation::Ed25519Signer;
462        let agent_key = Ed25519Signer::generate("key_agent").unwrap();
463        let other_key = Ed25519Signer::generate("key_other").unwrap();
464        let vk = vk_of(&agent_key);
465
466        // Happy path.
467        let block = signed_challenge_block(&agent_key, "agent://a", "art_card", "nonce-1");
468        assert!(
469            check_challenge(&block, "agent://a", "art_card", "nonce-1", "key_agent", &vk).is_ok()
470        );
471
472        // Wrong nonce: a captured response must not answer a new challenge.
473        assert!(
474            check_challenge(&block, "agent://a", "art_card", "nonce-2", "key_agent", &vk)
475                .unwrap_err()
476                .contains("DIFFERENT challenge")
477        );
478
479        // Signed by a different key than the card's.
480        let forged = signed_challenge_block(&other_key, "agent://a", "art_card", "nonce-1");
481        assert!(
482            check_challenge(
483                &forged,
484                "agent://a",
485                "art_card",
486                "nonce-1",
487                "key_agent",
488                &vk
489            )
490            .is_err(),
491            "response signed by a non-card key must reject"
492        );
493
494        // Replayed for a DIFFERENT card of the same agent: canonical binds card_id.
495        assert!(
496            check_challenge(
497                &block,
498                "agent://a",
499                "art_other_card",
500                "nonce-1",
501                "key_agent",
502                &vk
503            )
504            .unwrap_err()
505            .contains("INVALID"),
506            "challenge for one card must not vouch for another"
507        );
508
509        // Replayed for a DIFFERENT agent: canonical binds the agent URI.
510        assert!(
511            check_challenge(&block, "agent://b", "art_card", "nonce-1", "key_agent", &vk)
512                .unwrap_err()
513                .contains("INVALID")
514        );
515
516        // Tampered signed_at: freshness is bearer-signed, not bearer-editable.
517        let mut aged = block.clone();
518        aged["signed_at"] = serde_json::json!("2020-01-01T00:00:00Z");
519        assert!(
520            check_challenge(&aged, "agent://a", "art_card", "nonce-1", "key_agent", &vk)
521                .unwrap_err()
522                .contains("INVALID")
523        );
524    }
525}