Skip to main content

suminuri_wire/
metadata.rs

1//! The `sops:` block — and the one invariant that makes this crate worth having
2//! rather than a serde struct.
3//!
4//! # The declared-vs-actual gap, and why it is typed away here
5//!
6//! Only a *current* recipient of a file can re-wrap its data key. So adding a
7//! recipient to `.sops.yaml` does nothing on its own: somebody holding an
8//! existing key has to run `sops updatekeys` before the ciphertext learns about
9//! it. Until then the declaration and the file disagree, and **nothing says so**
10//! — every tool reads the recipient list straight out of the file it is already
11//! decrypting.
12//!
13//! This is not hypothetical. The operator's own `nix/.sops.yaml` declared an
14//! admin-recovery co-recipient for `users/gabi/secrets.yaml` on 2026-07-24 and
15//! it never took effect; the file carried exactly one recipient for two weeks
16//! while the config claimed two, and the divergence was found by reading, not by
17//! any check. The comment that eventually removed it says so outright: "a
18//! DECLARATION THAT DISAGREES WITH THE CIPHERTEXT for two weeks".
19//!
20//! So [`Metadata`] does not have settable key arrays. It is built by
21//! [`Metadata::from_wrapped`] from the set of [`WrappedKey`]s that actually
22//! exist, and the arrays are a *projection* of that set. A `Metadata` whose
23//! `age:` list names a recipient with no wrapped key has no constructor —
24//! **truly-unrep**, in the sense `UNREPRESENTABILITY.md` reserves for an absent
25//! code path rather than a guarded one.
26//!
27//! What this does *not* do is police `.sops.yaml`. Comparing a config's declared
28//! recipients against a file's actual ones is a different job, done by the
29//! reconciler a layer up; this type just makes the *emitted file* incapable of
30//! lying about itself.
31//!
32//! # Field order is part of the format
33//!
34//! go-yaml marshals a struct in declaration order, so the field order in the
35//! struct below **is** the byte order in every sops file ever written. It is not
36//! alphabetical and must not be sorted. Two upstream inconsistencies are
37//! reproduced deliberately: inside a `key_groups` entry, `hc_vault` and `age`
38//! lack `omitempty` and therefore emit even when empty, while at top level they
39//! do not.
40
41use crate::WireError;
42use serde::{Deserialize, Serialize};
43
44/// A key provider that can wrap the data key.
45///
46/// Present as a **closed enum** even for the providers we do not yet implement,
47/// because ★★ MODULARIZE, DON'T DELETE cuts both ways: a provider we cannot
48/// serve should be a *named refusal*, not an unparseable file. A `sops` file
49/// carrying KMS keys must round-trip through us intact even when we cannot
50/// unwrap it, or aliasing us over `sops` would corrupt files on write.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
52pub enum KeyProvider {
53    /// X25519 age recipients. **Implemented.**
54    Age,
55    /// PGP fingerprints, via gpg. Declared, not implemented.
56    Pgp,
57    /// AWS KMS ARNs. Declared, not implemented.
58    AwsKms,
59    /// GCP KMS resource IDs. Declared, not implemented.
60    GcpKms,
61    /// HuaweiCloud KMS key IDs. Declared, not implemented.
62    HuaweiKms,
63    /// Azure Key Vault URLs. Declared, not implemented.
64    AzureKeyVault,
65    /// HashiCorp Vault transit URIs. Declared, not implemented.
66    HcVault,
67}
68
69impl KeyProvider {
70    /// The `sops.<field>` name this provider's keys live under.
71    #[must_use]
72    pub fn field(self) -> &'static str {
73        match self {
74            Self::Age => "age",
75            Self::Pgp => "pgp",
76            Self::AwsKms => "kms",
77            Self::GcpKms => "gcp_kms",
78            Self::HuaweiKms => "hckms",
79            Self::AzureKeyVault => "azure_kv",
80            Self::HcVault => "hc_vault",
81        }
82    }
83
84    /// The `--decryption-order` token for this provider.
85    #[must_use]
86    pub fn order_token(self) -> &'static str {
87        match self {
88            Self::Age => "age",
89            Self::Pgp => "pgp",
90            Self::AwsKms => "kms",
91            Self::GcpKms => "gcp_kms",
92            Self::HuaweiKms => "hckms",
93            Self::AzureKeyVault => "azure_kv",
94            Self::HcVault => "hc_vault",
95        }
96    }
97
98    /// Whether this build can actually unwrap a data key for this provider.
99    ///
100    /// A typed `false` rather than a missing variant: the file still parses, the
101    /// key still round-trips, and the refusal is nameable at the point a caller
102    /// needs a data key. That is the difference between "we do not support KMS"
103    /// and "we corrupt KMS files".
104    #[must_use]
105    pub fn is_implemented(self) -> bool {
106        matches!(self, Self::Age)
107    }
108}
109
110/// One recipient's wrapped copy of the data key.
111///
112/// The pairing of "who" with "the bytes only they can open" is the whole point:
113/// a `WrappedKey` cannot exist without its ciphertext, which is why deriving the
114/// metadata's recipient lists from a set of these closes the declared-vs-actual
115/// gap.
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub struct WrappedKey {
118    provider: KeyProvider,
119    /// The recipient identifier as it appears in the file: an age recipient, a
120    /// PGP fingerprint, a KMS ARN, …
121    recipient: String,
122    /// The wrapped data key, verbatim. For age this is a fully armored age file.
123    enc: String,
124    /// `created_at`, for the providers that carry one. age does not.
125    created_at: Option<String>,
126}
127
128impl WrappedKey {
129    /// An age recipient plus its armored wrapped key.
130    #[must_use]
131    pub fn age(recipient: impl Into<String>, enc: impl Into<String>) -> Self {
132        Self {
133            provider: KeyProvider::Age,
134            recipient: recipient.into(),
135            enc: enc.into(),
136            created_at: None,
137        }
138    }
139
140    /// A key for a provider we do not unwrap, preserved so the file round-trips.
141    #[must_use]
142    pub fn opaque(
143        provider: KeyProvider,
144        recipient: impl Into<String>,
145        enc: impl Into<String>,
146        created_at: Option<String>,
147    ) -> Self {
148        Self {
149            provider,
150            recipient: recipient.into(),
151            enc: enc.into(),
152            created_at,
153        }
154    }
155
156    #[must_use]
157    pub fn provider(&self) -> KeyProvider {
158        self.provider
159    }
160
161    #[must_use]
162    pub fn recipient(&self) -> &str {
163        &self.recipient
164    }
165
166    /// The wrapped key bytes as they appear on the wire.
167    ///
168    /// Not a secret in itself — it is ciphertext, and it ships in the file.
169    #[must_use]
170    pub fn enc(&self) -> &str {
171        &self.enc
172    }
173
174    #[must_use]
175    pub fn created_at(&self) -> Option<&str> {
176        self.created_at.as_deref()
177    }
178}
179
180/// An `age` entry in the `sops.age` array.
181#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
182pub struct AgeKey {
183    pub recipient: String,
184    pub enc: String,
185}
186
187/// The `sops:` metadata block.
188///
189/// Field order below is byte order in the file — see the module docs. Key arrays
190/// are private and derived; see [`Metadata::from_wrapped`].
191#[derive(Debug, Clone, PartialEq, Eq)]
192pub struct Metadata {
193    /// Every recipient's wrapped data key. The single source the arrays project
194    /// from, so a declared recipient without a wrapped key cannot be built.
195    keys: Vec<WrappedKey>,
196    /// Shamir threshold, when key groups are in use.
197    pub shamir_threshold: Option<u32>,
198    /// `lastmodified`, kept **verbatim** as it appeared in the file.
199    ///
200    /// Not a parsed timestamp, and deliberately so: this string is the AAD of the
201    /// `mac` field, so any normalisation we applied on the way through — `Z`
202    /// becoming `+00:00`, a dropped fractional second — would silently make a
203    /// valid file unreadable.
204    pub lastmodified: String,
205    /// The `mac` field, still in its `ENC[…]` form.
206    pub mac: String,
207    pub unencrypted_suffix: Option<String>,
208    pub encrypted_suffix: Option<String>,
209    pub unencrypted_regex: Option<String>,
210    pub encrypted_regex: Option<String>,
211    pub unencrypted_comment_regex: Option<String>,
212    pub encrypted_comment_regex: Option<String>,
213    pub mac_only_encrypted: bool,
214    pub version: String,
215}
216
217impl Metadata {
218    /// Build metadata from the keys that actually exist.
219    ///
220    /// This is the only constructor. There is no `Metadata { age: … }` literal
221    /// available to a caller and no setter for a recipient list, which is what
222    /// makes "declared recipients disagree with the ciphertext" unrepresentable
223    /// in an emitted file.
224    #[must_use]
225    pub fn from_wrapped(
226        keys: Vec<WrappedKey>,
227        lastmodified: impl Into<String>,
228        mac: impl Into<String>,
229    ) -> Self {
230        Self {
231            keys,
232            shamir_threshold: None,
233            lastmodified: lastmodified.into(),
234            mac: mac.into(),
235            unencrypted_suffix: None,
236            encrypted_suffix: None,
237            unencrypted_regex: None,
238            encrypted_regex: None,
239            unencrypted_comment_regex: None,
240            encrypted_comment_regex: None,
241            mac_only_encrypted: false,
242            version: crate::FORMAT_VERSION.to_string(),
243        }
244    }
245
246    /// Every wrapped key, in file order.
247    #[must_use]
248    pub fn keys(&self) -> &[WrappedKey] {
249        &self.keys
250    }
251
252    /// The wrapped keys for one provider, in file order — the projection the
253    /// `sops.<provider>` array is emitted from.
254    #[must_use]
255    pub fn keys_for(&self, provider: KeyProvider) -> Vec<&WrappedKey> {
256        self.keys
257            .iter()
258            .filter(|k| k.provider == provider)
259            .collect()
260    }
261
262    /// The `sops.age` array, derived.
263    #[must_use]
264    pub fn age_keys(&self) -> Vec<AgeKey> {
265        self.keys_for(KeyProvider::Age)
266            .into_iter()
267            .map(|k| AgeKey {
268                recipient: k.recipient.clone(),
269                enc: k.enc.clone(),
270            })
271            .collect()
272    }
273
274    /// Every provider present in this file, sorted for a stable report.
275    #[must_use]
276    pub fn providers(&self) -> Vec<KeyProvider> {
277        let mut ps: Vec<_> = self.keys.iter().map(|k| k.provider).collect();
278        ps.sort_unstable();
279        ps.dedup();
280        ps
281    }
282
283    /// The providers this file needs that this build cannot unwrap.
284    ///
285    /// Empty means every key in the file is one we could open given the right
286    /// identity. Non-empty is a *refusal to name*, not a reason to guess.
287    #[must_use]
288    pub fn unimplemented_providers(&self) -> Vec<KeyProvider> {
289        self.providers()
290            .into_iter()
291            .filter(|p| !p.is_implemented())
292            .collect()
293    }
294
295    /// Replace the key set — a rekey or `updatekeys`.
296    ///
297    /// Takes the whole set rather than offering `add`/`remove`, so the arrays
298    /// stay a projection of one atomically-replaced truth. Refuses an empty set:
299    /// a file with no wrapped keys can never be decrypted by anyone, and
300    /// producing one is how a rekey bug becomes permanent data loss.
301    pub fn rewrap(&mut self, keys: Vec<WrappedKey>) -> Result<(), WireError> {
302        if keys.is_empty() {
303            return Err(WireError::DataKeyLength(0));
304        }
305        self.keys = keys;
306        Ok(())
307    }
308
309    /// Build the compiled selector for this file's policy.
310    ///
311    /// Falls back to sops's default (`_unencrypted`) when nothing is configured,
312    /// matching upstream's defaulting of `UnencryptedSuffix`.
313    pub fn selector(&self) -> Result<crate::selector::EncryptionSelector, WireError> {
314        let s = crate::selector::EncryptionSelector::new(
315            self.unencrypted_suffix.as_deref(),
316            self.encrypted_suffix.as_deref(),
317            self.unencrypted_regex.as_deref(),
318            self.encrypted_regex.as_deref(),
319            self.unencrypted_comment_regex.as_deref(),
320            self.encrypted_comment_regex.as_deref(),
321        )?;
322        Ok(if s.is_unconfigured() {
323            crate::selector::EncryptionSelector::default_policy()
324        } else {
325            s
326        })
327    }
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333
334    fn meta() -> Metadata {
335        Metadata::from_wrapped(
336            vec![
337                WrappedKey::age(
338                    "age1aaa",
339                    "-----BEGIN AGE ENCRYPTED FILE-----\nA\n-----END AGE ENCRYPTED FILE-----\n",
340                ),
341                WrappedKey::age(
342                    "age1bbb",
343                    "-----BEGIN AGE ENCRYPTED FILE-----\nB\n-----END AGE ENCRYPTED FILE-----\n",
344                ),
345            ],
346            "2026-08-18T00:00:00Z",
347            "ENC[AES256_GCM,data:x,iv:y,tag:z,type:str]",
348        )
349    }
350
351    #[test]
352    fn recipient_lists_are_projections_of_the_wrapped_keys() {
353        let m = meta();
354        let age = m.age_keys();
355        assert_eq!(age.len(), 2);
356        assert_eq!(age[0].recipient, "age1aaa");
357        assert_eq!(age[1].recipient, "age1bbb");
358    }
359
360    /// The gabi defect, as a test. There is no way to *write* this state, so the
361    /// test asserts the shape of the API rather than catching a bad value: the
362    /// only route to a recipient list is through keys that carry ciphertext.
363    #[test]
364    fn a_recipient_cannot_exist_without_its_wrapped_key() {
365        let m = meta();
366        for k in m.age_keys() {
367            assert!(
368                !k.enc.is_empty(),
369                "a projected recipient always carries its wrapped key"
370            );
371        }
372        // Adding a recipient means adding a WrappedKey, which requires the
373        // ciphertext. `rewrap` takes the whole set; there is no `add_recipient`.
374        let mut m2 = meta();
375        m2.rewrap(vec![WrappedKey::age(
376            "age1ccc",
377            "-----BEGIN AGE ENCRYPTED FILE-----\nC\n-----END AGE ENCRYPTED FILE-----\n",
378        )])
379        .expect("rewrap");
380        assert_eq!(m2.age_keys().len(), 1);
381        assert_eq!(m2.age_keys()[0].recipient, "age1ccc");
382    }
383
384    /// A file nobody can decrypt is the one rekey outcome that is unrecoverable.
385    #[test]
386    fn rewrapping_to_nothing_is_refused() {
387        let mut m = meta();
388        assert!(m.rewrap(vec![]).is_err());
389        assert_eq!(m.age_keys().len(), 2, "the refusal left the file intact");
390    }
391
392    #[test]
393    fn an_unimplemented_provider_is_named_not_dropped() {
394        let mut m = meta();
395        m.rewrap(vec![
396            WrappedKey::age("age1aaa", "enc"),
397            WrappedKey::opaque(
398                KeyProvider::AwsKms,
399                "arn:aws:kms:us-east-2:1:key/abc",
400                "CiA…",
401                Some("2026-01-01T00:00:00Z".into()),
402            ),
403        ])
404        .expect("rewrap");
405        assert_eq!(m.unimplemented_providers(), vec![KeyProvider::AwsKms]);
406        // and the key is still there to be written back out
407        assert_eq!(m.keys_for(KeyProvider::AwsKms).len(), 1);
408        assert_eq!(
409            m.keys_for(KeyProvider::AwsKms)[0].created_at(),
410            Some("2026-01-01T00:00:00Z")
411        );
412    }
413
414    #[test]
415    fn an_all_age_file_has_nothing_unimplemented() {
416        assert!(meta().unimplemented_providers().is_empty());
417    }
418
419    #[test]
420    fn provider_field_names_match_the_wire() {
421        assert_eq!(KeyProvider::Age.field(), "age");
422        assert_eq!(KeyProvider::AwsKms.field(), "kms");
423        assert_eq!(KeyProvider::GcpKms.field(), "gcp_kms");
424        assert_eq!(KeyProvider::HuaweiKms.field(), "hckms");
425        assert_eq!(KeyProvider::AzureKeyVault.field(), "azure_kv");
426        assert_eq!(KeyProvider::HcVault.field(), "hc_vault");
427        assert_eq!(KeyProvider::Pgp.field(), "pgp");
428    }
429
430    #[test]
431    fn lastmodified_is_kept_verbatim() {
432        // Deliberately a shape a normaliser would "improve".
433        let m =
434            Metadata::from_wrapped(vec![WrappedKey::age("a", "e")], "2026-08-18T00:00:00Z", "m");
435        assert_eq!(m.lastmodified, "2026-08-18T00:00:00Z");
436    }
437
438    #[test]
439    fn an_unconfigured_file_gets_the_default_policy() {
440        let s = meta().selector().expect("selector");
441        assert!(
442            !s.is_unconfigured(),
443            "must have fallen back to the _unencrypted default"
444        );
445    }
446}