1use std::error::Error;
2use std::fmt::{self, Display, Formatter};
3use std::future::Future;
4use std::path::{Component, Path};
5use std::pin::Pin;
6
7#[cfg(unix)]
8use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
9use tokio::io::AsyncReadExt as _;
10
11use aws_lc_rs::digest;
12use lodestone_wire::{
13 ActivateServiceCredentialRequest, Digest, Identity, ResolveServiceCredentialReservationRequest,
14 SecurityActionOutcome, ServiceCredentialReservation, U64,
15};
16
17use crate::config::{parse_identity, parse_u64};
18use crate::{CallOptions, Client, ClientError, SubmissionDisposition};
19
20pub const API_KEY_SECRET_BYTES: usize = 32;
21const API_KEY_VERIFIER_DOMAIN: &[u8] = b"lodestone/p8/api-key/verifier/v1";
22const FILE_CREDENTIAL_FORMAT_VERSION: u16 = 1;
23const MAX_FILE_CREDENTIAL_BYTES: u64 = 4 * 1024;
24
25pub struct SecretBytes([u8; API_KEY_SECRET_BYTES]);
26
27impl SecretBytes {
28 pub fn generate() -> Result<Self, CredentialProviderError> {
29 let mut bytes = [0; API_KEY_SECRET_BYTES];
30 getrandom::fill(&mut bytes).map_err(|_| CredentialProviderError::RandomUnavailable)?;
31 Ok(Self(bytes))
32 }
33
34 #[must_use]
35 pub const fn from_array(bytes: [u8; API_KEY_SECRET_BYTES]) -> Self {
36 Self(bytes)
37 }
38
39 pub(crate) const fn expose(&self) -> &[u8; API_KEY_SECRET_BYTES] {
40 &self.0
41 }
42}
43
44impl Drop for SecretBytes {
45 fn drop(&mut self) {
46 self.0.fill(0);
47 std::hint::black_box(&mut self.0);
48 }
49}
50
51pub struct CredentialMaterial {
52 api_key_id: Identity,
53 generation: U64,
54 secret: SecretBytes,
55}
56
57impl CredentialMaterial {
58 pub fn new(api_key_id: Identity, generation: U64, secret: SecretBytes) -> Result<Self, CredentialProviderError> {
59 if api_key_id.validate().is_err() || parse_u64(&generation.value).is_none_or(|value| value == 0) {
60 return Err(CredentialProviderError::InvalidMaterial);
61 }
62 Ok(Self {
63 api_key_id,
64 generation,
65 secret,
66 })
67 }
68
69 #[must_use]
70 pub const fn api_key_id(&self) -> &Identity {
71 &self.api_key_id
72 }
73
74 #[must_use]
75 pub const fn generation(&self) -> &U64 {
76 &self.generation
77 }
78
79 pub(crate) const fn secret(&self) -> &SecretBytes {
80 &self.secret
81 }
82}
83
84pub type CredentialFuture<'a> =
85 Pin<Box<dyn Future<Output = Result<CredentialMaterial, CredentialProviderError>> + Send + 'a>>;
86
87pub trait CredentialProvider: Send + Sync + 'static {
88 fn provide<'a>(&'a self, secret_reference: &'a str) -> CredentialFuture<'a>;
89}
90
91#[derive(Clone, Copy, Debug, Default)]
95pub struct FileCredentialProvider;
96
97impl CredentialProvider for FileCredentialProvider {
98 fn provide<'a>(&'a self, secret_reference: &'a str) -> CredentialFuture<'a> {
99 Box::pin(async move {
100 let path = file_credential_path(secret_reference)?;
101 let before = tokio::fs::symlink_metadata(path)
102 .await
103 .map_err(map_file_credential_error)?;
104 if !secure_credential_metadata(&before) {
105 return Err(CredentialProviderError::InvalidMaterial);
106 }
107 let file = tokio::fs::File::open(path).await.map_err(map_file_credential_error)?;
108 let opened = file.metadata().await.map_err(map_file_credential_error)?;
109 let after = tokio::fs::symlink_metadata(path)
110 .await
111 .map_err(map_file_credential_error)?;
112 if !same_credential_file(&before, &opened) || !same_credential_file(&opened, &after) {
113 return Err(CredentialProviderError::InvalidMaterial);
114 }
115 let mut bytes = Vec::new();
116 file.take(MAX_FILE_CREDENTIAL_BYTES.saturating_add(1))
117 .read_to_end(&mut bytes)
118 .await
119 .map_err(map_file_credential_error)?;
120 if bytes.is_empty() || bytes.len() > MAX_FILE_CREDENTIAL_BYTES as usize {
121 bytes.fill(0);
122 return Err(CredentialProviderError::InvalidMaterial);
123 }
124 let decoded = decode_file_credential(&bytes);
125 bytes.fill(0);
126 std::hint::black_box(&mut bytes);
127 decoded
128 })
129 }
130}
131
132#[cfg(unix)]
133fn secure_credential_metadata(metadata: &std::fs::Metadata) -> bool {
134 metadata.is_file()
135 && !metadata.file_type().is_symlink()
136 && metadata.len() > 0
137 && metadata.len() <= MAX_FILE_CREDENTIAL_BYTES
138 && metadata.permissions().mode() & 0o077 == 0
139 && metadata.uid() == rustix::process::geteuid().as_raw()
140 && metadata.nlink() == 1
141}
142
143#[cfg(not(unix))]
144fn secure_credential_metadata(_metadata: &std::fs::Metadata) -> bool {
145 false
146}
147
148#[cfg(unix)]
149fn same_credential_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
150 secure_credential_metadata(left)
151 && secure_credential_metadata(right)
152 && left.dev() == right.dev()
153 && left.ino() == right.ino()
154 && left.len() == right.len()
155}
156
157#[cfg(not(unix))]
158fn same_credential_file(_left: &std::fs::Metadata, _right: &std::fs::Metadata) -> bool {
159 false
160}
161
162#[derive(serde::Deserialize, serde::Serialize)]
163#[serde(deny_unknown_fields)]
164struct FileCredential<'a> {
165 format_version: u16,
166 #[serde(borrow)]
167 api_key_id: &'a str,
168 #[serde(borrow)]
169 generation: &'a str,
170 #[serde(borrow)]
171 secret_hex: &'a str,
172}
173
174fn file_credential_path(reference: &str) -> Result<&Path, CredentialProviderError> {
175 let value = reference
176 .strip_prefix("file://")
177 .ok_or(CredentialProviderError::InvalidMaterial)?;
178 let path = Path::new(value);
179 if value.is_empty()
180 || value.bytes().any(|byte| byte.is_ascii_control())
181 || !path.is_absolute()
182 || path
183 .components()
184 .any(|component| matches!(component, Component::ParentDir))
185 {
186 return Err(CredentialProviderError::InvalidMaterial);
187 }
188 Ok(path)
189}
190
191fn map_file_credential_error(error: std::io::Error) -> CredentialProviderError {
192 if error.kind() == std::io::ErrorKind::NotFound {
193 CredentialProviderError::NotFound
194 } else {
195 CredentialProviderError::Unavailable
196 }
197}
198
199pub(crate) fn decode_file_credential(bytes: &[u8]) -> Result<CredentialMaterial, CredentialProviderError> {
200 if bytes.is_empty() || bytes.len() > 4 * 1024 {
201 return Err(CredentialProviderError::InvalidMaterial);
202 }
203 let value: FileCredential<'_> =
204 serde_json::from_slice(bytes).map_err(|_| CredentialProviderError::InvalidMaterial)?;
205 if serde_json::to_vec(&value).map_err(|_| CredentialProviderError::InvalidMaterial)? != bytes {
206 return Err(CredentialProviderError::InvalidMaterial);
207 }
208 if value.format_version != FILE_CREDENTIAL_FORMAT_VERSION {
209 return Err(CredentialProviderError::InvalidMaterial);
210 }
211 if parse_identity(value.api_key_id).is_none() {
212 return Err(CredentialProviderError::InvalidMaterial);
213 }
214 let api_key_id = Identity {
215 value: value.api_key_id.to_owned(),
216 };
217 let generation = parse_u64(value.generation)
218 .filter(|generation| *generation > 0)
219 .ok_or(CredentialProviderError::InvalidMaterial)?;
220 let secret = decode_secret_hex(value.secret_hex)?;
221 CredentialMaterial::new(
222 api_key_id,
223 U64 {
224 value: generation.to_string(),
225 },
226 SecretBytes::from_array(secret),
227 )
228}
229
230fn decode_secret_hex(value: &str) -> Result<[u8; API_KEY_SECRET_BYTES], CredentialProviderError> {
231 if value.len() != API_KEY_SECRET_BYTES * 2
232 || value
233 .bytes()
234 .any(|byte| !byte.is_ascii_digit() && !(b'a'..=b'f').contains(&byte))
235 {
236 return Err(CredentialProviderError::InvalidMaterial);
237 }
238 let mut secret = [0_u8; API_KEY_SECRET_BYTES];
239 for (index, chunk) in value.as_bytes().chunks_exact(2).enumerate() {
240 secret[index] = decode_hex_nibble(chunk[0])
241 .and_then(|high| decode_hex_nibble(chunk[1]).map(|low| (high << 4) | low))
242 .ok_or(CredentialProviderError::InvalidMaterial)?;
243 }
244 Ok(secret)
245}
246
247const fn decode_hex_nibble(value: u8) -> Option<u8> {
248 match value {
249 b'0'..=b'9' => Some(value - b'0'),
250 b'a'..=b'f' => Some(value - b'a' + 10),
251 _ => None,
252 }
253}
254
255#[derive(Clone, Copy, Debug, Eq, PartialEq)]
256pub enum CredentialProviderError {
257 Unavailable,
258 NotFound,
259 InvalidMaterial,
260 RandomUnavailable,
261}
262
263impl Display for CredentialProviderError {
264 fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
265 formatter.write_str(match self {
266 Self::Unavailable => "Core credential provider is unavailable",
267 Self::NotFound => "Core credential material is absent",
268 Self::InvalidMaterial => "Core credential material does not match its binding",
269 Self::RandomUnavailable => "secure random credential generation is unavailable",
270 })
271 }
272}
273
274impl Error for CredentialProviderError {}
275
276#[derive(Clone, Debug, PartialEq)]
277pub struct CredentialActivationPlan {
278 pub profile_generation: u64,
279 pub secret_reference: String,
280 pub reservation_action_id: Digest,
281 pub activation_action_id: Digest,
282 pub reservation: ServiceCredentialReservation,
283}
284
285impl CredentialActivationPlan {
286 fn validate(&self) -> Result<(), CredentialActivationError> {
287 if self.profile_generation == 0
288 || self.secret_reference.is_empty()
289 || self.secret_reference.len() > 512
290 || self.reservation_action_id.validate().is_err()
291 || self.activation_action_id.validate().is_err()
292 || self.reservation_action_id == self.activation_action_id
293 || self.reservation.reservation_action_id != self.reservation_action_id
294 || self.reservation.activation_action_id != self.activation_action_id
295 || self.reservation.locator.authority != "service_credential_reservation"
296 || self.reservation.locator.value.is_empty()
297 || self.reservation.locator.value.len() > 16 * 1024
298 || self
299 .reservation
300 .locator
301 .value
302 .bytes()
303 .any(|byte| byte.is_ascii_control())
304 || self.reservation.principal_id.validate().is_err()
305 || self.reservation.api_key_id.validate().is_err()
306 || parse_u64(&self.reservation.generation.value).is_none_or(|generation| generation == 0)
307 || parse_u64(&self.reservation.reservation_expires_at.value)
308 .zip(parse_u64(&self.reservation.credential_expires_at.value))
309 .is_none_or(|(reservation_expiry, credential_expiry)| {
310 reservation_expiry == 0 || credential_expiry <= reservation_expiry
311 })
312 || self.reservation.required_secret_bytes != u32::try_from(API_KEY_SECRET_BYTES).unwrap_or(u32::MAX)
313 || self.reservation.verifier_algorithm != "sha256_identity_bound_v1"
314 || self.reservation.verifier_algorithm_version != 1
315 {
316 return Err(CredentialActivationError::InvalidReservation);
317 }
318 Ok(())
319 }
320}
321
322pub struct PendingOperationalCredential {
323 pub plan: CredentialActivationPlan,
324 pub secret: SecretBytes,
325}
326
327#[derive(Clone, Debug, PartialEq)]
328pub struct ActiveOperationalCredential {
329 pub profile_generation: u64,
330 pub secret_reference: String,
331 pub reservation_action_id: Digest,
332 pub activation_action_id: Digest,
333 pub principal_id: Identity,
334 pub api_key_id: Identity,
335 pub generation: U64,
336}
337
338#[derive(Clone, Debug, PartialEq)]
339pub enum StoredCredentialTransition {
340 Pending {
341 profile_generation: u64,
342 secret_reference: String,
343 reservation_action_id: Digest,
344 activation_action_id: Digest,
345 api_key_id: Identity,
346 generation: U64,
347 },
348 Active(ActiveOperationalCredential),
349 Retired {
350 profile_generation: u64,
351 secret_reference: String,
352 api_key_id: Identity,
353 generation: U64,
354 },
355}
356
357pub type SecretStoreFuture<'a> =
358 Pin<Box<dyn Future<Output = Result<StoredCredentialTransition, CredentialMaterialStoreError>> + Send + 'a>>;
359
360pub trait OperationalCredentialMaterialStore: CredentialProvider {
361 fn stage_pending<'a>(&'a self, pending: PendingOperationalCredential) -> SecretStoreFuture<'a>;
362
363 fn mark_active<'a>(&'a self, active: ActiveOperationalCredential) -> SecretStoreFuture<'a>;
364
365 fn mark_retired<'a>(
366 &'a self,
367 profile_generation: u64,
368 secret_reference: &'a str,
369 api_key_id: &'a Identity,
370 generation: &'a U64,
371 ) -> SecretStoreFuture<'a>;
372}
373
374pub type EphemeralDeliveryFuture<'a> =
375 Pin<Box<dyn Future<Output = Result<(), CredentialMaterialStoreError>> + Send + 'a>>;
376
377pub trait EphemeralCredentialDelivery: Send + Sync + 'static {
378 fn deliver_once<'a>(
379 &'a self,
380 active: ActiveOperationalCredential,
381 secret: SecretBytes,
382 ) -> EphemeralDeliveryFuture<'a>;
383}
384
385#[derive(Clone, Copy, Debug, Eq, PartialEq)]
386pub enum CredentialMaterialStoreError {
387 Unavailable,
388 CasConflict,
389 DeliveryFailed,
390}
391
392impl Display for CredentialMaterialStoreError {
393 fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
394 formatter.write_str(match self {
395 Self::Unavailable => "external credential material store is unavailable",
396 Self::CasConflict => "external credential material generation CAS conflicted",
397 Self::DeliveryFailed => "ephemeral credential one-time delivery failed",
398 })
399 }
400}
401
402impl Error for CredentialMaterialStoreError {}
403
404impl Client {
405 pub async fn activate_operational_credential<S>(
406 &self,
407 plan: CredentialActivationPlan,
408 store: &S,
409 options: CallOptions,
410 ) -> Result<ActiveOperationalCredential, CredentialActivationError>
411 where
412 S: OperationalCredentialMaterialStore,
413 {
414 plan.validate()?;
415 let secret = SecretBytes::generate().map_err(CredentialActivationError::Credential)?;
416 let verifier = credential_verifier(&plan.reservation.api_key_id, &secret)?;
417 let pending = PendingOperationalCredential {
418 plan: plan.clone(),
419 secret,
420 };
421 let staged = store
422 .stage_pending(pending)
423 .await
424 .map_err(CredentialActivationError::Store)?;
425 validate_pending_transition(&plan, &staged)?;
426 let active = self.activate_or_resolve(plan, verifier, options).await?;
427 let committed = store
428 .mark_active(active.clone())
429 .await
430 .map_err(CredentialActivationError::Store)?;
431 if committed != StoredCredentialTransition::Active(active.clone()) {
432 return Err(CredentialActivationError::Store(
433 CredentialMaterialStoreError::CasConflict,
434 ));
435 }
436 Ok(active)
437 }
438
439 pub async fn activate_ephemeral_credential<D>(
440 &self,
441 plan: CredentialActivationPlan,
442 delivery: &D,
443 options: CallOptions,
444 ) -> Result<ActiveOperationalCredential, CredentialActivationError>
445 where
446 D: EphemeralCredentialDelivery,
447 {
448 plan.validate()?;
449 let secret = SecretBytes::generate().map_err(CredentialActivationError::Credential)?;
450 let verifier = credential_verifier(&plan.reservation.api_key_id, &secret)?;
451 let active = self.activate_or_resolve(plan, verifier, options).await?;
452 delivery
453 .deliver_once(active.clone(), secret)
454 .await
455 .map_err(CredentialActivationError::Store)?;
456 Ok(active)
457 }
458
459 async fn activate_or_resolve(
460 &self,
461 plan: CredentialActivationPlan,
462 verifier: Digest,
463 options: CallOptions,
464 ) -> Result<ActiveOperationalCredential, CredentialActivationError> {
465 let activation = self
466 .activate_service_credential(
467 ActivateServiceCredentialRequest {
468 reservation: plan.reservation.clone(),
469 verifier,
470 },
471 options.clone(),
472 )
473 .await;
474 let outcome = match activation {
475 Ok(result) => {
476 if result.secret_escrowed {
477 return Err(CredentialActivationError::SecretEscrowed);
478 }
479 if result.locator != plan.reservation.locator {
480 return Err(CredentialActivationError::OutcomeMismatch);
481 }
482 result.outcome
483 }
484 Err(error) if error.submission_disposition() != SubmissionDisposition::NotSubmitted => {
485 let resolution = self
486 .resolve_service_credential_reservation(
487 ResolveServiceCredentialReservationRequest {
488 reservation_action_id: plan.reservation_action_id.clone(),
489 },
490 options.for_resolution(),
491 )
492 .await;
493 let resolution = match resolution {
494 Ok(resolution) => resolution,
495 Err(error) if error.is_deadline_exceeded() => {
496 return Err(CredentialActivationError::Indeterminate);
497 }
498 Err(error) => return Err(CredentialActivationError::Client(error)),
499 };
500 if resolution.reservation.as_ref() != Some(&plan.reservation) {
501 return Err(CredentialActivationError::OutcomeMismatch);
502 }
503 if resolution.state != "activated" {
504 return Err(CredentialActivationError::Indeterminate);
505 }
506 resolution.outcome.ok_or(CredentialActivationError::Indeterminate)?
507 }
508 Err(error) => return Err(CredentialActivationError::Client(error)),
509 };
510 active_from_outcome(&plan, outcome)
511 }
512}
513
514pub(crate) fn credential_verifier(
515 api_key_id: &Identity,
516 secret: &SecretBytes,
517) -> Result<Digest, CredentialActivationError> {
518 let api_key_id = parse_identity(&api_key_id.value).ok_or(CredentialActivationError::InvalidReservation)?;
519 let mut input = Vec::with_capacity(API_KEY_VERIFIER_DOMAIN.len() + 16 + API_KEY_SECRET_BYTES);
520 input.extend_from_slice(API_KEY_VERIFIER_DOMAIN);
521 input.extend_from_slice(&api_key_id.to_be_bytes());
522 input.extend_from_slice(secret.expose());
523 let observed = digest::digest(&digest::SHA256, &input);
524 input.fill(0);
525 std::hint::black_box(&mut input);
526 Ok(Digest {
527 value: lower_hex(observed.as_ref()),
528 })
529}
530
531pub(crate) fn encode_file_credential(
532 api_key_id: &Identity,
533 generation: &U64,
534 secret: &SecretBytes,
535) -> Result<Vec<u8>, CredentialProviderError> {
536 if api_key_id.validate().is_err() || parse_u64(&generation.value).is_none_or(|value| value == 0) {
537 return Err(CredentialProviderError::InvalidMaterial);
538 }
539 const HEX: &[u8; 16] = b"0123456789abcdef";
540 let mut secret_hex = [0_u8; API_KEY_SECRET_BYTES * 2];
541 for (index, byte) in secret.expose().iter().copied().enumerate() {
542 secret_hex[index * 2] = HEX[usize::from(byte >> 4)];
543 secret_hex[index * 2 + 1] = HEX[usize::from(byte & 0x0f)];
544 }
545 let secret_hex_text = std::str::from_utf8(&secret_hex).map_err(|_| CredentialProviderError::InvalidMaterial)?;
546 let value = FileCredential {
547 format_version: FILE_CREDENTIAL_FORMAT_VERSION,
548 api_key_id: &api_key_id.value,
549 generation: &generation.value,
550 secret_hex: secret_hex_text,
551 };
552 let encoded = serde_json::to_vec(&value).map_err(|_| CredentialProviderError::InvalidMaterial);
553 secret_hex.fill(0);
554 std::hint::black_box(&mut secret_hex);
555 encoded
556}
557
558fn active_from_outcome(
559 plan: &CredentialActivationPlan,
560 outcome: SecurityActionOutcome,
561) -> Result<ActiveOperationalCredential, CredentialActivationError> {
562 let SecurityActionOutcome::Activated {
563 principal_id,
564 api_key_id,
565 generation,
566 } = outcome
567 else {
568 return Err(CredentialActivationError::Indeterminate);
569 };
570 if principal_id != plan.reservation.principal_id
571 || api_key_id != plan.reservation.api_key_id
572 || generation != plan.reservation.generation
573 {
574 return Err(CredentialActivationError::OutcomeMismatch);
575 }
576 Ok(ActiveOperationalCredential {
577 profile_generation: plan.profile_generation,
578 secret_reference: plan.secret_reference.clone(),
579 reservation_action_id: plan.reservation_action_id.clone(),
580 activation_action_id: plan.activation_action_id.clone(),
581 principal_id,
582 api_key_id,
583 generation,
584 })
585}
586
587fn validate_pending_transition(
588 plan: &CredentialActivationPlan,
589 transition: &StoredCredentialTransition,
590) -> Result<(), CredentialActivationError> {
591 if matches!(
592 transition,
593 StoredCredentialTransition::Pending {
594 profile_generation,
595 secret_reference,
596 reservation_action_id,
597 activation_action_id,
598 api_key_id,
599 generation,
600 } if *profile_generation == plan.profile_generation
601 && secret_reference == &plan.secret_reference
602 && reservation_action_id == &plan.reservation_action_id
603 && activation_action_id == &plan.activation_action_id
604 && api_key_id == &plan.reservation.api_key_id
605 && generation == &plan.reservation.generation
606 ) {
607 Ok(())
608 } else {
609 Err(CredentialActivationError::Store(
610 CredentialMaterialStoreError::CasConflict,
611 ))
612 }
613}
614
615fn lower_hex(bytes: &[u8]) -> String {
616 const HEX: &[u8; 16] = b"0123456789abcdef";
617 let mut output = String::with_capacity(bytes.len().saturating_mul(2));
618 for byte in bytes {
619 output.push(char::from(HEX[usize::from(byte >> 4)]));
620 output.push(char::from(HEX[usize::from(byte & 0x0f)]));
621 }
622 output
623}
624
625#[derive(Debug)]
626pub enum CredentialActivationError {
627 InvalidReservation,
628 Credential(CredentialProviderError),
629 Store(CredentialMaterialStoreError),
630 Client(ClientError),
631 SecretEscrowed,
632 OutcomeMismatch,
633 Indeterminate,
634}
635
636impl Display for CredentialActivationError {
637 fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
638 formatter.write_str(match self {
639 Self::InvalidReservation => "Core credential reservation is invalid",
640 Self::Credential(_) => "Core credential generation failed",
641 Self::Store(_) => "external credential material transition failed",
642 Self::Client(_) => "Core credential activation or resolution failed",
643 Self::SecretEscrowed => "Core reported forbidden credential secret escrow",
644 Self::OutcomeMismatch => "Core credential activation outcome changed the reserved identity",
645 Self::Indeterminate => "Core credential activation outcome remains indeterminate",
646 })
647 }
648}
649
650impl Error for CredentialActivationError {}
651
652#[cfg(test)]
653mod tests {
654 use lodestone_wire::{Locator, ServiceCredentialReservation};
655
656 use super::*;
657
658 fn digest(value: u8) -> Digest {
659 Digest {
660 value: format!("{value:02x}").repeat(32),
661 }
662 }
663
664 fn identity(value: u128) -> Identity {
665 Identity {
666 value: format!("{value:032x}"),
667 }
668 }
669
670 fn plan() -> CredentialActivationPlan {
671 let reservation_action_id = digest(1);
672 let activation_action_id = digest(2);
673 CredentialActivationPlan {
674 profile_generation: 1,
675 secret_reference: "external://core/ui".to_owned(),
676 reservation_action_id: reservation_action_id.clone(),
677 activation_action_id: activation_action_id.clone(),
678 reservation: ServiceCredentialReservation {
679 locator: Locator {
680 authority: "service_credential_reservation".to_owned(),
681 value: "01".repeat(64),
682 },
683 reservation_action_id,
684 activation_action_id,
685 principal_id: identity(3),
686 api_key_id: identity(4),
687 generation: U64 { value: "1".to_owned() },
688 reservation_expires_at: U64 {
689 value: "100".to_owned(),
690 },
691 credential_expires_at: U64 {
692 value: "200".to_owned(),
693 },
694 verifier_algorithm: "sha256_identity_bound_v1".to_owned(),
695 verifier_algorithm_version: 1,
696 required_secret_bytes: 32,
697 },
698 }
699 }
700
701 #[test]
702 fn activation_plan_binds_both_action_ids_to_the_reservation_projection() {
703 let value = plan();
704 assert!(value.validate().is_ok());
705 let mut substituted = value;
706 substituted.activation_action_id = digest(5);
707 assert!(matches!(
708 substituted.validate(),
709 Err(CredentialActivationError::InvalidReservation)
710 ));
711
712 let mut invalid_expiry = plan();
713 invalid_expiry.reservation.credential_expires_at = U64 {
714 value: invalid_expiry.reservation.reservation_expires_at.value.clone(),
715 };
716 assert!(matches!(
717 invalid_expiry.validate(),
718 Err(CredentialActivationError::InvalidReservation)
719 ));
720 }
721
722 #[test]
723 fn activation_outcome_binds_reserved_principal_key_and_generation() {
724 let value = plan();
725 let outcome = SecurityActionOutcome::Activated {
726 principal_id: identity(9),
727 api_key_id: value.reservation.api_key_id.clone(),
728 generation: value.reservation.generation.clone(),
729 };
730 assert!(matches!(
731 active_from_outcome(&value, outcome),
732 Err(CredentialActivationError::OutcomeMismatch)
733 ));
734 }
735
736 #[test]
737 fn file_provider_contract_refuses_ambiguous_references_and_material() {
738 assert!(file_credential_path("file:///var/run/lodestone/core.json").is_ok());
739 assert!(file_credential_path("file://relative/core.json").is_err());
740 assert!(file_credential_path("file:///var/run/../secret").is_err());
741 assert!(file_credential_path("vault://core/key").is_err());
742
743 let valid = br#"{"format_version":1,"api_key_id":"00000000000000000000000000000001","generation":"1","secret_hex":"0000000000000000000000000000000000000000000000000000000000000000"}"#;
744 assert!(decode_file_credential(valid).is_ok());
745 let uppercase = br#"{"format_version":1,"api_key_id":"00000000000000000000000000000001","generation":"1","secret_hex":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}"#;
746 assert!(decode_file_credential(uppercase).is_err());
747 let newer = br#"{"format_version":2,"api_key_id":"00000000000000000000000000000001","generation":"1","secret_hex":"0000000000000000000000000000000000000000000000000000000000000000"}"#;
748 assert!(decode_file_credential(newer).is_err());
749 }
750
751 #[cfg(unix)]
752 #[tokio::test]
753 async fn file_provider_accepts_only_private_regular_files_and_refuses_symlinks()
754 -> Result<(), Box<dyn std::error::Error>> {
755 use std::os::unix::fs::{PermissionsExt as _, symlink};
756
757 struct Cleanup(std::path::PathBuf);
758 impl Drop for Cleanup {
759 fn drop(&mut self) {
760 let _ = std::fs::remove_dir_all(&self.0);
761 }
762 }
763
764 let mut nonce = [0_u8; 8];
765 getrandom::fill(&mut nonce)?;
766 let directory = std::env::temp_dir().join(format!("lodestone-ec1-credential-{}", lower_hex(&nonce)));
767 std::fs::create_dir(&directory)?;
768 let _cleanup = Cleanup(directory.clone());
769 let credential = directory.join("credential.json");
770 let link = directory.join("credential-link.json");
771 let hard_link = directory.join("credential-hard-link.json");
772 let bytes = br#"{"format_version":1,"api_key_id":"00000000000000000000000000000001","generation":"1","secret_hex":"0000000000000000000000000000000000000000000000000000000000000000"}"#;
773 std::fs::write(&credential, bytes)?;
774 std::fs::set_permissions(&credential, std::fs::Permissions::from_mode(0o600))?;
775 let reference = format!("file://{}", credential.display());
776 assert!(FileCredentialProvider.provide(&reference).await.is_ok());
777
778 std::fs::set_permissions(&credential, std::fs::Permissions::from_mode(0o644))?;
779 assert!(matches!(
780 FileCredentialProvider.provide(&reference).await,
781 Err(CredentialProviderError::InvalidMaterial)
782 ));
783 std::fs::set_permissions(&credential, std::fs::Permissions::from_mode(0o600))?;
784 std::fs::hard_link(&credential, &hard_link)?;
785 assert!(matches!(
786 FileCredentialProvider.provide(&reference).await,
787 Err(CredentialProviderError::InvalidMaterial)
788 ));
789 std::fs::remove_file(&hard_link)?;
790 symlink(&credential, &link)?;
791 let link_reference = format!("file://{}", link.display());
792 assert!(matches!(
793 FileCredentialProvider.provide(&link_reference).await,
794 Err(CredentialProviderError::InvalidMaterial)
795 ));
796 Ok(())
797 }
798}