Skip to main content

rocketmq_auth/
credential_rotation.rs

1// Copyright 2023 The RocketMQ Rust Authors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Audit-first credential rotation with immutable request-path snapshots.
16
17mod model;
18
19pub use model::BreakGlassStatus;
20pub use model::CredentialRotationSnapshot;
21pub use model::CredentialVerification;
22pub use model::CredentialVerificationSource;
23pub use model::RetiringCredentialSnapshot;
24
25use std::collections::BTreeSet;
26use std::fmt;
27use std::sync::Arc;
28use std::sync::Mutex;
29use std::time::Duration;
30use std::time::SystemTime;
31use std::time::UNIX_EPOCH;
32
33use arc_swap::ArcSwap;
34use cheetah_string::CheetahString;
35use rocketmq_security_api::SecretMaterial;
36use rocketmq_security_api::SecretName;
37use rocketmq_security_api::SecretProvider;
38use rocketmq_security_api::SecretVersion;
39use sha2::Digest;
40use sha2::Sha256;
41use subtle::ConstantTimeEq;
42
43use crate::AuthFailureKind;
44use crate::AuthOperation;
45use crate::AuthServiceError;
46use crate::AuthServiceResult;
47
48const MINIMUM_CREDENTIAL_PROOF_BYTES: usize = 32;
49
50/// Stable, non-secret key identifier used for routing verification requests.
51#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
52pub struct CredentialId(CheetahString);
53
54impl CredentialId {
55    /// Creates an identifier containing only ASCII letters, digits, `-`, `_`, or `.`.
56    ///
57    /// # Errors
58    ///
59    /// Returns a redacted authentication-service error for an empty, hidden, oversized, or
60    /// unsupported identifier.
61    pub fn new(value: impl Into<CheetahString>) -> AuthServiceResult<Self> {
62        let value = value.into();
63        if value.is_empty()
64            || value.len() > 128
65            || value.starts_with('.')
66            || !value
67                .bytes()
68                .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
69        {
70            return Err(rotation_error(AuthFailureKind::InvalidInput));
71        }
72        Ok(Self(value))
73    }
74
75    pub fn as_str(&self) -> &str {
76        &self.0
77    }
78}
79
80impl fmt::Debug for CredentialId {
81    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82        f.debug_tuple("CredentialId").field(&self.0).finish()
83    }
84}
85
86/// Public metadata for a fully validated credential. It never contains proof or key material.
87#[derive(Clone, PartialEq, Eq)]
88pub struct CredentialDescriptor {
89    id: CredentialId,
90    certificate_sha256: [u8; 32],
91    valid_from_unix_seconds: u64,
92    expires_at_unix_seconds: u64,
93    provider_version: Option<SecretVersion>,
94}
95
96impl fmt::Debug for CredentialDescriptor {
97    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98        f.debug_struct("CredentialDescriptor")
99            .field("id", &self.id)
100            .field("certificate_sha256", &"[REDACTED]")
101            .field("valid_from_unix_seconds", &self.valid_from_unix_seconds)
102            .field("expires_at_unix_seconds", &self.expires_at_unix_seconds)
103            .field("provider_version", &self.provider_version)
104            .finish()
105    }
106}
107
108impl CredentialDescriptor {
109    pub fn id(&self) -> &CredentialId {
110        &self.id
111    }
112
113    pub const fn certificate_sha256(&self) -> &[u8; 32] {
114        &self.certificate_sha256
115    }
116
117    pub const fn valid_from_unix_seconds(&self) -> u64 {
118        self.valid_from_unix_seconds
119    }
120
121    pub const fn expires_at_unix_seconds(&self) -> u64 {
122        self.expires_at_unix_seconds
123    }
124
125    pub const fn provider_version(&self) -> Option<SecretVersion> {
126        self.provider_version
127    }
128}
129
130/// Credential whose certificate/key pair and proof have already been parsed and validated.
131pub struct ValidatedCredential {
132    descriptor: CredentialDescriptor,
133    proof_digest: [u8; 32],
134}
135
136impl ValidatedCredential {
137    /// Creates a validated snapshot and discards the raw proof after hashing it.
138    ///
139    /// Parsers must validate the certificate/key relationship before calling this constructor.
140    ///
141    /// # Errors
142    ///
143    /// Returns a redacted authentication-service error for invalid validity bounds, an all-zero
144    /// certificate fingerprint, or proof material shorter than 32 bytes.
145    pub fn new(
146        id: CredentialId,
147        certificate_sha256: [u8; 32],
148        valid_from: SystemTime,
149        expires_at: SystemTime,
150        proof: SecretMaterial,
151        provider_version: Option<SecretVersion>,
152    ) -> AuthServiceResult<Self> {
153        let valid_from_unix_seconds = unix_seconds(valid_from)?;
154        let expires_at_unix_seconds = unix_seconds(expires_at)?;
155        if valid_from_unix_seconds >= expires_at_unix_seconds
156            || certificate_sha256.iter().all(|byte| *byte == 0)
157            || proof.len() < MINIMUM_CREDENTIAL_PROOF_BYTES
158        {
159            return Err(rotation_error(AuthFailureKind::InvalidInput));
160        }
161        Ok(Self {
162            descriptor: CredentialDescriptor {
163                id,
164                certificate_sha256,
165                valid_from_unix_seconds,
166                expires_at_unix_seconds,
167                provider_version,
168            },
169            proof_digest: Sha256::digest(proof.expose_secret()).into(),
170        })
171    }
172
173    pub fn descriptor(&self) -> &CredentialDescriptor {
174        &self.descriptor
175    }
176
177    fn is_valid_at(&self, now: u64) -> bool {
178        self.descriptor.valid_from_unix_seconds <= now && now < self.descriptor.expires_at_unix_seconds
179    }
180
181    fn verifies(&self, proof: &SecretMaterial) -> bool {
182        let presented: [u8; 32] = Sha256::digest(proof.expose_secret()).into();
183        self.proof_digest.ct_eq(&presented).unwrap_u8() == 1
184    }
185}
186
187impl fmt::Debug for ValidatedCredential {
188    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
189        f.debug_struct("ValidatedCredential")
190            .field("descriptor", &self.descriptor)
191            .field("proof_digest", &"[REDACTED]")
192            .finish()
193    }
194}
195
196/// Parser injected by the owning protocol/TLS adapter.
197pub trait CredentialBundleParser: Send + Sync {
198    /// Parses and validates a single atomic provider bundle.
199    ///
200    /// The implementation must validate the entire certificate/key/proof relationship before
201    /// returning. The returned provider version must equal `provider_version`.
202    fn parse(
203        &self,
204        material: SecretMaterial,
205        provider_version: Option<SecretVersion>,
206    ) -> AuthServiceResult<ValidatedCredential>;
207}
208
209/// Typed reason required before break-glass can be enabled.
210#[derive(Debug, Clone, Copy, PartialEq, Eq)]
211pub enum BreakGlassReason {
212    IdentityProviderOutage,
213    CertificateRecovery,
214    AdministrativeRecovery,
215}
216
217/// Stable actions sent to the injected audit sink before state publication.
218#[derive(Debug, Clone, Copy, PartialEq, Eq)]
219pub enum CredentialAuditAction {
220    RotationStarted,
221    RetiringCredentialRevoked,
222    RotationRolledBack,
223    ReloadRejected,
224    BreakGlassEnabled,
225    BreakGlassDisabled,
226}
227
228#[derive(Debug, Clone, Copy, PartialEq, Eq)]
229pub enum CredentialAuditOutcome {
230    Authorized,
231    Rejected,
232}
233
234/// Non-sensitive audit event. IDs and fingerprints are metadata, never credential material.
235#[derive(Clone, PartialEq, Eq)]
236pub struct CredentialAuditEvent {
237    action: CredentialAuditAction,
238    outcome: CredentialAuditOutcome,
239    generation: u64,
240    credential_id: Option<CredentialId>,
241    related_credential_id: Option<CredentialId>,
242    break_glass_reason: Option<BreakGlassReason>,
243}
244
245impl fmt::Debug for CredentialAuditEvent {
246    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
247        f.debug_struct("CredentialAuditEvent")
248            .field("action", &self.action)
249            .field("outcome", &self.outcome)
250            .field("generation", &self.generation)
251            .field("credential_id", &self.credential_id.as_ref().map(|_| "[REDACTED]"))
252            .field(
253                "related_credential_id",
254                &self.related_credential_id.as_ref().map(|_| "[REDACTED]"),
255            )
256            .field("break_glass_reason", &self.break_glass_reason)
257            .finish()
258    }
259}
260
261impl CredentialAuditEvent {
262    pub const fn action(&self) -> CredentialAuditAction {
263        self.action
264    }
265
266    pub const fn outcome(&self) -> CredentialAuditOutcome {
267        self.outcome
268    }
269
270    pub const fn generation(&self) -> u64 {
271        self.generation
272    }
273
274    pub fn credential_id(&self) -> Option<&CredentialId> {
275        self.credential_id.as_ref()
276    }
277
278    pub fn related_credential_id(&self) -> Option<&CredentialId> {
279        self.related_credential_id.as_ref()
280    }
281
282    pub const fn break_glass_reason(&self) -> Option<BreakGlassReason> {
283        self.break_glass_reason
284    }
285}
286
287/// Synchronous audit-first boundary. PR-M11-06 owns asynchronous buffering and shutdown drain.
288pub trait CredentialAuditSink: Send + Sync {
289    fn record(&self, event: &CredentialAuditEvent) -> AuthServiceResult<()>;
290}
291
292struct RetiringCredential {
293    credential: Arc<ValidatedCredential>,
294    accept_until_unix_seconds: u64,
295}
296
297struct BreakGlassActivation {
298    expires_at_unix_seconds: u64,
299    reason: BreakGlassReason,
300}
301
302struct BreakGlassCredential {
303    credential: Arc<ValidatedCredential>,
304    activation: Option<BreakGlassActivation>,
305}
306
307struct RotationState {
308    generation: u64,
309    active: Arc<ValidatedCredential>,
310    retiring: Option<RetiringCredential>,
311    revoked: BTreeSet<CredentialId>,
312    break_glass: Option<BreakGlassCredential>,
313}
314
315/// Concurrent credential owner. Readers load one immutable snapshot; writers serialize and
316/// publish only after validation and audit succeed.
317pub struct CredentialRotationManager {
318    state: ArcSwap<RotationState>,
319    writer: Mutex<()>,
320    audit: Arc<dyn CredentialAuditSink>,
321    maximum_break_glass_duration: Duration,
322}
323
324impl CredentialRotationManager {
325    /// Creates generation 1 with break-glass disabled by default.
326    ///
327    /// # Errors
328    ///
329    /// Returns a redacted error if the active credential is not currently valid or the optional
330    /// break-glass credential conflicts with it.
331    pub fn new(
332        active: ValidatedCredential,
333        break_glass: Option<ValidatedCredential>,
334        maximum_break_glass_duration: Duration,
335        now: SystemTime,
336        audit: Arc<dyn CredentialAuditSink>,
337    ) -> AuthServiceResult<Self> {
338        let now = unix_seconds(now)?;
339        if !active.is_valid_at(now) || maximum_break_glass_duration.is_zero() {
340            return Err(rotation_error(AuthFailureKind::InvalidInput));
341        }
342        if break_glass
343            .as_ref()
344            .is_some_and(|credential| credential.descriptor.id == active.descriptor.id || !credential.is_valid_at(now))
345        {
346            return Err(rotation_error(AuthFailureKind::InvalidInput));
347        }
348        Ok(Self {
349            state: ArcSwap::from_pointee(RotationState {
350                generation: 1,
351                active: Arc::new(active),
352                retiring: None,
353                revoked: BTreeSet::new(),
354                break_glass: break_glass.map(|credential| BreakGlassCredential {
355                    credential: Arc::new(credential),
356                    activation: None,
357                }),
358            }),
359            writer: Mutex::new(()),
360            audit,
361            maximum_break_glass_duration,
362        })
363    }
364
365    pub fn snapshot(&self) -> CredentialRotationSnapshot {
366        let state = self.state.load();
367        CredentialRotationSnapshot {
368            generation: state.generation,
369            active: state.active.descriptor.clone(),
370            retiring: state.retiring.as_ref().map(|retiring| RetiringCredentialSnapshot {
371                credential: retiring.credential.descriptor.clone(),
372                accept_until_unix_seconds: retiring.accept_until_unix_seconds,
373            }),
374            revoked: state.revoked.iter().cloned().collect(),
375            break_glass: match state.break_glass.as_ref() {
376                None => BreakGlassStatus::NotConfigured,
377                Some(break_glass) => match break_glass.activation.as_ref() {
378                    None => BreakGlassStatus::Disabled {
379                        credential: break_glass.credential.descriptor.clone(),
380                    },
381                    Some(activation) => BreakGlassStatus::Enabled {
382                        credential: break_glass.credential.descriptor.clone(),
383                        expires_at_unix_seconds: activation.expires_at_unix_seconds,
384                        reason: activation.reason,
385                    },
386                },
387            },
388        }
389    }
390
391    /// Loads a single atomic provider bundle, parses it completely, then starts rotation.
392    ///
393    /// # Errors
394    ///
395    /// Provider and parser failures leave the current snapshot unchanged and emit a rejected
396    /// audit event. Audit failure also leaves the snapshot unchanged.
397    pub fn reload_from_provider(
398        &self,
399        provider: &dyn SecretProvider,
400        name: &SecretName,
401        parser: &dyn CredentialBundleParser,
402        overlap_until: SystemTime,
403        now: SystemTime,
404    ) -> AuthServiceResult<u64> {
405        let loaded = match provider.read(name) {
406            Ok(loaded) => loaded,
407            Err(source) => {
408                self.audit_rejected_reload()?;
409                return Err(AuthServiceError::with_source(
410                    AuthOperation::RotateCredential,
411                    AuthFailureKind::Unavailable,
412                    source,
413                ));
414            }
415        };
416        let provider_version = loaded.version();
417        let candidate = match parser.parse(loaded.into_material(), provider_version) {
418            Ok(candidate) if candidate.descriptor.provider_version == provider_version => candidate,
419            Ok(_) => {
420                self.audit_rejected_reload()?;
421                return Err(rotation_error(AuthFailureKind::InvalidData));
422            }
423            Err(source) => {
424                self.audit_rejected_reload()?;
425                return Err(AuthServiceError::with_source(
426                    AuthOperation::RotateCredential,
427                    AuthFailureKind::InvalidData,
428                    source,
429                ));
430            }
431        };
432        self.start_rotation(candidate, overlap_until, now)
433    }
434
435    /// Publishes a fully validated candidate as active while retaining the previous active
436    /// credential for the bounded overlap window.
437    pub fn start_rotation(
438        &self,
439        candidate: ValidatedCredential,
440        overlap_until: SystemTime,
441        now: SystemTime,
442    ) -> AuthServiceResult<u64> {
443        let _writer = self
444            .writer
445            .lock()
446            .map_err(|_| rotation_error(AuthFailureKind::Internal))?;
447        let state = self.state.load_full();
448        if state.retiring.is_some() {
449            return Err(rotation_error(AuthFailureKind::Conflict));
450        }
451        let now = unix_seconds(now)?;
452        let overlap_until = unix_seconds(overlap_until)?;
453        if !candidate.is_valid_at(now)
454            || overlap_until <= now
455            || overlap_until > candidate.descriptor.expires_at_unix_seconds
456            || overlap_until > state.active.descriptor.expires_at_unix_seconds
457            || candidate.descriptor.id == state.active.descriptor.id
458            || state.revoked.contains(&candidate.descriptor.id)
459            || state
460                .break_glass
461                .as_ref()
462                .is_some_and(|break_glass| break_glass.credential.descriptor.id == candidate.descriptor.id)
463        {
464            return Err(rotation_error(AuthFailureKind::InvalidInput));
465        }
466        let generation = next_generation(state.generation)?;
467        self.record_audit(CredentialAuditEvent {
468            action: CredentialAuditAction::RotationStarted,
469            outcome: CredentialAuditOutcome::Authorized,
470            generation,
471            credential_id: Some(candidate.descriptor.id.clone()),
472            related_credential_id: Some(state.active.descriptor.id.clone()),
473            break_glass_reason: None,
474        })?;
475        self.state.store(Arc::new(RotationState {
476            generation,
477            active: Arc::new(candidate),
478            retiring: Some(RetiringCredential {
479                credential: state.active.clone(),
480                accept_until_unix_seconds: overlap_until,
481            }),
482            revoked: state.revoked.clone(),
483            break_glass: clone_break_glass(&state.break_glass),
484        }));
485        Ok(generation)
486    }
487
488    /// Revokes the retiring credential after the overlap window.
489    pub fn finalize_rotation(&self, now: SystemTime) -> AuthServiceResult<u64> {
490        let _writer = self
491            .writer
492            .lock()
493            .map_err(|_| rotation_error(AuthFailureKind::Internal))?;
494        let state = self.state.load_full();
495        let retiring = state
496            .retiring
497            .as_ref()
498            .ok_or_else(|| rotation_error(AuthFailureKind::Conflict))?;
499        if unix_seconds(now)? < retiring.accept_until_unix_seconds {
500            return Err(rotation_error(AuthFailureKind::Conflict));
501        }
502        let generation = next_generation(state.generation)?;
503        self.record_audit(CredentialAuditEvent {
504            action: CredentialAuditAction::RetiringCredentialRevoked,
505            outcome: CredentialAuditOutcome::Authorized,
506            generation,
507            credential_id: Some(retiring.credential.descriptor.id.clone()),
508            related_credential_id: Some(state.active.descriptor.id.clone()),
509            break_glass_reason: None,
510        })?;
511        let mut revoked = state.revoked.clone();
512        revoked.insert(retiring.credential.descriptor.id.clone());
513        self.state.store(Arc::new(RotationState {
514            generation,
515            active: state.active.clone(),
516            retiring: None,
517            revoked,
518            break_glass: clone_break_glass(&state.break_glass),
519        }));
520        Ok(generation)
521    }
522
523    /// Rolls back to the unrevoked last-known-good credential and revokes the failed candidate.
524    pub fn rollback(&self, now: SystemTime) -> AuthServiceResult<u64> {
525        let _writer = self
526            .writer
527            .lock()
528            .map_err(|_| rotation_error(AuthFailureKind::Internal))?;
529        let state = self.state.load_full();
530        let retiring = state
531            .retiring
532            .as_ref()
533            .ok_or_else(|| rotation_error(AuthFailureKind::Conflict))?;
534        let now = unix_seconds(now)?;
535        if !retiring.credential.is_valid_at(now) || state.revoked.contains(&retiring.credential.descriptor.id) {
536            return Err(rotation_error(AuthFailureKind::Unauthenticated));
537        }
538        let generation = next_generation(state.generation)?;
539        self.record_audit(CredentialAuditEvent {
540            action: CredentialAuditAction::RotationRolledBack,
541            outcome: CredentialAuditOutcome::Authorized,
542            generation,
543            credential_id: Some(retiring.credential.descriptor.id.clone()),
544            related_credential_id: Some(state.active.descriptor.id.clone()),
545            break_glass_reason: None,
546        })?;
547        let mut revoked = state.revoked.clone();
548        revoked.insert(state.active.descriptor.id.clone());
549        self.state.store(Arc::new(RotationState {
550            generation,
551            active: retiring.credential.clone(),
552            retiring: None,
553            revoked,
554            break_glass: clone_break_glass(&state.break_glass),
555        }));
556        Ok(generation)
557    }
558
559    pub fn enable_break_glass(
560        &self,
561        reason: BreakGlassReason,
562        expires_at: SystemTime,
563        now: SystemTime,
564    ) -> AuthServiceResult<u64> {
565        let _writer = self
566            .writer
567            .lock()
568            .map_err(|_| rotation_error(AuthFailureKind::Internal))?;
569        let state = self.state.load_full();
570        let break_glass = state
571            .break_glass
572            .as_ref()
573            .ok_or_else(|| rotation_error(AuthFailureKind::NotFound))?;
574        if break_glass.activation.is_some() {
575            return Err(rotation_error(AuthFailureKind::Conflict));
576        }
577        let now = unix_seconds(now)?;
578        let expires_at = unix_seconds(expires_at)?;
579        let maximum = now.saturating_add(self.maximum_break_glass_duration.as_secs());
580        if expires_at <= now
581            || expires_at > maximum
582            || expires_at > break_glass.credential.descriptor.expires_at_unix_seconds
583        {
584            return Err(rotation_error(AuthFailureKind::InvalidInput));
585        }
586        let generation = next_generation(state.generation)?;
587        self.record_audit(CredentialAuditEvent {
588            action: CredentialAuditAction::BreakGlassEnabled,
589            outcome: CredentialAuditOutcome::Authorized,
590            generation,
591            credential_id: Some(break_glass.credential.descriptor.id.clone()),
592            related_credential_id: None,
593            break_glass_reason: Some(reason),
594        })?;
595        self.state.store(Arc::new(RotationState {
596            generation,
597            active: state.active.clone(),
598            retiring: clone_retiring(&state.retiring),
599            revoked: state.revoked.clone(),
600            break_glass: Some(BreakGlassCredential {
601                credential: break_glass.credential.clone(),
602                activation: Some(BreakGlassActivation {
603                    expires_at_unix_seconds: expires_at,
604                    reason,
605                }),
606            }),
607        }));
608        Ok(generation)
609    }
610
611    pub fn disable_break_glass(&self) -> AuthServiceResult<u64> {
612        let _writer = self
613            .writer
614            .lock()
615            .map_err(|_| rotation_error(AuthFailureKind::Internal))?;
616        let state = self.state.load_full();
617        let break_glass = state
618            .break_glass
619            .as_ref()
620            .ok_or_else(|| rotation_error(AuthFailureKind::NotFound))?;
621        let activation = break_glass
622            .activation
623            .as_ref()
624            .ok_or_else(|| rotation_error(AuthFailureKind::Conflict))?;
625        let generation = next_generation(state.generation)?;
626        self.record_audit(CredentialAuditEvent {
627            action: CredentialAuditAction::BreakGlassDisabled,
628            outcome: CredentialAuditOutcome::Authorized,
629            generation,
630            credential_id: Some(break_glass.credential.descriptor.id.clone()),
631            related_credential_id: None,
632            break_glass_reason: Some(activation.reason),
633        })?;
634        self.state.store(Arc::new(RotationState {
635            generation,
636            active: state.active.clone(),
637            retiring: clone_retiring(&state.retiring),
638            revoked: state.revoked.clone(),
639            break_glass: Some(BreakGlassCredential {
640                credential: break_glass.credential.clone(),
641                activation: None,
642            }),
643        }));
644        Ok(generation)
645    }
646
647    /// Verifies proof against one immutable snapshot.
648    pub fn verify(
649        &self,
650        credential_id: &CredentialId,
651        proof: SecretMaterial,
652        now: SystemTime,
653    ) -> AuthServiceResult<CredentialVerification> {
654        let now = unix_seconds(now)?;
655        let state = self.state.load();
656        if state.revoked.contains(credential_id) {
657            return Err(rotation_error(AuthFailureKind::Unauthenticated));
658        }
659        let (credential, source) = if state.active.descriptor.id == *credential_id {
660            (state.active.as_ref(), CredentialVerificationSource::Active)
661        } else if let Some(retiring) = state.retiring.as_ref().filter(|retiring| {
662            retiring.credential.descriptor.id == *credential_id && now < retiring.accept_until_unix_seconds
663        }) {
664            (retiring.credential.as_ref(), CredentialVerificationSource::Retiring)
665        } else if let Some(break_glass) = state
666            .break_glass
667            .as_ref()
668            .filter(|break_glass| break_glass.credential.descriptor.id == *credential_id)
669        {
670            let activation = break_glass
671                .activation
672                .as_ref()
673                .ok_or_else(|| rotation_error(AuthFailureKind::Conflict))?;
674            if now >= activation.expires_at_unix_seconds {
675                return Err(rotation_error(AuthFailureKind::Expired));
676            }
677            (
678                break_glass.credential.as_ref(),
679                CredentialVerificationSource::BreakGlass,
680            )
681        } else {
682            return Err(rotation_error(AuthFailureKind::Unauthenticated));
683        };
684        if !credential.is_valid_at(now) {
685            return Err(rotation_error(AuthFailureKind::Expired));
686        }
687        if !credential.verifies(&proof) {
688            return Err(rotation_error(AuthFailureKind::Unauthenticated));
689        }
690        Ok(CredentialVerification {
691            credential_id: credential_id.clone(),
692            generation: state.generation,
693            source,
694        })
695    }
696
697    fn audit_rejected_reload(&self) -> AuthServiceResult<()> {
698        let state = self.state.load();
699        self.record_audit(CredentialAuditEvent {
700            action: CredentialAuditAction::ReloadRejected,
701            outcome: CredentialAuditOutcome::Rejected,
702            generation: state.generation,
703            credential_id: None,
704            related_credential_id: Some(state.active.descriptor.id.clone()),
705            break_glass_reason: None,
706        })
707    }
708
709    fn record_audit(&self, event: CredentialAuditEvent) -> AuthServiceResult<()> {
710        self.audit.record(&event).map_err(|source| {
711            AuthServiceError::with_source(AuthOperation::RotateCredential, AuthFailureKind::Internal, source)
712        })
713    }
714}
715
716impl fmt::Debug for CredentialRotationManager {
717    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
718        f.debug_struct("CredentialRotationManager")
719            .field("snapshot", &self.snapshot())
720            .field("audit", &"[INJECTED]")
721            .finish()
722    }
723}
724
725fn clone_retiring(value: &Option<RetiringCredential>) -> Option<RetiringCredential> {
726    value.as_ref().map(|retiring| RetiringCredential {
727        credential: retiring.credential.clone(),
728        accept_until_unix_seconds: retiring.accept_until_unix_seconds,
729    })
730}
731
732fn clone_break_glass(value: &Option<BreakGlassCredential>) -> Option<BreakGlassCredential> {
733    value.as_ref().map(|break_glass| BreakGlassCredential {
734        credential: break_glass.credential.clone(),
735        activation: break_glass.activation.as_ref().map(|activation| BreakGlassActivation {
736            expires_at_unix_seconds: activation.expires_at_unix_seconds,
737            reason: activation.reason,
738        }),
739    })
740}
741
742fn next_generation(current: u64) -> AuthServiceResult<u64> {
743    current
744        .checked_add(1)
745        .ok_or_else(|| rotation_error(AuthFailureKind::Internal))
746}
747
748fn unix_seconds(time: SystemTime) -> AuthServiceResult<u64> {
749    time.duration_since(UNIX_EPOCH)
750        .map(|duration| duration.as_secs())
751        .map_err(|source| {
752            AuthServiceError::with_source(AuthOperation::RotateCredential, AuthFailureKind::InvalidInput, source)
753        })
754}
755
756fn rotation_error(kind: AuthFailureKind) -> AuthServiceError {
757    AuthServiceError::new(AuthOperation::RotateCredential, kind)
758}