fix: validate legacy JSON before migrating encrypted state
This commit is contained in:
@@ -75,11 +75,15 @@ pub fn open(data: &[u8], key: &[u8; 32]) -> Result<Vec<u8>> {
|
|||||||
.map_err(|_| anyhow::anyhow!("decryption failed — key mismatch or corruption"))
|
.map_err(|_| anyhow::anyhow!("decryption failed — key mismatch or corruption"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Heuristic: does this look like legacy plaintext JSON (starts with `{`/`[`)?
|
/// Recognize a complete legacy JSON object/array, including leading whitespace.
|
||||||
/// Encrypted blobs start with a random nonce byte, so a `{`/`[` first byte is a
|
/// A random nonce can start with `{` or `[`; checking only that byte misclassifies
|
||||||
/// reliable migration signal.
|
/// valid ciphertext and can trigger an empty-store migration. Validate the entire
|
||||||
|
/// document without allocating a second copy of the store's object tree.
|
||||||
pub fn is_plaintext_json(raw: &[u8]) -> bool {
|
pub fn is_plaintext_json(raw: &[u8]) -> bool {
|
||||||
matches!(raw.first(), Some(b'{') | Some(b'['))
|
matches!(
|
||||||
|
raw.iter().copied().find(|byte| !byte.is_ascii_whitespace()),
|
||||||
|
Some(b'{') | Some(b'[')
|
||||||
|
) && serde_json::from_slice::<serde::de::IgnoredAny>(raw).is_ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -110,9 +114,44 @@ mod tests {
|
|||||||
fn detects_plaintext_vs_ciphertext() {
|
fn detects_plaintext_vs_ciphertext() {
|
||||||
assert!(is_plaintext_json(b"{\"a\":1}"));
|
assert!(is_plaintext_json(b"{\"a\":1}"));
|
||||||
assert!(is_plaintext_json(b"[]"));
|
assert!(is_plaintext_json(b"[]"));
|
||||||
|
assert!(is_plaintext_json(b" \r\n\t{\"messages\": []}\n"));
|
||||||
|
for invalid in [
|
||||||
|
b"{".as_slice(),
|
||||||
|
b"[",
|
||||||
|
b"{}trailing",
|
||||||
|
b"[\xff]",
|
||||||
|
b"null",
|
||||||
|
b"\"text\"",
|
||||||
|
] {
|
||||||
|
assert!(!is_plaintext_json(invalid));
|
||||||
|
}
|
||||||
assert!(!is_plaintext_json(&seal(b"x", &[3u8; 32]).unwrap()));
|
assert!(!is_plaintext_json(&seal(b"x", &[3u8; 32]).unwrap()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn json_prefix_nonce_does_not_trigger_plaintext_migration() {
|
||||||
|
use chacha20poly1305::aead::{Aead, KeyInit};
|
||||||
|
let key = [3u8; 32];
|
||||||
|
let plaintext = br#"{"messages":[{"message":"preserve me"}]}"#;
|
||||||
|
let cipher = chacha20poly1305::ChaCha20Poly1305::new_from_slice(&key).unwrap();
|
||||||
|
// Deterministically reproduce both collisions instead of relying on
|
||||||
|
// OsRng to happen to pick one during a test run.
|
||||||
|
for prefix in [b'{', b'['] {
|
||||||
|
let mut nonce = [0xffu8; 12];
|
||||||
|
nonce[0] = prefix;
|
||||||
|
let encrypted = cipher
|
||||||
|
.encrypt(
|
||||||
|
chacha20poly1305::aead::generic_array::GenericArray::from_slice(&nonce),
|
||||||
|
plaintext.as_slice(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let mut envelope = nonce.to_vec();
|
||||||
|
envelope.extend(encrypted);
|
||||||
|
assert!(!is_plaintext_json(&envelope));
|
||||||
|
assert_eq!(open(&envelope, &key).unwrap(), plaintext);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// KEY-05 regression: a blob written by the pre-migration `seal` must still
|
/// KEY-05 regression: a blob written by the pre-migration `seal` must still
|
||||||
/// open after the migration.
|
/// open after the migration.
|
||||||
///
|
///
|
||||||
|
|||||||
Reference in New Issue
Block a user