#![warn(clippy::pedantic)]
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Charset {
Alphanumeric,
Symbols,
Hex,
Base32,
Base64UrlSafe,
}
impl Charset {
#[must_use]
pub fn alphabet(self) -> &'static [u8] {
match self {
Self::Alphanumeric => {
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
}
Self::Symbols => {
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789~!@#$%^&*()-_=+[]{};:,.<>?/"
}
Self::Hex => b"0123456789abcdef",
Self::Base32 => b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567",
Self::Base64UrlSafe => {
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", tag = "kind")]
pub enum SecretGenPolicy {
PasswordRandom {
length: u8,
charset: Charset,
max_length: Option<u8>,
},
PreSharedKey { length_bytes: u8 },
Token { length: u8, prefix: Option<String> },
WireguardKeypair,
SshKeypair { algo: SshAlgo },
TlsKeypair { algo: TlsAlgo, validity_days: u32 },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum SshAlgo {
Ed25519,
Rsa4096,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum TlsAlgo {
Ed25519,
Rsa4096,
EcdsaP256,
}
impl SecretGenPolicy {
#[must_use]
pub fn backend_paths(&self) -> usize {
match self {
Self::WireguardKeypair | Self::SshKeypair { .. } | Self::TlsKeypair { .. } => 2,
_ => 1,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum RotationPolicy {
Manual,
Quarterly,
Yearly,
Never,
}
impl RotationPolicy {
#[must_use]
pub fn overdue_after_days(self) -> Option<u32> {
match self {
Self::Manual | Self::Never => None,
Self::Quarterly => Some(90),
Self::Yearly => Some(365),
}
}
}
#[derive(
Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, gen_platform::TypedDispatcher,
)]
#[serde(rename_all = "kebab-case", tag = "kind")]
pub enum BackendKind {
Sops {
file: String,
yaml_path: String,
},
Akeyless {
path: String,
},
Mock { name: String },
}
impl BackendKind {
#[must_use]
pub fn stable_id(&self) -> String {
match self {
Self::Sops { file, yaml_path } => format!("sops:{file}:{yaml_path}"),
Self::Akeyless { path } => format!("akeyless:{path}"),
Self::Mock { name } => format!("mock:{name}"),
}
}
#[must_use]
pub fn is_test_only(&self) -> bool {
matches!(self, Self::Mock { .. })
}
}
gen_platform::register_dispatcher!("cofre.backend-kind", BackendKind);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SecretRef {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub backend: BackendKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub generation: Option<SecretGenPolicy>,
#[serde(default = "default_rotation")]
pub rotation: RotationPolicy,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub labels: Vec<String>,
}
fn default_rotation() -> RotationPolicy {
RotationPolicy::Manual
}
impl SecretRef {
#[must_use]
pub fn materialization_targets(&self) -> Vec<String> {
let base = self.backend.stable_id();
match &self.generation {
Some(SecretGenPolicy::WireguardKeypair) | Some(SecretGenPolicy::SshKeypair { .. }) => {
vec![format!("{base}.private"), format!("{base}.public")]
}
Some(SecretGenPolicy::TlsKeypair { .. }) => {
vec![format!("{base}.key"), format!("{base}.crt")]
}
_ => vec![base],
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SecretMaterializationPlan {
#[serde(rename = "apiVersion")]
pub api_version: String,
pub kind: String,
pub metadata: PlanMetadata,
pub secrets: Vec<SecretRef>,
#[serde(default)]
pub test_only: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PlanMetadata {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
}
impl SecretMaterializationPlan {
pub const API_VERSION: &'static str = "pleme.io/v1";
pub const KIND: &'static str = "SecretMaterializationPlan";
#[must_use]
pub fn new(name: impl Into<String>, secrets: Vec<SecretRef>) -> Self {
Self {
api_version: Self::API_VERSION.into(),
kind: Self::KIND.into(),
metadata: PlanMetadata {
name: name.into(),
description: None,
source: None,
},
secrets,
test_only: false,
}
}
pub fn from_yaml(s: &str) -> Result<Self, PlanError> {
let plan: Self = serde_yaml::from_str(s).map_err(PlanError::Parse)?;
plan.validate()?;
Ok(plan)
}
pub fn to_yaml(&self) -> Result<String, PlanError> {
serde_yaml::to_string(self).map_err(PlanError::Serialize)
}
}
#[derive(Debug, thiserror::Error)]
pub enum PlanError {
#[error("plan parse failure: {0}")]
Parse(serde_yaml::Error),
#[error("plan serialize failure: {0}")]
Serialize(serde_yaml::Error),
#[error("unsupported apiVersion: {0:?} (expected {expected:?})", expected = SecretMaterializationPlan::API_VERSION)]
UnsupportedApiVersion(String),
#[error("unexpected kind: {0:?} (expected {expected:?})", expected = SecretMaterializationPlan::KIND)]
UnexpectedKind(String),
#[error("plan name must match [a-z0-9-]+ (was {0:?})")]
InvalidPlanName(String),
#[error("secret name must match [a-z0-9-]+ (was {0:?})")]
InvalidSecretName(String),
#[error("secret name {0:?} appears more than once in the plan")]
DuplicateSecretName(String),
#[error("backend stable-id {0:?} appears in more than one secret — would collide on apply")]
DuplicateBackend(String),
#[error("BackendKind::Mock present in non-test plan — set test_only=true if intentional")]
MockBackendInProductionPlan,
#[error("PasswordRandom length must be > 0")]
ZeroLengthPassword,
#[error("PasswordRandom length {requested} exceeds max_length {cap}")]
PasswordExceedsMaxLength { requested: u8, cap: u8 },
#[error("PreSharedKey length_bytes must be > 0")]
ZeroLengthPreSharedKey,
#[error("Token length must be > 0")]
ZeroLengthToken,
#[error("TlsKeypair validity_days must be > 0")]
ZeroValidityDays,
#[error("Token prefix must match [a-zA-Z0-9_-]* (was {0:?})")]
InvalidTokenPrefix(String),
#[error("Sops backend file path must be absolute (was {0:?})")]
NonAbsoluteSopsFile(String),
#[error("Sops backend yaml_path must be non-empty")]
EmptySopsYamlPath,
#[error("Akeyless backend path must start with '/' (was {0:?})")]
InvalidAkeylessPath(String),
#[error("plan must contain at least one secret")]
EmptyPlan,
}
fn is_slug(s: &str) -> bool {
!s.is_empty()
&& s.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
}
fn is_token_prefix(s: &str) -> bool {
s.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
}
impl SecretMaterializationPlan {
pub fn validate(&self) -> Result<(), PlanError> {
if self.api_version != Self::API_VERSION {
return Err(PlanError::UnsupportedApiVersion(self.api_version.clone()));
}
if self.kind != Self::KIND {
return Err(PlanError::UnexpectedKind(self.kind.clone()));
}
if !is_slug(&self.metadata.name) {
return Err(PlanError::InvalidPlanName(self.metadata.name.clone()));
}
if self.secrets.is_empty() {
return Err(PlanError::EmptyPlan);
}
let mut seen_names = std::collections::HashSet::new();
let mut seen_backends = std::collections::HashSet::new();
for s in &self.secrets {
if !is_slug(&s.name) {
return Err(PlanError::InvalidSecretName(s.name.clone()));
}
if !seen_names.insert(s.name.clone()) {
return Err(PlanError::DuplicateSecretName(s.name.clone()));
}
for tgt in s.materialization_targets() {
if !seen_backends.insert(tgt.clone()) {
return Err(PlanError::DuplicateBackend(tgt));
}
}
if !self.test_only && s.backend.is_test_only() {
return Err(PlanError::MockBackendInProductionPlan);
}
validate_backend(&s.backend)?;
if let Some(g) = &s.generation {
validate_generation(g)?;
}
}
Ok(())
}
}
fn validate_backend(b: &BackendKind) -> Result<(), PlanError> {
match b {
BackendKind::Sops { file, yaml_path } => {
if !file.starts_with('/') {
return Err(PlanError::NonAbsoluteSopsFile(file.clone()));
}
if yaml_path.is_empty() {
return Err(PlanError::EmptySopsYamlPath);
}
}
BackendKind::Akeyless { path } => {
if !path.starts_with('/') {
return Err(PlanError::InvalidAkeylessPath(path.clone()));
}
}
BackendKind::Mock { .. } => {}
}
Ok(())
}
fn validate_generation(g: &SecretGenPolicy) -> Result<(), PlanError> {
match g {
SecretGenPolicy::PasswordRandom {
length,
max_length,
charset: _,
} => {
if *length == 0 {
return Err(PlanError::ZeroLengthPassword);
}
if let Some(cap) = max_length {
if length > cap {
return Err(PlanError::PasswordExceedsMaxLength {
requested: *length,
cap: *cap,
});
}
}
}
SecretGenPolicy::PreSharedKey { length_bytes } => {
if *length_bytes == 0 {
return Err(PlanError::ZeroLengthPreSharedKey);
}
}
SecretGenPolicy::Token { length, prefix } => {
if *length == 0 {
return Err(PlanError::ZeroLengthToken);
}
if let Some(p) = prefix {
if !is_token_prefix(p) {
return Err(PlanError::InvalidTokenPrefix(p.clone()));
}
}
}
SecretGenPolicy::TlsKeypair { validity_days, .. } => {
if *validity_days == 0 {
return Err(PlanError::ZeroValidityDays);
}
}
SecretGenPolicy::WireguardKeypair | SecretGenPolicy::SshKeypair { .. } => {}
}
Ok(())
}
impl SecretRef {
#[must_use]
pub fn password(name: impl Into<String>, backend: BackendKind, length: u8) -> Self {
Self {
name: name.into(),
description: None,
backend,
generation: Some(SecretGenPolicy::PasswordRandom {
length,
charset: Charset::Alphanumeric,
max_length: None,
}),
rotation: RotationPolicy::Manual,
labels: vec![],
}
}
#[must_use]
pub fn capped_password(
name: impl Into<String>,
backend: BackendKind,
length: u8,
max_length: u8,
) -> Self {
Self {
name: name.into(),
description: None,
backend,
generation: Some(SecretGenPolicy::PasswordRandom {
length,
charset: Charset::Alphanumeric,
max_length: Some(max_length),
}),
rotation: RotationPolicy::Manual,
labels: vec![],
}
}
#[must_use]
pub fn with_rotation(mut self, r: RotationPolicy) -> Self {
self.rotation = r;
self
}
#[must_use]
pub fn with_description(mut self, d: impl Into<String>) -> Self {
self.description = Some(d.into());
self
}
#[must_use]
pub fn with_labels(mut self, labels: Vec<String>) -> Self {
self.labels = labels;
self
}
}
#[cfg(test)]
mod tests {
use super::*;
fn akeyless_password(name: &str, length: u8) -> SecretRef {
SecretRef::password(
name,
BackendKind::Akeyless {
path: format!("/test/{name}"),
},
length,
)
}
fn ryn_plan() -> SecretMaterializationPlan {
SecretMaterializationPlan::new(
"ryn-remote-access",
vec![
SecretRef::capped_password(
"vnc-password",
BackendKind::Akeyless {
path: "/pleme-io/ryn/remote-access/vnc-password".into(),
},
16,
16,
)
.with_rotation(RotationPolicy::Quarterly)
.with_description("Apple Screen Sharing VNC password (capped at 16 by ARD-XOR)"),
SecretRef::password(
"rustdesk-password",
BackendKind::Akeyless {
path: "/pleme-io/ryn/remote-access/rustdesk-password".into(),
},
24,
)
.with_rotation(RotationPolicy::Quarterly),
],
)
}
#[test]
fn every_charset_is_non_empty() {
for c in [
Charset::Alphanumeric,
Charset::Symbols,
Charset::Hex,
Charset::Base32,
Charset::Base64UrlSafe,
] {
assert!(!c.alphabet().is_empty());
}
}
#[test]
fn hex_alphabet_is_lowercase() {
assert_eq!(Charset::Hex.alphabet(), b"0123456789abcdef");
}
#[test]
fn ryn_plan_validates() {
assert!(ryn_plan().validate().is_ok());
}
#[test]
fn empty_plan_rejected() {
let p = SecretMaterializationPlan::new("empty", vec![]);
assert!(matches!(p.validate(), Err(PlanError::EmptyPlan)));
}
#[test]
fn duplicate_secret_name_rejected() {
let p = SecretMaterializationPlan::new(
"dup",
vec![akeyless_password("foo", 16), akeyless_password("foo", 16)],
);
assert!(matches!(
p.validate(),
Err(PlanError::DuplicateSecretName(_))
));
}
#[test]
fn duplicate_backend_rejected() {
let mut a = akeyless_password("foo", 16);
let mut b = akeyless_password("bar", 16);
a.backend = BackendKind::Akeyless { path: "/x".into() };
b.backend = BackendKind::Akeyless { path: "/x".into() };
let p = SecretMaterializationPlan::new("dup-backend", vec![a, b]);
assert!(matches!(p.validate(), Err(PlanError::DuplicateBackend(_))));
}
#[test]
fn invalid_plan_name_rejected() {
let p = SecretMaterializationPlan::new("Bad Name!", vec![akeyless_password("x", 16)]);
assert!(matches!(p.validate(), Err(PlanError::InvalidPlanName(_))));
}
#[test]
fn invalid_secret_name_rejected() {
let p = SecretMaterializationPlan::new("ok", vec![akeyless_password("Bad Name", 16)]);
assert!(matches!(p.validate(), Err(PlanError::InvalidSecretName(_))));
}
#[test]
fn unsupported_apiversion_rejected() {
let mut p = ryn_plan();
p.api_version = "wrong/v0".into();
assert!(matches!(
p.validate(),
Err(PlanError::UnsupportedApiVersion(_))
));
}
#[test]
fn unexpected_kind_rejected() {
let mut p = ryn_plan();
p.kind = "Whatever".into();
assert!(matches!(p.validate(), Err(PlanError::UnexpectedKind(_))));
}
#[test]
fn mock_backend_in_prod_plan_rejected() {
let mut s = akeyless_password("foo", 16);
s.backend = BackendKind::Mock { name: "x".into() };
let p = SecretMaterializationPlan::new("prod", vec![s]);
assert!(matches!(
p.validate(),
Err(PlanError::MockBackendInProductionPlan)
));
}
#[test]
fn mock_backend_in_test_plan_allowed() {
let mut s = akeyless_password("foo", 16);
s.backend = BackendKind::Mock { name: "x".into() };
let mut p = SecretMaterializationPlan::new("test", vec![s]);
p.test_only = true;
assert!(p.validate().is_ok());
}
#[test]
fn nonabsolute_sops_file_rejected() {
let s = SecretRef::password(
"foo",
BackendKind::Sops {
file: "relative/path.yaml".into(),
yaml_path: "x.y".into(),
},
16,
);
let p = SecretMaterializationPlan::new("nonabs", vec![s]);
assert!(matches!(
p.validate(),
Err(PlanError::NonAbsoluteSopsFile(_))
));
}
#[test]
fn empty_sops_yaml_path_rejected() {
let s = SecretRef::password(
"foo",
BackendKind::Sops {
file: "/abs.yaml".into(),
yaml_path: String::new(),
},
16,
);
let p = SecretMaterializationPlan::new("emptyyp", vec![s]);
assert!(matches!(p.validate(), Err(PlanError::EmptySopsYamlPath)));
}
#[test]
fn invalid_akeyless_path_rejected() {
let s = SecretRef::password(
"foo",
BackendKind::Akeyless {
path: "no-slash".into(),
},
16,
);
let p = SecretMaterializationPlan::new("invak", vec![s]);
assert!(matches!(
p.validate(),
Err(PlanError::InvalidAkeylessPath(_))
));
}
#[test]
fn zero_length_password_rejected() {
let s = SecretRef::password("foo", BackendKind::Akeyless { path: "/x".into() }, 0);
let p = SecretMaterializationPlan::new("zerolen", vec![s]);
assert!(matches!(p.validate(), Err(PlanError::ZeroLengthPassword)));
}
#[test]
fn password_exceeds_max_length_rejected() {
let s =
SecretRef::capped_password("vnc", BackendKind::Akeyless { path: "/x".into() }, 32, 16);
let p = SecretMaterializationPlan::new("toolong", vec![s]);
assert!(matches!(
p.validate(),
Err(PlanError::PasswordExceedsMaxLength {
requested: 32,
cap: 16
})
));
}
#[test]
fn vnc_at_max_length_allowed() {
let s =
SecretRef::capped_password("vnc", BackendKind::Akeyless { path: "/x".into() }, 16, 16);
let p = SecretMaterializationPlan::new("vnc", vec![s]);
assert!(p.validate().is_ok());
}
#[test]
fn zero_byte_psk_rejected() {
let s = SecretRef {
name: "psk".into(),
description: None,
backend: BackendKind::Akeyless { path: "/x".into() },
generation: Some(SecretGenPolicy::PreSharedKey { length_bytes: 0 }),
rotation: RotationPolicy::Manual,
labels: vec![],
};
let p = SecretMaterializationPlan::new("zeropsk", vec![s]);
assert!(matches!(
p.validate(),
Err(PlanError::ZeroLengthPreSharedKey)
));
}
#[test]
fn zero_validity_tls_rejected() {
let s = SecretRef {
name: "tls".into(),
description: None,
backend: BackendKind::Akeyless { path: "/x".into() },
generation: Some(SecretGenPolicy::TlsKeypair {
algo: TlsAlgo::Ed25519,
validity_days: 0,
}),
rotation: RotationPolicy::Manual,
labels: vec![],
};
let p = SecretMaterializationPlan::new("notvalid", vec![s]);
assert!(matches!(p.validate(), Err(PlanError::ZeroValidityDays)));
}
#[test]
fn invalid_token_prefix_rejected() {
let s = SecretRef {
name: "tok".into(),
description: None,
backend: BackendKind::Akeyless { path: "/x".into() },
generation: Some(SecretGenPolicy::Token {
length: 32,
prefix: Some("bad space".into()),
}),
rotation: RotationPolicy::Manual,
labels: vec![],
};
let p = SecretMaterializationPlan::new("badprefix", vec![s]);
assert!(matches!(
p.validate(),
Err(PlanError::InvalidTokenPrefix(_))
));
}
#[test]
fn singleton_password_has_one_target() {
let s = akeyless_password("foo", 16);
assert_eq!(s.materialization_targets().len(), 1);
}
#[test]
fn wireguard_keypair_has_two_targets() {
let s = SecretRef {
name: "wg".into(),
description: None,
backend: BackendKind::Akeyless { path: "/x".into() },
generation: Some(SecretGenPolicy::WireguardKeypair),
rotation: RotationPolicy::Manual,
labels: vec![],
};
let t = s.materialization_targets();
assert_eq!(t.len(), 2);
assert!(t[0].ends_with(".private"));
assert!(t[1].ends_with(".public"));
}
#[test]
fn tls_keypair_targets_are_key_and_crt() {
let s = SecretRef {
name: "tls".into(),
description: None,
backend: BackendKind::Akeyless { path: "/x".into() },
generation: Some(SecretGenPolicy::TlsKeypair {
algo: TlsAlgo::Ed25519,
validity_days: 365,
}),
rotation: RotationPolicy::Yearly,
labels: vec![],
};
let t = s.materialization_targets();
assert!(t[0].ends_with(".key"));
assert!(t[1].ends_with(".crt"));
}
#[test]
fn yaml_round_trip_is_total() {
let p = ryn_plan();
let s = p.to_yaml().unwrap();
let q = SecretMaterializationPlan::from_yaml(&s).unwrap();
assert_eq!(p, q);
}
#[test]
fn json_round_trip_is_total() {
let p = ryn_plan();
let s = serde_json::to_string(&p).unwrap();
let q: SecretMaterializationPlan = serde_json::from_str(&s).unwrap();
assert_eq!(p, q);
}
#[test]
fn yaml_is_deterministic() {
let p = ryn_plan();
assert_eq!(p.to_yaml().unwrap(), p.to_yaml().unwrap());
}
#[test]
fn rotation_overdue_table() {
assert_eq!(RotationPolicy::Manual.overdue_after_days(), None);
assert_eq!(RotationPolicy::Quarterly.overdue_after_days(), Some(90));
assert_eq!(RotationPolicy::Yearly.overdue_after_days(), Some(365));
assert_eq!(RotationPolicy::Never.overdue_after_days(), None);
}
#[test]
fn camelot_bootstrap_plan_validates() {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../../nix/cofre-plans/camelot-bootstrap.yaml");
let Ok(body) = std::fs::read_to_string(&path) else {
eprintln!(
"SKIP camelot_bootstrap_plan_validates: the plan lives in the \
sibling `nix` repo and is not present at {} — check out \
pleme-io/nix beside this repo to exercise it",
path.display()
);
return;
};
let plan = SecretMaterializationPlan::from_yaml(&body)
.unwrap_or_else(|e| panic!("camelot-bootstrap.yaml failed validation: {e}"));
assert_eq!(plan.metadata.name, "camelot-dev-bootstrap");
assert_eq!(plan.secrets.len(), 7);
assert!(!plan.test_only);
for s in &plan.secrets {
assert!(matches!(s.backend, BackendKind::Sops { .. }));
assert_eq!(s.rotation, RotationPolicy::Quarterly);
}
}
}