Skip to main content

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, &quoted, 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}