use std::time::SystemTime;
use std::time::UNIX_EPOCH;
use data_encoding::BASE64;
use monosecret_ipc::Revision;
use secrecy::zeroize::Zeroizing;
use crate::ProviderValue;
use crate::SecretBytes;
pub(crate) const CACHE_ENVELOPE_MARKER: &str = "monosecret-cache-v4:";
const V3_CACHE_ENVELOPE_MARKER: &str = "monosecret-cache-v3:";
const LEGACY_CACHE_ENVELOPE_MARKER: &str = "monosecret-cache-v2:";
#[derive(serde::Serialize, serde::Deserialize)]
struct CacheEnvelope {
project: String,
profile: String,
expires_at: u64,
max_age_secs: u64,
route_fingerprint: String,
#[serde(default)]
secret_expires_at_unix_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
revision: Option<Revision>,
#[serde(with = "zeroizing_string")]
value_base64: Zeroizing<String>,
}
#[derive(serde::Deserialize)]
struct V3CacheEnvelope {
project: String,
profile: String,
expires_at: u64,
max_age_secs: u64,
route_fingerprint: String,
#[serde(default)]
secret_expires_at_unix_ms: Option<u64>,
#[serde(with = "zeroizing_string")]
value: Zeroizing<String>,
}
#[derive(serde::Deserialize)]
struct LegacyCacheEnvelope {
project: String,
profile: String,
cached_at: u64,
route_fingerprint: String,
#[serde(with = "zeroizing_string")]
value: Zeroizing<String>,
}
enum DecodedEnvelope {
Current(CacheEnvelope),
V3(V3CacheEnvelope),
Legacy(LegacyCacheEnvelope),
}
mod zeroizing_string {
use secrecy::zeroize::Zeroizing;
use serde::Deserialize;
use serde::Deserializer;
use serde::Serializer;
pub(super) fn serialize<S: Serializer>(
value: &Zeroizing<String>,
serializer: S,
) -> Result<S::Ok, S::Error> {
serializer.serialize_str(value)
}
pub(super) fn deserialize<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<Zeroizing<String>, D::Error> {
String::deserialize(deserializer).map(Zeroizing::new)
}
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum CacheOwnership {
Ours,
Expired,
Foreign { project: String, profile: String },
OursUnreadable,
Unrecognized,
}
pub(crate) enum CacheEntryStatus {
Fresh {
value: SecretBytes,
refresh_at_unix_ms: Option<u64>,
expires_at_unix_ms: Option<u64>,
revision: Option<Revision>,
},
Stale,
OursUnreadable,
Foreign { project: String, profile: String },
Unrecognized,
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum CacheEncodeError {
#[error(transparent)]
Clock(#[from] std::time::SystemTimeError),
#[error("cache expiration timestamp is too large")]
ExpirationOverflow,
#[error("the secret has already expired")]
SecretExpired,
#[error(transparent)]
Serialize(#[from] serde_json::Error),
}
fn decode(stored: &SecretBytes) -> Option<Result<DecodedEnvelope, String>> {
fn text(payload: &[u8]) -> Result<&str, String> {
std::str::from_utf8(payload).map_err(|_| "cache envelope is not UTF-8".to_string())
}
let stored = stored.expose_secret();
if let Some(payload) = stored.strip_prefix(CACHE_ENVELOPE_MARKER.as_bytes()) {
return Some(text(payload).and_then(|payload| {
serde_json::from_str(payload)
.map(DecodedEnvelope::Current)
.map_err(|error| error.to_string())
}));
}
if let Some(payload) = stored.strip_prefix(V3_CACHE_ENVELOPE_MARKER.as_bytes()) {
return Some(text(payload).and_then(|payload| {
serde_json::from_str(payload)
.map(DecodedEnvelope::V3)
.map_err(|error| error.to_string())
}));
}
stored
.strip_prefix(LEGACY_CACHE_ENVELOPE_MARKER.as_bytes())
.map(|payload| {
text(payload).and_then(|payload| {
serde_json::from_str(payload)
.map(DecodedEnvelope::Legacy)
.map_err(|error| error.to_string())
})
})
}
fn unix_timestamp() -> Result<u64, std::time::SystemTimeError> {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
}
pub(crate) fn ownership(stored: &SecretBytes, project: &str, profile: &str) -> CacheOwnership {
ownership_at(stored, project, profile, unix_timestamp().ok())
}
fn ownership_at(
stored: &SecretBytes,
project: &str,
profile: &str,
now: Option<u64>,
) -> CacheOwnership {
match decode(stored) {
None => CacheOwnership::Unrecognized,
Some(Err(_)) => CacheOwnership::OursUnreadable,
Some(Ok(DecodedEnvelope::Current(envelope))) => {
if now.is_some_and(|now| now >= envelope.expires_at) {
CacheOwnership::Expired
} else if envelope.project == project && envelope.profile == profile {
CacheOwnership::Ours
} else {
CacheOwnership::Foreign {
project: envelope.project,
profile: envelope.profile,
}
}
}
Some(Ok(DecodedEnvelope::V3(envelope))) => {
if now.is_some_and(|now| now >= envelope.expires_at) {
CacheOwnership::Expired
} else if envelope.project == project && envelope.profile == profile {
CacheOwnership::Ours
} else {
CacheOwnership::Foreign {
project: envelope.project,
profile: envelope.profile,
}
}
}
Some(Ok(DecodedEnvelope::Legacy(envelope)))
if envelope.project == project && envelope.profile == profile =>
{
CacheOwnership::Ours
}
Some(Ok(DecodedEnvelope::Legacy(envelope))) => {
CacheOwnership::Foreign {
project: envelope.project,
profile: envelope.profile,
}
}
}
}
pub(crate) fn inspect_entry(
stored: &SecretBytes,
project: &str,
profile: &str,
route_fingerprint: &str,
max_age_secs: u64,
) -> Result<CacheEntryStatus, std::time::SystemTimeError> {
inspect_entry_with_clock(
stored,
project,
profile,
route_fingerprint,
max_age_secs,
unix_timestamp,
)
}
fn inspect_entry_with_clock<E>(
stored: &SecretBytes,
project: &str,
profile: &str,
route_fingerprint: &str,
max_age_secs: u64,
clock: impl FnOnce() -> Result<u64, E>,
) -> Result<CacheEntryStatus, E> {
let Some(decoded) = decode(stored) else {
return Ok(CacheEntryStatus::Unrecognized);
};
let (
project_owner,
profile_owner,
expires_at,
envelope_max_age,
route,
value,
secret_expiry,
revision,
) = match decoded {
Ok(DecodedEnvelope::Current(envelope)) => {
let value = BASE64
.decode(envelope.value_base64.as_bytes())
.map(SecretBytes::from_vec)
.map_err(|_| ())
.ok();
(
envelope.project,
envelope.profile,
envelope.expires_at,
envelope.max_age_secs,
envelope.route_fingerprint,
value,
envelope.secret_expires_at_unix_ms,
envelope.revision,
)
}
Ok(DecodedEnvelope::V3(envelope)) => {
(
envelope.project,
envelope.profile,
envelope.expires_at,
envelope.max_age_secs,
envelope.route_fingerprint,
Some(SecretBytes::from_utf8(envelope.value.as_str())),
envelope.secret_expires_at_unix_ms,
None,
)
}
Ok(DecodedEnvelope::Legacy(envelope)) => {
if envelope.project != project || envelope.profile != profile {
return Ok(CacheEntryStatus::Foreign {
project: envelope.project,
profile: envelope.profile,
});
}
if envelope.route_fingerprint != route_fingerprint {
return Ok(CacheEntryStatus::Stale);
}
let now = clock()?;
if envelope.cached_at > now || now.saturating_sub(envelope.cached_at) > max_age_secs {
return Ok(CacheEntryStatus::Stale);
}
return Ok(CacheEntryStatus::Fresh {
value: SecretBytes::from_utf8(envelope.value.as_str()),
refresh_at_unix_ms: envelope
.cached_at
.checked_add(max_age_secs)
.and_then(|expires_at| expires_at.checked_mul(1000)),
expires_at_unix_ms: None,
revision: None,
});
}
Err(_) => return Ok(CacheEntryStatus::OursUnreadable),
};
let now = clock()?;
if now >= expires_at || secret_expiry.is_some_and(|expiry| now.saturating_mul(1000) >= expiry) {
return Ok(CacheEntryStatus::Stale);
}
if project_owner != project || profile_owner != profile {
return Ok(CacheEntryStatus::Foreign {
project: project_owner,
profile: profile_owner,
});
}
let Some(cached_at) = expires_at.checked_sub(envelope_max_age) else {
return Ok(CacheEntryStatus::Stale);
};
if cached_at > now || envelope_max_age != max_age_secs {
return Ok(CacheEntryStatus::Stale);
}
if route != route_fingerprint {
return Ok(CacheEntryStatus::Stale);
}
Ok(match value {
Some(value) => {
CacheEntryStatus::Fresh {
value,
refresh_at_unix_ms: expires_at.checked_mul(1000),
expires_at_unix_ms: secret_expiry,
revision,
}
}
None => CacheEntryStatus::OursUnreadable,
})
}
#[cfg(test)]
fn inspect_entry_at(
stored: &SecretBytes,
project: &str,
profile: &str,
route_fingerprint: &str,
max_age_secs: u64,
now: u64,
) -> CacheEntryStatus {
inspect_entry_with_clock(
stored,
project,
profile,
route_fingerprint,
max_age_secs,
|| Ok::<u64, std::convert::Infallible>(now),
)
.expect("an infallible test clock cannot fail")
}
pub(crate) fn encode_entry(
project: &str,
profile: &str,
max_age_secs: u64,
route_fingerprint: String,
value: &ProviderValue,
) -> Result<SecretBytes, CacheEncodeError> {
encode_provider_entry_at(
project,
profile,
unix_timestamp()?,
max_age_secs,
route_fingerprint,
value,
)
}
fn encode_provider_entry_at(
project: &str,
profile: &str,
now: u64,
max_age_secs: u64,
route_fingerprint: String,
value: &ProviderValue,
) -> Result<SecretBytes, CacheEncodeError> {
let cache_expires_at = now
.checked_add(max_age_secs)
.ok_or(CacheEncodeError::ExpirationOverflow)?;
let expires_at = match value.expires_at_unix_ms {
Some(secret_expiry) => {
let secret_expiry_secs = secret_expiry / 1000;
if secret_expiry_secs <= now {
return Err(CacheEncodeError::SecretExpired);
}
cache_expires_at.min(secret_expiry_secs)
}
None => cache_expires_at,
};
let envelope = CacheEnvelope {
project: project.to_string(),
profile: profile.to_string(),
expires_at,
max_age_secs,
route_fingerprint,
value_base64: Zeroizing::new(BASE64.encode(value.value.expose_secret())),
secret_expires_at_unix_ms: value.expires_at_unix_ms,
revision: value.revision.clone(),
};
let json = Zeroizing::new(serde_json::to_string(&envelope)?);
let serialized = Zeroizing::new(format!("{CACHE_ENVELOPE_MARKER}{}", json.as_str()));
Ok(SecretBytes::from_utf8(serialized.as_str()))
}
#[cfg(test)]
fn encode_entry_at(
project: &str,
profile: &str,
now: u64,
max_age_secs: u64,
route_fingerprint: String,
value: &SecretBytes,
expiry: Option<u64>,
) -> Result<SecretBytes, CacheEncodeError> {
encode_provider_entry_at(
project,
profile,
now,
max_age_secs,
route_fingerprint,
&ProviderValue::new(value.clone(), expiry),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn revision_cache_envelopes_preserve_pairs_and_accept_older_entries() {
let value = ProviderValue::new(SecretBytes::from_utf8("old-value"), Some(1_055_000))
.with_revision(Some(
Revision::new("provider:old-generation".into()).unwrap(),
));
let encoded = encode_provider_entry_at(
PROJECT,
PROFILE,
WRITTEN_AT,
MAX_AGE,
FINGERPRINT.into(),
&value,
)
.unwrap();
let CacheEntryStatus::Fresh {
value: cached,
revision,
expires_at_unix_ms,
refresh_at_unix_ms,
} = inspect_entry_at(
&encoded,
PROJECT,
PROFILE,
FINGERPRINT,
MAX_AGE,
WRITTEN_AT + 1,
)
else {
panic!()
};
assert_eq!(cached.expose_secret(), b"old-value");
assert_eq!(revision, value.revision);
assert_eq!(expires_at_unix_ms, Some(1_055_000));
assert_eq!(refresh_at_unix_ms, Some(1_055_000));
let mut envelope: serde_json::Value = serde_json::from_str(
encoded
.try_as_utf8()
.unwrap()
.strip_prefix(CACHE_ENVELOPE_MARKER)
.unwrap(),
)
.unwrap();
envelope.as_object_mut().unwrap().remove("revision");
let old = SecretBytes::from_utf8(format!("{CACHE_ENVELOPE_MARKER}{envelope}"));
let CacheEntryStatus::Fresh {
revision, value, ..
} = inspect_entry_at(&old, PROJECT, PROFILE, FINGERPRINT, MAX_AGE, WRITTEN_AT + 1)
else {
panic!()
};
assert!(revision.is_none());
assert_eq!(value.expose_secret(), b"old-value");
}
const PROJECT: &str = "project";
const PROFILE: &str = "default";
const FINGERPRINT: &str = "route-v1";
const WRITTEN_AT: u64 = 1_000;
const MAX_AGE: u64 = 60;
const EXPIRES_AT: u64 = 1_060;
fn entry() -> SecretBytes {
encode_entry_at(
PROJECT,
PROFILE,
WRITTEN_AT,
MAX_AGE,
FINGERPRINT.to_string(),
&SecretBytes::from_utf8("sensitive"),
None,
)
.expect("cache envelope serializes")
}
#[test]
fn encoded_entry_round_trips_before_expiration() {
let decoded = decode(&entry())
.expect("marker present")
.expect("valid envelope");
let DecodedEnvelope::Current(envelope) = decoded else {
panic!("new entries use the current envelope");
};
let status = inspect_entry_at(
&entry(),
PROJECT,
PROFILE,
FINGERPRINT,
MAX_AGE,
EXPIRES_AT - 1,
);
let CacheEntryStatus::Fresh {
value,
refresh_at_unix_ms,
expires_at_unix_ms,
..
} = status
else {
panic!("an entry is fresh before its expiration timestamp");
};
assert_eq!(envelope.expires_at, EXPIRES_AT);
assert_eq!(envelope.max_age_secs, MAX_AGE);
assert_eq!(value.expose_secret(), b"sensitive");
assert_eq!(refresh_at_unix_ms, Some(EXPIRES_AT * 1000));
assert_eq!(expires_at_unix_ms, None);
}
#[test]
fn v4_round_trips_arbitrary_bytes_with_padded_base64() {
let expected = [0x00, 0xff, 0x80, 0x0a];
let entry = encode_entry_at(
PROJECT,
PROFILE,
WRITTEN_AT,
MAX_AGE,
FINGERPRINT.to_string(),
&SecretBytes::from_slice(&expected),
None,
)
.unwrap();
let payload = entry
.expose_secret()
.strip_prefix(CACHE_ENVELOPE_MARKER.as_bytes())
.unwrap();
let envelope: serde_json::Value = serde_json::from_slice(payload).unwrap();
assert_eq!(
envelope
.get("value_base64")
.and_then(serde_json::Value::as_str),
Some("AP+ACg==")
);
assert!(envelope.get("value").is_none());
let CacheEntryStatus::Fresh { value, .. } = inspect_entry_at(
&entry,
PROJECT,
PROFILE,
FINGERPRINT,
MAX_AGE,
EXPIRES_AT - 1,
) else {
panic!("v4 entry should be fresh");
};
assert_eq!(value.expose_secret(), expected);
}
#[test]
fn v3_text_entries_remain_readable_as_utf8_bytes() {
let entry = SecretBytes::from_utf8(format!(
"{V3_CACHE_ENVELOPE_MARKER}{}",
serde_json::json!({
"project": PROJECT,
"profile": PROFILE,
"expires_at": EXPIRES_AT,
"max_age_secs": MAX_AGE,
"route_fingerprint": FINGERPRINT,
"value": "legacy text",
})
));
let CacheEntryStatus::Fresh { value, .. } = inspect_entry_at(
&entry,
PROJECT,
PROFILE,
FINGERPRINT,
MAX_AGE,
EXPIRES_AT - 1,
) else {
panic!("v3 entry should remain fresh");
};
assert_eq!(value.expose_secret(), b"legacy text");
}
#[test]
fn entry_is_stale_at_its_expiration() {
assert!(matches!(
inspect_entry_at(&entry(), PROJECT, PROFILE, FINGERPRINT, MAX_AGE, EXPIRES_AT),
CacheEntryStatus::Stale
));
}
#[test]
fn secret_expiry_is_preserved_and_caps_cache_freshness() {
let secret_expiry_ms = (WRITTEN_AT + 20) * 1000 + 500;
let entry = encode_entry_at(
PROJECT,
PROFILE,
WRITTEN_AT,
MAX_AGE,
FINGERPRINT.to_string(),
&SecretBytes::from_utf8("sensitive"),
Some(secret_expiry_ms),
)
.expect("unexpired secret can be cached");
let CacheEntryStatus::Fresh {
refresh_at_unix_ms,
expires_at_unix_ms,
..
} = inspect_entry_at(
&entry,
PROJECT,
PROFILE,
FINGERPRINT,
MAX_AGE,
WRITTEN_AT + 19,
)
else {
panic!("entry is fresh before the capped cache boundary");
};
assert_eq!(refresh_at_unix_ms, Some((WRITTEN_AT + 20) * 1000));
assert_eq!(expires_at_unix_ms, Some(secret_expiry_ms));
assert!(matches!(
inspect_entry_at(
&entry,
PROJECT,
PROFILE,
FINGERPRINT,
MAX_AGE,
WRITTEN_AT + 20,
),
CacheEntryStatus::Stale
));
}
#[test]
fn clock_rollback_makes_an_implausibly_distant_expiration_stale() {
assert!(matches!(
inspect_entry_at(
&entry(),
PROJECT,
PROFILE,
FINGERPRINT,
MAX_AGE,
WRITTEN_AT - 1
),
CacheEntryStatus::Stale
));
}
#[test]
fn changed_max_age_invalidates_an_unexpired_entry() {
assert!(matches!(
inspect_entry_at(
&entry(),
PROJECT,
PROFILE,
FINGERPRINT,
MAX_AGE / 2,
WRITTEN_AT
),
CacheEntryStatus::Stale
));
}
#[test]
fn encoded_entry_stores_expiration_instead_of_write_time() {
let entry = entry();
let payload = entry
.expose_secret()
.strip_prefix(CACHE_ENVELOPE_MARKER.as_bytes())
.expect("marker present");
let envelope: serde_json::Value = serde_json::from_slice(payload).unwrap();
assert_eq!(
envelope
.get("expires_at")
.and_then(serde_json::Value::as_u64),
Some(EXPIRES_AT)
);
assert_eq!(
envelope
.get("max_age_secs")
.and_then(serde_json::Value::as_u64),
Some(MAX_AGE)
);
assert!(envelope.get("cached_at").is_none());
}
#[test]
fn expiration_timestamp_overflow_refuses_the_cache_entry() {
assert!(matches!(
encode_entry_at(
PROJECT,
PROFILE,
u64::MAX,
MAX_AGE,
FINGERPRINT.to_string(),
&SecretBytes::from_utf8("sensitive"),
None,
),
Err(CacheEncodeError::ExpirationOverflow)
));
}
#[test]
fn ownership_distinguishes_ours_foreign_unreadable_and_unrecognized() {
assert_eq!(
ownership_at(&entry(), PROJECT, PROFILE, Some(EXPIRES_AT - 1)),
CacheOwnership::Ours
);
assert_eq!(
ownership_at(&entry(), "other-project", PROFILE, Some(EXPIRES_AT - 1)),
CacheOwnership::Foreign {
project: PROJECT.to_string(),
profile: PROFILE.to_string(),
}
);
assert_eq!(
ownership(
&SecretBytes::from_utf8(format!("{CACHE_ENVELOPE_MARKER}{{truncated")),
PROJECT,
PROFILE
),
CacheOwnership::OursUnreadable
);
assert_eq!(
ownership(
&SecretBytes::from_utf8("someone else's value"),
PROJECT,
PROFILE
),
CacheOwnership::Unrecognized
);
}
#[test]
fn expired_entry_can_be_removed_by_whichever_project_encounters_it() {
assert_eq!(
ownership_at(&entry(), "other-project", "other-profile", Some(EXPIRES_AT)),
CacheOwnership::Expired
);
assert!(matches!(
inspect_entry_at(
&entry(),
"other-project",
"other-profile",
"different-route",
MAX_AGE,
EXPIRES_AT,
),
CacheEntryStatus::Stale
));
}
fn legacy_entry() -> SecretBytes {
SecretBytes::from_utf8(format!(
"{LEGACY_CACHE_ENVELOPE_MARKER}{}",
serde_json::json!({
"project": PROJECT,
"profile": PROFILE,
"cached_at": WRITTEN_AT,
"route_fingerprint": FINGERPRINT,
"value": "sensitive",
})
))
}
#[test]
fn legacy_entries_preserve_ownership_during_migration() {
let legacy = legacy_entry();
assert_eq!(
ownership_at(&legacy, PROJECT, PROFILE, Some(EXPIRES_AT)),
CacheOwnership::Ours
);
assert_eq!(
ownership_at(&legacy, "other-project", PROFILE, Some(EXPIRES_AT)),
CacheOwnership::Foreign {
project: PROJECT.to_string(),
profile: PROFILE.to_string(),
}
);
}
#[test]
fn fresh_legacy_entry_remains_usable_during_migration() {
let legacy = legacy_entry();
let status = inspect_entry_at(&legacy, PROJECT, PROFILE, FINGERPRINT, MAX_AGE, EXPIRES_AT);
let CacheEntryStatus::Fresh {
value,
refresh_at_unix_ms,
expires_at_unix_ms,
..
} = status
else {
panic!("v2 preserves its original inclusive freshness boundary");
};
assert_eq!(value.expose_secret(), b"sensitive");
assert_eq!(refresh_at_unix_ms, Some(EXPIRES_AT * 1000));
assert_eq!(expires_at_unix_ms, None);
}
#[test]
fn expired_legacy_entry_is_stale_for_its_owner() {
assert!(matches!(
inspect_entry_at(
&legacy_entry(),
PROJECT,
PROFILE,
FINGERPRINT,
MAX_AGE,
EXPIRES_AT + 1
),
CacheEntryStatus::Stale
));
}
#[test]
fn changed_route_is_stale_even_inside_the_time_window() {
assert!(matches!(
inspect_entry_at(
&entry(),
PROJECT,
PROFILE,
"different-route",
MAX_AGE,
EXPIRES_AT - 1
),
CacheEntryStatus::Stale
));
}
}