fix(sync): secure encrypted snapshot lifecycle
This commit is contained in:
@@ -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(())
|
||||
}
|
||||
Reference in New Issue
Block a user