227 lines
8.3 KiB
TypeScript
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;
|
|
}
|
|
}
|