1use 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
19pub const SOPS_VERSION: &str = "3.13.3";
24
25pub const UNENCRYPTED_SUFFIX: &str = "_unencrypted";
27
28const METADATA_PREFIX: &str = "sops_";
29const COMMENT_AAD: &[u8] = b":";
32
33#[derive(Debug, Clone)]
35pub struct Metadata {
36 pub version: Option<String>,
37 pub lastmodified: Option<String>,
38 pub unencrypted_suffix: Option<String>,
39 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#[derive(Debug, Clone, PartialEq, Eq)]
52enum Entry {
53 Comment(String),
54 Var { key: String, value: String },
55}
56
57type 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
68fn 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
102fn 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
181fn 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
193pub 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
261pub 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 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
322pub 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
333pub fn recipients_from_strings(strings: &[String]) -> Result<Vec<Recipient>> {
335 strings.iter().map(|s| keys::parse_recipient(s)).collect()
336}