Skip to main content

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}