Skip to main content

chio_kernel/
authority.rs

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
13/// Validate that the local authority can issue the requested scope semantics.
14pub fn ensure_capability_issuance_supported(_scope: &ChioScope) -> Result<(), KernelError> {
15    Ok(())
16}
17
18/// Validate that an authority response is exactly the direct capability requested.
19pub 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
41/// Deterministically validate an issuance response against the request and current authority.
42pub 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                &current_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}