fix(sync): secure encrypted snapshot lifecycle
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE IF NOT EXISTS sync_snapshot_encryption (
|
||||
user_id TEXT NOT NULL,
|
||||
snapshot_id TEXT NOT NULL,
|
||||
encryption_version INTEGER NOT NULL CHECK (encryption_version = 1),
|
||||
vault_generation INTEGER NOT NULL CHECK (vault_generation >= 1),
|
||||
key_id TEXT NOT NULL,
|
||||
content_hash TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, snapshot_id),
|
||||
FOREIGN KEY (user_id, snapshot_id) REFERENCES sync_snapshots (user_id, snapshot_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sync_snapshots_encrypted_latest
|
||||
ON sync_snapshot_encryption (user_id, encryption_version, snapshot_id);
|
||||
@@ -0,0 +1,45 @@
|
||||
CREATE TABLE IF NOT EXISTS sync_vault_accounts (
|
||||
user_id TEXT NOT NULL PRIMARY KEY,
|
||||
current_key_id TEXT NOT NULL
|
||||
CHECK (length(current_key_id) = 64 AND current_key_id NOT GLOB '*[^0-9a-f]*'),
|
||||
current_generation INTEGER NOT NULL CHECK (current_generation >= 1),
|
||||
created_at INTEGER NOT NULL CHECK (created_at >= 0),
|
||||
updated_at INTEGER NOT NULL CHECK (updated_at >= 0),
|
||||
FOREIGN KEY (user_id) REFERENCES better_auth_user (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sync_vault_envelopes (
|
||||
user_id TEXT NOT NULL,
|
||||
recipient_device_id TEXT NOT NULL,
|
||||
approver_device_id TEXT NOT NULL,
|
||||
key_id TEXT NOT NULL
|
||||
CHECK (length(key_id) = 64 AND key_id NOT GLOB '*[^0-9a-f]*'),
|
||||
generation INTEGER NOT NULL CHECK (generation >= 1),
|
||||
envelope_version INTEGER NOT NULL CHECK (envelope_version = 1),
|
||||
suite TEXT NOT NULL
|
||||
CHECK (suite = 'HPKE-BASE-X25519-HKDF-SHA256-CHACHA20POLY1305'),
|
||||
encapped_key TEXT NOT NULL
|
||||
CHECK (
|
||||
length(encapped_key) = 43
|
||||
AND encapped_key NOT GLOB '*[^A-Za-z0-9_-]*'
|
||||
AND substr(encapped_key, 43, 1) GLOB '[AEIMQUYcgkosw048]'
|
||||
),
|
||||
ciphertext TEXT NOT NULL
|
||||
CHECK (length(ciphertext) = 64 AND ciphertext NOT GLOB '*[^A-Za-z0-9_-]*'),
|
||||
idempotency_key TEXT NOT NULL
|
||||
CHECK (
|
||||
length(idempotency_key) BETWEEN 16 AND 128
|
||||
AND idempotency_key NOT GLOB '*[^A-Za-z0-9._:-]*'
|
||||
),
|
||||
created_at INTEGER NOT NULL CHECK (created_at >= 0),
|
||||
PRIMARY KEY (user_id, recipient_device_id, key_id, generation),
|
||||
UNIQUE (user_id, idempotency_key),
|
||||
FOREIGN KEY (user_id) REFERENCES sync_vault_accounts (user_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id, recipient_device_id)
|
||||
REFERENCES user_devices (user_id, device_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id, approver_device_id)
|
||||
REFERENCES user_devices (user_id, device_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sync_vault_envelopes_current_device
|
||||
ON sync_vault_envelopes (user_id, recipient_device_id, generation, key_id);
|
||||
@@ -0,0 +1,57 @@
|
||||
CREATE TABLE IF NOT EXISTS user_device_keys (
|
||||
user_id TEXT NOT NULL,
|
||||
device_id TEXT NOT NULL,
|
||||
signing_public_key TEXT NOT NULL,
|
||||
wrapping_public_key TEXT,
|
||||
key_protocol_version INTEGER NOT NULL CHECK (key_protocol_version IN (1, 2)),
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (user_id, device_id),
|
||||
FOREIGN KEY (user_id, device_id)
|
||||
REFERENCES user_devices (user_id, device_id) ON DELETE CASCADE,
|
||||
CHECK (
|
||||
(key_protocol_version = 1 AND wrapping_public_key IS NULL)
|
||||
OR
|
||||
(
|
||||
key_protocol_version = 2
|
||||
AND length(signing_public_key) = 64
|
||||
AND signing_public_key NOT GLOB '*[^0-9a-f]*'
|
||||
AND wrapping_public_key IS NOT NULL
|
||||
AND length(wrapping_public_key) = 64
|
||||
AND wrapping_public_key NOT GLOB '*[^0-9a-f]*'
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO user_device_keys (
|
||||
user_id,
|
||||
device_id,
|
||||
signing_public_key,
|
||||
wrapping_public_key,
|
||||
key_protocol_version,
|
||||
created_at
|
||||
)
|
||||
SELECT user_id, device_id, public_key, NULL, 1, created_at
|
||||
FROM user_devices;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS device_rebind_challenges (
|
||||
challenge_id TEXT NOT NULL PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL UNIQUE,
|
||||
device_id TEXT NOT NULL,
|
||||
challenge TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
consumed_at INTEGER,
|
||||
consumption_nonce TEXT,
|
||||
FOREIGN KEY (session_id) REFERENCES better_auth_session (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id, device_id)
|
||||
REFERENCES user_device_keys (user_id, device_id) ON DELETE CASCADE,
|
||||
CHECK (expires_at > created_at),
|
||||
CHECK (
|
||||
(consumed_at IS NULL AND consumption_nonce IS NULL)
|
||||
OR (consumed_at IS NOT NULL AND consumption_nonce IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_device_rebind_challenges_expiry
|
||||
ON device_rebind_challenges (expires_at, consumed_at);
|
||||
@@ -0,0 +1,373 @@
|
||||
CREATE TABLE IF NOT EXISTS sync_vault_rotations (
|
||||
user_id TEXT NOT NULL,
|
||||
idempotency_key TEXT NOT NULL
|
||||
CHECK (
|
||||
length(idempotency_key) BETWEEN 16 AND 128
|
||||
AND idempotency_key NOT GLOB '*[^A-Za-z0-9._:-]*'
|
||||
),
|
||||
audit_event_id TEXT NOT NULL UNIQUE,
|
||||
target_device_id TEXT NOT NULL,
|
||||
approver_device_id TEXT NOT NULL,
|
||||
previous_key_id TEXT NOT NULL
|
||||
CHECK (length(previous_key_id) = 64 AND previous_key_id NOT GLOB '*[^0-9a-f]*'),
|
||||
previous_generation INTEGER NOT NULL CHECK (previous_generation >= 1),
|
||||
new_key_id TEXT NOT NULL
|
||||
CHECK (length(new_key_id) = 64 AND new_key_id NOT GLOB '*[^0-9a-f]*'),
|
||||
new_generation INTEGER NOT NULL CHECK (new_generation >= 2),
|
||||
request_hash TEXT NOT NULL
|
||||
CHECK (length(request_hash) = 64 AND request_hash NOT GLOB '*[^0-9a-f]*'),
|
||||
envelope_count INTEGER NOT NULL CHECK (envelope_count BETWEEN 1 AND 128),
|
||||
r2_object_count INTEGER NOT NULL CHECK (r2_object_count >= 0),
|
||||
created_at INTEGER NOT NULL CHECK (created_at >= 0),
|
||||
completed_at INTEGER CHECK (completed_at IS NULL OR completed_at >= created_at),
|
||||
cleanup_snapshot_id TEXT
|
||||
CHECK (
|
||||
cleanup_snapshot_id IS NULL
|
||||
OR (
|
||||
length(cleanup_snapshot_id) BETWEEN 1 AND 128
|
||||
AND substr(cleanup_snapshot_id, 1, 1) GLOB '[a-z0-9]'
|
||||
AND cleanup_snapshot_id NOT GLOB '*[^a-z0-9._-]*'
|
||||
)
|
||||
),
|
||||
cleanup_started_at INTEGER,
|
||||
storage_cleaned_at INTEGER
|
||||
CHECK (
|
||||
storage_cleaned_at IS NULL
|
||||
OR (
|
||||
cleanup_started_at IS NOT NULL
|
||||
AND storage_cleaned_at >= cleanup_started_at
|
||||
)
|
||||
),
|
||||
PRIMARY KEY (user_id, idempotency_key),
|
||||
FOREIGN KEY (user_id) REFERENCES sync_vault_accounts (user_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id, target_device_id)
|
||||
REFERENCES user_devices (user_id, device_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id, approver_device_id)
|
||||
REFERENCES user_devices (user_id, device_id) ON DELETE CASCADE,
|
||||
CHECK (target_device_id <> approver_device_id),
|
||||
CHECK (new_key_id <> previous_key_id),
|
||||
CHECK (new_generation = previous_generation + 1),
|
||||
CHECK (
|
||||
(cleanup_snapshot_id IS NULL AND cleanup_started_at IS NULL)
|
||||
OR (
|
||||
cleanup_snapshot_id IS NOT NULL
|
||||
AND cleanup_started_at IS NOT NULL
|
||||
AND completed_at IS NOT NULL
|
||||
AND cleanup_started_at >= completed_at
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sync_vault_rotation_envelopes (
|
||||
user_id TEXT NOT NULL,
|
||||
rotation_idempotency_key TEXT NOT NULL,
|
||||
recipient_device_id TEXT NOT NULL,
|
||||
envelope_idempotency_key TEXT NOT NULL
|
||||
CHECK (
|
||||
length(envelope_idempotency_key) = 64
|
||||
AND envelope_idempotency_key NOT GLOB '*[^0-9a-f]*'
|
||||
),
|
||||
envelope_version INTEGER NOT NULL CHECK (envelope_version = 1),
|
||||
suite TEXT NOT NULL
|
||||
CHECK (suite = 'HPKE-BASE-X25519-HKDF-SHA256-CHACHA20POLY1305'),
|
||||
encapped_key TEXT NOT NULL
|
||||
CHECK (
|
||||
length(encapped_key) = 43
|
||||
AND encapped_key NOT GLOB '*[^A-Za-z0-9_-]*'
|
||||
AND substr(encapped_key, 43, 1) GLOB '[AEIMQUYcgkosw048]'
|
||||
),
|
||||
ciphertext TEXT NOT NULL
|
||||
CHECK (length(ciphertext) = 64 AND ciphertext NOT GLOB '*[^A-Za-z0-9_-]*'),
|
||||
PRIMARY KEY (user_id, rotation_idempotency_key, recipient_device_id),
|
||||
UNIQUE (user_id, envelope_idempotency_key),
|
||||
FOREIGN KEY (user_id, rotation_idempotency_key)
|
||||
REFERENCES sync_vault_rotations (user_id, idempotency_key) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id, recipient_device_id)
|
||||
REFERENCES user_devices (user_id, device_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sync_vault_rotation_r2_objects (
|
||||
user_id TEXT NOT NULL,
|
||||
rotation_idempotency_key TEXT NOT NULL,
|
||||
r2_key TEXT NOT NULL CHECK (length(r2_key) BETWEEN 1 AND 1024),
|
||||
PRIMARY KEY (user_id, rotation_idempotency_key, r2_key),
|
||||
FOREIGN KEY (user_id, rotation_idempotency_key)
|
||||
REFERENCES sync_vault_rotations (user_id, idempotency_key) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pending_device_revocations (
|
||||
user_id TEXT NOT NULL,
|
||||
idempotency_key TEXT NOT NULL
|
||||
CHECK (
|
||||
length(idempotency_key) BETWEEN 16 AND 128
|
||||
AND idempotency_key NOT GLOB '*[^A-Za-z0-9._:-]*'
|
||||
),
|
||||
audit_event_id TEXT NOT NULL UNIQUE,
|
||||
target_device_id TEXT NOT NULL,
|
||||
approver_device_id TEXT NOT NULL,
|
||||
request_hash TEXT NOT NULL
|
||||
CHECK (length(request_hash) = 64 AND request_hash NOT GLOB '*[^0-9a-f]*'),
|
||||
created_at INTEGER NOT NULL CHECK (created_at >= 0),
|
||||
completed_at INTEGER CHECK (completed_at IS NULL OR completed_at >= created_at),
|
||||
PRIMARY KEY (user_id, idempotency_key),
|
||||
FOREIGN KEY (user_id, target_device_id)
|
||||
REFERENCES user_devices (user_id, device_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id, approver_device_id)
|
||||
REFERENCES user_devices (user_id, device_id) ON DELETE CASCADE,
|
||||
CHECK (target_device_id <> approver_device_id)
|
||||
);
|
||||
|
||||
UPDATE user_devices
|
||||
SET
|
||||
approval_status = 'revoked',
|
||||
revoked_at = COALESCE(revoked_at, unixepoch())
|
||||
WHERE approval_status = 'approved'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM user_device_keys AS keys
|
||||
WHERE keys.user_id = user_devices.user_id
|
||||
AND keys.device_id = user_devices.device_id
|
||||
AND keys.key_protocol_version = 1
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS finalize_pending_device_revocation
|
||||
BEFORE UPDATE OF completed_at ON pending_device_revocations
|
||||
FOR EACH ROW
|
||||
WHEN OLD.completed_at IS NULL AND NEW.completed_at IS NOT NULL
|
||||
BEGIN
|
||||
SELECT CASE WHEN
|
||||
NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM user_devices AS device
|
||||
INNER JOIN user_device_keys AS keys
|
||||
ON keys.user_id = device.user_id AND keys.device_id = device.device_id
|
||||
WHERE device.user_id = OLD.user_id
|
||||
AND device.device_id = OLD.approver_device_id
|
||||
AND device.approval_status = 'approved'
|
||||
AND device.revoked_at IS NULL
|
||||
AND keys.key_protocol_version = 2
|
||||
AND keys.wrapping_public_key IS NOT NULL
|
||||
)
|
||||
OR NOT EXISTS (
|
||||
SELECT 1 FROM user_devices
|
||||
WHERE user_id = OLD.user_id
|
||||
AND device_id = OLD.target_device_id
|
||||
AND approval_status = 'pending'
|
||||
AND revoked_at IS NULL
|
||||
)
|
||||
THEN RAISE(ABORT, 'pending_device_revocation_guard_failed') END;
|
||||
|
||||
DELETE FROM better_auth_session
|
||||
WHERE id IN (
|
||||
SELECT session_id FROM better_auth_session_device_context
|
||||
WHERE user_id = OLD.user_id AND device_id = OLD.target_device_id
|
||||
);
|
||||
|
||||
UPDATE user_devices
|
||||
SET approval_status = 'revoked', revoked_at = NEW.completed_at
|
||||
WHERE user_id = OLD.user_id
|
||||
AND device_id = OLD.target_device_id
|
||||
AND approval_status = 'pending'
|
||||
AND revoked_at IS NULL;
|
||||
|
||||
INSERT INTO audit_events (
|
||||
event_id, user_id, actor_device_id, event_type, subject_type,
|
||||
subject_id, outcome, metadata_hash, created_at
|
||||
) VALUES (
|
||||
OLD.audit_event_id, OLD.user_id, OLD.approver_device_id, 'device.revoke', 'device',
|
||||
OLD.target_device_id, 'success', OLD.request_hash, NEW.completed_at
|
||||
);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS finalize_sync_vault_rotation
|
||||
BEFORE UPDATE OF completed_at ON sync_vault_rotations
|
||||
FOR EACH ROW
|
||||
WHEN OLD.completed_at IS NULL AND NEW.completed_at IS NOT NULL
|
||||
BEGIN
|
||||
INSERT OR IGNORE INTO sync_vault_rotation_r2_objects (
|
||||
user_id, rotation_idempotency_key, r2_key
|
||||
)
|
||||
SELECT OLD.user_id, OLD.idempotency_key, current_r2.r2_key
|
||||
FROM (
|
||||
SELECT payload_r2_key AS r2_key
|
||||
FROM sync_objects
|
||||
WHERE user_id = OLD.user_id AND payload_r2_key IS NOT NULL
|
||||
UNION
|
||||
SELECT r2_key FROM sync_snapshots WHERE user_id = OLD.user_id
|
||||
) AS current_r2;
|
||||
|
||||
SELECT CASE WHEN
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM sync_vault_accounts
|
||||
WHERE user_id = OLD.user_id
|
||||
AND current_key_id = OLD.previous_key_id
|
||||
AND current_generation = OLD.previous_generation
|
||||
)
|
||||
OR NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM user_devices AS device
|
||||
INNER JOIN user_device_keys AS keys
|
||||
ON keys.user_id = device.user_id AND keys.device_id = device.device_id
|
||||
WHERE device.user_id = OLD.user_id
|
||||
AND device.device_id = OLD.approver_device_id
|
||||
AND device.approval_status = 'approved'
|
||||
AND device.revoked_at IS NULL
|
||||
AND keys.key_protocol_version = 2
|
||||
AND keys.wrapping_public_key IS NOT NULL
|
||||
)
|
||||
OR NOT EXISTS (
|
||||
SELECT 1 FROM user_devices
|
||||
WHERE user_id = OLD.user_id
|
||||
AND device_id = OLD.target_device_id
|
||||
AND approval_status IN ('pending', 'approved')
|
||||
AND revoked_at IS NULL
|
||||
)
|
||||
OR (
|
||||
SELECT COUNT(*) FROM sync_vault_rotation_envelopes
|
||||
WHERE user_id = OLD.user_id
|
||||
AND rotation_idempotency_key = OLD.idempotency_key
|
||||
) <> OLD.envelope_count
|
||||
OR (
|
||||
SELECT COUNT(*)
|
||||
FROM user_devices AS device
|
||||
INNER JOIN user_device_keys AS keys
|
||||
ON keys.user_id = device.user_id AND keys.device_id = device.device_id
|
||||
WHERE device.user_id = OLD.user_id
|
||||
AND device.device_id <> OLD.target_device_id
|
||||
AND device.approval_status = 'approved'
|
||||
AND device.revoked_at IS NULL
|
||||
AND keys.key_protocol_version = 2
|
||||
AND keys.wrapping_public_key IS NOT NULL
|
||||
) <> OLD.envelope_count
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM sync_vault_rotation_envelopes AS envelope
|
||||
WHERE envelope.user_id = OLD.user_id
|
||||
AND envelope.rotation_idempotency_key = OLD.idempotency_key
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM user_devices AS device
|
||||
INNER JOIN user_device_keys AS keys
|
||||
ON keys.user_id = device.user_id AND keys.device_id = device.device_id
|
||||
WHERE device.user_id = OLD.user_id
|
||||
AND device.device_id = envelope.recipient_device_id
|
||||
AND device.device_id <> OLD.target_device_id
|
||||
AND device.approval_status = 'approved'
|
||||
AND device.revoked_at IS NULL
|
||||
AND keys.key_protocol_version = 2
|
||||
AND keys.wrapping_public_key IS NOT NULL
|
||||
)
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM user_devices AS device
|
||||
INNER JOIN user_device_keys AS keys
|
||||
ON keys.user_id = device.user_id AND keys.device_id = device.device_id
|
||||
WHERE device.user_id = OLD.user_id
|
||||
AND device.device_id <> OLD.target_device_id
|
||||
AND device.approval_status = 'approved'
|
||||
AND device.revoked_at IS NULL
|
||||
AND keys.key_protocol_version = 2
|
||||
AND keys.wrapping_public_key IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM sync_vault_rotation_envelopes AS envelope
|
||||
WHERE envelope.user_id = OLD.user_id
|
||||
AND envelope.rotation_idempotency_key = OLD.idempotency_key
|
||||
AND envelope.recipient_device_id = device.device_id
|
||||
)
|
||||
)
|
||||
OR (
|
||||
SELECT COUNT(*) FROM sync_vault_rotation_r2_objects
|
||||
WHERE user_id = OLD.user_id
|
||||
AND rotation_idempotency_key = OLD.idempotency_key
|
||||
) <> OLD.r2_object_count
|
||||
OR (
|
||||
SELECT COUNT(*) FROM (
|
||||
SELECT payload_r2_key AS r2_key
|
||||
FROM sync_objects
|
||||
WHERE user_id = OLD.user_id AND payload_r2_key IS NOT NULL
|
||||
UNION
|
||||
SELECT r2_key FROM sync_snapshots WHERE user_id = OLD.user_id
|
||||
)
|
||||
) <> OLD.r2_object_count
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM sync_vault_rotation_r2_objects AS staged
|
||||
WHERE staged.user_id = OLD.user_id
|
||||
AND staged.rotation_idempotency_key = OLD.idempotency_key
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM (
|
||||
SELECT payload_r2_key AS r2_key
|
||||
FROM sync_objects
|
||||
WHERE user_id = OLD.user_id AND payload_r2_key IS NOT NULL
|
||||
UNION
|
||||
SELECT r2_key FROM sync_snapshots WHERE user_id = OLD.user_id
|
||||
) AS current_r2
|
||||
WHERE current_r2.r2_key = staged.r2_key
|
||||
)
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM (
|
||||
SELECT payload_r2_key AS r2_key
|
||||
FROM sync_objects
|
||||
WHERE user_id = OLD.user_id AND payload_r2_key IS NOT NULL
|
||||
UNION
|
||||
SELECT r2_key FROM sync_snapshots WHERE user_id = OLD.user_id
|
||||
) AS current_r2
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM sync_vault_rotation_r2_objects AS staged
|
||||
WHERE staged.user_id = OLD.user_id
|
||||
AND staged.rotation_idempotency_key = OLD.idempotency_key
|
||||
AND staged.r2_key = current_r2.r2_key
|
||||
)
|
||||
)
|
||||
THEN RAISE(ABORT, 'sync_vault_rotation_guard_failed') END;
|
||||
|
||||
INSERT INTO sync_vault_envelopes (
|
||||
user_id, recipient_device_id, approver_device_id, key_id, generation,
|
||||
envelope_version, suite, encapped_key, ciphertext, idempotency_key, created_at
|
||||
)
|
||||
SELECT
|
||||
envelope.user_id,
|
||||
envelope.recipient_device_id,
|
||||
OLD.approver_device_id,
|
||||
OLD.new_key_id,
|
||||
OLD.new_generation,
|
||||
envelope.envelope_version,
|
||||
envelope.suite,
|
||||
envelope.encapped_key,
|
||||
envelope.ciphertext,
|
||||
envelope.envelope_idempotency_key,
|
||||
NEW.completed_at
|
||||
FROM sync_vault_rotation_envelopes AS envelope
|
||||
WHERE envelope.user_id = OLD.user_id
|
||||
AND envelope.rotation_idempotency_key = OLD.idempotency_key;
|
||||
|
||||
UPDATE sync_vault_accounts
|
||||
SET
|
||||
current_key_id = OLD.new_key_id,
|
||||
current_generation = OLD.new_generation,
|
||||
updated_at = NEW.completed_at
|
||||
WHERE user_id = OLD.user_id
|
||||
AND current_key_id = OLD.previous_key_id
|
||||
AND current_generation = OLD.previous_generation;
|
||||
|
||||
DELETE FROM better_auth_session
|
||||
WHERE id IN (
|
||||
SELECT session_id FROM better_auth_session_device_context
|
||||
WHERE user_id = OLD.user_id AND device_id = OLD.target_device_id
|
||||
);
|
||||
|
||||
UPDATE user_devices
|
||||
SET approval_status = 'revoked', revoked_at = NEW.completed_at
|
||||
WHERE user_id = OLD.user_id
|
||||
AND device_id = OLD.target_device_id
|
||||
AND approval_status IN ('pending', 'approved')
|
||||
AND revoked_at IS NULL;
|
||||
|
||||
INSERT INTO audit_events (
|
||||
event_id, user_id, actor_device_id, event_type, subject_type,
|
||||
subject_id, outcome, metadata_hash, created_at
|
||||
) VALUES (
|
||||
OLD.audit_event_id, OLD.user_id, OLD.approver_device_id, 'device.revoke', 'device',
|
||||
OLD.target_device_id, 'success', OLD.request_hash, NEW.completed_at
|
||||
);
|
||||
END;
|
||||
@@ -0,0 +1,282 @@
|
||||
ALTER TABLE sync_snapshots
|
||||
ADD COLUMN head_revision INTEGER NOT NULL DEFAULT 0 CHECK (head_revision >= 0);
|
||||
|
||||
ALTER TABLE sync_snapshots
|
||||
ADD COLUMN base_head_revision INTEGER CHECK (base_head_revision IS NULL OR base_head_revision >= 1);
|
||||
|
||||
ALTER TABLE sync_snapshots
|
||||
ADD COLUMN base_snapshot_id TEXT;
|
||||
|
||||
ALTER TABLE sync_snapshots
|
||||
ADD COLUMN base_payload_hash TEXT
|
||||
CHECK (
|
||||
base_payload_hash IS NULL
|
||||
OR (
|
||||
length(base_payload_hash) = 64
|
||||
AND base_payload_hash NOT GLOB '*[^0-9a-f]*'
|
||||
)
|
||||
);
|
||||
|
||||
ALTER TABLE sync_snapshot_encryption RENAME TO sync_snapshot_encryption_v1;
|
||||
|
||||
DROP INDEX IF EXISTS idx_sync_snapshots_encrypted_latest;
|
||||
|
||||
CREATE TABLE sync_snapshot_encryption (
|
||||
user_id TEXT NOT NULL,
|
||||
snapshot_id TEXT NOT NULL,
|
||||
encryption_version INTEGER NOT NULL CHECK (encryption_version IN (1, 2)),
|
||||
vault_generation INTEGER NOT NULL CHECK (vault_generation >= 1),
|
||||
key_id TEXT NOT NULL,
|
||||
content_hash TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, snapshot_id),
|
||||
FOREIGN KEY (user_id, snapshot_id) REFERENCES sync_snapshots (user_id, snapshot_id)
|
||||
);
|
||||
|
||||
INSERT INTO sync_snapshot_encryption (
|
||||
user_id,
|
||||
snapshot_id,
|
||||
encryption_version,
|
||||
vault_generation,
|
||||
key_id,
|
||||
content_hash
|
||||
)
|
||||
SELECT
|
||||
user_id,
|
||||
snapshot_id,
|
||||
encryption_version,
|
||||
vault_generation,
|
||||
key_id,
|
||||
content_hash
|
||||
FROM sync_snapshot_encryption_v1;
|
||||
|
||||
DROP TABLE sync_snapshot_encryption_v1;
|
||||
|
||||
CREATE INDEX idx_sync_snapshots_encrypted_latest
|
||||
ON sync_snapshot_encryption (user_id, encryption_version, snapshot_id);
|
||||
|
||||
UPDATE sync_snapshots AS candidate
|
||||
SET head_revision = 1
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM sync_snapshot_encryption AS encryption
|
||||
WHERE encryption.user_id = candidate.user_id
|
||||
AND encryption.snapshot_id = candidate.snapshot_id
|
||||
AND encryption.encryption_version = 1
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM sync_snapshots AS newer
|
||||
INNER JOIN sync_snapshot_encryption AS newer_encryption
|
||||
ON newer_encryption.user_id = newer.user_id
|
||||
AND newer_encryption.snapshot_id = newer.snapshot_id
|
||||
AND newer_encryption.encryption_version = 1
|
||||
WHERE newer.user_id = candidate.user_id
|
||||
AND (
|
||||
newer.created_at > candidate.created_at
|
||||
OR (
|
||||
newer.created_at = candidate.created_at
|
||||
AND newer.snapshot_id < candidate.snapshot_id
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_sync_snapshot_committed_revision
|
||||
ON sync_snapshots (user_id, head_revision)
|
||||
WHERE head_revision > 0;
|
||||
|
||||
CREATE TABLE sync_snapshot_heads (
|
||||
user_id TEXT NOT NULL PRIMARY KEY,
|
||||
head_revision INTEGER NOT NULL CHECK (head_revision >= 1),
|
||||
snapshot_id TEXT NOT NULL,
|
||||
payload_hash TEXT NOT NULL
|
||||
CHECK (length(payload_hash) = 64 AND payload_hash NOT GLOB '*[^0-9a-f]*'),
|
||||
updated_at INTEGER NOT NULL CHECK (updated_at >= 0),
|
||||
FOREIGN KEY (user_id, snapshot_id)
|
||||
REFERENCES sync_snapshots (user_id, snapshot_id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (user_id, snapshot_id)
|
||||
REFERENCES sync_snapshot_encryption (user_id, snapshot_id) ON DELETE RESTRICT
|
||||
);
|
||||
|
||||
INSERT INTO sync_snapshot_heads (
|
||||
user_id,
|
||||
head_revision,
|
||||
snapshot_id,
|
||||
payload_hash,
|
||||
updated_at
|
||||
)
|
||||
SELECT user_id, 1, snapshot_id, payload_hash, created_at
|
||||
FROM sync_snapshots
|
||||
WHERE head_revision = 1;
|
||||
|
||||
CREATE TRIGGER sync_snapshot_candidate_insert_guard
|
||||
BEFORE INSERT ON sync_snapshots
|
||||
FOR EACH ROW
|
||||
WHEN NEW.head_revision > 0
|
||||
BEGIN
|
||||
SELECT CASE WHEN (
|
||||
(
|
||||
NEW.head_revision = 1
|
||||
AND NEW.base_head_revision IS NULL
|
||||
AND NEW.base_snapshot_id IS NULL
|
||||
AND NEW.base_payload_hash IS NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM sync_snapshot_heads WHERE user_id = NEW.user_id
|
||||
)
|
||||
)
|
||||
OR
|
||||
(
|
||||
NEW.head_revision > 1
|
||||
AND NEW.base_head_revision IS NOT NULL
|
||||
AND NEW.base_snapshot_id IS NOT NULL
|
||||
AND NEW.base_payload_hash IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM sync_snapshot_heads AS head
|
||||
INNER JOIN sync_snapshots AS base
|
||||
ON base.user_id = head.user_id
|
||||
AND base.snapshot_id = head.snapshot_id
|
||||
AND base.payload_hash = head.payload_hash
|
||||
WHERE head.user_id = NEW.user_id
|
||||
AND head.head_revision = NEW.base_head_revision
|
||||
AND head.snapshot_id = NEW.base_snapshot_id
|
||||
AND head.payload_hash = NEW.base_payload_hash
|
||||
AND NEW.head_revision = head.head_revision + 1
|
||||
AND NEW.logical_clock > base.logical_clock
|
||||
)
|
||||
)
|
||||
) THEN 1 ELSE RAISE(ABORT, 'sync_snapshot_head_cas_failed') END;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER sync_snapshot_candidate_update_guard
|
||||
BEFORE UPDATE ON sync_snapshots
|
||||
FOR EACH ROW
|
||||
WHEN OLD.head_revision > 0 OR NEW.head_revision > 0
|
||||
BEGIN
|
||||
SELECT CASE WHEN (
|
||||
(
|
||||
NEW.head_revision = 1
|
||||
AND NEW.base_head_revision IS NULL
|
||||
AND NEW.base_snapshot_id IS NULL
|
||||
AND NEW.base_payload_hash IS NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM sync_snapshot_heads WHERE user_id = NEW.user_id
|
||||
)
|
||||
)
|
||||
OR
|
||||
(
|
||||
NEW.head_revision > 1
|
||||
AND NEW.base_head_revision IS NOT NULL
|
||||
AND NEW.base_snapshot_id IS NOT NULL
|
||||
AND NEW.base_payload_hash IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM sync_snapshot_heads AS head
|
||||
INNER JOIN sync_snapshots AS base
|
||||
ON base.user_id = head.user_id
|
||||
AND base.snapshot_id = head.snapshot_id
|
||||
AND base.payload_hash = head.payload_hash
|
||||
WHERE head.user_id = NEW.user_id
|
||||
AND head.head_revision = NEW.base_head_revision
|
||||
AND head.snapshot_id = NEW.base_snapshot_id
|
||||
AND head.payload_hash = NEW.base_payload_hash
|
||||
AND NEW.head_revision = head.head_revision + 1
|
||||
AND NEW.logical_clock > base.logical_clock
|
||||
)
|
||||
)
|
||||
) THEN 1 ELSE RAISE(ABORT, 'sync_snapshot_head_cas_failed') END;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER sync_snapshot_head_insert_guard
|
||||
BEFORE INSERT ON sync_snapshot_heads
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
SELECT CASE WHEN EXISTS (
|
||||
SELECT 1
|
||||
FROM sync_snapshots AS snapshot
|
||||
INNER JOIN sync_snapshot_encryption AS encryption
|
||||
ON encryption.user_id = snapshot.user_id
|
||||
AND encryption.snapshot_id = snapshot.snapshot_id
|
||||
INNER JOIN sync_vault_accounts AS account
|
||||
ON account.user_id = snapshot.user_id
|
||||
AND account.current_key_id = encryption.key_id
|
||||
AND account.current_generation = encryption.vault_generation
|
||||
INNER JOIN user_devices AS device
|
||||
ON device.user_id = snapshot.user_id
|
||||
AND device.device_id = snapshot.device_id
|
||||
AND device.approval_status = 'approved'
|
||||
AND device.revoked_at IS NULL
|
||||
INNER JOIN user_device_keys AS keys
|
||||
ON keys.user_id = device.user_id
|
||||
AND keys.device_id = device.device_id
|
||||
AND keys.key_protocol_version = 2
|
||||
AND keys.wrapping_public_key IS NOT NULL
|
||||
WHERE snapshot.user_id = NEW.user_id
|
||||
AND snapshot.snapshot_id = NEW.snapshot_id
|
||||
AND snapshot.payload_hash = NEW.payload_hash
|
||||
AND snapshot.head_revision = NEW.head_revision
|
||||
AND snapshot.head_revision = 1
|
||||
AND snapshot.base_head_revision IS NULL
|
||||
AND snapshot.base_snapshot_id IS NULL
|
||||
AND snapshot.base_payload_hash IS NULL
|
||||
AND encryption.encryption_version = 2
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM sync_vault_rotation_r2_objects AS staged
|
||||
INNER JOIN sync_vault_rotations AS rotation
|
||||
ON rotation.user_id = staged.user_id
|
||||
AND rotation.idempotency_key = staged.rotation_idempotency_key
|
||||
WHERE staged.user_id = snapshot.user_id
|
||||
AND staged.r2_key = snapshot.r2_key
|
||||
AND rotation.cleanup_started_at IS NOT NULL
|
||||
)
|
||||
) THEN 1 ELSE RAISE(ABORT, 'sync_snapshot_head_cas_failed') END;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER sync_snapshot_head_update_guard
|
||||
BEFORE UPDATE ON sync_snapshot_heads
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
SELECT CASE WHEN (
|
||||
NEW.user_id = OLD.user_id
|
||||
AND NEW.head_revision = OLD.head_revision + 1
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM sync_snapshots AS snapshot
|
||||
INNER JOIN sync_snapshot_encryption AS encryption
|
||||
ON encryption.user_id = snapshot.user_id
|
||||
AND encryption.snapshot_id = snapshot.snapshot_id
|
||||
INNER JOIN sync_vault_accounts AS account
|
||||
ON account.user_id = snapshot.user_id
|
||||
AND account.current_key_id = encryption.key_id
|
||||
AND account.current_generation = encryption.vault_generation
|
||||
INNER JOIN user_devices AS device
|
||||
ON device.user_id = snapshot.user_id
|
||||
AND device.device_id = snapshot.device_id
|
||||
AND device.approval_status = 'approved'
|
||||
AND device.revoked_at IS NULL
|
||||
INNER JOIN user_device_keys AS keys
|
||||
ON keys.user_id = device.user_id
|
||||
AND keys.device_id = device.device_id
|
||||
AND keys.key_protocol_version = 2
|
||||
AND keys.wrapping_public_key IS NOT NULL
|
||||
WHERE snapshot.user_id = NEW.user_id
|
||||
AND snapshot.snapshot_id = NEW.snapshot_id
|
||||
AND snapshot.payload_hash = NEW.payload_hash
|
||||
AND snapshot.head_revision = NEW.head_revision
|
||||
AND snapshot.base_head_revision = OLD.head_revision
|
||||
AND snapshot.base_snapshot_id = OLD.snapshot_id
|
||||
AND snapshot.base_payload_hash = OLD.payload_hash
|
||||
AND encryption.encryption_version = 2
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM sync_vault_rotation_r2_objects AS staged
|
||||
INNER JOIN sync_vault_rotations AS rotation
|
||||
ON rotation.user_id = staged.user_id
|
||||
AND rotation.idempotency_key = staged.rotation_idempotency_key
|
||||
WHERE staged.user_id = snapshot.user_id
|
||||
AND staged.r2_key = snapshot.r2_key
|
||||
AND rotation.cleanup_started_at IS NOT NULL
|
||||
)
|
||||
)
|
||||
) THEN 1 ELSE RAISE(ABORT, 'sync_snapshot_head_cas_failed') END;
|
||||
END;
|
||||
@@ -0,0 +1,309 @@
|
||||
CREATE TABLE sync_r2_gc_candidates (
|
||||
r2_key TEXT NOT NULL PRIMARY KEY CHECK (length(r2_key) BETWEEN 1 AND 1024),
|
||||
user_id TEXT,
|
||||
owner_hash TEXT NOT NULL
|
||||
CHECK (length(owner_hash) = 64 AND owner_hash NOT GLOB '*[^0-9a-f]*'),
|
||||
object_kind TEXT NOT NULL CHECK (object_kind IN ('payload', 'snapshot')),
|
||||
state TEXT NOT NULL CHECK (state IN ('pending', 'referenced', 'ready', 'deleting', 'deleted')),
|
||||
write_token TEXT
|
||||
CHECK (
|
||||
write_token IS NULL
|
||||
OR (length(write_token) = 64 AND write_token NOT GLOB '*[^0-9a-f]*')
|
||||
),
|
||||
lease_expires_at INTEGER NOT NULL CHECK (lease_expires_at >= 0),
|
||||
gc_token TEXT
|
||||
CHECK (
|
||||
gc_token IS NULL
|
||||
OR (length(gc_token) = 64 AND gc_token NOT GLOB '*[^0-9a-f]*')
|
||||
),
|
||||
created_at INTEGER NOT NULL CHECK (created_at >= 0),
|
||||
updated_at INTEGER NOT NULL CHECK (updated_at >= created_at),
|
||||
referenced_at INTEGER,
|
||||
ready_at INTEGER,
|
||||
delete_started_at INTEGER,
|
||||
deleted_at INTEGER,
|
||||
CHECK (state <> 'pending' OR (user_id IS NOT NULL AND write_token IS NOT NULL)),
|
||||
CHECK (state <> 'referenced' OR referenced_at IS NOT NULL),
|
||||
CHECK (state NOT IN ('ready', 'deleting', 'deleted') OR ready_at IS NOT NULL),
|
||||
CHECK (state NOT IN ('deleting', 'deleted') OR (gc_token IS NOT NULL AND delete_started_at IS NOT NULL)),
|
||||
CHECK (state <> 'deleted' OR deleted_at IS NOT NULL)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_sync_r2_gc_ready
|
||||
ON sync_r2_gc_candidates (state, lease_expires_at, delete_started_at, updated_at);
|
||||
|
||||
CREATE INDEX idx_sync_r2_gc_user
|
||||
ON sync_r2_gc_candidates (user_id, state, updated_at);
|
||||
|
||||
CREATE INDEX idx_sync_r2_gc_owner
|
||||
ON sync_r2_gc_candidates (owner_hash, state, updated_at);
|
||||
|
||||
CREATE TABLE sync_r2_inventory_cursors (
|
||||
prefix TEXT NOT NULL PRIMARY KEY CHECK (prefix IN ('sync-payloads/', 'sync-snapshots/')),
|
||||
cursor TEXT,
|
||||
updated_at INTEGER NOT NULL CHECK (updated_at >= 0),
|
||||
next_scan_at INTEGER NOT NULL CHECK (next_scan_at >= updated_at)
|
||||
);
|
||||
|
||||
INSERT INTO sync_r2_inventory_cursors (prefix, cursor, updated_at, next_scan_at)
|
||||
VALUES
|
||||
('sync-payloads/', NULL, 0, 0),
|
||||
('sync-snapshots/', NULL, 0, 0);
|
||||
|
||||
INSERT OR IGNORE INTO sync_r2_gc_candidates (
|
||||
r2_key, user_id, owner_hash, object_kind, state, write_token,
|
||||
lease_expires_at, gc_token, created_at, updated_at, referenced_at,
|
||||
ready_at, delete_started_at, deleted_at
|
||||
)
|
||||
SELECT
|
||||
object.payload_r2_key,
|
||||
object.user_id,
|
||||
substr(
|
||||
object.payload_r2_key,
|
||||
instr(object.payload_r2_key, '/')
|
||||
+ instr(substr(object.payload_r2_key, instr(object.payload_r2_key, '/') + 1), '/')
|
||||
+ 1,
|
||||
64
|
||||
),
|
||||
'payload',
|
||||
'referenced',
|
||||
NULL,
|
||||
0,
|
||||
NULL,
|
||||
object.created_at,
|
||||
object.updated_at,
|
||||
object.updated_at,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL
|
||||
FROM sync_objects AS object
|
||||
WHERE object.payload_r2_key IS NOT NULL;
|
||||
|
||||
INSERT OR IGNORE INTO sync_r2_gc_candidates (
|
||||
r2_key, user_id, owner_hash, object_kind, state, write_token,
|
||||
lease_expires_at, gc_token, created_at, updated_at, referenced_at,
|
||||
ready_at, delete_started_at, deleted_at
|
||||
)
|
||||
SELECT
|
||||
snapshot.r2_key,
|
||||
snapshot.user_id,
|
||||
substr(
|
||||
snapshot.r2_key,
|
||||
instr(snapshot.r2_key, '/')
|
||||
+ instr(substr(snapshot.r2_key, instr(snapshot.r2_key, '/') + 1), '/')
|
||||
+ 1,
|
||||
64
|
||||
),
|
||||
'snapshot',
|
||||
'referenced',
|
||||
NULL,
|
||||
0,
|
||||
NULL,
|
||||
snapshot.created_at,
|
||||
snapshot.created_at,
|
||||
snapshot.created_at,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL
|
||||
FROM sync_snapshots AS snapshot;
|
||||
|
||||
CREATE TRIGGER sync_r2_gc_state_transition_guard
|
||||
BEFORE UPDATE OF state ON sync_r2_gc_candidates
|
||||
FOR EACH ROW
|
||||
WHEN NOT (
|
||||
OLD.state = NEW.state
|
||||
OR (OLD.state = 'pending' AND NEW.state IN ('referenced', 'ready', 'deleting'))
|
||||
OR (OLD.state = 'referenced' AND NEW.state = 'ready')
|
||||
OR (OLD.state = 'ready' AND NEW.state = 'deleting')
|
||||
OR (OLD.state = 'deleting' AND NEW.state = 'deleted')
|
||||
OR (OLD.state = 'deleted' AND NEW.state = 'ready')
|
||||
)
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'sync_r2_gc_state_transition_invalid');
|
||||
END;
|
||||
|
||||
CREATE TRIGGER sync_r2_snapshot_insert_fence
|
||||
BEFORE INSERT ON sync_snapshots
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
SELECT CASE WHEN EXISTS (
|
||||
SELECT 1 FROM sync_r2_gc_candidates AS candidate
|
||||
WHERE candidate.r2_key = NEW.r2_key
|
||||
AND candidate.user_id = NEW.user_id
|
||||
AND candidate.object_kind = 'snapshot'
|
||||
AND candidate.state = 'pending'
|
||||
AND candidate.write_token IS NOT NULL
|
||||
) THEN 1 ELSE RAISE(ABORT, 'sync_r2_write_fenced') END;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER sync_r2_snapshot_update_fence
|
||||
BEFORE UPDATE OF r2_key, payload_hash, head_revision ON sync_snapshots
|
||||
FOR EACH ROW
|
||||
WHEN OLD.r2_key <> NEW.r2_key
|
||||
OR OLD.payload_hash <> NEW.payload_hash
|
||||
OR OLD.head_revision <> NEW.head_revision
|
||||
BEGIN
|
||||
SELECT CASE WHEN EXISTS (
|
||||
SELECT 1 FROM sync_r2_gc_candidates AS candidate
|
||||
WHERE candidate.r2_key = NEW.r2_key
|
||||
AND candidate.user_id = NEW.user_id
|
||||
AND candidate.object_kind = 'snapshot'
|
||||
AND candidate.state = 'pending'
|
||||
AND candidate.write_token IS NOT NULL
|
||||
) THEN 1 ELSE RAISE(ABORT, 'sync_r2_write_fenced') END;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER sync_r2_snapshot_head_insert_fence
|
||||
BEFORE INSERT ON sync_snapshot_heads
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
SELECT CASE WHEN EXISTS (
|
||||
SELECT 1
|
||||
FROM sync_snapshots AS snapshot
|
||||
INNER JOIN sync_r2_gc_candidates AS candidate
|
||||
ON candidate.r2_key = snapshot.r2_key
|
||||
AND candidate.user_id = snapshot.user_id
|
||||
AND candidate.object_kind = 'snapshot'
|
||||
AND candidate.state = 'pending'
|
||||
AND candidate.write_token IS NOT NULL
|
||||
WHERE snapshot.user_id = NEW.user_id
|
||||
AND snapshot.snapshot_id = NEW.snapshot_id
|
||||
AND snapshot.head_revision = NEW.head_revision
|
||||
AND snapshot.payload_hash = NEW.payload_hash
|
||||
) THEN 1 ELSE RAISE(ABORT, 'sync_r2_write_fenced') END;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER sync_r2_snapshot_head_update_fence
|
||||
BEFORE UPDATE ON sync_snapshot_heads
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
SELECT CASE WHEN EXISTS (
|
||||
SELECT 1
|
||||
FROM sync_snapshots AS snapshot
|
||||
INNER JOIN sync_r2_gc_candidates AS candidate
|
||||
ON candidate.r2_key = snapshot.r2_key
|
||||
AND candidate.user_id = snapshot.user_id
|
||||
AND candidate.object_kind = 'snapshot'
|
||||
AND candidate.state = 'pending'
|
||||
AND candidate.write_token IS NOT NULL
|
||||
WHERE snapshot.user_id = NEW.user_id
|
||||
AND snapshot.snapshot_id = NEW.snapshot_id
|
||||
AND snapshot.head_revision = NEW.head_revision
|
||||
AND snapshot.payload_hash = NEW.payload_hash
|
||||
) THEN 1 ELSE RAISE(ABORT, 'sync_r2_write_fenced') END;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER sync_r2_payload_insert_fence
|
||||
BEFORE INSERT ON sync_objects
|
||||
FOR EACH ROW
|
||||
WHEN NEW.payload_r2_key IS NOT NULL
|
||||
BEGIN
|
||||
SELECT CASE WHEN EXISTS (
|
||||
SELECT 1 FROM sync_r2_gc_candidates AS candidate
|
||||
WHERE candidate.r2_key = NEW.payload_r2_key
|
||||
AND candidate.user_id = NEW.user_id
|
||||
AND candidate.object_kind = 'payload'
|
||||
AND candidate.state = 'pending'
|
||||
AND candidate.write_token IS NOT NULL
|
||||
) THEN 1 ELSE RAISE(ABORT, 'sync_r2_write_fenced') END;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER sync_r2_payload_update_fence
|
||||
BEFORE UPDATE OF payload_r2_key ON sync_objects
|
||||
FOR EACH ROW
|
||||
WHEN NEW.payload_r2_key IS NOT NULL
|
||||
AND OLD.payload_r2_key IS NOT NEW.payload_r2_key
|
||||
BEGIN
|
||||
SELECT CASE WHEN EXISTS (
|
||||
SELECT 1 FROM sync_r2_gc_candidates AS candidate
|
||||
WHERE candidate.r2_key = NEW.payload_r2_key
|
||||
AND candidate.user_id = NEW.user_id
|
||||
AND candidate.object_kind = 'payload'
|
||||
AND candidate.state = 'pending'
|
||||
AND candidate.write_token IS NOT NULL
|
||||
) THEN 1 ELSE RAISE(ABORT, 'sync_r2_write_fenced') END;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER sync_r2_snapshot_mark_referenced_guard
|
||||
BEFORE UPDATE OF state ON sync_r2_gc_candidates
|
||||
FOR EACH ROW
|
||||
WHEN OLD.object_kind = 'snapshot'
|
||||
AND OLD.state = 'pending'
|
||||
AND NEW.state = 'referenced'
|
||||
BEGIN
|
||||
SELECT CASE WHEN EXISTS (
|
||||
SELECT 1
|
||||
FROM sync_snapshot_heads AS head
|
||||
INNER JOIN sync_snapshots AS snapshot
|
||||
ON snapshot.user_id = head.user_id
|
||||
AND snapshot.snapshot_id = head.snapshot_id
|
||||
AND snapshot.head_revision = head.head_revision
|
||||
AND snapshot.payload_hash = head.payload_hash
|
||||
WHERE snapshot.r2_key = OLD.r2_key
|
||||
AND snapshot.user_id = OLD.user_id
|
||||
) THEN 1 ELSE RAISE(ABORT, 'sync_r2_reference_commit_invalid') END;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER sync_r2_snapshot_displaced
|
||||
AFTER UPDATE OF r2_key ON sync_snapshots
|
||||
FOR EACH ROW
|
||||
WHEN OLD.r2_key <> NEW.r2_key
|
||||
BEGIN
|
||||
UPDATE sync_r2_gc_candidates
|
||||
SET
|
||||
state = 'ready',
|
||||
updated_at = MAX(updated_at, unixepoch()),
|
||||
ready_at = COALESCE(ready_at, unixepoch())
|
||||
WHERE r2_key = OLD.r2_key
|
||||
AND state = 'referenced'
|
||||
AND NOT EXISTS (SELECT 1 FROM sync_snapshots WHERE r2_key = OLD.r2_key)
|
||||
AND NOT EXISTS (SELECT 1 FROM sync_objects WHERE payload_r2_key = OLD.r2_key);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER sync_r2_snapshot_deleted
|
||||
AFTER DELETE ON sync_snapshots
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
UPDATE sync_r2_gc_candidates
|
||||
SET
|
||||
state = 'ready',
|
||||
updated_at = MAX(updated_at, unixepoch()),
|
||||
ready_at = COALESCE(ready_at, unixepoch())
|
||||
WHERE r2_key = OLD.r2_key
|
||||
AND state = 'referenced'
|
||||
AND NOT EXISTS (SELECT 1 FROM sync_snapshots WHERE r2_key = OLD.r2_key)
|
||||
AND NOT EXISTS (SELECT 1 FROM sync_objects WHERE payload_r2_key = OLD.r2_key);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER sync_r2_payload_displaced
|
||||
AFTER UPDATE OF payload_r2_key ON sync_objects
|
||||
FOR EACH ROW
|
||||
WHEN OLD.payload_r2_key IS NOT NULL
|
||||
AND OLD.payload_r2_key IS NOT NEW.payload_r2_key
|
||||
BEGIN
|
||||
UPDATE sync_r2_gc_candidates
|
||||
SET
|
||||
state = 'ready',
|
||||
updated_at = MAX(updated_at, unixepoch()),
|
||||
ready_at = COALESCE(ready_at, unixepoch())
|
||||
WHERE r2_key = OLD.payload_r2_key
|
||||
AND state = 'referenced'
|
||||
AND NOT EXISTS (SELECT 1 FROM sync_snapshots WHERE r2_key = OLD.payload_r2_key)
|
||||
AND NOT EXISTS (SELECT 1 FROM sync_objects WHERE payload_r2_key = OLD.payload_r2_key);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER sync_r2_payload_deleted
|
||||
AFTER DELETE ON sync_objects
|
||||
FOR EACH ROW
|
||||
WHEN OLD.payload_r2_key IS NOT NULL
|
||||
BEGIN
|
||||
UPDATE sync_r2_gc_candidates
|
||||
SET
|
||||
state = 'ready',
|
||||
updated_at = MAX(updated_at, unixepoch()),
|
||||
ready_at = COALESCE(ready_at, unixepoch())
|
||||
WHERE r2_key = OLD.payload_r2_key
|
||||
AND state = 'referenced'
|
||||
AND NOT EXISTS (SELECT 1 FROM sync_snapshots WHERE r2_key = OLD.payload_r2_key)
|
||||
AND NOT EXISTS (SELECT 1 FROM sync_objects WHERE payload_r2_key = OLD.payload_r2_key);
|
||||
END;
|
||||
@@ -1,12 +1,31 @@
|
||||
import type { AuthContext } from "./auth.js";
|
||||
import { authSessionCacheKvKey } from "./auth.js";
|
||||
import type { ElyD1PreparedStatement, Env } from "./bindings.js";
|
||||
import { StorageObjectError, deleteKnownObject } from "./storage.js";
|
||||
import type { ElyD1DatabaseSession, ElyD1PreparedStatement, ElyD1Result, Env } from "./bindings.js";
|
||||
import { primaryD1Session } from "./bindings.js";
|
||||
import {
|
||||
assertDestructiveActionGateResult,
|
||||
destructiveActionGateIsLive,
|
||||
destructiveActionGateStatement,
|
||||
} from "./destructive_action_gate.js";
|
||||
import { deleteLegacySessionKeys } from "./legacy_auth_kv_cleanup.js";
|
||||
import {
|
||||
type RecentDeviceActionProof,
|
||||
RecentDeviceActionPermissionError,
|
||||
RecentDeviceActionRequestError,
|
||||
assertFreshDeviceActionProof,
|
||||
assertRecentDeviceActionProof,
|
||||
recentDeviceActionProof,
|
||||
recentDeviceActionRequestHash,
|
||||
} from "./recent_device_action_proof.js";
|
||||
import {
|
||||
SYNC_R2_ANONYMIZE_USER_QUERY,
|
||||
SYNC_R2_FENCE_USER_QUERY,
|
||||
collectSyncR2Garbage,
|
||||
} from "./sync_r2_gc.js";
|
||||
|
||||
const ACCOUNT_DELETION_CONFIRMATION = "delete-elydora-account";
|
||||
const IDEMPOTENCY_KEY_PATTERN = /^[a-zA-Z0-9._:-]{16,128}$/;
|
||||
const ACCOUNT_DELETION_EVENT_QUERY = `
|
||||
SELECT actor_device_id, outcome, subject_id, created_at
|
||||
SELECT actor_device_id, outcome, subject_id, metadata_hash, created_at
|
||||
FROM audit_events
|
||||
WHERE event_id = ? AND event_type = 'account.delete'
|
||||
`;
|
||||
@@ -25,19 +44,24 @@ const ACCOUNT_DELETION_COUNTS_QUERY = `
|
||||
(SELECT COUNT(*) FROM better_auth_user WHERE id = ?) AS users
|
||||
`;
|
||||
const ACCOUNT_DELETION_R2_KEYS_QUERY = `
|
||||
SELECT payload_r2_key AS r2_key
|
||||
FROM sync_objects
|
||||
WHERE user_id = ? AND payload_r2_key IS NOT NULL
|
||||
UNION
|
||||
SELECT r2_key
|
||||
FROM sync_snapshots
|
||||
WHERE user_id = ?
|
||||
SELECT r2_key FROM sync_r2_gc_candidates
|
||||
WHERE user_id = ? AND state <> 'deleted'
|
||||
ORDER BY r2_key ASC
|
||||
`;
|
||||
const ACCOUNT_DELETION_SESSION_TOKENS_QUERY = `
|
||||
SELECT token FROM better_auth_session
|
||||
WHERE userId = ?
|
||||
ORDER BY id ASC
|
||||
`;
|
||||
const DELETE_SYNC_CHANGE_LOG_QUERY = "DELETE FROM sync_change_log WHERE user_id = ?";
|
||||
const DELETE_SYNC_TOMBSTONES_QUERY = "DELETE FROM sync_tombstones WHERE user_id = ?";
|
||||
const DELETE_SYNC_SNAPSHOT_HEADS_QUERY = "DELETE FROM sync_snapshot_heads WHERE user_id = ?";
|
||||
const DELETE_SYNC_SNAPSHOT_ENCRYPTION_QUERY =
|
||||
"DELETE FROM sync_snapshot_encryption WHERE user_id = ?";
|
||||
const DELETE_SYNC_SNAPSHOTS_QUERY = "DELETE FROM sync_snapshots WHERE user_id = ?";
|
||||
const DELETE_SYNC_OBJECTS_QUERY = "DELETE FROM sync_objects WHERE user_id = ?";
|
||||
const DELETE_SYNC_VAULT_ENVELOPES_QUERY = "DELETE FROM sync_vault_envelopes WHERE user_id = ?";
|
||||
const DELETE_SYNC_VAULT_ACCOUNTS_QUERY = "DELETE FROM sync_vault_accounts WHERE user_id = ?";
|
||||
const DELETE_DEVICE_APPROVALS_QUERY = "DELETE FROM device_approvals WHERE user_id = ?";
|
||||
const DELETE_USER_DEVICES_QUERY = "DELETE FROM user_devices WHERE user_id = ?";
|
||||
const DELETE_SESSION_DEVICE_CONTEXTS_QUERY =
|
||||
@@ -46,20 +70,6 @@ const DELETE_BETTER_AUTH_SESSIONS_QUERY = "DELETE FROM better_auth_session WHERE
|
||||
const DELETE_BETTER_AUTH_ACCOUNTS_QUERY = "DELETE FROM better_auth_account WHERE userId = ?";
|
||||
const DELETE_BETTER_AUTH_USER_QUERY = "DELETE FROM better_auth_user WHERE id = ?";
|
||||
const DELETE_USER_AUDIT_EVENTS_QUERY = "DELETE FROM audit_events WHERE user_id = ?";
|
||||
const ACCOUNT_DELETION_AUDIT_INSERT_QUERY = `
|
||||
INSERT INTO audit_events (
|
||||
event_id,
|
||||
user_id,
|
||||
actor_device_id,
|
||||
event_type,
|
||||
subject_type,
|
||||
subject_id,
|
||||
outcome,
|
||||
metadata_hash,
|
||||
created_at
|
||||
) VALUES (?, NULL, ?, 'account.delete', 'account', ?, 'success', ?, ?)
|
||||
ON CONFLICT(event_id) DO NOTHING
|
||||
`;
|
||||
|
||||
export interface AccountDeletionDocument {
|
||||
version: 1;
|
||||
@@ -86,7 +96,7 @@ export interface AccountDeletionDeletedDocument {
|
||||
kv_session_cache: number;
|
||||
}
|
||||
|
||||
interface AccountDeletionRequest {
|
||||
interface AccountDeletionRequest extends RecentDeviceActionProof {
|
||||
idempotencyKey: string;
|
||||
}
|
||||
|
||||
@@ -94,6 +104,7 @@ interface AccountDeletionEventRow {
|
||||
actor_device_id: unknown;
|
||||
outcome: unknown;
|
||||
subject_id: unknown;
|
||||
metadata_hash: unknown;
|
||||
created_at: unknown;
|
||||
}
|
||||
|
||||
@@ -114,6 +125,7 @@ interface AccountDeletionCountsRow {
|
||||
interface AccountDeletionR2KeyRow {
|
||||
r2_key: unknown;
|
||||
}
|
||||
interface AccountDeletionSessionTokenRow { token: unknown }
|
||||
|
||||
type RequestBody = Record<string, unknown>;
|
||||
|
||||
@@ -141,32 +153,77 @@ export async function accountDeletionDocument(
|
||||
const deletion = await accountDeletionRequest(request);
|
||||
const accountHash = await sha256Hex(textBytes(context.userId));
|
||||
const idempotencyHash = await sha256Hex(textBytes(deletion.idempotencyKey));
|
||||
const requestHash = await recentDeviceActionRequestHash({
|
||||
action: "account.delete",
|
||||
userId: context.userId,
|
||||
sessionId: context.sessionId,
|
||||
deviceId,
|
||||
confirmation: ACCOUNT_DELETION_CONFIRMATION,
|
||||
idempotencyKey: deletion.idempotencyKey,
|
||||
proofCreatedAt: deletion.proofCreatedAt,
|
||||
actionProof: deletion.actionProof,
|
||||
});
|
||||
const eventId = accountDeletionEventId(accountHash, idempotencyHash);
|
||||
const existingEvent = await env.ELY_DB.prepare(ACCOUNT_DELETION_EVENT_QUERY)
|
||||
const database = primaryD1Session(env.ELY_DB);
|
||||
const existingEvent = await database.prepare(ACCOUNT_DELETION_EVENT_QUERY)
|
||||
.bind(eventId)
|
||||
.first<AccountDeletionEventRow>();
|
||||
if (existingEvent !== null) {
|
||||
return existingDeletionDocument(accountHash, deviceId, deletion, existingEvent);
|
||||
return existingDeletionDocument(accountHash, deviceId, requestHash, deletion, existingEvent);
|
||||
}
|
||||
const signingPublicKey = await assertRecentDeviceActionProof(
|
||||
database,
|
||||
context,
|
||||
"account.delete",
|
||||
ACCOUNT_DELETION_CONFIRMATION,
|
||||
deletion.idempotencyKey,
|
||||
deletion,
|
||||
);
|
||||
assertFreshDeviceActionProof(deletion, nowSeconds, true);
|
||||
|
||||
const counts = await accountDeletionCounts(env, context.userId);
|
||||
const r2Keys = await accountDeletionR2Keys(env, context.userId);
|
||||
for (const key of r2Keys) {
|
||||
await deleteAccountObject(env, key);
|
||||
}
|
||||
|
||||
await env.ELY_DB.batch(
|
||||
const counts = await accountDeletionCounts(database, context.userId);
|
||||
const r2Keys = await accountDeletionR2Keys(database, context.userId);
|
||||
const sessionTokens = await accountDeletionSessionTokens(database, context.userId);
|
||||
let results: ElyD1Result[];
|
||||
try {
|
||||
results = await database.batch<ElyD1Result>(
|
||||
accountDeletionStatements(
|
||||
env,
|
||||
context.userId,
|
||||
deviceId,
|
||||
database,
|
||||
context,
|
||||
signingPublicKey,
|
||||
accountHash,
|
||||
idempotencyHash,
|
||||
requestHash,
|
||||
eventId,
|
||||
nowSeconds,
|
||||
),
|
||||
);
|
||||
await deleteCurrentSessionCache(env, context.tokenHash);
|
||||
} catch (error) {
|
||||
const replayDatabase = primaryD1Session(env.ELY_DB);
|
||||
const racedEvent = await replayDatabase.prepare(ACCOUNT_DELETION_EVENT_QUERY)
|
||||
.bind(eventId)
|
||||
.first<AccountDeletionEventRow>();
|
||||
if (racedEvent !== null) {
|
||||
return existingDeletionDocument(accountHash, deviceId, requestHash, deletion, racedEvent);
|
||||
}
|
||||
if (!(await destructiveActionGateIsLive(
|
||||
replayDatabase,
|
||||
context,
|
||||
signingPublicKey,
|
||||
nowSeconds,
|
||||
))) {
|
||||
throw new RecentDeviceActionPermissionError("device_action_gate_failed");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
assertDestructiveActionGateResult(results[0]);
|
||||
const kvSessionCache = await cleanupDeletedAccount(
|
||||
env,
|
||||
accountHash,
|
||||
sessionTokens,
|
||||
context.tokenHash,
|
||||
nowSeconds,
|
||||
r2Keys.length,
|
||||
);
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
@@ -174,20 +231,22 @@ export async function accountDeletionDocument(
|
||||
device_id: deviceId,
|
||||
idempotency_key: deletion.idempotencyKey,
|
||||
deleted_at: nowSeconds,
|
||||
deleted: { ...counts, r2_objects: r2Keys.length, kv_session_cache: 1 },
|
||||
deleted: { ...counts, r2_objects: r2Keys.length, kv_session_cache: kvSessionCache },
|
||||
};
|
||||
}
|
||||
|
||||
function existingDeletionDocument(
|
||||
accountHash: string,
|
||||
deviceId: string,
|
||||
requestHash: string,
|
||||
deletion: AccountDeletionRequest,
|
||||
row: AccountDeletionEventRow,
|
||||
): AccountDeletionDocument {
|
||||
if (
|
||||
row.actor_device_id !== deviceId ||
|
||||
row.outcome !== "success" ||
|
||||
row.subject_id !== accountHash
|
||||
row.subject_id !== accountHash ||
|
||||
row.metadata_hash !== requestHash
|
||||
) {
|
||||
throw new AccountDeletionRequestError("account_deletion_replay_mismatch");
|
||||
}
|
||||
@@ -202,10 +261,10 @@ function existingDeletionDocument(
|
||||
}
|
||||
|
||||
async function accountDeletionCounts(
|
||||
env: Env,
|
||||
database: ElyD1DatabaseSession,
|
||||
userId: string,
|
||||
): Promise<Omit<AccountDeletionDeletedDocument, "r2_objects" | "kv_session_cache">> {
|
||||
const row = await env.ELY_DB.prepare(ACCOUNT_DELETION_COUNTS_QUERY)
|
||||
const row = await database.prepare(ACCOUNT_DELETION_COUNTS_QUERY)
|
||||
.bind(userId, userId, userId, userId, userId, userId, userId, userId, userId, userId, userId)
|
||||
.first<AccountDeletionCountsRow>();
|
||||
if (row === null) {
|
||||
@@ -226,69 +285,126 @@ async function accountDeletionCounts(
|
||||
};
|
||||
}
|
||||
|
||||
async function accountDeletionR2Keys(env: Env, userId: string): Promise<string[]> {
|
||||
const result = await env.ELY_DB.prepare(ACCOUNT_DELETION_R2_KEYS_QUERY)
|
||||
.bind(userId, userId)
|
||||
async function accountDeletionR2Keys(
|
||||
database: ElyD1DatabaseSession,
|
||||
userId: string,
|
||||
): Promise<string[]> {
|
||||
const result = await database.prepare(ACCOUNT_DELETION_R2_KEYS_QUERY)
|
||||
.bind(userId)
|
||||
.all<AccountDeletionR2KeyRow>();
|
||||
return result.results.map(r2Key);
|
||||
}
|
||||
|
||||
function accountDeletionStatements(
|
||||
env: Env,
|
||||
async function accountDeletionSessionTokens(
|
||||
database: ElyD1DatabaseSession,
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
): Promise<string[]> {
|
||||
const result = await database.prepare(ACCOUNT_DELETION_SESSION_TOKENS_QUERY)
|
||||
.bind(userId)
|
||||
.all<AccountDeletionSessionTokenRow>();
|
||||
return result.results.map((row) => {
|
||||
if (typeof row.token !== "string" || row.token.length === 0) {
|
||||
throw new AccountDeletionPersistenceError("session_token_invalid");
|
||||
}
|
||||
return row.token;
|
||||
});
|
||||
}
|
||||
|
||||
function accountDeletionStatements(
|
||||
database: ElyD1DatabaseSession,
|
||||
context: AuthContext,
|
||||
signingPublicKey: string,
|
||||
accountHash: string,
|
||||
idempotencyHash: string,
|
||||
requestHash: string,
|
||||
eventId: string,
|
||||
nowSeconds: number,
|
||||
): ElyD1PreparedStatement[] {
|
||||
const userId = context.userId;
|
||||
return [
|
||||
env.ELY_DB.prepare(DELETE_SYNC_CHANGE_LOG_QUERY).bind(userId),
|
||||
env.ELY_DB.prepare(DELETE_SYNC_TOMBSTONES_QUERY).bind(userId),
|
||||
env.ELY_DB.prepare(DELETE_SYNC_SNAPSHOTS_QUERY).bind(userId),
|
||||
env.ELY_DB.prepare(DELETE_SYNC_OBJECTS_QUERY).bind(userId),
|
||||
env.ELY_DB.prepare(DELETE_DEVICE_APPROVALS_QUERY).bind(userId),
|
||||
env.ELY_DB.prepare(DELETE_SESSION_DEVICE_CONTEXTS_QUERY).bind(userId),
|
||||
env.ELY_DB.prepare(DELETE_USER_DEVICES_QUERY).bind(userId),
|
||||
env.ELY_DB.prepare(DELETE_BETTER_AUTH_SESSIONS_QUERY).bind(userId),
|
||||
env.ELY_DB.prepare(DELETE_BETTER_AUTH_ACCOUNTS_QUERY).bind(userId),
|
||||
env.ELY_DB.prepare(DELETE_BETTER_AUTH_USER_QUERY).bind(userId),
|
||||
env.ELY_DB.prepare(DELETE_USER_AUDIT_EVENTS_QUERY).bind(userId),
|
||||
env.ELY_DB.prepare(ACCOUNT_DELETION_AUDIT_INSERT_QUERY).bind(
|
||||
destructiveActionGateStatement(database, context, signingPublicKey, {
|
||||
eventId,
|
||||
deviceId,
|
||||
accountHash,
|
||||
idempotencyHash,
|
||||
auditUserId: null,
|
||||
eventType: "account.delete",
|
||||
subjectType: "account",
|
||||
subjectId: accountHash,
|
||||
metadataHash: requestHash,
|
||||
}, nowSeconds),
|
||||
database.prepare(SYNC_R2_FENCE_USER_QUERY).bind(
|
||||
nowSeconds,
|
||||
nowSeconds,
|
||||
nowSeconds,
|
||||
userId,
|
||||
),
|
||||
database.prepare(DELETE_SYNC_CHANGE_LOG_QUERY).bind(userId),
|
||||
database.prepare(DELETE_SYNC_TOMBSTONES_QUERY).bind(userId),
|
||||
database.prepare(DELETE_SYNC_SNAPSHOT_HEADS_QUERY).bind(userId),
|
||||
database.prepare(DELETE_SYNC_SNAPSHOT_ENCRYPTION_QUERY).bind(userId),
|
||||
database.prepare(DELETE_SYNC_SNAPSHOTS_QUERY).bind(userId),
|
||||
database.prepare(DELETE_SYNC_OBJECTS_QUERY).bind(userId),
|
||||
database.prepare(DELETE_SYNC_VAULT_ENVELOPES_QUERY).bind(userId),
|
||||
database.prepare(DELETE_SYNC_VAULT_ACCOUNTS_QUERY).bind(userId),
|
||||
database.prepare(DELETE_DEVICE_APPROVALS_QUERY).bind(userId),
|
||||
database.prepare(DELETE_SESSION_DEVICE_CONTEXTS_QUERY).bind(userId),
|
||||
database.prepare(DELETE_USER_DEVICES_QUERY).bind(userId),
|
||||
database.prepare(DELETE_BETTER_AUTH_SESSIONS_QUERY).bind(userId),
|
||||
database.prepare(DELETE_BETTER_AUTH_ACCOUNTS_QUERY).bind(userId),
|
||||
database.prepare(DELETE_BETTER_AUTH_USER_QUERY).bind(userId),
|
||||
database.prepare(DELETE_USER_AUDIT_EVENTS_QUERY).bind(userId),
|
||||
database.prepare(SYNC_R2_ANONYMIZE_USER_QUERY).bind(nowSeconds, userId, accountHash),
|
||||
];
|
||||
}
|
||||
|
||||
async function deleteAccountObject(env: Env, key: string): Promise<void> {
|
||||
async function cleanupDeletedAccount(
|
||||
env: Env,
|
||||
accountHash: string,
|
||||
sessionTokens: string[],
|
||||
currentTokenHash: string,
|
||||
nowSeconds: number,
|
||||
candidateCount: number,
|
||||
): Promise<number> {
|
||||
try {
|
||||
await deleteKnownObject(env.ELY_STORAGE, key);
|
||||
} catch (error) {
|
||||
if (error instanceof StorageObjectError) {
|
||||
throw new AccountDeletionPersistenceError(error.message);
|
||||
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> {
|
||||
|
||||
@@ -14,9 +14,13 @@ export type ApiHandler = () => Promise<Response>;
|
||||
export type AuthenticatedApiHandler = (context: AuthContext) => Promise<Response>;
|
||||
|
||||
const APPROVED_DEVICE_QUERY = `
|
||||
SELECT device_id
|
||||
FROM user_devices
|
||||
WHERE user_id = ? AND device_id = ? AND approval_status = 'approved' AND revoked_at IS NULL
|
||||
SELECT device.device_id
|
||||
FROM user_devices AS device
|
||||
INNER JOIN user_device_keys AS keys
|
||||
ON keys.user_id = device.user_id AND keys.device_id = device.device_id
|
||||
WHERE device.user_id = ? AND device.device_id = ?
|
||||
AND device.approval_status = 'approved' AND device.revoked_at IS NULL
|
||||
AND keys.key_protocol_version = 2 AND keys.wrapping_public_key IS NOT NULL
|
||||
`;
|
||||
|
||||
export async function withPublicApiControls(
|
||||
|
||||
@@ -10,6 +10,7 @@ const BETTER_AUTH_SESSION_QUERY = `
|
||||
session.id,
|
||||
session.userId,
|
||||
session.expiresAt,
|
||||
session.createdAt,
|
||||
device_context.device_id AS deviceId
|
||||
FROM better_auth_session AS session
|
||||
LEFT JOIN better_auth_session_device_context AS device_context
|
||||
@@ -22,6 +23,7 @@ export interface AuthContext {
|
||||
sessionId: string;
|
||||
tokenHash: string;
|
||||
expiresAt: string;
|
||||
createdAt: string;
|
||||
deviceId?: string;
|
||||
}
|
||||
|
||||
@@ -29,6 +31,7 @@ interface BetterAuthSessionRow extends Record<string, unknown> {
|
||||
id: unknown;
|
||||
userId: unknown;
|
||||
expiresAt: unknown;
|
||||
createdAt: unknown;
|
||||
deviceId?: unknown;
|
||||
}
|
||||
|
||||
@@ -111,6 +114,7 @@ async function readBetterAuthSessionContext(
|
||||
sessionId: subjectId(stringField(row, "id"), "session_id"),
|
||||
tokenHash,
|
||||
expiresAt: timestampField(row, "expiresAt"),
|
||||
createdAt: timestampField(row, "createdAt"),
|
||||
};
|
||||
const deviceId = optionalStringField(row, "deviceId");
|
||||
if (deviceId !== undefined) {
|
||||
|
||||
@@ -35,10 +35,18 @@ export interface ElyD1Result<T = unknown> {
|
||||
};
|
||||
}
|
||||
|
||||
export interface ElyD1Database {
|
||||
export interface ElyD1DatabaseSession {
|
||||
prepare(query: string): ElyD1PreparedStatement;
|
||||
batch<T = unknown>(statements: ElyD1PreparedStatement[]): Promise<T[]>;
|
||||
}
|
||||
|
||||
export interface ElyD1Database extends ElyD1DatabaseSession {
|
||||
exec(query: string): Promise<unknown>;
|
||||
withSession?(constraint: "first-primary"): ElyD1DatabaseSession;
|
||||
}
|
||||
|
||||
export function primaryD1Session(database: ElyD1Database): ElyD1DatabaseSession {
|
||||
return database.withSession?.("first-primary") ?? database;
|
||||
}
|
||||
|
||||
export interface ElyRateLimit {
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { AuthContext } from "./auth.js";
|
||||
import type { ElyD1DatabaseSession, ElyD1PreparedStatement, ElyD1Result } from "./bindings.js";
|
||||
|
||||
const DESTRUCTIVE_ACTION_GATE_INSERT_QUERY = `
|
||||
INSERT INTO audit_events (
|
||||
event_id,
|
||||
user_id,
|
||||
actor_device_id,
|
||||
event_type,
|
||||
subject_type,
|
||||
subject_id,
|
||||
outcome,
|
||||
metadata_hash,
|
||||
created_at
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?,
|
||||
CASE WHEN EXISTS (
|
||||
SELECT 1
|
||||
FROM better_auth_session AS session
|
||||
INNER JOIN better_auth_session_device_context AS session_device
|
||||
ON session_device.session_id = session.id
|
||||
AND session_device.user_id = session.userId
|
||||
INNER JOIN user_devices AS device
|
||||
ON device.user_id = session_device.user_id
|
||||
AND device.device_id = session_device.device_id
|
||||
INNER JOIN user_device_keys AS keys
|
||||
ON keys.user_id = device.user_id
|
||||
AND keys.device_id = device.device_id
|
||||
WHERE session.id = ? AND session.userId = ?
|
||||
AND session_device.device_id = ?
|
||||
AND (
|
||||
(typeof(session.expiresAt) = 'text' AND julianday(session.expiresAt) > julianday(?))
|
||||
OR
|
||||
(typeof(session.expiresAt) IN ('integer', 'real') AND session.expiresAt > ?)
|
||||
)
|
||||
AND device.approval_status = 'approved' AND device.revoked_at IS NULL
|
||||
AND keys.key_protocol_version = 2 AND keys.wrapping_public_key IS NOT NULL
|
||||
AND keys.signing_public_key = ?
|
||||
) THEN 'success' ELSE NULL END,
|
||||
?, ?
|
||||
)
|
||||
`;
|
||||
|
||||
const DESTRUCTIVE_ACTION_LIVE_QUERY = `
|
||||
SELECT 1 AS authorized
|
||||
FROM better_auth_session AS session
|
||||
INNER JOIN better_auth_session_device_context AS session_device
|
||||
ON session_device.session_id = session.id
|
||||
AND session_device.user_id = session.userId
|
||||
INNER JOIN user_devices AS device
|
||||
ON device.user_id = session_device.user_id
|
||||
AND device.device_id = session_device.device_id
|
||||
INNER JOIN user_device_keys AS keys
|
||||
ON keys.user_id = device.user_id
|
||||
AND keys.device_id = device.device_id
|
||||
WHERE session.id = ? AND session.userId = ?
|
||||
AND session_device.device_id = ?
|
||||
AND (
|
||||
(typeof(session.expiresAt) = 'text' AND julianday(session.expiresAt) > julianday(?))
|
||||
OR
|
||||
(typeof(session.expiresAt) IN ('integer', 'real') AND session.expiresAt > ?)
|
||||
)
|
||||
AND device.approval_status = 'approved' AND device.revoked_at IS NULL
|
||||
AND keys.key_protocol_version = 2 AND keys.wrapping_public_key IS NOT NULL
|
||||
AND keys.signing_public_key = ?
|
||||
`;
|
||||
|
||||
export interface DestructiveActionGate {
|
||||
eventId: string;
|
||||
auditUserId: string | null;
|
||||
eventType: "account.delete" | "sync.reset";
|
||||
subjectType: "account" | "sync";
|
||||
subjectId: string;
|
||||
metadataHash: string | null;
|
||||
}
|
||||
|
||||
export function destructiveActionGateStatement(
|
||||
database: ElyD1DatabaseSession,
|
||||
context: AuthContext,
|
||||
signingPublicKey: string,
|
||||
gate: DestructiveActionGate,
|
||||
nowSeconds: number,
|
||||
): ElyD1PreparedStatement {
|
||||
if (context.deviceId === undefined) {
|
||||
throw new DestructiveActionGateError("device_context_required");
|
||||
}
|
||||
const now = new Date(nowSeconds * 1000);
|
||||
if (!Number.isSafeInteger(nowSeconds) || nowSeconds < 0 || !Number.isFinite(now.getTime())) {
|
||||
throw new DestructiveActionGateError("destructive_action_time_invalid");
|
||||
}
|
||||
return database.prepare(DESTRUCTIVE_ACTION_GATE_INSERT_QUERY).bind(
|
||||
gate.eventId,
|
||||
gate.auditUserId,
|
||||
context.deviceId,
|
||||
gate.eventType,
|
||||
gate.subjectType,
|
||||
gate.subjectId,
|
||||
context.sessionId,
|
||||
context.userId,
|
||||
context.deviceId,
|
||||
now.toISOString(),
|
||||
now.getTime(),
|
||||
signingPublicKey,
|
||||
gate.metadataHash,
|
||||
nowSeconds,
|
||||
);
|
||||
}
|
||||
|
||||
export function assertDestructiveActionGateResult(result: unknown): void {
|
||||
if (changedRows(result) !== 1) {
|
||||
throw new DestructiveActionGateError("destructive_action_gate_failed");
|
||||
}
|
||||
}
|
||||
|
||||
export async function destructiveActionGateIsLive(
|
||||
database: ElyD1DatabaseSession,
|
||||
context: AuthContext,
|
||||
signingPublicKey: string,
|
||||
nowSeconds: number,
|
||||
): Promise<boolean> {
|
||||
if (context.deviceId === undefined) return false;
|
||||
const now = new Date(nowSeconds * 1000);
|
||||
if (!Number.isSafeInteger(nowSeconds) || nowSeconds < 0 || !Number.isFinite(now.getTime())) {
|
||||
throw new DestructiveActionGateError("destructive_action_time_invalid");
|
||||
}
|
||||
const row = await database.prepare(DESTRUCTIVE_ACTION_LIVE_QUERY).bind(
|
||||
context.sessionId,
|
||||
context.userId,
|
||||
context.deviceId,
|
||||
now.toISOString(),
|
||||
now.getTime(),
|
||||
signingPublicKey,
|
||||
).first<{ authorized: unknown }>();
|
||||
return row?.authorized === 1;
|
||||
}
|
||||
|
||||
export class DestructiveActionGateError extends Error {}
|
||||
|
||||
function changedRows(result: unknown): number {
|
||||
if (typeof result !== "object" || result === null || !("meta" in result)) return -1;
|
||||
const changes = (result as ElyD1Result).meta?.changes;
|
||||
return typeof changes === "number" && Number.isSafeInteger(changes) ? changes : -1;
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
import type { AuthContext } from "./auth.js";
|
||||
import type { Env } from "./bindings.js";
|
||||
import {
|
||||
assertDeviceApprovalProof,
|
||||
assertFreshDeviceApprovalProof,
|
||||
} from "./device_approval_proof.js";
|
||||
import {
|
||||
type DeviceApprovalDocument,
|
||||
type DeviceApprovalRequest,
|
||||
type DeviceApprovalRow,
|
||||
type DeviceRow,
|
||||
DeviceConflictError,
|
||||
DevicePermissionError,
|
||||
DevicePersistenceError,
|
||||
DeviceSchemaError,
|
||||
approvedDeviceDocument,
|
||||
currentDeviceId,
|
||||
deviceApprovalRequest,
|
||||
deviceDocument,
|
||||
deviceIdValue,
|
||||
idempotencyKeyValue,
|
||||
keyIdValue,
|
||||
positiveInteger,
|
||||
publicKeyValue,
|
||||
timestamp,
|
||||
wrappedAccountKey,
|
||||
} from "./device_schema.js";
|
||||
import { syncVaultRecipientEnvelopeStatement } from "./sync_vault.js";
|
||||
|
||||
const STORED_APPROVAL_STATUSES = new Set(["pending", "approved", "rejected", "expired"]);
|
||||
const APPROVED_REQUESTER_QUERY = `
|
||||
SELECT device.device_id, keys.signing_public_key
|
||||
FROM user_devices AS device
|
||||
INNER JOIN user_device_keys AS keys
|
||||
ON keys.user_id = device.user_id AND keys.device_id = device.device_id
|
||||
WHERE device.user_id = ? AND device.device_id = ?
|
||||
AND device.approval_status = 'approved' AND device.revoked_at IS NULL
|
||||
AND keys.key_protocol_version = 2 AND keys.wrapping_public_key IS NOT NULL
|
||||
`;
|
||||
const DEVICE_BY_ID_QUERY = `
|
||||
SELECT
|
||||
device.device_id,
|
||||
device.public_key,
|
||||
device.device_name,
|
||||
device.platform,
|
||||
device.approval_status,
|
||||
device.created_at,
|
||||
device.approved_at,
|
||||
device.last_active_at,
|
||||
device.revoked_at,
|
||||
keys.wrapping_public_key
|
||||
FROM user_devices AS device
|
||||
LEFT JOIN user_device_keys AS keys
|
||||
ON keys.user_id = device.user_id AND keys.device_id = device.device_id
|
||||
WHERE device.user_id = ? AND device.device_id = ?
|
||||
`;
|
||||
const DEVICE_APPROVAL_BY_IDEMPOTENCY_KEY_QUERY = `
|
||||
SELECT device_id, requester_device_id, status, decided_at
|
||||
FROM device_approvals
|
||||
WHERE user_id = ? AND idempotency_key = ?
|
||||
`;
|
||||
const DEVICE_APPROVAL_INSERT_QUERY = `
|
||||
INSERT INTO device_approvals (
|
||||
user_id, approval_id, device_id, requester_device_id, status,
|
||||
requested_at, decided_at, expires_at, idempotency_key
|
||||
)
|
||||
SELECT ?, ?, ?, ?, 'approved', ?, ?, ?, ?
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM sync_vault_accounts AS accounts
|
||||
INNER JOIN sync_vault_envelopes AS envelope
|
||||
ON envelope.user_id = accounts.user_id
|
||||
AND envelope.key_id = accounts.current_key_id
|
||||
AND envelope.generation = accounts.current_generation
|
||||
WHERE accounts.user_id = ? AND envelope.recipient_device_id = ?
|
||||
AND envelope.approver_device_id = ? AND envelope.key_id = ?
|
||||
AND envelope.generation = ? AND envelope.envelope_version = ?
|
||||
AND envelope.suite = ? AND envelope.encapped_key = ?
|
||||
AND envelope.ciphertext = ? AND envelope.idempotency_key = ?
|
||||
)
|
||||
ON CONFLICT(user_id, idempotency_key) DO NOTHING
|
||||
`;
|
||||
const DEVICE_APPROVE_QUERY = `
|
||||
UPDATE user_devices
|
||||
SET approval_status = 'approved', approved_at = COALESCE(approved_at, ?), last_active_at = ?
|
||||
WHERE user_id = ? AND device_id = ? AND approval_status = 'pending' AND revoked_at IS NULL
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM sync_vault_accounts AS accounts
|
||||
INNER JOIN sync_vault_envelopes AS envelope
|
||||
ON envelope.user_id = accounts.user_id
|
||||
AND envelope.key_id = accounts.current_key_id
|
||||
AND envelope.generation = accounts.current_generation
|
||||
WHERE accounts.user_id = ? AND envelope.recipient_device_id = ?
|
||||
AND envelope.approver_device_id = ? AND envelope.key_id = ?
|
||||
AND envelope.generation = ? AND envelope.envelope_version = ?
|
||||
AND envelope.suite = ? AND envelope.encapped_key = ?
|
||||
AND envelope.ciphertext = ? AND envelope.idempotency_key = ?
|
||||
)
|
||||
`;
|
||||
const CURRENT_RECIPIENT_ENVELOPE_QUERY = `
|
||||
SELECT
|
||||
accounts.current_key_id AS key_id,
|
||||
accounts.current_generation AS generation,
|
||||
envelope.recipient_device_id,
|
||||
envelope.approver_device_id,
|
||||
envelope.envelope_version,
|
||||
envelope.suite,
|
||||
envelope.encapped_key,
|
||||
envelope.ciphertext,
|
||||
envelope.idempotency_key
|
||||
FROM sync_vault_accounts AS accounts
|
||||
INNER JOIN sync_vault_envelopes AS envelope
|
||||
ON envelope.user_id = accounts.user_id
|
||||
AND envelope.key_id = accounts.current_key_id
|
||||
AND envelope.generation = accounts.current_generation
|
||||
WHERE accounts.user_id = ? AND envelope.recipient_device_id = ?
|
||||
`;
|
||||
|
||||
interface CurrentRecipientEnvelopeRow {
|
||||
key_id: unknown;
|
||||
generation: unknown;
|
||||
recipient_device_id: unknown;
|
||||
approver_device_id: unknown;
|
||||
envelope_version: unknown;
|
||||
suite: unknown;
|
||||
encapped_key: unknown;
|
||||
ciphertext: unknown;
|
||||
idempotency_key: unknown;
|
||||
}
|
||||
|
||||
interface ApprovedRequesterRow {
|
||||
device_id: unknown;
|
||||
signing_public_key: unknown;
|
||||
}
|
||||
|
||||
export async function approveDeviceDocument(
|
||||
request: Request,
|
||||
env: Env,
|
||||
context: AuthContext,
|
||||
nowSeconds = Math.floor(Date.now() / 1000),
|
||||
): Promise<DeviceApprovalDocument> {
|
||||
const approval = await deviceApprovalRequest(request);
|
||||
const requesterDeviceId = currentDeviceId(context);
|
||||
if (requesterDeviceId === approval.deviceId) {
|
||||
throw new DevicePermissionError("device_self_approval_forbidden");
|
||||
}
|
||||
const signingPublicKey = await approvedRequesterSigningKey(
|
||||
env,
|
||||
context.userId,
|
||||
requesterDeviceId,
|
||||
);
|
||||
await assertDeviceApprovalProof(
|
||||
signingPublicKey,
|
||||
context.userId,
|
||||
requesterDeviceId,
|
||||
approval,
|
||||
);
|
||||
const existingApproval = await env.ELY_DB.prepare(DEVICE_APPROVAL_BY_IDEMPOTENCY_KEY_QUERY)
|
||||
.bind(context.userId, approval.idempotencyKey)
|
||||
.first<DeviceApprovalRow>();
|
||||
assertFreshDeviceApprovalProof(approval, nowSeconds, existingApproval === null);
|
||||
if (existingApproval !== null) {
|
||||
return existingApprovalDocument(env, context, approval, requesterDeviceId, existingApproval);
|
||||
}
|
||||
|
||||
const pendingDevice = await deviceRowById(env, context.userId, approval.deviceId);
|
||||
if (pendingDevice === null) {
|
||||
throw new DevicePermissionError("device_not_found");
|
||||
}
|
||||
const pendingDocument = storedDeviceDocument(pendingDevice, requesterDeviceId);
|
||||
if (
|
||||
pendingDocument.approval_status !== "pending" ||
|
||||
pendingDocument.revoked_at !== null ||
|
||||
pendingDocument.wrapping_public_key === undefined
|
||||
) {
|
||||
throw new DevicePermissionError("device_not_pending");
|
||||
}
|
||||
|
||||
const envelopeBindings = approvalEnvelopeBindings(
|
||||
context.userId,
|
||||
approval,
|
||||
requesterDeviceId,
|
||||
);
|
||||
await env.ELY_DB.batch([
|
||||
syncVaultRecipientEnvelopeStatement(
|
||||
env,
|
||||
context.userId,
|
||||
approval.deviceId,
|
||||
requesterDeviceId,
|
||||
approval.keyId,
|
||||
approval.generation,
|
||||
approval.envelope,
|
||||
approval.idempotencyKey,
|
||||
nowSeconds,
|
||||
),
|
||||
env.ELY_DB.prepare(DEVICE_APPROVAL_INSERT_QUERY).bind(
|
||||
context.userId,
|
||||
approval.idempotencyKey,
|
||||
approval.deviceId,
|
||||
requesterDeviceId,
|
||||
nowSeconds,
|
||||
nowSeconds,
|
||||
nowSeconds,
|
||||
approval.idempotencyKey,
|
||||
...envelopeBindings,
|
||||
),
|
||||
env.ELY_DB.prepare(DEVICE_APPROVE_QUERY).bind(
|
||||
nowSeconds,
|
||||
nowSeconds,
|
||||
context.userId,
|
||||
approval.deviceId,
|
||||
...envelopeBindings,
|
||||
),
|
||||
]);
|
||||
|
||||
return approvedDeviceWithEnvelope(env, context, approval, requesterDeviceId, false);
|
||||
}
|
||||
|
||||
async function existingApprovalDocument(
|
||||
env: Env,
|
||||
context: AuthContext,
|
||||
approval: DeviceApprovalRequest,
|
||||
requesterDeviceId: string,
|
||||
row: DeviceApprovalRow,
|
||||
): Promise<DeviceApprovalDocument> {
|
||||
const { approvedDeviceId, approvedByDeviceId, status, decidedAt } = storedApproval(row);
|
||||
if (
|
||||
approvedDeviceId !== approval.deviceId ||
|
||||
approvedByDeviceId !== requesterDeviceId ||
|
||||
status !== "approved"
|
||||
) {
|
||||
throw new DevicePermissionError("device_approval_replay_mismatch");
|
||||
}
|
||||
if (decidedAt === null) {
|
||||
throw new DevicePersistenceError("device_approval_state_invalid");
|
||||
}
|
||||
const document = await approvedDeviceWithEnvelope(
|
||||
env,
|
||||
context,
|
||||
approval,
|
||||
requesterDeviceId,
|
||||
true,
|
||||
);
|
||||
return { ...document, approved_at: decidedAt };
|
||||
}
|
||||
|
||||
async function approvedDeviceWithEnvelope(
|
||||
env: Env,
|
||||
context: AuthContext,
|
||||
approval: DeviceApprovalRequest,
|
||||
requesterDeviceId: string,
|
||||
replay: boolean,
|
||||
): Promise<DeviceApprovalDocument> {
|
||||
const approvedDevice = await deviceRowById(env, context.userId, approval.deviceId);
|
||||
if (approvedDevice === null) {
|
||||
throw new DevicePersistenceError("device_approval_missing");
|
||||
}
|
||||
const device = storedDeviceDocument(approvedDevice, requesterDeviceId);
|
||||
if (
|
||||
device.approval_status !== "approved" ||
|
||||
device.approved_at === null ||
|
||||
device.wrapping_public_key === undefined
|
||||
) {
|
||||
if (device.approval_status === "approved" && device.wrapping_public_key === undefined) {
|
||||
throw new DevicePersistenceError("device_approval_state_invalid");
|
||||
}
|
||||
throw approvalMismatch(replay);
|
||||
}
|
||||
const envelope = await env.ELY_DB.prepare(CURRENT_RECIPIENT_ENVELOPE_QUERY)
|
||||
.bind(context.userId, approval.deviceId)
|
||||
.first<CurrentRecipientEnvelopeRow>();
|
||||
if (
|
||||
envelope === null ||
|
||||
!approvalEnvelopeMatches(storedApprovalEnvelope(envelope), approval, requesterDeviceId)
|
||||
) {
|
||||
throw approvalMismatch(replay);
|
||||
}
|
||||
try {
|
||||
return approvedDeviceDocument(context.userId, requesterDeviceId, approvedDevice);
|
||||
} catch (error) {
|
||||
throw storedApprovalError(error);
|
||||
}
|
||||
}
|
||||
|
||||
function approvalEnvelopeBindings(
|
||||
userId: string,
|
||||
approval: DeviceApprovalRequest,
|
||||
requesterDeviceId: string,
|
||||
): unknown[] {
|
||||
return [
|
||||
userId,
|
||||
approval.deviceId,
|
||||
requesterDeviceId,
|
||||
approval.keyId,
|
||||
approval.generation,
|
||||
approval.envelope.version,
|
||||
approval.envelope.suite,
|
||||
approval.envelope.encapped_key,
|
||||
approval.envelope.ciphertext,
|
||||
approval.idempotencyKey,
|
||||
];
|
||||
}
|
||||
|
||||
function approvalEnvelopeMatches(
|
||||
row: CurrentRecipientEnvelopeRow,
|
||||
approval: DeviceApprovalRequest,
|
||||
requesterDeviceId: string,
|
||||
): boolean {
|
||||
return (
|
||||
row.key_id === approval.keyId &&
|
||||
row.generation === approval.generation &&
|
||||
row.recipient_device_id === approval.deviceId &&
|
||||
row.approver_device_id === requesterDeviceId &&
|
||||
row.envelope_version === approval.envelope.version &&
|
||||
row.suite === approval.envelope.suite &&
|
||||
row.encapped_key === approval.envelope.encapped_key &&
|
||||
row.ciphertext === approval.envelope.ciphertext &&
|
||||
row.idempotency_key === approval.idempotencyKey
|
||||
);
|
||||
}
|
||||
|
||||
function approvalMismatch(replay: boolean): Error {
|
||||
return replay
|
||||
? new DevicePermissionError("device_approval_replay_mismatch")
|
||||
: new DeviceConflictError("device_approval_envelope_conflict");
|
||||
}
|
||||
|
||||
function storedApproval(row: DeviceApprovalRow): {
|
||||
approvedDeviceId: string;
|
||||
approvedByDeviceId: string;
|
||||
status: string;
|
||||
decidedAt: number | null;
|
||||
} {
|
||||
try {
|
||||
if (typeof row.status !== "string" || !STORED_APPROVAL_STATUSES.has(row.status)) {
|
||||
throw new DeviceSchemaError("approval_status_invalid");
|
||||
}
|
||||
return {
|
||||
approvedDeviceId: deviceIdValue(row.device_id, "device_id"),
|
||||
approvedByDeviceId: deviceIdValue(row.requester_device_id, "requester_device_id"),
|
||||
status: row.status,
|
||||
decidedAt: row.decided_at === null ? null : timestamp(row.decided_at, "decided_at"),
|
||||
};
|
||||
} catch (error) {
|
||||
throw storedApprovalError(error);
|
||||
}
|
||||
}
|
||||
|
||||
function storedDeviceDocument(row: DeviceRow, requesterDeviceId: string) {
|
||||
try {
|
||||
return deviceDocument(row, requesterDeviceId);
|
||||
} catch (error) {
|
||||
throw storedApprovalError(error);
|
||||
}
|
||||
}
|
||||
|
||||
function storedApprovalEnvelope(row: CurrentRecipientEnvelopeRow): CurrentRecipientEnvelopeRow {
|
||||
try {
|
||||
keyIdValue(row.key_id);
|
||||
positiveInteger(row.generation, "generation");
|
||||
deviceIdValue(row.recipient_device_id, "recipient_device_id");
|
||||
deviceIdValue(row.approver_device_id, "approver_device_id");
|
||||
wrappedAccountKey({
|
||||
version: row.envelope_version,
|
||||
suite: row.suite,
|
||||
encapped_key: row.encapped_key,
|
||||
ciphertext: row.ciphertext,
|
||||
});
|
||||
idempotencyKeyValue(row.idempotency_key);
|
||||
return row;
|
||||
} catch (error) {
|
||||
throw storedApprovalError(error);
|
||||
}
|
||||
}
|
||||
|
||||
function storedApprovalError(error: unknown): Error {
|
||||
return error instanceof DeviceSchemaError
|
||||
? new DevicePersistenceError("device_approval_state_invalid")
|
||||
: error as Error;
|
||||
}
|
||||
|
||||
async function approvedRequesterSigningKey(
|
||||
env: Env,
|
||||
userId: string,
|
||||
requesterDeviceId: string,
|
||||
): Promise<string> {
|
||||
const requester = await env.ELY_DB.prepare(APPROVED_REQUESTER_QUERY)
|
||||
.bind(userId, requesterDeviceId)
|
||||
.first<ApprovedRequesterRow>();
|
||||
if (requester === null) {
|
||||
throw new DevicePermissionError("requester_device_unapproved");
|
||||
}
|
||||
try {
|
||||
return publicKeyValue(requester.signing_public_key, "signing_public_key");
|
||||
} catch (error) {
|
||||
if (error instanceof DeviceSchemaError) {
|
||||
throw new DevicePersistenceError("requester_signing_key_invalid");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function deviceRowById(env: Env, userId: string, deviceId: string): Promise<DeviceRow | null> {
|
||||
return env.ELY_DB.prepare(DEVICE_BY_ID_QUERY).bind(userId, deviceId).first<DeviceRow>();
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { verifyEd25519Signature } from "./device_crypto.js";
|
||||
import {
|
||||
type DeviceApprovalRequest,
|
||||
DevicePermissionError,
|
||||
} from "./device_schema.js";
|
||||
|
||||
const PROOF_MAX_AGE_SECONDS = 5 * 60;
|
||||
const PROOF_CLOCK_SKEW_SECONDS = 30;
|
||||
|
||||
export async function assertDeviceApprovalProof(
|
||||
signingPublicKey: string,
|
||||
userId: string,
|
||||
approverDeviceId: string,
|
||||
approval: DeviceApprovalRequest,
|
||||
): Promise<void> {
|
||||
if (!(await verifyEd25519Signature(
|
||||
signingPublicKey,
|
||||
approval.approvalProof,
|
||||
deviceApprovalProofBytes(userId, approverDeviceId, approval),
|
||||
))) {
|
||||
throw new DevicePermissionError("device_approval_proof_invalid");
|
||||
}
|
||||
}
|
||||
|
||||
export function assertFreshDeviceApprovalProof(
|
||||
approval: DeviceApprovalRequest,
|
||||
nowSeconds: number,
|
||||
required: boolean,
|
||||
): void {
|
||||
if (required && (
|
||||
approval.proofCreatedAt < nowSeconds - PROOF_MAX_AGE_SECONDS ||
|
||||
approval.proofCreatedAt > nowSeconds + PROOF_CLOCK_SKEW_SECONDS
|
||||
)) {
|
||||
throw new DevicePermissionError("device_approval_proof_expired");
|
||||
}
|
||||
}
|
||||
|
||||
export function deviceApprovalProofBytes(
|
||||
userId: string,
|
||||
approverDeviceId: string,
|
||||
approval: Omit<DeviceApprovalRequest, "approvalProof">,
|
||||
): Uint8Array {
|
||||
return canonicalBytes([
|
||||
"elydora-device-approval-v2",
|
||||
userId,
|
||||
approverDeviceId,
|
||||
approval.deviceId,
|
||||
approval.keyId,
|
||||
approval.generation,
|
||||
approval.envelope.version,
|
||||
approval.envelope.suite,
|
||||
approval.envelope.encapped_key,
|
||||
approval.envelope.ciphertext,
|
||||
approval.idempotencyKey,
|
||||
approval.proofCreatedAt,
|
||||
]);
|
||||
}
|
||||
|
||||
function canonicalBytes(values: (number | string)[]): Uint8Array {
|
||||
const encoder = new TextEncoder();
|
||||
return encoder.encode(values.map((value) => {
|
||||
const text = value.toString();
|
||||
return `${encoder.encode(text).byteLength}:${text}`;
|
||||
}).join(""));
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
export async function verifyEd25519Signature(
|
||||
publicKey: string,
|
||||
signature: string,
|
||||
message: Uint8Array,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
hexBytes(publicKey),
|
||||
{ name: "Ed25519" },
|
||||
false,
|
||||
["verify"],
|
||||
);
|
||||
return crypto.subtle.verify(
|
||||
{ name: "Ed25519" },
|
||||
key,
|
||||
hexBytes(signature),
|
||||
message,
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function hexBytes(value: string): Uint8Array {
|
||||
const bytes = new Uint8Array(value.length / 2);
|
||||
for (let index = 0; index < bytes.length; index += 1) {
|
||||
bytes[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
import type { AuthContext } from "./auth.js";
|
||||
import type { Env } from "./bindings.js";
|
||||
import { verifyEd25519Signature } from "./device_crypto.js";
|
||||
import {
|
||||
DeviceConflictError,
|
||||
DevicePermissionError,
|
||||
DevicePersistenceError,
|
||||
DeviceSchemaError,
|
||||
assertOnlyFields,
|
||||
deviceIdValue,
|
||||
deviceRequestBody,
|
||||
publicKeyValue,
|
||||
signatureValue,
|
||||
timestamp,
|
||||
} from "./device_schema.js";
|
||||
|
||||
const CHALLENGE_TTL_SECONDS = 300;
|
||||
const CHALLENGE_ID_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
|
||||
const APPROVED_DEVICE_KEY_QUERY = `
|
||||
SELECT keys.signing_public_key
|
||||
FROM user_devices AS device
|
||||
INNER JOIN user_device_keys AS keys
|
||||
ON keys.user_id = device.user_id AND keys.device_id = device.device_id
|
||||
WHERE device.user_id = ? AND device.device_id = ?
|
||||
AND device.approval_status = 'approved' AND device.revoked_at IS NULL
|
||||
AND keys.key_protocol_version = 2
|
||||
`;
|
||||
const CHALLENGE_UPSERT_QUERY = `
|
||||
INSERT INTO device_rebind_challenges (
|
||||
challenge_id,
|
||||
user_id,
|
||||
session_id,
|
||||
device_id,
|
||||
challenge,
|
||||
created_at,
|
||||
expires_at,
|
||||
consumed_at,
|
||||
consumption_nonce
|
||||
)
|
||||
SELECT ?, ?, ?, ?, ?, ?, ?, NULL, NULL
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM better_auth_session WHERE id = ? AND userId = ?
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM better_auth_session_device_context WHERE session_id = ?
|
||||
)
|
||||
ON CONFLICT(session_id) DO UPDATE SET
|
||||
challenge_id = excluded.challenge_id,
|
||||
user_id = excluded.user_id,
|
||||
device_id = excluded.device_id,
|
||||
challenge = excluded.challenge,
|
||||
created_at = excluded.created_at,
|
||||
expires_at = excluded.expires_at,
|
||||
consumed_at = NULL,
|
||||
consumption_nonce = NULL
|
||||
`;
|
||||
const CHALLENGE_QUERY = `
|
||||
SELECT
|
||||
rebind.challenge,
|
||||
rebind.expires_at,
|
||||
keys.signing_public_key
|
||||
FROM device_rebind_challenges AS rebind
|
||||
INNER JOIN user_devices AS device
|
||||
ON device.user_id = rebind.user_id AND device.device_id = rebind.device_id
|
||||
INNER JOIN user_device_keys AS keys
|
||||
ON keys.user_id = device.user_id AND keys.device_id = device.device_id
|
||||
WHERE rebind.challenge_id = ? AND rebind.user_id = ?
|
||||
AND rebind.session_id = ? AND rebind.device_id = ?
|
||||
AND rebind.consumed_at IS NULL
|
||||
AND device.approval_status = 'approved' AND device.revoked_at IS NULL
|
||||
AND keys.key_protocol_version = 2
|
||||
`;
|
||||
const CHALLENGE_CONSUME_QUERY = `
|
||||
UPDATE device_rebind_challenges
|
||||
SET consumed_at = ?, consumption_nonce = ?
|
||||
WHERE challenge_id = ? AND user_id = ? AND session_id = ? AND device_id = ?
|
||||
AND consumed_at IS NULL AND expires_at > ?
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM better_auth_session_device_context WHERE session_id = ?
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM user_devices AS device
|
||||
INNER JOIN user_device_keys AS keys
|
||||
ON keys.user_id = device.user_id AND keys.device_id = device.device_id
|
||||
WHERE device.user_id = ? AND device.device_id = ?
|
||||
AND device.approval_status = 'approved' AND device.revoked_at IS NULL
|
||||
AND keys.key_protocol_version = 2
|
||||
)
|
||||
`;
|
||||
const SESSION_BIND_QUERY = `
|
||||
INSERT INTO better_auth_session_device_context (
|
||||
session_id,
|
||||
user_id,
|
||||
device_id,
|
||||
updated_at
|
||||
)
|
||||
SELECT rebind.session_id, rebind.user_id, rebind.device_id, ?
|
||||
FROM device_rebind_challenges AS rebind
|
||||
WHERE rebind.challenge_id = ? AND rebind.consumption_nonce = ?
|
||||
AND rebind.consumed_at = ?
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM better_auth_session
|
||||
WHERE id = rebind.session_id AND userId = rebind.user_id
|
||||
)
|
||||
ON CONFLICT(session_id) DO NOTHING
|
||||
`;
|
||||
|
||||
interface ApprovedDeviceKeyRow {
|
||||
signing_public_key: unknown;
|
||||
}
|
||||
|
||||
interface RebindChallengeRow extends ApprovedDeviceKeyRow {
|
||||
challenge: unknown;
|
||||
expires_at: unknown;
|
||||
}
|
||||
|
||||
export interface DeviceRebindChallengeDocument {
|
||||
version: 1;
|
||||
challenge_id: string;
|
||||
device_id: string;
|
||||
challenge: string;
|
||||
expires_at: number;
|
||||
}
|
||||
|
||||
export interface DeviceRebindDocument {
|
||||
version: 1;
|
||||
user_id: string;
|
||||
session_id: string;
|
||||
device_id: string;
|
||||
bound_at: number;
|
||||
}
|
||||
|
||||
export async function issueDeviceRebindChallenge(
|
||||
request: Request,
|
||||
env: Env,
|
||||
context: AuthContext,
|
||||
nowSeconds = Math.floor(Date.now() / 1000),
|
||||
): Promise<DeviceRebindChallengeDocument> {
|
||||
const deviceId = await rebindChallengeDeviceId(request);
|
||||
assertUnboundSession(context);
|
||||
const keyRow = await env.ELY_DB.prepare(APPROVED_DEVICE_KEY_QUERY)
|
||||
.bind(context.userId, deviceId)
|
||||
.first<ApprovedDeviceKeyRow>();
|
||||
if (keyRow === null) {
|
||||
throw new DevicePermissionError("device_rebind_unavailable");
|
||||
}
|
||||
publicKeyValue(keyRow.signing_public_key, "signing_public_key");
|
||||
|
||||
const challengeId = crypto.randomUUID();
|
||||
const expiresAt = nowSeconds + CHALLENGE_TTL_SECONDS;
|
||||
const challenge = canonicalChallenge(
|
||||
challengeId,
|
||||
context.userId,
|
||||
context.sessionId,
|
||||
deviceId,
|
||||
expiresAt,
|
||||
randomHex(32),
|
||||
);
|
||||
const result = await env.ELY_DB.prepare(CHALLENGE_UPSERT_QUERY)
|
||||
.bind(
|
||||
challengeId,
|
||||
context.userId,
|
||||
context.sessionId,
|
||||
deviceId,
|
||||
challenge,
|
||||
nowSeconds,
|
||||
expiresAt,
|
||||
context.sessionId,
|
||||
context.userId,
|
||||
context.sessionId,
|
||||
)
|
||||
.run();
|
||||
if (changedRowCount(result) !== 1) {
|
||||
throw new DevicePersistenceError("device_rebind_challenge_write_failed");
|
||||
}
|
||||
return { version: 1, challenge_id: challengeId, device_id: deviceId, challenge, expires_at: expiresAt };
|
||||
}
|
||||
|
||||
export async function rebindDeviceSession(
|
||||
request: Request,
|
||||
env: Env,
|
||||
context: AuthContext,
|
||||
nowSeconds = Math.floor(Date.now() / 1000),
|
||||
): Promise<DeviceRebindDocument> {
|
||||
const rebind = await rebindRequest(request);
|
||||
assertUnboundSession(context);
|
||||
const row = await env.ELY_DB.prepare(CHALLENGE_QUERY)
|
||||
.bind(rebind.challengeId, context.userId, context.sessionId, rebind.deviceId)
|
||||
.first<RebindChallengeRow>();
|
||||
if (row === null) {
|
||||
throw new DevicePermissionError("device_rebind_forbidden");
|
||||
}
|
||||
const expiresAt = timestamp(row.expires_at, "expires_at");
|
||||
if (expiresAt <= nowSeconds) {
|
||||
throw new DevicePermissionError("device_rebind_challenge_expired");
|
||||
}
|
||||
const challenge = challengeValue(row.challenge);
|
||||
assertCanonicalChallenge(challenge, rebind.challengeId, context, rebind.deviceId, expiresAt);
|
||||
const signingPublicKey = publicKeyValue(row.signing_public_key, "signing_public_key");
|
||||
if (
|
||||
!(await verifyEd25519Signature(
|
||||
signingPublicKey,
|
||||
rebind.signature,
|
||||
new TextEncoder().encode(challenge),
|
||||
))
|
||||
) {
|
||||
throw new DevicePermissionError("device_rebind_signature_invalid");
|
||||
}
|
||||
|
||||
const consumptionNonce = randomHex(32);
|
||||
const [consumeResult, bindResult] = await env.ELY_DB.batch([
|
||||
env.ELY_DB.prepare(CHALLENGE_CONSUME_QUERY).bind(
|
||||
nowSeconds,
|
||||
consumptionNonce,
|
||||
rebind.challengeId,
|
||||
context.userId,
|
||||
context.sessionId,
|
||||
rebind.deviceId,
|
||||
nowSeconds,
|
||||
context.sessionId,
|
||||
context.userId,
|
||||
rebind.deviceId,
|
||||
),
|
||||
env.ELY_DB.prepare(SESSION_BIND_QUERY).bind(
|
||||
nowSeconds,
|
||||
rebind.challengeId,
|
||||
consumptionNonce,
|
||||
nowSeconds,
|
||||
),
|
||||
]);
|
||||
if (changedRowCount(consumeResult) !== 1 || changedRowCount(bindResult) !== 1) {
|
||||
throw new DeviceConflictError("device_rebind_challenge_consumed");
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
user_id: context.userId,
|
||||
session_id: context.sessionId,
|
||||
device_id: rebind.deviceId,
|
||||
bound_at: nowSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
async function rebindChallengeDeviceId(request: Request): Promise<string> {
|
||||
const value = await deviceRequestBody(request, "device_rebind_challenge");
|
||||
assertOnlyFields(value, ["version", "device_id"]);
|
||||
if (value.version !== 1) {
|
||||
throw new DeviceSchemaError("device_rebind_challenge_version_invalid");
|
||||
}
|
||||
return deviceIdValue(value.device_id, "device_id");
|
||||
}
|
||||
|
||||
async function rebindRequest(
|
||||
request: Request,
|
||||
): Promise<{ challengeId: string; deviceId: string; signature: string }> {
|
||||
const value = await deviceRequestBody(request, "device_rebind");
|
||||
assertOnlyFields(value, ["version", "challenge_id", "device_id", "signature"]);
|
||||
if (value.version !== 1) {
|
||||
throw new DeviceSchemaError("device_rebind_version_invalid");
|
||||
}
|
||||
if (typeof value.challenge_id !== "string" || !CHALLENGE_ID_PATTERN.test(value.challenge_id)) {
|
||||
throw new DeviceSchemaError("challenge_id_invalid");
|
||||
}
|
||||
return {
|
||||
challengeId: value.challenge_id,
|
||||
deviceId: deviceIdValue(value.device_id, "device_id"),
|
||||
signature: signatureValue(value.signature, "signature"),
|
||||
};
|
||||
}
|
||||
|
||||
function assertUnboundSession(context: AuthContext): void {
|
||||
if (context.deviceId !== undefined) {
|
||||
throw new DevicePermissionError("device_context_already_bound");
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalChallenge(
|
||||
challengeId: string,
|
||||
userId: string,
|
||||
sessionId: string,
|
||||
deviceId: string,
|
||||
expiresAt: number,
|
||||
nonce: string,
|
||||
): string {
|
||||
return [
|
||||
"elydora-device-rebind-v1",
|
||||
`challenge_id=${challengeId}`,
|
||||
`user_id=${userId}`,
|
||||
`session_id=${sessionId}`,
|
||||
`device_id=${deviceId}`,
|
||||
`expires_at=${expiresAt}`,
|
||||
`nonce=${nonce}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function assertCanonicalChallenge(
|
||||
challenge: string,
|
||||
challengeId: string,
|
||||
context: AuthContext,
|
||||
deviceId: string,
|
||||
expiresAt: number,
|
||||
): void {
|
||||
const prefix = canonicalChallenge(
|
||||
challengeId,
|
||||
context.userId,
|
||||
context.sessionId,
|
||||
deviceId,
|
||||
expiresAt,
|
||||
"",
|
||||
);
|
||||
const nonce = challenge.slice(prefix.length);
|
||||
if (!challenge.startsWith(prefix) || !/^[a-f0-9]{64}$/.test(nonce)) {
|
||||
throw new DevicePersistenceError("device_rebind_challenge_invalid");
|
||||
}
|
||||
}
|
||||
|
||||
function challengeValue(value: unknown): string {
|
||||
if (typeof value !== "string" || value.length < 1 || value.length > 1024) {
|
||||
throw new DevicePersistenceError("device_rebind_challenge_invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function changedRowCount(result: unknown): number {
|
||||
if (typeof result !== "object" || result === null || !("meta" in result)) {
|
||||
throw new DevicePersistenceError("device_rebind_write_result_invalid");
|
||||
}
|
||||
const meta = result.meta;
|
||||
if (typeof meta !== "object" || meta === null || !("changes" in meta)) {
|
||||
throw new DevicePersistenceError("device_rebind_write_result_invalid");
|
||||
}
|
||||
const changes = meta.changes;
|
||||
if (typeof changes !== "number" || !Number.isSafeInteger(changes) || changes < 0) {
|
||||
throw new DevicePersistenceError("device_rebind_write_result_invalid");
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
function randomHex(byteLength: number): string {
|
||||
const bytes = new Uint8Array(byteLength);
|
||||
crypto.getRandomValues(bytes);
|
||||
return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { verifyEd25519Signature } from "./device_crypto.js";
|
||||
import {
|
||||
type DeviceRegistrationRequest,
|
||||
DevicePermissionError,
|
||||
} from "./device_schema.js";
|
||||
|
||||
const REGISTRATION_DOMAIN = "elydora-device-registration-v2";
|
||||
|
||||
export async function assertDeviceRegistrationProof(
|
||||
registration: DeviceRegistrationRequest,
|
||||
): Promise<void> {
|
||||
const valid = await verifyEd25519Signature(
|
||||
registration.publicKey,
|
||||
registration.registrationProof,
|
||||
deviceRegistrationProofBytes(registration),
|
||||
);
|
||||
if (!valid) {
|
||||
throw new DevicePermissionError("device_registration_proof_invalid");
|
||||
}
|
||||
}
|
||||
|
||||
export function deviceRegistrationProofBytes(
|
||||
registration: Omit<DeviceRegistrationRequest, "registrationProof">,
|
||||
): Uint8Array {
|
||||
const encoder = new TextEncoder();
|
||||
const payload = [
|
||||
REGISTRATION_DOMAIN,
|
||||
registration.deviceId,
|
||||
registration.publicKey,
|
||||
registration.wrappingPublicKey,
|
||||
registration.deviceName,
|
||||
registration.platform,
|
||||
registration.idempotencyKey,
|
||||
]
|
||||
.map((value) => `${encoder.encode(value).byteLength}:${value}`)
|
||||
.join("");
|
||||
return encoder.encode(payload);
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
import type { AuthContext } from "./auth.js";
|
||||
import type { ElyD1Result, Env } from "./bindings.js";
|
||||
import { verifyEd25519Signature } from "./device_crypto.js";
|
||||
import {
|
||||
type DeviceRevocationDocument,
|
||||
type DeviceRow,
|
||||
DeviceConflictError,
|
||||
DevicePermissionError,
|
||||
DevicePersistenceError,
|
||||
currentDeviceId,
|
||||
deviceDocument,
|
||||
deviceIdValue,
|
||||
publicKeyValue,
|
||||
} from "./device_schema.js";
|
||||
import {
|
||||
type ApprovedDeviceRevocationRequest,
|
||||
compareDeviceIds,
|
||||
deviceRevocationRequest,
|
||||
deviceRevocationRequestHash,
|
||||
deviceRevocationProofBytes,
|
||||
pendingDeviceRevocationProofBytes,
|
||||
pendingDeviceRevocationRequestHash,
|
||||
} from "./device_revocation_schema.js";
|
||||
import {
|
||||
type RotationResultRow,
|
||||
rotationR2ObjectCount,
|
||||
rotationResult,
|
||||
rotationStatements,
|
||||
} from "./device_revocation_store.js";
|
||||
import { revokePendingDeviceDocument } from "./pending_device_revocation.js";
|
||||
|
||||
const APPROVED_V2_REQUESTER_QUERY = `
|
||||
SELECT device.device_id, keys.signing_public_key
|
||||
FROM user_devices AS device
|
||||
INNER JOIN user_device_keys AS keys
|
||||
ON keys.user_id = device.user_id AND keys.device_id = device.device_id
|
||||
WHERE device.user_id = ? AND device.device_id = ?
|
||||
AND device.approval_status = 'approved' AND device.revoked_at IS NULL
|
||||
AND keys.key_protocol_version = 2 AND keys.wrapping_public_key IS NOT NULL
|
||||
`;
|
||||
const DEVICE_BY_ID_QUERY = `
|
||||
SELECT
|
||||
device.device_id, device.public_key, device.device_name, device.platform,
|
||||
device.approval_status, device.created_at, device.approved_at,
|
||||
device.last_active_at, device.revoked_at, keys.wrapping_public_key
|
||||
FROM user_devices AS device
|
||||
LEFT JOIN user_device_keys AS keys
|
||||
ON keys.user_id = device.user_id AND keys.device_id = device.device_id
|
||||
WHERE device.user_id = ? AND device.device_id = ?
|
||||
`;
|
||||
const CURRENT_VAULT_KEY_QUERY = `
|
||||
SELECT current_key_id AS key_id, current_generation AS generation
|
||||
FROM sync_vault_accounts
|
||||
WHERE user_id = ?
|
||||
`;
|
||||
const REMAINING_APPROVED_V2_QUERY = `
|
||||
SELECT device.device_id
|
||||
FROM user_devices AS device
|
||||
INNER JOIN user_device_keys AS keys
|
||||
ON keys.user_id = device.user_id AND keys.device_id = device.device_id
|
||||
WHERE device.user_id = ? AND device.device_id <> ?
|
||||
AND device.approval_status = 'approved' AND device.revoked_at IS NULL
|
||||
AND keys.key_protocol_version = 2 AND keys.wrapping_public_key IS NOT NULL
|
||||
ORDER BY device.device_id ASC
|
||||
`;
|
||||
interface VaultKeyRow { key_id: unknown; generation: unknown }
|
||||
interface DeviceIdRow { device_id: unknown }
|
||||
interface ApprovedV2RequesterRow extends DeviceIdRow { signing_public_key: unknown }
|
||||
|
||||
export async function revokeDeviceDocument(
|
||||
request: Request,
|
||||
env: Env,
|
||||
context: AuthContext,
|
||||
nowSeconds = Math.floor(Date.now() / 1000),
|
||||
): Promise<DeviceRevocationDocument> {
|
||||
const revocation = await deviceRevocationRequest(request);
|
||||
const approverDeviceId = currentDeviceId(context);
|
||||
if (approverDeviceId === revocation.deviceId) {
|
||||
throw new DevicePermissionError("device_self_revocation_forbidden");
|
||||
}
|
||||
const signingPublicKey = await approvedV2RequesterKey(env, context.userId, approverDeviceId);
|
||||
if (revocation.mode === "pending_revoke") {
|
||||
const { pendingRevocationProof, ...unsignedRevocation } = revocation;
|
||||
if (!(await verifyEd25519Signature(
|
||||
signingPublicKey,
|
||||
pendingRevocationProof,
|
||||
pendingDeviceRevocationProofBytes(context.userId, approverDeviceId, unsignedRevocation),
|
||||
))) {
|
||||
throw new DevicePermissionError("device_revocation_proof_invalid");
|
||||
}
|
||||
const requestHash = await pendingDeviceRevocationRequestHash(
|
||||
context.userId,
|
||||
approverDeviceId,
|
||||
revocation,
|
||||
);
|
||||
return revokePendingDeviceDocument(
|
||||
env,
|
||||
context,
|
||||
approverDeviceId,
|
||||
revocation,
|
||||
requestHash,
|
||||
nowSeconds,
|
||||
);
|
||||
}
|
||||
const { rotationProof, ...unsignedRevocation } = revocation;
|
||||
if (!(await verifyEd25519Signature(
|
||||
signingPublicKey,
|
||||
rotationProof,
|
||||
deviceRevocationProofBytes(context.userId, approverDeviceId, unsignedRevocation),
|
||||
))) {
|
||||
throw new DevicePermissionError("device_revocation_proof_invalid");
|
||||
}
|
||||
|
||||
const requestHash = await deviceRevocationRequestHash(
|
||||
context.userId,
|
||||
approverDeviceId,
|
||||
revocation,
|
||||
);
|
||||
const existing = await rotationResult(env, context.userId, revocation.idempotencyKey);
|
||||
if (existing !== null) {
|
||||
return completedRevocationDocument(
|
||||
env,
|
||||
context.userId,
|
||||
approverDeviceId,
|
||||
revocation,
|
||||
requestHash,
|
||||
existing,
|
||||
);
|
||||
}
|
||||
|
||||
await assertCurrentVaultKey(env, context.userId, revocation);
|
||||
await assertRevocableTarget(env, context.userId, approverDeviceId, revocation.deviceId);
|
||||
const recipients = await remainingApprovedV2Recipients(env, context.userId, revocation.deviceId);
|
||||
assertExactRecipients(revocation, recipients);
|
||||
const r2ObjectCount = await rotationR2ObjectCount(env, context.userId);
|
||||
const statements = await rotationStatements(
|
||||
env,
|
||||
context.userId,
|
||||
approverDeviceId,
|
||||
revocation,
|
||||
requestHash,
|
||||
r2ObjectCount,
|
||||
nowSeconds,
|
||||
);
|
||||
let results: ElyD1Result[];
|
||||
try {
|
||||
results = await env.ELY_DB.batch<ElyD1Result>(statements);
|
||||
} catch (error) {
|
||||
if (isRotationConflict(error)) {
|
||||
throw new DeviceConflictError("device_revocation_race");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const finalizeChanges = changedRowCount(results.at(-1), "device_revocation_finalize");
|
||||
if (finalizeChanges > 1) {
|
||||
throw new DevicePersistenceError("device_revocation_write_count_invalid");
|
||||
}
|
||||
|
||||
const completed = await rotationResult(env, context.userId, revocation.idempotencyKey);
|
||||
if (completed === null) {
|
||||
throw new DeviceConflictError("device_revocation_race");
|
||||
}
|
||||
try {
|
||||
return await completedRevocationDocument(
|
||||
env,
|
||||
context.userId,
|
||||
approverDeviceId,
|
||||
revocation,
|
||||
requestHash,
|
||||
completed,
|
||||
);
|
||||
} catch (error) {
|
||||
if (finalizeChanges === 0 && error instanceof DeviceConflictError) {
|
||||
throw new DeviceConflictError("device_revocation_race");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function completedRevocationDocument(
|
||||
env: Env,
|
||||
userId: string,
|
||||
approverDeviceId: string,
|
||||
revocation: ApprovedDeviceRevocationRequest,
|
||||
requestHash: string,
|
||||
result: RotationResultRow,
|
||||
): Promise<DeviceRevocationDocument> {
|
||||
assertRotationResult(result, revocation, approverDeviceId, requestHash);
|
||||
const revokedDevice = await deviceRowById(env, userId, revocation.deviceId);
|
||||
if (revokedDevice === null) {
|
||||
throw new DevicePersistenceError("device_revocation_missing");
|
||||
}
|
||||
const device = deviceDocument(revokedDevice, approverDeviceId);
|
||||
const completedAt = storedInteger(result.completed_at, "completed_at");
|
||||
if (device.approval_status !== "revoked" || device.revoked_at !== completedAt) {
|
||||
throw new DevicePersistenceError("device_revocation_state_invalid");
|
||||
}
|
||||
return {
|
||||
version: 2,
|
||||
mode: "approved_rotate",
|
||||
user_id: userId,
|
||||
revoked_by_device_id: approverDeviceId,
|
||||
revoked_at: completedAt,
|
||||
key_id: revocation.newKeyId,
|
||||
generation: revocation.newGeneration,
|
||||
device,
|
||||
};
|
||||
}
|
||||
|
||||
function assertRotationResult(
|
||||
row: RotationResultRow,
|
||||
revocation: ApprovedDeviceRevocationRequest,
|
||||
approverDeviceId: string,
|
||||
requestHash: string,
|
||||
): void {
|
||||
if (
|
||||
row.target_device_id !== revocation.deviceId ||
|
||||
row.approver_device_id !== approverDeviceId ||
|
||||
row.previous_key_id !== revocation.previousKeyId ||
|
||||
row.previous_generation !== revocation.previousGeneration ||
|
||||
row.new_key_id !== revocation.newKeyId ||
|
||||
row.new_generation !== revocation.newGeneration ||
|
||||
row.request_hash !== requestHash ||
|
||||
row.envelope_count !== revocation.envelopes.length
|
||||
) {
|
||||
throw new DeviceConflictError("device_revocation_replay_mismatch");
|
||||
}
|
||||
const completedAt = storedInteger(row.completed_at, "completed_at");
|
||||
if (
|
||||
row.current_key_id !== revocation.newKeyId ||
|
||||
row.current_generation !== revocation.newGeneration ||
|
||||
row.target_status !== "revoked" ||
|
||||
row.revoked_at !== completedAt ||
|
||||
row.active_session_count !== 0 ||
|
||||
row.item_count !== revocation.envelopes.length ||
|
||||
storedInteger(row.r2_object_count, "r2_object_count") !==
|
||||
storedInteger(row.r2_item_count, "r2_item_count") ||
|
||||
row.persisted_count !== revocation.envelopes.length ||
|
||||
row.audit_count !== 1
|
||||
) {
|
||||
throw new DevicePersistenceError("device_revocation_result_invalid");
|
||||
}
|
||||
}
|
||||
|
||||
async function approvedV2RequesterKey(
|
||||
env: Env,
|
||||
userId: string,
|
||||
approverDeviceId: string,
|
||||
): Promise<string> {
|
||||
const row = await env.ELY_DB.prepare(APPROVED_V2_REQUESTER_QUERY)
|
||||
.bind(userId, approverDeviceId)
|
||||
.first<ApprovedV2RequesterRow>();
|
||||
if (row === null) {
|
||||
throw new DevicePermissionError("requester_device_unapproved");
|
||||
}
|
||||
return publicKeyValue(row.signing_public_key, "signing_public_key");
|
||||
}
|
||||
|
||||
async function assertCurrentVaultKey(
|
||||
env: Env,
|
||||
userId: string,
|
||||
revocation: ApprovedDeviceRevocationRequest,
|
||||
): Promise<void> {
|
||||
const row = await env.ELY_DB.prepare(CURRENT_VAULT_KEY_QUERY).bind(userId).first<VaultKeyRow>();
|
||||
if (
|
||||
row === null ||
|
||||
row.key_id !== revocation.previousKeyId ||
|
||||
row.generation !== revocation.previousGeneration
|
||||
) {
|
||||
throw new DeviceConflictError("device_revocation_vault_conflict");
|
||||
}
|
||||
}
|
||||
|
||||
async function assertRevocableTarget(
|
||||
env: Env,
|
||||
userId: string,
|
||||
approverDeviceId: string,
|
||||
targetDeviceId: string,
|
||||
): Promise<void> {
|
||||
const row = await deviceRowById(env, userId, targetDeviceId);
|
||||
if (row === null) {
|
||||
throw new DevicePermissionError("device_not_found");
|
||||
}
|
||||
const device = deviceDocument(row, approverDeviceId);
|
||||
if (device.revoked_at !== null || device.approval_status !== "approved") {
|
||||
throw new DevicePermissionError("device_not_revocable");
|
||||
}
|
||||
}
|
||||
|
||||
async function remainingApprovedV2Recipients(
|
||||
env: Env,
|
||||
userId: string,
|
||||
targetDeviceId: string,
|
||||
): Promise<string[]> {
|
||||
const result = await env.ELY_DB.prepare(REMAINING_APPROVED_V2_QUERY)
|
||||
.bind(userId, targetDeviceId)
|
||||
.all<DeviceIdRow>();
|
||||
const recipients = result.results.map((row) => deviceIdValue(row.device_id, "device_id"));
|
||||
if (new Set(recipients).size !== recipients.length) {
|
||||
throw new DevicePersistenceError("device_revocation_recipient_rows_invalid");
|
||||
}
|
||||
recipients.sort(compareDeviceIds);
|
||||
return recipients;
|
||||
}
|
||||
|
||||
function assertExactRecipients(revocation: ApprovedDeviceRevocationRequest, expected: string[]): void {
|
||||
const actual = revocation.envelopes.map((item) => item.recipientDeviceId);
|
||||
if (actual.length !== expected.length || actual.some((deviceId, index) => deviceId !== expected[index])) {
|
||||
throw new DeviceConflictError("device_revocation_envelope_set_mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
function deviceRowById(env: Env, userId: string, deviceId: string): Promise<DeviceRow | null> {
|
||||
return env.ELY_DB.prepare(DEVICE_BY_ID_QUERY).bind(userId, deviceId).first<DeviceRow>();
|
||||
}
|
||||
|
||||
function storedInteger(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
|
||||
throw new DevicePersistenceError(`${label}_invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function changedRowCount(result: ElyD1Result | undefined, label: string): number {
|
||||
const changes = result?.meta?.changes;
|
||||
if (typeof changes !== "number" || !Number.isSafeInteger(changes) || changes < 0) {
|
||||
throw new DevicePersistenceError(`${label}_write_result_invalid`);
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
function isRotationConflict(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) {
|
||||
return false;
|
||||
}
|
||||
return error.message.includes("sync_vault_rotation_guard_failed") ||
|
||||
error.message.includes("UNIQUE constraint failed: sync_vault_envelopes") ||
|
||||
error.message.includes("FOREIGN KEY constraint failed");
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import {
|
||||
DeviceSchemaError,
|
||||
assertOnlyFields,
|
||||
deviceIdValue,
|
||||
deviceRequestBody,
|
||||
idempotencyKeyValue,
|
||||
keyIdValue,
|
||||
positiveInteger,
|
||||
signatureValue,
|
||||
wrappedAccountKey,
|
||||
} from "./device_schema.js";
|
||||
import type { WrappedAccountKeyDocument } from "./sync_vault.js";
|
||||
|
||||
const MAX_ROTATION_ENVELOPES = 128;
|
||||
|
||||
export function compareDeviceIds(left: string, right: string): number {
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
|
||||
export interface DeviceRevocationEnvelopeRequest {
|
||||
recipientDeviceId: string;
|
||||
envelope: WrappedAccountKeyDocument;
|
||||
}
|
||||
|
||||
interface DeviceRevocationBaseRequest {
|
||||
deviceId: string;
|
||||
idempotencyKey: string;
|
||||
}
|
||||
|
||||
export interface ApprovedDeviceRevocationRequest extends DeviceRevocationBaseRequest {
|
||||
mode: "approved_rotate";
|
||||
previousKeyId: string;
|
||||
previousGeneration: number;
|
||||
newKeyId: string;
|
||||
newGeneration: number;
|
||||
envelopes: DeviceRevocationEnvelopeRequest[];
|
||||
rotationProof: string;
|
||||
}
|
||||
|
||||
export interface PendingDeviceRevocationRequest extends DeviceRevocationBaseRequest {
|
||||
mode: "pending_revoke";
|
||||
pendingRevocationProof: string;
|
||||
}
|
||||
|
||||
export type DeviceRevocationRequest =
|
||||
| ApprovedDeviceRevocationRequest
|
||||
| PendingDeviceRevocationRequest;
|
||||
|
||||
export async function deviceRevocationRequest(request: Request): Promise<DeviceRevocationRequest> {
|
||||
const value = await deviceRequestBody(request, "device_revocation");
|
||||
if (value.version !== 2) {
|
||||
throw new DeviceSchemaError("device_revocation_version_invalid");
|
||||
}
|
||||
if (value.mode === "pending_revoke") {
|
||||
assertOnlyFields(value, [
|
||||
"version",
|
||||
"mode",
|
||||
"device_id",
|
||||
"idempotency_key",
|
||||
"pending_revocation_proof",
|
||||
]);
|
||||
return {
|
||||
mode: "pending_revoke",
|
||||
deviceId: deviceIdValue(value.device_id, "device_id"),
|
||||
idempotencyKey: idempotencyKeyValue(value.idempotency_key),
|
||||
pendingRevocationProof: signatureValue(
|
||||
value.pending_revocation_proof,
|
||||
"pending_revocation_proof",
|
||||
),
|
||||
};
|
||||
}
|
||||
if (value.mode !== "approved_rotate") {
|
||||
throw new DeviceSchemaError("device_revocation_mode_invalid");
|
||||
}
|
||||
assertOnlyFields(value, [
|
||||
"version",
|
||||
"mode",
|
||||
"device_id",
|
||||
"previous_key_id",
|
||||
"previous_generation",
|
||||
"new_key_id",
|
||||
"new_generation",
|
||||
"envelopes",
|
||||
"idempotency_key",
|
||||
"rotation_proof",
|
||||
]);
|
||||
const deviceId = deviceIdValue(value.device_id, "device_id");
|
||||
const previousKeyId = keyIdValue(value.previous_key_id);
|
||||
const previousGeneration = positiveInteger(value.previous_generation, "previous_generation");
|
||||
const newKeyId = keyIdValue(value.new_key_id);
|
||||
const newGeneration = positiveInteger(value.new_generation, "new_generation");
|
||||
if (
|
||||
previousGeneration === Number.MAX_SAFE_INTEGER ||
|
||||
newGeneration !== previousGeneration + 1
|
||||
) {
|
||||
throw new DeviceSchemaError("new_generation_invalid");
|
||||
}
|
||||
if (newKeyId === previousKeyId) {
|
||||
throw new DeviceSchemaError("new_key_id_invalid");
|
||||
}
|
||||
|
||||
return {
|
||||
mode: "approved_rotate",
|
||||
deviceId,
|
||||
previousKeyId,
|
||||
previousGeneration,
|
||||
newKeyId,
|
||||
newGeneration,
|
||||
envelopes: revocationEnvelopes(value.envelopes, deviceId),
|
||||
idempotencyKey: idempotencyKeyValue(value.idempotency_key),
|
||||
rotationProof: signatureValue(value.rotation_proof, "rotation_proof"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function deviceRevocationRequestHash(
|
||||
userId: string,
|
||||
approverDeviceId: string,
|
||||
revocation: ApprovedDeviceRevocationRequest,
|
||||
): Promise<string> {
|
||||
return sha256Hex(JSON.stringify({
|
||||
version: 2,
|
||||
mode: revocation.mode,
|
||||
user_id: userId,
|
||||
approver_device_id: approverDeviceId,
|
||||
device_id: revocation.deviceId,
|
||||
previous_key_id: revocation.previousKeyId,
|
||||
previous_generation: revocation.previousGeneration,
|
||||
new_key_id: revocation.newKeyId,
|
||||
new_generation: revocation.newGeneration,
|
||||
envelopes: revocation.envelopes.map((item) => ({
|
||||
recipient_device_id: item.recipientDeviceId,
|
||||
envelope: item.envelope,
|
||||
})),
|
||||
idempotency_key: revocation.idempotencyKey,
|
||||
rotation_proof: revocation.rotationProof,
|
||||
}));
|
||||
}
|
||||
|
||||
export function deviceRevocationProofBytes(
|
||||
userId: string,
|
||||
approverDeviceId: string,
|
||||
revocation: Omit<ApprovedDeviceRevocationRequest, "rotationProof">,
|
||||
): Uint8Array {
|
||||
const values: (number | string)[] = [
|
||||
"elydora-device-revocation-v2",
|
||||
userId,
|
||||
approverDeviceId,
|
||||
revocation.deviceId,
|
||||
revocation.previousKeyId,
|
||||
revocation.previousGeneration,
|
||||
revocation.newKeyId,
|
||||
revocation.newGeneration,
|
||||
revocation.idempotencyKey,
|
||||
revocation.envelopes.length,
|
||||
];
|
||||
for (const item of revocation.envelopes) {
|
||||
values.push(
|
||||
item.recipientDeviceId,
|
||||
item.envelope.version,
|
||||
item.envelope.suite,
|
||||
item.envelope.encapped_key,
|
||||
item.envelope.ciphertext,
|
||||
);
|
||||
}
|
||||
return canonicalBytes(values);
|
||||
}
|
||||
|
||||
export async function pendingDeviceRevocationRequestHash(
|
||||
userId: string,
|
||||
approverDeviceId: string,
|
||||
revocation: PendingDeviceRevocationRequest,
|
||||
): Promise<string> {
|
||||
return sha256Hex(JSON.stringify({
|
||||
version: 2,
|
||||
mode: revocation.mode,
|
||||
user_id: userId,
|
||||
approver_device_id: approverDeviceId,
|
||||
device_id: revocation.deviceId,
|
||||
idempotency_key: revocation.idempotencyKey,
|
||||
pending_revocation_proof: revocation.pendingRevocationProof,
|
||||
}));
|
||||
}
|
||||
|
||||
export function pendingDeviceRevocationProofBytes(
|
||||
userId: string,
|
||||
approverDeviceId: string,
|
||||
revocation: Omit<PendingDeviceRevocationRequest, "pendingRevocationProof">,
|
||||
): Uint8Array {
|
||||
return canonicalBytes([
|
||||
"elydora-pending-device-revocation-v2",
|
||||
userId,
|
||||
approverDeviceId,
|
||||
revocation.deviceId,
|
||||
revocation.idempotencyKey,
|
||||
]);
|
||||
}
|
||||
|
||||
export function rotationEnvelopeIdempotencyKey(
|
||||
userId: string,
|
||||
rotationIdempotencyKey: string,
|
||||
recipientDeviceId: string,
|
||||
): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
return sha256Hex([userId, rotationIdempotencyKey, recipientDeviceId]
|
||||
.map((value) => `${encoder.encode(value).byteLength}:${value}`)
|
||||
.join(""));
|
||||
}
|
||||
|
||||
function revocationEnvelopes(value: unknown, targetDeviceId: string): DeviceRevocationEnvelopeRequest[] {
|
||||
if (!Array.isArray(value) || value.length < 1 || value.length > MAX_ROTATION_ENVELOPES) {
|
||||
throw new DeviceSchemaError("envelopes_invalid");
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const envelopes = value.map((item, index) => {
|
||||
const record = requestRecord(item, `envelopes[${index}]`);
|
||||
assertOnlyFields(record, ["recipient_device_id", "envelope"]);
|
||||
const recipientDeviceId = deviceIdValue(
|
||||
record.recipient_device_id,
|
||||
`envelopes[${index}].recipient_device_id`,
|
||||
);
|
||||
if (recipientDeviceId === targetDeviceId || seen.has(recipientDeviceId)) {
|
||||
throw new DeviceSchemaError("envelope_recipient_invalid");
|
||||
}
|
||||
seen.add(recipientDeviceId);
|
||||
return {
|
||||
recipientDeviceId,
|
||||
envelope: wrappedAccountKey(record.envelope),
|
||||
};
|
||||
});
|
||||
envelopes.sort((left, right) => compareDeviceIds(left.recipientDeviceId, right.recipientDeviceId));
|
||||
return envelopes;
|
||||
}
|
||||
|
||||
function requestRecord(value: unknown, label: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new DeviceSchemaError(`${label}_invalid`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function sha256Hex(value: string): Promise<string> {
|
||||
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
|
||||
return [...new Uint8Array(digest)]
|
||||
.map((byte) => byte.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
function canonicalBytes(values: (number | string)[]): Uint8Array {
|
||||
const encoder = new TextEncoder();
|
||||
return encoder.encode(values.map((value) => {
|
||||
const text = value.toString();
|
||||
return `${encoder.encode(text).byteLength}:${text}`;
|
||||
}).join(""));
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import type { ElyD1PreparedStatement, Env } from "./bindings.js";
|
||||
import {
|
||||
type ApprovedDeviceRevocationRequest,
|
||||
rotationEnvelopeIdempotencyKey,
|
||||
} from "./device_revocation_schema.js";
|
||||
import { DevicePersistenceError } from "./device_schema.js";
|
||||
|
||||
const ROTATION_R2_COUNT_QUERY = `
|
||||
SELECT COUNT(*) AS object_count FROM (
|
||||
SELECT payload_r2_key AS r2_key
|
||||
FROM sync_objects
|
||||
WHERE user_id = ? AND payload_r2_key IS NOT NULL
|
||||
UNION
|
||||
SELECT r2_key FROM sync_snapshots WHERE user_id = ?
|
||||
)
|
||||
`;
|
||||
const ROTATION_RESULT_QUERY = `
|
||||
SELECT
|
||||
rotation.target_device_id, rotation.approver_device_id,
|
||||
rotation.previous_key_id, rotation.previous_generation,
|
||||
rotation.new_key_id, rotation.new_generation, rotation.request_hash,
|
||||
rotation.envelope_count, rotation.r2_object_count, rotation.completed_at,
|
||||
account.current_key_id, account.current_generation,
|
||||
target.approval_status AS target_status, target.revoked_at,
|
||||
(SELECT COUNT(*)
|
||||
FROM better_auth_session AS session
|
||||
INNER JOIN better_auth_session_device_context AS context
|
||||
ON context.session_id = session.id
|
||||
WHERE context.user_id = rotation.user_id
|
||||
AND context.device_id = rotation.target_device_id) AS active_session_count,
|
||||
(SELECT COUNT(*) FROM sync_vault_rotation_envelopes AS item
|
||||
WHERE item.user_id = rotation.user_id
|
||||
AND item.rotation_idempotency_key = rotation.idempotency_key) AS item_count,
|
||||
(SELECT COUNT(*) FROM sync_vault_rotation_r2_objects AS item
|
||||
WHERE item.user_id = rotation.user_id
|
||||
AND item.rotation_idempotency_key = rotation.idempotency_key) AS r2_item_count,
|
||||
(SELECT COUNT(*)
|
||||
FROM sync_vault_rotation_envelopes AS item
|
||||
INNER JOIN sync_vault_envelopes AS envelope
|
||||
ON envelope.user_id = item.user_id
|
||||
AND envelope.recipient_device_id = item.recipient_device_id
|
||||
AND envelope.key_id = rotation.new_key_id
|
||||
AND envelope.generation = rotation.new_generation
|
||||
AND envelope.approver_device_id = rotation.approver_device_id
|
||||
AND envelope.envelope_version = item.envelope_version
|
||||
AND envelope.suite = item.suite
|
||||
AND envelope.encapped_key = item.encapped_key
|
||||
AND envelope.ciphertext = item.ciphertext
|
||||
AND envelope.idempotency_key = item.envelope_idempotency_key
|
||||
WHERE item.user_id = rotation.user_id
|
||||
AND item.rotation_idempotency_key = rotation.idempotency_key) AS persisted_count,
|
||||
(SELECT COUNT(*) FROM audit_events AS audit
|
||||
WHERE audit.event_id = rotation.audit_event_id
|
||||
AND audit.user_id = rotation.user_id
|
||||
AND audit.actor_device_id = rotation.approver_device_id
|
||||
AND audit.event_type = 'device.revoke'
|
||||
AND audit.subject_id = rotation.target_device_id
|
||||
AND audit.outcome = 'success'
|
||||
AND audit.metadata_hash = rotation.request_hash
|
||||
AND audit.created_at = rotation.completed_at) AS audit_count
|
||||
FROM sync_vault_rotations AS rotation
|
||||
LEFT JOIN sync_vault_accounts AS account ON account.user_id = rotation.user_id
|
||||
LEFT JOIN user_devices AS target
|
||||
ON target.user_id = rotation.user_id AND target.device_id = rotation.target_device_id
|
||||
WHERE rotation.user_id = ? AND rotation.idempotency_key = ?
|
||||
`;
|
||||
const ROTATION_INSERT_QUERY = `
|
||||
INSERT INTO sync_vault_rotations (
|
||||
user_id, idempotency_key, audit_event_id, target_device_id, approver_device_id,
|
||||
previous_key_id, previous_generation, new_key_id, new_generation,
|
||||
request_hash, envelope_count, r2_object_count, created_at, completed_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)
|
||||
ON CONFLICT(user_id, idempotency_key) DO NOTHING
|
||||
`;
|
||||
const ROTATION_ENVELOPE_INSERT_QUERY = `
|
||||
INSERT INTO sync_vault_rotation_envelopes (
|
||||
user_id, rotation_idempotency_key, recipient_device_id,
|
||||
envelope_idempotency_key, envelope_version, suite, encapped_key, ciphertext
|
||||
)
|
||||
SELECT ?, ?, ?, ?, ?, ?, ?, ?
|
||||
FROM sync_vault_rotations AS rotation
|
||||
WHERE rotation.user_id = ? AND rotation.idempotency_key = ?
|
||||
AND rotation.completed_at IS NULL
|
||||
AND rotation.target_device_id = ? AND rotation.approver_device_id = ?
|
||||
AND rotation.previous_key_id = ? AND rotation.previous_generation = ?
|
||||
AND rotation.new_key_id = ? AND rotation.new_generation = ?
|
||||
AND rotation.request_hash = ?
|
||||
ON CONFLICT DO NOTHING
|
||||
`;
|
||||
const ROTATION_FINALIZE_QUERY = `
|
||||
UPDATE sync_vault_rotations
|
||||
SET completed_at = ?
|
||||
WHERE user_id = ? AND idempotency_key = ? AND completed_at IS NULL
|
||||
AND target_device_id = ? AND approver_device_id = ?
|
||||
AND previous_key_id = ? AND previous_generation = ?
|
||||
AND new_key_id = ? AND new_generation = ?
|
||||
AND request_hash = ? AND envelope_count = ? AND r2_object_count = ?
|
||||
`;
|
||||
|
||||
interface R2CountRow { object_count: unknown }
|
||||
export interface RotationResultRow {
|
||||
target_device_id: unknown;
|
||||
approver_device_id: unknown;
|
||||
previous_key_id: unknown;
|
||||
previous_generation: unknown;
|
||||
new_key_id: unknown;
|
||||
new_generation: unknown;
|
||||
request_hash: unknown;
|
||||
envelope_count: unknown;
|
||||
r2_object_count: unknown;
|
||||
completed_at: unknown;
|
||||
current_key_id: unknown;
|
||||
current_generation: unknown;
|
||||
target_status: unknown;
|
||||
revoked_at: unknown;
|
||||
active_session_count: unknown;
|
||||
item_count: unknown;
|
||||
r2_item_count: unknown;
|
||||
persisted_count: unknown;
|
||||
audit_count: unknown;
|
||||
}
|
||||
|
||||
export async function rotationR2ObjectCount(env: Env, userId: string): Promise<number> {
|
||||
const rows = await env.ELY_DB.prepare(ROTATION_R2_COUNT_QUERY)
|
||||
.bind(userId, userId)
|
||||
.all<R2CountRow>();
|
||||
const count = rows.results[0]?.object_count;
|
||||
if (rows.results.length !== 1 || typeof count !== "number" || !Number.isSafeInteger(count) || count < 0) {
|
||||
throw new DevicePersistenceError("device_revocation_r2_count_invalid");
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
export function rotationResult(
|
||||
env: Env,
|
||||
userId: string,
|
||||
idempotencyKey: string,
|
||||
): Promise<RotationResultRow | null> {
|
||||
return env.ELY_DB.prepare(ROTATION_RESULT_QUERY)
|
||||
.bind(userId, idempotencyKey)
|
||||
.first<RotationResultRow>();
|
||||
}
|
||||
|
||||
export async function rotationStatements(
|
||||
env: Env,
|
||||
userId: string,
|
||||
approverDeviceId: string,
|
||||
revocation: ApprovedDeviceRevocationRequest,
|
||||
requestHash: string,
|
||||
r2ObjectCount: number,
|
||||
nowSeconds: number,
|
||||
): Promise<ElyD1PreparedStatement[]> {
|
||||
const statements = [env.ELY_DB.prepare(ROTATION_INSERT_QUERY).bind(
|
||||
userId,
|
||||
revocation.idempotencyKey,
|
||||
`device-revoke:${requestHash}`,
|
||||
revocation.deviceId,
|
||||
approverDeviceId,
|
||||
revocation.previousKeyId,
|
||||
revocation.previousGeneration,
|
||||
revocation.newKeyId,
|
||||
revocation.newGeneration,
|
||||
requestHash,
|
||||
revocation.envelopes.length,
|
||||
r2ObjectCount,
|
||||
nowSeconds,
|
||||
)];
|
||||
const envelopeIds = await Promise.all(revocation.envelopes.map((item) =>
|
||||
rotationEnvelopeIdempotencyKey(userId, revocation.idempotencyKey, item.recipientDeviceId)
|
||||
));
|
||||
revocation.envelopes.forEach((item, index) => statements.push(
|
||||
guardedEnvelopeStatement(
|
||||
env, userId, approverDeviceId, revocation, requestHash, envelopeIds[index]!, item,
|
||||
),
|
||||
));
|
||||
statements.push(env.ELY_DB.prepare(ROTATION_FINALIZE_QUERY).bind(
|
||||
nowSeconds,
|
||||
userId,
|
||||
revocation.idempotencyKey,
|
||||
revocation.deviceId,
|
||||
approverDeviceId,
|
||||
revocation.previousKeyId,
|
||||
revocation.previousGeneration,
|
||||
revocation.newKeyId,
|
||||
revocation.newGeneration,
|
||||
requestHash,
|
||||
revocation.envelopes.length,
|
||||
r2ObjectCount,
|
||||
));
|
||||
return statements;
|
||||
}
|
||||
|
||||
function guardedEnvelopeStatement(
|
||||
env: Env,
|
||||
userId: string,
|
||||
approverDeviceId: string,
|
||||
revocation: ApprovedDeviceRevocationRequest,
|
||||
requestHash: string,
|
||||
envelopeId: string,
|
||||
item: ApprovedDeviceRevocationRequest["envelopes"][number],
|
||||
): ElyD1PreparedStatement {
|
||||
return env.ELY_DB.prepare(ROTATION_ENVELOPE_INSERT_QUERY).bind(
|
||||
userId,
|
||||
revocation.idempotencyKey,
|
||||
item.recipientDeviceId,
|
||||
envelopeId,
|
||||
item.envelope.version,
|
||||
item.envelope.suite,
|
||||
item.envelope.encapped_key,
|
||||
item.envelope.ciphertext,
|
||||
userId,
|
||||
revocation.idempotencyKey,
|
||||
revocation.deviceId,
|
||||
approverDeviceId,
|
||||
revocation.previousKeyId,
|
||||
revocation.previousGeneration,
|
||||
revocation.newKeyId,
|
||||
revocation.newGeneration,
|
||||
requestHash,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { Env } from "./bindings.js";
|
||||
import { withAuthenticatedApiControls } from "./api_controls.js";
|
||||
import { issueDeviceRebindChallenge, rebindDeviceSession } from "./device_rebind.js";
|
||||
import { approveDeviceDocument } from "./device_approval.js";
|
||||
import { revokeDeviceDocument } from "./device_revocation.js";
|
||||
import {
|
||||
DeviceConflictError,
|
||||
DevicePermissionError,
|
||||
DevicePersistenceError,
|
||||
DeviceSchemaError,
|
||||
deviceListDocument,
|
||||
registerDeviceDocument,
|
||||
} from "./devices.js";
|
||||
import { jsonResponse } from "./responses.js";
|
||||
|
||||
const NO_STORE = { "Cache-Control": "no-store" } as const;
|
||||
|
||||
export async function handleDeviceRoute(
|
||||
request: Request,
|
||||
env: Env,
|
||||
url: URL,
|
||||
): Promise<Response | null> {
|
||||
if (url.pathname === "/api/devices") {
|
||||
return withAuthenticatedApiControls(request, env, "devices.list", ["GET"], async (context) => {
|
||||
try {
|
||||
return jsonResponse(await deviceListDocument(env, context), 200, NO_STORE);
|
||||
} catch (error) {
|
||||
if (error instanceof DeviceSchemaError) {
|
||||
return jsonResponse({ error: "devices_invalid" }, 500, NO_STORE);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
if (url.pathname === "/api/devices/register") {
|
||||
return withAuthenticatedApiControls(
|
||||
request,
|
||||
env,
|
||||
"devices.register",
|
||||
["POST"],
|
||||
async (context) => {
|
||||
try {
|
||||
return jsonResponse(await registerDeviceDocument(request, env, context), 201, NO_STORE);
|
||||
} catch (error) {
|
||||
if (error instanceof DeviceConflictError) {
|
||||
return jsonResponse({ error: "device_registration_conflict" }, 409, NO_STORE);
|
||||
}
|
||||
if (error instanceof DevicePermissionError) {
|
||||
return jsonResponse({ error: "device_registration_forbidden" }, 403, NO_STORE);
|
||||
}
|
||||
if (error instanceof DeviceSchemaError) {
|
||||
return jsonResponse({ error: "invalid_device_registration" }, 400, NO_STORE);
|
||||
}
|
||||
if (error instanceof DevicePersistenceError) {
|
||||
return jsonResponse({ error: "device_registration_failed" }, 500, NO_STORE);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
if (url.pathname === "/api/devices/rebind/challenge") {
|
||||
return withAuthenticatedApiControls(
|
||||
request,
|
||||
env,
|
||||
"devices.rebind_challenge",
|
||||
["POST"],
|
||||
async (context) => {
|
||||
try {
|
||||
return jsonResponse(await issueDeviceRebindChallenge(request, env, context), 201, NO_STORE);
|
||||
} catch (error) {
|
||||
return deviceRebindErrorResponse(error, "challenge");
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
if (url.pathname === "/api/devices/rebind") {
|
||||
return withAuthenticatedApiControls(
|
||||
request,
|
||||
env,
|
||||
"devices.rebind",
|
||||
["POST"],
|
||||
async (context) => {
|
||||
try {
|
||||
return jsonResponse(await rebindDeviceSession(request, env, context), 200, NO_STORE);
|
||||
} catch (error) {
|
||||
return deviceRebindErrorResponse(error, "rebind");
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
if (url.pathname === "/api/devices/approve") {
|
||||
return withAuthenticatedApiControls(
|
||||
request,
|
||||
env,
|
||||
"devices.approve",
|
||||
["POST"],
|
||||
async (context) => {
|
||||
try {
|
||||
return jsonResponse(await approveDeviceDocument(request, env, context), 200, NO_STORE);
|
||||
} catch (error) {
|
||||
if (error instanceof DeviceConflictError) {
|
||||
return jsonResponse({ error: "device_approval_conflict" }, 409, NO_STORE);
|
||||
}
|
||||
if (error instanceof DevicePermissionError) {
|
||||
return jsonResponse({ error: "device_approval_forbidden" }, 403, NO_STORE);
|
||||
}
|
||||
if (error instanceof DeviceSchemaError) {
|
||||
return jsonResponse({ error: "invalid_device_approval" }, 400, NO_STORE);
|
||||
}
|
||||
if (error instanceof DevicePersistenceError) {
|
||||
return jsonResponse({ error: "device_approval_failed" }, 500, NO_STORE);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
if (url.pathname === "/api/devices/revoke") {
|
||||
return withAuthenticatedApiControls(
|
||||
request,
|
||||
env,
|
||||
"devices.revoke",
|
||||
["POST"],
|
||||
async (context) => {
|
||||
try {
|
||||
return jsonResponse(await revokeDeviceDocument(request, env, context), 200, NO_STORE);
|
||||
} catch (error) {
|
||||
if (error instanceof DeviceConflictError) {
|
||||
return jsonResponse({ error: "device_revocation_conflict" }, 409, NO_STORE);
|
||||
}
|
||||
if (error instanceof DevicePermissionError) {
|
||||
return jsonResponse({ error: "device_revocation_forbidden" }, 403, NO_STORE);
|
||||
}
|
||||
if (error instanceof DeviceSchemaError) {
|
||||
return jsonResponse({ error: "invalid_device_revocation" }, 400, NO_STORE);
|
||||
}
|
||||
if (error instanceof DevicePersistenceError) {
|
||||
return jsonResponse({ error: "device_revocation_failed" }, 500, NO_STORE);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function deviceRebindErrorResponse(error: unknown, operation: "challenge" | "rebind"): Response {
|
||||
if (error instanceof DeviceConflictError) {
|
||||
return jsonResponse({ error: "device_rebind_conflict" }, 409, NO_STORE);
|
||||
}
|
||||
if (error instanceof DevicePermissionError) {
|
||||
return jsonResponse({ error: "device_rebind_forbidden" }, 403, NO_STORE);
|
||||
}
|
||||
if (error instanceof DeviceSchemaError) {
|
||||
return jsonResponse({ error: `invalid_device_${operation}` }, 400, NO_STORE);
|
||||
}
|
||||
if (error instanceof DevicePersistenceError) {
|
||||
return jsonResponse({ error: "device_rebind_failed" }, 500, NO_STORE);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
+115
-56
@@ -1,7 +1,13 @@
|
||||
import type { AuthContext } from "./auth.js";
|
||||
import {
|
||||
type WrappedAccountKeyDocument,
|
||||
SyncVaultRequestError,
|
||||
parseWrappedAccountKey,
|
||||
} from "./sync_vault.js";
|
||||
|
||||
const DEVICE_ID_PATTERN = /^[a-zA-Z0-9._:-]{3,128}$/;
|
||||
const PUBLIC_KEY_PATTERN = /^[a-fA-F0-9]{64,256}$/;
|
||||
const PUBLIC_KEY_PATTERN = /^[a-f0-9]{64}$/;
|
||||
const SIGNATURE_PATTERN = /^[a-f0-9]{128}$/;
|
||||
const DEVICE_TEXT_PATTERN = /^[^\p{Cc}\p{Cs}]{1,128}$/u;
|
||||
const APPROVAL_STATUS = new Set(["pending", "approved", "revoked"]);
|
||||
|
||||
@@ -12,7 +18,7 @@ export interface DeviceListDocument {
|
||||
}
|
||||
|
||||
export interface DeviceRegistrationDocument {
|
||||
version: 1;
|
||||
version: 2;
|
||||
user_id: string;
|
||||
device: DeviceDocument;
|
||||
}
|
||||
@@ -25,17 +31,32 @@ export interface DeviceApprovalDocument {
|
||||
device: DeviceDocument;
|
||||
}
|
||||
|
||||
export interface DeviceRevocationDocument {
|
||||
version: 1;
|
||||
interface DeviceRevocationDocumentBase {
|
||||
version: 2;
|
||||
user_id: string;
|
||||
revoked_by_device_id: string;
|
||||
revoked_at: number;
|
||||
device: DeviceDocument;
|
||||
}
|
||||
|
||||
export interface ApprovedDeviceRevocationDocument extends DeviceRevocationDocumentBase {
|
||||
mode: "approved_rotate";
|
||||
key_id: string;
|
||||
generation: number;
|
||||
}
|
||||
|
||||
export interface PendingDeviceRevocationDocument extends DeviceRevocationDocumentBase {
|
||||
mode: "pending_revoke";
|
||||
}
|
||||
|
||||
export type DeviceRevocationDocument =
|
||||
| ApprovedDeviceRevocationDocument
|
||||
| PendingDeviceRevocationDocument;
|
||||
|
||||
export interface DeviceDocument {
|
||||
device_id: string;
|
||||
public_key: string;
|
||||
wrapping_public_key?: string;
|
||||
device_name: string;
|
||||
platform: string;
|
||||
approval_status: "pending" | "approved" | "revoked";
|
||||
@@ -49,6 +70,7 @@ export interface DeviceDocument {
|
||||
export interface DeviceRow {
|
||||
device_id: unknown;
|
||||
public_key: unknown;
|
||||
wrapping_public_key?: unknown;
|
||||
device_name: unknown;
|
||||
platform: unknown;
|
||||
approval_status: unknown;
|
||||
@@ -61,14 +83,22 @@ export interface DeviceRow {
|
||||
export interface DeviceRegistrationRequest {
|
||||
deviceId: string;
|
||||
publicKey: string;
|
||||
wrappingPublicKey: string;
|
||||
registrationProof: string;
|
||||
deviceName: string;
|
||||
platform: string;
|
||||
idempotencyKey: string;
|
||||
}
|
||||
|
||||
export type DeviceApprovalRequest = { deviceId: string; idempotencyKey: string };
|
||||
export type DeviceRevocationRequest = { deviceId: string; idempotencyKey: string };
|
||||
|
||||
export interface DeviceApprovalRequest {
|
||||
deviceId: string;
|
||||
keyId: string;
|
||||
generation: number;
|
||||
envelope: WrappedAccountKeyDocument;
|
||||
idempotencyKey: string;
|
||||
proofCreatedAt: number;
|
||||
approvalProof: string;
|
||||
}
|
||||
export interface DeviceApprovalRow {
|
||||
device_id: unknown;
|
||||
requester_device_id: unknown;
|
||||
@@ -76,13 +106,6 @@ export interface DeviceApprovalRow {
|
||||
decided_at: unknown;
|
||||
}
|
||||
|
||||
export interface DeviceRevocationRow {
|
||||
actor_device_id: unknown;
|
||||
subject_id: unknown;
|
||||
outcome: unknown;
|
||||
created_at: unknown;
|
||||
}
|
||||
|
||||
type DeviceRequestBody = Record<string, unknown>;
|
||||
|
||||
export class DeviceSchemaError extends Error {
|
||||
@@ -121,17 +144,21 @@ export async function deviceRegistrationRequest(
|
||||
"version",
|
||||
"device_id",
|
||||
"public_key",
|
||||
"wrapping_public_key",
|
||||
"registration_proof",
|
||||
"device_name",
|
||||
"platform",
|
||||
"idempotency_key",
|
||||
]);
|
||||
if (value.version !== 1) {
|
||||
if (value.version !== 2) {
|
||||
throw new DeviceSchemaError("device_registration_version_invalid");
|
||||
}
|
||||
|
||||
return {
|
||||
deviceId: deviceIdValue(value.device_id, "device_id"),
|
||||
publicKey: publicKeyValue(value.public_key),
|
||||
publicKey: publicKeyValue(value.public_key, "public_key"),
|
||||
wrappingPublicKey: publicKeyValue(value.wrapping_public_key, "wrapping_public_key"),
|
||||
registrationProof: signatureValue(value.registration_proof, "registration_proof"),
|
||||
deviceName: deviceText(value.device_name, "device_name"),
|
||||
platform: deviceText(value.platform, "platform"),
|
||||
idempotencyKey: idempotencyKeyValue(value.idempotency_key),
|
||||
@@ -140,27 +167,28 @@ export async function deviceRegistrationRequest(
|
||||
|
||||
export async function deviceApprovalRequest(request: Request): Promise<DeviceApprovalRequest> {
|
||||
const value = await deviceRequestBody(request, "device_approval");
|
||||
assertOnlyFields(value, ["version", "device_id", "idempotency_key"]);
|
||||
if (value.version !== 1) {
|
||||
assertOnlyFields(value, [
|
||||
"version",
|
||||
"device_id",
|
||||
"key_id",
|
||||
"generation",
|
||||
"envelope",
|
||||
"idempotency_key",
|
||||
"proof_created_at",
|
||||
"approval_proof",
|
||||
]);
|
||||
if (value.version !== 2) {
|
||||
throw new DeviceSchemaError("device_approval_version_invalid");
|
||||
}
|
||||
|
||||
return {
|
||||
deviceId: deviceIdValue(value.device_id, "device_id"),
|
||||
keyId: keyIdValue(value.key_id),
|
||||
generation: positiveInteger(value.generation, "generation"),
|
||||
envelope: wrappedAccountKey(value.envelope),
|
||||
idempotencyKey: idempotencyKeyValue(value.idempotency_key),
|
||||
};
|
||||
}
|
||||
|
||||
export async function deviceRevocationRequest(request: Request): Promise<DeviceRevocationRequest> {
|
||||
const value = await deviceRequestBody(request, "device_revocation");
|
||||
assertOnlyFields(value, ["version", "device_id", "idempotency_key"]);
|
||||
if (value.version !== 1) {
|
||||
throw new DeviceSchemaError("device_revocation_version_invalid");
|
||||
}
|
||||
|
||||
return {
|
||||
deviceId: deviceIdValue(value.device_id, "device_id"),
|
||||
idempotencyKey: idempotencyKeyValue(value.idempotency_key),
|
||||
proofCreatedAt: positiveInteger(value.proof_created_at, "proof_created_at"),
|
||||
approvalProof: signatureValue(value.approval_proof, "approval_proof"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -182,24 +210,6 @@ export function approvedDeviceDocument(
|
||||
};
|
||||
}
|
||||
|
||||
export function revokedDeviceDocument(
|
||||
userId: string,
|
||||
revokedByDeviceId: string,
|
||||
row: DeviceRow,
|
||||
): DeviceRevocationDocument {
|
||||
const device = deviceDocument(row, revokedByDeviceId);
|
||||
if (device.approval_status !== "revoked" || device.revoked_at === null) {
|
||||
throw new DevicePersistenceError("device_revocation_missing");
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
user_id: userId,
|
||||
revoked_by_device_id: revokedByDeviceId,
|
||||
revoked_at: device.revoked_at,
|
||||
device,
|
||||
};
|
||||
}
|
||||
|
||||
export function currentDeviceId(context: AuthContext): string {
|
||||
if (context.deviceId === undefined) {
|
||||
throw new DevicePermissionError("device_context_required");
|
||||
@@ -212,9 +222,16 @@ export function deviceDocument(
|
||||
currentDeviceIdValue: string | undefined,
|
||||
): DeviceDocument {
|
||||
const deviceId = deviceIdValue(row.device_id, "device_id");
|
||||
const wrappingPublicKey = optionalPublicKeyValue(
|
||||
row.wrapping_public_key,
|
||||
"wrapping_public_key",
|
||||
);
|
||||
return {
|
||||
device_id: deviceId,
|
||||
public_key: publicKeyValue(row.public_key),
|
||||
public_key: publicKeyValue(row.public_key, "public_key"),
|
||||
...(wrappingPublicKey === undefined
|
||||
? {}
|
||||
: { wrapping_public_key: wrappingPublicKey }),
|
||||
device_name: deviceText(row.device_name, "device_name"),
|
||||
platform: deviceText(row.platform, "platform"),
|
||||
approval_status: approvalStatus(row.approval_status),
|
||||
@@ -240,11 +257,25 @@ export function timestamp(value: unknown, label: string): number {
|
||||
return value;
|
||||
}
|
||||
|
||||
function publicKeyValue(value: unknown): string {
|
||||
export function publicKeyValue(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !PUBLIC_KEY_PATTERN.test(value)) {
|
||||
throw new DeviceSchemaError("public_key_invalid");
|
||||
throw new DeviceSchemaError(`${label}_invalid`);
|
||||
}
|
||||
return value.toLowerCase();
|
||||
return value;
|
||||
}
|
||||
|
||||
export function signatureValue(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !SIGNATURE_PATTERN.test(value)) {
|
||||
throw new DeviceSchemaError(`${label}_invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalPublicKeyValue(value: unknown, label: string): string | undefined {
|
||||
if (value === undefined || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
return publicKeyValue(value, label);
|
||||
}
|
||||
|
||||
function deviceText(value: unknown, label: string): string {
|
||||
@@ -265,13 +296,38 @@ function approvalStatus(value: unknown): DeviceDocument["approval_status"] {
|
||||
return value as DeviceDocument["approval_status"];
|
||||
}
|
||||
|
||||
function idempotencyKeyValue(value: unknown): string {
|
||||
export function idempotencyKeyValue(value: unknown): string {
|
||||
if (typeof value !== "string" || !/^[a-zA-Z0-9._:-]{16,128}$/.test(value)) {
|
||||
throw new DeviceSchemaError("idempotency_key_invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function keyIdValue(value: unknown): string {
|
||||
if (typeof value !== "string" || !/^[a-f0-9]{64}$/.test(value)) {
|
||||
throw new DeviceSchemaError("key_id_invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function positiveInteger(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1) {
|
||||
throw new DeviceSchemaError(`${label}_invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function wrappedAccountKey(value: unknown): WrappedAccountKeyDocument {
|
||||
try {
|
||||
return parseWrappedAccountKey(value);
|
||||
} catch (error) {
|
||||
if (error instanceof SyncVaultRequestError) {
|
||||
throw new DeviceSchemaError("envelope_invalid");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function nullableTimestamp(value: unknown, label: string): number | null {
|
||||
if (value === null) {
|
||||
return null;
|
||||
@@ -279,7 +335,7 @@ function nullableTimestamp(value: unknown, label: string): number | null {
|
||||
return timestamp(value, label);
|
||||
}
|
||||
|
||||
function assertOnlyFields(value: DeviceRequestBody, fields: string[]): void {
|
||||
export function assertOnlyFields(value: DeviceRequestBody, fields: string[]): void {
|
||||
const allowed = new Set(fields);
|
||||
for (const field of Object.keys(value)) {
|
||||
if (!allowed.has(field)) {
|
||||
@@ -288,7 +344,10 @@ function assertOnlyFields(value: DeviceRequestBody, fields: string[]): void {
|
||||
}
|
||||
}
|
||||
|
||||
async function deviceRequestBody(request: Request, label: string): Promise<DeviceRequestBody> {
|
||||
export async function deviceRequestBody(
|
||||
request: Request,
|
||||
label: string,
|
||||
): Promise<DeviceRequestBody> {
|
||||
let value: unknown;
|
||||
try {
|
||||
value = await request.json();
|
||||
|
||||
+122
-339
@@ -1,28 +1,17 @@
|
||||
import type { AuthContext } from "./auth.js";
|
||||
import type { Env } from "./bindings.js";
|
||||
import { assertDeviceRegistrationProof } from "./device_registration_proof.js";
|
||||
import {
|
||||
type DeviceApprovalDocument,
|
||||
type DeviceApprovalRequest,
|
||||
type DeviceApprovalRow,
|
||||
type DeviceListDocument,
|
||||
type DeviceRegistrationDocument,
|
||||
type DeviceRegistrationRequest,
|
||||
type DeviceRevocationDocument,
|
||||
type DeviceRevocationRequest,
|
||||
type DeviceRevocationRow,
|
||||
type DeviceRow,
|
||||
DeviceConflictError,
|
||||
DevicePermissionError,
|
||||
DevicePersistenceError,
|
||||
approvedDeviceDocument,
|
||||
currentDeviceId,
|
||||
deviceApprovalRequest,
|
||||
deviceDocument,
|
||||
deviceIdValue,
|
||||
deviceRegistrationRequest,
|
||||
deviceRevocationRequest,
|
||||
revokedDeviceDocument,
|
||||
timestamp,
|
||||
} from "./device_schema.js";
|
||||
|
||||
export {
|
||||
@@ -32,149 +21,87 @@ export {
|
||||
DeviceSchemaError,
|
||||
} from "./device_schema.js";
|
||||
|
||||
const UNBOUND_SESSION_MAX_AGE_SECONDS = 10 * 60;
|
||||
const SESSION_CLOCK_SKEW_SECONDS = 30;
|
||||
|
||||
const DEVICE_COLUMNS = `
|
||||
device.device_id,
|
||||
device.public_key,
|
||||
device.device_name,
|
||||
device.platform,
|
||||
device.approval_status,
|
||||
device.created_at,
|
||||
device.approved_at,
|
||||
device.last_active_at,
|
||||
device.revoked_at,
|
||||
keys.wrapping_public_key
|
||||
`;
|
||||
const DEVICE_FROM = `
|
||||
FROM user_devices AS device
|
||||
LEFT JOIN user_device_keys AS keys
|
||||
ON keys.user_id = device.user_id AND keys.device_id = device.device_id
|
||||
`;
|
||||
const DEVICE_LIST_QUERY = `
|
||||
SELECT
|
||||
device_id,
|
||||
public_key,
|
||||
device_name,
|
||||
platform,
|
||||
approval_status,
|
||||
created_at,
|
||||
approved_at,
|
||||
last_active_at,
|
||||
revoked_at
|
||||
FROM user_devices
|
||||
WHERE user_id = ?
|
||||
SELECT ${DEVICE_COLUMNS}
|
||||
${DEVICE_FROM}
|
||||
WHERE device.user_id = ?
|
||||
ORDER BY
|
||||
revoked_at IS NOT NULL,
|
||||
COALESCE(last_active_at, approved_at, created_at) DESC,
|
||||
device_id ASC
|
||||
device.revoked_at IS NOT NULL,
|
||||
COALESCE(device.last_active_at, device.approved_at, device.created_at) DESC,
|
||||
device.device_id ASC
|
||||
`;
|
||||
const DEVICE_REGISTER_QUERY = `
|
||||
WITH registration AS (
|
||||
SELECT CASE
|
||||
WHEN NOT EXISTS (SELECT 1 FROM user_devices WHERE user_id = ?) THEN 'approved'
|
||||
ELSE 'pending'
|
||||
END AS approval_status
|
||||
)
|
||||
INSERT INTO user_devices (
|
||||
user_id,
|
||||
device_id,
|
||||
public_key,
|
||||
device_name,
|
||||
platform,
|
||||
approval_status,
|
||||
created_at,
|
||||
approved_at,
|
||||
last_active_at,
|
||||
revoked_at,
|
||||
idempotency_key
|
||||
) VALUES (?, ?, ?, ?, ?, 'pending', ?, NULL, ?, NULL, ?)
|
||||
user_id, device_id, public_key, device_name, platform,
|
||||
approval_status, created_at, approved_at, last_active_at, revoked_at, idempotency_key
|
||||
)
|
||||
SELECT ?, ?, ?, ?, ?, approval_status, ?,
|
||||
CASE WHEN approval_status = 'approved' THEN ? ELSE NULL END,
|
||||
?, NULL, ?
|
||||
FROM registration
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM better_auth_session
|
||||
WHERE id = ? AND userId = ?
|
||||
)
|
||||
ON CONFLICT DO NOTHING
|
||||
`;
|
||||
const DEVICE_KEYS_INSERT_QUERY = `
|
||||
INSERT INTO user_device_keys (
|
||||
user_id, device_id, signing_public_key,
|
||||
wrapping_public_key, key_protocol_version, created_at
|
||||
)
|
||||
SELECT ?, ?, ?, ?, 2, ?
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM user_devices
|
||||
WHERE user_id = ? AND device_id = ? AND public_key = ?
|
||||
AND device_name = ? AND platform = ? AND idempotency_key = ?
|
||||
AND revoked_at IS NULL
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM better_auth_session
|
||||
WHERE id = ? AND userId = ?
|
||||
)
|
||||
ON CONFLICT DO NOTHING
|
||||
`;
|
||||
const DEVICE_BY_IDEMPOTENCY_KEY_QUERY = `
|
||||
SELECT
|
||||
device_id,
|
||||
public_key,
|
||||
device_name,
|
||||
platform,
|
||||
approval_status,
|
||||
created_at,
|
||||
approved_at,
|
||||
last_active_at,
|
||||
revoked_at
|
||||
FROM user_devices
|
||||
WHERE user_id = ? AND idempotency_key = ?
|
||||
SELECT ${DEVICE_COLUMNS}
|
||||
${DEVICE_FROM}
|
||||
WHERE device.user_id = ? AND device.idempotency_key = ?
|
||||
`;
|
||||
const DEVICE_BY_ID_QUERY = `
|
||||
SELECT
|
||||
device_id,
|
||||
public_key,
|
||||
device_name,
|
||||
platform,
|
||||
approval_status,
|
||||
created_at,
|
||||
approved_at,
|
||||
last_active_at,
|
||||
revoked_at
|
||||
FROM user_devices
|
||||
WHERE user_id = ? AND device_id = ?
|
||||
`;
|
||||
const APPROVED_DEVICE_QUERY = `
|
||||
SELECT
|
||||
device_id,
|
||||
public_key,
|
||||
device_name,
|
||||
platform,
|
||||
approval_status,
|
||||
created_at,
|
||||
approved_at,
|
||||
last_active_at,
|
||||
revoked_at
|
||||
FROM user_devices
|
||||
WHERE user_id = ? AND device_id = ? AND approval_status = 'approved' AND revoked_at IS NULL
|
||||
`;
|
||||
const DEVICE_APPROVAL_BY_IDEMPOTENCY_KEY_QUERY = `
|
||||
SELECT
|
||||
device_id,
|
||||
requester_device_id,
|
||||
status,
|
||||
decided_at
|
||||
FROM device_approvals
|
||||
WHERE user_id = ? AND idempotency_key = ?
|
||||
`;
|
||||
const DEVICE_APPROVAL_INSERT_QUERY = `
|
||||
INSERT INTO device_approvals (
|
||||
user_id,
|
||||
approval_id,
|
||||
device_id,
|
||||
requester_device_id,
|
||||
status,
|
||||
requested_at,
|
||||
decided_at,
|
||||
expires_at,
|
||||
idempotency_key
|
||||
) VALUES (?, ?, ?, ?, 'approved', ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id, idempotency_key) DO NOTHING
|
||||
`;
|
||||
const DEVICE_APPROVE_QUERY = `
|
||||
UPDATE user_devices
|
||||
SET
|
||||
approval_status = 'approved',
|
||||
approved_at = COALESCE(approved_at, ?),
|
||||
last_active_at = ?
|
||||
WHERE user_id = ? AND device_id = ? AND approval_status = 'pending' AND revoked_at IS NULL
|
||||
`;
|
||||
const DEVICE_REVOCATION_BY_IDEMPOTENCY_KEY_QUERY = `
|
||||
SELECT
|
||||
actor_device_id,
|
||||
subject_id,
|
||||
outcome,
|
||||
created_at
|
||||
FROM audit_events
|
||||
WHERE user_id = ? AND event_id = ? AND event_type = 'device.revoke'
|
||||
`;
|
||||
const DEVICE_REVOCATION_INSERT_QUERY = `
|
||||
INSERT INTO audit_events (
|
||||
event_id,
|
||||
user_id,
|
||||
actor_device_id,
|
||||
event_type,
|
||||
subject_type,
|
||||
subject_id,
|
||||
outcome,
|
||||
metadata_hash,
|
||||
created_at
|
||||
) VALUES (?, ?, ?, 'device.revoke', 'device', ?, 'success', NULL, ?)
|
||||
ON CONFLICT(event_id) DO NOTHING
|
||||
`;
|
||||
const DEVICE_REVOKE_QUERY = `
|
||||
UPDATE user_devices
|
||||
SET
|
||||
approval_status = 'revoked',
|
||||
revoked_at = COALESCE(revoked_at, ?)
|
||||
WHERE user_id = ? AND device_id = ? AND revoked_at IS NULL
|
||||
SELECT ${DEVICE_COLUMNS}
|
||||
${DEVICE_FROM}
|
||||
WHERE device.user_id = ? AND device.device_id = ?
|
||||
`;
|
||||
const SESSION_DEVICE_CONTEXT_UPSERT_QUERY = `
|
||||
INSERT INTO better_auth_session_device_context (
|
||||
session_id,
|
||||
user_id,
|
||||
device_id,
|
||||
updated_at
|
||||
session_id, user_id, device_id, updated_at
|
||||
)
|
||||
SELECT ?, ?, ?, ?
|
||||
WHERE EXISTS (
|
||||
@@ -182,10 +109,7 @@ const SESSION_DEVICE_CONTEXT_UPSERT_QUERY = `
|
||||
FROM better_auth_session
|
||||
WHERE id = ? AND userId = ?
|
||||
)
|
||||
ON CONFLICT(session_id) DO UPDATE SET
|
||||
user_id = excluded.user_id,
|
||||
device_id = excluded.device_id,
|
||||
updated_at = excluded.updated_at
|
||||
ON CONFLICT(session_id) DO NOTHING
|
||||
`;
|
||||
|
||||
export async function deviceListDocument(
|
||||
@@ -210,9 +134,12 @@ export async function registerDeviceDocument(
|
||||
if (context.deviceId !== undefined && context.deviceId !== registration.deviceId) {
|
||||
throw new DevicePermissionError("device_context_mismatch");
|
||||
}
|
||||
await assertDeviceRegistrationProof(registration);
|
||||
assertFreshUnboundSession(context, nowSeconds);
|
||||
|
||||
const writeResult = await env.ELY_DB.prepare(DEVICE_REGISTER_QUERY)
|
||||
.bind(
|
||||
const [deviceWriteResult, keyWriteResult] = await env.ELY_DB.batch([
|
||||
env.ELY_DB.prepare(DEVICE_REGISTER_QUERY).bind(
|
||||
context.userId,
|
||||
context.userId,
|
||||
registration.deviceId,
|
||||
registration.publicKey,
|
||||
@@ -220,11 +147,30 @@ export async function registerDeviceDocument(
|
||||
registration.platform,
|
||||
nowSeconds,
|
||||
nowSeconds,
|
||||
nowSeconds,
|
||||
registration.idempotencyKey,
|
||||
)
|
||||
.run();
|
||||
const insertedRows = changedRowCount(writeResult);
|
||||
if (insertedRows > 1) {
|
||||
context.sessionId,
|
||||
context.userId,
|
||||
),
|
||||
env.ELY_DB.prepare(DEVICE_KEYS_INSERT_QUERY).bind(
|
||||
context.userId,
|
||||
registration.deviceId,
|
||||
registration.publicKey,
|
||||
registration.wrappingPublicKey,
|
||||
nowSeconds,
|
||||
context.userId,
|
||||
registration.deviceId,
|
||||
registration.publicKey,
|
||||
registration.deviceName,
|
||||
registration.platform,
|
||||
registration.idempotencyKey,
|
||||
context.sessionId,
|
||||
context.userId,
|
||||
),
|
||||
]);
|
||||
const insertedRows = changedRowCount(deviceWriteResult, "device_registration");
|
||||
const insertedKeyRows = changedRowCount(keyWriteResult, "device_key_registration");
|
||||
if (insertedRows > 1 || insertedKeyRows > 1 || insertedRows !== insertedKeyRows) {
|
||||
throw new DevicePersistenceError("device_registration_write_count_invalid");
|
||||
}
|
||||
const row = await env.ELY_DB.prepare(DEVICE_BY_IDEMPOTENCY_KEY_QUERY)
|
||||
@@ -249,23 +195,36 @@ export async function registerDeviceDocument(
|
||||
throw new DeviceConflictError("device_registration_conflict");
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
version: 2,
|
||||
user_id: context.userId,
|
||||
device,
|
||||
};
|
||||
}
|
||||
if (device.approval_status !== "pending" || device.revoked_at !== null) {
|
||||
if (device.revoked_at !== null) {
|
||||
throw new DevicePersistenceError("device_registration_state_invalid");
|
||||
}
|
||||
await bindSessionDeviceContext(env, context, device.device_id, nowSeconds);
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
version: 2,
|
||||
user_id: context.userId,
|
||||
device,
|
||||
};
|
||||
}
|
||||
|
||||
function assertFreshUnboundSession(context: AuthContext, nowSeconds: number): void {
|
||||
if (context.deviceId !== undefined) {
|
||||
return;
|
||||
}
|
||||
const createdAtSeconds = Math.floor(Date.parse(context.createdAt) / 1000);
|
||||
if (
|
||||
createdAtSeconds < nowSeconds - UNBOUND_SESSION_MAX_AGE_SECONDS ||
|
||||
createdAtSeconds > nowSeconds + SESSION_CLOCK_SKEW_SECONDS
|
||||
) {
|
||||
throw new DevicePermissionError("fresh_session_required");
|
||||
}
|
||||
}
|
||||
|
||||
function registrationMatches(
|
||||
device: DeviceRegistrationDocument["device"],
|
||||
registration: DeviceRegistrationRequest,
|
||||
@@ -273,22 +232,23 @@ function registrationMatches(
|
||||
return (
|
||||
device.device_id === registration.deviceId &&
|
||||
device.public_key === registration.publicKey &&
|
||||
device.wrapping_public_key === registration.wrappingPublicKey &&
|
||||
device.device_name === registration.deviceName &&
|
||||
device.platform === registration.platform
|
||||
);
|
||||
}
|
||||
|
||||
function changedRowCount(result: unknown): number {
|
||||
function changedRowCount(result: unknown, label: string): number {
|
||||
if (typeof result !== "object" || result === null || !("meta" in result)) {
|
||||
throw new DevicePersistenceError("device_registration_write_result_invalid");
|
||||
throw new DevicePersistenceError(`${label}_write_result_invalid`);
|
||||
}
|
||||
const meta = result.meta;
|
||||
if (typeof meta !== "object" || meta === null || !("changes" in meta)) {
|
||||
throw new DevicePersistenceError("device_registration_write_result_invalid");
|
||||
throw new DevicePersistenceError(`${label}_write_result_invalid`);
|
||||
}
|
||||
const changes = meta.changes;
|
||||
if (typeof changes !== "number" || !Number.isSafeInteger(changes) || changes < 0) {
|
||||
throw new DevicePersistenceError("device_registration_write_result_invalid");
|
||||
throw new DevicePersistenceError(`${label}_write_result_invalid`);
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
@@ -299,187 +259,10 @@ async function bindSessionDeviceContext(
|
||||
deviceId: string,
|
||||
nowSeconds: number,
|
||||
): Promise<void> {
|
||||
await env.ELY_DB.prepare(SESSION_DEVICE_CONTEXT_UPSERT_QUERY)
|
||||
const result = await env.ELY_DB.prepare(SESSION_DEVICE_CONTEXT_UPSERT_QUERY)
|
||||
.bind(context.sessionId, context.userId, deviceId, nowSeconds, context.sessionId, context.userId)
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function approveDeviceDocument(
|
||||
request: Request,
|
||||
env: Env,
|
||||
context: AuthContext,
|
||||
nowSeconds = Math.floor(Date.now() / 1000),
|
||||
): Promise<DeviceApprovalDocument> {
|
||||
const approval = await deviceApprovalRequest(request);
|
||||
const requesterDeviceId = currentDeviceId(context);
|
||||
if (requesterDeviceId === approval.deviceId) {
|
||||
throw new DevicePermissionError("device_self_approval_forbidden");
|
||||
}
|
||||
|
||||
await assertApprovedRequester(env, context.userId, requesterDeviceId);
|
||||
const existingApproval = await env.ELY_DB.prepare(DEVICE_APPROVAL_BY_IDEMPOTENCY_KEY_QUERY)
|
||||
.bind(context.userId, approval.idempotencyKey)
|
||||
.first<DeviceApprovalRow>();
|
||||
if (existingApproval !== null) {
|
||||
return existingApprovalDocument(env, context, approval, requesterDeviceId, existingApproval);
|
||||
}
|
||||
|
||||
const pendingDevice = await deviceRowById(env, context.userId, approval.deviceId);
|
||||
if (pendingDevice === null) {
|
||||
throw new DevicePermissionError("device_not_found");
|
||||
}
|
||||
const pendingDocument = deviceDocument(pendingDevice, requesterDeviceId);
|
||||
if (pendingDocument.approval_status !== "pending" || pendingDocument.revoked_at !== null) {
|
||||
throw new DevicePermissionError("device_not_pending");
|
||||
}
|
||||
|
||||
await env.ELY_DB.batch([
|
||||
env.ELY_DB.prepare(DEVICE_APPROVAL_INSERT_QUERY).bind(
|
||||
context.userId,
|
||||
approval.idempotencyKey,
|
||||
approval.deviceId,
|
||||
requesterDeviceId,
|
||||
nowSeconds,
|
||||
nowSeconds,
|
||||
nowSeconds,
|
||||
approval.idempotencyKey,
|
||||
),
|
||||
env.ELY_DB.prepare(DEVICE_APPROVE_QUERY).bind(
|
||||
nowSeconds,
|
||||
nowSeconds,
|
||||
context.userId,
|
||||
approval.deviceId,
|
||||
),
|
||||
]);
|
||||
|
||||
const approvedDevice = await deviceRowById(env, context.userId, approval.deviceId);
|
||||
if (approvedDevice === null) {
|
||||
throw new DevicePersistenceError("device_approval_missing");
|
||||
}
|
||||
return approvedDeviceDocument(context.userId, requesterDeviceId, approvedDevice);
|
||||
}
|
||||
|
||||
export async function revokeDeviceDocument(
|
||||
request: Request,
|
||||
env: Env,
|
||||
context: AuthContext,
|
||||
nowSeconds = Math.floor(Date.now() / 1000),
|
||||
): Promise<DeviceRevocationDocument> {
|
||||
const revocation = await deviceRevocationRequest(request);
|
||||
const requesterDeviceId = currentDeviceId(context);
|
||||
if (requesterDeviceId === revocation.deviceId) {
|
||||
throw new DevicePermissionError("device_self_revocation_forbidden");
|
||||
}
|
||||
|
||||
const revocationEventId = deviceRevocationEventId(context.userId, revocation.idempotencyKey);
|
||||
await assertApprovedRequester(env, context.userId, requesterDeviceId);
|
||||
const existingRevocation = await env.ELY_DB.prepare(DEVICE_REVOCATION_BY_IDEMPOTENCY_KEY_QUERY)
|
||||
.bind(context.userId, revocationEventId)
|
||||
.first<DeviceRevocationRow>();
|
||||
if (existingRevocation !== null) {
|
||||
return existingRevocationDocument(env, context, revocation, requesterDeviceId, existingRevocation);
|
||||
}
|
||||
|
||||
const targetDevice = await deviceRowById(env, context.userId, revocation.deviceId);
|
||||
if (targetDevice === null) {
|
||||
throw new DevicePermissionError("device_not_found");
|
||||
}
|
||||
const targetDocument = deviceDocument(targetDevice, requesterDeviceId);
|
||||
if (targetDocument.revoked_at !== null) {
|
||||
throw new DevicePermissionError("device_already_revoked");
|
||||
}
|
||||
|
||||
await env.ELY_DB.batch([
|
||||
env.ELY_DB.prepare(DEVICE_REVOCATION_INSERT_QUERY).bind(
|
||||
revocationEventId,
|
||||
context.userId,
|
||||
requesterDeviceId,
|
||||
revocation.deviceId,
|
||||
nowSeconds,
|
||||
),
|
||||
env.ELY_DB.prepare(DEVICE_REVOKE_QUERY).bind(nowSeconds, context.userId, revocation.deviceId),
|
||||
]);
|
||||
|
||||
const revokedDevice = await deviceRowById(env, context.userId, revocation.deviceId);
|
||||
if (revokedDevice === null) {
|
||||
throw new DevicePersistenceError("device_revocation_missing");
|
||||
}
|
||||
return revokedDeviceDocument(context.userId, requesterDeviceId, revokedDevice);
|
||||
}
|
||||
|
||||
function deviceRevocationEventId(userId: string, idempotencyKey: string): string {
|
||||
return `device-revoke:${userId}:${idempotencyKey}`;
|
||||
}
|
||||
|
||||
async function existingApprovalDocument(
|
||||
env: Env,
|
||||
context: AuthContext,
|
||||
approval: DeviceApprovalRequest,
|
||||
requesterDeviceId: string,
|
||||
row: DeviceApprovalRow,
|
||||
): Promise<DeviceApprovalDocument> {
|
||||
const approvedDeviceId = deviceIdValue(row.device_id, "device_id");
|
||||
const approvedByDeviceId = deviceIdValue(row.requester_device_id, "requester_device_id");
|
||||
if (
|
||||
approvedDeviceId !== approval.deviceId ||
|
||||
approvedByDeviceId !== requesterDeviceId ||
|
||||
row.status !== "approved"
|
||||
) {
|
||||
throw new DevicePermissionError("device_approval_replay_mismatch");
|
||||
}
|
||||
|
||||
const approvedDevice = await deviceRowById(env, context.userId, approvedDeviceId);
|
||||
if (approvedDevice === null) {
|
||||
throw new DevicePersistenceError("device_approval_missing");
|
||||
}
|
||||
|
||||
return {
|
||||
...approvedDeviceDocument(context.userId, requesterDeviceId, approvedDevice),
|
||||
approved_at: timestamp(row.decided_at, "decided_at"),
|
||||
};
|
||||
}
|
||||
|
||||
async function existingRevocationDocument(
|
||||
env: Env,
|
||||
context: AuthContext,
|
||||
revocation: DeviceRevocationRequest,
|
||||
requesterDeviceId: string,
|
||||
row: DeviceRevocationRow,
|
||||
): Promise<DeviceRevocationDocument> {
|
||||
const revokedDeviceId = deviceIdValue(row.subject_id, "subject_id");
|
||||
const revokedByDeviceId = deviceIdValue(row.actor_device_id, "actor_device_id");
|
||||
if (
|
||||
revokedDeviceId !== revocation.deviceId ||
|
||||
revokedByDeviceId !== requesterDeviceId ||
|
||||
row.outcome !== "success"
|
||||
) {
|
||||
throw new DevicePermissionError("device_revocation_replay_mismatch");
|
||||
}
|
||||
|
||||
const revokedDevice = await deviceRowById(env, context.userId, revokedDeviceId);
|
||||
if (revokedDevice === null) {
|
||||
throw new DevicePersistenceError("device_revocation_missing");
|
||||
}
|
||||
|
||||
return {
|
||||
...revokedDeviceDocument(context.userId, requesterDeviceId, revokedDevice),
|
||||
revoked_at: timestamp(row.created_at, "created_at"),
|
||||
};
|
||||
}
|
||||
|
||||
async function assertApprovedRequester(
|
||||
env: Env,
|
||||
userId: string,
|
||||
requesterDeviceId: string,
|
||||
): Promise<void> {
|
||||
const requester = await env.ELY_DB.prepare(APPROVED_DEVICE_QUERY)
|
||||
.bind(userId, requesterDeviceId)
|
||||
.first<DeviceRow>();
|
||||
if (requester === null) {
|
||||
throw new DevicePermissionError("requester_device_unapproved");
|
||||
if (changedRowCount(result, "device_session_binding") !== 1) {
|
||||
throw new DeviceConflictError("device_session_binding_conflict");
|
||||
}
|
||||
}
|
||||
|
||||
async function deviceRowById(env: Env, userId: string, deviceId: string): Promise<DeviceRow | null> {
|
||||
return env.ELY_DB.prepare(DEVICE_BY_ID_QUERY).bind(userId, deviceId).first<DeviceRow>();
|
||||
}
|
||||
|
||||
+25
-146
@@ -1,6 +1,5 @@
|
||||
import type { Env } from "./bindings.js";
|
||||
import {
|
||||
withApprovedDeviceApiControls,
|
||||
withAuthenticatedApiControls,
|
||||
withPublicApiControls,
|
||||
} from "./api_controls.js";
|
||||
@@ -10,16 +9,12 @@ import {
|
||||
accountDeletionDocument,
|
||||
} from "./account_deletion.js";
|
||||
import { handleBetterAuthRoute } from "./better_auth.js";
|
||||
import { handleDeviceRoute } from "./device_routes.js";
|
||||
import { DestructiveActionGateError } from "./destructive_action_gate.js";
|
||||
import {
|
||||
DeviceConflictError,
|
||||
DevicePermissionError,
|
||||
DevicePersistenceError,
|
||||
DeviceSchemaError,
|
||||
approveDeviceDocument,
|
||||
deviceListDocument,
|
||||
registerDeviceDocument,
|
||||
revokeDeviceDocument,
|
||||
} from "./devices.js";
|
||||
RecentDeviceActionPermissionError,
|
||||
RecentDeviceActionPersistenceError,
|
||||
} from "./recent_device_action_proof.js";
|
||||
import {
|
||||
PluginRegistrySchemaError,
|
||||
parsePluginRegistryDocument,
|
||||
@@ -43,6 +38,7 @@ import {
|
||||
publicSigningKeysKvKey,
|
||||
} from "./signing_keys.js";
|
||||
import { handleSyncRoute } from "./sync_routes.js";
|
||||
import { maintainSyncR2Storage } from "./sync_r2_maintenance.js";
|
||||
import {
|
||||
TelemetrySchemaError,
|
||||
telemetryEventAcceptedDocument,
|
||||
@@ -53,6 +49,9 @@ export default {
|
||||
fetch(request: Request, env: Env): Promise<Response> {
|
||||
return handleRequest(request, env);
|
||||
},
|
||||
scheduled(controller: { scheduledTime: number }, env: Env): Promise<void> {
|
||||
return maintainSyncR2Storage(env, Math.floor(controller.scheduledTime / 1000));
|
||||
},
|
||||
};
|
||||
|
||||
export async function handleRequest(request: Request, env: Env): Promise<Response> {
|
||||
@@ -60,143 +59,12 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
||||
if (url.pathname === "/api/auth" || url.pathname.startsWith("/api/auth/")) {
|
||||
return handleBetterAuthRoute(request, env);
|
||||
}
|
||||
if (url.pathname === "/api/devices") {
|
||||
return withAuthenticatedApiControls(request, env, "devices.list", ["GET"], async (context) => {
|
||||
try {
|
||||
return jsonResponse(await deviceListDocument(env, context), 200, {
|
||||
"Cache-Control": "no-store",
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof DeviceSchemaError) {
|
||||
return jsonResponse({ error: "devices_invalid" }, 500, { "Cache-Control": "no-store" });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
if (url.pathname === "/api/devices/register") {
|
||||
return withAuthenticatedApiControls(
|
||||
request,
|
||||
env,
|
||||
"devices.register",
|
||||
["POST"],
|
||||
async (context) => {
|
||||
try {
|
||||
return jsonResponse(await registerDeviceDocument(request, env, context), 201, {
|
||||
"Cache-Control": "no-store",
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof DeviceConflictError) {
|
||||
return jsonResponse(
|
||||
{ error: "device_registration_conflict" },
|
||||
409,
|
||||
{ "Cache-Control": "no-store" },
|
||||
);
|
||||
}
|
||||
if (error instanceof DevicePermissionError) {
|
||||
return jsonResponse(
|
||||
{ error: "device_context_mismatch" },
|
||||
403,
|
||||
{ "Cache-Control": "no-store" },
|
||||
);
|
||||
}
|
||||
if (error instanceof DeviceSchemaError) {
|
||||
return jsonResponse(
|
||||
{ error: "invalid_device_registration" },
|
||||
400,
|
||||
{ "Cache-Control": "no-store" },
|
||||
);
|
||||
}
|
||||
if (error instanceof DevicePersistenceError) {
|
||||
return jsonResponse(
|
||||
{ error: "device_registration_failed" },
|
||||
500,
|
||||
{ "Cache-Control": "no-store" },
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
if (url.pathname === "/api/devices/approve") {
|
||||
return withAuthenticatedApiControls(
|
||||
request,
|
||||
env,
|
||||
"devices.approve",
|
||||
["POST"],
|
||||
async (context) => {
|
||||
try {
|
||||
return jsonResponse(await approveDeviceDocument(request, env, context), 200, {
|
||||
"Cache-Control": "no-store",
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof DevicePermissionError) {
|
||||
return jsonResponse(
|
||||
{ error: "device_approval_forbidden" },
|
||||
403,
|
||||
{ "Cache-Control": "no-store" },
|
||||
);
|
||||
}
|
||||
if (error instanceof DeviceSchemaError) {
|
||||
return jsonResponse(
|
||||
{ error: "invalid_device_approval" },
|
||||
400,
|
||||
{ "Cache-Control": "no-store" },
|
||||
);
|
||||
}
|
||||
if (error instanceof DevicePersistenceError) {
|
||||
return jsonResponse(
|
||||
{ error: "device_approval_failed" },
|
||||
500,
|
||||
{ "Cache-Control": "no-store" },
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
if (url.pathname === "/api/devices/revoke") {
|
||||
return withAuthenticatedApiControls(
|
||||
request,
|
||||
env,
|
||||
"devices.revoke",
|
||||
["POST"],
|
||||
async (context) => {
|
||||
try {
|
||||
return jsonResponse(await revokeDeviceDocument(request, env, context), 200, {
|
||||
"Cache-Control": "no-store",
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof DevicePermissionError) {
|
||||
return jsonResponse(
|
||||
{ error: "device_revocation_forbidden" },
|
||||
403,
|
||||
{ "Cache-Control": "no-store" },
|
||||
);
|
||||
}
|
||||
if (error instanceof DeviceSchemaError) {
|
||||
return jsonResponse(
|
||||
{ error: "invalid_device_revocation" },
|
||||
400,
|
||||
{ "Cache-Control": "no-store" },
|
||||
);
|
||||
}
|
||||
if (error instanceof DevicePersistenceError) {
|
||||
return jsonResponse(
|
||||
{ error: "device_revocation_failed" },
|
||||
500,
|
||||
{ "Cache-Control": "no-store" },
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
);
|
||||
const deviceResponse = await handleDeviceRoute(request, env, url);
|
||||
if (deviceResponse !== null) {
|
||||
return deviceResponse;
|
||||
}
|
||||
if (url.pathname === "/api/account/delete") {
|
||||
return withApprovedDeviceApiControls(
|
||||
return withAuthenticatedApiControls(
|
||||
request,
|
||||
env,
|
||||
"account.delete",
|
||||
@@ -214,7 +82,18 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
||||
{ "Cache-Control": "no-store" },
|
||||
);
|
||||
}
|
||||
if (error instanceof AccountDeletionPersistenceError) {
|
||||
if (error instanceof RecentDeviceActionPermissionError) {
|
||||
return jsonResponse(
|
||||
{ error: "account_deletion_forbidden" },
|
||||
403,
|
||||
{ "Cache-Control": "no-store" },
|
||||
);
|
||||
}
|
||||
if (
|
||||
error instanceof AccountDeletionPersistenceError ||
|
||||
error instanceof RecentDeviceActionPersistenceError ||
|
||||
error instanceof DestructiveActionGateError
|
||||
) {
|
||||
return jsonResponse(
|
||||
{ error: "account_deletion_failed" },
|
||||
500,
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { authSessionCacheKvKey, authTokenHash } from "./auth.js";
|
||||
import type { Env } from "./bindings.js";
|
||||
|
||||
const LIST_LIMIT = 1000;
|
||||
const MAX_LIST_PAGES = 10;
|
||||
|
||||
interface KvListResult {
|
||||
keys: { name: string }[];
|
||||
list_complete: boolean;
|
||||
cursor?: string;
|
||||
}
|
||||
|
||||
interface ListableKv {
|
||||
list(options: { prefix: string; cursor?: string; limit: number }): Promise<KvListResult>;
|
||||
}
|
||||
|
||||
export class LegacyAuthKvCleanupError extends Error {}
|
||||
|
||||
export async function deleteLegacySessionKeys(
|
||||
env: Env,
|
||||
tokens: string[],
|
||||
currentTokenHash: string,
|
||||
): Promise<number> {
|
||||
const keys = new Set<string>([
|
||||
authSessionCacheKvKey(env.ELY_ENVIRONMENT, currentTokenHash),
|
||||
]);
|
||||
for (const token of tokens) {
|
||||
keys.add(authSessionCacheKvKey(env.ELY_ENVIRONMENT, await authTokenHash(token)));
|
||||
}
|
||||
let deleted = 0;
|
||||
for (const key of keys) {
|
||||
if (await env.ELY_KV.get(key) === null) continue;
|
||||
await env.ELY_KV.delete(key);
|
||||
deleted += 1;
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
export async function purgeLegacySessionCache(
|
||||
env: Env,
|
||||
maxPages = MAX_LIST_PAGES,
|
||||
): Promise<number> {
|
||||
const namespace = env.ELY_KV as Env["ELY_KV"] & Partial<ListableKv>;
|
||||
if (typeof namespace.list !== "function") {
|
||||
throw new LegacyAuthKvCleanupError("legacy_auth_kv_list_unavailable");
|
||||
}
|
||||
const prefix = authSessionCacheKvKey(env.ELY_ENVIRONMENT, "0".repeat(64)).slice(0, -64);
|
||||
let cursor: string | undefined;
|
||||
let deleted = 0;
|
||||
for (let page = 0; page < maxPages; page += 1) {
|
||||
const result = await namespace.list({
|
||||
prefix,
|
||||
...(cursor === undefined ? {} : { cursor }),
|
||||
limit: LIST_LIMIT,
|
||||
});
|
||||
for (const key of result.keys) {
|
||||
if (!key.name.startsWith(prefix)) {
|
||||
throw new LegacyAuthKvCleanupError("legacy_auth_kv_key_invalid");
|
||||
}
|
||||
await namespace.delete(key.name);
|
||||
deleted += 1;
|
||||
}
|
||||
if (result.list_complete) break;
|
||||
if (typeof result.cursor !== "string" || result.cursor.length === 0) {
|
||||
throw new LegacyAuthKvCleanupError("legacy_auth_kv_cursor_invalid");
|
||||
}
|
||||
cursor = result.cursor;
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import type { AuthContext } from "./auth.js";
|
||||
import type { ElyD1Result, Env } from "./bindings.js";
|
||||
import type { PendingDeviceRevocationRequest } from "./device_revocation_schema.js";
|
||||
import {
|
||||
type DeviceRevocationDocument,
|
||||
type DeviceRow,
|
||||
DeviceConflictError,
|
||||
DevicePermissionError,
|
||||
DevicePersistenceError,
|
||||
deviceDocument,
|
||||
} from "./device_schema.js";
|
||||
|
||||
const DEVICE_BY_ID_QUERY = `
|
||||
SELECT
|
||||
device.device_id, device.public_key, device.device_name, device.platform,
|
||||
device.approval_status, device.created_at, device.approved_at,
|
||||
device.last_active_at, device.revoked_at, keys.wrapping_public_key
|
||||
FROM user_devices AS device
|
||||
LEFT JOIN user_device_keys AS keys
|
||||
ON keys.user_id = device.user_id AND keys.device_id = device.device_id
|
||||
WHERE device.user_id = ? AND device.device_id = ?
|
||||
`;
|
||||
const PENDING_RESULT_QUERY = `
|
||||
SELECT
|
||||
revocation.target_device_id, revocation.approver_device_id,
|
||||
revocation.request_hash, revocation.completed_at,
|
||||
target.approval_status AS target_status, target.revoked_at,
|
||||
(SELECT COUNT(*)
|
||||
FROM better_auth_session AS session
|
||||
INNER JOIN better_auth_session_device_context AS context
|
||||
ON context.session_id = session.id
|
||||
WHERE context.user_id = revocation.user_id
|
||||
AND context.device_id = revocation.target_device_id) AS active_session_count,
|
||||
(SELECT COUNT(*) FROM audit_events AS audit
|
||||
WHERE audit.event_id = revocation.audit_event_id
|
||||
AND audit.user_id = revocation.user_id
|
||||
AND audit.actor_device_id = revocation.approver_device_id
|
||||
AND audit.event_type = 'device.revoke'
|
||||
AND audit.subject_id = revocation.target_device_id
|
||||
AND audit.outcome = 'success'
|
||||
AND audit.metadata_hash = revocation.request_hash
|
||||
AND audit.created_at = revocation.completed_at) AS audit_count
|
||||
FROM pending_device_revocations AS revocation
|
||||
LEFT JOIN user_devices AS target
|
||||
ON target.user_id = revocation.user_id AND target.device_id = revocation.target_device_id
|
||||
WHERE revocation.user_id = ? AND revocation.idempotency_key = ?
|
||||
`;
|
||||
const PENDING_INSERT_QUERY = `
|
||||
INSERT INTO pending_device_revocations (
|
||||
user_id, idempotency_key, audit_event_id, target_device_id,
|
||||
approver_device_id, request_hash, created_at, completed_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, NULL)
|
||||
ON CONFLICT(user_id, idempotency_key) DO NOTHING
|
||||
`;
|
||||
const PENDING_FINALIZE_QUERY = `
|
||||
UPDATE pending_device_revocations
|
||||
SET completed_at = ?
|
||||
WHERE user_id = ? AND idempotency_key = ? AND completed_at IS NULL
|
||||
AND target_device_id = ? AND approver_device_id = ? AND request_hash = ?
|
||||
`;
|
||||
|
||||
interface PendingResultRow {
|
||||
target_device_id: unknown;
|
||||
approver_device_id: unknown;
|
||||
request_hash: unknown;
|
||||
completed_at: unknown;
|
||||
target_status: unknown;
|
||||
revoked_at: unknown;
|
||||
active_session_count: unknown;
|
||||
audit_count: unknown;
|
||||
}
|
||||
|
||||
export async function revokePendingDeviceDocument(
|
||||
env: Env,
|
||||
context: AuthContext,
|
||||
approverDeviceId: string,
|
||||
revocation: PendingDeviceRevocationRequest,
|
||||
requestHash: string,
|
||||
nowSeconds: number,
|
||||
): Promise<DeviceRevocationDocument> {
|
||||
const existing = await pendingResult(env, context.userId, revocation.idempotencyKey);
|
||||
if (existing !== null) {
|
||||
return completedPendingDocument(
|
||||
env,
|
||||
context.userId,
|
||||
approverDeviceId,
|
||||
revocation,
|
||||
requestHash,
|
||||
existing,
|
||||
);
|
||||
}
|
||||
const target = await deviceRowById(env, context.userId, revocation.deviceId);
|
||||
if (target === null) {
|
||||
throw new DevicePermissionError("device_not_found");
|
||||
}
|
||||
const targetDocument = deviceDocument(target, approverDeviceId);
|
||||
if (targetDocument.approval_status !== "pending" || targetDocument.revoked_at !== null) {
|
||||
throw new DeviceConflictError("pending_device_revocation_target_invalid");
|
||||
}
|
||||
let results: ElyD1Result[];
|
||||
try {
|
||||
results = await env.ELY_DB.batch<ElyD1Result>([
|
||||
env.ELY_DB.prepare(PENDING_INSERT_QUERY).bind(
|
||||
context.userId,
|
||||
revocation.idempotencyKey,
|
||||
`pending-device-revoke:${requestHash}`,
|
||||
revocation.deviceId,
|
||||
approverDeviceId,
|
||||
requestHash,
|
||||
nowSeconds,
|
||||
),
|
||||
env.ELY_DB.prepare(PENDING_FINALIZE_QUERY).bind(
|
||||
nowSeconds,
|
||||
context.userId,
|
||||
revocation.idempotencyKey,
|
||||
revocation.deviceId,
|
||||
approverDeviceId,
|
||||
requestHash,
|
||||
),
|
||||
]);
|
||||
} catch (error) {
|
||||
if (pendingConflict(error)) {
|
||||
throw new DeviceConflictError("pending_device_revocation_race");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const finalizeChanges = changedRowCount(results.at(-1));
|
||||
if (finalizeChanges > 1) {
|
||||
throw new DevicePersistenceError("pending_device_revocation_write_count_invalid");
|
||||
}
|
||||
const completed = await pendingResult(env, context.userId, revocation.idempotencyKey);
|
||||
if (completed === null) {
|
||||
throw new DeviceConflictError("pending_device_revocation_race");
|
||||
}
|
||||
try {
|
||||
return await completedPendingDocument(
|
||||
env,
|
||||
context.userId,
|
||||
approverDeviceId,
|
||||
revocation,
|
||||
requestHash,
|
||||
completed,
|
||||
);
|
||||
} catch (error) {
|
||||
if (finalizeChanges === 0 && error instanceof DeviceConflictError) {
|
||||
throw new DeviceConflictError("pending_device_revocation_race");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function completedPendingDocument(
|
||||
env: Env,
|
||||
userId: string,
|
||||
approverDeviceId: string,
|
||||
revocation: PendingDeviceRevocationRequest,
|
||||
requestHash: string,
|
||||
result: PendingResultRow,
|
||||
): Promise<DeviceRevocationDocument> {
|
||||
if (
|
||||
result.target_device_id !== revocation.deviceId ||
|
||||
result.approver_device_id !== approverDeviceId ||
|
||||
result.request_hash !== requestHash
|
||||
) {
|
||||
throw new DeviceConflictError("pending_device_revocation_replay_mismatch");
|
||||
}
|
||||
const completedAt = storedInteger(result.completed_at, "completed_at");
|
||||
if (
|
||||
result.target_status !== "revoked" ||
|
||||
result.revoked_at !== completedAt ||
|
||||
result.active_session_count !== 0 ||
|
||||
result.audit_count !== 1
|
||||
) {
|
||||
throw new DevicePersistenceError("pending_device_revocation_result_invalid");
|
||||
}
|
||||
const row = await deviceRowById(env, userId, revocation.deviceId);
|
||||
if (row === null) {
|
||||
throw new DevicePersistenceError("pending_device_revocation_missing");
|
||||
}
|
||||
const device = deviceDocument(row, approverDeviceId);
|
||||
if (device.approval_status !== "revoked" || device.revoked_at !== completedAt) {
|
||||
throw new DevicePersistenceError("pending_device_revocation_state_invalid");
|
||||
}
|
||||
return {
|
||||
version: 2,
|
||||
mode: "pending_revoke",
|
||||
user_id: userId,
|
||||
revoked_by_device_id: approverDeviceId,
|
||||
revoked_at: completedAt,
|
||||
device,
|
||||
};
|
||||
}
|
||||
|
||||
function pendingResult(
|
||||
env: Env,
|
||||
userId: string,
|
||||
idempotencyKey: string,
|
||||
): Promise<PendingResultRow | null> {
|
||||
return env.ELY_DB.prepare(PENDING_RESULT_QUERY)
|
||||
.bind(userId, idempotencyKey)
|
||||
.first<PendingResultRow>();
|
||||
}
|
||||
|
||||
function deviceRowById(env: Env, userId: string, deviceId: string): Promise<DeviceRow | null> {
|
||||
return env.ELY_DB.prepare(DEVICE_BY_ID_QUERY).bind(userId, deviceId).first<DeviceRow>();
|
||||
}
|
||||
|
||||
function storedInteger(value: unknown, label: string): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
|
||||
throw new DevicePersistenceError(`${label}_invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function changedRowCount(result: ElyD1Result | undefined): number {
|
||||
const changes = result?.meta?.changes;
|
||||
if (typeof changes !== "number" || !Number.isSafeInteger(changes) || changes < 0) {
|
||||
throw new DevicePersistenceError("pending_device_revocation_write_result_invalid");
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
function pendingConflict(error: unknown): boolean {
|
||||
return error instanceof Error && (
|
||||
error.message.includes("pending_device_revocation_guard_failed") ||
|
||||
error.message.includes("FOREIGN KEY constraint failed")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { AuthContext } from "./auth.js";
|
||||
import type { ElyD1DatabaseSession } from "./bindings.js";
|
||||
import { verifyEd25519Signature } from "./device_crypto.js";
|
||||
|
||||
const ACTION_PROOF_DOMAIN = "elydora-sensitive-action-v2";
|
||||
const PROOF_MAX_AGE_SECONDS = 5 * 60;
|
||||
const PROOF_CLOCK_SKEW_SECONDS = 30;
|
||||
const PUBLIC_KEY_PATTERN = /^[a-f0-9]{64}$/;
|
||||
const SIGNATURE_PATTERN = /^[a-f0-9]{128}$/;
|
||||
|
||||
const APPROVED_DEVICE_SIGNING_KEY_QUERY = `
|
||||
SELECT keys.signing_public_key
|
||||
FROM user_devices AS device
|
||||
INNER JOIN user_device_keys AS keys
|
||||
ON keys.user_id = device.user_id AND keys.device_id = device.device_id
|
||||
WHERE device.user_id = ? AND device.device_id = ?
|
||||
AND device.approval_status = 'approved' AND device.revoked_at IS NULL
|
||||
AND keys.key_protocol_version = 2 AND keys.wrapping_public_key IS NOT NULL
|
||||
`;
|
||||
|
||||
export type SensitiveAction = "account.delete" | "sync.reset";
|
||||
|
||||
export interface RecentDeviceActionProof {
|
||||
proofCreatedAt: number;
|
||||
actionProof: string;
|
||||
}
|
||||
|
||||
export interface RecentDeviceActionProofFields extends RecentDeviceActionProof {
|
||||
action: SensitiveAction;
|
||||
userId: string;
|
||||
sessionId: string;
|
||||
deviceId: string;
|
||||
confirmation: string;
|
||||
idempotencyKey: string;
|
||||
}
|
||||
|
||||
interface SigningKeyRow { signing_public_key: unknown }
|
||||
|
||||
export class RecentDeviceActionRequestError extends Error {}
|
||||
export class RecentDeviceActionPermissionError extends Error {}
|
||||
export class RecentDeviceActionPersistenceError extends Error {}
|
||||
|
||||
export function recentDeviceActionProof(
|
||||
proofCreatedAt: unknown,
|
||||
actionProof: unknown,
|
||||
): RecentDeviceActionProof {
|
||||
if (
|
||||
typeof proofCreatedAt !== "number" ||
|
||||
!Number.isSafeInteger(proofCreatedAt) ||
|
||||
proofCreatedAt < 1
|
||||
) {
|
||||
throw new RecentDeviceActionRequestError("proof_created_at_invalid");
|
||||
}
|
||||
if (typeof actionProof !== "string" || !SIGNATURE_PATTERN.test(actionProof)) {
|
||||
throw new RecentDeviceActionRequestError("action_proof_invalid");
|
||||
}
|
||||
return { proofCreatedAt, actionProof };
|
||||
}
|
||||
|
||||
export async function assertRecentDeviceActionProof(
|
||||
database: ElyD1DatabaseSession,
|
||||
context: AuthContext,
|
||||
action: SensitiveAction,
|
||||
confirmation: string,
|
||||
idempotencyKey: string,
|
||||
proof: RecentDeviceActionProof,
|
||||
): Promise<string> {
|
||||
if (context.deviceId === undefined) {
|
||||
throw new RecentDeviceActionPermissionError("device_context_required");
|
||||
}
|
||||
const row = await database.prepare(APPROVED_DEVICE_SIGNING_KEY_QUERY)
|
||||
.bind(context.userId, context.deviceId)
|
||||
.first<SigningKeyRow>();
|
||||
if (row === null) {
|
||||
throw new RecentDeviceActionPermissionError("device_action_forbidden");
|
||||
}
|
||||
if (
|
||||
typeof row.signing_public_key !== "string" ||
|
||||
!PUBLIC_KEY_PATTERN.test(row.signing_public_key)
|
||||
) {
|
||||
throw new RecentDeviceActionPersistenceError("device_signing_key_invalid");
|
||||
}
|
||||
const fields: RecentDeviceActionProofFields = {
|
||||
action,
|
||||
userId: context.userId,
|
||||
sessionId: context.sessionId,
|
||||
deviceId: context.deviceId,
|
||||
confirmation,
|
||||
idempotencyKey,
|
||||
...proof,
|
||||
};
|
||||
if (!(await verifyEd25519Signature(
|
||||
row.signing_public_key,
|
||||
proof.actionProof,
|
||||
recentDeviceActionProofBytes(fields),
|
||||
))) {
|
||||
throw new RecentDeviceActionPermissionError("device_action_proof_invalid");
|
||||
}
|
||||
return row.signing_public_key;
|
||||
}
|
||||
|
||||
export function assertFreshDeviceActionProof(
|
||||
proof: RecentDeviceActionProof,
|
||||
nowSeconds: number,
|
||||
freshnessRequired: boolean,
|
||||
): void {
|
||||
if (freshnessRequired && (
|
||||
proof.proofCreatedAt < nowSeconds - PROOF_MAX_AGE_SECONDS ||
|
||||
proof.proofCreatedAt > nowSeconds + PROOF_CLOCK_SKEW_SECONDS
|
||||
)) {
|
||||
throw new RecentDeviceActionPermissionError("device_action_proof_expired");
|
||||
}
|
||||
}
|
||||
|
||||
export function recentDeviceActionProofBytes(
|
||||
fields: Omit<RecentDeviceActionProofFields, "actionProof">,
|
||||
): Uint8Array {
|
||||
return canonicalBytes(deviceActionProofValues(fields));
|
||||
}
|
||||
|
||||
export async function recentDeviceActionRequestHash(
|
||||
fields: RecentDeviceActionProofFields,
|
||||
): Promise<string> {
|
||||
const digest = await crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
canonicalBytes([...deviceActionProofValues(fields), fields.actionProof]),
|
||||
);
|
||||
return [...new Uint8Array(digest)]
|
||||
.map((byte) => byte.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
function deviceActionProofValues(
|
||||
fields: Omit<RecentDeviceActionProofFields, "actionProof">,
|
||||
): (number | string)[] {
|
||||
return [
|
||||
ACTION_PROOF_DOMAIN,
|
||||
fields.action,
|
||||
fields.userId,
|
||||
fields.sessionId,
|
||||
fields.deviceId,
|
||||
fields.confirmation,
|
||||
fields.idempotencyKey,
|
||||
fields.proofCreatedAt,
|
||||
];
|
||||
}
|
||||
|
||||
function canonicalBytes(values: (number | string)[]): Uint8Array {
|
||||
const encoder = new TextEncoder();
|
||||
return encoder.encode(values.map((value) => {
|
||||
const text = value.toString();
|
||||
return `${encoder.encode(text).byteLength}:${text}`;
|
||||
}).join(""));
|
||||
}
|
||||
@@ -54,11 +54,19 @@ export function syncSnapshotKey(params: {
|
||||
region: string;
|
||||
userHash: string;
|
||||
snapshotId: string;
|
||||
payloadHash: string;
|
||||
}): string {
|
||||
assertRegion(params.region);
|
||||
assertSha256Hex(params.userHash, "user_hash");
|
||||
assertSegment(params.snapshotId, "snapshot_id");
|
||||
return ["sync-snapshots", params.region, params.userHash, `${params.snapshotId}.bin`].join("/");
|
||||
assertSha256Hex(params.payloadHash, "payload_hash");
|
||||
return [
|
||||
"sync-snapshots",
|
||||
params.region,
|
||||
params.userHash,
|
||||
params.snapshotId,
|
||||
`${params.payloadHash}.bin`,
|
||||
].join("/");
|
||||
}
|
||||
|
||||
export function pluginPackageKey(params: { pluginId: string; packageHash: string }): string {
|
||||
@@ -101,9 +109,7 @@ export async function putVerifiedObject(
|
||||
expectedSha256: string,
|
||||
contentType: string,
|
||||
): Promise<StoredObject> {
|
||||
assertKnownObjectKey(key);
|
||||
assertSha256Hex(expectedSha256, "sha256");
|
||||
assertKeyChecksum(key, expectedSha256);
|
||||
assertKnownObjectKeyHash(key, expectedSha256);
|
||||
const actualSha256 = await sha256Hex(payload);
|
||||
if (actualSha256 !== expectedSha256) {
|
||||
throw new StorageObjectError("r2_checksum_mismatch");
|
||||
@@ -122,9 +128,7 @@ export async function getVerifiedObject(
|
||||
key: string,
|
||||
expectedSha256: string,
|
||||
): Promise<ArrayBuffer | null> {
|
||||
assertKnownObjectKey(key);
|
||||
assertSha256Hex(expectedSha256, "sha256");
|
||||
assertKeyChecksum(key, expectedSha256);
|
||||
assertKnownObjectKeyHash(key, expectedSha256);
|
||||
const object = await bucket.get(key);
|
||||
if (object === null) {
|
||||
return null;
|
||||
@@ -143,10 +147,11 @@ export async function deleteKnownObject(bucket: ElyR2Bucket, key: string): Promi
|
||||
await bucket.delete(key);
|
||||
}
|
||||
|
||||
function assertKnownObjectKey(key: string): void {
|
||||
export function assertKnownObjectKey(key: string): void {
|
||||
const matches = [
|
||||
/^sync-payloads\/[a-z0-9][a-z0-9-]{1,31}\/[a-f0-9]{64}\/[a-z0-9][a-z0-9._-]{0,127}\/[a-z0-9][a-z0-9._-]{0,127}\/[a-f0-9]{64}\.bin$/,
|
||||
/^sync-snapshots\/[a-z0-9][a-z0-9-]{1,31}\/[a-f0-9]{64}\/[a-z0-9][a-z0-9._-]{0,127}\.bin$/,
|
||||
/^sync-snapshots\/[a-z0-9][a-z0-9-]{1,31}\/[a-f0-9]{64}\/[a-z0-9][a-z0-9._-]{0,127}\/[a-f0-9]{64}\.bin$/,
|
||||
/^plugin-packages\/[a-z0-9][a-z0-9._-]{0,127}\/[a-f0-9]{64}\.rplug$/,
|
||||
/^plugin-assets\/[a-z0-9][a-z0-9._-]{0,127}\/[a-f0-9]{64}$/,
|
||||
/^user-avatars\/[a-f0-9]{64}\/[a-f0-9]{64}$/,
|
||||
@@ -161,6 +166,12 @@ function assertKnownObjectKey(key: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function assertKnownObjectKeyHash(key: string, expectedSha256: string): void {
|
||||
assertKnownObjectKey(key);
|
||||
assertSha256Hex(expectedSha256, "sha256");
|
||||
assertKeyChecksum(key, expectedSha256);
|
||||
}
|
||||
|
||||
function assertKeyChecksum(key: string, expectedSha256: string): void {
|
||||
const keyChecksum = checksumFromKey(key);
|
||||
if (keyChecksum !== null && keyChecksum !== expectedSha256) {
|
||||
@@ -176,7 +187,7 @@ function checksumFromKey(key: string): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (prefix === "sync-payloads") {
|
||||
if (prefix === "sync-payloads" || (prefix === "sync-snapshots" && segments.length === 5)) {
|
||||
return lastSegment.slice(0, -".bin".length);
|
||||
}
|
||||
if (prefix === "plugin-packages") {
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
import type { AuthContext } from "./auth.js";
|
||||
import type { Env } from "./bindings.js";
|
||||
|
||||
const DEFAULT_SYNC_PULL_LIMIT = 100;
|
||||
const MAX_SYNC_PULL_LIMIT = 500;
|
||||
const SYNC_OBJECT_ID_PATTERN = /^[a-zA-Z0-9._:-]{1,128}$/;
|
||||
const SYNC_OBJECT_TYPE_PATTERN = /^[a-z0-9][a-z0-9._:-]{0,127}$/;
|
||||
const SHA256_HEX = /^[a-f0-9]{64}$/;
|
||||
|
||||
const SYNC_CHANGE_LOG_QUERY = `
|
||||
SELECT
|
||||
change_id,
|
||||
object_id,
|
||||
object_type,
|
||||
operation,
|
||||
payload_hash,
|
||||
logical_clock,
|
||||
device_id,
|
||||
created_at
|
||||
FROM sync_change_log
|
||||
WHERE user_id = ? AND change_id > ?
|
||||
ORDER BY change_id ASC
|
||||
LIMIT ?
|
||||
`;
|
||||
|
||||
export interface SyncPullDocument {
|
||||
version: 1;
|
||||
user_id: string;
|
||||
device_id: string;
|
||||
cursor: number;
|
||||
next_cursor: number;
|
||||
has_more: boolean;
|
||||
changes: SyncChangeDocument[];
|
||||
}
|
||||
|
||||
export interface SyncChangeDocument {
|
||||
change_id: number;
|
||||
object_id: string;
|
||||
object_type: string;
|
||||
operation: "upsert" | "delete";
|
||||
payload_hash: string;
|
||||
logical_clock: number;
|
||||
device_id: string;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
interface SyncChangeRow {
|
||||
change_id: unknown;
|
||||
object_id: unknown;
|
||||
object_type: unknown;
|
||||
operation: unknown;
|
||||
payload_hash: unknown;
|
||||
logical_clock: unknown;
|
||||
device_id: unknown;
|
||||
created_at: unknown;
|
||||
}
|
||||
|
||||
interface SyncPullQuery {
|
||||
cursor: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export class SyncSchemaError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "SyncSchemaError";
|
||||
}
|
||||
}
|
||||
|
||||
export class SyncRequestError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "SyncRequestError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncPullDocument(
|
||||
url: URL,
|
||||
env: Env,
|
||||
context: AuthContext,
|
||||
): Promise<SyncPullDocument> {
|
||||
const deviceId = currentDeviceId(context);
|
||||
const query = syncPullQuery(url);
|
||||
const result = await env.ELY_DB.prepare(SYNC_CHANGE_LOG_QUERY)
|
||||
.bind(context.userId, query.cursor, query.limit + 1)
|
||||
.all<SyncChangeRow>();
|
||||
const rows = result.results.slice(0, query.limit);
|
||||
const changes = rows.map(syncChangeDocument);
|
||||
return {
|
||||
version: 1,
|
||||
user_id: context.userId,
|
||||
device_id: deviceId,
|
||||
cursor: query.cursor,
|
||||
next_cursor: changes.at(-1)?.change_id ?? query.cursor,
|
||||
has_more: result.results.length > query.limit,
|
||||
changes,
|
||||
};
|
||||
}
|
||||
|
||||
function syncPullQuery(url: URL): SyncPullQuery {
|
||||
assertOnlyQueryParams(url, ["cursor", "limit"]);
|
||||
const cursor = requiredQueryInteger(url, "cursor", 0, Number.MAX_SAFE_INTEGER);
|
||||
const limit = optionalQueryInteger(url, "limit", 1, MAX_SYNC_PULL_LIMIT) ?? DEFAULT_SYNC_PULL_LIMIT;
|
||||
return { cursor, limit };
|
||||
}
|
||||
|
||||
function syncChangeDocument(row: SyncChangeRow): SyncChangeDocument {
|
||||
return {
|
||||
change_id: integerValue(row.change_id, "change_id", 0, Number.MAX_SAFE_INTEGER),
|
||||
object_id: objectId(row.object_id),
|
||||
object_type: objectType(row.object_type),
|
||||
operation: operation(row.operation),
|
||||
payload_hash: payloadHash(row.payload_hash),
|
||||
logical_clock: integerValue(row.logical_clock, "logical_clock", 0, Number.MAX_SAFE_INTEGER),
|
||||
device_id: objectId(row.device_id),
|
||||
created_at: integerValue(row.created_at, "created_at", 0, Number.MAX_SAFE_INTEGER),
|
||||
};
|
||||
}
|
||||
|
||||
function currentDeviceId(context: AuthContext): string {
|
||||
if (context.deviceId === undefined) {
|
||||
throw new SyncSchemaError("device_context_required");
|
||||
}
|
||||
return context.deviceId;
|
||||
}
|
||||
|
||||
function assertOnlyQueryParams(url: URL, fields: string[]): void {
|
||||
const allowed = new Set(fields);
|
||||
for (const field of url.searchParams.keys()) {
|
||||
if (!allowed.has(field)) {
|
||||
throw new SyncRequestError(`unexpected_query:${field}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function requiredQueryInteger(url: URL, field: string, min: number, max: number): number {
|
||||
const value = url.searchParams.get(field);
|
||||
if (value === null) {
|
||||
throw new SyncRequestError(`${field}_required`);
|
||||
}
|
||||
return queryInteger(value, field, min, max);
|
||||
}
|
||||
|
||||
function optionalQueryInteger(
|
||||
url: URL,
|
||||
field: string,
|
||||
min: number,
|
||||
max: number,
|
||||
): number | undefined {
|
||||
const value = url.searchParams.get(field);
|
||||
if (value === null) {
|
||||
return undefined;
|
||||
}
|
||||
return queryInteger(value, field, min, max);
|
||||
}
|
||||
|
||||
function queryInteger(value: string, field: string, min: number, max: number): number {
|
||||
if (!/^[0-9]+$/.test(value)) {
|
||||
throw new SyncRequestError(`${field}_invalid`);
|
||||
}
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < min || parsed > max) {
|
||||
throw new SyncRequestError(`${field}_invalid`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function integerValue(value: unknown, field: string, min: number, max: number): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < min || value > max) {
|
||||
throw new SyncSchemaError(`${field}_invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function objectId(value: unknown): string {
|
||||
if (typeof value !== "string" || !SYNC_OBJECT_ID_PATTERN.test(value)) {
|
||||
throw new SyncSchemaError("object_id_invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function objectType(value: unknown): string {
|
||||
if (typeof value !== "string" || !SYNC_OBJECT_TYPE_PATTERN.test(value)) {
|
||||
throw new SyncSchemaError("object_type_invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function operation(value: unknown): SyncChangeDocument["operation"] {
|
||||
if (value !== "upsert" && value !== "delete") {
|
||||
throw new SyncSchemaError("operation_invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function payloadHash(value: unknown): string {
|
||||
if (typeof value !== "string" || !SHA256_HEX.test(value)) {
|
||||
throw new SyncSchemaError("payload_hash_invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
import type { AuthContext } from "./auth.js";
|
||||
import type { Env } from "./bindings.js";
|
||||
import type { ElyD1PreparedStatement } from "./bindings.js";
|
||||
import { StorageObjectError, putVerifiedObject } from "./storage.js";
|
||||
import {
|
||||
type SyncObjectRow,
|
||||
SyncPushConflictError,
|
||||
type SyncPushDocument,
|
||||
SyncPushPersistenceError,
|
||||
type SyncPushRequest,
|
||||
SyncPushRequestError,
|
||||
type SyncPushedObjectDocument,
|
||||
currentDeviceId,
|
||||
syncObjectDocument,
|
||||
syncPushRequest,
|
||||
} from "./sync_push_schema.js";
|
||||
|
||||
export {
|
||||
SyncPushConflictError,
|
||||
SyncPushPersistenceError,
|
||||
SyncPushRequestError,
|
||||
} from "./sync_push_schema.js";
|
||||
|
||||
const SYNC_OBJECT_BY_ID_QUERY = `
|
||||
SELECT
|
||||
object_id,
|
||||
object_type,
|
||||
payload_r2_key,
|
||||
payload_hash,
|
||||
schema_rev,
|
||||
logical_clock,
|
||||
device_id,
|
||||
created_at,
|
||||
updated_at,
|
||||
deleted_at
|
||||
FROM sync_objects
|
||||
WHERE user_id = ? AND object_id = ?
|
||||
`;
|
||||
const SYNC_OBJECT_UPSERT_QUERY = `
|
||||
INSERT INTO sync_objects (
|
||||
user_id,
|
||||
object_id,
|
||||
object_type,
|
||||
payload_inline,
|
||||
payload_r2_key,
|
||||
payload_hash,
|
||||
schema_rev,
|
||||
logical_clock,
|
||||
device_id,
|
||||
created_at,
|
||||
updated_at,
|
||||
deleted_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id, object_id) DO UPDATE SET
|
||||
object_type = excluded.object_type,
|
||||
payload_inline = excluded.payload_inline,
|
||||
payload_r2_key = excluded.payload_r2_key,
|
||||
payload_hash = excluded.payload_hash,
|
||||
schema_rev = excluded.schema_rev,
|
||||
logical_clock = excluded.logical_clock,
|
||||
device_id = excluded.device_id,
|
||||
updated_at = excluded.updated_at,
|
||||
deleted_at = excluded.deleted_at
|
||||
WHERE excluded.logical_clock > sync_objects.logical_clock
|
||||
OR (
|
||||
excluded.logical_clock = sync_objects.logical_clock
|
||||
AND sync_objects.object_type = excluded.object_type
|
||||
AND (
|
||||
(sync_objects.payload_r2_key IS NULL AND excluded.payload_r2_key IS NULL)
|
||||
OR sync_objects.payload_r2_key = excluded.payload_r2_key
|
||||
)
|
||||
AND sync_objects.payload_hash = excluded.payload_hash
|
||||
AND sync_objects.schema_rev = excluded.schema_rev
|
||||
AND sync_objects.device_id = excluded.device_id
|
||||
AND (
|
||||
(sync_objects.deleted_at IS NULL AND excluded.deleted_at IS NULL)
|
||||
OR (sync_objects.deleted_at IS NOT NULL AND excluded.deleted_at IS NOT NULL)
|
||||
)
|
||||
)
|
||||
`;
|
||||
const SYNC_CHANGE_INSERT_QUERY = `
|
||||
INSERT INTO sync_change_log (
|
||||
user_id,
|
||||
object_id,
|
||||
object_type,
|
||||
operation,
|
||||
payload_hash,
|
||||
logical_clock,
|
||||
device_id,
|
||||
created_at
|
||||
)
|
||||
SELECT ?, ?, ?, ?, ?, ?, ?, ?
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM sync_objects
|
||||
WHERE user_id = ?
|
||||
AND object_id = ?
|
||||
AND object_type = ?
|
||||
AND payload_hash = ?
|
||||
AND logical_clock = ?
|
||||
AND device_id = ?
|
||||
AND ((? = 1 AND deleted_at IS NOT NULL) OR (? = 0 AND deleted_at IS NULL))
|
||||
)
|
||||
ON CONFLICT(user_id, object_id, logical_clock, device_id, operation) DO NOTHING
|
||||
`;
|
||||
const SYNC_TOMBSTONE_UPSERT_QUERY = `
|
||||
INSERT INTO sync_tombstones (
|
||||
user_id,
|
||||
object_id,
|
||||
object_type,
|
||||
logical_clock,
|
||||
device_id,
|
||||
deleted_at
|
||||
)
|
||||
SELECT user_id, object_id, object_type, logical_clock, device_id, deleted_at
|
||||
FROM sync_objects
|
||||
WHERE user_id = ? AND object_id = ? AND logical_clock = ? AND deleted_at IS NOT NULL
|
||||
ON CONFLICT(user_id, object_id) DO UPDATE SET
|
||||
object_type = excluded.object_type,
|
||||
logical_clock = excluded.logical_clock,
|
||||
device_id = excluded.device_id,
|
||||
deleted_at = excluded.deleted_at
|
||||
WHERE excluded.logical_clock >= sync_tombstones.logical_clock
|
||||
`;
|
||||
|
||||
export async function syncPushDocument(
|
||||
request: Request,
|
||||
env: Env,
|
||||
context: AuthContext,
|
||||
nowSeconds = Math.floor(Date.now() / 1000),
|
||||
): Promise<SyncPushDocument> {
|
||||
const deviceId = currentDeviceId(context);
|
||||
const push = await syncPushRequest(request, context.userId);
|
||||
const existingRow = await env.ELY_DB.prepare(SYNC_OBJECT_BY_ID_QUERY)
|
||||
.bind(context.userId, push.objectId)
|
||||
.first<SyncObjectRow>();
|
||||
if (existingRow !== null) {
|
||||
assertPushCanReplaceExisting(push, deviceId, syncObjectDocument(existingRow));
|
||||
}
|
||||
|
||||
await persistR2PayloadIfNeeded(env, push);
|
||||
await env.ELY_DB.batch(syncPushStatements(env, context.userId, deviceId, push, nowSeconds));
|
||||
|
||||
const savedRow = await env.ELY_DB.prepare(SYNC_OBJECT_BY_ID_QUERY)
|
||||
.bind(context.userId, push.objectId)
|
||||
.first<SyncObjectRow>();
|
||||
if (savedRow === null) {
|
||||
throw new SyncPushPersistenceError("sync_object_missing");
|
||||
}
|
||||
const object = syncObjectDocument(savedRow);
|
||||
assertSavedObjectMatchesPush(push, deviceId, object);
|
||||
|
||||
return { version: 1, user_id: context.userId, device_id: deviceId, object };
|
||||
}
|
||||
|
||||
async function persistR2PayloadIfNeeded(env: Env, push: SyncPushRequest): Promise<void> {
|
||||
if (push.payload.kind !== "r2") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await putVerifiedObject(
|
||||
env.ELY_STORAGE,
|
||||
push.payload.r2Key,
|
||||
push.payload.bytes,
|
||||
push.payloadHash,
|
||||
"application/octet-stream",
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof StorageObjectError) {
|
||||
throw new SyncPushRequestError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function syncPushStatements(
|
||||
env: Env,
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
push: SyncPushRequest,
|
||||
nowSeconds: number,
|
||||
): ElyD1PreparedStatement[] {
|
||||
const deletedAt = push.operation === "delete" ? nowSeconds : null;
|
||||
const isDelete = push.operation === "delete" ? 1 : 0;
|
||||
const statements = [
|
||||
env.ELY_DB.prepare(SYNC_OBJECT_UPSERT_QUERY).bind(
|
||||
userId,
|
||||
push.objectId,
|
||||
push.objectType,
|
||||
push.payload.kind === "inline" ? push.payload.bytes : null,
|
||||
push.payload.r2Key,
|
||||
push.payloadHash,
|
||||
push.schemaRev,
|
||||
push.logicalClock,
|
||||
deviceId,
|
||||
nowSeconds,
|
||||
nowSeconds,
|
||||
deletedAt,
|
||||
),
|
||||
env.ELY_DB.prepare(SYNC_CHANGE_INSERT_QUERY).bind(
|
||||
userId,
|
||||
push.objectId,
|
||||
push.objectType,
|
||||
push.operation,
|
||||
push.payloadHash,
|
||||
push.logicalClock,
|
||||
deviceId,
|
||||
nowSeconds,
|
||||
userId,
|
||||
push.objectId,
|
||||
push.objectType,
|
||||
push.payloadHash,
|
||||
push.logicalClock,
|
||||
deviceId,
|
||||
isDelete,
|
||||
isDelete,
|
||||
),
|
||||
];
|
||||
if (push.operation === "delete") {
|
||||
statements.push(
|
||||
env.ELY_DB.prepare(SYNC_TOMBSTONE_UPSERT_QUERY).bind(
|
||||
userId,
|
||||
push.objectId,
|
||||
push.logicalClock,
|
||||
),
|
||||
);
|
||||
}
|
||||
return statements;
|
||||
}
|
||||
|
||||
function assertPushCanReplaceExisting(
|
||||
push: SyncPushRequest,
|
||||
deviceId: string,
|
||||
existing: SyncPushedObjectDocument,
|
||||
): void {
|
||||
if (existing.logical_clock > push.logicalClock) {
|
||||
throw new SyncPushConflictError("logical_clock_stale");
|
||||
}
|
||||
if (existing.logical_clock < push.logicalClock) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
existing.operation !== push.operation ||
|
||||
existing.payload_hash !== push.payloadHash ||
|
||||
existing.device_id !== deviceId
|
||||
) {
|
||||
throw new SyncPushConflictError("logical_clock_conflict");
|
||||
}
|
||||
}
|
||||
|
||||
function assertSavedObjectMatchesPush(
|
||||
push: SyncPushRequest,
|
||||
deviceId: string,
|
||||
object: SyncPushedObjectDocument,
|
||||
): void {
|
||||
if (object.logical_clock > push.logicalClock) {
|
||||
throw new SyncPushConflictError("logical_clock_stale");
|
||||
}
|
||||
if (
|
||||
object.logical_clock === push.logicalClock &&
|
||||
(object.object_type !== push.objectType ||
|
||||
object.operation !== push.operation ||
|
||||
object.payload_hash !== push.payloadHash ||
|
||||
object.schema_rev !== push.schemaRev ||
|
||||
object.device_id !== deviceId ||
|
||||
object.payload_r2_key !== push.payload.r2Key)
|
||||
) {
|
||||
throw new SyncPushConflictError("logical_clock_conflict");
|
||||
}
|
||||
if (
|
||||
object.object_id !== push.objectId ||
|
||||
object.object_type !== push.objectType ||
|
||||
object.operation !== push.operation ||
|
||||
object.payload_hash !== push.payloadHash ||
|
||||
object.schema_rev !== push.schemaRev ||
|
||||
object.logical_clock !== push.logicalClock ||
|
||||
object.device_id !== deviceId
|
||||
) {
|
||||
throw new SyncPushPersistenceError("sync_object_mismatch");
|
||||
}
|
||||
}
|
||||
@@ -1,348 +0,0 @@
|
||||
import type { AuthContext } from "./auth.js";
|
||||
import {
|
||||
StorageObjectError,
|
||||
assertSyncObjectType,
|
||||
syncPayloadKey,
|
||||
} from "./storage.js";
|
||||
|
||||
const MAX_INLINE_PAYLOAD_BYTES = 64 * 1024;
|
||||
const MAX_R2_PAYLOAD_BYTES = 10 * 1024 * 1024;
|
||||
const SYNC_OBJECT_ID_PATTERN = /^[a-zA-Z0-9._:-]{1,128}$/;
|
||||
const SYNC_OBJECT_TYPE_PATTERN = /^[a-z0-9][a-z0-9._:-]{0,127}$/;
|
||||
const SHA256_HEX = /^[a-f0-9]{64}$/;
|
||||
const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
||||
const REGION = /^[a-z0-9][a-z0-9-]{1,31}$/;
|
||||
|
||||
export interface SyncPushDocument {
|
||||
version: 1;
|
||||
user_id: string;
|
||||
device_id: string;
|
||||
object: SyncPushedObjectDocument;
|
||||
}
|
||||
|
||||
export interface SyncPushedObjectDocument {
|
||||
object_id: string;
|
||||
object_type: string;
|
||||
operation: "upsert" | "delete";
|
||||
payload_hash: string;
|
||||
schema_rev: number;
|
||||
logical_clock: number;
|
||||
device_id: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
deleted_at: number | null;
|
||||
payload_storage: "inline" | "r2" | "tombstone";
|
||||
payload_r2_key: string | null;
|
||||
}
|
||||
|
||||
export interface SyncPushRequest {
|
||||
objectId: string;
|
||||
objectType: string;
|
||||
operation: "upsert" | "delete";
|
||||
payloadHash: string;
|
||||
schemaRev: number;
|
||||
logicalClock: number;
|
||||
payload: SyncPushPayload;
|
||||
}
|
||||
|
||||
export type SyncPushPayload =
|
||||
| { kind: "inline"; bytes: ArrayBuffer; r2Key: null }
|
||||
| { kind: "r2"; bytes: ArrayBuffer; region: string; r2Key: string }
|
||||
| { kind: "tombstone"; bytes: null; r2Key: null };
|
||||
|
||||
export interface SyncObjectRow {
|
||||
object_id: unknown;
|
||||
object_type: unknown;
|
||||
payload_r2_key: unknown;
|
||||
payload_hash: unknown;
|
||||
schema_rev: unknown;
|
||||
logical_clock: unknown;
|
||||
device_id: unknown;
|
||||
created_at: unknown;
|
||||
updated_at: unknown;
|
||||
deleted_at: unknown;
|
||||
}
|
||||
|
||||
type RequestBody = Record<string, unknown>;
|
||||
|
||||
export class SyncPushRequestError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "SyncPushRequestError";
|
||||
}
|
||||
}
|
||||
|
||||
export class SyncPushConflictError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "SyncPushConflictError";
|
||||
}
|
||||
}
|
||||
|
||||
export class SyncPushPersistenceError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "SyncPushPersistenceError";
|
||||
}
|
||||
}
|
||||
|
||||
export function currentDeviceId(context: AuthContext): string {
|
||||
if (context.deviceId === undefined) {
|
||||
throw new SyncPushRequestError("device_context_required");
|
||||
}
|
||||
return context.deviceId;
|
||||
}
|
||||
|
||||
export async function syncPushRequest(
|
||||
request: Request,
|
||||
userId: string,
|
||||
): Promise<SyncPushRequest> {
|
||||
const body = await requestBody(request);
|
||||
assertOnlyFields(body, [
|
||||
"version",
|
||||
"object_id",
|
||||
"object_type",
|
||||
"operation",
|
||||
"payload_hash",
|
||||
"schema_rev",
|
||||
"logical_clock",
|
||||
"payload",
|
||||
]);
|
||||
if (body.version !== 1) {
|
||||
throw new SyncPushRequestError("version_invalid");
|
||||
}
|
||||
|
||||
const objectId = syncObjectId(body.object_id);
|
||||
const operation = syncOperation(body.operation);
|
||||
const objectType = syncObjectType(body.object_type);
|
||||
const payloadHash = sha256HexValue(body.payload_hash, "payload_hash");
|
||||
const payload =
|
||||
operation === "delete"
|
||||
? tombstonePayload(body.payload)
|
||||
: await upsertPayload(body.payload, userId, objectType, objectId, payloadHash);
|
||||
|
||||
return {
|
||||
objectId,
|
||||
objectType,
|
||||
operation,
|
||||
payloadHash,
|
||||
schemaRev: integer(body.schema_rev, "schema_rev", 1, Number.MAX_SAFE_INTEGER),
|
||||
logicalClock: integer(body.logical_clock, "logical_clock", 0, Number.MAX_SAFE_INTEGER),
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
export function syncObjectDocument(row: SyncObjectRow): SyncPushedObjectDocument {
|
||||
const deletedAt = nullableInteger(row.deleted_at, "deleted_at", 0, Number.MAX_SAFE_INTEGER);
|
||||
const payloadR2Key = nullableText(row.payload_r2_key, "payload_r2_key");
|
||||
return {
|
||||
object_id: syncObjectId(row.object_id),
|
||||
object_type: syncObjectType(row.object_type),
|
||||
operation: deletedAt === null ? "upsert" : "delete",
|
||||
payload_hash: sha256HexValue(row.payload_hash, "payload_hash"),
|
||||
schema_rev: integer(row.schema_rev, "schema_rev", 1, Number.MAX_SAFE_INTEGER),
|
||||
logical_clock: integer(row.logical_clock, "logical_clock", 0, Number.MAX_SAFE_INTEGER),
|
||||
device_id: syncObjectId(row.device_id),
|
||||
created_at: integer(row.created_at, "created_at", 0, Number.MAX_SAFE_INTEGER),
|
||||
updated_at: integer(row.updated_at, "updated_at", 0, Number.MAX_SAFE_INTEGER),
|
||||
deleted_at: deletedAt,
|
||||
payload_storage: deletedAt !== null ? "tombstone" : payloadR2Key === null ? "inline" : "r2",
|
||||
payload_r2_key: payloadR2Key,
|
||||
};
|
||||
}
|
||||
|
||||
async function upsertPayload(
|
||||
value: unknown,
|
||||
userId: string,
|
||||
objectType: string,
|
||||
objectId: string,
|
||||
payloadHash: string,
|
||||
): Promise<SyncPushPayload> {
|
||||
const payload = record(value, "payload");
|
||||
const kind = text(payload.kind, "payload.kind");
|
||||
if (kind === "inline") {
|
||||
assertOnlyFields(payload, ["kind", "data_base64"]);
|
||||
const bytes = payloadBytes(payload.data_base64, "payload.data_base64", MAX_INLINE_PAYLOAD_BYTES);
|
||||
await assertPayloadHash(bytes, payloadHash);
|
||||
return { kind, bytes, r2Key: null };
|
||||
}
|
||||
if (kind === "r2") {
|
||||
assertOnlyFields(payload, ["kind", "region", "data_base64"]);
|
||||
const region = regionValue(payload.region);
|
||||
const bytes = payloadBytes(payload.data_base64, "payload.data_base64", MAX_R2_PAYLOAD_BYTES);
|
||||
await assertPayloadHash(bytes, payloadHash);
|
||||
return {
|
||||
kind,
|
||||
bytes,
|
||||
region,
|
||||
r2Key: await syncPayloadStorageKey(region, userId, objectType, objectId, payloadHash),
|
||||
};
|
||||
}
|
||||
throw new SyncPushRequestError("payload.kind_invalid");
|
||||
}
|
||||
|
||||
async function syncPayloadStorageKey(
|
||||
region: string,
|
||||
userId: string,
|
||||
objectType: string,
|
||||
objectId: string,
|
||||
payloadHash: string,
|
||||
): Promise<string> {
|
||||
try {
|
||||
return syncPayloadKey({
|
||||
region,
|
||||
userHash: await sha256Hex(arrayBufferFromBytes(new TextEncoder().encode(userId))),
|
||||
objectType,
|
||||
objectId,
|
||||
payloadHash,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof StorageObjectError) {
|
||||
throw new SyncPushRequestError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function tombstonePayload(value: unknown): SyncPushPayload {
|
||||
if (value !== undefined) {
|
||||
throw new SyncPushRequestError("payload_forbidden");
|
||||
}
|
||||
return { kind: "tombstone", bytes: null, r2Key: null };
|
||||
}
|
||||
|
||||
async function requestBody(request: Request): Promise<RequestBody> {
|
||||
let value: unknown;
|
||||
try {
|
||||
value = await request.json();
|
||||
} catch {
|
||||
throw new SyncPushRequestError("json_invalid");
|
||||
}
|
||||
return record(value, "body");
|
||||
}
|
||||
|
||||
function record(value: unknown, label: string): RequestBody {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new SyncPushRequestError(`${label}_invalid`);
|
||||
}
|
||||
return value as RequestBody;
|
||||
}
|
||||
|
||||
function assertOnlyFields(value: RequestBody, fields: string[]): void {
|
||||
const allowed = new Set(fields);
|
||||
for (const field of Object.keys(value)) {
|
||||
if (!allowed.has(field)) {
|
||||
throw new SyncPushRequestError(`unexpected_field:${field}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function syncOperation(value: unknown): SyncPushRequest["operation"] {
|
||||
if (value !== "upsert" && value !== "delete") {
|
||||
throw new SyncPushRequestError("operation_invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function syncObjectId(value: unknown): string {
|
||||
if (typeof value !== "string" || !SYNC_OBJECT_ID_PATTERN.test(value)) {
|
||||
throw new SyncPushRequestError("object_id_invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function syncObjectType(value: unknown): string {
|
||||
if (typeof value !== "string" || !SYNC_OBJECT_TYPE_PATTERN.test(value)) {
|
||||
throw new SyncPushRequestError("object_type_invalid");
|
||||
}
|
||||
try {
|
||||
assertSyncObjectType(value);
|
||||
} catch (error) {
|
||||
if (error instanceof StorageObjectError) {
|
||||
throw new SyncPushRequestError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function sha256HexValue(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !SHA256_HEX.test(value)) {
|
||||
throw new SyncPushRequestError(`${label}_invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function integer(value: unknown, label: string, min: number, max: number): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < min || value > max) {
|
||||
throw new SyncPushRequestError(`${label}_invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function nullableInteger(value: unknown, label: string, min: number, max: number): number | null {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
return integer(value, label, min, max);
|
||||
}
|
||||
|
||||
function text(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
throw new SyncPushRequestError(`${label}_invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function nullableText(value: unknown, label: string): string | null {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
return text(value, label);
|
||||
}
|
||||
|
||||
function regionValue(value: unknown): string {
|
||||
if (typeof value !== "string" || !REGION.test(value)) {
|
||||
throw new SyncPushRequestError("payload.region_invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function payloadBytes(value: unknown, label: string, maxBytes: number): ArrayBuffer {
|
||||
const encoded = text(value, label);
|
||||
if (!BASE64.test(encoded)) {
|
||||
throw new SyncPushRequestError(`${label}_invalid`);
|
||||
}
|
||||
const bytes = bytesFromBase64(encoded);
|
||||
if (bytes.byteLength === 0 || bytes.byteLength > maxBytes) {
|
||||
throw new SyncPushRequestError(`${label}_size_invalid`);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function bytesFromBase64(value: string): ArrayBuffer {
|
||||
const binary = atob(value);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
function arrayBufferFromBytes(bytes: Uint8Array): ArrayBuffer {
|
||||
const copy = new Uint8Array(bytes.byteLength);
|
||||
copy.set(bytes);
|
||||
return copy.buffer;
|
||||
}
|
||||
|
||||
async function assertPayloadHash(payload: ArrayBuffer, expectedHash: string): Promise<void> {
|
||||
const actualHash = await sha256Hex(payload);
|
||||
if (actualHash !== expectedHash) {
|
||||
throw new SyncPushRequestError("payload_hash_mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
async function sha256Hex(payload: ArrayBuffer): Promise<string> {
|
||||
const digest = await crypto.subtle.digest("SHA-256", payload);
|
||||
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
import type {
|
||||
ElyD1DatabaseSession,
|
||||
ElyD1PreparedStatement,
|
||||
ElyD1Result,
|
||||
Env,
|
||||
} from "./bindings.js";
|
||||
import { primaryD1Session } from "./bindings.js";
|
||||
import { StorageObjectError, assertKnownObjectKey, deleteKnownObject } from "./storage.js";
|
||||
|
||||
const WRITE_LEASE_SECONDS = 10 * 60;
|
||||
const DELETE_RETRY_SECONDS = 60;
|
||||
const DEFAULT_GC_LIMIT = 25;
|
||||
|
||||
const CLAIM_NEW_SNAPSHOT_QUERY = `
|
||||
WITH write (
|
||||
user_id, device_id, r2_key, owner_hash, key_id, generation,
|
||||
head_revision, base_revision, base_snapshot_id, base_payload_hash,
|
||||
write_token, now_seconds, lease_expires_at
|
||||
) AS (VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?))
|
||||
INSERT INTO sync_r2_gc_candidates (
|
||||
r2_key, user_id, owner_hash, object_kind, state, write_token,
|
||||
lease_expires_at, gc_token, created_at, updated_at, referenced_at,
|
||||
ready_at, delete_started_at, deleted_at
|
||||
)
|
||||
SELECT
|
||||
write.r2_key, write.user_id, write.owner_hash, 'snapshot', 'pending',
|
||||
write.write_token, write.lease_expires_at, NULL, write.now_seconds,
|
||||
write.now_seconds, NULL, NULL, NULL, NULL
|
||||
FROM write
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM sync_vault_accounts AS account
|
||||
INNER JOIN user_devices AS device
|
||||
ON device.user_id = account.user_id
|
||||
AND device.device_id = write.device_id
|
||||
AND device.approval_status = 'approved'
|
||||
AND device.revoked_at IS NULL
|
||||
INNER JOIN user_device_keys AS keys
|
||||
ON keys.user_id = device.user_id
|
||||
AND keys.device_id = device.device_id
|
||||
AND keys.key_protocol_version = 2
|
||||
AND keys.wrapping_public_key IS NOT NULL
|
||||
WHERE account.user_id = write.user_id
|
||||
AND account.current_key_id = write.key_id
|
||||
AND account.current_generation = write.generation
|
||||
)
|
||||
AND (
|
||||
(
|
||||
write.head_revision = 1
|
||||
AND write.base_revision IS NULL
|
||||
AND write.base_snapshot_id IS NULL
|
||||
AND write.base_payload_hash IS NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM sync_snapshot_heads AS head
|
||||
WHERE head.user_id = write.user_id
|
||||
)
|
||||
)
|
||||
OR
|
||||
(
|
||||
write.base_revision IS NOT NULL
|
||||
AND write.base_snapshot_id IS NOT NULL
|
||||
AND write.base_payload_hash IS NOT NULL
|
||||
AND write.head_revision = write.base_revision + 1
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM sync_snapshot_heads AS head
|
||||
WHERE head.user_id = write.user_id
|
||||
AND head.head_revision = write.base_revision
|
||||
AND head.snapshot_id = write.base_snapshot_id
|
||||
AND head.payload_hash = write.base_payload_hash
|
||||
)
|
||||
)
|
||||
)
|
||||
ON CONFLICT(r2_key) DO UPDATE SET
|
||||
write_token = excluded.write_token,
|
||||
lease_expires_at = excluded.lease_expires_at,
|
||||
updated_at = excluded.updated_at
|
||||
WHERE sync_r2_gc_candidates.user_id = excluded.user_id
|
||||
AND sync_r2_gc_candidates.owner_hash = excluded.owner_hash
|
||||
AND sync_r2_gc_candidates.object_kind = 'snapshot'
|
||||
AND sync_r2_gc_candidates.state = 'pending'
|
||||
`;
|
||||
|
||||
export const SYNC_R2_MARK_REFERENCED_QUERY = `
|
||||
UPDATE sync_r2_gc_candidates
|
||||
SET state = 'referenced', lease_expires_at = ?, updated_at = ?, referenced_at = ?
|
||||
WHERE r2_key = ? AND user_id = ?
|
||||
AND object_kind = 'snapshot'
|
||||
AND state = 'pending'
|
||||
AND write_token = ?
|
||||
AND lease_expires_at >= ?
|
||||
`;
|
||||
|
||||
export const SYNC_R2_FENCE_USER_QUERY = `
|
||||
UPDATE sync_r2_gc_candidates
|
||||
SET
|
||||
state = 'ready',
|
||||
lease_expires_at = CASE WHEN state = 'pending' THEN lease_expires_at ELSE ? END,
|
||||
updated_at = MAX(updated_at, ?),
|
||||
ready_at = COALESCE(ready_at, ?)
|
||||
WHERE user_id = ? AND state IN ('pending', 'referenced')
|
||||
`;
|
||||
|
||||
export const SYNC_R2_ANONYMIZE_USER_QUERY = `
|
||||
UPDATE sync_r2_gc_candidates
|
||||
SET user_id = NULL, updated_at = MAX(updated_at, ?)
|
||||
WHERE user_id = ? AND owner_hash = ?
|
||||
`;
|
||||
|
||||
const ABANDON_WRITE_QUERY = `
|
||||
UPDATE sync_r2_gc_candidates AS candidate
|
||||
SET
|
||||
state = 'ready',
|
||||
lease_expires_at = ?,
|
||||
updated_at = MAX(updated_at, ?),
|
||||
ready_at = COALESCE(ready_at, ?)
|
||||
WHERE candidate.r2_key = ?
|
||||
AND candidate.owner_hash = ?
|
||||
AND (candidate.user_id = ? OR candidate.user_id IS NULL)
|
||||
AND candidate.state IN ('pending', 'ready')
|
||||
AND candidate.write_token = ?
|
||||
AND NOT EXISTS (SELECT 1 FROM sync_objects WHERE payload_r2_key = candidate.r2_key)
|
||||
AND NOT EXISTS (SELECT 1 FROM sync_snapshots WHERE r2_key = candidate.r2_key)
|
||||
`;
|
||||
|
||||
const CLAIMED_CANDIDATES_QUERY = `
|
||||
SELECT r2_key
|
||||
FROM sync_r2_gc_candidates
|
||||
WHERE state = 'deleting' AND gc_token = ?
|
||||
ORDER BY r2_key ASC
|
||||
`;
|
||||
|
||||
const MARK_DELETED_QUERY = `
|
||||
UPDATE sync_r2_gc_candidates
|
||||
SET state = 'deleted', updated_at = ?, deleted_at = ?
|
||||
WHERE r2_key = ? AND state = 'deleting' AND gc_token = ?
|
||||
`;
|
||||
|
||||
interface SnapshotHeadRef {
|
||||
revision: number;
|
||||
snapshotId: string;
|
||||
payloadHash: string;
|
||||
}
|
||||
|
||||
export interface SyncR2SnapshotWriteClaim {
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
r2Key: string;
|
||||
ownerHash: string;
|
||||
keyId: string;
|
||||
generation: number;
|
||||
headRevision: number;
|
||||
baseHead: SnapshotHeadRef | null;
|
||||
}
|
||||
|
||||
export interface SyncR2WriteLease {
|
||||
writeToken: string;
|
||||
leaseExpiresAt: number;
|
||||
}
|
||||
|
||||
interface CandidateRow { r2_key: unknown }
|
||||
|
||||
export class SyncR2GcError extends Error {}
|
||||
export class SyncR2WriteFenceError extends Error {}
|
||||
|
||||
export async function claimSyncR2SnapshotWrite(
|
||||
env: Env,
|
||||
claim: SyncR2SnapshotWriteClaim,
|
||||
nowSeconds: number,
|
||||
writeToken = randomToken(),
|
||||
database: ElyD1DatabaseSession = primaryD1Session(env.ELY_DB),
|
||||
): Promise<SyncR2WriteLease> {
|
||||
const leaseExpiresAt = nowSeconds + WRITE_LEASE_SECONDS;
|
||||
const result = await database.prepare(CLAIM_NEW_SNAPSHOT_QUERY).bind(
|
||||
claim.userId,
|
||||
claim.deviceId,
|
||||
claim.r2Key,
|
||||
claim.ownerHash,
|
||||
claim.keyId,
|
||||
claim.generation,
|
||||
claim.headRevision,
|
||||
claim.baseHead?.revision ?? null,
|
||||
claim.baseHead?.snapshotId ?? null,
|
||||
claim.baseHead?.payloadHash ?? null,
|
||||
writeToken,
|
||||
nowSeconds,
|
||||
leaseExpiresAt,
|
||||
).run();
|
||||
if (changedRows(result) !== 1) {
|
||||
throw new SyncR2WriteFenceError("sync_r2_write_fenced");
|
||||
}
|
||||
return { writeToken, leaseExpiresAt };
|
||||
}
|
||||
|
||||
export function syncR2MarkReferencedStatement(
|
||||
database: ElyD1DatabaseSession,
|
||||
userId: string,
|
||||
r2Key: string,
|
||||
lease: SyncR2WriteLease,
|
||||
nowSeconds: number,
|
||||
): ElyD1PreparedStatement {
|
||||
return database.prepare(SYNC_R2_MARK_REFERENCED_QUERY).bind(
|
||||
nowSeconds,
|
||||
nowSeconds,
|
||||
nowSeconds,
|
||||
r2Key,
|
||||
userId,
|
||||
lease.writeToken,
|
||||
nowSeconds,
|
||||
);
|
||||
}
|
||||
|
||||
export async function abandonSyncR2Write(
|
||||
env: Env,
|
||||
userId: string,
|
||||
ownerHash: string,
|
||||
r2Key: string,
|
||||
writeToken: string,
|
||||
nowSeconds: number,
|
||||
database: ElyD1DatabaseSession = primaryD1Session(env.ELY_DB),
|
||||
): Promise<void> {
|
||||
await database.prepare(ABANDON_WRITE_QUERY).bind(
|
||||
nowSeconds,
|
||||
nowSeconds,
|
||||
nowSeconds,
|
||||
r2Key,
|
||||
ownerHash,
|
||||
userId,
|
||||
writeToken,
|
||||
).run();
|
||||
}
|
||||
|
||||
export async function collectSyncR2Garbage(
|
||||
env: Env,
|
||||
nowSeconds: number,
|
||||
options: {
|
||||
userId?: string;
|
||||
ownerHash?: string;
|
||||
limit?: number;
|
||||
database?: ElyD1DatabaseSession;
|
||||
} = {},
|
||||
): Promise<number> {
|
||||
const database = options.database ?? primaryD1Session(env.ELY_DB);
|
||||
const gcToken = randomToken();
|
||||
const retryBefore = Math.max(0, nowSeconds - DELETE_RETRY_SECONDS);
|
||||
const scope = options.userId === undefined
|
||||
? options.ownerHash === undefined ? "global" : "owner"
|
||||
: "user";
|
||||
const scopeValue = options.userId ?? options.ownerHash;
|
||||
await database.prepare(claimGarbageQuery(scope)).bind(
|
||||
gcToken,
|
||||
nowSeconds,
|
||||
nowSeconds,
|
||||
nowSeconds,
|
||||
nowSeconds,
|
||||
retryBefore,
|
||||
...(scopeValue === undefined ? [] : [scopeValue]),
|
||||
options.limit ?? DEFAULT_GC_LIMIT,
|
||||
).run();
|
||||
const claimed = await database.prepare(CLAIMED_CANDIDATES_QUERY)
|
||||
.bind(gcToken)
|
||||
.all<CandidateRow>();
|
||||
for (const row of claimed.results) {
|
||||
const key = storedR2Key(row.r2_key);
|
||||
await deleteKnownObject(env.ELY_STORAGE, key);
|
||||
const result = await database.prepare(MARK_DELETED_QUERY)
|
||||
.bind(nowSeconds, nowSeconds, key, gcToken)
|
||||
.run();
|
||||
if (changedRows(result) !== 1) {
|
||||
throw new SyncR2GcError("sync_r2_gc_finalize_failed");
|
||||
}
|
||||
}
|
||||
return claimed.results.length;
|
||||
}
|
||||
|
||||
function claimGarbageQuery(scope: "global" | "user" | "owner"): string {
|
||||
const scopeSql = scope === "user"
|
||||
? "AND candidate.user_id = ?"
|
||||
: scope === "owner" ? "AND candidate.owner_hash = ?" : "";
|
||||
return `
|
||||
UPDATE sync_r2_gc_candidates
|
||||
SET
|
||||
state = 'deleting',
|
||||
gc_token = ?,
|
||||
delete_started_at = ?,
|
||||
updated_at = MAX(updated_at, ?),
|
||||
ready_at = COALESCE(ready_at, ?)
|
||||
WHERE r2_key IN (
|
||||
SELECT candidate.r2_key
|
||||
FROM sync_r2_gc_candidates AS candidate
|
||||
WHERE (
|
||||
(candidate.state IN ('pending', 'ready') AND candidate.lease_expires_at <= ?)
|
||||
OR (candidate.state = 'deleting' AND candidate.delete_started_at <= ?)
|
||||
)
|
||||
${scopeSql}
|
||||
AND NOT EXISTS (SELECT 1 FROM sync_objects WHERE payload_r2_key = candidate.r2_key)
|
||||
AND NOT EXISTS (SELECT 1 FROM sync_snapshots WHERE r2_key = candidate.r2_key)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM sync_snapshot_heads AS head
|
||||
INNER JOIN sync_snapshots AS snapshot
|
||||
ON snapshot.user_id = head.user_id
|
||||
AND snapshot.snapshot_id = head.snapshot_id
|
||||
AND snapshot.head_revision = head.head_revision
|
||||
AND snapshot.payload_hash = head.payload_hash
|
||||
WHERE snapshot.r2_key = candidate.r2_key
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM sync_vault_rotation_r2_objects AS staged
|
||||
INNER JOIN sync_vault_rotations AS rotation
|
||||
ON rotation.user_id = staged.user_id
|
||||
AND rotation.idempotency_key = staged.rotation_idempotency_key
|
||||
WHERE staged.r2_key = candidate.r2_key
|
||||
AND rotation.cleanup_started_at IS NULL
|
||||
)
|
||||
ORDER BY candidate.updated_at ASC, candidate.r2_key ASC
|
||||
LIMIT ?
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
function storedR2Key(value: unknown): string {
|
||||
if (typeof value !== "string") throw new SyncR2GcError("sync_r2_key_invalid");
|
||||
try {
|
||||
assertKnownObjectKey(value);
|
||||
} catch (error) {
|
||||
if (error instanceof StorageObjectError) throw new SyncR2GcError(error.message);
|
||||
throw error;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function changedRows(result: unknown): number {
|
||||
if (typeof result !== "object" || result === null || !("meta" in result)) return -1;
|
||||
const changes = (result as ElyD1Result).meta?.changes;
|
||||
return typeof changes === "number" && Number.isSafeInteger(changes) ? changes : -1;
|
||||
}
|
||||
|
||||
function randomToken(): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(32));
|
||||
return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { Env } from "./bindings.js";
|
||||
import { primaryD1Session } from "./bindings.js";
|
||||
import { StorageObjectError, assertKnownObjectKey } from "./storage.js";
|
||||
|
||||
const INVENTORY_RESCAN_SECONDS = 24 * 60 * 60;
|
||||
const DEFAULT_INVENTORY_LIMIT = 100;
|
||||
const SHA256_HEX = /^[a-f0-9]{64}$/;
|
||||
|
||||
const INVENTORY_CURSOR_QUERY = `
|
||||
SELECT prefix, cursor
|
||||
FROM sync_r2_inventory_cursors
|
||||
WHERE next_scan_at <= ?
|
||||
ORDER BY next_scan_at ASC, updated_at ASC, prefix ASC
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
const INVENTORY_CANDIDATE_QUERY = `
|
||||
INSERT INTO sync_r2_gc_candidates (
|
||||
r2_key, user_id, owner_hash, object_kind, state, write_token,
|
||||
lease_expires_at, gc_token, created_at, updated_at, referenced_at,
|
||||
ready_at, delete_started_at, deleted_at
|
||||
)
|
||||
SELECT ?, NULL, ?, ?, 'ready', NULL, ?, NULL, ?, ?, NULL, ?, NULL, NULL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM sync_objects WHERE payload_r2_key = ?)
|
||||
AND NOT EXISTS (SELECT 1 FROM sync_snapshots WHERE r2_key = ?)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM sync_snapshot_heads AS head
|
||||
INNER JOIN sync_snapshots AS snapshot
|
||||
ON snapshot.user_id = head.user_id
|
||||
AND snapshot.snapshot_id = head.snapshot_id
|
||||
AND snapshot.head_revision = head.head_revision
|
||||
AND snapshot.payload_hash = head.payload_hash
|
||||
WHERE snapshot.r2_key = ?
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM sync_vault_rotation_r2_objects AS staged
|
||||
INNER JOIN sync_vault_rotations AS rotation
|
||||
ON rotation.user_id = staged.user_id
|
||||
AND rotation.idempotency_key = staged.rotation_idempotency_key
|
||||
WHERE staged.r2_key = ? AND rotation.cleanup_started_at IS NULL
|
||||
)
|
||||
ON CONFLICT(r2_key) DO UPDATE SET
|
||||
state = 'ready',
|
||||
lease_expires_at = excluded.lease_expires_at,
|
||||
gc_token = NULL,
|
||||
updated_at = excluded.updated_at,
|
||||
ready_at = excluded.ready_at,
|
||||
delete_started_at = NULL,
|
||||
deleted_at = NULL
|
||||
WHERE sync_r2_gc_candidates.state = 'deleted'
|
||||
`;
|
||||
|
||||
const INVENTORY_CURSOR_UPDATE_QUERY = `
|
||||
UPDATE sync_r2_inventory_cursors
|
||||
SET cursor = ?, updated_at = ?, next_scan_at = ?
|
||||
WHERE prefix = ?
|
||||
`;
|
||||
|
||||
interface InventoryCursorRow { prefix: unknown; cursor: unknown }
|
||||
interface R2ListObject { key: string }
|
||||
interface R2ListResult {
|
||||
objects: R2ListObject[];
|
||||
truncated: boolean;
|
||||
cursor?: string;
|
||||
}
|
||||
interface ListableBucket {
|
||||
list(options: { prefix: string; cursor?: string; limit: number }): Promise<R2ListResult>;
|
||||
}
|
||||
|
||||
export class SyncR2InventoryError extends Error {}
|
||||
|
||||
export async function inventorySyncR2Objects(
|
||||
env: Env,
|
||||
nowSeconds: number,
|
||||
limit = DEFAULT_INVENTORY_LIMIT,
|
||||
): Promise<number> {
|
||||
const database = primaryD1Session(env.ELY_DB);
|
||||
const cursorRow = await database.prepare(INVENTORY_CURSOR_QUERY)
|
||||
.bind(nowSeconds)
|
||||
.first<InventoryCursorRow>();
|
||||
if (cursorRow === null) return 0;
|
||||
const prefix = inventoryPrefix(cursorRow.prefix);
|
||||
const cursor = inventoryCursor(cursorRow.cursor);
|
||||
const bucket = env.ELY_STORAGE as Env["ELY_STORAGE"] & Partial<ListableBucket>;
|
||||
if (typeof bucket.list !== "function") {
|
||||
throw new SyncR2InventoryError("sync_r2_inventory_unavailable");
|
||||
}
|
||||
const result = await bucket.list({ prefix, ...(cursor === null ? {} : { cursor }), limit });
|
||||
const statements = result.objects.flatMap((object) => {
|
||||
const candidate = inventoryCandidate(object.key, prefix);
|
||||
if (candidate === null) return [];
|
||||
return [database.prepare(INVENTORY_CANDIDATE_QUERY).bind(
|
||||
candidate.r2Key,
|
||||
candidate.ownerHash,
|
||||
candidate.objectKind,
|
||||
nowSeconds,
|
||||
nowSeconds,
|
||||
nowSeconds,
|
||||
nowSeconds,
|
||||
candidate.r2Key,
|
||||
candidate.r2Key,
|
||||
candidate.r2Key,
|
||||
candidate.r2Key,
|
||||
)];
|
||||
});
|
||||
const nextCursor = result.truncated ? result.cursor : null;
|
||||
if (result.truncated && typeof nextCursor !== "string") {
|
||||
throw new SyncR2InventoryError("sync_r2_inventory_cursor_invalid");
|
||||
}
|
||||
statements.push(database.prepare(INVENTORY_CURSOR_UPDATE_QUERY).bind(
|
||||
nextCursor,
|
||||
nowSeconds,
|
||||
result.truncated ? nowSeconds : nowSeconds + INVENTORY_RESCAN_SECONDS,
|
||||
prefix,
|
||||
));
|
||||
await database.batch(statements);
|
||||
return statements.length - 1;
|
||||
}
|
||||
|
||||
function inventoryCandidate(
|
||||
key: string,
|
||||
prefix: string,
|
||||
): { r2Key: string; ownerHash: string; objectKind: "payload" | "snapshot" } | null {
|
||||
try {
|
||||
assertKnownObjectKey(key);
|
||||
} catch (error) {
|
||||
if (error instanceof StorageObjectError) return null;
|
||||
throw error;
|
||||
}
|
||||
if (!key.startsWith(prefix)) return null;
|
||||
const ownerHash = key.split("/")[2] ?? "";
|
||||
if (!SHA256_HEX.test(ownerHash)) return null;
|
||||
return {
|
||||
r2Key: key,
|
||||
ownerHash,
|
||||
objectKind: prefix === "sync-payloads/" ? "payload" : "snapshot",
|
||||
};
|
||||
}
|
||||
|
||||
function inventoryPrefix(value: unknown): "sync-payloads/" | "sync-snapshots/" {
|
||||
if (value !== "sync-payloads/" && value !== "sync-snapshots/") {
|
||||
throw new SyncR2InventoryError("sync_r2_inventory_prefix_invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function inventoryCursor(value: unknown): string | null {
|
||||
if (value !== null && typeof value !== "string") {
|
||||
throw new SyncR2InventoryError("sync_r2_inventory_cursor_invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Env } from "./bindings.js";
|
||||
import { purgeLegacySessionCache } from "./legacy_auth_kv_cleanup.js";
|
||||
import { collectSyncR2Garbage } from "./sync_r2_gc.js";
|
||||
import { inventorySyncR2Objects } from "./sync_r2_inventory.js";
|
||||
import { finalizeCleanedVaultRotations } from "./sync_vault_rotation_cleanup.js";
|
||||
|
||||
export async function maintainSyncR2Storage(env: Env, nowSeconds: number): Promise<void> {
|
||||
const errors: unknown[] = [];
|
||||
for (const task of [
|
||||
() => purgeLegacySessionCache(env),
|
||||
() => inventorySyncR2Objects(env, nowSeconds),
|
||||
() => collectSyncR2Garbage(env, nowSeconds, { limit: 100 }),
|
||||
() => finalizeCleanedVaultRotations(env, nowSeconds),
|
||||
]) {
|
||||
try {
|
||||
await task();
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
if (errors.length > 0) throw new AggregateError(errors, "sync_storage_maintenance_failed");
|
||||
}
|
||||
+130
-55
@@ -1,6 +1,20 @@
|
||||
import type { AuthContext } from "./auth.js";
|
||||
import type { ElyD1PreparedStatement, Env } from "./bindings.js";
|
||||
import { StorageObjectError, deleteKnownObject } from "./storage.js";
|
||||
import type { ElyD1DatabaseSession, ElyD1PreparedStatement, ElyD1Result, Env } from "./bindings.js";
|
||||
import { primaryD1Session } from "./bindings.js";
|
||||
import {
|
||||
assertDestructiveActionGateResult,
|
||||
destructiveActionGateIsLive,
|
||||
destructiveActionGateStatement,
|
||||
} from "./destructive_action_gate.js";
|
||||
import {
|
||||
type RecentDeviceActionProof,
|
||||
RecentDeviceActionPermissionError,
|
||||
RecentDeviceActionRequestError,
|
||||
assertFreshDeviceActionProof,
|
||||
assertRecentDeviceActionProof,
|
||||
recentDeviceActionProof,
|
||||
} from "./recent_device_action_proof.js";
|
||||
import { SYNC_R2_FENCE_USER_QUERY, collectSyncR2Garbage } from "./sync_r2_gc.js";
|
||||
|
||||
const RESET_CONFIRMATION = "delete-cloud-sync-data";
|
||||
const IDEMPOTENCY_KEY_PATTERN = /^[a-zA-Z0-9._:-]{16,128}$/;
|
||||
@@ -17,33 +31,23 @@ const SYNC_RESET_COUNTS_QUERY = `
|
||||
(SELECT COUNT(*) FROM sync_tombstones WHERE user_id = ?) AS tombstones
|
||||
`;
|
||||
const SYNC_RESET_R2_KEYS_QUERY = `
|
||||
SELECT payload_r2_key AS r2_key
|
||||
FROM sync_objects
|
||||
WHERE user_id = ? AND payload_r2_key IS NOT NULL
|
||||
UNION
|
||||
SELECT r2_key
|
||||
FROM sync_snapshots
|
||||
WHERE user_id = ?
|
||||
SELECT r2_key FROM sync_r2_gc_candidates
|
||||
WHERE user_id = ? AND state <> 'deleted'
|
||||
ORDER BY r2_key ASC
|
||||
`;
|
||||
const SYNC_RESET_AUDIT_INSERT_QUERY = `
|
||||
INSERT INTO audit_events (
|
||||
event_id,
|
||||
user_id,
|
||||
actor_device_id,
|
||||
event_type,
|
||||
subject_type,
|
||||
subject_id,
|
||||
outcome,
|
||||
metadata_hash,
|
||||
created_at
|
||||
) VALUES (?, ?, ?, 'sync.reset', 'sync', ?, 'success', NULL, ?)
|
||||
ON CONFLICT(event_id) DO NOTHING
|
||||
`;
|
||||
const SYNC_OBJECTS_DELETE_QUERY = "DELETE FROM sync_objects WHERE user_id = ?";
|
||||
const SYNC_CHANGE_LOG_DELETE_QUERY = "DELETE FROM sync_change_log WHERE user_id = ?";
|
||||
const SYNC_SNAPSHOT_HEADS_DELETE_QUERY = "DELETE FROM sync_snapshot_heads WHERE user_id = ?";
|
||||
const SYNC_SNAPSHOT_ENCRYPTION_DELETE_QUERY =
|
||||
"DELETE FROM sync_snapshot_encryption WHERE user_id = ?";
|
||||
const SYNC_SNAPSHOTS_DELETE_QUERY = "DELETE FROM sync_snapshots WHERE user_id = ?";
|
||||
const SYNC_TOMBSTONES_DELETE_QUERY = "DELETE FROM sync_tombstones WHERE user_id = ?";
|
||||
const START_ROTATION_CLEANUP_QUERY = `
|
||||
UPDATE sync_vault_rotations
|
||||
SET cleanup_snapshot_id = 'sync-reset', cleanup_started_at = ?
|
||||
WHERE user_id = ? AND completed_at IS NOT NULL
|
||||
AND cleanup_started_at IS NULL AND storage_cleaned_at IS NULL
|
||||
`;
|
||||
|
||||
export interface SyncResetDocument {
|
||||
version: 1;
|
||||
@@ -62,7 +66,7 @@ export interface SyncResetDeletedDocument {
|
||||
r2_objects: number;
|
||||
}
|
||||
|
||||
interface SyncResetRequest {
|
||||
interface SyncResetRequest extends RecentDeviceActionProof {
|
||||
idempotencyKey: string;
|
||||
}
|
||||
|
||||
@@ -108,19 +112,55 @@ export async function syncResetDocument(
|
||||
const deviceId = currentDeviceId(context);
|
||||
const reset = await syncResetRequest(request);
|
||||
const eventId = syncResetEventId(context.userId, reset.idempotencyKey);
|
||||
const existingEvent = await env.ELY_DB.prepare(SYNC_RESET_EVENT_QUERY)
|
||||
const database = primaryD1Session(env.ELY_DB);
|
||||
const signingPublicKey = await assertRecentDeviceActionProof(
|
||||
database,
|
||||
context,
|
||||
"sync.reset",
|
||||
RESET_CONFIRMATION,
|
||||
reset.idempotencyKey,
|
||||
reset,
|
||||
);
|
||||
const existingEvent = await database.prepare(SYNC_RESET_EVENT_QUERY)
|
||||
.bind(context.userId, eventId)
|
||||
.first<SyncResetEventRow>();
|
||||
assertFreshDeviceActionProof(reset, nowSeconds, existingEvent === null);
|
||||
if (existingEvent !== null) {
|
||||
await collectResetGarbage(env, context.userId, nowSeconds, 100);
|
||||
return existingResetDocument(context, deviceId, reset, existingEvent);
|
||||
}
|
||||
|
||||
const counts = await syncResetCounts(env, context.userId);
|
||||
const r2Keys = await syncResetR2Keys(env, context.userId);
|
||||
for (const key of r2Keys) {
|
||||
await deleteResetObject(env, key);
|
||||
const counts = await syncResetCounts(database, context.userId);
|
||||
const r2Keys = await syncResetR2Keys(database, context.userId);
|
||||
let results: ElyD1Result[];
|
||||
try {
|
||||
results = await database.batch<ElyD1Result>(syncResetStatements(
|
||||
database,
|
||||
context,
|
||||
signingPublicKey,
|
||||
eventId,
|
||||
nowSeconds,
|
||||
));
|
||||
} catch (error) {
|
||||
const replayDatabase = primaryD1Session(env.ELY_DB);
|
||||
const racedEvent = await replayDatabase.prepare(SYNC_RESET_EVENT_QUERY)
|
||||
.bind(context.userId, eventId)
|
||||
.first<SyncResetEventRow>();
|
||||
if (racedEvent !== null) {
|
||||
return existingResetDocument(context, deviceId, reset, racedEvent);
|
||||
}
|
||||
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> {
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
const SNAPSHOT_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,127}$/;
|
||||
const DEVICE_ID_PATTERN = /^[a-zA-Z0-9._:-]{3,128}$/;
|
||||
const SHA256_HEX = /^[a-f0-9]{64}$/;
|
||||
const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
||||
const REGION = /^[a-z0-9][a-z0-9-]{1,31}$/;
|
||||
|
||||
export type SnapshotRequestBody = Record<string, unknown>;
|
||||
|
||||
export class SyncSnapshotRequestError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "SyncSnapshotRequestError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestBody(request: Request): Promise<SnapshotRequestBody> {
|
||||
let value: unknown;
|
||||
try {
|
||||
value = await request.json();
|
||||
} catch {
|
||||
throw new SyncSnapshotRequestError("json_invalid");
|
||||
}
|
||||
return record(value, "body");
|
||||
}
|
||||
|
||||
export function assertOnlyFields(value: SnapshotRequestBody, fields: string[]): void {
|
||||
const allowed = new Set(fields);
|
||||
for (const field of Object.keys(value)) {
|
||||
if (!allowed.has(field)) {
|
||||
throw new SyncSnapshotRequestError(`unexpected_field:${field}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function assertOnlyQueryParams(url: URL, fields: string[]): void {
|
||||
const allowed = new Set(fields);
|
||||
for (const field of url.searchParams.keys()) {
|
||||
if (!allowed.has(field)) {
|
||||
throw new SyncSnapshotRequestError(`unexpected_query:${field}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function snapshotIdValue(value: unknown): string {
|
||||
if (typeof value !== "string" || !SNAPSHOT_ID_PATTERN.test(value)) {
|
||||
throw new SyncSnapshotRequestError("snapshot_id_invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function deviceIdValue(value: unknown): string {
|
||||
if (typeof value !== "string" || !DEVICE_ID_PATTERN.test(value)) {
|
||||
throw new SyncSnapshotRequestError("device_id_invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function regionValue(value: unknown): string {
|
||||
if (typeof value !== "string" || !REGION.test(value)) {
|
||||
throw new SyncSnapshotRequestError("region_invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function sha256HexValue(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !SHA256_HEX.test(value)) {
|
||||
throw new SyncSnapshotRequestError(`${label}_invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function integer(value: unknown, label: string, min: number, max: number): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < min || value > max) {
|
||||
throw new SyncSnapshotRequestError(`${label}_invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function exactInteger<T extends number>(value: unknown, label: string, expected: T): T {
|
||||
if (value !== expected) {
|
||||
throw new SyncSnapshotRequestError(`${label}_invalid`);
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
export function text(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
throw new SyncSnapshotRequestError(`${label}_invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function payloadBytes(value: unknown, label: string, maxBytes: number): ArrayBuffer {
|
||||
const encoded = text(value, label);
|
||||
const maxEncodedLength = 4 * Math.ceil(maxBytes / 3);
|
||||
if (encoded.length > maxEncodedLength) {
|
||||
throw new SyncSnapshotRequestError(`${label}_size_invalid`);
|
||||
}
|
||||
if (!BASE64.test(encoded)) {
|
||||
throw new SyncSnapshotRequestError(`${label}_invalid`);
|
||||
}
|
||||
const bytes = bytesFromBase64(encoded);
|
||||
if (bytes.byteLength === 0 || bytes.byteLength > maxBytes) {
|
||||
throw new SyncSnapshotRequestError(`${label}_size_invalid`);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function base64FromBytes(payload: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(payload);
|
||||
const parts: string[] = [];
|
||||
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
|
||||
parts.push(String.fromCharCode(...bytes.subarray(offset, offset + 0x8000)));
|
||||
}
|
||||
return btoa(parts.join(""));
|
||||
}
|
||||
|
||||
export function arrayBufferFromBytes(bytes: Uint8Array): ArrayBuffer {
|
||||
const copy = new Uint8Array(bytes.byteLength);
|
||||
copy.set(bytes);
|
||||
return copy.buffer;
|
||||
}
|
||||
|
||||
export async function assertPayloadHash(
|
||||
payload: ArrayBuffer,
|
||||
expectedHash: string,
|
||||
): Promise<void> {
|
||||
const actualHash = await sha256Hex(payload);
|
||||
if (actualHash !== expectedHash) {
|
||||
throw new SyncSnapshotRequestError("payload_hash_mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
export async function sha256Hex(payload: ArrayBuffer): Promise<string> {
|
||||
const digest = await crypto.subtle.digest("SHA-256", payload);
|
||||
return [...new Uint8Array(digest)]
|
||||
.map((byte) => byte.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
function record(value: unknown, label: string): SnapshotRequestBody {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new SyncSnapshotRequestError(`${label}_invalid`);
|
||||
}
|
||||
return value as SnapshotRequestBody;
|
||||
}
|
||||
|
||||
function bytesFromBase64(value: string): ArrayBuffer {
|
||||
const binary = atob(value);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
return bytes.buffer;
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import type { ElyD1DatabaseSession, ElyD1Result } from "./bindings.js";
|
||||
import { StorageObjectError, assertKnownObjectKeyHash } from "./storage.js";
|
||||
import {
|
||||
SyncSnapshotRequestError,
|
||||
assertOnlyFields,
|
||||
deviceIdValue,
|
||||
integer,
|
||||
sha256HexValue,
|
||||
snapshotIdValue,
|
||||
text,
|
||||
} from "./sync_snapshot_codec.js";
|
||||
import {
|
||||
SYNC_SNAPSHOT_BY_TOKEN_QUERY,
|
||||
SYNC_SNAPSHOT_HEAD_QUERY,
|
||||
} from "./sync_snapshot_sql.js";
|
||||
|
||||
const MAX_SNAPSHOT_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
export interface SnapshotHeadRefDocument {
|
||||
revision: number;
|
||||
snapshot_id: string;
|
||||
payload_hash: string;
|
||||
}
|
||||
|
||||
export interface SyncSnapshotDocument {
|
||||
snapshot_id: string;
|
||||
r2_key: string;
|
||||
payload_hash: string;
|
||||
encryption_version: 1 | 2;
|
||||
vault_generation: number;
|
||||
key_id: string;
|
||||
content_hash: string;
|
||||
schema_rev: number;
|
||||
logical_clock: number;
|
||||
head_revision: number;
|
||||
base_head: SnapshotHeadRefDocument | null;
|
||||
device_id: string;
|
||||
size_bytes: number;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface SyncSnapshotRow {
|
||||
snapshot_id: unknown;
|
||||
r2_key: unknown;
|
||||
payload_hash: unknown;
|
||||
encryption_version: unknown;
|
||||
vault_generation: unknown;
|
||||
key_id: unknown;
|
||||
content_hash: unknown;
|
||||
schema_rev: unknown;
|
||||
logical_clock: unknown;
|
||||
head_revision: unknown;
|
||||
base_head_revision: unknown;
|
||||
base_snapshot_id: unknown;
|
||||
base_payload_hash: unknown;
|
||||
device_id: unknown;
|
||||
size_bytes: unknown;
|
||||
created_at: unknown;
|
||||
}
|
||||
|
||||
export interface SyncSnapshotHeadConflictDocument {
|
||||
version: 1;
|
||||
error: "sync_snapshot_head_conflict";
|
||||
current_head: SyncSnapshotDocument | null;
|
||||
}
|
||||
|
||||
export class SyncSnapshotHeadSchemaError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "SyncSnapshotHeadSchemaError";
|
||||
}
|
||||
}
|
||||
|
||||
export class SyncSnapshotConflictError extends Error {
|
||||
readonly currentHead: SyncSnapshotDocument | null;
|
||||
|
||||
constructor(message: string, currentHead: SyncSnapshotDocument | null) {
|
||||
super(message);
|
||||
this.name = "SyncSnapshotConflictError";
|
||||
this.currentHead = currentHead;
|
||||
}
|
||||
|
||||
document(): SyncSnapshotHeadConflictDocument {
|
||||
return {
|
||||
version: 1,
|
||||
error: "sync_snapshot_head_conflict",
|
||||
current_head: this.currentHead,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function snapshotHeadRefValue(
|
||||
value: unknown,
|
||||
headRevision: number,
|
||||
): SnapshotHeadRefDocument | null {
|
||||
if (value === null) {
|
||||
if (headRevision !== 1) {
|
||||
throw new SyncSnapshotRequestError("base_head_invalid");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const row = record(value, "base_head");
|
||||
assertOnlyFields(row, ["revision", "snapshot_id", "payload_hash"]);
|
||||
const revision = integer(row.revision, "base_head.revision", 1, Number.MAX_SAFE_INTEGER);
|
||||
if (revision >= Number.MAX_SAFE_INTEGER || revision + 1 !== headRevision) {
|
||||
throw new SyncSnapshotRequestError("base_head_invalid");
|
||||
}
|
||||
return {
|
||||
revision,
|
||||
snapshot_id: snapshotIdValue(row.snapshot_id),
|
||||
payload_hash: sha256HexValue(row.payload_hash, "base_head.payload_hash"),
|
||||
};
|
||||
}
|
||||
|
||||
export function snapshotDocumentFromRow(row: SyncSnapshotRow): SyncSnapshotDocument {
|
||||
try {
|
||||
const headRevision = integer(
|
||||
row.head_revision,
|
||||
"head_revision",
|
||||
1,
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
);
|
||||
const payloadHash = sha256HexValue(row.payload_hash, "payload_hash");
|
||||
return {
|
||||
snapshot_id: snapshotIdValue(row.snapshot_id),
|
||||
r2_key: storedR2Key(row.r2_key, payloadHash),
|
||||
payload_hash: payloadHash,
|
||||
encryption_version: storedEncryptionVersion(row.encryption_version),
|
||||
vault_generation: integer(
|
||||
row.vault_generation,
|
||||
"vault_generation",
|
||||
1,
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
),
|
||||
key_id: sha256HexValue(row.key_id, "key_id"),
|
||||
content_hash: sha256HexValue(row.content_hash, "content_hash"),
|
||||
schema_rev: integer(row.schema_rev, "schema_rev", 1, Number.MAX_SAFE_INTEGER),
|
||||
logical_clock: integer(
|
||||
row.logical_clock,
|
||||
"logical_clock",
|
||||
0,
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
),
|
||||
head_revision: headRevision,
|
||||
base_head: storedBaseHead(row, headRevision),
|
||||
device_id: deviceIdValue(row.device_id),
|
||||
size_bytes: integer(row.size_bytes, "size_bytes", 1, MAX_SNAPSHOT_BYTES),
|
||||
created_at: integer(row.created_at, "created_at", 0, Number.MAX_SAFE_INTEGER),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof SyncSnapshotRequestError) {
|
||||
throw new SyncSnapshotHeadSchemaError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function snapshotDocumentFromResult(
|
||||
result: ElyD1Result<SyncSnapshotRow> | undefined,
|
||||
): SyncSnapshotDocument {
|
||||
const row = result?.results.length === 1 ? result.results[0] : undefined;
|
||||
if (row === undefined) {
|
||||
throw new SyncSnapshotHeadSchemaError("sync_snapshot_missing");
|
||||
}
|
||||
return snapshotDocumentFromRow(row);
|
||||
}
|
||||
|
||||
export function snapshotHeadRef(snapshot: SyncSnapshotDocument): SnapshotHeadRefDocument {
|
||||
return {
|
||||
revision: snapshot.head_revision,
|
||||
snapshot_id: snapshot.snapshot_id,
|
||||
payload_hash: snapshot.payload_hash,
|
||||
};
|
||||
}
|
||||
|
||||
export function sameSnapshotHead(
|
||||
left: SnapshotHeadRefDocument | null,
|
||||
right: SnapshotHeadRefDocument | null,
|
||||
): boolean {
|
||||
if (left === null || right === null) {
|
||||
return left === right;
|
||||
}
|
||||
return left.revision === right.revision &&
|
||||
left.snapshot_id === right.snapshot_id &&
|
||||
left.payload_hash === right.payload_hash;
|
||||
}
|
||||
|
||||
export async function currentSyncSnapshotHead(
|
||||
database: ElyD1DatabaseSession,
|
||||
userId: string,
|
||||
): Promise<SyncSnapshotDocument | null> {
|
||||
const row = await database.prepare(SYNC_SNAPSHOT_HEAD_QUERY)
|
||||
.bind(userId)
|
||||
.first<SyncSnapshotRow>();
|
||||
return row === null ? null : snapshotDocumentFromRow(row);
|
||||
}
|
||||
|
||||
export async function syncSnapshotByToken(
|
||||
database: ElyD1DatabaseSession,
|
||||
userId: string,
|
||||
head: SnapshotHeadRefDocument,
|
||||
): Promise<SyncSnapshotDocument | null> {
|
||||
const row = await database.prepare(SYNC_SNAPSHOT_BY_TOKEN_QUERY)
|
||||
.bind(userId, head.snapshot_id, head.revision, head.payload_hash)
|
||||
.first<SyncSnapshotRow>();
|
||||
return row === null ? null : snapshotDocumentFromRow(row);
|
||||
}
|
||||
|
||||
function storedBaseHead(
|
||||
row: SyncSnapshotRow,
|
||||
headRevision: number,
|
||||
): SnapshotHeadRefDocument | null {
|
||||
const values = [row.base_head_revision, row.base_snapshot_id, row.base_payload_hash];
|
||||
if (values.every((value) => value === null)) {
|
||||
if (headRevision !== 1) {
|
||||
throw new SyncSnapshotRequestError("base_head_invalid");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (values.some((value) => value === null)) {
|
||||
throw new SyncSnapshotRequestError("base_head_invalid");
|
||||
}
|
||||
const revision = integer(
|
||||
row.base_head_revision,
|
||||
"base_head.revision",
|
||||
1,
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
);
|
||||
if (revision >= Number.MAX_SAFE_INTEGER || revision + 1 !== headRevision) {
|
||||
throw new SyncSnapshotRequestError("base_head_invalid");
|
||||
}
|
||||
return {
|
||||
revision,
|
||||
snapshot_id: snapshotIdValue(row.base_snapshot_id),
|
||||
payload_hash: sha256HexValue(row.base_payload_hash, "base_head.payload_hash"),
|
||||
};
|
||||
}
|
||||
|
||||
function storedEncryptionVersion(value: unknown): 1 | 2 {
|
||||
if (value !== 1 && value !== 2) {
|
||||
throw new SyncSnapshotRequestError("encryption_version_invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function storedR2Key(value: unknown, payloadHash: string): string {
|
||||
const key = text(value, "r2_key");
|
||||
try {
|
||||
assertKnownObjectKeyHash(key, payloadHash);
|
||||
} catch (error) {
|
||||
if (error instanceof StorageObjectError) {
|
||||
throw new SyncSnapshotRequestError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new SyncSnapshotRequestError(`${label}_invalid`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
const SNAPSHOT_COLUMNS = `
|
||||
snapshots.snapshot_id AS snapshot_id,
|
||||
snapshots.r2_key AS r2_key,
|
||||
snapshots.payload_hash AS payload_hash,
|
||||
encryption.encryption_version AS encryption_version,
|
||||
encryption.vault_generation AS vault_generation,
|
||||
encryption.key_id AS key_id,
|
||||
encryption.content_hash AS content_hash,
|
||||
snapshots.schema_rev AS schema_rev,
|
||||
snapshots.logical_clock AS logical_clock,
|
||||
snapshots.head_revision AS head_revision,
|
||||
snapshots.base_head_revision AS base_head_revision,
|
||||
snapshots.base_snapshot_id AS base_snapshot_id,
|
||||
snapshots.base_payload_hash AS base_payload_hash,
|
||||
snapshots.device_id AS device_id,
|
||||
snapshots.size_bytes AS size_bytes,
|
||||
snapshots.created_at AS created_at
|
||||
`;
|
||||
|
||||
export const SYNC_SNAPSHOT_HEAD_QUERY = `
|
||||
SELECT ${SNAPSHOT_COLUMNS}
|
||||
FROM sync_snapshot_heads AS head
|
||||
INNER JOIN sync_snapshots AS snapshots
|
||||
ON snapshots.user_id = head.user_id
|
||||
AND snapshots.snapshot_id = head.snapshot_id
|
||||
AND snapshots.head_revision = head.head_revision
|
||||
AND snapshots.payload_hash = head.payload_hash
|
||||
LEFT JOIN sync_snapshot_encryption AS encryption
|
||||
ON encryption.user_id = snapshots.user_id
|
||||
AND encryption.snapshot_id = snapshots.snapshot_id
|
||||
WHERE head.user_id = ?
|
||||
`;
|
||||
|
||||
export const SYNC_SNAPSHOT_BY_TOKEN_QUERY = `
|
||||
SELECT ${SNAPSHOT_COLUMNS}
|
||||
FROM sync_snapshot_heads AS head
|
||||
INNER JOIN sync_snapshots AS snapshots
|
||||
ON snapshots.user_id = head.user_id
|
||||
AND snapshots.snapshot_id = head.snapshot_id
|
||||
AND snapshots.head_revision = head.head_revision
|
||||
AND snapshots.payload_hash = head.payload_hash
|
||||
INNER JOIN sync_snapshot_encryption AS encryption
|
||||
ON encryption.user_id = snapshots.user_id
|
||||
AND encryption.snapshot_id = snapshots.snapshot_id
|
||||
WHERE head.user_id = ?
|
||||
AND head.snapshot_id = ?
|
||||
AND head.head_revision = ?
|
||||
AND head.payload_hash = ?
|
||||
AND encryption.encryption_version IN (1, 2)
|
||||
`;
|
||||
|
||||
export const SYNC_SNAPSHOT_CANDIDATE_UPSERT_QUERY = `
|
||||
WITH candidate (
|
||||
user_id, snapshot_id, r2_key, payload_hash, schema_rev,
|
||||
logical_clock, device_id, size_bytes, created_at, head_revision,
|
||||
base_head_revision, base_snapshot_id, base_payload_hash, key_id, vault_generation,
|
||||
write_token, lease_now
|
||||
) AS (VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?))
|
||||
INSERT INTO sync_snapshots (
|
||||
user_id, snapshot_id, r2_key, payload_hash, schema_rev,
|
||||
logical_clock, device_id, size_bytes, created_at, head_revision,
|
||||
base_head_revision, base_snapshot_id, base_payload_hash
|
||||
)
|
||||
SELECT
|
||||
user_id, snapshot_id, r2_key, payload_hash, schema_rev,
|
||||
logical_clock, device_id, size_bytes, created_at, head_revision,
|
||||
base_head_revision, base_snapshot_id, base_payload_hash
|
||||
FROM candidate
|
||||
WHERE (
|
||||
(
|
||||
candidate.head_revision = 1
|
||||
AND candidate.base_head_revision IS NULL
|
||||
AND candidate.base_snapshot_id IS NULL
|
||||
AND candidate.base_payload_hash IS NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM sync_snapshot_heads AS head
|
||||
WHERE head.user_id = candidate.user_id
|
||||
)
|
||||
)
|
||||
OR
|
||||
(
|
||||
candidate.base_head_revision IS NOT NULL
|
||||
AND candidate.base_snapshot_id IS NOT NULL
|
||||
AND candidate.base_payload_hash IS NOT NULL
|
||||
AND candidate.head_revision = candidate.base_head_revision + 1
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM sync_snapshot_heads AS head
|
||||
INNER JOIN sync_snapshots AS base
|
||||
ON base.user_id = head.user_id
|
||||
AND base.snapshot_id = head.snapshot_id
|
||||
AND base.payload_hash = head.payload_hash
|
||||
WHERE head.user_id = candidate.user_id
|
||||
AND head.head_revision = candidate.base_head_revision
|
||||
AND head.snapshot_id = candidate.base_snapshot_id
|
||||
AND head.payload_hash = candidate.base_payload_hash
|
||||
AND candidate.logical_clock > base.logical_clock
|
||||
)
|
||||
)
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM sync_vault_accounts AS account
|
||||
WHERE account.user_id = candidate.user_id
|
||||
AND account.current_key_id = candidate.key_id
|
||||
AND account.current_generation = candidate.vault_generation
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM sync_r2_gc_candidates AS ledger
|
||||
WHERE ledger.r2_key = candidate.r2_key
|
||||
AND ledger.user_id = candidate.user_id
|
||||
AND ledger.object_kind = 'snapshot'
|
||||
AND ledger.state = 'pending'
|
||||
AND ledger.write_token = candidate.write_token
|
||||
AND ledger.lease_expires_at >= candidate.lease_now
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM user_devices AS device
|
||||
INNER JOIN user_device_keys AS keys
|
||||
ON keys.user_id = device.user_id
|
||||
AND keys.device_id = device.device_id
|
||||
WHERE device.user_id = candidate.user_id
|
||||
AND device.device_id = candidate.device_id
|
||||
AND device.approval_status = 'approved'
|
||||
AND device.revoked_at IS NULL
|
||||
AND keys.key_protocol_version = 2
|
||||
AND keys.wrapping_public_key IS NOT NULL
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM sync_vault_rotation_r2_objects AS staged
|
||||
INNER JOIN sync_vault_rotations AS rotation
|
||||
ON rotation.user_id = staged.user_id
|
||||
AND rotation.idempotency_key = staged.rotation_idempotency_key
|
||||
WHERE staged.user_id = candidate.user_id
|
||||
AND staged.r2_key = candidate.r2_key
|
||||
AND rotation.cleanup_started_at IS NOT NULL
|
||||
)
|
||||
ON CONFLICT(user_id, snapshot_id) DO UPDATE SET
|
||||
r2_key = excluded.r2_key,
|
||||
payload_hash = excluded.payload_hash,
|
||||
schema_rev = excluded.schema_rev,
|
||||
logical_clock = excluded.logical_clock,
|
||||
device_id = excluded.device_id,
|
||||
size_bytes = excluded.size_bytes,
|
||||
created_at = excluded.created_at,
|
||||
head_revision = excluded.head_revision,
|
||||
base_head_revision = excluded.base_head_revision,
|
||||
base_snapshot_id = excluded.base_snapshot_id,
|
||||
base_payload_hash = excluded.base_payload_hash
|
||||
`;
|
||||
|
||||
export const SYNC_SNAPSHOT_ENCRYPTION_UPSERT_QUERY = `
|
||||
WITH candidate (
|
||||
user_id, snapshot_id, r2_key, payload_hash, schema_rev,
|
||||
logical_clock, device_id, size_bytes, created_at, head_revision,
|
||||
base_head_revision, base_snapshot_id, base_payload_hash,
|
||||
encryption_version, vault_generation, key_id, content_hash, write_token, lease_now
|
||||
) AS (VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?))
|
||||
INSERT INTO sync_snapshot_encryption (
|
||||
user_id, snapshot_id, encryption_version, vault_generation, key_id, content_hash
|
||||
)
|
||||
SELECT
|
||||
candidate.user_id,
|
||||
candidate.snapshot_id,
|
||||
candidate.encryption_version,
|
||||
candidate.vault_generation,
|
||||
candidate.key_id,
|
||||
candidate.content_hash
|
||||
FROM candidate
|
||||
INNER JOIN sync_snapshots AS snapshot
|
||||
ON snapshot.user_id = candidate.user_id
|
||||
AND snapshot.snapshot_id = candidate.snapshot_id
|
||||
AND snapshot.r2_key = candidate.r2_key
|
||||
AND snapshot.payload_hash = candidate.payload_hash
|
||||
AND snapshot.schema_rev = candidate.schema_rev
|
||||
AND snapshot.logical_clock = candidate.logical_clock
|
||||
AND snapshot.device_id = candidate.device_id
|
||||
AND snapshot.size_bytes = candidate.size_bytes
|
||||
AND snapshot.created_at = candidate.created_at
|
||||
AND snapshot.head_revision = candidate.head_revision
|
||||
AND snapshot.base_head_revision IS candidate.base_head_revision
|
||||
AND snapshot.base_snapshot_id IS candidate.base_snapshot_id
|
||||
AND snapshot.base_payload_hash IS candidate.base_payload_hash
|
||||
INNER JOIN sync_vault_accounts AS account
|
||||
ON account.user_id = candidate.user_id
|
||||
AND account.current_key_id = candidate.key_id
|
||||
AND account.current_generation = candidate.vault_generation
|
||||
INNER JOIN user_devices AS device
|
||||
ON device.user_id = candidate.user_id
|
||||
AND device.device_id = candidate.device_id
|
||||
AND device.approval_status = 'approved'
|
||||
AND device.revoked_at IS NULL
|
||||
INNER JOIN user_device_keys AS keys
|
||||
ON keys.user_id = device.user_id
|
||||
AND keys.device_id = device.device_id
|
||||
AND keys.key_protocol_version = 2
|
||||
AND keys.wrapping_public_key IS NOT NULL
|
||||
WHERE candidate.encryption_version = 2
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM sync_r2_gc_candidates AS ledger
|
||||
WHERE ledger.r2_key = candidate.r2_key
|
||||
AND ledger.user_id = candidate.user_id
|
||||
AND ledger.object_kind = 'snapshot'
|
||||
AND ledger.state = 'pending'
|
||||
AND ledger.write_token = candidate.write_token
|
||||
AND ledger.lease_expires_at >= candidate.lease_now
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM sync_vault_rotation_r2_objects AS staged
|
||||
INNER JOIN sync_vault_rotations AS rotation
|
||||
ON rotation.user_id = staged.user_id
|
||||
AND rotation.idempotency_key = staged.rotation_idempotency_key
|
||||
WHERE staged.user_id = candidate.user_id
|
||||
AND staged.r2_key = candidate.r2_key
|
||||
AND rotation.cleanup_started_at IS NOT NULL
|
||||
)
|
||||
ON CONFLICT(user_id, snapshot_id) DO UPDATE SET
|
||||
encryption_version = excluded.encryption_version,
|
||||
vault_generation = excluded.vault_generation,
|
||||
key_id = excluded.key_id,
|
||||
content_hash = excluded.content_hash
|
||||
`;
|
||||
|
||||
export const SYNC_SNAPSHOT_HEAD_INSERT_QUERY = `
|
||||
INSERT INTO sync_snapshot_heads (
|
||||
user_id, head_revision, snapshot_id, payload_hash, updated_at
|
||||
)
|
||||
SELECT ?, ?, ?, ?, ?
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM sync_r2_gc_candidates
|
||||
WHERE r2_key = ? AND user_id = ? AND object_kind = 'snapshot'
|
||||
AND state = 'pending' AND write_token = ? AND lease_expires_at >= ?
|
||||
)
|
||||
`;
|
||||
|
||||
export const SYNC_SNAPSHOT_HEAD_UPDATE_QUERY = `
|
||||
UPDATE sync_snapshot_heads
|
||||
SET head_revision = ?, snapshot_id = ?, payload_hash = ?, updated_at = ?
|
||||
WHERE user_id = ?
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM sync_r2_gc_candidates
|
||||
WHERE r2_key = ? AND user_id = ? AND object_kind = 'snapshot'
|
||||
AND state = 'pending' AND write_token = ? AND lease_expires_at >= ?
|
||||
)
|
||||
`;
|
||||
|
||||
export function syncSnapshotStatements(
|
||||
database: ElyD1DatabaseSession,
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
upload: SyncSnapshotWrite,
|
||||
writeLease: SyncR2WriteLease,
|
||||
nowSeconds: number,
|
||||
): ElyD1PreparedStatement[] {
|
||||
const snapshotValues = [
|
||||
userId,
|
||||
upload.snapshotId,
|
||||
upload.r2Key,
|
||||
upload.payloadHash,
|
||||
upload.schemaRev,
|
||||
upload.logicalClock,
|
||||
deviceId,
|
||||
upload.sizeBytes,
|
||||
nowSeconds,
|
||||
upload.headRevision,
|
||||
upload.baseHead?.revision ?? null,
|
||||
upload.baseHead?.snapshot_id ?? null,
|
||||
upload.baseHead?.payload_hash ?? null,
|
||||
];
|
||||
const headStatement = upload.baseHead === null
|
||||
? database.prepare(SYNC_SNAPSHOT_HEAD_INSERT_QUERY).bind(
|
||||
userId,
|
||||
upload.headRevision,
|
||||
upload.snapshotId,
|
||||
upload.payloadHash,
|
||||
nowSeconds,
|
||||
upload.r2Key,
|
||||
userId,
|
||||
writeLease.writeToken,
|
||||
nowSeconds,
|
||||
)
|
||||
: database.prepare(SYNC_SNAPSHOT_HEAD_UPDATE_QUERY).bind(
|
||||
upload.headRevision,
|
||||
upload.snapshotId,
|
||||
upload.payloadHash,
|
||||
nowSeconds,
|
||||
userId,
|
||||
upload.r2Key,
|
||||
userId,
|
||||
writeLease.writeToken,
|
||||
nowSeconds,
|
||||
);
|
||||
return [
|
||||
database.prepare(SYNC_SNAPSHOT_CANDIDATE_UPSERT_QUERY).bind(
|
||||
...snapshotValues,
|
||||
upload.keyId,
|
||||
upload.vaultGeneration,
|
||||
writeLease.writeToken,
|
||||
nowSeconds,
|
||||
),
|
||||
database.prepare(SYNC_SNAPSHOT_ENCRYPTION_UPSERT_QUERY).bind(
|
||||
...snapshotValues,
|
||||
upload.encryptionVersion,
|
||||
upload.vaultGeneration,
|
||||
upload.keyId,
|
||||
upload.contentHash,
|
||||
writeLease.writeToken,
|
||||
nowSeconds,
|
||||
),
|
||||
headStatement,
|
||||
syncR2MarkReferencedStatement(database, userId, upload.r2Key, writeLease, nowSeconds),
|
||||
database.prepare(SYNC_SNAPSHOT_HEAD_QUERY).bind(userId),
|
||||
];
|
||||
}
|
||||
import type { ElyD1DatabaseSession, ElyD1PreparedStatement } from "./bindings.js";
|
||||
import { type SyncR2WriteLease, syncR2MarkReferencedStatement } from "./sync_r2_gc.js";
|
||||
import type { SnapshotHeadRefDocument } from "./sync_snapshot_head.js";
|
||||
|
||||
export interface SyncSnapshotWrite {
|
||||
snapshotId: string;
|
||||
r2Key: string;
|
||||
payloadHash: string;
|
||||
encryptionVersion: 2;
|
||||
vaultGeneration: number;
|
||||
keyId: string;
|
||||
contentHash: string;
|
||||
schemaRev: number;
|
||||
logicalClock: number;
|
||||
headRevision: number;
|
||||
baseHead: SnapshotHeadRefDocument | null;
|
||||
sizeBytes: number;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { ElyD1DatabaseSession, Env } from "./bindings.js";
|
||||
import {
|
||||
type SyncR2WriteLease,
|
||||
abandonSyncR2Write,
|
||||
claimSyncR2SnapshotWrite,
|
||||
collectSyncR2Garbage,
|
||||
} from "./sync_r2_gc.js";
|
||||
import {
|
||||
SyncSnapshotRequestError,
|
||||
arrayBufferFromBytes,
|
||||
sha256Hex,
|
||||
} from "./sync_snapshot_codec.js";
|
||||
import type { SnapshotHeadRefDocument } from "./sync_snapshot_head.js";
|
||||
import { StorageObjectError, putVerifiedObject, syncSnapshotKey } from "./storage.js";
|
||||
|
||||
interface SnapshotStorageWrite {
|
||||
r2Key: string;
|
||||
payloadHash: string;
|
||||
keyId: string;
|
||||
vaultGeneration: number;
|
||||
headRevision: number;
|
||||
baseHead: SnapshotHeadRefDocument | null;
|
||||
bytes: ArrayBuffer;
|
||||
}
|
||||
|
||||
export type { SyncR2WriteLease } from "./sync_r2_gc.js";
|
||||
|
||||
export function claimSnapshotStorageWrite(
|
||||
env: Env,
|
||||
database: ElyD1DatabaseSession,
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
upload: SnapshotStorageWrite,
|
||||
nowSeconds: number,
|
||||
): Promise<SyncR2WriteLease> {
|
||||
return syncOwnerHash(userId).then((ownerHash) => claimSyncR2SnapshotWrite(
|
||||
env,
|
||||
{
|
||||
userId,
|
||||
deviceId,
|
||||
r2Key: upload.r2Key,
|
||||
ownerHash,
|
||||
keyId: upload.keyId,
|
||||
generation: upload.vaultGeneration,
|
||||
headRevision: upload.headRevision,
|
||||
baseHead: upload.baseHead === null ? null : {
|
||||
revision: upload.baseHead.revision,
|
||||
snapshotId: upload.baseHead.snapshot_id,
|
||||
payloadHash: upload.baseHead.payload_hash,
|
||||
},
|
||||
},
|
||||
nowSeconds,
|
||||
undefined,
|
||||
database,
|
||||
));
|
||||
}
|
||||
|
||||
export async function persistClaimedSnapshot(
|
||||
env: Env,
|
||||
upload: SnapshotStorageWrite,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await putVerifiedObject(
|
||||
env.ELY_STORAGE,
|
||||
upload.r2Key,
|
||||
upload.bytes,
|
||||
upload.payloadHash,
|
||||
"application/octet-stream",
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof StorageObjectError) throw new SyncSnapshotRequestError(error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function releaseFailedSnapshotWrite(
|
||||
env: Env,
|
||||
database: ElyD1DatabaseSession,
|
||||
userId: string,
|
||||
r2Key: string,
|
||||
lease: SyncR2WriteLease,
|
||||
nowSeconds: number,
|
||||
): Promise<void> {
|
||||
const ownerHash = await syncOwnerHash(userId);
|
||||
await abandonSyncR2Write(
|
||||
env,
|
||||
userId,
|
||||
ownerHash,
|
||||
r2Key,
|
||||
lease.writeToken,
|
||||
nowSeconds,
|
||||
database,
|
||||
);
|
||||
try {
|
||||
await collectSyncR2Garbage(env, nowSeconds, { ownerHash, limit: 5, database });
|
||||
} catch {
|
||||
// The durable candidate remains available to scheduled GC.
|
||||
}
|
||||
}
|
||||
|
||||
export async function snapshotStorageKey(
|
||||
region: string,
|
||||
userId: string,
|
||||
snapshotId: string,
|
||||
payloadHash: string,
|
||||
): Promise<string> {
|
||||
try {
|
||||
return syncSnapshotKey({
|
||||
region,
|
||||
userHash: await syncOwnerHash(userId),
|
||||
snapshotId,
|
||||
payloadHash,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof StorageObjectError) throw new SyncSnapshotRequestError(error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function syncOwnerHash(userId: string): Promise<string> {
|
||||
return sha256Hex(arrayBufferFromBytes(new TextEncoder().encode(userId)));
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
import type { AuthContext } from "./auth.js";
|
||||
import type { Env } from "./bindings.js";
|
||||
import type { ElyD1Result, Env } from "./bindings.js";
|
||||
import { primaryD1Session } from "./bindings.js";
|
||||
import { StorageObjectError, assertSyncObjectType } from "./storage.js";
|
||||
import type { SnapshotHeadRefDocument, SyncSnapshotRow } from "./sync_snapshot_head.js";
|
||||
import {
|
||||
SyncSnapshotHeadSchemaError,
|
||||
snapshotDocumentFromRow,
|
||||
} from "./sync_snapshot_head.js";
|
||||
import { SYNC_SNAPSHOT_HEAD_QUERY } from "./sync_snapshot_sql.js";
|
||||
|
||||
const CHANGE_CURSOR_QUERY = `
|
||||
SELECT
|
||||
@@ -23,21 +30,11 @@ const OBJECT_STATUS_QUERY = `
|
||||
`;
|
||||
const SNAPSHOT_COUNT_QUERY = `
|
||||
SELECT COUNT(*) AS total_snapshots
|
||||
FROM sync_snapshots
|
||||
WHERE user_id = ?
|
||||
`;
|
||||
const LATEST_SNAPSHOT_QUERY = `
|
||||
SELECT
|
||||
snapshot_id,
|
||||
payload_hash,
|
||||
logical_clock,
|
||||
device_id,
|
||||
size_bytes,
|
||||
created_at
|
||||
FROM sync_snapshots
|
||||
WHERE user_id = ?
|
||||
ORDER BY created_at DESC, snapshot_id ASC
|
||||
LIMIT 1
|
||||
FROM sync_snapshots AS snapshots
|
||||
INNER JOIN sync_snapshot_encryption AS encryption
|
||||
ON encryption.user_id = snapshots.user_id
|
||||
AND encryption.snapshot_id = snapshots.snapshot_id
|
||||
WHERE snapshots.user_id = ? AND encryption.encryption_version IN (1, 2)
|
||||
`;
|
||||
const APPROVED_DEVICE_COUNT_QUERY = `
|
||||
SELECT COUNT(*) AS approved_devices
|
||||
@@ -45,12 +42,8 @@ const APPROVED_DEVICE_COUNT_QUERY = `
|
||||
WHERE user_id = ? AND approval_status = 'approved' AND revoked_at IS NULL
|
||||
`;
|
||||
|
||||
const SNAPSHOT_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,127}$/;
|
||||
const DEVICE_ID_PATTERN = /^[a-zA-Z0-9._:-]{3,128}$/;
|
||||
const SHA256_HEX = /^[a-f0-9]{64}$/;
|
||||
|
||||
export interface SyncStatusDocument {
|
||||
version: 1;
|
||||
version: 2;
|
||||
user_id: string;
|
||||
device_id: string;
|
||||
cursor: SyncCursorStatusDocument;
|
||||
@@ -74,13 +67,19 @@ export interface SyncObjectStatusDocument {
|
||||
|
||||
export interface SyncSnapshotStatusDocument {
|
||||
total_snapshots: number;
|
||||
latest: SyncLatestSnapshotDocument | null;
|
||||
head: SyncSnapshotHeadStatusDocument | null;
|
||||
}
|
||||
|
||||
export interface SyncLatestSnapshotDocument {
|
||||
export interface SyncSnapshotHeadStatusDocument {
|
||||
snapshot_id: string;
|
||||
payload_hash: string;
|
||||
encryption_version: 1 | 2;
|
||||
vault_generation: number;
|
||||
key_id: string;
|
||||
content_hash: string;
|
||||
logical_clock: number;
|
||||
head_revision: number;
|
||||
base_head: SnapshotHeadRefDocument | null;
|
||||
device_id: string;
|
||||
size_bytes: number;
|
||||
created_at: number;
|
||||
@@ -109,15 +108,6 @@ interface SnapshotCountRow {
|
||||
total_snapshots: unknown;
|
||||
}
|
||||
|
||||
interface LatestSnapshotRow {
|
||||
snapshot_id: unknown;
|
||||
payload_hash: unknown;
|
||||
logical_clock: unknown;
|
||||
device_id: unknown;
|
||||
size_bytes: unknown;
|
||||
created_at: unknown;
|
||||
}
|
||||
|
||||
interface DeviceStatusRow {
|
||||
approved_devices: unknown;
|
||||
}
|
||||
@@ -134,33 +124,60 @@ export async function syncStatusDocument(
|
||||
context: AuthContext,
|
||||
): Promise<SyncStatusDocument> {
|
||||
const deviceId = currentDeviceId(context);
|
||||
const cursorRow = await env.ELY_DB.prepare(CHANGE_CURSOR_QUERY)
|
||||
.bind(context.userId)
|
||||
.first<ChangeCursorRow>();
|
||||
const objectRows = await env.ELY_DB.prepare(OBJECT_STATUS_QUERY)
|
||||
.bind(context.userId)
|
||||
.all<ObjectStatusRow>();
|
||||
const snapshotCountRow = await env.ELY_DB.prepare(SNAPSHOT_COUNT_QUERY)
|
||||
.bind(context.userId)
|
||||
.first<SnapshotCountRow>();
|
||||
const latestSnapshotRow = await env.ELY_DB.prepare(LATEST_SNAPSHOT_QUERY)
|
||||
.bind(context.userId)
|
||||
.first<LatestSnapshotRow>();
|
||||
const deviceStatusRow = await env.ELY_DB.prepare(APPROVED_DEVICE_COUNT_QUERY)
|
||||
.bind(context.userId)
|
||||
.first<DeviceStatusRow>();
|
||||
const database = primaryD1Session(env.ELY_DB);
|
||||
const results = await database.batch<ElyD1Result>([
|
||||
database.prepare(CHANGE_CURSOR_QUERY).bind(context.userId),
|
||||
database.prepare(OBJECT_STATUS_QUERY).bind(context.userId),
|
||||
database.prepare(SNAPSHOT_COUNT_QUERY).bind(context.userId),
|
||||
database.prepare(SYNC_SNAPSHOT_HEAD_QUERY).bind(context.userId),
|
||||
database.prepare(APPROVED_DEVICE_COUNT_QUERY).bind(context.userId),
|
||||
]);
|
||||
if (results.length !== 5) {
|
||||
throw new SyncStatusSchemaError("sync_status_batch_invalid");
|
||||
}
|
||||
const cursorRow = oneRow<ChangeCursorRow>(results[0], "sync_cursor_status_missing");
|
||||
const objectRows = rows<ObjectStatusRow>(results[1]);
|
||||
const snapshotCountRow = oneRow<SnapshotCountRow>(
|
||||
results[2],
|
||||
"sync_snapshot_status_missing",
|
||||
);
|
||||
const snapshotHeadRow = optionalRow<SyncSnapshotRow>(results[3]);
|
||||
const deviceStatusRow = oneRow<DeviceStatusRow>(results[4], "sync_device_status_missing");
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
version: 2,
|
||||
user_id: context.userId,
|
||||
device_id: deviceId,
|
||||
cursor: cursorStatus(cursorRow),
|
||||
objects: objectRows.results.map(objectStatus),
|
||||
snapshots: snapshotStatus(snapshotCountRow, latestSnapshotRow),
|
||||
objects: objectRows.map(objectStatus),
|
||||
snapshots: snapshotStatus(snapshotCountRow, snapshotHeadRow),
|
||||
devices: deviceStatus(deviceStatusRow, deviceId),
|
||||
};
|
||||
}
|
||||
|
||||
function rows<T>(result: ElyD1Result | undefined): T[] {
|
||||
if (result === undefined || !Array.isArray(result.results)) {
|
||||
throw new SyncStatusSchemaError("sync_status_batch_invalid");
|
||||
}
|
||||
return result.results as T[];
|
||||
}
|
||||
|
||||
function oneRow<T>(result: ElyD1Result | undefined, message: string): T {
|
||||
const values = rows<T>(result);
|
||||
if (values.length !== 1) {
|
||||
throw new SyncStatusSchemaError(message);
|
||||
}
|
||||
return values[0] as T;
|
||||
}
|
||||
|
||||
function optionalRow<T>(result: ElyD1Result | undefined): T | null {
|
||||
const values = rows<T>(result);
|
||||
if (values.length > 1) {
|
||||
throw new SyncStatusSchemaError("sync_snapshot_head_rows_invalid");
|
||||
}
|
||||
return values[0] ?? null;
|
||||
}
|
||||
|
||||
function cursorStatus(row: ChangeCursorRow | null): SyncCursorStatusDocument {
|
||||
if (row === null) {
|
||||
throw new SyncStatusSchemaError("sync_cursor_status_missing");
|
||||
@@ -183,26 +200,44 @@ function objectStatus(row: ObjectStatusRow): SyncObjectStatusDocument {
|
||||
|
||||
function snapshotStatus(
|
||||
countRow: SnapshotCountRow | null,
|
||||
latestRow: LatestSnapshotRow | null,
|
||||
headRow: SyncSnapshotRow | null,
|
||||
): SyncSnapshotStatusDocument {
|
||||
if (countRow === null) {
|
||||
throw new SyncStatusSchemaError("sync_snapshot_status_missing");
|
||||
}
|
||||
const totalSnapshots = integer(countRow.total_snapshots, "total_snapshots");
|
||||
if ((totalSnapshots === 0) !== (headRow === null)) {
|
||||
throw new SyncStatusSchemaError("sync_snapshot_head_missing");
|
||||
}
|
||||
return {
|
||||
total_snapshots: integer(countRow.total_snapshots, "total_snapshots"),
|
||||
latest: latestRow === null ? null : latestSnapshot(latestRow),
|
||||
total_snapshots: totalSnapshots,
|
||||
head: headRow === null ? null : snapshotHeadStatus(headRow),
|
||||
};
|
||||
}
|
||||
|
||||
function latestSnapshot(row: LatestSnapshotRow): SyncLatestSnapshotDocument {
|
||||
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`);
|
||||
|
||||
@@ -0,0 +1,499 @@
|
||||
import type { AuthContext } from "./auth.js";
|
||||
import type { ElyD1DatabaseSession, ElyD1PreparedStatement, Env } from "./bindings.js";
|
||||
import { syncVaultBootstrapProofValid } from "./sync_vault_bootstrap_proof.js";
|
||||
import {
|
||||
CURRENT_DEVICE_ENVELOPE_QUERY,
|
||||
CURRENT_SYNC_VAULT_KEY_QUERY,
|
||||
HISTORICAL_DEVICE_ENVELOPE_QUERY,
|
||||
SYNC_VAULT_ACCOUNT_INSERT_QUERY,
|
||||
SYNC_VAULT_ENVELOPE_INSERT_QUERY,
|
||||
} from "./sync_vault_sql.js";
|
||||
|
||||
const HPKE_SUITE = "HPKE-BASE-X25519-HKDF-SHA256-CHACHA20POLY1305";
|
||||
const SHA256_HEX_PATTERN = /^[a-f0-9]{64}$/;
|
||||
const DEVICE_ID_PATTERN = /^[a-zA-Z0-9._:-]{3,128}$/;
|
||||
const IDEMPOTENCY_KEY_PATTERN = /^[a-zA-Z0-9._:-]{16,128}$/;
|
||||
const SIGNATURE_PATTERN = /^[a-f0-9]{128}$/;
|
||||
const BASE64URL_32_BYTES_PATTERN = /^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/;
|
||||
const BASE64URL_48_BYTES_PATTERN = /^[A-Za-z0-9_-]{64}$/;
|
||||
|
||||
export interface CurrentSyncVaultKey {
|
||||
keyId: string;
|
||||
generation: number;
|
||||
}
|
||||
|
||||
export interface SyncVaultDocument {
|
||||
version: 1;
|
||||
user_id: string;
|
||||
key_id: string;
|
||||
generation: number;
|
||||
recipient_device_id: string;
|
||||
approver_device_id: string;
|
||||
envelope: WrappedAccountKeyDocument;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface WrappedAccountKeyDocument {
|
||||
version: 1;
|
||||
suite: typeof HPKE_SUITE;
|
||||
encapped_key: string;
|
||||
ciphertext: string;
|
||||
}
|
||||
|
||||
interface SyncVaultBootstrapRequest {
|
||||
keyId: string;
|
||||
generation: number;
|
||||
envelope: WrappedAccountKeyDocument;
|
||||
idempotencyKey: string;
|
||||
bootstrapProof: string;
|
||||
}
|
||||
|
||||
interface SyncVaultEnvelopeLookup {
|
||||
keyId: string;
|
||||
generation: number;
|
||||
}
|
||||
|
||||
interface SyncVaultKeyRow {
|
||||
key_id: unknown;
|
||||
generation: unknown;
|
||||
}
|
||||
|
||||
interface SyncVaultEnvelopeRow extends SyncVaultKeyRow {
|
||||
recipient_device_id: unknown;
|
||||
approver_device_id: unknown;
|
||||
envelope_version: unknown;
|
||||
suite: unknown;
|
||||
encapped_key: unknown;
|
||||
ciphertext: unknown;
|
||||
idempotency_key: unknown;
|
||||
created_at: unknown;
|
||||
}
|
||||
interface StoredSyncVaultEnvelope {
|
||||
document: SyncVaultDocument;
|
||||
idempotencyKey: string;
|
||||
}
|
||||
type RequestBody = Record<string, unknown>;
|
||||
export class SyncVaultRequestError extends Error {}
|
||||
export class SyncVaultPermissionError extends Error {}
|
||||
export class SyncVaultNotFoundError extends Error {}
|
||||
export class SyncVaultConflictError extends Error {}
|
||||
export class SyncVaultPersistenceError extends Error {}
|
||||
export async function syncVaultBootstrapDocument(
|
||||
request: Request,
|
||||
env: Env,
|
||||
context: AuthContext,
|
||||
nowSeconds = Math.floor(Date.now() / 1000),
|
||||
): Promise<SyncVaultDocument> {
|
||||
const deviceId = currentDeviceId(context);
|
||||
const bootstrap = await syncVaultBootstrapRequest(request);
|
||||
const { bootstrapProof, ...unsignedBootstrap } = bootstrap;
|
||||
if (!(await syncVaultBootstrapProofValid(
|
||||
env,
|
||||
context.userId,
|
||||
deviceId,
|
||||
unsignedBootstrap,
|
||||
bootstrapProof,
|
||||
))) {
|
||||
throw new SyncVaultPermissionError("sync_vault_bootstrap_proof_invalid");
|
||||
}
|
||||
await env.ELY_DB.batch(syncVaultBootstrapStatements(env, context.userId, deviceId, bootstrap, nowSeconds));
|
||||
|
||||
const stored = await currentDeviceEnvelope(env, context.userId, deviceId);
|
||||
if (stored === null) {
|
||||
const currentKey = await currentSyncVaultKey(env, context.userId);
|
||||
if (currentKey === null) {
|
||||
throw new SyncVaultPersistenceError("sync_vault_account_missing");
|
||||
}
|
||||
if (currentKey.keyId !== bootstrap.keyId || currentKey.generation !== bootstrap.generation) {
|
||||
throw new SyncVaultConflictError("sync_vault_key_conflict");
|
||||
}
|
||||
throw new SyncVaultPersistenceError("sync_vault_envelope_missing");
|
||||
}
|
||||
assertBootstrapMatches(stored, bootstrap, deviceId);
|
||||
return stored.document;
|
||||
}
|
||||
|
||||
export async function syncVaultCurrentDeviceDocument(
|
||||
url: URL,
|
||||
env: Env,
|
||||
context: AuthContext,
|
||||
): Promise<SyncVaultDocument> {
|
||||
const deviceId = currentDeviceId(context);
|
||||
const stored = await currentDeviceEnvelope(
|
||||
env,
|
||||
context.userId,
|
||||
deviceId,
|
||||
syncVaultEnvelopeLookup(url),
|
||||
);
|
||||
if (stored === null) {
|
||||
throw new SyncVaultNotFoundError("sync_vault_envelope_not_found");
|
||||
}
|
||||
return stored.document;
|
||||
}
|
||||
|
||||
export async function currentSyncVaultKey(
|
||||
env: Env,
|
||||
userId: string,
|
||||
database: ElyD1DatabaseSession = env.ELY_DB,
|
||||
): Promise<CurrentSyncVaultKey | null> {
|
||||
const row = await database.prepare(CURRENT_SYNC_VAULT_KEY_QUERY)
|
||||
.bind(userId)
|
||||
.first<SyncVaultKeyRow>();
|
||||
if (row === null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
keyId: storedKeyId(row.key_id),
|
||||
generation: storedInteger(row.generation, "generation", 1),
|
||||
};
|
||||
}
|
||||
|
||||
export async function assertCurrentSyncVaultKey(
|
||||
env: Env,
|
||||
userId: string,
|
||||
keyId: string,
|
||||
generation: number,
|
||||
database: ElyD1DatabaseSession = env.ELY_DB,
|
||||
): Promise<void> {
|
||||
const current = await currentSyncVaultKey(env, userId, database);
|
||||
if (current === null) {
|
||||
throw new SyncVaultNotFoundError("sync_vault_not_initialized");
|
||||
}
|
||||
if (current.keyId !== keyId || current.generation !== generation) {
|
||||
throw new SyncVaultConflictError("sync_vault_key_not_current");
|
||||
}
|
||||
}
|
||||
export function parseWrappedAccountKey(value: unknown): WrappedAccountKeyDocument { return requestEnvelope(value); }
|
||||
|
||||
export function syncVaultRecipientEnvelopeStatement(
|
||||
env: Env,
|
||||
userId: string,
|
||||
recipientDeviceId: string,
|
||||
approverDeviceId: string,
|
||||
keyId: string,
|
||||
generation: number,
|
||||
envelope: WrappedAccountKeyDocument,
|
||||
idempotencyKey: string,
|
||||
nowSeconds: number,
|
||||
): ElyD1PreparedStatement {
|
||||
return syncVaultEnvelopeStatement(
|
||||
env,
|
||||
userId,
|
||||
recipientDeviceId,
|
||||
approverDeviceId,
|
||||
keyId,
|
||||
generation,
|
||||
envelope,
|
||||
idempotencyKey,
|
||||
nowSeconds,
|
||||
"pending",
|
||||
);
|
||||
}
|
||||
|
||||
function syncVaultBootstrapStatements(
|
||||
env: Env,
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
bootstrap: SyncVaultBootstrapRequest,
|
||||
nowSeconds: number,
|
||||
): ElyD1PreparedStatement[] {
|
||||
return [
|
||||
env.ELY_DB.prepare(SYNC_VAULT_ACCOUNT_INSERT_QUERY).bind(
|
||||
userId,
|
||||
bootstrap.keyId,
|
||||
bootstrap.generation,
|
||||
nowSeconds,
|
||||
nowSeconds,
|
||||
userId,
|
||||
deviceId,
|
||||
),
|
||||
syncVaultEnvelopeStatement(
|
||||
env,
|
||||
userId,
|
||||
deviceId,
|
||||
deviceId,
|
||||
bootstrap.keyId,
|
||||
bootstrap.generation,
|
||||
bootstrap.envelope,
|
||||
bootstrap.idempotencyKey,
|
||||
nowSeconds,
|
||||
"approved",
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function syncVaultEnvelopeStatement(
|
||||
env: Env,
|
||||
userId: string,
|
||||
recipientDeviceId: string,
|
||||
approverDeviceId: string,
|
||||
keyId: string,
|
||||
generation: number,
|
||||
envelope: WrappedAccountKeyDocument,
|
||||
idempotencyKey: string,
|
||||
nowSeconds: number,
|
||||
recipientStatus: "pending" | "approved",
|
||||
): ElyD1PreparedStatement {
|
||||
const wrapped = requestEnvelope(envelope);
|
||||
requestDeviceId(recipientDeviceId, "recipient_device_id");
|
||||
requestDeviceId(approverDeviceId, "approver_device_id");
|
||||
requestKeyId(keyId);
|
||||
requestInteger(generation, "generation", 1);
|
||||
requestIdempotencyKey(idempotencyKey);
|
||||
requestInteger(nowSeconds, "created_at", 0);
|
||||
return env.ELY_DB.prepare(SYNC_VAULT_ENVELOPE_INSERT_QUERY).bind(
|
||||
userId,
|
||||
recipientDeviceId,
|
||||
approverDeviceId,
|
||||
keyId,
|
||||
generation,
|
||||
wrapped.version,
|
||||
wrapped.suite,
|
||||
wrapped.encapped_key,
|
||||
wrapped.ciphertext,
|
||||
idempotencyKey,
|
||||
nowSeconds,
|
||||
recipientDeviceId,
|
||||
recipientStatus,
|
||||
approverDeviceId,
|
||||
userId,
|
||||
keyId,
|
||||
generation,
|
||||
);
|
||||
}
|
||||
|
||||
async function currentDeviceEnvelope(
|
||||
env: Env,
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
lookup?: SyncVaultEnvelopeLookup,
|
||||
): Promise<StoredSyncVaultEnvelope | null> {
|
||||
const statement = lookup === undefined
|
||||
? env.ELY_DB.prepare(CURRENT_DEVICE_ENVELOPE_QUERY).bind(userId, deviceId)
|
||||
: env.ELY_DB.prepare(HISTORICAL_DEVICE_ENVELOPE_QUERY)
|
||||
.bind(userId, deviceId, lookup.keyId, lookup.generation);
|
||||
const row = await statement.first<SyncVaultEnvelopeRow>();
|
||||
if (row === null) {
|
||||
return null;
|
||||
}
|
||||
const recipientDeviceId = storedDeviceId(row.recipient_device_id, "recipient_device_id");
|
||||
if (recipientDeviceId !== deviceId) {
|
||||
throw new SyncVaultPersistenceError("recipient_device_id_mismatch");
|
||||
}
|
||||
return {
|
||||
document: {
|
||||
version: 1,
|
||||
user_id: userId,
|
||||
key_id: storedKeyId(row.key_id),
|
||||
generation: storedInteger(row.generation, "generation", 1),
|
||||
recipient_device_id: recipientDeviceId,
|
||||
approver_device_id: storedDeviceId(row.approver_device_id, "approver_device_id"),
|
||||
envelope: storedEnvelope(row),
|
||||
created_at: storedInteger(row.created_at, "created_at", 0),
|
||||
},
|
||||
idempotencyKey: storedIdempotencyKey(row.idempotency_key),
|
||||
};
|
||||
}
|
||||
|
||||
function syncVaultEnvelopeLookup(url: URL): SyncVaultEnvelopeLookup | undefined {
|
||||
if ([...url.searchParams].length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
for (const field of url.searchParams.keys()) {
|
||||
if (field !== "key_id" && field !== "generation") {
|
||||
throw new SyncVaultRequestError(`unexpected_query:${field}`);
|
||||
}
|
||||
}
|
||||
const keyIds = url.searchParams.getAll("key_id");
|
||||
const generations = url.searchParams.getAll("generation");
|
||||
if (keyIds.length !== 1 || generations.length !== 1) {
|
||||
throw new SyncVaultRequestError("vault_query_pair_required");
|
||||
}
|
||||
if (!/^[1-9][0-9]*$/.test(generations[0] ?? "")) {
|
||||
throw new SyncVaultRequestError("generation_invalid");
|
||||
}
|
||||
return {
|
||||
keyId: requestKeyId(keyIds[0]),
|
||||
generation: requestInteger(Number(generations[0]), "generation", 1),
|
||||
};
|
||||
}
|
||||
|
||||
async function syncVaultBootstrapRequest(request: Request): Promise<SyncVaultBootstrapRequest> {
|
||||
const body = await requestBody(request);
|
||||
assertOnlyFields(body, [
|
||||
"version",
|
||||
"key_id",
|
||||
"generation",
|
||||
"envelope",
|
||||
"idempotency_key",
|
||||
"bootstrap_proof",
|
||||
]);
|
||||
if (body.version !== 2) {
|
||||
throw new SyncVaultRequestError("version_invalid");
|
||||
}
|
||||
if (body.generation !== 1) {
|
||||
throw new SyncVaultRequestError("generation_invalid");
|
||||
}
|
||||
return {
|
||||
keyId: requestKeyId(body.key_id),
|
||||
generation: 1,
|
||||
envelope: requestEnvelope(body.envelope),
|
||||
idempotencyKey: requestIdempotencyKey(body.idempotency_key),
|
||||
bootstrapProof: requestSignature(body.bootstrap_proof),
|
||||
};
|
||||
}
|
||||
|
||||
function requestEnvelope(value: unknown): WrappedAccountKeyDocument {
|
||||
const envelope = record(value, "envelope");
|
||||
assertOnlyFields(envelope, ["version", "suite", "encapped_key", "ciphertext"]);
|
||||
if (envelope.version !== 1 || envelope.suite !== HPKE_SUITE) {
|
||||
throw new SyncVaultRequestError("envelope_metadata_invalid");
|
||||
}
|
||||
if (typeof envelope.encapped_key !== "string" || !BASE64URL_32_BYTES_PATTERN.test(envelope.encapped_key)) {
|
||||
throw new SyncVaultRequestError("encapped_key_invalid");
|
||||
}
|
||||
if (typeof envelope.ciphertext !== "string" || !BASE64URL_48_BYTES_PATTERN.test(envelope.ciphertext)) {
|
||||
throw new SyncVaultRequestError("ciphertext_invalid");
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
suite: HPKE_SUITE,
|
||||
encapped_key: envelope.encapped_key,
|
||||
ciphertext: envelope.ciphertext,
|
||||
};
|
||||
}
|
||||
|
||||
function storedEnvelope(row: SyncVaultEnvelopeRow): WrappedAccountKeyDocument {
|
||||
if (row.envelope_version !== 1 || row.suite !== HPKE_SUITE) {
|
||||
throw new SyncVaultPersistenceError("envelope_metadata_invalid");
|
||||
}
|
||||
if (typeof row.encapped_key !== "string" || !BASE64URL_32_BYTES_PATTERN.test(row.encapped_key)) {
|
||||
throw new SyncVaultPersistenceError("encapped_key_invalid");
|
||||
}
|
||||
if (typeof row.ciphertext !== "string" || !BASE64URL_48_BYTES_PATTERN.test(row.ciphertext)) {
|
||||
throw new SyncVaultPersistenceError("ciphertext_invalid");
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
suite: HPKE_SUITE,
|
||||
encapped_key: row.encapped_key,
|
||||
ciphertext: row.ciphertext,
|
||||
};
|
||||
}
|
||||
|
||||
function assertBootstrapMatches(
|
||||
stored: StoredSyncVaultEnvelope,
|
||||
bootstrap: SyncVaultBootstrapRequest,
|
||||
deviceId: string,
|
||||
): void {
|
||||
const { document } = stored;
|
||||
if (
|
||||
document.key_id !== bootstrap.keyId ||
|
||||
document.generation !== bootstrap.generation ||
|
||||
document.recipient_device_id !== deviceId ||
|
||||
document.approver_device_id !== deviceId ||
|
||||
document.envelope.version !== bootstrap.envelope.version ||
|
||||
document.envelope.suite !== bootstrap.envelope.suite ||
|
||||
document.envelope.encapped_key !== bootstrap.envelope.encapped_key ||
|
||||
document.envelope.ciphertext !== bootstrap.envelope.ciphertext ||
|
||||
stored.idempotencyKey !== bootstrap.idempotencyKey
|
||||
) {
|
||||
throw new SyncVaultConflictError("sync_vault_bootstrap_replay_mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
async function requestBody(request: Request): Promise<RequestBody> {
|
||||
let value: unknown;
|
||||
try {
|
||||
value = await request.json();
|
||||
} catch {
|
||||
throw new SyncVaultRequestError("json_invalid");
|
||||
}
|
||||
return record(value, "body");
|
||||
}
|
||||
|
||||
function record(value: unknown, label: string): RequestBody {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new SyncVaultRequestError(`${label}_invalid`);
|
||||
}
|
||||
return value as RequestBody;
|
||||
}
|
||||
|
||||
function assertOnlyFields(value: RequestBody, fields: string[]): void {
|
||||
const allowed = new Set(fields);
|
||||
for (const field of Object.keys(value)) {
|
||||
if (!allowed.has(field)) {
|
||||
throw new SyncVaultRequestError(`unexpected_field:${field}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function currentDeviceId(context: AuthContext): string {
|
||||
if (context.deviceId === undefined) {
|
||||
throw new SyncVaultRequestError("device_context_required");
|
||||
}
|
||||
return context.deviceId;
|
||||
}
|
||||
|
||||
function requestKeyId(value: unknown): string {
|
||||
if (typeof value !== "string" || !SHA256_HEX_PATTERN.test(value)) {
|
||||
throw new SyncVaultRequestError("key_id_invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function storedKeyId(value: unknown): string {
|
||||
if (typeof value !== "string" || !SHA256_HEX_PATTERN.test(value)) {
|
||||
throw new SyncVaultPersistenceError("key_id_invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requestIdempotencyKey(value: unknown): string {
|
||||
if (typeof value !== "string" || !IDEMPOTENCY_KEY_PATTERN.test(value)) {
|
||||
throw new SyncVaultRequestError("idempotency_key_invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requestSignature(value: unknown): string {
|
||||
if (typeof value !== "string" || !SIGNATURE_PATTERN.test(value)) {
|
||||
throw new SyncVaultRequestError("bootstrap_proof_invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requestDeviceId(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !DEVICE_ID_PATTERN.test(value)) {
|
||||
throw new SyncVaultRequestError(`${label}_invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requestInteger(value: unknown, label: string, min: number): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < min) {
|
||||
throw new SyncVaultRequestError(`${label}_invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function storedIdempotencyKey(value: unknown): string {
|
||||
if (typeof value !== "string" || !IDEMPOTENCY_KEY_PATTERN.test(value)) {
|
||||
throw new SyncVaultPersistenceError("idempotency_key_invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function storedDeviceId(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !DEVICE_ID_PATTERN.test(value)) {
|
||||
throw new SyncVaultPersistenceError(`${label}_invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function storedInteger(value: unknown, label: string, min: number): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < min) {
|
||||
throw new SyncVaultPersistenceError(`${label}_invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { Env } from "./bindings.js";
|
||||
import { verifyEd25519Signature } from "./device_crypto.js";
|
||||
import type { WrappedAccountKeyDocument } from "./sync_vault.js";
|
||||
|
||||
const BOOTSTRAP_DOMAIN = "elydora-sync-vault-bootstrap-v2";
|
||||
const PUBLIC_KEY_PATTERN = /^[a-f0-9]{64}$/;
|
||||
|
||||
const APPROVED_V2_SIGNING_KEY_QUERY = `
|
||||
SELECT keys.signing_public_key
|
||||
FROM user_devices AS device
|
||||
INNER JOIN user_device_keys AS keys
|
||||
ON keys.user_id = device.user_id AND keys.device_id = device.device_id
|
||||
WHERE device.user_id = ? AND device.device_id = ?
|
||||
AND device.approval_status = 'approved' AND device.revoked_at IS NULL
|
||||
AND keys.key_protocol_version = 2 AND keys.signing_public_key IS NOT NULL
|
||||
`;
|
||||
|
||||
export interface SyncVaultBootstrapProofInput {
|
||||
keyId: string;
|
||||
generation: number;
|
||||
envelope: WrappedAccountKeyDocument;
|
||||
idempotencyKey: string;
|
||||
}
|
||||
|
||||
interface SigningKeyRow {
|
||||
signing_public_key: unknown;
|
||||
}
|
||||
|
||||
export async function syncVaultBootstrapProofValid(
|
||||
env: Env,
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
bootstrap: SyncVaultBootstrapProofInput,
|
||||
proof: string,
|
||||
): Promise<boolean> {
|
||||
const row = await env.ELY_DB.prepare(APPROVED_V2_SIGNING_KEY_QUERY)
|
||||
.bind(userId, deviceId)
|
||||
.first<SigningKeyRow>();
|
||||
if (
|
||||
row === null ||
|
||||
typeof row.signing_public_key !== "string" ||
|
||||
!PUBLIC_KEY_PATTERN.test(row.signing_public_key)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return verifyEd25519Signature(
|
||||
row.signing_public_key,
|
||||
proof,
|
||||
syncVaultBootstrapProofBytes(userId, deviceId, bootstrap),
|
||||
);
|
||||
}
|
||||
|
||||
export function syncVaultBootstrapProofBytes(
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
bootstrap: SyncVaultBootstrapProofInput,
|
||||
): Uint8Array {
|
||||
const values: (number | string)[] = [
|
||||
BOOTSTRAP_DOMAIN,
|
||||
userId,
|
||||
deviceId,
|
||||
bootstrap.keyId,
|
||||
bootstrap.generation,
|
||||
bootstrap.envelope.version,
|
||||
bootstrap.envelope.suite,
|
||||
bootstrap.envelope.encapped_key,
|
||||
bootstrap.envelope.ciphertext,
|
||||
bootstrap.idempotencyKey,
|
||||
];
|
||||
const encoder = new TextEncoder();
|
||||
return encoder.encode(values.map((value) => {
|
||||
const text = value.toString();
|
||||
return `${encoder.encode(text).byteLength}:${text}`;
|
||||
}).join(""));
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import type {
|
||||
ElyD1DatabaseSession,
|
||||
ElyD1PreparedStatement,
|
||||
ElyD1Result,
|
||||
Env,
|
||||
} from "./bindings.js";
|
||||
import { primaryD1Session } from "./bindings.js";
|
||||
import { collectSyncR2Garbage } from "./sync_r2_gc.js";
|
||||
|
||||
const MARK_CLEANUP_READY_QUERY = `
|
||||
UPDATE sync_vault_rotations
|
||||
SET cleanup_snapshot_id = ?, cleanup_started_at = ?
|
||||
WHERE user_id = ? AND completed_at IS NOT NULL AND storage_cleaned_at IS NULL
|
||||
AND cleanup_snapshot_id IS NULL AND new_generation <= ?
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM sync_vault_accounts AS account
|
||||
WHERE account.user_id = sync_vault_rotations.user_id
|
||||
AND account.current_key_id = ? AND account.current_generation = ?
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM sync_snapshots AS snapshot
|
||||
INNER JOIN sync_snapshot_encryption AS encryption
|
||||
ON encryption.user_id = snapshot.user_id
|
||||
AND encryption.snapshot_id = snapshot.snapshot_id
|
||||
INNER JOIN sync_snapshot_heads AS head
|
||||
ON head.user_id = snapshot.user_id
|
||||
AND head.snapshot_id = snapshot.snapshot_id
|
||||
AND head.head_revision = snapshot.head_revision
|
||||
AND head.payload_hash = snapshot.payload_hash
|
||||
WHERE snapshot.user_id = sync_vault_rotations.user_id
|
||||
AND snapshot.snapshot_id = ?
|
||||
AND encryption.key_id = ? AND encryption.vault_generation = ?
|
||||
)
|
||||
`;
|
||||
const READY_ROTATIONS_QUERY = `
|
||||
SELECT idempotency_key, new_key_id, new_generation
|
||||
FROM sync_vault_rotations
|
||||
WHERE user_id = ? AND cleanup_snapshot_id IS NOT NULL AND storage_cleaned_at IS NULL
|
||||
ORDER BY new_generation ASC, idempotency_key ASC
|
||||
`;
|
||||
const DELETE_CHANGE_LOG_QUERY = `
|
||||
DELETE FROM sync_change_log
|
||||
WHERE user_id = ? AND object_id IN (
|
||||
SELECT object.object_id
|
||||
FROM sync_objects AS object
|
||||
INNER JOIN sync_vault_rotation_r2_objects AS staged
|
||||
ON staged.user_id = object.user_id AND staged.r2_key = object.payload_r2_key
|
||||
WHERE object.user_id = ? AND staged.rotation_idempotency_key = ?
|
||||
)
|
||||
`;
|
||||
const FENCE_ROTATION_R2_QUERY = `
|
||||
UPDATE sync_r2_gc_candidates
|
||||
SET
|
||||
state = 'ready',
|
||||
lease_expires_at = ?,
|
||||
updated_at = MAX(updated_at, ?),
|
||||
ready_at = COALESCE(ready_at, ?)
|
||||
WHERE state = 'referenced' AND r2_key IN (
|
||||
SELECT r2_key FROM sync_vault_rotation_r2_objects
|
||||
WHERE user_id = ? AND rotation_idempotency_key = ?
|
||||
)
|
||||
`;
|
||||
const DELETE_TOMBSTONES_QUERY = `
|
||||
DELETE FROM sync_tombstones
|
||||
WHERE user_id = ? AND object_id IN (
|
||||
SELECT object.object_id
|
||||
FROM sync_objects AS object
|
||||
INNER JOIN sync_vault_rotation_r2_objects AS staged
|
||||
ON staged.user_id = object.user_id AND staged.r2_key = object.payload_r2_key
|
||||
WHERE object.user_id = ? AND staged.rotation_idempotency_key = ?
|
||||
)
|
||||
`;
|
||||
const DELETE_OBJECTS_QUERY = `
|
||||
DELETE FROM sync_objects
|
||||
WHERE user_id = ? AND payload_r2_key IN (
|
||||
SELECT r2_key FROM sync_vault_rotation_r2_objects
|
||||
WHERE user_id = ? AND rotation_idempotency_key = ?
|
||||
)
|
||||
`;
|
||||
const DELETE_SNAPSHOT_ENCRYPTION_QUERY = `
|
||||
DELETE FROM sync_snapshot_encryption
|
||||
WHERE user_id = ?
|
||||
AND NOT (key_id = ? AND vault_generation = ?)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM sync_snapshot_heads AS head
|
||||
WHERE head.user_id = sync_snapshot_encryption.user_id
|
||||
AND head.snapshot_id = sync_snapshot_encryption.snapshot_id
|
||||
)
|
||||
AND snapshot_id IN (
|
||||
SELECT snapshot.snapshot_id
|
||||
FROM sync_snapshots AS snapshot
|
||||
INNER JOIN sync_vault_rotation_r2_objects AS staged
|
||||
ON staged.user_id = snapshot.user_id AND staged.r2_key = snapshot.r2_key
|
||||
WHERE snapshot.user_id = ? AND staged.rotation_idempotency_key = ?
|
||||
)
|
||||
`;
|
||||
const DELETE_SNAPSHOTS_QUERY = `
|
||||
DELETE FROM sync_snapshots
|
||||
WHERE user_id = ?
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM sync_snapshot_heads AS head
|
||||
WHERE head.user_id = sync_snapshots.user_id
|
||||
AND head.snapshot_id = sync_snapshots.snapshot_id
|
||||
)
|
||||
AND r2_key IN (
|
||||
SELECT r2_key FROM sync_vault_rotation_r2_objects
|
||||
WHERE user_id = ? AND rotation_idempotency_key = ?
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM sync_snapshot_encryption AS encryption
|
||||
WHERE encryption.user_id = sync_snapshots.user_id
|
||||
AND encryption.snapshot_id = sync_snapshots.snapshot_id
|
||||
)
|
||||
`;
|
||||
const MARK_STORAGE_CLEAN_QUERY = `
|
||||
UPDATE sync_vault_rotations
|
||||
SET storage_cleaned_at = ?
|
||||
WHERE user_id = ? AND idempotency_key = ?
|
||||
AND cleanup_snapshot_id IS NOT NULL AND storage_cleaned_at IS NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM sync_vault_rotation_r2_objects AS staged
|
||||
WHERE staged.user_id = sync_vault_rotations.user_id
|
||||
AND staged.rotation_idempotency_key = sync_vault_rotations.idempotency_key
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM sync_objects AS object
|
||||
WHERE object.payload_r2_key = staged.r2_key
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM sync_snapshots AS snapshot
|
||||
WHERE snapshot.r2_key = staged.r2_key
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM sync_r2_gc_candidates AS candidate
|
||||
WHERE candidate.r2_key = staged.r2_key AND candidate.state = 'deleted'
|
||||
)
|
||||
)
|
||||
`;
|
||||
const FINALIZE_STORAGE_CLEAN_QUERY = `
|
||||
UPDATE sync_vault_rotations
|
||||
SET storage_cleaned_at = MAX(cleanup_started_at, ?)
|
||||
WHERE cleanup_snapshot_id IS NOT NULL AND storage_cleaned_at IS NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM sync_vault_rotation_r2_objects AS staged
|
||||
WHERE staged.user_id = sync_vault_rotations.user_id
|
||||
AND staged.rotation_idempotency_key = sync_vault_rotations.idempotency_key
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM sync_objects AS object
|
||||
WHERE object.payload_r2_key = staged.r2_key
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM sync_snapshots AS snapshot
|
||||
WHERE snapshot.r2_key = staged.r2_key
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM sync_r2_gc_candidates AS candidate
|
||||
WHERE candidate.r2_key = staged.r2_key AND candidate.state = 'deleted'
|
||||
)
|
||||
)
|
||||
`;
|
||||
|
||||
interface RotationRow {
|
||||
idempotency_key: unknown;
|
||||
new_key_id: unknown;
|
||||
new_generation: unknown;
|
||||
}
|
||||
|
||||
export class SyncVaultRotationCleanupError extends Error {}
|
||||
|
||||
export async function cleanupRotatedVaultStorage(
|
||||
env: Env,
|
||||
userId: string,
|
||||
snapshotId: string,
|
||||
keyId: string,
|
||||
generation: number,
|
||||
nowSeconds: number,
|
||||
): Promise<void> {
|
||||
const database = primaryD1Session(env.ELY_DB);
|
||||
await database.prepare(MARK_CLEANUP_READY_QUERY).bind(
|
||||
snapshotId,
|
||||
nowSeconds,
|
||||
userId,
|
||||
generation,
|
||||
keyId,
|
||||
generation,
|
||||
snapshotId,
|
||||
keyId,
|
||||
generation,
|
||||
).run();
|
||||
const ready = await database.prepare(READY_ROTATIONS_QUERY)
|
||||
.bind(userId)
|
||||
.all<RotationRow>();
|
||||
for (const row of ready.results) {
|
||||
const idempotencyKey = storedIdempotencyKey(row.idempotency_key);
|
||||
const rotationKeyId = storedKeyId(row.new_key_id);
|
||||
const rotationGeneration = storedGeneration(row.new_generation);
|
||||
await database.batch(cleanupMetadataStatements(
|
||||
database,
|
||||
userId,
|
||||
idempotencyKey,
|
||||
rotationKeyId,
|
||||
rotationGeneration,
|
||||
nowSeconds,
|
||||
));
|
||||
await collectSyncR2Garbage(env, nowSeconds, { userId, limit: 100, database });
|
||||
const result = await database.prepare(MARK_STORAGE_CLEAN_QUERY)
|
||||
.bind(nowSeconds, userId, idempotencyKey)
|
||||
.run() as ElyD1Result;
|
||||
const changes = result.meta?.changes;
|
||||
if (typeof changes !== "number" || !Number.isSafeInteger(changes) || changes > 1 || changes < 0) {
|
||||
throw new SyncVaultRotationCleanupError("sync_vault_rotation_cleanup_write_invalid");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function finalizeCleanedVaultRotations(
|
||||
env: Env,
|
||||
nowSeconds: number,
|
||||
): Promise<number> {
|
||||
const result = await primaryD1Session(env.ELY_DB)
|
||||
.prepare(FINALIZE_STORAGE_CLEAN_QUERY)
|
||||
.bind(nowSeconds)
|
||||
.run() as ElyD1Result;
|
||||
const changes = result.meta?.changes;
|
||||
if (typeof changes !== "number" || !Number.isSafeInteger(changes) || changes < 0) {
|
||||
throw new SyncVaultRotationCleanupError("sync_vault_rotation_cleanup_write_invalid");
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
function cleanupMetadataStatements(
|
||||
database: ElyD1DatabaseSession,
|
||||
userId: string,
|
||||
idempotencyKey: string,
|
||||
keyId: string,
|
||||
generation: number,
|
||||
nowSeconds: number,
|
||||
): ElyD1PreparedStatement[] {
|
||||
return [
|
||||
database.prepare(FENCE_ROTATION_R2_QUERY).bind(
|
||||
nowSeconds,
|
||||
nowSeconds,
|
||||
nowSeconds,
|
||||
userId,
|
||||
idempotencyKey,
|
||||
),
|
||||
database.prepare(DELETE_CHANGE_LOG_QUERY).bind(userId, userId, idempotencyKey),
|
||||
database.prepare(DELETE_TOMBSTONES_QUERY).bind(userId, userId, idempotencyKey),
|
||||
database.prepare(DELETE_OBJECTS_QUERY).bind(userId, userId, idempotencyKey),
|
||||
database.prepare(DELETE_SNAPSHOT_ENCRYPTION_QUERY).bind(
|
||||
userId,
|
||||
keyId,
|
||||
generation,
|
||||
userId,
|
||||
idempotencyKey,
|
||||
),
|
||||
database.prepare(DELETE_SNAPSHOTS_QUERY).bind(userId, userId, idempotencyKey),
|
||||
];
|
||||
}
|
||||
|
||||
function storedIdempotencyKey(value: unknown): string {
|
||||
if (typeof value !== "string" || !/^[a-zA-Z0-9._:-]{16,128}$/.test(value)) {
|
||||
throw new SyncVaultRotationCleanupError("sync_vault_rotation_idempotency_key_invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function storedKeyId(value: unknown): string {
|
||||
if (typeof value !== "string" || !/^[a-f0-9]{64}$/.test(value)) {
|
||||
throw new SyncVaultRotationCleanupError("sync_vault_rotation_key_id_invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function storedGeneration(value: unknown): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 2) {
|
||||
throw new SyncVaultRotationCleanupError("sync_vault_rotation_generation_invalid");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
export const CURRENT_SYNC_VAULT_KEY_QUERY = `
|
||||
SELECT current_key_id AS key_id, current_generation AS generation
|
||||
FROM sync_vault_accounts
|
||||
WHERE user_id = ?
|
||||
`;
|
||||
|
||||
export const CURRENT_DEVICE_ENVELOPE_QUERY = `
|
||||
SELECT
|
||||
accounts.current_key_id AS key_id,
|
||||
accounts.current_generation AS generation,
|
||||
envelopes.recipient_device_id AS recipient_device_id,
|
||||
envelopes.approver_device_id AS approver_device_id,
|
||||
envelopes.envelope_version AS envelope_version,
|
||||
envelopes.suite AS suite,
|
||||
envelopes.encapped_key AS encapped_key,
|
||||
envelopes.ciphertext AS ciphertext,
|
||||
envelopes.idempotency_key AS idempotency_key,
|
||||
envelopes.created_at AS created_at
|
||||
FROM sync_vault_accounts AS accounts
|
||||
INNER JOIN sync_vault_envelopes AS envelopes
|
||||
ON envelopes.user_id = accounts.user_id
|
||||
AND envelopes.key_id = accounts.current_key_id
|
||||
AND envelopes.generation = accounts.current_generation
|
||||
WHERE accounts.user_id = ? AND envelopes.recipient_device_id = ?
|
||||
`;
|
||||
|
||||
export const HISTORICAL_DEVICE_ENVELOPE_QUERY = `
|
||||
SELECT
|
||||
key_id,
|
||||
generation,
|
||||
recipient_device_id,
|
||||
approver_device_id,
|
||||
envelope_version,
|
||||
suite,
|
||||
encapped_key,
|
||||
ciphertext,
|
||||
idempotency_key,
|
||||
created_at
|
||||
FROM sync_vault_envelopes
|
||||
WHERE user_id = ? AND recipient_device_id = ? AND key_id = ? AND generation = ?
|
||||
`;
|
||||
|
||||
export const SYNC_VAULT_ACCOUNT_INSERT_QUERY = `
|
||||
INSERT INTO sync_vault_accounts (user_id, current_key_id, current_generation, created_at, updated_at)
|
||||
SELECT ?, ?, ?, ?, ?
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM user_devices AS device
|
||||
INNER JOIN user_device_keys AS keys
|
||||
ON keys.user_id = device.user_id AND keys.device_id = device.device_id
|
||||
WHERE device.user_id = ?
|
||||
AND device.device_id = ?
|
||||
AND device.approval_status = 'approved'
|
||||
AND device.revoked_at IS NULL
|
||||
AND keys.key_protocol_version = 2
|
||||
AND keys.wrapping_public_key IS NOT NULL
|
||||
)
|
||||
ON CONFLICT(user_id) DO NOTHING
|
||||
`;
|
||||
|
||||
export const SYNC_VAULT_ENVELOPE_INSERT_QUERY = `
|
||||
INSERT INTO sync_vault_envelopes (
|
||||
user_id, recipient_device_id, approver_device_id, key_id, generation,
|
||||
envelope_version, suite, encapped_key, ciphertext, idempotency_key, created_at
|
||||
)
|
||||
SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
FROM sync_vault_accounts AS accounts
|
||||
INNER JOIN user_devices AS recipient
|
||||
ON recipient.user_id = accounts.user_id
|
||||
AND recipient.device_id = ?
|
||||
AND recipient.approval_status = ?
|
||||
AND recipient.revoked_at IS NULL
|
||||
INNER JOIN user_device_keys AS recipient_keys
|
||||
ON recipient_keys.user_id = recipient.user_id
|
||||
AND recipient_keys.device_id = recipient.device_id
|
||||
AND recipient_keys.key_protocol_version = 2
|
||||
AND recipient_keys.wrapping_public_key IS NOT NULL
|
||||
INNER JOIN user_devices AS approver
|
||||
ON approver.user_id = accounts.user_id
|
||||
AND approver.device_id = ?
|
||||
AND approver.approval_status = 'approved'
|
||||
AND approver.revoked_at IS NULL
|
||||
INNER JOIN user_device_keys AS approver_keys
|
||||
ON approver_keys.user_id = approver.user_id
|
||||
AND approver_keys.device_id = approver.device_id
|
||||
AND approver_keys.key_protocol_version = 2
|
||||
AND approver_keys.wrapping_public_key IS NOT NULL
|
||||
WHERE accounts.user_id = ?
|
||||
AND accounts.current_key_id = ?
|
||||
AND accounts.current_generation = ?
|
||||
ON CONFLICT DO NOTHING
|
||||
`;
|
||||
@@ -4,7 +4,18 @@ import { describe, it } from "node:test";
|
||||
|
||||
import { authSessionCacheKvKey, authTokenHash } from "../src/auth.js";
|
||||
import { handleRequest } from "../src/index.js";
|
||||
import { ACCESS_TOKEN, sessionDocument, testD1Database, testEnv } from "./devices_test_support.js";
|
||||
import {
|
||||
recentDeviceActionProofBytes,
|
||||
recentDeviceActionRequestHash,
|
||||
} from "../src/recent_device_action_proof.js";
|
||||
import {
|
||||
ACCESS_TOKEN,
|
||||
PUBLIC_KEY,
|
||||
sessionDocument,
|
||||
signDeviceMessage,
|
||||
testD1Database,
|
||||
testEnv,
|
||||
} from "./devices_test_support.js";
|
||||
|
||||
const USER_ID = "user-01";
|
||||
const DEVICE_ID = "device-01";
|
||||
@@ -21,13 +32,22 @@ describe("account deletion routes", () => {
|
||||
const r2Deletes: string[] = [];
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const sessionCacheKey = authSessionCacheKvKey("local", tokenHash);
|
||||
const requestBody = await accountDeleteBody();
|
||||
const d1 = testD1Database({
|
||||
firstRows: [{ device_id: DEVICE_ID }, null, deletionCountsRow()],
|
||||
allRows: [{ r2_key: PAYLOAD_KEY }, { r2_key: SNAPSHOT_KEY }],
|
||||
firstRows: [
|
||||
null,
|
||||
{ signing_public_key: PUBLIC_KEY },
|
||||
deletionCountsRow(),
|
||||
],
|
||||
allRowSets: [
|
||||
[{ r2_key: PAYLOAD_KEY }, { r2_key: SNAPSHOT_KEY }],
|
||||
[{ token: ACCESS_TOKEN }],
|
||||
[{ r2_key: PAYLOAD_KEY }, { r2_key: SNAPSHOT_KEY }],
|
||||
],
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
accountDeleteRequest(accountDeleteBody()),
|
||||
accountDeleteRequest(requestBody),
|
||||
testEnv({
|
||||
d1,
|
||||
kvDeletes,
|
||||
@@ -68,36 +88,49 @@ describe("account deletion routes", () => {
|
||||
});
|
||||
assert.deepEqual(r2Deletes, [PAYLOAD_KEY, SNAPSHOT_KEY]);
|
||||
assert.deepEqual(kvDeletes, [sessionCacheKey]);
|
||||
assert.equal(d1.batches[0], 12);
|
||||
assert.ok(d1.queries[1]?.includes("FROM audit_events"));
|
||||
assert.equal(d1.batches[0], 18);
|
||||
assert.ok(d1.queries[0]?.includes("FROM audit_events"));
|
||||
assert.ok(d1.queries[1]?.includes("signing_public_key"));
|
||||
assert.ok(d1.queries[2]?.includes("FROM user_devices"));
|
||||
assert.ok(d1.queries[3]?.includes("UNION"));
|
||||
assert.ok(d1.queries[4]?.includes("DELETE FROM sync_change_log"));
|
||||
assert.ok(d1.queries[9]?.includes("DELETE FROM better_auth_session_device_context"));
|
||||
assert.ok(d1.queries[10]?.includes("DELETE FROM user_devices"));
|
||||
assert.ok(d1.queries[15]?.includes("INSERT INTO audit_events"));
|
||||
assert.deepEqual(d1.binds[1], [accountDeletionEventId()]);
|
||||
assert.deepEqual(d1.binds[3], [USER_ID, USER_ID]);
|
||||
assert.deepEqual(d1.binds[15], [
|
||||
assert.ok(d1.queries[3]?.includes("FROM sync_r2_gc_candidates"));
|
||||
assert.ok(d1.queries[4]?.includes("FROM better_auth_session"));
|
||||
assert.ok(d1.queries[5]?.includes("CASE WHEN EXISTS"));
|
||||
assert.ok(d1.queries[6]?.includes("UPDATE sync_r2_gc_candidates"));
|
||||
assert.ok(d1.queries[7]?.includes("DELETE FROM sync_change_log"));
|
||||
assert.ok(d1.queries[9]?.includes("DELETE FROM sync_snapshot_heads"));
|
||||
assert.ok(d1.queries[10]?.includes("DELETE FROM sync_snapshot_encryption"));
|
||||
assert.ok(d1.queries[11]?.includes("DELETE FROM sync_snapshots"));
|
||||
assert.ok(d1.queries[14]?.includes("DELETE FROM sync_vault_accounts"));
|
||||
assert.ok(d1.queries[16]?.includes("DELETE FROM better_auth_session_device_context"));
|
||||
assert.ok(d1.queries[17]?.includes("DELETE FROM user_devices"));
|
||||
assert.deepEqual(d1.binds[0], [accountDeletionEventId()]);
|
||||
assert.deepEqual(d1.binds[3], [USER_ID]);
|
||||
assert.ok(d1.queries[22]?.includes("SET user_id = NULL"));
|
||||
assert.deepEqual(d1.binds[5]?.slice(0, 6), [
|
||||
accountDeletionEventId(),
|
||||
null,
|
||||
DEVICE_ID,
|
||||
"account.delete",
|
||||
"account",
|
||||
USER_HASH,
|
||||
IDEMPOTENCY_HASH,
|
||||
body.deleted_at,
|
||||
]);
|
||||
assert.equal(d1.binds[5]?.[11], PUBLIC_KEY);
|
||||
assert.equal(d1.binds[5]?.[12], await requestHash(requestBody));
|
||||
assert.equal(d1.binds[5]?.[13], body.deleted_at);
|
||||
});
|
||||
|
||||
it("returns an idempotent deletion document for existing audit events", async () => {
|
||||
const kvDeletes: string[] = [];
|
||||
const r2Deletes: string[] = [];
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const requestBody = await accountDeleteBody();
|
||||
const d1 = testD1Database({
|
||||
firstRows: [
|
||||
{ device_id: DEVICE_ID },
|
||||
{
|
||||
actor_device_id: DEVICE_ID,
|
||||
outcome: "success",
|
||||
subject_id: USER_HASH,
|
||||
metadata_hash: await requestHash(requestBody),
|
||||
created_at: 1_780_001_000,
|
||||
},
|
||||
],
|
||||
@@ -105,7 +138,7 @@ describe("account deletion routes", () => {
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
accountDeleteRequest(accountDeleteBody()),
|
||||
accountDeleteRequest(requestBody),
|
||||
testEnv({
|
||||
d1,
|
||||
kvDeletes,
|
||||
@@ -139,28 +172,63 @@ describe("account deletion routes", () => {
|
||||
});
|
||||
assert.deepEqual(r2Deletes, []);
|
||||
assert.deepEqual(kvDeletes, []);
|
||||
assert.equal(d1.queries.length, 2);
|
||||
assert.equal(d1.queries.length, 1);
|
||||
assert.deepEqual(d1.batches, []);
|
||||
});
|
||||
|
||||
it("deletes every legacy KV session key for the account", async () => {
|
||||
const secondToken = "second-session-token-0000000000000000";
|
||||
const currentHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const secondHash = await authTokenHash(secondToken);
|
||||
const currentKey = authSessionCacheKvKey("local", currentHash);
|
||||
const secondKey = authSessionCacheKvKey("local", secondHash);
|
||||
const kvDeletes: string[] = [];
|
||||
const d1 = testD1Database({
|
||||
firstRows: [
|
||||
null,
|
||||
{ signing_public_key: PUBLIC_KEY },
|
||||
deletionCountsRow(),
|
||||
],
|
||||
allRowSets: [[], [{ token: ACCESS_TOKEN }, { token: secondToken }], []],
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
accountDeleteRequest(await accountDeleteBody()),
|
||||
testEnv({
|
||||
d1,
|
||||
kvDeletes,
|
||||
kvEntries: [
|
||||
[currentKey, sessionDocument(DEVICE_ID)],
|
||||
[secondKey, sessionDocument("device-02")],
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
const body = await response.json() as { deleted: { kv_session_cache: number } };
|
||||
assert.equal(body.deleted.kv_session_cache, 2);
|
||||
assert.deepEqual(kvDeletes.sort(), [currentKey, secondKey].sort());
|
||||
});
|
||||
|
||||
it("rejects replay mismatches before deleting account data", async () => {
|
||||
const kvDeletes: string[] = [];
|
||||
const r2Deletes: string[] = [];
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const requestBody = await accountDeleteBody();
|
||||
const d1 = testD1Database({
|
||||
firstRows: [
|
||||
{ device_id: DEVICE_ID },
|
||||
{
|
||||
actor_device_id: "device-02",
|
||||
outcome: "success",
|
||||
subject_id: USER_HASH,
|
||||
metadata_hash: await requestHash(requestBody),
|
||||
created_at: 1_780_001_000,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
accountDeleteRequest(accountDeleteBody()),
|
||||
accountDeleteRequest(requestBody),
|
||||
testEnv({
|
||||
d1,
|
||||
kvDeletes,
|
||||
@@ -180,10 +248,10 @@ describe("account deletion routes", () => {
|
||||
const kvDeletes: string[] = [];
|
||||
const r2Deletes: string[] = [];
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({ firstRows: [{ device_id: DEVICE_ID }] });
|
||||
const d1 = testD1Database([]);
|
||||
|
||||
const response = await handleRequest(
|
||||
accountDeleteRequest(accountDeleteBody({ confirmation: "delete" })),
|
||||
accountDeleteRequest(await accountDeleteBody({ confirmation: "delete" })),
|
||||
testEnv({
|
||||
d1,
|
||||
kvDeletes,
|
||||
@@ -196,16 +264,16 @@ describe("account deletion routes", () => {
|
||||
assert.deepEqual(await response.json(), { error: "invalid_account_deletion" });
|
||||
assert.deepEqual(r2Deletes, []);
|
||||
assert.deepEqual(kvDeletes, []);
|
||||
assert.equal(d1.queries.length, 1);
|
||||
assert.equal(d1.queries.length, 0);
|
||||
assert.deepEqual(d1.batches, []);
|
||||
});
|
||||
|
||||
it("rejects revoked devices before reading account deletion bodies", async () => {
|
||||
it("requires an approved device key for a new account deletion", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({ firstRows: [null] });
|
||||
const d1 = testD1Database({ firstRows: [null, null] });
|
||||
|
||||
const response = await handleRequest(
|
||||
accountDeleteRequest(accountDeleteBody()),
|
||||
accountDeleteRequest(await accountDeleteBody()),
|
||||
testEnv({
|
||||
d1,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
|
||||
@@ -213,22 +281,30 @@ describe("account deletion routes", () => {
|
||||
);
|
||||
|
||||
assert.equal(response.status, 403);
|
||||
assert.deepEqual(await response.json(), { error: "device_not_approved" });
|
||||
assert.equal(d1.queries.length, 1);
|
||||
assert.deepEqual(await response.json(), { error: "account_deletion_forbidden" });
|
||||
assert.equal(d1.queries.length, 2);
|
||||
assert.deepEqual(d1.batches, []);
|
||||
});
|
||||
|
||||
it("fails closed when stored R2 keys are malformed", async () => {
|
||||
it("keeps account deletion successful when scheduled GC must handle a malformed key", async () => {
|
||||
const kvDeletes: string[] = [];
|
||||
const r2Deletes: string[] = [];
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({
|
||||
firstRows: [{ device_id: DEVICE_ID }, null, deletionCountsRow()],
|
||||
allRows: [{ r2_key: "sync-snapshots/../bad.bin" }],
|
||||
firstRows: [
|
||||
null,
|
||||
{ signing_public_key: PUBLIC_KEY },
|
||||
deletionCountsRow(),
|
||||
],
|
||||
allRowSets: [
|
||||
[{ r2_key: "sync-snapshots/../bad.bin" }],
|
||||
[],
|
||||
[{ r2_key: "sync-snapshots/../bad.bin" }],
|
||||
],
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
accountDeleteRequest(accountDeleteBody()),
|
||||
accountDeleteRequest(await accountDeleteBody()),
|
||||
testEnv({
|
||||
d1,
|
||||
kvDeletes,
|
||||
@@ -237,11 +313,10 @@ describe("account deletion routes", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 500);
|
||||
assert.deepEqual(await response.json(), { error: "account_deletion_failed" });
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(r2Deletes, []);
|
||||
assert.deepEqual(kvDeletes, []);
|
||||
assert.deepEqual(d1.batches, []);
|
||||
assert.deepEqual(kvDeletes, [authSessionCacheKvKey("local", tokenHash)]);
|
||||
assert.deepEqual(d1.batches, [18]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -256,13 +331,26 @@ function accountDeleteRequest(body: Record<string, unknown>): Request {
|
||||
});
|
||||
}
|
||||
|
||||
function accountDeleteBody(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
version: 1,
|
||||
async function accountDeleteBody(
|
||||
overrides: Record<string, unknown> = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
const body: Record<string, unknown> = {
|
||||
version: 2,
|
||||
confirmation: "delete-elydora-account",
|
||||
idempotency_key: IDEMPOTENCY_KEY,
|
||||
proof_created_at: Math.floor(Date.now() / 1000),
|
||||
...overrides,
|
||||
};
|
||||
body.action_proof = await signDeviceMessage(recentDeviceActionProofBytes({
|
||||
action: "account.delete",
|
||||
userId: USER_ID,
|
||||
sessionId: "session-01",
|
||||
deviceId: DEVICE_ID,
|
||||
confirmation: String(body.confirmation),
|
||||
idempotencyKey: String(body.idempotency_key),
|
||||
proofCreatedAt: Number(body.proof_created_at),
|
||||
}));
|
||||
return body;
|
||||
}
|
||||
|
||||
function deletionCountsRow(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
@@ -286,6 +374,19 @@ function accountDeletionEventId(): string {
|
||||
return `account-delete:${USER_HASH}:${IDEMPOTENCY_HASH}`;
|
||||
}
|
||||
|
||||
function requestHash(body: Record<string, unknown>): Promise<string> {
|
||||
return recentDeviceActionRequestHash({
|
||||
action: "account.delete",
|
||||
userId: USER_ID,
|
||||
sessionId: "session-01",
|
||||
deviceId: DEVICE_ID,
|
||||
confirmation: String(body.confirmation),
|
||||
idempotencyKey: String(body.idempotency_key),
|
||||
proofCreatedAt: Number(body.proof_created_at),
|
||||
actionProof: String(body.action_proof),
|
||||
});
|
||||
}
|
||||
|
||||
function bytes(value: string): Uint8Array {
|
||||
return new TextEncoder().encode(value);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import type { ElyR2Object, ElyR2PutOptions, Env } from "../src/bindings.js";
|
||||
import { accountDeletionDocument } from "../src/account_deletion.js";
|
||||
import { authSessionCacheKvKey } from "../src/auth.js";
|
||||
import { purgeLegacySessionCache } from "../src/legacy_auth_kv_cleanup.js";
|
||||
import {
|
||||
recentDeviceActionProofBytes,
|
||||
type SensitiveAction,
|
||||
} from "../src/recent_device_action_proof.js";
|
||||
import { collectSyncR2Garbage } from "../src/sync_r2_gc.js";
|
||||
import { maintainSyncR2Storage } from "../src/sync_r2_maintenance.js";
|
||||
import { syncResetDocument } from "../src/sync_reset.js";
|
||||
import { PUBLIC_KEY, signDeviceMessage } from "./devices_test_support.js";
|
||||
import { SqliteD1Database, execute, query } from "./sqlite_d1_test_support.js";
|
||||
|
||||
const USER_ID = "user-01";
|
||||
const DEVICE_ID = "device-01";
|
||||
const KEY_ID = "1".repeat(64);
|
||||
const TOKEN_HASH = "2".repeat(64);
|
||||
const OWNER_HASH = createHash("sha256").update(USER_ID).digest("hex");
|
||||
const NOW = 1_800_000_000;
|
||||
const MIGRATIONS_DIR = join(process.cwd(), "migrations");
|
||||
|
||||
describe("account deletion and reset GC drains", () => {
|
||||
it("drains 101 reset candidates through bounded batches", async () => {
|
||||
await withDatabase(async (databasePath, bucket, _kv, env) => {
|
||||
const keys = seedReadyCandidates(databasePath, bucket, 101);
|
||||
|
||||
const document = await syncResetDocument(
|
||||
await resetRequest("sync-reset-101-items", NOW),
|
||||
env,
|
||||
authContext(),
|
||||
NOW,
|
||||
);
|
||||
const replay = await syncResetDocument(
|
||||
await resetRequest("sync-reset-101-items", NOW),
|
||||
env,
|
||||
authContext(),
|
||||
NOW + 1_000,
|
||||
);
|
||||
|
||||
assert.equal(document.deleted.r2_objects, 101);
|
||||
assert.equal(replay.reset_at, NOW);
|
||||
assert.equal(replay.deleted.r2_objects, 0);
|
||||
assert.equal(deletedCandidateCount(databasePath), 101);
|
||||
assert.equal(bucket.size, 0);
|
||||
assert.deepEqual(bucket.deletes.sort(), keys.sort());
|
||||
});
|
||||
});
|
||||
|
||||
it("releases rotation staging on reset and finalizes cleanup during maintenance", async () => {
|
||||
await withDatabase(async (databasePath, bucket, _kv, env) => {
|
||||
const [key] = seedReadyCandidates(databasePath, bucket, 1);
|
||||
assert.ok(key !== undefined);
|
||||
seedCompletedRotation(databasePath, key);
|
||||
|
||||
const document = await syncResetDocument(
|
||||
await resetRequest("sync-reset-rotation", NOW),
|
||||
env,
|
||||
authContext(),
|
||||
NOW,
|
||||
);
|
||||
|
||||
assert.equal(document.deleted.r2_objects, 1);
|
||||
assert.equal(candidateState(databasePath, key), "deleted");
|
||||
assert.deepEqual(query(databasePath, `
|
||||
SELECT cleanup_snapshot_id, storage_cleaned_at
|
||||
FROM sync_vault_rotations
|
||||
WHERE user_id = '${USER_ID}' AND idempotency_key = 'rotation-reset-0001'
|
||||
`), [{ cleanup_snapshot_id: "sync-reset", storage_cleaned_at: null }]);
|
||||
|
||||
await maintainSyncR2Storage(env, NOW + 1);
|
||||
|
||||
assert.deepEqual(query(databasePath, `
|
||||
SELECT storage_cleaned_at
|
||||
FROM sync_vault_rotations
|
||||
WHERE user_id = '${USER_ID}' AND idempotency_key = 'rotation-reset-0001'
|
||||
`), [{ storage_cleaned_at: NOW + 1 }]);
|
||||
});
|
||||
});
|
||||
|
||||
it("drains 101 account candidates after anonymizing their owner", async () => {
|
||||
await withDatabase(async (databasePath, bucket, _kv, env) => {
|
||||
seedReadyCandidates(databasePath, bucket, 101);
|
||||
const context = authContext();
|
||||
const request = await accountDeleteRequest("account-delete-101-items", NOW);
|
||||
const replayRequest = request.clone();
|
||||
const document = await accountDeletionDocument(
|
||||
request,
|
||||
env,
|
||||
context,
|
||||
NOW,
|
||||
);
|
||||
const replay = await accountDeletionDocument(replayRequest, env, context, NOW + 1_000);
|
||||
|
||||
assert.equal(document.deleted.r2_objects, 101);
|
||||
assert.equal(replay.account_hash, document.account_hash);
|
||||
assert.equal(replay.deleted_at, NOW);
|
||||
assert.equal(replay.deleted.users, 0);
|
||||
assert.equal(deletedCandidateCount(databasePath), 101);
|
||||
assert.equal(bucket.size, 0);
|
||||
assert.equal(query(databasePath, `
|
||||
SELECT COUNT(*) AS count FROM sync_r2_gc_candidates WHERE user_id IS NOT NULL
|
||||
`)[0]?.count, 0);
|
||||
assert.equal(query(databasePath, `
|
||||
SELECT COUNT(*) AS count FROM better_auth_user WHERE id = '${USER_ID}'
|
||||
`)[0]?.count, 0);
|
||||
assert.deepEqual(query(databasePath, `
|
||||
SELECT user_id, outcome FROM audit_events WHERE event_type = 'account.delete'
|
||||
`), [{ user_id: null, outcome: "success" }]);
|
||||
});
|
||||
});
|
||||
|
||||
it("returns account deletion success while scheduled cleanup retries R2 and KV failures", async () => {
|
||||
await withDatabase(async (databasePath, bucket, kv, env) => {
|
||||
const [key] = seedReadyCandidates(databasePath, bucket, 1);
|
||||
assert.ok(key !== undefined);
|
||||
const legacyKey = authSessionCacheKvKey("local", TOKEN_HASH);
|
||||
kv.values.set(legacyKey, "legacy-session");
|
||||
bucket.failDeletes = true;
|
||||
kv.failDeletes = true;
|
||||
|
||||
const document = await accountDeletionDocument(
|
||||
await accountDeleteRequest("account-delete-cleanup-failure", NOW),
|
||||
env,
|
||||
authContext(),
|
||||
NOW,
|
||||
);
|
||||
|
||||
assert.equal(document.deleted.kv_session_cache, 0);
|
||||
assert.equal(candidateState(databasePath, key), "deleting");
|
||||
assert.equal(query(databasePath, `
|
||||
SELECT COUNT(*) AS count FROM better_auth_user WHERE id = '${USER_ID}'
|
||||
`)[0]?.count, 0);
|
||||
|
||||
bucket.failDeletes = false;
|
||||
kv.failDeletes = false;
|
||||
assert.equal(await collectSyncR2Garbage(env, NOW + 61, { ownerHash: OWNER_HASH }), 1);
|
||||
assert.equal(await purgeLegacySessionCache(env), 1);
|
||||
assert.equal(candidateState(databasePath, key), "deleted");
|
||||
assert.equal(bucket.size, 0);
|
||||
assert.equal(kv.values.size, 0);
|
||||
});
|
||||
});
|
||||
|
||||
it("rolls back account deletion when its authenticated authority changes before the batch", async () => {
|
||||
for (const beforeBatchSql of [
|
||||
"DELETE FROM better_auth_session WHERE id = 'session-01';",
|
||||
`UPDATE user_device_keys SET signing_public_key = '${"9".repeat(64)}'
|
||||
WHERE user_id = '${USER_ID}' AND device_id = '${DEVICE_ID}';`,
|
||||
]) {
|
||||
await withDatabase(async (databasePath, bucket, _kv, env) => {
|
||||
const [key] = seedReadyCandidates(databasePath, bucket, 1);
|
||||
assert.ok(key !== undefined);
|
||||
const request = await accountDeleteRequest("account-delete-authority-race", NOW);
|
||||
const racedEnv = {
|
||||
...env,
|
||||
ELY_DB: new SqliteD1Database(databasePath, beforeBatchSql),
|
||||
} as Env;
|
||||
|
||||
await assert.rejects(
|
||||
() => accountDeletionDocument(request, racedEnv, authContext(), NOW),
|
||||
/device_action_gate_failed/,
|
||||
);
|
||||
|
||||
assert.deepEqual(query(databasePath, `SELECT
|
||||
(SELECT COUNT(*) FROM better_auth_user WHERE id = '${USER_ID}') AS users,
|
||||
(SELECT COUNT(*) FROM user_devices WHERE user_id = '${USER_ID}') AS devices,
|
||||
(SELECT COUNT(*) FROM user_device_keys WHERE user_id = '${USER_ID}') AS device_keys,
|
||||
(SELECT COUNT(*) FROM sync_vault_accounts WHERE user_id = '${USER_ID}') AS vaults,
|
||||
(SELECT COUNT(*) FROM audit_events WHERE event_type = 'account.delete') AS audits
|
||||
`), [{ users: 1, devices: 1, device_keys: 1, vaults: 1, audits: 0 }]);
|
||||
assert.equal(candidateState(databasePath, key), "ready");
|
||||
assert.equal(bucket.size, 1);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("continues R2 inventory and GC when legacy KV purge fails", async () => {
|
||||
await withDatabase(async (databasePath, bucket, kv, env) => {
|
||||
const hash = "f".repeat(64);
|
||||
const key = `sync-payloads/us-east/${OWNER_HASH}/bookmarks/object-01/${hash}.bin`;
|
||||
bucket.values.set(key, new Uint8Array([1]).buffer);
|
||||
kv.failLists = true;
|
||||
|
||||
await assert.rejects(() => maintainSyncR2Storage(env, NOW), AggregateError);
|
||||
|
||||
assert.equal(bucket.size, 0);
|
||||
assert.equal(candidateState(databasePath, key), "deleted");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function withDatabase(
|
||||
run: (databasePath: string, bucket: TestBucket, kv: TestKv, env: Env) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "ely-account-reset-gc-"));
|
||||
try {
|
||||
const databasePath = join(tempDir, "ely.db");
|
||||
for (const fileName of readdirSync(MIGRATIONS_DIR).filter((name) => name.endsWith(".sql")).sort()) {
|
||||
execute(databasePath, readFileSync(join(MIGRATIONS_DIR, fileName), "utf8"));
|
||||
}
|
||||
seedAuthority(databasePath);
|
||||
const bucket = new TestBucket();
|
||||
const kv = new TestKv();
|
||||
const env = {
|
||||
ELY_DB: new SqliteD1Database(databasePath),
|
||||
ELY_STORAGE: bucket,
|
||||
ELY_KV: kv,
|
||||
ELY_ENVIRONMENT: "local",
|
||||
} as unknown as Env;
|
||||
await run(databasePath, bucket, kv, env);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function seedAuthority(databasePath: string): void {
|
||||
execute(databasePath, `
|
||||
INSERT INTO better_auth_user (
|
||||
id, name, email, emailVerified, createdAt, updatedAt
|
||||
) VALUES (
|
||||
'${USER_ID}', 'User', 'user@example.com', 1,
|
||||
'2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z'
|
||||
);
|
||||
INSERT INTO user_devices (
|
||||
user_id, device_id, public_key, device_name, platform,
|
||||
approval_status, created_at, approved_at, last_active_at, revoked_at, idempotency_key
|
||||
) VALUES (
|
||||
'${USER_ID}', '${DEVICE_ID}', '${PUBLIC_KEY}', 'Mac', 'macOS',
|
||||
'approved', 1, 1, 1, NULL, 'device-register-0001'
|
||||
);
|
||||
INSERT INTO user_device_keys (
|
||||
user_id, device_id, signing_public_key, wrapping_public_key,
|
||||
key_protocol_version, created_at
|
||||
) VALUES (
|
||||
'${USER_ID}', '${DEVICE_ID}', '${PUBLIC_KEY}', '${"4".repeat(64)}', 2, 1
|
||||
);
|
||||
INSERT INTO better_auth_session (
|
||||
id, expiresAt, token, createdAt, updatedAt, userId
|
||||
) VALUES (
|
||||
'session-01', '2099-01-01T00:00:00Z', 'session-token-01',
|
||||
'2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', '${USER_ID}'
|
||||
);
|
||||
INSERT INTO better_auth_session_device_context (
|
||||
session_id, user_id, device_id, updated_at
|
||||
) VALUES ('session-01', '${USER_ID}', '${DEVICE_ID}', 1);
|
||||
INSERT INTO sync_vault_accounts (
|
||||
user_id, current_key_id, current_generation, created_at, updated_at
|
||||
) VALUES ('${USER_ID}', '${KEY_ID}', 1, 1, 1);
|
||||
`);
|
||||
}
|
||||
|
||||
function seedReadyCandidates(databasePath: string, bucket: TestBucket, count: number): string[] {
|
||||
const keys = Array.from({ length: count }, (_, index) => snapshotKey(index + 1));
|
||||
execute(databasePath, keys.map((key, index) => `
|
||||
INSERT INTO sync_r2_gc_candidates (
|
||||
r2_key, user_id, owner_hash, object_kind, state, write_token,
|
||||
lease_expires_at, gc_token, created_at, updated_at, referenced_at,
|
||||
ready_at, delete_started_at, deleted_at
|
||||
) VALUES (
|
||||
'${key}', '${USER_ID}', '${OWNER_HASH}', 'snapshot', 'ready', NULL,
|
||||
0, NULL, 1, 1, NULL, 1, NULL, NULL
|
||||
);
|
||||
`).join("\n"));
|
||||
for (const [index, key] of keys.entries()) {
|
||||
bucket.values.set(key, new Uint8Array([index % 256]).buffer);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
function seedCompletedRotation(databasePath: string, r2Key: string): void {
|
||||
execute(databasePath, `
|
||||
INSERT INTO user_devices (
|
||||
user_id, device_id, public_key, device_name, platform,
|
||||
approval_status, created_at, approved_at, last_active_at, revoked_at, idempotency_key
|
||||
) VALUES (
|
||||
'${USER_ID}', 'device-02', '${"5".repeat(64)}', 'Old Mac', 'macOS',
|
||||
'revoked', 1, 1, 1, 2, 'device-register-0002'
|
||||
);
|
||||
INSERT INTO sync_vault_rotations (
|
||||
user_id, idempotency_key, audit_event_id, target_device_id, approver_device_id,
|
||||
previous_key_id, previous_generation, new_key_id, new_generation, request_hash,
|
||||
envelope_count, r2_object_count, created_at, completed_at
|
||||
) VALUES (
|
||||
'${USER_ID}', 'rotation-reset-0001', 'rotation-reset-audit', 'device-02', '${DEVICE_ID}',
|
||||
'${KEY_ID}', 1, '${"6".repeat(64)}', 2, '${"7".repeat(64)}', 1, 1, 1, 2
|
||||
);
|
||||
INSERT INTO sync_vault_rotation_r2_objects (
|
||||
user_id, rotation_idempotency_key, r2_key
|
||||
) VALUES ('${USER_ID}', 'rotation-reset-0001', '${r2Key}');
|
||||
`);
|
||||
}
|
||||
|
||||
function resetRequest(idempotencyKey: string, proofCreatedAt: number): Promise<Request> {
|
||||
return actionRequest(
|
||||
"sync.reset",
|
||||
"delete-cloud-sync-data",
|
||||
idempotencyKey,
|
||||
proofCreatedAt,
|
||||
"/api/sync/reset",
|
||||
);
|
||||
}
|
||||
|
||||
function accountDeleteRequest(idempotencyKey: string, proofCreatedAt: number): Promise<Request> {
|
||||
return actionRequest(
|
||||
"account.delete",
|
||||
"delete-elydora-account",
|
||||
idempotencyKey,
|
||||
proofCreatedAt,
|
||||
"/api/account/delete",
|
||||
);
|
||||
}
|
||||
|
||||
async function actionRequest(
|
||||
action: SensitiveAction,
|
||||
confirmation: string,
|
||||
idempotencyKey: string,
|
||||
proofCreatedAt: number,
|
||||
path: string,
|
||||
): Promise<Request> {
|
||||
const actionProof = await signDeviceMessage(recentDeviceActionProofBytes({
|
||||
action,
|
||||
userId: USER_ID,
|
||||
sessionId: authContext().sessionId,
|
||||
deviceId: DEVICE_ID,
|
||||
confirmation,
|
||||
idempotencyKey,
|
||||
proofCreatedAt,
|
||||
}));
|
||||
return new Request(`https://elydora.test${path}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
version: 2,
|
||||
confirmation,
|
||||
idempotency_key: idempotencyKey,
|
||||
proof_created_at: proofCreatedAt,
|
||||
action_proof: actionProof,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function authContext() {
|
||||
return {
|
||||
userId: USER_ID,
|
||||
deviceId: DEVICE_ID,
|
||||
sessionId: "session-01",
|
||||
tokenHash: TOKEN_HASH,
|
||||
expiresAt: "2099-01-01T00:00:00Z",
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
} as const;
|
||||
}
|
||||
|
||||
function snapshotKey(index: number): string {
|
||||
const hash = index.toString(16).padStart(64, "0");
|
||||
return `sync-snapshots/us-east/${OWNER_HASH}/snapshot-${index}/${hash}.bin`;
|
||||
}
|
||||
|
||||
function deletedCandidateCount(databasePath: string): unknown {
|
||||
return query(databasePath, `
|
||||
SELECT COUNT(*) AS count FROM sync_r2_gc_candidates WHERE state = 'deleted'
|
||||
`)[0]?.count;
|
||||
}
|
||||
|
||||
function candidateState(databasePath: string, key: string): unknown {
|
||||
return query(databasePath, `
|
||||
SELECT state FROM sync_r2_gc_candidates WHERE r2_key = '${key}'
|
||||
`)[0]?.state;
|
||||
}
|
||||
|
||||
class TestBucket {
|
||||
readonly deletes: string[] = [];
|
||||
readonly values = new Map<string, ArrayBuffer>();
|
||||
failDeletes = false;
|
||||
|
||||
get(key: string): Promise<ElyR2Object | null> {
|
||||
const value = this.values.get(key);
|
||||
return Promise.resolve(value === undefined ? null : object(value));
|
||||
}
|
||||
|
||||
put(key: string, value: ArrayBuffer, _options?: ElyR2PutOptions): Promise<ElyR2Object> {
|
||||
this.values.set(key, value);
|
||||
return Promise.resolve(object(value));
|
||||
}
|
||||
|
||||
delete(key: string): Promise<void> {
|
||||
if (this.failDeletes) return Promise.reject(new Error("r2_delete_failed"));
|
||||
this.deletes.push(key);
|
||||
this.values.delete(key);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
list(options: { prefix: string; cursor?: string; limit: number }) {
|
||||
const objects = [...this.values.keys()]
|
||||
.filter((key) => key.startsWith(options.prefix))
|
||||
.slice(0, options.limit)
|
||||
.map((key) => ({ key }));
|
||||
return Promise.resolve({ objects, truncated: false as const });
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.values.size;
|
||||
}
|
||||
}
|
||||
|
||||
class TestKv {
|
||||
readonly values = new Map<string, string>();
|
||||
failDeletes = false;
|
||||
failLists = false;
|
||||
|
||||
get(key: string): Promise<string | null> {
|
||||
return Promise.resolve(this.values.get(key) ?? null);
|
||||
}
|
||||
|
||||
put(key: string, value: string): Promise<void> {
|
||||
this.values.set(key, value);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
delete(key: string): Promise<void> {
|
||||
if (this.failDeletes) return Promise.reject(new Error("kv_delete_failed"));
|
||||
this.values.delete(key);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
list(options: { prefix: string; cursor?: string; limit: number }) {
|
||||
if (this.failLists) return Promise.reject(new Error("kv_list_failed"));
|
||||
const keys = [...this.values.keys()]
|
||||
.filter((key) => key.startsWith(options.prefix))
|
||||
.slice(0, options.limit)
|
||||
.map((name) => ({ name }));
|
||||
return Promise.resolve({ keys, list_complete: true as const });
|
||||
}
|
||||
}
|
||||
|
||||
function object(value: ArrayBuffer): ElyR2Object {
|
||||
return { arrayBuffer: () => Promise.resolve(value) };
|
||||
}
|
||||
@@ -216,6 +216,7 @@ describe("api controls", () => {
|
||||
id: "session-01",
|
||||
userId: "user-01",
|
||||
expiresAt: "2099-01-01T00:00:00.000Z",
|
||||
createdAt: new Date().toISOString(),
|
||||
deviceId: "device-01",
|
||||
},
|
||||
],
|
||||
@@ -300,6 +301,7 @@ describe("api controls", () => {
|
||||
id: "session-01",
|
||||
userId: "user-01",
|
||||
expiresAt: "2026-01-01T00:00:00.000Z",
|
||||
createdAt: "2025-01-01T00:00:00.000Z",
|
||||
deviceId: "device-01",
|
||||
},
|
||||
],
|
||||
@@ -419,6 +421,7 @@ function testD1Database(options: TestD1DatabaseOptions = {}): Env["ELY_DB"] {
|
||||
id: "session-01",
|
||||
userId: "user-01",
|
||||
expiresAt: "2099-01-01T00:00:00.000Z",
|
||||
createdAt: new Date().toISOString(),
|
||||
deviceId: "device-01",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import { handleRequest } from "../src/index.js";
|
||||
import {
|
||||
type SensitiveAction,
|
||||
recentDeviceActionProofBytes,
|
||||
recentDeviceActionRequestHash,
|
||||
} from "../src/recent_device_action_proof.js";
|
||||
import {
|
||||
ACCESS_TOKEN,
|
||||
PUBLIC_KEY,
|
||||
signDeviceMessage,
|
||||
testD1Database,
|
||||
testEnv,
|
||||
} from "./devices_test_support.js";
|
||||
|
||||
const NOW = 1_780_001_000;
|
||||
|
||||
interface ActionCase {
|
||||
action: SensitiveAction;
|
||||
path: string;
|
||||
confirmation: string;
|
||||
idempotencyKey: string;
|
||||
forbiddenError: string;
|
||||
failedError: string;
|
||||
existingEvent: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const ACTIONS: ActionCase[] = [
|
||||
{
|
||||
action: "sync.reset",
|
||||
path: "/api/sync/reset",
|
||||
confirmation: "delete-cloud-sync-data",
|
||||
idempotencyKey: "sync-reset-security-0001",
|
||||
forbiddenError: "sync_reset_forbidden",
|
||||
failedError: "sync_reset_failed",
|
||||
existingEvent: {
|
||||
actor_device_id: "device-01",
|
||||
outcome: "success",
|
||||
created_at: NOW,
|
||||
},
|
||||
},
|
||||
{
|
||||
action: "account.delete",
|
||||
path: "/api/account/delete",
|
||||
confirmation: "delete-elydora-account",
|
||||
idempotencyKey: "account-delete-security-0001",
|
||||
forbiddenError: "account_deletion_forbidden",
|
||||
failedError: "account_deletion_failed",
|
||||
existingEvent: {
|
||||
actor_device_id: "device-01",
|
||||
outcome: "success",
|
||||
subject_id: "2fb6b7445391dae3bf4fb63927132e773d8d00e5963b5270dddecc84e99811fa",
|
||||
created_at: NOW,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe("destructive action proofs", () => {
|
||||
it("blocks a stolen bearer without the device private key", async () => {
|
||||
for (const action of ACTIONS) {
|
||||
const body = await actionBody(action, NOW);
|
||||
body.action_proof = "0".repeat(128);
|
||||
const response = await actionRequest(action, body, newActionRows(action));
|
||||
|
||||
assert.equal(response.status, 403);
|
||||
assert.deepEqual(await response.json(), { error: action.forbiddenError });
|
||||
}
|
||||
});
|
||||
|
||||
it("binds the action, session, and idempotency key", async () => {
|
||||
for (const action of ACTIONS) {
|
||||
for (const changed of ["action", "session", "idempotency"] as const) {
|
||||
const signedAction = changed === "action"
|
||||
? action.action === "sync.reset" ? "account.delete" : "sync.reset"
|
||||
: action.action;
|
||||
const body = await actionBody(
|
||||
action,
|
||||
NOW,
|
||||
signedAction,
|
||||
changed === "session" ? "session-02" : "session-01",
|
||||
);
|
||||
if (changed === "idempotency") {
|
||||
body.idempotency_key = `${action.idempotencyKey}-changed`;
|
||||
}
|
||||
const response = await actionRequest(action, body, newActionRows(action));
|
||||
|
||||
assert.equal(response.status, 403);
|
||||
assert.deepEqual(await response.json(), { error: action.forbiddenError });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("requires freshness for a new destructive action", async () => {
|
||||
for (const action of ACTIONS) {
|
||||
const response = await actionRequest(
|
||||
action,
|
||||
await actionBody(action, NOW - 301),
|
||||
newActionRows(action, true),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 403);
|
||||
assert.deepEqual(await response.json(), { error: action.forbiddenError });
|
||||
}
|
||||
});
|
||||
|
||||
it("verifies old proofs for exact idempotent replays without requiring freshness", async () => {
|
||||
for (const action of ACTIONS) {
|
||||
const body = await actionBody(action, NOW - 301);
|
||||
const event = {
|
||||
...action.existingEvent,
|
||||
...(action.action === "account.delete"
|
||||
? { metadata_hash: await requestHash(action, body) }
|
||||
: {}),
|
||||
};
|
||||
const response = await actionRequest(action, body, replayRows(action, event));
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
}
|
||||
});
|
||||
|
||||
it("maps a malformed stored signing key to a persistence failure", async () => {
|
||||
for (const action of ACTIONS) {
|
||||
const rows = newActionRows(action);
|
||||
rows[rows.length - 1] = { signing_public_key: "invalid" };
|
||||
const response = await actionRequest(action, await actionBody(action, NOW), rows);
|
||||
|
||||
assert.equal(response.status, 500);
|
||||
assert.deepEqual(await response.json(), { error: action.failedError });
|
||||
}
|
||||
});
|
||||
|
||||
it("opens a fresh primary session after a concurrent replay abort", async () => {
|
||||
const action = ACTIONS[0];
|
||||
assert.ok(action !== undefined);
|
||||
const proofCreatedAt = Math.floor(Date.now() / 1000);
|
||||
const d1 = testD1Database({
|
||||
firstRows: [
|
||||
{ device_id: "device-01" },
|
||||
{ signing_public_key: PUBLIC_KEY },
|
||||
null,
|
||||
{ objects: 0, changes: 0, snapshots: 0, tombstones: 0 },
|
||||
action.existingEvent,
|
||||
],
|
||||
allRows: [],
|
||||
batchError: new Error("UNIQUE constraint failed: audit_events.event_id"),
|
||||
});
|
||||
const response = await handleRequest(
|
||||
new Request(`https://elydora.test${action.path}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${ACCESS_TOKEN}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(await actionBody(action, proofCreatedAt)),
|
||||
}),
|
||||
testEnv({ d1 }),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(d1.sessionConstraints, ["first-primary", "first-primary"]);
|
||||
});
|
||||
|
||||
it("requires the exact timestamp and session for an account deletion replay", async () => {
|
||||
const action = ACTIONS[1];
|
||||
assert.ok(action !== undefined);
|
||||
const body = await actionBody(action, NOW - 301);
|
||||
const event = {
|
||||
...action.existingEvent,
|
||||
metadata_hash: await requestHash(action, body),
|
||||
};
|
||||
|
||||
const changedTimestamp = { ...body, proof_created_at: NOW - 300 };
|
||||
const timestampResponse = await actionRequest(action, changedTimestamp, [event]);
|
||||
assert.equal(timestampResponse.status, 400);
|
||||
|
||||
const d1 = testD1Database({
|
||||
firstRows: [event],
|
||||
sessionRow: {
|
||||
id: "session-02",
|
||||
userId: "user-01",
|
||||
expiresAt: "2099-01-01T00:00:00.000Z",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
deviceId: "device-01",
|
||||
},
|
||||
});
|
||||
const sessionResponse = await handleRequest(
|
||||
new Request(`https://elydora.test${action.path}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${ACCESS_TOKEN}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
testEnv({ d1 }),
|
||||
);
|
||||
assert.equal(sessionResponse.status, 400);
|
||||
});
|
||||
});
|
||||
|
||||
async function actionRequest(
|
||||
action: ActionCase,
|
||||
body: Record<string, unknown>,
|
||||
firstRows: unknown[],
|
||||
): Promise<Response> {
|
||||
return handleRequest(
|
||||
new Request(`https://elydora.test${action.path}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${ACCESS_TOKEN}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
testEnv({ d1: testD1Database({ firstRows }) }),
|
||||
);
|
||||
}
|
||||
|
||||
function newActionRows(action: ActionCase, includeEventMiss = false): unknown[] {
|
||||
if (action.action === "account.delete") {
|
||||
return [null, { signing_public_key: PUBLIC_KEY }];
|
||||
}
|
||||
return [
|
||||
{ device_id: "device-01" },
|
||||
{ signing_public_key: PUBLIC_KEY },
|
||||
...(includeEventMiss ? [null] : []),
|
||||
];
|
||||
}
|
||||
|
||||
function replayRows(action: ActionCase, event: Record<string, unknown>): unknown[] {
|
||||
return action.action === "account.delete"
|
||||
? [event]
|
||||
: [{ device_id: "device-01" }, { signing_public_key: PUBLIC_KEY }, event];
|
||||
}
|
||||
|
||||
async function actionBody(
|
||||
action: ActionCase,
|
||||
proofCreatedAt: number,
|
||||
signedAction = action.action,
|
||||
signedSessionId = "session-01",
|
||||
): Promise<Record<string, unknown>> {
|
||||
const body = {
|
||||
version: 2,
|
||||
confirmation: action.confirmation,
|
||||
idempotency_key: action.idempotencyKey,
|
||||
proof_created_at: proofCreatedAt,
|
||||
action_proof: "",
|
||||
};
|
||||
body.action_proof = await signDeviceMessage(recentDeviceActionProofBytes({
|
||||
action: signedAction,
|
||||
userId: "user-01",
|
||||
sessionId: signedSessionId,
|
||||
deviceId: "device-01",
|
||||
confirmation: body.confirmation,
|
||||
idempotencyKey: body.idempotency_key,
|
||||
proofCreatedAt,
|
||||
}));
|
||||
return body;
|
||||
}
|
||||
|
||||
function requestHash(
|
||||
action: ActionCase,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<string> {
|
||||
return recentDeviceActionRequestHash({
|
||||
action: action.action,
|
||||
userId: "user-01",
|
||||
sessionId: "session-01",
|
||||
deviceId: "device-01",
|
||||
confirmation: String(body.confirmation),
|
||||
idempotencyKey: String(body.idempotency_key),
|
||||
proofCreatedAt: Number(body.proof_created_at),
|
||||
actionProof: String(body.action_proof),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, it } from "node:test";
|
||||
import { DeviceConflictError } from "../src/device_schema.js";
|
||||
import { revokeDeviceDocument } from "../src/device_revocation.js";
|
||||
import {
|
||||
type ApprovedDeviceRevocationRequest,
|
||||
type PendingDeviceRevocationRequest,
|
||||
deviceRevocationProofBytes,
|
||||
pendingDeviceRevocationProofBytes,
|
||||
} from "../src/device_revocation_schema.js";
|
||||
import {
|
||||
PUBLIC_KEY,
|
||||
WRAPPING_PUBLIC_KEY,
|
||||
signDeviceMessage,
|
||||
testEnv,
|
||||
} from "./devices_test_support.js";
|
||||
import { SqliteD1Database, execute, query } from "./sqlite_d1_test_support.js";
|
||||
|
||||
const MIGRATIONS_DIR = join(process.cwd(), "migrations");
|
||||
const USER_ID = "user-01", APPROVER_ID = "device-01";
|
||||
const TARGET_ID = "device-02", REMAINING_ID = "device-03";
|
||||
const OLD_KEY = "a".repeat(64), NEW_KEY = "b".repeat(64);
|
||||
const HASH = "c".repeat(64), USER_HASH = "d".repeat(64);
|
||||
const IDEMPOTENCY_KEY = "rotation-key-0001", NOW = 200;
|
||||
const SUITE = "HPKE-BASE-X25519-HKDF-SHA256-CHACHA20POLY1305";
|
||||
const PAYLOAD_R2_KEY = `sync-payloads/us/${USER_HASH}/bookmarks/object-01/${HASH}.bin`;
|
||||
const SNAPSHOT_R2_KEY = `sync-snapshots/us/${USER_HASH}/snapshot-01/${HASH}.bin`;
|
||||
describe("device revocation real D1 flow", () => {
|
||||
it("executes the handler queries and trigger atomically", async () => {
|
||||
await withDatabase(async (databasePath) => {
|
||||
const database = new SqliteD1Database(databasePath);
|
||||
const document = await revokeDeviceDocument(
|
||||
await revocationRequest(),
|
||||
testEnv({ d1: database }),
|
||||
authContext(),
|
||||
NOW,
|
||||
);
|
||||
|
||||
assert.equal(document.mode, "approved_rotate");
|
||||
if (document.mode !== "approved_rotate") throw new Error("approved rotation expected");
|
||||
assert.equal(document.generation, 2);
|
||||
assert.equal(document.key_id, NEW_KEY);
|
||||
assert.equal(document.device.approval_status, "revoked");
|
||||
assert.equal(document.device.revoked_at, NOW);
|
||||
assert.deepEqual(database.batches, [4]);
|
||||
assert.deepEqual(query(databasePath, `
|
||||
SELECT current_key_id, current_generation
|
||||
FROM sync_vault_accounts WHERE user_id = '${USER_ID}'
|
||||
`), [{ current_key_id: NEW_KEY, current_generation: 2 }]);
|
||||
assert.deepEqual(query(databasePath, `
|
||||
SELECT recipient_device_id, approver_device_id, key_id, generation,
|
||||
envelope_version, suite, encapped_key, ciphertext, created_at
|
||||
FROM sync_vault_envelopes
|
||||
WHERE user_id = '${USER_ID}' AND key_id = '${NEW_KEY}'
|
||||
ORDER BY recipient_device_id
|
||||
`), [
|
||||
envelopeRow(APPROVER_ID, "A".repeat(43), "B".repeat(64)),
|
||||
envelopeRow(REMAINING_ID, `${"C".repeat(42)}E`, "D".repeat(64)),
|
||||
]);
|
||||
assert.deepEqual(query(databasePath, `
|
||||
SELECT target.approval_status, target.revoked_at,
|
||||
rotation.previous_generation, rotation.new_generation,
|
||||
rotation.envelope_count, rotation.r2_object_count,
|
||||
rotation.completed_at
|
||||
FROM user_devices AS target
|
||||
INNER JOIN sync_vault_rotations AS rotation
|
||||
ON rotation.user_id = target.user_id
|
||||
AND rotation.target_device_id = target.device_id
|
||||
WHERE target.user_id = '${USER_ID}' AND target.device_id = '${TARGET_ID}'
|
||||
`), [{
|
||||
approval_status: "revoked",
|
||||
revoked_at: NOW,
|
||||
previous_generation: 1,
|
||||
new_generation: 2,
|
||||
envelope_count: 2,
|
||||
r2_object_count: 2,
|
||||
completed_at: NOW,
|
||||
}]);
|
||||
assert.deepEqual(query(databasePath, `
|
||||
SELECT actor_device_id, event_type, subject_type, subject_id, outcome, created_at,
|
||||
event_id = 'device-revoke:' || (SELECT request_hash FROM sync_vault_rotations
|
||||
WHERE user_id = '${USER_ID}' AND idempotency_key = '${IDEMPOTENCY_KEY}')
|
||||
AS event_id_matches,
|
||||
metadata_hash = (SELECT request_hash FROM sync_vault_rotations
|
||||
WHERE user_id = '${USER_ID}' AND idempotency_key = '${IDEMPOTENCY_KEY}')
|
||||
AS request_hash_matches
|
||||
FROM audit_events WHERE user_id = '${USER_ID}'
|
||||
`), [{
|
||||
actor_device_id: APPROVER_ID,
|
||||
event_type: "device.revoke",
|
||||
subject_type: "device",
|
||||
subject_id: TARGET_ID,
|
||||
outcome: "success",
|
||||
created_at: NOW,
|
||||
event_id_matches: 1,
|
||||
request_hash_matches: 1,
|
||||
}]);
|
||||
assert.deepEqual(query(databasePath, `
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM sync_objects WHERE user_id = '${USER_ID}') AS objects,
|
||||
(SELECT COUNT(*) FROM sync_snapshots WHERE user_id = '${USER_ID}') AS snapshots,
|
||||
(SELECT COUNT(*) FROM sync_snapshot_encryption WHERE user_id = '${USER_ID}') AS encryption,
|
||||
(SELECT COUNT(*) FROM sync_vault_rotation_r2_objects
|
||||
WHERE user_id = '${USER_ID}') AS staged_r2,
|
||||
(SELECT COUNT(*) FROM better_auth_session
|
||||
WHERE id = 'target-session') AS target_sessions
|
||||
`), [{ objects: 1, snapshots: 1, encryption: 1, staged_r2: 2, target_sessions: 0 }]);
|
||||
assert.deepEqual(query(databasePath, `
|
||||
SELECT r2_key FROM sync_vault_rotation_r2_objects
|
||||
WHERE user_id = '${USER_ID}' AND rotation_idempotency_key = '${IDEMPOTENCY_KEY}'
|
||||
ORDER BY r2_key
|
||||
`), [{ r2_key: PAYLOAD_R2_KEY }, { r2_key: SNAPSHOT_R2_KEY }]);
|
||||
});
|
||||
});
|
||||
|
||||
it("rolls back the rotation when the recipient set changes before batch", async () => {
|
||||
await withDatabase(async (databasePath) => {
|
||||
const database = new SqliteD1Database(databasePath, raceDeviceSql());
|
||||
await assert.rejects(
|
||||
revokeDeviceDocument(
|
||||
await revocationRequest(),
|
||||
testEnv({ d1: database }),
|
||||
authContext(),
|
||||
NOW,
|
||||
),
|
||||
(error: unknown) =>
|
||||
error instanceof DeviceConflictError && error.message === "device_revocation_race",
|
||||
);
|
||||
assert.deepEqual(query(databasePath, `
|
||||
SELECT
|
||||
(SELECT current_generation FROM sync_vault_accounts
|
||||
WHERE user_id = '${USER_ID}') AS generation,
|
||||
(SELECT approval_status FROM user_devices
|
||||
WHERE user_id = '${USER_ID}' AND device_id = '${TARGET_ID}') AS target_status,
|
||||
(SELECT COUNT(*) FROM sync_vault_rotations WHERE user_id = '${USER_ID}') AS rotations,
|
||||
(SELECT COUNT(*) FROM sync_vault_envelopes
|
||||
WHERE user_id = '${USER_ID}' AND key_id = '${NEW_KEY}') AS envelopes,
|
||||
(SELECT COUNT(*) FROM audit_events WHERE user_id = '${USER_ID}') AS audits
|
||||
`), [{
|
||||
generation: 1,
|
||||
target_status: "approved",
|
||||
rotations: 0,
|
||||
envelopes: 0,
|
||||
audits: 0,
|
||||
}]);
|
||||
assert.deepEqual(query(databasePath, `
|
||||
SELECT COUNT(*) AS target_sessions FROM better_auth_session
|
||||
WHERE id = 'target-session'
|
||||
`), [{ target_sessions: 1 }]);
|
||||
});
|
||||
});
|
||||
|
||||
it("revokes a pending device without changing vault or sync state", async () => {
|
||||
await withDatabase(async (databasePath) => {
|
||||
execute(databasePath, `
|
||||
UPDATE user_devices SET approval_status = 'pending', approved_at = NULL
|
||||
WHERE user_id = '${USER_ID}' AND device_id = '${TARGET_ID}';
|
||||
`);
|
||||
const database = new SqliteD1Database(databasePath);
|
||||
const document = await revokeDeviceDocument(
|
||||
await pendingRevocationRequest(),
|
||||
testEnv({ d1: database }),
|
||||
authContext(),
|
||||
NOW,
|
||||
);
|
||||
assert.equal(document.mode, "pending_revoke");
|
||||
assert.equal(document.device.approval_status, "revoked");
|
||||
assert.deepEqual(database.batches, [2]);
|
||||
assert.deepEqual(query(databasePath, `
|
||||
SELECT
|
||||
(SELECT current_generation FROM sync_vault_accounts
|
||||
WHERE user_id = '${USER_ID}') AS generation,
|
||||
(SELECT COUNT(*) FROM sync_vault_rotations WHERE user_id = '${USER_ID}') AS rotations,
|
||||
(SELECT COUNT(*) FROM pending_device_revocations
|
||||
WHERE user_id = '${USER_ID}') AS pending_revocations,
|
||||
(SELECT COUNT(*) FROM sync_objects WHERE user_id = '${USER_ID}') AS objects,
|
||||
(SELECT COUNT(*) FROM sync_snapshots WHERE user_id = '${USER_ID}') AS snapshots,
|
||||
(SELECT COUNT(*) FROM better_auth_session
|
||||
WHERE id = 'target-session') AS target_sessions
|
||||
`), [{
|
||||
generation: 1,
|
||||
rotations: 0,
|
||||
pending_revocations: 1,
|
||||
objects: 1,
|
||||
snapshots: 1,
|
||||
target_sessions: 0,
|
||||
}]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function revocationRequest(): Promise<Request> {
|
||||
const envelopes: ApprovedDeviceRevocationRequest["envelopes"] = [
|
||||
rotationEnvelope(APPROVER_ID, "A".repeat(43), "B".repeat(64)),
|
||||
rotationEnvelope(REMAINING_ID, `${"C".repeat(42)}E`, "D".repeat(64)),
|
||||
];
|
||||
const unsigned: Omit<ApprovedDeviceRevocationRequest, "rotationProof"> = {
|
||||
mode: "approved_rotate",
|
||||
deviceId: TARGET_ID,
|
||||
previousKeyId: OLD_KEY,
|
||||
previousGeneration: 1,
|
||||
newKeyId: NEW_KEY,
|
||||
newGeneration: 2,
|
||||
envelopes,
|
||||
idempotencyKey: IDEMPOTENCY_KEY,
|
||||
};
|
||||
return new Request("https://elydora.test/api/devices/revoke", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
version: 2,
|
||||
mode: "approved_rotate",
|
||||
device_id: TARGET_ID,
|
||||
previous_key_id: OLD_KEY,
|
||||
previous_generation: 1,
|
||||
new_key_id: NEW_KEY,
|
||||
new_generation: 2,
|
||||
envelopes: envelopes.map((item) => ({
|
||||
recipient_device_id: item.recipientDeviceId,
|
||||
envelope: item.envelope,
|
||||
})),
|
||||
idempotency_key: IDEMPOTENCY_KEY,
|
||||
rotation_proof: await signDeviceMessage(
|
||||
deviceRevocationProofBytes(USER_ID, APPROVER_ID, unsigned),
|
||||
),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async function pendingRevocationRequest(): Promise<Request> {
|
||||
const unsigned: Omit<PendingDeviceRevocationRequest, "pendingRevocationProof"> = {
|
||||
mode: "pending_revoke",
|
||||
deviceId: TARGET_ID,
|
||||
idempotencyKey: IDEMPOTENCY_KEY,
|
||||
};
|
||||
return new Request("https://elydora.test/api/devices/revoke", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
version: 2,
|
||||
mode: "pending_revoke",
|
||||
device_id: TARGET_ID,
|
||||
idempotency_key: IDEMPOTENCY_KEY,
|
||||
pending_revocation_proof: await signDeviceMessage(
|
||||
pendingDeviceRevocationProofBytes(USER_ID, APPROVER_ID, unsigned),
|
||||
),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function rotationEnvelope(
|
||||
recipientDeviceId: string,
|
||||
encappedKey: string,
|
||||
ciphertext: string,
|
||||
): ApprovedDeviceRevocationRequest["envelopes"][number] {
|
||||
return {
|
||||
recipientDeviceId,
|
||||
envelope: { version: 1, suite: SUITE, encapped_key: encappedKey, ciphertext },
|
||||
};
|
||||
}
|
||||
|
||||
function envelopeRow(
|
||||
recipientDeviceId: string,
|
||||
encappedKey: string,
|
||||
ciphertext: string,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
recipient_device_id: recipientDeviceId,
|
||||
approver_device_id: APPROVER_ID,
|
||||
key_id: NEW_KEY,
|
||||
generation: 2,
|
||||
envelope_version: 1,
|
||||
suite: SUITE,
|
||||
encapped_key: encappedKey,
|
||||
ciphertext,
|
||||
created_at: NOW,
|
||||
};
|
||||
}
|
||||
|
||||
function authContext() {
|
||||
return {
|
||||
userId: USER_ID,
|
||||
sessionId: "session-01",
|
||||
tokenHash: "0".repeat(64),
|
||||
expiresAt: "2099-01-01T00:00:00.000Z",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
deviceId: APPROVER_ID,
|
||||
};
|
||||
}
|
||||
|
||||
function seedSql(): string {
|
||||
return `
|
||||
INSERT INTO better_auth_user
|
||||
(id, name, email, emailVerified, createdAt, updatedAt)
|
||||
VALUES ('${USER_ID}', 'User', 'user@example.com', 1, '2026-01-01', '2026-01-01');
|
||||
INSERT INTO user_devices
|
||||
(user_id, device_id, public_key, device_name, platform, approval_status,
|
||||
created_at, approved_at, last_active_at, revoked_at, idempotency_key)
|
||||
VALUES
|
||||
('${USER_ID}', '${APPROVER_ID}', '${PUBLIC_KEY}', 'Approver', 'macOS', 'approved',
|
||||
10, 11, 12, NULL, 'device-register-0001'),
|
||||
('${USER_ID}', '${TARGET_ID}', '${PUBLIC_KEY}', 'Target', 'macOS', 'approved',
|
||||
10, 11, 12, NULL, 'device-register-0002'),
|
||||
('${USER_ID}', '${REMAINING_ID}', '${PUBLIC_KEY}', 'Remaining', 'macOS', 'approved',
|
||||
10, 11, 12, NULL, 'device-register-0003');
|
||||
INSERT INTO user_device_keys
|
||||
(user_id, device_id, signing_public_key, wrapping_public_key,
|
||||
key_protocol_version, created_at)
|
||||
VALUES
|
||||
('${USER_ID}', '${APPROVER_ID}', '${PUBLIC_KEY}', '${WRAPPING_PUBLIC_KEY}', 2, 10),
|
||||
('${USER_ID}', '${TARGET_ID}', '${PUBLIC_KEY}', '${WRAPPING_PUBLIC_KEY}', 2, 10),
|
||||
('${USER_ID}', '${REMAINING_ID}', '${PUBLIC_KEY}', '${WRAPPING_PUBLIC_KEY}', 2, 10);
|
||||
INSERT INTO better_auth_session
|
||||
(id, expiresAt, token, createdAt, updatedAt, userId)
|
||||
VALUES
|
||||
('target-session', '2099-01-01', 'target-session-token',
|
||||
'2026-01-01', '2026-01-01', '${USER_ID}');
|
||||
INSERT INTO better_auth_session_device_context
|
||||
(session_id, user_id, device_id, updated_at)
|
||||
VALUES ('target-session', '${USER_ID}', '${TARGET_ID}', 15);
|
||||
INSERT INTO sync_vault_accounts
|
||||
(user_id, current_key_id, current_generation, created_at, updated_at)
|
||||
VALUES ('${USER_ID}', '${OLD_KEY}', 1, 20, 20);
|
||||
INSERT INTO sync_r2_gc_candidates (
|
||||
r2_key, user_id, owner_hash, object_kind, state, write_token,
|
||||
lease_expires_at, gc_token, created_at, updated_at, referenced_at,
|
||||
ready_at, delete_started_at, deleted_at
|
||||
) VALUES (
|
||||
'${PAYLOAD_R2_KEY}', '${USER_ID}', '${USER_HASH}', 'payload', 'pending',
|
||||
'${"1".repeat(64)}', 1000, NULL, 30, 30, NULL, NULL, NULL, NULL
|
||||
);
|
||||
INSERT INTO sync_objects
|
||||
(user_id, object_id, object_type, payload_inline, payload_r2_key, payload_hash,
|
||||
schema_rev, logical_clock, device_id, created_at, updated_at, deleted_at)
|
||||
VALUES ('${USER_ID}', 'object-01', 'bookmarks', NULL, '${PAYLOAD_R2_KEY}', '${HASH}',
|
||||
1, 1, '${APPROVER_ID}', 30, 30, NULL);
|
||||
UPDATE sync_r2_gc_candidates
|
||||
SET state = 'referenced', referenced_at = 30, updated_at = 30
|
||||
WHERE r2_key = '${PAYLOAD_R2_KEY}';
|
||||
INSERT INTO sync_r2_gc_candidates (
|
||||
r2_key, user_id, owner_hash, object_kind, state, write_token,
|
||||
lease_expires_at, gc_token, created_at, updated_at, referenced_at,
|
||||
ready_at, delete_started_at, deleted_at
|
||||
) VALUES (
|
||||
'${SNAPSHOT_R2_KEY}', '${USER_ID}', '${USER_HASH}', 'snapshot', 'pending',
|
||||
'${"2".repeat(64)}', 1000, NULL, 40, 40, NULL, NULL, NULL, NULL
|
||||
);
|
||||
INSERT INTO sync_snapshots
|
||||
(user_id, snapshot_id, r2_key, payload_hash, schema_rev, logical_clock,
|
||||
device_id, size_bytes, created_at)
|
||||
VALUES ('${USER_ID}', 'snapshot-01', '${SNAPSHOT_R2_KEY}', '${HASH}', 1, 1,
|
||||
'${APPROVER_ID}', 64, 40);
|
||||
INSERT INTO sync_snapshot_encryption
|
||||
(user_id, snapshot_id, encryption_version, vault_generation, key_id, content_hash)
|
||||
VALUES ('${USER_ID}', 'snapshot-01', 1, 1, '${OLD_KEY}', '${HASH}');
|
||||
`;
|
||||
}
|
||||
|
||||
function raceDeviceSql(): string {
|
||||
return `
|
||||
INSERT INTO user_devices
|
||||
(user_id, device_id, public_key, device_name, platform, approval_status,
|
||||
created_at, approved_at, last_active_at, revoked_at, idempotency_key)
|
||||
VALUES ('${USER_ID}', 'device-04', '${PUBLIC_KEY}', 'Race', 'macOS', 'approved',
|
||||
100, 101, 102, NULL, 'device-register-0004');
|
||||
INSERT INTO user_device_keys
|
||||
(user_id, device_id, signing_public_key, wrapping_public_key,
|
||||
key_protocol_version, created_at)
|
||||
VALUES ('${USER_ID}', 'device-04', '${PUBLIC_KEY}', '${WRAPPING_PUBLIC_KEY}', 2, 100);
|
||||
`;
|
||||
}
|
||||
|
||||
async function withDatabase(run: (databasePath: string) => Promise<void>): Promise<void> {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "ely-revoke-handler-"));
|
||||
try {
|
||||
const databasePath = join(tempDir, "ely.db");
|
||||
const migrations = readdirSync(MIGRATIONS_DIR)
|
||||
.filter((name) => name.endsWith(".sql"))
|
||||
.sort()
|
||||
.map((name) => readFileSync(join(MIGRATIONS_DIR, name), "utf8"))
|
||||
.join("\n");
|
||||
execute(databasePath, migrations);
|
||||
execute(databasePath, seedSql());
|
||||
await run(databasePath);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
compareDeviceIds,
|
||||
deviceRevocationProofBytes,
|
||||
deviceRevocationRequest,
|
||||
pendingDeviceRevocationProofBytes,
|
||||
} from "../src/device_revocation_schema.js";
|
||||
import { DeviceSchemaError } from "../src/device_schema.js";
|
||||
import { handleRequest } from "../src/index.js";
|
||||
import {
|
||||
ACCESS_TOKEN,
|
||||
PUBLIC_KEY,
|
||||
signDeviceMessage,
|
||||
testD1Database,
|
||||
testEnv,
|
||||
} from "./devices_test_support.js";
|
||||
|
||||
const USER_ID = "user-01", APPROVER_ID = "device-01", TARGET_ID = "device-02";
|
||||
const OLD_KEY = "a".repeat(64), NEW_KEY = "b".repeat(64);
|
||||
const IDEMPOTENCY_KEY = "device-revocation-0001";
|
||||
const SUITE = "HPKE-BASE-X25519-HKDF-SHA256-CHACHA20POLY1305";
|
||||
|
||||
describe("device revocation proof schema", () => {
|
||||
it("rejects malformed envelope recipients and rotation metadata", async () => {
|
||||
for (const body of [
|
||||
approvedBody({ envelopes: [envelope(TARGET_ID)] }),
|
||||
approvedBody({ envelopes: [envelope(APPROVER_ID), envelope(APPROVER_ID)] }),
|
||||
approvedBody({ envelopes: [envelope(APPROVER_ID, { encapped_key: `${"A".repeat(42)}B` })] }),
|
||||
approvedBody({ new_generation: 3 }),
|
||||
approvedBody({ new_key_id: OLD_KEY }),
|
||||
{ ...approvedBody(), mode: undefined },
|
||||
{ ...pendingBody(), new_key_id: NEW_KEY },
|
||||
]) {
|
||||
await assert.rejects(
|
||||
deviceRevocationRequest(request({ ...body, rotation_proof: "0".repeat(128) })),
|
||||
DeviceSchemaError,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses one ASCII code-unit order for proof and exact recipient checks", async () => {
|
||||
const ids = ["a_1", "a:1", "a.1", "a-1", "A_1", "A-1"];
|
||||
const expected = ["A-1", "A_1", "a-1", "a.1", "a:1", "a_1"];
|
||||
assert.deepEqual([...ids].sort(compareDeviceIds), expected);
|
||||
const parsed = await deviceRevocationRequest(request(
|
||||
await signedBody(approvedBody({ envelopes: ids.map((id) => envelope(id)) })),
|
||||
));
|
||||
assert.equal(parsed.mode, "approved_rotate");
|
||||
if (parsed.mode !== "approved_rotate") throw new Error("approved rotation expected");
|
||||
assert.deepEqual(parsed.envelopes.map((item) => item.recipientDeviceId), expected);
|
||||
});
|
||||
|
||||
it("uses the frozen v2 approved rotation proof wire", async () => {
|
||||
const parsed = await deviceRevocationRequest(request(
|
||||
await signedBody(approvedBody({ envelopes: [envelope(APPROVER_ID)] })),
|
||||
));
|
||||
if (parsed.mode !== "approved_rotate") throw new Error("approved rotation expected");
|
||||
const { rotationProof: _, ...unsigned } = parsed;
|
||||
assert.equal(new TextDecoder().decode(
|
||||
deviceRevocationProofBytes(USER_ID, APPROVER_ID, unsigned),
|
||||
), [
|
||||
"28:elydora-device-revocation-v2",
|
||||
"7:user-01",
|
||||
"9:device-01",
|
||||
"9:device-02",
|
||||
`64:${OLD_KEY}`,
|
||||
"1:1",
|
||||
`64:${NEW_KEY}`,
|
||||
"1:2",
|
||||
"22:device-revocation-0001",
|
||||
"1:1",
|
||||
"9:device-01",
|
||||
"1:1",
|
||||
`45:${SUITE}`,
|
||||
`43:${"A".repeat(43)}`,
|
||||
`64:${"B".repeat(64)}`,
|
||||
].join(""));
|
||||
});
|
||||
|
||||
it("uses the frozen v2 pending revocation proof wire", async () => {
|
||||
const parsed = await deviceRevocationRequest(request(await signedBody(pendingBody())));
|
||||
if (parsed.mode !== "pending_revoke") throw new Error("pending revocation expected");
|
||||
const { pendingRevocationProof: _, ...unsigned } = parsed;
|
||||
assert.equal(new TextDecoder().decode(
|
||||
pendingDeviceRevocationProofBytes(USER_ID, APPROVER_ID, unsigned),
|
||||
), [
|
||||
"36:elydora-pending-device-revocation-v2",
|
||||
"7:user-01",
|
||||
"9:device-01",
|
||||
"9:device-02",
|
||||
"22:device-revocation-0001",
|
||||
].join(""));
|
||||
});
|
||||
|
||||
it("rejects invalid and tampered proofs before revocation state reads", async () => {
|
||||
const cases = [
|
||||
approvedBody({ rotation_proof: "0".repeat(128) }),
|
||||
{ ...await signedBody(approvedBody()), new_key_id: "c".repeat(64) },
|
||||
pendingBody({ pending_revocation_proof: "0".repeat(128) }),
|
||||
];
|
||||
for (const body of cases) {
|
||||
const d1 = testD1Database({ firstRows: [approverRow()] });
|
||||
const response = await handleRequest(request(body, true), testEnv({ d1 }));
|
||||
assert.equal(response.status, 403);
|
||||
assert.equal(d1.queries.length, 1);
|
||||
assert.deepEqual(d1.batches, []);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function approvedBody(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
version: 2,
|
||||
mode: "approved_rotate",
|
||||
device_id: TARGET_ID,
|
||||
previous_key_id: OLD_KEY,
|
||||
previous_generation: 1,
|
||||
new_key_id: NEW_KEY,
|
||||
new_generation: 2,
|
||||
envelopes: [envelope("device-03"), envelope(APPROVER_ID)],
|
||||
idempotency_key: IDEMPOTENCY_KEY,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function pendingBody(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
version: 2,
|
||||
mode: "pending_revoke",
|
||||
device_id: TARGET_ID,
|
||||
idempotency_key: IDEMPOTENCY_KEY,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function envelope(
|
||||
recipient: string,
|
||||
overrides: Record<string, unknown> = {},
|
||||
): Record<string, unknown> {
|
||||
const other = recipient === "device-03";
|
||||
return {
|
||||
recipient_device_id: recipient,
|
||||
envelope: {
|
||||
version: 1,
|
||||
suite: SUITE,
|
||||
encapped_key: other ? `${"C".repeat(42)}E` : "A".repeat(43),
|
||||
ciphertext: other ? "D".repeat(64) : "B".repeat(64),
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function signedBody(body: Record<string, unknown>): Promise<Record<string, unknown>> {
|
||||
if (body.mode === "pending_revoke") {
|
||||
if (body.pending_revocation_proof !== undefined) return body;
|
||||
const draft = { ...body, pending_revocation_proof: "0".repeat(128) };
|
||||
const parsed = await deviceRevocationRequest(request(draft));
|
||||
if (parsed.mode !== "pending_revoke") throw new Error("pending revocation expected");
|
||||
const { pendingRevocationProof: _, ...unsigned } = parsed;
|
||||
return {
|
||||
...body,
|
||||
pending_revocation_proof: await signDeviceMessage(
|
||||
pendingDeviceRevocationProofBytes(USER_ID, APPROVER_ID, unsigned),
|
||||
),
|
||||
};
|
||||
}
|
||||
if (body.rotation_proof !== undefined) return body;
|
||||
const draft = { ...body, rotation_proof: "0".repeat(128) };
|
||||
const parsed = await deviceRevocationRequest(request(draft));
|
||||
if (parsed.mode !== "approved_rotate") throw new Error("approved rotation expected");
|
||||
const { rotationProof: _, ...unsigned } = parsed;
|
||||
return {
|
||||
...body,
|
||||
rotation_proof: await signDeviceMessage(
|
||||
deviceRevocationProofBytes(USER_ID, APPROVER_ID, unsigned),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function request(body: Record<string, unknown>, authenticated = false): Request {
|
||||
return new Request("https://elydora.test/api/devices/revoke", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...(authenticated ? { authorization: `Bearer ${ACCESS_TOKEN}` } : {}),
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function approverRow(): Record<string, unknown> {
|
||||
return { device_id: APPROVER_ID, signing_public_key: PUBLIC_KEY };
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
const MIGRATIONS_DIR = join(process.cwd(), "migrations");
|
||||
const OLD_KEY = "a".repeat(64);
|
||||
const NEW_KEY = "b".repeat(64);
|
||||
const HASH = "c".repeat(64);
|
||||
const USER_HASH = "d".repeat(64);
|
||||
const WRAPPING_KEY = "e".repeat(64);
|
||||
const SIGNING_KEY = "f".repeat(64);
|
||||
const PAYLOAD_R2_KEY = `sync-payloads/us/${USER_HASH}/bookmarks/object-01/${HASH}.bin`;
|
||||
const SNAPSHOT_R2_KEY = `sync-snapshots/us/${USER_HASH}/snapshot-01/${HASH}.bin`;
|
||||
|
||||
describe("sync vault rotation migration", () => {
|
||||
it("finalizes atomically and retains the old head until replacement", () => {
|
||||
withDatabase((databasePath) => {
|
||||
execute(databasePath, seedSql());
|
||||
execute(databasePath, validRotationSql());
|
||||
|
||||
assert.deepEqual(query(databasePath, `
|
||||
SELECT current_key_id, current_generation FROM sync_vault_accounts WHERE user_id = 'user-01'
|
||||
`), [{ current_key_id: NEW_KEY, current_generation: 2 }]);
|
||||
assert.deepEqual(query(databasePath, `
|
||||
SELECT approval_status, revoked_at FROM user_devices
|
||||
WHERE user_id = 'user-01' AND device_id = 'device-02'
|
||||
`), [{ approval_status: "revoked", revoked_at: 200 }]);
|
||||
assert.deepEqual(query(databasePath, `
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM sync_vault_envelopes
|
||||
WHERE user_id = 'user-01' AND key_id = '${NEW_KEY}' AND generation = 2) AS envelopes,
|
||||
(SELECT COUNT(*) FROM audit_events
|
||||
WHERE event_id = 'device-revoke:user-01:rotation-key-0001') AS audits,
|
||||
(SELECT COUNT(*) FROM sync_vault_rotation_r2_objects
|
||||
WHERE user_id = 'user-01' AND rotation_idempotency_key = 'rotation-key-0001') AS r2_manifest,
|
||||
(SELECT COUNT(*) FROM sync_objects WHERE user_id = 'user-01') AS objects,
|
||||
(SELECT COUNT(*) FROM sync_snapshots WHERE user_id = 'user-01') AS snapshots,
|
||||
(SELECT COUNT(*) FROM sync_snapshot_encryption WHERE user_id = 'user-01') AS encryption
|
||||
`), [{ envelopes: 2, audits: 1, r2_manifest: 2, objects: 1, snapshots: 1, encryption: 1 }]);
|
||||
});
|
||||
});
|
||||
|
||||
it("rolls back every mutation when the staged recipient set is incomplete", () => {
|
||||
withDatabase((databasePath) => {
|
||||
execute(databasePath, seedSql());
|
||||
assert.throws(
|
||||
() => execute(databasePath, invalidRotationSql()),
|
||||
/sync_vault_rotation_guard_failed/,
|
||||
);
|
||||
assert.deepEqual(query(databasePath, `
|
||||
SELECT
|
||||
(SELECT current_generation FROM sync_vault_accounts WHERE user_id = 'user-01') AS generation,
|
||||
(SELECT approval_status FROM user_devices
|
||||
WHERE user_id = 'user-01' AND device_id = 'device-02') AS target_status,
|
||||
(SELECT COUNT(*) FROM audit_events WHERE user_id = 'user-01') AS audits,
|
||||
(SELECT COUNT(*) FROM sync_vault_rotations WHERE user_id = 'user-01') AS rotations
|
||||
`), [{ generation: 1, target_status: "approved", audits: 0, rotations: 0 }]);
|
||||
});
|
||||
});
|
||||
|
||||
it("quarantines approved protocol-v1 devices when 0011 is applied", () => {
|
||||
withDatabase((databasePath) => {
|
||||
execute(databasePath, `
|
||||
INSERT INTO better_auth_user
|
||||
(id, name, email, emailVerified, createdAt, updatedAt)
|
||||
VALUES ('legacy-user', 'Legacy', 'legacy@example.com', 1, '2026-01-01', '2026-01-01');
|
||||
INSERT INTO user_devices
|
||||
(user_id, device_id, public_key, device_name, platform, approval_status,
|
||||
created_at, approved_at, last_active_at, revoked_at, idempotency_key)
|
||||
VALUES
|
||||
('legacy-user', 'legacy-device', '${SIGNING_KEY}', 'Legacy', 'macOS', 'approved',
|
||||
10, 11, 12, NULL, 'legacy-register-0001');
|
||||
INSERT INTO user_device_keys
|
||||
(user_id, device_id, signing_public_key, wrapping_public_key,
|
||||
key_protocol_version, created_at)
|
||||
VALUES ('legacy-user', 'legacy-device', '${SIGNING_KEY}', NULL, 1, 10);
|
||||
`);
|
||||
execute(databasePath, readFileSync(join(MIGRATIONS_DIR, "0011_sync_vault_rotation.sql"), "utf8"));
|
||||
assert.deepEqual(query(databasePath, `
|
||||
SELECT approval_status, revoked_at IS NOT NULL AS has_revoked_at
|
||||
FROM user_devices WHERE user_id = 'legacy-user' AND device_id = 'legacy-device'
|
||||
`), [{ approval_status: "revoked", has_revoked_at: 1 }]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function seedSql(): string {
|
||||
return `
|
||||
INSERT INTO better_auth_user
|
||||
(id, name, email, emailVerified, createdAt, updatedAt)
|
||||
VALUES ('user-01', 'User', 'user@example.com', 1, '2026-01-01', '2026-01-01');
|
||||
INSERT INTO user_devices
|
||||
(user_id, device_id, public_key, device_name, platform, approval_status,
|
||||
created_at, approved_at, last_active_at, revoked_at, idempotency_key)
|
||||
VALUES
|
||||
('user-01', 'device-01', '${SIGNING_KEY}', 'Approver', 'macOS', 'approved',
|
||||
10, 11, 12, NULL, 'device-register-0001'),
|
||||
('user-01', 'device-02', '${SIGNING_KEY}', 'Target', 'macOS', 'approved',
|
||||
10, 11, 12, NULL, 'device-register-0002'),
|
||||
('user-01', 'device-03', '${SIGNING_KEY}', 'Remaining', 'macOS', 'approved',
|
||||
10, 11, 12, NULL, 'device-register-0003');
|
||||
INSERT INTO user_device_keys
|
||||
(user_id, device_id, signing_public_key, wrapping_public_key,
|
||||
key_protocol_version, created_at)
|
||||
VALUES
|
||||
('user-01', 'device-01', '${SIGNING_KEY}', '${WRAPPING_KEY}', 2, 10),
|
||||
('user-01', 'device-02', '${SIGNING_KEY}', '${WRAPPING_KEY}', 2, 10),
|
||||
('user-01', 'device-03', '${SIGNING_KEY}', '${WRAPPING_KEY}', 2, 10);
|
||||
INSERT INTO sync_vault_accounts
|
||||
(user_id, current_key_id, current_generation, created_at, updated_at)
|
||||
VALUES ('user-01', '${OLD_KEY}', 1, 20, 20);
|
||||
INSERT INTO sync_r2_gc_candidates (
|
||||
r2_key, user_id, owner_hash, object_kind, state, write_token,
|
||||
lease_expires_at, gc_token, created_at, updated_at, referenced_at,
|
||||
ready_at, delete_started_at, deleted_at
|
||||
) VALUES (
|
||||
'${PAYLOAD_R2_KEY}', 'user-01', '${USER_HASH}', 'payload', 'pending',
|
||||
'${"1".repeat(64)}', 1000, NULL, 30, 30, NULL, NULL, NULL, NULL
|
||||
);
|
||||
INSERT INTO sync_objects
|
||||
(user_id, object_id, object_type, payload_inline, payload_r2_key, payload_hash,
|
||||
schema_rev, logical_clock, device_id, created_at, updated_at, deleted_at)
|
||||
VALUES
|
||||
('user-01', 'object-01', 'bookmarks', NULL, '${PAYLOAD_R2_KEY}', '${HASH}',
|
||||
1, 1, 'device-01', 30, 30, NULL);
|
||||
UPDATE sync_r2_gc_candidates
|
||||
SET state = 'referenced', referenced_at = 30, updated_at = 30
|
||||
WHERE r2_key = '${PAYLOAD_R2_KEY}';
|
||||
INSERT INTO sync_r2_gc_candidates (
|
||||
r2_key, user_id, owner_hash, object_kind, state, write_token,
|
||||
lease_expires_at, gc_token, created_at, updated_at, referenced_at,
|
||||
ready_at, delete_started_at, deleted_at
|
||||
) VALUES (
|
||||
'${SNAPSHOT_R2_KEY}', 'user-01', '${USER_HASH}', 'snapshot', 'pending',
|
||||
'${"2".repeat(64)}', 1000, NULL, 40, 40, NULL, NULL, NULL, NULL
|
||||
);
|
||||
INSERT INTO sync_snapshots
|
||||
(user_id, snapshot_id, r2_key, payload_hash, schema_rev, logical_clock,
|
||||
device_id, size_bytes, created_at)
|
||||
VALUES
|
||||
('user-01', 'snapshot-01', '${SNAPSHOT_R2_KEY}', '${HASH}', 1, 1,
|
||||
'device-01', 64, 40);
|
||||
INSERT INTO sync_snapshot_encryption
|
||||
(user_id, snapshot_id, encryption_version, vault_generation, key_id, content_hash)
|
||||
VALUES ('user-01', 'snapshot-01', 1, 1, '${OLD_KEY}', '${HASH}');
|
||||
`;
|
||||
}
|
||||
|
||||
function validRotationSql(): string {
|
||||
return `
|
||||
BEGIN IMMEDIATE;
|
||||
${rotationHeaderSql(2, 2)}
|
||||
${rotationEnvelopeSql("device-01", "1".repeat(64), "A".repeat(43), "B".repeat(64))}
|
||||
${rotationEnvelopeSql("device-03", "2".repeat(64), `${"C".repeat(42)}E`, "D".repeat(64))}
|
||||
UPDATE sync_vault_rotations SET completed_at = 200
|
||||
WHERE user_id = 'user-01' AND idempotency_key = 'rotation-key-0001';
|
||||
COMMIT;
|
||||
`;
|
||||
}
|
||||
|
||||
function invalidRotationSql(): string {
|
||||
return `
|
||||
BEGIN IMMEDIATE;
|
||||
${rotationHeaderSql(2, 2)}
|
||||
${rotationEnvelopeSql("device-01", "1".repeat(64), "A".repeat(43), "B".repeat(64))}
|
||||
UPDATE sync_vault_rotations SET completed_at = 200
|
||||
WHERE user_id = 'user-01' AND idempotency_key = 'rotation-key-0001';
|
||||
COMMIT;
|
||||
`;
|
||||
}
|
||||
|
||||
function rotationHeaderSql(envelopeCount: number, r2Count: number): string {
|
||||
return `
|
||||
INSERT INTO sync_vault_rotations
|
||||
(user_id, idempotency_key, audit_event_id, target_device_id, approver_device_id,
|
||||
previous_key_id, previous_generation, new_key_id, new_generation, request_hash,
|
||||
envelope_count, r2_object_count, created_at, completed_at)
|
||||
VALUES
|
||||
('user-01', 'rotation-key-0001', 'device-revoke:user-01:rotation-key-0001',
|
||||
'device-02', 'device-01', '${OLD_KEY}', 1, '${NEW_KEY}', 2, '${HASH}',
|
||||
${envelopeCount}, ${r2Count}, 100, NULL);
|
||||
`;
|
||||
}
|
||||
|
||||
function rotationEnvelopeSql(
|
||||
recipient: string,
|
||||
idempotencyKey: string,
|
||||
encappedKey: string,
|
||||
ciphertext: string,
|
||||
): string {
|
||||
return `
|
||||
INSERT INTO sync_vault_rotation_envelopes
|
||||
(user_id, rotation_idempotency_key, recipient_device_id, envelope_idempotency_key,
|
||||
envelope_version, suite, encapped_key, ciphertext)
|
||||
VALUES
|
||||
('user-01', 'rotation-key-0001', '${recipient}', '${idempotencyKey}', 1,
|
||||
'HPKE-BASE-X25519-HKDF-SHA256-CHACHA20POLY1305', '${encappedKey}', '${ciphertext}');
|
||||
`;
|
||||
}
|
||||
|
||||
function withDatabase(assertions: (databasePath: string) => void): void {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "ely-rotation-"));
|
||||
try {
|
||||
const databasePath = join(tempDir, "ely.db");
|
||||
const migrations = readdirSync(MIGRATIONS_DIR)
|
||||
.filter((name) => name.endsWith(".sql"))
|
||||
.sort()
|
||||
.map((name) => readFileSync(join(MIGRATIONS_DIR, name), "utf8"))
|
||||
.join("\n");
|
||||
execute(databasePath, migrations);
|
||||
assertions(databasePath);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function execute(databasePath: string, sql: string): void {
|
||||
execFileSync("sqlite3", [databasePath], {
|
||||
input: `.bail on\nPRAGMA foreign_keys = ON;\n${sql}`,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
}
|
||||
|
||||
function query(databasePath: string, sql: string): Record<string, unknown>[] {
|
||||
const output = execFileSync("sqlite3", ["-json", databasePath, sql], { encoding: "utf8" });
|
||||
return JSON.parse(output) as Record<string, unknown>[];
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import { handleRequest } from "../src/index.js";
|
||||
import {
|
||||
ACCESS_TOKEN,
|
||||
PUBLIC_KEY,
|
||||
WRAPPING_PUBLIC_KEY,
|
||||
deviceRegistrationBody,
|
||||
signDeviceMessage,
|
||||
testD1Database,
|
||||
testEnv,
|
||||
} from "./devices_test_support.js";
|
||||
|
||||
describe("device trust routes", () => {
|
||||
it("atomically approves the first v2 device and stores both public keys", async () => {
|
||||
const device = {
|
||||
device_id: "device-01",
|
||||
public_key: PUBLIC_KEY,
|
||||
wrapping_public_key: WRAPPING_PUBLIC_KEY,
|
||||
device_name: "MacBook Pro",
|
||||
platform: "macOS",
|
||||
approval_status: "approved",
|
||||
created_at: 1_780_000_100,
|
||||
approved_at: 1_780_000_100,
|
||||
last_active_at: 1_780_000_100,
|
||||
revoked_at: null,
|
||||
};
|
||||
const d1 = testD1Database({
|
||||
firstRows: [device],
|
||||
sessionRow: {
|
||||
id: "session-01",
|
||||
userId: "user-01",
|
||||
expiresAt: "2099-01-01T00:00:00.000Z",
|
||||
createdAt: new Date().toISOString(),
|
||||
deviceId: null,
|
||||
},
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
new Request("https://elydora.test/api/devices/register", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${ACCESS_TOKEN}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(await deviceRegistrationBody()),
|
||||
}),
|
||||
testEnv({ d1 }),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 201);
|
||||
assert.equal(((await response.json()) as { device: { approval_status: string } }).device.approval_status, "approved");
|
||||
assert.ok(d1.queries.some((query) => query.includes("NOT EXISTS")));
|
||||
assert.ok(
|
||||
d1.queries.some(
|
||||
(query) =>
|
||||
query.includes("user_device_keys") &&
|
||||
query.includes("device_name = ?") &&
|
||||
query.includes("idempotency_key = ?"),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps subsequent v2 devices pending", async () => {
|
||||
const device = deviceRow({ approval_status: "pending", approved_at: null });
|
||||
const d1 = testD1Database({
|
||||
firstRows: [device],
|
||||
sessionRow: unboundSession(),
|
||||
});
|
||||
const response = await registerRequest(d1, await deviceRegistrationBody());
|
||||
|
||||
assert.equal(response.status, 201);
|
||||
const body = (await response.json()) as { device: { approval_status: string } };
|
||||
assert.equal(body.device.approval_status, "pending");
|
||||
});
|
||||
|
||||
it("requires a fresh session before registering an unbound device", async () => {
|
||||
const d1 = testD1Database({
|
||||
sessionRow: { ...unboundSession(), createdAt: "2020-01-01T00:00:00.000Z" },
|
||||
});
|
||||
const response = await registerRequest(d1, await deviceRegistrationBody());
|
||||
|
||||
assert.equal(response.status, 403);
|
||||
assert.deepEqual(await response.json(), { error: "device_registration_forbidden" });
|
||||
assert.deepEqual(d1.queries, []);
|
||||
});
|
||||
|
||||
it("rejects v1 and non-canonical v2 registration keys before D1 writes", async () => {
|
||||
for (const registration of [
|
||||
{ ...(await deviceRegistrationBody()), version: 1 },
|
||||
await deviceRegistrationBody({ public_key: PUBLIC_KEY.toUpperCase() }),
|
||||
await deviceRegistrationBody({ wrapping_public_key: WRAPPING_PUBLIC_KEY.toUpperCase() }),
|
||||
]) {
|
||||
const d1 = testD1Database({ sessionRow: unboundSession() });
|
||||
const response = await registerRequest(d1, registration);
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
assert.deepEqual(d1.queries, []);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a tampered registration proof before D1 writes", async () => {
|
||||
const registration = await deviceRegistrationBody();
|
||||
registration.wrapping_public_key = "c".repeat(64);
|
||||
const d1 = testD1Database({ sessionRow: unboundSession() });
|
||||
const response = await registerRequest(d1, registration);
|
||||
|
||||
assert.equal(response.status, 403);
|
||||
assert.deepEqual(await response.json(), { error: "device_registration_forbidden" });
|
||||
assert.deepEqual(d1.queries, []);
|
||||
});
|
||||
|
||||
it("preserves an existing session binding that wins a registration race", async () => {
|
||||
const d1 = testD1Database({
|
||||
firstRows: [deviceRow()],
|
||||
runChanges: [0],
|
||||
sessionRow: unboundSession(),
|
||||
});
|
||||
const response = await registerRequest(d1, await deviceRegistrationBody());
|
||||
|
||||
assert.equal(response.status, 409);
|
||||
assert.deepEqual(await response.json(), { error: "device_registration_conflict" });
|
||||
assert.ok(d1.queries.at(-1)?.includes("ON CONFLICT(session_id) DO NOTHING"));
|
||||
});
|
||||
|
||||
it("issues a short-lived challenge only for an approved v2 device", async () => {
|
||||
const { challenge, d1 } = await issueChallenge();
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
|
||||
assert.match(challenge.challenge_id, /^[0-9a-f-]{36}$/);
|
||||
assert.match(challenge.challenge, /^elydora-device-rebind-v1\n/);
|
||||
assert.ok(challenge.expires_at - nowSeconds >= 299);
|
||||
assert.ok(challenge.expires_at - nowSeconds <= 300);
|
||||
assert.ok(d1.queries[0]?.includes("key_protocol_version = 2"));
|
||||
assert.ok(d1.queries[1]?.includes("ON CONFLICT(session_id) DO UPDATE"));
|
||||
assert.deepEqual(d1.binds[1]?.slice(1, 4), ["user-01", "session-01", "device-01"]);
|
||||
});
|
||||
|
||||
it("rebinds an unbound session after a valid Ed25519 challenge signature", async () => {
|
||||
const { challenge } = await issueChallenge();
|
||||
const signature = await signDeviceMessage(new TextEncoder().encode(challenge.challenge));
|
||||
const d1 = rebindDatabase(challenge);
|
||||
const response = await rebindRequest(d1, challenge, signature);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(await response.json(), {
|
||||
version: 1,
|
||||
user_id: "user-01",
|
||||
session_id: "session-01",
|
||||
device_id: "device-01",
|
||||
bound_at: d1.binds[1]?.[0],
|
||||
});
|
||||
assert.equal(d1.batches[0], 2);
|
||||
assert.ok(d1.queries[1]?.includes("consumed_at IS NULL"));
|
||||
assert.ok(d1.queries[1]?.includes("session_id = ?"));
|
||||
assert.ok(d1.queries[2]?.includes("ON CONFLICT(session_id) DO NOTHING"));
|
||||
});
|
||||
|
||||
it("rejects invalid signatures without consuming the challenge", async () => {
|
||||
const { challenge } = await issueChallenge();
|
||||
const d1 = rebindDatabase(challenge);
|
||||
const response = await rebindRequest(d1, challenge, "00".repeat(64));
|
||||
|
||||
assert.equal(response.status, 403);
|
||||
assert.deepEqual(await response.json(), { error: "device_rebind_forbidden" });
|
||||
assert.deepEqual(d1.batches, []);
|
||||
});
|
||||
|
||||
it("rejects expired and replayed challenges", async () => {
|
||||
const { challenge } = await issueChallenge();
|
||||
const signature = await signDeviceMessage(new TextEncoder().encode(challenge.challenge));
|
||||
const expiredD1 = rebindDatabase({ ...challenge, expires_at: 1 });
|
||||
const expiredResponse = await rebindRequest(expiredD1, challenge, signature);
|
||||
assert.equal(expiredResponse.status, 403);
|
||||
assert.deepEqual(expiredD1.batches, []);
|
||||
|
||||
const replayD1 = rebindDatabase(challenge, [[0, 0]]);
|
||||
const replayResponse = await rebindRequest(replayD1, challenge, signature);
|
||||
assert.equal(replayResponse.status, 409);
|
||||
assert.deepEqual(await replayResponse.json(), { error: "device_rebind_conflict" });
|
||||
});
|
||||
});
|
||||
|
||||
interface ChallengeDocument {
|
||||
challenge_id: string;
|
||||
device_id: string;
|
||||
challenge: string;
|
||||
expires_at: number;
|
||||
}
|
||||
|
||||
async function registerRequest(
|
||||
d1: ReturnType<typeof testD1Database>,
|
||||
registration: Record<string, unknown>,
|
||||
): Promise<Response> {
|
||||
return handleRequest(
|
||||
new Request("https://elydora.test/api/devices/register", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${ACCESS_TOKEN}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(registration),
|
||||
}),
|
||||
testEnv({ d1 }),
|
||||
);
|
||||
}
|
||||
|
||||
async function issueChallenge(): Promise<{
|
||||
challenge: ChallengeDocument;
|
||||
d1: ReturnType<typeof testD1Database>;
|
||||
}> {
|
||||
const d1 = testD1Database({
|
||||
firstRows: [{ signing_public_key: PUBLIC_KEY }],
|
||||
sessionRow: unboundSession(),
|
||||
});
|
||||
const response = await handleRequest(
|
||||
new Request("https://elydora.test/api/devices/rebind/challenge", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${ACCESS_TOKEN}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ version: 1, device_id: "device-01" }),
|
||||
}),
|
||||
testEnv({ d1 }),
|
||||
);
|
||||
assert.equal(response.status, 201);
|
||||
return { challenge: (await response.json()) as ChallengeDocument, d1 };
|
||||
}
|
||||
|
||||
function rebindDatabase(
|
||||
challenge: ChallengeDocument,
|
||||
batchChanges: number[][] = [[1, 1]],
|
||||
): ReturnType<typeof testD1Database> {
|
||||
return testD1Database({
|
||||
batchChanges,
|
||||
firstRows: [
|
||||
{
|
||||
challenge: challenge.challenge,
|
||||
expires_at: challenge.expires_at,
|
||||
signing_public_key: PUBLIC_KEY,
|
||||
},
|
||||
],
|
||||
sessionRow: unboundSession(),
|
||||
});
|
||||
}
|
||||
|
||||
async function rebindRequest(
|
||||
d1: ReturnType<typeof testD1Database>,
|
||||
challenge: ChallengeDocument,
|
||||
signature: string,
|
||||
): Promise<Response> {
|
||||
return handleRequest(
|
||||
new Request("https://elydora.test/api/devices/rebind", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${ACCESS_TOKEN}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
version: 1,
|
||||
challenge_id: challenge.challenge_id,
|
||||
device_id: challenge.device_id,
|
||||
signature,
|
||||
}),
|
||||
}),
|
||||
testEnv({ d1 }),
|
||||
);
|
||||
}
|
||||
|
||||
function unboundSession(): Record<string, unknown> {
|
||||
return {
|
||||
id: "session-01",
|
||||
userId: "user-01",
|
||||
expiresAt: "2099-01-01T00:00:00.000Z",
|
||||
createdAt: new Date().toISOString(),
|
||||
deviceId: null,
|
||||
};
|
||||
}
|
||||
|
||||
function deviceRow(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
device_id: "device-01",
|
||||
public_key: PUBLIC_KEY,
|
||||
wrapping_public_key: WRAPPING_PUBLIC_KEY,
|
||||
device_name: "MacBook Pro",
|
||||
platform: "macOS",
|
||||
approval_status: "approved",
|
||||
created_at: 1_780_000_100,
|
||||
approved_at: 1_780_000_100,
|
||||
last_active_at: 1_780_000_100,
|
||||
revoked_at: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import { deviceApprovalProofBytes } from "../src/device_approval_proof.js";
|
||||
import { handleRequest } from "../src/index.js";
|
||||
import {
|
||||
ACCESS_TOKEN,
|
||||
PUBLIC_KEY,
|
||||
WRAPPING_PUBLIC_KEY,
|
||||
signDeviceMessage,
|
||||
testD1Database,
|
||||
testEnv,
|
||||
} from "./devices_test_support.js";
|
||||
|
||||
const KEY_ID = "a".repeat(64);
|
||||
const IDEMPOTENCY_KEY = "device-approval-0001";
|
||||
const SUITE = "HPKE-BASE-X25519-HKDF-SHA256-CHACHA20POLY1305" as const;
|
||||
|
||||
describe("device approval stored state", () => {
|
||||
it("reports a malformed requester key as a persistence failure", async () => {
|
||||
await assertPersistenceFailure([{ device_id: "device-01", signing_public_key: "invalid" }]);
|
||||
});
|
||||
|
||||
it("reports malformed approval metadata as a persistence failure", async () => {
|
||||
await assertPersistenceFailure([
|
||||
deviceRow(),
|
||||
approvalRow({ device_id: 2 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports an invalid approval status as a persistence failure", async () => {
|
||||
await assertPersistenceFailure([
|
||||
deviceRow(),
|
||||
approvalRow({ status: "corrupt" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats a valid pending row as an idempotency mismatch", async () => {
|
||||
const d1 = testD1Database({
|
||||
firstRows: [deviceRow(), approvalRow({ status: "pending", decided_at: null })],
|
||||
});
|
||||
const response = await approvalRequest(d1);
|
||||
|
||||
assert.equal(response.status, 403);
|
||||
assert.deepEqual(await response.json(), { error: "device_approval_forbidden" });
|
||||
assert.deepEqual(d1.batches, []);
|
||||
});
|
||||
|
||||
it("reports malformed device state as a persistence failure", async () => {
|
||||
await assertPersistenceFailure([
|
||||
deviceRow(),
|
||||
null,
|
||||
deviceRow({ device_id: "device-02", approval_status: "pending", created_at: "invalid" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports a missing approved-device key row as a persistence failure", async () => {
|
||||
await assertPersistenceFailure([
|
||||
deviceRow(),
|
||||
approvalRow(),
|
||||
deviceRow({ device_id: "device-02", wrapping_public_key: null }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports malformed envelope state as a persistence failure", async () => {
|
||||
await assertPersistenceFailure([
|
||||
deviceRow(),
|
||||
approvalRow(),
|
||||
deviceRow({ device_id: "device-02" }),
|
||||
approvalEnvelopeRow({ generation: "1" }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
async function assertPersistenceFailure(firstRows: unknown[]): Promise<void> {
|
||||
const d1 = testD1Database({ firstRows });
|
||||
const response = await approvalRequest(d1);
|
||||
assert.equal(response.status, 500);
|
||||
assert.deepEqual(await response.json(), { error: "device_approval_failed" });
|
||||
assert.deepEqual(d1.batches, []);
|
||||
}
|
||||
|
||||
async function approvalRequest(d1: ReturnType<typeof testD1Database>): Promise<Response> {
|
||||
return handleRequest(
|
||||
new Request("https://elydora.test/api/devices/approve", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${ACCESS_TOKEN}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(await approvalBody()),
|
||||
}),
|
||||
testEnv({ d1 }),
|
||||
);
|
||||
}
|
||||
|
||||
async function approvalBody(): Promise<Record<string, unknown>> {
|
||||
const proofCreatedAt = Math.floor(Date.now() / 1000);
|
||||
const envelope = {
|
||||
version: 1 as const,
|
||||
suite: SUITE,
|
||||
encapped_key: "A".repeat(43),
|
||||
ciphertext: "B".repeat(64),
|
||||
};
|
||||
const action = {
|
||||
deviceId: "device-02",
|
||||
keyId: KEY_ID,
|
||||
generation: 1,
|
||||
envelope,
|
||||
idempotencyKey: IDEMPOTENCY_KEY,
|
||||
proofCreatedAt,
|
||||
};
|
||||
return {
|
||||
version: 2,
|
||||
device_id: action.deviceId,
|
||||
key_id: action.keyId,
|
||||
generation: action.generation,
|
||||
envelope,
|
||||
idempotency_key: action.idempotencyKey,
|
||||
proof_created_at: proofCreatedAt,
|
||||
approval_proof: await signDeviceMessage(
|
||||
deviceApprovalProofBytes("user-01", "device-01", action),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function approvalRow(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
device_id: "device-02",
|
||||
requester_device_id: "device-01",
|
||||
status: "approved",
|
||||
decided_at: 1_780_000_300,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function deviceRow(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
device_id: "device-01",
|
||||
public_key: PUBLIC_KEY,
|
||||
signing_public_key: PUBLIC_KEY,
|
||||
wrapping_public_key: WRAPPING_PUBLIC_KEY,
|
||||
device_name: "MacBook Pro",
|
||||
platform: "macOS",
|
||||
approval_status: "approved",
|
||||
created_at: 1_780_000_000,
|
||||
approved_at: 1_780_000_010,
|
||||
last_active_at: 1_780_000_020,
|
||||
revoked_at: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function approvalEnvelopeRow(overrides: Record<string, unknown>): Record<string, unknown> {
|
||||
return {
|
||||
key_id: KEY_ID,
|
||||
generation: 1,
|
||||
recipient_device_id: "device-02",
|
||||
approver_device_id: "device-01",
|
||||
envelope_version: 1,
|
||||
suite: SUITE,
|
||||
encapped_key: "A".repeat(43),
|
||||
ciphertext: "B".repeat(64),
|
||||
idempotency_key: IDEMPOTENCY_KEY,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -2,18 +2,34 @@ import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import { authSessionCacheKvKey, authTokenHash } from "../src/auth.js";
|
||||
import { deviceApprovalProofBytes } from "../src/device_approval_proof.js";
|
||||
import { handleRequest } from "../src/index.js";
|
||||
import {
|
||||
ACCESS_TOKEN,
|
||||
PUBLIC_KEY,
|
||||
WRAPPING_PUBLIC_KEY,
|
||||
sessionDocument,
|
||||
signDeviceMessage,
|
||||
testD1Database,
|
||||
testEnv,
|
||||
} from "./devices_test_support.js";
|
||||
|
||||
const DEVICE_APPROVAL_IDEMPOTENCY_KEY = "device-approval-0001";
|
||||
const KEY_ID = "a".repeat(64);
|
||||
const GENERATION = 1;
|
||||
const SUITE = "HPKE-BASE-X25519-HKDF-SHA256-CHACHA20POLY1305";
|
||||
const ENCAPPED_KEY = "A".repeat(43);
|
||||
const CIPHERTEXT = "B".repeat(64);
|
||||
|
||||
describe("device approval routes", () => {
|
||||
it("matches the frozen cross-runtime approval proof vector", async () => {
|
||||
const body = await deviceApprovalBody({ proof_created_at: 1_780_000_300 });
|
||||
assert.equal(
|
||||
body.approval_proof,
|
||||
"f12fb7a5f7f20551bd22d0fcf8f5787d49f6202f89e42c332c248772fd9a59c82a9d8b6ac47ea84340170fc1555fc74d70a0d6ba3541df257882d46d6d79d901",
|
||||
);
|
||||
});
|
||||
|
||||
it("approves a pending device from an approved current device", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({
|
||||
@@ -26,6 +42,7 @@ describe("device approval routes", () => {
|
||||
approval_status: "approved",
|
||||
approved_at: 1_780_000_300,
|
||||
}),
|
||||
approvalEnvelopeRow(),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -36,7 +53,7 @@ describe("device approval routes", () => {
|
||||
authorization: `Bearer ${ACCESS_TOKEN}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(deviceApprovalBody()),
|
||||
body: JSON.stringify(await deviceApprovalBody()),
|
||||
}),
|
||||
testEnv({
|
||||
d1,
|
||||
@@ -54,6 +71,7 @@ describe("device approval routes", () => {
|
||||
device: {
|
||||
device_id: "device-02",
|
||||
public_key: PUBLIC_KEY,
|
||||
wrapping_public_key: WRAPPING_PUBLIC_KEY,
|
||||
device_name: "MacBook Pro",
|
||||
platform: "macOS",
|
||||
approval_status: "approved",
|
||||
@@ -64,22 +82,34 @@ describe("device approval routes", () => {
|
||||
current: false,
|
||||
},
|
||||
});
|
||||
assert.equal(d1.batches[0], 2);
|
||||
assert.equal(d1.batches[0], 3);
|
||||
assert.ok(d1.queries[0]?.includes("approval_status = 'approved'"));
|
||||
assert.ok(d1.queries[0]?.includes("key_protocol_version = 2"));
|
||||
assert.ok(d1.queries[1]?.includes("FROM device_approvals"));
|
||||
assert.ok(d1.queries[3]?.includes("INSERT INTO device_approvals"));
|
||||
assert.ok(d1.queries[4]?.includes("UPDATE user_devices"));
|
||||
assert.ok(d1.queries[3]?.includes("INSERT INTO sync_vault_envelopes"));
|
||||
assert.ok(d1.queries[4]?.includes("INSERT INTO device_approvals"));
|
||||
assert.ok(d1.queries[5]?.includes("UPDATE user_devices"));
|
||||
assert.ok(d1.queries[5]?.includes("sync_vault_envelopes"));
|
||||
assert.ok(d1.queries[7]?.includes("current_key_id"));
|
||||
assert.deepEqual(d1.binds[0], ["user-01", "device-01"]);
|
||||
assert.deepEqual(d1.binds[1], ["user-01", DEVICE_APPROVAL_IDEMPOTENCY_KEY]);
|
||||
assert.deepEqual(d1.binds[2], ["user-01", "device-02"]);
|
||||
assert.deepEqual(d1.binds[3]?.slice(0, 4), [
|
||||
assert.deepEqual(d1.binds[3]?.slice(0, 5), [
|
||||
"user-01",
|
||||
"device-02",
|
||||
"device-01",
|
||||
KEY_ID,
|
||||
GENERATION,
|
||||
]);
|
||||
assert.deepEqual(d1.binds[4]?.slice(0, 4), [
|
||||
"user-01",
|
||||
DEVICE_APPROVAL_IDEMPOTENCY_KEY,
|
||||
"device-02",
|
||||
"device-01",
|
||||
]);
|
||||
assert.equal(d1.binds[3]?.[7], DEVICE_APPROVAL_IDEMPOTENCY_KEY);
|
||||
assert.deepEqual(d1.binds[5], ["user-01", "device-02"]);
|
||||
assert.equal(d1.binds[4]?.[7], DEVICE_APPROVAL_IDEMPOTENCY_KEY);
|
||||
assert.deepEqual(d1.binds[6], ["user-01", "device-02"]);
|
||||
assert.deepEqual(d1.binds[7], ["user-01", "device-02"]);
|
||||
});
|
||||
|
||||
it("returns the existing approval for an idempotent replay", async () => {
|
||||
@@ -98,6 +128,7 @@ describe("device approval routes", () => {
|
||||
approval_status: "approved",
|
||||
approved_at: 1_780_000_300,
|
||||
}),
|
||||
approvalEnvelopeRow(),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -108,7 +139,7 @@ describe("device approval routes", () => {
|
||||
authorization: `Bearer ${ACCESS_TOKEN}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(deviceApprovalBody()),
|
||||
body: JSON.stringify(await deviceApprovalBody()),
|
||||
}),
|
||||
testEnv({
|
||||
d1,
|
||||
@@ -120,14 +151,79 @@ describe("device approval routes", () => {
|
||||
const body = (await response.json()) as { approved_at: number };
|
||||
assert.equal(body.approved_at, 1_780_000_300);
|
||||
assert.deepEqual(d1.batches, []);
|
||||
assert.equal(d1.queries.length, 3);
|
||||
assert.equal(d1.queries.length, 4);
|
||||
assert.deepEqual(d1.binds, [
|
||||
["user-01", "device-01"],
|
||||
["user-01", DEVICE_APPROVAL_IDEMPOTENCY_KEY],
|
||||
["user-01", "device-02"],
|
||||
["user-01", "device-02"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects an approval replay with different wrapped key material", async () => {
|
||||
const d1 = testD1Database({
|
||||
firstRows: [
|
||||
deviceRow({ device_id: "device-01", approval_status: "approved" }),
|
||||
{
|
||||
device_id: "device-02",
|
||||
requester_device_id: "device-01",
|
||||
status: "approved",
|
||||
decided_at: 1_780_000_300,
|
||||
},
|
||||
deviceRow({ device_id: "device-02", approval_status: "approved" }),
|
||||
approvalEnvelopeRow({ ciphertext: "C".repeat(64) }),
|
||||
],
|
||||
});
|
||||
|
||||
const response = await approvalRequest(d1, await deviceApprovalBody());
|
||||
|
||||
assert.equal(response.status, 403);
|
||||
assert.deepEqual(await response.json(), { error: "device_approval_forbidden" });
|
||||
assert.deepEqual(d1.batches, []);
|
||||
});
|
||||
|
||||
it("rejects approval replays with a different current key or generation", async () => {
|
||||
for (const override of [{ key_id: "b".repeat(64) }, { generation: 2 }]) {
|
||||
const d1 = testD1Database({
|
||||
firstRows: [
|
||||
deviceRow({ device_id: "device-01", approval_status: "approved" }),
|
||||
{
|
||||
device_id: "device-02",
|
||||
requester_device_id: "device-01",
|
||||
status: "approved",
|
||||
decided_at: 1_780_000_300,
|
||||
},
|
||||
deviceRow({ device_id: "device-02", approval_status: "approved" }),
|
||||
approvalEnvelopeRow(),
|
||||
],
|
||||
});
|
||||
|
||||
const response = await approvalRequest(d1, await deviceApprovalBody(override));
|
||||
|
||||
assert.equal(response.status, 403);
|
||||
assert.deepEqual(await response.json(), { error: "device_approval_forbidden" });
|
||||
assert.deepEqual(d1.batches, []);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the target pending when the current vault envelope cannot be written", async () => {
|
||||
const d1 = testD1Database({
|
||||
firstRows: [
|
||||
deviceRow({ device_id: "device-01", approval_status: "approved" }),
|
||||
null,
|
||||
deviceRow({ device_id: "device-02", approval_status: "pending", approved_at: null }),
|
||||
deviceRow({ device_id: "device-02", approval_status: "pending", approved_at: null }),
|
||||
],
|
||||
});
|
||||
|
||||
const response = await approvalRequest(d1, await deviceApprovalBody());
|
||||
|
||||
assert.equal(response.status, 409);
|
||||
assert.deepEqual(await response.json(), { error: "device_approval_conflict" });
|
||||
assert.ok(d1.queries[4]?.includes("WHERE EXISTS"));
|
||||
assert.ok(d1.queries[5]?.includes("AND EXISTS"));
|
||||
});
|
||||
|
||||
it("rejects approval from a current device that is not approved", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({ firstRows: [null] });
|
||||
@@ -138,7 +234,7 @@ describe("device approval routes", () => {
|
||||
authorization: `Bearer ${ACCESS_TOKEN}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(deviceApprovalBody()),
|
||||
body: JSON.stringify(await deviceApprovalBody()),
|
||||
}),
|
||||
testEnv({
|
||||
d1,
|
||||
@@ -151,6 +247,38 @@ describe("device approval routes", () => {
|
||||
assert.deepEqual(d1.batches, []);
|
||||
});
|
||||
|
||||
it("rejects a tampered current-device proof before approval state reads", async () => {
|
||||
const d1 = testD1Database({
|
||||
firstRows: [deviceRow({ device_id: "device-01", approval_status: "approved" })],
|
||||
});
|
||||
const body = await deviceApprovalBody();
|
||||
body.approval_proof = "0".repeat(128);
|
||||
const response = await approvalRequest(d1, body);
|
||||
|
||||
assert.equal(response.status, 403);
|
||||
assert.deepEqual(await response.json(), { error: "device_approval_forbidden" });
|
||||
assert.deepEqual(d1.batches, []);
|
||||
assert.equal(d1.queries.length, 1);
|
||||
});
|
||||
|
||||
it("requires a recent proof for a new approval", async () => {
|
||||
const d1 = testD1Database({
|
||||
firstRows: [
|
||||
deviceRow({ device_id: "device-01", approval_status: "approved" }),
|
||||
null,
|
||||
],
|
||||
});
|
||||
const response = await approvalRequest(
|
||||
d1,
|
||||
await deviceApprovalBody({ proof_created_at: 1_700_000_000 }),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 403);
|
||||
assert.deepEqual(await response.json(), { error: "device_approval_forbidden" });
|
||||
assert.deepEqual(d1.batches, []);
|
||||
assert.equal(d1.queries.length, 2);
|
||||
});
|
||||
|
||||
it("rejects self approval before D1 writes", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database([]);
|
||||
@@ -161,7 +289,7 @@ describe("device approval routes", () => {
|
||||
authorization: `Bearer ${ACCESS_TOKEN}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ ...deviceApprovalBody(), device_id: "device-01" }),
|
||||
body: JSON.stringify(await deviceApprovalBody({ device_id: "device-01" })),
|
||||
}),
|
||||
testEnv({
|
||||
d1,
|
||||
@@ -183,7 +311,7 @@ describe("device approval routes", () => {
|
||||
authorization: `Bearer ${ACCESS_TOKEN}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ ...deviceApprovalBody(), idempotency_key: "short" }),
|
||||
body: JSON.stringify(await deviceApprovalBody({ idempotency_key: "short" })),
|
||||
}),
|
||||
testEnv({
|
||||
d1,
|
||||
@@ -202,7 +330,7 @@ describe("device approval routes", () => {
|
||||
new Request("https://elydora.test/api/devices/approve", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(deviceApprovalBody()),
|
||||
body: JSON.stringify(await deviceApprovalBody()),
|
||||
}),
|
||||
testEnv({ d1 }),
|
||||
);
|
||||
@@ -213,18 +341,46 @@ describe("device approval routes", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function deviceApprovalBody(): Record<string, unknown> {
|
||||
return {
|
||||
version: 1,
|
||||
async function deviceApprovalBody(
|
||||
overrides: Record<string, unknown> = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
const body: Record<string, unknown> = {
|
||||
version: 2,
|
||||
device_id: "device-02",
|
||||
key_id: KEY_ID,
|
||||
generation: GENERATION,
|
||||
envelope: wrappedEnvelope(),
|
||||
idempotency_key: DEVICE_APPROVAL_IDEMPOTENCY_KEY,
|
||||
proof_created_at: Math.floor(Date.now() / 1000),
|
||||
...overrides,
|
||||
};
|
||||
const envelope = body.envelope as {
|
||||
version: 1;
|
||||
suite: typeof SUITE;
|
||||
encapped_key: string;
|
||||
ciphertext: string;
|
||||
};
|
||||
body.approval_proof = await signDeviceMessage(deviceApprovalProofBytes(
|
||||
"user-01",
|
||||
"device-01",
|
||||
{
|
||||
deviceId: String(body.device_id),
|
||||
keyId: String(body.key_id),
|
||||
generation: Number(body.generation),
|
||||
envelope,
|
||||
idempotencyKey: String(body.idempotency_key),
|
||||
proofCreatedAt: Number(body.proof_created_at),
|
||||
},
|
||||
));
|
||||
return body;
|
||||
}
|
||||
|
||||
function deviceRow(overrides: Record<string, unknown>): Record<string, unknown> {
|
||||
return {
|
||||
device_id: "device-01",
|
||||
public_key: PUBLIC_KEY,
|
||||
signing_public_key: PUBLIC_KEY,
|
||||
wrapping_public_key: WRAPPING_PUBLIC_KEY,
|
||||
device_name: "MacBook Pro",
|
||||
platform: "macOS",
|
||||
approval_status: "approved",
|
||||
@@ -235,3 +391,44 @@ function deviceRow(overrides: Record<string, unknown>): Record<string, unknown>
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function wrappedEnvelope(): Record<string, unknown> {
|
||||
return {
|
||||
version: 1,
|
||||
suite: SUITE,
|
||||
encapped_key: ENCAPPED_KEY,
|
||||
ciphertext: CIPHERTEXT,
|
||||
};
|
||||
}
|
||||
|
||||
function approvalEnvelopeRow(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
key_id: KEY_ID,
|
||||
generation: GENERATION,
|
||||
recipient_device_id: "device-02",
|
||||
approver_device_id: "device-01",
|
||||
envelope_version: 1,
|
||||
suite: SUITE,
|
||||
encapped_key: ENCAPPED_KEY,
|
||||
ciphertext: CIPHERTEXT,
|
||||
idempotency_key: DEVICE_APPROVAL_IDEMPOTENCY_KEY,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function approvalRequest(
|
||||
d1: ReturnType<typeof testD1Database>,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<Response> {
|
||||
return handleRequest(
|
||||
new Request("https://elydora.test/api/devices/approve", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${ACCESS_TOKEN}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
testEnv({ d1 }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,229 +1,452 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import { authSessionCacheKvKey, authTokenHash } from "../src/auth.js";
|
||||
import {
|
||||
deviceRevocationRequest,
|
||||
deviceRevocationRequestHash,
|
||||
deviceRevocationProofBytes,
|
||||
pendingDeviceRevocationProofBytes,
|
||||
pendingDeviceRevocationRequestHash,
|
||||
} from "../src/device_revocation_schema.js";
|
||||
import { handleRequest } from "../src/index.js";
|
||||
import {
|
||||
ACCESS_TOKEN,
|
||||
PUBLIC_KEY,
|
||||
WRAPPING_PUBLIC_KEY,
|
||||
sessionDocument,
|
||||
signDeviceMessage,
|
||||
testD1Database,
|
||||
testEnv,
|
||||
} from "./devices_test_support.js";
|
||||
|
||||
const DEVICE_REVOCATION_IDEMPOTENCY_KEY = "device-revocation-0001";
|
||||
const DEVICE_REVOCATION_EVENT_ID = `device-revoke:user-01:${DEVICE_REVOCATION_IDEMPOTENCY_KEY}`;
|
||||
|
||||
const USER_ID = "user-01", APPROVER_DEVICE_ID = "device-01";
|
||||
const TARGET_DEVICE_ID = "device-02", OTHER_DEVICE_ID = "device-03";
|
||||
const IDEMPOTENCY_KEY = "device-revocation-0001";
|
||||
const PREVIOUS_KEY_ID = "a".repeat(64), NEW_KEY_ID = "b".repeat(64);
|
||||
const PREVIOUS_GENERATION = 1, NEW_GENERATION = 2;
|
||||
const SUITE = "HPKE-BASE-X25519-HKDF-SHA256-CHACHA20POLY1305";
|
||||
describe("device revocation routes", () => {
|
||||
it("revokes a device from an approved current device", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
it("atomically rotates the vault and revokes an approved device", async () => {
|
||||
const body = deviceRevocationBody();
|
||||
const d1 = testD1Database({
|
||||
firstRows: [
|
||||
deviceRow({ device_id: "device-01", approval_status: "approved" }),
|
||||
approverRow(),
|
||||
null,
|
||||
deviceRow({ device_id: "device-02", approval_status: "approved" }),
|
||||
deviceRow({
|
||||
device_id: "device-02",
|
||||
approval_status: "revoked",
|
||||
revoked_at: 1_780_000_400,
|
||||
}),
|
||||
currentVaultKeyRow(),
|
||||
deviceRow({ device_id: TARGET_DEVICE_ID }),
|
||||
await rotationResultRow(body, { r2_object_count: 2, r2_item_count: 2 }),
|
||||
deviceRow({ device_id: TARGET_DEVICE_ID, approval_status: "revoked", revoked_at: 1_780_000_400 }),
|
||||
],
|
||||
allRowSets: [
|
||||
[{ device_id: APPROVER_DEVICE_ID }, { device_id: OTHER_DEVICE_ID }],
|
||||
[{ object_count: 2 }],
|
||||
],
|
||||
batchChanges: [[1, 1, 1, 1]],
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
new Request("https://elydora.test/api/devices/revoke", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${ACCESS_TOKEN}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(deviceRevocationBody()),
|
||||
}),
|
||||
testEnv({
|
||||
d1,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument("device-01")]],
|
||||
}),
|
||||
);
|
||||
const response = await revocationResponse(d1, body);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.headers.get("cache-control"), "no-store");
|
||||
assert.deepEqual(await response.json(), {
|
||||
version: 1,
|
||||
user_id: "user-01",
|
||||
revoked_by_device_id: "device-01",
|
||||
revoked_at: 1_780_000_400,
|
||||
device: {
|
||||
device_id: "device-02",
|
||||
public_key: PUBLIC_KEY,
|
||||
device_name: "MacBook Pro",
|
||||
platform: "macOS",
|
||||
approval_status: "revoked",
|
||||
created_at: 1_780_000_000,
|
||||
approved_at: 1_780_000_010,
|
||||
last_active_at: 1_780_000_020,
|
||||
revoked_at: 1_780_000_400,
|
||||
current: false,
|
||||
},
|
||||
});
|
||||
assert.equal(d1.batches[0], 2);
|
||||
assert.ok(d1.queries[0]?.includes("approval_status = 'approved'"));
|
||||
assert.ok(d1.queries[1]?.includes("FROM audit_events"));
|
||||
assert.ok(d1.queries[3]?.includes("INSERT INTO audit_events"));
|
||||
assert.ok(d1.queries[4]?.includes("UPDATE user_devices"));
|
||||
assert.deepEqual(d1.binds[0], ["user-01", "device-01"]);
|
||||
assert.deepEqual(d1.binds[1], ["user-01", DEVICE_REVOCATION_EVENT_ID]);
|
||||
assert.deepEqual(d1.binds[2], ["user-01", "device-02"]);
|
||||
assert.deepEqual(d1.binds[3]?.slice(0, 4), [
|
||||
DEVICE_REVOCATION_EVENT_ID,
|
||||
"user-01",
|
||||
"device-01",
|
||||
"device-02",
|
||||
]);
|
||||
assert.deepEqual(d1.binds[5], ["user-01", "device-02"]);
|
||||
assert.deepEqual(await response.json(), revocationDocument());
|
||||
assert.deepEqual(d1.batches, [4]);
|
||||
assert.ok(d1.queries[6]?.includes("INSERT INTO sync_vault_rotations"));
|
||||
assert.ok(d1.queries[7]?.includes("INSERT INTO sync_vault_rotation_envelopes"));
|
||||
assert.ok(d1.queries[5]?.includes("COUNT(*) AS object_count"));
|
||||
assert.ok(d1.queries[9]?.includes("SET completed_at = ?"));
|
||||
assert.deepEqual(d1.binds[6]?.slice(0, 2), [USER_ID, IDEMPOTENCY_KEY]);
|
||||
assert.match(String(d1.binds[6]?.[2]), /^device-revoke:[a-f0-9]{64}$/);
|
||||
assert.deepEqual(d1.binds[6]?.slice(3, 5), [TARGET_DEVICE_ID, APPROVER_DEVICE_ID]);
|
||||
});
|
||||
|
||||
it("returns the existing revocation for an idempotent replay", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
it("revokes a pending target without rotating or cleaning the vault", async () => {
|
||||
const body = pendingRevocationBody();
|
||||
const d1 = testD1Database({
|
||||
firstRows: [
|
||||
deviceRow({ device_id: "device-01", approval_status: "approved" }),
|
||||
{
|
||||
actor_device_id: "device-01",
|
||||
subject_id: "device-02",
|
||||
outcome: "success",
|
||||
created_at: 1_780_000_400,
|
||||
},
|
||||
deviceRow({
|
||||
device_id: "device-02",
|
||||
approval_status: "revoked",
|
||||
revoked_at: 1_780_000_400,
|
||||
}),
|
||||
approverRow(),
|
||||
null,
|
||||
deviceRow({ approval_status: "pending", approved_at: null }),
|
||||
await pendingResultRow(body),
|
||||
deviceRow({ approval_status: "revoked", approved_at: null, revoked_at: 1_780_000_400 }),
|
||||
],
|
||||
batchChanges: [[1, 1]],
|
||||
});
|
||||
|
||||
const response = await revocationResponse(d1, body);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(await response.json(), pendingRevocationDocument());
|
||||
assert.deepEqual(d1.batches, [2]);
|
||||
assert.ok(d1.queries[3]?.includes("INSERT INTO pending_device_revocations"));
|
||||
assert.ok(d1.queries[4]?.includes("SET completed_at = ?"));
|
||||
assert.equal(d1.queries.some((query) => query.includes("sync_vault_accounts")), false);
|
||||
});
|
||||
|
||||
it("returns an exact pending revocation replay and rejects mismatches", async () => {
|
||||
const body = pendingRevocationBody();
|
||||
const replay = testD1Database({
|
||||
firstRows: [
|
||||
approverRow(),
|
||||
await pendingResultRow(body),
|
||||
deviceRow({ approval_status: "revoked", approved_at: null, revoked_at: 1_780_000_400 }),
|
||||
],
|
||||
});
|
||||
assert.equal((await revocationResponse(replay, body)).status, 200);
|
||||
assert.deepEqual(replay.batches, []);
|
||||
|
||||
const mismatch = testD1Database({
|
||||
firstRows: [approverRow(), { ...await pendingResultRow(body), request_hash: "f".repeat(64) }],
|
||||
});
|
||||
assert.equal((await revocationResponse(mismatch, body)).status, 409);
|
||||
assert.deepEqual(mismatch.batches, []);
|
||||
});
|
||||
|
||||
it("rejects pending mode for an approved target and trigger races", async () => {
|
||||
const body = pendingRevocationBody();
|
||||
const approved = testD1Database({
|
||||
firstRows: [approverRow(), null, deviceRow()],
|
||||
});
|
||||
assert.equal((await revocationResponse(approved, body)).status, 409);
|
||||
assert.deepEqual(approved.batches, []);
|
||||
|
||||
const race = testD1Database({
|
||||
firstRows: [approverRow(), null, deviceRow({ approval_status: "pending", approved_at: null })],
|
||||
batchError: new Error("pending_device_revocation_guard_failed"),
|
||||
});
|
||||
assert.equal((await revocationResponse(race, body)).status, 409);
|
||||
});
|
||||
|
||||
it("returns an exact idempotent replay", async () => {
|
||||
const body = deviceRevocationBody();
|
||||
const d1 = testD1Database({
|
||||
firstRows: [
|
||||
approverRow(),
|
||||
await rotationResultRow(body),
|
||||
deviceRow({ device_id: TARGET_DEVICE_ID, approval_status: "revoked", revoked_at: 1_780_000_400 }),
|
||||
],
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
new Request("https://elydora.test/api/devices/revoke", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${ACCESS_TOKEN}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(deviceRevocationBody()),
|
||||
}),
|
||||
testEnv({
|
||||
d1,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument("device-01")]],
|
||||
}),
|
||||
);
|
||||
const response = await revocationResponse(d1, body);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
const body = (await response.json()) as { revoked_at: number };
|
||||
assert.equal(body.revoked_at, 1_780_000_400);
|
||||
assert.deepEqual(await response.json(), revocationDocument());
|
||||
assert.deepEqual(d1.batches, []);
|
||||
assert.deepEqual(d1.binds, [
|
||||
["user-01", "device-01"],
|
||||
["user-01", DEVICE_REVOCATION_EVENT_ID],
|
||||
["user-01", "device-02"],
|
||||
]);
|
||||
assert.equal(d1.queries.length, 3);
|
||||
});
|
||||
|
||||
it("rejects revocation from a current device that is not approved", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({ firstRows: [null] });
|
||||
const response = await handleRequest(
|
||||
new Request("https://elydora.test/api/devices/revoke", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${ACCESS_TOKEN}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(deviceRevocationBody()),
|
||||
}),
|
||||
testEnv({
|
||||
d1,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument("device-01")]],
|
||||
}),
|
||||
);
|
||||
it("rejects a replay with different rotation metadata", async () => {
|
||||
const body = deviceRevocationBody();
|
||||
const d1 = testD1Database({
|
||||
firstRows: [
|
||||
approverRow(),
|
||||
await rotationResultRow(body, { new_key_id: "c".repeat(64) }),
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(response.status, 403);
|
||||
assert.deepEqual(await response.json(), { error: "device_revocation_forbidden" });
|
||||
const response = await revocationResponse(d1, body);
|
||||
|
||||
assert.equal(response.status, 409);
|
||||
assert.deepEqual(await response.json(), { error: "device_revocation_conflict" });
|
||||
assert.deepEqual(d1.batches, []);
|
||||
});
|
||||
|
||||
it("rejects self revocation before D1 writes", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database([]);
|
||||
const response = await handleRequest(
|
||||
new Request("https://elydora.test/api/devices/revoke", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${ACCESS_TOKEN}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,9 +5,14 @@ import type {
|
||||
ElyR2PutOptions,
|
||||
Env,
|
||||
} from "../src/bindings.js";
|
||||
import { deviceRegistrationProofBytes } from "../src/device_registration_proof.js";
|
||||
|
||||
export const ACCESS_TOKEN = "D".repeat(48);
|
||||
export const PUBLIC_KEY = "a".repeat(64);
|
||||
export const PUBLIC_KEY = "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a";
|
||||
export const WRAPPING_PUBLIC_KEY = "b".repeat(64);
|
||||
const SIGNING_PRIVATE_KEY_PKCS8 =
|
||||
"302e020100300506032b657004220420" +
|
||||
"9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60";
|
||||
|
||||
export interface TestEnvOptions {
|
||||
auditEvents?: ElyAnalyticsDataPoint[];
|
||||
@@ -29,6 +34,7 @@ export interface RecordedD1Database extends ElyD1Database {
|
||||
batches: number[];
|
||||
binds: unknown[][];
|
||||
queries: string[];
|
||||
sessionConstraints?: string[];
|
||||
}
|
||||
|
||||
export interface RecordedR2Put {
|
||||
@@ -37,8 +43,54 @@ export interface RecordedR2Put {
|
||||
options: ElyR2PutOptions;
|
||||
}
|
||||
|
||||
export async function deviceRegistrationBody(
|
||||
overrides: Record<string, unknown> = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
const body: Record<string, unknown> = {
|
||||
version: 2,
|
||||
device_id: "device-01",
|
||||
public_key: PUBLIC_KEY,
|
||||
wrapping_public_key: WRAPPING_PUBLIC_KEY,
|
||||
device_name: "MacBook Pro",
|
||||
platform: "macOS",
|
||||
idempotency_key: "device-register-0001",
|
||||
...overrides,
|
||||
};
|
||||
body.registration_proof = await signDeviceMessage(
|
||||
deviceRegistrationProofBytes({
|
||||
deviceId: stringValue(body.device_id),
|
||||
publicKey: stringValue(body.public_key),
|
||||
wrappingPublicKey: stringValue(body.wrapping_public_key),
|
||||
deviceName: stringValue(body.device_name),
|
||||
platform: stringValue(body.platform),
|
||||
idempotencyKey: stringValue(body.idempotency_key),
|
||||
}),
|
||||
);
|
||||
return body;
|
||||
}
|
||||
|
||||
export async function signDeviceMessage(message: Uint8Array): Promise<string> {
|
||||
const privateKey = await crypto.subtle.importKey(
|
||||
"pkcs8",
|
||||
hexBytes(SIGNING_PRIVATE_KEY_PKCS8),
|
||||
{ name: "Ed25519" },
|
||||
false,
|
||||
["sign"],
|
||||
);
|
||||
const signature = await crypto.subtle.sign(
|
||||
{ name: "Ed25519" },
|
||||
privateKey,
|
||||
message,
|
||||
);
|
||||
return hexString(new Uint8Array(signature));
|
||||
}
|
||||
|
||||
interface TestD1DatabaseOptions {
|
||||
allRows?: unknown[];
|
||||
allRowSets?: unknown[][];
|
||||
batchChanges?: number[][];
|
||||
batchError?: Error;
|
||||
batchRowSets?: unknown[][][];
|
||||
firstRows?: unknown[];
|
||||
runChanges?: number[];
|
||||
sessionRow?: unknown | null;
|
||||
@@ -48,6 +100,7 @@ const DEFAULT_AUTH_SESSION_ROW = {
|
||||
id: "session-01",
|
||||
userId: "user-01",
|
||||
expiresAt: "2099-01-01T00:00:00.000Z",
|
||||
createdAt: new Date().toISOString(),
|
||||
deviceId: "device-01",
|
||||
};
|
||||
|
||||
@@ -108,6 +161,7 @@ export function testD1Database(rows: unknown[] | TestD1DatabaseOptions): Recorde
|
||||
const binds: unknown[][] = [];
|
||||
const batches: number[] = [];
|
||||
const queries: string[] = [];
|
||||
const sessionConstraints: string[] = [];
|
||||
const allRows = Array.isArray(rows) ? rows : rows.allRows ?? [];
|
||||
const firstRows = Array.isArray(rows) ? rows : rows.firstRows ?? [];
|
||||
const sessionRow =
|
||||
@@ -115,18 +169,21 @@ export function testD1Database(rows: unknown[] | TestD1DatabaseOptions): Recorde
|
||||
? rows.sessionRow ?? null
|
||||
: DEFAULT_AUTH_SESSION_ROW;
|
||||
let firstIndex = 0;
|
||||
let allIndex = 0;
|
||||
let batchIndex = 0;
|
||||
let runIndex = 0;
|
||||
return {
|
||||
const database: RecordedD1Database = {
|
||||
authBinds,
|
||||
authQueries,
|
||||
batches,
|
||||
binds,
|
||||
queries,
|
||||
sessionConstraints,
|
||||
prepare(query: string) {
|
||||
const isAuthSessionQuery = query.includes("FROM better_auth_session AS session");
|
||||
const isAuthSessionQuery = query.includes("WHERE session.token = ?");
|
||||
(isAuthSessionQuery ? authQueries : queries).push(query);
|
||||
return testD1PreparedStatement(
|
||||
allRows,
|
||||
() => (!Array.isArray(rows) ? rows.allRowSets?.[allIndex++] : undefined) ?? allRows,
|
||||
firstRows,
|
||||
() => firstIndex++,
|
||||
isAuthSessionQuery ? authBinds : binds,
|
||||
@@ -135,14 +192,33 @@ export function testD1Database(rows: unknown[] | TestD1DatabaseOptions): Recorde
|
||||
() => (!Array.isArray(rows) ? rows.runChanges?.[runIndex++] : undefined) ?? 1,
|
||||
);
|
||||
},
|
||||
batch(statements: ElyD1PreparedStatement[]) {
|
||||
batch<T>(statements: ElyD1PreparedStatement[]) {
|
||||
batches.push(statements.length);
|
||||
return Promise.resolve([]);
|
||||
if (!Array.isArray(rows) && rows.batchError !== undefined) {
|
||||
return Promise.reject(rows.batchError);
|
||||
}
|
||||
const currentBatchIndex = batchIndex++;
|
||||
const configuredChanges = !Array.isArray(rows)
|
||||
? rows.batchChanges?.[currentBatchIndex]
|
||||
: undefined;
|
||||
const configuredRows = !Array.isArray(rows)
|
||||
? rows.batchRowSets?.[currentBatchIndex]
|
||||
: undefined;
|
||||
const results = statements.map((_, index) => ({
|
||||
results: configuredRows?.[index] ?? [],
|
||||
meta: { changes: configuredChanges?.[index] ?? 1 },
|
||||
}));
|
||||
return Promise.resolve(results as T[]);
|
||||
},
|
||||
exec() {
|
||||
return Promise.resolve({});
|
||||
},
|
||||
withSession(constraint) {
|
||||
sessionConstraints.push(constraint);
|
||||
return database;
|
||||
},
|
||||
};
|
||||
return database;
|
||||
}
|
||||
|
||||
export function sessionDocument(deviceId: string | null = "device-01"): string {
|
||||
@@ -156,7 +232,7 @@ export function sessionDocument(deviceId: string | null = "device-01"): string {
|
||||
}
|
||||
|
||||
function testD1PreparedStatement(
|
||||
allRows: unknown[],
|
||||
allRows: () => unknown[],
|
||||
firstRows: unknown[],
|
||||
nextFirstIndex: () => number,
|
||||
binds: unknown[][],
|
||||
@@ -176,7 +252,7 @@ function testD1PreparedStatement(
|
||||
return Promise.resolve((firstRows[nextFirstIndex()] as T | undefined) ?? null);
|
||||
},
|
||||
all<T>() {
|
||||
return Promise.resolve({ results: allRows as T[] });
|
||||
return Promise.resolve({ results: allRows() as T[] });
|
||||
},
|
||||
run() {
|
||||
return Promise.resolve({ results: [], meta: { changes: nextRunChanges() } });
|
||||
@@ -220,3 +296,22 @@ function testR2Bucket(
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string {
|
||||
if (typeof value !== "string") {
|
||||
throw new TypeError("registration fixture field must be a string");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function hexBytes(value: string): Uint8Array {
|
||||
const bytes = new Uint8Array(value.length / 2);
|
||||
for (let index = 0; index < bytes.length; index += 1) {
|
||||
bytes[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function hexString(bytes: Uint8Array): string {
|
||||
return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import type { Env } from "../src/bindings.js";
|
||||
import { purgeLegacySessionCache } from "../src/legacy_auth_kv_cleanup.js";
|
||||
|
||||
describe("legacy auth KV cleanup", () => {
|
||||
it("purges KV-only historical session keys across list pages", async () => {
|
||||
const prefix = "ely:production:auth_session_cache:";
|
||||
const first = `${prefix}${"a".repeat(64)}`;
|
||||
const second = `${prefix}${"b".repeat(64)}`;
|
||||
const unrelated = "ely:production:public_cache:plugins";
|
||||
const kv = new PaginatedKv([first, second, unrelated]);
|
||||
const env = { ELY_KV: kv, ELY_ENVIRONMENT: "production" } as unknown as Env;
|
||||
|
||||
assert.equal(await purgeLegacySessionCache(env), 2);
|
||||
|
||||
assert.deepEqual(kv.deleted, [first, second]);
|
||||
assert.deepEqual([...kv.values], [unrelated]);
|
||||
assert.deepEqual(kv.cursors, [undefined, "page-2"]);
|
||||
});
|
||||
});
|
||||
|
||||
class PaginatedKv {
|
||||
readonly values: Set<string>;
|
||||
readonly deleted: string[] = [];
|
||||
readonly cursors: (string | undefined)[] = [];
|
||||
|
||||
constructor(keys: string[]) {
|
||||
this.values = new Set(keys);
|
||||
}
|
||||
|
||||
get(key: string): Promise<string | null> {
|
||||
return Promise.resolve(this.values.has(key) ? "value" : null);
|
||||
}
|
||||
|
||||
put(key: string): Promise<void> {
|
||||
this.values.add(key);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
delete(key: string): Promise<void> {
|
||||
this.deleted.push(key);
|
||||
this.values.delete(key);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
list(options: { prefix: string; cursor?: string; limit: number }) {
|
||||
this.cursors.push(options.cursor);
|
||||
const matching = [...this.values].filter((key) => key.startsWith(options.prefix)).sort();
|
||||
if (options.cursor === undefined) {
|
||||
return Promise.resolve({
|
||||
keys: matching.slice(0, 1).map((name) => ({ name })),
|
||||
list_complete: false as const,
|
||||
cursor: "page-2",
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
keys: matching.map((name) => ({ name })),
|
||||
list_complete: true as const,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -14,14 +14,31 @@ const EXPECTED_MIGRATIONS = [
|
||||
"0005_audit.sql",
|
||||
"0006_better_auth.sql",
|
||||
"0007_better_auth_session_device_context.sql",
|
||||
"0008_sync_encryption.sql",
|
||||
"0009_sync_vault.sql",
|
||||
"0010_device_trust.sql",
|
||||
"0011_sync_vault_rotation.sql",
|
||||
"0012_sync_snapshot_head.sql",
|
||||
"0013_sync_r2_gc.sql",
|
||||
];
|
||||
const USER_SCOPED_TABLES = [
|
||||
"user_devices",
|
||||
"device_approvals",
|
||||
"device_rebind_challenges",
|
||||
"pending_device_revocations",
|
||||
"sync_objects",
|
||||
"sync_r2_gc_candidates",
|
||||
"sync_change_log",
|
||||
"sync_snapshots",
|
||||
"sync_snapshot_encryption",
|
||||
"sync_snapshot_heads",
|
||||
"sync_tombstones",
|
||||
"sync_vault_accounts",
|
||||
"sync_vault_envelopes",
|
||||
"sync_vault_rotation_envelopes",
|
||||
"sync_vault_rotation_r2_objects",
|
||||
"sync_vault_rotations",
|
||||
"user_device_keys",
|
||||
];
|
||||
|
||||
describe("D1 migrations", () => {
|
||||
@@ -44,14 +61,26 @@ describe("D1 migrations", () => {
|
||||
"better_auth_user",
|
||||
"better_auth_verification",
|
||||
"device_approvals",
|
||||
"device_rebind_challenges",
|
||||
"pending_device_revocations",
|
||||
"plugin_packages",
|
||||
"plugin_registry",
|
||||
"plugin_reviews",
|
||||
"release_manifests",
|
||||
"sync_change_log",
|
||||
"sync_objects",
|
||||
"sync_r2_gc_candidates",
|
||||
"sync_r2_inventory_cursors",
|
||||
"sync_snapshots",
|
||||
"sync_snapshot_encryption",
|
||||
"sync_snapshot_heads",
|
||||
"sync_tombstones",
|
||||
"sync_vault_accounts",
|
||||
"sync_vault_envelopes",
|
||||
"sync_vault_rotation_envelopes",
|
||||
"sync_vault_rotation_r2_objects",
|
||||
"sync_vault_rotations",
|
||||
"user_device_keys",
|
||||
"user_devices",
|
||||
]) {
|
||||
assert.ok(tables.includes(table), table);
|
||||
@@ -115,6 +144,31 @@ describe("D1 migrations", () => {
|
||||
]),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(
|
||||
requiredColumns(databasePath, "user_device_keys", [
|
||||
"user_id",
|
||||
"device_id",
|
||||
"signing_public_key",
|
||||
"wrapping_public_key",
|
||||
"key_protocol_version",
|
||||
"created_at",
|
||||
]),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(
|
||||
requiredColumns(databasePath, "device_rebind_challenges", [
|
||||
"challenge_id",
|
||||
"user_id",
|
||||
"session_id",
|
||||
"device_id",
|
||||
"challenge",
|
||||
"created_at",
|
||||
"expires_at",
|
||||
"consumed_at",
|
||||
"consumption_nonce",
|
||||
]),
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -149,6 +203,185 @@ describe("D1 migrations", () => {
|
||||
]),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(
|
||||
requiredColumns(databasePath, "sync_snapshots", [
|
||||
"head_revision",
|
||||
"base_head_revision",
|
||||
"base_snapshot_id",
|
||||
"base_payload_hash",
|
||||
]),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(
|
||||
requiredColumns(databasePath, "sync_snapshot_encryption", [
|
||||
"user_id",
|
||||
"snapshot_id",
|
||||
"encryption_version",
|
||||
"vault_generation",
|
||||
"key_id",
|
||||
"content_hash",
|
||||
]),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(
|
||||
requiredColumns(databasePath, "sync_snapshot_heads", [
|
||||
"user_id",
|
||||
"head_revision",
|
||||
"snapshot_id",
|
||||
"payload_hash",
|
||||
"updated_at",
|
||||
]),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(
|
||||
requiredColumns(databasePath, "sync_r2_gc_candidates", [
|
||||
"r2_key",
|
||||
"user_id",
|
||||
"owner_hash",
|
||||
"object_kind",
|
||||
"state",
|
||||
"write_token",
|
||||
"lease_expires_at",
|
||||
"gc_token",
|
||||
"deleted_at",
|
||||
]),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(
|
||||
requiredColumns(databasePath, "sync_vault_accounts", [
|
||||
"user_id",
|
||||
"current_key_id",
|
||||
"current_generation",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(
|
||||
requiredColumns(databasePath, "sync_vault_envelopes", [
|
||||
"user_id",
|
||||
"recipient_device_id",
|
||||
"approver_device_id",
|
||||
"key_id",
|
||||
"generation",
|
||||
"envelope_version",
|
||||
"suite",
|
||||
"encapped_key",
|
||||
"ciphertext",
|
||||
"idempotency_key",
|
||||
"created_at",
|
||||
]),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(
|
||||
requiredColumns(databasePath, "pending_device_revocations", [
|
||||
"user_id",
|
||||
"idempotency_key",
|
||||
"target_device_id",
|
||||
"approver_device_id",
|
||||
"request_hash",
|
||||
"completed_at",
|
||||
]),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(
|
||||
requiredColumns(databasePath, "sync_vault_rotations", [
|
||||
"user_id",
|
||||
"idempotency_key",
|
||||
"target_device_id",
|
||||
"approver_device_id",
|
||||
"previous_key_id",
|
||||
"previous_generation",
|
||||
"new_key_id",
|
||||
"new_generation",
|
||||
"request_hash",
|
||||
"envelope_count",
|
||||
"r2_object_count",
|
||||
"completed_at",
|
||||
"cleanup_snapshot_id",
|
||||
"cleanup_started_at",
|
||||
"storage_cleaned_at",
|
||||
]),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(
|
||||
requiredColumns(databasePath, "sync_vault_rotation_envelopes", [
|
||||
"user_id",
|
||||
"rotation_idempotency_key",
|
||||
"recipient_device_id",
|
||||
"envelope_idempotency_key",
|
||||
"envelope_version",
|
||||
"suite",
|
||||
"encapped_key",
|
||||
"ciphertext",
|
||||
]),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(
|
||||
requiredColumns(databasePath, "sync_vault_rotation_r2_objects", [
|
||||
"user_id",
|
||||
"rotation_idempotency_key",
|
||||
"r2_key",
|
||||
]),
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("backfills one deterministic legacy encrypted head per user", () => {
|
||||
withDatabaseBeforeSnapshotHeadMigration((databasePath) => {
|
||||
execFileSync("sqlite3", [databasePath], {
|
||||
input: `
|
||||
INSERT INTO sync_snapshots (
|
||||
user_id, snapshot_id, r2_key, payload_hash, schema_rev,
|
||||
logical_clock, device_id, size_bytes, created_at
|
||||
) VALUES
|
||||
('user-01', 'snapshot-b', 'key-b', '${"b".repeat(64)}', 1, 2, 'device-01', 1, 100),
|
||||
('user-01', 'snapshot-a', 'key-a', '${"a".repeat(64)}', 1, 3, 'device-01', 1, 100),
|
||||
('user-01', 'snapshot-c', 'key-c', '${"c".repeat(64)}', 1, 1, 'device-01', 1, 90);
|
||||
INSERT INTO sync_snapshot_encryption (
|
||||
user_id, snapshot_id, encryption_version, vault_generation, key_id, content_hash
|
||||
) VALUES
|
||||
('user-01', 'snapshot-a', 1, 1, '${"1".repeat(64)}', '${"2".repeat(64)}'),
|
||||
('user-01', 'snapshot-b', 1, 1, '${"1".repeat(64)}', '${"3".repeat(64)}'),
|
||||
('user-01', 'snapshot-c', 1, 1, '${"1".repeat(64)}', '${"4".repeat(64)}');
|
||||
`,
|
||||
});
|
||||
execFileSync("sqlite3", [databasePath], {
|
||||
input: `PRAGMA foreign_keys = ON;\n${readFileSync(
|
||||
join(MIGRATIONS_DIR, "0012_sync_snapshot_head.sql"),
|
||||
"utf8",
|
||||
)}`,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
sqliteJson(databasePath, `
|
||||
SELECT head_revision, snapshot_id, payload_hash
|
||||
FROM sync_snapshot_heads
|
||||
WHERE user_id = 'user-01'
|
||||
`),
|
||||
[{ head_revision: 1, snapshot_id: "snapshot-a", payload_hash: "a".repeat(64) }],
|
||||
);
|
||||
assert.deepEqual(
|
||||
sqliteJson(databasePath, `
|
||||
SELECT snapshot_id, head_revision
|
||||
FROM sync_snapshots
|
||||
WHERE user_id = 'user-01'
|
||||
ORDER BY snapshot_id
|
||||
`),
|
||||
[
|
||||
{ snapshot_id: "snapshot-a", head_revision: 1 },
|
||||
{ snapshot_id: "snapshot-b", head_revision: 0 },
|
||||
{ snapshot_id: "snapshot-c", head_revision: 0 },
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
sqliteJson(databasePath, `
|
||||
SELECT DISTINCT encryption_version
|
||||
FROM sync_snapshot_encryption
|
||||
`),
|
||||
[{ encryption_version: 1 }],
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -165,6 +398,22 @@ function withReplayedDatabase(assertions: (databasePath: string) => void): void
|
||||
.map((fileName) => readFileSync(join(MIGRATIONS_DIR, fileName), "utf8"))
|
||||
.join("\n");
|
||||
execFileSync("sqlite3", [databasePath], { input: sql });
|
||||
assertions(databasePath);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function withDatabaseBeforeSnapshotHeadMigration(
|
||||
assertions: (databasePath: string) => void,
|
||||
): void {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "ely-d1-before-head-"));
|
||||
try {
|
||||
const databasePath = join(tempDir, "ely.db");
|
||||
const sql = migrationFiles()
|
||||
.filter((fileName) => fileName < "0012_sync_snapshot_head.sql")
|
||||
.map((fileName) => readFileSync(join(MIGRATIONS_DIR, fileName), "utf8"))
|
||||
.join("\n");
|
||||
execFileSync("sqlite3", [databasePath], { input: sql });
|
||||
assertions(databasePath);
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import type { ElyD1PreparedStatement, ElyD1Result } from "../src/bindings.js";
|
||||
import type { RecordedD1Database } from "./devices_test_support.js";
|
||||
|
||||
export class SqliteD1Database implements RecordedD1Database {
|
||||
readonly authBinds: unknown[][] = [];
|
||||
readonly authQueries: string[] = [];
|
||||
readonly batches: number[] = [];
|
||||
readonly binds: unknown[][] = [];
|
||||
readonly queries: string[] = [];
|
||||
readonly sessionConstraints: string[] = [];
|
||||
private beforeBatchSql: string | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly databasePath: string,
|
||||
beforeBatchSql?: string,
|
||||
) {
|
||||
this.beforeBatchSql = beforeBatchSql;
|
||||
}
|
||||
|
||||
prepare(sql: string): ElyD1PreparedStatement {
|
||||
this.queries.push(sql);
|
||||
return new SqliteD1Statement(this, sql);
|
||||
}
|
||||
|
||||
async batch<T>(statements: ElyD1PreparedStatement[]): Promise<T[]> {
|
||||
this.batches.push(statements.length);
|
||||
if (this.beforeBatchSql !== undefined) {
|
||||
execute(this.databasePath, this.beforeBatchSql);
|
||||
this.beforeBatchSql = undefined;
|
||||
}
|
||||
const prepared = statements.map((statement) => {
|
||||
assert.ok(statement instanceof SqliteD1Statement);
|
||||
return statement.sql();
|
||||
});
|
||||
const script = [
|
||||
".bail on",
|
||||
"PRAGMA foreign_keys = ON;",
|
||||
"BEGIN IMMEDIATE;",
|
||||
...prepared.flatMap((sql, index) => [
|
||||
`.print __ELY_BEGIN_${index}`,
|
||||
sql,
|
||||
`.print __ELY_CHANGES_${index}`,
|
||||
"SELECT changes() AS __ely_changes;",
|
||||
`.print __ELY_END_${index}`,
|
||||
]),
|
||||
"COMMIT;",
|
||||
].join("\n");
|
||||
const output = sqlite(this.databasePath, script, true);
|
||||
const lines = output.trim().split(/\r?\n/).filter(Boolean);
|
||||
const results = prepared.map((_, index) => {
|
||||
const begin = lines.indexOf(`__ELY_BEGIN_${index}`);
|
||||
const changesMarker = lines.indexOf(`__ELY_CHANGES_${index}`);
|
||||
const end = lines.indexOf(`__ELY_END_${index}`);
|
||||
assert.ok(begin >= 0 && changesMarker > begin && end > changesMarker);
|
||||
const rows = lines
|
||||
.slice(begin + 1, changesMarker)
|
||||
.flatMap((line) => JSON.parse(line) as unknown[]);
|
||||
const changeRows = JSON.parse(lines[changesMarker + 1] ?? "[]") as {
|
||||
__ely_changes?: unknown;
|
||||
}[];
|
||||
const value = changeRows[0]?.__ely_changes;
|
||||
assert.equal(typeof value, "number");
|
||||
return { results: rows, meta: { changes: value } };
|
||||
});
|
||||
return results as T[];
|
||||
}
|
||||
|
||||
async exec(sql: string): Promise<unknown> {
|
||||
execute(this.databasePath, sql);
|
||||
return {};
|
||||
}
|
||||
|
||||
withSession(constraint: "first-primary"): SqliteD1Database {
|
||||
this.sessionConstraints.push(constraint);
|
||||
return this;
|
||||
}
|
||||
|
||||
rows<T>(sql: string): T[] {
|
||||
return query(this.databasePath, sql) as T[];
|
||||
}
|
||||
}
|
||||
|
||||
class SqliteD1Statement implements ElyD1PreparedStatement {
|
||||
private values: unknown[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly database: SqliteD1Database,
|
||||
private readonly queryText: string,
|
||||
) {}
|
||||
|
||||
bind(...values: unknown[]): ElyD1PreparedStatement {
|
||||
this.values = values;
|
||||
this.database.binds.push(values);
|
||||
return this;
|
||||
}
|
||||
|
||||
async first<T>(): Promise<T | null> {
|
||||
return this.database.rows<T>(this.sql())[0] ?? null;
|
||||
}
|
||||
|
||||
async all<T>(): Promise<ElyD1Result<T>> {
|
||||
return { results: this.database.rows<T>(this.sql()) };
|
||||
}
|
||||
|
||||
async run(): Promise<unknown> {
|
||||
const rows = this.database.rows<{ changes: number }>(
|
||||
`${this.sql()}\nSELECT changes() AS changes;`,
|
||||
);
|
||||
return { results: [], meta: { changes: rows[0]?.changes ?? 0 } };
|
||||
}
|
||||
|
||||
sql(): string {
|
||||
let index = 0;
|
||||
const sql = this.queryText.replace(/\?/g, () => sqlLiteral(this.values[index++]));
|
||||
assert.equal(index, this.values.length, "D1 bind count must match SQL placeholders");
|
||||
return `${sql.trim().replace(/;$/, "")};`;
|
||||
}
|
||||
}
|
||||
|
||||
export function execute(databasePath: string, sql: string): void {
|
||||
sqlite(databasePath, `.bail on\nPRAGMA foreign_keys = ON;\n${sql}`);
|
||||
}
|
||||
|
||||
export function query(databasePath: string, sql: string): Record<string, unknown>[] {
|
||||
const output = sqlite(databasePath, `PRAGMA foreign_keys = ON;\n${sql}`, true);
|
||||
return output.trim() === "" ? [] : JSON.parse(output) as Record<string, unknown>[];
|
||||
}
|
||||
|
||||
function sqlite(databasePath: string, sql: string, json = false): string {
|
||||
try {
|
||||
return execFileSync("sqlite3", [...(json ? ["-json"] : []), databasePath], {
|
||||
input: sql,
|
||||
encoding: "utf8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
} catch (error) {
|
||||
const stderr = typeof error === "object" && error !== null && "stderr" in error
|
||||
? String(error.stderr)
|
||||
: "";
|
||||
throw new Error(`${error instanceof Error ? error.message : String(error)}\n${stderr}`);
|
||||
}
|
||||
}
|
||||
|
||||
function sqlLiteral(value: unknown): string {
|
||||
if (value === null) return "NULL";
|
||||
if (typeof value === "string") return `'${value.replaceAll("'", "''")}'`;
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value.toString();
|
||||
throw new TypeError("Unsupported SQLite test binding");
|
||||
}
|
||||
@@ -37,8 +37,13 @@ describe("R2 storage contracts", () => {
|
||||
`sync-payloads/us-east/${USER_HASH}/tabs/tab-01/${PAYLOAD_HASH}.bin`,
|
||||
);
|
||||
assert.equal(
|
||||
syncSnapshotKey({ region: "us-east", userHash: USER_HASH, snapshotId: "snapshot-01" }),
|
||||
`sync-snapshots/us-east/${USER_HASH}/snapshot-01.bin`,
|
||||
syncSnapshotKey({
|
||||
region: "us-east",
|
||||
userHash: USER_HASH,
|
||||
snapshotId: "snapshot-01",
|
||||
payloadHash: PAYLOAD_HASH,
|
||||
}),
|
||||
`sync-snapshots/us-east/${USER_HASH}/snapshot-01/${PAYLOAD_HASH}.bin`,
|
||||
);
|
||||
assert.equal(
|
||||
pluginPackageKey({ pluginId: "elydora.reader", packageHash: PACKAGE_HASH }),
|
||||
@@ -163,6 +168,7 @@ describe("R2 storage contracts", () => {
|
||||
region: "us-east",
|
||||
userHash: USER_HASH,
|
||||
snapshotId: "snapshot-01",
|
||||
payloadHash: checksum,
|
||||
});
|
||||
|
||||
const downloaded = await getVerifiedObject(bucket, key, checksum);
|
||||
@@ -176,6 +182,7 @@ describe("R2 storage contracts", () => {
|
||||
region: "us-east",
|
||||
userHash: USER_HASH,
|
||||
snapshotId: "snapshot-01",
|
||||
payloadHash: PAYLOAD_HASH,
|
||||
});
|
||||
|
||||
await deleteKnownObject(bucket, key);
|
||||
|
||||
@@ -5,104 +5,12 @@ import { authSessionCacheKvKey, authTokenHash } from "../src/auth.js";
|
||||
import { handleRequest } from "../src/index.js";
|
||||
import { ACCESS_TOKEN, sessionDocument, testD1Database, testEnv } from "./devices_test_support.js";
|
||||
|
||||
const PAYLOAD_HASH = "a".repeat(64);
|
||||
|
||||
describe("sync pull routes", () => {
|
||||
it("returns sync change log entries for an approved current device", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({
|
||||
firstRows: [{ device_id: "device-01" }],
|
||||
allRows: [
|
||||
syncChangeRow({ change_id: 11, object_id: "tab-01" }),
|
||||
syncChangeRow({ change_id: 12, object_id: "bookmark-01", object_type: "bookmarks" }),
|
||||
],
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
new Request("https://elydora.test/api/sync/pull?cursor=10&limit=2", {
|
||||
headers: { authorization: `Bearer ${ACCESS_TOKEN}` },
|
||||
}),
|
||||
testEnv({
|
||||
d1,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument("device-01")]],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.headers.get("cache-control"), "no-store");
|
||||
assert.deepEqual(await response.json(), {
|
||||
version: 1,
|
||||
user_id: "user-01",
|
||||
device_id: "device-01",
|
||||
cursor: 10,
|
||||
next_cursor: 12,
|
||||
has_more: false,
|
||||
changes: [
|
||||
syncChangeDocument({ change_id: 11, object_id: "tab-01" }),
|
||||
syncChangeDocument({ change_id: 12, object_id: "bookmark-01", object_type: "bookmarks" }),
|
||||
],
|
||||
});
|
||||
assert.ok(d1.queries[0]?.includes("approval_status = 'approved'"));
|
||||
assert.ok(d1.queries[1]?.includes("FROM sync_change_log"));
|
||||
assert.deepEqual(d1.binds, [
|
||||
["user-01", "device-01"],
|
||||
["user-01", 10, 3],
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports more changes when the pull window is saturated", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({
|
||||
firstRows: [{ device_id: "device-01" }],
|
||||
allRows: [
|
||||
syncChangeRow({ change_id: 11, object_id: "tab-01" }),
|
||||
syncChangeRow({ change_id: 12, object_id: "tab-02" }),
|
||||
syncChangeRow({ change_id: 13, object_id: "tab-03" }),
|
||||
],
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
new Request("https://elydora.test/api/sync/pull?cursor=10&limit=2", {
|
||||
headers: { authorization: `Bearer ${ACCESS_TOKEN}` },
|
||||
}),
|
||||
testEnv({
|
||||
d1,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument("device-01")]],
|
||||
}),
|
||||
);
|
||||
|
||||
const body = (await response.json()) as { has_more: boolean; next_cursor: number; changes: [] };
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(body.has_more, true);
|
||||
assert.equal(body.next_cursor, 12);
|
||||
assert.equal(body.changes.length, 2);
|
||||
});
|
||||
|
||||
it("rejects revoked devices before reading sync deltas", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({ firstRows: [null], allRows: [syncChangeRow()] });
|
||||
|
||||
const response = await handleRequest(
|
||||
new Request("https://elydora.test/api/sync/pull?cursor=10", {
|
||||
headers: { authorization: `Bearer ${ACCESS_TOKEN}` },
|
||||
}),
|
||||
testEnv({
|
||||
d1,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument("device-01")]],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 403);
|
||||
assert.deepEqual(await response.json(), { error: "device_not_approved" });
|
||||
assert.equal(d1.queries.length, 1);
|
||||
});
|
||||
|
||||
it("rejects invalid cursors after session and device validation", async () => {
|
||||
describe("retired sync pull route", () => {
|
||||
it("rejects legacy object reads after device authorization", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({ firstRows: [{ device_id: "device-01" }] });
|
||||
|
||||
const response = await handleRequest(
|
||||
new Request("https://elydora.test/api/sync/pull?cursor=old", {
|
||||
new Request("https://elydora.test/api/sync/pull?cursor=0", {
|
||||
headers: { authorization: `Bearer ${ACCESS_TOKEN}` },
|
||||
}),
|
||||
testEnv({
|
||||
@@ -111,34 +19,15 @@ describe("sync pull routes", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
assert.deepEqual(await response.json(), { error: "invalid_sync_pull" });
|
||||
assert.equal(response.status, 410);
|
||||
assert.equal(response.headers.get("cache-control"), "no-store");
|
||||
assert.deepEqual(await response.json(), { error: "sync_object_protocol_retired" });
|
||||
assert.equal(d1.queries.length, 1);
|
||||
assert.deepEqual(d1.batches, []);
|
||||
});
|
||||
|
||||
it("returns a server error for malformed sync change rows", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({
|
||||
firstRows: [{ device_id: "device-01" }],
|
||||
allRows: [syncChangeRow({ payload_hash: "bad" })],
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
new Request("https://elydora.test/api/sync/pull?cursor=10", {
|
||||
headers: { authorization: `Bearer ${ACCESS_TOKEN}` },
|
||||
}),
|
||||
testEnv({
|
||||
d1,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument("device-01")]],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 500);
|
||||
assert.deepEqual(await response.json(), { error: "sync_pull_invalid" });
|
||||
});
|
||||
|
||||
it("rejects unauthenticated sync pulls before D1 reads", async () => {
|
||||
const d1 = testD1Database({ allRows: [syncChangeRow()] });
|
||||
it("keeps retired object reads behind authentication", async () => {
|
||||
const d1 = testD1Database({});
|
||||
const response = await handleRequest(
|
||||
new Request("https://elydora.test/api/sync/pull?cursor=0"),
|
||||
testEnv({ d1 }),
|
||||
@@ -149,21 +38,3 @@ describe("sync pull routes", () => {
|
||||
assert.deepEqual(d1.queries, []);
|
||||
});
|
||||
});
|
||||
|
||||
function syncChangeRow(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
change_id: 11,
|
||||
object_id: "tab-01",
|
||||
object_type: "tabs",
|
||||
operation: "upsert",
|
||||
payload_hash: PAYLOAD_HASH,
|
||||
logical_clock: 42,
|
||||
device_id: "device-02",
|
||||
created_at: 1_780_000_500,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function syncChangeDocument(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return syncChangeRow(overrides);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import { authSessionCacheKvKey, authTokenHash } from "../src/auth.js";
|
||||
@@ -12,327 +11,32 @@ import {
|
||||
testEnv,
|
||||
} from "./devices_test_support.js";
|
||||
|
||||
const USER_ID = "user-01";
|
||||
const DEVICE_ID = "device-01";
|
||||
const OBJECT_ID = "tab-01";
|
||||
const OBJECT_TYPE = "tabs";
|
||||
|
||||
describe("sync push routes", () => {
|
||||
it("pushes an inline encrypted sync object from an approved current device", async () => {
|
||||
const payload = bytes("encrypted tab payload");
|
||||
const payloadHash = sha256(payload);
|
||||
describe("retired sync push route", () => {
|
||||
it("rejects legacy object writes before D1 and R2 persistence", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({
|
||||
firstRows: [
|
||||
{ device_id: DEVICE_ID },
|
||||
null,
|
||||
syncObjectRow({ payload_hash: payloadHash }),
|
||||
],
|
||||
});
|
||||
|
||||
const 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");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import type { ElyR2Object, ElyR2PutOptions, Env } from "../src/bindings.js";
|
||||
import { recentDeviceActionProofBytes } from "../src/recent_device_action_proof.js";
|
||||
import {
|
||||
SYNC_R2_ANONYMIZE_USER_QUERY,
|
||||
SYNC_R2_FENCE_USER_QUERY,
|
||||
abandonSyncR2Write,
|
||||
claimSyncR2SnapshotWrite,
|
||||
collectSyncR2Garbage,
|
||||
} from "../src/sync_r2_gc.js";
|
||||
import { inventorySyncR2Objects } from "../src/sync_r2_inventory.js";
|
||||
import { syncResetDocument } from "../src/sync_reset.js";
|
||||
import { PUBLIC_KEY, signDeviceMessage } from "./devices_test_support.js";
|
||||
import { SqliteD1Database, execute, query } from "./sqlite_d1_test_support.js";
|
||||
|
||||
const USER_ID = "user-01";
|
||||
const DEVICE_ID = "device-01";
|
||||
const KEY_ID = "1".repeat(64);
|
||||
const OWNER_HASH = createHash("sha256").update(USER_ID).digest("hex");
|
||||
const HASH_A = "a".repeat(64);
|
||||
const HASH_B = "b".repeat(64);
|
||||
const TOKEN_A = "c".repeat(64);
|
||||
const TOKEN_B = "d".repeat(64);
|
||||
const NOW = 1_800_000_000;
|
||||
const MIGRATIONS_DIR = join(process.cwd(), "migrations");
|
||||
|
||||
describe("sync R2 GC SQLite state machine", () => {
|
||||
it("commits a leased candidate and deletes it only after D1 references are fenced", async () => {
|
||||
await withDatabase(async (databasePath, database, bucket, env) => {
|
||||
const key = snapshotKey(HASH_A);
|
||||
const lease = await claimSyncR2SnapshotWrite(env, snapshotClaim(key), NOW, TOKEN_A);
|
||||
await bucket.put(key, bytes("ciphertext-a"));
|
||||
commitGenesis(databasePath, key, HASH_A, lease.writeToken);
|
||||
|
||||
assert.equal(candidateState(databasePath, key), "referenced");
|
||||
assert.equal(await collectSyncR2Garbage(env, NOW + 100_000), 0);
|
||||
|
||||
await database.batch([
|
||||
database.prepare(SYNC_R2_FENCE_USER_QUERY).bind(NOW + 1, NOW + 1, NOW + 1, USER_ID),
|
||||
database.prepare("DELETE FROM sync_snapshot_heads WHERE user_id = ?").bind(USER_ID),
|
||||
database.prepare("DELETE FROM sync_snapshot_encryption WHERE user_id = ?").bind(USER_ID),
|
||||
database.prepare("DELETE FROM sync_snapshots WHERE user_id = ?").bind(USER_ID),
|
||||
]);
|
||||
assert.equal(candidateState(databasePath, key), "ready");
|
||||
assert.equal(await collectSyncR2Garbage(env, NOW + 1), 1);
|
||||
assert.equal(candidateState(databasePath, key), "deleted");
|
||||
assert.deepEqual(bucket.deletes, [key]);
|
||||
});
|
||||
});
|
||||
|
||||
it("turns a CAS loser into an immediately collectible ready candidate", async () => {
|
||||
await withDatabase(async (databasePath, _database, bucket, env) => {
|
||||
const key = snapshotKey(HASH_A);
|
||||
const lease = await claimSyncR2SnapshotWrite(env, snapshotClaim(key), NOW, TOKEN_A);
|
||||
await bucket.put(key, bytes("ciphertext-a"));
|
||||
|
||||
await abandonSyncR2Write(env, USER_ID, OWNER_HASH, key, lease.writeToken, NOW + 1);
|
||||
|
||||
assert.equal(candidateState(databasePath, key), "ready");
|
||||
assert.equal(await collectSyncR2Garbage(env, NOW + 1), 1);
|
||||
assert.equal(candidateState(databasePath, key), "deleted");
|
||||
assert.deepEqual(bucket.deletes, [key]);
|
||||
});
|
||||
});
|
||||
|
||||
it("resets sync state while preserving the vault generation and device envelopes", async () => {
|
||||
await withDatabase(async (databasePath, _database, bucket, env) => {
|
||||
seedVaultEnvelope(databasePath);
|
||||
const key = snapshotKey(HASH_A);
|
||||
const lease = await claimSyncR2SnapshotWrite(env, snapshotClaim(key), NOW, TOKEN_A);
|
||||
await bucket.put(key, bytes("ciphertext-a"));
|
||||
commitGenesis(databasePath, key, HASH_A, lease.writeToken);
|
||||
|
||||
const document = await syncResetDocument(
|
||||
await resetRequest("sync-reset-000001", NOW + 1),
|
||||
env,
|
||||
authContext(),
|
||||
NOW + 1,
|
||||
);
|
||||
|
||||
assert.equal(document.deleted.snapshots, 1);
|
||||
assert.equal(document.deleted.r2_objects, 1);
|
||||
assert.deepEqual(query(databasePath, `
|
||||
SELECT current_key_id, current_generation FROM sync_vault_accounts
|
||||
WHERE user_id = '${USER_ID}'
|
||||
`), [{ current_key_id: KEY_ID, current_generation: 1 }]);
|
||||
assert.equal(query(databasePath, `
|
||||
SELECT COUNT(*) AS count FROM sync_vault_envelopes WHERE user_id = '${USER_ID}'
|
||||
`)[0]?.count, 1);
|
||||
assert.equal(query(databasePath, `
|
||||
SELECT COUNT(*) AS count FROM user_devices WHERE user_id = '${USER_ID}'
|
||||
`)[0]?.count, 1);
|
||||
assert.equal(query(databasePath, `
|
||||
SELECT COUNT(*) AS count FROM sync_snapshots WHERE user_id = '${USER_ID}'
|
||||
`)[0]?.count, 0);
|
||||
assert.equal(candidateState(databasePath, key), "deleted");
|
||||
assert.deepEqual(bucket.deletes, [key]);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a fenced pending lease until a late R2 put can be collected", async () => {
|
||||
await withDatabase(async (databasePath, _database, bucket, env) => {
|
||||
const key = snapshotKey(HASH_A);
|
||||
await claimSyncR2SnapshotWrite(env, snapshotClaim(key), NOW, TOKEN_A);
|
||||
await syncResetDocument(
|
||||
await resetRequest("sync-reset-000002", NOW + 1),
|
||||
env,
|
||||
authContext(),
|
||||
NOW + 1,
|
||||
);
|
||||
|
||||
assert.equal(candidateState(databasePath, key), "ready");
|
||||
assert.equal(await collectSyncR2Garbage(env, NOW + 1, { userId: USER_ID }), 0);
|
||||
await bucket.put(key, bytes("late-pending-ciphertext"));
|
||||
assert.equal(await collectSyncR2Garbage(env, NOW + 600, { userId: USER_ID }), 1);
|
||||
assert.equal(candidateState(databasePath, key), "deleted");
|
||||
assert.equal(bucket.has(key), false);
|
||||
assert.deepEqual(bucket.deletes, [key]);
|
||||
assert.equal(query(databasePath, `
|
||||
SELECT COUNT(*) AS count FROM sync_vault_accounts WHERE user_id = '${USER_ID}'
|
||||
`)[0]?.count, 1);
|
||||
});
|
||||
});
|
||||
|
||||
it("rolls back reset when its authenticated authority changes before the batch", async () => {
|
||||
for (const beforeBatchSql of [
|
||||
"DELETE FROM better_auth_session WHERE id = 'session-01';",
|
||||
`UPDATE user_devices SET revoked_at = ${NOW} WHERE device_id = '${DEVICE_ID}';`,
|
||||
]) {
|
||||
await withDatabase(async (databasePath, _database, bucket, env) => {
|
||||
const key = snapshotKey(HASH_A);
|
||||
const lease = await claimSyncR2SnapshotWrite(env, snapshotClaim(key), NOW, TOKEN_A);
|
||||
await bucket.put(key, bytes("ciphertext-a"));
|
||||
commitGenesis(databasePath, key, HASH_A, lease.writeToken);
|
||||
const request = await resetRequest("sync-reset-authority-race", NOW + 1);
|
||||
const racedEnv = {
|
||||
...env,
|
||||
ELY_DB: new SqliteD1Database(databasePath, beforeBatchSql),
|
||||
} as Env;
|
||||
|
||||
await assert.rejects(
|
||||
() => syncResetDocument(request, racedEnv, authContext(), NOW + 1),
|
||||
/device_action_gate_failed/,
|
||||
);
|
||||
|
||||
assert.deepEqual(query(databasePath, `SELECT
|
||||
(SELECT COUNT(*) FROM sync_snapshots WHERE user_id = '${USER_ID}') AS snapshots,
|
||||
(SELECT COUNT(*) FROM sync_snapshot_encryption WHERE user_id = '${USER_ID}') AS encryption,
|
||||
(SELECT COUNT(*) FROM sync_snapshot_heads WHERE user_id = '${USER_ID}') AS heads,
|
||||
(SELECT COUNT(*) FROM audit_events WHERE event_type = 'sync.reset') AS audits
|
||||
`), [{ snapshots: 1, encryption: 1, heads: 1, audits: 0 }]);
|
||||
assert.equal(candidateState(databasePath, key), "referenced");
|
||||
assert.equal(bucket.has(key), true);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("retries an idempotent R2 deletion after a crash before D1 finalization", async () => {
|
||||
await withDatabase(async (databasePath, _database, bucket, env) => {
|
||||
const key = snapshotKey(HASH_A);
|
||||
const lease = await claimSyncR2SnapshotWrite(env, snapshotClaim(key), NOW, TOKEN_A);
|
||||
await bucket.put(key, bytes("ciphertext-a"));
|
||||
await abandonSyncR2Write(env, USER_ID, OWNER_HASH, key, lease.writeToken, NOW + 1);
|
||||
bucket.crashAfterNextDelete = true;
|
||||
|
||||
await assert.rejects(() => collectSyncR2Garbage(env, NOW + 1), /simulated_delete_crash/);
|
||||
assert.equal(candidateState(databasePath, key), "deleting");
|
||||
assert.equal(bucket.has(key), false);
|
||||
|
||||
assert.equal(await collectSyncR2Garbage(env, NOW + 62), 1);
|
||||
assert.equal(candidateState(databasePath, key), "deleted");
|
||||
assert.deepEqual(bucket.deletes, [key, key]);
|
||||
});
|
||||
});
|
||||
|
||||
it("fences an upload that overlaps account deletion and clears the raw owner id", async () => {
|
||||
await withDatabase(async (databasePath, database, bucket, env) => {
|
||||
const key = snapshotKey(HASH_A);
|
||||
const lease = await claimSyncR2SnapshotWrite(env, snapshotClaim(key), NOW, TOKEN_A);
|
||||
await database.batch([
|
||||
database.prepare(SYNC_R2_FENCE_USER_QUERY).bind(NOW + 1, NOW + 1, NOW + 1, USER_ID),
|
||||
database.prepare(SYNC_R2_ANONYMIZE_USER_QUERY).bind(NOW + 1, USER_ID, OWNER_HASH),
|
||||
]);
|
||||
assert.equal(await collectSyncR2Garbage(env, NOW + 1, { ownerHash: OWNER_HASH }), 0);
|
||||
await bucket.put(key, bytes("late-ciphertext"));
|
||||
|
||||
assert.throws(
|
||||
() => commitGenesis(databasePath, key, HASH_A, lease.writeToken),
|
||||
/sync_r2_write_fenced/,
|
||||
);
|
||||
assert.deepEqual(candidateOwner(databasePath, key), {
|
||||
user_id: null,
|
||||
owner_hash: OWNER_HASH,
|
||||
state: "ready",
|
||||
});
|
||||
assert.equal(await collectSyncR2Garbage(
|
||||
env,
|
||||
lease.leaseExpiresAt,
|
||||
{ ownerHash: OWNER_HASH },
|
||||
), 1);
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a pre-put claim after reset removes the vault authority", async () => {
|
||||
await withDatabase(async (_databasePath, database, _bucket, env) => {
|
||||
await database.prepare("DELETE FROM sync_vault_accounts WHERE user_id = ?")
|
||||
.bind(USER_ID)
|
||||
.run();
|
||||
|
||||
await assert.rejects(
|
||||
() => claimSyncR2SnapshotWrite(
|
||||
env,
|
||||
snapshotClaim(snapshotKey(HASH_A)),
|
||||
NOW,
|
||||
TOKEN_A,
|
||||
),
|
||||
/sync_r2_write_fenced/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("inventories historical snapshot and sync-payload orphans", async () => {
|
||||
await withDatabase(async (databasePath, _database, bucket, env) => {
|
||||
const snapshot = snapshotKey(HASH_A);
|
||||
const payload = payloadKey(HASH_B);
|
||||
await bucket.put(snapshot, bytes("snapshot-orphan"));
|
||||
await bucket.put(payload, bytes("payload-orphan"));
|
||||
|
||||
assert.equal(await inventorySyncR2Objects(env, NOW, 100), 1);
|
||||
assert.equal(await inventorySyncR2Objects(env, NOW + 1, 100), 1);
|
||||
assert.equal(candidateState(databasePath, snapshot), "ready");
|
||||
assert.equal(candidateState(databasePath, payload), "ready");
|
||||
assert.equal(await collectSyncR2Garbage(env, NOW + 1), 2);
|
||||
assert.deepEqual(bucket.deletes.sort(), [payload, snapshot].sort());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function withDatabase(
|
||||
assertions: (
|
||||
databasePath: string,
|
||||
database: SqliteD1Database,
|
||||
bucket: TestBucket,
|
||||
env: Env,
|
||||
) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "ely-r2-gc-"));
|
||||
try {
|
||||
const databasePath = join(tempDir, "ely.db");
|
||||
for (const fileName of readdirSync(MIGRATIONS_DIR).filter((name) => name.endsWith(".sql")).sort()) {
|
||||
execute(databasePath, readFileSync(join(MIGRATIONS_DIR, fileName), "utf8"));
|
||||
}
|
||||
seedAuthority(databasePath);
|
||||
const database = new SqliteD1Database(databasePath);
|
||||
const bucket = new TestBucket();
|
||||
const env = { ELY_DB: database, ELY_STORAGE: bucket } as unknown as Env;
|
||||
await assertions(databasePath, database, bucket, env);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function seedAuthority(databasePath: string): void {
|
||||
execute(databasePath, `
|
||||
INSERT INTO better_auth_user (
|
||||
id, name, email, emailVerified, createdAt, updatedAt
|
||||
) VALUES (
|
||||
'${USER_ID}', 'User', 'user@example.com', 1,
|
||||
'2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z'
|
||||
);
|
||||
INSERT INTO user_devices (
|
||||
user_id, device_id, public_key, device_name, platform,
|
||||
approval_status, created_at, approved_at, last_active_at, revoked_at, idempotency_key
|
||||
) VALUES (
|
||||
'${USER_ID}', '${DEVICE_ID}', '${PUBLIC_KEY}', 'Mac', 'macOS',
|
||||
'approved', 1, 1, 1, NULL, 'device-register-0001'
|
||||
);
|
||||
INSERT INTO user_device_keys (
|
||||
user_id, device_id, signing_public_key, wrapping_public_key,
|
||||
key_protocol_version, created_at
|
||||
) VALUES (
|
||||
'${USER_ID}', '${DEVICE_ID}', '${PUBLIC_KEY}', '${"f".repeat(64)}', 2, 1
|
||||
);
|
||||
INSERT INTO better_auth_session (
|
||||
id, expiresAt, token, createdAt, updatedAt, userId
|
||||
) VALUES (
|
||||
'session-01', '2099-01-01T00:00:00Z', 'session-token-01',
|
||||
'2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', '${USER_ID}'
|
||||
);
|
||||
INSERT INTO better_auth_session_device_context (
|
||||
session_id, user_id, device_id, updated_at
|
||||
) VALUES ('session-01', '${USER_ID}', '${DEVICE_ID}', 1);
|
||||
INSERT INTO sync_vault_accounts (
|
||||
user_id, current_key_id, current_generation, created_at, updated_at
|
||||
) VALUES ('${USER_ID}', '${KEY_ID}', 1, 1, 1);
|
||||
`);
|
||||
}
|
||||
|
||||
function seedVaultEnvelope(databasePath: string): void {
|
||||
execute(databasePath, `
|
||||
INSERT INTO sync_vault_envelopes (
|
||||
user_id, recipient_device_id, approver_device_id, key_id, generation,
|
||||
envelope_version, suite, encapped_key, ciphertext, idempotency_key, created_at
|
||||
) VALUES (
|
||||
'${USER_ID}', '${DEVICE_ID}', '${DEVICE_ID}', '${KEY_ID}', 1, 1,
|
||||
'HPKE-BASE-X25519-HKDF-SHA256-CHACHA20POLY1305',
|
||||
'${"A".repeat(43)}', '${"B".repeat(64)}', 'vault-bootstrap-0001', 1
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
function commitGenesis(databasePath: string, r2Key: string, payloadHash: string, token: string): void {
|
||||
execute(databasePath, `
|
||||
BEGIN IMMEDIATE;
|
||||
INSERT INTO sync_snapshots (
|
||||
user_id, snapshot_id, r2_key, payload_hash, schema_rev, logical_clock,
|
||||
device_id, size_bytes, created_at, head_revision,
|
||||
base_head_revision, base_snapshot_id, base_payload_hash
|
||||
) VALUES (
|
||||
'${USER_ID}', '${DEVICE_ID}', '${r2Key}', '${payloadHash}', 1, 1,
|
||||
'${DEVICE_ID}', 12, ${NOW}, 1, NULL, NULL, NULL
|
||||
);
|
||||
INSERT INTO sync_snapshot_encryption (
|
||||
user_id, snapshot_id, encryption_version, vault_generation, key_id, content_hash
|
||||
) VALUES ('${USER_ID}', '${DEVICE_ID}', 2, 1, '${KEY_ID}', '${HASH_B}');
|
||||
INSERT INTO sync_snapshot_heads (
|
||||
user_id, head_revision, snapshot_id, payload_hash, updated_at
|
||||
) VALUES ('${USER_ID}', 1, '${DEVICE_ID}', '${payloadHash}', ${NOW});
|
||||
UPDATE sync_r2_gc_candidates
|
||||
SET state = 'referenced', lease_expires_at = ${NOW},
|
||||
updated_at = ${NOW}, referenced_at = ${NOW}
|
||||
WHERE r2_key = '${r2Key}' AND user_id = '${USER_ID}'
|
||||
AND state = 'pending' AND write_token = '${token}'
|
||||
AND lease_expires_at >= ${NOW};
|
||||
COMMIT;
|
||||
`);
|
||||
}
|
||||
|
||||
function snapshotClaim(r2Key: string) {
|
||||
return {
|
||||
userId: USER_ID,
|
||||
deviceId: DEVICE_ID,
|
||||
r2Key,
|
||||
ownerHash: OWNER_HASH,
|
||||
keyId: KEY_ID,
|
||||
generation: 1,
|
||||
headRevision: 1,
|
||||
baseHead: null,
|
||||
} as const;
|
||||
}
|
||||
|
||||
function authContext() {
|
||||
return {
|
||||
userId: USER_ID,
|
||||
deviceId: DEVICE_ID,
|
||||
sessionId: "session-01",
|
||||
tokenHash: HASH_B,
|
||||
expiresAt: "2099-01-01T00:00:00Z",
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
} as const;
|
||||
}
|
||||
|
||||
async function resetRequest(idempotencyKey: string, proofCreatedAt: number): Promise<Request> {
|
||||
const confirmation = "delete-cloud-sync-data";
|
||||
const actionProof = await signDeviceMessage(recentDeviceActionProofBytes({
|
||||
action: "sync.reset",
|
||||
userId: USER_ID,
|
||||
sessionId: authContext().sessionId,
|
||||
deviceId: DEVICE_ID,
|
||||
confirmation,
|
||||
idempotencyKey,
|
||||
proofCreatedAt,
|
||||
}));
|
||||
return new Request("https://elydora.test/api/sync/reset", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
version: 2,
|
||||
confirmation,
|
||||
idempotency_key: idempotencyKey,
|
||||
proof_created_at: proofCreatedAt,
|
||||
action_proof: actionProof,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function candidateState(databasePath: string, key: string): unknown {
|
||||
return query(databasePath, `
|
||||
SELECT state FROM sync_r2_gc_candidates WHERE r2_key = '${key}'
|
||||
`)[0]?.state;
|
||||
}
|
||||
|
||||
function candidateOwner(databasePath: string, key: string): Record<string, unknown> | undefined {
|
||||
return query(databasePath, `
|
||||
SELECT user_id, owner_hash, state FROM sync_r2_gc_candidates WHERE r2_key = '${key}'
|
||||
`)[0];
|
||||
}
|
||||
|
||||
function snapshotKey(hash: string): string {
|
||||
return `sync-snapshots/us-east/${OWNER_HASH}/${DEVICE_ID}/${hash}.bin`;
|
||||
}
|
||||
|
||||
function payloadKey(hash: string): string {
|
||||
return `sync-payloads/us-east/${OWNER_HASH}/bookmarks/object-01/${hash}.bin`;
|
||||
}
|
||||
|
||||
function bytes(value: string): ArrayBuffer {
|
||||
return new TextEncoder().encode(value).buffer;
|
||||
}
|
||||
|
||||
class TestBucket {
|
||||
readonly deletes: string[] = [];
|
||||
crashAfterNextDelete = false;
|
||||
private readonly values = new Map<string, ArrayBuffer>();
|
||||
|
||||
get(key: string): Promise<ElyR2Object | null> {
|
||||
const value = this.values.get(key);
|
||||
return Promise.resolve(value === undefined ? null : object(value));
|
||||
}
|
||||
|
||||
put(key: string, value: ArrayBuffer, _options?: ElyR2PutOptions): Promise<ElyR2Object> {
|
||||
this.values.set(key, value);
|
||||
return Promise.resolve(object(value));
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
this.deletes.push(key);
|
||||
this.values.delete(key);
|
||||
if (this.crashAfterNextDelete) {
|
||||
this.crashAfterNextDelete = false;
|
||||
throw new Error("simulated_delete_crash");
|
||||
}
|
||||
}
|
||||
|
||||
list(options: { prefix: string; cursor?: string; limit: number }) {
|
||||
const keys = [...this.values.keys()].filter((key) => key.startsWith(options.prefix)).sort();
|
||||
return Promise.resolve({
|
||||
objects: keys.slice(0, options.limit).map((key) => ({ key })),
|
||||
truncated: false as const,
|
||||
});
|
||||
}
|
||||
|
||||
has(key: string): boolean {
|
||||
return this.values.has(key);
|
||||
}
|
||||
}
|
||||
|
||||
function object(value: ArrayBuffer): ElyR2Object {
|
||||
return { arrayBuffer: () => Promise.resolve(value) };
|
||||
}
|
||||
@@ -4,7 +4,15 @@ import { describe, it } from "node:test";
|
||||
|
||||
import { authSessionCacheKvKey, authTokenHash } from "../src/auth.js";
|
||||
import { handleRequest } from "../src/index.js";
|
||||
import { ACCESS_TOKEN, sessionDocument, testD1Database, testEnv } from "./devices_test_support.js";
|
||||
import { recentDeviceActionProofBytes } from "../src/recent_device_action_proof.js";
|
||||
import {
|
||||
ACCESS_TOKEN,
|
||||
PUBLIC_KEY,
|
||||
sessionDocument,
|
||||
signDeviceMessage,
|
||||
testD1Database,
|
||||
testEnv,
|
||||
} from "./devices_test_support.js";
|
||||
|
||||
const USER_ID = "user-01";
|
||||
const DEVICE_ID = "device-01";
|
||||
@@ -19,12 +27,17 @@ describe("sync reset routes", () => {
|
||||
const r2Deletes: string[] = [];
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({
|
||||
firstRows: [{ device_id: DEVICE_ID }, null, resetCountsRow()],
|
||||
firstRows: [
|
||||
{ device_id: DEVICE_ID },
|
||||
{ signing_public_key: PUBLIC_KEY },
|
||||
null,
|
||||
resetCountsRow(),
|
||||
],
|
||||
allRows: [{ r2_key: PAYLOAD_KEY }, { r2_key: SNAPSHOT_KEY }],
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
syncResetRequest(syncResetBody()),
|
||||
syncResetRequest(await syncResetBody()),
|
||||
testEnv({
|
||||
d1,
|
||||
r2Deletes,
|
||||
@@ -55,16 +68,31 @@ describe("sync reset routes", () => {
|
||||
r2_objects: 2,
|
||||
});
|
||||
assert.deepEqual(r2Deletes, [PAYLOAD_KEY, SNAPSHOT_KEY]);
|
||||
assert.equal(d1.batches[0], 5);
|
||||
assert.ok(d1.queries[1]?.includes("FROM audit_events"));
|
||||
assert.ok(d1.queries[2]?.includes("FROM sync_objects"));
|
||||
assert.ok(d1.queries[3]?.includes("UNION"));
|
||||
assert.ok(d1.queries[4]?.includes("DELETE FROM sync_change_log"));
|
||||
assert.ok(d1.queries[8]?.includes("INSERT INTO audit_events"));
|
||||
assert.deepEqual(d1.binds[1], [USER_ID, syncResetEventId()]);
|
||||
assert.deepEqual(d1.binds[2], [USER_ID, USER_ID, USER_ID, USER_ID]);
|
||||
assert.deepEqual(d1.binds[3], [USER_ID, USER_ID]);
|
||||
assert.deepEqual(d1.binds[8]?.slice(0, 4), [syncResetEventId(), USER_ID, DEVICE_ID, USER_ID]);
|
||||
assert.equal(d1.batches[0], 9);
|
||||
assert.ok(d1.queries[1]?.includes("signing_public_key"));
|
||||
assert.ok(d1.queries[2]?.includes("FROM audit_events"));
|
||||
assert.ok(d1.queries[3]?.includes("FROM sync_objects"));
|
||||
assert.ok(d1.queries[4]?.includes("FROM sync_r2_gc_candidates"));
|
||||
assert.deepEqual(d1.binds[2], [USER_ID, syncResetEventId()]);
|
||||
assert.deepEqual(d1.binds[3], [USER_ID, USER_ID, USER_ID, USER_ID]);
|
||||
assert.deepEqual(d1.binds[4], [USER_ID]);
|
||||
assert.ok(d1.queries[5]?.includes("CASE WHEN EXISTS"));
|
||||
assert.ok(d1.queries[6]?.includes("UPDATE sync_r2_gc_candidates"));
|
||||
assert.ok(d1.queries[7]?.includes("UPDATE sync_vault_rotations"));
|
||||
assert.ok(d1.queries[8]?.includes("DELETE FROM sync_change_log"));
|
||||
assert.ok(d1.queries[10]?.includes("DELETE FROM sync_snapshot_heads"));
|
||||
assert.ok(d1.queries[11]?.includes("DELETE FROM sync_snapshot_encryption"));
|
||||
assert.ok(d1.queries[12]?.includes("DELETE FROM sync_snapshots"));
|
||||
assert.equal(d1.queries.some((query) => query.includes("DELETE FROM sync_vault")), false);
|
||||
assert.deepEqual(d1.binds[5]?.slice(0, 6), [
|
||||
syncResetEventId(),
|
||||
USER_ID,
|
||||
DEVICE_ID,
|
||||
"sync.reset",
|
||||
"sync",
|
||||
USER_ID,
|
||||
]);
|
||||
assert.equal(d1.binds[5]?.[11], PUBLIC_KEY);
|
||||
});
|
||||
|
||||
it("returns an idempotent reset document for existing audit events", async () => {
|
||||
@@ -73,13 +101,14 @@ describe("sync reset routes", () => {
|
||||
const d1 = testD1Database({
|
||||
firstRows: [
|
||||
{ device_id: DEVICE_ID },
|
||||
{ signing_public_key: PUBLIC_KEY },
|
||||
{ actor_device_id: DEVICE_ID, outcome: "success", created_at: 1_780_001_000 },
|
||||
],
|
||||
allRows: [{ r2_key: PAYLOAD_KEY }],
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
syncResetRequest(syncResetBody()),
|
||||
syncResetRequest(await syncResetBody()),
|
||||
testEnv({
|
||||
d1,
|
||||
r2Deletes,
|
||||
@@ -96,8 +125,8 @@ describe("sync reset routes", () => {
|
||||
reset_at: 1_780_001_000,
|
||||
deleted: { objects: 0, changes: 0, snapshots: 0, tombstones: 0, r2_objects: 0 },
|
||||
});
|
||||
assert.deepEqual(r2Deletes, []);
|
||||
assert.equal(d1.queries.length, 2);
|
||||
assert.deepEqual(r2Deletes, [PAYLOAD_KEY]);
|
||||
assert.equal(d1.queries.length, 6);
|
||||
assert.deepEqual(d1.batches, []);
|
||||
});
|
||||
|
||||
@@ -107,12 +136,13 @@ describe("sync reset routes", () => {
|
||||
const d1 = testD1Database({
|
||||
firstRows: [
|
||||
{ device_id: DEVICE_ID },
|
||||
{ signing_public_key: PUBLIC_KEY },
|
||||
{ actor_device_id: "device-02", outcome: "success", created_at: 1_780_001_000 },
|
||||
],
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
syncResetRequest(syncResetBody()),
|
||||
syncResetRequest(await syncResetBody()),
|
||||
testEnv({
|
||||
d1,
|
||||
r2Deletes,
|
||||
@@ -132,7 +162,7 @@ describe("sync reset routes", () => {
|
||||
const d1 = testD1Database({ firstRows: [{ device_id: DEVICE_ID }] });
|
||||
|
||||
const response = await handleRequest(
|
||||
syncResetRequest(syncResetBody({ confirmation: "delete" })),
|
||||
syncResetRequest(await syncResetBody({ confirmation: "delete" })),
|
||||
testEnv({
|
||||
d1,
|
||||
r2Deletes,
|
||||
@@ -152,7 +182,7 @@ describe("sync reset routes", () => {
|
||||
const d1 = testD1Database({ firstRows: [null] });
|
||||
|
||||
const response = await handleRequest(
|
||||
syncResetRequest(syncResetBody()),
|
||||
syncResetRequest(await syncResetBody()),
|
||||
testEnv({
|
||||
d1,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
|
||||
@@ -165,16 +195,21 @@ describe("sync reset routes", () => {
|
||||
assert.deepEqual(d1.batches, []);
|
||||
});
|
||||
|
||||
it("fails closed when stored R2 keys are malformed", async () => {
|
||||
it("keeps reset successful when scheduled GC must handle a malformed legacy key", async () => {
|
||||
const r2Deletes: string[] = [];
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({
|
||||
firstRows: [{ device_id: DEVICE_ID }, null, resetCountsRow()],
|
||||
firstRows: [
|
||||
{ device_id: DEVICE_ID },
|
||||
{ signing_public_key: PUBLIC_KEY },
|
||||
null,
|
||||
resetCountsRow(),
|
||||
],
|
||||
allRows: [{ r2_key: "sync-snapshots/../bad.bin" }],
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
syncResetRequest(syncResetBody()),
|
||||
syncResetRequest(await syncResetBody()),
|
||||
testEnv({
|
||||
d1,
|
||||
r2Deletes,
|
||||
@@ -182,10 +217,9 @@ describe("sync reset routes", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 500);
|
||||
assert.deepEqual(await response.json(), { error: "sync_reset_failed" });
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(r2Deletes, []);
|
||||
assert.deepEqual(d1.batches, []);
|
||||
assert.deepEqual(d1.batches, [9]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -200,13 +234,26 @@ function syncResetRequest(body: Record<string, unknown>): Request {
|
||||
});
|
||||
}
|
||||
|
||||
function syncResetBody(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
version: 1,
|
||||
async function syncResetBody(
|
||||
overrides: Record<string, unknown> = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
const body: Record<string, unknown> = {
|
||||
version: 2,
|
||||
confirmation: "delete-cloud-sync-data",
|
||||
idempotency_key: IDEMPOTENCY_KEY,
|
||||
proof_created_at: Math.floor(Date.now() / 1000),
|
||||
...overrides,
|
||||
};
|
||||
body.action_proof = await signDeviceMessage(recentDeviceActionProofBytes({
|
||||
action: "sync.reset",
|
||||
userId: USER_ID,
|
||||
sessionId: "session-01",
|
||||
deviceId: DEVICE_ID,
|
||||
confirmation: String(body.confirmation),
|
||||
idempotencyKey: String(body.idempotency_key),
|
||||
proofCreatedAt: Number(body.proof_created_at),
|
||||
}));
|
||||
return body;
|
||||
}
|
||||
|
||||
function resetCountsRow(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import { payloadBytes, SyncSnapshotRequestError } from "../src/sync_snapshot_codec.js";
|
||||
|
||||
describe("sync snapshot codec", () => {
|
||||
it("rejects oversized base64 before decoding", () => {
|
||||
const originalAtob = globalThis.atob;
|
||||
let decoded = false;
|
||||
globalThis.atob = () => {
|
||||
decoded = true;
|
||||
return "";
|
||||
};
|
||||
try {
|
||||
assert.throws(
|
||||
() => payloadBytes("AAAAAAAA", "data_base64", 3),
|
||||
(error) =>
|
||||
error instanceof SyncSnapshotRequestError &&
|
||||
error.message === "data_base64_size_invalid",
|
||||
);
|
||||
} finally {
|
||||
globalThis.atob = originalAtob;
|
||||
}
|
||||
assert.equal(decoded, false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import type { AuthContext } from "../src/auth.js";
|
||||
import { syncSnapshotUploadDocument } from "../src/sync_snapshot.js";
|
||||
import { type RecordedR2Put, testEnv } from "./devices_test_support.js";
|
||||
import { SqliteD1Database, execute } from "./sqlite_d1_test_support.js";
|
||||
|
||||
const USER_ID = "user-01";
|
||||
const DEVICE_ID = "device-01";
|
||||
const KEY_ID = "1".repeat(64);
|
||||
const CONTENT_HASH = "2".repeat(64);
|
||||
const MIGRATIONS_DIR = join(process.cwd(), "migrations");
|
||||
|
||||
describe("sync snapshot handler real D1 flow", () => {
|
||||
it("commits genesis, same-id child, and exact duplicate through the five-statement batch", async () => {
|
||||
await withDatabase(async (databasePath) => {
|
||||
const d1 = new SqliteD1Database(databasePath);
|
||||
const r2Puts: RecordedR2Put[] = [];
|
||||
const env = testEnv({ d1, r2Puts });
|
||||
const genesisPayload = bytes("genesis ciphertext");
|
||||
const genesisHash = sha256(genesisPayload);
|
||||
const genesis = await syncSnapshotUploadDocument(
|
||||
request(body(genesisPayload, genesisHash, 1, null, 10)),
|
||||
env,
|
||||
authContext(),
|
||||
10,
|
||||
);
|
||||
const base = {
|
||||
revision: genesis.snapshot.head_revision,
|
||||
snapshot_id: genesis.snapshot.snapshot_id,
|
||||
payload_hash: genesis.snapshot.payload_hash,
|
||||
};
|
||||
const childPayload = bytes("child ciphertext");
|
||||
const childHash = sha256(childPayload);
|
||||
const childRequest = request(body(childPayload, childHash, 2, base, 11));
|
||||
const child = await syncSnapshotUploadDocument(childRequest, env, authContext(), 11);
|
||||
const duplicate = await syncSnapshotUploadDocument(
|
||||
request(body(childPayload, childHash, 2, base, 11)),
|
||||
env,
|
||||
authContext(),
|
||||
12,
|
||||
);
|
||||
|
||||
assert.equal(genesis.snapshot.head_revision, 1);
|
||||
assert.equal(child.snapshot.head_revision, 2);
|
||||
assert.deepEqual(child.snapshot.base_head, base);
|
||||
assert.deepEqual(duplicate, child);
|
||||
assert.deepEqual(d1.batches, [5, 5]);
|
||||
assert.deepEqual(d1.sessionConstraints, [
|
||||
"first-primary",
|
||||
"first-primary",
|
||||
"first-primary",
|
||||
"first-primary",
|
||||
"first-primary",
|
||||
"first-primary",
|
||||
]);
|
||||
assert.equal(r2Puts.length, 2);
|
||||
assert.deepEqual(d1.rows(`
|
||||
SELECT state, COUNT(*) AS count
|
||||
FROM sync_r2_gc_candidates
|
||||
WHERE user_id = '${USER_ID}' AND object_kind = 'snapshot'
|
||||
GROUP BY state
|
||||
ORDER BY state ASC
|
||||
`), [
|
||||
{ state: "ready", count: 1 },
|
||||
{ state: "referenced", count: 1 },
|
||||
]);
|
||||
assert.deepEqual(d1.rows(`
|
||||
SELECT head.head_revision, head.snapshot_id, head.payload_hash,
|
||||
snapshot.logical_clock, encryption.content_hash
|
||||
FROM sync_snapshot_heads AS head
|
||||
INNER JOIN sync_snapshots AS snapshot
|
||||
ON snapshot.user_id = head.user_id AND snapshot.snapshot_id = head.snapshot_id
|
||||
INNER JOIN sync_snapshot_encryption AS encryption
|
||||
ON encryption.user_id = snapshot.user_id
|
||||
AND encryption.snapshot_id = snapshot.snapshot_id
|
||||
WHERE head.user_id = '${USER_ID}'
|
||||
`), [{
|
||||
head_revision: 2,
|
||||
snapshot_id: DEVICE_ID,
|
||||
payload_hash: childHash,
|
||||
logical_clock: 11,
|
||||
content_hash: CONTENT_HASH,
|
||||
}]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function withDatabase(assertions: (databasePath: string) => Promise<void>): Promise<void> {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "ely-snapshot-handler-"));
|
||||
try {
|
||||
const databasePath = join(tempDir, "ely.db");
|
||||
for (const fileName of readdirSync(MIGRATIONS_DIR).filter((name) => name.endsWith(".sql")).sort()) {
|
||||
execute(databasePath, readFileSync(join(MIGRATIONS_DIR, fileName), "utf8"));
|
||||
}
|
||||
execute(databasePath, `
|
||||
INSERT INTO better_auth_user (
|
||||
id, name, email, emailVerified, createdAt, updatedAt
|
||||
) VALUES (
|
||||
'${USER_ID}', 'User', 'user@example.com', 1,
|
||||
'2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z'
|
||||
);
|
||||
INSERT INTO user_devices (
|
||||
user_id, device_id, public_key, device_name, platform,
|
||||
approval_status, created_at, approved_at, last_active_at, revoked_at, idempotency_key
|
||||
) VALUES (
|
||||
'${USER_ID}', '${DEVICE_ID}', '${"d".repeat(64)}', 'Mac', 'macOS',
|
||||
'approved', 1, 1, 1, NULL, 'device-register-0001'
|
||||
);
|
||||
INSERT INTO user_device_keys (
|
||||
user_id, device_id, signing_public_key, wrapping_public_key,
|
||||
key_protocol_version, created_at
|
||||
) VALUES (
|
||||
'${USER_ID}', '${DEVICE_ID}', '${"d".repeat(64)}', '${"e".repeat(64)}', 2, 1
|
||||
);
|
||||
INSERT INTO sync_vault_accounts (
|
||||
user_id, current_key_id, current_generation, created_at, updated_at
|
||||
) VALUES ('${USER_ID}', '${KEY_ID}', 1, 1, 1);
|
||||
`);
|
||||
await assertions(databasePath);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function body(
|
||||
payload: ArrayBuffer,
|
||||
payloadHash: string,
|
||||
headRevision: number,
|
||||
baseHead: Record<string, unknown> | null,
|
||||
logicalClock: number,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
version: 3,
|
||||
snapshot_id: DEVICE_ID,
|
||||
region: "us-east",
|
||||
payload_hash: payloadHash,
|
||||
encryption_version: 2,
|
||||
vault_generation: 1,
|
||||
key_id: KEY_ID,
|
||||
content_hash: CONTENT_HASH,
|
||||
schema_rev: 1,
|
||||
logical_clock: logicalClock,
|
||||
head_revision: headRevision,
|
||||
base_head: baseHead,
|
||||
data_base64: Buffer.from(payload).toString("base64"),
|
||||
};
|
||||
}
|
||||
|
||||
function request(value: Record<string, unknown>): Request {
|
||||
return new Request("https://elydora.test/api/sync/snapshot", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(value),
|
||||
});
|
||||
}
|
||||
|
||||
function authContext(): AuthContext {
|
||||
return {
|
||||
userId: USER_ID,
|
||||
sessionId: "session-01",
|
||||
tokenHash: "f".repeat(64),
|
||||
expiresAt: "2099-01-01T00:00:00.000Z",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
deviceId: DEVICE_ID,
|
||||
};
|
||||
}
|
||||
|
||||
function bytes(value: string): ArrayBuffer {
|
||||
return new TextEncoder().encode(value).buffer;
|
||||
}
|
||||
|
||||
function sha256(payload: ArrayBuffer): string {
|
||||
return createHash("sha256").update(new Uint8Array(payload)).digest("hex");
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { DatabaseSync, type SQLInputValue } from "node:sqlite";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import {
|
||||
SYNC_SNAPSHOT_CANDIDATE_UPSERT_QUERY,
|
||||
SYNC_SNAPSHOT_ENCRYPTION_UPSERT_QUERY,
|
||||
SYNC_SNAPSHOT_HEAD_INSERT_QUERY,
|
||||
SYNC_SNAPSHOT_HEAD_QUERY,
|
||||
SYNC_SNAPSHOT_HEAD_UPDATE_QUERY,
|
||||
} from "../src/sync_snapshot_sql.js";
|
||||
import { SYNC_R2_MARK_REFERENCED_QUERY } from "../src/sync_r2_gc.js";
|
||||
import {
|
||||
SyncSnapshotHeadSchemaError,
|
||||
type SyncSnapshotRow,
|
||||
snapshotDocumentFromRow,
|
||||
} from "../src/sync_snapshot_head.js";
|
||||
|
||||
const USER_ID = "user-01";
|
||||
const DEVICE_ID = "device-01";
|
||||
const KEY_ID = "1".repeat(64);
|
||||
const NEXT_KEY_ID = "2".repeat(64);
|
||||
const WRITE_TOKEN = "9".repeat(64);
|
||||
const MIGRATIONS_DIR = join(process.cwd(), "migrations");
|
||||
|
||||
interface HeadRef {
|
||||
revision: number;
|
||||
snapshotId: string;
|
||||
payloadHash: string;
|
||||
}
|
||||
|
||||
interface Candidate {
|
||||
snapshotId: string;
|
||||
payloadHash: string;
|
||||
contentHash: string;
|
||||
logicalClock: number;
|
||||
headRevision: number;
|
||||
base: HeadRef | null;
|
||||
keyId?: string;
|
||||
generation?: number;
|
||||
}
|
||||
|
||||
describe("sync snapshot head SQLite guards", () => {
|
||||
it("commits a genesis head through the route SQL", () => {
|
||||
using database = databaseWithApprovedDevice();
|
||||
const genesis = candidate({ payloadHash: "a".repeat(64) });
|
||||
|
||||
commitCandidate(database, genesis);
|
||||
|
||||
assert.deepEqual(currentHead(database), {
|
||||
head_revision: 1,
|
||||
snapshot_id: "device-01",
|
||||
payload_hash: "a".repeat(64),
|
||||
});
|
||||
});
|
||||
|
||||
it("allows one writer per base even when the stale writer has a higher clock", () => {
|
||||
using database = databaseWithApprovedDevice();
|
||||
const genesis = candidate({ payloadHash: "a".repeat(64) });
|
||||
commitCandidate(database, genesis);
|
||||
const base = headRef(genesis);
|
||||
const winner = candidate({
|
||||
payloadHash: "b".repeat(64),
|
||||
contentHash: "3".repeat(64),
|
||||
logicalClock: 11,
|
||||
headRevision: 2,
|
||||
base,
|
||||
});
|
||||
commitCandidate(database, winner);
|
||||
const loser = candidate({
|
||||
payloadHash: "c".repeat(64),
|
||||
contentHash: "4".repeat(64),
|
||||
logicalClock: 999,
|
||||
headRevision: 2,
|
||||
base,
|
||||
});
|
||||
|
||||
assert.throws(
|
||||
() => commitCandidate(database, loser),
|
||||
/sync_r2_write_fenced/,
|
||||
);
|
||||
assert.deepEqual(currentHead(database), {
|
||||
head_revision: 2,
|
||||
snapshot_id: "device-01",
|
||||
payload_hash: "b".repeat(64),
|
||||
});
|
||||
assert.deepEqual(snapshotState(database, "device-01"), {
|
||||
payload_hash: "b".repeat(64),
|
||||
content_hash: "3".repeat(64),
|
||||
logical_clock: 11,
|
||||
head_revision: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("rolls back candidate metadata when the final head guard aborts", () => {
|
||||
using database = databaseWithApprovedDevice();
|
||||
const genesis = candidate({ payloadHash: "a".repeat(64) });
|
||||
commitCandidate(database, genesis);
|
||||
const child = candidate({
|
||||
snapshotId: "device-02",
|
||||
payloadHash: "b".repeat(64),
|
||||
contentHash: "3".repeat(64),
|
||||
logicalClock: 11,
|
||||
headRevision: 2,
|
||||
base: headRef(genesis),
|
||||
});
|
||||
|
||||
assert.throws(
|
||||
() => commitCandidate(database, child, "f".repeat(64)),
|
||||
/sync_r2_write_fenced/,
|
||||
);
|
||||
assert.equal(snapshotState(database, "device-02"), undefined);
|
||||
assert.deepEqual(currentHead(database), {
|
||||
head_revision: 1,
|
||||
snapshot_id: "device-01",
|
||||
payload_hash: "a".repeat(64),
|
||||
});
|
||||
});
|
||||
|
||||
it("advances an old-generation base with the current rotated key", () => {
|
||||
using database = databaseWithApprovedDevice();
|
||||
const genesis = candidate({ payloadHash: "a".repeat(64) });
|
||||
commitCandidate(database, genesis);
|
||||
database.prepare(`
|
||||
UPDATE sync_vault_accounts
|
||||
SET current_key_id = ?, current_generation = 2, updated_at = 2
|
||||
WHERE user_id = ?
|
||||
`).run(NEXT_KEY_ID, USER_ID);
|
||||
const child = candidate({
|
||||
payloadHash: "b".repeat(64),
|
||||
contentHash: "3".repeat(64),
|
||||
logicalClock: 11,
|
||||
headRevision: 2,
|
||||
base: headRef(genesis),
|
||||
keyId: NEXT_KEY_ID,
|
||||
generation: 2,
|
||||
});
|
||||
|
||||
commitCandidate(database, child);
|
||||
|
||||
assert.deepEqual(snapshotState(database, DEVICE_ID), {
|
||||
payload_hash: "b".repeat(64),
|
||||
content_hash: "3".repeat(64),
|
||||
logical_clock: 11,
|
||||
head_revision: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces a current head whose encryption row is missing", () => {
|
||||
using database = databaseWithApprovedDevice();
|
||||
const genesis = candidate({ payloadHash: "a".repeat(64) });
|
||||
commitCandidate(database, genesis);
|
||||
const deletion = database.prepare(`
|
||||
DELETE FROM sync_snapshot_encryption
|
||||
WHERE user_id = ? AND snapshot_id = ?
|
||||
`);
|
||||
assert.throws(() => deletion.run(USER_ID, DEVICE_ID), /FOREIGN KEY constraint failed/);
|
||||
database.exec("PRAGMA foreign_keys = OFF");
|
||||
deletion.run(USER_ID, DEVICE_ID);
|
||||
database.exec("PRAGMA foreign_keys = ON");
|
||||
const row = database.prepare(SYNC_SNAPSHOT_HEAD_QUERY).get(USER_ID) as
|
||||
| SyncSnapshotRow
|
||||
| undefined;
|
||||
|
||||
assert.ok(row !== undefined);
|
||||
assert.throws(
|
||||
() => snapshotDocumentFromRow(row),
|
||||
(error) =>
|
||||
error instanceof SyncSnapshotHeadSchemaError &&
|
||||
error.message === "encryption_version_invalid",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function databaseWithApprovedDevice(): DatabaseSync {
|
||||
const database = new DatabaseSync(":memory:");
|
||||
database.exec("PRAGMA foreign_keys = ON");
|
||||
for (const fileName of readdirSync(MIGRATIONS_DIR).filter((name) => name.endsWith(".sql")).sort()) {
|
||||
database.exec(readFileSync(join(MIGRATIONS_DIR, fileName), "utf8"));
|
||||
}
|
||||
database.exec(`
|
||||
INSERT INTO better_auth_user (
|
||||
id, name, email, emailVerified, createdAt, updatedAt
|
||||
) VALUES (
|
||||
'${USER_ID}', 'User', 'user@example.com', 1,
|
||||
'2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z'
|
||||
);
|
||||
INSERT INTO user_devices (
|
||||
user_id, device_id, public_key, device_name, platform,
|
||||
approval_status, created_at, approved_at, last_active_at, revoked_at, idempotency_key
|
||||
) VALUES (
|
||||
'${USER_ID}', '${DEVICE_ID}', '${"d".repeat(64)}', 'Mac', 'macOS',
|
||||
'approved', 1, 1, 1, NULL, 'device-register-0001'
|
||||
);
|
||||
INSERT INTO user_device_keys (
|
||||
user_id, device_id, signing_public_key, wrapping_public_key,
|
||||
key_protocol_version, created_at
|
||||
) VALUES (
|
||||
'${USER_ID}', '${DEVICE_ID}', '${"d".repeat(64)}', '${"e".repeat(64)}', 2, 1
|
||||
);
|
||||
INSERT INTO sync_vault_accounts (
|
||||
user_id, current_key_id, current_generation, created_at, updated_at
|
||||
) VALUES ('${USER_ID}', '${KEY_ID}', 1, 1, 1);
|
||||
`);
|
||||
return database;
|
||||
}
|
||||
|
||||
function commitCandidate(
|
||||
database: DatabaseSync,
|
||||
value: Candidate,
|
||||
headPayloadHash = value.payloadHash,
|
||||
): void {
|
||||
const snapshotValues = candidateValues(value);
|
||||
const r2Key = candidateR2Key(value);
|
||||
database.prepare(`
|
||||
INSERT INTO sync_r2_gc_candidates (
|
||||
r2_key, user_id, owner_hash, object_kind, state, write_token,
|
||||
lease_expires_at, gc_token, created_at, updated_at, referenced_at,
|
||||
ready_at, delete_started_at, deleted_at
|
||||
) VALUES (?, ?, ?, 'snapshot', 'pending', ?, 1000, NULL, 0, 0, NULL, NULL, NULL, NULL)
|
||||
`).run(r2Key, USER_ID, "f".repeat(64), WRITE_TOKEN);
|
||||
database.exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
database.prepare(SYNC_SNAPSHOT_CANDIDATE_UPSERT_QUERY).run(
|
||||
...snapshotValues,
|
||||
value.keyId ?? KEY_ID,
|
||||
value.generation ?? 1,
|
||||
WRITE_TOKEN,
|
||||
value.headRevision,
|
||||
);
|
||||
database.prepare(SYNC_SNAPSHOT_ENCRYPTION_UPSERT_QUERY).run(
|
||||
...snapshotValues,
|
||||
2,
|
||||
value.generation ?? 1,
|
||||
value.keyId ?? KEY_ID,
|
||||
value.contentHash,
|
||||
WRITE_TOKEN,
|
||||
value.headRevision,
|
||||
);
|
||||
if (value.base === null) {
|
||||
database.prepare(SYNC_SNAPSHOT_HEAD_INSERT_QUERY).run(
|
||||
USER_ID,
|
||||
value.headRevision,
|
||||
value.snapshotId,
|
||||
headPayloadHash,
|
||||
value.headRevision,
|
||||
r2Key,
|
||||
USER_ID,
|
||||
WRITE_TOKEN,
|
||||
value.headRevision,
|
||||
);
|
||||
} else {
|
||||
database.prepare(SYNC_SNAPSHOT_HEAD_UPDATE_QUERY).run(
|
||||
value.headRevision,
|
||||
value.snapshotId,
|
||||
headPayloadHash,
|
||||
value.headRevision,
|
||||
USER_ID,
|
||||
r2Key,
|
||||
USER_ID,
|
||||
WRITE_TOKEN,
|
||||
value.headRevision,
|
||||
);
|
||||
}
|
||||
database.prepare(SYNC_R2_MARK_REFERENCED_QUERY).run(
|
||||
value.headRevision,
|
||||
value.headRevision,
|
||||
value.headRevision,
|
||||
r2Key,
|
||||
USER_ID,
|
||||
WRITE_TOKEN,
|
||||
value.headRevision,
|
||||
);
|
||||
database.exec("COMMIT");
|
||||
} catch (error) {
|
||||
database.exec("ROLLBACK");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function candidateValues(value: Candidate): SQLInputValue[] {
|
||||
return [
|
||||
USER_ID,
|
||||
value.snapshotId,
|
||||
candidateR2Key(value),
|
||||
value.payloadHash,
|
||||
1,
|
||||
value.logicalClock,
|
||||
DEVICE_ID,
|
||||
26,
|
||||
value.headRevision,
|
||||
value.headRevision,
|
||||
value.base?.revision ?? null,
|
||||
value.base?.snapshotId ?? null,
|
||||
value.base?.payloadHash ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
function candidateR2Key(value: Candidate): string {
|
||||
return `sync-snapshots/us-east/${"f".repeat(64)}/${value.snapshotId}/${value.payloadHash}.bin`;
|
||||
}
|
||||
|
||||
function candidate(overrides: Partial<Candidate>): Candidate {
|
||||
return {
|
||||
snapshotId: DEVICE_ID,
|
||||
payloadHash: "a".repeat(64),
|
||||
contentHash: "2".repeat(64),
|
||||
logicalClock: 10,
|
||||
headRevision: 1,
|
||||
base: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function headRef(value: Candidate): HeadRef {
|
||||
return {
|
||||
revision: value.headRevision,
|
||||
snapshotId: value.snapshotId,
|
||||
payloadHash: value.payloadHash,
|
||||
};
|
||||
}
|
||||
|
||||
function currentHead(database: DatabaseSync): Record<string, unknown> | undefined {
|
||||
const row = database.prepare(`
|
||||
SELECT head_revision, snapshot_id, payload_hash
|
||||
FROM sync_snapshot_heads
|
||||
WHERE user_id = ?
|
||||
`).get(USER_ID) as Record<string, unknown> | undefined;
|
||||
return row === undefined ? undefined : { ...row };
|
||||
}
|
||||
|
||||
function snapshotState(
|
||||
database: DatabaseSync,
|
||||
snapshotId: string,
|
||||
): Record<string, unknown> | undefined {
|
||||
const row = database.prepare(`
|
||||
SELECT
|
||||
snapshot.payload_hash,
|
||||
encryption.content_hash,
|
||||
snapshot.logical_clock,
|
||||
snapshot.head_revision
|
||||
FROM sync_snapshots AS snapshot
|
||||
INNER JOIN sync_snapshot_encryption AS encryption
|
||||
ON encryption.user_id = snapshot.user_id
|
||||
AND encryption.snapshot_id = snapshot.snapshot_id
|
||||
WHERE snapshot.user_id = ? AND snapshot.snapshot_id = ?
|
||||
`).get(USER_ID, snapshotId) as Record<string, unknown> | undefined;
|
||||
return row === undefined ? undefined : { ...row };
|
||||
}
|
||||
@@ -14,273 +14,372 @@ import {
|
||||
|
||||
const USER_ID = "user-01";
|
||||
const DEVICE_ID = "device-01";
|
||||
const SNAPSHOT_ID = "snapshot-01";
|
||||
const SNAPSHOT_ID = "device-01";
|
||||
const REGION = "us-east";
|
||||
const KEY_ID = "1".repeat(64);
|
||||
const CONTENT_HASH = "2".repeat(64);
|
||||
|
||||
describe("sync snapshot routes", () => {
|
||||
it("uploads an encrypted snapshot from an approved current device", async () => {
|
||||
const payload = bytes("encrypted snapshot payload");
|
||||
it("commits a genesis encrypted snapshot as the global head", async () => {
|
||||
const payload = opaqueEnvelopeBytes();
|
||||
const payloadHash = sha256(payload);
|
||||
const key = snapshotKey(payloadHash);
|
||||
const row = snapshotRow({
|
||||
r2_key: snapshotKey(payloadHash),
|
||||
payload_hash: payloadHash,
|
||||
size_bytes: payload.byteLength,
|
||||
});
|
||||
const r2Puts: RecordedR2Put[] = [];
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({
|
||||
firstRows: [
|
||||
{ device_id: DEVICE_ID },
|
||||
null,
|
||||
snapshotRow({ r2_key: key, payload_hash: payloadHash, size_bytes: payload.byteLength }),
|
||||
],
|
||||
firstRows: [{ device_id: DEVICE_ID }, null, vaultKeyRow()],
|
||||
batchRowSets: [[[], [], [], [], [row]]],
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
syncSnapshotPostRequest(
|
||||
syncSnapshotBody({ payload_hash: payloadHash, data_base64: base64(payload) }),
|
||||
),
|
||||
testEnv({
|
||||
d1,
|
||||
r2Puts,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
|
||||
}),
|
||||
syncSnapshotPostRequest(syncSnapshotBody({
|
||||
payload_hash: payloadHash,
|
||||
data_base64: base64(payload),
|
||||
})),
|
||||
await authorizedEnv(d1, { r2Puts }),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 201);
|
||||
assert.equal(response.headers.get("cache-control"), "no-store");
|
||||
assert.deepEqual(await response.json(), {
|
||||
version: 1,
|
||||
user_id: USER_ID,
|
||||
device_id: DEVICE_ID,
|
||||
snapshot: snapshotDocument({
|
||||
r2_key: key,
|
||||
payload_hash: payloadHash,
|
||||
size_bytes: payload.byteLength,
|
||||
}),
|
||||
});
|
||||
assert.deepEqual(await response.json(), uploadDocument(row));
|
||||
assert.equal(r2Puts.length, 1);
|
||||
assert.equal(r2Puts[0]?.key, key);
|
||||
assert.deepEqual(new Uint8Array(r2Puts[0]?.payload ?? new ArrayBuffer(0)), new Uint8Array(payload));
|
||||
assert.equal(r2Puts[0]?.options.customMetadata?.sha256, payloadHash);
|
||||
assert.equal(d1.batches[0], 1);
|
||||
assert.ok(d1.queries[1]?.includes("FROM sync_snapshots"));
|
||||
assert.ok(d1.queries[2]?.includes("INSERT INTO sync_snapshots"));
|
||||
assert.deepEqual(d1.binds[2]?.slice(0, 4), [USER_ID, SNAPSHOT_ID, key, payloadHash]);
|
||||
assert.equal(d1.batches[0], 5);
|
||||
assert.ok(d1.queries.some((query) => query.includes("INSERT INTO sync_snapshot_heads")));
|
||||
});
|
||||
|
||||
it("downloads an encrypted snapshot with R2 checksum verification", async () => {
|
||||
const payload = bytes("encrypted snapshot payload");
|
||||
it("returns the original success document for an exact replay", async () => {
|
||||
const payload = opaqueEnvelopeBytes();
|
||||
const payloadHash = sha256(payload);
|
||||
const key = snapshotKey(payloadHash);
|
||||
const r2Gets: string[] = [];
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const row = snapshotRow({
|
||||
r2_key: snapshotKey(payloadHash),
|
||||
payload_hash: payloadHash,
|
||||
size_bytes: payload.byteLength,
|
||||
});
|
||||
const r2Puts: RecordedR2Put[] = [];
|
||||
const d1 = testD1Database({
|
||||
firstRows: [{ device_id: DEVICE_ID }, row, vaultKeyRow()],
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
syncSnapshotPostRequest(syncSnapshotBody({
|
||||
payload_hash: payloadHash,
|
||||
data_base64: base64(payload),
|
||||
})),
|
||||
await authorizedEnv(d1, { r2Puts }),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 201);
|
||||
assert.deepEqual(await response.json(), uploadDocument(row));
|
||||
assert.deepEqual(d1.batches, []);
|
||||
assert.equal(r2Puts.length, 0);
|
||||
assert.ok(d1.queries.some((query) => query.includes("SET cleanup_snapshot_id = ?")));
|
||||
});
|
||||
|
||||
it("returns the original success after the vault rotates", async () => {
|
||||
const payload = opaqueEnvelopeBytes();
|
||||
const payloadHash = sha256(payload);
|
||||
const row = snapshotRow({
|
||||
r2_key: snapshotKey(payloadHash),
|
||||
payload_hash: payloadHash,
|
||||
size_bytes: payload.byteLength,
|
||||
});
|
||||
const r2Puts: RecordedR2Put[] = [];
|
||||
const d1 = testD1Database({
|
||||
firstRows: [
|
||||
{ device_id: DEVICE_ID },
|
||||
snapshotRow({ r2_key: key, payload_hash: payloadHash, size_bytes: payload.byteLength }),
|
||||
row,
|
||||
{ key_id: "f".repeat(64), generation: 2 },
|
||||
],
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
syncSnapshotGetRequest(),
|
||||
testEnv({
|
||||
d1,
|
||||
r2Gets,
|
||||
r2Objects: [[key, payload]],
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
|
||||
}),
|
||||
syncSnapshotPostRequest(syncSnapshotBody({
|
||||
payload_hash: payloadHash,
|
||||
data_base64: base64(payload),
|
||||
})),
|
||||
await authorizedEnv(d1, { r2Puts }),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 201);
|
||||
assert.deepEqual(await response.json(), uploadDocument(row));
|
||||
assert.equal(r2Puts.length, 0);
|
||||
assert.equal(d1.queries.some((query) => query.includes("SET cleanup_snapshot_id = ?")), false);
|
||||
});
|
||||
|
||||
it("returns the committed document when an identical concurrent writer wins", async () => {
|
||||
const payload = opaqueEnvelopeBytes();
|
||||
const payloadHash = sha256(payload);
|
||||
const baseRow = snapshotRow({ payload_hash: "a".repeat(64) });
|
||||
const committed = snapshotRow({
|
||||
payload_hash: payloadHash,
|
||||
r2_key: snapshotKey(payloadHash),
|
||||
content_hash: CONTENT_HASH,
|
||||
logical_clock: 43,
|
||||
head_revision: 2,
|
||||
base_head_revision: 1,
|
||||
base_snapshot_id: SNAPSHOT_ID,
|
||||
base_payload_hash: "a".repeat(64),
|
||||
size_bytes: payload.byteLength,
|
||||
});
|
||||
const d1 = testD1Database({
|
||||
firstRows: [{ device_id: DEVICE_ID }, baseRow, vaultKeyRow(), committed],
|
||||
batchError: new Error("sync_snapshot_head_cas_failed"),
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
syncSnapshotPostRequest(syncSnapshotBody({
|
||||
payload_hash: payloadHash,
|
||||
logical_clock: 43,
|
||||
head_revision: 2,
|
||||
base_head: headRef(baseRow),
|
||||
data_base64: base64(payload),
|
||||
})),
|
||||
await authorizedEnv(d1),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 201);
|
||||
assert.deepEqual(await response.json(), uploadDocument(committed));
|
||||
assert.deepEqual(d1.sessionConstraints, ["first-primary", "first-primary"]);
|
||||
});
|
||||
|
||||
it("rejects a stale base before writing R2", async () => {
|
||||
const current = snapshotRow({ payload_hash: "a".repeat(64) });
|
||||
const staleBase = headRef({ payload_hash: "b".repeat(64) });
|
||||
const r2Puts: RecordedR2Put[] = [];
|
||||
const d1 = testD1Database({
|
||||
firstRows: [{ device_id: DEVICE_ID }, current, vaultKeyRow()],
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
syncSnapshotPostRequest(syncSnapshotBody({
|
||||
head_revision: 2,
|
||||
base_head: staleBase,
|
||||
logical_clock: 43,
|
||||
})),
|
||||
await authorizedEnv(d1, { r2Puts }),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 409);
|
||||
assert.deepEqual(await response.json(), conflictDocument(current));
|
||||
assert.equal(r2Puts.length, 0);
|
||||
assert.deepEqual(d1.batches, []);
|
||||
});
|
||||
|
||||
it("returns the winning head when D1 rejects a concurrent writer", async () => {
|
||||
const baseRow = snapshotRow({ payload_hash: "a".repeat(64) });
|
||||
const winner = snapshotRow({
|
||||
payload_hash: "b".repeat(64),
|
||||
r2_key: snapshotKey("b".repeat(64)),
|
||||
content_hash: "3".repeat(64),
|
||||
logical_clock: 43,
|
||||
head_revision: 2,
|
||||
base_head_revision: 1,
|
||||
base_snapshot_id: SNAPSHOT_ID,
|
||||
base_payload_hash: "a".repeat(64),
|
||||
});
|
||||
const d1 = testD1Database({
|
||||
firstRows: [{ device_id: DEVICE_ID }, baseRow, vaultKeyRow(), winner],
|
||||
batchError: new Error("sync_snapshot_head_cas_failed"),
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
syncSnapshotPostRequest(syncSnapshotBody({
|
||||
head_revision: 2,
|
||||
base_head: headRef(baseRow),
|
||||
logical_clock: 44,
|
||||
})),
|
||||
await authorizedEnv(d1),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 409);
|
||||
assert.deepEqual(await response.json(), conflictDocument(winner));
|
||||
});
|
||||
|
||||
it("downloads a snapshot only through its exact head token", async () => {
|
||||
const payload = opaqueEnvelopeBytes();
|
||||
const payloadHash = sha256(payload);
|
||||
const key = snapshotKey(payloadHash);
|
||||
const row = snapshotRow({
|
||||
r2_key: key,
|
||||
payload_hash: payloadHash,
|
||||
size_bytes: payload.byteLength,
|
||||
});
|
||||
const r2Gets: string[] = [];
|
||||
const d1 = testD1Database({ firstRows: [{ device_id: DEVICE_ID }, row] });
|
||||
|
||||
const response = await handleRequest(
|
||||
syncSnapshotGetRequest(headRef(row)),
|
||||
await authorizedEnv(d1, { r2Gets, r2Objects: [[key, payload]] }),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(await response.json(), {
|
||||
version: 1,
|
||||
user_id: USER_ID,
|
||||
device_id: DEVICE_ID,
|
||||
snapshot: snapshotDocument({
|
||||
r2_key: key,
|
||||
payload_hash: payloadHash,
|
||||
size_bytes: payload.byteLength,
|
||||
}),
|
||||
...uploadDocument(row),
|
||||
data_base64: base64(payload),
|
||||
});
|
||||
assert.deepEqual(r2Gets, [key]);
|
||||
});
|
||||
|
||||
it("returns not found for missing snapshot indexes", async () => {
|
||||
const r2Gets: string[] = [];
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({ firstRows: [{ device_id: DEVICE_ID }, null] });
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import { authSessionCacheKvKey, authTokenHash } from "../src/auth.js";
|
||||
import { handleRequest } from "../src/index.js";
|
||||
import {
|
||||
SyncVaultConflictError,
|
||||
SyncVaultNotFoundError,
|
||||
assertCurrentSyncVaultKey,
|
||||
parseWrappedAccountKey,
|
||||
syncVaultRecipientEnvelopeStatement,
|
||||
} from "../src/sync_vault.js";
|
||||
import { syncVaultBootstrapProofBytes } from "../src/sync_vault_bootstrap_proof.js";
|
||||
import {
|
||||
ACCESS_TOKEN,
|
||||
PUBLIC_KEY,
|
||||
sessionDocument,
|
||||
signDeviceMessage,
|
||||
testD1Database,
|
||||
testEnv,
|
||||
} from "./devices_test_support.js";
|
||||
|
||||
const USER_ID = "user-01";
|
||||
const DEVICE_ID = "device-01";
|
||||
const KEY_ID = "a".repeat(64);
|
||||
const GENERATION = 1;
|
||||
const HISTORICAL_KEY_ID = "c".repeat(64);
|
||||
const HISTORICAL_GENERATION = 3;
|
||||
const IDEMPOTENCY_KEY = "sync-vault-bootstrap-0001";
|
||||
const SUITE = "HPKE-BASE-X25519-HKDF-SHA256-CHACHA20POLY1305";
|
||||
const ENCAPPED_KEY = "A".repeat(43);
|
||||
const CIPHERTEXT = "B".repeat(64);
|
||||
|
||||
describe("sync vault routes", () => {
|
||||
it("bootstraps the current approved device envelope", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({
|
||||
firstRows: [approvedDeviceRow(), signingKeyRow(), currentEnvelopeRow()],
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
vaultBootstrapRequest(await vaultBootstrapBody()),
|
||||
testEnv({
|
||||
d1,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 201);
|
||||
assert.equal(response.headers.get("cache-control"), "no-store");
|
||||
assert.deepEqual(await response.json(), vaultDocument());
|
||||
assert.equal(d1.batches[0], 2);
|
||||
assert.ok(d1.queries[1]?.includes("keys.signing_public_key"));
|
||||
assert.ok(d1.queries[1]?.includes("keys.key_protocol_version = 2"));
|
||||
assert.ok(d1.queries[2]?.includes("INSERT INTO sync_vault_accounts"));
|
||||
assert.ok(d1.queries[3]?.includes("INSERT INTO sync_vault_envelopes"));
|
||||
assert.ok(d1.queries[4]?.includes("FROM sync_vault_accounts AS accounts"));
|
||||
assert.deepEqual(d1.binds[4], [USER_ID, DEVICE_ID]);
|
||||
});
|
||||
|
||||
it("returns the current device envelope", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({
|
||||
firstRows: [{ device_id: DEVICE_ID }, currentEnvelopeRow()],
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
new Request("https://elydora.test/api/sync/vault", {
|
||||
headers: { authorization: `Bearer ${ACCESS_TOKEN}` },
|
||||
}),
|
||||
testEnv({
|
||||
d1,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(await response.json(), vaultDocument());
|
||||
assert.equal(d1.batches.length, 0);
|
||||
assert.deepEqual(d1.binds[1], [USER_ID, DEVICE_ID]);
|
||||
});
|
||||
|
||||
it("returns an exact historical envelope for the authenticated device", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({
|
||||
firstRows: [
|
||||
approvedDeviceRow(),
|
||||
currentEnvelopeRow({ key_id: HISTORICAL_KEY_ID, generation: HISTORICAL_GENERATION }),
|
||||
],
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
vaultGetRequest(`?generation=${HISTORICAL_GENERATION}&key_id=${HISTORICAL_KEY_ID}`),
|
||||
testEnv({
|
||||
d1,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(await response.json(), vaultDocument({
|
||||
key_id: HISTORICAL_KEY_ID,
|
||||
generation: HISTORICAL_GENERATION,
|
||||
}));
|
||||
assert.ok(d1.queries[1]?.includes("FROM sync_vault_envelopes"));
|
||||
assert.ok(d1.queries[1]?.includes("recipient_device_id = ?"));
|
||||
assert.deepEqual(d1.binds[1], [
|
||||
USER_ID,
|
||||
DEVICE_ID,
|
||||
HISTORICAL_KEY_ID,
|
||||
HISTORICAL_GENERATION,
|
||||
]);
|
||||
});
|
||||
|
||||
it("isolates historical envelopes by recipient and exact generation", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
for (const generation of [HISTORICAL_GENERATION, HISTORICAL_GENERATION + 1]) {
|
||||
const d1 = testD1Database({ firstRows: [approvedDeviceRow(), null] });
|
||||
const response = await handleRequest(
|
||||
vaultGetRequest(`?key_id=${HISTORICAL_KEY_ID}&generation=${generation}`),
|
||||
testEnv({
|
||||
d1,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 404);
|
||||
assert.deepEqual(d1.binds[1], [USER_ID, DEVICE_ID, HISTORICAL_KEY_ID, generation]);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects partial, duplicate, and extra historical queries", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const queries = [
|
||||
`?generation=${HISTORICAL_GENERATION}`,
|
||||
`?key_id=${HISTORICAL_KEY_ID}`,
|
||||
`?key_id=${HISTORICAL_KEY_ID}&generation=3&generation=3`,
|
||||
`?key_id=${HISTORICAL_KEY_ID}&generation=3&extra=1`,
|
||||
];
|
||||
for (const query of queries) {
|
||||
const d1 = testD1Database({ firstRows: [approvedDeviceRow()] });
|
||||
const response = await handleRequest(
|
||||
vaultGetRequest(query),
|
||||
testEnv({
|
||||
d1,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
assert.equal(d1.queries.length, 1);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects malformed opaque envelopes before vault writes", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({ firstRows: [{ device_id: DEVICE_ID }] });
|
||||
|
||||
const response = await handleRequest(
|
||||
vaultBootstrapRequest(
|
||||
await vaultBootstrapBody({ envelope: { ...wrappedEnvelope(), ciphertext: "B".repeat(63) } }),
|
||||
),
|
||||
testEnv({
|
||||
d1,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
assert.deepEqual(await response.json(), { error: "invalid_sync_vault" });
|
||||
assert.equal(d1.queries.length, 1);
|
||||
assert.deepEqual(d1.batches, []);
|
||||
});
|
||||
|
||||
it("rejects noncanonical encapped keys before vault writes", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({ firstRows: [{ device_id: DEVICE_ID }] });
|
||||
|
||||
const response = await handleRequest(
|
||||
vaultBootstrapRequest(
|
||||
await vaultBootstrapBody({ envelope: { ...wrappedEnvelope(), encapped_key: `${"A".repeat(42)}B` } }),
|
||||
),
|
||||
testEnv({
|
||||
d1,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
assert.deepEqual(await response.json(), { error: "invalid_sync_vault" });
|
||||
assert.equal(d1.queries.length, 1);
|
||||
assert.deepEqual(d1.batches, []);
|
||||
});
|
||||
|
||||
it("rejects unknown envelope fields before vault writes", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({ firstRows: [{ device_id: DEVICE_ID }] });
|
||||
|
||||
const response = await handleRequest(
|
||||
vaultBootstrapRequest(
|
||||
await vaultBootstrapBody({ envelope: { ...wrappedEnvelope(), plaintext_key: KEY_ID } }),
|
||||
),
|
||||
testEnv({
|
||||
d1,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
assert.equal(d1.queries.length, 1);
|
||||
assert.deepEqual(d1.batches, []);
|
||||
});
|
||||
|
||||
it("rejects noninitial bootstrap generations before vault writes", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({ firstRows: [{ device_id: DEVICE_ID }] });
|
||||
|
||||
const response = await handleRequest(
|
||||
vaultBootstrapRequest(await vaultBootstrapBody({ generation: 2 })),
|
||||
testEnv({
|
||||
d1,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
assert.deepEqual(await response.json(), { error: "invalid_sync_vault" });
|
||||
assert.equal(d1.queries.length, 1);
|
||||
assert.deepEqual(d1.batches, []);
|
||||
});
|
||||
|
||||
it("rejects a bootstrap replay with different stored ciphertext", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({
|
||||
firstRows: [
|
||||
approvedDeviceRow(),
|
||||
signingKeyRow(),
|
||||
currentEnvelopeRow({ ciphertext: "C".repeat(64) }),
|
||||
],
|
||||
});
|
||||
|
||||
const response = await handleRequest(
|
||||
vaultBootstrapRequest(await vaultBootstrapBody()),
|
||||
testEnv({
|
||||
d1,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 409);
|
||||
assert.deepEqual(await response.json(), { error: "sync_vault_conflict" });
|
||||
});
|
||||
|
||||
it("rejects tampered bootstrap fields before vault writes", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const body = await vaultBootstrapBody();
|
||||
body.key_id = "b".repeat(64);
|
||||
const d1 = testD1Database({ firstRows: [approvedDeviceRow(), signingKeyRow()] });
|
||||
|
||||
const response = await handleRequest(
|
||||
vaultBootstrapRequest(body),
|
||||
testEnv({
|
||||
d1,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 403);
|
||||
assert.deepEqual(await response.json(), { error: "sync_vault_forbidden" });
|
||||
assert.equal(d1.queries.length, 2);
|
||||
assert.deepEqual(d1.batches, []);
|
||||
});
|
||||
|
||||
it("rejects bootstrap v1 before signing-key reads", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({ firstRows: [approvedDeviceRow()] });
|
||||
|
||||
const response = await handleRequest(
|
||||
vaultBootstrapRequest(await vaultBootstrapBody({ version: 1 })),
|
||||
testEnv({
|
||||
d1,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
assert.deepEqual(await response.json(), { error: "invalid_sync_vault" });
|
||||
assert.equal(d1.queries.length, 1);
|
||||
assert.deepEqual(d1.batches, []);
|
||||
});
|
||||
|
||||
it("rejects missing approved v2 signing keys before vault writes", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({ firstRows: [approvedDeviceRow(), null] });
|
||||
|
||||
const response = await handleRequest(
|
||||
vaultBootstrapRequest(await vaultBootstrapBody()),
|
||||
testEnv({
|
||||
d1,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 403);
|
||||
assert.equal(d1.queries.length, 2);
|
||||
assert.deepEqual(d1.batches, []);
|
||||
});
|
||||
|
||||
it("uses the frozen v2 bootstrap proof wire", () => {
|
||||
assert.equal(
|
||||
new TextDecoder().decode(syncVaultBootstrapProofBytes(
|
||||
USER_ID,
|
||||
DEVICE_ID,
|
||||
bootstrapProofInput(),
|
||||
)),
|
||||
"31:elydora-sync-vault-bootstrap-v2" +
|
||||
"7:user-01" +
|
||||
"9:device-01" +
|
||||
`64:${KEY_ID}` +
|
||||
"1:1" +
|
||||
"1:1" +
|
||||
`45:${SUITE}` +
|
||||
`43:${ENCAPPED_KEY}` +
|
||||
`64:${CIPHERTEXT}` +
|
||||
"25:sync-vault-bootstrap-0001",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns not found when the current device has no envelope", async () => {
|
||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||
const d1 = testD1Database({ firstRows: [{ device_id: DEVICE_ID }, null] });
|
||||
|
||||
const response = await handleRequest(
|
||||
new Request("https://elydora.test/api/sync/vault", {
|
||||
headers: { authorization: `Bearer ${ACCESS_TOKEN}` },
|
||||
}),
|
||||
testEnv({
|
||||
d1,
|
||||
kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument(DEVICE_ID)]],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 404);
|
||||
assert.deepEqual(await response.json(), { error: "sync_vault_not_found" });
|
||||
});
|
||||
|
||||
it("validates snapshot key metadata against the current vault key", async () => {
|
||||
const matching = testD1Database({ firstRows: [{ key_id: KEY_ID, generation: GENERATION }] });
|
||||
await assertCurrentSyncVaultKey(testEnv({ d1: matching }), USER_ID, KEY_ID, GENERATION);
|
||||
|
||||
const mismatched = testD1Database({ firstRows: [{ key_id: KEY_ID, generation: GENERATION }] });
|
||||
await assert.rejects(
|
||||
assertCurrentSyncVaultKey(testEnv({ d1: mismatched }), USER_ID, "b".repeat(64), GENERATION),
|
||||
SyncVaultConflictError,
|
||||
);
|
||||
|
||||
const missing = testD1Database({ firstRows: [null] });
|
||||
await assert.rejects(
|
||||
assertCurrentSyncVaultKey(testEnv({ d1: missing }), USER_ID, KEY_ID, GENERATION),
|
||||
SyncVaultNotFoundError,
|
||||
);
|
||||
});
|
||||
|
||||
it("builds a recipient envelope write guarded by device trust and the current vault key", () => {
|
||||
const d1 = testD1Database([]);
|
||||
syncVaultRecipientEnvelopeStatement(
|
||||
testEnv({ d1 }),
|
||||
USER_ID,
|
||||
"device-02",
|
||||
DEVICE_ID,
|
||||
KEY_ID,
|
||||
GENERATION,
|
||||
parseWrappedAccountKey(wrappedEnvelope()),
|
||||
"sync-vault-recipient-0001",
|
||||
1_780_000_400,
|
||||
);
|
||||
|
||||
assert.ok(d1.queries[0]?.includes("accounts.current_key_id = ?"));
|
||||
assert.ok(d1.queries[0]?.includes("recipient.approval_status = ?"));
|
||||
assert.ok(d1.queries[0]?.includes("approver.approval_status = 'approved'"));
|
||||
assert.deepEqual(d1.binds[0], [
|
||||
USER_ID,
|
||||
"device-02",
|
||||
DEVICE_ID,
|
||||
KEY_ID,
|
||||
GENERATION,
|
||||
1,
|
||||
SUITE,
|
||||
ENCAPPED_KEY,
|
||||
CIPHERTEXT,
|
||||
"sync-vault-recipient-0001",
|
||||
1_780_000_400,
|
||||
"device-02",
|
||||
"pending",
|
||||
DEVICE_ID,
|
||||
USER_ID,
|
||||
KEY_ID,
|
||||
GENERATION,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
function vaultBootstrapRequest(body: Record<string, unknown>): Request {
|
||||
return new Request("https://elydora.test/api/sync/vault/bootstrap", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${ACCESS_TOKEN}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function vaultGetRequest(query = ""): Request {
|
||||
return new Request(`https://elydora.test/api/sync/vault${query}`, {
|
||||
headers: { authorization: `Bearer ${ACCESS_TOKEN}` },
|
||||
});
|
||||
}
|
||||
|
||||
async function vaultBootstrapBody(
|
||||
overrides: Record<string, unknown> = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
return {
|
||||
version: 2,
|
||||
key_id: KEY_ID,
|
||||
generation: GENERATION,
|
||||
envelope: wrappedEnvelope(),
|
||||
idempotency_key: IDEMPOTENCY_KEY,
|
||||
bootstrap_proof: await signDeviceMessage(
|
||||
syncVaultBootstrapProofBytes(USER_ID, DEVICE_ID, bootstrapProofInput()),
|
||||
),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function bootstrapProofInput() {
|
||||
return {
|
||||
keyId: KEY_ID,
|
||||
generation: GENERATION,
|
||||
envelope: parseWrappedAccountKey(wrappedEnvelope()),
|
||||
idempotencyKey: IDEMPOTENCY_KEY,
|
||||
};
|
||||
}
|
||||
|
||||
function approvedDeviceRow(): Record<string, unknown> {
|
||||
return { device_id: DEVICE_ID };
|
||||
}
|
||||
|
||||
function signingKeyRow(): Record<string, unknown> {
|
||||
return { signing_public_key: PUBLIC_KEY };
|
||||
}
|
||||
|
||||
function wrappedEnvelope(): Record<string, unknown> {
|
||||
return {
|
||||
version: 1,
|
||||
suite: SUITE,
|
||||
encapped_key: ENCAPPED_KEY,
|
||||
ciphertext: CIPHERTEXT,
|
||||
};
|
||||
}
|
||||
|
||||
function currentEnvelopeRow(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
key_id: KEY_ID,
|
||||
generation: GENERATION,
|
||||
recipient_device_id: DEVICE_ID,
|
||||
approver_device_id: DEVICE_ID,
|
||||
envelope_version: 1,
|
||||
suite: SUITE,
|
||||
encapped_key: ENCAPPED_KEY,
|
||||
ciphertext: CIPHERTEXT,
|
||||
idempotency_key: IDEMPOTENCY_KEY,
|
||||
created_at: 1_780_000_300,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function vaultDocument(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
version: 1,
|
||||
user_id: USER_ID,
|
||||
key_id: KEY_ID,
|
||||
generation: GENERATION,
|
||||
recipient_device_id: DEVICE_ID,
|
||||
approver_device_id: DEVICE_ID,
|
||||
envelope: wrappedEnvelope(),
|
||||
created_at: 1_780_000_300,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,9 @@ main = "src/index.ts"
|
||||
compatibility_date = "2026-05-08"
|
||||
compatibility_flags = ["nodejs_compat"]
|
||||
|
||||
[triggers]
|
||||
crons = ["17 * * * *"]
|
||||
|
||||
[[d1_databases]]
|
||||
binding = "ELY_DB"
|
||||
database_name = "elydora-browser-db"
|
||||
|
||||
Reference in New Issue
Block a user