use std::sync::Arc;
use serde::{Deserialize, Serialize};
use crate::envelope::KeyEnvelope;
use crate::kv::KvStore;
use crate::project::ProjectRef;
const MAX_SECRET_NAME_LEN: usize = 128;
const MAX_SECRET_VALUE_LEN: usize = 64 * 1024;
#[derive(Debug)]
pub enum SecretError {
InvalidName(String),
ValueTooLarge { len: usize, max: usize },
Backend(String),
}
impl std::fmt::Display for SecretError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidName(m) => write!(f, "{m}"),
Self::ValueTooLarge { len, max } => {
write!(f, "secret value is {len} bytes; the maximum is {max}")
}
Self::Backend(m) => write!(f, "{m}"),
}
}
}
impl std::error::Error for SecretError {}
impl SecretError {
#[must_use]
pub fn is_client_error(&self) -> bool {
matches!(self, Self::InvalidName(_) | Self::ValueTooLarge { .. })
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct SecretRecord {
version: u32,
created_at: u64,
updated_at: u64,
revision: u32,
sealed: Vec<u8>,
}
impl SecretRecord {
fn meta(&self, name: &str) -> SecretMeta {
SecretMeta {
name: name.to_string(),
created_at: self.created_at,
updated_at: self.updated_at,
revision: self.revision,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SecretMeta {
pub name: String,
pub created_at: u64,
pub updated_at: u64,
pub revision: u32,
}
#[derive(Clone)]
pub struct SecretStore {
kv: Arc<dyn KvStore>,
envelope: Arc<dyn KeyEnvelope>,
}
impl SecretStore {
#[must_use]
pub fn new(kv: Arc<dyn KvStore>, envelope: Arc<dyn KeyEnvelope>) -> Self {
Self { kv, envelope }
}
pub async fn set(
&self,
project: ProjectRef<'_>,
name: &str,
plaintext: &[u8],
) -> Result<SecretMeta, SecretError> {
validate_name(name)?;
if plaintext.len() > MAX_SECRET_VALUE_LEN {
return Err(SecretError::ValueTooLarge {
len: plaintext.len(),
max: MAX_SECRET_VALUE_LEN,
});
}
let key = crate::deploy::keys::secret(project, name);
let now = crate::time::now_unix();
let prev = self.load_record(&key).await?;
let created_at = prev.as_ref().map_or(now, |r| r.created_at);
let revision = prev.as_ref().map_or(0, |r| r.revision) + 1;
let sealed = self
.envelope
.wrap(plaintext)
.await
.map_err(|e| SecretError::Backend(e.to_string()))?;
let record = SecretRecord {
version: 1,
created_at,
updated_at: now,
revision,
sealed,
};
let bytes = serde_json::to_vec(&record).map_err(|e| SecretError::Backend(e.to_string()))?;
self.kv
.put(&key, bytes)
.await
.map_err(|e| SecretError::Backend(e.to_string()))?;
Ok(record.meta(name))
}
pub async fn get(
&self,
project: ProjectRef<'_>,
name: &str,
) -> Result<Option<Vec<u8>>, SecretError> {
validate_name(name)?;
let key = crate::deploy::keys::secret(project, name);
match self.load_record(&key).await? {
Some(r) => Ok(Some(
self.envelope
.unwrap(&r.sealed)
.await
.map_err(|e| SecretError::Backend(e.to_string()))?,
)),
None => Ok(None),
}
}
pub async fn list(&self, project: ProjectRef<'_>) -> Result<Vec<SecretMeta>, SecretError> {
let prefix = crate::deploy::keys::secret_prefix(project);
let mut out = Vec::new();
for key in self
.kv
.list_prefix(&prefix)
.await
.map_err(|e| SecretError::Backend(e.to_string()))?
{
let name = key.strip_prefix(&prefix).unwrap_or(&key).to_string();
if let Some(bytes) = self
.kv
.get(&key)
.await
.map_err(|e| SecretError::Backend(e.to_string()))?
{
if let Ok(record) = serde_json::from_slice::<SecretRecord>(&bytes) {
out.push(record.meta(&name));
}
}
}
out.sort_by(|a, b| a.name.cmp(&b.name));
Ok(out)
}
pub async fn delete(&self, project: ProjectRef<'_>, name: &str) -> Result<bool, SecretError> {
validate_name(name)?;
let key = crate::deploy::keys::secret(project, name);
let existed = self
.kv
.get(&key)
.await
.map_err(|e| SecretError::Backend(e.to_string()))?
.is_some();
if existed {
self.kv
.delete(&key)
.await
.map_err(|e| SecretError::Backend(e.to_string()))?;
}
Ok(existed)
}
async fn load_record(&self, key: &str) -> Result<Option<SecretRecord>, SecretError> {
match self
.kv
.get(key)
.await
.map_err(|e| SecretError::Backend(e.to_string()))?
{
Some(bytes) => serde_json::from_slice::<SecretRecord>(&bytes)
.map(Some)
.map_err(|e| SecretError::Backend(format!("corrupt secret record at {key}: {e}"))),
None => Ok(None),
}
}
}
fn validate_name(name: &str) -> Result<(), SecretError> {
if name.is_empty() || name.len() > MAX_SECRET_NAME_LEN {
return Err(SecretError::InvalidName(format!(
"secret name must be 1..={MAX_SECRET_NAME_LEN} characters"
)));
}
if name == "." || name == ".." {
return Err(SecretError::InvalidName(
"secret name must not be '.' or '..'".to_string(),
));
}
if !name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
{
return Err(SecretError::InvalidName(
"secret name may contain only [A-Za-z0-9._-]".to_string(),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::envelope::EnvelopeError;
use crate::kv::MemoryKv;
struct XorEnvelope;
#[async_trait::async_trait]
impl KeyEnvelope for XorEnvelope {
async fn wrap(&self, plaintext: &[u8]) -> Result<Vec<u8>, EnvelopeError> {
Ok(plaintext.iter().map(|b| b ^ 0x5a).collect())
}
async fn unwrap(&self, wrapped: &[u8]) -> Result<Vec<u8>, EnvelopeError> {
Ok(wrapped.iter().map(|b| b ^ 0x5a).collect())
}
}
fn store() -> SecretStore {
SecretStore::new(Arc::new(MemoryKv::new()), Arc::new(XorEnvelope))
}
#[tokio::test]
async fn set_get_round_trips_and_is_sealed_at_rest() {
let s = store();
let p = ProjectRef::new("acme");
s.set(p, "db-pw", b"hunter2").await.unwrap();
assert_eq!(
s.get(p, "db-pw").await.unwrap().as_deref(),
Some(&b"hunter2"[..])
);
let kv = Arc::new(MemoryKv::new());
let s2 = SecretStore::new(kv.clone(), Arc::new(XorEnvelope));
s2.set(p, "db-pw", b"hunter2").await.unwrap();
let raw = kv
.get(&crate::deploy::keys::secret(p, "db-pw"))
.await
.unwrap()
.unwrap();
assert!(
!raw.windows(7).any(|w| w == b"hunter2"),
"plaintext must never be stored in the clear"
);
}
#[tokio::test]
async fn get_absent_is_none() {
assert!(store()
.get(ProjectRef::new("acme"), "nope")
.await
.unwrap()
.is_none());
}
#[tokio::test]
async fn rotate_preserves_created_at_and_bumps_revision() {
let s = store();
let p = ProjectRef::new("acme");
let m1 = s.set(p, "api-key", b"v1").await.unwrap();
assert_eq!(m1.revision, 1);
let m2 = s.set(p, "api-key", b"v2").await.unwrap();
assert_eq!(m2.revision, 2);
assert_eq!(
m2.created_at, m1.created_at,
"created_at preserved across rotation"
);
assert_eq!(
s.get(p, "api-key").await.unwrap().as_deref(),
Some(&b"v2"[..])
);
}
#[tokio::test]
async fn list_returns_sorted_value_free_metadata() {
let s = store();
let p = ProjectRef::new("acme");
s.set(p, "beta", b"b").await.unwrap();
s.set(p, "alpha", b"a").await.unwrap();
let names: Vec<_> = s
.list(p)
.await
.unwrap()
.into_iter()
.map(|m| m.name)
.collect();
assert_eq!(names, vec!["alpha".to_string(), "beta".to_string()]);
}
#[tokio::test]
async fn secrets_are_isolated_per_project() {
let s = store();
s.set(ProjectRef::new("acme"), "shared-name", b"acme-secret")
.await
.unwrap();
assert!(s
.get(ProjectRef::new("globex"), "shared-name")
.await
.unwrap()
.is_none());
assert!(s.list(ProjectRef::new("globex")).await.unwrap().is_empty());
}
#[tokio::test]
async fn delete_reports_existence_then_removes() {
let s = store();
let p = ProjectRef::new("acme");
s.set(p, "gone", b"x").await.unwrap();
assert!(s.delete(p, "gone").await.unwrap());
assert!(!s.delete(p, "gone").await.unwrap());
assert!(s.get(p, "gone").await.unwrap().is_none());
}
#[tokio::test]
async fn invalid_names_are_refused_fail_closed() {
let s = store();
let p = ProjectRef::new("acme");
for bad in [
"",
"has/slash",
"..",
".",
"space bad",
"new\nline",
&"x".repeat(129),
] {
assert!(
s.set(p, bad, b"v").await.is_err(),
"name {bad:?} must be rejected"
);
assert!(
s.get(p, bad).await.is_err(),
"name {bad:?} must be rejected"
);
}
assert!(s.set(p, "ok.name_1-2", b"v").await.is_ok());
}
#[tokio::test]
async fn an_oversized_value_is_refused() {
let s = store();
let p = ProjectRef::new("acme");
assert!(s
.set(p, "big", &vec![b'x'; MAX_SECRET_VALUE_LEN])
.await
.is_ok());
let err = s
.set(p, "toobig", &vec![b'x'; MAX_SECRET_VALUE_LEN + 1])
.await
.expect_err("an oversized value must be refused");
assert!(
matches!(err, SecretError::ValueTooLarge { .. }) && err.is_client_error(),
"{err}"
);
}
#[tokio::test]
async fn error_classification_client_vs_backend() {
let s = store();
let p = ProjectRef::new("acme");
let e = s.set(p, "bad/name", b"v").await.unwrap_err();
assert!(matches!(e, SecretError::InvalidName(_)) && e.is_client_error());
}
}