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}