Skip to main content

cloudiful_redactor/session/
crypto.rs

1use aes_gcm_siv::aead::{Aead, KeyInit, Payload};
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;
13const OPAQUE_VERSION: u32 = 1;
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub(crate) struct OpaqueEncryptedPayload {
17    version: u32,
18    salt_b64: String,
19    nonce_b64: String,
20    ciphertext_b64: String,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub(crate) struct EncryptedSessionFile {
25    pub(crate) version: u32,
26    pub(crate) session_id: String,
27    pub(crate) scope_id: String,
28    pub(crate) external_id: Option<String>,
29    pub(crate) fingerprint: String,
30    pub(crate) redacted_fingerprint: String,
31    pub(crate) entry_count: usize,
32    pub(crate) entries: Vec<SessionEntrySummary>,
33    pub(crate) salt_b64: String,
34    pub(crate) nonce_b64: String,
35    pub(crate) ciphertext_b64: String,
36}
37
38pub fn encrypt_session_to_string(session: &RedactionSession, passphrase: &str) -> Result<String> {
39    let mut salt = [0u8; 16];
40    let mut nonce = [0u8; 12];
41    getrandom::fill(&mut salt)
42        .map_err(|error| anyhow!("failed to generate session salt: {error}"))?;
43    getrandom::fill(&mut nonce)
44        .map_err(|error| anyhow!("failed to generate session nonce: {error}"))?;
45
46    let key = derive_key(passphrase, &salt);
47    let cipher = Aes256GcmSiv::new_from_slice(&key)
48        .map_err(|error| anyhow!("failed to initialize session cipher: {error}"))?;
49    let plaintext = serde_json::to_vec(session).context("failed to serialize session")?;
50    let ciphertext = cipher
51        .encrypt(Nonce::from_slice(&nonce), plaintext.as_ref())
52        .map_err(|_| anyhow!("failed to encrypt session"))?;
53
54    let envelope = EncryptedSessionFile {
55        version: SESSION_VERSION,
56        session_id: session.session_id.clone(),
57        scope_id: session.scope_id.clone(),
58        external_id: session.external_id.clone(),
59        fingerprint: session.fingerprint.clone(),
60        redacted_fingerprint: session.redacted_fingerprint.clone(),
61        entry_count: session.entries.len(),
62        entries: session
63            .entries
64            .iter()
65            .map(|entry| SessionEntrySummary {
66                token: entry.token.clone(),
67                kind: entry.kind,
68                replacement_hint: entry.replacement_hint.clone(),
69                occurrences: entry.occurrences,
70            })
71            .collect(),
72        salt_b64: STANDARD.encode(salt),
73        nonce_b64: STANDARD.encode(nonce),
74        ciphertext_b64: STANDARD.encode(ciphertext),
75    };
76
77    serde_json::to_string_pretty(&envelope)
78        .context("failed to serialize encrypted session envelope")
79}
80
81pub fn encrypt_session_for_storage(
82    session: &RedactionSession,
83    passphrase: &str,
84    external_id: &str,
85) -> Result<String> {
86    seal_json(session, passphrase, storage_aad(external_id).as_bytes())
87}
88
89pub fn decrypt_session_from_storage(
90    data: &str,
91    passphrase: &str,
92    external_id: &str,
93) -> Result<RedactionSession> {
94    open_json(data, passphrase, storage_aad(external_id).as_bytes())
95        .context("failed to decrypt stored session")
96}
97
98pub fn decrypt_session_from_str(data: &str, passphrase: &str) -> Result<RedactionSession> {
99    let envelope = parse_envelope(data)?;
100    if envelope.version != SESSION_VERSION {
101        return Err(anyhow!(
102            "unsupported encrypted session version {}, expected {}",
103            envelope.version,
104            SESSION_VERSION
105        ));
106    }
107    let salt = decode_exact::<16>(&envelope.salt_b64).context("invalid encrypted session salt")?;
108    let nonce =
109        decode_exact::<12>(&envelope.nonce_b64).context("invalid encrypted session nonce")?;
110    let ciphertext = STANDARD
111        .decode(envelope.ciphertext_b64.as_bytes())
112        .context("invalid encrypted session ciphertext")?;
113
114    let key = derive_key(passphrase, &salt);
115    let cipher = Aes256GcmSiv::new_from_slice(&key)
116        .map_err(|error| anyhow!("failed to initialize session cipher: {error}"))?;
117    let plaintext = cipher
118        .decrypt(Nonce::from_slice(&nonce), ciphertext.as_ref())
119        .map_err(|_| anyhow!("failed to decrypt session"))?;
120
121    serde_json::from_slice(&plaintext).context("failed to deserialize decrypted session")
122}
123
124pub(crate) fn parse_envelope(data: &str) -> Result<EncryptedSessionFile> {
125    serde_json::from_str(data).context("failed to parse encrypted session file")
126}
127
128pub(crate) fn seal_json<T: Serialize>(value: &T, passphrase: &str, aad: &[u8]) -> Result<String> {
129    let mut salt = [0u8; 16];
130    let mut nonce = [0u8; 12];
131    getrandom::fill(&mut salt).map_err(|error| anyhow!("failed to generate salt: {error}"))?;
132    getrandom::fill(&mut nonce).map_err(|error| anyhow!("failed to generate nonce: {error}"))?;
133    let key = derive_key(passphrase, &salt);
134    let cipher = Aes256GcmSiv::new_from_slice(&key)
135        .map_err(|error| anyhow!("failed to initialize cipher: {error}"))?;
136    let plaintext = serde_json::to_vec(value).context("failed to serialize encrypted payload")?;
137    let ciphertext = cipher
138        .encrypt(
139            Nonce::from_slice(&nonce),
140            Payload {
141                msg: &plaintext,
142                aad,
143            },
144        )
145        .map_err(|_| anyhow!("failed to encrypt payload"))?;
146    serde_json::to_string(&OpaqueEncryptedPayload {
147        version: OPAQUE_VERSION,
148        salt_b64: STANDARD.encode(salt),
149        nonce_b64: STANDARD.encode(nonce),
150        ciphertext_b64: STANDARD.encode(ciphertext),
151    })
152    .context("failed to serialize encrypted payload")
153}
154
155pub(crate) fn open_json<T: for<'de> Deserialize<'de>>(
156    data: &str,
157    passphrase: &str,
158    aad: &[u8],
159) -> Result<T> {
160    let payload: OpaqueEncryptedPayload =
161        serde_json::from_str(data).context("failed to parse encrypted payload")?;
162    if payload.version != OPAQUE_VERSION {
163        return Err(anyhow!(
164            "unsupported encrypted payload version {}",
165            payload.version
166        ));
167    }
168    let salt = decode_exact::<16>(&payload.salt_b64).context("invalid encrypted payload salt")?;
169    let nonce =
170        decode_exact::<12>(&payload.nonce_b64).context("invalid encrypted payload nonce")?;
171    let ciphertext = STANDARD
172        .decode(payload.ciphertext_b64.as_bytes())
173        .context("invalid encrypted payload ciphertext")?;
174    let key = derive_key(passphrase, &salt);
175    let cipher = Aes256GcmSiv::new_from_slice(&key)
176        .map_err(|error| anyhow!("failed to initialize cipher: {error}"))?;
177    let plaintext = cipher
178        .decrypt(
179            Nonce::from_slice(&nonce),
180            Payload {
181                msg: &ciphertext,
182                aad,
183            },
184        )
185        .map_err(|_| anyhow!("failed to decrypt payload"))?;
186    serde_json::from_slice(&plaintext).context("failed to deserialize encrypted payload")
187}
188
189fn storage_aad(external_id: &str) -> String {
190    format!("redactor:session-store:v1:{external_id}")
191}
192
193fn derive_key(passphrase: &str, salt: &[u8; 16]) -> [u8; 32] {
194    pbkdf2_hmac_array::<Sha256, 32>(passphrase.as_bytes(), salt, KDF_ROUNDS)
195}
196
197fn decode_exact<const N: usize>(data: &str) -> Result<[u8; N]> {
198    let decoded = STANDARD
199        .decode(data.as_bytes())
200        .context("invalid base64 data")?;
201    decoded
202        .try_into()
203        .map_err(|_| anyhow!("unexpected decoded length"))
204}