Skip to main content

suminuri_wire/
mac.rs

1//! The file MAC — and it is a bare SHA-512, not an HMAC.
2//!
3//! That is worth saying twice, because "MAC" reads as "keyed" to anyone who has
4//! met one before. `sops.go` imports `crypto/sha512` and calls `sha512.New()`;
5//! there is no key in the construction at all. The *integrity* comes from the
6//! second step: the resulting digest string is itself AES-GCM-encrypted under
7//! the data key, with `sops.lastmodified` as its AAD. So the file is bound to
8//! its own timestamp, and only a holder of the data key can produce a MAC field
9//! that verifies.
10//!
11//! ```text
12//! digest      = SHA512( [sha256("sops") if mac_only_encrypted] ||
13//!                       ToBytes(leaf₀) || ToBytes(leaf₁) || … )
14//! sops.mac    = ENC[…,type:str]  of  UPPERCASE_HEX(digest),  AAD = RFC3339(lastmodified)
15//! ```
16//!
17//! Three rules the accumulator encodes:
18//!
19//! - **order matters.** Leaves are fed in tree-walk order, so reordering two
20//!   mapping keys invalidates the file. This is why the YAML layer must preserve
21//!   key order and cannot round-trip through a `HashMap`.
22//! - **comments never contribute.** Both `Encrypt` and `Decrypt` guard the
23//!   `hash.Write` with "only add to MAC if not a comment", even when the comment
24//!   itself is encrypted.
25//! - **the `sops:` block is outside the MAC.** The metadata key is never walked,
26//!   which is what lets the MAC field live inside the structure it covers.
27
28use crate::WireError;
29use crate::aad::Aad;
30use crate::cipher::{DataKey, Iv, decrypt_leaf_as_string, encrypt_leaf};
31use crate::leaf::{EncryptedLeaf, Plaintext};
32use sha2::{Digest, Sha512};
33use subtle::ConstantTimeEq;
34
35/// `sha256(b"sops")`, the pre-seed for a `mac_only_encrypted` digest.
36///
37/// It exists so a MAC computed with the setting on can never collide with one
38/// computed with it off — otherwise flipping the flag on a file whose every leaf
39/// happens to be encrypted would produce the same digest, and the two policies
40/// would be indistinguishable. Upstream calls it `MACOnlyEncryptedInitialization`
41/// and documents the derivation as `echo -n sops | sha256sum`.
42pub const MAC_ONLY_ENCRYPTED_SEED: [u8; 32] = [
43    0x8a, 0x3f, 0xd2, 0xad, 0x54, 0xce, 0x66, 0x52, 0x7b, 0x10, 0x34, 0xf3, 0xd1, 0x47, 0xbe, 0x0b,
44    0x0b, 0x97, 0x5b, 0x3b, 0xf4, 0x4f, 0x72, 0xc6, 0xfd, 0xad, 0xec, 0x81, 0x76, 0xf2, 0x7d, 0x69,
45];
46
47/// A computed file MAC: 128 uppercase hex characters.
48///
49/// The inner string is private and [`PartialEq`] routes through
50/// `subtle::ConstantTimeEq`, so there is no non-constant-time way to compare two
51/// of these. Upstream uses Go's `!=`; the verdict is identical and the timing
52/// channel is gone — a strict improvement that costs nothing at the wire.
53#[derive(Clone)]
54pub struct Mac(String);
55
56impl Mac {
57    /// The uppercase-hex rendering, for writing into the file.
58    ///
59    /// A MAC is not a secret — it ships in the file, encrypted only to bind it to
60    /// the data key — so exposing the string is fine. Comparing it as a plain
61    /// string is what is prevented, and that is done by making [`PartialEq`] the
62    /// only comparison available.
63    #[must_use]
64    pub fn as_hex(&self) -> &str {
65        &self.0
66    }
67
68    /// Adopt a MAC recovered from a file's decrypted `mac` field.
69    #[must_use]
70    pub fn from_file(hex: impl Into<String>) -> Self {
71        Self(hex.into())
72    }
73
74    /// Whether this MAC is the empty string, which upstream reports as "no MAC"
75    /// rather than as a mismatch against nothing.
76    #[must_use]
77    pub fn is_absent(&self) -> bool {
78        self.0.is_empty()
79    }
80}
81
82impl PartialEq for Mac {
83    fn eq(&self, other: &Self) -> bool {
84        // Length is public (always 128 for a real MAC), so an early length
85        // check leaks nothing and keeps the byte compare well-defined.
86        self.0.len() == other.0.len() && self.0.as_bytes().ct_eq(other.0.as_bytes()).into()
87    }
88}
89
90impl Eq for Mac {}
91
92impl std::fmt::Debug for Mac {
93    /// Elided in the middle. A MAC is not secret, but a full 128-char digest in
94    /// a log line is noise, and printing both ends is what makes a mismatch
95    /// eyeball-comparable.
96    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        if self.0.len() > 24 {
98            write!(f, "Mac({}…{})", &self.0[..16], &self.0[self.0.len() - 8..])
99        } else {
100            write!(f, "Mac({})", self.0)
101        }
102    }
103}
104
105/// Accumulates leaf plaintexts into a file MAC, in walk order.
106///
107/// Construct with [`MacAccumulator::new`], feed every non-comment leaf the
108/// selector said to include, then [`MacAccumulator::finish`].
109pub struct MacAccumulator {
110    hash: Sha512,
111    mac_only_encrypted: bool,
112    fed: usize,
113}
114
115impl MacAccumulator {
116    /// Start a MAC. `mac_only_encrypted` pre-seeds the digest and changes which
117    /// leaves the caller should feed.
118    #[must_use]
119    pub fn new(mac_only_encrypted: bool) -> Self {
120        let mut hash = Sha512::new();
121        if mac_only_encrypted {
122            hash.update(MAC_ONLY_ENCRYPTED_SEED);
123        }
124        Self {
125            hash,
126            mac_only_encrypted,
127            fed: 0,
128        }
129    }
130
131    /// Whether this accumulator is in `mac_only_encrypted` mode, so a walker can
132    /// ask rather than thread the flag separately.
133    #[must_use]
134    pub fn mac_only_encrypted(&self) -> bool {
135        self.mac_only_encrypted
136    }
137
138    /// Feed one leaf.
139    ///
140    /// The caller owns the two policy decisions — comments are excluded, and
141    /// under `mac_only_encrypted` only leaves that end up encrypted count —
142    /// because both depend on the selector, which lives a layer up.
143    pub fn feed(&mut self, plaintext: &Plaintext) {
144        self.hash.update(plaintext.mac_bytes());
145        self.fed += 1;
146    }
147
148    /// How many leaves were fed. **The denominator.**
149    ///
150    /// A MAC over zero leaves is a perfectly valid SHA-512 and will happily
151    /// match another MAC over zero leaves, so a walker that silently stopped
152    /// finding leaves would verify green while checking nothing. Callers that
153    /// gate on this MAC should assert the count is what they expect — the same
154    /// anti-vacuity discipline the fleet's Nix gates carry.
155    #[must_use]
156    pub fn leaves_fed(&self) -> usize {
157        self.fed
158    }
159
160    /// Finish the digest. `fmt.Sprintf("%X", …)` — uppercase, 128 chars.
161    #[must_use]
162    pub fn finish(self) -> Mac {
163        use std::fmt::Write as _;
164        let digest = self.hash.finalize();
165        Mac(digest.iter().fold(String::with_capacity(128), |mut s, b| {
166            let _ = write!(s, "{b:02X}");
167            s
168        }))
169    }
170}
171
172/// The AAD under which the `sops.mac` field itself is encrypted: the RFC 3339
173/// rendering of `sops.lastmodified`, verbatim from the file.
174///
175/// Taking the string straight from the file rather than re-formatting a parsed
176/// timestamp is deliberate — any normalisation we applied (a `Z` becoming
177/// `+00:00`, a dropped fractional second) would change the AAD and make a valid
178/// file unreadable. Upstream computes it from a parsed `time.Time`, which is why
179/// hand-editing `lastmodified` invalidates a file.
180#[must_use]
181pub fn mac_field_aad(lastmodified_verbatim: &str) -> Aad {
182    // The MAC field's AAD is not a path, so it is built through the
183    // crate-private `Aad::field` rather than through `AadPath` — the one
184    // legitimate second source of an `Aad`, reachable only from here.
185    Aad::field(lastmodified_verbatim)
186}
187
188/// Decrypt a file's `mac` field and compare it against a recomputed MAC.
189///
190/// Returns the file's MAC on success so a caller can report both sides.
191pub fn verify_mac_field(
192    key: &DataKey,
193    mac_field: &str,
194    lastmodified_verbatim: &str,
195    computed: &Mac,
196) -> Result<Mac, WireError> {
197    verify_mac_field_recording(key, mac_field, lastmodified_verbatim, computed, None)
198}
199
200/// [`verify_mac_field`], recording the MAC field's own IV into a stash.
201///
202/// Upstream gets this for free: the `mac` field goes through the **same `Cipher`
203/// instance** as every leaf, so decrypting it populates that Cipher's stash and a
204/// later re-encrypt of an unchanged MAC reproduces the identical line. Splitting
205/// the MAC out into free functions here lost that for nothing, and the symptom was
206/// subtle — a no-op re-encrypt whose every *data* line was byte-identical and
207/// whose `mac:` line alone had moved.
208///
209/// It only bites when the timestamp is unchanged too, since `lastmodified` is the
210/// AAD: a normal `edit` stamps a new one and the line legitimately changes. The
211/// cases where it matters are a same-second re-encrypt and a fixed-clock test —
212/// and a property that holds only when nobody looks closely is not a property.
213pub fn verify_mac_field_recording(
214    key: &DataKey,
215    mac_field: &str,
216    lastmodified_verbatim: &str,
217    computed: &Mac,
218    stash: Option<&mut crate::cipher::IvStash>,
219) -> Result<Mac, WireError> {
220    let leaf = EncryptedLeaf::parse(mac_field).map_err(|_| WireError::MacUndecryptable)?;
221    let aad = mac_field_aad(lastmodified_verbatim);
222    let stored =
223        decrypt_leaf_as_string(key, &leaf, &aad, stash).map_err(|_| WireError::MacUndecryptable)?;
224    let stored = Mac::from_file(stored.as_str());
225    if stored == *computed {
226        Ok(stored)
227    } else {
228        Err(WireError::MacMismatch)
229    }
230}
231
232/// Encrypt a computed MAC into the `sops.mac` field value.
233pub fn seal_mac_field(
234    key: &DataKey,
235    mac: &Mac,
236    lastmodified_verbatim: &str,
237    iv: Option<Iv>,
238) -> Result<String, WireError> {
239    let aad = mac_field_aad(lastmodified_verbatim);
240    let pt = Plaintext::string(mac.as_hex());
241    let leaf = encrypt_leaf(key, &pt, &aad, iv)?.ok_or(WireError::MacUndecryptable)?;
242    Ok(leaf.render())
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    #[test]
250    fn the_seed_is_sha256_of_the_word_sops() {
251        use sha2::Sha256;
252        let mut h = Sha256::new();
253        h.update(b"sops");
254        assert_eq!(h.finalize().as_slice(), MAC_ONLY_ENCRYPTED_SEED);
255    }
256
257    #[test]
258    fn digest_is_128_uppercase_hex_chars() {
259        let mut acc = MacAccumulator::new(false);
260        acc.feed(&Plaintext::string("a"));
261        let mac = acc.finish();
262        assert_eq!(mac.as_hex().len(), 128);
263        assert!(
264            mac.as_hex()
265                .chars()
266                .all(|c| c.is_ascii_digit() || c.is_ascii_uppercase())
267        );
268    }
269
270    /// The known-answer test. SHA-512 of the single byte "a", uppercase, is a
271    /// published constant — so this pins the digest to the algorithm rather than
272    /// to our own implementation of it.
273    #[test]
274    fn known_answer_for_a_single_leaf() {
275        let mut acc = MacAccumulator::new(false);
276        acc.feed(&Plaintext::string("a"));
277        assert_eq!(
278            acc.finish().as_hex(),
279            "1F40FC92DA241694750979EE6CF582F2D5D7D28E18335DE05ABC54D0560E0F5302860C652BF08D560252AA5E74210546F369FBBBCE8C12CFC7957B2652FE9A75"
280        );
281    }
282
283    #[test]
284    fn the_seed_changes_the_digest() {
285        let plain = {
286            let mut a = MacAccumulator::new(false);
287            a.feed(&Plaintext::string("x"));
288            a.finish()
289        };
290        let seeded = {
291            let mut a = MacAccumulator::new(true);
292            a.feed(&Plaintext::string("x"));
293            a.finish()
294        };
295        assert_ne!(plain, seeded, "the seed exists precisely to separate these");
296    }
297
298    /// Order is part of the file's integrity. If this ever passes, the YAML
299    /// layer is free to reorder keys and it is not.
300    #[test]
301    fn order_changes_the_digest() {
302        let ab = {
303            let mut a = MacAccumulator::new(false);
304            a.feed(&Plaintext::string("a"));
305            a.feed(&Plaintext::string("b"));
306            a.finish()
307        };
308        let ba = {
309            let mut a = MacAccumulator::new(false);
310            a.feed(&Plaintext::string("b"));
311            a.feed(&Plaintext::string("a"));
312            a.finish()
313        };
314        assert_ne!(ab, ba);
315    }
316
317    /// The concatenation is unseparated, which means `["ab"]` and `["a","b"]`
318    /// collide. That is upstream's behaviour and it is reproduced knowingly —
319    /// documented here so nobody "fixes" it and breaks every existing file.
320    #[test]
321    fn concatenation_is_unseparated_upstream_collision_included() {
322        let joined = {
323            let mut a = MacAccumulator::new(false);
324            a.feed(&Plaintext::string("ab"));
325            a.finish()
326        };
327        let split = {
328            let mut a = MacAccumulator::new(false);
329            a.feed(&Plaintext::string("a"));
330            a.feed(&Plaintext::string("b"));
331            a.finish()
332        };
333        assert_eq!(joined, split, "reproduced, not endorsed");
334    }
335
336    #[test]
337    fn the_denominator_is_reported() {
338        let mut acc = MacAccumulator::new(false);
339        assert_eq!(acc.leaves_fed(), 0);
340        acc.feed(&Plaintext::string("a"));
341        acc.feed(&Plaintext::string("b"));
342        assert_eq!(acc.leaves_fed(), 2);
343    }
344
345    #[test]
346    fn mac_field_round_trips_and_binds_to_lastmodified() {
347        let key = DataKey::from_bytes(&[3u8; 32]).expect("32");
348        let mut acc = MacAccumulator::new(false);
349        acc.feed(&Plaintext::string("value"));
350        let mac = acc.finish();
351        let ts = "2026-08-18T12:00:00Z";
352
353        let field = seal_mac_field(&key, &mac, ts, None).expect("seal");
354        assert_eq!(
355            verify_mac_field(&key, &field, ts, &mac).expect("verify"),
356            mac
357        );
358
359        // A different timestamp is a different AAD, so the field will not open —
360        // which is exactly why hand-editing lastmodified breaks a file.
361        assert_eq!(
362            verify_mac_field(&key, &field, "2026-08-18T12:00:01Z", &mac),
363            Err(WireError::MacUndecryptable)
364        );
365    }
366
367    #[test]
368    fn a_changed_leaf_is_a_mismatch_not_an_undecryptable_field() {
369        let key = DataKey::from_bytes(&[3u8; 32]).expect("32");
370        let ts = "2026-08-18T12:00:00Z";
371        let original = {
372            let mut a = MacAccumulator::new(false);
373            a.feed(&Plaintext::string("before"));
374            a.finish()
375        };
376        let field = seal_mac_field(&key, &original, ts, None).expect("seal");
377        let tampered = {
378            let mut a = MacAccumulator::new(false);
379            a.feed(&Plaintext::string("after"));
380            a.finish()
381        };
382        assert_eq!(
383            verify_mac_field(&key, &field, ts, &tampered),
384            Err(WireError::MacMismatch)
385        );
386    }
387
388    #[test]
389    fn debug_elides_the_middle() {
390        let mut acc = MacAccumulator::new(false);
391        acc.feed(&Plaintext::string("a"));
392        let shown = format!("{:?}", acc.finish());
393        assert!(shown.starts_with("Mac(1F40FC92DA241694…"), "got {shown}");
394    }
395}