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, a malformed nonce, or
67    /// (R911-F4) a record whose name or access rule is not the one it was
68    /// sealed under ([`secret_aad`]): a widened rule or a ciphertext copied to
69    /// another name. Fails closed; the message carries only the logical name,
70    /// never key or ciphertext bytes.
71    #[error("cluster secret {name} failed to decrypt")]
72    ClusterDecrypt { name: String },
73
74    /// The cluster secret store could not answer for `name` — the fleet object
75    /// store is unreachable, returned a malformed record, refused the name as a
76    /// key, or this node has no store configured at all (R911-F1).
77    ///
78    /// **Deliberately distinct from [`SecretError::ClusterNotFound`].** A
79    /// caller that treats absence as a decision — headscale minting a fresh
80    /// noise identity when the store holds none — must not reach that decision
81    /// because a bucket blipped. The message carries only the logical name; the
82    /// node logs the underlying store error. It is not a namespace oracle: an
83    /// outage answers the same for every name.
84    #[error("cluster secret {name} is unavailable: the cluster secret store could not be read")]
85    ClusterUnavailable { name: String },
86
87    /// The node-local cluster KEK could not be loaded (missing, unreadable, or
88    /// not exactly 32 bytes). Fails closed; `reason` is a generic diagnostic
89    /// and never contains key material.
90    #[error("cluster KEK unavailable: {reason}")]
91    Kek { reason: String },
92
93    /// I/O error on a secret file. `op` is the operation that failed, as a
94    /// present participle (`"reading"`, `"writing"`, `"creating"`, …).
95    ///
96    /// R848: the message used to hardcode "reading" while yubaba's *writer*
97    /// (`deploy::secret_mount::write_secret_file`) reused the variant for its
98    /// writes. Yubaba surfaces this `Display` form in the 422 deploy-rejection
99    /// body, so an EACCES writing the tmpfs file read as a resolver failure and
100    /// sent the operator to the cluster KEK instead of to the file being
101    /// written two lines down. Naming the operation is the whole fix.
102    #[error("I/O error {op} {path}: {source}")]
103    Io {
104        op: &'static str,
105        path: PathBuf,
106        #[source]
107        source: std::io::Error,
108    },
109}
110
111/// Resolves a [`SecretRef`] to its raw byte content.
112///
113/// The trait is defined here (in `workload-spec`) so callers don't need to
114/// link yubaba. Yubaba's `LocalFileResolver` reads from the per-machine secret
115/// store at `/var/lib/yah/yubaba/secrets/`. Tests use an inline `FakeResolver`.
116pub trait SecretResolver {
117    fn resolve(&self, r: &SecretRef) -> Result<Vec<u8>, SecretError>;
118}
119
120// ── Access rules (R706 / W294) ────────────────────────────────────────────────
121
122/// The identity a cluster-secret access rule is evaluated against: the
123/// requesting workload, as yubaba knows it at mount time.
124///
125/// Built from a [`WorkloadSpec`] via [`SecretConsumer::of`]. These three fields
126/// are the whole vocabulary because they are the whole identity available at the
127/// enforcement point — yubaba resolves secrets while holding a spec, with no
128/// cheers principal and no spec→principal mapping in reach.
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
131pub struct SecretConsumer {
132    /// [`WorkloadSpec::name`] — the DNS-friendly workload name.
133    pub workload: String,
134    /// [`WorkloadSpec::tenant`] — the isolation axis (W206).
135    pub tenant: TenantId,
136    /// [`WorkloadSpec::namespace`] — the routing/naming axis (W206).
137    pub namespace: NamespaceId,
138    /// The signed recipe this run was admitted as, when it carried a grant that
139    /// **verified** (R555-F5). `None` for every ordinary service workload, and
140    /// for any spec whose grant did not verify — see [`RecipeIdentity`].
141    #[serde(default)]
142    pub recipe: Option<RecipeIdentity>,
143}
144
145/// Who a remote run proved itself to be, cryptographically.
146///
147/// A forge workload's [`WorkloadSpec::name`] is a fresh `forge-<uuid>` per run,
148/// so it can never appear in an allow-list written in advance — which left
149/// [`SecretAccess::AllowAny`] as the only rule under which a dispatched recipe
150/// could read a cluster secret at all. That is precisely the ambient grant W235
151/// §(c) says must not be how a remote build gets the R2 and cosign keys.
152///
153/// This is the durable identity underneath the ephemeral one: the recipe name
154/// out of a verified admission grant, plus the key that vouched for it. Both
155/// halves matter — the name alone would let anyone holding *any* trusted key
156/// mint a grant claiming to be `rusty-v8-musl`.
157///
158/// Construct only from
159/// [`admission::admit_grant`](crate::admission::admit_grant)'s return value.
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
162pub struct RecipeIdentity {
163    /// `AdmissionGrant::recipe` from the verified grant.
164    pub recipe: String,
165    /// Hex Ed25519 public key that signed it, as pinned on the node.
166    pub key: String,
167}
168
169impl SecretConsumer {
170    /// The consumer identity of `spec`.
171    ///
172    /// Carries no recipe identity: this constructor sees only the spec, and a
173    /// recipe identity is a claim about a signature. Add one with
174    /// [`SecretConsumer::admitted_as`] after verifying.
175    pub fn of(spec: &WorkloadSpec) -> Self {
176        Self {
177            workload: spec.name.clone(),
178            tenant: spec.tenant.clone(),
179            namespace: spec.namespace.clone(),
180            recipe: None,
181        }
182    }
183
184    /// Attach the recipe identity a verified admission grant established.
185    pub fn admitted_as(mut self, recipe: RecipeIdentity) -> Self {
186        self.recipe = Some(recipe);
187        self
188    }
189
190    /// A consumer in the singleton tenant/namespace — the shape every spec on a
191    /// single-tenant fleet has. Convenience for tests and for authoring rules.
192    pub fn workload(name: impl Into<String>) -> Self {
193        Self {
194            workload: name.into(),
195            tenant: TenantId::singleton(),
196            namespace: NamespaceId::singleton(),
197            recipe: None,
198        }
199    }
200}
201
202/// One entry in a [`SecretAccess::Workloads`] allow-list.
203///
204/// A match requires **all three** fields to be equal. `tenant` and `namespace`
205/// default to their singletons rather than to a wildcard: on today's
206/// single-tenant fleet that makes them free to omit, and it means a rule written
207/// today cannot silently widen to admit a same-named workload in a tenant that
208/// gets created tomorrow. Cross-tenant sharing is spelled as two entries.
209#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
210#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
211pub struct WorkloadMatch {
212    /// The admitted [`WorkloadSpec::name`].
213    pub workload: String,
214    /// Tenant the workload must be in. Defaults to [`TenantId::singleton`].
215    #[serde(default = "TenantId::singleton")]
216    pub tenant: TenantId,
217    /// Namespace the workload must be in. Defaults to [`NamespaceId::singleton`].
218    #[serde(default = "NamespaceId::singleton")]
219    pub namespace: NamespaceId,
220}
221
222impl WorkloadMatch {
223    /// A match on `name` in the singleton tenant/namespace.
224    pub fn workload(name: impl Into<String>) -> Self {
225        Self {
226            workload: name.into(),
227            tenant: TenantId::singleton(),
228            namespace: NamespaceId::singleton(),
229        }
230    }
231
232    /// Whether `consumer` satisfies this entry.
233    pub fn admits(&self, consumer: &SecretConsumer) -> bool {
234        self.workload == consumer.workload
235            && self.tenant == consumer.tenant
236            && self.namespace == consumer.namespace
237    }
238}
239
240/// Who may be served a given cluster secret.
241///
242/// Stored alongside the ciphertext (yubaba's `SecretRecord`) so the check rides
243/// on the record itself and is evaluated on the node at mount time — a rule
244/// checked only by the tool that authors a deploy is a lint, not a rule.
245///
246/// [`Default`] is `Workloads(vec![])`, which admits nobody. That is what makes
247/// the migration fail closed: a record serialized before this field existed
248/// deserializes (via `#[serde(default)]`) to an empty allow-list and is refused,
249/// rather than being implicitly granted to everyone.
250#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
251#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
252#[serde(rename_all = "snake_case")]
253pub enum SecretAccess {
254    /// Deliberately unrestricted: any workload that names this secret gets it.
255    ///
256    /// This is the *explicit* escape hatch, never an implicit one. It has to be
257    /// written into the record by whoever put the secret there, and it shows up
258    /// in `yah cloud secret ls` as `allow-any`, so an unrestricted secret is an
259    /// auditable choice rather than the silent default.
260    AllowAny,
261
262    /// Only workloads matching one of these entries. An empty list admits
263    /// nobody — see the type-level note on fail-closed defaulting.
264    Workloads(Vec<WorkloadMatch>),
265
266    /// Only runs of one of these **signed recipes** (R555-F5 / W235 §(c)).
267    ///
268    /// The rule a dispatched build needs: its workload name is a per-run
269    /// `forge-<uuid>` that no allow-list can name in advance, so
270    /// [`SecretAccess::Workloads`] cannot express "the rusty-v8-musl build may
271    /// read the R2 write key" and [`SecretAccess::AllowAny`] over-answers it by
272    /// handing that key to anything that can reach the node.
273    ///
274    /// Matching consumes a [`RecipeIdentity`] that only exists on the far side
275    /// of a verified Ed25519 grant, so this is *narrower* than the workload
276    /// rule, not a loophole in it: the requester has to be running argv the
277    /// recipe author signed, on a node that pins the author's key.
278    Recipes(Vec<RecipeMatch>),
279}
280
281/// One entry in a [`SecretAccess::Recipes`] allow-list.
282///
283/// Both fields are required and both are compared exactly. `key` is here
284/// because the recipe *name* is chosen by whoever writes the recipe: without
285/// it, any holder of any key the node trusts could sign a recipe called
286/// `rusty-v8-musl` and inherit its credentials.
287#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
288#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
289pub struct RecipeMatch {
290    /// The admitted recipe name, as it appears in the signed grant.
291    pub recipe: String,
292    /// Hex Ed25519 public key that must have signed the grant.
293    pub key: String,
294}
295
296impl RecipeMatch {
297    /// Whether `consumer` presents a verified identity this entry admits.
298    pub fn admits(&self, consumer: &SecretConsumer) -> bool {
299        consumer
300            .recipe
301            .as_ref()
302            .is_some_and(|id| id.recipe == self.recipe && id.key == self.key)
303    }
304}
305
306impl Default for SecretAccess {
307    fn default() -> Self {
308        Self::Workloads(Vec::new())
309    }
310}
311
312impl SecretAccess {
313    /// Allow exactly the named workloads, in the singleton tenant/namespace.
314    pub fn workloads<I, S>(names: I) -> Self
315    where
316        I: IntoIterator<Item = S>,
317        S: Into<String>,
318    {
319        Self::Workloads(names.into_iter().map(WorkloadMatch::workload).collect())
320    }
321
322    /// Whether `consumer` may be served the secret this rule guards.
323    /// Allow exactly the named recipes, each signed by the given hex key.
324    pub fn recipes<I, N, K>(entries: I) -> Self
325    where
326        I: IntoIterator<Item = (N, K)>,
327        N: Into<String>,
328        K: Into<String>,
329    {
330        Self::Recipes(
331            entries
332                .into_iter()
333                .map(|(recipe, key)| RecipeMatch {
334                    recipe: recipe.into(),
335                    key: key.into(),
336                })
337                .collect(),
338        )
339    }
340
341    pub fn admits(&self, consumer: &SecretConsumer) -> bool {
342        match self {
343            Self::AllowAny => true,
344            Self::Workloads(entries) => entries.iter().any(|e| e.admits(consumer)),
345            Self::Recipes(entries) => entries.iter().any(|e| e.admits(consumer)),
346        }
347    }
348
349    /// Short operator-facing rendering for `yah cloud secret ls`.
350    pub fn summary(&self) -> String {
351        match self {
352            Self::AllowAny => "allow-any".to_string(),
353            Self::Recipes(entries) if entries.is_empty() => "deny-all (no rule)".to_string(),
354            Self::Recipes(entries) => entries
355                .iter()
356                // Keys are 64 hex chars; a truncated prefix is enough to tell
357                // two signing identities apart in a table without wrapping it.
358                .map(|e| format!("recipe {}@{}", e.recipe, &e.key[..e.key.len().min(8)]))
359                .collect::<Vec<_>>()
360                .join(", "),
361            Self::Workloads(entries) if entries.is_empty() => "deny-all (no rule)".to_string(),
362            Self::Workloads(entries) => entries
363                .iter()
364                .map(|e| {
365                    if e.tenant.is_singleton() && e.namespace.is_singleton() {
366                        e.workload.clone()
367                    } else {
368                        format!("{}/{}/{}", e.tenant.0, e.namespace.0, e.workload)
369                    }
370                })
371                .collect::<Vec<_>>()
372                .join(", "),
373        }
374    }
375}
376
377// ── Associated data (R911-F4) ────────────────────────────────────────────────
378
379/// The version tag every sealed-secret associated-data block starts with.
380pub const SECRET_AAD_V1: &[u8] = b"yah/secret-aad/v1\0";
381
382/// The AEAD associated data a cluster secret is sealed and opened under: its
383/// logical `name` and its `access` rule (R911-F4). **The only builder** — the
384/// camp's `yah cloud secret put`, the node's issuers, and the node's resolver
385/// all call this, so the two sides cannot encode it differently.
386///
387/// Why it exists: in the fleet object store a record's rule is plaintext JSON
388/// beside its ciphertext. Binding both here means a bucket writer who widens a
389/// rule, or copies one name's ciphertext under another name, produces a record
390/// whose GCM tag no longer verifies.
391///
392/// The layout is a contract, pinned byte-for-byte by `aad_bytes_are_pinned`:
393///
394/// 1. [`SECRET_AAD_V1`];
395/// 2. the name: u32 big-endian byte length, then its UTF-8;
396/// 3. the rule's variant tag (`allow_any` / `workloads` / `recipes`), length-
397///    prefixed the same way;
398/// 4. for a list variant, a u32 big-endian entry count, then each entry's
399///    fields in declared order (`workload`, `tenant`, `namespace` /
400///    `recipe`, `key`), each length-prefixed.
401///
402/// Entries are bound in their **stored** order, never sorted: reordering a
403/// rule is an edit to it. Deliberately not `serde_json` — map order and
404/// whitespace are not a contract anyone promised to keep.
405pub fn secret_aad(name: &str, access: &SecretAccess) -> Vec<u8> {
406    fn field(out: &mut Vec<u8>, bytes: &[u8]) {
407        let len = u32::try_from(bytes.len()).expect("a secret name or rule field over 4 GiB");
408        out.extend_from_slice(&len.to_be_bytes());
409        out.extend_from_slice(bytes);
410    }
411    fn count(out: &mut Vec<u8>, n: usize) {
412        let n = u32::try_from(n).expect("an access rule with over 4 billion entries");
413        out.extend_from_slice(&n.to_be_bytes());
414    }
415
416    let mut out = SECRET_AAD_V1.to_vec();
417    field(&mut out, name.as_bytes());
418    // Exhaustive matches and destructures on purpose: a new rule variant or a
419    // new entry field does not compile until it chooses its encoding here.
420    match access {
421        SecretAccess::AllowAny => field(&mut out, b"allow_any"),
422        SecretAccess::Workloads(entries) => {
423            field(&mut out, b"workloads");
424            count(&mut out, entries.len());
425            for WorkloadMatch {
426                workload,
427                tenant,
428                namespace,
429            } in entries
430            {
431                field(&mut out, workload.as_bytes());
432                field(&mut out, tenant.0.as_bytes());
433                field(&mut out, namespace.0.as_bytes());
434            }
435        }
436        SecretAccess::Recipes(entries) => {
437            field(&mut out, b"recipes");
438            count(&mut out, entries.len());
439            for RecipeMatch { recipe, key } in entries {
440                field(&mut out, recipe.as_bytes());
441                field(&mut out, key.as_bytes());
442            }
443        }
444    }
445    out
446}
447
448// ── Sealing (R706 / W294, `seal` feature) ────────────────────────────────────
449
450/// A cluster secret's sealed bytes: AES-256-GCM ciphertext plus the 12-byte
451/// nonce it was sealed under.
452///
453/// Deliberately *not* the storage record — yubaba's `SecretRecord` adds the
454/// timestamp and the access rule and lives in the raft layer. This is only the
455/// cryptographic output, which is the part both writers share.
456#[cfg(feature = "seal")]
457#[derive(Debug, Clone, PartialEq, Eq)]
458pub struct Sealed {
459    /// AES-256-GCM output: sealed bytes with the GCM tag appended.
460    pub ciphertext: Vec<u8>,
461    /// The 12-byte GCM nonce, freshly drawn for this call.
462    pub nonce: Vec<u8>,
463}
464
465/// AES-256-GCM-seal `plaintext` under the 32-byte cluster `kek`, authenticating
466/// `aad` alongside it (R911-F4). For a cluster secret `aad` is always
467/// [`secret_aad`]`(name, access)`; [`open`] must be handed the same bytes.
468///
469/// A cryptographically-random 12-byte nonce is drawn **per call**, so re-sealing
470/// identical plaintext (a rotation, a re-ship of an unchanged value) never
471/// reuses a nonce. That is the whole reason this lives in one place: nonce reuse
472/// under a fixed key is catastrophic for GCM, and it is exactly the invariant
473/// that erodes when two call sites each roll their own seal.
474///
475/// Infallible by construction: the only error `aead` can return here is a
476/// plaintext-length overflow far beyond any credential.
477///
478/// @yah:ticket(R911-F4, "Seal binds secret name + access rule as AEAD associated data, so a bucket writer cannot widen access or swap records")
479/// @yah:status(review)
480/// @yah:at(2026-09-15T05:11:28Z)
481/// @yah:assignee(agent:bundle-anthropic-ashguard)
482/// @yah:parent(R911)
483/// @yah:next("Tier: Warrior — crypto format change across camp and node; getting the AAD canonicalization wrong silently fails every open.")
484/// @yah:next("WHY: in raft, a record's `access` rule could only be written through the node API. In R2 it is plaintext JSON beside the ciphertext, and seal(kek, plaintext) (:390) has no associated data. So anyone with bucket write can widen access to admit another workload, or copy one name's ciphertext under another name, and the node would open it.")
485/// @yah:next("Change seal/open to take associated data = a canonical, versioned encoding of (secret name, SecretAccess). Use a fixed field order, not serde_json map order. A record that fails to open is a distinct fail-closed error. Update every seal() caller (grep `secrets::seal(` across app/, oss/yubaba, crates/) and ClusterResolver's open. The access check still runs BEFORE decrypt, and a tampered access rule then fails the AEAD tag.")
486/// @yah:next("Existing records (the raft map and per-domain certs/<issuer>/ objects written by domain_issuer) will not open under the new format. R911-F5's node-side migration re-seals them; say in the handoff exactly which records need it.")
487/// @yah:verify("cargo test -p yah-workload-spec (from oss/yah-base), cargo test -p yubaba --lib, cargo test -p yah --lib cloud_secret: green vs baseline.")
488/// @yah:verify("Tests: an edited access rule fails to open; a ciphertext moved to another name fails to open; a round trip succeeds.")
489/// @yah:depends_on(R911-F3)
490/// @yah:files(oss/yah-base/crates/workload-spec/src/secrets.rs)
491/// @yah:files(oss/yubaba/crates/yubaba/src/secrets.rs)
492/// @yah:files(app/yah/cli/src/cloud_secret.rs)
493/// @yah:files(oss/yubaba/crates/yubaba/src/acme_issuer.rs)
494/// @yah:handoff("AAD (oss/yah-base/crates/workload-spec/src/secrets.rs): new `pub const SECRET_AAD_V1 = b\"yah/secret-aad/v1\\0\"` and `pub fn secret_aad(name: &str, access: &SecretAccess) -> Vec<u8>`, the ONLY builder. The layout is the version tag, then u32-BE length + UTF-8 name, then the rule: a length-prefixed variant tag (allow_any / workloads / recipes) and, for a list variant, a u32-BE entry count followed by each entry's fields in declared order (workload, tenant.0, namespace.0 / recipe, key), each length-prefixed. Entries are bound in stored order, never sorted, and serde_json is not involved. The match and the entry destructures are exhaustive, so a new rule variant or entry field does not compile until it chooses an encoding. `secret_aad` is not feature-gated (it does no crypto).")
495/// @yah:handoff("SIGNATURES BROKEN (pre-1.0): `seal(kek, plaintext, aad) -> Sealed`; new `open(kek, nonce, ciphertext, aad) -> Result<Vec<u8>, OpenError>` (a nonce that is not 12 bytes returns OpenError, never a panic); new zero-information `pub struct OpenError`. All three are behind the `seal` feature. The one legacy door is `#[doc(hidden)] open_legacy_unbound(kek, nonce, ciphertext)` (= open with empty AAD), with a one-line comment saying it exists only for R911-F5's migration and R911-T7 deletes it. Code-only, its only callers are two workload-spec tests.")
496/// @yah:handoff("CALLERS UPDATED, counts confirmed code-only with wc -l. `workload_spec::secrets::seal(` has 3 callers: yubaba `seal_cluster_secret` and cloud_secret.rs `put` (both now pass `secret_aad(name, &access)`), plus one yubaba test that builds a deliberately unbound legacy record. `workload_spec::secrets::open(` has 1 caller: yubaba `open_cluster_secret`, the only open path for ClusterResolver and tenant_passway. `.decrypt(` in cluster-secret code: 1, inside workload-spec `open`. yubaba's own Aes256Gcm/Nonce imports moved into its test module, where the fixed-nonce fixtures still encrypt directly. `seal_cluster_secret` gained `name` as its second argument, and all 23 callers pass the name the record is stored AND resolved under: acme_issuer (cert_key/key_key + 2 tests), domain_issuer (cert_name/key_name), tenant_passway tests (cert/key_secret_name of the domain), lib.rs, headscale_state, cert_materialize, secret_reload, secret_watch, fleet_secrets and secrets.rs tests. New test fixture `seal_named`, used by the recipe test that resolves r2/write.")
497/// @yah:handoff("ORDER IN THE RESOLVER is unchanged: `open_cluster_secret` checks `rec.access.admits(consumer)` first (refusal = SecretError::Forbidden, KEK untouched), then opens with `secret_aad(name, &rec.access)`. Any open failure (widened rule, ciphertext moved from another name, wrong KEK, tampered bytes, malformed nonce, unbound pre-F4 record) maps to **SecretError::ClusterDecrypt { name }**. It is fail-closed and name-only, and it was already distinct from ClusterNotFound/Forbidden/ClusterUnavailable. Its doc now names the AAD case. The digest (HMAC over plaintext) is unchanged.")
498/// @yah:handoff("FOR R911-F5 — LIVE RECORD CLASSES THAT NO LONGER OPEN under this build (every one was sealed with no associated data): (1) EVERY record in each group's RAFT secret map (YubabaState secrets, written by raft PutSecret). Per the R911 recon, prod has 7: cheers/cloud-admin/verify-key, headscale/noise-private-key, noisetable/account/{magic-link-key,session-key,smtp-password}, tls/yah.dev/{cert,key}. Dev has 3: cloudflare-tunnel-token, noisetable/account-staging/{magic-link-key,session-key}. (2) EVERY object under certs/<issuer>/<domain>/{cert,key}.sealed in the shared bucket: per-domain tenant certs written by domain_issuer, plus the tls/yah.dev pair the pre-F3 acme issuer mirrored there under R779. (3) Anything already under secrets/<group>/, which should be empty in production because the F3 PUT route has not rolled. F5 must open each with `open_legacy_unbound` under the node KEK and re-seal it with `seal_cluster_secret(kek, name, plaintext, updated_at, access)`, preserving updated_at, access, digest, sans and ari.")
499/// @yah:verify("BASELINES recorded before editing: workload-spec (oss/yah-base, `cargo test -p yah-workload-spec`) = 205 + 107 passed / 0 failed; with `--features seal` = 207 + 107 / 0. Operator-stated: yubaba lib 980/0, cloud-client 52+1, yah cloud_secret 23/0.")
500/// @yah:verify("AFTER: `cargo test -p yah-workload-spec` = 207 + 107 / 0 (+2: aad_bytes_are_pinned, aad_binds_the_rule_in_stored_order_and_separates_fields); `--features seal` = 214 + 107 / 0 (+7: those two plus a_sealed_secret_round_trips_under_its_own_name_and_rule, an_edited_access_rule_fails_to_open, a_ciphertext_moved_to_another_name_fails_to_open, a_malformed_nonce_is_an_open_error_not_a_panic, the_legacy_door_opens_only_unbound_records). From oss/yubaba: `cargo check -p yubaba --all-targets` EXIT=0 (log shows Checking yubaba, no yubaba warnings); `cargo test -p yubaba --lib` = 983 / 0 (+3: secrets::tests::a_widened_access_rule_passes_the_check_and_fails_the_tag, ::a_record_copied_under_another_name_fails_the_tag, ::a_pre_r911_f4_unbound_record_does_not_open); `cargo test -p yubaba --features testing --lib secret_reload` = 2 / 0. Root: `cargo test -p cloud-client` = 52 + 1 / 0 (unchanged); `cargo test -p yah --lib cloud_secret` = 23 / 0 (unchanged); `cargo run -p xtask -- cluster-epochs` EXIT=0 (no protocol surface moved).")
501/// @yah:verify("aad_bytes_are_pinned pins exact bytes for a Workloads rule (name a/b, one entry w/t/n), a Recipes rule, and AllowAny. If it fails, the change would break every sealed record in the fleet: bump the version tag and migrate instead.")
502/// @yah:gotcha("ROLL ORDER, HARD: this build opens NO pre-F4 record. Rolling it before R911-F5 has re-sealed both the raft map and certs/<issuer>/ would make every cluster-secret deploy fail ClusterDecrypt. start_headscale would REFUSE to start (NoiseKeyError::Unusable), secret rotation and cert_materialize would stop, and tenant_passway would count every per-domain cert as failed. The issuers do not re-order because of it: both renewal gates read record metadata (updated_at/sans/ari), never the plaintext, so there is no LE rate-limit exposure.")
503/// @yah:gotcha("CAMP/NODE LOCKSTEP: a `yah cloud secret put` from a CLI built before this change seals with no AAD, and this node would store the record but never open it. Install the CLI (`cargo xtask install`) in the same roll.")
504#[cfg(feature = "seal")]
505pub fn seal(kek: &[u8; 32], plaintext: &[u8], aad: &[u8]) -> Sealed {
506    use aes_gcm::aead::{Aead, AeadCore, OsRng, Payload};
507    use aes_gcm::{Aes256Gcm, Key, KeyInit};
508
509    let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(kek));
510    let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
511    let ciphertext = cipher
512        .encrypt(&nonce, Payload { msg: plaintext, aad })
513        .expect("AES-256-GCM seal of a KB-scale secret cannot fail on length");
514    Sealed {
515        ciphertext,
516        nonce: nonce.to_vec(),
517    }
518}
519
520/// A sealed secret did not open. Deliberately carries nothing: which of wrong
521/// key, tampered bytes, malformed nonce or mismatched associated data it was is
522/// not something a caller may learn or log.
523#[cfg(feature = "seal")]
524#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
525#[error("sealed secret failed to open")]
526pub struct OpenError;
527
528/// Open what [`seal`] produced: `nonce` and `ciphertext` from the record, and
529/// the same `aad` it was sealed under (for a cluster secret,
530/// [`secret_aad`]`(name, &record.access)`). A nonce that is not 12 bytes is an
531/// [`OpenError`], never a panic.
532#[cfg(feature = "seal")]
533pub fn open(
534    kek: &[u8; 32],
535    nonce: &[u8],
536    ciphertext: &[u8],
537    aad: &[u8],
538) -> Result<Vec<u8>, OpenError> {
539    use aes_gcm::aead::{Aead, Payload};
540    use aes_gcm::{Aes256Gcm, Key, KeyInit, Nonce};
541
542    if nonce.len() != 12 {
543        return Err(OpenError);
544    }
545    let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(kek));
546    cipher
547        .decrypt(Nonce::from_slice(nonce), Payload { msg: ciphertext, aad })
548        .map_err(|_| OpenError)
549}
550/// Draw 32 cryptographically-secure random bytes for a fresh cluster KEK.
551///
552/// Same `OsRng` [`seal`] draws its nonces from, on purpose: a KEK minted from a
553/// weaker source would silently undermine every secret sealed under it, and
554/// pulling a second RNG dependency into the camp is how that happens.
555#[cfg(feature = "seal")]
556pub fn generate_kek() -> zeroize::Zeroizing<[u8; 32]> {
557    use aes_gcm::aead::rand_core::RngCore;
558    let mut kek = zeroize::Zeroizing::new([0u8; 32]);
559    aes_gcm::aead::OsRng.fill_bytes(kek.as_mut());
560    kek
561}
562
563#[cfg(test)]
564mod tests {
565    use super::*;
566
567    /// R911-F4: the associated-data layout is a wire contract between the camp
568    /// and every node. If this fails, the change breaks every sealed record in
569    /// the fleet — bump the version tag and migrate instead.
570    #[test]
571    fn aad_bytes_are_pinned() {
572        let rule = SecretAccess::Workloads(vec![WorkloadMatch {
573            workload: "w".into(),
574            tenant: TenantId("t".into()),
575            namespace: NamespaceId("n".into()),
576        }]);
577        let expected: Vec<u8> = [
578            b"yah/secret-aad/v1\0".as_slice(),
579            &[0, 0, 0, 3],
580            b"a/b",
581            &[0, 0, 0, 9],
582            b"workloads",
583            &[0, 0, 0, 1],
584            &[0, 0, 0, 1],
585            b"w",
586            &[0, 0, 0, 1],
587            b"t",
588            &[0, 0, 0, 1],
589            b"n",
590        ]
591        .concat();
592        assert_eq!(secret_aad("a/b", &rule), expected);
593
594        let recipes = SecretAccess::Recipes(vec![RecipeMatch {
595            recipe: "r".into(),
596            key: "k".into(),
597        }]);
598        let expected: Vec<u8> = [
599            b"yah/secret-aad/v1\0".as_slice(),
600            &[0, 0, 0, 1],
601            b"x",
602            &[0, 0, 0, 7],
603            b"recipes",
604            &[0, 0, 0, 1],
605            &[0, 0, 0, 1],
606            b"r",
607            &[0, 0, 0, 1],
608            b"k",
609        ]
610        .concat();
611        assert_eq!(secret_aad("x", &recipes), expected);
612
613        let expected: Vec<u8> = [
614            b"yah/secret-aad/v1\0".as_slice(),
615            &[0, 0, 0, 1],
616            b"x",
617            &[0, 0, 0, 9],
618            b"allow_any",
619        ]
620        .concat();
621        assert_eq!(secret_aad("x", &SecretAccess::AllowAny), expected);
622    }
623
624    #[test]
625    fn aad_binds_the_rule_in_stored_order_and_separates_fields() {
626        let ab = SecretAccess::workloads(["a", "b"]);
627        let ba = SecretAccess::workloads(["b", "a"]);
628        assert_ne!(secret_aad("s", &ab), secret_aad("s", &ba), "reordering is an edit");
629        // Length prefixes keep field boundaries unambiguous.
630        assert_ne!(
631            secret_aad("s", &SecretAccess::workloads(["ab"])),
632            secret_aad("s", &SecretAccess::workloads(["a", "b"]))
633        );
634        assert_ne!(secret_aad("a", &SecretAccess::AllowAny), secret_aad("b", &SecretAccess::AllowAny));
635        assert_ne!(
636            secret_aad("s", &SecretAccess::default()),
637            secret_aad("s", &SecretAccess::AllowAny)
638        );
639    }
640
641    #[cfg(feature = "seal")]
642    #[test]
643    fn a_sealed_secret_round_trips_under_its_own_name_and_rule() {
644        let kek = [7u8; 32];
645        let rule = SecretAccess::workloads(["ingress"]);
646        let aad = secret_aad("tls/yah.dev/key", &rule);
647        let sealed = seal(&kek, b"KEYPEM", &aad);
648        assert_eq!(open(&kek, &sealed.nonce, &sealed.ciphertext, &aad).unwrap(), b"KEYPEM");
649    }
650
651    #[cfg(feature = "seal")]
652    #[test]
653    fn an_edited_access_rule_fails_to_open() {
654        let kek = [7u8; 32];
655        let sealed = seal(
656            &kek,
657            b"secret",
658            &secret_aad("cf/dns-token", &SecretAccess::workloads(["passway"])),
659        );
660        for widened in [
661            SecretAccess::AllowAny,
662            SecretAccess::workloads(["passway", "attacker"]),
663        ] {
664            let aad = secret_aad("cf/dns-token", &widened);
665            assert_eq!(open(&kek, &sealed.nonce, &sealed.ciphertext, &aad), Err(OpenError));
666        }
667    }
668
669    #[cfg(feature = "seal")]
670    #[test]
671    fn a_ciphertext_moved_to_another_name_fails_to_open() {
672        let kek = [7u8; 32];
673        let rule = SecretAccess::AllowAny;
674        let sealed = seal(&kek, b"secret", &secret_aad("noisetable/account/session-key", &rule));
675        let aad = secret_aad("cheers/cloud-admin/verify-key", &rule);
676        assert_eq!(open(&kek, &sealed.nonce, &sealed.ciphertext, &aad), Err(OpenError));
677    }
678
679    #[cfg(feature = "seal")]
680    #[test]
681    fn a_malformed_nonce_is_an_open_error_not_a_panic() {
682        let kek = [7u8; 32];
683        let aad = secret_aad("x", &SecretAccess::AllowAny);
684        let sealed = seal(&kek, b"secret", &aad);
685        assert_eq!(open(&kek, &sealed.nonce[..8], &sealed.ciphertext, &aad), Err(OpenError));
686    }
687
688    #[cfg(feature = "seal")]
689    #[test]
690    fn seal_draws_a_fresh_nonce_per_call() {
691        let kek = [7u8; 32];
692        let a = seal(&kek, b"same-plaintext", b"aad");
693        let b = seal(&kek, b"same-plaintext", b"aad");
694        assert_eq!(a.nonce.len(), 12);
695        assert_ne!(a.nonce, b.nonce, "nonce must never repeat under one key");
696        assert_ne!(a.ciphertext, b.ciphertext);
697        assert_ne!(a.ciphertext, b"same-plaintext".to_vec());
698    }
699
700    #[cfg(feature = "seal")]
701    #[test]
702    fn generated_keks_are_32_bytes_and_distinct() {
703        let a = generate_kek();
704        let b = generate_kek();
705        assert_eq!(a.len(), 32);
706        assert_ne!(*a, *b, "two mints must not collide");
707        assert_ne!(*a, [0u8; 32], "must not be all-zero");
708    }
709
710    #[test]
711    fn default_access_admits_nobody() {
712        // The fail-closed migration hinges on exactly this.
713        let rule = SecretAccess::default();
714        assert!(!rule.admits(&SecretConsumer::workload("yah-cloud-admin")));
715        assert_eq!(rule.summary(), "deny-all (no rule)");
716    }
717
718    #[test]
719    fn legacy_record_shape_deserializes_to_deny_all() {
720        // A record serialized before the field existed: serde(default) must land
721        // on deny-all, not allow-all.
722        #[derive(Deserialize)]
723        struct Legacyish {
724            #[serde(default)]
725            access: SecretAccess,
726        }
727        let v: Legacyish = serde_json::from_str("{}").unwrap();
728        assert!(!v.access.admits(&SecretConsumer::workload("anything")));
729    }
730
731    #[test]
732    fn allow_list_matches_on_all_three_axes() {
733        let rule = SecretAccess::workloads(["yah-cloud-admin"]);
734        assert!(rule.admits(&SecretConsumer::workload("yah-cloud-admin")));
735        assert!(!rule.admits(&SecretConsumer::workload("other-service")));
736
737        // Same name, different tenant → refused (the entry defaulted to the
738        // singleton tenant, and defaults are narrowing, not widening).
739        let other_tenant = SecretConsumer {
740            workload: "yah-cloud-admin".into(),
741            tenant: TenantId("acme".into()),
742            namespace: NamespaceId::singleton(),
743            recipe: None,
744        };
745        assert!(!rule.admits(&other_tenant));
746    }
747
748    #[test]
749    fn allow_any_is_explicit_and_visible() {
750        let rule = SecretAccess::AllowAny;
751        assert!(rule.admits(&SecretConsumer::workload("anything-at-all")));
752        assert_eq!(rule.summary(), "allow-any");
753        // And it must survive a round-trip as a distinct, greppable token.
754        let json = serde_json::to_string(&rule).unwrap();
755        assert_eq!(json, "\"allow_any\"");
756    }
757
758    #[test]
759    fn omitted_tenant_and_namespace_default_to_singleton() {
760        let m: WorkloadMatch = serde_json::from_str(r#"{"workload":"api"}"#).unwrap();
761        assert_eq!(m.tenant, TenantId::singleton());
762        assert_eq!(m.namespace, NamespaceId::singleton());
763    }
764
765    // ── recipe rules (R555-F5) ───────────────────────────────────────────────
766
767    const KEY: &str = "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0537bb43f2a8d9c";
768
769    fn forge_run(recipe: Option<&str>) -> SecretConsumer {
770        // What a dispatched build actually looks like: a per-run workload name
771        // no allow-list could have named in advance.
772        let c = SecretConsumer::workload("forge-0193a7c2-9f11-7e3a-9c1e-2b0f4d8e6a55");
773        match recipe {
774            Some(r) => c.admitted_as(RecipeIdentity {
775                recipe: r.into(),
776                key: KEY.into(),
777            }),
778            None => c,
779        }
780    }
781
782    #[test]
783    fn a_recipe_rule_admits_the_signed_recipe_whatever_the_run_is_called() {
784        let rule = SecretAccess::recipes([("rusty-v8-musl", KEY)]);
785        assert!(rule.admits(&forge_run(Some("rusty-v8-musl"))));
786        // A second run of the same recipe has a different workload name and is
787        // still admitted — that is the whole point of keying on the recipe.
788        let other_run = SecretConsumer::workload("forge-0193a7c2-ffff-7e3a-9c1e-2b0f4d8e6a55")
789            .admitted_as(RecipeIdentity {
790                recipe: "rusty-v8-musl".into(),
791                key: KEY.into(),
792            });
793        assert!(rule.admits(&other_run));
794    }
795
796    #[test]
797    fn a_recipe_rule_admits_nobody_without_a_verified_identity() {
798        // The fail-closed direction: an unsigned dispatch, or one whose grant
799        // did not verify, carries `recipe: None` and gets nothing.
800        let rule = SecretAccess::recipes([("rusty-v8-musl", KEY)]);
801        assert!(!rule.admits(&forge_run(None)));
802        assert!(!rule.admits(&SecretConsumer::workload("rusty-v8-musl")));
803    }
804
805    #[test]
806    fn a_recipe_rule_matches_on_the_signing_key_too() {
807        // Otherwise anyone holding any key the node pins could sign a recipe
808        // named `rusty-v8-musl` and inherit its credentials.
809        let rule = SecretAccess::recipes([("rusty-v8-musl", KEY)]);
810        let impostor = SecretConsumer::workload("forge-1").admitted_as(RecipeIdentity {
811            recipe: "rusty-v8-musl".into(),
812            key: "00".repeat(32),
813        });
814        assert!(!rule.admits(&impostor));
815        assert!(!rule.admits(&forge_run(Some("whisper-bundle-tar"))));
816    }
817
818    #[test]
819    fn the_two_rule_kinds_do_not_leak_into_each_other() {
820        // A workload rule is not satisfied by a recipe identity...
821        let by_workload = SecretAccess::workloads(["rusty-v8-musl"]);
822        assert!(!by_workload.admits(&forge_run(Some("rusty-v8-musl"))));
823        // ...and a recipe rule is not satisfied by a same-named workload.
824        let by_recipe = SecretAccess::recipes([("ingress", KEY)]);
825        assert!(!by_recipe.admits(&SecretConsumer::workload("ingress")));
826    }
827
828    #[test]
829    fn an_empty_recipe_list_admits_nobody_and_says_so() {
830        let rule = SecretAccess::Recipes(Vec::new());
831        assert!(!rule.admits(&forge_run(Some("rusty-v8-musl"))));
832        assert_eq!(rule.summary(), "deny-all (no rule)");
833    }
834
835    #[test]
836    fn a_recipe_rule_renders_recipe_and_key_prefix() {
837        let rule = SecretAccess::recipes([("rusty-v8-musl", KEY)]);
838        assert_eq!(rule.summary(), "recipe rusty-v8-musl@3d4017c3");
839    }
840
841    #[test]
842    fn a_recipe_rule_round_trips_through_the_stored_record() {
843        let rule = SecretAccess::recipes([("rusty-v8-musl", KEY)]);
844        let json = serde_json::to_string(&rule).unwrap();
845        assert_eq!(serde_json::from_str::<SecretAccess>(&json).unwrap(), rule);
846        // And the pre-R555-F5 record shape still deserializes unchanged.
847        let legacy: SecretAccess =
848            serde_json::from_str(r#"{"workloads":[{"workload":"ingress"}]}"#).unwrap();
849        assert!(legacy.admits(&SecretConsumer::workload("ingress")));
850    }
851
852    #[test]
853    fn a_consumer_serialized_before_this_field_existed_carries_no_recipe() {
854        // `recipe` is serde(default) on SecretConsumer, and the default is None
855        // — an absent field must not become a claim.
856        let c: SecretConsumer = serde_json::from_str(
857            r#"{"workload":"ingress","tenant":"default","namespace":"default"}"#,
858        )
859        .unwrap();
860        assert_eq!(c.recipe, None);
861    }
862
863    #[test]
864    fn secrets_forbidden_is_externally_indistinguishable() {
865        // A probing spec must not be able to tell "exists but denied" from
866        // "does not exist" — see the note on SecretError::Forbidden.
867        let denied = SecretError::Forbidden {
868            name: "cheers/cloud-admin/verify-key".into(),
869        };
870        let absent = SecretError::ClusterNotFound {
871            name: "cheers/cloud-admin/verify-key".into(),
872        };
873        assert_eq!(denied.to_string(), absent.to_string());
874    }
875}