1use 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
368pub 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
483pub 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(¤t, 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}