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 `(plaintext, aad)` pair so re-encrypting an
127/// 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. Upstream calls it `stash` and keys
133/// it on exactly the same pair.
134///
135/// The security shape is worth stating rather than inheriting silently: two
136/// identical values at the same path reuse a nonce under one data key. Since the
137/// plaintexts are identical, GCM's nonce-reuse failure reveals nothing an
138/// attacker did not already have (equal ciphertext for equal plaintext, which
139/// deterministic encryption concedes by construction). It is a knowing trade,
140/// not an oversight, and it is confined to *unchanged* values.
141#[derive(Default)]
142pub struct IvStash {
143    seen: HashMap<(Vec<u8>, Vec<u8>), Iv>,
144}
145
146impl IvStash {
147    #[must_use]
148    pub fn new() -> Self {
149        Self::default()
150    }
151
152    /// Record the IV a leaf was decrypted with, so an unchanged value keeps it.
153    pub fn remember(&mut self, plaintext: &Plaintext, aad: &Aad, iv: &[u8]) {
154        if let Some(iv) = Iv::from_wire_exact(iv) {
155            self.seen
156                .insert((plaintext.expose().to_vec(), aad.as_bytes().to_vec()), iv);
157        }
158    }
159
160    /// The remembered IV for this pair, if any.
161    #[must_use]
162    pub fn recall(&self, plaintext: &Plaintext, aad: &Aad) -> Option<Iv> {
163        self.seen
164            .get(&(plaintext.expose().to_vec(), aad.as_bytes().to_vec()))
165            .cloned()
166    }
167
168    /// How many pairs are remembered. Diagnostics only.
169    #[must_use]
170    pub fn len(&self) -> usize {
171        self.seen.len()
172    }
173
174    #[must_use]
175    pub fn is_empty(&self) -> bool {
176        self.seen.is_empty()
177    }
178}
179
180impl std::fmt::Debug for IvStash {
181    /// The keys of this map are plaintexts. Printing the map would leak every
182    /// value in the file, so `Debug` prints only the count.
183    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
184        write!(f, "IvStash({} pairs)", self.seen.len())
185    }
186}
187
188/// Encrypt one leaf.
189///
190/// `None` for `iv` draws a fresh one; pass a stash hit to reproduce previous
191/// bytes. An **empty plaintext stays empty** — `isEmpty` short-circuits both
192/// directions upstream, so an empty string is a fixed point of the format rather
193/// than a zero-length ciphertext.
194pub fn encrypt_leaf(
195    key: &DataKey,
196    plaintext: &Plaintext,
197    aad: &Aad,
198    iv: Option<Iv>,
199) -> Result<Option<EncryptedLeaf>, WireError> {
200    if plaintext.is_empty() {
201        return Ok(None);
202    }
203    let iv = match iv {
204        Some(iv) => iv,
205        None => Iv::generate()?,
206    };
207    let gcm = SopsGcm32::new_from_slice(key.expose()).map_err(|_| WireError::AeadOpen)?;
208    let sealed = gcm
209        .encrypt(
210            aes_gcm::Nonce::<aes_gcm::aead::consts::U32>::from_slice(iv.as_bytes()),
211            Payload {
212                msg: plaintext.expose(),
213                aad: aad.as_bytes(),
214            },
215        )
216        .map_err(|_| WireError::AeadOpen)?;
217    // Go's Seal returns ciphertext||tag and sops splits at BlockSize (16).
218    // `aes-gcm` returns the same layout, so the split is identical.
219    let split = sealed.len().saturating_sub(TAG_LEN);
220    let (data, tag) = sealed.split_at(split);
221    Ok(Some(EncryptedLeaf {
222        data: data.to_vec(),
223        iv: iv.as_bytes().to_vec(),
224        tag: tag.to_vec(),
225        ty: plaintext.leaf_type(),
226    }))
227}
228
229/// The GCM tag length. `cryptoaes.BlockSize` upstream — 16 bytes.
230const TAG_LEN: usize = 16;
231
232/// Decrypt one leaf.
233///
234/// Honours the nonce length recorded in the file rather than the 32-byte
235/// constant, matching upstream's `NewGCMWithNonceSize(…, len(iv))`, so a file
236/// from another implementation still opens. On success the IV is recorded in
237/// `stash` when one is supplied.
238pub fn decrypt_leaf(
239    key: &DataKey,
240    leaf: &EncryptedLeaf,
241    aad: &Aad,
242    stash: Option<&mut IvStash>,
243) -> Result<Plaintext, WireError> {
244    let mut sealed = Vec::with_capacity(leaf.data.len() + leaf.tag.len());
245    sealed.extend_from_slice(&leaf.data);
246    sealed.extend_from_slice(&leaf.tag);
247
248    let opened = match leaf.iv.len() {
249        Iv::LEN => {
250            let gcm = SopsGcm32::new_from_slice(key.expose()).map_err(|_| WireError::AeadOpen)?;
251            gcm.decrypt(
252                aes_gcm::Nonce::<aes_gcm::aead::consts::U32>::from_slice(&leaf.iv),
253                Payload {
254                    msg: &sealed,
255                    aad: aad.as_bytes(),
256                },
257            )
258        }
259        12 => {
260            // The RFC-standard nonce. sops never writes one, but it reads one,
261            // so a file produced by a third-party implementation opens here too.
262            let gcm = aes_gcm::Aes256Gcm::new_from_slice(key.expose())
263                .map_err(|_| WireError::AeadOpen)?;
264            gcm.decrypt(
265                aes_gcm::Nonce::<aes_gcm::aead::consts::U12>::from_slice(&leaf.iv),
266                Payload {
267                    msg: &sealed,
268                    aad: aad.as_bytes(),
269                },
270            )
271        }
272        // Any other length is refused rather than guessed at. Upstream would
273        // accept it via a dynamically-sized GCM; we would rather name the
274        // unsupported shape than silently succeed on one specimen and fail on
275        // the next.
276        _ => return Err(WireError::AeadOpen),
277    }
278    .map_err(|_| WireError::AeadOpen)?;
279
280    let plaintext = Plaintext::from_wire(opened, leaf.ty);
281    if let Some(stash) = stash {
282        stash.remember(&plaintext, aad, &leaf.iv);
283    }
284    Ok(plaintext)
285}
286
287/// Decrypt a leaf that is known to be a plain `str` — the MAC field's shape.
288pub(crate) fn decrypt_leaf_as_string(
289    key: &DataKey,
290    leaf: &EncryptedLeaf,
291    aad: &Aad,
292    stash: Option<&mut IvStash>,
293) -> Result<Zeroizing<String>, WireError> {
294    let pt = decrypt_leaf(key, leaf, aad, stash)?;
295    if pt.leaf_type() != LeafType::Str {
296        return Err(WireError::DatatypeMismatch { ty: "str" });
297    }
298    Ok(Zeroizing::new(
299        String::from_utf8_lossy(pt.expose()).into_owned(),
300    ))
301}
302
303fn hex_lower(bytes: &[u8]) -> String {
304    use std::fmt::Write as _;
305    bytes
306        .iter()
307        .fold(String::with_capacity(bytes.len() * 2), |mut s, b| {
308            let _ = write!(s, "{b:02x}");
309            s
310        })
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316    use crate::aad::AadPath;
317
318    fn key() -> DataKey {
319        DataKey::from_bytes(&[7u8; 32]).expect("32 bytes")
320    }
321
322    fn aad(parts: &[&str]) -> Aad {
323        let mut p = AadPath::root();
324        for c in parts {
325            p.push_key(*c);
326        }
327        p.aad()
328    }
329
330    #[test]
331    fn round_trips_through_the_wire_rendering() {
332        let a = aad(&["db", "password"]);
333        let pt = Plaintext::string("s3kr1t");
334        let leaf = encrypt_leaf(&key(), &pt, &a, None)
335            .expect("encrypt")
336            .expect("non-empty");
337        let rendered = leaf.render();
338        let reparsed = EncryptedLeaf::parse(&rendered).expect("reparse");
339        let back = decrypt_leaf(&key(), &reparsed, &a, None).expect("decrypt");
340        assert_eq!(back.expose(), b"s3kr1t");
341        assert_eq!(back.leaf_type(), LeafType::Str);
342    }
343
344    #[test]
345    fn writes_a_thirty_two_byte_nonce() {
346        let leaf = encrypt_leaf(&key(), &Plaintext::string("x"), &aad(&["k"]), None)
347            .expect("encrypt")
348            .expect("non-empty");
349        assert_eq!(leaf.iv_len(), Iv::LEN);
350        assert_eq!(leaf.tag.len(), TAG_LEN);
351    }
352
353    /// The AAD is authenticated, so a leaf moved to a different key must not
354    /// open. This is what makes the path part of the file's integrity.
355    #[test]
356    fn a_leaf_moved_to_another_path_will_not_open() {
357        let leaf = encrypt_leaf(&key(), &Plaintext::string("v"), &aad(&["a", "b"]), None)
358            .expect("encrypt")
359            .expect("non-empty");
360        assert_eq!(
361            decrypt_leaf(&key(), &leaf, &aad(&["a", "c"]), None),
362            Err(WireError::AeadOpen)
363        );
364    }
365
366    #[test]
367    fn a_wrong_data_key_will_not_open() {
368        let leaf = encrypt_leaf(&key(), &Plaintext::string("v"), &aad(&["a"]), None)
369            .expect("encrypt")
370            .expect("non-empty");
371        let other = DataKey::from_bytes(&[9u8; 32]).expect("32 bytes");
372        assert_eq!(
373            decrypt_leaf(&other, &leaf, &aad(&["a"]), None),
374            Err(WireError::AeadOpen)
375        );
376    }
377
378    #[test]
379    fn a_flipped_ciphertext_bit_will_not_open() {
380        let mut leaf = encrypt_leaf(&key(), &Plaintext::string("value"), &aad(&["a"]), None)
381            .expect("encrypt")
382            .expect("non-empty");
383        leaf.data[0] ^= 1;
384        assert_eq!(
385            decrypt_leaf(&key(), &leaf, &aad(&["a"]), None),
386            Err(WireError::AeadOpen)
387        );
388    }
389
390    #[test]
391    fn empty_is_a_fixed_point_in_both_directions() {
392        let empty = Plaintext::string("");
393        assert!(
394            encrypt_leaf(&key(), &empty, &aad(&["k"]), None)
395                .expect("encrypt")
396                .is_none()
397        );
398    }
399
400    /// Without the stash an unchanged value would get a fresh nonce and the
401    /// whole file would churn on every edit.
402    #[test]
403    fn the_stash_reproduces_previous_bytes_exactly() {
404        let a = aad(&["k"]);
405        let pt = Plaintext::string("unchanged");
406        let first = encrypt_leaf(&key(), &pt, &a, None)
407            .expect("encrypt")
408            .expect("non-empty");
409
410        let mut stash = IvStash::new();
411        let recovered = decrypt_leaf(&key(), &first, &a, Some(&mut stash)).expect("decrypt");
412        assert_eq!(stash.len(), 1);
413
414        let second = encrypt_leaf(&key(), &recovered, &a, stash.recall(&recovered, &a))
415            .expect("re-encrypt")
416            .expect("non-empty");
417        assert_eq!(
418            first.render(),
419            second.render(),
420            "an unchanged value must re-encrypt identically"
421        );
422    }
423
424    #[test]
425    fn without_the_stash_the_bytes_change() {
426        let a = aad(&["k"]);
427        let pt = Plaintext::string("unchanged");
428        let first = encrypt_leaf(&key(), &pt, &a, None)
429            .expect("e")
430            .expect("non-empty");
431        let second = encrypt_leaf(&key(), &pt, &a, None)
432            .expect("e")
433            .expect("non-empty");
434        assert_ne!(first.render(), second.render(), "fresh nonces must differ");
435    }
436
437    #[test]
438    fn a_short_data_key_is_named_not_swallowed() {
439        let err = DataKey::from_bytes(&[0u8; 16])
440            .err()
441            .expect("16 bytes must be refused");
442        assert_eq!(err, WireError::DataKeyLength(16));
443    }
444
445    #[test]
446    fn debug_never_shows_key_or_plaintext() {
447        assert_eq!(format!("{:?}", key()), "DataKey(*** 32 bytes)");
448        let mut stash = IvStash::new();
449        stash.remember(&Plaintext::string("hunter2"), &aad(&["k"]), &[0u8; 32]);
450        let shown = format!("{stash:?}");
451        assert!(
452            !shown.contains("hunter2"),
453            "IvStash Debug leaked a plaintext: {shown}"
454        );
455        assert_eq!(shown, "IvStash(1 pairs)");
456    }
457}