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    rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut data_key);
210
211    let mut mac_values = Vec::new();
212    let mut lines = Vec::new();
213    for entry in &entries {
214        match entry {
215            Entry::Comment(content) => {
216                let encrypted =
217                    value::encrypt_kind(content.as_bytes(), &data_key, COMMENT_AAD, "comment")
218                        .context("encrypt comment")?;
219                lines.push(format!("#{}", escape(&encrypted)));
220            }
221            Entry::Var { key, value } => {
222                let unencrypted = key.ends_with(UNENCRYPTED_SUFFIX);
223                mac_values.push(value.clone().into_bytes());
224                if unencrypted {
225                    lines.push(format!("{key}={}", escape(value)));
226                } else {
227                    let aad = format!("{key}:");
228                    let encrypted = value::encrypt(value.as_bytes(), &data_key, aad.as_bytes())
229                        .with_context(|| format!("encrypt value {key}"))?;
230                    lines.push(format!("{key}={}", escape(&encrypted)));
231                }
232            }
233        }
234    }
235    let mac = compute_mac(mac_values);
236    let lastmodified = util::rfc3339_now();
237    let mac_enc = value::encrypt(mac.as_bytes(), &data_key, lastmodified.as_bytes())
238        .context("encrypt MAC")?;
239
240    let mut age = Vec::new();
241    for recipient in recipients {
242        let enc = keys::wrap_data_key(&data_key, recipient)
243            .with_context(|| format!("wrap data key for {recipient}"))?;
244        age.push((recipient.to_string(), enc));
245    }
246    data_key.zeroize();
247
248    let metadata = Metadata {
249        version: Some(SOPS_VERSION.into()),
250        lastmodified: Some(lastmodified),
251        unencrypted_suffix: Some(UNENCRYPTED_SUFFIX.into()),
252        age,
253        mac: Some(mac_enc),
254    };
255    for (key, value) in flatten_metadata(&metadata) {
256        lines.push(format!("{key}={}", escape(&value)));
257    }
258    Ok(format!("{}\n", lines.join("\n")))
259}
260
261/// Decrypt a SOPS dotenv document with the provided identities.
262///
263/// Fails closed when no identity can unwrap the data key or when the MAC does
264/// not match. Returns the plaintext dotenv text.
265pub fn decrypt(encrypted: &str, identities: &[Identity]) -> Result<String> {
266    let (entries, metadata_leaves) = parse_document(encrypted)?;
267    let metadata = parse_metadata(&metadata_leaves)?;
268    let lastmodified = metadata
269        .lastmodified
270        .as_deref()
271        .context("missing sops_lastmodified metadata")?;
272    let mac_enc = metadata
273        .mac
274        .as_deref()
275        .context("missing sops_mac metadata")?;
276
277    let enc_values: Vec<String> = metadata.age.iter().map(|(_, enc)| enc.clone()).collect();
278    let mut data_key = keys::unwrap_data_key(&enc_values, identities)?;
279
280    let suffix = metadata.unencrypted_suffix.as_deref().unwrap_or("");
281    let mut mac_values = Vec::new();
282    let mut lines = Vec::new();
283    for entry in &entries {
284        match entry {
285            Entry::Comment(content) => {
286                let plain = if content.starts_with("ENC[AES256_GCM,") {
287                    match value::decrypt(content, &data_key, COMMENT_AAD) {
288                        Ok(plain) => plain,
289                        // SOPS tolerates plaintext comments that happen to look
290                        // like ENC values; keep them verbatim.
291                        Err(_) => content.clone(),
292                    }
293                } else {
294                    content.clone()
295                };
296                lines.push(format!("#{}", escape(&plain)));
297            }
298            Entry::Var { key, value } => {
299                let plain = if !suffix.is_empty() && key.ends_with(suffix) {
300                    value.clone()
301                } else {
302                    let aad = format!("{key}:");
303                    value::decrypt(value, &data_key, aad.as_bytes())
304                        .with_context(|| format!("decrypt value {key}"))?
305                };
306                mac_values.push(plain.clone().into_bytes());
307                lines.push(format!("{key}={}", escape(&plain)));
308            }
309        }
310    }
311    let computed = compute_mac(mac_values);
312    let stored = value::decrypt(mac_enc, &data_key, lastmodified.as_bytes())
313        .context("cannot decrypt MAC (wrong key?)")?;
314    data_key.zeroize();
315
316    if computed != stored {
317        bail!("MAC mismatch: file is corrupted or was modified");
318    }
319    Ok(format!("{}\n", lines.join("\n")))
320}
321
322/// List the public recipients recorded in an encrypted document (no key needed).
323pub fn list_recipients(encrypted: &str) -> Result<Vec<String>> {
324    let (_, metadata_leaves) = parse_document(encrypted)?;
325    let metadata = parse_metadata(&metadata_leaves)?;
326    let recipients: Vec<String> = metadata.age.iter().map(|(r, _)| r.clone()).collect();
327    if recipients.is_empty() {
328        bail!("no age recipients found in metadata");
329    }
330    Ok(recipients)
331}
332
333/// Parse recipient strings.
334pub fn recipients_from_strings(strings: &[String]) -> Result<Vec<Recipient>> {
335    strings.iter().map(|s| keys::parse_recipient(s)).collect()
336}