Files
nekonest-cloud/db/relay-authorization.ts

227 lines
8.3 KiB
TypeScript

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<string, unknown>)
.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<SignedRelayAuthorizationSnapshot> {
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<boolean> {
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;
}
}