1use 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#[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 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 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#[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 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}