suminuri_wire/lib.rs
1//! `suminuri-wire` — 墨塗り, "ink-blacked": the sops-compatible encrypted-file
2//! wire format as a **typed border**.
3//!
4//! A 墨塗り document is one whose *structure stays readable* while each *value*
5//! is blacked out in place. That is exactly what a sops file is, and it is the
6//! whole reason the format exists rather than encrypting the file as a blob:
7//! the keys, the shape and the diff survive.
8//!
9//! # What this crate is
10//!
11//! The pure half. It owns the bytes-on-disk contract and nothing else — no
12//! filesystem, no clock, no randomness beyond IV generation, no key providers,
13//! no CLI. Everything that touches the outside world lives behind the
14//! `Environment` seam in `suminuri` proper.
15//!
16//! Every claim encoded here was **measured** against the sops v3 Go source and
17//! then proven end-to-end against the operator's own live files: 272 leaves of
18//! `nix/secrets.yaml` decrypt with a byte-exact MAC, and the one file whose data
19//! key is not ours is *refused* rather than silently passed. The full spec, with
20//! the file:line citations behind each rule, is `docs/WIRE-FORMAT.md`.
21//!
22//! # The illegal states that have no code path here
23//!
24//! Named first, then removed — the ordering `UNREPRESENTABILITY.md` §IV asks for.
25//!
26//! 1. **A 12-byte nonce.** sops uses a **32-byte** GCM nonce ([`Iv::LEN`]).
27//! Every mainstream AES-GCM API defaults to 12, so the wrong choice compiles,
28//! runs, and produces a file nothing can open. Here [`Iv::generate`] is the
29//! only way to make an IV for encryption and it is `[u8; 32]` by type — there
30//! is no constructor that takes a length. *truly-unrep.*
31//! 2. **An AAD built by hand.** The additional authenticated data is the leaf's
32//! dotted path joined by `:` with a **trailing** `:`, and sequence indices are
33//! **excluded**. [`Aad`] has no `From<String>`; the only way to get one is
34//! [`AadPath::aad`], which always appends the colon, and [`AadPath`] has no
35//! method that takes an index. *truly-unrep (absent method).*
36//! 3. **Using a tree whose MAC was never checked.** Decryption yields
37//! [`Unverified<T>`], whose only safe exit is [`Unverified::verify`]. The
38//! `--ignore-mac` escape exists but is spelled
39//! [`Unverified::into_inner_ignoring_mac`] — one greppable token, never a
40//! default. *truly-unrep for the accidental case.*
41//! 4. **A MAC compared in non-constant time.** Upstream compares with Go's `!=`.
42//! [`Mac`]'s inner string is private and its [`PartialEq`] routes through
43//! `subtle::ConstantTimeEq`, so there is no other comparison to reach for.
44//! Same verdict, no timing signal. *truly-unrep.*
45//! 5. **Declared recipients that disagree with the wrapped keys.** This is not a
46//! hypothetical: `nix/.sops.yaml` carried a declared admin-recovery recipient
47//! for `users/gabi/secrets.yaml` for two weeks that was never in the
48//! ciphertext, because only a current recipient can re-wrap a data key.
49//! [`Metadata`]'s key arrays are **derived** from the [`WrappedKey`] set via
50//! [`Metadata::from_wrapped`] — they are not independently settable, so a
51//! declaration that outruns the ciphertext cannot be emitted. *truly-unrep.*
52//! 6. **A plaintext in a `String`.** Leaf plaintext is [`Plaintext`], which
53//! holds `Zeroizing<Vec<u8>>`, has no `Display`, no `Deref<str>`, and prints
54//! `Plaintext(*** N bytes)` under `Debug`. Reading it takes the greppable
55//! [`Plaintext::expose`]. *parse-time-rejected — an author can still call
56//! `expose()`; the ceiling is that Rust cannot forbid a named call (C1).*
57//!
58//! # What is deliberately reproduced rather than fixed
59//!
60//! Two upstream quirks are bugs we must speak anyway, because the wire is the
61//! wire (the magma posture: speak the wire, own the executor).
62//!
63//! - **AAD path collision.** Keys are joined unescaped, so `{"a:b": {"c": v}}`
64//! and `{"a": {"b:c": v}}` produce the same AAD. Reproduced; flagged by
65//! [`AadPath::has_ambiguous_component`] so a caller *can* refuse.
66//! - **IV reuse by design.** [`IvStash`] re-uses the IV recorded for a
67//! `(plaintext, aad)` pair so an unchanged value re-encrypts to identical
68//! bytes — which is what keeps `edit` diffs small. Without it every edit
69//! rewrites every line.
70
71#![forbid(unsafe_code)]
72
73mod aad;
74mod cipher;
75mod leaf;
76mod mac;
77mod metadata;
78mod selector;
79mod verified;
80
81pub use aad::{Aad, AadPath};
82pub use cipher::{DataKey, Iv, IvStash, decrypt_leaf, encrypt_leaf};
83pub use leaf::{EncryptedLeaf, LeafType, Plaintext, format_go_float_f};
84pub use mac::{
85 MAC_ONLY_ENCRYPTED_SEED, Mac, MacAccumulator, mac_field_aad, seal_mac_field, verify_mac_field,
86};
87pub use metadata::{AgeKey, KeyProvider, Metadata, WrappedKey};
88pub use selector::{DEFAULT_UNENCRYPTED_SUFFIX, EncryptionSelector, Selection, regex_is_match};
89pub use verified::Unverified;
90
91/// Everything that can go wrong inside the wire border.
92///
93/// Note what is *absent*: there is no `Other(String)` arm and no
94/// `#[from] anyhow::Error`. A new failure mode has to be named here, which is
95/// what keeps a caller's `match` honest.
96#[derive(Debug, thiserror::Error, PartialEq, Eq)]
97pub enum WireError {
98 /// The value is not in `ENC[AES256_GCM,…]` form at all.
99 #[error("value is not a suminuri/sops encrypted leaf")]
100 NotAnEncryptedLeaf,
101
102 /// One of the three base64 fields did not decode.
103 #[error("base64 field `{field}` did not decode")]
104 Base64 { field: &'static str },
105
106 /// The `type:` tag is not one sops can produce or consume.
107 #[error("unknown leaf datatype `{0}`")]
108 UnknownDatatype(String),
109
110 /// AES-GCM refused to open the leaf. Deliberately carries no detail: the
111 /// distinction between "wrong key" and "tampered bytes" is exactly the
112 /// oracle an attacker wants.
113 #[error("could not open leaf with AES-256-GCM")]
114 AeadOpen,
115
116 /// The recovered bytes are not a valid rendering of the declared type.
117 #[error("leaf declared type `{ty}` but its plaintext does not parse as one")]
118 DatatypeMismatch { ty: &'static str },
119
120 /// The data key is not 32 bytes.
121 #[error("data key must be 32 bytes, got {0}")]
122 DataKeyLength(usize),
123
124 /// The MAC recorded in the file does not match the recomputed one.
125 #[error("MAC mismatch — the file's contents do not match its recorded MAC")]
126 MacMismatch,
127
128 /// The MAC field itself would not decrypt, which usually means the data key
129 /// is wrong or `lastmodified` was edited by hand.
130 #[error("could not decrypt the MAC field (wrong data key, or lastmodified was edited)")]
131 MacUndecryptable,
132
133 /// A mapping key was not a string. sops cannot represent one.
134 #[error("mapping key is not a string; suminuri and sops both require string keys")]
135 NonStringKey,
136
137 /// A selector regex from the metadata did not compile.
138 #[error("selector regex `{pattern}` is not valid: {reason}")]
139 BadSelectorRegex { pattern: String, reason: String },
140
141 /// An encrypted comment would match `unencrypted_comment_regex`, which would
142 /// make the file permanently undecryptable. Upstream refuses too.
143 #[error(
144 "an encrypted comment matches unencrypted_comment_regex; the file would never decrypt again"
145 )]
146 SelfDefeatingCommentRegex,
147
148 /// No randomness available for an IV.
149 #[error("could not draw randomness for an IV: {0}")]
150 Randomness(String),
151}
152
153/// The sops format version this crate writes into `sops.version`.
154///
155/// We claim the format version, not our own release version, because the field
156/// is read by real sops to decide how to parse. Measured against the binary in
157/// the operator's profile.
158pub const FORMAT_VERSION: &str = "3.12.1";