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
@@ -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;