use std::error::Error;
use std::fmt::{self, Display, Formatter};
use std::future::Future;
use std::path::{Component, Path};
use std::pin::Pin;
#[cfg(unix)]
use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
use tokio::io::AsyncReadExt as _;
use aws_lc_rs::digest;
use lodestone_wire::{
ActivateServiceCredentialRequest, Digest, Identity, ResolveServiceCredentialReservationRequest,
SecurityActionOutcome, ServiceCredentialReservation, U64,
};
use crate::config::{parse_identity, parse_u64};
use crate::{CallOptions, Client, ClientError, SubmissionDisposition};
pub const API_KEY_SECRET_BYTES: usize = 32;
const API_KEY_VERIFIER_DOMAIN: &[u8] = b"lodestone/p8/api-key/verifier/v1";
const FILE_CREDENTIAL_FORMAT_VERSION: u16 = 1;
const MAX_FILE_CREDENTIAL_BYTES: u64 = 4 * 1024;
pub struct SecretBytes([u8; API_KEY_SECRET_BYTES]);
impl SecretBytes {
pub fn generate() -> Result<Self, CredentialProviderError> {
let mut bytes = [0; API_KEY_SECRET_BYTES];
getrandom::fill(&mut bytes).map_err(|_| CredentialProviderError::RandomUnavailable)?;
Ok(Self(bytes))
}
#[must_use]
pub const fn from_array(bytes: [u8; API_KEY_SECRET_BYTES]) -> Self {
Self(bytes)
}
pub(crate) const fn expose(&self) -> &[u8; API_KEY_SECRET_BYTES] {
&self.0
}
}
impl Drop for SecretBytes {
fn drop(&mut self) {
self.0.fill(0);
std::hint::black_box(&mut self.0);
}
}
pub struct CredentialMaterial {
api_key_id: Identity,
generation: U64,
secret: SecretBytes,
}
impl CredentialMaterial {
pub fn new(api_key_id: Identity, generation: U64, secret: SecretBytes) -> Result<Self, CredentialProviderError> {
if api_key_id.validate().is_err() || parse_u64(&generation.value).is_none_or(|value| value == 0) {
return Err(CredentialProviderError::InvalidMaterial);
}
Ok(Self {
api_key_id,
generation,
secret,
})
}
#[must_use]
pub const fn api_key_id(&self) -> &Identity {
&self.api_key_id
}
#[must_use]
pub const fn generation(&self) -> &U64 {
&self.generation
}
pub(crate) const fn secret(&self) -> &SecretBytes {
&self.secret
}
}
pub type CredentialFuture<'a> =
Pin<Box<dyn Future<Output = Result<CredentialMaterial, CredentialProviderError>> + Send + 'a>>;
pub trait CredentialProvider: Send + Sync + 'static {
fn provide<'a>(&'a self, secret_reference: &'a str) -> CredentialFuture<'a>;
}
#[derive(Clone, Copy, Debug, Default)]
pub struct FileCredentialProvider;
impl CredentialProvider for FileCredentialProvider {
fn provide<'a>(&'a self, secret_reference: &'a str) -> CredentialFuture<'a> {
Box::pin(async move {
let path = file_credential_path(secret_reference)?;
let before = tokio::fs::symlink_metadata(path)
.await
.map_err(map_file_credential_error)?;
if !secure_credential_metadata(&before) {
return Err(CredentialProviderError::InvalidMaterial);
}
let file = tokio::fs::File::open(path).await.map_err(map_file_credential_error)?;
let opened = file.metadata().await.map_err(map_file_credential_error)?;
let after = tokio::fs::symlink_metadata(path)
.await
.map_err(map_file_credential_error)?;
if !same_credential_file(&before, &opened) || !same_credential_file(&opened, &after) {
return Err(CredentialProviderError::InvalidMaterial);
}
let mut bytes = Vec::new();
file.take(MAX_FILE_CREDENTIAL_BYTES.saturating_add(1))
.read_to_end(&mut bytes)
.await
.map_err(map_file_credential_error)?;
if bytes.is_empty() || bytes.len() > MAX_FILE_CREDENTIAL_BYTES as usize {
bytes.fill(0);
return Err(CredentialProviderError::InvalidMaterial);
}
let decoded = decode_file_credential(&bytes);
bytes.fill(0);
std::hint::black_box(&mut bytes);
decoded
})
}
}
#[cfg(unix)]
fn secure_credential_metadata(metadata: &std::fs::Metadata) -> bool {
metadata.is_file()
&& !metadata.file_type().is_symlink()
&& metadata.len() > 0
&& metadata.len() <= MAX_FILE_CREDENTIAL_BYTES
&& metadata.permissions().mode() & 0o077 == 0
&& metadata.uid() == rustix::process::geteuid().as_raw()
&& metadata.nlink() == 1
}
#[cfg(not(unix))]
fn secure_credential_metadata(_metadata: &std::fs::Metadata) -> bool {
false
}
#[cfg(unix)]
fn same_credential_file(left: &std::fs::Metadata, right: &std::fs::Metadata) -> bool {
secure_credential_metadata(left)
&& secure_credential_metadata(right)
&& left.dev() == right.dev()
&& left.ino() == right.ino()
&& left.len() == right.len()
}
#[cfg(not(unix))]
fn same_credential_file(_left: &std::fs::Metadata, _right: &std::fs::Metadata) -> bool {
false
}
#[derive(serde::Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)]
struct FileCredential<'a> {
format_version: u16,
#[serde(borrow)]
api_key_id: &'a str,
#[serde(borrow)]
generation: &'a str,
#[serde(borrow)]
secret_hex: &'a str,
}
fn file_credential_path(reference: &str) -> Result<&Path, CredentialProviderError> {
let value = reference
.strip_prefix("file://")
.ok_or(CredentialProviderError::InvalidMaterial)?;
let path = Path::new(value);
if value.is_empty()
|| value.bytes().any(|byte| byte.is_ascii_control())
|| !path.is_absolute()
|| path
.components()
.any(|component| matches!(component, Component::ParentDir))
{
return Err(CredentialProviderError::InvalidMaterial);
}
Ok(path)
}
fn map_file_credential_error(error: std::io::Error) -> CredentialProviderError {
if error.kind() == std::io::ErrorKind::NotFound {
CredentialProviderError::NotFound
} else {
CredentialProviderError::Unavailable
}
}
pub(crate) fn decode_file_credential(bytes: &[u8]) -> Result<CredentialMaterial, CredentialProviderError> {
if bytes.is_empty() || bytes.len() > 4 * 1024 {
return Err(CredentialProviderError::InvalidMaterial);
}
let value: FileCredential<'_> =
serde_json::from_slice(bytes).map_err(|_| CredentialProviderError::InvalidMaterial)?;
if serde_json::to_vec(&value).map_err(|_| CredentialProviderError::InvalidMaterial)? != bytes {
return Err(CredentialProviderError::InvalidMaterial);
}
if value.format_version != FILE_CREDENTIAL_FORMAT_VERSION {
return Err(CredentialProviderError::InvalidMaterial);
}
if parse_identity(value.api_key_id).is_none() {
return Err(CredentialProviderError::InvalidMaterial);
}
let api_key_id = Identity {
value: value.api_key_id.to_owned(),
};
let generation = parse_u64(value.generation)
.filter(|generation| *generation > 0)
.ok_or(CredentialProviderError::InvalidMaterial)?;
let secret = decode_secret_hex(value.secret_hex)?;
CredentialMaterial::new(
api_key_id,
U64 {
value: generation.to_string(),
},
SecretBytes::from_array(secret),
)
}
fn decode_secret_hex(value: &str) -> Result<[u8; API_KEY_SECRET_BYTES], CredentialProviderError> {
if value.len() != API_KEY_SECRET_BYTES * 2
|| value
.bytes()
.any(|byte| !byte.is_ascii_digit() && !(b'a'..=b'f').contains(&byte))
{
return Err(CredentialProviderError::InvalidMaterial);
}
let mut secret = [0_u8; API_KEY_SECRET_BYTES];
for (index, chunk) in value.as_bytes().chunks_exact(2).enumerate() {
secret[index] = decode_hex_nibble(chunk[0])
.and_then(|high| decode_hex_nibble(chunk[1]).map(|low| (high << 4) | low))
.ok_or(CredentialProviderError::InvalidMaterial)?;
}
Ok(secret)
}
const fn decode_hex_nibble(value: u8) -> Option<u8> {
match value {
b'0'..=b'9' => Some(value - b'0'),
b'a'..=b'f' => Some(value - b'a' + 10),
_ => None,
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CredentialProviderError {
Unavailable,
NotFound,
InvalidMaterial,
RandomUnavailable,
}
impl Display for CredentialProviderError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
Self::Unavailable => "Core credential provider is unavailable",
Self::NotFound => "Core credential material is absent",
Self::InvalidMaterial => "Core credential material does not match its binding",
Self::RandomUnavailable => "secure random credential generation is unavailable",
})
}
}
impl Error for CredentialProviderError {}
#[derive(Clone, Debug, PartialEq)]
pub struct CredentialActivationPlan {
pub profile_generation: u64,
pub secret_reference: String,
pub reservation_action_id: Digest,
pub activation_action_id: Digest,
pub reservation: ServiceCredentialReservation,
}
impl CredentialActivationPlan {
fn validate(&self) -> Result<(), CredentialActivationError> {
if self.profile_generation == 0
|| self.secret_reference.is_empty()
|| self.secret_reference.len() > 512
|| self.reservation_action_id.validate().is_err()
|| self.activation_action_id.validate().is_err()
|| self.reservation_action_id == self.activation_action_id
|| self.reservation.reservation_action_id != self.reservation_action_id
|| self.reservation.activation_action_id != self.activation_action_id
|| self.reservation.locator.authority != "service_credential_reservation"
|| self.reservation.locator.value.is_empty()
|| self.reservation.locator.value.len() > 16 * 1024
|| self
.reservation
.locator
.value
.bytes()
.any(|byte| byte.is_ascii_control())
|| self.reservation.principal_id.validate().is_err()
|| self.reservation.api_key_id.validate().is_err()
|| parse_u64(&self.reservation.generation.value).is_none_or(|generation| generation == 0)
|| parse_u64(&self.reservation.reservation_expires_at.value)
.zip(parse_u64(&self.reservation.credential_expires_at.value))
.is_none_or(|(reservation_expiry, credential_expiry)| {
reservation_expiry == 0 || credential_expiry <= reservation_expiry
})
|| self.reservation.required_secret_bytes != u32::try_from(API_KEY_SECRET_BYTES).unwrap_or(u32::MAX)
|| self.reservation.verifier_algorithm != "sha256_identity_bound_v1"
|| self.reservation.verifier_algorithm_version != 1
{
return Err(CredentialActivationError::InvalidReservation);
}
Ok(())
}
}
pub struct PendingOperationalCredential {
pub plan: CredentialActivationPlan,
pub secret: SecretBytes,
}
#[derive(Clone, Debug, PartialEq)]
pub struct ActiveOperationalCredential {
pub profile_generation: u64,
pub secret_reference: String,
pub reservation_action_id: Digest,
pub activation_action_id: Digest,
pub principal_id: Identity,
pub api_key_id: Identity,
pub generation: U64,
}
#[derive(Clone, Debug, PartialEq)]
pub enum StoredCredentialTransition {
Pending {
profile_generation: u64,
secret_reference: String,
reservation_action_id: Digest,
activation_action_id: Digest,
api_key_id: Identity,
generation: U64,
},
Active(ActiveOperationalCredential),
Retired {
profile_generation: u64,
secret_reference: String,
api_key_id: Identity,
generation: U64,
},
}
pub type SecretStoreFuture<'a> =
Pin<Box<dyn Future<Output = Result<StoredCredentialTransition, CredentialMaterialStoreError>> + Send + 'a>>;
pub trait OperationalCredentialMaterialStore: CredentialProvider {
fn stage_pending<'a>(&'a self, pending: PendingOperationalCredential) -> SecretStoreFuture<'a>;
fn mark_active<'a>(&'a self, active: ActiveOperationalCredential) -> SecretStoreFuture<'a>;
fn mark_retired<'a>(
&'a self,
profile_generation: u64,
secret_reference: &'a str,
api_key_id: &'a Identity,
generation: &'a U64,
) -> SecretStoreFuture<'a>;
}
pub type EphemeralDeliveryFuture<'a> =
Pin<Box<dyn Future<Output = Result<(), CredentialMaterialStoreError>> + Send + 'a>>;
pub trait EphemeralCredentialDelivery: Send + Sync + 'static {
fn deliver_once<'a>(
&'a self,
active: ActiveOperationalCredential,
secret: SecretBytes,
) -> EphemeralDeliveryFuture<'a>;
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CredentialMaterialStoreError {
Unavailable,
CasConflict,
DeliveryFailed,
}
impl Display for CredentialMaterialStoreError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
Self::Unavailable => "external credential material store is unavailable",
Self::CasConflict => "external credential material generation CAS conflicted",
Self::DeliveryFailed => "ephemeral credential one-time delivery failed",
})
}
}
impl Error for CredentialMaterialStoreError {}
impl Client {
pub async fn activate_operational_credential<S>(
&self,
plan: CredentialActivationPlan,
store: &S,
options: CallOptions,
) -> Result<ActiveOperationalCredential, CredentialActivationError>
where
S: OperationalCredentialMaterialStore,
{
plan.validate()?;
let secret = SecretBytes::generate().map_err(CredentialActivationError::Credential)?;
let verifier = credential_verifier(&plan.reservation.api_key_id, &secret)?;
let pending = PendingOperationalCredential {
plan: plan.clone(),
secret,
};
let staged = store
.stage_pending(pending)
.await
.map_err(CredentialActivationError::Store)?;
validate_pending_transition(&plan, &staged)?;
let active = self.activate_or_resolve(plan, verifier, options).await?;
let committed = store
.mark_active(active.clone())
.await
.map_err(CredentialActivationError::Store)?;
if committed != StoredCredentialTransition::Active(active.clone()) {
return Err(CredentialActivationError::Store(
CredentialMaterialStoreError::CasConflict,
));
}
Ok(active)
}
pub async fn activate_ephemeral_credential<D>(
&self,
plan: CredentialActivationPlan,
delivery: &D,
options: CallOptions,
) -> Result<ActiveOperationalCredential, CredentialActivationError>
where
D: EphemeralCredentialDelivery,
{
plan.validate()?;
let secret = SecretBytes::generate().map_err(CredentialActivationError::Credential)?;
let verifier = credential_verifier(&plan.reservation.api_key_id, &secret)?;
let active = self.activate_or_resolve(plan, verifier, options).await?;
delivery
.deliver_once(active.clone(), secret)
.await
.map_err(CredentialActivationError::Store)?;
Ok(active)
}
async fn activate_or_resolve(
&self,
plan: CredentialActivationPlan,
verifier: Digest,
options: CallOptions,
) -> Result<ActiveOperationalCredential, CredentialActivationError> {
let activation = self
.activate_service_credential(
ActivateServiceCredentialRequest {
reservation: plan.reservation.clone(),
verifier,
},
options.clone(),
)
.await;
let outcome = match activation {
Ok(result) => {
if result.secret_escrowed {
return Err(CredentialActivationError::SecretEscrowed);
}
if result.locator != plan.reservation.locator {
return Err(CredentialActivationError::OutcomeMismatch);
}
result.outcome
}
Err(error) if error.submission_disposition() != SubmissionDisposition::NotSubmitted => {
let resolution = self
.resolve_service_credential_reservation(
ResolveServiceCredentialReservationRequest {
reservation_action_id: plan.reservation_action_id.clone(),
},
options.for_resolution(),
)
.await;
let resolution = match resolution {
Ok(resolution) => resolution,
Err(error) if error.is_deadline_exceeded() => {
return Err(CredentialActivationError::Indeterminate);
}
Err(error) => return Err(CredentialActivationError::Client(error)),
};
if resolution.reservation.as_ref() != Some(&plan.reservation) {
return Err(CredentialActivationError::OutcomeMismatch);
}
if resolution.state != "activated" {
return Err(CredentialActivationError::Indeterminate);
}
resolution.outcome.ok_or(CredentialActivationError::Indeterminate)?
}
Err(error) => return Err(CredentialActivationError::Client(error)),
};
active_from_outcome(&plan, outcome)
}
}
pub(crate) fn credential_verifier(
api_key_id: &Identity,
secret: &SecretBytes,
) -> Result<Digest, CredentialActivationError> {
let api_key_id = parse_identity(&api_key_id.value).ok_or(CredentialActivationError::InvalidReservation)?;
let mut input = Vec::with_capacity(API_KEY_VERIFIER_DOMAIN.len() + 16 + API_KEY_SECRET_BYTES);
input.extend_from_slice(API_KEY_VERIFIER_DOMAIN);
input.extend_from_slice(&api_key_id.to_be_bytes());
input.extend_from_slice(secret.expose());
let observed = digest::digest(&digest::SHA256, &input);
input.fill(0);
std::hint::black_box(&mut input);
Ok(Digest {
value: lower_hex(observed.as_ref()),
})
}
pub(crate) fn encode_file_credential(
api_key_id: &Identity,
generation: &U64,
secret: &SecretBytes,
) -> Result<Vec<u8>, CredentialProviderError> {
if api_key_id.validate().is_err() || parse_u64(&generation.value).is_none_or(|value| value == 0) {
return Err(CredentialProviderError::InvalidMaterial);
}
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut secret_hex = [0_u8; API_KEY_SECRET_BYTES * 2];
for (index, byte) in secret.expose().iter().copied().enumerate() {
secret_hex[index * 2] = HEX[usize::from(byte >> 4)];
secret_hex[index * 2 + 1] = HEX[usize::from(byte & 0x0f)];
}
let secret_hex_text = std::str::from_utf8(&secret_hex).map_err(|_| CredentialProviderError::InvalidMaterial)?;
let value = FileCredential {
format_version: FILE_CREDENTIAL_FORMAT_VERSION,
api_key_id: &api_key_id.value,
generation: &generation.value,
secret_hex: secret_hex_text,
};
let encoded = serde_json::to_vec(&value).map_err(|_| CredentialProviderError::InvalidMaterial);
secret_hex.fill(0);
std::hint::black_box(&mut secret_hex);
encoded
}
fn active_from_outcome(
plan: &CredentialActivationPlan,
outcome: SecurityActionOutcome,
) -> Result<ActiveOperationalCredential, CredentialActivationError> {
let SecurityActionOutcome::Activated {
principal_id,
api_key_id,
generation,
} = outcome
else {
return Err(CredentialActivationError::Indeterminate);
};
if principal_id != plan.reservation.principal_id
|| api_key_id != plan.reservation.api_key_id
|| generation != plan.reservation.generation
{
return Err(CredentialActivationError::OutcomeMismatch);
}
Ok(ActiveOperationalCredential {
profile_generation: plan.profile_generation,
secret_reference: plan.secret_reference.clone(),
reservation_action_id: plan.reservation_action_id.clone(),
activation_action_id: plan.activation_action_id.clone(),
principal_id,
api_key_id,
generation,
})
}
fn validate_pending_transition(
plan: &CredentialActivationPlan,
transition: &StoredCredentialTransition,
) -> Result<(), CredentialActivationError> {
if matches!(
transition,
StoredCredentialTransition::Pending {
profile_generation,
secret_reference,
reservation_action_id,
activation_action_id,
api_key_id,
generation,
} if *profile_generation == plan.profile_generation
&& secret_reference == &plan.secret_reference
&& reservation_action_id == &plan.reservation_action_id
&& activation_action_id == &plan.activation_action_id
&& api_key_id == &plan.reservation.api_key_id
&& generation == &plan.reservation.generation
) {
Ok(())
} else {
Err(CredentialActivationError::Store(
CredentialMaterialStoreError::CasConflict,
))
}
}
fn lower_hex(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut output = String::with_capacity(bytes.len().saturating_mul(2));
for byte in bytes {
output.push(char::from(HEX[usize::from(byte >> 4)]));
output.push(char::from(HEX[usize::from(byte & 0x0f)]));
}
output
}
#[derive(Debug)]
pub enum CredentialActivationError {
InvalidReservation,
Credential(CredentialProviderError),
Store(CredentialMaterialStoreError),
Client(ClientError),
SecretEscrowed,
OutcomeMismatch,
Indeterminate,
}
impl Display for CredentialActivationError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
Self::InvalidReservation => "Core credential reservation is invalid",
Self::Credential(_) => "Core credential generation failed",
Self::Store(_) => "external credential material transition failed",
Self::Client(_) => "Core credential activation or resolution failed",
Self::SecretEscrowed => "Core reported forbidden credential secret escrow",
Self::OutcomeMismatch => "Core credential activation outcome changed the reserved identity",
Self::Indeterminate => "Core credential activation outcome remains indeterminate",
})
}
}
impl Error for CredentialActivationError {}
#[cfg(test)]
mod tests {
use lodestone_wire::{Locator, ServiceCredentialReservation};
use super::*;
fn digest(value: u8) -> Digest {
Digest {
value: format!("{value:02x}").repeat(32),
}
}
fn identity(value: u128) -> Identity {
Identity {
value: format!("{value:032x}"),
}
}
fn plan() -> CredentialActivationPlan {
let reservation_action_id = digest(1);
let activation_action_id = digest(2);
CredentialActivationPlan {
profile_generation: 1,
secret_reference: "external://core/ui".to_owned(),
reservation_action_id: reservation_action_id.clone(),
activation_action_id: activation_action_id.clone(),
reservation: ServiceCredentialReservation {
locator: Locator {
authority: "service_credential_reservation".to_owned(),
value: "01".repeat(64),
},
reservation_action_id,
activation_action_id,
principal_id: identity(3),
api_key_id: identity(4),
generation: U64 { value: "1".to_owned() },
reservation_expires_at: U64 {
value: "100".to_owned(),
},
credential_expires_at: U64 {
value: "200".to_owned(),
},
verifier_algorithm: "sha256_identity_bound_v1".to_owned(),
verifier_algorithm_version: 1,
required_secret_bytes: 32,
},
}
}
#[test]
fn activation_plan_binds_both_action_ids_to_the_reservation_projection() {
let value = plan();
assert!(value.validate().is_ok());
let mut substituted = value;
substituted.activation_action_id = digest(5);
assert!(matches!(
substituted.validate(),
Err(CredentialActivationError::InvalidReservation)
));
let mut invalid_expiry = plan();
invalid_expiry.reservation.credential_expires_at = U64 {
value: invalid_expiry.reservation.reservation_expires_at.value.clone(),
};
assert!(matches!(
invalid_expiry.validate(),
Err(CredentialActivationError::InvalidReservation)
));
}
#[test]
fn activation_outcome_binds_reserved_principal_key_and_generation() {
let value = plan();
let outcome = SecurityActionOutcome::Activated {
principal_id: identity(9),
api_key_id: value.reservation.api_key_id.clone(),
generation: value.reservation.generation.clone(),
};
assert!(matches!(
active_from_outcome(&value, outcome),
Err(CredentialActivationError::OutcomeMismatch)
));
}
#[test]
fn file_provider_contract_refuses_ambiguous_references_and_material() {
assert!(file_credential_path("file:///var/run/lodestone/core.json").is_ok());
assert!(file_credential_path("file://relative/core.json").is_err());
assert!(file_credential_path("file:///var/run/../secret").is_err());
assert!(file_credential_path("vault://core/key").is_err());
let valid = br#"{"format_version":1,"api_key_id":"00000000000000000000000000000001","generation":"1","secret_hex":"0000000000000000000000000000000000000000000000000000000000000000"}"#;
assert!(decode_file_credential(valid).is_ok());
let uppercase = br#"{"format_version":1,"api_key_id":"00000000000000000000000000000001","generation":"1","secret_hex":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}"#;
assert!(decode_file_credential(uppercase).is_err());
let newer = br#"{"format_version":2,"api_key_id":"00000000000000000000000000000001","generation":"1","secret_hex":"0000000000000000000000000000000000000000000000000000000000000000"}"#;
assert!(decode_file_credential(newer).is_err());
}
#[cfg(unix)]
#[tokio::test]
async fn file_provider_accepts_only_private_regular_files_and_refuses_symlinks()
-> Result<(), Box<dyn std::error::Error>> {
use std::os::unix::fs::{PermissionsExt as _, symlink};
struct Cleanup(std::path::PathBuf);
impl Drop for Cleanup {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
let mut nonce = [0_u8; 8];
getrandom::fill(&mut nonce)?;
let directory = std::env::temp_dir().join(format!("lodestone-ec1-credential-{}", lower_hex(&nonce)));
std::fs::create_dir(&directory)?;
let _cleanup = Cleanup(directory.clone());
let credential = directory.join("credential.json");
let link = directory.join("credential-link.json");
let hard_link = directory.join("credential-hard-link.json");
let bytes = br#"{"format_version":1,"api_key_id":"00000000000000000000000000000001","generation":"1","secret_hex":"0000000000000000000000000000000000000000000000000000000000000000"}"#;
std::fs::write(&credential, bytes)?;
std::fs::set_permissions(&credential, std::fs::Permissions::from_mode(0o600))?;
let reference = format!("file://{}", credential.display());
assert!(FileCredentialProvider.provide(&reference).await.is_ok());
std::fs::set_permissions(&credential, std::fs::Permissions::from_mode(0o644))?;
assert!(matches!(
FileCredentialProvider.provide(&reference).await,
Err(CredentialProviderError::InvalidMaterial)
));
std::fs::set_permissions(&credential, std::fs::Permissions::from_mode(0o600))?;
std::fs::hard_link(&credential, &hard_link)?;
assert!(matches!(
FileCredentialProvider.provide(&reference).await,
Err(CredentialProviderError::InvalidMaterial)
));
std::fs::remove_file(&hard_link)?;
symlink(&credential, &link)?;
let link_reference = format!("file://{}", link.display());
assert!(matches!(
FileCredentialProvider.provide(&link_reference).await,
Err(CredentialProviderError::InvalidMaterial)
));
Ok(())
}
}