signet_client/sops_encrypt.rs
1//! Produces SOPS-compatible encrypted secret values.
2//!
3//! Mirrors `go/sops_encrypt.go`. See that file's doc comment for the full
4//! rationale; the short version: `SyncBundle`/`TriggerSync` never encrypt on
5//! signetd's behalf (signet's documented trust model is "only SOPS
6//! ciphertext leaves the operator's machine" — see `design/draft.md` in
7//! bytepunx/signet), so a plaintext value submitted via `SyncBundle` fails
8//! to decrypt and is silently never stored. [`encrypt_for_secret`] is the
9//! correct way to produce a value `SyncBundle` will actually persist. See
10//! bytepunx/signet-clients#49.
11//!
12//! Implementation note: this hand-implements the narrow slice of the SOPS
13//! file format needed for a single-leaf, single-age-recipient document —
14//! AES-256-GCM value/MAC encryption and a SHA-512 MAC — directly on the
15//! [`age`] crate (the canonical Rust age implementation) rather than
16//! depending on a SOPS-format crate. The only one that exists, `rops`, is
17//! pre-1.0 with open issues specifically about MAC/AAD compatibility — the
18//! exact area this can't get wrong. Every other bytepunx/signet-clients
19//! language hand-rolls this same logic against its own age library, for the
20//! same reason (no ecosystem has a trustworthy SOPS-format library). The
21//! algorithm below is verified line-for-line against
22//! `github.com/getsops/sops/v3`'s own `sops.go` (`Tree.Encrypt`) and
23//! `aes/cipher.go` (`Cipher.Encrypt`), and this module's tests confirm the
24//! real `sops` binary can decrypt the result.
25
26use aes_gcm::aead::consts::U32;
27use aes_gcm::aead::generic_array::GenericArray;
28use aes_gcm::aead::rand_core::RngCore;
29use aes_gcm::aead::{Aead, KeyInit, OsRng, Payload};
30use aes_gcm::aes::Aes256;
31use aes_gcm::AesGcm;
32use base64::engine::general_purpose::STANDARD as BASE64;
33use base64::Engine as _;
34use serde::Serialize;
35use sha2::{Digest, Sha512};
36use std::time::SystemTime;
37
38/// AES-256-GCM instantiated with sops's own non-standard 32-byte nonce (see
39/// `github.com/getsops/sops/v3/aes.Cipher`'s `nonceSize` constant) instead
40/// of the usual 12-byte GCM nonce.
41type SopsAesGcm = AesGcm<Aes256, U32>;
42
43/// The standard AES-GCM authentication tag size. A standard AEAD seal
44/// appends this to the ciphertext; sops splits its output on this boundary
45/// into "data" and "tag" wire-format fields.
46const GCM_TAG_SIZE: usize = 16;
47
48/// Recorded in the file's `sops.version` metadata field. The real `sops`
49/// CLI only requires this to be non-empty to decrypt (it doesn't gate
50/// decryption on matching its own build version).
51const SOPS_FILE_VERSION: &str = "3.13.1";
52
53/// Errors returned by [`encrypt_for_secret`].
54#[derive(Debug, thiserror::Error)]
55pub enum SopsEncryptError {
56 /// `encrypt_for_secret` was called with an empty public key.
57 #[error("signet: public key must not be empty")]
58 EmptyPublicKey,
59
60 /// The supplied public key is not a parseable age (X25519) recipient.
61 #[error("signet: parse age recipient: {0}")]
62 ParseRecipient(String),
63
64 /// Encrypting the `value` leaf failed.
65 #[error("signet: encrypt value: {0}")]
66 EncryptValue(String),
67
68 /// Encrypting the MAC failed.
69 #[error("signet: encrypt mac: {0}")]
70 EncryptMac(String),
71
72 /// Wrapping the data key to the age recipient failed.
73 #[error("signet: wrap data key: {0}")]
74 WrapDataKey(String),
75
76 /// Serializing the SOPS document to YAML failed.
77 #[error("signet: marshal sops document: {0}")]
78 Marshal(String),
79}
80
81#[derive(Serialize)]
82struct SopsAgeKey {
83 recipient: String,
84 enc: String,
85}
86
87#[derive(Serialize)]
88struct SopsMetadata {
89 age: Vec<SopsAgeKey>,
90 lastmodified: String,
91 mac: String,
92 version: String,
93}
94
95#[derive(Serialize)]
96struct SopsDocument {
97 value: String,
98 sops: SopsMetadata,
99}
100
101/// Produces SOPS-compatible encrypted YAML content for `value`, encrypted
102/// to `public_key` (an age recipient string, e.g. as returned by
103/// `GitOpsService`'s `GetSOPSPublicKey` RPC). The result is exactly the
104/// shape signetd's gitops sync path (`TriggerSync`, `SyncBundle`) and the
105/// `signet secret set` CLI both expect: a YAML document with a top-level
106/// `value` key, SOPS-encrypted to a single age recipient.
107pub fn encrypt_for_secret(public_key: &str, value: &[u8]) -> Result<Vec<u8>, SopsEncryptError> {
108 if public_key.is_empty() {
109 return Err(SopsEncryptError::EmptyPublicKey);
110 }
111 let recipient: age::x25519::Recipient = public_key
112 .parse()
113 .map_err(|e: &str| SopsEncryptError::ParseRecipient(e.to_string()))?;
114
115 let mut data_key = [0u8; 32];
116 OsRng.fill_bytes(&mut data_key);
117
118 // MAC: SHA-512 over the plaintext bytes of every leaf value, in tree
119 // order (github.com/getsops/sops/v3's Tree.Encrypt + ToBytes). For our
120 // fixed {value: ...} shape that's the single "value" leaf, and a plain
121 // string's ToBytes is just its raw UTF-8 bytes.
122 let digest = Sha512::digest(value);
123 let mac_hex = hex_upper(&digest);
124
125 let enc_value =
126 sops_encrypt_leaf(&data_key, value, b"value:").map_err(SopsEncryptError::EncryptValue)?;
127
128 let last_modified = rfc3339_now_utc();
129 let enc_mac = sops_encrypt_leaf(&data_key, mac_hex.as_bytes(), last_modified.as_bytes())
130 .map_err(SopsEncryptError::EncryptMac)?;
131
132 let enc_data_key = age::encrypt_and_armor(&recipient, &data_key)
133 .map_err(|e| SopsEncryptError::WrapDataKey(e.to_string()))?;
134
135 let doc = SopsDocument {
136 value: enc_value,
137 sops: SopsMetadata {
138 age: vec![SopsAgeKey {
139 recipient: public_key.to_string(),
140 enc: enc_data_key,
141 }],
142 lastmodified: last_modified.clone(),
143 mac: enc_mac,
144 version: SOPS_FILE_VERSION.to_string(),
145 },
146 };
147
148 let yaml =
149 serde_yaml_ng::to_string(&doc).map_err(|e| SopsEncryptError::Marshal(e.to_string()))?;
150
151 // serde_yaml_ng infers plain (unquoted) scalar style purely from
152 // whether its own bool/int/float/null resolver would misparse the
153 // string on the way back in — it has no notion of YAML's timestamp
154 // resolution, so an RFC3339 `lastmodified` value comes out unquoted.
155 // sops's own YAML library (gopkg.in/yaml.v3) *does* implicitly resolve
156 // an unquoted scalar matching that shape as `time.Time`, which then
157 // fails to unmarshal into sops's string-typed `Metadata.LastModified`
158 // field ("expected type 'string', got unconvertible type 'time.Time'").
159 // Force it into an explicit double-quoted scalar so every YAML
160 // implementation treats it as a plain string; `version` is quoted the
161 // same way purely to match the real sops CLI's own output shape (it
162 // isn't at risk of misresolution, since "3.13.1" isn't a valid
163 // int/float/bool/timestamp).
164 let yaml = force_quote_scalar(&yaml, "lastmodified", &last_modified);
165 let yaml = force_quote_scalar(&yaml, "version", SOPS_FILE_VERSION);
166
167 Ok(yaml.into_bytes())
168}
169
170/// Rewrites the single occurrence of an unquoted `"{key}: {raw_value}"`
171/// scalar (as produced by serde_yaml_ng for a plain `String` field) into an
172/// explicitly double-quoted one. `raw_value` is always a value this module
173/// generated itself (an RFC3339 timestamp or the fixed version string), so
174/// it's known not to contain characters needing escaping, and known not to
175/// collide with any other line in the document (every other value is
176/// base64, bech32, or an `ENC[...]` wrapper, none of which can produce the
177/// exact substring `"{key}: {raw_value}"`).
178fn force_quote_scalar(yaml: &str, key: &str, raw_value: &str) -> String {
179 let unquoted = format!("{key}: {raw_value}");
180 let quoted = format!("{key}: \"{raw_value}\"");
181 yaml.replacen(&unquoted, "ed, 1)
182}
183
184/// Encrypts `plaintext` with AES-256-GCM under `data_key` and renders it in
185/// sops's own wire format, matching
186/// `github.com/getsops/sops/v3/aes.Cipher.Encrypt`: a 32-byte random nonce
187/// (not the usual 12 bytes), `additional_data` as GCM's AAD, and the
188/// ciphertext split into "data" (everything but the last 16 bytes) and
189/// "tag" (the last 16 bytes) — a standard AEAD seal appends the tag to the
190/// ciphertext, sops stores them as separate base64 fields.
191fn sops_encrypt_leaf(
192 data_key: &[u8; 32],
193 plaintext: &[u8],
194 additional_data: &[u8],
195) -> Result<String, String> {
196 let cipher = SopsAesGcm::new(GenericArray::from_slice(data_key));
197
198 let mut nonce = [0u8; 32];
199 OsRng.fill_bytes(&mut nonce);
200
201 let sealed = cipher
202 .encrypt(
203 GenericArray::from_slice(&nonce),
204 Payload {
205 msg: plaintext,
206 aad: additional_data,
207 },
208 )
209 .map_err(|e| e.to_string())?;
210 let (data, tag) = sealed.split_at(sealed.len() - GCM_TAG_SIZE);
211
212 Ok(format!(
213 "ENC[AES256_GCM,data:{},iv:{},tag:{},type:str]",
214 BASE64.encode(data),
215 BASE64.encode(nonce),
216 BASE64.encode(tag),
217 ))
218}
219
220/// Formats a SHA-512 digest as uppercase hex, matching Go's `fmt.Sprintf("%X", sum)`.
221fn hex_upper(digest: &[u8]) -> String {
222 let mut out = String::with_capacity(digest.len() * 2);
223 for byte in digest {
224 out.push_str(&format!("{byte:02X}"));
225 }
226 out
227}
228
229/// Formats the current UTC time as RFC3339 with a `Z` suffix (e.g.
230/// `2026-08-15T12:00:00Z`), matching Go's `time.Now().UTC().Format(time.RFC3339)`.
231///
232/// Hand-rolled on `std::time::SystemTime` rather than pulling in a
233/// date/time crate (`chrono`/`time`), since this is the only place in the
234/// crate that needs calendar math. Uses Howard Hinnant's well-known
235/// `civil_from_days` algorithm for the Gregorian conversion:
236/// <http://howardhinnant.github.io/date_algorithms.html>.
237fn rfc3339_now_utc() -> String {
238 let since_epoch = SystemTime::now()
239 .duration_since(SystemTime::UNIX_EPOCH)
240 .expect("system clock is before the Unix epoch");
241 let total_secs = since_epoch.as_secs();
242 let days = (total_secs / 86_400) as i64;
243 let secs_of_day = total_secs % 86_400;
244
245 let hour = secs_of_day / 3600;
246 let minute = (secs_of_day % 3600) / 60;
247 let second = secs_of_day % 60;
248
249 let (year, month, day) = civil_from_days(days);
250
251 format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
252}
253
254/// Converts a count of days since the Unix epoch (1970-01-01) into a
255/// proleptic-Gregorian `(year, month, day)`. See
256/// <http://howardhinnant.github.io/date_algorithms.html#civil_from_days>.
257fn civil_from_days(z: i64) -> (i64, u32, u32) {
258 let z = z + 719_468;
259 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
260 let doe = (z - era * 146_097) as u64; // [0, 146096]
261 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; // [0, 399]
262 let y = yoe as i64 + era * 400;
263 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
264 let mp = (5 * doy + 2) / 153; // [0, 11]
265 let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
266 let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
267 let y = if m <= 2 { y + 1 } else { y };
268 (y, m, d)
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274 use age::secrecy::ExposeSecret;
275 use std::process::Command;
276
277 #[derive(serde::Deserialize)]
278 struct DecryptedDoc {
279 value: String,
280 }
281
282 /// The core correctness check: content produced by `encrypt_for_secret`
283 /// must be decryptable by the real `sops` binary (the same tool
284 /// signetd's own server-side decrypt path is compatible with), not just
285 /// self-consistent with this module's own code.
286 #[test]
287 fn encrypt_for_secret_real_sops_can_decrypt() {
288 let Ok(sops_path) = which_sops() else {
289 eprintln!("sops binary not found on PATH; skipping real-sops round-trip test");
290 return;
291 };
292
293 let identity = age::x25519::Identity::generate();
294 let encrypted = encrypt_for_secret(&identity.to_public().to_string(), b"hello-world")
295 .expect("encrypt_for_secret");
296
297 let dir = tempdir();
298 let secret_path = dir.join("secret.yaml");
299 std::fs::write(&secret_path, &encrypted).expect("write encrypted file");
300
301 let output = Command::new(&sops_path)
302 .arg("--decrypt")
303 .arg(&secret_path)
304 .env("SOPS_AGE_KEY", identity.to_string().expose_secret())
305 .output()
306 .expect("run sops");
307 assert!(
308 output.status.success(),
309 "sops decrypt failed: {}",
310 String::from_utf8_lossy(&output.stderr)
311 );
312
313 let doc: DecryptedDoc =
314 serde_yaml_ng::from_slice(&output.stdout).expect("parse decrypted yaml");
315 assert_eq!(doc.value, "hello-world");
316 }
317
318 /// Verifies the real `sops` binary rejects decryption with an identity
319 /// that wasn't encrypted to — i.e. that `encrypt_for_secret` is
320 /// actually scoping access to the given recipient, not producing
321 /// something any identity can open.
322 #[test]
323 fn encrypt_for_secret_wrong_identity_fails() {
324 let Ok(sops_path) = which_sops() else {
325 eprintln!("sops binary not found on PATH; skipping real-sops round-trip test");
326 return;
327 };
328
329 let encrypted_to = age::x25519::Identity::generate();
330 let wrong_identity = age::x25519::Identity::generate();
331
332 let encrypted = encrypt_for_secret(&encrypted_to.to_public().to_string(), b"hello-world")
333 .expect("encrypt_for_secret");
334
335 let dir = tempdir();
336 let secret_path = dir.join("secret.yaml");
337 std::fs::write(&secret_path, &encrypted).expect("write encrypted file");
338
339 let output = Command::new(&sops_path)
340 .arg("--decrypt")
341 .arg(&secret_path)
342 .env("SOPS_AGE_KEY", wrong_identity.to_string().expose_secret())
343 .output()
344 .expect("run sops");
345 assert!(
346 !output.status.success(),
347 "expected sops decrypt with the wrong identity to fail, got: {}",
348 String::from_utf8_lossy(&output.stdout)
349 );
350 }
351
352 /// Verifies the empty-input case is rejected explicitly rather than
353 /// failing deeper inside the age library with a less obvious error.
354 #[test]
355 fn encrypt_for_secret_empty_public_key() {
356 let err = encrypt_for_secret("", b"hello-world").expect_err("expected an error");
357 assert!(matches!(err, SopsEncryptError::EmptyPublicKey));
358 }
359
360 /// Verifies a malformed recipient string is rejected clearly rather
361 /// than producing a file that silently can't be decrypted by anyone.
362 #[test]
363 fn encrypt_for_secret_invalid_public_key() {
364 let err = encrypt_for_secret("not-a-real-age-key", b"hello-world")
365 .expect_err("expected an error");
366 assert!(matches!(err, SopsEncryptError::ParseRecipient(_)));
367 }
368
369 fn which_sops() -> Result<String, ()> {
370 let output = Command::new("which").arg("sops").output().map_err(|_| ())?;
371 if !output.status.success() {
372 return Err(());
373 }
374 String::from_utf8(output.stdout)
375 .map(|s| s.trim().to_string())
376 .map_err(|_| ())
377 }
378
379 /// A minimal per-test temp directory, without pulling in the `tempfile`
380 /// crate as a dev-dependency for a single test module.
381 fn tempdir() -> std::path::PathBuf {
382 let mut dir = std::env::temp_dir();
383 let unique = format!(
384 "signet-sops-encrypt-test-{}-{}",
385 std::process::id(),
386 std::time::SystemTime::now()
387 .duration_since(std::time::UNIX_EPOCH)
388 .unwrap()
389 .as_nanos()
390 );
391 dir.push(unique);
392 std::fs::create_dir_all(&dir).expect("create temp dir");
393 dir
394 }
395}