Skip to main content

lodestone_client/
credential_rotation.rs

1//! Create-new local publication for service credential rotations.
2
3use std::error::Error;
4use std::fmt::{self, Display, Formatter};
5use std::path::{Component, Path, PathBuf};
6
7use aws_lc_rs::digest;
8use lodestone_wire::{Digest, Identity, ServiceCredentialReservation, U64};
9use serde::{Deserialize, Serialize};
10
11use crate::credential::{
12    CredentialActivationError, CredentialProviderError, SecretBytes, credential_verifier, decode_file_credential,
13    encode_file_credential,
14};
15use crate::{
16    LocalOperatorWorkflowKind, ValidatedConnectionProfile, WorkflowJournal, WorkflowJournalError,
17    WorkflowJournalIntent, WorkflowJournalTransition, WorkflowTransactionId, profile_sha256,
18    secure_publication::{PublishedFileIdentity, SecureCreateNewFilePublisher, SecurePublicationError},
19};
20
21pub const CREDENTIAL_ROTATION_FORMAT_VERSION: u16 = 1;
22const TRANSACTION_PREFIX: &str = ".lodestone-credential-rotation-v1-";
23const PROFILE_STAGE_PREFIX: &str = ".lodestone-profile-rotation-v1-";
24const JOURNAL_NAME: &str = "journal";
25const CREDENTIAL_STAGE_NAME: &str = "credential.stage";
26const PROFILE_STAGE_NAME: &str = "profile.stage";
27
28#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
29#[serde(deny_unknown_fields)]
30pub struct CredentialRotationIntent {
31    pub format_version: u16,
32    pub current_profile_sha256: [u8; 32],
33    pub current_profile_generation: U64,
34    pub cluster_id: Identity,
35    pub collection_id: Identity,
36    pub current_api_key_id: Identity,
37    pub current_credential_generation: U64,
38    pub credential_expires_at: U64,
39    pub overlap_until: U64,
40    pub reservation_expires_at: U64,
41    pub credential_output: String,
42    pub profile_output: String,
43}
44
45impl CredentialRotationIntent {
46    #[allow(clippy::too_many_arguments)]
47    pub fn new(
48        current_profile: &ValidatedConnectionProfile,
49        cluster_id: Identity,
50        collection_id: Identity,
51        credential_expires_at: U64,
52        overlap_until: U64,
53        reservation_expires_at: U64,
54        credential_output: String,
55        profile_output: String,
56    ) -> Result<Self, CredentialRotationError> {
57        let wire = current_profile.as_wire();
58        let intent = Self {
59            format_version: CREDENTIAL_ROTATION_FORMAT_VERSION,
60            current_profile_sha256: profile_sha256(current_profile).map_err(CredentialRotationError::Journal)?,
61            current_profile_generation: wire.profile_generation.clone(),
62            cluster_id,
63            collection_id,
64            current_api_key_id: wire.credential_id.clone(),
65            current_credential_generation: wire.credential_generation.clone(),
66            credential_expires_at,
67            overlap_until,
68            reservation_expires_at,
69            credential_output,
70            profile_output,
71        };
72        intent.validate().map_err(CredentialRotationError::Journal)?;
73        Ok(intent)
74    }
75}
76
77impl crate::workflow_journal::sealed::Sealed for CredentialRotationIntent {}
78
79impl WorkflowJournalIntent for CredentialRotationIntent {
80    type Transition = CredentialRotationTransition;
81
82    const KIND: LocalOperatorWorkflowKind = LocalOperatorWorkflowKind::RotateCredential;
83
84    fn validate(&self) -> Result<(), WorkflowJournalError> {
85        let profile_generation = canonical_u64(&self.current_profile_generation);
86        let current_generation = canonical_u64(&self.current_credential_generation);
87        let credential_expiry = canonical_u64(&self.credential_expires_at);
88        let overlap = canonical_u64(&self.overlap_until);
89        let reservation_expiry = canonical_u64(&self.reservation_expires_at);
90        if self.format_version != CREDENTIAL_ROTATION_FORMAT_VERSION
91            || !valid_sha256(&self.current_profile_sha256)
92            || profile_generation.is_none_or(|value| value == 0)
93            || current_generation.is_none_or(|value| value == 0)
94            || credential_expiry.is_none_or(|value| value == 0)
95            || overlap.is_none_or(|value| value == 0)
96            || reservation_expiry.is_none_or(|value| value == 0)
97            || !valid_identity(&self.cluster_id)
98            || !valid_identity(&self.collection_id)
99            || !valid_identity(&self.current_api_key_id)
100            || !valid_output_path(&self.credential_output)
101            || !valid_output_path(&self.profile_output)
102            || !valid_file_reference_path(&self.credential_output)
103            || self.credential_output == self.profile_output
104        {
105            return Err(WorkflowJournalError::InvalidIntent);
106        }
107        Ok(())
108    }
109}
110
111#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
112#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
113pub enum CredentialRotationTransition {
114    ActionsAllocated {
115        attempt: u32,
116        reservation_action_id: Digest,
117        activation_action_id: Digest,
118    },
119    ReservationRecorded {
120        reservation: ServiceCredentialReservation,
121    },
122    CredentialStaged {
123        file: PublishedFileIdentity,
124    },
125    ReservationExpiredUnactivated {
126        reservation_action_id: Digest,
127    },
128    Activated {
129        principal_id: Identity,
130        api_key_id: Identity,
131        generation: U64,
132    },
133    CredentialPublished {
134        file: PublishedFileIdentity,
135    },
136    ProfileStaged {
137        file: PublishedFileIdentity,
138    },
139    ProfilePublished {
140        file: PublishedFileIdentity,
141    },
142    Verified {
143        principal_id: Identity,
144        api_key_id: Identity,
145        generation: U64,
146    },
147    Completed,
148}
149
150impl crate::workflow_journal::sealed::Sealed for CredentialRotationTransition {}
151
152impl WorkflowJournalTransition for CredentialRotationTransition {
153    fn validate(&self) -> Result<(), WorkflowJournalError> {
154        let valid = match self {
155            Self::ActionsAllocated {
156                attempt: _,
157                reservation_action_id,
158                activation_action_id,
159            } => {
160                valid_digest(reservation_action_id)
161                    && valid_digest(activation_action_id)
162                    && reservation_action_id != activation_action_id
163            }
164            Self::ReservationRecorded { reservation } => valid_reservation(reservation),
165            Self::CredentialStaged { file }
166            | Self::CredentialPublished { file }
167            | Self::ProfileStaged { file }
168            | Self::ProfilePublished { file } => valid_file_identity(file),
169            Self::ReservationExpiredUnactivated { reservation_action_id } => valid_digest(reservation_action_id),
170            Self::Activated {
171                principal_id,
172                api_key_id,
173                generation,
174            }
175            | Self::Verified {
176                principal_id,
177                api_key_id,
178                generation,
179            } => valid_identity(principal_id) && valid_identity(api_key_id) && canonical_u64(generation).is_some(),
180            Self::Completed => true,
181        };
182        if valid {
183            Ok(())
184        } else {
185            Err(WorkflowJournalError::InvalidTransition)
186        }
187    }
188}
189
190#[derive(Clone, Debug, Default, PartialEq)]
191pub struct CredentialRotationState {
192    attempt: u32,
193    action_ids: Option<(Digest, Digest)>,
194    reservation: Option<ServiceCredentialReservation>,
195    credential_staged: Option<PublishedFileIdentity>,
196    activated: Option<(Identity, Identity, U64)>,
197    credential_published: Option<PublishedFileIdentity>,
198    profile_staged: Option<PublishedFileIdentity>,
199    profile_published: Option<PublishedFileIdentity>,
200    verified: bool,
201    completed: bool,
202}
203
204impl CredentialRotationState {
205    pub fn replay(transitions: &[CredentialRotationTransition]) -> Result<Self, WorkflowJournalError> {
206        let mut state = Self::default();
207        for transition in transitions {
208            transition.validate()?;
209            state.apply(transition)?;
210        }
211        Ok(state)
212    }
213
214    fn apply(&mut self, transition: &CredentialRotationTransition) -> Result<(), WorkflowJournalError> {
215        if self.completed {
216            return Err(WorkflowJournalError::Corrupt);
217        }
218        match transition {
219            CredentialRotationTransition::ActionsAllocated {
220                attempt,
221                reservation_action_id,
222                activation_action_id,
223            } if self.action_ids.is_none() && *attempt == self.attempt => {
224                self.action_ids = Some((reservation_action_id.clone(), activation_action_id.clone()));
225            }
226            CredentialRotationTransition::ReservationRecorded { reservation }
227                if self.reservation.is_none()
228                    && self.action_ids.as_ref().is_some_and(|(reservation_id, activation_id)| {
229                        &reservation.reservation_action_id == reservation_id
230                            && &reservation.activation_action_id == activation_id
231                    }) =>
232            {
233                self.reservation = Some(reservation.clone());
234            }
235            CredentialRotationTransition::CredentialStaged { file }
236                if self.reservation.is_some() && self.credential_staged.is_none() =>
237            {
238                self.credential_staged = Some(file.clone());
239            }
240            CredentialRotationTransition::ReservationExpiredUnactivated { reservation_action_id }
241                if self
242                    .action_ids
243                    .as_ref()
244                    .is_some_and(|(current, _)| current == reservation_action_id)
245                    && self
246                        .reservation
247                        .as_ref()
248                        .is_none_or(|reservation| &reservation.reservation_action_id == reservation_action_id)
249                    && self.activated.is_none()
250                    && self.credential_published.is_none() =>
251            {
252                self.attempt = self.attempt.checked_add(1).ok_or(WorkflowJournalError::Corrupt)?;
253                self.action_ids = None;
254                self.reservation = None;
255                self.credential_staged = None;
256            }
257            CredentialRotationTransition::Activated {
258                principal_id,
259                api_key_id,
260                generation,
261            } if self.activated.is_none()
262                && self.credential_staged.is_some()
263                && self.reservation.as_ref().is_some_and(|reservation| {
264                    reservation.principal_id == *principal_id
265                        && reservation.api_key_id == *api_key_id
266                        && reservation.generation == *generation
267                }) =>
268            {
269                self.activated = Some((principal_id.clone(), api_key_id.clone(), generation.clone()));
270            }
271            CredentialRotationTransition::CredentialPublished { file }
272                if self.activated.is_some()
273                    && self.credential_published.is_none()
274                    && self.credential_staged.as_ref() == Some(file) =>
275            {
276                self.credential_published = Some(file.clone());
277            }
278            CredentialRotationTransition::ProfileStaged { file }
279                if self.credential_published.is_some() && self.profile_staged.is_none() =>
280            {
281                self.profile_staged = Some(file.clone());
282            }
283            CredentialRotationTransition::ProfilePublished { file }
284                if self.profile_published.is_none() && self.profile_staged.as_ref() == Some(file) =>
285            {
286                self.profile_published = Some(file.clone());
287            }
288            CredentialRotationTransition::Verified {
289                principal_id,
290                api_key_id,
291                generation,
292            } if !self.verified
293                && self.profile_published.is_some()
294                && self.activated.as_ref() == Some(&(principal_id.clone(), api_key_id.clone(), generation.clone())) =>
295            {
296                self.verified = true;
297            }
298            CredentialRotationTransition::Completed if self.verified => {
299                self.completed = true;
300            }
301            _ => return Err(WorkflowJournalError::Corrupt),
302        }
303        Ok(())
304    }
305
306    #[must_use]
307    pub fn action_ids(&self) -> Option<&(Digest, Digest)> {
308        self.action_ids.as_ref()
309    }
310
311    #[must_use]
312    pub const fn attempt(&self) -> u32 {
313        self.attempt
314    }
315
316    #[must_use]
317    pub fn reservation(&self) -> Option<&ServiceCredentialReservation> {
318        self.reservation.as_ref()
319    }
320
321    #[must_use]
322    pub fn credential_staged(&self) -> Option<&PublishedFileIdentity> {
323        self.credential_staged.as_ref()
324    }
325
326    #[must_use]
327    pub fn activated(&self) -> Option<&(Identity, Identity, U64)> {
328        self.activated.as_ref()
329    }
330
331    #[must_use]
332    pub fn credential_published(&self) -> Option<&PublishedFileIdentity> {
333        self.credential_published.as_ref()
334    }
335
336    #[must_use]
337    pub fn profile_staged(&self) -> Option<&PublishedFileIdentity> {
338        self.profile_staged.as_ref()
339    }
340
341    #[must_use]
342    pub fn profile_published(&self) -> Option<&PublishedFileIdentity> {
343        self.profile_published.as_ref()
344    }
345
346    #[must_use]
347    pub const fn verified(&self) -> bool {
348        self.verified
349    }
350
351    #[must_use]
352    pub const fn completed(&self) -> bool {
353        self.completed
354    }
355}
356
357pub struct StagedCredential {
358    verifier: Digest,
359}
360
361impl StagedCredential {
362    #[must_use]
363    pub const fn verifier(&self) -> &Digest {
364        &self.verifier
365    }
366}
367
368/// Secret-owning create-new credential file used by workflows whose durable
369/// journal is owned outside the credential-rotation workflow.
370pub struct RecoverableServiceCredentialFile {
371    publisher: SecureCreateNewFilePublisher,
372}
373
374pub struct StagedServiceCredentialFile {
375    verifier: Digest,
376    identity: PublishedFileIdentity,
377}
378
379impl StagedServiceCredentialFile {
380    #[must_use]
381    pub const fn verifier(&self) -> &Digest {
382        &self.verifier
383    }
384
385    #[must_use]
386    pub const fn identity(&self) -> &PublishedFileIdentity {
387        &self.identity
388    }
389}
390
391impl RecoverableServiceCredentialFile {
392    pub async fn open(
393        stage_directory: PathBuf,
394        stage_name: String,
395        destination: PathBuf,
396    ) -> Result<Self, CredentialRotationError> {
397        let publisher = SecureCreateNewFilePublisher::open(stage_directory, stage_name, destination)
398            .await
399            .map_err(map_publication_error)?;
400        Ok(Self { publisher })
401    }
402
403    pub async fn stage(
404        &self,
405        reservation: &ServiceCredentialReservation,
406        expected: Option<PublishedFileIdentity>,
407    ) -> Result<StagedServiceCredentialFile, CredentialRotationError> {
408        let mut retried_invalid_unjournaled_stage = false;
409        loop {
410            match self
411                .publisher
412                .read_stage(4 * 1024)
413                .await
414                .map_err(map_publication_error)?
415            {
416                Some(mut bytes) => {
417                    let material = match decode_file_credential(&bytes) {
418                        Ok(material) => material,
419                        Err(_error) if expected.is_none() && !retried_invalid_unjournaled_stage => {
420                            bytes.fill(0);
421                            std::hint::black_box(&mut bytes);
422                            self.publisher
423                                .remove_unpublished_stage(None)
424                                .await
425                                .map_err(map_publication_error)?;
426                            retried_invalid_unjournaled_stage = true;
427                            continue;
428                        }
429                        Err(error) => {
430                            bytes.fill(0);
431                            std::hint::black_box(&mut bytes);
432                            return Err(CredentialRotationError::Credential(error));
433                        }
434                    };
435                    if material.api_key_id() != &reservation.api_key_id
436                        || material.generation() != &reservation.generation
437                    {
438                        bytes.fill(0);
439                        std::hint::black_box(&mut bytes);
440                        return Err(CredentialRotationError::Conflict);
441                    }
442                    let verifier = credential_verifier(&reservation.api_key_id, material.secret())
443                        .map_err(CredentialRotationError::Activation)?;
444                    let identity = self
445                        .publisher
446                        .stage_sensitive(bytes, 4 * 1024, expected)
447                        .await
448                        .map_err(map_publication_error)?;
449                    return Ok(StagedServiceCredentialFile { verifier, identity });
450                }
451                None => {
452                    if expected.is_some() {
453                        return Err(CredentialRotationError::InvalidState);
454                    }
455                    let secret = SecretBytes::generate().map_err(CredentialRotationError::Credential)?;
456                    let verifier = credential_verifier(&reservation.api_key_id, &secret)
457                        .map_err(CredentialRotationError::Activation)?;
458                    let bytes = encode_file_credential(&reservation.api_key_id, &reservation.generation, &secret)
459                        .map_err(CredentialRotationError::Credential)?;
460                    let identity = self
461                        .publisher
462                        .stage_sensitive(bytes, 4 * 1024, None)
463                        .await
464                        .map_err(map_publication_error)?;
465                    return Ok(StagedServiceCredentialFile { verifier, identity });
466                }
467            }
468        }
469    }
470
471    pub async fn publish(&self, expected: PublishedFileIdentity) -> Result<(), CredentialRotationError> {
472        self.publisher.publish(expected).await.map_err(map_publication_error)
473    }
474
475    pub async fn verify_published(&self, expected: PublishedFileIdentity) -> Result<(), CredentialRotationError> {
476        self.publisher
477            .verify_published(expected)
478            .await
479            .map_err(map_publication_error)
480    }
481}
482
483/// Locked create-new transaction for one exact pair of output paths.
484pub struct AtomicFileCredentialMaterialStore {
485    intent: CredentialRotationIntent,
486    journal: WorkflowJournal<CredentialRotationIntent>,
487    transitions: Vec<CredentialRotationTransition>,
488    state: CredentialRotationState,
489    credential_publisher: SecureCreateNewFilePublisher,
490    profile_publisher: SecureCreateNewFilePublisher,
491}
492
493impl AtomicFileCredentialMaterialStore {
494    pub async fn open_or_create(intent: CredentialRotationIntent) -> Result<Self, CredentialRotationError> {
495        intent.validate().map_err(CredentialRotationError::Journal)?;
496
497        #[cfg(any(target_os = "linux", target_os = "macos"))]
498        {
499            let setup_intent = intent.clone();
500            let setup = tokio::task::spawn_blocking(move || unix::prepare_transaction(&setup_intent))
501                .await
502                .map_err(|_| CredentialRotationError::WorkerUnavailable)??;
503            let journal_path = setup.transaction_path.join(JOURNAL_NAME);
504            let replay = WorkflowJournal::open_or_create(journal_path, intent.clone())
505                .await
506                .map_err(CredentialRotationError::Journal)?;
507            let (journal, transitions) = replay.into_parts();
508            let state = CredentialRotationState::replay(&transitions).map_err(CredentialRotationError::Journal)?;
509            let setup_state = state.clone();
510            let paths = tokio::task::spawn_blocking(move || unix::finish_transaction_setup(setup, &setup_state))
511                .await
512                .map_err(|_| CredentialRotationError::WorkerUnavailable)??;
513            let credential_publisher = SecureCreateNewFilePublisher::open(
514                paths.transaction_path,
515                CREDENTIAL_STAGE_NAME.to_owned(),
516                PathBuf::from(&intent.credential_output),
517            )
518            .await
519            .map_err(map_publication_error)?;
520            let profile_publisher = SecureCreateNewFilePublisher::open(
521                paths.profile_stage_path,
522                PROFILE_STAGE_NAME.to_owned(),
523                PathBuf::from(&intent.profile_output),
524            )
525            .await
526            .map_err(map_publication_error)?;
527            let credential_destination_identity = state
528                .credential_published()
529                .cloned()
530                .or_else(|| state.activated().and_then(|_| state.credential_staged().cloned()));
531            credential_publisher
532                .validate_destination(credential_destination_identity, state.credential_published().is_some())
533                .await
534                .map_err(map_publication_error)?;
535            let profile_destination_identity = state
536                .profile_published()
537                .cloned()
538                .or_else(|| state.profile_staged().cloned());
539            profile_publisher
540                .validate_destination(profile_destination_identity, state.profile_published().is_some())
541                .await
542                .map_err(map_publication_error)?;
543            Ok(Self {
544                intent,
545                journal,
546                transitions,
547                state,
548                credential_publisher,
549                profile_publisher,
550            })
551        }
552
553        #[cfg(not(any(target_os = "linux", target_os = "macos")))]
554        {
555            let _ = intent;
556            Err(CredentialRotationError::UnsupportedPlatform)
557        }
558    }
559
560    #[must_use]
561    pub const fn intent(&self) -> &CredentialRotationIntent {
562        &self.intent
563    }
564
565    #[must_use]
566    pub const fn state(&self) -> &CredentialRotationState {
567        &self.state
568    }
569
570    pub async fn ensure_action_ids(&mut self) -> Result<(Digest, Digest), CredentialRotationError> {
571        if let Some(ids) = self.state.action_ids() {
572            return Ok(ids.clone());
573        }
574        let transaction_id = self.journal.transaction_id();
575        let attempt = self.state.attempt();
576        let reservation_action_id = action_id(
577            b"lodestone/credential-rotation/reservation/v1\0",
578            transaction_id,
579            attempt,
580        );
581        let activation_action_id = action_id(
582            b"lodestone/credential-rotation/activation/v1\0",
583            transaction_id,
584            attempt,
585        );
586        self.record(CredentialRotationTransition::ActionsAllocated {
587            attempt,
588            reservation_action_id: reservation_action_id.clone(),
589            activation_action_id: activation_action_id.clone(),
590        })
591        .await?;
592        Ok((reservation_action_id, activation_action_id))
593    }
594
595    pub async fn record_reservation(
596        &mut self,
597        reservation: ServiceCredentialReservation,
598    ) -> Result<(), CredentialRotationError> {
599        if let Some(current) = self.state.reservation() {
600            return if current == &reservation {
601                Ok(())
602            } else {
603                Err(CredentialRotationError::Conflict)
604            };
605        }
606        self.record(CredentialRotationTransition::ReservationRecorded { reservation })
607            .await
608    }
609
610    pub async fn stage_credential(&mut self) -> Result<StagedCredential, CredentialRotationError> {
611        #[cfg(not(any(target_os = "linux", target_os = "macos")))]
612        {
613            return Err(CredentialRotationError::UnsupportedPlatform);
614        }
615
616        #[cfg(any(target_os = "linux", target_os = "macos"))]
617        {
618            let reservation = self
619                .state
620                .reservation()
621                .cloned()
622                .ok_or(CredentialRotationError::InvalidState)?;
623            let expected = self.state.credential_staged().cloned();
624            let mut retried_invalid_unjournaled_stage = false;
625            let result = loop {
626                match self
627                    .credential_publisher
628                    .read_stage(4 * 1024)
629                    .await
630                    .map_err(map_publication_error)?
631                {
632                    Some(mut bytes) => {
633                        let material = match decode_file_credential(&bytes) {
634                            Ok(material) => material,
635                            Err(_error) if expected.is_none() && !retried_invalid_unjournaled_stage => {
636                                bytes.fill(0);
637                                std::hint::black_box(&mut bytes);
638                                self.credential_publisher
639                                    .remove_unpublished_stage(None)
640                                    .await
641                                    .map_err(map_publication_error)?;
642                                retried_invalid_unjournaled_stage = true;
643                                continue;
644                            }
645                            Err(error) => {
646                                bytes.fill(0);
647                                std::hint::black_box(&mut bytes);
648                                return Err(CredentialRotationError::Credential(error));
649                            }
650                        };
651                        if material.api_key_id() != &reservation.api_key_id
652                            || material.generation() != &reservation.generation
653                        {
654                            bytes.fill(0);
655                            std::hint::black_box(&mut bytes);
656                            return Err(CredentialRotationError::Conflict);
657                        }
658                        let verifier = match credential_verifier(&reservation.api_key_id, material.secret()) {
659                            Ok(verifier) => verifier,
660                            Err(error) => {
661                                bytes.fill(0);
662                                std::hint::black_box(&mut bytes);
663                                return Err(CredentialRotationError::Activation(error));
664                            }
665                        };
666                        let identity = self
667                            .credential_publisher
668                            .stage_sensitive(bytes, 4 * 1024, expected.clone())
669                            .await
670                            .map_err(map_publication_error)?;
671                        break unix::StagedCredentialResult { identity, verifier };
672                    }
673                    None => {
674                        if expected.is_some() {
675                            return Err(CredentialRotationError::InvalidState);
676                        }
677                        let secret = SecretBytes::generate().map_err(CredentialRotationError::Credential)?;
678                        let verifier = credential_verifier(&reservation.api_key_id, &secret)
679                            .map_err(CredentialRotationError::Activation)?;
680                        let bytes = encode_file_credential(&reservation.api_key_id, &reservation.generation, &secret)
681                            .map_err(CredentialRotationError::Credential)?;
682                        let identity = self
683                            .credential_publisher
684                            .stage_sensitive(bytes, 4 * 1024, None)
685                            .await
686                            .map_err(map_publication_error)?;
687                        break unix::StagedCredentialResult { identity, verifier };
688                    }
689                }
690            };
691            if self.state.credential_staged().is_none() {
692                self.record(CredentialRotationTransition::CredentialStaged {
693                    file: result.identity.clone(),
694                })
695                .await?;
696            }
697            Ok(StagedCredential {
698                verifier: result.verifier,
699            })
700        }
701    }
702
703    pub async fn retire_expired_unactivated(&mut self) -> Result<(), CredentialRotationError> {
704        let reservation_action_id = self
705            .state
706            .action_ids()
707            .map(|(reservation_action_id, _)| reservation_action_id.clone())
708            .ok_or(CredentialRotationError::InvalidState)?;
709        let expected = self.state.credential_staged().cloned();
710
711        #[cfg(not(any(target_os = "linux", target_os = "macos")))]
712        {
713            let _ = expected;
714            return Err(CredentialRotationError::UnsupportedPlatform);
715        }
716
717        #[cfg(any(target_os = "linux", target_os = "macos"))]
718        {
719            self.credential_publisher
720                .remove_unpublished_stage(expected)
721                .await
722                .map_err(map_publication_error)?;
723            self.record(CredentialRotationTransition::ReservationExpiredUnactivated { reservation_action_id })
724                .await
725        }
726    }
727
728    pub async fn record_activation(
729        &mut self,
730        principal_id: Identity,
731        api_key_id: Identity,
732        generation: U64,
733    ) -> Result<(), CredentialRotationError> {
734        if let Some(current) = self.state.activated() {
735            return if current == &(principal_id, api_key_id, generation) {
736                Ok(())
737            } else {
738                Err(CredentialRotationError::Conflict)
739            };
740        }
741        self.record(CredentialRotationTransition::Activated {
742            principal_id,
743            api_key_id,
744            generation,
745        })
746        .await
747    }
748
749    pub async fn publish_credential(&mut self) -> Result<(), CredentialRotationError> {
750        self.publish(FileKind::Credential).await
751    }
752
753    pub async fn stage_profile(&mut self, bytes: Vec<u8>) -> Result<(), CredentialRotationError> {
754        #[cfg(not(any(target_os = "linux", target_os = "macos")))]
755        {
756            let _ = bytes;
757            return Err(CredentialRotationError::UnsupportedPlatform);
758        }
759
760        #[cfg(any(target_os = "linux", target_os = "macos"))]
761        {
762            if bytes.is_empty() || bytes.len() > 64 * 1024 {
763                return Err(CredentialRotationError::InvalidMaterial);
764            }
765            let expected = self.state.profile_staged().cloned();
766            let identity = self
767                .profile_publisher
768                .stage(bytes, 64 * 1024, expected)
769                .await
770                .map_err(map_publication_error)?;
771            if self.state.profile_staged().is_none() {
772                self.record(CredentialRotationTransition::ProfileStaged { file: identity })
773                    .await?;
774            }
775            Ok(())
776        }
777    }
778
779    pub async fn publish_profile(&mut self) -> Result<(), CredentialRotationError> {
780        self.publish(FileKind::Profile).await
781    }
782
783    pub async fn record_verified(
784        &mut self,
785        principal_id: Identity,
786        api_key_id: Identity,
787        generation: U64,
788    ) -> Result<(), CredentialRotationError> {
789        if self.state.verified() {
790            return Ok(());
791        }
792        self.record(CredentialRotationTransition::Verified {
793            principal_id,
794            api_key_id,
795            generation,
796        })
797        .await
798    }
799
800    pub async fn complete(&mut self) -> Result<(), CredentialRotationError> {
801        if !self.state.completed() {
802            self.record(CredentialRotationTransition::Completed).await?;
803        }
804        Ok(())
805    }
806
807    async fn publish(&mut self, kind: FileKind) -> Result<(), CredentialRotationError> {
808        #[cfg(not(any(target_os = "linux", target_os = "macos")))]
809        {
810            let _ = kind;
811            return Err(CredentialRotationError::UnsupportedPlatform);
812        }
813
814        #[cfg(any(target_os = "linux", target_os = "macos"))]
815        {
816            let expected = match kind {
817                FileKind::Credential => self
818                    .state
819                    .credential_staged()
820                    .cloned()
821                    .ok_or(CredentialRotationError::InvalidState)?,
822                FileKind::Profile => self
823                    .state
824                    .profile_staged()
825                    .cloned()
826                    .ok_or(CredentialRotationError::InvalidState)?,
827            };
828            let published = match kind {
829                FileKind::Credential => self.state.credential_published(),
830                FileKind::Profile => self.state.profile_published(),
831            };
832            match kind {
833                FileKind::Credential => self.credential_publisher.publish(expected.clone()).await,
834                FileKind::Profile => self.profile_publisher.publish(expected.clone()).await,
835            }
836            .map_err(map_publication_error)?;
837            if published.is_none() {
838                let transition = match kind {
839                    FileKind::Credential => {
840                        CredentialRotationTransition::CredentialPublished { file: expected.clone() }
841                    }
842                    FileKind::Profile => CredentialRotationTransition::ProfilePublished { file: expected.clone() },
843                };
844                self.record(transition).await?;
845            }
846            Ok(())
847        }
848    }
849
850    async fn record(&mut self, transition: CredentialRotationTransition) -> Result<(), CredentialRotationError> {
851        self.journal
852            .append(transition.clone())
853            .await
854            .map_err(CredentialRotationError::Journal)?;
855        self.transitions.push(transition);
856        self.state = CredentialRotationState::replay(&self.transitions).map_err(CredentialRotationError::Journal)?;
857        Ok(())
858    }
859}
860
861#[derive(Clone, Copy)]
862enum FileKind {
863    Credential,
864    Profile,
865}
866
867#[derive(Debug)]
868pub enum CredentialRotationError {
869    UnsupportedPlatform,
870    InvalidPath,
871    InsecurePath,
872    DestinationExists,
873    Conflict,
874    InvalidState,
875    InvalidMaterial,
876    Io,
877    RandomUnavailable,
878    WorkerUnavailable,
879    Journal(WorkflowJournalError),
880    Credential(CredentialProviderError),
881    Activation(CredentialActivationError),
882}
883
884impl CredentialRotationError {
885    #[must_use]
886    pub const fn code(&self) -> &'static str {
887        match self {
888            Self::UnsupportedPlatform => "unsupported_platform",
889            Self::InvalidPath => "invalid_path",
890            Self::InsecurePath => "insecure_path",
891            Self::DestinationExists => "destination_exists",
892            Self::Conflict => "conflict",
893            Self::InvalidState => "invalid_state",
894            Self::InvalidMaterial => "invalid_material",
895            Self::Io => "io",
896            Self::RandomUnavailable => "random_unavailable",
897            Self::WorkerUnavailable => "worker_unavailable",
898            Self::Journal(error) => error.code(),
899            Self::Credential(_) => "credential",
900            Self::Activation(_) => "activation",
901        }
902    }
903}
904
905impl Display for CredentialRotationError {
906    fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
907        formatter.write_str(match self {
908            Self::UnsupportedPlatform => "the platform cannot provide atomic credential publication",
909            Self::InvalidPath => "a credential rotation path is not canonical and absolute",
910            Self::InsecurePath => "a credential rotation path is symlinked, foreign-owned, or broadly writable",
911            Self::DestinationExists => "a create-new credential rotation destination already exists",
912            Self::Conflict => "credential rotation state conflicts with the requested transaction",
913            Self::InvalidState => "credential rotation transitions are incomplete or out of order",
914            Self::InvalidMaterial => "credential rotation material is invalid",
915            Self::Io => "credential rotation filesystem operation failed",
916            Self::RandomUnavailable => "secure random credential generation failed",
917            Self::WorkerUnavailable => "credential rotation filesystem worker did not complete",
918            Self::Journal(_) => "credential rotation journal failed",
919            Self::Credential(_) => "credential rotation material decoding failed",
920            Self::Activation(_) => "credential rotation verifier derivation failed",
921        })
922    }
923}
924
925impl Error for CredentialRotationError {}
926
927pub fn credential_file_reference(path: &str) -> Result<String, CredentialRotationError> {
928    if !valid_output_path(path) || !valid_file_reference_path(path) {
929        return Err(CredentialRotationError::InvalidPath);
930    }
931    Ok(format!("file://{path}"))
932}
933
934fn valid_file_reference_path(path: &str) -> bool {
935    path.len() <= 505
936        && path
937            .bytes()
938            .all(|byte| byte.is_ascii_graphic() && !matches!(byte, b'@' | b'=' | b'?' | b'#'))
939}
940
941fn action_id(domain: &[u8], transaction_id: WorkflowTransactionId, attempt: u32) -> Digest {
942    let mut context = digest::Context::new(&digest::SHA256);
943    context.update(domain);
944    context.update(&transaction_id.as_bytes());
945    context.update(&attempt.to_be_bytes());
946    Digest {
947        value: lower_hex(context.finish().as_ref()),
948    }
949}
950
951fn canonical_u64(value: &U64) -> Option<u64> {
952    value
953        .value
954        .parse::<u64>()
955        .ok()
956        .filter(|parsed| parsed.to_string() == value.value)
957}
958
959fn valid_identity(value: &Identity) -> bool {
960    value.value.len() == 32
961        && value
962            .value
963            .bytes()
964            .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
965        && u128::from_str_radix(&value.value, 16).is_ok_and(|value| value != 0)
966}
967
968fn valid_sha256(value: &[u8; 32]) -> bool {
969    value.iter().any(|byte| *byte != 0)
970}
971
972fn valid_digest(value: &Digest) -> bool {
973    value.value.len() == 64
974        && value
975            .value
976            .bytes()
977            .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
978        && value.value.bytes().any(|byte| byte != b'0')
979}
980
981fn valid_reservation(value: &ServiceCredentialReservation) -> bool {
982    value.locator.authority == "service_credential_reservation"
983        && !value.locator.value.is_empty()
984        && valid_digest(&value.reservation_action_id)
985        && valid_digest(&value.activation_action_id)
986        && valid_identity(&value.principal_id)
987        && valid_identity(&value.api_key_id)
988        && canonical_u64(&value.generation).is_some_and(|generation| generation > 0)
989        && canonical_u64(&value.reservation_expires_at).is_some_and(|expiry| expiry > 0)
990        && canonical_u64(&value.credential_expires_at).is_some_and(|expiry| expiry > 0)
991        && value.verifier_algorithm == "sha256_identity_bound_v1"
992        && value.verifier_algorithm_version == 1
993        && value.required_secret_bytes == 32
994}
995
996fn valid_file_identity(value: &PublishedFileIdentity) -> bool {
997    value.is_valid()
998}
999
1000fn map_publication_error(error: SecurePublicationError) -> CredentialRotationError {
1001    match error {
1002        SecurePublicationError::UnsupportedPlatform => CredentialRotationError::UnsupportedPlatform,
1003        SecurePublicationError::InvalidPath => CredentialRotationError::InvalidPath,
1004        SecurePublicationError::InsecurePath => CredentialRotationError::InsecurePath,
1005        SecurePublicationError::DestinationExists => CredentialRotationError::DestinationExists,
1006        SecurePublicationError::Conflict => CredentialRotationError::Conflict,
1007        SecurePublicationError::InvalidState => CredentialRotationError::InvalidState,
1008        SecurePublicationError::InvalidMaterial => CredentialRotationError::InvalidMaterial,
1009        SecurePublicationError::Io => CredentialRotationError::Io,
1010        SecurePublicationError::WorkerUnavailable => CredentialRotationError::WorkerUnavailable,
1011    }
1012}
1013
1014fn valid_output_path(value: &str) -> bool {
1015    if value.is_empty() || value.bytes().any(|byte| byte.is_ascii_control()) {
1016        return false;
1017    }
1018    let path = Path::new(value);
1019    if !path.is_absolute()
1020        || path
1021            .components()
1022            .any(|component| !matches!(component, Component::RootDir | Component::Normal(_)))
1023    {
1024        return false;
1025    }
1026    path.components().collect::<PathBuf>().as_os_str() == path.as_os_str()
1027        && path.parent().is_some()
1028        && path.file_name().is_some_and(|name| !name.is_empty())
1029}
1030
1031fn lower_hex(bytes: &[u8]) -> String {
1032    const HEX: &[u8; 16] = b"0123456789abcdef";
1033    let mut output = String::with_capacity(bytes.len().saturating_mul(2));
1034    for byte in bytes {
1035        output.push(char::from(HEX[usize::from(byte >> 4)]));
1036        output.push(char::from(HEX[usize::from(byte & 0x0f)]));
1037    }
1038    output
1039}
1040
1041#[cfg(any(target_os = "linux", target_os = "macos"))]
1042mod unix {
1043    use std::ffi::OsStr;
1044    use std::fs::File;
1045    use std::path::{Component, Path, PathBuf};
1046
1047    use aws_lc_rs::digest;
1048    use rustix::fs::{FileType, Mode, OFlags, fstat, fsync, mkdirat, open, openat};
1049
1050    use super::{
1051        CREDENTIAL_STAGE_NAME, CredentialRotationError, CredentialRotationIntent, CredentialRotationState,
1052        JOURNAL_NAME, PROFILE_STAGE_NAME, PROFILE_STAGE_PREFIX, PublishedFileIdentity, TRANSACTION_PREFIX, lower_hex,
1053    };
1054
1055    const DIRECTORY_MODE: rustix::fs::RawMode = 0o700;
1056
1057    pub(super) struct Setup {
1058        pub transaction_path: PathBuf,
1059        transaction: File,
1060        profile_stage_path: Option<PathBuf>,
1061    }
1062
1063    pub(super) struct PublicationPaths {
1064        pub transaction_path: PathBuf,
1065        pub profile_stage_path: PathBuf,
1066    }
1067
1068    pub(super) struct StagedCredentialResult {
1069        pub identity: PublishedFileIdentity,
1070        pub verifier: lodestone_wire::Digest,
1071    }
1072
1073    pub(super) fn prepare_transaction(intent: &CredentialRotationIntent) -> Result<Setup, CredentialRotationError> {
1074        let credential_path = Path::new(&intent.credential_output);
1075        let profile_path = Path::new(&intent.profile_output);
1076        let (credential_parent_path, _credential_name) = split_output(credential_path)?;
1077        let (profile_parent_path, _profile_name) = split_output(profile_path)?;
1078        let credential_parent = open_directory_chain(credential_parent_path)?;
1079        let profile_parent = open_directory_chain(profile_parent_path)?;
1080        validate_parent(&credential_parent)?;
1081        validate_parent(&profile_parent)?;
1082
1083        let key = path_key(&intent.credential_output);
1084        let transaction_name = format!("{TRANSACTION_PREFIX}{}", &lower_hex(&key)[..24]);
1085        let transaction_path = credential_parent_path.join(&transaction_name);
1086        if transaction_path == credential_path || transaction_path == profile_path {
1087            return Err(CredentialRotationError::InvalidPath);
1088        }
1089        let transaction = open_or_create_private_directory(&credential_parent, OsStr::new(&transaction_name))?;
1090        let credential_parent_stat = fstat(&credential_parent).map_err(|_| CredentialRotationError::Io)?;
1091        let profile_parent_stat = fstat(&profile_parent).map_err(|_| CredentialRotationError::Io)?;
1092        let same_parent = credential_parent_stat.st_dev == profile_parent_stat.st_dev
1093            && credential_parent_stat.st_ino == profile_parent_stat.st_ino;
1094        let profile_stage_path = if same_parent {
1095            None
1096        } else {
1097            Some(profile_parent_path.join(format!("{PROFILE_STAGE_PREFIX}{}", &lower_hex(&key)[..24])))
1098        };
1099        if profile_stage_path
1100            .as_ref()
1101            .is_some_and(|path| path == credential_path || path == profile_path)
1102        {
1103            return Err(CredentialRotationError::InvalidPath);
1104        }
1105        Ok(Setup {
1106            transaction_path,
1107            transaction,
1108            profile_stage_path,
1109        })
1110    }
1111
1112    pub(super) fn finish_transaction_setup(
1113        setup: Setup,
1114        _state: &CredentialRotationState,
1115    ) -> Result<PublicationPaths, CredentialRotationError> {
1116        let transaction_path = setup.transaction_path.clone();
1117        let profile_stage_path = setup
1118            .profile_stage_path
1119            .clone()
1120            .unwrap_or_else(|| transaction_path.clone());
1121        let (profile_stage, profile_stage_is_transaction) = match &setup.profile_stage_path {
1122            None => (
1123                setup.transaction.try_clone().map_err(|_| CredentialRotationError::Io)?,
1124                true,
1125            ),
1126            Some(path) => {
1127                let parent = path.parent().ok_or(CredentialRotationError::InvalidPath)?;
1128                let name = path.file_name().ok_or(CredentialRotationError::InvalidPath)?;
1129                let parent = open_directory_chain(parent)?;
1130                (open_or_create_private_directory(&parent, name)?, false)
1131            }
1132        };
1133        validate_known_names(&setup.transaction, true)?;
1134        if !profile_stage_is_transaction {
1135            validate_known_names(&profile_stage, false)?;
1136        }
1137        Ok(PublicationPaths {
1138            transaction_path,
1139            profile_stage_path,
1140        })
1141    }
1142
1143    fn split_output(path: &Path) -> Result<(&Path, &OsStr), CredentialRotationError> {
1144        Ok((
1145            path.parent().ok_or(CredentialRotationError::InvalidPath)?,
1146            path.file_name().ok_or(CredentialRotationError::InvalidPath)?,
1147        ))
1148    }
1149
1150    fn open_directory_chain(path: &Path) -> Result<File, CredentialRotationError> {
1151        let mut components = path.components();
1152        if !matches!(components.next(), Some(Component::RootDir)) {
1153            return Err(CredentialRotationError::InvalidPath);
1154        }
1155        let flags = directory_flags();
1156        let mut current = File::from(open("/", flags, Mode::empty()).map_err(map_open_error)?);
1157        for component in components {
1158            let Component::Normal(name) = component else {
1159                return Err(CredentialRotationError::InvalidPath);
1160            };
1161            current = File::from(openat(&current, name, flags, Mode::empty()).map_err(map_open_error)?);
1162        }
1163        Ok(current)
1164    }
1165
1166    fn open_or_create_private_directory(parent: &File, name: &OsStr) -> Result<File, CredentialRotationError> {
1167        match mkdirat(parent, name, Mode::from_raw_mode(DIRECTORY_MODE)) {
1168            Ok(()) => fsync(parent).map_err(|_| CredentialRotationError::Io)?,
1169            Err(error) if error == rustix::io::Errno::EXIST => {}
1170            Err(_) => return Err(CredentialRotationError::Io),
1171        }
1172        let directory = File::from(openat(parent, name, directory_flags(), Mode::empty()).map_err(map_open_error)?);
1173        validate_private_directory(&directory)?;
1174        Ok(directory)
1175    }
1176
1177    const fn directory_flags() -> OFlags {
1178        OFlags::RDONLY
1179            .union(OFlags::DIRECTORY)
1180            .union(OFlags::CLOEXEC)
1181            .union(OFlags::NOFOLLOW)
1182    }
1183
1184    fn validate_parent(parent: &File) -> Result<(), CredentialRotationError> {
1185        let stat = fstat(parent).map_err(|_| CredentialRotationError::Io)?;
1186        if FileType::from_raw_mode(stat.st_mode) != FileType::Directory
1187            || stat.st_uid != rustix::process::geteuid().as_raw()
1188            || stat.st_mode & 0o022 != 0
1189        {
1190            return Err(CredentialRotationError::InsecurePath);
1191        }
1192        Ok(())
1193    }
1194
1195    fn validate_private_directory(directory: &File) -> Result<(), CredentialRotationError> {
1196        let stat = fstat(directory).map_err(|_| CredentialRotationError::Io)?;
1197        if FileType::from_raw_mode(stat.st_mode) != FileType::Directory
1198            || stat.st_uid != rustix::process::geteuid().as_raw()
1199            || stat.st_mode & 0o777 != DIRECTORY_MODE
1200        {
1201            return Err(CredentialRotationError::InsecurePath);
1202        }
1203        Ok(())
1204    }
1205
1206    fn validate_known_names(directory: &File, transaction: bool) -> Result<(), CredentialRotationError> {
1207        let entries = rustix::fs::Dir::read_from(directory).map_err(|_| CredentialRotationError::Io)?;
1208        for entry in entries {
1209            let entry = entry.map_err(|_| CredentialRotationError::Io)?;
1210            let name = entry.file_name().to_bytes();
1211            if matches!(name, b"." | b"..") {
1212                continue;
1213            }
1214            let allowed = if transaction {
1215                name == JOURNAL_NAME.as_bytes()
1216                    || name == CREDENTIAL_STAGE_NAME.as_bytes()
1217                    || name == PROFILE_STAGE_NAME.as_bytes()
1218            } else {
1219                name == PROFILE_STAGE_NAME.as_bytes()
1220            };
1221            if !allowed {
1222                return Err(CredentialRotationError::Conflict);
1223            }
1224        }
1225        Ok(())
1226    }
1227
1228    fn path_key(path: &str) -> [u8; 32] {
1229        let mut context = digest::Context::new(&digest::SHA256);
1230        context.update(b"lodestone/credential-rotation/path/v1\0");
1231        context.update(path.as_bytes());
1232        let observed = context.finish();
1233        let mut output = [0; 32];
1234        output.copy_from_slice(observed.as_ref());
1235        output
1236    }
1237
1238    fn map_open_error(error: rustix::io::Errno) -> CredentialRotationError {
1239        match error {
1240            rustix::io::Errno::LOOP | rustix::io::Errno::NOTDIR => CredentialRotationError::InsecurePath,
1241            rustix::io::Errno::NOENT => CredentialRotationError::InvalidPath,
1242            _ => CredentialRotationError::Io,
1243        }
1244    }
1245}
1246
1247#[cfg(test)]
1248mod tests {
1249    use super::*;
1250    use crate::{CredentialProvider, FileCredentialProvider};
1251
1252    fn identity(value: u128) -> Identity {
1253        Identity {
1254            value: format!("{value:032x}"),
1255        }
1256    }
1257
1258    fn digest(value: u8) -> Digest {
1259        Digest {
1260            value: format!("{value:02x}").repeat(32),
1261        }
1262    }
1263
1264    fn reservation() -> ServiceCredentialReservation {
1265        ServiceCredentialReservation {
1266            locator: lodestone_wire::Locator {
1267                authority: "service_credential_reservation".to_owned(),
1268                value: "locator".to_owned(),
1269            },
1270            reservation_action_id: digest(1),
1271            activation_action_id: digest(2),
1272            principal_id: identity(3),
1273            api_key_id: identity(4),
1274            generation: U64 { value: "2".to_owned() },
1275            reservation_expires_at: U64 {
1276                value: "100".to_owned(),
1277            },
1278            credential_expires_at: U64 {
1279                value: "200".to_owned(),
1280            },
1281            verifier_algorithm: "sha256_identity_bound_v1".to_owned(),
1282            verifier_algorithm_version: 1,
1283            required_secret_bytes: 32,
1284        }
1285    }
1286
1287    #[test]
1288    fn reducer_accepts_only_the_atomic_publication_order() -> Result<(), WorkflowJournalError> {
1289        let file = PublishedFileIdentity {
1290            device: 1,
1291            inode: 2,
1292            size: 3,
1293            sha256: [4; 32],
1294        };
1295        let reservation = reservation();
1296        let transitions = vec![
1297            CredentialRotationTransition::ActionsAllocated {
1298                attempt: 0,
1299                reservation_action_id: reservation.reservation_action_id.clone(),
1300                activation_action_id: reservation.activation_action_id.clone(),
1301            },
1302            CredentialRotationTransition::ReservationRecorded {
1303                reservation: reservation.clone(),
1304            },
1305            CredentialRotationTransition::CredentialStaged { file: file.clone() },
1306            CredentialRotationTransition::Activated {
1307                principal_id: reservation.principal_id.clone(),
1308                api_key_id: reservation.api_key_id.clone(),
1309                generation: reservation.generation.clone(),
1310            },
1311            CredentialRotationTransition::CredentialPublished { file: file.clone() },
1312            CredentialRotationTransition::ProfileStaged { file: file.clone() },
1313            CredentialRotationTransition::ProfilePublished { file },
1314            CredentialRotationTransition::Verified {
1315                principal_id: reservation.principal_id,
1316                api_key_id: reservation.api_key_id,
1317                generation: reservation.generation,
1318            },
1319            CredentialRotationTransition::Completed,
1320        ];
1321        let state = CredentialRotationState::replay(&transitions)?;
1322        assert!(state.completed());
1323        assert_eq!(
1324            CredentialRotationState::replay(&transitions[1..]),
1325            Err(WorkflowJournalError::Corrupt)
1326        );
1327        Ok(())
1328    }
1329
1330    #[test]
1331    fn reducer_retires_only_the_current_unactivated_attempt() -> Result<(), WorkflowJournalError> {
1332        let first_reservation = reservation();
1333        let second_reservation = ServiceCredentialReservation {
1334            reservation_action_id: digest(5),
1335            activation_action_id: digest(6),
1336            ..reservation()
1337        };
1338        let transitions = vec![
1339            CredentialRotationTransition::ActionsAllocated {
1340                attempt: 0,
1341                reservation_action_id: first_reservation.reservation_action_id.clone(),
1342                activation_action_id: first_reservation.activation_action_id.clone(),
1343            },
1344            CredentialRotationTransition::ReservationExpiredUnactivated {
1345                reservation_action_id: first_reservation.reservation_action_id,
1346            },
1347            CredentialRotationTransition::ActionsAllocated {
1348                attempt: 1,
1349                reservation_action_id: second_reservation.reservation_action_id.clone(),
1350                activation_action_id: second_reservation.activation_action_id.clone(),
1351            },
1352            CredentialRotationTransition::ReservationRecorded {
1353                reservation: second_reservation,
1354            },
1355        ];
1356        let state = CredentialRotationState::replay(&transitions)?;
1357        assert_eq!(state.attempt(), 1);
1358        assert!(state.reservation().is_some());
1359        assert_eq!(
1360            CredentialRotationState::replay(&[
1361                transitions[0].clone(),
1362                CredentialRotationTransition::ReservationExpiredUnactivated {
1363                    reservation_action_id: digest(7),
1364                },
1365            ]),
1366            Err(WorkflowJournalError::Corrupt)
1367        );
1368        Ok(())
1369    }
1370
1371    #[test]
1372    fn output_paths_are_absolute_canonical_and_distinct() {
1373        assert!(valid_output_path("/var/lib/lodestone/credential.json"));
1374        assert!(!valid_output_path("relative/credential.json"));
1375        assert!(!valid_output_path("/var/lib/../credential.json"));
1376        assert!(!valid_output_path("/var/lib//credential.json"));
1377    }
1378
1379    #[test]
1380    fn intent_rejects_a_zero_profile_digest() {
1381        let intent = CredentialRotationIntent {
1382            format_version: CREDENTIAL_ROTATION_FORMAT_VERSION,
1383            current_profile_sha256: [0; 32],
1384            current_profile_generation: U64 { value: "1".to_owned() },
1385            cluster_id: identity(10),
1386            collection_id: identity(11),
1387            current_api_key_id: identity(12),
1388            current_credential_generation: U64 { value: "1".to_owned() },
1389            credential_expires_at: U64 {
1390                value: "200".to_owned(),
1391            },
1392            overlap_until: U64 {
1393                value: "150".to_owned(),
1394            },
1395            reservation_expires_at: U64 {
1396                value: "100".to_owned(),
1397            },
1398            credential_output: "/var/lib/lodestone/credential.json".to_owned(),
1399            profile_output: "/var/lib/lodestone/profile.json".to_owned(),
1400        };
1401        assert_eq!(intent.validate(), Err(WorkflowJournalError::InvalidIntent));
1402    }
1403
1404    #[cfg(any(target_os = "linux", target_os = "macos"))]
1405    #[tokio::test]
1406    async fn atomic_store_recovers_a_link_before_its_journal_record() -> Result<(), Box<dyn Error>> {
1407        use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
1408
1409        struct Cleanup(PathBuf);
1410        impl Drop for Cleanup {
1411            fn drop(&mut self) {
1412                let _ = std::fs::remove_dir_all(&self.0);
1413            }
1414        }
1415
1416        let mut nonce = [0_u8; 8];
1417        getrandom::fill(&mut nonce)?;
1418        let root =
1419            std::fs::canonicalize(std::env::temp_dir())?.join(format!("lodestone-rotation-test-{}", lower_hex(&nonce)));
1420        std::fs::create_dir(&root)?;
1421        std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700))?;
1422        let _cleanup = Cleanup(root.clone());
1423        let credential_path = root.join("credential.json");
1424        let profile_path = root.join("profile.json");
1425        let intent = CredentialRotationIntent {
1426            format_version: CREDENTIAL_ROTATION_FORMAT_VERSION,
1427            current_profile_sha256: [9; 32],
1428            current_profile_generation: U64 { value: "1".to_owned() },
1429            cluster_id: identity(10),
1430            collection_id: identity(11),
1431            current_api_key_id: identity(12),
1432            current_credential_generation: U64 { value: "1".to_owned() },
1433            credential_expires_at: U64 {
1434                value: "200".to_owned(),
1435            },
1436            overlap_until: U64 {
1437                value: "150".to_owned(),
1438            },
1439            reservation_expires_at: U64 {
1440                value: "100".to_owned(),
1441            },
1442            credential_output: credential_path.to_string_lossy().into_owned(),
1443            profile_output: profile_path.to_string_lossy().into_owned(),
1444        };
1445        let mut store = AtomicFileCredentialMaterialStore::open_or_create(intent.clone()).await?;
1446        let first_action_ids = store.ensure_action_ids().await?;
1447        let first_reservation = ServiceCredentialReservation {
1448            reservation_action_id: first_action_ids.0,
1449            activation_action_id: first_action_ids.1,
1450            ..reservation()
1451        };
1452        store.record_reservation(first_reservation).await?;
1453        let first_staged = store.stage_credential().await?;
1454        store.retire_expired_unactivated().await?;
1455        assert_eq!(store.state().attempt(), 1);
1456        assert!(store.state().action_ids().is_none());
1457        assert!(store.state().reservation().is_none());
1458        assert!(store.state().credential_staged().is_none());
1459
1460        let second_action_ids = store.ensure_action_ids().await?;
1461        assert_ne!(
1462            second_action_ids,
1463            action_ids_for_attempt(store.journal.transaction_id(), 0)
1464        );
1465        let reservation = ServiceCredentialReservation {
1466            reservation_action_id: second_action_ids.0,
1467            activation_action_id: second_action_ids.1,
1468            ..reservation()
1469        };
1470        store.record_reservation(reservation.clone()).await?;
1471        let second_staged = store.stage_credential().await?;
1472        assert_ne!(first_staged.verifier(), second_staged.verifier());
1473        store
1474            .record_activation(
1475                reservation.principal_id.clone(),
1476                reservation.api_key_id.clone(),
1477                reservation.generation.clone(),
1478            )
1479            .await?;
1480        let expected = store
1481            .state()
1482            .credential_staged()
1483            .cloned()
1484            .ok_or(CredentialRotationError::InvalidState)?;
1485        store.credential_publisher.publish(expected).await?;
1486        drop(store);
1487
1488        let mut resumed = AtomicFileCredentialMaterialStore::open_or_create(intent.clone()).await?;
1489        assert!(resumed.state().credential_published().is_none());
1490        resumed.publish_credential().await?;
1491        let metadata = std::fs::metadata(&credential_path)?;
1492        assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
1493        assert_eq!(metadata.nlink(), 1);
1494        let reference = credential_file_reference(&credential_path.to_string_lossy())?;
1495        let material = FileCredentialProvider.provide(&reference).await?;
1496        assert_eq!(material.api_key_id(), &reservation.api_key_id);
1497
1498        let profile_bytes = br#"{"format_version":1,"redacted":true}"#.to_vec();
1499        resumed.stage_profile(profile_bytes.clone()).await?;
1500        resumed.publish_profile().await?;
1501        assert_eq!(std::fs::read(&profile_path)?, profile_bytes);
1502        resumed
1503            .record_verified(reservation.principal_id, reservation.api_key_id, reservation.generation)
1504            .await?;
1505        resumed.complete().await?;
1506        assert!(resumed.state().completed());
1507        drop(resumed);
1508        let reopened = AtomicFileCredentialMaterialStore::open_or_create(intent).await?;
1509        assert!(reopened.state().completed());
1510        Ok(())
1511    }
1512
1513    fn action_ids_for_attempt(transaction_id: WorkflowTransactionId, attempt: u32) -> (Digest, Digest) {
1514        (
1515            action_id(
1516                b"lodestone/credential-rotation/reservation/v1\0",
1517                transaction_id,
1518                attempt,
1519            ),
1520            action_id(
1521                b"lodestone/credential-rotation/activation/v1\0",
1522                transaction_id,
1523                attempt,
1524            ),
1525        )
1526    }
1527}