fix(sync): secure encrypted snapshot lifecycle

This commit is contained in:
2026-07-10 06:24:53 -04:00
parent 556c5ff624
commit 540b901fd6
106 changed files with 18026 additions and 3309 deletions
Generated
+113
View File
@@ -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"
+9
View File
@@ -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.
+115 -62
View File
@@ -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-Poly1305AAD 绑定 user、generation、snapshot、schema、logical clock、device 和完整 head lineage。
- 新设备先进入 pendingapproved 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 headlogical 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 summaryhead/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 分钟 leasecandidate、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 sessionKV 不参与 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 proofproof 绑定 action、user、session、device、confirmation、idempotency key 与创建时间。
- Destructive D1 batch 首条执行 live session/device/key gateguard 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 proofD1 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 markermarker 绑定 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);
+45
View File
@@ -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;
+309
View File
@@ -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;
+195 -79
View File
@@ -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);
const maxBatches = Math.ceil(candidateCount / 100) + 1;
for (let batch = 0; batch < maxBatches; batch += 1) {
if (await collectSyncR2Garbage(env, nowSeconds, { ownerHash: accountHash, limit: 100 }) < 100) {
break;
}
}
throw error;
} catch {
// Scheduled maintenance drains the durable GC ledger.
}
try {
return await deleteLegacySessionKeys(env, sessionTokens, currentTokenHash);
} catch {
return 0;
}
}
function deleteCurrentSessionCache(env: Env, tokenHash: string): Promise<void> {
return env.ELY_KV.delete(authSessionCacheKvKey(env.ELY_ENVIRONMENT, tokenHash));
}
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> {
+7 -3
View File
@@ -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(
+4
View File
@@ -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) {
+9 -1
View File
@@ -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 {
+143
View File
@@ -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;
}
+406
View File
@@ -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>();
}
+65
View File
@@ -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(""));
}
+31
View File
@@ -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;
}
+345
View File
@@ -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);
}
+339
View File
@@ -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");
}
+254
View File
@@ -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(""));
}
+221
View File
@@ -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,
);
}
+163
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+70
View File
@@ -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;
}
+228
View File
@@ -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(""));
}
+20 -9
View File
@@ -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") {
-201
View File
@@ -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;
}
-281
View File
@@ -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");
}
}
-348
View File
@@ -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("");
}
+342
View File
@@ -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("");
}
+154
View File
@@ -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;
}
+22
View File
@@ -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
View File
@@ -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);
}
if (!(await destructiveActionGateIsLive(
replayDatabase,
context,
signingPublicKey,
nowSeconds,
))) {
throw new RecentDeviceActionPermissionError("device_action_gate_failed");
}
throw error;
}
await env.ELY_DB.batch(syncResetStatements(env, context.userId, deviceId, eventId, nowSeconds));
assertDestructiveActionGateResult(results[0]);
await collectResetGarbage(env, context.userId, nowSeconds, r2Keys.length);
return {
version: 1,
@@ -152,10 +192,10 @@ function existingResetDocument(
}
async function syncResetCounts(
env: Env,
database: ElyD1DatabaseSession,
userId: string,
): Promise<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> {
+85 -51
View File
@@ -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,
+350 -349
View File
@@ -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));
const upload = await syncSnapshotUploadRequest(request, context.userId);
const database = primaryD1Session(env.ELY_DB);
const currentHead = await readCurrentHead(database, context.userId);
if (currentHead !== null && snapshotMatchesUpload(upload, deviceId, currentHead)) {
try {
await assertCurrentSyncVaultKey(
env,
context.userId,
upload.keyId,
upload.vaultGeneration,
database,
);
} catch (error) {
if (error instanceof SyncVaultConflictError || error instanceof SyncVaultNotFoundError) {
return uploadDocument(context.userId, deviceId, currentHead);
}
throw error;
}
await cleanupAfterSnapshot(env, context.userId, currentHead, nowSeconds);
return uploadDocument(context.userId, deviceId, currentHead);
}
await persistSnapshot(env, snapshot);
await env.ELY_DB.batch([
env.ELY_DB.prepare(SYNC_SNAPSHOT_UPSERT_QUERY).bind(
try {
await assertCurrentSyncVaultKey(
env,
context.userId,
snapshot.snapshotId,
snapshot.r2Key,
snapshot.payloadHash,
snapshot.schemaRev,
snapshot.logicalClock,
deviceId,
snapshot.bytes.byteLength,
nowSeconds,
),
]);
const savedRow = await snapshotRow(env, context.userId, snapshot.snapshotId);
if (savedRow === null) {
throw new SyncSnapshotPersistenceError("sync_snapshot_missing");
upload.keyId,
upload.vaultGeneration,
database,
);
} catch (error) {
if (error instanceof SyncVaultConflictError || error instanceof SyncVaultNotFoundError) {
throw new SyncSnapshotConflictError("sync_vault_key_not_current", currentHead);
}
throw error;
}
const savedSnapshot = syncSnapshotDocumentFromRow(savedRow);
assertSavedSnapshotMatchesUpload(snapshot, deviceId, savedSnapshot);
return {
version: 1,
user_id: context.userId,
device_id: deviceId,
snapshot: savedSnapshot,
};
assertUploadBase(upload, currentHead);
let writeLease: SyncR2WriteLease;
try {
writeLease = await claimSnapshotStorageWrite(
env,
database,
context.userId,
deviceId,
upload,
nowSeconds,
);
} catch (error) {
if (error instanceof SyncR2WriteFenceError) {
const head = await readCurrentHead(database, context.userId);
throw new SyncSnapshotConflictError("sync_snapshot_head_conflict", head);
}
throw error;
}
try {
await persistClaimedSnapshot(env, upload);
} catch (error) {
await releaseFailedSnapshotWrite(
env,
database,
context.userId,
upload.r2Key,
writeLease,
nowSeconds,
);
throw error;
}
let results: ElyD1Result<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) {
throw new SyncSnapshotNotFoundError("sync_snapshot_missing");
const database = primaryD1Session(env.ELY_DB);
const token = syncSnapshotDownloadQuery(url);
const snapshot = await readSnapshotByToken(database, context.userId, token);
if (snapshot === null) {
const currentHead = await readCurrentHead(database, context.userId);
if (currentHead === null) {
throw new SyncSnapshotNotFoundError("sync_snapshot_missing");
}
throw new SyncSnapshotConflictError("sync_snapshot_download_token_stale", currentHead);
}
const snapshot = syncSnapshotDocumentFromRow(row);
let payload: ArrayBuffer | null;
try {
payload = await getVerifiedObject(env.ELY_STORAGE, snapshot.r2_key, snapshot.payload_hash);
} catch (error) {
if (error instanceof StorageObjectError) {
throw new SyncSnapshotPersistenceError(error.message);
await throwDownloadStorageFailure(env, context.userId, token, error.message);
}
throw error;
}
if (payload === null) {
throw new SyncSnapshotPersistenceError("sync_snapshot_payload_missing");
return throwDownloadStorageFailure(
env,
context.userId,
token,
"sync_snapshot_payload_missing",
);
}
return {
version: 1,
user_id: context.userId,
device_id: deviceId,
snapshot,
...uploadDocument(context.userId, deviceId, snapshot),
data_base64: base64FromBytes(payload),
};
}
@@ -212,282 +279,216 @@ async function syncSnapshotUploadRequest(
"snapshot_id",
"region",
"payload_hash",
"encryption_version",
"vault_generation",
"key_id",
"content_hash",
"schema_rev",
"logical_clock",
"head_revision",
"base_head",
"data_base64",
]);
if (body.version !== 1) {
if (body.version !== 3) {
throw new SyncSnapshotRequestError("version_invalid");
}
const snapshotId = snapshotIdValue(body.snapshot_id);
const region = regionValue(body.region);
const payloadHash = sha256HexValue(body.payload_hash, "payload_hash");
const headRevision = integer(
body.head_revision,
"head_revision",
1,
Number.MAX_SAFE_INTEGER,
);
const baseHead = snapshotHeadRefValue(body.base_head, headRevision);
const bytes = payloadBytes(body.data_base64, "data_base64", MAX_SNAPSHOT_BYTES);
await assertPayloadHash(bytes, payloadHash);
return {
snapshotId,
r2Key: await snapshotStorageKey(region, userId, snapshotId),
r2Key: await snapshotStorageKey(
regionValue(body.region),
userId,
snapshotId,
payloadHash,
),
payloadHash,
encryptionVersion: exactInteger(body.encryption_version, "encryption_version", 2),
vaultGeneration: integer(
body.vault_generation,
"vault_generation",
1,
Number.MAX_SAFE_INTEGER,
),
keyId: sha256HexValue(body.key_id, "key_id"),
contentHash: sha256HexValue(body.content_hash, "content_hash"),
schemaRev: integer(body.schema_rev, "schema_rev", 1, Number.MAX_SAFE_INTEGER),
logicalClock: integer(body.logical_clock, "logical_clock", 0, Number.MAX_SAFE_INTEGER),
headRevision,
baseHead,
bytes,
};
}
function syncSnapshotDownloadQuery(url: URL): string {
assertOnlyQueryParams(url, ["snapshot_id"]);
return snapshotIdValue(url.searchParams.get("snapshot_id"));
function syncSnapshotDownloadQuery(url: URL): SnapshotHeadRefDocument {
assertOnlyQueryParams(url, ["snapshot_id", "head_revision", "payload_hash"]);
return {
snapshot_id: snapshotIdValue(url.searchParams.get("snapshot_id")),
revision: integer(
numberQueryValue(url.searchParams.get("head_revision")),
"head_revision",
1,
Number.MAX_SAFE_INTEGER,
),
payload_hash: sha256HexValue(url.searchParams.get("payload_hash"), "payload_hash"),
};
}
async function snapshotStorageKey(
region: string,
userId: string,
snapshotId: string,
): Promise<string> {
try {
return syncSnapshotKey({
region,
userHash: await sha256Hex(arrayBufferFromBytes(new TextEncoder().encode(userId))),
snapshotId,
});
} catch (error) {
if (error instanceof StorageObjectError) {
throw new SyncSnapshotRequestError(error.message);
}
throw error;
function assertUploadBase(
upload: SyncSnapshotUploadRequest,
currentHead: SyncSnapshotDocument | null,
): void {
const currentRef = currentHead === null ? null : snapshotHeadRef(currentHead);
if (!sameSnapshotHead(upload.baseHead, currentRef)) {
throw new SyncSnapshotConflictError("sync_snapshot_head_conflict", currentHead);
}
if (currentHead !== null && upload.logicalClock <= currentHead.logical_clock) {
throw new SyncSnapshotConflictError("logical_clock_stale", currentHead);
}
}
async function snapshotRow(
env: Env,
userId: string,
snapshotId: string,
): Promise<SyncSnapshotRow | null> {
return env.ELY_DB.prepare(SYNC_SNAPSHOT_BY_ID_QUERY).bind(userId, snapshotId).first();
function snapshotMatchesUpload(
upload: SyncSnapshotUploadRequest,
deviceId: string,
snapshot: SyncSnapshotDocument,
): boolean {
return snapshot.snapshot_id === upload.snapshotId &&
snapshot.r2_key === upload.r2Key &&
snapshot.payload_hash === upload.payloadHash &&
snapshot.encryption_version === upload.encryptionVersion &&
snapshot.vault_generation === upload.vaultGeneration &&
snapshot.key_id === upload.keyId &&
snapshot.content_hash === upload.contentHash &&
snapshot.schema_rev === upload.schemaRev &&
snapshot.logical_clock === upload.logicalClock &&
snapshot.head_revision === upload.headRevision &&
sameSnapshotHead(snapshot.base_head, upload.baseHead) &&
snapshot.device_id === deviceId &&
snapshot.size_bytes === upload.bytes.byteLength;
}
function syncSnapshotDocumentFromRow(row: SyncSnapshotRow): SyncSnapshotDocument {
async function readCurrentHead(
database: ElyD1DatabaseSession,
userId: string,
): Promise<SyncSnapshotDocument | null> {
try {
return syncSnapshotDocument(row);
return await currentSyncSnapshotHead(database, userId);
} catch (error) {
if (error instanceof SyncSnapshotRequestError) {
if (error instanceof SyncSnapshotHeadSchemaError) {
throw new SyncSnapshotPersistenceError(error.message);
}
throw error;
}
}
function syncSnapshotDocument(row: SyncSnapshotRow): SyncSnapshotDocument {
return {
snapshot_id: snapshotIdValue(row.snapshot_id),
r2_key: text(row.r2_key, "r2_key"),
payload_hash: sha256HexValue(row.payload_hash, "payload_hash"),
schema_rev: integer(row.schema_rev, "schema_rev", 1, Number.MAX_SAFE_INTEGER),
logical_clock: integer(row.logical_clock, "logical_clock", 0, Number.MAX_SAFE_INTEGER),
device_id: deviceIdValue(row.device_id),
size_bytes: integer(row.size_bytes, "size_bytes", 1, MAX_SNAPSHOT_BYTES),
created_at: integer(row.created_at, "created_at", 0, Number.MAX_SAFE_INTEGER),
};
}
function assertSnapshotCanReplaceExisting(
snapshot: SyncSnapshotUploadRequest,
deviceId: string,
existing: SyncSnapshotDocument,
): void {
if (existing.logical_clock > snapshot.logicalClock) {
throw new SyncSnapshotConflictError("logical_clock_stale");
}
if (existing.logical_clock < snapshot.logicalClock) {
return;
}
if (
existing.payload_hash !== snapshot.payloadHash ||
existing.schema_rev !== snapshot.schemaRev ||
existing.device_id !== deviceId ||
existing.size_bytes !== snapshot.bytes.byteLength
) {
throw new SyncSnapshotConflictError("logical_clock_conflict");
}
}
function assertSavedSnapshotMatchesUpload(
upload: SyncSnapshotUploadRequest,
deviceId: string,
snapshot: SyncSnapshotDocument,
): void {
if (snapshot.logical_clock > upload.logicalClock) {
throw new SyncSnapshotConflictError("logical_clock_stale");
}
if (
snapshot.logical_clock === upload.logicalClock &&
(snapshot.r2_key !== upload.r2Key ||
snapshot.payload_hash !== upload.payloadHash ||
snapshot.schema_rev !== upload.schemaRev ||
snapshot.device_id !== deviceId ||
snapshot.size_bytes !== upload.bytes.byteLength)
) {
throw new SyncSnapshotConflictError("logical_clock_conflict");
}
if (
snapshot.snapshot_id !== upload.snapshotId ||
snapshot.r2_key !== upload.r2Key ||
snapshot.payload_hash !== upload.payloadHash ||
snapshot.schema_rev !== upload.schemaRev ||
snapshot.logical_clock !== upload.logicalClock ||
snapshot.device_id !== deviceId ||
snapshot.size_bytes !== upload.bytes.byteLength
) {
throw new SyncSnapshotPersistenceError("sync_snapshot_mismatch");
}
}
async function persistSnapshot(env: Env, snapshot: SyncSnapshotUploadRequest): Promise<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");
}
return record(value, "body");
}
function record(value: unknown, label: string): RequestBody {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new SyncSnapshotRequestError(`${label}_invalid`);
}
return value as RequestBody;
}
function assertOnlyFields(value: RequestBody, fields: string[]): void {
const allowed = new Set(fields);
for (const field of Object.keys(value)) {
if (!allowed.has(field)) {
throw new SyncSnapshotRequestError(`unexpected_field:${field}`);
await cleanupRotatedVaultStorage(
env,
userId,
snapshot.snapshot_id,
snapshot.key_id,
snapshot.vault_generation,
nowSeconds,
);
} catch (error) {
if (error instanceof SyncVaultRotationCleanupError) {
throw new SyncSnapshotPersistenceError(error.message);
}
throw error;
}
}
function assertOnlyQueryParams(url: URL, fields: string[]): void {
const allowed = new Set(fields);
for (const field of url.searchParams.keys()) {
if (!allowed.has(field)) {
throw new SyncSnapshotRequestError(`unexpected_query:${field}`);
}
}
function uploadDocument(
userId: string,
deviceId: string,
snapshot: SyncSnapshotDocument,
): SyncSnapshotUploadDocument {
return { version: 3, user_id: userId, device_id: deviceId, snapshot };
}
function snapshotIdValue(value: unknown): string {
if (typeof value !== "string" || !SNAPSHOT_ID_PATTERN.test(value)) {
throw new SyncSnapshotRequestError("snapshot_id_invalid");
}
return value;
function currentDeviceId(context: AuthContext): string {
return deviceIdValue(context.deviceId);
}
function deviceIdValue(value: unknown): string {
if (typeof value !== "string" || !DEVICE_ID_PATTERN.test(value)) {
throw new SyncSnapshotRequestError("device_id_invalid");
function numberQueryValue(value: string | null): number {
if (value === null || !/^[1-9][0-9]*$/.test(value)) {
throw new SyncSnapshotRequestError("head_revision_invalid");
}
return value;
return Number(value);
}
function regionValue(value: unknown): string {
if (typeof value !== "string" || !REGION.test(value)) {
throw new SyncSnapshotRequestError("region_invalid");
}
return value;
function changedRowCount(result: ElyD1Result): number {
const changes = result.meta?.changes;
return typeof changes === "number" && Number.isSafeInteger(changes) && changes >= 0 ? changes : -1;
}
function sha256HexValue(value: unknown, label: string): string {
if (typeof value !== "string" || !SHA256_HEX.test(value)) {
throw new SyncSnapshotRequestError(`${label}_invalid`);
function isSnapshotHeadConflict(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
}
return value;
}
function integer(value: unknown, label: string, min: number, max: number): number {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < min || value > max) {
throw new SyncSnapshotRequestError(`${label}_invalid`);
}
return value;
}
function text(value: unknown, label: string): string {
if (typeof value !== "string" || value.length === 0) {
throw new SyncSnapshotRequestError(`${label}_invalid`);
}
return value;
}
function payloadBytes(value: unknown, label: string, maxBytes: number): ArrayBuffer {
const encoded = text(value, label);
if (!BASE64.test(encoded)) {
throw new SyncSnapshotRequestError(`${label}_invalid`);
}
const bytes = bytesFromBase64(encoded);
if (bytes.byteLength === 0 || bytes.byteLength > maxBytes) {
throw new SyncSnapshotRequestError(`${label}_size_invalid`);
}
return bytes;
}
function bytesFromBase64(value: string): ArrayBuffer {
const binary = atob(value);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return bytes.buffer;
}
function base64FromBytes(payload: ArrayBuffer): string {
const bytes = new Uint8Array(payload);
const parts: string[] = [];
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
parts.push(String.fromCharCode(...bytes.subarray(offset, offset + 0x8000)));
}
return btoa(parts.join(""));
}
function arrayBufferFromBytes(bytes: Uint8Array): ArrayBuffer {
const copy = new Uint8Array(bytes.byteLength);
copy.set(bytes);
return copy.buffer;
}
async function assertPayloadHash(payload: ArrayBuffer, expectedHash: string): Promise<void> {
const actualHash = await sha256Hex(payload);
if (actualHash !== expectedHash) {
throw new SyncSnapshotRequestError("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("");
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");
}
+155
View File
@@ -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;
}
+264
View File
@@ -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>;
}
+334
View File
@@ -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;
}
+122
View File
@@ -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)));
}
+97 -83
View File
@@ -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 {
return {
snapshot_id: snapshotId(row.snapshot_id),
payload_hash: payloadHash(row.payload_hash),
logical_clock: integer(row.logical_clock, "logical_clock"),
device_id: deviceId(row.device_id),
size_bytes: integer(row.size_bytes, "size_bytes"),
created_at: integer(row.created_at, "created_at"),
};
function snapshotHeadStatus(row: SyncSnapshotRow): SyncSnapshotHeadStatusDocument {
try {
const snapshot = snapshotDocumentFromRow(row);
return {
snapshot_id: snapshot.snapshot_id,
payload_hash: snapshot.payload_hash,
encryption_version: snapshot.encryption_version,
vault_generation: snapshot.vault_generation,
key_id: snapshot.key_id,
content_hash: snapshot.content_hash,
logical_clock: snapshot.logical_clock,
head_revision: snapshot.head_revision,
base_head: snapshot.base_head,
device_id: snapshot.device_id,
size_bytes: snapshot.size_bytes,
created_at: snapshot.created_at,
};
} catch (error) {
if (error instanceof SyncSnapshotHeadSchemaError) {
throw new SyncStatusSchemaError(error.message);
}
throw error;
}
}
function deviceStatus(
@@ -241,27 +276,6 @@ function objectType(value: unknown): string {
return value;
}
function snapshotId(value: unknown): string {
if (typeof value !== "string" || !SNAPSHOT_ID_PATTERN.test(value)) {
throw new SyncStatusSchemaError("snapshot_id_invalid");
}
return value;
}
function deviceId(value: unknown): string {
if (typeof value !== "string" || !DEVICE_ID_PATTERN.test(value)) {
throw new SyncStatusSchemaError("device_id_invalid");
}
return value;
}
function payloadHash(value: unknown): string {
if (typeof value !== "string" || !SHA256_HEX.test(value)) {
throw new SyncStatusSchemaError("payload_hash_invalid");
}
return value;
}
function integer(value: unknown, label: string): number {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
throw new SyncStatusSchemaError(`${label}_invalid`);
+499
View File
@@ -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;
}
+92
View File
@@ -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
`;
+141 -40
View File
@@ -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) };
}
+3
View File
@@ -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,
};
}
+213 -16
View File
@@ -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 }),
);
}
+435 -172
View File
@@ -1,229 +1,452 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { authSessionCacheKvKey, authTokenHash } from "../src/auth.js";
import {
deviceRevocationRequest,
deviceRevocationRequestHash,
deviceRevocationProofBytes,
pendingDeviceRevocationProofBytes,
pendingDeviceRevocationRequestHash,
} from "../src/device_revocation_schema.js";
import { handleRequest } from "../src/index.js";
import {
ACCESS_TOKEN,
PUBLIC_KEY,
WRAPPING_PUBLIC_KEY,
sessionDocument,
signDeviceMessage,
testD1Database,
testEnv,
} from "./devices_test_support.js";
const DEVICE_REVOCATION_IDEMPOTENCY_KEY = "device-revocation-0001";
const DEVICE_REVOCATION_EVENT_ID = `device-revoke:user-01:${DEVICE_REVOCATION_IDEMPOTENCY_KEY}`;
const USER_ID = "user-01", APPROVER_DEVICE_ID = "device-01";
const TARGET_DEVICE_ID = "device-02", OTHER_DEVICE_ID = "device-03";
const IDEMPOTENCY_KEY = "device-revocation-0001";
const PREVIOUS_KEY_ID = "a".repeat(64), NEW_KEY_ID = "b".repeat(64);
const PREVIOUS_GENERATION = 1, NEW_GENERATION = 2;
const SUITE = "HPKE-BASE-X25519-HKDF-SHA256-CHACHA20POLY1305";
describe("device revocation routes", () => {
it("revokes a device from an approved current device", async () => {
const tokenHash = await authTokenHash(ACCESS_TOKEN);
it("atomically rotates the vault and revokes an approved device", async () => {
const body = deviceRevocationBody();
const d1 = testD1Database({
firstRows: [
deviceRow({ device_id: "device-01", approval_status: "approved" }),
approverRow(),
null,
deviceRow({ device_id: "device-02", approval_status: "approved" }),
deviceRow({
device_id: "device-02",
approval_status: "revoked",
revoked_at: 1_780_000_400,
}),
currentVaultKeyRow(),
deviceRow({ device_id: TARGET_DEVICE_ID }),
await rotationResultRow(body, { r2_object_count: 2, r2_item_count: 2 }),
deviceRow({ device_id: TARGET_DEVICE_ID, approval_status: "revoked", revoked_at: 1_780_000_400 }),
],
allRowSets: [
[{ device_id: APPROVER_DEVICE_ID }, { device_id: OTHER_DEVICE_ID }],
[{ object_count: 2 }],
],
batchChanges: [[1, 1, 1, 1]],
});
const response = await handleRequest(
new Request("https://elydora.test/api/devices/revoke", {
method: "POST",
headers: {
authorization: `Bearer ${ACCESS_TOKEN}`,
"content-type": "application/json",
},
body: JSON.stringify(deviceRevocationBody()),
}),
testEnv({
d1,
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument("device-01")]],
}),
);
const response = await revocationResponse(d1, body);
assert.equal(response.status, 200);
assert.equal(response.headers.get("cache-control"), "no-store");
assert.deepEqual(await response.json(), {
version: 1,
user_id: "user-01",
revoked_by_device_id: "device-01",
revoked_at: 1_780_000_400,
device: {
device_id: "device-02",
public_key: PUBLIC_KEY,
device_name: "MacBook Pro",
platform: "macOS",
approval_status: "revoked",
created_at: 1_780_000_000,
approved_at: 1_780_000_010,
last_active_at: 1_780_000_020,
revoked_at: 1_780_000_400,
current: false,
},
});
assert.equal(d1.batches[0], 2);
assert.ok(d1.queries[0]?.includes("approval_status = 'approved'"));
assert.ok(d1.queries[1]?.includes("FROM audit_events"));
assert.ok(d1.queries[3]?.includes("INSERT INTO audit_events"));
assert.ok(d1.queries[4]?.includes("UPDATE user_devices"));
assert.deepEqual(d1.binds[0], ["user-01", "device-01"]);
assert.deepEqual(d1.binds[1], ["user-01", DEVICE_REVOCATION_EVENT_ID]);
assert.deepEqual(d1.binds[2], ["user-01", "device-02"]);
assert.deepEqual(d1.binds[3]?.slice(0, 4), [
DEVICE_REVOCATION_EVENT_ID,
"user-01",
"device-01",
"device-02",
]);
assert.deepEqual(d1.binds[5], ["user-01", "device-02"]);
assert.deepEqual(await response.json(), revocationDocument());
assert.deepEqual(d1.batches, [4]);
assert.ok(d1.queries[6]?.includes("INSERT INTO sync_vault_rotations"));
assert.ok(d1.queries[7]?.includes("INSERT INTO sync_vault_rotation_envelopes"));
assert.ok(d1.queries[5]?.includes("COUNT(*) AS object_count"));
assert.ok(d1.queries[9]?.includes("SET completed_at = ?"));
assert.deepEqual(d1.binds[6]?.slice(0, 2), [USER_ID, IDEMPOTENCY_KEY]);
assert.match(String(d1.binds[6]?.[2]), /^device-revoke:[a-f0-9]{64}$/);
assert.deepEqual(d1.binds[6]?.slice(3, 5), [TARGET_DEVICE_ID, APPROVER_DEVICE_ID]);
});
it("returns the existing revocation for an idempotent replay", async () => {
const tokenHash = await authTokenHash(ACCESS_TOKEN);
it("revokes a pending target without rotating or cleaning the vault", async () => {
const body = pendingRevocationBody();
const d1 = testD1Database({
firstRows: [
deviceRow({ device_id: "device-01", approval_status: "approved" }),
{
actor_device_id: "device-01",
subject_id: "device-02",
outcome: "success",
created_at: 1_780_000_400,
},
deviceRow({
device_id: "device-02",
approval_status: "revoked",
revoked_at: 1_780_000_400,
}),
approverRow(),
null,
deviceRow({ approval_status: "pending", approved_at: null }),
await pendingResultRow(body),
deviceRow({ approval_status: "revoked", approved_at: null, revoked_at: 1_780_000_400 }),
],
batchChanges: [[1, 1]],
});
const response = await revocationResponse(d1, body);
assert.equal(response.status, 200);
assert.deepEqual(await response.json(), pendingRevocationDocument());
assert.deepEqual(d1.batches, [2]);
assert.ok(d1.queries[3]?.includes("INSERT INTO pending_device_revocations"));
assert.ok(d1.queries[4]?.includes("SET completed_at = ?"));
assert.equal(d1.queries.some((query) => query.includes("sync_vault_accounts")), false);
});
it("returns an exact pending revocation replay and rejects mismatches", async () => {
const body = pendingRevocationBody();
const replay = testD1Database({
firstRows: [
approverRow(),
await pendingResultRow(body),
deviceRow({ approval_status: "revoked", approved_at: null, revoked_at: 1_780_000_400 }),
],
});
assert.equal((await revocationResponse(replay, body)).status, 200);
assert.deepEqual(replay.batches, []);
const mismatch = testD1Database({
firstRows: [approverRow(), { ...await pendingResultRow(body), request_hash: "f".repeat(64) }],
});
assert.equal((await revocationResponse(mismatch, body)).status, 409);
assert.deepEqual(mismatch.batches, []);
});
it("rejects pending mode for an approved target and trigger races", async () => {
const body = pendingRevocationBody();
const approved = testD1Database({
firstRows: [approverRow(), null, deviceRow()],
});
assert.equal((await revocationResponse(approved, body)).status, 409);
assert.deepEqual(approved.batches, []);
const race = testD1Database({
firstRows: [approverRow(), null, deviceRow({ approval_status: "pending", approved_at: null })],
batchError: new Error("pending_device_revocation_guard_failed"),
});
assert.equal((await revocationResponse(race, body)).status, 409);
});
it("returns an exact idempotent replay", async () => {
const body = deviceRevocationBody();
const d1 = testD1Database({
firstRows: [
approverRow(),
await rotationResultRow(body),
deviceRow({ device_id: TARGET_DEVICE_ID, approval_status: "revoked", revoked_at: 1_780_000_400 }),
],
});
const response = await handleRequest(
new Request("https://elydora.test/api/devices/revoke", {
method: "POST",
headers: {
authorization: `Bearer ${ACCESS_TOKEN}`,
"content-type": "application/json",
},
body: JSON.stringify(deviceRevocationBody()),
}),
testEnv({
d1,
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument("device-01")]],
}),
);
const response = await revocationResponse(d1, body);
assert.equal(response.status, 200);
const body = (await response.json()) as { revoked_at: number };
assert.equal(body.revoked_at, 1_780_000_400);
assert.deepEqual(await response.json(), revocationDocument());
assert.deepEqual(d1.batches, []);
assert.deepEqual(d1.binds, [
["user-01", "device-01"],
["user-01", DEVICE_REVOCATION_EVENT_ID],
["user-01", "device-02"],
]);
assert.equal(d1.queries.length, 3);
});
it("rejects revocation from a current device that is not approved", async () => {
const tokenHash = await authTokenHash(ACCESS_TOKEN);
const d1 = testD1Database({ firstRows: [null] });
const response = await handleRequest(
new Request("https://elydora.test/api/devices/revoke", {
method: "POST",
headers: {
authorization: `Bearer ${ACCESS_TOKEN}`,
"content-type": "application/json",
},
body: JSON.stringify(deviceRevocationBody()),
}),
testEnv({
d1,
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument("device-01")]],
}),
);
it("rejects a replay with different rotation metadata", async () => {
const body = deviceRevocationBody();
const d1 = testD1Database({
firstRows: [
approverRow(),
await rotationResultRow(body, { new_key_id: "c".repeat(64) }),
],
});
assert.equal(response.status, 403);
assert.deepEqual(await response.json(), { error: "device_revocation_forbidden" });
const response = await revocationResponse(d1, body);
assert.equal(response.status, 409);
assert.deepEqual(await response.json(), { error: "device_revocation_conflict" });
assert.deepEqual(d1.batches, []);
});
it("rejects self revocation before D1 writes", async () => {
const tokenHash = await authTokenHash(ACCESS_TOKEN);
const d1 = testD1Database([]);
const response = await handleRequest(
new Request("https://elydora.test/api/devices/revoke", {
method: "POST",
headers: {
authorization: `Bearer ${ACCESS_TOKEN}`,
"content-type": "application/json",
},
body: JSON.stringify({ ...deviceRevocationBody(), device_id: "device-01" }),
}),
testEnv({
d1,
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument("device-01")]],
}),
);
assert.equal(response.status, 403);
assert.deepEqual(d1.queries, []);
it("rejects missing and extra recipient envelopes", async () => {
const cases = [
{
body: deviceRevocationBody({ envelopes: [rotationEnvelope(APPROVER_DEVICE_ID)] }),
rows: [{ device_id: APPROVER_DEVICE_ID }, { device_id: OTHER_DEVICE_ID }],
},
{
body: deviceRevocationBody(),
rows: [{ device_id: APPROVER_DEVICE_ID }],
},
];
for (const testCase of cases) {
const d1 = preflightD1(testCase.rows);
const response = await revocationResponse(d1, testCase.body);
assert.equal(response.status, 409);
assert.deepEqual(await response.json(), { error: "device_revocation_conflict" });
assert.deepEqual(d1.batches, []);
}
});
it("rejects invalid revocation payloads before D1 writes", async () => {
const tokenHash = await authTokenHash(ACCESS_TOKEN);
const d1 = testD1Database([]);
const response = await handleRequest(
new Request("https://elydora.test/api/devices/revoke", {
method: "POST",
headers: {
authorization: `Bearer ${ACCESS_TOKEN}`,
"content-type": "application/json",
},
body: JSON.stringify({ ...deviceRevocationBody(), idempotency_key: "short" }),
}),
testEnv({
d1,
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument("device-01")]],
}),
);
it("rejects stale vault metadata before target reads", async () => {
const d1 = testD1Database({
firstRows: [
approverRow(),
null,
{ key_id: "c".repeat(64), generation: PREVIOUS_GENERATION },
],
});
assert.equal(response.status, 400);
assert.deepEqual(await response.json(), { error: "invalid_device_revocation" });
assert.deepEqual(d1.queries, []);
const response = await revocationResponse(d1, deviceRevocationBody());
assert.equal(response.status, 409);
assert.deepEqual(d1.batches, []);
assert.equal(d1.queries.length, 3);
});
it("rejects unauthenticated device revocation before D1 writes", async () => {
const d1 = testD1Database([]);
const response = await handleRequest(
new Request("https://elydora.test/api/devices/revoke", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(deviceRevocationBody()),
}),
testEnv({ d1 }),
);
it("fails a rotation race when the D1 guard aborts", async () => {
const d1 = testD1Database({
firstRows: [
approverRow(),
null,
currentVaultKeyRow(),
deviceRow({ device_id: TARGET_DEVICE_ID }),
],
allRowSets: [
[{ device_id: APPROVER_DEVICE_ID }, { device_id: OTHER_DEVICE_ID }],
[{ object_count: 0 }],
],
batchError: new Error("sync_vault_rotation_guard_failed"),
});
const response = await revocationResponse(d1, deviceRevocationBody());
assert.equal(response.status, 409);
assert.deepEqual(await response.json(), { error: "device_revocation_conflict" });
});
it("fails closed when a zero-change finalize has no exact completed replay", async () => {
const body = deviceRevocationBody();
const d1 = testD1Database({
firstRows: [
approverRow(),
null,
currentVaultKeyRow(),
deviceRow({ device_id: TARGET_DEVICE_ID }),
null,
],
allRowSets: [
[{ device_id: APPROVER_DEVICE_ID }, { device_id: OTHER_DEVICE_ID }],
[{ object_count: 0 }],
],
batchChanges: [[1, 1, 1, 0]],
});
const response = await revocationResponse(d1, body);
assert.equal(response.status, 409);
assert.deepEqual(d1.batches, [4]);
});
it("accepts a zero-change finalize that resolves to the exact concurrent replay", async () => {
const body = deviceRevocationBody();
const d1 = successfulD1(body, "approved", [[1, 1, 1, 0]]);
const response = await revocationResponse(d1, body);
assert.equal(response.status, 200);
assert.deepEqual(await response.json(), revocationDocument());
});
it("rejects unapproved, self, and unauthenticated revocation", async () => {
const unapproved = testD1Database({ firstRows: [null] });
assert.equal((await revocationResponse(unapproved, deviceRevocationBody())).status, 403);
const self = testD1Database([]);
const selfBody = deviceRevocationBody({
device_id: APPROVER_DEVICE_ID,
envelopes: [rotationEnvelope(OTHER_DEVICE_ID)],
});
assert.equal((await revocationResponse(self, selfBody)).status, 403);
assert.deepEqual(self.queries, []);
const anonymous = testD1Database([]);
const response = await handleRequest(revocationRequest(deviceRevocationBody(), false), testEnv({ d1: anonymous }));
assert.equal(response.status, 401);
assert.deepEqual(await response.json(), { error: "authorization_missing" });
assert.deepEqual(d1.queries, []);
assert.deepEqual(anonymous.queries, []);
});
});
function deviceRevocationBody(): Record<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,
},
};
}
+56 -47
View File
@@ -7,6 +7,8 @@ import { handleRequest } from "../src/index.js";
import {
ACCESS_TOKEN,
PUBLIC_KEY,
WRAPPING_PUBLIC_KEY,
deviceRegistrationBody,
sessionDocument,
testD1Database,
testEnv,
@@ -155,19 +157,20 @@ describe("device routes", () => {
assert.deepEqual(await response.json(), { error: "devices_invalid" });
});
it("registers the current device as a pending idempotent D1 write", async () => {
it("registers the first current device as an approved idempotent D1 write", async () => {
const tokenHash = await authTokenHash(ACCESS_TOKEN);
const sessionCacheKey = authSessionCacheKvKey("local", tokenHash);
const kvPuts: [string, string][] = [];
const d1 = testD1Database([
{
device_id: "device-01",
public_key: PUBLIC_KEY.toUpperCase(),
public_key: PUBLIC_KEY,
wrapping_public_key: WRAPPING_PUBLIC_KEY,
device_name: "MacBook Pro",
platform: "macOS",
approval_status: "pending",
approval_status: "approved",
created_at: 1_780_000_100,
approved_at: null,
approved_at: 1_780_000_100,
last_active_at: 1_780_000_100,
revoked_at: null,
},
@@ -180,7 +183,7 @@ describe("device routes", () => {
authorization: `Bearer ${ACCESS_TOKEN}`,
"content-type": "application/json",
},
body: JSON.stringify(deviceRegistrationBody()),
body: JSON.stringify(await deviceRegistrationBody()),
}),
testEnv({
d1,
@@ -192,37 +195,49 @@ describe("device routes", () => {
assert.equal(response.status, 201);
assert.equal(response.headers.get("cache-control"), "no-store");
assert.deepEqual(await response.json(), {
version: 1,
version: 2,
user_id: "user-01",
device: {
device_id: "device-01",
public_key: PUBLIC_KEY,
wrapping_public_key: WRAPPING_PUBLIC_KEY,
device_name: "MacBook Pro",
platform: "macOS",
approval_status: "pending",
approval_status: "approved",
created_at: 1_780_000_100,
approved_at: null,
approved_at: 1_780_000_100,
last_active_at: 1_780_000_100,
revoked_at: null,
current: true,
},
});
assert.ok(d1.queries[0]?.includes("INSERT INTO user_devices"));
assert.ok(d1.queries[0]?.includes("NOT EXISTS"));
assert.ok(d1.queries[0]?.includes("ON CONFLICT DO NOTHING"));
assert.ok(d1.queries[1]?.includes("WHERE user_id = ? AND idempotency_key = ?"));
assert.ok(d1.queries[2]?.includes("better_auth_session_device_context"));
assert.deepEqual(d1.binds[0]?.slice(0, 5), [
assert.ok(d1.queries[1]?.includes("INSERT INTO user_device_keys"));
assert.ok(d1.queries[2]?.includes("idempotency_key = ?"));
assert.ok(d1.queries[3]?.includes("better_auth_session_device_context"));
assert.deepEqual(d1.binds[0]?.slice(0, 6), [
"user-01",
"user-01",
"device-01",
PUBLIC_KEY,
"MacBook Pro",
"macOS",
]);
assert.equal(typeof d1.binds[0]?.[5], "number");
assert.equal(typeof d1.binds[0]?.[6], "number");
assert.equal(d1.binds[0]?.[7], IDEMPOTENCY_KEY);
assert.deepEqual(d1.binds[1], ["user-01", IDEMPOTENCY_KEY]);
assert.deepEqual(d1.binds[2]?.slice(0, 3), ["session-01", "user-01", "device-01"]);
assert.equal(typeof d1.binds[0]?.[7], "number");
assert.equal(typeof d1.binds[0]?.[8], "number");
assert.equal(d1.binds[0]?.[9], IDEMPOTENCY_KEY);
assert.deepEqual(d1.binds[1]?.slice(0, 5), [
"user-01",
"device-01",
PUBLIC_KEY,
WRAPPING_PUBLIC_KEY,
d1.binds[0]?.[6],
]);
assert.deepEqual(d1.binds[2], ["user-01", IDEMPOTENCY_KEY]);
assert.deepEqual(d1.binds[3]?.slice(0, 3), ["session-01", "user-01", "device-01"]);
assert.deepEqual(kvPuts, []);
});
@@ -233,11 +248,12 @@ describe("device routes", () => {
const deviceRow = {
device_id: "device-01",
public_key: PUBLIC_KEY,
wrapping_public_key: WRAPPING_PUBLIC_KEY,
device_name: "MacBook Pro",
platform: "macOS",
approval_status: "pending",
approval_status: "approved",
created_at: 1_780_000_100,
approved_at: null,
approved_at: 1_780_000_100,
last_active_at: 1_780_000_100,
revoked_at: null,
};
@@ -248,6 +264,7 @@ describe("device routes", () => {
id: "session-01",
userId: "user-01",
expiresAt: "2099-01-01T00:00:00.000Z",
createdAt: new Date().toISOString(),
deviceId: null,
},
});
@@ -259,7 +276,7 @@ describe("device routes", () => {
authorization: `Bearer ${ACCESS_TOKEN}`,
"content-type": "application/json",
},
body: JSON.stringify(deviceRegistrationBody()),
body: JSON.stringify(await deviceRegistrationBody()),
}),
testEnv({
d1,
@@ -269,7 +286,7 @@ describe("device routes", () => {
);
assert.equal(response.status, 201);
assert.deepEqual(d1.binds[2]?.slice(0, 3), ["session-01", "user-01", "device-01"]);
assert.deepEqual(d1.binds[3]?.slice(0, 3), ["session-01", "user-01", "device-01"]);
assert.deepEqual(kvPuts, []);
});
@@ -278,6 +295,7 @@ describe("device routes", () => {
const existingDevice = {
device_id: "device-01",
public_key: PUBLIC_KEY,
wrapping_public_key: WRAPPING_PUBLIC_KEY,
device_name: "MacBook Pro",
platform: "macOS",
approval_status: status,
@@ -288,11 +306,12 @@ describe("device routes", () => {
};
const d1 = testD1Database({
firstRows: [existingDevice],
runChanges: [0],
batchChanges: [[0, 0]],
sessionRow: {
id: "session-02",
userId: "user-01",
expiresAt: "2099-01-01T00:00:00.000Z",
createdAt: new Date().toISOString(),
deviceId: null,
},
});
@@ -304,7 +323,7 @@ describe("device routes", () => {
authorization: `Bearer ${ACCESS_TOKEN}`,
"content-type": "application/json",
},
body: JSON.stringify(deviceRegistrationBody()),
body: JSON.stringify(await deviceRegistrationBody()),
}),
testEnv({ d1 }),
);
@@ -319,6 +338,7 @@ describe("device routes", () => {
const pendingDevice = {
device_id: "device-01",
public_key: PUBLIC_KEY,
wrapping_public_key: WRAPPING_PUBLIC_KEY,
device_name: "MacBook Pro",
platform: "macOS",
approval_status: "pending",
@@ -327,7 +347,7 @@ describe("device routes", () => {
last_active_at: 1_780_000_020,
revoked_at: null,
};
const d1 = testD1Database({ firstRows: [pendingDevice], runChanges: [0] });
const d1 = testD1Database({ firstRows: [pendingDevice], batchChanges: [[0, 0]] });
const response = await handleRequest(
new Request("https://elydora.test/api/devices/register", {
@@ -336,7 +356,7 @@ describe("device routes", () => {
authorization: `Bearer ${ACCESS_TOKEN}`,
"content-type": "application/json",
},
body: JSON.stringify(deviceRegistrationBody()),
body: JSON.stringify(await deviceRegistrationBody()),
}),
testEnv({ d1 }),
);
@@ -352,12 +372,13 @@ describe("device routes", () => {
it("rejects a device id collision with a different idempotency key", async () => {
const d1 = testD1Database({
firstRows: [],
runChanges: [0],
batchChanges: [[0, 0]],
sessionRow: {
id: "session-02",
userId: "user-01",
expiresAt: "2099-01-01T00:00:00.000Z",
deviceId: null,
id: "session-02",
userId: "user-01",
expiresAt: "2099-01-01T00:00:00.000Z",
createdAt: new Date().toISOString(),
deviceId: null,
},
});
const response = await handleRequest(
@@ -367,10 +388,9 @@ describe("device routes", () => {
authorization: `Bearer ${ACCESS_TOKEN}`,
"content-type": "application/json",
},
body: JSON.stringify({
...deviceRegistrationBody(),
idempotency_key: "device-register-0002",
}),
body: JSON.stringify(
await deviceRegistrationBody({ idempotency_key: "device-register-0002" }),
),
}),
testEnv({ d1 }),
);
@@ -390,7 +410,7 @@ describe("device routes", () => {
authorization: `Bearer ${ACCESS_TOKEN}`,
"content-type": "application/json",
},
body: JSON.stringify({ ...deviceRegistrationBody(), idempotency_key: "short" }),
body: JSON.stringify(await deviceRegistrationBody({ idempotency_key: "short" })),
}),
testEnv({
d1,
@@ -413,7 +433,7 @@ describe("device routes", () => {
authorization: `Bearer ${ACCESS_TOKEN}`,
"content-type": "application/json",
},
body: JSON.stringify({ ...deviceRegistrationBody(), device_id: "device-02" }),
body: JSON.stringify(await deviceRegistrationBody({ device_id: "device-02" })),
}),
testEnv({
d1,
@@ -422,7 +442,7 @@ describe("device routes", () => {
);
assert.equal(response.status, 403);
assert.deepEqual(await response.json(), { error: "device_context_mismatch" });
assert.deepEqual(await response.json(), { error: "device_registration_forbidden" });
assert.deepEqual(d1.queries, []);
});
@@ -432,7 +452,7 @@ describe("device routes", () => {
new Request("https://elydora.test/api/devices/register", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(deviceRegistrationBody()),
body: JSON.stringify(await deviceRegistrationBody()),
}),
testEnv({ d1 }),
);
@@ -442,14 +462,3 @@ describe("device routes", () => {
assert.deepEqual(d1.queries, []);
});
});
function deviceRegistrationBody(): Record<string, unknown> {
return {
version: 1,
device_id: "device-01",
public_key: PUBLIC_KEY,
device_name: "MacBook Pro",
platform: "macOS",
idempotency_key: IDEMPOTENCY_KEY,
};
}
+103 -8
View File
@@ -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,
});
}
}
+249
View File
@@ -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 {
+151
View File
@@ -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");
}
+9 -2
View File
@@ -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);
+9 -138
View File
@@ -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);
}
+17 -313
View File
@@ -1,5 +1,4 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { describe, it } from "node:test";
import { authSessionCacheKvKey, authTokenHash } from "../src/auth.js";
@@ -12,327 +11,32 @@ import {
testEnv,
} from "./devices_test_support.js";
const USER_ID = "user-01";
const DEVICE_ID = "device-01";
const OBJECT_ID = "tab-01";
const OBJECT_TYPE = "tabs";
describe("sync push routes", () => {
it("pushes an inline encrypted sync object from an approved current device", async () => {
const payload = bytes("encrypted tab payload");
const payloadHash = sha256(payload);
describe("retired sync push route", () => {
it("rejects legacy object writes before D1 and R2 persistence", async () => {
const tokenHash = await authTokenHash(ACCESS_TOKEN);
const d1 = testD1Database({
firstRows: [
{ device_id: DEVICE_ID },
null,
syncObjectRow({ payload_hash: payloadHash }),
],
});
const d1 = testD1Database({ firstRows: [{ device_id: "device-01" }] });
const r2Puts: RecordedR2Put[] = [];
const response = await handleRequest(
syncPushRequest(syncPushBody({ payload_hash: payloadHash, payload: inlinePayload(payload) })),
new Request("https://elydora.test/api/sync/push", {
method: "POST",
headers: {
authorization: `Bearer ${ACCESS_TOKEN}`,
"content-type": "application/json",
},
body: JSON.stringify({ version: 1, payload: "legacy-plaintext" }),
}),
testEnv({
d1,
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
r2Puts,
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument("device-01")]],
}),
);
assert.equal(response.status, 201);
assert.equal(response.status, 410);
assert.equal(response.headers.get("cache-control"), "no-store");
assert.deepEqual(await response.json(), {
version: 1,
user_id: USER_ID,
device_id: DEVICE_ID,
object: syncObjectDocument({ payload_hash: payloadHash }),
});
assert.equal(d1.batches[0], 2);
assert.ok(d1.queries[0]?.includes("approval_status = 'approved'"));
assert.ok(d1.queries[1]?.includes("FROM sync_objects"));
assert.ok(d1.queries[2]?.includes("INSERT INTO sync_objects"));
assert.ok(d1.queries[3]?.includes("INSERT INTO sync_change_log"));
assert.deepEqual(d1.binds[0], [USER_ID, DEVICE_ID]);
assert.deepEqual(d1.binds[1], [USER_ID, OBJECT_ID]);
assert.deepEqual(d1.binds[2]?.slice(0, 3), [USER_ID, OBJECT_ID, OBJECT_TYPE]);
assert.deepEqual(new Uint8Array(d1.binds[2]?.[3] as ArrayBuffer), new Uint8Array(payload));
assert.equal(d1.binds[2]?.[4], null);
assert.equal(d1.binds[3]?.[3], "upsert");
});
it("pushes an R2 encrypted sync object after checksum verification", async () => {
const payload = bytes("large encrypted tab payload");
const payloadHash = sha256(payload);
const userHash = sha256(bytes(USER_ID));
const r2Puts: RecordedR2Put[] = [];
const tokenHash = await authTokenHash(ACCESS_TOKEN);
const d1 = testD1Database({
firstRows: [
{ device_id: DEVICE_ID },
null,
syncObjectRow({
payload_hash: payloadHash,
payload_r2_key: `sync-payloads/us-east/${userHash}/tabs/${OBJECT_ID}/${payloadHash}.bin`,
}),
],
});
const response = await handleRequest(
syncPushRequest(
syncPushBody({ payload_hash: payloadHash, payload: r2Payload("us-east", payload) }),
),
testEnv({
d1,
r2Puts,
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
}),
);
assert.equal(response.status, 201);
assert.equal(r2Puts.length, 1);
assert.equal(
r2Puts[0]?.key,
`sync-payloads/us-east/${userHash}/tabs/${OBJECT_ID}/${payloadHash}.bin`,
);
assert.equal(r2Puts[0]?.options.customMetadata?.sha256, payloadHash);
const body = (await response.json()) as { object: { payload_storage: string } };
assert.equal(body.object.payload_storage, "r2");
assert.equal(d1.binds[2]?.[3], null);
assert.equal(d1.binds[2]?.[4], r2Puts[0]?.key);
});
it("pushes a delete tombstone and writes the change log", async () => {
const payloadHash = "b".repeat(64);
const tokenHash = await authTokenHash(ACCESS_TOKEN);
const d1 = testD1Database({
firstRows: [
{ device_id: DEVICE_ID },
syncObjectRow({ payload_hash: "a".repeat(64), logical_clock: 41 }),
syncObjectRow({ payload_hash: payloadHash, logical_clock: 42, deleted_at: 1_780_000_800 }),
],
});
const response = await handleRequest(
syncPushRequest(
syncPushBody({ operation: "delete", payload_hash: payloadHash, payload: undefined }),
),
testEnv({
d1,
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
}),
);
assert.equal(response.status, 201);
assert.equal(d1.batches[0], 3);
assert.ok(d1.queries[4]?.includes("INSERT INTO sync_tombstones"));
assert.equal(d1.binds[2]?.[3], null);
assert.equal(d1.binds[2]?.[4], null);
assert.equal(d1.binds[3]?.[3], "delete");
const body = (await response.json()) as { object: { payload_storage: string } };
assert.equal(body.object.payload_storage, "tombstone");
});
it("rejects payload checksum mismatches before D1 writes", async () => {
const tokenHash = await authTokenHash(ACCESS_TOKEN);
const d1 = testD1Database({ firstRows: [{ device_id: DEVICE_ID }] });
const response = await handleRequest(
syncPushRequest(
syncPushBody({
payload_hash: "c".repeat(64),
payload: inlinePayload(bytes("encrypted tab payload")),
}),
),
testEnv({
d1,
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
}),
);
assert.equal(response.status, 400);
assert.deepEqual(await response.json(), { error: "invalid_sync_push" });
assert.equal(d1.queries.length, 1);
assert.deepEqual(d1.batches, []);
});
it("rejects R2 object ids that cannot form storage keys before D1 writes", async () => {
const payload = bytes("large encrypted tab payload");
const payloadHash = sha256(payload);
const r2Puts: RecordedR2Put[] = [];
const tokenHash = await authTokenHash(ACCESS_TOKEN);
const d1 = testD1Database({ firstRows: [{ device_id: DEVICE_ID }] });
const response = await handleRequest(
syncPushRequest(
syncPushBody({
object_id: "Tab:01",
payload_hash: payloadHash,
payload: r2Payload("us-east", payload),
}),
),
testEnv({
d1,
r2Puts,
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
}),
);
assert.equal(response.status, 400);
assert.deepEqual(await response.json(), { error: "invalid_sync_push" });
assert.equal(r2Puts.length, 0);
assert.equal(d1.queries.length, 1);
assert.deepEqual(d1.batches, []);
});
it("rejects stale logical clocks before persistence writes", async () => {
const payload = bytes("encrypted tab payload");
const payloadHash = sha256(payload);
const tokenHash = await authTokenHash(ACCESS_TOKEN);
const d1 = testD1Database({
firstRows: [
{ device_id: DEVICE_ID },
syncObjectRow({ payload_hash: "d".repeat(64), logical_clock: 43 }),
],
});
const response = await handleRequest(
syncPushRequest(
syncPushBody({
payload_hash: payloadHash,
logical_clock: 42,
payload: inlinePayload(payload),
}),
),
testEnv({
d1,
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
}),
);
assert.equal(response.status, 409);
assert.deepEqual(await response.json(), { error: "sync_conflict" });
assert.deepEqual(d1.batches, []);
});
it("rejects same-clock object write races after D1 persistence", async () => {
const payload = bytes("encrypted tab payload");
const payloadHash = sha256(payload);
const tokenHash = await authTokenHash(ACCESS_TOKEN);
const d1 = testD1Database({
firstRows: [
{ device_id: DEVICE_ID },
null,
syncObjectRow({ payload_hash: "e".repeat(64), logical_clock: 42 }),
],
});
const response = await handleRequest(
syncPushRequest(syncPushBody({ payload_hash: payloadHash, payload: inlinePayload(payload) })),
testEnv({
d1,
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
}),
);
assert.equal(response.status, 409);
assert.deepEqual(await response.json(), { error: "sync_conflict" });
assert.equal(d1.batches[0], 2);
});
it("rejects revoked devices before reading the sync push body", async () => {
const tokenHash = await authTokenHash(ACCESS_TOKEN);
const d1 = testD1Database({ firstRows: [null] });
const response = await handleRequest(
syncPushRequest(syncPushBody()),
testEnv({
d1,
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
}),
);
assert.equal(response.status, 403);
assert.deepEqual(await response.json(), { error: "device_not_approved" });
assert.deepEqual(await response.json(), { error: "sync_object_protocol_retired" });
assert.equal(d1.queries.length, 1);
assert.deepEqual(d1.batches, []);
assert.deepEqual(r2Puts, []);
});
});
function syncPushRequest(body: Record<string, unknown>): Request {
return new Request("https://elydora.test/api/sync/push", {
method: "POST",
headers: {
authorization: `Bearer ${ACCESS_TOKEN}`,
"content-type": "application/json",
},
body: JSON.stringify(body),
});
}
function syncPushBody(overrides: Record<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");
}
+456
View File
@@ -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) };
}
+75 -28
View File
@@ -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 };
}
+356 -193
View File
@@ -14,273 +14,372 @@ import {
const USER_ID = "user-01";
const DEVICE_ID = "device-01";
const SNAPSHOT_ID = "snapshot-01";
const SNAPSHOT_ID = "device-01";
const REGION = "us-east";
const KEY_ID = "1".repeat(64);
const CONTENT_HASH = "2".repeat(64);
describe("sync snapshot routes", () => {
it("uploads an encrypted snapshot from an approved current device", async () => {
const payload = bytes("encrypted snapshot payload");
it("commits a genesis encrypted snapshot as the global head", async () => {
const payload = opaqueEnvelopeBytes();
const payloadHash = sha256(payload);
const key = snapshotKey(payloadHash);
const row = snapshotRow({
r2_key: snapshotKey(payloadHash),
payload_hash: payloadHash,
size_bytes: payload.byteLength,
});
const r2Puts: RecordedR2Put[] = [];
const tokenHash = await authTokenHash(ACCESS_TOKEN);
const d1 = testD1Database({
firstRows: [
{ device_id: DEVICE_ID },
null,
snapshotRow({ r2_key: key, payload_hash: payloadHash, size_bytes: payload.byteLength }),
],
firstRows: [{ device_id: DEVICE_ID }, null, vaultKeyRow()],
batchRowSets: [[[], [], [], [], [row]]],
});
const response = await handleRequest(
syncSnapshotPostRequest(
syncSnapshotBody({ payload_hash: payloadHash, data_base64: base64(payload) }),
),
testEnv({
d1,
r2Puts,
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
}),
syncSnapshotPostRequest(syncSnapshotBody({
payload_hash: payloadHash,
data_base64: base64(payload),
})),
await authorizedEnv(d1, { r2Puts }),
);
assert.equal(response.status, 201);
assert.equal(response.headers.get("cache-control"), "no-store");
assert.deepEqual(await response.json(), {
version: 1,
user_id: USER_ID,
device_id: DEVICE_ID,
snapshot: snapshotDocument({
r2_key: key,
payload_hash: payloadHash,
size_bytes: payload.byteLength,
}),
});
assert.deepEqual(await response.json(), uploadDocument(row));
assert.equal(r2Puts.length, 1);
assert.equal(r2Puts[0]?.key, key);
assert.deepEqual(new Uint8Array(r2Puts[0]?.payload ?? new ArrayBuffer(0)), new Uint8Array(payload));
assert.equal(r2Puts[0]?.options.customMetadata?.sha256, payloadHash);
assert.equal(d1.batches[0], 1);
assert.ok(d1.queries[1]?.includes("FROM sync_snapshots"));
assert.ok(d1.queries[2]?.includes("INSERT INTO sync_snapshots"));
assert.deepEqual(d1.binds[2]?.slice(0, 4), [USER_ID, SNAPSHOT_ID, key, payloadHash]);
assert.equal(d1.batches[0], 5);
assert.ok(d1.queries.some((query) => query.includes("INSERT INTO sync_snapshot_heads")));
});
it("downloads an encrypted snapshot with R2 checksum verification", async () => {
const payload = bytes("encrypted snapshot payload");
it("returns the original success document for an exact replay", async () => {
const payload = opaqueEnvelopeBytes();
const payloadHash = sha256(payload);
const key = snapshotKey(payloadHash);
const r2Gets: string[] = [];
const tokenHash = await authTokenHash(ACCESS_TOKEN);
const row = snapshotRow({
r2_key: snapshotKey(payloadHash),
payload_hash: payloadHash,
size_bytes: payload.byteLength,
});
const r2Puts: RecordedR2Put[] = [];
const d1 = testD1Database({
firstRows: [{ device_id: DEVICE_ID }, row, vaultKeyRow()],
});
const response = await handleRequest(
syncSnapshotPostRequest(syncSnapshotBody({
payload_hash: payloadHash,
data_base64: base64(payload),
})),
await authorizedEnv(d1, { r2Puts }),
);
assert.equal(response.status, 201);
assert.deepEqual(await response.json(), uploadDocument(row));
assert.deepEqual(d1.batches, []);
assert.equal(r2Puts.length, 0);
assert.ok(d1.queries.some((query) => query.includes("SET cleanup_snapshot_id = ?")));
});
it("returns the original success after the vault rotates", async () => {
const payload = opaqueEnvelopeBytes();
const payloadHash = sha256(payload);
const row = snapshotRow({
r2_key: snapshotKey(payloadHash),
payload_hash: payloadHash,
size_bytes: payload.byteLength,
});
const r2Puts: RecordedR2Put[] = [];
const d1 = testD1Database({
firstRows: [
{ device_id: DEVICE_ID },
snapshotRow({ r2_key: key, payload_hash: payloadHash, size_bytes: payload.byteLength }),
row,
{ key_id: "f".repeat(64), generation: 2 },
],
});
const response = await handleRequest(
syncSnapshotGetRequest(),
testEnv({
d1,
r2Gets,
r2Objects: [[key, payload]],
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
}),
syncSnapshotPostRequest(syncSnapshotBody({
payload_hash: payloadHash,
data_base64: base64(payload),
})),
await authorizedEnv(d1, { r2Puts }),
);
assert.equal(response.status, 201);
assert.deepEqual(await response.json(), uploadDocument(row));
assert.equal(r2Puts.length, 0);
assert.equal(d1.queries.some((query) => query.includes("SET cleanup_snapshot_id = ?")), false);
});
it("returns the committed document when an identical concurrent writer wins", async () => {
const payload = opaqueEnvelopeBytes();
const payloadHash = sha256(payload);
const baseRow = snapshotRow({ payload_hash: "a".repeat(64) });
const committed = snapshotRow({
payload_hash: payloadHash,
r2_key: snapshotKey(payloadHash),
content_hash: CONTENT_HASH,
logical_clock: 43,
head_revision: 2,
base_head_revision: 1,
base_snapshot_id: SNAPSHOT_ID,
base_payload_hash: "a".repeat(64),
size_bytes: payload.byteLength,
});
const d1 = testD1Database({
firstRows: [{ device_id: DEVICE_ID }, baseRow, vaultKeyRow(), committed],
batchError: new Error("sync_snapshot_head_cas_failed"),
});
const response = await handleRequest(
syncSnapshotPostRequest(syncSnapshotBody({
payload_hash: payloadHash,
logical_clock: 43,
head_revision: 2,
base_head: headRef(baseRow),
data_base64: base64(payload),
})),
await authorizedEnv(d1),
);
assert.equal(response.status, 201);
assert.deepEqual(await response.json(), uploadDocument(committed));
assert.deepEqual(d1.sessionConstraints, ["first-primary", "first-primary"]);
});
it("rejects a stale base before writing R2", async () => {
const current = snapshotRow({ payload_hash: "a".repeat(64) });
const staleBase = headRef({ payload_hash: "b".repeat(64) });
const r2Puts: RecordedR2Put[] = [];
const d1 = testD1Database({
firstRows: [{ device_id: DEVICE_ID }, current, vaultKeyRow()],
});
const response = await handleRequest(
syncSnapshotPostRequest(syncSnapshotBody({
head_revision: 2,
base_head: staleBase,
logical_clock: 43,
})),
await authorizedEnv(d1, { r2Puts }),
);
assert.equal(response.status, 409);
assert.deepEqual(await response.json(), conflictDocument(current));
assert.equal(r2Puts.length, 0);
assert.deepEqual(d1.batches, []);
});
it("returns the winning head when D1 rejects a concurrent writer", async () => {
const baseRow = snapshotRow({ payload_hash: "a".repeat(64) });
const winner = snapshotRow({
payload_hash: "b".repeat(64),
r2_key: snapshotKey("b".repeat(64)),
content_hash: "3".repeat(64),
logical_clock: 43,
head_revision: 2,
base_head_revision: 1,
base_snapshot_id: SNAPSHOT_ID,
base_payload_hash: "a".repeat(64),
});
const d1 = testD1Database({
firstRows: [{ device_id: DEVICE_ID }, baseRow, vaultKeyRow(), winner],
batchError: new Error("sync_snapshot_head_cas_failed"),
});
const response = await handleRequest(
syncSnapshotPostRequest(syncSnapshotBody({
head_revision: 2,
base_head: headRef(baseRow),
logical_clock: 44,
})),
await authorizedEnv(d1),
);
assert.equal(response.status, 409);
assert.deepEqual(await response.json(), conflictDocument(winner));
});
it("downloads a snapshot only through its exact head token", async () => {
const payload = opaqueEnvelopeBytes();
const payloadHash = sha256(payload);
const key = snapshotKey(payloadHash);
const row = snapshotRow({
r2_key: key,
payload_hash: payloadHash,
size_bytes: payload.byteLength,
});
const r2Gets: string[] = [];
const d1 = testD1Database({ firstRows: [{ device_id: DEVICE_ID }, row] });
const response = await handleRequest(
syncSnapshotGetRequest(headRef(row)),
await authorizedEnv(d1, { r2Gets, r2Objects: [[key, payload]] }),
);
assert.equal(response.status, 200);
assert.deepEqual(await response.json(), {
version: 1,
user_id: USER_ID,
device_id: DEVICE_ID,
snapshot: snapshotDocument({
r2_key: key,
payload_hash: payloadHash,
size_bytes: payload.byteLength,
}),
...uploadDocument(row),
data_base64: base64(payload),
});
assert.deepEqual(r2Gets, [key]);
});
it("returns not found for missing snapshot indexes", async () => {
const r2Gets: string[] = [];
const tokenHash = await authTokenHash(ACCESS_TOKEN);
const d1 = testD1Database({ firstRows: [{ device_id: DEVICE_ID }, null] });
it("returns the current head for a historical different-device token", async () => {
const current = snapshotRow({
snapshot_id: "device-02",
payload_hash: "b".repeat(64),
r2_key: snapshotKey("b".repeat(64)),
head_revision: 2,
base_head_revision: 1,
base_snapshot_id: SNAPSHOT_ID,
base_payload_hash: "a".repeat(64),
});
const d1 = testD1Database({
firstRows: [{ device_id: DEVICE_ID }, null, current],
});
const response = await handleRequest(
syncSnapshotGetRequest(),
testEnv({
d1,
r2Gets,
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
}),
syncSnapshotGetRequest(headRef({ payload_hash: "a".repeat(64) })),
await authorizedEnv(d1),
);
assert.equal(response.status, 404);
assert.deepEqual(await response.json(), { error: "sync_snapshot_not_found" });
assert.deepEqual(r2Gets, []);
assert.equal(response.status, 409);
assert.deepEqual(await response.json(), conflictDocument(current));
});
it("rejects snapshot checksum mismatches before D1 writes", async () => {
const payload = bytes("encrypted snapshot payload");
it("returns a new head when cleanup removes payload after token validation", async () => {
const old = snapshotRow();
const current = snapshotRow({
snapshot_id: "device-02",
payload_hash: "b".repeat(64),
r2_key: snapshotKey("b".repeat(64)),
head_revision: 2,
base_head_revision: 1,
base_snapshot_id: SNAPSHOT_ID,
base_payload_hash: "a".repeat(64),
logical_clock: 43,
});
const d1 = testD1Database({
firstRows: [{ device_id: DEVICE_ID }, old, current],
});
const response = await handleRequest(
syncSnapshotGetRequest(headRef(old)),
await authorizedEnv(d1),
);
assert.equal(response.status, 409);
assert.deepEqual(await response.json(), conflictDocument(current));
assert.deepEqual(d1.sessionConstraints, ["first-primary", "first-primary"]);
});
it("preserves legacy encryption metadata on exact downloads", async () => {
const payload = opaqueEnvelopeBytes();
const payloadHash = sha256(payload);
const key = snapshotKey(payloadHash);
const row = snapshotRow({
r2_key: key,
payload_hash: payloadHash,
encryption_version: 1,
size_bytes: payload.byteLength,
});
const d1 = testD1Database({ firstRows: [{ device_id: DEVICE_ID }, row] });
const response = await handleRequest(
syncSnapshotGetRequest(headRef(row)),
await authorizedEnv(d1, { r2Objects: [[key, payload]] }),
);
assert.equal(response.status, 200);
const document = await response.json() as { version: number; snapshot: { encryption_version: number } };
assert.equal(document.version, 3);
assert.equal(document.snapshot.encryption_version, 1);
});
it("rejects upload wire version 2 before R2 and D1 writes", async () => {
const r2Puts: RecordedR2Put[] = [];
const tokenHash = await authTokenHash(ACCESS_TOKEN);
const d1 = testD1Database({ firstRows: [{ device_id: DEVICE_ID }] });
const response = await handleRequest(
syncSnapshotPostRequest(
syncSnapshotBody({ payload_hash: "c".repeat(64), data_base64: base64(payload) }),
),
testEnv({
d1,
r2Puts,
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
}),
syncSnapshotPostRequest(syncSnapshotBody({ version: 2 })),
await authorizedEnv(d1, { r2Puts }),
);
assert.equal(response.status, 400);
assert.deepEqual(await response.json(), { error: "invalid_sync_snapshot" });
assert.equal(r2Puts.length, 0);
assert.equal(d1.queries.length, 1);
assert.deepEqual(d1.batches, []);
});
it("rejects stale snapshot clocks before R2 writes", async () => {
const payload = bytes("encrypted snapshot payload");
const payloadHash = sha256(payload);
const r2Puts: RecordedR2Put[] = [];
const tokenHash = await authTokenHash(ACCESS_TOKEN);
it("fails closed when the committed head SELECT is empty", async () => {
const d1 = testD1Database({
firstRows: [
{ device_id: DEVICE_ID },
snapshotRow({ payload_hash: "d".repeat(64), logical_clock: 43 }),
],
firstRows: [{ device_id: DEVICE_ID }, null, vaultKeyRow()],
batchRowSets: [[[], [], [], [], []]],
});
const response = await handleRequest(
syncSnapshotPostRequest(
syncSnapshotBody({
payload_hash: payloadHash,
logical_clock: 42,
data_base64: base64(payload),
}),
),
testEnv({
d1,
r2Puts,
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
}),
);
assert.equal(response.status, 409);
assert.deepEqual(await response.json(), { error: "sync_snapshot_conflict" });
assert.equal(r2Puts.length, 0);
assert.deepEqual(d1.batches, []);
});
it("rejects same-clock snapshot write races after D1 persistence", async () => {
const payload = bytes("encrypted snapshot payload");
const payloadHash = sha256(payload);
const key = snapshotKey(payloadHash);
const r2Puts: RecordedR2Put[] = [];
const tokenHash = await authTokenHash(ACCESS_TOKEN);
const d1 = testD1Database({
firstRows: [
{ device_id: DEVICE_ID },
null,
snapshotRow({ r2_key: key, payload_hash: "e".repeat(64), size_bytes: payload.byteLength }),
],
});
const response = await handleRequest(
syncSnapshotPostRequest(
syncSnapshotBody({ payload_hash: payloadHash, data_base64: base64(payload) }),
),
testEnv({
d1,
r2Puts,
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
}),
);
assert.equal(response.status, 409);
assert.deepEqual(await response.json(), { error: "sync_snapshot_conflict" });
assert.equal(r2Puts.length, 1);
assert.equal(d1.batches[0], 1);
});
it("rejects revoked devices before reading snapshot payloads", async () => {
const tokenHash = await authTokenHash(ACCESS_TOKEN);
const d1 = testD1Database({ firstRows: [null] });
const response = await handleRequest(
syncSnapshotPostRequest(syncSnapshotBody()),
testEnv({
d1,
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
}),
);
assert.equal(response.status, 403);
assert.deepEqual(await response.json(), { error: "device_not_approved" });
assert.equal(d1.queries.length, 1);
assert.deepEqual(d1.batches, []);
});
it("fails closed when a stored snapshot payload fails checksum verification", async () => {
const payload = bytes("encrypted snapshot payload");
const payloadHash = sha256(payload);
const key = snapshotKey(payloadHash);
const tokenHash = await authTokenHash(ACCESS_TOKEN);
const d1 = testD1Database({
firstRows: [
{ device_id: DEVICE_ID },
snapshotRow({ r2_key: key, payload_hash: payloadHash, size_bytes: payload.byteLength }),
],
});
const response = await handleRequest(
syncSnapshotGetRequest(),
testEnv({
d1,
r2Objects: [[key, bytes("corrupt snapshot payload")]],
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
}),
await authorizedEnv(d1),
);
assert.equal(response.status, 500);
assert.deepEqual(await response.json(), { error: "sync_snapshot_failed" });
});
it("fails closed when stored ciphertext fails checksum verification", async () => {
const payload = opaqueEnvelopeBytes();
const payloadHash = sha256(payload);
const key = snapshotKey(payloadHash);
const row = snapshotRow({
r2_key: key,
payload_hash: payloadHash,
size_bytes: payload.byteLength,
});
const d1 = testD1Database({ firstRows: [{ device_id: DEVICE_ID }, row, row] });
const response = await handleRequest(
syncSnapshotGetRequest(headRef(row)),
await authorizedEnv(d1, { r2Objects: [[key, bytes("corrupt")]] }),
);
assert.equal(response.status, 500);
assert.deepEqual(await response.json(), { error: "sync_snapshot_failed" });
});
});
function syncSnapshotPostRequest(body: Record<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");
}
+111 -34
View File
@@ -12,17 +12,17 @@ describe("sync status routes", () => {
it("returns cloud sync cursor, object, snapshot, and device status", async () => {
const tokenHash = await authTokenHash(ACCESS_TOKEN);
const d1 = testD1Database({
firstRows: [
{ device_id: DEVICE_ID },
{ latest_change_id: 51, total_changes: 7 },
{ total_snapshots: 2 },
latestSnapshotRow(),
{ approved_devices: 3 },
],
allRows: [
objectStatusRow({ object_type: "bookmarks", active_count: 4, deleted_count: 1 }),
objectStatusRow({ object_type: "tabs", active_count: 9, latest_logical_clock: 44 }),
],
firstRows: [{ device_id: DEVICE_ID }],
batchRowSets: [[
[{ latest_change_id: 51, total_changes: 7 }],
[
objectStatusRow({ object_type: "bookmarks", active_count: 4, deleted_count: 1 }),
objectStatusRow({ object_type: "tabs", active_count: 9, latest_logical_clock: 44 }),
],
[{ total_snapshots: 2 }],
[snapshotHeadRow()],
[{ approved_devices: 3 }],
]],
});
const response = await handleRequest(
@@ -36,7 +36,7 @@ describe("sync status routes", () => {
assert.equal(response.status, 200);
assert.equal(response.headers.get("cache-control"), "no-store");
assert.deepEqual(await response.json(), {
version: 1,
version: 2,
user_id: USER_ID,
device_id: DEVICE_ID,
cursor: { latest_change_id: 51, total_changes: 7 },
@@ -46,7 +46,7 @@ describe("sync status routes", () => {
],
snapshots: {
total_snapshots: 2,
latest: latestSnapshotRow(),
head: snapshotHeadStatus(),
},
devices: {
approved_count: 3,
@@ -58,7 +58,7 @@ describe("sync status routes", () => {
assert.ok(d1.queries[1]?.includes("FROM sync_change_log"));
assert.ok(d1.queries[2]?.includes("FROM sync_objects"));
assert.ok(d1.queries[3]?.includes("FROM sync_snapshots"));
assert.ok(d1.queries[4]?.includes("FROM sync_snapshots"));
assert.ok(d1.queries[4]?.includes("FROM sync_snapshot_heads"));
assert.ok(d1.queries[5]?.includes("FROM user_devices"));
assert.deepEqual(d1.binds, [
[USER_ID, DEVICE_ID],
@@ -68,18 +68,21 @@ describe("sync status routes", () => {
[USER_ID],
[USER_ID],
]);
assert.deepEqual(d1.batches, [5]);
assert.deepEqual(d1.sessionConstraints, ["first-primary"]);
});
it("returns empty status when the account has no sync facts", async () => {
const tokenHash = await authTokenHash(ACCESS_TOKEN);
const d1 = testD1Database({
firstRows: [
{ device_id: DEVICE_ID },
{ latest_change_id: 0, total_changes: 0 },
{ total_snapshots: 0 },
null,
{ approved_devices: 1 },
],
firstRows: [{ device_id: DEVICE_ID }],
batchRowSets: [[
[{ latest_change_id: 0, total_changes: 0 }],
[],
[{ total_snapshots: 0 }],
[],
[{ approved_devices: 1 }],
]],
});
const response = await handleRequest(
@@ -94,18 +97,17 @@ describe("sync status routes", () => {
const body = (await response.json()) as {
cursor: { latest_change_id: number; total_changes: number };
objects: [];
snapshots: { total_snapshots: number; latest: null };
snapshots: { total_snapshots: number; head: null };
};
assert.deepEqual(body.cursor, { latest_change_id: 0, total_changes: 0 });
assert.deepEqual(body.objects, []);
assert.deepEqual(body.snapshots, { total_snapshots: 0, latest: null });
assert.deepEqual(body.snapshots, { total_snapshots: 0, head: null });
});
it("rejects revoked devices before reading sync status", async () => {
const tokenHash = await authTokenHash(ACCESS_TOKEN);
const d1 = testD1Database({
firstRows: [null, { latest_change_id: 51, total_changes: 7 }],
allRows: [objectStatusRow()],
firstRows: [null],
});
const response = await handleRequest(
@@ -138,14 +140,64 @@ describe("sync status routes", () => {
it("returns a server error for malformed status rows", async () => {
const tokenHash = await authTokenHash(ACCESS_TOKEN);
const d1 = testD1Database({
firstRows: [
{ device_id: DEVICE_ID },
{ latest_change_id: 51, total_changes: 7 },
{ total_snapshots: 1 },
latestSnapshotRow(),
{ approved_devices: 1 },
],
allRows: [objectStatusRow({ object_type: "passwords" })],
firstRows: [{ device_id: DEVICE_ID }],
batchRowSets: [[
[{ latest_change_id: 51, total_changes: 7 }],
[objectStatusRow({ object_type: "passwords" })],
[{ total_snapshots: 1 }],
[snapshotHeadRow()],
[{ approved_devices: 1 }],
]],
});
const response = await handleRequest(
syncStatusRequest(),
testEnv({
d1,
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
}),
);
assert.equal(response.status, 500);
assert.deepEqual(await response.json(), { error: "sync_status_invalid" });
});
it("fails closed when encrypted snapshots exist without a global head", async () => {
const tokenHash = await authTokenHash(ACCESS_TOKEN);
const d1 = testD1Database({
firstRows: [{ device_id: DEVICE_ID }],
batchRowSets: [[
[{ latest_change_id: 0, total_changes: 0 }],
[],
[{ total_snapshots: 1 }],
[],
[{ approved_devices: 1 }],
]],
});
const response = await handleRequest(
syncStatusRequest(),
testEnv({
d1,
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
}),
);
assert.equal(response.status, 500);
assert.deepEqual(await response.json(), { error: "sync_status_invalid" });
});
it("fails closed when global head storage metadata is malformed", async () => {
const tokenHash = await authTokenHash(ACCESS_TOKEN);
const d1 = testD1Database({
firstRows: [{ device_id: DEVICE_ID }],
batchRowSets: [[
[{ latest_change_id: 0, total_changes: 0 }],
[],
[{ total_snapshots: 1 }],
[snapshotHeadRow({ r2_key: "invalid" })],
[{ approved_devices: 1 }],
]],
});
const response = await handleRequest(
@@ -182,14 +234,39 @@ function objectStatusDocument(overrides: Record<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';
`;
}
+490
View File
@@ -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
View File
@@ -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"
+67 -24
View File
@@ -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(),
};
+224 -9
View File
@@ -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(render_card_heading("Account"))
.child(render_sign_out_button(shell, cx))
.child(
div()
.flex()
.items_center()
.justify_between()
.child(render_card_heading("Account"))
.child(render_sign_out_button(shell, cx)),
)
.child(
div()
.text_size(px(12.0))
.text_color(rgb(colors::ink_3()))
.child("End-to-end encrypted"),
)
.child(render_devices(shell, cx))
.into_any_element(),
}
}
fn account_form(shell: &ElyShell, cx: &mut Context<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,
+17 -3
View File
@@ -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();
+37 -20
View File
@@ -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);
}
+260
View File
@@ -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());
}
}
+133 -45
View File
@@ -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 => {
latest_connection = Some(SyncConnectionState::SignedOut);
SyncStateUpdate::SignedOut { profile_id } => {
upload_finished = true;
if active_profile_id(&self.state).as_ref() == Some(&profile_id) {
latest_connection = Some(SyncConnectionState::SignedOut);
}
}
SyncStateUpdate::AwaitingDeviceApproval => {
latest_connection = Some(SyncConnectionState::AwaitingDeviceApproval);
SyncStateUpdate::AwaitingDeviceApproval { profile_id } => {
upload_finished = true;
if active_profile_id(&self.state).as_ref() == Some(&profile_id) {
latest_connection = Some(SyncConnectionState::AwaitingDeviceApproval);
}
}
SyncStateUpdate::RemoteSnapshot { bytes, logical_clock } => {
SyncStateUpdate::RemoteSnapshot { profile_id, bytes, merge } => {
upload_finished = true;
if active_profile_id(&self.state).as_ref() != Some(&profile_id) {
continue;
}
if let ShellState::Ready(core) = &mut self.state {
match core.apply_sync_snapshot_bytes(&bytes) {
Ok(summary) => {
@@ -145,7 +176,15 @@ impl ElyShell {
skipped = summary.skipped(),
"remote snapshot applied",
);
trigger_merged_upload = Some(logical_clock);
if merge.conflict_count >= CAS_RETRY_LIMIT {
latest_connection = Some(SyncConnectionState::SyncError {
message: "Cloud Sync is busy; retrying shortly".to_string(),
});
self.sync_retry_at =
Some(std::time::Instant::now() + CAS_RETRY_DELAY);
} else {
trigger_merged_upload = Some(merge);
}
}
Err(error) => {
latest_connection = Some(SyncConnectionState::SyncError {
@@ -155,29 +194,63 @@ impl ElyShell {
}
}
}
SyncStateUpdate::SyncReady { last_synced_at_secs } => {
latest_connection =
Some(SyncConnectionState::SyncReady { last_synced_at_secs });
SyncStateUpdate::SyncReady { profile_id, last_synced_at_secs } => {
upload_finished = true;
if active_profile_id(&self.state).as_ref() == Some(&profile_id) {
latest_connection =
Some(SyncConnectionState::SyncReady { last_synced_at_secs });
}
}
SyncStateUpdate::SyncError { message } => {
latest_connection = Some(SyncConnectionState::SyncError { message });
SyncStateUpdate::SyncBusy { profile_id } => {
upload_finished = true;
if active_profile_id(&self.state).as_ref() == Some(&profile_id) {
latest_connection = Some(SyncConnectionState::SyncError {
message: "Cloud Sync is busy; retrying shortly".to_string(),
});
self.sync_retry_at = Some(std::time::Instant::now() + CAS_RETRY_DELAY);
}
}
SyncStateUpdate::AuthOtpSent { email } => {
self.auth_flow_phase = auth::AuthFlowPhase::AwaitingOtp { email };
auth_changed = true;
SyncStateUpdate::SyncError { profile_id, message } => {
upload_finished = true;
if active_profile_id(&self.state).as_ref() == Some(&profile_id) {
latest_connection = Some(SyncConnectionState::SyncError { message });
}
}
SyncStateUpdate::AuthSucceeded { email } => {
self.auth_flow_phase = auth::AuthFlowPhase::Idle;
latest_connection = Some(SyncConnectionState::SignedIn);
trigger_initial_sync = true;
tracing::info!(target: "ely::sync", email = %email, "email OTP sign-in succeeded");
auth_changed = true;
SyncStateUpdate::DevicesLoaded { profile_id, devices, current_code } => {
if active_profile_id(&self.state).as_ref() == Some(&profile_id) {
self.sync_devices.set_ready(profile_id, devices, current_code);
devices_changed = true;
}
}
SyncStateUpdate::AuthError { email, message } => {
self.auth_flow_phase = auth::AuthFlowPhase::Error { email, message };
auth_changed = true;
SyncStateUpdate::DevicesError { profile_id, message } => {
if active_profile_id(&self.state).as_ref() == Some(&profile_id) {
self.sync_devices.set_error(profile_id, message);
devices_changed = true;
}
}
SyncStateUpdate::AuthOtpSent { profile_id, email } => {
if active_profile_id(&self.state).as_ref() == Some(&profile_id) {
self.auth_flow_phase =
auth::AuthFlowPhase::AwaitingOtp { profile_id, email };
auth_changed = true;
}
}
SyncStateUpdate::AuthSucceeded { profile_id, email } => {
if active_profile_id(&self.state).as_ref() == Some(&profile_id) {
self.auth_flow_phase = auth::AuthFlowPhase::Idle;
self.sync_devices.reset();
latest_connection = Some(SyncConnectionState::SignedIn);
trigger_initial_sync = true;
tracing::info!(target: "ely::sync", email = %email, "email OTP sign-in succeeded");
auth_changed = true;
}
}
SyncStateUpdate::AuthError { profile_id, email, message } => {
if active_profile_id(&self.state).as_ref() == Some(&profile_id) {
self.auth_flow_phase =
auth::AuthFlowPhase::Error { profile_id, email, message };
auth_changed = true;
}
}
}
}
@@ -194,17 +267,32 @@ impl ElyShell {
}
let merged_upload_requested = trigger_merged_upload.is_some();
if let Some(logical_clock_floor) = trigger_merged_upload {
if let Some(merge) = trigger_merged_upload {
self.clear_pending_cloud_sync_upload();
self.trigger_cloud_sync_upload_after_remote(logical_clock_floor);
self.trigger_cloud_sync_upload_after_remote(merge);
} else if upload_finished {
self.trigger_pending_cloud_sync_upload();
}
if retry_due {
self.trigger_cloud_sync_upload();
}
auth_changed || trigger_initial_sync || merged_upload_requested || connection_changed
auth_changed
|| devices_changed
|| trigger_initial_sync
|| merged_upload_requested
|| retry_due
|| connection_changed
}
}
fn active_profile_id(state: &ShellState) -> Option<ProfileId> {
let ShellState::Ready(core) = state else {
return None;
};
core.snapshot().ok().map(|snapshot| snapshot.active_profile_id)
}
fn probe_initial_sync_state_at(
core: &mut ely_browser_core::BrowserCore,
profile_root: &Path,
+1
View File
@@ -12,6 +12,7 @@ serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
url.workspace = true
uuid.workspace = true
[lints]
workspace = true
+217 -55
View File
@@ -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() => {
SyncOutcome::AlreadyCurrent {
snapshot_id: latest.snapshot_id,
logical_clock: latest.logical_clock,
payload_bytes: latest.size_bytes,
device_id: latest.device_id,
validate_sync_status(&status, &user_id, &self.identity.device_id)?;
let outcome = match status.snapshots.head {
Some(head) => {
let remote = self.download_remote_snapshot(&client, &user_id, &vault, head)?;
if remote.bytes == bytes && remote.merge_base.vault_generation() == vault.generation
{
SyncOutcome::AlreadyCurrent {
snapshot_id: remote.merge_base.snapshot_id().to_string(),
logical_clock: remote.merge_base.logical_clock(),
payload_bytes: remote.merge_base.size_bytes(),
device_id: remote.merge_base.device_id().to_string(),
}
} else if remote.bytes == bytes {
self.upload_payload(&client, &user_id, &vault, bytes, Some(&remote.merge_base))?
} else {
remote.into_outcome(false)
}
}
Some(latest) if latest.device_id != self.identity.device_id => {
self.download_remote_snapshot(&client, latest)?
}
Some(latest) => self.upload_payload(&client, payload, latest.logical_clock)?,
None => self.upload_payload(&client, payload, 0)?,
None => self.upload_payload(&client, &user_id, &vault, bytes, None)?,
};
self.last_outcome = Some(outcome.clone());
Ok(outcome)
@@ -116,22 +133,22 @@ impl SyncEngine {
pub fn upload_merged_bytes(
&mut self,
bytes: Vec<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,44 +170,173 @@ impl SyncEngine {
})
}
fn registered_client(
&self,
client: SyncApiClient,
) -> Result<(SyncApiClient, ely_sync_client::client::DeviceRecordDocument), SyncClientError>
{
let idempotency_key = device_registration_idempotency_key(&self.identity);
let registration = match client.register_device(&self.identity, &idempotency_key) {
Ok(registration) => registration,
Err(SyncClientError::HttpStatus { status: 409, .. }) => {
let rebound = client.rebind_device(&self.identity)?;
let registration = client.register_device(&self.identity, &idempotency_key)?;
if registration.user_id != rebound.user_id {
return Err(SyncClientError::DeviceTrust {
reason: "device rebind account does not match registration",
});
}
registration
}
Err(error) => return Err(error),
};
Ok((client, registration))
}
fn upload_payload(
&self,
client: &SyncApiClient,
payload: SnapshotPayload,
logical_clock_floor: u64,
user_id: &str,
vault: &ResolvedVault,
bytes: Vec<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)?;
Ok(SyncOutcome::Uploaded {
snapshot_id: document.snapshot.snapshot_id,
logical_clock: document.snapshot.logical_clock,
payload_bytes: document.snapshot.size_bytes,
device_id: document.device_id,
})
)?;
match client.upload_snapshot(&request)? {
SnapshotUploadResult::Committed(document) => {
if document.version != 3
|| document.user_id != user_id
|| document.device_id != self.identity.device_id
|| document.snapshot.snapshot_id != snapshot_id
|| document.snapshot.head_revision != head_revision
|| document.snapshot.base_head.as_ref()
!= base.map(AuthenticatedSnapshotHead::head_ref)
|| document.snapshot.payload_hash != payload.payload_hash()
|| document.snapshot.encryption_version != SNAPSHOT_ENCRYPTION_VERSION
|| document.snapshot.key_id != vault.account_key.key_id()
|| document.snapshot.vault_generation != vault.generation
|| document.snapshot.content_hash != encrypted.content_hash()
|| document.snapshot.schema_rev != SNAPSHOT_SCHEMA_REV
|| document.snapshot.logical_clock != logical_clock
|| document.snapshot.size_bytes
!= u64::try_from(payload.bytes().len()).map_err(|_| {
SyncClientError::SnapshotEncryption {
reason: "snapshot payload size is invalid",
}
})?
{
return Err(SyncClientError::SnapshotEncryption {
reason: "snapshot upload response does not match request",
});
}
Ok(SyncOutcome::Uploaded {
snapshot_id: document.snapshot.snapshot_id,
logical_clock: document.snapshot.logical_clock,
payload_bytes: document.snapshot.size_bytes,
device_id: document.device_id,
})
}
SnapshotUploadResult::Conflict(conflict) => {
let head = conflict_head(conflict)?;
self.download_remote_snapshot(client, user_id, vault, head)
.map(|remote| remote.into_outcome(true))
}
}
}
fn download_remote_snapshot(
&self,
client: &SyncApiClient,
latest: SyncLatestSnapshotDocument,
) -> Result<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,
&registration.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)
}
}
+9
View File
@@ -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
+229 -21
View File
@@ -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: &registration_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;
+107
View File
@@ -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())
}
+319 -64
View File
@@ -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()
))
})?;
identity.validate()?;
Ok(identity)
}
Ok(contents) => match decode_stored_identity(&contents, path)? {
StoredIdentity::V2(identity) => {
identity.validate()?;
match DeviceSecretStore::new(identity.device_id.clone())?.load()? {
Some(secrets) => {
identity.validate_secrets(&secrets)?;
Ok(identity)
}
None => {
Self::create_and_save(path, identity.device_name, identity.platform)
}
}
}
StoredIdentity::Legacy(identity) => {
identity.validate()?;
Self::create_and_save(path, identity.device_name, identity.platform)
}
},
Err(error) if error.kind() == ErrorKind::NotFound => {
let identity = Self::generate(device_name, platform);
identity.save(path)?;
Ok(identity)
Self::create_and_save(path, device_name, platform)
}
Err(error) => Err(SyncClientError::TokenStorage(error.to_string())),
}
}
pub fn generate(device_name: impl Into<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(())
}
fn validate(&self) -> Result<(), SyncClientError> {
if !is_device_id_shape(&self.device_id) {
return Err(SyncClientError::TokenStorage(
"device_id does not match the Cloudflare worker pattern".to_string(),
));
pub(crate) fn validate_secrets(&self, secrets: &DeviceSecrets) -> Result<(), SyncClientError> {
let expected_signing_public_key =
decode_hex_32(&self.public_key, "device signing public key encoding is invalid")?;
let signing_key = SigningKey::from_bytes(secrets.signing_private_key());
if signing_key.verifying_key().to_bytes() != expected_signing_public_key {
return Err(key_error("device signing private key does not match identity"));
}
if self.public_key.trim().is_empty() {
return Err(SyncClientError::TokenStorage("device public_key is empty".to_string()));
}
if self.device_name.trim().is_empty() {
return Err(SyncClientError::TokenStorage("device_name is empty".to_string()));
}
if self.platform.trim().is_empty() {
return Err(SyncClientError::TokenStorage("platform is empty".to_string()));
let private_key =
<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)
}
}
fn is_device_id_shape(value: &str) -> bool {
pub(crate) fn generate_key_material(
device_name: String,
platform: String,
) -> Result<(DeviceIdentity, DeviceSecrets), SyncClientError> {
let mut signing_private_key = Zeroizing::new([0_u8; PUBLIC_KEY_BYTES]);
getrandom::fill(signing_private_key.as_mut())
.map_err(|_| key_error("secure randomness unavailable"))?;
let signing_key = SigningKey::from_bytes(&signing_private_key);
let mut wrapping_ikm = Zeroizing::new([0_u8; PUBLIC_KEY_BYTES]);
getrandom::fill(wrapping_ikm.as_mut())
.map_err(|_| key_error("secure randomness unavailable"))?;
let (wrapping_private_key, wrapping_public_key) =
X25519HkdfSha256::derive_keypair(wrapping_ikm.as_slice());
let mut wrapping_private_bytes = wrapping_private_key.to_bytes();
let mut stored_wrapping_private_key = [0_u8; PUBLIC_KEY_BYTES];
stored_wrapping_private_key.copy_from_slice(&wrapping_private_bytes);
wrapping_private_bytes.zeroize();
let identity = DeviceIdentity {
device_id: format!("ely-{}", Uuid::now_v7().simple()),
public_key: hex_string(&signing_key.verifying_key().to_bytes()),
wrapping_public_key: hex_string(&wrapping_public_key.to_bytes()),
device_name: device_name.trim().to_string(),
platform: platform.trim().to_string(),
};
identity.validate()?;
let secrets = DeviceSecrets::new(*signing_private_key, stored_wrapping_private_key);
identity.validate_secrets(&secrets)?;
Ok((identity, secrets))
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct LegacyDeviceIdentity {
device_id: String,
public_key: String,
device_name: String,
platform: String,
}
impl LegacyDeviceIdentity {
fn validate(&self) -> Result<(), SyncClientError> {
validate_common(&self.device_id, &self.public_key, &self.device_name, &self.platform, false)
}
}
enum StoredIdentity {
V2(DeviceIdentity),
Legacy(LegacyDeviceIdentity),
}
fn decode_stored_identity(contents: &str, path: &Path) -> Result<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
}
}
+362
View File
@@ -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(())
}
}
+169
View File
@@ -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());
}
}
+371
View File
@@ -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