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;
+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"