Skip to main content

suminuri_wire/
cipher.rs

1//! AES-256-GCM with a **32-byte** nonce, and the IV stash that keeps edits small.
2//!
3//! # The 32-byte nonce
4//!
5//! `aes/cipher.go` opens with `const nonceSize int = 32` and encrypts through
6//! `cipher.NewGCMWithNonceSize(aescipher, nonceSize)`. Every mainstream AES-GCM
7//! API — Go's own `cipher.NewGCM`, Rust's `Aes256Gcm` alias, every tutorial —
8//! defaults to 96 bits. So the wrong choice here is not a compile error
9//! anywhere; it is a file that no sops can open, failing as an opaque
10//! authentication error with no hint about the cause.
11//!
12//! GCM with a nonce that is not 96 bits derives its counter block by GHASH-ing
13//! the nonce instead of using it directly, which is a different code path in
14//! every implementation. That Rust's `aes-gcm` takes it correctly is not assumed
15//! here: it was proven end-to-end against the operator's live `secrets.yaml`
16//! before this module existed.
17//!
18//! # Why encryption and decryption are asymmetric
19//!
20//! Upstream *writes* 32 and *reads* `len(iv)`. That asymmetry is deliberate and
21//! reproduced: [`Iv`] is `[u8; 32]` and is the only thing [`encrypt_leaf`] will
22//! accept, while [`decrypt_leaf`] honours whatever length the file carries. New
23//! bytes are always canonical; old bytes are always readable.
24
25use crate::WireError;
26use crate::aad::Aad;
27use crate::leaf::{EncryptedLeaf, LeafType, Plaintext};
28use aes_gcm::AesGcm;
29use aes_gcm::aead::{Aead, KeyInit, Payload};
30use std::collections::HashMap;
31use zeroize::Zeroizing;
32
33/// AES-256-GCM parameterised for the nonce length sops actually uses.
34type SopsGcm32 = AesGcm<aes::Aes256, aes_gcm::aead::consts::U32>;
35
36/// The 32-byte symmetric key every leaf in one file is encrypted under.
37///
38/// Wrapped per recipient (age, PGP, KMS, …) into the `sops.<provider>[].enc`
39/// fields; this type is the unwrapped form and is zeroed on drop. No `Display`,
40/// no `Debug` of contents.
41#[derive(Clone)]
42pub struct DataKey(Zeroizing<[u8; 32]>);
43
44impl DataKey {
45    /// Length in bytes of a data key. Not configurable — `GenerateDataKey` uses
46    /// `make([]byte, 32)`.
47    pub const LEN: usize = 32;
48
49    /// A fresh data key from the OS CSPRNG.
50    pub fn generate() -> Result<Self, WireError> {
51        let mut k = [0u8; Self::LEN];
52        getrandom::getrandom(&mut k).map_err(|e| WireError::Randomness(e.to_string()))?;
53        Ok(Self(Zeroizing::new(k)))
54    }
55
56    /// Adopt bytes recovered from a key provider.
57    ///
58    /// The length check is here rather than at the call site because a short key
59    /// from a misbehaving provider would otherwise surface as an AEAD failure
60    /// far from its cause.
61    pub fn from_bytes(bytes: &[u8]) -> Result<Self, WireError> {
62        if bytes.len() != Self::LEN {
63            return Err(WireError::DataKeyLength(bytes.len()));
64        }
65        let mut k = [0u8; Self::LEN];
66        k.copy_from_slice(bytes);
67        Ok(Self(Zeroizing::new(k)))
68    }
69
70    /// The raw key, for handing to a key provider that must wrap it.
71    ///
72    /// Named to be greppable, like `Plaintext::expose`.
73    #[must_use]
74    pub fn expose(&self) -> &[u8; 32] {
75        &self.0
76    }
77}
78
79impl std::fmt::Debug for DataKey {
80    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81        f.write_str("DataKey(*** 32 bytes)")
82    }
83}
84
85/// A nonce for *writing*. Always 32 bytes, by type.
86///
87/// There is no constructor that takes a length and none that takes fewer bytes,
88/// so "encrypt with a 12-byte nonce" has no spelling in this crate.
89#[derive(Clone, PartialEq, Eq, Hash)]
90pub struct Iv([u8; 32]);
91
92impl Iv {
93    /// The nonce length sops writes.
94    pub const LEN: usize = 32;
95
96    /// Draw a fresh nonce.
97    pub fn generate() -> Result<Self, WireError> {
98        let mut iv = [0u8; Self::LEN];
99        getrandom::getrandom(&mut iv).map_err(|e| WireError::Randomness(e.to_string()))?;
100        Ok(Self(iv))
101    }
102
103    /// Adopt a nonce recovered from a file so an unchanged value re-encrypts
104    /// identically. Only accepts the canonical length — a shorter nonce off the
105    /// wire can be *read* but is never carried forward into a write.
106    #[must_use]
107    pub fn from_wire_exact(bytes: &[u8]) -> Option<Self> {
108        let arr: [u8; Self::LEN] = bytes.try_into().ok()?;
109        Some(Self(arr))
110    }
111
112    #[must_use]
113    pub fn as_bytes(&self) -> &[u8; 32] {
114        &self.0
115    }
116}
117
118impl std::fmt::Debug for Iv {
119    /// A nonce is public data — it ships in the file — so showing it is fine and
120    /// makes an IV-reuse question answerable.
121    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
122        write!(f, "Iv({})", hex_lower(&self.0))
123    }
124}
125
126/// Remembers the IV used for each `(type, plaintext, aad)` triple so re-encrypting
127/// an unchanged value reproduces its exact previous ciphertext.
128///
129/// This is not an optimisation. `sops edit` decrypts, hands the tree to an
130/// editor, and re-encrypts everything; without the stash **every line of the
131/// file changes on every edit**, which destroys the property the whole format
132/// exists for — a readable, reviewable diff.
133///
134/// # The type is part of the key, and leaving it out is a real bug
135///
136/// Upstream's key is `stashKey{plaintext interface{}, additionalData string}`, and
137/// a Go map compares an `interface{}` by **dynamic type and value** — so `int(1)`
138/// and `string("1")` are two different keys there. The first version of this
139/// struct keyed on the raw plaintext *bytes*, which collapses exactly the pairs
140/// the encodings make indistinguishable:
141///
142/// | these are distinct upstream | but share one byte string |
143/// |---|---|
144/// | `1` (int) / `1.0` (float) / `"1"` (str) | `1` |
145/// | `true` (bool) / `"True"` (str) | `True` |
146/// | `false` (bool) / `"False"` (str) | `False` |
147///
148/// Two such leaves under the *same* AAD — which is to say two elements of one
149/// list, since a sequence adds no path component — would then be handed the same
150/// nonce. The plaintext bytes are equal, so this is not the catastrophic form of
151/// GCM nonce reuse; the consequence is a file whose bytes differ from the one
152/// sops would have written, which for a tool whose entire claim is byte-parity is
153/// the bug that matters. [`LeafType`] is in the key.
154///
155/// # The reuse that remains, stated rather than inherited
156///
157/// Two *genuinely identical* typed values at one path do still share a nonce. The
158/// plaintexts are identical, so an attacker learns only that they are equal —
159/// which any deterministic encryption concedes by construction. It is a knowing
160/// trade, confined to unchanged values, and it is the price of a reviewable diff.
161#[derive(Default)]
162pub struct IvStash {
163    seen: HashMap<(LeafType, Vec<u8>, Vec<u8>), Iv>,
164}
165
166impl IvStash {
167    #[must_use]
168    pub fn new() -> Self {
169        Self::default()
170    }
171
172    fn key(plaintext: &Plaintext, aad: &Aad) -> (LeafType, Vec<u8>, Vec<u8>) {
173        (
174            plaintext.leaf_type(),
175            plaintext.expose().to_vec(),
176            aad.as_bytes().to_vec(),
177        )
178    }
179
180    /// Record the IV a leaf was decrypted with, so an unchanged value keeps it.
181    pub fn remember(&mut self, plaintext: &Plaintext, aad: &Aad, iv: &[u8]) {
182        if let Some(iv) = Iv::from_wire_exact(iv) {
183            self.seen.insert(Self::key(plaintext, aad), iv);
184        }
185    }
186
187    /// The remembered IV for this pair, if any.
188    #[must_use]
189    pub fn recall(&self, plaintext: &Plaintext, aad: &Aad) -> Option<Iv> {
190        self.seen.get(&Self::key(plaintext, aad)).cloned()
191    }
192
193    /// How many pairs are remembered. Diagnostics only.
194    #[must_use]
195    pub fn len(&self) -> usize {
196        self.seen.len()
197    }
198
199    #[must_use]
200    pub fn is_empty(&self) -> bool {
201        self.seen.is_empty()
202    }
203}
204
205impl std::fmt::Debug for IvStash {
206    /// The keys of this map are plaintexts. Printing the map would leak every
207    /// value in the file, so `Debug` prints only the count.
208    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
209        write!(f, "IvStash({} pairs)", self.seen.len())
210    }
211}
212
213/// Encrypt one leaf.
214///
215/// `None` for `iv` draws a fresh one; pass a stash hit to reproduce previous
216/// bytes. An **empty plaintext stays empty** — `isEmpty` short-circuits both
217/// directions upstream, so an empty string is a fixed point of the format rather
218/// than a zero-length ciphertext.
219pub fn encrypt_leaf(
220    key: &DataKey,
221    plaintext: &Plaintext,
222    aad: &Aad,
223    iv: Option<Iv>,
224) -> Result<Option<EncryptedLeaf>, WireError> {
225    if plaintext.is_empty() {
226        return Ok(None);
227    }
228    let iv = match iv {
229        Some(iv) => iv,
230        None => Iv::generate()?,
231    };
232    let gcm = SopsGcm32::new_from_slice(key.expose()).map_err(|_| WireError::AeadOpen)?;
233    let sealed = gcm
234        .encrypt(
235            aes_gcm::Nonce::<aes_gcm::aead::consts::U32>::from_slice(iv.as_bytes()),
236            Payload {
237                msg: plaintext.expose(),
238                aad: aad.as_bytes(),
239            },
240        )
241        .map_err(|_| WireError::AeadOpen)?;
242    // Go's Seal returns ciphertext||tag and sops splits at BlockSize (16).
243    // `aes-gcm` returns the same layout, so the split is identical.
244    let split = sealed.len().saturating_sub(TAG_LEN);
245    let (data, tag) = sealed.split_at(split);
246    Ok(Some(EncryptedLeaf {
247        data: data.to_vec(),
248        iv: iv.as_bytes().to_vec(),
249        tag: tag.to_vec(),
250        ty: plaintext.leaf_type(),
251    }))
252}
253
254/// The GCM tag length. `cryptoaes.BlockSize` upstream — 16 bytes.
255const TAG_LEN: usize = 16;
256
257/// Decrypt one leaf.
258///
259/// Honours the nonce length recorded in the file rather than the 32-byte
260/// constant, matching upstream's `NewGCMWithNonceSize(…, len(iv))`, so a file
261/// from another implementation still opens. On success the IV is recorded in
262/// `stash` when one is supplied.
263pub fn decrypt_leaf(
264    key: &DataKey,
265    leaf: &EncryptedLeaf,
266    aad: &Aad,
267    stash: Option<&mut IvStash>,
268) -> Result<Plaintext, WireError> {
269    let mut sealed = Vec::with_capacity(leaf.data.len() + leaf.tag.len());
270    sealed.extend_from_slice(&leaf.data);
271    sealed.extend_from_slice(&leaf.tag);
272
273    let opened = match leaf.iv.len() {
274        Iv::LEN => {
275            let gcm = SopsGcm32::new_from_slice(key.expose()).map_err(|_| WireError::AeadOpen)?;
276            gcm.decrypt(
277                aes_gcm::Nonce::<aes_gcm::aead::consts::U32>::from_slice(&leaf.iv),
278                Payload {
279                    msg: &sealed,
280                    aad: aad.as_bytes(),
281                },
282            )
283        }
284        12 => {
285            // The RFC-standard nonce. sops never writes one, but it reads one,
286            // so a file produced by a third-party implementation opens here too.
287            let gcm = aes_gcm::Aes256Gcm::new_from_slice(key.expose())
288                .map_err(|_| WireError::AeadOpen)?;
289            gcm.decrypt(
290                aes_gcm::Nonce::<aes_gcm::aead::consts::U12>::from_slice(&leaf.iv),
291                Payload {
292                    msg: &sealed,
293                    aad: aad.as_bytes(),
294                },
295            )
296        }
297        // Any other length is refused rather than guessed at. Upstream would
298        // accept it via a dynamically-sized GCM; we would rather name the
299        // unsupported shape than silently succeed on one specimen and fail on
300        // the next.
301        _ => return Err(WireError::AeadOpen),
302    }
303    .map_err(|_| WireError::AeadOpen)?;
304
305    let plaintext = Plaintext::from_wire(opened, leaf.ty);
306    if let Some(stash) = stash {
307        stash.remember(&plaintext, aad, &leaf.iv);
308    }
309    Ok(plaintext)
310}
311
312/// Decrypt a leaf that is known to be a plain `str` — the MAC field's shape.
313pub(crate) fn decrypt_leaf_as_string(
314    key: &DataKey,
315    leaf: &EncryptedLeaf,
316    aad: &Aad,
317    stash: Option<&mut IvStash>,
318) -> Result<Zeroizing<String>, WireError> {
319    let pt = decrypt_leaf(key, leaf, aad, stash)?;
320    if pt.leaf_type() != LeafType::Str {
321        return Err(WireError::DatatypeMismatch { ty: "str" });
322    }
323    Ok(Zeroizing::new(
324        String::from_utf8_lossy(pt.expose()).into_owned(),
325    ))
326}
327
328fn hex_lower(bytes: &[u8]) -> String {
329    use std::fmt::Write as _;
330    bytes
331        .iter()
332        .fold(String::with_capacity(bytes.len() * 2), |mut s, b| {
333            let _ = write!(s, "{b:02x}");
334            s
335        })
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341    use crate::aad::AadPath;
342
343    fn key() -> DataKey {
344        DataKey::from_bytes(&[7u8; 32]).expect("32 bytes")
345    }
346
347    fn aad(parts: &[&str]) -> Aad {
348        let mut p = AadPath::root();
349        for c in parts {
350            p.push_key(*c);
351        }
352        p.aad()
353    }
354
355    #[test]
356    fn round_trips_through_the_wire_rendering() {
357        let a = aad(&["db", "password"]);
358        let pt = Plaintext::string("s3kr1t");
359        let leaf = encrypt_leaf(&key(), &pt, &a, None)
360            .expect("encrypt")
361            .expect("non-empty");
362        let rendered = leaf.render();
363        let reparsed = EncryptedLeaf::parse(&rendered).expect("reparse");
364        let back = decrypt_leaf(&key(), &reparsed, &a, None).expect("decrypt");
365        assert_eq!(back.expose(), b"s3kr1t");
366        assert_eq!(back.leaf_type(), LeafType::Str);
367    }
368
369    #[test]
370    fn writes_a_thirty_two_byte_nonce() {
371        let leaf = encrypt_leaf(&key(), &Plaintext::string("x"), &aad(&["k"]), None)
372            .expect("encrypt")
373            .expect("non-empty");
374        assert_eq!(leaf.iv_len(), Iv::LEN);
375        assert_eq!(leaf.tag.len(), TAG_LEN);
376    }
377
378    /// The AAD is authenticated, so a leaf moved to a different key must not
379    /// open. This is what makes the path part of the file's integrity.
380    #[test]
381    fn a_leaf_moved_to_another_path_will_not_open() {
382        let leaf = encrypt_leaf(&key(), &Plaintext::string("v"), &aad(&["a", "b"]), None)
383            .expect("encrypt")
384            .expect("non-empty");
385        assert_eq!(
386            decrypt_leaf(&key(), &leaf, &aad(&["a", "c"]), None),
387            Err(WireError::AeadOpen)
388        );
389    }
390
391    #[test]
392    fn a_wrong_data_key_will_not_open() {
393        let leaf = encrypt_leaf(&key(), &Plaintext::string("v"), &aad(&["a"]), None)
394            .expect("encrypt")
395            .expect("non-empty");
396        let other = DataKey::from_bytes(&[9u8; 32]).expect("32 bytes");
397        assert_eq!(
398            decrypt_leaf(&other, &leaf, &aad(&["a"]), None),
399            Err(WireError::AeadOpen)
400        );
401    }
402
403    #[test]
404    fn a_flipped_ciphertext_bit_will_not_open() {
405        let mut leaf = encrypt_leaf(&key(), &Plaintext::string("value"), &aad(&["a"]), None)
406            .expect("encrypt")
407            .expect("non-empty");
408        leaf.data[0] ^= 1;
409        assert_eq!(
410            decrypt_leaf(&key(), &leaf, &aad(&["a"]), None),
411            Err(WireError::AeadOpen)
412        );
413    }
414
415    #[test]
416    fn empty_is_a_fixed_point_in_both_directions() {
417        let empty = Plaintext::string("");
418        assert!(
419            encrypt_leaf(&key(), &empty, &aad(&["k"]), None)
420                .expect("encrypt")
421                .is_none()
422        );
423    }
424
425    /// Without the stash an unchanged value would get a fresh nonce and the
426    /// whole file would churn on every edit.
427    #[test]
428    fn the_stash_reproduces_previous_bytes_exactly() {
429        let a = aad(&["k"]);
430        let pt = Plaintext::string("unchanged");
431        let first = encrypt_leaf(&key(), &pt, &a, None)
432            .expect("encrypt")
433            .expect("non-empty");
434
435        let mut stash = IvStash::new();
436        let recovered = decrypt_leaf(&key(), &first, &a, Some(&mut stash)).expect("decrypt");
437        assert_eq!(stash.len(), 1);
438
439        let second = encrypt_leaf(&key(), &recovered, &a, stash.recall(&recovered, &a))
440            .expect("re-encrypt")
441            .expect("non-empty");
442        assert_eq!(
443            first.render(),
444            second.render(),
445            "an unchanged value must re-encrypt identically"
446        );
447    }
448
449    /// The typed-key regression. Upstream's stash key is a Go `interface{}`, so
450    /// `int(1)` and `string("1")` are different keys; keying on the raw bytes
451    /// collapses them and hands two list elements the same nonce.
452    #[test]
453    fn the_stash_key_separates_values_that_share_a_byte_string() {
454        let a = aad(&["items"]);
455        let mut stash = IvStash::new();
456        let iv = [42u8; 32];
457
458        // `1` as an int, remembered.
459        stash.remember(&Plaintext::integer(1), &a, &iv);
460        assert_eq!(stash.len(), 1);
461
462        // The *string* "1" has the same bytes and must NOT hit.
463        assert!(
464            stash.recall(&Plaintext::string("1"), &a).is_none(),
465            "a str must not recall an int's nonce"
466        );
467        // Nor must a float that renders to the same digits.
468        assert!(
469            stash.recall(&Plaintext::float(1.0), &a).is_none(),
470            "a float must not recall an int's nonce"
471        );
472        // The int itself still does.
473        assert!(stash.recall(&Plaintext::integer(1), &a).is_some());
474
475        // `true` renders as `True`, which is also a perfectly good string.
476        stash.remember(&Plaintext::boolean(true), &a, &iv);
477        assert!(
478            stash.recall(&Plaintext::string("True"), &a).is_none(),
479            "a str must not recall a bool's nonce"
480        );
481        assert!(stash.recall(&Plaintext::boolean(true), &a).is_some());
482
483        // Four distinct entries from three byte strings.
484        stash.remember(&Plaintext::string("1"), &a, &iv);
485        stash.remember(&Plaintext::float(1.0), &a, &iv);
486        stash.remember(&Plaintext::string("True"), &a, &iv);
487        assert_eq!(stash.len(), 5, "int, bool, str-1, float-1, str-True");
488    }
489
490    /// And the AAD is still part of the key, so the same value at a different path
491    /// gets its own nonce.
492    #[test]
493    fn the_stash_key_separates_paths() {
494        let mut stash = IvStash::new();
495        stash.remember(&Plaintext::string("v"), &aad(&["a"]), &[1u8; 32]);
496        assert!(
497            stash
498                .recall(&Plaintext::string("v"), &aad(&["b"]))
499                .is_none()
500        );
501        assert!(
502            stash
503                .recall(&Plaintext::string("v"), &aad(&["a"]))
504                .is_some()
505        );
506    }
507
508    #[test]
509    fn without_the_stash_the_bytes_change() {
510        let a = aad(&["k"]);
511        let pt = Plaintext::string("unchanged");
512        let first = encrypt_leaf(&key(), &pt, &a, None)
513            .expect("e")
514            .expect("non-empty");
515        let second = encrypt_leaf(&key(), &pt, &a, None)
516            .expect("e")
517            .expect("non-empty");
518        assert_ne!(first.render(), second.render(), "fresh nonces must differ");
519    }
520
521    #[test]
522    fn a_short_data_key_is_named_not_swallowed() {
523        let err = DataKey::from_bytes(&[0u8; 16])
524            .err()
525            .expect("16 bytes must be refused");
526        assert_eq!(err, WireError::DataKeyLength(16));
527    }
528
529    #[test]
530    fn debug_never_shows_key_or_plaintext() {
531        assert_eq!(format!("{:?}", key()), "DataKey(*** 32 bytes)");
532        let mut stash = IvStash::new();
533        stash.remember(&Plaintext::string("hunter2"), &aad(&["k"]), &[0u8; 32]);
534        let shown = format!("{stash:?}");
535        assert!(
536            !shown.contains("hunter2"),
537            "IvStash Debug leaked a plaintext: {shown}"
538        );
539        assert_eq!(shown, "IvStash(1 pairs)");
540    }
541}