fix(sync): secure encrypted snapshot lifecycle
This commit is contained in:
@@ -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("");
|
||||
}
|
||||
Reference in New Issue
Block a user