fix(sync): secure encrypted snapshot lifecycle
This commit is contained in:
Generated
+113
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
@@ -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<AccountDeletionEventRow>();
|
||||
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<ElyD1Result>(
|
||||
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<AccountDeletionEventRow>();
|
||||
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<Omit<AccountDeletionDeletedDocument, "r2_objects" | "kv_session_cache">> {
|
||||
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<AccountDeletionCountsRow>();
|
||||
if (row === null) {
|
||||
@@ -226,69 +285,126 @@ async function accountDeletionCounts(
|
||||
};
|
||||
}
|
||||
|
||||
async function accountDeletionR2Keys(env: Env, userId: string): Promise<string[]> {
|
||||
const result = await env.ELY_DB.prepare(ACCOUNT_DELETION_R2_KEYS_QUERY)
|
||||
.bind(userId, userId)
|
||||
async function accountDeletionR2Keys(
|
||||
database: ElyD1DatabaseSession,
|
||||
userId: string,
|
||||
): Promise<string[]> {
|
||||
const result = await database.prepare(ACCOUNT_DELETION_R2_KEYS_QUERY)
|
||||
.bind(userId)
|
||||
.all<AccountDeletionR2KeyRow>();
|
||||
return result.results.map(r2Key);
|
||||
}
|
||||
|
||||
function accountDeletionStatements(
|
||||
env: Env,
|
||||
async function accountDeletionSessionTokens(
|
||||
database: ElyD1DatabaseSession,
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
): Promise<string[]> {
|
||||
const result = await database.prepare(ACCOUNT_DELETION_SESSION_TOKENS_QUERY)
|
||||
.bind(userId)
|
||||
.all<AccountDeletionSessionTokenRow>();
|
||||
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<void> {
|
||||
async function cleanupDeletedAccount(
|
||||
env: Env,
|
||||
accountHash: string,
|
||||
sessionTokens: string[],
|
||||
currentTokenHash: string,
|
||||
nowSeconds: number,
|
||||
candidateCount: number,
|
||||
): Promise<number> {
|
||||
try {
|
||||
await deleteKnownObject(env.ELY_STORAGE, key);
|
||||
} catch (error) {
|
||||
if (error instanceof StorageObjectError) {
|
||||
throw new AccountDeletionPersistenceError(error.message);
|
||||
}
|
||||
throw error;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
function deleteCurrentSessionCache(env: Env, tokenHash: string): Promise<void> {
|
||||
return env.ELY_KV.delete(authSessionCacheKvKey(env.ELY_ENVIRONMENT, tokenHash));
|
||||
} catch {
|
||||
// Scheduled maintenance drains the durable GC ledger.
|
||||
}
|
||||
try {
|
||||
return await deleteLegacySessionKeys(env, sessionTokens, currentTokenHash);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function accountDeletionRequest(request: Request): Promise<AccountDeletionRequest> {
|
||||
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<RequestBody> {
|
||||
|
||||
@@ -14,9 +14,13 @@ export type ApiHandler = () => Promise<Response>;
|
||||
export type AuthenticatedApiHandler = (context: AuthContext) => Promise<Response>;
|
||||
|
||||
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(
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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) {
|
||||
|
||||
@@ -35,10 +35,18 @@ export interface ElyD1Result<T = unknown> {
|
||||
};
|
||||
}
|
||||
|
||||
export interface ElyD1Database {
|
||||
export interface ElyD1DatabaseSession {
|
||||
prepare(query: string): ElyD1PreparedStatement;
|
||||
batch<T = unknown>(statements: ElyD1PreparedStatement[]): Promise<T[]>;
|
||||
}
|
||||
|
||||
export interface ElyD1Database extends ElyD1DatabaseSession {
|
||||
exec(query: string): Promise<unknown>;
|
||||
withSession?(constraint: "first-primary"): ElyD1DatabaseSession;
|
||||
}
|
||||
|
||||
export function primaryD1Session(database: ElyD1Database): ElyD1DatabaseSession {
|
||||
return database.withSession?.("first-primary") ?? database;
|
||||
}
|
||||
|
||||
export interface ElyRateLimit {
|
||||
|
||||
@@ -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<boolean> {
|
||||
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;
|
||||
}
|
||||
@@ -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<DeviceApprovalDocument> {
|
||||
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<DeviceApprovalRow>();
|
||||
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<DeviceApprovalDocument> {
|
||||
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<DeviceApprovalDocument> {
|
||||
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<CurrentRecipientEnvelopeRow>();
|
||||
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<string> {
|
||||
const requester = await env.ELY_DB.prepare(APPROVED_REQUESTER_QUERY)
|
||||
.bind(userId, requesterDeviceId)
|
||||
.first<ApprovedRequesterRow>();
|
||||
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<DeviceRow | null> {
|
||||
return env.ELY_DB.prepare(DEVICE_BY_ID_QUERY).bind(userId, deviceId).first<DeviceRow>();
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<DeviceApprovalRequest, "approvalProof">,
|
||||
): 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(""));
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
export async function verifyEd25519Signature(
|
||||
publicKey: string,
|
||||
signature: string,
|
||||
message: Uint8Array,
|
||||
): Promise<boolean> {
|
||||
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;
|
||||
}
|
||||
@@ -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<DeviceRebindChallengeDocument> {
|
||||
const deviceId = await rebindChallengeDeviceId(request);
|
||||
assertUnboundSession(context);
|
||||
const keyRow = await env.ELY_DB.prepare(APPROVED_DEVICE_KEY_QUERY)
|
||||
.bind(context.userId, deviceId)
|
||||
.first<ApprovedDeviceKeyRow>();
|
||||
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<DeviceRebindDocument> {
|
||||
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<RebindChallengeRow>();
|
||||
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<string> {
|
||||
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("");
|
||||
}
|
||||
@@ -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<void> {
|
||||
const valid = await verifyEd25519Signature(
|
||||
registration.publicKey,
|
||||
registration.registrationProof,
|
||||
deviceRegistrationProofBytes(registration),
|
||||
);
|
||||
if (!valid) {
|
||||
throw new DevicePermissionError("device_registration_proof_invalid");
|
||||
}
|
||||
}
|
||||
|
||||
export function deviceRegistrationProofBytes(
|
||||
registration: Omit<DeviceRegistrationRequest, "registrationProof">,
|
||||
): 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);
|
||||
}
|
||||
@@ -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<DeviceRevocationDocument> {
|
||||
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<ElyD1Result>(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<DeviceRevocationDocument> {
|
||||
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<string> {
|
||||
const row = await env.ELY_DB.prepare(APPROVED_V2_REQUESTER_QUERY)
|
||||
.bind(userId, approverDeviceId)
|
||||
.first<ApprovedV2RequesterRow>();
|
||||
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<void> {
|
||||
const row = await env.ELY_DB.prepare(CURRENT_VAULT_KEY_QUERY).bind(userId).first<VaultKeyRow>();
|
||||
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<void> {
|
||||
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<string[]> {
|
||||
const result = await env.ELY_DB.prepare(REMAINING_APPROVED_V2_QUERY)
|
||||
.bind(userId, targetDeviceId)
|
||||
.all<DeviceIdRow>();
|
||||
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<DeviceRow | null> {
|
||||
return env.ELY_DB.prepare(DEVICE_BY_ID_QUERY).bind(userId, deviceId).first<DeviceRow>();
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
@@ -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<DeviceRevocationRequest> {
|
||||
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<string> {
|
||||
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<ApprovedDeviceRevocationRequest, "rotationProof">,
|
||||
): 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<string> {
|
||||
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<PendingDeviceRevocationRequest, "pendingRevocationProof">,
|
||||
): Uint8Array {
|
||||
return canonicalBytes([
|
||||
"elydora-pending-device-revocation-v2",
|
||||
userId,
|
||||
approverDeviceId,
|
||||
revocation.deviceId,
|
||||
revocation.idempotencyKey,
|
||||
]);
|
||||
}
|
||||
|
||||
export function rotationEnvelopeIdempotencyKey(
|
||||
userId: string,
|
||||
rotationIdempotencyKey: string,
|
||||
recipientDeviceId: string,
|
||||
): Promise<string> {
|
||||
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<string>();
|
||||
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<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new DeviceSchemaError(`${label}_invalid`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function sha256Hex(value: string): Promise<string> {
|
||||
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(""));
|
||||
}
|
||||
@@ -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<number> {
|
||||
const rows = await env.ELY_DB.prepare(ROTATION_R2_COUNT_QUERY)
|
||||
.bind(userId, userId)
|
||||
.all<R2CountRow>();
|
||||
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<RotationResultRow | null> {
|
||||
return env.ELY_DB.prepare(ROTATION_RESULT_QUERY)
|
||||
.bind(userId, idempotencyKey)
|
||||
.first<RotationResultRow>();
|
||||
}
|
||||
|
||||
export async function rotationStatements(
|
||||
env: Env,
|
||||
userId: string,
|
||||
approverDeviceId: string,
|
||||
revocation: ApprovedDeviceRevocationRequest,
|
||||
requestHash: string,
|
||||
r2ObjectCount: number,
|
||||
nowSeconds: number,
|
||||
): Promise<ElyD1PreparedStatement[]> {
|
||||
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,
|
||||
);
|
||||
}
|
||||
@@ -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<Response | null> {
|
||||
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;
|
||||
}
|
||||
+115
-56
@@ -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<string, unknown>;
|
||||
|
||||
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<DeviceApprovalRequest> {
|
||||
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<DeviceRevocationRequest> {
|
||||
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<DeviceRequestBody> {
|
||||
export async function deviceRequestBody(
|
||||
request: Request,
|
||||
label: string,
|
||||
): Promise<DeviceRequestBody> {
|
||||
let value: unknown;
|
||||
try {
|
||||
value = await request.json();
|
||||
|
||||
+122
-339
@@ -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<void> {
|
||||
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<DeviceApprovalDocument> {
|
||||
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<DeviceApprovalRow>();
|
||||
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<DeviceRevocationDocument> {
|
||||
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<DeviceRevocationRow>();
|
||||
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<DeviceApprovalDocument> {
|
||||
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<DeviceRevocationDocument> {
|
||||
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<void> {
|
||||
const requester = await env.ELY_DB.prepare(APPROVED_DEVICE_QUERY)
|
||||
.bind(userId, requesterDeviceId)
|
||||
.first<DeviceRow>();
|
||||
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<DeviceRow | null> {
|
||||
return env.ELY_DB.prepare(DEVICE_BY_ID_QUERY).bind(userId, deviceId).first<DeviceRow>();
|
||||
}
|
||||
|
||||
+25
-146
@@ -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<Response> {
|
||||
return handleRequest(request, env);
|
||||
},
|
||||
scheduled(controller: { scheduledTime: number }, env: Env): Promise<void> {
|
||||
return maintainSyncR2Storage(env, Math.floor(controller.scheduledTime / 1000));
|
||||
},
|
||||
};
|
||||
|
||||
export async function handleRequest(request: Request, env: Env): Promise<Response> {
|
||||
@@ -60,143 +59,12 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
||||
if (url.pathname === "/api/auth" || url.pathname.startsWith("/api/auth/")) {
|
||||
return handleBetterAuthRoute(request, env);
|
||||
}
|
||||
if (url.pathname === "/api/devices") {
|
||||
return withAuthenticatedApiControls(request, env, "devices.list", ["GET"], async (context) => {
|
||||
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<Respons
|
||||
{ "Cache-Control": "no-store" },
|
||||
);
|
||||
}
|
||||
if (error instanceof AccountDeletionPersistenceError) {
|
||||
if (error instanceof RecentDeviceActionPermissionError) {
|
||||
return jsonResponse(
|
||||
{ error: "account_deletion_forbidden" },
|
||||
403,
|
||||
{ "Cache-Control": "no-store" },
|
||||
);
|
||||
}
|
||||
if (
|
||||
error instanceof AccountDeletionPersistenceError ||
|
||||
error instanceof RecentDeviceActionPersistenceError ||
|
||||
error instanceof DestructiveActionGateError
|
||||
) {
|
||||
return jsonResponse(
|
||||
{ error: "account_deletion_failed" },
|
||||
500,
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { authSessionCacheKvKey, authTokenHash } from "./auth.js";
|
||||
import type { Env } from "./bindings.js";
|
||||
|
||||
const LIST_LIMIT = 1000;
|
||||
const MAX_LIST_PAGES = 10;
|
||||
|
||||
interface KvListResult {
|
||||
keys: { name: string }[];
|
||||
list_complete: boolean;
|
||||
cursor?: string;
|
||||
}
|
||||
|
||||
interface ListableKv {
|
||||
list(options: { prefix: string; cursor?: string; limit: number }): Promise<KvListResult>;
|
||||
}
|
||||
|
||||
export class LegacyAuthKvCleanupError extends Error {}
|
||||
|
||||
export async function deleteLegacySessionKeys(
|
||||
env: Env,
|
||||
tokens: string[],
|
||||
currentTokenHash: string,
|
||||
): Promise<number> {
|
||||
const keys = new Set<string>([
|
||||
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<number> {
|
||||
const namespace = env.ELY_KV as Env["ELY_KV"] & Partial<ListableKv>;
|
||||
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;
|
||||
}
|
||||
@@ -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<DeviceRevocationDocument> {
|
||||
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<ElyD1Result>([
|
||||
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<DeviceRevocationDocument> {
|
||||
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<PendingResultRow | null> {
|
||||
return env.ELY_DB.prepare(PENDING_RESULT_QUERY)
|
||||
.bind(userId, idempotencyKey)
|
||||
.first<PendingResultRow>();
|
||||
}
|
||||
|
||||
function deviceRowById(env: Env, userId: string, deviceId: string): Promise<DeviceRow | null> {
|
||||
return env.ELY_DB.prepare(DEVICE_BY_ID_QUERY).bind(userId, deviceId).first<DeviceRow>();
|
||||
}
|
||||
|
||||
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")
|
||||
);
|
||||
}
|
||||
@@ -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<string> {
|
||||
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<SigningKeyRow>();
|
||||
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<RecentDeviceActionProofFields, "actionProof">,
|
||||
): Uint8Array {
|
||||
return canonicalBytes(deviceActionProofValues(fields));
|
||||
}
|
||||
|
||||
export async function recentDeviceActionRequestHash(
|
||||
fields: RecentDeviceActionProofFields,
|
||||
): Promise<string> {
|
||||
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<RecentDeviceActionProofFields, "actionProof">,
|
||||
): (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(""));
|
||||
}
|
||||
@@ -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<StoredObject> {
|
||||
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<ArrayBuffer | null> {
|
||||
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") {
|
||||
|
||||
@@ -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<SyncPullDocument> {
|
||||
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<SyncChangeRow>();
|
||||
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;
|
||||
}
|
||||
@@ -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<SyncPushDocument> {
|
||||
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<SyncObjectRow>();
|
||||
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<SyncObjectRow>();
|
||||
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<void> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
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<SyncPushRequest> {
|
||||
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<SyncPushPayload> {
|
||||
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<string> {
|
||||
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<RequestBody> {
|
||||
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<void> {
|
||||
const actualHash = await sha256Hex(payload);
|
||||
if (actualHash !== expectedHash) {
|
||||
throw new SyncPushRequestError("payload_hash_mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
async function sha256Hex(payload: ArrayBuffer): Promise<string> {
|
||||
const digest = await crypto.subtle.digest("SHA-256", payload);
|
||||
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
@@ -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<SyncR2WriteLease> {
|
||||
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<void> {
|
||||
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<number> {
|
||||
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<CandidateRow>();
|
||||
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("");
|
||||
}
|
||||
@@ -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<R2ListResult>;
|
||||
}
|
||||
|
||||
export class SyncR2InventoryError extends Error {}
|
||||
|
||||
export async function inventorySyncR2Objects(
|
||||
env: Env,
|
||||
nowSeconds: number,
|
||||
limit = DEFAULT_INVENTORY_LIMIT,
|
||||
): Promise<number> {
|
||||
const database = primaryD1Session(env.ELY_DB);
|
||||
const cursorRow = await database.prepare(INVENTORY_CURSOR_QUERY)
|
||||
.bind(nowSeconds)
|
||||
.first<InventoryCursorRow>();
|
||||
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<ListableBucket>;
|
||||
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;
|
||||
}
|
||||
@@ -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<void> {
|
||||
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");
|
||||
}
|
||||
+130
-55
@@ -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<SyncResetEventRow>();
|
||||
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<ElyD1Result>(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<SyncResetEventRow>();
|
||||
if (racedEvent !== null) {
|
||||
return existingResetDocument(context, deviceId, reset, racedEvent);
|
||||
}
|
||||
await env.ELY_DB.batch(syncResetStatements(env, context.userId, deviceId, eventId, nowSeconds));
|
||||
if (!(await destructiveActionGateIsLive(
|
||||
replayDatabase,
|
||||
context,
|
||||
signingPublicKey,
|
||||
nowSeconds,
|
||||
))) {
|
||||
throw new RecentDeviceActionPermissionError("device_action_gate_failed");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
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<Omit<SyncResetDeletedDocument, "r2_objects">> {
|
||||
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<SyncResetCountsRow>();
|
||||
if (row === null) {
|
||||
@@ -169,56 +209,91 @@ async function syncResetCounts(
|
||||
};
|
||||
}
|
||||
|
||||
async function syncResetR2Keys(env: Env, userId: string): Promise<string[]> {
|
||||
const result = await env.ELY_DB.prepare(SYNC_RESET_R2_KEYS_QUERY)
|
||||
.bind(userId, userId)
|
||||
async function syncResetR2Keys(
|
||||
database: ElyD1DatabaseSession,
|
||||
userId: string,
|
||||
): Promise<string[]> {
|
||||
const result = await database.prepare(SYNC_RESET_R2_KEYS_QUERY)
|
||||
.bind(userId)
|
||||
.all<SyncResetR2KeyRow>();
|
||||
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<void> {
|
||||
async function collectResetGarbage(
|
||||
env: Env,
|
||||
userId: string,
|
||||
nowSeconds: number,
|
||||
candidateCount: number,
|
||||
): Promise<void> {
|
||||
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<SyncResetRequest> {
|
||||
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<RequestBody> {
|
||||
|
||||
@@ -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<Response | null> {
|
||||
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<Response> {
|
||||
function handleSyncVaultBootstrap(request: Request, env: Env): Promise<Response> {
|
||||
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<Response>
|
||||
);
|
||||
}
|
||||
|
||||
function handleSyncPush(request: Request, env: Env): Promise<Response> {
|
||||
function handleSyncVault(request: Request, env: Env, url: URL): Promise<Response> {
|
||||
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<Response> {
|
||||
);
|
||||
}
|
||||
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
return withApprovedDeviceApiControls(
|
||||
request,
|
||||
@@ -145,11 +172,7 @@ function handleSyncSnapshot(request: Request, env: Env, url: URL): Promise<Respo
|
||||
);
|
||||
}
|
||||
if (error instanceof SyncSnapshotConflictError) {
|
||||
return jsonResponse(
|
||||
{ error: "sync_snapshot_conflict" },
|
||||
409,
|
||||
{ "Cache-Control": "no-store" },
|
||||
);
|
||||
return jsonResponse(error.document(), 409, { "Cache-Control": "no-store" });
|
||||
}
|
||||
if (error instanceof SyncSnapshotPersistenceError) {
|
||||
return jsonResponse(
|
||||
@@ -208,7 +231,18 @@ function handleSyncReset(request: Request, env: Env): Promise<Response> {
|
||||
{ "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,
|
||||
|
||||
+364
-363
@@ -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<string, unknown>;
|
||||
|
||||
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<SyncSnapshotUploadDocument> {
|
||||
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));
|
||||
}
|
||||
|
||||
await persistSnapshot(env, snapshot);
|
||||
await env.ELY_DB.batch([
|
||||
env.ELY_DB.prepare(SYNC_SNAPSHOT_UPSERT_QUERY).bind(
|
||||
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,
|
||||
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) {
|
||||
return uploadDocument(context.userId, deviceId, currentHead);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
await cleanupAfterSnapshot(env, context.userId, currentHead, nowSeconds);
|
||||
return uploadDocument(context.userId, deviceId, currentHead);
|
||||
}
|
||||
try {
|
||||
await assertCurrentSyncVaultKey(
|
||||
env,
|
||||
context.userId,
|
||||
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<SyncSnapshotRow>[];
|
||||
try {
|
||||
results = await database.batch<ElyD1Result<SyncSnapshotRow>>(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<SyncSnapshotDownloadDocument> {
|
||||
const deviceId = currentDeviceId(context);
|
||||
const snapshotId = syncSnapshotDownloadQuery(url);
|
||||
const row = await snapshotRow(env, context.userId, snapshotId);
|
||||
if (row === null) {
|
||||
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,
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function readCurrentHead(
|
||||
database: ElyD1DatabaseSession,
|
||||
userId: string,
|
||||
snapshotId: string,
|
||||
): Promise<string> {
|
||||
): Promise<SyncSnapshotDocument | null> {
|
||||
try {
|
||||
return syncSnapshotKey({
|
||||
region,
|
||||
userHash: await sha256Hex(arrayBufferFromBytes(new TextEncoder().encode(userId))),
|
||||
snapshotId,
|
||||
});
|
||||
return await currentSyncSnapshotHead(database, userId);
|
||||
} catch (error) {
|
||||
if (error instanceof StorageObjectError) {
|
||||
throw new SyncSnapshotRequestError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function snapshotRow(
|
||||
env: Env,
|
||||
userId: string,
|
||||
snapshotId: string,
|
||||
): Promise<SyncSnapshotRow | null> {
|
||||
return env.ELY_DB.prepare(SYNC_SNAPSHOT_BY_ID_QUERY).bind(userId, snapshotId).first();
|
||||
}
|
||||
|
||||
function syncSnapshotDocumentFromRow(row: SyncSnapshotRow): SyncSnapshotDocument {
|
||||
try {
|
||||
return syncSnapshotDocument(row);
|
||||
} 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<void> {
|
||||
async function readSnapshotByToken(
|
||||
database: ElyD1DatabaseSession,
|
||||
userId: string,
|
||||
token: SnapshotHeadRefDocument,
|
||||
): Promise<SyncSnapshotDocument | null> {
|
||||
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<SyncSnapshotUploadDocument> {
|
||||
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<RequestBody> {
|
||||
let value: unknown;
|
||||
async function throwDownloadStorageFailure(
|
||||
env: Env,
|
||||
userId: string,
|
||||
token: SnapshotHeadRefDocument,
|
||||
message: string,
|
||||
): Promise<never> {
|
||||
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<void> {
|
||||
try {
|
||||
value = await request.json();
|
||||
} catch {
|
||||
throw new SyncSnapshotRequestError("json_invalid");
|
||||
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);
|
||||
}
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 snapshotIdValue(value: unknown): string {
|
||||
if (typeof value !== "string" || !SNAPSHOT_ID_PATTERN.test(value)) {
|
||||
throw new SyncSnapshotRequestError("snapshot_id_invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function deviceIdValue(value: unknown): string {
|
||||
if (typeof value !== "string" || !DEVICE_ID_PATTERN.test(value)) {
|
||||
throw new SyncSnapshotRequestError("device_id_invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function regionValue(value: unknown): string {
|
||||
if (typeof value !== "string" || !REGION.test(value)) {
|
||||
throw new SyncSnapshotRequestError("region_invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function sha256HexValue(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !SHA256_HEX.test(value)) {
|
||||
throw new SyncSnapshotRequestError(`${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 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<void> {
|
||||
const actualHash = await sha256Hex(payload);
|
||||
if (actualHash !== expectedHash) {
|
||||
throw new SyncSnapshotRequestError("payload_hash_mismatch");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function sha256Hex(payload: ArrayBuffer): Promise<string> {
|
||||
const digest = await crypto.subtle.digest("SHA-256", payload);
|
||||
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
function uploadDocument(
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
snapshot: SyncSnapshotDocument,
|
||||
): SyncSnapshotUploadDocument {
|
||||
return { version: 3, user_id: userId, device_id: deviceId, snapshot };
|
||||
}
|
||||
|
||||
function currentDeviceId(context: AuthContext): string {
|
||||
return deviceIdValue(context.deviceId);
|
||||
}
|
||||
|
||||
function numberQueryValue(value: string | null): number {
|
||||
if (value === null || !/^[1-9][0-9]*$/.test(value)) {
|
||||
throw new SyncSnapshotRequestError("head_revision_invalid");
|
||||
}
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
function changedRowCount(result: ElyD1Result): number {
|
||||
const changes = result.meta?.changes;
|
||||
return typeof changes === "number" && Number.isSafeInteger(changes) && changes >= 0 ? changes : -1;
|
||||
}
|
||||
|
||||
function isSnapshotHeadConflict(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) {
|
||||
return false;
|
||||
}
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
export class SyncSnapshotRequestError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "SyncSnapshotRequestError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestBody(request: Request): Promise<SnapshotRequestBody> {
|
||||
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<T extends number>(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<void> {
|
||||
const actualHash = await sha256Hex(payload);
|
||||
if (actualHash !== expectedHash) {
|
||||
throw new SyncSnapshotRequestError("payload_hash_mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
export async function sha256Hex(payload: ArrayBuffer): Promise<string> {
|
||||
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;
|
||||
}
|
||||
@@ -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<SyncSnapshotRow> | 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<SyncSnapshotDocument | null> {
|
||||
const row = await database.prepare(SYNC_SNAPSHOT_HEAD_QUERY)
|
||||
.bind(userId)
|
||||
.first<SyncSnapshotRow>();
|
||||
return row === null ? null : snapshotDocumentFromRow(row);
|
||||
}
|
||||
|
||||
export async function syncSnapshotByToken(
|
||||
database: ElyD1DatabaseSession,
|
||||
userId: string,
|
||||
head: SnapshotHeadRefDocument,
|
||||
): Promise<SyncSnapshotDocument | null> {
|
||||
const row = await database.prepare(SYNC_SNAPSHOT_BY_TOKEN_QUERY)
|
||||
.bind(userId, head.snapshot_id, head.revision, head.payload_hash)
|
||||
.first<SyncSnapshotRow>();
|
||||
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<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new SyncSnapshotRequestError(`${label}_invalid`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<SyncR2WriteLease> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
return sha256Hex(arrayBufferFromBytes(new TextEncoder().encode(userId)));
|
||||
}
|
||||
@@ -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<SyncStatusDocument> {
|
||||
const deviceId = currentDeviceId(context);
|
||||
const cursorRow = await env.ELY_DB.prepare(CHANGE_CURSOR_QUERY)
|
||||
.bind(context.userId)
|
||||
.first<ChangeCursorRow>();
|
||||
const objectRows = await env.ELY_DB.prepare(OBJECT_STATUS_QUERY)
|
||||
.bind(context.userId)
|
||||
.all<ObjectStatusRow>();
|
||||
const snapshotCountRow = await env.ELY_DB.prepare(SNAPSHOT_COUNT_QUERY)
|
||||
.bind(context.userId)
|
||||
.first<SnapshotCountRow>();
|
||||
const latestSnapshotRow = await env.ELY_DB.prepare(LATEST_SNAPSHOT_QUERY)
|
||||
.bind(context.userId)
|
||||
.first<LatestSnapshotRow>();
|
||||
const deviceStatusRow = await env.ELY_DB.prepare(APPROVED_DEVICE_COUNT_QUERY)
|
||||
.bind(context.userId)
|
||||
.first<DeviceStatusRow>();
|
||||
const database = primaryD1Session(env.ELY_DB);
|
||||
const results = await database.batch<ElyD1Result>([
|
||||
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<ChangeCursorRow>(results[0], "sync_cursor_status_missing");
|
||||
const objectRows = rows<ObjectStatusRow>(results[1]);
|
||||
const snapshotCountRow = oneRow<SnapshotCountRow>(
|
||||
results[2],
|
||||
"sync_snapshot_status_missing",
|
||||
);
|
||||
const snapshotHeadRow = optionalRow<SyncSnapshotRow>(results[3]);
|
||||
const deviceStatusRow = oneRow<DeviceStatusRow>(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<T>(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<T>(result: ElyD1Result | undefined, message: string): T {
|
||||
const values = rows<T>(result);
|
||||
if (values.length !== 1) {
|
||||
throw new SyncStatusSchemaError(message);
|
||||
}
|
||||
return values[0] as T;
|
||||
}
|
||||
|
||||
function optionalRow<T>(result: ElyD1Result | undefined): T | null {
|
||||
const values = rows<T>(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 {
|
||||
function snapshotHeadStatus(row: SyncSnapshotRow): SyncSnapshotHeadStatusDocument {
|
||||
try {
|
||||
const snapshot = snapshotDocumentFromRow(row);
|
||||
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"),
|
||||
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`);
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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<SyncVaultDocument> {
|
||||
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<SyncVaultDocument> {
|
||||
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<CurrentSyncVaultKey | null> {
|
||||
const row = await database.prepare(CURRENT_SYNC_VAULT_KEY_QUERY)
|
||||
.bind(userId)
|
||||
.first<SyncVaultKeyRow>();
|
||||
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<void> {
|
||||
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<StoredSyncVaultEnvelope | null> {
|
||||
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<SyncVaultEnvelopeRow>();
|
||||
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<SyncVaultBootstrapRequest> {
|
||||
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<RequestBody> {
|
||||
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;
|
||||
}
|
||||
@@ -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<boolean> {
|
||||
const row = await env.ELY_DB.prepare(APPROVED_V2_SIGNING_KEY_QUERY)
|
||||
.bind(userId, deviceId)
|
||||
.first<SigningKeyRow>();
|
||||
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(""));
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<RotationRow>();
|
||||
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<number> {
|
||||
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;
|
||||
}
|
||||
@@ -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
|
||||
`;
|
||||
@@ -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<string, unknown>): Request {
|
||||
});
|
||||
}
|
||||
|
||||
function accountDeleteBody(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
version: 1,
|
||||
async function accountDeleteBody(
|
||||
overrides: Record<string, unknown> = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
const body: Record<string, unknown> = {
|
||||
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<string, unknown> = {}): Record<string, unknown> {
|
||||
@@ -286,6 +374,19 @@ function accountDeletionEventId(): string {
|
||||
return `account-delete:${USER_HASH}:${IDEMPOTENCY_HASH}`;
|
||||
}
|
||||
|
||||
function requestHash(body: Record<string, unknown>): Promise<string> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<void>,
|
||||
): Promise<void> {
|
||||
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<Request> {
|
||||
return actionRequest(
|
||||
"sync.reset",
|
||||
"delete-cloud-sync-data",
|
||||
idempotencyKey,
|
||||
proofCreatedAt,
|
||||
"/api/sync/reset",
|
||||
);
|
||||
}
|
||||
|
||||
function accountDeleteRequest(idempotencyKey: string, proofCreatedAt: number): Promise<Request> {
|
||||
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<Request> {
|
||||
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<string, ArrayBuffer>();
|
||||
failDeletes = false;
|
||||
|
||||
get(key: string): Promise<ElyR2Object | null> {
|
||||
const value = this.values.get(key);
|
||||
return Promise.resolve(value === undefined ? null : object(value));
|
||||
}
|
||||
|
||||
put(key: string, value: ArrayBuffer, _options?: ElyR2PutOptions): Promise<ElyR2Object> {
|
||||
this.values.set(key, value);
|
||||
return Promise.resolve(object(value));
|
||||
}
|
||||
|
||||
delete(key: string): Promise<void> {
|
||||
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<string, string>();
|
||||
failDeletes = false;
|
||||
failLists = false;
|
||||
|
||||
get(key: string): Promise<string | null> {
|
||||
return Promise.resolve(this.values.get(key) ?? null);
|
||||
}
|
||||
|
||||
put(key: string, value: string): Promise<void> {
|
||||
this.values.set(key, value);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
delete(key: string): Promise<void> {
|
||||
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) };
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, unknown>,
|
||||
firstRows: unknown[],
|
||||
): Promise<Response> {
|
||||
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<string, unknown>): 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<Record<string, unknown>> {
|
||||
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<string, unknown>,
|
||||
): Promise<string> {
|
||||
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),
|
||||
});
|
||||
}
|
||||
@@ -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<Request> {
|
||||
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<ApprovedDeviceRevocationRequest, "rotationProof"> = {
|
||||
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<Request> {
|
||||
const unsigned: Omit<PendingDeviceRevocationRequest, "pendingRevocationProof"> = {
|
||||
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<string, unknown> {
|
||||
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<void>): Promise<void> {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown> = {}): Record<string, unknown> {
|
||||
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<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
version: 2,
|
||||
mode: "pending_revoke",
|
||||
device_id: TARGET_ID,
|
||||
idempotency_key: IDEMPOTENCY_KEY,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function envelope(
|
||||
recipient: string,
|
||||
overrides: Record<string, unknown> = {},
|
||||
): Record<string, unknown> {
|
||||
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<string, unknown>): Promise<Record<string, unknown>> {
|
||||
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<string, unknown>, 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<string, unknown> {
|
||||
return { device_id: APPROVER_ID, signing_public_key: PUBLIC_KEY };
|
||||
}
|
||||
@@ -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<string, unknown>[] {
|
||||
const output = execFileSync("sqlite3", ["-json", databasePath, sql], { encoding: "utf8" });
|
||||
return JSON.parse(output) as Record<string, unknown>[];
|
||||
}
|
||||
@@ -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<typeof testD1Database>,
|
||||
registration: Record<string, unknown>,
|
||||
): Promise<Response> {
|
||||
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<typeof testD1Database>;
|
||||
}> {
|
||||
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<typeof testD1Database> {
|
||||
return testD1Database({
|
||||
batchChanges,
|
||||
firstRows: [
|
||||
{
|
||||
challenge: challenge.challenge,
|
||||
expires_at: challenge.expires_at,
|
||||
signing_public_key: PUBLIC_KEY,
|
||||
},
|
||||
],
|
||||
sessionRow: unboundSession(),
|
||||
});
|
||||
}
|
||||
|
||||
async function rebindRequest(
|
||||
d1: ReturnType<typeof testD1Database>,
|
||||
challenge: ChallengeDocument,
|
||||
signature: string,
|
||||
): Promise<Response> {
|
||||
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<string, unknown> {
|
||||
return {
|
||||
id: "session-01",
|
||||
userId: "user-01",
|
||||
expiresAt: "2099-01-01T00:00:00.000Z",
|
||||
createdAt: new Date().toISOString(),
|
||||
deviceId: null,
|
||||
};
|
||||
}
|
||||
|
||||
function deviceRow(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<typeof testD1Database>): Promise<Response> {
|
||||
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<Record<string, unknown>> {
|
||||
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<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
device_id: "device-02",
|
||||
requester_device_id: "device-01",
|
||||
status: "approved",
|
||||
decided_at: 1_780_000_300,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function deviceRow(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
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<string, unknown>): Record<string, unknown> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<string, unknown> {
|
||||
return {
|
||||
version: 1,
|
||||
async function deviceApprovalBody(
|
||||
overrides: Record<string, unknown> = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
const body: Record<string, unknown> = {
|
||||
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<string, unknown>): Record<string, unknown> {
|
||||
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<string, unknown>): Record<string, unknown>
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function wrappedEnvelope(): Record<string, unknown> {
|
||||
return {
|
||||
version: 1,
|
||||
suite: SUITE,
|
||||
encapped_key: ENCAPPED_KEY,
|
||||
ciphertext: CIPHERTEXT,
|
||||
};
|
||||
}
|
||||
|
||||
function approvalEnvelopeRow(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
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<typeof testD1Database>,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<Response> {
|
||||
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 }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
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: 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, []);
|
||||
{
|
||||
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")]],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
assert.deepEqual(await response.json(), { error: "invalid_device_revocation" });
|
||||
assert.deepEqual(d1.queries, []);
|
||||
it("rejects stale vault metadata before target reads", async () => {
|
||||
const d1 = testD1Database({
|
||||
firstRows: [
|
||||
approverRow(),
|
||||
null,
|
||||
{ key_id: "c".repeat(64), generation: PREVIOUS_GENERATION },
|
||||
],
|
||||
});
|
||||
|
||||
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 }),
|
||||
);
|
||||
const response = await revocationResponse(d1, deviceRevocationBody());
|
||||
|
||||
assert.equal(response.status, 409);
|
||||
assert.deepEqual(d1.batches, []);
|
||||
assert.equal(d1.queries.length, 3);
|
||||
});
|
||||
|
||||
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<string, unknown> {
|
||||
function successfulD1(
|
||||
body: Record<string, unknown>,
|
||||
targetStatus: "pending" | "approved",
|
||||
batchChanges: number[][] = [[1, 1, 1, 1]],
|
||||
): ReturnType<typeof testD1Database> {
|
||||
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<string, unknown>[]): ReturnType<typeof testD1Database> {
|
||||
return testD1Database({
|
||||
firstRows: [
|
||||
approverRow(),
|
||||
null,
|
||||
currentVaultKeyRow(),
|
||||
deviceRow({ device_id: TARGET_DEVICE_ID }),
|
||||
],
|
||||
allRowSets: [recipientRows, [{ object_count: 0 }]],
|
||||
});
|
||||
}
|
||||
|
||||
function revocationResponse(
|
||||
d1: ReturnType<typeof testD1Database>,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<Response> {
|
||||
return signedRevocationBody(body).then((signedBody) =>
|
||||
handleRequest(revocationRequest(signedBody), testEnv({ d1 })),
|
||||
);
|
||||
}
|
||||
|
||||
function revocationRequest(body: Record<string, unknown>, 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<string, unknown> = {}): Record<string, unknown> {
|
||||
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<string, unknown>): Record<string, unknown> {
|
||||
function pendingRevocationBody(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
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<string, unknown> = {},
|
||||
): Record<string, unknown> {
|
||||
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<string, unknown> { return { key_id: PREVIOUS_KEY_ID, generation: PREVIOUS_GENERATION }; }
|
||||
|
||||
async function rotationResultRow(
|
||||
body: Record<string, unknown>,
|
||||
overrides: Record<string, unknown> = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
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<string, unknown>): Promise<Record<string, unknown>> {
|
||||
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<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
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<string, unknown> { return { device_id: APPROVER_DEVICE_ID, signing_public_key: PUBLIC_KEY }; }
|
||||
|
||||
function deviceRow(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
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<string, unknown>): Record<string, unknown>
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function revocationDocument(): Record<string, unknown> {
|
||||
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<string, unknown> {
|
||||
const document = revocationDocument();
|
||||
delete document.key_id;
|
||||
delete document.generation;
|
||||
return {
|
||||
...document,
|
||||
mode: "pending_revoke",
|
||||
device: {
|
||||
...(document.device as Record<string, unknown>),
|
||||
approval_status: "revoked",
|
||||
approved_at: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,11 +372,12 @@ 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",
|
||||
createdAt: new Date().toISOString(),
|
||||
deviceId: null,
|
||||
},
|
||||
});
|
||||
@@ -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<string, unknown> {
|
||||
return {
|
||||
version: 1,
|
||||
device_id: "device-01",
|
||||
public_key: PUBLIC_KEY,
|
||||
device_name: "MacBook Pro",
|
||||
platform: "macOS",
|
||||
idempotency_key: IDEMPOTENCY_KEY,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
const body: Record<string, unknown> = {
|
||||
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<string> {
|
||||
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<T>(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<T>() {
|
||||
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("");
|
||||
}
|
||||
|
||||
@@ -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<string>;
|
||||
readonly deleted: string[] = [];
|
||||
readonly cursors: (string | undefined)[] = [];
|
||||
|
||||
constructor(keys: string[]) {
|
||||
this.values = new Set(keys);
|
||||
}
|
||||
|
||||
get(key: string): Promise<string | null> {
|
||||
return Promise.resolve(this.values.has(key) ? "value" : null);
|
||||
}
|
||||
|
||||
put(key: string): Promise<void> {
|
||||
this.values.add(key);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
delete(key: string): Promise<void> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<T>(statements: ElyD1PreparedStatement[]): Promise<T[]> {
|
||||
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<unknown> {
|
||||
execute(this.databasePath, sql);
|
||||
return {};
|
||||
}
|
||||
|
||||
withSession(constraint: "first-primary"): SqliteD1Database {
|
||||
this.sessionConstraints.push(constraint);
|
||||
return this;
|
||||
}
|
||||
|
||||
rows<T>(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<T>(): Promise<T | null> {
|
||||
return this.database.rows<T>(this.sql())[0] ?? null;
|
||||
}
|
||||
|
||||
async all<T>(): Promise<ElyD1Result<T>> {
|
||||
return { results: this.database.rows<T>(this.sql()) };
|
||||
}
|
||||
|
||||
async run(): Promise<unknown> {
|
||||
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<string, unknown>[] {
|
||||
const output = sqlite(databasePath, `PRAGMA foreign_keys = ON;\n${sql}`, true);
|
||||
return output.trim() === "" ? [] : JSON.parse(output) as Record<string, unknown>[];
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, unknown> = {}): Record<string, unknown> {
|
||||
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<string, unknown> = {}): Record<string, unknown> {
|
||||
return syncChangeRow(overrides);
|
||||
}
|
||||
|
||||
@@ -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 response = await handleRequest(
|
||||
syncPushRequest(syncPushBody({ payload_hash: payloadHash, payload: inlinePayload(payload) })),
|
||||
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(), {
|
||||
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 d1 = testD1Database({ firstRows: [{ device_id: "device-01" }] });
|
||||
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.equal(d1.queries.length, 1);
|
||||
assert.deepEqual(d1.batches, []);
|
||||
});
|
||||
});
|
||||
|
||||
function syncPushRequest(body: Record<string, unknown>): Request {
|
||||
return new Request("https://elydora.test/api/sync/push", {
|
||||
new Request("https://elydora.test/api/sync/push", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${ACCESS_TOKEN}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
body: JSON.stringify({ version: 1, payload: "legacy-plaintext" }),
|
||||
}),
|
||||
testEnv({
|
||||
d1,
|
||||
r2Puts,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument("device-01")]],
|
||||
}),
|
||||
);
|
||||
|
||||
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, []);
|
||||
assert.deepEqual(r2Puts, []);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function syncPushBody(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
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<string, unknown> {
|
||||
return { kind: "inline", data_base64: base64(payload) };
|
||||
}
|
||||
|
||||
function r2Payload(region: string, payload: ArrayBuffer): Record<string, unknown> {
|
||||
return { kind: "r2", region, data_base64: base64(payload) };
|
||||
}
|
||||
|
||||
function syncObjectRow(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
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<string, unknown> = {}): Record<string, unknown> {
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -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<void>,
|
||||
): Promise<void> {
|
||||
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<Request> {
|
||||
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<string, unknown> | 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<string, ArrayBuffer>();
|
||||
|
||||
get(key: string): Promise<ElyR2Object | null> {
|
||||
const value = this.values.get(key);
|
||||
return Promise.resolve(value === undefined ? null : object(value));
|
||||
}
|
||||
|
||||
put(key: string, value: ArrayBuffer, _options?: ElyR2PutOptions): Promise<ElyR2Object> {
|
||||
this.values.set(key, value);
|
||||
return Promise.resolve(object(value));
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
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) };
|
||||
}
|
||||
@@ -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<string, unknown>): Request {
|
||||
});
|
||||
}
|
||||
|
||||
function syncResetBody(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
version: 1,
|
||||
async function syncResetBody(
|
||||
overrides: Record<string, unknown> = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
const body: Record<string, unknown> = {
|
||||
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<string, unknown> = {}): Record<string, unknown> {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<void>): Promise<void> {
|
||||
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<string, unknown> | null,
|
||||
logicalClock: number,
|
||||
): Record<string, unknown> {
|
||||
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<string, unknown>): 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");
|
||||
}
|
||||
@@ -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>): 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<string, unknown> | undefined {
|
||||
const row = database.prepare(`
|
||||
SELECT head_revision, snapshot_id, payload_hash
|
||||
FROM sync_snapshot_heads
|
||||
WHERE user_id = ?
|
||||
`).get(USER_ID) as Record<string, unknown> | undefined;
|
||||
return row === undefined ? undefined : { ...row };
|
||||
}
|
||||
|
||||
function snapshotState(
|
||||
database: DatabaseSync,
|
||||
snapshotId: string,
|
||||
): Record<string, unknown> | 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<string, unknown> | undefined;
|
||||
return row === undefined ? undefined : { ...row };
|
||||
}
|
||||
@@ -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] });
|
||||
|
||||
const response = await handleRequest(
|
||||
syncSnapshotGetRequest(),
|
||||
testEnv({
|
||||
d1,
|
||||
r2Gets,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 404);
|
||||
assert.deepEqual(await response.json(), { error: "sync_snapshot_not_found" });
|
||||
assert.deepEqual(r2Gets, []);
|
||||
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],
|
||||
});
|
||||
|
||||
it("rejects snapshot checksum mismatches before D1 writes", async () => {
|
||||
const payload = bytes("encrypted snapshot payload");
|
||||
const response = await handleRequest(
|
||||
syncSnapshotGetRequest(headRef({ payload_hash: "a".repeat(64) })),
|
||||
await authorizedEnv(d1),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 409);
|
||||
assert.deepEqual(await response.json(), conflictDocument(current));
|
||||
});
|
||||
|
||||
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<string, unknown>): 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<string, unknown>): 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<string, unknown> = {}): Record<string, unknown> {
|
||||
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<string, unknown> = {}): Record<string, un
|
||||
snapshot_id: SNAPSHOT_ID,
|
||||
r2_key: snapshotKey("a".repeat(64)),
|
||||
payload_hash: "a".repeat(64),
|
||||
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_revision: null,
|
||||
base_snapshot_id: null,
|
||||
base_payload_hash: null,
|
||||
device_id: DEVICE_ID,
|
||||
size_bytes: 26,
|
||||
size_bytes: 11,
|
||||
created_at: 1_780_000_900,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function snapshotDocument(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return snapshotRow(overrides);
|
||||
function snapshotDocument(row: Record<string, unknown>): Record<string, unknown> {
|
||||
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<string, unknown>): Record<string, unknown> {
|
||||
return {
|
||||
version: 3,
|
||||
user_id: USER_ID,
|
||||
device_id: DEVICE_ID,
|
||||
snapshot: snapshotDocument(row),
|
||||
};
|
||||
}
|
||||
|
||||
function conflictDocument(row: Record<string, unknown>): Record<string, unknown> {
|
||||
return {
|
||||
version: 1,
|
||||
error: "sync_snapshot_head_conflict",
|
||||
current_head: snapshotDocument(row),
|
||||
};
|
||||
}
|
||||
|
||||
function headRef(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
revision: overrides.head_revision ?? 1,
|
||||
snapshot_id: overrides.snapshot_id ?? SNAPSHOT_ID,
|
||||
payload_hash: overrides.payload_hash ?? "a".repeat(64),
|
||||
};
|
||||
}
|
||||
|
||||
function vaultKeyRow(): Record<string, unknown> {
|
||||
return { key_id: KEY_ID, generation: 1 };
|
||||
}
|
||||
|
||||
async function authorizedEnv(
|
||||
d1: ReturnType<typeof testD1Database>,
|
||||
options: Omit<Parameters<typeof testEnv>[0], "d1" | "kvEntries"> = {},
|
||||
): Promise<ReturnType<typeof testEnv>> {
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -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: [
|
||||
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<string, unknown> = {}): Record<s
|
||||
return objectStatusRow(overrides);
|
||||
}
|
||||
|
||||
function latestSnapshotRow(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
function snapshotHeadRow(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
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<string, unknown> = {}): Record<string, unknown> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<void>,
|
||||
): Promise<void> {
|
||||
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';
|
||||
`;
|
||||
}
|
||||
@@ -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<string, unknown>): 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<string, unknown> = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
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<string, unknown> {
|
||||
return { device_id: DEVICE_ID };
|
||||
}
|
||||
|
||||
function signingKeyRow(): Record<string, unknown> {
|
||||
return { signing_public_key: PUBLIC_KEY };
|
||||
}
|
||||
|
||||
function wrappedEnvelope(): Record<string, unknown> {
|
||||
return {
|
||||
version: 1,
|
||||
suite: SUITE,
|
||||
encapped_key: ENCAPPED_KEY,
|
||||
ciphertext: CIPHERTEXT,
|
||||
};
|
||||
}
|
||||
|
||||
function currentEnvelopeRow(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
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<string, unknown> = {}): Record<string, unknown> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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<Self>) {
|
||||
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<Self>) {
|
||||
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>) {
|
||||
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<SyncStateUpdate>) {
|
||||
fn spawn_send_otp(profile_id: ProfileId, email: String, tx: Sender<SyncStateUpdate>) {
|
||||
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<SyncStateUpdate>) {
|
||||
}
|
||||
|
||||
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(),
|
||||
};
|
||||
|
||||
@@ -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<Self>,
|
||||
) -> 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<ElyShell>,
|
||||
) -> 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(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.child(render_card_heading("Account"))
|
||||
.child(render_sign_out_button(shell, cx))
|
||||
.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<ElyShell>) -> Vec<AnyElement> {
|
||||
fn render_devices(shell: &mut ElyShell, cx: &mut Context<ElyShell>) -> 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<ElyShell>,
|
||||
) -> 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<ElyShell>,
|
||||
) -> 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<ElyShell>,
|
||||
) -> 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<ElyShell>,
|
||||
) -> Vec<AnyElement> {
|
||||
let mut elements: Vec<AnyElement> = 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));
|
||||
|
||||
@@ -133,7 +133,7 @@ pub(super) fn render_policy_toggle(
|
||||
chrome_motion_feedback(press_id, selection_id, enabled, element)
|
||||
}
|
||||
|
||||
fn render_secondary_button<F>(
|
||||
pub(super) fn render_secondary_button<F>(
|
||||
shell: &ElyShell,
|
||||
id: &'static str,
|
||||
label: &'static str,
|
||||
|
||||
@@ -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<u64>,
|
||||
sync_upload_pending_merge: Option<PendingMergeUpload>,
|
||||
sync_retry_at: Option<std::time::Instant>,
|
||||
pub(crate) sync_devices: SyncDeviceUiState,
|
||||
pub(crate) sync_verification_input: Entity<InputState>,
|
||||
pub(crate) auth_email_input: Entity<InputState>,
|
||||
pub(crate) auth_otp_input: Entity<InputState>,
|
||||
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();
|
||||
|
||||
@@ -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<u64>) {
|
||||
fn trigger_cloud_sync_upload_with_merge(&mut self, mut merge: Option<PendingMergeUpload>) {
|
||||
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<u8>,
|
||||
logical_clock_floor: Option<u64>,
|
||||
merge: Option<PendingMergeUpload>,
|
||||
inbox: std::sync::mpsc::Sender<SyncStateUpdate>,
|
||||
) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<ProfileId>,
|
||||
phase: DeviceUiPhase,
|
||||
devices: Vec<DeviceRecord>,
|
||||
current_code: Option<String>,
|
||||
revoke_confirmation: Option<String>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
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<DeviceRecord>,
|
||||
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>) {
|
||||
self.load_sync_devices(false, cx);
|
||||
}
|
||||
|
||||
pub(crate) fn refresh_sync_devices(&mut self, cx: &mut Context<Self>) {
|
||||
self.load_sync_devices(true, cx);
|
||||
}
|
||||
|
||||
pub(crate) fn approve_sync_device(&mut self, device_id: String, cx: &mut Context<Self>) {
|
||||
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<Self>) {
|
||||
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<Self>) {
|
||||
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<SyncStateUpdate, ely_sync_client::SyncClientError> {
|
||||
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<F>(
|
||||
name: &str,
|
||||
profile_id: ProfileId,
|
||||
tx: std::sync::mpsc::Sender<SyncStateUpdate>,
|
||||
task: F,
|
||||
) where
|
||||
F: FnOnce() -> Result<SyncStateUpdate, ely_sync_client::SyncClientError> + 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());
|
||||
}
|
||||
}
|
||||
@@ -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<u8>, 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<u8>, 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<DeviceRecord>, 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<u64>) {
|
||||
pub(super) fn queue_cloud_sync_upload(&mut self, merge: Option<PendingMergeUpload>) {
|
||||
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<SyncConnectionState> = 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 => {
|
||||
SyncStateUpdate::SignedOut { profile_id } => {
|
||||
upload_finished = true;
|
||||
if active_profile_id(&self.state).as_ref() == Some(&profile_id) {
|
||||
latest_connection = Some(SyncConnectionState::SignedOut);
|
||||
upload_finished = true;
|
||||
}
|
||||
SyncStateUpdate::AwaitingDeviceApproval => {
|
||||
}
|
||||
SyncStateUpdate::AwaitingDeviceApproval { profile_id } => {
|
||||
upload_finished = true;
|
||||
if active_profile_id(&self.state).as_ref() == Some(&profile_id) {
|
||||
latest_connection = Some(SyncConnectionState::AwaitingDeviceApproval);
|
||||
upload_finished = true;
|
||||
}
|
||||
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,32 +194,66 @@ impl ElyShell {
|
||||
}
|
||||
}
|
||||
}
|
||||
SyncStateUpdate::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 });
|
||||
upload_finished = true;
|
||||
}
|
||||
SyncStateUpdate::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::SyncError { profile_id, message } => {
|
||||
upload_finished = true;
|
||||
if active_profile_id(&self.state).as_ref() == Some(&profile_id) {
|
||||
latest_connection = Some(SyncConnectionState::SyncError { message });
|
||||
upload_finished = true;
|
||||
}
|
||||
SyncStateUpdate::AuthOtpSent { email } => {
|
||||
self.auth_flow_phase = auth::AuthFlowPhase::AwaitingOtp { email };
|
||||
}
|
||||
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::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 { email } => {
|
||||
}
|
||||
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 { email, message } => {
|
||||
self.auth_flow_phase = auth::AuthFlowPhase::Error { email, message };
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let connection_changed = latest_connection.is_some();
|
||||
if let (Some(state), ShellState::Ready(core)) = (latest_connection, &mut self.state) {
|
||||
@@ -194,15 +267,30 @@ 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();
|
||||
}
|
||||
|
||||
auth_changed || trigger_initial_sync || merged_upload_requested || connection_changed
|
||||
if retry_due {
|
||||
self.trigger_cloud_sync_upload();
|
||||
}
|
||||
|
||||
auth_changed
|
||||
|| devices_changed
|
||||
|| trigger_initial_sync
|
||||
|| merged_upload_requested
|
||||
|| retry_due
|
||||
|| connection_changed
|
||||
}
|
||||
}
|
||||
|
||||
fn active_profile_id(state: &ShellState) -> Option<ProfileId> {
|
||||
let ShellState::Ready(core) = state else {
|
||||
return None;
|
||||
};
|
||||
core.snapshot().ok().map(|snapshot| snapshot.active_profile_id)
|
||||
}
|
||||
|
||||
fn probe_initial_sync_state_at(
|
||||
|
||||
@@ -12,6 +12,7 @@ serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
url.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -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<SyncOutcome>,
|
||||
}
|
||||
@@ -30,12 +39,18 @@ impl SyncEngine {
|
||||
platform: impl Into<String>,
|
||||
) -> Result<Self, SyncClientError> {
|
||||
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<u8>) -> Result<SyncOutcome, SyncClientError> {
|
||||
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() => {
|
||||
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: latest.snapshot_id,
|
||||
logical_clock: latest.logical_clock,
|
||||
payload_bytes: latest.size_bytes,
|
||||
device_id: latest.device_id,
|
||||
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<u8>,
|
||||
logical_clock_floor: u64,
|
||||
merge_base: AuthenticatedSnapshotHead,
|
||||
) -> Result<SyncOutcome, SyncClientError> {
|
||||
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<Option<SyncApiClient>, SyncClientError> {
|
||||
let registration = client.register_device(
|
||||
&self.identity,
|
||||
&device_registration_idempotency_key(&self.identity),
|
||||
)?;
|
||||
) -> Result<Option<(SyncApiClient, String)>, 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,22 +170,90 @@ 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<u8>,
|
||||
base: Option<&AuthenticatedSnapshotHead>,
|
||||
) -> Result<SyncOutcome, SyncClientError> {
|
||||
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)?;
|
||||
)?;
|
||||
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,
|
||||
@@ -179,21 +261,82 @@ impl SyncEngine {
|
||||
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<SyncOutcome, SyncClientError> {
|
||||
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<AuthenticatedRemote, SyncClientError> {
|
||||
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<AccountKey, SyncClientError> {
|
||||
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<u8>,
|
||||
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<u8>,
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
use ely_sync_client::{
|
||||
SyncClientError, SyncLatestSnapshotDocument, SyncSnapshotHeadConflictDocument,
|
||||
};
|
||||
|
||||
pub(super) fn conflict_head(
|
||||
conflict: SyncSnapshotHeadConflictDocument,
|
||||
) -> Result<SyncLatestSnapshotDocument, SyncClientError> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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<DeviceListResponse, SyncClientError> {
|
||||
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<DeviceApprovalDocument, SyncClientError> {
|
||||
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<DeviceRevocationDocument, SyncClientError> {
|
||||
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<SyncApiClient, SyncClientError> {
|
||||
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::<Vec<_>>();
|
||||
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<Vec<(String, WrappedAccountKey)>, 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<ResolvedVault, SyncClientError> {
|
||||
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<ResolvedVault, SyncClientError> {
|
||||
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<ResolvedVault, SyncClientError> {
|
||||
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<AccountKey, SyncClientError> {
|
||||
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, SyncClientError> {
|
||||
AccountKeyStore::new(user_id, &self.account_key_lock_dir)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<DeviceRecordDocument, SyncClientError> {
|
||||
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::<DeviceListResponse>(&endpoint, response)
|
||||
}
|
||||
|
||||
/// Rebind an existing v2 device to a fresh authenticated session.
|
||||
pub fn rebind_device(
|
||||
&self,
|
||||
identity: &DeviceIdentity,
|
||||
) -> Result<DeviceRebindDocument, SyncClientError> {
|
||||
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::<DeviceRebindChallengeDocument>(
|
||||
&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::<DeviceRebindDocument>(&rebind_endpoint, rebind_response)?;
|
||||
document.validate(identity, &challenge, current_time_seconds()?)?;
|
||||
Ok(document)
|
||||
}
|
||||
|
||||
pub fn approve_device(
|
||||
&self,
|
||||
request: &DeviceApprovalRequest<'_>,
|
||||
) -> Result<DeviceApprovalDocument, SyncClientError> {
|
||||
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::<DeviceApprovalDocument>(&endpoint, response)
|
||||
}
|
||||
|
||||
pub fn revoke_device(
|
||||
&self,
|
||||
request: &DeviceRevocationRequest,
|
||||
) -> Result<DeviceRevocationDocument, SyncClientError> {
|
||||
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::<DeviceRevocationDocument>(&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::<SyncStatusDocument>(&endpoint, response)
|
||||
}
|
||||
|
||||
pub fn current_sync_vault(&self) -> Result<SyncVaultDocument, SyncClientError> {
|
||||
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::<SyncVaultDocument>(&endpoint, response)
|
||||
}
|
||||
|
||||
pub fn sync_vault_generation(
|
||||
&self,
|
||||
generation: u64,
|
||||
key_id: &str,
|
||||
) -> Result<SyncVaultDocument, SyncClientError> {
|
||||
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::<SyncVaultDocument>(&endpoint, response)
|
||||
}
|
||||
|
||||
pub fn bootstrap_sync_vault(
|
||||
&self,
|
||||
request: &SyncVaultBootstrapRequest<'_>,
|
||||
) -> Result<SyncVaultDocument, SyncClientError> {
|
||||
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::<SyncVaultDocument>(&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<SnapshotUploadDocument, SyncClientError> {
|
||||
) -> Result<SnapshotUploadResult, SyncClientError> {
|
||||
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::<SnapshotUploadDocument>(&endpoint, response)
|
||||
match response {
|
||||
Ok(response) => read_json_from_response::<SnapshotUploadDocument>(&endpoint, response)
|
||||
.map(SnapshotUploadResult::Committed),
|
||||
Err(ureq::Error::Status(409, response)) => {
|
||||
let conflict = read_json_from_response::<SyncSnapshotHeadConflictDocument>(
|
||||
&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::<SnapshotUploadDocument>(&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<SnapshotDownload, SyncClientError> {
|
||||
let endpoint = self.endpoint(&format!("/api/sync/snapshot?snapshot_id={snapshot_id}"));
|
||||
head: &SnapshotHeadRef,
|
||||
) -> Result<SnapshotDownloadResult, SyncClientError> {
|
||||
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::<SnapshotDownload>(&endpoint, response)
|
||||
match response {
|
||||
Ok(response) => read_json_from_response::<SnapshotDownload>(&endpoint, response)
|
||||
.map(SnapshotDownloadResult::Downloaded),
|
||||
Err(ureq::Error::Status(409, response)) => {
|
||||
let conflict = read_json_from_response::<SyncSnapshotHeadConflictDocument>(
|
||||
&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::<SnapshotDownload>(&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<SyncLatestSnapshotDocument>,
|
||||
pub head: Option<SyncLatestSnapshotDocument>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Deserialize)]
|
||||
pub struct SyncLatestSnapshotDocument {
|
||||
pub head_revision: u64,
|
||||
pub base_head: Option<SnapshotHeadRef>,
|
||||
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, SyncClientError> {
|
||||
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<SyncLatestSnapshotDocument>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Deserialize)]
|
||||
pub struct SyncDeviceStatusDocument {
|
||||
pub approved_count: u64,
|
||||
@@ -230,18 +424,7 @@ fn read_json_response<T: DeserializeOwned>(
|
||||
response: Result<ureq::Response, ureq::Error>,
|
||||
) -> Result<T, SyncClientError> {
|
||||
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::<T>(&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<T: DeserializeOwned>(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_json_from_response<T: DeserializeOwned>(
|
||||
endpoint: &str,
|
||||
response: ureq::Response,
|
||||
) -> Result<T, SyncClientError> {
|
||||
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::<T>(&body)
|
||||
.map_err(|source| SyncClientError::Json { endpoint: endpoint.to_string(), source })
|
||||
}
|
||||
|
||||
fn current_time_seconds() -> Result<u64, SyncClientError> {
|
||||
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;
|
||||
|
||||
@@ -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<std::io::Result<()>>;
|
||||
|
||||
#[test]
|
||||
fn upload_parses_structured_snapshot_head_conflict() -> Result<(), Box<dyn Error>> {
|
||||
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<dyn Error>> {
|
||||
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<dyn Error>> {
|
||||
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<dyn Error>> {
|
||||
match server.join() {
|
||||
Ok(result) => result.map_err(Into::into),
|
||||
Err(_) => Err("snapshot conflict server thread panicked".into()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
use keyring::{Entry, Error as KeyringError};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
pub(crate) fn load_secret(
|
||||
service: &str,
|
||||
account: &str,
|
||||
) -> Result<Option<Zeroizing<Vec<u8>>>, 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, String> {
|
||||
Entry::new(service, account).map_err(|error| error.to_string())
|
||||
}
|
||||
@@ -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
|
||||
/// `<profile_data>/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<String>,
|
||||
platform: impl Into<String>,
|
||||
) -> Result<Self, SyncClientError> {
|
||||
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()
|
||||
))
|
||||
})?;
|
||||
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<String>, platform: impl Into<String>) -> 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<String>,
|
||||
platform: impl Into<String>,
|
||||
) -> Result<Self, SyncClientError> {
|
||||
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<String, SyncClientError> {
|
||||
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",
|
||||
)?;
|
||||
<X25519HkdfSha256 as Kem>::PublicKey::from_bytes(&wrapping_public_key)
|
||||
.map_err(|_| key_error("device wrapping public key is invalid"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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"));
|
||||
}
|
||||
|
||||
let private_key =
|
||||
<X25519HkdfSha256 as Kem>::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<Self, SyncClientError> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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> {
|
||||
if !is_device_id_shape(&self.device_id) {
|
||||
return Err(SyncClientError::TokenStorage(
|
||||
"device_id does not match the Cloudflare worker pattern".to_string(),
|
||||
));
|
||||
}
|
||||
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()));
|
||||
}
|
||||
Ok(())
|
||||
validate_common(&self.device_id, &self.public_key, &self.device_name, &self.platform, false)
|
||||
}
|
||||
}
|
||||
|
||||
fn is_device_id_shape(value: &str) -> bool {
|
||||
enum StoredIdentity {
|
||||
V2(DeviceIdentity),
|
||||
Legacy(LegacyDeviceIdentity),
|
||||
}
|
||||
|
||||
fn decode_stored_identity(contents: &str, path: &Path) -> Result<StoredIdentity, SyncClientError> {
|
||||
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<u8> {
|
||||
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<String>) -> 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<String>,
|
||||
pub device_name: String,
|
||||
pub platform: String,
|
||||
pub approval_status: String,
|
||||
pub current: bool,
|
||||
pub created_at: u64,
|
||||
pub approved_at: Option<u64>,
|
||||
pub last_active_at: Option<u64>,
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<DeviceRebindRequest, SyncClientError> {
|
||||
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::<Vec<_>>();
|
||||
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<Self, SyncClientError> {
|
||||
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<Vec<u8>, 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::<String>();
|
||||
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::<String>();
|
||||
assert_eq!(
|
||||
signature_hex,
|
||||
"f12fb7a5f7f20551bd22d0fcf8f5787d49f6202f89e42c332c248772fd9a59c82a9d8b6ac47ea84340170fc1555fc74d70a0d6ba3541df257882d46d6d79d901"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -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<String, SyncClientError> {
|
||||
let message = self.registration_proof_message(idempotency_key)?;
|
||||
self.sign_message(&message)
|
||||
}
|
||||
|
||||
pub fn verification_code(&self) -> Result<String, SyncClientError> {
|
||||
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<Vec<u8>, 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<String, SyncClientError> {
|
||||
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<String, SyncClientError> {
|
||||
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::<Vec<_>>()
|
||||
.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<u8>, 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<String>) -> 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::<String>();
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -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<DeviceRevocationEnvelope>,
|
||||
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<Self, SyncClientError> {
|
||||
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<Self, SyncClientError> {
|
||||
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<u8> {
|
||||
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<Vec<DeviceRevocationEnvelope>, 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::<Result<Vec<_>, 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<u8> {
|
||||
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::<String>();
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -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<String>) -> Result<Self, SyncClientError> {
|
||||
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<Option<DeviceSecrets>, 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<DeviceSecrets, SyncClientError> {
|
||||
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<Vec<u8>>) -> Result<DeviceSecrets, SyncClientError> {
|
||||
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<String>) -> 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());
|
||||
}
|
||||
}
|
||||
@@ -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<Sha256>;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AccountKey(Zeroizing<[u8; KEY_BYTES]>);
|
||||
|
||||
impl AccountKey {
|
||||
pub fn generate() -> Result<Self, SyncClientError> {
|
||||
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<String, SyncClientError> {
|
||||
let key = self.derived_key(CONTENT_KEY_INFO)?;
|
||||
let mut mac = <HmacSha256 as 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<EncryptedSnapshot, SyncClientError> {
|
||||
self.encrypt_with_version(context, plaintext, SNAPSHOT_ENCRYPTION_VERSION)
|
||||
}
|
||||
|
||||
fn encrypt_with_version(
|
||||
&self,
|
||||
context: &SnapshotCryptoContext<'_>,
|
||||
plaintext: &[u8],
|
||||
encryption_version: u32,
|
||||
) -> Result<EncryptedSnapshot, SyncClientError> {
|
||||
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<Vec<u8>, 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::<KEY_BYTES>(envelope, offset)?;
|
||||
offset += KEY_BYTES;
|
||||
let content_hash_bytes = array_at::<HASH_BYTES>(envelope, offset)?;
|
||||
offset += HASH_BYTES;
|
||||
let nonce = array_at::<NONCE_BYTES>(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<Zeroizing<[u8; KEY_BYTES]>, SyncClientError> {
|
||||
let hkdf = Hkdf::<Sha256>::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<String>,
|
||||
payload_hash: impl Into<String>,
|
||||
) -> Result<Self, SyncClientError> {
|
||||
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<u8>,
|
||||
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<Vec<u8>, 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<u8>,
|
||||
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<u8>, 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<const N: usize>(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<u8, SyncClientError> {
|
||||
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;
|
||||
@@ -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(())
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user