1use 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
20pub 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
40pub 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#[derive(Debug, PartialEq, Eq)]
96pub enum StapleStatus {
97 NoStaple,
99 Unparseable,
101 SignerNotTrusted,
104 InclusionInvalid,
106 Verified,
108}
109
110pub struct StapleVerdict {
113 pub verified: bool,
114 pub checkpoint_index: Option<u64>,
116 pub checkpoint_public_key: Option<String>,
119 pub age_secs: Option<u64>,
121 pub status: StapleStatus,
122}
123
124pub 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#[derive(Debug)]
194pub enum ChallengeOutcome {
195 NotRequested,
197 PresentButUnchecked,
199 NoResponse,
201 NoEstablishedKey,
204 Verified { signed_at: String },
207 Failed { reason: String },
209}
210
211impl ChallengeOutcome {
212 pub fn is_ok(&self) -> bool {
216 matches!(
217 self,
218 Self::NotRequested | Self::PresentButUnchecked | Self::Verified { .. }
219 )
220 }
221}
222
223pub struct PresentationVerdict {
227 pub agent: String,
228 pub card_id: String,
229 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
239pub 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 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 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 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 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 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 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 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 assert!(
474 check_challenge(&block, "agent://a", "art_card", "nonce-2", "key_agent", &vk)
475 .unwrap_err()
476 .contains("DIFFERENT challenge")
477 );
478
479 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 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 assert!(
511 check_challenge(&block, "agent://b", "art_card", "nonce-1", "key_agent", &vk)
512 .unwrap_err()
513 .contains("INVALID")
514 );
515
516 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}