use std::collections::{BTreeMap, BTreeSet};
use rand::RngCore;
use serde::{Deserialize, Serialize};
use zero_secrets::{SecretArray, SecretBytes, SecretString};
pub const CRED_SCHEMA_VERSION: u16 = 2;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CredBundle {
pub schema_version: u16,
pub backends: BTreeMap<String, BackendCred>,
#[serde(default)]
pub deposit: DepositMaterial,
}
impl CredBundle {
#[must_use]
pub const fn empty() -> Self {
Self {
schema_version: CRED_SCHEMA_VERSION,
backends: BTreeMap::new(),
deposit: DepositMaterial::empty(),
}
}
pub fn set(&mut self, backend_id: impl Into<String>, cred: BackendCred) {
self.backends.insert(backend_id.into(), cred);
}
pub fn ensure_deposit_identity(&mut self) {
if self.deposit.ingest_private_key.is_some() {
return;
}
let mut seed = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut seed);
self.deposit.ingest_private_key = Some(SecretArray::new(seed));
}
#[must_use]
pub fn deposit_recipient(&self) -> Option<[u8; 32]> {
let private = self.deposit.ingest_private_key.as_ref()?;
let private = zeroize::Zeroizing::new(private.expose_secret().try_into().ok()?);
Some(crate::core::x25519_seal::public_from_private(&private))
}
}
impl Default for CredBundle {
fn default() -> Self {
Self::empty()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DepositMaterial {
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "secret_array_32_opt"
)]
pub ingest_private_key: Option<SecretArray<32>>,
#[serde(default)]
pub contributors: BTreeMap<String, DepositContributor>,
}
impl DepositMaterial {
#[must_use]
pub const fn empty() -> Self {
Self {
ingest_private_key: None,
contributors: BTreeMap::new(),
}
}
}
impl Default for DepositMaterial {
fn default() -> Self {
Self::empty()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DepositContributor {
pub public_key: String,
#[serde(default)]
pub allowed_backend_ids: BTreeSet<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum BackendCred {
VaultToken {
#[serde(with = "secret_string")]
token: SecretString,
#[serde(default, skip_serializing_if = "Option::is_none")]
addr: Option<String>,
},
VaultAppRole {
role_id: String,
#[serde(with = "secret_string")]
secret_id: SecretString,
#[serde(default, skip_serializing_if = "Option::is_none")]
addr: Option<String>,
},
SpiffeSigner {
#[serde(with = "secret_string")]
key_pem: SecretString,
spiffe_id: String,
},
DbKeystoreDek {
#[serde(with = "secret_dek")]
dek: SecretArray<32>,
},
OnePassword {
provider_uri: String,
project: String,
profile: String,
},
AwsKms {
#[serde(default, skip_serializing_if = "String::is_empty")]
region: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
profile: String,
},
GcpKms {
project: String,
location: String,
key_ring: String,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "secret_string_opt"
)]
service_account_json: Option<SecretString>,
},
Opaque {
kind: String,
#[serde(with = "secret_bytes")]
secret: SecretBytes,
},
}
mod secret_string {
use serde::{Deserialize, Deserializer, Serializer};
use zero_secrets::SecretString;
pub fn serialize<S: Serializer>(v: &SecretString, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(v.expose_secret())
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<SecretString, D::Error> {
Ok(SecretString::new(String::deserialize(d)?))
}
}
mod secret_string_opt {
use serde::{Deserialize, Deserializer, Serializer};
use zero_secrets::SecretString;
#[allow(clippy::ref_option)] pub fn serialize<S: Serializer>(v: &Option<SecretString>, s: S) -> Result<S::Ok, S::Error> {
match v {
Some(secret) => s.serialize_some(secret.expose_secret()),
None => s.serialize_none(),
}
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<SecretString>, D::Error> {
Ok(Option::<String>::deserialize(d)?.map(SecretString::new))
}
}
mod secret_bytes {
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
use serde::{Deserialize, Deserializer, Serializer};
use zero_secrets::SecretBytes;
pub fn serialize<S: Serializer>(v: &SecretBytes, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&B64.encode(v.expose_secret()))
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<SecretBytes, D::Error> {
let s = String::deserialize(d)?;
B64.decode(s.as_bytes())
.map(SecretBytes::new)
.map_err(serde::de::Error::custom)
}
}
mod secret_dek {
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
use serde::{Deserialize, Deserializer, Serializer};
use zero_secrets::SecretArray;
pub fn serialize<S: Serializer>(v: &SecretArray<32>, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&B64.encode(v.expose_secret()))
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<SecretArray<32>, D::Error> {
let s = String::deserialize(d)?;
let v = B64.decode(s.as_bytes()).map_err(serde::de::Error::custom)?;
let arr = <[u8; 32]>::try_from(v.as_slice())
.map_err(|_| serde::de::Error::custom("dek must be 32 bytes"))?;
Ok(SecretArray::new(arr))
}
}
mod secret_array_32_opt {
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
use serde::{Deserialize, Deserializer, Serializer};
use zero_secrets::SecretArray;
#[allow(clippy::ref_option)] pub fn serialize<S: Serializer>(v: &Option<SecretArray<32>>, s: S) -> Result<S::Ok, S::Error> {
match v {
Some(secret) => s.serialize_some(&B64.encode(secret.expose_secret())),
None => s.serialize_none(),
}
}
pub fn deserialize<'de, D: Deserializer<'de>>(
d: D,
) -> Result<Option<SecretArray<32>>, D::Error> {
let Some(s) = Option::<String>::deserialize(d)? else {
return Ok(None);
};
let bytes = B64.decode(s.as_bytes()).map_err(serde::de::Error::custom)?;
let arr = <[u8; 32]>::try_from(bytes.as_slice())
.map_err(|_| serde::de::Error::custom("secret array must be 32 bytes"))?;
Ok(Some(SecretArray::new(arr)))
}
}
impl BackendCred {
#[must_use]
pub const fn kind(&self) -> &'static str {
match self {
Self::VaultToken { .. } => "vault-token",
Self::VaultAppRole { .. } => "vault-approle",
Self::SpiffeSigner { .. } => "spiffe-signer",
Self::DbKeystoreDek { .. } => "db-keystore-dek",
Self::OnePassword { .. } => "onepassword",
Self::AwsKms { .. } => "aws-kms",
Self::GcpKms { .. } => "gcp-kms",
Self::Opaque { .. } => "opaque",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use zero_secrets::{SecretArray, SecretBytes, SecretString};
#[test]
fn round_trip_json() {
let mut bundle = CredBundle::empty();
bundle.set(
"vault-transit",
BackendCred::VaultToken {
token: SecretString::new("s.deadbeef".to_string()),
addr: Some("http://127.0.0.1:8200".to_string()),
},
);
bundle.set(
"vault-approle",
BackendCred::VaultAppRole {
role_id: "role-123".to_string(),
secret_id: SecretString::new("secret-456".to_string()),
addr: None,
},
);
let json = serde_json::to_vec(&bundle).unwrap();
let back: CredBundle = serde_json::from_slice(&json).unwrap();
assert_eq!(back.schema_version, CRED_SCHEMA_VERSION);
assert_eq!(back.backends.len(), 2);
match back.backends.get("vault-transit") {
Some(BackendCred::VaultToken { token, addr }) => {
assert_eq!(token.expose_secret(), "s.deadbeef");
assert_eq!(addr.as_deref(), Some("http://127.0.0.1:8200"));
}
other => panic!("wrong variant: {:?}", other.map(BackendCred::kind)),
}
match back.backends.get("vault-approle") {
Some(BackendCred::VaultAppRole {
role_id,
secret_id,
addr,
}) => {
assert_eq!(role_id, "role-123");
assert_eq!(secret_id.expose_secret(), "secret-456");
assert!(addr.is_none());
}
other => panic!("wrong variant: {:?}", other.map(BackendCred::kind)),
}
}
#[test]
fn approle_omits_absent_addr() {
let cred = BackendCred::VaultAppRole {
role_id: "r".to_string(),
secret_id: SecretString::new("s".to_string()),
addr: None,
};
let v = serde_json::to_value(&cred).unwrap();
assert!(v["VaultAppRole"].get("addr").is_none());
}
#[test]
fn debug_redacts_secret_fields() {
let cases = [
format!(
"{:?}",
BackendCred::VaultToken {
token: SecretString::new("s.debug-token".to_string()),
addr: None,
}
),
format!(
"{:?}",
BackendCred::VaultAppRole {
role_id: "role".to_string(),
secret_id: SecretString::new("debug-secret-id".to_string()),
addr: None,
}
),
format!(
"{:?}",
BackendCred::SpiffeSigner {
key_pem: SecretString::new("-----BEGIN PRIVATE KEY-----".to_string()),
spiffe_id: "spiffe://example.test/basil".to_string(),
}
),
format!(
"{:?}",
BackendCred::DbKeystoreDek {
dek: SecretArray::new([0xabu8; 32]),
}
),
format!(
"{:?}",
BackendCred::GcpKms {
project: "p".to_string(),
location: "global".to_string(),
key_ring: "ring".to_string(),
service_account_json: Some(SecretString::new(
"{\"private_key\":\"debug-private-key\"}".to_string(),
)),
}
),
format!(
"{:?}",
BackendCred::Opaque {
kind: "test".to_string(),
secret: SecretBytes::new(vec![0xde, 0xad, 0xbe, 0xef]),
}
),
];
for rendered in cases {
assert!(rendered.contains("REDACTED"));
assert!(!rendered.contains("debug-token"));
assert!(!rendered.contains("debug-secret-id"));
assert!(!rendered.contains("PRIVATE KEY"));
assert!(!rendered.contains("debug-private-key"));
assert!(!rendered.contains("171"));
assert!(!rendered.contains("222"));
}
}
}