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;
33use crate::mac::{Mac, verify_mac_field};
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 if self.leaves_fed == 0 {
91 return Err(WireError::MacMismatch);
92 }
93 self.verify_allowing_empty(key)
94 }
95
96 /// [`Unverified::verify`] without the anti-vacuity refusal, for the genuinely
97 /// empty document.
98 pub fn verify_allowing_empty(self, key: &DataKey) -> Result<T, WireError> {
99 verify_mac_field(key, &self.mac_field, &self.lastmodified, &self.computed)?;
100 Ok(self.inner)
101 }
102
103 /// The `--ignore-mac` escape.
104 ///
105 /// Deliberately verbose. sops offers `--ignore-mac` and real operators need
106 /// it — a file whose MAC broke because someone hand-edited `lastmodified` is
107 /// still recoverable, and refusing outright would make us *less* useful than
108 /// what we replace. So the escape exists; it is just impossible to take
109 /// without typing its name.
110 pub fn into_inner_ignoring_mac(self) -> T {
111 self.inner
112 }
113
114 /// Map the wrapped value without unwrapping it, so a caller can keep
115 /// transforming a still-unauthenticated tree without losing the marker.
116 pub fn map<U>(self, f: impl FnOnce(T) -> U) -> Unverified<U> {
117 Unverified {
118 inner: f(self.inner),
119 computed: self.computed,
120 mac_field: self.mac_field,
121 lastmodified: self.lastmodified,
122 leaves_fed: self.leaves_fed,
123 }
124 }
125}
126
127impl<T> std::fmt::Debug for Unverified<T> {
128 /// Never prints the wrapped value — it is decrypted plaintext, and this type
129 /// is most likely to be `Debug`-printed exactly when someone is debugging a
130 /// MAC failure over a real file.
131 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132 f.debug_struct("Unverified")
133 .field("computed", &self.computed)
134 .field("leaves_fed", &self.leaves_fed)
135 .field("value", &"***")
136 .finish()
137 }
138}
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143 use crate::leaf::Plaintext;
144 use crate::mac::{MacAccumulator, seal_mac_field};
145
146 fn key() -> DataKey {
147 DataKey::from_bytes(&[5u8; 32]).expect("32")
148 }
149
150 fn wrapped(contents: &[&str], ts: &str) -> (Unverified<Vec<String>>, DataKey) {
151 let k = key();
152 let mut acc = MacAccumulator::new(false);
153 for c in contents {
154 acc.feed(&Plaintext::string(*c));
155 }
156 let fed = acc.leaves_fed();
157 let mac = acc.finish();
158 let field = seal_mac_field(&k, &mac, ts, None).expect("seal");
159 let tree: Vec<String> = contents.iter().map(|s| (*s).to_string()).collect();
160 (Unverified::new(tree, mac, field, ts, fed), k)
161 }
162
163 #[test]
164 fn a_matching_mac_releases_the_value() {
165 let (u, k) = wrapped(&["a", "b"], "2026-08-18T00:00:00Z");
166 assert_eq!(u.verify(&k).expect("verify"), vec!["a", "b"]);
167 }
168
169 #[test]
170 fn a_wrong_key_does_not_release_the_value() {
171 let (u, _) = wrapped(&["a"], "2026-08-18T00:00:00Z");
172 let other = DataKey::from_bytes(&[6u8; 32]).expect("32");
173 assert_eq!(u.verify(&other), Err(WireError::MacUndecryptable));
174 }
175
176 /// The anti-vacuity refusal. Without it, a walker that found no leaves would
177 /// compute the empty digest, match another empty digest, and report success.
178 #[test]
179 fn a_zero_leaf_verification_is_refused_as_vacuous() {
180 let (u, k) = wrapped(&[], "2026-08-18T00:00:00Z");
181 assert_eq!(u.leaves_fed(), 0);
182 assert_eq!(u.verify(&k), Err(WireError::MacMismatch));
183 }
184
185 #[test]
186 fn an_explicitly_empty_document_can_still_be_accepted() {
187 let (u, k) = wrapped(&[], "2026-08-18T00:00:00Z");
188 assert!(u.verify_allowing_empty(&k).is_ok());
189 }
190
191 #[test]
192 fn the_ignore_mac_escape_works_and_is_named() {
193 let (u, _) = wrapped(&["a"], "2026-08-18T00:00:00Z");
194 assert_eq!(u.into_inner_ignoring_mac(), vec!["a"]);
195 }
196
197 #[test]
198 fn map_preserves_the_marker_and_the_denominator() {
199 let (u, k) = wrapped(&["a", "b"], "2026-08-18T00:00:00Z");
200 let mapped = u.map(|v| v.len());
201 assert_eq!(mapped.leaves_fed(), 2);
202 assert_eq!(mapped.verify(&k).expect("verify"), 2);
203 }
204
205 #[test]
206 fn debug_never_prints_the_wrapped_value() {
207 let (u, _) = wrapped(&["hunter2"], "2026-08-18T00:00:00Z");
208 let shown = format!("{u:?}");
209 assert!(
210 !shown.contains("hunter2"),
211 "Unverified Debug leaked plaintext: {shown}"
212 );
213 assert!(shown.contains("leaves_fed: 1"));
214 }
215}