suminuri_wire/aad.rs
1//! The additional authenticated data — a leaf's path, and the exact rules for
2//! building it.
3//!
4//! `sops.go`'s walker does this and only this:
5//!
6//! ```go
7//! pathString := strings.Join(path, ":") + ":"
8//! ```
9//!
10//! Two details in that one line decide whether a file we write can ever be read
11//! by real sops, and both are easy to get wrong in the helpful direction:
12//!
13//! - the **trailing colon** is part of the AAD, and
14//! - **sequence indices are not in the path at all** — `walkSlice` recurses with
15//! `path` unchanged, so every element of a list authenticates under its
16//! parent key's path.
17//!
18//! An implementation that appends `[0]`, `.0` or `:0` produces ciphertext that
19//! sops rejects with an opaque GCM error, miles from the cause. So this module
20//! offers no way to do it: [`AadPath`] has exactly one push, and it takes a key.
21
22/// The finished AAD string for one leaf. Opaque on purpose.
23///
24/// There is no `From<String>`, no `new`, and no `Deref<Target = str>` — the only
25/// way to obtain one is [`AadPath::aad`]. That is what makes rule 2 of the crate
26/// docs structural rather than advisory.
27#[derive(Clone, PartialEq, Eq, Hash)]
28pub struct Aad(String);
29
30impl Aad {
31 /// The bytes fed to AES-GCM as additional authenticated data.
32 #[must_use]
33 pub fn as_bytes(&self) -> &[u8] {
34 self.0.as_bytes()
35 }
36
37 /// The one sanctioned AAD that is **not** a leaf path: the `sops.mac` field
38 /// authenticates under the verbatim `lastmodified` string.
39 ///
40 /// `pub(crate)` on purpose. Rule 2 of the crate docs — "no AAD built by
41 /// hand" — is about *leaf* AADs, and this is genuinely a different thing, so
42 /// the escape exists; keeping it crate-private and reachable only through
43 /// the named [`crate::mac::mac_field_aad`] means the invariant still holds
44 /// for every caller outside this crate, with exactly one auditable
45 /// exception rather than an open constructor.
46 pub(crate) fn field(literal: &str) -> Self {
47 Self(literal.to_string())
48 }
49}
50
51impl std::fmt::Debug for Aad {
52 /// An AAD is a *path*, not a secret — printing it is how a decrypt failure
53 /// becomes diagnosable. Shown quoted so a trailing colon is visible.
54 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55 write!(f, "Aad({:?})", self.0)
56 }
57}
58
59/// The stack of string mapping keys from the document root down to a leaf.
60///
61/// Push on the way down, pop on the way back up. Descending into a *sequence*
62/// pushes nothing, which is not an omission — see the module docs.
63#[derive(Debug, Default, Clone, PartialEq, Eq)]
64pub struct AadPath {
65 components: Vec<String>,
66}
67
68impl AadPath {
69 /// A path at the document root.
70 #[must_use]
71 pub fn root() -> Self {
72 Self::default()
73 }
74
75 /// Descend into a mapping under `key`.
76 pub fn push_key(&mut self, key: impl Into<String>) {
77 self.components.push(key.into());
78 }
79
80 /// Ascend back out of the last mapping.
81 pub fn pop(&mut self) {
82 self.components.pop();
83 }
84
85 /// Run `f` with `key` pushed, restoring the path afterwards even if `f`
86 /// returns early.
87 ///
88 /// The manual push/pop pair is the shape that goes wrong under `?`, so the
89 /// walker uses this instead.
90 pub fn within<T>(&mut self, key: impl Into<String>, f: impl FnOnce(&mut Self) -> T) -> T {
91 self.push_key(key);
92 let out = f(self);
93 self.pop();
94 out
95 }
96
97 /// The number of mapping keys from the root.
98 #[must_use]
99 pub fn depth(&self) -> usize {
100 self.components.len()
101 }
102
103 /// The components, for the selector rules — which test *every* component,
104 /// not just the leaf's own key.
105 #[must_use]
106 pub fn components(&self) -> &[String] {
107 &self.components
108 }
109
110 /// Build the AAD for a leaf at this path.
111 ///
112 /// Exactly `strings.Join(path, ":") + ":"`.
113 ///
114 /// Written as a join-then-append rather than a push-each-with-separator loop,
115 /// because the two disagree at depth 0: Go's `Join` over an empty slice is
116 /// `""`, so the AAD at the root is a **bare `":"`**, whereas the loop form
117 /// produces `""`. That is not a degenerate case nobody reaches — a
118 /// **top-level comment** has an empty path, because `walkBranch` passes
119 /// `item.Key` to `walkValue` with `path` unchanged. The loop form was the
120 /// first version of this function and the depth-0 test is what caught it.
121 #[must_use]
122 pub fn aad(&self) -> Aad {
123 let mut s = self.components.join(":");
124 s.push(':');
125 Aad(s)
126 }
127
128 /// Whether any component contains `:`, which makes this path's AAD
129 /// ambiguous with a differently-nested document.
130 ///
131 /// Upstream neither escapes nor detects this. We reproduce the encoding —
132 /// the wire is the wire — but we can at least *tell* a caller, so refusing
133 /// is a policy decision made in the open instead of a silent collision.
134 #[must_use]
135 pub fn has_ambiguous_component(&self) -> bool {
136 self.components.iter().any(|c| c.contains(':'))
137 }
138}
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143
144 #[test]
145 fn trailing_colon_is_part_of_the_aad() {
146 let mut p = AadPath::root();
147 p.push_key("a");
148 p.push_key("b");
149 assert_eq!(p.aad().as_bytes(), b"a:b:");
150 }
151
152 #[test]
153 fn root_aad_is_a_bare_colon() {
154 assert_eq!(AadPath::root().aad().as_bytes(), b":");
155 }
156
157 #[test]
158 fn within_restores_the_path() {
159 let mut p = AadPath::root();
160 p.push_key("outer");
161 let inner = p.within("inner", |p| p.aad());
162 assert_eq!(inner.as_bytes(), b"outer:inner:");
163 assert_eq!(p.aad().as_bytes(), b"outer:");
164 }
165
166 /// The regression this whole module exists to prevent. A sequence adds no
167 /// component, so both elements of `attic.age[..]` share one AAD — which is
168 /// what let the probe decrypt the operator's real `sops.age` array.
169 #[test]
170 fn sequence_descent_adds_nothing() {
171 let mut p = AadPath::root();
172 p.push_key("age");
173 let first = p.aad();
174 // Descending into element 0, then element 1, changes nothing at all:
175 // there is no method here that could.
176 let second = p.aad();
177 assert_eq!(first.as_bytes(), second.as_bytes());
178 assert_eq!(first.as_bytes(), b"age:");
179 }
180
181 #[test]
182 fn ambiguity_is_reported_not_escaped() {
183 let mut p = AadPath::root();
184 p.push_key("a:b");
185 p.push_key("c");
186 assert!(p.has_ambiguous_component());
187 // and the encoding is still the upstream one, collision included
188 assert_eq!(p.aad().as_bytes(), b"a:b:c:");
189
190 let mut q = AadPath::root();
191 q.push_key("a");
192 q.push_key("b:c");
193 assert_eq!(
194 q.aad().as_bytes(),
195 p.aad().as_bytes(),
196 "the upstream collision, reproduced"
197 );
198 }
199
200 #[test]
201 fn debug_shows_the_trailing_colon() {
202 let mut p = AadPath::root();
203 p.push_key("k");
204 assert_eq!(format!("{:?}", p.aad()), r#"Aad("k:")"#);
205 }
206}