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}