1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
19#[serde(rename_all = "snake_case")]
20pub enum KeyLifecycleState {
21 Active,
23 Retired,
25 Revoked,
27}
28
29#[derive(
31 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
32)]
33#[serde(rename_all = "snake_case")]
34pub enum TrustTier {
35 Untrusted,
37 Observed,
39 Verified,
41 Operator,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
47#[serde(rename_all = "snake_case")]
48pub enum TemporalAuthorityReason {
49 SignedBeforeActivation,
51 SignedAfterRevocation,
53 SignedAfterRetirement,
55 RevokedAfterSigning,
58 HistoricalRetiredKey,
60 TrustTierDowngraded,
62 InsufficientTrustAtSigning,
64 PrincipalRemoved,
66 TrustReviewExpired,
68 KeyUnknown,
70 PrincipalUnknown,
72}
73
74impl TemporalAuthorityReason {
75 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
96pub struct TemporalAuthorityEvidence {
97 pub key_id: String,
99 pub principal_id: Option<String>,
101 pub event_time: DateTime<Utc>,
103 pub now: DateTime<Utc>,
105 pub key_activated_at: Option<DateTime<Utc>>,
107 pub key_retired_at: Option<DateTime<Utc>>,
109 pub key_revoked_at: Option<DateTime<Utc>>,
111 pub trust_tier_at_event_time: Option<TrustTier>,
113 pub current_trust_tier: Option<TrustTier>,
115 pub current_trust_tier_effective_at: Option<DateTime<Utc>>,
117 pub minimum_trust_tier: TrustTier,
119 pub principal_removed_at: Option<DateTime<Utc>>,
121 pub trust_review_due_at: Option<DateTime<Utc>>,
123}
124
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
127pub struct TemporalAuthorityReport {
128 pub key_id: String,
130 pub principal_id: Option<String>,
132 pub event_time: DateTime<Utc>,
134 pub now: DateTime<Utc>,
136 pub valid_at_event_time: bool,
138 pub valid_now: bool,
140 #[serde(skip_serializing_if = "Option::is_none")]
142 pub invalidated_after: Option<DateTime<Utc>>,
143 pub reasons: Vec<TemporalAuthorityReason>,
145 #[serde(skip_serializing_if = "Option::is_none")]
147 pub trust_tier_at_event_time: Option<TrustTier>,
148 #[serde(skip_serializing_if = "Option::is_none")]
150 pub current_trust_tier: Option<TrustTier>,
151}
152
153impl TemporalAuthorityReport {
154 #[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 #[must_use]
178 pub fn has_reason(&self, reason: TemporalAuthorityReason) -> bool {
179 self.reasons.contains(&reason)
180 }
181
182 #[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 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#[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}