| 18 |
18 |
|
use argon2::{Argon2, Algorithm, Version, Params};
|
| 19 |
19 |
|
use base64::{engine::general_purpose::STANDARD as B64, Engine};
|
| 20 |
20 |
|
use chacha20poly1305::{
|
| 21 |
|
- |
aead::{Aead, KeyInit},
|
|
21 |
+ |
aead::{Aead, KeyInit, Payload},
|
| 22 |
22 |
|
XChaCha20Poly1305, XNonce,
|
| 23 |
23 |
|
};
|
| 24 |
24 |
|
use rand::RngCore;
|
| 41 |
41 |
|
const ARGON2_TIME_COST: u32 = 3;
|
| 42 |
42 |
|
const ARGON2_PARALLELISM: u32 = 1;
|
| 43 |
43 |
|
|
|
44 |
+ |
fn default_argon_mem() -> u32 { ARGON2_MEM_COST_KB }
|
|
45 |
+ |
fn default_argon_time() -> u32 { ARGON2_TIME_COST }
|
|
46 |
+ |
fn default_argon_par() -> u32 { ARGON2_PARALLELISM }
|
|
47 |
+ |
|
| 44 |
48 |
|
/// Encrypted master key envelope stored on the server.
|
|
49 |
+ |
///
|
|
50 |
+ |
/// The Argon2 cost parameters travel with the envelope (defaulting to the
|
|
51 |
+ |
/// current constants for v1 envelopes that predate them), so the work factor
|
|
52 |
+ |
/// can be raised later without a flag-day: an old envelope still re-derives
|
|
53 |
+ |
/// with the parameters it was wrapped under.
|
| 45 |
54 |
|
#[derive(Debug, Serialize, Deserialize)]
|
| 46 |
55 |
|
pub(crate) struct KeyEnvelope {
|
| 47 |
56 |
|
/// Envelope version (currently 1).
|
| 52 |
61 |
|
pub nonce: String,
|
| 53 |
62 |
|
/// Encrypted master key (base64).
|
| 54 |
63 |
|
pub ciphertext: String,
|
|
64 |
+ |
/// Argon2 memory cost in KiB.
|
|
65 |
+ |
#[serde(default = "default_argon_mem")]
|
|
66 |
+ |
pub m: u32,
|
|
67 |
+ |
/// Argon2 time cost (iterations).
|
|
68 |
+ |
#[serde(default = "default_argon_time")]
|
|
69 |
+ |
pub t: u32,
|
|
70 |
+ |
/// Argon2 parallelism.
|
|
71 |
+ |
#[serde(default = "default_argon_par")]
|
|
72 |
+ |
pub p: u32,
|
| 55 |
73 |
|
}
|
| 56 |
74 |
|
|
| 57 |
75 |
|
/// Generate a random 256-bit master key.
|
| 93 |
111 |
|
password: &str,
|
| 94 |
112 |
|
salt: &[u8; 32],
|
| 95 |
113 |
|
) -> Result<ZeroizeOnDrop> {
|
| 96 |
|
- |
let normalized = normalize_password(password)?;
|
| 97 |
|
- |
|
| 98 |
|
- |
let params = Params::new(
|
|
114 |
+ |
derive_wrapping_key_with_params(
|
|
115 |
+ |
password,
|
|
116 |
+ |
salt,
|
| 99 |
117 |
|
ARGON2_MEM_COST_KB,
|
| 100 |
118 |
|
ARGON2_TIME_COST,
|
| 101 |
119 |
|
ARGON2_PARALLELISM,
|
| 102 |
|
- |
Some(KEY_SIZE),
|
| 103 |
120 |
|
)
|
| 104 |
|
- |
.map_err(|e| SyncKitError::Crypto(format!("Argon2 params: {e}")))?;
|
|
121 |
+ |
}
|
|
122 |
+ |
|
|
123 |
+ |
/// Derive a wrapping key with explicit Argon2 cost parameters (read from the
|
|
124 |
+ |
/// envelope on unwrap, so old envelopes re-derive under their original cost).
|
|
125 |
+ |
fn derive_wrapping_key_with_params(
|
|
126 |
+ |
password: &str,
|
|
127 |
+ |
salt: &[u8; 32],
|
|
128 |
+ |
mem_kb: u32,
|
|
129 |
+ |
time_cost: u32,
|
|
130 |
+ |
parallelism: u32,
|
|
131 |
+ |
) -> Result<ZeroizeOnDrop> {
|
|
132 |
+ |
let normalized = normalize_password(password)?;
|
|
133 |
+ |
|
|
134 |
+ |
let params = Params::new(mem_kb, time_cost, parallelism, Some(KEY_SIZE))
|
|
135 |
+ |
.map_err(|e| SyncKitError::Crypto(format!("Argon2 params: {e}")))?;
|
| 105 |
136 |
|
|
| 106 |
137 |
|
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
|
| 107 |
138 |
|
|
| 161 |
192 |
|
salt: B64.encode(salt),
|
| 162 |
193 |
|
nonce: B64.encode(nonce_bytes),
|
| 163 |
194 |
|
ciphertext: B64.encode(ciphertext),
|
|
195 |
+ |
m: ARGON2_MEM_COST_KB,
|
|
196 |
+ |
t: ARGON2_TIME_COST,
|
|
197 |
+ |
p: ARGON2_PARALLELISM,
|
| 164 |
198 |
|
};
|
| 165 |
199 |
|
|
| 166 |
200 |
|
serde_json::to_string(&envelope).map_err(Into::into)
|
| 204 |
238 |
|
|
| 205 |
239 |
|
let mut salt = [0u8; 32];
|
| 206 |
240 |
|
salt.copy_from_slice(&salt_bytes);
|
| 207 |
|
- |
let wrapping_key = derive_wrapping_key(password, &salt)?;
|
|
241 |
+ |
let wrapping_key =
|
|
242 |
+ |
derive_wrapping_key_with_params(password, &salt, envelope.m, envelope.t, envelope.p)?;
|
| 208 |
243 |
|
|
| 209 |
244 |
|
let cipher = XChaCha20Poly1305::new((&*wrapping_key).into());
|
| 210 |
245 |
|
let nonce = XNonce::from_slice(&nonce_bytes);
|
| 226 |
261 |
|
Ok(key)
|
| 227 |
262 |
|
}
|
| 228 |
263 |
|
|
| 229 |
|
- |
/// Encrypt a data entry with the master key.
|
| 230 |
|
- |
/// Returns base64(nonce[24] || ciphertext || poly1305_tag[16]).
|
| 231 |
|
- |
pub fn encrypt_data(
|
| 232 |
|
- |
plaintext: &[u8],
|
| 233 |
|
- |
master_key: &[u8; KEY_SIZE],
|
| 234 |
|
- |
) -> Result<String> {
|
|
264 |
+ |
/// Wire-format tag marking a v2 payload: AEAD sealed with associated data
|
|
265 |
+ |
/// binding the ciphertext to its logical position. Legacy (untagged) payloads
|
|
266 |
+ |
/// were sealed with empty AAD. The tag is read *before* decryption, so the
|
|
267 |
+ |
/// reader always knows which AAD to apply — no structural guessing.
|
|
268 |
+ |
///
|
|
269 |
+ |
/// `:` is not in the base64 alphabet, so a tagged base64 string is
|
|
270 |
+ |
/// unambiguous against a legacy one.
|
|
271 |
+ |
const WIRE_V2_TAG: &str = "sk2:";
|
|
272 |
+ |
/// Raw-bytes form of [`WIRE_V2_TAG`] for blob payloads.
|
|
273 |
+ |
const WIRE_V2_TAG_BYTES: &[u8] = b"sk2:";
|
|
274 |
+ |
|
|
275 |
+ |
/// Canonical associated-data encoding binding an entry's ciphertext to its
|
|
276 |
+ |
/// `(table, row_id)` address. A server that relocates a ciphertext to a
|
|
277 |
+ |
/// different row or table changes the AAD, so decryption fails closed — the
|
|
278 |
+ |
/// AEAD tag no longer authenticates the moved bytes.
|
|
279 |
+ |
///
|
|
280 |
+ |
/// The unit separator (`0x1f`) cannot appear in the encoded form of either
|
|
281 |
+ |
/// component's length boundary ambiguously, so `("a", "bc")` and `("ab", "c")`
|
|
282 |
+ |
/// produce distinct AAD.
|
|
283 |
+ |
pub fn aad_for_entry(table: &str, row_id: &str) -> Vec<u8> {
|
|
284 |
+ |
let mut aad = Vec::with_capacity(table.len() + row_id.len() + 1);
|
|
285 |
+ |
aad.extend_from_slice(table.as_bytes());
|
|
286 |
+ |
aad.push(0x1f);
|
|
287 |
+ |
aad.extend_from_slice(row_id.as_bytes());
|
|
288 |
+ |
aad
|
|
289 |
+ |
}
|
|
290 |
+ |
|
|
291 |
+ |
/// AEAD seal: returns `nonce[24] || ciphertext || poly1305_tag[16]`.
|
|
292 |
+ |
fn seal(plaintext: &[u8], master_key: &[u8; KEY_SIZE], aad: &[u8]) -> Result<Vec<u8>> {
|
| 235 |
293 |
|
let mut nonce_bytes = [0u8; NONCE_SIZE];
|
| 236 |
294 |
|
rand::rng().fill_bytes(&mut nonce_bytes);
|
| 237 |
295 |
|
|
| 239 |
297 |
|
let nonce = XNonce::from_slice(&nonce_bytes);
|
| 240 |
298 |
|
|
| 241 |
299 |
|
let ciphertext = cipher
|
| 242 |
|
- |
.encrypt(nonce, plaintext)
|
|
300 |
+ |
.encrypt(nonce, Payload { msg: plaintext, aad })
|
| 243 |
301 |
|
.map_err(|e| SyncKitError::Crypto(format!("encrypt: {e}")))?;
|
| 244 |
302 |
|
|
| 245 |
|
- |
// Wire format: nonce || ciphertext (which includes poly1305 tag)
|
| 246 |
303 |
|
let mut blob = Vec::with_capacity(NONCE_SIZE + ciphertext.len());
|
| 247 |
304 |
|
blob.extend_from_slice(&nonce_bytes);
|
| 248 |
305 |
|
blob.extend_from_slice(&ciphertext);
|
| 249 |
|
- |
|
| 250 |
|
- |
Ok(B64.encode(blob))
|
|
306 |
+ |
Ok(blob)
|
| 251 |
307 |
|
}
|
| 252 |
308 |
|
|
| 253 |
|
- |
/// Decrypt a data entry with the master key.
|
| 254 |
|
- |
/// Input is base64(nonce[24] || ciphertext || poly1305_tag[16]).
|
| 255 |
|
- |
pub fn decrypt_data(
|
| 256 |
|
- |
encoded: &str,
|
| 257 |
|
- |
master_key: &[u8; KEY_SIZE],
|
| 258 |
|
- |
) -> Result<Vec<u8>> {
|
| 259 |
|
- |
let blob = B64.decode(encoded)?;
|
| 260 |
|
- |
|
|
309 |
+ |
/// AEAD open of a `nonce || ciphertext || tag` blob with the given AAD.
|
|
310 |
+ |
fn open(blob: &[u8], master_key: &[u8; KEY_SIZE], aad: &[u8]) -> Result<Vec<u8>> {
|
| 261 |
311 |
|
if blob.len() < NONCE_SIZE + 16 {
|
| 262 |
312 |
|
// Minimum: nonce + poly1305 tag (empty plaintext)
|
| 263 |
|
- |
return Err(SyncKitError::Crypto(
|
| 264 |
|
- |
"ciphertext too short".into(),
|
| 265 |
|
- |
));
|
|
313 |
+ |
return Err(SyncKitError::Crypto("ciphertext too short".into()));
|
| 266 |
314 |
|
}
|
| 267 |
|
- |
|
| 268 |
315 |
|
let (nonce_bytes, ciphertext) = blob.split_at(NONCE_SIZE);
|
| 269 |
316 |
|
let cipher = XChaCha20Poly1305::new(master_key.into());
|
| 270 |
317 |
|
let nonce = XNonce::from_slice(nonce_bytes);
|
| 271 |
|
- |
|
| 272 |
318 |
|
cipher
|
| 273 |
|
- |
.decrypt(nonce, ciphertext)
|
|
319 |
+ |
.decrypt(nonce, Payload { msg: ciphertext, aad })
|
| 274 |
320 |
|
.map_err(|_| SyncKitError::DecryptionFailed)
|
| 275 |
321 |
|
}
|
| 276 |
322 |
|
|
| 277 |
|
- |
/// Encrypt raw bytes with the master key.
|
| 278 |
|
- |
/// Returns `nonce[24] || ciphertext || poly1305_tag[16]` as raw bytes (no base64).
|
| 279 |
|
- |
/// Use this for blob data where base64 overhead is undesirable.
|
| 280 |
|
- |
pub fn encrypt_bytes(
|
|
323 |
+ |
/// Encrypt a data entry with the master key (legacy, untagged, empty AAD).
|
|
324 |
+ |
/// Returns base64(nonce[24] || ciphertext || poly1305_tag[16]). Prefer
|
|
325 |
+ |
/// [`encrypt_data_aad`] for new writes so the ciphertext is position-bound.
|
|
326 |
+ |
pub fn encrypt_data(plaintext: &[u8], master_key: &[u8; KEY_SIZE]) -> Result<String> {
|
|
327 |
+ |
Ok(B64.encode(seal(plaintext, master_key, &[])?))
|
|
328 |
+ |
}
|
|
329 |
+ |
|
|
330 |
+ |
/// Decrypt a legacy (untagged, empty-AAD) data entry.
|
|
331 |
+ |
/// Input is base64(nonce[24] || ciphertext || poly1305_tag[16]).
|
|
332 |
+ |
pub fn decrypt_data(encoded: &str, master_key: &[u8; KEY_SIZE]) -> Result<Vec<u8>> {
|
|
333 |
+ |
open(&B64.decode(encoded)?, master_key, &[])
|
|
334 |
+ |
}
|
|
335 |
+ |
|
|
336 |
+ |
/// Encrypt a data entry, binding `aad`, and emit the [`WIRE_V2_TAG`]-prefixed form.
|
|
337 |
+ |
pub fn encrypt_data_aad(
|
| 281 |
338 |
|
plaintext: &[u8],
|
| 282 |
339 |
|
master_key: &[u8; KEY_SIZE],
|
| 283 |
|
- |
) -> Result<Vec<u8>> {
|
| 284 |
|
- |
let mut nonce_bytes = [0u8; NONCE_SIZE];
|
| 285 |
|
- |
rand::rng().fill_bytes(&mut nonce_bytes);
|
|
340 |
+ |
aad: &[u8],
|
|
341 |
+ |
) -> Result<String> {
|
|
342 |
+ |
Ok(format!("{WIRE_V2_TAG}{}", B64.encode(seal(plaintext, master_key, aad)?)))
|
|
343 |
+ |
}
|
| 286 |
344 |
|
|
| 287 |
|
- |
let cipher = XChaCha20Poly1305::new(master_key.into());
|
| 288 |
|
- |
let nonce = XNonce::from_slice(&nonce_bytes);
|
|
345 |
+ |
/// Decrypt a data entry, auto-detecting the wire version. A `sk2:`-tagged
|
|
346 |
+ |
/// payload is opened with `aad`; an untagged (legacy) payload is opened with
|
|
347 |
+ |
/// empty AAD. This lets v1 and v2 ciphertext coexist with no flag-day.
|
|
348 |
+ |
pub fn decrypt_data_aad(
|
|
349 |
+ |
encoded: &str,
|
|
350 |
+ |
master_key: &[u8; KEY_SIZE],
|
|
351 |
+ |
aad: &[u8],
|
|
352 |
+ |
) -> Result<Vec<u8>> {
|
|
353 |
+ |
match encoded.strip_prefix(WIRE_V2_TAG) {
|
|
354 |
+ |
Some(rest) => open(&B64.decode(rest)?, master_key, aad),
|
|
355 |
+ |
None => open(&B64.decode(encoded)?, master_key, &[]),
|
|
356 |
+ |
}
|
|
357 |
+ |
}
|
| 289 |
358 |
|
|
| 290 |
|
- |
let ciphertext = cipher
|
| 291 |
|
- |
.encrypt(nonce, plaintext)
|
| 292 |
|
- |
.map_err(|e| SyncKitError::Crypto(format!("encrypt: {e}")))?;
|
|
359 |
+ |
/// Encrypt raw bytes with the master key (legacy, untagged, empty AAD).
|
|
360 |
+ |
/// Returns `nonce[24] || ciphertext || poly1305_tag[16]` as raw bytes (no base64).
|
|
361 |
+ |
pub fn encrypt_bytes(plaintext: &[u8], master_key: &[u8; KEY_SIZE]) -> Result<Vec<u8>> {
|
|
362 |
+ |
seal(plaintext, master_key, &[])
|
|
363 |
+ |
}
|
| 293 |
364 |
|
|
| 294 |
|
- |
let mut blob = Vec::with_capacity(NONCE_SIZE + ciphertext.len());
|
| 295 |
|
- |
blob.extend_from_slice(&nonce_bytes);
|
| 296 |
|
- |
blob.extend_from_slice(&ciphertext);
|
|
365 |
+ |
/// Decrypt legacy (untagged, empty-AAD) raw bytes.
|
|
366 |
+ |
pub fn decrypt_bytes(encrypted: &[u8], master_key: &[u8; KEY_SIZE]) -> Result<Vec<u8>> {
|
|
367 |
+ |
open(encrypted, master_key, &[])
|
|
368 |
+ |
}
|
| 297 |
369 |
|
|
| 298 |
|
- |
Ok(blob)
|
|
370 |
+ |
/// Encrypt raw bytes binding `aad`, emitting the `sk2:`-prefixed raw form.
|
|
371 |
+ |
/// Used for blobs, where `aad` is the content hash.
|
|
372 |
+ |
pub fn encrypt_bytes_aad(
|
|
373 |
+ |
plaintext: &[u8],
|
|
374 |
+ |
master_key: &[u8; KEY_SIZE],
|
|
375 |
+ |
aad: &[u8],
|
|
376 |
+ |
) -> Result<Vec<u8>> {
|
|
377 |
+ |
let sealed = seal(plaintext, master_key, aad)?;
|
|
378 |
+ |
let mut out = Vec::with_capacity(WIRE_V2_TAG_BYTES.len() + sealed.len());
|
|
379 |
+ |
out.extend_from_slice(WIRE_V2_TAG_BYTES);
|
|
380 |
+ |
out.extend_from_slice(&sealed);
|
|
381 |
+ |
Ok(out)
|
| 299 |
382 |
|
}
|
| 300 |
383 |
|
|
| 301 |
|
- |
/// Decrypt raw bytes with the master key.
|
| 302 |
|
- |
/// Input is `nonce[24] || ciphertext || poly1305_tag[16]`.
|
| 303 |
|
- |
pub fn decrypt_bytes(
|
|
384 |
+ |
/// Decrypt raw bytes, auto-detecting the wire version. A `sk2:`-tagged blob is
|
|
385 |
+ |
/// opened with `aad`; an untagged (legacy) blob is opened with empty AAD.
|
|
386 |
+ |
pub fn decrypt_bytes_aad(
|
| 304 |
387 |
|
encrypted: &[u8],
|
| 305 |
388 |
|
master_key: &[u8; KEY_SIZE],
|
|
389 |
+ |
aad: &[u8],
|
| 306 |
390 |
|
) -> Result<Vec<u8>> {
|
| 307 |
|
- |
if encrypted.len() < NONCE_SIZE + 16 {
|
| 308 |
|
- |
return Err(SyncKitError::Crypto(
|
| 309 |
|
- |
"ciphertext too short".into(),
|
| 310 |
|
- |
));
|
|
391 |
+ |
match encrypted.strip_prefix(WIRE_V2_TAG_BYTES) {
|
|
392 |
+ |
Some(rest) => open(rest, master_key, aad),
|
|
393 |
+ |
None => open(encrypted, master_key, &[]),
|
| 311 |
394 |
|
}
|
| 312 |
|
- |
|
| 313 |
|
- |
let (nonce_bytes, ciphertext) = encrypted.split_at(NONCE_SIZE);
|
| 314 |
|
- |
let cipher = XChaCha20Poly1305::new(master_key.into());
|
| 315 |
|
- |
let nonce = XNonce::from_slice(nonce_bytes);
|
| 316 |
|
- |
|
| 317 |
|
- |
cipher
|
| 318 |
|
- |
.decrypt(nonce, ciphertext)
|
| 319 |
|
- |
.map_err(|_| SyncKitError::DecryptionFailed)
|
| 320 |
395 |
|
}
|
| 321 |
396 |
|
|
| 322 |
397 |
|
/// Encryption overhead in bytes (24-byte nonce + 16-byte Poly1305 tag).
|
| 323 |
398 |
|
pub const ENCRYPTION_OVERHEAD: usize = NONCE_SIZE + 16;
|
| 324 |
399 |
|
|
| 325 |
|
- |
/// Encrypt a JSON value, returning a JSON string suitable for the `data` field.
|
|
400 |
+ |
/// Encrypt a JSON value (legacy, untagged, empty AAD). Prefer [`encrypt_json_aad`].
|
| 326 |
401 |
|
pub fn encrypt_json(
|
| 327 |
402 |
|
value: &serde_json::Value,
|
| 328 |
403 |
|
master_key: &[u8; KEY_SIZE],
|
| 334 |
409 |
|
Ok(serde_json::Value::String(encrypted?))
|
| 335 |
410 |
|
}
|
| 336 |
411 |
|
|
| 337 |
|
- |
/// Decrypt a JSON string from the `data` field back into the original value.
|
|
412 |
+ |
/// Decrypt a legacy (untagged, empty-AAD) JSON string from the `data` field.
|
| 338 |
413 |
|
pub fn decrypt_json(
|
| 339 |
414 |
|
encrypted_value: &serde_json::Value,
|
| 340 |
415 |
|
master_key: &[u8; KEY_SIZE],
|
| 341 |
416 |
|
) -> Result<serde_json::Value> {
|
|
417 |
+ |
decrypt_json_aad(encrypted_value, master_key, &[])
|
|
418 |
+ |
}
|
|
419 |
+ |
|
|
420 |
+ |
/// Encrypt a JSON value binding `aad`, emitting the `sk2:`-tagged form.
|
|
421 |
+ |
pub fn encrypt_json_aad(
|
|
422 |
+ |
value: &serde_json::Value,
|
|
423 |
+ |
master_key: &[u8; KEY_SIZE],
|
|
424 |
+ |
aad: &[u8],
|
|
425 |
+ |
) -> Result<serde_json::Value> {
|
|
426 |
+ |
use zeroize::Zeroize;
|
|
427 |
+ |
let mut plaintext = serde_json::to_vec(value)?;
|
|
428 |
+ |
let encrypted = encrypt_data_aad(&plaintext, master_key, aad);
|
|
429 |
+ |
plaintext.zeroize();
|
|
430 |
+ |
Ok(serde_json::Value::String(encrypted?))
|
|
431 |
+ |
}
|
|
432 |
+ |
|
|
433 |
+ |
/// Decrypt a JSON string from the `data` field, auto-detecting the wire
|
|
434 |
+ |
/// version (tagged → use `aad`, untagged → empty AAD).
|
|
435 |
+ |
pub fn decrypt_json_aad(
|
|
436 |
+ |
encrypted_value: &serde_json::Value,
|
|
437 |
+ |
master_key: &[u8; KEY_SIZE],
|
|
438 |
+ |
aad: &[u8],
|
|
439 |
+ |
) -> Result<serde_json::Value> {
|
| 342 |
440 |
|
use zeroize::Zeroize;
|
| 343 |
441 |
|
let encoded = encrypted_value
|
| 344 |
442 |
|
.as_str()
|
| 345 |
443 |
|
.ok_or_else(|| SyncKitError::Crypto("data field is not a string".into()))?;
|
| 346 |
444 |
|
|
| 347 |
|
- |
let mut plaintext = decrypt_data(encoded, master_key)?;
|
|
445 |
+ |
let mut plaintext = decrypt_data_aad(encoded, master_key, aad)?;
|
| 348 |
446 |
|
let result = serde_json::from_slice(&plaintext);
|
| 349 |
447 |
|
plaintext.zeroize();
|
| 350 |
448 |
|
result.map_err(Into::into)
|
| 785 |
883 |
|
salt: B64.encode([0u8; 16]), // 16 bytes, should be 32
|
| 786 |
884 |
|
nonce: B64.encode([0u8; NONCE_SIZE]),
|
| 787 |
885 |
|
ciphertext: B64.encode([0u8; 48]),
|
|
886 |
+ |
m: ARGON2_MEM_COST_KB,
|
|
887 |
+ |
t: ARGON2_TIME_COST,
|
|
888 |
+ |
p: ARGON2_PARALLELISM,
|
| 788 |
889 |
|
};
|
| 789 |
890 |
|
let json = serde_json::to_string(&envelope).unwrap();
|
| 790 |
891 |
|
|
| 803 |
904 |
|
salt: B64.encode([0u8; 32]),
|
| 804 |
905 |
|
nonce: B64.encode([0u8; 12]), // 12 bytes, should be 24
|
| 805 |
906 |
|
ciphertext: B64.encode([0u8; 48]),
|
|
907 |
+ |
m: ARGON2_MEM_COST_KB,
|
|
908 |
+ |
t: ARGON2_TIME_COST,
|
|
909 |
+ |
p: ARGON2_PARALLELISM,
|
| 806 |
910 |
|
};
|
| 807 |
911 |
|
let json = serde_json::to_string(&envelope).unwrap();
|
| 808 |
912 |
|
|
| 1046 |
1150 |
|
|
| 1047 |
1151 |
|
assert_eq!(decrypted, plaintext);
|
| 1048 |
1152 |
|
}
|
|
1153 |
+ |
|
|
1154 |
+ |
// ── AAD binding + wire version tag (v2) ──
|
|
1155 |
+ |
|
|
1156 |
+ |
#[test]
|
|
1157 |
+ |
fn aad_for_entry_is_injective_across_boundary() {
|
|
1158 |
+ |
// ("a","bc") and ("ab","c") must not collide, or a server could swap
|
|
1159 |
+ |
// table/row_id halves and keep the AAD constant.
|
|
1160 |
+ |
assert_ne!(aad_for_entry("a", "bc"), aad_for_entry("ab", "c"));
|
|
1161 |
+ |
assert_eq!(aad_for_entry("tasks", "r1"), aad_for_entry("tasks", "r1"));
|
|
1162 |
+ |
}
|
|
1163 |
+ |
|
|
1164 |
+ |
#[test]
|
|
1165 |
+ |
fn v2_data_is_tagged_and_roundtrips() {
|
|
1166 |
+ |
let key = generate_master_key();
|
|
1167 |
+ |
let aad = aad_for_entry("tasks", "row-1");
|
|
1168 |
+ |
let wire = encrypt_data_aad(b"hello", &key, &aad).unwrap();
|
|
1169 |
+ |
assert!(wire.starts_with("sk2:"), "v2 payload must carry the wire tag: {wire}");
|
|
1170 |
+ |
let pt = decrypt_data_aad(&wire, &key, &aad).unwrap();
|
|
1171 |
+ |
assert_eq!(pt, b"hello");
|
|
1172 |
+ |
}
|
|
1173 |
+ |
|
|
1174 |
+ |
#[test]
|
|
1175 |
+ |
fn v2_relocation_to_different_row_fails_closed() {
|
|
1176 |
+ |
// The headline X1 guarantee: a ciphertext sealed for (tasks,row-1)
|
|
1177 |
+ |
// must not decrypt when the server presents it under (tasks,row-2).
|
|
1178 |
+ |
let key = generate_master_key();
|
|
1179 |
+ |
let sealed = encrypt_data_aad(b"secret", &key, &aad_for_entry("tasks", "row-1")).unwrap();
|
|
1180 |
+ |
let relocated = decrypt_data_aad(&sealed, &key, &aad_for_entry("tasks", "row-2"));
|
|
1181 |
+ |
assert!(matches!(relocated, Err(SyncKitError::DecryptionFailed)));
|
|
1182 |
+ |
let relocated_table = decrypt_data_aad(&sealed, &key, &aad_for_entry("notes", "row-1"));
|
|
1183 |
+ |
assert!(matches!(relocated_table, Err(SyncKitError::DecryptionFailed)));
|
|
1184 |
+ |
}
|
|
1185 |
+ |
|
|
1186 |
+ |
#[test]
|
|
1187 |
+ |
fn legacy_untagged_still_decrypts_under_aad_reader() {
|
|
1188 |
+ |
// A v1 (untagged, empty-AAD) payload must keep decrypting through the
|
|
1189 |
+ |
// tag-aware reader, regardless of the AAD passed — no flag-day.
|
|
1190 |
+ |
let key = generate_master_key();
|
|
1191 |
+ |
let legacy = encrypt_data(b"old data", &key).unwrap();
|
|
1192 |
+ |
assert!(!legacy.starts_with("sk2:"));
|
|
1193 |
+ |
let pt = decrypt_data_aad(&legacy, &key, &aad_for_entry("tasks", "row-1")).unwrap();
|
|
1194 |
+ |
assert_eq!(pt, b"old data");
|
|
1195 |
+ |
}
|
|
1196 |
+ |
|
|
1197 |
+ |
#[test]
|
|
1198 |
+ |
fn v2_json_roundtrip_and_relocation_fails() {
|
|
1199 |
+ |
let key = generate_master_key();
|
|
1200 |
+ |
let value = serde_json::json!({"title": "Buy milk"});
|
|
1201 |
+ |
let aad = aad_for_entry("tasks", "row-1");
|
|
1202 |
+ |
let wire = encrypt_json_aad(&value, &key, &aad).unwrap();
|
|
1203 |
+ |
assert!(wire.as_str().unwrap().starts_with("sk2:"));
|
|
1204 |
+ |
assert_eq!(decrypt_json_aad(&wire, &key, &aad).unwrap(), value);
|
|
1205 |
+ |
let moved = decrypt_json_aad(&wire, &key, &aad_for_entry("tasks", "row-2"));
|
|
1206 |
+ |
assert!(moved.is_err());
|
|
1207 |
+ |
}
|
|
1208 |
+ |
|
|
1209 |
+ |
#[test]
|
|
1210 |
+ |
fn v2_bytes_tagged_roundtrip_and_relocation_fails() {
|
|
1211 |
+ |
let key = generate_master_key();
|
|
1212 |
+ |
let hash_a = b"sha256-aaa";
|
|
1213 |
+ |
let hash_b = b"sha256-bbb";
|
|
1214 |
+ |
let blob = encrypt_bytes_aad(b"blob bytes", &key, hash_a).unwrap();
|
|
1215 |
+ |
assert!(blob.starts_with(b"sk2:"), "v2 blob must carry the raw tag");
|
|
1216 |
+ |
assert_eq!(decrypt_bytes_aad(&blob, &key, hash_a).unwrap(), b"blob bytes");
|
|
1217 |
+ |
// Substituting a different hash's AAD fails closed.
|
|
1218 |
+ |
assert!(matches!(
|
|
1219 |
+ |
decrypt_bytes_aad(&blob, &key, hash_b),
|
|
1220 |
+ |
Err(SyncKitError::DecryptionFailed)
|
|
1221 |
+ |
));
|
|
1222 |
+ |
}
|
|
1223 |
+ |
|
|
1224 |
+ |
#[test]
|
|
1225 |
+ |
fn legacy_bytes_still_decrypt_under_aad_reader() {
|
|
1226 |
+ |
let key = generate_master_key();
|
|
1227 |
+ |
let legacy = encrypt_bytes(b"old blob", &key).unwrap();
|
|
1228 |
+ |
assert!(!legacy.starts_with(b"sk2:"));
|
|
1229 |
+ |
let pt = decrypt_bytes_aad(&legacy, &key, b"sha256-whatever").unwrap();
|
|
1230 |
+ |
assert_eq!(pt, b"old blob");
|
|
1231 |
+ |
}
|
| 1049 |
1232 |
|
}
|