1use chrono::{DateTime, Duration, Utc};
7use ed25519_dalek::SigningKey;
8use serde::{Deserialize, Serialize};
9
10#[cfg(feature = "http-server")]
11use super::task::TaskAuthorityExpectationV1;
12use super::task::{
13 TASK_STATUS_VERSION, TaskAuthorityConfigV1, TaskControlDescriptorV1, TaskPeerTrustV1,
14 TaskStatusV1,
15};
16
17const DOMAIN: &[u8] = b"leanctx.a2a.task.status.response.sig.v1\0";
18const MAX_RESPONSE_BYTES: usize = 64 * 1024;
19const RESPONSE_SECONDS: i64 = 60;
20
21#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
22#[error("invalid or unauthorized task status response")]
23pub struct InvalidTaskResponse;
24
25#[cfg(feature = "http-server")]
28pub struct AdmittedStatusSigner<'a> {
29 request: &'a TaskControlDescriptorV1,
30 peer: &'a TaskPeerTrustV1,
31 key: SigningKey,
32}
33
34#[cfg(feature = "http-server")]
35impl<'a> AdmittedStatusSigner<'a> {
36 pub fn existing(
37 request: &'a TaskControlDescriptorV1,
38 policy: &'a TaskAuthorityConfigV1,
39 expected: &TaskAuthorityExpectationV1<'_>,
40 ) -> Result<Self, InvalidTaskResponse> {
41 request
43 .verify_authority(policy, expected)
44 .map_err(|_| InvalidTaskResponse)?;
45 let key = crate::core::agent_identity::get_stored_signing_key(expected.recipient)
46 .map_err(|_| InvalidTaskResponse)?;
47 let public_key = key.verifying_key();
48 let peer = policy
49 .peers
50 .iter()
51 .find(|peer| {
52 peer.agent_id == expected.recipient
53 && crate::core::agent_identity::hex_decode(&peer.public_key)
54 .is_ok_and(|bytes| bytes == public_key.as_bytes())
55 })
56 .ok_or(InvalidTaskResponse)?;
57 validate_response_peer(peer, request, expected.now, expected.now, expected.now)?;
58 Ok(Self { request, peer, key })
59 }
60
61 pub fn sign(
62 &self,
63 status: TaskStatusV1,
64 now: DateTime<Utc>,
65 ) -> Result<SignedTaskStatusV1, InvalidTaskResponse> {
66 let reply = SignedTaskStatusV1::sign_status_until(
67 self.request,
68 status,
69 &self.peer.key_id,
70 now,
71 self.peer.expires_at,
72 |bytes| {
73 Ok(crate::core::agent_identity::sign_bytes_with(
74 &self.key, bytes,
75 ))
76 },
77 )?;
78 validate_response_peer(
79 self.peer,
80 self.request,
81 reply.issued_at,
82 reply.expires_at,
83 now,
84 )?;
85 Ok(reply)
86 }
87}
88
89fn validate_response_peer(
90 peer: &TaskPeerTrustV1,
91 request: &TaskControlDescriptorV1,
92 issued_at: DateTime<Utc>,
93 expires_at: DateTime<Utc>,
94 now: DateTime<Utc>,
95) -> Result<(), InvalidTaskResponse> {
96 if peer.revoked
97 || peer.agent_id != request.recipient
98 || now < peer.not_before
99 || issued_at < peer.not_before
100 || now >= peer.expires_at
101 || expires_at > peer.expires_at
102 || !peer.allowed_actions.contains(&request.action)
103 || !peer.allowed_scopes.iter().any(|scope| {
104 scope.tenant_id == request.tenant_id && scope.project_id == request.project_id
105 })
106 {
107 return Err(InvalidTaskResponse);
108 }
109 Ok(())
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
113#[serde(deny_unknown_fields)]
114pub struct SignedTaskStatusV1 {
115 pub schema_version: u32,
116 pub sender: String,
117 pub recipient: String,
118 pub tenant_id: String,
119 pub project_id: String,
120 pub task_id: String,
121 pub request_digest: String,
122 pub status: TaskStatusV1,
123 pub issued_at: DateTime<Utc>,
124 pub expires_at: DateTime<Utc>,
125 pub key_id: String,
126 pub signature: String,
127}
128
129impl SignedTaskStatusV1 {
130 pub fn sign_status(
134 request: &TaskControlDescriptorV1,
135 status: TaskStatusV1,
136 key_id: &str,
137 key: &SigningKey,
138 now: DateTime<Utc>,
139 ) -> Result<Self, InvalidTaskResponse> {
140 Self::sign_status_with(request, status, key_id, now, |bytes| {
141 Ok(crate::core::agent_identity::sign_bytes_with(key, bytes))
142 })
143 }
144
145 pub fn sign_status_with(
149 request: &TaskControlDescriptorV1,
150 status: TaskStatusV1,
151 key_id: &str,
152 now: DateTime<Utc>,
153 sign: impl FnOnce(&[u8]) -> Result<Vec<u8>, String>,
154 ) -> Result<Self, InvalidTaskResponse> {
155 Self::sign_status_until(request, status, key_id, now, request.expires_at, sign)
156 }
157
158 fn sign_status_until(
159 request: &TaskControlDescriptorV1,
160 status: TaskStatusV1,
161 key_id: &str,
162 now: DateTime<Utc>,
163 not_after: DateTime<Utc>,
164 sign: impl FnOnce(&[u8]) -> Result<Vec<u8>, String>,
165 ) -> Result<Self, InvalidTaskResponse> {
166 let deadline = now
167 .checked_add_signed(Duration::seconds(RESPONSE_SECONDS))
168 .ok_or(InvalidTaskResponse)?;
169 let mut reply = Self {
170 schema_version: 1,
171 sender: request.recipient.clone(),
172 recipient: request.sender.clone(),
173 tenant_id: request.tenant_id.clone(),
174 project_id: request.project_id.clone(),
175 task_id: request.task_id.clone(),
176 request_digest: request.content_digest(),
177 status,
178 issued_at: now,
179 expires_at: deadline.min(request.expires_at).min(not_after),
180 key_id: key_id.to_string(),
181 signature: String::new(),
182 };
183 reply.validate_binding(request, now)?;
184 let signature = sign(&reply.signing_bytes()).map_err(|_| InvalidTaskResponse)?;
185 if signature.len() != 64 {
186 return Err(InvalidTaskResponse);
187 }
188 reply.signature = crate::core::agent_identity::hex_encode(&signature);
189 Ok(reply)
190 }
191
192 pub fn from_json(raw: &str) -> Result<Self, InvalidTaskResponse> {
195 if raw.len() > MAX_RESPONSE_BYTES {
196 return Err(InvalidTaskResponse);
197 }
198 serde_json::from_str(raw).map_err(|_| InvalidTaskResponse)
199 }
200
201 pub fn verify(
206 &self,
207 request: &TaskControlDescriptorV1,
208 policy: &TaskAuthorityConfigV1,
209 now: DateTime<Utc>,
210 ) -> Result<(), InvalidTaskResponse> {
211 if self.signature.len() != 128
214 || !self.signature.bytes().all(|byte| byte.is_ascii_hexdigit())
215 {
216 return Err(InvalidTaskResponse);
217 }
218 self.validate_binding(request, now)?;
219 policy.validate().map_err(|_| InvalidTaskResponse)?;
220 let peer = policy
221 .peers
222 .iter()
223 .find(|peer| peer.key_id == self.key_id)
224 .ok_or(InvalidTaskResponse)?;
225 validate_response_peer(peer, request, self.issued_at, self.expires_at, now)?;
226 let public_key = crate::core::agent_identity::hex_decode(&peer.public_key)
227 .map_err(|_| InvalidTaskResponse)?;
228 let signature = crate::core::agent_identity::hex_decode(&self.signature)
229 .map_err(|_| InvalidTaskResponse)?;
230 if !crate::core::agent_identity::verify_signature(
231 &public_key,
232 &self.signing_bytes(),
233 &signature,
234 ) {
235 return Err(InvalidTaskResponse);
236 }
237 Ok(())
238 }
239
240 fn validate_binding(
241 &self,
242 request: &TaskControlDescriptorV1,
243 now: DateTime<Utc>,
244 ) -> Result<(), InvalidTaskResponse> {
245 request.validate_shape().map_err(|_| InvalidTaskResponse)?;
246 let skew = Duration::seconds(RESPONSE_SECONDS);
247 if self.schema_version != 1
248 || self.status.schema_version != TASK_STATUS_VERSION
249 || self.sender != request.recipient
250 || self.recipient != request.sender
251 || self.tenant_id != request.tenant_id
252 || self.project_id != request.project_id
253 || self.task_id != request.task_id
254 || self.request_digest != request.content_digest()
255 || self.key_id.is_empty()
256 || self.key_id.len() > 256
257 || self.expires_at <= self.issued_at
258 || self.expires_at > request.expires_at
259 || self.expires_at - self.issued_at > skew
260 || now >= self.expires_at
261 || self.issued_at - now > skew
262 || request.issued_at - self.issued_at > skew
263 || self.status.timestamp - self.issued_at > skew
264 || self.signing_bytes().len() > MAX_RESPONSE_BYTES
265 {
266 return Err(InvalidTaskResponse);
267 }
268 Ok(())
269 }
270
271 fn signing_bytes(&self) -> Vec<u8> {
272 let mut unsigned = self.clone();
273 unsigned.signature.clear();
274 let mut bytes = DOMAIN.to_vec();
275 bytes.extend(crate::core::canonical::canonical_serialize(&unsigned));
276 bytes
277 }
278}
279
280#[cfg(test)]
281mod tests {
282 use super::*;
283 use crate::core::a2a::task::{TASK_ACTION_GET, TaskPeerTrustV1, TaskScopeV1, TaskState};
284
285 fn fixture() -> (
286 TaskControlDescriptorV1,
287 SignedTaskStatusV1,
288 TaskAuthorityConfigV1,
289 DateTime<Utc>,
290 ) {
291 let now = DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
292 .expect("fixed time")
293 .with_timezone(&Utc);
294 let server_key = SigningKey::from_bytes(&[9; 32]);
295 let mut request = TaskControlDescriptorV1::new(
296 "owner",
297 "receiver",
298 "tenant",
299 "project",
300 TASK_ACTION_GET,
301 "task-1",
302 &format!("sha256:{}", "a".repeat(64)),
303 now,
304 now + Duration::minutes(5),
305 "nonce-1",
306 None,
307 "grant",
308 "owner-key",
309 );
310 request.sign(&SigningKey::from_bytes(&[7; 32]));
311 let status = TaskStatusV1 {
312 schema_version: 1,
313 state: TaskState::Created,
314 timestamp: now,
315 };
316 let reply =
317 SignedTaskStatusV1::sign_status(&request, status, "server-key", &server_key, now)
318 .expect("sign response");
319 let policy = TaskAuthorityConfigV1 {
320 schema_version: 1,
321 peers: vec![TaskPeerTrustV1 {
322 schema_version: 1,
323 key_id: "server-key".into(),
324 agent_id: "receiver".into(),
325 public_key: crate::core::agent_identity::hex_encode(
326 &server_key.verifying_key().to_bytes(),
327 ),
328 allowed_actions: vec![TASK_ACTION_GET.into()],
329 allowed_scopes: vec![TaskScopeV1 {
330 schema_version: 1,
331 tenant_id: "tenant".into(),
332 project_id: "project".into(),
333 }],
334 not_before: now - Duration::minutes(1),
335 expires_at: now + Duration::hours(1),
336 revoked: false,
337 }],
338 grants: vec![],
339 };
340 (request, reply, policy, now)
341 }
342
343 #[test]
344 fn response_verifies_against_configured_receiver_and_survives_wire_roundtrip() {
345 let (request, reply, policy, now) = fixture();
346 assert!(reply.signing_bytes().starts_with(DOMAIN));
347 assert_eq!(reply.verify(&request, &policy, now), Ok(()));
348 let parsed =
349 SignedTaskStatusV1::from_json(&serde_json::to_string(&reply).expect("serialize"))
350 .expect("parse");
351 assert_eq!(parsed, reply);
352 assert_eq!(parsed.verify(&request, &policy, now), Ok(()));
353 }
354
355 #[test]
356 fn response_cannot_be_replayed_for_a_different_poll_or_tampered_status() {
357 let (mut request, mut reply, policy, now) = fixture();
358 request.nonce = "nonce-2".into();
359 request.sign(&SigningKey::from_bytes(&[7; 32]));
360 assert_eq!(
361 reply.verify(&request, &policy, now),
362 Err(InvalidTaskResponse)
363 );
364 let (request, _, _, _) = fixture();
365 reply.status.state = TaskState::Completed;
366 assert_eq!(
367 reply.verify(&request, &policy, now),
368 Err(InvalidTaskResponse)
369 );
370 }
371
372 #[test]
373 fn response_rejects_expiry_revocation_and_wrong_receiver_trust() {
374 let (request, reply, policy, now) = fixture();
375 assert_eq!(
376 reply.verify(&request, &policy, reply.expires_at),
377 Err(InvalidTaskResponse)
378 );
379 let mut revoked = policy.clone();
380 revoked.peers[0].revoked = true;
381 assert_eq!(
382 reply.verify(&request, &revoked, now),
383 Err(InvalidTaskResponse)
384 );
385 let mut wrong = policy.clone();
386 wrong.peers[0].agent_id = "other-receiver".into();
387 assert_eq!(
388 reply.verify(&request, &wrong, now),
389 Err(InvalidTaskResponse)
390 );
391 let mut expired = policy;
392 expired.peers[0].expires_at = now;
393 assert_eq!(
394 reply.verify(&request, &expired, now),
395 Err(InvalidTaskResponse)
396 );
397 }
398
399 #[test]
400 fn response_wire_schema_and_size_are_closed() {
401 let (_, reply, _, _) = fixture();
402 let mut wire = serde_json::to_value(reply).expect("serialize");
403 wire["authority_override"] = serde_json::json!(true);
404 assert!(SignedTaskStatusV1::from_json(&wire.to_string()).is_err());
405 assert!(SignedTaskStatusV1::from_json(&" ".repeat(MAX_RESPONSE_BYTES + 1)).is_err());
406 }
407
408 #[test]
409 fn malformed_signature_encoding_is_rejected_without_panicking() {
410 let (request, mut reply, policy, now) = fixture();
411 for signature in [
412 format!("€{}", "a".repeat(125)),
413 "a".repeat(127),
414 "a".repeat(129),
415 "g".repeat(128),
416 String::new(),
417 ] {
418 reply.signature = signature;
419 let wire = serde_json::to_string(&reply).unwrap();
420 let parsed = SignedTaskStatusV1::from_json(&wire).unwrap();
421 assert_eq!(
422 parsed.verify(&request, &policy, now),
423 Err(InvalidTaskResponse)
424 );
425 }
426 }
427
428 #[test]
429 fn transport_receipt_exposes_status_only_after_current_request_verification() {
430 use crate::core::a2a::remote_transport::DeliveryReceipt;
431
432 let (request, reply, policy, now) = fixture();
433 let mut receipt = DeliveryReceipt {
434 envelope_id: "transport-metadata-is-not-authority".into(),
435 delivered_at: now,
436 remote_status: 200,
437 round_trip_ms: 1,
438 unverified_task_response: Some(serde_json::to_string(&reply).unwrap()),
439 };
440 assert_eq!(
441 receipt.verified_task_status(&request, &policy, now),
442 Ok(reply.status.clone())
443 );
444 let restored: DeliveryReceipt =
445 serde_json::from_str(&serde_json::to_string(&receipt).unwrap()).unwrap();
446 assert_eq!(
447 restored.verified_task_status(&request, &policy, reply.expires_at),
448 Err(InvalidTaskResponse)
449 );
450 let mut revoked = policy.clone();
451 revoked.peers[0].revoked = true;
452 assert!(
453 restored
454 .verified_task_status(&request, &revoked, now)
455 .is_err()
456 );
457 let mut other_request = request.clone();
458 other_request.nonce = "different-poll".into();
459 assert!(
460 receipt
461 .verified_task_status(&other_request, &policy, now)
462 .is_err()
463 );
464
465 receipt.remote_status = 503;
466 assert!(
467 receipt
468 .verified_task_status(&request, &policy, now)
469 .is_err()
470 );
471 receipt.remote_status = 200;
472 let mut tampered = reply;
473 tampered.status.state = TaskState::Completed;
474 receipt.unverified_task_response = Some(serde_json::to_string(&tampered).unwrap());
475 assert!(
476 receipt
477 .verified_task_status(&request, &policy, now)
478 .is_err()
479 );
480 for raw in [
481 None,
482 Some(String::new()),
483 Some("{\"accepted\":true}".into()),
484 ] {
485 receipt.unverified_task_response = raw;
486 assert!(
487 receipt
488 .verified_task_status(&request, &policy, now)
489 .is_err()
490 );
491 }
492 }
493
494 #[test]
495 fn callback_signer_is_called_once_and_preserves_the_transcript() {
496 let (request, original, policy, now) = fixture();
497 let calls = std::cell::Cell::new(0);
498 let signed = SignedTaskStatusV1::sign_status_with(
499 &request,
500 original.status.clone(),
501 "server-key",
502 now,
503 |bytes| {
504 calls.set(calls.get() + 1);
505 Ok(crate::core::agent_identity::sign_bytes_with(
506 &SigningKey::from_bytes(&[9; 32]),
507 bytes,
508 ))
509 },
510 )
511 .expect("callback signs");
512 assert_eq!(calls.get(), 1);
513 assert_eq!(signed, original);
514 assert_eq!(signed.verify(&request, &policy, now), Ok(()));
515 }
516
517 #[test]
518 fn callback_failure_or_bad_input_never_produces_a_fallback_signature() {
519 let (mut request, reply, _, now) = fixture();
520 assert!(
521 SignedTaskStatusV1::sign_status_with(
522 &request,
523 reply.status.clone(),
524 "server-key",
525 now,
526 |_| Err("governed key unavailable".into()),
527 )
528 .is_err()
529 );
530 assert!(
531 SignedTaskStatusV1::sign_status_with(
532 &request,
533 reply.status.clone(),
534 "server-key",
535 now,
536 |_| Ok(vec![0; 63]),
537 )
538 .is_err()
539 );
540 request.schema_version = 99;
541 assert!(
542 SignedTaskStatusV1::sign_status_with(
543 &request,
544 reply.status,
545 "server-key",
546 now,
547 |_| panic!("invalid input must not invoke signing"),
548 )
549 .is_err()
550 );
551 }
552}