//! Envelope setup, password change, and server-key presence. //! //! `setup_encryption_new`/`_existing` are how a device gets the master key, and //! `change_password` re-wraps it. These are the paths where a wrong answer costs //! the user their data, so the negative cases outnumber the positive ones. use crate::common::*; const KEYS_PATH: &str = "/api/v1/sync/keys"; /// The `GET /keys` body: a master key wrapped under `password`. fn envelope_body(envelope: &str) -> serde_json::Value { json!({ "encrypted_key": envelope }) } /// The envelope from the `n`th `PUT /keys` the client sent. async fn uploaded_envelope(kit: &MockKit, n: usize) -> String { let bodies = kit.bodies("PUT", KEYS_PATH).await; let body = bodies .get(n) .unwrap_or_else(|| panic!("expected at least {} PUTs to {KEYS_PATH}", n + 1)); body["encrypted_key"] .as_str() .expect("the PUT body carries an encrypted_key") .to_string() } // ── Key management ── #[tokio::test] async fn has_server_key_true_on_200() { let kit = MockKit::start().await; kit.get(KEYS_PATH) .json(envelope_body("envelope-data")) .await; assert!(kit.authed().has_server_key().await.unwrap()); } #[tokio::test] async fn has_server_key_false_on_404() { let kit = MockKit::start().await; kit.get(KEYS_PATH).code(404).empty().await; assert!(!kit.authed().has_server_key().await.unwrap()); } #[tokio::test] async fn has_server_key_retries_on_500() { let kit = MockKit::start().await; kit.get(KEYS_PATH) .code(500) .once() .text("Internal Server Error") .await; kit.get(KEYS_PATH).json(envelope_body("envelope")).await; assert!(kit.authed().has_server_key().await.unwrap()); } // ── change_password: CRITICAL bug fix tests ── /// A logged-in client holding its master key, plus that key and the envelope /// wrapping it under `password`. The state a real device is in when the user /// changes their password. fn cached_key_client(kit: &MockKit, password: &str) -> (SyncKitClient, [u8; 32], String) { let (client, master_key) = kit.keyed(); let envelope = synckit_client::crypto::wrap_master_key(&master_key, password).unwrap(); (client, master_key, envelope) } #[tokio::test] async fn change_password_wrong_old_password_with_cached_key_fails() { let kit = MockKit::start().await; let (client, _master_key, envelope) = cached_key_client(&kit, "correct-old-pass"); kit.get(KEYS_PATH).json(envelope_body(&envelope)).await; // Attempt to change password with wrong old password. // The key IS cached, but the old password must still be validated. let result = client.change_password("wrong-old-pass", "new-pass").await; assert!( result.is_err(), "change_password must fail when old_password is wrong, even with cached key" ); assert!( matches!(result.unwrap_err(), SyncKitError::DecryptionFailed), "Should get DecryptionFailed for wrong old password" ); } #[tokio::test] async fn change_password_correct_old_password_with_cached_key_succeeds() { let kit = MockKit::start().await; let (client, master_key, envelope) = cached_key_client(&kit, "correct-old-pass"); kit.get(KEYS_PATH).json(envelope_body(&envelope)).await; kit.put(KEYS_PATH).empty().await; let result = client.change_password("correct-old-pass", "new-pass").await; assert!( result.is_ok(), "change_password should succeed with correct old password" ); // Exactly one PUT: the new envelope was uploaded, once. let puts = kit.bodies("PUT", KEYS_PATH).await; assert_eq!(puts.len(), 1, "Should have sent exactly one PUT"); // Verify the new envelope can be unwrapped with the new password let new_envelope = uploaded_envelope(&kit, 0).await; let recovered = synckit_client::crypto::unwrap_master_key(&new_envelope, "new-pass").unwrap(); assert_eq!( recovered, master_key, "New envelope should unwrap to the same master key" ); } #[tokio::test] async fn change_password_wrong_old_password_without_cached_key_fails() { let kit = MockKit::start().await; let master_key = synckit_client::crypto::generate_master_key(); let envelope = synckit_client::crypto::wrap_master_key(&master_key, "correct-old-pass").unwrap(); // Deliberately NOT setting a master key: no cached key let client = kit.authed(); kit.get(KEYS_PATH).json(envelope_body(&envelope)).await; let result = client.change_password("wrong-old-pass", "new-pass").await; assert!( result.is_err(), "change_password must fail with wrong old password even without cached key" ); assert!(matches!( result.unwrap_err(), SyncKitError::DecryptionFailed )); } #[tokio::test] async fn change_password_old_envelope_invalid_with_new_password() { let kit = MockKit::start().await; let (client, _master_key, envelope) = cached_key_client(&kit, "old-pass"); kit.get(KEYS_PATH).json(envelope_body(&envelope)).await; kit.put(KEYS_PATH).empty().await; client .change_password("old-pass", "new-pass") .await .unwrap(); // Old password should NOT work on the new envelope let new_envelope = uploaded_envelope(&kit, 0).await; let result = synckit_client::crypto::unwrap_master_key(&new_envelope, "old-pass"); assert!( result.is_err(), "Old password must not work on the new envelope" ); } // ── Encryption setup ── #[tokio::test] async fn setup_encryption_new_stores_key_and_uploads_envelope() { let kit = MockKit::start().await; kit.put(KEYS_PATH).exactly(1).empty().await; let client = kit.authed(); assert!(!client.has_master_key()); client.setup_encryption_new("test-password").await.unwrap(); // Master key should now be in memory assert!(client.has_master_key()); // Verify the PUT body contains a valid envelope unwrappable with the same password let envelope = uploaded_envelope(&kit, 0).await; let recovered = synckit_client::crypto::unwrap_master_key(&envelope, "test-password").unwrap(); assert_eq!(recovered.len(), 32); } #[tokio::test] async fn setup_encryption_new_without_auth_fails() { let kit = MockKit::start().await; let err = kit .client() .setup_encryption_new("password") .await .unwrap_err(); assert!(matches!(err, SyncKitError::NotAuthenticated)); } #[tokio::test] async fn setup_encryption_new_retries_on_server_error() { let kit = MockKit::start().await; kit.put(KEYS_PATH) .code(500) .once() .text("Internal Server Error") .await; kit.put(KEYS_PATH).empty().await; let client = kit.authed(); let result = client.setup_encryption_new("password").await; assert!(result.is_ok(), "Should succeed after retry: {result:?}"); assert!(client.has_master_key()); } #[tokio::test] async fn setup_encryption_existing_recovers_key() { let kit = MockKit::start().await; let master_key = synckit_client::crypto::generate_master_key(); let envelope = synckit_client::crypto::wrap_master_key(&master_key, "my-password").unwrap(); kit.get(KEYS_PATH).json(envelope_body(&envelope)).await; let client = kit.authed(); assert!(!client.has_master_key()); client .setup_encryption_existing("my-password") .await .unwrap(); assert!(client.has_master_key()); } #[tokio::test] async fn setup_encryption_existing_wrong_password_fails() { let kit = MockKit::start().await; let master_key = synckit_client::crypto::generate_master_key(); let envelope = synckit_client::crypto::wrap_master_key(&master_key, "correct-password").unwrap(); kit.get(KEYS_PATH).json(envelope_body(&envelope)).await; let client = kit.authed(); let err = client .setup_encryption_existing("wrong-password") .await .unwrap_err(); assert!( matches!(err, SyncKitError::DecryptionFailed), "Wrong password should produce DecryptionFailed: {err:?}" ); assert!(!client.has_master_key()); } #[tokio::test] async fn setup_encryption_existing_without_auth_fails() { let kit = MockKit::start().await; let err = kit .client() .setup_encryption_existing("password") .await .unwrap_err(); assert!(matches!(err, SyncKitError::NotAuthenticated)); } #[tokio::test] async fn setup_encryption_existing_retries_on_server_error() { let kit = MockKit::start().await; let master_key = synckit_client::crypto::generate_master_key(); let envelope = synckit_client::crypto::wrap_master_key(&master_key, "password").unwrap(); kit.get(KEYS_PATH) .code(502) .once() .text("Bad Gateway") .await; kit.get(KEYS_PATH).json(envelope_body(&envelope)).await; let client = kit.authed(); let result = client.setup_encryption_existing("password").await; assert!(result.is_ok(), "Should succeed after retry: {result:?}"); assert!(client.has_master_key()); } #[tokio::test] async fn setup_encryption_existing_no_server_key_returns_error() { let kit = MockKit::start().await; kit.get(KEYS_PATH).code(404).text("Not Found").await; let err = kit .authed() .setup_encryption_existing("password") .await .unwrap_err(); assert!( matches!(err, SyncKitError::Server { status: 404, .. }), "Missing server key should produce 404 error: {err:?}" ); } /// Two-device roundtrip: device 1 generates key via setup_encryption_new, /// device 2 recovers it via setup_encryption_existing. Data encrypted by /// device 1 must be decryptable by device 2. #[tokio::test] async fn encryption_setup_cross_device_roundtrip() { let kit = MockKit::start().await; // Device 1: setup_encryption_new kit.put(KEYS_PATH).empty().await; kit.post("/api/v1/sync/push") .json(json!({"cursor": 1})) .await; let client1 = kit.authed(); client1 .setup_encryption_new("shared-password") .await .unwrap(); // Push encrypted data from device 1 let device_id = DeviceId::new(Uuid::new_v4()); let original_data = json!({"title": "cross-device test", "secret": true}); client1 .push( device_id, vec![ChangeEntry { table: "tasks".into(), op: ChangeOp::Insert, row_id: "cross-r1".into(), timestamp: Utc::now(), hlc: Hlc::zero(DeviceId::nil()), data: Some(original_data.clone()), extra: serde_json::Map::default(), }], ) .await .unwrap(); // Capture the envelope and encrypted data let envelope = uploaded_envelope(&kit, 0).await; let push_body = kit.body("/api/v1/sync/push").await; let encrypted_data = push_body["changes"][0]["data"].clone(); // Device 2: setup_encryption_existing with same password kit.reset().await; kit.get(KEYS_PATH).json(envelope_body(&envelope)).await; kit.post("/api/v1/sync/pull") .json(json!({ "changes": [{ "seq": 1, "device_id": device_id, "table": "tasks", "op": "INSERT", "row_id": "cross-r1", "timestamp": "2025-06-01T12:00:00Z", "data": encrypted_data, }], "cursor": 1, "has_more": false, })) .await; let client2 = kit.authed(); client2 .setup_encryption_existing("shared-password") .await .unwrap(); // Pull and decrypt with device 2's recovered key let (changes, _, _) = client2.pull(device_id, 0).await.unwrap(); assert_eq!(changes.len(), 1); assert_eq!( changes[0].data.as_ref().unwrap(), &original_data, "Data encrypted by device 1 must be decryptable by device 2" ); } // ── has_server_key without auth ── #[tokio::test] async fn has_server_key_without_auth_returns_not_authenticated() { let kit = MockKit::start().await; let err = kit.client().has_server_key().await.unwrap_err(); assert!(matches!(err, SyncKitError::NotAuthenticated)); } // ── Encryption state edge cases ── #[tokio::test] async fn setup_encryption_new_twice_overwrites() { let kit = MockKit::start().await; kit.put(KEYS_PATH).empty().await; let client = kit.authed(); client.setup_encryption_new("pass1").await.unwrap(); assert!(client.has_master_key()); // Second call overwrites client.setup_encryption_new("pass2").await.unwrap(); assert!(client.has_master_key()); // Verify the second PUT used a different envelope let puts = kit.bodies("PUT", KEYS_PATH).await; assert_eq!(puts.len(), 2); // Different envelopes (different random keys) assert_ne!(puts[0], puts[1]); }