Skip to main content

cortex_core/
authority.rs

1//! Temporal authority revalidation.
2//!
3//! Authority is time-relative: a signature can be valid for the event time
4//! and still be invalid for current reasoning after revocation, principal
5//! removal, trust downgrade, or trust-review expiry.
6
7use chrono::{DateTime, Utc};
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10
11use crate::{
12    compose_policy_outcomes, CoreError, CoreResult, PolicyContribution, PolicyDecision,
13    PolicyOutcome,
14};
15use crate::{FailingEdge, ProofEdgeFailure, ProofEdgeKind};
16
17/// Key lifecycle state from ADR 0023.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
19#[serde(rename_all = "snake_case")]
20pub enum KeyLifecycleState {
21    /// Key may sign new material from this effective time.
22    Active,
23    /// Key may not sign new material after this effective time.
24    Retired,
25    /// Key may not sign new material after this effective time.
26    Revoked,
27}
28
29/// Principal trust tier from ADR 0019.
30#[derive(
31    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
32)]
33#[serde(rename_all = "snake_case")]
34pub enum TrustTier {
35    /// No high-impact authority.
36    Untrusted,
37    /// Audit-visible only.
38    Observed,
39    /// Operator-approved for bounded automation.
40    Verified,
41    /// Human root of trust for this deployment scope.
42    Operator,
43}
44
45/// Reason a temporal authority check was annotated, downgraded, or failed.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
47#[serde(rename_all = "snake_case")]
48pub enum TemporalAuthorityReason {
49    /// Key had not been activated at event time.
50    SignedBeforeActivation,
51    /// Event was signed at or after the key revocation effective time.
52    SignedAfterRevocation,
53    /// Event was signed at or after the key retirement effective time.
54    SignedAfterRetirement,
55    /// Key was revoked after the event, so historical evidence exists but
56    /// current reasoning is downgraded.
57    RevokedAfterSigning,
58    /// Key was retired after the event; historical verification remains valid.
59    HistoricalRetiredKey,
60    /// Principal trust tier fell below the required gate after signing.
61    TrustTierDowngraded,
62    /// Principal did not meet the required trust tier at event time.
63    InsufficientTrustAtSigning,
64    /// Principal was removed after signing.
65    PrincipalRemoved,
66    /// Principal trust review has expired.
67    TrustReviewExpired,
68    /// Key timeline was missing.
69    KeyUnknown,
70    /// Principal timeline was missing.
71    PrincipalUnknown,
72}
73
74impl TemporalAuthorityReason {
75    /// Stable wire string for diagnostics and proof-edge reasons.
76    #[must_use]
77    pub const fn wire_str(self) -> &'static str {
78        match self {
79            Self::SignedBeforeActivation => "signed_before_activation",
80            Self::SignedAfterRevocation => "signed_after_revocation",
81            Self::SignedAfterRetirement => "signed_after_retirement",
82            Self::RevokedAfterSigning => "revoked_after_signing",
83            Self::HistoricalRetiredKey => "historical_retired_key",
84            Self::TrustTierDowngraded => "trust_tier_downgraded",
85            Self::InsufficientTrustAtSigning => "insufficient_trust_at_signing",
86            Self::PrincipalRemoved => "principal_removed",
87            Self::TrustReviewExpired => "trust_review_expired",
88            Self::KeyUnknown => "key_unknown",
89            Self::PrincipalUnknown => "principal_unknown",
90        }
91    }
92}
93
94/// Evidence used for temporal authority revalidation.
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
96pub struct TemporalAuthorityEvidence {
97    /// Key identifier carried by the attestation.
98    pub key_id: String,
99    /// Principal bound to the key, when known.
100    pub principal_id: Option<String>,
101    /// Signed event/audit time.
102    pub event_time: DateTime<Utc>,
103    /// Verification time.
104    pub now: DateTime<Utc>,
105    /// Key activation effective time.
106    pub key_activated_at: Option<DateTime<Utc>>,
107    /// Key retirement effective time.
108    pub key_retired_at: Option<DateTime<Utc>>,
109    /// Key revocation effective time.
110    pub key_revoked_at: Option<DateTime<Utc>>,
111    /// Trust tier at event time.
112    pub trust_tier_at_event_time: Option<TrustTier>,
113    /// Current trust tier.
114    pub current_trust_tier: Option<TrustTier>,
115    /// Effective time for the current trust tier.
116    pub current_trust_tier_effective_at: Option<DateTime<Utc>>,
117    /// Minimum trust tier required for current use.
118    pub minimum_trust_tier: TrustTier,
119    /// Principal removal time, when removed.
120    pub principal_removed_at: Option<DateTime<Utc>>,
121    /// Principal trust review deadline.
122    pub trust_review_due_at: Option<DateTime<Utc>>,
123}
124
125/// Temporal authority check result.
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
127pub struct TemporalAuthorityReport {
128    /// Key identifier carried by the attestation.
129    pub key_id: String,
130    /// Principal bound to the key, when known.
131    pub principal_id: Option<String>,
132    /// Signed event/audit time.
133    pub event_time: DateTime<Utc>,
134    /// Verification time.
135    pub now: DateTime<Utc>,
136    /// Whether the key/principal was valid for the signed event time.
137    pub valid_at_event_time: bool,
138    /// Whether this authority may condition current reasoning.
139    pub valid_now: bool,
140    /// First time after signing that invalidated current use.
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub invalidated_after: Option<DateTime<Utc>>,
143    /// Stable authority annotations and failure reasons.
144    pub reasons: Vec<TemporalAuthorityReason>,
145    /// Trust tier at event time.
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub trust_tier_at_event_time: Option<TrustTier>,
148    /// Current trust tier.
149    #[serde(skip_serializing_if = "Option::is_none")]
150    pub current_trust_tier: Option<TrustTier>,
151}
152
153impl TemporalAuthorityReport {
154    /// Convert a failed current-use result into a proof failing edge.
155    #[must_use]
156    pub fn current_use_failing_edge(&self, target_ref: impl Into<String>) -> Option<FailingEdge> {
157        if self.valid_now {
158            return None;
159        }
160
161        let reason = self
162            .reasons
163            .iter()
164            .map(|reason| reason.wire_str())
165            .collect::<Vec<_>>()
166            .join(",");
167        Some(FailingEdge::broken(
168            ProofEdgeKind::AuthorityFold,
169            target_ref,
170            self.key_id.clone(),
171            ProofEdgeFailure::AuthorityMismatch,
172            reason,
173        ))
174    }
175
176    /// Whether this report contains a specific reason.
177    #[must_use]
178    pub fn has_reason(&self, reason: TemporalAuthorityReason) -> bool {
179        self.reasons.contains(&reason)
180    }
181
182    /// Derive the ADR 0026 policy decision for current use of this temporal
183    /// authority.
184    #[must_use]
185    pub fn policy_decision(&self) -> PolicyDecision {
186        let outcome = if self.valid_now {
187            PolicyOutcome::Allow
188        } else if self.valid_at_event_time {
189            PolicyOutcome::Quarantine
190        } else {
191            PolicyOutcome::Reject
192        };
193        let reason = if self.valid_now {
194            "temporal authority is valid for current use"
195        } else if self.valid_at_event_time {
196            "temporal authority remains historical evidence but is invalid for current use"
197        } else {
198            "temporal authority was invalid at event time"
199        };
200        compose_policy_outcomes(
201            vec![
202                PolicyContribution::new("authority.temporal.current_use", outcome, reason)
203                    .expect("static policy contribution is valid"),
204            ],
205            None,
206        )
207    }
208
209    /// Fail closed before this temporal authority report is consumed for
210    /// current reasoning authority.
211    pub fn require_current_use_allowed(&self) -> CoreResult<()> {
212        let policy = self.policy_decision();
213        match policy.final_outcome {
214            PolicyOutcome::Reject | PolicyOutcome::Quarantine => {
215                Err(CoreError::Validation(format!(
216                    "temporal authority current use blocked by policy outcome {:?}",
217                    policy.final_outcome
218                )))
219            }
220            PolicyOutcome::Allow | PolicyOutcome::Warn | PolicyOutcome::BreakGlass => Ok(()),
221        }
222    }
223}
224
225/// Revalidate temporal authority using ADR 0023 and ADR 0019 semantics.
226#[must_use]
227pub fn revalidate_temporal_authority(
228    evidence: TemporalAuthorityEvidence,
229) -> TemporalAuthorityReport {
230    let mut valid_at_event_time = true;
231    let mut valid_now = true;
232    let mut invalidated_after = None;
233    let mut reasons = Vec::new();
234
235    match evidence.key_activated_at {
236        Some(activated_at) if evidence.event_time < activated_at => {
237            valid_at_event_time = false;
238            valid_now = false;
239            reasons.push(TemporalAuthorityReason::SignedBeforeActivation);
240        }
241        Some(_) => {}
242        None => {
243            valid_at_event_time = false;
244            valid_now = false;
245            reasons.push(TemporalAuthorityReason::KeyUnknown);
246        }
247    }
248
249    if let Some(revoked_at) = evidence.key_revoked_at {
250        if evidence.event_time >= revoked_at {
251            valid_at_event_time = false;
252            valid_now = false;
253            reasons.push(TemporalAuthorityReason::SignedAfterRevocation);
254        } else if evidence.now >= revoked_at {
255            valid_now = false;
256            invalidated_after = min_time(invalidated_after, revoked_at);
257            reasons.push(TemporalAuthorityReason::RevokedAfterSigning);
258        }
259    }
260
261    if let Some(retired_at) = evidence.key_retired_at {
262        if evidence.event_time >= retired_at {
263            valid_at_event_time = false;
264            valid_now = false;
265            reasons.push(TemporalAuthorityReason::SignedAfterRetirement);
266        } else if evidence.now >= retired_at {
267            reasons.push(TemporalAuthorityReason::HistoricalRetiredKey);
268        }
269    }
270
271    match evidence.trust_tier_at_event_time {
272        Some(tier) if tier < evidence.minimum_trust_tier => {
273            valid_at_event_time = false;
274            valid_now = false;
275            reasons.push(TemporalAuthorityReason::InsufficientTrustAtSigning);
276        }
277        Some(_) => {}
278        None => {
279            valid_at_event_time = false;
280            valid_now = false;
281            reasons.push(TemporalAuthorityReason::PrincipalUnknown);
282        }
283    }
284
285    match evidence.current_trust_tier {
286        Some(current) if current < evidence.minimum_trust_tier => {
287            valid_now = false;
288            if let Some(changed_at) = evidence.current_trust_tier_effective_at {
289                invalidated_after = min_time(invalidated_after, changed_at);
290            }
291            reasons.push(TemporalAuthorityReason::TrustTierDowngraded);
292        }
293        Some(_) => {}
294        None => {
295            valid_now = false;
296            reasons.push(TemporalAuthorityReason::PrincipalUnknown);
297        }
298    }
299
300    if let Some(removed_at) = evidence.principal_removed_at {
301        if evidence.now >= removed_at {
302            valid_now = false;
303            invalidated_after = min_time(invalidated_after, removed_at);
304            reasons.push(TemporalAuthorityReason::PrincipalRemoved);
305        }
306    }
307
308    if let Some(review_due_at) = evidence.trust_review_due_at {
309        if evidence.now > review_due_at {
310            valid_now = false;
311            invalidated_after = min_time(invalidated_after, review_due_at);
312            reasons.push(TemporalAuthorityReason::TrustReviewExpired);
313        }
314    }
315
316    TemporalAuthorityReport {
317        key_id: evidence.key_id,
318        principal_id: evidence.principal_id,
319        event_time: evidence.event_time,
320        now: evidence.now,
321        valid_at_event_time,
322        valid_now: valid_at_event_time && valid_now,
323        invalidated_after,
324        reasons,
325        trust_tier_at_event_time: evidence.trust_tier_at_event_time,
326        current_trust_tier: evidence.current_trust_tier,
327    }
328}
329
330fn min_time(current: Option<DateTime<Utc>>, candidate: DateTime<Utc>) -> Option<DateTime<Utc>> {
331    Some(current.map_or(candidate, |current| current.min(candidate)))
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337    use chrono::TimeZone;
338
339    fn at(day: u32) -> DateTime<Utc> {
340        Utc.with_ymd_and_hms(2026, 1, day, 12, 0, 0).unwrap()
341    }
342
343    fn evidence() -> TemporalAuthorityEvidence {
344        TemporalAuthorityEvidence {
345            key_id: "key_1".into(),
346            principal_id: Some("principal_1".into()),
347            event_time: at(2),
348            now: at(4),
349            key_activated_at: Some(at(1)),
350            key_retired_at: None,
351            key_revoked_at: None,
352            trust_tier_at_event_time: Some(TrustTier::Operator),
353            current_trust_tier: Some(TrustTier::Operator),
354            current_trust_tier_effective_at: Some(at(1)),
355            minimum_trust_tier: TrustTier::Verified,
356            principal_removed_at: None,
357            trust_review_due_at: None,
358        }
359    }
360
361    #[test]
362    fn revoked_after_signing_is_historical_but_not_valid_now() {
363        let mut evidence = evidence();
364        evidence.key_revoked_at = Some(at(3));
365
366        let report = revalidate_temporal_authority(evidence);
367
368        assert!(report.valid_at_event_time);
369        assert!(!report.valid_now);
370        assert_eq!(report.invalidated_after, Some(at(3)));
371        assert!(report.has_reason(TemporalAuthorityReason::RevokedAfterSigning));
372    }
373
374    #[test]
375    fn signed_after_revocation_is_invalid_at_event_time() {
376        let mut evidence = evidence();
377        evidence.event_time = at(4);
378        evidence.key_revoked_at = Some(at(3));
379
380        let report = revalidate_temporal_authority(evidence);
381
382        assert!(!report.valid_at_event_time);
383        assert!(!report.valid_now);
384        assert!(report.has_reason(TemporalAuthorityReason::SignedAfterRevocation));
385    }
386
387    #[test]
388    fn trust_tier_downgrade_invalidates_current_use() {
389        let mut evidence = evidence();
390        evidence.current_trust_tier = Some(TrustTier::Observed);
391
392        let report = revalidate_temporal_authority(evidence);
393
394        assert!(report.valid_at_event_time);
395        assert!(!report.valid_now);
396        assert!(report.has_reason(TemporalAuthorityReason::TrustTierDowngraded));
397        assert!(report.current_use_failing_edge("principle:1").is_some());
398        assert_eq!(
399            report.policy_decision().final_outcome,
400            PolicyOutcome::Quarantine
401        );
402        assert!(report.require_current_use_allowed().is_err());
403    }
404
405    #[test]
406    fn invalid_at_event_time_maps_to_policy_reject() {
407        let mut evidence = evidence();
408        evidence.event_time = at(4);
409        evidence.key_revoked_at = Some(at(3));
410
411        let report = revalidate_temporal_authority(evidence);
412
413        assert_eq!(
414            report.policy_decision().final_outcome,
415            PolicyOutcome::Reject
416        );
417        assert!(report.require_current_use_allowed().is_err());
418    }
419
420    #[test]
421    fn currently_valid_authority_maps_to_policy_allow() {
422        let report = revalidate_temporal_authority(evidence());
423
424        assert_eq!(report.policy_decision().final_outcome, PolicyOutcome::Allow);
425        report
426            .require_current_use_allowed()
427            .expect("currently valid authority supports current use");
428    }
429}