cloudiful_redactor/session/
crypto.rs1use aes_gcm_siv::aead::{Aead, KeyInit};
2use aes_gcm_siv::{Aes256GcmSiv, Nonce};
3use anyhow::{Context, Result, anyhow};
4use base64::{Engine as _, engine::general_purpose::STANDARD};
5use pbkdf2::pbkdf2_hmac_array;
6use serde::{Deserialize, Serialize};
7use sha2::Sha256;
8
9use crate::types::{RedactionSession, SessionEntrySummary};
10
11const KDF_ROUNDS: u32 = 600_000;
12const SESSION_VERSION: u32 = 2;
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub(crate) struct EncryptedSessionFile {
16 pub(crate) version: u32,
17 pub(crate) session_id: String,
18 pub(crate) scope_id: String,
19 pub(crate) external_id: Option<String>,
20 pub(crate) fingerprint: String,
21 pub(crate) redacted_fingerprint: String,
22 pub(crate) entry_count: usize,
23 pub(crate) entries: Vec<SessionEntrySummary>,
24 pub(crate) salt_b64: String,
25 pub(crate) nonce_b64: String,
26 pub(crate) ciphertext_b64: String,
27}
28
29pub fn encrypt_session_to_string(session: &RedactionSession, passphrase: &str) -> Result<String> {
30 let mut salt = [0u8; 16];
31 let mut nonce = [0u8; 12];
32 getrandom::fill(&mut salt)
33 .map_err(|error| anyhow!("failed to generate session salt: {error}"))?;
34 getrandom::fill(&mut nonce)
35 .map_err(|error| anyhow!("failed to generate session nonce: {error}"))?;
36
37 let key = derive_key(passphrase, &salt);
38 let cipher = Aes256GcmSiv::new_from_slice(&key)
39 .map_err(|error| anyhow!("failed to initialize session cipher: {error}"))?;
40 let plaintext = serde_json::to_vec(session).context("failed to serialize session")?;
41 let ciphertext = cipher
42 .encrypt(Nonce::from_slice(&nonce), plaintext.as_ref())
43 .map_err(|_| anyhow!("failed to encrypt session"))?;
44
45 let envelope = EncryptedSessionFile {
46 version: SESSION_VERSION,
47 session_id: session.session_id.clone(),
48 scope_id: session.scope_id.clone(),
49 external_id: session.external_id.clone(),
50 fingerprint: session.fingerprint.clone(),
51 redacted_fingerprint: session.redacted_fingerprint.clone(),
52 entry_count: session.entries.len(),
53 entries: session
54 .entries
55 .iter()
56 .map(|entry| SessionEntrySummary {
57 token: entry.token.clone(),
58 kind: entry.kind,
59 replacement_hint: entry.replacement_hint.clone(),
60 occurrences: entry.occurrences,
61 })
62 .collect(),
63 salt_b64: STANDARD.encode(salt),
64 nonce_b64: STANDARD.encode(nonce),
65 ciphertext_b64: STANDARD.encode(ciphertext),
66 };
67
68 serde_json::to_string_pretty(&envelope)
69 .context("failed to serialize encrypted session envelope")
70}
71
72pub fn decrypt_session_from_str(data: &str, passphrase: &str) -> Result<RedactionSession> {
73 let envelope = parse_envelope(data)?;
74 if envelope.version != SESSION_VERSION {
75 return Err(anyhow!(
76 "unsupported encrypted session version {}, expected {}",
77 envelope.version,
78 SESSION_VERSION
79 ));
80 }
81 let salt = decode_exact::<16>(&envelope.salt_b64).context("invalid encrypted session salt")?;
82 let nonce =
83 decode_exact::<12>(&envelope.nonce_b64).context("invalid encrypted session nonce")?;
84 let ciphertext = STANDARD
85 .decode(envelope.ciphertext_b64.as_bytes())
86 .context("invalid encrypted session ciphertext")?;
87
88 let key = derive_key(passphrase, &salt);
89 let cipher = Aes256GcmSiv::new_from_slice(&key)
90 .map_err(|error| anyhow!("failed to initialize session cipher: {error}"))?;
91 let plaintext = cipher
92 .decrypt(Nonce::from_slice(&nonce), ciphertext.as_ref())
93 .map_err(|_| anyhow!("failed to decrypt session"))?;
94
95 serde_json::from_slice(&plaintext).context("failed to deserialize decrypted session")
96}
97
98pub(crate) fn parse_envelope(data: &str) -> Result<EncryptedSessionFile> {
99 serde_json::from_str(data).context("failed to parse encrypted session file")
100}
101
102fn derive_key(passphrase: &str, salt: &[u8; 16]) -> [u8; 32] {
103 pbkdf2_hmac_array::<Sha256, 32>(passphrase.as_bytes(), salt, KDF_ROUNDS)
104}
105
106fn decode_exact<const N: usize>(data: &str) -> Result<[u8; N]> {
107 let decoded = STANDARD
108 .decode(data.as_bytes())
109 .context("invalid base64 data")?;
110 decoded
111 .try_into()
112 .map_err(|_| anyhow!("unexpected decoded length"))
113}