use std::collections::BTreeMap;
use argon2::{Argon2, Params, Version};
use base64ct::{Base64, Encoding};
use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;
pub const ARTIFACT_VERSION: u32 = 1;
pub const KDF_MEMORY_KIB: u32 = 65536;
pub const KDF_TIME_COST: u32 = 3;
pub const KDF_PARALLELISM: u32 = 4;
pub const KDF_SALT_BYTES: usize = 16;
#[derive(Debug, thiserror::Error)]
pub enum ArtifactError {
#[error("passphrase must not be empty or whitespace")]
WeakPassphrase,
#[error("envy.enc is malformed: {0}")]
MalformedArtifact(String),
#[error("envelope for environment '{0}' is malformed: {1}")]
MalformedEnvelope(String, String),
#[error("unsupported artifact version {0} (expected {1})")]
UnsupportedVersion(u32, u32),
#[error("key derivation failed: {0}")]
KdfFailed(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SyncArtifact {
pub version: u32,
pub environments: BTreeMap<String, EncryptedEnvelope>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EncryptedEnvelope {
pub ciphertext: String,
pub nonce: String,
pub kdf: KdfParams,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KdfParams {
pub algorithm: String,
pub memory_kib: u32,
pub time_cost: u32,
pub parallelism: u32,
pub salt: String,
}
pub struct ArtifactPayload {
pub secrets: BTreeMap<String, Zeroizing<String>>,
}
pub fn derive_key(
passphrase: &str,
salt: &[u8; 16],
params: &KdfParams,
) -> Result<Zeroizing<[u8; 32]>, ArtifactError> {
if passphrase.trim().is_empty() {
return Err(ArtifactError::WeakPassphrase);
}
let argon2_params = Params::new(
params.memory_kib,
params.time_cost,
params.parallelism,
Some(32),
)
.map_err(|e| ArtifactError::KdfFailed(e.to_string()))?;
let argon2 = Argon2::new(argon2::Algorithm::Argon2id, Version::V0x13, argon2_params);
let mut output = Zeroizing::new([0u8; 32]);
argon2
.hash_password_into(passphrase.as_bytes(), salt, output.as_mut())
.map_err(|e| ArtifactError::KdfFailed(e.to_string()))?;
Ok(output)
}
pub fn seal_envelope(
passphrase: &str,
payload: &ArtifactPayload,
) -> Result<EncryptedEnvelope, ArtifactError> {
if passphrase.trim().is_empty() {
return Err(ArtifactError::WeakPassphrase);
}
use aes_gcm::aead::OsRng;
use aes_gcm::aead::rand_core::RngCore;
let mut salt = [0u8; KDF_SALT_BYTES];
OsRng.fill_bytes(&mut salt);
let kdf = KdfParams {
algorithm: "argon2id".to_string(),
memory_kib: KDF_MEMORY_KIB,
time_cost: KDF_TIME_COST,
parallelism: KDF_PARALLELISM,
salt: Base64::encode_string(&salt),
};
let key = derive_key(passphrase, &salt, &kdf)?;
let plain_map: BTreeMap<&str, &str> = payload
.secrets
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
let json_bytes = serde_json::to_vec(&plain_map)
.map_err(|e| ArtifactError::MalformedArtifact(e.to_string()))?;
let encrypted = crate::crypto::encrypt(&key, &json_bytes)
.map_err(|e| ArtifactError::MalformedArtifact(e.to_string()))?;
Ok(EncryptedEnvelope {
ciphertext: Base64::encode_string(&encrypted.ciphertext),
nonce: Base64::encode_string(&encrypted.nonce),
kdf,
})
}
pub fn unseal_envelope(
passphrase: &str,
env_name: &str,
envelope: &EncryptedEnvelope,
) -> Result<ArtifactPayload, ArtifactError> {
if passphrase.trim().is_empty() {
return Err(ArtifactError::WeakPassphrase);
}
if envelope.kdf.algorithm != "argon2id" {
return Err(ArtifactError::UnsupportedVersion(0, ARTIFACT_VERSION));
}
let salt_bytes = Base64::decode_vec(&envelope.kdf.salt)
.map_err(|e| ArtifactError::MalformedEnvelope(env_name.to_string(), e.to_string()))?;
let salt: [u8; 16] = salt_bytes.try_into().map_err(|_| {
ArtifactError::MalformedEnvelope(env_name.to_string(), "salt must be 16 bytes".to_string())
})?;
let key = derive_key(passphrase, &salt, &envelope.kdf)?;
let ct_bytes = Base64::decode_vec(&envelope.ciphertext)
.map_err(|e| ArtifactError::MalformedEnvelope(env_name.to_string(), e.to_string()))?;
let nonce_bytes = Base64::decode_vec(&envelope.nonce)
.map_err(|e| ArtifactError::MalformedEnvelope(env_name.to_string(), e.to_string()))?;
let plaintext = crate::crypto::decrypt(&key, &ct_bytes, &nonce_bytes).map_err(|_| {
ArtifactError::MalformedEnvelope(env_name.to_string(), "authentication failed".to_string())
})?;
let raw: BTreeMap<String, String> = serde_json::from_slice(&plaintext)
.map_err(|e| ArtifactError::MalformedEnvelope(env_name.to_string(), e.to_string()))?;
let secrets = raw
.into_iter()
.map(|(k, v)| (k, Zeroizing::new(v)))
.collect();
Ok(ArtifactPayload { secrets })
}
#[cfg(test)]
mod tests {
use super::*;
fn test_kdf_params() -> KdfParams {
KdfParams {
algorithm: "argon2id".to_string(),
memory_kib: 256,
time_cost: 1,
parallelism: 1,
salt: Base64::encode_string(&[0u8; 16]),
}
}
fn one_secret_payload() -> ArtifactPayload {
let mut secrets = BTreeMap::new();
secrets.insert(
"STRIPE_KEY".to_string(),
Zeroizing::new("sk_test_12345".to_string()),
);
ArtifactPayload { secrets }
}
#[test]
fn derive_key_round_trip() {
let passphrase = "hunter2";
let salt: [u8; 16] = [42u8; 16];
let params = test_kdf_params();
let key1 = derive_key(passphrase, &salt, ¶ms).expect("derive_key must succeed");
let key2 = derive_key(passphrase, &salt, ¶ms).expect("derive_key must succeed");
assert_eq!(
*key1, *key2,
"same passphrase + salt must yield the same key"
);
}
#[test]
fn derive_key_different_salts_produce_different_keys() {
let passphrase = "hunter2";
let salt_a: [u8; 16] = [1u8; 16];
let salt_b: [u8; 16] = [2u8; 16];
let params = test_kdf_params();
let key_a = derive_key(passphrase, &salt_a, ¶ms).expect("derive_key must succeed");
let key_b = derive_key(passphrase, &salt_b, ¶ms).expect("derive_key must succeed");
assert_ne!(*key_a, *key_b, "different salts must yield different keys");
}
#[test]
fn seal_unseal_envelope_round_trip() {
let passphrase = "correct-horse-battery-staple";
let payload = one_secret_payload();
let envelope = seal_envelope(passphrase, &payload).expect("seal must succeed");
let recovered = unseal_envelope(passphrase, "development", &envelope)
.expect("unseal must succeed with correct passphrase");
assert_eq!(
recovered.secrets.get("STRIPE_KEY").map(|v| v.as_str()),
Some("sk_test_12345"),
"recovered value must match original"
);
}
#[test]
fn wrong_passphrase_returns_malformed_envelope() {
let payload = one_secret_payload();
let envelope = seal_envelope("correct", &payload).expect("seal must succeed");
let result = unseal_envelope("wrong", "development", &envelope);
assert!(
matches!(result, Err(ArtifactError::MalformedEnvelope(_, _))),
"wrong passphrase must return MalformedEnvelope, got: {:?}",
result.err()
);
}
#[test]
fn tampered_ciphertext_returns_malformed_envelope() {
let payload = one_secret_payload();
let mut envelope = seal_envelope("passphrase", &payload).expect("seal must succeed");
let mut ct_bytes =
Base64::decode_vec(&envelope.ciphertext).expect("ciphertext must be valid Base64");
ct_bytes[0] ^= 0xFF;
envelope.ciphertext = Base64::encode_string(&ct_bytes);
let result = unseal_envelope("passphrase", "development", &envelope);
assert!(
matches!(result, Err(ArtifactError::MalformedEnvelope(_, _))),
"tampered ciphertext must return MalformedEnvelope, got: {:?}",
result.err()
);
}
#[test]
fn empty_passphrase_returns_weak_passphrase() {
let payload = one_secret_payload();
let result_empty = seal_envelope("", &payload);
assert!(
matches!(result_empty, Err(ArtifactError::WeakPassphrase)),
"empty passphrase must return WeakPassphrase"
);
let result_whitespace = seal_envelope(" ", &payload);
assert!(
matches!(result_whitespace, Err(ArtifactError::WeakPassphrase)),
"whitespace passphrase must return WeakPassphrase"
);
}
}