Skip to main content

lean_ctx/core/a2a/
task_response.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Request-bound status response provenance, separate from control authority.
4//! The receiver must authorize the request and target before creating a reply.
5
6use 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/// Request-scoped signer admitted by the receiver's configured task policy.
26/// Loading never creates a key; an immutable policy snapshot governs the request.
27#[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        // Caller authentication precedes even reading the receiver's private key.
42        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    /// Call only after receiver-side request and target authorization. The key
131    /// must be the configured identity for request.recipient, not a generated
132    /// fallback identity. Verification independently checks that trust binding.
133    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    /// Low-level callback seam, not an identity/admission authority. Callers
146    /// must authorize request, target and signer independently. The HTTP task
147    /// receiver uses AdmittedStatusSigner with the configured task policy.
148    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    /// Bounded wire parsing is not verification. Call verify before consuming
193    /// status; successful HTTP transport alone conveys no response provenance.
194    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    /// Trust comes from the caller's configured peer table, never the reply.
202    /// This authenticates the peer's observation; it does not grant permission
203    /// to execute a task operation. A fresh request nonce changes request_digest
204    /// and rejects a response replayed from any prior polling request.
205    pub fn verify(
206        &self,
207        request: &TaskControlDescriptorV1,
208        policy: &TaskAuthorityConfigV1,
209        now: DateTime<Utc>,
210    ) -> Result<(), InvalidTaskResponse> {
211        // Reject non-ASCII before the legacy hex decoder slices byte pairs.
212        // Ed25519 signatures are exactly 64 bytes, encoded as 128 hex bytes.
213        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}