1use chio_core::capability::{
2 runtime_attestation::RuntimeAttestationEvidence,
3 scope::ChioScope,
4 token::{CapabilityToken, CapabilityTokenBody},
5};
6use chio_core::crypto::{Keypair, PublicKey};
7use uuid::Uuid;
8
9use crate::KernelError;
10
11const DEFAULT_CAPABILITY_ISSUANCE_CLOCK_SKEW_SECONDS: u64 = 30;
12
13pub fn ensure_capability_issuance_supported(_scope: &ChioScope) -> Result<(), KernelError> {
15 Ok(())
16}
17
18pub fn validate_issued_capability_response(
20 capability: &CapabilityToken,
21 requested_subject: &PublicKey,
22 requested_scope: &ChioScope,
23 requested_ttl_seconds: u64,
24 current_issuer: &PublicKey,
25) -> Result<(), KernelError> {
26 let now = std::time::SystemTime::now()
27 .duration_since(std::time::UNIX_EPOCH)
28 .map_err(|error| KernelError::CapabilityIssuanceFailed(error.to_string()))?
29 .as_secs();
30 validate_issued_capability_response_at(
31 capability,
32 requested_subject,
33 requested_scope,
34 requested_ttl_seconds,
35 current_issuer,
36 now,
37 DEFAULT_CAPABILITY_ISSUANCE_CLOCK_SKEW_SECONDS,
38 )
39}
40
41pub fn validate_issued_capability_response_at(
43 capability: &CapabilityToken,
44 requested_subject: &PublicKey,
45 requested_scope: &ChioScope,
46 requested_ttl_seconds: u64,
47 current_issuer: &PublicKey,
48 now: u64,
49 allowed_clock_skew_seconds: u64,
50) -> Result<(), KernelError> {
51 if &capability.issuer != current_issuer {
52 return Err(KernelError::UntrustedIssuer);
53 }
54 if !matches!(capability.verify_signature(), Ok(true)) {
55 return Err(KernelError::InvalidSignature);
56 }
57 if capability.aggregate_invocation_budget.is_some() {
58 return Err(KernelError::CapabilityIssuanceDenied(
59 "aggregate invocation capability issuance requires atomic composite admission enforcement"
60 .to_string(),
61 ));
62 }
63 ensure_capability_issuance_supported(&capability.scope)?;
64 if &capability.subject != requested_subject {
65 return Err(KernelError::CapabilityIssuanceFailed(
66 "issued capability subject does not match the requested subject".to_string(),
67 ));
68 }
69 if capability.scope.has_cumulative_approval() {
70 chio_core::capability::cumulative_approval::verify_cumulative_approval_constraints(
71 capability,
72 std::slice::from_ref(current_issuer),
73 None,
74 )
75 .map_err(|error| KernelError::CapabilityIssuanceFailed(error.to_string()))?;
76 }
77 let mut issued_scope = capability.scope.clone();
78 for grant in &mut issued_scope.grants {
79 for constraint in &mut grant.constraints {
80 if let chio_core::capability::scope::Constraint::RequireCumulativeApprovalAbove {
81 cumulative_approval_root_binding,
82 ..
83 } = constraint
84 {
85 *cumulative_approval_root_binding = None;
86 }
87 }
88 }
89 let issued_scope = chio_core::canonical_json_bytes(&issued_scope)
90 .map_err(|error| KernelError::CapabilityIssuanceFailed(error.to_string()))?;
91 let requested_scope = chio_core::canonical_json_bytes(requested_scope)
92 .map_err(|error| KernelError::CapabilityIssuanceFailed(error.to_string()))?;
93 if issued_scope != requested_scope {
94 return Err(KernelError::CapabilityIssuanceFailed(
95 "issued capability scope does not match the requested scope".to_string(),
96 ));
97 }
98 if !capability.delegation_chain.is_empty() {
99 return Err(KernelError::CapabilityIssuanceFailed(
100 "issued capability must be direct".to_string(),
101 ));
102 }
103 let latest_issued_at = now.saturating_add(allowed_clock_skew_seconds);
104 if capability.issued_at > latest_issued_at {
105 return Err(KernelError::CapabilityIssuanceFailed(format!(
106 "issued capability timestamp {} is too far in the future relative to {now}",
107 capability.issued_at
108 )));
109 }
110 if capability.expires_at <= now {
111 return Err(KernelError::CapabilityIssuanceFailed(format!(
112 "issued capability is already expired at {} relative to {now}",
113 capability.expires_at
114 )));
115 }
116 let latest_expires_at = now
117 .saturating_add(requested_ttl_seconds)
118 .saturating_add(allowed_clock_skew_seconds);
119 if capability.expires_at > latest_expires_at {
120 return Err(KernelError::CapabilityIssuanceFailed(format!(
121 "issued capability wall-clock expiry {} exceeds allowed maximum {latest_expires_at}",
122 capability.expires_at
123 )));
124 }
125 let lifetime = capability
126 .expires_at
127 .checked_sub(capability.issued_at)
128 .ok_or_else(|| {
129 KernelError::CapabilityIssuanceFailed(
130 "issued capability lifetime is reversed".to_string(),
131 )
132 })?;
133 if lifetime > requested_ttl_seconds {
134 return Err(KernelError::CapabilityIssuanceFailed(format!(
135 "issued capability lifetime {lifetime} exceeds requested TTL {requested_ttl_seconds}"
136 )));
137 }
138 Ok(())
139}
140
141pub trait CapabilityAuthority: Send + Sync {
142 fn authority_public_key(&self) -> PublicKey;
143
144 fn trusted_public_keys(&self) -> Vec<PublicKey> {
145 vec![self.authority_public_key()]
146 }
147
148 fn issue_capability(
149 &self,
150 subject: &PublicKey,
151 scope: ChioScope,
152 ttl_seconds: u64,
153 ) -> Result<CapabilityToken, KernelError>;
154
155 fn issue_capability_with_attestation(
156 &self,
157 subject: &PublicKey,
158 scope: ChioScope,
159 ttl_seconds: u64,
160 _runtime_attestation: Option<RuntimeAttestationEvidence>,
161 ) -> Result<CapabilityToken, KernelError> {
162 self.issue_capability(subject, scope, ttl_seconds)
163 }
164}
165
166pub struct LocalCapabilityAuthority {
167 keypair: Keypair,
168}
169
170impl LocalCapabilityAuthority {
171 pub fn new(keypair: Keypair) -> Self {
172 Self { keypair }
173 }
174}
175
176impl CapabilityAuthority for LocalCapabilityAuthority {
177 fn authority_public_key(&self) -> PublicKey {
178 self.keypair.public_key()
179 }
180
181 fn issue_capability(
182 &self,
183 subject: &PublicKey,
184 scope: ChioScope,
185 ttl_seconds: u64,
186 ) -> Result<CapabilityToken, KernelError> {
187 ensure_capability_issuance_supported(&scope)?;
188 let now = std::time::SystemTime::now()
189 .duration_since(std::time::UNIX_EPOCH)
190 .map(|duration| duration.as_secs())
191 .unwrap_or(0);
192 let body = CapabilityTokenBody {
193 id: format!("cap-{}", Uuid::now_v7()),
194 issuer: self.keypair.public_key(),
195 subject: subject.clone(),
196 scope,
197 issued_at: now,
198 expires_at: now.saturating_add(ttl_seconds),
199 delegation_chain: vec![],
200 aggregate_invocation_budget: None,
201 };
202
203 if body.scope.has_cumulative_approval()
204 && body.scope.grants.iter().any(|grant| {
205 grant
206 .operations
207 .contains(&chio_core::capability::scope::Operation::Delegate)
208 })
209 {
210 CapabilityToken::sign_cumulative_approval_family_root(body, &self.keypair)
211 } else {
212 CapabilityToken::sign(body, &self.keypair)
213 }
214 .map_err(|error| KernelError::CapabilityIssuanceFailed(error.to_string()))
215 }
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221 use chio_core::capability::{
222 aggregate_invocation::{AggregateInvocationBudget, AggregateInvocationScope},
223 attenuation::{DelegationLink, DelegationLinkBody},
224 scope::{Constraint, MonetaryAmount, Operation, ToolGrant},
225 };
226
227 fn tool_scope() -> ChioScope {
228 ChioScope {
229 grants: vec![ToolGrant {
230 server_id: "server".to_string(),
231 tool_name: "tool".to_string(),
232 operations: vec![Operation::Invoke],
233 constraints: Vec::new(),
234 max_invocations: None,
235 max_cost_per_invocation: None,
236 max_total_cost: None,
237 dpop_required: None,
238 }],
239 ..ChioScope::default()
240 }
241 }
242
243 fn signed_response(
244 issuer: &Keypair,
245 subject: &PublicKey,
246 scope: ChioScope,
247 issued_at: u64,
248 expires_at: u64,
249 delegation_chain: Vec<DelegationLink>,
250 aggregate_invocation_budget: Option<AggregateInvocationBudget>,
251 ) -> Result<CapabilityToken, chio_core::error::Error> {
252 CapabilityToken::sign(
253 CapabilityTokenBody {
254 id: "cap-issued-response".to_string(),
255 issuer: issuer.public_key(),
256 subject: subject.clone(),
257 scope,
258 issued_at,
259 expires_at,
260 delegation_chain,
261 aggregate_invocation_budget,
262 },
263 issuer,
264 )
265 }
266
267 #[test]
268 fn issuance_response_accepts_exact_trusted_direct_capability(
269 ) -> Result<(), Box<dyn std::error::Error>> {
270 let issuer = Keypair::generate();
271 let subject = Keypair::generate().public_key();
272 let scope = tool_scope();
273 let capability = signed_response(&issuer, &subject, scope.clone(), 100, 160, vec![], None)?;
274
275 validate_issued_capability_response_at(
276 &capability,
277 &subject,
278 &scope,
279 60,
280 &issuer.public_key(),
281 100,
282 30,
283 )?;
284 Ok(())
285 }
286
287 #[test]
288 fn issuance_response_rejects_historical_issuer() -> Result<(), Box<dyn std::error::Error>> {
289 let historical_issuer = Keypair::generate();
290 let current_issuer = Keypair::generate();
291 let subject = Keypair::generate().public_key();
292 let scope = tool_scope();
293 let capability = signed_response(
294 &historical_issuer,
295 &subject,
296 scope.clone(),
297 100,
298 160,
299 vec![],
300 None,
301 )?;
302
303 assert!(matches!(
304 validate_issued_capability_response_at(
305 &capability,
306 &subject,
307 &scope,
308 60,
309 ¤t_issuer.public_key(),
310 100,
311 30,
312 ),
313 Err(KernelError::UntrustedIssuer)
314 ));
315 Ok(())
316 }
317
318 #[test]
319 fn issuance_response_rejects_far_future_issue_time() -> Result<(), Box<dyn std::error::Error>> {
320 let issuer = Keypair::generate();
321 let subject = Keypair::generate().public_key();
322 let scope = tool_scope();
323 let capability = signed_response(&issuer, &subject, scope.clone(), 131, 131, vec![], None)?;
324
325 let error = validate_issued_capability_response_at(
326 &capability,
327 &subject,
328 &scope,
329 60,
330 &issuer.public_key(),
331 100,
332 30,
333 )
334 .expect_err("future issuance must fail closed");
335 assert!(error.to_string().contains("too far in the future"));
336 Ok(())
337 }
338
339 #[test]
340 fn issuance_response_rejects_overlong_wall_clock_expiry(
341 ) -> Result<(), Box<dyn std::error::Error>> {
342 let issuer = Keypair::generate();
343 let subject = Keypair::generate().public_key();
344 let scope = tool_scope();
345 let capability = signed_response(&issuer, &subject, scope.clone(), 130, 191, vec![], None)?;
346
347 let error = validate_issued_capability_response_at(
348 &capability,
349 &subject,
350 &scope,
351 60,
352 &issuer.public_key(),
353 100,
354 30,
355 )
356 .expect_err("wall-clock expiry beyond request allowance must fail closed");
357 assert!(error.to_string().contains("wall-clock expiry"));
358 Ok(())
359 }
360
361 #[test]
362 fn issuance_response_rejects_already_expired_response() -> Result<(), Box<dyn std::error::Error>>
363 {
364 let issuer = Keypair::generate();
365 let subject = Keypair::generate().public_key();
366 let scope = tool_scope();
367 let capability = signed_response(&issuer, &subject, scope.clone(), 40, 100, vec![], None)?;
368
369 let error = match validate_issued_capability_response_at(
370 &capability,
371 &subject,
372 &scope,
373 60,
374 &issuer.public_key(),
375 100,
376 30,
377 ) {
378 Ok(()) => panic!("expired issuance response must fail closed"),
379 Err(error) => error,
380 };
381 assert!(error.to_string().contains("already expired"));
382 Ok(())
383 }
384
385 #[test]
386 fn issuance_response_rejects_trusted_signed_subject_and_scope_substitutions(
387 ) -> Result<(), Box<dyn std::error::Error>> {
388 let issuer = Keypair::generate();
389 let requested_subject = Keypair::generate().public_key();
390 let substituted_subject = Keypair::generate().public_key();
391 let requested_scope = ChioScope::default();
392 let subject_substitution = signed_response(
393 &issuer,
394 &substituted_subject,
395 requested_scope.clone(),
396 100,
397 160,
398 vec![],
399 None,
400 )?;
401 let scope_substitution = signed_response(
402 &issuer,
403 &requested_subject,
404 tool_scope(),
405 100,
406 160,
407 vec![],
408 None,
409 )?;
410
411 for capability in [&subject_substitution, &scope_substitution] {
412 assert!(matches!(
413 validate_issued_capability_response(
414 capability,
415 &requested_subject,
416 &requested_scope,
417 60,
418 &issuer.public_key(),
419 ),
420 Err(KernelError::CapabilityIssuanceFailed(_))
421 ));
422 }
423 Ok(())
424 }
425
426 #[test]
427 fn issuance_response_rejects_delegation_and_invalid_lifetimes(
428 ) -> Result<(), Box<dyn std::error::Error>> {
429 let issuer = Keypair::generate();
430 let subject = Keypair::generate().public_key();
431 let scope = tool_scope();
432 let link = DelegationLink::sign(
433 DelegationLinkBody {
434 capability_id: "cap-parent".to_string(),
435 delegator: issuer.public_key(),
436 delegatee: subject.clone(),
437 attenuations: Vec::new(),
438 timestamp: 100,
439 scope_hash: None,
440 aggregate_budget: None,
441 cumulative_approval: None,
442 },
443 &issuer,
444 )?;
445 let delegated =
446 signed_response(&issuer, &subject, scope.clone(), 100, 160, vec![link], None)?;
447 let widened = signed_response(&issuer, &subject, scope.clone(), 100, 161, vec![], None)?;
448 let reversed = signed_response(&issuer, &subject, scope.clone(), 101, 100, vec![], None)?;
449
450 for capability in [&delegated, &widened, &reversed] {
451 assert!(matches!(
452 validate_issued_capability_response(
453 capability,
454 &subject,
455 &scope,
456 60,
457 &issuer.public_key(),
458 ),
459 Err(KernelError::CapabilityIssuanceFailed(_))
460 ));
461 }
462 Ok(())
463 }
464
465 #[test]
466 fn issuance_response_rejects_aggregate_and_accepts_cumulative_semantics(
467 ) -> Result<(), Box<dyn std::error::Error>> {
468 let issuer = Keypair::generate();
469 let subject = Keypair::generate().public_key();
470 let aggregate_scope = tool_scope();
471 let aggregate = signed_response(
472 &issuer,
473 &subject,
474 aggregate_scope.clone(),
475 100,
476 160,
477 vec![],
478 Some(AggregateInvocationBudget {
479 scope: AggregateInvocationScope::Capability,
480 max_invocations: 2,
481 root_binding: None,
482 }),
483 )?;
484 assert!(matches!(
485 validate_issued_capability_response(
486 &aggregate,
487 &subject,
488 &aggregate_scope,
489 60,
490 &issuer.public_key(),
491 ),
492 Err(KernelError::CapabilityIssuanceDenied(_))
493 ));
494
495 let cumulative_scope = ChioScope {
496 grants: vec![ToolGrant {
497 server_id: "server".to_string(),
498 tool_name: "tool".to_string(),
499 operations: vec![Operation::Invoke, Operation::Delegate],
500 constraints: vec![Constraint::RequireCumulativeApprovalAbove {
501 threshold: MonetaryAmount {
502 units: 100,
503 currency: "USD".to_string(),
504 },
505 approval_budget_id: "budget-1".to_string(),
506 approval_budget_epoch: 1,
507 cumulative_approval_root_binding: None,
508 }],
509 max_invocations: None,
510 max_cost_per_invocation: None,
511 max_total_cost: None,
512 dpop_required: None,
513 }],
514 ..ChioScope::default()
515 };
516 let requested_cumulative_scope = cumulative_scope.clone();
517 let cumulative = CapabilityToken::sign_cumulative_approval_family_root(
518 CapabilityTokenBody {
519 id: "cap-cumulative-response".to_string(),
520 issuer: issuer.public_key(),
521 subject: subject.clone(),
522 scope: cumulative_scope,
523 issued_at: 100,
524 expires_at: 160,
525 delegation_chain: vec![],
526 aggregate_invocation_budget: None,
527 },
528 &issuer,
529 )?;
530 validate_issued_capability_response_at(
531 &cumulative,
532 &subject,
533 &requested_cumulative_scope,
534 60,
535 &issuer.public_key(),
536 100,
537 0,
538 )?;
539 Ok(())
540 }
541
542 #[test]
543 fn local_authority_issues_cumulative_approval_capabilities() {
544 let authority = LocalCapabilityAuthority::new(Keypair::generate());
545 let scope = ChioScope {
546 grants: vec![ToolGrant {
547 server_id: "server".to_string(),
548 tool_name: "tool".to_string(),
549 operations: vec![Operation::Invoke],
550 constraints: vec![Constraint::RequireCumulativeApprovalAbove {
551 threshold: MonetaryAmount {
552 units: 100,
553 currency: "USD".to_string(),
554 },
555 approval_budget_id: "budget-1".to_string(),
556 approval_budget_epoch: 1,
557 cumulative_approval_root_binding: None,
558 }],
559 max_invocations: None,
560 max_cost_per_invocation: None,
561 max_total_cost: None,
562 dpop_required: None,
563 }],
564 ..ChioScope::default()
565 };
566
567 let capability = authority
568 .issue_capability(&Keypair::generate().public_key(), scope, 300)
569 .expect("cumulative approval capability");
570 assert!(capability.scope.has_cumulative_approval());
571 assert!(capability.scope.grants[0]
572 .constraints
573 .iter()
574 .any(|constraint| {
575 matches!(
576 constraint,
577 Constraint::RequireCumulativeApprovalAbove {
578 cumulative_approval_root_binding: None,
579 ..
580 }
581 )
582 }));
583 }
584}
585
586#[derive(Debug, Clone, PartialEq, Eq)]
587pub struct AuthorityStatus {
588 pub public_key: PublicKey,
589 pub generation: u64,
590 pub rotated_at: u64,
591 pub trusted_public_keys: Vec<PublicKey>,
592}
593
594#[derive(Debug, Clone, PartialEq, Eq)]
595pub struct AuthorityTrustedKeySnapshot {
596 pub public_key_hex: String,
597 pub generation: u64,
598 pub activated_at: u64,
599}
600
601#[derive(Debug, Clone, PartialEq, Eq)]
602pub struct AuthoritySnapshot {
603 pub public_key_hex: String,
604 pub generation: u64,
605 pub rotated_at: u64,
606 pub trusted_keys: Vec<AuthorityTrustedKeySnapshot>,
607}
608
609#[derive(Debug, thiserror::Error)]
610pub enum AuthorityStoreError {
611 #[error("sqlite error: {0}")]
612 Sqlite(#[from] rusqlite::Error),
613
614 #[error("failed to prepare authority store directory: {0}")]
615 Io(#[from] std::io::Error),
616
617 #[error("invalid authority seed: {0}")]
618 Core(#[from] chio_core::error::Error),
619
620 #[error("authority fence rejected mutation: {0}")]
621 Fence(String),
622
623 #[error("{0}")]
624 Schema(String),
625}