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
+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 {