use aes_gcm::aead::consts::U32;
use aes_gcm::aead::generic_array::GenericArray;
use aes_gcm::aead::rand_core::RngCore;
use aes_gcm::aead::{Aead, KeyInit, OsRng, Payload};
use aes_gcm::aes::Aes256;
use aes_gcm::AesGcm;
use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine as _;
use serde::Serialize;
use sha2::{Digest, Sha512};
use std::time::SystemTime;
type SopsAesGcm = AesGcm<Aes256, U32>;
const GCM_TAG_SIZE: usize = 16;
const SOPS_FILE_VERSION: &str = "3.13.1";
#[derive(Debug, thiserror::Error)]
pub enum SopsEncryptError {
#[error("signet: public key must not be empty")]
EmptyPublicKey,
#[error("signet: parse age recipient: {0}")]
ParseRecipient(String),
#[error("signet: encrypt value: {0}")]
EncryptValue(String),
#[error("signet: encrypt mac: {0}")]
EncryptMac(String),
#[error("signet: wrap data key: {0}")]
WrapDataKey(String),
#[error("signet: marshal sops document: {0}")]
Marshal(String),
}
#[derive(Serialize)]
struct SopsAgeKey {
recipient: String,
enc: String,
}
#[derive(Serialize)]
struct SopsMetadata {
age: Vec<SopsAgeKey>,
lastmodified: String,
mac: String,
version: String,
}
#[derive(Serialize)]
struct SopsDocument {
value: String,
sops: SopsMetadata,
}
pub fn encrypt_for_secret(public_key: &str, value: &[u8]) -> Result<Vec<u8>, SopsEncryptError> {
if public_key.is_empty() {
return Err(SopsEncryptError::EmptyPublicKey);
}
let recipient: age::x25519::Recipient = public_key
.parse()
.map_err(|e: &str| SopsEncryptError::ParseRecipient(e.to_string()))?;
let mut data_key = [0u8; 32];
OsRng.fill_bytes(&mut data_key);
let digest = Sha512::digest(value);
let mac_hex = hex_upper(&digest);
let enc_value =
sops_encrypt_leaf(&data_key, value, b"value:").map_err(SopsEncryptError::EncryptValue)?;
let last_modified = rfc3339_now_utc();
let enc_mac = sops_encrypt_leaf(&data_key, mac_hex.as_bytes(), last_modified.as_bytes())
.map_err(SopsEncryptError::EncryptMac)?;
let enc_data_key = age::encrypt_and_armor(&recipient, &data_key)
.map_err(|e| SopsEncryptError::WrapDataKey(e.to_string()))?;
let doc = SopsDocument {
value: enc_value,
sops: SopsMetadata {
age: vec![SopsAgeKey {
recipient: public_key.to_string(),
enc: enc_data_key,
}],
lastmodified: last_modified.clone(),
mac: enc_mac,
version: SOPS_FILE_VERSION.to_string(),
},
};
let yaml =
serde_yaml_ng::to_string(&doc).map_err(|e| SopsEncryptError::Marshal(e.to_string()))?;
let yaml = force_quote_scalar(&yaml, "lastmodified", &last_modified);
let yaml = force_quote_scalar(&yaml, "version", SOPS_FILE_VERSION);
Ok(yaml.into_bytes())
}
fn force_quote_scalar(yaml: &str, key: &str, raw_value: &str) -> String {
let unquoted = format!("{key}: {raw_value}");
let quoted = format!("{key}: \"{raw_value}\"");
yaml.replacen(&unquoted, "ed, 1)
}
fn sops_encrypt_leaf(
data_key: &[u8; 32],
plaintext: &[u8],
additional_data: &[u8],
) -> Result<String, String> {
let cipher = SopsAesGcm::new(GenericArray::from_slice(data_key));
let mut nonce = [0u8; 32];
OsRng.fill_bytes(&mut nonce);
let sealed = cipher
.encrypt(
GenericArray::from_slice(&nonce),
Payload {
msg: plaintext,
aad: additional_data,
},
)
.map_err(|e| e.to_string())?;
let (data, tag) = sealed.split_at(sealed.len() - GCM_TAG_SIZE);
Ok(format!(
"ENC[AES256_GCM,data:{},iv:{},tag:{},type:str]",
BASE64.encode(data),
BASE64.encode(nonce),
BASE64.encode(tag),
))
}
fn hex_upper(digest: &[u8]) -> String {
let mut out = String::with_capacity(digest.len() * 2);
for byte in digest {
out.push_str(&format!("{byte:02X}"));
}
out
}
fn rfc3339_now_utc() -> String {
let since_epoch = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.expect("system clock is before the Unix epoch");
let total_secs = since_epoch.as_secs();
let days = (total_secs / 86_400) as i64;
let secs_of_day = total_secs % 86_400;
let hour = secs_of_day / 3600;
let minute = (secs_of_day % 3600) / 60;
let second = secs_of_day % 60;
let (year, month, day) = civil_from_days(days);
format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
}
fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as u64; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = (doy - (153 * mp + 2) / 5 + 1) as u32; let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; let y = if m <= 2 { y + 1 } else { y };
(y, m, d)
}
#[cfg(test)]
mod tests {
use super::*;
use age::secrecy::ExposeSecret;
use std::process::Command;
#[derive(serde::Deserialize)]
struct DecryptedDoc {
value: String,
}
#[test]
fn encrypt_for_secret_real_sops_can_decrypt() {
let Ok(sops_path) = which_sops() else {
eprintln!("sops binary not found on PATH; skipping real-sops round-trip test");
return;
};
let identity = age::x25519::Identity::generate();
let encrypted = encrypt_for_secret(&identity.to_public().to_string(), b"hello-world")
.expect("encrypt_for_secret");
let dir = tempdir();
let secret_path = dir.join("secret.yaml");
std::fs::write(&secret_path, &encrypted).expect("write encrypted file");
let output = Command::new(&sops_path)
.arg("--decrypt")
.arg(&secret_path)
.env("SOPS_AGE_KEY", identity.to_string().expose_secret())
.output()
.expect("run sops");
assert!(
output.status.success(),
"sops decrypt failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let doc: DecryptedDoc =
serde_yaml_ng::from_slice(&output.stdout).expect("parse decrypted yaml");
assert_eq!(doc.value, "hello-world");
}
#[test]
fn encrypt_for_secret_wrong_identity_fails() {
let Ok(sops_path) = which_sops() else {
eprintln!("sops binary not found on PATH; skipping real-sops round-trip test");
return;
};
let encrypted_to = age::x25519::Identity::generate();
let wrong_identity = age::x25519::Identity::generate();
let encrypted = encrypt_for_secret(&encrypted_to.to_public().to_string(), b"hello-world")
.expect("encrypt_for_secret");
let dir = tempdir();
let secret_path = dir.join("secret.yaml");
std::fs::write(&secret_path, &encrypted).expect("write encrypted file");
let output = Command::new(&sops_path)
.arg("--decrypt")
.arg(&secret_path)
.env("SOPS_AGE_KEY", wrong_identity.to_string().expose_secret())
.output()
.expect("run sops");
assert!(
!output.status.success(),
"expected sops decrypt with the wrong identity to fail, got: {}",
String::from_utf8_lossy(&output.stdout)
);
}
#[test]
fn encrypt_for_secret_empty_public_key() {
let err = encrypt_for_secret("", b"hello-world").expect_err("expected an error");
assert!(matches!(err, SopsEncryptError::EmptyPublicKey));
}
#[test]
fn encrypt_for_secret_invalid_public_key() {
let err = encrypt_for_secret("not-a-real-age-key", b"hello-world")
.expect_err("expected an error");
assert!(matches!(err, SopsEncryptError::ParseRecipient(_)));
}
fn which_sops() -> Result<String, ()> {
let output = Command::new("which").arg("sops").output().map_err(|_| ())?;
if !output.status.success() {
return Err(());
}
String::from_utf8(output.stdout)
.map(|s| s.trim().to_string())
.map_err(|_| ())
}
fn tempdir() -> std::path::PathBuf {
let mut dir = std::env::temp_dir();
let unique = format!(
"signet-sops-encrypt-test-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
);
dir.push(unique);
std::fs::create_dir_all(&dir).expect("create temp dir");
dir
}
}