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}