Skip to main content

open_envault/crypto/
store.rs

1//! SOPS-compatible dotenv store.
2//!
3//! Mirrors `github.com/getsops/sops/v3/stores/dotenv` with the
4//! `MetadataFlattenFull` layout used by SOPS >= 3.9 for age-only files:
5//! encrypted `KEY=ENC[...]` lines followed by flattened metadata lines
6//! (`sops_age__list_0__map_enc`, `sops_mac`, ...). Files are interchangeable
7//! with official `sops` output.
8
9use anyhow::{Context, Result, bail};
10use sha2::{Digest, Sha512};
11use std::collections::BTreeMap;
12use zeroize::Zeroize;
13
14use super::{
15    keys::{self, Identity, Recipient},
16    util, value,
17};
18
19/// SOPS version string written into the metadata (`sops_version`).
20///
21/// This pins the metadata layout (flattened `sops_` keys, timestamped MAC AAD)
22/// that this crate implements; files remain readable by that sops version.
23pub const SOPS_VERSION: &str = "3.13.3";
24
25/// Default unencrypted suffix written into the metadata.
26pub const UNENCRYPTED_SUFFIX: &str = "_unencrypted";
27
28const METADATA_PREFIX: &str = "sops_";
29/// AAD for top-level comment leaves: their tree path is empty, so the path
30/// string is `":"`.
31const COMMENT_AAD: &[u8] = b":";
32
33/// Parsed metadata from an encrypted file.
34#[derive(Debug, Clone)]
35pub struct Metadata {
36    pub version: Option<String>,
37    pub lastmodified: Option<String>,
38    pub unencrypted_suffix: Option<String>,
39    /// `(recipient, armored encrypted data key)` entries.
40    pub age: Vec<(String, String)>,
41    pub mac: Option<String>,
42}
43
44impl Metadata {
45    fn has_prefix(key: &str) -> bool {
46        key.starts_with(METADATA_PREFIX)
47    }
48}
49
50/// One logical line of a dotenv document.
51#[derive(Debug, Clone, PartialEq, Eq)]
52enum Entry {
53    Comment(String),
54    Var { key: String, value: String },
55}
56
57/// A raw `sops_*` metadata leaf: `(flattened key, value)`.
58type MetadataLeaf = (String, String);
59
60fn unescape(value: &str) -> String {
61    value.replace("\\n", "\n")
62}
63
64fn escape(value: &str) -> String {
65    value.replace('\n', "\\n")
66}
67
68/// Parse a dotenv document into ordered entries plus any `sops_` metadata
69/// leaves, in order.
70fn parse_document(text: &str) -> Result<(Vec<Entry>, Vec<MetadataLeaf>)> {
71    let mut entries = Vec::new();
72    let mut metadata = Vec::new();
73    for line in text.split('\n') {
74        if line.is_empty() {
75            continue;
76        }
77        if let Some(content) = line.strip_prefix('#') {
78            entries.push(Entry::Comment(content.to_string()));
79            continue;
80        }
81        let Some(pos) = line.find('=') else {
82            bail!("invalid dotenv line (missing '='): {line}");
83        };
84        let key = line[..pos].to_string();
85        let entry = Entry::Var {
86            value: unescape(&line[pos + 1..]),
87            key: key.clone(),
88        };
89        if Metadata::has_prefix(&key) {
90            let value = match &entry {
91                Entry::Var { value, .. } => value.clone(),
92                Entry::Comment(_) => unreachable!(),
93            };
94            metadata.push((key, value));
95        } else {
96            entries.push(entry);
97        }
98    }
99    Ok((entries, metadata))
100}
101
102/// Assemble the flattened metadata lines in canonical SOPS order.
103fn flatten_metadata(metadata: &Metadata) -> Vec<(String, String)> {
104    let mut out = Vec::new();
105    for (index, (recipient, enc)) in metadata.age.iter().enumerate() {
106        out.push((format!("sops_age__list_{index}__map_enc"), enc.clone()));
107        out.push((
108            format!("sops_age__list_{index}__map_recipient"),
109            recipient.clone(),
110        ));
111    }
112    if let Some(v) = &metadata.lastmodified {
113        out.push(("sops_lastmodified".into(), v.clone()));
114    }
115    if let Some(v) = &metadata.mac {
116        out.push(("sops_mac".into(), v.clone()));
117    }
118    if let Some(v) = &metadata.unencrypted_suffix {
119        out.push(("sops_unencrypted_suffix".into(), v.clone()));
120    }
121    if let Some(v) = &metadata.version {
122        out.push(("sops_version".into(), v.clone()));
123    }
124    out
125}
126
127fn parse_metadata(leaves: &[(String, String)]) -> Result<Metadata> {
128    let mut seen = BTreeMap::new();
129    let mut age = BTreeMap::new();
130    for (key, value) in leaves {
131        if let Some(rest) = key.strip_prefix(METADATA_PREFIX) {
132            if let Some((index, field)) = rest
133                .strip_prefix("age__list_")
134                .and_then(|r| r.split_once("__map_"))
135                .and_then(|(idx, field)| idx.parse::<usize>().ok().map(|i| (i, field)))
136            {
137                if seen.insert(key.clone(), ()).is_some() {
138                    bail!("duplicate metadata key {key}");
139                }
140                let item = age.entry(index).or_insert_with(|| (None, None));
141                match field {
142                    "recipient" => item.0 = Some(value.clone()),
143                    "enc" => item.1 = Some(value.clone()),
144                    other => bail!("unsupported age metadata field: {other}"),
145                }
146            } else {
147                if seen.insert(key.clone(), ()).is_some() {
148                    bail!("duplicate metadata key {key}");
149                }
150            }
151        }
152    }
153    let mut metadata = Metadata {
154        version: None,
155        lastmodified: None,
156        unencrypted_suffix: None,
157        age: Vec::new(),
158        mac: None,
159    };
160    for (key, value) in leaves {
161        let Some(rest) = key.strip_prefix(METADATA_PREFIX) else {
162            continue;
163        };
164        match rest {
165            "version" => metadata.version = Some(value.clone()),
166            "lastmodified" => metadata.lastmodified = Some(value.clone()),
167            "unencrypted_suffix" => metadata.unencrypted_suffix = Some(value.clone()),
168            "mac" => metadata.mac = Some(value.clone()),
169            _ => {}
170        }
171    }
172    for (index, (recipient, enc)) in age {
173        let (Some(_recipient), Some(_enc)) = (&recipient, &enc) else {
174            bail!("incomplete age metadata entry at index {index}");
175        };
176        metadata.age.push((recipient.unwrap(), enc.unwrap()));
177    }
178    Ok(metadata)
179}
180
181/// Compute the SHA-512 MAC over plaintext leaf values in document order.
182fn compute_mac<I>(values: I) -> String
183where
184    I: IntoIterator<Item = Vec<u8>>,
185{
186    let mut hasher = Sha512::new();
187    for value in values {
188        hasher.update(&value);
189    }
190    hex::encode_upper(hasher.finalize())
191}
192
193/// Encrypt a plaintext dotenv document for `recipients`.
194///
195/// Returns the SOPS-encrypted dotenv text. Fails closed on a reserved `sops_`
196/// key or an empty recipient list.
197pub fn encrypt(plaintext: &str, recipients: &[Recipient]) -> Result<String> {
198    if recipients.is_empty() {
199        bail!("refusing to encrypt: no age recipients configured");
200    }
201    let (entries, metadata) = parse_document(plaintext)?;
202    if !metadata.is_empty() {
203        bail!(
204            "refusing to encrypt: the input contains {METADATA_PREFIX}* keys reserved for SOPS metadata"
205        );
206    }
207
208    let mut data_key = [0u8; 32];
209    let mut rng = rand::rngs::SysRng;
210    rand::TryRng::try_fill_bytes(&mut rng, &mut data_key)
211        .map_err(|_| anyhow::anyhow!("system RNG unavailable"))?;
212
213    let mut mac_values = Vec::new();
214    let mut lines = Vec::new();
215    for entry in &entries {
216        match entry {
217            Entry::Comment(content) => {
218                let encrypted =
219                    value::encrypt_kind(content.as_bytes(), &data_key, COMMENT_AAD, "comment")
220                        .context("encrypt comment")?;
221                lines.push(format!("#{}", escape(&encrypted)));
222            }
223            Entry::Var { key, value } => {
224                let unencrypted = key.ends_with(UNENCRYPTED_SUFFIX);
225                mac_values.push(value.clone().into_bytes());
226                if unencrypted {
227                    lines.push(format!("{key}={}", escape(value)));
228                } else {
229                    let aad = format!("{key}:");
230                    let encrypted = value::encrypt(value.as_bytes(), &data_key, aad.as_bytes())
231                        .with_context(|| format!("encrypt value {key}"))?;
232                    lines.push(format!("{key}={}", escape(&encrypted)));
233                }
234            }
235        }
236    }
237    let mac = compute_mac(mac_values);
238    let lastmodified = util::rfc3339_now();
239    let mac_enc = value::encrypt(mac.as_bytes(), &data_key, lastmodified.as_bytes())
240        .context("encrypt MAC")?;
241
242    let mut age = Vec::new();
243    for recipient in recipients {
244        let enc = keys::wrap_data_key(&data_key, recipient)
245            .with_context(|| format!("wrap data key for {recipient}"))?;
246        age.push((recipient.to_string(), enc));
247    }
248    data_key.zeroize();
249
250    let metadata = Metadata {
251        version: Some(SOPS_VERSION.into()),
252        lastmodified: Some(lastmodified),
253        unencrypted_suffix: Some(UNENCRYPTED_SUFFIX.into()),
254        age,
255        mac: Some(mac_enc),
256    };
257    for (key, value) in flatten_metadata(&metadata) {
258        lines.push(format!("{key}={}", escape(&value)));
259    }
260    Ok(format!("{}\n", lines.join("\n")))
261}
262
263/// Decrypt a SOPS dotenv document with the provided identities.
264///
265/// Fails closed when no identity can unwrap the data key or when the MAC does
266/// not match. Returns the plaintext dotenv text.
267pub fn decrypt(encrypted: &str, identities: &[Identity]) -> Result<String> {
268    let (entries, metadata_leaves) = parse_document(encrypted)?;
269    let metadata = parse_metadata(&metadata_leaves)?;
270    let lastmodified = metadata
271        .lastmodified
272        .as_deref()
273        .context("missing sops_lastmodified metadata")?;
274    let mac_enc = metadata
275        .mac
276        .as_deref()
277        .context("missing sops_mac metadata")?;
278
279    let enc_values: Vec<String> = metadata.age.iter().map(|(_, enc)| enc.clone()).collect();
280    let mut data_key = keys::unwrap_data_key(&enc_values, identities)?;
281
282    let suffix = metadata.unencrypted_suffix.as_deref().unwrap_or("");
283    let mut mac_values = Vec::new();
284    let mut lines = Vec::new();
285    for entry in &entries {
286        match entry {
287            Entry::Comment(content) => {
288                let plain = if content.starts_with("ENC[AES256_GCM,") {
289                    match value::decrypt(content, &data_key, COMMENT_AAD) {
290                        Ok(plain) => plain,
291                        // SOPS tolerates plaintext comments that happen to look
292                        // like ENC values; keep them verbatim.
293                        Err(_) => content.clone(),
294                    }
295                } else {
296                    content.clone()
297                };
298                lines.push(format!("#{}", escape(&plain)));
299            }
300            Entry::Var { key, value } => {
301                let plain = if !suffix.is_empty() && key.ends_with(suffix) {
302                    value.clone()
303                } else {
304                    let aad = format!("{key}:");
305                    value::decrypt(value, &data_key, aad.as_bytes())
306                        .with_context(|| format!("decrypt value {key}"))?
307                };
308                mac_values.push(plain.clone().into_bytes());
309                lines.push(format!("{key}={}", escape(&plain)));
310            }
311        }
312    }
313    let computed = compute_mac(mac_values);
314    let stored = value::decrypt(mac_enc, &data_key, lastmodified.as_bytes())
315        .context("cannot decrypt MAC (wrong key?)")?;
316    data_key.zeroize();
317
318    if computed != stored {
319        bail!("MAC mismatch: file is corrupted or was modified");
320    }
321    Ok(format!("{}\n", lines.join("\n")))
322}
323
324/// List the public recipients recorded in an encrypted document (no key needed).
325pub fn list_recipients(encrypted: &str) -> Result<Vec<String>> {
326    let (_, metadata_leaves) = parse_document(encrypted)?;
327    let metadata = parse_metadata(&metadata_leaves)?;
328    let recipients: Vec<String> = metadata.age.iter().map(|(r, _)| r.clone()).collect();
329    if recipients.is_empty() {
330        bail!("no age recipients found in metadata");
331    }
332    Ok(recipients)
333}
334
335/// Parse recipient strings.
336pub fn recipients_from_strings(strings: &[String]) -> Result<Vec<Recipient>> {
337    strings.iter().map(|s| keys::parse_recipient(s)).collect()
338}