export const RELAY_AUTHORIZATION_SNAPSHOT_VERSION = 1 as const; export const RELAY_AUTHORIZATION_MAX_TTL_SECONDS = 5 * 60; export const RELAY_AUTHORIZATION_REFRESH_SECONDS = 60; export const RELAY_AUTHORIZATION_DELTA_SECONDS = 15; const SNAPSHOT_DOMAIN = "nekonest-cloud/relay-authorization-snapshot/v1\n"; export const RELAY_SIGNING_KEY_FOR_SNAPSHOT_SQL = ` SELECT kid, public_key_jwk, private_key_ref FROM relay_signing_keys WHERE status = 'active' AND not_before <= ?1 AND not_after >= ?2 ORDER BY not_before DESC, kid DESC LIMIT 1`; export type AuthorizedDevice = { device_id: string; name: string; os: "windows" | "linux"; ed25519_public: string; x25519_public: string; credential_hash: string; identity_fingerprint: string; }; export type AuthorizedPhone = { phone_id: string; name: string; credential_hash: string; ed25519_public: string; x25519_public: string; identity_fingerprint: string; }; export type RelayAuthorizationSnapshotPayload = { snapshot_version: typeof RELAY_AUTHORIZATION_SNAPSHOT_VERSION; tenant_id: string; tenant_status: "active" | "suspended"; home_region: string; relay_node_id: string; placement_generation: number; authorization_revision: number; devices: AuthorizedDevice[]; phones?: AuthorizedPhone[]; issued_at: string; expires_at: string; }; export type SignedRelayAuthorizationSnapshot = { algorithm: "Ed25519"; kid: string; payload: RelayAuthorizationSnapshotPayload; signature: string; }; export type SnapshotPlacementAdmission = { relay_node_id: string | null; generation: number; tenant_status: "active" | "suspended"; placement_state: string; }; export function classifySnapshotPlacement(input: { placement: SnapshotPlacementAdmission | null; nodeId: string; expectedGeneration: number; }): "ready" | "wrong_node" | "stale_generation" | "suspended" | "provisioning" { const placement = input.placement; if (!placement || placement.relay_node_id !== input.nodeId) return "wrong_node"; if (placement.generation !== input.expectedGeneration) return "stale_generation"; if (placement.tenant_status !== "active") return "suspended"; if (!["active", "draining"].includes(placement.placement_state)) return "provisioning"; return "ready"; } function canonicalNumber(value: number): string { if (!Number.isFinite(value)) throw new TypeError("non_finite_json_number"); return Object.is(value, -0) ? "0" : JSON.stringify(value); } /** RFC 8785-compatible for the JSON subset used by authorization snapshots. */ export function canonicalJson(value: unknown): string { if (value === null) return "null"; if (typeof value === "string") return JSON.stringify(value); if (typeof value === "boolean") return value ? "true" : "false"; if (typeof value === "number") return canonicalNumber(value); if (Array.isArray(value)) { return `[${value.map((item) => canonicalJson(item)).join(",")}]`; } if (typeof value === "object") { const entries = Object.entries(value as Record) .filter(([, item]) => item !== undefined) .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)); return `{${entries .map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`) .join(",")}}`; } throw new TypeError("unsupported_json_value"); } function snapshotBytes(payload: RelayAuthorizationSnapshotPayload): Uint8Array { return new TextEncoder().encode(`${SNAPSHOT_DOMAIN}${canonicalJson(payload)}`); } function base64UrlEncode(bytes: Uint8Array): string { let binary = ""; for (const byte of bytes) binary += String.fromCharCode(byte); return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/u, ""); } function base64UrlDecode(value: string): Uint8Array { if (!/^[A-Za-z0-9_-]+$/u.test(value)) throw new TypeError("invalid_base64url"); const padding = "=".repeat((4 - (value.length % 4)) % 4); const binary = atob(`${value.replaceAll("-", "+").replaceAll("_", "/")}${padding}`); return Uint8Array.from(binary, (character) => character.charCodeAt(0)); } function assertSnapshotLifetime( payload: RelayAuthorizationSnapshotPayload, nowMs?: number, ): void { const issuedAt = Date.parse(payload.issued_at); const expiresAt = Date.parse(payload.expires_at); if (!Number.isFinite(issuedAt) || !Number.isFinite(expiresAt)) { throw new RangeError("invalid_snapshot_time"); } if (expiresAt <= issuedAt) throw new RangeError("invalid_snapshot_lifetime"); if (expiresAt - issuedAt > RELAY_AUTHORIZATION_MAX_TTL_SECONDS * 1_000) { throw new RangeError("snapshot_ttl_exceeds_maximum"); } if (nowMs !== undefined && (nowMs < issuedAt - 30_000 || nowMs >= expiresAt)) { throw new RangeError("snapshot_not_current"); } } function assertSnapshotShape(payload: RelayAuthorizationSnapshotPayload): void { if (payload.snapshot_version !== RELAY_AUTHORIZATION_SNAPSHOT_VERSION) { throw new RangeError("unsupported_snapshot_version"); } if (!/^tenant_[0-9a-f]{32}$/u.test(payload.tenant_id)) { throw new TypeError("invalid_snapshot_tenant"); } if (!/^node_[A-Za-z0-9._:-]{1,96}$/u.test(payload.relay_node_id)) { throw new TypeError("invalid_snapshot_node"); } if (!Number.isSafeInteger(payload.placement_generation) || payload.placement_generation < 1) { throw new RangeError("invalid_placement_generation"); } if (!Number.isSafeInteger(payload.authorization_revision) || payload.authorization_revision < 0) { throw new RangeError("invalid_authorization_revision"); } for (const device of payload.devices) { if (typeof device.name !== "string" || device.name.length < 1 || device.name.length > 48) { throw new TypeError("invalid_snapshot_device_name"); } if (device.os !== "windows" && device.os !== "linux") { throw new TypeError("invalid_snapshot_device_os"); } if (!/^[A-Za-z0-9_-]{43}$/u.test(device.ed25519_public) || !/^[A-Za-z0-9_-]{43}$/u.test(device.x25519_public)) { throw new TypeError("invalid_snapshot_device_public_key"); } } const ordered = [...payload.devices].sort((left, right) => left.device_id < right.device_id ? -1 : left.device_id > right.device_id ? 1 : 0, ); if (ordered.some((device, index) => device !== payload.devices[index])) { throw new TypeError("snapshot_devices_not_ordered"); } const phones = payload.phones ?? []; for (const phone of phones) { if (!/^phone_[A-Za-z0-9._:-]{1,120}$/u.test(phone.phone_id) || typeof phone.name !== "string" || phone.name.length < 1 || phone.name.length > 48 || !/^[0-9a-f]{64}$/u.test(phone.credential_hash) || !/^[A-Za-z0-9_-]{43}$/u.test(phone.ed25519_public) || !/^[A-Za-z0-9_-]{43}$/u.test(phone.x25519_public) || !/^[0-9a-f]{64}$/u.test(phone.identity_fingerprint)) { throw new TypeError("invalid_snapshot_phone"); } } const orderedPhones = [...phones].sort((left, right) => left.phone_id < right.phone_id ? -1 : left.phone_id > right.phone_id ? 1 : 0, ); if (orderedPhones.some((phone, index) => phone !== phones[index])) { throw new TypeError("snapshot_phones_not_ordered"); } assertSnapshotLifetime(payload); } export async function signRelayAuthorizationSnapshot(input: { kid: string; privateKey: CryptoKey; payload: RelayAuthorizationSnapshotPayload; }): Promise { if (!/^[A-Za-z0-9._:-]{1,128}$/u.test(input.kid)) throw new TypeError("invalid_snapshot_kid"); assertSnapshotShape(input.payload); const signature = await crypto.subtle.sign( { name: "Ed25519" }, input.privateKey, Uint8Array.from(snapshotBytes(input.payload)).buffer, ); return { algorithm: "Ed25519", kid: input.kid, payload: input.payload, signature: base64UrlEncode(new Uint8Array(signature)), }; } export async function verifyRelayAuthorizationSnapshot(input: { snapshot: SignedRelayAuthorizationSnapshot; publicKey: CryptoKey; nowMs?: number; }): Promise { try { if (input.snapshot.algorithm !== "Ed25519") return false; assertSnapshotShape(input.snapshot.payload); assertSnapshotLifetime(input.snapshot.payload, input.nowMs ?? Date.now()); return crypto.subtle.verify( { name: "Ed25519" }, input.publicKey, Uint8Array.from(base64UrlDecode(input.snapshot.signature)).buffer, Uint8Array.from(snapshotBytes(input.snapshot.payload)).buffer, ); } catch { return false; } }