use aes_gcm_siv::aead::{Aead, KeyInit};
use aes_gcm_siv::{Aes256GcmSiv, Nonce};
use anyhow::{Context, Result, anyhow};
use base64::{Engine as _, engine::general_purpose::STANDARD};
use pbkdf2::pbkdf2_hmac_array;
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use crate::types::{RedactionSession, SessionEntrySummary};
const KDF_ROUNDS: u32 = 600_000;
const SESSION_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct EncryptedSessionFile {
pub(crate) version: u32,
pub(crate) session_id: String,
pub(crate) fingerprint: String,
pub(crate) redacted_fingerprint: String,
pub(crate) entry_count: usize,
pub(crate) entries: Vec<SessionEntrySummary>,
pub(crate) salt_b64: String,
pub(crate) nonce_b64: String,
pub(crate) ciphertext_b64: String,
}
pub fn encrypt_session_to_string(session: &RedactionSession, passphrase: &str) -> Result<String> {
let mut salt = [0u8; 16];
let mut nonce = [0u8; 12];
getrandom::fill(&mut salt)
.map_err(|error| anyhow!("failed to generate session salt: {error}"))?;
getrandom::fill(&mut nonce)
.map_err(|error| anyhow!("failed to generate session nonce: {error}"))?;
let key = derive_key(passphrase, &salt);
let cipher = Aes256GcmSiv::new_from_slice(&key)
.map_err(|error| anyhow!("failed to initialize session cipher: {error}"))?;
let plaintext = serde_json::to_vec(session).context("failed to serialize session")?;
let ciphertext = cipher
.encrypt(Nonce::from_slice(&nonce), plaintext.as_ref())
.map_err(|_| anyhow!("failed to encrypt session"))?;
let envelope = EncryptedSessionFile {
version: SESSION_VERSION,
session_id: session.session_id.clone(),
fingerprint: session.fingerprint.clone(),
redacted_fingerprint: session.redacted_fingerprint.clone(),
entry_count: session.entries.len(),
entries: session
.entries
.iter()
.map(|entry| SessionEntrySummary {
token: entry.token.clone(),
kind: entry.kind,
replacement_hint: entry.replacement_hint.clone(),
occurrences: entry.occurrences,
})
.collect(),
salt_b64: STANDARD.encode(salt),
nonce_b64: STANDARD.encode(nonce),
ciphertext_b64: STANDARD.encode(ciphertext),
};
serde_json::to_string_pretty(&envelope)
.context("failed to serialize encrypted session envelope")
}
pub fn decrypt_session_from_str(data: &str, passphrase: &str) -> Result<RedactionSession> {
let envelope = parse_envelope(data)?;
let salt = decode_exact::<16>(&envelope.salt_b64).context("invalid encrypted session salt")?;
let nonce =
decode_exact::<12>(&envelope.nonce_b64).context("invalid encrypted session nonce")?;
let ciphertext = STANDARD
.decode(envelope.ciphertext_b64.as_bytes())
.context("invalid encrypted session ciphertext")?;
let key = derive_key(passphrase, &salt);
let cipher = Aes256GcmSiv::new_from_slice(&key)
.map_err(|error| anyhow!("failed to initialize session cipher: {error}"))?;
let plaintext = cipher
.decrypt(Nonce::from_slice(&nonce), ciphertext.as_ref())
.map_err(|_| anyhow!("failed to decrypt session"))?;
serde_json::from_slice(&plaintext).context("failed to deserialize decrypted session")
}
pub(crate) fn parse_envelope(data: &str) -> Result<EncryptedSessionFile> {
serde_json::from_str(data).context("failed to parse encrypted session file")
}
fn derive_key(passphrase: &str, salt: &[u8; 16]) -> [u8; 32] {
pbkdf2_hmac_array::<Sha256, 32>(passphrase.as_bytes(), salt, KDF_ROUNDS)
}
fn decode_exact<const N: usize>(data: &str) -> Result<[u8; N]> {
let decoded = STANDARD
.decode(data.as_bytes())
.context("invalid base64 data")?;
decoded
.try_into()
.map_err(|_| anyhow!("unexpected decoded length"))
}