Skip to main content

lean_ctx/core/a2a/task/
delivery_authority.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Signed delivery operations using the existing peer/grant authority.
4//! Verification does not consume a nonce or authorize access to a private record;
5//! the receiving transport must enforce replay protection and record ownership.
6
7use chrono::{DateTime, Utc};
8use ed25519_dalek::SigningKey;
9use serde::{Deserialize, Serialize};
10
11use super::{
12    AuthorityClaimV1, CapabilityGrantRefV1, MAX_CONTROL_NONCE_BYTES, MAX_DESCRIPTOR_STRING_BYTES,
13    TaskAuthorityConfigV1, TaskAuthorityError, TaskAuthorityExpectationV1, authorize,
14    validate_digest, validate_identifier,
15};
16
17pub const DELIVERY_CHECK: &str = "delivery/check";
18pub const DELIVERY_RECORD: &str = "delivery/record";
19const DOMAIN: &[u8] = b"leanctx.delivery.operation.sig.v1\0";
20
21/// Local signer configuration, not a server-side authorization grant. The
22/// receiver independently verifies every operation against its trust policy.
23#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
24#[serde(deny_unknown_fields)]
25pub struct DeliverySigningProfileV1 {
26    pub schema_version: u32,
27    pub agent_id: String,
28    pub recipient: String,
29    pub tenant_id: String,
30    pub project_id: String,
31    pub project_root: std::path::PathBuf,
32    pub key_id: String,
33    pub read_grant_id: String,
34    pub write_grant_id: String,
35    pub privacy: lean_ctx_ocla::delivery_scope::DeliveryPrivacyV1,
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub delegation: Option<Box<super::delivery_delegation::DeliveryDelegationV1>>,
38}
39
40impl DeliverySigningProfileV1 {
41    /// Explicit per-process profile supplied by the host. Contains identifiers,
42    /// not secret key material; server trust remains independently configured.
43    pub fn from_environment() -> anyhow::Result<Option<Self>> {
44        match std::env::var("LEAN_CTX_DELIVERY_PROFILE") {
45            Ok(raw) => {
46                anyhow::ensure!(raw.len() <= 16_384, "delivery profile exceeds size bound");
47                Ok(Some(serde_json::from_str(&raw)?))
48            }
49            Err(std::env::VarError::NotPresent) => Ok(None),
50            Err(_) => anyhow::bail!("delivery profile is not valid Unicode"),
51        }
52    }
53
54    pub fn sign_request(
55        &self,
56        request: DeliveryOperation,
57    ) -> anyhow::Result<SignedDeliveryRequest> {
58        anyhow::ensure!(
59            self.schema_version == 1,
60            "unsupported delivery signing profile"
61        );
62        for value in [
63            &self.agent_id,
64            &self.recipient,
65            &self.tenant_id,
66            &self.project_id,
67            &self.key_id,
68            &self.read_grant_id,
69            &self.write_grant_id,
70        ] {
71            validate_identifier(value, MAX_DESCRIPTOR_STRING_BYTES)?;
72        }
73        let scope = lean_ctx_ocla::delivery_scope::DeliveryScopeV1::new(
74            self.tenant_id.clone(),
75            self.project_id.clone(),
76        )
77        .map_err(anyhow::Error::msg)?;
78        let path = match &request {
79            DeliveryOperation::Check { path, .. } => path,
80            DeliveryOperation::Record { entry } => {
81                anyhow::ensure!(
82                    entry.agent_id == self.agent_id
83                        && entry.access.as_ref().is_some_and(
84                            |access| access.scope == scope && access.privacy == self.privacy
85                        ),
86                    "delivery record differs from signing profile"
87                );
88                &entry.path
89            }
90        };
91        anyhow::ensure!(
92            self.project_root.is_absolute(),
93            "absolute delivery project root required"
94        );
95        let root = self.project_root.canonicalize()?;
96        let resolved = std::path::Path::new(path).canonicalize()?;
97        anyhow::ensure!(
98            root.is_dir() && resolved.is_file() && resolved.starts_with(&root),
99            "delivery path outside configured project"
100        );
101        let key = crate::core::agent_identity::get_stored_signing_key(&self.agent_id)
102            .map_err(anyhow::Error::msg)?;
103        let now = Utc::now();
104        let mut expires_at = now + chrono::Duration::minutes(1);
105        if let Some(certificate) = &self.delegation {
106            anyhow::ensure!(
107                certificate.schema_version == 1
108                    && certificate.issued_at <= now
109                    && certificate.project_root == root
110                    && certificate.privacy == self.privacy
111                    && certificate.child_agent == self.agent_id
112                    && certificate.child_key_id == self.key_id
113                    && certificate.recipient == self.recipient
114                    && certificate.tenant_id == self.tenant_id
115                    && certificate.project_id == self.project_id
116                    && certificate.read_grant_id == self.read_grant_id
117                    && certificate.child_public_key
118                        == crate::core::agent_identity::hex_encode(key.verifying_key().as_bytes())
119                    && certificate.expires_at > now,
120                "delegation differs from signing profile or is expired"
121            );
122            if request.action() == DELIVERY_RECORD {
123                anyhow::ensure!(
124                    certificate.write_grant_id.as_deref() == Some(self.write_grant_id.as_str()),
125                    "delegation does not permit this write grant"
126                );
127            }
128            expires_at = expires_at.min(certificate.expires_at);
129        }
130        let authority = DeliveryAuthorityV1 {
131            schema_version: 1,
132            sender: self.agent_id.clone(),
133            recipient: self.recipient.clone(),
134            tenant_id: self.tenant_id.clone(),
135            project_id: self.project_id.clone(),
136            action: request.action().into(),
137            request_digest: request.request_digest(),
138            issued_at: now,
139            expires_at,
140            nonce: uuid::Uuid::new_v4().to_string(),
141            grant_ref: CapabilityGrantRefV1 {
142                schema_version: 1,
143                grant_id: match request {
144                    DeliveryOperation::Check { .. } => self.read_grant_id.clone(),
145                    DeliveryOperation::Record { .. } => self.write_grant_id.clone(),
146                },
147            },
148            key_id: self.key_id.clone(),
149            signature: String::new(),
150        };
151        let mut signed = SignedDeliveryRequest {
152            authority,
153            request,
154            delegation: self.delegation.clone(),
155        };
156        signed.authority.request_digest = signed.bound_request_digest();
157        signed.authority.sign(&key);
158        Ok(signed)
159    }
160}
161
162#[derive(Deserialize, Serialize)]
163#[serde(deny_unknown_fields)]
164pub struct SignedDeliveryRequest {
165    pub authority: DeliveryAuthorityV1,
166    pub request: DeliveryOperation,
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub delegation: Option<Box<super::delivery_delegation::DeliveryDelegationV1>>,
169}
170
171impl SignedDeliveryRequest {
172    /// The child signs the certificate as well as the operation, preventing a
173    /// proof from being transplanted onto a different task delegation.
174    pub fn bound_request_digest(&self) -> String {
175        use sha2::{Digest, Sha256};
176        match &self.delegation {
177            None => self.request.request_digest(),
178            Some(certificate) => {
179                let bytes = crate::core::canonical::canonical_serialize(&(
180                    "leanctx.delegated.delivery.request.v1",
181                    &self.request,
182                    certificate,
183                ));
184                format!(
185                    "sha256:{}",
186                    crate::core::agent_identity::hex_encode(&Sha256::digest(bytes))
187                )
188            }
189        }
190    }
191}
192
193#[derive(Deserialize, Serialize)]
194#[serde(tag = "operation", rename_all = "snake_case", deny_unknown_fields)]
195pub enum DeliveryOperation {
196    Check {
197        path: String,
198        blake3: [u8; 12],
199        conversation_id: Option<String>,
200    },
201    Record {
202        entry: crate::core::ocla::types::DeliveryEntry,
203    },
204}
205
206impl DeliveryOperation {
207    pub fn action(&self) -> &'static str {
208        match self {
209            Self::Check { .. } => DELIVERY_CHECK,
210            Self::Record { .. } => DELIVERY_RECORD,
211        }
212    }
213
214    pub fn request_digest(&self) -> String {
215        use sha2::{Digest, Sha256};
216        format!(
217            "sha256:{}",
218            crate::core::agent_identity::hex_encode(&Sha256::digest(
219                crate::core::canonical::canonical_serialize(self)
220            ),)
221        )
222    }
223}
224
225pub(super) fn is_delivery_action(action: &str) -> bool {
226    matches!(action, DELIVERY_CHECK | DELIVERY_RECORD)
227}
228
229/// The digest binds the complete canonical request, including path, content
230/// identity, privacy and conversation. No unsigned request field grants access.
231#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
232#[serde(deny_unknown_fields)]
233pub struct DeliveryAuthorityV1 {
234    pub schema_version: u32,
235    pub sender: String,
236    pub recipient: String,
237    pub tenant_id: String,
238    pub project_id: String,
239    pub action: String,
240    pub request_digest: String,
241    pub issued_at: DateTime<Utc>,
242    pub expires_at: DateTime<Utc>,
243    pub nonce: String,
244    pub grant_ref: CapabilityGrantRefV1,
245    pub key_id: String,
246    pub signature: String,
247}
248
249impl DeliveryAuthorityV1 {
250    pub fn signing_bytes(&self) -> Vec<u8> {
251        let mut unsigned = self.clone();
252        unsigned.signature.clear();
253        let mut bytes = DOMAIN.to_vec();
254        bytes.extend(crate::core::canonical::canonical_serialize(&unsigned));
255        bytes
256    }
257
258    pub fn sign(&mut self, key: &SigningKey) {
259        self.signature = crate::core::agent_identity::hex_encode(
260            &crate::core::agent_identity::sign_bytes_with(key, &self.signing_bytes()),
261        );
262    }
263
264    /// The receiver supplies its actual operation and computed request digest;
265    /// using descriptor-provided values for these arguments defeats binding.
266    pub fn verify_authority(
267        &self,
268        policy: &TaskAuthorityConfigV1,
269        expected: &TaskAuthorityExpectationV1<'_>,
270        operation: &str,
271        request_digest: &str,
272    ) -> Result<(), TaskAuthorityError> {
273        if !is_delivery_action(&self.action) || self.action != operation {
274            return Err(TaskAuthorityError::UnsupportedAction);
275        }
276        if self.schema_version != 1
277            || self.expires_at <= self.issued_at
278            || self.expires_at - self.issued_at > chrono::Duration::minutes(5)
279            || self.signature.len() != 128
280            || !self.signature.bytes().all(|byte| byte.is_ascii_hexdigit())
281            || self.request_digest != request_digest
282        {
283            return Err(TaskAuthorityError::MalformedBounds);
284        }
285        for value in [
286            &self.sender,
287            &self.recipient,
288            &self.tenant_id,
289            &self.project_id,
290            &self.key_id,
291        ] {
292            validate_identifier(value, MAX_DESCRIPTOR_STRING_BYTES)?;
293        }
294        validate_identifier(&self.nonce, MAX_CONTROL_NONCE_BYTES)?;
295        validate_digest(&self.request_digest)?;
296        self.grant_ref.validate()?;
297        authorize(
298            &AuthorityClaimV1 {
299                sender: &self.sender,
300                recipient: &self.recipient,
301                tenant_id: &self.tenant_id,
302                project_id: &self.project_id,
303                action: &self.action,
304                issued_at: self.issued_at,
305                expires_at: self.expires_at,
306                grant_id: &self.grant_ref.grant_id,
307                key_id: &self.key_id,
308                signature: &self.signature,
309                signing_bytes: self.signing_bytes(),
310            },
311            policy,
312            expected,
313        )
314    }
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320    use crate::core::a2a::task::{
321        TASK_ACTION_SEND, TaskCapabilityGrantV1, TaskPeerTrustV1, TaskScopeV1,
322    };
323
324    #[test]
325    fn delivery_signing_profile_requires_existing_key_and_project_path() {
326        let _isolated = crate::core::data_dir::isolated_data_dir();
327        let root = tempfile::tempdir().expect("project");
328        let outside = tempfile::NamedTempFile::new().expect("outside file");
329        let path = root.path().join("context.rs");
330        std::fs::write(&path, "context").expect("fixture");
331        let profile = DeliverySigningProfileV1 {
332            schema_version: 1,
333            agent_id: "profile-reader".into(),
334            delegation: None,
335            recipient: "daemon".into(),
336            tenant_id: "account".into(),
337            project_id: "project".into(),
338            project_root: root.path().to_owned(),
339            key_id: "key".into(),
340            read_grant_id: "read".into(),
341            write_grant_id: "write".into(),
342            privacy: lean_ctx_ocla::delivery_scope::DeliveryPrivacyV1::Project,
343        };
344        let check = |path: &std::path::Path| DeliveryOperation::Check {
345            path: path.to_string_lossy().into_owned(),
346            blake3: [7; 12],
347            conversation_id: Some("conversation".into()),
348        };
349        assert!(profile.sign_request(check(&path)).is_err());
350        assert!(crate::core::agent_identity::get_stored_signing_key("profile-reader").is_err());
351        assert!(crate::core::agent_identity::get_stored_public_key("profile-reader").is_err());
352        let key = crate::core::agent_identity::get_or_create_keypair("profile-reader")
353            .expect("explicit provisioning");
354        let signed = profile.sign_request(check(&path)).expect("signed lookup");
355        assert_eq!(signed.authority.grant_ref.grant_id, "read");
356        assert_eq!(
357            signed.authority.request_digest,
358            signed.request.request_digest()
359        );
360        let signature = crate::core::agent_identity::hex_decode(&signed.authority.signature)
361            .expect("signature");
362        assert!(crate::core::agent_identity::verify_signature(
363            key.verifying_key().as_bytes(),
364            &signed.authority.signing_bytes(),
365            &signature,
366        ));
367        let second = profile.sign_request(check(&path)).expect("second lookup");
368        assert_ne!(signed.authority.nonce, second.authority.nonce);
369        let (mut certificate, policy, host_key) =
370            super::super::delivery_delegation::test_support::fixture();
371        certificate.child_agent = profile.agent_id.clone();
372        certificate.project_root = root.path().canonicalize().expect("canonical root");
373        certificate.privacy = profile.privacy;
374        certificate.child_key_id = profile.key_id.clone();
375        certificate.child_public_key =
376            crate::core::agent_identity::hex_encode(key.verifying_key().as_bytes());
377        certificate.expires_at = Utc::now() + chrono::Duration::seconds(30);
378        certificate.sign(&host_key);
379        let child_policy = certificate
380            .verify_for_execution(
381                &policy,
382                &TaskAuthorityExpectationV1 {
383                    sender: "host",
384                    recipient: "daemon",
385                    tenant_id: "account",
386                    project_id: "project",
387                    now: Utc::now(),
388                },
389                &certificate.execution,
390                &profile.agent_id,
391            )
392            .expect("host delegates to provisioned child");
393        let mut delegated = profile.clone();
394        delegated.delegation = Some(Box::new(certificate.clone()));
395        let signed = delegated
396            .sign_request(check(&path))
397            .expect("delegated lookup");
398        assert_eq!(signed.authority.expires_at, certificate.expires_at);
399        assert_eq!(
400            signed.authority.request_digest,
401            signed.bound_request_digest()
402        );
403        assert_ne!(
404            signed.authority.request_digest,
405            signed.request.request_digest()
406        );
407        signed
408            .authority
409            .verify_authority(
410                &child_policy,
411                &TaskAuthorityExpectationV1 {
412                    sender: &profile.agent_id,
413                    recipient: "daemon",
414                    tenant_id: "account",
415                    project_id: "project",
416                    now: Utc::now(),
417                },
418                DELIVERY_CHECK,
419                &signed.bound_request_digest(),
420            )
421            .expect("certificate-bound child signature");
422        let record = || DeliveryOperation::Record {
423            entry: crate::core::ocla::types::DeliveryEntry {
424                path: path.to_string_lossy().into_owned(),
425                agent_id: profile.agent_id.clone(),
426                access: Some(lean_ctx_ocla::delivery_scope::DeliveryAccessV1 {
427                    scope: lean_ctx_ocla::delivery_scope::DeliveryScopeV1::new(
428                        profile.tenant_id.clone(),
429                        profile.project_id.clone(),
430                    )
431                    .expect("scope"),
432                    privacy: profile.privacy,
433                }),
434                blake3: [7; 12],
435                line_count: 1,
436                token_count: 1,
437                conversation_id: "conversation".into(),
438                mtime: 0,
439                relay_content: None,
440                relay_mode: None,
441            },
442        };
443        assert!(delegated.sign_request(record()).is_err());
444        let mut writable = certificate.clone();
445        writable.write_grant_id = Some(profile.write_grant_id.clone());
446        writable.sign(&host_key);
447        delegated.delegation = Some(Box::new(writable));
448        let written = delegated
449            .sign_request(record())
450            .expect("explicit write grant");
451        assert_eq!(written.authority.grant_ref.grant_id, profile.write_grant_id);
452        delegated.write_grant_id = "other-write".into();
453        assert!(delegated.sign_request(record()).is_err());
454        delegated.write_grant_id = profile.write_grant_id.clone();
455        for field in [
456            "child_agent",
457            "child_key_id",
458            "recipient",
459            "tenant_id",
460            "project_id",
461            "read_grant_id",
462            "child_public_key",
463        ] {
464            let mut value = serde_json::to_value(&certificate).expect("certificate JSON");
465            value[field] = serde_json::json!("mismatch");
466            delegated.delegation = Some(Box::new(
467                serde_json::from_value(value).expect("modified certificate"),
468            ));
469            assert!(delegated.sign_request(check(&path)).is_err(), "{field}");
470        }
471        for (field, replacement) in [
472            ("schema_version", serde_json::json!(2)),
473            ("privacy", serde_json::json!("private")),
474            (
475                "project_root",
476                serde_json::json!(outside.path().parent().unwrap()),
477            ),
478            (
479                "issued_at",
480                serde_json::json!(Utc::now() + chrono::Duration::hours(1)),
481            ),
482        ] {
483            let mut value = serde_json::to_value(&certificate).expect("certificate");
484            value[field] = replacement;
485            delegated.delegation = Some(Box::new(serde_json::from_value(value).expect("modified")));
486            assert!(delegated.sign_request(check(&path)).is_err(), "{field}");
487        }
488        certificate.expires_at = Utc::now() - chrono::Duration::seconds(1);
489        delegated.delegation = Some(Box::new(certificate));
490        assert!(delegated.sign_request(check(&path)).is_err());
491        assert!(profile.sign_request(check(outside.path())).is_err());
492        #[cfg(unix)]
493        {
494            let link = root.path().join("escape.rs");
495            std::os::unix::fs::symlink(outside.path(), &link).expect("symlink");
496            assert!(profile.sign_request(check(&link)).is_err());
497        }
498        let mut invalid = profile.clone();
499        invalid.schema_version = 2;
500        assert!(invalid.sign_request(check(&path)).is_err());
501        invalid = profile;
502        invalid.project_root = std::path::PathBuf::from(".");
503        assert!(invalid.sign_request(check(&path)).is_err());
504    }
505
506    #[test]
507    fn delivery_requires_exact_signed_request_and_live_scoped_grant() {
508        let now = Utc::now();
509        let key = SigningKey::from_bytes(&[42; 32]);
510        let mut descriptor = DeliveryAuthorityV1 {
511            schema_version: 1,
512            sender: "reader".into(),
513            recipient: "daemon".into(),
514            tenant_id: "account".into(),
515            project_id: "project".into(),
516            action: DELIVERY_CHECK.into(),
517            request_digest: format!("sha256:{}", "a".repeat(64)),
518            issued_at: now,
519            expires_at: now + chrono::Duration::minutes(1),
520            nonce: "request-1".into(),
521            grant_ref: CapabilityGrantRefV1 {
522                schema_version: 1,
523                grant_id: "read-grant".into(),
524            },
525            key_id: "reader-key".into(),
526            signature: String::new(),
527        };
528        descriptor.sign(&key);
529        let mut policy = TaskAuthorityConfigV1 {
530            schema_version: 1,
531            peers: vec![TaskPeerTrustV1 {
532                schema_version: 1,
533                key_id: "reader-key".into(),
534                agent_id: "reader".into(),
535                public_key: crate::core::agent_identity::hex_encode(key.verifying_key().as_bytes()),
536                allowed_actions: vec![DELIVERY_CHECK.into()],
537                allowed_scopes: vec![TaskScopeV1 {
538                    schema_version: 1,
539                    tenant_id: "account".into(),
540                    project_id: "project".into(),
541                }],
542                not_before: now - chrono::Duration::minutes(1),
543                expires_at: now + chrono::Duration::hours(1),
544                revoked: false,
545            }],
546            grants: vec![TaskCapabilityGrantV1 {
547                schema_version: 1,
548                grant_id: "read-grant".into(),
549                key_id: "reader-key".into(),
550                action: DELIVERY_CHECK.into(),
551                tenant_id: "account".into(),
552                project_id: "project".into(),
553                not_before: now - chrono::Duration::minutes(1),
554                expires_at: now + chrono::Duration::hours(1),
555                revoked: false,
556            }],
557        };
558        let expected = TaskAuthorityExpectationV1 {
559            sender: "reader",
560            recipient: "daemon",
561            tenant_id: "account",
562            project_id: "project",
563            now,
564        };
565        let verify = |d: &DeliveryAuthorityV1, p: &TaskAuthorityConfigV1| {
566            d.verify_authority(p, &expected, DELIVERY_CHECK, &descriptor.request_digest)
567        };
568        assert!(verify(&descriptor, &policy).is_ok());
569        let mut tampered = descriptor.clone();
570        tampered.nonce = "request-2".into();
571        assert!(verify(&tampered, &policy).is_err());
572        tampered = descriptor.clone();
573        tampered.project_id = "other".into();
574        tampered.sign(&key);
575        assert!(verify(&tampered, &policy).is_err());
576        assert!(
577            descriptor
578                .verify_authority(
579                    &policy,
580                    &expected,
581                    DELIVERY_RECORD,
582                    &descriptor.request_digest,
583                )
584                .is_err()
585        );
586        assert!(
587            descriptor
588                .verify_authority(
589                    &policy,
590                    &expected,
591                    DELIVERY_CHECK,
592                    &format!("sha256:{}", "b".repeat(64)),
593                )
594                .is_err()
595        );
596        policy.grants[0].revoked = true;
597        assert!(verify(&descriptor, &policy).is_err());
598        policy.grants[0].revoked = false;
599        policy.grants[0].action = TASK_ACTION_SEND.into();
600        assert!(verify(&descriptor, &policy).is_err());
601    }
602}