use aes_gcm::{
aead::{Aead, KeyInit, Payload},
Aes256Gcm, Nonce,
};
use base64::engine::general_purpose::STANDARD;
use base64::Engine;
use hmac::{Hmac, Mac};
use rand::RngCore;
use serde::{Deserialize, Serialize};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
pub const PBKDF2_ITERATIONS: u32 = 600_000;
const KDF: &str = "pbkdf2-hmac-sha256";
const VERSION: u8 = 1;
pub const ENV_PASSPHRASE: &str = "IICP_OPERATOR_PASSPHRASE";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EncryptedSecret {
pub v: u8,
pub kdf: String,
pub iter: u32,
pub salt: String,
pub nonce: String,
pub ct: String,
}
fn pbkdf2_hmac_sha256_32(passphrase: &[u8], salt: &[u8], iterations: u32) -> [u8; 32] {
let prf = |data: &[u8]| -> [u8; 32] {
let mut mac =
<HmacSha256 as Mac>::new_from_slice(passphrase).expect("hmac accepts any key length");
mac.update(data);
let mut block = [0u8; 32];
block.copy_from_slice(&mac.finalize().into_bytes());
block
};
let mut salt_idx = Vec::with_capacity(salt.len() + 4);
salt_idx.extend_from_slice(salt);
salt_idx.extend_from_slice(&1u32.to_be_bytes());
let mut u = prf(&salt_idx);
let mut out = u;
for _ in 1..iterations {
u = prf(&u);
for (o, x) in out.iter_mut().zip(u.iter()) {
*o ^= x;
}
}
out
}
pub fn encrypt_seed(
passphrase: &str,
seed_b64: &str,
operator_id: &str,
) -> Result<EncryptedSecret, String> {
if passphrase.is_empty() {
return Err("passphrase must not be empty".into());
}
let seed = STANDARD
.decode(seed_b64)
.map_err(|e| format!("bad seed base64: {e}"))?;
let mut salt = [0u8; 16];
let mut nonce = [0u8; 12];
rand::thread_rng().fill_bytes(&mut salt);
rand::thread_rng().fill_bytes(&mut nonce);
let key = pbkdf2_hmac_sha256_32(passphrase.as_bytes(), &salt, PBKDF2_ITERATIONS);
let cipher = Aes256Gcm::new_from_slice(&key).map_err(|_| "AES key error".to_string())?;
let ct = cipher
.encrypt(
Nonce::from_slice(&nonce),
Payload {
msg: &seed,
aad: operator_id.as_bytes(),
},
)
.map_err(|_| "AES-GCM encrypt failed".to_string())?;
Ok(EncryptedSecret {
v: VERSION,
kdf: KDF.to_string(),
iter: PBKDF2_ITERATIONS,
salt: STANDARD.encode(salt),
nonce: STANDARD.encode(nonce),
ct: STANDARD.encode(ct),
})
}
pub fn decrypt_seed(
passphrase: &str,
enc: &EncryptedSecret,
operator_id: &str,
) -> Result<String, String> {
if enc.kdf != KDF || enc.v != VERSION {
return Err(format!(
"unsupported operator_secret_enc format: {} v{}",
enc.kdf, enc.v
));
}
let salt = STANDARD
.decode(&enc.salt)
.map_err(|e| format!("bad salt: {e}"))?;
let nonce = STANDARD
.decode(&enc.nonce)
.map_err(|e| format!("bad nonce: {e}"))?;
let ct = STANDARD
.decode(&enc.ct)
.map_err(|e| format!("bad ct: {e}"))?;
let key = pbkdf2_hmac_sha256_32(passphrase.as_bytes(), &salt, enc.iter);
let cipher = Aes256Gcm::new_from_slice(&key).map_err(|_| "AES key error".to_string())?;
let seed = cipher
.decrypt(
Nonce::from_slice(&nonce),
Payload {
msg: &ct,
aad: operator_id.as_bytes(),
},
)
.map_err(|_| {
"operator secret decryption failed (wrong passphrase or corrupt file)".to_string()
})?;
Ok(STANDARD.encode(seed))
}
pub fn passphrase_from_env() -> Option<String> {
std::env::var(ENV_PASSPHRASE).ok().filter(|s| !s.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
const PASSPHRASE: &str = "correct horse battery staple";
const OPERATOR_ID: &str = "T3BQdWI="; const SEED_B64: &str = "ICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj8=";
fn kat_record() -> EncryptedSecret {
EncryptedSecret {
v: 1,
kdf: "pbkdf2-hmac-sha256".to_string(),
iter: 600_000,
salt: "AAECAwQFBgcICQoLDA0ODw==".to_string(),
nonce: "EBESExQVFhcYGRob".to_string(),
ct: "LDNf5jTajlDjk7Pj4N5a1SEJqNeyUuCc+wkh0fSEftCq1ypsedl8nLMPuMZQ7Xvl".to_string(),
}
}
#[test]
fn opens_cross_language_kat_record() {
assert_eq!(
decrypt_seed(PASSPHRASE, &kat_record(), OPERATOR_ID).unwrap(),
SEED_B64
);
}
#[test]
fn encrypt_then_decrypt_round_trip() {
let enc = encrypt_seed("hunter2", SEED_B64, OPERATOR_ID).unwrap();
assert_eq!(enc.kdf, "pbkdf2-hmac-sha256");
assert_ne!(enc.ct, kat_record().ct); assert_eq!(
decrypt_seed("hunter2", &enc, OPERATOR_ID).unwrap(),
SEED_B64
);
}
#[test]
fn wrong_passphrase_fails() {
let enc = encrypt_seed("right", SEED_B64, OPERATOR_ID).unwrap();
assert!(decrypt_seed("WRONG", &enc, OPERATOR_ID).is_err());
}
#[test]
fn aad_binds_operator_id() {
let enc = encrypt_seed("pw", SEED_B64, OPERATOR_ID).unwrap();
assert!(decrypt_seed("pw", &enc, "different-operator-id").is_err());
}
}