Skip to main content

basil_core/core/seal/
cred.rs

1// SPDX-FileCopyrightText: 2026 OpenBasil Contributors
2//
3// SPDX-License-Identifier: Apache-2.0
4
5//! Per-backend credential payload (§4 of `designs/unlock-and-bundle.html`).
6//!
7//! The decrypted sealed-bundle payload is a [`CredBundle`]: a map of opaque
8//! backend id → [`BackendCred`]. Every secret field is wrapped in
9//! [`Zeroizing`] so the *whole* decrypted payload (not just the master KEK)
10//! is wiped on drop. The broker hands `creds[backend_id]` to the matching
11//! backend constructor at startup, then drops (zeroizes) the bundle.
12
13use std::collections::{BTreeMap, BTreeSet};
14
15use rand::RngCore;
16use serde::{Deserialize, Serialize};
17use zero_secrets::{SecretArray, SecretBytes, SecretString};
18
19/// Schema version of the cred payload, independent of the container
20/// `format_version`. Bump when [`BackendCred`] gains/changes a variant.
21pub const CRED_SCHEMA_VERSION: u16 = 2;
22
23/// The decrypted per-backend credential map.
24///
25/// This is the AEAD plaintext of the sealed payload (§2.3). It holds the
26/// broker's *own* bootstrap credentials, the secrets it needs to authenticate
27/// *to* each backend, and nothing else.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct CredBundle {
30    /// Payload schema version (see [`CRED_SCHEMA_VERSION`]).
31    pub schema_version: u16,
32    /// Opaque backend id → credential. The id is the same string the catalog
33    /// routes keys to.
34    pub backends: BTreeMap<String, BackendCred>,
35    /// Sealed credential-deposit material. The ingest private key and
36    /// contributor allow-list live inside the encrypted payload; append-only
37    /// deposit records outside the payload are useless without this material.
38    #[serde(default)]
39    pub deposit: DepositMaterial,
40}
41
42impl CredBundle {
43    /// An empty bundle at the current schema version.
44    #[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    /// Insert or replace the credential for `backend_id`.
54    pub fn set(&mut self, backend_id: impl Into<String>, cred: BackendCred) {
55        self.backends.insert(backend_id.into(), cred);
56    }
57
58    /// Ensure this bundle has an ingest identity for public credential deposits.
59    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    /// Return the public deposit recipient when an ingest identity exists.
69    #[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/// Sealed configuration that authorizes public-key credential deposits.
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct DepositMaterial {
86    /// Private X25519 ingest identity. Its public half is safe to publish; this
87    /// private half stays in the sealed payload and is recovered only after a
88    /// normal bundle unlock.
89    #[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    /// Contributor id → signing public key and delegated backend ids.
96    #[serde(default)]
97    pub contributors: BTreeMap<String, DepositContributor>,
98}
99
100impl DepositMaterial {
101    /// Empty deposit material.
102    #[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/// One allow-listed credential depositor.
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct DepositContributor {
120    /// Base64url-nopad Ed25519 verifying key.
121    pub public_key: String,
122    /// Backend ids this contributor may replace via the deposit log.
123    #[serde(default)]
124    pub allowed_backend_ids: BTreeSet<String>,
125}
126
127/// A single backend's bootstrap credential. Secret fields are `Zeroizing`.
128///
129/// Serialized with serde's external tagging (a `{ "<variant>": { … } }`
130/// object), the same style the wire protocol uses.
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub enum BackendCred {
133    /// Vault static token (the `bundle create --backend …,token-file=` cred).
134    /// Dev / simple setups.
135    VaultToken {
136        /// The bearer token sent as `X-Vault-Token`.
137        #[serde(with = "secret_string")]
138        token: SecretString,
139        /// Optional per-cred address override; otherwise the broker's
140        /// `--vault-addr` applies.
141        #[serde(default, skip_serializing_if = "Option::is_none")]
142        addr: Option<String>,
143    },
144
145    /// Vault `AppRole`: `secret_id` (+ `role_id`) exchanged for a short-lived
146    /// token at startup. Recommended production default (§4).
147    VaultAppRole {
148        /// Non-secret `role_id`.
149        role_id: String,
150        /// Secret `secret_id` (zeroized on drop).
151        #[serde(with = "secret_string")]
152        secret_id: SecretString,
153        /// Optional per-cred address override.
154        #[serde(default, skip_serializing_if = "Option::is_none")]
155        addr: Option<String>,
156    },
157
158    /// SPIFFE/JWT-SVID signing key for the `spiffe` backend path: a private key
159    /// the broker uses to self-issue an SVID, exchanged via bao `jwt` auth.
160    SpiffeSigner {
161        /// PEM-encoded private signing key (zeroized on drop).
162        #[serde(with = "secret_string")]
163        key_pem: SecretString,
164        /// SPIFFE id stamped into the SVID `sub`.
165        spiffe_id: String,
166    },
167
168    /// Future value-store backend (db-keystore): the DEK that opens the local DB.
169    DbKeystoreDek {
170        /// 32-byte data-encryption key (zeroized on drop).
171        #[serde(with = "secret_dek")]
172        dek: SecretArray<32>,
173    },
174
175    /// `1Password` provider configuration.
176    OnePassword {
177        /// Provider URI, for example `onepassword://vault` or
178        /// `onepassword+token://token@vault`.
179        provider_uri: String,
180        /// Item-title project namespace.
181        project: String,
182        /// Item-title profile.
183        profile: String,
184    },
185
186    /// AWS KMS transit backend addressing. Auth is the ambient AWS credential
187    /// chain (env / profile / IAM role / IMDS), so **no secret is sealed here**:
188    /// this variant carries only non-secret addressing.
189    AwsKms {
190        /// AWS region, for example `us-east-1`. Empty ⇒ SDK default resolution.
191        #[serde(default, skip_serializing_if = "String::is_empty")]
192        region: String,
193        /// Optional named profile from `~/.aws/config`. Empty ⇒ default chain.
194        #[serde(default, skip_serializing_if = "String::is_empty")]
195        profile: String,
196    },
197
198    /// GCP Cloud KMS transit backend addressing. Auth defaults to Application
199    /// Default Credentials; when `service_account_json` is present, the whole
200    /// service-account key file is sealed as one opaque secret. Crypto-key names
201    /// route under
202    /// `projects/{project}/locations/{location}/keyRings/{key_ring}`.
203    GcpKms {
204        /// GCP project id.
205        project: String,
206        /// KMS location, for example `global` or `us-west1`.
207        location: String,
208        /// Key ring name that holds the crypto keys.
209        key_ring: String,
210        /// Optional service-account JSON key. `None` uses ambient ADC.
211        #[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    /// Escape hatch for a backend kind not yet modelled: opaque labelled bytes.
220    Opaque {
221        /// Operator-facing kind label.
222        kind: String,
223        /// Opaque secret bytes (zeroized on drop).
224        #[serde(with = "secret_bytes")]
225        secret: SecretBytes,
226    },
227}
228
229/// serde for `SecretString` (passes through as a JSON string).
230mod 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
243/// serde for optional `SecretString` fields.
244mod secret_string_opt {
245    use serde::{Deserialize, Deserializer, Serializer};
246    use zero_secrets::SecretString;
247
248    #[allow(clippy::ref_option)] // serde `with` requires `&Option<T>` here.
249    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
261/// serde for `SecretBytes` (base64-nopad string, matching the container).
262mod 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
280/// serde for `SecretArray<32>` (base64-nopad, exactly 32 bytes).
281mod 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
300/// serde for optional `SecretArray<32>` fields.
301mod 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)] // serde `with` requires `&Option<T>` here.
308    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    /// Short, stable name for the variant (for audit/logging, never the bytes).
330    #[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        // `addr: None` is skipped, not serialized as null.
402        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}