Skip to main content

suminuri_wire/
verified.rs

1//! `Unverified<T>` — the type that makes "I forgot to check the MAC" impossible
2//! to write by accident.
3//!
4//! Upstream's shape is a boolean and an early return:
5//!
6//! ```go
7//! if !opts.IgnoreMac {
8//!     if fileMac != computedMac { return … MacMismatch }
9//! }
10//! return dataKey, nil            // the tree is already decrypted either way
11//! ```
12//!
13//! The tree exists, decrypted, before the check — so every later line is one
14//! `if` away from operating on unauthenticated data, and nothing in the type of
15//! the value records whether the check happened. That is fine in a codebase where
16//! one function owns the whole path, and it is exactly the shape that rots once a
17//! second caller appears.
18//!
19//! Here the decrypted value comes back wrapped. The only way to get at it is
20//! [`Unverified::verify`], which needs the MAC to match, or
21//! [`Unverified::into_inner_ignoring_mac`] — the `--ignore-mac` escape, named so
22//! a reviewer greps for one token rather than noticing a missing branch.
23//!
24//! The ceiling, stated: this is **truly-unrep for the accidental case** — there
25//! is no code path that reaches the value without one of those two calls. It is
26//! not unrepresentable in the absolute sense, because Rust cannot forbid a caller
27//! from *choosing* the named escape (C1: no dependent types to encode "and the
28//! operator authorised it"). What it buys is that the unsafe path can never be
29//! the *default* or the *silent* one.
30
31use crate::WireError;
32use crate::cipher::{DataKey, IvStash};
33use crate::mac::{Mac, verify_mac_field_recording};
34
35/// A decrypted value whose file MAC has not been checked yet.
36///
37/// Carries everything the check needs so a caller cannot be asked for the MAC
38/// inputs at some later point where they are no longer in scope.
39#[must_use = "an Unverified value is unauthenticated until you call verify()"]
40pub struct Unverified<T> {
41    inner: T,
42    computed: Mac,
43    mac_field: String,
44    lastmodified: String,
45    leaves_fed: usize,
46}
47
48impl<T> Unverified<T> {
49    /// Wrap a freshly-decrypted value together with its MAC inputs.
50    pub fn new(
51        inner: T,
52        computed: Mac,
53        mac_field: impl Into<String>,
54        lastmodified: impl Into<String>,
55        leaves_fed: usize,
56    ) -> Self {
57        Self {
58            inner,
59            computed,
60            mac_field: mac_field.into(),
61            lastmodified: lastmodified.into(),
62            leaves_fed,
63        }
64    }
65
66    /// The MAC recomputed from the decrypted contents.
67    pub fn computed_mac(&self) -> &Mac {
68        &self.computed
69    }
70
71    /// How many leaves went into the recomputed MAC. **The denominator.**
72    ///
73    /// A MAC over zero leaves matches another MAC over zero leaves, so a walker
74    /// that silently stopped finding leaves would verify green while checking
75    /// nothing. [`Unverified::verify`] refuses that case outright; this getter
76    /// lets a caller assert a specific expected count on top.
77    pub fn leaves_fed(&self) -> usize {
78        self.leaves_fed
79    }
80
81    /// Check the MAC and release the value.
82    ///
83    /// Refuses a zero-leaf verification as vacuous. That is a deliberate
84    /// divergence from upstream, which would happily verify an empty walk: the
85    /// only file that legitimately has no leaves is an empty document, and
86    /// treating one as authenticated is how a broken walker reads as a green
87    /// gate. A caller that genuinely wants to accept an empty document can say so
88    /// with [`Unverified::verify_allowing_empty`].
89    pub fn verify(self, key: &DataKey) -> Result<T, WireError> {
90        self.verify_recording(key, None)
91    }
92
93    /// [`Unverified::verify`], recording the MAC field's own IV into `stash`.
94    ///
95    /// Pass the same stash the decrypt walk filled. Upstream gets this for free
96    /// because the `mac` field shares one `Cipher` with every leaf; without it a
97    /// no-op re-encrypt leaves every data line untouched and moves the `mac:`
98    /// line alone.
99    pub fn verify_recording(
100        self,
101        key: &DataKey,
102        stash: Option<&mut IvStash>,
103    ) -> Result<T, WireError> {
104        if self.leaves_fed == 0 {
105            // Named for what it is. Reporting `MacMismatch` here sent a reader
106            // hunting for corruption in a file that was simply empty.
107            return Err(WireError::NothingToVerify);
108        }
109        verify_mac_field_recording(
110            key,
111            &self.mac_field,
112            &self.lastmodified,
113            &self.computed,
114            stash,
115        )?;
116        Ok(self.inner)
117    }
118
119    /// [`Unverified::verify`] without the anti-vacuity refusal, for the genuinely
120    /// empty document.
121    pub fn verify_allowing_empty(self, key: &DataKey) -> Result<T, WireError> {
122        verify_mac_field_recording(
123            key,
124            &self.mac_field,
125            &self.lastmodified,
126            &self.computed,
127            None,
128        )?;
129        Ok(self.inner)
130    }
131
132    /// The `--ignore-mac` escape.
133    ///
134    /// Deliberately verbose. sops offers `--ignore-mac` and real operators need
135    /// it — a file whose MAC broke because someone hand-edited `lastmodified` is
136    /// still recoverable, and refusing outright would make us *less* useful than
137    /// what we replace. So the escape exists; it is just impossible to take
138    /// without typing its name.
139    pub fn into_inner_ignoring_mac(self) -> T {
140        self.inner
141    }
142
143    /// Map the wrapped value without unwrapping it, so a caller can keep
144    /// transforming a still-unauthenticated tree without losing the marker.
145    pub fn map<U>(self, f: impl FnOnce(T) -> U) -> Unverified<U> {
146        Unverified {
147            inner: f(self.inner),
148            computed: self.computed,
149            mac_field: self.mac_field,
150            lastmodified: self.lastmodified,
151            leaves_fed: self.leaves_fed,
152        }
153    }
154}
155
156impl<T> std::fmt::Debug for Unverified<T> {
157    /// Never prints the wrapped value — it is decrypted plaintext, and this type
158    /// is most likely to be `Debug`-printed exactly when someone is debugging a
159    /// MAC failure over a real file.
160    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
161        f.debug_struct("Unverified")
162            .field("computed", &self.computed)
163            .field("leaves_fed", &self.leaves_fed)
164            .field("value", &"***")
165            .finish()
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use crate::leaf::Plaintext;
173    use crate::mac::{MacAccumulator, seal_mac_field};
174
175    fn key() -> DataKey {
176        DataKey::from_bytes(&[5u8; 32]).expect("32")
177    }
178
179    fn wrapped(contents: &[&str], ts: &str) -> (Unverified<Vec<String>>, DataKey) {
180        let k = key();
181        let mut acc = MacAccumulator::new(false);
182        for c in contents {
183            acc.feed(&Plaintext::string(*c));
184        }
185        let fed = acc.leaves_fed();
186        let mac = acc.finish();
187        let field = seal_mac_field(&k, &mac, ts, None).expect("seal");
188        let tree: Vec<String> = contents.iter().map(|s| (*s).to_string()).collect();
189        (Unverified::new(tree, mac, field, ts, fed), k)
190    }
191
192    #[test]
193    fn a_matching_mac_releases_the_value() {
194        let (u, k) = wrapped(&["a", "b"], "2026-08-18T00:00:00Z");
195        assert_eq!(u.verify(&k).expect("verify"), vec!["a", "b"]);
196    }
197
198    #[test]
199    fn a_wrong_key_does_not_release_the_value() {
200        let (u, _) = wrapped(&["a"], "2026-08-18T00:00:00Z");
201        let other = DataKey::from_bytes(&[6u8; 32]).expect("32");
202        assert_eq!(u.verify(&other), Err(WireError::MacUndecryptable));
203    }
204
205    /// The anti-vacuity refusal. Without it, a walker that found no leaves would
206    /// compute the empty digest, match another empty digest, and report success.
207    ///
208    /// The error is `NothingToVerify`, not `MacMismatch`. It reported the latter
209    /// until 2026-08-19 and that cost a real diagnosis cycle: document 0 of a
210    /// 5-document fleet file is two comments and an empty mapping, legitimately
211    /// MACs to nothing, and "MAC mismatch" sent me looking for corruption in a file
212    /// that was intact. An error naming the wrong cause is worse than a vague one.
213    #[test]
214    fn a_zero_leaf_verification_is_refused_as_vacuous() {
215        let (u, k) = wrapped(&[], "2026-08-18T00:00:00Z");
216        assert_eq!(u.leaves_fed(), 0);
217        assert_eq!(u.verify(&k), Err(WireError::NothingToVerify));
218    }
219
220    #[test]
221    fn an_explicitly_empty_document_can_still_be_accepted() {
222        let (u, k) = wrapped(&[], "2026-08-18T00:00:00Z");
223        assert!(u.verify_allowing_empty(&k).is_ok());
224    }
225
226    #[test]
227    fn the_ignore_mac_escape_works_and_is_named() {
228        let (u, _) = wrapped(&["a"], "2026-08-18T00:00:00Z");
229        assert_eq!(u.into_inner_ignoring_mac(), vec!["a"]);
230    }
231
232    #[test]
233    fn map_preserves_the_marker_and_the_denominator() {
234        let (u, k) = wrapped(&["a", "b"], "2026-08-18T00:00:00Z");
235        let mapped = u.map(|v| v.len());
236        assert_eq!(mapped.leaves_fed(), 2);
237        assert_eq!(mapped.verify(&k).expect("verify"), 2);
238    }
239
240    #[test]
241    fn debug_never_prints_the_wrapped_value() {
242        let (u, _) = wrapped(&["hunter2"], "2026-08-18T00:00:00Z");
243        let shown = format!("{u:?}");
244        assert!(
245            !shown.contains("hunter2"),
246            "Unverified Debug leaked plaintext: {shown}"
247        );
248        assert!(shown.contains("leaves_fed: 1"));
249    }
250}