Skip to main content

lodestone_client/
credential.rs

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/// Reads current Core credential material from an explicit local file
92/// reference. Connection profiles retain only `file:///absolute/path`
93/// references; secret bytes never enter CLI arguments or profile JSON.
94#[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}