Skip to main content

lean_ctx/core/a2a/task/
authority.rs

1// SPDX-License-Identifier: Apache-2.0
2
3use chrono::{DateTime, Utc};
4use ed25519_dalek::SigningKey;
5use serde::{Deserialize, Serialize};
6use std::path::Path;
7
8use super::{
9    ARTIFACT_REF_VERSION, CAPABILITY_GRANT_REF_VERSION, DIGEST_HEX_LEN, DIGEST_PREFIX,
10    MAX_ARTIFACT_REFS, MAX_AUTHORITY_CLOCK_SKEW_SECONDS, MAX_CANCEL_REASON_BYTES,
11    MAX_CONTROL_NONCE_BYTES, MAX_DESCRIPTION_BYTES, MAX_DESCRIPTOR_STRING_BYTES,
12    MAX_IDEMPOTENCY_KEY_BYTES, MAX_POLICY_LIFETIME_SECONDS, MAX_TASK_CONTROL_LIFETIME_SECONDS,
13    MAX_TASK_DESCRIPTOR_BYTES, MAX_TASK_LIFETIME_SECONDS, MAX_TRUST_ENTRIES, TASK_ACTION_CANCEL,
14    TASK_ACTION_GET, TASK_ACTION_SEND, TASK_AUTHORITY_CONFIG_VERSION,
15    TASK_CONTROL_DESCRIPTOR_VERSION, TASK_CONTROL_SIGNING_DOMAIN, TASK_DESCRIPTOR_SIGNING_DOMAIN,
16    TASK_DESCRIPTOR_VERSION, delivery_authority, delivery_delegation, policy_file,
17};
18
19#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
20#[serde(deny_unknown_fields)]
21pub struct AgentArtifactRefV1 {
22    pub schema_version: u32,
23    pub artifact_id: String,
24    pub digest: String,
25    pub media_type: String,
26    pub size_bytes: u64,
27    pub uri: Option<String>,
28}
29
30impl AgentArtifactRefV1 {
31    fn validate(&self) -> Result<(), TaskAuthorityError> {
32        if self.schema_version != ARTIFACT_REF_VERSION
33            || self.size_bytes > MAX_TASK_DESCRIPTOR_BYTES as u64
34        {
35            return Err(TaskAuthorityError::MalformedBounds);
36        }
37        validate_identifier(&self.artifact_id, MAX_DESCRIPTOR_STRING_BYTES)?;
38        validate_identifier(&self.digest, MAX_DESCRIPTOR_STRING_BYTES)?;
39        validate_identifier(&self.media_type, MAX_DESCRIPTOR_STRING_BYTES)?;
40        if let Some(uri) = &self.uri {
41            validate_identifier(uri, MAX_DESCRIPTOR_STRING_BYTES)?;
42        }
43        Ok(())
44    }
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
48#[serde(deny_unknown_fields)]
49pub struct CapabilityGrantRefV1 {
50    pub schema_version: u32,
51    pub grant_id: String,
52}
53
54impl CapabilityGrantRefV1 {
55    pub(super) fn validate(&self) -> Result<(), TaskAuthorityError> {
56        if self.schema_version != CAPABILITY_GRANT_REF_VERSION {
57            return Err(TaskAuthorityError::MalformedBounds);
58        }
59        validate_identifier(&self.grant_id, MAX_DESCRIPTOR_STRING_BYTES)
60    }
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
64#[serde(deny_unknown_fields)]
65pub struct TaskScopeV1 {
66    pub schema_version: u32,
67    pub tenant_id: String,
68    pub project_id: String,
69}
70
71impl TaskScopeV1 {
72    fn validate(&self) -> Result<(), TaskAuthorityError> {
73        if self.schema_version != TASK_AUTHORITY_CONFIG_VERSION {
74            return Err(TaskAuthorityError::MalformedBounds);
75        }
76        validate_identifier(&self.tenant_id, MAX_DESCRIPTOR_STRING_BYTES)?;
77        validate_identifier(&self.project_id, MAX_DESCRIPTOR_STRING_BYTES)
78    }
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
82#[serde(deny_unknown_fields)]
83pub struct TaskPeerTrustV1 {
84    pub schema_version: u32,
85    pub key_id: String,
86    pub agent_id: String,
87    pub public_key: String,
88    pub allowed_actions: Vec<String>,
89    pub allowed_scopes: Vec<TaskScopeV1>,
90    pub not_before: DateTime<Utc>,
91    pub expires_at: DateTime<Utc>,
92    pub revoked: bool,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
96#[serde(deny_unknown_fields)]
97pub struct TaskCapabilityGrantV1 {
98    pub schema_version: u32,
99    pub grant_id: String,
100    pub key_id: String,
101    pub action: String,
102    pub tenant_id: String,
103    pub project_id: String,
104    pub not_before: DateTime<Utc>,
105    pub expires_at: DateTime<Utc>,
106    pub revoked: bool,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
110#[serde(deny_unknown_fields)]
111pub struct TaskAuthorityConfigV1 {
112    pub schema_version: u32,
113    pub peers: Vec<TaskPeerTrustV1>,
114    pub grants: Vec<TaskCapabilityGrantV1>,
115}
116
117impl Default for TaskAuthorityConfigV1 {
118    fn default() -> Self {
119        Self {
120            schema_version: TASK_AUTHORITY_CONFIG_VERSION,
121            peers: Vec::new(),
122            grants: Vec::new(),
123        }
124    }
125}
126
127impl TaskAuthorityConfigV1 {
128    /// Load and fully validate the same bounded policy used by the receiver.
129    pub fn from_file(path: &Path) -> Result<Self, String> {
130        Self::from_json(&policy_file::read_bounded(path)?)
131    }
132
133    /// Parse a configured trust policy from JSON, rejecting anything that is
134    /// not fully valid. Trust configuration is authority, so a partially
135    /// understood policy is never loaded.
136    pub fn from_json(raw: &str) -> Result<Self, String> {
137        let config: Self = serde_json::from_str(raw).map_err(|error| error.to_string())?;
138        config.validate().map_err(|error| error.to_string())?;
139        Ok(config)
140    }
141
142    /// Validate every trust entry, its bounds, and cross-references.
143    ///
144    /// Fails closed on duplicate key or grant identifiers: a duplicate makes
145    /// the effective authority depend on lookup order rather than on the
146    /// operator's intent, and a revoked duplicate could be shadowed by an
147    /// active one.
148    pub fn validate(&self) -> Result<(), TaskAuthorityError> {
149        if self.schema_version != TASK_AUTHORITY_CONFIG_VERSION
150            || self.peers.len() > MAX_TRUST_ENTRIES
151            || self.grants.len() > MAX_TRUST_ENTRIES
152        {
153            return Err(TaskAuthorityError::MalformedBounds);
154        }
155        for (index, peer) in self.peers.iter().enumerate() {
156            validate_peer(peer)?;
157            if self.peers[..index].iter().any(|other| {
158                other.key_id == peer.key_id
159                    || other.public_key.eq_ignore_ascii_case(&peer.public_key)
160            }) {
161                return Err(TaskAuthorityError::DuplicateTrustEntry);
162            }
163        }
164        for (index, grant) in self.grants.iter().enumerate() {
165            validate_grant(grant)?;
166            if self.grants[..index]
167                .iter()
168                .any(|other| other.grant_id == grant.grant_id)
169            {
170                return Err(TaskAuthorityError::DuplicateTrustEntry);
171            }
172            if !self.peers.iter().any(|peer| peer.key_id == grant.key_id) {
173                return Err(TaskAuthorityError::UnknownKey);
174            }
175        }
176        Ok(())
177    }
178
179    /// Reject a policy whose Ed25519 authority material reuses a bearer token
180    /// or the HMAC channel secret. Channel integrity and task authority are
181    /// separate trust domains; sharing one secret across both would let any
182    /// holder of the channel secret mint task authority.
183    pub fn reject_shared_secret(&self, secret: &str) -> Result<(), TaskAuthorityError> {
184        let secret = secret.trim();
185        if secret.is_empty() {
186            return Ok(());
187        }
188        if self
189            .peers
190            .iter()
191            .any(|peer| peer.public_key.eq_ignore_ascii_case(secret) || peer.key_id == secret)
192        {
193            return Err(TaskAuthorityError::SharedSecretReuse);
194        }
195        Ok(())
196    }
197}
198
199#[derive(Debug, Clone, PartialEq, Eq)]
200pub enum TaskAuthorityError {
201    MalformedBounds,
202    UnsupportedAction,
203    UnknownKey,
204    RevokedKey,
205    InvalidKey,
206    InvalidSignature,
207    WrongSender,
208    WrongRecipient,
209    WrongScope,
210    KeyNotYetValid,
211    KeyExpired,
212    MissingGrant,
213    RevokedGrant,
214    GrantNotYetValid,
215    GrantExpired,
216    GrantMismatch,
217    DescriptorNotYetValid,
218    DescriptorExpired,
219    DuplicateTrustEntry,
220    SharedSecretReuse,
221}
222
223impl std::fmt::Display for TaskAuthorityError {
224    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
225        f.write_str(match self {
226            Self::MalformedBounds => "malformed task authority bounds",
227            Self::UnsupportedAction => "unsupported task action",
228            Self::UnknownKey => "unknown task authority key",
229            Self::RevokedKey => "revoked task authority key",
230            Self::InvalidKey => "invalid task authority key",
231            Self::InvalidSignature => "invalid task descriptor signature",
232            Self::WrongSender => "task descriptor sender mismatch",
233            Self::WrongRecipient => "task descriptor recipient mismatch",
234            Self::WrongScope => "task descriptor scope mismatch",
235            Self::KeyNotYetValid => "task authority key is not yet valid",
236            Self::KeyExpired => "task authority key is expired",
237            Self::MissingGrant => "task capability grant is missing",
238            Self::RevokedGrant => "task capability grant is revoked",
239            Self::GrantNotYetValid => "task capability grant is not yet valid",
240            Self::GrantExpired => "task capability grant is expired",
241            Self::GrantMismatch => "task capability grant does not authorize descriptor",
242            Self::DescriptorNotYetValid => "task descriptor is not yet valid",
243            Self::DescriptorExpired => "task descriptor is expired",
244            Self::DuplicateTrustEntry => "duplicate task authority trust entry",
245            Self::SharedSecretReuse => {
246                "task authority key must not reuse a bearer or channel secret"
247            }
248        })
249    }
250}
251
252impl std::error::Error for TaskAuthorityError {}
253
254#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
255#[serde(deny_unknown_fields)]
256pub struct TaskDescriptorV1 {
257    pub schema_version: u32,
258    pub sender: String,
259    pub recipient: String,
260    pub tenant_id: String,
261    pub project_id: String,
262    pub action: String,
263    pub issued_at: DateTime<Utc>,
264    pub expires_at: DateTime<Utc>,
265    pub idempotency_key: String,
266    pub description: String,
267    pub artifact_refs: Vec<AgentArtifactRefV1>,
268    pub grant_ref: CapabilityGrantRefV1,
269    pub key_id: String,
270    pub signature: String,
271}
272
273impl TaskDescriptorV1 {
274    #[allow(clippy::too_many_arguments)]
275    pub fn new(
276        sender: &str,
277        recipient: &str,
278        tenant_id: &str,
279        project_id: &str,
280        action: &str,
281        issued_at: DateTime<Utc>,
282        expires_at: DateTime<Utc>,
283        idempotency_key: &str,
284        description: &str,
285        artifact_refs: Vec<AgentArtifactRefV1>,
286        grant_id: &str,
287        key_id: &str,
288    ) -> Self {
289        Self {
290            schema_version: TASK_DESCRIPTOR_VERSION,
291            sender: sender.to_string(),
292            recipient: recipient.to_string(),
293            tenant_id: tenant_id.to_string(),
294            project_id: project_id.to_string(),
295            action: action.to_string(),
296            issued_at,
297            expires_at,
298            idempotency_key: idempotency_key.to_string(),
299            description: description.to_string(),
300            artifact_refs,
301            grant_ref: CapabilityGrantRefV1 {
302                schema_version: CAPABILITY_GRANT_REF_VERSION,
303                grant_id: grant_id.to_string(),
304            },
305            key_id: key_id.to_string(),
306            signature: String::new(),
307        }
308    }
309
310    /// Bind every authority-relevant field to an Ed25519 signature over the
311    /// deterministic signing transcript.
312    pub fn sign(&mut self, signing_key: &SigningKey) {
313        self.signature.clear();
314        let bytes = self.signing_bytes();
315        self.signature = crate::core::agent_identity::hex_encode(
316            &crate::core::agent_identity::sign_bytes_with(signing_key, &bytes),
317        );
318    }
319
320    /// Deterministic signing transcript: a domain tag followed by canonical
321    /// JSON (recursively sorted keys, no whitespace) of the descriptor with an
322    /// empty `signature` field. Independent of struct field order and of any
323    /// map iteration order.
324    #[must_use]
325    pub fn signing_bytes(&self) -> Vec<u8> {
326        let mut unsigned = self.clone();
327        unsigned.signature.clear();
328        let mut bytes = Vec::from(TASK_DESCRIPTOR_SIGNING_DOMAIN);
329        bytes.extend_from_slice(&crate::core::canonical::canonical_serialize(&unsigned));
330        bytes
331    }
332
333    /// Digest of the signed content, excluding the signature itself.
334    ///
335    /// Idempotency conflict detection compares this digest, so a retry that
336    /// re-signs byte-identical content stays a duplicate rather than becoming
337    /// a spurious conflict.
338    #[must_use]
339    pub fn content_digest(&self) -> String {
340        use sha2::{Digest, Sha256};
341        let digest = Sha256::digest(self.signing_bytes());
342        format!(
343            "sha256:{}",
344            crate::core::agent_identity::hex_encode(&digest)
345        )
346    }
347
348    pub fn validate_shape(&self) -> Result<(), TaskAuthorityError> {
349        if self.schema_version != TASK_DESCRIPTOR_VERSION
350            || self.action != TASK_ACTION_SEND
351            || self.artifact_refs.len() > MAX_ARTIFACT_REFS
352            || self.description.len() > MAX_DESCRIPTION_BYTES
353            || self.idempotency_key.len() > MAX_IDEMPOTENCY_KEY_BYTES
354            || self.expires_at <= self.issued_at
355            || (self.expires_at - self.issued_at).num_seconds() > MAX_TASK_LIFETIME_SECONDS
356        {
357            return Err(if self.action == TASK_ACTION_SEND {
358                TaskAuthorityError::MalformedBounds
359            } else {
360                TaskAuthorityError::UnsupportedAction
361            });
362        }
363        for value in [
364            &self.sender,
365            &self.recipient,
366            &self.tenant_id,
367            &self.project_id,
368            &self.action,
369            &self.idempotency_key,
370            &self.key_id,
371        ] {
372            validate_identifier(value, MAX_DESCRIPTOR_STRING_BYTES)?;
373        }
374        if self.description.is_empty()
375            || self.idempotency_key.is_empty()
376            || self.signature.len() != 128
377            || !self.signature.bytes().all(|byte| byte.is_ascii_hexdigit())
378        {
379            return Err(TaskAuthorityError::MalformedBounds);
380        }
381        self.grant_ref.validate()?;
382        for (index, artifact) in self.artifact_refs.iter().enumerate() {
383            artifact.validate()?;
384            if self.artifact_refs[..index]
385                .iter()
386                .any(|other| other.artifact_id == artifact.artifact_id)
387            {
388                return Err(TaskAuthorityError::DuplicateTrustEntry);
389            }
390        }
391        if self.signing_bytes().len() > MAX_TASK_DESCRIPTOR_BYTES {
392            return Err(TaskAuthorityError::MalformedBounds);
393        }
394        Ok(())
395    }
396
397    /// Fail-closed authority check performed before any task store mutation.
398    ///
399    /// Sender-provided capability claims are never consulted: authority comes
400    /// only from the configured peer trust entry resolved by `key_id`, the
401    /// Ed25519 signature over the descriptor, and the configured capability
402    /// grant it references.
403    pub fn verify_authority(
404        &self,
405        policy: &TaskAuthorityConfigV1,
406        expected: &TaskAuthorityExpectationV1<'_>,
407    ) -> Result<(), TaskAuthorityError> {
408        self.validate_shape()?;
409        authorize(
410            &AuthorityClaimV1 {
411                sender: &self.sender,
412                recipient: &self.recipient,
413                tenant_id: &self.tenant_id,
414                project_id: &self.project_id,
415                action: &self.action,
416                issued_at: self.issued_at,
417                expires_at: self.expires_at,
418                grant_id: &self.grant_ref.grant_id,
419                key_id: &self.key_id,
420                signature: &self.signature,
421                signing_bytes: self.signing_bytes(),
422            },
423            policy,
424            expected,
425        )
426    }
427}
428
429/// Signed control operation (`tasks/get`, `tasks/cancel`) against one existing
430/// task.
431///
432/// Deliberately a sibling of [`TaskDescriptorV1`] rather than an extension of
433/// it: the send signing transcript is derived from the send fields, so adding a
434/// target-task field there would either change the deployed V1 send signature
435/// or leave the new field unsigned and forgeable. This type carries its own
436/// signing domain, so a send signature can never be replayed as a control
437/// signature nor the reverse.
438///
439/// Verifying this descriptor authorizes the *operation*, never the *target*. It
440/// proves the sender holds a live, unrevoked grant for this action in this
441/// scope; it does not prove the sender owns `task_id`. Before disclosing or
442/// mutating a task the receiver must additionally compare the persisted owner
443/// ([`super::Task::from_agent`]) against `sender`, the persisted scope against
444/// `tenant_id`/`project_id`, and the persisted descriptor digest against
445/// `task_descriptor_digest`, and must answer a mismatch indistinguishably from
446/// an unknown task id so that no cross-tenant existence oracle appears.
447#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
448#[serde(deny_unknown_fields)]
449pub struct TaskControlDescriptorV1 {
450    pub schema_version: u32,
451    pub sender: String,
452    pub recipient: String,
453    pub tenant_id: String,
454    pub project_id: String,
455    pub action: String,
456    pub task_id: String,
457    pub task_descriptor_digest: String,
458    pub issued_at: DateTime<Utc>,
459    pub expires_at: DateTime<Utc>,
460    pub nonce: String,
461    pub reason: Option<String>,
462    pub grant_ref: CapabilityGrantRefV1,
463    pub key_id: String,
464    pub signature: String,
465}
466
467impl TaskControlDescriptorV1 {
468    #[allow(clippy::too_many_arguments)]
469    pub fn new(
470        sender: &str,
471        recipient: &str,
472        tenant_id: &str,
473        project_id: &str,
474        action: &str,
475        task_id: &str,
476        task_descriptor_digest: &str,
477        issued_at: DateTime<Utc>,
478        expires_at: DateTime<Utc>,
479        nonce: &str,
480        reason: Option<&str>,
481        grant_id: &str,
482        key_id: &str,
483    ) -> Self {
484        Self {
485            schema_version: TASK_CONTROL_DESCRIPTOR_VERSION,
486            sender: sender.to_string(),
487            recipient: recipient.to_string(),
488            tenant_id: tenant_id.to_string(),
489            project_id: project_id.to_string(),
490            action: action.to_string(),
491            task_id: task_id.to_string(),
492            task_descriptor_digest: task_descriptor_digest.to_string(),
493            issued_at,
494            expires_at,
495            nonce: nonce.to_string(),
496            reason: reason.map(str::to_string),
497            grant_ref: CapabilityGrantRefV1 {
498                schema_version: CAPABILITY_GRANT_REF_VERSION,
499                grant_id: grant_id.to_string(),
500            },
501            key_id: key_id.to_string(),
502            signature: String::new(),
503        }
504    }
505
506    /// Bind every authority-relevant field, including the target task id and
507    /// the digest of the task's original descriptor, to an Ed25519 signature.
508    pub fn sign(&mut self, signing_key: &SigningKey) {
509        self.signature.clear();
510        let bytes = self.signing_bytes();
511        self.signature = crate::core::agent_identity::hex_encode(
512            &crate::core::agent_identity::sign_bytes_with(signing_key, &bytes),
513        );
514    }
515
516    /// Deterministic signing transcript: the control domain tag followed by
517    /// canonical JSON of the descriptor with an empty `signature` field.
518    ///
519    /// The domain tag differs from `TASK_DESCRIPTOR_SIGNING_DOMAIN`, so the
520    /// two transcripts are disjoint even if their canonical bodies were made to
521    /// collide.
522    #[must_use]
523    pub fn signing_bytes(&self) -> Vec<u8> {
524        let mut unsigned = self.clone();
525        unsigned.signature.clear();
526        let mut bytes = Vec::from(TASK_CONTROL_SIGNING_DOMAIN);
527        bytes.extend_from_slice(&crate::core::canonical::canonical_serialize(&unsigned));
528        bytes
529    }
530
531    /// Digest of the signed content, excluding the signature itself. Used to
532    /// dedupe a replayed control operation and to bind a response to the
533    /// request it answers.
534    #[must_use]
535    pub fn content_digest(&self) -> String {
536        use sha2::{Digest, Sha256};
537        let digest = Sha256::digest(self.signing_bytes());
538        format!(
539            "sha256:{}",
540            crate::core::agent_identity::hex_encode(&digest)
541        )
542    }
543
544    pub fn validate_shape(&self) -> Result<(), TaskAuthorityError> {
545        if !is_control_action(&self.action) {
546            return Err(TaskAuthorityError::UnsupportedAction);
547        }
548        if self.schema_version != TASK_CONTROL_DESCRIPTOR_VERSION
549            || self.expires_at <= self.issued_at
550            || (self.expires_at - self.issued_at)
551                > chrono::Duration::seconds(MAX_TASK_CONTROL_LIFETIME_SECONDS)
552            || self.signature.len() != 128
553            || !self.signature.bytes().all(|byte| byte.is_ascii_hexdigit())
554        {
555            return Err(TaskAuthorityError::MalformedBounds);
556        }
557        for value in [
558            &self.sender,
559            &self.recipient,
560            &self.tenant_id,
561            &self.project_id,
562            &self.action,
563            &self.task_id,
564            &self.key_id,
565        ] {
566            validate_identifier(value, MAX_DESCRIPTOR_STRING_BYTES)?;
567        }
568        validate_identifier(&self.nonce, MAX_CONTROL_NONCE_BYTES)?;
569        validate_digest(&self.task_descriptor_digest)?;
570        validate_control_reason(&self.action, self.reason.as_deref())?;
571        self.grant_ref.validate()?;
572        if self.signing_bytes().len() > MAX_TASK_DESCRIPTOR_BYTES {
573            return Err(TaskAuthorityError::MalformedBounds);
574        }
575        Ok(())
576    }
577
578    /// Fail-closed authority check for a control operation, performed before
579    /// any task lookup.
580    ///
581    /// Identical in strictness to the send path — same peer resolution, same
582    /// revocation, agent, scope, skew, TTL, grant-window and never-outlive-the-
583    /// grant rules — with a tighter maximum lifetime and its own signing
584    /// transcript. Because the grant is matched on `action`, a peer holding
585    /// only a `tasks/send` grant is never authorized for `tasks/get` or
586    /// `tasks/cancel`.
587    ///
588    /// Returning `Ok(())` does not establish ownership of `task_id`; see the
589    /// type-level documentation for the owner, scope and digest comparison the
590    /// receiver still owes before any disclosure or mutation.
591    pub fn verify_authority(
592        &self,
593        policy: &TaskAuthorityConfigV1,
594        expected: &TaskAuthorityExpectationV1<'_>,
595    ) -> Result<(), TaskAuthorityError> {
596        self.validate_shape()?;
597        authorize(
598            &AuthorityClaimV1 {
599                sender: &self.sender,
600                recipient: &self.recipient,
601                tenant_id: &self.tenant_id,
602                project_id: &self.project_id,
603                action: &self.action,
604                issued_at: self.issued_at,
605                expires_at: self.expires_at,
606                grant_id: &self.grant_ref.grant_id,
607                key_id: &self.key_id,
608                signature: &self.signature,
609                signing_bytes: self.signing_bytes(),
610            },
611            policy,
612            expected,
613        )
614    }
615}
616
617/// Receiver-side expectations a descriptor must match. Grouped so the four
618/// string bounds cannot be swapped at a call site.
619#[derive(Debug, Clone, Copy)]
620pub struct TaskAuthorityExpectationV1<'a> {
621    pub sender: &'a str,
622    pub recipient: &'a str,
623    pub tenant_id: &'a str,
624    pub project_id: &'a str,
625    pub now: DateTime<Utc>,
626}
627
628/// The authority-relevant projection every signed descriptor reduces to.
629///
630/// Send and control descriptors have different shapes and different signing
631/// transcripts but exactly one authority decision, so they share this claim
632/// instead of each carrying its own copy of the security logic.
633pub(super) struct AuthorityClaimV1<'a> {
634    pub(super) sender: &'a str,
635    pub(super) recipient: &'a str,
636    pub(super) tenant_id: &'a str,
637    pub(super) project_id: &'a str,
638    pub(super) action: &'a str,
639    pub(super) issued_at: DateTime<Utc>,
640    pub(super) expires_at: DateTime<Utc>,
641    pub(super) grant_id: &'a str,
642    pub(super) key_id: &'a str,
643    pub(super) signature: &'a str,
644    pub(super) signing_bytes: Vec<u8>,
645}
646
647/// Fail-closed authority check performed before any task store read or
648/// mutation, on a claim whose shape the caller has already validated.
649///
650/// Sender-provided capability claims are never consulted: authority comes only
651/// from the configured peer trust entry resolved by `key_id`, the Ed25519
652/// signature over that descriptor's own transcript, and the configured
653/// capability grant it references.
654pub(super) fn authorize(
655    claim: &AuthorityClaimV1<'_>,
656    policy: &TaskAuthorityConfigV1,
657    expected: &TaskAuthorityExpectationV1<'_>,
658) -> Result<(), TaskAuthorityError> {
659    policy.validate()?;
660    if claim.sender != expected.sender {
661        return Err(TaskAuthorityError::WrongSender);
662    }
663    if claim.recipient != expected.recipient {
664        return Err(TaskAuthorityError::WrongRecipient);
665    }
666    if claim.tenant_id != expected.tenant_id || claim.project_id != expected.project_id {
667        return Err(TaskAuthorityError::WrongScope);
668    }
669
670    let now = expected.now;
671    let peer = policy
672        .peers
673        .iter()
674        .find(|peer| peer.key_id == claim.key_id)
675        .ok_or(TaskAuthorityError::UnknownKey)?;
676    if peer.revoked {
677        return Err(TaskAuthorityError::RevokedKey);
678    }
679    if peer.agent_id != claim.sender {
680        return Err(TaskAuthorityError::WrongSender);
681    }
682    if !peer
683        .allowed_actions
684        .iter()
685        .any(|action| action == claim.action)
686    {
687        return Err(TaskAuthorityError::UnsupportedAction);
688    }
689    if !peer
690        .allowed_scopes
691        .iter()
692        .any(|scope| scope.tenant_id == claim.tenant_id && scope.project_id == claim.project_id)
693    {
694        return Err(TaskAuthorityError::WrongScope);
695    }
696    // Authenticate the bytes before any further field is trusted.
697    if !verify_signed_transcript(claim.signature, &claim.signing_bytes, peer)? {
698        return Err(TaskAuthorityError::InvalidSignature);
699    }
700    if claim.issued_at > now + chrono::Duration::seconds(MAX_AUTHORITY_CLOCK_SKEW_SECONDS) {
701        return Err(TaskAuthorityError::DescriptorNotYetValid);
702    }
703    if now >= claim.expires_at {
704        return Err(TaskAuthorityError::DescriptorExpired);
705    }
706    if now < peer.not_before || claim.issued_at < peer.not_before {
707        return Err(TaskAuthorityError::KeyNotYetValid);
708    }
709    if now >= peer.expires_at {
710        return Err(TaskAuthorityError::KeyExpired);
711    }
712
713    let grant = policy
714        .grants
715        .iter()
716        .find(|grant| grant.grant_id == claim.grant_id)
717        .ok_or(TaskAuthorityError::MissingGrant)?;
718    if grant.revoked {
719        return Err(TaskAuthorityError::RevokedGrant);
720    }
721    if grant.key_id != claim.key_id
722        || grant.action != claim.action
723        || grant.tenant_id != claim.tenant_id
724        || grant.project_id != claim.project_id
725    {
726        return Err(TaskAuthorityError::GrantMismatch);
727    }
728    if now < grant.not_before || claim.issued_at < grant.not_before {
729        return Err(TaskAuthorityError::GrantNotYetValid);
730    }
731    if now >= grant.expires_at {
732        return Err(TaskAuthorityError::GrantExpired);
733    }
734    // A descriptor may never outlive the authority that permits it.
735    if claim.expires_at > grant.expires_at || claim.expires_at > peer.expires_at {
736        return Err(TaskAuthorityError::GrantMismatch);
737    }
738    Ok(())
739}
740
741fn verify_signed_transcript(
742    signature: &str,
743    transcript: &[u8],
744    peer: &TaskPeerTrustV1,
745) -> Result<bool, TaskAuthorityError> {
746    let public_key = crate::core::agent_identity::hex_decode(&peer.public_key)
747        .map_err(|_| TaskAuthorityError::InvalidKey)?;
748    let signature = crate::core::agent_identity::hex_decode(signature)
749        .map_err(|_| TaskAuthorityError::InvalidSignature)?;
750    Ok(crate::core::agent_identity::verify_signature(
751        &public_key,
752        transcript,
753        &signature,
754    ))
755}
756
757/// `tasks/get` and `tasks/cancel` are the only control operations. `tasks/send`
758/// creates a task and is never expressible as a control descriptor, so holding
759/// a send grant can never be laundered into a control operation.
760fn is_control_action(action: &str) -> bool {
761    action == TASK_ACTION_GET || action == TASK_ACTION_CANCEL
762}
763
764/// The closed set of actions this build understands. Peer and grant validation
765/// accept only these constants — never an arbitrary string — so a policy naming
766/// an action this build does not implement fails closed at load.
767fn is_supported_task_action(action: &str) -> bool {
768    action == TASK_ACTION_SEND
769        || is_control_action(action)
770        || delivery_authority::is_delivery_action(action)
771        || action == delivery_delegation::DELIVERY_DELEGATE
772}
773
774pub(super) fn validate_digest(value: &str) -> Result<(), TaskAuthorityError> {
775    let hex = value
776        .strip_prefix(DIGEST_PREFIX)
777        .ok_or(TaskAuthorityError::MalformedBounds)?;
778    if hex.len() != DIGEST_HEX_LEN || !hex.bytes().all(|byte| byte.is_ascii_hexdigit()) {
779        return Err(TaskAuthorityError::MalformedBounds);
780    }
781    Ok(())
782}
783
784/// A cancel reason is operator-facing free text, so it is bounded and confined
785/// to printable ASCII rather than run through the identifier charset.
786/// `tasks/get` carries no reason at all: an unexpected one means the sender and
787/// this receiver disagree about the operation, and that is never resolved in
788/// the sender's favour.
789fn validate_control_reason(action: &str, reason: Option<&str>) -> Result<(), TaskAuthorityError> {
790    let Some(reason) = reason else {
791        return Ok(());
792    };
793    if action != TASK_ACTION_CANCEL
794        || reason.is_empty()
795        || reason.len() > MAX_CANCEL_REASON_BYTES
796        || !reason
797            .bytes()
798            .all(|byte| byte.is_ascii_graphic() || byte == b' ')
799    {
800        return Err(TaskAuthorityError::MalformedBounds);
801    }
802    Ok(())
803}
804
805fn validate_peer(peer: &TaskPeerTrustV1) -> Result<(), TaskAuthorityError> {
806    if peer.schema_version != TASK_AUTHORITY_CONFIG_VERSION
807        || peer.allowed_actions.is_empty()
808        || peer.allowed_scopes.is_empty()
809        || peer.expires_at <= peer.not_before
810        || (peer.expires_at - peer.not_before).num_seconds() > MAX_POLICY_LIFETIME_SECONDS
811    {
812        return Err(TaskAuthorityError::MalformedBounds);
813    }
814    validate_identifier(&peer.key_id, MAX_DESCRIPTOR_STRING_BYTES)?;
815    validate_identifier(&peer.agent_id, MAX_DESCRIPTOR_STRING_BYTES)?;
816    if peer.public_key.len() != 64 || !peer.public_key.bytes().all(|byte| byte.is_ascii_hexdigit())
817    {
818        return Err(TaskAuthorityError::InvalidKey);
819    }
820    for action in &peer.allowed_actions {
821        if !is_supported_task_action(action) {
822            return Err(TaskAuthorityError::UnsupportedAction);
823        }
824    }
825    for scope in &peer.allowed_scopes {
826        scope.validate()?;
827    }
828    Ok(())
829}
830
831fn validate_grant(grant: &TaskCapabilityGrantV1) -> Result<(), TaskAuthorityError> {
832    if grant.schema_version != TASK_AUTHORITY_CONFIG_VERSION
833        || grant.expires_at <= grant.not_before
834        || (grant.expires_at - grant.not_before).num_seconds() > MAX_POLICY_LIFETIME_SECONDS
835    {
836        return Err(TaskAuthorityError::MalformedBounds);
837    }
838    validate_identifier(&grant.grant_id, MAX_DESCRIPTOR_STRING_BYTES)?;
839    validate_identifier(&grant.key_id, MAX_DESCRIPTOR_STRING_BYTES)?;
840    validate_identifier(&grant.action, MAX_DESCRIPTOR_STRING_BYTES)?;
841    validate_identifier(&grant.tenant_id, MAX_DESCRIPTOR_STRING_BYTES)?;
842    validate_identifier(&grant.project_id, MAX_DESCRIPTOR_STRING_BYTES)?;
843    if !is_supported_task_action(&grant.action) {
844        return Err(TaskAuthorityError::UnsupportedAction);
845    }
846    Ok(())
847}
848
849pub(super) fn validate_identifier(value: &str, max_bytes: usize) -> Result<(), TaskAuthorityError> {
850    if value.is_empty()
851        || value.len() > max_bytes
852        || !value.is_ascii()
853        || !value
854            .bytes()
855            .all(|byte| byte.is_ascii_alphanumeric() || b"._:/-".contains(&byte))
856    {
857        return Err(TaskAuthorityError::MalformedBounds);
858    }
859    Ok(())
860}