From 540b901fd698fd4991309fad5d56a950f5e8e3f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 10 Jul 2026 06:24:53 -0400 Subject: [PATCH] fix(sync): secure encrypted snapshot lifecycle --- Cargo.lock | 113 +++ Cargo.toml | 9 + PRD.md | 177 +++-- .../migrations/0008_sync_encryption.sql | 13 + cloudflare/migrations/0009_sync_vault.sql | 45 ++ cloudflare/migrations/0010_device_trust.sql | 57 ++ .../migrations/0011_sync_vault_rotation.sql | 373 ++++++++++ .../migrations/0012_sync_snapshot_head.sql | 282 +++++++ cloudflare/migrations/0013_sync_r2_gc.sql | 309 ++++++++ cloudflare/src/account_deletion.ts | 274 +++++-- cloudflare/src/api_controls.ts | 10 +- cloudflare/src/auth.ts | 4 + cloudflare/src/bindings.ts | 10 +- cloudflare/src/destructive_action_gate.ts | 143 ++++ cloudflare/src/device_approval.ts | 406 ++++++++++ cloudflare/src/device_approval_proof.ts | 65 ++ cloudflare/src/device_crypto.ts | 31 + cloudflare/src/device_rebind.ts | 345 +++++++++ cloudflare/src/device_registration_proof.ts | 38 + cloudflare/src/device_revocation.ts | 339 +++++++++ cloudflare/src/device_revocation_schema.ts | 254 +++++++ cloudflare/src/device_revocation_store.ts | 221 ++++++ cloudflare/src/device_routes.ts | 163 ++++ cloudflare/src/device_schema.ts | 171 +++-- cloudflare/src/devices.ts | 461 +++--------- cloudflare/src/index.ts | 171 +---- cloudflare/src/legacy_auth_kv_cleanup.ts | 70 ++ cloudflare/src/pending_device_revocation.ts | 228 ++++++ cloudflare/src/recent_device_action_proof.ts | 154 ++++ cloudflare/src/storage.ts | 29 +- cloudflare/src/sync_pull.ts | 201 ----- cloudflare/src/sync_push.ts | 281 ------- cloudflare/src/sync_push_schema.ts | 348 --------- cloudflare/src/sync_r2_gc.ts | 342 +++++++++ cloudflare/src/sync_r2_inventory.ts | 154 ++++ cloudflare/src/sync_r2_maintenance.ts | 22 + cloudflare/src/sync_reset.ts | 185 +++-- cloudflare/src/sync_routes.ts | 136 ++-- cloudflare/src/sync_snapshot.ts | 699 +++++++++--------- cloudflare/src/sync_snapshot_codec.ts | 155 ++++ cloudflare/src/sync_snapshot_head.ts | 264 +++++++ cloudflare/src/sync_snapshot_sql.ts | 334 +++++++++ cloudflare/src/sync_snapshot_write.ts | 122 +++ cloudflare/src/sync_status.ts | 180 ++--- cloudflare/src/sync_vault.ts | 499 +++++++++++++ cloudflare/src/sync_vault_bootstrap_proof.ts | 75 ++ cloudflare/src/sync_vault_rotation_cleanup.ts | 282 +++++++ cloudflare/src/sync_vault_sql.ts | 92 +++ .../tests/account_deletion_routes.test.ts | 181 ++++- .../tests/account_reset_gc_sqlite.test.ts | 445 +++++++++++ cloudflare/tests/api_controls.test.ts | 3 + .../tests/destructive_action_routes.test.ts | 277 +++++++ .../device_revocation_handler_sqlite.test.ts | 391 ++++++++++ .../tests/device_revocation_proof.test.ts | 194 +++++ .../tests/device_revocation_sqlite.test.ts | 230 ++++++ cloudflare/tests/device_trust_routes.test.ts | 296 ++++++++ .../devices_approval_persistence.test.ts | 167 +++++ .../tests/devices_approval_routes.test.ts | 229 +++++- .../tests/devices_revocation_routes.test.ts | 607 ++++++++++----- cloudflare/tests/devices_routes.test.ts | 103 +-- cloudflare/tests/devices_test_support.ts | 111 ++- .../tests/legacy_auth_kv_cleanup.test.ts | 63 ++ cloudflare/tests/migrations.test.ts | 249 +++++++ cloudflare/tests/sqlite_d1_test_support.ts | 151 ++++ cloudflare/tests/storage.test.ts | 11 +- cloudflare/tests/sync_pull_routes.test.ts | 147 +--- cloudflare/tests/sync_push_routes.test.ts | 330 +-------- cloudflare/tests/sync_r2_gc_sqlite.test.ts | 456 ++++++++++++ cloudflare/tests/sync_reset_routes.test.ts | 103 ++- cloudflare/tests/sync_snapshot_codec.test.ts | 26 + .../sync_snapshot_handler_sqlite.test.ts | 180 +++++ .../tests/sync_snapshot_head_sqlite.test.ts | 351 +++++++++ cloudflare/tests/sync_snapshot_routes.test.ts | 549 +++++++++----- cloudflare/tests/sync_status_routes.test.ts | 145 +++- ...sync_vault_rotation_cleanup_sqlite.test.ts | 233 ++++++ cloudflare/tests/sync_vault_routes.test.ts | 490 ++++++++++++ cloudflare/wrangler.toml | 3 + crates/ely_app/src/shell/auth.rs | 91 ++- .../ely_app/src/shell/internal_pages/sync.rs | 233 +++++- .../src/shell/internal_pages/sync_controls.rs | 2 +- crates/ely_app/src/shell/mod.rs | 20 +- crates/ely_app/src/shell/settings_actions.rs | 57 +- crates/ely_app/src/shell/sync_devices.rs | 260 +++++++ crates/ely_app/src/shell/sync_state.rs | 178 +++-- crates/ely_browser_core/Cargo.toml | 1 + crates/ely_browser_core/src/sync_engine.rs | 272 +++++-- .../src/sync_engine/concurrency.rs | 43 ++ .../src/sync_engine/device_management.rs | 459 ++++++++++++ .../src/sync_engine/vault_management.rs | 109 +++ crates/ely_sync_client/Cargo.toml | 9 + crates/ely_sync_client/src/client.rs | 250 ++++++- crates/ely_sync_client/src/client_tests.rs | 107 +++ .../ely_sync_client/src/credential_store.rs | 28 + crates/ely_sync_client/src/device.rs | 383 ++++++++-- crates/ely_sync_client/src/device_api.rs | 362 +++++++++ crates/ely_sync_client/src/device_proof.rs | 169 +++++ .../ely_sync_client/src/device_revocation.rs | 380 ++++++++++ .../src/device_secret_store.rs | 121 +++ crates/ely_sync_client/src/encryption.rs | 371 ++++++++++ .../ely_sync_client/src/encryption_tests.rs | 202 +++++ crates/ely_sync_client/src/error.rs | 24 + crates/ely_sync_client/src/key_store.rs | 310 ++++++++ crates/ely_sync_client/src/lib.rs | 32 +- crates/ely_sync_client/src/snapshot.rs | 279 ++++++- crates/ely_sync_client/src/vault.rs | 396 ++++++++++ crates/ely_sync_client/src/vault_bootstrap.rs | 130 ++++ 106 files changed, 18026 insertions(+), 3309 deletions(-) create mode 100644 cloudflare/migrations/0008_sync_encryption.sql create mode 100644 cloudflare/migrations/0009_sync_vault.sql create mode 100644 cloudflare/migrations/0010_device_trust.sql create mode 100644 cloudflare/migrations/0011_sync_vault_rotation.sql create mode 100644 cloudflare/migrations/0012_sync_snapshot_head.sql create mode 100644 cloudflare/migrations/0013_sync_r2_gc.sql create mode 100644 cloudflare/src/destructive_action_gate.ts create mode 100644 cloudflare/src/device_approval.ts create mode 100644 cloudflare/src/device_approval_proof.ts create mode 100644 cloudflare/src/device_crypto.ts create mode 100644 cloudflare/src/device_rebind.ts create mode 100644 cloudflare/src/device_registration_proof.ts create mode 100644 cloudflare/src/device_revocation.ts create mode 100644 cloudflare/src/device_revocation_schema.ts create mode 100644 cloudflare/src/device_revocation_store.ts create mode 100644 cloudflare/src/device_routes.ts create mode 100644 cloudflare/src/legacy_auth_kv_cleanup.ts create mode 100644 cloudflare/src/pending_device_revocation.ts create mode 100644 cloudflare/src/recent_device_action_proof.ts delete mode 100644 cloudflare/src/sync_pull.ts delete mode 100644 cloudflare/src/sync_push.ts delete mode 100644 cloudflare/src/sync_push_schema.ts create mode 100644 cloudflare/src/sync_r2_gc.ts create mode 100644 cloudflare/src/sync_r2_inventory.ts create mode 100644 cloudflare/src/sync_r2_maintenance.ts create mode 100644 cloudflare/src/sync_snapshot_codec.ts create mode 100644 cloudflare/src/sync_snapshot_head.ts create mode 100644 cloudflare/src/sync_snapshot_sql.ts create mode 100644 cloudflare/src/sync_snapshot_write.ts create mode 100644 cloudflare/src/sync_vault.ts create mode 100644 cloudflare/src/sync_vault_bootstrap_proof.ts create mode 100644 cloudflare/src/sync_vault_rotation_cleanup.ts create mode 100644 cloudflare/src/sync_vault_sql.ts create mode 100644 cloudflare/tests/account_reset_gc_sqlite.test.ts create mode 100644 cloudflare/tests/destructive_action_routes.test.ts create mode 100644 cloudflare/tests/device_revocation_handler_sqlite.test.ts create mode 100644 cloudflare/tests/device_revocation_proof.test.ts create mode 100644 cloudflare/tests/device_revocation_sqlite.test.ts create mode 100644 cloudflare/tests/device_trust_routes.test.ts create mode 100644 cloudflare/tests/devices_approval_persistence.test.ts create mode 100644 cloudflare/tests/legacy_auth_kv_cleanup.test.ts create mode 100644 cloudflare/tests/sqlite_d1_test_support.ts create mode 100644 cloudflare/tests/sync_r2_gc_sqlite.test.ts create mode 100644 cloudflare/tests/sync_snapshot_codec.test.ts create mode 100644 cloudflare/tests/sync_snapshot_handler_sqlite.test.ts create mode 100644 cloudflare/tests/sync_snapshot_head_sqlite.test.ts create mode 100644 cloudflare/tests/sync_vault_rotation_cleanup_sqlite.test.ts create mode 100644 cloudflare/tests/sync_vault_routes.test.ts create mode 100644 crates/ely_app/src/shell/sync_devices.rs create mode 100644 crates/ely_browser_core/src/sync_engine/concurrency.rs create mode 100644 crates/ely_browser_core/src/sync_engine/device_management.rs create mode 100644 crates/ely_browser_core/src/sync_engine/vault_management.rs create mode 100644 crates/ely_sync_client/src/client_tests.rs create mode 100644 crates/ely_sync_client/src/credential_store.rs create mode 100644 crates/ely_sync_client/src/device_api.rs create mode 100644 crates/ely_sync_client/src/device_proof.rs create mode 100644 crates/ely_sync_client/src/device_revocation.rs create mode 100644 crates/ely_sync_client/src/device_secret_store.rs create mode 100644 crates/ely_sync_client/src/encryption.rs create mode 100644 crates/ely_sync_client/src/encryption_tests.rs create mode 100644 crates/ely_sync_client/src/key_store.rs create mode 100644 crates/ely_sync_client/src/vault.rs create mode 100644 crates/ely_sync_client/src/vault_bootstrap.rs diff --git a/Cargo.lock b/Cargo.lock index a2e7458..f1a5fd9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -237,6 +237,17 @@ dependencies = [ "serde", ] +[[package]] +name = "apple-native-keyring-store" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7be2f067ccd8d4b4d4a66ddafe0f32a5dff31732f32dbff85fefc40929b1f72" +dependencies = [ + "keyring-core", + "log", + "security-framework", +] + [[package]] name = "ar_archive_writer" version = "0.5.1" @@ -2474,6 +2485,7 @@ dependencies = [ "serde_json", "thiserror 2.0.18", "url", + "uuid", ] [[package]] @@ -2520,14 +2532,23 @@ dependencies = [ name = "ely_sync_client" version = "0.1.0" dependencies = [ + "base64", + "chacha20poly1305", "ed25519-dalek 2.2.0", "ely_domain", + "fs2", + "getrandom 0.4.2", + "hkdf 0.12.4", + "hmac 0.12.1", + "hpke", + "keyring", "serde", "serde_json", "sha2 0.10.9", "thiserror 2.0.18", "ureq", "uuid", + "zeroize", ] [[package]] @@ -3087,6 +3108,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -4032,6 +4063,24 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "hpke" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5130e119706b4d8c2180da6126f7e60b6c38c2d340d539219f57051f0a7af7" +dependencies = [ + "aead", + "chacha20poly1305", + "getrandom 0.4.2", + "hkdf 0.13.0", + "hybrid-array", + "rand_core 0.10.1", + "sha2 0.11.0", + "subtle", + "x25519-dalek", + "zeroize", +] + [[package]] name = "html5ever" version = "0.27.0" @@ -5192,6 +5241,27 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "keyring" +version = "4.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ee8d4dae108d4177d0a0ce241f98acc1ef28e20837bdef43cff4d160cf70fe" +dependencies = [ + "apple-native-keyring-store", + "keyring-core", + "windows-native-keyring-store", + "zbus-secret-service-keyring-store", +] + +[[package]] +name = "keyring-core" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb1e621458ca9c51aa110bd0339d4751a056b9576bf1253aee1aa560dda0fc9d" +dependencies = [ + "log", +] + [[package]] name = "khronos-egl" version = "6.0.0" @@ -8068,6 +8138,25 @@ dependencies = [ "zeroize", ] +[[package]] +name = "secret-service" +version = "5.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a62d7f86047af0077255a29494136b9aaaf697c76ff70b8e49cded4e2623c14" +dependencies = [ + "aes 0.8.4", + "cbc 0.1.2", + "futures-util", + "generic-array", + "getrandom 0.2.17", + "hkdf 0.12.4", + "num", + "once_cell", + "serde", + "sha2 0.10.9", + "zbus", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -12173,6 +12262,19 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-native-keyring-store" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8" +dependencies = [ + "byteorder", + "keyring-core", + "regex", + "windows-sys 0.61.2", + "zeroize", +] + [[package]] name = "windows-numerics" version = "0.2.0" @@ -12983,6 +13085,17 @@ dependencies = [ "zvariant", ] +[[package]] +name = "zbus-secret-service-keyring-store" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ccede190ba363386a24e8021c7f3848393976609ec9f5d1f8c6c09ef37075b4" +dependencies = [ + "keyring-core", + "secret-service", + "zbus", +] + [[package]] name = "zbus_macros" version = "5.15.0" diff --git a/Cargo.toml b/Cargo.toml index 022dcde..bef273a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,13 +18,21 @@ license = "Apache-2.0" rust-version = "1.95" [workspace.dependencies] +base64 = "0.22.1" +chacha20poly1305 = "0.11.0" directories = "6.0.0" dpi = "0.1" ed25519-dalek = "2.2.0" +fs2 = "0.4.3" +getrandom = "0.4.2" gpui = "0.2.2" gpui-component = "0.5.1" gpui-component-assets = "0.5.1" +hkdf = "0.12.4" +hmac = "0.12.1" +hpke = { version = "0.14.0", default-features = false, features = ["alloc", "chacha", "getrandom", "x25519"] } image = "0.25.10" +keyring = "4.1.4" servo = { version = "0.4.0", git = "https://github.com/servo/servo.git", rev = "c983c232385edf1170118b24637d2eb2564674d7" } sha2 = "0.10.9" semver = "1.0.28" @@ -38,6 +46,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } ureq = "2.12.1" url = "2.5.4" uuid = { version = "1.12.1", features = ["v7"] } +zeroize = "1.8.2" [patch.crates-io] # Local GPUI 0.2.2 patch used by the native shell while the upstream crate is pinned. diff --git a/PRD.md b/PRD.md index 51aa66e..88c394a 100644 --- a/PRD.md +++ b/PRD.md @@ -633,28 +633,27 @@ User Action ↓ Local Encrypted Store ↓ -Sync Queue - ↓ -Client-side Encryption +Snapshot Merge + XChaCha20-Poly1305 Encryption ↓ Cloudflare Workers Sync API ↓ -D1 Object Index + R2 Encrypted Payload + KV Cache +D1 Global Head + Vault Metadata + R2 Ciphertext ↓ -Other Devices Pull Delta +Exact Head Token Download ↓ Decrypt + Merge + Apply ``` +当前 Cloud Sync 数据面使用 encrypted snapshot v3。`/api/sync/push` 与 `/api/sync/pull` 保留为认证后的退役端点,固定返回 `410 sync_object_protocol_retired`。D1 是会话、设备、Vault、head 与 GC 状态的权威源;KV 承担公开配置和历史数据清理。 + ### 9.2 登录能力 登录入口: - Email + password。 -- OAuth provider,可配置 Apple、Google、GitHub。 -- Passkey。 -- 设备二维码登录。 -- Recovery key 登录恢复。 +- Email OTP:5 分钟有效、3 次尝试、重发轮换、服务端加密存储。 +- OAuth provider:当前支持按环境配置 Google、GitHub。 +- 目标能力:Apple、Passkey、设备二维码登录、Recovery Key 恢复。 登录 UX: @@ -665,13 +664,12 @@ Decrypt + Merge + Apply - 本地浏览器数据库密钥保存在系统钥匙串。 - 账号注销时可以选择保留本地数据或清理本地数据。 -Better Auth 职责: +当前 Better Auth 职责: -- 用户注册、登录、邮箱验证、OAuth、Passkey、会话刷新、会话撤销。 -- 设备登录授权。 -- 账号删除流程。 -- 安全事件记录。 -- 与 D1 绑定,存储用户、账号、会话和验证相关表。 +- Email/password、Email OTP、可选 Google/GitHub OAuth。 +- D1 authoritative bearer session,存储用户、账号、会话和验证记录。 +- ELY 自定义 device trust、Vault、Sync reset 与账号删除协议。 +- `better_auth_session_device_context` 将会话绑定到经过证明的设备身份。 Desktop auth callback: @@ -712,25 +710,26 @@ Sync setup starts inside ELY settings ### 9.4 Sync 加密模型 -密钥结构: +当前密钥结构: ```text -User Secret - ├─ Account Recovery Key - ├─ Device Key Pair - └─ Sync Root Key - ├─ Spaces Key - ├─ Tabs Key - ├─ Bookmarks Key - ├─ History Key - ├─ Settings Key - ├─ Plugin Data Key - └─ Snapshot Key +Approved Device + ├─ Ed25519 Signing Key + └─ X25519 Wrapping Key + ↓ HPKE envelope per approved device +AccountKey generation N + ↓ HKDF-SHA256 +Snapshot Encryption Key + Content Authentication Key ``` 要求: -- Sync Root Key 在客户端生成。 +- 32-byte AccountKey 在客户端生成,服务器保存 `(key_id, generation)` 与每设备 envelope。 +- Envelope suite 固定为 `HPKE-BASE-X25519-HKDF-SHA256-CHACHA20POLY1305`。 +- Snapshot 使用 XChaCha20-Poly1305;AAD 绑定 user、generation、snapshot、schema、logical clock、device 和完整 head lineage。 +- 新设备先进入 pending;approved v2 device 使用 recent Ed25519 proof 与 current-generation envelope 完成批准。 +- 撤销 approved device 时,同一 D1 事务推进 Vault generation、写入剩余 approved v2 devices 的完整 envelope 集合、撤销目标会话并建立旧 R2 manifest。 +- 新写入固定 `encryption_version=2`;legacy v1 仅承担历史解密与迁移。 - Better Auth 密码、OAuth 账号和服务器会话均不能直接解密 Sync payload。 - 新设备加入时,需要已登录设备批准、Recovery Key 或 Passkey + Recovery Key 组合。 - 密文 payload 使用 AEAD 加密。 @@ -738,6 +737,8 @@ User Secret - D1 只存索引和小型密文;大型密文写入 R2。 - R2 object key 不直接暴露 URL、标题、站点名等敏感信息。 - 服务端日志不得记录 URL 明文、标题明文、书签明文、历史明文。 +- 每个账户使用一个全局 snapshot head;上传通过 base head token 执行 CAS 推进。 +- R2 写入先在 D1 建立带租约的写入记录,head 提交与引用状态在同一 D1 事务完成。 ### 9.5 Sync 冲突处理 @@ -751,6 +752,12 @@ User Secret 处理方式: +- 每个账户只有一个 global snapshot head。 +- Genesis 固定 `head_revision=1` 与 `base_head=null`。 +- 后继提交满足 `head_revision=base.revision+1`,base 的 `(revision,snapshot_id,payload_hash)` 精确匹配 current head,logical clock 严格递增。 +- CAS 冲突返回 `{version:1,error,current_head}` 的 structured `409`。 +- 完全相同的提交 replay 返回原 `201`,R2 put 次数保持为零。 +- GET 使用 `(snapshot_id,head_revision,payload_hash)` exact token;历史 token 返回携带 current head 的 `409`。 - 有序列表使用 CRDT ordered list,保留稳定排序键。 - 普通对象使用 `updated_at + device_id + logical_clock` 解决。 - 不可自动合并的冲突进入 Conflict Center。 @@ -789,6 +796,8 @@ Settings / Sync - 用户可以按对象类型暂停同步。 - 用户可以导出加密 Sync 备份。 - 用户可以从云端删除全部 Sync 数据。 +- Sync reset 清除云端 Sync 对象、快照、head 和变更记录,保留当前 Vault generation、设备信任和设备密钥 envelope。 +- `/api/sync/status` response v2 在一个 `first-primary` D1 batch 中读取 cursor、对象聚合、snapshot count/head 与 device summary;head/count 或存储元数据失配时 fail closed。 --- @@ -801,10 +810,12 @@ ELY Desktop Client ↓ HTTPS Cloudflare Workers API ├─ /api/auth/* Better Auth - ├─ /api/sync/push Sync object upload - ├─ /api/sync/pull Delta pull - ├─ /api/sync/snapshot Snapshot upload/download + ├─ /api/sync/push|pull Authenticated retired endpoints + ├─ /api/sync/snapshot Encrypted snapshot v3 + global head CAS + ├─ /api/sync/vault/* AccountKey envelope bootstrap/read + ├─ /api/sync/reset Signed destructive action ├─ /api/devices/* Device management + ├─ /api/account/delete Signed account deletion ├─ /api/plugins/* Plugin registry ├─ /api/releases/* Update manifest └─ /api/telemetry/* Minimal diagnostics @@ -812,9 +823,10 @@ Cloudflare Workers API Cloudflare D1 ├─ better_auth_* tables ├─ user_devices - ├─ sync_objects - ├─ sync_change_log - ├─ sync_snapshots + ├─ user_device_keys + device trust + ├─ sync_snapshots + sync_snapshot_heads + ├─ sync_vault_* + rotation manifests + ├─ sync_r2_gc_candidates + inventory cursors ├─ plugin_registry ├─ plugin_reviews └─ audit_events @@ -829,7 +841,7 @@ Cloudflare R2 ↓ Cloudflare Workers KV ├─ public_config - ├─ auth_session_cache + ├─ auth_session_cache legacy cleanup namespace ├─ plugin_registry_cache ├─ public_signing_keys ├─ release_manifest_cache @@ -847,6 +859,9 @@ D1 存储: - Sync 对象索引:对象 ID、对象类型、owner、payload 位置、hash、schema、时间戳。 - Sync 变更日志:用于增量拉取。 - Sync 快照索引:指向 R2 snapshot object。 +- Snapshot encryption metadata 与 global head。 +- AccountKey Vault generation、per-device HPKE envelope 与 rotation manifest。 +- R2 write lease、引用状态、删除状态与 inventory cursor。 - 插件注册表元数据:插件 ID、名称、作者、权限声明、签名状态、包位置。 - 审计事件:登录、设备加入、设备撤销、权限变更、插件安装、Sync reset。 @@ -862,10 +877,10 @@ D1 不存储: D1 schema 设计要求: -- 所有用户数据表必须有 `user_id`。 -- 所有 Sync 事实表必须有 `object_id`、`object_type`、`logical_clock`、`device_id`。 -- 所有可删除对象必须有 `deleted_at` tombstone。 -- 所有增量拉取必须通过 `sync_change_log` 读取。 +- 用户作用域表使用 `user_id`;账号删除后的 R2 ledger 使用不可逆 `owner_hash` 收尾。 +- Snapshot/Vault/head/GC 表使用各自的严格 key、generation、revision 与状态约束。 +- Snapshot 删除使用 hard delete + durable R2 ledger;对象协议表保留历史 migration compatibility。 +- D1 batch 提供原子 CAS 与 destructive action gate;`first-primary` session 提供顺序一致读取。 - 所有 D1 写入必须可幂等重放。 - 单个 D1 数据库接近容量阈值时按地区或账户拆分。 @@ -887,7 +902,7 @@ R2 object key 规范: ```text sync-payloads/{region}/{user_hash}/{object_type}/{object_id}/{payload_hash}.bin -sync-snapshots/{region}/{user_hash}/{snapshot_id}.bin +sync-snapshots/{region}/{user_hash}/{snapshot_id}/{payload_hash}.bin plugin-packages/{plugin_id}/{package_hash}.rplug plugin-assets/{plugin_id}/{asset_hash} user-avatars/{user_hash}/{avatar_hash} @@ -899,9 +914,14 @@ exports/{user_hash}/{export_id}.bin - 所有 Sync payload 上传前在客户端加密。 - R2 metadata 不写敏感明文。 -- Worker 只签发短期上传/下载访问。 +- Worker 经认证 API 直接执行 checksum-verified R2 put/get。 - 插件包必须通过签名验证后才进入 registry 可见状态。 - 用户删除账号时触发 R2 对象清理任务。 +- Sync R2 对象使用 D1 ledger 跟踪 `pending`、`referenced`、`ready`、`deleting`、`deleted` 状态。 +- 定时任务扫描 `sync-payloads/` 与 `sync-snapshots/` 历史对象并重试幂等删除。 +- 账号删除提交后清除 ledger 中的原始 user ID,使用不可逆 owner hash 继续完成 R2 清理。 +- Snapshot 写入先领取 64-hex write token 与 10 分钟 lease;candidate、encryption、head、referenced state、exact head SELECT 在五语句 D1 batch 中提交。 +- Hourly cron `17 * * * *` 分别执行 legacy KV purge、R2 prefix inventory、bounded GC 与 rotation cleanup;完整 prefix 重扫周期为 24 小时。 ### 10.4 Cloudflare Workers KV 使用边界 @@ -910,11 +930,11 @@ KV 是缓存和读多写少配置存储。 KV 存储: - `public_config`:公开运行配置、服务端开关、地区路由提示。 -- `auth_session_cache`:短期会话验证缓存。 +- `auth_session_cache`:legacy namespace,进入分页清理流程。 - `plugin_registry_cache`:插件市场列表缓存。 - `public_signing_keys`:插件签名公钥、服务端公钥。 - `release_manifest_cache`:自动更新清单缓存。 -- `sync_cursor_cache`:短期同步游标缓存。 +- `sync_cursor_cache`:规划中的短期同步游标缓存。 KV 使用规则: @@ -924,24 +944,25 @@ KV 使用规则: - KV 缓存可随时失效,所有关键数据必须可从 D1/R2 重建。 - KV key 设计必须包含 namespace 和环境前缀。 - KV TTL 必须按用途设置,默认不得无限期保存短期状态。 +- 定时任务分页清理历史 `auth_session_cache` key。 +- 所有认证请求直接读取 Better Auth D1 session;KV 不参与 session authority。 ### 10.5 Better Auth 集成 Better Auth 在 Cloudflare Workers 中初始化,D1 binding 作为 database 传入。 -能力要求: +当前能力: - Email/password 注册登录。 -- OAuth provider 配置。 -- Passkey 支持。 -- 邮箱验证。 -- 会话刷新。 -- 会话撤销。 -- 设备登录授权。 -- 安全事件记录。 -- 账号删除。 +- Encrypted Email OTP。 +- Google/GitHub OAuth 按环境启用。 +- D1 session validation 与自定义 device/session binding。 +- 设备注册、rebind、批准、撤销与 Vault rotation。 +- Signed Sync reset 和 signed account deletion。 - 管理所有 `/api/auth/*` 路由。 +目标能力包括 Apple OAuth、Passkey、Recovery Key 与完整服务端 session revoke UX。 + 会话策略: - Access session 短期有效。 @@ -949,6 +970,8 @@ Better Auth 在 Cloudflare Workers 中初始化,D1 binding 作为 database 传 - 服务端可以撤销单设备或全部设备会话。 - Desktop client 每次启动执行 session validation。 - 高风险操作要求重新验证。 +- Sync reset 与账号删除 request v2 要求 5 分钟内的 Ed25519 action proof;proof 绑定 action、user、session、device、confirmation、idempotency key 与创建时间。 +- Destructive D1 batch 首条执行 live session/device/key gate;guard failure 与 concurrent replay 触发事务 abort,后续通过 exact audit marker 收敛。 ### 10.6 API 规格 @@ -959,19 +982,26 @@ Auth: | `/api/auth/*` | Any | Better Auth handler | | `/api/devices` | GET | 当前账号设备列表 | | `/api/devices/register` | POST | 注册当前设备公钥 | +| `/api/devices/rebind/challenge` | POST | 为未绑定的新会话签发短期 challenge | +| `/api/devices/rebind` | POST | 用现有 Ed25519 device key 绑定新会话 | | `/api/devices/approve` | POST | 批准新设备加入 | | `/api/devices/revoke` | POST | 撤销设备 | +| `/api/account/delete` | POST | request v2 recent proof + atomic gate 删除账号 | Sync: | Endpoint | Method | 说明 | |---|---|---| -| `/api/sync/push` | POST | 上传对象变更 | -| `/api/sync/pull` | GET | 按 cursor 拉取增量 | -| `/api/sync/snapshot` | POST | 上传加密快照 | -| `/api/sync/snapshot` | GET | 下载加密快照 | -| `/api/sync/reset` | POST | 删除云端 Sync 数据 | -| `/api/sync/status` | GET | 获取队列和游标状态 | +| `/api/sync/push` | POST | 认证后返回 `410` 的退役对象协议 | +| `/api/sync/pull` | GET | 认证后返回 `410` 的退役对象协议 | +| `/api/sync/snapshot` | POST | request/response v3 encrypted snapshot CAS | +| `/api/sync/snapshot` | GET | exact head token 下载 encrypted snapshot | +| `/api/sync/vault/bootstrap` | POST | request v2 proof 创建 generation 1 Vault | +| `/api/sync/vault` | GET | 读取当前或 exact historical device envelope | +| `/api/sync/reset` | POST | request v2 recent proof + atomic gate 删除云端 Sync 数据 | +| `/api/sync/status` | GET | response v2 cursor/object/snapshot/device summary | + +`/api/sync/reset` 与账号删除响应中的 `deleted.r2_objects` 表示已识别并进入持久清理队列的 R2 对象数;物理删除由请求内回收和定时重试共同完成。 Plugin: @@ -997,11 +1027,24 @@ Better Auth 表由 Better Auth schema 管理。ELY 自定义表如下: | 表 | 用途 | |---|---| | `user_devices` | 用户设备、公钥、平台、活跃状态 | +| `user_device_keys` | Ed25519/X25519 public key 与 protocol version | | `device_approvals` | 新设备加入批准记录 | +| `device_rebind_challenges` | 短期 session rebind challenge 与消费状态 | +| `better_auth_session_device_context` | Better Auth session 与 device identity 绑定 | | `sync_objects` | Sync 对象索引 | | `sync_change_log` | Sync 增量日志 | | `sync_snapshots` | Sync 快照索引 | +| `sync_snapshot_encryption` | Snapshot encryption version、generation、key/content hash | +| `sync_snapshot_heads` | 每账户唯一 global snapshot head | | `sync_tombstones` | 删除标记 | +| `sync_vault_accounts` | 当前 AccountKey id 与 generation | +| `sync_vault_envelopes` | Per-device HPKE AccountKey envelope | +| `sync_vault_rotations` | Device revoke Vault rotation 状态 | +| `sync_vault_rotation_envelopes` | Rotation recipient exact set | +| `sync_vault_rotation_r2_objects` | Rotation 旧 R2 manifest | +| `pending_device_revocations` | Pending device revoke 幂等记录 | +| `sync_r2_gc_candidates` | R2 write lease、reference 与 GC state machine | +| `sync_r2_inventory_cursors` | R2 prefix inventory cursor | | `plugin_registry` | 插件注册表 | | `plugin_packages` | 插件包和签名 | | `plugin_reviews` | 插件审核记录 | @@ -1303,6 +1346,7 @@ Servo Host 需要实现: - Sync 密文与账号会话分离。 - 本地密钥与云端会话分离。 - 高风险操作有显式确认和审计日志。 +- Sync reset 与账号删除使用 current-device Ed25519 recent proof;D1 commit 原子复核 live session、session-device binding、approved v2 device 与 exact signing key。 ### 13.2 隐私默认值 @@ -1331,6 +1375,8 @@ Servo Host 需要实现: 审计日志默认只保存在本地,可选择端到端加密同步。 +服务端 `audit_events` 记录 device approval/revoke、Sync reset 与账号删除。账号删除保留 anonymized success marker;marker 绑定 exact proof hash,使已认证的并发请求在 device/key 清理后仍能安全收敛。 + --- ## 14. 性能指标 @@ -1522,14 +1568,16 @@ Servo Host 需要实现: |---|---|---| | S-001 | Sync encryption | 服务端无法解密 Sync payload | | S-002 | Keychain | session token 和本地数据库密钥存入系统钥匙串 | -| S-003 | Device revoke | 撤销设备后无法继续拉取 Sync delta | +| S-003 | Device revoke | 撤销设备后目标 sessions 失效、后续 Sync API 被拒绝、approved revoke 原子轮换 Vault generation | | S-004 | Plugin sandbox | 插件无声明权限时无法访问 tabs/bookmarks/history/page | | S-005 | Plugin signature | 市场插件必须签名验证通过 | | S-006 | Site permissions | 所有站点权限按 Profile 隔离 | | S-007 | Private Window | 关闭后无持久 Cookie、Storage、History | | S-008 | Audit log | 高风险操作全部写入本地审计日志 | | S-009 | Cloud logs | 服务端日志不得记录敏感明文 URL、标题、历史、书签 | -| S-010 | Account deletion | 删除账号触发 D1/R2/KV 数据清理 | +| S-010 | Account deletion | Signed request 原子删除 D1 权威数据;R2 ledger 与 legacy KV cleanup 持久排队并由请求内 drain + scheduled retry 收口 | +| S-011 | Snapshot CAS | 单一 global head、exact base CAS、structured 409、exact replay zero R2 put | +| S-012 | Destructive proof | Stolen bearer 缺少 device private key 时无法执行 Sync reset 或账号删除 | ### 19.3 性能验收 @@ -1658,6 +1706,9 @@ KV namespace: ELY_KV - Cloudflare Worker API tests。 - D1 migration tests。 - R2 upload/download tests。 +- Real SQLite CAS、rollback 与 proof-after-session-revoke interleaving tests。 +- R2 write lease、late put、101+ drain、inventory、delete retry tests。 +- Legacy encryption metadata backfill 与 structured `409` contract tests。 - KV cache invalidation tests。 - Cross-platform smoke tests。 - Servo site compatibility smoke tests。 @@ -1715,8 +1766,10 @@ KV namespace: ELY_KV - D1 write failure。 - R2 upload/download failure。 - KV cache hit rate。 -- Sync push/pull latency。 -- Sync conflict rate。 +- Snapshot upload/download latency。 +- Snapshot head conflict rate。 +- R2 pending/ready/deleting backlog 与 inventory cursor age。 +- Scheduled storage maintenance failure 与 Vault rotation cleanup lag。 - Device revoke events。 - Plugin install failure。 - Plugin signature failure。 diff --git a/cloudflare/migrations/0008_sync_encryption.sql b/cloudflare/migrations/0008_sync_encryption.sql new file mode 100644 index 0000000..b21d54e --- /dev/null +++ b/cloudflare/migrations/0008_sync_encryption.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS sync_snapshot_encryption ( + user_id TEXT NOT NULL, + snapshot_id TEXT NOT NULL, + encryption_version INTEGER NOT NULL CHECK (encryption_version = 1), + vault_generation INTEGER NOT NULL CHECK (vault_generation >= 1), + key_id TEXT NOT NULL, + content_hash TEXT NOT NULL, + PRIMARY KEY (user_id, snapshot_id), + FOREIGN KEY (user_id, snapshot_id) REFERENCES sync_snapshots (user_id, snapshot_id) +); + +CREATE INDEX IF NOT EXISTS idx_sync_snapshots_encrypted_latest + ON sync_snapshot_encryption (user_id, encryption_version, snapshot_id); diff --git a/cloudflare/migrations/0009_sync_vault.sql b/cloudflare/migrations/0009_sync_vault.sql new file mode 100644 index 0000000..90fbedd --- /dev/null +++ b/cloudflare/migrations/0009_sync_vault.sql @@ -0,0 +1,45 @@ +CREATE TABLE IF NOT EXISTS sync_vault_accounts ( + user_id TEXT NOT NULL PRIMARY KEY, + current_key_id TEXT NOT NULL + CHECK (length(current_key_id) = 64 AND current_key_id NOT GLOB '*[^0-9a-f]*'), + current_generation INTEGER NOT NULL CHECK (current_generation >= 1), + created_at INTEGER NOT NULL CHECK (created_at >= 0), + updated_at INTEGER NOT NULL CHECK (updated_at >= 0), + FOREIGN KEY (user_id) REFERENCES better_auth_user (id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS sync_vault_envelopes ( + user_id TEXT NOT NULL, + recipient_device_id TEXT NOT NULL, + approver_device_id TEXT NOT NULL, + key_id TEXT NOT NULL + CHECK (length(key_id) = 64 AND key_id NOT GLOB '*[^0-9a-f]*'), + generation INTEGER NOT NULL CHECK (generation >= 1), + envelope_version INTEGER NOT NULL CHECK (envelope_version = 1), + suite TEXT NOT NULL + CHECK (suite = 'HPKE-BASE-X25519-HKDF-SHA256-CHACHA20POLY1305'), + encapped_key TEXT NOT NULL + CHECK ( + length(encapped_key) = 43 + AND encapped_key NOT GLOB '*[^A-Za-z0-9_-]*' + AND substr(encapped_key, 43, 1) GLOB '[AEIMQUYcgkosw048]' + ), + ciphertext TEXT NOT NULL + CHECK (length(ciphertext) = 64 AND ciphertext NOT GLOB '*[^A-Za-z0-9_-]*'), + idempotency_key TEXT NOT NULL + CHECK ( + length(idempotency_key) BETWEEN 16 AND 128 + AND idempotency_key NOT GLOB '*[^A-Za-z0-9._:-]*' + ), + created_at INTEGER NOT NULL CHECK (created_at >= 0), + PRIMARY KEY (user_id, recipient_device_id, key_id, generation), + UNIQUE (user_id, idempotency_key), + FOREIGN KEY (user_id) REFERENCES sync_vault_accounts (user_id) ON DELETE CASCADE, + FOREIGN KEY (user_id, recipient_device_id) + REFERENCES user_devices (user_id, device_id) ON DELETE CASCADE, + FOREIGN KEY (user_id, approver_device_id) + REFERENCES user_devices (user_id, device_id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_sync_vault_envelopes_current_device + ON sync_vault_envelopes (user_id, recipient_device_id, generation, key_id); diff --git a/cloudflare/migrations/0010_device_trust.sql b/cloudflare/migrations/0010_device_trust.sql new file mode 100644 index 0000000..cab9aeb --- /dev/null +++ b/cloudflare/migrations/0010_device_trust.sql @@ -0,0 +1,57 @@ +CREATE TABLE IF NOT EXISTS user_device_keys ( + user_id TEXT NOT NULL, + device_id TEXT NOT NULL, + signing_public_key TEXT NOT NULL, + wrapping_public_key TEXT, + key_protocol_version INTEGER NOT NULL CHECK (key_protocol_version IN (1, 2)), + created_at INTEGER NOT NULL, + PRIMARY KEY (user_id, device_id), + FOREIGN KEY (user_id, device_id) + REFERENCES user_devices (user_id, device_id) ON DELETE CASCADE, + CHECK ( + (key_protocol_version = 1 AND wrapping_public_key IS NULL) + OR + ( + key_protocol_version = 2 + AND length(signing_public_key) = 64 + AND signing_public_key NOT GLOB '*[^0-9a-f]*' + AND wrapping_public_key IS NOT NULL + AND length(wrapping_public_key) = 64 + AND wrapping_public_key NOT GLOB '*[^0-9a-f]*' + ) + ) +); + +INSERT OR IGNORE INTO user_device_keys ( + user_id, + device_id, + signing_public_key, + wrapping_public_key, + key_protocol_version, + created_at +) +SELECT user_id, device_id, public_key, NULL, 1, created_at +FROM user_devices; + +CREATE TABLE IF NOT EXISTS device_rebind_challenges ( + challenge_id TEXT NOT NULL PRIMARY KEY, + user_id TEXT NOT NULL, + session_id TEXT NOT NULL UNIQUE, + device_id TEXT NOT NULL, + challenge TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + consumed_at INTEGER, + consumption_nonce TEXT, + FOREIGN KEY (session_id) REFERENCES better_auth_session (id) ON DELETE CASCADE, + FOREIGN KEY (user_id, device_id) + REFERENCES user_device_keys (user_id, device_id) ON DELETE CASCADE, + CHECK (expires_at > created_at), + CHECK ( + (consumed_at IS NULL AND consumption_nonce IS NULL) + OR (consumed_at IS NOT NULL AND consumption_nonce IS NOT NULL) + ) +); + +CREATE INDEX IF NOT EXISTS idx_device_rebind_challenges_expiry + ON device_rebind_challenges (expires_at, consumed_at); diff --git a/cloudflare/migrations/0011_sync_vault_rotation.sql b/cloudflare/migrations/0011_sync_vault_rotation.sql new file mode 100644 index 0000000..e9480cf --- /dev/null +++ b/cloudflare/migrations/0011_sync_vault_rotation.sql @@ -0,0 +1,373 @@ +CREATE TABLE IF NOT EXISTS sync_vault_rotations ( + user_id TEXT NOT NULL, + idempotency_key TEXT NOT NULL + CHECK ( + length(idempotency_key) BETWEEN 16 AND 128 + AND idempotency_key NOT GLOB '*[^A-Za-z0-9._:-]*' + ), + audit_event_id TEXT NOT NULL UNIQUE, + target_device_id TEXT NOT NULL, + approver_device_id TEXT NOT NULL, + previous_key_id TEXT NOT NULL + CHECK (length(previous_key_id) = 64 AND previous_key_id NOT GLOB '*[^0-9a-f]*'), + previous_generation INTEGER NOT NULL CHECK (previous_generation >= 1), + new_key_id TEXT NOT NULL + CHECK (length(new_key_id) = 64 AND new_key_id NOT GLOB '*[^0-9a-f]*'), + new_generation INTEGER NOT NULL CHECK (new_generation >= 2), + request_hash TEXT NOT NULL + CHECK (length(request_hash) = 64 AND request_hash NOT GLOB '*[^0-9a-f]*'), + envelope_count INTEGER NOT NULL CHECK (envelope_count BETWEEN 1 AND 128), + r2_object_count INTEGER NOT NULL CHECK (r2_object_count >= 0), + created_at INTEGER NOT NULL CHECK (created_at >= 0), + completed_at INTEGER CHECK (completed_at IS NULL OR completed_at >= created_at), + cleanup_snapshot_id TEXT + CHECK ( + cleanup_snapshot_id IS NULL + OR ( + length(cleanup_snapshot_id) BETWEEN 1 AND 128 + AND substr(cleanup_snapshot_id, 1, 1) GLOB '[a-z0-9]' + AND cleanup_snapshot_id NOT GLOB '*[^a-z0-9._-]*' + ) + ), + cleanup_started_at INTEGER, + storage_cleaned_at INTEGER + CHECK ( + storage_cleaned_at IS NULL + OR ( + cleanup_started_at IS NOT NULL + AND storage_cleaned_at >= cleanup_started_at + ) + ), + PRIMARY KEY (user_id, idempotency_key), + FOREIGN KEY (user_id) REFERENCES sync_vault_accounts (user_id) ON DELETE CASCADE, + FOREIGN KEY (user_id, target_device_id) + REFERENCES user_devices (user_id, device_id) ON DELETE CASCADE, + FOREIGN KEY (user_id, approver_device_id) + REFERENCES user_devices (user_id, device_id) ON DELETE CASCADE, + CHECK (target_device_id <> approver_device_id), + CHECK (new_key_id <> previous_key_id), + CHECK (new_generation = previous_generation + 1), + CHECK ( + (cleanup_snapshot_id IS NULL AND cleanup_started_at IS NULL) + OR ( + cleanup_snapshot_id IS NOT NULL + AND cleanup_started_at IS NOT NULL + AND completed_at IS NOT NULL + AND cleanup_started_at >= completed_at + ) + ) +); + +CREATE TABLE IF NOT EXISTS sync_vault_rotation_envelopes ( + user_id TEXT NOT NULL, + rotation_idempotency_key TEXT NOT NULL, + recipient_device_id TEXT NOT NULL, + envelope_idempotency_key TEXT NOT NULL + CHECK ( + length(envelope_idempotency_key) = 64 + AND envelope_idempotency_key NOT GLOB '*[^0-9a-f]*' + ), + envelope_version INTEGER NOT NULL CHECK (envelope_version = 1), + suite TEXT NOT NULL + CHECK (suite = 'HPKE-BASE-X25519-HKDF-SHA256-CHACHA20POLY1305'), + encapped_key TEXT NOT NULL + CHECK ( + length(encapped_key) = 43 + AND encapped_key NOT GLOB '*[^A-Za-z0-9_-]*' + AND substr(encapped_key, 43, 1) GLOB '[AEIMQUYcgkosw048]' + ), + ciphertext TEXT NOT NULL + CHECK (length(ciphertext) = 64 AND ciphertext NOT GLOB '*[^A-Za-z0-9_-]*'), + PRIMARY KEY (user_id, rotation_idempotency_key, recipient_device_id), + UNIQUE (user_id, envelope_idempotency_key), + FOREIGN KEY (user_id, rotation_idempotency_key) + REFERENCES sync_vault_rotations (user_id, idempotency_key) ON DELETE CASCADE, + FOREIGN KEY (user_id, recipient_device_id) + REFERENCES user_devices (user_id, device_id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS sync_vault_rotation_r2_objects ( + user_id TEXT NOT NULL, + rotation_idempotency_key TEXT NOT NULL, + r2_key TEXT NOT NULL CHECK (length(r2_key) BETWEEN 1 AND 1024), + PRIMARY KEY (user_id, rotation_idempotency_key, r2_key), + FOREIGN KEY (user_id, rotation_idempotency_key) + REFERENCES sync_vault_rotations (user_id, idempotency_key) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS pending_device_revocations ( + user_id TEXT NOT NULL, + idempotency_key TEXT NOT NULL + CHECK ( + length(idempotency_key) BETWEEN 16 AND 128 + AND idempotency_key NOT GLOB '*[^A-Za-z0-9._:-]*' + ), + audit_event_id TEXT NOT NULL UNIQUE, + target_device_id TEXT NOT NULL, + approver_device_id TEXT NOT NULL, + request_hash TEXT NOT NULL + CHECK (length(request_hash) = 64 AND request_hash NOT GLOB '*[^0-9a-f]*'), + created_at INTEGER NOT NULL CHECK (created_at >= 0), + completed_at INTEGER CHECK (completed_at IS NULL OR completed_at >= created_at), + PRIMARY KEY (user_id, idempotency_key), + FOREIGN KEY (user_id, target_device_id) + REFERENCES user_devices (user_id, device_id) ON DELETE CASCADE, + FOREIGN KEY (user_id, approver_device_id) + REFERENCES user_devices (user_id, device_id) ON DELETE CASCADE, + CHECK (target_device_id <> approver_device_id) +); + +UPDATE user_devices +SET + approval_status = 'revoked', + revoked_at = COALESCE(revoked_at, unixepoch()) +WHERE approval_status = 'approved' + AND EXISTS ( + SELECT 1 FROM user_device_keys AS keys + WHERE keys.user_id = user_devices.user_id + AND keys.device_id = user_devices.device_id + AND keys.key_protocol_version = 1 + ); + +CREATE TRIGGER IF NOT EXISTS finalize_pending_device_revocation +BEFORE UPDATE OF completed_at ON pending_device_revocations +FOR EACH ROW +WHEN OLD.completed_at IS NULL AND NEW.completed_at IS NOT NULL +BEGIN + SELECT CASE WHEN + NOT EXISTS ( + SELECT 1 + FROM user_devices AS device + INNER JOIN user_device_keys AS keys + ON keys.user_id = device.user_id AND keys.device_id = device.device_id + WHERE device.user_id = OLD.user_id + AND device.device_id = OLD.approver_device_id + AND device.approval_status = 'approved' + AND device.revoked_at IS NULL + AND keys.key_protocol_version = 2 + AND keys.wrapping_public_key IS NOT NULL + ) + OR NOT EXISTS ( + SELECT 1 FROM user_devices + WHERE user_id = OLD.user_id + AND device_id = OLD.target_device_id + AND approval_status = 'pending' + AND revoked_at IS NULL + ) + THEN RAISE(ABORT, 'pending_device_revocation_guard_failed') END; + + DELETE FROM better_auth_session + WHERE id IN ( + SELECT session_id FROM better_auth_session_device_context + WHERE user_id = OLD.user_id AND device_id = OLD.target_device_id + ); + + UPDATE user_devices + SET approval_status = 'revoked', revoked_at = NEW.completed_at + WHERE user_id = OLD.user_id + AND device_id = OLD.target_device_id + AND approval_status = 'pending' + AND revoked_at IS NULL; + + INSERT INTO audit_events ( + event_id, user_id, actor_device_id, event_type, subject_type, + subject_id, outcome, metadata_hash, created_at + ) VALUES ( + OLD.audit_event_id, OLD.user_id, OLD.approver_device_id, 'device.revoke', 'device', + OLD.target_device_id, 'success', OLD.request_hash, NEW.completed_at + ); +END; + +CREATE TRIGGER IF NOT EXISTS finalize_sync_vault_rotation +BEFORE UPDATE OF completed_at ON sync_vault_rotations +FOR EACH ROW +WHEN OLD.completed_at IS NULL AND NEW.completed_at IS NOT NULL +BEGIN + INSERT OR IGNORE INTO sync_vault_rotation_r2_objects ( + user_id, rotation_idempotency_key, r2_key + ) + SELECT OLD.user_id, OLD.idempotency_key, current_r2.r2_key + FROM ( + SELECT payload_r2_key AS r2_key + FROM sync_objects + WHERE user_id = OLD.user_id AND payload_r2_key IS NOT NULL + UNION + SELECT r2_key FROM sync_snapshots WHERE user_id = OLD.user_id + ) AS current_r2; + + SELECT CASE WHEN + NOT EXISTS ( + SELECT 1 FROM sync_vault_accounts + WHERE user_id = OLD.user_id + AND current_key_id = OLD.previous_key_id + AND current_generation = OLD.previous_generation + ) + OR NOT EXISTS ( + SELECT 1 + FROM user_devices AS device + INNER JOIN user_device_keys AS keys + ON keys.user_id = device.user_id AND keys.device_id = device.device_id + WHERE device.user_id = OLD.user_id + AND device.device_id = OLD.approver_device_id + AND device.approval_status = 'approved' + AND device.revoked_at IS NULL + AND keys.key_protocol_version = 2 + AND keys.wrapping_public_key IS NOT NULL + ) + OR NOT EXISTS ( + SELECT 1 FROM user_devices + WHERE user_id = OLD.user_id + AND device_id = OLD.target_device_id + AND approval_status IN ('pending', 'approved') + AND revoked_at IS NULL + ) + OR ( + SELECT COUNT(*) FROM sync_vault_rotation_envelopes + WHERE user_id = OLD.user_id + AND rotation_idempotency_key = OLD.idempotency_key + ) <> OLD.envelope_count + OR ( + SELECT COUNT(*) + FROM user_devices AS device + INNER JOIN user_device_keys AS keys + ON keys.user_id = device.user_id AND keys.device_id = device.device_id + WHERE device.user_id = OLD.user_id + AND device.device_id <> OLD.target_device_id + AND device.approval_status = 'approved' + AND device.revoked_at IS NULL + AND keys.key_protocol_version = 2 + AND keys.wrapping_public_key IS NOT NULL + ) <> OLD.envelope_count + OR EXISTS ( + SELECT 1 + FROM sync_vault_rotation_envelopes AS envelope + WHERE envelope.user_id = OLD.user_id + AND envelope.rotation_idempotency_key = OLD.idempotency_key + AND NOT EXISTS ( + SELECT 1 + FROM user_devices AS device + INNER JOIN user_device_keys AS keys + ON keys.user_id = device.user_id AND keys.device_id = device.device_id + WHERE device.user_id = OLD.user_id + AND device.device_id = envelope.recipient_device_id + AND device.device_id <> OLD.target_device_id + AND device.approval_status = 'approved' + AND device.revoked_at IS NULL + AND keys.key_protocol_version = 2 + AND keys.wrapping_public_key IS NOT NULL + ) + ) + OR EXISTS ( + SELECT 1 + FROM user_devices AS device + INNER JOIN user_device_keys AS keys + ON keys.user_id = device.user_id AND keys.device_id = device.device_id + WHERE device.user_id = OLD.user_id + AND device.device_id <> OLD.target_device_id + AND device.approval_status = 'approved' + AND device.revoked_at IS NULL + AND keys.key_protocol_version = 2 + AND keys.wrapping_public_key IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM sync_vault_rotation_envelopes AS envelope + WHERE envelope.user_id = OLD.user_id + AND envelope.rotation_idempotency_key = OLD.idempotency_key + AND envelope.recipient_device_id = device.device_id + ) + ) + OR ( + SELECT COUNT(*) FROM sync_vault_rotation_r2_objects + WHERE user_id = OLD.user_id + AND rotation_idempotency_key = OLD.idempotency_key + ) <> OLD.r2_object_count + OR ( + SELECT COUNT(*) FROM ( + SELECT payload_r2_key AS r2_key + FROM sync_objects + WHERE user_id = OLD.user_id AND payload_r2_key IS NOT NULL + UNION + SELECT r2_key FROM sync_snapshots WHERE user_id = OLD.user_id + ) + ) <> OLD.r2_object_count + OR EXISTS ( + SELECT 1 FROM sync_vault_rotation_r2_objects AS staged + WHERE staged.user_id = OLD.user_id + AND staged.rotation_idempotency_key = OLD.idempotency_key + AND NOT EXISTS ( + SELECT 1 FROM ( + SELECT payload_r2_key AS r2_key + FROM sync_objects + WHERE user_id = OLD.user_id AND payload_r2_key IS NOT NULL + UNION + SELECT r2_key FROM sync_snapshots WHERE user_id = OLD.user_id + ) AS current_r2 + WHERE current_r2.r2_key = staged.r2_key + ) + ) + OR EXISTS ( + SELECT 1 FROM ( + SELECT payload_r2_key AS r2_key + FROM sync_objects + WHERE user_id = OLD.user_id AND payload_r2_key IS NOT NULL + UNION + SELECT r2_key FROM sync_snapshots WHERE user_id = OLD.user_id + ) AS current_r2 + WHERE NOT EXISTS ( + SELECT 1 FROM sync_vault_rotation_r2_objects AS staged + WHERE staged.user_id = OLD.user_id + AND staged.rotation_idempotency_key = OLD.idempotency_key + AND staged.r2_key = current_r2.r2_key + ) + ) + THEN RAISE(ABORT, 'sync_vault_rotation_guard_failed') END; + + INSERT INTO sync_vault_envelopes ( + user_id, recipient_device_id, approver_device_id, key_id, generation, + envelope_version, suite, encapped_key, ciphertext, idempotency_key, created_at + ) + SELECT + envelope.user_id, + envelope.recipient_device_id, + OLD.approver_device_id, + OLD.new_key_id, + OLD.new_generation, + envelope.envelope_version, + envelope.suite, + envelope.encapped_key, + envelope.ciphertext, + envelope.envelope_idempotency_key, + NEW.completed_at + FROM sync_vault_rotation_envelopes AS envelope + WHERE envelope.user_id = OLD.user_id + AND envelope.rotation_idempotency_key = OLD.idempotency_key; + + UPDATE sync_vault_accounts + SET + current_key_id = OLD.new_key_id, + current_generation = OLD.new_generation, + updated_at = NEW.completed_at + WHERE user_id = OLD.user_id + AND current_key_id = OLD.previous_key_id + AND current_generation = OLD.previous_generation; + + DELETE FROM better_auth_session + WHERE id IN ( + SELECT session_id FROM better_auth_session_device_context + WHERE user_id = OLD.user_id AND device_id = OLD.target_device_id + ); + + UPDATE user_devices + SET approval_status = 'revoked', revoked_at = NEW.completed_at + WHERE user_id = OLD.user_id + AND device_id = OLD.target_device_id + AND approval_status IN ('pending', 'approved') + AND revoked_at IS NULL; + + INSERT INTO audit_events ( + event_id, user_id, actor_device_id, event_type, subject_type, + subject_id, outcome, metadata_hash, created_at + ) VALUES ( + OLD.audit_event_id, OLD.user_id, OLD.approver_device_id, 'device.revoke', 'device', + OLD.target_device_id, 'success', OLD.request_hash, NEW.completed_at + ); +END; diff --git a/cloudflare/migrations/0012_sync_snapshot_head.sql b/cloudflare/migrations/0012_sync_snapshot_head.sql new file mode 100644 index 0000000..bd89b11 --- /dev/null +++ b/cloudflare/migrations/0012_sync_snapshot_head.sql @@ -0,0 +1,282 @@ +ALTER TABLE sync_snapshots + ADD COLUMN head_revision INTEGER NOT NULL DEFAULT 0 CHECK (head_revision >= 0); + +ALTER TABLE sync_snapshots + ADD COLUMN base_head_revision INTEGER CHECK (base_head_revision IS NULL OR base_head_revision >= 1); + +ALTER TABLE sync_snapshots + ADD COLUMN base_snapshot_id TEXT; + +ALTER TABLE sync_snapshots + ADD COLUMN base_payload_hash TEXT + CHECK ( + base_payload_hash IS NULL + OR ( + length(base_payload_hash) = 64 + AND base_payload_hash NOT GLOB '*[^0-9a-f]*' + ) + ); + +ALTER TABLE sync_snapshot_encryption RENAME TO sync_snapshot_encryption_v1; + +DROP INDEX IF EXISTS idx_sync_snapshots_encrypted_latest; + +CREATE TABLE sync_snapshot_encryption ( + user_id TEXT NOT NULL, + snapshot_id TEXT NOT NULL, + encryption_version INTEGER NOT NULL CHECK (encryption_version IN (1, 2)), + vault_generation INTEGER NOT NULL CHECK (vault_generation >= 1), + key_id TEXT NOT NULL, + content_hash TEXT NOT NULL, + PRIMARY KEY (user_id, snapshot_id), + FOREIGN KEY (user_id, snapshot_id) REFERENCES sync_snapshots (user_id, snapshot_id) +); + +INSERT INTO sync_snapshot_encryption ( + user_id, + snapshot_id, + encryption_version, + vault_generation, + key_id, + content_hash +) +SELECT + user_id, + snapshot_id, + encryption_version, + vault_generation, + key_id, + content_hash +FROM sync_snapshot_encryption_v1; + +DROP TABLE sync_snapshot_encryption_v1; + +CREATE INDEX idx_sync_snapshots_encrypted_latest + ON sync_snapshot_encryption (user_id, encryption_version, snapshot_id); + +UPDATE sync_snapshots AS candidate +SET head_revision = 1 +WHERE EXISTS ( + SELECT 1 + FROM sync_snapshot_encryption AS encryption + WHERE encryption.user_id = candidate.user_id + AND encryption.snapshot_id = candidate.snapshot_id + AND encryption.encryption_version = 1 + ) + AND NOT EXISTS ( + SELECT 1 + FROM sync_snapshots AS newer + INNER JOIN sync_snapshot_encryption AS newer_encryption + ON newer_encryption.user_id = newer.user_id + AND newer_encryption.snapshot_id = newer.snapshot_id + AND newer_encryption.encryption_version = 1 + WHERE newer.user_id = candidate.user_id + AND ( + newer.created_at > candidate.created_at + OR ( + newer.created_at = candidate.created_at + AND newer.snapshot_id < candidate.snapshot_id + ) + ) + ); + +CREATE UNIQUE INDEX idx_sync_snapshot_committed_revision + ON sync_snapshots (user_id, head_revision) + WHERE head_revision > 0; + +CREATE TABLE sync_snapshot_heads ( + user_id TEXT NOT NULL PRIMARY KEY, + head_revision INTEGER NOT NULL CHECK (head_revision >= 1), + snapshot_id TEXT NOT NULL, + payload_hash TEXT NOT NULL + CHECK (length(payload_hash) = 64 AND payload_hash NOT GLOB '*[^0-9a-f]*'), + updated_at INTEGER NOT NULL CHECK (updated_at >= 0), + FOREIGN KEY (user_id, snapshot_id) + REFERENCES sync_snapshots (user_id, snapshot_id) ON DELETE RESTRICT, + FOREIGN KEY (user_id, snapshot_id) + REFERENCES sync_snapshot_encryption (user_id, snapshot_id) ON DELETE RESTRICT +); + +INSERT INTO sync_snapshot_heads ( + user_id, + head_revision, + snapshot_id, + payload_hash, + updated_at +) +SELECT user_id, 1, snapshot_id, payload_hash, created_at +FROM sync_snapshots +WHERE head_revision = 1; + +CREATE TRIGGER sync_snapshot_candidate_insert_guard +BEFORE INSERT ON sync_snapshots +FOR EACH ROW +WHEN NEW.head_revision > 0 +BEGIN + SELECT CASE WHEN ( + ( + NEW.head_revision = 1 + AND NEW.base_head_revision IS NULL + AND NEW.base_snapshot_id IS NULL + AND NEW.base_payload_hash IS NULL + AND NOT EXISTS ( + SELECT 1 FROM sync_snapshot_heads WHERE user_id = NEW.user_id + ) + ) + OR + ( + NEW.head_revision > 1 + AND NEW.base_head_revision IS NOT NULL + AND NEW.base_snapshot_id IS NOT NULL + AND NEW.base_payload_hash IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM sync_snapshot_heads AS head + INNER JOIN sync_snapshots AS base + ON base.user_id = head.user_id + AND base.snapshot_id = head.snapshot_id + AND base.payload_hash = head.payload_hash + WHERE head.user_id = NEW.user_id + AND head.head_revision = NEW.base_head_revision + AND head.snapshot_id = NEW.base_snapshot_id + AND head.payload_hash = NEW.base_payload_hash + AND NEW.head_revision = head.head_revision + 1 + AND NEW.logical_clock > base.logical_clock + ) + ) + ) THEN 1 ELSE RAISE(ABORT, 'sync_snapshot_head_cas_failed') END; +END; + +CREATE TRIGGER sync_snapshot_candidate_update_guard +BEFORE UPDATE ON sync_snapshots +FOR EACH ROW +WHEN OLD.head_revision > 0 OR NEW.head_revision > 0 +BEGIN + SELECT CASE WHEN ( + ( + NEW.head_revision = 1 + AND NEW.base_head_revision IS NULL + AND NEW.base_snapshot_id IS NULL + AND NEW.base_payload_hash IS NULL + AND NOT EXISTS ( + SELECT 1 FROM sync_snapshot_heads WHERE user_id = NEW.user_id + ) + ) + OR + ( + NEW.head_revision > 1 + AND NEW.base_head_revision IS NOT NULL + AND NEW.base_snapshot_id IS NOT NULL + AND NEW.base_payload_hash IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM sync_snapshot_heads AS head + INNER JOIN sync_snapshots AS base + ON base.user_id = head.user_id + AND base.snapshot_id = head.snapshot_id + AND base.payload_hash = head.payload_hash + WHERE head.user_id = NEW.user_id + AND head.head_revision = NEW.base_head_revision + AND head.snapshot_id = NEW.base_snapshot_id + AND head.payload_hash = NEW.base_payload_hash + AND NEW.head_revision = head.head_revision + 1 + AND NEW.logical_clock > base.logical_clock + ) + ) + ) THEN 1 ELSE RAISE(ABORT, 'sync_snapshot_head_cas_failed') END; +END; + +CREATE TRIGGER sync_snapshot_head_insert_guard +BEFORE INSERT ON sync_snapshot_heads +FOR EACH ROW +BEGIN + SELECT CASE WHEN EXISTS ( + SELECT 1 + FROM sync_snapshots AS snapshot + INNER JOIN sync_snapshot_encryption AS encryption + ON encryption.user_id = snapshot.user_id + AND encryption.snapshot_id = snapshot.snapshot_id + INNER JOIN sync_vault_accounts AS account + ON account.user_id = snapshot.user_id + AND account.current_key_id = encryption.key_id + AND account.current_generation = encryption.vault_generation + INNER JOIN user_devices AS device + ON device.user_id = snapshot.user_id + AND device.device_id = snapshot.device_id + AND device.approval_status = 'approved' + AND device.revoked_at IS NULL + INNER JOIN user_device_keys AS keys + ON keys.user_id = device.user_id + AND keys.device_id = device.device_id + AND keys.key_protocol_version = 2 + AND keys.wrapping_public_key IS NOT NULL + WHERE snapshot.user_id = NEW.user_id + AND snapshot.snapshot_id = NEW.snapshot_id + AND snapshot.payload_hash = NEW.payload_hash + AND snapshot.head_revision = NEW.head_revision + AND snapshot.head_revision = 1 + AND snapshot.base_head_revision IS NULL + AND snapshot.base_snapshot_id IS NULL + AND snapshot.base_payload_hash IS NULL + AND encryption.encryption_version = 2 + AND NOT EXISTS ( + SELECT 1 + FROM sync_vault_rotation_r2_objects AS staged + INNER JOIN sync_vault_rotations AS rotation + ON rotation.user_id = staged.user_id + AND rotation.idempotency_key = staged.rotation_idempotency_key + WHERE staged.user_id = snapshot.user_id + AND staged.r2_key = snapshot.r2_key + AND rotation.cleanup_started_at IS NOT NULL + ) + ) THEN 1 ELSE RAISE(ABORT, 'sync_snapshot_head_cas_failed') END; +END; + +CREATE TRIGGER sync_snapshot_head_update_guard +BEFORE UPDATE ON sync_snapshot_heads +FOR EACH ROW +BEGIN + SELECT CASE WHEN ( + NEW.user_id = OLD.user_id + AND NEW.head_revision = OLD.head_revision + 1 + AND EXISTS ( + SELECT 1 + FROM sync_snapshots AS snapshot + INNER JOIN sync_snapshot_encryption AS encryption + ON encryption.user_id = snapshot.user_id + AND encryption.snapshot_id = snapshot.snapshot_id + INNER JOIN sync_vault_accounts AS account + ON account.user_id = snapshot.user_id + AND account.current_key_id = encryption.key_id + AND account.current_generation = encryption.vault_generation + INNER JOIN user_devices AS device + ON device.user_id = snapshot.user_id + AND device.device_id = snapshot.device_id + AND device.approval_status = 'approved' + AND device.revoked_at IS NULL + INNER JOIN user_device_keys AS keys + ON keys.user_id = device.user_id + AND keys.device_id = device.device_id + AND keys.key_protocol_version = 2 + AND keys.wrapping_public_key IS NOT NULL + WHERE snapshot.user_id = NEW.user_id + AND snapshot.snapshot_id = NEW.snapshot_id + AND snapshot.payload_hash = NEW.payload_hash + AND snapshot.head_revision = NEW.head_revision + AND snapshot.base_head_revision = OLD.head_revision + AND snapshot.base_snapshot_id = OLD.snapshot_id + AND snapshot.base_payload_hash = OLD.payload_hash + AND encryption.encryption_version = 2 + AND NOT EXISTS ( + SELECT 1 + FROM sync_vault_rotation_r2_objects AS staged + INNER JOIN sync_vault_rotations AS rotation + ON rotation.user_id = staged.user_id + AND rotation.idempotency_key = staged.rotation_idempotency_key + WHERE staged.user_id = snapshot.user_id + AND staged.r2_key = snapshot.r2_key + AND rotation.cleanup_started_at IS NOT NULL + ) + ) + ) THEN 1 ELSE RAISE(ABORT, 'sync_snapshot_head_cas_failed') END; +END; diff --git a/cloudflare/migrations/0013_sync_r2_gc.sql b/cloudflare/migrations/0013_sync_r2_gc.sql new file mode 100644 index 0000000..dcfb3c9 --- /dev/null +++ b/cloudflare/migrations/0013_sync_r2_gc.sql @@ -0,0 +1,309 @@ +CREATE TABLE sync_r2_gc_candidates ( + r2_key TEXT NOT NULL PRIMARY KEY CHECK (length(r2_key) BETWEEN 1 AND 1024), + user_id TEXT, + owner_hash TEXT NOT NULL + CHECK (length(owner_hash) = 64 AND owner_hash NOT GLOB '*[^0-9a-f]*'), + object_kind TEXT NOT NULL CHECK (object_kind IN ('payload', 'snapshot')), + state TEXT NOT NULL CHECK (state IN ('pending', 'referenced', 'ready', 'deleting', 'deleted')), + write_token TEXT + CHECK ( + write_token IS NULL + OR (length(write_token) = 64 AND write_token NOT GLOB '*[^0-9a-f]*') + ), + lease_expires_at INTEGER NOT NULL CHECK (lease_expires_at >= 0), + gc_token TEXT + CHECK ( + gc_token IS NULL + OR (length(gc_token) = 64 AND gc_token NOT GLOB '*[^0-9a-f]*') + ), + created_at INTEGER NOT NULL CHECK (created_at >= 0), + updated_at INTEGER NOT NULL CHECK (updated_at >= created_at), + referenced_at INTEGER, + ready_at INTEGER, + delete_started_at INTEGER, + deleted_at INTEGER, + CHECK (state <> 'pending' OR (user_id IS NOT NULL AND write_token IS NOT NULL)), + CHECK (state <> 'referenced' OR referenced_at IS NOT NULL), + CHECK (state NOT IN ('ready', 'deleting', 'deleted') OR ready_at IS NOT NULL), + CHECK (state NOT IN ('deleting', 'deleted') OR (gc_token IS NOT NULL AND delete_started_at IS NOT NULL)), + CHECK (state <> 'deleted' OR deleted_at IS NOT NULL) +); + +CREATE INDEX idx_sync_r2_gc_ready + ON sync_r2_gc_candidates (state, lease_expires_at, delete_started_at, updated_at); + +CREATE INDEX idx_sync_r2_gc_user + ON sync_r2_gc_candidates (user_id, state, updated_at); + +CREATE INDEX idx_sync_r2_gc_owner + ON sync_r2_gc_candidates (owner_hash, state, updated_at); + +CREATE TABLE sync_r2_inventory_cursors ( + prefix TEXT NOT NULL PRIMARY KEY CHECK (prefix IN ('sync-payloads/', 'sync-snapshots/')), + cursor TEXT, + updated_at INTEGER NOT NULL CHECK (updated_at >= 0), + next_scan_at INTEGER NOT NULL CHECK (next_scan_at >= updated_at) +); + +INSERT INTO sync_r2_inventory_cursors (prefix, cursor, updated_at, next_scan_at) +VALUES + ('sync-payloads/', NULL, 0, 0), + ('sync-snapshots/', NULL, 0, 0); + +INSERT OR IGNORE INTO sync_r2_gc_candidates ( + r2_key, user_id, owner_hash, object_kind, state, write_token, + lease_expires_at, gc_token, created_at, updated_at, referenced_at, + ready_at, delete_started_at, deleted_at +) +SELECT + object.payload_r2_key, + object.user_id, + substr( + object.payload_r2_key, + instr(object.payload_r2_key, '/') + + instr(substr(object.payload_r2_key, instr(object.payload_r2_key, '/') + 1), '/') + + 1, + 64 + ), + 'payload', + 'referenced', + NULL, + 0, + NULL, + object.created_at, + object.updated_at, + object.updated_at, + NULL, + NULL, + NULL +FROM sync_objects AS object +WHERE object.payload_r2_key IS NOT NULL; + +INSERT OR IGNORE INTO sync_r2_gc_candidates ( + r2_key, user_id, owner_hash, object_kind, state, write_token, + lease_expires_at, gc_token, created_at, updated_at, referenced_at, + ready_at, delete_started_at, deleted_at +) +SELECT + snapshot.r2_key, + snapshot.user_id, + substr( + snapshot.r2_key, + instr(snapshot.r2_key, '/') + + instr(substr(snapshot.r2_key, instr(snapshot.r2_key, '/') + 1), '/') + + 1, + 64 + ), + 'snapshot', + 'referenced', + NULL, + 0, + NULL, + snapshot.created_at, + snapshot.created_at, + snapshot.created_at, + NULL, + NULL, + NULL +FROM sync_snapshots AS snapshot; + +CREATE TRIGGER sync_r2_gc_state_transition_guard +BEFORE UPDATE OF state ON sync_r2_gc_candidates +FOR EACH ROW +WHEN NOT ( + OLD.state = NEW.state + OR (OLD.state = 'pending' AND NEW.state IN ('referenced', 'ready', 'deleting')) + OR (OLD.state = 'referenced' AND NEW.state = 'ready') + OR (OLD.state = 'ready' AND NEW.state = 'deleting') + OR (OLD.state = 'deleting' AND NEW.state = 'deleted') + OR (OLD.state = 'deleted' AND NEW.state = 'ready') +) +BEGIN + SELECT RAISE(ABORT, 'sync_r2_gc_state_transition_invalid'); +END; + +CREATE TRIGGER sync_r2_snapshot_insert_fence +BEFORE INSERT ON sync_snapshots +FOR EACH ROW +BEGIN + SELECT CASE WHEN EXISTS ( + SELECT 1 FROM sync_r2_gc_candidates AS candidate + WHERE candidate.r2_key = NEW.r2_key + AND candidate.user_id = NEW.user_id + AND candidate.object_kind = 'snapshot' + AND candidate.state = 'pending' + AND candidate.write_token IS NOT NULL + ) THEN 1 ELSE RAISE(ABORT, 'sync_r2_write_fenced') END; +END; + +CREATE TRIGGER sync_r2_snapshot_update_fence +BEFORE UPDATE OF r2_key, payload_hash, head_revision ON sync_snapshots +FOR EACH ROW +WHEN OLD.r2_key <> NEW.r2_key + OR OLD.payload_hash <> NEW.payload_hash + OR OLD.head_revision <> NEW.head_revision +BEGIN + SELECT CASE WHEN EXISTS ( + SELECT 1 FROM sync_r2_gc_candidates AS candidate + WHERE candidate.r2_key = NEW.r2_key + AND candidate.user_id = NEW.user_id + AND candidate.object_kind = 'snapshot' + AND candidate.state = 'pending' + AND candidate.write_token IS NOT NULL + ) THEN 1 ELSE RAISE(ABORT, 'sync_r2_write_fenced') END; +END; + +CREATE TRIGGER sync_r2_snapshot_head_insert_fence +BEFORE INSERT ON sync_snapshot_heads +FOR EACH ROW +BEGIN + SELECT CASE WHEN EXISTS ( + SELECT 1 + FROM sync_snapshots AS snapshot + INNER JOIN sync_r2_gc_candidates AS candidate + ON candidate.r2_key = snapshot.r2_key + AND candidate.user_id = snapshot.user_id + AND candidate.object_kind = 'snapshot' + AND candidate.state = 'pending' + AND candidate.write_token IS NOT NULL + WHERE snapshot.user_id = NEW.user_id + AND snapshot.snapshot_id = NEW.snapshot_id + AND snapshot.head_revision = NEW.head_revision + AND snapshot.payload_hash = NEW.payload_hash + ) THEN 1 ELSE RAISE(ABORT, 'sync_r2_write_fenced') END; +END; + +CREATE TRIGGER sync_r2_snapshot_head_update_fence +BEFORE UPDATE ON sync_snapshot_heads +FOR EACH ROW +BEGIN + SELECT CASE WHEN EXISTS ( + SELECT 1 + FROM sync_snapshots AS snapshot + INNER JOIN sync_r2_gc_candidates AS candidate + ON candidate.r2_key = snapshot.r2_key + AND candidate.user_id = snapshot.user_id + AND candidate.object_kind = 'snapshot' + AND candidate.state = 'pending' + AND candidate.write_token IS NOT NULL + WHERE snapshot.user_id = NEW.user_id + AND snapshot.snapshot_id = NEW.snapshot_id + AND snapshot.head_revision = NEW.head_revision + AND snapshot.payload_hash = NEW.payload_hash + ) THEN 1 ELSE RAISE(ABORT, 'sync_r2_write_fenced') END; +END; + +CREATE TRIGGER sync_r2_payload_insert_fence +BEFORE INSERT ON sync_objects +FOR EACH ROW +WHEN NEW.payload_r2_key IS NOT NULL +BEGIN + SELECT CASE WHEN EXISTS ( + SELECT 1 FROM sync_r2_gc_candidates AS candidate + WHERE candidate.r2_key = NEW.payload_r2_key + AND candidate.user_id = NEW.user_id + AND candidate.object_kind = 'payload' + AND candidate.state = 'pending' + AND candidate.write_token IS NOT NULL + ) THEN 1 ELSE RAISE(ABORT, 'sync_r2_write_fenced') END; +END; + +CREATE TRIGGER sync_r2_payload_update_fence +BEFORE UPDATE OF payload_r2_key ON sync_objects +FOR EACH ROW +WHEN NEW.payload_r2_key IS NOT NULL + AND OLD.payload_r2_key IS NOT NEW.payload_r2_key +BEGIN + SELECT CASE WHEN EXISTS ( + SELECT 1 FROM sync_r2_gc_candidates AS candidate + WHERE candidate.r2_key = NEW.payload_r2_key + AND candidate.user_id = NEW.user_id + AND candidate.object_kind = 'payload' + AND candidate.state = 'pending' + AND candidate.write_token IS NOT NULL + ) THEN 1 ELSE RAISE(ABORT, 'sync_r2_write_fenced') END; +END; + +CREATE TRIGGER sync_r2_snapshot_mark_referenced_guard +BEFORE UPDATE OF state ON sync_r2_gc_candidates +FOR EACH ROW +WHEN OLD.object_kind = 'snapshot' + AND OLD.state = 'pending' + AND NEW.state = 'referenced' +BEGIN + SELECT CASE WHEN EXISTS ( + SELECT 1 + FROM sync_snapshot_heads AS head + INNER JOIN sync_snapshots AS snapshot + ON snapshot.user_id = head.user_id + AND snapshot.snapshot_id = head.snapshot_id + AND snapshot.head_revision = head.head_revision + AND snapshot.payload_hash = head.payload_hash + WHERE snapshot.r2_key = OLD.r2_key + AND snapshot.user_id = OLD.user_id + ) THEN 1 ELSE RAISE(ABORT, 'sync_r2_reference_commit_invalid') END; +END; + +CREATE TRIGGER sync_r2_snapshot_displaced +AFTER UPDATE OF r2_key ON sync_snapshots +FOR EACH ROW +WHEN OLD.r2_key <> NEW.r2_key +BEGIN + UPDATE sync_r2_gc_candidates + SET + state = 'ready', + updated_at = MAX(updated_at, unixepoch()), + ready_at = COALESCE(ready_at, unixepoch()) + WHERE r2_key = OLD.r2_key + AND state = 'referenced' + AND NOT EXISTS (SELECT 1 FROM sync_snapshots WHERE r2_key = OLD.r2_key) + AND NOT EXISTS (SELECT 1 FROM sync_objects WHERE payload_r2_key = OLD.r2_key); +END; + +CREATE TRIGGER sync_r2_snapshot_deleted +AFTER DELETE ON sync_snapshots +FOR EACH ROW +BEGIN + UPDATE sync_r2_gc_candidates + SET + state = 'ready', + updated_at = MAX(updated_at, unixepoch()), + ready_at = COALESCE(ready_at, unixepoch()) + WHERE r2_key = OLD.r2_key + AND state = 'referenced' + AND NOT EXISTS (SELECT 1 FROM sync_snapshots WHERE r2_key = OLD.r2_key) + AND NOT EXISTS (SELECT 1 FROM sync_objects WHERE payload_r2_key = OLD.r2_key); +END; + +CREATE TRIGGER sync_r2_payload_displaced +AFTER UPDATE OF payload_r2_key ON sync_objects +FOR EACH ROW +WHEN OLD.payload_r2_key IS NOT NULL + AND OLD.payload_r2_key IS NOT NEW.payload_r2_key +BEGIN + UPDATE sync_r2_gc_candidates + SET + state = 'ready', + updated_at = MAX(updated_at, unixepoch()), + ready_at = COALESCE(ready_at, unixepoch()) + WHERE r2_key = OLD.payload_r2_key + AND state = 'referenced' + AND NOT EXISTS (SELECT 1 FROM sync_snapshots WHERE r2_key = OLD.payload_r2_key) + AND NOT EXISTS (SELECT 1 FROM sync_objects WHERE payload_r2_key = OLD.payload_r2_key); +END; + +CREATE TRIGGER sync_r2_payload_deleted +AFTER DELETE ON sync_objects +FOR EACH ROW +WHEN OLD.payload_r2_key IS NOT NULL +BEGIN + UPDATE sync_r2_gc_candidates + SET + state = 'ready', + updated_at = MAX(updated_at, unixepoch()), + ready_at = COALESCE(ready_at, unixepoch()) + WHERE r2_key = OLD.payload_r2_key + AND state = 'referenced' + AND NOT EXISTS (SELECT 1 FROM sync_snapshots WHERE r2_key = OLD.payload_r2_key) + AND NOT EXISTS (SELECT 1 FROM sync_objects WHERE payload_r2_key = OLD.payload_r2_key); +END; diff --git a/cloudflare/src/account_deletion.ts b/cloudflare/src/account_deletion.ts index 1f93e96..7d1c74c 100644 --- a/cloudflare/src/account_deletion.ts +++ b/cloudflare/src/account_deletion.ts @@ -1,12 +1,31 @@ import type { AuthContext } from "./auth.js"; -import { authSessionCacheKvKey } from "./auth.js"; -import type { ElyD1PreparedStatement, Env } from "./bindings.js"; -import { StorageObjectError, deleteKnownObject } from "./storage.js"; +import type { ElyD1DatabaseSession, ElyD1PreparedStatement, ElyD1Result, Env } from "./bindings.js"; +import { primaryD1Session } from "./bindings.js"; +import { + assertDestructiveActionGateResult, + destructiveActionGateIsLive, + destructiveActionGateStatement, +} from "./destructive_action_gate.js"; +import { deleteLegacySessionKeys } from "./legacy_auth_kv_cleanup.js"; +import { + type RecentDeviceActionProof, + RecentDeviceActionPermissionError, + RecentDeviceActionRequestError, + assertFreshDeviceActionProof, + assertRecentDeviceActionProof, + recentDeviceActionProof, + recentDeviceActionRequestHash, +} from "./recent_device_action_proof.js"; +import { + SYNC_R2_ANONYMIZE_USER_QUERY, + SYNC_R2_FENCE_USER_QUERY, + collectSyncR2Garbage, +} from "./sync_r2_gc.js"; const ACCOUNT_DELETION_CONFIRMATION = "delete-elydora-account"; const IDEMPOTENCY_KEY_PATTERN = /^[a-zA-Z0-9._:-]{16,128}$/; const ACCOUNT_DELETION_EVENT_QUERY = ` - SELECT actor_device_id, outcome, subject_id, created_at + SELECT actor_device_id, outcome, subject_id, metadata_hash, created_at FROM audit_events WHERE event_id = ? AND event_type = 'account.delete' `; @@ -25,19 +44,24 @@ const ACCOUNT_DELETION_COUNTS_QUERY = ` (SELECT COUNT(*) FROM better_auth_user WHERE id = ?) AS users `; const ACCOUNT_DELETION_R2_KEYS_QUERY = ` - SELECT payload_r2_key AS r2_key - FROM sync_objects - WHERE user_id = ? AND payload_r2_key IS NOT NULL - UNION - SELECT r2_key - FROM sync_snapshots - WHERE user_id = ? + SELECT r2_key FROM sync_r2_gc_candidates + WHERE user_id = ? AND state <> 'deleted' ORDER BY r2_key ASC `; +const ACCOUNT_DELETION_SESSION_TOKENS_QUERY = ` + SELECT token FROM better_auth_session + WHERE userId = ? + ORDER BY id ASC +`; const DELETE_SYNC_CHANGE_LOG_QUERY = "DELETE FROM sync_change_log WHERE user_id = ?"; const DELETE_SYNC_TOMBSTONES_QUERY = "DELETE FROM sync_tombstones WHERE user_id = ?"; +const DELETE_SYNC_SNAPSHOT_HEADS_QUERY = "DELETE FROM sync_snapshot_heads WHERE user_id = ?"; +const DELETE_SYNC_SNAPSHOT_ENCRYPTION_QUERY = + "DELETE FROM sync_snapshot_encryption WHERE user_id = ?"; const DELETE_SYNC_SNAPSHOTS_QUERY = "DELETE FROM sync_snapshots WHERE user_id = ?"; const DELETE_SYNC_OBJECTS_QUERY = "DELETE FROM sync_objects WHERE user_id = ?"; +const DELETE_SYNC_VAULT_ENVELOPES_QUERY = "DELETE FROM sync_vault_envelopes WHERE user_id = ?"; +const DELETE_SYNC_VAULT_ACCOUNTS_QUERY = "DELETE FROM sync_vault_accounts WHERE user_id = ?"; const DELETE_DEVICE_APPROVALS_QUERY = "DELETE FROM device_approvals WHERE user_id = ?"; const DELETE_USER_DEVICES_QUERY = "DELETE FROM user_devices WHERE user_id = ?"; const DELETE_SESSION_DEVICE_CONTEXTS_QUERY = @@ -46,20 +70,6 @@ const DELETE_BETTER_AUTH_SESSIONS_QUERY = "DELETE FROM better_auth_session WHERE const DELETE_BETTER_AUTH_ACCOUNTS_QUERY = "DELETE FROM better_auth_account WHERE userId = ?"; const DELETE_BETTER_AUTH_USER_QUERY = "DELETE FROM better_auth_user WHERE id = ?"; const DELETE_USER_AUDIT_EVENTS_QUERY = "DELETE FROM audit_events WHERE user_id = ?"; -const ACCOUNT_DELETION_AUDIT_INSERT_QUERY = ` - INSERT INTO audit_events ( - event_id, - user_id, - actor_device_id, - event_type, - subject_type, - subject_id, - outcome, - metadata_hash, - created_at - ) VALUES (?, NULL, ?, 'account.delete', 'account', ?, 'success', ?, ?) - ON CONFLICT(event_id) DO NOTHING -`; export interface AccountDeletionDocument { version: 1; @@ -86,7 +96,7 @@ export interface AccountDeletionDeletedDocument { kv_session_cache: number; } -interface AccountDeletionRequest { +interface AccountDeletionRequest extends RecentDeviceActionProof { idempotencyKey: string; } @@ -94,6 +104,7 @@ interface AccountDeletionEventRow { actor_device_id: unknown; outcome: unknown; subject_id: unknown; + metadata_hash: unknown; created_at: unknown; } @@ -114,6 +125,7 @@ interface AccountDeletionCountsRow { interface AccountDeletionR2KeyRow { r2_key: unknown; } +interface AccountDeletionSessionTokenRow { token: unknown } type RequestBody = Record; @@ -141,32 +153,77 @@ export async function accountDeletionDocument( const deletion = await accountDeletionRequest(request); const accountHash = await sha256Hex(textBytes(context.userId)); const idempotencyHash = await sha256Hex(textBytes(deletion.idempotencyKey)); + const requestHash = await recentDeviceActionRequestHash({ + action: "account.delete", + userId: context.userId, + sessionId: context.sessionId, + deviceId, + confirmation: ACCOUNT_DELETION_CONFIRMATION, + idempotencyKey: deletion.idempotencyKey, + proofCreatedAt: deletion.proofCreatedAt, + actionProof: deletion.actionProof, + }); const eventId = accountDeletionEventId(accountHash, idempotencyHash); - const existingEvent = await env.ELY_DB.prepare(ACCOUNT_DELETION_EVENT_QUERY) + const database = primaryD1Session(env.ELY_DB); + const existingEvent = await database.prepare(ACCOUNT_DELETION_EVENT_QUERY) .bind(eventId) .first(); if (existingEvent !== null) { - return existingDeletionDocument(accountHash, deviceId, deletion, existingEvent); + return existingDeletionDocument(accountHash, deviceId, requestHash, deletion, existingEvent); } + const signingPublicKey = await assertRecentDeviceActionProof( + database, + context, + "account.delete", + ACCOUNT_DELETION_CONFIRMATION, + deletion.idempotencyKey, + deletion, + ); + assertFreshDeviceActionProof(deletion, nowSeconds, true); - const counts = await accountDeletionCounts(env, context.userId); - const r2Keys = await accountDeletionR2Keys(env, context.userId); - for (const key of r2Keys) { - await deleteAccountObject(env, key); - } - - await env.ELY_DB.batch( + const counts = await accountDeletionCounts(database, context.userId); + const r2Keys = await accountDeletionR2Keys(database, context.userId); + const sessionTokens = await accountDeletionSessionTokens(database, context.userId); + let results: ElyD1Result[]; + try { + results = await database.batch( accountDeletionStatements( - env, - context.userId, - deviceId, + database, + context, + signingPublicKey, accountHash, - idempotencyHash, + requestHash, eventId, nowSeconds, ), ); - await deleteCurrentSessionCache(env, context.tokenHash); + } catch (error) { + const replayDatabase = primaryD1Session(env.ELY_DB); + const racedEvent = await replayDatabase.prepare(ACCOUNT_DELETION_EVENT_QUERY) + .bind(eventId) + .first(); + if (racedEvent !== null) { + return existingDeletionDocument(accountHash, deviceId, requestHash, deletion, racedEvent); + } + if (!(await destructiveActionGateIsLive( + replayDatabase, + context, + signingPublicKey, + nowSeconds, + ))) { + throw new RecentDeviceActionPermissionError("device_action_gate_failed"); + } + throw error; + } + assertDestructiveActionGateResult(results[0]); + const kvSessionCache = await cleanupDeletedAccount( + env, + accountHash, + sessionTokens, + context.tokenHash, + nowSeconds, + r2Keys.length, + ); return { version: 1, @@ -174,20 +231,22 @@ export async function accountDeletionDocument( device_id: deviceId, idempotency_key: deletion.idempotencyKey, deleted_at: nowSeconds, - deleted: { ...counts, r2_objects: r2Keys.length, kv_session_cache: 1 }, + deleted: { ...counts, r2_objects: r2Keys.length, kv_session_cache: kvSessionCache }, }; } function existingDeletionDocument( accountHash: string, deviceId: string, + requestHash: string, deletion: AccountDeletionRequest, row: AccountDeletionEventRow, ): AccountDeletionDocument { if ( row.actor_device_id !== deviceId || row.outcome !== "success" || - row.subject_id !== accountHash + row.subject_id !== accountHash || + row.metadata_hash !== requestHash ) { throw new AccountDeletionRequestError("account_deletion_replay_mismatch"); } @@ -202,10 +261,10 @@ function existingDeletionDocument( } async function accountDeletionCounts( - env: Env, + database: ElyD1DatabaseSession, userId: string, ): Promise> { - const row = await env.ELY_DB.prepare(ACCOUNT_DELETION_COUNTS_QUERY) + const row = await database.prepare(ACCOUNT_DELETION_COUNTS_QUERY) .bind(userId, userId, userId, userId, userId, userId, userId, userId, userId, userId, userId) .first(); if (row === null) { @@ -226,69 +285,126 @@ async function accountDeletionCounts( }; } -async function accountDeletionR2Keys(env: Env, userId: string): Promise { - const result = await env.ELY_DB.prepare(ACCOUNT_DELETION_R2_KEYS_QUERY) - .bind(userId, userId) +async function accountDeletionR2Keys( + database: ElyD1DatabaseSession, + userId: string, +): Promise { + const result = await database.prepare(ACCOUNT_DELETION_R2_KEYS_QUERY) + .bind(userId) .all(); return result.results.map(r2Key); } -function accountDeletionStatements( - env: Env, +async function accountDeletionSessionTokens( + database: ElyD1DatabaseSession, userId: string, - deviceId: string, +): Promise { + const result = await database.prepare(ACCOUNT_DELETION_SESSION_TOKENS_QUERY) + .bind(userId) + .all(); + return result.results.map((row) => { + if (typeof row.token !== "string" || row.token.length === 0) { + throw new AccountDeletionPersistenceError("session_token_invalid"); + } + return row.token; + }); +} + +function accountDeletionStatements( + database: ElyD1DatabaseSession, + context: AuthContext, + signingPublicKey: string, accountHash: string, - idempotencyHash: string, + requestHash: string, eventId: string, nowSeconds: number, ): ElyD1PreparedStatement[] { + const userId = context.userId; return [ - env.ELY_DB.prepare(DELETE_SYNC_CHANGE_LOG_QUERY).bind(userId), - env.ELY_DB.prepare(DELETE_SYNC_TOMBSTONES_QUERY).bind(userId), - env.ELY_DB.prepare(DELETE_SYNC_SNAPSHOTS_QUERY).bind(userId), - env.ELY_DB.prepare(DELETE_SYNC_OBJECTS_QUERY).bind(userId), - env.ELY_DB.prepare(DELETE_DEVICE_APPROVALS_QUERY).bind(userId), - env.ELY_DB.prepare(DELETE_SESSION_DEVICE_CONTEXTS_QUERY).bind(userId), - env.ELY_DB.prepare(DELETE_USER_DEVICES_QUERY).bind(userId), - env.ELY_DB.prepare(DELETE_BETTER_AUTH_SESSIONS_QUERY).bind(userId), - env.ELY_DB.prepare(DELETE_BETTER_AUTH_ACCOUNTS_QUERY).bind(userId), - env.ELY_DB.prepare(DELETE_BETTER_AUTH_USER_QUERY).bind(userId), - env.ELY_DB.prepare(DELETE_USER_AUDIT_EVENTS_QUERY).bind(userId), - env.ELY_DB.prepare(ACCOUNT_DELETION_AUDIT_INSERT_QUERY).bind( + destructiveActionGateStatement(database, context, signingPublicKey, { eventId, - deviceId, - accountHash, - idempotencyHash, + auditUserId: null, + eventType: "account.delete", + subjectType: "account", + subjectId: accountHash, + metadataHash: requestHash, + }, nowSeconds), + database.prepare(SYNC_R2_FENCE_USER_QUERY).bind( nowSeconds, + nowSeconds, + nowSeconds, + userId, ), + database.prepare(DELETE_SYNC_CHANGE_LOG_QUERY).bind(userId), + database.prepare(DELETE_SYNC_TOMBSTONES_QUERY).bind(userId), + database.prepare(DELETE_SYNC_SNAPSHOT_HEADS_QUERY).bind(userId), + database.prepare(DELETE_SYNC_SNAPSHOT_ENCRYPTION_QUERY).bind(userId), + database.prepare(DELETE_SYNC_SNAPSHOTS_QUERY).bind(userId), + database.prepare(DELETE_SYNC_OBJECTS_QUERY).bind(userId), + database.prepare(DELETE_SYNC_VAULT_ENVELOPES_QUERY).bind(userId), + database.prepare(DELETE_SYNC_VAULT_ACCOUNTS_QUERY).bind(userId), + database.prepare(DELETE_DEVICE_APPROVALS_QUERY).bind(userId), + database.prepare(DELETE_SESSION_DEVICE_CONTEXTS_QUERY).bind(userId), + database.prepare(DELETE_USER_DEVICES_QUERY).bind(userId), + database.prepare(DELETE_BETTER_AUTH_SESSIONS_QUERY).bind(userId), + database.prepare(DELETE_BETTER_AUTH_ACCOUNTS_QUERY).bind(userId), + database.prepare(DELETE_BETTER_AUTH_USER_QUERY).bind(userId), + database.prepare(DELETE_USER_AUDIT_EVENTS_QUERY).bind(userId), + database.prepare(SYNC_R2_ANONYMIZE_USER_QUERY).bind(nowSeconds, userId, accountHash), ]; } -async function deleteAccountObject(env: Env, key: string): Promise { +async function cleanupDeletedAccount( + env: Env, + accountHash: string, + sessionTokens: string[], + currentTokenHash: string, + nowSeconds: number, + candidateCount: number, +): Promise { try { - await deleteKnownObject(env.ELY_STORAGE, key); - } catch (error) { - if (error instanceof StorageObjectError) { - throw new AccountDeletionPersistenceError(error.message); + const maxBatches = Math.ceil(candidateCount / 100) + 1; + for (let batch = 0; batch < maxBatches; batch += 1) { + if (await collectSyncR2Garbage(env, nowSeconds, { ownerHash: accountHash, limit: 100 }) < 100) { + break; + } } - throw error; + } catch { + // Scheduled maintenance drains the durable GC ledger. + } + try { + return await deleteLegacySessionKeys(env, sessionTokens, currentTokenHash); + } catch { + return 0; } -} - -function deleteCurrentSessionCache(env: Env, tokenHash: string): Promise { - return env.ELY_KV.delete(authSessionCacheKvKey(env.ELY_ENVIRONMENT, tokenHash)); } async function accountDeletionRequest(request: Request): Promise { const body = await requestBody(request); - assertOnlyFields(body, ["version", "confirmation", "idempotency_key"]); - if (body.version !== 1) { + assertOnlyFields(body, [ + "version", + "confirmation", + "idempotency_key", + "proof_created_at", + "action_proof", + ]); + if (body.version !== 2) { throw new AccountDeletionRequestError("version_invalid"); } if (body.confirmation !== ACCOUNT_DELETION_CONFIRMATION) { throw new AccountDeletionRequestError("confirmation_invalid"); } - return { idempotencyKey: idempotencyKey(body.idempotency_key) }; + try { + return { + idempotencyKey: idempotencyKey(body.idempotency_key), + ...recentDeviceActionProof(body.proof_created_at, body.action_proof), + }; + } catch (error) { + if (error instanceof RecentDeviceActionRequestError) { + throw new AccountDeletionRequestError(error.message); + } + throw error; + } } async function requestBody(request: Request): Promise { diff --git a/cloudflare/src/api_controls.ts b/cloudflare/src/api_controls.ts index 8e1f96d..5a55512 100644 --- a/cloudflare/src/api_controls.ts +++ b/cloudflare/src/api_controls.ts @@ -14,9 +14,13 @@ export type ApiHandler = () => Promise; export type AuthenticatedApiHandler = (context: AuthContext) => Promise; const APPROVED_DEVICE_QUERY = ` - SELECT device_id - FROM user_devices - WHERE user_id = ? AND device_id = ? AND approval_status = 'approved' AND revoked_at IS NULL + SELECT device.device_id + FROM user_devices AS device + INNER JOIN user_device_keys AS keys + ON keys.user_id = device.user_id AND keys.device_id = device.device_id + WHERE device.user_id = ? AND device.device_id = ? + AND device.approval_status = 'approved' AND device.revoked_at IS NULL + AND keys.key_protocol_version = 2 AND keys.wrapping_public_key IS NOT NULL `; export async function withPublicApiControls( diff --git a/cloudflare/src/auth.ts b/cloudflare/src/auth.ts index 6385919..556d830 100644 --- a/cloudflare/src/auth.ts +++ b/cloudflare/src/auth.ts @@ -10,6 +10,7 @@ const BETTER_AUTH_SESSION_QUERY = ` session.id, session.userId, session.expiresAt, + session.createdAt, device_context.device_id AS deviceId FROM better_auth_session AS session LEFT JOIN better_auth_session_device_context AS device_context @@ -22,6 +23,7 @@ export interface AuthContext { sessionId: string; tokenHash: string; expiresAt: string; + createdAt: string; deviceId?: string; } @@ -29,6 +31,7 @@ interface BetterAuthSessionRow extends Record { id: unknown; userId: unknown; expiresAt: unknown; + createdAt: unknown; deviceId?: unknown; } @@ -111,6 +114,7 @@ async function readBetterAuthSessionContext( sessionId: subjectId(stringField(row, "id"), "session_id"), tokenHash, expiresAt: timestampField(row, "expiresAt"), + createdAt: timestampField(row, "createdAt"), }; const deviceId = optionalStringField(row, "deviceId"); if (deviceId !== undefined) { diff --git a/cloudflare/src/bindings.ts b/cloudflare/src/bindings.ts index 69f2d1a..c563017 100644 --- a/cloudflare/src/bindings.ts +++ b/cloudflare/src/bindings.ts @@ -35,10 +35,18 @@ export interface ElyD1Result { }; } -export interface ElyD1Database { +export interface ElyD1DatabaseSession { prepare(query: string): ElyD1PreparedStatement; batch(statements: ElyD1PreparedStatement[]): Promise; +} + +export interface ElyD1Database extends ElyD1DatabaseSession { exec(query: string): Promise; + withSession?(constraint: "first-primary"): ElyD1DatabaseSession; +} + +export function primaryD1Session(database: ElyD1Database): ElyD1DatabaseSession { + return database.withSession?.("first-primary") ?? database; } export interface ElyRateLimit { diff --git a/cloudflare/src/destructive_action_gate.ts b/cloudflare/src/destructive_action_gate.ts new file mode 100644 index 0000000..ed805f1 --- /dev/null +++ b/cloudflare/src/destructive_action_gate.ts @@ -0,0 +1,143 @@ +import type { AuthContext } from "./auth.js"; +import type { ElyD1DatabaseSession, ElyD1PreparedStatement, ElyD1Result } from "./bindings.js"; + +const DESTRUCTIVE_ACTION_GATE_INSERT_QUERY = ` + INSERT INTO audit_events ( + event_id, + user_id, + actor_device_id, + event_type, + subject_type, + subject_id, + outcome, + metadata_hash, + created_at + ) VALUES ( + ?, ?, ?, ?, ?, ?, + CASE WHEN EXISTS ( + SELECT 1 + FROM better_auth_session AS session + INNER JOIN better_auth_session_device_context AS session_device + ON session_device.session_id = session.id + AND session_device.user_id = session.userId + INNER JOIN user_devices AS device + ON device.user_id = session_device.user_id + AND device.device_id = session_device.device_id + INNER JOIN user_device_keys AS keys + ON keys.user_id = device.user_id + AND keys.device_id = device.device_id + WHERE session.id = ? AND session.userId = ? + AND session_device.device_id = ? + AND ( + (typeof(session.expiresAt) = 'text' AND julianday(session.expiresAt) > julianday(?)) + OR + (typeof(session.expiresAt) IN ('integer', 'real') AND session.expiresAt > ?) + ) + AND device.approval_status = 'approved' AND device.revoked_at IS NULL + AND keys.key_protocol_version = 2 AND keys.wrapping_public_key IS NOT NULL + AND keys.signing_public_key = ? + ) THEN 'success' ELSE NULL END, + ?, ? + ) +`; + +const DESTRUCTIVE_ACTION_LIVE_QUERY = ` + SELECT 1 AS authorized + FROM better_auth_session AS session + INNER JOIN better_auth_session_device_context AS session_device + ON session_device.session_id = session.id + AND session_device.user_id = session.userId + INNER JOIN user_devices AS device + ON device.user_id = session_device.user_id + AND device.device_id = session_device.device_id + INNER JOIN user_device_keys AS keys + ON keys.user_id = device.user_id + AND keys.device_id = device.device_id + WHERE session.id = ? AND session.userId = ? + AND session_device.device_id = ? + AND ( + (typeof(session.expiresAt) = 'text' AND julianday(session.expiresAt) > julianday(?)) + OR + (typeof(session.expiresAt) IN ('integer', 'real') AND session.expiresAt > ?) + ) + AND device.approval_status = 'approved' AND device.revoked_at IS NULL + AND keys.key_protocol_version = 2 AND keys.wrapping_public_key IS NOT NULL + AND keys.signing_public_key = ? +`; + +export interface DestructiveActionGate { + eventId: string; + auditUserId: string | null; + eventType: "account.delete" | "sync.reset"; + subjectType: "account" | "sync"; + subjectId: string; + metadataHash: string | null; +} + +export function destructiveActionGateStatement( + database: ElyD1DatabaseSession, + context: AuthContext, + signingPublicKey: string, + gate: DestructiveActionGate, + nowSeconds: number, +): ElyD1PreparedStatement { + if (context.deviceId === undefined) { + throw new DestructiveActionGateError("device_context_required"); + } + const now = new Date(nowSeconds * 1000); + if (!Number.isSafeInteger(nowSeconds) || nowSeconds < 0 || !Number.isFinite(now.getTime())) { + throw new DestructiveActionGateError("destructive_action_time_invalid"); + } + return database.prepare(DESTRUCTIVE_ACTION_GATE_INSERT_QUERY).bind( + gate.eventId, + gate.auditUserId, + context.deviceId, + gate.eventType, + gate.subjectType, + gate.subjectId, + context.sessionId, + context.userId, + context.deviceId, + now.toISOString(), + now.getTime(), + signingPublicKey, + gate.metadataHash, + nowSeconds, + ); +} + +export function assertDestructiveActionGateResult(result: unknown): void { + if (changedRows(result) !== 1) { + throw new DestructiveActionGateError("destructive_action_gate_failed"); + } +} + +export async function destructiveActionGateIsLive( + database: ElyD1DatabaseSession, + context: AuthContext, + signingPublicKey: string, + nowSeconds: number, +): Promise { + if (context.deviceId === undefined) return false; + const now = new Date(nowSeconds * 1000); + if (!Number.isSafeInteger(nowSeconds) || nowSeconds < 0 || !Number.isFinite(now.getTime())) { + throw new DestructiveActionGateError("destructive_action_time_invalid"); + } + const row = await database.prepare(DESTRUCTIVE_ACTION_LIVE_QUERY).bind( + context.sessionId, + context.userId, + context.deviceId, + now.toISOString(), + now.getTime(), + signingPublicKey, + ).first<{ authorized: unknown }>(); + return row?.authorized === 1; +} + +export class DestructiveActionGateError extends Error {} + +function changedRows(result: unknown): number { + if (typeof result !== "object" || result === null || !("meta" in result)) return -1; + const changes = (result as ElyD1Result).meta?.changes; + return typeof changes === "number" && Number.isSafeInteger(changes) ? changes : -1; +} diff --git a/cloudflare/src/device_approval.ts b/cloudflare/src/device_approval.ts new file mode 100644 index 0000000..a9439d3 --- /dev/null +++ b/cloudflare/src/device_approval.ts @@ -0,0 +1,406 @@ +import type { AuthContext } from "./auth.js"; +import type { Env } from "./bindings.js"; +import { + assertDeviceApprovalProof, + assertFreshDeviceApprovalProof, +} from "./device_approval_proof.js"; +import { + type DeviceApprovalDocument, + type DeviceApprovalRequest, + type DeviceApprovalRow, + type DeviceRow, + DeviceConflictError, + DevicePermissionError, + DevicePersistenceError, + DeviceSchemaError, + approvedDeviceDocument, + currentDeviceId, + deviceApprovalRequest, + deviceDocument, + deviceIdValue, + idempotencyKeyValue, + keyIdValue, + positiveInteger, + publicKeyValue, + timestamp, + wrappedAccountKey, +} from "./device_schema.js"; +import { syncVaultRecipientEnvelopeStatement } from "./sync_vault.js"; + +const STORED_APPROVAL_STATUSES = new Set(["pending", "approved", "rejected", "expired"]); +const APPROVED_REQUESTER_QUERY = ` + SELECT device.device_id, keys.signing_public_key + FROM user_devices AS device + INNER JOIN user_device_keys AS keys + ON keys.user_id = device.user_id AND keys.device_id = device.device_id + WHERE device.user_id = ? AND device.device_id = ? + AND device.approval_status = 'approved' AND device.revoked_at IS NULL + AND keys.key_protocol_version = 2 AND keys.wrapping_public_key IS NOT NULL +`; +const DEVICE_BY_ID_QUERY = ` + SELECT + device.device_id, + device.public_key, + device.device_name, + device.platform, + device.approval_status, + device.created_at, + device.approved_at, + device.last_active_at, + device.revoked_at, + keys.wrapping_public_key + FROM user_devices AS device + LEFT JOIN user_device_keys AS keys + ON keys.user_id = device.user_id AND keys.device_id = device.device_id + WHERE device.user_id = ? AND device.device_id = ? +`; +const DEVICE_APPROVAL_BY_IDEMPOTENCY_KEY_QUERY = ` + SELECT device_id, requester_device_id, status, decided_at + FROM device_approvals + WHERE user_id = ? AND idempotency_key = ? +`; +const DEVICE_APPROVAL_INSERT_QUERY = ` + INSERT INTO device_approvals ( + user_id, approval_id, device_id, requester_device_id, status, + requested_at, decided_at, expires_at, idempotency_key + ) + SELECT ?, ?, ?, ?, 'approved', ?, ?, ?, ? + WHERE EXISTS ( + SELECT 1 + FROM sync_vault_accounts AS accounts + INNER JOIN sync_vault_envelopes AS envelope + ON envelope.user_id = accounts.user_id + AND envelope.key_id = accounts.current_key_id + AND envelope.generation = accounts.current_generation + WHERE accounts.user_id = ? AND envelope.recipient_device_id = ? + AND envelope.approver_device_id = ? AND envelope.key_id = ? + AND envelope.generation = ? AND envelope.envelope_version = ? + AND envelope.suite = ? AND envelope.encapped_key = ? + AND envelope.ciphertext = ? AND envelope.idempotency_key = ? + ) + ON CONFLICT(user_id, idempotency_key) DO NOTHING +`; +const DEVICE_APPROVE_QUERY = ` + UPDATE user_devices + SET approval_status = 'approved', approved_at = COALESCE(approved_at, ?), last_active_at = ? + WHERE user_id = ? AND device_id = ? AND approval_status = 'pending' AND revoked_at IS NULL + AND EXISTS ( + SELECT 1 + FROM sync_vault_accounts AS accounts + INNER JOIN sync_vault_envelopes AS envelope + ON envelope.user_id = accounts.user_id + AND envelope.key_id = accounts.current_key_id + AND envelope.generation = accounts.current_generation + WHERE accounts.user_id = ? AND envelope.recipient_device_id = ? + AND envelope.approver_device_id = ? AND envelope.key_id = ? + AND envelope.generation = ? AND envelope.envelope_version = ? + AND envelope.suite = ? AND envelope.encapped_key = ? + AND envelope.ciphertext = ? AND envelope.idempotency_key = ? + ) +`; +const CURRENT_RECIPIENT_ENVELOPE_QUERY = ` + SELECT + accounts.current_key_id AS key_id, + accounts.current_generation AS generation, + envelope.recipient_device_id, + envelope.approver_device_id, + envelope.envelope_version, + envelope.suite, + envelope.encapped_key, + envelope.ciphertext, + envelope.idempotency_key + FROM sync_vault_accounts AS accounts + INNER JOIN sync_vault_envelopes AS envelope + ON envelope.user_id = accounts.user_id + AND envelope.key_id = accounts.current_key_id + AND envelope.generation = accounts.current_generation + WHERE accounts.user_id = ? AND envelope.recipient_device_id = ? +`; + +interface CurrentRecipientEnvelopeRow { + key_id: unknown; + generation: unknown; + recipient_device_id: unknown; + approver_device_id: unknown; + envelope_version: unknown; + suite: unknown; + encapped_key: unknown; + ciphertext: unknown; + idempotency_key: unknown; +} + +interface ApprovedRequesterRow { + device_id: unknown; + signing_public_key: unknown; +} + +export async function approveDeviceDocument( + request: Request, + env: Env, + context: AuthContext, + nowSeconds = Math.floor(Date.now() / 1000), +): Promise { + const approval = await deviceApprovalRequest(request); + const requesterDeviceId = currentDeviceId(context); + if (requesterDeviceId === approval.deviceId) { + throw new DevicePermissionError("device_self_approval_forbidden"); + } + const signingPublicKey = await approvedRequesterSigningKey( + env, + context.userId, + requesterDeviceId, + ); + await assertDeviceApprovalProof( + signingPublicKey, + context.userId, + requesterDeviceId, + approval, + ); + const existingApproval = await env.ELY_DB.prepare(DEVICE_APPROVAL_BY_IDEMPOTENCY_KEY_QUERY) + .bind(context.userId, approval.idempotencyKey) + .first(); + assertFreshDeviceApprovalProof(approval, nowSeconds, existingApproval === null); + if (existingApproval !== null) { + return existingApprovalDocument(env, context, approval, requesterDeviceId, existingApproval); + } + + const pendingDevice = await deviceRowById(env, context.userId, approval.deviceId); + if (pendingDevice === null) { + throw new DevicePermissionError("device_not_found"); + } + const pendingDocument = storedDeviceDocument(pendingDevice, requesterDeviceId); + if ( + pendingDocument.approval_status !== "pending" || + pendingDocument.revoked_at !== null || + pendingDocument.wrapping_public_key === undefined + ) { + throw new DevicePermissionError("device_not_pending"); + } + + const envelopeBindings = approvalEnvelopeBindings( + context.userId, + approval, + requesterDeviceId, + ); + await env.ELY_DB.batch([ + syncVaultRecipientEnvelopeStatement( + env, + context.userId, + approval.deviceId, + requesterDeviceId, + approval.keyId, + approval.generation, + approval.envelope, + approval.idempotencyKey, + nowSeconds, + ), + env.ELY_DB.prepare(DEVICE_APPROVAL_INSERT_QUERY).bind( + context.userId, + approval.idempotencyKey, + approval.deviceId, + requesterDeviceId, + nowSeconds, + nowSeconds, + nowSeconds, + approval.idempotencyKey, + ...envelopeBindings, + ), + env.ELY_DB.prepare(DEVICE_APPROVE_QUERY).bind( + nowSeconds, + nowSeconds, + context.userId, + approval.deviceId, + ...envelopeBindings, + ), + ]); + + return approvedDeviceWithEnvelope(env, context, approval, requesterDeviceId, false); +} + +async function existingApprovalDocument( + env: Env, + context: AuthContext, + approval: DeviceApprovalRequest, + requesterDeviceId: string, + row: DeviceApprovalRow, +): Promise { + const { approvedDeviceId, approvedByDeviceId, status, decidedAt } = storedApproval(row); + if ( + approvedDeviceId !== approval.deviceId || + approvedByDeviceId !== requesterDeviceId || + status !== "approved" + ) { + throw new DevicePermissionError("device_approval_replay_mismatch"); + } + if (decidedAt === null) { + throw new DevicePersistenceError("device_approval_state_invalid"); + } + const document = await approvedDeviceWithEnvelope( + env, + context, + approval, + requesterDeviceId, + true, + ); + return { ...document, approved_at: decidedAt }; +} + +async function approvedDeviceWithEnvelope( + env: Env, + context: AuthContext, + approval: DeviceApprovalRequest, + requesterDeviceId: string, + replay: boolean, +): Promise { + const approvedDevice = await deviceRowById(env, context.userId, approval.deviceId); + if (approvedDevice === null) { + throw new DevicePersistenceError("device_approval_missing"); + } + const device = storedDeviceDocument(approvedDevice, requesterDeviceId); + if ( + device.approval_status !== "approved" || + device.approved_at === null || + device.wrapping_public_key === undefined + ) { + if (device.approval_status === "approved" && device.wrapping_public_key === undefined) { + throw new DevicePersistenceError("device_approval_state_invalid"); + } + throw approvalMismatch(replay); + } + const envelope = await env.ELY_DB.prepare(CURRENT_RECIPIENT_ENVELOPE_QUERY) + .bind(context.userId, approval.deviceId) + .first(); + if ( + envelope === null || + !approvalEnvelopeMatches(storedApprovalEnvelope(envelope), approval, requesterDeviceId) + ) { + throw approvalMismatch(replay); + } + try { + return approvedDeviceDocument(context.userId, requesterDeviceId, approvedDevice); + } catch (error) { + throw storedApprovalError(error); + } +} + +function approvalEnvelopeBindings( + userId: string, + approval: DeviceApprovalRequest, + requesterDeviceId: string, +): unknown[] { + return [ + userId, + approval.deviceId, + requesterDeviceId, + approval.keyId, + approval.generation, + approval.envelope.version, + approval.envelope.suite, + approval.envelope.encapped_key, + approval.envelope.ciphertext, + approval.idempotencyKey, + ]; +} + +function approvalEnvelopeMatches( + row: CurrentRecipientEnvelopeRow, + approval: DeviceApprovalRequest, + requesterDeviceId: string, +): boolean { + return ( + row.key_id === approval.keyId && + row.generation === approval.generation && + row.recipient_device_id === approval.deviceId && + row.approver_device_id === requesterDeviceId && + row.envelope_version === approval.envelope.version && + row.suite === approval.envelope.suite && + row.encapped_key === approval.envelope.encapped_key && + row.ciphertext === approval.envelope.ciphertext && + row.idempotency_key === approval.idempotencyKey + ); +} + +function approvalMismatch(replay: boolean): Error { + return replay + ? new DevicePermissionError("device_approval_replay_mismatch") + : new DeviceConflictError("device_approval_envelope_conflict"); +} + +function storedApproval(row: DeviceApprovalRow): { + approvedDeviceId: string; + approvedByDeviceId: string; + status: string; + decidedAt: number | null; +} { + try { + if (typeof row.status !== "string" || !STORED_APPROVAL_STATUSES.has(row.status)) { + throw new DeviceSchemaError("approval_status_invalid"); + } + return { + approvedDeviceId: deviceIdValue(row.device_id, "device_id"), + approvedByDeviceId: deviceIdValue(row.requester_device_id, "requester_device_id"), + status: row.status, + decidedAt: row.decided_at === null ? null : timestamp(row.decided_at, "decided_at"), + }; + } catch (error) { + throw storedApprovalError(error); + } +} + +function storedDeviceDocument(row: DeviceRow, requesterDeviceId: string) { + try { + return deviceDocument(row, requesterDeviceId); + } catch (error) { + throw storedApprovalError(error); + } +} + +function storedApprovalEnvelope(row: CurrentRecipientEnvelopeRow): CurrentRecipientEnvelopeRow { + try { + keyIdValue(row.key_id); + positiveInteger(row.generation, "generation"); + deviceIdValue(row.recipient_device_id, "recipient_device_id"); + deviceIdValue(row.approver_device_id, "approver_device_id"); + wrappedAccountKey({ + version: row.envelope_version, + suite: row.suite, + encapped_key: row.encapped_key, + ciphertext: row.ciphertext, + }); + idempotencyKeyValue(row.idempotency_key); + return row; + } catch (error) { + throw storedApprovalError(error); + } +} + +function storedApprovalError(error: unknown): Error { + return error instanceof DeviceSchemaError + ? new DevicePersistenceError("device_approval_state_invalid") + : error as Error; +} + +async function approvedRequesterSigningKey( + env: Env, + userId: string, + requesterDeviceId: string, +): Promise { + const requester = await env.ELY_DB.prepare(APPROVED_REQUESTER_QUERY) + .bind(userId, requesterDeviceId) + .first(); + if (requester === null) { + throw new DevicePermissionError("requester_device_unapproved"); + } + try { + return publicKeyValue(requester.signing_public_key, "signing_public_key"); + } catch (error) { + if (error instanceof DeviceSchemaError) { + throw new DevicePersistenceError("requester_signing_key_invalid"); + } + throw error; + } +} + +function deviceRowById(env: Env, userId: string, deviceId: string): Promise { + return env.ELY_DB.prepare(DEVICE_BY_ID_QUERY).bind(userId, deviceId).first(); +} diff --git a/cloudflare/src/device_approval_proof.ts b/cloudflare/src/device_approval_proof.ts new file mode 100644 index 0000000..0881e37 --- /dev/null +++ b/cloudflare/src/device_approval_proof.ts @@ -0,0 +1,65 @@ +import { verifyEd25519Signature } from "./device_crypto.js"; +import { + type DeviceApprovalRequest, + DevicePermissionError, +} from "./device_schema.js"; + +const PROOF_MAX_AGE_SECONDS = 5 * 60; +const PROOF_CLOCK_SKEW_SECONDS = 30; + +export async function assertDeviceApprovalProof( + signingPublicKey: string, + userId: string, + approverDeviceId: string, + approval: DeviceApprovalRequest, +): Promise { + if (!(await verifyEd25519Signature( + signingPublicKey, + approval.approvalProof, + deviceApprovalProofBytes(userId, approverDeviceId, approval), + ))) { + throw new DevicePermissionError("device_approval_proof_invalid"); + } +} + +export function assertFreshDeviceApprovalProof( + approval: DeviceApprovalRequest, + nowSeconds: number, + required: boolean, +): void { + if (required && ( + approval.proofCreatedAt < nowSeconds - PROOF_MAX_AGE_SECONDS || + approval.proofCreatedAt > nowSeconds + PROOF_CLOCK_SKEW_SECONDS + )) { + throw new DevicePermissionError("device_approval_proof_expired"); + } +} + +export function deviceApprovalProofBytes( + userId: string, + approverDeviceId: string, + approval: Omit, +): Uint8Array { + return canonicalBytes([ + "elydora-device-approval-v2", + userId, + approverDeviceId, + approval.deviceId, + approval.keyId, + approval.generation, + approval.envelope.version, + approval.envelope.suite, + approval.envelope.encapped_key, + approval.envelope.ciphertext, + approval.idempotencyKey, + approval.proofCreatedAt, + ]); +} + +function canonicalBytes(values: (number | string)[]): Uint8Array { + const encoder = new TextEncoder(); + return encoder.encode(values.map((value) => { + const text = value.toString(); + return `${encoder.encode(text).byteLength}:${text}`; + }).join("")); +} diff --git a/cloudflare/src/device_crypto.ts b/cloudflare/src/device_crypto.ts new file mode 100644 index 0000000..467fc9a --- /dev/null +++ b/cloudflare/src/device_crypto.ts @@ -0,0 +1,31 @@ +export async function verifyEd25519Signature( + publicKey: string, + signature: string, + message: Uint8Array, +): Promise { + try { + const key = await crypto.subtle.importKey( + "raw", + hexBytes(publicKey), + { name: "Ed25519" }, + false, + ["verify"], + ); + return crypto.subtle.verify( + { name: "Ed25519" }, + key, + hexBytes(signature), + message, + ); + } catch { + return false; + } +} + +function hexBytes(value: string): Uint8Array { + const bytes = new Uint8Array(value.length / 2); + for (let index = 0; index < bytes.length; index += 1) { + bytes[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16); + } + return bytes; +} diff --git a/cloudflare/src/device_rebind.ts b/cloudflare/src/device_rebind.ts new file mode 100644 index 0000000..092149e --- /dev/null +++ b/cloudflare/src/device_rebind.ts @@ -0,0 +1,345 @@ +import type { AuthContext } from "./auth.js"; +import type { Env } from "./bindings.js"; +import { verifyEd25519Signature } from "./device_crypto.js"; +import { + DeviceConflictError, + DevicePermissionError, + DevicePersistenceError, + DeviceSchemaError, + assertOnlyFields, + deviceIdValue, + deviceRequestBody, + publicKeyValue, + signatureValue, + timestamp, +} from "./device_schema.js"; + +const CHALLENGE_TTL_SECONDS = 300; +const CHALLENGE_ID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +const APPROVED_DEVICE_KEY_QUERY = ` + SELECT keys.signing_public_key + FROM user_devices AS device + INNER JOIN user_device_keys AS keys + ON keys.user_id = device.user_id AND keys.device_id = device.device_id + WHERE device.user_id = ? AND device.device_id = ? + AND device.approval_status = 'approved' AND device.revoked_at IS NULL + AND keys.key_protocol_version = 2 +`; +const CHALLENGE_UPSERT_QUERY = ` + INSERT INTO device_rebind_challenges ( + challenge_id, + user_id, + session_id, + device_id, + challenge, + created_at, + expires_at, + consumed_at, + consumption_nonce + ) + SELECT ?, ?, ?, ?, ?, ?, ?, NULL, NULL + WHERE EXISTS ( + SELECT 1 FROM better_auth_session WHERE id = ? AND userId = ? + ) + AND NOT EXISTS ( + SELECT 1 FROM better_auth_session_device_context WHERE session_id = ? + ) + ON CONFLICT(session_id) DO UPDATE SET + challenge_id = excluded.challenge_id, + user_id = excluded.user_id, + device_id = excluded.device_id, + challenge = excluded.challenge, + created_at = excluded.created_at, + expires_at = excluded.expires_at, + consumed_at = NULL, + consumption_nonce = NULL +`; +const CHALLENGE_QUERY = ` + SELECT + rebind.challenge, + rebind.expires_at, + keys.signing_public_key + FROM device_rebind_challenges AS rebind + INNER JOIN user_devices AS device + ON device.user_id = rebind.user_id AND device.device_id = rebind.device_id + INNER JOIN user_device_keys AS keys + ON keys.user_id = device.user_id AND keys.device_id = device.device_id + WHERE rebind.challenge_id = ? AND rebind.user_id = ? + AND rebind.session_id = ? AND rebind.device_id = ? + AND rebind.consumed_at IS NULL + AND device.approval_status = 'approved' AND device.revoked_at IS NULL + AND keys.key_protocol_version = 2 +`; +const CHALLENGE_CONSUME_QUERY = ` + UPDATE device_rebind_challenges + SET consumed_at = ?, consumption_nonce = ? + WHERE challenge_id = ? AND user_id = ? AND session_id = ? AND device_id = ? + AND consumed_at IS NULL AND expires_at > ? + AND NOT EXISTS ( + SELECT 1 FROM better_auth_session_device_context WHERE session_id = ? + ) + AND EXISTS ( + SELECT 1 + FROM user_devices AS device + INNER JOIN user_device_keys AS keys + ON keys.user_id = device.user_id AND keys.device_id = device.device_id + WHERE device.user_id = ? AND device.device_id = ? + AND device.approval_status = 'approved' AND device.revoked_at IS NULL + AND keys.key_protocol_version = 2 + ) +`; +const SESSION_BIND_QUERY = ` + INSERT INTO better_auth_session_device_context ( + session_id, + user_id, + device_id, + updated_at + ) + SELECT rebind.session_id, rebind.user_id, rebind.device_id, ? + FROM device_rebind_challenges AS rebind + WHERE rebind.challenge_id = ? AND rebind.consumption_nonce = ? + AND rebind.consumed_at = ? + AND EXISTS ( + SELECT 1 FROM better_auth_session + WHERE id = rebind.session_id AND userId = rebind.user_id + ) + ON CONFLICT(session_id) DO NOTHING +`; + +interface ApprovedDeviceKeyRow { + signing_public_key: unknown; +} + +interface RebindChallengeRow extends ApprovedDeviceKeyRow { + challenge: unknown; + expires_at: unknown; +} + +export interface DeviceRebindChallengeDocument { + version: 1; + challenge_id: string; + device_id: string; + challenge: string; + expires_at: number; +} + +export interface DeviceRebindDocument { + version: 1; + user_id: string; + session_id: string; + device_id: string; + bound_at: number; +} + +export async function issueDeviceRebindChallenge( + request: Request, + env: Env, + context: AuthContext, + nowSeconds = Math.floor(Date.now() / 1000), +): Promise { + const deviceId = await rebindChallengeDeviceId(request); + assertUnboundSession(context); + const keyRow = await env.ELY_DB.prepare(APPROVED_DEVICE_KEY_QUERY) + .bind(context.userId, deviceId) + .first(); + if (keyRow === null) { + throw new DevicePermissionError("device_rebind_unavailable"); + } + publicKeyValue(keyRow.signing_public_key, "signing_public_key"); + + const challengeId = crypto.randomUUID(); + const expiresAt = nowSeconds + CHALLENGE_TTL_SECONDS; + const challenge = canonicalChallenge( + challengeId, + context.userId, + context.sessionId, + deviceId, + expiresAt, + randomHex(32), + ); + const result = await env.ELY_DB.prepare(CHALLENGE_UPSERT_QUERY) + .bind( + challengeId, + context.userId, + context.sessionId, + deviceId, + challenge, + nowSeconds, + expiresAt, + context.sessionId, + context.userId, + context.sessionId, + ) + .run(); + if (changedRowCount(result) !== 1) { + throw new DevicePersistenceError("device_rebind_challenge_write_failed"); + } + return { version: 1, challenge_id: challengeId, device_id: deviceId, challenge, expires_at: expiresAt }; +} + +export async function rebindDeviceSession( + request: Request, + env: Env, + context: AuthContext, + nowSeconds = Math.floor(Date.now() / 1000), +): Promise { + const rebind = await rebindRequest(request); + assertUnboundSession(context); + const row = await env.ELY_DB.prepare(CHALLENGE_QUERY) + .bind(rebind.challengeId, context.userId, context.sessionId, rebind.deviceId) + .first(); + if (row === null) { + throw new DevicePermissionError("device_rebind_forbidden"); + } + const expiresAt = timestamp(row.expires_at, "expires_at"); + if (expiresAt <= nowSeconds) { + throw new DevicePermissionError("device_rebind_challenge_expired"); + } + const challenge = challengeValue(row.challenge); + assertCanonicalChallenge(challenge, rebind.challengeId, context, rebind.deviceId, expiresAt); + const signingPublicKey = publicKeyValue(row.signing_public_key, "signing_public_key"); + if ( + !(await verifyEd25519Signature( + signingPublicKey, + rebind.signature, + new TextEncoder().encode(challenge), + )) + ) { + throw new DevicePermissionError("device_rebind_signature_invalid"); + } + + const consumptionNonce = randomHex(32); + const [consumeResult, bindResult] = await env.ELY_DB.batch([ + env.ELY_DB.prepare(CHALLENGE_CONSUME_QUERY).bind( + nowSeconds, + consumptionNonce, + rebind.challengeId, + context.userId, + context.sessionId, + rebind.deviceId, + nowSeconds, + context.sessionId, + context.userId, + rebind.deviceId, + ), + env.ELY_DB.prepare(SESSION_BIND_QUERY).bind( + nowSeconds, + rebind.challengeId, + consumptionNonce, + nowSeconds, + ), + ]); + if (changedRowCount(consumeResult) !== 1 || changedRowCount(bindResult) !== 1) { + throw new DeviceConflictError("device_rebind_challenge_consumed"); + } + return { + version: 1, + user_id: context.userId, + session_id: context.sessionId, + device_id: rebind.deviceId, + bound_at: nowSeconds, + }; +} + +async function rebindChallengeDeviceId(request: Request): Promise { + const value = await deviceRequestBody(request, "device_rebind_challenge"); + assertOnlyFields(value, ["version", "device_id"]); + if (value.version !== 1) { + throw new DeviceSchemaError("device_rebind_challenge_version_invalid"); + } + return deviceIdValue(value.device_id, "device_id"); +} + +async function rebindRequest( + request: Request, +): Promise<{ challengeId: string; deviceId: string; signature: string }> { + const value = await deviceRequestBody(request, "device_rebind"); + assertOnlyFields(value, ["version", "challenge_id", "device_id", "signature"]); + if (value.version !== 1) { + throw new DeviceSchemaError("device_rebind_version_invalid"); + } + if (typeof value.challenge_id !== "string" || !CHALLENGE_ID_PATTERN.test(value.challenge_id)) { + throw new DeviceSchemaError("challenge_id_invalid"); + } + return { + challengeId: value.challenge_id, + deviceId: deviceIdValue(value.device_id, "device_id"), + signature: signatureValue(value.signature, "signature"), + }; +} + +function assertUnboundSession(context: AuthContext): void { + if (context.deviceId !== undefined) { + throw new DevicePermissionError("device_context_already_bound"); + } +} + +function canonicalChallenge( + challengeId: string, + userId: string, + sessionId: string, + deviceId: string, + expiresAt: number, + nonce: string, +): string { + return [ + "elydora-device-rebind-v1", + `challenge_id=${challengeId}`, + `user_id=${userId}`, + `session_id=${sessionId}`, + `device_id=${deviceId}`, + `expires_at=${expiresAt}`, + `nonce=${nonce}`, + ].join("\n"); +} + +function assertCanonicalChallenge( + challenge: string, + challengeId: string, + context: AuthContext, + deviceId: string, + expiresAt: number, +): void { + const prefix = canonicalChallenge( + challengeId, + context.userId, + context.sessionId, + deviceId, + expiresAt, + "", + ); + const nonce = challenge.slice(prefix.length); + if (!challenge.startsWith(prefix) || !/^[a-f0-9]{64}$/.test(nonce)) { + throw new DevicePersistenceError("device_rebind_challenge_invalid"); + } +} + +function challengeValue(value: unknown): string { + if (typeof value !== "string" || value.length < 1 || value.length > 1024) { + throw new DevicePersistenceError("device_rebind_challenge_invalid"); + } + return value; +} + +function changedRowCount(result: unknown): number { + if (typeof result !== "object" || result === null || !("meta" in result)) { + throw new DevicePersistenceError("device_rebind_write_result_invalid"); + } + const meta = result.meta; + if (typeof meta !== "object" || meta === null || !("changes" in meta)) { + throw new DevicePersistenceError("device_rebind_write_result_invalid"); + } + const changes = meta.changes; + if (typeof changes !== "number" || !Number.isSafeInteger(changes) || changes < 0) { + throw new DevicePersistenceError("device_rebind_write_result_invalid"); + } + return changes; +} + +function randomHex(byteLength: number): string { + const bytes = new Uint8Array(byteLength); + crypto.getRandomValues(bytes); + return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} diff --git a/cloudflare/src/device_registration_proof.ts b/cloudflare/src/device_registration_proof.ts new file mode 100644 index 0000000..ff46ab3 --- /dev/null +++ b/cloudflare/src/device_registration_proof.ts @@ -0,0 +1,38 @@ +import { verifyEd25519Signature } from "./device_crypto.js"; +import { + type DeviceRegistrationRequest, + DevicePermissionError, +} from "./device_schema.js"; + +const REGISTRATION_DOMAIN = "elydora-device-registration-v2"; + +export async function assertDeviceRegistrationProof( + registration: DeviceRegistrationRequest, +): Promise { + const valid = await verifyEd25519Signature( + registration.publicKey, + registration.registrationProof, + deviceRegistrationProofBytes(registration), + ); + if (!valid) { + throw new DevicePermissionError("device_registration_proof_invalid"); + } +} + +export function deviceRegistrationProofBytes( + registration: Omit, +): Uint8Array { + const encoder = new TextEncoder(); + const payload = [ + REGISTRATION_DOMAIN, + registration.deviceId, + registration.publicKey, + registration.wrappingPublicKey, + registration.deviceName, + registration.platform, + registration.idempotencyKey, + ] + .map((value) => `${encoder.encode(value).byteLength}:${value}`) + .join(""); + return encoder.encode(payload); +} diff --git a/cloudflare/src/device_revocation.ts b/cloudflare/src/device_revocation.ts new file mode 100644 index 0000000..f1d256b --- /dev/null +++ b/cloudflare/src/device_revocation.ts @@ -0,0 +1,339 @@ +import type { AuthContext } from "./auth.js"; +import type { ElyD1Result, Env } from "./bindings.js"; +import { verifyEd25519Signature } from "./device_crypto.js"; +import { + type DeviceRevocationDocument, + type DeviceRow, + DeviceConflictError, + DevicePermissionError, + DevicePersistenceError, + currentDeviceId, + deviceDocument, + deviceIdValue, + publicKeyValue, +} from "./device_schema.js"; +import { + type ApprovedDeviceRevocationRequest, + compareDeviceIds, + deviceRevocationRequest, + deviceRevocationRequestHash, + deviceRevocationProofBytes, + pendingDeviceRevocationProofBytes, + pendingDeviceRevocationRequestHash, +} from "./device_revocation_schema.js"; +import { + type RotationResultRow, + rotationR2ObjectCount, + rotationResult, + rotationStatements, +} from "./device_revocation_store.js"; +import { revokePendingDeviceDocument } from "./pending_device_revocation.js"; + +const APPROVED_V2_REQUESTER_QUERY = ` + SELECT device.device_id, keys.signing_public_key + FROM user_devices AS device + INNER JOIN user_device_keys AS keys + ON keys.user_id = device.user_id AND keys.device_id = device.device_id + WHERE device.user_id = ? AND device.device_id = ? + AND device.approval_status = 'approved' AND device.revoked_at IS NULL + AND keys.key_protocol_version = 2 AND keys.wrapping_public_key IS NOT NULL +`; +const DEVICE_BY_ID_QUERY = ` + SELECT + device.device_id, device.public_key, device.device_name, device.platform, + device.approval_status, device.created_at, device.approved_at, + device.last_active_at, device.revoked_at, keys.wrapping_public_key + FROM user_devices AS device + LEFT JOIN user_device_keys AS keys + ON keys.user_id = device.user_id AND keys.device_id = device.device_id + WHERE device.user_id = ? AND device.device_id = ? +`; +const CURRENT_VAULT_KEY_QUERY = ` + SELECT current_key_id AS key_id, current_generation AS generation + FROM sync_vault_accounts + WHERE user_id = ? +`; +const REMAINING_APPROVED_V2_QUERY = ` + SELECT device.device_id + FROM user_devices AS device + INNER JOIN user_device_keys AS keys + ON keys.user_id = device.user_id AND keys.device_id = device.device_id + WHERE device.user_id = ? AND device.device_id <> ? + AND device.approval_status = 'approved' AND device.revoked_at IS NULL + AND keys.key_protocol_version = 2 AND keys.wrapping_public_key IS NOT NULL + ORDER BY device.device_id ASC +`; +interface VaultKeyRow { key_id: unknown; generation: unknown } +interface DeviceIdRow { device_id: unknown } +interface ApprovedV2RequesterRow extends DeviceIdRow { signing_public_key: unknown } + +export async function revokeDeviceDocument( + request: Request, + env: Env, + context: AuthContext, + nowSeconds = Math.floor(Date.now() / 1000), +): Promise { + const revocation = await deviceRevocationRequest(request); + const approverDeviceId = currentDeviceId(context); + if (approverDeviceId === revocation.deviceId) { + throw new DevicePermissionError("device_self_revocation_forbidden"); + } + const signingPublicKey = await approvedV2RequesterKey(env, context.userId, approverDeviceId); + if (revocation.mode === "pending_revoke") { + const { pendingRevocationProof, ...unsignedRevocation } = revocation; + if (!(await verifyEd25519Signature( + signingPublicKey, + pendingRevocationProof, + pendingDeviceRevocationProofBytes(context.userId, approverDeviceId, unsignedRevocation), + ))) { + throw new DevicePermissionError("device_revocation_proof_invalid"); + } + const requestHash = await pendingDeviceRevocationRequestHash( + context.userId, + approverDeviceId, + revocation, + ); + return revokePendingDeviceDocument( + env, + context, + approverDeviceId, + revocation, + requestHash, + nowSeconds, + ); + } + const { rotationProof, ...unsignedRevocation } = revocation; + if (!(await verifyEd25519Signature( + signingPublicKey, + rotationProof, + deviceRevocationProofBytes(context.userId, approverDeviceId, unsignedRevocation), + ))) { + throw new DevicePermissionError("device_revocation_proof_invalid"); + } + + const requestHash = await deviceRevocationRequestHash( + context.userId, + approverDeviceId, + revocation, + ); + const existing = await rotationResult(env, context.userId, revocation.idempotencyKey); + if (existing !== null) { + return completedRevocationDocument( + env, + context.userId, + approverDeviceId, + revocation, + requestHash, + existing, + ); + } + + await assertCurrentVaultKey(env, context.userId, revocation); + await assertRevocableTarget(env, context.userId, approverDeviceId, revocation.deviceId); + const recipients = await remainingApprovedV2Recipients(env, context.userId, revocation.deviceId); + assertExactRecipients(revocation, recipients); + const r2ObjectCount = await rotationR2ObjectCount(env, context.userId); + const statements = await rotationStatements( + env, + context.userId, + approverDeviceId, + revocation, + requestHash, + r2ObjectCount, + nowSeconds, + ); + let results: ElyD1Result[]; + try { + results = await env.ELY_DB.batch(statements); + } catch (error) { + if (isRotationConflict(error)) { + throw new DeviceConflictError("device_revocation_race"); + } + throw error; + } + const finalizeChanges = changedRowCount(results.at(-1), "device_revocation_finalize"); + if (finalizeChanges > 1) { + throw new DevicePersistenceError("device_revocation_write_count_invalid"); + } + + const completed = await rotationResult(env, context.userId, revocation.idempotencyKey); + if (completed === null) { + throw new DeviceConflictError("device_revocation_race"); + } + try { + return await completedRevocationDocument( + env, + context.userId, + approverDeviceId, + revocation, + requestHash, + completed, + ); + } catch (error) { + if (finalizeChanges === 0 && error instanceof DeviceConflictError) { + throw new DeviceConflictError("device_revocation_race"); + } + throw error; + } +} + +async function completedRevocationDocument( + env: Env, + userId: string, + approverDeviceId: string, + revocation: ApprovedDeviceRevocationRequest, + requestHash: string, + result: RotationResultRow, +): Promise { + assertRotationResult(result, revocation, approverDeviceId, requestHash); + const revokedDevice = await deviceRowById(env, userId, revocation.deviceId); + if (revokedDevice === null) { + throw new DevicePersistenceError("device_revocation_missing"); + } + const device = deviceDocument(revokedDevice, approverDeviceId); + const completedAt = storedInteger(result.completed_at, "completed_at"); + if (device.approval_status !== "revoked" || device.revoked_at !== completedAt) { + throw new DevicePersistenceError("device_revocation_state_invalid"); + } + return { + version: 2, + mode: "approved_rotate", + user_id: userId, + revoked_by_device_id: approverDeviceId, + revoked_at: completedAt, + key_id: revocation.newKeyId, + generation: revocation.newGeneration, + device, + }; +} + +function assertRotationResult( + row: RotationResultRow, + revocation: ApprovedDeviceRevocationRequest, + approverDeviceId: string, + requestHash: string, +): void { + if ( + row.target_device_id !== revocation.deviceId || + row.approver_device_id !== approverDeviceId || + row.previous_key_id !== revocation.previousKeyId || + row.previous_generation !== revocation.previousGeneration || + row.new_key_id !== revocation.newKeyId || + row.new_generation !== revocation.newGeneration || + row.request_hash !== requestHash || + row.envelope_count !== revocation.envelopes.length + ) { + throw new DeviceConflictError("device_revocation_replay_mismatch"); + } + const completedAt = storedInteger(row.completed_at, "completed_at"); + if ( + row.current_key_id !== revocation.newKeyId || + row.current_generation !== revocation.newGeneration || + row.target_status !== "revoked" || + row.revoked_at !== completedAt || + row.active_session_count !== 0 || + row.item_count !== revocation.envelopes.length || + storedInteger(row.r2_object_count, "r2_object_count") !== + storedInteger(row.r2_item_count, "r2_item_count") || + row.persisted_count !== revocation.envelopes.length || + row.audit_count !== 1 + ) { + throw new DevicePersistenceError("device_revocation_result_invalid"); + } +} + +async function approvedV2RequesterKey( + env: Env, + userId: string, + approverDeviceId: string, +): Promise { + const row = await env.ELY_DB.prepare(APPROVED_V2_REQUESTER_QUERY) + .bind(userId, approverDeviceId) + .first(); + if (row === null) { + throw new DevicePermissionError("requester_device_unapproved"); + } + return publicKeyValue(row.signing_public_key, "signing_public_key"); +} + +async function assertCurrentVaultKey( + env: Env, + userId: string, + revocation: ApprovedDeviceRevocationRequest, +): Promise { + const row = await env.ELY_DB.prepare(CURRENT_VAULT_KEY_QUERY).bind(userId).first(); + if ( + row === null || + row.key_id !== revocation.previousKeyId || + row.generation !== revocation.previousGeneration + ) { + throw new DeviceConflictError("device_revocation_vault_conflict"); + } +} + +async function assertRevocableTarget( + env: Env, + userId: string, + approverDeviceId: string, + targetDeviceId: string, +): Promise { + const row = await deviceRowById(env, userId, targetDeviceId); + if (row === null) { + throw new DevicePermissionError("device_not_found"); + } + const device = deviceDocument(row, approverDeviceId); + if (device.revoked_at !== null || device.approval_status !== "approved") { + throw new DevicePermissionError("device_not_revocable"); + } +} + +async function remainingApprovedV2Recipients( + env: Env, + userId: string, + targetDeviceId: string, +): Promise { + const result = await env.ELY_DB.prepare(REMAINING_APPROVED_V2_QUERY) + .bind(userId, targetDeviceId) + .all(); + const recipients = result.results.map((row) => deviceIdValue(row.device_id, "device_id")); + if (new Set(recipients).size !== recipients.length) { + throw new DevicePersistenceError("device_revocation_recipient_rows_invalid"); + } + recipients.sort(compareDeviceIds); + return recipients; +} + +function assertExactRecipients(revocation: ApprovedDeviceRevocationRequest, expected: string[]): void { + const actual = revocation.envelopes.map((item) => item.recipientDeviceId); + if (actual.length !== expected.length || actual.some((deviceId, index) => deviceId !== expected[index])) { + throw new DeviceConflictError("device_revocation_envelope_set_mismatch"); + } +} + +function deviceRowById(env: Env, userId: string, deviceId: string): Promise { + return env.ELY_DB.prepare(DEVICE_BY_ID_QUERY).bind(userId, deviceId).first(); +} + +function storedInteger(value: unknown, label: string): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) { + throw new DevicePersistenceError(`${label}_invalid`); + } + return value; +} + +function changedRowCount(result: ElyD1Result | undefined, label: string): number { + const changes = result?.meta?.changes; + if (typeof changes !== "number" || !Number.isSafeInteger(changes) || changes < 0) { + throw new DevicePersistenceError(`${label}_write_result_invalid`); + } + return changes; +} + +function isRotationConflict(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + return error.message.includes("sync_vault_rotation_guard_failed") || + error.message.includes("UNIQUE constraint failed: sync_vault_envelopes") || + error.message.includes("FOREIGN KEY constraint failed"); +} diff --git a/cloudflare/src/device_revocation_schema.ts b/cloudflare/src/device_revocation_schema.ts new file mode 100644 index 0000000..46a4ed8 --- /dev/null +++ b/cloudflare/src/device_revocation_schema.ts @@ -0,0 +1,254 @@ +import { + DeviceSchemaError, + assertOnlyFields, + deviceIdValue, + deviceRequestBody, + idempotencyKeyValue, + keyIdValue, + positiveInteger, + signatureValue, + wrappedAccountKey, +} from "./device_schema.js"; +import type { WrappedAccountKeyDocument } from "./sync_vault.js"; + +const MAX_ROTATION_ENVELOPES = 128; + +export function compareDeviceIds(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +export interface DeviceRevocationEnvelopeRequest { + recipientDeviceId: string; + envelope: WrappedAccountKeyDocument; +} + +interface DeviceRevocationBaseRequest { + deviceId: string; + idempotencyKey: string; +} + +export interface ApprovedDeviceRevocationRequest extends DeviceRevocationBaseRequest { + mode: "approved_rotate"; + previousKeyId: string; + previousGeneration: number; + newKeyId: string; + newGeneration: number; + envelopes: DeviceRevocationEnvelopeRequest[]; + rotationProof: string; +} + +export interface PendingDeviceRevocationRequest extends DeviceRevocationBaseRequest { + mode: "pending_revoke"; + pendingRevocationProof: string; +} + +export type DeviceRevocationRequest = + | ApprovedDeviceRevocationRequest + | PendingDeviceRevocationRequest; + +export async function deviceRevocationRequest(request: Request): Promise { + const value = await deviceRequestBody(request, "device_revocation"); + if (value.version !== 2) { + throw new DeviceSchemaError("device_revocation_version_invalid"); + } + if (value.mode === "pending_revoke") { + assertOnlyFields(value, [ + "version", + "mode", + "device_id", + "idempotency_key", + "pending_revocation_proof", + ]); + return { + mode: "pending_revoke", + deviceId: deviceIdValue(value.device_id, "device_id"), + idempotencyKey: idempotencyKeyValue(value.idempotency_key), + pendingRevocationProof: signatureValue( + value.pending_revocation_proof, + "pending_revocation_proof", + ), + }; + } + if (value.mode !== "approved_rotate") { + throw new DeviceSchemaError("device_revocation_mode_invalid"); + } + assertOnlyFields(value, [ + "version", + "mode", + "device_id", + "previous_key_id", + "previous_generation", + "new_key_id", + "new_generation", + "envelopes", + "idempotency_key", + "rotation_proof", + ]); + const deviceId = deviceIdValue(value.device_id, "device_id"); + const previousKeyId = keyIdValue(value.previous_key_id); + const previousGeneration = positiveInteger(value.previous_generation, "previous_generation"); + const newKeyId = keyIdValue(value.new_key_id); + const newGeneration = positiveInteger(value.new_generation, "new_generation"); + if ( + previousGeneration === Number.MAX_SAFE_INTEGER || + newGeneration !== previousGeneration + 1 + ) { + throw new DeviceSchemaError("new_generation_invalid"); + } + if (newKeyId === previousKeyId) { + throw new DeviceSchemaError("new_key_id_invalid"); + } + + return { + mode: "approved_rotate", + deviceId, + previousKeyId, + previousGeneration, + newKeyId, + newGeneration, + envelopes: revocationEnvelopes(value.envelopes, deviceId), + idempotencyKey: idempotencyKeyValue(value.idempotency_key), + rotationProof: signatureValue(value.rotation_proof, "rotation_proof"), + }; +} + +export async function deviceRevocationRequestHash( + userId: string, + approverDeviceId: string, + revocation: ApprovedDeviceRevocationRequest, +): Promise { + return sha256Hex(JSON.stringify({ + version: 2, + mode: revocation.mode, + user_id: userId, + approver_device_id: approverDeviceId, + device_id: revocation.deviceId, + previous_key_id: revocation.previousKeyId, + previous_generation: revocation.previousGeneration, + new_key_id: revocation.newKeyId, + new_generation: revocation.newGeneration, + envelopes: revocation.envelopes.map((item) => ({ + recipient_device_id: item.recipientDeviceId, + envelope: item.envelope, + })), + idempotency_key: revocation.idempotencyKey, + rotation_proof: revocation.rotationProof, + })); +} + +export function deviceRevocationProofBytes( + userId: string, + approverDeviceId: string, + revocation: Omit, +): Uint8Array { + const values: (number | string)[] = [ + "elydora-device-revocation-v2", + userId, + approverDeviceId, + revocation.deviceId, + revocation.previousKeyId, + revocation.previousGeneration, + revocation.newKeyId, + revocation.newGeneration, + revocation.idempotencyKey, + revocation.envelopes.length, + ]; + for (const item of revocation.envelopes) { + values.push( + item.recipientDeviceId, + item.envelope.version, + item.envelope.suite, + item.envelope.encapped_key, + item.envelope.ciphertext, + ); + } + return canonicalBytes(values); +} + +export async function pendingDeviceRevocationRequestHash( + userId: string, + approverDeviceId: string, + revocation: PendingDeviceRevocationRequest, +): Promise { + return sha256Hex(JSON.stringify({ + version: 2, + mode: revocation.mode, + user_id: userId, + approver_device_id: approverDeviceId, + device_id: revocation.deviceId, + idempotency_key: revocation.idempotencyKey, + pending_revocation_proof: revocation.pendingRevocationProof, + })); +} + +export function pendingDeviceRevocationProofBytes( + userId: string, + approverDeviceId: string, + revocation: Omit, +): Uint8Array { + return canonicalBytes([ + "elydora-pending-device-revocation-v2", + userId, + approverDeviceId, + revocation.deviceId, + revocation.idempotencyKey, + ]); +} + +export function rotationEnvelopeIdempotencyKey( + userId: string, + rotationIdempotencyKey: string, + recipientDeviceId: string, +): Promise { + const encoder = new TextEncoder(); + return sha256Hex([userId, rotationIdempotencyKey, recipientDeviceId] + .map((value) => `${encoder.encode(value).byteLength}:${value}`) + .join("")); +} + +function revocationEnvelopes(value: unknown, targetDeviceId: string): DeviceRevocationEnvelopeRequest[] { + if (!Array.isArray(value) || value.length < 1 || value.length > MAX_ROTATION_ENVELOPES) { + throw new DeviceSchemaError("envelopes_invalid"); + } + const seen = new Set(); + const envelopes = value.map((item, index) => { + const record = requestRecord(item, `envelopes[${index}]`); + assertOnlyFields(record, ["recipient_device_id", "envelope"]); + const recipientDeviceId = deviceIdValue( + record.recipient_device_id, + `envelopes[${index}].recipient_device_id`, + ); + if (recipientDeviceId === targetDeviceId || seen.has(recipientDeviceId)) { + throw new DeviceSchemaError("envelope_recipient_invalid"); + } + seen.add(recipientDeviceId); + return { + recipientDeviceId, + envelope: wrappedAccountKey(record.envelope), + }; + }); + envelopes.sort((left, right) => compareDeviceIds(left.recipientDeviceId, right.recipientDeviceId)); + return envelopes; +} + +function requestRecord(value: unknown, label: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new DeviceSchemaError(`${label}_invalid`); + } + return value as Record; +} + +async function sha256Hex(value: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)); + return [...new Uint8Array(digest)] + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +} + +function canonicalBytes(values: (number | string)[]): Uint8Array { + const encoder = new TextEncoder(); + return encoder.encode(values.map((value) => { + const text = value.toString(); + return `${encoder.encode(text).byteLength}:${text}`; + }).join("")); +} diff --git a/cloudflare/src/device_revocation_store.ts b/cloudflare/src/device_revocation_store.ts new file mode 100644 index 0000000..13a9f93 --- /dev/null +++ b/cloudflare/src/device_revocation_store.ts @@ -0,0 +1,221 @@ +import type { ElyD1PreparedStatement, Env } from "./bindings.js"; +import { + type ApprovedDeviceRevocationRequest, + rotationEnvelopeIdempotencyKey, +} from "./device_revocation_schema.js"; +import { DevicePersistenceError } from "./device_schema.js"; + +const ROTATION_R2_COUNT_QUERY = ` + SELECT COUNT(*) AS object_count FROM ( + SELECT payload_r2_key AS r2_key + FROM sync_objects + WHERE user_id = ? AND payload_r2_key IS NOT NULL + UNION + SELECT r2_key FROM sync_snapshots WHERE user_id = ? + ) +`; +const ROTATION_RESULT_QUERY = ` + SELECT + rotation.target_device_id, rotation.approver_device_id, + rotation.previous_key_id, rotation.previous_generation, + rotation.new_key_id, rotation.new_generation, rotation.request_hash, + rotation.envelope_count, rotation.r2_object_count, rotation.completed_at, + account.current_key_id, account.current_generation, + target.approval_status AS target_status, target.revoked_at, + (SELECT COUNT(*) + FROM better_auth_session AS session + INNER JOIN better_auth_session_device_context AS context + ON context.session_id = session.id + WHERE context.user_id = rotation.user_id + AND context.device_id = rotation.target_device_id) AS active_session_count, + (SELECT COUNT(*) FROM sync_vault_rotation_envelopes AS item + WHERE item.user_id = rotation.user_id + AND item.rotation_idempotency_key = rotation.idempotency_key) AS item_count, + (SELECT COUNT(*) FROM sync_vault_rotation_r2_objects AS item + WHERE item.user_id = rotation.user_id + AND item.rotation_idempotency_key = rotation.idempotency_key) AS r2_item_count, + (SELECT COUNT(*) + FROM sync_vault_rotation_envelopes AS item + INNER JOIN sync_vault_envelopes AS envelope + ON envelope.user_id = item.user_id + AND envelope.recipient_device_id = item.recipient_device_id + AND envelope.key_id = rotation.new_key_id + AND envelope.generation = rotation.new_generation + AND envelope.approver_device_id = rotation.approver_device_id + AND envelope.envelope_version = item.envelope_version + AND envelope.suite = item.suite + AND envelope.encapped_key = item.encapped_key + AND envelope.ciphertext = item.ciphertext + AND envelope.idempotency_key = item.envelope_idempotency_key + WHERE item.user_id = rotation.user_id + AND item.rotation_idempotency_key = rotation.idempotency_key) AS persisted_count, + (SELECT COUNT(*) FROM audit_events AS audit + WHERE audit.event_id = rotation.audit_event_id + AND audit.user_id = rotation.user_id + AND audit.actor_device_id = rotation.approver_device_id + AND audit.event_type = 'device.revoke' + AND audit.subject_id = rotation.target_device_id + AND audit.outcome = 'success' + AND audit.metadata_hash = rotation.request_hash + AND audit.created_at = rotation.completed_at) AS audit_count + FROM sync_vault_rotations AS rotation + LEFT JOIN sync_vault_accounts AS account ON account.user_id = rotation.user_id + LEFT JOIN user_devices AS target + ON target.user_id = rotation.user_id AND target.device_id = rotation.target_device_id + WHERE rotation.user_id = ? AND rotation.idempotency_key = ? +`; +const ROTATION_INSERT_QUERY = ` + INSERT INTO sync_vault_rotations ( + user_id, idempotency_key, audit_event_id, target_device_id, approver_device_id, + previous_key_id, previous_generation, new_key_id, new_generation, + request_hash, envelope_count, r2_object_count, created_at, completed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL) + ON CONFLICT(user_id, idempotency_key) DO NOTHING +`; +const ROTATION_ENVELOPE_INSERT_QUERY = ` + INSERT INTO sync_vault_rotation_envelopes ( + user_id, rotation_idempotency_key, recipient_device_id, + envelope_idempotency_key, envelope_version, suite, encapped_key, ciphertext + ) + SELECT ?, ?, ?, ?, ?, ?, ?, ? + FROM sync_vault_rotations AS rotation + WHERE rotation.user_id = ? AND rotation.idempotency_key = ? + AND rotation.completed_at IS NULL + AND rotation.target_device_id = ? AND rotation.approver_device_id = ? + AND rotation.previous_key_id = ? AND rotation.previous_generation = ? + AND rotation.new_key_id = ? AND rotation.new_generation = ? + AND rotation.request_hash = ? + ON CONFLICT DO NOTHING +`; +const ROTATION_FINALIZE_QUERY = ` + UPDATE sync_vault_rotations + SET completed_at = ? + WHERE user_id = ? AND idempotency_key = ? AND completed_at IS NULL + AND target_device_id = ? AND approver_device_id = ? + AND previous_key_id = ? AND previous_generation = ? + AND new_key_id = ? AND new_generation = ? + AND request_hash = ? AND envelope_count = ? AND r2_object_count = ? +`; + +interface R2CountRow { object_count: unknown } +export interface RotationResultRow { + target_device_id: unknown; + approver_device_id: unknown; + previous_key_id: unknown; + previous_generation: unknown; + new_key_id: unknown; + new_generation: unknown; + request_hash: unknown; + envelope_count: unknown; + r2_object_count: unknown; + completed_at: unknown; + current_key_id: unknown; + current_generation: unknown; + target_status: unknown; + revoked_at: unknown; + active_session_count: unknown; + item_count: unknown; + r2_item_count: unknown; + persisted_count: unknown; + audit_count: unknown; +} + +export async function rotationR2ObjectCount(env: Env, userId: string): Promise { + const rows = await env.ELY_DB.prepare(ROTATION_R2_COUNT_QUERY) + .bind(userId, userId) + .all(); + const count = rows.results[0]?.object_count; + if (rows.results.length !== 1 || typeof count !== "number" || !Number.isSafeInteger(count) || count < 0) { + throw new DevicePersistenceError("device_revocation_r2_count_invalid"); + } + return count; +} + +export function rotationResult( + env: Env, + userId: string, + idempotencyKey: string, +): Promise { + return env.ELY_DB.prepare(ROTATION_RESULT_QUERY) + .bind(userId, idempotencyKey) + .first(); +} + +export async function rotationStatements( + env: Env, + userId: string, + approverDeviceId: string, + revocation: ApprovedDeviceRevocationRequest, + requestHash: string, + r2ObjectCount: number, + nowSeconds: number, +): Promise { + const statements = [env.ELY_DB.prepare(ROTATION_INSERT_QUERY).bind( + userId, + revocation.idempotencyKey, + `device-revoke:${requestHash}`, + revocation.deviceId, + approverDeviceId, + revocation.previousKeyId, + revocation.previousGeneration, + revocation.newKeyId, + revocation.newGeneration, + requestHash, + revocation.envelopes.length, + r2ObjectCount, + nowSeconds, + )]; + const envelopeIds = await Promise.all(revocation.envelopes.map((item) => + rotationEnvelopeIdempotencyKey(userId, revocation.idempotencyKey, item.recipientDeviceId) + )); + revocation.envelopes.forEach((item, index) => statements.push( + guardedEnvelopeStatement( + env, userId, approverDeviceId, revocation, requestHash, envelopeIds[index]!, item, + ), + )); + statements.push(env.ELY_DB.prepare(ROTATION_FINALIZE_QUERY).bind( + nowSeconds, + userId, + revocation.idempotencyKey, + revocation.deviceId, + approverDeviceId, + revocation.previousKeyId, + revocation.previousGeneration, + revocation.newKeyId, + revocation.newGeneration, + requestHash, + revocation.envelopes.length, + r2ObjectCount, + )); + return statements; +} + +function guardedEnvelopeStatement( + env: Env, + userId: string, + approverDeviceId: string, + revocation: ApprovedDeviceRevocationRequest, + requestHash: string, + envelopeId: string, + item: ApprovedDeviceRevocationRequest["envelopes"][number], +): ElyD1PreparedStatement { + return env.ELY_DB.prepare(ROTATION_ENVELOPE_INSERT_QUERY).bind( + userId, + revocation.idempotencyKey, + item.recipientDeviceId, + envelopeId, + item.envelope.version, + item.envelope.suite, + item.envelope.encapped_key, + item.envelope.ciphertext, + userId, + revocation.idempotencyKey, + revocation.deviceId, + approverDeviceId, + revocation.previousKeyId, + revocation.previousGeneration, + revocation.newKeyId, + revocation.newGeneration, + requestHash, + ); +} diff --git a/cloudflare/src/device_routes.ts b/cloudflare/src/device_routes.ts new file mode 100644 index 0000000..809ccc1 --- /dev/null +++ b/cloudflare/src/device_routes.ts @@ -0,0 +1,163 @@ +import type { Env } from "./bindings.js"; +import { withAuthenticatedApiControls } from "./api_controls.js"; +import { issueDeviceRebindChallenge, rebindDeviceSession } from "./device_rebind.js"; +import { approveDeviceDocument } from "./device_approval.js"; +import { revokeDeviceDocument } from "./device_revocation.js"; +import { + DeviceConflictError, + DevicePermissionError, + DevicePersistenceError, + DeviceSchemaError, + deviceListDocument, + registerDeviceDocument, +} from "./devices.js"; +import { jsonResponse } from "./responses.js"; + +const NO_STORE = { "Cache-Control": "no-store" } as const; + +export async function handleDeviceRoute( + request: Request, + env: Env, + url: URL, +): Promise { + if (url.pathname === "/api/devices") { + return withAuthenticatedApiControls(request, env, "devices.list", ["GET"], async (context) => { + try { + return jsonResponse(await deviceListDocument(env, context), 200, NO_STORE); + } catch (error) { + if (error instanceof DeviceSchemaError) { + return jsonResponse({ error: "devices_invalid" }, 500, NO_STORE); + } + throw error; + } + }); + } + if (url.pathname === "/api/devices/register") { + return withAuthenticatedApiControls( + request, + env, + "devices.register", + ["POST"], + async (context) => { + try { + return jsonResponse(await registerDeviceDocument(request, env, context), 201, NO_STORE); + } catch (error) { + if (error instanceof DeviceConflictError) { + return jsonResponse({ error: "device_registration_conflict" }, 409, NO_STORE); + } + if (error instanceof DevicePermissionError) { + return jsonResponse({ error: "device_registration_forbidden" }, 403, NO_STORE); + } + if (error instanceof DeviceSchemaError) { + return jsonResponse({ error: "invalid_device_registration" }, 400, NO_STORE); + } + if (error instanceof DevicePersistenceError) { + return jsonResponse({ error: "device_registration_failed" }, 500, NO_STORE); + } + throw error; + } + }, + ); + } + if (url.pathname === "/api/devices/rebind/challenge") { + return withAuthenticatedApiControls( + request, + env, + "devices.rebind_challenge", + ["POST"], + async (context) => { + try { + return jsonResponse(await issueDeviceRebindChallenge(request, env, context), 201, NO_STORE); + } catch (error) { + return deviceRebindErrorResponse(error, "challenge"); + } + }, + ); + } + if (url.pathname === "/api/devices/rebind") { + return withAuthenticatedApiControls( + request, + env, + "devices.rebind", + ["POST"], + async (context) => { + try { + return jsonResponse(await rebindDeviceSession(request, env, context), 200, NO_STORE); + } catch (error) { + return deviceRebindErrorResponse(error, "rebind"); + } + }, + ); + } + if (url.pathname === "/api/devices/approve") { + return withAuthenticatedApiControls( + request, + env, + "devices.approve", + ["POST"], + async (context) => { + try { + return jsonResponse(await approveDeviceDocument(request, env, context), 200, NO_STORE); + } catch (error) { + if (error instanceof DeviceConflictError) { + return jsonResponse({ error: "device_approval_conflict" }, 409, NO_STORE); + } + if (error instanceof DevicePermissionError) { + return jsonResponse({ error: "device_approval_forbidden" }, 403, NO_STORE); + } + if (error instanceof DeviceSchemaError) { + return jsonResponse({ error: "invalid_device_approval" }, 400, NO_STORE); + } + if (error instanceof DevicePersistenceError) { + return jsonResponse({ error: "device_approval_failed" }, 500, NO_STORE); + } + throw error; + } + }, + ); + } + if (url.pathname === "/api/devices/revoke") { + return withAuthenticatedApiControls( + request, + env, + "devices.revoke", + ["POST"], + async (context) => { + try { + return jsonResponse(await revokeDeviceDocument(request, env, context), 200, NO_STORE); + } catch (error) { + if (error instanceof DeviceConflictError) { + return jsonResponse({ error: "device_revocation_conflict" }, 409, NO_STORE); + } + if (error instanceof DevicePermissionError) { + return jsonResponse({ error: "device_revocation_forbidden" }, 403, NO_STORE); + } + if (error instanceof DeviceSchemaError) { + return jsonResponse({ error: "invalid_device_revocation" }, 400, NO_STORE); + } + if (error instanceof DevicePersistenceError) { + return jsonResponse({ error: "device_revocation_failed" }, 500, NO_STORE); + } + throw error; + } + }, + ); + } + return null; +} + +function deviceRebindErrorResponse(error: unknown, operation: "challenge" | "rebind"): Response { + if (error instanceof DeviceConflictError) { + return jsonResponse({ error: "device_rebind_conflict" }, 409, NO_STORE); + } + if (error instanceof DevicePermissionError) { + return jsonResponse({ error: "device_rebind_forbidden" }, 403, NO_STORE); + } + if (error instanceof DeviceSchemaError) { + return jsonResponse({ error: `invalid_device_${operation}` }, 400, NO_STORE); + } + if (error instanceof DevicePersistenceError) { + return jsonResponse({ error: "device_rebind_failed" }, 500, NO_STORE); + } + throw error; +} diff --git a/cloudflare/src/device_schema.ts b/cloudflare/src/device_schema.ts index fc4374b..7b42a36 100644 --- a/cloudflare/src/device_schema.ts +++ b/cloudflare/src/device_schema.ts @@ -1,7 +1,13 @@ import type { AuthContext } from "./auth.js"; +import { + type WrappedAccountKeyDocument, + SyncVaultRequestError, + parseWrappedAccountKey, +} from "./sync_vault.js"; const DEVICE_ID_PATTERN = /^[a-zA-Z0-9._:-]{3,128}$/; -const PUBLIC_KEY_PATTERN = /^[a-fA-F0-9]{64,256}$/; +const PUBLIC_KEY_PATTERN = /^[a-f0-9]{64}$/; +const SIGNATURE_PATTERN = /^[a-f0-9]{128}$/; const DEVICE_TEXT_PATTERN = /^[^\p{Cc}\p{Cs}]{1,128}$/u; const APPROVAL_STATUS = new Set(["pending", "approved", "revoked"]); @@ -12,7 +18,7 @@ export interface DeviceListDocument { } export interface DeviceRegistrationDocument { - version: 1; + version: 2; user_id: string; device: DeviceDocument; } @@ -25,17 +31,32 @@ export interface DeviceApprovalDocument { device: DeviceDocument; } -export interface DeviceRevocationDocument { - version: 1; +interface DeviceRevocationDocumentBase { + version: 2; user_id: string; revoked_by_device_id: string; revoked_at: number; device: DeviceDocument; } +export interface ApprovedDeviceRevocationDocument extends DeviceRevocationDocumentBase { + mode: "approved_rotate"; + key_id: string; + generation: number; +} + +export interface PendingDeviceRevocationDocument extends DeviceRevocationDocumentBase { + mode: "pending_revoke"; +} + +export type DeviceRevocationDocument = + | ApprovedDeviceRevocationDocument + | PendingDeviceRevocationDocument; + export interface DeviceDocument { device_id: string; public_key: string; + wrapping_public_key?: string; device_name: string; platform: string; approval_status: "pending" | "approved" | "revoked"; @@ -49,6 +70,7 @@ export interface DeviceDocument { export interface DeviceRow { device_id: unknown; public_key: unknown; + wrapping_public_key?: unknown; device_name: unknown; platform: unknown; approval_status: unknown; @@ -61,14 +83,22 @@ export interface DeviceRow { export interface DeviceRegistrationRequest { deviceId: string; publicKey: string; + wrappingPublicKey: string; + registrationProof: string; deviceName: string; platform: string; idempotencyKey: string; } -export type DeviceApprovalRequest = { deviceId: string; idempotencyKey: string }; -export type DeviceRevocationRequest = { deviceId: string; idempotencyKey: string }; - +export interface DeviceApprovalRequest { + deviceId: string; + keyId: string; + generation: number; + envelope: WrappedAccountKeyDocument; + idempotencyKey: string; + proofCreatedAt: number; + approvalProof: string; +} export interface DeviceApprovalRow { device_id: unknown; requester_device_id: unknown; @@ -76,13 +106,6 @@ export interface DeviceApprovalRow { decided_at: unknown; } -export interface DeviceRevocationRow { - actor_device_id: unknown; - subject_id: unknown; - outcome: unknown; - created_at: unknown; -} - type DeviceRequestBody = Record; export class DeviceSchemaError extends Error { @@ -121,17 +144,21 @@ export async function deviceRegistrationRequest( "version", "device_id", "public_key", + "wrapping_public_key", + "registration_proof", "device_name", "platform", "idempotency_key", ]); - if (value.version !== 1) { + if (value.version !== 2) { throw new DeviceSchemaError("device_registration_version_invalid"); } return { deviceId: deviceIdValue(value.device_id, "device_id"), - publicKey: publicKeyValue(value.public_key), + publicKey: publicKeyValue(value.public_key, "public_key"), + wrappingPublicKey: publicKeyValue(value.wrapping_public_key, "wrapping_public_key"), + registrationProof: signatureValue(value.registration_proof, "registration_proof"), deviceName: deviceText(value.device_name, "device_name"), platform: deviceText(value.platform, "platform"), idempotencyKey: idempotencyKeyValue(value.idempotency_key), @@ -140,27 +167,28 @@ export async function deviceRegistrationRequest( export async function deviceApprovalRequest(request: Request): Promise { const value = await deviceRequestBody(request, "device_approval"); - assertOnlyFields(value, ["version", "device_id", "idempotency_key"]); - if (value.version !== 1) { + assertOnlyFields(value, [ + "version", + "device_id", + "key_id", + "generation", + "envelope", + "idempotency_key", + "proof_created_at", + "approval_proof", + ]); + if (value.version !== 2) { throw new DeviceSchemaError("device_approval_version_invalid"); } return { deviceId: deviceIdValue(value.device_id, "device_id"), + keyId: keyIdValue(value.key_id), + generation: positiveInteger(value.generation, "generation"), + envelope: wrappedAccountKey(value.envelope), idempotencyKey: idempotencyKeyValue(value.idempotency_key), - }; -} - -export async function deviceRevocationRequest(request: Request): Promise { - const value = await deviceRequestBody(request, "device_revocation"); - assertOnlyFields(value, ["version", "device_id", "idempotency_key"]); - if (value.version !== 1) { - throw new DeviceSchemaError("device_revocation_version_invalid"); - } - - return { - deviceId: deviceIdValue(value.device_id, "device_id"), - idempotencyKey: idempotencyKeyValue(value.idempotency_key), + proofCreatedAt: positiveInteger(value.proof_created_at, "proof_created_at"), + approvalProof: signatureValue(value.approval_proof, "approval_proof"), }; } @@ -182,24 +210,6 @@ export function approvedDeviceDocument( }; } -export function revokedDeviceDocument( - userId: string, - revokedByDeviceId: string, - row: DeviceRow, -): DeviceRevocationDocument { - const device = deviceDocument(row, revokedByDeviceId); - if (device.approval_status !== "revoked" || device.revoked_at === null) { - throw new DevicePersistenceError("device_revocation_missing"); - } - return { - version: 1, - user_id: userId, - revoked_by_device_id: revokedByDeviceId, - revoked_at: device.revoked_at, - device, - }; -} - export function currentDeviceId(context: AuthContext): string { if (context.deviceId === undefined) { throw new DevicePermissionError("device_context_required"); @@ -212,9 +222,16 @@ export function deviceDocument( currentDeviceIdValue: string | undefined, ): DeviceDocument { const deviceId = deviceIdValue(row.device_id, "device_id"); + const wrappingPublicKey = optionalPublicKeyValue( + row.wrapping_public_key, + "wrapping_public_key", + ); return { device_id: deviceId, - public_key: publicKeyValue(row.public_key), + public_key: publicKeyValue(row.public_key, "public_key"), + ...(wrappingPublicKey === undefined + ? {} + : { wrapping_public_key: wrappingPublicKey }), device_name: deviceText(row.device_name, "device_name"), platform: deviceText(row.platform, "platform"), approval_status: approvalStatus(row.approval_status), @@ -240,11 +257,25 @@ export function timestamp(value: unknown, label: string): number { return value; } -function publicKeyValue(value: unknown): string { +export function publicKeyValue(value: unknown, label: string): string { if (typeof value !== "string" || !PUBLIC_KEY_PATTERN.test(value)) { - throw new DeviceSchemaError("public_key_invalid"); + throw new DeviceSchemaError(`${label}_invalid`); } - return value.toLowerCase(); + return value; +} + +export function signatureValue(value: unknown, label: string): string { + if (typeof value !== "string" || !SIGNATURE_PATTERN.test(value)) { + throw new DeviceSchemaError(`${label}_invalid`); + } + return value; +} + +function optionalPublicKeyValue(value: unknown, label: string): string | undefined { + if (value === undefined || value === null) { + return undefined; + } + return publicKeyValue(value, label); } function deviceText(value: unknown, label: string): string { @@ -265,13 +296,38 @@ function approvalStatus(value: unknown): DeviceDocument["approval_status"] { return value as DeviceDocument["approval_status"]; } -function idempotencyKeyValue(value: unknown): string { +export function idempotencyKeyValue(value: unknown): string { if (typeof value !== "string" || !/^[a-zA-Z0-9._:-]{16,128}$/.test(value)) { throw new DeviceSchemaError("idempotency_key_invalid"); } return value; } +export function keyIdValue(value: unknown): string { + if (typeof value !== "string" || !/^[a-f0-9]{64}$/.test(value)) { + throw new DeviceSchemaError("key_id_invalid"); + } + return value; +} + +export function positiveInteger(value: unknown, label: string): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1) { + throw new DeviceSchemaError(`${label}_invalid`); + } + return value; +} + +export function wrappedAccountKey(value: unknown): WrappedAccountKeyDocument { + try { + return parseWrappedAccountKey(value); + } catch (error) { + if (error instanceof SyncVaultRequestError) { + throw new DeviceSchemaError("envelope_invalid"); + } + throw error; + } +} + function nullableTimestamp(value: unknown, label: string): number | null { if (value === null) { return null; @@ -279,7 +335,7 @@ function nullableTimestamp(value: unknown, label: string): number | null { return timestamp(value, label); } -function assertOnlyFields(value: DeviceRequestBody, fields: string[]): void { +export function assertOnlyFields(value: DeviceRequestBody, fields: string[]): void { const allowed = new Set(fields); for (const field of Object.keys(value)) { if (!allowed.has(field)) { @@ -288,7 +344,10 @@ function assertOnlyFields(value: DeviceRequestBody, fields: string[]): void { } } -async function deviceRequestBody(request: Request, label: string): Promise { +export async function deviceRequestBody( + request: Request, + label: string, +): Promise { let value: unknown; try { value = await request.json(); diff --git a/cloudflare/src/devices.ts b/cloudflare/src/devices.ts index 958d998..9f6eb4f 100644 --- a/cloudflare/src/devices.ts +++ b/cloudflare/src/devices.ts @@ -1,28 +1,17 @@ import type { AuthContext } from "./auth.js"; import type { Env } from "./bindings.js"; +import { assertDeviceRegistrationProof } from "./device_registration_proof.js"; import { - type DeviceApprovalDocument, - type DeviceApprovalRequest, - type DeviceApprovalRow, type DeviceListDocument, type DeviceRegistrationDocument, type DeviceRegistrationRequest, - type DeviceRevocationDocument, - type DeviceRevocationRequest, - type DeviceRevocationRow, type DeviceRow, DeviceConflictError, DevicePermissionError, DevicePersistenceError, - approvedDeviceDocument, currentDeviceId, - deviceApprovalRequest, deviceDocument, - deviceIdValue, deviceRegistrationRequest, - deviceRevocationRequest, - revokedDeviceDocument, - timestamp, } from "./device_schema.js"; export { @@ -32,149 +21,87 @@ export { DeviceSchemaError, } from "./device_schema.js"; +const UNBOUND_SESSION_MAX_AGE_SECONDS = 10 * 60; +const SESSION_CLOCK_SKEW_SECONDS = 30; + +const DEVICE_COLUMNS = ` + device.device_id, + device.public_key, + device.device_name, + device.platform, + device.approval_status, + device.created_at, + device.approved_at, + device.last_active_at, + device.revoked_at, + keys.wrapping_public_key +`; +const DEVICE_FROM = ` + FROM user_devices AS device + LEFT JOIN user_device_keys AS keys + ON keys.user_id = device.user_id AND keys.device_id = device.device_id +`; const DEVICE_LIST_QUERY = ` - SELECT - device_id, - public_key, - device_name, - platform, - approval_status, - created_at, - approved_at, - last_active_at, - revoked_at - FROM user_devices - WHERE user_id = ? + SELECT ${DEVICE_COLUMNS} + ${DEVICE_FROM} + WHERE device.user_id = ? ORDER BY - revoked_at IS NOT NULL, - COALESCE(last_active_at, approved_at, created_at) DESC, - device_id ASC + device.revoked_at IS NOT NULL, + COALESCE(device.last_active_at, device.approved_at, device.created_at) DESC, + device.device_id ASC `; const DEVICE_REGISTER_QUERY = ` + WITH registration AS ( + SELECT CASE + WHEN NOT EXISTS (SELECT 1 FROM user_devices WHERE user_id = ?) THEN 'approved' + ELSE 'pending' + END AS approval_status + ) INSERT INTO user_devices ( - user_id, - device_id, - public_key, - device_name, - platform, - approval_status, - created_at, - approved_at, - last_active_at, - revoked_at, - idempotency_key - ) VALUES (?, ?, ?, ?, ?, 'pending', ?, NULL, ?, NULL, ?) + user_id, device_id, public_key, device_name, platform, + approval_status, created_at, approved_at, last_active_at, revoked_at, idempotency_key + ) + SELECT ?, ?, ?, ?, ?, approval_status, ?, + CASE WHEN approval_status = 'approved' THEN ? ELSE NULL END, + ?, NULL, ? + FROM registration + WHERE EXISTS ( + SELECT 1 FROM better_auth_session + WHERE id = ? AND userId = ? + ) + ON CONFLICT DO NOTHING +`; +const DEVICE_KEYS_INSERT_QUERY = ` + INSERT INTO user_device_keys ( + user_id, device_id, signing_public_key, + wrapping_public_key, key_protocol_version, created_at + ) + SELECT ?, ?, ?, ?, 2, ? + WHERE EXISTS ( + SELECT 1 FROM user_devices + WHERE user_id = ? AND device_id = ? AND public_key = ? + AND device_name = ? AND platform = ? AND idempotency_key = ? + AND revoked_at IS NULL + ) + AND EXISTS ( + SELECT 1 FROM better_auth_session + WHERE id = ? AND userId = ? + ) ON CONFLICT DO NOTHING `; const DEVICE_BY_IDEMPOTENCY_KEY_QUERY = ` - SELECT - device_id, - public_key, - device_name, - platform, - approval_status, - created_at, - approved_at, - last_active_at, - revoked_at - FROM user_devices - WHERE user_id = ? AND idempotency_key = ? + SELECT ${DEVICE_COLUMNS} + ${DEVICE_FROM} + WHERE device.user_id = ? AND device.idempotency_key = ? `; const DEVICE_BY_ID_QUERY = ` - SELECT - device_id, - public_key, - device_name, - platform, - approval_status, - created_at, - approved_at, - last_active_at, - revoked_at - FROM user_devices - WHERE user_id = ? AND device_id = ? -`; -const APPROVED_DEVICE_QUERY = ` - SELECT - device_id, - public_key, - device_name, - platform, - approval_status, - created_at, - approved_at, - last_active_at, - revoked_at - FROM user_devices - WHERE user_id = ? AND device_id = ? AND approval_status = 'approved' AND revoked_at IS NULL -`; -const DEVICE_APPROVAL_BY_IDEMPOTENCY_KEY_QUERY = ` - SELECT - device_id, - requester_device_id, - status, - decided_at - FROM device_approvals - WHERE user_id = ? AND idempotency_key = ? -`; -const DEVICE_APPROVAL_INSERT_QUERY = ` - INSERT INTO device_approvals ( - user_id, - approval_id, - device_id, - requester_device_id, - status, - requested_at, - decided_at, - expires_at, - idempotency_key - ) VALUES (?, ?, ?, ?, 'approved', ?, ?, ?, ?) - ON CONFLICT(user_id, idempotency_key) DO NOTHING -`; -const DEVICE_APPROVE_QUERY = ` - UPDATE user_devices - SET - approval_status = 'approved', - approved_at = COALESCE(approved_at, ?), - last_active_at = ? - WHERE user_id = ? AND device_id = ? AND approval_status = 'pending' AND revoked_at IS NULL -`; -const DEVICE_REVOCATION_BY_IDEMPOTENCY_KEY_QUERY = ` - SELECT - actor_device_id, - subject_id, - outcome, - created_at - FROM audit_events - WHERE user_id = ? AND event_id = ? AND event_type = 'device.revoke' -`; -const DEVICE_REVOCATION_INSERT_QUERY = ` - INSERT INTO audit_events ( - event_id, - user_id, - actor_device_id, - event_type, - subject_type, - subject_id, - outcome, - metadata_hash, - created_at - ) VALUES (?, ?, ?, 'device.revoke', 'device', ?, 'success', NULL, ?) - ON CONFLICT(event_id) DO NOTHING -`; -const DEVICE_REVOKE_QUERY = ` - UPDATE user_devices - SET - approval_status = 'revoked', - revoked_at = COALESCE(revoked_at, ?) - WHERE user_id = ? AND device_id = ? AND revoked_at IS NULL + SELECT ${DEVICE_COLUMNS} + ${DEVICE_FROM} + WHERE device.user_id = ? AND device.device_id = ? `; const SESSION_DEVICE_CONTEXT_UPSERT_QUERY = ` INSERT INTO better_auth_session_device_context ( - session_id, - user_id, - device_id, - updated_at + session_id, user_id, device_id, updated_at ) SELECT ?, ?, ?, ? WHERE EXISTS ( @@ -182,10 +109,7 @@ const SESSION_DEVICE_CONTEXT_UPSERT_QUERY = ` FROM better_auth_session WHERE id = ? AND userId = ? ) - ON CONFLICT(session_id) DO UPDATE SET - user_id = excluded.user_id, - device_id = excluded.device_id, - updated_at = excluded.updated_at + ON CONFLICT(session_id) DO NOTHING `; export async function deviceListDocument( @@ -210,9 +134,12 @@ export async function registerDeviceDocument( if (context.deviceId !== undefined && context.deviceId !== registration.deviceId) { throw new DevicePermissionError("device_context_mismatch"); } + await assertDeviceRegistrationProof(registration); + assertFreshUnboundSession(context, nowSeconds); - const writeResult = await env.ELY_DB.prepare(DEVICE_REGISTER_QUERY) - .bind( + const [deviceWriteResult, keyWriteResult] = await env.ELY_DB.batch([ + env.ELY_DB.prepare(DEVICE_REGISTER_QUERY).bind( + context.userId, context.userId, registration.deviceId, registration.publicKey, @@ -220,11 +147,30 @@ export async function registerDeviceDocument( registration.platform, nowSeconds, nowSeconds, + nowSeconds, registration.idempotencyKey, - ) - .run(); - const insertedRows = changedRowCount(writeResult); - if (insertedRows > 1) { + context.sessionId, + context.userId, + ), + env.ELY_DB.prepare(DEVICE_KEYS_INSERT_QUERY).bind( + context.userId, + registration.deviceId, + registration.publicKey, + registration.wrappingPublicKey, + nowSeconds, + context.userId, + registration.deviceId, + registration.publicKey, + registration.deviceName, + registration.platform, + registration.idempotencyKey, + context.sessionId, + context.userId, + ), + ]); + const insertedRows = changedRowCount(deviceWriteResult, "device_registration"); + const insertedKeyRows = changedRowCount(keyWriteResult, "device_key_registration"); + if (insertedRows > 1 || insertedKeyRows > 1 || insertedRows !== insertedKeyRows) { throw new DevicePersistenceError("device_registration_write_count_invalid"); } const row = await env.ELY_DB.prepare(DEVICE_BY_IDEMPOTENCY_KEY_QUERY) @@ -249,23 +195,36 @@ export async function registerDeviceDocument( throw new DeviceConflictError("device_registration_conflict"); } return { - version: 1, + version: 2, user_id: context.userId, device, }; } - if (device.approval_status !== "pending" || device.revoked_at !== null) { + if (device.revoked_at !== null) { throw new DevicePersistenceError("device_registration_state_invalid"); } await bindSessionDeviceContext(env, context, device.device_id, nowSeconds); return { - version: 1, + version: 2, user_id: context.userId, device, }; } +function assertFreshUnboundSession(context: AuthContext, nowSeconds: number): void { + if (context.deviceId !== undefined) { + return; + } + const createdAtSeconds = Math.floor(Date.parse(context.createdAt) / 1000); + if ( + createdAtSeconds < nowSeconds - UNBOUND_SESSION_MAX_AGE_SECONDS || + createdAtSeconds > nowSeconds + SESSION_CLOCK_SKEW_SECONDS + ) { + throw new DevicePermissionError("fresh_session_required"); + } +} + function registrationMatches( device: DeviceRegistrationDocument["device"], registration: DeviceRegistrationRequest, @@ -273,22 +232,23 @@ function registrationMatches( return ( device.device_id === registration.deviceId && device.public_key === registration.publicKey && + device.wrapping_public_key === registration.wrappingPublicKey && device.device_name === registration.deviceName && device.platform === registration.platform ); } -function changedRowCount(result: unknown): number { +function changedRowCount(result: unknown, label: string): number { if (typeof result !== "object" || result === null || !("meta" in result)) { - throw new DevicePersistenceError("device_registration_write_result_invalid"); + throw new DevicePersistenceError(`${label}_write_result_invalid`); } const meta = result.meta; if (typeof meta !== "object" || meta === null || !("changes" in meta)) { - throw new DevicePersistenceError("device_registration_write_result_invalid"); + throw new DevicePersistenceError(`${label}_write_result_invalid`); } const changes = meta.changes; if (typeof changes !== "number" || !Number.isSafeInteger(changes) || changes < 0) { - throw new DevicePersistenceError("device_registration_write_result_invalid"); + throw new DevicePersistenceError(`${label}_write_result_invalid`); } return changes; } @@ -299,187 +259,10 @@ async function bindSessionDeviceContext( deviceId: string, nowSeconds: number, ): Promise { - await env.ELY_DB.prepare(SESSION_DEVICE_CONTEXT_UPSERT_QUERY) + const result = await env.ELY_DB.prepare(SESSION_DEVICE_CONTEXT_UPSERT_QUERY) .bind(context.sessionId, context.userId, deviceId, nowSeconds, context.sessionId, context.userId) .run(); -} - -export async function approveDeviceDocument( - request: Request, - env: Env, - context: AuthContext, - nowSeconds = Math.floor(Date.now() / 1000), -): Promise { - const approval = await deviceApprovalRequest(request); - const requesterDeviceId = currentDeviceId(context); - if (requesterDeviceId === approval.deviceId) { - throw new DevicePermissionError("device_self_approval_forbidden"); - } - - await assertApprovedRequester(env, context.userId, requesterDeviceId); - const existingApproval = await env.ELY_DB.prepare(DEVICE_APPROVAL_BY_IDEMPOTENCY_KEY_QUERY) - .bind(context.userId, approval.idempotencyKey) - .first(); - if (existingApproval !== null) { - return existingApprovalDocument(env, context, approval, requesterDeviceId, existingApproval); - } - - const pendingDevice = await deviceRowById(env, context.userId, approval.deviceId); - if (pendingDevice === null) { - throw new DevicePermissionError("device_not_found"); - } - const pendingDocument = deviceDocument(pendingDevice, requesterDeviceId); - if (pendingDocument.approval_status !== "pending" || pendingDocument.revoked_at !== null) { - throw new DevicePermissionError("device_not_pending"); - } - - await env.ELY_DB.batch([ - env.ELY_DB.prepare(DEVICE_APPROVAL_INSERT_QUERY).bind( - context.userId, - approval.idempotencyKey, - approval.deviceId, - requesterDeviceId, - nowSeconds, - nowSeconds, - nowSeconds, - approval.idempotencyKey, - ), - env.ELY_DB.prepare(DEVICE_APPROVE_QUERY).bind( - nowSeconds, - nowSeconds, - context.userId, - approval.deviceId, - ), - ]); - - const approvedDevice = await deviceRowById(env, context.userId, approval.deviceId); - if (approvedDevice === null) { - throw new DevicePersistenceError("device_approval_missing"); - } - return approvedDeviceDocument(context.userId, requesterDeviceId, approvedDevice); -} - -export async function revokeDeviceDocument( - request: Request, - env: Env, - context: AuthContext, - nowSeconds = Math.floor(Date.now() / 1000), -): Promise { - const revocation = await deviceRevocationRequest(request); - const requesterDeviceId = currentDeviceId(context); - if (requesterDeviceId === revocation.deviceId) { - throw new DevicePermissionError("device_self_revocation_forbidden"); - } - - const revocationEventId = deviceRevocationEventId(context.userId, revocation.idempotencyKey); - await assertApprovedRequester(env, context.userId, requesterDeviceId); - const existingRevocation = await env.ELY_DB.prepare(DEVICE_REVOCATION_BY_IDEMPOTENCY_KEY_QUERY) - .bind(context.userId, revocationEventId) - .first(); - if (existingRevocation !== null) { - return existingRevocationDocument(env, context, revocation, requesterDeviceId, existingRevocation); - } - - const targetDevice = await deviceRowById(env, context.userId, revocation.deviceId); - if (targetDevice === null) { - throw new DevicePermissionError("device_not_found"); - } - const targetDocument = deviceDocument(targetDevice, requesterDeviceId); - if (targetDocument.revoked_at !== null) { - throw new DevicePermissionError("device_already_revoked"); - } - - await env.ELY_DB.batch([ - env.ELY_DB.prepare(DEVICE_REVOCATION_INSERT_QUERY).bind( - revocationEventId, - context.userId, - requesterDeviceId, - revocation.deviceId, - nowSeconds, - ), - env.ELY_DB.prepare(DEVICE_REVOKE_QUERY).bind(nowSeconds, context.userId, revocation.deviceId), - ]); - - const revokedDevice = await deviceRowById(env, context.userId, revocation.deviceId); - if (revokedDevice === null) { - throw new DevicePersistenceError("device_revocation_missing"); - } - return revokedDeviceDocument(context.userId, requesterDeviceId, revokedDevice); -} - -function deviceRevocationEventId(userId: string, idempotencyKey: string): string { - return `device-revoke:${userId}:${idempotencyKey}`; -} - -async function existingApprovalDocument( - env: Env, - context: AuthContext, - approval: DeviceApprovalRequest, - requesterDeviceId: string, - row: DeviceApprovalRow, -): Promise { - const approvedDeviceId = deviceIdValue(row.device_id, "device_id"); - const approvedByDeviceId = deviceIdValue(row.requester_device_id, "requester_device_id"); - if ( - approvedDeviceId !== approval.deviceId || - approvedByDeviceId !== requesterDeviceId || - row.status !== "approved" - ) { - throw new DevicePermissionError("device_approval_replay_mismatch"); - } - - const approvedDevice = await deviceRowById(env, context.userId, approvedDeviceId); - if (approvedDevice === null) { - throw new DevicePersistenceError("device_approval_missing"); - } - - return { - ...approvedDeviceDocument(context.userId, requesterDeviceId, approvedDevice), - approved_at: timestamp(row.decided_at, "decided_at"), - }; -} - -async function existingRevocationDocument( - env: Env, - context: AuthContext, - revocation: DeviceRevocationRequest, - requesterDeviceId: string, - row: DeviceRevocationRow, -): Promise { - const revokedDeviceId = deviceIdValue(row.subject_id, "subject_id"); - const revokedByDeviceId = deviceIdValue(row.actor_device_id, "actor_device_id"); - if ( - revokedDeviceId !== revocation.deviceId || - revokedByDeviceId !== requesterDeviceId || - row.outcome !== "success" - ) { - throw new DevicePermissionError("device_revocation_replay_mismatch"); - } - - const revokedDevice = await deviceRowById(env, context.userId, revokedDeviceId); - if (revokedDevice === null) { - throw new DevicePersistenceError("device_revocation_missing"); - } - - return { - ...revokedDeviceDocument(context.userId, requesterDeviceId, revokedDevice), - revoked_at: timestamp(row.created_at, "created_at"), - }; -} - -async function assertApprovedRequester( - env: Env, - userId: string, - requesterDeviceId: string, -): Promise { - const requester = await env.ELY_DB.prepare(APPROVED_DEVICE_QUERY) - .bind(userId, requesterDeviceId) - .first(); - if (requester === null) { - throw new DevicePermissionError("requester_device_unapproved"); + if (changedRowCount(result, "device_session_binding") !== 1) { + throw new DeviceConflictError("device_session_binding_conflict"); } } - -async function deviceRowById(env: Env, userId: string, deviceId: string): Promise { - return env.ELY_DB.prepare(DEVICE_BY_ID_QUERY).bind(userId, deviceId).first(); -} diff --git a/cloudflare/src/index.ts b/cloudflare/src/index.ts index 60570ef..b51fa4b 100644 --- a/cloudflare/src/index.ts +++ b/cloudflare/src/index.ts @@ -1,6 +1,5 @@ import type { Env } from "./bindings.js"; import { - withApprovedDeviceApiControls, withAuthenticatedApiControls, withPublicApiControls, } from "./api_controls.js"; @@ -10,16 +9,12 @@ import { accountDeletionDocument, } from "./account_deletion.js"; import { handleBetterAuthRoute } from "./better_auth.js"; +import { handleDeviceRoute } from "./device_routes.js"; +import { DestructiveActionGateError } from "./destructive_action_gate.js"; import { - DeviceConflictError, - DevicePermissionError, - DevicePersistenceError, - DeviceSchemaError, - approveDeviceDocument, - deviceListDocument, - registerDeviceDocument, - revokeDeviceDocument, -} from "./devices.js"; + RecentDeviceActionPermissionError, + RecentDeviceActionPersistenceError, +} from "./recent_device_action_proof.js"; import { PluginRegistrySchemaError, parsePluginRegistryDocument, @@ -43,6 +38,7 @@ import { publicSigningKeysKvKey, } from "./signing_keys.js"; import { handleSyncRoute } from "./sync_routes.js"; +import { maintainSyncR2Storage } from "./sync_r2_maintenance.js"; import { TelemetrySchemaError, telemetryEventAcceptedDocument, @@ -53,6 +49,9 @@ export default { fetch(request: Request, env: Env): Promise { return handleRequest(request, env); }, + scheduled(controller: { scheduledTime: number }, env: Env): Promise { + return maintainSyncR2Storage(env, Math.floor(controller.scheduledTime / 1000)); + }, }; export async function handleRequest(request: Request, env: Env): Promise { @@ -60,143 +59,12 @@ export async function handleRequest(request: Request, env: Env): Promise { - try { - return jsonResponse(await deviceListDocument(env, context), 200, { - "Cache-Control": "no-store", - }); - } catch (error) { - if (error instanceof DeviceSchemaError) { - return jsonResponse({ error: "devices_invalid" }, 500, { "Cache-Control": "no-store" }); - } - throw error; - } - }); - } - if (url.pathname === "/api/devices/register") { - return withAuthenticatedApiControls( - request, - env, - "devices.register", - ["POST"], - async (context) => { - try { - return jsonResponse(await registerDeviceDocument(request, env, context), 201, { - "Cache-Control": "no-store", - }); - } catch (error) { - if (error instanceof DeviceConflictError) { - return jsonResponse( - { error: "device_registration_conflict" }, - 409, - { "Cache-Control": "no-store" }, - ); - } - if (error instanceof DevicePermissionError) { - return jsonResponse( - { error: "device_context_mismatch" }, - 403, - { "Cache-Control": "no-store" }, - ); - } - if (error instanceof DeviceSchemaError) { - return jsonResponse( - { error: "invalid_device_registration" }, - 400, - { "Cache-Control": "no-store" }, - ); - } - if (error instanceof DevicePersistenceError) { - return jsonResponse( - { error: "device_registration_failed" }, - 500, - { "Cache-Control": "no-store" }, - ); - } - throw error; - } - }, - ); - } - if (url.pathname === "/api/devices/approve") { - return withAuthenticatedApiControls( - request, - env, - "devices.approve", - ["POST"], - async (context) => { - try { - return jsonResponse(await approveDeviceDocument(request, env, context), 200, { - "Cache-Control": "no-store", - }); - } catch (error) { - if (error instanceof DevicePermissionError) { - return jsonResponse( - { error: "device_approval_forbidden" }, - 403, - { "Cache-Control": "no-store" }, - ); - } - if (error instanceof DeviceSchemaError) { - return jsonResponse( - { error: "invalid_device_approval" }, - 400, - { "Cache-Control": "no-store" }, - ); - } - if (error instanceof DevicePersistenceError) { - return jsonResponse( - { error: "device_approval_failed" }, - 500, - { "Cache-Control": "no-store" }, - ); - } - throw error; - } - }, - ); - } - if (url.pathname === "/api/devices/revoke") { - return withAuthenticatedApiControls( - request, - env, - "devices.revoke", - ["POST"], - async (context) => { - try { - return jsonResponse(await revokeDeviceDocument(request, env, context), 200, { - "Cache-Control": "no-store", - }); - } catch (error) { - if (error instanceof DevicePermissionError) { - return jsonResponse( - { error: "device_revocation_forbidden" }, - 403, - { "Cache-Control": "no-store" }, - ); - } - if (error instanceof DeviceSchemaError) { - return jsonResponse( - { error: "invalid_device_revocation" }, - 400, - { "Cache-Control": "no-store" }, - ); - } - if (error instanceof DevicePersistenceError) { - return jsonResponse( - { error: "device_revocation_failed" }, - 500, - { "Cache-Control": "no-store" }, - ); - } - throw error; - } - }, - ); + const deviceResponse = await handleDeviceRoute(request, env, url); + if (deviceResponse !== null) { + return deviceResponse; } if (url.pathname === "/api/account/delete") { - return withApprovedDeviceApiControls( + return withAuthenticatedApiControls( request, env, "account.delete", @@ -214,7 +82,18 @@ export async function handleRequest(request: Request, env: Env): Promise; +} + +export class LegacyAuthKvCleanupError extends Error {} + +export async function deleteLegacySessionKeys( + env: Env, + tokens: string[], + currentTokenHash: string, +): Promise { + const keys = new Set([ + authSessionCacheKvKey(env.ELY_ENVIRONMENT, currentTokenHash), + ]); + for (const token of tokens) { + keys.add(authSessionCacheKvKey(env.ELY_ENVIRONMENT, await authTokenHash(token))); + } + let deleted = 0; + for (const key of keys) { + if (await env.ELY_KV.get(key) === null) continue; + await env.ELY_KV.delete(key); + deleted += 1; + } + return deleted; +} + +export async function purgeLegacySessionCache( + env: Env, + maxPages = MAX_LIST_PAGES, +): Promise { + const namespace = env.ELY_KV as Env["ELY_KV"] & Partial; + if (typeof namespace.list !== "function") { + throw new LegacyAuthKvCleanupError("legacy_auth_kv_list_unavailable"); + } + const prefix = authSessionCacheKvKey(env.ELY_ENVIRONMENT, "0".repeat(64)).slice(0, -64); + let cursor: string | undefined; + let deleted = 0; + for (let page = 0; page < maxPages; page += 1) { + const result = await namespace.list({ + prefix, + ...(cursor === undefined ? {} : { cursor }), + limit: LIST_LIMIT, + }); + for (const key of result.keys) { + if (!key.name.startsWith(prefix)) { + throw new LegacyAuthKvCleanupError("legacy_auth_kv_key_invalid"); + } + await namespace.delete(key.name); + deleted += 1; + } + if (result.list_complete) break; + if (typeof result.cursor !== "string" || result.cursor.length === 0) { + throw new LegacyAuthKvCleanupError("legacy_auth_kv_cursor_invalid"); + } + cursor = result.cursor; + } + return deleted; +} diff --git a/cloudflare/src/pending_device_revocation.ts b/cloudflare/src/pending_device_revocation.ts new file mode 100644 index 0000000..704e451 --- /dev/null +++ b/cloudflare/src/pending_device_revocation.ts @@ -0,0 +1,228 @@ +import type { AuthContext } from "./auth.js"; +import type { ElyD1Result, Env } from "./bindings.js"; +import type { PendingDeviceRevocationRequest } from "./device_revocation_schema.js"; +import { + type DeviceRevocationDocument, + type DeviceRow, + DeviceConflictError, + DevicePermissionError, + DevicePersistenceError, + deviceDocument, +} from "./device_schema.js"; + +const DEVICE_BY_ID_QUERY = ` + SELECT + device.device_id, device.public_key, device.device_name, device.platform, + device.approval_status, device.created_at, device.approved_at, + device.last_active_at, device.revoked_at, keys.wrapping_public_key + FROM user_devices AS device + LEFT JOIN user_device_keys AS keys + ON keys.user_id = device.user_id AND keys.device_id = device.device_id + WHERE device.user_id = ? AND device.device_id = ? +`; +const PENDING_RESULT_QUERY = ` + SELECT + revocation.target_device_id, revocation.approver_device_id, + revocation.request_hash, revocation.completed_at, + target.approval_status AS target_status, target.revoked_at, + (SELECT COUNT(*) + FROM better_auth_session AS session + INNER JOIN better_auth_session_device_context AS context + ON context.session_id = session.id + WHERE context.user_id = revocation.user_id + AND context.device_id = revocation.target_device_id) AS active_session_count, + (SELECT COUNT(*) FROM audit_events AS audit + WHERE audit.event_id = revocation.audit_event_id + AND audit.user_id = revocation.user_id + AND audit.actor_device_id = revocation.approver_device_id + AND audit.event_type = 'device.revoke' + AND audit.subject_id = revocation.target_device_id + AND audit.outcome = 'success' + AND audit.metadata_hash = revocation.request_hash + AND audit.created_at = revocation.completed_at) AS audit_count + FROM pending_device_revocations AS revocation + LEFT JOIN user_devices AS target + ON target.user_id = revocation.user_id AND target.device_id = revocation.target_device_id + WHERE revocation.user_id = ? AND revocation.idempotency_key = ? +`; +const PENDING_INSERT_QUERY = ` + INSERT INTO pending_device_revocations ( + user_id, idempotency_key, audit_event_id, target_device_id, + approver_device_id, request_hash, created_at, completed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, NULL) + ON CONFLICT(user_id, idempotency_key) DO NOTHING +`; +const PENDING_FINALIZE_QUERY = ` + UPDATE pending_device_revocations + SET completed_at = ? + WHERE user_id = ? AND idempotency_key = ? AND completed_at IS NULL + AND target_device_id = ? AND approver_device_id = ? AND request_hash = ? +`; + +interface PendingResultRow { + target_device_id: unknown; + approver_device_id: unknown; + request_hash: unknown; + completed_at: unknown; + target_status: unknown; + revoked_at: unknown; + active_session_count: unknown; + audit_count: unknown; +} + +export async function revokePendingDeviceDocument( + env: Env, + context: AuthContext, + approverDeviceId: string, + revocation: PendingDeviceRevocationRequest, + requestHash: string, + nowSeconds: number, +): Promise { + const existing = await pendingResult(env, context.userId, revocation.idempotencyKey); + if (existing !== null) { + return completedPendingDocument( + env, + context.userId, + approverDeviceId, + revocation, + requestHash, + existing, + ); + } + const target = await deviceRowById(env, context.userId, revocation.deviceId); + if (target === null) { + throw new DevicePermissionError("device_not_found"); + } + const targetDocument = deviceDocument(target, approverDeviceId); + if (targetDocument.approval_status !== "pending" || targetDocument.revoked_at !== null) { + throw new DeviceConflictError("pending_device_revocation_target_invalid"); + } + let results: ElyD1Result[]; + try { + results = await env.ELY_DB.batch([ + env.ELY_DB.prepare(PENDING_INSERT_QUERY).bind( + context.userId, + revocation.idempotencyKey, + `pending-device-revoke:${requestHash}`, + revocation.deviceId, + approverDeviceId, + requestHash, + nowSeconds, + ), + env.ELY_DB.prepare(PENDING_FINALIZE_QUERY).bind( + nowSeconds, + context.userId, + revocation.idempotencyKey, + revocation.deviceId, + approverDeviceId, + requestHash, + ), + ]); + } catch (error) { + if (pendingConflict(error)) { + throw new DeviceConflictError("pending_device_revocation_race"); + } + throw error; + } + const finalizeChanges = changedRowCount(results.at(-1)); + if (finalizeChanges > 1) { + throw new DevicePersistenceError("pending_device_revocation_write_count_invalid"); + } + const completed = await pendingResult(env, context.userId, revocation.idempotencyKey); + if (completed === null) { + throw new DeviceConflictError("pending_device_revocation_race"); + } + try { + return await completedPendingDocument( + env, + context.userId, + approverDeviceId, + revocation, + requestHash, + completed, + ); + } catch (error) { + if (finalizeChanges === 0 && error instanceof DeviceConflictError) { + throw new DeviceConflictError("pending_device_revocation_race"); + } + throw error; + } +} + +async function completedPendingDocument( + env: Env, + userId: string, + approverDeviceId: string, + revocation: PendingDeviceRevocationRequest, + requestHash: string, + result: PendingResultRow, +): Promise { + if ( + result.target_device_id !== revocation.deviceId || + result.approver_device_id !== approverDeviceId || + result.request_hash !== requestHash + ) { + throw new DeviceConflictError("pending_device_revocation_replay_mismatch"); + } + const completedAt = storedInteger(result.completed_at, "completed_at"); + if ( + result.target_status !== "revoked" || + result.revoked_at !== completedAt || + result.active_session_count !== 0 || + result.audit_count !== 1 + ) { + throw new DevicePersistenceError("pending_device_revocation_result_invalid"); + } + const row = await deviceRowById(env, userId, revocation.deviceId); + if (row === null) { + throw new DevicePersistenceError("pending_device_revocation_missing"); + } + const device = deviceDocument(row, approverDeviceId); + if (device.approval_status !== "revoked" || device.revoked_at !== completedAt) { + throw new DevicePersistenceError("pending_device_revocation_state_invalid"); + } + return { + version: 2, + mode: "pending_revoke", + user_id: userId, + revoked_by_device_id: approverDeviceId, + revoked_at: completedAt, + device, + }; +} + +function pendingResult( + env: Env, + userId: string, + idempotencyKey: string, +): Promise { + return env.ELY_DB.prepare(PENDING_RESULT_QUERY) + .bind(userId, idempotencyKey) + .first(); +} + +function deviceRowById(env: Env, userId: string, deviceId: string): Promise { + return env.ELY_DB.prepare(DEVICE_BY_ID_QUERY).bind(userId, deviceId).first(); +} + +function storedInteger(value: unknown, label: string): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) { + throw new DevicePersistenceError(`${label}_invalid`); + } + return value; +} + +function changedRowCount(result: ElyD1Result | undefined): number { + const changes = result?.meta?.changes; + if (typeof changes !== "number" || !Number.isSafeInteger(changes) || changes < 0) { + throw new DevicePersistenceError("pending_device_revocation_write_result_invalid"); + } + return changes; +} + +function pendingConflict(error: unknown): boolean { + return error instanceof Error && ( + error.message.includes("pending_device_revocation_guard_failed") || + error.message.includes("FOREIGN KEY constraint failed") + ); +} diff --git a/cloudflare/src/recent_device_action_proof.ts b/cloudflare/src/recent_device_action_proof.ts new file mode 100644 index 0000000..2b7d376 --- /dev/null +++ b/cloudflare/src/recent_device_action_proof.ts @@ -0,0 +1,154 @@ +import type { AuthContext } from "./auth.js"; +import type { ElyD1DatabaseSession } from "./bindings.js"; +import { verifyEd25519Signature } from "./device_crypto.js"; + +const ACTION_PROOF_DOMAIN = "elydora-sensitive-action-v2"; +const PROOF_MAX_AGE_SECONDS = 5 * 60; +const PROOF_CLOCK_SKEW_SECONDS = 30; +const PUBLIC_KEY_PATTERN = /^[a-f0-9]{64}$/; +const SIGNATURE_PATTERN = /^[a-f0-9]{128}$/; + +const APPROVED_DEVICE_SIGNING_KEY_QUERY = ` + SELECT keys.signing_public_key + FROM user_devices AS device + INNER JOIN user_device_keys AS keys + ON keys.user_id = device.user_id AND keys.device_id = device.device_id + WHERE device.user_id = ? AND device.device_id = ? + AND device.approval_status = 'approved' AND device.revoked_at IS NULL + AND keys.key_protocol_version = 2 AND keys.wrapping_public_key IS NOT NULL +`; + +export type SensitiveAction = "account.delete" | "sync.reset"; + +export interface RecentDeviceActionProof { + proofCreatedAt: number; + actionProof: string; +} + +export interface RecentDeviceActionProofFields extends RecentDeviceActionProof { + action: SensitiveAction; + userId: string; + sessionId: string; + deviceId: string; + confirmation: string; + idempotencyKey: string; +} + +interface SigningKeyRow { signing_public_key: unknown } + +export class RecentDeviceActionRequestError extends Error {} +export class RecentDeviceActionPermissionError extends Error {} +export class RecentDeviceActionPersistenceError extends Error {} + +export function recentDeviceActionProof( + proofCreatedAt: unknown, + actionProof: unknown, +): RecentDeviceActionProof { + if ( + typeof proofCreatedAt !== "number" || + !Number.isSafeInteger(proofCreatedAt) || + proofCreatedAt < 1 + ) { + throw new RecentDeviceActionRequestError("proof_created_at_invalid"); + } + if (typeof actionProof !== "string" || !SIGNATURE_PATTERN.test(actionProof)) { + throw new RecentDeviceActionRequestError("action_proof_invalid"); + } + return { proofCreatedAt, actionProof }; +} + +export async function assertRecentDeviceActionProof( + database: ElyD1DatabaseSession, + context: AuthContext, + action: SensitiveAction, + confirmation: string, + idempotencyKey: string, + proof: RecentDeviceActionProof, +): Promise { + if (context.deviceId === undefined) { + throw new RecentDeviceActionPermissionError("device_context_required"); + } + const row = await database.prepare(APPROVED_DEVICE_SIGNING_KEY_QUERY) + .bind(context.userId, context.deviceId) + .first(); + if (row === null) { + throw new RecentDeviceActionPermissionError("device_action_forbidden"); + } + if ( + typeof row.signing_public_key !== "string" || + !PUBLIC_KEY_PATTERN.test(row.signing_public_key) + ) { + throw new RecentDeviceActionPersistenceError("device_signing_key_invalid"); + } + const fields: RecentDeviceActionProofFields = { + action, + userId: context.userId, + sessionId: context.sessionId, + deviceId: context.deviceId, + confirmation, + idempotencyKey, + ...proof, + }; + if (!(await verifyEd25519Signature( + row.signing_public_key, + proof.actionProof, + recentDeviceActionProofBytes(fields), + ))) { + throw new RecentDeviceActionPermissionError("device_action_proof_invalid"); + } + return row.signing_public_key; +} + +export function assertFreshDeviceActionProof( + proof: RecentDeviceActionProof, + nowSeconds: number, + freshnessRequired: boolean, +): void { + if (freshnessRequired && ( + proof.proofCreatedAt < nowSeconds - PROOF_MAX_AGE_SECONDS || + proof.proofCreatedAt > nowSeconds + PROOF_CLOCK_SKEW_SECONDS + )) { + throw new RecentDeviceActionPermissionError("device_action_proof_expired"); + } +} + +export function recentDeviceActionProofBytes( + fields: Omit, +): Uint8Array { + return canonicalBytes(deviceActionProofValues(fields)); +} + +export async function recentDeviceActionRequestHash( + fields: RecentDeviceActionProofFields, +): Promise { + const digest = await crypto.subtle.digest( + "SHA-256", + canonicalBytes([...deviceActionProofValues(fields), fields.actionProof]), + ); + return [...new Uint8Array(digest)] + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +} + +function deviceActionProofValues( + fields: Omit, +): (number | string)[] { + return [ + ACTION_PROOF_DOMAIN, + fields.action, + fields.userId, + fields.sessionId, + fields.deviceId, + fields.confirmation, + fields.idempotencyKey, + fields.proofCreatedAt, + ]; +} + +function canonicalBytes(values: (number | string)[]): Uint8Array { + const encoder = new TextEncoder(); + return encoder.encode(values.map((value) => { + const text = value.toString(); + return `${encoder.encode(text).byteLength}:${text}`; + }).join("")); +} diff --git a/cloudflare/src/storage.ts b/cloudflare/src/storage.ts index 6c885bb..d5449d4 100644 --- a/cloudflare/src/storage.ts +++ b/cloudflare/src/storage.ts @@ -54,11 +54,19 @@ export function syncSnapshotKey(params: { region: string; userHash: string; snapshotId: string; + payloadHash: string; }): string { assertRegion(params.region); assertSha256Hex(params.userHash, "user_hash"); assertSegment(params.snapshotId, "snapshot_id"); - return ["sync-snapshots", params.region, params.userHash, `${params.snapshotId}.bin`].join("/"); + assertSha256Hex(params.payloadHash, "payload_hash"); + return [ + "sync-snapshots", + params.region, + params.userHash, + params.snapshotId, + `${params.payloadHash}.bin`, + ].join("/"); } export function pluginPackageKey(params: { pluginId: string; packageHash: string }): string { @@ -101,9 +109,7 @@ export async function putVerifiedObject( expectedSha256: string, contentType: string, ): Promise { - assertKnownObjectKey(key); - assertSha256Hex(expectedSha256, "sha256"); - assertKeyChecksum(key, expectedSha256); + assertKnownObjectKeyHash(key, expectedSha256); const actualSha256 = await sha256Hex(payload); if (actualSha256 !== expectedSha256) { throw new StorageObjectError("r2_checksum_mismatch"); @@ -122,9 +128,7 @@ export async function getVerifiedObject( key: string, expectedSha256: string, ): Promise { - assertKnownObjectKey(key); - assertSha256Hex(expectedSha256, "sha256"); - assertKeyChecksum(key, expectedSha256); + assertKnownObjectKeyHash(key, expectedSha256); const object = await bucket.get(key); if (object === null) { return null; @@ -143,10 +147,11 @@ export async function deleteKnownObject(bucket: ElyR2Bucket, key: string): Promi await bucket.delete(key); } -function assertKnownObjectKey(key: string): void { +export function assertKnownObjectKey(key: string): void { const matches = [ /^sync-payloads\/[a-z0-9][a-z0-9-]{1,31}\/[a-f0-9]{64}\/[a-z0-9][a-z0-9._-]{0,127}\/[a-z0-9][a-z0-9._-]{0,127}\/[a-f0-9]{64}\.bin$/, /^sync-snapshots\/[a-z0-9][a-z0-9-]{1,31}\/[a-f0-9]{64}\/[a-z0-9][a-z0-9._-]{0,127}\.bin$/, + /^sync-snapshots\/[a-z0-9][a-z0-9-]{1,31}\/[a-f0-9]{64}\/[a-z0-9][a-z0-9._-]{0,127}\/[a-f0-9]{64}\.bin$/, /^plugin-packages\/[a-z0-9][a-z0-9._-]{0,127}\/[a-f0-9]{64}\.rplug$/, /^plugin-assets\/[a-z0-9][a-z0-9._-]{0,127}\/[a-f0-9]{64}$/, /^user-avatars\/[a-f0-9]{64}\/[a-f0-9]{64}$/, @@ -161,6 +166,12 @@ function assertKnownObjectKey(key: string): void { } } +export function assertKnownObjectKeyHash(key: string, expectedSha256: string): void { + assertKnownObjectKey(key); + assertSha256Hex(expectedSha256, "sha256"); + assertKeyChecksum(key, expectedSha256); +} + function assertKeyChecksum(key: string, expectedSha256: string): void { const keyChecksum = checksumFromKey(key); if (keyChecksum !== null && keyChecksum !== expectedSha256) { @@ -176,7 +187,7 @@ function checksumFromKey(key: string): string | null { return null; } - if (prefix === "sync-payloads") { + if (prefix === "sync-payloads" || (prefix === "sync-snapshots" && segments.length === 5)) { return lastSegment.slice(0, -".bin".length); } if (prefix === "plugin-packages") { diff --git a/cloudflare/src/sync_pull.ts b/cloudflare/src/sync_pull.ts deleted file mode 100644 index b09f46a..0000000 --- a/cloudflare/src/sync_pull.ts +++ /dev/null @@ -1,201 +0,0 @@ -import type { AuthContext } from "./auth.js"; -import type { Env } from "./bindings.js"; - -const DEFAULT_SYNC_PULL_LIMIT = 100; -const MAX_SYNC_PULL_LIMIT = 500; -const SYNC_OBJECT_ID_PATTERN = /^[a-zA-Z0-9._:-]{1,128}$/; -const SYNC_OBJECT_TYPE_PATTERN = /^[a-z0-9][a-z0-9._:-]{0,127}$/; -const SHA256_HEX = /^[a-f0-9]{64}$/; - -const SYNC_CHANGE_LOG_QUERY = ` - SELECT - change_id, - object_id, - object_type, - operation, - payload_hash, - logical_clock, - device_id, - created_at - FROM sync_change_log - WHERE user_id = ? AND change_id > ? - ORDER BY change_id ASC - LIMIT ? -`; - -export interface SyncPullDocument { - version: 1; - user_id: string; - device_id: string; - cursor: number; - next_cursor: number; - has_more: boolean; - changes: SyncChangeDocument[]; -} - -export interface SyncChangeDocument { - change_id: number; - object_id: string; - object_type: string; - operation: "upsert" | "delete"; - payload_hash: string; - logical_clock: number; - device_id: string; - created_at: number; -} - -interface SyncChangeRow { - change_id: unknown; - object_id: unknown; - object_type: unknown; - operation: unknown; - payload_hash: unknown; - logical_clock: unknown; - device_id: unknown; - created_at: unknown; -} - -interface SyncPullQuery { - cursor: number; - limit: number; -} - -export class SyncSchemaError extends Error { - constructor(message: string) { - super(message); - this.name = "SyncSchemaError"; - } -} - -export class SyncRequestError extends Error { - constructor(message: string) { - super(message); - this.name = "SyncRequestError"; - } -} - -export async function syncPullDocument( - url: URL, - env: Env, - context: AuthContext, -): Promise { - const deviceId = currentDeviceId(context); - const query = syncPullQuery(url); - const result = await env.ELY_DB.prepare(SYNC_CHANGE_LOG_QUERY) - .bind(context.userId, query.cursor, query.limit + 1) - .all(); - const rows = result.results.slice(0, query.limit); - const changes = rows.map(syncChangeDocument); - return { - version: 1, - user_id: context.userId, - device_id: deviceId, - cursor: query.cursor, - next_cursor: changes.at(-1)?.change_id ?? query.cursor, - has_more: result.results.length > query.limit, - changes, - }; -} - -function syncPullQuery(url: URL): SyncPullQuery { - assertOnlyQueryParams(url, ["cursor", "limit"]); - const cursor = requiredQueryInteger(url, "cursor", 0, Number.MAX_SAFE_INTEGER); - const limit = optionalQueryInteger(url, "limit", 1, MAX_SYNC_PULL_LIMIT) ?? DEFAULT_SYNC_PULL_LIMIT; - return { cursor, limit }; -} - -function syncChangeDocument(row: SyncChangeRow): SyncChangeDocument { - return { - change_id: integerValue(row.change_id, "change_id", 0, Number.MAX_SAFE_INTEGER), - object_id: objectId(row.object_id), - object_type: objectType(row.object_type), - operation: operation(row.operation), - payload_hash: payloadHash(row.payload_hash), - logical_clock: integerValue(row.logical_clock, "logical_clock", 0, Number.MAX_SAFE_INTEGER), - device_id: objectId(row.device_id), - created_at: integerValue(row.created_at, "created_at", 0, Number.MAX_SAFE_INTEGER), - }; -} - -function currentDeviceId(context: AuthContext): string { - if (context.deviceId === undefined) { - throw new SyncSchemaError("device_context_required"); - } - return context.deviceId; -} - -function assertOnlyQueryParams(url: URL, fields: string[]): void { - const allowed = new Set(fields); - for (const field of url.searchParams.keys()) { - if (!allowed.has(field)) { - throw new SyncRequestError(`unexpected_query:${field}`); - } - } -} - -function requiredQueryInteger(url: URL, field: string, min: number, max: number): number { - const value = url.searchParams.get(field); - if (value === null) { - throw new SyncRequestError(`${field}_required`); - } - return queryInteger(value, field, min, max); -} - -function optionalQueryInteger( - url: URL, - field: string, - min: number, - max: number, -): number | undefined { - const value = url.searchParams.get(field); - if (value === null) { - return undefined; - } - return queryInteger(value, field, min, max); -} - -function queryInteger(value: string, field: string, min: number, max: number): number { - if (!/^[0-9]+$/.test(value)) { - throw new SyncRequestError(`${field}_invalid`); - } - const parsed = Number(value); - if (!Number.isSafeInteger(parsed) || parsed < min || parsed > max) { - throw new SyncRequestError(`${field}_invalid`); - } - return parsed; -} - -function integerValue(value: unknown, field: string, min: number, max: number): number { - if (typeof value !== "number" || !Number.isSafeInteger(value) || value < min || value > max) { - throw new SyncSchemaError(`${field}_invalid`); - } - return value; -} - -function objectId(value: unknown): string { - if (typeof value !== "string" || !SYNC_OBJECT_ID_PATTERN.test(value)) { - throw new SyncSchemaError("object_id_invalid"); - } - return value; -} - -function objectType(value: unknown): string { - if (typeof value !== "string" || !SYNC_OBJECT_TYPE_PATTERN.test(value)) { - throw new SyncSchemaError("object_type_invalid"); - } - return value; -} - -function operation(value: unknown): SyncChangeDocument["operation"] { - if (value !== "upsert" && value !== "delete") { - throw new SyncSchemaError("operation_invalid"); - } - return value; -} - -function payloadHash(value: unknown): string { - if (typeof value !== "string" || !SHA256_HEX.test(value)) { - throw new SyncSchemaError("payload_hash_invalid"); - } - return value; -} diff --git a/cloudflare/src/sync_push.ts b/cloudflare/src/sync_push.ts deleted file mode 100644 index c6a73f0..0000000 --- a/cloudflare/src/sync_push.ts +++ /dev/null @@ -1,281 +0,0 @@ -import type { AuthContext } from "./auth.js"; -import type { Env } from "./bindings.js"; -import type { ElyD1PreparedStatement } from "./bindings.js"; -import { StorageObjectError, putVerifiedObject } from "./storage.js"; -import { - type SyncObjectRow, - SyncPushConflictError, - type SyncPushDocument, - SyncPushPersistenceError, - type SyncPushRequest, - SyncPushRequestError, - type SyncPushedObjectDocument, - currentDeviceId, - syncObjectDocument, - syncPushRequest, -} from "./sync_push_schema.js"; - -export { - SyncPushConflictError, - SyncPushPersistenceError, - SyncPushRequestError, -} from "./sync_push_schema.js"; - -const SYNC_OBJECT_BY_ID_QUERY = ` - SELECT - object_id, - object_type, - payload_r2_key, - payload_hash, - schema_rev, - logical_clock, - device_id, - created_at, - updated_at, - deleted_at - FROM sync_objects - WHERE user_id = ? AND object_id = ? -`; -const SYNC_OBJECT_UPSERT_QUERY = ` - INSERT INTO sync_objects ( - user_id, - object_id, - object_type, - payload_inline, - payload_r2_key, - payload_hash, - schema_rev, - logical_clock, - device_id, - created_at, - updated_at, - deleted_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(user_id, object_id) DO UPDATE SET - object_type = excluded.object_type, - payload_inline = excluded.payload_inline, - payload_r2_key = excluded.payload_r2_key, - payload_hash = excluded.payload_hash, - schema_rev = excluded.schema_rev, - logical_clock = excluded.logical_clock, - device_id = excluded.device_id, - updated_at = excluded.updated_at, - deleted_at = excluded.deleted_at - WHERE excluded.logical_clock > sync_objects.logical_clock - OR ( - excluded.logical_clock = sync_objects.logical_clock - AND sync_objects.object_type = excluded.object_type - AND ( - (sync_objects.payload_r2_key IS NULL AND excluded.payload_r2_key IS NULL) - OR sync_objects.payload_r2_key = excluded.payload_r2_key - ) - AND sync_objects.payload_hash = excluded.payload_hash - AND sync_objects.schema_rev = excluded.schema_rev - AND sync_objects.device_id = excluded.device_id - AND ( - (sync_objects.deleted_at IS NULL AND excluded.deleted_at IS NULL) - OR (sync_objects.deleted_at IS NOT NULL AND excluded.deleted_at IS NOT NULL) - ) - ) -`; -const SYNC_CHANGE_INSERT_QUERY = ` - INSERT INTO sync_change_log ( - user_id, - object_id, - object_type, - operation, - payload_hash, - logical_clock, - device_id, - created_at - ) - SELECT ?, ?, ?, ?, ?, ?, ?, ? - WHERE EXISTS ( - SELECT 1 - FROM sync_objects - WHERE user_id = ? - AND object_id = ? - AND object_type = ? - AND payload_hash = ? - AND logical_clock = ? - AND device_id = ? - AND ((? = 1 AND deleted_at IS NOT NULL) OR (? = 0 AND deleted_at IS NULL)) - ) - ON CONFLICT(user_id, object_id, logical_clock, device_id, operation) DO NOTHING -`; -const SYNC_TOMBSTONE_UPSERT_QUERY = ` - INSERT INTO sync_tombstones ( - user_id, - object_id, - object_type, - logical_clock, - device_id, - deleted_at - ) - SELECT user_id, object_id, object_type, logical_clock, device_id, deleted_at - FROM sync_objects - WHERE user_id = ? AND object_id = ? AND logical_clock = ? AND deleted_at IS NOT NULL - ON CONFLICT(user_id, object_id) DO UPDATE SET - object_type = excluded.object_type, - logical_clock = excluded.logical_clock, - device_id = excluded.device_id, - deleted_at = excluded.deleted_at - WHERE excluded.logical_clock >= sync_tombstones.logical_clock -`; - -export async function syncPushDocument( - request: Request, - env: Env, - context: AuthContext, - nowSeconds = Math.floor(Date.now() / 1000), -): Promise { - const deviceId = currentDeviceId(context); - const push = await syncPushRequest(request, context.userId); - const existingRow = await env.ELY_DB.prepare(SYNC_OBJECT_BY_ID_QUERY) - .bind(context.userId, push.objectId) - .first(); - if (existingRow !== null) { - assertPushCanReplaceExisting(push, deviceId, syncObjectDocument(existingRow)); - } - - await persistR2PayloadIfNeeded(env, push); - await env.ELY_DB.batch(syncPushStatements(env, context.userId, deviceId, push, nowSeconds)); - - const savedRow = await env.ELY_DB.prepare(SYNC_OBJECT_BY_ID_QUERY) - .bind(context.userId, push.objectId) - .first(); - if (savedRow === null) { - throw new SyncPushPersistenceError("sync_object_missing"); - } - const object = syncObjectDocument(savedRow); - assertSavedObjectMatchesPush(push, deviceId, object); - - return { version: 1, user_id: context.userId, device_id: deviceId, object }; -} - -async function persistR2PayloadIfNeeded(env: Env, push: SyncPushRequest): Promise { - if (push.payload.kind !== "r2") { - return; - } - try { - await putVerifiedObject( - env.ELY_STORAGE, - push.payload.r2Key, - push.payload.bytes, - push.payloadHash, - "application/octet-stream", - ); - } catch (error) { - if (error instanceof StorageObjectError) { - throw new SyncPushRequestError(error.message); - } - throw error; - } -} - -function syncPushStatements( - env: Env, - userId: string, - deviceId: string, - push: SyncPushRequest, - nowSeconds: number, -): ElyD1PreparedStatement[] { - const deletedAt = push.operation === "delete" ? nowSeconds : null; - const isDelete = push.operation === "delete" ? 1 : 0; - const statements = [ - env.ELY_DB.prepare(SYNC_OBJECT_UPSERT_QUERY).bind( - userId, - push.objectId, - push.objectType, - push.payload.kind === "inline" ? push.payload.bytes : null, - push.payload.r2Key, - push.payloadHash, - push.schemaRev, - push.logicalClock, - deviceId, - nowSeconds, - nowSeconds, - deletedAt, - ), - env.ELY_DB.prepare(SYNC_CHANGE_INSERT_QUERY).bind( - userId, - push.objectId, - push.objectType, - push.operation, - push.payloadHash, - push.logicalClock, - deviceId, - nowSeconds, - userId, - push.objectId, - push.objectType, - push.payloadHash, - push.logicalClock, - deviceId, - isDelete, - isDelete, - ), - ]; - if (push.operation === "delete") { - statements.push( - env.ELY_DB.prepare(SYNC_TOMBSTONE_UPSERT_QUERY).bind( - userId, - push.objectId, - push.logicalClock, - ), - ); - } - return statements; -} - -function assertPushCanReplaceExisting( - push: SyncPushRequest, - deviceId: string, - existing: SyncPushedObjectDocument, -): void { - if (existing.logical_clock > push.logicalClock) { - throw new SyncPushConflictError("logical_clock_stale"); - } - if (existing.logical_clock < push.logicalClock) { - return; - } - if ( - existing.operation !== push.operation || - existing.payload_hash !== push.payloadHash || - existing.device_id !== deviceId - ) { - throw new SyncPushConflictError("logical_clock_conflict"); - } -} - -function assertSavedObjectMatchesPush( - push: SyncPushRequest, - deviceId: string, - object: SyncPushedObjectDocument, -): void { - if (object.logical_clock > push.logicalClock) { - throw new SyncPushConflictError("logical_clock_stale"); - } - if ( - object.logical_clock === push.logicalClock && - (object.object_type !== push.objectType || - object.operation !== push.operation || - object.payload_hash !== push.payloadHash || - object.schema_rev !== push.schemaRev || - object.device_id !== deviceId || - object.payload_r2_key !== push.payload.r2Key) - ) { - throw new SyncPushConflictError("logical_clock_conflict"); - } - if ( - object.object_id !== push.objectId || - object.object_type !== push.objectType || - object.operation !== push.operation || - object.payload_hash !== push.payloadHash || - object.schema_rev !== push.schemaRev || - object.logical_clock !== push.logicalClock || - object.device_id !== deviceId - ) { - throw new SyncPushPersistenceError("sync_object_mismatch"); - } -} diff --git a/cloudflare/src/sync_push_schema.ts b/cloudflare/src/sync_push_schema.ts deleted file mode 100644 index 4413091..0000000 --- a/cloudflare/src/sync_push_schema.ts +++ /dev/null @@ -1,348 +0,0 @@ -import type { AuthContext } from "./auth.js"; -import { - StorageObjectError, - assertSyncObjectType, - syncPayloadKey, -} from "./storage.js"; - -const MAX_INLINE_PAYLOAD_BYTES = 64 * 1024; -const MAX_R2_PAYLOAD_BYTES = 10 * 1024 * 1024; -const SYNC_OBJECT_ID_PATTERN = /^[a-zA-Z0-9._:-]{1,128}$/; -const SYNC_OBJECT_TYPE_PATTERN = /^[a-z0-9][a-z0-9._:-]{0,127}$/; -const SHA256_HEX = /^[a-f0-9]{64}$/; -const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; -const REGION = /^[a-z0-9][a-z0-9-]{1,31}$/; - -export interface SyncPushDocument { - version: 1; - user_id: string; - device_id: string; - object: SyncPushedObjectDocument; -} - -export interface SyncPushedObjectDocument { - object_id: string; - object_type: string; - operation: "upsert" | "delete"; - payload_hash: string; - schema_rev: number; - logical_clock: number; - device_id: string; - created_at: number; - updated_at: number; - deleted_at: number | null; - payload_storage: "inline" | "r2" | "tombstone"; - payload_r2_key: string | null; -} - -export interface SyncPushRequest { - objectId: string; - objectType: string; - operation: "upsert" | "delete"; - payloadHash: string; - schemaRev: number; - logicalClock: number; - payload: SyncPushPayload; -} - -export type SyncPushPayload = - | { kind: "inline"; bytes: ArrayBuffer; r2Key: null } - | { kind: "r2"; bytes: ArrayBuffer; region: string; r2Key: string } - | { kind: "tombstone"; bytes: null; r2Key: null }; - -export interface SyncObjectRow { - object_id: unknown; - object_type: unknown; - payload_r2_key: unknown; - payload_hash: unknown; - schema_rev: unknown; - logical_clock: unknown; - device_id: unknown; - created_at: unknown; - updated_at: unknown; - deleted_at: unknown; -} - -type RequestBody = Record; - -export class SyncPushRequestError extends Error { - constructor(message: string) { - super(message); - this.name = "SyncPushRequestError"; - } -} - -export class SyncPushConflictError extends Error { - constructor(message: string) { - super(message); - this.name = "SyncPushConflictError"; - } -} - -export class SyncPushPersistenceError extends Error { - constructor(message: string) { - super(message); - this.name = "SyncPushPersistenceError"; - } -} - -export function currentDeviceId(context: AuthContext): string { - if (context.deviceId === undefined) { - throw new SyncPushRequestError("device_context_required"); - } - return context.deviceId; -} - -export async function syncPushRequest( - request: Request, - userId: string, -): Promise { - const body = await requestBody(request); - assertOnlyFields(body, [ - "version", - "object_id", - "object_type", - "operation", - "payload_hash", - "schema_rev", - "logical_clock", - "payload", - ]); - if (body.version !== 1) { - throw new SyncPushRequestError("version_invalid"); - } - - const objectId = syncObjectId(body.object_id); - const operation = syncOperation(body.operation); - const objectType = syncObjectType(body.object_type); - const payloadHash = sha256HexValue(body.payload_hash, "payload_hash"); - const payload = - operation === "delete" - ? tombstonePayload(body.payload) - : await upsertPayload(body.payload, userId, objectType, objectId, payloadHash); - - return { - objectId, - objectType, - operation, - payloadHash, - schemaRev: integer(body.schema_rev, "schema_rev", 1, Number.MAX_SAFE_INTEGER), - logicalClock: integer(body.logical_clock, "logical_clock", 0, Number.MAX_SAFE_INTEGER), - payload, - }; -} - -export function syncObjectDocument(row: SyncObjectRow): SyncPushedObjectDocument { - const deletedAt = nullableInteger(row.deleted_at, "deleted_at", 0, Number.MAX_SAFE_INTEGER); - const payloadR2Key = nullableText(row.payload_r2_key, "payload_r2_key"); - return { - object_id: syncObjectId(row.object_id), - object_type: syncObjectType(row.object_type), - operation: deletedAt === null ? "upsert" : "delete", - payload_hash: sha256HexValue(row.payload_hash, "payload_hash"), - schema_rev: integer(row.schema_rev, "schema_rev", 1, Number.MAX_SAFE_INTEGER), - logical_clock: integer(row.logical_clock, "logical_clock", 0, Number.MAX_SAFE_INTEGER), - device_id: syncObjectId(row.device_id), - created_at: integer(row.created_at, "created_at", 0, Number.MAX_SAFE_INTEGER), - updated_at: integer(row.updated_at, "updated_at", 0, Number.MAX_SAFE_INTEGER), - deleted_at: deletedAt, - payload_storage: deletedAt !== null ? "tombstone" : payloadR2Key === null ? "inline" : "r2", - payload_r2_key: payloadR2Key, - }; -} - -async function upsertPayload( - value: unknown, - userId: string, - objectType: string, - objectId: string, - payloadHash: string, -): Promise { - const payload = record(value, "payload"); - const kind = text(payload.kind, "payload.kind"); - if (kind === "inline") { - assertOnlyFields(payload, ["kind", "data_base64"]); - const bytes = payloadBytes(payload.data_base64, "payload.data_base64", MAX_INLINE_PAYLOAD_BYTES); - await assertPayloadHash(bytes, payloadHash); - return { kind, bytes, r2Key: null }; - } - if (kind === "r2") { - assertOnlyFields(payload, ["kind", "region", "data_base64"]); - const region = regionValue(payload.region); - const bytes = payloadBytes(payload.data_base64, "payload.data_base64", MAX_R2_PAYLOAD_BYTES); - await assertPayloadHash(bytes, payloadHash); - return { - kind, - bytes, - region, - r2Key: await syncPayloadStorageKey(region, userId, objectType, objectId, payloadHash), - }; - } - throw new SyncPushRequestError("payload.kind_invalid"); -} - -async function syncPayloadStorageKey( - region: string, - userId: string, - objectType: string, - objectId: string, - payloadHash: string, -): Promise { - try { - return syncPayloadKey({ - region, - userHash: await sha256Hex(arrayBufferFromBytes(new TextEncoder().encode(userId))), - objectType, - objectId, - payloadHash, - }); - } catch (error) { - if (error instanceof StorageObjectError) { - throw new SyncPushRequestError(error.message); - } - throw error; - } -} - -function tombstonePayload(value: unknown): SyncPushPayload { - if (value !== undefined) { - throw new SyncPushRequestError("payload_forbidden"); - } - return { kind: "tombstone", bytes: null, r2Key: null }; -} - -async function requestBody(request: Request): Promise { - let value: unknown; - try { - value = await request.json(); - } catch { - throw new SyncPushRequestError("json_invalid"); - } - return record(value, "body"); -} - -function record(value: unknown, label: string): RequestBody { - if (typeof value !== "object" || value === null || Array.isArray(value)) { - throw new SyncPushRequestError(`${label}_invalid`); - } - return value as RequestBody; -} - -function assertOnlyFields(value: RequestBody, fields: string[]): void { - const allowed = new Set(fields); - for (const field of Object.keys(value)) { - if (!allowed.has(field)) { - throw new SyncPushRequestError(`unexpected_field:${field}`); - } - } -} - -function syncOperation(value: unknown): SyncPushRequest["operation"] { - if (value !== "upsert" && value !== "delete") { - throw new SyncPushRequestError("operation_invalid"); - } - return value; -} - -function syncObjectId(value: unknown): string { - if (typeof value !== "string" || !SYNC_OBJECT_ID_PATTERN.test(value)) { - throw new SyncPushRequestError("object_id_invalid"); - } - return value; -} - -function syncObjectType(value: unknown): string { - if (typeof value !== "string" || !SYNC_OBJECT_TYPE_PATTERN.test(value)) { - throw new SyncPushRequestError("object_type_invalid"); - } - try { - assertSyncObjectType(value); - } catch (error) { - if (error instanceof StorageObjectError) { - throw new SyncPushRequestError(error.message); - } - throw error; - } - return value; -} - -function sha256HexValue(value: unknown, label: string): string { - if (typeof value !== "string" || !SHA256_HEX.test(value)) { - throw new SyncPushRequestError(`${label}_invalid`); - } - return value; -} - -function integer(value: unknown, label: string, min: number, max: number): number { - if (typeof value !== "number" || !Number.isSafeInteger(value) || value < min || value > max) { - throw new SyncPushRequestError(`${label}_invalid`); - } - return value; -} - -function nullableInteger(value: unknown, label: string, min: number, max: number): number | null { - if (value === null) { - return null; - } - return integer(value, label, min, max); -} - -function text(value: unknown, label: string): string { - if (typeof value !== "string" || value.length === 0) { - throw new SyncPushRequestError(`${label}_invalid`); - } - return value; -} - -function nullableText(value: unknown, label: string): string | null { - if (value === null) { - return null; - } - return text(value, label); -} - -function regionValue(value: unknown): string { - if (typeof value !== "string" || !REGION.test(value)) { - throw new SyncPushRequestError("payload.region_invalid"); - } - return value; -} - -function payloadBytes(value: unknown, label: string, maxBytes: number): ArrayBuffer { - const encoded = text(value, label); - if (!BASE64.test(encoded)) { - throw new SyncPushRequestError(`${label}_invalid`); - } - const bytes = bytesFromBase64(encoded); - if (bytes.byteLength === 0 || bytes.byteLength > maxBytes) { - throw new SyncPushRequestError(`${label}_size_invalid`); - } - return bytes; -} - -function bytesFromBase64(value: string): ArrayBuffer { - const binary = atob(value); - const bytes = new Uint8Array(binary.length); - for (let index = 0; index < binary.length; index += 1) { - bytes[index] = binary.charCodeAt(index); - } - return bytes.buffer; -} - -function arrayBufferFromBytes(bytes: Uint8Array): ArrayBuffer { - const copy = new Uint8Array(bytes.byteLength); - copy.set(bytes); - return copy.buffer; -} - -async function assertPayloadHash(payload: ArrayBuffer, expectedHash: string): Promise { - const actualHash = await sha256Hex(payload); - if (actualHash !== expectedHash) { - throw new SyncPushRequestError("payload_hash_mismatch"); - } -} - -async function sha256Hex(payload: ArrayBuffer): Promise { - const digest = await crypto.subtle.digest("SHA-256", payload); - return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); -} diff --git a/cloudflare/src/sync_r2_gc.ts b/cloudflare/src/sync_r2_gc.ts new file mode 100644 index 0000000..88cdade --- /dev/null +++ b/cloudflare/src/sync_r2_gc.ts @@ -0,0 +1,342 @@ +import type { + ElyD1DatabaseSession, + ElyD1PreparedStatement, + ElyD1Result, + Env, +} from "./bindings.js"; +import { primaryD1Session } from "./bindings.js"; +import { StorageObjectError, assertKnownObjectKey, deleteKnownObject } from "./storage.js"; + +const WRITE_LEASE_SECONDS = 10 * 60; +const DELETE_RETRY_SECONDS = 60; +const DEFAULT_GC_LIMIT = 25; + +const CLAIM_NEW_SNAPSHOT_QUERY = ` + WITH write ( + user_id, device_id, r2_key, owner_hash, key_id, generation, + head_revision, base_revision, base_snapshot_id, base_payload_hash, + write_token, now_seconds, lease_expires_at + ) AS (VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)) + INSERT INTO sync_r2_gc_candidates ( + r2_key, user_id, owner_hash, object_kind, state, write_token, + lease_expires_at, gc_token, created_at, updated_at, referenced_at, + ready_at, delete_started_at, deleted_at + ) + SELECT + write.r2_key, write.user_id, write.owner_hash, 'snapshot', 'pending', + write.write_token, write.lease_expires_at, NULL, write.now_seconds, + write.now_seconds, NULL, NULL, NULL, NULL + FROM write + WHERE EXISTS ( + SELECT 1 + FROM sync_vault_accounts AS account + INNER JOIN user_devices AS device + ON device.user_id = account.user_id + AND device.device_id = write.device_id + AND device.approval_status = 'approved' + AND device.revoked_at IS NULL + INNER JOIN user_device_keys AS keys + ON keys.user_id = device.user_id + AND keys.device_id = device.device_id + AND keys.key_protocol_version = 2 + AND keys.wrapping_public_key IS NOT NULL + WHERE account.user_id = write.user_id + AND account.current_key_id = write.key_id + AND account.current_generation = write.generation + ) + AND ( + ( + write.head_revision = 1 + AND write.base_revision IS NULL + AND write.base_snapshot_id IS NULL + AND write.base_payload_hash IS NULL + AND NOT EXISTS ( + SELECT 1 FROM sync_snapshot_heads AS head + WHERE head.user_id = write.user_id + ) + ) + OR + ( + write.base_revision IS NOT NULL + AND write.base_snapshot_id IS NOT NULL + AND write.base_payload_hash IS NOT NULL + AND write.head_revision = write.base_revision + 1 + AND EXISTS ( + SELECT 1 FROM sync_snapshot_heads AS head + WHERE head.user_id = write.user_id + AND head.head_revision = write.base_revision + AND head.snapshot_id = write.base_snapshot_id + AND head.payload_hash = write.base_payload_hash + ) + ) + ) + ON CONFLICT(r2_key) DO UPDATE SET + write_token = excluded.write_token, + lease_expires_at = excluded.lease_expires_at, + updated_at = excluded.updated_at + WHERE sync_r2_gc_candidates.user_id = excluded.user_id + AND sync_r2_gc_candidates.owner_hash = excluded.owner_hash + AND sync_r2_gc_candidates.object_kind = 'snapshot' + AND sync_r2_gc_candidates.state = 'pending' +`; + +export const SYNC_R2_MARK_REFERENCED_QUERY = ` + UPDATE sync_r2_gc_candidates + SET state = 'referenced', lease_expires_at = ?, updated_at = ?, referenced_at = ? + WHERE r2_key = ? AND user_id = ? + AND object_kind = 'snapshot' + AND state = 'pending' + AND write_token = ? + AND lease_expires_at >= ? +`; + +export const SYNC_R2_FENCE_USER_QUERY = ` + UPDATE sync_r2_gc_candidates + SET + state = 'ready', + lease_expires_at = CASE WHEN state = 'pending' THEN lease_expires_at ELSE ? END, + updated_at = MAX(updated_at, ?), + ready_at = COALESCE(ready_at, ?) + WHERE user_id = ? AND state IN ('pending', 'referenced') +`; + +export const SYNC_R2_ANONYMIZE_USER_QUERY = ` + UPDATE sync_r2_gc_candidates + SET user_id = NULL, updated_at = MAX(updated_at, ?) + WHERE user_id = ? AND owner_hash = ? +`; + +const ABANDON_WRITE_QUERY = ` + UPDATE sync_r2_gc_candidates AS candidate + SET + state = 'ready', + lease_expires_at = ?, + updated_at = MAX(updated_at, ?), + ready_at = COALESCE(ready_at, ?) + WHERE candidate.r2_key = ? + AND candidate.owner_hash = ? + AND (candidate.user_id = ? OR candidate.user_id IS NULL) + AND candidate.state IN ('pending', 'ready') + AND candidate.write_token = ? + AND NOT EXISTS (SELECT 1 FROM sync_objects WHERE payload_r2_key = candidate.r2_key) + AND NOT EXISTS (SELECT 1 FROM sync_snapshots WHERE r2_key = candidate.r2_key) +`; + +const CLAIMED_CANDIDATES_QUERY = ` + SELECT r2_key + FROM sync_r2_gc_candidates + WHERE state = 'deleting' AND gc_token = ? + ORDER BY r2_key ASC +`; + +const MARK_DELETED_QUERY = ` + UPDATE sync_r2_gc_candidates + SET state = 'deleted', updated_at = ?, deleted_at = ? + WHERE r2_key = ? AND state = 'deleting' AND gc_token = ? +`; + +interface SnapshotHeadRef { + revision: number; + snapshotId: string; + payloadHash: string; +} + +export interface SyncR2SnapshotWriteClaim { + userId: string; + deviceId: string; + r2Key: string; + ownerHash: string; + keyId: string; + generation: number; + headRevision: number; + baseHead: SnapshotHeadRef | null; +} + +export interface SyncR2WriteLease { + writeToken: string; + leaseExpiresAt: number; +} + +interface CandidateRow { r2_key: unknown } + +export class SyncR2GcError extends Error {} +export class SyncR2WriteFenceError extends Error {} + +export async function claimSyncR2SnapshotWrite( + env: Env, + claim: SyncR2SnapshotWriteClaim, + nowSeconds: number, + writeToken = randomToken(), + database: ElyD1DatabaseSession = primaryD1Session(env.ELY_DB), +): Promise { + const leaseExpiresAt = nowSeconds + WRITE_LEASE_SECONDS; + const result = await database.prepare(CLAIM_NEW_SNAPSHOT_QUERY).bind( + claim.userId, + claim.deviceId, + claim.r2Key, + claim.ownerHash, + claim.keyId, + claim.generation, + claim.headRevision, + claim.baseHead?.revision ?? null, + claim.baseHead?.snapshotId ?? null, + claim.baseHead?.payloadHash ?? null, + writeToken, + nowSeconds, + leaseExpiresAt, + ).run(); + if (changedRows(result) !== 1) { + throw new SyncR2WriteFenceError("sync_r2_write_fenced"); + } + return { writeToken, leaseExpiresAt }; +} + +export function syncR2MarkReferencedStatement( + database: ElyD1DatabaseSession, + userId: string, + r2Key: string, + lease: SyncR2WriteLease, + nowSeconds: number, +): ElyD1PreparedStatement { + return database.prepare(SYNC_R2_MARK_REFERENCED_QUERY).bind( + nowSeconds, + nowSeconds, + nowSeconds, + r2Key, + userId, + lease.writeToken, + nowSeconds, + ); +} + +export async function abandonSyncR2Write( + env: Env, + userId: string, + ownerHash: string, + r2Key: string, + writeToken: string, + nowSeconds: number, + database: ElyD1DatabaseSession = primaryD1Session(env.ELY_DB), +): Promise { + await database.prepare(ABANDON_WRITE_QUERY).bind( + nowSeconds, + nowSeconds, + nowSeconds, + r2Key, + ownerHash, + userId, + writeToken, + ).run(); +} + +export async function collectSyncR2Garbage( + env: Env, + nowSeconds: number, + options: { + userId?: string; + ownerHash?: string; + limit?: number; + database?: ElyD1DatabaseSession; + } = {}, +): Promise { + const database = options.database ?? primaryD1Session(env.ELY_DB); + const gcToken = randomToken(); + const retryBefore = Math.max(0, nowSeconds - DELETE_RETRY_SECONDS); + const scope = options.userId === undefined + ? options.ownerHash === undefined ? "global" : "owner" + : "user"; + const scopeValue = options.userId ?? options.ownerHash; + await database.prepare(claimGarbageQuery(scope)).bind( + gcToken, + nowSeconds, + nowSeconds, + nowSeconds, + nowSeconds, + retryBefore, + ...(scopeValue === undefined ? [] : [scopeValue]), + options.limit ?? DEFAULT_GC_LIMIT, + ).run(); + const claimed = await database.prepare(CLAIMED_CANDIDATES_QUERY) + .bind(gcToken) + .all(); + for (const row of claimed.results) { + const key = storedR2Key(row.r2_key); + await deleteKnownObject(env.ELY_STORAGE, key); + const result = await database.prepare(MARK_DELETED_QUERY) + .bind(nowSeconds, nowSeconds, key, gcToken) + .run(); + if (changedRows(result) !== 1) { + throw new SyncR2GcError("sync_r2_gc_finalize_failed"); + } + } + return claimed.results.length; +} + +function claimGarbageQuery(scope: "global" | "user" | "owner"): string { + const scopeSql = scope === "user" + ? "AND candidate.user_id = ?" + : scope === "owner" ? "AND candidate.owner_hash = ?" : ""; + return ` + UPDATE sync_r2_gc_candidates + SET + state = 'deleting', + gc_token = ?, + delete_started_at = ?, + updated_at = MAX(updated_at, ?), + ready_at = COALESCE(ready_at, ?) + WHERE r2_key IN ( + SELECT candidate.r2_key + FROM sync_r2_gc_candidates AS candidate + WHERE ( + (candidate.state IN ('pending', 'ready') AND candidate.lease_expires_at <= ?) + OR (candidate.state = 'deleting' AND candidate.delete_started_at <= ?) + ) + ${scopeSql} + AND NOT EXISTS (SELECT 1 FROM sync_objects WHERE payload_r2_key = candidate.r2_key) + AND NOT EXISTS (SELECT 1 FROM sync_snapshots WHERE r2_key = candidate.r2_key) + AND NOT EXISTS ( + SELECT 1 + FROM sync_snapshot_heads AS head + INNER JOIN sync_snapshots AS snapshot + ON snapshot.user_id = head.user_id + AND snapshot.snapshot_id = head.snapshot_id + AND snapshot.head_revision = head.head_revision + AND snapshot.payload_hash = head.payload_hash + WHERE snapshot.r2_key = candidate.r2_key + ) + AND NOT EXISTS ( + SELECT 1 + FROM sync_vault_rotation_r2_objects AS staged + INNER JOIN sync_vault_rotations AS rotation + ON rotation.user_id = staged.user_id + AND rotation.idempotency_key = staged.rotation_idempotency_key + WHERE staged.r2_key = candidate.r2_key + AND rotation.cleanup_started_at IS NULL + ) + ORDER BY candidate.updated_at ASC, candidate.r2_key ASC + LIMIT ? + ) + `; +} + +function storedR2Key(value: unknown): string { + if (typeof value !== "string") throw new SyncR2GcError("sync_r2_key_invalid"); + try { + assertKnownObjectKey(value); + } catch (error) { + if (error instanceof StorageObjectError) throw new SyncR2GcError(error.message); + throw error; + } + return value; +} + +function changedRows(result: unknown): number { + if (typeof result !== "object" || result === null || !("meta" in result)) return -1; + const changes = (result as ElyD1Result).meta?.changes; + return typeof changes === "number" && Number.isSafeInteger(changes) ? changes : -1; +} + +function randomToken(): string { + const bytes = crypto.getRandomValues(new Uint8Array(32)); + return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} diff --git a/cloudflare/src/sync_r2_inventory.ts b/cloudflare/src/sync_r2_inventory.ts new file mode 100644 index 0000000..3333580 --- /dev/null +++ b/cloudflare/src/sync_r2_inventory.ts @@ -0,0 +1,154 @@ +import type { Env } from "./bindings.js"; +import { primaryD1Session } from "./bindings.js"; +import { StorageObjectError, assertKnownObjectKey } from "./storage.js"; + +const INVENTORY_RESCAN_SECONDS = 24 * 60 * 60; +const DEFAULT_INVENTORY_LIMIT = 100; +const SHA256_HEX = /^[a-f0-9]{64}$/; + +const INVENTORY_CURSOR_QUERY = ` + SELECT prefix, cursor + FROM sync_r2_inventory_cursors + WHERE next_scan_at <= ? + ORDER BY next_scan_at ASC, updated_at ASC, prefix ASC + LIMIT 1 +`; + +const INVENTORY_CANDIDATE_QUERY = ` + INSERT INTO sync_r2_gc_candidates ( + r2_key, user_id, owner_hash, object_kind, state, write_token, + lease_expires_at, gc_token, created_at, updated_at, referenced_at, + ready_at, delete_started_at, deleted_at + ) + SELECT ?, NULL, ?, ?, 'ready', NULL, ?, NULL, ?, ?, NULL, ?, NULL, NULL + WHERE NOT EXISTS (SELECT 1 FROM sync_objects WHERE payload_r2_key = ?) + AND NOT EXISTS (SELECT 1 FROM sync_snapshots WHERE r2_key = ?) + AND NOT EXISTS ( + SELECT 1 + FROM sync_snapshot_heads AS head + INNER JOIN sync_snapshots AS snapshot + ON snapshot.user_id = head.user_id + AND snapshot.snapshot_id = head.snapshot_id + AND snapshot.head_revision = head.head_revision + AND snapshot.payload_hash = head.payload_hash + WHERE snapshot.r2_key = ? + ) + AND NOT EXISTS ( + SELECT 1 + FROM sync_vault_rotation_r2_objects AS staged + INNER JOIN sync_vault_rotations AS rotation + ON rotation.user_id = staged.user_id + AND rotation.idempotency_key = staged.rotation_idempotency_key + WHERE staged.r2_key = ? AND rotation.cleanup_started_at IS NULL + ) + ON CONFLICT(r2_key) DO UPDATE SET + state = 'ready', + lease_expires_at = excluded.lease_expires_at, + gc_token = NULL, + updated_at = excluded.updated_at, + ready_at = excluded.ready_at, + delete_started_at = NULL, + deleted_at = NULL + WHERE sync_r2_gc_candidates.state = 'deleted' +`; + +const INVENTORY_CURSOR_UPDATE_QUERY = ` + UPDATE sync_r2_inventory_cursors + SET cursor = ?, updated_at = ?, next_scan_at = ? + WHERE prefix = ? +`; + +interface InventoryCursorRow { prefix: unknown; cursor: unknown } +interface R2ListObject { key: string } +interface R2ListResult { + objects: R2ListObject[]; + truncated: boolean; + cursor?: string; +} +interface ListableBucket { + list(options: { prefix: string; cursor?: string; limit: number }): Promise; +} + +export class SyncR2InventoryError extends Error {} + +export async function inventorySyncR2Objects( + env: Env, + nowSeconds: number, + limit = DEFAULT_INVENTORY_LIMIT, +): Promise { + const database = primaryD1Session(env.ELY_DB); + const cursorRow = await database.prepare(INVENTORY_CURSOR_QUERY) + .bind(nowSeconds) + .first(); + if (cursorRow === null) return 0; + const prefix = inventoryPrefix(cursorRow.prefix); + const cursor = inventoryCursor(cursorRow.cursor); + const bucket = env.ELY_STORAGE as Env["ELY_STORAGE"] & Partial; + if (typeof bucket.list !== "function") { + throw new SyncR2InventoryError("sync_r2_inventory_unavailable"); + } + const result = await bucket.list({ prefix, ...(cursor === null ? {} : { cursor }), limit }); + const statements = result.objects.flatMap((object) => { + const candidate = inventoryCandidate(object.key, prefix); + if (candidate === null) return []; + return [database.prepare(INVENTORY_CANDIDATE_QUERY).bind( + candidate.r2Key, + candidate.ownerHash, + candidate.objectKind, + nowSeconds, + nowSeconds, + nowSeconds, + nowSeconds, + candidate.r2Key, + candidate.r2Key, + candidate.r2Key, + candidate.r2Key, + )]; + }); + const nextCursor = result.truncated ? result.cursor : null; + if (result.truncated && typeof nextCursor !== "string") { + throw new SyncR2InventoryError("sync_r2_inventory_cursor_invalid"); + } + statements.push(database.prepare(INVENTORY_CURSOR_UPDATE_QUERY).bind( + nextCursor, + nowSeconds, + result.truncated ? nowSeconds : nowSeconds + INVENTORY_RESCAN_SECONDS, + prefix, + )); + await database.batch(statements); + return statements.length - 1; +} + +function inventoryCandidate( + key: string, + prefix: string, +): { r2Key: string; ownerHash: string; objectKind: "payload" | "snapshot" } | null { + try { + assertKnownObjectKey(key); + } catch (error) { + if (error instanceof StorageObjectError) return null; + throw error; + } + if (!key.startsWith(prefix)) return null; + const ownerHash = key.split("/")[2] ?? ""; + if (!SHA256_HEX.test(ownerHash)) return null; + return { + r2Key: key, + ownerHash, + objectKind: prefix === "sync-payloads/" ? "payload" : "snapshot", + }; +} + +function inventoryPrefix(value: unknown): "sync-payloads/" | "sync-snapshots/" { + if (value !== "sync-payloads/" && value !== "sync-snapshots/") { + throw new SyncR2InventoryError("sync_r2_inventory_prefix_invalid"); + } + return value; +} + +function inventoryCursor(value: unknown): string | null { + if (value !== null && typeof value !== "string") { + throw new SyncR2InventoryError("sync_r2_inventory_cursor_invalid"); + } + return value; +} diff --git a/cloudflare/src/sync_r2_maintenance.ts b/cloudflare/src/sync_r2_maintenance.ts new file mode 100644 index 0000000..f8ec65b --- /dev/null +++ b/cloudflare/src/sync_r2_maintenance.ts @@ -0,0 +1,22 @@ +import type { Env } from "./bindings.js"; +import { purgeLegacySessionCache } from "./legacy_auth_kv_cleanup.js"; +import { collectSyncR2Garbage } from "./sync_r2_gc.js"; +import { inventorySyncR2Objects } from "./sync_r2_inventory.js"; +import { finalizeCleanedVaultRotations } from "./sync_vault_rotation_cleanup.js"; + +export async function maintainSyncR2Storage(env: Env, nowSeconds: number): Promise { + const errors: unknown[] = []; + for (const task of [ + () => purgeLegacySessionCache(env), + () => inventorySyncR2Objects(env, nowSeconds), + () => collectSyncR2Garbage(env, nowSeconds, { limit: 100 }), + () => finalizeCleanedVaultRotations(env, nowSeconds), + ]) { + try { + await task(); + } catch (error) { + errors.push(error); + } + } + if (errors.length > 0) throw new AggregateError(errors, "sync_storage_maintenance_failed"); +} diff --git a/cloudflare/src/sync_reset.ts b/cloudflare/src/sync_reset.ts index bea48a6..d751900 100644 --- a/cloudflare/src/sync_reset.ts +++ b/cloudflare/src/sync_reset.ts @@ -1,6 +1,20 @@ import type { AuthContext } from "./auth.js"; -import type { ElyD1PreparedStatement, Env } from "./bindings.js"; -import { StorageObjectError, deleteKnownObject } from "./storage.js"; +import type { ElyD1DatabaseSession, ElyD1PreparedStatement, ElyD1Result, Env } from "./bindings.js"; +import { primaryD1Session } from "./bindings.js"; +import { + assertDestructiveActionGateResult, + destructiveActionGateIsLive, + destructiveActionGateStatement, +} from "./destructive_action_gate.js"; +import { + type RecentDeviceActionProof, + RecentDeviceActionPermissionError, + RecentDeviceActionRequestError, + assertFreshDeviceActionProof, + assertRecentDeviceActionProof, + recentDeviceActionProof, +} from "./recent_device_action_proof.js"; +import { SYNC_R2_FENCE_USER_QUERY, collectSyncR2Garbage } from "./sync_r2_gc.js"; const RESET_CONFIRMATION = "delete-cloud-sync-data"; const IDEMPOTENCY_KEY_PATTERN = /^[a-zA-Z0-9._:-]{16,128}$/; @@ -17,33 +31,23 @@ const SYNC_RESET_COUNTS_QUERY = ` (SELECT COUNT(*) FROM sync_tombstones WHERE user_id = ?) AS tombstones `; const SYNC_RESET_R2_KEYS_QUERY = ` - SELECT payload_r2_key AS r2_key - FROM sync_objects - WHERE user_id = ? AND payload_r2_key IS NOT NULL - UNION - SELECT r2_key - FROM sync_snapshots - WHERE user_id = ? + SELECT r2_key FROM sync_r2_gc_candidates + WHERE user_id = ? AND state <> 'deleted' ORDER BY r2_key ASC `; -const SYNC_RESET_AUDIT_INSERT_QUERY = ` - INSERT INTO audit_events ( - event_id, - user_id, - actor_device_id, - event_type, - subject_type, - subject_id, - outcome, - metadata_hash, - created_at - ) VALUES (?, ?, ?, 'sync.reset', 'sync', ?, 'success', NULL, ?) - ON CONFLICT(event_id) DO NOTHING -`; const SYNC_OBJECTS_DELETE_QUERY = "DELETE FROM sync_objects WHERE user_id = ?"; const SYNC_CHANGE_LOG_DELETE_QUERY = "DELETE FROM sync_change_log WHERE user_id = ?"; +const SYNC_SNAPSHOT_HEADS_DELETE_QUERY = "DELETE FROM sync_snapshot_heads WHERE user_id = ?"; +const SYNC_SNAPSHOT_ENCRYPTION_DELETE_QUERY = + "DELETE FROM sync_snapshot_encryption WHERE user_id = ?"; const SYNC_SNAPSHOTS_DELETE_QUERY = "DELETE FROM sync_snapshots WHERE user_id = ?"; const SYNC_TOMBSTONES_DELETE_QUERY = "DELETE FROM sync_tombstones WHERE user_id = ?"; +const START_ROTATION_CLEANUP_QUERY = ` + UPDATE sync_vault_rotations + SET cleanup_snapshot_id = 'sync-reset', cleanup_started_at = ? + WHERE user_id = ? AND completed_at IS NOT NULL + AND cleanup_started_at IS NULL AND storage_cleaned_at IS NULL +`; export interface SyncResetDocument { version: 1; @@ -62,7 +66,7 @@ export interface SyncResetDeletedDocument { r2_objects: number; } -interface SyncResetRequest { +interface SyncResetRequest extends RecentDeviceActionProof { idempotencyKey: string; } @@ -108,19 +112,55 @@ export async function syncResetDocument( const deviceId = currentDeviceId(context); const reset = await syncResetRequest(request); const eventId = syncResetEventId(context.userId, reset.idempotencyKey); - const existingEvent = await env.ELY_DB.prepare(SYNC_RESET_EVENT_QUERY) + const database = primaryD1Session(env.ELY_DB); + const signingPublicKey = await assertRecentDeviceActionProof( + database, + context, + "sync.reset", + RESET_CONFIRMATION, + reset.idempotencyKey, + reset, + ); + const existingEvent = await database.prepare(SYNC_RESET_EVENT_QUERY) .bind(context.userId, eventId) .first(); + assertFreshDeviceActionProof(reset, nowSeconds, existingEvent === null); if (existingEvent !== null) { + await collectResetGarbage(env, context.userId, nowSeconds, 100); return existingResetDocument(context, deviceId, reset, existingEvent); } - const counts = await syncResetCounts(env, context.userId); - const r2Keys = await syncResetR2Keys(env, context.userId); - for (const key of r2Keys) { - await deleteResetObject(env, key); + const counts = await syncResetCounts(database, context.userId); + const r2Keys = await syncResetR2Keys(database, context.userId); + let results: ElyD1Result[]; + try { + results = await database.batch(syncResetStatements( + database, + context, + signingPublicKey, + eventId, + nowSeconds, + )); + } catch (error) { + const replayDatabase = primaryD1Session(env.ELY_DB); + const racedEvent = await replayDatabase.prepare(SYNC_RESET_EVENT_QUERY) + .bind(context.userId, eventId) + .first(); + if (racedEvent !== null) { + return existingResetDocument(context, deviceId, reset, racedEvent); + } + if (!(await destructiveActionGateIsLive( + replayDatabase, + context, + signingPublicKey, + nowSeconds, + ))) { + throw new RecentDeviceActionPermissionError("device_action_gate_failed"); + } + throw error; } - await env.ELY_DB.batch(syncResetStatements(env, context.userId, deviceId, eventId, nowSeconds)); + assertDestructiveActionGateResult(results[0]); + await collectResetGarbage(env, context.userId, nowSeconds, r2Keys.length); return { version: 1, @@ -152,10 +192,10 @@ function existingResetDocument( } async function syncResetCounts( - env: Env, + database: ElyD1DatabaseSession, userId: string, ): Promise> { - const row = await env.ELY_DB.prepare(SYNC_RESET_COUNTS_QUERY) + const row = await database.prepare(SYNC_RESET_COUNTS_QUERY) .bind(userId, userId, userId, userId) .first(); if (row === null) { @@ -169,56 +209,91 @@ async function syncResetCounts( }; } -async function syncResetR2Keys(env: Env, userId: string): Promise { - const result = await env.ELY_DB.prepare(SYNC_RESET_R2_KEYS_QUERY) - .bind(userId, userId) +async function syncResetR2Keys( + database: ElyD1DatabaseSession, + userId: string, +): Promise { + const result = await database.prepare(SYNC_RESET_R2_KEYS_QUERY) + .bind(userId) .all(); return result.results.map(r2Key); } function syncResetStatements( - env: Env, - userId: string, - deviceId: string, + database: ElyD1DatabaseSession, + context: AuthContext, + signingPublicKey: string, eventId: string, nowSeconds: number, ): ElyD1PreparedStatement[] { + const userId = context.userId; return [ - env.ELY_DB.prepare(SYNC_CHANGE_LOG_DELETE_QUERY).bind(userId), - env.ELY_DB.prepare(SYNC_TOMBSTONES_DELETE_QUERY).bind(userId), - env.ELY_DB.prepare(SYNC_SNAPSHOTS_DELETE_QUERY).bind(userId), - env.ELY_DB.prepare(SYNC_OBJECTS_DELETE_QUERY).bind(userId), - env.ELY_DB.prepare(SYNC_RESET_AUDIT_INSERT_QUERY).bind( + destructiveActionGateStatement(database, context, signingPublicKey, { eventId, - userId, - deviceId, - userId, + auditUserId: userId, + eventType: "sync.reset", + subjectType: "sync", + subjectId: userId, + metadataHash: null, + }, nowSeconds), + database.prepare(SYNC_R2_FENCE_USER_QUERY).bind( nowSeconds, + nowSeconds, + nowSeconds, + userId, ), + database.prepare(START_ROTATION_CLEANUP_QUERY).bind(nowSeconds, userId), + database.prepare(SYNC_CHANGE_LOG_DELETE_QUERY).bind(userId), + database.prepare(SYNC_TOMBSTONES_DELETE_QUERY).bind(userId), + database.prepare(SYNC_SNAPSHOT_HEADS_DELETE_QUERY).bind(userId), + database.prepare(SYNC_SNAPSHOT_ENCRYPTION_DELETE_QUERY).bind(userId), + database.prepare(SYNC_SNAPSHOTS_DELETE_QUERY).bind(userId), + database.prepare(SYNC_OBJECTS_DELETE_QUERY).bind(userId), ]; } -async function deleteResetObject(env: Env, key: string): Promise { +async function collectResetGarbage( + env: Env, + userId: string, + nowSeconds: number, + candidateCount: number, +): Promise { try { - await deleteKnownObject(env.ELY_STORAGE, key); - } catch (error) { - if (error instanceof StorageObjectError) { - throw new SyncResetPersistenceError(error.message); + const maxBatches = Math.ceil(candidateCount / 100) + 1; + for (let batch = 0; batch < maxBatches; batch += 1) { + if (await collectSyncR2Garbage(env, nowSeconds, { userId, limit: 100 }) < 100) break; } - throw error; + } catch { + // Scheduled maintenance drains the durable GC ledger. } } async function syncResetRequest(request: Request): Promise { const body = await requestBody(request); - assertOnlyFields(body, ["version", "confirmation", "idempotency_key"]); - if (body.version !== 1) { + assertOnlyFields(body, [ + "version", + "confirmation", + "idempotency_key", + "proof_created_at", + "action_proof", + ]); + if (body.version !== 2) { throw new SyncResetRequestError("version_invalid"); } if (body.confirmation !== RESET_CONFIRMATION) { throw new SyncResetRequestError("confirmation_invalid"); } - return { idempotencyKey: idempotencyKey(body.idempotency_key) }; + try { + return { + idempotencyKey: idempotencyKey(body.idempotency_key), + ...recentDeviceActionProof(body.proof_created_at, body.action_proof), + }; + } catch (error) { + if (error instanceof RecentDeviceActionRequestError) { + throw new SyncResetRequestError(error.message); + } + throw error; + } } async function requestBody(request: Request): Promise { diff --git a/cloudflare/src/sync_routes.ts b/cloudflare/src/sync_routes.ts index 6eea2cd..835c394 100644 --- a/cloudflare/src/sync_routes.ts +++ b/cloudflare/src/sync_routes.ts @@ -1,13 +1,11 @@ import type { Env } from "./bindings.js"; import { withApprovedDeviceApiControls } from "./api_controls.js"; +import { DestructiveActionGateError } from "./destructive_action_gate.js"; import { jsonResponse } from "./responses.js"; -import { SyncRequestError, SyncSchemaError, syncPullDocument } from "./sync_pull.js"; import { - SyncPushConflictError, - SyncPushPersistenceError, - SyncPushRequestError, - syncPushDocument, -} from "./sync_push.js"; + RecentDeviceActionPermissionError, + RecentDeviceActionPersistenceError, +} from "./recent_device_action_proof.js"; import { SyncResetPersistenceError, SyncResetRequestError, @@ -22,6 +20,15 @@ import { syncSnapshotUploadDocument, } from "./sync_snapshot.js"; import { SyncStatusSchemaError, syncStatusDocument } from "./sync_status.js"; +import { + SyncVaultConflictError, + SyncVaultNotFoundError, + SyncVaultPermissionError, + SyncVaultPersistenceError, + SyncVaultRequestError, + syncVaultBootstrapDocument, + syncVaultCurrentDeviceDocument, +} from "./sync_vault.js"; export async function handleSyncRoute( request: Request, @@ -29,10 +36,10 @@ export async function handleSyncRoute( url: URL, ): Promise { if (url.pathname === "/api/sync/pull") { - return handleSyncPull(request, env, url); + return handleRetiredSyncObjectRoute(request, env, "sync.pull", ["GET"]); } if (url.pathname === "/api/sync/push") { - return handleSyncPush(request, env); + return handleRetiredSyncObjectRoute(request, env, "sync.push", ["POST"]); } if (url.pathname === "/api/sync/snapshot") { return handleSyncSnapshot(request, env, url); @@ -40,37 +47,33 @@ export async function handleSyncRoute( if (url.pathname === "/api/sync/status") { return handleSyncStatus(request, env); } + if (url.pathname === "/api/sync/vault/bootstrap") { + return handleSyncVaultBootstrap(request, env); + } + if (url.pathname === "/api/sync/vault") { + return handleSyncVault(request, env, url); + } if (url.pathname === "/api/sync/reset") { return handleSyncReset(request, env); } return null; } -function handleSyncPull(request: Request, env: Env, url: URL): Promise { +function handleSyncVaultBootstrap(request: Request, env: Env): Promise { return withApprovedDeviceApiControls( request, env, - "sync.pull", - ["GET"], + "sync.vault.bootstrap", + ["POST"], async (context) => { try { - return jsonResponse(await syncPullDocument(url, env, context), 200, { + return jsonResponse(await syncVaultBootstrapDocument(request, env, context), 201, { "Cache-Control": "no-store", }); } catch (error) { - if (error instanceof SyncRequestError) { - return jsonResponse( - { error: "invalid_sync_pull" }, - 400, - { "Cache-Control": "no-store" }, - ); - } - if (error instanceof SyncSchemaError) { - return jsonResponse( - { error: "sync_pull_invalid" }, - 500, - { "Cache-Control": "no-store" }, - ); + const response = syncVaultErrorResponse(error); + if (response !== null) { + return response; } throw error; } @@ -78,34 +81,21 @@ function handleSyncPull(request: Request, env: Env, url: URL): Promise ); } -function handleSyncPush(request: Request, env: Env): Promise { +function handleSyncVault(request: Request, env: Env, url: URL): Promise { return withApprovedDeviceApiControls( request, env, - "sync.push", - ["POST"], + "sync.vault", + ["GET"], async (context) => { try { - return jsonResponse(await syncPushDocument(request, env, context), 201, { + return jsonResponse(await syncVaultCurrentDeviceDocument(url, env, context), 200, { "Cache-Control": "no-store", }); } catch (error) { - if (error instanceof SyncPushRequestError) { - return jsonResponse( - { error: "invalid_sync_push" }, - 400, - { "Cache-Control": "no-store" }, - ); - } - if (error instanceof SyncPushConflictError) { - return jsonResponse({ error: "sync_conflict" }, 409, { "Cache-Control": "no-store" }); - } - if (error instanceof SyncPushPersistenceError) { - return jsonResponse( - { error: "sync_push_failed" }, - 500, - { "Cache-Control": "no-store" }, - ); + const response = syncVaultErrorResponse(error); + if (response !== null) { + return response; } throw error; } @@ -113,6 +103,43 @@ function handleSyncPush(request: Request, env: Env): Promise { ); } +function syncVaultErrorResponse(error: unknown): Response | null { + if (error instanceof SyncVaultPermissionError) { + return jsonResponse({ error: "sync_vault_forbidden" }, 403, { "Cache-Control": "no-store" }); + } + if (error instanceof SyncVaultRequestError) { + return jsonResponse({ error: "invalid_sync_vault" }, 400, { "Cache-Control": "no-store" }); + } + if (error instanceof SyncVaultNotFoundError) { + return jsonResponse({ error: "sync_vault_not_found" }, 404, { "Cache-Control": "no-store" }); + } + if (error instanceof SyncVaultConflictError) { + return jsonResponse({ error: "sync_vault_conflict" }, 409, { "Cache-Control": "no-store" }); + } + if (error instanceof SyncVaultPersistenceError) { + return jsonResponse({ error: "sync_vault_failed" }, 500, { "Cache-Control": "no-store" }); + } + return null; +} + +function handleRetiredSyncObjectRoute( + request: Request, + env: Env, + route: string, + allowedMethods: readonly string[], +): Promise { + return withApprovedDeviceApiControls( + request, + env, + route, + allowedMethods, + async () => + jsonResponse({ error: "sync_object_protocol_retired" }, 410, { + "Cache-Control": "no-store", + }), + ); +} + function handleSyncSnapshot(request: Request, env: Env, url: URL): Promise { return withApprovedDeviceApiControls( request, @@ -145,11 +172,7 @@ function handleSyncSnapshot(request: Request, env: Env, url: URL): Promise { { "Cache-Control": "no-store" }, ); } - if (error instanceof SyncResetPersistenceError) { + if (error instanceof RecentDeviceActionPermissionError) { + return jsonResponse( + { error: "sync_reset_forbidden" }, + 403, + { "Cache-Control": "no-store" }, + ); + } + if ( + error instanceof SyncResetPersistenceError || + error instanceof RecentDeviceActionPersistenceError || + error instanceof DestructiveActionGateError + ) { return jsonResponse( { error: "sync_reset_failed" }, 500, diff --git a/cloudflare/src/sync_snapshot.ts b/cloudflare/src/sync_snapshot.ts index 84917e3..5d25a32 100644 --- a/cloudflare/src/sync_snapshot.ts +++ b/cloudflare/src/sync_snapshot.ts @@ -1,59 +1,66 @@ import type { AuthContext } from "./auth.js"; -import type { Env } from "./bindings.js"; -import { StorageObjectError, getVerifiedObject, putVerifiedObject, syncSnapshotKey } from "./storage.js"; +import type { ElyD1DatabaseSession, ElyD1Result, Env } from "./bindings.js"; +import { primaryD1Session } from "./bindings.js"; +import { StorageObjectError, getVerifiedObject } from "./storage.js"; +import { + SyncSnapshotRequestError, + assertOnlyFields, + assertOnlyQueryParams, + assertPayloadHash, + base64FromBytes, + deviceIdValue, + exactInteger, + integer, + payloadBytes, + regionValue, + requestBody, + sha256HexValue, + snapshotIdValue, +} from "./sync_snapshot_codec.js"; +import { + type SnapshotHeadRefDocument, + type SyncSnapshotDocument, + type SyncSnapshotRow, + SyncSnapshotConflictError, + SyncSnapshotHeadSchemaError, + currentSyncSnapshotHead, + sameSnapshotHead, + snapshotDocumentFromResult, + snapshotHeadRef, + snapshotHeadRefValue, + syncSnapshotByToken, +} from "./sync_snapshot_head.js"; +import { + syncSnapshotStatements, +} from "./sync_snapshot_sql.js"; +import { + SyncR2WriteFenceError, +} from "./sync_r2_gc.js"; +import { + type SyncR2WriteLease, + claimSnapshotStorageWrite, + persistClaimedSnapshot, + releaseFailedSnapshotWrite, + snapshotStorageKey, +} from "./sync_snapshot_write.js"; +import { + SyncVaultConflictError, + SyncVaultNotFoundError, + assertCurrentSyncVaultKey, +} from "./sync_vault.js"; +import { + SyncVaultRotationCleanupError, + cleanupRotatedVaultStorage, +} from "./sync_vault_rotation_cleanup.js"; + +export { SyncSnapshotRequestError } from "./sync_snapshot_codec.js"; +export { SyncSnapshotConflictError } from "./sync_snapshot_head.js"; +export type { SyncSnapshotDocument } from "./sync_snapshot_head.js"; const MAX_SNAPSHOT_BYTES = 10 * 1024 * 1024; -const SNAPSHOT_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,127}$/; -const DEVICE_ID_PATTERN = /^[a-zA-Z0-9._:-]{3,128}$/; -const SHA256_HEX = /^[a-f0-9]{64}$/; -const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; -const REGION = /^[a-z0-9][a-z0-9-]{1,31}$/; -const SYNC_SNAPSHOT_BY_ID_QUERY = ` - SELECT - snapshot_id, - r2_key, - payload_hash, - schema_rev, - logical_clock, - device_id, - size_bytes, - created_at - FROM sync_snapshots - WHERE user_id = ? AND snapshot_id = ? -`; -const SYNC_SNAPSHOT_UPSERT_QUERY = ` - INSERT INTO sync_snapshots ( - user_id, - snapshot_id, - r2_key, - payload_hash, - schema_rev, - logical_clock, - device_id, - size_bytes, - created_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(user_id, snapshot_id) DO UPDATE SET - r2_key = excluded.r2_key, - payload_hash = excluded.payload_hash, - schema_rev = excluded.schema_rev, - logical_clock = excluded.logical_clock, - device_id = excluded.device_id, - size_bytes = excluded.size_bytes, - created_at = excluded.created_at - WHERE excluded.logical_clock > sync_snapshots.logical_clock - OR ( - excluded.logical_clock = sync_snapshots.logical_clock - AND sync_snapshots.r2_key = excluded.r2_key - AND sync_snapshots.payload_hash = excluded.payload_hash - AND sync_snapshots.schema_rev = excluded.schema_rev - AND sync_snapshots.device_id = excluded.device_id - AND sync_snapshots.size_bytes = excluded.size_bytes - ) -`; export interface SyncSnapshotUploadDocument { - version: 1; + version: 3; user_id: string; device_id: string; snapshot: SyncSnapshotDocument; @@ -63,53 +70,21 @@ export interface SyncSnapshotDownloadDocument extends SyncSnapshotUploadDocument data_base64: string; } -export interface SyncSnapshotDocument { - snapshot_id: string; - r2_key: string; - payload_hash: string; - schema_rev: number; - logical_clock: number; - device_id: string; - size_bytes: number; - created_at: number; -} - interface SyncSnapshotUploadRequest { snapshotId: string; r2Key: string; payloadHash: string; + encryptionVersion: 2; + vaultGeneration: number; + keyId: string; + contentHash: string; schemaRev: number; logicalClock: number; + headRevision: number; + baseHead: SnapshotHeadRefDocument | null; bytes: ArrayBuffer; } -interface SyncSnapshotRow { - snapshot_id: unknown; - r2_key: unknown; - payload_hash: unknown; - schema_rev: unknown; - logical_clock: unknown; - device_id: unknown; - size_bytes: unknown; - created_at: unknown; -} - -type RequestBody = Record; - -export class SyncSnapshotRequestError extends Error { - constructor(message: string) { - super(message); - this.name = "SyncSnapshotRequestError"; - } -} - -export class SyncSnapshotConflictError extends Error { - constructor(message: string) { - super(message); - this.name = "SyncSnapshotConflictError"; - } -} - export class SyncSnapshotNotFoundError extends Error { constructor(message: string) { super(message); @@ -131,40 +106,127 @@ export async function syncSnapshotUploadDocument( nowSeconds = Math.floor(Date.now() / 1000), ): Promise { const deviceId = currentDeviceId(context); - const snapshot = await syncSnapshotUploadRequest(request, context.userId); - const existingRow = await snapshotRow(env, context.userId, snapshot.snapshotId); - if (existingRow !== null) { - assertSnapshotCanReplaceExisting(snapshot, deviceId, syncSnapshotDocumentFromRow(existingRow)); + const upload = await syncSnapshotUploadRequest(request, context.userId); + const database = primaryD1Session(env.ELY_DB); + const currentHead = await readCurrentHead(database, context.userId); + if (currentHead !== null && snapshotMatchesUpload(upload, deviceId, currentHead)) { + try { + await assertCurrentSyncVaultKey( + env, + context.userId, + upload.keyId, + upload.vaultGeneration, + database, + ); + } catch (error) { + if (error instanceof SyncVaultConflictError || error instanceof SyncVaultNotFoundError) { + return uploadDocument(context.userId, deviceId, currentHead); + } + throw error; + } + await cleanupAfterSnapshot(env, context.userId, currentHead, nowSeconds); + return uploadDocument(context.userId, deviceId, currentHead); } - - await persistSnapshot(env, snapshot); - await env.ELY_DB.batch([ - env.ELY_DB.prepare(SYNC_SNAPSHOT_UPSERT_QUERY).bind( + try { + await assertCurrentSyncVaultKey( + env, context.userId, - snapshot.snapshotId, - snapshot.r2Key, - snapshot.payloadHash, - snapshot.schemaRev, - snapshot.logicalClock, - deviceId, - snapshot.bytes.byteLength, - nowSeconds, - ), - ]); - - const savedRow = await snapshotRow(env, context.userId, snapshot.snapshotId); - if (savedRow === null) { - throw new SyncSnapshotPersistenceError("sync_snapshot_missing"); + upload.keyId, + upload.vaultGeneration, + database, + ); + } catch (error) { + if (error instanceof SyncVaultConflictError || error instanceof SyncVaultNotFoundError) { + throw new SyncSnapshotConflictError("sync_vault_key_not_current", currentHead); + } + throw error; } - const savedSnapshot = syncSnapshotDocumentFromRow(savedRow); - assertSavedSnapshotMatchesUpload(snapshot, deviceId, savedSnapshot); - return { - version: 1, - user_id: context.userId, - device_id: deviceId, - snapshot: savedSnapshot, - }; + assertUploadBase(upload, currentHead); + let writeLease: SyncR2WriteLease; + try { + writeLease = await claimSnapshotStorageWrite( + env, + database, + context.userId, + deviceId, + upload, + nowSeconds, + ); + } catch (error) { + if (error instanceof SyncR2WriteFenceError) { + const head = await readCurrentHead(database, context.userId); + throw new SyncSnapshotConflictError("sync_snapshot_head_conflict", head); + } + throw error; + } + try { + await persistClaimedSnapshot(env, upload); + } catch (error) { + await releaseFailedSnapshotWrite( + env, + database, + context.userId, + upload.r2Key, + writeLease, + nowSeconds, + ); + throw error; + } + + let results: ElyD1Result[]; + try { + results = await database.batch>(syncSnapshotStatements( + database, + context.userId, + deviceId, + { ...upload, sizeBytes: upload.bytes.byteLength }, + writeLease, + nowSeconds, + )); + } catch (error) { + await releaseFailedSnapshotWrite( + env, + database, + context.userId, + upload.r2Key, + writeLease, + nowSeconds, + ); + if (!isSnapshotHeadConflict(error)) { + throw error; + } + return concurrentUploadResult(env, database, context.userId, deviceId, upload, nowSeconds); + } + if ( + results.length !== 5 || + results.slice(0, 4).some((result) => changedRowCount(result) !== 1) + ) { + await releaseFailedSnapshotWrite( + env, + database, + context.userId, + upload.r2Key, + writeLease, + nowSeconds, + ); + return concurrentUploadResult(env, database, context.userId, deviceId, upload, nowSeconds); + } + + let saved: SyncSnapshotDocument; + try { + saved = snapshotDocumentFromResult(results[4]); + } catch (error) { + if (error instanceof SyncSnapshotHeadSchemaError) { + throw new SyncSnapshotPersistenceError(error.message); + } + throw error; + } + if (!snapshotMatchesUpload(upload, deviceId, saved)) { + throw new SyncSnapshotPersistenceError("sync_snapshot_mismatch"); + } + await cleanupAfterSnapshot(env, context.userId, saved, nowSeconds); + return uploadDocument(context.userId, deviceId, saved); } export async function syncSnapshotDownloadDocument( @@ -173,31 +235,36 @@ export async function syncSnapshotDownloadDocument( context: AuthContext, ): Promise { const deviceId = currentDeviceId(context); - const snapshotId = syncSnapshotDownloadQuery(url); - const row = await snapshotRow(env, context.userId, snapshotId); - if (row === null) { - throw new SyncSnapshotNotFoundError("sync_snapshot_missing"); + const database = primaryD1Session(env.ELY_DB); + const token = syncSnapshotDownloadQuery(url); + const snapshot = await readSnapshotByToken(database, context.userId, token); + if (snapshot === null) { + const currentHead = await readCurrentHead(database, context.userId); + if (currentHead === null) { + throw new SyncSnapshotNotFoundError("sync_snapshot_missing"); + } + throw new SyncSnapshotConflictError("sync_snapshot_download_token_stale", currentHead); } - const snapshot = syncSnapshotDocumentFromRow(row); let payload: ArrayBuffer | null; try { payload = await getVerifiedObject(env.ELY_STORAGE, snapshot.r2_key, snapshot.payload_hash); } catch (error) { if (error instanceof StorageObjectError) { - throw new SyncSnapshotPersistenceError(error.message); + await throwDownloadStorageFailure(env, context.userId, token, error.message); } throw error; } if (payload === null) { - throw new SyncSnapshotPersistenceError("sync_snapshot_payload_missing"); + return throwDownloadStorageFailure( + env, + context.userId, + token, + "sync_snapshot_payload_missing", + ); } - return { - version: 1, - user_id: context.userId, - device_id: deviceId, - snapshot, + ...uploadDocument(context.userId, deviceId, snapshot), data_base64: base64FromBytes(payload), }; } @@ -212,282 +279,216 @@ async function syncSnapshotUploadRequest( "snapshot_id", "region", "payload_hash", + "encryption_version", + "vault_generation", + "key_id", + "content_hash", "schema_rev", "logical_clock", + "head_revision", + "base_head", "data_base64", ]); - if (body.version !== 1) { + if (body.version !== 3) { throw new SyncSnapshotRequestError("version_invalid"); } const snapshotId = snapshotIdValue(body.snapshot_id); - const region = regionValue(body.region); const payloadHash = sha256HexValue(body.payload_hash, "payload_hash"); + const headRevision = integer( + body.head_revision, + "head_revision", + 1, + Number.MAX_SAFE_INTEGER, + ); + const baseHead = snapshotHeadRefValue(body.base_head, headRevision); const bytes = payloadBytes(body.data_base64, "data_base64", MAX_SNAPSHOT_BYTES); await assertPayloadHash(bytes, payloadHash); return { snapshotId, - r2Key: await snapshotStorageKey(region, userId, snapshotId), + r2Key: await snapshotStorageKey( + regionValue(body.region), + userId, + snapshotId, + payloadHash, + ), payloadHash, + encryptionVersion: exactInteger(body.encryption_version, "encryption_version", 2), + vaultGeneration: integer( + body.vault_generation, + "vault_generation", + 1, + Number.MAX_SAFE_INTEGER, + ), + keyId: sha256HexValue(body.key_id, "key_id"), + contentHash: sha256HexValue(body.content_hash, "content_hash"), schemaRev: integer(body.schema_rev, "schema_rev", 1, Number.MAX_SAFE_INTEGER), logicalClock: integer(body.logical_clock, "logical_clock", 0, Number.MAX_SAFE_INTEGER), + headRevision, + baseHead, bytes, }; } -function syncSnapshotDownloadQuery(url: URL): string { - assertOnlyQueryParams(url, ["snapshot_id"]); - return snapshotIdValue(url.searchParams.get("snapshot_id")); +function syncSnapshotDownloadQuery(url: URL): SnapshotHeadRefDocument { + assertOnlyQueryParams(url, ["snapshot_id", "head_revision", "payload_hash"]); + return { + snapshot_id: snapshotIdValue(url.searchParams.get("snapshot_id")), + revision: integer( + numberQueryValue(url.searchParams.get("head_revision")), + "head_revision", + 1, + Number.MAX_SAFE_INTEGER, + ), + payload_hash: sha256HexValue(url.searchParams.get("payload_hash"), "payload_hash"), + }; } -async function snapshotStorageKey( - region: string, - userId: string, - snapshotId: string, -): Promise { - try { - return syncSnapshotKey({ - region, - userHash: await sha256Hex(arrayBufferFromBytes(new TextEncoder().encode(userId))), - snapshotId, - }); - } catch (error) { - if (error instanceof StorageObjectError) { - throw new SyncSnapshotRequestError(error.message); - } - throw error; +function assertUploadBase( + upload: SyncSnapshotUploadRequest, + currentHead: SyncSnapshotDocument | null, +): void { + const currentRef = currentHead === null ? null : snapshotHeadRef(currentHead); + if (!sameSnapshotHead(upload.baseHead, currentRef)) { + throw new SyncSnapshotConflictError("sync_snapshot_head_conflict", currentHead); + } + if (currentHead !== null && upload.logicalClock <= currentHead.logical_clock) { + throw new SyncSnapshotConflictError("logical_clock_stale", currentHead); } } -async function snapshotRow( - env: Env, - userId: string, - snapshotId: string, -): Promise { - return env.ELY_DB.prepare(SYNC_SNAPSHOT_BY_ID_QUERY).bind(userId, snapshotId).first(); +function snapshotMatchesUpload( + upload: SyncSnapshotUploadRequest, + deviceId: string, + snapshot: SyncSnapshotDocument, +): boolean { + return snapshot.snapshot_id === upload.snapshotId && + snapshot.r2_key === upload.r2Key && + snapshot.payload_hash === upload.payloadHash && + snapshot.encryption_version === upload.encryptionVersion && + snapshot.vault_generation === upload.vaultGeneration && + snapshot.key_id === upload.keyId && + snapshot.content_hash === upload.contentHash && + snapshot.schema_rev === upload.schemaRev && + snapshot.logical_clock === upload.logicalClock && + snapshot.head_revision === upload.headRevision && + sameSnapshotHead(snapshot.base_head, upload.baseHead) && + snapshot.device_id === deviceId && + snapshot.size_bytes === upload.bytes.byteLength; } -function syncSnapshotDocumentFromRow(row: SyncSnapshotRow): SyncSnapshotDocument { +async function readCurrentHead( + database: ElyD1DatabaseSession, + userId: string, +): Promise { try { - return syncSnapshotDocument(row); + return await currentSyncSnapshotHead(database, userId); } catch (error) { - if (error instanceof SyncSnapshotRequestError) { + if (error instanceof SyncSnapshotHeadSchemaError) { throw new SyncSnapshotPersistenceError(error.message); } throw error; } } -function syncSnapshotDocument(row: SyncSnapshotRow): SyncSnapshotDocument { - return { - snapshot_id: snapshotIdValue(row.snapshot_id), - r2_key: text(row.r2_key, "r2_key"), - payload_hash: sha256HexValue(row.payload_hash, "payload_hash"), - schema_rev: integer(row.schema_rev, "schema_rev", 1, Number.MAX_SAFE_INTEGER), - logical_clock: integer(row.logical_clock, "logical_clock", 0, Number.MAX_SAFE_INTEGER), - device_id: deviceIdValue(row.device_id), - size_bytes: integer(row.size_bytes, "size_bytes", 1, MAX_SNAPSHOT_BYTES), - created_at: integer(row.created_at, "created_at", 0, Number.MAX_SAFE_INTEGER), - }; -} - -function assertSnapshotCanReplaceExisting( - snapshot: SyncSnapshotUploadRequest, - deviceId: string, - existing: SyncSnapshotDocument, -): void { - if (existing.logical_clock > snapshot.logicalClock) { - throw new SyncSnapshotConflictError("logical_clock_stale"); - } - if (existing.logical_clock < snapshot.logicalClock) { - return; - } - if ( - existing.payload_hash !== snapshot.payloadHash || - existing.schema_rev !== snapshot.schemaRev || - existing.device_id !== deviceId || - existing.size_bytes !== snapshot.bytes.byteLength - ) { - throw new SyncSnapshotConflictError("logical_clock_conflict"); - } -} - -function assertSavedSnapshotMatchesUpload( - upload: SyncSnapshotUploadRequest, - deviceId: string, - snapshot: SyncSnapshotDocument, -): void { - if (snapshot.logical_clock > upload.logicalClock) { - throw new SyncSnapshotConflictError("logical_clock_stale"); - } - if ( - snapshot.logical_clock === upload.logicalClock && - (snapshot.r2_key !== upload.r2Key || - snapshot.payload_hash !== upload.payloadHash || - snapshot.schema_rev !== upload.schemaRev || - snapshot.device_id !== deviceId || - snapshot.size_bytes !== upload.bytes.byteLength) - ) { - throw new SyncSnapshotConflictError("logical_clock_conflict"); - } - if ( - snapshot.snapshot_id !== upload.snapshotId || - snapshot.r2_key !== upload.r2Key || - snapshot.payload_hash !== upload.payloadHash || - snapshot.schema_rev !== upload.schemaRev || - snapshot.logical_clock !== upload.logicalClock || - snapshot.device_id !== deviceId || - snapshot.size_bytes !== upload.bytes.byteLength - ) { - throw new SyncSnapshotPersistenceError("sync_snapshot_mismatch"); - } -} - -async function persistSnapshot(env: Env, snapshot: SyncSnapshotUploadRequest): Promise { +async function readSnapshotByToken( + database: ElyD1DatabaseSession, + userId: string, + token: SnapshotHeadRefDocument, +): Promise { try { - await putVerifiedObject( - env.ELY_STORAGE, - snapshot.r2Key, - snapshot.bytes, - snapshot.payloadHash, - "application/octet-stream", - ); + return await syncSnapshotByToken(database, userId, token); } catch (error) { - if (error instanceof StorageObjectError) { - throw new SyncSnapshotRequestError(error.message); + if (error instanceof SyncSnapshotHeadSchemaError) { + throw new SyncSnapshotPersistenceError(error.message); } throw error; } } -function currentDeviceId(context: AuthContext): string { - if (context.deviceId === undefined) { - throw new SyncSnapshotRequestError("device_context_required"); +async function concurrentUploadResult( + env: Env, + database: ElyD1DatabaseSession, + userId: string, + deviceId: string, + upload: SyncSnapshotUploadRequest, + nowSeconds: number, +): Promise { + const currentHead = await readCurrentHead(database, userId); + if (currentHead !== null && snapshotMatchesUpload(upload, deviceId, currentHead)) { + await cleanupAfterSnapshot(env, userId, currentHead, nowSeconds); + return uploadDocument(userId, deviceId, currentHead); } - return context.deviceId; + throw new SyncSnapshotConflictError("sync_snapshot_head_conflict", currentHead); } -async function requestBody(request: Request): Promise { - let value: unknown; +async function throwDownloadStorageFailure( + env: Env, + userId: string, + token: SnapshotHeadRefDocument, + message: string, +): Promise { + const currentHead = await readCurrentHead(primaryD1Session(env.ELY_DB), userId); + if (currentHead === null || !sameSnapshotHead(token, snapshotHeadRef(currentHead))) { + throw new SyncSnapshotConflictError("sync_snapshot_download_token_stale", currentHead); + } + throw new SyncSnapshotPersistenceError(message); +} + +async function cleanupAfterSnapshot( + env: Env, + userId: string, + snapshot: SyncSnapshotDocument, + nowSeconds: number, +): Promise { try { - value = await request.json(); - } catch { - throw new SyncSnapshotRequestError("json_invalid"); - } - return record(value, "body"); -} - -function record(value: unknown, label: string): RequestBody { - if (typeof value !== "object" || value === null || Array.isArray(value)) { - throw new SyncSnapshotRequestError(`${label}_invalid`); - } - return value as RequestBody; -} - -function assertOnlyFields(value: RequestBody, fields: string[]): void { - const allowed = new Set(fields); - for (const field of Object.keys(value)) { - if (!allowed.has(field)) { - throw new SyncSnapshotRequestError(`unexpected_field:${field}`); + await cleanupRotatedVaultStorage( + env, + userId, + snapshot.snapshot_id, + snapshot.key_id, + snapshot.vault_generation, + nowSeconds, + ); + } catch (error) { + if (error instanceof SyncVaultRotationCleanupError) { + throw new SyncSnapshotPersistenceError(error.message); } + throw error; } } -function assertOnlyQueryParams(url: URL, fields: string[]): void { - const allowed = new Set(fields); - for (const field of url.searchParams.keys()) { - if (!allowed.has(field)) { - throw new SyncSnapshotRequestError(`unexpected_query:${field}`); - } - } +function uploadDocument( + userId: string, + deviceId: string, + snapshot: SyncSnapshotDocument, +): SyncSnapshotUploadDocument { + return { version: 3, user_id: userId, device_id: deviceId, snapshot }; } -function snapshotIdValue(value: unknown): string { - if (typeof value !== "string" || !SNAPSHOT_ID_PATTERN.test(value)) { - throw new SyncSnapshotRequestError("snapshot_id_invalid"); - } - return value; +function currentDeviceId(context: AuthContext): string { + return deviceIdValue(context.deviceId); } -function deviceIdValue(value: unknown): string { - if (typeof value !== "string" || !DEVICE_ID_PATTERN.test(value)) { - throw new SyncSnapshotRequestError("device_id_invalid"); +function numberQueryValue(value: string | null): number { + if (value === null || !/^[1-9][0-9]*$/.test(value)) { + throw new SyncSnapshotRequestError("head_revision_invalid"); } - return value; + return Number(value); } -function regionValue(value: unknown): string { - if (typeof value !== "string" || !REGION.test(value)) { - throw new SyncSnapshotRequestError("region_invalid"); - } - return value; +function changedRowCount(result: ElyD1Result): number { + const changes = result.meta?.changes; + return typeof changes === "number" && Number.isSafeInteger(changes) && changes >= 0 ? changes : -1; } -function sha256HexValue(value: unknown, label: string): string { - if (typeof value !== "string" || !SHA256_HEX.test(value)) { - throw new SyncSnapshotRequestError(`${label}_invalid`); +function isSnapshotHeadConflict(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; } - return value; -} - -function integer(value: unknown, label: string, min: number, max: number): number { - if (typeof value !== "number" || !Number.isSafeInteger(value) || value < min || value > max) { - throw new SyncSnapshotRequestError(`${label}_invalid`); - } - return value; -} - -function text(value: unknown, label: string): string { - if (typeof value !== "string" || value.length === 0) { - throw new SyncSnapshotRequestError(`${label}_invalid`); - } - return value; -} - -function payloadBytes(value: unknown, label: string, maxBytes: number): ArrayBuffer { - const encoded = text(value, label); - if (!BASE64.test(encoded)) { - throw new SyncSnapshotRequestError(`${label}_invalid`); - } - const bytes = bytesFromBase64(encoded); - if (bytes.byteLength === 0 || bytes.byteLength > maxBytes) { - throw new SyncSnapshotRequestError(`${label}_size_invalid`); - } - return bytes; -} - -function bytesFromBase64(value: string): ArrayBuffer { - const binary = atob(value); - const bytes = new Uint8Array(binary.length); - for (let index = 0; index < binary.length; index += 1) { - bytes[index] = binary.charCodeAt(index); - } - return bytes.buffer; -} - -function base64FromBytes(payload: ArrayBuffer): string { - const bytes = new Uint8Array(payload); - const parts: string[] = []; - for (let offset = 0; offset < bytes.length; offset += 0x8000) { - parts.push(String.fromCharCode(...bytes.subarray(offset, offset + 0x8000))); - } - return btoa(parts.join("")); -} - -function arrayBufferFromBytes(bytes: Uint8Array): ArrayBuffer { - const copy = new Uint8Array(bytes.byteLength); - copy.set(bytes); - return copy.buffer; -} - -async function assertPayloadHash(payload: ArrayBuffer, expectedHash: string): Promise { - const actualHash = await sha256Hex(payload); - if (actualHash !== expectedHash) { - throw new SyncSnapshotRequestError("payload_hash_mismatch"); - } -} - -async function sha256Hex(payload: ArrayBuffer): Promise { - const digest = await crypto.subtle.digest("SHA-256", payload); - return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); + return error.message.includes("sync_snapshot_head_cas_failed") || + error.message.includes("sync_r2_write_fenced") || + error.message.includes("sync_r2_reference_commit_invalid") || + error.message.includes("UNIQUE constraint failed: sync_snapshot_heads.user_id") || + error.message.includes("sync_snapshots.user_id, sync_snapshots.head_revision"); } diff --git a/cloudflare/src/sync_snapshot_codec.ts b/cloudflare/src/sync_snapshot_codec.ts new file mode 100644 index 0000000..46f1d1b --- /dev/null +++ b/cloudflare/src/sync_snapshot_codec.ts @@ -0,0 +1,155 @@ +const SNAPSHOT_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,127}$/; +const DEVICE_ID_PATTERN = /^[a-zA-Z0-9._:-]{3,128}$/; +const SHA256_HEX = /^[a-f0-9]{64}$/; +const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; +const REGION = /^[a-z0-9][a-z0-9-]{1,31}$/; + +export type SnapshotRequestBody = Record; + +export class SyncSnapshotRequestError extends Error { + constructor(message: string) { + super(message); + this.name = "SyncSnapshotRequestError"; + } +} + +export async function requestBody(request: Request): Promise { + let value: unknown; + try { + value = await request.json(); + } catch { + throw new SyncSnapshotRequestError("json_invalid"); + } + return record(value, "body"); +} + +export function assertOnlyFields(value: SnapshotRequestBody, fields: string[]): void { + const allowed = new Set(fields); + for (const field of Object.keys(value)) { + if (!allowed.has(field)) { + throw new SyncSnapshotRequestError(`unexpected_field:${field}`); + } + } +} + +export function assertOnlyQueryParams(url: URL, fields: string[]): void { + const allowed = new Set(fields); + for (const field of url.searchParams.keys()) { + if (!allowed.has(field)) { + throw new SyncSnapshotRequestError(`unexpected_query:${field}`); + } + } +} + +export function snapshotIdValue(value: unknown): string { + if (typeof value !== "string" || !SNAPSHOT_ID_PATTERN.test(value)) { + throw new SyncSnapshotRequestError("snapshot_id_invalid"); + } + return value; +} + +export function deviceIdValue(value: unknown): string { + if (typeof value !== "string" || !DEVICE_ID_PATTERN.test(value)) { + throw new SyncSnapshotRequestError("device_id_invalid"); + } + return value; +} + +export function regionValue(value: unknown): string { + if (typeof value !== "string" || !REGION.test(value)) { + throw new SyncSnapshotRequestError("region_invalid"); + } + return value; +} + +export function sha256HexValue(value: unknown, label: string): string { + if (typeof value !== "string" || !SHA256_HEX.test(value)) { + throw new SyncSnapshotRequestError(`${label}_invalid`); + } + return value; +} + +export function integer(value: unknown, label: string, min: number, max: number): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < min || value > max) { + throw new SyncSnapshotRequestError(`${label}_invalid`); + } + return value; +} + +export function exactInteger(value: unknown, label: string, expected: T): T { + if (value !== expected) { + throw new SyncSnapshotRequestError(`${label}_invalid`); + } + return expected; +} + +export function text(value: unknown, label: string): string { + if (typeof value !== "string" || value.length === 0) { + throw new SyncSnapshotRequestError(`${label}_invalid`); + } + return value; +} + +export function payloadBytes(value: unknown, label: string, maxBytes: number): ArrayBuffer { + const encoded = text(value, label); + const maxEncodedLength = 4 * Math.ceil(maxBytes / 3); + if (encoded.length > maxEncodedLength) { + throw new SyncSnapshotRequestError(`${label}_size_invalid`); + } + if (!BASE64.test(encoded)) { + throw new SyncSnapshotRequestError(`${label}_invalid`); + } + const bytes = bytesFromBase64(encoded); + if (bytes.byteLength === 0 || bytes.byteLength > maxBytes) { + throw new SyncSnapshotRequestError(`${label}_size_invalid`); + } + return bytes; +} + +export function base64FromBytes(payload: ArrayBuffer): string { + const bytes = new Uint8Array(payload); + const parts: string[] = []; + for (let offset = 0; offset < bytes.length; offset += 0x8000) { + parts.push(String.fromCharCode(...bytes.subarray(offset, offset + 0x8000))); + } + return btoa(parts.join("")); +} + +export function arrayBufferFromBytes(bytes: Uint8Array): ArrayBuffer { + const copy = new Uint8Array(bytes.byteLength); + copy.set(bytes); + return copy.buffer; +} + +export async function assertPayloadHash( + payload: ArrayBuffer, + expectedHash: string, +): Promise { + const actualHash = await sha256Hex(payload); + if (actualHash !== expectedHash) { + throw new SyncSnapshotRequestError("payload_hash_mismatch"); + } +} + +export async function sha256Hex(payload: ArrayBuffer): Promise { + const digest = await crypto.subtle.digest("SHA-256", payload); + return [...new Uint8Array(digest)] + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +} + +function record(value: unknown, label: string): SnapshotRequestBody { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new SyncSnapshotRequestError(`${label}_invalid`); + } + return value as SnapshotRequestBody; +} + +function bytesFromBase64(value: string): ArrayBuffer { + const binary = atob(value); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return bytes.buffer; +} diff --git a/cloudflare/src/sync_snapshot_head.ts b/cloudflare/src/sync_snapshot_head.ts new file mode 100644 index 0000000..926e6af --- /dev/null +++ b/cloudflare/src/sync_snapshot_head.ts @@ -0,0 +1,264 @@ +import type { ElyD1DatabaseSession, ElyD1Result } from "./bindings.js"; +import { StorageObjectError, assertKnownObjectKeyHash } from "./storage.js"; +import { + SyncSnapshotRequestError, + assertOnlyFields, + deviceIdValue, + integer, + sha256HexValue, + snapshotIdValue, + text, +} from "./sync_snapshot_codec.js"; +import { + SYNC_SNAPSHOT_BY_TOKEN_QUERY, + SYNC_SNAPSHOT_HEAD_QUERY, +} from "./sync_snapshot_sql.js"; + +const MAX_SNAPSHOT_BYTES = 10 * 1024 * 1024; + +export interface SnapshotHeadRefDocument { + revision: number; + snapshot_id: string; + payload_hash: string; +} + +export interface SyncSnapshotDocument { + snapshot_id: string; + r2_key: string; + payload_hash: string; + encryption_version: 1 | 2; + vault_generation: number; + key_id: string; + content_hash: string; + schema_rev: number; + logical_clock: number; + head_revision: number; + base_head: SnapshotHeadRefDocument | null; + device_id: string; + size_bytes: number; + created_at: number; +} + +export interface SyncSnapshotRow { + snapshot_id: unknown; + r2_key: unknown; + payload_hash: unknown; + encryption_version: unknown; + vault_generation: unknown; + key_id: unknown; + content_hash: unknown; + schema_rev: unknown; + logical_clock: unknown; + head_revision: unknown; + base_head_revision: unknown; + base_snapshot_id: unknown; + base_payload_hash: unknown; + device_id: unknown; + size_bytes: unknown; + created_at: unknown; +} + +export interface SyncSnapshotHeadConflictDocument { + version: 1; + error: "sync_snapshot_head_conflict"; + current_head: SyncSnapshotDocument | null; +} + +export class SyncSnapshotHeadSchemaError extends Error { + constructor(message: string) { + super(message); + this.name = "SyncSnapshotHeadSchemaError"; + } +} + +export class SyncSnapshotConflictError extends Error { + readonly currentHead: SyncSnapshotDocument | null; + + constructor(message: string, currentHead: SyncSnapshotDocument | null) { + super(message); + this.name = "SyncSnapshotConflictError"; + this.currentHead = currentHead; + } + + document(): SyncSnapshotHeadConflictDocument { + return { + version: 1, + error: "sync_snapshot_head_conflict", + current_head: this.currentHead, + }; + } +} + +export function snapshotHeadRefValue( + value: unknown, + headRevision: number, +): SnapshotHeadRefDocument | null { + if (value === null) { + if (headRevision !== 1) { + throw new SyncSnapshotRequestError("base_head_invalid"); + } + return null; + } + const row = record(value, "base_head"); + assertOnlyFields(row, ["revision", "snapshot_id", "payload_hash"]); + const revision = integer(row.revision, "base_head.revision", 1, Number.MAX_SAFE_INTEGER); + if (revision >= Number.MAX_SAFE_INTEGER || revision + 1 !== headRevision) { + throw new SyncSnapshotRequestError("base_head_invalid"); + } + return { + revision, + snapshot_id: snapshotIdValue(row.snapshot_id), + payload_hash: sha256HexValue(row.payload_hash, "base_head.payload_hash"), + }; +} + +export function snapshotDocumentFromRow(row: SyncSnapshotRow): SyncSnapshotDocument { + try { + const headRevision = integer( + row.head_revision, + "head_revision", + 1, + Number.MAX_SAFE_INTEGER, + ); + const payloadHash = sha256HexValue(row.payload_hash, "payload_hash"); + return { + snapshot_id: snapshotIdValue(row.snapshot_id), + r2_key: storedR2Key(row.r2_key, payloadHash), + payload_hash: payloadHash, + encryption_version: storedEncryptionVersion(row.encryption_version), + vault_generation: integer( + row.vault_generation, + "vault_generation", + 1, + Number.MAX_SAFE_INTEGER, + ), + key_id: sha256HexValue(row.key_id, "key_id"), + content_hash: sha256HexValue(row.content_hash, "content_hash"), + schema_rev: integer(row.schema_rev, "schema_rev", 1, Number.MAX_SAFE_INTEGER), + logical_clock: integer( + row.logical_clock, + "logical_clock", + 0, + Number.MAX_SAFE_INTEGER, + ), + head_revision: headRevision, + base_head: storedBaseHead(row, headRevision), + device_id: deviceIdValue(row.device_id), + size_bytes: integer(row.size_bytes, "size_bytes", 1, MAX_SNAPSHOT_BYTES), + created_at: integer(row.created_at, "created_at", 0, Number.MAX_SAFE_INTEGER), + }; + } catch (error) { + if (error instanceof SyncSnapshotRequestError) { + throw new SyncSnapshotHeadSchemaError(error.message); + } + throw error; + } +} + +export function snapshotDocumentFromResult( + result: ElyD1Result | undefined, +): SyncSnapshotDocument { + const row = result?.results.length === 1 ? result.results[0] : undefined; + if (row === undefined) { + throw new SyncSnapshotHeadSchemaError("sync_snapshot_missing"); + } + return snapshotDocumentFromRow(row); +} + +export function snapshotHeadRef(snapshot: SyncSnapshotDocument): SnapshotHeadRefDocument { + return { + revision: snapshot.head_revision, + snapshot_id: snapshot.snapshot_id, + payload_hash: snapshot.payload_hash, + }; +} + +export function sameSnapshotHead( + left: SnapshotHeadRefDocument | null, + right: SnapshotHeadRefDocument | null, +): boolean { + if (left === null || right === null) { + return left === right; + } + return left.revision === right.revision && + left.snapshot_id === right.snapshot_id && + left.payload_hash === right.payload_hash; +} + +export async function currentSyncSnapshotHead( + database: ElyD1DatabaseSession, + userId: string, +): Promise { + const row = await database.prepare(SYNC_SNAPSHOT_HEAD_QUERY) + .bind(userId) + .first(); + return row === null ? null : snapshotDocumentFromRow(row); +} + +export async function syncSnapshotByToken( + database: ElyD1DatabaseSession, + userId: string, + head: SnapshotHeadRefDocument, +): Promise { + const row = await database.prepare(SYNC_SNAPSHOT_BY_TOKEN_QUERY) + .bind(userId, head.snapshot_id, head.revision, head.payload_hash) + .first(); + return row === null ? null : snapshotDocumentFromRow(row); +} + +function storedBaseHead( + row: SyncSnapshotRow, + headRevision: number, +): SnapshotHeadRefDocument | null { + const values = [row.base_head_revision, row.base_snapshot_id, row.base_payload_hash]; + if (values.every((value) => value === null)) { + if (headRevision !== 1) { + throw new SyncSnapshotRequestError("base_head_invalid"); + } + return null; + } + if (values.some((value) => value === null)) { + throw new SyncSnapshotRequestError("base_head_invalid"); + } + const revision = integer( + row.base_head_revision, + "base_head.revision", + 1, + Number.MAX_SAFE_INTEGER, + ); + if (revision >= Number.MAX_SAFE_INTEGER || revision + 1 !== headRevision) { + throw new SyncSnapshotRequestError("base_head_invalid"); + } + return { + revision, + snapshot_id: snapshotIdValue(row.base_snapshot_id), + payload_hash: sha256HexValue(row.base_payload_hash, "base_head.payload_hash"), + }; +} + +function storedEncryptionVersion(value: unknown): 1 | 2 { + if (value !== 1 && value !== 2) { + throw new SyncSnapshotRequestError("encryption_version_invalid"); + } + return value; +} + +function storedR2Key(value: unknown, payloadHash: string): string { + const key = text(value, "r2_key"); + try { + assertKnownObjectKeyHash(key, payloadHash); + } catch (error) { + if (error instanceof StorageObjectError) { + throw new SyncSnapshotRequestError(error.message); + } + throw error; + } + return key; +} + +function record(value: unknown, label: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new SyncSnapshotRequestError(`${label}_invalid`); + } + return value as Record; +} diff --git a/cloudflare/src/sync_snapshot_sql.ts b/cloudflare/src/sync_snapshot_sql.ts new file mode 100644 index 0000000..f71aa4c --- /dev/null +++ b/cloudflare/src/sync_snapshot_sql.ts @@ -0,0 +1,334 @@ +const SNAPSHOT_COLUMNS = ` + snapshots.snapshot_id AS snapshot_id, + snapshots.r2_key AS r2_key, + snapshots.payload_hash AS payload_hash, + encryption.encryption_version AS encryption_version, + encryption.vault_generation AS vault_generation, + encryption.key_id AS key_id, + encryption.content_hash AS content_hash, + snapshots.schema_rev AS schema_rev, + snapshots.logical_clock AS logical_clock, + snapshots.head_revision AS head_revision, + snapshots.base_head_revision AS base_head_revision, + snapshots.base_snapshot_id AS base_snapshot_id, + snapshots.base_payload_hash AS base_payload_hash, + snapshots.device_id AS device_id, + snapshots.size_bytes AS size_bytes, + snapshots.created_at AS created_at +`; + +export const SYNC_SNAPSHOT_HEAD_QUERY = ` + SELECT ${SNAPSHOT_COLUMNS} + FROM sync_snapshot_heads AS head + INNER JOIN sync_snapshots AS snapshots + ON snapshots.user_id = head.user_id + AND snapshots.snapshot_id = head.snapshot_id + AND snapshots.head_revision = head.head_revision + AND snapshots.payload_hash = head.payload_hash + LEFT JOIN sync_snapshot_encryption AS encryption + ON encryption.user_id = snapshots.user_id + AND encryption.snapshot_id = snapshots.snapshot_id + WHERE head.user_id = ? +`; + +export const SYNC_SNAPSHOT_BY_TOKEN_QUERY = ` + SELECT ${SNAPSHOT_COLUMNS} + FROM sync_snapshot_heads AS head + INNER JOIN sync_snapshots AS snapshots + ON snapshots.user_id = head.user_id + AND snapshots.snapshot_id = head.snapshot_id + AND snapshots.head_revision = head.head_revision + AND snapshots.payload_hash = head.payload_hash + INNER JOIN sync_snapshot_encryption AS encryption + ON encryption.user_id = snapshots.user_id + AND encryption.snapshot_id = snapshots.snapshot_id + WHERE head.user_id = ? + AND head.snapshot_id = ? + AND head.head_revision = ? + AND head.payload_hash = ? + AND encryption.encryption_version IN (1, 2) +`; + +export const SYNC_SNAPSHOT_CANDIDATE_UPSERT_QUERY = ` + WITH candidate ( + user_id, snapshot_id, r2_key, payload_hash, schema_rev, + logical_clock, device_id, size_bytes, created_at, head_revision, + base_head_revision, base_snapshot_id, base_payload_hash, key_id, vault_generation, + write_token, lease_now + ) AS (VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)) + INSERT INTO sync_snapshots ( + user_id, snapshot_id, r2_key, payload_hash, schema_rev, + logical_clock, device_id, size_bytes, created_at, head_revision, + base_head_revision, base_snapshot_id, base_payload_hash + ) + SELECT + user_id, snapshot_id, r2_key, payload_hash, schema_rev, + logical_clock, device_id, size_bytes, created_at, head_revision, + base_head_revision, base_snapshot_id, base_payload_hash + FROM candidate + WHERE ( + ( + candidate.head_revision = 1 + AND candidate.base_head_revision IS NULL + AND candidate.base_snapshot_id IS NULL + AND candidate.base_payload_hash IS NULL + AND NOT EXISTS ( + SELECT 1 FROM sync_snapshot_heads AS head + WHERE head.user_id = candidate.user_id + ) + ) + OR + ( + candidate.base_head_revision IS NOT NULL + AND candidate.base_snapshot_id IS NOT NULL + AND candidate.base_payload_hash IS NOT NULL + AND candidate.head_revision = candidate.base_head_revision + 1 + AND EXISTS ( + SELECT 1 + FROM sync_snapshot_heads AS head + INNER JOIN sync_snapshots AS base + ON base.user_id = head.user_id + AND base.snapshot_id = head.snapshot_id + AND base.payload_hash = head.payload_hash + WHERE head.user_id = candidate.user_id + AND head.head_revision = candidate.base_head_revision + AND head.snapshot_id = candidate.base_snapshot_id + AND head.payload_hash = candidate.base_payload_hash + AND candidate.logical_clock > base.logical_clock + ) + ) + ) + AND EXISTS ( + SELECT 1 FROM sync_vault_accounts AS account + WHERE account.user_id = candidate.user_id + AND account.current_key_id = candidate.key_id + AND account.current_generation = candidate.vault_generation + ) + AND EXISTS ( + SELECT 1 FROM sync_r2_gc_candidates AS ledger + WHERE ledger.r2_key = candidate.r2_key + AND ledger.user_id = candidate.user_id + AND ledger.object_kind = 'snapshot' + AND ledger.state = 'pending' + AND ledger.write_token = candidate.write_token + AND ledger.lease_expires_at >= candidate.lease_now + ) + AND EXISTS ( + SELECT 1 + FROM user_devices AS device + INNER JOIN user_device_keys AS keys + ON keys.user_id = device.user_id + AND keys.device_id = device.device_id + WHERE device.user_id = candidate.user_id + AND device.device_id = candidate.device_id + AND device.approval_status = 'approved' + AND device.revoked_at IS NULL + AND keys.key_protocol_version = 2 + AND keys.wrapping_public_key IS NOT NULL + ) + AND NOT EXISTS ( + SELECT 1 + FROM sync_vault_rotation_r2_objects AS staged + INNER JOIN sync_vault_rotations AS rotation + ON rotation.user_id = staged.user_id + AND rotation.idempotency_key = staged.rotation_idempotency_key + WHERE staged.user_id = candidate.user_id + AND staged.r2_key = candidate.r2_key + AND rotation.cleanup_started_at IS NOT NULL + ) + ON CONFLICT(user_id, snapshot_id) DO UPDATE SET + r2_key = excluded.r2_key, + payload_hash = excluded.payload_hash, + schema_rev = excluded.schema_rev, + logical_clock = excluded.logical_clock, + device_id = excluded.device_id, + size_bytes = excluded.size_bytes, + created_at = excluded.created_at, + head_revision = excluded.head_revision, + base_head_revision = excluded.base_head_revision, + base_snapshot_id = excluded.base_snapshot_id, + base_payload_hash = excluded.base_payload_hash +`; + +export const SYNC_SNAPSHOT_ENCRYPTION_UPSERT_QUERY = ` + WITH candidate ( + user_id, snapshot_id, r2_key, payload_hash, schema_rev, + logical_clock, device_id, size_bytes, created_at, head_revision, + base_head_revision, base_snapshot_id, base_payload_hash, + encryption_version, vault_generation, key_id, content_hash, write_token, lease_now + ) AS (VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)) + INSERT INTO sync_snapshot_encryption ( + user_id, snapshot_id, encryption_version, vault_generation, key_id, content_hash + ) + SELECT + candidate.user_id, + candidate.snapshot_id, + candidate.encryption_version, + candidate.vault_generation, + candidate.key_id, + candidate.content_hash + FROM candidate + INNER JOIN sync_snapshots AS snapshot + ON snapshot.user_id = candidate.user_id + AND snapshot.snapshot_id = candidate.snapshot_id + AND snapshot.r2_key = candidate.r2_key + AND snapshot.payload_hash = candidate.payload_hash + AND snapshot.schema_rev = candidate.schema_rev + AND snapshot.logical_clock = candidate.logical_clock + AND snapshot.device_id = candidate.device_id + AND snapshot.size_bytes = candidate.size_bytes + AND snapshot.created_at = candidate.created_at + AND snapshot.head_revision = candidate.head_revision + AND snapshot.base_head_revision IS candidate.base_head_revision + AND snapshot.base_snapshot_id IS candidate.base_snapshot_id + AND snapshot.base_payload_hash IS candidate.base_payload_hash + INNER JOIN sync_vault_accounts AS account + ON account.user_id = candidate.user_id + AND account.current_key_id = candidate.key_id + AND account.current_generation = candidate.vault_generation + INNER JOIN user_devices AS device + ON device.user_id = candidate.user_id + AND device.device_id = candidate.device_id + AND device.approval_status = 'approved' + AND device.revoked_at IS NULL + INNER JOIN user_device_keys AS keys + ON keys.user_id = device.user_id + AND keys.device_id = device.device_id + AND keys.key_protocol_version = 2 + AND keys.wrapping_public_key IS NOT NULL + WHERE candidate.encryption_version = 2 + AND EXISTS ( + SELECT 1 FROM sync_r2_gc_candidates AS ledger + WHERE ledger.r2_key = candidate.r2_key + AND ledger.user_id = candidate.user_id + AND ledger.object_kind = 'snapshot' + AND ledger.state = 'pending' + AND ledger.write_token = candidate.write_token + AND ledger.lease_expires_at >= candidate.lease_now + ) + AND NOT EXISTS ( + SELECT 1 + FROM sync_vault_rotation_r2_objects AS staged + INNER JOIN sync_vault_rotations AS rotation + ON rotation.user_id = staged.user_id + AND rotation.idempotency_key = staged.rotation_idempotency_key + WHERE staged.user_id = candidate.user_id + AND staged.r2_key = candidate.r2_key + AND rotation.cleanup_started_at IS NOT NULL + ) + ON CONFLICT(user_id, snapshot_id) DO UPDATE SET + encryption_version = excluded.encryption_version, + vault_generation = excluded.vault_generation, + key_id = excluded.key_id, + content_hash = excluded.content_hash +`; + +export const SYNC_SNAPSHOT_HEAD_INSERT_QUERY = ` + INSERT INTO sync_snapshot_heads ( + user_id, head_revision, snapshot_id, payload_hash, updated_at + ) + SELECT ?, ?, ?, ?, ? + WHERE EXISTS ( + SELECT 1 FROM sync_r2_gc_candidates + WHERE r2_key = ? AND user_id = ? AND object_kind = 'snapshot' + AND state = 'pending' AND write_token = ? AND lease_expires_at >= ? + ) +`; + +export const SYNC_SNAPSHOT_HEAD_UPDATE_QUERY = ` + UPDATE sync_snapshot_heads + SET head_revision = ?, snapshot_id = ?, payload_hash = ?, updated_at = ? + WHERE user_id = ? + AND EXISTS ( + SELECT 1 FROM sync_r2_gc_candidates + WHERE r2_key = ? AND user_id = ? AND object_kind = 'snapshot' + AND state = 'pending' AND write_token = ? AND lease_expires_at >= ? + ) +`; + +export function syncSnapshotStatements( + database: ElyD1DatabaseSession, + userId: string, + deviceId: string, + upload: SyncSnapshotWrite, + writeLease: SyncR2WriteLease, + nowSeconds: number, +): ElyD1PreparedStatement[] { + const snapshotValues = [ + userId, + upload.snapshotId, + upload.r2Key, + upload.payloadHash, + upload.schemaRev, + upload.logicalClock, + deviceId, + upload.sizeBytes, + nowSeconds, + upload.headRevision, + upload.baseHead?.revision ?? null, + upload.baseHead?.snapshot_id ?? null, + upload.baseHead?.payload_hash ?? null, + ]; + const headStatement = upload.baseHead === null + ? database.prepare(SYNC_SNAPSHOT_HEAD_INSERT_QUERY).bind( + userId, + upload.headRevision, + upload.snapshotId, + upload.payloadHash, + nowSeconds, + upload.r2Key, + userId, + writeLease.writeToken, + nowSeconds, + ) + : database.prepare(SYNC_SNAPSHOT_HEAD_UPDATE_QUERY).bind( + upload.headRevision, + upload.snapshotId, + upload.payloadHash, + nowSeconds, + userId, + upload.r2Key, + userId, + writeLease.writeToken, + nowSeconds, + ); + return [ + database.prepare(SYNC_SNAPSHOT_CANDIDATE_UPSERT_QUERY).bind( + ...snapshotValues, + upload.keyId, + upload.vaultGeneration, + writeLease.writeToken, + nowSeconds, + ), + database.prepare(SYNC_SNAPSHOT_ENCRYPTION_UPSERT_QUERY).bind( + ...snapshotValues, + upload.encryptionVersion, + upload.vaultGeneration, + upload.keyId, + upload.contentHash, + writeLease.writeToken, + nowSeconds, + ), + headStatement, + syncR2MarkReferencedStatement(database, userId, upload.r2Key, writeLease, nowSeconds), + database.prepare(SYNC_SNAPSHOT_HEAD_QUERY).bind(userId), + ]; +} +import type { ElyD1DatabaseSession, ElyD1PreparedStatement } from "./bindings.js"; +import { type SyncR2WriteLease, syncR2MarkReferencedStatement } from "./sync_r2_gc.js"; +import type { SnapshotHeadRefDocument } from "./sync_snapshot_head.js"; + +export interface SyncSnapshotWrite { + snapshotId: string; + r2Key: string; + payloadHash: string; + encryptionVersion: 2; + vaultGeneration: number; + keyId: string; + contentHash: string; + schemaRev: number; + logicalClock: number; + headRevision: number; + baseHead: SnapshotHeadRefDocument | null; + sizeBytes: number; +} diff --git a/cloudflare/src/sync_snapshot_write.ts b/cloudflare/src/sync_snapshot_write.ts new file mode 100644 index 0000000..8087972 --- /dev/null +++ b/cloudflare/src/sync_snapshot_write.ts @@ -0,0 +1,122 @@ +import type { ElyD1DatabaseSession, Env } from "./bindings.js"; +import { + type SyncR2WriteLease, + abandonSyncR2Write, + claimSyncR2SnapshotWrite, + collectSyncR2Garbage, +} from "./sync_r2_gc.js"; +import { + SyncSnapshotRequestError, + arrayBufferFromBytes, + sha256Hex, +} from "./sync_snapshot_codec.js"; +import type { SnapshotHeadRefDocument } from "./sync_snapshot_head.js"; +import { StorageObjectError, putVerifiedObject, syncSnapshotKey } from "./storage.js"; + +interface SnapshotStorageWrite { + r2Key: string; + payloadHash: string; + keyId: string; + vaultGeneration: number; + headRevision: number; + baseHead: SnapshotHeadRefDocument | null; + bytes: ArrayBuffer; +} + +export type { SyncR2WriteLease } from "./sync_r2_gc.js"; + +export function claimSnapshotStorageWrite( + env: Env, + database: ElyD1DatabaseSession, + userId: string, + deviceId: string, + upload: SnapshotStorageWrite, + nowSeconds: number, +): Promise { + return syncOwnerHash(userId).then((ownerHash) => claimSyncR2SnapshotWrite( + env, + { + userId, + deviceId, + r2Key: upload.r2Key, + ownerHash, + keyId: upload.keyId, + generation: upload.vaultGeneration, + headRevision: upload.headRevision, + baseHead: upload.baseHead === null ? null : { + revision: upload.baseHead.revision, + snapshotId: upload.baseHead.snapshot_id, + payloadHash: upload.baseHead.payload_hash, + }, + }, + nowSeconds, + undefined, + database, + )); +} + +export async function persistClaimedSnapshot( + env: Env, + upload: SnapshotStorageWrite, +): Promise { + try { + await putVerifiedObject( + env.ELY_STORAGE, + upload.r2Key, + upload.bytes, + upload.payloadHash, + "application/octet-stream", + ); + } catch (error) { + if (error instanceof StorageObjectError) throw new SyncSnapshotRequestError(error.message); + throw error; + } +} + +export async function releaseFailedSnapshotWrite( + env: Env, + database: ElyD1DatabaseSession, + userId: string, + r2Key: string, + lease: SyncR2WriteLease, + nowSeconds: number, +): Promise { + const ownerHash = await syncOwnerHash(userId); + await abandonSyncR2Write( + env, + userId, + ownerHash, + r2Key, + lease.writeToken, + nowSeconds, + database, + ); + try { + await collectSyncR2Garbage(env, nowSeconds, { ownerHash, limit: 5, database }); + } catch { + // The durable candidate remains available to scheduled GC. + } +} + +export async function snapshotStorageKey( + region: string, + userId: string, + snapshotId: string, + payloadHash: string, +): Promise { + try { + return syncSnapshotKey({ + region, + userHash: await syncOwnerHash(userId), + snapshotId, + payloadHash, + }); + } catch (error) { + if (error instanceof StorageObjectError) throw new SyncSnapshotRequestError(error.message); + throw error; + } +} + +function syncOwnerHash(userId: string): Promise { + return sha256Hex(arrayBufferFromBytes(new TextEncoder().encode(userId))); +} diff --git a/cloudflare/src/sync_status.ts b/cloudflare/src/sync_status.ts index cb2adbf..abdddfd 100644 --- a/cloudflare/src/sync_status.ts +++ b/cloudflare/src/sync_status.ts @@ -1,6 +1,13 @@ import type { AuthContext } from "./auth.js"; -import type { Env } from "./bindings.js"; +import type { ElyD1Result, Env } from "./bindings.js"; +import { primaryD1Session } from "./bindings.js"; import { StorageObjectError, assertSyncObjectType } from "./storage.js"; +import type { SnapshotHeadRefDocument, SyncSnapshotRow } from "./sync_snapshot_head.js"; +import { + SyncSnapshotHeadSchemaError, + snapshotDocumentFromRow, +} from "./sync_snapshot_head.js"; +import { SYNC_SNAPSHOT_HEAD_QUERY } from "./sync_snapshot_sql.js"; const CHANGE_CURSOR_QUERY = ` SELECT @@ -23,21 +30,11 @@ const OBJECT_STATUS_QUERY = ` `; const SNAPSHOT_COUNT_QUERY = ` SELECT COUNT(*) AS total_snapshots - FROM sync_snapshots - WHERE user_id = ? -`; -const LATEST_SNAPSHOT_QUERY = ` - SELECT - snapshot_id, - payload_hash, - logical_clock, - device_id, - size_bytes, - created_at - FROM sync_snapshots - WHERE user_id = ? - ORDER BY created_at DESC, snapshot_id ASC - LIMIT 1 + FROM sync_snapshots AS snapshots + INNER JOIN sync_snapshot_encryption AS encryption + ON encryption.user_id = snapshots.user_id + AND encryption.snapshot_id = snapshots.snapshot_id + WHERE snapshots.user_id = ? AND encryption.encryption_version IN (1, 2) `; const APPROVED_DEVICE_COUNT_QUERY = ` SELECT COUNT(*) AS approved_devices @@ -45,12 +42,8 @@ const APPROVED_DEVICE_COUNT_QUERY = ` WHERE user_id = ? AND approval_status = 'approved' AND revoked_at IS NULL `; -const SNAPSHOT_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,127}$/; -const DEVICE_ID_PATTERN = /^[a-zA-Z0-9._:-]{3,128}$/; -const SHA256_HEX = /^[a-f0-9]{64}$/; - export interface SyncStatusDocument { - version: 1; + version: 2; user_id: string; device_id: string; cursor: SyncCursorStatusDocument; @@ -74,13 +67,19 @@ export interface SyncObjectStatusDocument { export interface SyncSnapshotStatusDocument { total_snapshots: number; - latest: SyncLatestSnapshotDocument | null; + head: SyncSnapshotHeadStatusDocument | null; } -export interface SyncLatestSnapshotDocument { +export interface SyncSnapshotHeadStatusDocument { snapshot_id: string; payload_hash: string; + encryption_version: 1 | 2; + vault_generation: number; + key_id: string; + content_hash: string; logical_clock: number; + head_revision: number; + base_head: SnapshotHeadRefDocument | null; device_id: string; size_bytes: number; created_at: number; @@ -109,15 +108,6 @@ interface SnapshotCountRow { total_snapshots: unknown; } -interface LatestSnapshotRow { - snapshot_id: unknown; - payload_hash: unknown; - logical_clock: unknown; - device_id: unknown; - size_bytes: unknown; - created_at: unknown; -} - interface DeviceStatusRow { approved_devices: unknown; } @@ -134,33 +124,60 @@ export async function syncStatusDocument( context: AuthContext, ): Promise { const deviceId = currentDeviceId(context); - const cursorRow = await env.ELY_DB.prepare(CHANGE_CURSOR_QUERY) - .bind(context.userId) - .first(); - const objectRows = await env.ELY_DB.prepare(OBJECT_STATUS_QUERY) - .bind(context.userId) - .all(); - const snapshotCountRow = await env.ELY_DB.prepare(SNAPSHOT_COUNT_QUERY) - .bind(context.userId) - .first(); - const latestSnapshotRow = await env.ELY_DB.prepare(LATEST_SNAPSHOT_QUERY) - .bind(context.userId) - .first(); - const deviceStatusRow = await env.ELY_DB.prepare(APPROVED_DEVICE_COUNT_QUERY) - .bind(context.userId) - .first(); + const database = primaryD1Session(env.ELY_DB); + const results = await database.batch([ + database.prepare(CHANGE_CURSOR_QUERY).bind(context.userId), + database.prepare(OBJECT_STATUS_QUERY).bind(context.userId), + database.prepare(SNAPSHOT_COUNT_QUERY).bind(context.userId), + database.prepare(SYNC_SNAPSHOT_HEAD_QUERY).bind(context.userId), + database.prepare(APPROVED_DEVICE_COUNT_QUERY).bind(context.userId), + ]); + if (results.length !== 5) { + throw new SyncStatusSchemaError("sync_status_batch_invalid"); + } + const cursorRow = oneRow(results[0], "sync_cursor_status_missing"); + const objectRows = rows(results[1]); + const snapshotCountRow = oneRow( + results[2], + "sync_snapshot_status_missing", + ); + const snapshotHeadRow = optionalRow(results[3]); + const deviceStatusRow = oneRow(results[4], "sync_device_status_missing"); return { - version: 1, + version: 2, user_id: context.userId, device_id: deviceId, cursor: cursorStatus(cursorRow), - objects: objectRows.results.map(objectStatus), - snapshots: snapshotStatus(snapshotCountRow, latestSnapshotRow), + objects: objectRows.map(objectStatus), + snapshots: snapshotStatus(snapshotCountRow, snapshotHeadRow), devices: deviceStatus(deviceStatusRow, deviceId), }; } +function rows(result: ElyD1Result | undefined): T[] { + if (result === undefined || !Array.isArray(result.results)) { + throw new SyncStatusSchemaError("sync_status_batch_invalid"); + } + return result.results as T[]; +} + +function oneRow(result: ElyD1Result | undefined, message: string): T { + const values = rows(result); + if (values.length !== 1) { + throw new SyncStatusSchemaError(message); + } + return values[0] as T; +} + +function optionalRow(result: ElyD1Result | undefined): T | null { + const values = rows(result); + if (values.length > 1) { + throw new SyncStatusSchemaError("sync_snapshot_head_rows_invalid"); + } + return values[0] ?? null; +} + function cursorStatus(row: ChangeCursorRow | null): SyncCursorStatusDocument { if (row === null) { throw new SyncStatusSchemaError("sync_cursor_status_missing"); @@ -183,26 +200,44 @@ function objectStatus(row: ObjectStatusRow): SyncObjectStatusDocument { function snapshotStatus( countRow: SnapshotCountRow | null, - latestRow: LatestSnapshotRow | null, + headRow: SyncSnapshotRow | null, ): SyncSnapshotStatusDocument { if (countRow === null) { throw new SyncStatusSchemaError("sync_snapshot_status_missing"); } + const totalSnapshots = integer(countRow.total_snapshots, "total_snapshots"); + if ((totalSnapshots === 0) !== (headRow === null)) { + throw new SyncStatusSchemaError("sync_snapshot_head_missing"); + } return { - total_snapshots: integer(countRow.total_snapshots, "total_snapshots"), - latest: latestRow === null ? null : latestSnapshot(latestRow), + total_snapshots: totalSnapshots, + head: headRow === null ? null : snapshotHeadStatus(headRow), }; } -function latestSnapshot(row: LatestSnapshotRow): SyncLatestSnapshotDocument { - return { - snapshot_id: snapshotId(row.snapshot_id), - payload_hash: payloadHash(row.payload_hash), - logical_clock: integer(row.logical_clock, "logical_clock"), - device_id: deviceId(row.device_id), - size_bytes: integer(row.size_bytes, "size_bytes"), - created_at: integer(row.created_at, "created_at"), - }; +function snapshotHeadStatus(row: SyncSnapshotRow): SyncSnapshotHeadStatusDocument { + try { + const snapshot = snapshotDocumentFromRow(row); + return { + snapshot_id: snapshot.snapshot_id, + payload_hash: snapshot.payload_hash, + encryption_version: snapshot.encryption_version, + vault_generation: snapshot.vault_generation, + key_id: snapshot.key_id, + content_hash: snapshot.content_hash, + logical_clock: snapshot.logical_clock, + head_revision: snapshot.head_revision, + base_head: snapshot.base_head, + device_id: snapshot.device_id, + size_bytes: snapshot.size_bytes, + created_at: snapshot.created_at, + }; + } catch (error) { + if (error instanceof SyncSnapshotHeadSchemaError) { + throw new SyncStatusSchemaError(error.message); + } + throw error; + } } function deviceStatus( @@ -241,27 +276,6 @@ function objectType(value: unknown): string { return value; } -function snapshotId(value: unknown): string { - if (typeof value !== "string" || !SNAPSHOT_ID_PATTERN.test(value)) { - throw new SyncStatusSchemaError("snapshot_id_invalid"); - } - return value; -} - -function deviceId(value: unknown): string { - if (typeof value !== "string" || !DEVICE_ID_PATTERN.test(value)) { - throw new SyncStatusSchemaError("device_id_invalid"); - } - return value; -} - -function payloadHash(value: unknown): string { - if (typeof value !== "string" || !SHA256_HEX.test(value)) { - throw new SyncStatusSchemaError("payload_hash_invalid"); - } - return value; -} - function integer(value: unknown, label: string): number { if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) { throw new SyncStatusSchemaError(`${label}_invalid`); diff --git a/cloudflare/src/sync_vault.ts b/cloudflare/src/sync_vault.ts new file mode 100644 index 0000000..cddd413 --- /dev/null +++ b/cloudflare/src/sync_vault.ts @@ -0,0 +1,499 @@ +import type { AuthContext } from "./auth.js"; +import type { ElyD1DatabaseSession, ElyD1PreparedStatement, Env } from "./bindings.js"; +import { syncVaultBootstrapProofValid } from "./sync_vault_bootstrap_proof.js"; +import { + CURRENT_DEVICE_ENVELOPE_QUERY, + CURRENT_SYNC_VAULT_KEY_QUERY, + HISTORICAL_DEVICE_ENVELOPE_QUERY, + SYNC_VAULT_ACCOUNT_INSERT_QUERY, + SYNC_VAULT_ENVELOPE_INSERT_QUERY, +} from "./sync_vault_sql.js"; + +const HPKE_SUITE = "HPKE-BASE-X25519-HKDF-SHA256-CHACHA20POLY1305"; +const SHA256_HEX_PATTERN = /^[a-f0-9]{64}$/; +const DEVICE_ID_PATTERN = /^[a-zA-Z0-9._:-]{3,128}$/; +const IDEMPOTENCY_KEY_PATTERN = /^[a-zA-Z0-9._:-]{16,128}$/; +const SIGNATURE_PATTERN = /^[a-f0-9]{128}$/; +const BASE64URL_32_BYTES_PATTERN = /^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/; +const BASE64URL_48_BYTES_PATTERN = /^[A-Za-z0-9_-]{64}$/; + +export interface CurrentSyncVaultKey { + keyId: string; + generation: number; +} + +export interface SyncVaultDocument { + version: 1; + user_id: string; + key_id: string; + generation: number; + recipient_device_id: string; + approver_device_id: string; + envelope: WrappedAccountKeyDocument; + created_at: number; +} + +export interface WrappedAccountKeyDocument { + version: 1; + suite: typeof HPKE_SUITE; + encapped_key: string; + ciphertext: string; +} + +interface SyncVaultBootstrapRequest { + keyId: string; + generation: number; + envelope: WrappedAccountKeyDocument; + idempotencyKey: string; + bootstrapProof: string; +} + +interface SyncVaultEnvelopeLookup { + keyId: string; + generation: number; +} + +interface SyncVaultKeyRow { + key_id: unknown; + generation: unknown; +} + +interface SyncVaultEnvelopeRow extends SyncVaultKeyRow { + recipient_device_id: unknown; + approver_device_id: unknown; + envelope_version: unknown; + suite: unknown; + encapped_key: unknown; + ciphertext: unknown; + idempotency_key: unknown; + created_at: unknown; +} +interface StoredSyncVaultEnvelope { + document: SyncVaultDocument; + idempotencyKey: string; +} +type RequestBody = Record; +export class SyncVaultRequestError extends Error {} +export class SyncVaultPermissionError extends Error {} +export class SyncVaultNotFoundError extends Error {} +export class SyncVaultConflictError extends Error {} +export class SyncVaultPersistenceError extends Error {} +export async function syncVaultBootstrapDocument( + request: Request, + env: Env, + context: AuthContext, + nowSeconds = Math.floor(Date.now() / 1000), +): Promise { + const deviceId = currentDeviceId(context); + const bootstrap = await syncVaultBootstrapRequest(request); + const { bootstrapProof, ...unsignedBootstrap } = bootstrap; + if (!(await syncVaultBootstrapProofValid( + env, + context.userId, + deviceId, + unsignedBootstrap, + bootstrapProof, + ))) { + throw new SyncVaultPermissionError("sync_vault_bootstrap_proof_invalid"); + } + await env.ELY_DB.batch(syncVaultBootstrapStatements(env, context.userId, deviceId, bootstrap, nowSeconds)); + + const stored = await currentDeviceEnvelope(env, context.userId, deviceId); + if (stored === null) { + const currentKey = await currentSyncVaultKey(env, context.userId); + if (currentKey === null) { + throw new SyncVaultPersistenceError("sync_vault_account_missing"); + } + if (currentKey.keyId !== bootstrap.keyId || currentKey.generation !== bootstrap.generation) { + throw new SyncVaultConflictError("sync_vault_key_conflict"); + } + throw new SyncVaultPersistenceError("sync_vault_envelope_missing"); + } + assertBootstrapMatches(stored, bootstrap, deviceId); + return stored.document; +} + +export async function syncVaultCurrentDeviceDocument( + url: URL, + env: Env, + context: AuthContext, +): Promise { + const deviceId = currentDeviceId(context); + const stored = await currentDeviceEnvelope( + env, + context.userId, + deviceId, + syncVaultEnvelopeLookup(url), + ); + if (stored === null) { + throw new SyncVaultNotFoundError("sync_vault_envelope_not_found"); + } + return stored.document; +} + +export async function currentSyncVaultKey( + env: Env, + userId: string, + database: ElyD1DatabaseSession = env.ELY_DB, +): Promise { + const row = await database.prepare(CURRENT_SYNC_VAULT_KEY_QUERY) + .bind(userId) + .first(); + if (row === null) { + return null; + } + return { + keyId: storedKeyId(row.key_id), + generation: storedInteger(row.generation, "generation", 1), + }; +} + +export async function assertCurrentSyncVaultKey( + env: Env, + userId: string, + keyId: string, + generation: number, + database: ElyD1DatabaseSession = env.ELY_DB, +): Promise { + const current = await currentSyncVaultKey(env, userId, database); + if (current === null) { + throw new SyncVaultNotFoundError("sync_vault_not_initialized"); + } + if (current.keyId !== keyId || current.generation !== generation) { + throw new SyncVaultConflictError("sync_vault_key_not_current"); + } +} +export function parseWrappedAccountKey(value: unknown): WrappedAccountKeyDocument { return requestEnvelope(value); } + +export function syncVaultRecipientEnvelopeStatement( + env: Env, + userId: string, + recipientDeviceId: string, + approverDeviceId: string, + keyId: string, + generation: number, + envelope: WrappedAccountKeyDocument, + idempotencyKey: string, + nowSeconds: number, +): ElyD1PreparedStatement { + return syncVaultEnvelopeStatement( + env, + userId, + recipientDeviceId, + approverDeviceId, + keyId, + generation, + envelope, + idempotencyKey, + nowSeconds, + "pending", + ); +} + +function syncVaultBootstrapStatements( + env: Env, + userId: string, + deviceId: string, + bootstrap: SyncVaultBootstrapRequest, + nowSeconds: number, +): ElyD1PreparedStatement[] { + return [ + env.ELY_DB.prepare(SYNC_VAULT_ACCOUNT_INSERT_QUERY).bind( + userId, + bootstrap.keyId, + bootstrap.generation, + nowSeconds, + nowSeconds, + userId, + deviceId, + ), + syncVaultEnvelopeStatement( + env, + userId, + deviceId, + deviceId, + bootstrap.keyId, + bootstrap.generation, + bootstrap.envelope, + bootstrap.idempotencyKey, + nowSeconds, + "approved", + ), + ]; +} + +function syncVaultEnvelopeStatement( + env: Env, + userId: string, + recipientDeviceId: string, + approverDeviceId: string, + keyId: string, + generation: number, + envelope: WrappedAccountKeyDocument, + idempotencyKey: string, + nowSeconds: number, + recipientStatus: "pending" | "approved", +): ElyD1PreparedStatement { + const wrapped = requestEnvelope(envelope); + requestDeviceId(recipientDeviceId, "recipient_device_id"); + requestDeviceId(approverDeviceId, "approver_device_id"); + requestKeyId(keyId); + requestInteger(generation, "generation", 1); + requestIdempotencyKey(idempotencyKey); + requestInteger(nowSeconds, "created_at", 0); + return env.ELY_DB.prepare(SYNC_VAULT_ENVELOPE_INSERT_QUERY).bind( + userId, + recipientDeviceId, + approverDeviceId, + keyId, + generation, + wrapped.version, + wrapped.suite, + wrapped.encapped_key, + wrapped.ciphertext, + idempotencyKey, + nowSeconds, + recipientDeviceId, + recipientStatus, + approverDeviceId, + userId, + keyId, + generation, + ); +} + +async function currentDeviceEnvelope( + env: Env, + userId: string, + deviceId: string, + lookup?: SyncVaultEnvelopeLookup, +): Promise { + const statement = lookup === undefined + ? env.ELY_DB.prepare(CURRENT_DEVICE_ENVELOPE_QUERY).bind(userId, deviceId) + : env.ELY_DB.prepare(HISTORICAL_DEVICE_ENVELOPE_QUERY) + .bind(userId, deviceId, lookup.keyId, lookup.generation); + const row = await statement.first(); + if (row === null) { + return null; + } + const recipientDeviceId = storedDeviceId(row.recipient_device_id, "recipient_device_id"); + if (recipientDeviceId !== deviceId) { + throw new SyncVaultPersistenceError("recipient_device_id_mismatch"); + } + return { + document: { + version: 1, + user_id: userId, + key_id: storedKeyId(row.key_id), + generation: storedInteger(row.generation, "generation", 1), + recipient_device_id: recipientDeviceId, + approver_device_id: storedDeviceId(row.approver_device_id, "approver_device_id"), + envelope: storedEnvelope(row), + created_at: storedInteger(row.created_at, "created_at", 0), + }, + idempotencyKey: storedIdempotencyKey(row.idempotency_key), + }; +} + +function syncVaultEnvelopeLookup(url: URL): SyncVaultEnvelopeLookup | undefined { + if ([...url.searchParams].length === 0) { + return undefined; + } + for (const field of url.searchParams.keys()) { + if (field !== "key_id" && field !== "generation") { + throw new SyncVaultRequestError(`unexpected_query:${field}`); + } + } + const keyIds = url.searchParams.getAll("key_id"); + const generations = url.searchParams.getAll("generation"); + if (keyIds.length !== 1 || generations.length !== 1) { + throw new SyncVaultRequestError("vault_query_pair_required"); + } + if (!/^[1-9][0-9]*$/.test(generations[0] ?? "")) { + throw new SyncVaultRequestError("generation_invalid"); + } + return { + keyId: requestKeyId(keyIds[0]), + generation: requestInteger(Number(generations[0]), "generation", 1), + }; +} + +async function syncVaultBootstrapRequest(request: Request): Promise { + const body = await requestBody(request); + assertOnlyFields(body, [ + "version", + "key_id", + "generation", + "envelope", + "idempotency_key", + "bootstrap_proof", + ]); + if (body.version !== 2) { + throw new SyncVaultRequestError("version_invalid"); + } + if (body.generation !== 1) { + throw new SyncVaultRequestError("generation_invalid"); + } + return { + keyId: requestKeyId(body.key_id), + generation: 1, + envelope: requestEnvelope(body.envelope), + idempotencyKey: requestIdempotencyKey(body.idempotency_key), + bootstrapProof: requestSignature(body.bootstrap_proof), + }; +} + +function requestEnvelope(value: unknown): WrappedAccountKeyDocument { + const envelope = record(value, "envelope"); + assertOnlyFields(envelope, ["version", "suite", "encapped_key", "ciphertext"]); + if (envelope.version !== 1 || envelope.suite !== HPKE_SUITE) { + throw new SyncVaultRequestError("envelope_metadata_invalid"); + } + if (typeof envelope.encapped_key !== "string" || !BASE64URL_32_BYTES_PATTERN.test(envelope.encapped_key)) { + throw new SyncVaultRequestError("encapped_key_invalid"); + } + if (typeof envelope.ciphertext !== "string" || !BASE64URL_48_BYTES_PATTERN.test(envelope.ciphertext)) { + throw new SyncVaultRequestError("ciphertext_invalid"); + } + return { + version: 1, + suite: HPKE_SUITE, + encapped_key: envelope.encapped_key, + ciphertext: envelope.ciphertext, + }; +} + +function storedEnvelope(row: SyncVaultEnvelopeRow): WrappedAccountKeyDocument { + if (row.envelope_version !== 1 || row.suite !== HPKE_SUITE) { + throw new SyncVaultPersistenceError("envelope_metadata_invalid"); + } + if (typeof row.encapped_key !== "string" || !BASE64URL_32_BYTES_PATTERN.test(row.encapped_key)) { + throw new SyncVaultPersistenceError("encapped_key_invalid"); + } + if (typeof row.ciphertext !== "string" || !BASE64URL_48_BYTES_PATTERN.test(row.ciphertext)) { + throw new SyncVaultPersistenceError("ciphertext_invalid"); + } + return { + version: 1, + suite: HPKE_SUITE, + encapped_key: row.encapped_key, + ciphertext: row.ciphertext, + }; +} + +function assertBootstrapMatches( + stored: StoredSyncVaultEnvelope, + bootstrap: SyncVaultBootstrapRequest, + deviceId: string, +): void { + const { document } = stored; + if ( + document.key_id !== bootstrap.keyId || + document.generation !== bootstrap.generation || + document.recipient_device_id !== deviceId || + document.approver_device_id !== deviceId || + document.envelope.version !== bootstrap.envelope.version || + document.envelope.suite !== bootstrap.envelope.suite || + document.envelope.encapped_key !== bootstrap.envelope.encapped_key || + document.envelope.ciphertext !== bootstrap.envelope.ciphertext || + stored.idempotencyKey !== bootstrap.idempotencyKey + ) { + throw new SyncVaultConflictError("sync_vault_bootstrap_replay_mismatch"); + } +} + +async function requestBody(request: Request): Promise { + let value: unknown; + try { + value = await request.json(); + } catch { + throw new SyncVaultRequestError("json_invalid"); + } + return record(value, "body"); +} + +function record(value: unknown, label: string): RequestBody { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new SyncVaultRequestError(`${label}_invalid`); + } + return value as RequestBody; +} + +function assertOnlyFields(value: RequestBody, fields: string[]): void { + const allowed = new Set(fields); + for (const field of Object.keys(value)) { + if (!allowed.has(field)) { + throw new SyncVaultRequestError(`unexpected_field:${field}`); + } + } +} + +function currentDeviceId(context: AuthContext): string { + if (context.deviceId === undefined) { + throw new SyncVaultRequestError("device_context_required"); + } + return context.deviceId; +} + +function requestKeyId(value: unknown): string { + if (typeof value !== "string" || !SHA256_HEX_PATTERN.test(value)) { + throw new SyncVaultRequestError("key_id_invalid"); + } + return value; +} + +function storedKeyId(value: unknown): string { + if (typeof value !== "string" || !SHA256_HEX_PATTERN.test(value)) { + throw new SyncVaultPersistenceError("key_id_invalid"); + } + return value; +} + +function requestIdempotencyKey(value: unknown): string { + if (typeof value !== "string" || !IDEMPOTENCY_KEY_PATTERN.test(value)) { + throw new SyncVaultRequestError("idempotency_key_invalid"); + } + return value; +} + +function requestSignature(value: unknown): string { + if (typeof value !== "string" || !SIGNATURE_PATTERN.test(value)) { + throw new SyncVaultRequestError("bootstrap_proof_invalid"); + } + return value; +} + +function requestDeviceId(value: unknown, label: string): string { + if (typeof value !== "string" || !DEVICE_ID_PATTERN.test(value)) { + throw new SyncVaultRequestError(`${label}_invalid`); + } + return value; +} + +function requestInteger(value: unknown, label: string, min: number): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < min) { + throw new SyncVaultRequestError(`${label}_invalid`); + } + return value; +} + +function storedIdempotencyKey(value: unknown): string { + if (typeof value !== "string" || !IDEMPOTENCY_KEY_PATTERN.test(value)) { + throw new SyncVaultPersistenceError("idempotency_key_invalid"); + } + return value; +} + +function storedDeviceId(value: unknown, label: string): string { + if (typeof value !== "string" || !DEVICE_ID_PATTERN.test(value)) { + throw new SyncVaultPersistenceError(`${label}_invalid`); + } + return value; +} + +function storedInteger(value: unknown, label: string, min: number): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < min) { + throw new SyncVaultPersistenceError(`${label}_invalid`); + } + return value; +} diff --git a/cloudflare/src/sync_vault_bootstrap_proof.ts b/cloudflare/src/sync_vault_bootstrap_proof.ts new file mode 100644 index 0000000..ec5d3e0 --- /dev/null +++ b/cloudflare/src/sync_vault_bootstrap_proof.ts @@ -0,0 +1,75 @@ +import type { Env } from "./bindings.js"; +import { verifyEd25519Signature } from "./device_crypto.js"; +import type { WrappedAccountKeyDocument } from "./sync_vault.js"; + +const BOOTSTRAP_DOMAIN = "elydora-sync-vault-bootstrap-v2"; +const PUBLIC_KEY_PATTERN = /^[a-f0-9]{64}$/; + +const APPROVED_V2_SIGNING_KEY_QUERY = ` + SELECT keys.signing_public_key + FROM user_devices AS device + INNER JOIN user_device_keys AS keys + ON keys.user_id = device.user_id AND keys.device_id = device.device_id + WHERE device.user_id = ? AND device.device_id = ? + AND device.approval_status = 'approved' AND device.revoked_at IS NULL + AND keys.key_protocol_version = 2 AND keys.signing_public_key IS NOT NULL +`; + +export interface SyncVaultBootstrapProofInput { + keyId: string; + generation: number; + envelope: WrappedAccountKeyDocument; + idempotencyKey: string; +} + +interface SigningKeyRow { + signing_public_key: unknown; +} + +export async function syncVaultBootstrapProofValid( + env: Env, + userId: string, + deviceId: string, + bootstrap: SyncVaultBootstrapProofInput, + proof: string, +): Promise { + const row = await env.ELY_DB.prepare(APPROVED_V2_SIGNING_KEY_QUERY) + .bind(userId, deviceId) + .first(); + if ( + row === null || + typeof row.signing_public_key !== "string" || + !PUBLIC_KEY_PATTERN.test(row.signing_public_key) + ) { + return false; + } + return verifyEd25519Signature( + row.signing_public_key, + proof, + syncVaultBootstrapProofBytes(userId, deviceId, bootstrap), + ); +} + +export function syncVaultBootstrapProofBytes( + userId: string, + deviceId: string, + bootstrap: SyncVaultBootstrapProofInput, +): Uint8Array { + const values: (number | string)[] = [ + BOOTSTRAP_DOMAIN, + userId, + deviceId, + bootstrap.keyId, + bootstrap.generation, + bootstrap.envelope.version, + bootstrap.envelope.suite, + bootstrap.envelope.encapped_key, + bootstrap.envelope.ciphertext, + bootstrap.idempotencyKey, + ]; + const encoder = new TextEncoder(); + return encoder.encode(values.map((value) => { + const text = value.toString(); + return `${encoder.encode(text).byteLength}:${text}`; + }).join("")); +} diff --git a/cloudflare/src/sync_vault_rotation_cleanup.ts b/cloudflare/src/sync_vault_rotation_cleanup.ts new file mode 100644 index 0000000..c1e6354 --- /dev/null +++ b/cloudflare/src/sync_vault_rotation_cleanup.ts @@ -0,0 +1,282 @@ +import type { + ElyD1DatabaseSession, + ElyD1PreparedStatement, + ElyD1Result, + Env, +} from "./bindings.js"; +import { primaryD1Session } from "./bindings.js"; +import { collectSyncR2Garbage } from "./sync_r2_gc.js"; + +const MARK_CLEANUP_READY_QUERY = ` + UPDATE sync_vault_rotations + SET cleanup_snapshot_id = ?, cleanup_started_at = ? + WHERE user_id = ? AND completed_at IS NOT NULL AND storage_cleaned_at IS NULL + AND cleanup_snapshot_id IS NULL AND new_generation <= ? + AND EXISTS ( + SELECT 1 FROM sync_vault_accounts AS account + WHERE account.user_id = sync_vault_rotations.user_id + AND account.current_key_id = ? AND account.current_generation = ? + ) + AND EXISTS ( + SELECT 1 + FROM sync_snapshots AS snapshot + INNER JOIN sync_snapshot_encryption AS encryption + ON encryption.user_id = snapshot.user_id + AND encryption.snapshot_id = snapshot.snapshot_id + INNER JOIN sync_snapshot_heads AS head + ON head.user_id = snapshot.user_id + AND head.snapshot_id = snapshot.snapshot_id + AND head.head_revision = snapshot.head_revision + AND head.payload_hash = snapshot.payload_hash + WHERE snapshot.user_id = sync_vault_rotations.user_id + AND snapshot.snapshot_id = ? + AND encryption.key_id = ? AND encryption.vault_generation = ? + ) +`; +const READY_ROTATIONS_QUERY = ` + SELECT idempotency_key, new_key_id, new_generation + FROM sync_vault_rotations + WHERE user_id = ? AND cleanup_snapshot_id IS NOT NULL AND storage_cleaned_at IS NULL + ORDER BY new_generation ASC, idempotency_key ASC +`; +const DELETE_CHANGE_LOG_QUERY = ` + DELETE FROM sync_change_log + WHERE user_id = ? AND object_id IN ( + SELECT object.object_id + FROM sync_objects AS object + INNER JOIN sync_vault_rotation_r2_objects AS staged + ON staged.user_id = object.user_id AND staged.r2_key = object.payload_r2_key + WHERE object.user_id = ? AND staged.rotation_idempotency_key = ? + ) +`; +const FENCE_ROTATION_R2_QUERY = ` + UPDATE sync_r2_gc_candidates + SET + state = 'ready', + lease_expires_at = ?, + updated_at = MAX(updated_at, ?), + ready_at = COALESCE(ready_at, ?) + WHERE state = 'referenced' AND r2_key IN ( + SELECT r2_key FROM sync_vault_rotation_r2_objects + WHERE user_id = ? AND rotation_idempotency_key = ? + ) +`; +const DELETE_TOMBSTONES_QUERY = ` + DELETE FROM sync_tombstones + WHERE user_id = ? AND object_id IN ( + SELECT object.object_id + FROM sync_objects AS object + INNER JOIN sync_vault_rotation_r2_objects AS staged + ON staged.user_id = object.user_id AND staged.r2_key = object.payload_r2_key + WHERE object.user_id = ? AND staged.rotation_idempotency_key = ? + ) +`; +const DELETE_OBJECTS_QUERY = ` + DELETE FROM sync_objects + WHERE user_id = ? AND payload_r2_key IN ( + SELECT r2_key FROM sync_vault_rotation_r2_objects + WHERE user_id = ? AND rotation_idempotency_key = ? + ) +`; +const DELETE_SNAPSHOT_ENCRYPTION_QUERY = ` + DELETE FROM sync_snapshot_encryption + WHERE user_id = ? + AND NOT (key_id = ? AND vault_generation = ?) + AND NOT EXISTS ( + SELECT 1 FROM sync_snapshot_heads AS head + WHERE head.user_id = sync_snapshot_encryption.user_id + AND head.snapshot_id = sync_snapshot_encryption.snapshot_id + ) + AND snapshot_id IN ( + SELECT snapshot.snapshot_id + FROM sync_snapshots AS snapshot + INNER JOIN sync_vault_rotation_r2_objects AS staged + ON staged.user_id = snapshot.user_id AND staged.r2_key = snapshot.r2_key + WHERE snapshot.user_id = ? AND staged.rotation_idempotency_key = ? + ) +`; +const DELETE_SNAPSHOTS_QUERY = ` + DELETE FROM sync_snapshots + WHERE user_id = ? + AND NOT EXISTS ( + SELECT 1 FROM sync_snapshot_heads AS head + WHERE head.user_id = sync_snapshots.user_id + AND head.snapshot_id = sync_snapshots.snapshot_id + ) + AND r2_key IN ( + SELECT r2_key FROM sync_vault_rotation_r2_objects + WHERE user_id = ? AND rotation_idempotency_key = ? + ) + AND NOT EXISTS ( + SELECT 1 FROM sync_snapshot_encryption AS encryption + WHERE encryption.user_id = sync_snapshots.user_id + AND encryption.snapshot_id = sync_snapshots.snapshot_id + ) +`; +const MARK_STORAGE_CLEAN_QUERY = ` + UPDATE sync_vault_rotations + SET storage_cleaned_at = ? + WHERE user_id = ? AND idempotency_key = ? + AND cleanup_snapshot_id IS NOT NULL AND storage_cleaned_at IS NULL + AND NOT EXISTS ( + SELECT 1 + FROM sync_vault_rotation_r2_objects AS staged + WHERE staged.user_id = sync_vault_rotations.user_id + AND staged.rotation_idempotency_key = sync_vault_rotations.idempotency_key + AND NOT EXISTS ( + SELECT 1 FROM sync_objects AS object + WHERE object.payload_r2_key = staged.r2_key + ) + AND NOT EXISTS ( + SELECT 1 FROM sync_snapshots AS snapshot + WHERE snapshot.r2_key = staged.r2_key + ) + AND NOT EXISTS ( + SELECT 1 FROM sync_r2_gc_candidates AS candidate + WHERE candidate.r2_key = staged.r2_key AND candidate.state = 'deleted' + ) + ) +`; +const FINALIZE_STORAGE_CLEAN_QUERY = ` + UPDATE sync_vault_rotations + SET storage_cleaned_at = MAX(cleanup_started_at, ?) + WHERE cleanup_snapshot_id IS NOT NULL AND storage_cleaned_at IS NULL + AND NOT EXISTS ( + SELECT 1 + FROM sync_vault_rotation_r2_objects AS staged + WHERE staged.user_id = sync_vault_rotations.user_id + AND staged.rotation_idempotency_key = sync_vault_rotations.idempotency_key + AND NOT EXISTS ( + SELECT 1 FROM sync_objects AS object + WHERE object.payload_r2_key = staged.r2_key + ) + AND NOT EXISTS ( + SELECT 1 FROM sync_snapshots AS snapshot + WHERE snapshot.r2_key = staged.r2_key + ) + AND NOT EXISTS ( + SELECT 1 FROM sync_r2_gc_candidates AS candidate + WHERE candidate.r2_key = staged.r2_key AND candidate.state = 'deleted' + ) + ) +`; + +interface RotationRow { + idempotency_key: unknown; + new_key_id: unknown; + new_generation: unknown; +} + +export class SyncVaultRotationCleanupError extends Error {} + +export async function cleanupRotatedVaultStorage( + env: Env, + userId: string, + snapshotId: string, + keyId: string, + generation: number, + nowSeconds: number, +): Promise { + const database = primaryD1Session(env.ELY_DB); + await database.prepare(MARK_CLEANUP_READY_QUERY).bind( + snapshotId, + nowSeconds, + userId, + generation, + keyId, + generation, + snapshotId, + keyId, + generation, + ).run(); + const ready = await database.prepare(READY_ROTATIONS_QUERY) + .bind(userId) + .all(); + for (const row of ready.results) { + const idempotencyKey = storedIdempotencyKey(row.idempotency_key); + const rotationKeyId = storedKeyId(row.new_key_id); + const rotationGeneration = storedGeneration(row.new_generation); + await database.batch(cleanupMetadataStatements( + database, + userId, + idempotencyKey, + rotationKeyId, + rotationGeneration, + nowSeconds, + )); + await collectSyncR2Garbage(env, nowSeconds, { userId, limit: 100, database }); + const result = await database.prepare(MARK_STORAGE_CLEAN_QUERY) + .bind(nowSeconds, userId, idempotencyKey) + .run() as ElyD1Result; + const changes = result.meta?.changes; + if (typeof changes !== "number" || !Number.isSafeInteger(changes) || changes > 1 || changes < 0) { + throw new SyncVaultRotationCleanupError("sync_vault_rotation_cleanup_write_invalid"); + } + } +} + +export async function finalizeCleanedVaultRotations( + env: Env, + nowSeconds: number, +): Promise { + const result = await primaryD1Session(env.ELY_DB) + .prepare(FINALIZE_STORAGE_CLEAN_QUERY) + .bind(nowSeconds) + .run() as ElyD1Result; + const changes = result.meta?.changes; + if (typeof changes !== "number" || !Number.isSafeInteger(changes) || changes < 0) { + throw new SyncVaultRotationCleanupError("sync_vault_rotation_cleanup_write_invalid"); + } + return changes; +} + +function cleanupMetadataStatements( + database: ElyD1DatabaseSession, + userId: string, + idempotencyKey: string, + keyId: string, + generation: number, + nowSeconds: number, +): ElyD1PreparedStatement[] { + return [ + database.prepare(FENCE_ROTATION_R2_QUERY).bind( + nowSeconds, + nowSeconds, + nowSeconds, + userId, + idempotencyKey, + ), + database.prepare(DELETE_CHANGE_LOG_QUERY).bind(userId, userId, idempotencyKey), + database.prepare(DELETE_TOMBSTONES_QUERY).bind(userId, userId, idempotencyKey), + database.prepare(DELETE_OBJECTS_QUERY).bind(userId, userId, idempotencyKey), + database.prepare(DELETE_SNAPSHOT_ENCRYPTION_QUERY).bind( + userId, + keyId, + generation, + userId, + idempotencyKey, + ), + database.prepare(DELETE_SNAPSHOTS_QUERY).bind(userId, userId, idempotencyKey), + ]; +} + +function storedIdempotencyKey(value: unknown): string { + if (typeof value !== "string" || !/^[a-zA-Z0-9._:-]{16,128}$/.test(value)) { + throw new SyncVaultRotationCleanupError("sync_vault_rotation_idempotency_key_invalid"); + } + return value; +} + +function storedKeyId(value: unknown): string { + if (typeof value !== "string" || !/^[a-f0-9]{64}$/.test(value)) { + throw new SyncVaultRotationCleanupError("sync_vault_rotation_key_id_invalid"); + } + return value; +} + +function storedGeneration(value: unknown): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 2) { + throw new SyncVaultRotationCleanupError("sync_vault_rotation_generation_invalid"); + } + return value; +} diff --git a/cloudflare/src/sync_vault_sql.ts b/cloudflare/src/sync_vault_sql.ts new file mode 100644 index 0000000..2e4062e --- /dev/null +++ b/cloudflare/src/sync_vault_sql.ts @@ -0,0 +1,92 @@ +export const CURRENT_SYNC_VAULT_KEY_QUERY = ` + SELECT current_key_id AS key_id, current_generation AS generation + FROM sync_vault_accounts + WHERE user_id = ? +`; + +export const CURRENT_DEVICE_ENVELOPE_QUERY = ` + SELECT + accounts.current_key_id AS key_id, + accounts.current_generation AS generation, + envelopes.recipient_device_id AS recipient_device_id, + envelopes.approver_device_id AS approver_device_id, + envelopes.envelope_version AS envelope_version, + envelopes.suite AS suite, + envelopes.encapped_key AS encapped_key, + envelopes.ciphertext AS ciphertext, + envelopes.idempotency_key AS idempotency_key, + envelopes.created_at AS created_at + FROM sync_vault_accounts AS accounts + INNER JOIN sync_vault_envelopes AS envelopes + ON envelopes.user_id = accounts.user_id + AND envelopes.key_id = accounts.current_key_id + AND envelopes.generation = accounts.current_generation + WHERE accounts.user_id = ? AND envelopes.recipient_device_id = ? +`; + +export const HISTORICAL_DEVICE_ENVELOPE_QUERY = ` + SELECT + key_id, + generation, + recipient_device_id, + approver_device_id, + envelope_version, + suite, + encapped_key, + ciphertext, + idempotency_key, + created_at + FROM sync_vault_envelopes + WHERE user_id = ? AND recipient_device_id = ? AND key_id = ? AND generation = ? +`; + +export const SYNC_VAULT_ACCOUNT_INSERT_QUERY = ` + INSERT INTO sync_vault_accounts (user_id, current_key_id, current_generation, created_at, updated_at) + SELECT ?, ?, ?, ?, ? + WHERE EXISTS ( + SELECT 1 + FROM user_devices AS device + INNER JOIN user_device_keys AS keys + ON keys.user_id = device.user_id AND keys.device_id = device.device_id + WHERE device.user_id = ? + AND device.device_id = ? + AND device.approval_status = 'approved' + AND device.revoked_at IS NULL + AND keys.key_protocol_version = 2 + AND keys.wrapping_public_key IS NOT NULL + ) + ON CONFLICT(user_id) DO NOTHING +`; + +export const SYNC_VAULT_ENVELOPE_INSERT_QUERY = ` + INSERT INTO sync_vault_envelopes ( + user_id, recipient_device_id, approver_device_id, key_id, generation, + envelope_version, suite, encapped_key, ciphertext, idempotency_key, created_at + ) + SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + FROM sync_vault_accounts AS accounts + INNER JOIN user_devices AS recipient + ON recipient.user_id = accounts.user_id + AND recipient.device_id = ? + AND recipient.approval_status = ? + AND recipient.revoked_at IS NULL + INNER JOIN user_device_keys AS recipient_keys + ON recipient_keys.user_id = recipient.user_id + AND recipient_keys.device_id = recipient.device_id + AND recipient_keys.key_protocol_version = 2 + AND recipient_keys.wrapping_public_key IS NOT NULL + INNER JOIN user_devices AS approver + ON approver.user_id = accounts.user_id + AND approver.device_id = ? + AND approver.approval_status = 'approved' + AND approver.revoked_at IS NULL + INNER JOIN user_device_keys AS approver_keys + ON approver_keys.user_id = approver.user_id + AND approver_keys.device_id = approver.device_id + AND approver_keys.key_protocol_version = 2 + AND approver_keys.wrapping_public_key IS NOT NULL + WHERE accounts.user_id = ? + AND accounts.current_key_id = ? + AND accounts.current_generation = ? + ON CONFLICT DO NOTHING +`; diff --git a/cloudflare/tests/account_deletion_routes.test.ts b/cloudflare/tests/account_deletion_routes.test.ts index e26f3f3..3647d8c 100644 --- a/cloudflare/tests/account_deletion_routes.test.ts +++ b/cloudflare/tests/account_deletion_routes.test.ts @@ -4,7 +4,18 @@ import { describe, it } from "node:test"; import { authSessionCacheKvKey, authTokenHash } from "../src/auth.js"; import { handleRequest } from "../src/index.js"; -import { ACCESS_TOKEN, sessionDocument, testD1Database, testEnv } from "./devices_test_support.js"; +import { + recentDeviceActionProofBytes, + recentDeviceActionRequestHash, +} from "../src/recent_device_action_proof.js"; +import { + ACCESS_TOKEN, + PUBLIC_KEY, + sessionDocument, + signDeviceMessage, + testD1Database, + testEnv, +} from "./devices_test_support.js"; const USER_ID = "user-01"; const DEVICE_ID = "device-01"; @@ -21,13 +32,22 @@ describe("account deletion routes", () => { const r2Deletes: string[] = []; const tokenHash = await authTokenHash(ACCESS_TOKEN); const sessionCacheKey = authSessionCacheKvKey("local", tokenHash); + const requestBody = await accountDeleteBody(); const d1 = testD1Database({ - firstRows: [{ device_id: DEVICE_ID }, null, deletionCountsRow()], - allRows: [{ r2_key: PAYLOAD_KEY }, { r2_key: SNAPSHOT_KEY }], + firstRows: [ + null, + { signing_public_key: PUBLIC_KEY }, + deletionCountsRow(), + ], + allRowSets: [ + [{ r2_key: PAYLOAD_KEY }, { r2_key: SNAPSHOT_KEY }], + [{ token: ACCESS_TOKEN }], + [{ r2_key: PAYLOAD_KEY }, { r2_key: SNAPSHOT_KEY }], + ], }); const response = await handleRequest( - accountDeleteRequest(accountDeleteBody()), + accountDeleteRequest(requestBody), testEnv({ d1, kvDeletes, @@ -68,36 +88,49 @@ describe("account deletion routes", () => { }); assert.deepEqual(r2Deletes, [PAYLOAD_KEY, SNAPSHOT_KEY]); assert.deepEqual(kvDeletes, [sessionCacheKey]); - assert.equal(d1.batches[0], 12); - assert.ok(d1.queries[1]?.includes("FROM audit_events")); + assert.equal(d1.batches[0], 18); + assert.ok(d1.queries[0]?.includes("FROM audit_events")); + assert.ok(d1.queries[1]?.includes("signing_public_key")); assert.ok(d1.queries[2]?.includes("FROM user_devices")); - assert.ok(d1.queries[3]?.includes("UNION")); - assert.ok(d1.queries[4]?.includes("DELETE FROM sync_change_log")); - assert.ok(d1.queries[9]?.includes("DELETE FROM better_auth_session_device_context")); - assert.ok(d1.queries[10]?.includes("DELETE FROM user_devices")); - assert.ok(d1.queries[15]?.includes("INSERT INTO audit_events")); - assert.deepEqual(d1.binds[1], [accountDeletionEventId()]); - assert.deepEqual(d1.binds[3], [USER_ID, USER_ID]); - assert.deepEqual(d1.binds[15], [ + assert.ok(d1.queries[3]?.includes("FROM sync_r2_gc_candidates")); + assert.ok(d1.queries[4]?.includes("FROM better_auth_session")); + assert.ok(d1.queries[5]?.includes("CASE WHEN EXISTS")); + assert.ok(d1.queries[6]?.includes("UPDATE sync_r2_gc_candidates")); + assert.ok(d1.queries[7]?.includes("DELETE FROM sync_change_log")); + assert.ok(d1.queries[9]?.includes("DELETE FROM sync_snapshot_heads")); + assert.ok(d1.queries[10]?.includes("DELETE FROM sync_snapshot_encryption")); + assert.ok(d1.queries[11]?.includes("DELETE FROM sync_snapshots")); + assert.ok(d1.queries[14]?.includes("DELETE FROM sync_vault_accounts")); + assert.ok(d1.queries[16]?.includes("DELETE FROM better_auth_session_device_context")); + assert.ok(d1.queries[17]?.includes("DELETE FROM user_devices")); + assert.deepEqual(d1.binds[0], [accountDeletionEventId()]); + assert.deepEqual(d1.binds[3], [USER_ID]); + assert.ok(d1.queries[22]?.includes("SET user_id = NULL")); + assert.deepEqual(d1.binds[5]?.slice(0, 6), [ accountDeletionEventId(), + null, DEVICE_ID, + "account.delete", + "account", USER_HASH, - IDEMPOTENCY_HASH, - body.deleted_at, ]); + assert.equal(d1.binds[5]?.[11], PUBLIC_KEY); + assert.equal(d1.binds[5]?.[12], await requestHash(requestBody)); + assert.equal(d1.binds[5]?.[13], body.deleted_at); }); it("returns an idempotent deletion document for existing audit events", async () => { const kvDeletes: string[] = []; const r2Deletes: string[] = []; const tokenHash = await authTokenHash(ACCESS_TOKEN); + const requestBody = await accountDeleteBody(); const d1 = testD1Database({ firstRows: [ - { device_id: DEVICE_ID }, { actor_device_id: DEVICE_ID, outcome: "success", subject_id: USER_HASH, + metadata_hash: await requestHash(requestBody), created_at: 1_780_001_000, }, ], @@ -105,7 +138,7 @@ describe("account deletion routes", () => { }); const response = await handleRequest( - accountDeleteRequest(accountDeleteBody()), + accountDeleteRequest(requestBody), testEnv({ d1, kvDeletes, @@ -139,28 +172,63 @@ describe("account deletion routes", () => { }); assert.deepEqual(r2Deletes, []); assert.deepEqual(kvDeletes, []); - assert.equal(d1.queries.length, 2); + assert.equal(d1.queries.length, 1); assert.deepEqual(d1.batches, []); }); + it("deletes every legacy KV session key for the account", async () => { + const secondToken = "second-session-token-0000000000000000"; + const currentHash = await authTokenHash(ACCESS_TOKEN); + const secondHash = await authTokenHash(secondToken); + const currentKey = authSessionCacheKvKey("local", currentHash); + const secondKey = authSessionCacheKvKey("local", secondHash); + const kvDeletes: string[] = []; + const d1 = testD1Database({ + firstRows: [ + null, + { signing_public_key: PUBLIC_KEY }, + deletionCountsRow(), + ], + allRowSets: [[], [{ token: ACCESS_TOKEN }, { token: secondToken }], []], + }); + + const response = await handleRequest( + accountDeleteRequest(await accountDeleteBody()), + testEnv({ + d1, + kvDeletes, + kvEntries: [ + [currentKey, sessionDocument(DEVICE_ID)], + [secondKey, sessionDocument("device-02")], + ], + }), + ); + + assert.equal(response.status, 200); + const body = await response.json() as { deleted: { kv_session_cache: number } }; + assert.equal(body.deleted.kv_session_cache, 2); + assert.deepEqual(kvDeletes.sort(), [currentKey, secondKey].sort()); + }); + it("rejects replay mismatches before deleting account data", async () => { const kvDeletes: string[] = []; const r2Deletes: string[] = []; const tokenHash = await authTokenHash(ACCESS_TOKEN); + const requestBody = await accountDeleteBody(); const d1 = testD1Database({ firstRows: [ - { device_id: DEVICE_ID }, { actor_device_id: "device-02", outcome: "success", subject_id: USER_HASH, + metadata_hash: await requestHash(requestBody), created_at: 1_780_001_000, }, ], }); const response = await handleRequest( - accountDeleteRequest(accountDeleteBody()), + accountDeleteRequest(requestBody), testEnv({ d1, kvDeletes, @@ -180,10 +248,10 @@ describe("account deletion routes", () => { const kvDeletes: string[] = []; const r2Deletes: string[] = []; const tokenHash = await authTokenHash(ACCESS_TOKEN); - const d1 = testD1Database({ firstRows: [{ device_id: DEVICE_ID }] }); + const d1 = testD1Database([]); const response = await handleRequest( - accountDeleteRequest(accountDeleteBody({ confirmation: "delete" })), + accountDeleteRequest(await accountDeleteBody({ confirmation: "delete" })), testEnv({ d1, kvDeletes, @@ -196,16 +264,16 @@ describe("account deletion routes", () => { assert.deepEqual(await response.json(), { error: "invalid_account_deletion" }); assert.deepEqual(r2Deletes, []); assert.deepEqual(kvDeletes, []); - assert.equal(d1.queries.length, 1); + assert.equal(d1.queries.length, 0); assert.deepEqual(d1.batches, []); }); - it("rejects revoked devices before reading account deletion bodies", async () => { + it("requires an approved device key for a new account deletion", async () => { const tokenHash = await authTokenHash(ACCESS_TOKEN); - const d1 = testD1Database({ firstRows: [null] }); + const d1 = testD1Database({ firstRows: [null, null] }); const response = await handleRequest( - accountDeleteRequest(accountDeleteBody()), + accountDeleteRequest(await accountDeleteBody()), testEnv({ d1, kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]], @@ -213,22 +281,30 @@ describe("account deletion routes", () => { ); assert.equal(response.status, 403); - assert.deepEqual(await response.json(), { error: "device_not_approved" }); - assert.equal(d1.queries.length, 1); + assert.deepEqual(await response.json(), { error: "account_deletion_forbidden" }); + assert.equal(d1.queries.length, 2); assert.deepEqual(d1.batches, []); }); - it("fails closed when stored R2 keys are malformed", async () => { + it("keeps account deletion successful when scheduled GC must handle a malformed key", async () => { const kvDeletes: string[] = []; const r2Deletes: string[] = []; const tokenHash = await authTokenHash(ACCESS_TOKEN); const d1 = testD1Database({ - firstRows: [{ device_id: DEVICE_ID }, null, deletionCountsRow()], - allRows: [{ r2_key: "sync-snapshots/../bad.bin" }], + firstRows: [ + null, + { signing_public_key: PUBLIC_KEY }, + deletionCountsRow(), + ], + allRowSets: [ + [{ r2_key: "sync-snapshots/../bad.bin" }], + [], + [{ r2_key: "sync-snapshots/../bad.bin" }], + ], }); const response = await handleRequest( - accountDeleteRequest(accountDeleteBody()), + accountDeleteRequest(await accountDeleteBody()), testEnv({ d1, kvDeletes, @@ -237,11 +313,10 @@ describe("account deletion routes", () => { }), ); - assert.equal(response.status, 500); - assert.deepEqual(await response.json(), { error: "account_deletion_failed" }); + assert.equal(response.status, 200); assert.deepEqual(r2Deletes, []); - assert.deepEqual(kvDeletes, []); - assert.deepEqual(d1.batches, []); + assert.deepEqual(kvDeletes, [authSessionCacheKvKey("local", tokenHash)]); + assert.deepEqual(d1.batches, [18]); }); }); @@ -256,13 +331,26 @@ function accountDeleteRequest(body: Record): Request { }); } -function accountDeleteBody(overrides: Record = {}): Record { - return { - version: 1, +async function accountDeleteBody( + overrides: Record = {}, +): Promise> { + const body: Record = { + version: 2, confirmation: "delete-elydora-account", idempotency_key: IDEMPOTENCY_KEY, + proof_created_at: Math.floor(Date.now() / 1000), ...overrides, }; + body.action_proof = await signDeviceMessage(recentDeviceActionProofBytes({ + action: "account.delete", + userId: USER_ID, + sessionId: "session-01", + deviceId: DEVICE_ID, + confirmation: String(body.confirmation), + idempotencyKey: String(body.idempotency_key), + proofCreatedAt: Number(body.proof_created_at), + })); + return body; } function deletionCountsRow(overrides: Record = {}): Record { @@ -286,6 +374,19 @@ function accountDeletionEventId(): string { return `account-delete:${USER_HASH}:${IDEMPOTENCY_HASH}`; } +function requestHash(body: Record): Promise { + return recentDeviceActionRequestHash({ + action: "account.delete", + userId: USER_ID, + sessionId: "session-01", + deviceId: DEVICE_ID, + confirmation: String(body.confirmation), + idempotencyKey: String(body.idempotency_key), + proofCreatedAt: Number(body.proof_created_at), + actionProof: String(body.action_proof), + }); +} + function bytes(value: string): Uint8Array { return new TextEncoder().encode(value); } diff --git a/cloudflare/tests/account_reset_gc_sqlite.test.ts b/cloudflare/tests/account_reset_gc_sqlite.test.ts new file mode 100644 index 0000000..7a7c45a --- /dev/null +++ b/cloudflare/tests/account_reset_gc_sqlite.test.ts @@ -0,0 +1,445 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, it } from "node:test"; + +import type { ElyR2Object, ElyR2PutOptions, Env } from "../src/bindings.js"; +import { accountDeletionDocument } from "../src/account_deletion.js"; +import { authSessionCacheKvKey } from "../src/auth.js"; +import { purgeLegacySessionCache } from "../src/legacy_auth_kv_cleanup.js"; +import { + recentDeviceActionProofBytes, + type SensitiveAction, +} from "../src/recent_device_action_proof.js"; +import { collectSyncR2Garbage } from "../src/sync_r2_gc.js"; +import { maintainSyncR2Storage } from "../src/sync_r2_maintenance.js"; +import { syncResetDocument } from "../src/sync_reset.js"; +import { PUBLIC_KEY, signDeviceMessage } from "./devices_test_support.js"; +import { SqliteD1Database, execute, query } from "./sqlite_d1_test_support.js"; + +const USER_ID = "user-01"; +const DEVICE_ID = "device-01"; +const KEY_ID = "1".repeat(64); +const TOKEN_HASH = "2".repeat(64); +const OWNER_HASH = createHash("sha256").update(USER_ID).digest("hex"); +const NOW = 1_800_000_000; +const MIGRATIONS_DIR = join(process.cwd(), "migrations"); + +describe("account deletion and reset GC drains", () => { + it("drains 101 reset candidates through bounded batches", async () => { + await withDatabase(async (databasePath, bucket, _kv, env) => { + const keys = seedReadyCandidates(databasePath, bucket, 101); + + const document = await syncResetDocument( + await resetRequest("sync-reset-101-items", NOW), + env, + authContext(), + NOW, + ); + const replay = await syncResetDocument( + await resetRequest("sync-reset-101-items", NOW), + env, + authContext(), + NOW + 1_000, + ); + + assert.equal(document.deleted.r2_objects, 101); + assert.equal(replay.reset_at, NOW); + assert.equal(replay.deleted.r2_objects, 0); + assert.equal(deletedCandidateCount(databasePath), 101); + assert.equal(bucket.size, 0); + assert.deepEqual(bucket.deletes.sort(), keys.sort()); + }); + }); + + it("releases rotation staging on reset and finalizes cleanup during maintenance", async () => { + await withDatabase(async (databasePath, bucket, _kv, env) => { + const [key] = seedReadyCandidates(databasePath, bucket, 1); + assert.ok(key !== undefined); + seedCompletedRotation(databasePath, key); + + const document = await syncResetDocument( + await resetRequest("sync-reset-rotation", NOW), + env, + authContext(), + NOW, + ); + + assert.equal(document.deleted.r2_objects, 1); + assert.equal(candidateState(databasePath, key), "deleted"); + assert.deepEqual(query(databasePath, ` + SELECT cleanup_snapshot_id, storage_cleaned_at + FROM sync_vault_rotations + WHERE user_id = '${USER_ID}' AND idempotency_key = 'rotation-reset-0001' + `), [{ cleanup_snapshot_id: "sync-reset", storage_cleaned_at: null }]); + + await maintainSyncR2Storage(env, NOW + 1); + + assert.deepEqual(query(databasePath, ` + SELECT storage_cleaned_at + FROM sync_vault_rotations + WHERE user_id = '${USER_ID}' AND idempotency_key = 'rotation-reset-0001' + `), [{ storage_cleaned_at: NOW + 1 }]); + }); + }); + + it("drains 101 account candidates after anonymizing their owner", async () => { + await withDatabase(async (databasePath, bucket, _kv, env) => { + seedReadyCandidates(databasePath, bucket, 101); + const context = authContext(); + const request = await accountDeleteRequest("account-delete-101-items", NOW); + const replayRequest = request.clone(); + const document = await accountDeletionDocument( + request, + env, + context, + NOW, + ); + const replay = await accountDeletionDocument(replayRequest, env, context, NOW + 1_000); + + assert.equal(document.deleted.r2_objects, 101); + assert.equal(replay.account_hash, document.account_hash); + assert.equal(replay.deleted_at, NOW); + assert.equal(replay.deleted.users, 0); + assert.equal(deletedCandidateCount(databasePath), 101); + assert.equal(bucket.size, 0); + assert.equal(query(databasePath, ` + SELECT COUNT(*) AS count FROM sync_r2_gc_candidates WHERE user_id IS NOT NULL + `)[0]?.count, 0); + assert.equal(query(databasePath, ` + SELECT COUNT(*) AS count FROM better_auth_user WHERE id = '${USER_ID}' + `)[0]?.count, 0); + assert.deepEqual(query(databasePath, ` + SELECT user_id, outcome FROM audit_events WHERE event_type = 'account.delete' + `), [{ user_id: null, outcome: "success" }]); + }); + }); + + it("returns account deletion success while scheduled cleanup retries R2 and KV failures", async () => { + await withDatabase(async (databasePath, bucket, kv, env) => { + const [key] = seedReadyCandidates(databasePath, bucket, 1); + assert.ok(key !== undefined); + const legacyKey = authSessionCacheKvKey("local", TOKEN_HASH); + kv.values.set(legacyKey, "legacy-session"); + bucket.failDeletes = true; + kv.failDeletes = true; + + const document = await accountDeletionDocument( + await accountDeleteRequest("account-delete-cleanup-failure", NOW), + env, + authContext(), + NOW, + ); + + assert.equal(document.deleted.kv_session_cache, 0); + assert.equal(candidateState(databasePath, key), "deleting"); + assert.equal(query(databasePath, ` + SELECT COUNT(*) AS count FROM better_auth_user WHERE id = '${USER_ID}' + `)[0]?.count, 0); + + bucket.failDeletes = false; + kv.failDeletes = false; + assert.equal(await collectSyncR2Garbage(env, NOW + 61, { ownerHash: OWNER_HASH }), 1); + assert.equal(await purgeLegacySessionCache(env), 1); + assert.equal(candidateState(databasePath, key), "deleted"); + assert.equal(bucket.size, 0); + assert.equal(kv.values.size, 0); + }); + }); + + it("rolls back account deletion when its authenticated authority changes before the batch", async () => { + for (const beforeBatchSql of [ + "DELETE FROM better_auth_session WHERE id = 'session-01';", + `UPDATE user_device_keys SET signing_public_key = '${"9".repeat(64)}' + WHERE user_id = '${USER_ID}' AND device_id = '${DEVICE_ID}';`, + ]) { + await withDatabase(async (databasePath, bucket, _kv, env) => { + const [key] = seedReadyCandidates(databasePath, bucket, 1); + assert.ok(key !== undefined); + const request = await accountDeleteRequest("account-delete-authority-race", NOW); + const racedEnv = { + ...env, + ELY_DB: new SqliteD1Database(databasePath, beforeBatchSql), + } as Env; + + await assert.rejects( + () => accountDeletionDocument(request, racedEnv, authContext(), NOW), + /device_action_gate_failed/, + ); + + assert.deepEqual(query(databasePath, `SELECT + (SELECT COUNT(*) FROM better_auth_user WHERE id = '${USER_ID}') AS users, + (SELECT COUNT(*) FROM user_devices WHERE user_id = '${USER_ID}') AS devices, + (SELECT COUNT(*) FROM user_device_keys WHERE user_id = '${USER_ID}') AS device_keys, + (SELECT COUNT(*) FROM sync_vault_accounts WHERE user_id = '${USER_ID}') AS vaults, + (SELECT COUNT(*) FROM audit_events WHERE event_type = 'account.delete') AS audits + `), [{ users: 1, devices: 1, device_keys: 1, vaults: 1, audits: 0 }]); + assert.equal(candidateState(databasePath, key), "ready"); + assert.equal(bucket.size, 1); + }); + } + }); + + it("continues R2 inventory and GC when legacy KV purge fails", async () => { + await withDatabase(async (databasePath, bucket, kv, env) => { + const hash = "f".repeat(64); + const key = `sync-payloads/us-east/${OWNER_HASH}/bookmarks/object-01/${hash}.bin`; + bucket.values.set(key, new Uint8Array([1]).buffer); + kv.failLists = true; + + await assert.rejects(() => maintainSyncR2Storage(env, NOW), AggregateError); + + assert.equal(bucket.size, 0); + assert.equal(candidateState(databasePath, key), "deleted"); + }); + }); +}); + +async function withDatabase( + run: (databasePath: string, bucket: TestBucket, kv: TestKv, env: Env) => Promise, +): Promise { + const tempDir = mkdtempSync(join(tmpdir(), "ely-account-reset-gc-")); + try { + const databasePath = join(tempDir, "ely.db"); + for (const fileName of readdirSync(MIGRATIONS_DIR).filter((name) => name.endsWith(".sql")).sort()) { + execute(databasePath, readFileSync(join(MIGRATIONS_DIR, fileName), "utf8")); + } + seedAuthority(databasePath); + const bucket = new TestBucket(); + const kv = new TestKv(); + const env = { + ELY_DB: new SqliteD1Database(databasePath), + ELY_STORAGE: bucket, + ELY_KV: kv, + ELY_ENVIRONMENT: "local", + } as unknown as Env; + await run(databasePath, bucket, kv, env); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +} + +function seedAuthority(databasePath: string): void { + execute(databasePath, ` + INSERT INTO better_auth_user ( + id, name, email, emailVerified, createdAt, updatedAt + ) VALUES ( + '${USER_ID}', 'User', 'user@example.com', 1, + '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z' + ); + INSERT INTO user_devices ( + user_id, device_id, public_key, device_name, platform, + approval_status, created_at, approved_at, last_active_at, revoked_at, idempotency_key + ) VALUES ( + '${USER_ID}', '${DEVICE_ID}', '${PUBLIC_KEY}', 'Mac', 'macOS', + 'approved', 1, 1, 1, NULL, 'device-register-0001' + ); + INSERT INTO user_device_keys ( + user_id, device_id, signing_public_key, wrapping_public_key, + key_protocol_version, created_at + ) VALUES ( + '${USER_ID}', '${DEVICE_ID}', '${PUBLIC_KEY}', '${"4".repeat(64)}', 2, 1 + ); + INSERT INTO better_auth_session ( + id, expiresAt, token, createdAt, updatedAt, userId + ) VALUES ( + 'session-01', '2099-01-01T00:00:00Z', 'session-token-01', + '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', '${USER_ID}' + ); + INSERT INTO better_auth_session_device_context ( + session_id, user_id, device_id, updated_at + ) VALUES ('session-01', '${USER_ID}', '${DEVICE_ID}', 1); + INSERT INTO sync_vault_accounts ( + user_id, current_key_id, current_generation, created_at, updated_at + ) VALUES ('${USER_ID}', '${KEY_ID}', 1, 1, 1); + `); +} + +function seedReadyCandidates(databasePath: string, bucket: TestBucket, count: number): string[] { + const keys = Array.from({ length: count }, (_, index) => snapshotKey(index + 1)); + execute(databasePath, keys.map((key, index) => ` + INSERT INTO sync_r2_gc_candidates ( + r2_key, user_id, owner_hash, object_kind, state, write_token, + lease_expires_at, gc_token, created_at, updated_at, referenced_at, + ready_at, delete_started_at, deleted_at + ) VALUES ( + '${key}', '${USER_ID}', '${OWNER_HASH}', 'snapshot', 'ready', NULL, + 0, NULL, 1, 1, NULL, 1, NULL, NULL + ); + `).join("\n")); + for (const [index, key] of keys.entries()) { + bucket.values.set(key, new Uint8Array([index % 256]).buffer); + } + return keys; +} + +function seedCompletedRotation(databasePath: string, r2Key: string): void { + execute(databasePath, ` + INSERT INTO user_devices ( + user_id, device_id, public_key, device_name, platform, + approval_status, created_at, approved_at, last_active_at, revoked_at, idempotency_key + ) VALUES ( + '${USER_ID}', 'device-02', '${"5".repeat(64)}', 'Old Mac', 'macOS', + 'revoked', 1, 1, 1, 2, 'device-register-0002' + ); + INSERT INTO sync_vault_rotations ( + user_id, idempotency_key, audit_event_id, target_device_id, approver_device_id, + previous_key_id, previous_generation, new_key_id, new_generation, request_hash, + envelope_count, r2_object_count, created_at, completed_at + ) VALUES ( + '${USER_ID}', 'rotation-reset-0001', 'rotation-reset-audit', 'device-02', '${DEVICE_ID}', + '${KEY_ID}', 1, '${"6".repeat(64)}', 2, '${"7".repeat(64)}', 1, 1, 1, 2 + ); + INSERT INTO sync_vault_rotation_r2_objects ( + user_id, rotation_idempotency_key, r2_key + ) VALUES ('${USER_ID}', 'rotation-reset-0001', '${r2Key}'); + `); +} + +function resetRequest(idempotencyKey: string, proofCreatedAt: number): Promise { + return actionRequest( + "sync.reset", + "delete-cloud-sync-data", + idempotencyKey, + proofCreatedAt, + "/api/sync/reset", + ); +} + +function accountDeleteRequest(idempotencyKey: string, proofCreatedAt: number): Promise { + return actionRequest( + "account.delete", + "delete-elydora-account", + idempotencyKey, + proofCreatedAt, + "/api/account/delete", + ); +} + +async function actionRequest( + action: SensitiveAction, + confirmation: string, + idempotencyKey: string, + proofCreatedAt: number, + path: string, +): Promise { + const actionProof = await signDeviceMessage(recentDeviceActionProofBytes({ + action, + userId: USER_ID, + sessionId: authContext().sessionId, + deviceId: DEVICE_ID, + confirmation, + idempotencyKey, + proofCreatedAt, + })); + return new Request(`https://elydora.test${path}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + version: 2, + confirmation, + idempotency_key: idempotencyKey, + proof_created_at: proofCreatedAt, + action_proof: actionProof, + }), + }); +} + +function authContext() { + return { + userId: USER_ID, + deviceId: DEVICE_ID, + sessionId: "session-01", + tokenHash: TOKEN_HASH, + expiresAt: "2099-01-01T00:00:00Z", + createdAt: "2026-01-01T00:00:00Z", + } as const; +} + +function snapshotKey(index: number): string { + const hash = index.toString(16).padStart(64, "0"); + return `sync-snapshots/us-east/${OWNER_HASH}/snapshot-${index}/${hash}.bin`; +} + +function deletedCandidateCount(databasePath: string): unknown { + return query(databasePath, ` + SELECT COUNT(*) AS count FROM sync_r2_gc_candidates WHERE state = 'deleted' + `)[0]?.count; +} + +function candidateState(databasePath: string, key: string): unknown { + return query(databasePath, ` + SELECT state FROM sync_r2_gc_candidates WHERE r2_key = '${key}' + `)[0]?.state; +} + +class TestBucket { + readonly deletes: string[] = []; + readonly values = new Map(); + failDeletes = false; + + get(key: string): Promise { + const value = this.values.get(key); + return Promise.resolve(value === undefined ? null : object(value)); + } + + put(key: string, value: ArrayBuffer, _options?: ElyR2PutOptions): Promise { + this.values.set(key, value); + return Promise.resolve(object(value)); + } + + delete(key: string): Promise { + if (this.failDeletes) return Promise.reject(new Error("r2_delete_failed")); + this.deletes.push(key); + this.values.delete(key); + return Promise.resolve(); + } + + list(options: { prefix: string; cursor?: string; limit: number }) { + const objects = [...this.values.keys()] + .filter((key) => key.startsWith(options.prefix)) + .slice(0, options.limit) + .map((key) => ({ key })); + return Promise.resolve({ objects, truncated: false as const }); + } + + get size(): number { + return this.values.size; + } +} + +class TestKv { + readonly values = new Map(); + failDeletes = false; + failLists = false; + + get(key: string): Promise { + return Promise.resolve(this.values.get(key) ?? null); + } + + put(key: string, value: string): Promise { + this.values.set(key, value); + return Promise.resolve(); + } + + delete(key: string): Promise { + if (this.failDeletes) return Promise.reject(new Error("kv_delete_failed")); + this.values.delete(key); + return Promise.resolve(); + } + + list(options: { prefix: string; cursor?: string; limit: number }) { + if (this.failLists) return Promise.reject(new Error("kv_list_failed")); + const keys = [...this.values.keys()] + .filter((key) => key.startsWith(options.prefix)) + .slice(0, options.limit) + .map((name) => ({ name })); + return Promise.resolve({ keys, list_complete: true as const }); + } +} + +function object(value: ArrayBuffer): ElyR2Object { + return { arrayBuffer: () => Promise.resolve(value) }; +} diff --git a/cloudflare/tests/api_controls.test.ts b/cloudflare/tests/api_controls.test.ts index 6fcd5de..9ff0540 100644 --- a/cloudflare/tests/api_controls.test.ts +++ b/cloudflare/tests/api_controls.test.ts @@ -216,6 +216,7 @@ describe("api controls", () => { id: "session-01", userId: "user-01", expiresAt: "2099-01-01T00:00:00.000Z", + createdAt: new Date().toISOString(), deviceId: "device-01", }, ], @@ -300,6 +301,7 @@ describe("api controls", () => { id: "session-01", userId: "user-01", expiresAt: "2026-01-01T00:00:00.000Z", + createdAt: "2025-01-01T00:00:00.000Z", deviceId: "device-01", }, ], @@ -419,6 +421,7 @@ function testD1Database(options: TestD1DatabaseOptions = {}): Env["ELY_DB"] { id: "session-01", userId: "user-01", expiresAt: "2099-01-01T00:00:00.000Z", + createdAt: new Date().toISOString(), deviceId: "device-01", }, ], diff --git a/cloudflare/tests/destructive_action_routes.test.ts b/cloudflare/tests/destructive_action_routes.test.ts new file mode 100644 index 0000000..3e9090b --- /dev/null +++ b/cloudflare/tests/destructive_action_routes.test.ts @@ -0,0 +1,277 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { handleRequest } from "../src/index.js"; +import { + type SensitiveAction, + recentDeviceActionProofBytes, + recentDeviceActionRequestHash, +} from "../src/recent_device_action_proof.js"; +import { + ACCESS_TOKEN, + PUBLIC_KEY, + signDeviceMessage, + testD1Database, + testEnv, +} from "./devices_test_support.js"; + +const NOW = 1_780_001_000; + +interface ActionCase { + action: SensitiveAction; + path: string; + confirmation: string; + idempotencyKey: string; + forbiddenError: string; + failedError: string; + existingEvent: Record; +} + +const ACTIONS: ActionCase[] = [ + { + action: "sync.reset", + path: "/api/sync/reset", + confirmation: "delete-cloud-sync-data", + idempotencyKey: "sync-reset-security-0001", + forbiddenError: "sync_reset_forbidden", + failedError: "sync_reset_failed", + existingEvent: { + actor_device_id: "device-01", + outcome: "success", + created_at: NOW, + }, + }, + { + action: "account.delete", + path: "/api/account/delete", + confirmation: "delete-elydora-account", + idempotencyKey: "account-delete-security-0001", + forbiddenError: "account_deletion_forbidden", + failedError: "account_deletion_failed", + existingEvent: { + actor_device_id: "device-01", + outcome: "success", + subject_id: "2fb6b7445391dae3bf4fb63927132e773d8d00e5963b5270dddecc84e99811fa", + created_at: NOW, + }, + }, +]; + +describe("destructive action proofs", () => { + it("blocks a stolen bearer without the device private key", async () => { + for (const action of ACTIONS) { + const body = await actionBody(action, NOW); + body.action_proof = "0".repeat(128); + const response = await actionRequest(action, body, newActionRows(action)); + + assert.equal(response.status, 403); + assert.deepEqual(await response.json(), { error: action.forbiddenError }); + } + }); + + it("binds the action, session, and idempotency key", async () => { + for (const action of ACTIONS) { + for (const changed of ["action", "session", "idempotency"] as const) { + const signedAction = changed === "action" + ? action.action === "sync.reset" ? "account.delete" : "sync.reset" + : action.action; + const body = await actionBody( + action, + NOW, + signedAction, + changed === "session" ? "session-02" : "session-01", + ); + if (changed === "idempotency") { + body.idempotency_key = `${action.idempotencyKey}-changed`; + } + const response = await actionRequest(action, body, newActionRows(action)); + + assert.equal(response.status, 403); + assert.deepEqual(await response.json(), { error: action.forbiddenError }); + } + } + }); + + it("requires freshness for a new destructive action", async () => { + for (const action of ACTIONS) { + const response = await actionRequest( + action, + await actionBody(action, NOW - 301), + newActionRows(action, true), + ); + + assert.equal(response.status, 403); + assert.deepEqual(await response.json(), { error: action.forbiddenError }); + } + }); + + it("verifies old proofs for exact idempotent replays without requiring freshness", async () => { + for (const action of ACTIONS) { + const body = await actionBody(action, NOW - 301); + const event = { + ...action.existingEvent, + ...(action.action === "account.delete" + ? { metadata_hash: await requestHash(action, body) } + : {}), + }; + const response = await actionRequest(action, body, replayRows(action, event)); + + assert.equal(response.status, 200); + } + }); + + it("maps a malformed stored signing key to a persistence failure", async () => { + for (const action of ACTIONS) { + const rows = newActionRows(action); + rows[rows.length - 1] = { signing_public_key: "invalid" }; + const response = await actionRequest(action, await actionBody(action, NOW), rows); + + assert.equal(response.status, 500); + assert.deepEqual(await response.json(), { error: action.failedError }); + } + }); + + it("opens a fresh primary session after a concurrent replay abort", async () => { + const action = ACTIONS[0]; + assert.ok(action !== undefined); + const proofCreatedAt = Math.floor(Date.now() / 1000); + const d1 = testD1Database({ + firstRows: [ + { device_id: "device-01" }, + { signing_public_key: PUBLIC_KEY }, + null, + { objects: 0, changes: 0, snapshots: 0, tombstones: 0 }, + action.existingEvent, + ], + allRows: [], + batchError: new Error("UNIQUE constraint failed: audit_events.event_id"), + }); + const response = await handleRequest( + new Request(`https://elydora.test${action.path}`, { + method: "POST", + headers: { + authorization: `Bearer ${ACCESS_TOKEN}`, + "content-type": "application/json", + }, + body: JSON.stringify(await actionBody(action, proofCreatedAt)), + }), + testEnv({ d1 }), + ); + + assert.equal(response.status, 200); + assert.deepEqual(d1.sessionConstraints, ["first-primary", "first-primary"]); + }); + + it("requires the exact timestamp and session for an account deletion replay", async () => { + const action = ACTIONS[1]; + assert.ok(action !== undefined); + const body = await actionBody(action, NOW - 301); + const event = { + ...action.existingEvent, + metadata_hash: await requestHash(action, body), + }; + + const changedTimestamp = { ...body, proof_created_at: NOW - 300 }; + const timestampResponse = await actionRequest(action, changedTimestamp, [event]); + assert.equal(timestampResponse.status, 400); + + const d1 = testD1Database({ + firstRows: [event], + sessionRow: { + id: "session-02", + userId: "user-01", + expiresAt: "2099-01-01T00:00:00.000Z", + createdAt: "2026-01-01T00:00:00.000Z", + deviceId: "device-01", + }, + }); + const sessionResponse = await handleRequest( + new Request(`https://elydora.test${action.path}`, { + method: "POST", + headers: { + authorization: `Bearer ${ACCESS_TOKEN}`, + "content-type": "application/json", + }, + body: JSON.stringify(body), + }), + testEnv({ d1 }), + ); + assert.equal(sessionResponse.status, 400); + }); +}); + +async function actionRequest( + action: ActionCase, + body: Record, + firstRows: unknown[], +): Promise { + return handleRequest( + new Request(`https://elydora.test${action.path}`, { + method: "POST", + headers: { + authorization: `Bearer ${ACCESS_TOKEN}`, + "content-type": "application/json", + }, + body: JSON.stringify(body), + }), + testEnv({ d1: testD1Database({ firstRows }) }), + ); +} + +function newActionRows(action: ActionCase, includeEventMiss = false): unknown[] { + if (action.action === "account.delete") { + return [null, { signing_public_key: PUBLIC_KEY }]; + } + return [ + { device_id: "device-01" }, + { signing_public_key: PUBLIC_KEY }, + ...(includeEventMiss ? [null] : []), + ]; +} + +function replayRows(action: ActionCase, event: Record): unknown[] { + return action.action === "account.delete" + ? [event] + : [{ device_id: "device-01" }, { signing_public_key: PUBLIC_KEY }, event]; +} + +async function actionBody( + action: ActionCase, + proofCreatedAt: number, + signedAction = action.action, + signedSessionId = "session-01", +): Promise> { + const body = { + version: 2, + confirmation: action.confirmation, + idempotency_key: action.idempotencyKey, + proof_created_at: proofCreatedAt, + action_proof: "", + }; + body.action_proof = await signDeviceMessage(recentDeviceActionProofBytes({ + action: signedAction, + userId: "user-01", + sessionId: signedSessionId, + deviceId: "device-01", + confirmation: body.confirmation, + idempotencyKey: body.idempotency_key, + proofCreatedAt, + })); + return body; +} + +function requestHash( + action: ActionCase, + body: Record, +): Promise { + return recentDeviceActionRequestHash({ + action: action.action, + userId: "user-01", + sessionId: "session-01", + deviceId: "device-01", + confirmation: String(body.confirmation), + idempotencyKey: String(body.idempotency_key), + proofCreatedAt: Number(body.proof_created_at), + actionProof: String(body.action_proof), + }); +} diff --git a/cloudflare/tests/device_revocation_handler_sqlite.test.ts b/cloudflare/tests/device_revocation_handler_sqlite.test.ts new file mode 100644 index 0000000..45674e6 --- /dev/null +++ b/cloudflare/tests/device_revocation_handler_sqlite.test.ts @@ -0,0 +1,391 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, it } from "node:test"; +import { DeviceConflictError } from "../src/device_schema.js"; +import { revokeDeviceDocument } from "../src/device_revocation.js"; +import { + type ApprovedDeviceRevocationRequest, + type PendingDeviceRevocationRequest, + deviceRevocationProofBytes, + pendingDeviceRevocationProofBytes, +} from "../src/device_revocation_schema.js"; +import { + PUBLIC_KEY, + WRAPPING_PUBLIC_KEY, + signDeviceMessage, + testEnv, +} from "./devices_test_support.js"; +import { SqliteD1Database, execute, query } from "./sqlite_d1_test_support.js"; + +const MIGRATIONS_DIR = join(process.cwd(), "migrations"); +const USER_ID = "user-01", APPROVER_ID = "device-01"; +const TARGET_ID = "device-02", REMAINING_ID = "device-03"; +const OLD_KEY = "a".repeat(64), NEW_KEY = "b".repeat(64); +const HASH = "c".repeat(64), USER_HASH = "d".repeat(64); +const IDEMPOTENCY_KEY = "rotation-key-0001", NOW = 200; +const SUITE = "HPKE-BASE-X25519-HKDF-SHA256-CHACHA20POLY1305"; +const PAYLOAD_R2_KEY = `sync-payloads/us/${USER_HASH}/bookmarks/object-01/${HASH}.bin`; +const SNAPSHOT_R2_KEY = `sync-snapshots/us/${USER_HASH}/snapshot-01/${HASH}.bin`; +describe("device revocation real D1 flow", () => { + it("executes the handler queries and trigger atomically", async () => { + await withDatabase(async (databasePath) => { + const database = new SqliteD1Database(databasePath); + const document = await revokeDeviceDocument( + await revocationRequest(), + testEnv({ d1: database }), + authContext(), + NOW, + ); + + assert.equal(document.mode, "approved_rotate"); + if (document.mode !== "approved_rotate") throw new Error("approved rotation expected"); + assert.equal(document.generation, 2); + assert.equal(document.key_id, NEW_KEY); + assert.equal(document.device.approval_status, "revoked"); + assert.equal(document.device.revoked_at, NOW); + assert.deepEqual(database.batches, [4]); + assert.deepEqual(query(databasePath, ` + SELECT current_key_id, current_generation + FROM sync_vault_accounts WHERE user_id = '${USER_ID}' + `), [{ current_key_id: NEW_KEY, current_generation: 2 }]); + assert.deepEqual(query(databasePath, ` + SELECT recipient_device_id, approver_device_id, key_id, generation, + envelope_version, suite, encapped_key, ciphertext, created_at + FROM sync_vault_envelopes + WHERE user_id = '${USER_ID}' AND key_id = '${NEW_KEY}' + ORDER BY recipient_device_id + `), [ + envelopeRow(APPROVER_ID, "A".repeat(43), "B".repeat(64)), + envelopeRow(REMAINING_ID, `${"C".repeat(42)}E`, "D".repeat(64)), + ]); + assert.deepEqual(query(databasePath, ` + SELECT target.approval_status, target.revoked_at, + rotation.previous_generation, rotation.new_generation, + rotation.envelope_count, rotation.r2_object_count, + rotation.completed_at + FROM user_devices AS target + INNER JOIN sync_vault_rotations AS rotation + ON rotation.user_id = target.user_id + AND rotation.target_device_id = target.device_id + WHERE target.user_id = '${USER_ID}' AND target.device_id = '${TARGET_ID}' + `), [{ + approval_status: "revoked", + revoked_at: NOW, + previous_generation: 1, + new_generation: 2, + envelope_count: 2, + r2_object_count: 2, + completed_at: NOW, + }]); + assert.deepEqual(query(databasePath, ` + SELECT actor_device_id, event_type, subject_type, subject_id, outcome, created_at, + event_id = 'device-revoke:' || (SELECT request_hash FROM sync_vault_rotations + WHERE user_id = '${USER_ID}' AND idempotency_key = '${IDEMPOTENCY_KEY}') + AS event_id_matches, + metadata_hash = (SELECT request_hash FROM sync_vault_rotations + WHERE user_id = '${USER_ID}' AND idempotency_key = '${IDEMPOTENCY_KEY}') + AS request_hash_matches + FROM audit_events WHERE user_id = '${USER_ID}' + `), [{ + actor_device_id: APPROVER_ID, + event_type: "device.revoke", + subject_type: "device", + subject_id: TARGET_ID, + outcome: "success", + created_at: NOW, + event_id_matches: 1, + request_hash_matches: 1, + }]); + assert.deepEqual(query(databasePath, ` + SELECT + (SELECT COUNT(*) FROM sync_objects WHERE user_id = '${USER_ID}') AS objects, + (SELECT COUNT(*) FROM sync_snapshots WHERE user_id = '${USER_ID}') AS snapshots, + (SELECT COUNT(*) FROM sync_snapshot_encryption WHERE user_id = '${USER_ID}') AS encryption, + (SELECT COUNT(*) FROM sync_vault_rotation_r2_objects + WHERE user_id = '${USER_ID}') AS staged_r2, + (SELECT COUNT(*) FROM better_auth_session + WHERE id = 'target-session') AS target_sessions + `), [{ objects: 1, snapshots: 1, encryption: 1, staged_r2: 2, target_sessions: 0 }]); + assert.deepEqual(query(databasePath, ` + SELECT r2_key FROM sync_vault_rotation_r2_objects + WHERE user_id = '${USER_ID}' AND rotation_idempotency_key = '${IDEMPOTENCY_KEY}' + ORDER BY r2_key + `), [{ r2_key: PAYLOAD_R2_KEY }, { r2_key: SNAPSHOT_R2_KEY }]); + }); + }); + + it("rolls back the rotation when the recipient set changes before batch", async () => { + await withDatabase(async (databasePath) => { + const database = new SqliteD1Database(databasePath, raceDeviceSql()); + await assert.rejects( + revokeDeviceDocument( + await revocationRequest(), + testEnv({ d1: database }), + authContext(), + NOW, + ), + (error: unknown) => + error instanceof DeviceConflictError && error.message === "device_revocation_race", + ); + assert.deepEqual(query(databasePath, ` + SELECT + (SELECT current_generation FROM sync_vault_accounts + WHERE user_id = '${USER_ID}') AS generation, + (SELECT approval_status FROM user_devices + WHERE user_id = '${USER_ID}' AND device_id = '${TARGET_ID}') AS target_status, + (SELECT COUNT(*) FROM sync_vault_rotations WHERE user_id = '${USER_ID}') AS rotations, + (SELECT COUNT(*) FROM sync_vault_envelopes + WHERE user_id = '${USER_ID}' AND key_id = '${NEW_KEY}') AS envelopes, + (SELECT COUNT(*) FROM audit_events WHERE user_id = '${USER_ID}') AS audits + `), [{ + generation: 1, + target_status: "approved", + rotations: 0, + envelopes: 0, + audits: 0, + }]); + assert.deepEqual(query(databasePath, ` + SELECT COUNT(*) AS target_sessions FROM better_auth_session + WHERE id = 'target-session' + `), [{ target_sessions: 1 }]); + }); + }); + + it("revokes a pending device without changing vault or sync state", async () => { + await withDatabase(async (databasePath) => { + execute(databasePath, ` + UPDATE user_devices SET approval_status = 'pending', approved_at = NULL + WHERE user_id = '${USER_ID}' AND device_id = '${TARGET_ID}'; + `); + const database = new SqliteD1Database(databasePath); + const document = await revokeDeviceDocument( + await pendingRevocationRequest(), + testEnv({ d1: database }), + authContext(), + NOW, + ); + assert.equal(document.mode, "pending_revoke"); + assert.equal(document.device.approval_status, "revoked"); + assert.deepEqual(database.batches, [2]); + assert.deepEqual(query(databasePath, ` + SELECT + (SELECT current_generation FROM sync_vault_accounts + WHERE user_id = '${USER_ID}') AS generation, + (SELECT COUNT(*) FROM sync_vault_rotations WHERE user_id = '${USER_ID}') AS rotations, + (SELECT COUNT(*) FROM pending_device_revocations + WHERE user_id = '${USER_ID}') AS pending_revocations, + (SELECT COUNT(*) FROM sync_objects WHERE user_id = '${USER_ID}') AS objects, + (SELECT COUNT(*) FROM sync_snapshots WHERE user_id = '${USER_ID}') AS snapshots, + (SELECT COUNT(*) FROM better_auth_session + WHERE id = 'target-session') AS target_sessions + `), [{ + generation: 1, + rotations: 0, + pending_revocations: 1, + objects: 1, + snapshots: 1, + target_sessions: 0, + }]); + }); + }); +}); + +async function revocationRequest(): Promise { + const envelopes: ApprovedDeviceRevocationRequest["envelopes"] = [ + rotationEnvelope(APPROVER_ID, "A".repeat(43), "B".repeat(64)), + rotationEnvelope(REMAINING_ID, `${"C".repeat(42)}E`, "D".repeat(64)), + ]; + const unsigned: Omit = { + mode: "approved_rotate", + deviceId: TARGET_ID, + previousKeyId: OLD_KEY, + previousGeneration: 1, + newKeyId: NEW_KEY, + newGeneration: 2, + envelopes, + idempotencyKey: IDEMPOTENCY_KEY, + }; + return new Request("https://elydora.test/api/devices/revoke", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + version: 2, + mode: "approved_rotate", + device_id: TARGET_ID, + previous_key_id: OLD_KEY, + previous_generation: 1, + new_key_id: NEW_KEY, + new_generation: 2, + envelopes: envelopes.map((item) => ({ + recipient_device_id: item.recipientDeviceId, + envelope: item.envelope, + })), + idempotency_key: IDEMPOTENCY_KEY, + rotation_proof: await signDeviceMessage( + deviceRevocationProofBytes(USER_ID, APPROVER_ID, unsigned), + ), + }), + }); +} + +async function pendingRevocationRequest(): Promise { + const unsigned: Omit = { + mode: "pending_revoke", + deviceId: TARGET_ID, + idempotencyKey: IDEMPOTENCY_KEY, + }; + return new Request("https://elydora.test/api/devices/revoke", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + version: 2, + mode: "pending_revoke", + device_id: TARGET_ID, + idempotency_key: IDEMPOTENCY_KEY, + pending_revocation_proof: await signDeviceMessage( + pendingDeviceRevocationProofBytes(USER_ID, APPROVER_ID, unsigned), + ), + }), + }); +} + +function rotationEnvelope( + recipientDeviceId: string, + encappedKey: string, + ciphertext: string, +): ApprovedDeviceRevocationRequest["envelopes"][number] { + return { + recipientDeviceId, + envelope: { version: 1, suite: SUITE, encapped_key: encappedKey, ciphertext }, + }; +} + +function envelopeRow( + recipientDeviceId: string, + encappedKey: string, + ciphertext: string, +): Record { + return { + recipient_device_id: recipientDeviceId, + approver_device_id: APPROVER_ID, + key_id: NEW_KEY, + generation: 2, + envelope_version: 1, + suite: SUITE, + encapped_key: encappedKey, + ciphertext, + created_at: NOW, + }; +} + +function authContext() { + return { + userId: USER_ID, + sessionId: "session-01", + tokenHash: "0".repeat(64), + expiresAt: "2099-01-01T00:00:00.000Z", + createdAt: "2026-01-01T00:00:00.000Z", + deviceId: APPROVER_ID, + }; +} + +function seedSql(): string { + return ` + INSERT INTO better_auth_user + (id, name, email, emailVerified, createdAt, updatedAt) + VALUES ('${USER_ID}', 'User', 'user@example.com', 1, '2026-01-01', '2026-01-01'); + INSERT INTO user_devices + (user_id, device_id, public_key, device_name, platform, approval_status, + created_at, approved_at, last_active_at, revoked_at, idempotency_key) + VALUES + ('${USER_ID}', '${APPROVER_ID}', '${PUBLIC_KEY}', 'Approver', 'macOS', 'approved', + 10, 11, 12, NULL, 'device-register-0001'), + ('${USER_ID}', '${TARGET_ID}', '${PUBLIC_KEY}', 'Target', 'macOS', 'approved', + 10, 11, 12, NULL, 'device-register-0002'), + ('${USER_ID}', '${REMAINING_ID}', '${PUBLIC_KEY}', 'Remaining', 'macOS', 'approved', + 10, 11, 12, NULL, 'device-register-0003'); + INSERT INTO user_device_keys + (user_id, device_id, signing_public_key, wrapping_public_key, + key_protocol_version, created_at) + VALUES + ('${USER_ID}', '${APPROVER_ID}', '${PUBLIC_KEY}', '${WRAPPING_PUBLIC_KEY}', 2, 10), + ('${USER_ID}', '${TARGET_ID}', '${PUBLIC_KEY}', '${WRAPPING_PUBLIC_KEY}', 2, 10), + ('${USER_ID}', '${REMAINING_ID}', '${PUBLIC_KEY}', '${WRAPPING_PUBLIC_KEY}', 2, 10); + INSERT INTO better_auth_session + (id, expiresAt, token, createdAt, updatedAt, userId) + VALUES + ('target-session', '2099-01-01', 'target-session-token', + '2026-01-01', '2026-01-01', '${USER_ID}'); + INSERT INTO better_auth_session_device_context + (session_id, user_id, device_id, updated_at) + VALUES ('target-session', '${USER_ID}', '${TARGET_ID}', 15); + INSERT INTO sync_vault_accounts + (user_id, current_key_id, current_generation, created_at, updated_at) + VALUES ('${USER_ID}', '${OLD_KEY}', 1, 20, 20); + INSERT INTO sync_r2_gc_candidates ( + r2_key, user_id, owner_hash, object_kind, state, write_token, + lease_expires_at, gc_token, created_at, updated_at, referenced_at, + ready_at, delete_started_at, deleted_at + ) VALUES ( + '${PAYLOAD_R2_KEY}', '${USER_ID}', '${USER_HASH}', 'payload', 'pending', + '${"1".repeat(64)}', 1000, NULL, 30, 30, NULL, NULL, NULL, NULL + ); + INSERT INTO sync_objects + (user_id, object_id, object_type, payload_inline, payload_r2_key, payload_hash, + schema_rev, logical_clock, device_id, created_at, updated_at, deleted_at) + VALUES ('${USER_ID}', 'object-01', 'bookmarks', NULL, '${PAYLOAD_R2_KEY}', '${HASH}', + 1, 1, '${APPROVER_ID}', 30, 30, NULL); + UPDATE sync_r2_gc_candidates + SET state = 'referenced', referenced_at = 30, updated_at = 30 + WHERE r2_key = '${PAYLOAD_R2_KEY}'; + INSERT INTO sync_r2_gc_candidates ( + r2_key, user_id, owner_hash, object_kind, state, write_token, + lease_expires_at, gc_token, created_at, updated_at, referenced_at, + ready_at, delete_started_at, deleted_at + ) VALUES ( + '${SNAPSHOT_R2_KEY}', '${USER_ID}', '${USER_HASH}', 'snapshot', 'pending', + '${"2".repeat(64)}', 1000, NULL, 40, 40, NULL, NULL, NULL, NULL + ); + INSERT INTO sync_snapshots + (user_id, snapshot_id, r2_key, payload_hash, schema_rev, logical_clock, + device_id, size_bytes, created_at) + VALUES ('${USER_ID}', 'snapshot-01', '${SNAPSHOT_R2_KEY}', '${HASH}', 1, 1, + '${APPROVER_ID}', 64, 40); + INSERT INTO sync_snapshot_encryption + (user_id, snapshot_id, encryption_version, vault_generation, key_id, content_hash) + VALUES ('${USER_ID}', 'snapshot-01', 1, 1, '${OLD_KEY}', '${HASH}'); + `; +} + +function raceDeviceSql(): string { + return ` + INSERT INTO user_devices + (user_id, device_id, public_key, device_name, platform, approval_status, + created_at, approved_at, last_active_at, revoked_at, idempotency_key) + VALUES ('${USER_ID}', 'device-04', '${PUBLIC_KEY}', 'Race', 'macOS', 'approved', + 100, 101, 102, NULL, 'device-register-0004'); + INSERT INTO user_device_keys + (user_id, device_id, signing_public_key, wrapping_public_key, + key_protocol_version, created_at) + VALUES ('${USER_ID}', 'device-04', '${PUBLIC_KEY}', '${WRAPPING_PUBLIC_KEY}', 2, 100); + `; +} + +async function withDatabase(run: (databasePath: string) => Promise): Promise { + const tempDir = mkdtempSync(join(tmpdir(), "ely-revoke-handler-")); + try { + const databasePath = join(tempDir, "ely.db"); + const migrations = readdirSync(MIGRATIONS_DIR) + .filter((name) => name.endsWith(".sql")) + .sort() + .map((name) => readFileSync(join(MIGRATIONS_DIR, name), "utf8")) + .join("\n"); + execute(databasePath, migrations); + execute(databasePath, seedSql()); + await run(databasePath); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +} diff --git a/cloudflare/tests/device_revocation_proof.test.ts b/cloudflare/tests/device_revocation_proof.test.ts new file mode 100644 index 0000000..e5cf438 --- /dev/null +++ b/cloudflare/tests/device_revocation_proof.test.ts @@ -0,0 +1,194 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + compareDeviceIds, + deviceRevocationProofBytes, + deviceRevocationRequest, + pendingDeviceRevocationProofBytes, +} from "../src/device_revocation_schema.js"; +import { DeviceSchemaError } from "../src/device_schema.js"; +import { handleRequest } from "../src/index.js"; +import { + ACCESS_TOKEN, + PUBLIC_KEY, + signDeviceMessage, + testD1Database, + testEnv, +} from "./devices_test_support.js"; + +const USER_ID = "user-01", APPROVER_ID = "device-01", TARGET_ID = "device-02"; +const OLD_KEY = "a".repeat(64), NEW_KEY = "b".repeat(64); +const IDEMPOTENCY_KEY = "device-revocation-0001"; +const SUITE = "HPKE-BASE-X25519-HKDF-SHA256-CHACHA20POLY1305"; + +describe("device revocation proof schema", () => { + it("rejects malformed envelope recipients and rotation metadata", async () => { + for (const body of [ + approvedBody({ envelopes: [envelope(TARGET_ID)] }), + approvedBody({ envelopes: [envelope(APPROVER_ID), envelope(APPROVER_ID)] }), + approvedBody({ envelopes: [envelope(APPROVER_ID, { encapped_key: `${"A".repeat(42)}B` })] }), + approvedBody({ new_generation: 3 }), + approvedBody({ new_key_id: OLD_KEY }), + { ...approvedBody(), mode: undefined }, + { ...pendingBody(), new_key_id: NEW_KEY }, + ]) { + await assert.rejects( + deviceRevocationRequest(request({ ...body, rotation_proof: "0".repeat(128) })), + DeviceSchemaError, + ); + } + }); + + it("uses one ASCII code-unit order for proof and exact recipient checks", async () => { + const ids = ["a_1", "a:1", "a.1", "a-1", "A_1", "A-1"]; + const expected = ["A-1", "A_1", "a-1", "a.1", "a:1", "a_1"]; + assert.deepEqual([...ids].sort(compareDeviceIds), expected); + const parsed = await deviceRevocationRequest(request( + await signedBody(approvedBody({ envelopes: ids.map((id) => envelope(id)) })), + )); + assert.equal(parsed.mode, "approved_rotate"); + if (parsed.mode !== "approved_rotate") throw new Error("approved rotation expected"); + assert.deepEqual(parsed.envelopes.map((item) => item.recipientDeviceId), expected); + }); + + it("uses the frozen v2 approved rotation proof wire", async () => { + const parsed = await deviceRevocationRequest(request( + await signedBody(approvedBody({ envelopes: [envelope(APPROVER_ID)] })), + )); + if (parsed.mode !== "approved_rotate") throw new Error("approved rotation expected"); + const { rotationProof: _, ...unsigned } = parsed; + assert.equal(new TextDecoder().decode( + deviceRevocationProofBytes(USER_ID, APPROVER_ID, unsigned), + ), [ + "28:elydora-device-revocation-v2", + "7:user-01", + "9:device-01", + "9:device-02", + `64:${OLD_KEY}`, + "1:1", + `64:${NEW_KEY}`, + "1:2", + "22:device-revocation-0001", + "1:1", + "9:device-01", + "1:1", + `45:${SUITE}`, + `43:${"A".repeat(43)}`, + `64:${"B".repeat(64)}`, + ].join("")); + }); + + it("uses the frozen v2 pending revocation proof wire", async () => { + const parsed = await deviceRevocationRequest(request(await signedBody(pendingBody()))); + if (parsed.mode !== "pending_revoke") throw new Error("pending revocation expected"); + const { pendingRevocationProof: _, ...unsigned } = parsed; + assert.equal(new TextDecoder().decode( + pendingDeviceRevocationProofBytes(USER_ID, APPROVER_ID, unsigned), + ), [ + "36:elydora-pending-device-revocation-v2", + "7:user-01", + "9:device-01", + "9:device-02", + "22:device-revocation-0001", + ].join("")); + }); + + it("rejects invalid and tampered proofs before revocation state reads", async () => { + const cases = [ + approvedBody({ rotation_proof: "0".repeat(128) }), + { ...await signedBody(approvedBody()), new_key_id: "c".repeat(64) }, + pendingBody({ pending_revocation_proof: "0".repeat(128) }), + ]; + for (const body of cases) { + const d1 = testD1Database({ firstRows: [approverRow()] }); + const response = await handleRequest(request(body, true), testEnv({ d1 })); + assert.equal(response.status, 403); + assert.equal(d1.queries.length, 1); + assert.deepEqual(d1.batches, []); + } + }); +}); + +function approvedBody(overrides: Record = {}): Record { + return { + version: 2, + mode: "approved_rotate", + device_id: TARGET_ID, + previous_key_id: OLD_KEY, + previous_generation: 1, + new_key_id: NEW_KEY, + new_generation: 2, + envelopes: [envelope("device-03"), envelope(APPROVER_ID)], + idempotency_key: IDEMPOTENCY_KEY, + ...overrides, + }; +} + +function pendingBody(overrides: Record = {}): Record { + return { + version: 2, + mode: "pending_revoke", + device_id: TARGET_ID, + idempotency_key: IDEMPOTENCY_KEY, + ...overrides, + }; +} + +function envelope( + recipient: string, + overrides: Record = {}, +): Record { + const other = recipient === "device-03"; + return { + recipient_device_id: recipient, + envelope: { + version: 1, + suite: SUITE, + encapped_key: other ? `${"C".repeat(42)}E` : "A".repeat(43), + ciphertext: other ? "D".repeat(64) : "B".repeat(64), + ...overrides, + }, + }; +} + +async function signedBody(body: Record): Promise> { + if (body.mode === "pending_revoke") { + if (body.pending_revocation_proof !== undefined) return body; + const draft = { ...body, pending_revocation_proof: "0".repeat(128) }; + const parsed = await deviceRevocationRequest(request(draft)); + if (parsed.mode !== "pending_revoke") throw new Error("pending revocation expected"); + const { pendingRevocationProof: _, ...unsigned } = parsed; + return { + ...body, + pending_revocation_proof: await signDeviceMessage( + pendingDeviceRevocationProofBytes(USER_ID, APPROVER_ID, unsigned), + ), + }; + } + if (body.rotation_proof !== undefined) return body; + const draft = { ...body, rotation_proof: "0".repeat(128) }; + const parsed = await deviceRevocationRequest(request(draft)); + if (parsed.mode !== "approved_rotate") throw new Error("approved rotation expected"); + const { rotationProof: _, ...unsigned } = parsed; + return { + ...body, + rotation_proof: await signDeviceMessage( + deviceRevocationProofBytes(USER_ID, APPROVER_ID, unsigned), + ), + }; +} + +function request(body: Record, authenticated = false): Request { + return new Request("https://elydora.test/api/devices/revoke", { + method: "POST", + headers: { + ...(authenticated ? { authorization: `Bearer ${ACCESS_TOKEN}` } : {}), + "content-type": "application/json", + }, + body: JSON.stringify(body), + }); +} + +function approverRow(): Record { + return { device_id: APPROVER_ID, signing_public_key: PUBLIC_KEY }; +} diff --git a/cloudflare/tests/device_revocation_sqlite.test.ts b/cloudflare/tests/device_revocation_sqlite.test.ts new file mode 100644 index 0000000..41ca7b8 --- /dev/null +++ b/cloudflare/tests/device_revocation_sqlite.test.ts @@ -0,0 +1,230 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, it } from "node:test"; + +const MIGRATIONS_DIR = join(process.cwd(), "migrations"); +const OLD_KEY = "a".repeat(64); +const NEW_KEY = "b".repeat(64); +const HASH = "c".repeat(64); +const USER_HASH = "d".repeat(64); +const WRAPPING_KEY = "e".repeat(64); +const SIGNING_KEY = "f".repeat(64); +const PAYLOAD_R2_KEY = `sync-payloads/us/${USER_HASH}/bookmarks/object-01/${HASH}.bin`; +const SNAPSHOT_R2_KEY = `sync-snapshots/us/${USER_HASH}/snapshot-01/${HASH}.bin`; + +describe("sync vault rotation migration", () => { + it("finalizes atomically and retains the old head until replacement", () => { + withDatabase((databasePath) => { + execute(databasePath, seedSql()); + execute(databasePath, validRotationSql()); + + assert.deepEqual(query(databasePath, ` + SELECT current_key_id, current_generation FROM sync_vault_accounts WHERE user_id = 'user-01' + `), [{ current_key_id: NEW_KEY, current_generation: 2 }]); + assert.deepEqual(query(databasePath, ` + SELECT approval_status, revoked_at FROM user_devices + WHERE user_id = 'user-01' AND device_id = 'device-02' + `), [{ approval_status: "revoked", revoked_at: 200 }]); + assert.deepEqual(query(databasePath, ` + SELECT + (SELECT COUNT(*) FROM sync_vault_envelopes + WHERE user_id = 'user-01' AND key_id = '${NEW_KEY}' AND generation = 2) AS envelopes, + (SELECT COUNT(*) FROM audit_events + WHERE event_id = 'device-revoke:user-01:rotation-key-0001') AS audits, + (SELECT COUNT(*) FROM sync_vault_rotation_r2_objects + WHERE user_id = 'user-01' AND rotation_idempotency_key = 'rotation-key-0001') AS r2_manifest, + (SELECT COUNT(*) FROM sync_objects WHERE user_id = 'user-01') AS objects, + (SELECT COUNT(*) FROM sync_snapshots WHERE user_id = 'user-01') AS snapshots, + (SELECT COUNT(*) FROM sync_snapshot_encryption WHERE user_id = 'user-01') AS encryption + `), [{ envelopes: 2, audits: 1, r2_manifest: 2, objects: 1, snapshots: 1, encryption: 1 }]); + }); + }); + + it("rolls back every mutation when the staged recipient set is incomplete", () => { + withDatabase((databasePath) => { + execute(databasePath, seedSql()); + assert.throws( + () => execute(databasePath, invalidRotationSql()), + /sync_vault_rotation_guard_failed/, + ); + assert.deepEqual(query(databasePath, ` + SELECT + (SELECT current_generation FROM sync_vault_accounts WHERE user_id = 'user-01') AS generation, + (SELECT approval_status FROM user_devices + WHERE user_id = 'user-01' AND device_id = 'device-02') AS target_status, + (SELECT COUNT(*) FROM audit_events WHERE user_id = 'user-01') AS audits, + (SELECT COUNT(*) FROM sync_vault_rotations WHERE user_id = 'user-01') AS rotations + `), [{ generation: 1, target_status: "approved", audits: 0, rotations: 0 }]); + }); + }); + + it("quarantines approved protocol-v1 devices when 0011 is applied", () => { + withDatabase((databasePath) => { + execute(databasePath, ` + INSERT INTO better_auth_user + (id, name, email, emailVerified, createdAt, updatedAt) + VALUES ('legacy-user', 'Legacy', 'legacy@example.com', 1, '2026-01-01', '2026-01-01'); + INSERT INTO user_devices + (user_id, device_id, public_key, device_name, platform, approval_status, + created_at, approved_at, last_active_at, revoked_at, idempotency_key) + VALUES + ('legacy-user', 'legacy-device', '${SIGNING_KEY}', 'Legacy', 'macOS', 'approved', + 10, 11, 12, NULL, 'legacy-register-0001'); + INSERT INTO user_device_keys + (user_id, device_id, signing_public_key, wrapping_public_key, + key_protocol_version, created_at) + VALUES ('legacy-user', 'legacy-device', '${SIGNING_KEY}', NULL, 1, 10); + `); + execute(databasePath, readFileSync(join(MIGRATIONS_DIR, "0011_sync_vault_rotation.sql"), "utf8")); + assert.deepEqual(query(databasePath, ` + SELECT approval_status, revoked_at IS NOT NULL AS has_revoked_at + FROM user_devices WHERE user_id = 'legacy-user' AND device_id = 'legacy-device' + `), [{ approval_status: "revoked", has_revoked_at: 1 }]); + }); + }); +}); + +function seedSql(): string { + return ` + INSERT INTO better_auth_user + (id, name, email, emailVerified, createdAt, updatedAt) + VALUES ('user-01', 'User', 'user@example.com', 1, '2026-01-01', '2026-01-01'); + INSERT INTO user_devices + (user_id, device_id, public_key, device_name, platform, approval_status, + created_at, approved_at, last_active_at, revoked_at, idempotency_key) + VALUES + ('user-01', 'device-01', '${SIGNING_KEY}', 'Approver', 'macOS', 'approved', + 10, 11, 12, NULL, 'device-register-0001'), + ('user-01', 'device-02', '${SIGNING_KEY}', 'Target', 'macOS', 'approved', + 10, 11, 12, NULL, 'device-register-0002'), + ('user-01', 'device-03', '${SIGNING_KEY}', 'Remaining', 'macOS', 'approved', + 10, 11, 12, NULL, 'device-register-0003'); + INSERT INTO user_device_keys + (user_id, device_id, signing_public_key, wrapping_public_key, + key_protocol_version, created_at) + VALUES + ('user-01', 'device-01', '${SIGNING_KEY}', '${WRAPPING_KEY}', 2, 10), + ('user-01', 'device-02', '${SIGNING_KEY}', '${WRAPPING_KEY}', 2, 10), + ('user-01', 'device-03', '${SIGNING_KEY}', '${WRAPPING_KEY}', 2, 10); + INSERT INTO sync_vault_accounts + (user_id, current_key_id, current_generation, created_at, updated_at) + VALUES ('user-01', '${OLD_KEY}', 1, 20, 20); + INSERT INTO sync_r2_gc_candidates ( + r2_key, user_id, owner_hash, object_kind, state, write_token, + lease_expires_at, gc_token, created_at, updated_at, referenced_at, + ready_at, delete_started_at, deleted_at + ) VALUES ( + '${PAYLOAD_R2_KEY}', 'user-01', '${USER_HASH}', 'payload', 'pending', + '${"1".repeat(64)}', 1000, NULL, 30, 30, NULL, NULL, NULL, NULL + ); + INSERT INTO sync_objects + (user_id, object_id, object_type, payload_inline, payload_r2_key, payload_hash, + schema_rev, logical_clock, device_id, created_at, updated_at, deleted_at) + VALUES + ('user-01', 'object-01', 'bookmarks', NULL, '${PAYLOAD_R2_KEY}', '${HASH}', + 1, 1, 'device-01', 30, 30, NULL); + UPDATE sync_r2_gc_candidates + SET state = 'referenced', referenced_at = 30, updated_at = 30 + WHERE r2_key = '${PAYLOAD_R2_KEY}'; + INSERT INTO sync_r2_gc_candidates ( + r2_key, user_id, owner_hash, object_kind, state, write_token, + lease_expires_at, gc_token, created_at, updated_at, referenced_at, + ready_at, delete_started_at, deleted_at + ) VALUES ( + '${SNAPSHOT_R2_KEY}', 'user-01', '${USER_HASH}', 'snapshot', 'pending', + '${"2".repeat(64)}', 1000, NULL, 40, 40, NULL, NULL, NULL, NULL + ); + INSERT INTO sync_snapshots + (user_id, snapshot_id, r2_key, payload_hash, schema_rev, logical_clock, + device_id, size_bytes, created_at) + VALUES + ('user-01', 'snapshot-01', '${SNAPSHOT_R2_KEY}', '${HASH}', 1, 1, + 'device-01', 64, 40); + INSERT INTO sync_snapshot_encryption + (user_id, snapshot_id, encryption_version, vault_generation, key_id, content_hash) + VALUES ('user-01', 'snapshot-01', 1, 1, '${OLD_KEY}', '${HASH}'); + `; +} + +function validRotationSql(): string { + return ` + BEGIN IMMEDIATE; + ${rotationHeaderSql(2, 2)} + ${rotationEnvelopeSql("device-01", "1".repeat(64), "A".repeat(43), "B".repeat(64))} + ${rotationEnvelopeSql("device-03", "2".repeat(64), `${"C".repeat(42)}E`, "D".repeat(64))} + UPDATE sync_vault_rotations SET completed_at = 200 + WHERE user_id = 'user-01' AND idempotency_key = 'rotation-key-0001'; + COMMIT; + `; +} + +function invalidRotationSql(): string { + return ` + BEGIN IMMEDIATE; + ${rotationHeaderSql(2, 2)} + ${rotationEnvelopeSql("device-01", "1".repeat(64), "A".repeat(43), "B".repeat(64))} + UPDATE sync_vault_rotations SET completed_at = 200 + WHERE user_id = 'user-01' AND idempotency_key = 'rotation-key-0001'; + COMMIT; + `; +} + +function rotationHeaderSql(envelopeCount: number, r2Count: number): string { + return ` + INSERT INTO sync_vault_rotations + (user_id, idempotency_key, audit_event_id, target_device_id, approver_device_id, + previous_key_id, previous_generation, new_key_id, new_generation, request_hash, + envelope_count, r2_object_count, created_at, completed_at) + VALUES + ('user-01', 'rotation-key-0001', 'device-revoke:user-01:rotation-key-0001', + 'device-02', 'device-01', '${OLD_KEY}', 1, '${NEW_KEY}', 2, '${HASH}', + ${envelopeCount}, ${r2Count}, 100, NULL); + `; +} + +function rotationEnvelopeSql( + recipient: string, + idempotencyKey: string, + encappedKey: string, + ciphertext: string, +): string { + return ` + INSERT INTO sync_vault_rotation_envelopes + (user_id, rotation_idempotency_key, recipient_device_id, envelope_idempotency_key, + envelope_version, suite, encapped_key, ciphertext) + VALUES + ('user-01', 'rotation-key-0001', '${recipient}', '${idempotencyKey}', 1, + 'HPKE-BASE-X25519-HKDF-SHA256-CHACHA20POLY1305', '${encappedKey}', '${ciphertext}'); + `; +} + +function withDatabase(assertions: (databasePath: string) => void): void { + const tempDir = mkdtempSync(join(tmpdir(), "ely-rotation-")); + try { + const databasePath = join(tempDir, "ely.db"); + const migrations = readdirSync(MIGRATIONS_DIR) + .filter((name) => name.endsWith(".sql")) + .sort() + .map((name) => readFileSync(join(MIGRATIONS_DIR, name), "utf8")) + .join("\n"); + execute(databasePath, migrations); + assertions(databasePath); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +} + +function execute(databasePath: string, sql: string): void { + execFileSync("sqlite3", [databasePath], { + input: `.bail on\nPRAGMA foreign_keys = ON;\n${sql}`, + stdio: ["pipe", "pipe", "pipe"], + }); +} + +function query(databasePath: string, sql: string): Record[] { + const output = execFileSync("sqlite3", ["-json", databasePath, sql], { encoding: "utf8" }); + return JSON.parse(output) as Record[]; +} diff --git a/cloudflare/tests/device_trust_routes.test.ts b/cloudflare/tests/device_trust_routes.test.ts new file mode 100644 index 0000000..2a55241 --- /dev/null +++ b/cloudflare/tests/device_trust_routes.test.ts @@ -0,0 +1,296 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { handleRequest } from "../src/index.js"; +import { + ACCESS_TOKEN, + PUBLIC_KEY, + WRAPPING_PUBLIC_KEY, + deviceRegistrationBody, + signDeviceMessage, + testD1Database, + testEnv, +} from "./devices_test_support.js"; + +describe("device trust routes", () => { + it("atomically approves the first v2 device and stores both public keys", async () => { + const device = { + device_id: "device-01", + public_key: PUBLIC_KEY, + wrapping_public_key: WRAPPING_PUBLIC_KEY, + device_name: "MacBook Pro", + platform: "macOS", + approval_status: "approved", + created_at: 1_780_000_100, + approved_at: 1_780_000_100, + last_active_at: 1_780_000_100, + revoked_at: null, + }; + const d1 = testD1Database({ + firstRows: [device], + sessionRow: { + id: "session-01", + userId: "user-01", + expiresAt: "2099-01-01T00:00:00.000Z", + createdAt: new Date().toISOString(), + deviceId: null, + }, + }); + + const response = await handleRequest( + new Request("https://elydora.test/api/devices/register", { + method: "POST", + headers: { + authorization: `Bearer ${ACCESS_TOKEN}`, + "content-type": "application/json", + }, + body: JSON.stringify(await deviceRegistrationBody()), + }), + testEnv({ d1 }), + ); + + assert.equal(response.status, 201); + assert.equal(((await response.json()) as { device: { approval_status: string } }).device.approval_status, "approved"); + assert.ok(d1.queries.some((query) => query.includes("NOT EXISTS"))); + assert.ok( + d1.queries.some( + (query) => + query.includes("user_device_keys") && + query.includes("device_name = ?") && + query.includes("idempotency_key = ?"), + ), + ); + }); + + it("keeps subsequent v2 devices pending", async () => { + const device = deviceRow({ approval_status: "pending", approved_at: null }); + const d1 = testD1Database({ + firstRows: [device], + sessionRow: unboundSession(), + }); + const response = await registerRequest(d1, await deviceRegistrationBody()); + + assert.equal(response.status, 201); + const body = (await response.json()) as { device: { approval_status: string } }; + assert.equal(body.device.approval_status, "pending"); + }); + + it("requires a fresh session before registering an unbound device", async () => { + const d1 = testD1Database({ + sessionRow: { ...unboundSession(), createdAt: "2020-01-01T00:00:00.000Z" }, + }); + const response = await registerRequest(d1, await deviceRegistrationBody()); + + assert.equal(response.status, 403); + assert.deepEqual(await response.json(), { error: "device_registration_forbidden" }); + assert.deepEqual(d1.queries, []); + }); + + it("rejects v1 and non-canonical v2 registration keys before D1 writes", async () => { + for (const registration of [ + { ...(await deviceRegistrationBody()), version: 1 }, + await deviceRegistrationBody({ public_key: PUBLIC_KEY.toUpperCase() }), + await deviceRegistrationBody({ wrapping_public_key: WRAPPING_PUBLIC_KEY.toUpperCase() }), + ]) { + const d1 = testD1Database({ sessionRow: unboundSession() }); + const response = await registerRequest(d1, registration); + + assert.equal(response.status, 400); + assert.deepEqual(d1.queries, []); + } + }); + + it("rejects a tampered registration proof before D1 writes", async () => { + const registration = await deviceRegistrationBody(); + registration.wrapping_public_key = "c".repeat(64); + const d1 = testD1Database({ sessionRow: unboundSession() }); + const response = await registerRequest(d1, registration); + + assert.equal(response.status, 403); + assert.deepEqual(await response.json(), { error: "device_registration_forbidden" }); + assert.deepEqual(d1.queries, []); + }); + + it("preserves an existing session binding that wins a registration race", async () => { + const d1 = testD1Database({ + firstRows: [deviceRow()], + runChanges: [0], + sessionRow: unboundSession(), + }); + const response = await registerRequest(d1, await deviceRegistrationBody()); + + assert.equal(response.status, 409); + assert.deepEqual(await response.json(), { error: "device_registration_conflict" }); + assert.ok(d1.queries.at(-1)?.includes("ON CONFLICT(session_id) DO NOTHING")); + }); + + it("issues a short-lived challenge only for an approved v2 device", async () => { + const { challenge, d1 } = await issueChallenge(); + const nowSeconds = Math.floor(Date.now() / 1000); + + assert.match(challenge.challenge_id, /^[0-9a-f-]{36}$/); + assert.match(challenge.challenge, /^elydora-device-rebind-v1\n/); + assert.ok(challenge.expires_at - nowSeconds >= 299); + assert.ok(challenge.expires_at - nowSeconds <= 300); + assert.ok(d1.queries[0]?.includes("key_protocol_version = 2")); + assert.ok(d1.queries[1]?.includes("ON CONFLICT(session_id) DO UPDATE")); + assert.deepEqual(d1.binds[1]?.slice(1, 4), ["user-01", "session-01", "device-01"]); + }); + + it("rebinds an unbound session after a valid Ed25519 challenge signature", async () => { + const { challenge } = await issueChallenge(); + const signature = await signDeviceMessage(new TextEncoder().encode(challenge.challenge)); + const d1 = rebindDatabase(challenge); + const response = await rebindRequest(d1, challenge, signature); + + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { + version: 1, + user_id: "user-01", + session_id: "session-01", + device_id: "device-01", + bound_at: d1.binds[1]?.[0], + }); + assert.equal(d1.batches[0], 2); + assert.ok(d1.queries[1]?.includes("consumed_at IS NULL")); + assert.ok(d1.queries[1]?.includes("session_id = ?")); + assert.ok(d1.queries[2]?.includes("ON CONFLICT(session_id) DO NOTHING")); + }); + + it("rejects invalid signatures without consuming the challenge", async () => { + const { challenge } = await issueChallenge(); + const d1 = rebindDatabase(challenge); + const response = await rebindRequest(d1, challenge, "00".repeat(64)); + + assert.equal(response.status, 403); + assert.deepEqual(await response.json(), { error: "device_rebind_forbidden" }); + assert.deepEqual(d1.batches, []); + }); + + it("rejects expired and replayed challenges", async () => { + const { challenge } = await issueChallenge(); + const signature = await signDeviceMessage(new TextEncoder().encode(challenge.challenge)); + const expiredD1 = rebindDatabase({ ...challenge, expires_at: 1 }); + const expiredResponse = await rebindRequest(expiredD1, challenge, signature); + assert.equal(expiredResponse.status, 403); + assert.deepEqual(expiredD1.batches, []); + + const replayD1 = rebindDatabase(challenge, [[0, 0]]); + const replayResponse = await rebindRequest(replayD1, challenge, signature); + assert.equal(replayResponse.status, 409); + assert.deepEqual(await replayResponse.json(), { error: "device_rebind_conflict" }); + }); +}); + +interface ChallengeDocument { + challenge_id: string; + device_id: string; + challenge: string; + expires_at: number; +} + +async function registerRequest( + d1: ReturnType, + registration: Record, +): Promise { + return handleRequest( + new Request("https://elydora.test/api/devices/register", { + method: "POST", + headers: { + authorization: `Bearer ${ACCESS_TOKEN}`, + "content-type": "application/json", + }, + body: JSON.stringify(registration), + }), + testEnv({ d1 }), + ); +} + +async function issueChallenge(): Promise<{ + challenge: ChallengeDocument; + d1: ReturnType; +}> { + const d1 = testD1Database({ + firstRows: [{ signing_public_key: PUBLIC_KEY }], + sessionRow: unboundSession(), + }); + const response = await handleRequest( + new Request("https://elydora.test/api/devices/rebind/challenge", { + method: "POST", + headers: { + authorization: `Bearer ${ACCESS_TOKEN}`, + "content-type": "application/json", + }, + body: JSON.stringify({ version: 1, device_id: "device-01" }), + }), + testEnv({ d1 }), + ); + assert.equal(response.status, 201); + return { challenge: (await response.json()) as ChallengeDocument, d1 }; +} + +function rebindDatabase( + challenge: ChallengeDocument, + batchChanges: number[][] = [[1, 1]], +): ReturnType { + return testD1Database({ + batchChanges, + firstRows: [ + { + challenge: challenge.challenge, + expires_at: challenge.expires_at, + signing_public_key: PUBLIC_KEY, + }, + ], + sessionRow: unboundSession(), + }); +} + +async function rebindRequest( + d1: ReturnType, + challenge: ChallengeDocument, + signature: string, +): Promise { + return handleRequest( + new Request("https://elydora.test/api/devices/rebind", { + method: "POST", + headers: { + authorization: `Bearer ${ACCESS_TOKEN}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + version: 1, + challenge_id: challenge.challenge_id, + device_id: challenge.device_id, + signature, + }), + }), + testEnv({ d1 }), + ); +} + +function unboundSession(): Record { + return { + id: "session-01", + userId: "user-01", + expiresAt: "2099-01-01T00:00:00.000Z", + createdAt: new Date().toISOString(), + deviceId: null, + }; +} + +function deviceRow(overrides: Record = {}): Record { + return { + device_id: "device-01", + public_key: PUBLIC_KEY, + wrapping_public_key: WRAPPING_PUBLIC_KEY, + device_name: "MacBook Pro", + platform: "macOS", + approval_status: "approved", + created_at: 1_780_000_100, + approved_at: 1_780_000_100, + last_active_at: 1_780_000_100, + revoked_at: null, + ...overrides, + }; +} diff --git a/cloudflare/tests/devices_approval_persistence.test.ts b/cloudflare/tests/devices_approval_persistence.test.ts new file mode 100644 index 0000000..b5e02f7 --- /dev/null +++ b/cloudflare/tests/devices_approval_persistence.test.ts @@ -0,0 +1,167 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { deviceApprovalProofBytes } from "../src/device_approval_proof.js"; +import { handleRequest } from "../src/index.js"; +import { + ACCESS_TOKEN, + PUBLIC_KEY, + WRAPPING_PUBLIC_KEY, + signDeviceMessage, + testD1Database, + testEnv, +} from "./devices_test_support.js"; + +const KEY_ID = "a".repeat(64); +const IDEMPOTENCY_KEY = "device-approval-0001"; +const SUITE = "HPKE-BASE-X25519-HKDF-SHA256-CHACHA20POLY1305" as const; + +describe("device approval stored state", () => { + it("reports a malformed requester key as a persistence failure", async () => { + await assertPersistenceFailure([{ device_id: "device-01", signing_public_key: "invalid" }]); + }); + + it("reports malformed approval metadata as a persistence failure", async () => { + await assertPersistenceFailure([ + deviceRow(), + approvalRow({ device_id: 2 }), + ]); + }); + + it("reports an invalid approval status as a persistence failure", async () => { + await assertPersistenceFailure([ + deviceRow(), + approvalRow({ status: "corrupt" }), + ]); + }); + + it("treats a valid pending row as an idempotency mismatch", async () => { + const d1 = testD1Database({ + firstRows: [deviceRow(), approvalRow({ status: "pending", decided_at: null })], + }); + const response = await approvalRequest(d1); + + assert.equal(response.status, 403); + assert.deepEqual(await response.json(), { error: "device_approval_forbidden" }); + assert.deepEqual(d1.batches, []); + }); + + it("reports malformed device state as a persistence failure", async () => { + await assertPersistenceFailure([ + deviceRow(), + null, + deviceRow({ device_id: "device-02", approval_status: "pending", created_at: "invalid" }), + ]); + }); + + it("reports a missing approved-device key row as a persistence failure", async () => { + await assertPersistenceFailure([ + deviceRow(), + approvalRow(), + deviceRow({ device_id: "device-02", wrapping_public_key: null }), + ]); + }); + + it("reports malformed envelope state as a persistence failure", async () => { + await assertPersistenceFailure([ + deviceRow(), + approvalRow(), + deviceRow({ device_id: "device-02" }), + approvalEnvelopeRow({ generation: "1" }), + ]); + }); +}); + +async function assertPersistenceFailure(firstRows: unknown[]): Promise { + const d1 = testD1Database({ firstRows }); + const response = await approvalRequest(d1); + assert.equal(response.status, 500); + assert.deepEqual(await response.json(), { error: "device_approval_failed" }); + assert.deepEqual(d1.batches, []); +} + +async function approvalRequest(d1: ReturnType): Promise { + return handleRequest( + new Request("https://elydora.test/api/devices/approve", { + method: "POST", + headers: { + authorization: `Bearer ${ACCESS_TOKEN}`, + "content-type": "application/json", + }, + body: JSON.stringify(await approvalBody()), + }), + testEnv({ d1 }), + ); +} + +async function approvalBody(): Promise> { + const proofCreatedAt = Math.floor(Date.now() / 1000); + const envelope = { + version: 1 as const, + suite: SUITE, + encapped_key: "A".repeat(43), + ciphertext: "B".repeat(64), + }; + const action = { + deviceId: "device-02", + keyId: KEY_ID, + generation: 1, + envelope, + idempotencyKey: IDEMPOTENCY_KEY, + proofCreatedAt, + }; + return { + version: 2, + device_id: action.deviceId, + key_id: action.keyId, + generation: action.generation, + envelope, + idempotency_key: action.idempotencyKey, + proof_created_at: proofCreatedAt, + approval_proof: await signDeviceMessage( + deviceApprovalProofBytes("user-01", "device-01", action), + ), + }; +} + +function approvalRow(overrides: Record = {}): Record { + return { + device_id: "device-02", + requester_device_id: "device-01", + status: "approved", + decided_at: 1_780_000_300, + ...overrides, + }; +} + +function deviceRow(overrides: Record = {}): Record { + return { + device_id: "device-01", + public_key: PUBLIC_KEY, + signing_public_key: PUBLIC_KEY, + wrapping_public_key: WRAPPING_PUBLIC_KEY, + device_name: "MacBook Pro", + platform: "macOS", + approval_status: "approved", + created_at: 1_780_000_000, + approved_at: 1_780_000_010, + last_active_at: 1_780_000_020, + revoked_at: null, + ...overrides, + }; +} + +function approvalEnvelopeRow(overrides: Record): Record { + return { + key_id: KEY_ID, + generation: 1, + recipient_device_id: "device-02", + approver_device_id: "device-01", + envelope_version: 1, + suite: SUITE, + encapped_key: "A".repeat(43), + ciphertext: "B".repeat(64), + idempotency_key: IDEMPOTENCY_KEY, + ...overrides, + }; +} diff --git a/cloudflare/tests/devices_approval_routes.test.ts b/cloudflare/tests/devices_approval_routes.test.ts index c7635fa..f75df92 100644 --- a/cloudflare/tests/devices_approval_routes.test.ts +++ b/cloudflare/tests/devices_approval_routes.test.ts @@ -2,18 +2,34 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { authSessionCacheKvKey, authTokenHash } from "../src/auth.js"; +import { deviceApprovalProofBytes } from "../src/device_approval_proof.js"; import { handleRequest } from "../src/index.js"; import { ACCESS_TOKEN, PUBLIC_KEY, + WRAPPING_PUBLIC_KEY, sessionDocument, + signDeviceMessage, testD1Database, testEnv, } from "./devices_test_support.js"; const DEVICE_APPROVAL_IDEMPOTENCY_KEY = "device-approval-0001"; +const KEY_ID = "a".repeat(64); +const GENERATION = 1; +const SUITE = "HPKE-BASE-X25519-HKDF-SHA256-CHACHA20POLY1305"; +const ENCAPPED_KEY = "A".repeat(43); +const CIPHERTEXT = "B".repeat(64); describe("device approval routes", () => { + it("matches the frozen cross-runtime approval proof vector", async () => { + const body = await deviceApprovalBody({ proof_created_at: 1_780_000_300 }); + assert.equal( + body.approval_proof, + "f12fb7a5f7f20551bd22d0fcf8f5787d49f6202f89e42c332c248772fd9a59c82a9d8b6ac47ea84340170fc1555fc74d70a0d6ba3541df257882d46d6d79d901", + ); + }); + it("approves a pending device from an approved current device", async () => { const tokenHash = await authTokenHash(ACCESS_TOKEN); const d1 = testD1Database({ @@ -26,6 +42,7 @@ describe("device approval routes", () => { approval_status: "approved", approved_at: 1_780_000_300, }), + approvalEnvelopeRow(), ], }); @@ -36,7 +53,7 @@ describe("device approval routes", () => { authorization: `Bearer ${ACCESS_TOKEN}`, "content-type": "application/json", }, - body: JSON.stringify(deviceApprovalBody()), + body: JSON.stringify(await deviceApprovalBody()), }), testEnv({ d1, @@ -54,6 +71,7 @@ describe("device approval routes", () => { device: { device_id: "device-02", public_key: PUBLIC_KEY, + wrapping_public_key: WRAPPING_PUBLIC_KEY, device_name: "MacBook Pro", platform: "macOS", approval_status: "approved", @@ -64,22 +82,34 @@ describe("device approval routes", () => { current: false, }, }); - assert.equal(d1.batches[0], 2); + assert.equal(d1.batches[0], 3); assert.ok(d1.queries[0]?.includes("approval_status = 'approved'")); + assert.ok(d1.queries[0]?.includes("key_protocol_version = 2")); assert.ok(d1.queries[1]?.includes("FROM device_approvals")); - assert.ok(d1.queries[3]?.includes("INSERT INTO device_approvals")); - assert.ok(d1.queries[4]?.includes("UPDATE user_devices")); + assert.ok(d1.queries[3]?.includes("INSERT INTO sync_vault_envelopes")); + assert.ok(d1.queries[4]?.includes("INSERT INTO device_approvals")); + assert.ok(d1.queries[5]?.includes("UPDATE user_devices")); + assert.ok(d1.queries[5]?.includes("sync_vault_envelopes")); + assert.ok(d1.queries[7]?.includes("current_key_id")); assert.deepEqual(d1.binds[0], ["user-01", "device-01"]); assert.deepEqual(d1.binds[1], ["user-01", DEVICE_APPROVAL_IDEMPOTENCY_KEY]); assert.deepEqual(d1.binds[2], ["user-01", "device-02"]); - assert.deepEqual(d1.binds[3]?.slice(0, 4), [ + assert.deepEqual(d1.binds[3]?.slice(0, 5), [ + "user-01", + "device-02", + "device-01", + KEY_ID, + GENERATION, + ]); + assert.deepEqual(d1.binds[4]?.slice(0, 4), [ "user-01", DEVICE_APPROVAL_IDEMPOTENCY_KEY, "device-02", "device-01", ]); - assert.equal(d1.binds[3]?.[7], DEVICE_APPROVAL_IDEMPOTENCY_KEY); - assert.deepEqual(d1.binds[5], ["user-01", "device-02"]); + assert.equal(d1.binds[4]?.[7], DEVICE_APPROVAL_IDEMPOTENCY_KEY); + assert.deepEqual(d1.binds[6], ["user-01", "device-02"]); + assert.deepEqual(d1.binds[7], ["user-01", "device-02"]); }); it("returns the existing approval for an idempotent replay", async () => { @@ -98,6 +128,7 @@ describe("device approval routes", () => { approval_status: "approved", approved_at: 1_780_000_300, }), + approvalEnvelopeRow(), ], }); @@ -108,7 +139,7 @@ describe("device approval routes", () => { authorization: `Bearer ${ACCESS_TOKEN}`, "content-type": "application/json", }, - body: JSON.stringify(deviceApprovalBody()), + body: JSON.stringify(await deviceApprovalBody()), }), testEnv({ d1, @@ -120,14 +151,79 @@ describe("device approval routes", () => { const body = (await response.json()) as { approved_at: number }; assert.equal(body.approved_at, 1_780_000_300); assert.deepEqual(d1.batches, []); - assert.equal(d1.queries.length, 3); + assert.equal(d1.queries.length, 4); assert.deepEqual(d1.binds, [ ["user-01", "device-01"], ["user-01", DEVICE_APPROVAL_IDEMPOTENCY_KEY], ["user-01", "device-02"], + ["user-01", "device-02"], ]); }); + it("rejects an approval replay with different wrapped key material", async () => { + const d1 = testD1Database({ + firstRows: [ + deviceRow({ device_id: "device-01", approval_status: "approved" }), + { + device_id: "device-02", + requester_device_id: "device-01", + status: "approved", + decided_at: 1_780_000_300, + }, + deviceRow({ device_id: "device-02", approval_status: "approved" }), + approvalEnvelopeRow({ ciphertext: "C".repeat(64) }), + ], + }); + + const response = await approvalRequest(d1, await deviceApprovalBody()); + + assert.equal(response.status, 403); + assert.deepEqual(await response.json(), { error: "device_approval_forbidden" }); + assert.deepEqual(d1.batches, []); + }); + + it("rejects approval replays with a different current key or generation", async () => { + for (const override of [{ key_id: "b".repeat(64) }, { generation: 2 }]) { + const d1 = testD1Database({ + firstRows: [ + deviceRow({ device_id: "device-01", approval_status: "approved" }), + { + device_id: "device-02", + requester_device_id: "device-01", + status: "approved", + decided_at: 1_780_000_300, + }, + deviceRow({ device_id: "device-02", approval_status: "approved" }), + approvalEnvelopeRow(), + ], + }); + + const response = await approvalRequest(d1, await deviceApprovalBody(override)); + + assert.equal(response.status, 403); + assert.deepEqual(await response.json(), { error: "device_approval_forbidden" }); + assert.deepEqual(d1.batches, []); + } + }); + + it("keeps the target pending when the current vault envelope cannot be written", async () => { + const d1 = testD1Database({ + firstRows: [ + deviceRow({ device_id: "device-01", approval_status: "approved" }), + null, + deviceRow({ device_id: "device-02", approval_status: "pending", approved_at: null }), + deviceRow({ device_id: "device-02", approval_status: "pending", approved_at: null }), + ], + }); + + const response = await approvalRequest(d1, await deviceApprovalBody()); + + assert.equal(response.status, 409); + assert.deepEqual(await response.json(), { error: "device_approval_conflict" }); + assert.ok(d1.queries[4]?.includes("WHERE EXISTS")); + assert.ok(d1.queries[5]?.includes("AND EXISTS")); + }); + it("rejects approval from a current device that is not approved", async () => { const tokenHash = await authTokenHash(ACCESS_TOKEN); const d1 = testD1Database({ firstRows: [null] }); @@ -138,7 +234,7 @@ describe("device approval routes", () => { authorization: `Bearer ${ACCESS_TOKEN}`, "content-type": "application/json", }, - body: JSON.stringify(deviceApprovalBody()), + body: JSON.stringify(await deviceApprovalBody()), }), testEnv({ d1, @@ -151,6 +247,38 @@ describe("device approval routes", () => { assert.deepEqual(d1.batches, []); }); + it("rejects a tampered current-device proof before approval state reads", async () => { + const d1 = testD1Database({ + firstRows: [deviceRow({ device_id: "device-01", approval_status: "approved" })], + }); + const body = await deviceApprovalBody(); + body.approval_proof = "0".repeat(128); + const response = await approvalRequest(d1, body); + + assert.equal(response.status, 403); + assert.deepEqual(await response.json(), { error: "device_approval_forbidden" }); + assert.deepEqual(d1.batches, []); + assert.equal(d1.queries.length, 1); + }); + + it("requires a recent proof for a new approval", async () => { + const d1 = testD1Database({ + firstRows: [ + deviceRow({ device_id: "device-01", approval_status: "approved" }), + null, + ], + }); + const response = await approvalRequest( + d1, + await deviceApprovalBody({ proof_created_at: 1_700_000_000 }), + ); + + assert.equal(response.status, 403); + assert.deepEqual(await response.json(), { error: "device_approval_forbidden" }); + assert.deepEqual(d1.batches, []); + assert.equal(d1.queries.length, 2); + }); + it("rejects self approval before D1 writes", async () => { const tokenHash = await authTokenHash(ACCESS_TOKEN); const d1 = testD1Database([]); @@ -161,7 +289,7 @@ describe("device approval routes", () => { authorization: `Bearer ${ACCESS_TOKEN}`, "content-type": "application/json", }, - body: JSON.stringify({ ...deviceApprovalBody(), device_id: "device-01" }), + body: JSON.stringify(await deviceApprovalBody({ device_id: "device-01" })), }), testEnv({ d1, @@ -183,7 +311,7 @@ describe("device approval routes", () => { authorization: `Bearer ${ACCESS_TOKEN}`, "content-type": "application/json", }, - body: JSON.stringify({ ...deviceApprovalBody(), idempotency_key: "short" }), + body: JSON.stringify(await deviceApprovalBody({ idempotency_key: "short" })), }), testEnv({ d1, @@ -202,7 +330,7 @@ describe("device approval routes", () => { new Request("https://elydora.test/api/devices/approve", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify(deviceApprovalBody()), + body: JSON.stringify(await deviceApprovalBody()), }), testEnv({ d1 }), ); @@ -213,18 +341,46 @@ describe("device approval routes", () => { }); }); -function deviceApprovalBody(): Record { - return { - version: 1, +async function deviceApprovalBody( + overrides: Record = {}, +): Promise> { + const body: Record = { + version: 2, device_id: "device-02", + key_id: KEY_ID, + generation: GENERATION, + envelope: wrappedEnvelope(), idempotency_key: DEVICE_APPROVAL_IDEMPOTENCY_KEY, + proof_created_at: Math.floor(Date.now() / 1000), + ...overrides, }; + const envelope = body.envelope as { + version: 1; + suite: typeof SUITE; + encapped_key: string; + ciphertext: string; + }; + body.approval_proof = await signDeviceMessage(deviceApprovalProofBytes( + "user-01", + "device-01", + { + deviceId: String(body.device_id), + keyId: String(body.key_id), + generation: Number(body.generation), + envelope, + idempotencyKey: String(body.idempotency_key), + proofCreatedAt: Number(body.proof_created_at), + }, + )); + return body; } function deviceRow(overrides: Record): Record { return { device_id: "device-01", public_key: PUBLIC_KEY, + signing_public_key: PUBLIC_KEY, + wrapping_public_key: WRAPPING_PUBLIC_KEY, device_name: "MacBook Pro", platform: "macOS", approval_status: "approved", @@ -235,3 +391,44 @@ function deviceRow(overrides: Record): Record ...overrides, }; } + +function wrappedEnvelope(): Record { + return { + version: 1, + suite: SUITE, + encapped_key: ENCAPPED_KEY, + ciphertext: CIPHERTEXT, + }; +} + +function approvalEnvelopeRow(overrides: Record = {}): Record { + return { + key_id: KEY_ID, + generation: GENERATION, + recipient_device_id: "device-02", + approver_device_id: "device-01", + envelope_version: 1, + suite: SUITE, + encapped_key: ENCAPPED_KEY, + ciphertext: CIPHERTEXT, + idempotency_key: DEVICE_APPROVAL_IDEMPOTENCY_KEY, + ...overrides, + }; +} + +function approvalRequest( + d1: ReturnType, + body: Record, +): Promise { + return handleRequest( + new Request("https://elydora.test/api/devices/approve", { + method: "POST", + headers: { + authorization: `Bearer ${ACCESS_TOKEN}`, + "content-type": "application/json", + }, + body: JSON.stringify(body), + }), + testEnv({ d1 }), + ); +} diff --git a/cloudflare/tests/devices_revocation_routes.test.ts b/cloudflare/tests/devices_revocation_routes.test.ts index 151ec2e..817b5ce 100644 --- a/cloudflare/tests/devices_revocation_routes.test.ts +++ b/cloudflare/tests/devices_revocation_routes.test.ts @@ -1,229 +1,452 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; - import { authSessionCacheKvKey, authTokenHash } from "../src/auth.js"; +import { + deviceRevocationRequest, + deviceRevocationRequestHash, + deviceRevocationProofBytes, + pendingDeviceRevocationProofBytes, + pendingDeviceRevocationRequestHash, +} from "../src/device_revocation_schema.js"; import { handleRequest } from "../src/index.js"; import { ACCESS_TOKEN, PUBLIC_KEY, + WRAPPING_PUBLIC_KEY, sessionDocument, + signDeviceMessage, testD1Database, testEnv, } from "./devices_test_support.js"; -const DEVICE_REVOCATION_IDEMPOTENCY_KEY = "device-revocation-0001"; -const DEVICE_REVOCATION_EVENT_ID = `device-revoke:user-01:${DEVICE_REVOCATION_IDEMPOTENCY_KEY}`; - +const USER_ID = "user-01", APPROVER_DEVICE_ID = "device-01"; +const TARGET_DEVICE_ID = "device-02", OTHER_DEVICE_ID = "device-03"; +const IDEMPOTENCY_KEY = "device-revocation-0001"; +const PREVIOUS_KEY_ID = "a".repeat(64), NEW_KEY_ID = "b".repeat(64); +const PREVIOUS_GENERATION = 1, NEW_GENERATION = 2; +const SUITE = "HPKE-BASE-X25519-HKDF-SHA256-CHACHA20POLY1305"; describe("device revocation routes", () => { - it("revokes a device from an approved current device", async () => { - const tokenHash = await authTokenHash(ACCESS_TOKEN); + it("atomically rotates the vault and revokes an approved device", async () => { + const body = deviceRevocationBody(); const d1 = testD1Database({ firstRows: [ - deviceRow({ device_id: "device-01", approval_status: "approved" }), + approverRow(), null, - deviceRow({ device_id: "device-02", approval_status: "approved" }), - deviceRow({ - device_id: "device-02", - approval_status: "revoked", - revoked_at: 1_780_000_400, - }), + currentVaultKeyRow(), + deviceRow({ device_id: TARGET_DEVICE_ID }), + await rotationResultRow(body, { r2_object_count: 2, r2_item_count: 2 }), + deviceRow({ device_id: TARGET_DEVICE_ID, approval_status: "revoked", revoked_at: 1_780_000_400 }), ], + allRowSets: [ + [{ device_id: APPROVER_DEVICE_ID }, { device_id: OTHER_DEVICE_ID }], + [{ object_count: 2 }], + ], + batchChanges: [[1, 1, 1, 1]], }); - const response = await handleRequest( - new Request("https://elydora.test/api/devices/revoke", { - method: "POST", - headers: { - authorization: `Bearer ${ACCESS_TOKEN}`, - "content-type": "application/json", - }, - body: JSON.stringify(deviceRevocationBody()), - }), - testEnv({ - d1, - kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument("device-01")]], - }), - ); + const response = await revocationResponse(d1, body); assert.equal(response.status, 200); assert.equal(response.headers.get("cache-control"), "no-store"); - assert.deepEqual(await response.json(), { - version: 1, - user_id: "user-01", - revoked_by_device_id: "device-01", - revoked_at: 1_780_000_400, - device: { - device_id: "device-02", - public_key: PUBLIC_KEY, - device_name: "MacBook Pro", - platform: "macOS", - approval_status: "revoked", - created_at: 1_780_000_000, - approved_at: 1_780_000_010, - last_active_at: 1_780_000_020, - revoked_at: 1_780_000_400, - current: false, - }, - }); - assert.equal(d1.batches[0], 2); - assert.ok(d1.queries[0]?.includes("approval_status = 'approved'")); - assert.ok(d1.queries[1]?.includes("FROM audit_events")); - assert.ok(d1.queries[3]?.includes("INSERT INTO audit_events")); - assert.ok(d1.queries[4]?.includes("UPDATE user_devices")); - assert.deepEqual(d1.binds[0], ["user-01", "device-01"]); - assert.deepEqual(d1.binds[1], ["user-01", DEVICE_REVOCATION_EVENT_ID]); - assert.deepEqual(d1.binds[2], ["user-01", "device-02"]); - assert.deepEqual(d1.binds[3]?.slice(0, 4), [ - DEVICE_REVOCATION_EVENT_ID, - "user-01", - "device-01", - "device-02", - ]); - assert.deepEqual(d1.binds[5], ["user-01", "device-02"]); + assert.deepEqual(await response.json(), revocationDocument()); + assert.deepEqual(d1.batches, [4]); + assert.ok(d1.queries[6]?.includes("INSERT INTO sync_vault_rotations")); + assert.ok(d1.queries[7]?.includes("INSERT INTO sync_vault_rotation_envelopes")); + assert.ok(d1.queries[5]?.includes("COUNT(*) AS object_count")); + assert.ok(d1.queries[9]?.includes("SET completed_at = ?")); + assert.deepEqual(d1.binds[6]?.slice(0, 2), [USER_ID, IDEMPOTENCY_KEY]); + assert.match(String(d1.binds[6]?.[2]), /^device-revoke:[a-f0-9]{64}$/); + assert.deepEqual(d1.binds[6]?.slice(3, 5), [TARGET_DEVICE_ID, APPROVER_DEVICE_ID]); }); - it("returns the existing revocation for an idempotent replay", async () => { - const tokenHash = await authTokenHash(ACCESS_TOKEN); + it("revokes a pending target without rotating or cleaning the vault", async () => { + const body = pendingRevocationBody(); const d1 = testD1Database({ firstRows: [ - deviceRow({ device_id: "device-01", approval_status: "approved" }), - { - actor_device_id: "device-01", - subject_id: "device-02", - outcome: "success", - created_at: 1_780_000_400, - }, - deviceRow({ - device_id: "device-02", - approval_status: "revoked", - revoked_at: 1_780_000_400, - }), + approverRow(), + null, + deviceRow({ approval_status: "pending", approved_at: null }), + await pendingResultRow(body), + deviceRow({ approval_status: "revoked", approved_at: null, revoked_at: 1_780_000_400 }), + ], + batchChanges: [[1, 1]], + }); + + const response = await revocationResponse(d1, body); + + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), pendingRevocationDocument()); + assert.deepEqual(d1.batches, [2]); + assert.ok(d1.queries[3]?.includes("INSERT INTO pending_device_revocations")); + assert.ok(d1.queries[4]?.includes("SET completed_at = ?")); + assert.equal(d1.queries.some((query) => query.includes("sync_vault_accounts")), false); + }); + + it("returns an exact pending revocation replay and rejects mismatches", async () => { + const body = pendingRevocationBody(); + const replay = testD1Database({ + firstRows: [ + approverRow(), + await pendingResultRow(body), + deviceRow({ approval_status: "revoked", approved_at: null, revoked_at: 1_780_000_400 }), + ], + }); + assert.equal((await revocationResponse(replay, body)).status, 200); + assert.deepEqual(replay.batches, []); + + const mismatch = testD1Database({ + firstRows: [approverRow(), { ...await pendingResultRow(body), request_hash: "f".repeat(64) }], + }); + assert.equal((await revocationResponse(mismatch, body)).status, 409); + assert.deepEqual(mismatch.batches, []); + }); + + it("rejects pending mode for an approved target and trigger races", async () => { + const body = pendingRevocationBody(); + const approved = testD1Database({ + firstRows: [approverRow(), null, deviceRow()], + }); + assert.equal((await revocationResponse(approved, body)).status, 409); + assert.deepEqual(approved.batches, []); + + const race = testD1Database({ + firstRows: [approverRow(), null, deviceRow({ approval_status: "pending", approved_at: null })], + batchError: new Error("pending_device_revocation_guard_failed"), + }); + assert.equal((await revocationResponse(race, body)).status, 409); + }); + + it("returns an exact idempotent replay", async () => { + const body = deviceRevocationBody(); + const d1 = testD1Database({ + firstRows: [ + approverRow(), + await rotationResultRow(body), + deviceRow({ device_id: TARGET_DEVICE_ID, approval_status: "revoked", revoked_at: 1_780_000_400 }), ], }); - const response = await handleRequest( - new Request("https://elydora.test/api/devices/revoke", { - method: "POST", - headers: { - authorization: `Bearer ${ACCESS_TOKEN}`, - "content-type": "application/json", - }, - body: JSON.stringify(deviceRevocationBody()), - }), - testEnv({ - d1, - kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument("device-01")]], - }), - ); + const response = await revocationResponse(d1, body); assert.equal(response.status, 200); - const body = (await response.json()) as { revoked_at: number }; - assert.equal(body.revoked_at, 1_780_000_400); + assert.deepEqual(await response.json(), revocationDocument()); assert.deepEqual(d1.batches, []); - assert.deepEqual(d1.binds, [ - ["user-01", "device-01"], - ["user-01", DEVICE_REVOCATION_EVENT_ID], - ["user-01", "device-02"], - ]); + assert.equal(d1.queries.length, 3); }); - it("rejects revocation from a current device that is not approved", async () => { - const tokenHash = await authTokenHash(ACCESS_TOKEN); - const d1 = testD1Database({ firstRows: [null] }); - const response = await handleRequest( - new Request("https://elydora.test/api/devices/revoke", { - method: "POST", - headers: { - authorization: `Bearer ${ACCESS_TOKEN}`, - "content-type": "application/json", - }, - body: JSON.stringify(deviceRevocationBody()), - }), - testEnv({ - d1, - kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument("device-01")]], - }), - ); + it("rejects a replay with different rotation metadata", async () => { + const body = deviceRevocationBody(); + const d1 = testD1Database({ + firstRows: [ + approverRow(), + await rotationResultRow(body, { new_key_id: "c".repeat(64) }), + ], + }); - assert.equal(response.status, 403); - assert.deepEqual(await response.json(), { error: "device_revocation_forbidden" }); + const response = await revocationResponse(d1, body); + + assert.equal(response.status, 409); + assert.deepEqual(await response.json(), { error: "device_revocation_conflict" }); assert.deepEqual(d1.batches, []); }); - it("rejects self revocation before D1 writes", async () => { - const tokenHash = await authTokenHash(ACCESS_TOKEN); - const d1 = testD1Database([]); - const response = await handleRequest( - new Request("https://elydora.test/api/devices/revoke", { - method: "POST", - headers: { - authorization: `Bearer ${ACCESS_TOKEN}`, - "content-type": "application/json", - }, - body: JSON.stringify({ ...deviceRevocationBody(), device_id: "device-01" }), - }), - testEnv({ - d1, - kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument("device-01")]], - }), - ); - - assert.equal(response.status, 403); - assert.deepEqual(d1.queries, []); + it("rejects missing and extra recipient envelopes", async () => { + const cases = [ + { + body: deviceRevocationBody({ envelopes: [rotationEnvelope(APPROVER_DEVICE_ID)] }), + rows: [{ device_id: APPROVER_DEVICE_ID }, { device_id: OTHER_DEVICE_ID }], + }, + { + body: deviceRevocationBody(), + rows: [{ device_id: APPROVER_DEVICE_ID }], + }, + ]; + for (const testCase of cases) { + const d1 = preflightD1(testCase.rows); + const response = await revocationResponse(d1, testCase.body); + assert.equal(response.status, 409); + assert.deepEqual(await response.json(), { error: "device_revocation_conflict" }); + assert.deepEqual(d1.batches, []); + } }); - it("rejects invalid revocation payloads before D1 writes", async () => { - const tokenHash = await authTokenHash(ACCESS_TOKEN); - const d1 = testD1Database([]); - const response = await handleRequest( - new Request("https://elydora.test/api/devices/revoke", { - method: "POST", - headers: { - authorization: `Bearer ${ACCESS_TOKEN}`, - "content-type": "application/json", - }, - body: JSON.stringify({ ...deviceRevocationBody(), idempotency_key: "short" }), - }), - testEnv({ - d1, - kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument("device-01")]], - }), - ); + it("rejects stale vault metadata before target reads", async () => { + const d1 = testD1Database({ + firstRows: [ + approverRow(), + null, + { key_id: "c".repeat(64), generation: PREVIOUS_GENERATION }, + ], + }); - assert.equal(response.status, 400); - assert.deepEqual(await response.json(), { error: "invalid_device_revocation" }); - assert.deepEqual(d1.queries, []); + const response = await revocationResponse(d1, deviceRevocationBody()); + + assert.equal(response.status, 409); + assert.deepEqual(d1.batches, []); + assert.equal(d1.queries.length, 3); }); - it("rejects unauthenticated device revocation before D1 writes", async () => { - const d1 = testD1Database([]); - const response = await handleRequest( - new Request("https://elydora.test/api/devices/revoke", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(deviceRevocationBody()), - }), - testEnv({ d1 }), - ); + it("fails a rotation race when the D1 guard aborts", async () => { + const d1 = testD1Database({ + firstRows: [ + approverRow(), + null, + currentVaultKeyRow(), + deviceRow({ device_id: TARGET_DEVICE_ID }), + ], + allRowSets: [ + [{ device_id: APPROVER_DEVICE_ID }, { device_id: OTHER_DEVICE_ID }], + [{ object_count: 0 }], + ], + batchError: new Error("sync_vault_rotation_guard_failed"), + }); + const response = await revocationResponse(d1, deviceRevocationBody()); + + assert.equal(response.status, 409); + assert.deepEqual(await response.json(), { error: "device_revocation_conflict" }); + }); + + it("fails closed when a zero-change finalize has no exact completed replay", async () => { + const body = deviceRevocationBody(); + const d1 = testD1Database({ + firstRows: [ + approverRow(), + null, + currentVaultKeyRow(), + deviceRow({ device_id: TARGET_DEVICE_ID }), + null, + ], + allRowSets: [ + [{ device_id: APPROVER_DEVICE_ID }, { device_id: OTHER_DEVICE_ID }], + [{ object_count: 0 }], + ], + batchChanges: [[1, 1, 1, 0]], + }); + + const response = await revocationResponse(d1, body); + + assert.equal(response.status, 409); + assert.deepEqual(d1.batches, [4]); + }); + + it("accepts a zero-change finalize that resolves to the exact concurrent replay", async () => { + const body = deviceRevocationBody(); + const d1 = successfulD1(body, "approved", [[1, 1, 1, 0]]); + + const response = await revocationResponse(d1, body); + + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), revocationDocument()); + }); + + it("rejects unapproved, self, and unauthenticated revocation", async () => { + const unapproved = testD1Database({ firstRows: [null] }); + assert.equal((await revocationResponse(unapproved, deviceRevocationBody())).status, 403); + + const self = testD1Database([]); + const selfBody = deviceRevocationBody({ + device_id: APPROVER_DEVICE_ID, + envelopes: [rotationEnvelope(OTHER_DEVICE_ID)], + }); + assert.equal((await revocationResponse(self, selfBody)).status, 403); + assert.deepEqual(self.queries, []); + + const anonymous = testD1Database([]); + const response = await handleRequest(revocationRequest(deviceRevocationBody(), false), testEnv({ d1: anonymous })); assert.equal(response.status, 401); - assert.deepEqual(await response.json(), { error: "authorization_missing" }); - assert.deepEqual(d1.queries, []); + assert.deepEqual(anonymous.queries, []); }); }); -function deviceRevocationBody(): Record { +function successfulD1( + body: Record, + targetStatus: "pending" | "approved", + batchChanges: number[][] = [[1, 1, 1, 1]], +): ReturnType { + return testD1Database({ + firstRows: [ + approverRow(), + null, + currentVaultKeyRow(), + deviceRow({ device_id: TARGET_DEVICE_ID, approval_status: targetStatus }), + rotationResultRow(body), + deviceRow({ device_id: TARGET_DEVICE_ID, approval_status: "revoked", revoked_at: 1_780_000_400 }), + ], + allRowSets: [ + [{ device_id: APPROVER_DEVICE_ID }, { device_id: OTHER_DEVICE_ID }], + [{ object_count: 0 }], + ], + batchChanges, + }); +} + +function preflightD1(recipientRows: Record[]): ReturnType { + return testD1Database({ + firstRows: [ + approverRow(), + null, + currentVaultKeyRow(), + deviceRow({ device_id: TARGET_DEVICE_ID }), + ], + allRowSets: [recipientRows, [{ object_count: 0 }]], + }); +} + +function revocationResponse( + d1: ReturnType, + body: Record, +): Promise { + return signedRevocationBody(body).then((signedBody) => + handleRequest(revocationRequest(signedBody), testEnv({ d1 })), + ); +} + +function revocationRequest(body: Record, authenticated = true): Request { + return new Request("https://elydora.test/api/devices/revoke", { + method: "POST", + headers: { + ...(authenticated ? { authorization: `Bearer ${ACCESS_TOKEN}` } : {}), + "content-type": "application/json", + }, + body: JSON.stringify(body), + }); +} + +function deviceRevocationBody(overrides: Record = {}): Record { return { - version: 1, - device_id: "device-02", - idempotency_key: DEVICE_REVOCATION_IDEMPOTENCY_KEY, + version: 2, + mode: "approved_rotate", + device_id: TARGET_DEVICE_ID, + previous_key_id: PREVIOUS_KEY_ID, + previous_generation: PREVIOUS_GENERATION, + new_key_id: NEW_KEY_ID, + new_generation: NEW_GENERATION, + envelopes: [rotationEnvelope(OTHER_DEVICE_ID), rotationEnvelope(APPROVER_DEVICE_ID)], + idempotency_key: IDEMPOTENCY_KEY, + ...overrides, }; } -function deviceRow(overrides: Record): Record { +function pendingRevocationBody(overrides: Record = {}): Record { return { - device_id: "device-01", + version: 2, + mode: "pending_revoke", + device_id: TARGET_DEVICE_ID, + idempotency_key: IDEMPOTENCY_KEY, + ...overrides, + }; +} + +function rotationEnvelope( + recipientDeviceId: string, + overrides: Record = {}, +): Record { + const other = recipientDeviceId === OTHER_DEVICE_ID; + return { + recipient_device_id: recipientDeviceId, + envelope: { + version: 1, + suite: SUITE, + encapped_key: other ? `${"C".repeat(42)}E` : "A".repeat(43), + ciphertext: other ? "D".repeat(64) : "B".repeat(64), + ...overrides, + }, + }; +} + +function currentVaultKeyRow(): Record { return { key_id: PREVIOUS_KEY_ID, generation: PREVIOUS_GENERATION }; } + +async function rotationResultRow( + body: Record, + overrides: Record = {}, +): Promise> { + const parsed = await deviceRevocationRequest(revocationRequest(await signedRevocationBody(body))); + if (parsed.mode !== "approved_rotate") throw new Error("approved rotation expected"); + const requestHash = await deviceRevocationRequestHash(USER_ID, APPROVER_DEVICE_ID, parsed); + return { + target_device_id: TARGET_DEVICE_ID, + approver_device_id: APPROVER_DEVICE_ID, + previous_key_id: PREVIOUS_KEY_ID, + previous_generation: PREVIOUS_GENERATION, + new_key_id: NEW_KEY_ID, + new_generation: NEW_GENERATION, + request_hash: requestHash, + envelope_count: 2, + r2_object_count: 0, + completed_at: 1_780_000_400, + current_key_id: NEW_KEY_ID, + current_generation: NEW_GENERATION, + target_status: "revoked", + revoked_at: 1_780_000_400, + active_session_count: 0, + item_count: 2, + r2_item_count: 0, + persisted_count: 2, + audit_count: 1, + ...overrides, + }; +} + +async function pendingResultRow(body: Record): Promise> { + const parsed = await deviceRevocationRequest(revocationRequest(await signedRevocationBody(body))); + if (parsed.mode !== "pending_revoke") throw new Error("pending revocation expected"); + return { + target_device_id: TARGET_DEVICE_ID, + approver_device_id: APPROVER_DEVICE_ID, + request_hash: await pendingDeviceRevocationRequestHash(USER_ID, APPROVER_DEVICE_ID, parsed), + completed_at: 1_780_000_400, + target_status: "revoked", + revoked_at: 1_780_000_400, + active_session_count: 0, + audit_count: 1, + }; +} + +async function signedRevocationBody( + body: Record, +): Promise> { + if (body.mode === "pending_revoke") { + if (body.pending_revocation_proof !== undefined) return body; + const draft = { ...body, pending_revocation_proof: "0".repeat(128) }; + try { + const parsed = await deviceRevocationRequest(revocationRequest(draft)); + if (parsed.mode !== "pending_revoke") return draft; + const { pendingRevocationProof: _, ...unsigned } = parsed; + return { + ...body, + pending_revocation_proof: await signDeviceMessage( + pendingDeviceRevocationProofBytes(USER_ID, APPROVER_DEVICE_ID, unsigned), + ), + }; + } catch { + return draft; + } + } + if (body.rotation_proof !== undefined) return body; + const draft = { ...body, rotation_proof: "0".repeat(128) }; + try { + const parsed = await deviceRevocationRequest(revocationRequest(draft)); + if (parsed.mode !== "approved_rotate") return draft; + const { rotationProof: _, ...unsigned } = parsed; + return { + ...body, + rotation_proof: await signDeviceMessage( + deviceRevocationProofBytes(USER_ID, APPROVER_DEVICE_ID, unsigned), + ), + }; + } catch { + return draft; + } +} + +function approverRow(): Record { return { device_id: APPROVER_DEVICE_ID, signing_public_key: PUBLIC_KEY }; } + +function deviceRow(overrides: Record = {}): Record { + return { + device_id: TARGET_DEVICE_ID, public_key: PUBLIC_KEY, + wrapping_public_key: WRAPPING_PUBLIC_KEY, device_name: "MacBook Pro", platform: "macOS", approval_status: "approved", @@ -234,3 +457,43 @@ function deviceRow(overrides: Record): Record ...overrides, }; } + +function revocationDocument(): Record { + return { + version: 2, + mode: "approved_rotate", + user_id: USER_ID, + revoked_by_device_id: APPROVER_DEVICE_ID, + revoked_at: 1_780_000_400, + key_id: NEW_KEY_ID, + generation: NEW_GENERATION, + device: { + device_id: TARGET_DEVICE_ID, + public_key: PUBLIC_KEY, + wrapping_public_key: WRAPPING_PUBLIC_KEY, + device_name: "MacBook Pro", + platform: "macOS", + approval_status: "revoked", + created_at: 1_780_000_000, + approved_at: 1_780_000_010, + last_active_at: 1_780_000_020, + revoked_at: 1_780_000_400, + current: false, + }, + }; +} + +function pendingRevocationDocument(): Record { + const document = revocationDocument(); + delete document.key_id; + delete document.generation; + return { + ...document, + mode: "pending_revoke", + device: { + ...(document.device as Record), + approval_status: "revoked", + approved_at: null, + }, + }; +} diff --git a/cloudflare/tests/devices_routes.test.ts b/cloudflare/tests/devices_routes.test.ts index a16a0e1..23980b8 100644 --- a/cloudflare/tests/devices_routes.test.ts +++ b/cloudflare/tests/devices_routes.test.ts @@ -7,6 +7,8 @@ import { handleRequest } from "../src/index.js"; import { ACCESS_TOKEN, PUBLIC_KEY, + WRAPPING_PUBLIC_KEY, + deviceRegistrationBody, sessionDocument, testD1Database, testEnv, @@ -155,19 +157,20 @@ describe("device routes", () => { assert.deepEqual(await response.json(), { error: "devices_invalid" }); }); - it("registers the current device as a pending idempotent D1 write", async () => { + it("registers the first current device as an approved idempotent D1 write", async () => { const tokenHash = await authTokenHash(ACCESS_TOKEN); const sessionCacheKey = authSessionCacheKvKey("local", tokenHash); const kvPuts: [string, string][] = []; const d1 = testD1Database([ { device_id: "device-01", - public_key: PUBLIC_KEY.toUpperCase(), + public_key: PUBLIC_KEY, + wrapping_public_key: WRAPPING_PUBLIC_KEY, device_name: "MacBook Pro", platform: "macOS", - approval_status: "pending", + approval_status: "approved", created_at: 1_780_000_100, - approved_at: null, + approved_at: 1_780_000_100, last_active_at: 1_780_000_100, revoked_at: null, }, @@ -180,7 +183,7 @@ describe("device routes", () => { authorization: `Bearer ${ACCESS_TOKEN}`, "content-type": "application/json", }, - body: JSON.stringify(deviceRegistrationBody()), + body: JSON.stringify(await deviceRegistrationBody()), }), testEnv({ d1, @@ -192,37 +195,49 @@ describe("device routes", () => { assert.equal(response.status, 201); assert.equal(response.headers.get("cache-control"), "no-store"); assert.deepEqual(await response.json(), { - version: 1, + version: 2, user_id: "user-01", device: { device_id: "device-01", public_key: PUBLIC_KEY, + wrapping_public_key: WRAPPING_PUBLIC_KEY, device_name: "MacBook Pro", platform: "macOS", - approval_status: "pending", + approval_status: "approved", created_at: 1_780_000_100, - approved_at: null, + approved_at: 1_780_000_100, last_active_at: 1_780_000_100, revoked_at: null, current: true, }, }); assert.ok(d1.queries[0]?.includes("INSERT INTO user_devices")); + assert.ok(d1.queries[0]?.includes("NOT EXISTS")); assert.ok(d1.queries[0]?.includes("ON CONFLICT DO NOTHING")); - assert.ok(d1.queries[1]?.includes("WHERE user_id = ? AND idempotency_key = ?")); - assert.ok(d1.queries[2]?.includes("better_auth_session_device_context")); - assert.deepEqual(d1.binds[0]?.slice(0, 5), [ + assert.ok(d1.queries[1]?.includes("INSERT INTO user_device_keys")); + assert.ok(d1.queries[2]?.includes("idempotency_key = ?")); + assert.ok(d1.queries[3]?.includes("better_auth_session_device_context")); + assert.deepEqual(d1.binds[0]?.slice(0, 6), [ + "user-01", "user-01", "device-01", PUBLIC_KEY, "MacBook Pro", "macOS", ]); - assert.equal(typeof d1.binds[0]?.[5], "number"); assert.equal(typeof d1.binds[0]?.[6], "number"); - assert.equal(d1.binds[0]?.[7], IDEMPOTENCY_KEY); - assert.deepEqual(d1.binds[1], ["user-01", IDEMPOTENCY_KEY]); - assert.deepEqual(d1.binds[2]?.slice(0, 3), ["session-01", "user-01", "device-01"]); + assert.equal(typeof d1.binds[0]?.[7], "number"); + assert.equal(typeof d1.binds[0]?.[8], "number"); + assert.equal(d1.binds[0]?.[9], IDEMPOTENCY_KEY); + assert.deepEqual(d1.binds[1]?.slice(0, 5), [ + "user-01", + "device-01", + PUBLIC_KEY, + WRAPPING_PUBLIC_KEY, + d1.binds[0]?.[6], + ]); + assert.deepEqual(d1.binds[2], ["user-01", IDEMPOTENCY_KEY]); + assert.deepEqual(d1.binds[3]?.slice(0, 3), ["session-01", "user-01", "device-01"]); assert.deepEqual(kvPuts, []); }); @@ -233,11 +248,12 @@ describe("device routes", () => { const deviceRow = { device_id: "device-01", public_key: PUBLIC_KEY, + wrapping_public_key: WRAPPING_PUBLIC_KEY, device_name: "MacBook Pro", platform: "macOS", - approval_status: "pending", + approval_status: "approved", created_at: 1_780_000_100, - approved_at: null, + approved_at: 1_780_000_100, last_active_at: 1_780_000_100, revoked_at: null, }; @@ -248,6 +264,7 @@ describe("device routes", () => { id: "session-01", userId: "user-01", expiresAt: "2099-01-01T00:00:00.000Z", + createdAt: new Date().toISOString(), deviceId: null, }, }); @@ -259,7 +276,7 @@ describe("device routes", () => { authorization: `Bearer ${ACCESS_TOKEN}`, "content-type": "application/json", }, - body: JSON.stringify(deviceRegistrationBody()), + body: JSON.stringify(await deviceRegistrationBody()), }), testEnv({ d1, @@ -269,7 +286,7 @@ describe("device routes", () => { ); assert.equal(response.status, 201); - assert.deepEqual(d1.binds[2]?.slice(0, 3), ["session-01", "user-01", "device-01"]); + assert.deepEqual(d1.binds[3]?.slice(0, 3), ["session-01", "user-01", "device-01"]); assert.deepEqual(kvPuts, []); }); @@ -278,6 +295,7 @@ describe("device routes", () => { const existingDevice = { device_id: "device-01", public_key: PUBLIC_KEY, + wrapping_public_key: WRAPPING_PUBLIC_KEY, device_name: "MacBook Pro", platform: "macOS", approval_status: status, @@ -288,11 +306,12 @@ describe("device routes", () => { }; const d1 = testD1Database({ firstRows: [existingDevice], - runChanges: [0], + batchChanges: [[0, 0]], sessionRow: { id: "session-02", userId: "user-01", expiresAt: "2099-01-01T00:00:00.000Z", + createdAt: new Date().toISOString(), deviceId: null, }, }); @@ -304,7 +323,7 @@ describe("device routes", () => { authorization: `Bearer ${ACCESS_TOKEN}`, "content-type": "application/json", }, - body: JSON.stringify(deviceRegistrationBody()), + body: JSON.stringify(await deviceRegistrationBody()), }), testEnv({ d1 }), ); @@ -319,6 +338,7 @@ describe("device routes", () => { const pendingDevice = { device_id: "device-01", public_key: PUBLIC_KEY, + wrapping_public_key: WRAPPING_PUBLIC_KEY, device_name: "MacBook Pro", platform: "macOS", approval_status: "pending", @@ -327,7 +347,7 @@ describe("device routes", () => { last_active_at: 1_780_000_020, revoked_at: null, }; - const d1 = testD1Database({ firstRows: [pendingDevice], runChanges: [0] }); + const d1 = testD1Database({ firstRows: [pendingDevice], batchChanges: [[0, 0]] }); const response = await handleRequest( new Request("https://elydora.test/api/devices/register", { @@ -336,7 +356,7 @@ describe("device routes", () => { authorization: `Bearer ${ACCESS_TOKEN}`, "content-type": "application/json", }, - body: JSON.stringify(deviceRegistrationBody()), + body: JSON.stringify(await deviceRegistrationBody()), }), testEnv({ d1 }), ); @@ -352,12 +372,13 @@ describe("device routes", () => { it("rejects a device id collision with a different idempotency key", async () => { const d1 = testD1Database({ firstRows: [], - runChanges: [0], + batchChanges: [[0, 0]], sessionRow: { - id: "session-02", - userId: "user-01", - expiresAt: "2099-01-01T00:00:00.000Z", - deviceId: null, + id: "session-02", + userId: "user-01", + expiresAt: "2099-01-01T00:00:00.000Z", + createdAt: new Date().toISOString(), + deviceId: null, }, }); const response = await handleRequest( @@ -367,10 +388,9 @@ describe("device routes", () => { authorization: `Bearer ${ACCESS_TOKEN}`, "content-type": "application/json", }, - body: JSON.stringify({ - ...deviceRegistrationBody(), - idempotency_key: "device-register-0002", - }), + body: JSON.stringify( + await deviceRegistrationBody({ idempotency_key: "device-register-0002" }), + ), }), testEnv({ d1 }), ); @@ -390,7 +410,7 @@ describe("device routes", () => { authorization: `Bearer ${ACCESS_TOKEN}`, "content-type": "application/json", }, - body: JSON.stringify({ ...deviceRegistrationBody(), idempotency_key: "short" }), + body: JSON.stringify(await deviceRegistrationBody({ idempotency_key: "short" })), }), testEnv({ d1, @@ -413,7 +433,7 @@ describe("device routes", () => { authorization: `Bearer ${ACCESS_TOKEN}`, "content-type": "application/json", }, - body: JSON.stringify({ ...deviceRegistrationBody(), device_id: "device-02" }), + body: JSON.stringify(await deviceRegistrationBody({ device_id: "device-02" })), }), testEnv({ d1, @@ -422,7 +442,7 @@ describe("device routes", () => { ); assert.equal(response.status, 403); - assert.deepEqual(await response.json(), { error: "device_context_mismatch" }); + assert.deepEqual(await response.json(), { error: "device_registration_forbidden" }); assert.deepEqual(d1.queries, []); }); @@ -432,7 +452,7 @@ describe("device routes", () => { new Request("https://elydora.test/api/devices/register", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify(deviceRegistrationBody()), + body: JSON.stringify(await deviceRegistrationBody()), }), testEnv({ d1 }), ); @@ -442,14 +462,3 @@ describe("device routes", () => { assert.deepEqual(d1.queries, []); }); }); - -function deviceRegistrationBody(): Record { - return { - version: 1, - device_id: "device-01", - public_key: PUBLIC_KEY, - device_name: "MacBook Pro", - platform: "macOS", - idempotency_key: IDEMPOTENCY_KEY, - }; -} diff --git a/cloudflare/tests/devices_test_support.ts b/cloudflare/tests/devices_test_support.ts index e7ef8e5..385c8bd 100644 --- a/cloudflare/tests/devices_test_support.ts +++ b/cloudflare/tests/devices_test_support.ts @@ -5,9 +5,14 @@ import type { ElyR2PutOptions, Env, } from "../src/bindings.js"; +import { deviceRegistrationProofBytes } from "../src/device_registration_proof.js"; export const ACCESS_TOKEN = "D".repeat(48); -export const PUBLIC_KEY = "a".repeat(64); +export const PUBLIC_KEY = "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"; +export const WRAPPING_PUBLIC_KEY = "b".repeat(64); +const SIGNING_PRIVATE_KEY_PKCS8 = + "302e020100300506032b657004220420" + + "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60"; export interface TestEnvOptions { auditEvents?: ElyAnalyticsDataPoint[]; @@ -29,6 +34,7 @@ export interface RecordedD1Database extends ElyD1Database { batches: number[]; binds: unknown[][]; queries: string[]; + sessionConstraints?: string[]; } export interface RecordedR2Put { @@ -37,8 +43,54 @@ export interface RecordedR2Put { options: ElyR2PutOptions; } +export async function deviceRegistrationBody( + overrides: Record = {}, +): Promise> { + const body: Record = { + version: 2, + device_id: "device-01", + public_key: PUBLIC_KEY, + wrapping_public_key: WRAPPING_PUBLIC_KEY, + device_name: "MacBook Pro", + platform: "macOS", + idempotency_key: "device-register-0001", + ...overrides, + }; + body.registration_proof = await signDeviceMessage( + deviceRegistrationProofBytes({ + deviceId: stringValue(body.device_id), + publicKey: stringValue(body.public_key), + wrappingPublicKey: stringValue(body.wrapping_public_key), + deviceName: stringValue(body.device_name), + platform: stringValue(body.platform), + idempotencyKey: stringValue(body.idempotency_key), + }), + ); + return body; +} + +export async function signDeviceMessage(message: Uint8Array): Promise { + const privateKey = await crypto.subtle.importKey( + "pkcs8", + hexBytes(SIGNING_PRIVATE_KEY_PKCS8), + { name: "Ed25519" }, + false, + ["sign"], + ); + const signature = await crypto.subtle.sign( + { name: "Ed25519" }, + privateKey, + message, + ); + return hexString(new Uint8Array(signature)); +} + interface TestD1DatabaseOptions { allRows?: unknown[]; + allRowSets?: unknown[][]; + batchChanges?: number[][]; + batchError?: Error; + batchRowSets?: unknown[][][]; firstRows?: unknown[]; runChanges?: number[]; sessionRow?: unknown | null; @@ -48,6 +100,7 @@ const DEFAULT_AUTH_SESSION_ROW = { id: "session-01", userId: "user-01", expiresAt: "2099-01-01T00:00:00.000Z", + createdAt: new Date().toISOString(), deviceId: "device-01", }; @@ -108,6 +161,7 @@ export function testD1Database(rows: unknown[] | TestD1DatabaseOptions): Recorde const binds: unknown[][] = []; const batches: number[] = []; const queries: string[] = []; + const sessionConstraints: string[] = []; const allRows = Array.isArray(rows) ? rows : rows.allRows ?? []; const firstRows = Array.isArray(rows) ? rows : rows.firstRows ?? []; const sessionRow = @@ -115,18 +169,21 @@ export function testD1Database(rows: unknown[] | TestD1DatabaseOptions): Recorde ? rows.sessionRow ?? null : DEFAULT_AUTH_SESSION_ROW; let firstIndex = 0; + let allIndex = 0; + let batchIndex = 0; let runIndex = 0; - return { + const database: RecordedD1Database = { authBinds, authQueries, batches, binds, queries, + sessionConstraints, prepare(query: string) { - const isAuthSessionQuery = query.includes("FROM better_auth_session AS session"); + const isAuthSessionQuery = query.includes("WHERE session.token = ?"); (isAuthSessionQuery ? authQueries : queries).push(query); return testD1PreparedStatement( - allRows, + () => (!Array.isArray(rows) ? rows.allRowSets?.[allIndex++] : undefined) ?? allRows, firstRows, () => firstIndex++, isAuthSessionQuery ? authBinds : binds, @@ -135,14 +192,33 @@ export function testD1Database(rows: unknown[] | TestD1DatabaseOptions): Recorde () => (!Array.isArray(rows) ? rows.runChanges?.[runIndex++] : undefined) ?? 1, ); }, - batch(statements: ElyD1PreparedStatement[]) { + batch(statements: ElyD1PreparedStatement[]) { batches.push(statements.length); - return Promise.resolve([]); + if (!Array.isArray(rows) && rows.batchError !== undefined) { + return Promise.reject(rows.batchError); + } + const currentBatchIndex = batchIndex++; + const configuredChanges = !Array.isArray(rows) + ? rows.batchChanges?.[currentBatchIndex] + : undefined; + const configuredRows = !Array.isArray(rows) + ? rows.batchRowSets?.[currentBatchIndex] + : undefined; + const results = statements.map((_, index) => ({ + results: configuredRows?.[index] ?? [], + meta: { changes: configuredChanges?.[index] ?? 1 }, + })); + return Promise.resolve(results as T[]); }, exec() { return Promise.resolve({}); }, + withSession(constraint) { + sessionConstraints.push(constraint); + return database; + }, }; + return database; } export function sessionDocument(deviceId: string | null = "device-01"): string { @@ -156,7 +232,7 @@ export function sessionDocument(deviceId: string | null = "device-01"): string { } function testD1PreparedStatement( - allRows: unknown[], + allRows: () => unknown[], firstRows: unknown[], nextFirstIndex: () => number, binds: unknown[][], @@ -176,7 +252,7 @@ function testD1PreparedStatement( return Promise.resolve((firstRows[nextFirstIndex()] as T | undefined) ?? null); }, all() { - return Promise.resolve({ results: allRows as T[] }); + return Promise.resolve({ results: allRows() as T[] }); }, run() { return Promise.resolve({ results: [], meta: { changes: nextRunChanges() } }); @@ -220,3 +296,22 @@ function testR2Bucket( }, }; } + +function stringValue(value: unknown): string { + if (typeof value !== "string") { + throw new TypeError("registration fixture field must be a string"); + } + return value; +} + +function hexBytes(value: string): Uint8Array { + const bytes = new Uint8Array(value.length / 2); + for (let index = 0; index < bytes.length; index += 1) { + bytes[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16); + } + return bytes; +} + +function hexString(bytes: Uint8Array): string { + return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} diff --git a/cloudflare/tests/legacy_auth_kv_cleanup.test.ts b/cloudflare/tests/legacy_auth_kv_cleanup.test.ts new file mode 100644 index 0000000..7d2430a --- /dev/null +++ b/cloudflare/tests/legacy_auth_kv_cleanup.test.ts @@ -0,0 +1,63 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import type { Env } from "../src/bindings.js"; +import { purgeLegacySessionCache } from "../src/legacy_auth_kv_cleanup.js"; + +describe("legacy auth KV cleanup", () => { + it("purges KV-only historical session keys across list pages", async () => { + const prefix = "ely:production:auth_session_cache:"; + const first = `${prefix}${"a".repeat(64)}`; + const second = `${prefix}${"b".repeat(64)}`; + const unrelated = "ely:production:public_cache:plugins"; + const kv = new PaginatedKv([first, second, unrelated]); + const env = { ELY_KV: kv, ELY_ENVIRONMENT: "production" } as unknown as Env; + + assert.equal(await purgeLegacySessionCache(env), 2); + + assert.deepEqual(kv.deleted, [first, second]); + assert.deepEqual([...kv.values], [unrelated]); + assert.deepEqual(kv.cursors, [undefined, "page-2"]); + }); +}); + +class PaginatedKv { + readonly values: Set; + readonly deleted: string[] = []; + readonly cursors: (string | undefined)[] = []; + + constructor(keys: string[]) { + this.values = new Set(keys); + } + + get(key: string): Promise { + return Promise.resolve(this.values.has(key) ? "value" : null); + } + + put(key: string): Promise { + this.values.add(key); + return Promise.resolve(); + } + + delete(key: string): Promise { + this.deleted.push(key); + this.values.delete(key); + return Promise.resolve(); + } + + list(options: { prefix: string; cursor?: string; limit: number }) { + this.cursors.push(options.cursor); + const matching = [...this.values].filter((key) => key.startsWith(options.prefix)).sort(); + if (options.cursor === undefined) { + return Promise.resolve({ + keys: matching.slice(0, 1).map((name) => ({ name })), + list_complete: false as const, + cursor: "page-2", + }); + } + return Promise.resolve({ + keys: matching.map((name) => ({ name })), + list_complete: true as const, + }); + } +} diff --git a/cloudflare/tests/migrations.test.ts b/cloudflare/tests/migrations.test.ts index d3f154c..3852266 100644 --- a/cloudflare/tests/migrations.test.ts +++ b/cloudflare/tests/migrations.test.ts @@ -14,14 +14,31 @@ const EXPECTED_MIGRATIONS = [ "0005_audit.sql", "0006_better_auth.sql", "0007_better_auth_session_device_context.sql", + "0008_sync_encryption.sql", + "0009_sync_vault.sql", + "0010_device_trust.sql", + "0011_sync_vault_rotation.sql", + "0012_sync_snapshot_head.sql", + "0013_sync_r2_gc.sql", ]; const USER_SCOPED_TABLES = [ "user_devices", "device_approvals", + "device_rebind_challenges", + "pending_device_revocations", "sync_objects", + "sync_r2_gc_candidates", "sync_change_log", "sync_snapshots", + "sync_snapshot_encryption", + "sync_snapshot_heads", "sync_tombstones", + "sync_vault_accounts", + "sync_vault_envelopes", + "sync_vault_rotation_envelopes", + "sync_vault_rotation_r2_objects", + "sync_vault_rotations", + "user_device_keys", ]; describe("D1 migrations", () => { @@ -44,14 +61,26 @@ describe("D1 migrations", () => { "better_auth_user", "better_auth_verification", "device_approvals", + "device_rebind_challenges", + "pending_device_revocations", "plugin_packages", "plugin_registry", "plugin_reviews", "release_manifests", "sync_change_log", "sync_objects", + "sync_r2_gc_candidates", + "sync_r2_inventory_cursors", "sync_snapshots", + "sync_snapshot_encryption", + "sync_snapshot_heads", "sync_tombstones", + "sync_vault_accounts", + "sync_vault_envelopes", + "sync_vault_rotation_envelopes", + "sync_vault_rotation_r2_objects", + "sync_vault_rotations", + "user_device_keys", "user_devices", ]) { assert.ok(tables.includes(table), table); @@ -115,6 +144,31 @@ describe("D1 migrations", () => { ]), [], ); + assert.deepEqual( + requiredColumns(databasePath, "user_device_keys", [ + "user_id", + "device_id", + "signing_public_key", + "wrapping_public_key", + "key_protocol_version", + "created_at", + ]), + [], + ); + assert.deepEqual( + requiredColumns(databasePath, "device_rebind_challenges", [ + "challenge_id", + "user_id", + "session_id", + "device_id", + "challenge", + "created_at", + "expires_at", + "consumed_at", + "consumption_nonce", + ]), + [], + ); }); }); @@ -149,6 +203,185 @@ describe("D1 migrations", () => { ]), [], ); + assert.deepEqual( + requiredColumns(databasePath, "sync_snapshots", [ + "head_revision", + "base_head_revision", + "base_snapshot_id", + "base_payload_hash", + ]), + [], + ); + assert.deepEqual( + requiredColumns(databasePath, "sync_snapshot_encryption", [ + "user_id", + "snapshot_id", + "encryption_version", + "vault_generation", + "key_id", + "content_hash", + ]), + [], + ); + assert.deepEqual( + requiredColumns(databasePath, "sync_snapshot_heads", [ + "user_id", + "head_revision", + "snapshot_id", + "payload_hash", + "updated_at", + ]), + [], + ); + assert.deepEqual( + requiredColumns(databasePath, "sync_r2_gc_candidates", [ + "r2_key", + "user_id", + "owner_hash", + "object_kind", + "state", + "write_token", + "lease_expires_at", + "gc_token", + "deleted_at", + ]), + [], + ); + assert.deepEqual( + requiredColumns(databasePath, "sync_vault_accounts", [ + "user_id", + "current_key_id", + "current_generation", + "created_at", + "updated_at", + ]), + [], + ); + assert.deepEqual( + requiredColumns(databasePath, "sync_vault_envelopes", [ + "user_id", + "recipient_device_id", + "approver_device_id", + "key_id", + "generation", + "envelope_version", + "suite", + "encapped_key", + "ciphertext", + "idempotency_key", + "created_at", + ]), + [], + ); + assert.deepEqual( + requiredColumns(databasePath, "pending_device_revocations", [ + "user_id", + "idempotency_key", + "target_device_id", + "approver_device_id", + "request_hash", + "completed_at", + ]), + [], + ); + assert.deepEqual( + requiredColumns(databasePath, "sync_vault_rotations", [ + "user_id", + "idempotency_key", + "target_device_id", + "approver_device_id", + "previous_key_id", + "previous_generation", + "new_key_id", + "new_generation", + "request_hash", + "envelope_count", + "r2_object_count", + "completed_at", + "cleanup_snapshot_id", + "cleanup_started_at", + "storage_cleaned_at", + ]), + [], + ); + assert.deepEqual( + requiredColumns(databasePath, "sync_vault_rotation_envelopes", [ + "user_id", + "rotation_idempotency_key", + "recipient_device_id", + "envelope_idempotency_key", + "envelope_version", + "suite", + "encapped_key", + "ciphertext", + ]), + [], + ); + assert.deepEqual( + requiredColumns(databasePath, "sync_vault_rotation_r2_objects", [ + "user_id", + "rotation_idempotency_key", + "r2_key", + ]), + [], + ); + }); + }); + + it("backfills one deterministic legacy encrypted head per user", () => { + withDatabaseBeforeSnapshotHeadMigration((databasePath) => { + execFileSync("sqlite3", [databasePath], { + input: ` + INSERT INTO sync_snapshots ( + user_id, snapshot_id, r2_key, payload_hash, schema_rev, + logical_clock, device_id, size_bytes, created_at + ) VALUES + ('user-01', 'snapshot-b', 'key-b', '${"b".repeat(64)}', 1, 2, 'device-01', 1, 100), + ('user-01', 'snapshot-a', 'key-a', '${"a".repeat(64)}', 1, 3, 'device-01', 1, 100), + ('user-01', 'snapshot-c', 'key-c', '${"c".repeat(64)}', 1, 1, 'device-01', 1, 90); + INSERT INTO sync_snapshot_encryption ( + user_id, snapshot_id, encryption_version, vault_generation, key_id, content_hash + ) VALUES + ('user-01', 'snapshot-a', 1, 1, '${"1".repeat(64)}', '${"2".repeat(64)}'), + ('user-01', 'snapshot-b', 1, 1, '${"1".repeat(64)}', '${"3".repeat(64)}'), + ('user-01', 'snapshot-c', 1, 1, '${"1".repeat(64)}', '${"4".repeat(64)}'); + `, + }); + execFileSync("sqlite3", [databasePath], { + input: `PRAGMA foreign_keys = ON;\n${readFileSync( + join(MIGRATIONS_DIR, "0012_sync_snapshot_head.sql"), + "utf8", + )}`, + }); + + assert.deepEqual( + sqliteJson(databasePath, ` + SELECT head_revision, snapshot_id, payload_hash + FROM sync_snapshot_heads + WHERE user_id = 'user-01' + `), + [{ head_revision: 1, snapshot_id: "snapshot-a", payload_hash: "a".repeat(64) }], + ); + assert.deepEqual( + sqliteJson(databasePath, ` + SELECT snapshot_id, head_revision + FROM sync_snapshots + WHERE user_id = 'user-01' + ORDER BY snapshot_id + `), + [ + { snapshot_id: "snapshot-a", head_revision: 1 }, + { snapshot_id: "snapshot-b", head_revision: 0 }, + { snapshot_id: "snapshot-c", head_revision: 0 }, + ], + ); + assert.deepEqual( + sqliteJson(databasePath, ` + SELECT DISTINCT encryption_version + FROM sync_snapshot_encryption + `), + [{ encryption_version: 1 }], + ); }); }); }); @@ -165,6 +398,22 @@ function withReplayedDatabase(assertions: (databasePath: string) => void): void .map((fileName) => readFileSync(join(MIGRATIONS_DIR, fileName), "utf8")) .join("\n"); execFileSync("sqlite3", [databasePath], { input: sql }); + assertions(databasePath); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +} + +function withDatabaseBeforeSnapshotHeadMigration( + assertions: (databasePath: string) => void, +): void { + const tempDir = mkdtempSync(join(tmpdir(), "ely-d1-before-head-")); + try { + const databasePath = join(tempDir, "ely.db"); + const sql = migrationFiles() + .filter((fileName) => fileName < "0012_sync_snapshot_head.sql") + .map((fileName) => readFileSync(join(MIGRATIONS_DIR, fileName), "utf8")) + .join("\n"); execFileSync("sqlite3", [databasePath], { input: sql }); assertions(databasePath); } finally { diff --git a/cloudflare/tests/sqlite_d1_test_support.ts b/cloudflare/tests/sqlite_d1_test_support.ts new file mode 100644 index 0000000..4f25804 --- /dev/null +++ b/cloudflare/tests/sqlite_d1_test_support.ts @@ -0,0 +1,151 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import type { ElyD1PreparedStatement, ElyD1Result } from "../src/bindings.js"; +import type { RecordedD1Database } from "./devices_test_support.js"; + +export class SqliteD1Database implements RecordedD1Database { + readonly authBinds: unknown[][] = []; + readonly authQueries: string[] = []; + readonly batches: number[] = []; + readonly binds: unknown[][] = []; + readonly queries: string[] = []; + readonly sessionConstraints: string[] = []; + private beforeBatchSql: string | undefined; + + constructor( + private readonly databasePath: string, + beforeBatchSql?: string, + ) { + this.beforeBatchSql = beforeBatchSql; + } + + prepare(sql: string): ElyD1PreparedStatement { + this.queries.push(sql); + return new SqliteD1Statement(this, sql); + } + + async batch(statements: ElyD1PreparedStatement[]): Promise { + this.batches.push(statements.length); + if (this.beforeBatchSql !== undefined) { + execute(this.databasePath, this.beforeBatchSql); + this.beforeBatchSql = undefined; + } + const prepared = statements.map((statement) => { + assert.ok(statement instanceof SqliteD1Statement); + return statement.sql(); + }); + const script = [ + ".bail on", + "PRAGMA foreign_keys = ON;", + "BEGIN IMMEDIATE;", + ...prepared.flatMap((sql, index) => [ + `.print __ELY_BEGIN_${index}`, + sql, + `.print __ELY_CHANGES_${index}`, + "SELECT changes() AS __ely_changes;", + `.print __ELY_END_${index}`, + ]), + "COMMIT;", + ].join("\n"); + const output = sqlite(this.databasePath, script, true); + const lines = output.trim().split(/\r?\n/).filter(Boolean); + const results = prepared.map((_, index) => { + const begin = lines.indexOf(`__ELY_BEGIN_${index}`); + const changesMarker = lines.indexOf(`__ELY_CHANGES_${index}`); + const end = lines.indexOf(`__ELY_END_${index}`); + assert.ok(begin >= 0 && changesMarker > begin && end > changesMarker); + const rows = lines + .slice(begin + 1, changesMarker) + .flatMap((line) => JSON.parse(line) as unknown[]); + const changeRows = JSON.parse(lines[changesMarker + 1] ?? "[]") as { + __ely_changes?: unknown; + }[]; + const value = changeRows[0]?.__ely_changes; + assert.equal(typeof value, "number"); + return { results: rows, meta: { changes: value } }; + }); + return results as T[]; + } + + async exec(sql: string): Promise { + execute(this.databasePath, sql); + return {}; + } + + withSession(constraint: "first-primary"): SqliteD1Database { + this.sessionConstraints.push(constraint); + return this; + } + + rows(sql: string): T[] { + return query(this.databasePath, sql) as T[]; + } +} + +class SqliteD1Statement implements ElyD1PreparedStatement { + private values: unknown[] = []; + + constructor( + private readonly database: SqliteD1Database, + private readonly queryText: string, + ) {} + + bind(...values: unknown[]): ElyD1PreparedStatement { + this.values = values; + this.database.binds.push(values); + return this; + } + + async first(): Promise { + return this.database.rows(this.sql())[0] ?? null; + } + + async all(): Promise> { + return { results: this.database.rows(this.sql()) }; + } + + async run(): Promise { + const rows = this.database.rows<{ changes: number }>( + `${this.sql()}\nSELECT changes() AS changes;`, + ); + return { results: [], meta: { changes: rows[0]?.changes ?? 0 } }; + } + + sql(): string { + let index = 0; + const sql = this.queryText.replace(/\?/g, () => sqlLiteral(this.values[index++])); + assert.equal(index, this.values.length, "D1 bind count must match SQL placeholders"); + return `${sql.trim().replace(/;$/, "")};`; + } +} + +export function execute(databasePath: string, sql: string): void { + sqlite(databasePath, `.bail on\nPRAGMA foreign_keys = ON;\n${sql}`); +} + +export function query(databasePath: string, sql: string): Record[] { + const output = sqlite(databasePath, `PRAGMA foreign_keys = ON;\n${sql}`, true); + return output.trim() === "" ? [] : JSON.parse(output) as Record[]; +} + +function sqlite(databasePath: string, sql: string, json = false): string { + try { + return execFileSync("sqlite3", [...(json ? ["-json"] : []), databasePath], { + input: sql, + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + }); + } catch (error) { + const stderr = typeof error === "object" && error !== null && "stderr" in error + ? String(error.stderr) + : ""; + throw new Error(`${error instanceof Error ? error.message : String(error)}\n${stderr}`); + } +} + +function sqlLiteral(value: unknown): string { + if (value === null) return "NULL"; + if (typeof value === "string") return `'${value.replaceAll("'", "''")}'`; + if (typeof value === "number" && Number.isFinite(value)) return value.toString(); + throw new TypeError("Unsupported SQLite test binding"); +} diff --git a/cloudflare/tests/storage.test.ts b/cloudflare/tests/storage.test.ts index 1db4335..d098e54 100644 --- a/cloudflare/tests/storage.test.ts +++ b/cloudflare/tests/storage.test.ts @@ -37,8 +37,13 @@ describe("R2 storage contracts", () => { `sync-payloads/us-east/${USER_HASH}/tabs/tab-01/${PAYLOAD_HASH}.bin`, ); assert.equal( - syncSnapshotKey({ region: "us-east", userHash: USER_HASH, snapshotId: "snapshot-01" }), - `sync-snapshots/us-east/${USER_HASH}/snapshot-01.bin`, + syncSnapshotKey({ + region: "us-east", + userHash: USER_HASH, + snapshotId: "snapshot-01", + payloadHash: PAYLOAD_HASH, + }), + `sync-snapshots/us-east/${USER_HASH}/snapshot-01/${PAYLOAD_HASH}.bin`, ); assert.equal( pluginPackageKey({ pluginId: "elydora.reader", packageHash: PACKAGE_HASH }), @@ -163,6 +168,7 @@ describe("R2 storage contracts", () => { region: "us-east", userHash: USER_HASH, snapshotId: "snapshot-01", + payloadHash: checksum, }); const downloaded = await getVerifiedObject(bucket, key, checksum); @@ -176,6 +182,7 @@ describe("R2 storage contracts", () => { region: "us-east", userHash: USER_HASH, snapshotId: "snapshot-01", + payloadHash: PAYLOAD_HASH, }); await deleteKnownObject(bucket, key); diff --git a/cloudflare/tests/sync_pull_routes.test.ts b/cloudflare/tests/sync_pull_routes.test.ts index 1f975a3..d614bb3 100644 --- a/cloudflare/tests/sync_pull_routes.test.ts +++ b/cloudflare/tests/sync_pull_routes.test.ts @@ -5,104 +5,12 @@ import { authSessionCacheKvKey, authTokenHash } from "../src/auth.js"; import { handleRequest } from "../src/index.js"; import { ACCESS_TOKEN, sessionDocument, testD1Database, testEnv } from "./devices_test_support.js"; -const PAYLOAD_HASH = "a".repeat(64); - -describe("sync pull routes", () => { - it("returns sync change log entries for an approved current device", async () => { - const tokenHash = await authTokenHash(ACCESS_TOKEN); - const d1 = testD1Database({ - firstRows: [{ device_id: "device-01" }], - allRows: [ - syncChangeRow({ change_id: 11, object_id: "tab-01" }), - syncChangeRow({ change_id: 12, object_id: "bookmark-01", object_type: "bookmarks" }), - ], - }); - - const response = await handleRequest( - new Request("https://elydora.test/api/sync/pull?cursor=10&limit=2", { - headers: { authorization: `Bearer ${ACCESS_TOKEN}` }, - }), - testEnv({ - d1, - kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument("device-01")]], - }), - ); - - assert.equal(response.status, 200); - assert.equal(response.headers.get("cache-control"), "no-store"); - assert.deepEqual(await response.json(), { - version: 1, - user_id: "user-01", - device_id: "device-01", - cursor: 10, - next_cursor: 12, - has_more: false, - changes: [ - syncChangeDocument({ change_id: 11, object_id: "tab-01" }), - syncChangeDocument({ change_id: 12, object_id: "bookmark-01", object_type: "bookmarks" }), - ], - }); - assert.ok(d1.queries[0]?.includes("approval_status = 'approved'")); - assert.ok(d1.queries[1]?.includes("FROM sync_change_log")); - assert.deepEqual(d1.binds, [ - ["user-01", "device-01"], - ["user-01", 10, 3], - ]); - }); - - it("reports more changes when the pull window is saturated", async () => { - const tokenHash = await authTokenHash(ACCESS_TOKEN); - const d1 = testD1Database({ - firstRows: [{ device_id: "device-01" }], - allRows: [ - syncChangeRow({ change_id: 11, object_id: "tab-01" }), - syncChangeRow({ change_id: 12, object_id: "tab-02" }), - syncChangeRow({ change_id: 13, object_id: "tab-03" }), - ], - }); - - const response = await handleRequest( - new Request("https://elydora.test/api/sync/pull?cursor=10&limit=2", { - headers: { authorization: `Bearer ${ACCESS_TOKEN}` }, - }), - testEnv({ - d1, - kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument("device-01")]], - }), - ); - - const body = (await response.json()) as { has_more: boolean; next_cursor: number; changes: [] }; - assert.equal(response.status, 200); - assert.equal(body.has_more, true); - assert.equal(body.next_cursor, 12); - assert.equal(body.changes.length, 2); - }); - - it("rejects revoked devices before reading sync deltas", async () => { - const tokenHash = await authTokenHash(ACCESS_TOKEN); - const d1 = testD1Database({ firstRows: [null], allRows: [syncChangeRow()] }); - - const response = await handleRequest( - new Request("https://elydora.test/api/sync/pull?cursor=10", { - headers: { authorization: `Bearer ${ACCESS_TOKEN}` }, - }), - testEnv({ - d1, - kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument("device-01")]], - }), - ); - - assert.equal(response.status, 403); - assert.deepEqual(await response.json(), { error: "device_not_approved" }); - assert.equal(d1.queries.length, 1); - }); - - it("rejects invalid cursors after session and device validation", async () => { +describe("retired sync pull route", () => { + it("rejects legacy object reads after device authorization", async () => { const tokenHash = await authTokenHash(ACCESS_TOKEN); const d1 = testD1Database({ firstRows: [{ device_id: "device-01" }] }); - const response = await handleRequest( - new Request("https://elydora.test/api/sync/pull?cursor=old", { + new Request("https://elydora.test/api/sync/pull?cursor=0", { headers: { authorization: `Bearer ${ACCESS_TOKEN}` }, }), testEnv({ @@ -111,34 +19,15 @@ describe("sync pull routes", () => { }), ); - assert.equal(response.status, 400); - assert.deepEqual(await response.json(), { error: "invalid_sync_pull" }); + assert.equal(response.status, 410); + assert.equal(response.headers.get("cache-control"), "no-store"); + assert.deepEqual(await response.json(), { error: "sync_object_protocol_retired" }); assert.equal(d1.queries.length, 1); + assert.deepEqual(d1.batches, []); }); - it("returns a server error for malformed sync change rows", async () => { - const tokenHash = await authTokenHash(ACCESS_TOKEN); - const d1 = testD1Database({ - firstRows: [{ device_id: "device-01" }], - allRows: [syncChangeRow({ payload_hash: "bad" })], - }); - - const response = await handleRequest( - new Request("https://elydora.test/api/sync/pull?cursor=10", { - headers: { authorization: `Bearer ${ACCESS_TOKEN}` }, - }), - testEnv({ - d1, - kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument("device-01")]], - }), - ); - - assert.equal(response.status, 500); - assert.deepEqual(await response.json(), { error: "sync_pull_invalid" }); - }); - - it("rejects unauthenticated sync pulls before D1 reads", async () => { - const d1 = testD1Database({ allRows: [syncChangeRow()] }); + it("keeps retired object reads behind authentication", async () => { + const d1 = testD1Database({}); const response = await handleRequest( new Request("https://elydora.test/api/sync/pull?cursor=0"), testEnv({ d1 }), @@ -149,21 +38,3 @@ describe("sync pull routes", () => { assert.deepEqual(d1.queries, []); }); }); - -function syncChangeRow(overrides: Record = {}): Record { - return { - change_id: 11, - object_id: "tab-01", - object_type: "tabs", - operation: "upsert", - payload_hash: PAYLOAD_HASH, - logical_clock: 42, - device_id: "device-02", - created_at: 1_780_000_500, - ...overrides, - }; -} - -function syncChangeDocument(overrides: Record = {}): Record { - return syncChangeRow(overrides); -} diff --git a/cloudflare/tests/sync_push_routes.test.ts b/cloudflare/tests/sync_push_routes.test.ts index b522518..cf44d7e 100644 --- a/cloudflare/tests/sync_push_routes.test.ts +++ b/cloudflare/tests/sync_push_routes.test.ts @@ -1,5 +1,4 @@ import assert from "node:assert/strict"; -import { createHash } from "node:crypto"; import { describe, it } from "node:test"; import { authSessionCacheKvKey, authTokenHash } from "../src/auth.js"; @@ -12,327 +11,32 @@ import { testEnv, } from "./devices_test_support.js"; -const USER_ID = "user-01"; -const DEVICE_ID = "device-01"; -const OBJECT_ID = "tab-01"; -const OBJECT_TYPE = "tabs"; - -describe("sync push routes", () => { - it("pushes an inline encrypted sync object from an approved current device", async () => { - const payload = bytes("encrypted tab payload"); - const payloadHash = sha256(payload); +describe("retired sync push route", () => { + it("rejects legacy object writes before D1 and R2 persistence", async () => { const tokenHash = await authTokenHash(ACCESS_TOKEN); - const d1 = testD1Database({ - firstRows: [ - { device_id: DEVICE_ID }, - null, - syncObjectRow({ payload_hash: payloadHash }), - ], - }); - + const d1 = testD1Database({ firstRows: [{ device_id: "device-01" }] }); + const r2Puts: RecordedR2Put[] = []; const response = await handleRequest( - syncPushRequest(syncPushBody({ payload_hash: payloadHash, payload: inlinePayload(payload) })), + new Request("https://elydora.test/api/sync/push", { + method: "POST", + headers: { + authorization: `Bearer ${ACCESS_TOKEN}`, + "content-type": "application/json", + }, + body: JSON.stringify({ version: 1, payload: "legacy-plaintext" }), + }), testEnv({ d1, - kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]], + r2Puts, + kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument("device-01")]], }), ); - assert.equal(response.status, 201); + assert.equal(response.status, 410); assert.equal(response.headers.get("cache-control"), "no-store"); - assert.deepEqual(await response.json(), { - version: 1, - user_id: USER_ID, - device_id: DEVICE_ID, - object: syncObjectDocument({ payload_hash: payloadHash }), - }); - assert.equal(d1.batches[0], 2); - assert.ok(d1.queries[0]?.includes("approval_status = 'approved'")); - assert.ok(d1.queries[1]?.includes("FROM sync_objects")); - assert.ok(d1.queries[2]?.includes("INSERT INTO sync_objects")); - assert.ok(d1.queries[3]?.includes("INSERT INTO sync_change_log")); - assert.deepEqual(d1.binds[0], [USER_ID, DEVICE_ID]); - assert.deepEqual(d1.binds[1], [USER_ID, OBJECT_ID]); - assert.deepEqual(d1.binds[2]?.slice(0, 3), [USER_ID, OBJECT_ID, OBJECT_TYPE]); - assert.deepEqual(new Uint8Array(d1.binds[2]?.[3] as ArrayBuffer), new Uint8Array(payload)); - assert.equal(d1.binds[2]?.[4], null); - assert.equal(d1.binds[3]?.[3], "upsert"); - }); - - it("pushes an R2 encrypted sync object after checksum verification", async () => { - const payload = bytes("large encrypted tab payload"); - const payloadHash = sha256(payload); - const userHash = sha256(bytes(USER_ID)); - const r2Puts: RecordedR2Put[] = []; - const tokenHash = await authTokenHash(ACCESS_TOKEN); - const d1 = testD1Database({ - firstRows: [ - { device_id: DEVICE_ID }, - null, - syncObjectRow({ - payload_hash: payloadHash, - payload_r2_key: `sync-payloads/us-east/${userHash}/tabs/${OBJECT_ID}/${payloadHash}.bin`, - }), - ], - }); - - const response = await handleRequest( - syncPushRequest( - syncPushBody({ payload_hash: payloadHash, payload: r2Payload("us-east", payload) }), - ), - testEnv({ - d1, - r2Puts, - kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]], - }), - ); - - assert.equal(response.status, 201); - assert.equal(r2Puts.length, 1); - assert.equal( - r2Puts[0]?.key, - `sync-payloads/us-east/${userHash}/tabs/${OBJECT_ID}/${payloadHash}.bin`, - ); - assert.equal(r2Puts[0]?.options.customMetadata?.sha256, payloadHash); - const body = (await response.json()) as { object: { payload_storage: string } }; - assert.equal(body.object.payload_storage, "r2"); - assert.equal(d1.binds[2]?.[3], null); - assert.equal(d1.binds[2]?.[4], r2Puts[0]?.key); - }); - - it("pushes a delete tombstone and writes the change log", async () => { - const payloadHash = "b".repeat(64); - const tokenHash = await authTokenHash(ACCESS_TOKEN); - const d1 = testD1Database({ - firstRows: [ - { device_id: DEVICE_ID }, - syncObjectRow({ payload_hash: "a".repeat(64), logical_clock: 41 }), - syncObjectRow({ payload_hash: payloadHash, logical_clock: 42, deleted_at: 1_780_000_800 }), - ], - }); - - const response = await handleRequest( - syncPushRequest( - syncPushBody({ operation: "delete", payload_hash: payloadHash, payload: undefined }), - ), - testEnv({ - d1, - kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]], - }), - ); - - assert.equal(response.status, 201); - assert.equal(d1.batches[0], 3); - assert.ok(d1.queries[4]?.includes("INSERT INTO sync_tombstones")); - assert.equal(d1.binds[2]?.[3], null); - assert.equal(d1.binds[2]?.[4], null); - assert.equal(d1.binds[3]?.[3], "delete"); - const body = (await response.json()) as { object: { payload_storage: string } }; - assert.equal(body.object.payload_storage, "tombstone"); - }); - - it("rejects payload checksum mismatches before D1 writes", async () => { - const tokenHash = await authTokenHash(ACCESS_TOKEN); - const d1 = testD1Database({ firstRows: [{ device_id: DEVICE_ID }] }); - const response = await handleRequest( - syncPushRequest( - syncPushBody({ - payload_hash: "c".repeat(64), - payload: inlinePayload(bytes("encrypted tab payload")), - }), - ), - testEnv({ - d1, - kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]], - }), - ); - - assert.equal(response.status, 400); - assert.deepEqual(await response.json(), { error: "invalid_sync_push" }); - assert.equal(d1.queries.length, 1); - assert.deepEqual(d1.batches, []); - }); - - it("rejects R2 object ids that cannot form storage keys before D1 writes", async () => { - const payload = bytes("large encrypted tab payload"); - const payloadHash = sha256(payload); - const r2Puts: RecordedR2Put[] = []; - const tokenHash = await authTokenHash(ACCESS_TOKEN); - const d1 = testD1Database({ firstRows: [{ device_id: DEVICE_ID }] }); - - const response = await handleRequest( - syncPushRequest( - syncPushBody({ - object_id: "Tab:01", - payload_hash: payloadHash, - payload: r2Payload("us-east", payload), - }), - ), - testEnv({ - d1, - r2Puts, - kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]], - }), - ); - - assert.equal(response.status, 400); - assert.deepEqual(await response.json(), { error: "invalid_sync_push" }); - assert.equal(r2Puts.length, 0); - assert.equal(d1.queries.length, 1); - assert.deepEqual(d1.batches, []); - }); - - it("rejects stale logical clocks before persistence writes", async () => { - const payload = bytes("encrypted tab payload"); - const payloadHash = sha256(payload); - const tokenHash = await authTokenHash(ACCESS_TOKEN); - const d1 = testD1Database({ - firstRows: [ - { device_id: DEVICE_ID }, - syncObjectRow({ payload_hash: "d".repeat(64), logical_clock: 43 }), - ], - }); - - const response = await handleRequest( - syncPushRequest( - syncPushBody({ - payload_hash: payloadHash, - logical_clock: 42, - payload: inlinePayload(payload), - }), - ), - testEnv({ - d1, - kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]], - }), - ); - - assert.equal(response.status, 409); - assert.deepEqual(await response.json(), { error: "sync_conflict" }); - assert.deepEqual(d1.batches, []); - }); - - it("rejects same-clock object write races after D1 persistence", async () => { - const payload = bytes("encrypted tab payload"); - const payloadHash = sha256(payload); - const tokenHash = await authTokenHash(ACCESS_TOKEN); - const d1 = testD1Database({ - firstRows: [ - { device_id: DEVICE_ID }, - null, - syncObjectRow({ payload_hash: "e".repeat(64), logical_clock: 42 }), - ], - }); - - const response = await handleRequest( - syncPushRequest(syncPushBody({ payload_hash: payloadHash, payload: inlinePayload(payload) })), - testEnv({ - d1, - kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]], - }), - ); - - assert.equal(response.status, 409); - assert.deepEqual(await response.json(), { error: "sync_conflict" }); - assert.equal(d1.batches[0], 2); - }); - - it("rejects revoked devices before reading the sync push body", async () => { - const tokenHash = await authTokenHash(ACCESS_TOKEN); - const d1 = testD1Database({ firstRows: [null] }); - const response = await handleRequest( - syncPushRequest(syncPushBody()), - testEnv({ - d1, - kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]], - }), - ); - - assert.equal(response.status, 403); - assert.deepEqual(await response.json(), { error: "device_not_approved" }); + assert.deepEqual(await response.json(), { error: "sync_object_protocol_retired" }); assert.equal(d1.queries.length, 1); assert.deepEqual(d1.batches, []); + assert.deepEqual(r2Puts, []); }); }); - -function syncPushRequest(body: Record): Request { - return new Request("https://elydora.test/api/sync/push", { - method: "POST", - headers: { - authorization: `Bearer ${ACCESS_TOKEN}`, - "content-type": "application/json", - }, - body: JSON.stringify(body), - }); -} - -function syncPushBody(overrides: Record = {}): Record { - const payload = bytes("encrypted tab payload"); - const payloadHash = sha256(payload); - return { - version: 1, - object_id: OBJECT_ID, - object_type: OBJECT_TYPE, - operation: "upsert", - payload_hash: payloadHash, - schema_rev: 1, - logical_clock: 42, - payload: inlinePayload(payload), - ...overrides, - }; -} - -function inlinePayload(payload: ArrayBuffer): Record { - return { kind: "inline", data_base64: base64(payload) }; -} - -function r2Payload(region: string, payload: ArrayBuffer): Record { - return { kind: "r2", region, data_base64: base64(payload) }; -} - -function syncObjectRow(overrides: Record = {}): Record { - return { - object_id: OBJECT_ID, - object_type: OBJECT_TYPE, - payload_r2_key: null, - payload_hash: "a".repeat(64), - schema_rev: 1, - logical_clock: 42, - device_id: DEVICE_ID, - created_at: 1_780_000_700, - updated_at: 1_780_000_700, - deleted_at: null, - ...overrides, - }; -} - -function syncObjectDocument(overrides: Record = {}): Record { - const row = syncObjectRow(overrides); - return { - object_id: row.object_id, - object_type: row.object_type, - operation: row.deleted_at === null ? "upsert" : "delete", - payload_hash: row.payload_hash, - schema_rev: row.schema_rev, - logical_clock: row.logical_clock, - device_id: row.device_id, - created_at: row.created_at, - updated_at: row.updated_at, - deleted_at: row.deleted_at, - payload_storage: - row.deleted_at !== null ? "tombstone" : row.payload_r2_key === null ? "inline" : "r2", - payload_r2_key: row.payload_r2_key, - }; -} - -function bytes(value: string): ArrayBuffer { - return new TextEncoder().encode(value).buffer; -} - -function base64(payload: ArrayBuffer): string { - return Buffer.from(payload).toString("base64"); -} - -function sha256(payload: ArrayBuffer): string { - return createHash("sha256").update(new Uint8Array(payload)).digest("hex"); -} diff --git a/cloudflare/tests/sync_r2_gc_sqlite.test.ts b/cloudflare/tests/sync_r2_gc_sqlite.test.ts new file mode 100644 index 0000000..8837af0 --- /dev/null +++ b/cloudflare/tests/sync_r2_gc_sqlite.test.ts @@ -0,0 +1,456 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, it } from "node:test"; + +import type { ElyR2Object, ElyR2PutOptions, Env } from "../src/bindings.js"; +import { recentDeviceActionProofBytes } from "../src/recent_device_action_proof.js"; +import { + SYNC_R2_ANONYMIZE_USER_QUERY, + SYNC_R2_FENCE_USER_QUERY, + abandonSyncR2Write, + claimSyncR2SnapshotWrite, + collectSyncR2Garbage, +} from "../src/sync_r2_gc.js"; +import { inventorySyncR2Objects } from "../src/sync_r2_inventory.js"; +import { syncResetDocument } from "../src/sync_reset.js"; +import { PUBLIC_KEY, signDeviceMessage } from "./devices_test_support.js"; +import { SqliteD1Database, execute, query } from "./sqlite_d1_test_support.js"; + +const USER_ID = "user-01"; +const DEVICE_ID = "device-01"; +const KEY_ID = "1".repeat(64); +const OWNER_HASH = createHash("sha256").update(USER_ID).digest("hex"); +const HASH_A = "a".repeat(64); +const HASH_B = "b".repeat(64); +const TOKEN_A = "c".repeat(64); +const TOKEN_B = "d".repeat(64); +const NOW = 1_800_000_000; +const MIGRATIONS_DIR = join(process.cwd(), "migrations"); + +describe("sync R2 GC SQLite state machine", () => { + it("commits a leased candidate and deletes it only after D1 references are fenced", async () => { + await withDatabase(async (databasePath, database, bucket, env) => { + const key = snapshotKey(HASH_A); + const lease = await claimSyncR2SnapshotWrite(env, snapshotClaim(key), NOW, TOKEN_A); + await bucket.put(key, bytes("ciphertext-a")); + commitGenesis(databasePath, key, HASH_A, lease.writeToken); + + assert.equal(candidateState(databasePath, key), "referenced"); + assert.equal(await collectSyncR2Garbage(env, NOW + 100_000), 0); + + await database.batch([ + database.prepare(SYNC_R2_FENCE_USER_QUERY).bind(NOW + 1, NOW + 1, NOW + 1, USER_ID), + database.prepare("DELETE FROM sync_snapshot_heads WHERE user_id = ?").bind(USER_ID), + database.prepare("DELETE FROM sync_snapshot_encryption WHERE user_id = ?").bind(USER_ID), + database.prepare("DELETE FROM sync_snapshots WHERE user_id = ?").bind(USER_ID), + ]); + assert.equal(candidateState(databasePath, key), "ready"); + assert.equal(await collectSyncR2Garbage(env, NOW + 1), 1); + assert.equal(candidateState(databasePath, key), "deleted"); + assert.deepEqual(bucket.deletes, [key]); + }); + }); + + it("turns a CAS loser into an immediately collectible ready candidate", async () => { + await withDatabase(async (databasePath, _database, bucket, env) => { + const key = snapshotKey(HASH_A); + const lease = await claimSyncR2SnapshotWrite(env, snapshotClaim(key), NOW, TOKEN_A); + await bucket.put(key, bytes("ciphertext-a")); + + await abandonSyncR2Write(env, USER_ID, OWNER_HASH, key, lease.writeToken, NOW + 1); + + assert.equal(candidateState(databasePath, key), "ready"); + assert.equal(await collectSyncR2Garbage(env, NOW + 1), 1); + assert.equal(candidateState(databasePath, key), "deleted"); + assert.deepEqual(bucket.deletes, [key]); + }); + }); + + it("resets sync state while preserving the vault generation and device envelopes", async () => { + await withDatabase(async (databasePath, _database, bucket, env) => { + seedVaultEnvelope(databasePath); + const key = snapshotKey(HASH_A); + const lease = await claimSyncR2SnapshotWrite(env, snapshotClaim(key), NOW, TOKEN_A); + await bucket.put(key, bytes("ciphertext-a")); + commitGenesis(databasePath, key, HASH_A, lease.writeToken); + + const document = await syncResetDocument( + await resetRequest("sync-reset-000001", NOW + 1), + env, + authContext(), + NOW + 1, + ); + + assert.equal(document.deleted.snapshots, 1); + assert.equal(document.deleted.r2_objects, 1); + assert.deepEqual(query(databasePath, ` + SELECT current_key_id, current_generation FROM sync_vault_accounts + WHERE user_id = '${USER_ID}' + `), [{ current_key_id: KEY_ID, current_generation: 1 }]); + assert.equal(query(databasePath, ` + SELECT COUNT(*) AS count FROM sync_vault_envelopes WHERE user_id = '${USER_ID}' + `)[0]?.count, 1); + assert.equal(query(databasePath, ` + SELECT COUNT(*) AS count FROM user_devices WHERE user_id = '${USER_ID}' + `)[0]?.count, 1); + assert.equal(query(databasePath, ` + SELECT COUNT(*) AS count FROM sync_snapshots WHERE user_id = '${USER_ID}' + `)[0]?.count, 0); + assert.equal(candidateState(databasePath, key), "deleted"); + assert.deepEqual(bucket.deletes, [key]); + }); + }); + + it("keeps a fenced pending lease until a late R2 put can be collected", async () => { + await withDatabase(async (databasePath, _database, bucket, env) => { + const key = snapshotKey(HASH_A); + await claimSyncR2SnapshotWrite(env, snapshotClaim(key), NOW, TOKEN_A); + await syncResetDocument( + await resetRequest("sync-reset-000002", NOW + 1), + env, + authContext(), + NOW + 1, + ); + + assert.equal(candidateState(databasePath, key), "ready"); + assert.equal(await collectSyncR2Garbage(env, NOW + 1, { userId: USER_ID }), 0); + await bucket.put(key, bytes("late-pending-ciphertext")); + assert.equal(await collectSyncR2Garbage(env, NOW + 600, { userId: USER_ID }), 1); + assert.equal(candidateState(databasePath, key), "deleted"); + assert.equal(bucket.has(key), false); + assert.deepEqual(bucket.deletes, [key]); + assert.equal(query(databasePath, ` + SELECT COUNT(*) AS count FROM sync_vault_accounts WHERE user_id = '${USER_ID}' + `)[0]?.count, 1); + }); + }); + + it("rolls back reset when its authenticated authority changes before the batch", async () => { + for (const beforeBatchSql of [ + "DELETE FROM better_auth_session WHERE id = 'session-01';", + `UPDATE user_devices SET revoked_at = ${NOW} WHERE device_id = '${DEVICE_ID}';`, + ]) { + await withDatabase(async (databasePath, _database, bucket, env) => { + const key = snapshotKey(HASH_A); + const lease = await claimSyncR2SnapshotWrite(env, snapshotClaim(key), NOW, TOKEN_A); + await bucket.put(key, bytes("ciphertext-a")); + commitGenesis(databasePath, key, HASH_A, lease.writeToken); + const request = await resetRequest("sync-reset-authority-race", NOW + 1); + const racedEnv = { + ...env, + ELY_DB: new SqliteD1Database(databasePath, beforeBatchSql), + } as Env; + + await assert.rejects( + () => syncResetDocument(request, racedEnv, authContext(), NOW + 1), + /device_action_gate_failed/, + ); + + assert.deepEqual(query(databasePath, `SELECT + (SELECT COUNT(*) FROM sync_snapshots WHERE user_id = '${USER_ID}') AS snapshots, + (SELECT COUNT(*) FROM sync_snapshot_encryption WHERE user_id = '${USER_ID}') AS encryption, + (SELECT COUNT(*) FROM sync_snapshot_heads WHERE user_id = '${USER_ID}') AS heads, + (SELECT COUNT(*) FROM audit_events WHERE event_type = 'sync.reset') AS audits + `), [{ snapshots: 1, encryption: 1, heads: 1, audits: 0 }]); + assert.equal(candidateState(databasePath, key), "referenced"); + assert.equal(bucket.has(key), true); + }); + } + }); + + it("retries an idempotent R2 deletion after a crash before D1 finalization", async () => { + await withDatabase(async (databasePath, _database, bucket, env) => { + const key = snapshotKey(HASH_A); + const lease = await claimSyncR2SnapshotWrite(env, snapshotClaim(key), NOW, TOKEN_A); + await bucket.put(key, bytes("ciphertext-a")); + await abandonSyncR2Write(env, USER_ID, OWNER_HASH, key, lease.writeToken, NOW + 1); + bucket.crashAfterNextDelete = true; + + await assert.rejects(() => collectSyncR2Garbage(env, NOW + 1), /simulated_delete_crash/); + assert.equal(candidateState(databasePath, key), "deleting"); + assert.equal(bucket.has(key), false); + + assert.equal(await collectSyncR2Garbage(env, NOW + 62), 1); + assert.equal(candidateState(databasePath, key), "deleted"); + assert.deepEqual(bucket.deletes, [key, key]); + }); + }); + + it("fences an upload that overlaps account deletion and clears the raw owner id", async () => { + await withDatabase(async (databasePath, database, bucket, env) => { + const key = snapshotKey(HASH_A); + const lease = await claimSyncR2SnapshotWrite(env, snapshotClaim(key), NOW, TOKEN_A); + await database.batch([ + database.prepare(SYNC_R2_FENCE_USER_QUERY).bind(NOW + 1, NOW + 1, NOW + 1, USER_ID), + database.prepare(SYNC_R2_ANONYMIZE_USER_QUERY).bind(NOW + 1, USER_ID, OWNER_HASH), + ]); + assert.equal(await collectSyncR2Garbage(env, NOW + 1, { ownerHash: OWNER_HASH }), 0); + await bucket.put(key, bytes("late-ciphertext")); + + assert.throws( + () => commitGenesis(databasePath, key, HASH_A, lease.writeToken), + /sync_r2_write_fenced/, + ); + assert.deepEqual(candidateOwner(databasePath, key), { + user_id: null, + owner_hash: OWNER_HASH, + state: "ready", + }); + assert.equal(await collectSyncR2Garbage( + env, + lease.leaseExpiresAt, + { ownerHash: OWNER_HASH }, + ), 1); + }); + }); + + it("rejects a pre-put claim after reset removes the vault authority", async () => { + await withDatabase(async (_databasePath, database, _bucket, env) => { + await database.prepare("DELETE FROM sync_vault_accounts WHERE user_id = ?") + .bind(USER_ID) + .run(); + + await assert.rejects( + () => claimSyncR2SnapshotWrite( + env, + snapshotClaim(snapshotKey(HASH_A)), + NOW, + TOKEN_A, + ), + /sync_r2_write_fenced/, + ); + }); + }); + + it("inventories historical snapshot and sync-payload orphans", async () => { + await withDatabase(async (databasePath, _database, bucket, env) => { + const snapshot = snapshotKey(HASH_A); + const payload = payloadKey(HASH_B); + await bucket.put(snapshot, bytes("snapshot-orphan")); + await bucket.put(payload, bytes("payload-orphan")); + + assert.equal(await inventorySyncR2Objects(env, NOW, 100), 1); + assert.equal(await inventorySyncR2Objects(env, NOW + 1, 100), 1); + assert.equal(candidateState(databasePath, snapshot), "ready"); + assert.equal(candidateState(databasePath, payload), "ready"); + assert.equal(await collectSyncR2Garbage(env, NOW + 1), 2); + assert.deepEqual(bucket.deletes.sort(), [payload, snapshot].sort()); + }); + }); +}); + +async function withDatabase( + assertions: ( + databasePath: string, + database: SqliteD1Database, + bucket: TestBucket, + env: Env, + ) => Promise, +): Promise { + const tempDir = mkdtempSync(join(tmpdir(), "ely-r2-gc-")); + try { + const databasePath = join(tempDir, "ely.db"); + for (const fileName of readdirSync(MIGRATIONS_DIR).filter((name) => name.endsWith(".sql")).sort()) { + execute(databasePath, readFileSync(join(MIGRATIONS_DIR, fileName), "utf8")); + } + seedAuthority(databasePath); + const database = new SqliteD1Database(databasePath); + const bucket = new TestBucket(); + const env = { ELY_DB: database, ELY_STORAGE: bucket } as unknown as Env; + await assertions(databasePath, database, bucket, env); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +} + +function seedAuthority(databasePath: string): void { + execute(databasePath, ` + INSERT INTO better_auth_user ( + id, name, email, emailVerified, createdAt, updatedAt + ) VALUES ( + '${USER_ID}', 'User', 'user@example.com', 1, + '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z' + ); + INSERT INTO user_devices ( + user_id, device_id, public_key, device_name, platform, + approval_status, created_at, approved_at, last_active_at, revoked_at, idempotency_key + ) VALUES ( + '${USER_ID}', '${DEVICE_ID}', '${PUBLIC_KEY}', 'Mac', 'macOS', + 'approved', 1, 1, 1, NULL, 'device-register-0001' + ); + INSERT INTO user_device_keys ( + user_id, device_id, signing_public_key, wrapping_public_key, + key_protocol_version, created_at + ) VALUES ( + '${USER_ID}', '${DEVICE_ID}', '${PUBLIC_KEY}', '${"f".repeat(64)}', 2, 1 + ); + INSERT INTO better_auth_session ( + id, expiresAt, token, createdAt, updatedAt, userId + ) VALUES ( + 'session-01', '2099-01-01T00:00:00Z', 'session-token-01', + '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', '${USER_ID}' + ); + INSERT INTO better_auth_session_device_context ( + session_id, user_id, device_id, updated_at + ) VALUES ('session-01', '${USER_ID}', '${DEVICE_ID}', 1); + INSERT INTO sync_vault_accounts ( + user_id, current_key_id, current_generation, created_at, updated_at + ) VALUES ('${USER_ID}', '${KEY_ID}', 1, 1, 1); + `); +} + +function seedVaultEnvelope(databasePath: string): void { + execute(databasePath, ` + INSERT INTO sync_vault_envelopes ( + user_id, recipient_device_id, approver_device_id, key_id, generation, + envelope_version, suite, encapped_key, ciphertext, idempotency_key, created_at + ) VALUES ( + '${USER_ID}', '${DEVICE_ID}', '${DEVICE_ID}', '${KEY_ID}', 1, 1, + 'HPKE-BASE-X25519-HKDF-SHA256-CHACHA20POLY1305', + '${"A".repeat(43)}', '${"B".repeat(64)}', 'vault-bootstrap-0001', 1 + ); + `); +} + +function commitGenesis(databasePath: string, r2Key: string, payloadHash: string, token: string): void { + execute(databasePath, ` + BEGIN IMMEDIATE; + INSERT INTO sync_snapshots ( + user_id, snapshot_id, r2_key, payload_hash, schema_rev, logical_clock, + device_id, size_bytes, created_at, head_revision, + base_head_revision, base_snapshot_id, base_payload_hash + ) VALUES ( + '${USER_ID}', '${DEVICE_ID}', '${r2Key}', '${payloadHash}', 1, 1, + '${DEVICE_ID}', 12, ${NOW}, 1, NULL, NULL, NULL + ); + INSERT INTO sync_snapshot_encryption ( + user_id, snapshot_id, encryption_version, vault_generation, key_id, content_hash + ) VALUES ('${USER_ID}', '${DEVICE_ID}', 2, 1, '${KEY_ID}', '${HASH_B}'); + INSERT INTO sync_snapshot_heads ( + user_id, head_revision, snapshot_id, payload_hash, updated_at + ) VALUES ('${USER_ID}', 1, '${DEVICE_ID}', '${payloadHash}', ${NOW}); + UPDATE sync_r2_gc_candidates + SET state = 'referenced', lease_expires_at = ${NOW}, + updated_at = ${NOW}, referenced_at = ${NOW} + WHERE r2_key = '${r2Key}' AND user_id = '${USER_ID}' + AND state = 'pending' AND write_token = '${token}' + AND lease_expires_at >= ${NOW}; + COMMIT; + `); +} + +function snapshotClaim(r2Key: string) { + return { + userId: USER_ID, + deviceId: DEVICE_ID, + r2Key, + ownerHash: OWNER_HASH, + keyId: KEY_ID, + generation: 1, + headRevision: 1, + baseHead: null, + } as const; +} + +function authContext() { + return { + userId: USER_ID, + deviceId: DEVICE_ID, + sessionId: "session-01", + tokenHash: HASH_B, + expiresAt: "2099-01-01T00:00:00Z", + createdAt: "2026-01-01T00:00:00Z", + } as const; +} + +async function resetRequest(idempotencyKey: string, proofCreatedAt: number): Promise { + const confirmation = "delete-cloud-sync-data"; + const actionProof = await signDeviceMessage(recentDeviceActionProofBytes({ + action: "sync.reset", + userId: USER_ID, + sessionId: authContext().sessionId, + deviceId: DEVICE_ID, + confirmation, + idempotencyKey, + proofCreatedAt, + })); + return new Request("https://elydora.test/api/sync/reset", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + version: 2, + confirmation, + idempotency_key: idempotencyKey, + proof_created_at: proofCreatedAt, + action_proof: actionProof, + }), + }); +} + +function candidateState(databasePath: string, key: string): unknown { + return query(databasePath, ` + SELECT state FROM sync_r2_gc_candidates WHERE r2_key = '${key}' + `)[0]?.state; +} + +function candidateOwner(databasePath: string, key: string): Record | undefined { + return query(databasePath, ` + SELECT user_id, owner_hash, state FROM sync_r2_gc_candidates WHERE r2_key = '${key}' + `)[0]; +} + +function snapshotKey(hash: string): string { + return `sync-snapshots/us-east/${OWNER_HASH}/${DEVICE_ID}/${hash}.bin`; +} + +function payloadKey(hash: string): string { + return `sync-payloads/us-east/${OWNER_HASH}/bookmarks/object-01/${hash}.bin`; +} + +function bytes(value: string): ArrayBuffer { + return new TextEncoder().encode(value).buffer; +} + +class TestBucket { + readonly deletes: string[] = []; + crashAfterNextDelete = false; + private readonly values = new Map(); + + get(key: string): Promise { + const value = this.values.get(key); + return Promise.resolve(value === undefined ? null : object(value)); + } + + put(key: string, value: ArrayBuffer, _options?: ElyR2PutOptions): Promise { + this.values.set(key, value); + return Promise.resolve(object(value)); + } + + async delete(key: string): Promise { + this.deletes.push(key); + this.values.delete(key); + if (this.crashAfterNextDelete) { + this.crashAfterNextDelete = false; + throw new Error("simulated_delete_crash"); + } + } + + list(options: { prefix: string; cursor?: string; limit: number }) { + const keys = [...this.values.keys()].filter((key) => key.startsWith(options.prefix)).sort(); + return Promise.resolve({ + objects: keys.slice(0, options.limit).map((key) => ({ key })), + truncated: false as const, + }); + } + + has(key: string): boolean { + return this.values.has(key); + } +} + +function object(value: ArrayBuffer): ElyR2Object { + return { arrayBuffer: () => Promise.resolve(value) }; +} diff --git a/cloudflare/tests/sync_reset_routes.test.ts b/cloudflare/tests/sync_reset_routes.test.ts index 5a6fb7f..2ce01cd 100644 --- a/cloudflare/tests/sync_reset_routes.test.ts +++ b/cloudflare/tests/sync_reset_routes.test.ts @@ -4,7 +4,15 @@ import { describe, it } from "node:test"; import { authSessionCacheKvKey, authTokenHash } from "../src/auth.js"; import { handleRequest } from "../src/index.js"; -import { ACCESS_TOKEN, sessionDocument, testD1Database, testEnv } from "./devices_test_support.js"; +import { recentDeviceActionProofBytes } from "../src/recent_device_action_proof.js"; +import { + ACCESS_TOKEN, + PUBLIC_KEY, + sessionDocument, + signDeviceMessage, + testD1Database, + testEnv, +} from "./devices_test_support.js"; const USER_ID = "user-01"; const DEVICE_ID = "device-01"; @@ -19,12 +27,17 @@ describe("sync reset routes", () => { const r2Deletes: string[] = []; const tokenHash = await authTokenHash(ACCESS_TOKEN); const d1 = testD1Database({ - firstRows: [{ device_id: DEVICE_ID }, null, resetCountsRow()], + firstRows: [ + { device_id: DEVICE_ID }, + { signing_public_key: PUBLIC_KEY }, + null, + resetCountsRow(), + ], allRows: [{ r2_key: PAYLOAD_KEY }, { r2_key: SNAPSHOT_KEY }], }); const response = await handleRequest( - syncResetRequest(syncResetBody()), + syncResetRequest(await syncResetBody()), testEnv({ d1, r2Deletes, @@ -55,16 +68,31 @@ describe("sync reset routes", () => { r2_objects: 2, }); assert.deepEqual(r2Deletes, [PAYLOAD_KEY, SNAPSHOT_KEY]); - assert.equal(d1.batches[0], 5); - assert.ok(d1.queries[1]?.includes("FROM audit_events")); - assert.ok(d1.queries[2]?.includes("FROM sync_objects")); - assert.ok(d1.queries[3]?.includes("UNION")); - assert.ok(d1.queries[4]?.includes("DELETE FROM sync_change_log")); - assert.ok(d1.queries[8]?.includes("INSERT INTO audit_events")); - assert.deepEqual(d1.binds[1], [USER_ID, syncResetEventId()]); - assert.deepEqual(d1.binds[2], [USER_ID, USER_ID, USER_ID, USER_ID]); - assert.deepEqual(d1.binds[3], [USER_ID, USER_ID]); - assert.deepEqual(d1.binds[8]?.slice(0, 4), [syncResetEventId(), USER_ID, DEVICE_ID, USER_ID]); + assert.equal(d1.batches[0], 9); + assert.ok(d1.queries[1]?.includes("signing_public_key")); + assert.ok(d1.queries[2]?.includes("FROM audit_events")); + assert.ok(d1.queries[3]?.includes("FROM sync_objects")); + assert.ok(d1.queries[4]?.includes("FROM sync_r2_gc_candidates")); + assert.deepEqual(d1.binds[2], [USER_ID, syncResetEventId()]); + assert.deepEqual(d1.binds[3], [USER_ID, USER_ID, USER_ID, USER_ID]); + assert.deepEqual(d1.binds[4], [USER_ID]); + assert.ok(d1.queries[5]?.includes("CASE WHEN EXISTS")); + assert.ok(d1.queries[6]?.includes("UPDATE sync_r2_gc_candidates")); + assert.ok(d1.queries[7]?.includes("UPDATE sync_vault_rotations")); + assert.ok(d1.queries[8]?.includes("DELETE FROM sync_change_log")); + assert.ok(d1.queries[10]?.includes("DELETE FROM sync_snapshot_heads")); + assert.ok(d1.queries[11]?.includes("DELETE FROM sync_snapshot_encryption")); + assert.ok(d1.queries[12]?.includes("DELETE FROM sync_snapshots")); + assert.equal(d1.queries.some((query) => query.includes("DELETE FROM sync_vault")), false); + assert.deepEqual(d1.binds[5]?.slice(0, 6), [ + syncResetEventId(), + USER_ID, + DEVICE_ID, + "sync.reset", + "sync", + USER_ID, + ]); + assert.equal(d1.binds[5]?.[11], PUBLIC_KEY); }); it("returns an idempotent reset document for existing audit events", async () => { @@ -73,13 +101,14 @@ describe("sync reset routes", () => { const d1 = testD1Database({ firstRows: [ { device_id: DEVICE_ID }, + { signing_public_key: PUBLIC_KEY }, { actor_device_id: DEVICE_ID, outcome: "success", created_at: 1_780_001_000 }, ], allRows: [{ r2_key: PAYLOAD_KEY }], }); const response = await handleRequest( - syncResetRequest(syncResetBody()), + syncResetRequest(await syncResetBody()), testEnv({ d1, r2Deletes, @@ -96,8 +125,8 @@ describe("sync reset routes", () => { reset_at: 1_780_001_000, deleted: { objects: 0, changes: 0, snapshots: 0, tombstones: 0, r2_objects: 0 }, }); - assert.deepEqual(r2Deletes, []); - assert.equal(d1.queries.length, 2); + assert.deepEqual(r2Deletes, [PAYLOAD_KEY]); + assert.equal(d1.queries.length, 6); assert.deepEqual(d1.batches, []); }); @@ -107,12 +136,13 @@ describe("sync reset routes", () => { const d1 = testD1Database({ firstRows: [ { device_id: DEVICE_ID }, + { signing_public_key: PUBLIC_KEY }, { actor_device_id: "device-02", outcome: "success", created_at: 1_780_001_000 }, ], }); const response = await handleRequest( - syncResetRequest(syncResetBody()), + syncResetRequest(await syncResetBody()), testEnv({ d1, r2Deletes, @@ -132,7 +162,7 @@ describe("sync reset routes", () => { const d1 = testD1Database({ firstRows: [{ device_id: DEVICE_ID }] }); const response = await handleRequest( - syncResetRequest(syncResetBody({ confirmation: "delete" })), + syncResetRequest(await syncResetBody({ confirmation: "delete" })), testEnv({ d1, r2Deletes, @@ -152,7 +182,7 @@ describe("sync reset routes", () => { const d1 = testD1Database({ firstRows: [null] }); const response = await handleRequest( - syncResetRequest(syncResetBody()), + syncResetRequest(await syncResetBody()), testEnv({ d1, kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]], @@ -165,16 +195,21 @@ describe("sync reset routes", () => { assert.deepEqual(d1.batches, []); }); - it("fails closed when stored R2 keys are malformed", async () => { + it("keeps reset successful when scheduled GC must handle a malformed legacy key", async () => { const r2Deletes: string[] = []; const tokenHash = await authTokenHash(ACCESS_TOKEN); const d1 = testD1Database({ - firstRows: [{ device_id: DEVICE_ID }, null, resetCountsRow()], + firstRows: [ + { device_id: DEVICE_ID }, + { signing_public_key: PUBLIC_KEY }, + null, + resetCountsRow(), + ], allRows: [{ r2_key: "sync-snapshots/../bad.bin" }], }); const response = await handleRequest( - syncResetRequest(syncResetBody()), + syncResetRequest(await syncResetBody()), testEnv({ d1, r2Deletes, @@ -182,10 +217,9 @@ describe("sync reset routes", () => { }), ); - assert.equal(response.status, 500); - assert.deepEqual(await response.json(), { error: "sync_reset_failed" }); + assert.equal(response.status, 200); assert.deepEqual(r2Deletes, []); - assert.deepEqual(d1.batches, []); + assert.deepEqual(d1.batches, [9]); }); }); @@ -200,13 +234,26 @@ function syncResetRequest(body: Record): Request { }); } -function syncResetBody(overrides: Record = {}): Record { - return { - version: 1, +async function syncResetBody( + overrides: Record = {}, +): Promise> { + const body: Record = { + version: 2, confirmation: "delete-cloud-sync-data", idempotency_key: IDEMPOTENCY_KEY, + proof_created_at: Math.floor(Date.now() / 1000), ...overrides, }; + body.action_proof = await signDeviceMessage(recentDeviceActionProofBytes({ + action: "sync.reset", + userId: USER_ID, + sessionId: "session-01", + deviceId: DEVICE_ID, + confirmation: String(body.confirmation), + idempotencyKey: String(body.idempotency_key), + proofCreatedAt: Number(body.proof_created_at), + })); + return body; } function resetCountsRow(overrides: Record = {}): Record { diff --git a/cloudflare/tests/sync_snapshot_codec.test.ts b/cloudflare/tests/sync_snapshot_codec.test.ts new file mode 100644 index 0000000..12dcf7e --- /dev/null +++ b/cloudflare/tests/sync_snapshot_codec.test.ts @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { payloadBytes, SyncSnapshotRequestError } from "../src/sync_snapshot_codec.js"; + +describe("sync snapshot codec", () => { + it("rejects oversized base64 before decoding", () => { + const originalAtob = globalThis.atob; + let decoded = false; + globalThis.atob = () => { + decoded = true; + return ""; + }; + try { + assert.throws( + () => payloadBytes("AAAAAAAA", "data_base64", 3), + (error) => + error instanceof SyncSnapshotRequestError && + error.message === "data_base64_size_invalid", + ); + } finally { + globalThis.atob = originalAtob; + } + assert.equal(decoded, false); + }); +}); diff --git a/cloudflare/tests/sync_snapshot_handler_sqlite.test.ts b/cloudflare/tests/sync_snapshot_handler_sqlite.test.ts new file mode 100644 index 0000000..79ad093 --- /dev/null +++ b/cloudflare/tests/sync_snapshot_handler_sqlite.test.ts @@ -0,0 +1,180 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, it } from "node:test"; + +import type { AuthContext } from "../src/auth.js"; +import { syncSnapshotUploadDocument } from "../src/sync_snapshot.js"; +import { type RecordedR2Put, testEnv } from "./devices_test_support.js"; +import { SqliteD1Database, execute } from "./sqlite_d1_test_support.js"; + +const USER_ID = "user-01"; +const DEVICE_ID = "device-01"; +const KEY_ID = "1".repeat(64); +const CONTENT_HASH = "2".repeat(64); +const MIGRATIONS_DIR = join(process.cwd(), "migrations"); + +describe("sync snapshot handler real D1 flow", () => { + it("commits genesis, same-id child, and exact duplicate through the five-statement batch", async () => { + await withDatabase(async (databasePath) => { + const d1 = new SqliteD1Database(databasePath); + const r2Puts: RecordedR2Put[] = []; + const env = testEnv({ d1, r2Puts }); + const genesisPayload = bytes("genesis ciphertext"); + const genesisHash = sha256(genesisPayload); + const genesis = await syncSnapshotUploadDocument( + request(body(genesisPayload, genesisHash, 1, null, 10)), + env, + authContext(), + 10, + ); + const base = { + revision: genesis.snapshot.head_revision, + snapshot_id: genesis.snapshot.snapshot_id, + payload_hash: genesis.snapshot.payload_hash, + }; + const childPayload = bytes("child ciphertext"); + const childHash = sha256(childPayload); + const childRequest = request(body(childPayload, childHash, 2, base, 11)); + const child = await syncSnapshotUploadDocument(childRequest, env, authContext(), 11); + const duplicate = await syncSnapshotUploadDocument( + request(body(childPayload, childHash, 2, base, 11)), + env, + authContext(), + 12, + ); + + assert.equal(genesis.snapshot.head_revision, 1); + assert.equal(child.snapshot.head_revision, 2); + assert.deepEqual(child.snapshot.base_head, base); + assert.deepEqual(duplicate, child); + assert.deepEqual(d1.batches, [5, 5]); + assert.deepEqual(d1.sessionConstraints, [ + "first-primary", + "first-primary", + "first-primary", + "first-primary", + "first-primary", + "first-primary", + ]); + assert.equal(r2Puts.length, 2); + assert.deepEqual(d1.rows(` + SELECT state, COUNT(*) AS count + FROM sync_r2_gc_candidates + WHERE user_id = '${USER_ID}' AND object_kind = 'snapshot' + GROUP BY state + ORDER BY state ASC + `), [ + { state: "ready", count: 1 }, + { state: "referenced", count: 1 }, + ]); + assert.deepEqual(d1.rows(` + SELECT head.head_revision, head.snapshot_id, head.payload_hash, + snapshot.logical_clock, encryption.content_hash + FROM sync_snapshot_heads AS head + INNER JOIN sync_snapshots AS snapshot + ON snapshot.user_id = head.user_id AND snapshot.snapshot_id = head.snapshot_id + INNER JOIN sync_snapshot_encryption AS encryption + ON encryption.user_id = snapshot.user_id + AND encryption.snapshot_id = snapshot.snapshot_id + WHERE head.user_id = '${USER_ID}' + `), [{ + head_revision: 2, + snapshot_id: DEVICE_ID, + payload_hash: childHash, + logical_clock: 11, + content_hash: CONTENT_HASH, + }]); + }); + }); +}); + +async function withDatabase(assertions: (databasePath: string) => Promise): Promise { + const tempDir = mkdtempSync(join(tmpdir(), "ely-snapshot-handler-")); + try { + const databasePath = join(tempDir, "ely.db"); + for (const fileName of readdirSync(MIGRATIONS_DIR).filter((name) => name.endsWith(".sql")).sort()) { + execute(databasePath, readFileSync(join(MIGRATIONS_DIR, fileName), "utf8")); + } + execute(databasePath, ` + INSERT INTO better_auth_user ( + id, name, email, emailVerified, createdAt, updatedAt + ) VALUES ( + '${USER_ID}', 'User', 'user@example.com', 1, + '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z' + ); + INSERT INTO user_devices ( + user_id, device_id, public_key, device_name, platform, + approval_status, created_at, approved_at, last_active_at, revoked_at, idempotency_key + ) VALUES ( + '${USER_ID}', '${DEVICE_ID}', '${"d".repeat(64)}', 'Mac', 'macOS', + 'approved', 1, 1, 1, NULL, 'device-register-0001' + ); + INSERT INTO user_device_keys ( + user_id, device_id, signing_public_key, wrapping_public_key, + key_protocol_version, created_at + ) VALUES ( + '${USER_ID}', '${DEVICE_ID}', '${"d".repeat(64)}', '${"e".repeat(64)}', 2, 1 + ); + INSERT INTO sync_vault_accounts ( + user_id, current_key_id, current_generation, created_at, updated_at + ) VALUES ('${USER_ID}', '${KEY_ID}', 1, 1, 1); + `); + await assertions(databasePath); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +} + +function body( + payload: ArrayBuffer, + payloadHash: string, + headRevision: number, + baseHead: Record | null, + logicalClock: number, +): Record { + return { + version: 3, + snapshot_id: DEVICE_ID, + region: "us-east", + payload_hash: payloadHash, + encryption_version: 2, + vault_generation: 1, + key_id: KEY_ID, + content_hash: CONTENT_HASH, + schema_rev: 1, + logical_clock: logicalClock, + head_revision: headRevision, + base_head: baseHead, + data_base64: Buffer.from(payload).toString("base64"), + }; +} + +function request(value: Record): Request { + return new Request("https://elydora.test/api/sync/snapshot", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(value), + }); +} + +function authContext(): AuthContext { + return { + userId: USER_ID, + sessionId: "session-01", + tokenHash: "f".repeat(64), + expiresAt: "2099-01-01T00:00:00.000Z", + createdAt: "2026-01-01T00:00:00.000Z", + deviceId: DEVICE_ID, + }; +} + +function bytes(value: string): ArrayBuffer { + return new TextEncoder().encode(value).buffer; +} + +function sha256(payload: ArrayBuffer): string { + return createHash("sha256").update(new Uint8Array(payload)).digest("hex"); +} diff --git a/cloudflare/tests/sync_snapshot_head_sqlite.test.ts b/cloudflare/tests/sync_snapshot_head_sqlite.test.ts new file mode 100644 index 0000000..eff7a0a --- /dev/null +++ b/cloudflare/tests/sync_snapshot_head_sqlite.test.ts @@ -0,0 +1,351 @@ +import assert from "node:assert/strict"; +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { DatabaseSync, type SQLInputValue } from "node:sqlite"; +import { describe, it } from "node:test"; + +import { + SYNC_SNAPSHOT_CANDIDATE_UPSERT_QUERY, + SYNC_SNAPSHOT_ENCRYPTION_UPSERT_QUERY, + SYNC_SNAPSHOT_HEAD_INSERT_QUERY, + SYNC_SNAPSHOT_HEAD_QUERY, + SYNC_SNAPSHOT_HEAD_UPDATE_QUERY, +} from "../src/sync_snapshot_sql.js"; +import { SYNC_R2_MARK_REFERENCED_QUERY } from "../src/sync_r2_gc.js"; +import { + SyncSnapshotHeadSchemaError, + type SyncSnapshotRow, + snapshotDocumentFromRow, +} from "../src/sync_snapshot_head.js"; + +const USER_ID = "user-01"; +const DEVICE_ID = "device-01"; +const KEY_ID = "1".repeat(64); +const NEXT_KEY_ID = "2".repeat(64); +const WRITE_TOKEN = "9".repeat(64); +const MIGRATIONS_DIR = join(process.cwd(), "migrations"); + +interface HeadRef { + revision: number; + snapshotId: string; + payloadHash: string; +} + +interface Candidate { + snapshotId: string; + payloadHash: string; + contentHash: string; + logicalClock: number; + headRevision: number; + base: HeadRef | null; + keyId?: string; + generation?: number; +} + +describe("sync snapshot head SQLite guards", () => { + it("commits a genesis head through the route SQL", () => { + using database = databaseWithApprovedDevice(); + const genesis = candidate({ payloadHash: "a".repeat(64) }); + + commitCandidate(database, genesis); + + assert.deepEqual(currentHead(database), { + head_revision: 1, + snapshot_id: "device-01", + payload_hash: "a".repeat(64), + }); + }); + + it("allows one writer per base even when the stale writer has a higher clock", () => { + using database = databaseWithApprovedDevice(); + const genesis = candidate({ payloadHash: "a".repeat(64) }); + commitCandidate(database, genesis); + const base = headRef(genesis); + const winner = candidate({ + payloadHash: "b".repeat(64), + contentHash: "3".repeat(64), + logicalClock: 11, + headRevision: 2, + base, + }); + commitCandidate(database, winner); + const loser = candidate({ + payloadHash: "c".repeat(64), + contentHash: "4".repeat(64), + logicalClock: 999, + headRevision: 2, + base, + }); + + assert.throws( + () => commitCandidate(database, loser), + /sync_r2_write_fenced/, + ); + assert.deepEqual(currentHead(database), { + head_revision: 2, + snapshot_id: "device-01", + payload_hash: "b".repeat(64), + }); + assert.deepEqual(snapshotState(database, "device-01"), { + payload_hash: "b".repeat(64), + content_hash: "3".repeat(64), + logical_clock: 11, + head_revision: 2, + }); + }); + + it("rolls back candidate metadata when the final head guard aborts", () => { + using database = databaseWithApprovedDevice(); + const genesis = candidate({ payloadHash: "a".repeat(64) }); + commitCandidate(database, genesis); + const child = candidate({ + snapshotId: "device-02", + payloadHash: "b".repeat(64), + contentHash: "3".repeat(64), + logicalClock: 11, + headRevision: 2, + base: headRef(genesis), + }); + + assert.throws( + () => commitCandidate(database, child, "f".repeat(64)), + /sync_r2_write_fenced/, + ); + assert.equal(snapshotState(database, "device-02"), undefined); + assert.deepEqual(currentHead(database), { + head_revision: 1, + snapshot_id: "device-01", + payload_hash: "a".repeat(64), + }); + }); + + it("advances an old-generation base with the current rotated key", () => { + using database = databaseWithApprovedDevice(); + const genesis = candidate({ payloadHash: "a".repeat(64) }); + commitCandidate(database, genesis); + database.prepare(` + UPDATE sync_vault_accounts + SET current_key_id = ?, current_generation = 2, updated_at = 2 + WHERE user_id = ? + `).run(NEXT_KEY_ID, USER_ID); + const child = candidate({ + payloadHash: "b".repeat(64), + contentHash: "3".repeat(64), + logicalClock: 11, + headRevision: 2, + base: headRef(genesis), + keyId: NEXT_KEY_ID, + generation: 2, + }); + + commitCandidate(database, child); + + assert.deepEqual(snapshotState(database, DEVICE_ID), { + payload_hash: "b".repeat(64), + content_hash: "3".repeat(64), + logical_clock: 11, + head_revision: 2, + }); + }); + + it("surfaces a current head whose encryption row is missing", () => { + using database = databaseWithApprovedDevice(); + const genesis = candidate({ payloadHash: "a".repeat(64) }); + commitCandidate(database, genesis); + const deletion = database.prepare(` + DELETE FROM sync_snapshot_encryption + WHERE user_id = ? AND snapshot_id = ? + `); + assert.throws(() => deletion.run(USER_ID, DEVICE_ID), /FOREIGN KEY constraint failed/); + database.exec("PRAGMA foreign_keys = OFF"); + deletion.run(USER_ID, DEVICE_ID); + database.exec("PRAGMA foreign_keys = ON"); + const row = database.prepare(SYNC_SNAPSHOT_HEAD_QUERY).get(USER_ID) as + | SyncSnapshotRow + | undefined; + + assert.ok(row !== undefined); + assert.throws( + () => snapshotDocumentFromRow(row), + (error) => + error instanceof SyncSnapshotHeadSchemaError && + error.message === "encryption_version_invalid", + ); + }); +}); + +function databaseWithApprovedDevice(): DatabaseSync { + const database = new DatabaseSync(":memory:"); + database.exec("PRAGMA foreign_keys = ON"); + for (const fileName of readdirSync(MIGRATIONS_DIR).filter((name) => name.endsWith(".sql")).sort()) { + database.exec(readFileSync(join(MIGRATIONS_DIR, fileName), "utf8")); + } + database.exec(` + INSERT INTO better_auth_user ( + id, name, email, emailVerified, createdAt, updatedAt + ) VALUES ( + '${USER_ID}', 'User', 'user@example.com', 1, + '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z' + ); + INSERT INTO user_devices ( + user_id, device_id, public_key, device_name, platform, + approval_status, created_at, approved_at, last_active_at, revoked_at, idempotency_key + ) VALUES ( + '${USER_ID}', '${DEVICE_ID}', '${"d".repeat(64)}', 'Mac', 'macOS', + 'approved', 1, 1, 1, NULL, 'device-register-0001' + ); + INSERT INTO user_device_keys ( + user_id, device_id, signing_public_key, wrapping_public_key, + key_protocol_version, created_at + ) VALUES ( + '${USER_ID}', '${DEVICE_ID}', '${"d".repeat(64)}', '${"e".repeat(64)}', 2, 1 + ); + INSERT INTO sync_vault_accounts ( + user_id, current_key_id, current_generation, created_at, updated_at + ) VALUES ('${USER_ID}', '${KEY_ID}', 1, 1, 1); + `); + return database; +} + +function commitCandidate( + database: DatabaseSync, + value: Candidate, + headPayloadHash = value.payloadHash, +): void { + const snapshotValues = candidateValues(value); + const r2Key = candidateR2Key(value); + database.prepare(` + INSERT INTO sync_r2_gc_candidates ( + r2_key, user_id, owner_hash, object_kind, state, write_token, + lease_expires_at, gc_token, created_at, updated_at, referenced_at, + ready_at, delete_started_at, deleted_at + ) VALUES (?, ?, ?, 'snapshot', 'pending', ?, 1000, NULL, 0, 0, NULL, NULL, NULL, NULL) + `).run(r2Key, USER_ID, "f".repeat(64), WRITE_TOKEN); + database.exec("BEGIN IMMEDIATE"); + try { + database.prepare(SYNC_SNAPSHOT_CANDIDATE_UPSERT_QUERY).run( + ...snapshotValues, + value.keyId ?? KEY_ID, + value.generation ?? 1, + WRITE_TOKEN, + value.headRevision, + ); + database.prepare(SYNC_SNAPSHOT_ENCRYPTION_UPSERT_QUERY).run( + ...snapshotValues, + 2, + value.generation ?? 1, + value.keyId ?? KEY_ID, + value.contentHash, + WRITE_TOKEN, + value.headRevision, + ); + if (value.base === null) { + database.prepare(SYNC_SNAPSHOT_HEAD_INSERT_QUERY).run( + USER_ID, + value.headRevision, + value.snapshotId, + headPayloadHash, + value.headRevision, + r2Key, + USER_ID, + WRITE_TOKEN, + value.headRevision, + ); + } else { + database.prepare(SYNC_SNAPSHOT_HEAD_UPDATE_QUERY).run( + value.headRevision, + value.snapshotId, + headPayloadHash, + value.headRevision, + USER_ID, + r2Key, + USER_ID, + WRITE_TOKEN, + value.headRevision, + ); + } + database.prepare(SYNC_R2_MARK_REFERENCED_QUERY).run( + value.headRevision, + value.headRevision, + value.headRevision, + r2Key, + USER_ID, + WRITE_TOKEN, + value.headRevision, + ); + database.exec("COMMIT"); + } catch (error) { + database.exec("ROLLBACK"); + throw error; + } +} + +function candidateValues(value: Candidate): SQLInputValue[] { + return [ + USER_ID, + value.snapshotId, + candidateR2Key(value), + value.payloadHash, + 1, + value.logicalClock, + DEVICE_ID, + 26, + value.headRevision, + value.headRevision, + value.base?.revision ?? null, + value.base?.snapshotId ?? null, + value.base?.payloadHash ?? null, + ]; +} + +function candidateR2Key(value: Candidate): string { + return `sync-snapshots/us-east/${"f".repeat(64)}/${value.snapshotId}/${value.payloadHash}.bin`; +} + +function candidate(overrides: Partial): Candidate { + return { + snapshotId: DEVICE_ID, + payloadHash: "a".repeat(64), + contentHash: "2".repeat(64), + logicalClock: 10, + headRevision: 1, + base: null, + ...overrides, + }; +} + +function headRef(value: Candidate): HeadRef { + return { + revision: value.headRevision, + snapshotId: value.snapshotId, + payloadHash: value.payloadHash, + }; +} + +function currentHead(database: DatabaseSync): Record | undefined { + const row = database.prepare(` + SELECT head_revision, snapshot_id, payload_hash + FROM sync_snapshot_heads + WHERE user_id = ? + `).get(USER_ID) as Record | undefined; + return row === undefined ? undefined : { ...row }; +} + +function snapshotState( + database: DatabaseSync, + snapshotId: string, +): Record | undefined { + const row = database.prepare(` + SELECT + snapshot.payload_hash, + encryption.content_hash, + snapshot.logical_clock, + snapshot.head_revision + FROM sync_snapshots AS snapshot + INNER JOIN sync_snapshot_encryption AS encryption + ON encryption.user_id = snapshot.user_id + AND encryption.snapshot_id = snapshot.snapshot_id + WHERE snapshot.user_id = ? AND snapshot.snapshot_id = ? + `).get(USER_ID, snapshotId) as Record | undefined; + return row === undefined ? undefined : { ...row }; +} diff --git a/cloudflare/tests/sync_snapshot_routes.test.ts b/cloudflare/tests/sync_snapshot_routes.test.ts index bd8fa48..67b678d 100644 --- a/cloudflare/tests/sync_snapshot_routes.test.ts +++ b/cloudflare/tests/sync_snapshot_routes.test.ts @@ -14,273 +14,372 @@ import { const USER_ID = "user-01"; const DEVICE_ID = "device-01"; -const SNAPSHOT_ID = "snapshot-01"; +const SNAPSHOT_ID = "device-01"; const REGION = "us-east"; +const KEY_ID = "1".repeat(64); +const CONTENT_HASH = "2".repeat(64); describe("sync snapshot routes", () => { - it("uploads an encrypted snapshot from an approved current device", async () => { - const payload = bytes("encrypted snapshot payload"); + it("commits a genesis encrypted snapshot as the global head", async () => { + const payload = opaqueEnvelopeBytes(); const payloadHash = sha256(payload); - const key = snapshotKey(payloadHash); + const row = snapshotRow({ + r2_key: snapshotKey(payloadHash), + payload_hash: payloadHash, + size_bytes: payload.byteLength, + }); const r2Puts: RecordedR2Put[] = []; - const tokenHash = await authTokenHash(ACCESS_TOKEN); const d1 = testD1Database({ - firstRows: [ - { device_id: DEVICE_ID }, - null, - snapshotRow({ r2_key: key, payload_hash: payloadHash, size_bytes: payload.byteLength }), - ], + firstRows: [{ device_id: DEVICE_ID }, null, vaultKeyRow()], + batchRowSets: [[[], [], [], [], [row]]], }); const response = await handleRequest( - syncSnapshotPostRequest( - syncSnapshotBody({ payload_hash: payloadHash, data_base64: base64(payload) }), - ), - testEnv({ - d1, - r2Puts, - kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]], - }), + syncSnapshotPostRequest(syncSnapshotBody({ + payload_hash: payloadHash, + data_base64: base64(payload), + })), + await authorizedEnv(d1, { r2Puts }), ); assert.equal(response.status, 201); - assert.equal(response.headers.get("cache-control"), "no-store"); - assert.deepEqual(await response.json(), { - version: 1, - user_id: USER_ID, - device_id: DEVICE_ID, - snapshot: snapshotDocument({ - r2_key: key, - payload_hash: payloadHash, - size_bytes: payload.byteLength, - }), - }); + assert.deepEqual(await response.json(), uploadDocument(row)); assert.equal(r2Puts.length, 1); - assert.equal(r2Puts[0]?.key, key); - assert.deepEqual(new Uint8Array(r2Puts[0]?.payload ?? new ArrayBuffer(0)), new Uint8Array(payload)); - assert.equal(r2Puts[0]?.options.customMetadata?.sha256, payloadHash); - assert.equal(d1.batches[0], 1); - assert.ok(d1.queries[1]?.includes("FROM sync_snapshots")); - assert.ok(d1.queries[2]?.includes("INSERT INTO sync_snapshots")); - assert.deepEqual(d1.binds[2]?.slice(0, 4), [USER_ID, SNAPSHOT_ID, key, payloadHash]); + assert.equal(d1.batches[0], 5); + assert.ok(d1.queries.some((query) => query.includes("INSERT INTO sync_snapshot_heads"))); }); - it("downloads an encrypted snapshot with R2 checksum verification", async () => { - const payload = bytes("encrypted snapshot payload"); + it("returns the original success document for an exact replay", async () => { + const payload = opaqueEnvelopeBytes(); const payloadHash = sha256(payload); - const key = snapshotKey(payloadHash); - const r2Gets: string[] = []; - const tokenHash = await authTokenHash(ACCESS_TOKEN); + const row = snapshotRow({ + r2_key: snapshotKey(payloadHash), + payload_hash: payloadHash, + size_bytes: payload.byteLength, + }); + const r2Puts: RecordedR2Put[] = []; + const d1 = testD1Database({ + firstRows: [{ device_id: DEVICE_ID }, row, vaultKeyRow()], + }); + + const response = await handleRequest( + syncSnapshotPostRequest(syncSnapshotBody({ + payload_hash: payloadHash, + data_base64: base64(payload), + })), + await authorizedEnv(d1, { r2Puts }), + ); + + assert.equal(response.status, 201); + assert.deepEqual(await response.json(), uploadDocument(row)); + assert.deepEqual(d1.batches, []); + assert.equal(r2Puts.length, 0); + assert.ok(d1.queries.some((query) => query.includes("SET cleanup_snapshot_id = ?"))); + }); + + it("returns the original success after the vault rotates", async () => { + const payload = opaqueEnvelopeBytes(); + const payloadHash = sha256(payload); + const row = snapshotRow({ + r2_key: snapshotKey(payloadHash), + payload_hash: payloadHash, + size_bytes: payload.byteLength, + }); + const r2Puts: RecordedR2Put[] = []; const d1 = testD1Database({ firstRows: [ { device_id: DEVICE_ID }, - snapshotRow({ r2_key: key, payload_hash: payloadHash, size_bytes: payload.byteLength }), + row, + { key_id: "f".repeat(64), generation: 2 }, ], }); const response = await handleRequest( - syncSnapshotGetRequest(), - testEnv({ - d1, - r2Gets, - r2Objects: [[key, payload]], - kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]], - }), + syncSnapshotPostRequest(syncSnapshotBody({ + payload_hash: payloadHash, + data_base64: base64(payload), + })), + await authorizedEnv(d1, { r2Puts }), + ); + + assert.equal(response.status, 201); + assert.deepEqual(await response.json(), uploadDocument(row)); + assert.equal(r2Puts.length, 0); + assert.equal(d1.queries.some((query) => query.includes("SET cleanup_snapshot_id = ?")), false); + }); + + it("returns the committed document when an identical concurrent writer wins", async () => { + const payload = opaqueEnvelopeBytes(); + const payloadHash = sha256(payload); + const baseRow = snapshotRow({ payload_hash: "a".repeat(64) }); + const committed = snapshotRow({ + payload_hash: payloadHash, + r2_key: snapshotKey(payloadHash), + content_hash: CONTENT_HASH, + logical_clock: 43, + head_revision: 2, + base_head_revision: 1, + base_snapshot_id: SNAPSHOT_ID, + base_payload_hash: "a".repeat(64), + size_bytes: payload.byteLength, + }); + const d1 = testD1Database({ + firstRows: [{ device_id: DEVICE_ID }, baseRow, vaultKeyRow(), committed], + batchError: new Error("sync_snapshot_head_cas_failed"), + }); + + const response = await handleRequest( + syncSnapshotPostRequest(syncSnapshotBody({ + payload_hash: payloadHash, + logical_clock: 43, + head_revision: 2, + base_head: headRef(baseRow), + data_base64: base64(payload), + })), + await authorizedEnv(d1), + ); + + assert.equal(response.status, 201); + assert.deepEqual(await response.json(), uploadDocument(committed)); + assert.deepEqual(d1.sessionConstraints, ["first-primary", "first-primary"]); + }); + + it("rejects a stale base before writing R2", async () => { + const current = snapshotRow({ payload_hash: "a".repeat(64) }); + const staleBase = headRef({ payload_hash: "b".repeat(64) }); + const r2Puts: RecordedR2Put[] = []; + const d1 = testD1Database({ + firstRows: [{ device_id: DEVICE_ID }, current, vaultKeyRow()], + }); + + const response = await handleRequest( + syncSnapshotPostRequest(syncSnapshotBody({ + head_revision: 2, + base_head: staleBase, + logical_clock: 43, + })), + await authorizedEnv(d1, { r2Puts }), + ); + + assert.equal(response.status, 409); + assert.deepEqual(await response.json(), conflictDocument(current)); + assert.equal(r2Puts.length, 0); + assert.deepEqual(d1.batches, []); + }); + + it("returns the winning head when D1 rejects a concurrent writer", async () => { + const baseRow = snapshotRow({ payload_hash: "a".repeat(64) }); + const winner = snapshotRow({ + payload_hash: "b".repeat(64), + r2_key: snapshotKey("b".repeat(64)), + content_hash: "3".repeat(64), + logical_clock: 43, + head_revision: 2, + base_head_revision: 1, + base_snapshot_id: SNAPSHOT_ID, + base_payload_hash: "a".repeat(64), + }); + const d1 = testD1Database({ + firstRows: [{ device_id: DEVICE_ID }, baseRow, vaultKeyRow(), winner], + batchError: new Error("sync_snapshot_head_cas_failed"), + }); + + const response = await handleRequest( + syncSnapshotPostRequest(syncSnapshotBody({ + head_revision: 2, + base_head: headRef(baseRow), + logical_clock: 44, + })), + await authorizedEnv(d1), + ); + + assert.equal(response.status, 409); + assert.deepEqual(await response.json(), conflictDocument(winner)); + }); + + it("downloads a snapshot only through its exact head token", async () => { + const payload = opaqueEnvelopeBytes(); + const payloadHash = sha256(payload); + const key = snapshotKey(payloadHash); + const row = snapshotRow({ + r2_key: key, + payload_hash: payloadHash, + size_bytes: payload.byteLength, + }); + const r2Gets: string[] = []; + const d1 = testD1Database({ firstRows: [{ device_id: DEVICE_ID }, row] }); + + const response = await handleRequest( + syncSnapshotGetRequest(headRef(row)), + await authorizedEnv(d1, { r2Gets, r2Objects: [[key, payload]] }), ); assert.equal(response.status, 200); assert.deepEqual(await response.json(), { - version: 1, - user_id: USER_ID, - device_id: DEVICE_ID, - snapshot: snapshotDocument({ - r2_key: key, - payload_hash: payloadHash, - size_bytes: payload.byteLength, - }), + ...uploadDocument(row), data_base64: base64(payload), }); assert.deepEqual(r2Gets, [key]); }); - it("returns not found for missing snapshot indexes", async () => { - const r2Gets: string[] = []; - const tokenHash = await authTokenHash(ACCESS_TOKEN); - const d1 = testD1Database({ firstRows: [{ device_id: DEVICE_ID }, null] }); + it("returns the current head for a historical different-device token", async () => { + const current = snapshotRow({ + snapshot_id: "device-02", + payload_hash: "b".repeat(64), + r2_key: snapshotKey("b".repeat(64)), + head_revision: 2, + base_head_revision: 1, + base_snapshot_id: SNAPSHOT_ID, + base_payload_hash: "a".repeat(64), + }); + const d1 = testD1Database({ + firstRows: [{ device_id: DEVICE_ID }, null, current], + }); const response = await handleRequest( - syncSnapshotGetRequest(), - testEnv({ - d1, - r2Gets, - kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]], - }), + syncSnapshotGetRequest(headRef({ payload_hash: "a".repeat(64) })), + await authorizedEnv(d1), ); - assert.equal(response.status, 404); - assert.deepEqual(await response.json(), { error: "sync_snapshot_not_found" }); - assert.deepEqual(r2Gets, []); + assert.equal(response.status, 409); + assert.deepEqual(await response.json(), conflictDocument(current)); }); - it("rejects snapshot checksum mismatches before D1 writes", async () => { - const payload = bytes("encrypted snapshot payload"); + it("returns a new head when cleanup removes payload after token validation", async () => { + const old = snapshotRow(); + const current = snapshotRow({ + snapshot_id: "device-02", + payload_hash: "b".repeat(64), + r2_key: snapshotKey("b".repeat(64)), + head_revision: 2, + base_head_revision: 1, + base_snapshot_id: SNAPSHOT_ID, + base_payload_hash: "a".repeat(64), + logical_clock: 43, + }); + const d1 = testD1Database({ + firstRows: [{ device_id: DEVICE_ID }, old, current], + }); + + const response = await handleRequest( + syncSnapshotGetRequest(headRef(old)), + await authorizedEnv(d1), + ); + + assert.equal(response.status, 409); + assert.deepEqual(await response.json(), conflictDocument(current)); + assert.deepEqual(d1.sessionConstraints, ["first-primary", "first-primary"]); + }); + + it("preserves legacy encryption metadata on exact downloads", async () => { + const payload = opaqueEnvelopeBytes(); + const payloadHash = sha256(payload); + const key = snapshotKey(payloadHash); + const row = snapshotRow({ + r2_key: key, + payload_hash: payloadHash, + encryption_version: 1, + size_bytes: payload.byteLength, + }); + const d1 = testD1Database({ firstRows: [{ device_id: DEVICE_ID }, row] }); + + const response = await handleRequest( + syncSnapshotGetRequest(headRef(row)), + await authorizedEnv(d1, { r2Objects: [[key, payload]] }), + ); + + assert.equal(response.status, 200); + const document = await response.json() as { version: number; snapshot: { encryption_version: number } }; + assert.equal(document.version, 3); + assert.equal(document.snapshot.encryption_version, 1); + }); + + it("rejects upload wire version 2 before R2 and D1 writes", async () => { const r2Puts: RecordedR2Put[] = []; - const tokenHash = await authTokenHash(ACCESS_TOKEN); const d1 = testD1Database({ firstRows: [{ device_id: DEVICE_ID }] }); const response = await handleRequest( - syncSnapshotPostRequest( - syncSnapshotBody({ payload_hash: "c".repeat(64), data_base64: base64(payload) }), - ), - testEnv({ - d1, - r2Puts, - kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]], - }), + syncSnapshotPostRequest(syncSnapshotBody({ version: 2 })), + await authorizedEnv(d1, { r2Puts }), ); assert.equal(response.status, 400); assert.deepEqual(await response.json(), { error: "invalid_sync_snapshot" }); assert.equal(r2Puts.length, 0); - assert.equal(d1.queries.length, 1); assert.deepEqual(d1.batches, []); }); - it("rejects stale snapshot clocks before R2 writes", async () => { - const payload = bytes("encrypted snapshot payload"); - const payloadHash = sha256(payload); - const r2Puts: RecordedR2Put[] = []; - const tokenHash = await authTokenHash(ACCESS_TOKEN); + it("fails closed when the committed head SELECT is empty", async () => { const d1 = testD1Database({ - firstRows: [ - { device_id: DEVICE_ID }, - snapshotRow({ payload_hash: "d".repeat(64), logical_clock: 43 }), - ], + firstRows: [{ device_id: DEVICE_ID }, null, vaultKeyRow()], + batchRowSets: [[[], [], [], [], []]], }); - const response = await handleRequest( - syncSnapshotPostRequest( - syncSnapshotBody({ - payload_hash: payloadHash, - logical_clock: 42, - data_base64: base64(payload), - }), - ), - testEnv({ - d1, - r2Puts, - kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]], - }), - ); - - assert.equal(response.status, 409); - assert.deepEqual(await response.json(), { error: "sync_snapshot_conflict" }); - assert.equal(r2Puts.length, 0); - assert.deepEqual(d1.batches, []); - }); - - it("rejects same-clock snapshot write races after D1 persistence", async () => { - const payload = bytes("encrypted snapshot payload"); - const payloadHash = sha256(payload); - const key = snapshotKey(payloadHash); - const r2Puts: RecordedR2Put[] = []; - const tokenHash = await authTokenHash(ACCESS_TOKEN); - const d1 = testD1Database({ - firstRows: [ - { device_id: DEVICE_ID }, - null, - snapshotRow({ r2_key: key, payload_hash: "e".repeat(64), size_bytes: payload.byteLength }), - ], - }); - - const response = await handleRequest( - syncSnapshotPostRequest( - syncSnapshotBody({ payload_hash: payloadHash, data_base64: base64(payload) }), - ), - testEnv({ - d1, - r2Puts, - kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]], - }), - ); - - assert.equal(response.status, 409); - assert.deepEqual(await response.json(), { error: "sync_snapshot_conflict" }); - assert.equal(r2Puts.length, 1); - assert.equal(d1.batches[0], 1); - }); - - it("rejects revoked devices before reading snapshot payloads", async () => { - const tokenHash = await authTokenHash(ACCESS_TOKEN); - const d1 = testD1Database({ firstRows: [null] }); - const response = await handleRequest( syncSnapshotPostRequest(syncSnapshotBody()), - testEnv({ - d1, - kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]], - }), - ); - - assert.equal(response.status, 403); - assert.deepEqual(await response.json(), { error: "device_not_approved" }); - assert.equal(d1.queries.length, 1); - assert.deepEqual(d1.batches, []); - }); - - it("fails closed when a stored snapshot payload fails checksum verification", async () => { - const payload = bytes("encrypted snapshot payload"); - const payloadHash = sha256(payload); - const key = snapshotKey(payloadHash); - const tokenHash = await authTokenHash(ACCESS_TOKEN); - const d1 = testD1Database({ - firstRows: [ - { device_id: DEVICE_ID }, - snapshotRow({ r2_key: key, payload_hash: payloadHash, size_bytes: payload.byteLength }), - ], - }); - - const response = await handleRequest( - syncSnapshotGetRequest(), - testEnv({ - d1, - r2Objects: [[key, bytes("corrupt snapshot payload")]], - kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]], - }), + await authorizedEnv(d1), ); assert.equal(response.status, 500); assert.deepEqual(await response.json(), { error: "sync_snapshot_failed" }); }); + + it("fails closed when stored ciphertext fails checksum verification", async () => { + const payload = opaqueEnvelopeBytes(); + const payloadHash = sha256(payload); + const key = snapshotKey(payloadHash); + const row = snapshotRow({ + r2_key: key, + payload_hash: payloadHash, + size_bytes: payload.byteLength, + }); + const d1 = testD1Database({ firstRows: [{ device_id: DEVICE_ID }, row, row] }); + + const response = await handleRequest( + syncSnapshotGetRequest(headRef(row)), + await authorizedEnv(d1, { r2Objects: [[key, bytes("corrupt")]] }), + ); + + assert.equal(response.status, 500); + assert.deepEqual(await response.json(), { error: "sync_snapshot_failed" }); + }); + }); function syncSnapshotPostRequest(body: Record): Request { return new Request("https://elydora.test/api/sync/snapshot", { method: "POST", - headers: { - authorization: `Bearer ${ACCESS_TOKEN}`, - "content-type": "application/json", - }, + headers: { authorization: `Bearer ${ACCESS_TOKEN}`, "content-type": "application/json" }, body: JSON.stringify(body), }); } -function syncSnapshotGetRequest(): Request { - return new Request(`https://elydora.test/api/sync/snapshot?snapshot_id=${SNAPSHOT_ID}`, { +function syncSnapshotGetRequest(head: Record): Request { + const query = new URLSearchParams({ + snapshot_id: String(head.snapshot_id), + head_revision: String(head.revision), + payload_hash: String(head.payload_hash), + }); + return new Request(`https://elydora.test/api/sync/snapshot?${query}`, { headers: { authorization: `Bearer ${ACCESS_TOKEN}` }, }); } function syncSnapshotBody(overrides: Record = {}): Record { - const payload = bytes("encrypted snapshot payload"); - const payloadHash = sha256(payload); + const payload = opaqueEnvelopeBytes(); return { - version: 1, + version: 3, snapshot_id: SNAPSHOT_ID, region: REGION, - payload_hash: payloadHash, + payload_hash: sha256(payload), + encryption_version: 2, + vault_generation: 1, + key_id: KEY_ID, + content_hash: CONTENT_HASH, schema_rev: 1, logical_clock: 42, + head_revision: 1, + base_head: null, data_base64: base64(payload), ...overrides, }; @@ -291,27 +390,91 @@ function snapshotRow(overrides: Record = {}): Record = {}): Record { - return snapshotRow(overrides); +function snapshotDocument(row: Record): Record { + const { + base_head_revision: revision, + base_snapshot_id: snapshotId, + base_payload_hash: payloadHash, + ...document + } = row; + return { + ...document, + base_head: revision === null + ? null + : { revision, snapshot_id: snapshotId, payload_hash: payloadHash }, + }; } -function snapshotKey(_payloadHash: string): string { - return `sync-snapshots/${REGION}/${sha256(bytes(USER_ID))}/${SNAPSHOT_ID}.bin`; +function uploadDocument(row: Record): Record { + return { + version: 3, + user_id: USER_ID, + device_id: DEVICE_ID, + snapshot: snapshotDocument(row), + }; +} + +function conflictDocument(row: Record): Record { + return { + version: 1, + error: "sync_snapshot_head_conflict", + current_head: snapshotDocument(row), + }; +} + +function headRef(overrides: Record = {}): Record { + return { + revision: overrides.head_revision ?? 1, + snapshot_id: overrides.snapshot_id ?? SNAPSHOT_ID, + payload_hash: overrides.payload_hash ?? "a".repeat(64), + }; +} + +function vaultKeyRow(): Record { + return { key_id: KEY_ID, generation: 1 }; +} + +async function authorizedEnv( + d1: ReturnType, + options: Omit[0], "d1" | "kvEntries"> = {}, +): Promise> { + const tokenHash = await authTokenHash(ACCESS_TOKEN); + return testEnv({ + ...options, + d1, + kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]], + }); +} + +function snapshotKey(payloadHash: string): string { + return `sync-snapshots/${REGION}/${sha256(bytes(USER_ID))}/${SNAPSHOT_ID}/${payloadHash}.bin`; } function bytes(value: string): ArrayBuffer { return new TextEncoder().encode(value).buffer; } +function opaqueEnvelopeBytes(): ArrayBuffer { + return new Uint8Array([0x45, 0x4c, 0x59, 0x53, 0x59, 0x4e, 0x43, 0x00, 0xff, 0x80, 0x01]).buffer; +} + function base64(payload: ArrayBuffer): string { return Buffer.from(payload).toString("base64"); } diff --git a/cloudflare/tests/sync_status_routes.test.ts b/cloudflare/tests/sync_status_routes.test.ts index 5ed03aa..c133b37 100644 --- a/cloudflare/tests/sync_status_routes.test.ts +++ b/cloudflare/tests/sync_status_routes.test.ts @@ -12,17 +12,17 @@ describe("sync status routes", () => { it("returns cloud sync cursor, object, snapshot, and device status", async () => { const tokenHash = await authTokenHash(ACCESS_TOKEN); const d1 = testD1Database({ - firstRows: [ - { device_id: DEVICE_ID }, - { latest_change_id: 51, total_changes: 7 }, - { total_snapshots: 2 }, - latestSnapshotRow(), - { approved_devices: 3 }, - ], - allRows: [ - objectStatusRow({ object_type: "bookmarks", active_count: 4, deleted_count: 1 }), - objectStatusRow({ object_type: "tabs", active_count: 9, latest_logical_clock: 44 }), - ], + firstRows: [{ device_id: DEVICE_ID }], + batchRowSets: [[ + [{ latest_change_id: 51, total_changes: 7 }], + [ + objectStatusRow({ object_type: "bookmarks", active_count: 4, deleted_count: 1 }), + objectStatusRow({ object_type: "tabs", active_count: 9, latest_logical_clock: 44 }), + ], + [{ total_snapshots: 2 }], + [snapshotHeadRow()], + [{ approved_devices: 3 }], + ]], }); const response = await handleRequest( @@ -36,7 +36,7 @@ describe("sync status routes", () => { assert.equal(response.status, 200); assert.equal(response.headers.get("cache-control"), "no-store"); assert.deepEqual(await response.json(), { - version: 1, + version: 2, user_id: USER_ID, device_id: DEVICE_ID, cursor: { latest_change_id: 51, total_changes: 7 }, @@ -46,7 +46,7 @@ describe("sync status routes", () => { ], snapshots: { total_snapshots: 2, - latest: latestSnapshotRow(), + head: snapshotHeadStatus(), }, devices: { approved_count: 3, @@ -58,7 +58,7 @@ describe("sync status routes", () => { assert.ok(d1.queries[1]?.includes("FROM sync_change_log")); assert.ok(d1.queries[2]?.includes("FROM sync_objects")); assert.ok(d1.queries[3]?.includes("FROM sync_snapshots")); - assert.ok(d1.queries[4]?.includes("FROM sync_snapshots")); + assert.ok(d1.queries[4]?.includes("FROM sync_snapshot_heads")); assert.ok(d1.queries[5]?.includes("FROM user_devices")); assert.deepEqual(d1.binds, [ [USER_ID, DEVICE_ID], @@ -68,18 +68,21 @@ describe("sync status routes", () => { [USER_ID], [USER_ID], ]); + assert.deepEqual(d1.batches, [5]); + assert.deepEqual(d1.sessionConstraints, ["first-primary"]); }); it("returns empty status when the account has no sync facts", async () => { const tokenHash = await authTokenHash(ACCESS_TOKEN); const d1 = testD1Database({ - firstRows: [ - { device_id: DEVICE_ID }, - { latest_change_id: 0, total_changes: 0 }, - { total_snapshots: 0 }, - null, - { approved_devices: 1 }, - ], + firstRows: [{ device_id: DEVICE_ID }], + batchRowSets: [[ + [{ latest_change_id: 0, total_changes: 0 }], + [], + [{ total_snapshots: 0 }], + [], + [{ approved_devices: 1 }], + ]], }); const response = await handleRequest( @@ -94,18 +97,17 @@ describe("sync status routes", () => { const body = (await response.json()) as { cursor: { latest_change_id: number; total_changes: number }; objects: []; - snapshots: { total_snapshots: number; latest: null }; + snapshots: { total_snapshots: number; head: null }; }; assert.deepEqual(body.cursor, { latest_change_id: 0, total_changes: 0 }); assert.deepEqual(body.objects, []); - assert.deepEqual(body.snapshots, { total_snapshots: 0, latest: null }); + assert.deepEqual(body.snapshots, { total_snapshots: 0, head: null }); }); it("rejects revoked devices before reading sync status", async () => { const tokenHash = await authTokenHash(ACCESS_TOKEN); const d1 = testD1Database({ - firstRows: [null, { latest_change_id: 51, total_changes: 7 }], - allRows: [objectStatusRow()], + firstRows: [null], }); const response = await handleRequest( @@ -138,14 +140,64 @@ describe("sync status routes", () => { it("returns a server error for malformed status rows", async () => { const tokenHash = await authTokenHash(ACCESS_TOKEN); const d1 = testD1Database({ - firstRows: [ - { device_id: DEVICE_ID }, - { latest_change_id: 51, total_changes: 7 }, - { total_snapshots: 1 }, - latestSnapshotRow(), - { approved_devices: 1 }, - ], - allRows: [objectStatusRow({ object_type: "passwords" })], + firstRows: [{ device_id: DEVICE_ID }], + batchRowSets: [[ + [{ latest_change_id: 51, total_changes: 7 }], + [objectStatusRow({ object_type: "passwords" })], + [{ total_snapshots: 1 }], + [snapshotHeadRow()], + [{ approved_devices: 1 }], + ]], + }); + + const response = await handleRequest( + syncStatusRequest(), + testEnv({ + d1, + kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]], + }), + ); + + assert.equal(response.status, 500); + assert.deepEqual(await response.json(), { error: "sync_status_invalid" }); + }); + + it("fails closed when encrypted snapshots exist without a global head", async () => { + const tokenHash = await authTokenHash(ACCESS_TOKEN); + const d1 = testD1Database({ + firstRows: [{ device_id: DEVICE_ID }], + batchRowSets: [[ + [{ latest_change_id: 0, total_changes: 0 }], + [], + [{ total_snapshots: 1 }], + [], + [{ approved_devices: 1 }], + ]], + }); + + const response = await handleRequest( + syncStatusRequest(), + testEnv({ + d1, + kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]], + }), + ); + + assert.equal(response.status, 500); + assert.deepEqual(await response.json(), { error: "sync_status_invalid" }); + }); + + it("fails closed when global head storage metadata is malformed", async () => { + const tokenHash = await authTokenHash(ACCESS_TOKEN); + const d1 = testD1Database({ + firstRows: [{ device_id: DEVICE_ID }], + batchRowSets: [[ + [{ latest_change_id: 0, total_changes: 0 }], + [], + [{ total_snapshots: 1 }], + [snapshotHeadRow({ r2_key: "invalid" })], + [{ approved_devices: 1 }], + ]], }); const response = await handleRequest( @@ -182,14 +234,39 @@ function objectStatusDocument(overrides: Record = {}): Record = {}): Record { +function snapshotHeadRow(overrides: Record = {}): Record { return { snapshot_id: "snapshot-01", + r2_key: `sync-snapshots/us-east/${"d".repeat(64)}/snapshot-01/${"a".repeat(64)}.bin`, payload_hash: "a".repeat(64), + encryption_version: 2, + vault_generation: 1, + key_id: "b".repeat(64), + content_hash: "c".repeat(64), + schema_rev: 1, logical_clock: 42, + head_revision: 1, + base_head_revision: null, + base_snapshot_id: null, + base_payload_hash: null, device_id: DEVICE_ID, size_bytes: 26, created_at: 1_780_000_900, ...overrides, }; } + +function snapshotHeadStatus(overrides: Record = {}): Record { + const { + r2_key: _r2Key, + schema_rev: _schemaRev, + base_head_revision: _baseHeadRevision, + base_snapshot_id: _baseSnapshotId, + base_payload_hash: _basePayloadHash, + ...status + } = snapshotHeadRow(overrides); + return { + ...status, + base_head: null, + }; +} diff --git a/cloudflare/tests/sync_vault_rotation_cleanup_sqlite.test.ts b/cloudflare/tests/sync_vault_rotation_cleanup_sqlite.test.ts new file mode 100644 index 0000000..f554b62 --- /dev/null +++ b/cloudflare/tests/sync_vault_rotation_cleanup_sqlite.test.ts @@ -0,0 +1,233 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, it } from "node:test"; +import { cleanupRotatedVaultStorage } from "../src/sync_vault_rotation_cleanup.js"; +import { testEnv } from "./devices_test_support.js"; +import { SqliteD1Database, execute, query } from "./sqlite_d1_test_support.js"; + +const MIGRATIONS_DIR = join(process.cwd(), "migrations"); +const USER_ID = "user-01", APPROVER_ID = "device-01", TARGET_ID = "device-02"; +const OLD_KEY = "a".repeat(64), NEW_KEY = "b".repeat(64); +const OLD_HASH = "c".repeat(64), NEW_HASH = "d".repeat(64), USER_HASH = "e".repeat(64); +const OLD_PAYLOAD_KEY = `sync-payloads/us/${USER_HASH}/bookmarks/object-01/${OLD_HASH}.bin`; +const OLD_SNAPSHOT_KEY = `sync-snapshots/us/${USER_HASH}/snapshot-01/${OLD_HASH}.bin`; +const NEW_SNAPSHOT_KEY = `sync-snapshots/us/${USER_HASH}/snapshot-02/${NEW_HASH}.bin`; +const CLEANUP_AT = 300; + +describe("sync vault rotation cleanup real D1 flow", () => { + it("cleans staged storage with a higher-clock non-head history row", async () => { + await withDatabase(true, async (databasePath) => { + seedHighClockNonHead(databasePath); + const r2Deletes: string[] = []; + await cleanupRotatedVaultStorage( + testEnv({ d1: new SqliteD1Database(databasePath), r2Deletes }), + USER_ID, + "snapshot-02", + NEW_KEY, + 2, + CLEANUP_AT, + ); + assert.deepEqual(r2Deletes, [OLD_PAYLOAD_KEY, OLD_SNAPSHOT_KEY]); + assert.deepEqual(query(databasePath, ` + SELECT + (SELECT COUNT(*) FROM sync_objects WHERE user_id = '${USER_ID}') AS objects, + (SELECT COUNT(*) FROM sync_snapshots + WHERE user_id = '${USER_ID}' AND snapshot_id = 'snapshot-01') AS old_snapshot, + (SELECT COUNT(*) FROM sync_snapshots + WHERE user_id = '${USER_ID}' AND snapshot_id = 'snapshot-02') AS new_snapshot, + (SELECT COUNT(*) FROM sync_snapshots + WHERE user_id = '${USER_ID}' AND snapshot_id = 'snapshot-high') AS non_head, + (SELECT head_revision FROM sync_snapshot_heads + WHERE user_id = '${USER_ID}') AS head_revision, + (SELECT storage_cleaned_at FROM sync_vault_rotations + WHERE user_id = '${USER_ID}') AS storage_cleaned_at + `), [{ + objects: 0, + old_snapshot: 0, + new_snapshot: 1, + non_head: 1, + head_revision: 2, + storage_cleaned_at: CLEANUP_AT, + }]); + }); + }); + + it("preserves the old head and R2 state for a CAS loser", async () => { + await withDatabase(false, async (databasePath) => { + const r2Deletes: string[] = []; + await cleanupRotatedVaultStorage( + testEnv({ d1: new SqliteD1Database(databasePath), r2Deletes }), + USER_ID, + "snapshot-02", + NEW_KEY, + 2, + CLEANUP_AT, + ); + assert.deepEqual(r2Deletes, []); + assert.deepEqual(query(databasePath, ` + SELECT + (SELECT COUNT(*) FROM sync_objects WHERE user_id = '${USER_ID}') AS objects, + (SELECT COUNT(*) FROM sync_snapshots + WHERE user_id = '${USER_ID}' AND snapshot_id = 'snapshot-01') AS old_snapshot, + (SELECT snapshot_id FROM sync_snapshot_heads + WHERE user_id = '${USER_ID}') AS head_snapshot_id, + (SELECT cleanup_snapshot_id FROM sync_vault_rotations + WHERE user_id = '${USER_ID}') AS cleanup_snapshot_id + `), [{ + objects: 1, + old_snapshot: 1, + head_snapshot_id: "snapshot-01", + cleanup_snapshot_id: null, + }]); + }); + }); +}); + +async function withDatabase( + commitReplacement: boolean, + run: (databasePath: string) => Promise, +): Promise { + const tempDir = mkdtempSync(join(tmpdir(), "ely-rotation-cleanup-")); + try { + const databasePath = join(tempDir, "ely.db"); + const migrations = readdirSync(MIGRATIONS_DIR) + .filter((name) => name.endsWith(".sql")) + .sort() + .map((name) => readFileSync(join(MIGRATIONS_DIR, name), "utf8")) + .join("\n"); + execute(databasePath, migrations); + execute(databasePath, seedSql(commitReplacement)); + await run(databasePath); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +} + +function seedSql(commitReplacement: boolean): string { + return ` + INSERT INTO better_auth_user + (id, name, email, emailVerified, createdAt, updatedAt) + VALUES ('${USER_ID}', 'User', 'user@example.com', 1, '2026-01-01', '2026-01-01'); + INSERT INTO user_devices + (user_id, device_id, public_key, device_name, platform, approval_status, + created_at, approved_at, last_active_at, revoked_at, idempotency_key) + VALUES + ('${USER_ID}', '${APPROVER_ID}', '${"1".repeat(64)}', 'Approver', 'macOS', + 'approved', 10, 11, 12, NULL, 'device-register-0001'), + ('${USER_ID}', '${TARGET_ID}', '${"2".repeat(64)}', 'Target', 'macOS', + 'approved', 10, 11, 12, NULL, 'device-register-0002'), + ('${USER_ID}', 'device-03', '${"3".repeat(64)}', 'Remaining', 'macOS', + 'approved', 10, 11, 12, NULL, 'device-register-0003'); + INSERT INTO user_device_keys + (user_id, device_id, signing_public_key, wrapping_public_key, + key_protocol_version, created_at) + VALUES + ('${USER_ID}', '${APPROVER_ID}', '${"1".repeat(64)}', '${"4".repeat(64)}', 2, 10), + ('${USER_ID}', '${TARGET_ID}', '${"2".repeat(64)}', '${"5".repeat(64)}', 2, 10), + ('${USER_ID}', 'device-03', '${"3".repeat(64)}', '${"6".repeat(64)}', 2, 10); + INSERT INTO sync_vault_accounts + (user_id, current_key_id, current_generation, created_at, updated_at) + VALUES ('${USER_ID}', '${OLD_KEY}', 1, 20, 20); + ${ledgerSql(OLD_PAYLOAD_KEY, "payload", "1".repeat(64), 30)} + INSERT INTO sync_objects + (user_id, object_id, object_type, payload_inline, payload_r2_key, payload_hash, + schema_rev, logical_clock, device_id, created_at, updated_at, deleted_at) + VALUES ('${USER_ID}', 'object-01', 'bookmarks', NULL, '${OLD_PAYLOAD_KEY}', '${OLD_HASH}', + 1, 1, '${APPROVER_ID}', 30, 30, NULL); + UPDATE sync_r2_gc_candidates + SET state = 'referenced', referenced_at = 30, updated_at = 30 + WHERE r2_key = '${OLD_PAYLOAD_KEY}'; + ${ledgerSql(OLD_SNAPSHOT_KEY, "snapshot", "2".repeat(64), 40)} + INSERT INTO sync_snapshots + (user_id, snapshot_id, r2_key, payload_hash, schema_rev, logical_clock, + device_id, size_bytes, created_at, head_revision, + base_head_revision, base_snapshot_id, base_payload_hash) + VALUES ('${USER_ID}', 'snapshot-01', '${OLD_SNAPSHOT_KEY}', '${OLD_HASH}', 1, 1, + '${APPROVER_ID}', 64, 40, 1, NULL, NULL, NULL); + INSERT INTO sync_snapshot_encryption + (user_id, snapshot_id, encryption_version, vault_generation, key_id, content_hash) + VALUES ('${USER_ID}', 'snapshot-01', 2, 1, '${OLD_KEY}', '${OLD_HASH}'); + INSERT INTO sync_snapshot_heads + (user_id, head_revision, snapshot_id, payload_hash, updated_at) + VALUES ('${USER_ID}', 1, 'snapshot-01', '${OLD_HASH}', 40); + UPDATE sync_r2_gc_candidates + SET state = 'referenced', referenced_at = 40, updated_at = 40 + WHERE r2_key = '${OLD_SNAPSHOT_KEY}'; + ${rotationSql()} + ${ledgerSql(NEW_SNAPSHOT_KEY, "snapshot", "3".repeat(64), 250)} + INSERT INTO sync_snapshots + (user_id, snapshot_id, r2_key, payload_hash, schema_rev, logical_clock, + device_id, size_bytes, created_at, head_revision, + base_head_revision, base_snapshot_id, base_payload_hash) + VALUES ('${USER_ID}', 'snapshot-02', '${NEW_SNAPSHOT_KEY}', '${NEW_HASH}', 1, 2, + '${APPROVER_ID}', 64, 250, 2, 1, 'snapshot-01', '${OLD_HASH}'); + INSERT INTO sync_snapshot_encryption + (user_id, snapshot_id, encryption_version, vault_generation, key_id, content_hash) + VALUES ('${USER_ID}', 'snapshot-02', 2, 2, '${NEW_KEY}', '${NEW_HASH}'); + ${commitReplacement ? ` + UPDATE sync_snapshot_heads + SET head_revision = 2, snapshot_id = 'snapshot-02', + payload_hash = '${NEW_HASH}', updated_at = 250 + WHERE user_id = '${USER_ID}'; + UPDATE sync_r2_gc_candidates + SET state = 'referenced', lease_expires_at = 250, + referenced_at = 250, updated_at = 250 + WHERE r2_key = '${NEW_SNAPSHOT_KEY}'; + ` : ""} + `; +} + +function ledgerSql(r2Key: string, kind: "payload" | "snapshot", token: string, now: number): string { + return ` + INSERT INTO sync_r2_gc_candidates ( + r2_key, user_id, owner_hash, object_kind, state, write_token, + lease_expires_at, gc_token, created_at, updated_at, referenced_at, + ready_at, delete_started_at, deleted_at + ) VALUES ( + '${r2Key}', '${USER_ID}', '${USER_HASH}', '${kind}', 'pending', '${token}', + 1000, NULL, ${now}, ${now}, NULL, NULL, NULL, NULL + ); + `; +} + +function seedHighClockNonHead(databasePath: string): void { + const hash = "f".repeat(64); + const r2Key = `sync-snapshots/us/${USER_HASH}/snapshot-high/${hash}.bin`; + execute(databasePath, ` + ${ledgerSql(r2Key, "snapshot", "4".repeat(64), 260)} + INSERT INTO sync_snapshots ( + user_id, snapshot_id, r2_key, payload_hash, schema_rev, logical_clock, + device_id, size_bytes, created_at, head_revision, + base_head_revision, base_snapshot_id, base_payload_hash + ) VALUES ( + '${USER_ID}', 'snapshot-high', '${r2Key}', '${hash}', 1, + ${Number.MAX_SAFE_INTEGER}, '${APPROVER_ID}', 64, 260, 0, NULL, NULL, NULL + ); + INSERT INTO sync_snapshot_encryption ( + user_id, snapshot_id, encryption_version, vault_generation, key_id, content_hash + ) VALUES ('${USER_ID}', 'snapshot-high', 2, 2, '${NEW_KEY}', '${hash}'); + `); +} + +function rotationSql(): string { + return ` + INSERT INTO sync_vault_rotations + (user_id, idempotency_key, audit_event_id, target_device_id, approver_device_id, + previous_key_id, previous_generation, new_key_id, new_generation, request_hash, + envelope_count, r2_object_count, created_at, completed_at) + VALUES ('${USER_ID}', 'rotation-key-0001', 'rotation-audit-0001', '${TARGET_ID}', + '${APPROVER_ID}', '${OLD_KEY}', 1, '${NEW_KEY}', 2, '${"7".repeat(64)}', 2, 2, 100, NULL); + INSERT INTO sync_vault_rotation_envelopes + (user_id, rotation_idempotency_key, recipient_device_id, envelope_idempotency_key, + envelope_version, suite, encapped_key, ciphertext) + VALUES + ('${USER_ID}', 'rotation-key-0001', '${APPROVER_ID}', '${"8".repeat(64)}', 1, + 'HPKE-BASE-X25519-HKDF-SHA256-CHACHA20POLY1305', '${"A".repeat(43)}', '${"B".repeat(64)}'), + ('${USER_ID}', 'rotation-key-0001', 'device-03', '${"9".repeat(64)}', 1, + 'HPKE-BASE-X25519-HKDF-SHA256-CHACHA20POLY1305', '${"C".repeat(42)}E', '${"D".repeat(64)}'); + UPDATE sync_vault_rotations SET completed_at = 200 + WHERE user_id = '${USER_ID}' AND idempotency_key = 'rotation-key-0001'; + `; +} diff --git a/cloudflare/tests/sync_vault_routes.test.ts b/cloudflare/tests/sync_vault_routes.test.ts new file mode 100644 index 0000000..5b4b601 --- /dev/null +++ b/cloudflare/tests/sync_vault_routes.test.ts @@ -0,0 +1,490 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { authSessionCacheKvKey, authTokenHash } from "../src/auth.js"; +import { handleRequest } from "../src/index.js"; +import { + SyncVaultConflictError, + SyncVaultNotFoundError, + assertCurrentSyncVaultKey, + parseWrappedAccountKey, + syncVaultRecipientEnvelopeStatement, +} from "../src/sync_vault.js"; +import { syncVaultBootstrapProofBytes } from "../src/sync_vault_bootstrap_proof.js"; +import { + ACCESS_TOKEN, + PUBLIC_KEY, + sessionDocument, + signDeviceMessage, + testD1Database, + testEnv, +} from "./devices_test_support.js"; + +const USER_ID = "user-01"; +const DEVICE_ID = "device-01"; +const KEY_ID = "a".repeat(64); +const GENERATION = 1; +const HISTORICAL_KEY_ID = "c".repeat(64); +const HISTORICAL_GENERATION = 3; +const IDEMPOTENCY_KEY = "sync-vault-bootstrap-0001"; +const SUITE = "HPKE-BASE-X25519-HKDF-SHA256-CHACHA20POLY1305"; +const ENCAPPED_KEY = "A".repeat(43); +const CIPHERTEXT = "B".repeat(64); + +describe("sync vault routes", () => { + it("bootstraps the current approved device envelope", async () => { + const tokenHash = await authTokenHash(ACCESS_TOKEN); + const d1 = testD1Database({ + firstRows: [approvedDeviceRow(), signingKeyRow(), currentEnvelopeRow()], + }); + + const response = await handleRequest( + vaultBootstrapRequest(await vaultBootstrapBody()), + testEnv({ + d1, + kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]], + }), + ); + + assert.equal(response.status, 201); + assert.equal(response.headers.get("cache-control"), "no-store"); + assert.deepEqual(await response.json(), vaultDocument()); + assert.equal(d1.batches[0], 2); + assert.ok(d1.queries[1]?.includes("keys.signing_public_key")); + assert.ok(d1.queries[1]?.includes("keys.key_protocol_version = 2")); + assert.ok(d1.queries[2]?.includes("INSERT INTO sync_vault_accounts")); + assert.ok(d1.queries[3]?.includes("INSERT INTO sync_vault_envelopes")); + assert.ok(d1.queries[4]?.includes("FROM sync_vault_accounts AS accounts")); + assert.deepEqual(d1.binds[4], [USER_ID, DEVICE_ID]); + }); + + it("returns the current device envelope", async () => { + const tokenHash = await authTokenHash(ACCESS_TOKEN); + const d1 = testD1Database({ + firstRows: [{ device_id: DEVICE_ID }, currentEnvelopeRow()], + }); + + const response = await handleRequest( + new Request("https://elydora.test/api/sync/vault", { + headers: { authorization: `Bearer ${ACCESS_TOKEN}` }, + }), + testEnv({ + d1, + kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]], + }), + ); + + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), vaultDocument()); + assert.equal(d1.batches.length, 0); + assert.deepEqual(d1.binds[1], [USER_ID, DEVICE_ID]); + }); + + it("returns an exact historical envelope for the authenticated device", async () => { + const tokenHash = await authTokenHash(ACCESS_TOKEN); + const d1 = testD1Database({ + firstRows: [ + approvedDeviceRow(), + currentEnvelopeRow({ key_id: HISTORICAL_KEY_ID, generation: HISTORICAL_GENERATION }), + ], + }); + + const response = await handleRequest( + vaultGetRequest(`?generation=${HISTORICAL_GENERATION}&key_id=${HISTORICAL_KEY_ID}`), + testEnv({ + d1, + kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]], + }), + ); + + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), vaultDocument({ + key_id: HISTORICAL_KEY_ID, + generation: HISTORICAL_GENERATION, + })); + assert.ok(d1.queries[1]?.includes("FROM sync_vault_envelopes")); + assert.ok(d1.queries[1]?.includes("recipient_device_id = ?")); + assert.deepEqual(d1.binds[1], [ + USER_ID, + DEVICE_ID, + HISTORICAL_KEY_ID, + HISTORICAL_GENERATION, + ]); + }); + + it("isolates historical envelopes by recipient and exact generation", async () => { + const tokenHash = await authTokenHash(ACCESS_TOKEN); + for (const generation of [HISTORICAL_GENERATION, HISTORICAL_GENERATION + 1]) { + const d1 = testD1Database({ firstRows: [approvedDeviceRow(), null] }); + const response = await handleRequest( + vaultGetRequest(`?key_id=${HISTORICAL_KEY_ID}&generation=${generation}`), + testEnv({ + d1, + kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]], + }), + ); + + assert.equal(response.status, 404); + assert.deepEqual(d1.binds[1], [USER_ID, DEVICE_ID, HISTORICAL_KEY_ID, generation]); + } + }); + + it("rejects partial, duplicate, and extra historical queries", async () => { + const tokenHash = await authTokenHash(ACCESS_TOKEN); + const queries = [ + `?generation=${HISTORICAL_GENERATION}`, + `?key_id=${HISTORICAL_KEY_ID}`, + `?key_id=${HISTORICAL_KEY_ID}&generation=3&generation=3`, + `?key_id=${HISTORICAL_KEY_ID}&generation=3&extra=1`, + ]; + for (const query of queries) { + const d1 = testD1Database({ firstRows: [approvedDeviceRow()] }); + const response = await handleRequest( + vaultGetRequest(query), + testEnv({ + d1, + kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]], + }), + ); + + assert.equal(response.status, 400); + assert.equal(d1.queries.length, 1); + } + }); + + it("rejects malformed opaque envelopes before vault writes", async () => { + const tokenHash = await authTokenHash(ACCESS_TOKEN); + const d1 = testD1Database({ firstRows: [{ device_id: DEVICE_ID }] }); + + const response = await handleRequest( + vaultBootstrapRequest( + await vaultBootstrapBody({ envelope: { ...wrappedEnvelope(), ciphertext: "B".repeat(63) } }), + ), + testEnv({ + d1, + kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]], + }), + ); + + assert.equal(response.status, 400); + assert.deepEqual(await response.json(), { error: "invalid_sync_vault" }); + assert.equal(d1.queries.length, 1); + assert.deepEqual(d1.batches, []); + }); + + it("rejects noncanonical encapped keys before vault writes", async () => { + const tokenHash = await authTokenHash(ACCESS_TOKEN); + const d1 = testD1Database({ firstRows: [{ device_id: DEVICE_ID }] }); + + const response = await handleRequest( + vaultBootstrapRequest( + await vaultBootstrapBody({ envelope: { ...wrappedEnvelope(), encapped_key: `${"A".repeat(42)}B` } }), + ), + testEnv({ + d1, + kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]], + }), + ); + + assert.equal(response.status, 400); + assert.deepEqual(await response.json(), { error: "invalid_sync_vault" }); + assert.equal(d1.queries.length, 1); + assert.deepEqual(d1.batches, []); + }); + + it("rejects unknown envelope fields before vault writes", async () => { + const tokenHash = await authTokenHash(ACCESS_TOKEN); + const d1 = testD1Database({ firstRows: [{ device_id: DEVICE_ID }] }); + + const response = await handleRequest( + vaultBootstrapRequest( + await vaultBootstrapBody({ envelope: { ...wrappedEnvelope(), plaintext_key: KEY_ID } }), + ), + testEnv({ + d1, + kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]], + }), + ); + + assert.equal(response.status, 400); + assert.equal(d1.queries.length, 1); + assert.deepEqual(d1.batches, []); + }); + + it("rejects noninitial bootstrap generations before vault writes", async () => { + const tokenHash = await authTokenHash(ACCESS_TOKEN); + const d1 = testD1Database({ firstRows: [{ device_id: DEVICE_ID }] }); + + const response = await handleRequest( + vaultBootstrapRequest(await vaultBootstrapBody({ generation: 2 })), + testEnv({ + d1, + kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]], + }), + ); + + assert.equal(response.status, 400); + assert.deepEqual(await response.json(), { error: "invalid_sync_vault" }); + assert.equal(d1.queries.length, 1); + assert.deepEqual(d1.batches, []); + }); + + it("rejects a bootstrap replay with different stored ciphertext", async () => { + const tokenHash = await authTokenHash(ACCESS_TOKEN); + const d1 = testD1Database({ + firstRows: [ + approvedDeviceRow(), + signingKeyRow(), + currentEnvelopeRow({ ciphertext: "C".repeat(64) }), + ], + }); + + const response = await handleRequest( + vaultBootstrapRequest(await vaultBootstrapBody()), + testEnv({ + d1, + kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]], + }), + ); + + assert.equal(response.status, 409); + assert.deepEqual(await response.json(), { error: "sync_vault_conflict" }); + }); + + it("rejects tampered bootstrap fields before vault writes", async () => { + const tokenHash = await authTokenHash(ACCESS_TOKEN); + const body = await vaultBootstrapBody(); + body.key_id = "b".repeat(64); + const d1 = testD1Database({ firstRows: [approvedDeviceRow(), signingKeyRow()] }); + + const response = await handleRequest( + vaultBootstrapRequest(body), + testEnv({ + d1, + kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]], + }), + ); + + assert.equal(response.status, 403); + assert.deepEqual(await response.json(), { error: "sync_vault_forbidden" }); + assert.equal(d1.queries.length, 2); + assert.deepEqual(d1.batches, []); + }); + + it("rejects bootstrap v1 before signing-key reads", async () => { + const tokenHash = await authTokenHash(ACCESS_TOKEN); + const d1 = testD1Database({ firstRows: [approvedDeviceRow()] }); + + const response = await handleRequest( + vaultBootstrapRequest(await vaultBootstrapBody({ version: 1 })), + testEnv({ + d1, + kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]], + }), + ); + + assert.equal(response.status, 400); + assert.deepEqual(await response.json(), { error: "invalid_sync_vault" }); + assert.equal(d1.queries.length, 1); + assert.deepEqual(d1.batches, []); + }); + + it("rejects missing approved v2 signing keys before vault writes", async () => { + const tokenHash = await authTokenHash(ACCESS_TOKEN); + const d1 = testD1Database({ firstRows: [approvedDeviceRow(), null] }); + + const response = await handleRequest( + vaultBootstrapRequest(await vaultBootstrapBody()), + testEnv({ + d1, + kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]], + }), + ); + + assert.equal(response.status, 403); + assert.equal(d1.queries.length, 2); + assert.deepEqual(d1.batches, []); + }); + + it("uses the frozen v2 bootstrap proof wire", () => { + assert.equal( + new TextDecoder().decode(syncVaultBootstrapProofBytes( + USER_ID, + DEVICE_ID, + bootstrapProofInput(), + )), + "31:elydora-sync-vault-bootstrap-v2" + + "7:user-01" + + "9:device-01" + + `64:${KEY_ID}` + + "1:1" + + "1:1" + + `45:${SUITE}` + + `43:${ENCAPPED_KEY}` + + `64:${CIPHERTEXT}` + + "25:sync-vault-bootstrap-0001", + ); + }); + + it("returns not found when the current device has no envelope", async () => { + const tokenHash = await authTokenHash(ACCESS_TOKEN); + const d1 = testD1Database({ firstRows: [{ device_id: DEVICE_ID }, null] }); + + const response = await handleRequest( + new Request("https://elydora.test/api/sync/vault", { + headers: { authorization: `Bearer ${ACCESS_TOKEN}` }, + }), + testEnv({ + d1, + kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]], + }), + ); + + assert.equal(response.status, 404); + assert.deepEqual(await response.json(), { error: "sync_vault_not_found" }); + }); + + it("validates snapshot key metadata against the current vault key", async () => { + const matching = testD1Database({ firstRows: [{ key_id: KEY_ID, generation: GENERATION }] }); + await assertCurrentSyncVaultKey(testEnv({ d1: matching }), USER_ID, KEY_ID, GENERATION); + + const mismatched = testD1Database({ firstRows: [{ key_id: KEY_ID, generation: GENERATION }] }); + await assert.rejects( + assertCurrentSyncVaultKey(testEnv({ d1: mismatched }), USER_ID, "b".repeat(64), GENERATION), + SyncVaultConflictError, + ); + + const missing = testD1Database({ firstRows: [null] }); + await assert.rejects( + assertCurrentSyncVaultKey(testEnv({ d1: missing }), USER_ID, KEY_ID, GENERATION), + SyncVaultNotFoundError, + ); + }); + + it("builds a recipient envelope write guarded by device trust and the current vault key", () => { + const d1 = testD1Database([]); + syncVaultRecipientEnvelopeStatement( + testEnv({ d1 }), + USER_ID, + "device-02", + DEVICE_ID, + KEY_ID, + GENERATION, + parseWrappedAccountKey(wrappedEnvelope()), + "sync-vault-recipient-0001", + 1_780_000_400, + ); + + assert.ok(d1.queries[0]?.includes("accounts.current_key_id = ?")); + assert.ok(d1.queries[0]?.includes("recipient.approval_status = ?")); + assert.ok(d1.queries[0]?.includes("approver.approval_status = 'approved'")); + assert.deepEqual(d1.binds[0], [ + USER_ID, + "device-02", + DEVICE_ID, + KEY_ID, + GENERATION, + 1, + SUITE, + ENCAPPED_KEY, + CIPHERTEXT, + "sync-vault-recipient-0001", + 1_780_000_400, + "device-02", + "pending", + DEVICE_ID, + USER_ID, + KEY_ID, + GENERATION, + ]); + }); +}); + +function vaultBootstrapRequest(body: Record): Request { + return new Request("https://elydora.test/api/sync/vault/bootstrap", { + method: "POST", + headers: { + authorization: `Bearer ${ACCESS_TOKEN}`, + "content-type": "application/json", + }, + body: JSON.stringify(body), + }); +} + +function vaultGetRequest(query = ""): Request { + return new Request(`https://elydora.test/api/sync/vault${query}`, { + headers: { authorization: `Bearer ${ACCESS_TOKEN}` }, + }); +} + +async function vaultBootstrapBody( + overrides: Record = {}, +): Promise> { + return { + version: 2, + key_id: KEY_ID, + generation: GENERATION, + envelope: wrappedEnvelope(), + idempotency_key: IDEMPOTENCY_KEY, + bootstrap_proof: await signDeviceMessage( + syncVaultBootstrapProofBytes(USER_ID, DEVICE_ID, bootstrapProofInput()), + ), + ...overrides, + }; +} + +function bootstrapProofInput() { + return { + keyId: KEY_ID, + generation: GENERATION, + envelope: parseWrappedAccountKey(wrappedEnvelope()), + idempotencyKey: IDEMPOTENCY_KEY, + }; +} + +function approvedDeviceRow(): Record { + return { device_id: DEVICE_ID }; +} + +function signingKeyRow(): Record { + return { signing_public_key: PUBLIC_KEY }; +} + +function wrappedEnvelope(): Record { + return { + version: 1, + suite: SUITE, + encapped_key: ENCAPPED_KEY, + ciphertext: CIPHERTEXT, + }; +} + +function currentEnvelopeRow(overrides: Record = {}): Record { + return { + key_id: KEY_ID, + generation: GENERATION, + recipient_device_id: DEVICE_ID, + approver_device_id: DEVICE_ID, + envelope_version: 1, + suite: SUITE, + encapped_key: ENCAPPED_KEY, + ciphertext: CIPHERTEXT, + idempotency_key: IDEMPOTENCY_KEY, + created_at: 1_780_000_300, + ...overrides, + }; +} + +function vaultDocument(overrides: Record = {}): Record { + return { + version: 1, + user_id: USER_ID, + key_id: KEY_ID, + generation: GENERATION, + recipient_device_id: DEVICE_ID, + approver_device_id: DEVICE_ID, + envelope: wrappedEnvelope(), + created_at: 1_780_000_300, + ...overrides, + }; +} diff --git a/cloudflare/wrangler.toml b/cloudflare/wrangler.toml index 4080180..3baa098 100644 --- a/cloudflare/wrangler.toml +++ b/cloudflare/wrangler.toml @@ -3,6 +3,9 @@ main = "src/index.ts" compatibility_date = "2026-05-08" compatibility_flags = ["nodejs_compat"] +[triggers] +crons = ["17 * * * *"] + [[d1_databases]] binding = "ELY_DB" database_name = "elydora-browser-db" diff --git a/crates/ely_app/src/shell/auth.rs b/crates/ely_app/src/shell/auth.rs index 3f39a9c..2786fb7 100644 --- a/crates/ely_app/src/shell/auth.rs +++ b/crates/ely_app/src/shell/auth.rs @@ -34,17 +34,17 @@ pub(crate) enum AuthFlowPhase { Idle, /// `send_email_otp` is in flight. UI disables the form so the /// user can't resend before the worker confirms acceptance. - SendingCode { email: String }, + SendingCode { profile_id: ProfileId, email: String }, /// The worker accepted the request and Cloudflare's `SEND_EMAIL` /// binding handed the message to the recipient's MTA. UI now /// reveals the OTP field. - AwaitingOtp { email: String }, + AwaitingOtp { profile_id: ProfileId, email: String }, /// `verify_email_otp` is in flight. UI shows a transient /// "Verifying…" state. - Verifying { email: String }, + Verifying { profile_id: ProfileId, email: String }, /// Last attempt failed. UI surfaces the message inline so the /// user knows what to retry. - Error { email: String, message: String }, + Error { profile_id: ProfileId, email: String, message: String }, } impl AuthFlowPhase { @@ -58,6 +58,16 @@ impl AuthFlowPhase { pub(crate) fn is_busy(&self) -> bool { matches!(self, Self::SendingCode { .. } | Self::Verifying { .. }) } + + pub(crate) fn belongs_to(&self, profile_id: &ProfileId) -> bool { + match self { + Self::Idle => true, + Self::SendingCode { profile_id: owner, .. } + | Self::AwaitingOtp { profile_id: owner, .. } + | Self::Verifying { profile_id: owner, .. } + | Self::Error { profile_id: owner, .. } => owner == profile_id, + } + } } impl ElyShell { @@ -66,54 +76,66 @@ impl ElyShell { /// through the shared `SyncStateUpdate` channel, which the next /// shell tick reconciles into the `auth_flow_phase`. pub(crate) fn submit_email_otp_request(&mut self, cx: &mut Context) { - if active_profile_sync_context_for(&self.state).is_none() { + let Some(active_profile) = active_profile_sync_context_for(&self.state) else { return; - } + }; let email = self.read_auth_email_input(cx); let Some(email) = normalize_email(&email) else { self.auth_flow_phase = AuthFlowPhase::Error { + profile_id: active_profile.id, email: String::new(), message: "Enter a valid email to receive a code.".to_string(), }; return; }; - self.auth_flow_phase = AuthFlowPhase::SendingCode { email: email.clone() }; + let profile_id = active_profile.id; + self.auth_flow_phase = + AuthFlowPhase::SendingCode { profile_id: profile_id.clone(), email: email.clone() }; let tx = self.sync_inbox_tx.clone(); - spawn_send_otp(email, tx); + spawn_send_otp(profile_id, email, tx); } /// Hand the typed OTP to the worker thread that calls /// `verify_email_otp`, persists the bearer token, and triggers /// the first snapshot upload on success. pub(crate) fn submit_email_otp_verify(&mut self, cx: &mut Context) { - let email = match self.auth_flow_phase.clone() { - AuthFlowPhase::AwaitingOtp { email } - | AuthFlowPhase::Error { email, .. } - | AuthFlowPhase::Verifying { email } => email, + let (profile_id, email) = match self.auth_flow_phase.clone() { + AuthFlowPhase::AwaitingOtp { profile_id, email } + | AuthFlowPhase::Error { profile_id, email, .. } + | AuthFlowPhase::Verifying { profile_id, email } => (profile_id, email), _ => return, }; let otp = self.read_auth_otp_input(cx); let normalized_otp = otp.trim().replace(['-', ' '], ""); if normalized_otp.is_empty() { - self.auth_flow_phase = - AuthFlowPhase::Error { email, message: "Enter the code you received.".to_string() }; + self.auth_flow_phase = AuthFlowPhase::Error { + profile_id, + email, + message: "Enter the code you received.".to_string(), + }; return; } let active_profile = match active_profile_sync_context_for(&self.state) { Some(profile) => profile, None => return, }; + if active_profile.id != profile_id { + self.auth_flow_phase = AuthFlowPhase::Idle; + return; + } let Some(profile_root) = default_profile_data_root() else { self.auth_flow_phase = AuthFlowPhase::Error { + profile_id, email, message: "Profile data root is unavailable on this machine.".to_string(), }; return; }; - let profile_dir = sync_profile_data_dir(&profile_root, &active_profile.id); - self.auth_flow_phase = AuthFlowPhase::Verifying { email: email.clone() }; + let profile_dir = sync_profile_data_dir(&profile_root, &profile_id); + self.auth_flow_phase = + AuthFlowPhase::Verifying { profile_id: profile_id.clone(), email: email.clone() }; let tx = self.sync_inbox_tx.clone(); - spawn_verify_otp(email, normalized_otp, profile_dir, tx); + spawn_verify_otp(profile_id, email, normalized_otp, profile_dir, tx); } /// Drop the persisted bearer token and reset the local form. @@ -121,6 +143,9 @@ impl ElyShell { /// call to make, the token is the only artefact we own. pub(crate) fn submit_sign_out(&mut self, _cx: &mut Context) { self.auth_flow_phase = AuthFlowPhase::Idle; + self.sync_devices.reset(); + self.sync_retry_at = None; + self.clear_pending_cloud_sync_upload(); let active_profile_id = match active_profile_id_for(&self.state) { Some(profile_id) => profile_id, None => return, @@ -180,18 +205,21 @@ pub(super) fn clear_persisted_bearer(profile_dir: &Path) -> Result<(), SyncClien BearerTokenStore::new(profile_dir.join("sync").join("bearer.token")).clear() } -fn spawn_send_otp(email: String, tx: Sender) { +fn spawn_send_otp(profile_id: ProfileId, email: String, tx: Sender) { std::thread::Builder::new() .name("ely-sync-auth-send".to_string()) .spawn(move || { let config = ApiClientConfig::production(); match send_email_otp(&config, &email) { Ok(()) => { - let _ = tx.send(SyncStateUpdate::AuthOtpSent { email }); + let _ = tx.send(SyncStateUpdate::AuthOtpSent { profile_id, email }); } Err(error) => { - let _ = - tx.send(SyncStateUpdate::AuthError { email, message: error.to_string() }); + let _ = tx.send(SyncStateUpdate::AuthError { + profile_id, + email, + message: error.to_string(), + }); } } }) @@ -202,6 +230,7 @@ fn spawn_send_otp(email: String, tx: Sender) { } fn spawn_verify_otp( + profile_id: ProfileId, email: String, otp: String, profile_dir: std::path::PathBuf, @@ -215,6 +244,7 @@ fn spawn_verify_otp( Ok(token) => token, Err(error) => { let _ = tx.send(SyncStateUpdate::AuthError { + profile_id, email, message: error.to_string(), }); @@ -226,6 +256,7 @@ fn spawn_verify_otp( Ok(engine) => engine, Err(error) => { let _ = tx.send(SyncStateUpdate::AuthError { + profile_id, email, message: error.to_string(), }); @@ -233,10 +264,14 @@ fn spawn_verify_otp( } }; if let Err(error) = engine.install_bearer(token.as_str()) { - let _ = tx.send(SyncStateUpdate::AuthError { email, message: error.to_string() }); + let _ = tx.send(SyncStateUpdate::AuthError { + profile_id, + email, + message: error.to_string(), + }); return; } - let _ = tx.send(SyncStateUpdate::AuthSucceeded { email }); + let _ = tx.send(SyncStateUpdate::AuthSucceeded { profile_id, email }); }) .map(|_| ()) .unwrap_or_else(|error| { @@ -247,6 +282,7 @@ fn spawn_verify_otp( #[cfg(test)] mod tests { use ely_browser_core::{BrowserCore, InitialBrowserConfig}; + use ely_domain::ProfileId; use super::{AuthFlowPhase, active_profile_sync_context_for, normalize_email}; use crate::shell::ShellState; @@ -266,11 +302,18 @@ mod tests { #[test] fn auth_phase_helpers() { - let phase = AuthFlowPhase::Verifying { email: "you@there".to_string() }; + let profile_id = ProfileId::new(); + let phase = AuthFlowPhase::Verifying { + profile_id: profile_id.clone(), + email: "you@there".to_string(), + }; assert!(phase.is_busy()); + assert!(phase.belongs_to(&profile_id)); + assert!(!phase.belongs_to(&ProfileId::new())); assert_eq!(phase.error_message(), None); let phase = AuthFlowPhase::Error { + profile_id, email: "you@there".to_string(), message: "rate limited".to_string(), }; diff --git a/crates/ely_app/src/shell/internal_pages/sync.rs b/crates/ely_app/src/shell/internal_pages/sync.rs index 0d8c507..ff7f3a1 100644 --- a/crates/ely_app/src/shell/internal_pages/sync.rs +++ b/crates/ely_app/src/shell/internal_pages/sync.rs @@ -1,8 +1,10 @@ use ely_browser_core::BrowserSnapshot; use ely_design_system::colors; -use ely_domain::{ProfileKind, SyncConnectionState, SyncObjectKind, SyncObjectStatus}; +use ely_domain::{ProfileId, ProfileKind, SyncConnectionState, SyncObjectKind, SyncObjectStatus}; +use ely_sync_client::DeviceRecord; use gpui::{ - AnyElement, Context, FontWeight, IntoElement, ParentElement, Styled, div, px, rgb, rgba, + AnyElement, Context, FontWeight, InteractiveElement, IntoElement, ParentElement, SharedString, + StatefulInteractiveElement, Styled, div, prelude::FluentBuilder, px, rgb, rgba, }; use gpui_component::{input::Input, scroll::ScrollableElement}; @@ -10,7 +12,7 @@ use crate::shell::auth::AuthFlowPhase; use super::sync_controls::{ button_bg, render_dual_button_row, render_policy_toggle, render_primary_button, - render_reset_button, render_sign_out_button, + render_reset_button, render_secondary_button, render_sign_out_button, }; use super::{ElyShell, render_canvas_surface}; @@ -20,6 +22,11 @@ impl ElyShell { snapshot: &BrowserSnapshot, cx: &mut Context, ) -> AnyElement { + if profile_allows_sync_controls(&snapshot.active_profile_kind) + && !matches!(snapshot.sync_status.connection(), SyncConnectionState::SignedOut) + { + self.ensure_sync_devices_loaded(cx); + } render_canvas_surface( div() .size_full() @@ -83,7 +90,7 @@ fn render_private_profile_card() -> AnyElement { } fn render_account_card( - shell: &ElyShell, + shell: &mut ElyShell, snapshot: &BrowserSnapshot, cx: &mut Context, ) -> AnyElement { @@ -93,21 +100,229 @@ fn render_account_card( match snapshot.sync_status.connection() { SyncConnectionState::SignedOut => card .child(render_card_heading("Account")) - .children(account_form(shell, cx)) + .children(account_form(shell, &snapshot.active_profile_id, cx)) .into_any_element(), SyncConnectionState::SignedIn | SyncConnectionState::AwaitingDeviceApproval | SyncConnectionState::SyncReady { .. } | SyncConnectionState::SyncError { .. } => card - .child(render_card_heading("Account")) - .child(render_sign_out_button(shell, cx)) + .child( + div() + .flex() + .items_center() + .justify_between() + .child(render_card_heading("Account")) + .child(render_sign_out_button(shell, cx)), + ) + .child( + div() + .text_size(px(12.0)) + .text_color(rgb(colors::ink_3())) + .child("End-to-end encrypted"), + ) + .child(render_devices(shell, cx)) .into_any_element(), } } -fn account_form(shell: &ElyShell, cx: &mut Context) -> Vec { +fn render_devices(shell: &mut ElyShell, cx: &mut Context) -> AnyElement { + let loading = shell.sync_devices.is_loading(); + let header = + div().flex().items_center().justify_between().child(render_card_heading("Devices")).child( + render_secondary_button( + shell, + "sync-devices-refresh", + "Refresh", + loading, + cx, + |shell, cx| shell.refresh_sync_devices(cx), + ), + ); + let mut section = div() + .pt(px(12.0)) + .border_t_1() + .border_color(rgba(colors::divider())) + .flex() + .flex_col() + .gap(px(10.0)) + .child(header); + if shell.sync_devices.is_loading() { + return section.child(render_device_note("Loading devices")).into_any_element(); + } + if let Some(message) = shell.sync_devices.error() { + section = section.child(render_inline_error(message)); + } + let devices = shell.sync_devices.devices().to_vec(); + if devices.is_empty() { + return section.child(render_device_note("No devices")).into_any_element(); + } + let current_approved = devices.iter().any(|device| device.current && device.is_approved()); + if current_approved + && devices.iter().any(|device| !device.current && device.approval_status == "pending") + { + section = section.child( + div().px(px(10.0)).py(px(7.0)).rounded(px(8.0)).bg(rgba(button_bg())).child( + Input::new(&shell.sync_verification_input).appearance(false).cleanable(false), + ), + ); + } + for device in devices { + section = section.child(render_device_row(shell, &device, current_approved, cx)); + } + section.into_any_element() +} + +fn render_device_row( + shell: &ElyShell, + device: &DeviceRecord, + current_approved: bool, + cx: &mut Context, +) -> AnyElement { + let status = if device.current { + "This device" + } else if device.is_approved() { + "Approved" + } else if device.approval_status == "pending" { + "Pending" + } else { + "Revoked" + }; + let mut row = div() + .py(px(8.0)) + .border_b_1() + .border_color(rgba(colors::divider())) + .flex() + .flex_col() + .gap(px(7.0)) + .child( + div() + .flex() + .items_center() + .justify_between() + .gap(px(10.0)) + .child( + div() + .min_w_0() + .text_size(px(12.5)) + .font_weight(FontWeight(500.0)) + .text_color(rgb(colors::ink())) + .child(device.device_name.clone()), + ) + .child(div().text_size(px(11.0)).text_color(rgb(colors::ink_4())).child(status)), + ); + let code = if device.current { + shell.sync_devices.current_code().map(str::to_string) + } else { + device.verification_code().ok() + }; + if let Some(code) = code { + row = row.child(div().text_size(px(11.5)).text_color(rgb(colors::ink_3())).child(code)); + } + let can_revoke = current_approved + && !device.current + && device.revoked_at.is_none() + && matches!(device.approval_status.as_str(), "pending" | "approved"); + if can_revoke { + let device_id = device.device_id.clone(); + let busy = shell.sync_devices.is_acting_on(&device_id); + let confirmed = shell.sync_devices.is_revoke_confirmation(&device_id); + row = row.child( + div() + .flex() + .items_center() + .justify_end() + .gap(px(8.0)) + .when(device.approval_status == "pending", |buttons| { + buttons.child(render_device_approve_button(device_id.clone(), busy, cx)) + }) + .when(can_revoke, |buttons| { + buttons.child(render_device_revoke_button(device_id, confirmed, busy, cx)) + }), + ); + } + row.into_any_element() +} + +fn render_device_approve_button( + device_id: String, + disabled: bool, + cx: &mut Context, +) -> AnyElement { + let id = SharedString::from(format!("sync-device-approve-{device_id}")); + div() + .id(id) + .px(px(12.0)) + .py(px(8.0)) + .rounded(px(8.0)) + .bg(rgba(colors::accent())) + .text_size(px(12.0)) + .font_weight(FontWeight(500.0)) + .text_color(rgb(0xfff5e6)) + .when(!disabled, |element| { + element + .cursor_pointer() + .hover(|style| style.opacity(0.92)) + .active(|style| style.opacity(0.78)) + .on_click(cx.listener(move |shell, _, _, cx| { + shell.approve_sync_device(device_id.clone(), cx); + })) + }) + .when(disabled, |element| element.opacity(0.6)) + .child(if disabled { "Approving" } else { "Approve" }) + .into_any_element() +} + +fn render_device_revoke_button( + device_id: String, + confirmed: bool, + disabled: bool, + cx: &mut Context, +) -> AnyElement { + let id = SharedString::from(format!("sync-device-revoke-{device_id}")); + div() + .id(id) + .px(px(12.0)) + .py(px(8.0)) + .rounded(px(8.0)) + .bg(rgba(if confirmed { colors::error() } else { button_bg() })) + .text_size(px(12.0)) + .font_weight(FontWeight(500.0)) + .text_color(rgb(if confirmed { 0xffffff } else { colors::error() })) + .when(!disabled, |element| { + element + .cursor_pointer() + .hover(|style| style.opacity(0.9)) + .active(|style| style.opacity(0.78)) + .on_click(cx.listener(move |shell, _, _, cx| { + shell.revoke_sync_device(device_id.clone(), cx); + })) + }) + .when(disabled, |element| element.opacity(0.6)) + .child(if disabled { + "Revoking" + } else if confirmed { + "Confirm revoke" + } else { + "Revoke" + }) + .into_any_element() +} + +fn render_device_note(message: &'static str) -> AnyElement { + div().text_size(px(11.5)).text_color(rgb(colors::ink_4())).child(message).into_any_element() +} + +fn account_form( + shell: &ElyShell, + profile_id: &ProfileId, + cx: &mut Context, +) -> Vec { let mut elements: Vec = Vec::new(); - let phase = shell.auth_flow_phase.clone(); + let phase = if shell.auth_flow_phase.belongs_to(profile_id) { + shell.auth_flow_phase.clone() + } else { + AuthFlowPhase::Idle + }; elements.push(render_field_label("Email")); elements.push(render_input(&shell.auth_email_input)); diff --git a/crates/ely_app/src/shell/internal_pages/sync_controls.rs b/crates/ely_app/src/shell/internal_pages/sync_controls.rs index f99b80f..15ee041 100644 --- a/crates/ely_app/src/shell/internal_pages/sync_controls.rs +++ b/crates/ely_app/src/shell/internal_pages/sync_controls.rs @@ -133,7 +133,7 @@ pub(super) fn render_policy_toggle( chrome_motion_feedback(press_id, selection_id, enabled, element) } -fn render_secondary_button( +pub(super) fn render_secondary_button( shell: &ElyShell, id: &'static str, label: &'static str, diff --git a/crates/ely_app/src/shell/mod.rs b/crates/ely_app/src/shell/mod.rs index c0d801d..2d996b5 100644 --- a/crates/ely_app/src/shell/mod.rs +++ b/crates/ely_app/src/shell/mod.rs @@ -24,6 +24,7 @@ mod site_permissions; mod space_files; mod spaces; mod splits; +mod sync_devices; mod sync_state; mod tab_groups; mod tab_lifecycle; @@ -57,7 +58,8 @@ use bookmarks::PendingBookmarkEdit; use downloads::PendingDownloadFileAction; use history::{PendingHistoryDomainClear, PendingHistoryTimeClear}; use plugins::{PendingPluginInstall, PendingPluginUninstall}; -use sync_state::SyncStateUpdate; +use sync_devices::SyncDeviceUiState; +use sync_state::{PendingMergeUpload, SyncStateUpdate}; use web_surface::WebSurfaceStore; enum ShellState { @@ -111,7 +113,10 @@ pub struct ElyShell { sync_upload_scheduled: bool, sync_upload_in_flight: bool, sync_upload_pending: bool, - sync_upload_pending_logical_clock_floor: Option, + sync_upload_pending_merge: Option, + sync_retry_at: Option, + pub(crate) sync_devices: SyncDeviceUiState, + pub(crate) sync_verification_input: Entity, pub(crate) auth_email_input: Entity, pub(crate) auth_otp_input: Entity, pub(crate) auth_flow_phase: auth::AuthFlowPhase, @@ -153,6 +158,8 @@ impl ElyShell { let auth_email_input = cx.new(|cx| InputState::new(window, cx).placeholder("you@elydora.com")); let auth_otp_input = cx.new(|cx| InputState::new(window, cx).placeholder("123456")); + let sync_verification_input = + cx.new(|cx| InputState::new(window, cx).placeholder("ABCD-EF01-2345-6789")); let translucency_slider = cx.new(|_cx| { SliderState::new() .min(0.0) @@ -252,7 +259,10 @@ impl ElyShell { sync_upload_scheduled: false, sync_upload_in_flight: false, sync_upload_pending: false, - sync_upload_pending_logical_clock_floor: None, + sync_upload_pending_merge: None, + sync_retry_at: None, + sync_devices: SyncDeviceUiState::default(), + sync_verification_input, auth_email_input, auth_otp_input, auth_flow_phase: auth::AuthFlowPhase::Idle, @@ -308,6 +318,10 @@ impl ElyShell { if let ShellState::Ready(core) = &mut self.state && core.select_profile(profile_id).is_ok() { + self.auth_flow_phase = auth::AuthFlowPhase::Idle; + self.sync_devices.reset(); + self.sync_retry_at = None; + self.clear_pending_cloud_sync_upload(); self.sync_address_input(window, cx); self.schedule_cloud_sync_upload(cx); cx.notify(); diff --git a/crates/ely_app/src/shell/settings_actions.rs b/crates/ely_app/src/shell/settings_actions.rs index 4d81160..40dd9a1 100644 --- a/crates/ely_app/src/shell/settings_actions.rs +++ b/crates/ely_app/src/shell/settings_actions.rs @@ -9,7 +9,7 @@ use gpui_component::slider::SliderValue; use crate::services::servo_profile_data::{default_profile_data_root, sync_profile_data_dir}; -use super::sync_state::{SyncStateUpdate, sync_platform_label}; +use super::sync_state::{PendingMergeUpload, SyncStateUpdate, sync_platform_label}; use super::{ElyShell, ShellState}; impl ElyShell { @@ -247,14 +247,14 @@ impl ElyShell { } pub(crate) fn trigger_cloud_sync_upload(&mut self) { - self.trigger_cloud_sync_upload_with_clock_floor(None); + self.trigger_cloud_sync_upload_with_merge(None); } - pub(crate) fn trigger_cloud_sync_upload_after_remote(&mut self, logical_clock_floor: u64) { - self.trigger_cloud_sync_upload_with_clock_floor(Some(logical_clock_floor)); + pub(super) fn trigger_cloud_sync_upload_after_remote(&mut self, merge: PendingMergeUpload) { + self.trigger_cloud_sync_upload_with_merge(Some(merge)); } - fn trigger_cloud_sync_upload_with_clock_floor(&mut self, logical_clock_floor: Option) { + fn trigger_cloud_sync_upload_with_merge(&mut self, mut merge: Option) { let active_profile_allows_sync = match &self.state { ShellState::Ready(core) => core.active_profile_allows_sync(), ShellState::StartupError(_) => false, @@ -266,7 +266,7 @@ impl ElyShell { } if self.sync_upload_in_flight { self.sync_upload_scheduled = false; - self.queue_cloud_sync_upload(logical_clock_floor); + self.queue_cloud_sync_upload(merge); return; } self.sync_upload_scheduled = false; @@ -278,6 +278,9 @@ impl ElyShell { return; }; let active_profile_id = snapshot.active_profile_id.clone(); + if merge.as_ref().is_some_and(|merge| merge.profile_id != active_profile_id) { + merge = None; + } let device_name = format!("ELY · {}", snapshot.active_profile_name); let Some(profile_root) = default_profile_data_root() else { tracing::warn!(target: "ely::sync", "profile data root is unavailable"); @@ -296,12 +299,12 @@ impl ElyShell { } }; let tx = self.sync_inbox_tx.clone(); - let thread_name = - if logical_clock_floor.is_some() { "ely-sync-merge-upload" } else { "ely-sync-upload" }; + let worker_profile_id = active_profile_id.clone(); + let thread_name = if merge.is_some() { "ely-sync-merge-upload" } else { "ely-sync-upload" }; self.sync_upload_in_flight = true; if let Err(error) = std::thread::Builder::new().name(thread_name.to_string()).spawn(move || { - run_sync_upload(profile_dir, device_name, bytes, logical_clock_floor, tx) + run_sync_upload(worker_profile_id, profile_dir, device_name, bytes, merge, tx) }) { self.sync_upload_in_flight = false; @@ -341,10 +344,11 @@ impl ElyShell { } fn run_sync_upload( + profile_id: ProfileId, profile_dir: std::path::PathBuf, device_name: String, bytes: Vec, - logical_clock_floor: Option, + merge: Option, inbox: std::sync::mpsc::Sender, ) { let mut engine = match SyncEngine::for_profile_dir( @@ -356,18 +360,19 @@ fn run_sync_upload( Err(error) => { let message = error.to_string(); tracing::warn!(target: "ely::sync", error = %message, "could not initialise sync engine"); - let _ = inbox.send(SyncStateUpdate::SyncError { message }); + let _ = inbox.send(SyncStateUpdate::SyncError { profile_id, message }); return; } }; - let outcome = match logical_clock_floor { - Some(floor) => engine.upload_merged_bytes(bytes, floor), + let prior_conflict_count = merge.as_ref().map_or(0, |merge| merge.conflict_count); + let outcome = match merge { + Some(merge) => engine.upload_merged_bytes(bytes, merge.base), None => engine.sync_bytes(bytes), }; match outcome { Ok(ely_browser_core::SyncOutcome::SignedOut) => { tracing::info!(target: "ely::sync", "no bearer token on disk; sync skipped"); - let _ = inbox.send(SyncStateUpdate::SignedOut); + let _ = inbox.send(SyncStateUpdate::SignedOut { profile_id }); } Ok(ely_browser_core::SyncOutcome::AwaitingDeviceApproval { device_id }) => { tracing::info!( @@ -375,7 +380,7 @@ fn run_sync_upload( device_id = %device_id, "sync device is awaiting approval", ); - let _ = inbox.send(SyncStateUpdate::AwaitingDeviceApproval); + let _ = inbox.send(SyncStateUpdate::AwaitingDeviceApproval { profile_id }); } Ok(ely_browser_core::SyncOutcome::RemoteSnapshot { snapshot_id, @@ -383,6 +388,8 @@ fn run_sync_upload( payload_bytes, device_id, bytes, + merge_base, + cas_conflict, }) => { tracing::info!( target: "ely::sync", @@ -392,7 +399,13 @@ fn run_sync_upload( device_id = %device_id, "remote snapshot downloaded", ); - let _ = inbox.send(SyncStateUpdate::RemoteSnapshot { bytes, logical_clock }); + let conflict_count = + if cas_conflict { prior_conflict_count.saturating_add(1) } else { 0 }; + let _ = inbox.send(SyncStateUpdate::RemoteSnapshot { + profile_id: profile_id.clone(), + bytes, + merge: PendingMergeUpload { profile_id, base: merge_base, conflict_count }, + }); } Ok(ely_browser_core::SyncOutcome::AlreadyCurrent { snapshot_id, @@ -412,7 +425,7 @@ fn run_sync_upload( .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); - let _ = inbox.send(SyncStateUpdate::SyncReady { last_synced_at_secs }); + let _ = inbox.send(SyncStateUpdate::SyncReady { profile_id, last_synced_at_secs }); } Ok(ely_browser_core::SyncOutcome::Uploaded { snapshot_id, @@ -432,15 +445,19 @@ fn run_sync_upload( .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); - let _ = inbox.send(SyncStateUpdate::SyncReady { last_synced_at_secs }); + let _ = inbox.send(SyncStateUpdate::SyncReady { profile_id, last_synced_at_secs }); + } + Err(ely_sync_client::SyncClientError::SnapshotBusy) => { + tracing::info!(target: "ely::sync", "snapshot head is busy; retry scheduled"); + let _ = inbox.send(SyncStateUpdate::SyncBusy { profile_id }); } Err(error) => { let message = error.to_string(); tracing::warn!(target: "ely::sync", error = %message, "snapshot upload failed"); let update = if message.contains("device_not_approved") { - SyncStateUpdate::AwaitingDeviceApproval + SyncStateUpdate::AwaitingDeviceApproval { profile_id } } else { - SyncStateUpdate::SyncError { message } + SyncStateUpdate::SyncError { profile_id, message } }; let _ = inbox.send(update); } diff --git a/crates/ely_app/src/shell/sync_devices.rs b/crates/ely_app/src/shell/sync_devices.rs new file mode 100644 index 0000000..3bf7fc1 --- /dev/null +++ b/crates/ely_app/src/shell/sync_devices.rs @@ -0,0 +1,260 @@ +use std::path::PathBuf; + +use ely_browser_core::SyncEngine; +use ely_domain::ProfileId; +use ely_sync_client::DeviceRecord; +use gpui::Context; + +use crate::services::servo_profile_data::{default_profile_data_root, sync_profile_data_dir}; + +use super::sync_state::sync_platform_label; +use super::{ElyShell, ShellState, sync_state::SyncStateUpdate}; + +#[derive(Clone, Debug, Default)] +enum DeviceUiPhase { + #[default] + Idle, + Loading, + Ready, + Acting { + device_id: String, + }, + Error, +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct SyncDeviceUiState { + profile_id: Option, + phase: DeviceUiPhase, + devices: Vec, + current_code: Option, + revoke_confirmation: Option, + error: Option, +} + +impl SyncDeviceUiState { + pub(crate) fn devices(&self) -> &[DeviceRecord] { + &self.devices + } + + pub(crate) fn current_code(&self) -> Option<&str> { + self.current_code.as_deref() + } + + pub(crate) fn error(&self) -> Option<&str> { + self.error.as_deref() + } + + pub(crate) fn is_loading(&self) -> bool { + matches!(self.phase, DeviceUiPhase::Loading) + } + + pub(crate) fn is_acting_on(&self, device_id: &str) -> bool { + matches!(&self.phase, DeviceUiPhase::Acting { device_id: active } if active == device_id) + } + + pub(crate) fn is_revoke_confirmation(&self, device_id: &str) -> bool { + self.revoke_confirmation.as_deref() == Some(device_id) + } + + pub(crate) fn reset(&mut self) { + *self = Self::default(); + } + + pub(crate) fn set_ready( + &mut self, + profile_id: ProfileId, + devices: Vec, + current_code: String, + ) { + self.profile_id = Some(profile_id); + self.phase = DeviceUiPhase::Ready; + self.devices = devices; + self.current_code = Some(current_code); + self.revoke_confirmation = None; + self.error = None; + } + + pub(crate) fn set_error(&mut self, profile_id: ProfileId, message: String) { + self.profile_id = Some(profile_id); + self.phase = DeviceUiPhase::Error; + self.revoke_confirmation = None; + self.error = Some(message); + } + + fn prepare_profile(&mut self, profile_id: &ProfileId) { + if self.profile_id.as_ref() != Some(profile_id) { + self.reset(); + self.profile_id = Some(profile_id.clone()); + } + } + + fn begin_load(&mut self, force: bool) -> bool { + if !force && !matches!(self.phase, DeviceUiPhase::Idle) { + return false; + } + if matches!(self.phase, DeviceUiPhase::Loading | DeviceUiPhase::Acting { .. }) { + return false; + } + self.phase = DeviceUiPhase::Loading; + self.error = None; + true + } + + fn begin_action(&mut self, device_id: String) -> bool { + if !matches!(self.phase, DeviceUiPhase::Ready | DeviceUiPhase::Error) { + return false; + } + self.phase = DeviceUiPhase::Acting { device_id }; + self.revoke_confirmation = None; + self.error = None; + true + } + + fn request_revoke_confirmation(&mut self, device_id: &str) -> bool { + if self.revoke_confirmation.as_deref() == Some(device_id) { + return true; + } + self.revoke_confirmation = Some(device_id.to_string()); + false + } +} + +impl ElyShell { + pub(crate) fn ensure_sync_devices_loaded(&mut self, cx: &mut Context) { + self.load_sync_devices(false, cx); + } + + pub(crate) fn refresh_sync_devices(&mut self, cx: &mut Context) { + self.load_sync_devices(true, cx); + } + + pub(crate) fn approve_sync_device(&mut self, device_id: String, cx: &mut Context) { + let verification_code = self.sync_verification_input.read(cx).value().to_string(); + let Some((profile_id, profile_dir, device_name)) = self.sync_device_context() else { + self.sync_devices.reset(); + return; + }; + self.sync_devices.prepare_profile(&profile_id); + if !self.sync_devices.begin_action(device_id.clone()) { + return; + } + let tx = self.sync_inbox_tx.clone(); + spawn_device_task("ely-sync-device-approve", profile_id.clone(), tx, move || { + let engine = + SyncEngine::for_profile_dir(&profile_dir, device_name, sync_platform_label())?; + engine.approve_cloud_device(&device_id, &verification_code)?; + load_devices(profile_id, engine) + }); + } + + pub(crate) fn revoke_sync_device(&mut self, device_id: String, cx: &mut Context) { + let Some((profile_id, profile_dir, device_name)) = self.sync_device_context() else { + self.sync_devices.reset(); + return; + }; + self.sync_devices.prepare_profile(&profile_id); + if !self.sync_devices.request_revoke_confirmation(&device_id) { + cx.notify(); + return; + } + if !self.sync_devices.begin_action(device_id.clone()) { + return; + } + let tx = self.sync_inbox_tx.clone(); + spawn_device_task("ely-sync-device-revoke", profile_id.clone(), tx, move || { + let engine = + SyncEngine::for_profile_dir(&profile_dir, device_name, sync_platform_label())?; + engine.revoke_cloud_device(&device_id)?; + load_devices(profile_id, engine) + }); + } + + fn load_sync_devices(&mut self, force: bool, _cx: &mut Context) { + let Some((profile_id, profile_dir, device_name)) = self.sync_device_context() else { + self.sync_devices.reset(); + return; + }; + self.sync_devices.prepare_profile(&profile_id); + if !self.sync_devices.begin_load(force) { + return; + } + let tx = self.sync_inbox_tx.clone(); + spawn_device_task("ely-sync-device-list", profile_id.clone(), tx, move || { + let engine = + SyncEngine::for_profile_dir(&profile_dir, device_name, sync_platform_label())?; + load_devices(profile_id, engine) + }); + } + + fn sync_device_context(&self) -> Option<(ProfileId, PathBuf, String)> { + let ShellState::Ready(core) = &self.state else { + return None; + }; + if !core.active_profile_allows_sync() { + return None; + } + let snapshot = core.snapshot().ok()?; + let root = default_profile_data_root()?; + Some(( + snapshot.active_profile_id.clone(), + sync_profile_data_dir(&root, &snapshot.active_profile_id), + format!("ELY · {}", snapshot.active_profile_name), + )) + } +} + +fn load_devices( + profile_id: ProfileId, + engine: SyncEngine, +) -> Result { + let current_code = engine.identity().verification_code()?; + let devices = engine.cloud_devices()?.devices; + Ok(SyncStateUpdate::DevicesLoaded { profile_id, devices, current_code }) +} + +fn spawn_device_task( + name: &str, + profile_id: ProfileId, + tx: std::sync::mpsc::Sender, + task: F, +) where + F: FnOnce() -> Result + Send + 'static, +{ + let thread_name = name.to_string(); + let worker_tx = tx.clone(); + let worker_profile_id = profile_id.clone(); + let spawn_result = std::thread::Builder::new().name(thread_name).spawn(move || { + let update = task().unwrap_or_else(|error| SyncStateUpdate::DevicesError { + profile_id: worker_profile_id, + message: error.to_string(), + }); + let _ = worker_tx.send(update); + }); + if let Err(error) = spawn_result { + tracing::warn!(target: "ely::sync", error = %error, "device task spawn failed"); + let _ = tx.send(SyncStateUpdate::DevicesError { profile_id, message: error.to_string() }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn changing_profiles_clears_device_ui_state() { + let first_profile = ProfileId::new(); + let second_profile = ProfileId::new(); + let mut state = SyncDeviceUiState::default(); + + state.prepare_profile(&first_profile); + state.set_error(first_profile.clone(), "first profile error".to_string()); + state.prepare_profile(&first_profile); + assert_eq!(state.error(), Some("first profile error")); + + state.prepare_profile(&second_profile); + assert!(state.devices().is_empty()); + assert!(state.current_code().is_none()); + assert!(state.error().is_none()); + } +} diff --git a/crates/ely_app/src/shell/sync_state.rs b/crates/ely_app/src/shell/sync_state.rs index c4ebb01..48c38b1 100644 --- a/crates/ely_app/src/shell/sync_state.rs +++ b/crates/ely_app/src/shell/sync_state.rs @@ -1,11 +1,21 @@ use std::{path::Path, time::Duration}; -use ely_domain::{ProfileKind, SyncConnectionState}; +use ely_domain::{ProfileId, ProfileKind, SyncConnectionState}; +use ely_sync_client::{AuthenticatedSnapshotHead, DeviceRecord}; use gpui::{Context, Timer}; use super::{ElyShell, ShellState, auth}; const CLOUD_SYNC_UPLOAD_DEBOUNCE: Duration = Duration::from_millis(750); +const CAS_RETRY_LIMIT: u8 = 3; +const CAS_RETRY_DELAY: Duration = Duration::from_secs(2); + +#[derive(Clone, Debug)] +pub(crate) struct PendingMergeUpload { + pub(super) profile_id: ProfileId, + pub(super) base: AuthenticatedSnapshotHead, + pub(super) conflict_count: u8, +} /// Messages the off-thread sync workers push back to the shell so /// `SyncConnectionState` on `BrowserCore` and the in-flight auth @@ -14,14 +24,17 @@ const CLOUD_SYNC_UPLOAD_DEBOUNCE: Duration = Duration::from_millis(750); /// on shell startup and does not flow through this channel. #[derive(Clone, Debug)] pub(crate) enum SyncStateUpdate { - SignedOut, - AwaitingDeviceApproval, - RemoteSnapshot { bytes: Vec, logical_clock: u64 }, - SyncReady { last_synced_at_secs: u64 }, - SyncError { message: String }, - AuthOtpSent { email: String }, - AuthSucceeded { email: String }, - AuthError { email: String, message: String }, + SignedOut { profile_id: ProfileId }, + AwaitingDeviceApproval { profile_id: ProfileId }, + RemoteSnapshot { profile_id: ProfileId, bytes: Vec, merge: PendingMergeUpload }, + SyncReady { profile_id: ProfileId, last_synced_at_secs: u64 }, + SyncBusy { profile_id: ProfileId }, + SyncError { profile_id: ProfileId, message: String }, + DevicesLoaded { profile_id: ProfileId, devices: Vec, current_code: String }, + DevicesError { profile_id: ProfileId, message: String }, + AuthOtpSent { profile_id: ProfileId, email: String }, + AuthSucceeded { profile_id: ProfileId, email: String }, + AuthError { profile_id: ProfileId, email: String, message: String }, } /// Stable label for the current OS used by the device registration @@ -64,13 +77,18 @@ impl ElyShell { .detach(); } - pub(super) fn queue_cloud_sync_upload(&mut self, logical_clock_floor: Option) { + pub(super) fn queue_cloud_sync_upload(&mut self, merge: Option) { self.sync_upload_pending = true; - if let Some(floor) = logical_clock_floor { - self.sync_upload_pending_logical_clock_floor = Some( - self.sync_upload_pending_logical_clock_floor - .map_or(floor, |current| current.max(floor)), - ); + if let Some(candidate) = merge { + let replace = self.sync_upload_pending_merge.as_ref().is_none_or(|current| { + candidate.profile_id != current.profile_id + || candidate.base.revision() > current.base.revision() + || candidate.base.revision() == current.base.revision() + && candidate.conflict_count > current.conflict_count + }); + if replace { + self.sync_upload_pending_merge = Some(candidate); + } } } @@ -80,9 +98,9 @@ impl ElyShell { } self.sync_upload_pending = false; - let logical_clock_floor = self.sync_upload_pending_logical_clock_floor.take(); - match logical_clock_floor { - Some(floor) => self.trigger_cloud_sync_upload_after_remote(floor), + let merge = self.sync_upload_pending_merge.take(); + match merge { + Some(merge) => self.trigger_cloud_sync_upload_after_remote(merge), None => self.trigger_cloud_sync_upload(), } true @@ -90,7 +108,7 @@ impl ElyShell { pub(super) fn clear_pending_cloud_sync_upload(&mut self) { self.sync_upload_pending = false; - self.sync_upload_pending_logical_clock_floor = None; + self.sync_upload_pending_merge = None; } fn can_schedule_cloud_sync_upload(&self) -> bool { @@ -118,23 +136,36 @@ impl ElyShell { /// state on `BrowserCore`. Returns `true` when at least one /// update was applied so callers can `cx.notify()` accordingly. pub(super) fn drain_sync_updates(&mut self) -> bool { + let retry_due = + self.sync_retry_at.is_some_and(|deadline| deadline <= std::time::Instant::now()); + if retry_due { + self.sync_retry_at = None; + } let mut latest_connection: Option = None; let mut auth_changed = false; let mut trigger_initial_sync = false; let mut trigger_merged_upload = None; let mut upload_finished = false; + let mut devices_changed = false; while let Ok(update) = self.sync_inbox_rx.try_recv() { match update { - SyncStateUpdate::SignedOut => { - latest_connection = Some(SyncConnectionState::SignedOut); + SyncStateUpdate::SignedOut { profile_id } => { upload_finished = true; + if active_profile_id(&self.state).as_ref() == Some(&profile_id) { + latest_connection = Some(SyncConnectionState::SignedOut); + } } - SyncStateUpdate::AwaitingDeviceApproval => { - latest_connection = Some(SyncConnectionState::AwaitingDeviceApproval); + SyncStateUpdate::AwaitingDeviceApproval { profile_id } => { upload_finished = true; + if active_profile_id(&self.state).as_ref() == Some(&profile_id) { + latest_connection = Some(SyncConnectionState::AwaitingDeviceApproval); + } } - SyncStateUpdate::RemoteSnapshot { bytes, logical_clock } => { + SyncStateUpdate::RemoteSnapshot { profile_id, bytes, merge } => { upload_finished = true; + if active_profile_id(&self.state).as_ref() != Some(&profile_id) { + continue; + } if let ShellState::Ready(core) = &mut self.state { match core.apply_sync_snapshot_bytes(&bytes) { Ok(summary) => { @@ -145,7 +176,15 @@ impl ElyShell { skipped = summary.skipped(), "remote snapshot applied", ); - trigger_merged_upload = Some(logical_clock); + if merge.conflict_count >= CAS_RETRY_LIMIT { + latest_connection = Some(SyncConnectionState::SyncError { + message: "Cloud Sync is busy; retrying shortly".to_string(), + }); + self.sync_retry_at = + Some(std::time::Instant::now() + CAS_RETRY_DELAY); + } else { + trigger_merged_upload = Some(merge); + } } Err(error) => { latest_connection = Some(SyncConnectionState::SyncError { @@ -155,29 +194,63 @@ impl ElyShell { } } } - SyncStateUpdate::SyncReady { last_synced_at_secs } => { - latest_connection = - Some(SyncConnectionState::SyncReady { last_synced_at_secs }); + SyncStateUpdate::SyncReady { profile_id, last_synced_at_secs } => { upload_finished = true; + if active_profile_id(&self.state).as_ref() == Some(&profile_id) { + latest_connection = + Some(SyncConnectionState::SyncReady { last_synced_at_secs }); + } } - SyncStateUpdate::SyncError { message } => { - latest_connection = Some(SyncConnectionState::SyncError { message }); + SyncStateUpdate::SyncBusy { profile_id } => { upload_finished = true; + if active_profile_id(&self.state).as_ref() == Some(&profile_id) { + latest_connection = Some(SyncConnectionState::SyncError { + message: "Cloud Sync is busy; retrying shortly".to_string(), + }); + self.sync_retry_at = Some(std::time::Instant::now() + CAS_RETRY_DELAY); + } } - SyncStateUpdate::AuthOtpSent { email } => { - self.auth_flow_phase = auth::AuthFlowPhase::AwaitingOtp { email }; - auth_changed = true; + SyncStateUpdate::SyncError { profile_id, message } => { + upload_finished = true; + if active_profile_id(&self.state).as_ref() == Some(&profile_id) { + latest_connection = Some(SyncConnectionState::SyncError { message }); + } } - SyncStateUpdate::AuthSucceeded { email } => { - self.auth_flow_phase = auth::AuthFlowPhase::Idle; - latest_connection = Some(SyncConnectionState::SignedIn); - trigger_initial_sync = true; - tracing::info!(target: "ely::sync", email = %email, "email OTP sign-in succeeded"); - auth_changed = true; + SyncStateUpdate::DevicesLoaded { profile_id, devices, current_code } => { + if active_profile_id(&self.state).as_ref() == Some(&profile_id) { + self.sync_devices.set_ready(profile_id, devices, current_code); + devices_changed = true; + } } - SyncStateUpdate::AuthError { email, message } => { - self.auth_flow_phase = auth::AuthFlowPhase::Error { email, message }; - auth_changed = true; + SyncStateUpdate::DevicesError { profile_id, message } => { + if active_profile_id(&self.state).as_ref() == Some(&profile_id) { + self.sync_devices.set_error(profile_id, message); + devices_changed = true; + } + } + SyncStateUpdate::AuthOtpSent { profile_id, email } => { + if active_profile_id(&self.state).as_ref() == Some(&profile_id) { + self.auth_flow_phase = + auth::AuthFlowPhase::AwaitingOtp { profile_id, email }; + auth_changed = true; + } + } + SyncStateUpdate::AuthSucceeded { profile_id, email } => { + if active_profile_id(&self.state).as_ref() == Some(&profile_id) { + self.auth_flow_phase = auth::AuthFlowPhase::Idle; + self.sync_devices.reset(); + latest_connection = Some(SyncConnectionState::SignedIn); + trigger_initial_sync = true; + tracing::info!(target: "ely::sync", email = %email, "email OTP sign-in succeeded"); + auth_changed = true; + } + } + SyncStateUpdate::AuthError { profile_id, email, message } => { + if active_profile_id(&self.state).as_ref() == Some(&profile_id) { + self.auth_flow_phase = + auth::AuthFlowPhase::Error { profile_id, email, message }; + auth_changed = true; + } } } } @@ -194,17 +267,32 @@ impl ElyShell { } let merged_upload_requested = trigger_merged_upload.is_some(); - if let Some(logical_clock_floor) = trigger_merged_upload { + if let Some(merge) = trigger_merged_upload { self.clear_pending_cloud_sync_upload(); - self.trigger_cloud_sync_upload_after_remote(logical_clock_floor); + self.trigger_cloud_sync_upload_after_remote(merge); } else if upload_finished { self.trigger_pending_cloud_sync_upload(); } + if retry_due { + self.trigger_cloud_sync_upload(); + } - auth_changed || trigger_initial_sync || merged_upload_requested || connection_changed + auth_changed + || devices_changed + || trigger_initial_sync + || merged_upload_requested + || retry_due + || connection_changed } } +fn active_profile_id(state: &ShellState) -> Option { + let ShellState::Ready(core) = state else { + return None; + }; + core.snapshot().ok().map(|snapshot| snapshot.active_profile_id) +} + fn probe_initial_sync_state_at( core: &mut ely_browser_core::BrowserCore, profile_root: &Path, diff --git a/crates/ely_browser_core/Cargo.toml b/crates/ely_browser_core/Cargo.toml index 199ac97..3e661be 100644 --- a/crates/ely_browser_core/Cargo.toml +++ b/crates/ely_browser_core/Cargo.toml @@ -12,6 +12,7 @@ serde.workspace = true serde_json.workspace = true thiserror.workspace = true url.workspace = true +uuid.workspace = true [lints] workspace = true diff --git a/crates/ely_browser_core/src/sync_engine.rs b/crates/ely_browser_core/src/sync_engine.rs index be42321..c4b10ca 100644 --- a/crates/ely_browser_core/src/sync_engine.rs +++ b/crates/ely_browser_core/src/sync_engine.rs @@ -4,18 +4,27 @@ use std::{ }; use ely_sync_client::{ - ApiClientConfig, BearerToken, BearerTokenStore, DeviceIdentity, SnapshotPayload, - SnapshotUploadRequest, SyncApiClient, SyncClientError, SyncLatestSnapshotDocument, + AccountKey, ApiClientConfig, AuthenticatedSnapshotHead, BearerToken, BearerTokenStore, + DeviceIdentity, SNAPSHOT_ENCRYPTION_VERSION, SnapshotCryptoContext, SnapshotDownloadResult, + SnapshotPayload, SnapshotUploadRequest, SnapshotUploadResult, SyncApiClient, SyncClientError, + SyncLatestSnapshotDocument, }; use crate::state::BrowserCore; use crate::sync_records::{SNAPSHOT_SCHEMA_REV, SyncSnapshotBody}; +mod concurrency; +mod device_management; +mod vault_management; + +use concurrency::{conflict_head, ensure_remote_generation_is_available}; + /// Per-profile sync engine for device identity, bearer-token storage, and snapshot IO. #[derive(Debug)] pub struct SyncEngine { api_config: ApiClientConfig, bearer_store: BearerTokenStore, + account_key_lock_dir: PathBuf, identity: DeviceIdentity, last_outcome: Option, } @@ -30,12 +39,18 @@ impl SyncEngine { platform: impl Into, ) -> Result { let sync_dir = profile_data_dir.join("sync"); + let account_key_lock_dir = profile_data_dir + .parent() + .and_then(Path::parent) + .unwrap_or(profile_data_dir) + .join(".sync-key-locks"); let identity = DeviceIdentity::load_or_create(&sync_dir.join("device.json"), device_name, platform)?; let bearer_store = BearerTokenStore::new(sync_dir.join("bearer.token")); Ok(Self { api_config: ApiClientConfig::production(), bearer_store, + account_key_lock_dir, identity, last_outcome: None, }) @@ -71,40 +86,42 @@ impl SyncEngine { self.bearer_store.load().map(|token| token.is_some()) } - /// Run the snapshot sync plan for a pre-serialised local payload. - /// The engine registers the device, checks the worker's latest - /// snapshot, downloads a newer remote payload when another device - /// wrote one, and uploads when the local payload is ready to win. + /// Reconcile a pre-serialised local payload with the authenticated global snapshot head. pub fn sync_bytes(&mut self, bytes: Vec) -> Result { let Some(bearer) = self.bearer_store.load()? else { let outcome = SyncOutcome::SignedOut; self.last_outcome = Some(outcome.clone()); return Ok(outcome); }; - let payload = SnapshotPayload::new(bytes)?; let client = SyncApiClient::new(self.api_config.clone(), bearer)?; - let Some(client) = self.approved_client(client)? else { + let Some((client, user_id)) = self.approved_client(client)? else { let outcome = SyncOutcome::AwaitingDeviceApproval { device_id: self.identity.device_id.clone() }; self.last_outcome = Some(outcome.clone()); return Ok(outcome); }; + let vault = self.resolve_vault(&client, &user_id)?; let status = client.sync_status()?; - let outcome = match status.snapshots.latest { - Some(latest) if latest.payload_hash == payload.payload_hash() => { - SyncOutcome::AlreadyCurrent { - snapshot_id: latest.snapshot_id, - logical_clock: latest.logical_clock, - payload_bytes: latest.size_bytes, - device_id: latest.device_id, + validate_sync_status(&status, &user_id, &self.identity.device_id)?; + let outcome = match status.snapshots.head { + Some(head) => { + let remote = self.download_remote_snapshot(&client, &user_id, &vault, head)?; + if remote.bytes == bytes && remote.merge_base.vault_generation() == vault.generation + { + SyncOutcome::AlreadyCurrent { + snapshot_id: remote.merge_base.snapshot_id().to_string(), + logical_clock: remote.merge_base.logical_clock(), + payload_bytes: remote.merge_base.size_bytes(), + device_id: remote.merge_base.device_id().to_string(), + } + } else if remote.bytes == bytes { + self.upload_payload(&client, &user_id, &vault, bytes, Some(&remote.merge_base))? + } else { + remote.into_outcome(false) } } - Some(latest) if latest.device_id != self.identity.device_id => { - self.download_remote_snapshot(&client, latest)? - } - Some(latest) => self.upload_payload(&client, payload, latest.logical_clock)?, - None => self.upload_payload(&client, payload, 0)?, + None => self.upload_payload(&client, &user_id, &vault, bytes, None)?, }; self.last_outcome = Some(outcome.clone()); Ok(outcome) @@ -116,22 +133,22 @@ impl SyncEngine { pub fn upload_merged_bytes( &mut self, bytes: Vec, - logical_clock_floor: u64, + merge_base: AuthenticatedSnapshotHead, ) -> Result { let Some(bearer) = self.bearer_store.load()? else { let outcome = SyncOutcome::SignedOut; self.last_outcome = Some(outcome.clone()); return Ok(outcome); }; - let payload = SnapshotPayload::new(bytes)?; let client = SyncApiClient::new(self.api_config.clone(), bearer)?; - let Some(client) = self.approved_client(client)? else { + let Some((client, user_id)) = self.approved_client(client)? else { let outcome = SyncOutcome::AwaitingDeviceApproval { device_id: self.identity.device_id.clone() }; self.last_outcome = Some(outcome.clone()); return Ok(outcome); }; - let outcome = self.upload_payload(&client, payload, logical_clock_floor)?; + let vault = self.resolve_vault(&client, &user_id)?; + let outcome = self.upload_payload(&client, &user_id, &vault, bytes, Some(&merge_base))?; self.last_outcome = Some(outcome.clone()); Ok(outcome) } @@ -139,13 +156,10 @@ impl SyncEngine { fn approved_client( &self, client: SyncApiClient, - ) -> Result, SyncClientError> { - let registration = client.register_device( - &self.identity, - &device_registration_idempotency_key(&self.identity), - )?; + ) -> Result, SyncClientError> { + let (client, registration) = self.registered_client(client)?; if registration.device.is_approved() { - return Ok(Some(client)); + return Ok(Some((client, registration.user_id))); } if registration.device.approval_status == "pending" { return Ok(None); @@ -156,44 +170,173 @@ impl SyncEngine { }) } + fn registered_client( + &self, + client: SyncApiClient, + ) -> Result<(SyncApiClient, ely_sync_client::client::DeviceRecordDocument), SyncClientError> + { + let idempotency_key = device_registration_idempotency_key(&self.identity); + let registration = match client.register_device(&self.identity, &idempotency_key) { + Ok(registration) => registration, + Err(SyncClientError::HttpStatus { status: 409, .. }) => { + let rebound = client.rebind_device(&self.identity)?; + let registration = client.register_device(&self.identity, &idempotency_key)?; + if registration.user_id != rebound.user_id { + return Err(SyncClientError::DeviceTrust { + reason: "device rebind account does not match registration", + }); + } + registration + } + Err(error) => return Err(error), + }; + Ok((client, registration)) + } + fn upload_payload( &self, client: &SyncApiClient, - payload: SnapshotPayload, - logical_clock_floor: u64, + user_id: &str, + vault: &ResolvedVault, + bytes: Vec, + base: Option<&AuthenticatedSnapshotHead>, ) -> Result { + let logical_clock_floor = base.map_or(0, AuthenticatedSnapshotHead::logical_clock); let logical_clock = current_logical_clock().max(logical_clock_floor.saturating_add(1)); let snapshot_id = snapshot_id_for_user(&self.identity); - let request = SnapshotUploadRequest::new( - &snapshot_id, - self.api_config.region(), - SNAPSHOT_SCHEMA_REV, + let head_revision = match base { + Some(base) => base.next_revision()?, + None => 1, + }; + let context = SnapshotCryptoContext { + user_id, + vault_generation: vault.generation, + snapshot_id: &snapshot_id, + schema_rev: SNAPSHOT_SCHEMA_REV, logical_clock, + device_id: &self.identity.device_id, + head_revision, + base_head: base.map(AuthenticatedSnapshotHead::head_ref), + }; + let encrypted = vault.account_key.encrypt(&context, &bytes)?; + let payload = SnapshotPayload::new(encrypted.bytes().to_vec())?; + let request = SnapshotUploadRequest::new( + self.api_config.region(), + &context, + base, + &encrypted, &payload, - ); - let document = client.upload_snapshot(&request)?; - Ok(SyncOutcome::Uploaded { - snapshot_id: document.snapshot.snapshot_id, - logical_clock: document.snapshot.logical_clock, - payload_bytes: document.snapshot.size_bytes, - device_id: document.device_id, - }) + )?; + match client.upload_snapshot(&request)? { + SnapshotUploadResult::Committed(document) => { + if document.version != 3 + || document.user_id != user_id + || document.device_id != self.identity.device_id + || document.snapshot.snapshot_id != snapshot_id + || document.snapshot.head_revision != head_revision + || document.snapshot.base_head.as_ref() + != base.map(AuthenticatedSnapshotHead::head_ref) + || document.snapshot.payload_hash != payload.payload_hash() + || document.snapshot.encryption_version != SNAPSHOT_ENCRYPTION_VERSION + || document.snapshot.key_id != vault.account_key.key_id() + || document.snapshot.vault_generation != vault.generation + || document.snapshot.content_hash != encrypted.content_hash() + || document.snapshot.schema_rev != SNAPSHOT_SCHEMA_REV + || document.snapshot.logical_clock != logical_clock + || document.snapshot.size_bytes + != u64::try_from(payload.bytes().len()).map_err(|_| { + SyncClientError::SnapshotEncryption { + reason: "snapshot payload size is invalid", + } + })? + { + return Err(SyncClientError::SnapshotEncryption { + reason: "snapshot upload response does not match request", + }); + } + Ok(SyncOutcome::Uploaded { + snapshot_id: document.snapshot.snapshot_id, + logical_clock: document.snapshot.logical_clock, + payload_bytes: document.snapshot.size_bytes, + device_id: document.device_id, + }) + } + SnapshotUploadResult::Conflict(conflict) => { + let head = conflict_head(conflict)?; + self.download_remote_snapshot(client, user_id, vault, head) + .map(|remote| remote.into_outcome(true)) + } + } } fn download_remote_snapshot( &self, client: &SyncApiClient, - latest: SyncLatestSnapshotDocument, - ) -> Result { - let download = client.download_snapshot(&latest.snapshot_id)?; - let payload = download.payload()?; - Ok(SyncOutcome::RemoteSnapshot { - snapshot_id: latest.snapshot_id, - logical_clock: latest.logical_clock, - payload_bytes: latest.size_bytes, - device_id: latest.device_id, - bytes: payload.into_bytes(), - }) + user_id: &str, + vault: &ResolvedVault, + mut latest: SyncLatestSnapshotDocument, + ) -> Result { + for _ in 0..3 { + let account_key = self.key_for_snapshot(client, user_id, vault, &latest)?; + let expected_head = latest.head_ref()?; + match client.download_snapshot(&expected_head)? { + SnapshotDownloadResult::Downloaded(download) => { + let (bytes, merge_base) = + download.authenticate(&expected_head, &account_key)?.into_parts(); + return Ok(AuthenticatedRemote { bytes, merge_base }); + } + SnapshotDownloadResult::Conflict(conflict) => { + latest = conflict_head(conflict)?; + } + } + } + Err(SyncClientError::SnapshotBusy) + } + + fn key_for_snapshot( + &self, + client: &SyncApiClient, + user_id: &str, + vault: &ResolvedVault, + snapshot: &SyncLatestSnapshotDocument, + ) -> Result { + if !matches!(snapshot.encryption_version, 1 | SNAPSHOT_ENCRYPTION_VERSION) { + return Err(SyncClientError::SnapshotEncryption { + reason: "remote snapshot encryption version is unsupported", + }); + } + ensure_remote_generation_is_available(snapshot.vault_generation, vault.generation)?; + if snapshot.vault_generation == vault.generation { + if snapshot.key_id != vault.account_key.key_id() { + return Err(SyncClientError::AccountKeyUnavailable); + } + return Ok(vault.account_key.clone()); + } + self.resolve_historical_key(client, user_id, snapshot) + } +} + +struct ResolvedVault { + account_key: AccountKey, + generation: u64, +} + +struct AuthenticatedRemote { + bytes: Vec, + merge_base: AuthenticatedSnapshotHead, +} + +impl AuthenticatedRemote { + fn into_outcome(self, cas_conflict: bool) -> SyncOutcome { + SyncOutcome::RemoteSnapshot { + snapshot_id: self.merge_base.snapshot_id().to_string(), + logical_clock: self.merge_base.logical_clock(), + payload_bytes: self.merge_base.size_bytes(), + device_id: self.merge_base.device_id().to_string(), + bytes: self.bytes, + merge_base: self.merge_base, + cas_conflict, + } } } @@ -215,6 +358,8 @@ pub enum SyncOutcome { payload_bytes: u64, device_id: String, bytes: Vec, + merge_base: AuthenticatedSnapshotHead, + cas_conflict: bool, }, Uploaded { snapshot_id: String, @@ -224,6 +369,23 @@ pub enum SyncOutcome { }, } +fn validate_sync_status( + status: &ely_sync_client::SyncStatusDocument, + user_id: &str, + device_id: &str, +) -> Result<(), SyncClientError> { + if status.version != 2 + || status.user_id != user_id + || status.device_id != device_id + || status.devices.current_device_id != device_id + || !status.devices.current_device_approved + || (status.snapshots.total_snapshots == 0) != status.snapshots.head.is_none() + { + return Err(SyncClientError::DeviceTrust { reason: "sync status identity is invalid" }); + } + Ok(()) +} + #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub struct SyncSnapshotApplySummary { imported: usize, diff --git a/crates/ely_browser_core/src/sync_engine/concurrency.rs b/crates/ely_browser_core/src/sync_engine/concurrency.rs new file mode 100644 index 0000000..47a1e78 --- /dev/null +++ b/crates/ely_browser_core/src/sync_engine/concurrency.rs @@ -0,0 +1,43 @@ +use ely_sync_client::{ + SyncClientError, SyncLatestSnapshotDocument, SyncSnapshotHeadConflictDocument, +}; + +pub(super) fn conflict_head( + conflict: SyncSnapshotHeadConflictDocument, +) -> Result { + conflict.current_head.ok_or(SyncClientError::SnapshotBusy) +} + +pub(super) fn ensure_remote_generation_is_available( + remote_generation: u64, + resolved_generation: u64, +) -> Result<(), SyncClientError> { + if remote_generation > resolved_generation { + return Err(SyncClientError::SnapshotBusy); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_conflict_head_requests_a_bounded_retry() { + let conflict = SyncSnapshotHeadConflictDocument { + version: 1, + error: "sync_snapshot_head_conflict".to_string(), + current_head: None, + }; + assert!(matches!(conflict_head(conflict), Err(SyncClientError::SnapshotBusy))); + } + + #[test] + fn remote_vault_generation_ahead_requests_a_bounded_retry() { + assert!(matches!( + ensure_remote_generation_is_available(2, 1), + Err(SyncClientError::SnapshotBusy) + )); + assert!(ensure_remote_generation_is_available(2, 2).is_ok()); + } +} diff --git a/crates/ely_browser_core/src/sync_engine/device_management.rs b/crates/ely_browser_core/src/sync_engine/device_management.rs new file mode 100644 index 0000000..a5642e6 --- /dev/null +++ b/crates/ely_browser_core/src/sync_engine/device_management.rs @@ -0,0 +1,459 @@ +use ely_sync_client::{ + AccountKey, DeviceApprovalDocument, DeviceApprovalRequest, DeviceListResponse, DeviceRecord, + DeviceRevocationDocument, DeviceRevocationRequest, SyncApiClient, SyncClientError, + VaultContext, WrappedAccountKey, +}; +use uuid::Uuid; + +use super::SyncEngine; + +impl SyncEngine { + pub fn cloud_devices(&self) -> Result { + let client = self.authenticated_client()?; + let (client, registration) = self.registered_client(client)?; + let devices = client.list_devices()?; + validate_device_list( + &devices, + ®istration.user_id, + &self.identity.device_id, + registration.device.is_approved(), + )?; + Ok(devices) + } + + pub fn approve_cloud_device( + &self, + target_device_id: &str, + verification_code: &str, + ) -> Result { + let (client, user_id) = self.approved_device_client()?; + let devices = client.list_devices()?; + validate_device_list(&devices, &user_id, &self.identity.device_id, true)?; + let target = pending_device(&devices.devices, target_device_id)?; + if !target.verification_code()?.eq_ignore_ascii_case(verification_code.trim()) { + return Err(SyncClientError::DeviceTrust { + reason: "device verification code does not match", + }); + } + let wrapping_public_key = + target.wrapping_public_key.as_deref().ok_or(SyncClientError::DeviceTrust { + reason: "pending device has no wrapping public key", + })?; + + let vault = self.resolve_vault(&client, &user_id)?; + let key_id = vault.account_key.key_id(); + let envelope = WrappedAccountKey::wrap( + &vault.account_key, + &VaultContext { + user_id: &user_id, + recipient_device_id: &target.device_id, + recipient_wrapping_public_key: wrapping_public_key, + approver_device_id: &self.identity.device_id, + generation: vault.generation, + key_id: &key_id, + }, + )?; + let idempotency_key = format!("device-approval:{}", Uuid::now_v7().simple()); + let request = DeviceApprovalRequest::new( + &user_id, + &self.identity, + &target.device_id, + &key_id, + vault.generation, + &envelope, + &idempotency_key, + )?; + let document = client.approve_device(&request)?; + validate_approval(&document, &user_id, &self.identity.device_id, &target.device_id)?; + Ok(document) + } + + pub fn revoke_cloud_device( + &self, + target_device_id: &str, + ) -> Result { + let (client, user_id) = self.approved_device_client()?; + let devices = client.list_devices()?; + validate_device_list(&devices, &user_id, &self.identity.device_id, true)?; + let target = revocable_device(&devices.devices, target_device_id)?; + let idempotency_key = format!("device-revocation:{}", Uuid::now_v7().simple()); + if target.approval_status == "pending" { + let request = DeviceRevocationRequest::pending( + &user_id, + &self.identity, + &target.device_id, + &idempotency_key, + )?; + let document = client.revoke_device(&request)?; + validate_pending_revocation( + &document, + &user_id, + &self.identity.device_id, + &target.device_id, + )?; + return Ok(document); + } + let vault = self.resolve_vault(&client, &user_id)?; + let previous_key_id = vault.account_key.key_id(); + let new_generation = + vault.generation.checked_add(1).ok_or(SyncClientError::DeviceTrust { + reason: "device revocation generation overflowed", + })?; + let new_key = AccountKey::generate()?; + let new_key_id = new_key.key_id(); + let envelopes = rotation_envelopes( + &devices.devices, + &target.device_id, + &user_id, + &self.identity.device_id, + &new_key, + new_generation, + &new_key_id, + )?; + let request = DeviceRevocationRequest::approved_rotation( + &user_id, + &self.identity, + &target.device_id, + &previous_key_id, + vault.generation, + &new_key_id, + new_generation, + envelopes, + &idempotency_key, + )?; + let document = client.revoke_device(&request)?; + validate_approved_revocation( + &document, + &user_id, + &self.identity.device_id, + &target.device_id, + &new_key_id, + new_generation, + )?; + self.account_key_store(&user_id)?.save_current(&new_key, new_generation)?; + Ok(document) + } + + fn approved_device_client(&self) -> Result<(SyncApiClient, String), SyncClientError> { + let client = self.authenticated_client()?; + self.approved_client(client)?.ok_or_else(|| SyncClientError::DeviceApprovalStatus { + device_id: self.identity.device_id.clone(), + status: "pending".to_string(), + }) + } + + fn authenticated_client(&self) -> Result { + let bearer = self.bearer_store.load()?.ok_or(SyncClientError::DeviceTrust { + reason: "device management requires an authenticated session", + })?; + SyncApiClient::new(self.api_config.clone(), bearer) + } +} + +fn validate_device_list( + document: &DeviceListResponse, + user_id: &str, + current_device_id: &str, + require_approved: bool, +) -> Result<(), SyncClientError> { + let current = document.devices.iter().filter(|device| device.current).collect::>(); + if document.version != 1 + || document.user_id != user_id + || current.len() != 1 + || current[0].device_id != current_device_id + || (require_approved && !current[0].is_approved()) + { + return Err(SyncClientError::DeviceTrust { + reason: "device list does not match the authenticated device", + }); + } + Ok(()) +} + +fn pending_device<'a>( + devices: &'a [DeviceRecord], + target_device_id: &str, +) -> Result<&'a DeviceRecord, SyncClientError> { + let target = devices + .iter() + .find(|device| device.device_id == target_device_id) + .ok_or(SyncClientError::DeviceTrust { reason: "pending device was not found" })?; + if target.current || target.approval_status != "pending" || target.revoked_at.is_some() { + return Err(SyncClientError::DeviceTrust { reason: "device is not pending approval" }); + } + Ok(target) +} + +fn revocable_device<'a>( + devices: &'a [DeviceRecord], + target_device_id: &str, +) -> Result<&'a DeviceRecord, SyncClientError> { + let target = devices + .iter() + .find(|device| device.device_id == target_device_id) + .ok_or(SyncClientError::DeviceTrust { reason: "device was not found" })?; + if target.current + || target.revoked_at.is_some() + || !matches!(target.approval_status.as_str(), "pending" | "approved") + { + return Err(SyncClientError::DeviceTrust { reason: "device cannot be revoked" }); + } + Ok(target) +} + +#[allow(clippy::too_many_arguments)] +fn rotation_envelopes( + devices: &[DeviceRecord], + target_device_id: &str, + user_id: &str, + approver_device_id: &str, + new_key: &AccountKey, + new_generation: u64, + new_key_id: &str, +) -> Result, SyncClientError> { + devices + .iter() + .filter(|device| device.device_id != target_device_id && device.is_approved()) + .filter_map(|device| { + device + .wrapping_public_key + .as_deref() + .map(|wrapping_public_key| (device, wrapping_public_key)) + }) + .map(|(device, wrapping_public_key)| { + let envelope = WrappedAccountKey::wrap( + new_key, + &VaultContext { + user_id, + recipient_device_id: &device.device_id, + recipient_wrapping_public_key: wrapping_public_key, + approver_device_id, + generation: new_generation, + key_id: new_key_id, + }, + )?; + Ok((device.device_id.clone(), envelope)) + }) + .collect() +} + +fn validate_approval( + document: &DeviceApprovalDocument, + user_id: &str, + approver_device_id: &str, + target_device_id: &str, +) -> Result<(), SyncClientError> { + if document.version != 1 + || document.user_id != user_id + || document.approved_by_device_id != approver_device_id + || document.device.device_id != target_device_id + || !document.device.is_approved() + || document.device.approved_at != Some(document.approved_at) + { + return Err(SyncClientError::DeviceTrust { + reason: "device approval response does not match the request", + }); + } + Ok(()) +} + +fn validate_approved_revocation( + document: &DeviceRevocationDocument, + user_id: &str, + approver_device_id: &str, + target_device_id: &str, + key_id: &str, + generation: u64, +) -> Result<(), SyncClientError> { + let DeviceRevocationDocument::ApprovedRotate { + version, + user_id: response_user_id, + revoked_by_device_id, + revoked_at, + key_id: response_key_id, + generation: response_generation, + device, + } = document + else { + return Err(revocation_response_error()); + }; + if response_key_id != key_id || *response_generation != generation { + return Err(revocation_response_error()); + } + validate_revocation_common( + *version, + response_user_id, + revoked_by_device_id, + *revoked_at, + device, + user_id, + approver_device_id, + target_device_id, + ) +} + +fn validate_pending_revocation( + document: &DeviceRevocationDocument, + user_id: &str, + approver_device_id: &str, + target_device_id: &str, +) -> Result<(), SyncClientError> { + let DeviceRevocationDocument::PendingRevoke { + version, + user_id: response_user_id, + revoked_by_device_id, + revoked_at, + device, + } = document + else { + return Err(revocation_response_error()); + }; + validate_revocation_common( + *version, + response_user_id, + revoked_by_device_id, + *revoked_at, + device, + user_id, + approver_device_id, + target_device_id, + ) +} + +#[allow(clippy::too_many_arguments)] +fn validate_revocation_common( + version: u32, + response_user_id: &str, + revoked_by_device_id: &str, + revoked_at: u64, + device: &DeviceRecord, + user_id: &str, + approver_device_id: &str, + target_device_id: &str, +) -> Result<(), SyncClientError> { + if version == 2 + && response_user_id == user_id + && revoked_by_device_id == approver_device_id + && device.device_id == target_device_id + && device.approval_status == "revoked" + && device.revoked_at == Some(revoked_at) + && !device.current + { + return Ok(()); + } + Err(revocation_response_error()) +} + +fn revocation_response_error() -> SyncClientError { + SyncClientError::DeviceTrust { reason: "device revocation response does not match the request" } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn device(device_id: &str, status: &str, current: bool) -> DeviceRecord { + DeviceRecord { + device_id: device_id.to_string(), + public_key: "01".repeat(32), + wrapping_public_key: Some("02".repeat(32)), + device_name: "Test".to_string(), + platform: "macos".to_string(), + approval_status: status.to_string(), + current, + created_at: 1, + approved_at: (status == "approved").then_some(2), + last_active_at: None, + revoked_at: None, + } + } + + #[test] + fn device_list_requires_one_approved_current_device() { + let document = DeviceListResponse { + version: 1, + user_id: "user-01".to_string(), + devices: vec![device("device-01", "approved", true)], + }; + assert!(validate_device_list(&document, "user-01", "device-01", true).is_ok()); + assert!(validate_device_list(&document, "user-02", "device-01", true).is_err()); + } + + #[test] + fn approval_target_must_be_pending() { + let devices = + [device("device-01", "approved", true), device("device-02", "pending", false)]; + assert!(matches!( + pending_device(&devices, "device-02"), + Ok(device) if device.device_id == "device-02" + )); + assert!(pending_device(&devices, "device-01").is_err()); + } + + #[test] + fn approval_response_requires_matching_device() { + let device = device("device-02", "approved", false); + let document = DeviceApprovalDocument { + version: 1, + user_id: "user-01".to_string(), + approved_by_device_id: "device-01".to_string(), + approved_at: 2, + device, + }; + assert!(validate_approval(&document, "user-01", "device-01", "device-02").is_ok()); + assert!(validate_approval(&document, "user-01", "device-01", "device-03").is_err()); + } + + #[test] + fn revocation_target_must_be_another_active_device() { + let devices = [ + device("device-01", "approved", true), + device("device-02", "approved", false), + device("device-03", "revoked", false), + ]; + assert!(matches!( + revocable_device(&devices, "device-02"), + Ok(device) if device.device_id == "device-02" + )); + assert!(revocable_device(&devices, "device-01").is_err()); + assert!(revocable_device(&devices, "device-03").is_err()); + } + + #[test] + fn revocation_response_binds_rotated_key_and_target() { + let mut revoked = device("device-02", "revoked", false); + revoked.approved_at = Some(2); + revoked.revoked_at = Some(3); + let document = DeviceRevocationDocument::ApprovedRotate { + version: 2, + user_id: "user-01".to_string(), + revoked_by_device_id: "device-01".to_string(), + revoked_at: 3, + key_id: "03".repeat(32), + generation: 2, + device: revoked, + }; + assert!( + validate_approved_revocation( + &document, + "user-01", + "device-01", + "device-02", + &"03".repeat(32), + 2, + ) + .is_ok() + ); + assert!( + validate_approved_revocation( + &document, + "user-01", + "device-01", + "device-03", + &"03".repeat(32), + 2, + ) + .is_err() + ); + } +} diff --git a/crates/ely_browser_core/src/sync_engine/vault_management.rs b/crates/ely_browser_core/src/sync_engine/vault_management.rs new file mode 100644 index 0000000..d71b447 --- /dev/null +++ b/crates/ely_browser_core/src/sync_engine/vault_management.rs @@ -0,0 +1,109 @@ +use ely_sync_client::{ + AccountKey, AccountKeyStore, SyncApiClient, SyncClientError, SyncLatestSnapshotDocument, + SyncVaultBootstrapRequest, SyncVaultDocument, WrappedAccountKey, +}; + +use super::{ResolvedVault, SyncEngine}; + +impl SyncEngine { + pub(super) fn resolve_vault( + &self, + client: &SyncApiClient, + user_id: &str, + ) -> Result { + match client.current_sync_vault() { + Ok(document) => self.resolve_vault_document(user_id, document), + Err(SyncClientError::HttpStatus { status: 404, .. }) => { + self.bootstrap_vault(client, user_id) + } + Err(error) => Err(error), + } + } + + fn resolve_vault_document( + &self, + user_id: &str, + document: SyncVaultDocument, + ) -> Result { + let store = self.account_key_store(user_id)?; + let key = document.unwrap_for(user_id, &self.identity)?; + if let Some(stored) = store.load()? { + if stored.current_generation() > document.generation { + return Err(SyncClientError::VaultCrypto { + reason: "sync vault generation rolled back", + }); + } + if let Some(stored_key) = stored.key(document.generation) + && stored_key.key_id() != key.key_id() + { + return Err(SyncClientError::VaultCrypto { + reason: "sync vault key changed within one generation", + }); + } + } + store.save_current(&key, document.generation)?; + Ok(ResolvedVault { account_key: key, generation: document.generation }) + } + + fn bootstrap_vault( + &self, + client: &SyncApiClient, + user_id: &str, + ) -> Result { + let store = self.account_key_store(user_id)?; + if store.load()?.is_some() { + return Err(SyncClientError::VaultCrypto { + reason: "sync vault bootstrap would discard stored key history", + }); + } + let key = AccountKey::generate()?; + let envelope = WrappedAccountKey::self_wrap(&key, user_id, &self.identity, 1)?; + let key_id = key.key_id(); + let idempotency_key = format!("vault-bootstrap:{}:{key_id}", self.identity.device_id); + let request = SyncVaultBootstrapRequest::signed( + user_id, + &self.identity, + &key_id, + &envelope, + &idempotency_key, + )?; + let document = client.bootstrap_sync_vault(&request)?; + let confirmed_key = document.unwrap_for(user_id, &self.identity)?; + if confirmed_key.key_id() != key_id { + return Err(SyncClientError::AccountKeyUnavailable); + } + store.save_current(&confirmed_key, document.generation)?; + Ok(ResolvedVault { account_key: confirmed_key, generation: document.generation }) + } + + pub(super) fn resolve_historical_key( + &self, + client: &SyncApiClient, + user_id: &str, + snapshot: &SyncLatestSnapshotDocument, + ) -> Result { + let store = self.account_key_store(user_id)?; + if let Some(stored) = store.load()? + && let Some(key) = stored.key(snapshot.vault_generation) + { + if key.key_id() != snapshot.key_id { + return Err(SyncClientError::AccountKeyUnavailable); + } + return Ok(key.clone()); + } + let document = client.sync_vault_generation(snapshot.vault_generation, &snapshot.key_id)?; + let key = document.unwrap_for(user_id, &self.identity)?; + if document.generation != snapshot.vault_generation || key.key_id() != snapshot.key_id { + return Err(SyncClientError::AccountKeyUnavailable); + } + store.save_historical(&key, snapshot.vault_generation)?; + Ok(key) + } + + pub(super) fn account_key_store( + &self, + user_id: &str, + ) -> Result { + AccountKeyStore::new(user_id, &self.account_key_lock_dir) + } +} diff --git a/crates/ely_sync_client/Cargo.toml b/crates/ely_sync_client/Cargo.toml index 4dd1492..5085829 100644 --- a/crates/ely_sync_client/Cargo.toml +++ b/crates/ely_sync_client/Cargo.toml @@ -6,14 +6,23 @@ license.workspace = true rust-version.workspace = true [dependencies] +base64.workspace = true +chacha20poly1305.workspace = true ed25519-dalek.workspace = true ely_domain = { path = "../ely_domain" } +fs2.workspace = true +getrandom.workspace = true +hkdf.workspace = true +hmac.workspace = true +hpke.workspace = true +keyring.workspace = true serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } thiserror = { workspace = true } ureq = { workspace = true, features = ["json"] } uuid = { workspace = true } +zeroize.workspace = true [lints] workspace = true diff --git a/crates/ely_sync_client/src/client.rs b/crates/ely_sync_client/src/client.rs index 90008aa..6e28e1e 100644 --- a/crates/ely_sync_client/src/client.rs +++ b/crates/ely_sync_client/src/client.rs @@ -1,13 +1,21 @@ -use std::time::Duration; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use serde::de::DeserializeOwned; use ureq::{Agent, AgentBuilder}; use crate::{ + SnapshotHeadRef, auth::BearerToken, device::{DeviceIdentity, DeviceListResponse, DeviceRegistration}, + device_api::{ + DeviceApprovalDocument, DeviceApprovalRequest, DeviceRebindChallengeDocument, + DeviceRebindChallengeRequest, DeviceRebindDocument, + }, + device_revocation::{DeviceRevocationDocument, DeviceRevocationRequest}, error::SyncClientError, snapshot::{SnapshotDownload, SnapshotUploadRequest}, + vault::SyncVaultDocument, + vault_bootstrap::SyncVaultBootstrapRequest, }; const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); @@ -73,13 +81,16 @@ impl SyncApiClient { identity: &DeviceIdentity, idempotency_key: &str, ) -> Result { + let registration_proof = identity.registration_proof(idempotency_key)?; let registration = DeviceRegistration { - version: 1, + version: 2, device_id: &identity.device_id, public_key: &identity.public_key, + wrapping_public_key: &identity.wrapping_public_key, device_name: &identity.device_name, platform: &identity.platform, idempotency_key, + registration_proof: ®istration_proof, }; let endpoint = self.endpoint("/api/devices/register"); let response = self @@ -107,6 +118,75 @@ impl SyncApiClient { read_json_response::(&endpoint, response) } + /// Rebind an existing v2 device to a fresh authenticated session. + pub fn rebind_device( + &self, + identity: &DeviceIdentity, + ) -> Result { + let challenge_endpoint = self.endpoint("/api/devices/rebind/challenge"); + let challenge_request = + DeviceRebindChallengeRequest { version: 1, device_id: &identity.device_id }; + let challenge_response = self + .agent + .post(&challenge_endpoint) + .set("Authorization", &format!("Bearer {}", self.bearer.as_str())) + .set("Content-Type", "application/json") + .send_json(serde_json::to_value(&challenge_request).map_err(|source| { + SyncClientError::Json { endpoint: challenge_endpoint.clone(), source } + })?); + let challenge = read_json_response::( + &challenge_endpoint, + challenge_response, + )?; + let now_seconds = current_time_seconds()?; + let rebind_request = challenge.signed_request(identity, now_seconds)?; + let rebind_endpoint = self.endpoint("/api/devices/rebind"); + let rebind_response = self + .agent + .post(&rebind_endpoint) + .set("Authorization", &format!("Bearer {}", self.bearer.as_str())) + .set("Content-Type", "application/json") + .send_json(serde_json::to_value(&rebind_request).map_err(|source| { + SyncClientError::Json { endpoint: rebind_endpoint.clone(), source } + })?); + let document = + read_json_response::(&rebind_endpoint, rebind_response)?; + document.validate(identity, &challenge, current_time_seconds()?)?; + Ok(document) + } + + pub fn approve_device( + &self, + request: &DeviceApprovalRequest<'_>, + ) -> Result { + let endpoint = self.endpoint("/api/devices/approve"); + let response = + self.agent + .post(&endpoint) + .set("Authorization", &format!("Bearer {}", self.bearer.as_str())) + .set("Content-Type", "application/json") + .send_json(serde_json::to_value(request).map_err(|source| { + SyncClientError::Json { endpoint: endpoint.clone(), source } + })?); + read_json_response::(&endpoint, response) + } + + pub fn revoke_device( + &self, + request: &DeviceRevocationRequest, + ) -> Result { + let endpoint = self.endpoint("/api/devices/revoke"); + let response = + self.agent + .post(&endpoint) + .set("Authorization", &format!("Bearer {}", self.bearer.as_str())) + .set("Content-Type", "application/json") + .send_json(serde_json::to_value(request).map_err(|source| { + SyncClientError::Json { endpoint: endpoint.clone(), source } + })?); + read_json_response::(&endpoint, response) + } + /// `GET /api/sync/status` — return the worker-side cursor, /// object, snapshot, and device summary for the authenticated /// approved device. @@ -120,13 +200,54 @@ impl SyncApiClient { read_json_response::(&endpoint, response) } + pub fn current_sync_vault(&self) -> Result { + let endpoint = self.endpoint("/api/sync/vault"); + let response = self + .agent + .get(&endpoint) + .set("Authorization", &format!("Bearer {}", self.bearer.as_str())) + .call(); + read_json_response::(&endpoint, response) + } + + pub fn sync_vault_generation( + &self, + generation: u64, + key_id: &str, + ) -> Result { + let endpoint = + self.endpoint(&format!("/api/sync/vault?generation={generation}&key_id={key_id}")); + let response = self + .agent + .get(&endpoint) + .set("Authorization", &format!("Bearer {}", self.bearer.as_str())) + .call(); + read_json_response::(&endpoint, response) + } + + pub fn bootstrap_sync_vault( + &self, + request: &SyncVaultBootstrapRequest<'_>, + ) -> Result { + let endpoint = self.endpoint("/api/sync/vault/bootstrap"); + let response = + self.agent + .post(&endpoint) + .set("Authorization", &format!("Bearer {}", self.bearer.as_str())) + .set("Content-Type", "application/json") + .send_json(serde_json::to_value(request).map_err(|error| { + SyncClientError::Json { endpoint: endpoint.clone(), source: error } + })?); + read_json_response::(&endpoint, response) + } + /// `POST /api/sync/snapshot` — push the full per-user state. The /// worker enforces logical-clock monotonicity, so callers must /// pass a value strictly greater than the last accepted snapshot. pub fn upload_snapshot( &self, request: &SnapshotUploadRequest<'_>, - ) -> Result { + ) -> Result { let endpoint = self.endpoint("/api/sync/snapshot"); let response = self.agent @@ -136,7 +257,23 @@ impl SyncApiClient { .send_json(serde_json::to_value(request).map_err(|error| { SyncClientError::Json { endpoint: endpoint.clone(), source: error } })?); - read_json_response::(&endpoint, response) + match response { + Ok(response) => read_json_from_response::(&endpoint, response) + .map(SnapshotUploadResult::Committed), + Err(ureq::Error::Status(409, response)) => { + let conflict = read_json_from_response::( + &endpoint, response, + )?; + if conflict.version != 1 || conflict.error != "sync_snapshot_head_conflict" { + return Err(SyncClientError::DeviceTrust { + reason: "snapshot conflict response is invalid", + }); + } + Ok(SnapshotUploadResult::Conflict(conflict)) + } + Err(error) => read_json_response::(&endpoint, Err(error)) + .map(SnapshotUploadResult::Committed), + } } /// `GET /api/sync/snapshot?snapshot_id=…` — fetch the snapshot for @@ -145,15 +282,36 @@ impl SyncApiClient { /// the bytes. pub fn download_snapshot( &self, - snapshot_id: &str, - ) -> Result { - let endpoint = self.endpoint(&format!("/api/sync/snapshot?snapshot_id={snapshot_id}")); + head: &SnapshotHeadRef, + ) -> Result { + let endpoint = self.endpoint(&format!( + "/api/sync/snapshot?snapshot_id={}&head_revision={}&payload_hash={}", + head.snapshot_id(), + head.revision(), + head.payload_hash(), + )); let response = self .agent .get(&endpoint) .set("Authorization", &format!("Bearer {}", self.bearer.as_str())) .call(); - read_json_response::(&endpoint, response) + match response { + Ok(response) => read_json_from_response::(&endpoint, response) + .map(SnapshotDownloadResult::Downloaded), + Err(ureq::Error::Status(409, response)) => { + let conflict = read_json_from_response::( + &endpoint, response, + )?; + if conflict.version != 1 || conflict.error != "sync_snapshot_head_conflict" { + return Err(SyncClientError::DeviceTrust { + reason: "snapshot conflict response is invalid", + }); + } + Ok(SnapshotDownloadResult::Conflict(conflict)) + } + Err(error) => read_json_response::(&endpoint, Err(error)) + .map(SnapshotDownloadResult::Downloaded), + } } fn endpoint(&self, path: &str) -> String { @@ -205,19 +363,55 @@ pub struct SyncObjectStatusDocument { #[derive(Clone, Debug, serde::Deserialize)] pub struct SyncSnapshotStatusDocument { pub total_snapshots: u64, - pub latest: Option, + pub head: Option, } #[derive(Clone, Debug, serde::Deserialize)] pub struct SyncLatestSnapshotDocument { + pub head_revision: u64, + pub base_head: Option, pub snapshot_id: String, pub payload_hash: String, + pub encryption_version: u32, + pub vault_generation: u64, + pub key_id: String, + pub content_hash: String, pub logical_clock: u64, pub device_id: String, pub size_bytes: u64, pub created_at: u64, } +impl SyncLatestSnapshotDocument { + pub fn head_ref(&self) -> Result { + SnapshotHeadRef::new( + self.head_revision, + self.snapshot_id.clone(), + self.payload_hash.clone(), + ) + } +} + +#[derive(Clone, Debug)] +pub enum SnapshotUploadResult { + Committed(SnapshotUploadDocument), + Conflict(SyncSnapshotHeadConflictDocument), +} + +#[derive(Clone, Debug)] +pub enum SnapshotDownloadResult { + Downloaded(SnapshotDownload), + Conflict(SyncSnapshotHeadConflictDocument), +} + +#[derive(Clone, Debug, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SyncSnapshotHeadConflictDocument { + pub version: u32, + pub error: String, + pub current_head: Option, +} + #[derive(Clone, Debug, serde::Deserialize)] pub struct SyncDeviceStatusDocument { pub approved_count: u64, @@ -230,18 +424,7 @@ fn read_json_response( response: Result, ) -> Result { match response { - Ok(ok) => { - let status = ok.status(); - let body = ok.into_string().map_err(|error| SyncClientError::HttpStatus { - endpoint: endpoint.to_string(), - status, - body: error.to_string(), - })?; - serde_json::from_str::(&body).map_err(|error| SyncClientError::Json { - endpoint: endpoint.to_string(), - source: error, - }) - } + Ok(ok) => read_json_from_response(endpoint, ok), Err(ureq::Error::Status(status, raw)) => { let body = raw.into_string().unwrap_or_default(); Err(SyncClientError::HttpStatus { endpoint: endpoint.to_string(), status, body }) @@ -251,3 +434,28 @@ fn read_json_response( } } } + +fn read_json_from_response( + endpoint: &str, + response: ureq::Response, +) -> Result { + let status = response.status(); + let body = response.into_string().map_err(|error| SyncClientError::HttpStatus { + endpoint: endpoint.to_string(), + status, + body: error.to_string(), + })?; + serde_json::from_str::(&body) + .map_err(|source| SyncClientError::Json { endpoint: endpoint.to_string(), source }) +} + +fn current_time_seconds() -> Result { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .map_err(|_| SyncClientError::DeviceTrust { reason: "system clock is invalid" }) +} + +#[cfg(test)] +#[path = "client_tests.rs"] +mod tests; diff --git a/crates/ely_sync_client/src/client_tests.rs b/crates/ely_sync_client/src/client_tests.rs new file mode 100644 index 0000000..b634b7d --- /dev/null +++ b/crates/ely_sync_client/src/client_tests.rs @@ -0,0 +1,107 @@ +use std::{ + error::Error, + io::{Read, Write}, + net::TcpListener, + thread::{self, JoinHandle}, +}; + +use crate::{ + AccountKey, ApiClientConfig, BearerToken, SnapshotCryptoContext, SnapshotDownloadResult, + SnapshotHeadRef, SnapshotPayload, SnapshotUploadRequest, SnapshotUploadResult, SyncApiClient, +}; + +type TestServer = JoinHandle>; + +#[test] +fn upload_parses_structured_snapshot_head_conflict() -> Result<(), Box> { + let (base_url, server) = spawn_conflict_server()?; + let client = SyncApiClient::new( + ApiClientConfig::custom(base_url, "auto"), + BearerToken::new("a".repeat(64))?, + )?; + let key = AccountKey::from_bytes([31; 32]); + let context = SnapshotCryptoContext { + user_id: "user-01", + vault_generation: 1, + snapshot_id: "snapshot-local", + schema_rev: 1, + logical_clock: 8, + device_id: "device-local", + head_revision: 1, + base_head: None, + }; + let encrypted = key.encrypt(&context, b"local snapshot")?; + let payload = SnapshotPayload::new(encrypted.bytes().to_vec())?; + let request = SnapshotUploadRequest::new("auto", &context, None, &encrypted, &payload)?; + + let SnapshotUploadResult::Conflict(conflict) = client.upload_snapshot(&request)? else { + return Err("snapshot upload conflict was not preserved".into()); + }; + + assert_eq!(conflict.current_head.ok_or("missing conflict head")?.head_revision, 7); + join_server(server) +} + +#[test] +fn download_parses_structured_snapshot_head_conflict() -> Result<(), Box> { + let (base_url, server) = spawn_conflict_server()?; + let client = SyncApiClient::new( + ApiClientConfig::custom(base_url, "auto"), + BearerToken::new("a".repeat(64))?, + )?; + let requested = SnapshotHeadRef::new(6, "snapshot-old", "cd".repeat(32))?; + + let SnapshotDownloadResult::Conflict(conflict) = client.download_snapshot(&requested)? else { + return Err("snapshot download conflict was not preserved".into()); + }; + + assert_eq!(conflict.current_head.ok_or("missing conflict head")?.snapshot_id, "snapshot-new"); + join_server(server) +} + +fn spawn_conflict_server() -> Result<(String, TestServer), Box> { + let listener = TcpListener::bind("127.0.0.1:0")?; + let address = listener.local_addr()?; + let body = serde_json::json!({ + "version": 1, + "error": "sync_snapshot_head_conflict", + "current_head": { + "head_revision": 7, + "base_head": { + "revision": 6, + "snapshot_id": "snapshot-old", + "payload_hash": "cd".repeat(32) + }, + "snapshot_id": "snapshot-new", + "payload_hash": "ab".repeat(32), + "encryption_version": 2, + "vault_generation": 1, + "key_id": "ef".repeat(32), + "content_hash": "12".repeat(32), + "logical_clock": 9, + "device_id": "device-remote", + "size_bytes": 256, + "created_at": 1 + } + }) + .to_string(); + let server = thread::spawn(move || -> std::io::Result<()> { + let (mut stream, _) = listener.accept()?; + let mut request = [0_u8; 16 * 1024]; + let _ = stream.read(&mut request)?; + let response = format!( + "HTTP/1.1 409 Conflict\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(response.as_bytes())?; + stream.flush() + }); + Ok((format!("http://{address}"), server)) +} + +fn join_server(server: TestServer) -> Result<(), Box> { + match server.join() { + Ok(result) => result.map_err(Into::into), + Err(_) => Err("snapshot conflict server thread panicked".into()), + } +} diff --git a/crates/ely_sync_client/src/credential_store.rs b/crates/ely_sync_client/src/credential_store.rs new file mode 100644 index 0000000..d79e485 --- /dev/null +++ b/crates/ely_sync_client/src/credential_store.rs @@ -0,0 +1,28 @@ +use keyring::{Entry, Error as KeyringError}; +use zeroize::Zeroizing; + +pub(crate) fn load_secret( + service: &str, + account: &str, +) -> Result>>, String> { + match entry(service, account)?.get_secret() { + Ok(secret) => Ok(Some(Zeroizing::new(secret))), + Err(KeyringError::NoEntry) => Ok(None), + Err(error) => Err(error.to_string()), + } +} + +pub(crate) fn save_secret(service: &str, account: &str, secret: &[u8]) -> Result<(), String> { + entry(service, account)?.set_secret(secret).map_err(|error| error.to_string()) +} + +pub(crate) fn clear_secret(service: &str, account: &str) -> Result<(), String> { + match entry(service, account)?.delete_credential() { + Ok(()) | Err(KeyringError::NoEntry) => Ok(()), + Err(error) => Err(error.to_string()), + } +} + +fn entry(service: &str, account: &str) -> Result { + Entry::new(service, account).map_err(|error| error.to_string()) +} diff --git a/crates/ely_sync_client/src/device.rs b/crates/ely_sync_client/src/device.rs index 861ae5d..9b079ed 100644 --- a/crates/ely_sync_client/src/device.rs +++ b/crates/ely_sync_client/src/device.rs @@ -4,59 +4,82 @@ use std::{ path::Path, }; -use ed25519_dalek::SigningKey; +use ed25519_dalek::{Signer, SigningKey, VerifyingKey}; +use hpke::{Deserializable, Kem, Serializable, kem::X25519HkdfSha256}; use serde::{Deserialize, Serialize}; use uuid::Uuid; +use zeroize::{Zeroize, Zeroizing}; -use crate::error::SyncClientError; +use crate::{ + device_secret_store::{DeviceSecretStore, DeviceSecrets}, + error::SyncClientError, +}; -/// Locally-stable device identity. Constructed once per profile data -/// directory and persisted so reinstalls don't trigger re-approval -/// requests — the same `device_id` is reused across runs. +const PUBLIC_KEY_BYTES: usize = 32; +const MAX_DEVICE_TEXT_CHARS: usize = 128; + +/// Public device identity persisted in the profile directory. Both private +/// keys live in the macOS data-protection Keychain under `device_id`. #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct DeviceIdentity { pub device_id: String, + /// Ed25519 verification key encoded as lowercase hex. pub public_key: String, + /// RFC 9180 X25519 recipient key encoded as lowercase hex. + pub wrapping_public_key: String, pub device_name: String, pub platform: String, } impl DeviceIdentity { - /// Load the persisted identity, or create-and-save a new one. - /// The identity file lives at `path`; callers usually pick - /// `/sync/device.json`. + /// Loads a v2 identity and its Keychain secrets. A legacy public-only + /// identity is rotated to a fresh device ID because its private key was + /// never persisted and cannot prove device continuity. pub fn load_or_create( path: &Path, device_name: impl Into, platform: impl Into, ) -> Result { + let device_name = device_name.into(); + let platform = platform.into(); match fs::read_to_string(path) { - Ok(contents) => { - let identity: Self = serde_json::from_str(&contents).map_err(|error| { - SyncClientError::TokenStorage(format!( - "device identity is corrupt at {}: {error}", - path.display() - )) - })?; - identity.validate()?; - Ok(identity) - } + Ok(contents) => match decode_stored_identity(&contents, path)? { + StoredIdentity::V2(identity) => { + identity.validate()?; + match DeviceSecretStore::new(identity.device_id.clone())?.load()? { + Some(secrets) => { + identity.validate_secrets(&secrets)?; + Ok(identity) + } + None => { + Self::create_and_save(path, identity.device_name, identity.platform) + } + } + } + StoredIdentity::Legacy(identity) => { + identity.validate()?; + Self::create_and_save(path, identity.device_name, identity.platform) + } + }, Err(error) if error.kind() == ErrorKind::NotFound => { - let identity = Self::generate(device_name, platform); - identity.save(path)?; - Ok(identity) + Self::create_and_save(path, device_name, platform) } Err(error) => Err(SyncClientError::TokenStorage(error.to_string())), } } - pub fn generate(device_name: impl Into, platform: impl Into) -> Self { - let device_id = format!("ely-{}", Uuid::now_v7().simple()); - let public_key = public_key_hex(); - Self { device_id, public_key, device_name: device_name.into(), platform: platform.into() } + pub fn generate( + device_name: impl Into, + platform: impl Into, + ) -> Result { + let (identity, secrets) = generate_key_material(device_name.into(), platform.into())?; + DeviceSecretStore::new(identity.device_id.clone())?.save(&secrets)?; + Ok(identity) } pub fn save(&self, path: &Path) -> Result<(), SyncClientError> { + self.validate()?; if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(io_err)?; } @@ -65,30 +88,180 @@ impl DeviceIdentity { SyncClientError::TokenStorage(format!("device identity serialize: {error}")) })?; fs::write(&tmp, serialized).map_err(io_err)?; - fs::rename(&tmp, path).map_err(io_err)?; + fs::rename(&tmp, path).map_err(io_err) + } + + /// Signs exact canonical bytes for device registration and rebind proofs. + pub fn sign_message(&self, message: &[u8]) -> Result { + self.validate()?; + let secrets = DeviceSecretStore::new(self.device_id.clone())?.load_required()?; + self.validate_secrets(&secrets)?; + Ok(self.sign_message_with_secrets(message, &secrets)) + } + + fn sign_message_with_secrets(&self, message: &[u8], secrets: &DeviceSecrets) -> String { + let signing_key = SigningKey::from_bytes(secrets.signing_private_key()); + hex_string(&signing_key.sign(message).to_bytes()) + } + + pub(crate) fn validate(&self) -> Result<(), SyncClientError> { + validate_common( + &self.device_id, + &self.public_key, + &self.device_name, + &self.platform, + true, + )?; + let wrapping_public_key = decode_hex_32( + &self.wrapping_public_key, + "device wrapping public key encoding is invalid", + )?; + ::PublicKey::from_bytes(&wrapping_public_key) + .map_err(|_| key_error("device wrapping public key is invalid"))?; Ok(()) } - fn validate(&self) -> Result<(), SyncClientError> { - if !is_device_id_shape(&self.device_id) { - return Err(SyncClientError::TokenStorage( - "device_id does not match the Cloudflare worker pattern".to_string(), - )); + pub(crate) fn validate_secrets(&self, secrets: &DeviceSecrets) -> Result<(), SyncClientError> { + let expected_signing_public_key = + decode_hex_32(&self.public_key, "device signing public key encoding is invalid")?; + let signing_key = SigningKey::from_bytes(secrets.signing_private_key()); + if signing_key.verifying_key().to_bytes() != expected_signing_public_key { + return Err(key_error("device signing private key does not match identity")); } - if self.public_key.trim().is_empty() { - return Err(SyncClientError::TokenStorage("device public_key is empty".to_string())); - } - if self.device_name.trim().is_empty() { - return Err(SyncClientError::TokenStorage("device_name is empty".to_string())); - } - if self.platform.trim().is_empty() { - return Err(SyncClientError::TokenStorage("platform is empty".to_string())); + + let private_key = + ::PrivateKey::from_bytes(secrets.wrapping_private_key()) + .map_err(|_| key_error("device wrapping private key is invalid"))?; + let expected_wrapping_public_key = decode_hex_32( + &self.wrapping_public_key, + "device wrapping public key encoding is invalid", + )?; + if X25519HkdfSha256::sk_to_pk(&private_key).to_bytes().as_slice() + != expected_wrapping_public_key + { + return Err(key_error("device wrapping private key does not match identity")); } Ok(()) } + + fn create_and_save( + path: &Path, + device_name: String, + platform: String, + ) -> Result { + let (identity, secrets) = generate_key_material(device_name, platform)?; + let store = DeviceSecretStore::new(identity.device_id.clone())?; + store.save(&secrets)?; + if let Err(error) = identity.save(path) { + let _ = store.clear(); + return Err(error); + } + Ok(identity) + } } -fn is_device_id_shape(value: &str) -> bool { +pub(crate) fn generate_key_material( + device_name: String, + platform: String, +) -> Result<(DeviceIdentity, DeviceSecrets), SyncClientError> { + let mut signing_private_key = Zeroizing::new([0_u8; PUBLIC_KEY_BYTES]); + getrandom::fill(signing_private_key.as_mut()) + .map_err(|_| key_error("secure randomness unavailable"))?; + let signing_key = SigningKey::from_bytes(&signing_private_key); + + let mut wrapping_ikm = Zeroizing::new([0_u8; PUBLIC_KEY_BYTES]); + getrandom::fill(wrapping_ikm.as_mut()) + .map_err(|_| key_error("secure randomness unavailable"))?; + let (wrapping_private_key, wrapping_public_key) = + X25519HkdfSha256::derive_keypair(wrapping_ikm.as_slice()); + let mut wrapping_private_bytes = wrapping_private_key.to_bytes(); + let mut stored_wrapping_private_key = [0_u8; PUBLIC_KEY_BYTES]; + stored_wrapping_private_key.copy_from_slice(&wrapping_private_bytes); + wrapping_private_bytes.zeroize(); + + let identity = DeviceIdentity { + device_id: format!("ely-{}", Uuid::now_v7().simple()), + public_key: hex_string(&signing_key.verifying_key().to_bytes()), + wrapping_public_key: hex_string(&wrapping_public_key.to_bytes()), + device_name: device_name.trim().to_string(), + platform: platform.trim().to_string(), + }; + identity.validate()?; + let secrets = DeviceSecrets::new(*signing_private_key, stored_wrapping_private_key); + identity.validate_secrets(&secrets)?; + Ok((identity, secrets)) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct LegacyDeviceIdentity { + device_id: String, + public_key: String, + device_name: String, + platform: String, +} + +impl LegacyDeviceIdentity { + fn validate(&self) -> Result<(), SyncClientError> { + validate_common(&self.device_id, &self.public_key, &self.device_name, &self.platform, false) + } +} + +enum StoredIdentity { + V2(DeviceIdentity), + Legacy(LegacyDeviceIdentity), +} + +fn decode_stored_identity(contents: &str, path: &Path) -> Result { + let value: serde_json::Value = + serde_json::from_str(contents).map_err(|error| corrupt_identity_error(path, error))?; + if value.get("wrapping_public_key").is_some() { + serde_json::from_value(value) + .map(StoredIdentity::V2) + .map_err(|error| corrupt_identity_error(path, error)) + } else { + serde_json::from_value(value) + .map(StoredIdentity::Legacy) + .map_err(|error| corrupt_identity_error(path, error)) + } +} + +fn validate_common( + device_id: &str, + signing_public_key: &str, + device_name: &str, + platform: &str, + require_canonical_text: bool, +) -> Result<(), SyncClientError> { + if !is_device_id_shape(device_id) { + return Err(key_error("device_id does not match the Cloudflare worker pattern")); + } + let signing_public_key = + decode_hex_32(signing_public_key, "device signing public key encoding is invalid")?; + VerifyingKey::from_bytes(&signing_public_key) + .map_err(|_| key_error("device signing public key is invalid"))?; + validate_device_text(device_name, "device_name is invalid", require_canonical_text)?; + validate_device_text(platform, "platform is invalid", require_canonical_text)?; + Ok(()) +} + +fn validate_device_text( + value: &str, + reason: &'static str, + require_canonical: bool, +) -> Result<(), SyncClientError> { + let trimmed = value.trim(); + if trimmed.is_empty() + || trimmed.chars().count() > MAX_DEVICE_TEXT_CHARS + || trimmed.chars().any(char::is_control) + || (require_canonical && value != trimmed) + { + return Err(key_error(reason)); + } + Ok(()) +} + +pub(crate) fn is_device_id_shape(value: &str) -> bool { (3..=128).contains(&value.len()) && value .as_bytes() @@ -96,20 +269,52 @@ fn is_device_id_shape(value: &str) -> bool { .all(|byte| matches!(byte, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'.' | b'_' | b':' | b'-')) } -fn io_err(error: io::Error) -> SyncClientError { - SyncClientError::TokenStorage(error.to_string()) +pub(crate) fn decode_hex_32( + value: &str, + reason: &'static str, +) -> Result<[u8; 32], SyncClientError> { + if value.len() != 64 { + return Err(key_error(reason)); + } + let mut bytes = [0_u8; 32]; + for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() { + bytes[index] = (hex_nibble(pair[0]).ok_or_else(|| key_error(reason))? << 4) + | hex_nibble(pair[1]).ok_or_else(|| key_error(reason))?; + } + Ok(bytes) } -fn public_key_hex() -> String { - let mut seed = [0_u8; 32]; - seed[..16].copy_from_slice(Uuid::now_v7().as_bytes()); - seed[16..].copy_from_slice(Uuid::now_v7().as_bytes()); - let signing_key = SigningKey::from_bytes(&seed); - hex_string(&signing_key.verifying_key().to_bytes()) +fn hex_nibble(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + _ => None, + } } fn hex_string(bytes: &[u8]) -> String { - bytes.iter().map(|byte| format!("{byte:02x}")).collect() + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut output = String::with_capacity(bytes.len() * 2); + for &byte in bytes { + output.push(char::from(HEX[usize::from(byte >> 4)])); + output.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + output +} + +fn key_error(message: impl Into) -> SyncClientError { + SyncClientError::DeviceKeyStorage(message.into()) +} + +fn corrupt_identity_error(path: &Path, error: serde_json::Error) -> SyncClientError { + SyncClientError::TokenStorage(format!( + "device identity is corrupt at {}: {error}", + path.display() + )) +} + +fn io_err(error: io::Error) -> SyncClientError { + SyncClientError::TokenStorage(error.to_string()) } #[derive(Clone, Debug, Serialize)] @@ -117,6 +322,8 @@ pub struct DeviceRegistration<'a> { pub version: u32, pub device_id: &'a str, pub public_key: &'a str, + pub wrapping_public_key: &'a str, + pub registration_proof: &'a str, pub device_name: &'a str, pub platform: &'a str, pub idempotency_key: &'a str, @@ -132,9 +339,12 @@ pub struct DeviceListResponse { #[derive(Clone, Debug, Deserialize)] pub struct DeviceRecord { pub device_id: String, + pub public_key: String, + pub wrapping_public_key: Option, pub device_name: String, pub platform: String, pub approval_status: String, + pub current: bool, pub created_at: u64, pub approved_at: Option, pub last_active_at: Option, @@ -150,38 +360,83 @@ impl DeviceRecord { #[cfg(test)] mod tests { use super::*; - use std::env::temp_dir; #[test] - fn identity_round_trips() -> Result<(), SyncClientError> { - let dir = temp_dir().join(format!("ely-device-{}", Uuid::now_v7().simple())); - let path = dir.join("device.json"); - let identity = DeviceIdentity::load_or_create(&path, "Test", "macos")?; - identity.validate()?; - assert_eq!(identity.public_key.len(), 64); - assert!(identity.public_key.as_bytes().iter().all(u8::is_ascii_hexdigit)); + fn generated_identity_contains_public_keys_only() -> Result<(), SyncClientError> { + let (identity, _) = generate_key_material(" Test ".to_string(), " macos ".to_string())?; + let value = serde_json::to_value(&identity).map_err(|error| { + SyncClientError::TokenStorage(format!("device identity serialize: {error}")) + })?; - let again = DeviceIdentity::load_or_create(&path, "ignored", "ignored")?; - assert_eq!(identity, again); + assert_eq!(value.as_object().map(serde_json::Map::len), Some(5)); + assert_eq!(identity.public_key.len(), 64); + assert_eq!(identity.wrapping_public_key.len(), 64); + assert_eq!(identity.device_name, "Test"); + assert_eq!(identity.platform, "macos"); + assert!(value.get("private_key").is_none()); Ok(()) } #[test] - fn device_registration_serializes_worker_schema_version() -> Result<(), SyncClientError> { + fn device_registration_serializes_v2_worker_schema() -> Result<(), SyncClientError> { let registration = DeviceRegistration { - version: 1, + version: 2, device_id: "device-01", - public_key: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + public_key: "01", + wrapping_public_key: "02", + registration_proof: "03", device_name: "MacBook Pro", platform: "macOS", idempotency_key: "device-register-device-01", }; - let value = serde_json::to_value(registration).map_err(|error| { SyncClientError::TokenStorage(format!("device registration serialize: {error}")) })?; - assert_eq!(value["version"], 1); + assert_eq!(value["version"], 2); + assert_eq!(value["wrapping_public_key"], "02"); + assert_eq!(value["registration_proof"], "03"); Ok(()) } + + #[test] + fn legacy_identity_is_detected_for_rotation() -> Result<(), SyncClientError> { + let (identity, _) = generate_key_material("Test".to_string(), "macos".to_string())?; + let legacy = serde_json::json!({ + "device_id": identity.device_id, + "public_key": identity.public_key, + "device_name": identity.device_name, + "platform": identity.platform, + }); + let stored = decode_stored_identity(&legacy.to_string(), Path::new("device.json"))?; + assert!(matches!(stored, StoredIdentity::Legacy(_))); + Ok(()) + } + + #[cfg(target_os = "macos")] + #[test] + fn legacy_identity_rotates_to_new_device_id() -> Result<(), SyncClientError> { + let dir = std::env::temp_dir().join(format!("ely-device-{}", Uuid::now_v7().simple())); + let path = dir.join("device.json"); + fs::create_dir_all(&dir).map_err(io_err)?; + let legacy_device_id = "ely-legacy-device"; + let (_, secrets) = generate_key_material("Test".to_string(), "macos".to_string())?; + let signing_key = SigningKey::from_bytes(secrets.signing_private_key()); + let legacy = serde_json::json!({ + "device_id": legacy_device_id, + "public_key": hex_string(&signing_key.verifying_key().to_bytes()), + "device_name": "Legacy", + "platform": "macos", + }); + fs::write(&path, legacy.to_string()).map_err(io_err)?; + + let result = (|| { + let identity = DeviceIdentity::load_or_create(&path, "ignored", "ignored")?; + assert_ne!(identity.device_id, legacy_device_id); + assert_eq!(DeviceIdentity::load_or_create(&path, "ignored", "ignored")?, identity); + DeviceSecretStore::new(identity.device_id)?.clear() + })(); + let _ = fs::remove_dir_all(dir); + result + } } diff --git a/crates/ely_sync_client/src/device_api.rs b/crates/ely_sync_client/src/device_api.rs new file mode 100644 index 0000000..2df00e0 --- /dev/null +++ b/crates/ely_sync_client/src/device_api.rs @@ -0,0 +1,362 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + DeviceIdentity, DeviceRecord, SyncClientError, + device_proof::{is_idempotency_key_shape, push_field}, + vault::WrappedAccountKey, +}; + +const REBIND_CHALLENGE_VERSION: u32 = 1; +const MAX_CHALLENGE_LIFETIME_SECONDS: u64 = 600; + +#[derive(Debug, Serialize)] +pub(crate) struct DeviceRebindChallengeRequest<'a> { + pub version: u32, + pub device_id: &'a str, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct DeviceRebindChallengeDocument { + pub version: u32, + pub challenge_id: String, + pub device_id: String, + pub challenge: String, + pub expires_at: u64, +} + +impl DeviceRebindChallengeDocument { + pub(crate) fn signed_request( + &self, + identity: &DeviceIdentity, + now_seconds: u64, + ) -> Result { + let _ = self.validated_context(identity, now_seconds)?; + Ok(DeviceRebindRequest { + version: REBIND_CHALLENGE_VERSION, + challenge_id: self.challenge_id.clone(), + device_id: self.device_id.clone(), + signature: identity.sign_message(self.challenge.as_bytes())?, + }) + } + + fn validated_context<'a>( + &'a self, + identity: &DeviceIdentity, + now_seconds: u64, + ) -> Result<(&'a str, &'a str), SyncClientError> { + if self.version != REBIND_CHALLENGE_VERSION || self.device_id != identity.device_id { + return Err(protocol_error("device rebind challenge identity does not match")); + } + if self.expires_at <= now_seconds + || self.expires_at > now_seconds.saturating_add(MAX_CHALLENGE_LIFETIME_SECONDS) + { + return Err(protocol_error("device rebind challenge expiry is invalid")); + } + let challenge_id = Uuid::parse_str(&self.challenge_id) + .map_err(|_| protocol_error("device rebind challenge identifier is invalid"))?; + if challenge_id.hyphenated().to_string() != self.challenge_id { + return Err(protocol_error("device rebind challenge identifier is not canonical")); + } + + let lines = self.challenge.split('\n').collect::>(); + if lines.len() != 7 || lines[0] != "elydora-device-rebind-v1" { + return Err(protocol_error("device rebind challenge format is invalid")); + } + assert_challenge_field(lines[1], "challenge_id", &self.challenge_id)?; + let user_id = challenge_value(lines[2], "user_id")?; + let session_id = challenge_value(lines[3], "session_id")?; + assert_challenge_field(lines[4], "device_id", &self.device_id)?; + assert_challenge_field(lines[5], "expires_at", &self.expires_at.to_string())?; + let nonce = challenge_value(lines[6], "nonce")?; + if !is_lower_hex(nonce, 64) || !is_subject_id(user_id) || !is_subject_id(session_id) { + return Err(protocol_error("device rebind challenge value is invalid")); + } + Ok((user_id, session_id)) + } +} + +#[derive(Debug, Serialize)] +pub(crate) struct DeviceRebindRequest { + pub version: u32, + pub challenge_id: String, + pub device_id: String, + pub signature: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DeviceRebindDocument { + pub version: u32, + pub user_id: String, + pub session_id: String, + pub device_id: String, + pub bound_at: u64, +} + +impl DeviceRebindDocument { + pub(crate) fn validate( + &self, + identity: &DeviceIdentity, + challenge: &DeviceRebindChallengeDocument, + now_seconds: u64, + ) -> Result<(), SyncClientError> { + let (user_id, session_id) = challenge.validated_context(identity, now_seconds)?; + if self.version != REBIND_CHALLENGE_VERSION + || self.device_id != identity.device_id + || self.user_id != user_id + || self.session_id != session_id + || self.bound_at > challenge.expires_at + { + return Err(protocol_error("device rebind response does not match challenge")); + } + Ok(()) + } +} + +#[derive(Debug, Serialize)] +pub struct DeviceApprovalRequest<'a> { + pub version: u32, + pub device_id: &'a str, + pub key_id: &'a str, + pub generation: u64, + pub envelope: &'a WrappedAccountKey, + pub idempotency_key: &'a str, + pub proof_created_at: u64, + pub approval_proof: String, +} + +impl<'a> DeviceApprovalRequest<'a> { + pub fn new( + user_id: &str, + approver: &DeviceIdentity, + device_id: &'a str, + key_id: &'a str, + generation: u64, + envelope: &'a WrappedAccountKey, + idempotency_key: &'a str, + ) -> Result { + let proof_created_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| protocol_error("system clock is invalid"))? + .as_secs(); + let message = approval_proof_message(&ApprovalProofFields { + user_id, + approver_device_id: &approver.device_id, + device_id, + key_id, + generation, + envelope, + idempotency_key, + proof_created_at, + })?; + Ok(Self { + version: 2, + device_id, + key_id, + generation, + envelope, + idempotency_key, + proof_created_at, + approval_proof: approver.sign_message(&message)?, + }) + } +} + +struct ApprovalProofFields<'a> { + user_id: &'a str, + approver_device_id: &'a str, + device_id: &'a str, + key_id: &'a str, + generation: u64, + envelope: &'a WrappedAccountKey, + idempotency_key: &'a str, + proof_created_at: u64, +} + +fn approval_proof_message(fields: &ApprovalProofFields<'_>) -> Result, SyncClientError> { + if fields.user_id.is_empty() + || fields.generation == 0 + || !is_idempotency_key_shape(fields.idempotency_key) + || fields.key_id.len() != 64 + { + return Err(protocol_error("device approval proof fields are invalid")); + } + fields.envelope.validate_wire()?; + let generation = fields.generation.to_string(); + let envelope_version = fields.envelope.version.to_string(); + let proof_created_at = fields.proof_created_at.to_string(); + let mut message = Vec::with_capacity(512); + for field in [ + "elydora-device-approval-v2", + fields.user_id, + fields.approver_device_id, + fields.device_id, + fields.key_id, + &generation, + &envelope_version, + &fields.envelope.suite, + &fields.envelope.encapped_key, + &fields.envelope.ciphertext, + fields.idempotency_key, + &proof_created_at, + ] { + push_field(&mut message, field); + } + Ok(message) +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DeviceApprovalDocument { + pub version: u32, + pub user_id: String, + pub approved_by_device_id: String, + pub approved_at: u64, + pub device: DeviceRecord, +} + +fn assert_challenge_field( + line: &str, + name: &'static str, + expected: &str, +) -> Result<(), SyncClientError> { + if challenge_value(line, name)? != expected { + return Err(protocol_error("device rebind challenge binding does not match")); + } + Ok(()) +} + +fn challenge_value<'a>(line: &'a str, name: &'static str) -> Result<&'a str, SyncClientError> { + let value = line + .strip_prefix(name) + .and_then(|suffix| suffix.strip_prefix('=')) + .ok_or_else(|| protocol_error("device rebind challenge field is invalid"))?; + if value.is_empty() { + return Err(protocol_error("device rebind challenge field is empty")); + } + Ok(value) +} + +fn is_subject_id(value: &str) -> bool { + (3..=128).contains(&value.len()) + && value.bytes().all(|byte| byte.is_ascii_alphanumeric() || b"._:-".contains(&byte)) +} + +fn is_lower_hex(value: &str, length: usize) -> bool { + value.len() == length + && value.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn protocol_error(reason: &'static str) -> SyncClientError { + SyncClientError::DeviceTrust { reason } +} + +#[cfg(test)] +mod tests { + use ed25519_dalek::{Signer, SigningKey}; + + use super::*; + + fn identity() -> DeviceIdentity { + DeviceIdentity { + device_id: "ely-018f0f4fbbcc7f36a241d1a2a1f01111".to_string(), + public_key: "01".repeat(32), + wrapping_public_key: "02".repeat(32), + device_name: "Test".to_string(), + platform: "macos".to_string(), + } + } + + fn challenge() -> DeviceRebindChallengeDocument { + let challenge_id = "018f0f4f-bbcc-7f36-a241-d1a2a1f01111"; + let device_id = identity().device_id; + let expires_at = 1_000; + DeviceRebindChallengeDocument { + version: 1, + challenge_id: challenge_id.to_string(), + device_id: device_id.clone(), + challenge: format!( + "elydora-device-rebind-v1\nchallenge_id={challenge_id}\nuser_id=user-01\nsession_id=session-01\ndevice_id={device_id}\nexpires_at={expires_at}\nnonce={}", + "ab".repeat(32) + ), + expires_at, + } + } + + #[test] + fn canonical_rebind_challenge_binds_device_and_session() -> Result<(), SyncClientError> { + let challenge = challenge(); + let identity = identity(); + assert_eq!(challenge.validated_context(&identity, 700)?, ("user-01", "session-01")); + Ok(()) + } + + #[test] + fn rebind_challenge_rejects_metadata_changes() { + let identity = identity(); + for changed in [ + DeviceRebindChallengeDocument { device_id: "device-02".to_string(), ..challenge() }, + DeviceRebindChallengeDocument { expires_at: 701, ..challenge() }, + DeviceRebindChallengeDocument { + challenge: challenge().challenge.replace("nonce=ab", "nonce=Ab"), + ..challenge() + }, + ] { + assert!(changed.validated_context(&identity, 700).is_err()); + } + } + + #[test] + fn approval_proof_uses_the_frozen_worker_field_order() -> Result<(), SyncClientError> { + let envelope = WrappedAccountKey { + version: 1, + suite: crate::ACCOUNT_KEY_WRAP_SUITE.to_string(), + encapped_key: "A".repeat(43), + ciphertext: "B".repeat(64), + }; + let key_id = "a".repeat(64); + let message = approval_proof_message(&ApprovalProofFields { + user_id: "user-01", + approver_device_id: "device-01", + device_id: "device-02", + key_id: &key_id, + generation: 1, + envelope: &envelope, + idempotency_key: "device-approval-0001", + proof_created_at: 1_780_000_300, + })?; + let fields = [ + "elydora-device-approval-v2".to_string(), + "user-01".to_string(), + "device-01".to_string(), + "device-02".to_string(), + "a".repeat(64), + "1".to_string(), + "1".to_string(), + crate::ACCOUNT_KEY_WRAP_SUITE.to_string(), + "A".repeat(43), + "B".repeat(64), + "device-approval-0001".to_string(), + "1780000300".to_string(), + ]; + let expected = + fields.iter().map(|field| format!("{}:{field}", field.len())).collect::(); + assert_eq!(message, expected.as_bytes()); + let private_key = crate::device::decode_hex_32( + "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60", + "test private key is invalid", + )?; + let signature = SigningKey::from_bytes(&private_key).sign(&message); + let signature_hex = + signature.to_bytes().iter().map(|byte| format!("{byte:02x}")).collect::(); + assert_eq!( + signature_hex, + "f12fb7a5f7f20551bd22d0fcf8f5787d49f6202f89e42c332c248772fd9a59c82a9d8b6ac47ea84340170fc1555fc74d70a0d6ba3541df257882d46d6d79d901" + ); + Ok(()) + } +} diff --git a/crates/ely_sync_client/src/device_proof.rs b/crates/ely_sync_client/src/device_proof.rs new file mode 100644 index 0000000..97b4a70 --- /dev/null +++ b/crates/ely_sync_client/src/device_proof.rs @@ -0,0 +1,169 @@ +use sha2::{Digest, Sha256}; + +use crate::{DeviceIdentity, DeviceRecord, SyncClientError, device::decode_hex_32}; + +const REGISTRATION_PROOF_DOMAIN: &str = "elydora-device-registration-v2"; +const VERIFICATION_CODE_DOMAIN: &str = "elydora-device-verification-v1"; + +impl DeviceIdentity { + pub fn registration_proof(&self, idempotency_key: &str) -> Result { + let message = self.registration_proof_message(idempotency_key)?; + self.sign_message(&message) + } + + pub fn verification_code(&self) -> Result { + self.validate()?; + verification_code( + &self.device_id, + &self.public_key, + &self.wrapping_public_key, + &self.device_name, + &self.platform, + ) + } + + fn registration_proof_message( + &self, + idempotency_key: &str, + ) -> Result, SyncClientError> { + self.validate()?; + if !is_idempotency_key_shape(idempotency_key) { + return Err(proof_error("device registration idempotency key is invalid")); + } + let fields = [ + REGISTRATION_PROOF_DOMAIN, + &self.device_id, + &self.public_key, + &self.wrapping_public_key, + &self.device_name, + &self.platform, + idempotency_key, + ]; + let mut message = Vec::with_capacity(512); + for field in fields { + push_field(&mut message, field); + } + Ok(message) + } +} + +impl DeviceRecord { + pub fn verification_code(&self) -> Result { + let wrapping_public_key = self + .wrapping_public_key + .as_deref() + .ok_or_else(|| proof_error("device wrapping public key is unavailable"))?; + verification_code( + &self.device_id, + &self.public_key, + wrapping_public_key, + &self.device_name, + &self.platform, + ) + } +} + +fn verification_code( + device_id: &str, + public_key: &str, + wrapping_public_key: &str, + device_name: &str, + platform: &str, +) -> Result { + if !(3..=128).contains(&device_id.len()) + || !device_id.bytes().all(|byte| byte.is_ascii_alphanumeric() || b"._:-".contains(&byte)) + { + return Err(proof_error("device identifier is invalid")); + } + decode_hex_32(public_key, "device signing public key encoding is invalid")?; + decode_hex_32(wrapping_public_key, "device wrapping public key encoding is invalid")?; + let fields = [ + VERIFICATION_CODE_DOMAIN, + device_id, + public_key, + wrapping_public_key, + device_name, + platform, + ]; + let mut message = Vec::with_capacity(512); + for field in fields { + push_field(&mut message, field); + } + let digest = Sha256::digest(message); + Ok(digest[..8] + .chunks_exact(2) + .map(|chunk| format!("{:02X}{:02X}", chunk[0], chunk[1])) + .collect::>() + .join("-")) +} + +pub(crate) fn is_idempotency_key_shape(value: &str) -> bool { + (16..=128).contains(&value.len()) + && value + .as_bytes() + .iter() + .all(|byte| matches!(byte, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'.' | b'_' | b':' | b'-')) +} + +pub(crate) fn push_field(message: &mut Vec, value: &str) { + message.extend_from_slice(value.len().to_string().as_bytes()); + message.push(b':'); + message.extend_from_slice(value.as_bytes()); +} + +fn proof_error(message: impl Into) -> SyncClientError { + SyncClientError::DeviceKeyStorage(message.into()) +} + +#[cfg(test)] +mod tests { + use ed25519_dalek::{Signer, SigningKey, Verifier}; + + use super::*; + use crate::device::generate_key_material; + + #[test] + fn registration_proof_matches_worker_canonical_bytes() -> Result<(), SyncClientError> { + let (identity, secrets) = generate_key_material("ELY ñ".to_string(), "macOS".to_string())?; + let idempotency_key = "device-register:01"; + let message = identity.registration_proof_message(idempotency_key)?; + let fields = [ + REGISTRATION_PROOF_DOMAIN, + &identity.device_id, + &identity.public_key, + &identity.wrapping_public_key, + &identity.device_name, + &identity.platform, + idempotency_key, + ]; + let expected = + fields.iter().map(|field| format!("{}:{field}", field.len())).collect::(); + assert_eq!(message, expected.as_bytes()); + + let signing_key = SigningKey::from_bytes(secrets.signing_private_key()); + let signature = signing_key.sign(&message); + signing_key + .verifying_key() + .verify(&message, &signature) + .map_err(|_| proof_error("device registration proof verification failed")) + } + + #[test] + fn verification_code_binds_every_public_identity_field() -> Result<(), SyncClientError> { + let (identity, _) = generate_key_material("ELY ñ".to_string(), "macOS".to_string())?; + let code = identity.verification_code()?; + assert_eq!(code.len(), 19); + + let changed = [ + DeviceIdentity { device_id: "device-02".to_string(), ..identity.clone() }, + DeviceIdentity { public_key: "01".repeat(32), ..identity.clone() }, + DeviceIdentity { wrapping_public_key: "02".repeat(32), ..identity.clone() }, + DeviceIdentity { device_name: "Other".to_string(), ..identity.clone() }, + DeviceIdentity { platform: "linux".to_string(), ..identity.clone() }, + ]; + for candidate in changed { + assert_ne!(candidate.verification_code()?, code); + } + Ok(()) + } +} diff --git a/crates/ely_sync_client/src/device_revocation.rs b/crates/ely_sync_client/src/device_revocation.rs new file mode 100644 index 0000000..4c04d87 --- /dev/null +++ b/crates/ely_sync_client/src/device_revocation.rs @@ -0,0 +1,380 @@ +use serde::{Deserialize, Serialize}; + +use crate::{ + DeviceIdentity, DeviceRecord, SyncClientError, + device::is_device_id_shape, + device_proof::{is_idempotency_key_shape, push_field}, + vault::WrappedAccountKey, +}; + +const REVOCATION_PROOF_DOMAIN: &str = "elydora-device-revocation-v2"; +const PENDING_REVOCATION_PROOF_DOMAIN: &str = "elydora-pending-device-revocation-v2"; +const MAX_ROTATION_ENVELOPES: usize = 128; +const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991; + +#[derive(Clone, Debug, Serialize)] +struct DeviceRevocationEnvelope { + recipient_device_id: String, + envelope: WrappedAccountKey, +} + +#[derive(Clone, Debug, Serialize)] +pub struct ApprovedDeviceRevocationRequest { + version: u32, + mode: &'static str, + device_id: String, + previous_key_id: String, + previous_generation: u64, + new_key_id: String, + new_generation: u64, + envelopes: Vec, + idempotency_key: String, + rotation_proof: String, +} + +#[derive(Clone, Debug, Serialize)] +pub struct PendingDeviceRevocationRequest { + version: u32, + mode: &'static str, + device_id: String, + idempotency_key: String, + pending_revocation_proof: String, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(untagged)] +pub enum DeviceRevocationRequest { + ApprovedRotate(ApprovedDeviceRevocationRequest), + PendingRevoke(PendingDeviceRevocationRequest), +} + +impl DeviceRevocationRequest { + #[allow(clippy::too_many_arguments)] + pub fn approved_rotation( + user_id: &str, + approver: &DeviceIdentity, + target_device_id: &str, + previous_key_id: &str, + previous_generation: u64, + new_key_id: &str, + new_generation: u64, + envelopes: Vec<(String, WrappedAccountKey)>, + idempotency_key: &str, + ) -> Result { + let envelopes = validate_and_sort_envelopes(envelopes, target_device_id)?; + validate_request_fields( + user_id, + approver, + target_device_id, + previous_key_id, + previous_generation, + new_key_id, + new_generation, + idempotency_key, + )?; + let message = revocation_proof_message( + user_id, + approver, + target_device_id, + previous_key_id, + previous_generation, + new_key_id, + new_generation, + &envelopes, + idempotency_key, + ); + Ok(Self::ApprovedRotate(ApprovedDeviceRevocationRequest { + version: 2, + mode: "approved_rotate", + device_id: target_device_id.to_string(), + previous_key_id: previous_key_id.to_string(), + previous_generation, + new_key_id: new_key_id.to_string(), + new_generation, + envelopes, + idempotency_key: idempotency_key.to_string(), + rotation_proof: approver.sign_message(&message)?, + })) + } + + pub fn pending( + user_id: &str, + approver: &DeviceIdentity, + target_device_id: &str, + idempotency_key: &str, + ) -> Result { + validate_pending_fields(user_id, approver, target_device_id, idempotency_key)?; + let message = pending_revocation_proof_message( + user_id, + &approver.device_id, + target_device_id, + idempotency_key, + ); + Ok(Self::PendingRevoke(PendingDeviceRevocationRequest { + version: 2, + mode: "pending_revoke", + device_id: target_device_id.to_string(), + idempotency_key: idempotency_key.to_string(), + pending_revocation_proof: approver.sign_message(&message)?, + })) + } +} + +fn pending_revocation_proof_message( + user_id: &str, + approver_device_id: &str, + target_device_id: &str, + idempotency_key: &str, +) -> Vec { + let mut message = Vec::with_capacity(256); + for field in [ + PENDING_REVOCATION_PROOF_DOMAIN, + user_id, + approver_device_id, + target_device_id, + idempotency_key, + ] { + push_field(&mut message, field); + } + message +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)] +pub enum DeviceRevocationDocument { + ApprovedRotate { + version: u32, + user_id: String, + revoked_by_device_id: String, + revoked_at: u64, + key_id: String, + generation: u64, + device: DeviceRecord, + }, + PendingRevoke { + version: u32, + user_id: String, + revoked_by_device_id: String, + revoked_at: u64, + device: DeviceRecord, + }, +} + +#[allow(clippy::too_many_arguments)] +fn validate_request_fields( + user_id: &str, + approver: &DeviceIdentity, + target_device_id: &str, + previous_key_id: &str, + previous_generation: u64, + new_key_id: &str, + new_generation: u64, + idempotency_key: &str, +) -> Result<(), SyncClientError> { + validate_pending_fields(user_id, approver, target_device_id, idempotency_key)?; + if !is_key_id(previous_key_id) || !is_key_id(new_key_id) || previous_key_id == new_key_id { + return Err(protocol_error("device revocation key identifier is invalid")); + } + if previous_generation == 0 + || previous_generation >= MAX_SAFE_INTEGER + || new_generation != previous_generation + 1 + { + return Err(protocol_error("device revocation generation is invalid")); + } + Ok(()) +} + +fn validate_pending_fields( + user_id: &str, + approver: &DeviceIdentity, + target_device_id: &str, + idempotency_key: &str, +) -> Result<(), SyncClientError> { + if user_id.trim().is_empty() || user_id.len() > 4096 { + return Err(protocol_error("device revocation user identifier is invalid")); + } + approver.validate()?; + if !is_device_id_shape(target_device_id) || target_device_id == approver.device_id { + return Err(protocol_error("device revocation target is invalid")); + } + if !is_idempotency_key_shape(idempotency_key) { + return Err(protocol_error("device revocation idempotency key is invalid")); + } + Ok(()) +} + +fn validate_and_sort_envelopes( + envelopes: Vec<(String, WrappedAccountKey)>, + target_device_id: &str, +) -> Result, SyncClientError> { + if envelopes.is_empty() || envelopes.len() > MAX_ROTATION_ENVELOPES { + return Err(protocol_error("device revocation envelope count is invalid")); + } + let mut envelopes = envelopes + .into_iter() + .map(|(recipient_device_id, envelope)| { + if !is_device_id_shape(&recipient_device_id) || recipient_device_id == target_device_id + { + return Err(protocol_error("device revocation envelope recipient is invalid")); + } + envelope.validate_wire()?; + Ok(DeviceRevocationEnvelope { recipient_device_id, envelope }) + }) + .collect::, SyncClientError>>()?; + envelopes.sort_by(|left, right| left.recipient_device_id.cmp(&right.recipient_device_id)); + if envelopes.windows(2).any(|pair| pair[0].recipient_device_id == pair[1].recipient_device_id) { + return Err(protocol_error("device revocation envelope recipient is duplicated")); + } + Ok(envelopes) +} + +#[allow(clippy::too_many_arguments)] +fn revocation_proof_message( + user_id: &str, + approver: &DeviceIdentity, + target_device_id: &str, + previous_key_id: &str, + previous_generation: u64, + new_key_id: &str, + new_generation: u64, + envelopes: &[DeviceRevocationEnvelope], + idempotency_key: &str, +) -> Vec { + let previous_generation = previous_generation.to_string(); + let new_generation = new_generation.to_string(); + let envelope_count = envelopes.len().to_string(); + let fields = [ + REVOCATION_PROOF_DOMAIN, + user_id, + &approver.device_id, + target_device_id, + previous_key_id, + &previous_generation, + new_key_id, + &new_generation, + idempotency_key, + &envelope_count, + ]; + let mut message = Vec::with_capacity(1024); + for field in fields { + push_field(&mut message, field); + } + for item in envelopes { + let envelope_version = item.envelope.version.to_string(); + for field in [ + item.recipient_device_id.as_str(), + &envelope_version, + &item.envelope.suite, + &item.envelope.encapped_key, + &item.envelope.ciphertext, + ] { + push_field(&mut message, field); + } + } + message +} + +fn is_key_id(value: &str) -> bool { + value.len() == 64 + && value.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn protocol_error(reason: &'static str) -> SyncClientError { + SyncClientError::DeviceTrust { reason } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + AccountKey, VaultContext, device::generate_key_material, vault::WrappedAccountKey, + }; + + #[test] + fn pending_proof_matches_worker_vector() { + let message = pending_revocation_proof_message( + "user-01", + "device-01", + "device-02", + "device-revocation-0001", + ); + assert_eq!( + message, + b"36:elydora-pending-device-revocation-v27:user-019:device-019:device-0222:device-revocation-0001" + ); + } + + #[test] + fn proof_matches_worker_order_and_canonical_bytes() -> Result<(), SyncClientError> { + let (approver, _) = generate_key_material("Approver".to_string(), "macos".to_string())?; + let (recipient_b, _) = generate_key_material("B".to_string(), "macos".to_string())?; + let (recipient_a, _) = generate_key_material("A".to_string(), "macos".to_string())?; + let previous_key = AccountKey::from_bytes([41; 32]); + let new_key = AccountKey::from_bytes([43; 32]); + let new_key_id = new_key.key_id(); + let envelopes = validate_and_sort_envelopes( + vec![ + wrapped(&new_key, &new_key_id, &approver, &recipient_b)?, + wrapped(&new_key, &new_key_id, &approver, &recipient_a)?, + ], + "device-target", + )?; + let message = revocation_proof_message( + "user-01", + &approver, + "device-target", + &previous_key.key_id(), + 1, + &new_key_id, + 2, + &envelopes, + "device-revocation:01", + ); + assert!(envelopes[0].recipient_device_id < envelopes[1].recipient_device_id); + let mut fields = vec![ + REVOCATION_PROOF_DOMAIN.to_string(), + "user-01".to_string(), + approver.device_id, + "device-target".to_string(), + previous_key.key_id(), + "1".to_string(), + new_key_id, + "2".to_string(), + "device-revocation:01".to_string(), + envelopes.len().to_string(), + ]; + for item in envelopes { + fields.extend([ + item.recipient_device_id, + item.envelope.version.to_string(), + item.envelope.suite, + item.envelope.encapped_key, + item.envelope.ciphertext, + ]); + } + let expected = + fields.iter().map(|field| format!("{}:{field}", field.len())).collect::(); + assert_eq!(message, expected.as_bytes()); + Ok(()) + } + + fn wrapped( + key: &AccountKey, + key_id: &str, + approver: &DeviceIdentity, + recipient: &DeviceIdentity, + ) -> Result<(String, WrappedAccountKey), SyncClientError> { + let envelope = WrappedAccountKey::wrap( + key, + &VaultContext { + user_id: "user-01", + recipient_device_id: &recipient.device_id, + recipient_wrapping_public_key: &recipient.wrapping_public_key, + approver_device_id: &approver.device_id, + generation: 2, + key_id, + }, + )?; + Ok((recipient.device_id.clone(), envelope)) + } +} diff --git a/crates/ely_sync_client/src/device_secret_store.rs b/crates/ely_sync_client/src/device_secret_store.rs new file mode 100644 index 0000000..37d40d4 --- /dev/null +++ b/crates/ely_sync_client/src/device_secret_store.rs @@ -0,0 +1,121 @@ +use zeroize::{Zeroize, Zeroizing}; + +use crate::{ + SyncClientError, + credential_store::{clear_secret, load_secret, save_secret}, + device::is_device_id_shape, +}; + +const KEYCHAIN_SERVICE: &str = "com.elydora.ely-browser.sync.device-secrets.v2"; +const RECORD_VERSION: u8 = 2; +const SECRET_BYTES: usize = 32; +const RECORD_BYTES: usize = 1 + 2 * SECRET_BYTES; + +#[derive(Clone, Debug)] +pub struct DeviceSecretStore { + device_id: String, +} + +impl DeviceSecretStore { + pub fn new(device_id: impl Into) -> Result { + let device_id = device_id.into(); + if !is_device_id_shape(&device_id) { + return Err(storage_error("device identifier is invalid")); + } + Ok(Self { device_id }) + } + + pub(crate) fn load(&self) -> Result, SyncClientError> { + match load_secret(KEYCHAIN_SERVICE, &self.device_id).map_err(storage_error)? { + Some(record) => decode_secret_record(record).map(Some), + None => Ok(None), + } + } + + pub(crate) fn load_required(&self) -> Result { + self.load()?.ok_or_else(|| SyncClientError::DeviceKeyUnavailable { + device_id: self.device_id.clone(), + }) + } + + pub(crate) fn save(&self, secrets: &DeviceSecrets) -> Result<(), SyncClientError> { + let record = encode_secret_record(secrets); + save_secret(KEYCHAIN_SERVICE, &self.device_id, record.as_slice()).map_err(storage_error) + } + + pub fn clear(&self) -> Result<(), SyncClientError> { + clear_secret(KEYCHAIN_SERVICE, &self.device_id).map_err(storage_error) + } +} + +pub(crate) struct DeviceSecrets { + signing_private_key: Zeroizing<[u8; SECRET_BYTES]>, + wrapping_private_key: Zeroizing<[u8; SECRET_BYTES]>, +} + +impl DeviceSecrets { + pub(crate) fn new( + signing_private_key: [u8; SECRET_BYTES], + wrapping_private_key: [u8; SECRET_BYTES], + ) -> Self { + Self { + signing_private_key: Zeroizing::new(signing_private_key), + wrapping_private_key: Zeroizing::new(wrapping_private_key), + } + } + + pub(crate) fn signing_private_key(&self) -> &[u8; SECRET_BYTES] { + &self.signing_private_key + } + + pub(crate) fn wrapping_private_key(&self) -> &[u8; SECRET_BYTES] { + &self.wrapping_private_key + } +} + +fn encode_secret_record(secrets: &DeviceSecrets) -> Zeroizing<[u8; RECORD_BYTES]> { + let mut record = Zeroizing::new([0_u8; RECORD_BYTES]); + record[0] = RECORD_VERSION; + record[1..1 + SECRET_BYTES].copy_from_slice(secrets.signing_private_key()); + record[1 + SECRET_BYTES..].copy_from_slice(secrets.wrapping_private_key()); + record +} + +fn decode_secret_record(mut record: Zeroizing>) -> Result { + if record.len() != RECORD_BYTES || record[0] != RECORD_VERSION { + return Err(storage_error("device secret record is invalid")); + } + let mut signing_private_key = [0_u8; SECRET_BYTES]; + let mut wrapping_private_key = [0_u8; SECRET_BYTES]; + signing_private_key.copy_from_slice(&record[1..1 + SECRET_BYTES]); + wrapping_private_key.copy_from_slice(&record[1 + SECRET_BYTES..]); + record.zeroize(); + Ok(DeviceSecrets::new(signing_private_key, wrapping_private_key)) +} + +fn storage_error(message: impl Into) -> SyncClientError { + SyncClientError::DeviceKeyStorage(message.into()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn secret_record_round_trips_both_private_keys() -> Result<(), SyncClientError> { + let secrets = DeviceSecrets::new([7; SECRET_BYTES], [19; SECRET_BYTES]); + let record = encode_secret_record(&secrets); + let decoded = decode_secret_record(Zeroizing::new(record.to_vec()))?; + + assert_eq!(decoded.signing_private_key(), &[7; SECRET_BYTES]); + assert_eq!(decoded.wrapping_private_key(), &[19; SECRET_BYTES]); + Ok(()) + } + + #[test] + fn secret_record_rejects_unknown_versions() { + let mut record = vec![0_u8; RECORD_BYTES]; + record[0] = RECORD_VERSION + 1; + assert!(decode_secret_record(Zeroizing::new(record)).is_err()); + } +} diff --git a/crates/ely_sync_client/src/encryption.rs b/crates/ely_sync_client/src/encryption.rs new file mode 100644 index 0000000..3c4c789 --- /dev/null +++ b/crates/ely_sync_client/src/encryption.rs @@ -0,0 +1,371 @@ +use std::fmt; + +use chacha20poly1305::{ + XChaCha20Poly1305, XNonce, + aead::{Aead, KeyInit, Payload}, +}; +use hkdf::Hkdf; +use hmac::{Hmac, Mac}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use zeroize::Zeroizing; + +use crate::{error::SyncClientError, snapshot::MAX_SNAPSHOT_BYTES}; + +pub const SNAPSHOT_ENCRYPTION_VERSION: u32 = 2; + +const ENVELOPE_MAGIC: &[u8; 8] = b"ELYSYNC\0"; +const ENVELOPE_VERSION: u8 = 1; +const ALGORITHM_XCHACHA20_POLY1305: u8 = 1; +const KEY_BYTES: usize = 32; +const HASH_BYTES: usize = 32; +const NONCE_BYTES: usize = 24; +const TAG_BYTES: usize = 16; +const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991; +const HEADER_BYTES: usize = ENVELOPE_MAGIC.len() + 2 + KEY_BYTES + HASH_BYTES + NONCE_BYTES; +const MAX_PLAINTEXT_BYTES: usize = MAX_SNAPSHOT_BYTES - HEADER_BYTES - TAG_BYTES; +const HKDF_SALT: &[u8] = b"ely-sync-account-key-v1"; +const ENCRYPTION_KEY_INFO: &[u8] = b"snapshot-encryption-key"; +const CONTENT_KEY_INFO: &[u8] = b"snapshot-content-authentication-key"; +const KEY_ID_DOMAIN: &[u8] = b"ely-sync-key-id-v1\0"; +const AAD_DOMAIN_V1: &[u8] = b"ely-sync-snapshot-aad-v1\0"; +const AAD_DOMAIN_V2: &[u8] = b"ely-sync-snapshot-aad-v2\0"; + +type HmacSha256 = Hmac; + +#[derive(Clone)] +pub struct AccountKey(Zeroizing<[u8; KEY_BYTES]>); + +impl AccountKey { + pub fn generate() -> Result { + let mut bytes = Zeroizing::new([0_u8; KEY_BYTES]); + getrandom::fill(bytes.as_mut()) + .map_err(|_| encryption_error("secure randomness unavailable"))?; + Ok(Self::from_secret(bytes)) + } + + pub fn from_bytes(bytes: [u8; KEY_BYTES]) -> Self { + Self::from_secret(Zeroizing::new(bytes)) + } + + pub fn key_id(&self) -> String { + let mut hasher = Sha256::new(); + hasher.update(KEY_ID_DOMAIN); + hasher.update(self.0.as_slice()); + hex_string(&hasher.finalize()) + } + + pub fn content_hash(&self, plaintext: &[u8]) -> Result { + let key = self.derived_key(CONTENT_KEY_INFO)?; + let mut mac = ::new_from_slice(key.as_slice()) + .map_err(|_| encryption_error("content authentication key is invalid"))?; + mac.update(plaintext); + Ok(hex_string(&mac.finalize().into_bytes())) + } + + pub fn encrypt( + &self, + context: &SnapshotCryptoContext<'_>, + plaintext: &[u8], + ) -> Result { + self.encrypt_with_version(context, plaintext, SNAPSHOT_ENCRYPTION_VERSION) + } + + fn encrypt_with_version( + &self, + context: &SnapshotCryptoContext<'_>, + plaintext: &[u8], + encryption_version: u32, + ) -> Result { + if !matches!(encryption_version, 1 | SNAPSHOT_ENCRYPTION_VERSION) { + return Err(encryption_error("snapshot encryption version is unsupported")); + } + if plaintext.is_empty() || plaintext.len() > MAX_PLAINTEXT_BYTES { + return Err(SyncClientError::SnapshotTooLarge { + bytes: plaintext.len(), + limit: MAX_PLAINTEXT_BYTES, + }); + } + + let key_id = self.key_id(); + let content_hash = self.content_hash(plaintext)?; + let key_id_bytes = decode_hex_32(&key_id)?; + let content_hash_bytes = decode_hex_32(&content_hash)?; + let aad = snapshot_aad(context, encryption_version, &key_id_bytes, &content_hash_bytes)?; + let encryption_key = self.derived_key(ENCRYPTION_KEY_INFO)?; + let cipher = XChaCha20Poly1305::new_from_slice(encryption_key.as_slice()) + .map_err(|_| encryption_error("snapshot encryption key is invalid"))?; + let mut nonce = [0_u8; NONCE_BYTES]; + getrandom::fill(&mut nonce) + .map_err(|_| encryption_error("secure randomness unavailable"))?; + let nonce = nonce_ref(&nonce)?; + let ciphertext = cipher + .encrypt(nonce, Payload { msg: plaintext, aad: &aad }) + .map_err(|_| encryption_error("snapshot encryption failed"))?; + + let mut bytes = Vec::with_capacity(HEADER_BYTES + ciphertext.len()); + bytes.extend_from_slice(ENVELOPE_MAGIC); + bytes.push(ENVELOPE_VERSION); + bytes.push(ALGORITHM_XCHACHA20_POLY1305); + bytes.extend_from_slice(&key_id_bytes); + bytes.extend_from_slice(&content_hash_bytes); + bytes.extend_from_slice(nonce); + bytes.extend_from_slice(&ciphertext); + + Ok(EncryptedSnapshot { bytes, key_id, content_hash }) + } + + pub fn decrypt( + &self, + context: &SnapshotCryptoContext<'_>, + encryption_version: u32, + expected_key_id: &str, + expected_content_hash: &str, + envelope: &[u8], + ) -> Result, SyncClientError> { + if !matches!(encryption_version, 1 | SNAPSHOT_ENCRYPTION_VERSION) { + return Err(encryption_error("snapshot encryption version is unsupported")); + } + if envelope.len() < HEADER_BYTES + TAG_BYTES { + return Err(encryption_error("snapshot envelope is truncated")); + } + if &envelope[..ENVELOPE_MAGIC.len()] != ENVELOPE_MAGIC { + return Err(encryption_error("snapshot envelope magic is invalid")); + } + if envelope[ENVELOPE_MAGIC.len()] != ENVELOPE_VERSION + || envelope[ENVELOPE_MAGIC.len() + 1] != ALGORITHM_XCHACHA20_POLY1305 + { + return Err(encryption_error("snapshot envelope algorithm is unsupported")); + } + + let mut offset = ENVELOPE_MAGIC.len() + 2; + let key_id_bytes = array_at::(envelope, offset)?; + offset += KEY_BYTES; + let content_hash_bytes = array_at::(envelope, offset)?; + offset += HASH_BYTES; + let nonce = array_at::(envelope, offset)?; + offset += NONCE_BYTES; + + let key_id = hex_string(&key_id_bytes); + let content_hash = hex_string(&content_hash_bytes); + if key_id != expected_key_id || key_id != self.key_id() { + return Err(encryption_error("snapshot key identifier does not match")); + } + if content_hash != expected_content_hash { + return Err(encryption_error("snapshot content hash does not match")); + } + + let aad = snapshot_aad(context, encryption_version, &key_id_bytes, &content_hash_bytes)?; + let encryption_key = self.derived_key(ENCRYPTION_KEY_INFO)?; + let cipher = XChaCha20Poly1305::new_from_slice(encryption_key.as_slice()) + .map_err(|_| encryption_error("snapshot encryption key is invalid"))?; + let nonce = nonce_ref(&nonce)?; + let plaintext = cipher + .decrypt(nonce, Payload { msg: &envelope[offset..], aad: &aad }) + .map_err(|_| encryption_error("snapshot authentication failed"))?; + if self.content_hash(&plaintext)? != content_hash { + return Err(encryption_error("snapshot plaintext authentication failed")); + } + Ok(plaintext) + } + + pub(crate) fn bytes(&self) -> &[u8; KEY_BYTES] { + &self.0 + } + + pub(crate) fn from_secret(bytes: Zeroizing<[u8; KEY_BYTES]>) -> Self { + Self(bytes) + } + + fn derived_key(&self, info: &[u8]) -> Result, SyncClientError> { + let hkdf = Hkdf::::new(Some(HKDF_SALT), self.0.as_slice()); + let mut output = Zeroizing::new([0_u8; KEY_BYTES]); + hkdf.expand(info, output.as_mut()) + .map_err(|_| encryption_error("account key derivation failed"))?; + Ok(output) + } +} + +impl fmt::Debug for AccountKey { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.debug_tuple("AccountKey").field(&"[REDACTED]").finish() + } +} + +#[derive(Clone, Copy, Debug)] +pub struct SnapshotCryptoContext<'a> { + pub user_id: &'a str, + pub vault_generation: u64, + pub snapshot_id: &'a str, + pub schema_rev: u32, + pub logical_clock: u64, + pub device_id: &'a str, + pub head_revision: u64, + pub base_head: Option<&'a SnapshotHeadRef>, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SnapshotHeadRef { + pub(crate) revision: u64, + pub(crate) snapshot_id: String, + pub(crate) payload_hash: String, +} + +impl SnapshotHeadRef { + pub(crate) fn new( + revision: u64, + snapshot_id: impl Into, + payload_hash: impl Into, + ) -> Result { + let head = + Self { revision, snapshot_id: snapshot_id.into(), payload_hash: payload_hash.into() }; + head.validate()?; + Ok(head) + } + + pub fn revision(&self) -> u64 { + self.revision + } + + pub fn snapshot_id(&self) -> &str { + &self.snapshot_id + } + + pub fn payload_hash(&self) -> &str { + &self.payload_hash + } + + fn validate(&self) -> Result<(), SyncClientError> { + if self.revision == 0 + || self.revision > MAX_SAFE_INTEGER + || self.snapshot_id.is_empty() + || self.snapshot_id.len() > 128 + || !self.snapshot_id.as_bytes()[0].is_ascii_lowercase() + && !self.snapshot_id.as_bytes()[0].is_ascii_digit() + || !self.snapshot_id.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || b"._-".contains(&byte) + }) + || decode_hex_32(&self.payload_hash).is_err() + { + return Err(encryption_error("snapshot head reference is invalid")); + } + Ok(()) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EncryptedSnapshot { + bytes: Vec, + key_id: String, + content_hash: String, +} + +impl EncryptedSnapshot { + pub fn bytes(&self) -> &[u8] { + &self.bytes + } + + pub fn key_id(&self) -> &str { + &self.key_id + } + + pub fn content_hash(&self) -> &str { + &self.content_hash + } +} + +fn snapshot_aad( + context: &SnapshotCryptoContext<'_>, + encryption_version: u32, + key_id: &[u8; KEY_BYTES], + content_hash: &[u8; HASH_BYTES], +) -> Result, SyncClientError> { + let domain = if encryption_version == 1 { AAD_DOMAIN_V1 } else { AAD_DOMAIN_V2 }; + let mut aad = Vec::with_capacity(domain.len() + 3 * 130 + 128); + aad.extend_from_slice(domain); + push_text(&mut aad, context.user_id)?; + aad.extend_from_slice(&context.vault_generation.to_be_bytes()); + push_text(&mut aad, context.snapshot_id)?; + aad.extend_from_slice(&context.schema_rev.to_be_bytes()); + aad.extend_from_slice(&context.logical_clock.to_be_bytes()); + push_text(&mut aad, context.device_id)?; + aad.extend_from_slice(key_id); + aad.extend_from_slice(content_hash); + if encryption_version == SNAPSHOT_ENCRYPTION_VERSION { + push_head_lineage(&mut aad, context)?; + } + Ok(aad) +} + +fn push_head_lineage( + aad: &mut Vec, + context: &SnapshotCryptoContext<'_>, +) -> Result<(), SyncClientError> { + if context.head_revision == 0 { + return Err(encryption_error("snapshot head revision is invalid")); + } + aad.extend_from_slice(&context.head_revision.to_be_bytes()); + match context.base_head { + None if context.head_revision == 1 => aad.push(0), + Some(base) if base.revision.checked_add(1) == Some(context.head_revision) => { + base.validate()?; + aad.push(1); + aad.extend_from_slice(&base.revision.to_be_bytes()); + push_text(aad, &base.snapshot_id)?; + aad.extend_from_slice(&decode_hex_32(&base.payload_hash)?); + } + _ => return Err(encryption_error("snapshot head lineage is invalid")), + } + Ok(()) +} + +fn push_text(output: &mut Vec, value: &str) -> Result<(), SyncClientError> { + let length = u16::try_from(value.len()) + .map_err(|_| encryption_error("snapshot authenticated metadata is too long"))?; + output.extend_from_slice(&length.to_be_bytes()); + output.extend_from_slice(value.as_bytes()); + Ok(()) +} + +fn array_at(bytes: &[u8], offset: usize) -> Result<[u8; N], SyncClientError> { + bytes + .get(offset..offset + N) + .and_then(|slice| slice.try_into().ok()) + .ok_or_else(|| encryption_error("snapshot envelope is truncated")) +} + +fn nonce_ref(bytes: &[u8; NONCE_BYTES]) -> Result<&XNonce, SyncClientError> { + bytes.as_slice().try_into().map_err(|_| encryption_error("snapshot nonce is invalid")) +} + +fn decode_hex_32(value: &str) -> Result<[u8; 32], SyncClientError> { + if value.len() != 64 { + return Err(encryption_error("snapshot hash encoding is invalid")); + } + let mut bytes = [0_u8; 32]; + for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() { + bytes[index] = (hex_nibble(pair[0])? << 4) | hex_nibble(pair[1])?; + } + Ok(bytes) +} + +fn hex_nibble(byte: u8) -> Result { + match byte { + b'0'..=b'9' => Ok(byte - b'0'), + b'a'..=b'f' => Ok(byte - b'a' + 10), + _ => Err(encryption_error("snapshot hash encoding is invalid")), + } +} + +fn hex_string(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +fn encryption_error(reason: &'static str) -> SyncClientError { + SyncClientError::SnapshotEncryption { reason } +} + +#[cfg(test)] +#[path = "encryption_tests.rs"] +mod tests; diff --git a/crates/ely_sync_client/src/encryption_tests.rs b/crates/ely_sync_client/src/encryption_tests.rs new file mode 100644 index 0000000..0842c29 --- /dev/null +++ b/crates/ely_sync_client/src/encryption_tests.rs @@ -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(()) +} diff --git a/crates/ely_sync_client/src/error.rs b/crates/ely_sync_client/src/error.rs index 3a13e08..01ac7fe 100644 --- a/crates/ely_sync_client/src/error.rs +++ b/crates/ely_sync_client/src/error.rs @@ -34,6 +34,30 @@ pub enum SyncClientError { #[error("Snapshot schema is invalid: {0}")] SnapshotSchema(String), + #[error("Snapshot encryption failed: {reason}")] + SnapshotEncryption { reason: &'static str }, + + #[error("Cloud Sync snapshot head is changing; retry shortly")] + SnapshotBusy, + + #[error("Sync account key storage is unavailable: {0}")] + AccountKeyStorage(String), + + #[error("Sync account key is unavailable for encrypted cloud data")] + AccountKeyUnavailable, + + #[error("Device private key storage is unavailable: {0}")] + DeviceKeyStorage(String), + + #[error("Device private keys are unavailable for {device_id}")] + DeviceKeyUnavailable { device_id: String }, + + #[error("Device trust protocol failed: {reason}")] + DeviceTrust { reason: &'static str }, + + #[error("Sync account key vault operation failed: {reason}")] + VaultCrypto { reason: &'static str }, + #[error("Sync policy blocks this operation: {reason}")] SyncPolicy { reason: String }, diff --git a/crates/ely_sync_client/src/key_store.rs b/crates/ely_sync_client/src/key_store.rs new file mode 100644 index 0000000..c0f0666 --- /dev/null +++ b/crates/ely_sync_client/src/key_store.rs @@ -0,0 +1,310 @@ +use std::{ + collections::BTreeMap, + fs::{File, OpenOptions}, + path::{Path, PathBuf}, +}; + +use fs2::FileExt; +use sha2::{Digest, Sha256}; +use zeroize::Zeroizing; + +use crate::{ + AccountKey, SyncClientError, + credential_store::{clear_secret, load_secret, save_secret}, +}; + +const KEYCHAIN_SERVICE: &str = "com.elydora.ely-browser.sync.account-key.v3"; +const KEY_RECORD_VERSION: u8 = 3; +const KEY_RECORD_HEADER_BYTES: usize = 11; +const KEY_RECORD_ENTRY_BYTES: usize = 40; +const MAX_STORED_KEYS: usize = 1024; + +#[derive(Clone, Debug)] +pub struct StoredAccountKeys { + current_generation: u64, + keys: BTreeMap, +} + +impl StoredAccountKeys { + pub fn current_generation(&self) -> u64 { + self.current_generation + } + + pub fn current_key(&self) -> Option<&AccountKey> { + self.keys.get(&self.current_generation) + } + + pub fn key(&self, generation: u64) -> Option<&AccountKey> { + self.keys.get(&generation) + } + + fn from_current(key: &AccountKey, generation: u64) -> Result { + assert_generation(generation)?; + let mut keys = BTreeMap::new(); + keys.insert(generation, key.clone()); + Ok(Self { current_generation: generation, keys }) + } + + fn set_current(&mut self, key: &AccountKey, generation: u64) -> Result { + assert_generation(generation)?; + if generation < self.current_generation { + return Err(storage_error("sync account key generation would roll back")); + } + let changed = self.insert_key(key, generation)?; + if generation == self.current_generation { + return Ok(changed); + } + self.current_generation = generation; + Ok(true) + } + + fn insert_historical( + &mut self, + key: &AccountKey, + generation: u64, + ) -> Result { + assert_generation(generation)?; + if generation > self.current_generation { + return Err(storage_error("historical sync key exceeds current generation")); + } + self.insert_key(key, generation) + } + + fn insert_key(&mut self, key: &AccountKey, generation: u64) -> Result { + if let Some(stored) = self.keys.get(&generation) { + if stored.key_id() != key.key_id() { + return Err(storage_error("sync account key changed within one generation")); + } + return Ok(false); + } + if self.keys.len() >= MAX_STORED_KEYS { + return Err(storage_error("sync account key history is full")); + } + self.keys.insert(generation, key.clone()); + Ok(true) + } +} + +#[derive(Clone, Debug)] +pub struct AccountKeyStore { + user_id: String, + lock_path: PathBuf, +} + +impl AccountKeyStore { + pub fn new( + user_id: impl Into, + lock_directory: impl Into, + ) -> Result { + let user_id = user_id.into(); + if user_id.trim().is_empty() { + return Err(storage_error("sync user identifier is empty")); + } + let lock_name = format!("{}.lock", hex_string(&Sha256::digest(user_id.as_bytes()))); + Ok(Self { user_id, lock_path: lock_directory.into().join(lock_name) }) + } + + pub fn load(&self) -> Result, SyncClientError> { + match load_secret(KEYCHAIN_SERVICE, &self.user_id).map_err(storage_error)? { + Some(record) => decode_key_record(record).map(Some), + None => Ok(None), + } + } + + pub fn save_current(&self, key: &AccountKey, generation: u64) -> Result<(), SyncClientError> { + self.with_lock(|| { + let Some(mut stored) = self.load()? else { + return self.write(&StoredAccountKeys::from_current(key, generation)?); + }; + if !stored.set_current(key, generation)? { + return Ok(()); + } + self.write(&stored) + }) + } + + pub fn save_historical( + &self, + key: &AccountKey, + generation: u64, + ) -> Result<(), SyncClientError> { + self.with_lock(|| { + let mut stored = self + .load()? + .ok_or_else(|| storage_error("current sync account key is unavailable"))?; + if !stored.insert_historical(key, generation)? { + return Ok(()); + } + self.write(&stored) + }) + } + + pub fn clear(&self) -> Result<(), SyncClientError> { + self.with_lock(|| clear_secret(KEYCHAIN_SERVICE, &self.user_id).map_err(storage_error)) + } + + fn write(&self, stored: &StoredAccountKeys) -> Result<(), SyncClientError> { + let record = encode_key_record(stored)?; + save_secret(KEYCHAIN_SERVICE, &self.user_id, record.as_slice()).map_err(storage_error) + } + + fn with_lock( + &self, + operation: impl FnOnce() -> Result, + ) -> Result { + let lock = open_lock_file(&self.lock_path)?; + lock.lock_exclusive().map_err(|error| storage_error(error.to_string()))?; + let result = operation(); + let unlock_result = + FileExt::unlock(&lock).map_err(|error| storage_error(error.to_string())); + match (result, unlock_result) { + (Err(error), _) => Err(error), + (Ok(_), Err(error)) => Err(error), + (Ok(value), Ok(())) => Ok(value), + } + } +} + +fn open_lock_file(path: &Path) -> Result { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|error| storage_error(error.to_string()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)) + .map_err(|error| storage_error(error.to_string()))?; + } + } + let mut options = OpenOptions::new(); + options.create(true).read(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let file = options.open(path).map_err(|error| storage_error(error.to_string()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + file.set_permissions(std::fs::Permissions::from_mode(0o600)) + .map_err(|error| storage_error(error.to_string()))?; + } + Ok(file) +} + +fn encode_key_record(stored: &StoredAccountKeys) -> Result>, SyncClientError> { + if stored.keys.is_empty() + || stored.keys.len() > MAX_STORED_KEYS + || !stored.keys.contains_key(&stored.current_generation) + { + return Err(storage_error("sync account key history is invalid")); + } + let count = u16::try_from(stored.keys.len()) + .map_err(|_| storage_error("sync account key history is too large"))?; + let mut record = Zeroizing::new(Vec::with_capacity( + KEY_RECORD_HEADER_BYTES + stored.keys.len() * KEY_RECORD_ENTRY_BYTES, + )); + record.push(KEY_RECORD_VERSION); + record.extend_from_slice(&stored.current_generation.to_be_bytes()); + record.extend_from_slice(&count.to_be_bytes()); + for (generation, key) in &stored.keys { + record.extend_from_slice(&generation.to_be_bytes()); + record.extend_from_slice(key.bytes()); + } + Ok(record) +} + +fn decode_key_record(record: Zeroizing>) -> Result { + if record.len() < KEY_RECORD_HEADER_BYTES || record[0] != KEY_RECORD_VERSION { + return Err(storage_error("sync account key record is invalid")); + } + let current_generation = u64::from_be_bytes( + record[1..9] + .try_into() + .map_err(|_| storage_error("sync account key generation is invalid"))?, + ); + assert_generation(current_generation)?; + let count = usize::from(u16::from_be_bytes( + record[9..11].try_into().map_err(|_| storage_error("sync account key count is invalid"))?, + )); + if count == 0 + || count > MAX_STORED_KEYS + || record.len() != KEY_RECORD_HEADER_BYTES + count * KEY_RECORD_ENTRY_BYTES + { + return Err(storage_error("sync account key record size is invalid")); + } + let mut keys = BTreeMap::new(); + for entry in record[KEY_RECORD_HEADER_BYTES..].chunks_exact(KEY_RECORD_ENTRY_BYTES) { + let generation = u64::from_be_bytes( + entry[..8] + .try_into() + .map_err(|_| storage_error("sync account key generation is invalid"))?, + ); + assert_generation(generation)?; + let mut bytes = Zeroizing::new([0_u8; 32]); + bytes.copy_from_slice(&entry[8..]); + if keys.insert(generation, AccountKey::from_secret(bytes)).is_some() { + return Err(storage_error("sync account key generation is duplicated")); + } + } + if !keys.contains_key(¤t_generation) { + return Err(storage_error("current sync account key is missing")); + } + Ok(StoredAccountKeys { current_generation, keys }) +} + +fn assert_generation(generation: u64) -> Result<(), SyncClientError> { + if generation == 0 { + return Err(storage_error("sync account key generation is invalid")); + } + Ok(()) +} + +fn storage_error(message: impl Into) -> SyncClientError { + SyncClientError::AccountKeyStorage(message.into()) +} + +fn hex_string(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn key_record_round_trips_current_and_historical_keys() -> Result<(), SyncClientError> { + let current = AccountKey::from_bytes([19; 32]); + let historical = AccountKey::from_bytes([17; 32]); + let mut stored = StoredAccountKeys::from_current(¤t, 7)?; + stored.insert_historical(&historical, 3)?; + let record = encode_key_record(&stored)?; + let decoded = decode_key_record(Zeroizing::new(record.to_vec()))?; + + assert_eq!(decoded.current_key().map(AccountKey::key_id), Some(current.key_id())); + assert_eq!(decoded.key(3).map(AccountKey::key_id), Some(historical.key_id())); + assert_eq!(decoded.current_generation(), 7); + Ok(()) + } + + #[test] + fn key_record_rejects_unknown_versions() { + let record = vec![KEY_RECORD_VERSION + 1; KEY_RECORD_HEADER_BYTES]; + assert!(decode_key_record(Zeroizing::new(record)).is_err()); + } + + #[test] + fn key_record_rejects_zero_generation() { + assert!(StoredAccountKeys::from_current(&AccountKey::from_bytes([21; 32]), 0).is_err()); + } + + #[test] + fn current_generation_cannot_roll_back() -> Result<(), SyncClientError> { + let key = AccountKey::from_bytes([23; 32]); + let mut stored = StoredAccountKeys::from_current(&key, 4)?; + assert!(stored.set_current(&AccountKey::from_bytes([25; 32]), 3).is_err()); + assert!(stored.insert_historical(&AccountKey::from_bytes([27; 32]), 3)?); + assert_eq!(stored.current_generation(), 4); + Ok(()) + } +} diff --git a/crates/ely_sync_client/src/lib.rs b/crates/ely_sync_client/src/lib.rs index 478552d..47613ce 100644 --- a/crates/ely_sync_client/src/lib.rs +++ b/crates/ely_sync_client/src/lib.rs @@ -18,14 +18,42 @@ pub mod auth; pub mod client; +mod credential_store; pub mod device; +mod device_api; +mod device_proof; +mod device_revocation; +pub mod device_secret_store; pub mod email_otp; +pub mod encryption; pub mod error; +pub mod key_store; pub mod snapshot; +pub mod vault; +mod vault_bootstrap; pub use auth::{BearerToken, BearerTokenStore}; -pub use client::{ApiClientConfig, SyncApiClient, SyncLatestSnapshotDocument, SyncStatusDocument}; +pub use client::{ + ApiClientConfig, SnapshotDownloadResult, SnapshotUploadResult, SyncApiClient, + SyncLatestSnapshotDocument, SyncSnapshotHeadConflictDocument, SyncStatusDocument, +}; pub use device::{DeviceIdentity, DeviceListResponse, DeviceRecord, DeviceRegistration}; +pub use device_api::{DeviceApprovalDocument, DeviceApprovalRequest, DeviceRebindDocument}; +pub use device_revocation::{DeviceRevocationDocument, DeviceRevocationRequest}; +pub use device_secret_store::DeviceSecretStore; pub use email_otp::{send_email_otp, verify_email_otp}; +pub use encryption::{ + AccountKey, EncryptedSnapshot, SNAPSHOT_ENCRYPTION_VERSION, SnapshotCryptoContext, + SnapshotHeadRef, +}; pub use error::SyncClientError; -pub use snapshot::{SnapshotDownload, SnapshotPayload, SnapshotUploadRequest}; +pub use key_store::{AccountKeyStore, StoredAccountKeys}; +pub use snapshot::{ + AuthenticatedSnapshot, AuthenticatedSnapshotHead, SnapshotDownload, SnapshotPayload, + SnapshotUploadRequest, +}; +pub use vault::{ + ACCOUNT_KEY_WRAP_SUITE, ACCOUNT_KEY_WRAP_VERSION, SyncVaultDocument, VaultContext, + WrappedAccountKey, +}; +pub use vault_bootstrap::SyncVaultBootstrapRequest; diff --git a/crates/ely_sync_client/src/snapshot.rs b/crates/ely_sync_client/src/snapshot.rs index 5df5d31..32de086 100644 --- a/crates/ely_sync_client/src/snapshot.rs +++ b/crates/ely_sync_client/src/snapshot.rs @@ -1,7 +1,13 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -use crate::error::SyncClientError; +use crate::{ + encryption::{ + AccountKey, EncryptedSnapshot, SNAPSHOT_ENCRYPTION_VERSION, SnapshotCryptoContext, + SnapshotHeadRef, + }, + error::SyncClientError, +}; /// Hard cap from `cloudflare/src/sync_snapshot.ts`: a single snapshot /// upload may not exceed 10 MiB. We enforce the same limit client-side @@ -56,28 +62,51 @@ pub struct SnapshotUploadRequest<'a> { pub snapshot_id: &'a str, pub region: &'a str, pub payload_hash: &'a str, + pub encryption_version: u32, + pub vault_generation: u64, + pub key_id: &'a str, + pub content_hash: &'a str, pub schema_rev: u32, pub logical_clock: u64, + pub head_revision: u64, + pub base_head: Option<&'a SnapshotHeadRef>, pub data_base64: String, } impl<'a> SnapshotUploadRequest<'a> { pub fn new( - snapshot_id: &'a str, region: &'a str, - schema_rev: u32, - logical_clock: u64, + context: &SnapshotCryptoContext<'a>, + base_head: Option<&'a AuthenticatedSnapshotHead>, + encrypted: &'a EncryptedSnapshot, payload: &'a SnapshotPayload, - ) -> Self { - Self { - version: 1, - snapshot_id, + ) -> Result { + let head_revision = match base_head { + Some(base) => base.next_revision()?, + None => 1, + }; + if context.head_revision != head_revision + || context.base_head != base_head.map(AuthenticatedSnapshotHead::head_ref) + { + return Err(SyncClientError::SnapshotEncryption { + reason: "snapshot upload context does not match authenticated base", + }); + } + Ok(Self { + version: 3, + snapshot_id: context.snapshot_id, region, payload_hash: payload.payload_hash(), - schema_rev, - logical_clock, + encryption_version: SNAPSHOT_ENCRYPTION_VERSION, + vault_generation: context.vault_generation, + key_id: encrypted.key_id(), + content_hash: encrypted.content_hash(), + schema_rev: context.schema_rev, + logical_clock: context.logical_clock, + head_revision, + base_head: base_head.map(AuthenticatedSnapshotHead::head_ref), data_base64: encode_base64(payload.bytes()), - } + }) } } @@ -96,6 +125,12 @@ impl SnapshotDownload { /// worker enforces on upload — we re-check on download so a /// tampered storage layer doesn't silently desync the user. pub fn payload(&self) -> Result { + if self.data_base64.len() > MAX_SNAPSHOT_BYTES.div_ceil(3) * 4 { + return Err(SyncClientError::SnapshotTooLarge { + bytes: self.data_base64.len(), + limit: MAX_SNAPSHOT_BYTES.div_ceil(3) * 4, + }); + } let bytes = decode_base64(&self.data_base64) .map_err(|error| SyncClientError::SnapshotBase64(error.to_string()))?; let payload = SnapshotPayload::new(bytes)?; @@ -106,6 +141,54 @@ impl SnapshotDownload { } Ok(payload) } + + pub fn authenticate( + &self, + expected_head: &SnapshotHeadRef, + key: &AccountKey, + ) -> Result { + if self.version != 3 { + return Err(SyncClientError::SnapshotEncryption { + reason: "snapshot response version is unsupported", + }); + } + let actual_head = self.snapshot.head_ref()?; + if &actual_head != expected_head { + return Err(SyncClientError::SnapshotEncryption { + reason: "snapshot response head does not match request", + }); + } + let payload = self.payload()?; + let context = SnapshotCryptoContext { + user_id: &self.user_id, + vault_generation: self.snapshot.vault_generation, + snapshot_id: &self.snapshot.snapshot_id, + schema_rev: self.snapshot.schema_rev, + logical_clock: self.snapshot.logical_clock, + device_id: &self.snapshot.device_id, + head_revision: self.snapshot.head_revision, + base_head: self.snapshot.base_head.as_ref(), + }; + let plaintext = key.decrypt( + &context, + self.snapshot.encryption_version, + &self.snapshot.key_id, + &self.snapshot.content_hash, + payload.bytes(), + )?; + Ok(AuthenticatedSnapshot { + plaintext, + head: AuthenticatedSnapshotHead { + head: actual_head, + logical_clock: self.snapshot.logical_clock, + content_hash: self.snapshot.content_hash.clone(), + vault_generation: self.snapshot.vault_generation, + key_id: self.snapshot.key_id.clone(), + device_id: self.snapshot.device_id.clone(), + size_bytes: self.snapshot.size_bytes, + }, + }) + } } #[derive(Clone, Debug, Deserialize)] @@ -113,13 +196,101 @@ pub struct SnapshotDocument { pub snapshot_id: String, pub r2_key: String, pub payload_hash: String, + pub encryption_version: u32, + pub vault_generation: u64, + pub key_id: String, + pub content_hash: String, pub schema_rev: u32, pub logical_clock: u64, + pub head_revision: u64, + pub base_head: Option, pub device_id: String, pub size_bytes: u64, pub created_at: u64, } +impl SnapshotDocument { + fn head_ref(&self) -> Result { + SnapshotHeadRef::new( + self.head_revision, + self.snapshot_id.clone(), + self.payload_hash.clone(), + ) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AuthenticatedSnapshotHead { + head: SnapshotHeadRef, + logical_clock: u64, + content_hash: String, + vault_generation: u64, + key_id: String, + device_id: String, + size_bytes: u64, +} + +impl AuthenticatedSnapshotHead { + pub fn revision(&self) -> u64 { + self.head.revision() + } + + pub fn logical_clock(&self) -> u64 { + self.logical_clock + } + + pub fn content_hash(&self) -> &str { + &self.content_hash + } + + pub fn vault_generation(&self) -> u64 { + self.vault_generation + } + + pub fn key_id(&self) -> &str { + &self.key_id + } + + pub fn snapshot_id(&self) -> &str { + self.head.snapshot_id() + } + + pub fn device_id(&self) -> &str { + &self.device_id + } + + pub fn size_bytes(&self) -> u64 { + self.size_bytes + } + + pub fn next_revision(&self) -> Result { + if self.head.revision >= 9_007_199_254_740_991 { + return Err(SyncClientError::SnapshotEncryption { + reason: "snapshot head revision exceeds the wire limit", + }); + } + self.head.revision.checked_add(1).ok_or(SyncClientError::SnapshotEncryption { + reason: "snapshot head revision overflowed", + }) + } + + pub fn head_ref(&self) -> &SnapshotHeadRef { + &self.head + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AuthenticatedSnapshot { + plaintext: Vec, + head: AuthenticatedSnapshotHead, +} + +impl AuthenticatedSnapshot { + pub fn into_parts(self) -> (Vec, AuthenticatedSnapshotHead) { + (self.plaintext, self.head) + } +} + const BASE64_CHARS: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; fn encode_base64(bytes: &[u8]) -> String { @@ -228,4 +399,90 @@ mod tests { } Ok(()) } + + #[test] + fn upload_request_serializes_encrypted_wire_v3() -> Result<(), SyncClientError> { + let key = AccountKey::from_bytes([23; 32]); + let context = 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, + }; + let encrypted = key.encrypt(&context, br#"{"secret":"value"}"#)?; + let payload = SnapshotPayload::new(encrypted.bytes().to_vec())?; + let request = SnapshotUploadRequest::new("auto", &context, None, &encrypted, &payload)?; + let value = serde_json::to_value(request) + .map_err(|source| SyncClientError::Json { endpoint: "test".to_string(), source })?; + + assert_eq!(value["version"], 3); + assert_eq!(value["encryption_version"], SNAPSHOT_ENCRYPTION_VERSION); + assert_eq!(value["head_revision"], 1); + assert!(value["base_head"].is_null()); + assert_eq!(value["key_id"], encrypted.key_id()); + assert_eq!(value["content_hash"], encrypted.content_hash()); + assert!(!value["data_base64"].as_str().unwrap_or_default().contains("secret")); + Ok(()) + } + + #[test] + fn downloaded_head_becomes_a_merge_base_after_authentication() -> Result<(), SyncClientError> { + let key = AccountKey::from_bytes([25; 32]); + let expected_head = SnapshotHeadRef::new(1, "device-01", "00".repeat(32))?; + let context = SnapshotCryptoContext { + user_id: "user-01", + vault_generation: 1, + snapshot_id: expected_head.snapshot_id(), + schema_rev: 1, + logical_clock: 42, + device_id: "device-01", + head_revision: 1, + base_head: None, + }; + let encrypted = key.encrypt(&context, b"authenticated head")?; + let payload = SnapshotPayload::new(encrypted.bytes().to_vec())?; + let expected_head = + SnapshotHeadRef::new(1, context.snapshot_id, payload.payload_hash().to_string())?; + let download = SnapshotDownload { + version: 3, + user_id: context.user_id.to_string(), + device_id: context.device_id.to_string(), + snapshot: SnapshotDocument { + snapshot_id: context.snapshot_id.to_string(), + r2_key: "sync-snapshots/test".to_string(), + payload_hash: payload.payload_hash().to_string(), + encryption_version: SNAPSHOT_ENCRYPTION_VERSION, + vault_generation: context.vault_generation, + key_id: encrypted.key_id().to_string(), + content_hash: encrypted.content_hash().to_string(), + schema_rev: context.schema_rev, + logical_clock: context.logical_clock, + head_revision: context.head_revision, + base_head: None, + device_id: context.device_id.to_string(), + size_bytes: u64::try_from(payload.bytes().len()).unwrap_or_default(), + created_at: 1, + }, + data_base64: encode_base64(payload.bytes()), + }; + let (plaintext, authenticated_head) = + download.authenticate(&expected_head, &key)?.into_parts(); + + assert_eq!(plaintext, b"authenticated head"); + assert_eq!(authenticated_head.revision(), 1); + assert_eq!(authenticated_head.content_hash(), encrypted.content_hash()); + assert!( + download + .authenticate( + &SnapshotHeadRef::new(1, "device-02", payload.payload_hash().to_string())?, + &key, + ) + .is_err() + ); + Ok(()) + } } diff --git a/crates/ely_sync_client/src/vault.rs b/crates/ely_sync_client/src/vault.rs new file mode 100644 index 0000000..a71c4e5 --- /dev/null +++ b/crates/ely_sync_client/src/vault.rs @@ -0,0 +1,396 @@ +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use hpke::{ + Deserializable, Kem, OpModeR, OpModeS, Serializable, aead::ChaCha20Poly1305, kdf::HkdfSha256, + kem::X25519HkdfSha256, single_shot_open, single_shot_seal, +}; +use serde::{Deserialize, Serialize}; +use zeroize::Zeroizing; + +use crate::{ + AccountKey, DeviceIdentity, DeviceSecretStore, SyncClientError, + device::{decode_hex_32, is_device_id_shape}, +}; + +pub const ACCOUNT_KEY_WRAP_VERSION: u32 = 1; +pub const ACCOUNT_KEY_WRAP_SUITE: &str = "HPKE-BASE-X25519-HKDF-SHA256-CHACHA20POLY1305"; + +const INFO_DOMAIN: &[u8] = b"ely-sync-account-key-hpke-info-v1\0"; +const AAD_DOMAIN: &[u8] = b"ely-sync-account-key-hpke-aad-v1\0"; +const ACCOUNT_KEY_BYTES: usize = 32; +const ENCAPSULATED_KEY_BYTES: usize = 32; +const CIPHERTEXT_BYTES: usize = ACCOUNT_KEY_BYTES + 16; +const MAX_CONTEXT_TEXT_BYTES: usize = 4096; + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SyncVaultDocument { + pub version: u32, + pub user_id: String, + pub key_id: String, + pub generation: u64, + pub recipient_device_id: String, + pub approver_device_id: String, + pub envelope: WrappedAccountKey, + pub created_at: u64, +} + +impl SyncVaultDocument { + pub fn unwrap_for( + &self, + expected_user_id: &str, + identity: &DeviceIdentity, + ) -> Result { + if self.version != 1 + || self.user_id != expected_user_id + || self.recipient_device_id != identity.device_id + { + return Err(vault_error("vault document identity does not match")); + } + self.envelope.unwrap( + &VaultContext { + user_id: &self.user_id, + recipient_device_id: &self.recipient_device_id, + recipient_wrapping_public_key: &identity.wrapping_public_key, + approver_device_id: &self.approver_device_id, + generation: self.generation, + key_id: &self.key_id, + }, + identity, + ) + } +} + +#[derive(Clone, Copy, Debug)] +pub struct VaultContext<'a> { + pub user_id: &'a str, + pub recipient_device_id: &'a str, + pub recipient_wrapping_public_key: &'a str, + pub approver_device_id: &'a str, + pub generation: u64, + pub key_id: &'a str, +} + +impl VaultContext<'_> { + fn validate(&self) -> Result<(), SyncClientError> { + validate_text(self.user_id, "vault user identifier is invalid")?; + if !is_device_id_shape(self.recipient_device_id) { + return Err(vault_error("vault recipient device identifier is invalid")); + } + if !is_device_id_shape(self.approver_device_id) { + return Err(vault_error("vault approver device identifier is invalid")); + } + decode_vault_hex_32( + self.recipient_wrapping_public_key, + "vault recipient wrapping public key is invalid", + )?; + decode_vault_hex_32(self.key_id, "vault account key identifier is invalid")?; + if self.generation == 0 { + return Err(vault_error("vault generation is invalid")); + } + Ok(()) + } +} + +/// Strict JSON envelope for an AccountKey wrapped to one approved device. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WrappedAccountKey { + pub version: u32, + pub suite: String, + pub encapped_key: String, + pub ciphertext: String, +} + +impl WrappedAccountKey { + pub fn wrap( + account_key: &AccountKey, + context: &VaultContext<'_>, + ) -> Result { + context.validate()?; + if account_key.key_id() != context.key_id { + return Err(vault_error("vault account key identifier does not match")); + } + let public_key_bytes = decode_vault_hex_32( + context.recipient_wrapping_public_key, + "vault recipient wrapping public key is invalid", + )?; + let public_key = ::PublicKey::from_bytes(&public_key_bytes) + .map_err(|_| vault_error("vault recipient wrapping public key is invalid"))?; + let info = context_bytes(INFO_DOMAIN, context)?; + let aad = context_bytes(AAD_DOMAIN, context)?; + let (encapped_key, ciphertext) = + single_shot_seal::( + &OpModeS::Base, + &public_key, + &info, + account_key.bytes(), + &aad, + ) + .map_err(|_| vault_error("account key wrapping failed"))?; + + Ok(Self { + version: ACCOUNT_KEY_WRAP_VERSION, + suite: ACCOUNT_KEY_WRAP_SUITE.to_string(), + encapped_key: URL_SAFE_NO_PAD.encode(encapped_key.to_bytes()), + ciphertext: URL_SAFE_NO_PAD.encode(ciphertext), + }) + } + + pub fn unwrap( + &self, + context: &VaultContext<'_>, + recipient: &DeviceIdentity, + ) -> Result { + context.validate()?; + recipient.validate()?; + if recipient.device_id != context.recipient_device_id + || recipient.wrapping_public_key != context.recipient_wrapping_public_key + { + return Err(vault_error("vault recipient identity does not match context")); + } + let store = DeviceSecretStore::new(recipient.device_id.clone())?; + let secrets = store.load_required()?; + recipient.validate_secrets(&secrets)?; + self.unwrap_with_private_key(context, secrets.wrapping_private_key()) + } + + pub fn self_wrap( + account_key: &AccountKey, + user_id: &str, + identity: &DeviceIdentity, + generation: u64, + ) -> Result { + identity.validate()?; + Self::wrap( + account_key, + &VaultContext { + user_id, + recipient_device_id: &identity.device_id, + recipient_wrapping_public_key: &identity.wrapping_public_key, + approver_device_id: &identity.device_id, + generation, + key_id: &account_key.key_id(), + }, + ) + } + + pub fn self_unwrap( + &self, + user_id: &str, + identity: &DeviceIdentity, + generation: u64, + key_id: &str, + ) -> Result { + self.unwrap( + &VaultContext { + user_id, + recipient_device_id: &identity.device_id, + recipient_wrapping_public_key: &identity.wrapping_public_key, + approver_device_id: &identity.device_id, + generation, + key_id, + }, + identity, + ) + } + + fn unwrap_with_private_key( + &self, + context: &VaultContext<'_>, + private_key_bytes: &[u8; ACCOUNT_KEY_BYTES], + ) -> Result { + self.validate_wire()?; + let private_key = ::PrivateKey::from_bytes(private_key_bytes) + .map_err(|_| vault_error("vault recipient private key is invalid"))?; + let encapped_key_bytes = decode_base64url_exact::( + &self.encapped_key, + "vault encapsulated key encoding is invalid", + )?; + let encapped_key = ::EncappedKey::from_bytes(&encapped_key_bytes) + .map_err(|_| vault_error("vault encapsulated key is invalid"))?; + let ciphertext = decode_base64url_exact::( + &self.ciphertext, + "vault ciphertext encoding is invalid", + )?; + let info = context_bytes(INFO_DOMAIN, context)?; + let aad = context_bytes(AAD_DOMAIN, context)?; + let plaintext = Zeroizing::new( + single_shot_open::( + &OpModeR::Base, + &private_key, + &encapped_key, + &info, + &ciphertext, + &aad, + ) + .map_err(|_| vault_error("account key unwrap authentication failed"))?, + ); + if plaintext.len() != ACCOUNT_KEY_BYTES { + return Err(vault_error("unwrapped account key has invalid length")); + } + let mut bytes = Zeroizing::new([0_u8; ACCOUNT_KEY_BYTES]); + bytes.copy_from_slice(&plaintext); + let account_key = AccountKey::from_secret(bytes); + if account_key.key_id() != context.key_id { + return Err(vault_error("unwrapped account key identifier does not match")); + } + Ok(account_key) + } + + pub(crate) fn validate_wire(&self) -> Result<(), SyncClientError> { + if self.version != ACCOUNT_KEY_WRAP_VERSION { + return Err(vault_error("vault envelope version is unsupported")); + } + if self.suite != ACCOUNT_KEY_WRAP_SUITE { + return Err(vault_error("vault envelope suite is unsupported")); + } + decode_base64url_exact::( + &self.encapped_key, + "vault encapsulated key encoding is invalid", + )?; + decode_base64url_exact::( + &self.ciphertext, + "vault ciphertext encoding is invalid", + )?; + Ok(()) + } +} + +fn context_bytes(domain: &[u8], context: &VaultContext<'_>) -> Result, SyncClientError> { + context.validate()?; + let mut bytes = Vec::with_capacity(domain.len() + 512); + bytes.extend_from_slice(domain); + bytes.extend_from_slice(&ACCOUNT_KEY_WRAP_VERSION.to_be_bytes()); + push_text(&mut bytes, ACCOUNT_KEY_WRAP_SUITE)?; + push_text(&mut bytes, context.user_id)?; + push_text(&mut bytes, context.recipient_device_id)?; + push_text(&mut bytes, context.recipient_wrapping_public_key)?; + push_text(&mut bytes, context.approver_device_id)?; + bytes.extend_from_slice(&context.generation.to_be_bytes()); + push_text(&mut bytes, context.key_id)?; + Ok(bytes) +} + +fn validate_text(value: &str, reason: &'static str) -> Result<(), SyncClientError> { + if value.trim().is_empty() || value.len() > MAX_CONTEXT_TEXT_BYTES { + return Err(vault_error(reason)); + } + Ok(()) +} + +fn push_text(output: &mut Vec, value: &str) -> Result<(), SyncClientError> { + validate_text(value, "vault authenticated metadata is invalid")?; + let length = u16::try_from(value.len()) + .map_err(|_| vault_error("vault authenticated metadata is too long"))?; + output.extend_from_slice(&length.to_be_bytes()); + output.extend_from_slice(value.as_bytes()); + Ok(()) +} + +fn decode_base64url_exact( + value: &str, + reason: &'static str, +) -> Result<[u8; N], SyncClientError> { + let decoded = URL_SAFE_NO_PAD.decode(value).map_err(|_| vault_error(reason))?; + if URL_SAFE_NO_PAD.encode(&decoded) != value { + return Err(vault_error(reason)); + } + decoded.try_into().map_err(|_| vault_error(reason)) +} + +fn decode_vault_hex_32(value: &str, reason: &'static str) -> Result<[u8; 32], SyncClientError> { + decode_hex_32(value, reason).map_err(|_| vault_error(reason)) +} + +fn vault_error(reason: &'static str) -> SyncClientError { + SyncClientError::VaultCrypto { reason } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::device::generate_key_material; + + fn context<'a>(identity: &'a DeviceIdentity, key_id: &'a str) -> VaultContext<'a> { + VaultContext { + user_id: "user-01", + recipient_device_id: &identity.device_id, + recipient_wrapping_public_key: &identity.wrapping_public_key, + approver_device_id: &identity.device_id, + generation: 1, + key_id, + } + } + + #[test] + fn account_key_round_trips_through_hpke() -> Result<(), SyncClientError> { + let (identity, secrets) = generate_key_material("Test".to_string(), "macos".to_string())?; + let account_key = AccountKey::from_bytes([41; ACCOUNT_KEY_BYTES]); + let key_id = account_key.key_id(); + let context = context(&identity, &key_id); + let wrapped = WrappedAccountKey::self_wrap(&account_key, "user-01", &identity, 1)?; + let unwrapped = + wrapped.unwrap_with_private_key(&context, secrets.wrapping_private_key())?; + + assert_eq!(unwrapped.key_id(), account_key.key_id()); + assert_eq!(wrapped.encapped_key.len(), 43); + assert_eq!(wrapped.ciphertext.len(), 64); + Ok(()) + } + + #[test] + fn authenticated_context_rejects_metadata_changes() -> Result<(), SyncClientError> { + let (identity, secrets) = generate_key_material("Test".to_string(), "macos".to_string())?; + let account_key = AccountKey::from_bytes([43; ACCOUNT_KEY_BYTES]); + let key_id = account_key.key_id(); + let context = context(&identity, &key_id); + let wrapped = WrappedAccountKey::wrap(&account_key, &context)?; + let other_key_id = AccountKey::from_bytes([44; ACCOUNT_KEY_BYTES]).key_id(); + let other_wrapping_public_key = "01".repeat(ACCOUNT_KEY_BYTES); + + let changed = [ + VaultContext { user_id: "user-02", ..context }, + VaultContext { recipient_device_id: "device-02", ..context }, + VaultContext { recipient_wrapping_public_key: &other_wrapping_public_key, ..context }, + VaultContext { approver_device_id: "device-02", ..context }, + VaultContext { generation: 2, ..context }, + VaultContext { key_id: &other_key_id, ..context }, + ]; + for changed_context in changed { + assert!( + wrapped + .unwrap_with_private_key(&changed_context, secrets.wrapping_private_key()) + .is_err() + ); + } + Ok(()) + } + + #[test] + fn wire_schema_rejects_unknown_fields() -> Result<(), SyncClientError> { + let json = format!( + r#"{{"version":1,"suite":"{ACCOUNT_KEY_WRAP_SUITE}","encapped_key":"{}","ciphertext":"{}","unknown":true}}"#, + "A".repeat(43), + "A".repeat(64) + ); + assert!(serde_json::from_str::(&json).is_err()); + Ok(()) + } + + #[cfg(target_os = "macos")] + #[test] + fn self_wrap_uses_native_credential_store() -> Result<(), SyncClientError> { + let identity = DeviceIdentity::generate("Test", "macos")?; + let store = DeviceSecretStore::new(identity.device_id.clone())?; + let result = (|| { + let account_key = AccountKey::from_bytes([47; ACCOUNT_KEY_BYTES]); + let key_id = account_key.key_id(); + let wrapped = WrappedAccountKey::self_wrap(&account_key, "user-01", &identity, 1)?; + let unwrapped = wrapped.self_unwrap("user-01", &identity, 1, &key_id)?; + assert_eq!(unwrapped.key_id(), key_id); + Ok(()) + })(); + store.clear()?; + result + } +} diff --git a/crates/ely_sync_client/src/vault_bootstrap.rs b/crates/ely_sync_client/src/vault_bootstrap.rs new file mode 100644 index 0000000..8c314fe --- /dev/null +++ b/crates/ely_sync_client/src/vault_bootstrap.rs @@ -0,0 +1,130 @@ +use serde::Serialize; + +use crate::{ + DeviceIdentity, SyncClientError, + device::decode_hex_32, + device_proof::{is_idempotency_key_shape, push_field}, + vault::WrappedAccountKey, +}; + +const BOOTSTRAP_PROOF_DOMAIN: &str = "elydora-sync-vault-bootstrap-v2"; +const MAX_USER_ID_BYTES: usize = 4096; + +#[derive(Clone, Debug, Serialize)] +pub struct SyncVaultBootstrapRequest<'a> { + pub version: u32, + pub key_id: &'a str, + pub generation: u64, + pub envelope: &'a WrappedAccountKey, + pub idempotency_key: &'a str, + pub bootstrap_proof: String, +} + +impl<'a> SyncVaultBootstrapRequest<'a> { + pub fn signed( + user_id: &str, + identity: &DeviceIdentity, + key_id: &'a str, + envelope: &'a WrappedAccountKey, + idempotency_key: &'a str, + ) -> Result { + let generation = 1; + let message = bootstrap_proof_message( + user_id, + identity, + key_id, + generation, + envelope, + idempotency_key, + )?; + Ok(Self { + version: 2, + key_id, + generation, + envelope, + idempotency_key, + bootstrap_proof: identity.sign_message(&message)?, + }) + } +} + +fn bootstrap_proof_message( + user_id: &str, + identity: &DeviceIdentity, + key_id: &str, + generation: u64, + envelope: &WrappedAccountKey, + idempotency_key: &str, +) -> Result, SyncClientError> { + if user_id.trim().is_empty() || user_id.len() > MAX_USER_ID_BYTES { + return Err(bootstrap_error("vault user identifier is invalid")); + } + identity.validate()?; + decode_hex_32(key_id, "vault account key identifier is invalid")?; + if generation != 1 { + return Err(bootstrap_error("vault bootstrap generation is invalid")); + } + envelope.validate_wire()?; + if !is_idempotency_key_shape(idempotency_key) { + return Err(bootstrap_error("vault bootstrap idempotency key is invalid")); + } + + let generation = generation.to_string(); + let envelope_version = envelope.version.to_string(); + let fields = [ + BOOTSTRAP_PROOF_DOMAIN, + user_id, + &identity.device_id, + key_id, + &generation, + &envelope_version, + &envelope.suite, + &envelope.encapped_key, + &envelope.ciphertext, + idempotency_key, + ]; + let mut message = Vec::with_capacity(512); + for field in fields { + push_field(&mut message, field); + } + Ok(message) +} + +fn bootstrap_error(reason: &'static str) -> SyncClientError { + SyncClientError::VaultCrypto { reason } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{AccountKey, device::generate_key_material, vault::WrappedAccountKey}; + + #[test] + fn proof_matches_worker_canonical_bytes() -> Result<(), SyncClientError> { + let (identity, _) = generate_key_material("Test".to_string(), "macos".to_string())?; + let account_key = AccountKey::from_bytes([37; 32]); + let key_id = account_key.key_id(); + let user_id = "usér-01"; + let envelope = WrappedAccountKey::self_wrap(&account_key, user_id, &identity, 1)?; + let idempotency_key = "vault-bootstrap:01"; + let message = + bootstrap_proof_message(user_id, &identity, &key_id, 1, &envelope, idempotency_key)?; + let fields = [ + BOOTSTRAP_PROOF_DOMAIN.to_string(), + user_id.to_string(), + identity.device_id, + key_id, + "1".to_string(), + envelope.version.to_string(), + envelope.suite, + envelope.encapped_key, + envelope.ciphertext, + idempotency_key.to_string(), + ]; + let expected = + fields.iter().map(|field| format!("{}:{field}", field.len())).collect::(); + + assert_eq!(message, expected.as_bytes()); + Ok(()) + } +}