fix(sync): secure encrypted snapshot lifecycle

This commit is contained in:
2026-07-10 06:24:53 -04:00
parent 556c5ff624
commit 540b901fd6
106 changed files with 18026 additions and 3309 deletions
@@ -0,0 +1,202 @@
use super::*;
const CONTEXT: SnapshotCryptoContext<'static> = SnapshotCryptoContext {
user_id: "user-01",
vault_generation: 1,
snapshot_id: "device-01",
schema_rev: 1,
logical_clock: 42,
device_id: "device-01",
head_revision: 1,
base_head: None,
};
#[test]
fn snapshot_encryption_round_trips_and_hides_plaintext() -> Result<(), SyncClientError> {
let key = AccountKey::from_bytes([7; 32]);
let plaintext = br#"{"tabs":[{"url":"https://private.example"}]}"#;
let encrypted = key.encrypt(&CONTEXT, plaintext)?;
assert!(!encrypted.bytes().windows(plaintext.len()).any(|window| window == plaintext));
assert_eq!(
key.decrypt(
&CONTEXT,
SNAPSHOT_ENCRYPTION_VERSION,
encrypted.key_id(),
encrypted.content_hash(),
encrypted.bytes(),
)?,
plaintext
);
Ok(())
}
#[test]
fn snapshot_encryption_uses_fresh_nonces_and_stable_keyed_content_hashes()
-> Result<(), SyncClientError> {
let key = AccountKey::from_bytes([9; 32]);
let first = key.encrypt(&CONTEXT, b"same payload")?;
let second = key.encrypt(&CONTEXT, b"same payload")?;
assert_ne!(first.bytes(), second.bytes());
assert_eq!(first.content_hash(), second.content_hash());
assert_ne!(
first.content_hash(),
AccountKey::from_bytes([10; 32]).content_hash(b"same payload")?
);
Ok(())
}
#[test]
fn snapshot_authentication_rejects_metadata_and_ciphertext_tampering() -> Result<(), SyncClientError>
{
let key = AccountKey::from_bytes([11; 32]);
let encrypted = key.encrypt(&CONTEXT, b"authenticated payload")?;
let changed_context = SnapshotCryptoContext { logical_clock: 43, ..CONTEXT };
assert!(
key.decrypt(
&changed_context,
SNAPSHOT_ENCRYPTION_VERSION,
encrypted.key_id(),
encrypted.content_hash(),
encrypted.bytes(),
)
.is_err()
);
let mut tampered = encrypted.bytes().to_vec();
let last = tampered.len() - 1;
tampered[last] ^= 1;
assert!(
key.decrypt(
&CONTEXT,
SNAPSHOT_ENCRYPTION_VERSION,
encrypted.key_id(),
encrypted.content_hash(),
&tampered,
)
.is_err()
);
Ok(())
}
#[test]
fn snapshot_authentication_binds_every_routing_field() -> Result<(), SyncClientError> {
let key = AccountKey::from_bytes([13; 32]);
let encrypted = key.encrypt(&CONTEXT, b"routing metadata")?;
let changed = [
SnapshotCryptoContext { user_id: "user-02", ..CONTEXT },
SnapshotCryptoContext { vault_generation: 2, ..CONTEXT },
SnapshotCryptoContext { snapshot_id: "device-02", ..CONTEXT },
SnapshotCryptoContext { schema_rev: 2, ..CONTEXT },
SnapshotCryptoContext { logical_clock: 41, ..CONTEXT },
SnapshotCryptoContext { device_id: "device-02", ..CONTEXT },
];
for context in changed {
assert!(
key.decrypt(
&context,
SNAPSHOT_ENCRYPTION_VERSION,
encrypted.key_id(),
encrypted.content_hash(),
encrypted.bytes(),
)
.is_err()
);
}
Ok(())
}
#[test]
fn snapshot_authentication_binds_head_lineage() -> Result<(), SyncClientError> {
let key = AccountKey::from_bytes([14; 32]);
let base = SnapshotHeadRef::new(7, "device-02", "31".repeat(32))?;
let context = SnapshotCryptoContext { head_revision: 8, base_head: Some(&base), ..CONTEXT };
let encrypted = key.encrypt(&context, b"head lineage")?;
for changed_base in [
SnapshotHeadRef::new(6, "device-02", "31".repeat(32))?,
SnapshotHeadRef::new(7, "device-03", "31".repeat(32))?,
SnapshotHeadRef::new(7, "device-02", "32".repeat(32))?,
] {
let changed = SnapshotCryptoContext {
head_revision: changed_base.revision + 1,
base_head: Some(&changed_base),
..CONTEXT
};
assert!(
key.decrypt(
&changed,
SNAPSHOT_ENCRYPTION_VERSION,
encrypted.key_id(),
encrypted.content_hash(),
encrypted.bytes(),
)
.is_err()
);
}
let changed_revision = SnapshotCryptoContext { head_revision: 9, ..context };
assert!(
key.decrypt(
&changed_revision,
SNAPSHOT_ENCRYPTION_VERSION,
encrypted.key_id(),
encrypted.content_hash(),
encrypted.bytes(),
)
.is_err()
);
Ok(())
}
#[test]
fn legacy_v1_aad_remains_decryptable() -> Result<(), SyncClientError> {
let key = AccountKey::from_bytes([16; 32]);
let encrypted = key.encrypt_with_version(&CONTEXT, b"legacy snapshot", 1)?;
assert_eq!(
key.decrypt(&CONTEXT, 1, encrypted.key_id(), encrypted.content_hash(), encrypted.bytes(),)?,
b"legacy snapshot"
);
Ok(())
}
#[test]
fn snapshot_envelope_rejects_unknown_versions_and_wrong_keys() -> Result<(), SyncClientError> {
let key = AccountKey::from_bytes([15; 32]);
let encrypted = key.encrypt(&CONTEXT, b"versioned payload")?;
assert!(
key.decrypt(
&CONTEXT,
SNAPSHOT_ENCRYPTION_VERSION + 1,
encrypted.key_id(),
encrypted.content_hash(),
encrypted.bytes(),
)
.is_err()
);
assert!(
AccountKey::from_bytes([16; 32])
.decrypt(
&CONTEXT,
SNAPSHOT_ENCRYPTION_VERSION,
encrypted.key_id(),
encrypted.content_hash(),
encrypted.bytes(),
)
.is_err()
);
Ok(())
}
#[test]
fn snapshot_envelope_honors_the_transport_size_limit() -> Result<(), SyncClientError> {
let key = AccountKey::from_bytes([17; 32]);
let maximum = vec![0_u8; MAX_PLAINTEXT_BYTES];
let encrypted = key.encrypt(&CONTEXT, &maximum)?;
assert_eq!(encrypted.bytes().len(), MAX_SNAPSHOT_BYTES);
assert!(key.encrypt(&CONTEXT, &vec![0_u8; MAX_PLAINTEXT_BYTES + 1]).is_err());
Ok(())
}