1use std::collections::{BTreeMap, BTreeSet};
14
15use rand::RngCore;
16use serde::{Deserialize, Serialize};
17use zero_secrets::{SecretArray, SecretBytes, SecretString};
18
19pub const CRED_SCHEMA_VERSION: u16 = 2;
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct CredBundle {
30 pub schema_version: u16,
32 pub backends: BTreeMap<String, BackendCred>,
35 #[serde(default)]
39 pub deposit: DepositMaterial,
40}
41
42impl CredBundle {
43 #[must_use]
45 pub const fn empty() -> Self {
46 Self {
47 schema_version: CRED_SCHEMA_VERSION,
48 backends: BTreeMap::new(),
49 deposit: DepositMaterial::empty(),
50 }
51 }
52
53 pub fn set(&mut self, backend_id: impl Into<String>, cred: BackendCred) {
55 self.backends.insert(backend_id.into(), cred);
56 }
57
58 pub fn ensure_deposit_identity(&mut self) {
60 if self.deposit.ingest_private_key.is_some() {
61 return;
62 }
63 let mut seed = [0u8; 32];
64 rand::rngs::OsRng.fill_bytes(&mut seed);
65 self.deposit.ingest_private_key = Some(SecretArray::new(seed));
66 }
67
68 #[must_use]
70 pub fn deposit_recipient(&self) -> Option<[u8; 32]> {
71 let private = self.deposit.ingest_private_key.as_ref()?;
72 let private = zeroize::Zeroizing::new(private.expose_secret().try_into().ok()?);
73 Some(crate::core::x25519_seal::public_from_private(&private))
74 }
75}
76
77impl Default for CredBundle {
78 fn default() -> Self {
79 Self::empty()
80 }
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct DepositMaterial {
86 #[serde(
90 default,
91 skip_serializing_if = "Option::is_none",
92 with = "secret_array_32_opt"
93 )]
94 pub ingest_private_key: Option<SecretArray<32>>,
95 #[serde(default)]
97 pub contributors: BTreeMap<String, DepositContributor>,
98}
99
100impl DepositMaterial {
101 #[must_use]
103 pub const fn empty() -> Self {
104 Self {
105 ingest_private_key: None,
106 contributors: BTreeMap::new(),
107 }
108 }
109}
110
111impl Default for DepositMaterial {
112 fn default() -> Self {
113 Self::empty()
114 }
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct DepositContributor {
120 pub public_key: String,
122 #[serde(default)]
124 pub allowed_backend_ids: BTreeSet<String>,
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize)]
132pub enum BackendCred {
133 VaultToken {
136 #[serde(with = "secret_string")]
138 token: SecretString,
139 #[serde(default, skip_serializing_if = "Option::is_none")]
142 addr: Option<String>,
143 },
144
145 VaultAppRole {
148 role_id: String,
150 #[serde(with = "secret_string")]
152 secret_id: SecretString,
153 #[serde(default, skip_serializing_if = "Option::is_none")]
155 addr: Option<String>,
156 },
157
158 SpiffeSigner {
161 #[serde(with = "secret_string")]
163 key_pem: SecretString,
164 spiffe_id: String,
166 },
167
168 DbKeystoreDek {
170 #[serde(with = "secret_dek")]
172 dek: SecretArray<32>,
173 },
174
175 OnePassword {
177 provider_uri: String,
180 project: String,
182 profile: String,
184 },
185
186 AwsKms {
190 #[serde(default, skip_serializing_if = "String::is_empty")]
192 region: String,
193 #[serde(default, skip_serializing_if = "String::is_empty")]
195 profile: String,
196 },
197
198 GcpKms {
204 project: String,
206 location: String,
208 key_ring: String,
210 #[serde(
212 default,
213 skip_serializing_if = "Option::is_none",
214 with = "secret_string_opt"
215 )]
216 service_account_json: Option<SecretString>,
217 },
218
219 Opaque {
221 kind: String,
223 #[serde(with = "secret_bytes")]
225 secret: SecretBytes,
226 },
227}
228
229mod secret_string {
231 use serde::{Deserialize, Deserializer, Serializer};
232 use zero_secrets::SecretString;
233
234 pub fn serialize<S: Serializer>(v: &SecretString, s: S) -> Result<S::Ok, S::Error> {
235 s.serialize_str(v.expose_secret())
236 }
237
238 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<SecretString, D::Error> {
239 Ok(SecretString::new(String::deserialize(d)?))
240 }
241}
242
243mod secret_string_opt {
245 use serde::{Deserialize, Deserializer, Serializer};
246 use zero_secrets::SecretString;
247
248 #[allow(clippy::ref_option)] pub fn serialize<S: Serializer>(v: &Option<SecretString>, s: S) -> Result<S::Ok, S::Error> {
250 match v {
251 Some(secret) => s.serialize_some(secret.expose_secret()),
252 None => s.serialize_none(),
253 }
254 }
255
256 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<SecretString>, D::Error> {
257 Ok(Option::<String>::deserialize(d)?.map(SecretString::new))
258 }
259}
260
261mod secret_bytes {
263 use base64::Engine;
264 use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
265 use serde::{Deserialize, Deserializer, Serializer};
266 use zero_secrets::SecretBytes;
267
268 pub fn serialize<S: Serializer>(v: &SecretBytes, s: S) -> Result<S::Ok, S::Error> {
269 s.serialize_str(&B64.encode(v.expose_secret()))
270 }
271
272 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<SecretBytes, D::Error> {
273 let s = String::deserialize(d)?;
274 B64.decode(s.as_bytes())
275 .map(SecretBytes::new)
276 .map_err(serde::de::Error::custom)
277 }
278}
279
280mod secret_dek {
282 use base64::Engine;
283 use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
284 use serde::{Deserialize, Deserializer, Serializer};
285 use zero_secrets::SecretArray;
286
287 pub fn serialize<S: Serializer>(v: &SecretArray<32>, s: S) -> Result<S::Ok, S::Error> {
288 s.serialize_str(&B64.encode(v.expose_secret()))
289 }
290
291 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<SecretArray<32>, D::Error> {
292 let s = String::deserialize(d)?;
293 let v = B64.decode(s.as_bytes()).map_err(serde::de::Error::custom)?;
294 let arr = <[u8; 32]>::try_from(v.as_slice())
295 .map_err(|_| serde::de::Error::custom("dek must be 32 bytes"))?;
296 Ok(SecretArray::new(arr))
297 }
298}
299
300mod secret_array_32_opt {
302 use base64::Engine;
303 use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
304 use serde::{Deserialize, Deserializer, Serializer};
305 use zero_secrets::SecretArray;
306
307 #[allow(clippy::ref_option)] pub fn serialize<S: Serializer>(v: &Option<SecretArray<32>>, s: S) -> Result<S::Ok, S::Error> {
309 match v {
310 Some(secret) => s.serialize_some(&B64.encode(secret.expose_secret())),
311 None => s.serialize_none(),
312 }
313 }
314
315 pub fn deserialize<'de, D: Deserializer<'de>>(
316 d: D,
317 ) -> Result<Option<SecretArray<32>>, D::Error> {
318 let Some(s) = Option::<String>::deserialize(d)? else {
319 return Ok(None);
320 };
321 let bytes = B64.decode(s.as_bytes()).map_err(serde::de::Error::custom)?;
322 let arr = <[u8; 32]>::try_from(bytes.as_slice())
323 .map_err(|_| serde::de::Error::custom("secret array must be 32 bytes"))?;
324 Ok(Some(SecretArray::new(arr)))
325 }
326}
327
328impl BackendCred {
329 #[must_use]
331 pub const fn kind(&self) -> &'static str {
332 match self {
333 Self::VaultToken { .. } => "vault-token",
334 Self::VaultAppRole { .. } => "vault-approle",
335 Self::SpiffeSigner { .. } => "spiffe-signer",
336 Self::DbKeystoreDek { .. } => "db-keystore-dek",
337 Self::OnePassword { .. } => "onepassword",
338 Self::AwsKms { .. } => "aws-kms",
339 Self::GcpKms { .. } => "gcp-kms",
340 Self::Opaque { .. } => "opaque",
341 }
342 }
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348 use zero_secrets::{SecretArray, SecretBytes, SecretString};
349
350 #[test]
351 fn round_trip_json() {
352 let mut bundle = CredBundle::empty();
353 bundle.set(
354 "vault-transit",
355 BackendCred::VaultToken {
356 token: SecretString::new("s.deadbeef".to_string()),
357 addr: Some("http://127.0.0.1:8200".to_string()),
358 },
359 );
360 bundle.set(
361 "vault-approle",
362 BackendCred::VaultAppRole {
363 role_id: "role-123".to_string(),
364 secret_id: SecretString::new("secret-456".to_string()),
365 addr: None,
366 },
367 );
368 let json = serde_json::to_vec(&bundle).unwrap();
369 let back: CredBundle = serde_json::from_slice(&json).unwrap();
370 assert_eq!(back.schema_version, CRED_SCHEMA_VERSION);
371 assert_eq!(back.backends.len(), 2);
372 match back.backends.get("vault-transit") {
373 Some(BackendCred::VaultToken { token, addr }) => {
374 assert_eq!(token.expose_secret(), "s.deadbeef");
375 assert_eq!(addr.as_deref(), Some("http://127.0.0.1:8200"));
376 }
377 other => panic!("wrong variant: {:?}", other.map(BackendCred::kind)),
378 }
379 match back.backends.get("vault-approle") {
380 Some(BackendCred::VaultAppRole {
381 role_id,
382 secret_id,
383 addr,
384 }) => {
385 assert_eq!(role_id, "role-123");
386 assert_eq!(secret_id.expose_secret(), "secret-456");
387 assert!(addr.is_none());
388 }
389 other => panic!("wrong variant: {:?}", other.map(BackendCred::kind)),
390 }
391 }
392
393 #[test]
394 fn approle_omits_absent_addr() {
395 let cred = BackendCred::VaultAppRole {
396 role_id: "r".to_string(),
397 secret_id: SecretString::new("s".to_string()),
398 addr: None,
399 };
400 let v = serde_json::to_value(&cred).unwrap();
401 assert!(v["VaultAppRole"].get("addr").is_none());
403 }
404
405 #[test]
406 fn debug_redacts_secret_fields() {
407 let cases = [
408 format!(
409 "{:?}",
410 BackendCred::VaultToken {
411 token: SecretString::new("s.debug-token".to_string()),
412 addr: None,
413 }
414 ),
415 format!(
416 "{:?}",
417 BackendCred::VaultAppRole {
418 role_id: "role".to_string(),
419 secret_id: SecretString::new("debug-secret-id".to_string()),
420 addr: None,
421 }
422 ),
423 format!(
424 "{:?}",
425 BackendCred::SpiffeSigner {
426 key_pem: SecretString::new("-----BEGIN PRIVATE KEY-----".to_string()),
427 spiffe_id: "spiffe://example.test/basil".to_string(),
428 }
429 ),
430 format!(
431 "{:?}",
432 BackendCred::DbKeystoreDek {
433 dek: SecretArray::new([0xabu8; 32]),
434 }
435 ),
436 format!(
437 "{:?}",
438 BackendCred::GcpKms {
439 project: "p".to_string(),
440 location: "global".to_string(),
441 key_ring: "ring".to_string(),
442 service_account_json: Some(SecretString::new(
443 "{\"private_key\":\"debug-private-key\"}".to_string(),
444 )),
445 }
446 ),
447 format!(
448 "{:?}",
449 BackendCred::Opaque {
450 kind: "test".to_string(),
451 secret: SecretBytes::new(vec![0xde, 0xad, 0xbe, 0xef]),
452 }
453 ),
454 ];
455
456 for rendered in cases {
457 assert!(rendered.contains("REDACTED"));
458 assert!(!rendered.contains("debug-token"));
459 assert!(!rendered.contains("debug-secret-id"));
460 assert!(!rendered.contains("PRIVATE KEY"));
461 assert!(!rendered.contains("debug-private-key"));
462 assert!(!rendered.contains("171"));
463 assert!(!rendered.contains("222"));
464 }
465 }
466}