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    let leaf = EncryptedLeaf::parse(mac_field).map_err(|_| WireError::MacUndecryptable)?;
198    let aad = mac_field_aad(lastmodified_verbatim);
199    let stored =
200        decrypt_leaf_as_string(key, &leaf, &aad).map_err(|_| WireError::MacUndecryptable)?;
201    let stored = Mac::from_file(stored.as_str());
202    if stored == *computed {
203        Ok(stored)
204    } else {
205        Err(WireError::MacMismatch)
206    }
207}
208
209/// Encrypt a computed MAC into the `sops.mac` field value.
210pub fn seal_mac_field(
211    key: &DataKey,
212    mac: &Mac,
213    lastmodified_verbatim: &str,
214    iv: Option<Iv>,
215) -> Result<String, WireError> {
216    let aad = mac_field_aad(lastmodified_verbatim);
217    let pt = Plaintext::string(mac.as_hex());
218    let leaf = encrypt_leaf(key, &pt, &aad, iv)?.ok_or(WireError::MacUndecryptable)?;
219    Ok(leaf.render())
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    #[test]
227    fn the_seed_is_sha256_of_the_word_sops() {
228        use sha2::Sha256;
229        let mut h = Sha256::new();
230        h.update(b"sops");
231        assert_eq!(h.finalize().as_slice(), MAC_ONLY_ENCRYPTED_SEED);
232    }
233
234    #[test]
235    fn digest_is_128_uppercase_hex_chars() {
236        let mut acc = MacAccumulator::new(false);
237        acc.feed(&Plaintext::string("a"));
238        let mac = acc.finish();
239        assert_eq!(mac.as_hex().len(), 128);
240        assert!(
241            mac.as_hex()
242                .chars()
243                .all(|c| c.is_ascii_digit() || c.is_ascii_uppercase())
244        );
245    }
246
247    /// The known-answer test. SHA-512 of the single byte "a", uppercase, is a
248    /// published constant — so this pins the digest to the algorithm rather than
249    /// to our own implementation of it.
250    #[test]
251    fn known_answer_for_a_single_leaf() {
252        let mut acc = MacAccumulator::new(false);
253        acc.feed(&Plaintext::string("a"));
254        assert_eq!(
255            acc.finish().as_hex(),
256            "1F40FC92DA241694750979EE6CF582F2D5D7D28E18335DE05ABC54D0560E0F5302860C652BF08D560252AA5E74210546F369FBBBCE8C12CFC7957B2652FE9A75"
257        );
258    }
259
260    #[test]
261    fn the_seed_changes_the_digest() {
262        let plain = {
263            let mut a = MacAccumulator::new(false);
264            a.feed(&Plaintext::string("x"));
265            a.finish()
266        };
267        let seeded = {
268            let mut a = MacAccumulator::new(true);
269            a.feed(&Plaintext::string("x"));
270            a.finish()
271        };
272        assert_ne!(plain, seeded, "the seed exists precisely to separate these");
273    }
274
275    /// Order is part of the file's integrity. If this ever passes, the YAML
276    /// layer is free to reorder keys and it is not.
277    #[test]
278    fn order_changes_the_digest() {
279        let ab = {
280            let mut a = MacAccumulator::new(false);
281            a.feed(&Plaintext::string("a"));
282            a.feed(&Plaintext::string("b"));
283            a.finish()
284        };
285        let ba = {
286            let mut a = MacAccumulator::new(false);
287            a.feed(&Plaintext::string("b"));
288            a.feed(&Plaintext::string("a"));
289            a.finish()
290        };
291        assert_ne!(ab, ba);
292    }
293
294    /// The concatenation is unseparated, which means `["ab"]` and `["a","b"]`
295    /// collide. That is upstream's behaviour and it is reproduced knowingly —
296    /// documented here so nobody "fixes" it and breaks every existing file.
297    #[test]
298    fn concatenation_is_unseparated_upstream_collision_included() {
299        let joined = {
300            let mut a = MacAccumulator::new(false);
301            a.feed(&Plaintext::string("ab"));
302            a.finish()
303        };
304        let split = {
305            let mut a = MacAccumulator::new(false);
306            a.feed(&Plaintext::string("a"));
307            a.feed(&Plaintext::string("b"));
308            a.finish()
309        };
310        assert_eq!(joined, split, "reproduced, not endorsed");
311    }
312
313    #[test]
314    fn the_denominator_is_reported() {
315        let mut acc = MacAccumulator::new(false);
316        assert_eq!(acc.leaves_fed(), 0);
317        acc.feed(&Plaintext::string("a"));
318        acc.feed(&Plaintext::string("b"));
319        assert_eq!(acc.leaves_fed(), 2);
320    }
321
322    #[test]
323    fn mac_field_round_trips_and_binds_to_lastmodified() {
324        let key = DataKey::from_bytes(&[3u8; 32]).expect("32");
325        let mut acc = MacAccumulator::new(false);
326        acc.feed(&Plaintext::string("value"));
327        let mac = acc.finish();
328        let ts = "2026-08-18T12:00:00Z";
329
330        let field = seal_mac_field(&key, &mac, ts, None).expect("seal");
331        assert_eq!(
332            verify_mac_field(&key, &field, ts, &mac).expect("verify"),
333            mac
334        );
335
336        // A different timestamp is a different AAD, so the field will not open —
337        // which is exactly why hand-editing lastmodified breaks a file.
338        assert_eq!(
339            verify_mac_field(&key, &field, "2026-08-18T12:00:01Z", &mac),
340            Err(WireError::MacUndecryptable)
341        );
342    }
343
344    #[test]
345    fn a_changed_leaf_is_a_mismatch_not_an_undecryptable_field() {
346        let key = DataKey::from_bytes(&[3u8; 32]).expect("32");
347        let ts = "2026-08-18T12:00:00Z";
348        let original = {
349            let mut a = MacAccumulator::new(false);
350            a.feed(&Plaintext::string("before"));
351            a.finish()
352        };
353        let field = seal_mac_field(&key, &original, ts, None).expect("seal");
354        let tampered = {
355            let mut a = MacAccumulator::new(false);
356            a.feed(&Plaintext::string("after"));
357            a.finish()
358        };
359        assert_eq!(
360            verify_mac_field(&key, &field, ts, &tampered),
361            Err(WireError::MacMismatch)
362        );
363    }
364
365    #[test]
366    fn debug_elides_the_middle() {
367        let mut acc = MacAccumulator::new(false);
368        acc.feed(&Plaintext::string("a"));
369        let shown = format!("{:?}", acc.finish());
370        assert!(shown.starts_with("Mac(1F40FC92DA241694…"), "got {shown}");
371    }
372}