Skip to main content

suminuri_wire/
leaf.rs

1//! Leaf plaintext and the `ENC[…]` rendering around it.
2//!
3//! The rendering is fixed by `aes/cipher.go`:
4//!
5//! ```text
6//! ENC[AES256_GCM,data:<b64std>,iv:<b64std>,tag:<b64std>,type:<t>]
7//! ```
8//!
9//! Three things about it are load-bearing and none are guessable:
10//!
11//! - base64 is **`StdEncoding`** — padded, `+/` alphabet, not URL-safe.
12//! - the upstream regex is anchored **only at the start**, so trailing bytes
13//!   after `]` are ignored. We match that, because a file real sops accepts must
14//!   not be one we reject.
15//! - the type tag decides how the recovered bytes become a value, and two of the
16//!   renderings are Python's rather than Go's or Rust's: booleans are
17//!   `True`/`False`, floats are shortest-round-trip with no exponent.
18
19use crate::WireError;
20use base64::Engine as _;
21use zeroize::Zeroizing;
22
23/// The datatype tag carried in `type:`.
24///
25/// A closed enum, so adding a variant is a compile error at every match — which
26/// is the point. Upstream's `default:` arm returns a runtime error instead.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
28pub enum LeafType {
29    Str,
30    Int,
31    Float,
32    Bool,
33    /// Decrypt-only in practice. `Cipher.Encrypt` has no `[]byte` arm, so sops
34    /// itself never *writes* `type:bytes`; only Python-era files carry it. We
35    /// read it and never emit it, which is why there is no `Plaintext` variant
36    /// that renders back to it.
37    Bytes,
38    Time,
39    Comment,
40}
41
42impl LeafType {
43    /// The exact token that goes into `type:`.
44    #[must_use]
45    pub fn tag(self) -> &'static str {
46        match self {
47            Self::Str => "str",
48            Self::Int => "int",
49            Self::Float => "float",
50            Self::Bool => "bool",
51            Self::Bytes => "bytes",
52            Self::Time => "time",
53            Self::Comment => "comment",
54        }
55    }
56
57    fn parse(tag: &str) -> Result<Self, WireError> {
58        match tag {
59            "str" => Ok(Self::Str),
60            "int" => Ok(Self::Int),
61            "float" => Ok(Self::Float),
62            "bool" => Ok(Self::Bool),
63            "bytes" => Ok(Self::Bytes),
64            "time" => Ok(Self::Time),
65            "comment" => Ok(Self::Comment),
66            other => Err(WireError::UnknownDatatype(other.to_string())),
67        }
68    }
69}
70
71/// A leaf's plaintext bytes, together with the type they render as.
72///
73/// No `Display`, no `Deref<Target = str>`, no `AsRef<str>`, no `Into<String>`,
74/// and a `Debug` that prints a length rather than content. Reading the bytes
75/// takes [`Plaintext::expose`] — deliberately one greppable token, so a review
76/// searches for a presence instead of noticing an absence. Same discipline as
77/// `cofre_secret::Secret`, applied to a value that arrives from a *file* rather
78/// than from an operator.
79#[derive(Clone)]
80pub struct Plaintext {
81    bytes: Zeroizing<Vec<u8>>,
82    ty: LeafType,
83}
84
85impl Plaintext {
86    /// Wrap bytes that are already the canonical rendering for `ty`.
87    ///
88    /// Used on the decrypt path, where the bytes came out of GCM and the type
89    /// came off the wire.
90    #[must_use]
91    pub fn from_wire(bytes: Vec<u8>, ty: LeafType) -> Self {
92        Self {
93            bytes: Zeroizing::new(bytes),
94            ty,
95        }
96    }
97
98    /// A `type:str` leaf.
99    #[must_use]
100    pub fn string(s: impl Into<String>) -> Self {
101        Self {
102            bytes: Zeroizing::new(s.into().into_bytes()),
103            ty: LeafType::Str,
104        }
105    }
106
107    /// A `type:int` leaf. Rendered by Go's `strconv.Itoa`, which for the 64-bit
108    /// `int` in play is just decimal — matching Rust's `i64` `Display`.
109    #[must_use]
110    pub fn integer(v: i64) -> Self {
111        Self {
112            bytes: Zeroizing::new(v.to_string().into_bytes()),
113            ty: LeafType::Int,
114        }
115    }
116
117    /// A `type:float` leaf.
118    ///
119    /// Go uses `strconv.FormatFloat(v, 'f', -1, 64)`: shortest representation
120    /// that round-trips, **never** exponent notation, and — the comment in
121    /// `cipher.go` says so outright — no zero padding after the point, because
122    /// the Python implementation didn't pad. Rust's `{}` for `f64` is also
123    /// shortest-round-trip, but it *will* reach for exponent form on extreme
124    /// magnitudes, so those are rendered positionally by hand.
125    #[must_use]
126    pub fn float(v: f64) -> Self {
127        Self {
128            bytes: Zeroizing::new(format_go_float(v).into_bytes()),
129            ty: LeafType::Float,
130        }
131    }
132
133    /// A `type:bool` leaf. `True`/`False` — Python titlecase, as `cipher.go`
134    /// notes explicitly. Writing Rust's `true`/`false` here changes the MAC.
135    #[must_use]
136    pub fn boolean(v: bool) -> Self {
137        let s: &[u8] = if v { b"True" } else { b"False" };
138        Self {
139            bytes: Zeroizing::new(s.to_vec()),
140            ty: LeafType::Bool,
141        }
142    }
143
144    /// A `type:comment` leaf. The stored body excludes the leading `#`, which the
145    /// YAML store strips with `commentLine[1:]`.
146    #[must_use]
147    pub fn comment(body: impl Into<String>) -> Self {
148        Self {
149            bytes: Zeroizing::new(body.into().into_bytes()),
150            ty: LeafType::Comment,
151        }
152    }
153
154    /// The declared type.
155    #[must_use]
156    pub fn leaf_type(&self) -> LeafType {
157        self.ty
158    }
159
160    /// Length in bytes. Safe to log; it is what `Debug` prints.
161    #[must_use]
162    pub fn len(&self) -> usize {
163        self.bytes.len()
164    }
165
166    /// Whether the value is empty — which the format treats as a fixed point in
167    /// both directions (see [`EncryptedLeaf::render`]).
168    #[must_use]
169    pub fn is_empty(&self) -> bool {
170        self.bytes.is_empty()
171    }
172
173    /// The plaintext bytes. Named to be searched for.
174    #[must_use]
175    pub fn expose(&self) -> &[u8] {
176        &self.bytes
177    }
178
179    /// The bytes this leaf contributes to the MAC.
180    ///
181    /// `sops.ToBytes` over the *plaintext*, which for every type we can emit is
182    /// the same canonical rendering already held here — so this is the identity.
183    /// It exists as a named method anyway, because the MAC contribution and the
184    /// stored bytes are conceptually two different questions and a future type
185    /// could separate them.
186    #[must_use]
187    pub fn mac_bytes(&self) -> &[u8] {
188        &self.bytes
189    }
190
191    /// Check the bytes really are a valid rendering of the declared type.
192    ///
193    /// Not called on the hot decrypt path — sops does not validate either, and a
194    /// file it accepts must not be one we reject. Offered for `filestatus`-style
195    /// inspection and for tests.
196    pub fn validate(&self) -> Result<(), WireError> {
197        let s = || String::from_utf8_lossy(&self.bytes);
198        match self.ty {
199            LeafType::Str | LeafType::Bytes | LeafType::Comment => Ok(()),
200            LeafType::Int => s()
201                .parse::<i64>()
202                .map(|_| ())
203                .map_err(|_| WireError::DatatypeMismatch { ty: "int" }),
204            LeafType::Float => s()
205                .parse::<f64>()
206                .map(|_| ())
207                .map_err(|_| WireError::DatatypeMismatch { ty: "float" }),
208            LeafType::Bool => match self.bytes.as_slice() {
209                b"True" | b"False" => Ok(()),
210                _ => Err(WireError::DatatypeMismatch { ty: "bool" }),
211            },
212            LeafType::Time => Ok(()),
213        }
214    }
215}
216
217impl std::fmt::Debug for Plaintext {
218    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
219        write!(
220            f,
221            "Plaintext(*** {} bytes, {})",
222            self.bytes.len(),
223            self.ty.tag()
224        )
225    }
226}
227
228impl PartialEq for Plaintext {
229    /// Constant-time in the bytes.
230    ///
231    /// A plaintext comparison is a secret comparison — the obvious place it
232    /// happens is "did this value change?" during an edit, where a timing signal
233    /// leaks a prefix length. Length and type are not secret (the length ships in
234    /// the ciphertext), so short-circuiting on those leaks nothing and keeps the
235    /// byte compare well-defined.
236    fn eq(&self, other: &Self) -> bool {
237        self.ty == other.ty
238            && self.bytes.len() == other.bytes.len()
239            && bool::from(subtle::ConstantTimeEq::ct_eq(
240                self.bytes.as_slice(),
241                other.bytes.as_slice(),
242            ))
243    }
244}
245
246impl Eq for Plaintext {}
247
248/// Go's `strconv.FormatFloat(v, 'f', -1, 64)`.
249///
250/// `'f'` forbids exponent form at any magnitude, and `-1` asks for the shortest
251/// digit string that round-trips. Rust's `{}` gives the same shortest digits but
252/// switches to `1e300`-style output past a threshold, so the exponent case is
253/// expanded positionally from the shortest form rather than re-derived — which
254/// keeps the digits identical to Go's.
255fn format_go_float(v: f64) -> String {
256    let shortest = format!("{v}");
257    if !shortest.contains(['e', 'E']) {
258        return shortest;
259    }
260    let (mantissa, exp) = shortest
261        .split_once(['e', 'E'])
262        .unwrap_or((shortest.as_str(), "0"));
263    let exp: i32 = exp.parse().unwrap_or(0);
264    let (sign, mantissa) = match mantissa.strip_prefix('-') {
265        Some(rest) => ("-", rest),
266        None => ("", mantissa),
267    };
268    let (int_part, frac_part) = mantissa.split_once('.').unwrap_or((mantissa, ""));
269    let digits: String = format!("{int_part}{frac_part}");
270    // Where the point sits, counted from the left of `digits`.
271    let point = i32::try_from(int_part.len()).unwrap_or(0) + exp;
272    let out = if point <= 0 {
273        format!(
274            "0.{}{}",
275            "0".repeat(usize::try_from(-point).unwrap_or(0)),
276            digits
277        )
278    } else if usize::try_from(point).unwrap_or(0) >= digits.len() {
279        let pad = usize::try_from(point).unwrap_or(0) - digits.len();
280        format!("{digits}{}", "0".repeat(pad))
281    } else {
282        let at = usize::try_from(point).unwrap_or(0);
283        format!("{}.{}", &digits[..at], &digits[at..])
284    };
285    format!("{sign}{out}")
286}
287
288/// A parsed `ENC[…]` leaf: the three base64 fields plus the type tag.
289#[derive(Debug, Clone, PartialEq, Eq)]
290pub struct EncryptedLeaf {
291    pub(crate) data: Vec<u8>,
292    pub(crate) iv: Vec<u8>,
293    pub(crate) tag: Vec<u8>,
294    pub(crate) ty: LeafType,
295}
296
297impl EncryptedLeaf {
298    /// Whether a string even looks like an encrypted leaf.
299    ///
300    /// Cheap prefix test, used to decide whether a scalar is ciphertext or a
301    /// value that was left in the clear by a selector rule.
302    #[must_use]
303    pub fn looks_encrypted(s: &str) -> bool {
304        s.starts_with("ENC[AES256_GCM,data:")
305    }
306
307    /// Parse the wire rendering.
308    ///
309    /// Hand-split rather than regex-matched: the fields are positional and
310    /// delimiter-separated, and `.split_once` on each delimiter in order is both
311    /// faster and — more usefully — impossible to get subtly wrong the way a
312    /// greedy `(.+)` group can. Upstream's regex is unanchored at the end, so
313    /// trailing bytes are ignored here too.
314    pub fn parse(s: &str) -> Result<Self, WireError> {
315        let rest = s
316            .strip_prefix("ENC[AES256_GCM,data:")
317            .ok_or(WireError::NotAnEncryptedLeaf)?;
318        let (data, rest) = rest
319            .split_once(",iv:")
320            .ok_or(WireError::NotAnEncryptedLeaf)?;
321        let (iv, rest) = rest
322            .split_once(",tag:")
323            .ok_or(WireError::NotAnEncryptedLeaf)?;
324        let (tag, rest) = rest
325            .split_once(",type:")
326            .ok_or(WireError::NotAnEncryptedLeaf)?;
327        // Upstream's `^ENC\[…\]` has no `$`; everything past the bracket is
328        // ignored rather than rejected.
329        let ty = rest.split_once(']').map_or(rest, |(t, _)| t);
330        Ok(Self {
331            data: b64(data, "data")?,
332            iv: b64(iv, "iv")?,
333            tag: b64(tag, "tag")?,
334            ty: LeafType::parse(ty)?,
335        })
336    }
337
338    /// Render back to the wire.
339    #[must_use]
340    pub fn render(&self) -> String {
341        let e = base64::engine::general_purpose::STANDARD;
342        let mut out = String::with_capacity(
343            32 + (self.data.len() + self.iv.len() + self.tag.len()) * 4 / 3 + 8,
344        );
345        out.push_str("ENC[AES256_GCM,data:");
346        out.push_str(&e.encode(&self.data));
347        out.push_str(",iv:");
348        out.push_str(&e.encode(&self.iv));
349        out.push_str(",tag:");
350        out.push_str(&e.encode(&self.tag));
351        out.push_str(",type:");
352        out.push_str(self.ty.tag());
353        out.push(']');
354        out
355    }
356
357    /// The declared type.
358    #[must_use]
359    pub fn leaf_type(&self) -> LeafType {
360        self.ty
361    }
362
363    /// The nonce actually on the wire.
364    ///
365    /// Decryption honours this length rather than the 32-byte constant, exactly
366    /// as `cipher.go` does with `cipher.NewGCMWithNonceSize(…, len(iv))` — so a
367    /// file written by some other implementation still opens.
368    #[must_use]
369    pub fn iv_len(&self) -> usize {
370        self.iv.len()
371    }
372}
373
374fn b64(s: &str, field: &'static str) -> Result<Vec<u8>, WireError> {
375    base64::engine::general_purpose::STANDARD
376        .decode(s)
377        .map_err(|_| WireError::Base64 { field })
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383
384    /// A real leaf lifted from the operator's `nix/secrets.yaml`
385    /// (`akeyless_base_test.admin_access_id`) — already-encrypted bytes, so
386    /// nothing secret is committed here, and it is a genuine specimen rather
387    /// than a hand-built one.
388    const SPECIMEN: &str = "ENC[AES256_GCM,data:+s0vLJR7FqRk1dW3+LymL5aTHh4=,iv:irJYGNHV08Ey6RyO5YfqeaNCjLg8vWcdxoQvtnYCR40=,tag:Ax+kskUPjI/gXKq6WEPTxA==,type:str]";
389
390    #[test]
391    fn parses_a_real_specimen() {
392        let leaf = EncryptedLeaf::parse(SPECIMEN).expect("parse");
393        assert_eq!(leaf.leaf_type(), LeafType::Str);
394        assert_eq!(leaf.iv_len(), 32, "sops nonces are 32 bytes, not 12");
395        assert_eq!(leaf.tag.len(), 16);
396    }
397
398    #[test]
399    fn render_round_trips_byte_exactly() {
400        let leaf = EncryptedLeaf::parse(SPECIMEN).expect("parse");
401        assert_eq!(leaf.render(), SPECIMEN);
402    }
403
404    #[test]
405    fn trailing_bytes_after_the_bracket_are_ignored_like_upstream() {
406        let with_junk = format!("{SPECIMEN} and then some");
407        let a = EncryptedLeaf::parse(SPECIMEN).expect("parse");
408        let b = EncryptedLeaf::parse(&with_junk).expect("parse with junk");
409        assert_eq!(a, b);
410    }
411
412    #[test]
413    fn a_plain_value_is_not_mistaken_for_ciphertext() {
414        assert!(!EncryptedLeaf::looks_encrypted("hello"));
415        assert!(!EncryptedLeaf::looks_encrypted(
416            "ENC[SOMETHING_ELSE,data:x]"
417        ));
418        assert!(EncryptedLeaf::looks_encrypted(SPECIMEN));
419        assert_eq!(
420            EncryptedLeaf::parse("hello"),
421            Err(WireError::NotAnEncryptedLeaf)
422        );
423    }
424
425    #[test]
426    fn unknown_datatype_is_named_not_swallowed() {
427        let bad = SPECIMEN.replace("type:str", "type:quaternion");
428        assert_eq!(
429            EncryptedLeaf::parse(&bad),
430            Err(WireError::UnknownDatatype("quaternion".into()))
431        );
432    }
433
434    #[test]
435    fn bad_base64_names_its_field() {
436        let bad = SPECIMEN.replace("iv:irJY", "iv:!!!!");
437        assert_eq!(
438            EncryptedLeaf::parse(&bad),
439            Err(WireError::Base64 { field: "iv" })
440        );
441    }
442
443    #[test]
444    fn booleans_use_python_titlecase() {
445        assert_eq!(Plaintext::boolean(true).expose(), b"True");
446        assert_eq!(Plaintext::boolean(false).expose(), b"False");
447    }
448
449    #[test]
450    fn floats_match_go_formatfloat_f_minus_one() {
451        // shortest round-trip, no trailing zeros
452        assert_eq!(Plaintext::float(1.5).expose(), b"1.5");
453        assert_eq!(Plaintext::float(1.0).expose(), b"1");
454        assert_eq!(Plaintext::float(-0.25).expose(), b"-0.25");
455        // 'f' forbids exponent form at any magnitude
456        assert_eq!(Plaintext::float(1e21).expose(), b"1000000000000000000000");
457        assert_eq!(Plaintext::float(1e-7).expose(), b"0.0000001");
458        assert_eq!(Plaintext::float(-1.5e-7).expose(), b"-0.00000015");
459    }
460
461    #[test]
462    fn debug_never_shows_the_value() {
463        let p = Plaintext::string("hunter2");
464        let shown = format!("{p:?}");
465        assert!(
466            !shown.contains("hunter2"),
467            "Debug leaked the plaintext: {shown}"
468        );
469        assert_eq!(shown, "Plaintext(*** 7 bytes, str)");
470    }
471
472    #[test]
473    fn validate_catches_a_mislabelled_leaf() {
474        let lying = Plaintext::from_wire(b"not-a-number".to_vec(), LeafType::Int);
475        assert_eq!(
476            lying.validate(),
477            Err(WireError::DatatypeMismatch { ty: "int" })
478        );
479        // Rust's own bool spelling is exactly the thing that must be rejected.
480        let rusty = Plaintext::from_wire(b"true".to_vec(), LeafType::Bool);
481        assert_eq!(
482            rusty.validate(),
483            Err(WireError::DatatypeMismatch { ty: "bool" })
484        );
485        assert_eq!(Plaintext::boolean(true).validate(), Ok(()));
486    }
487}