agent_toolprint/
verify.rs1use base64::{engine::general_purpose::STANDARD, Engine};
4use chrono::{DateTime, Utc};
5use ed25519_dalek::{Signature, Verifier, VerifyingKey};
6use serde_json::Value;
7
8use crate::canonical::{canonical, sha256_hash};
9use crate::did_key::Resolver;
10use crate::envelope::{envelope_pae_bytes, envelope_payload_bytes};
11use crate::types::{validate_envelope, validate_receipt};
12
13#[derive(Default, Debug, Clone)]
15pub struct Plaintext {
16 pub args: Option<Value>,
17 pub response: Option<Value>,
18}
19
20pub struct VerifyOptions<'a> {
21 pub resolver: &'a dyn Resolver,
22 pub now: Option<DateTime<Utc>>,
23 pub max_clock_skew_ms: i64,
24 pub skip_timestamp_check: bool,
25 pub plaintext: Option<Plaintext>,
26}
27
28impl<'a> VerifyOptions<'a> {
29 pub fn new(resolver: &'a dyn Resolver) -> Self {
30 Self {
31 resolver,
32 now: None,
33 max_clock_skew_ms: 24 * 3600 * 1000,
34 skip_timestamp_check: false,
35 plaintext: None,
36 }
37 }
38}
39
40#[derive(Debug)]
41pub struct VerifyResult {
42 pub ok: bool,
43 pub receipt: Option<Value>,
44 pub error: Option<String>,
45}
46
47fn fail(error: impl Into<String>) -> VerifyResult {
48 VerifyResult {
49 ok: false,
50 receipt: None,
51 error: Some(error.into()),
52 }
53}
54
55pub async fn verify(envelope: &Value, opts: &VerifyOptions<'_>) -> VerifyResult {
56 if let Err(e) = validate_envelope(envelope) {
57 return fail(format!("envelope schema: {e}"));
58 }
59 let sigs = envelope["signatures"].as_array().unwrap();
60 if sigs.len() != 2 {
61 return fail(format!(
62 "verify requires exactly 2 signatures, got {}",
63 sigs.len()
64 ));
65 }
66 let kid0 = sigs[0]["keyid"].as_str().unwrap_or("");
67 let kid1 = sigs[1]["keyid"].as_str().unwrap_or("");
68 if kid0 == kid1 {
69 return fail("verify rejects duplicate keyid across signatures");
70 }
71
72 let payload_bytes = match envelope_payload_bytes(envelope) {
73 Ok(b) => b,
74 Err(e) => return fail(format!("payload: {e}")),
75 };
76 let receipt: Value = match serde_json::from_slice(&payload_bytes) {
77 Ok(v) => v,
78 Err(e) => return fail(format!("payload is not valid JSON: {e}")),
79 };
80 if let Err(e) = validate_receipt(&receipt) {
81 return fail(format!("receipt schema: {e}"));
82 }
83
84 let canonical_bytes = match canonical(&receipt) {
85 Ok(b) => b,
86 Err(e) => return fail(format!("canonicalize: {e}")),
87 };
88 if payload_bytes != canonical_bytes {
89 return fail("envelope payload is not JCS-canonical");
90 }
91
92 let agent_kid = receipt["agent"]["key_id"].as_str().unwrap_or("");
93 let tool_kid = receipt["tool"]["key_id"].as_str().unwrap_or("");
94 if kid0 != agent_kid {
95 return fail(format!(
96 "keyid mismatch: signatures[0].keyid ({kid0}) != receipt.agent.key_id ({agent_kid})"
97 ));
98 }
99 if kid1 != tool_kid {
100 return fail(format!(
101 "keyid mismatch: signatures[1].keyid ({kid1}) != receipt.tool.key_id ({tool_kid})"
102 ));
103 }
104
105 let ts_str = receipt["ts"].as_str().unwrap_or("");
106 let rts = match DateTime::parse_from_rfc3339(ts_str) {
107 Ok(t) => t.with_timezone(&Utc),
108 Err(_) => return fail(format!("invalid receipt ts: {ts_str}")),
109 };
110
111 if !opts.skip_timestamp_check {
112 let now = opts.now.unwrap_or_else(Utc::now);
113 let delta_ms = (now - rts).num_milliseconds().abs();
114 if delta_ms > opts.max_clock_skew_ms {
115 return fail(format!(
116 "timestamp window exceeded: |now - ts| = {delta_ms}ms, max = {}ms",
117 opts.max_clock_skew_ms
118 ));
119 }
120 }
121
122 let pae = match envelope_pae_bytes(envelope) {
123 Ok(b) => b,
124 Err(e) => return fail(format!("pae: {e}")),
125 };
126
127 let agent_did = receipt["agent"]["did"].as_str().unwrap_or("");
128 let agent_pk = match opts.resolver.resolve(agent_did, agent_kid, rts).await {
129 Some(pk) => pk,
130 None => return fail(format!("agent DID did not resolve: {agent_did}")),
131 };
132 if !verify_sig(&agent_pk, &pae, sigs[0]["sig"].as_str().unwrap_or("")) {
133 return fail("agent signature invalid");
134 }
135
136 let tool_did = receipt["tool"]["did"].as_str().unwrap_or("");
137 let tool_pk = match opts.resolver.resolve(tool_did, tool_kid, rts).await {
138 Some(pk) => pk,
139 None => return fail(format!("tool DID did not resolve: {tool_did}")),
140 };
141 if !verify_sig(&tool_pk, &pae, sigs[1]["sig"].as_str().unwrap_or("")) {
142 return fail("tool signature invalid");
143 }
144
145 if let Some(pt) = &opts.plaintext {
146 if let Some(args) = &pt.args {
147 let recomputed = match sha256_hash(args) {
148 Ok(h) => h,
149 Err(e) => return fail(format!("args hash: {e}")),
150 };
151 let expected = receipt["call"]["args_hash"].as_str().unwrap_or("");
152 if recomputed != expected {
153 return fail(format!(
154 "plaintext args_hash mismatch: expected {expected}, got {recomputed}"
155 ));
156 }
157 }
158 if let Some(response) = &pt.response {
159 let recomputed = match sha256_hash(response) {
160 Ok(h) => h,
161 Err(e) => return fail(format!("response hash: {e}")),
162 };
163 let expected = receipt["result"]["response_hash"].as_str().unwrap_or("");
164 if recomputed != expected {
165 return fail(format!(
166 "plaintext response_hash mismatch: expected {expected}, got {recomputed}"
167 ));
168 }
169 }
170 }
171
172 VerifyResult {
173 ok: true,
174 receipt: Some(receipt),
175 error: None,
176 }
177}
178
179fn verify_sig(pk_bytes: &[u8], message: &[u8], sig_b64: &str) -> bool {
180 if pk_bytes.len() != 32 {
181 return false;
182 }
183 let mut pk_arr = [0u8; 32];
184 pk_arr.copy_from_slice(pk_bytes);
185 let pk = match VerifyingKey::from_bytes(&pk_arr) {
186 Ok(k) => k,
187 Err(_) => return false,
188 };
189 let sig_bytes = match STANDARD.decode(sig_b64) {
190 Ok(b) => b,
191 Err(_) => return false,
192 };
193 if sig_bytes.len() != 64 {
194 return false;
195 }
196 let mut sig_arr = [0u8; 64];
197 sig_arr.copy_from_slice(&sig_bytes);
198 let sig = Signature::from_bytes(&sig_arr);
199 pk.verify(message, &sig).is_ok()
200}