Skip to main content

workload_spec/
secrets.rs

1//! Pluggable secret resolver for [`crate::SecretRef`] values, plus the access
2//! rule that decides which workloads a cluster secret may be served to.
3//!
4//! The trait lives in `workload-spec` so consumers can construct specs and
5//! invoke the resolver without linking yubaba's containerd client. Yubaba
6//! provides the production impl in `crates/yah/yubaba/src/secrets.rs`.
7//!
8//! ## Access rules (R706 / W294)
9//!
10//! Before R706, `SecretRef::Cluster { name }` was a **bearer reference**:
11//! naming the secret was the entire authorization. [`SecretAccess`] closes
12//! that — it rides on the stored record, so the check happens on the node at
13//! mount time, where it cannot be routed around by a hand-rolled deploy.
14//!
15//! The vocabulary is [`WorkloadSpec`](crate::WorkloadSpec) fields
16//! ([`SecretConsumer`]) rather than, say, cheers principals, because those are
17//! the only identity the enforcement point actually holds: at mount time yubaba
18//! has a `WorkloadSpec` and nothing else.
19//!
20//! Fail-closed by construction: [`SecretAccess::default`] is an **empty**
21//! allow-list, which admits nobody. A legacy record written before this field
22//! existed deserializes to that default, so it is refused rather than granted.
23
24use std::path::PathBuf;
25
26use serde::{Deserialize, Serialize};
27use thiserror::Error;
28
29use crate::{NamespaceId, SecretRef, TenantId, WorkloadSpec};
30
31/// Errors returned by [`SecretResolver::resolve`].
32#[derive(Debug, Error)]
33pub enum SecretError {
34    /// The referenced secret file does not exist in the yubaba secret store.
35    #[error("secret not found at {path}")]
36    NotFound { path: PathBuf },
37
38    /// `SecretRef::Cluster` reached a resolver that has no cluster backing —
39    /// e.g. the per-machine `LocalFileResolver`, which cannot decrypt cluster
40    /// secrets. The fleet resolver (yubaba's `ClusterResolver`) handles the
41    /// `Cluster` arm; this error means the wrong resolver was used.
42    #[error("cluster secrets require a cluster-backed resolver")]
43    ClusterNotImplemented,
44
45    /// The referenced cluster secret is not present in the local raft replica
46    /// (never written, or deleted). Fails closed — nothing is served.
47    #[error("cluster secret {name} not found in the local raft replica")]
48    ClusterNotFound { name: String },
49
50    /// The cluster secret exists but its [`SecretAccess`] rule does not admit
51    /// the requesting workload (R706 / W294).
52    ///
53    /// **The `#[error(...)]` text is a deliberate byte-for-byte duplicate of
54    /// [`SecretError::ClusterNotFound`]'s.** Yubaba surfaces the `Display` form
55    /// of this error in the deploy rejection body, so a distinguishable message
56    /// would turn any workload spec into an oracle for the cluster's secret
57    /// namespace: deploy a throwaway spec naming a guessed secret and read off
58    /// "forbidden" (it exists) versus "not found" (it doesn't). The variants
59    /// stay separate *internally* — the node logs which one it was, and
60    /// `secrets_forbidden_is_externally_indistinguishable` pins the equality so
61    /// a future edit to either message can't silently reopen the oracle.
62    #[error("cluster secret {name} not found in the local raft replica")]
63    Forbidden { name: String },
64
65    /// Decryption or authentication of a cluster secret failed — a wrong
66    /// node-local KEK, a truncated/tampered record, or a malformed nonce. Fails
67    /// closed; the message carries only the logical name, never key or
68    /// ciphertext bytes.
69    #[error("cluster secret {name} failed to decrypt")]
70    ClusterDecrypt { name: String },
71
72    /// The node-local cluster KEK could not be loaded (missing, unreadable, or
73    /// not exactly 32 bytes). Fails closed; `reason` is a generic diagnostic
74    /// and never contains key material.
75    #[error("cluster KEK unavailable: {reason}")]
76    Kek { reason: String },
77
78    /// I/O error reading the secret file.
79    #[error("I/O error reading {path}: {source}")]
80    Io {
81        path: PathBuf,
82        #[source]
83        source: std::io::Error,
84    },
85}
86
87/// Resolves a [`SecretRef`] to its raw byte content.
88///
89/// The trait is defined here (in `workload-spec`) so callers don't need to
90/// link yubaba. Yubaba's `LocalFileResolver` reads from the per-machine secret
91/// store at `/var/lib/yah/yubaba/secrets/`. Tests use an inline `FakeResolver`.
92pub trait SecretResolver {
93    fn resolve(&self, r: &SecretRef) -> Result<Vec<u8>, SecretError>;
94}
95
96// ── Access rules (R706 / W294) ────────────────────────────────────────────────
97
98/// The identity a cluster-secret access rule is evaluated against: the
99/// requesting workload, as yubaba knows it at mount time.
100///
101/// Built from a [`WorkloadSpec`] via [`SecretConsumer::of`]. These three fields
102/// are the whole vocabulary because they are the whole identity available at the
103/// enforcement point — yubaba resolves secrets while holding a spec, with no
104/// cheers principal and no spec→principal mapping in reach.
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
107pub struct SecretConsumer {
108    /// [`WorkloadSpec::name`] — the DNS-friendly workload name.
109    pub workload: String,
110    /// [`WorkloadSpec::tenant`] — the isolation axis (W206).
111    pub tenant: TenantId,
112    /// [`WorkloadSpec::namespace`] — the routing/naming axis (W206).
113    pub namespace: NamespaceId,
114}
115
116impl SecretConsumer {
117    /// The consumer identity of `spec`.
118    pub fn of(spec: &WorkloadSpec) -> Self {
119        Self {
120            workload: spec.name.clone(),
121            tenant: spec.tenant.clone(),
122            namespace: spec.namespace.clone(),
123        }
124    }
125
126    /// A consumer in the singleton tenant/namespace — the shape every spec on a
127    /// single-tenant fleet has. Convenience for tests and for authoring rules.
128    pub fn workload(name: impl Into<String>) -> Self {
129        Self {
130            workload: name.into(),
131            tenant: TenantId::singleton(),
132            namespace: NamespaceId::singleton(),
133        }
134    }
135}
136
137/// One entry in a [`SecretAccess::Workloads`] allow-list.
138///
139/// A match requires **all three** fields to be equal. `tenant` and `namespace`
140/// default to their singletons rather than to a wildcard: on today's
141/// single-tenant fleet that makes them free to omit, and it means a rule written
142/// today cannot silently widen to admit a same-named workload in a tenant that
143/// gets created tomorrow. Cross-tenant sharing is spelled as two entries.
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
146pub struct WorkloadMatch {
147    /// The admitted [`WorkloadSpec::name`].
148    pub workload: String,
149    /// Tenant the workload must be in. Defaults to [`TenantId::singleton`].
150    #[serde(default = "TenantId::singleton")]
151    pub tenant: TenantId,
152    /// Namespace the workload must be in. Defaults to [`NamespaceId::singleton`].
153    #[serde(default = "NamespaceId::singleton")]
154    pub namespace: NamespaceId,
155}
156
157impl WorkloadMatch {
158    /// A match on `name` in the singleton tenant/namespace.
159    pub fn workload(name: impl Into<String>) -> Self {
160        Self {
161            workload: name.into(),
162            tenant: TenantId::singleton(),
163            namespace: NamespaceId::singleton(),
164        }
165    }
166
167    /// Whether `consumer` satisfies this entry.
168    pub fn admits(&self, consumer: &SecretConsumer) -> bool {
169        self.workload == consumer.workload
170            && self.tenant == consumer.tenant
171            && self.namespace == consumer.namespace
172    }
173}
174
175/// Who may be served a given cluster secret.
176///
177/// Stored alongside the ciphertext (yubaba's `SecretRecord`) so the check rides
178/// on the record itself and is evaluated on the node at mount time — a rule
179/// checked only by the tool that authors a deploy is a lint, not a rule.
180///
181/// [`Default`] is `Workloads(vec![])`, which admits nobody. That is what makes
182/// the migration fail closed: a record serialized before this field existed
183/// deserializes (via `#[serde(default)]`) to an empty allow-list and is refused,
184/// rather than being implicitly granted to everyone.
185#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
186#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
187#[serde(rename_all = "snake_case")]
188pub enum SecretAccess {
189    /// Deliberately unrestricted: any workload that names this secret gets it.
190    ///
191    /// This is the *explicit* escape hatch, never an implicit one. It has to be
192    /// written into the record by whoever put the secret there, and it shows up
193    /// in `yah cloud secret ls` as `allow-any`, so an unrestricted secret is an
194    /// auditable choice rather than the silent default.
195    AllowAny,
196
197    /// Only workloads matching one of these entries. An empty list admits
198    /// nobody — see the type-level note on fail-closed defaulting.
199    Workloads(Vec<WorkloadMatch>),
200}
201
202impl Default for SecretAccess {
203    fn default() -> Self {
204        Self::Workloads(Vec::new())
205    }
206}
207
208impl SecretAccess {
209    /// Allow exactly the named workloads, in the singleton tenant/namespace.
210    pub fn workloads<I, S>(names: I) -> Self
211    where
212        I: IntoIterator<Item = S>,
213        S: Into<String>,
214    {
215        Self::Workloads(names.into_iter().map(WorkloadMatch::workload).collect())
216    }
217
218    /// Whether `consumer` may be served the secret this rule guards.
219    pub fn admits(&self, consumer: &SecretConsumer) -> bool {
220        match self {
221            Self::AllowAny => true,
222            Self::Workloads(entries) => entries.iter().any(|e| e.admits(consumer)),
223        }
224    }
225
226    /// Short operator-facing rendering for `yah cloud secret ls`.
227    pub fn summary(&self) -> String {
228        match self {
229            Self::AllowAny => "allow-any".to_string(),
230            Self::Workloads(entries) if entries.is_empty() => "deny-all (no rule)".to_string(),
231            Self::Workloads(entries) => entries
232                .iter()
233                .map(|e| {
234                    if e.tenant.is_singleton() && e.namespace.is_singleton() {
235                        e.workload.clone()
236                    } else {
237                        format!("{}/{}/{}", e.tenant.0, e.namespace.0, e.workload)
238                    }
239                })
240                .collect::<Vec<_>>()
241                .join(", "),
242        }
243    }
244}
245
246// ── Sealing (R706 / W294, `seal` feature) ────────────────────────────────────
247
248/// A cluster secret's sealed bytes: AES-256-GCM ciphertext plus the 12-byte
249/// nonce it was sealed under.
250///
251/// Deliberately *not* the storage record — yubaba's `SecretRecord` adds the
252/// timestamp and the access rule and lives in the raft layer. This is only the
253/// cryptographic output, which is the part both writers share.
254#[cfg(feature = "seal")]
255#[derive(Debug, Clone, PartialEq, Eq)]
256pub struct Sealed {
257    /// AES-256-GCM output: sealed bytes with the GCM tag appended.
258    pub ciphertext: Vec<u8>,
259    /// The 12-byte GCM nonce, freshly drawn for this call.
260    pub nonce: Vec<u8>,
261}
262
263/// AES-256-GCM-seal `plaintext` under the 32-byte cluster `kek`.
264///
265/// A cryptographically-random 12-byte nonce is drawn **per call**, so re-sealing
266/// identical plaintext (a rotation, a re-ship of an unchanged value) never
267/// reuses a nonce. That is the whole reason this lives in one place: nonce reuse
268/// under a fixed key is catastrophic for GCM, and it is exactly the invariant
269/// that erodes when two call sites each roll their own seal.
270///
271/// Infallible by construction: the only error `aead` can return here is a
272/// plaintext-length overflow far beyond any credential.
273#[cfg(feature = "seal")]
274pub fn seal(kek: &[u8; 32], plaintext: &[u8]) -> Sealed {
275    use aes_gcm::aead::{Aead, AeadCore, OsRng};
276    use aes_gcm::{Aes256Gcm, Key, KeyInit};
277
278    let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(kek));
279    let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
280    let ciphertext = cipher
281        .encrypt(&nonce, plaintext)
282        .expect("AES-256-GCM seal of a KB-scale secret cannot fail on length");
283    Sealed {
284        ciphertext,
285        nonce: nonce.to_vec(),
286    }
287}
288
289/// Draw 32 cryptographically-secure random bytes for a fresh cluster KEK.
290///
291/// Same `OsRng` [`seal`] draws its nonces from, on purpose: a KEK minted from a
292/// weaker source would silently undermine every secret sealed under it, and
293/// pulling a second RNG dependency into the camp is how that happens.
294#[cfg(feature = "seal")]
295pub fn generate_kek() -> zeroize::Zeroizing<[u8; 32]> {
296    use aes_gcm::aead::rand_core::RngCore;
297    let mut kek = zeroize::Zeroizing::new([0u8; 32]);
298    aes_gcm::aead::OsRng.fill_bytes(kek.as_mut());
299    kek
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    #[cfg(feature = "seal")]
307    #[test]
308    fn seal_draws_a_fresh_nonce_per_call() {
309        let kek = [7u8; 32];
310        let a = seal(&kek, b"same-plaintext");
311        let b = seal(&kek, b"same-plaintext");
312        assert_eq!(a.nonce.len(), 12);
313        assert_ne!(a.nonce, b.nonce, "nonce must never repeat under one key");
314        assert_ne!(a.ciphertext, b.ciphertext);
315        assert_ne!(a.ciphertext, b"same-plaintext".to_vec());
316    }
317
318    #[cfg(feature = "seal")]
319    #[test]
320    fn generated_keks_are_32_bytes_and_distinct() {
321        let a = generate_kek();
322        let b = generate_kek();
323        assert_eq!(a.len(), 32);
324        assert_ne!(*a, *b, "two mints must not collide");
325        assert_ne!(*a, [0u8; 32], "must not be all-zero");
326    }
327
328    #[test]
329    fn default_access_admits_nobody() {
330        // The fail-closed migration hinges on exactly this.
331        let rule = SecretAccess::default();
332        assert!(!rule.admits(&SecretConsumer::workload("yah-cloud-admin")));
333        assert_eq!(rule.summary(), "deny-all (no rule)");
334    }
335
336    #[test]
337    fn legacy_record_shape_deserializes_to_deny_all() {
338        // A record serialized before the field existed: serde(default) must land
339        // on deny-all, not allow-all.
340        #[derive(Deserialize)]
341        struct Legacyish {
342            #[serde(default)]
343            access: SecretAccess,
344        }
345        let v: Legacyish = serde_json::from_str("{}").unwrap();
346        assert!(!v.access.admits(&SecretConsumer::workload("anything")));
347    }
348
349    #[test]
350    fn allow_list_matches_on_all_three_axes() {
351        let rule = SecretAccess::workloads(["yah-cloud-admin"]);
352        assert!(rule.admits(&SecretConsumer::workload("yah-cloud-admin")));
353        assert!(!rule.admits(&SecretConsumer::workload("other-service")));
354
355        // Same name, different tenant → refused (the entry defaulted to the
356        // singleton tenant, and defaults are narrowing, not widening).
357        let other_tenant = SecretConsumer {
358            workload: "yah-cloud-admin".into(),
359            tenant: TenantId("acme".into()),
360            namespace: NamespaceId::singleton(),
361        };
362        assert!(!rule.admits(&other_tenant));
363    }
364
365    #[test]
366    fn allow_any_is_explicit_and_visible() {
367        let rule = SecretAccess::AllowAny;
368        assert!(rule.admits(&SecretConsumer::workload("anything-at-all")));
369        assert_eq!(rule.summary(), "allow-any");
370        // And it must survive a round-trip as a distinct, greppable token.
371        let json = serde_json::to_string(&rule).unwrap();
372        assert_eq!(json, "\"allow_any\"");
373    }
374
375    #[test]
376    fn omitted_tenant_and_namespace_default_to_singleton() {
377        let m: WorkloadMatch = serde_json::from_str(r#"{"workload":"api"}"#).unwrap();
378        assert_eq!(m.tenant, TenantId::singleton());
379        assert_eq!(m.namespace, NamespaceId::singleton());
380    }
381
382    #[test]
383    fn secrets_forbidden_is_externally_indistinguishable() {
384        // A probing spec must not be able to tell "exists but denied" from
385        // "does not exist" — see the note on SecretError::Forbidden.
386        let denied = SecretError::Forbidden {
387            name: "cheers/cloud-admin/verify-key".into(),
388        };
389        let absent = SecretError::ClusterNotFound {
390            name: "cheers/cloud-admin/verify-key".into(),
391        };
392        assert_eq!(denied.to_string(), absent.to_string());
393    }
394}