From 02dac92dd614e856a714b192db74c2d94c4db8d2 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Thu, 9 Jul 2026 23:25:09 -0700 Subject: [PATCH 01/37] feat(web): add Iroh trust broker --- tests/fixtures/iroh/path-hint-v1.json | 8 + web/.env.example | 16 + web/app/api/account/route.ts | 6 + web/app/api/devices/iroh/challenge/route.ts | 8 + web/app/api/devices/iroh/pair-grants/route.ts | 8 + web/app/api/devices/iroh/register/route.ts | 8 + web/app/api/devices/iroh/relay-token/route.ts | 8 + web/app/api/devices/iroh/route.ts | 12 + web/app/api/internal/iroh/retention/route.ts | 42 ++ web/app/env.ts | 23 + .../migration.sql | 157 ++++ web/db/schema.ts | 168 +++++ web/services/iroh/README.md | 36 + web/services/iroh/config.ts | 58 ++ web/services/iroh/crypto.ts | 331 ++++++++ web/services/iroh/errors.ts | 85 +++ web/services/iroh/model.ts | 556 ++++++++++++++ web/services/iroh/relayMinter.ts | 157 ++++ web/services/iroh/repository.ts | 709 ++++++++++++++++++ web/services/iroh/routeHandler.ts | 189 +++++ web/services/iroh/trustBroker.ts | 356 +++++++++ web/tests/client-config-env.test.ts | 13 + web/tests/iroh-db-behavior.test.ts | 322 ++++++++ web/tests/iroh-model-crypto.test.ts | 256 +++++++ web/tests/iroh-route-handler.test.ts | 160 ++++ web/tests/iroh-trust-broker.test.ts | 522 +++++++++++++ web/vercel.json | 4 + 27 files changed, 4218 insertions(+) create mode 100644 tests/fixtures/iroh/path-hint-v1.json create mode 100644 web/app/api/devices/iroh/challenge/route.ts create mode 100644 web/app/api/devices/iroh/pair-grants/route.ts create mode 100644 web/app/api/devices/iroh/register/route.ts create mode 100644 web/app/api/devices/iroh/relay-token/route.ts create mode 100644 web/app/api/devices/iroh/route.ts create mode 100644 web/app/api/internal/iroh/retention/route.ts create mode 100644 web/db/migrations/20260709000000_iroh_trust_broker/migration.sql create mode 100644 web/services/iroh/README.md create mode 100644 web/services/iroh/config.ts create mode 100644 web/services/iroh/crypto.ts create mode 100644 web/services/iroh/errors.ts create mode 100644 web/services/iroh/model.ts create mode 100644 web/services/iroh/relayMinter.ts create mode 100644 web/services/iroh/repository.ts create mode 100644 web/services/iroh/routeHandler.ts create mode 100644 web/services/iroh/trustBroker.ts create mode 100644 web/tests/iroh-db-behavior.test.ts create mode 100644 web/tests/iroh-model-crypto.test.ts create mode 100644 web/tests/iroh-route-handler.test.ts create mode 100644 web/tests/iroh-trust-broker.test.ts diff --git a/tests/fixtures/iroh/path-hint-v1.json b/tests/fixtures/iroh/path-hint-v1.json new file mode 100644 index 000000000000..be945f40aaf7 --- /dev/null +++ b/tests/fixtures/iroh/path-hint-v1.json @@ -0,0 +1,8 @@ +{ + "kind": "direct_address", + "value": "8.8.8.8:4433", + "source": "native", + "privacy_scope": "public_internet", + "observed_at": "2026-07-09T19:55:00.000Z", + "expires_at": "2026-07-09T20:45:00.000Z" +} diff --git a/web/.env.example b/web/.env.example index 77b509936dd0..11a43373f865 100644 --- a/web/.env.example +++ b/web/.env.example @@ -39,6 +39,22 @@ NEXT_PUBLIC_STACK_PROJECT_ID= NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY= STACK_SECRET_SERVER_KEY= +# Personal-account Iroh trust broker. The Iroh Services project API key is not +# accepted by this process; it belongs only in the isolated Rust relay minter. +# Grant verification JSON maps the current and previous KIDs to Ed25519 SPKI +# PEM strings. The developer binding override remains off unless all three +# override settings explicitly match the authenticated user and deployment. +CMUX_IROH_LAN_DISCOVERY_SECRET_B64= +CMUX_IROH_GRANT_SIGNING_KEY_P8= +CMUX_IROH_GRANT_SIGNING_KID= +CMUX_IROH_GRANT_VERIFY_KEYS_JSON= +CMUX_IROH_MINT_URL= +CMUX_IROH_MINT_HMAC_SECRET_B64= +CMUX_IROH_RATE_LIMIT_ID= +CMUX_IROH_DEV_BINDING_OVERRIDE_ENABLED=0 +CMUX_IROH_DEV_BINDING_OVERRIDE_USER_IDS= +CMUX_IROH_DEV_BINDING_OVERRIDE_ENVIRONMENTS= + # Local/Postgres database. `bun db:*` derives the local port from CMUX_PORT by default: # DATABASE_URL uses localhost:${CMUX_PORT + 10000}. CI should set explicit URLs. DATABASE_URL= diff --git a/web/app/api/account/route.ts b/web/app/api/account/route.ts index 50dc9cad36d2..d4c40271b325 100644 --- a/web/app/api/account/route.ts +++ b/web/app/api/account/route.ts @@ -18,6 +18,9 @@ import { cloudVms, deviceTokens, devices, + irohAccountSecurityStates, + irohEndpointBindings, + irohRegistrationChallenges, notificationSendEvents, stripeCustomers, stripeSubscriptions, @@ -891,6 +894,9 @@ async function deleteCmuxOwnedAccountRows(userId: string, accountTeamIds: readon await tx.delete(deviceTokens).where(eq(deviceTokens.userId, userId)); await tx.delete(notificationSendEvents).where(eq(notificationSendEvents.userId, userId)); + await tx.delete(irohRegistrationChallenges).where(eq(irohRegistrationChallenges.userId, userId)); + await tx.delete(irohEndpointBindings).where(eq(irohEndpointBindings.userId, userId)); + await tx.delete(irohAccountSecurityStates).where(eq(irohAccountSecurityStates.userId, userId)); await tx.delete(billingEmailClaims).where(or( eq(billingEmailClaims.stackUserId, userId), diff --git a/web/app/api/devices/iroh/challenge/route.ts b/web/app/api/devices/iroh/challenge/route.ts new file mode 100644 index 000000000000..1e2e21022264 --- /dev/null +++ b/web/app/api/devices/iroh/challenge/route.ts @@ -0,0 +1,8 @@ +import { handleIrohRoute } from "../../../../../services/iroh/routeHandler"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST(request: Request): Promise { + return handleIrohRoute(request, "challenge"); +} diff --git a/web/app/api/devices/iroh/pair-grants/route.ts b/web/app/api/devices/iroh/pair-grants/route.ts new file mode 100644 index 000000000000..764193e35fb6 --- /dev/null +++ b/web/app/api/devices/iroh/pair-grants/route.ts @@ -0,0 +1,8 @@ +import { handleIrohRoute } from "../../../../../services/iroh/routeHandler"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST(request: Request): Promise { + return handleIrohRoute(request, "pair_grant"); +} diff --git a/web/app/api/devices/iroh/register/route.ts b/web/app/api/devices/iroh/register/route.ts new file mode 100644 index 000000000000..02b40b335019 --- /dev/null +++ b/web/app/api/devices/iroh/register/route.ts @@ -0,0 +1,8 @@ +import { handleIrohRoute } from "../../../../../services/iroh/routeHandler"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST(request: Request): Promise { + return handleIrohRoute(request, "register"); +} diff --git a/web/app/api/devices/iroh/relay-token/route.ts b/web/app/api/devices/iroh/relay-token/route.ts new file mode 100644 index 000000000000..ea4fe4e1ecd0 --- /dev/null +++ b/web/app/api/devices/iroh/relay-token/route.ts @@ -0,0 +1,8 @@ +import { handleIrohRoute } from "../../../../../services/iroh/routeHandler"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST(request: Request): Promise { + return handleIrohRoute(request, "relay_token"); +} diff --git a/web/app/api/devices/iroh/route.ts b/web/app/api/devices/iroh/route.ts new file mode 100644 index 000000000000..1cc6225ed63c --- /dev/null +++ b/web/app/api/devices/iroh/route.ts @@ -0,0 +1,12 @@ +import { handleIrohRoute } from "../../../../services/iroh/routeHandler"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET(request: Request): Promise { + return handleIrohRoute(request, "discover"); +} + +export async function DELETE(request: Request): Promise { + return handleIrohRoute(request, "revoke"); +} diff --git a/web/app/api/internal/iroh/retention/route.ts b/web/app/api/internal/iroh/retention/route.ts new file mode 100644 index 000000000000..c19f85fa57f8 --- /dev/null +++ b/web/app/api/internal/iroh/retention/route.ts @@ -0,0 +1,42 @@ +import { timingSafeEqual } from "node:crypto"; +import * as Effect from "effect/Effect"; +import { IrohRepository, IrohRepositoryLive } from "../../../../../services/iroh/repository"; +import { jsonResponse } from "../../../../../services/vms/routeHelpers"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET(request: Request): Promise { + return handle(request); +} + +export async function POST(request: Request): Promise { + return handle(request); +} + +async function handle(request: Request): Promise { + const secret = process.env.CRON_SECRET?.trim(); + if (!secret) return jsonResponse({ error: "service_unavailable" }, 503); + const authorization = request.headers.get("authorization")?.trim() ?? ""; + const token = authorization.toLowerCase().startsWith("bearer ") + ? authorization.slice("bearer ".length).trim() + : ""; + const tokenBytes = Buffer.from(token); + const secretBytes = Buffer.from(secret); + if (tokenBytes.length !== secretBytes.length || !timingSafeEqual(tokenBytes, secretBytes)) { + return jsonResponse({ error: "unauthorized" }, 401); + } + + try { + await Effect.runPromise( + Effect.gen(function* () { + const repository = yield* IrohRepository; + yield* repository.pruneExpiredStateGlobally({ now: new Date() }); + }).pipe(Effect.provide(IrohRepositoryLive)), + ); + return jsonResponse({ ok: true }); + } catch { + console.error("iroh retention cleanup failed", { failure: "database" }); + return jsonResponse({ error: "iroh_retention_failed" }, 500); + } +} diff --git a/web/app/env.ts b/web/app/env.ts index 2ec34008acca..305d8ac0608e 100644 --- a/web/app/env.ts +++ b/web/app/env.ts @@ -94,6 +94,19 @@ export const env = createEnv({ SUBROUTER_BASE_URL: z.string().url().optional(), SUBROUTER_ADMIN_TOKEN: z.string().min(1).optional(), SUBROUTER_TENANT_KEY_SECRET: z.string().min(1).optional(), + // Iroh trust broker. The Services API key deliberately has no TypeScript + // env entry: only the isolated Rust relay minter may hold it. These values + // are server-only and routes fail closed when an operation's key is absent. + CMUX_IROH_LAN_DISCOVERY_SECRET_B64: z.string().regex(/^[A-Za-z0-9+/]{43,}={0,2}$/).optional(), + CMUX_IROH_GRANT_SIGNING_KEY_P8: z.string().min(64).max(16_384).optional(), + CMUX_IROH_GRANT_SIGNING_KID: z.string().regex(/^[A-Za-z0-9._-]{1,64}$/).optional(), + CMUX_IROH_GRANT_VERIFY_KEYS_JSON: z.string().min(2).max(32_768).optional(), + CMUX_IROH_MINT_URL: z.string().url().optional(), + CMUX_IROH_MINT_HMAC_SECRET_B64: z.string().regex(/^[A-Za-z0-9+/]{43,}={0,2}$/).optional(), + CMUX_IROH_RATE_LIMIT_ID: requireVercelNonPreviewValue("CMUX_IROH_RATE_LIMIT_ID"), + CMUX_IROH_DEV_BINDING_OVERRIDE_ENABLED: z.enum(["0", "1"]).optional(), + CMUX_IROH_DEV_BINDING_OVERRIDE_USER_IDS: z.string().max(8_192).optional(), + CMUX_IROH_DEV_BINDING_OVERRIDE_ENVIRONMENTS: z.string().max(256).optional(), }, client: { NEXT_PUBLIC_STACK_PROJECT_ID: z.string().min(1), @@ -131,6 +144,16 @@ export const env = createEnv({ SUBROUTER_BASE_URL: trimEnv(process.env.SUBROUTER_BASE_URL) ?? defaultSubrouterBaseUrl(), SUBROUTER_ADMIN_TOKEN: trimEnv(process.env.SUBROUTER_ADMIN_TOKEN), SUBROUTER_TENANT_KEY_SECRET: trimEnv(process.env.SUBROUTER_TENANT_KEY_SECRET), + CMUX_IROH_LAN_DISCOVERY_SECRET_B64: trimEnv(process.env.CMUX_IROH_LAN_DISCOVERY_SECRET_B64), + CMUX_IROH_GRANT_SIGNING_KEY_P8: trimEnv(process.env.CMUX_IROH_GRANT_SIGNING_KEY_P8), + CMUX_IROH_GRANT_SIGNING_KID: trimEnv(process.env.CMUX_IROH_GRANT_SIGNING_KID), + CMUX_IROH_GRANT_VERIFY_KEYS_JSON: trimEnv(process.env.CMUX_IROH_GRANT_VERIFY_KEYS_JSON), + CMUX_IROH_MINT_URL: trimEnv(process.env.CMUX_IROH_MINT_URL), + CMUX_IROH_MINT_HMAC_SECRET_B64: trimEnv(process.env.CMUX_IROH_MINT_HMAC_SECRET_B64), + CMUX_IROH_RATE_LIMIT_ID: trimEnv(process.env.CMUX_IROH_RATE_LIMIT_ID), + CMUX_IROH_DEV_BINDING_OVERRIDE_ENABLED: trimEnv(process.env.CMUX_IROH_DEV_BINDING_OVERRIDE_ENABLED), + CMUX_IROH_DEV_BINDING_OVERRIDE_USER_IDS: trimEnv(process.env.CMUX_IROH_DEV_BINDING_OVERRIDE_USER_IDS), + CMUX_IROH_DEV_BINDING_OVERRIDE_ENVIRONMENTS: trimEnv(process.env.CMUX_IROH_DEV_BINDING_OVERRIDE_ENVIRONMENTS), NEXT_PUBLIC_STACK_PROJECT_ID: stackEnv( process.env.NEXT_PUBLIC_STACK_PROJECT_ID, "00000000-0000-4000-8000-000000000000" diff --git a/web/db/migrations/20260709000000_iroh_trust_broker/migration.sql b/web/db/migrations/20260709000000_iroh_trust_broker/migration.sql new file mode 100644 index 000000000000..4905c409df43 --- /dev/null +++ b/web/db/migrations/20260709000000_iroh_trust_broker/migration.sql @@ -0,0 +1,157 @@ +CREATE TABLE "iroh_account_security_states" ( + "user_id" text PRIMARY KEY NOT NULL, + "lan_discovery_generation" integer DEFAULT 1 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "iroh_account_security_states_generation_check" + CHECK ("lan_discovery_generation" >= 1) +); +--> statement-breakpoint +CREATE TABLE "iroh_endpoint_bindings" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" text NOT NULL, + "device_uuid" uuid NOT NULL, + "app_instance_id" uuid NOT NULL, + "tag" text NOT NULL, + "platform" text NOT NULL, + "display_name" text, + "endpoint_id" text NOT NULL, + "identity_generation" integer NOT NULL, + "pairing_enabled" boolean DEFAULT false NOT NULL, + "capabilities" jsonb DEFAULT '[]'::jsonb NOT NULL, + "path_hints" jsonb DEFAULT '[]'::jsonb NOT NULL, + "device_limit_override_used" boolean DEFAULT false NOT NULL, + "last_seen_at" timestamp with time zone DEFAULT now() NOT NULL, + "registered_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "revoked_at" timestamp with time zone, + "revoked_reason" text, + CONSTRAINT "iroh_endpoint_bindings_endpoint_id_check" + CHECK ("endpoint_id" ~ '^[0-9a-f]{64}$'), + CONSTRAINT "iroh_endpoint_bindings_identity_generation_check" + CHECK ("identity_generation" >= 1), + CONSTRAINT "iroh_endpoint_bindings_tag_check" + CHECK ("tag" ~ '^[A-Za-z0-9._-]{1,64}$'), + CONSTRAINT "iroh_endpoint_bindings_platform_check" + CHECK ("platform" in ('mac', 'ios')), + CONSTRAINT "iroh_endpoint_bindings_display_name_check" + CHECK ("display_name" is null or "display_name" !~ '[[:cntrl:]]'), + CONSTRAINT "iroh_endpoint_bindings_capabilities_check" + CHECK (jsonb_typeof("capabilities") = 'array' and jsonb_array_length("capabilities") <= 32), + CONSTRAINT "iroh_endpoint_bindings_path_hints_check" + CHECK (jsonb_typeof("path_hints") = 'array' and jsonb_array_length("path_hints") <= 16) +); +--> statement-breakpoint +CREATE TABLE "iroh_registration_challenges" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" text NOT NULL, + "device_uuid" uuid NOT NULL, + "app_instance_id" uuid NOT NULL, + "tag" text NOT NULL, + "endpoint_id" text NOT NULL, + "identity_generation" integer NOT NULL, + "payload_sha256" text NOT NULL, + "nonce_hash" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "consumed_at" timestamp with time zone, + CONSTRAINT "iroh_registration_challenges_endpoint_id_check" + CHECK ("endpoint_id" ~ '^[0-9a-f]{64}$'), + CONSTRAINT "iroh_registration_challenges_identity_generation_check" + CHECK ("identity_generation" >= 1), + CONSTRAINT "iroh_registration_challenges_tag_check" + CHECK ("tag" ~ '^[A-Za-z0-9._-]{1,64}$'), + CONSTRAINT "iroh_registration_challenges_payload_hash_check" + CHECK ("payload_sha256" ~ '^[0-9a-f]{64}$'), + CONSTRAINT "iroh_registration_challenges_nonce_hash_check" + CHECK ("nonce_hash" ~ '^[0-9a-f]{64}$') +); +--> statement-breakpoint +CREATE TABLE "iroh_pair_grant_issuances" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" text NOT NULL, + "jti" uuid NOT NULL, + "initiator_binding_id" uuid NOT NULL, + "acceptor_binding_id" uuid NOT NULL, + "signing_key_id" text NOT NULL, + "alpn" text DEFAULT 'cmux/mobile/1' NOT NULL, + "scope" text DEFAULT 'cmux.mobile.attach' NOT NULL, + "issued_at" timestamp with time zone NOT NULL, + "not_before" timestamp with time zone NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "revoked_at" timestamp with time zone +); +--> statement-breakpoint +CREATE TABLE "iroh_relay_token_issuances" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" text NOT NULL, + "binding_id" uuid NOT NULL, + "endpoint_id_hash" text NOT NULL, + "status" text DEFAULT 'pending' NOT NULL, + "token_hash" text, + "failure_code" text, + "requested_at" timestamp with time zone NOT NULL, + "completed_at" timestamp with time zone, + "expires_at" timestamp with time zone, + CONSTRAINT "iroh_relay_token_issuances_endpoint_hash_check" + CHECK ("endpoint_id_hash" ~ '^[0-9a-f]{64}$'), + CONSTRAINT "iroh_relay_token_issuances_status_check" + CHECK ("status" in ('pending', 'succeeded', 'failed')) +); +--> statement-breakpoint +ALTER TABLE "iroh_pair_grant_issuances" + ADD CONSTRAINT "iroh_pair_grant_issuances_initiator_binding_id_iroh_endpoint_bindings_id_fk" + FOREIGN KEY ("initiator_binding_id") REFERENCES "public"."iroh_endpoint_bindings"("id") + ON DELETE cascade ON UPDATE no action; +--> statement-breakpoint +ALTER TABLE "iroh_pair_grant_issuances" + ADD CONSTRAINT "iroh_pair_grant_issuances_acceptor_binding_id_iroh_endpoint_bindings_id_fk" + FOREIGN KEY ("acceptor_binding_id") REFERENCES "public"."iroh_endpoint_bindings"("id") + ON DELETE cascade ON UPDATE no action; +--> statement-breakpoint +ALTER TABLE "iroh_relay_token_issuances" + ADD CONSTRAINT "iroh_relay_token_issuances_binding_id_iroh_endpoint_bindings_id_fk" + FOREIGN KEY ("binding_id") REFERENCES "public"."iroh_endpoint_bindings"("id") + ON DELETE cascade ON UPDATE no action; +--> statement-breakpoint +CREATE UNIQUE INDEX "iroh_endpoint_bindings_active_endpoint_unique" + ON "iroh_endpoint_bindings" USING btree ("endpoint_id") WHERE "revoked_at" is null; +--> statement-breakpoint +CREATE UNIQUE INDEX "iroh_endpoint_bindings_active_app_instance_unique" + ON "iroh_endpoint_bindings" USING btree ("app_instance_id") WHERE "revoked_at" is null; +--> statement-breakpoint +CREATE INDEX "iroh_endpoint_bindings_user_active_idx" + ON "iroh_endpoint_bindings" USING btree ("user_id", "updated_at") WHERE "revoked_at" is null; +--> statement-breakpoint +CREATE INDEX "iroh_endpoint_bindings_user_device_active_idx" + ON "iroh_endpoint_bindings" USING btree ("user_id", "device_uuid") WHERE "revoked_at" is null; +--> statement-breakpoint +CREATE INDEX "iroh_endpoint_bindings_revoked_idx" + ON "iroh_endpoint_bindings" USING btree ("revoked_at") WHERE "revoked_at" is not null; +--> statement-breakpoint +CREATE UNIQUE INDEX "iroh_registration_challenges_nonce_hash_unique" + ON "iroh_registration_challenges" USING btree ("nonce_hash"); +--> statement-breakpoint +CREATE INDEX "iroh_registration_challenges_user_created_idx" + ON "iroh_registration_challenges" USING btree ("user_id", "created_at"); +--> statement-breakpoint +CREATE INDEX "iroh_registration_challenges_user_device_created_idx" + ON "iroh_registration_challenges" USING btree ("user_id", "device_uuid", "created_at"); +--> statement-breakpoint +CREATE INDEX "iroh_registration_challenges_expires_idx" + ON "iroh_registration_challenges" USING btree ("expires_at") WHERE "consumed_at" is null; +--> statement-breakpoint +CREATE UNIQUE INDEX "iroh_pair_grant_issuances_jti_unique" + ON "iroh_pair_grant_issuances" USING btree ("jti"); +--> statement-breakpoint +CREATE INDEX "iroh_pair_grant_issuances_user_issued_idx" + ON "iroh_pair_grant_issuances" USING btree ("user_id", "issued_at"); +--> statement-breakpoint +CREATE INDEX "iroh_pair_grant_issuances_acceptor_expires_idx" + ON "iroh_pair_grant_issuances" USING btree ("acceptor_binding_id", "expires_at"); +--> statement-breakpoint +CREATE INDEX "iroh_relay_token_issuances_binding_requested_idx" + ON "iroh_relay_token_issuances" USING btree ("binding_id", "requested_at"); +--> statement-breakpoint +CREATE INDEX "iroh_relay_token_issuances_user_requested_idx" + ON "iroh_relay_token_issuances" USING btree ("user_id", "requested_at"); diff --git a/web/db/schema.ts b/web/db/schema.ts index 3f21598ddc16..0e684c60dc5a 100644 --- a/web/db/schema.ts +++ b/web/db/schema.ts @@ -2,6 +2,7 @@ import { sql } from "drizzle-orm"; import { bigint, boolean, + check, index, integer, jsonb, @@ -606,6 +607,173 @@ export const deviceAppInstances = pgTable( ], ); +/** + * Personal-account Iroh trust state. The rendezvous key is derived at the + * application boundary from a server-only HMAC secret and this generation, so + * Aurora never stores the LAN discovery secret itself. Revoking an endpoint + * increments the generation and invalidates previously advertised rendezvous + * values for the account. + */ +export const irohAccountSecurityStates = pgTable( + "iroh_account_security_states", + { + userId: text("user_id").primaryKey(), + lanDiscoveryGeneration: integer("lan_discovery_generation").notNull().default(1), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + check("iroh_account_security_states_generation_check", sql`${table.lanDiscoveryGeneration} >= 1`), + ], +); + +/** + * Authenticated Iroh endpoint bindings. These rows are intentionally separate + * from the legacy team-scoped device registry: Iroh discovery and grants are + * always scoped to the exact Stack user id that registered the endpoint. + */ +export const irohEndpointBindings = pgTable( + "iroh_endpoint_bindings", + { + id: uuid("id").defaultRandom().primaryKey(), + userId: text("user_id").notNull(), + deviceUuid: uuid("device_uuid").notNull(), + appInstanceId: uuid("app_instance_id").notNull(), + tag: text("tag").notNull(), + platform: text("platform").notNull(), + displayName: text("display_name"), + endpointId: text("endpoint_id").notNull(), + identityGeneration: integer("identity_generation").notNull(), + pairingEnabled: boolean("pairing_enabled").notNull().default(false), + capabilities: jsonb("capabilities").$type().notNull().default(sql`'[]'::jsonb`), + pathHints: jsonb("path_hints").$type().notNull().default(sql`'[]'::jsonb`), + deviceLimitOverrideUsed: boolean("device_limit_override_used").notNull().default(false), + lastSeenAt: timestamp("last_seen_at", { withTimezone: true }).notNull().defaultNow(), + registeredAt: timestamp("registered_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + revokedAt: timestamp("revoked_at", { withTimezone: true }), + revokedReason: text("revoked_reason"), + }, + (table) => [ + check("iroh_endpoint_bindings_endpoint_id_check", sql`${table.endpointId} ~ '^[0-9a-f]{64}$'`), + check("iroh_endpoint_bindings_identity_generation_check", sql`${table.identityGeneration} >= 1`), + check("iroh_endpoint_bindings_tag_check", sql`${table.tag} ~ '^[A-Za-z0-9._-]{1,64}$'`), + check("iroh_endpoint_bindings_platform_check", sql`${table.platform} in ('mac', 'ios')`), + check("iroh_endpoint_bindings_display_name_check", sql`${table.displayName} is null or ${table.displayName} !~ '[[:cntrl:]]'`), + check("iroh_endpoint_bindings_capabilities_check", sql`jsonb_typeof(${table.capabilities}) = 'array' and jsonb_array_length(${table.capabilities}) <= 32`), + check("iroh_endpoint_bindings_path_hints_check", sql`jsonb_typeof(${table.pathHints}) = 'array' and jsonb_array_length(${table.pathHints}) <= 16`), + uniqueIndex("iroh_endpoint_bindings_active_endpoint_unique") + .on(table.endpointId) + .where(sql`${table.revokedAt} is null`), + uniqueIndex("iroh_endpoint_bindings_active_app_instance_unique") + .on(table.appInstanceId) + .where(sql`${table.revokedAt} is null`), + index("iroh_endpoint_bindings_user_active_idx") + .on(table.userId, table.updatedAt) + .where(sql`${table.revokedAt} is null`), + index("iroh_endpoint_bindings_user_device_active_idx") + .on(table.userId, table.deviceUuid) + .where(sql`${table.revokedAt} is null`), + index("iroh_endpoint_bindings_revoked_idx") + .on(table.revokedAt) + .where(sql`${table.revokedAt} is not null`), + ], +); + +/** + * One-use registration challenges. Only a SHA-256 hash of the random nonce is + * persisted. The payload hash binds all endpoint metadata before signature + * verification and the consumed timestamp provides replay protection. + */ +export const irohRegistrationChallenges = pgTable( + "iroh_registration_challenges", + { + id: uuid("id").defaultRandom().primaryKey(), + userId: text("user_id").notNull(), + deviceUuid: uuid("device_uuid").notNull(), + appInstanceId: uuid("app_instance_id").notNull(), + tag: text("tag").notNull(), + endpointId: text("endpoint_id").notNull(), + identityGeneration: integer("identity_generation").notNull(), + payloadSha256: text("payload_sha256").notNull(), + nonceHash: text("nonce_hash").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + consumedAt: timestamp("consumed_at", { withTimezone: true }), + }, + (table) => [ + check("iroh_registration_challenges_endpoint_id_check", sql`${table.endpointId} ~ '^[0-9a-f]{64}$'`), + check("iroh_registration_challenges_identity_generation_check", sql`${table.identityGeneration} >= 1`), + check("iroh_registration_challenges_tag_check", sql`${table.tag} ~ '^[A-Za-z0-9._-]{1,64}$'`), + check("iroh_registration_challenges_payload_hash_check", sql`${table.payloadSha256} ~ '^[0-9a-f]{64}$'`), + check("iroh_registration_challenges_nonce_hash_check", sql`${table.nonceHash} ~ '^[0-9a-f]{64}$'`), + uniqueIndex("iroh_registration_challenges_nonce_hash_unique").on(table.nonceHash), + index("iroh_registration_challenges_user_created_idx").on(table.userId, table.createdAt), + index("iroh_registration_challenges_user_device_created_idx") + .on(table.userId, table.deviceUuid, table.createdAt), + index("iroh_registration_challenges_expires_idx") + .on(table.expiresAt) + .where(sql`${table.consumedAt} is null`), + ], +); + +/** Audit-only record of an issued compact pair-grant JWS. The JWS is returned + * once and is never persisted. */ +export const irohPairGrantIssuances = pgTable( + "iroh_pair_grant_issuances", + { + id: uuid("id").defaultRandom().primaryKey(), + userId: text("user_id").notNull(), + jti: uuid("jti").notNull(), + initiatorBindingId: uuid("initiator_binding_id") + .notNull() + .references(() => irohEndpointBindings.id, { onDelete: "cascade" }), + acceptorBindingId: uuid("acceptor_binding_id") + .notNull() + .references(() => irohEndpointBindings.id, { onDelete: "cascade" }), + signingKeyId: text("signing_key_id").notNull(), + alpn: text("alpn").notNull().default("cmux/mobile/1"), + scope: text("scope").notNull().default("cmux.mobile.attach"), + issuedAt: timestamp("issued_at", { withTimezone: true }).notNull(), + notBefore: timestamp("not_before", { withTimezone: true }).notNull(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + revokedAt: timestamp("revoked_at", { withTimezone: true }), + }, + (table) => [ + uniqueIndex("iroh_pair_grant_issuances_jti_unique").on(table.jti), + index("iroh_pair_grant_issuances_user_issued_idx").on(table.userId, table.issuedAt), + index("iroh_pair_grant_issuances_acceptor_expires_idx").on(table.acceptorBindingId, table.expiresAt), + ], +); + +/** + * DB-authoritative relay-mint quota ledger. At most a hash of a successfully + * minted token is recorded; plaintext relay credentials never enter Aurora. + */ +export const irohRelayTokenIssuances = pgTable( + "iroh_relay_token_issuances", + { + id: uuid("id").defaultRandom().primaryKey(), + userId: text("user_id").notNull(), + bindingId: uuid("binding_id") + .notNull() + .references(() => irohEndpointBindings.id, { onDelete: "cascade" }), + endpointIdHash: text("endpoint_id_hash").notNull(), + status: text("status").notNull().default("pending"), + tokenHash: text("token_hash"), + failureCode: text("failure_code"), + requestedAt: timestamp("requested_at", { withTimezone: true }).notNull(), + completedAt: timestamp("completed_at", { withTimezone: true }), + expiresAt: timestamp("expires_at", { withTimezone: true }), + }, + (table) => [ + check("iroh_relay_token_issuances_endpoint_hash_check", sql`${table.endpointIdHash} ~ '^[0-9a-f]{64}$'`), + check("iroh_relay_token_issuances_status_check", sql`${table.status} in ('pending', 'succeeded', 'failed')`), + index("iroh_relay_token_issuances_binding_requested_idx").on(table.bindingId, table.requestedAt), + index("iroh_relay_token_issuances_user_requested_idx").on(table.userId, table.requestedAt), + ], +); + export const cloudVmNotificationEvents = pgTable( "cloud_vm_notification_events", { diff --git a/web/services/iroh/README.md b/web/services/iroh/README.md new file mode 100644 index 000000000000..fdca1e9f2744 --- /dev/null +++ b/web/services/iroh/README.md @@ -0,0 +1,36 @@ +# Iroh trust broker + +The broker is scoped to the authenticated Stack `user.id`. Team membership and +the legacy team device registry never grant Iroh discovery or pairing access. + +Registration challenge signatures are currently the only accepted path-hint +mutation. A Stack bearer alone cannot update hints. Address churn therefore +requires a fresh five-minute registration challenge until +`endpoint-signed-monotonic-watch-addr-update-v1` adds a dedicated signed update +route with an endpoint-owned sequence number. Endpoint or identity-generation +replacement also requires explicit revocation and reapproval; a signature from +only the proposed new key cannot replace an active binding. + +`relay_fleet` is the server-configured connection preset/allowlist. It is not a +peer address. Each peer's `relay_url` hints come from its signed `watch_addr` +payload and must match that allowlist. + +Discovery runs the user-scoped retention cleanup before reading. It removes +expired hints from binding JSON, challenges more than 24 hours past expiry or +consumption, and pair/relay audit rows more than 30 days beyond their useful +window. Revocation clears hints immediately. Once a revoked binding is at least +30 days old and its pair/relay audit rows have reached their own retention +limit, cleanup deletes the binding's EndpointID, device/app UUIDs, tag, and +display name. The hourly `/api/internal/iroh/retention` cron applies the same +policy across inactive accounts; responses also filter expired hints +defensively. + +Postgres advisory locks make the authoritative limits concurrency-safe: six +challenges per device per ten minutes, 32 outstanding challenges per account, +32 active bindings per account, eight active bindings per physical device, 60 +pair grants per account per hour, three relay mints per endpoint per ten +minutes, 12 relay mints per endpoint per day, and 100 relay mints per account +per day. The optional Vercel Firewall rule is defense in depth. A tagged-build +device-limit override requires a server flag, an exact authenticated user-id +allowlist match, and an exact deployment-environment allowlist match; it never +raises the 32-binding account limit and records an audit marker on the binding. diff --git a/web/services/iroh/config.ts b/web/services/iroh/config.ts new file mode 100644 index 000000000000..469a8d83c9f9 --- /dev/null +++ b/web/services/iroh/config.ts @@ -0,0 +1,58 @@ +import * as Context from "effect/Context"; +import * as Layer from "effect/Layer"; +import { env } from "../../app/env"; + +export type IrohTrustBrokerConfigShape = { + readonly lanDiscoverySecretBase64?: string; + readonly grantSigningPrivateKeyPem?: string; + readonly grantSigningKid?: string; + readonly grantVerificationKeysJson?: string; + readonly relayMinterUrl?: string; + readonly relayMinterHmacSecretBase64?: string; + readonly rateLimitId?: string; + readonly deviceLimitOverrideEnabled: boolean; + readonly deviceLimitOverrideUserIds: ReadonlySet; + readonly deviceLimitOverrideEnvironments: ReadonlySet; + readonly deploymentEnvironment: string; +}; + +export class IrohTrustBrokerConfig extends Context.Tag("cmux/IrohTrustBrokerConfig")< + IrohTrustBrokerConfig, + IrohTrustBrokerConfigShape +>() {} + +export function irohTrustBrokerConfigFromEnv(): IrohTrustBrokerConfigShape { + return { + lanDiscoverySecretBase64: env.CMUX_IROH_LAN_DISCOVERY_SECRET_B64, + grantSigningPrivateKeyPem: env.CMUX_IROH_GRANT_SIGNING_KEY_P8, + grantSigningKid: env.CMUX_IROH_GRANT_SIGNING_KID, + grantVerificationKeysJson: env.CMUX_IROH_GRANT_VERIFY_KEYS_JSON, + relayMinterUrl: env.CMUX_IROH_MINT_URL, + relayMinterHmacSecretBase64: env.CMUX_IROH_MINT_HMAC_SECRET_B64, + rateLimitId: env.CMUX_IROH_RATE_LIMIT_ID, + deviceLimitOverrideEnabled: env.CMUX_IROH_DEV_BINDING_OVERRIDE_ENABLED === "1", + deviceLimitOverrideUserIds: csvSet(env.CMUX_IROH_DEV_BINDING_OVERRIDE_USER_IDS), + deviceLimitOverrideEnvironments: csvSet(env.CMUX_IROH_DEV_BINDING_OVERRIDE_ENVIRONMENTS), + deploymentEnvironment: process.env.VERCEL_ENV ?? process.env.NODE_ENV ?? "development", + }; +} + +export function deviceLimitOverrideAllowed( + config: IrohTrustBrokerConfigShape, + authenticatedUserId: string, +): boolean { + if (!config.deviceLimitOverrideEnabled) return false; + return config.deviceLimitOverrideUserIds.has(authenticatedUserId) && + config.deviceLimitOverrideEnvironments.has(config.deploymentEnvironment); +} + +export const IrohTrustBrokerConfigLive = Layer.succeed( + IrohTrustBrokerConfig, + irohTrustBrokerConfigFromEnv(), +); + +function csvSet(value: string | undefined): ReadonlySet { + return new Set( + value?.split(",").map((entry) => entry.trim()).filter(Boolean) ?? [], + ); +} diff --git a/web/services/iroh/crypto.ts b/web/services/iroh/crypto.ts new file mode 100644 index 000000000000..bfb21f5d3fc2 --- /dev/null +++ b/web/services/iroh/crypto.ts @@ -0,0 +1,331 @@ +import { + createHmac, + createPrivateKey, + createPublicKey, + sign, + timingSafeEqual, + verify, + type KeyObject, +} from "node:crypto"; +import { + IROH_ALPN, + IROH_PAIR_GRANT_LIFETIME_SECONDS, + IROH_PAIR_GRANT_TYP, + IROH_PAIR_SCOPE, + endpointId, + sha256, +} from "./model"; +import { IrohConfigurationError, IrohForbiddenError, IrohInvalidInputError } from "./errors"; + +const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex"); + +export type PairGrantPeer = { + readonly bindingId: string; + readonly deviceId: string; + readonly tag: string; + readonly endpointId: string; + readonly identityGeneration: number; +}; + +export type PairGrantClaims = { + readonly jti: string; + readonly iat: number; + readonly nbf: number; + readonly exp: number; + readonly alpn: typeof IROH_ALPN; + readonly scope: typeof IROH_PAIR_SCOPE; + readonly initiator: PairGrantPeer; + readonly acceptor: PairGrantPeer; +}; + +export type PairGrantVerificationExpectation = { + readonly initiator?: PairGrantPeer; + readonly acceptor?: PairGrantPeer; + readonly nowSeconds: number; +}; + +export function registrationTranscript(input: { + readonly challengeId: string; + readonly nonce: string; + readonly payloadSha256: string; +}): Uint8Array { + return Buffer.from( + `cmux/iroh/device-registration/v1\n${input.challengeId}\n${input.nonce}\n${input.payloadSha256}`, + "utf8", + ); +} + +export function verifyEndpointRegistrationSignature(input: { + readonly endpointId: string; + readonly challengeId: string; + readonly nonce: string; + readonly payloadSha256: string; + readonly signature: string; +}): void { + const publicKey = endpointPublicKey(input.endpointId); + const signature = Buffer.from(input.signature, "base64url"); + const valid = verify( + null, + registrationTranscript(input), + publicKey, + signature, + ); + if (!valid) throw new IrohForbiddenError({ code: "invalid_registration_signature" }); +} + +export function nonceHash(nonce: string): string { + return sha256(Buffer.from(nonce, "base64url")); +} + +export function hashesEqual(leftHex: string, rightHex: string): boolean { + if (!/^[0-9a-f]{64}$/.test(leftHex) || !/^[0-9a-f]{64}$/.test(rightHex)) return false; + return timingSafeEqual(Buffer.from(leftHex, "hex"), Buffer.from(rightHex, "hex")); +} + +export function deriveLanRendezvousKey( + secretBase64: string | undefined, + userId: string, + generation: number, +): string { + const secret = decodeSecret(secretBase64, "lan_discovery"); + if (!Number.isSafeInteger(generation) || generation < 1) { + throw new IrohConfigurationError({ component: "lan_discovery" }); + } + return createHmac("sha256", secret) + .update("cmux/iroh/lan-rendezvous/v1\0", "utf8") + .update(userId, "utf8") + .update("\0", "utf8") + .update(String(generation), "utf8") + .digest("base64url"); +} + +export function signPairGrant(input: { + readonly privateKeyPem: string | undefined; + readonly kid: string | undefined; + readonly claims: PairGrantClaims; +}): string { + const kid = validKid(input.kid, "grant_signing"); + let privateKey: KeyObject; + try { + privateKey = createPrivateKey(normalizePem(input.privateKeyPem, "grant_signing")); + } catch { + throw new IrohConfigurationError({ component: "grant_signing" }); + } + if (privateKey.asymmetricKeyType !== "ed25519") { + throw new IrohConfigurationError({ component: "grant_signing" }); + } + validatePairGrantClaims(input.claims, { nowSeconds: input.claims.iat }); + const encodedHeader = encodeJson({ alg: "EdDSA", typ: IROH_PAIR_GRANT_TYP, kid }); + const encodedClaims = encodeJson(input.claims); + const signingInput = `${encodedHeader}.${encodedClaims}`; + const signature = sign(null, Buffer.from(signingInput, "ascii"), privateKey).toString("base64url"); + return `${signingInput}.${signature}`; +} + +export function verifyPairGrant( + token: string, + publicKeys: ReadonlyMap, + expected: PairGrantVerificationExpectation, +): PairGrantClaims { + if (token.length > 16_384) throw new IrohInvalidInputError({ code: "invalid_pair_grant" }); + const parts = token.split("."); + if (parts.length !== 3) throw new IrohInvalidInputError({ code: "invalid_pair_grant" }); + const header = decodeJson(parts[0]); + assertExactKeys(header, ["alg", "typ", "kid"], "invalid_pair_grant_header"); + if (header.alg !== "EdDSA" || header.typ !== IROH_PAIR_GRANT_TYP || typeof header.kid !== "string") { + throw new IrohForbiddenError({ code: "invalid_pair_grant_header" }); + } + const keyPem = publicKeys.get(header.kid); + if (!keyPem) throw new IrohForbiddenError({ code: "unknown_pair_grant_kid" }); + let publicKey: KeyObject; + try { + publicKey = createPublicKey(normalizePem(keyPem, "grant_verification")); + } catch { + throw new IrohConfigurationError({ component: "grant_verification" }); + } + if (publicKey.asymmetricKeyType !== "ed25519") { + throw new IrohConfigurationError({ component: "grant_verification" }); + } + const valid = verify( + null, + Buffer.from(`${parts[0]}.${parts[1]}`, "ascii"), + publicKey, + decodeCanonicalBase64url(parts[2], 64), + ); + if (!valid) throw new IrohForbiddenError({ code: "invalid_pair_grant_signature" }); + const claims = decodeJson(parts[1]) as unknown as PairGrantClaims; + validatePairGrantClaims(claims, expected); + return claims; +} + +export function parseVerificationKeys(value: string | undefined): ReadonlyMap { + if (!value) return new Map(); + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + throw new IrohConfigurationError({ component: "grant_verification" }); + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new IrohConfigurationError({ component: "grant_verification" }); + } + const entries = Object.entries(parsed as Record); + if (entries.length < 1 || entries.length > 2) { + throw new IrohConfigurationError({ component: "grant_verification" }); + } + return new Map(entries.map(([kid, pem]) => [ + validKid(kid, "grant_verification"), + normalizePem(typeof pem === "string" ? pem : undefined, "grant_verification"), + ])); +} + +function validatePairGrantClaims( + value: PairGrantClaims, + expected: PairGrantVerificationExpectation, +): void { + if (!value || typeof value !== "object") throw new IrohForbiddenError({ code: "invalid_pair_grant_claims" }); + assertExactKeys(value as unknown as Record, [ + "jti", + "iat", + "nbf", + "exp", + "alpn", + "scope", + "initiator", + "acceptor", + ], "invalid_pair_grant_claims"); + if (typeof value.jti !== "string" || !UUID_PATTERN.test(value.jti)) { + throw new IrohForbiddenError({ code: "invalid_pair_grant_claims" }); + } + if (value.alpn !== IROH_ALPN) throw new IrohForbiddenError({ code: "invalid_pair_grant_alpn" }); + if (value.scope !== IROH_PAIR_SCOPE) throw new IrohForbiddenError({ code: "invalid_pair_grant_scope" }); + if (!Number.isSafeInteger(value.iat) || !Number.isSafeInteger(value.nbf) || !Number.isSafeInteger(value.exp)) { + throw new IrohForbiddenError({ code: "invalid_pair_grant_expiry" }); + } + if ( + value.nbf > expected.nowSeconds + 30 || + value.exp <= expected.nowSeconds || + value.exp <= value.nbf || + value.exp - value.iat > IROH_PAIR_GRANT_LIFETIME_SECONDS || + value.iat > expected.nowSeconds + 30 + ) { + throw new IrohForbiddenError({ code: "invalid_pair_grant_expiry" }); + } + validatePeer(value.initiator, "initiator"); + validatePeer(value.acceptor, "acceptor"); + if (expected.initiator && !samePeer(value.initiator, expected.initiator)) { + throw new IrohForbiddenError({ code: "pair_grant_initiator_mismatch" }); + } + if (expected.acceptor && !samePeer(value.acceptor, expected.acceptor)) { + throw new IrohForbiddenError({ code: "pair_grant_acceptor_mismatch" }); + } +} + +function validatePeer(peer: PairGrantPeer, side: string): void { + if ( + !peer || typeof peer !== "object" || + typeof peer.bindingId !== "string" || !UUID_PATTERN.test(peer.bindingId) || + typeof peer.deviceId !== "string" || !UUID_PATTERN.test(peer.deviceId) || + typeof peer.tag !== "string" || peer.tag.length < 1 || peer.tag.length > 64 || + !Number.isSafeInteger(peer.identityGeneration) || peer.identityGeneration < 1 + ) { + throw new IrohForbiddenError({ code: `invalid_pair_grant_${side}` }); + } + assertExactKeys(peer as unknown as Record, [ + "bindingId", + "deviceId", + "tag", + "endpointId", + "identityGeneration", + ], `invalid_pair_grant_${side}`); + endpointId(peer.endpointId); +} + +function samePeer(left: PairGrantPeer, right: PairGrantPeer): boolean { + return left.bindingId === right.bindingId && + left.deviceId === right.deviceId && + left.tag === right.tag && + left.endpointId === right.endpointId && + left.identityGeneration === right.identityGeneration; +} + +function endpointPublicKey(value: string): KeyObject { + const canonical = endpointId(value); + return createPublicKey({ + key: Buffer.concat([ED25519_SPKI_PREFIX, Buffer.from(canonical, "hex")]), + format: "der", + type: "spki", + }); +} + +function decodeSecret(value: string | undefined, component: "lan_discovery"): Buffer { + if (!value) throw new IrohConfigurationError({ component }); + const decoded = Buffer.from(value, "base64"); + if (decoded.byteLength < 32 || decoded.toString("base64").replace(/=+$/, "") !== value.replace(/=+$/, "")) { + throw new IrohConfigurationError({ component }); + } + return decoded; +} + +function validKid( + value: string | undefined, + component: "grant_signing" | "grant_verification", +): string { + if (!value || !/^[A-Za-z0-9._-]{1,64}$/.test(value)) { + throw new IrohConfigurationError({ component }); + } + return value; +} + +function normalizePem( + value: string | undefined, + component: "grant_signing" | "grant_verification", +): string { + if (!value) throw new IrohConfigurationError({ component }); + const normalized = value.replaceAll("\\n", "\n").trim(); + if (normalized.length > 16_384 || !normalized.includes("-----BEGIN")) { + throw new IrohConfigurationError({ component }); + } + return normalized; +} + +function encodeJson(value: unknown): string { + return Buffer.from(JSON.stringify(value), "utf8").toString("base64url"); +} + +function decodeJson(encoded: string): Record { + try { + const value = JSON.parse(decodeCanonicalBase64url(encoded).toString("utf8")); + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("not object"); + return value as Record; + } catch { + throw new IrohInvalidInputError({ code: "invalid_pair_grant" }); + } +} + +function decodeCanonicalBase64url(encoded: string, expectedLength?: number): Buffer { + if (!encoded || !/^[A-Za-z0-9_-]+$/.test(encoded)) { + throw new IrohInvalidInputError({ code: "invalid_pair_grant" }); + } + const decoded = Buffer.from(encoded, "base64url"); + if ( + decoded.toString("base64url") !== encoded || + (expectedLength !== undefined && decoded.byteLength !== expectedLength) + ) { + throw new IrohInvalidInputError({ code: "invalid_pair_grant" }); + } + return decoded; +} + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +function assertExactKeys( + value: Record, + allowed: readonly string[], + code: string, +): void { + const keys = Object.keys(value); + if (keys.length !== allowed.length || keys.some((key) => !allowed.includes(key))) { + throw new IrohForbiddenError({ code }); + } +} diff --git a/web/services/iroh/errors.ts b/web/services/iroh/errors.ts new file mode 100644 index 000000000000..d40a5085fa9f --- /dev/null +++ b/web/services/iroh/errors.ts @@ -0,0 +1,85 @@ +import * as Data from "effect/Data"; + +export class IrohInvalidInputError extends Data.TaggedError("IrohInvalidInputError")<{ + readonly code: string; +}> {} + +export class IrohNotFoundError extends Data.TaggedError("IrohNotFoundError")<{ + readonly resource: "binding" | "challenge"; +}> {} + +export class IrohForbiddenError extends Data.TaggedError("IrohForbiddenError")<{ + readonly code: string; +}> {} + +export class IrohConflictError extends Data.TaggedError("IrohConflictError")<{ + readonly code: string; +}> {} + +export class IrohQuotaExceededError extends Data.TaggedError("IrohQuotaExceededError")<{ + readonly code: string; + readonly retryAfterSeconds: number; +}> {} + +export class IrohConfigurationError extends Data.TaggedError("IrohConfigurationError")<{ + readonly component: "grant_signing" | "grant_verification" | "lan_discovery" | "relay_minter"; +}> {} + +export class IrohDatabaseError extends Data.TaggedError("IrohDatabaseError")<{ + readonly operation: string; + readonly cause: unknown; +}> {} + +export class IrohRelayMintError extends Data.TaggedError("IrohRelayMintError")<{ + readonly code: string; + readonly cause?: unknown; +}> {} + +export type IrohExpectedError = + | IrohInvalidInputError + | IrohNotFoundError + | IrohForbiddenError + | IrohConflictError + | IrohQuotaExceededError + | IrohConfigurationError + | IrohDatabaseError + | IrohRelayMintError; + +export function irohExpectedError(error: unknown): IrohExpectedError | null { + if (!error || typeof error !== "object") return null; + const tag = (error as { _tag?: unknown })._tag; + if (typeof tag === "string" && IROH_ERROR_TAGS.has(tag)) return error as IrohExpectedError; + + const symbol = Object.getOwnPropertySymbols(error).find( + (candidate) => candidate.description === "effect/Runtime/FiberFailure/Cause", + ); + if (symbol) return errorFromCause((error as Record)[symbol]); + return null; +} + +function errorFromCause(cause: unknown): IrohExpectedError | null { + if (!cause || typeof cause !== "object") return null; + const tag = (cause as { _tag?: unknown })._tag; + if (tag === "Fail") { + return irohExpectedError( + (cause as { failure?: unknown; error?: unknown }).failure ?? + (cause as { error?: unknown }).error, + ); + } + if (tag === "Sequential" || tag === "Parallel") { + return errorFromCause((cause as { left?: unknown }).left) ?? + errorFromCause((cause as { right?: unknown }).right); + } + return errorFromCause((cause as { cause?: unknown }).cause); +} + +const IROH_ERROR_TAGS = new Set([ + "IrohInvalidInputError", + "IrohNotFoundError", + "IrohForbiddenError", + "IrohConflictError", + "IrohQuotaExceededError", + "IrohConfigurationError", + "IrohDatabaseError", + "IrohRelayMintError", +]); diff --git a/web/services/iroh/model.ts b/web/services/iroh/model.ts new file mode 100644 index 000000000000..50a85bfb58a3 --- /dev/null +++ b/web/services/iroh/model.ts @@ -0,0 +1,556 @@ +import { createHash } from "node:crypto"; +import { isIP } from "node:net"; +import { IrohInvalidInputError } from "./errors"; + +export const IROH_ALPN = "cmux/mobile/1"; +export const IROH_PAIR_SCOPE = "cmux.mobile.attach"; +export const IROH_PAIR_GRANT_TYP = "cmux-pair-grant+jwt"; +export const IROH_CHALLENGE_LIFETIME_MS = 5 * 60 * 1_000; +export const IROH_PAIR_GRANT_LIFETIME_SECONDS = 7 * 24 * 60 * 60; +export const IROH_RELAY_TOKEN_LIFETIME_SECONDS = 24 * 60 * 60; +export const IROH_RELAY_TOKEN_REFRESH_SECONDS = 12 * 60 * 60; +export const IROH_ROUTE_CONTRACT_VERSION = 1; + +export const MANAGED_RELAY_URLS = [ + "https://euc1-1.relay.lawrence.cmux.iroh.link/", + "https://use1-1.relay.lawrence.cmux.iroh.link/", + "https://usw1-1.relay.lawrence.cmux.iroh.link/", + "https://aps1-1.relay.lawrence.cmux.iroh.link/", +] as const; + +export type IrohPathHint = { + readonly kind: "direct_address" | "relay_url"; + readonly value: string; + readonly source: "native" | "lan" | "tailscale" | "custom_vpn"; + readonly privacy_scope: "public_internet" | "local_network" | "private_network"; + readonly observed_at: string; + readonly expires_at: string; + readonly network_profile?: { + readonly source: "lan" | "tailscale" | "custom_vpn"; + readonly profile_id: string; + }; +}; + +export type IrohRegistrationPayload = { + readonly route_contract_version: typeof IROH_ROUTE_CONTRACT_VERSION; + readonly deviceId: string; + readonly appInstanceId: string; + readonly tag: string; + readonly platform: "mac" | "ios"; + readonly displayName?: string; + readonly endpointId: string; + readonly identityGeneration: number; + readonly pairingEnabled: boolean; + readonly capabilities: readonly string[]; + readonly pathHints: readonly IrohPathHint[]; +}; + +export type IrohChallengeRequest = Pick< + IrohRegistrationPayload, + "deviceId" | "appInstanceId" | "tag" | "endpointId" | "identityGeneration" +> & { + readonly payloadSha256: string; +}; + +export type IrohRegisterRequest = { + readonly challengeId: string; + readonly nonce: string; + readonly payload: string; + readonly signature: string; +}; + +export function parseChallengeRequest(value: unknown): IrohChallengeRequest { + const body = record(value); + const parsed: IrohChallengeRequest = { + deviceId: uuid(body.deviceId, "invalid_device_id"), + appInstanceId: uuid(body.appInstanceId, "invalid_app_instance_id"), + tag: safeTag(body.tag), + endpointId: endpointId(body.endpointId), + identityGeneration: positiveInteger(body.identityGeneration, "invalid_identity_generation"), + payloadSha256: sha256Hex(body.payloadSha256, "invalid_payload_hash"), + }; + rejectUnknownKeys(body, [ + "deviceId", + "appInstanceId", + "tag", + "endpointId", + "identityGeneration", + "payloadSha256", + ]); + return parsed; +} + +export function parseRegisterRequest(value: unknown): IrohRegisterRequest { + const body = record(value); + const parsed = { + challengeId: uuid(body.challengeId, "invalid_challenge_id"), + nonce: base64url(body.nonce, 32, "invalid_nonce"), + payload: boundedString(body.payload, 1, 48_000, "invalid_payload"), + signature: base64url(body.signature, 64, "invalid_signature"), + }; + rejectUnknownKeys(body, ["challengeId", "nonce", "payload", "signature"]); + return parsed; +} + +export function decodeRegistrationPayload(encoded: string, now: Date): { + readonly bytes: Uint8Array; + readonly sha256: string; + readonly payload: IrohRegistrationPayload; +} { + let bytes: Uint8Array; + try { + bytes = Buffer.from(encoded, "base64url"); + } catch { + throw new IrohInvalidInputError({ code: "invalid_payload" }); + } + if (bytes.byteLength === 0 || bytes.byteLength > 32_768) { + throw new IrohInvalidInputError({ code: "invalid_payload" }); + } + let raw: unknown; + try { + raw = JSON.parse(Buffer.from(bytes).toString("utf8")); + } catch { + throw new IrohInvalidInputError({ code: "invalid_payload" }); + } + return { + bytes, + sha256: sha256(bytes), + payload: parseRegistrationPayload(raw, now), + }; +} + +export function parseRegistrationPayload(value: unknown, now: Date): IrohRegistrationPayload { + const body = record(value); + const capabilitiesRaw = Array.isArray(body.capabilities) ? body.capabilities : null; + if (!capabilitiesRaw || capabilitiesRaw.length > 32) { + throw new IrohInvalidInputError({ code: "invalid_capabilities" }); + } + const capabilities = [...new Set(capabilitiesRaw.map((item) => { + const capability = boundedString(item, 1, 64, "invalid_capabilities"); + if (!/^[A-Za-z0-9._:-]+$/.test(capability)) { + throw new IrohInvalidInputError({ code: "invalid_capabilities" }); + } + return capability; + }))]; + const hintsRaw = Array.isArray(body.pathHints) ? body.pathHints : null; + if (!hintsRaw || hintsRaw.length > 16) { + throw new IrohInvalidInputError({ code: "invalid_path_hints" }); + } + const pathHints = hintsRaw.map((hint) => parseIrohPathHint(hint, now)); + if (pathHints.filter((hint) => hint.kind === "relay_url").length > 2) { + throw new IrohInvalidInputError({ code: "too_many_relay_hints" }); + } + if (new Set(pathHints.map((hint) => JSON.stringify(hint))).size !== pathHints.length) { + throw new IrohInvalidInputError({ code: "duplicate_path_hint" }); + } + const payload: IrohRegistrationPayload = { + route_contract_version: routeContractVersion(body.route_contract_version), + deviceId: uuid(body.deviceId, "invalid_device_id"), + appInstanceId: uuid(body.appInstanceId, "invalid_app_instance_id"), + tag: safeTag(body.tag), + platform: oneOf(body.platform, ["mac", "ios"] as const, "invalid_platform"), + ...(body.displayName === undefined || body.displayName === null + ? {} + : { displayName: safeDisplayName(body.displayName) }), + endpointId: endpointId(body.endpointId), + identityGeneration: positiveInteger(body.identityGeneration, "invalid_identity_generation"), + pairingEnabled: boolean(body.pairingEnabled, "invalid_pairing_enabled"), + capabilities, + pathHints, + }; + rejectUnknownKeys(body, [ + "deviceId", + "route_contract_version", + "appInstanceId", + "tag", + "platform", + "displayName", + "endpointId", + "identityGeneration", + "pairingEnabled", + "capabilities", + "pathHints", + ]); + return payload; +} + +export function parseBindingIdBody(value: unknown): { readonly bindingId: string } { + const body = record(value); + const result = { bindingId: uuid(body.bindingId, "invalid_binding_id") }; + rejectUnknownKeys(body, ["bindingId"]); + return result; +} + +export function parsePairGrantRequest(value: unknown): { + readonly initiatorBindingId: string; + readonly acceptorBindingId: string; +} { + const body = record(value); + const result = { + initiatorBindingId: uuid(body.initiatorBindingId, "invalid_initiator_binding_id"), + acceptorBindingId: uuid(body.acceptorBindingId, "invalid_acceptor_binding_id"), + }; + if (result.initiatorBindingId === result.acceptorBindingId) { + throw new IrohInvalidInputError({ code: "grant_peers_must_differ" }); + } + rejectUnknownKeys(body, ["initiatorBindingId", "acceptorBindingId"]); + return result; +} + +export function assertChallengeMatchesPayload( + challenge: IrohChallengeIdentity, + payload: IrohRegistrationPayload, +): void { + if ( + challenge.deviceUuid !== payload.deviceId || + challenge.appInstanceId !== payload.appInstanceId || + challenge.tag !== payload.tag || + challenge.endpointId !== payload.endpointId || + challenge.identityGeneration !== payload.identityGeneration + ) { + throw new IrohInvalidInputError({ code: "challenge_payload_mismatch" }); + } +} + +export type IrohChallengeIdentity = { + readonly deviceUuid: string; + readonly appInstanceId: string; + readonly tag: string; + readonly endpointId: string; + readonly identityGeneration: number; +}; + +export function parseIrohPathHint(value: unknown, now: Date): IrohPathHint { + const hint = record(value); + const kind = oneOf(hint.kind, ["direct_address", "relay_url"] as const, "invalid_path_hint_kind"); + const source = oneOf(hint.source, ["native", "lan", "tailscale", "custom_vpn"] as const, "invalid_path_hint_source"); + const privacyScope = oneOf( + hint.privacy_scope, + ["public_internet", "local_network", "private_network"] as const, + "invalid_path_hint_scope", + ); + const expectedScope = source === "native" + ? "public_internet" + : source === "lan" + ? "local_network" + : "private_network"; + if (privacyScope !== expectedScope) throw new IrohInvalidInputError({ code: "invalid_path_hint_scope" }); + + const observedAtRaw = boundedString(hint.observed_at, 1, 40, "invalid_path_hint_observation"); + const observedAt = new Date(observedAtRaw); + if ( + !Number.isFinite(observedAt.getTime()) || + observedAt.getTime() > now.getTime() + 5 * 60 * 1_000 || + observedAt.getTime() < now.getTime() - 60 * 60 * 1_000 + ) { + throw new IrohInvalidInputError({ code: "invalid_path_hint_observation" }); + } + const expiresAtRaw = boundedString(hint.expires_at, 1, 40, "invalid_path_hint_expiry"); + const expiresAt = new Date(expiresAtRaw); + if ( + !Number.isFinite(expiresAt.getTime()) || + expiresAt <= now || + expiresAt.getTime() > observedAt.getTime() + 60 * 60 * 1_000 + ) { + throw new IrohInvalidInputError({ code: "invalid_path_hint_expiry" }); + } + + const networkProfile = source === "native" + ? undefined + : parseNetworkProfile(hint.network_profile, source); + if (source === "native" && hint.network_profile !== undefined) { + throw new IrohInvalidInputError({ code: "invalid_path_hint_profile" }); + } + const hintValue = kind === "relay_url" + ? managedRelayUrl(hint.value, source) + : literalSocketAddress(hint.value, privacyScope, source); + if (kind === "relay_url" && source !== "native") { + throw new IrohInvalidInputError({ code: "invalid_relay_hint_source" }); + } + const parsed: IrohPathHint = { + kind, + value: hintValue, + source, + privacy_scope: privacyScope, + observed_at: observedAt.toISOString(), + expires_at: expiresAt.toISOString(), + ...(networkProfile ? { network_profile: networkProfile } : {}), + }; + rejectUnknownKeys(hint, [ + "kind", + "value", + "source", + "privacy_scope", + "observed_at", + "expires_at", + "network_profile", + ]); + return parsed; +} + +function literalSocketAddress( + value: unknown, + scope: IrohPathHint["privacy_scope"], + source: IrohPathHint["source"], +): string { + const address = boundedString(value, 3, 80, "invalid_path_hint_address"); + let host = ""; + let port = ""; + if (address.startsWith("[")) { + const close = address.indexOf("]"); + if (close <= 1 || address[close + 1] !== ":") { + throw new IrohInvalidInputError({ code: "invalid_path_hint_address" }); + } + host = address.slice(1, close); + port = address.slice(close + 2); + if (host.includes("%") || isIP(host) !== 6) { + throw new IrohInvalidInputError({ code: "invalid_path_hint_address" }); + } + } else { + const colon = address.lastIndexOf(":"); + if (colon <= 0) throw new IrohInvalidInputError({ code: "invalid_path_hint_address" }); + host = address.slice(0, colon); + port = address.slice(colon + 1); + if (isIP(host) !== 4) throw new IrohInvalidInputError({ code: "invalid_path_hint_address" }); + } + const numericPort = Number(port); + if ( + !/^\d{1,5}$/.test(port) || + !Number.isInteger(numericPort) || + numericPort < 1 || numericPort > 65_535 || + String(numericPort) !== port + ) { + throw new IrohInvalidInputError({ code: "invalid_path_hint_address" }); + } + assertAddressSafe(host, scope, source); + return isIP(host) === 6 + ? `[${canonicalIpv6(ipv6Bytes(host))}]:${numericPort}` + : `${host}:${numericPort}`; +} + +function parseNetworkProfile( + value: unknown, + source: Exclude, +): NonNullable { + const profile = record(value); + if (profile.source !== source) throw new IrohInvalidInputError({ code: "invalid_path_hint_profile" }); + const profileId = boundedString(profile.profile_id, 1, 96, "invalid_path_hint_profile"); + if (!/^[A-Za-z0-9._-]{1,96}$/.test(profileId)) { + throw new IrohInvalidInputError({ code: "invalid_path_hint_profile" }); + } + rejectUnknownKeys(profile, ["source", "profile_id"]); + return { source, profile_id: profileId }; +} + +function managedRelayUrl(value: unknown, source: IrohPathHint["source"]): string { + if (source !== "native" || typeof value !== "string" || !MANAGED_RELAY_URLS.includes(value as typeof MANAGED_RELAY_URLS[number])) { + throw new IrohInvalidInputError({ code: "unmanaged_relay_hint" }); + } + return value; +} + +function assertAddressSafe( + host: string, + scope: IrohPathHint["privacy_scope"], + source: IrohPathHint["source"], +): void { + const family = isIP(host); + const classification = family === 4 ? classifyIpv4(host) : classifyIpv6(host); + if (classification === "forbidden") { + throw new IrohInvalidInputError({ code: "unsafe_path_hint_address" }); + } + if (scope === "public_internet" && classification !== "public") { + throw new IrohInvalidInputError({ code: "unsafe_public_path_hint_address" }); + } + if (scope !== "public_internet" && classification === "public" && source !== "custom_vpn") { + throw new IrohInvalidInputError({ code: "unsafe_private_path_hint_address" }); + } + if (scope === "private_network" && classification === "ipv4_link_local") { + throw new IrohInvalidInputError({ code: "unsafe_private_path_hint_address" }); + } +} + +type AddressClassification = "public" | "private" | "ipv4_link_local" | "forbidden"; + +function classifyIpv4(host: string): AddressClassification { + const rawOctets = host.split("."); + const octets = rawOctets.map(Number); + if (rawOctets.some((octet, index) => String(octets[index]) !== octet)) return "forbidden"; + const [a, b, c, d] = octets as [number, number, number, number]; + if (a === 0 || a === 127 || a >= 224) return "forbidden"; + if (a === 169 && b === 254) { + if (c === 169 && d === 254) return "forbidden"; + return "ipv4_link_local"; + } + if (a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168)) return "private"; + if (a === 100 && b >= 64 && b <= 127) return "private"; + if ( + (a === 192 && b === 0 && (c === 0 || c === 2)) || + (a === 192 && b === 88 && c === 99) || + (a === 198 && (b === 18 || b === 19)) || + (a === 198 && b === 51 && c === 100) || + (a === 203 && b === 0 && c === 113) + ) return "forbidden"; + return "public"; +} + +function classifyIpv6(host: string): AddressClassification { + const bytes = ipv6Bytes(host); + const allZero = bytes.every((byte) => byte === 0); + const loopback = bytes.slice(0, 15).every((byte) => byte === 0) && bytes[15] === 1; + const multicast = bytes[0] === 0xff; + const linkLocal = bytes[0] === 0xfe && (bytes[1]! & 0xc0) === 0x80; + const documentation = bytes[0] === 0x20 && bytes[1] === 0x01 && bytes[2] === 0x0d && bytes[3] === 0xb8; + const mappedIpv4 = bytes.slice(0, 10).every((byte) => byte === 0) && bytes[10] === 0xff && bytes[11] === 0xff; + const metadata = bytesEqual(bytes, [0xfd, 0x00, 0x0e, 0xc2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x02, 0x54]); + if (allZero || loopback || multicast || linkLocal || documentation || mappedIpv4 || metadata) return "forbidden"; + if ((bytes[0]! & 0xfe) === 0xfc) return "private"; + return "public"; +} + +function ipv6Bytes(host: string): number[] { + const sides = host.toLowerCase().split("::"); + if (sides.length > 2) throw new IrohInvalidInputError({ code: "invalid_path_hint_address" }); + const left = sides[0] ? sides[0].split(":") : []; + const right = sides.length === 2 && sides[1] ? sides[1].split(":") : []; + const expand = (groups: string[]): number[] => groups.flatMap((group) => { + if (!/^[0-9a-f]{1,4}$/.test(group)) { + throw new IrohInvalidInputError({ code: "invalid_path_hint_address" }); + } + const value = Number.parseInt(group, 16); + return [(value >> 8) & 0xff, value & 0xff]; + }); + const missingGroups = 8 - left.length - right.length; + if ((sides.length === 1 && missingGroups !== 0) || missingGroups < (sides.length === 2 ? 1 : 0)) { + throw new IrohInvalidInputError({ code: "invalid_path_hint_address" }); + } + return [ + ...expand(left), + ...Array.from({ length: missingGroups * 2 }, () => 0), + ...expand(right), + ]; +} + +function canonicalIpv6(bytes: readonly number[]): string { + const groups = Array.from({ length: 8 }, (_, index) => + ((bytes[index * 2]! << 8) | bytes[index * 2 + 1]!).toString(16)); + let bestStart = -1; + let bestLength = 0; + for (let start = 0; start < groups.length;) { + if (groups[start] !== "0") { + start += 1; + continue; + } + let end = start; + while (end < groups.length && groups[end] === "0") end += 1; + if (end - start > bestLength && end - start >= 2) { + bestStart = start; + bestLength = end - start; + } + start = end; + } + if (bestStart < 0) return groups.join(":"); + const left = groups.slice(0, bestStart).join(":"); + const right = groups.slice(bestStart + bestLength).join(":"); + if (!left && !right) return "::"; + if (!left) return `::${right}`; + if (!right) return `${left}::`; + return `${left}::${right}`; +} + +function bytesEqual(left: readonly number[], right: readonly number[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +function routeContractVersion(value: unknown): typeof IROH_ROUTE_CONTRACT_VERSION { + if (value !== IROH_ROUTE_CONTRACT_VERSION) { + throw new IrohInvalidInputError({ code: "unsupported_route_contract" }); + } + return IROH_ROUTE_CONTRACT_VERSION; +} + +export function endpointId(value: unknown): string { + if (typeof value !== "string" || !/^[0-9a-f]{64}$/.test(value)) { + throw new IrohInvalidInputError({ code: "invalid_endpoint_id" }); + } + return value; +} + +export function sha256Hex(value: unknown, code: string): string { + if (typeof value !== "string" || !/^[0-9a-f]{64}$/.test(value)) { + throw new IrohInvalidInputError({ code }); + } + return value; +} + +export function sha256(value: Uint8Array | string): string { + return createHash("sha256").update(value).digest("hex"); +} + +function base64url(value: unknown, decodedLength: number, code: string): string { + if (typeof value !== "string" || value.length > decodedLength * 2 || !/^[A-Za-z0-9_-]+$/.test(value)) { + throw new IrohInvalidInputError({ code }); + } + const decoded = Buffer.from(value, "base64url"); + if (decoded.byteLength !== decodedLength || decoded.toString("base64url") !== value) { + throw new IrohInvalidInputError({ code }); + } + return value; +} + +function uuid(value: unknown, code: string): string { + if (typeof value !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)) { + throw new IrohInvalidInputError({ code }); + } + return value.toLowerCase(); +} + +function positiveInteger(value: unknown, code: string): number { + if (!Number.isSafeInteger(value) || (value as number) < 1) throw new IrohInvalidInputError({ code }); + return value as number; +} + +function boundedString(value: unknown, min: number, max: number, code: string): string { + if (typeof value !== "string" || value.length < min || value.length > max) { + throw new IrohInvalidInputError({ code }); + } + return value; +} + +function safeTag(value: unknown): string { + const tag = boundedString(value, 1, 64, "invalid_tag"); + if (!/^[A-Za-z0-9._-]+$/.test(tag)) throw new IrohInvalidInputError({ code: "invalid_tag" }); + return tag; +} + +function safeDisplayName(value: unknown): string { + const displayName = boundedString(value, 1, 128, "invalid_display_name"); + if (/[\u0000-\u001f\u007f]/.test(displayName)) { + throw new IrohInvalidInputError({ code: "invalid_display_name" }); + } + return displayName; +} + +function boolean(value: unknown, code: string): boolean { + if (typeof value !== "boolean") throw new IrohInvalidInputError({ code }); + return value; +} + +function record(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new IrohInvalidInputError({ code: "invalid_json" }); + } + return value as Record; +} + +function rejectUnknownKeys(value: Record, allowed: readonly string[]): void { + const allow = new Set(allowed); + if (Object.keys(value).some((key) => !allow.has(key))) { + throw new IrohInvalidInputError({ code: "unknown_field" }); + } +} + +function oneOf(value: unknown, allowed: T, code: string): T[number] { + if (typeof value !== "string" || !allowed.includes(value)) throw new IrohInvalidInputError({ code }); + return value as T[number]; +} diff --git a/web/services/iroh/relayMinter.ts b/web/services/iroh/relayMinter.ts new file mode 100644 index 000000000000..40662e0c722c --- /dev/null +++ b/web/services/iroh/relayMinter.ts @@ -0,0 +1,157 @@ +import { createHash, createHmac } from "node:crypto"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { IrohConfigurationError, IrohRelayMintError } from "./errors"; +import { IrohTrustBrokerConfig } from "./config"; +import { IROH_RELAY_TOKEN_LIFETIME_SECONDS, endpointId } from "./model"; + +const MAX_MINTER_RESPONSE_BYTES = 32 * 1_024; + +export type IrohRelayMintResult = { + readonly token: string; + readonly expiresAt: Date; +}; + +export type IrohRelayMinterShape = { + readonly mint: (input: { + readonly endpointId: string; + readonly lifetimeSeconds: typeof IROH_RELAY_TOKEN_LIFETIME_SECONDS; + readonly now: Date; + }) => Effect.Effect; +}; + +export class IrohRelayMinter extends Context.Tag("cmux/IrohRelayMinter")< + IrohRelayMinter, + IrohRelayMinterShape +>() {} + +export const IrohRelayMinterLive = Layer.effect( + IrohRelayMinter, + Effect.gen(function* () { + const config = yield* IrohTrustBrokerConfig; + return { + mint: (input) => mintWithIsolatedService(config, input), + } satisfies IrohRelayMinterShape; + }), +); + +function mintWithIsolatedService( + config: typeof IrohTrustBrokerConfig.Service, + input: Parameters[0], +): Effect.Effect { + return Effect.tryPromise({ + try: async () => { + endpointId(input.endpointId); + const url = configuredUrl(config.relayMinterUrl); + const secret = parseMinterHmacSecret(config.relayMinterHmacSecretBase64); + const body = JSON.stringify({ + endpointId: input.endpointId, + lifetimeSeconds: IROH_RELAY_TOKEN_LIFETIME_SECONDS, + }); + const timestamp = String(Math.floor(input.now.getTime() / 1_000)); + const bodyHash = createHash("sha256").update(body).digest("hex"); + const signature = createHmac("sha256", secret) + .update(`POST\n${url.pathname}\n${timestamp}\n${bodyHash}`, "utf8") + .digest("base64url"); + const response = await fetch(url, { + method: "POST", + redirect: "error", + signal: AbortSignal.timeout(10_000), + headers: { + "content-type": "application/json", + "x-cmux-iroh-timestamp": timestamp, + "x-cmux-iroh-signature": signature, + }, + body, + }); + if (!response.ok) throw new IrohRelayMintError({ code: "minter_rejected" }); + const raw = await readBoundedMinterJson(response); + if (typeof raw.token !== "string" || raw.token.length < 16 || raw.token.length > 16_384) { + throw new IrohRelayMintError({ code: "invalid_minter_response" }); + } + if (typeof raw.expiresAt !== "string") throw new IrohRelayMintError({ code: "invalid_minter_response" }); + const expiresAt = new Date(raw.expiresAt); + const contractExpiry = input.now.getTime() + IROH_RELAY_TOKEN_LIFETIME_SECONDS * 1_000; + if ( + !Number.isFinite(expiresAt.getTime()) || + expiresAt <= input.now || + expiresAt.getTime() > contractExpiry + 60_000 || + expiresAt.getTime() < contractExpiry - 5 * 60_000 + ) { + throw new IrohRelayMintError({ code: "invalid_minter_expiry" }); + } + return { token: raw.token, expiresAt }; + }, + catch: (cause) => { + if ((cause as { _tag?: unknown } | null)?._tag === "IrohConfigurationError") { + return cause as IrohConfigurationError; + } + if ((cause as { _tag?: unknown } | null)?._tag === "IrohRelayMintError") { + return cause as IrohRelayMintError; + } + return new IrohRelayMintError({ code: "minter_unavailable", cause: safeCause(cause) }); + }, + }); +} + +export async function readBoundedMinterJson( + response: Response, +): Promise<{ token?: unknown; expiresAt?: unknown }> { + const contentLength = response.headers.get("content-length"); + if (contentLength) { + const parsed = Number(contentLength); + if (!Number.isSafeInteger(parsed) || parsed < 0 || parsed > MAX_MINTER_RESPONSE_BYTES) { + throw new IrohRelayMintError({ code: "minter_response_too_large" }); + } + } + const reader = response.body?.getReader(); + if (!reader) throw new IrohRelayMintError({ code: "invalid_minter_response" }); + const chunks: Uint8Array[] = []; + let total = 0; + while (true) { + const next = await reader.read(); + if (next.done) break; + total += next.value.byteLength; + if (total > MAX_MINTER_RESPONSE_BYTES) { + await reader.cancel(); + throw new IrohRelayMintError({ code: "minter_response_too_large" }); + } + chunks.push(next.value); + } + const bytes = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)), total); + let parsed: unknown; + try { + parsed = JSON.parse(bytes.toString("utf8")); + } catch { + throw new IrohRelayMintError({ code: "invalid_minter_response" }); + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new IrohRelayMintError({ code: "invalid_minter_response" }); + } + return parsed as { token?: unknown; expiresAt?: unknown }; +} + +function configuredUrl(value: string | undefined): URL { + if (!value) throw new IrohConfigurationError({ component: "relay_minter" }); + const url = new URL(value); + if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash) { + throw new IrohConfigurationError({ component: "relay_minter" }); + } + return url; +} + +export function parseMinterHmacSecret(value: string | undefined): Buffer { + if (!value) throw new IrohConfigurationError({ component: "relay_minter" }); + const decoded = Buffer.from(value, "base64"); + const canonicalInput = value.replace(/=+$/, ""); + const canonicalDecoded = decoded.toString("base64").replace(/=+$/, ""); + if (decoded.byteLength < 32 || canonicalInput !== canonicalDecoded) { + throw new IrohConfigurationError({ component: "relay_minter" }); + } + return decoded; +} + +function safeCause(cause: unknown): unknown { + return cause instanceof Error ? { name: cause.name } : { type: typeof cause }; +} diff --git a/web/services/iroh/repository.ts b/web/services/iroh/repository.ts new file mode 100644 index 000000000000..3b04ed155232 --- /dev/null +++ b/web/services/iroh/repository.ts @@ -0,0 +1,709 @@ +import { and, asc, count, eq, gt, inArray, isNotNull, isNull, lt, or, sql } from "drizzle-orm"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { cloudDb } from "../../db/client"; +import { + irohAccountSecurityStates, + irohEndpointBindings, + irohPairGrantIssuances, + irohRegistrationChallenges, + irohRelayTokenIssuances, +} from "../../db/schema"; +import { + IrohConflictError, + IrohDatabaseError, + IrohForbiddenError, + IrohNotFoundError, + IrohQuotaExceededError, +} from "./errors"; +import { parseIrohPathHint, sha256, type IrohRegistrationPayload } from "./model"; + +export type IrohBindingRecord = typeof irohEndpointBindings.$inferSelect; +export type IrohChallengeRecord = typeof irohRegistrationChallenges.$inferSelect; + +type RepositoryError = + | IrohDatabaseError + | IrohForbiddenError + | IrohNotFoundError + | IrohConflictError + | IrohQuotaExceededError; + +export type IrohRepositoryShape = { + readonly issueChallenge: (input: { + readonly userId: string; + readonly deviceUuid: string; + readonly appInstanceId: string; + readonly tag: string; + readonly endpointId: string; + readonly identityGeneration: number; + readonly payloadSha256: string; + readonly nonceHash: string; + readonly now: Date; + readonly expiresAt: Date; + }) => Effect.Effect; + readonly findChallenge: ( + userId: string, + challengeId: string, + ) => Effect.Effect; + readonly consumeChallengeAndRegister: (input: { + readonly userId: string; + readonly challengeId: string; + readonly nonceHash: string; + readonly payload: IrohRegistrationPayload; + readonly now: Date; + readonly deviceLimitOverrideAllowed: boolean; + }) => Effect.Effect; + readonly listActiveBindings: ( + userId: string, + ) => Effect.Effect; + readonly findActiveBindings: ( + userId: string, + bindingIds: readonly string[], + ) => Effect.Effect; + readonly revokeBinding: (input: { + readonly userId: string; + readonly bindingId: string; + readonly now: Date; + }) => Effect.Effect; + readonly accountLanGeneration: (input: { + readonly userId: string; + readonly now: Date; + }) => Effect.Effect; + readonly pruneExpiredState: (input: { + readonly userId: string; + readonly now: Date; + }) => Effect.Effect; + readonly pruneExpiredStateGlobally: (input: { + readonly now: Date; + }) => Effect.Effect; + readonly recordPairGrant: (input: { + readonly userId: string; + readonly jti: string; + readonly initiatorBindingId: string; + readonly acceptorBindingId: string; + readonly signingKeyId: string; + readonly alpn: string; + readonly scope: string; + readonly issuedAt: Date; + readonly notBefore: Date; + readonly expiresAt: Date; + }) => Effect.Effect; + readonly reserveRelayIssuance: (input: { + readonly userId: string; + readonly bindingId: string; + readonly now: Date; + }) => Effect.Effect<{ + readonly issuanceId: string; + readonly binding: IrohBindingRecord; + }, RepositoryError>; + readonly completeRelayIssuance: (input: { + readonly issuanceId: string; + readonly tokenHash: string; + readonly completedAt: Date; + readonly expiresAt: Date; + }) => Effect.Effect; + readonly failRelayIssuance: (input: { + readonly issuanceId: string; + readonly completedAt: Date; + readonly failureCode: string; + }) => Effect.Effect; +}; + +export class IrohRepository extends Context.Tag("cmux/IrohRepository")< + IrohRepository, + IrohRepositoryShape +>() {} + +export const IrohRepositoryLive = Layer.succeed(IrohRepository, makeLiveRepository()); + +function makeLiveRepository(): IrohRepositoryShape { + return { + issueChallenge: (input) => repositoryEffect("issue_challenge", async () => { + const db = cloudDb(); + return await db.transaction(async (tx) => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`iroh:challenge:${input.userId}`}, 0))`); + const tenMinutesAgo = new Date(input.now.getTime() - 10 * 60 * 1_000); + const [recentForDevice] = await tx + .select({ total: count() }) + .from(irohRegistrationChallenges) + .where(and( + eq(irohRegistrationChallenges.userId, input.userId), + eq(irohRegistrationChallenges.deviceUuid, input.deviceUuid), + gt(irohRegistrationChallenges.createdAt, tenMinutesAgo), + )); + if ((recentForDevice?.total ?? 0) >= 6) { + throw new IrohQuotaExceededError({ code: "challenge_rate_limited", retryAfterSeconds: 600 }); + } + const [outstanding] = await tx + .select({ total: count() }) + .from(irohRegistrationChallenges) + .where(and( + eq(irohRegistrationChallenges.userId, input.userId), + isNull(irohRegistrationChallenges.consumedAt), + gt(irohRegistrationChallenges.expiresAt, input.now), + )); + if ((outstanding?.total ?? 0) >= 32) { + throw new IrohQuotaExceededError({ code: "too_many_outstanding_challenges", retryAfterSeconds: 300 }); + } + const [challenge] = await tx + .insert(irohRegistrationChallenges) + .values({ + userId: input.userId, + deviceUuid: input.deviceUuid, + appInstanceId: input.appInstanceId, + tag: input.tag, + endpointId: input.endpointId, + identityGeneration: input.identityGeneration, + payloadSha256: input.payloadSha256, + nonceHash: input.nonceHash, + createdAt: input.now, + expiresAt: input.expiresAt, + }) + .returning(); + if (!challenge) throw new Error("challenge insert returned no row"); + return challenge; + }); + }), + + findChallenge: (userId, challengeId) => repositoryEffect("find_challenge", async () => { + const [challenge] = await cloudDb() + .select() + .from(irohRegistrationChallenges) + .where(and( + eq(irohRegistrationChallenges.id, challengeId), + eq(irohRegistrationChallenges.userId, userId), + )) + .limit(1); + return challenge ?? null; + }), + + consumeChallengeAndRegister: (input) => repositoryEffect("register_binding", async () => { + const db = cloudDb(); + return await db.transaction(async (tx) => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`iroh:binding:${input.userId}`}, 0))`); + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`iroh:endpoint:${input.payload.endpointId}`}, 0))`); + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`iroh:app:${input.payload.appInstanceId}`}, 0))`); + const [challenge] = await tx + .select() + .from(irohRegistrationChallenges) + .where(and( + eq(irohRegistrationChallenges.id, input.challengeId), + eq(irohRegistrationChallenges.userId, input.userId), + )) + .for("update") + .limit(1); + if (!challenge) throw new IrohNotFoundError({ resource: "challenge" }); + if (challenge.consumedAt) throw new IrohConflictError({ code: "challenge_replayed" }); + if (challenge.expiresAt <= input.now) throw new IrohForbiddenError({ code: "challenge_expired" }); + if (challenge.nonceHash !== input.nonceHash) throw new IrohForbiddenError({ code: "invalid_challenge_nonce" }); + + const [existingApp] = await tx + .select() + .from(irohEndpointBindings) + .where(and( + eq(irohEndpointBindings.appInstanceId, input.payload.appInstanceId), + isNull(irohEndpointBindings.revokedAt), + )) + .for("update") + .limit(1); + + if (existingApp) { + if ( + existingApp.userId !== input.userId || + existingApp.endpointId !== input.payload.endpointId || + existingApp.identityGeneration !== input.payload.identityGeneration || + existingApp.deviceUuid !== input.payload.deviceId || + existingApp.tag !== input.payload.tag + ) { + throw new IrohConflictError({ code: "binding_replacement_requires_revocation" }); + } + const [updated] = await tx + .update(irohEndpointBindings) + .set({ + platform: input.payload.platform, + displayName: input.payload.displayName ?? null, + pairingEnabled: input.payload.pairingEnabled, + capabilities: [...input.payload.capabilities], + pathHints: [...input.payload.pathHints], + lastSeenAt: input.now, + updatedAt: input.now, + }) + .where(eq(irohEndpointBindings.id, existingApp.id)) + .returning(); + await tx + .update(irohRegistrationChallenges) + .set({ consumedAt: input.now }) + .where(eq(irohRegistrationChallenges.id, challenge.id)); + if (!updated) throw new Error("binding update returned no row"); + return updated; + } + + const [endpointOwner] = await tx + .select({ id: irohEndpointBindings.id }) + .from(irohEndpointBindings) + .where(and( + eq(irohEndpointBindings.endpointId, input.payload.endpointId), + isNull(irohEndpointBindings.revokedAt), + )) + .for("update") + .limit(1); + if (endpointOwner) throw new IrohConflictError({ code: "endpoint_already_bound" }); + + const [userTotal] = await tx + .select({ total: count() }) + .from(irohEndpointBindings) + .where(and( + eq(irohEndpointBindings.userId, input.userId), + isNull(irohEndpointBindings.revokedAt), + )); + if ((userTotal?.total ?? 0) >= 32) { + throw new IrohQuotaExceededError({ code: "too_many_bindings", retryAfterSeconds: 86_400 }); + } + const [deviceTotal] = await tx + .select({ total: count() }) + .from(irohEndpointBindings) + .where(and( + eq(irohEndpointBindings.userId, input.userId), + eq(irohEndpointBindings.deviceUuid, input.payload.deviceId), + isNull(irohEndpointBindings.revokedAt), + )); + const usesDeviceOverride = (deviceTotal?.total ?? 0) >= 8; + if (usesDeviceOverride && !input.deviceLimitOverrideAllowed) { + throw new IrohQuotaExceededError({ code: "too_many_device_bindings", retryAfterSeconds: 86_400 }); + } + + const [binding] = await tx + .insert(irohEndpointBindings) + .values({ + userId: input.userId, + deviceUuid: input.payload.deviceId, + appInstanceId: input.payload.appInstanceId, + tag: input.payload.tag, + platform: input.payload.platform, + displayName: input.payload.displayName ?? null, + endpointId: input.payload.endpointId, + identityGeneration: input.payload.identityGeneration, + pairingEnabled: input.payload.pairingEnabled, + capabilities: [...input.payload.capabilities], + pathHints: [...input.payload.pathHints], + deviceLimitOverrideUsed: usesDeviceOverride, + lastSeenAt: input.now, + registeredAt: input.now, + updatedAt: input.now, + }) + .returning(); + if (!binding) throw new Error("binding insert returned no row"); + await tx + .insert(irohAccountSecurityStates) + .values({ userId: input.userId, lanDiscoveryGeneration: 1, createdAt: input.now, updatedAt: input.now }) + .onConflictDoNothing({ target: irohAccountSecurityStates.userId }); + await tx + .update(irohRegistrationChallenges) + .set({ consumedAt: input.now }) + .where(and( + eq(irohRegistrationChallenges.id, challenge.id), + isNull(irohRegistrationChallenges.consumedAt), + )); + return binding; + }); + }), + + listActiveBindings: (userId) => repositoryEffect("list_bindings", async () => { + return await cloudDb() + .select() + .from(irohEndpointBindings) + .where(and( + eq(irohEndpointBindings.userId, userId), + isNull(irohEndpointBindings.revokedAt), + )) + .orderBy(asc(irohEndpointBindings.registeredAt)); + }), + + findActiveBindings: (userId, bindingIds) => repositoryEffect("find_bindings", async () => { + if (bindingIds.length === 0) return []; + return await cloudDb() + .select() + .from(irohEndpointBindings) + .where(and( + eq(irohEndpointBindings.userId, userId), + inArray(irohEndpointBindings.id, [...bindingIds]), + isNull(irohEndpointBindings.revokedAt), + )); + }), + + revokeBinding: (input) => repositoryEffect("revoke_binding", async () => { + return await cloudDb().transaction(async (tx) => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`iroh:binding:${input.userId}`}, 0))`); + const revoked = await tx + .update(irohEndpointBindings) + .set({ + revokedAt: input.now, + revokedReason: "user_requested", + pathHints: [], + updatedAt: input.now, + }) + .where(and( + eq(irohEndpointBindings.id, input.bindingId), + eq(irohEndpointBindings.userId, input.userId), + isNull(irohEndpointBindings.revokedAt), + )) + .returning({ id: irohEndpointBindings.id }); + if (revoked.length === 0) return false; + await tx + .update(irohPairGrantIssuances) + .set({ revokedAt: input.now }) + .where(and( + isNull(irohPairGrantIssuances.revokedAt), + or( + eq(irohPairGrantIssuances.initiatorBindingId, input.bindingId), + eq(irohPairGrantIssuances.acceptorBindingId, input.bindingId), + ), + )); + await tx + .insert(irohAccountSecurityStates) + .values({ userId: input.userId, lanDiscoveryGeneration: 2, createdAt: input.now, updatedAt: input.now }) + .onConflictDoUpdate({ + target: irohAccountSecurityStates.userId, + set: { + lanDiscoveryGeneration: sql`${irohAccountSecurityStates.lanDiscoveryGeneration} + 1`, + updatedAt: input.now, + }, + }); + return true; + }); + }), + + accountLanGeneration: (input) => repositoryEffect("lan_generation", async () => { + const [state] = await cloudDb() + .insert(irohAccountSecurityStates) + .values({ userId: input.userId, lanDiscoveryGeneration: 1, createdAt: input.now, updatedAt: input.now }) + .onConflictDoUpdate({ + target: irohAccountSecurityStates.userId, + set: { updatedAt: sql`${irohAccountSecurityStates.updatedAt}` }, + }) + .returning({ generation: irohAccountSecurityStates.lanDiscoveryGeneration }); + if (!state) throw new Error("account security state returned no row"); + return state.generation; + }), + + pruneExpiredState: (input) => repositoryEffect("prune_expired_state", async () => { + await cloudDb().transaction(async (tx) => { + const bindings = await tx + .select({ + id: irohEndpointBindings.id, + pathHints: irohEndpointBindings.pathHints, + revokedAt: irohEndpointBindings.revokedAt, + }) + .from(irohEndpointBindings) + .where(eq(irohEndpointBindings.userId, input.userId)); + for (const binding of bindings) { + const retained = binding.revokedAt + ? [] + : binding.pathHints.filter((hint) => isUnexpiredStoredHint(hint, input.now)); + if (retained.length !== binding.pathHints.length) { + await tx + .update(irohEndpointBindings) + .set({ pathHints: retained, updatedAt: input.now }) + .where(eq(irohEndpointBindings.id, binding.id)); + } + } + + const challengeRetentionCutoff = new Date(input.now.getTime() - 24 * 60 * 60 * 1_000); + await tx + .delete(irohRegistrationChallenges) + .where(and( + eq(irohRegistrationChallenges.userId, input.userId), + or( + lt(irohRegistrationChallenges.expiresAt, challengeRetentionCutoff), + and( + isNotNull(irohRegistrationChallenges.consumedAt), + lt(irohRegistrationChallenges.consumedAt, challengeRetentionCutoff), + ), + ), + )); + + const auditRetentionCutoff = new Date(input.now.getTime() - 30 * 24 * 60 * 60 * 1_000); + await tx + .delete(irohRelayTokenIssuances) + .where(and( + eq(irohRelayTokenIssuances.userId, input.userId), + lt(irohRelayTokenIssuances.requestedAt, auditRetentionCutoff), + )); + await tx + .delete(irohPairGrantIssuances) + .where(and( + eq(irohPairGrantIssuances.userId, input.userId), + lt(irohPairGrantIssuances.expiresAt, auditRetentionCutoff), + )); + await tx.execute(sql` + delete from iroh_endpoint_bindings as binding + where binding.user_id = ${input.userId} + and binding.revoked_at < ${auditRetentionCutoff.toISOString()}::timestamptz + and not exists ( + select 1 from iroh_pair_grant_issuances as pair_grant + where pair_grant.initiator_binding_id = binding.id + or pair_grant.acceptor_binding_id = binding.id + ) + and not exists ( + select 1 from iroh_relay_token_issuances as issuance + where issuance.binding_id = binding.id + ) + `); + }); + }), + + pruneExpiredStateGlobally: (input) => repositoryEffect("prune_expired_state_globally", async () => { + const challengeRetentionCutoff = new Date(input.now.getTime() - 24 * 60 * 60 * 1_000); + const auditRetentionCutoff = new Date(input.now.getTime() - 30 * 24 * 60 * 60 * 1_000); + await cloudDb().transaction(async (tx) => { + await tx.execute(sql` + update iroh_endpoint_bindings as binding + set + path_hints = coalesce(( + select jsonb_agg(hint) + from jsonb_array_elements(binding.path_hints) as hints(hint) + where binding.revoked_at is null + and case + when jsonb_typeof(hint) = 'object' + and jsonb_typeof(hint -> 'expires_at') = 'string' + and (hint ->> 'expires_at') ~ '^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$' + then (hint ->> 'expires_at') > ${input.now.toISOString()} + else false + end + ), '[]'::jsonb), + updated_at = ${input.now.toISOString()}::timestamptz + where binding.path_hints <> '[]'::jsonb + and ( + binding.revoked_at is not null + or exists ( + select 1 + from jsonb_array_elements(binding.path_hints) as candidates(candidate) + where case + when jsonb_typeof(candidate) = 'object' + and jsonb_typeof(candidate -> 'expires_at') = 'string' + and (candidate ->> 'expires_at') ~ '^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$' + then (candidate ->> 'expires_at') <= ${input.now.toISOString()} + else true + end + ) + ) + `); + await tx + .delete(irohRegistrationChallenges) + .where(or( + lt(irohRegistrationChallenges.expiresAt, challengeRetentionCutoff), + and( + isNotNull(irohRegistrationChallenges.consumedAt), + lt(irohRegistrationChallenges.consumedAt, challengeRetentionCutoff), + ), + )); + await tx + .delete(irohRelayTokenIssuances) + .where(lt(irohRelayTokenIssuances.requestedAt, auditRetentionCutoff)); + await tx + .delete(irohPairGrantIssuances) + .where(lt(irohPairGrantIssuances.expiresAt, auditRetentionCutoff)); + await tx.execute(sql` + delete from iroh_endpoint_bindings as binding + where binding.revoked_at < ${auditRetentionCutoff.toISOString()}::timestamptz + and not exists ( + select 1 from iroh_pair_grant_issuances as pair_grant + where pair_grant.initiator_binding_id = binding.id + or pair_grant.acceptor_binding_id = binding.id + ) + and not exists ( + select 1 from iroh_relay_token_issuances as issuance + where issuance.binding_id = binding.id + ) + `); + }); + }), + + recordPairGrant: (input) => repositoryEffect("record_pair_grant", async () => { + await cloudDb().transaction(async (tx) => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`iroh:pair-grant:${input.userId}`}, 0))`); + const hourAgo = new Date(input.issuedAt.getTime() - 60 * 60 * 1_000); + const recent = await tx + .select({ issuedAt: irohPairGrantIssuances.issuedAt }) + .from(irohPairGrantIssuances) + .where(and( + eq(irohPairGrantIssuances.userId, input.userId), + gt(irohPairGrantIssuances.issuedAt, hourAgo), + )) + .orderBy(asc(irohPairGrantIssuances.issuedAt)); + if (recent.length >= 60) { + throw quotaFromOldest( + "pair_grant_hour_quota", + recent[recent.length - 60]!.issuedAt, + 60 * 60, + input.issuedAt, + ); + } + await tx.insert(irohPairGrantIssuances).values(input); + }); + }), + + reserveRelayIssuance: (input) => repositoryEffect("reserve_relay_issuance", async () => { + return await cloudDb().transaction(async (tx) => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`iroh:relay:${input.userId}`}, 0))`); + const [binding] = await tx + .select() + .from(irohEndpointBindings) + .where(and( + eq(irohEndpointBindings.id, input.bindingId), + eq(irohEndpointBindings.userId, input.userId), + isNull(irohEndpointBindings.revokedAt), + )) + .for("update") + .limit(1); + if (!binding) throw new IrohNotFoundError({ resource: "binding" }); + + const dayAgo = new Date(input.now.getTime() - 24 * 60 * 60 * 1_000); + const tenMinutesAgo = new Date(input.now.getTime() - 10 * 60 * 1_000); + const endpointRows = await tx + .select({ requestedAt: irohRelayTokenIssuances.requestedAt }) + .from(irohRelayTokenIssuances) + .where(and( + eq(irohRelayTokenIssuances.bindingId, binding.id), + gt(irohRelayTokenIssuances.requestedAt, dayAgo), + )) + .orderBy(asc(irohRelayTokenIssuances.requestedAt)); + const recentRows = endpointRows.filter((row) => row.requestedAt > tenMinutesAgo); + if (recentRows.length >= 3) { + throw quotaFromOldest("relay_endpoint_10m_quota", recentRows[recentRows.length - 3]!.requestedAt, 10 * 60, input.now); + } + if (endpointRows.length >= 12) { + throw quotaFromOldest("relay_endpoint_day_quota", endpointRows[endpointRows.length - 12]!.requestedAt, 24 * 60 * 60, input.now); + } + const userRows = await tx + .select({ requestedAt: irohRelayTokenIssuances.requestedAt }) + .from(irohRelayTokenIssuances) + .where(and( + eq(irohRelayTokenIssuances.userId, input.userId), + gt(irohRelayTokenIssuances.requestedAt, dayAgo), + )) + .orderBy(asc(irohRelayTokenIssuances.requestedAt)); + if (userRows.length >= 100) { + throw quotaFromOldest("relay_user_day_quota", userRows[userRows.length - 100]!.requestedAt, 24 * 60 * 60, input.now); + } + + const [issuance] = await tx + .insert(irohRelayTokenIssuances) + .values({ + userId: input.userId, + bindingId: binding.id, + endpointIdHash: sha256(binding.endpointId), + status: "pending", + requestedAt: input.now, + }) + .returning({ id: irohRelayTokenIssuances.id }); + if (!issuance) throw new Error("relay issuance insert returned no row"); + return { issuanceId: issuance.id, binding }; + }); + }), + + completeRelayIssuance: (input) => repositoryEffect("complete_relay_issuance", async () => { + await cloudDb() + .update(irohRelayTokenIssuances) + .set({ + status: "succeeded", + tokenHash: input.tokenHash, + completedAt: input.completedAt, + expiresAt: input.expiresAt, + failureCode: null, + }) + .where(eq(irohRelayTokenIssuances.id, input.issuanceId)); + }), + + failRelayIssuance: (input) => repositoryEffect("fail_relay_issuance", async () => { + await cloudDb() + .update(irohRelayTokenIssuances) + .set({ status: "failed", completedAt: input.completedAt, failureCode: input.failureCode.slice(0, 64) }) + .where(eq(irohRelayTokenIssuances.id, input.issuanceId)); + }), + }; +} + +function repositoryEffect( + operation: string, + run: () => Promise, +): Effect.Effect { + return Effect.tryPromise({ + try: run, + catch: (cause) => { + if (isDomainError(cause)) return cause; + const conflict = databaseConflict(cause); + return conflict ?? new IrohDatabaseError({ operation, cause: sanitizedDatabaseCause(cause) }); + }, + }); +} + +function isDomainError(error: unknown): error is + | IrohForbiddenError + | IrohNotFoundError + | IrohConflictError + | IrohQuotaExceededError { + const tag = (error as { _tag?: unknown } | null)?._tag; + return tag === "IrohForbiddenError" || tag === "IrohNotFoundError" || + tag === "IrohConflictError" || tag === "IrohQuotaExceededError"; +} + +function quotaFromOldest( + code: string, + oldest: Date, + windowSeconds: number, + now: Date, +): IrohQuotaExceededError { + const retryAfterSeconds = Math.max( + 1, + Math.ceil((oldest.getTime() + windowSeconds * 1_000 - now.getTime()) / 1_000), + ); + return new IrohQuotaExceededError({ code, retryAfterSeconds }); +} + +function sanitizedDatabaseCause(cause: unknown): unknown { + const candidate = databaseCause(cause); + return { + code: typeof candidate?.code === "string" ? candidate.code : undefined, + name: typeof candidate?.name === "string" ? candidate.name : undefined, + }; +} + +function databaseConflict(cause: unknown): IrohConflictError | null { + const candidate = databaseCause(cause); + if (candidate?.code !== "23505") return null; + if (candidate.constraint === "iroh_endpoint_bindings_active_endpoint_unique") { + return new IrohConflictError({ code: "endpoint_already_bound" }); + } + if (candidate.constraint === "iroh_endpoint_bindings_active_app_instance_unique") { + return new IrohConflictError({ code: "binding_replacement_requires_revocation" }); + } + return null; +} + +function databaseCause(cause: unknown): { + readonly code?: unknown; + readonly name?: unknown; + readonly constraint?: unknown; +} | null { + let current = cause; + const seen = new Set(); + for (let depth = 0; depth < 5; depth += 1) { + if (!current || typeof current !== "object" || seen.has(current)) return null; + seen.add(current); + const candidate = current as { code?: unknown; name?: unknown; constraint?: unknown; cause?: unknown }; + if (typeof candidate.code === "string") return candidate; + current = candidate.cause; + } + return null; +} + +function isUnexpiredStoredHint(hint: unknown, now: Date): boolean { + try { + parseIrohPathHint(hint, now); + return true; + } catch { + return false; + } +} diff --git a/web/services/iroh/routeHandler.ts b/web/services/iroh/routeHandler.ts new file mode 100644 index 000000000000..c8060168c590 --- /dev/null +++ b/web/services/iroh/routeHandler.ts @@ -0,0 +1,189 @@ +import { checkRateLimit } from "@vercel/firewall"; +import { createHash } from "node:crypto"; +import * as Effect from "effect/Effect"; +import type * as Layer from "effect/Layer"; +import { env } from "../../app/env"; +import { unauthorized, verifyRequest, type AuthedUser } from "../vms/auth"; +import { enforceBrowserMutationProtection, jsonResponse } from "../vms/routeHelpers"; +import { irohExpectedError } from "./errors"; +import { + IrohTrustBroker, + IrohTrustBrokerRuntime, + type IrohTrustBrokerShape, +} from "./trustBroker"; + +const MAX_BODY_BYTES = 64 * 1_024; + +export type IrohRouteOperation = + | "challenge" + | "register" + | "discover" + | "revoke" + | "pair_grant" + | "relay_token"; + +type RouteDependencies = { + readonly verify?: typeof verifyRequest; + readonly broker?: IrohTrustBrokerShape; + readonly runtime?: Layer.Layer; +}; + +export async function handleIrohRoute( + request: Request, + operation: IrohRouteOperation, + dependencies: RouteDependencies = {}, +): Promise { + const verify = dependencies.verify ?? verifyRequest; + let user: AuthedUser | null; + try { + user = await verify(request, { allowCookie: false }); + } catch { + return jsonResponse({ error: "unauthorized" }, 401); + } + if (!user) return unauthorized(); + + if (process.env.VERCEL === "1" && env.CMUX_IROH_RATE_LIMIT_ID) { + const { error, rateLimited } = await checkRateLimit(env.CMUX_IROH_RATE_LIMIT_ID, { + request, + rateLimitKey: createHash("sha256").update(`iroh-rate:${user.id}:${operation}`).digest("hex"), + }); + if (rateLimited || error === "blocked") { + return irohJsonResponse({ error: "rate_limited" }, 429, { "retry-after": "60" }); + } + if (error) { + console.error("iroh trust broker firewall unavailable", { operation, failure: error }); + return jsonResponse({ error: "iroh_service_unavailable" }, 503); + } + } + + if (operation !== "discover") { + const mutationForbidden = enforceBrowserMutationProtection(request); + if (mutationForbidden) return mutationForbidden; + } + + const bodyResult = operation === "discover" + ? { ok: true as const, value: undefined } + : await readBoundedJson(request); + if (!bodyResult.ok) return bodyResult.response; + + try { + const value = dependencies.broker + ? await Effect.runPromise(invoke(dependencies.broker, operation, user.id, bodyResult.value)) + : await Effect.runPromise( + Effect.gen(function* () { + const broker = yield* IrohTrustBroker; + return yield* invoke(broker, operation, user.id, bodyResult.value); + }).pipe(Effect.provide(dependencies.runtime ?? IrohTrustBrokerRuntime)), + ); + return irohJsonResponse(value, successStatus(operation), { + "cache-control": "no-store", + }); + } catch (error) { + const expected = irohExpectedError(error); + if (expected) return expectedErrorResponse(expected); + // Do not include EndpointIDs, hints, grants, or tokens in logs. The route + // and coarse failure class are enough for operational correlation. + console.error("iroh trust broker request failed", { operation, failure: "unexpected" }); + return jsonResponse({ error: "iroh_internal_error" }, 500); + } +} + +function invoke( + broker: IrohTrustBrokerShape, + operation: IrohRouteOperation, + userId: string, + body: unknown, +) { + switch (operation) { + case "challenge": return broker.issueChallenge(userId, body); + case "register": return broker.register(userId, body); + case "discover": return broker.discover(userId); + case "revoke": return broker.revoke(userId, body); + case "pair_grant": return broker.issuePairGrant(userId, body); + case "relay_token": return broker.issueRelayToken(userId, body); + } +} + +async function readBoundedJson(request: Request): Promise< + | { readonly ok: true; readonly value: unknown } + | { readonly ok: false; readonly response: Response } +> { + if (request.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() !== "application/json") { + return { ok: false, response: jsonResponse({ error: "unsupported_media_type" }, 415) }; + } + const contentLength = request.headers.get("content-length"); + if (contentLength) { + const parsed = Number(contentLength); + if (!Number.isSafeInteger(parsed) || parsed < 0 || parsed > MAX_BODY_BYTES) { + return { ok: false, response: jsonResponse({ error: "request_too_large" }, 413) }; + } + } + const reader = request.body?.getReader(); + if (!reader) return { ok: false, response: jsonResponse({ error: "missing_body" }, 400) }; + const chunks: Uint8Array[] = []; + let total = 0; + try { + while (true) { + const next = await reader.read(); + if (next.done) break; + total += next.value.byteLength; + if (total > MAX_BODY_BYTES) { + await reader.cancel(); + return { ok: false, response: jsonResponse({ error: "request_too_large" }, 413) }; + } + chunks.push(next.value); + } + } catch { + return { ok: false, response: jsonResponse({ error: "invalid_body" }, 400) }; + } + if (total === 0) return { ok: false, response: jsonResponse({ error: "missing_body" }, 400) }; + const bytes = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)), total); + try { + return { ok: true, value: JSON.parse(bytes.toString("utf8")) }; + } catch { + return { ok: false, response: jsonResponse({ error: "invalid_json" }, 400) }; + } +} + +function successStatus(operation: IrohRouteOperation): number { + return operation === "discover" || operation === "revoke" ? 200 : 201; +} + +function expectedErrorResponse(error: ReturnType & object): Response { + const tag = (error as { _tag?: string })._tag; + if (tag === "IrohInvalidInputError") { + return jsonResponse({ error: (error as { code: string }).code }, 400); + } + if (tag === "IrohForbiddenError") { + return jsonResponse({ error: (error as { code: string }).code }, 403); + } + if (tag === "IrohNotFoundError") { + return jsonResponse({ error: `${(error as { resource: string }).resource}_not_found` }, 404); + } + if (tag === "IrohConflictError") { + return jsonResponse({ error: (error as { code: string }).code }, 409); + } + if (tag === "IrohQuotaExceededError") { + const quota = error as { code: string; retryAfterSeconds: number }; + return irohJsonResponse( + { error: quota.code, retry_after_seconds: quota.retryAfterSeconds }, + 429, + { "retry-after": String(quota.retryAfterSeconds) }, + ); + } + if (tag === "IrohConfigurationError" || tag === "IrohRelayMintError") { + return jsonResponse({ error: "iroh_service_unavailable" }, 503); + } + return jsonResponse({ error: "iroh_database_unavailable" }, 503); +} + +function irohJsonResponse( + value: unknown, + status: number, + headers: Record, +): Response { + return new Response(JSON.stringify(value), { + status, + headers: { "content-type": "application/json", ...headers }, + }); +} diff --git a/web/services/iroh/trustBroker.ts b/web/services/iroh/trustBroker.ts new file mode 100644 index 000000000000..f292c453a837 --- /dev/null +++ b/web/services/iroh/trustBroker.ts @@ -0,0 +1,356 @@ +import { randomBytes, randomUUID } from "node:crypto"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { + deriveLanRendezvousKey, + hashesEqual, + nonceHash, + parseVerificationKeys, + signPairGrant, + verifyEndpointRegistrationSignature, + verifyPairGrant, + type PairGrantClaims, + type PairGrantPeer, +} from "./crypto"; +import { + deviceLimitOverrideAllowed, + IrohTrustBrokerConfig, + IrohTrustBrokerConfigLive, + type IrohTrustBrokerConfigShape, +} from "./config"; +import { + IrohConflictError, + IrohConfigurationError, + IrohForbiddenError, + IrohInvalidInputError, + IrohNotFoundError, + type IrohExpectedError, +} from "./errors"; +import { + IROH_ALPN, + IROH_CHALLENGE_LIFETIME_MS, + IROH_PAIR_GRANT_LIFETIME_SECONDS, + IROH_PAIR_SCOPE, + IROH_RELAY_TOKEN_LIFETIME_SECONDS, + IROH_RELAY_TOKEN_REFRESH_SECONDS, + MANAGED_RELAY_URLS, + assertChallengeMatchesPayload, + decodeRegistrationPayload, + parseBindingIdBody, + parseChallengeRequest, + parseIrohPathHint, + parsePairGrantRequest, + parseRegisterRequest, + sha256, + type IrohPathHint, +} from "./model"; +import { + IrohRepository, + IrohRepositoryLive, + type IrohBindingRecord, + type IrohRepositoryShape, +} from "./repository"; +import { + IrohRelayMinter, + IrohRelayMinterLive, + type IrohRelayMinterShape, +} from "./relayMinter"; + +export type IrohTrustBrokerShape = { + readonly issueChallenge: ( + userId: string, + raw: unknown, + now?: Date, + ) => Effect.Effect; + readonly register: ( + userId: string, + raw: unknown, + now?: Date, + ) => Effect.Effect; + readonly discover: ( + userId: string, + now?: Date, + ) => Effect.Effect; + readonly revoke: ( + userId: string, + raw: unknown, + now?: Date, + ) => Effect.Effect; + readonly issuePairGrant: ( + userId: string, + raw: unknown, + now?: Date, + ) => Effect.Effect; + readonly issueRelayToken: ( + userId: string, + raw: unknown, + now?: Date, + ) => Effect.Effect; +}; + +export class IrohTrustBroker extends Context.Tag("cmux/IrohTrustBroker")< + IrohTrustBroker, + IrohTrustBrokerShape +>() {} + +export function makeIrohTrustBroker( + repository: IrohRepositoryShape, + relayMinter: IrohRelayMinterShape, + config: IrohTrustBrokerConfigShape, +): IrohTrustBrokerShape { + const issueRelayToken = ( + userId: string, + raw: unknown, + now = new Date(), + ): Effect.Effect => Effect.gen(function* () { + const { bindingId } = yield* parseEffect(() => parseBindingIdBody(raw)); + const reservation = yield* repository.reserveRelayIssuance({ userId, bindingId, now }); + const minted = yield* relayMinter.mint({ + endpointId: reservation.binding.endpointId, + lifetimeSeconds: IROH_RELAY_TOKEN_LIFETIME_SECONDS, + now, + }).pipe( + Effect.matchEffect({ + onFailure: (error) => repository.failRelayIssuance({ + issuanceId: reservation.issuanceId, + completedAt: new Date(), + failureCode: error._tag === "IrohRelayMintError" ? error.code : "not_configured", + }).pipe(Effect.flatMap(() => Effect.fail(error))), + onSuccess: Effect.succeed, + }), + ); + const completedAt = new Date(); + yield* repository.completeRelayIssuance({ + issuanceId: reservation.issuanceId, + tokenHash: sha256(minted.token), + completedAt, + expiresAt: minted.expiresAt, + }); + return { + token: minted.token, + expires_at: minted.expiresAt.toISOString(), + refresh_after: new Date(now.getTime() + IROH_RELAY_TOKEN_REFRESH_SECONDS * 1_000).toISOString(), + relay_fleet: MANAGED_RELAY_URLS, + }; + }); + + return { + issueChallenge: (userId, raw, now = new Date()) => Effect.gen(function* () { + const request = yield* parseEffect(() => parseChallengeRequest(raw)); + const nonce = randomBytes(32).toString("base64url"); + const challenge = yield* repository.issueChallenge({ + userId, + deviceUuid: request.deviceId, + appInstanceId: request.appInstanceId, + tag: request.tag, + endpointId: request.endpointId, + identityGeneration: request.identityGeneration, + payloadSha256: request.payloadSha256, + nonceHash: nonceHash(nonce), + now, + expiresAt: new Date(now.getTime() + IROH_CHALLENGE_LIFETIME_MS), + }); + return { + challenge_id: challenge.id, + nonce, + expires_at: challenge.expiresAt.toISOString(), + }; + }), + + register: (userId, raw, now = new Date()) => Effect.gen(function* () { + const request = yield* parseEffect(() => parseRegisterRequest(raw)); + const decoded = yield* parseEffect(() => decodeRegistrationPayload(request.payload, now)); + const challenge = yield* repository.findChallenge(userId, request.challengeId); + if (!challenge) return yield* Effect.fail(new IrohNotFoundError({ resource: "challenge" })); + if (challenge.consumedAt) return yield* Effect.fail(new IrohConflictError({ code: "challenge_replayed" })); + if (challenge.expiresAt <= now) return yield* Effect.fail(new IrohForbiddenError({ code: "challenge_expired" })); + if (!hashesEqual(challenge.payloadSha256, decoded.sha256)) { + return yield* Effect.fail(new IrohForbiddenError({ code: "payload_hash_mismatch" })); + } + if (!hashesEqual(challenge.nonceHash, nonceHash(request.nonce))) { + return yield* Effect.fail(new IrohForbiddenError({ code: "invalid_challenge_nonce" })); + } + yield* parseEffect(() => assertChallengeMatchesPayload(challenge, decoded.payload)); + yield* parseEffect(() => verifyEndpointRegistrationSignature({ + endpointId: decoded.payload.endpointId, + challengeId: request.challengeId, + nonce: request.nonce, + payloadSha256: decoded.sha256, + signature: request.signature, + })); + const binding = yield* repository.consumeChallengeAndRegister({ + userId, + challengeId: challenge.id, + nonceHash: nonceHash(request.nonce), + payload: decoded.payload, + now, + deviceLimitOverrideAllowed: deviceLimitOverrideAllowed(config, userId), + }); + + // Registration is already committed before relay minting starts. The + // dedicated relay route can retry independently if this bootstrap mint + // fails, so provider availability can never roll back device identity. + const relay = yield* issueRelayToken(userId, { bindingId: binding.id }, now).pipe( + Effect.map((value) => ({ status: "issued" as const, ...value as object })), + Effect.catchAll(() => Effect.succeed({ status: "unavailable" as const })), + ); + return { binding: publicBinding(binding, now), relay }; + }), + + discover: (userId, now = new Date()) => Effect.gen(function* () { + yield* repository.pruneExpiredState({ userId, now }); + const bindings = yield* repository.listActiveBindings(userId); + const generation = yield* repository.accountLanGeneration({ userId, now }); + const rendezvousKey = yield* parseEffect(() => deriveLanRendezvousKey( + config.lanDiscoverySecretBase64, + userId, + generation, + )); + return { + route_contract_version: 1, + bindings: bindings.map((binding) => publicBinding(binding, now)), + relay_fleet: MANAGED_RELAY_URLS, + lan_rendezvous: { generation, key: rendezvousKey }, + }; + }), + + revoke: (userId, raw, now = new Date()) => Effect.gen(function* () { + const { bindingId } = yield* parseEffect(() => parseBindingIdBody(raw)); + const revoked = yield* repository.revokeBinding({ userId, bindingId, now }); + if (!revoked) return yield* Effect.fail(new IrohNotFoundError({ resource: "binding" })); + return { revoked: true, lan_rendezvous_rotated: true }; + }), + + issuePairGrant: (userId, raw, now = new Date()) => Effect.gen(function* () { + const request = yield* parseEffect(() => parsePairGrantRequest(raw)); + const bindings = yield* repository.findActiveBindings(userId, [ + request.initiatorBindingId, + request.acceptorBindingId, + ]); + if (bindings.length !== 2) return yield* Effect.fail(new IrohNotFoundError({ resource: "binding" })); + const byId = new Map(bindings.map((binding) => [binding.id, binding])); + const initiator = byId.get(request.initiatorBindingId); + const acceptor = byId.get(request.acceptorBindingId); + if (!initiator || !acceptor) return yield* Effect.fail(new IrohNotFoundError({ resource: "binding" })); + if (acceptor.platform !== "mac" || !acceptor.pairingEnabled) { + return yield* Effect.fail(new IrohForbiddenError({ code: "target_not_pairable" })); + } + const issuedAtSeconds = Math.floor(now.getTime() / 1_000); + const claims: PairGrantClaims = { + jti: randomUUID(), + iat: issuedAtSeconds, + nbf: issuedAtSeconds - 5, + exp: issuedAtSeconds + IROH_PAIR_GRANT_LIFETIME_SECONDS, + alpn: IROH_ALPN, + scope: IROH_PAIR_SCOPE, + initiator: grantPeer(initiator), + acceptor: grantPeer(acceptor), + }; + const signingKeyId = yield* parseEffect(() => { + if (!config.grantSigningKid || !/^[A-Za-z0-9._-]{1,64}$/.test(config.grantSigningKid)) { + throw new IrohConfigurationError({ component: "grant_signing" }); + } + return config.grantSigningKid; + }); + const verificationKeys = yield* parseEffect(() => parseVerificationKeys(config.grantVerificationKeysJson)); + const token = yield* parseEffect(() => signPairGrant({ + privateKeyPem: config.grantSigningPrivateKeyPem, + kid: signingKeyId, + claims, + })); + yield* parseEffect(() => verifyPairGrant(token, verificationKeys, { + initiator: claims.initiator, + acceptor: claims.acceptor, + nowSeconds: issuedAtSeconds, + })); + yield* repository.recordPairGrant({ + userId, + jti: claims.jti, + initiatorBindingId: initiator.id, + acceptorBindingId: acceptor.id, + signingKeyId, + alpn: IROH_ALPN, + scope: IROH_PAIR_SCOPE, + issuedAt: new Date(claims.iat * 1_000), + notBefore: new Date(claims.nbf * 1_000), + expiresAt: new Date(claims.exp * 1_000), + }); + return { grant: token, expires_at: new Date(claims.exp * 1_000).toISOString() }; + }), + + issueRelayToken, + }; +} + +export const IrohTrustBrokerLive = Layer.effect( + IrohTrustBroker, + Effect.gen(function* () { + return makeIrohTrustBroker( + yield* IrohRepository, + yield* IrohRelayMinter, + yield* IrohTrustBrokerConfig, + ); + }), +); + +const IrohRelayMinterWithConfig = IrohRelayMinterLive.pipe( + Layer.provide(IrohTrustBrokerConfigLive), +); + +export const IrohTrustBrokerRuntime = IrohTrustBrokerLive.pipe( + Layer.provide(Layer.mergeAll( + IrohRepositoryLive, + IrohTrustBrokerConfigLive, + IrohRelayMinterWithConfig, + )), +); + +function parseEffect(run: () => A): Effect.Effect { + return Effect.try({ + try: run, + catch: (error) => { + const tag = (error as { _tag?: unknown } | null)?._tag; + if (typeof tag === "string" && tag.startsWith("Iroh")) return error as IrohExpectedError; + return new IrohInvalidInputError({ code: "invalid_input" }); + }, + }); +} + +function publicBinding(binding: IrohBindingRecord, now: Date): object { + return { + binding_id: binding.id, + device_id: binding.deviceUuid, + app_instance_id: binding.appInstanceId, + tag: binding.tag, + platform: binding.platform, + display_name: binding.displayName, + endpoint_id: binding.endpointId, + identity_generation: binding.identityGeneration, + pairing_enabled: binding.pairingEnabled, + capabilities: binding.capabilities, + path_hints: binding.pathHints.flatMap((hint): IrohPathHint[] => { + try { + return [parseIrohPathHint(hint, now)]; + } catch { + return []; + } + }), + last_seen_at: binding.lastSeenAt.toISOString(), + }; +} + +function grantPeer(binding: IrohBindingRecord): PairGrantPeer { + return { + bindingId: binding.id, + deviceId: binding.deviceUuid, + tag: binding.tag, + endpointId: binding.endpointId, + identityGeneration: binding.identityGeneration, + }; +} + +// Stack bearer authentication alone is never sufficient to mutate path hints. +// Until the dedicated endpoint-signed monotonic update route lands, clients +// refresh watch_addr output only through a new signed registration challenge. +export const IROH_SIGNED_PATH_HINT_UPDATE_FOLLOWUP = "endpoint-signed-monotonic-watch-addr-update-v1"; diff --git a/web/tests/client-config-env.test.ts b/web/tests/client-config-env.test.ts index eabd98c1727e..ed1e8c7893ff 100644 --- a/web/tests/client-config-env.test.ts +++ b/web/tests/client-config-env.test.ts @@ -41,10 +41,23 @@ describe("client config env validation", () => { VERCEL: "1", VERCEL_ENV: "production", CMUX_CLIENT_CONFIG_RATE_LIMIT_ID: "client-config-rule", + CMUX_IROH_RATE_LIMIT_ID: "iroh-rule", }); expect(result.exitCode).toBe(0); }); + + test("requires the Iroh limiter id in explicit Vercel production deployments", () => { + const result = importEnv({ + ...requiredEnv, + VERCEL: "1", + VERCEL_ENV: "production", + CMUX_CLIENT_CONFIG_RATE_LIMIT_ID: "client-config-rule", + }); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("CMUX_IROH_RATE_LIMIT_ID is required"); + }); }); function importEnv(env: Record): { exitCode: number; stderr: string } { diff --git a/web/tests/iroh-db-behavior.test.ts b/web/tests/iroh-db-behavior.test.ts new file mode 100644 index 000000000000..7022e80ba93b --- /dev/null +++ b/web/tests/iroh-db-behavior.test.ts @@ -0,0 +1,322 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import * as Effect from "effect/Effect"; +import postgres, { type Sql } from "postgres"; +import { closeCloudDbForTests } from "../db/client"; +import { + IrohRepository, + IrohRepositoryLive, + type IrohRepositoryShape, +} from "../services/iroh/repository"; + +const runDbTests = process.env.CMUX_DB_TEST === "1"; +const dbTest = runDbTests ? test : test.skip; +const NOW = new Date("2026-07-09T20:00:00.000Z"); + +let sql: Sql | null = null; +let repository: IrohRepositoryShape | null = null; + +beforeAll(async () => { + if (!runDbTests) return; + const databaseURL = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL; + if (!databaseURL) throw new Error("DATABASE_URL is required when CMUX_DB_TEST=1"); + sql = postgres(databaseURL, { max: 8 }); + repository = await Effect.runPromise( + Effect.gen(function* () { return yield* IrohRepository; }).pipe( + Effect.provide(IrohRepositoryLive), + ), + ); +}); + +beforeEach(async () => { + if (!sql) return; + await sql` + truncate + iroh_relay_token_issuances, + iroh_pair_grant_issuances, + iroh_registration_challenges, + iroh_endpoint_bindings, + iroh_account_security_states + restart identity cascade + `; +}); + +afterAll(async () => { + await closeCloudDbForTests(); + await sql?.end(); +}); + +describe("Iroh trust broker database behavior", () => { + dbTest("atomically consumes a challenge exactly once under concurrency", async () => { + const repo = requiredRepository(); + const deviceId = randomUUID(); + const appInstanceId = randomUUID(); + const endpointId = "10".repeat(32); + const nonceHash = "20".repeat(32); + const challenge = await Effect.runPromise(repo.issueChallenge({ + userId: "user-registration", + deviceUuid: deviceId, + appInstanceId, + tag: "stable", + endpointId, + identityGeneration: 1, + payloadSha256: "30".repeat(32), + nonceHash, + now: NOW, + expiresAt: new Date(NOW.getTime() + 5 * 60 * 1_000), + })); + const register = () => Effect.runPromise(repo.consumeChallengeAndRegister({ + userId: "user-registration", + challengeId: challenge.id, + nonceHash, + payload: { + route_contract_version: 1, + deviceId, + appInstanceId, + tag: "stable", + platform: "mac", + endpointId, + identityGeneration: 1, + pairingEnabled: true, + capabilities: [], + pathHints: [], + }, + now: NOW, + deviceLimitOverrideAllowed: false, + })); + const results = await Promise.allSettled([register(), register()]); + expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1); + expect(results.filter((result) => result.status === "rejected")).toHaveLength(1); + const [{ bindings, consumed }] = await requiredSql()>` + select + (select count(*)::text from iroh_endpoint_bindings) as bindings, + (select count(*)::text from iroh_registration_challenges where consumed_at is not null) as consumed + `; + expect({ bindings, consumed }).toEqual({ bindings: "1", consumed: "1" }); + }); + + dbTest("enforces globally unique active EndpointIDs and app instances", async () => { + const appInstanceId = randomUUID(); + const endpointId = "40".repeat(32); + await insertBinding({ userId: "user-a", appInstanceId, endpointId }); + await expectPostgresError(insertBinding({ userId: "user-b", endpointId }), "23505"); + await expectPostgresError(insertBinding({ userId: "user-b", appInstanceId, endpointId: "41".repeat(32) }), "23505"); + await expectPostgresError(insertBinding({ userId: "user-a", endpointId: "not-an-endpoint" }), "23514"); + await expectPostgresError(requiredSql()` + insert into iroh_endpoint_bindings ( + user_id, device_uuid, app_instance_id, tag, platform, endpoint_id, identity_generation + ) values ( + 'user-a', ${randomUUID()}, ${randomUUID()}, 'stable', 'linux', ${"42".repeat(32)}, 1 + ) + `, "23514"); + }); + + dbTest("serializes the pair-grant hourly quota", async () => { + const repo = requiredRepository(); + const initiatorId = await insertBinding({ userId: "user-pair", platform: "ios", endpointId: "50".repeat(32) }); + const acceptorId = await insertBinding({ userId: "user-pair", platform: "mac", endpointId: "51".repeat(32) }); + for (let index = 0; index < 59; index += 1) { + await requiredSql()` + insert into iroh_pair_grant_issuances ( + user_id, jti, initiator_binding_id, acceptor_binding_id, signing_key_id, + alpn, scope, issued_at, not_before, expires_at + ) values ( + 'user-pair', ${randomUUID()}, ${initiatorId}, ${acceptorId}, 'current', + 'cmux/mobile/1', 'cmux.mobile.attach', + ${new Date(NOW.getTime() - index * 1_000)}, + ${new Date(NOW.getTime() - index * 1_000)}, + ${new Date(NOW.getTime() + 7 * 24 * 60 * 60 * 1_000)} + ) + `; + } + const reserve = () => Effect.runPromise(repo.recordPairGrant({ + userId: "user-pair", + jti: randomUUID(), + initiatorBindingId: initiatorId, + acceptorBindingId: acceptorId, + signingKeyId: "current", + alpn: "cmux/mobile/1", + scope: "cmux.mobile.attach", + issuedAt: NOW, + notBefore: NOW, + expiresAt: new Date(NOW.getTime() + 7 * 24 * 60 * 60 * 1_000), + })); + const results = await Promise.allSettled([reserve(), reserve()]); + expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1); + expect(results.filter((result) => result.status === "rejected")).toHaveLength(1); + const [{ total }] = await requiredSql()>` + select count(*)::text as total from iroh_pair_grant_issuances where user_id = 'user-pair' + `; + expect(total).toBe("60"); + }); + + dbTest("serializes relay quota reservations before provider work", async () => { + const repo = requiredRepository(); + const bindingId = await insertBinding({ userId: "user-relay", endpointId: "60".repeat(32) }); + for (let index = 0; index < 2; index += 1) { + await requiredSql()` + insert into iroh_relay_token_issuances ( + user_id, binding_id, endpoint_id_hash, status, requested_at + ) values ( + 'user-relay', ${bindingId}, ${"70".repeat(32)}, 'failed', + ${new Date(NOW.getTime() - index * 1_000)} + ) + `; + } + const reserve = () => Effect.runPromise(repo.reserveRelayIssuance({ + userId: "user-relay", + bindingId, + now: NOW, + })); + const results = await Promise.allSettled([reserve(), reserve()]); + expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1); + expect(results.filter((result) => result.status === "rejected")).toHaveLength(1); + const [{ total }] = await requiredSql()>` + select count(*)::text as total from iroh_relay_token_issuances where binding_id = ${bindingId} + `; + expect(total).toBe("3"); + }); + + dbTest("global retention clears revoked hints and expired private data from Aurora", async () => { + const repo = requiredRepository(); + const activeId = await insertBinding({ + userId: "user-retention", + endpointId: "80".repeat(32), + pathHints: [ + storedLanHint("10.0.0.1:4433", "2026-07-09T18:55:00.000Z", "2026-07-09T19:00:00.000Z"), + storedLanHint("10.0.0.2:4433", "2026-07-09T19:55:00.000Z", "2026-07-09T20:30:00.000Z"), + ], + }); + const revokedId = await insertBinding({ + userId: "user-retention", + endpointId: "81".repeat(32), + pathHints: [storedLanHint("10.0.0.3:4433", "2026-07-09T19:55:00.000Z", "2026-07-09T20:30:00.000Z")], + }); + const untouchedId = await insertBinding({ + userId: "user-retention", + endpointId: "82".repeat(32), + pathHints: [storedLanHint("10.0.0.4:4433", "2026-07-09T19:55:00.000Z", "2026-07-09T20:30:00.000Z")], + }); + const oldRevokedId = await insertBinding({ + userId: "user-retention", + endpointId: "83".repeat(32), + }); + await requiredSql()` + update iroh_endpoint_bindings + set revoked_at = ${new Date(NOW.getTime() - 31 * 24 * 60 * 60 * 1_000)} + where id = ${oldRevokedId} + `; + const [untouchedBefore] = await requiredSql()>` + select updated_at as "updatedAt" from iroh_endpoint_bindings where id = ${untouchedId} + `; + await requiredSql()` + insert into iroh_pair_grant_issuances ( + user_id, jti, initiator_binding_id, acceptor_binding_id, signing_key_id, + alpn, scope, issued_at, not_before, expires_at + ) values ( + 'user-retention', ${randomUUID()}, ${activeId}, ${revokedId}, 'current', + 'cmux/mobile/1', 'cmux.mobile.attach', ${NOW}, ${NOW}, ${new Date(NOW.getTime() + 1_000)} + ) + `; + await Effect.runPromise(repo.revokeBinding({ userId: "user-retention", bindingId: revokedId, now: NOW })); + await Effect.runPromise(repo.pruneExpiredStateGlobally({ now: NOW })); + const rows = await requiredSql()>` + select id::text, path_hints as "pathHints" + from iroh_endpoint_bindings + where id in (${activeId}, ${revokedId}) + order by id + `; + expect(rows.find((row) => row.id === activeId)?.pathHints).toHaveLength(1); + expect(rows.find((row) => row.id === revokedId)?.pathHints).toEqual([]); + const [grant] = await requiredSql()>` + select revoked_at as "revokedAt" from iroh_pair_grant_issuances where acceptor_binding_id = ${revokedId} + `; + expect(grant?.revokedAt).not.toBeNull(); + const [retentionState] = await requiredSql()>` + select + exists(select 1 from iroh_endpoint_bindings where id = ${oldRevokedId}) as "oldExists", + (select updated_at from iroh_endpoint_bindings where id = ${untouchedId}) as "untouchedUpdatedAt" + `; + expect(retentionState?.oldExists).toBe(false); + expect(retentionState?.untouchedUpdatedAt.getTime()).toBe(untouchedBefore?.updatedAt.getTime()); + }); + + dbTest("binding deletion cascades grant and relay audit rows", async () => { + const bindingId = await insertBinding({ userId: "user-delete", endpointId: "90".repeat(32) }); + const peerId = await insertBinding({ userId: "user-delete", endpointId: "91".repeat(32) }); + await requiredSql()` + insert into iroh_pair_grant_issuances ( + user_id, jti, initiator_binding_id, acceptor_binding_id, signing_key_id, + alpn, scope, issued_at, not_before, expires_at + ) values ( + 'user-delete', ${randomUUID()}, ${bindingId}, ${peerId}, 'current', + 'cmux/mobile/1', 'cmux.mobile.attach', ${NOW}, ${NOW}, ${new Date(NOW.getTime() + 1_000)} + ) + `; + await requiredSql()` + insert into iroh_relay_token_issuances ( + user_id, binding_id, endpoint_id_hash, status, requested_at + ) values ('user-delete', ${bindingId}, ${"92".repeat(32)}, 'pending', ${NOW}) + `; + await requiredSql()`delete from iroh_endpoint_bindings where id = ${bindingId}`; + const [{ grants, relays }] = await requiredSql()>` + select + (select count(*)::text from iroh_pair_grant_issuances) as grants, + (select count(*)::text from iroh_relay_token_issuances) as relays + `; + expect({ grants, relays }).toEqual({ grants: "0", relays: "0" }); + }); +}); + +async function insertBinding(input: { + readonly userId: string; + readonly appInstanceId?: string; + readonly endpointId: string; + readonly platform?: "mac" | "ios"; + readonly pathHints?: unknown[]; +}): Promise { + const [row] = await requiredSql()>` + insert into iroh_endpoint_bindings ( + user_id, device_uuid, app_instance_id, tag, platform, endpoint_id, + identity_generation, pairing_enabled, capabilities, path_hints + ) values ( + ${input.userId}, ${randomUUID()}, ${input.appInstanceId ?? randomUUID()}, 'stable', + ${input.platform ?? "mac"}, ${input.endpointId}, 1, true, '[]'::jsonb, + ${requiredSql().json((input.pathHints ?? []) as never)} + ) returning id::text + `; + if (!row) throw new Error("binding insert returned no row"); + return row.id; +} + +function requiredSql(): Sql { + if (!sql) throw new Error("test database not initialized"); + return sql; +} + +function requiredRepository(): IrohRepositoryShape { + if (!repository) throw new Error("test repository not initialized"); + return repository; +} + +function storedLanHint(value: string, observedAt: string, expiresAt: string): Record { + return { + kind: "direct_address", + value, + source: "lan", + privacy_scope: "local_network", + observed_at: observedAt, + expires_at: expiresAt, + network_profile: { source: "lan", profile_id: "local" }, + }; +} + +async function expectPostgresError(promise: Promise, expectedCode: string): Promise { + try { + await promise; + } catch (error) { + expect((error as { code?: unknown }).code).toBe(expectedCode); + return; + } + throw new Error(`expected Postgres error ${expectedCode}`); +} diff --git a/web/tests/iroh-model-crypto.test.ts b/web/tests/iroh-model-crypto.test.ts new file mode 100644 index 000000000000..2238d011d5dd --- /dev/null +++ b/web/tests/iroh-model-crypto.test.ts @@ -0,0 +1,256 @@ +import { describe, expect, test } from "bun:test"; +import { generateKeyPairSync, sign } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { + signPairGrant, + verifyPairGrant, + type PairGrantClaims, + type PairGrantPeer, +} from "../services/iroh/crypto"; +import { + IROH_ALPN, + IROH_PAIR_GRANT_TYP, + IROH_PAIR_SCOPE, + MANAGED_RELAY_URLS, + parseRegistrationPayload, +} from "../services/iroh/model"; +import { parseMinterHmacSecret, readBoundedMinterJson } from "../services/iroh/relayMinter"; + +const NOW = new Date("2026-07-09T20:00:00.000Z"); + +function registrationPayload(pathHint: Record) { + return { + route_contract_version: 1, + deviceId: "10000000-0000-4000-8000-000000000001", + appInstanceId: "20000000-0000-4000-8000-000000000001", + tag: "stable", + platform: "mac", + endpointId: "11".repeat(32), + identityGeneration: 1, + pairingEnabled: true, + capabilities: ["terminal", "artifacts"], + pathHints: [pathHint], + }; +} + +function directHint(overrides: Record = {}) { + return { + kind: "direct_address", + value: "203.0.113.42:4433", + source: "native", + privacy_scope: "public_internet", + observed_at: "2026-07-09T19:55:00.000Z", + expires_at: "2026-07-09T20:45:00.000Z", + ...overrides, + }; +} + +describe("Iroh route wire contract", () => { + test("matches the versioned Swift Codable path-hint shape", () => { + const fixture = JSON.parse(readFileSync( + new URL("../../tests/fixtures/iroh/path-hint-v1.json", import.meta.url), + "utf8", + )) as Record; + const parsed = parseRegistrationPayload(registrationPayload(fixture), NOW); + expect(parsed.route_contract_version).toBe(1); + expect(parsed.pathHints).toEqual([fixture]); + expect(Object.keys(parsed.pathHints[0]!).sort()).toEqual([ + "expires_at", + "kind", + "observed_at", + "privacy_scope", + "source", + "value", + ]); + }); + + test("accepts provider-qualified private hints", () => { + const hint = directHint({ + value: "100.64.10.12:4433", + source: "tailscale", + privacy_scope: "private_network", + network_profile: { source: "tailscale", profile_id: "tailnet-prod" }, + }); + expect(parseRegistrationPayload(registrationPayload(hint), NOW).pathHints[0]).toEqual(hint); + }); + + test("allows globally routed address space inside an explicit custom VPN profile", () => { + const hint = directHint({ + value: "8.8.4.4:4433", + source: "custom_vpn", + privacy_scope: "private_network", + network_profile: { source: "custom_vpn", profile_id: "corp-routes" }, + }); + expect(parseRegistrationPayload(registrationPayload(hint), NOW).pathHints[0]).toEqual(hint); + }); + + test("canonicalizes accepted IPv6 spellings", () => { + const hint = directHint({ + value: "[FD00:0000:0000:0000:0000:0000:0000:0001]:4433", + source: "tailscale", + privacy_scope: "private_network", + network_profile: { source: "tailscale", profile_id: "tailnet-prod" }, + }); + expect(parseRegistrationPayload(registrationPayload(hint), NOW).pathHints[0]?.value).toBe("[fd00::1]:4433"); + }); + + test("rejects an unknown platform before it can affect Mac pairability", () => { + expect(() => parseRegistrationPayload({ + ...registrationPayload(directHint({ value: "8.8.8.8:4433" })), + platform: "linux", + }, NOW)).toThrow(); + }); + + test("accepts only endpoint-reported home relays from the separate fleet allowlist", () => { + const relayHint = directHint({ kind: "relay_url", value: MANAGED_RELAY_URLS[0] }); + expect(parseRegistrationPayload(registrationPayload(relayHint), NOW).pathHints[0]?.value).toBe(MANAGED_RELAY_URLS[0]); + const payload = registrationPayload(relayHint); + payload.pathHints = MANAGED_RELAY_URLS.slice(0, 3).map((value) => directHint({ kind: "relay_url", value })); + expect(() => parseRegistrationPayload(payload, NOW)).toThrow(); + }); + + for (const [name, hint] of [ + ["RFC1918 advertised as public", directHint({ value: "10.0.0.1:4433" })], + ["ULA advertised as public", directHint({ value: "[fd00::1]:4433" })], + ["loopback", directHint({ value: "127.0.0.1:4433" })], + ["multicast", directHint({ value: "224.0.0.1:4433" })], + ["cloud metadata", directHint({ value: "169.254.169.254:4433", source: "lan", privacy_scope: "local_network", network_profile: { source: "lan", profile_id: "local" } })], + ["IPv6 link-local", directHint({ value: "[fe80::1]:4433", source: "lan", privacy_scope: "local_network", network_profile: { source: "lan", profile_id: "local" } })], + ["IPv6 remote interface scope", directHint({ value: "[2001:4860::1%en0]:4433" })], + ["alternate-spelling IPv6 documentation range", directHint({ value: "[2001:0db8::1]:4433" })], + ["IPv4 leading zero", directHint({ value: "8.8.08.8:4433" })], + ["port leading zero", directHint({ value: "8.8.8.8:04433" })], + ["LAN marked private", directHint({ value: "192.168.1.2:4433", source: "lan", privacy_scope: "private_network", network_profile: { source: "lan", profile_id: "local" } })], + ["Tailscale marked local", directHint({ value: "100.64.1.2:4433", source: "tailscale", privacy_scope: "local_network", network_profile: { source: "tailscale", profile_id: "ts" } })], + ["stale observation", directHint({ value: "8.8.8.8:4433", observed_at: "2026-07-09T18:00:00.000Z" })], + ["overlong lifetime", directHint({ value: "8.8.8.8:4433", observed_at: "2026-07-09T19:55:00.000Z", expires_at: "2026-07-09T21:00:01.000Z" })], + ["unmanaged relay", directHint({ kind: "relay_url", value: "https://example.com/" })], + ] as const) { + test(`rejects ${name}`, () => { + expect(() => parseRegistrationPayload(registrationPayload(hint), NOW)).toThrow(); + }); + } +}); + +describe("Iroh pair-grant verification", () => { + const current = generateKeyPairSync("ed25519"); + const previous = generateKeyPairSync("ed25519"); + const currentPrivate = current.privateKey.export({ format: "pem", type: "pkcs8" }).toString(); + const currentPublic = current.publicKey.export({ format: "pem", type: "spki" }).toString(); + const previousPublic = previous.publicKey.export({ format: "pem", type: "spki" }).toString(); + const keys = new Map([ + ["current", currentPublic], + ["previous", previousPublic], + ]); + const initiator: PairGrantPeer = { + bindingId: "30000000-0000-4000-8000-000000000001", + deviceId: "10000000-0000-4000-8000-000000000001", + tag: "ios", + endpointId: "22".repeat(32), + identityGeneration: 2, + }; + const acceptor: PairGrantPeer = { + bindingId: "30000000-0000-4000-8000-000000000002", + deviceId: "10000000-0000-4000-8000-000000000002", + tag: "stable", + endpointId: "33".repeat(32), + identityGeneration: 4, + }; + const claims: PairGrantClaims = { + jti: "40000000-0000-4000-8000-000000000001", + iat: 1_783_627_200, + nbf: 1_783_627_195, + exp: 1_784_232_000, + alpn: IROH_ALPN, + scope: IROH_PAIR_SCOPE, + initiator, + acceptor, + }; + + test("accepts the current key while retaining a previous verification key", () => { + const token = signPairGrant({ privateKeyPem: currentPrivate, kid: "current", claims }); + expect(verifyPairGrant(token, keys, { initiator, acceptor, nowSeconds: claims.iat }).jti).toBe(claims.jti); + }); + + test("rejects peer and identity-generation substitution", () => { + const token = signPairGrant({ privateKeyPem: currentPrivate, kid: "current", claims }); + expect(() => verifyPairGrant(token, keys, { + initiator: { ...initiator, identityGeneration: 3 }, + acceptor, + nowSeconds: claims.iat, + })).toThrow(); + expect(() => verifyPairGrant(token, keys, { + initiator: { ...initiator, endpointId: "44".repeat(32) }, + acceptor, + nowSeconds: claims.iat, + })).toThrow(); + }); + + test("rejects identity or team claims outside the fixed grant contract", () => { + const token = manuallySignedJws( + { alg: "EdDSA", typ: IROH_PAIR_GRANT_TYP, kid: "current" }, + { ...claims, userId: "must-not-appear" }, + current.privateKey, + ); + expect(() => verifyPairGrant(token, keys, { initiator, acceptor, nowSeconds: claims.iat })).toThrow(); + }); + + test("rejects a noncanonical signature segment", () => { + const token = signPairGrant({ privateKeyPem: currentPrivate, kid: "current", claims }); + expect(() => verifyPairGrant(`${token}!`, keys, { + initiator, + acceptor, + nowSeconds: claims.iat, + })).toThrow(); + }); + + for (const [name, header, changedClaims, nowSeconds] of [ + ["alg", { alg: "ES256", typ: IROH_PAIR_GRANT_TYP, kid: "current" }, claims, claims.iat], + ["kid", { alg: "EdDSA", typ: IROH_PAIR_GRANT_TYP, kid: "unknown" }, claims, claims.iat], + ["ALPN", { alg: "EdDSA", typ: IROH_PAIR_GRANT_TYP, kid: "current" }, { ...claims, alpn: "cmux/other/1" }, claims.iat], + ["expiry", { alg: "EdDSA", typ: IROH_PAIR_GRANT_TYP, kid: "current" }, claims, claims.exp], + ] as const) { + test(`rejects invalid ${name}`, () => { + const token = manuallySignedJws(header, changedClaims, current.privateKey); + expect(() => verifyPairGrant(token, keys, { initiator, acceptor, nowSeconds })).toThrow(); + }); + } +}); + +describe("Iroh relay minter response bounds", () => { + test("requires a canonical 32-byte-or-longer HMAC secret", () => { + const valid = Buffer.alloc(32, 9).toString("base64"); + expect(parseMinterHmacSecret(valid)).toEqual(Buffer.alloc(32, 9)); + expect(() => parseMinterHmacSecret("%%%%" + valid)).toThrow(); + expect(() => parseMinterHmacSecret(Buffer.alloc(16, 9).toString("base64"))).toThrow(); + }); + + test("parses a bounded response", async () => { + const body = { token: "token-with-enough-length", expiresAt: "2026-07-10T20:00:00.000Z" }; + expect(await readBoundedMinterJson(new Response(JSON.stringify(body)))).toEqual(body); + }); + + test("rejects oversized fixed-length and chunked responses", async () => { + await expect(readBoundedMinterJson(new Response("{}", { + headers: { "content-length": "999999" }, + }))).rejects.toThrow(); + + const chunk = new Uint8Array(20_000); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(chunk); + controller.enqueue(chunk); + controller.close(); + }, + }); + await expect(readBoundedMinterJson(new Response(stream))).rejects.toThrow(); + }); +}); + +function manuallySignedJws(header: unknown, claims: unknown, privateKey: CryptoKey | import("node:crypto").KeyObject): string { + const encodedHeader = Buffer.from(JSON.stringify(header)).toString("base64url"); + const encodedClaims = Buffer.from(JSON.stringify(claims)).toString("base64url"); + const input = `${encodedHeader}.${encodedClaims}`; + const signature = sign(null, Buffer.from(input), privateKey as import("node:crypto").KeyObject).toString("base64url"); + return `${input}.${signature}`; +} diff --git a/web/tests/iroh-route-handler.test.ts b/web/tests/iroh-route-handler.test.ts new file mode 100644 index 000000000000..fed1b67f5b38 --- /dev/null +++ b/web/tests/iroh-route-handler.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, test } from "bun:test"; +import * as Effect from "effect/Effect"; +import { IrohQuotaExceededError } from "../services/iroh/errors"; +import { handleIrohRoute } from "../services/iroh/routeHandler"; +import type { IrohTrustBrokerShape } from "../services/iroh/trustBroker"; +import type { AuthedUser } from "../services/vms/auth"; +import { GET as retentionGet } from "../app/api/internal/iroh/retention/route"; + +const USER: AuthedUser = { + id: "personal-user-id", + displayName: null, + primaryEmail: null, + billingCustomerType: "team", + billingTeamId: "selected-team-id", + selectedTeamId: "selected-team-id", + teams: [{ id: "selected-team-id", displayName: null, billingPlanId: null }], + teamIds: ["selected-team-id"], + userBillingPlanId: null, + billingPlanId: null, +}; + +describe("Iroh route boundary", () => { + test("authenticates before reading an oversized body", async () => { + let called = false; + const response = await handleIrohRoute(new Request("https://cmux.test/api/devices/iroh/challenge", { + method: "POST", + body: "x".repeat(70_000), + }), "challenge", { + verify: async () => null, + broker: broker({ issueChallenge: () => { called = true; return Effect.succeed({}); } }), + }); + expect(response.status).toBe(401); + expect(called).toBe(false); + }); + + test("caps a chunked body while streaming and rejects a missing body", async () => { + let called = false; + const chunk = new Uint8Array(40_000); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(chunk); + controller.enqueue(chunk); + controller.close(); + }, + }); + const oversizedInit: RequestInit & { duplex: "half" } = { + method: "POST", + headers: { + authorization: "Bearer test-access", + "x-stack-refresh-token": "test-refresh", + "content-type": "application/json", + }, + body: stream, + duplex: "half", + }; + const oversized = await handleIrohRoute(new Request( + "https://cmux.test/api/devices/iroh/challenge", + oversizedInit, + ), "challenge", { + verify: async () => USER, + broker: broker({ issueChallenge: () => { called = true; return Effect.succeed({}); } }), + }); + expect(oversized.status).toBe(413); + expect(called).toBe(false); + + const missing = await handleIrohRoute(new Request("https://cmux.test/api/devices/iroh/challenge", { + method: "POST", + headers: { + authorization: "Bearer test-access", + "x-stack-refresh-token": "test-refresh", + "content-type": "application/json", + }, + }), "challenge", { + verify: async () => USER, + broker: broker(), + }); + expect(missing.status).toBe(400); + expect(await missing.json()).toEqual({ error: "missing_body" }); + }); + + test("uses exact personal user id and ignores selected team membership", async () => { + let receivedUserId = ""; + const response = await handleIrohRoute(new Request("https://cmux.test/api/devices/iroh", { + method: "GET", + }), "discover", { + verify: async () => USER, + broker: broker({ + discover: (userId) => { + receivedUserId = userId; + return Effect.succeed({ bindings: [] }); + }, + }), + }); + expect(response.status).toBe(200); + expect(receivedUserId).toBe("personal-user-id"); + expect(receivedUserId).not.toBe("selected-team-id"); + }); + + test("maps DB-authoritative quota failures to typed 429 with Retry-After", async () => { + const response = await handleIrohRoute(authedPost("/api/devices/iroh/relay-token", { + bindingId: "30000000-0000-4000-8000-000000000001", + }), "relay_token", { + verify: async () => USER, + broker: broker({ + issueRelayToken: () => Effect.fail(new IrohQuotaExceededError({ + code: "relay_endpoint_10m_quota", + retryAfterSeconds: 417, + })), + }), + }); + expect(response.status).toBe(429); + expect(response.headers.get("retry-after")).toBe("417"); + expect(await response.json()).toEqual({ + error: "relay_endpoint_10m_quota", + retry_after_seconds: 417, + }); + }); +}); + +describe("Iroh retention route", () => { + test("fails closed without the cron secret and rejects a wrong token", async () => { + const previous = process.env.CRON_SECRET; + try { + delete process.env.CRON_SECRET; + expect((await retentionGet(new Request("https://cmux.test/api/internal/iroh/retention"))).status).toBe(503); + process.env.CRON_SECRET = "expected-secret"; + expect((await retentionGet(new Request("https://cmux.test/api/internal/iroh/retention", { + headers: { authorization: "Bearer wrong-secret" }, + }))).status).toBe(401); + } finally { + if (previous === undefined) delete process.env.CRON_SECRET; + else process.env.CRON_SECRET = previous; + } + }); +}); + +function authedPost(path: string, body: unknown): Request { + return new Request(`https://cmux.test${path}`, { + method: "POST", + headers: { + authorization: "Bearer test-access", + "x-stack-refresh-token": "test-refresh", + "content-type": "application/json", + }, + body: JSON.stringify(body), + }); +} + +function broker(overrides: Partial = {}): IrohTrustBrokerShape { + const unavailable = () => Effect.die(new Error("unexpected broker operation")); + return { + issueChallenge: unavailable, + register: unavailable, + discover: unavailable, + revoke: unavailable, + issuePairGrant: unavailable, + issueRelayToken: unavailable, + ...overrides, + }; +} diff --git a/web/tests/iroh-trust-broker.test.ts b/web/tests/iroh-trust-broker.test.ts new file mode 100644 index 000000000000..97605a769355 --- /dev/null +++ b/web/tests/iroh-trust-broker.test.ts @@ -0,0 +1,522 @@ +import { describe, expect, test } from "bun:test"; +import { generateKeyPairSync, randomUUID, sign } from "node:crypto"; +import * as Effect from "effect/Effect"; +import { + deviceLimitOverrideAllowed, + type IrohTrustBrokerConfigShape, +} from "../services/iroh/config"; +import { registrationTranscript } from "../services/iroh/crypto"; +import { + IrohConflictError, + IrohForbiddenError, + IrohNotFoundError, + IrohQuotaExceededError, + IrohRelayMintError, +} from "../services/iroh/errors"; +import { + IROH_RELAY_TOKEN_LIFETIME_SECONDS, + MANAGED_RELAY_URLS, + sha256, + type IrohRegistrationPayload, +} from "../services/iroh/model"; +import type { + IrohBindingRecord, + IrohChallengeRecord, + IrohRepositoryShape, +} from "../services/iroh/repository"; +import type { IrohRelayMinterShape } from "../services/iroh/relayMinter"; +import { makeIrohTrustBroker } from "../services/iroh/trustBroker"; + +const NOW = new Date("2026-07-09T20:00:00.000Z"); +const USER_A = "user-a"; +const USER_B = "user-b"; + +describe("Iroh trust broker registration", () => { + test("registers a valid endpoint proof and mints relay credentials after commit", async () => { + const fixture = makeFixture(); + const request = await fixture.signedRegistration(); + const result = await Effect.runPromise(fixture.broker.register(USER_A, request, NOW)) as { + binding: { endpoint_id: string }; + relay: { status: string; token: string }; + }; + expect(result.binding.endpoint_id).toBe(fixture.endpointId); + expect(result.relay.status).toBe("issued"); + expect(fixture.repository.bindings).toHaveLength(1); + expect(fixture.minter.calls).toBe(1); + }); + + test("relay failure cannot roll back an authenticated registration", async () => { + const fixture = makeFixture({ minterFailure: true }); + const result = await Effect.runPromise( + fixture.broker.register(USER_A, await fixture.signedRegistration(), NOW), + ) as { relay: { status: string } }; + expect(result.relay.status).toBe("unavailable"); + expect(fixture.repository.bindings).toHaveLength(1); + }); + + test("rejects the wrong key and a changed payload", async () => { + const wrongKeyFixture = makeFixture(); + const wrongRequest = await wrongKeyFixture.signedRegistration(); + const otherKey = generateKeyPairSync("ed25519"); + wrongRequest.signature = sign( + null, + registrationTranscript({ + challengeId: wrongRequest.challengeId, + nonce: wrongRequest.nonce, + payloadSha256: sha256(Buffer.from(wrongRequest.payload, "base64url")), + }), + otherKey.privateKey, + ).toString("base64url"); + await expectEffectFailure( + wrongKeyFixture.broker.register(USER_A, wrongRequest, NOW), + "IrohForbiddenError", + ); + + const changedFixture = makeFixture(); + const changedRequest = await changedFixture.signedRegistration(); + const changed = JSON.parse(Buffer.from(changedRequest.payload, "base64url").toString()) as Record; + changed.tag = "redirected"; + changedRequest.payload = Buffer.from(JSON.stringify(changed)).toString("base64url"); + await expectEffectFailure( + changedFixture.broker.register(USER_A, changedRequest, NOW), + "IrohForbiddenError", + ); + }); + + test("rejects expired and replayed challenges", async () => { + const expired = makeFixture(); + await expectEffectFailure( + expired.broker.register( + USER_A, + await expired.signedRegistration(), + new Date(NOW.getTime() + 6 * 60 * 1_000), + ), + "IrohForbiddenError", + ); + + const replay = makeFixture(); + const request = await replay.signedRegistration(); + await Effect.runPromise(replay.broker.register(USER_A, request, NOW)); + await expectEffectFailure(replay.broker.register(USER_A, request, NOW), "IrohConflictError"); + }); + + test("requires revocation/reapproval for endpoint or generation replacement", async () => { + const fixture = makeFixture(); + await Effect.runPromise(fixture.broker.register(USER_A, await fixture.signedRegistration(), NOW)); + const replacement = makeFixture({ + repository: fixture.repository, + appInstanceId: fixture.appInstanceId, + deviceId: fixture.deviceId, + identityGeneration: 2, + }); + await expectEffectFailure( + replacement.broker.register(USER_A, await replacement.signedRegistration(), NOW), + "IrohConflictError", + ); + expect(fixture.repository.bindings).toHaveLength(1); + expect(fixture.repository.bindings[0]!.endpointId).toBe(fixture.endpointId); + }); +}); + +describe("Iroh discovery and grants", () => { + test("never exposes another user through shared team context", async () => { + const fixture = makeFixture(); + await Effect.runPromise(fixture.broker.register(USER_A, await fixture.signedRegistration(), NOW)); + const discovered = await Effect.runPromise(fixture.broker.discover(USER_B, NOW)) as { + bindings: unknown[]; + }; + expect(discovered.bindings).toEqual([]); + }); + + test("returns the relay fleet separately and prunes expired hints from storage", async () => { + const fixture = makeFixture(); + fixture.repository.bindings.push(binding({ + userId: USER_A, + pathHints: [{ + kind: "direct_address", + value: "10.0.0.2:4433", + source: "lan", + privacy_scope: "local_network", + observed_at: "2026-07-09T18:00:00.000Z", + expires_at: "2026-07-09T19:00:00.000Z", + network_profile: { source: "lan", profile_id: "local" }, + }], + })); + const discovered = await Effect.runPromise(fixture.broker.discover(USER_A, NOW)) as { + relay_fleet: string[]; + bindings: Array<{ path_hints: unknown[] }>; + }; + expect(discovered.relay_fleet).toEqual([...MANAGED_RELAY_URLS]); + expect(discovered.bindings[0]!.path_hints).toEqual([]); + expect(fixture.repository.bindings[0]!.pathHints).toEqual([]); + }); + + test("pair grants require two same-user bindings and a pairable Mac", async () => { + const fixture = makeFixture(); + const initiator = binding({ userId: USER_A, platform: "ios", pairingEnabled: false }); + const acceptor = binding({ userId: USER_A, platform: "mac", pairingEnabled: true }); + fixture.repository.bindings.push(initiator, acceptor); + const result = await Effect.runPromise(fixture.broker.issuePairGrant(USER_A, { + initiatorBindingId: initiator.id, + acceptorBindingId: acceptor.id, + }, NOW)) as { grant: string }; + expect(result.grant.split(".")).toHaveLength(3); + expect(fixture.repository.pairGrantAudits).toHaveLength(1); + expect(JSON.stringify(fixture.repository.pairGrantAudits[0])).not.toContain(result.grant); + + acceptor.userId = USER_B; + await expectEffectFailure(fixture.broker.issuePairGrant(USER_A, { + initiatorBindingId: initiator.id, + acceptorBindingId: acceptor.id, + }, NOW), "IrohNotFoundError"); + }); +}); + +describe("Iroh relay quotas", () => { + test("never calls the minter for an unregistered or revoked binding", async () => { + const fixture = makeFixture(); + await expectEffectFailure( + fixture.broker.issueRelayToken(USER_A, { bindingId: randomUUID() }, NOW), + "IrohNotFoundError", + ); + const revoked = binding({ userId: USER_A, revokedAt: NOW }); + fixture.repository.bindings.push(revoked); + await expectEffectFailure( + fixture.broker.issueRelayToken(USER_A, { bindingId: revoked.id }, NOW), + "IrohNotFoundError", + ); + expect(fixture.minter.calls).toBe(0); + }); + + test("enforces three endpoint mints per ten minutes before provider work", async () => { + const fixture = makeFixture(); + const active = binding({ userId: USER_A }); + fixture.repository.bindings.push(active); + for (let index = 0; index < 3; index += 1) { + await Effect.runPromise(fixture.broker.issueRelayToken( + USER_A, + { bindingId: active.id }, + new Date(NOW.getTime() + index * 1_000), + )); + } + await expectEffectFailure( + fixture.broker.issueRelayToken(USER_A, { bindingId: active.id }, new Date(NOW.getTime() + 4_000)), + "IrohQuotaExceededError", + ); + expect(fixture.minter.calls).toBe(3); + }); +}); + +describe("developer binding override", () => { + const base: IrohTrustBrokerConfigShape = { + deviceLimitOverrideEnabled: true, + deviceLimitOverrideUserIds: new Set([USER_A]), + deviceLimitOverrideEnvironments: new Set(["preview"]), + deploymentEnvironment: "preview", + }; + + test("requires both an explicit authenticated user and explicit environment", () => { + expect(deviceLimitOverrideAllowed(base, USER_A)).toBe(true); + expect(deviceLimitOverrideAllowed(base, USER_B)).toBe(false); + expect(deviceLimitOverrideAllowed({ ...base, deploymentEnvironment: "production" }, USER_A)).toBe(false); + expect(deviceLimitOverrideAllowed({ ...base, deviceLimitOverrideEnabled: false }, USER_A)).toBe(false); + }); +}); + +type MutableBinding = IrohBindingRecord & { userId: string }; + +class MemoryRepository implements IrohRepositoryShape { + readonly challenges: IrohChallengeRecord[] = []; + readonly bindings: MutableBinding[] = []; + readonly pairGrantAudits: unknown[] = []; + readonly relayIssuances: Array<{ + id: string; + userId: string; + bindingId: string; + requestedAt: Date; + status: string; + }> = []; + private lanGenerations = new Map(); + + issueChallenge(input: Parameters[0]) { + const challenge: IrohChallengeRecord = { + id: randomUUID(), + userId: input.userId, + deviceUuid: input.deviceUuid, + appInstanceId: input.appInstanceId, + tag: input.tag, + endpointId: input.endpointId, + identityGeneration: input.identityGeneration, + payloadSha256: input.payloadSha256, + nonceHash: input.nonceHash, + createdAt: input.now, + expiresAt: input.expiresAt, + consumedAt: null, + }; + this.challenges.push(challenge); + return Effect.succeed(challenge); + } + + findChallenge(userId: string, challengeId: string) { + return Effect.succeed(this.challenges.find((row) => row.userId === userId && row.id === challengeId) ?? null); + } + + consumeChallengeAndRegister(input: Parameters[0]) { + const challenge = this.challenges.find((row) => row.id === input.challengeId && row.userId === input.userId); + if (!challenge) return Effect.fail(new IrohNotFoundError({ resource: "challenge" })); + if (challenge.consumedAt) return Effect.fail(new IrohConflictError({ code: "challenge_replayed" })); + if (challenge.expiresAt <= input.now) return Effect.fail(new IrohForbiddenError({ code: "challenge_expired" })); + if (challenge.nonceHash !== input.nonceHash) return Effect.fail(new IrohForbiddenError({ code: "invalid_challenge_nonce" })); + const existing = this.bindings.find((row) => row.appInstanceId === input.payload.appInstanceId && !row.revokedAt); + if (existing) { + if ( + existing.userId !== input.userId || + existing.endpointId !== input.payload.endpointId || + existing.identityGeneration !== input.payload.identityGeneration || + existing.deviceUuid !== input.payload.deviceId || + existing.tag !== input.payload.tag + ) return Effect.fail(new IrohConflictError({ code: "binding_replacement_requires_revocation" })); + challenge.consumedAt = input.now; + existing.pathHints = [...input.payload.pathHints]; + return Effect.succeed(existing); + } + if (this.bindings.some((row) => row.endpointId === input.payload.endpointId && !row.revokedAt)) { + return Effect.fail(new IrohConflictError({ code: "endpoint_already_bound" })); + } + const activeUser = this.bindings.filter((row) => row.userId === input.userId && !row.revokedAt); + if (activeUser.length >= 32) return Effect.fail(new IrohQuotaExceededError({ code: "too_many_bindings", retryAfterSeconds: 86_400 })); + const activeDevice = activeUser.filter((row) => row.deviceUuid === input.payload.deviceId); + if (activeDevice.length >= 8 && !input.deviceLimitOverrideAllowed) { + return Effect.fail(new IrohQuotaExceededError({ code: "too_many_device_bindings", retryAfterSeconds: 86_400 })); + } + const inserted = binding({ + userId: input.userId, + deviceUuid: input.payload.deviceId, + appInstanceId: input.payload.appInstanceId, + tag: input.payload.tag, + platform: input.payload.platform, + displayName: input.payload.displayName ?? null, + endpointId: input.payload.endpointId, + identityGeneration: input.payload.identityGeneration, + pairingEnabled: input.payload.pairingEnabled, + capabilities: [...input.payload.capabilities], + pathHints: [...input.payload.pathHints], + deviceLimitOverrideUsed: activeDevice.length >= 8, + registeredAt: input.now, + updatedAt: input.now, + lastSeenAt: input.now, + }); + challenge.consumedAt = input.now; + this.bindings.push(inserted); + return Effect.succeed(inserted); + } + + listActiveBindings(userId: string) { + return Effect.succeed(this.bindings.filter((row) => row.userId === userId && !row.revokedAt)); + } + + findActiveBindings(userId: string, bindingIds: readonly string[]) { + return Effect.succeed(this.bindings.filter((row) => + row.userId === userId && bindingIds.includes(row.id) && !row.revokedAt)); + } + + revokeBinding(input: Parameters[0]) { + const row = this.bindings.find((candidate) => + candidate.id === input.bindingId && candidate.userId === input.userId && !candidate.revokedAt); + if (!row) return Effect.succeed(false); + row.revokedAt = input.now; + row.revokedReason = "user_requested"; + this.lanGenerations.set(input.userId, (this.lanGenerations.get(input.userId) ?? 1) + 1); + return Effect.succeed(true); + } + + accountLanGeneration(input: Parameters[0]) { + return Effect.succeed(this.lanGenerations.get(input.userId) ?? 1); + } + + pruneExpiredState(input: Parameters[0]) { + for (const row of this.bindings.filter((candidate) => candidate.userId === input.userId)) { + row.pathHints = row.pathHints.filter((hint) => { + const expiry = (hint as { expires_at?: unknown }).expires_at; + return typeof expiry === "string" && new Date(expiry) > input.now; + }); + } + return Effect.void; + } + + pruneExpiredStateGlobally(input: Parameters[0]) { + for (const row of this.bindings) { + row.pathHints = row.revokedAt + ? [] + : row.pathHints.filter((hint) => { + const expiry = (hint as { expires_at?: unknown }).expires_at; + return typeof expiry === "string" && new Date(expiry) > input.now; + }); + } + return Effect.void; + } + + recordPairGrant(input: Parameters[0]) { + this.pairGrantAudits.push(input); + return Effect.void; + } + + reserveRelayIssuance(input: Parameters[0]) { + const active = this.bindings.find((row) => + row.id === input.bindingId && row.userId === input.userId && !row.revokedAt); + if (!active) return Effect.fail(new IrohNotFoundError({ resource: "binding" })); + const recent = this.relayIssuances.filter((row) => + row.bindingId === active.id && row.requestedAt > new Date(input.now.getTime() - 10 * 60 * 1_000)); + if (recent.length >= 3) { + return Effect.fail(new IrohQuotaExceededError({ code: "relay_endpoint_10m_quota", retryAfterSeconds: 600 })); + } + const issuanceId = randomUUID(); + this.relayIssuances.push({ id: issuanceId, userId: input.userId, bindingId: active.id, requestedAt: input.now, status: "pending" }); + return Effect.succeed({ issuanceId, binding: active }); + } + + completeRelayIssuance(input: Parameters[0]) { + const row = this.relayIssuances.find((candidate) => candidate.id === input.issuanceId); + if (row) row.status = "succeeded"; + return Effect.void; + } + + failRelayIssuance(input: Parameters[0]) { + const row = this.relayIssuances.find((candidate) => candidate.id === input.issuanceId); + if (row) row.status = "failed"; + return Effect.void; + } +} + +class FakeMinter implements IrohRelayMinterShape { + calls = 0; + constructor(private readonly fail: boolean) {} + + mint(input: Parameters[0]) { + this.calls += 1; + if (this.fail) return Effect.fail(new IrohRelayMintError({ code: "test_failure" })); + return Effect.succeed({ + token: `relay-token-${this.calls}-with-safe-length`, + expiresAt: new Date(input.now.getTime() + IROH_RELAY_TOKEN_LIFETIME_SECONDS * 1_000), + }); + } +} + +function makeFixture(options: { + repository?: MemoryRepository; + minterFailure?: boolean; + appInstanceId?: string; + deviceId?: string; + identityGeneration?: number; +} = {}) { + const endpointKeys = generateKeyPairSync("ed25519"); + const grantKeys = generateKeyPairSync("ed25519"); + const previousKeys = generateKeyPairSync("ed25519"); + const endpointPublicDer = endpointKeys.publicKey.export({ format: "der", type: "spki" }); + const endpointId = Buffer.from(endpointPublicDer).subarray(-32).toString("hex"); + const repository = options.repository ?? new MemoryRepository(); + const minter = new FakeMinter(options.minterFailure ?? false); + const appInstanceId = options.appInstanceId ?? randomUUID(); + const deviceId = options.deviceId ?? randomUUID(); + const identityGeneration = options.identityGeneration ?? 1; + const config: IrohTrustBrokerConfigShape = { + lanDiscoverySecretBase64: Buffer.alloc(32, 7).toString("base64"), + grantSigningPrivateKeyPem: grantKeys.privateKey.export({ format: "pem", type: "pkcs8" }).toString(), + grantSigningKid: "current", + grantVerificationKeysJson: JSON.stringify({ + current: grantKeys.publicKey.export({ format: "pem", type: "spki" }).toString(), + previous: previousKeys.publicKey.export({ format: "pem", type: "spki" }).toString(), + }), + deviceLimitOverrideEnabled: false, + deviceLimitOverrideUserIds: new Set(), + deviceLimitOverrideEnvironments: new Set(), + deploymentEnvironment: "test", + }; + const broker = makeIrohTrustBroker(repository, minter, config); + + return { + repository, + minter, + broker, + endpointId, + appInstanceId, + deviceId, + identityGeneration, + async signedRegistration() { + const payload: IrohRegistrationPayload = { + route_contract_version: 1, + deviceId, + appInstanceId, + tag: "stable", + platform: "mac", + displayName: "Test Mac", + endpointId, + identityGeneration, + pairingEnabled: true, + capabilities: ["terminal", "artifacts"], + pathHints: [{ + kind: "direct_address", + value: "8.8.8.8:4433", + source: "native", + privacy_scope: "public_internet", + observed_at: "2026-07-09T19:55:00.000Z", + expires_at: "2026-07-09T20:45:00.000Z", + }], + }; + const payloadBytes = Buffer.from(JSON.stringify(payload)); + const challenge = await Effect.runPromise(broker.issueChallenge(USER_A, { + deviceId, + appInstanceId, + tag: payload.tag, + endpointId, + identityGeneration, + payloadSha256: sha256(payloadBytes), + }, NOW)) as { challenge_id: string; nonce: string }; + return { + challengeId: challenge.challenge_id, + nonce: challenge.nonce, + payload: payloadBytes.toString("base64url"), + signature: sign(null, registrationTranscript({ + challengeId: challenge.challenge_id, + nonce: challenge.nonce, + payloadSha256: sha256(payloadBytes), + }), endpointKeys.privateKey).toString("base64url"), + }; + }, + }; +} + +function binding(overrides: Partial = {}): MutableBinding { + const now = NOW; + return { + id: randomUUID(), + userId: USER_A, + deviceUuid: randomUUID(), + appInstanceId: randomUUID(), + tag: "stable", + platform: "mac", + displayName: null, + endpointId: "55".repeat(32), + identityGeneration: 1, + pairingEnabled: true, + capabilities: [], + pathHints: [], + deviceLimitOverrideUsed: false, + lastSeenAt: now, + registeredAt: now, + updatedAt: now, + revokedAt: null, + revokedReason: null, + ...overrides, + }; +} + +async function expectEffectFailure( + effect: Effect.Effect, + expectedTag: string, +): Promise { + const exit = await Effect.runPromiseExit(effect); + expect(exit._tag).toBe("Failure"); + if (exit._tag !== "Failure") return; + expect(String(exit.cause)).toContain(expectedTag); +} diff --git a/web/vercel.json b/web/vercel.json index a10f985175f3..7f3557af1c5a 100644 --- a/web/vercel.json +++ b/web/vercel.json @@ -15,6 +15,10 @@ { "path": "/api/internal/vm/leases/revoke-expired", "schedule": "*/10 * * * *" + }, + { + "path": "/api/internal/iroh/retention", + "schedule": "43 * * * *" } ] } From 755b07c961308652b0f3c13fc4b5b7af0c0f742c Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Fri, 10 Jul 2026 00:11:56 -0700 Subject: [PATCH 02/37] feat(iroh): add relay minter and offline attestations --- .github/workflows/iroh-relay-minter.yml | 48 + docs/iroh-offline-pairing-v1.md | 110 + services/iroh-relay-minter/.env.example | 3 + services/iroh-relay-minter/.gitignore | 3 + services/iroh-relay-minter/Cargo.lock | 4589 +++++++++++++++++ services/iroh-relay-minter/Cargo.toml | 51 + services/iroh-relay-minter/README.md | 71 + services/iroh-relay-minter/api/relay-token.rs | 18 + .../iroh-relay-minter/rust-toolchain.toml | 4 + services/iroh-relay-minter/src/lib.rs | 794 +++ services/iroh-relay-minter/vercel.json | 3 + .../iroh/relay-minter-request-v1.json | 6 + web/.env.example | 7 +- .../iroh/endpoint-attestations/route.ts | 8 + web/app/env.ts | 10 +- web/services/iroh/config.ts | 4 +- web/services/iroh/crypto.ts | 443 +- web/services/iroh/errors.ts | 7 +- web/services/iroh/model.ts | 4 + web/services/iroh/relayMinter.ts | 46 +- web/services/iroh/routeHandler.ts | 2 + web/services/iroh/trustBroker.ts | 85 +- web/tests/iroh-model-crypto.test.ts | 244 +- web/tests/iroh-route-handler.test.ts | 16 + web/tests/iroh-trust-broker.test.ts | 95 +- 25 files changed, 6597 insertions(+), 74 deletions(-) create mode 100644 .github/workflows/iroh-relay-minter.yml create mode 100644 docs/iroh-offline-pairing-v1.md create mode 100644 services/iroh-relay-minter/.env.example create mode 100644 services/iroh-relay-minter/.gitignore create mode 100644 services/iroh-relay-minter/Cargo.lock create mode 100644 services/iroh-relay-minter/Cargo.toml create mode 100644 services/iroh-relay-minter/README.md create mode 100644 services/iroh-relay-minter/api/relay-token.rs create mode 100644 services/iroh-relay-minter/rust-toolchain.toml create mode 100644 services/iroh-relay-minter/src/lib.rs create mode 100644 services/iroh-relay-minter/vercel.json create mode 100644 tests/fixtures/iroh/relay-minter-request-v1.json create mode 100644 web/app/api/devices/iroh/endpoint-attestations/route.ts diff --git a/.github/workflows/iroh-relay-minter.yml b/.github/workflows/iroh-relay-minter.yml new file mode 100644 index 000000000000..e11f9f44f86f --- /dev/null +++ b/.github/workflows/iroh-relay-minter.yml @@ -0,0 +1,48 @@ +name: Iroh relay minter + +on: + pull_request: + paths: + - "services/iroh-relay-minter/**" + - "tests/fixtures/iroh/relay-minter-request-v1.json" + - ".github/workflows/iroh-relay-minter.yml" + push: + branches: [main] + paths: + - "services/iroh-relay-minter/**" + - "tests/fixtures/iroh/relay-minter-request-v1.json" + - ".github/workflows/iroh-relay-minter.yml" + +concurrency: + group: iroh-relay-minter-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + test: + runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + timeout-minutes: 40 + defaults: + run: + working-directory: services/iroh-relay-minter + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Install pinned Rust toolchain + run: rustup toolchain install 1.91.0 --profile minimal --component clippy,rustfmt + + - name: Check formatting + run: cargo fmt --check + + - name: Lint + run: cargo clippy --all-targets --locked -- -D warnings + + - name: Test + run: cargo test --locked + + - name: Build production function + run: cargo build --release --locked diff --git a/docs/iroh-offline-pairing-v1.md b/docs/iroh-offline-pairing-v1.md new file mode 100644 index 000000000000..bd7f3045ca92 --- /dev/null +++ b/docs/iroh-offline-pairing-v1.md @@ -0,0 +1,110 @@ +# Iroh offline same-account pairing v1 + +Offline QR pairing is authorized by two backend-signed endpoint attestations. +Possession of the QR payload is never sufficient. + +## Online preparation + +Authenticated clients fetch `GET /api/devices/iroh`. Its +`grant_verification_keys` field is a version 1 public Ed25519 key set containing +the current key and, during rotation, one previous key. The response never +contains a private key, account ID, email, or team ID. + +An authenticated client requests an attestation for its active binding with: + +```http +POST /api/devices/iroh/endpoint-attestations +Content-Type: application/json + +{"bindingId":""} +``` + +The response contains `attestation_version`, `attestation`, `expires_at`, and +the same public verification-key set. The attestation lifetime is 24 hours. +Revoked bindings and bindings owned by another account return `binding_not_found`. + +The web deployment requires these server-only values: + +- `CMUX_IROH_ACCOUNT_SUBJECT_SECRET_B64`: 32 random bytes in standard base64. +- `CMUX_IROH_GRANT_SIGNING_KEY_P8`: an Ed25519 PKCS#8 private key in PEM form. +- `CMUX_IROH_GRANT_SIGNING_KID`: the current key ID. +- `CMUX_IROH_GRANT_VERIFICATION_KEYS_JSON`: the version 1 current plus optional + previous public-key set shown by the API. + +Generate signing material with: + +```sh +openssl genpkey -algorithm ED25519 -out iroh-grant-current.pem +openssl pkey -in iroh-grant-current.pem -pubout -outform DER | openssl base64 -A +openssl rand -base64 32 +``` + +Never put the private key or account-subject secret in a client-visible Vercel +variable. During signing-key rotation, publish the new current public key and +retain the old key as `previous` for at least seven days, the maximum pair-grant +lifetime. A forced account-subject-secret rotation invalidates cached offline +attestations, so clients must refresh before pairing. + +## Signed attestation + +The attestation is a compact JWS signed with Ed25519. The protected header has +exactly these fields: + +```json +{"alg":"EdDSA","typ":"cmux-endpoint-attestation-v1+jwt","kid":""} +``` + +The payload has exactly these fields: + +```json +{ + "version": 1, + "jti": "", + "sub": "<32-byte unpadded base64url account subject>", + "bindingId": "", + "deviceId": "", + "endpointId": "<64 lowercase hex characters>", + "identityGeneration": 1, + "platform": "ios", + "iat": 1783627200, + "nbf": 1783627195, + "exp": 1783713600, + "alpn": "cmux/mobile/1", + "scope": "cmux.offline-pair.same-account" +} +``` + +`sub` is HMAC-SHA256 over the private backend account identifier with a +dedicated server secret and a versioned domain separator. It lets two devices +compare account membership without disclosing the underlying identifier. + +## Offline authorization + +The Mac QR payload carries the Mac attestation. The iOS initiator also presents +its own cached attestation after the Iroh connection is established. Each peer +must perform all of these checks before accepting pairing: + +1. Verify canonical JWS encoding and Ed25519 signature with a cached current or + previous public key. +2. Require the fixed version, type, ALPN, scope, and a currently valid lifetime. +3. Bind every device, binding, EndpointID, identity generation, and platform + claim to the expected local state and the authenticated Iroh peer EndpointID. +4. Require an iOS initiator, a Mac acceptor, distinct bindings, devices, and + EndpointIDs, and equal 32-byte account subjects. +5. Apply local revocation and pairing-disabled state before accepting. + +A missing attestation, one attestation used for both peers, a subject mismatch, +an expired token, or an EndpointID substitution fails closed. Network addresses +inside a QR remain untrusted route hints and do not participate in authorization. + +Offline verification cannot observe a revocation made after the last refresh. +The 24-hour expiry bounds that window. A client without a fresh attestation must +go online before first-time pairing. + +## Release gate + +The TypeScript reference verifier and backend behavior tests enforce this +contract. Shipping offline QR pairing remains blocked until the Swift client has +equivalent behavior tests that prove QR possession alone fails, both +attestations are required, the live Iroh EndpointIDs are bound, and current plus +previous key rotation works. diff --git a/services/iroh-relay-minter/.env.example b/services/iroh-relay-minter/.env.example new file mode 100644 index 000000000000..64b31761ab11 --- /dev/null +++ b/services/iroh-relay-minter/.env.example @@ -0,0 +1,3 @@ +# Configure these only on the isolated relay-minter Vercel project. +IROH_SERVICES_API_SECRET= +CMUX_IROH_MINT_HMAC_SECRET_B64= diff --git a/services/iroh-relay-minter/.gitignore b/services/iroh-relay-minter/.gitignore new file mode 100644 index 000000000000..32a7de27b38f --- /dev/null +++ b/services/iroh-relay-minter/.gitignore @@ -0,0 +1,3 @@ +/target/ +/.vercel/ +/.env diff --git a/services/iroh-relay-minter/Cargo.lock b/services/iroh-relay-minter/Cargo.lock new file mode 100644 index 000000000000..98e38c21ccc4 --- /dev/null +++ b/services/iroh-relay-minter/Cargo.lock @@ -0,0 +1,4589 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async_io_stream" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d7b9decdf35d8908a7e3ef02f64c5e9b1695e230154c0e8de3969142d9b94c" +dependencies = [ + "futures", + "pharos", + "rustc_version", +] + +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "attohttpc" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9" +dependencies = [ + "base64", + "http", + "log", + "url", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", + "gloo-timers", + "tokio", +] + +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "built" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" +dependencies = [ + "cargo-lock", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo-lock" +version = "11.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63585cdf8572aa7adf0e30a253f988f2b77233bfac1973d52efb6dd53a75920e" +dependencies = [ + "semver", + "serde", + "toml", + "url", +] + +[[package]] +name = "cc" +version = "1.2.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "cmux-iroh-relay-minter" +version = "0.1.0" +dependencies = [ + "base64", + "data-encoding", + "futures-util", + "hex", + "hmac", + "http-body", + "http-body-util", + "hyper", + "iroh", + "iroh-services", + "rcan", + "serde", + "serde_json", + "sha2 0.10.9", + "time", + "tokio", + "vercel_runtime", + "zeroize", +] + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cordyceps" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "688d7fbb8092b8de775ef2536f36c8c31f2bc4006ece2e8d8ad2d17d00ce0a2a" +dependencies = [ + "loom", + "tracing", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "curve25519-dalek" +version = "5.0.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f359e08ca85e7bd759e1fd933ff2bccd81864c60a8fba0e259c7f822b0924bf" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "curve25519-dalek-derive", + "digest 0.11.3", + "fiat-crypto", + "rand_core", + "rustc_version", + "serde", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "data-encoding-macro" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3259c913752a86488b501ed8680446a5ed2d5aeac6e596cb23ba3800768ea32c" +dependencies = [ + "data-encoding", + "data-encoding-macro-internal", +] + +[[package]] +name = "data-encoding-macro-internal" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" +dependencies = [ + "data-encoding", + "syn", +] + +[[package]] +name = "der" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn", + "unicode-xid", +] + +[[package]] +name = "diatomic-waker" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab03c107fafeb3ee9f5925686dbb7a73bc76e3932abb0d2b365cb64b169cf04c" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "ed25519" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" +dependencies = [ + "pkcs8", + "serdect", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "3.0.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b011170fe4f04665565b4110afef66774fe9ffff278f3eb5b81cc73d26e27d60" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core", + "serde", + "sha2 0.11.0", + "signature", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "enum-assoc" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed8956bd5c1f0415200516e78ff07ec9e16415ade83c056c230d7b7ea0d55b7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-buffered" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4421cb78ee172b6b06080093479d3c50f058e7c81b7d577bbb8d118d551d4cd5" +dependencies = [ + "cordyceps", + "diatomic-waker", + "futures-core", + "pin-project-lite", + "spin 0.10.0", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generator" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link", + "windows-result", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32", + "rustc_version", + "serde", + "spin 0.9.8", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hickory-net" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2295ed2f9c31e471e1428a8f88a3f0e1f4b27c15049592138d1eebe9c35b183" +dependencies = [ + "async-trait", + "bytes", + "cfg-if", + "data-encoding", + "futures-channel", + "futures-io", + "futures-util", + "h2", + "hickory-proto", + "http", + "idna", + "ipnet", + "jni 0.22.4", + "rand", + "rustls", + "thiserror 2.0.18", + "tinyvec", + "tokio", + "tokio-rustls", + "tracing", + "url", +] + +[[package]] +name = "hickory-proto" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni 0.22.4", + "once_cell", + "prefix-trie", + "rand", + "ring", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d58d28879ceecde6607729660c2667a081ccdc082e082675042793960f178c" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-net", + "hickory-proto", + "ipconfig", + "ipnet", + "jni 0.22.4", + "moka", + "ndk-context", + "once_cell", + "parking_lot", + "rand", + "resolv-conf", + "rustls", + "smallvec", + "system-configuration", + "thiserror 2.0.18", + "tokio", + "tokio-rustls", + "tracing", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-layer", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "identity-hash" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfdd7caa900436d8f13b2346fe10257e0c05c1f1f9e351f4f5d57c03bd5f45da" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "igd-next" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de7238d487a9aff61f81b5ab41c0a841532a115a398b5fa92a2fadd0885e2581" +dependencies = [ + "attohttpc", + "bytes", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "rand", + "tokio", + "url", + "xmltree", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "ipconfig" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" +dependencies = [ + "socket2", + "widestring", + "windows-registry", + "windows-result", + "windows-sys 0.61.2", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +dependencies = [ + "serde", +] + +[[package]] +name = "iroh" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6435544bb3a5c4e6ff7affaa0c0aa0d1bca45bd700226329d5059d3eb54f9dff" +dependencies = [ + "backon", + "blake3", + "bytes", + "cfg_aliases", + "ctutils", + "data-encoding", + "derive_more", + "ed25519-dalek", + "futures-util", + "getrandom 0.4.3", + "hickory-resolver", + "http", + "ipnet", + "iroh-base", + "iroh-dns", + "iroh-metrics", + "iroh-relay", + "n0-error", + "n0-future", + "n0-watcher", + "netwatch", + "noq", + "noq-proto", + "noq-udp", + "papaya", + "pin-project", + "portable-atomic", + "portmapper", + "rand", + "reqwest", + "rustc-hash", + "rustls", + "rustls-pki-types", + "serde", + "smallvec", + "strum", + "time", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "url", + "wasm-bindgen-futures", +] + +[[package]] +name = "iroh-base" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830a582cd54410dc1aa71d4786a82c3297d7b0165accd8b6dbbb3b240b48140d" +dependencies = [ + "curve25519-dalek", + "data-encoding", + "data-encoding-macro", + "derive_more", + "ed25519-dalek", + "getrandom 0.4.3", + "n0-error", + "rand", + "serde", + "url", + "zeroize", +] + +[[package]] +name = "iroh-dns" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516e4eedc38e33ab69a6bd325520332dc3d67b25454e2d590ebb84a25240dd9a" +dependencies = [ + "arc-swap", + "cfg_aliases", + "derive_more", + "hickory-resolver", + "iroh-base", + "n0-error", + "n0-future", + "ndk-context", + "portable-atomic", + "rand", + "rustls", + "simple-dns", + "strum", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "iroh-metrics" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291065721ad7c477b972e581bbc528df031dc8eb5e39fe1ff3300ae5dfb157ef" +dependencies = [ + "iroh-metrics-derive", + "itoa", + "n0-error", + "portable-atomic", + "ryu", + "serde", + "tracing", +] + +[[package]] +name = "iroh-metrics-derive" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae5f0c4405d1fbc9fb16ff422ca40620e93dc36c30ecaba0c2aee3992b7bd48" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "iroh-relay" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8149bb6a57126225a07d6928846d82dcedfd24ea0f863ef7b2eb475e1d726354" +dependencies = [ + "blake3", + "bytes", + "cfg_aliases", + "data-encoding", + "derive_more", + "getrandom 0.4.3", + "hickory-resolver", + "http", + "http-body-util", + "hyper", + "hyper-util", + "iroh-base", + "iroh-dns", + "iroh-metrics", + "lru", + "n0-error", + "n0-future", + "noq", + "noq-proto", + "num_enum", + "pin-project", + "postcard", + "rand", + "reqwest", + "rustls", + "rustls-pki-types", + "serde", + "serde_bytes", + "strum", + "tokio", + "tokio-rustls", + "tokio-util", + "tokio-websockets", + "tracing", + "url", + "vergen-gitcl", + "webpki-roots", + "ws_stream_wasm", +] + +[[package]] +name = "iroh-services" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a88cd95fbd20abd9eadc4df91c722915dc943412b964e915f35b0b0a46a1fd" +dependencies = [ + "anyhow", + "base64", + "built", + "bytes", + "data-encoding", + "derive_more", + "ed25519-dalek", + "futures-buffered", + "getrandom 0.4.3", + "iroh", + "iroh-metrics", + "iroh-tickets", + "irpc", + "irpc-iroh", + "n0-error", + "n0-future", + "portmapper", + "postcard", + "rand", + "rcan", + "serde", + "serde_json", + "strum", + "thiserror 2.0.18", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "iroh-tickets" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da53233419ca36bf521ed45683b7748366f9b233032891eefc2d70567a84ac54" +dependencies = [ + "data-encoding", + "derive_more", + "iroh-base", + "n0-error", + "postcard", + "serde", +] + +[[package]] +name = "irpc" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3623d6ff582b415904b29bbe6ebcb4a4f9a262ccdee05a45fdd003ef0950c386" +dependencies = [ + "futures-buffered", + "futures-util", + "irpc-derive", + "n0-error", + "n0-future", + "noq", + "postcard", + "rcgen", + "rustls", + "serde", + "smallvec", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "irpc-derive" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35c254013736de16472140d26904e6ac98e8f3887284dcf4af40f88c77411b56" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "irpc-iroh" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2342daed629b312f61e57e452b0750a59da162f261b97f260a6354de61d4fb0e" +dependencies = [ + "getrandom 0.3.4", + "iroh", + "iroh-base", + "irpc", + "n0-error", + "n0-future", + "postcard", + "serde", + "tokio", + "tracing", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "lru" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b6180140927ee907000b0aa540091f6ea512ead4447c92b8fc35bc72788a5a6" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "mac-addr" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moka" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "n0-error" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c37e81176a83a77d2514528b91bdafc70ef88aab428f0e1b91aebb8d99888895" +dependencies = [ + "n0-error-macros", + "spez", +] + +[[package]] +name = "n0-error-macros" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2acd8b070213b0299282f884b4beba4e7b52d624fdcd504a3ad3665390c11e1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "n0-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2ab99dfb861450e68853d34ae665243a88b8c493d01ba957321a1e9b2312bbe" +dependencies = [ + "cfg_aliases", + "derive_more", + "futures-buffered", + "futures-lite", + "futures-util", + "js-sys", + "pin-project", + "send_wrapper", + "tokio", + "tokio-util", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-time", +] + +[[package]] +name = "n0-watcher" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc618745ad0b7414b149d0517ad8b5573b2fb4d4e2717add3d2446ce1fdd826" +dependencies = [ + "derive_more", + "n0-error", + "n0-future", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "netdev" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "569dfbdd2efd771b24ec9bb57f956e04d4fbfc72f62b2f11961723f9b3f4b020" +dependencies = [ + "block2", + "dispatch2", + "dlopen2", + "ipnet", + "jni 0.21.1", + "libc", + "mac-addr", + "ndk-context", + "netlink-packet-core", + "netlink-packet-route", + "netlink-sys", + "objc2", + "objc2-core-foundation", + "objc2-core-wlan", + "objc2-foundation", + "objc2-system-configuration", + "once_cell", + "plist", + "windows-sys 0.61.2", +] + +[[package]] +name = "netlink-packet-core" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3463cbb78394cb0141e2c926b93fc2197e473394b761986eca3b9da2c63ae0f4" +dependencies = [ + "paste", +] + +[[package]] +name = "netlink-packet-route" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2288fcb784eb3defd5fb16f4c4160d5f477de192eac730f43e1d11c24d9a007" +dependencies = [ + "bitflags", + "libc", + "log", + "netlink-packet-core", +] + +[[package]] +name = "netlink-proto" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b65d130ee111430e47eed7896ea43ca693c387f097dd97376bffafbf25812128" +dependencies = [ + "bytes", + "futures", + "log", + "netlink-packet-core", + "netlink-sys", + "thiserror 2.0.18", +] + +[[package]] +name = "netlink-sys" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd6c30ed10fa69cc491d491b85cc971f6bdeb8e7367b7cde2ee6cc878d583fae" +dependencies = [ + "bytes", + "futures-util", + "libc", + "log", + "tokio", +] + +[[package]] +name = "netwatch" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d9cbe01741347ef750d743d6690603f5eed8341e679fb51c8e629337aa11976" +dependencies = [ + "atomic-waker", + "bytes", + "cfg_aliases", + "derive_more", + "ipnet", + "js-sys", + "libc", + "n0-error", + "n0-future", + "n0-watcher", + "netdev", + "netlink-packet-core", + "netlink-packet-route", + "netlink-proto", + "netlink-sys", + "noq-udp", + "objc2-core-foundation", + "objc2-system-configuration", + "pin-project-lite", + "serde", + "socket2", + "time", + "tokio", + "tokio-util", + "tracing", + "web-sys", + "windows", + "windows-result", + "wmi", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "noq" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bf95190af1bd4a00a10e8255ca0c8ddd9e9a9f5e79151d7a7eb6d56aff5dc89" +dependencies = [ + "bytes", + "cfg_aliases", + "derive_more", + "noq-proto", + "noq-udp", + "pin-project-lite", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "web-time", +] + +[[package]] +name = "noq-proto" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa6c890013591e709a3e45dd53501351b7e27e7ff3c7e9fc3dce43e300e7e9d3" +dependencies = [ + "aes-gcm", + "bytes", + "derive_more", + "enum-assoc", + "getrandom 0.4.3", + "identity-hash", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "sorted-index-buffer", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "noq-udp" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3137a52df66c20090a889828d1c655f21f52294cba64e5c4fbb04fc83eee7c8e" +dependencies = [ + "cfg_aliases", + "libc", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "block2", + "dispatch2", + "libc", + "objc2", +] + +[[package]] +name = "objc2-core-wlan" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c71e34919aba0d701380d911702455038a8a3587467fe0141d6a71501e7ffe48" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", + "objc2-foundation", + "objc2-security", + "objc2-security-foundation", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-security" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-security-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef76382e9cedd18123099f17638715cc3d81dba3637d4c0d39ab69df2ef345a5" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "bitflags", + "dispatch2", + "libc", + "objc2", + "objc2-core-foundation", + "objc2-security", +] + +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "papaya" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "997ee03cd38c01469a7046643714f0ad28880bcb9e6679ff0666e24817ca19b7" +dependencies = [ + "equivalent", + "seize", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pharos" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9567389417feee6ce15dd6527a8a1ecac205ef62c2932bcf3d9f6fc5b78b414" +dependencies = [ + "futures", + "rustc_version", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "plist" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" +dependencies = [ + "base64", + "indexmap", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +dependencies = [ + "serde", +] + +[[package]] +name = "portmapper" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3713e4977408279158444a18c1a01ac9bf2e7eaf1fbfd1a19ac9cd18d90721" +dependencies = [ + "base64", + "bytes", + "derive_more", + "hyper-util", + "igd-next", + "iroh-metrics", + "libc", + "n0-error", + "n0-future", + "netwatch", + "num_enum", + "rand", + "serde", + "smallvec", + "socket2", + "time", + "tokio", + "tokio-util", + "tower-layer", + "tracing", + "url", +] + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "heapless", + "postcard-derive", + "serde", +] + +[[package]] +name = "postcard-derive" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0232bd009a197ceec9cc881ba46f727fcd8060a2d8d6a9dde7a69030a6fe2bb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "prefix-trie" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7" +dependencies = [ + "either", + "ipnet", + "num-traits", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rcan" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12a624a4a4742f8c6e58fba99712e606cea0491b76f5b2345f06af5802101027" +dependencies = [ + "anyhow", + "derive_more", + "ed25519-dalek", + "hex", + "n0-future", + "postcard", + "serde", + "serdect", +] + +[[package]] +name = "rcgen" +version = "0.14.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "seize" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct", + "serde", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simple-dns" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a75cbde1bf934313596a004973e462f9a82caa814dcf1a5f507bdf51597eeb4" +dependencies = [ + "bitflags", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "sorted-index-buffer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea06cc588e43c632923a55450401b8f25e628131571d4e1baea1bdfdb2b5ed06" + +[[package]] +name = "spez" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c87e960f4dca2788eeb86bbdde8dd246be8948790b7618d656e68f9b720a86e8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spin" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" + +[[package]] +name = "spki" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +dependencies = [ + "deranged", + "js-sys", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-websockets" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52efb639344a7c6adb8e62c6f3d2c19c001ff1b79a5041ba1c6ed42e19c6aa5" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-sink", + "getrandom 0.4.3", + "http", + "httparse", + "rand", + "ring", + "rustls-pki-types", + "sha1_smol", + "simdutf8", + "tokio", + "tokio-rustls", + "tokio-util", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.3", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.3", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vercel_runtime" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f5e7942f725023f1572b7fef91b0aeddc8749a3b9b0b191f59c208c42ed8e62" +dependencies = [ + "base64", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "lazy_static", + "libc", + "serde", + "serde_json", + "tokio", + "tokio-stream", + "tower", +] + +[[package]] +name = "vergen" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b849a1f6d8639e8de261e81ee0fc881e3e3620db1af9f2e0da015d4382ceaf75" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", + "vergen-lib", +] + +[[package]] +name = "vergen-gitcl" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ff3b5300a085d6bcd8fc96a507f706a28ae3814693236c9b409db71a1d15b9" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", + "time", + "vergen", + "vergen-lib", +] + +[[package]] +name = "vergen-lib" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b34a29ba7e9c59e62f229ae1932fb1b8fb8a6fdcc99215a641913f5f5a59a569" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wmi" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c81b85c57a57500e56669586496bf2abd5cf082b9d32995251185d105208b64" +dependencies = [ + "chrono", + "futures", + "log", + "serde", + "thiserror 2.0.18", + "windows", + "windows-core", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "ws_stream_wasm" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c173014acad22e83f16403ee360115b38846fe754e735c5d9d3803fe70c6abc" +dependencies = [ + "async_io_stream", + "futures", + "js-sys", + "log", + "pharos", + "rustc_version", + "send_wrapper", + "thiserror 2.0.18", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "xmltree" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d8a75eaf6557bb84a65ace8609883db44a29951042ada9b393151532e41fcb" +dependencies = [ + "xml-rs", +] + +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec", + "time", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/services/iroh-relay-minter/Cargo.toml b/services/iroh-relay-minter/Cargo.toml new file mode 100644 index 000000000000..e9f043180385 --- /dev/null +++ b/services/iroh-relay-minter/Cargo.toml @@ -0,0 +1,51 @@ +[package] +name = "cmux-iroh-relay-minter" +version = "0.1.0" +edition = "2024" +rust-version = "1.91" +publish = false +autobins = false + +[lib] +path = "src/lib.rs" + +[[bin]] +name = "relay-token" +path = "api/relay-token.rs" + +[dependencies] +base64 = "0.22.1" +data-encoding = "2.9.0" +hex = "0.4.3" +hmac = "0.12.1" +http-body = "1.0.1" +http-body-util = "0.1.3" +hyper = { version = "1.7.0", features = ["http1"] } +iroh = { version = "=1.0.0", default-features = false } +iroh-services = { version = "=1.0.0", default-features = false } +rcan = "=0.4.0" +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.145" +sha2 = "0.10.9" +time = { version = "0.3.44", features = ["formatting", "parsing"] } +tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread"] } +vercel_runtime = "=2.0.0" +zeroize = "1.8.1" + +[dev-dependencies] +futures-util = "0.3.31" + +[profile.release] +codegen-units = 1 +lto = "fat" +opt-level = 3 +panic = "abort" +strip = true + +[lints.rust] +unsafe_code = "forbid" + +[lints.clippy] +dbg_macro = "deny" +todo = "deny" +unimplemented = "deny" diff --git a/services/iroh-relay-minter/README.md b/services/iroh-relay-minter/README.md new file mode 100644 index 000000000000..ec5664ca9605 --- /dev/null +++ b/services/iroh-relay-minter/README.md @@ -0,0 +1,71 @@ +# Iroh relay-token minter + +This Vercel Rust project is the only cmux service allowed to hold the Iroh +Services project credential. It converts a short-lived request authenticated by +the cmux web trust broker into a 24-hour, endpoint-scoped RCAN containing only +`relay:use`. + +Deploy this directory as a separate Vercel project. Set its Root Directory to +`services/iroh-relay-minter`. Do not add `IROH_SERVICES_API_SECRET` to the cmux +web project because Vercel environment variables are project-wide. + +## Environment + +The minter project requires: + +- `IROH_SERVICES_API_SECRET`: the rotated Iroh Services project secret. It is + parsed by `iroh-services` 1.0.0 and is never returned or logged. +- `CMUX_IROH_MINT_HMAC_SECRET_B64`: 32 to 256 random bytes encoded as canonical + standard base64. Generate a new 32-byte value with `openssl rand -base64 32`. + +The web project requires the same `CMUX_IROH_MINT_HMAC_SECRET_B64` value and: + +- `CMUX_IROH_MINT_URL=https:///api/relay-token` + +Rotate any Iroh Services credential previously pasted into chat or logs before +putting it in Vercel. A Services credential rotation affects only the minter. +An HMAC rotation must update both projects together. + +## Wire contract + +The only accepted route is `POST /api/relay-token` with exact +`Content-Type: application/json`, no query string, and this body: + +```json +{"endpointId":"<64 lowercase hex characters>","lifetimeSeconds":86400} +``` + +The web service sends: + +- `x-cmux-iroh-timestamp`: canonical Unix seconds, within 30 seconds of the + minter clock. +- `x-cmux-iroh-signature`: unpadded base64url HMAC-SHA256 over the transcript + below. + +```text +POST +/api/relay-token + + +``` + +The response is bounded JSON: + +```json +{"token":"","expiresAt":""} +``` + +The RCAN issuer is the Iroh Services project key, the audience is the supplied +EndpointID, the sole capability is `relay:use`, and expiry is 86,400 seconds. +The trust broker stores only issuance audit state and refreshes the relay token +after 12 hours. + +## Local verification + +No production secrets are needed for tests. + +```sh +cargo fmt --check +cargo clippy --all-targets --locked -- -D warnings +cargo test --locked +``` diff --git a/services/iroh-relay-minter/api/relay-token.rs b/services/iroh-relay-minter/api/relay-token.rs new file mode 100644 index 000000000000..1c1da8d8e52a --- /dev/null +++ b/services/iroh-relay-minter/api/relay-token.rs @@ -0,0 +1,18 @@ +use std::time::SystemTime; + +use cmux_iroh_relay_minter::{MinterConfig, configuration_error_response, handle_request}; +use vercel_runtime::{Error, Request, Response, ResponseBody, run, service_fn}; + +#[tokio::main] +async fn main() -> Result<(), Error> { + run(service_fn(handler)).await +} + +async fn handler(request: Request) -> Result, Error> { + let config = match MinterConfig::from_env() { + Ok(config) => config, + Err(_) => return Ok(configuration_error_response()), + }; + + Ok(handle_request(request, &config, SystemTime::now()).await) +} diff --git a/services/iroh-relay-minter/rust-toolchain.toml b/services/iroh-relay-minter/rust-toolchain.toml new file mode 100644 index 000000000000..0f39414be778 --- /dev/null +++ b/services/iroh-relay-minter/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.91.0" +profile = "minimal" +components = ["clippy", "rustfmt"] diff --git a/services/iroh-relay-minter/src/lib.rs b/services/iroh-relay-minter/src/lib.rs new file mode 100644 index 000000000000..6f4560b0837f --- /dev/null +++ b/services/iroh-relay-minter/src/lib.rs @@ -0,0 +1,794 @@ +use std::{ + env, fmt, + str::FromStr, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use base64::{ + Engine as _, + engine::general_purpose::{STANDARD, STANDARD_NO_PAD, URL_SAFE_NO_PAD}, +}; +use data_encoding::BASE32_NOPAD; +use hmac::{Hmac, Mac}; +use http_body::Body; +use http_body_util::BodyExt as _; +use hyper::{ + Method, Request, Response, StatusCode, + body::Bytes, + header::{ALLOW, CACHE_CONTROL, CONTENT_LENGTH, CONTENT_TYPE, HeaderMap, HeaderName}, +}; +use iroh::{EndpointId, SecretKey}; +use iroh_services::{ + ApiSecret, + caps::{Cap, Caps, RelayCap, create_api_token_from_secret_key}, +}; +use rcan::Expires; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use time::{OffsetDateTime, format_description::well_known::Rfc3339}; +use vercel_runtime::ResponseBody; +use zeroize::Zeroizing; + +pub const MINT_PATH: &str = "/api/relay-token"; +pub const RELAY_TOKEN_LIFETIME_SECONDS: u64 = 86_400; +pub const MAX_REQUEST_BYTES: usize = 4 * 1_024; + +const MAX_RESPONSE_BYTES: usize = 32 * 1_024; +const MAX_TOKEN_BYTES: usize = 16 * 1_024; +const CLOCK_SKEW_SECONDS: u64 = 30; +const SERVICES_SECRET_ENV: &str = "IROH_SERVICES_API_SECRET"; +const HMAC_SECRET_ENV: &str = "CMUX_IROH_MINT_HMAC_SECRET_B64"; +const TIMESTAMP_HEADER: HeaderName = HeaderName::from_static("x-cmux-iroh-timestamp"); +const SIGNATURE_HEADER: HeaderName = HeaderName::from_static("x-cmux-iroh-signature"); + +type HmacSha256 = Hmac; + +pub struct MinterConfig { + issuer: SecretKey, + hmac_secret: Zeroizing>, +} + +impl MinterConfig { + pub fn from_env() -> Result { + let services_secret = Zeroizing::new(bounded_env(SERVICES_SECRET_ENV, 16_384)?); + let api_secret = + ApiSecret::from_str(services_secret.as_str()).map_err(|_| ConfigurationError)?; + let hmac_secret_text = Zeroizing::new(bounded_env(HMAC_SECRET_ENV, 512)?); + let hmac_secret = decode_hmac_secret(hmac_secret_text.as_str())?; + + Ok(Self { + issuer: api_secret.secret, + hmac_secret: Zeroizing::new(hmac_secret), + }) + } +} + +#[derive(Clone, Copy, Debug)] +pub struct ConfigurationError; + +impl fmt::Display for ConfigurationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("relay minter configuration is unavailable") + } +} + +impl std::error::Error for ConfigurationError {} + +pub async fn handle_request( + request: Request, + config: &MinterConfig, + now: SystemTime, +) -> Response +where + B: Body + Unpin, +{ + match process_request(request, config, now).await { + Ok(response) => json_response(StatusCode::OK, &response), + Err(error) => error_response(error.status(), error.code()), + } +} + +pub fn configuration_error_response() -> Response { + error_response(StatusCode::SERVICE_UNAVAILABLE, "configuration_unavailable") +} + +async fn process_request( + request: Request, + config: &MinterConfig, + now: SystemTime, +) -> Result +where + B: Body + Unpin, +{ + if request.method() != Method::POST { + return Err(RequestFailure::Method); + } + if request.uri().path() != MINT_PATH || request.uri().query().is_some() { + return Err(RequestFailure::Path); + } + require_json_content_type(request.headers())?; + validate_content_length(request.headers())?; + + let timestamp_text = single_header(request.headers(), &TIMESTAMP_HEADER) + .ok_or(RequestFailure::Authentication)? + .to_owned(); + let timestamp = parse_timestamp(×tamp_text).ok_or(RequestFailure::Authentication)?; + let now_seconds = unix_seconds(now).ok_or(RequestFailure::Internal)?; + if now_seconds.abs_diff(timestamp) > CLOCK_SKEW_SECONDS { + return Err(RequestFailure::Authentication); + } + + let signature_text = single_header(request.headers(), &SIGNATURE_HEADER) + .ok_or(RequestFailure::Authentication)?; + let signature = decode_signature(signature_text).ok_or(RequestFailure::Authentication)?; + + let body = read_bounded_body(request.into_body()).await?; + verify_hmac( + config.hmac_secret.as_slice(), + ×tamp_text, + &body, + &signature, + )?; + + let input: MintRequest = + serde_json::from_slice(&body).map_err(|_| RequestFailure::InvalidBody)?; + if input.lifetime_seconds != RELAY_TOKEN_LIFETIME_SECONDS { + return Err(RequestFailure::InvalidLifetime); + } + let endpoint_id = parse_endpoint_id(&input.endpoint_id)?; + + mint_token(config, endpoint_id, now_seconds) +} + +async fn read_bounded_body(mut body: B) -> Result, RequestFailure> +where + B: Body + Unpin, +{ + if body + .size_hint() + .upper() + .is_some_and(|upper| upper > MAX_REQUEST_BYTES as u64) + { + return Err(RequestFailure::BodyTooLarge); + } + + let mut bytes = Vec::with_capacity( + body.size_hint() + .upper() + .unwrap_or(0) + .min(MAX_REQUEST_BYTES as u64) as usize, + ); + while let Some(frame) = body.frame().await { + let frame = frame.map_err(|_| RequestFailure::InvalidBody)?; + let Ok(data) = frame.into_data() else { + continue; + }; + let Some(next_len) = bytes.len().checked_add(data.len()) else { + return Err(RequestFailure::BodyTooLarge); + }; + if next_len > MAX_REQUEST_BYTES { + return Err(RequestFailure::BodyTooLarge); + } + bytes.extend_from_slice(&data); + } + Ok(bytes) +} + +fn mint_token( + config: &MinterConfig, + endpoint_id: EndpointId, + authenticated_at: u64, +) -> Result { + let capability = Caps::new([Cap::Relay(RelayCap::Use)]); + let rcan = create_api_token_from_secret_key( + config.issuer.clone(), + endpoint_id, + Duration::from_secs(RELAY_TOKEN_LIFETIME_SECONDS), + capability, + ) + .map_err(|_| RequestFailure::Internal)?; + + let Expires::At(expires_at) = rcan.expires() else { + return Err(RequestFailure::Internal); + }; + let expected_expiry = authenticated_at + .checked_add(RELAY_TOKEN_LIFETIME_SECONDS) + .ok_or(RequestFailure::Internal)?; + if expected_expiry.abs_diff(*expires_at) > 2 { + return Err(RequestFailure::Internal); + } + + let mut token = BASE32_NOPAD.encode(&rcan.encode()); + token.make_ascii_lowercase(); + if token.is_empty() + || token.len() > MAX_TOKEN_BYTES + || token.contains('=') + || !token + .bytes() + .all(|byte| byte.is_ascii_lowercase() || (b'2'..=b'7').contains(&byte)) + { + return Err(RequestFailure::Internal); + } + + let expires_at_i64 = i64::try_from(*expires_at).map_err(|_| RequestFailure::Internal)?; + let expires_at = OffsetDateTime::from_unix_timestamp(expires_at_i64) + .map_err(|_| RequestFailure::Internal)? + .format(&Rfc3339) + .map_err(|_| RequestFailure::Internal)?; + if expires_at.len() > 64 { + return Err(RequestFailure::Internal); + } + + Ok(MintResponse { token, expires_at }) +} + +fn verify_hmac( + secret: &[u8], + timestamp: &str, + body: &[u8], + signature: &[u8; 32], +) -> Result<(), RequestFailure> { + let body_hash = hex::encode(Sha256::digest(body)); + let transcript = format!("POST\n{MINT_PATH}\n{timestamp}\n{body_hash}"); + let mut mac = HmacSha256::new_from_slice(secret).map_err(|_| RequestFailure::Internal)?; + mac.update(transcript.as_bytes()); + mac.verify_slice(signature) + .map_err(|_| RequestFailure::Authentication) +} + +fn parse_endpoint_id(value: &str) -> Result { + if value.len() != 64 + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(RequestFailure::InvalidEndpoint); + } + let mut bytes = [0_u8; 32]; + hex::decode_to_slice(value, &mut bytes).map_err(|_| RequestFailure::InvalidEndpoint)?; + EndpointId::from_bytes(&bytes).map_err(|_| RequestFailure::InvalidEndpoint) +} + +fn require_json_content_type(headers: &HeaderMap) -> Result<(), RequestFailure> { + let content_type = single_header(headers, &CONTENT_TYPE).ok_or(RequestFailure::ContentType)?; + if content_type == "application/json" { + Ok(()) + } else { + Err(RequestFailure::ContentType) + } +} + +fn validate_content_length(headers: &HeaderMap) -> Result<(), RequestFailure> { + let values = headers.get_all(&CONTENT_LENGTH); + let mut values = values.iter(); + let Some(value) = values.next() else { + return Ok(()); + }; + if values.next().is_some() { + return Err(RequestFailure::InvalidBody); + } + let length = value + .to_str() + .ok() + .and_then(|value| value.parse::().ok()) + .ok_or(RequestFailure::InvalidBody)?; + if length > MAX_REQUEST_BYTES { + Err(RequestFailure::BodyTooLarge) + } else { + Ok(()) + } +} + +fn single_header<'a>(headers: &'a HeaderMap, name: &HeaderName) -> Option<&'a str> { + let values = headers.get_all(name); + let mut values = values.iter(); + let value = values.next()?.to_str().ok()?; + if values.next().is_some() { + return None; + } + Some(value) +} + +fn parse_timestamp(value: &str) -> Option { + if value.is_empty() || value.len() > 20 || !value.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + let parsed: u64 = value.parse().ok()?; + (parsed.to_string() == value).then_some(parsed) +} + +fn decode_signature(value: &str) -> Option<[u8; 32]> { + if value.len() != 43 || value.contains('=') { + return None; + } + let decoded = URL_SAFE_NO_PAD.decode(value.as_bytes()).ok()?; + let signature: [u8; 32] = decoded.try_into().ok()?; + (URL_SAFE_NO_PAD.encode(signature) == value).then_some(signature) +} + +fn decode_hmac_secret(value: &str) -> Result, ConfigurationError> { + let decoded = STANDARD + .decode(value.as_bytes()) + .or_else(|_| STANDARD_NO_PAD.decode(value.as_bytes())) + .map_err(|_| ConfigurationError)?; + if decoded.len() < 32 || decoded.len() > 256 { + return Err(ConfigurationError); + } + let canonical_padded = STANDARD.encode(&decoded); + let canonical_unpadded = STANDARD_NO_PAD.encode(&decoded); + if value != canonical_padded && value != canonical_unpadded { + return Err(ConfigurationError); + } + Ok(decoded) +} + +fn bounded_env(name: &str, max_bytes: usize) -> Result { + let value = env::var(name).map_err(|_| ConfigurationError)?; + if value.is_empty() || value.len() > max_bytes { + return Err(ConfigurationError); + } + Ok(value) +} + +fn unix_seconds(time: SystemTime) -> Option { + time.duration_since(UNIX_EPOCH) + .ok() + .map(|value| value.as_secs()) +} + +fn json_response(status: StatusCode, value: &impl Serialize) -> Response { + let body = serde_json::to_string(value) + .unwrap_or_else(|_| "{\"error\":\"internal_error\"}".to_owned()); + if body.len() > MAX_RESPONSE_BYTES { + return error_response(StatusCode::INTERNAL_SERVER_ERROR, "internal_error"); + } + response(status, body) +} + +fn error_response(status: StatusCode, code: &'static str) -> Response { + let body = serde_json::to_string(&ErrorResponse { error: code }) + .unwrap_or_else(|_| "{\"error\":\"internal_error\"}".to_owned()); + let mut response = response(status, body); + if status == StatusCode::METHOD_NOT_ALLOWED { + response + .headers_mut() + .insert(ALLOW, "POST".parse().expect("static header value")); + } + response +} + +fn response(status: StatusCode, body: String) -> Response { + Response::builder() + .status(status) + .header(CONTENT_TYPE, "application/json") + .header(CACHE_CONTROL, "no-store") + .header("x-content-type-options", "nosniff") + .body(ResponseBody::from(body)) + .expect("static response metadata is valid") +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct MintRequest { + endpoint_id: String, + lifetime_seconds: u64, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct MintResponse { + token: String, + expires_at: String, +} + +#[derive(Serialize)] +struct ErrorResponse { + error: &'static str, +} + +#[derive(Clone, Copy, Debug)] +enum RequestFailure { + Method, + Path, + ContentType, + Authentication, + BodyTooLarge, + InvalidBody, + InvalidEndpoint, + InvalidLifetime, + Internal, +} + +impl RequestFailure { + const fn status(self) -> StatusCode { + match self { + Self::Method => StatusCode::METHOD_NOT_ALLOWED, + Self::Path => StatusCode::NOT_FOUND, + Self::ContentType => StatusCode::UNSUPPORTED_MEDIA_TYPE, + Self::Authentication => StatusCode::UNAUTHORIZED, + Self::BodyTooLarge => StatusCode::PAYLOAD_TOO_LARGE, + Self::InvalidBody | Self::InvalidEndpoint | Self::InvalidLifetime => { + StatusCode::BAD_REQUEST + } + Self::Internal => StatusCode::INTERNAL_SERVER_ERROR, + } + } + + const fn code(self) -> &'static str { + match self { + Self::Method => "method_not_allowed", + Self::Path => "not_found", + Self::ContentType => "unsupported_media_type", + Self::Authentication => "unauthorized", + Self::BodyTooLarge => "body_too_large", + Self::InvalidBody => "invalid_body", + Self::InvalidEndpoint => "invalid_endpoint_id", + Self::InvalidLifetime => "invalid_lifetime", + Self::Internal => "internal_error", + } + } +} + +#[cfg(test)] +mod tests { + use std::{convert::Infallible, time::SystemTime}; + + use futures_util::stream; + use http_body::Frame; + use http_body_util::{BodyExt as _, Full, StreamBody}; + use hyper::{Method, Request, StatusCode, body::Bytes, header::ALLOW}; + use iroh::SecretKey; + use rcan::{CapabilityOrigin, Expires, Rcan}; + use time::OffsetDateTime; + + use super::*; + + const TEST_HMAC_SECRET: [u8; 32] = [0x42; 32]; + + #[tokio::test] + async fn mints_lowercase_relay_only_rcan_for_the_exact_audience() { + let config = test_config(); + let endpoint = test_endpoint(); + let now = SystemTime::now(); + let now_seconds = unix_seconds(now).expect("test clock is after the Unix epoch"); + let body = valid_body(&endpoint.to_string()); + let request = signed_request(Method::POST, MINT_PATH, now_seconds, body); + + let response = handle_request(request, &config, now).await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()[CACHE_CONTROL], "no-store"); + let response: MintResponse = response_json(response).await; + + assert!(!response.token.contains('=')); + assert!(response.token.len() <= MAX_TOKEN_BYTES); + assert!( + response + .token + .bytes() + .all(|byte| byte.is_ascii_lowercase() || (b'2'..=b'7').contains(&byte)) + ); + + let token_bytes = BASE32_NOPAD + .decode(response.token.to_ascii_uppercase().as_bytes()) + .expect("response is unpadded base32"); + let rcan = Rcan::::decode(&token_bytes).expect("response is a signed RCAN"); + assert_eq!(rcan.audience(), &endpoint.as_verifying_key()); + assert_eq!(rcan.issuer(), &config.issuer.public().as_verifying_key()); + assert_eq!(rcan.capability_origin(), &CapabilityOrigin::Issuer); + assert_eq!(rcan.capability().to_strings(), ["relay:use"]); + + let Expires::At(token_expiry) = rcan.expires() else { + panic!("relay token must expire"); + }; + assert!( + now_seconds + .checked_add(RELAY_TOKEN_LIFETIME_SECONDS) + .expect("test expiry is representable") + .abs_diff(*token_expiry) + <= 2 + ); + let response_expiry = OffsetDateTime::parse(&response.expires_at, &Rfc3339) + .expect("response expiry is RFC 3339"); + assert_eq!(response_expiry.unix_timestamp(), *token_expiry as i64); + } + + #[tokio::test] + async fn rejects_every_method_and_path_except_the_single_post_route() { + let config = test_config(); + let now = SystemTime::now(); + let timestamp = unix_seconds(now).expect("test clock is valid"); + let body = valid_body(&test_endpoint().to_string()); + + let response = handle_request( + signed_request(Method::GET, MINT_PATH, timestamp, body.clone()), + &config, + now, + ) + .await; + assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); + assert_eq!(response.headers()[ALLOW], "POST"); + + for path in ["/", "/api/relay-token/", "/api/relay-token?debug=1"] { + let response = handle_request( + signed_request(Method::POST, path, timestamp, body.clone()), + &config, + now, + ) + .await; + assert_eq!(response.status(), StatusCode::NOT_FOUND, "path {path}"); + } + } + + #[tokio::test] + async fn rejects_missing_wrong_or_body_substituted_hmac() { + let config = test_config(); + let now = SystemTime::now(); + let timestamp = unix_seconds(now).expect("test clock is valid"); + let body = valid_body(&test_endpoint().to_string()); + + let mut missing = signed_request(Method::POST, MINT_PATH, timestamp, body.clone()); + missing.headers_mut().remove(&SIGNATURE_HEADER); + assert_eq!( + handle_request(missing, &config, now).await.status(), + StatusCode::UNAUTHORIZED + ); + + let mut wrong = signed_request(Method::POST, MINT_PATH, timestamp, body.clone()); + wrong.headers_mut().insert( + &SIGNATURE_HEADER, + URL_SAFE_NO_PAD + .encode([0_u8; 32]) + .parse() + .expect("test header is valid"), + ); + assert_eq!( + handle_request(wrong, &config, now).await.status(), + StatusCode::UNAUTHORIZED + ); + + let mut substituted = signed_request(Method::POST, MINT_PATH, timestamp, body); + *substituted.body_mut() = Full::new(Bytes::from(valid_body( + &SecretKey::from_bytes(&[0x33; 32]).public().to_string(), + ))); + assert_eq!( + handle_request(substituted, &config, now).await.status(), + StatusCode::UNAUTHORIZED + ); + } + + #[tokio::test] + async fn enforces_the_thirty_second_timestamp_window() { + let config = test_config(); + let now = SystemTime::now(); + let now_seconds = unix_seconds(now).expect("test clock is valid"); + let body = valid_body(&test_endpoint().to_string()); + + for timestamp in [now_seconds - 31, now_seconds + 31] { + let response = handle_request( + signed_request(Method::POST, MINT_PATH, timestamp, body.clone()), + &config, + now, + ) + .await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + for timestamp in [now_seconds - 30, now_seconds + 30] { + let response = handle_request( + signed_request(Method::POST, MINT_PATH, timestamp, body.clone()), + &config, + now, + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + } + + let mut malformed = signed_request(Method::POST, MINT_PATH, now_seconds, body); + malformed.headers_mut().insert( + &TIMESTAMP_HEADER, + "+123".parse().expect("test header is valid"), + ); + assert_eq!( + handle_request(malformed, &config, now).await.status(), + StatusCode::UNAUTHORIZED + ); + assert_eq!(parse_timestamp("01"), None); + } + + #[tokio::test] + async fn bounds_declared_and_streamed_request_bodies() { + let config = test_config(); + let now = SystemTime::now(); + let timestamp = unix_seconds(now).expect("test clock is valid"); + let body = valid_body(&test_endpoint().to_string()); + + let mut declared = signed_request(Method::POST, MINT_PATH, timestamp, body); + declared.headers_mut().insert( + CONTENT_LENGTH, + (MAX_REQUEST_BYTES + 1) + .to_string() + .parse() + .expect("test header is valid"), + ); + assert_eq!( + handle_request(declared, &config, now).await.status(), + StatusCode::PAYLOAD_TOO_LARGE + ); + + let chunk = Bytes::from(vec![b'x'; MAX_REQUEST_BYTES / 2 + 1]); + let streamed_body = [chunk.clone(), chunk]; + let joined = streamed_body.concat(); + let signature = sign_request(timestamp, &joined); + let body_stream = StreamBody::new(stream::iter( + streamed_body + .into_iter() + .map(|chunk| Ok::, Infallible>(Frame::data(chunk))), + )); + let streamed = Request::builder() + .method(Method::POST) + .uri(MINT_PATH) + .header(CONTENT_TYPE, "application/json") + .header(&TIMESTAMP_HEADER, timestamp.to_string()) + .header(&SIGNATURE_HEADER, signature) + .body(body_stream) + .expect("test request is valid"); + assert_eq!( + handle_request(streamed, &config, now).await.status(), + StatusCode::PAYLOAD_TOO_LARGE + ); + } + + #[tokio::test] + async fn rejects_invalid_endpoint_lifetime_and_json_shape() { + let config = test_config(); + let now = SystemTime::now(); + let timestamp = unix_seconds(now).expect("test clock is valid"); + let endpoint = test_endpoint().to_string(); + let invalid_bodies = [ + format!( + "{{\"endpointId\":\"{}\",\"lifetimeSeconds\":86400}}", + endpoint.to_ascii_uppercase() + ), + format!("{{\"endpointId\":\"{endpoint}\",\"lifetimeSeconds\":86401}}"), + format!( + "{{\"endpointId\":\"{endpoint}\",\"lifetimeSeconds\":86400,\"capability\":\"all\"}}" + ), + "{}".to_owned(), + ]; + + for body in invalid_bodies { + let response = handle_request( + signed_request(Method::POST, MINT_PATH, timestamp, body), + &config, + now, + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } + } + + #[tokio::test] + async fn requires_the_exact_json_content_type() { + let config = test_config(); + let now = SystemTime::now(); + let timestamp = unix_seconds(now).expect("test clock is valid"); + let body = valid_body(&test_endpoint().to_string()); + let mut request = signed_request(Method::POST, MINT_PATH, timestamp, body); + request.headers_mut().insert( + CONTENT_TYPE, + "text/plain".parse().expect("valid test header"), + ); + + assert_eq!( + handle_request(request, &config, now).await.status(), + StatusCode::UNSUPPORTED_MEDIA_TYPE + ); + } + + #[test] + fn matches_the_typescript_hmac_wire_fixture() { + #[derive(Deserialize)] + struct Fixture { + path: String, + timestamp: String, + body: String, + signature: String, + } + + let fixture: Fixture = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../tests/fixtures/iroh/relay-minter-request-v1.json" + ))) + .expect("shared relay-minter fixture is valid"); + assert_eq!(fixture.path, MINT_PATH); + let timestamp = parse_timestamp(&fixture.timestamp).expect("fixture timestamp is valid"); + assert_eq!( + sign_request(timestamp, fixture.body.as_bytes()), + fixture.signature + ); + let signature = decode_signature(&fixture.signature).expect("fixture signature is valid"); + verify_hmac( + &TEST_HMAC_SECRET, + &fixture.timestamp, + fixture.body.as_bytes(), + &signature, + ) + .expect("fixture authenticates"); + let request: MintRequest = + serde_json::from_str(&fixture.body).expect("fixture body matches the contract"); + assert_eq!(request.lifetime_seconds, RELAY_TOKEN_LIFETIME_SECONDS); + parse_endpoint_id(&request.endpoint_id).expect("fixture endpoint is valid"); + } + + #[test] + fn accepts_only_canonical_hmac_secret_encodings_of_at_least_32_bytes() { + let padded = STANDARD.encode(TEST_HMAC_SECRET); + let unpadded = STANDARD_NO_PAD.encode(TEST_HMAC_SECRET); + assert_eq!( + decode_hmac_secret(&padded).expect("padded secret"), + TEST_HMAC_SECRET + ); + assert_eq!( + decode_hmac_secret(&unpadded).expect("unpadded secret"), + TEST_HMAC_SECRET + ); + assert!(decode_hmac_secret(&STANDARD.encode([1_u8; 31])).is_err()); + assert!(decode_hmac_secret(&format!(" {padded}")).is_err()); + assert!(decode_hmac_secret(&URL_SAFE_NO_PAD.encode([0xff_u8; 32])).is_err()); + } + + fn test_config() -> MinterConfig { + MinterConfig { + issuer: SecretKey::from_bytes(&[0x11; 32]), + hmac_secret: Zeroizing::new(TEST_HMAC_SECRET.to_vec()), + } + } + + fn test_endpoint() -> EndpointId { + SecretKey::from_bytes(&[0x22; 32]).public() + } + + fn valid_body(endpoint_id: &str) -> String { + format!( + "{{\"endpointId\":\"{endpoint_id}\",\"lifetimeSeconds\":{RELAY_TOKEN_LIFETIME_SECONDS}}}" + ) + } + + fn signed_request( + method: Method, + path: &str, + timestamp: u64, + body: String, + ) -> Request> { + Request::builder() + .method(method) + .uri(path) + .header(CONTENT_TYPE, "application/json") + .header(&TIMESTAMP_HEADER, timestamp.to_string()) + .header(&SIGNATURE_HEADER, sign_request(timestamp, body.as_bytes())) + .body(Full::new(Bytes::from(body))) + .expect("test request is valid") + } + + fn sign_request(timestamp: u64, body: &[u8]) -> String { + let body_hash = hex::encode(Sha256::digest(body)); + let transcript = format!("POST\n{MINT_PATH}\n{timestamp}\n{body_hash}"); + let mut mac = + HmacSha256::new_from_slice(&TEST_HMAC_SECRET).expect("test HMAC key length is valid"); + mac.update(transcript.as_bytes()); + URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()) + } + + async fn response_json(response: Response) -> T + where + T: for<'de> Deserialize<'de>, + { + let bytes = response + .into_body() + .collect() + .await + .expect("test response body is readable") + .to_bytes(); + serde_json::from_slice(&bytes).expect("test response is JSON") + } +} diff --git a/services/iroh-relay-minter/vercel.json b/services/iroh-relay-minter/vercel.json new file mode 100644 index 000000000000..20fc77e7e7cf --- /dev/null +++ b/services/iroh-relay-minter/vercel.json @@ -0,0 +1,3 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json" +} diff --git a/tests/fixtures/iroh/relay-minter-request-v1.json b/tests/fixtures/iroh/relay-minter-request-v1.json new file mode 100644 index 000000000000..978434ba6659 --- /dev/null +++ b/tests/fixtures/iroh/relay-minter-request-v1.json @@ -0,0 +1,6 @@ +{ + "path": "/api/relay-token", + "timestamp": "1783641600", + "body": "{\"endpointId\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"lifetimeSeconds\":86400}", + "signature": "U7P9cSbpQMrtmshYTTuBALTsDhGwxWqTG4mIdlqgX4c" +} diff --git a/web/.env.example b/web/.env.example index 11a43373f865..50f566d6f4ca 100644 --- a/web/.env.example +++ b/web/.env.example @@ -45,9 +45,14 @@ STACK_SECRET_SERVER_KEY= # PEM strings. The developer binding override remains off unless all three # override settings explicitly match the authenticated user and deployment. CMUX_IROH_LAN_DISCOVERY_SECRET_B64= +# Independent 32-byte base64 secret used only to derive opaque account subjects +# for short-lived offline endpoint attestations. +CMUX_IROH_ACCOUNT_SUBJECT_SECRET_B64= CMUX_IROH_GRANT_SIGNING_KEY_P8= CMUX_IROH_GRANT_SIGNING_KID= -CMUX_IROH_GRANT_VERIFY_KEYS_JSON= +# Versioned public Ed25519 key set. Each key is canonical SPKI DER base64. +# {"version":1,"current_kid":"current","keys":[{"kid":"current","alg":"EdDSA","spki_der_base64":"..."}]} +CMUX_IROH_GRANT_VERIFICATION_KEYS_JSON= CMUX_IROH_MINT_URL= CMUX_IROH_MINT_HMAC_SECRET_B64= CMUX_IROH_RATE_LIMIT_ID= diff --git a/web/app/api/devices/iroh/endpoint-attestations/route.ts b/web/app/api/devices/iroh/endpoint-attestations/route.ts new file mode 100644 index 000000000000..281d87768e21 --- /dev/null +++ b/web/app/api/devices/iroh/endpoint-attestations/route.ts @@ -0,0 +1,8 @@ +import { handleIrohRoute } from "../../../../../services/iroh/routeHandler"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST(request: Request): Promise { + return handleIrohRoute(request, "endpoint_attestation"); +} diff --git a/web/app/env.ts b/web/app/env.ts index 305d8ac0608e..3c07c05bc4cf 100644 --- a/web/app/env.ts +++ b/web/app/env.ts @@ -97,12 +97,13 @@ export const env = createEnv({ // Iroh trust broker. The Services API key deliberately has no TypeScript // env entry: only the isolated Rust relay minter may hold it. These values // are server-only and routes fail closed when an operation's key is absent. - CMUX_IROH_LAN_DISCOVERY_SECRET_B64: z.string().regex(/^[A-Za-z0-9+/]{43,}={0,2}$/).optional(), + CMUX_IROH_LAN_DISCOVERY_SECRET_B64: z.string().max(512).regex(/^[A-Za-z0-9+/]{43,}={0,2}$/).optional(), + CMUX_IROH_ACCOUNT_SUBJECT_SECRET_B64: z.string().max(512).regex(/^[A-Za-z0-9+/]{43,}={0,2}$/).optional(), CMUX_IROH_GRANT_SIGNING_KEY_P8: z.string().min(64).max(16_384).optional(), CMUX_IROH_GRANT_SIGNING_KID: z.string().regex(/^[A-Za-z0-9._-]{1,64}$/).optional(), - CMUX_IROH_GRANT_VERIFY_KEYS_JSON: z.string().min(2).max(32_768).optional(), + CMUX_IROH_GRANT_VERIFICATION_KEYS_JSON: z.string().min(2).max(32_768).optional(), CMUX_IROH_MINT_URL: z.string().url().optional(), - CMUX_IROH_MINT_HMAC_SECRET_B64: z.string().regex(/^[A-Za-z0-9+/]{43,}={0,2}$/).optional(), + CMUX_IROH_MINT_HMAC_SECRET_B64: z.string().max(512).regex(/^[A-Za-z0-9+/]{43,}={0,2}$/).optional(), CMUX_IROH_RATE_LIMIT_ID: requireVercelNonPreviewValue("CMUX_IROH_RATE_LIMIT_ID"), CMUX_IROH_DEV_BINDING_OVERRIDE_ENABLED: z.enum(["0", "1"]).optional(), CMUX_IROH_DEV_BINDING_OVERRIDE_USER_IDS: z.string().max(8_192).optional(), @@ -145,9 +146,10 @@ export const env = createEnv({ SUBROUTER_ADMIN_TOKEN: trimEnv(process.env.SUBROUTER_ADMIN_TOKEN), SUBROUTER_TENANT_KEY_SECRET: trimEnv(process.env.SUBROUTER_TENANT_KEY_SECRET), CMUX_IROH_LAN_DISCOVERY_SECRET_B64: trimEnv(process.env.CMUX_IROH_LAN_DISCOVERY_SECRET_B64), + CMUX_IROH_ACCOUNT_SUBJECT_SECRET_B64: trimEnv(process.env.CMUX_IROH_ACCOUNT_SUBJECT_SECRET_B64), CMUX_IROH_GRANT_SIGNING_KEY_P8: trimEnv(process.env.CMUX_IROH_GRANT_SIGNING_KEY_P8), CMUX_IROH_GRANT_SIGNING_KID: trimEnv(process.env.CMUX_IROH_GRANT_SIGNING_KID), - CMUX_IROH_GRANT_VERIFY_KEYS_JSON: trimEnv(process.env.CMUX_IROH_GRANT_VERIFY_KEYS_JSON), + CMUX_IROH_GRANT_VERIFICATION_KEYS_JSON: trimEnv(process.env.CMUX_IROH_GRANT_VERIFICATION_KEYS_JSON), CMUX_IROH_MINT_URL: trimEnv(process.env.CMUX_IROH_MINT_URL), CMUX_IROH_MINT_HMAC_SECRET_B64: trimEnv(process.env.CMUX_IROH_MINT_HMAC_SECRET_B64), CMUX_IROH_RATE_LIMIT_ID: trimEnv(process.env.CMUX_IROH_RATE_LIMIT_ID), diff --git a/web/services/iroh/config.ts b/web/services/iroh/config.ts index 469a8d83c9f9..ec1bad861c98 100644 --- a/web/services/iroh/config.ts +++ b/web/services/iroh/config.ts @@ -4,6 +4,7 @@ import { env } from "../../app/env"; export type IrohTrustBrokerConfigShape = { readonly lanDiscoverySecretBase64?: string; + readonly accountSubjectSecretBase64?: string; readonly grantSigningPrivateKeyPem?: string; readonly grantSigningKid?: string; readonly grantVerificationKeysJson?: string; @@ -24,9 +25,10 @@ export class IrohTrustBrokerConfig extends Context.Tag("cmux/IrohTrustBrokerConf export function irohTrustBrokerConfigFromEnv(): IrohTrustBrokerConfigShape { return { lanDiscoverySecretBase64: env.CMUX_IROH_LAN_DISCOVERY_SECRET_B64, + accountSubjectSecretBase64: env.CMUX_IROH_ACCOUNT_SUBJECT_SECRET_B64, grantSigningPrivateKeyPem: env.CMUX_IROH_GRANT_SIGNING_KEY_P8, grantSigningKid: env.CMUX_IROH_GRANT_SIGNING_KID, - grantVerificationKeysJson: env.CMUX_IROH_GRANT_VERIFY_KEYS_JSON, + grantVerificationKeysJson: env.CMUX_IROH_GRANT_VERIFICATION_KEYS_JSON, relayMinterUrl: env.CMUX_IROH_MINT_URL, relayMinterHmacSecretBase64: env.CMUX_IROH_MINT_HMAC_SECRET_B64, rateLimitId: env.CMUX_IROH_RATE_LIMIT_ID, diff --git a/web/services/iroh/crypto.ts b/web/services/iroh/crypto.ts index bfb21f5d3fc2..bc09d2b53ad6 100644 --- a/web/services/iroh/crypto.ts +++ b/web/services/iroh/crypto.ts @@ -8,6 +8,10 @@ import { type KeyObject, } from "node:crypto"; import { + IROH_ENDPOINT_ATTESTATION_LIFETIME_SECONDS, + IROH_ENDPOINT_ATTESTATION_SCOPE, + IROH_ENDPOINT_ATTESTATION_TYP, + IROH_ENDPOINT_ATTESTATION_VERSION, IROH_ALPN, IROH_PAIR_GRANT_LIFETIME_SECONDS, IROH_PAIR_GRANT_TYP, @@ -44,6 +48,58 @@ export type PairGrantVerificationExpectation = { readonly nowSeconds: number; }; +export type PairGrantVerificationKey = { + readonly kid: string; + readonly alg: "EdDSA"; + readonly spki_der_base64: string; +}; + +export type PairGrantVerificationKeySet = { + readonly version: 1; + readonly current_kid: string; + readonly keys: readonly PairGrantVerificationKey[]; +}; + +export type ParsedPairGrantVerificationKeys = { + readonly keySet: PairGrantVerificationKeySet; + readonly publicKeys: ReadonlyMap; +}; + +export type EndpointAttestationClaims = { + readonly version: typeof IROH_ENDPOINT_ATTESTATION_VERSION; + readonly jti: string; + readonly sub: string; + readonly bindingId: string; + readonly deviceId: string; + readonly endpointId: string; + readonly identityGeneration: number; + readonly platform: "mac" | "ios"; + readonly iat: number; + readonly nbf: number; + readonly exp: number; + readonly alpn: typeof IROH_ALPN; + readonly scope: typeof IROH_ENDPOINT_ATTESTATION_SCOPE; +}; + +export type EndpointAttestationExpectation = { + readonly bindingId: string; + readonly deviceId: string; + readonly endpointId: string; + readonly identityGeneration: number; + readonly platform: "mac" | "ios"; + readonly nowSeconds: number; +}; + +export type OfflinePairVerificationExpectation = { + readonly initiator: Omit & { + readonly platform: "ios"; + }; + readonly acceptor: Omit & { + readonly platform: "mac"; + }; + readonly nowSeconds: number; +}; + export function registrationTranscript(input: { readonly challengeId: string; readonly nonce: string; @@ -99,21 +155,27 @@ export function deriveLanRendezvousKey( .digest("base64url"); } +export function deriveAccountSubject( + secretBase64: string | undefined, + userId: string, +): string { + const secret = decodeSecret(secretBase64, "account_subject"); + if (!userId || userId.length > 1_024) { + throw new IrohConfigurationError({ component: "account_subject" }); + } + return createHmac("sha256", secret) + .update("cmux/iroh/account-subject/v1\0", "utf8") + .update(userId, "utf8") + .digest("base64url"); +} + export function signPairGrant(input: { readonly privateKeyPem: string | undefined; readonly kid: string | undefined; readonly claims: PairGrantClaims; }): string { const kid = validKid(input.kid, "grant_signing"); - let privateKey: KeyObject; - try { - privateKey = createPrivateKey(normalizePem(input.privateKeyPem, "grant_signing")); - } catch { - throw new IrohConfigurationError({ component: "grant_signing" }); - } - if (privateKey.asymmetricKeyType !== "ed25519") { - throw new IrohConfigurationError({ component: "grant_signing" }); - } + const privateKey = signingPrivateKey(input.privateKeyPem); validatePairGrantClaims(input.claims, { nowSeconds: input.claims.iat }); const encodedHeader = encodeJson({ alg: "EdDSA", typ: IROH_PAIR_GRANT_TYP, kid }); const encodedClaims = encodeJson(input.claims); @@ -122,6 +184,32 @@ export function signPairGrant(input: { return `${signingInput}.${signature}`; } +export function signEndpointAttestation(input: { + readonly privateKeyPem: string | undefined; + readonly kid: string | undefined; + readonly claims: EndpointAttestationClaims; +}): string { + const kid = validKid(input.kid, "grant_signing"); + const privateKey = signingPrivateKey(input.privateKeyPem); + validateEndpointAttestationClaims(input.claims, { + bindingId: input.claims.bindingId, + deviceId: input.claims.deviceId, + endpointId: input.claims.endpointId, + identityGeneration: input.claims.identityGeneration, + platform: input.claims.platform, + nowSeconds: input.claims.iat, + }); + const encodedHeader = encodeJson({ + alg: "EdDSA", + typ: IROH_ENDPOINT_ATTESTATION_TYP, + kid, + }); + const encodedClaims = encodeJson(input.claims); + const signingInput = `${encodedHeader}.${encodedClaims}`; + const signature = sign(null, Buffer.from(signingInput, "ascii"), privateKey).toString("base64url"); + return `${signingInput}.${signature}`; +} + export function verifyPairGrant( token: string, publicKeys: ReadonlyMap, @@ -130,36 +218,103 @@ export function verifyPairGrant( if (token.length > 16_384) throw new IrohInvalidInputError({ code: "invalid_pair_grant" }); const parts = token.split("."); if (parts.length !== 3) throw new IrohInvalidInputError({ code: "invalid_pair_grant" }); - const header = decodeJson(parts[0]); + const header = decodeJson(parts[0], "invalid_pair_grant"); assertExactKeys(header, ["alg", "typ", "kid"], "invalid_pair_grant_header"); if (header.alg !== "EdDSA" || header.typ !== IROH_PAIR_GRANT_TYP || typeof header.kid !== "string") { throw new IrohForbiddenError({ code: "invalid_pair_grant_header" }); } - const keyPem = publicKeys.get(header.kid); - if (!keyPem) throw new IrohForbiddenError({ code: "unknown_pair_grant_kid" }); - let publicKey: KeyObject; - try { - publicKey = createPublicKey(normalizePem(keyPem, "grant_verification")); - } catch { - throw new IrohConfigurationError({ component: "grant_verification" }); - } - if (publicKey.asymmetricKeyType !== "ed25519") { - throw new IrohConfigurationError({ component: "grant_verification" }); - } + const keyDerBase64 = publicKeys.get(header.kid); + if (!keyDerBase64) throw new IrohForbiddenError({ code: "unknown_pair_grant_kid" }); + const publicKey = verificationPublicKey(keyDerBase64); const valid = verify( null, Buffer.from(`${parts[0]}.${parts[1]}`, "ascii"), publicKey, - decodeCanonicalBase64url(parts[2], 64), + decodeCanonicalBase64url(parts[2], 64, "invalid_pair_grant"), ); if (!valid) throw new IrohForbiddenError({ code: "invalid_pair_grant_signature" }); - const claims = decodeJson(parts[1]) as unknown as PairGrantClaims; + const claims = decodeJson(parts[1], "invalid_pair_grant") as unknown as PairGrantClaims; validatePairGrantClaims(claims, expected); return claims; } -export function parseVerificationKeys(value: string | undefined): ReadonlyMap { - if (!value) return new Map(); +export function verifyEndpointAttestation( + token: string, + publicKeys: ReadonlyMap, + expected: EndpointAttestationExpectation, +): EndpointAttestationClaims { + if (token.length > 16_384) { + throw new IrohInvalidInputError({ code: "invalid_endpoint_attestation" }); + } + const parts = token.split("."); + if (parts.length !== 3) { + throw new IrohInvalidInputError({ code: "invalid_endpoint_attestation" }); + } + const header = decodeJson(parts[0], "invalid_endpoint_attestation"); + assertExactKeys(header, ["alg", "typ", "kid"], "invalid_endpoint_attestation_header"); + if ( + header.alg !== "EdDSA" || + header.typ !== IROH_ENDPOINT_ATTESTATION_TYP || + typeof header.kid !== "string" + ) { + throw new IrohForbiddenError({ code: "invalid_endpoint_attestation_header" }); + } + const keyDerBase64 = publicKeys.get(header.kid); + if (!keyDerBase64) { + throw new IrohForbiddenError({ code: "unknown_endpoint_attestation_kid" }); + } + const publicKey = verificationPublicKey(keyDerBase64); + const valid = verify( + null, + Buffer.from(`${parts[0]}.${parts[1]}`, "ascii"), + publicKey, + decodeCanonicalBase64url(parts[2], 64, "invalid_endpoint_attestation"), + ); + if (!valid) { + throw new IrohForbiddenError({ code: "invalid_endpoint_attestation_signature" }); + } + const claims = decodeJson( + parts[1], + "invalid_endpoint_attestation", + ) as unknown as EndpointAttestationClaims; + validateEndpointAttestationClaims(claims, expected); + return claims; +} + +export function verifyOfflineSameAccountPair(input: { + readonly initiatorAttestation: string; + readonly acceptorAttestation: string; + readonly publicKeys: ReadonlyMap; + readonly expected: OfflinePairVerificationExpectation; +}): { + readonly initiator: EndpointAttestationClaims; + readonly acceptor: EndpointAttestationClaims; +} { + const initiator = verifyEndpointAttestation(input.initiatorAttestation, input.publicKeys, { + ...input.expected.initiator, + nowSeconds: input.expected.nowSeconds, + }); + const acceptor = verifyEndpointAttestation(input.acceptorAttestation, input.publicKeys, { + ...input.expected.acceptor, + nowSeconds: input.expected.nowSeconds, + }); + if ( + initiator.bindingId === acceptor.bindingId || + initiator.deviceId === acceptor.deviceId || + initiator.endpointId === acceptor.endpointId || + !canonicalSubjectsEqual(initiator.sub, acceptor.sub) + ) { + throw new IrohForbiddenError({ code: "offline_pair_same_account_proof_required" }); + } + return { initiator, acceptor }; +} + +export function parseVerificationKeys( + value: string | undefined, +): ParsedPairGrantVerificationKeys { + if (!value || value.length > 32_768) { + throw new IrohConfigurationError({ component: "grant_verification" }); + } let parsed: unknown; try { parsed = JSON.parse(value); @@ -169,14 +324,78 @@ export function parseVerificationKeys(value: string | undefined): ReadonlyMap); - if (entries.length < 1 || entries.length > 2) { + const object = parsed as Record; + if (!hasExactKeys(object, ["version", "current_kid", "keys"])) { + throw new IrohConfigurationError({ component: "grant_verification" }); + } + if (object.version !== 1 || !Array.isArray(object.keys)) { + throw new IrohConfigurationError({ component: "grant_verification" }); + } + const currentKid = validKid( + typeof object.current_kid === "string" ? object.current_kid : undefined, + "grant_verification", + ); + if (object.keys.length < 1 || object.keys.length > 2) { + throw new IrohConfigurationError({ component: "grant_verification" }); + } + + const seen = new Set(); + const keys = object.keys.map((raw): PairGrantVerificationKey => { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw new IrohConfigurationError({ component: "grant_verification" }); + } + const key = raw as Record; + if (!hasExactKeys(key, ["kid", "alg", "spki_der_base64"]) || key.alg !== "EdDSA") { + throw new IrohConfigurationError({ component: "grant_verification" }); + } + const kid = validKid(typeof key.kid === "string" ? key.kid : undefined, "grant_verification"); + if (seen.has(kid)) { + throw new IrohConfigurationError({ component: "grant_verification" }); + } + seen.add(kid); + const spkiDerBase64 = typeof key.spki_der_base64 === "string" + ? canonicalVerificationKey(key.spki_der_base64) + : configurationFailure(); + return { kid, alg: "EdDSA", spki_der_base64: spkiDerBase64 }; + }); + if (!seen.has(currentKid)) { throw new IrohConfigurationError({ component: "grant_verification" }); } - return new Map(entries.map(([kid, pem]) => [ - validKid(kid, "grant_verification"), - normalizePem(typeof pem === "string" ? pem : undefined, "grant_verification"), - ])); + keys.sort((left, right) => { + if (left.kid === currentKid) return -1; + if (right.kid === currentKid) return 1; + return left.kid.localeCompare(right.kid); + }); + return { + keySet: { version: 1, current_kid: currentKid, keys }, + publicKeys: new Map(keys.map((key) => [key.kid, key.spki_der_base64])), + }; +} + +export function assertCurrentSigningKey(input: { + readonly privateKeyPem: string | undefined; + readonly kid: string | undefined; + readonly verificationKeys: ParsedPairGrantVerificationKeys; +}): void { + const kid = validKid(input.kid, "grant_signing"); + if (kid !== input.verificationKeys.keySet.current_kid) { + throw new IrohConfigurationError({ component: "grant_signing" }); + } + const expectedDerBase64 = input.verificationKeys.publicKeys.get(kid); + if (!expectedDerBase64) { + throw new IrohConfigurationError({ component: "grant_signing" }); + } + const privateKey = signingPrivateKey(input.privateKeyPem); + let actualDer: Buffer; + try { + actualDer = createPublicKey(privateKey).export({ format: "der", type: "spki" }); + } catch { + throw new IrohConfigurationError({ component: "grant_signing" }); + } + const expectedDer = Buffer.from(expectedDerBase64, "base64"); + if (actualDer.byteLength !== expectedDer.byteLength || !timingSafeEqual(actualDer, expectedDer)) { + throw new IrohConfigurationError({ component: "grant_signing" }); + } } function validatePairGrantClaims( @@ -221,6 +440,66 @@ function validatePairGrantClaims( } } +function validateEndpointAttestationClaims( + value: EndpointAttestationClaims, + expected: EndpointAttestationExpectation, +): void { + if (!value || typeof value !== "object") { + throw new IrohForbiddenError({ code: "invalid_endpoint_attestation_claims" }); + } + assertExactKeys(value as unknown as Record, [ + "version", + "jti", + "sub", + "bindingId", + "deviceId", + "endpointId", + "identityGeneration", + "platform", + "iat", + "nbf", + "exp", + "alpn", + "scope", + ], "invalid_endpoint_attestation_claims"); + if ( + value.version !== IROH_ENDPOINT_ATTESTATION_VERSION || + typeof value.jti !== "string" || !UUID_PATTERN.test(value.jti) || + typeof value.bindingId !== "string" || !UUID_PATTERN.test(value.bindingId) || + typeof value.deviceId !== "string" || !UUID_PATTERN.test(value.deviceId) || + !Number.isSafeInteger(value.identityGeneration) || value.identityGeneration < 1 || + (value.platform !== "mac" && value.platform !== "ios") || + value.alpn !== IROH_ALPN || + value.scope !== IROH_ENDPOINT_ATTESTATION_SCOPE + ) { + throw new IrohForbiddenError({ code: "invalid_endpoint_attestation_claims" }); + } + endpointId(value.endpointId); + decodeCanonicalBase64url(value.sub, 32, "invalid_endpoint_attestation_claims"); + if ( + !Number.isSafeInteger(value.iat) || + !Number.isSafeInteger(value.nbf) || + !Number.isSafeInteger(value.exp) || + value.nbf < value.iat - 30 || + value.nbf > expected.nowSeconds + 30 || + value.exp <= expected.nowSeconds || + value.exp <= value.nbf || + value.exp - value.iat > IROH_ENDPOINT_ATTESTATION_LIFETIME_SECONDS || + value.iat > expected.nowSeconds + 30 + ) { + throw new IrohForbiddenError({ code: "invalid_endpoint_attestation_expiry" }); + } + if ( + value.bindingId !== expected.bindingId || + value.deviceId !== expected.deviceId || + value.endpointId !== expected.endpointId || + value.identityGeneration !== expected.identityGeneration || + value.platform !== expected.platform + ) { + throw new IrohForbiddenError({ code: "endpoint_attestation_identity_mismatch" }); + } +} + function validatePeer(peer: PairGrantPeer, side: string): void { if ( !peer || typeof peer !== "object" || @@ -258,10 +537,17 @@ function endpointPublicKey(value: string): KeyObject { }); } -function decodeSecret(value: string | undefined, component: "lan_discovery"): Buffer { - if (!value) throw new IrohConfigurationError({ component }); +function decodeSecret( + value: string | undefined, + component: "lan_discovery" | "account_subject", +): Buffer { + if (!value || value.length > 512) throw new IrohConfigurationError({ component }); const decoded = Buffer.from(value, "base64"); - if (decoded.byteLength < 32 || decoded.toString("base64").replace(/=+$/, "") !== value.replace(/=+$/, "")) { + if ( + decoded.byteLength < 32 || + decoded.byteLength > 256 || + decoded.toString("base64").replace(/=+$/, "") !== value.replace(/=+$/, "") + ) { throw new IrohConfigurationError({ component }); } return decoded; @@ -277,10 +563,7 @@ function validKid( return value; } -function normalizePem( - value: string | undefined, - component: "grant_signing" | "grant_verification", -): string { +function normalizePem(value: string | undefined, component: "grant_signing"): string { if (!value) throw new IrohConfigurationError({ component }); const normalized = value.replaceAll("\\n", "\n").trim(); if (normalized.length > 16_384 || !normalized.includes("-----BEGIN")) { @@ -293,30 +576,102 @@ function encodeJson(value: unknown): string { return Buffer.from(JSON.stringify(value), "utf8").toString("base64url"); } -function decodeJson(encoded: string): Record { +function decodeJson(encoded: string, code: string): Record { try { - const value = JSON.parse(decodeCanonicalBase64url(encoded).toString("utf8")); + const value = JSON.parse(decodeCanonicalBase64url(encoded, undefined, code).toString("utf8")); if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("not object"); return value as Record; } catch { - throw new IrohInvalidInputError({ code: "invalid_pair_grant" }); + throw new IrohInvalidInputError({ code }); } } -function decodeCanonicalBase64url(encoded: string, expectedLength?: number): Buffer { +function decodeCanonicalBase64url( + encoded: string, + expectedLength: number | undefined, + code: string, +): Buffer { if (!encoded || !/^[A-Za-z0-9_-]+$/.test(encoded)) { - throw new IrohInvalidInputError({ code: "invalid_pair_grant" }); + throw new IrohInvalidInputError({ code }); } const decoded = Buffer.from(encoded, "base64url"); if ( decoded.toString("base64url") !== encoded || (expectedLength !== undefined && decoded.byteLength !== expectedLength) ) { - throw new IrohInvalidInputError({ code: "invalid_pair_grant" }); + throw new IrohInvalidInputError({ code }); } return decoded; } +function signingPrivateKey(value: string | undefined): KeyObject { + let privateKey: KeyObject; + try { + privateKey = createPrivateKey(normalizePem(value, "grant_signing")); + } catch { + throw new IrohConfigurationError({ component: "grant_signing" }); + } + if (privateKey.asymmetricKeyType !== "ed25519") { + throw new IrohConfigurationError({ component: "grant_signing" }); + } + return privateKey; +} + +function verificationPublicKey(value: string): KeyObject { + const canonical = canonicalVerificationKey(value); + try { + const publicKey = createPublicKey({ + key: Buffer.from(canonical, "base64"), + format: "der", + type: "spki", + }); + if (publicKey.asymmetricKeyType !== "ed25519") throw new Error("wrong key type"); + return publicKey; + } catch { + throw new IrohConfigurationError({ component: "grant_verification" }); + } +} + +function canonicalVerificationKey(value: string): string { + if (!/^[A-Za-z0-9+/]+={0,2}$/.test(value)) { + throw new IrohConfigurationError({ component: "grant_verification" }); + } + const der = Buffer.from(value, "base64"); + if ( + der.byteLength !== ED25519_SPKI_PREFIX.byteLength + 32 || + der.toString("base64") !== value || + !der.subarray(0, ED25519_SPKI_PREFIX.byteLength).equals(ED25519_SPKI_PREFIX) + ) { + throw new IrohConfigurationError({ component: "grant_verification" }); + } + verificationPublicKeyShape(der); + return value; +} + +function verificationPublicKeyShape(der: Buffer): void { + try { + const key = createPublicKey({ key: der, format: "der", type: "spki" }); + if (key.asymmetricKeyType !== "ed25519") throw new Error("wrong key type"); + } catch { + throw new IrohConfigurationError({ component: "grant_verification" }); + } +} + +function configurationFailure(): never { + throw new IrohConfigurationError({ component: "grant_verification" }); +} + +function hasExactKeys(value: Record, allowed: readonly string[]): boolean { + const keys = Object.keys(value); + return keys.length === allowed.length && keys.every((key) => allowed.includes(key)); +} + +function canonicalSubjectsEqual(left: string, right: string): boolean { + const leftBytes = decodeCanonicalBase64url(left, 32, "invalid_endpoint_attestation"); + const rightBytes = decodeCanonicalBase64url(right, 32, "invalid_endpoint_attestation"); + return timingSafeEqual(leftBytes, rightBytes); +} + const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; function assertExactKeys( diff --git a/web/services/iroh/errors.ts b/web/services/iroh/errors.ts index d40a5085fa9f..8691bcf9dfbf 100644 --- a/web/services/iroh/errors.ts +++ b/web/services/iroh/errors.ts @@ -22,7 +22,12 @@ export class IrohQuotaExceededError extends Data.TaggedError("IrohQuotaExceededE }> {} export class IrohConfigurationError extends Data.TaggedError("IrohConfigurationError")<{ - readonly component: "grant_signing" | "grant_verification" | "lan_discovery" | "relay_minter"; + readonly component: + | "grant_signing" + | "grant_verification" + | "account_subject" + | "lan_discovery" + | "relay_minter"; }> {} export class IrohDatabaseError extends Data.TaggedError("IrohDatabaseError")<{ diff --git a/web/services/iroh/model.ts b/web/services/iroh/model.ts index 50a85bfb58a3..6eabddc1a62d 100644 --- a/web/services/iroh/model.ts +++ b/web/services/iroh/model.ts @@ -5,8 +5,12 @@ import { IrohInvalidInputError } from "./errors"; export const IROH_ALPN = "cmux/mobile/1"; export const IROH_PAIR_SCOPE = "cmux.mobile.attach"; export const IROH_PAIR_GRANT_TYP = "cmux-pair-grant+jwt"; +export const IROH_ENDPOINT_ATTESTATION_VERSION = 1; +export const IROH_ENDPOINT_ATTESTATION_TYP = "cmux-endpoint-attestation-v1+jwt"; +export const IROH_ENDPOINT_ATTESTATION_SCOPE = "cmux.offline-pair.same-account"; export const IROH_CHALLENGE_LIFETIME_MS = 5 * 60 * 1_000; export const IROH_PAIR_GRANT_LIFETIME_SECONDS = 7 * 24 * 60 * 60; +export const IROH_ENDPOINT_ATTESTATION_LIFETIME_SECONDS = 24 * 60 * 60; export const IROH_RELAY_TOKEN_LIFETIME_SECONDS = 24 * 60 * 60; export const IROH_RELAY_TOKEN_REFRESH_SECONDS = 12 * 60 * 60; export const IROH_ROUTE_CONTRACT_VERSION = 1; diff --git a/web/services/iroh/relayMinter.ts b/web/services/iroh/relayMinter.ts index 40662e0c722c..6e15b1833127 100644 --- a/web/services/iroh/relayMinter.ts +++ b/web/services/iroh/relayMinter.ts @@ -7,6 +7,7 @@ import { IrohTrustBrokerConfig } from "./config"; import { IROH_RELAY_TOKEN_LIFETIME_SECONDS, endpointId } from "./model"; const MAX_MINTER_RESPONSE_BYTES = 32 * 1_024; +export const IROH_RELAY_MINTER_PATH = "/api/relay-token"; export type IrohRelayMintResult = { readonly token: string; @@ -43,7 +44,7 @@ function mintWithIsolatedService( return Effect.tryPromise({ try: async () => { endpointId(input.endpointId); - const url = configuredUrl(config.relayMinterUrl); + const url = parseMinterUrl(config.relayMinterUrl); const secret = parseMinterHmacSecret(config.relayMinterHmacSecretBase64); const body = JSON.stringify({ endpointId: input.endpointId, @@ -67,7 +68,12 @@ function mintWithIsolatedService( }); if (!response.ok) throw new IrohRelayMintError({ code: "minter_rejected" }); const raw = await readBoundedMinterJson(response); - if (typeof raw.token !== "string" || raw.token.length < 16 || raw.token.length > 16_384) { + if ( + typeof raw.token !== "string" || + raw.token.length < 16 || + raw.token.length > 16_384 || + !/^[a-z2-7]+$/.test(raw.token) + ) { throw new IrohRelayMintError({ code: "invalid_minter_response" }); } if (typeof raw.expiresAt !== "string") throw new IrohRelayMintError({ code: "invalid_minter_response" }); @@ -98,6 +104,12 @@ function mintWithIsolatedService( export async function readBoundedMinterJson( response: Response, ): Promise<{ token?: unknown; expiresAt?: unknown }> { + if ( + response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() !== + "application/json" + ) { + throw new IrohRelayMintError({ code: "invalid_minter_response" }); + } const contentLength = response.headers.get("content-length"); if (contentLength) { const parsed = Number(contentLength); @@ -129,24 +141,40 @@ export async function readBoundedMinterJson( if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { throw new IrohRelayMintError({ code: "invalid_minter_response" }); } - return parsed as { token?: unknown; expiresAt?: unknown }; + const object = parsed as Record; + const keys = Object.keys(object); + if (keys.length !== 2 || !keys.includes("token") || !keys.includes("expiresAt")) { + throw new IrohRelayMintError({ code: "invalid_minter_response" }); + } + return object; } -function configuredUrl(value: string | undefined): URL { +export function parseMinterUrl(value: string | undefined): URL { if (!value) throw new IrohConfigurationError({ component: "relay_minter" }); const url = new URL(value); - if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash) { + if ( + url.protocol !== "https:" || + url.username || + url.password || + url.pathname !== IROH_RELAY_MINTER_PATH || + url.search || + url.hash + ) { throw new IrohConfigurationError({ component: "relay_minter" }); } return url; } export function parseMinterHmacSecret(value: string | undefined): Buffer { - if (!value) throw new IrohConfigurationError({ component: "relay_minter" }); + if (!value || value.length > 512) throw new IrohConfigurationError({ component: "relay_minter" }); const decoded = Buffer.from(value, "base64"); - const canonicalInput = value.replace(/=+$/, ""); - const canonicalDecoded = decoded.toString("base64").replace(/=+$/, ""); - if (decoded.byteLength < 32 || canonicalInput !== canonicalDecoded) { + const canonicalPadded = decoded.toString("base64"); + const canonicalUnpadded = canonicalPadded.replace(/=+$/, ""); + if ( + decoded.byteLength < 32 || + decoded.byteLength > 256 || + (value !== canonicalPadded && value !== canonicalUnpadded) + ) { throw new IrohConfigurationError({ component: "relay_minter" }); } return decoded; diff --git a/web/services/iroh/routeHandler.ts b/web/services/iroh/routeHandler.ts index c8060168c590..17e600c94475 100644 --- a/web/services/iroh/routeHandler.ts +++ b/web/services/iroh/routeHandler.ts @@ -18,6 +18,7 @@ export type IrohRouteOperation = | "challenge" | "register" | "discover" + | "endpoint_attestation" | "revoke" | "pair_grant" | "relay_token"; @@ -98,6 +99,7 @@ function invoke( case "challenge": return broker.issueChallenge(userId, body); case "register": return broker.register(userId, body); case "discover": return broker.discover(userId); + case "endpoint_attestation": return broker.issueEndpointAttestation(userId, body); case "revoke": return broker.revoke(userId, body); case "pair_grant": return broker.issuePairGrant(userId, body); case "relay_token": return broker.issueRelayToken(userId, body); diff --git a/web/services/iroh/trustBroker.ts b/web/services/iroh/trustBroker.ts index f292c453a837..8ba47394c8a0 100644 --- a/web/services/iroh/trustBroker.ts +++ b/web/services/iroh/trustBroker.ts @@ -3,13 +3,18 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { + assertCurrentSigningKey, + deriveAccountSubject, deriveLanRendezvousKey, hashesEqual, nonceHash, parseVerificationKeys, + signEndpointAttestation, signPairGrant, + verifyEndpointAttestation, verifyEndpointRegistrationSignature, verifyPairGrant, + type EndpointAttestationClaims, type PairGrantClaims, type PairGrantPeer, } from "./crypto"; @@ -30,6 +35,9 @@ import { import { IROH_ALPN, IROH_CHALLENGE_LIFETIME_MS, + IROH_ENDPOINT_ATTESTATION_LIFETIME_SECONDS, + IROH_ENDPOINT_ATTESTATION_SCOPE, + IROH_ENDPOINT_ATTESTATION_VERSION, IROH_PAIR_GRANT_LIFETIME_SECONDS, IROH_PAIR_SCOPE, IROH_RELAY_TOKEN_LIFETIME_SECONDS, @@ -82,6 +90,11 @@ export type IrohTrustBrokerShape = { raw: unknown, now?: Date, ) => Effect.Effect; + readonly issueEndpointAttestation: ( + userId: string, + raw: unknown, + now?: Date, + ) => Effect.Effect; readonly issueRelayToken: ( userId: string, raw: unknown, @@ -207,11 +220,13 @@ export function makeIrohTrustBroker( userId, generation, )); + const verificationKeys = yield* parseEffect(() => signingVerificationKeys(config)); return { route_contract_version: 1, bindings: bindings.map((binding) => publicBinding(binding, now)), relay_fleet: MANAGED_RELAY_URLS, lan_rendezvous: { generation, key: rendezvousKey }, + grant_verification_keys: verificationKeys.keySet, }; }), @@ -253,13 +268,13 @@ export function makeIrohTrustBroker( } return config.grantSigningKid; }); - const verificationKeys = yield* parseEffect(() => parseVerificationKeys(config.grantVerificationKeysJson)); + const verificationKeys = yield* parseEffect(() => signingVerificationKeys(config)); const token = yield* parseEffect(() => signPairGrant({ privateKeyPem: config.grantSigningPrivateKeyPem, kid: signingKeyId, claims, })); - yield* parseEffect(() => verifyPairGrant(token, verificationKeys, { + yield* parseEffect(() => verifyPairGrant(token, verificationKeys.publicKeys, { initiator: claims.initiator, acceptor: claims.acceptor, nowSeconds: issuedAtSeconds, @@ -279,6 +294,55 @@ export function makeIrohTrustBroker( return { grant: token, expires_at: new Date(claims.exp * 1_000).toISOString() }; }), + issueEndpointAttestation: (userId, raw, now = new Date()) => Effect.gen(function* () { + const { bindingId } = yield* parseEffect(() => parseBindingIdBody(raw)); + const bindings = yield* repository.findActiveBindings(userId, [bindingId]); + const binding = bindings.length === 1 ? bindings[0] : undefined; + if (!binding) return yield* Effect.fail(new IrohNotFoundError({ resource: "binding" })); + + const verificationKeys = yield* parseEffect(() => signingVerificationKeys(config)); + const issuedAtSeconds = Math.floor(now.getTime() / 1_000); + const platform = yield* parseEffect(() => bindingPlatform(binding)); + const claims: EndpointAttestationClaims = { + version: IROH_ENDPOINT_ATTESTATION_VERSION, + jti: randomUUID(), + sub: yield* parseEffect(() => deriveAccountSubject(config.accountSubjectSecretBase64, userId)), + bindingId: binding.id, + deviceId: binding.deviceUuid, + endpointId: binding.endpointId, + identityGeneration: binding.identityGeneration, + platform, + iat: issuedAtSeconds, + nbf: issuedAtSeconds - 5, + exp: issuedAtSeconds + IROH_ENDPOINT_ATTESTATION_LIFETIME_SECONDS, + alpn: IROH_ALPN, + scope: IROH_ENDPOINT_ATTESTATION_SCOPE, + }; + const attestation = yield* parseEffect(() => signEndpointAttestation({ + privateKeyPem: config.grantSigningPrivateKeyPem, + kid: config.grantSigningKid, + claims, + })); + yield* parseEffect(() => verifyEndpointAttestation( + attestation, + verificationKeys.publicKeys, + { + bindingId: binding.id, + deviceId: binding.deviceUuid, + endpointId: binding.endpointId, + identityGeneration: binding.identityGeneration, + platform, + nowSeconds: issuedAtSeconds, + }, + )); + return { + attestation_version: IROH_ENDPOINT_ATTESTATION_VERSION, + attestation, + expires_at: new Date(claims.exp * 1_000).toISOString(), + grant_verification_keys: verificationKeys.keySet, + }; + }), + issueRelayToken, }; } @@ -350,6 +414,23 @@ function grantPeer(binding: IrohBindingRecord): PairGrantPeer { }; } +function signingVerificationKeys(config: IrohTrustBrokerConfigShape) { + const verificationKeys = parseVerificationKeys(config.grantVerificationKeysJson); + assertCurrentSigningKey({ + privateKeyPem: config.grantSigningPrivateKeyPem, + kid: config.grantSigningKid, + verificationKeys, + }); + return verificationKeys; +} + +function bindingPlatform(binding: IrohBindingRecord): "mac" | "ios" { + if (binding.platform !== "mac" && binding.platform !== "ios") { + throw new IrohConfigurationError({ component: "grant_signing" }); + } + return binding.platform; +} + // Stack bearer authentication alone is never sufficient to mutate path hints. // Until the dedicated endpoint-signed monotonic update route lands, clients // refresh watch_addr output only through a new signed registration challenge. diff --git a/web/tests/iroh-model-crypto.test.ts b/web/tests/iroh-model-crypto.test.ts index 2238d011d5dd..f1a50e13f036 100644 --- a/web/tests/iroh-model-crypto.test.ts +++ b/web/tests/iroh-model-crypto.test.ts @@ -1,20 +1,33 @@ import { describe, expect, test } from "bun:test"; -import { generateKeyPairSync, sign } from "node:crypto"; +import { createHash, createHmac, generateKeyPairSync, sign } from "node:crypto"; import { readFileSync } from "node:fs"; import { + assertCurrentSigningKey, + deriveAccountSubject, + parseVerificationKeys, + signEndpointAttestation, signPairGrant, + verifyEndpointAttestation, + verifyOfflineSameAccountPair, verifyPairGrant, + type EndpointAttestationClaims, type PairGrantClaims, type PairGrantPeer, } from "../services/iroh/crypto"; import { IROH_ALPN, + IROH_ENDPOINT_ATTESTATION_SCOPE, + IROH_ENDPOINT_ATTESTATION_VERSION, IROH_PAIR_GRANT_TYP, IROH_PAIR_SCOPE, MANAGED_RELAY_URLS, parseRegistrationPayload, } from "../services/iroh/model"; -import { parseMinterHmacSecret, readBoundedMinterJson } from "../services/iroh/relayMinter"; +import { + parseMinterHmacSecret, + parseMinterUrl, + readBoundedMinterJson, +} from "../services/iroh/relayMinter"; const NOW = new Date("2026-07-09T20:00:00.000Z"); @@ -136,8 +149,9 @@ describe("Iroh pair-grant verification", () => { const current = generateKeyPairSync("ed25519"); const previous = generateKeyPairSync("ed25519"); const currentPrivate = current.privateKey.export({ format: "pem", type: "pkcs8" }).toString(); - const currentPublic = current.publicKey.export({ format: "pem", type: "spki" }).toString(); - const previousPublic = previous.publicKey.export({ format: "pem", type: "spki" }).toString(); + const previousPrivate = previous.privateKey.export({ format: "pem", type: "pkcs8" }).toString(); + const currentPublic = current.publicKey.export({ format: "der", type: "spki" }).toString("base64"); + const previousPublic = previous.publicKey.export({ format: "der", type: "spki" }).toString("base64"); const keys = new Map([ ["current", currentPublic], ["previous", previousPublic], @@ -170,6 +184,12 @@ describe("Iroh pair-grant verification", () => { test("accepts the current key while retaining a previous verification key", () => { const token = signPairGrant({ privateKeyPem: currentPrivate, kid: "current", claims }); expect(verifyPairGrant(token, keys, { initiator, acceptor, nowSeconds: claims.iat }).jti).toBe(claims.jti); + const previousToken = signPairGrant({ privateKeyPem: previousPrivate, kid: "previous", claims }); + expect(verifyPairGrant(previousToken, keys, { + initiator, + acceptor, + nowSeconds: claims.iat, + }).jti).toBe(claims.jti); }); test("rejects peer and identity-generation substitution", () => { @@ -217,22 +237,218 @@ describe("Iroh pair-grant verification", () => { } }); +describe("Iroh grant verification keys and offline endpoint attestations", () => { + const current = generateKeyPairSync("ed25519"); + const previous = generateKeyPairSync("ed25519"); + const currentPrivate = current.privateKey.export({ format: "pem", type: "pkcs8" }).toString(); + const currentPublic = current.publicKey.export({ format: "der", type: "spki" }).toString("base64"); + const previousPublic = previous.publicKey.export({ format: "der", type: "spki" }).toString("base64"); + const parsedKeys = parseVerificationKeys(JSON.stringify({ + version: 1, + current_kid: "current", + keys: [ + { kid: "previous", alg: "EdDSA", spki_der_base64: previousPublic }, + { kid: "current", alg: "EdDSA", spki_der_base64: currentPublic }, + ], + })); + const nowSeconds = 1_783_627_200; + const subject = deriveAccountSubject(Buffer.alloc(32, 0x51).toString("base64"), "private-user-id"); + const initiator: EndpointAttestationClaims = { + version: IROH_ENDPOINT_ATTESTATION_VERSION, + jti: "40000000-0000-4000-8000-000000000011", + sub: subject, + bindingId: "30000000-0000-4000-8000-000000000011", + deviceId: "10000000-0000-4000-8000-000000000011", + endpointId: "44".repeat(32), + identityGeneration: 2, + platform: "ios", + iat: nowSeconds, + nbf: nowSeconds - 5, + exp: nowSeconds + 86_400, + alpn: IROH_ALPN, + scope: IROH_ENDPOINT_ATTESTATION_SCOPE, + }; + const acceptor: EndpointAttestationClaims = { + ...initiator, + jti: "40000000-0000-4000-8000-000000000012", + bindingId: "30000000-0000-4000-8000-000000000012", + deviceId: "10000000-0000-4000-8000-000000000012", + endpointId: "55".repeat(32), + identityGeneration: 3, + platform: "mac", + }; + + test("publishes only canonical current and previous public keys and binds the signer", () => { + expect(parsedKeys.keySet).toEqual({ + version: 1, + current_kid: "current", + keys: [ + { kid: "current", alg: "EdDSA", spki_der_base64: currentPublic }, + { kid: "previous", alg: "EdDSA", spki_der_base64: previousPublic }, + ], + }); + expect(JSON.stringify(parsedKeys.keySet)).not.toContain("PRIVATE KEY"); + expect(() => assertCurrentSigningKey({ + privateKeyPem: currentPrivate, + kid: "current", + verificationKeys: parsedKeys, + })).not.toThrow(); + expect(() => assertCurrentSigningKey({ + privateKeyPem: previous.privateKey.export({ format: "pem", type: "pkcs8" }).toString(), + kid: "current", + verificationKeys: parsedKeys, + })).toThrow(); + }); + + test("requires two fresh endpoint-bound attestations with the same opaque account subject", () => { + const initiatorToken = signEndpointAttestation({ + privateKeyPem: currentPrivate, + kid: "current", + claims: initiator, + }); + const acceptorToken = signEndpointAttestation({ + privateKeyPem: currentPrivate, + kid: "current", + claims: acceptor, + }); + const expected = { + initiator: { ...endpointExpectation(initiator), platform: "ios" as const }, + acceptor: { ...endpointExpectation(acceptor), platform: "mac" as const }, + nowSeconds, + } as const; + + expect(verifyOfflineSameAccountPair({ + initiatorAttestation: initiatorToken, + acceptorAttestation: acceptorToken, + publicKeys: parsedKeys.publicKeys, + expected, + }).acceptor.endpointId).toBe(acceptor.endpointId); + expect(() => verifyOfflineSameAccountPair({ + initiatorAttestation: "", + acceptorAttestation: acceptorToken, + publicKeys: parsedKeys.publicKeys, + expected, + })).toThrow(); + + const otherAccountToken = signEndpointAttestation({ + privateKeyPem: currentPrivate, + kid: "current", + claims: { ...acceptor, sub: Buffer.alloc(32, 0x52).toString("base64url") }, + }); + expect(() => verifyOfflineSameAccountPair({ + initiatorAttestation: initiatorToken, + acceptorAttestation: otherAccountToken, + publicKeys: parsedKeys.publicKeys, + expected, + })).toThrow(); + }); + + test("rejects endpoint substitution, expiry, extra identity claims, and noncanonical signatures", () => { + const token = signEndpointAttestation({ + privateKeyPem: currentPrivate, + kid: "current", + claims: initiator, + }); + expect(() => verifyEndpointAttestation(token, parsedKeys.publicKeys, { + ...endpointExpectation(initiator), + endpointId: "66".repeat(32), + nowSeconds, + })).toThrow(); + expect(() => verifyEndpointAttestation(token, parsedKeys.publicKeys, { + ...endpointExpectation(initiator), + nowSeconds: initiator.exp, + })).toThrow(); + expect(() => verifyEndpointAttestation(`${token}!`, parsedKeys.publicKeys, { + ...endpointExpectation(initiator), + nowSeconds, + })).toThrow(); + const tokenWithRawIdentity = manuallySignedJws( + { alg: "EdDSA", typ: "cmux-endpoint-attestation-v1+jwt", kid: "current" }, + { ...initiator, userId: "must-not-appear" }, + current.privateKey, + ); + expect(() => verifyEndpointAttestation(tokenWithRawIdentity, parsedKeys.publicKeys, { + ...endpointExpectation(initiator), + nowSeconds, + })).toThrow(); + }); + + test("rejects malformed, oversized, duplicate, and signer-misaligned key sets", () => { + expect(() => parseVerificationKeys(undefined)).toThrow(); + expect(() => parseVerificationKeys(JSON.stringify({ current: currentPublic }))).toThrow(); + expect(() => parseVerificationKeys(JSON.stringify({ + version: 1, + current_kid: "current", + keys: [ + { kid: "current", alg: "EdDSA", spki_der_base64: currentPublic }, + { kid: "current", alg: "EdDSA", spki_der_base64: currentPublic }, + ], + }))).toThrow(); + expect(() => parseVerificationKeys(JSON.stringify({ + version: 1, + current_kid: "missing", + keys: [{ kid: "current", alg: "EdDSA", spki_der_base64: currentPublic }], + }))).toThrow(); + expect(() => parseVerificationKeys("x".repeat(32_769))).toThrow(); + }); +}); + describe("Iroh relay minter response bounds", () => { + test("matches the Rust minter HMAC wire fixture", () => { + const fixture = JSON.parse(readFileSync( + new URL("../../tests/fixtures/iroh/relay-minter-request-v1.json", import.meta.url), + "utf8", + )) as { path: string; timestamp: string; body: string; signature: string }; + const bodyHash = createHash("sha256").update(fixture.body).digest("hex"); + const signature = createHmac("sha256", Buffer.alloc(32, 0x42)) + .update(`POST\n${fixture.path}\n${fixture.timestamp}\n${bodyHash}`, "utf8") + .digest("base64url"); + expect(fixture.path).toBe("/api/relay-token"); + expect(signature).toBe(fixture.signature); + }); + test("requires a canonical 32-byte-or-longer HMAC secret", () => { const valid = Buffer.alloc(32, 9).toString("base64"); expect(parseMinterHmacSecret(valid)).toEqual(Buffer.alloc(32, 9)); expect(() => parseMinterHmacSecret("%%%%" + valid)).toThrow(); expect(() => parseMinterHmacSecret(Buffer.alloc(16, 9).toString("base64"))).toThrow(); + expect(() => parseMinterHmacSecret(Buffer.alloc(257, 9).toString("base64"))).toThrow(); + expect(() => parseMinterHmacSecret(Buffer.alloc(32, 0xff).toString("base64url"))).toThrow(); + }); + + test("requires HTTPS and the exact isolated minter route", () => { + expect(parseMinterUrl("https://minter.cmux.test/api/relay-token").pathname).toBe("/api/relay-token"); + for (const value of [ + "http://minter.cmux.test/api/relay-token", + "https://minter.cmux.test/api/relay-token/", + "https://minter.cmux.test/other", + "https://minter.cmux.test/api/relay-token?debug=1", + ]) { + expect(() => parseMinterUrl(value)).toThrow(); + } }); test("parses a bounded response", async () => { - const body = { token: "token-with-enough-length", expiresAt: "2026-07-10T20:00:00.000Z" }; - expect(await readBoundedMinterJson(new Response(JSON.stringify(body)))).toEqual(body); + const body = { token: "a".repeat(32), expiresAt: "2026-07-10T20:00:00.000Z" }; + expect(await readBoundedMinterJson(new Response(JSON.stringify(body), { + headers: { "content-type": "application/json" }, + }))).toEqual(body); + }); + + test("rejects a non-JSON or expanded minter response contract", async () => { + await expect(readBoundedMinterJson(new Response("{}"))).rejects.toThrow(); + await expect(readBoundedMinterJson(new Response(JSON.stringify({ + token: "a".repeat(32), + expiresAt: "2026-07-10T20:00:00.000Z", + servicesSecret: "must-not-appear", + }), { + headers: { "content-type": "application/json" }, + }))).rejects.toThrow(); }); test("rejects oversized fixed-length and chunked responses", async () => { await expect(readBoundedMinterJson(new Response("{}", { - headers: { "content-length": "999999" }, + headers: { "content-length": "999999", "content-type": "application/json" }, }))).rejects.toThrow(); const chunk = new Uint8Array(20_000); @@ -243,7 +459,9 @@ describe("Iroh relay minter response bounds", () => { controller.close(); }, }); - await expect(readBoundedMinterJson(new Response(stream))).rejects.toThrow(); + await expect(readBoundedMinterJson(new Response(stream, { + headers: { "content-type": "application/json" }, + }))).rejects.toThrow(); }); }); @@ -254,3 +472,13 @@ function manuallySignedJws(header: unknown, claims: unknown, privateKey: CryptoK const signature = sign(null, Buffer.from(input), privateKey as import("node:crypto").KeyObject).toString("base64url"); return `${input}.${signature}`; } + +function endpointExpectation(claims: EndpointAttestationClaims) { + return { + bindingId: claims.bindingId, + deviceId: claims.deviceId, + endpointId: claims.endpointId, + identityGeneration: claims.identityGeneration, + platform: claims.platform, + }; +} diff --git a/web/tests/iroh-route-handler.test.ts b/web/tests/iroh-route-handler.test.ts index fed1b67f5b38..b18b1b618232 100644 --- a/web/tests/iroh-route-handler.test.ts +++ b/web/tests/iroh-route-handler.test.ts @@ -20,6 +20,21 @@ const USER: AuthedUser = { }; describe("Iroh route boundary", () => { + test("requires authentication before returning the public verification-key set", async () => { + let called = false; + const response = await handleIrohRoute(new Request("https://cmux.test/api/devices/iroh"), "discover", { + verify: async () => null, + broker: broker({ + discover: () => { + called = true; + return Effect.succeed({ grant_verification_keys: { version: 1, keys: [] } }); + }, + }), + }); + expect(response.status).toBe(401); + expect(called).toBe(false); + }); + test("authenticates before reading an oversized body", async () => { let called = false; const response = await handleIrohRoute(new Request("https://cmux.test/api/devices/iroh/challenge", { @@ -152,6 +167,7 @@ function broker(overrides: Partial = {}): IrohTrustBrokerS issueChallenge: unavailable, register: unavailable, discover: unavailable, + issueEndpointAttestation: unavailable, revoke: unavailable, issuePairGrant: unavailable, issueRelayToken: unavailable, diff --git a/web/tests/iroh-trust-broker.test.ts b/web/tests/iroh-trust-broker.test.ts index 97605a769355..fda7e9378abe 100644 --- a/web/tests/iroh-trust-broker.test.ts +++ b/web/tests/iroh-trust-broker.test.ts @@ -5,7 +5,11 @@ import { deviceLimitOverrideAllowed, type IrohTrustBrokerConfigShape, } from "../services/iroh/config"; -import { registrationTranscript } from "../services/iroh/crypto"; +import { + parseVerificationKeys, + registrationTranscript, + verifyEndpointAttestation, +} from "../services/iroh/crypto"; import { IrohConflictError, IrohForbiddenError, @@ -128,7 +132,7 @@ describe("Iroh discovery and grants", () => { expect(discovered.bindings).toEqual([]); }); - test("returns the relay fleet separately and prunes expired hints from storage", async () => { + test("returns the relay fleet and authenticated current/previous public keys", async () => { const fixture = makeFixture(); fixture.repository.bindings.push(binding({ userId: USER_A, @@ -145,10 +149,20 @@ describe("Iroh discovery and grants", () => { const discovered = await Effect.runPromise(fixture.broker.discover(USER_A, NOW)) as { relay_fleet: string[]; bindings: Array<{ path_hints: unknown[] }>; + grant_verification_keys: { + current_kid: string; + keys: Array<{ kid: string; spki_der_base64: string }>; + }; }; expect(discovered.relay_fleet).toEqual([...MANAGED_RELAY_URLS]); expect(discovered.bindings[0]!.path_hints).toEqual([]); expect(fixture.repository.bindings[0]!.pathHints).toEqual([]); + expect(discovered.grant_verification_keys.current_kid).toBe("current"); + expect(discovered.grant_verification_keys.keys.map((key) => key.kid)).toEqual([ + "current", + "previous", + ]); + expect(JSON.stringify(discovered.grant_verification_keys)).not.toContain("PRIVATE KEY"); }); test("pair grants require two same-user bindings and a pairable Mac", async () => { @@ -170,6 +184,65 @@ describe("Iroh discovery and grants", () => { acceptorBindingId: acceptor.id, }, NOW), "IrohNotFoundError"); }); + + test("issues a short-lived opaque same-account attestation only for an owned active binding", async () => { + const fixture = makeFixture(); + const active = binding({ userId: USER_A, platform: "ios", identityGeneration: 4 }); + fixture.repository.bindings.push(active); + const result = await Effect.runPromise(fixture.broker.issueEndpointAttestation(USER_A, { + bindingId: active.id, + }, NOW)) as { + attestation_version: number; + attestation: string; + expires_at: string; + grant_verification_keys: { current_kid: string }; + }; + expect(result.attestation_version).toBe(1); + expect(result.grant_verification_keys.current_kid).toBe("current"); + expect(new Date(result.expires_at).getTime() - NOW.getTime()).toBe(24 * 60 * 60 * 1_000); + const payload = JSON.parse(Buffer.from(result.attestation.split(".")[1]!, "base64url").toString()) as { + sub: string; + }; + expect(payload.sub).toHaveLength(43); + expect(JSON.stringify(payload)).not.toContain(USER_A); + + const keys = parseVerificationKeys(fixture.config.grantVerificationKeysJson); + expect(verifyEndpointAttestation(result.attestation, keys.publicKeys, { + bindingId: active.id, + deviceId: active.deviceUuid, + endpointId: active.endpointId, + identityGeneration: active.identityGeneration, + platform: "ios", + nowSeconds: Math.floor(NOW.getTime() / 1_000), + }).sub).toBe(payload.sub); + + await expectEffectFailure(fixture.broker.issueEndpointAttestation(USER_B, { + bindingId: active.id, + }, NOW), "IrohNotFoundError"); + active.revokedAt = NOW; + await expectEffectFailure(fixture.broker.issueEndpointAttestation(USER_A, { + bindingId: active.id, + }, NOW), "IrohNotFoundError"); + }); + + test("fails closed when verification or opaque-subject signing material is unavailable", async () => { + const fixture = makeFixture(); + const active = binding({ userId: USER_A, platform: "ios" }); + fixture.repository.bindings.push(active); + const noVerificationKeys = makeIrohTrustBroker(fixture.repository, fixture.minter, { + ...fixture.config, + grantVerificationKeysJson: undefined, + }); + await expectEffectFailure(noVerificationKeys.discover(USER_A, NOW), "IrohConfigurationError"); + + const noAccountSubject = makeIrohTrustBroker(fixture.repository, fixture.minter, { + ...fixture.config, + accountSubjectSecretBase64: undefined, + }); + await expectEffectFailure(noAccountSubject.issueEndpointAttestation(USER_A, { + bindingId: active.id, + }, NOW), "IrohConfigurationError"); + }); }); describe("Iroh relay quotas", () => { @@ -421,11 +494,24 @@ function makeFixture(options: { const identityGeneration = options.identityGeneration ?? 1; const config: IrohTrustBrokerConfigShape = { lanDiscoverySecretBase64: Buffer.alloc(32, 7).toString("base64"), + accountSubjectSecretBase64: Buffer.alloc(32, 8).toString("base64"), grantSigningPrivateKeyPem: grantKeys.privateKey.export({ format: "pem", type: "pkcs8" }).toString(), grantSigningKid: "current", grantVerificationKeysJson: JSON.stringify({ - current: grantKeys.publicKey.export({ format: "pem", type: "spki" }).toString(), - previous: previousKeys.publicKey.export({ format: "pem", type: "spki" }).toString(), + version: 1, + current_kid: "current", + keys: [ + { + kid: "current", + alg: "EdDSA", + spki_der_base64: grantKeys.publicKey.export({ format: "der", type: "spki" }).toString("base64"), + }, + { + kid: "previous", + alg: "EdDSA", + spki_der_base64: previousKeys.publicKey.export({ format: "der", type: "spki" }).toString("base64"), + }, + ], }), deviceLimitOverrideEnabled: false, deviceLimitOverrideUserIds: new Set(), @@ -438,6 +524,7 @@ function makeFixture(options: { repository, minter, broker, + config, endpointId, appInstanceId, deviceId, From 89257073e5cc0a3af8d4797607727cab0f1a93f6 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Fri, 10 Jul 2026 00:40:00 -0700 Subject: [PATCH 03/37] test(iroh): cover trust-state linearization gaps --- web/tests/iroh-db-behavior.test.ts | 32 ++++++++++++++++++- web/tests/iroh-model-crypto.test.ts | 7 +++++ web/tests/iroh-trust-broker.test.ts | 48 +++++++++++++++++++++++++++-- 3 files changed, 84 insertions(+), 3 deletions(-) diff --git a/web/tests/iroh-db-behavior.test.ts b/web/tests/iroh-db-behavior.test.ts index 7022e80ba93b..f65084ddbd44 100644 --- a/web/tests/iroh-db-behavior.test.ts +++ b/web/tests/iroh-db-behavior.test.ts @@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto"; import * as Effect from "effect/Effect"; import postgres, { type Sql } from "postgres"; import { closeCloudDbForTests } from "../db/client"; +import { accountDeletionUserHash } from "../services/account/deletionLock"; import { IrohRepository, IrohRepositoryLive, @@ -36,7 +37,8 @@ beforeEach(async () => { iroh_pair_grant_issuances, iroh_registration_challenges, iroh_endpoint_bindings, - iroh_account_security_states + iroh_account_security_states, + account_deletion_tombstones restart identity cascade `; }); @@ -47,6 +49,34 @@ afterAll(async () => { }); describe("Iroh trust broker database behavior", () => { + dbTest("blocks new trust state once account deletion wins the account fence", async () => { + const userId = "user-deleting"; + await requiredSql()` + insert into account_deletion_tombstones (user_id_hash, user_id, status, updated_at) + values (${accountDeletionUserHash(userId)}, ${userId}, 'pending', ${NOW}) + `; + + const exit = await Effect.runPromiseExit(requiredRepository().issueChallenge({ + userId, + deviceUuid: randomUUID(), + appInstanceId: randomUUID(), + tag: "stable", + endpointId: "09".repeat(32), + identityGeneration: 1, + payloadSha256: "08".repeat(32), + nonceHash: "07".repeat(32), + now: NOW, + expiresAt: new Date(NOW.getTime() + 5 * 60 * 1_000), + })); + + expect(exit._tag).toBe("Failure"); + expect(String(exit)).toContain("account_deletion_in_progress"); + const [{ total }] = await requiredSql()>` + select count(*)::text as total from iroh_registration_challenges where user_id = ${userId} + `; + expect(total).toBe("0"); + }); + dbTest("atomically consumes a challenge exactly once under concurrency", async () => { const repo = requiredRepository(); const deviceId = randomUUID(); diff --git a/web/tests/iroh-model-crypto.test.ts b/web/tests/iroh-model-crypto.test.ts index f1a50e13f036..b26a59066759 100644 --- a/web/tests/iroh-model-crypto.test.ts +++ b/web/tests/iroh-model-crypto.test.ts @@ -114,6 +114,13 @@ describe("Iroh route wire contract", () => { }, NOW)).toThrow(); }); + test("rejects identity generations that overflow the Postgres integer contract", () => { + expect(() => parseRegistrationPayload({ + ...registrationPayload(directHint({ value: "8.8.8.8:4433" })), + identityGeneration: 2_147_483_648, + }, NOW)).toThrow(); + }); + test("accepts only endpoint-reported home relays from the separate fleet allowlist", () => { const relayHint = directHint({ kind: "relay_url", value: MANAGED_RELAY_URLS[0] }); expect(parseRegistrationPayload(registrationPayload(relayHint), NOW).pathHints[0]?.value).toBe(MANAGED_RELAY_URLS[0]); diff --git a/web/tests/iroh-trust-broker.test.ts b/web/tests/iroh-trust-broker.test.ts index fda7e9378abe..96c651a53fe6 100644 --- a/web/tests/iroh-trust-broker.test.ts +++ b/web/tests/iroh-trust-broker.test.ts @@ -185,6 +185,30 @@ describe("Iroh discovery and grants", () => { }, NOW), "IrohNotFoundError"); }); + test("pair grants require an iOS initiator and revalidate both peers at commit", async () => { + const wrongPlatform = makeFixture(); + const macInitiator = binding({ userId: USER_A, platform: "mac" }); + const macAcceptor = binding({ userId: USER_A, platform: "mac", pairingEnabled: true }); + wrongPlatform.repository.bindings.push(macInitiator, macAcceptor); + await expectEffectFailure(wrongPlatform.broker.issuePairGrant(USER_A, { + initiatorBindingId: macInitiator.id, + acceptorBindingId: macAcceptor.id, + }, NOW), "IrohForbiddenError"); + + const raced = makeFixture(); + const iosInitiator = binding({ userId: USER_A, platform: "ios" }); + const pairableMac = binding({ userId: USER_A, platform: "mac", pairingEnabled: true }); + raced.repository.bindings.push(iosInitiator, pairableMac); + raced.repository.beforeRecordPairGrant = () => { + pairableMac.revokedAt = NOW; + }; + await expectEffectFailure(raced.broker.issuePairGrant(USER_A, { + initiatorBindingId: iosInitiator.id, + acceptorBindingId: pairableMac.id, + }, NOW), "IrohNotFoundError"); + expect(raced.repository.pairGrantAudits).toHaveLength(0); + }); + test("issues a short-lived opaque same-account attestation only for an owned active binding", async () => { const fixture = makeFixture(); const active = binding({ userId: USER_A, platform: "ios", identityGeneration: 4 }); @@ -278,6 +302,21 @@ describe("Iroh relay quotas", () => { ); expect(fixture.minter.calls).toBe(3); }); + + test("does not return a relay credential when the binding is revoked during mint", async () => { + const fixture = makeFixture(); + const active = binding({ userId: USER_A }); + fixture.repository.bindings.push(active); + fixture.minter.afterMint = () => { + active.revokedAt = NOW; + }; + + await expectEffectFailure( + fixture.broker.issueRelayToken(USER_A, { bindingId: active.id }, NOW), + "IrohNotFoundError", + ); + expect(fixture.repository.relayIssuances[0]?.status).toBe("failed"); + }); }); describe("developer binding override", () => { @@ -310,6 +349,7 @@ class MemoryRepository implements IrohRepositoryShape { status: string; }> = []; private lanGenerations = new Map(); + beforeRecordPairGrant: (() => void) | undefined; issueChallenge(input: Parameters[0]) { const challenge: IrohChallengeRecord = { @@ -430,6 +470,7 @@ class MemoryRepository implements IrohRepositoryShape { } recordPairGrant(input: Parameters[0]) { + this.beforeRecordPairGrant?.(); this.pairGrantAudits.push(input); return Effect.void; } @@ -463,15 +504,18 @@ class MemoryRepository implements IrohRepositoryShape { class FakeMinter implements IrohRelayMinterShape { calls = 0; + afterMint: (() => void) | undefined; constructor(private readonly fail: boolean) {} mint(input: Parameters[0]) { this.calls += 1; if (this.fail) return Effect.fail(new IrohRelayMintError({ code: "test_failure" })); - return Effect.succeed({ + const result = { token: `relay-token-${this.calls}-with-safe-length`, expiresAt: new Date(input.now.getTime() + IROH_RELAY_TOKEN_LIFETIME_SECONDS * 1_000), - }); + }; + this.afterMint?.(); + return Effect.succeed(result); } } From 3f29140e1d2a9ff71655eb90306ab3bf71a5d18a Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Fri, 10 Jul 2026 01:04:52 -0700 Subject: [PATCH 04/37] fix(iroh): linearize trust authorization --- docs/iroh-offline-pairing-v1.md | 72 ++- services/iroh-relay-minter/.gitignore | 3 +- .../migration.sql | 71 +++ web/db/schema.ts | 27 +- web/services/iroh/README.md | 14 + web/services/iroh/crypto.ts | 166 +++++- web/services/iroh/model.ts | 18 +- web/services/iroh/repository.ts | 523 +++++++++++++----- web/services/iroh/trustBroker.ts | 19 +- web/tests/iroh-db-behavior.test.ts | 424 +++++++++++++- web/tests/iroh-model-crypto.test.ts | 190 ++++++- web/tests/iroh-trust-broker.test.ts | 22 +- 12 files changed, 1361 insertions(+), 188 deletions(-) create mode 100644 web/db/migrations/20260710000000_iroh_trust_broker_hardening/migration.sql diff --git a/docs/iroh-offline-pairing-v1.md b/docs/iroh-offline-pairing-v1.md index bd7f3045ca92..dce24686a59f 100644 --- a/docs/iroh-offline-pairing-v1.md +++ b/docs/iroh-offline-pairing-v1.md @@ -40,10 +40,19 @@ openssl rand -base64 32 ``` Never put the private key or account-subject secret in a client-visible Vercel -variable. During signing-key rotation, publish the new current public key and -retain the old key as `previous` for at least seven days, the maximum pair-grant -lifetime. A forced account-subject-secret rotation invalidates cached offline -attestations, so clients must refresh before pairing. +variable. Rotate signing keys in three deployments: + +1. Keep the old signer and `current_kid`, then add the next public key as the + second verification key. +2. After clients have fetched that key set, switch the signer and `current_kid` + to the new key while retaining the old public key as the second key. +3. At least seven days after the signer switch, remove the old public key. Seven + days is the maximum online pair-grant lifetime. + +The service rejects a deployment whose private signer does not match +`current_kid`. Roll back signer, KID, and verification-key JSON as one unit. A +forced account-subject-secret rotation invalidates cached offline attestations, +so clients must refresh before pairing. ## Signed attestation @@ -76,13 +85,37 @@ The payload has exactly these fields: `sub` is HMAC-SHA256 over the private backend account identifier with a dedicated server secret and a versioned domain separator. It lets two devices -compare account membership without disclosing the underlying identifier. +compare account membership without disclosing the underlying identifier. It is +stable for that account until the subject secret rotates. A peer that collects +copied attestations can therefore correlate them as belonging to the same +account during their 24-hour validity. Session-scoping `sub` would require both +devices to contact the backend for the same session, which would remove the +offline property. The one-use local invitation below limits authorization +replay, but does not remove this pseudonymous correlation. The final privacy +review must re-evaluate this tradeoff before release. ## Offline authorization -The Mac QR payload carries the Mac attestation. The iOS initiator also presents -its own cached attestation after the Iroh connection is established. Each peer -must perform all of these checks before accepting pairing: +The Mac creates a five-minute local pairing session inside its endpoint actor. +It generates a random UUID `session_id` and 32 random bytes `proof`. The QR +contains this exact authorization object plus the Mac attestation: + +```json +{ + "version": 1, + "session_id": "", + "proof": "<32-byte unpadded base64url>", + "expires_at": 1783627500, + "acceptor_attestation": "" +} +``` + +The Mac stores only the proof hash, exact local binding/device/EndpointID/ +identity-generation tuple, expiry, and an unconsumed marker. Route hints may be +included elsewhere in the QR, but never enter the proof transcript. The iOS +initiator presents the QR authorization and its own cached attestation after +the authenticated Iroh connection is established. The Mac performs all checks +and the consume transition in one actor-isolated operation: 1. Verify canonical JWS encoding and Ed25519 signature with a cached current or previous public key. @@ -91,11 +124,19 @@ must perform all of these checks before accepting pairing: claim to the expected local state and the authenticated Iroh peer EndpointID. 4. Require an iOS initiator, a Mac acceptor, distinct bindings, devices, and EndpointIDs, and equal 32-byte account subjects. -5. Apply local revocation and pairing-disabled state before accepting. +5. Require the signed online pair-grant direction everywhere: iOS initiator and + Mac acceptor. +6. Require the exact unexpired local `session_id`, constant-time proof-hash + match, exact local acceptor tuple, and `consumed_at == nil`. +7. Apply local revocation and pairing-disabled state, then set `consumed_at` + before returning the accepted admission. A missing attestation, one attestation used for both peers, a subject mismatch, -an expired token, or an EndpointID substitution fails closed. Network addresses -inside a QR remain untrusted route hints and do not participate in authorization. +an expired token, an EndpointID substitution, a wrong proof, or a replayed +session fails closed. A failed proof or attestation check does not consume the +session. A successful session is consumed even if later application setup +fails, so retry requires a new QR. Network addresses inside a QR remain +untrusted route hints and do not participate in authorization. Offline verification cannot observe a revocation made after the last refresh. The 24-hour expiry bounds that window. A client without a fresh attestation must @@ -104,7 +145,8 @@ go online before first-time pairing. ## Release gate The TypeScript reference verifier and backend behavior tests enforce this -contract. Shipping offline QR pairing remains blocked until the Swift client has -equivalent behavior tests that prove QR possession alone fails, both -attestations are required, the live Iroh EndpointIDs are bound, and current plus -previous key rotation works. +contract, including one-use consumption and staged key rotation. Shipping +offline QR pairing remains blocked until the Swift client has equivalent actor +tests that prove QR possession alone fails, both attestations are required, the +live Iroh EndpointIDs are bound, concurrent replay has one winner, and current +plus previous key rotation works. diff --git a/services/iroh-relay-minter/.gitignore b/services/iroh-relay-minter/.gitignore index 32a7de27b38f..226a93ca6de2 100644 --- a/services/iroh-relay-minter/.gitignore +++ b/services/iroh-relay-minter/.gitignore @@ -1,3 +1,4 @@ /target/ /.vercel/ -/.env +/.env* +!/.env.example diff --git a/web/db/migrations/20260710000000_iroh_trust_broker_hardening/migration.sql b/web/db/migrations/20260710000000_iroh_trust_broker_hardening/migration.sql new file mode 100644 index 000000000000..c25bca7a8f3e --- /dev/null +++ b/web/db/migrations/20260710000000_iroh_trust_broker_hardening/migration.sql @@ -0,0 +1,71 @@ +ALTER TABLE "iroh_endpoint_bindings" + ADD COLUMN "path_hints_next_expiry" timestamp with time zone; +--> statement-breakpoint +ALTER TABLE "iroh_endpoint_bindings" + DROP CONSTRAINT "iroh_endpoint_bindings_identity_generation_check"; +--> statement-breakpoint +ALTER TABLE "iroh_endpoint_bindings" + ADD CONSTRAINT "iroh_endpoint_bindings_identity_generation_check" + CHECK ("identity_generation" between 1 and 2147483647); +--> statement-breakpoint +ALTER TABLE "iroh_registration_challenges" + DROP CONSTRAINT "iroh_registration_challenges_identity_generation_check"; +--> statement-breakpoint +ALTER TABLE "iroh_registration_challenges" + ADD CONSTRAINT "iroh_registration_challenges_identity_generation_check" + CHECK ("identity_generation" between 1 and 2147483647); +--> statement-breakpoint +UPDATE "iroh_endpoint_bindings" AS binding +SET "path_hints_next_expiry" = ( + SELECT min((hint ->> 'expires_at')::timestamptz) + FROM jsonb_array_elements(binding."path_hints") AS hints(hint) + WHERE jsonb_typeof(hint) = 'object' + AND jsonb_typeof(hint -> 'expires_at') = 'string' + AND (hint ->> 'expires_at') ~ '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$' +) +WHERE binding."revoked_at" IS NULL + AND binding."path_hints" <> '[]'::jsonb; +--> statement-breakpoint +UPDATE "iroh_endpoint_bindings" +SET "path_hints" = '[]'::jsonb, + "path_hints_next_expiry" = NULL +WHERE "revoked_at" IS NOT NULL + AND "path_hints" <> '[]'::jsonb; +--> statement-breakpoint +CREATE INDEX "iroh_endpoint_bindings_path_hints_expiry_idx" + ON "iroh_endpoint_bindings" USING btree ("path_hints_next_expiry", "id") + WHERE "revoked_at" IS NULL AND "path_hints_next_expiry" IS NOT NULL; +--> statement-breakpoint +CREATE INDEX "iroh_endpoint_bindings_revoked_hints_idx" + ON "iroh_endpoint_bindings" USING btree ("revoked_at", "id") + WHERE "revoked_at" IS NOT NULL AND "path_hints_next_expiry" IS NOT NULL; +--> statement-breakpoint +DROP INDEX "iroh_registration_challenges_expires_idx"; +--> statement-breakpoint +CREATE INDEX "iroh_registration_challenges_expires_idx" + ON "iroh_registration_challenges" USING btree ("expires_at", "id"); +--> statement-breakpoint +CREATE INDEX "iroh_registration_challenges_consumed_idx" + ON "iroh_registration_challenges" USING btree ("consumed_at", "id") + WHERE "consumed_at" IS NOT NULL; +--> statement-breakpoint +CREATE INDEX "iroh_registration_challenges_user_expires_idx" + ON "iroh_registration_challenges" USING btree ("user_id", "expires_at", "id"); +--> statement-breakpoint +CREATE INDEX "iroh_registration_challenges_user_consumed_idx" + ON "iroh_registration_challenges" USING btree ("user_id", "consumed_at", "id") + WHERE "consumed_at" IS NOT NULL; +--> statement-breakpoint +CREATE INDEX "iroh_pair_grant_issuances_expires_idx" + ON "iroh_pair_grant_issuances" USING btree ("expires_at", "id"); +--> statement-breakpoint +CREATE INDEX "iroh_pair_grant_issuances_user_expires_idx" + ON "iroh_pair_grant_issuances" USING btree ("user_id", "expires_at", "id"); +--> statement-breakpoint +CREATE INDEX "iroh_relay_token_issuances_requested_idx" + ON "iroh_relay_token_issuances" USING btree ("requested_at", "id"); +--> statement-breakpoint +DROP INDEX "iroh_relay_token_issuances_user_requested_idx"; +--> statement-breakpoint +CREATE INDEX "iroh_relay_token_issuances_user_requested_idx" + ON "iroh_relay_token_issuances" USING btree ("user_id", "requested_at", "id"); diff --git a/web/db/schema.ts b/web/db/schema.ts index e3451a865723..705f8eb4f6bf 100644 --- a/web/db/schema.ts +++ b/web/db/schema.ts @@ -647,6 +647,7 @@ export const irohEndpointBindings = pgTable( pairingEnabled: boolean("pairing_enabled").notNull().default(false), capabilities: jsonb("capabilities").$type().notNull().default(sql`'[]'::jsonb`), pathHints: jsonb("path_hints").$type().notNull().default(sql`'[]'::jsonb`), + pathHintsNextExpiry: timestamp("path_hints_next_expiry", { withTimezone: true }), deviceLimitOverrideUsed: boolean("device_limit_override_used").notNull().default(false), lastSeenAt: timestamp("last_seen_at", { withTimezone: true }).notNull().defaultNow(), registeredAt: timestamp("registered_at", { withTimezone: true }).notNull().defaultNow(), @@ -656,7 +657,7 @@ export const irohEndpointBindings = pgTable( }, (table) => [ check("iroh_endpoint_bindings_endpoint_id_check", sql`${table.endpointId} ~ '^[0-9a-f]{64}$'`), - check("iroh_endpoint_bindings_identity_generation_check", sql`${table.identityGeneration} >= 1`), + check("iroh_endpoint_bindings_identity_generation_check", sql`${table.identityGeneration} between 1 and 2147483647`), check("iroh_endpoint_bindings_tag_check", sql`${table.tag} ~ '^[A-Za-z0-9._-]{1,64}$'`), check("iroh_endpoint_bindings_platform_check", sql`${table.platform} in ('mac', 'ios')`), check("iroh_endpoint_bindings_display_name_check", sql`${table.displayName} is null or ${table.displayName} !~ '[[:cntrl:]]'`), @@ -677,6 +678,12 @@ export const irohEndpointBindings = pgTable( index("iroh_endpoint_bindings_revoked_idx") .on(table.revokedAt) .where(sql`${table.revokedAt} is not null`), + index("iroh_endpoint_bindings_path_hints_expiry_idx") + .on(table.pathHintsNextExpiry, table.id) + .where(sql`${table.revokedAt} is null and ${table.pathHintsNextExpiry} is not null`), + index("iroh_endpoint_bindings_revoked_hints_idx") + .on(table.revokedAt, table.id) + .where(sql`${table.revokedAt} is not null and ${table.pathHintsNextExpiry} is not null`), ], ); @@ -703,7 +710,7 @@ export const irohRegistrationChallenges = pgTable( }, (table) => [ check("iroh_registration_challenges_endpoint_id_check", sql`${table.endpointId} ~ '^[0-9a-f]{64}$'`), - check("iroh_registration_challenges_identity_generation_check", sql`${table.identityGeneration} >= 1`), + check("iroh_registration_challenges_identity_generation_check", sql`${table.identityGeneration} between 1 and 2147483647`), check("iroh_registration_challenges_tag_check", sql`${table.tag} ~ '^[A-Za-z0-9._-]{1,64}$'`), check("iroh_registration_challenges_payload_hash_check", sql`${table.payloadSha256} ~ '^[0-9a-f]{64}$'`), check("iroh_registration_challenges_nonce_hash_check", sql`${table.nonceHash} ~ '^[0-9a-f]{64}$'`), @@ -712,8 +719,15 @@ export const irohRegistrationChallenges = pgTable( index("iroh_registration_challenges_user_device_created_idx") .on(table.userId, table.deviceUuid, table.createdAt), index("iroh_registration_challenges_expires_idx") - .on(table.expiresAt) - .where(sql`${table.consumedAt} is null`), + .on(table.expiresAt, table.id), + index("iroh_registration_challenges_consumed_idx") + .on(table.consumedAt, table.id) + .where(sql`${table.consumedAt} is not null`), + index("iroh_registration_challenges_user_expires_idx") + .on(table.userId, table.expiresAt, table.id), + index("iroh_registration_challenges_user_consumed_idx") + .on(table.userId, table.consumedAt, table.id) + .where(sql`${table.consumedAt} is not null`), ], ); @@ -743,6 +757,8 @@ export const irohPairGrantIssuances = pgTable( uniqueIndex("iroh_pair_grant_issuances_jti_unique").on(table.jti), index("iroh_pair_grant_issuances_user_issued_idx").on(table.userId, table.issuedAt), index("iroh_pair_grant_issuances_acceptor_expires_idx").on(table.acceptorBindingId, table.expiresAt), + index("iroh_pair_grant_issuances_expires_idx").on(table.expiresAt, table.id), + index("iroh_pair_grant_issuances_user_expires_idx").on(table.userId, table.expiresAt, table.id), ], ); @@ -770,7 +786,8 @@ export const irohRelayTokenIssuances = pgTable( check("iroh_relay_token_issuances_endpoint_hash_check", sql`${table.endpointIdHash} ~ '^[0-9a-f]{64}$'`), check("iroh_relay_token_issuances_status_check", sql`${table.status} in ('pending', 'succeeded', 'failed')`), index("iroh_relay_token_issuances_binding_requested_idx").on(table.bindingId, table.requestedAt), - index("iroh_relay_token_issuances_user_requested_idx").on(table.userId, table.requestedAt), + index("iroh_relay_token_issuances_user_requested_idx").on(table.userId, table.requestedAt, table.id), + index("iroh_relay_token_issuances_requested_idx").on(table.requestedAt, table.id), ], ); diff --git a/web/services/iroh/README.md b/web/services/iroh/README.md index fdca1e9f2744..f3d9090cee4e 100644 --- a/web/services/iroh/README.md +++ b/web/services/iroh/README.md @@ -34,3 +34,17 @@ per day. The optional Vercel Firewall rule is defense in depth. A tagged-build device-limit override requires a server flag, an exact authenticated user-id allowlist match, and an exact deployment-environment allowlist match; it never raises the 32-binding account limit and records an audit marker on the binding. + +Every user-scoped mutation acquires the account-deletion advisory fence before +any Iroh lock. If the deletion tombstone wins, no challenge, binding, grant, or +relay audit state can be created. If an Iroh mutation wins, account deletion +waits for that transaction and then removes its rows. Pair grants re-read and +lock both exact signed peers at audit insertion, requiring an iOS initiator and +a pairable Mac acceptor. Relay credentials are returned only after a second +locked active-binding check following the external mint. + +Registration stores the earliest path-hint expiry in +`path_hints_next_expiry`. The hourly cleanup uses that indexed scalar and +bounded 500-row `FOR UPDATE SKIP LOCKED` batches for hints, challenges, audit +rows, and revoked bindings. Concurrent cron workers can cooperate without a +full-table JSON scan. diff --git a/web/services/iroh/crypto.ts b/web/services/iroh/crypto.ts index bc09d2b53ad6..1eb1fdcd0e26 100644 --- a/web/services/iroh/crypto.ts +++ b/web/services/iroh/crypto.ts @@ -12,11 +12,14 @@ import { IROH_ENDPOINT_ATTESTATION_SCOPE, IROH_ENDPOINT_ATTESTATION_TYP, IROH_ENDPOINT_ATTESTATION_VERSION, + IROH_OFFLINE_PAIR_SESSION_LIFETIME_SECONDS, + IROH_OFFLINE_PAIR_SESSION_VERSION, IROH_ALPN, IROH_PAIR_GRANT_LIFETIME_SECONDS, IROH_PAIR_GRANT_TYP, IROH_PAIR_SCOPE, endpointId, + POSTGRES_INT32_MAX, sha256, } from "./model"; import { IrohConfigurationError, IrohForbiddenError, IrohInvalidInputError } from "./errors"; @@ -27,6 +30,7 @@ export type PairGrantPeer = { readonly bindingId: string; readonly deviceId: string; readonly tag: string; + readonly platform: "mac" | "ios"; readonly endpointId: string; readonly identityGeneration: number; }; @@ -100,6 +104,22 @@ export type OfflinePairVerificationExpectation = { readonly nowSeconds: number; }; +export type OfflinePairSessionRecord = { + readonly version: typeof IROH_OFFLINE_PAIR_SESSION_VERSION; + readonly sessionId: string; + readonly acceptor: OfflinePairVerificationExpectation["acceptor"]; + readonly proofHash: string; + readonly createdAtSeconds: number; + readonly expiresAtSeconds: number; + consumedAtSeconds: number | null; +}; + +export type OfflinePairInvitationProof = { + readonly version: typeof IROH_OFFLINE_PAIR_SESSION_VERSION; + readonly sessionId: string; + readonly proof: string; +}; + export function registrationTranscript(input: { readonly challengeId: string; readonly nonce: string; @@ -144,7 +164,7 @@ export function deriveLanRendezvousKey( generation: number, ): string { const secret = decodeSecret(secretBase64, "lan_discovery"); - if (!Number.isSafeInteger(generation) || generation < 1) { + if (!Number.isSafeInteger(generation) || generation < 1 || generation > POSTGRES_INT32_MAX) { throw new IrohConfigurationError({ component: "lan_discovery" }); } return createHmac("sha256", secret) @@ -281,7 +301,7 @@ export function verifyEndpointAttestation( return claims; } -export function verifyOfflineSameAccountPair(input: { +function verifyOfflineSameAccountPair(input: { readonly initiatorAttestation: string; readonly acceptorAttestation: string; readonly publicKeys: ReadonlyMap; @@ -290,6 +310,12 @@ export function verifyOfflineSameAccountPair(input: { readonly initiator: EndpointAttestationClaims; readonly acceptor: EndpointAttestationClaims; } { + if ( + input.expected.initiator.platform !== "ios" || + input.expected.acceptor.platform !== "mac" + ) { + throw new IrohForbiddenError({ code: "invalid_offline_pair_platforms" }); + } const initiator = verifyEndpointAttestation(input.initiatorAttestation, input.publicKeys, { ...input.expected.initiator, nowSeconds: input.expected.nowSeconds, @@ -309,6 +335,75 @@ export function verifyOfflineSameAccountPair(input: { return { initiator, acceptor }; } +export function createOfflinePairSessionRecord(input: { + readonly sessionId: string; + readonly proof: string; + readonly acceptor: OfflinePairVerificationExpectation["acceptor"]; + readonly nowSeconds: number; + readonly expiresAtSeconds: number; +}): OfflinePairSessionRecord { + validateOfflinePairSessionWindow(input.nowSeconds, input.expiresAtSeconds); + validateEndpointExpectation(input.acceptor, "mac"); + if (!UUID_PATTERN.test(input.sessionId) || input.sessionId !== input.sessionId.toLowerCase()) { + throw new IrohInvalidInputError({ code: "invalid_offline_pair_session" }); + } + const proof = decodeCanonicalBase64url(input.proof, 32, "invalid_offline_pair_proof"); + return { + version: IROH_OFFLINE_PAIR_SESSION_VERSION, + sessionId: input.sessionId.toLowerCase(), + acceptor: { ...input.acceptor }, + proofHash: offlinePairProofHash(input.sessionId.toLowerCase(), input.acceptor, proof), + createdAtSeconds: input.nowSeconds, + expiresAtSeconds: input.expiresAtSeconds, + consumedAtSeconds: null, + }; +} + +export function verifyAndConsumeOfflineSameAccountPair(input: { + readonly initiatorAttestation: string; + readonly acceptorAttestation: string; + readonly publicKeys: ReadonlyMap; + readonly expected: OfflinePairVerificationExpectation; + readonly session: OfflinePairSessionRecord; + readonly invitation: OfflinePairInvitationProof; +}): { + readonly initiator: EndpointAttestationClaims; + readonly acceptor: EndpointAttestationClaims; + readonly sessionId: string; +} { + const { session, invitation } = input; + if ( + typeof invitation.sessionId !== "string" || + !UUID_PATTERN.test(invitation.sessionId) || + invitation.sessionId !== invitation.sessionId.toLowerCase() + ) { + throw new IrohInvalidInputError({ code: "invalid_offline_pair_session" }); + } + if ( + session.version !== IROH_OFFLINE_PAIR_SESSION_VERSION || + invitation.version !== IROH_OFFLINE_PAIR_SESSION_VERSION || + session.consumedAtSeconds !== null || + session.sessionId !== invitation.sessionId || + !sameEndpointExpectation(session.acceptor, input.expected.acceptor) || + session.createdAtSeconds > input.expected.nowSeconds + 30 || + session.expiresAtSeconds <= input.expected.nowSeconds + ) { + throw new IrohForbiddenError({ code: "offline_pair_session_unavailable" }); + } + validateOfflinePairSessionWindow( + session.createdAtSeconds, + session.expiresAtSeconds, + ); + const proof = decodeCanonicalBase64url(invitation.proof, 32, "invalid_offline_pair_proof"); + const actualHash = offlinePairProofHash(session.sessionId, session.acceptor, proof); + if (!hashesEqual(session.proofHash, actualHash)) { + throw new IrohForbiddenError({ code: "invalid_offline_pair_proof" }); + } + const verified = verifyOfflineSameAccountPair(input); + session.consumedAtSeconds = input.expected.nowSeconds; + return { ...verified, sessionId: session.sessionId }; +} + export function parseVerificationKeys( value: string | undefined, ): ParsedPairGrantVerificationKeys { @@ -432,6 +527,9 @@ function validatePairGrantClaims( } validatePeer(value.initiator, "initiator"); validatePeer(value.acceptor, "acceptor"); + if (value.initiator.platform !== "ios" || value.acceptor.platform !== "mac") { + throw new IrohForbiddenError({ code: "invalid_pair_grant_platforms" }); + } if (expected.initiator && !samePeer(value.initiator, expected.initiator)) { throw new IrohForbiddenError({ code: "pair_grant_initiator_mismatch" }); } @@ -467,7 +565,9 @@ function validateEndpointAttestationClaims( typeof value.jti !== "string" || !UUID_PATTERN.test(value.jti) || typeof value.bindingId !== "string" || !UUID_PATTERN.test(value.bindingId) || typeof value.deviceId !== "string" || !UUID_PATTERN.test(value.deviceId) || - !Number.isSafeInteger(value.identityGeneration) || value.identityGeneration < 1 || + !Number.isSafeInteger(value.identityGeneration) || + value.identityGeneration < 1 || + value.identityGeneration > POSTGRES_INT32_MAX || (value.platform !== "mac" && value.platform !== "ios") || value.alpn !== IROH_ALPN || value.scope !== IROH_ENDPOINT_ATTESTATION_SCOPE @@ -506,7 +606,10 @@ function validatePeer(peer: PairGrantPeer, side: string): void { typeof peer.bindingId !== "string" || !UUID_PATTERN.test(peer.bindingId) || typeof peer.deviceId !== "string" || !UUID_PATTERN.test(peer.deviceId) || typeof peer.tag !== "string" || peer.tag.length < 1 || peer.tag.length > 64 || - !Number.isSafeInteger(peer.identityGeneration) || peer.identityGeneration < 1 + !Number.isSafeInteger(peer.identityGeneration) || + peer.identityGeneration < 1 || + peer.identityGeneration > POSTGRES_INT32_MAX || + (peer.platform !== "mac" && peer.platform !== "ios") ) { throw new IrohForbiddenError({ code: `invalid_pair_grant_${side}` }); } @@ -514,6 +617,7 @@ function validatePeer(peer: PairGrantPeer, side: string): void { "bindingId", "deviceId", "tag", + "platform", "endpointId", "identityGeneration", ], `invalid_pair_grant_${side}`); @@ -524,6 +628,7 @@ function samePeer(left: PairGrantPeer, right: PairGrantPeer): boolean { return left.bindingId === right.bindingId && left.deviceId === right.deviceId && left.tag === right.tag && + left.platform === right.platform && left.endpointId === right.endpointId && left.identityGeneration === right.identityGeneration; } @@ -672,6 +777,59 @@ function canonicalSubjectsEqual(left: string, right: string): boolean { return timingSafeEqual(leftBytes, rightBytes); } +function validateOfflinePairSessionWindow(nowSeconds: number, expiresAtSeconds: number): void { + if ( + !Number.isSafeInteger(nowSeconds) || + !Number.isSafeInteger(expiresAtSeconds) || + expiresAtSeconds <= nowSeconds || + expiresAtSeconds - nowSeconds > IROH_OFFLINE_PAIR_SESSION_LIFETIME_SECONDS + ) { + throw new IrohInvalidInputError({ code: "invalid_offline_pair_session" }); + } +} + +function validateEndpointExpectation( + value: OfflinePairVerificationExpectation["acceptor"], + platform: "mac" | "ios", +): void { + if ( + !UUID_PATTERN.test(value.bindingId) || + !UUID_PATTERN.test(value.deviceId) || + value.platform !== platform || + !Number.isSafeInteger(value.identityGeneration) || + value.identityGeneration < 1 || + value.identityGeneration > POSTGRES_INT32_MAX + ) { + throw new IrohInvalidInputError({ code: "invalid_offline_pair_session" }); + } + endpointId(value.endpointId); +} + +function sameEndpointExpectation( + left: OfflinePairVerificationExpectation["acceptor"], + right: OfflinePairVerificationExpectation["acceptor"], +): boolean { + return left.bindingId === right.bindingId && + left.deviceId === right.deviceId && + left.endpointId === right.endpointId && + left.identityGeneration === right.identityGeneration && + left.platform === right.platform; +} + +function offlinePairProofHash( + sessionId: string, + acceptor: OfflinePairVerificationExpectation["acceptor"], + proof: Uint8Array, +): string { + return sha256(Buffer.concat([ + Buffer.from( + `cmux/iroh/offline-pair-session/v1\n${sessionId}\n${acceptor.bindingId}\n${acceptor.deviceId}\n${acceptor.endpointId}\n${acceptor.identityGeneration}\n${acceptor.platform}\n`, + "utf8", + ), + Buffer.from(proof), + ])); +} + const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; function assertExactKeys( diff --git a/web/services/iroh/model.ts b/web/services/iroh/model.ts index 6eabddc1a62d..f048826d8951 100644 --- a/web/services/iroh/model.ts +++ b/web/services/iroh/model.ts @@ -11,9 +11,12 @@ export const IROH_ENDPOINT_ATTESTATION_SCOPE = "cmux.offline-pair.same-account"; export const IROH_CHALLENGE_LIFETIME_MS = 5 * 60 * 1_000; export const IROH_PAIR_GRANT_LIFETIME_SECONDS = 7 * 24 * 60 * 60; export const IROH_ENDPOINT_ATTESTATION_LIFETIME_SECONDS = 24 * 60 * 60; +export const IROH_OFFLINE_PAIR_SESSION_LIFETIME_SECONDS = 5 * 60; +export const IROH_OFFLINE_PAIR_SESSION_VERSION = 1; export const IROH_RELAY_TOKEN_LIFETIME_SECONDS = 24 * 60 * 60; export const IROH_RELAY_TOKEN_REFRESH_SECONDS = 12 * 60 * 60; export const IROH_ROUTE_CONTRACT_VERSION = 1; +export const POSTGRES_INT32_MAX = 2_147_483_647; export const MANAGED_RELAY_URLS = [ "https://euc1-1.relay.lawrence.cmux.iroh.link/", @@ -292,6 +295,15 @@ export function parseIrohPathHint(value: unknown, now: Date): IrohPathHint { return parsed; } +export function nextPathHintExpiry(pathHints: readonly IrohPathHint[]): Date | null { + if (pathHints.length === 0) return null; + const earliest = Math.min(...pathHints.map((hint) => new Date(hint.expires_at).getTime())); + if (!Number.isFinite(earliest)) { + throw new IrohInvalidInputError({ code: "invalid_path_hint_expiry" }); + } + return new Date(earliest); +} + function literalSocketAddress( value: unknown, scope: IrohPathHint["privacy_scope"], @@ -510,7 +522,11 @@ function uuid(value: unknown, code: string): string { } function positiveInteger(value: unknown, code: string): number { - if (!Number.isSafeInteger(value) || (value as number) < 1) throw new IrohInvalidInputError({ code }); + if ( + !Number.isSafeInteger(value) || + (value as number) < 1 || + (value as number) > POSTGRES_INT32_MAX + ) throw new IrohInvalidInputError({ code }); return value as number; } diff --git a/web/services/iroh/repository.ts b/web/services/iroh/repository.ts index 3b04ed155232..df22cec06ad8 100644 --- a/web/services/iroh/repository.ts +++ b/web/services/iroh/repository.ts @@ -1,8 +1,12 @@ -import { and, asc, count, eq, gt, inArray, isNotNull, isNull, lt, or, sql } from "drizzle-orm"; +import { and, asc, count, eq, gt, inArray, isNull, lte, or, sql } from "drizzle-orm"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { cloudDb } from "../../db/client"; +import { + AccountDeletionMutationBlockedError, + assertAccountDeletionUserMutationAllowed, +} from "../account/deletionLock"; import { irohAccountSecurityStates, irohEndpointBindings, @@ -17,10 +21,20 @@ import { IrohNotFoundError, IrohQuotaExceededError, } from "./errors"; -import { parseIrohPathHint, sha256, type IrohRegistrationPayload } from "./model"; +import type { PairGrantPeer } from "./crypto"; +import { + nextPathHintExpiry, + parseIrohPathHint, + sha256, + type IrohPathHint, + type IrohRegistrationPayload, +} from "./model"; + +export const IROH_RETENTION_BATCH_SIZE = 500; export type IrohBindingRecord = typeof irohEndpointBindings.$inferSelect; export type IrohChallengeRecord = typeof irohRegistrationChallenges.$inferSelect; +type CloudDbTransaction = Parameters["transaction"]>[0]>[0]; type RepositoryError = | IrohDatabaseError @@ -80,8 +94,8 @@ export type IrohRepositoryShape = { readonly recordPairGrant: (input: { readonly userId: string; readonly jti: string; - readonly initiatorBindingId: string; - readonly acceptorBindingId: string; + readonly initiator: PairGrantPeer; + readonly acceptor: PairGrantPeer; readonly signingKeyId: string; readonly alpn: string; readonly scope: string; @@ -98,12 +112,16 @@ export type IrohRepositoryShape = { readonly binding: IrohBindingRecord; }, RepositoryError>; readonly completeRelayIssuance: (input: { + readonly userId: string; readonly issuanceId: string; + readonly bindingId: string; + readonly endpointId: string; readonly tokenHash: string; readonly completedAt: Date; readonly expiresAt: Date; - }) => Effect.Effect; + }) => Effect.Effect; readonly failRelayIssuance: (input: { + readonly userId: string; readonly issuanceId: string; readonly completedAt: Date; readonly failureCode: string; @@ -122,6 +140,7 @@ function makeLiveRepository(): IrohRepositoryShape { issueChallenge: (input) => repositoryEffect("issue_challenge", async () => { const db = cloudDb(); return await db.transaction(async (tx) => { + await assertIrohUserMutationAllowed(tx, input.userId); await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`iroh:challenge:${input.userId}`}, 0))`); const tenMinutesAgo = new Date(input.now.getTime() - 10 * 60 * 1_000); const [recentForDevice] = await tx @@ -181,6 +200,7 @@ function makeLiveRepository(): IrohRepositoryShape { consumeChallengeAndRegister: (input) => repositoryEffect("register_binding", async () => { const db = cloudDb(); return await db.transaction(async (tx) => { + await assertIrohUserMutationAllowed(tx, input.userId); await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`iroh:binding:${input.userId}`}, 0))`); await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`iroh:endpoint:${input.payload.endpointId}`}, 0))`); await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`iroh:app:${input.payload.appInstanceId}`}, 0))`); @@ -226,6 +246,7 @@ function makeLiveRepository(): IrohRepositoryShape { pairingEnabled: input.payload.pairingEnabled, capabilities: [...input.payload.capabilities], pathHints: [...input.payload.pathHints], + pathHintsNextExpiry: nextPathHintExpiry(input.payload.pathHints), lastSeenAt: input.now, updatedAt: input.now, }) @@ -287,6 +308,7 @@ function makeLiveRepository(): IrohRepositoryShape { pairingEnabled: input.payload.pairingEnabled, capabilities: [...input.payload.capabilities], pathHints: [...input.payload.pathHints], + pathHintsNextExpiry: nextPathHintExpiry(input.payload.pathHints), deviceLimitOverrideUsed: usesDeviceOverride, lastSeenAt: input.now, registeredAt: input.now, @@ -322,18 +344,22 @@ function makeLiveRepository(): IrohRepositoryShape { findActiveBindings: (userId, bindingIds) => repositoryEffect("find_bindings", async () => { if (bindingIds.length === 0) return []; - return await cloudDb() - .select() - .from(irohEndpointBindings) - .where(and( - eq(irohEndpointBindings.userId, userId), - inArray(irohEndpointBindings.id, [...bindingIds]), - isNull(irohEndpointBindings.revokedAt), - )); + return await cloudDb().transaction(async (tx) => { + await assertIrohUserMutationAllowed(tx, userId); + return await tx + .select() + .from(irohEndpointBindings) + .where(and( + eq(irohEndpointBindings.userId, userId), + inArray(irohEndpointBindings.id, [...bindingIds]), + isNull(irohEndpointBindings.revokedAt), + )); + }); }), revokeBinding: (input) => repositoryEffect("revoke_binding", async () => { return await cloudDb().transaction(async (tx) => { + await assertIrohUserMutationAllowed(tx, input.userId); await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`iroh:binding:${input.userId}`}, 0))`); const revoked = await tx .update(irohEndpointBindings) @@ -341,6 +367,7 @@ function makeLiveRepository(): IrohRepositoryShape { revokedAt: input.now, revokedReason: "user_requested", pathHints: [], + pathHintsNextExpiry: null, updatedAt: input.now, }) .where(and( @@ -375,71 +402,114 @@ function makeLiveRepository(): IrohRepositoryShape { }), accountLanGeneration: (input) => repositoryEffect("lan_generation", async () => { - const [state] = await cloudDb() - .insert(irohAccountSecurityStates) - .values({ userId: input.userId, lanDiscoveryGeneration: 1, createdAt: input.now, updatedAt: input.now }) - .onConflictDoUpdate({ - target: irohAccountSecurityStates.userId, - set: { updatedAt: sql`${irohAccountSecurityStates.updatedAt}` }, - }) - .returning({ generation: irohAccountSecurityStates.lanDiscoveryGeneration }); - if (!state) throw new Error("account security state returned no row"); - return state.generation; + return await cloudDb().transaction(async (tx) => { + await assertIrohUserMutationAllowed(tx, input.userId); + const [state] = await tx + .insert(irohAccountSecurityStates) + .values({ userId: input.userId, lanDiscoveryGeneration: 1, createdAt: input.now, updatedAt: input.now }) + .onConflictDoUpdate({ + target: irohAccountSecurityStates.userId, + set: { updatedAt: sql`${irohAccountSecurityStates.updatedAt}` }, + }) + .returning({ generation: irohAccountSecurityStates.lanDiscoveryGeneration }); + if (!state) throw new Error("account security state returned no row"); + return state.generation; + }); }), pruneExpiredState: (input) => repositoryEffect("prune_expired_state", async () => { await cloudDb().transaction(async (tx) => { + await assertIrohUserMutationAllowed(tx, input.userId); + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`iroh:binding:${input.userId}`}, 0))`); const bindings = await tx .select({ id: irohEndpointBindings.id, pathHints: irohEndpointBindings.pathHints, - revokedAt: irohEndpointBindings.revokedAt, }) .from(irohEndpointBindings) - .where(eq(irohEndpointBindings.userId, input.userId)); + .where(and( + eq(irohEndpointBindings.userId, input.userId), + isNull(irohEndpointBindings.revokedAt), + lte(irohEndpointBindings.pathHintsNextExpiry, input.now), + )) + .limit(IROH_RETENTION_BATCH_SIZE) + .for("update"); for (const binding of bindings) { - const retained = binding.revokedAt - ? [] - : binding.pathHints.filter((hint) => isUnexpiredStoredHint(hint, input.now)); - if (retained.length !== binding.pathHints.length) { - await tx - .update(irohEndpointBindings) - .set({ pathHints: retained, updatedAt: input.now }) - .where(eq(irohEndpointBindings.id, binding.id)); - } + const retained = retainedStoredHints(binding.pathHints, input.now); + await tx + .update(irohEndpointBindings) + .set({ + pathHints: retained, + pathHintsNextExpiry: nextPathHintExpiry(retained), + updatedAt: input.now, + }) + .where(eq(irohEndpointBindings.id, binding.id)); } const challengeRetentionCutoff = new Date(input.now.getTime() - 24 * 60 * 60 * 1_000); - await tx - .delete(irohRegistrationChallenges) - .where(and( - eq(irohRegistrationChallenges.userId, input.userId), - or( - lt(irohRegistrationChallenges.expiresAt, challengeRetentionCutoff), - and( - isNotNull(irohRegistrationChallenges.consumedAt), - lt(irohRegistrationChallenges.consumedAt, challengeRetentionCutoff), - ), - ), - )); - const auditRetentionCutoff = new Date(input.now.getTime() - 30 * 24 * 60 * 60 * 1_000); - await tx - .delete(irohRelayTokenIssuances) - .where(and( - eq(irohRelayTokenIssuances.userId, input.userId), - lt(irohRelayTokenIssuances.requestedAt, auditRetentionCutoff), - )); - await tx - .delete(irohPairGrantIssuances) - .where(and( - eq(irohPairGrantIssuances.userId, input.userId), - lt(irohPairGrantIssuances.expiresAt, auditRetentionCutoff), - )); await tx.execute(sql` - delete from iroh_endpoint_bindings as binding - where binding.user_id = ${input.userId} - and binding.revoked_at < ${auditRetentionCutoff.toISOString()}::timestamptz + with candidates as materialized ( + select id + from iroh_registration_challenges + where user_id = ${input.userId} + and expires_at < ${challengeRetentionCutoff.toISOString()}::timestamptz + order by expires_at, id + limit ${IROH_RETENTION_BATCH_SIZE} + for update skip locked + ) + delete from iroh_registration_challenges as challenge + using candidates + where challenge.id = candidates.id + `); + await tx.execute(sql` + with candidates as materialized ( + select id + from iroh_registration_challenges + where user_id = ${input.userId} + and consumed_at < ${challengeRetentionCutoff.toISOString()}::timestamptz + order by consumed_at, id + limit ${IROH_RETENTION_BATCH_SIZE} + for update skip locked + ) + delete from iroh_registration_challenges as challenge + using candidates + where challenge.id = candidates.id + `); + await tx.execute(sql` + with candidates as materialized ( + select id + from iroh_relay_token_issuances + where user_id = ${input.userId} + and requested_at < ${auditRetentionCutoff.toISOString()}::timestamptz + order by requested_at, id + limit ${IROH_RETENTION_BATCH_SIZE} + for update skip locked + ) + delete from iroh_relay_token_issuances as issuance + using candidates + where issuance.id = candidates.id + `); + await tx.execute(sql` + with candidates as materialized ( + select id + from iroh_pair_grant_issuances + where user_id = ${input.userId} + and expires_at < ${auditRetentionCutoff.toISOString()}::timestamptz + order by expires_at, id + limit ${IROH_RETENTION_BATCH_SIZE} + for update skip locked + ) + delete from iroh_pair_grant_issuances as issuance + using candidates + where issuance.id = candidates.id + `); + await tx.execute(sql` + with candidates as materialized ( + select binding.id + from iroh_endpoint_bindings as binding + where binding.user_id = ${input.userId} + and binding.revoked_at < ${auditRetentionCutoff.toISOString()}::timestamptz and not exists ( select 1 from iroh_pair_grant_issuances as pair_grant where pair_grant.initiator_binding_id = binding.id @@ -449,6 +519,13 @@ function makeLiveRepository(): IrohRepositoryShape { select 1 from iroh_relay_token_issuances as issuance where issuance.binding_id = binding.id ) + order by binding.revoked_at, binding.id + limit ${IROH_RETENTION_BATCH_SIZE} + for update skip locked + ) + delete from iroh_endpoint_bindings as binding + using candidates + where binding.id = candidates.id `); }); }), @@ -458,55 +535,126 @@ function makeLiveRepository(): IrohRepositoryShape { const auditRetentionCutoff = new Date(input.now.getTime() - 30 * 24 * 60 * 60 * 1_000); await cloudDb().transaction(async (tx) => { await tx.execute(sql` + with candidates as materialized ( + select id + from iroh_endpoint_bindings + where revoked_at is not null + and path_hints_next_expiry is not null + order by revoked_at, id + limit ${IROH_RETENTION_BATCH_SIZE} + for update skip locked + ) update iroh_endpoint_bindings as binding - set - path_hints = coalesce(( - select jsonb_agg(hint) - from jsonb_array_elements(binding.path_hints) as hints(hint) - where binding.revoked_at is null - and case - when jsonb_typeof(hint) = 'object' - and jsonb_typeof(hint -> 'expires_at') = 'string' - and (hint ->> 'expires_at') ~ '^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$' - then (hint ->> 'expires_at') > ${input.now.toISOString()} - else false - end - ), '[]'::jsonb), - updated_at = ${input.now.toISOString()}::timestamptz - where binding.path_hints <> '[]'::jsonb - and ( - binding.revoked_at is not null - or exists ( - select 1 - from jsonb_array_elements(binding.path_hints) as candidates(candidate) - where case - when jsonb_typeof(candidate) = 'object' - and jsonb_typeof(candidate -> 'expires_at') = 'string' - and (candidate ->> 'expires_at') ~ '^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$' - then (candidate ->> 'expires_at') <= ${input.now.toISOString()} - else true - end - ) - ) + set path_hints = '[]'::jsonb, + path_hints_next_expiry = null, + updated_at = ${input.now.toISOString()}::timestamptz + from candidates + where binding.id = candidates.id `); await tx - .delete(irohRegistrationChallenges) - .where(or( - lt(irohRegistrationChallenges.expiresAt, challengeRetentionCutoff), - and( - isNotNull(irohRegistrationChallenges.consumedAt), - lt(irohRegistrationChallenges.consumedAt, challengeRetentionCutoff), - ), - )); - await tx - .delete(irohRelayTokenIssuances) - .where(lt(irohRelayTokenIssuances.requestedAt, auditRetentionCutoff)); - await tx - .delete(irohPairGrantIssuances) - .where(lt(irohPairGrantIssuances.expiresAt, auditRetentionCutoff)); + .execute(sql` + with candidates as materialized ( + select id + from iroh_endpoint_bindings + where revoked_at is null + and path_hints_next_expiry <= ${input.now.toISOString()}::timestamptz + order by path_hints_next_expiry, id + limit ${IROH_RETENTION_BATCH_SIZE} + for update skip locked + ), retained as ( + select + binding.id, + coalesce( + jsonb_agg(entry.hint order by entry.ordinality) filter ( + where case + when jsonb_typeof(entry.hint) = 'object' + and jsonb_typeof(entry.hint -> 'expires_at') = 'string' + and (entry.hint ->> 'expires_at') ~ '^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$' + then (entry.hint ->> 'expires_at')::timestamptz > ${input.now.toISOString()}::timestamptz + else false + end + ), + '[]'::jsonb + ) as path_hints + from candidates + join iroh_endpoint_bindings as binding on binding.id = candidates.id + left join lateral jsonb_array_elements(binding.path_hints) + with ordinality as entry(hint, ordinality) on true + group by binding.id + ), normalized as ( + select + retained.id, + retained.path_hints, + ( + select min((hint ->> 'expires_at')::timestamptz) + from jsonb_array_elements(retained.path_hints) as hints(hint) + ) as next_expiry + from retained + ) + update iroh_endpoint_bindings as binding + set path_hints = normalized.path_hints, + path_hints_next_expiry = normalized.next_expiry, + updated_at = ${input.now.toISOString()}::timestamptz + from normalized + where binding.id = normalized.id + `); await tx.execute(sql` - delete from iroh_endpoint_bindings as binding - where binding.revoked_at < ${auditRetentionCutoff.toISOString()}::timestamptz + with candidates as materialized ( + select id + from iroh_registration_challenges + where expires_at < ${challengeRetentionCutoff.toISOString()}::timestamptz + order by expires_at, id + limit ${IROH_RETENTION_BATCH_SIZE} + for update skip locked + ) + delete from iroh_registration_challenges as challenge + using candidates + where challenge.id = candidates.id + `); + await tx.execute(sql` + with candidates as materialized ( + select id + from iroh_registration_challenges + where consumed_at < ${challengeRetentionCutoff.toISOString()}::timestamptz + order by consumed_at, id + limit ${IROH_RETENTION_BATCH_SIZE} + for update skip locked + ) + delete from iroh_registration_challenges as challenge + using candidates + where challenge.id = candidates.id + `); + await tx.execute(sql` + with candidates as materialized ( + select id + from iroh_relay_token_issuances + where requested_at < ${auditRetentionCutoff.toISOString()}::timestamptz + order by requested_at, id + limit ${IROH_RETENTION_BATCH_SIZE} + for update skip locked + ) + delete from iroh_relay_token_issuances as issuance + using candidates + where issuance.id = candidates.id + `); + await tx.execute(sql` + with candidates as materialized ( + select id + from iroh_pair_grant_issuances + where expires_at < ${auditRetentionCutoff.toISOString()}::timestamptz + order by expires_at, id + limit ${IROH_RETENTION_BATCH_SIZE} + for update skip locked + ) + delete from iroh_pair_grant_issuances as issuance + using candidates + where issuance.id = candidates.id + `); + await tx.execute(sql` + with candidates as materialized ( + select binding.id + from iroh_endpoint_bindings as binding + where binding.revoked_at < ${auditRetentionCutoff.toISOString()}::timestamptz and not exists ( select 1 from iroh_pair_grant_issuances as pair_grant where pair_grant.initiator_binding_id = binding.id @@ -516,13 +664,44 @@ function makeLiveRepository(): IrohRepositoryShape { select 1 from iroh_relay_token_issuances as issuance where issuance.binding_id = binding.id ) + order by binding.revoked_at, binding.id + limit ${IROH_RETENTION_BATCH_SIZE} + for update skip locked + ) + delete from iroh_endpoint_bindings as binding + using candidates + where binding.id = candidates.id `); }); }), recordPairGrant: (input) => repositoryEffect("record_pair_grant", async () => { await cloudDb().transaction(async (tx) => { + await assertIrohUserMutationAllowed(tx, input.userId); + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`iroh:binding:${input.userId}`}, 0))`); await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`iroh:pair-grant:${input.userId}`}, 0))`); + const peers = await tx + .select() + .from(irohEndpointBindings) + .where(and( + eq(irohEndpointBindings.userId, input.userId), + inArray(irohEndpointBindings.id, [input.initiator.bindingId, input.acceptor.bindingId]), + isNull(irohEndpointBindings.revokedAt), + )) + .for("update"); + const byId = new Map(peers.map((peer) => [peer.id, peer])); + const initiator = byId.get(input.initiator.bindingId); + const acceptor = byId.get(input.acceptor.bindingId); + if (!initiator || !acceptor) throw new IrohNotFoundError({ resource: "binding" }); + if ( + !bindingMatchesGrantPeer(initiator, input.initiator) || + !bindingMatchesGrantPeer(acceptor, input.acceptor) + ) { + throw new IrohConflictError({ code: "binding_changed_during_grant" }); + } + if (initiator.platform !== "ios" || acceptor.platform !== "mac" || !acceptor.pairingEnabled) { + throw new IrohForbiddenError({ code: "target_not_pairable" }); + } const hourAgo = new Date(input.issuedAt.getTime() - 60 * 60 * 1_000); const recent = await tx .select({ issuedAt: irohPairGrantIssuances.issuedAt }) @@ -540,12 +719,25 @@ function makeLiveRepository(): IrohRepositoryShape { input.issuedAt, ); } - await tx.insert(irohPairGrantIssuances).values(input); + await tx.insert(irohPairGrantIssuances).values({ + userId: input.userId, + jti: input.jti, + initiatorBindingId: input.initiator.bindingId, + acceptorBindingId: input.acceptor.bindingId, + signingKeyId: input.signingKeyId, + alpn: input.alpn, + scope: input.scope, + issuedAt: input.issuedAt, + notBefore: input.notBefore, + expiresAt: input.expiresAt, + }); }); }), reserveRelayIssuance: (input) => repositoryEffect("reserve_relay_issuance", async () => { return await cloudDb().transaction(async (tx) => { + await assertIrohUserMutationAllowed(tx, input.userId); + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`iroh:binding:${input.userId}`}, 0))`); await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`iroh:relay:${input.userId}`}, 0))`); const [binding] = await tx .select() @@ -604,23 +796,76 @@ function makeLiveRepository(): IrohRepositoryShape { }), completeRelayIssuance: (input) => repositoryEffect("complete_relay_issuance", async () => { - await cloudDb() - .update(irohRelayTokenIssuances) - .set({ - status: "succeeded", - tokenHash: input.tokenHash, - completedAt: input.completedAt, - expiresAt: input.expiresAt, - failureCode: null, - }) - .where(eq(irohRelayTokenIssuances.id, input.issuanceId)); + return await cloudDb().transaction(async (tx) => { + await assertIrohUserMutationAllowed(tx, input.userId); + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`iroh:binding:${input.userId}`}, 0))`); + const [issuance] = await tx + .select() + .from(irohRelayTokenIssuances) + .where(and( + eq(irohRelayTokenIssuances.id, input.issuanceId), + eq(irohRelayTokenIssuances.userId, input.userId), + eq(irohRelayTokenIssuances.bindingId, input.bindingId), + eq(irohRelayTokenIssuances.status, "pending"), + )) + .for("update") + .limit(1); + if (!issuance) return false; + const [binding] = await tx + .select({ endpointId: irohEndpointBindings.endpointId }) + .from(irohEndpointBindings) + .where(and( + eq(irohEndpointBindings.id, input.bindingId), + eq(irohEndpointBindings.userId, input.userId), + isNull(irohEndpointBindings.revokedAt), + )) + .for("update") + .limit(1); + if ( + !binding || + binding.endpointId !== input.endpointId || + issuance.endpointIdHash !== sha256(input.endpointId) + ) { + await tx + .update(irohRelayTokenIssuances) + .set({ + status: "failed", + completedAt: input.completedAt, + failureCode: "binding_inactive_after_mint", + }) + .where(eq(irohRelayTokenIssuances.id, input.issuanceId)); + return false; + } + const completed = await tx + .update(irohRelayTokenIssuances) + .set({ + status: "succeeded", + tokenHash: input.tokenHash, + completedAt: input.completedAt, + expiresAt: input.expiresAt, + failureCode: null, + }) + .where(and( + eq(irohRelayTokenIssuances.id, input.issuanceId), + eq(irohRelayTokenIssuances.status, "pending"), + )) + .returning({ id: irohRelayTokenIssuances.id }); + return completed.length === 1; + }); }), failRelayIssuance: (input) => repositoryEffect("fail_relay_issuance", async () => { - await cloudDb() - .update(irohRelayTokenIssuances) - .set({ status: "failed", completedAt: input.completedAt, failureCode: input.failureCode.slice(0, 64) }) - .where(eq(irohRelayTokenIssuances.id, input.issuanceId)); + await cloudDb().transaction(async (tx) => { + await assertIrohUserMutationAllowed(tx, input.userId); + await tx + .update(irohRelayTokenIssuances) + .set({ status: "failed", completedAt: input.completedAt, failureCode: input.failureCode.slice(0, 64) }) + .where(and( + eq(irohRelayTokenIssuances.id, input.issuanceId), + eq(irohRelayTokenIssuances.userId, input.userId), + eq(irohRelayTokenIssuances.status, "pending"), + )); + }); }), }; } @@ -699,11 +944,35 @@ function databaseCause(cause: unknown): { return null; } -function isUnexpiredStoredHint(hint: unknown, now: Date): boolean { +async function assertIrohUserMutationAllowed( + tx: CloudDbTransaction, + userId: string, +): Promise { try { - parseIrohPathHint(hint, now); - return true; - } catch { - return false; + await assertAccountDeletionUserMutationAllowed(tx, userId); + } catch (error) { + if (error instanceof AccountDeletionMutationBlockedError) { + throw new IrohConflictError({ code: "account_deletion_in_progress" }); + } + throw error; } } + +function retainedStoredHints(pathHints: readonly unknown[], now: Date): IrohPathHint[] { + return pathHints.flatMap((hint): IrohPathHint[] => { + try { + return [parseIrohPathHint(hint, now)]; + } catch { + return []; + } + }); +} + +function bindingMatchesGrantPeer(binding: IrohBindingRecord, peer: PairGrantPeer): boolean { + return binding.id === peer.bindingId && + binding.deviceUuid === peer.deviceId && + binding.tag === peer.tag && + binding.platform === peer.platform && + binding.endpointId === peer.endpointId && + binding.identityGeneration === peer.identityGeneration; +} diff --git a/web/services/iroh/trustBroker.ts b/web/services/iroh/trustBroker.ts index 8ba47394c8a0..bf4665aba128 100644 --- a/web/services/iroh/trustBroker.ts +++ b/web/services/iroh/trustBroker.ts @@ -126,20 +126,28 @@ export function makeIrohTrustBroker( }).pipe( Effect.matchEffect({ onFailure: (error) => repository.failRelayIssuance({ + userId, issuanceId: reservation.issuanceId, completedAt: new Date(), failureCode: error._tag === "IrohRelayMintError" ? error.code : "not_configured", - }).pipe(Effect.flatMap(() => Effect.fail(error))), + }).pipe( + Effect.catchAll(() => Effect.void), + Effect.flatMap(() => Effect.fail(error)), + ), onSuccess: Effect.succeed, }), ); const completedAt = new Date(); - yield* repository.completeRelayIssuance({ + const completed = yield* repository.completeRelayIssuance({ + userId, issuanceId: reservation.issuanceId, + bindingId: reservation.binding.id, + endpointId: reservation.binding.endpointId, tokenHash: sha256(minted.token), completedAt, expiresAt: minted.expiresAt, }); + if (!completed) return yield* Effect.fail(new IrohNotFoundError({ resource: "binding" })); return { token: minted.token, expires_at: minted.expiresAt.toISOString(), @@ -248,7 +256,7 @@ export function makeIrohTrustBroker( const initiator = byId.get(request.initiatorBindingId); const acceptor = byId.get(request.acceptorBindingId); if (!initiator || !acceptor) return yield* Effect.fail(new IrohNotFoundError({ resource: "binding" })); - if (acceptor.platform !== "mac" || !acceptor.pairingEnabled) { + if (initiator.platform !== "ios" || acceptor.platform !== "mac" || !acceptor.pairingEnabled) { return yield* Effect.fail(new IrohForbiddenError({ code: "target_not_pairable" })); } const issuedAtSeconds = Math.floor(now.getTime() / 1_000); @@ -282,8 +290,8 @@ export function makeIrohTrustBroker( yield* repository.recordPairGrant({ userId, jti: claims.jti, - initiatorBindingId: initiator.id, - acceptorBindingId: acceptor.id, + initiator: claims.initiator, + acceptor: claims.acceptor, signingKeyId, alpn: IROH_ALPN, scope: IROH_PAIR_SCOPE, @@ -409,6 +417,7 @@ function grantPeer(binding: IrohBindingRecord): PairGrantPeer { bindingId: binding.id, deviceId: binding.deviceUuid, tag: binding.tag, + platform: bindingPlatform(binding), endpointId: binding.endpointId, identityGeneration: binding.identityGeneration, }; diff --git a/web/tests/iroh-db-behavior.test.ts b/web/tests/iroh-db-behavior.test.ts index f65084ddbd44..674b5df67eba 100644 --- a/web/tests/iroh-db-behavior.test.ts +++ b/web/tests/iroh-db-behavior.test.ts @@ -3,8 +3,13 @@ import { randomUUID } from "node:crypto"; import * as Effect from "effect/Effect"; import postgres, { type Sql } from "postgres"; import { closeCloudDbForTests } from "../db/client"; -import { accountDeletionUserHash } from "../services/account/deletionLock"; import { + accountDeletionAdvisoryLockKey, + accountDeletionUserHash, +} from "../services/account/deletionLock"; +import type { PairGrantPeer } from "../services/iroh/crypto"; +import { + IROH_RETENTION_BATCH_SIZE, IrohRepository, IrohRepositoryLive, type IrohRepositoryShape, @@ -51,23 +56,34 @@ afterAll(async () => { describe("Iroh trust broker database behavior", () => { dbTest("blocks new trust state once account deletion wins the account fence", async () => { const userId = "user-deleting"; - await requiredSql()` - insert into account_deletion_tombstones (user_id_hash, user_id, status, updated_at) - values (${accountDeletionUserHash(userId)}, ${userId}, 'pending', ${NOW}) - `; + let mutation: ReturnType | undefined; + await requiredSql().begin(async (deletionSql) => { + await deletionSql` + select pg_advisory_xact_lock( + hashtextextended(${accountDeletionAdvisoryLockKey(userId)}, 0) + ) + `; + await deletionSql` + insert into account_deletion_tombstones (user_id_hash, user_id, status, updated_at) + values (${accountDeletionUserHash(userId)}, ${userId}, 'pending', now()) + `; + mutation = Effect.runPromiseExit(requiredRepository().issueChallenge({ + userId, + deviceUuid: randomUUID(), + appInstanceId: randomUUID(), + tag: "stable", + endpointId: "09".repeat(32), + identityGeneration: 1, + payloadSha256: "08".repeat(32), + nonceHash: "07".repeat(32), + now: NOW, + expiresAt: new Date(NOW.getTime() + 5 * 60 * 1_000), + })); + await waitForAdvisoryLockWaiter(); + }); - const exit = await Effect.runPromiseExit(requiredRepository().issueChallenge({ - userId, - deviceUuid: randomUUID(), - appInstanceId: randomUUID(), - tag: "stable", - endpointId: "09".repeat(32), - identityGeneration: 1, - payloadSha256: "08".repeat(32), - nonceHash: "07".repeat(32), - now: NOW, - expiresAt: new Date(NOW.getTime() + 5 * 60 * 1_000), - })); + if (!mutation) throw new Error("mutation was not started"); + const exit = await mutation; expect(exit._tag).toBe("Failure"); expect(String(exit)).toContain("account_deletion_in_progress"); @@ -77,12 +93,147 @@ describe("Iroh trust broker database behavior", () => { expect(total).toBe("0"); }); + dbTest("lets an earlier trust transaction finish before deletion removes it", async () => { + const userId = "user-mutation-first"; + let deletion: Promise | undefined; + await requiredSql().begin(async (mutationSql) => { + await mutationSql` + select pg_advisory_xact_lock( + hashtextextended(${accountDeletionAdvisoryLockKey(userId)}, 0) + ) + `; + await mutationSql` + insert into iroh_registration_challenges ( + user_id, device_uuid, app_instance_id, tag, endpoint_id, + identity_generation, payload_sha256, nonce_hash, created_at, expires_at + ) values ( + ${userId}, ${randomUUID()}, ${randomUUID()}, 'stable', ${"0a".repeat(32)}, + 1, ${"0b".repeat(32)}, ${"0c".repeat(32)}, now(), now() + interval '5 minutes' + ) + `; + deletion = requiredSql().begin(async (deletionSql) => { + await deletionSql` + select pg_advisory_xact_lock( + hashtextextended(${accountDeletionAdvisoryLockKey(userId)}, 0) + ) + `; + await deletionSql` + insert into account_deletion_tombstones (user_id_hash, user_id, status, updated_at) + values (${accountDeletionUserHash(userId)}, ${userId}, 'pending', now()) + `; + await deletionSql`delete from iroh_registration_challenges where user_id = ${userId}`; + }); + await waitForAdvisoryLockWaiter(); + const [{ total }] = await mutationSql>` + select count(*)::text as total + from iroh_registration_challenges + where user_id = ${userId} + `; + expect(total).toBe("1"); + }); + if (!deletion) throw new Error("deletion was not started"); + await deletion; + const [{ total }] = await requiredSql()>` + select count(*)::text as total + from iroh_registration_challenges + where user_id = ${userId} + `; + expect(total).toBe("0"); + }); + + dbTest("fences binding authorization, grants, relay completion, and cleanup during deletion", async () => { + const userId = "user-deletion-fences"; + const iosId = await insertBinding({ + userId, + platform: "ios", + endpointId: "0d".repeat(32), + }); + const macId = await insertBinding({ + userId, + platform: "mac", + endpointId: "0e".repeat(32), + }); + const ios = await pairPeer(iosId); + const mac = await pairPeer(macId); + const [issuance] = await requiredSql()>` + insert into iroh_relay_token_issuances ( + user_id, binding_id, endpoint_id_hash, status, requested_at + ) values (${userId}, ${macId}, ${"0f".repeat(32)}, 'pending', ${NOW}) + returning id::text + `; + if (!issuance) throw new Error("issuance insert failed"); + await requiredSql()` + insert into account_deletion_tombstones (user_id_hash, user_id, status, updated_at) + values (${accountDeletionUserHash(userId)}, ${userId}, 'pending', now()) + `; + + const repository = requiredRepository(); + const operations: Array> = [ + repository.findActiveBindings(userId, [iosId, macId]), + repository.revokeBinding({ userId, bindingId: macId, now: NOW }), + repository.accountLanGeneration({ userId, now: NOW }), + repository.pruneExpiredState({ userId, now: NOW }), + repository.recordPairGrant({ + userId, + jti: randomUUID(), + initiator: ios, + acceptor: mac, + signingKeyId: "current", + alpn: "cmux/mobile/1", + scope: "cmux.mobile.attach", + issuedAt: NOW, + notBefore: NOW, + expiresAt: new Date(NOW.getTime() + 7 * 24 * 60 * 60 * 1_000), + }), + repository.reserveRelayIssuance({ userId, bindingId: macId, now: NOW }), + repository.completeRelayIssuance({ + userId, + issuanceId: issuance.id, + bindingId: macId, + endpointId: mac.endpointId, + tokenHash: "10".repeat(32), + completedAt: NOW, + expiresAt: new Date(NOW.getTime() + 24 * 60 * 60 * 1_000), + }), + repository.failRelayIssuance({ + userId, + issuanceId: issuance.id, + completedAt: NOW, + failureCode: "test_failure", + }), + ]; + for (const operation of operations) { + const exit = await Effect.runPromiseExit(operation); + expect(exit._tag).toBe("Failure"); + expect(String(exit)).toContain("account_deletion_in_progress"); + } + const [state] = await requiredSql()>` + select + exists(select 1 from iroh_endpoint_bindings where id = ${macId} and revoked_at is not null) as revoked, + (select count(*)::text from iroh_pair_grant_issuances where user_id = ${userId}) as grants, + (select status from iroh_relay_token_issuances where id = ${issuance.id}) as "issuanceStatus", + (select count(*)::text from iroh_account_security_states where user_id = ${userId}) as "securityStates" + `; + expect(state).toEqual({ + revoked: false, + grants: "0", + issuanceStatus: "pending", + securityStates: "0", + }); + }); + dbTest("atomically consumes a challenge exactly once under concurrency", async () => { const repo = requiredRepository(); const deviceId = randomUUID(); const appInstanceId = randomUUID(); const endpointId = "10".repeat(32); const nonceHash = "20".repeat(32); + const pathHintExpiry = new Date(NOW.getTime() + 30 * 60 * 1_000); const challenge = await Effect.runPromise(repo.issueChallenge({ userId: "user-registration", deviceUuid: deviceId, @@ -109,7 +260,15 @@ describe("Iroh trust broker database behavior", () => { identityGeneration: 1, pairingEnabled: true, capabilities: [], - pathHints: [], + pathHints: [{ + kind: "direct_address", + value: "10.0.0.20:4433", + source: "lan", + privacy_scope: "local_network", + observed_at: new Date(NOW.getTime() - 5 * 60 * 1_000).toISOString(), + expires_at: pathHintExpiry.toISOString(), + network_profile: { source: "lan", profile_id: "local" }, + }], }, now: NOW, deviceLimitOverrideAllowed: false, @@ -117,12 +276,18 @@ describe("Iroh trust broker database behavior", () => { const results = await Promise.allSettled([register(), register()]); expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1); expect(results.filter((result) => result.status === "rejected")).toHaveLength(1); - const [{ bindings, consumed }] = await requiredSql()>` + const [{ bindings, consumed, nextExpiry }] = await requiredSql()>` select (select count(*)::text from iroh_endpoint_bindings) as bindings, - (select count(*)::text from iroh_registration_challenges where consumed_at is not null) as consumed + (select count(*)::text from iroh_registration_challenges where consumed_at is not null) as consumed, + (select path_hints_next_expiry from iroh_endpoint_bindings limit 1) as "nextExpiry" `; expect({ bindings, consumed }).toEqual({ bindings: "1", consumed: "1" }); + expect(nextExpiry?.getTime()).toBe(pathHintExpiry.getTime()); }); dbTest("enforces globally unique active EndpointIDs and app instances", async () => { @@ -139,6 +304,13 @@ describe("Iroh trust broker database behavior", () => { 'user-a', ${randomUUID()}, ${randomUUID()}, 'stable', 'linux', ${"42".repeat(32)}, 1 ) `, "23514"); + await expectPostgresError(requiredSql()` + insert into iroh_endpoint_bindings ( + user_id, device_uuid, app_instance_id, tag, platform, endpoint_id, identity_generation + ) values ( + 'user-a', ${randomUUID()}, ${randomUUID()}, 'stable', 'mac', ${"43".repeat(32)}, 2147483648 + ) + `, "22003"); }); dbTest("serializes the pair-grant hourly quota", async () => { @@ -159,11 +331,13 @@ describe("Iroh trust broker database behavior", () => { ) `; } + const initiator = await pairPeer(initiatorId); + const acceptor = await pairPeer(acceptorId); const reserve = () => Effect.runPromise(repo.recordPairGrant({ userId: "user-pair", jti: randomUUID(), - initiatorBindingId: initiatorId, - acceptorBindingId: acceptorId, + initiator, + acceptor, signingKeyId: "current", alpn: "cmux/mobile/1", scope: "cmux.mobile.attach", @@ -180,6 +354,45 @@ describe("Iroh trust broker database behavior", () => { expect(total).toBe("60"); }); + dbTest("revalidates pairability and exact signed peers inside the grant transaction", async () => { + const initiatorId = await insertBinding({ + userId: "user-pair-race", + platform: "ios", + endpointId: "52".repeat(32), + }); + const acceptorId = await insertBinding({ + userId: "user-pair-race", + platform: "mac", + endpointId: "53".repeat(32), + }); + const initiator = await pairPeer(initiatorId); + const acceptor = await pairPeer(acceptorId); + await requiredSql()` + update iroh_endpoint_bindings + set pairing_enabled = false + where id = ${acceptorId} + `; + const exit = await Effect.runPromiseExit(requiredRepository().recordPairGrant({ + userId: "user-pair-race", + jti: randomUUID(), + initiator, + acceptor, + signingKeyId: "current", + alpn: "cmux/mobile/1", + scope: "cmux.mobile.attach", + issuedAt: NOW, + notBefore: NOW, + expiresAt: new Date(NOW.getTime() + 7 * 24 * 60 * 60 * 1_000), + })); + expect(exit._tag).toBe("Failure"); + const [{ total }] = await requiredSql()>` + select count(*)::text as total + from iroh_pair_grant_issuances + where user_id = 'user-pair-race' + `; + expect(total).toBe("0"); + }); + dbTest("serializes relay quota reservations before provider work", async () => { const repo = requiredRepository(); const bindingId = await insertBinding({ userId: "user-relay", endpointId: "60".repeat(32) }); @@ -207,6 +420,40 @@ describe("Iroh trust broker database behavior", () => { expect(total).toBe("3"); }); + dbTest("fails relay finalization when revocation commits during provider mint", async () => { + const repo = requiredRepository(); + const endpointId = "61".repeat(32); + const bindingId = await insertBinding({ userId: "user-relay-race", endpointId }); + const reservation = await Effect.runPromise(repo.reserveRelayIssuance({ + userId: "user-relay-race", + bindingId, + now: NOW, + })); + expect(await Effect.runPromise(repo.revokeBinding({ + userId: "user-relay-race", + bindingId, + now: new Date(NOW.getTime() + 1_000), + }))).toBe(true); + expect(await Effect.runPromise(repo.completeRelayIssuance({ + userId: "user-relay-race", + issuanceId: reservation.issuanceId, + bindingId, + endpointId, + tokenHash: "62".repeat(32), + completedAt: new Date(NOW.getTime() + 2_000), + expiresAt: new Date(NOW.getTime() + 24 * 60 * 60 * 1_000), + }))).toBe(false); + const [issuance] = await requiredSql()>` + select status, failure_code as "failureCode" + from iroh_relay_token_issuances + where id = ${reservation.issuanceId} + `; + expect(issuance).toEqual({ + status: "failed", + failureCode: "binding_inactive_after_mint", + }); + }); + dbTest("global retention clears revoked hints and expired private data from Aurora", async () => { const repo = requiredRepository(); const activeId = await insertBinding({ @@ -271,6 +518,86 @@ describe("Iroh trust broker database behavior", () => { expect(retentionState?.untouchedUpdatedAt.getTime()).toBe(untouchedBefore?.updatedAt.getTime()); }); + dbTest("retention skips locked hint rows and bounds each indexed batch", async () => { + const lockedId = await insertBinding({ + userId: "user-retention-lock", + endpointId: "84".repeat(32), + pathHints: [storedLanHint("10.0.0.10:4433", "2026-07-09T18:55:00.000Z", "2026-07-09T19:00:00.000Z")], + }); + const unlockedId = await insertBinding({ + userId: "user-retention-lock", + endpointId: "85".repeat(32), + pathHints: [storedLanHint("10.0.0.11:4433", "2026-07-09T18:55:00.000Z", "2026-07-09T19:00:00.000Z")], + }); + await requiredSql().begin(async (lockingSql) => { + await lockingSql`select id from iroh_endpoint_bindings where id = ${lockedId} for update`; + await Effect.runPromise(requiredRepository().pruneExpiredStateGlobally({ now: NOW })); + const rows = await lockingSql>` + select id::text, jsonb_array_length(path_hints)::int as hints + from iroh_endpoint_bindings + where id in (${lockedId}, ${unlockedId}) + `; + expect(rows.find((row) => row.id === lockedId)?.hints).toBe(1); + expect(rows.find((row) => row.id === unlockedId)?.hints).toBe(0); + }); + await Effect.runPromise(requiredRepository().pruneExpiredStateGlobally({ now: NOW })); + const [locked] = await requiredSql()>` + select + jsonb_array_length(path_hints)::int as hints, + path_hints_next_expiry as "nextExpiry" + from iroh_endpoint_bindings + where id = ${lockedId} + `; + expect(locked).toEqual({ hints: 0, nextExpiry: null }); + + await requiredSql()` + insert into iroh_registration_challenges ( + user_id, device_uuid, app_instance_id, tag, endpoint_id, + identity_generation, payload_sha256, nonce_hash, created_at, expires_at + ) + select + 'user-retention-batch', gen_random_uuid(), gen_random_uuid(), 'stable', + repeat('86', 32), 1, + md5('payload-a-' || value::text) || md5('payload-b-' || value::text), + md5('nonce-a-' || value::text) || md5('nonce-b-' || value::text), + ${new Date(NOW.getTime() - 3 * 24 * 60 * 60 * 1_000)}, + ${new Date(NOW.getTime() - 2 * 24 * 60 * 60 * 1_000)} + from generate_series(1, ${IROH_RETENTION_BATCH_SIZE + 1}) as values(value) + `; + await Effect.runPromise(requiredRepository().pruneExpiredStateGlobally({ now: NOW })); + const [{ remaining }] = await requiredSql()>` + select count(*)::text as remaining + from iroh_registration_challenges + where user_id = 'user-retention-batch' + `; + expect(remaining).toBe("1"); + + await requiredSql()` + insert into iroh_registration_challenges ( + user_id, device_uuid, app_instance_id, tag, endpoint_id, + identity_generation, payload_sha256, nonce_hash, created_at, expires_at + ) + select + 'user-retention-scoped', gen_random_uuid(), gen_random_uuid(), 'stable', + repeat('87', 32), 1, + md5('scoped-payload-a-' || value::text) || md5('scoped-payload-b-' || value::text), + md5('scoped-nonce-a-' || value::text) || md5('scoped-nonce-b-' || value::text), + ${new Date(NOW.getTime() - 3 * 24 * 60 * 60 * 1_000)}, + ${new Date(NOW.getTime() - 2 * 24 * 60 * 60 * 1_000)} + from generate_series(1, ${IROH_RETENTION_BATCH_SIZE + 1}) as values(value) + `; + await Effect.runPromise(requiredRepository().pruneExpiredState({ + userId: "user-retention-scoped", + now: NOW, + })); + const [{ scopedRemaining }] = await requiredSql()>` + select count(*)::text as "scopedRemaining" + from iroh_registration_challenges + where user_id = 'user-retention-scoped' + `; + expect(scopedRemaining).toBe("1"); + }); + dbTest("binding deletion cascades grant and relay audit rows", async () => { const bindingId = await insertBinding({ userId: "user-delete", endpointId: "90".repeat(32) }); const peerId = await insertBinding({ userId: "user-delete", endpointId: "91".repeat(32) }); @@ -308,17 +635,50 @@ async function insertBinding(input: { const [row] = await requiredSql()>` insert into iroh_endpoint_bindings ( user_id, device_uuid, app_instance_id, tag, platform, endpoint_id, - identity_generation, pairing_enabled, capabilities, path_hints + identity_generation, pairing_enabled, capabilities, path_hints, + path_hints_next_expiry ) values ( ${input.userId}, ${randomUUID()}, ${input.appInstanceId ?? randomUUID()}, 'stable', ${input.platform ?? "mac"}, ${input.endpointId}, 1, true, '[]'::jsonb, - ${requiredSql().json((input.pathHints ?? []) as never)} + ${requiredSql().json((input.pathHints ?? []) as never)}, + ${earliestStoredHintExpiry(input.pathHints ?? [])} ) returning id::text `; if (!row) throw new Error("binding insert returned no row"); return row.id; } +async function pairPeer(bindingId: string): Promise { + const [row] = await requiredSql()>` + select + id::text as "bindingId", + device_uuid::text as "deviceId", + tag, + platform, + endpoint_id as "endpointId", + identity_generation as "identityGeneration" + from iroh_endpoint_bindings + where id = ${bindingId} + `; + if (!row) throw new Error("binding not found"); + return row; +} + +function earliestStoredHintExpiry(pathHints: readonly unknown[]): Date | null { + const expiries = pathHints.flatMap((hint) => { + const value = (hint as { expires_at?: unknown } | null)?.expires_at; + return typeof value === "string" ? [new Date(value).getTime()] : []; + }); + return expiries.length > 0 ? new Date(Math.min(...expiries)) : null; +} + function requiredSql(): Sql { if (!sql) throw new Error("test database not initialized"); return sql; @@ -350,3 +710,19 @@ async function expectPostgresError(promise: Promise, expectedCode: stri } throw new Error(`expected Postgres error ${expectedCode}`); } + +async function waitForAdvisoryLockWaiter(): Promise { + for (let attempt = 0; attempt < 200; attempt += 1) { + const [row] = await requiredSql()>` + select exists ( + select 1 + from pg_stat_activity + where wait_event_type = 'Lock' + and query ilike '%pg_advisory_xact_lock%' + ) as waiting + `; + if (row?.waiting) return; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + throw new Error("timed out waiting for the Iroh mutation to reach the account deletion fence"); +} diff --git a/web/tests/iroh-model-crypto.test.ts b/web/tests/iroh-model-crypto.test.ts index b26a59066759..fd7650dda6cf 100644 --- a/web/tests/iroh-model-crypto.test.ts +++ b/web/tests/iroh-model-crypto.test.ts @@ -1,19 +1,26 @@ import { describe, expect, test } from "bun:test"; import { createHash, createHmac, generateKeyPairSync, sign } from "node:crypto"; import { readFileSync } from "node:fs"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import { assertCurrentSigningKey, + createOfflinePairSessionRecord, deriveAccountSubject, parseVerificationKeys, signEndpointAttestation, signPairGrant, verifyEndpointAttestation, - verifyOfflineSameAccountPair, + verifyAndConsumeOfflineSameAccountPair, verifyPairGrant, type EndpointAttestationClaims, type PairGrantClaims, type PairGrantPeer, } from "../services/iroh/crypto"; +import { + IrohTrustBrokerConfig, + type IrohTrustBrokerConfigShape, +} from "../services/iroh/config"; import { IROH_ALPN, IROH_ENDPOINT_ATTESTATION_SCOPE, @@ -27,6 +34,8 @@ import { parseMinterHmacSecret, parseMinterUrl, readBoundedMinterJson, + IrohRelayMinter, + IrohRelayMinterLive, } from "../services/iroh/relayMinter"; const NOW = new Date("2026-07-09T20:00:00.000Z"); @@ -167,6 +176,7 @@ describe("Iroh pair-grant verification", () => { bindingId: "30000000-0000-4000-8000-000000000001", deviceId: "10000000-0000-4000-8000-000000000001", tag: "ios", + platform: "ios", endpointId: "22".repeat(32), identityGeneration: 2, }; @@ -174,6 +184,7 @@ describe("Iroh pair-grant verification", () => { bindingId: "30000000-0000-4000-8000-000000000002", deviceId: "10000000-0000-4000-8000-000000000002", tag: "stable", + platform: "mac", endpointId: "33".repeat(32), identityGeneration: 4, }; @@ -235,6 +246,7 @@ describe("Iroh pair-grant verification", () => { ["alg", { alg: "ES256", typ: IROH_PAIR_GRANT_TYP, kid: "current" }, claims, claims.iat], ["kid", { alg: "EdDSA", typ: IROH_PAIR_GRANT_TYP, kid: "unknown" }, claims, claims.iat], ["ALPN", { alg: "EdDSA", typ: IROH_PAIR_GRANT_TYP, kid: "current" }, { ...claims, alpn: "cmux/other/1" }, claims.iat], + ["platform direction", { alg: "EdDSA", typ: IROH_PAIR_GRANT_TYP, kid: "current" }, { ...claims, initiator: { ...claims.initiator, platform: "mac" } }, claims.iat], ["expiry", { alg: "EdDSA", typ: IROH_PAIR_GRANT_TYP, kid: "current" }, claims, claims.exp], ] as const) { test(`rejects invalid ${name}`, () => { @@ -307,6 +319,58 @@ describe("Iroh grant verification keys and offline endpoint attestations", () => })).toThrow(); }); + test("supports prepublish, signer flip, and delayed old-key retirement", () => { + const oldPrivate = previous.privateKey.export({ format: "pem", type: "pkcs8" }).toString(); + const prepublished = parseVerificationKeys(JSON.stringify({ + version: 1, + current_kid: "old", + keys: [ + { kid: "old", alg: "EdDSA", spki_der_base64: previousPublic }, + { kid: "next", alg: "EdDSA", spki_der_base64: currentPublic }, + ], + })); + expect(() => assertCurrentSigningKey({ + privateKeyPem: oldPrivate, + kid: "old", + verificationKeys: prepublished, + })).not.toThrow(); + const oldToken = signEndpointAttestation({ + privateKeyPem: oldPrivate, + kid: "old", + claims: initiator, + }); + + const flipped = parseVerificationKeys(JSON.stringify({ + version: 1, + current_kid: "next", + keys: [ + { kid: "next", alg: "EdDSA", spki_der_base64: currentPublic }, + { kid: "old", alg: "EdDSA", spki_der_base64: previousPublic }, + ], + })); + expect(() => assertCurrentSigningKey({ + privateKeyPem: currentPrivate, + kid: "next", + verificationKeys: flipped, + })).not.toThrow(); + expect(verifyEndpointAttestation( + oldToken, + flipped.publicKeys, + { ...endpointExpectation(initiator), nowSeconds }, + ).jti).toBe(initiator.jti); + + const retired = parseVerificationKeys(JSON.stringify({ + version: 1, + current_kid: "next", + keys: [{ kid: "next", alg: "EdDSA", spki_der_base64: currentPublic }], + })); + expect(() => verifyEndpointAttestation( + oldToken, + retired.publicKeys, + { ...endpointExpectation(initiator), nowSeconds }, + )).toThrow(); + }); + test("requires two fresh endpoint-bound attestations with the same opaque account subject", () => { const initiatorToken = signEndpointAttestation({ privateKeyPem: currentPrivate, @@ -323,31 +387,93 @@ describe("Iroh grant verification keys and offline endpoint attestations", () => acceptor: { ...endpointExpectation(acceptor), platform: "mac" as const }, nowSeconds, } as const; + const proof = Buffer.alloc(32, 0x61).toString("base64url"); + const session = createOfflinePairSessionRecord({ + sessionId: "70000000-0000-4000-8000-000000000001", + proof, + acceptor: expected.acceptor, + nowSeconds, + expiresAtSeconds: nowSeconds + 300, + }); + const invitation = { version: 1 as const, sessionId: session.sessionId, proof }; - expect(verifyOfflineSameAccountPair({ + expect(verifyAndConsumeOfflineSameAccountPair({ initiatorAttestation: initiatorToken, acceptorAttestation: acceptorToken, publicKeys: parsedKeys.publicKeys, expected, + session, + invitation, }).acceptor.endpointId).toBe(acceptor.endpointId); - expect(() => verifyOfflineSameAccountPair({ + expect(session.consumedAtSeconds).toBe(nowSeconds); + expect(() => verifyAndConsumeOfflineSameAccountPair({ + initiatorAttestation: initiatorToken, + acceptorAttestation: acceptorToken, + publicKeys: parsedKeys.publicKeys, + expected, + session, + invitation, + })).toThrow(); + + const missingSession = createOfflinePairSessionRecord({ + sessionId: "70000000-0000-4000-8000-000000000002", + proof, + acceptor: expected.acceptor, + nowSeconds, + expiresAtSeconds: nowSeconds + 300, + }); + expect(() => verifyAndConsumeOfflineSameAccountPair({ initiatorAttestation: "", acceptorAttestation: acceptorToken, publicKeys: parsedKeys.publicKeys, expected, + session: missingSession, + invitation: { ...invitation, sessionId: missingSession.sessionId }, })).toThrow(); + expect(missingSession.consumedAtSeconds).toBeNull(); + + const wrongProofSession = createOfflinePairSessionRecord({ + sessionId: "70000000-0000-4000-8000-000000000004", + proof, + acceptor: expected.acceptor, + nowSeconds, + expiresAtSeconds: nowSeconds + 300, + }); + expect(() => verifyAndConsumeOfflineSameAccountPair({ + initiatorAttestation: initiatorToken, + acceptorAttestation: acceptorToken, + publicKeys: parsedKeys.publicKeys, + expected, + session: wrongProofSession, + invitation: { + version: 1, + sessionId: wrongProofSession.sessionId, + proof: Buffer.alloc(32, 0x62).toString("base64url"), + }, + })).toThrow(); + expect(wrongProofSession.consumedAtSeconds).toBeNull(); const otherAccountToken = signEndpointAttestation({ privateKeyPem: currentPrivate, kid: "current", claims: { ...acceptor, sub: Buffer.alloc(32, 0x52).toString("base64url") }, }); - expect(() => verifyOfflineSameAccountPair({ + const mismatchSession = createOfflinePairSessionRecord({ + sessionId: "70000000-0000-4000-8000-000000000003", + proof, + acceptor: expected.acceptor, + nowSeconds, + expiresAtSeconds: nowSeconds + 300, + }); + expect(() => verifyAndConsumeOfflineSameAccountPair({ initiatorAttestation: initiatorToken, acceptorAttestation: otherAccountToken, publicKeys: parsedKeys.publicKeys, expected, + session: mismatchSession, + invitation: { ...invitation, sessionId: mismatchSession.sessionId }, })).toThrow(); + expect(mismatchSession.consumedAtSeconds).toBeNull(); }); test("rejects endpoint substitution, expiry, extra identity claims, and noncanonical signatures", () => { @@ -401,6 +527,62 @@ describe("Iroh grant verification keys and offline endpoint attestations", () => }); describe("Iroh relay minter response bounds", () => { + test("the production Live layer sends the canonical fetch body and HMAC", async () => { + const secret = Buffer.alloc(32, 0x63); + const originalFetch = globalThis.fetch; + let captured: { url: string; init: RequestInit } | undefined; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + captured = { url: String(input), init: init ?? {} }; + return new Response(JSON.stringify({ + token: "a".repeat(64), + expiresAt: "2026-07-10T20:00:00.000Z", + }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + try { + const config: IrohTrustBrokerConfigShape = { + relayMinterUrl: "https://minter.cmux.test/api/relay-token", + relayMinterHmacSecretBase64: secret.toString("base64"), + deviceLimitOverrideEnabled: false, + deviceLimitOverrideUserIds: new Set(), + deviceLimitOverrideEnvironments: new Set(), + deploymentEnvironment: "test", + }; + const layer = IrohRelayMinterLive.pipe( + Layer.provide(Layer.succeed(IrohTrustBrokerConfig, config)), + ); + const result = await Effect.runPromise( + Effect.gen(function* () { + const minter = yield* IrohRelayMinter; + return yield* minter.mint({ + endpointId: "ab".repeat(32), + lifetimeSeconds: 86_400, + now: NOW, + }); + }).pipe(Effect.provide(layer)), + ); + expect(result.token).toBe("a".repeat(64)); + } finally { + globalThis.fetch = originalFetch; + } + + const request = captured; + expect(request?.url).toBe("https://minter.cmux.test/api/relay-token"); + expect(request?.init.method).toBe("POST"); + expect(request?.init.redirect).toBe("error"); + const body = JSON.stringify({ endpointId: "ab".repeat(32), lifetimeSeconds: 86_400 }); + expect(request?.init.body).toBe(body); + const timestamp = String(Math.floor(NOW.getTime() / 1_000)); + const expectedSignature = createHmac("sha256", secret) + .update(`POST\n/api/relay-token\n${timestamp}\n${createHash("sha256").update(body).digest("hex")}`) + .digest("base64url"); + expect(new Headers(request?.init.headers).get("x-cmux-iroh-timestamp")).toBe(timestamp); + expect(new Headers(request?.init.headers).get("x-cmux-iroh-signature")).toBe(expectedSignature); + expect(new Headers(request?.init.headers).get("content-type")).toBe("application/json"); + }); + test("matches the Rust minter HMAC wire fixture", () => { const fixture = JSON.parse(readFileSync( new URL("../../tests/fixtures/iroh/relay-minter-request-v1.json", import.meta.url), diff --git a/web/tests/iroh-trust-broker.test.ts b/web/tests/iroh-trust-broker.test.ts index 96c651a53fe6..0fe931cf1b21 100644 --- a/web/tests/iroh-trust-broker.test.ts +++ b/web/tests/iroh-trust-broker.test.ts @@ -471,6 +471,14 @@ class MemoryRepository implements IrohRepositoryShape { recordPairGrant(input: Parameters[0]) { this.beforeRecordPairGrant?.(); + const initiator = this.bindings.find((row) => + row.id === input.initiator.bindingId && row.userId === input.userId && !row.revokedAt); + const acceptor = this.bindings.find((row) => + row.id === input.acceptor.bindingId && row.userId === input.userId && !row.revokedAt); + if (!initiator || !acceptor) return Effect.fail(new IrohNotFoundError({ resource: "binding" })); + if (initiator.platform !== "ios" || acceptor.platform !== "mac" || !acceptor.pairingEnabled) { + return Effect.fail(new IrohForbiddenError({ code: "target_not_pairable" })); + } this.pairGrantAudits.push(input); return Effect.void; } @@ -491,8 +499,17 @@ class MemoryRepository implements IrohRepositoryShape { completeRelayIssuance(input: Parameters[0]) { const row = this.relayIssuances.find((candidate) => candidate.id === input.issuanceId); - if (row) row.status = "succeeded"; - return Effect.void; + const active = this.bindings.find((candidate) => + candidate.id === input.bindingId && + candidate.userId === input.userId && + candidate.endpointId === input.endpointId && + !candidate.revokedAt); + if (!row || !active) { + if (row) row.status = "failed"; + return Effect.succeed(false); + } + row.status = "succeeded"; + return Effect.succeed(true); } failRelayIssuance(input: Parameters[0]) { @@ -632,6 +649,7 @@ function binding(overrides: Partial = {}): MutableBinding { pairingEnabled: true, capabilities: [], pathHints: [], + pathHintsNextExpiry: null, deviceLimitOverrideUsed: false, lastSeenAt: now, registeredAt: now, From fbe0e058eeef776f095b6d1cc0c7a601da5ef26a Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Fri, 10 Jul 2026 01:30:32 -0700 Subject: [PATCH 05/37] test(iroh): expose second-pass trust gaps --- services/iroh-relay-minter/src/lib.rs | 44 ++++- web/tests/client-config-env.test.ts | 27 ++- web/tests/iroh-db-behavior.test.ts | 232 +++++++++++++++++++++++++- web/tests/iroh-model-crypto.test.ts | 18 ++ web/tests/iroh-trust-broker.test.ts | 62 +++++++ 5 files changed, 375 insertions(+), 8 deletions(-) diff --git a/services/iroh-relay-minter/src/lib.rs b/services/iroh-relay-minter/src/lib.rs index 6f4560b0837f..0e9a833ba0ce 100644 --- a/services/iroh-relay-minter/src/lib.rs +++ b/services/iroh-relay-minter/src/lib.rs @@ -444,6 +444,7 @@ mod tests { use super::*; const TEST_HMAC_SECRET: [u8; 32] = [0x42; 32]; + const PREVIOUS_HMAC_SECRET: [u8; 32] = [0x41; 32]; #[tokio::test] async fn mints_lowercase_relay_only_rcan_for_the_exact_audience() { @@ -556,6 +557,27 @@ mod tests { ); } + #[tokio::test] + async fn accepts_the_previous_hmac_secret_only_during_rotation_overlap() { + let mut config = test_config(); + config.hmac_previous_secret = Some(Zeroizing::new(PREVIOUS_HMAC_SECRET.to_vec())); + let now = SystemTime::now(); + let timestamp = unix_seconds(now).expect("test clock is valid"); + let body = valid_body(&test_endpoint().to_string()); + let request = signed_request_with_secret( + Method::POST, + MINT_PATH, + timestamp, + body, + &PREVIOUS_HMAC_SECRET, + ); + + assert_eq!( + handle_request(request, &config, now).await.status(), + StatusCode::OK + ); + } + #[tokio::test] async fn enforces_the_thirty_second_timestamp_window() { let config = test_config(); @@ -759,22 +781,38 @@ mod tests { path: &str, timestamp: u64, body: String, + ) -> Request> { + signed_request_with_secret(method, path, timestamp, body, &TEST_HMAC_SECRET) + } + + fn signed_request_with_secret( + method: Method, + path: &str, + timestamp: u64, + body: String, + secret: &[u8], ) -> Request> { Request::builder() .method(method) .uri(path) .header(CONTENT_TYPE, "application/json") .header(&TIMESTAMP_HEADER, timestamp.to_string()) - .header(&SIGNATURE_HEADER, sign_request(timestamp, body.as_bytes())) + .header( + &SIGNATURE_HEADER, + sign_request_with_secret(secret, timestamp, body.as_bytes()), + ) .body(Full::new(Bytes::from(body))) .expect("test request is valid") } fn sign_request(timestamp: u64, body: &[u8]) -> String { + sign_request_with_secret(&TEST_HMAC_SECRET, timestamp, body) + } + + fn sign_request_with_secret(secret: &[u8], timestamp: u64, body: &[u8]) -> String { let body_hash = hex::encode(Sha256::digest(body)); let transcript = format!("POST\n{MINT_PATH}\n{timestamp}\n{body_hash}"); - let mut mac = - HmacSha256::new_from_slice(&TEST_HMAC_SECRET).expect("test HMAC key length is valid"); + let mut mac = HmacSha256::new_from_slice(secret).expect("test HMAC key length is valid"); mac.update(transcript.as_bytes()); URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()) } diff --git a/web/tests/client-config-env.test.ts b/web/tests/client-config-env.test.ts index ed1e8c7893ff..e7d1a9087b45 100644 --- a/web/tests/client-config-env.test.ts +++ b/web/tests/client-config-env.test.ts @@ -12,6 +12,17 @@ const requiredEnv = { NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY: "stack-public", }; +const requiredIrohProductionEnv = { + CMUX_IROH_LAN_DISCOVERY_SECRET_B64: Buffer.alloc(32, 0x11).toString("base64"), + CMUX_IROH_ACCOUNT_SUBJECT_SECRET_B64: Buffer.alloc(32, 0x22).toString("base64"), + CMUX_IROH_GRANT_SIGNING_KEY_P8: `-----BEGIN PRIVATE KEY-----\n${"A".repeat(64)}\n-----END PRIVATE KEY-----`, + CMUX_IROH_GRANT_SIGNING_KID: "current", + CMUX_IROH_GRANT_VERIFICATION_KEYS_JSON: "{}", + CMUX_IROH_MINT_URL: "https://iroh-minter.example.com/api/relay-token", + CMUX_IROH_MINT_HMAC_SECRET_B64: Buffer.alloc(32, 0x33).toString("base64"), + CMUX_IROH_RATE_LIMIT_ID: "iroh-rule", +}; + describe("client config env validation", () => { test("allows local builds with VERCEL set but no deployment environment", () => { const result = importEnv({ @@ -41,7 +52,7 @@ describe("client config env validation", () => { VERCEL: "1", VERCEL_ENV: "production", CMUX_CLIENT_CONFIG_RATE_LIMIT_ID: "client-config-rule", - CMUX_IROH_RATE_LIMIT_ID: "iroh-rule", + ...requiredIrohProductionEnv, }); expect(result.exitCode).toBe(0); @@ -58,6 +69,20 @@ describe("client config env validation", () => { expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("CMUX_IROH_RATE_LIMIT_ID is required"); }); + + test("requires the complete Iroh trust-broker configuration in production", () => { + const result = importEnv({ + ...requiredEnv, + VERCEL: "1", + VERCEL_ENV: "production", + CMUX_CLIENT_CONFIG_RATE_LIMIT_ID: "client-config-rule", + CMUX_IROH_RATE_LIMIT_ID: "iroh-rule", + }); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("CMUX_IROH_GRANT_SIGNING_KEY_P8 is required"); + expect(result.stderr).toContain("CMUX_IROH_MINT_HMAC_SECRET_B64 is required"); + }); }); function importEnv(env: Record): { exitCode: number; stderr: string } { diff --git a/web/tests/iroh-db-behavior.test.ts b/web/tests/iroh-db-behavior.test.ts index 674b5df67eba..a16ee490244f 100644 --- a/web/tests/iroh-db-behavior.test.ts +++ b/web/tests/iroh-db-behavior.test.ts @@ -290,6 +290,49 @@ describe("Iroh trust broker database behavior", () => { expect(nextExpiry?.getTime()).toBe(pathHintExpiry.getTime()); }); + dbTest("serializes an account-wide registration challenge rate cap", async () => { + const userId = "user-challenge-flood"; + await requiredSql()` + insert into iroh_registration_challenges ( + user_id, device_uuid, app_instance_id, tag, endpoint_id, + identity_generation, payload_sha256, nonce_hash, created_at, expires_at, consumed_at + ) + select + ${userId}, gen_random_uuid(), gen_random_uuid(), 'stable', repeat('3a', 32), + 1, + md5('account-payload-a-' || value::text) || md5('account-payload-b-' || value::text), + md5('account-nonce-a-' || value::text) || md5('account-nonce-b-' || value::text), + ${new Date(NOW.getTime() - 60_000)}, ${new Date(NOW.getTime() + 60_000)}, ${NOW} + from generate_series(1, 119) as values(value) + `; + const issue = (suffix: string) => Effect.runPromise(requiredRepository().issueChallenge({ + userId, + deviceUuid: randomUUID(), + appInstanceId: randomUUID(), + tag: "stable", + endpointId: suffix.repeat(64), + identityGeneration: 1, + payloadSha256: suffix.repeat(64), + nonceHash: `${suffix}${"0".repeat(63)}`, + now: NOW, + expiresAt: new Date(NOW.getTime() + 5 * 60 * 1_000), + })); + + const results = await Promise.allSettled([issue("4"), issue("5")]); + expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1); + expect(results.filter((result) => result.status === "rejected")).toHaveLength(1); + expect(results.find((result) => result.status === "rejected")?.reason).toMatchObject({ + _tag: "IrohQuotaExceededError", + code: "challenge_account_rate_limited", + }); + const [{ total }] = await requiredSql()>` + select count(*)::text as total + from iroh_registration_challenges + where user_id = ${userId} + `; + expect(total).toBe("120"); + }); + dbTest("enforces globally unique active EndpointIDs and app instances", async () => { const appInstanceId = randomUUID(); const endpointId = "40".repeat(32); @@ -393,6 +436,87 @@ describe("Iroh trust broker database behavior", () => { expect(total).toBe("0"); }); + dbTest("rejects pair-grant peers that resolve to one physical device", async () => { + const userId = "user-pair-same-device"; + const deviceUuid = randomUUID(); + const initiatorId = await insertBinding({ + userId, + deviceUuid, + platform: "ios", + endpointId: "54".repeat(32), + }); + const acceptorId = await insertBinding({ + userId, + deviceUuid, + platform: "mac", + endpointId: "55".repeat(32), + }); + const exit = await Effect.runPromiseExit(requiredRepository().recordPairGrant({ + userId, + jti: randomUUID(), + initiator: await pairPeer(initiatorId), + acceptor: await pairPeer(acceptorId), + signingKeyId: "current", + alpn: "cmux/mobile/1", + scope: "cmux.mobile.attach", + issuedAt: NOW, + notBefore: NOW, + expiresAt: new Date(NOW.getTime() + 7 * 24 * 60 * 60 * 1_000), + })); + + expect(exit._tag).toBe("Failure"); + expect(String(exit)).toContain("pair_grant_same_device"); + }); + + dbTest("fails attestation finalization when revocation commits during signing", async () => { + type FinalizeEndpointAttestation = (input: { + readonly userId: string; + readonly bindingId: string; + readonly deviceId: string; + readonly endpointId: string; + readonly identityGeneration: number; + readonly platform: "mac" | "ios"; + }) => Effect.Effect; + const repositoryWithFinalizer = requiredRepository() as unknown as { + readonly finalizeEndpointAttestation?: FinalizeEndpointAttestation; + }; + expect(typeof repositoryWithFinalizer.finalizeEndpointAttestation).toBe("function"); + if (!repositoryWithFinalizer.finalizeEndpointAttestation) return; + + const userId = "user-attestation-race"; + const bindingId = await insertBinding({ + userId, + platform: "ios", + endpointId: "56".repeat(32), + }); + const peer = await pairPeer(bindingId); + let finalization: ReturnType | undefined; + await requiredSql().begin(async (revocationSql) => { + await revocationSql` + select pg_advisory_xact_lock(hashtextextended(${`iroh:binding:${userId}`}, 0)) + `; + finalization = Effect.runPromiseExit(repositoryWithFinalizer.finalizeEndpointAttestation!({ + userId, + bindingId, + deviceId: peer.deviceId, + endpointId: peer.endpointId, + identityGeneration: peer.identityGeneration, + platform: peer.platform, + })); + await waitForAdvisoryLockWaiter(); + await revocationSql` + update iroh_endpoint_bindings + set revoked_at = ${NOW}, revoked_reason = 'user_requested' + where id = ${bindingId} + `; + }); + + if (!finalization) throw new Error("attestation finalization was not started"); + const exit = await finalization; + expect(exit._tag).toBe("Failure"); + expect(String(exit)).toContain("IrohNotFoundError"); + }); + dbTest("serializes relay quota reservations before provider work", async () => { const repo = requiredRepository(); const bindingId = await insertBinding({ userId: "user-relay", endpointId: "60".repeat(32) }); @@ -518,7 +642,7 @@ describe("Iroh trust broker database behavior", () => { expect(retentionState?.untouchedUpdatedAt.getTime()).toBe(untouchedBefore?.updatedAt.getTime()); }); - dbTest("retention skips locked hint rows and bounds each indexed batch", async () => { + dbTest("retention skips locked hint rows and drains multiple indexed batches", async () => { const lockedId = await insertBinding({ userId: "user-retention-lock", endpointId: "84".repeat(32), @@ -562,7 +686,7 @@ describe("Iroh trust broker database behavior", () => { md5('nonce-a-' || value::text) || md5('nonce-b-' || value::text), ${new Date(NOW.getTime() - 3 * 24 * 60 * 60 * 1_000)}, ${new Date(NOW.getTime() - 2 * 24 * 60 * 60 * 1_000)} - from generate_series(1, ${IROH_RETENTION_BATCH_SIZE + 1}) as values(value) + from generate_series(1, ${IROH_RETENTION_BATCH_SIZE * 2 + 1}) as values(value) `; await Effect.runPromise(requiredRepository().pruneExpiredStateGlobally({ now: NOW })); const [{ remaining }] = await requiredSql()>` @@ -570,7 +694,7 @@ describe("Iroh trust broker database behavior", () => { from iroh_registration_challenges where user_id = 'user-retention-batch' `; - expect(remaining).toBe("1"); + expect(remaining).toBe("0"); await requiredSql()` insert into iroh_registration_challenges ( @@ -598,6 +722,105 @@ describe("Iroh trust broker database behavior", () => { expect(scopedRemaining).toBe("1"); }); + dbTest("global retention reports backlog when its row budget is exhausted", async () => { + await requiredSql()` + insert into iroh_registration_challenges ( + user_id, device_uuid, app_instance_id, tag, endpoint_id, + identity_generation, payload_sha256, nonce_hash, created_at, expires_at + ) + select + 'user-retention-budget', gen_random_uuid(), gen_random_uuid(), 'stable', + repeat('88', 32), 1, + md5('budget-payload-a-' || value::text) || md5('budget-payload-b-' || value::text), + md5('budget-nonce-a-' || value::text) || md5('budget-nonce-b-' || value::text), + ${new Date(NOW.getTime() - 3 * 24 * 60 * 60 * 1_000)}, + ${new Date(NOW.getTime() - 2 * 24 * 60 * 60 * 1_000)} + from generate_series(1, ${IROH_RETENTION_BATCH_SIZE + 1}) as values(value) + `; + const cleanupInput = { + now: NOW, + maxRows: IROH_RETENTION_BATCH_SIZE, + maxDurationMs: 30_000, + }; + const result = await Effect.runPromise( + requiredRepository().pruneExpiredStateGlobally(cleanupInput), + ) as unknown as { + rowsProcessed: number; + backlog: boolean; + budgetExhausted: "rows" | "time" | null; + byCategory: { expiredChallenges: number }; + }; + + expect(result).toMatchObject({ + rowsProcessed: IROH_RETENTION_BATCH_SIZE, + backlog: true, + budgetExhausted: "rows", + byCategory: { expiredChallenges: IROH_RETENTION_BATCH_SIZE }, + }); + const [{ remaining }] = await requiredSql()>` + select count(*)::text as remaining + from iroh_registration_challenges + where user_id = 'user-retention-budget' + `; + expect(remaining).toBe("1"); + }); + + dbTest("retention and cascade lookups use their dedicated indexes", async () => { + const userId = "user-retention-index-plan"; + const initiatorId = await insertBinding({ + userId, + platform: "ios", + endpointId: "89".repeat(32), + }); + const acceptorId = await insertBinding({ + userId, + platform: "mac", + endpointId: "8a".repeat(32), + }); + await requiredSql()` + insert into iroh_pair_grant_issuances ( + user_id, jti, initiator_binding_id, acceptor_binding_id, signing_key_id, + alpn, scope, issued_at, not_before, expires_at + ) values ( + ${userId}, ${randomUUID()}, ${initiatorId}, ${acceptorId}, 'current', + 'cmux/mobile/1', 'cmux.mobile.attach', ${NOW}, ${NOW}, ${new Date(NOW.getTime() + 1_000)} + ) + `; + await requiredSql()` + update iroh_endpoint_bindings + set revoked_at = ${new Date(NOW.getTime() - 31 * 24 * 60 * 60 * 1_000)} + where id = ${initiatorId} + `; + + await requiredSql().begin(async (planSql) => { + await planSql`set local enable_seqscan = off`; + const initiatorPlan = await planSql` + explain (format json) + select id + from iroh_pair_grant_issuances + where initiator_binding_id = ${initiatorId} + `; + const fullUserPlan = await planSql` + explain (format json) + select id + from iroh_endpoint_bindings + where user_id = ${userId} + `; + const revokedCleanupPlan = await planSql` + explain (format json) + select id + from iroh_endpoint_bindings + where user_id = ${userId} + and revoked_at < ${NOW} + order by revoked_at, id + `; + + expect(JSON.stringify(initiatorPlan)).toContain("iroh_pair_grant_issuances_initiator_idx"); + expect(JSON.stringify(fullUserPlan)).toContain("iroh_endpoint_bindings_user_idx"); + expect(JSON.stringify(revokedCleanupPlan)).toContain("iroh_endpoint_bindings_user_revoked_idx"); + }); + }); + dbTest("binding deletion cascades grant and relay audit rows", async () => { const bindingId = await insertBinding({ userId: "user-delete", endpointId: "90".repeat(32) }); const peerId = await insertBinding({ userId: "user-delete", endpointId: "91".repeat(32) }); @@ -627,6 +850,7 @@ describe("Iroh trust broker database behavior", () => { async function insertBinding(input: { readonly userId: string; + readonly deviceUuid?: string; readonly appInstanceId?: string; readonly endpointId: string; readonly platform?: "mac" | "ios"; @@ -638,7 +862,7 @@ async function insertBinding(input: { identity_generation, pairing_enabled, capabilities, path_hints, path_hints_next_expiry ) values ( - ${input.userId}, ${randomUUID()}, ${input.appInstanceId ?? randomUUID()}, 'stable', + ${input.userId}, ${input.deviceUuid ?? randomUUID()}, ${input.appInstanceId ?? randomUUID()}, 'stable', ${input.platform ?? "mac"}, ${input.endpointId}, 1, true, '[]'::jsonb, ${requiredSql().json((input.pathHints ?? []) as never)}, ${earliestStoredHintExpiry(input.pathHints ?? [])} diff --git a/web/tests/iroh-model-crypto.test.ts b/web/tests/iroh-model-crypto.test.ts index fd7650dda6cf..c27315ad27b0 100644 --- a/web/tests/iroh-model-crypto.test.ts +++ b/web/tests/iroh-model-crypto.test.ts @@ -224,6 +224,24 @@ describe("Iroh pair-grant verification", () => { })).toThrow(); }); + test("rejects grants whose peers claim the same physical device", () => { + const sameDeviceClaims = { + ...claims, + acceptor: { ...acceptor, deviceId: initiator.deviceId }, + }; + const token = manuallySignedJws( + { alg: "EdDSA", typ: IROH_PAIR_GRANT_TYP, kid: "current" }, + sameDeviceClaims, + current.privateKey, + ); + + expect(() => verifyPairGrant(token, keys, { + initiator, + acceptor: sameDeviceClaims.acceptor, + nowSeconds: claims.iat, + })).toThrow(); + }); + test("rejects identity or team claims outside the fixed grant contract", () => { const token = manuallySignedJws( { alg: "EdDSA", typ: IROH_PAIR_GRANT_TYP, kid: "current" }, diff --git a/web/tests/iroh-trust-broker.test.ts b/web/tests/iroh-trust-broker.test.ts index 0fe931cf1b21..7ddde17c31e6 100644 --- a/web/tests/iroh-trust-broker.test.ts +++ b/web/tests/iroh-trust-broker.test.ts @@ -209,6 +209,29 @@ describe("Iroh discovery and grants", () => { expect(raced.repository.pairGrantAudits).toHaveLength(0); }); + test("pair grants require two distinct physical devices", async () => { + const fixture = makeFixture(); + const deviceUuid = randomUUID(); + const iosInitiator = binding({ + userId: USER_A, + deviceUuid, + platform: "ios", + }); + const macAcceptor = binding({ + userId: USER_A, + deviceUuid, + platform: "mac", + pairingEnabled: true, + }); + fixture.repository.bindings.push(iosInitiator, macAcceptor); + + await expectEffectFailure(fixture.broker.issuePairGrant(USER_A, { + initiatorBindingId: iosInitiator.id, + acceptorBindingId: macAcceptor.id, + }, NOW), "IrohForbiddenError"); + expect(fixture.repository.pairGrantAudits).toHaveLength(0); + }); + test("issues a short-lived opaque same-account attestation only for an owned active binding", async () => { const fixture = makeFixture(); const active = binding({ userId: USER_A, platform: "ios", identityGeneration: 4 }); @@ -249,6 +272,19 @@ describe("Iroh discovery and grants", () => { }, NOW), "IrohNotFoundError"); }); + test("does not return an attestation when its exact binding is revoked during signing", async () => { + const fixture = makeFixture(); + const active = binding({ userId: USER_A, platform: "ios" }); + fixture.repository.bindings.push(active); + fixture.repository.beforeFinalizeEndpointAttestation = () => { + active.revokedAt = NOW; + }; + + await expectEffectFailure(fixture.broker.issueEndpointAttestation(USER_A, { + bindingId: active.id, + }, NOW), "IrohNotFoundError"); + }); + test("fails closed when verification or opaque-subject signing material is unavailable", async () => { const fixture = makeFixture(); const active = binding({ userId: USER_A, platform: "ios" }); @@ -350,6 +386,7 @@ class MemoryRepository implements IrohRepositoryShape { }> = []; private lanGenerations = new Map(); beforeRecordPairGrant: (() => void) | undefined; + beforeFinalizeEndpointAttestation: (() => void) | undefined; issueChallenge(input: Parameters[0]) { const challenge: IrohChallengeRecord = { @@ -483,6 +520,31 @@ class MemoryRepository implements IrohRepositoryShape { return Effect.void; } + finalizeEndpointAttestation(input: { + readonly userId: string; + readonly bindingId: string; + readonly deviceId: string; + readonly endpointId: string; + readonly identityGeneration: number; + readonly platform: "mac" | "ios"; + }) { + this.beforeFinalizeEndpointAttestation?.(); + const active = this.bindings.find((row) => + row.id === input.bindingId && + row.userId === input.userId && + !row.revokedAt); + if (!active) return Effect.fail(new IrohNotFoundError({ resource: "binding" })); + if ( + active.deviceUuid !== input.deviceId || + active.endpointId !== input.endpointId || + active.identityGeneration !== input.identityGeneration || + active.platform !== input.platform + ) { + return Effect.fail(new IrohConflictError({ code: "binding_changed_during_attestation" })); + } + return Effect.void; + } + reserveRelayIssuance(input: Parameters[0]) { const active = this.bindings.find((row) => row.id === input.bindingId && row.userId === input.userId && !row.revokedAt); From 3ee21c079afe1e6c32ed21c8a07ea8e9dfc2fa16 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Fri, 10 Jul 2026 01:42:58 -0700 Subject: [PATCH 06/37] fix(iroh): close credential races and drain retention --- web/.env.example | 2 + web/app/api/internal/iroh/retention/route.ts | 26 +- web/app/env.ts | 42 +- .../migration.sql | 9 + web/db/schema.ts | 6 + web/services/iroh/crypto.ts | 7 + web/services/iroh/repository.ts | 541 +++++++++++++----- web/services/iroh/trustBroker.ts | 11 + web/tests/iroh-db-behavior.test.ts | 22 +- web/tests/iroh-trust-broker.test.ts | 18 +- 10 files changed, 523 insertions(+), 161 deletions(-) create mode 100644 web/db/migrations/20260710010000_iroh_trust_broker_review_hardening/migration.sql diff --git a/web/.env.example b/web/.env.example index 50f566d6f4ca..c20fe40b6daf 100644 --- a/web/.env.example +++ b/web/.env.example @@ -54,6 +54,8 @@ CMUX_IROH_GRANT_SIGNING_KID= # {"version":1,"current_kid":"current","keys":[{"kid":"current","alg":"EdDSA","spki_der_base64":"..."}]} CMUX_IROH_GRANT_VERIFICATION_KEYS_JSON= CMUX_IROH_MINT_URL= +# Current request-authentication key. The optional previous overlap key belongs +# only in the isolated minter project. CMUX_IROH_MINT_HMAC_SECRET_B64= CMUX_IROH_RATE_LIMIT_ID= CMUX_IROH_DEV_BINDING_OVERRIDE_ENABLED=0 diff --git a/web/app/api/internal/iroh/retention/route.ts b/web/app/api/internal/iroh/retention/route.ts index c19f85fa57f8..2b41a9305fe3 100644 --- a/web/app/api/internal/iroh/retention/route.ts +++ b/web/app/api/internal/iroh/retention/route.ts @@ -1,6 +1,11 @@ import { timingSafeEqual } from "node:crypto"; import * as Effect from "effect/Effect"; -import { IrohRepository, IrohRepositoryLive } from "../../../../../services/iroh/repository"; +import { + IROH_RETENTION_MAX_DURATION_MS, + IROH_RETENTION_MAX_ROWS, + IrohRepository, + IrohRepositoryLive, +} from "../../../../../services/iroh/repository"; import { jsonResponse } from "../../../../../services/vms/routeHelpers"; export const runtime = "nodejs"; @@ -28,13 +33,26 @@ async function handle(request: Request): Promise { } try { - await Effect.runPromise( + const startedAt = Date.now(); + const retention = await Effect.runPromise( Effect.gen(function* () { const repository = yield* IrohRepository; - yield* repository.pruneExpiredStateGlobally({ now: new Date() }); + return yield* repository.pruneExpiredStateGlobally({ + now: new Date(), + maxRows: IROH_RETENTION_MAX_ROWS, + maxDurationMs: IROH_RETENTION_MAX_DURATION_MS, + }); }).pipe(Effect.provide(IrohRepositoryLive)), ); - return jsonResponse({ ok: true }); + console.info("iroh retention cleanup completed", { + rows_processed: retention.rowsProcessed, + batches: retention.batches, + backlog: retention.backlog, + budget_exhausted: retention.budgetExhausted, + by_category: retention.byCategory, + duration_ms: Date.now() - startedAt, + }); + return jsonResponse({ ok: true, retention }); } catch { console.error("iroh retention cleanup failed", { failure: "database" }); return jsonResponse({ error: "iroh_retention_failed" }, 500); diff --git a/web/app/env.ts b/web/app/env.ts index 3c07c05bc4cf..88e1b0184341 100644 --- a/web/app/env.ts +++ b/web/app/env.ts @@ -21,8 +21,11 @@ const isVercelNonPreviewDeployment = process.env.VERCEL === "1" && typeof process.env.VERCEL_ENV === "string" && process.env.VERCEL_ENV !== "preview"; -const requireVercelNonPreviewValue = (name: string): z.ZodType => - z.string().min(1).optional().superRefine((value, context) => { +const requireVercelNonPreviewValue = ( + name: string, + schema: z.ZodString = z.string().min(1), +): z.ZodType => + schema.optional().superRefine((value, context) => { if (isVercelNonPreviewDeployment && !value) { context.addIssue({ code: z.ZodIssueCode.custom, @@ -97,13 +100,34 @@ export const env = createEnv({ // Iroh trust broker. The Services API key deliberately has no TypeScript // env entry: only the isolated Rust relay minter may hold it. These values // are server-only and routes fail closed when an operation's key is absent. - CMUX_IROH_LAN_DISCOVERY_SECRET_B64: z.string().max(512).regex(/^[A-Za-z0-9+/]{43,}={0,2}$/).optional(), - CMUX_IROH_ACCOUNT_SUBJECT_SECRET_B64: z.string().max(512).regex(/^[A-Za-z0-9+/]{43,}={0,2}$/).optional(), - CMUX_IROH_GRANT_SIGNING_KEY_P8: z.string().min(64).max(16_384).optional(), - CMUX_IROH_GRANT_SIGNING_KID: z.string().regex(/^[A-Za-z0-9._-]{1,64}$/).optional(), - CMUX_IROH_GRANT_VERIFICATION_KEYS_JSON: z.string().min(2).max(32_768).optional(), - CMUX_IROH_MINT_URL: z.string().url().optional(), - CMUX_IROH_MINT_HMAC_SECRET_B64: z.string().max(512).regex(/^[A-Za-z0-9+/]{43,}={0,2}$/).optional(), + CMUX_IROH_LAN_DISCOVERY_SECRET_B64: requireVercelNonPreviewValue( + "CMUX_IROH_LAN_DISCOVERY_SECRET_B64", + z.string().max(512).regex(/^[A-Za-z0-9+/]{43,}={0,2}$/), + ), + CMUX_IROH_ACCOUNT_SUBJECT_SECRET_B64: requireVercelNonPreviewValue( + "CMUX_IROH_ACCOUNT_SUBJECT_SECRET_B64", + z.string().max(512).regex(/^[A-Za-z0-9+/]{43,}={0,2}$/), + ), + CMUX_IROH_GRANT_SIGNING_KEY_P8: requireVercelNonPreviewValue( + "CMUX_IROH_GRANT_SIGNING_KEY_P8", + z.string().min(64).max(16_384), + ), + CMUX_IROH_GRANT_SIGNING_KID: requireVercelNonPreviewValue( + "CMUX_IROH_GRANT_SIGNING_KID", + z.string().regex(/^[A-Za-z0-9._-]{1,64}$/), + ), + CMUX_IROH_GRANT_VERIFICATION_KEYS_JSON: requireVercelNonPreviewValue( + "CMUX_IROH_GRANT_VERIFICATION_KEYS_JSON", + z.string().min(2).max(32_768), + ), + CMUX_IROH_MINT_URL: requireVercelNonPreviewValue( + "CMUX_IROH_MINT_URL", + z.string().url(), + ), + CMUX_IROH_MINT_HMAC_SECRET_B64: requireVercelNonPreviewValue( + "CMUX_IROH_MINT_HMAC_SECRET_B64", + z.string().max(512).regex(/^[A-Za-z0-9+/]{43,}={0,2}$/), + ), CMUX_IROH_RATE_LIMIT_ID: requireVercelNonPreviewValue("CMUX_IROH_RATE_LIMIT_ID"), CMUX_IROH_DEV_BINDING_OVERRIDE_ENABLED: z.enum(["0", "1"]).optional(), CMUX_IROH_DEV_BINDING_OVERRIDE_USER_IDS: z.string().max(8_192).optional(), diff --git a/web/db/migrations/20260710010000_iroh_trust_broker_review_hardening/migration.sql b/web/db/migrations/20260710010000_iroh_trust_broker_review_hardening/migration.sql new file mode 100644 index 000000000000..ddda02ef8ed0 --- /dev/null +++ b/web/db/migrations/20260710010000_iroh_trust_broker_review_hardening/migration.sql @@ -0,0 +1,9 @@ +CREATE INDEX "iroh_endpoint_bindings_user_idx" + ON "iroh_endpoint_bindings" USING btree ("user_id"); +--> statement-breakpoint +CREATE INDEX "iroh_endpoint_bindings_user_revoked_idx" + ON "iroh_endpoint_bindings" USING btree ("user_id", "revoked_at", "id") + WHERE "revoked_at" IS NOT NULL; +--> statement-breakpoint +CREATE INDEX "iroh_pair_grant_issuances_initiator_idx" + ON "iroh_pair_grant_issuances" USING btree ("initiator_binding_id", "expires_at"); diff --git a/web/db/schema.ts b/web/db/schema.ts index 705f8eb4f6bf..adfc9ee36eff 100644 --- a/web/db/schema.ts +++ b/web/db/schema.ts @@ -675,6 +675,11 @@ export const irohEndpointBindings = pgTable( index("iroh_endpoint_bindings_user_device_active_idx") .on(table.userId, table.deviceUuid) .where(sql`${table.revokedAt} is null`), + index("iroh_endpoint_bindings_user_idx") + .on(table.userId), + index("iroh_endpoint_bindings_user_revoked_idx") + .on(table.userId, table.revokedAt, table.id) + .where(sql`${table.revokedAt} is not null`), index("iroh_endpoint_bindings_revoked_idx") .on(table.revokedAt) .where(sql`${table.revokedAt} is not null`), @@ -756,6 +761,7 @@ export const irohPairGrantIssuances = pgTable( (table) => [ uniqueIndex("iroh_pair_grant_issuances_jti_unique").on(table.jti), index("iroh_pair_grant_issuances_user_issued_idx").on(table.userId, table.issuedAt), + index("iroh_pair_grant_issuances_initiator_idx").on(table.initiatorBindingId, table.expiresAt), index("iroh_pair_grant_issuances_acceptor_expires_idx").on(table.acceptorBindingId, table.expiresAt), index("iroh_pair_grant_issuances_expires_idx").on(table.expiresAt, table.id), index("iroh_pair_grant_issuances_user_expires_idx").on(table.userId, table.expiresAt, table.id), diff --git a/web/services/iroh/crypto.ts b/web/services/iroh/crypto.ts index 1eb1fdcd0e26..3f97ec2b852c 100644 --- a/web/services/iroh/crypto.ts +++ b/web/services/iroh/crypto.ts @@ -530,6 +530,13 @@ function validatePairGrantClaims( if (value.initiator.platform !== "ios" || value.acceptor.platform !== "mac") { throw new IrohForbiddenError({ code: "invalid_pair_grant_platforms" }); } + if ( + value.initiator.bindingId === value.acceptor.bindingId || + value.initiator.deviceId === value.acceptor.deviceId || + value.initiator.endpointId === value.acceptor.endpointId + ) { + throw new IrohForbiddenError({ code: "pair_grant_peers_not_distinct" }); + } if (expected.initiator && !samePeer(value.initiator, expected.initiator)) { throw new IrohForbiddenError({ code: "pair_grant_initiator_mismatch" }); } diff --git a/web/services/iroh/repository.ts b/web/services/iroh/repository.ts index df22cec06ad8..7975b32818e9 100644 --- a/web/services/iroh/repository.ts +++ b/web/services/iroh/repository.ts @@ -31,6 +31,26 @@ import { } from "./model"; export const IROH_RETENTION_BATCH_SIZE = 500; +export const IROH_RETENTION_MAX_ROWS = 10_000; +export const IROH_RETENTION_MAX_DURATION_MS = 8_000; +export const IROH_ACCOUNT_CHALLENGE_LIMIT = 120; + +export type IrohRetentionCategory = + | "revokedHints" + | "expiredHints" + | "expiredChallenges" + | "consumedChallenges" + | "relayAudits" + | "pairGrantAudits" + | "revokedBindings"; + +export type IrohRetentionResult = { + readonly rowsProcessed: number; + readonly batches: number; + readonly backlog: boolean; + readonly budgetExhausted: "rows" | "time" | null; + readonly byCategory: Readonly>; +}; export type IrohBindingRecord = typeof irohEndpointBindings.$inferSelect; export type IrohChallengeRecord = typeof irohRegistrationChallenges.$inferSelect; @@ -90,6 +110,16 @@ export type IrohRepositoryShape = { }) => Effect.Effect; readonly pruneExpiredStateGlobally: (input: { readonly now: Date; + readonly maxRows?: number; + readonly maxDurationMs?: number; + }) => Effect.Effect; + readonly finalizeEndpointAttestation: (input: { + readonly userId: string; + readonly bindingId: string; + readonly deviceId: string; + readonly endpointId: string; + readonly identityGeneration: number; + readonly platform: "mac" | "ios"; }) => Effect.Effect; readonly recordPairGrant: (input: { readonly userId: string; @@ -143,6 +173,19 @@ function makeLiveRepository(): IrohRepositoryShape { await assertIrohUserMutationAllowed(tx, input.userId); await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`iroh:challenge:${input.userId}`}, 0))`); const tenMinutesAgo = new Date(input.now.getTime() - 10 * 60 * 1_000); + const [recentForAccount] = await tx + .select({ total: count() }) + .from(irohRegistrationChallenges) + .where(and( + eq(irohRegistrationChallenges.userId, input.userId), + gt(irohRegistrationChallenges.createdAt, tenMinutesAgo), + )); + if ((recentForAccount?.total ?? 0) >= IROH_ACCOUNT_CHALLENGE_LIMIT) { + throw new IrohQuotaExceededError({ + code: "challenge_account_rate_limited", + retryAfterSeconds: 600, + }); + } const [recentForDevice] = await tx .select({ total: count() }) .from(irohRegistrationChallenges) @@ -530,148 +573,34 @@ function makeLiveRepository(): IrohRepositoryShape { }); }), - pruneExpiredStateGlobally: (input) => repositoryEffect("prune_expired_state_globally", async () => { - const challengeRetentionCutoff = new Date(input.now.getTime() - 24 * 60 * 60 * 1_000); - const auditRetentionCutoff = new Date(input.now.getTime() - 30 * 24 * 60 * 60 * 1_000); + pruneExpiredStateGlobally: (input) => repositoryEffect( + "prune_expired_state_globally", + () => drainIrohRetention(input), + ), + + finalizeEndpointAttestation: (input) => repositoryEffect("finalize_endpoint_attestation", async () => { await cloudDb().transaction(async (tx) => { - await tx.execute(sql` - with candidates as materialized ( - select id - from iroh_endpoint_bindings - where revoked_at is not null - and path_hints_next_expiry is not null - order by revoked_at, id - limit ${IROH_RETENTION_BATCH_SIZE} - for update skip locked - ) - update iroh_endpoint_bindings as binding - set path_hints = '[]'::jsonb, - path_hints_next_expiry = null, - updated_at = ${input.now.toISOString()}::timestamptz - from candidates - where binding.id = candidates.id - `); - await tx - .execute(sql` - with candidates as materialized ( - select id - from iroh_endpoint_bindings - where revoked_at is null - and path_hints_next_expiry <= ${input.now.toISOString()}::timestamptz - order by path_hints_next_expiry, id - limit ${IROH_RETENTION_BATCH_SIZE} - for update skip locked - ), retained as ( - select - binding.id, - coalesce( - jsonb_agg(entry.hint order by entry.ordinality) filter ( - where case - when jsonb_typeof(entry.hint) = 'object' - and jsonb_typeof(entry.hint -> 'expires_at') = 'string' - and (entry.hint ->> 'expires_at') ~ '^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$' - then (entry.hint ->> 'expires_at')::timestamptz > ${input.now.toISOString()}::timestamptz - else false - end - ), - '[]'::jsonb - ) as path_hints - from candidates - join iroh_endpoint_bindings as binding on binding.id = candidates.id - left join lateral jsonb_array_elements(binding.path_hints) - with ordinality as entry(hint, ordinality) on true - group by binding.id - ), normalized as ( - select - retained.id, - retained.path_hints, - ( - select min((hint ->> 'expires_at')::timestamptz) - from jsonb_array_elements(retained.path_hints) as hints(hint) - ) as next_expiry - from retained - ) - update iroh_endpoint_bindings as binding - set path_hints = normalized.path_hints, - path_hints_next_expiry = normalized.next_expiry, - updated_at = ${input.now.toISOString()}::timestamptz - from normalized - where binding.id = normalized.id - `); - await tx.execute(sql` - with candidates as materialized ( - select id - from iroh_registration_challenges - where expires_at < ${challengeRetentionCutoff.toISOString()}::timestamptz - order by expires_at, id - limit ${IROH_RETENTION_BATCH_SIZE} - for update skip locked - ) - delete from iroh_registration_challenges as challenge - using candidates - where challenge.id = candidates.id - `); - await tx.execute(sql` - with candidates as materialized ( - select id - from iroh_registration_challenges - where consumed_at < ${challengeRetentionCutoff.toISOString()}::timestamptz - order by consumed_at, id - limit ${IROH_RETENTION_BATCH_SIZE} - for update skip locked - ) - delete from iroh_registration_challenges as challenge - using candidates - where challenge.id = candidates.id - `); - await tx.execute(sql` - with candidates as materialized ( - select id - from iroh_relay_token_issuances - where requested_at < ${auditRetentionCutoff.toISOString()}::timestamptz - order by requested_at, id - limit ${IROH_RETENTION_BATCH_SIZE} - for update skip locked - ) - delete from iroh_relay_token_issuances as issuance - using candidates - where issuance.id = candidates.id - `); - await tx.execute(sql` - with candidates as materialized ( - select id - from iroh_pair_grant_issuances - where expires_at < ${auditRetentionCutoff.toISOString()}::timestamptz - order by expires_at, id - limit ${IROH_RETENTION_BATCH_SIZE} - for update skip locked - ) - delete from iroh_pair_grant_issuances as issuance - using candidates - where issuance.id = candidates.id - `); - await tx.execute(sql` - with candidates as materialized ( - select binding.id - from iroh_endpoint_bindings as binding - where binding.revoked_at < ${auditRetentionCutoff.toISOString()}::timestamptz - and not exists ( - select 1 from iroh_pair_grant_issuances as pair_grant - where pair_grant.initiator_binding_id = binding.id - or pair_grant.acceptor_binding_id = binding.id - ) - and not exists ( - select 1 from iroh_relay_token_issuances as issuance - where issuance.binding_id = binding.id - ) - order by binding.revoked_at, binding.id - limit ${IROH_RETENTION_BATCH_SIZE} - for update skip locked - ) - delete from iroh_endpoint_bindings as binding - using candidates - where binding.id = candidates.id - `); + await assertIrohUserMutationAllowed(tx, input.userId); + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`iroh:binding:${input.userId}`}, 0))`); + const [binding] = await tx + .select() + .from(irohEndpointBindings) + .where(and( + eq(irohEndpointBindings.id, input.bindingId), + eq(irohEndpointBindings.userId, input.userId), + isNull(irohEndpointBindings.revokedAt), + )) + .for("update") + .limit(1); + if (!binding) throw new IrohNotFoundError({ resource: "binding" }); + if ( + binding.deviceUuid !== input.deviceId || + binding.endpointId !== input.endpointId || + binding.identityGeneration !== input.identityGeneration || + binding.platform !== input.platform + ) { + throw new IrohConflictError({ code: "binding_changed_during_attestation" }); + } }); }), @@ -699,6 +628,9 @@ function makeLiveRepository(): IrohRepositoryShape { ) { throw new IrohConflictError({ code: "binding_changed_during_grant" }); } + if (initiator.deviceUuid === acceptor.deviceUuid) { + throw new IrohForbiddenError({ code: "pair_grant_same_device" }); + } if (initiator.platform !== "ios" || acceptor.platform !== "mac" || !acceptor.pairingEnabled) { throw new IrohForbiddenError({ code: "target_not_pairable" }); } @@ -870,6 +802,333 @@ function makeLiveRepository(): IrohRepositoryShape { }; } +type RetentionBatchOperation = { + readonly category: IrohRetentionCategory; + readonly run: (limit: number) => Promise; +}; + +async function drainIrohRetention(input: { + readonly now: Date; + readonly maxRows?: number; + readonly maxDurationMs?: number; +}): Promise { + const maxRows = retentionBudget( + input.maxRows, + IROH_RETENTION_MAX_ROWS, + 100_000, + "maxRows", + ); + const maxDurationMs = retentionBudget( + input.maxDurationMs, + IROH_RETENTION_MAX_DURATION_MS, + 30_000, + "maxDurationMs", + ); + const challengeRetentionCutoff = new Date(input.now.getTime() - 24 * 60 * 60 * 1_000); + const auditRetentionCutoff = new Date(input.now.getTime() - 30 * 24 * 60 * 60 * 1_000); + const nowIso = input.now.toISOString(); + const challengeCutoffIso = challengeRetentionCutoff.toISOString(); + const auditCutoffIso = auditRetentionCutoff.toISOString(); + const operations: readonly RetentionBatchOperation[] = [ + { + category: "revokedHints", + run: (limit) => runRetentionBatch(async (tx) => await tx.execute(sql` + with candidates as materialized ( + select id + from iroh_endpoint_bindings + where revoked_at is not null + and path_hints_next_expiry is not null + order by revoked_at, id + limit ${limit} + for update skip locked + ), changed as ( + update iroh_endpoint_bindings as binding + set path_hints = '[]'::jsonb, + path_hints_next_expiry = null, + updated_at = ${nowIso}::timestamptz + from candidates + where binding.id = candidates.id + returning binding.id + ) + select count(*)::int as affected from changed + `)), + }, + { + category: "expiredHints", + run: (limit) => runRetentionBatch(async (tx) => await tx.execute(sql` + with candidates as materialized ( + select id + from iroh_endpoint_bindings + where revoked_at is null + and path_hints_next_expiry <= ${nowIso}::timestamptz + order by path_hints_next_expiry, id + limit ${limit} + for update skip locked + ), retained as ( + select + binding.id, + coalesce( + jsonb_agg(entry.hint order by entry.ordinality) filter ( + where case + when jsonb_typeof(entry.hint) = 'object' + and jsonb_typeof(entry.hint -> 'expires_at') = 'string' + and (entry.hint ->> 'expires_at') ~ '^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$' + then (entry.hint ->> 'expires_at')::timestamptz > ${nowIso}::timestamptz + else false + end + ), + '[]'::jsonb + ) as path_hints + from candidates + join iroh_endpoint_bindings as binding on binding.id = candidates.id + left join lateral jsonb_array_elements(binding.path_hints) + with ordinality as entry(hint, ordinality) on true + group by binding.id + ), normalized as ( + select + retained.id, + retained.path_hints, + ( + select min((hint ->> 'expires_at')::timestamptz) + from jsonb_array_elements(retained.path_hints) as hints(hint) + ) as next_expiry + from retained + ), changed as ( + update iroh_endpoint_bindings as binding + set path_hints = normalized.path_hints, + path_hints_next_expiry = normalized.next_expiry, + updated_at = ${nowIso}::timestamptz + from normalized + where binding.id = normalized.id + returning binding.id + ) + select count(*)::int as affected from changed + `)), + }, + { + category: "expiredChallenges", + run: (limit) => runRetentionBatch(async (tx) => await tx.execute(sql` + with candidates as materialized ( + select id + from iroh_registration_challenges + where expires_at < ${challengeCutoffIso}::timestamptz + order by expires_at, id + limit ${limit} + for update skip locked + ), changed as ( + delete from iroh_registration_challenges as challenge + using candidates + where challenge.id = candidates.id + returning challenge.id + ) + select count(*)::int as affected from changed + `)), + }, + { + category: "consumedChallenges", + run: (limit) => runRetentionBatch(async (tx) => await tx.execute(sql` + with candidates as materialized ( + select id + from iroh_registration_challenges + where consumed_at < ${challengeCutoffIso}::timestamptz + order by consumed_at, id + limit ${limit} + for update skip locked + ), changed as ( + delete from iroh_registration_challenges as challenge + using candidates + where challenge.id = candidates.id + returning challenge.id + ) + select count(*)::int as affected from changed + `)), + }, + { + category: "relayAudits", + run: (limit) => runRetentionBatch(async (tx) => await tx.execute(sql` + with candidates as materialized ( + select id + from iroh_relay_token_issuances + where requested_at < ${auditCutoffIso}::timestamptz + order by requested_at, id + limit ${limit} + for update skip locked + ), changed as ( + delete from iroh_relay_token_issuances as issuance + using candidates + where issuance.id = candidates.id + returning issuance.id + ) + select count(*)::int as affected from changed + `)), + }, + { + category: "pairGrantAudits", + run: (limit) => runRetentionBatch(async (tx) => await tx.execute(sql` + with candidates as materialized ( + select id + from iroh_pair_grant_issuances + where expires_at < ${auditCutoffIso}::timestamptz + order by expires_at, id + limit ${limit} + for update skip locked + ), changed as ( + delete from iroh_pair_grant_issuances as issuance + using candidates + where issuance.id = candidates.id + returning issuance.id + ) + select count(*)::int as affected from changed + `)), + }, + { + category: "revokedBindings", + run: (limit) => runRetentionBatch(async (tx) => await tx.execute(sql` + with candidates as materialized ( + select binding.id + from iroh_endpoint_bindings as binding + where binding.revoked_at < ${auditCutoffIso}::timestamptz + and not exists ( + select 1 from iroh_pair_grant_issuances as pair_grant + where pair_grant.initiator_binding_id = binding.id + or pair_grant.acceptor_binding_id = binding.id + ) + and not exists ( + select 1 from iroh_relay_token_issuances as issuance + where issuance.binding_id = binding.id + ) + order by binding.revoked_at, binding.id + limit ${limit} + for update skip locked + ), changed as ( + delete from iroh_endpoint_bindings as binding + using candidates + where binding.id = candidates.id + returning binding.id + ) + select count(*)::int as affected from changed + `)), + }, + ]; + const byCategory: Record = { + revokedHints: 0, + expiredHints: 0, + expiredChallenges: 0, + consumedChallenges: 0, + relayAudits: 0, + pairGrantAudits: 0, + revokedBindings: 0, + }; + const deadline = Date.now() + maxDurationMs; + const activeOperations = [...operations]; + let rowsProcessed = 0; + let batches = 0; + let operationIndex = 0; + + while (activeOperations.length > 0 && rowsProcessed < maxRows && Date.now() < deadline) { + const operation = activeOperations[operationIndex]!; + const limit = Math.min(IROH_RETENTION_BATCH_SIZE, maxRows - rowsProcessed); + const affected = await operation.run(limit); + batches += 1; + rowsProcessed += affected; + byCategory[operation.category] += affected; + if (affected < limit) { + activeOperations.splice(operationIndex, 1); + if (operationIndex >= activeOperations.length) operationIndex = 0; + } else { + operationIndex = (operationIndex + 1) % activeOperations.length; + } + } + + const budgetExhausted = rowsProcessed >= maxRows + ? "rows" + : Date.now() >= deadline + ? "time" + : null; + const backlog = budgetExhausted === "time" + ? true + : await irohRetentionBacklogExists(input.now, challengeRetentionCutoff, auditRetentionCutoff); + return { rowsProcessed, batches, backlog, budgetExhausted, byCategory }; +} + +async function runRetentionBatch( + execute: (tx: CloudDbTransaction) => Promise, +): Promise { + return await cloudDb().transaction(async (tx) => { + const result = await execute(tx); + const [row] = databaseRows(result); + const affected = Number(row?.affected ?? 0); + if (!Number.isSafeInteger(affected) || affected < 0 || affected > IROH_RETENTION_BATCH_SIZE) { + throw new Error("invalid Iroh retention batch result"); + } + return affected; + }); +} + +async function irohRetentionBacklogExists( + now: Date, + challengeRetentionCutoff: Date, + auditRetentionCutoff: Date, +): Promise { + const result = await cloudDb().execute(sql` + select ( + exists ( + select 1 from iroh_endpoint_bindings + where revoked_at is not null and path_hints_next_expiry is not null + ) or exists ( + select 1 from iroh_endpoint_bindings + where revoked_at is null and path_hints_next_expiry <= ${now.toISOString()}::timestamptz + ) or exists ( + select 1 from iroh_registration_challenges + where expires_at < ${challengeRetentionCutoff.toISOString()}::timestamptz + ) or exists ( + select 1 from iroh_registration_challenges + where consumed_at < ${challengeRetentionCutoff.toISOString()}::timestamptz + ) or exists ( + select 1 from iroh_relay_token_issuances + where requested_at < ${auditRetentionCutoff.toISOString()}::timestamptz + ) or exists ( + select 1 from iroh_pair_grant_issuances + where expires_at < ${auditRetentionCutoff.toISOString()}::timestamptz + ) or exists ( + select 1 + from iroh_endpoint_bindings as binding + where binding.revoked_at < ${auditRetentionCutoff.toISOString()}::timestamptz + and not exists ( + select 1 from iroh_pair_grant_issuances as pair_grant + where pair_grant.initiator_binding_id = binding.id + or pair_grant.acceptor_binding_id = binding.id + ) + and not exists ( + select 1 from iroh_relay_token_issuances as issuance + where issuance.binding_id = binding.id + ) + ) + ) as backlog + `); + const [row] = databaseRows(result); + return row?.backlog === true; +} + +function databaseRows(result: unknown): readonly Record[] { + if (Array.isArray(result)) return result as readonly Record[]; + const rows = (result as { readonly rows?: unknown } | null)?.rows; + return Array.isArray(rows) ? rows as readonly Record[] : []; +} + +function retentionBudget( + value: number | undefined, + fallback: number, + maximum: number, + name: string, +): number { + const resolved = value ?? fallback; + if (!Number.isSafeInteger(resolved) || resolved < 1 || resolved > maximum) { + throw new Error(`invalid Iroh retention ${name}`); + } + return resolved; +} + function repositoryEffect( operation: string, run: () => Promise, diff --git a/web/services/iroh/trustBroker.ts b/web/services/iroh/trustBroker.ts index bf4665aba128..d500445871ee 100644 --- a/web/services/iroh/trustBroker.ts +++ b/web/services/iroh/trustBroker.ts @@ -259,6 +259,9 @@ export function makeIrohTrustBroker( if (initiator.platform !== "ios" || acceptor.platform !== "mac" || !acceptor.pairingEnabled) { return yield* Effect.fail(new IrohForbiddenError({ code: "target_not_pairable" })); } + if (initiator.deviceUuid === acceptor.deviceUuid) { + return yield* Effect.fail(new IrohForbiddenError({ code: "pair_grant_same_device" })); + } const issuedAtSeconds = Math.floor(now.getTime() / 1_000); const claims: PairGrantClaims = { jti: randomUUID(), @@ -343,6 +346,14 @@ export function makeIrohTrustBroker( nowSeconds: issuedAtSeconds, }, )); + yield* repository.finalizeEndpointAttestation({ + userId, + bindingId: binding.id, + deviceId: binding.deviceUuid, + endpointId: binding.endpointId, + identityGeneration: binding.identityGeneration, + platform, + }); return { attestation_version: IROH_ENDPOINT_ATTESTATION_VERSION, attestation, diff --git a/web/tests/iroh-db-behavior.test.ts b/web/tests/iroh-db-behavior.test.ts index a16ee490244f..91ab8882943e 100644 --- a/web/tests/iroh-db-behavior.test.ts +++ b/web/tests/iroh-db-behavior.test.ts @@ -173,6 +173,14 @@ describe("Iroh trust broker database behavior", () => { repository.revokeBinding({ userId, bindingId: macId, now: NOW }), repository.accountLanGeneration({ userId, now: NOW }), repository.pruneExpiredState({ userId, now: NOW }), + repository.finalizeEndpointAttestation({ + userId, + bindingId: ios.bindingId, + deviceId: ios.deviceId, + endpointId: ios.endpointId, + identityGeneration: ios.identityGeneration, + platform: ios.platform, + }), repository.recordPairGrant({ userId, jti: randomUUID(), @@ -305,7 +313,7 @@ describe("Iroh trust broker database behavior", () => { ${new Date(NOW.getTime() - 60_000)}, ${new Date(NOW.getTime() + 60_000)}, ${NOW} from generate_series(1, 119) as values(value) `; - const issue = (suffix: string) => Effect.runPromise(requiredRepository().issueChallenge({ + const issue = (suffix: string) => Effect.runPromiseExit(requiredRepository().issueChallenge({ userId, deviceUuid: randomUUID(), appInstanceId: randomUUID(), @@ -318,10 +326,14 @@ describe("Iroh trust broker database behavior", () => { expiresAt: new Date(NOW.getTime() + 5 * 60 * 1_000), })); - const results = await Promise.allSettled([issue("4"), issue("5")]); - expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1); - expect(results.filter((result) => result.status === "rejected")).toHaveLength(1); - expect(results.find((result) => result.status === "rejected")?.reason).toMatchObject({ + const results = await Promise.all([issue("4"), issue("5")]); + expect(results.filter((result) => result._tag === "Success")).toHaveLength(1); + expect(results.filter((result) => result._tag === "Failure")).toHaveLength(1); + const failure = results.find((result) => result._tag === "Failure"); + const causeError = failure?._tag === "Failure" + ? (failure.cause as unknown as { error?: unknown }).error + : undefined; + expect(causeError).toMatchObject({ _tag: "IrohQuotaExceededError", code: "challenge_account_rate_limited", }); diff --git a/web/tests/iroh-trust-broker.test.ts b/web/tests/iroh-trust-broker.test.ts index 7ddde17c31e6..7de190ae2352 100644 --- a/web/tests/iroh-trust-broker.test.ts +++ b/web/tests/iroh-trust-broker.test.ts @@ -503,7 +503,21 @@ class MemoryRepository implements IrohRepositoryShape { return typeof expiry === "string" && new Date(expiry) > input.now; }); } - return Effect.void; + return Effect.succeed({ + rowsProcessed: 0, + batches: 0, + backlog: false, + budgetExhausted: null, + byCategory: { + revokedHints: 0, + expiredHints: 0, + expiredChallenges: 0, + consumedChallenges: 0, + relayAudits: 0, + pairGrantAudits: 0, + revokedBindings: 0, + }, + }); } recordPairGrant(input: Parameters[0]) { @@ -706,7 +720,7 @@ function binding(overrides: Partial = {}): MutableBinding { tag: "stable", platform: "mac", displayName: null, - endpointId: "55".repeat(32), + endpointId: randomUUID().replaceAll("-", "").repeat(2), identityGeneration: 1, pairingEnabled: true, capabilities: [], From b33f3b146cec0258f9f53b612fa3cf18268f77c4 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Fri, 10 Jul 2026 01:43:21 -0700 Subject: [PATCH 07/37] fix(iroh-minter): support bounded hmac rotation --- services/iroh-relay-minter/.env.example | 2 + services/iroh-relay-minter/README.md | 17 +++++- services/iroh-relay-minter/src/lib.rs | 72 +++++++++++++++++++++---- 3 files changed, 81 insertions(+), 10 deletions(-) diff --git a/services/iroh-relay-minter/.env.example b/services/iroh-relay-minter/.env.example index 64b31761ab11..5f56d2143364 100644 --- a/services/iroh-relay-minter/.env.example +++ b/services/iroh-relay-minter/.env.example @@ -1,3 +1,5 @@ # Configure these only on the isolated relay-minter Vercel project. IROH_SERVICES_API_SECRET= CMUX_IROH_MINT_HMAC_SECRET_B64= +# Optional, minter-only overlap key during a bounded HMAC rotation. +CMUX_IROH_MINT_HMAC_PREVIOUS_SECRET_B64= diff --git a/services/iroh-relay-minter/README.md b/services/iroh-relay-minter/README.md index ec5664ca9605..082fae1e5470 100644 --- a/services/iroh-relay-minter/README.md +++ b/services/iroh-relay-minter/README.md @@ -17,6 +17,9 @@ The minter project requires: parsed by `iroh-services` 1.0.0 and is never returned or logged. - `CMUX_IROH_MINT_HMAC_SECRET_B64`: 32 to 256 random bytes encoded as canonical standard base64. Generate a new 32-byte value with `openssl rand -base64 32`. +- `CMUX_IROH_MINT_HMAC_PREVIOUS_SECRET_B64`: optional minter-only previous key + accepted during a bounded rotation overlap. It must differ from the current + key. The web project never receives this value. The web project requires the same `CMUX_IROH_MINT_HMAC_SECRET_B64` value and: @@ -24,7 +27,19 @@ The web project requires the same `CMUX_IROH_MINT_HMAC_SECRET_B64` value and: Rotate any Iroh Services credential previously pasted into chat or logs before putting it in Vercel. A Services credential rotation affects only the minter. -An HMAC rotation must update both projects together. + +Rotate the HMAC without an outage in this order: + +1. Deploy the minter with the new key in `CMUX_IROH_MINT_HMAC_SECRET_B64` and + the old key in `CMUX_IROH_MINT_HMAC_PREVIOUS_SECRET_B64`. +2. Change the web project's `CMUX_IROH_MINT_HMAC_SECRET_B64` to the new key. +3. Keep the previous key for at least five minutes, which covers the 30-second + request timestamp window and deployment propagation. +4. Remove `CMUX_IROH_MINT_HMAC_PREVIOUS_SECRET_B64` from the minter. + +The overlap changes only which HMAC key authenticates the existing fixed +method, path, timestamp, and body-hash transcript. It does not expand the +minter route or RCAN capabilities. ## Wire contract diff --git a/services/iroh-relay-minter/src/lib.rs b/services/iroh-relay-minter/src/lib.rs index 0e9a833ba0ce..72ec74d39530 100644 --- a/services/iroh-relay-minter/src/lib.rs +++ b/services/iroh-relay-minter/src/lib.rs @@ -38,6 +38,7 @@ const MAX_TOKEN_BYTES: usize = 16 * 1_024; const CLOCK_SKEW_SECONDS: u64 = 30; const SERVICES_SECRET_ENV: &str = "IROH_SERVICES_API_SECRET"; const HMAC_SECRET_ENV: &str = "CMUX_IROH_MINT_HMAC_SECRET_B64"; +const HMAC_PREVIOUS_SECRET_ENV: &str = "CMUX_IROH_MINT_HMAC_PREVIOUS_SECRET_B64"; const TIMESTAMP_HEADER: HeaderName = HeaderName::from_static("x-cmux-iroh-timestamp"); const SIGNATURE_HEADER: HeaderName = HeaderName::from_static("x-cmux-iroh-signature"); @@ -46,6 +47,7 @@ type HmacSha256 = Hmac; pub struct MinterConfig { issuer: SecretKey, hmac_secret: Zeroizing>, + hmac_previous_secret: Option>>, } impl MinterConfig { @@ -54,11 +56,23 @@ impl MinterConfig { let api_secret = ApiSecret::from_str(services_secret.as_str()).map_err(|_| ConfigurationError)?; let hmac_secret_text = Zeroizing::new(bounded_env(HMAC_SECRET_ENV, 512)?); - let hmac_secret = decode_hmac_secret(hmac_secret_text.as_str())?; + let hmac_secret = Zeroizing::new(decode_hmac_secret(hmac_secret_text.as_str())?); + let hmac_previous_secret = optional_bounded_env(HMAC_PREVIOUS_SECRET_ENV, 512)? + .map(Zeroizing::new) + .map(|value| decode_hmac_secret(value.as_str())) + .transpose()? + .map(Zeroizing::new); + if hmac_previous_secret + .as_ref() + .is_some_and(|previous| previous.as_slice() == hmac_secret.as_slice()) + { + return Err(ConfigurationError); + } Ok(Self { issuer: api_secret.secret, - hmac_secret: Zeroizing::new(hmac_secret), + hmac_secret, + hmac_previous_secret, }) } } @@ -123,12 +137,7 @@ where let signature = decode_signature(signature_text).ok_or(RequestFailure::Authentication)?; let body = read_bounded_body(request.into_body()).await?; - verify_hmac( - config.hmac_secret.as_slice(), - ×tamp_text, - &body, - &signature, - )?; + verify_configured_hmac(config, ×tamp_text, &body, &signature)?; let input: MintRequest = serde_json::from_slice(&body).map_err(|_| RequestFailure::InvalidBody)?; @@ -236,6 +245,25 @@ fn verify_hmac( .map_err(|_| RequestFailure::Authentication) } +fn verify_configured_hmac( + config: &MinterConfig, + timestamp: &str, + body: &[u8], + signature: &[u8; 32], +) -> Result<(), RequestFailure> { + let current_matches = + verify_hmac(config.hmac_secret.as_slice(), timestamp, body, signature).is_ok(); + let previous_matches = config + .hmac_previous_secret + .as_ref() + .is_some_and(|secret| verify_hmac(secret.as_slice(), timestamp, body, signature).is_ok()); + if current_matches || previous_matches { + Ok(()) + } else { + Err(RequestFailure::Authentication) + } +} + fn parse_endpoint_id(value: &str) -> Result { if value.len() != 64 || !value @@ -330,6 +358,18 @@ fn bounded_env(name: &str, max_bytes: usize) -> Result Result, ConfigurationError> { + match env::var(name) { + Ok(value) if !value.is_empty() && value.len() <= max_bytes => Ok(Some(value)), + Ok(_) => Err(ConfigurationError), + Err(env::VarError::NotPresent) => Ok(None), + Err(env::VarError::NotUnicode(_)) => Err(ConfigurationError), + } +} + fn unix_seconds(time: SystemTime) -> Option { time.duration_since(UNIX_EPOCH) .ok() @@ -568,7 +608,7 @@ mod tests { Method::POST, MINT_PATH, timestamp, - body, + body.clone(), &PREVIOUS_HMAC_SECRET, ); @@ -576,6 +616,19 @@ mod tests { handle_request(request, &config, now).await.status(), StatusCode::OK ); + let previous_after_overlap = signed_request_with_secret( + Method::POST, + MINT_PATH, + timestamp, + body, + &PREVIOUS_HMAC_SECRET, + ); + assert_eq!( + handle_request(previous_after_overlap, &test_config(), now) + .await + .status(), + StatusCode::UNAUTHORIZED + ); } #[tokio::test] @@ -763,6 +816,7 @@ mod tests { MinterConfig { issuer: SecretKey::from_bytes(&[0x11; 32]), hmac_secret: Zeroizing::new(TEST_HMAC_SECRET.to_vec()), + hmac_previous_secret: None, } } From b552fc6666c0333045ba5173db3b185576868e5d Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Fri, 10 Jul 2026 04:10:41 -0700 Subject: [PATCH 08/37] fix(iroh): accept JSON media type parameters --- services/iroh-relay-minter/README.md | 5 ++- services/iroh-relay-minter/src/lib.rs | 53 ++++++++++++++++++++++++--- 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/services/iroh-relay-minter/README.md b/services/iroh-relay-minter/README.md index 082fae1e5470..9b5493a24139 100644 --- a/services/iroh-relay-minter/README.md +++ b/services/iroh-relay-minter/README.md @@ -43,8 +43,9 @@ minter route or RCAN capabilities. ## Wire contract -The only accepted route is `POST /api/relay-token` with exact -`Content-Type: application/json`, no query string, and this body: +The only accepted route is `POST /api/relay-token` with one `Content-Type` +header whose media type is `application/json`, optionally followed by parameters +such as `charset=utf-8`, no query string, and this body: ```json {"endpointId":"<64 lowercase hex characters>","lifetimeSeconds":86400} diff --git a/services/iroh-relay-minter/src/lib.rs b/services/iroh-relay-minter/src/lib.rs index 72ec74d39530..2672a171f1b0 100644 --- a/services/iroh-relay-minter/src/lib.rs +++ b/services/iroh-relay-minter/src/lib.rs @@ -279,7 +279,12 @@ fn parse_endpoint_id(value: &str) -> Result { fn require_json_content_type(headers: &HeaderMap) -> Result<(), RequestFailure> { let content_type = single_header(headers, &CONTENT_TYPE).ok_or(RequestFailure::ContentType)?; - if content_type == "application/json" { + let media_type = content_type + .split(';') + .next() + .map(str::trim) + .unwrap_or_default(); + if media_type.eq_ignore_ascii_case("application/json") { Ok(()) } else { Err(RequestFailure::ContentType) @@ -476,7 +481,11 @@ mod tests { use futures_util::stream; use http_body::Frame; use http_body_util::{BodyExt as _, Full, StreamBody}; - use hyper::{Method, Request, StatusCode, body::Bytes, header::ALLOW}; + use hyper::{ + Method, Request, StatusCode, + body::Bytes, + header::{ALLOW, HeaderValue}, + }; use iroh::SecretKey; use rcan::{CapabilityOrigin, Expires, Rcan}; use time::OffsetDateTime; @@ -743,7 +752,7 @@ mod tests { } #[tokio::test] - async fn requires_the_exact_json_content_type() { + async fn accepts_json_content_type_parameters() { let config = test_config(); let now = SystemTime::now(); let timestamp = unix_seconds(now).expect("test clock is valid"); @@ -751,13 +760,47 @@ mod tests { let mut request = signed_request(Method::POST, MINT_PATH, timestamp, body); request.headers_mut().insert( CONTENT_TYPE, - "text/plain".parse().expect("valid test header"), + "Application/JSON; charset=utf-8" + .parse() + .expect("valid test header"), ); assert_eq!( handle_request(request, &config, now).await.status(), - StatusCode::UNSUPPORTED_MEDIA_TYPE + StatusCode::OK + ); + } + + #[tokio::test] + async fn rejects_non_json_duplicate_and_malformed_content_type_headers() { + let config = test_config(); + let now = SystemTime::now(); + let timestamp = unix_seconds(now).expect("test clock is valid"); + let body = valid_body(&test_endpoint().to_string()); + let mut non_json = signed_request(Method::POST, MINT_PATH, timestamp, body.clone()); + non_json.headers_mut().insert( + CONTENT_TYPE, + "text/plain".parse().expect("valid test header"), + ); + let mut duplicate = signed_request(Method::POST, MINT_PATH, timestamp, body.clone()); + duplicate.headers_mut().append( + CONTENT_TYPE, + "application/json; charset=utf-8" + .parse() + .expect("valid test header"), ); + let mut malformed = signed_request(Method::POST, MINT_PATH, timestamp, body); + malformed.headers_mut().insert( + CONTENT_TYPE, + HeaderValue::from_bytes(&[0xff]).expect("opaque header bytes are representable"), + ); + + for request in [non_json, duplicate, malformed] { + assert_eq!( + handle_request(request, &config, now).await.status(), + StatusCode::UNSUPPORTED_MEDIA_TYPE + ); + } } #[test] From c1ad99b512a70ba1aea37732108d0cbc7a07ca63 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Fri, 10 Jul 2026 04:20:29 -0700 Subject: [PATCH 09/37] test(iroh): cover abandoned relay reservations --- web/tests/iroh-db-behavior.test.ts | 72 ++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/web/tests/iroh-db-behavior.test.ts b/web/tests/iroh-db-behavior.test.ts index 91ab8882943e..069582feb9f1 100644 --- a/web/tests/iroh-db-behavior.test.ts +++ b/web/tests/iroh-db-behavior.test.ts @@ -556,6 +556,78 @@ describe("Iroh trust broker database behavior", () => { expect(total).toBe("3"); }); + dbTest("expires abandoned relay reservations before enforcing endpoint and account quotas", async () => { + const repo = requiredRepository(); + const endpointUserId = "user-relay-abandoned-endpoint"; + const endpointBindingId = await insertBinding({ + userId: endpointUserId, + endpointId: "63".repeat(32), + }); + for (let index = 0; index < 3; index += 1) { + await requiredSql()` + insert into iroh_relay_token_issuances ( + user_id, binding_id, endpoint_id_hash, status, requested_at + ) values ( + ${endpointUserId}, ${endpointBindingId}, ${"64".repeat(32)}, 'pending', + ${new Date(NOW.getTime() - 5 * 60 * 1_000 - index * 1_000)} + ) + `; + } + + await Effect.runPromise(repo.reserveRelayIssuance({ + userId: endpointUserId, + bindingId: endpointBindingId, + now: NOW, + })); + const endpointStatuses = await requiredSql()>` + select status, count(*)::text as total + from iroh_relay_token_issuances + where user_id = ${endpointUserId} + group by status + order by status + `; + expect(endpointStatuses).toEqual([ + { status: "expired", total: "3" }, + { status: "pending", total: "1" }, + ]); + + const accountUserId = "user-relay-abandoned-account"; + const accountBindingIds: string[] = []; + for (let index = 0; index < 10; index += 1) { + const bindingId = await insertBinding({ + userId: accountUserId, + endpointId: (0xa0 + index).toString(16).repeat(32), + }); + accountBindingIds.push(bindingId); + await requiredSql()` + insert into iroh_relay_token_issuances ( + user_id, binding_id, endpoint_id_hash, status, requested_at + ) + select + ${accountUserId}, ${bindingId}, ${"65".repeat(32)}, 'pending', + ${new Date(NOW.getTime() - 15 * 60 * 1_000)} - make_interval(secs => value) + from generate_series(1, 10) as values(value) + `; + } + + await Effect.runPromise(repo.reserveRelayIssuance({ + userId: accountUserId, + bindingId: accountBindingIds[0]!, + now: NOW, + })); + const accountStatuses = await requiredSql()>` + select status, count(*)::text as total + from iroh_relay_token_issuances + where user_id = ${accountUserId} + group by status + order by status + `; + expect(accountStatuses).toEqual([ + { status: "expired", total: "100" }, + { status: "pending", total: "1" }, + ]); + }); + dbTest("fails relay finalization when revocation commits during provider mint", async () => { const repo = requiredRepository(); const endpointId = "61".repeat(32); From be7801a9d09668da2c5ece7d0425919be99f2bb2 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Fri, 10 Jul 2026 04:22:42 -0700 Subject: [PATCH 10/37] fix(iroh): expire abandoned relay reservations --- .../migration.sql | 6 ++++++ web/db/schema.ts | 7 +++++-- web/services/iroh/README.md | 4 +++- web/services/iroh/repository.ts | 21 ++++++++++++++++++- 4 files changed, 34 insertions(+), 4 deletions(-) create mode 100644 web/db/migrations/20260710113000_iroh_relay_reservation_expiry/migration.sql diff --git a/web/db/migrations/20260710113000_iroh_relay_reservation_expiry/migration.sql b/web/db/migrations/20260710113000_iroh_relay_reservation_expiry/migration.sql new file mode 100644 index 000000000000..43fa71ded019 --- /dev/null +++ b/web/db/migrations/20260710113000_iroh_relay_reservation_expiry/migration.sql @@ -0,0 +1,6 @@ +ALTER TABLE "iroh_relay_token_issuances" + DROP CONSTRAINT "iroh_relay_token_issuances_status_check"; +--> statement-breakpoint +ALTER TABLE "iroh_relay_token_issuances" + ADD CONSTRAINT "iroh_relay_token_issuances_status_check" + CHECK ("status" in ('pending', 'succeeded', 'failed', 'expired')); diff --git a/web/db/schema.ts b/web/db/schema.ts index adfc9ee36eff..6bc26f26b182 100644 --- a/web/db/schema.ts +++ b/web/db/schema.ts @@ -781,7 +781,10 @@ export const irohRelayTokenIssuances = pgTable( .notNull() .references(() => irohEndpointBindings.id, { onDelete: "cascade" }), endpointIdHash: text("endpoint_id_hash").notNull(), - status: text("status").notNull().default("pending"), + status: text("status") + .$type<"pending" | "succeeded" | "failed" | "expired">() + .notNull() + .default("pending"), tokenHash: text("token_hash"), failureCode: text("failure_code"), requestedAt: timestamp("requested_at", { withTimezone: true }).notNull(), @@ -790,7 +793,7 @@ export const irohRelayTokenIssuances = pgTable( }, (table) => [ check("iroh_relay_token_issuances_endpoint_hash_check", sql`${table.endpointIdHash} ~ '^[0-9a-f]{64}$'`), - check("iroh_relay_token_issuances_status_check", sql`${table.status} in ('pending', 'succeeded', 'failed')`), + check("iroh_relay_token_issuances_status_check", sql`${table.status} in ('pending', 'succeeded', 'failed', 'expired')`), index("iroh_relay_token_issuances_binding_requested_idx").on(table.bindingId, table.requestedAt), index("iroh_relay_token_issuances_user_requested_idx").on(table.userId, table.requestedAt, table.id), index("iroh_relay_token_issuances_requested_idx").on(table.requestedAt, table.id), diff --git a/web/services/iroh/README.md b/web/services/iroh/README.md index f3d9090cee4e..3acb37629376 100644 --- a/web/services/iroh/README.md +++ b/web/services/iroh/README.md @@ -30,7 +30,9 @@ challenges per device per ten minutes, 32 outstanding challenges per account, 32 active bindings per account, eight active bindings per physical device, 60 pair grants per account per hour, three relay mints per endpoint per ten minutes, 12 relay mints per endpoint per day, and 100 relay mints per account -per day. The optional Vercel Firewall rule is defense in depth. A tagged-build +per day. A relay reservation remains active for 60 seconds, then the next +account-scoped reservation marks it expired before applying those quotas. The +optional Vercel Firewall rule is defense in depth. A tagged-build device-limit override requires a server flag, an exact authenticated user-id allowlist match, and an exact deployment-environment allowlist match; it never raises the 32-binding account limit and records an audit marker on the binding. diff --git a/web/services/iroh/repository.ts b/web/services/iroh/repository.ts index 7975b32818e9..ac0bd77dff5a 100644 --- a/web/services/iroh/repository.ts +++ b/web/services/iroh/repository.ts @@ -1,4 +1,4 @@ -import { and, asc, count, eq, gt, inArray, isNull, lte, or, sql } from "drizzle-orm"; +import { and, asc, count, eq, gt, inArray, isNull, lte, ne, or, sql } from "drizzle-orm"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -34,6 +34,7 @@ export const IROH_RETENTION_BATCH_SIZE = 500; export const IROH_RETENTION_MAX_ROWS = 10_000; export const IROH_RETENTION_MAX_DURATION_MS = 8_000; export const IROH_ACCOUNT_CHALLENGE_LIMIT = 120; +export const IROH_RELAY_RESERVATION_LEASE_MS = 60 * 1_000; export type IrohRetentionCategory = | "revokedHints" @@ -683,6 +684,22 @@ function makeLiveRepository(): IrohRepositoryShape { .limit(1); if (!binding) throw new IrohNotFoundError({ resource: "binding" }); + const reservationCutoff = new Date( + input.now.getTime() - IROH_RELAY_RESERVATION_LEASE_MS, + ); + await tx + .update(irohRelayTokenIssuances) + .set({ + status: "expired", + completedAt: input.now, + failureCode: "reservation_expired", + }) + .where(and( + eq(irohRelayTokenIssuances.userId, input.userId), + eq(irohRelayTokenIssuances.status, "pending"), + lte(irohRelayTokenIssuances.requestedAt, reservationCutoff), + )); + const dayAgo = new Date(input.now.getTime() - 24 * 60 * 60 * 1_000); const tenMinutesAgo = new Date(input.now.getTime() - 10 * 60 * 1_000); const endpointRows = await tx @@ -690,6 +707,7 @@ function makeLiveRepository(): IrohRepositoryShape { .from(irohRelayTokenIssuances) .where(and( eq(irohRelayTokenIssuances.bindingId, binding.id), + ne(irohRelayTokenIssuances.status, "expired"), gt(irohRelayTokenIssuances.requestedAt, dayAgo), )) .orderBy(asc(irohRelayTokenIssuances.requestedAt)); @@ -705,6 +723,7 @@ function makeLiveRepository(): IrohRepositoryShape { .from(irohRelayTokenIssuances) .where(and( eq(irohRelayTokenIssuances.userId, input.userId), + ne(irohRelayTokenIssuances.status, "expired"), gt(irohRelayTokenIssuances.requestedAt, dayAgo), )) .orderBy(asc(irohRelayTokenIssuances.requestedAt)); From f4c5841483ff5cfd954b2eda8f72c9021745a95e Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Fri, 10 Jul 2026 04:39:07 -0700 Subject: [PATCH 11/37] test(iroh): cover LAN rendezvous consistency --- web/tests/iroh-model-crypto.test.ts | 23 +++++++++++++++++ web/tests/iroh-trust-broker.test.ts | 39 ++++++++++++++++++++++++++++- 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/web/tests/iroh-model-crypto.test.ts b/web/tests/iroh-model-crypto.test.ts index c27315ad27b0..83140b1617e0 100644 --- a/web/tests/iroh-model-crypto.test.ts +++ b/web/tests/iroh-model-crypto.test.ts @@ -7,6 +7,7 @@ import { assertCurrentSigningKey, createOfflinePairSessionRecord, deriveAccountSubject, + deriveLanRendezvousKey, parseVerificationKeys, signEndpointAttestation, signPairGrant, @@ -40,6 +41,28 @@ import { const NOW = new Date("2026-07-09T20:00:00.000Z"); +describe("Iroh LAN rendezvous derivation", () => { + test("derives an account-scoped generation key with a server-only HMAC secret", () => { + const secret = Buffer.alloc(32, 0x4c); + const encodedSecret = secret.toString("base64"); + const expected = createHmac("sha256", secret) + .update("cmux/iroh/lan-rendezvous/v1\0", "utf8") + .update("account-a", "utf8") + .update("\0", "utf8") + .update("1", "utf8") + .digest("base64url"); + + expect(deriveLanRendezvousKey(encodedSecret, "account-a", 1)).toBe(expected); + expect(deriveLanRendezvousKey(encodedSecret, "account-b", 1)).not.toBe(expected); + expect(deriveLanRendezvousKey(encodedSecret, "account-a", 2)).not.toBe(expected); + expect( + deriveLanRendezvousKey(Buffer.alloc(32, 0x4d).toString("base64"), "account-a", 1), + ).not.toBe(expected); + expect(() => deriveLanRendezvousKey(Buffer.alloc(31).toString("base64"), "account-a", 1)) + .toThrow(); + }); +}); + function registrationPayload(pathHint: Record) { return { route_contract_version: 1, diff --git a/web/tests/iroh-trust-broker.test.ts b/web/tests/iroh-trust-broker.test.ts index 7de190ae2352..410c348da29b 100644 --- a/web/tests/iroh-trust-broker.test.ts +++ b/web/tests/iroh-trust-broker.test.ts @@ -165,6 +165,36 @@ describe("Iroh discovery and grants", () => { expect(JSON.stringify(discovered.grant_verification_keys)).not.toContain("PRIVATE KEY"); }); + test("does not combine a pre-revocation binding with a post-revocation LAN generation", async () => { + const fixture = makeFixture(); + const active = binding({ userId: USER_A }); + fixture.repository.bindings.push(active); + let releaseList: (() => void) | undefined; + const listCanReturn = new Promise((resolve) => { + releaseList = resolve; + }); + let didList: (() => void) | undefined; + const listed = new Promise((resolve) => { + didList = resolve; + }); + fixture.repository.afterListActiveBindings = async () => { + didList?.(); + await listCanReturn; + }; + + const discovery = Effect.runPromise(fixture.broker.discover(USER_A, NOW)); + await listed; + await Effect.runPromise(fixture.broker.revoke(USER_A, { bindingId: active.id }, NOW)); + releaseList?.(); + const result = await discovery as { + bindings: unknown[]; + lan_rendezvous: { generation: number }; + }; + + expect(result.bindings).toEqual([]); + expect(result.lan_rendezvous.generation).toBe(2); + }); + test("pair grants require two same-user bindings and a pairable Mac", async () => { const fixture = makeFixture(); const initiator = binding({ userId: USER_A, platform: "ios", pairingEnabled: false }); @@ -385,6 +415,7 @@ class MemoryRepository implements IrohRepositoryShape { status: string; }> = []; private lanGenerations = new Map(); + afterListActiveBindings: (() => Promise) | undefined; beforeRecordPairGrant: (() => void) | undefined; beforeFinalizeEndpointAttestation: (() => void) | undefined; @@ -462,7 +493,13 @@ class MemoryRepository implements IrohRepositoryShape { } listActiveBindings(userId: string) { - return Effect.succeed(this.bindings.filter((row) => row.userId === userId && !row.revokedAt)); + const bindings = this.bindings.filter((row) => row.userId === userId && !row.revokedAt); + return this.afterListActiveBindings + ? Effect.promise(async () => { + await this.afterListActiveBindings?.(); + return bindings; + }) + : Effect.succeed(bindings); } findActiveBindings(userId: string, bindingIds: readonly string[]) { From c9661e490408ebb951796e37431102bb683f7b9b Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Fri, 10 Jul 2026 04:42:26 -0700 Subject: [PATCH 12/37] fix(iroh): serialize LAN discovery with revocation --- web/.env.example | 2 + web/services/iroh/README.md | 8 +++ web/services/iroh/repository.ts | 65 ++++++++++++----------- web/services/iroh/trustBroker.ts | 12 +++-- web/tests/iroh-db-behavior.test.ts | 82 ++++++++++++++++++++++++++++- web/tests/iroh-trust-broker.test.ts | 44 +++++++--------- 6 files changed, 151 insertions(+), 62 deletions(-) diff --git a/web/.env.example b/web/.env.example index c20fe40b6daf..88d2ade3f521 100644 --- a/web/.env.example +++ b/web/.env.example @@ -44,6 +44,8 @@ STACK_SECRET_SERVER_KEY= # Grant verification JSON maps the current and previous KIDs to Ed25519 SPKI # PEM strings. The developer binding override remains off unless all three # override settings explicitly match the authenticated user and deployment. +# Independent 32-byte random base64 secret used only for account-scoped LAN +# rendezvous HMAC derivation. Generate with `openssl rand -base64 32`. CMUX_IROH_LAN_DISCOVERY_SECRET_B64= # Independent 32-byte base64 secret used only to derive opaque account subjects # for short-lived offline endpoint attestations. diff --git a/web/services/iroh/README.md b/web/services/iroh/README.md index 3acb37629376..0998feeed247 100644 --- a/web/services/iroh/README.md +++ b/web/services/iroh/README.md @@ -25,6 +25,14 @@ display name. The hourly `/api/internal/iroh/retention` cron applies the same policy across inactive accounts; responses also filter expired hints defensively. +The LAN rendezvous key is HMAC-derived from an independent random server secret, +the exact Stack user id, and an account generation. Discovery reads active +bindings and that generation in one transaction under the same account lock as +registration and revocation, so it cannot mix tuples from opposite sides of a +revocation. Every successful binding revoke increments the generation in the +revocation transaction. Sign-out callers must invoke the authenticated revoke +route with their captured binding id before discarding the Stack credential. + Postgres advisory locks make the authoritative limits concurrency-safe: six challenges per device per ten minutes, 32 outstanding challenges per account, 32 active bindings per account, eight active bindings per physical device, 60 diff --git a/web/services/iroh/repository.ts b/web/services/iroh/repository.ts index ac0bd77dff5a..93f7456eb056 100644 --- a/web/services/iroh/repository.ts +++ b/web/services/iroh/repository.ts @@ -89,9 +89,13 @@ export type IrohRepositoryShape = { readonly now: Date; readonly deviceLimitOverrideAllowed: boolean; }) => Effect.Effect; - readonly listActiveBindings: ( - userId: string, - ) => Effect.Effect; + readonly discoverySnapshot: (input: { + readonly userId: string; + readonly now: Date; + }) => Effect.Effect<{ + readonly bindings: IrohBindingRecord[]; + readonly lanDiscoveryGeneration: number; + }, RepositoryError>; readonly findActiveBindings: ( userId: string, bindingIds: readonly string[], @@ -101,10 +105,6 @@ export type IrohRepositoryShape = { readonly bindingId: string; readonly now: Date; }) => Effect.Effect; - readonly accountLanGeneration: (input: { - readonly userId: string; - readonly now: Date; - }) => Effect.Effect; readonly pruneExpiredState: (input: { readonly userId: string; readonly now: Date; @@ -375,15 +375,32 @@ function makeLiveRepository(): IrohRepositoryShape { }); }), - listActiveBindings: (userId) => repositoryEffect("list_bindings", async () => { - return await cloudDb() - .select() - .from(irohEndpointBindings) - .where(and( - eq(irohEndpointBindings.userId, userId), - isNull(irohEndpointBindings.revokedAt), - )) - .orderBy(asc(irohEndpointBindings.registeredAt)); + discoverySnapshot: (input) => repositoryEffect("discovery_snapshot", async () => { + return await cloudDb().transaction(async (tx) => { + await assertIrohUserMutationAllowed(tx, input.userId); + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`iroh:binding:${input.userId}`}, 0))`); + const [state] = await tx + .insert(irohAccountSecurityStates) + .values({ userId: input.userId, lanDiscoveryGeneration: 1, createdAt: input.now, updatedAt: input.now }) + .onConflictDoUpdate({ + target: irohAccountSecurityStates.userId, + set: { updatedAt: sql`${irohAccountSecurityStates.updatedAt}` }, + }) + .returning({ generation: irohAccountSecurityStates.lanDiscoveryGeneration }); + if (!state) throw new Error("account security state returned no row"); + const bindings = await tx + .select() + .from(irohEndpointBindings) + .where(and( + eq(irohEndpointBindings.userId, input.userId), + isNull(irohEndpointBindings.revokedAt), + )) + .orderBy(asc(irohEndpointBindings.registeredAt)); + return { + bindings, + lanDiscoveryGeneration: state.generation, + }; + }); }), findActiveBindings: (userId, bindingIds) => repositoryEffect("find_bindings", async () => { @@ -445,22 +462,6 @@ function makeLiveRepository(): IrohRepositoryShape { }); }), - accountLanGeneration: (input) => repositoryEffect("lan_generation", async () => { - return await cloudDb().transaction(async (tx) => { - await assertIrohUserMutationAllowed(tx, input.userId); - const [state] = await tx - .insert(irohAccountSecurityStates) - .values({ userId: input.userId, lanDiscoveryGeneration: 1, createdAt: input.now, updatedAt: input.now }) - .onConflictDoUpdate({ - target: irohAccountSecurityStates.userId, - set: { updatedAt: sql`${irohAccountSecurityStates.updatedAt}` }, - }) - .returning({ generation: irohAccountSecurityStates.lanDiscoveryGeneration }); - if (!state) throw new Error("account security state returned no row"); - return state.generation; - }); - }), - pruneExpiredState: (input) => repositoryEffect("prune_expired_state", async () => { await cloudDb().transaction(async (tx) => { await assertIrohUserMutationAllowed(tx, input.userId); diff --git a/web/services/iroh/trustBroker.ts b/web/services/iroh/trustBroker.ts index d500445871ee..e35bfccb0497 100644 --- a/web/services/iroh/trustBroker.ts +++ b/web/services/iroh/trustBroker.ts @@ -221,19 +221,21 @@ export function makeIrohTrustBroker( discover: (userId, now = new Date()) => Effect.gen(function* () { yield* repository.pruneExpiredState({ userId, now }); - const bindings = yield* repository.listActiveBindings(userId); - const generation = yield* repository.accountLanGeneration({ userId, now }); + const snapshot = yield* repository.discoverySnapshot({ userId, now }); const rendezvousKey = yield* parseEffect(() => deriveLanRendezvousKey( config.lanDiscoverySecretBase64, userId, - generation, + snapshot.lanDiscoveryGeneration, )); const verificationKeys = yield* parseEffect(() => signingVerificationKeys(config)); return { route_contract_version: 1, - bindings: bindings.map((binding) => publicBinding(binding, now)), + bindings: snapshot.bindings.map((binding) => publicBinding(binding, now)), relay_fleet: MANAGED_RELAY_URLS, - lan_rendezvous: { generation, key: rendezvousKey }, + lan_rendezvous: { + generation: snapshot.lanDiscoveryGeneration, + key: rendezvousKey, + }, grant_verification_keys: verificationKeys.keySet, }; }), diff --git a/web/tests/iroh-db-behavior.test.ts b/web/tests/iroh-db-behavior.test.ts index 069582feb9f1..efa4adc7c55e 100644 --- a/web/tests/iroh-db-behavior.test.ts +++ b/web/tests/iroh-db-behavior.test.ts @@ -171,7 +171,7 @@ describe("Iroh trust broker database behavior", () => { const operations: Array> = [ repository.findActiveBindings(userId, [iosId, macId]), repository.revokeBinding({ userId, bindingId: macId, now: NOW }), - repository.accountLanGeneration({ userId, now: NOW }), + repository.discoverySnapshot({ userId, now: NOW }), repository.pruneExpiredState({ userId, now: NOW }), repository.finalizeEndpointAttestation({ userId, @@ -368,6 +368,86 @@ describe("Iroh trust broker database behavior", () => { `, "22003"); }); + dbTest("keeps LAN discovery account-scoped and coherent across binding revocation", async () => { + const repo = requiredRepository(); + const userId = "user-lan-revoke"; + const firstBindingId = await insertBinding({ + userId, + endpointId: "44".repeat(32), + }); + const secondBindingId = await insertBinding({ + userId, + endpointId: "45".repeat(32), + }); + const otherBindingId = await insertBinding({ + userId: "user-lan-other", + endpointId: "46".repeat(32), + }); + + const initial = await Effect.runPromise(repo.discoverySnapshot({ userId, now: NOW })); + const otherInitial = await Effect.runPromise(repo.discoverySnapshot({ + userId: "user-lan-other", + now: NOW, + })); + expect(initial.lanDiscoveryGeneration).toBe(1); + expect(initial.bindings.map((binding) => binding.id).sort()).toEqual([ + firstBindingId, + secondBindingId, + ].sort()); + expect(otherInitial).toMatchObject({ + lanDiscoveryGeneration: 1, + bindings: [{ id: otherBindingId }], + }); + + expect(await Effect.runPromise(repo.revokeBinding({ + userId, + bindingId: firstBindingId, + now: NOW, + }))).toBe(true); + const afterFirstRevoke = await Effect.runPromise(repo.discoverySnapshot({ userId, now: NOW })); + expect(afterFirstRevoke.lanDiscoveryGeneration).toBe(2); + expect(afterFirstRevoke.bindings.map((binding) => binding.id)).toEqual([secondBindingId]); + expect(await Effect.runPromise(repo.revokeBinding({ + userId, + bindingId: firstBindingId, + now: NOW, + }))).toBe(false); + + let concurrentDiscovery: ReturnType | undefined; + await requiredSql().begin(async (revocationSql) => { + await revocationSql` + select pg_advisory_xact_lock(hashtextextended(${`iroh:binding:${userId}`}, 0)) + `; + await revocationSql` + update iroh_endpoint_bindings + set revoked_at = ${NOW}, revoked_reason = 'user_requested', + path_hints = '[]'::jsonb, path_hints_next_expiry = null, updated_at = ${NOW} + where id = ${secondBindingId} and user_id = ${userId} and revoked_at is null + `; + await revocationSql` + update iroh_account_security_states + set lan_discovery_generation = lan_discovery_generation + 1, updated_at = ${NOW} + where user_id = ${userId} + `; + concurrentDiscovery = Effect.runPromise(repo.discoverySnapshot({ userId, now: NOW })); + await waitForAdvisoryLockWaiter(); + }); + if (!concurrentDiscovery) throw new Error("concurrent discovery was not started"); + const afterConcurrentRevoke = await concurrentDiscovery; + expect(afterConcurrentRevoke).toMatchObject({ + lanDiscoveryGeneration: 3, + bindings: [], + }); + const otherAfter = await Effect.runPromise(repo.discoverySnapshot({ + userId: "user-lan-other", + now: NOW, + })); + expect(otherAfter).toMatchObject({ + lanDiscoveryGeneration: 1, + bindings: [{ id: otherBindingId }], + }); + }); + dbTest("serializes the pair-grant hourly quota", async () => { const repo = requiredRepository(); const initiatorId = await insertBinding({ userId: "user-pair", platform: "ios", endpointId: "50".repeat(32) }); diff --git a/web/tests/iroh-trust-broker.test.ts b/web/tests/iroh-trust-broker.test.ts index 410c348da29b..4c1d9c96b1be 100644 --- a/web/tests/iroh-trust-broker.test.ts +++ b/web/tests/iroh-trust-broker.test.ts @@ -169,23 +169,23 @@ describe("Iroh discovery and grants", () => { const fixture = makeFixture(); const active = binding({ userId: USER_A }); fixture.repository.bindings.push(active); - let releaseList: (() => void) | undefined; - const listCanReturn = new Promise((resolve) => { - releaseList = resolve; + let releaseSnapshot: (() => void) | undefined; + const snapshotCanRead = new Promise((resolve) => { + releaseSnapshot = resolve; }); - let didList: (() => void) | undefined; - const listed = new Promise((resolve) => { - didList = resolve; + let didBeginSnapshot: (() => void) | undefined; + const snapshotStarted = new Promise((resolve) => { + didBeginSnapshot = resolve; }); - fixture.repository.afterListActiveBindings = async () => { - didList?.(); - await listCanReturn; + fixture.repository.beforeDiscoverySnapshot = async () => { + didBeginSnapshot?.(); + await snapshotCanRead; }; const discovery = Effect.runPromise(fixture.broker.discover(USER_A, NOW)); - await listed; + await snapshotStarted; await Effect.runPromise(fixture.broker.revoke(USER_A, { bindingId: active.id }, NOW)); - releaseList?.(); + releaseSnapshot?.(); const result = await discovery as { bindings: unknown[]; lan_rendezvous: { generation: number }; @@ -415,7 +415,7 @@ class MemoryRepository implements IrohRepositoryShape { status: string; }> = []; private lanGenerations = new Map(); - afterListActiveBindings: (() => Promise) | undefined; + beforeDiscoverySnapshot: (() => Promise) | undefined; beforeRecordPairGrant: (() => void) | undefined; beforeFinalizeEndpointAttestation: (() => void) | undefined; @@ -492,14 +492,14 @@ class MemoryRepository implements IrohRepositoryShape { return Effect.succeed(inserted); } - listActiveBindings(userId: string) { - const bindings = this.bindings.filter((row) => row.userId === userId && !row.revokedAt); - return this.afterListActiveBindings - ? Effect.promise(async () => { - await this.afterListActiveBindings?.(); - return bindings; - }) - : Effect.succeed(bindings); + discoverySnapshot(input: Parameters[0]) { + return Effect.promise(async () => { + await this.beforeDiscoverySnapshot?.(); + return { + bindings: this.bindings.filter((row) => row.userId === input.userId && !row.revokedAt), + lanDiscoveryGeneration: this.lanGenerations.get(input.userId) ?? 1, + }; + }); } findActiveBindings(userId: string, bindingIds: readonly string[]) { @@ -517,10 +517,6 @@ class MemoryRepository implements IrohRepositoryShape { return Effect.succeed(true); } - accountLanGeneration(input: Parameters[0]) { - return Effect.succeed(this.lanGenerations.get(input.userId) ?? 1); - } - pruneExpiredState(input: Parameters[0]) { for (const row of this.bindings.filter((candidate) => candidate.userId === input.userId)) { row.pathHints = row.pathHints.filter((hint) => { From f0b95cca1da857e7f522f512b837c9fa15fafc0f Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Fri, 10 Jul 2026 07:41:21 -0700 Subject: [PATCH 13/37] test(iroh): cover canonical trust errors --- web/tests/iroh-model-crypto.test.ts | 61 +++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/web/tests/iroh-model-crypto.test.ts b/web/tests/iroh-model-crypto.test.ts index 83140b1617e0..8e8f0bd9a056 100644 --- a/web/tests/iroh-model-crypto.test.ts +++ b/web/tests/iroh-model-crypto.test.ts @@ -1,17 +1,21 @@ import { describe, expect, test } from "bun:test"; import { createHash, createHmac, generateKeyPairSync, sign } from "node:crypto"; import { readFileSync } from "node:fs"; +import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import { assertCurrentSigningKey, createOfflinePairSessionRecord, deriveAccountSubject, deriveLanRendezvousKey, parseVerificationKeys, + registrationTranscript, signEndpointAttestation, signPairGrant, verifyEndpointAttestation, + verifyEndpointRegistrationSignature, verifyAndConsumeOfflineSameAccountPair, verifyPairGrant, type EndpointAttestationClaims, @@ -63,6 +67,33 @@ describe("Iroh LAN rendezvous derivation", () => { }); }); +describe("Iroh endpoint registration signatures", () => { + test("rejects a valid signature encoded with noncanonical padding", () => { + const keys = generateKeyPairSync("ed25519"); + const endpointId = keys.publicKey + .export({ format: "der", type: "spki" }) + .subarray(-32) + .toString("hex"); + const input = { + endpointId, + challengeId: "10000000-0000-4000-8000-000000000001", + nonce: Buffer.alloc(32, 0x42).toString("base64url"), + payloadSha256: "ab".repeat(32), + }; + const signature = sign( + null, + registrationTranscript(input), + keys.privateKey, + ).toString("base64url"); + + expect(() => verifyEndpointRegistrationSignature({ ...input, signature })).not.toThrow(); + expect(() => verifyEndpointRegistrationSignature({ + ...input, + signature: `${signature}=`, + })).toThrow(); + }); +}); + function registrationPayload(pathHint: Record) { return { route_contract_version: 1, @@ -624,6 +655,36 @@ describe("Iroh relay minter response bounds", () => { expect(new Headers(request?.init.headers).get("content-type")).toBe("application/json"); }); + test("preserves an invalid EndpointID as an input error", async () => { + const config: IrohTrustBrokerConfigShape = { + relayMinterUrl: "https://minter.cmux.test/api/relay-token", + relayMinterHmacSecretBase64: Buffer.alloc(32, 0x63).toString("base64"), + deviceLimitOverrideEnabled: false, + deviceLimitOverrideUserIds: new Set(), + deviceLimitOverrideEnvironments: new Set(), + deploymentEnvironment: "test", + }; + const layer = IrohRelayMinterLive.pipe( + Layer.provide(Layer.succeed(IrohTrustBrokerConfig, config)), + ); + const exit = await Effect.runPromiseExit( + Effect.gen(function* () { + const minter = yield* IrohRelayMinter; + return yield* minter.mint({ + endpointId: "not-an-endpoint-id", + lifetimeSeconds: 86_400, + now: NOW, + }); + }).pipe(Effect.provide(layer)), + ); + + expect(exit._tag).toBe("Failure"); + const failure = exit._tag === "Failure" + ? Option.getOrUndefined(Cause.failureOption(exit.cause)) + : undefined; + expect((failure as { _tag?: string } | undefined)?._tag).toBe("IrohInvalidInputError"); + }); + test("matches the Rust minter HMAC wire fixture", () => { const fixture = JSON.parse(readFileSync( new URL("../../tests/fixtures/iroh/relay-minter-request-v1.json", import.meta.url), From f1a0f596f4a3094aeaf402595ac474aff467f322 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Fri, 10 Jul 2026 07:44:36 -0700 Subject: [PATCH 14/37] fix(iroh): harden trust broker boundaries --- docs/iroh-offline-pairing-v1.md | 5 ++++- .../migration.sql | 5 ++++- web/services/iroh/crypto.ts | 6 +++++- web/services/iroh/errors.ts | 10 ++++++---- web/services/iroh/model.ts | 3 --- web/services/iroh/relayMinter.ts | 19 ++++++++++++++++--- web/services/iroh/trustBroker.ts | 9 ++------- web/tests/iroh-db-behavior.test.ts | 16 +--------------- 8 files changed, 38 insertions(+), 35 deletions(-) diff --git a/docs/iroh-offline-pairing-v1.md b/docs/iroh-offline-pairing-v1.md index dce24686a59f..b18f40ff8cf1 100644 --- a/docs/iroh-offline-pairing-v1.md +++ b/docs/iroh-offline-pairing-v1.md @@ -149,4 +149,7 @@ contract, including one-use consumption and staged key rotation. Shipping offline QR pairing remains blocked until the Swift client has equivalent actor tests that prove QR possession alone fails, both attestations are required, the live Iroh EndpointIDs are bound, concurrent replay has one winner, and current -plus previous key rotation works. +plus previous key rotation works. Release also requires a privacy decision on +the documented 24-hour pseudonymous account-correlation window. Production and +staging must use distinct account-subject secrets and Ed25519 signing key sets, +so an attestation from one environment cannot verify in another. diff --git a/web/db/migrations/20260710113000_iroh_relay_reservation_expiry/migration.sql b/web/db/migrations/20260710113000_iroh_relay_reservation_expiry/migration.sql index 43fa71ded019..4d10ce067ee8 100644 --- a/web/db/migrations/20260710113000_iroh_relay_reservation_expiry/migration.sql +++ b/web/db/migrations/20260710113000_iroh_relay_reservation_expiry/migration.sql @@ -3,4 +3,7 @@ ALTER TABLE "iroh_relay_token_issuances" --> statement-breakpoint ALTER TABLE "iroh_relay_token_issuances" ADD CONSTRAINT "iroh_relay_token_issuances_status_check" - CHECK ("status" in ('pending', 'succeeded', 'failed', 'expired')); + CHECK ("status" in ('pending', 'succeeded', 'failed', 'expired')) NOT VALID; +--> statement-breakpoint +ALTER TABLE "iroh_relay_token_issuances" + VALIDATE CONSTRAINT "iroh_relay_token_issuances_status_check"; diff --git a/web/services/iroh/crypto.ts b/web/services/iroh/crypto.ts index 3f97ec2b852c..c88bb0445ef2 100644 --- a/web/services/iroh/crypto.ts +++ b/web/services/iroh/crypto.ts @@ -139,7 +139,11 @@ export function verifyEndpointRegistrationSignature(input: { readonly signature: string; }): void { const publicKey = endpointPublicKey(input.endpointId); - const signature = Buffer.from(input.signature, "base64url"); + const signature = decodeCanonicalBase64url( + input.signature, + 64, + "invalid_registration_signature", + ); const valid = verify( null, registrationTranscript(input), diff --git a/web/services/iroh/errors.ts b/web/services/iroh/errors.ts index 8691bcf9dfbf..67d5214f3490 100644 --- a/web/services/iroh/errors.ts +++ b/web/services/iroh/errors.ts @@ -1,4 +1,5 @@ import * as Data from "effect/Data"; +import { FiberFailureCauseId } from "effect/Runtime"; export class IrohInvalidInputError extends Data.TaggedError("IrohInvalidInputError")<{ readonly code: string; @@ -55,10 +56,11 @@ export function irohExpectedError(error: unknown): IrohExpectedError | null { const tag = (error as { _tag?: unknown })._tag; if (typeof tag === "string" && IROH_ERROR_TAGS.has(tag)) return error as IrohExpectedError; - const symbol = Object.getOwnPropertySymbols(error).find( - (candidate) => candidate.description === "effect/Runtime/FiberFailure/Cause", - ); - if (symbol) return errorFromCause((error as Record)[symbol]); + if (FiberFailureCauseId in error) { + return errorFromCause( + (error as Record)[FiberFailureCauseId], + ); + } return null; } diff --git a/web/services/iroh/model.ts b/web/services/iroh/model.ts index f048826d8951..bbdfcd109758 100644 --- a/web/services/iroh/model.ts +++ b/web/services/iroh/model.ts @@ -271,9 +271,6 @@ export function parseIrohPathHint(value: unknown, now: Date): IrohPathHint { const hintValue = kind === "relay_url" ? managedRelayUrl(hint.value, source) : literalSocketAddress(hint.value, privacyScope, source); - if (kind === "relay_url" && source !== "native") { - throw new IrohInvalidInputError({ code: "invalid_relay_hint_source" }); - } const parsed: IrohPathHint = { kind, value: hintValue, diff --git a/web/services/iroh/relayMinter.ts b/web/services/iroh/relayMinter.ts index 6e15b1833127..aab46f57c4da 100644 --- a/web/services/iroh/relayMinter.ts +++ b/web/services/iroh/relayMinter.ts @@ -2,7 +2,11 @@ import { createHash, createHmac } from "node:crypto"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { IrohConfigurationError, IrohRelayMintError } from "./errors"; +import { + IrohConfigurationError, + type IrohInvalidInputError, + IrohRelayMintError, +} from "./errors"; import { IrohTrustBrokerConfig } from "./config"; import { IROH_RELAY_TOKEN_LIFETIME_SECONDS, endpointId } from "./model"; @@ -19,7 +23,10 @@ export type IrohRelayMinterShape = { readonly endpointId: string; readonly lifetimeSeconds: typeof IROH_RELAY_TOKEN_LIFETIME_SECONDS; readonly now: Date; - }) => Effect.Effect; + }) => Effect.Effect< + IrohRelayMintResult, + IrohConfigurationError | IrohInvalidInputError | IrohRelayMintError + >; }; export class IrohRelayMinter extends Context.Tag("cmux/IrohRelayMinter")< @@ -40,7 +47,10 @@ export const IrohRelayMinterLive = Layer.effect( function mintWithIsolatedService( config: typeof IrohTrustBrokerConfig.Service, input: Parameters[0], -): Effect.Effect { +): Effect.Effect< + IrohRelayMintResult, + IrohConfigurationError | IrohInvalidInputError | IrohRelayMintError +> { return Effect.tryPromise({ try: async () => { endpointId(input.endpointId); @@ -93,6 +103,9 @@ function mintWithIsolatedService( if ((cause as { _tag?: unknown } | null)?._tag === "IrohConfigurationError") { return cause as IrohConfigurationError; } + if ((cause as { _tag?: unknown } | null)?._tag === "IrohInvalidInputError") { + return cause as IrohInvalidInputError; + } if ((cause as { _tag?: unknown } | null)?._tag === "IrohRelayMintError") { return cause as IrohRelayMintError; } diff --git a/web/services/iroh/trustBroker.ts b/web/services/iroh/trustBroker.ts index e35bfccb0497..5cdb3b5f0b09 100644 --- a/web/services/iroh/trustBroker.ts +++ b/web/services/iroh/trustBroker.ts @@ -275,13 +275,8 @@ export function makeIrohTrustBroker( initiator: grantPeer(initiator), acceptor: grantPeer(acceptor), }; - const signingKeyId = yield* parseEffect(() => { - if (!config.grantSigningKid || !/^[A-Za-z0-9._-]{1,64}$/.test(config.grantSigningKid)) { - throw new IrohConfigurationError({ component: "grant_signing" }); - } - return config.grantSigningKid; - }); const verificationKeys = yield* parseEffect(() => signingVerificationKeys(config)); + const signingKeyId = verificationKeys.keySet.current_kid; const token = yield* parseEffect(() => signPairGrant({ privateKeyPem: config.grantSigningPrivateKeyPem, kid: signingKeyId, @@ -333,7 +328,7 @@ export function makeIrohTrustBroker( }; const attestation = yield* parseEffect(() => signEndpointAttestation({ privateKeyPem: config.grantSigningPrivateKeyPem, - kid: config.grantSigningKid, + kid: verificationKeys.keySet.current_kid, claims, })); yield* parseEffect(() => verifyEndpointAttestation( diff --git a/web/tests/iroh-db-behavior.test.ts b/web/tests/iroh-db-behavior.test.ts index efa4adc7c55e..7b6d5b240469 100644 --- a/web/tests/iroh-db-behavior.test.ts +++ b/web/tests/iroh-db-behavior.test.ts @@ -561,20 +561,6 @@ describe("Iroh trust broker database behavior", () => { }); dbTest("fails attestation finalization when revocation commits during signing", async () => { - type FinalizeEndpointAttestation = (input: { - readonly userId: string; - readonly bindingId: string; - readonly deviceId: string; - readonly endpointId: string; - readonly identityGeneration: number; - readonly platform: "mac" | "ios"; - }) => Effect.Effect; - const repositoryWithFinalizer = requiredRepository() as unknown as { - readonly finalizeEndpointAttestation?: FinalizeEndpointAttestation; - }; - expect(typeof repositoryWithFinalizer.finalizeEndpointAttestation).toBe("function"); - if (!repositoryWithFinalizer.finalizeEndpointAttestation) return; - const userId = "user-attestation-race"; const bindingId = await insertBinding({ userId, @@ -587,7 +573,7 @@ describe("Iroh trust broker database behavior", () => { await revocationSql` select pg_advisory_xact_lock(hashtextextended(${`iroh:binding:${userId}`}, 0)) `; - finalization = Effect.runPromiseExit(repositoryWithFinalizer.finalizeEndpointAttestation!({ + finalization = Effect.runPromiseExit(requiredRepository().finalizeEndpointAttestation({ userId, bindingId, deviceId: peer.deviceId, From 9e9597a2f678c6ef7851930f0426c548584f3c87 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Fri, 10 Jul 2026 09:27:51 -0700 Subject: [PATCH 15/37] test(iroh): hide database failure details --- web/tests/iroh-route-handler.test.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/web/tests/iroh-route-handler.test.ts b/web/tests/iroh-route-handler.test.ts index b18b1b618232..e4b4584b7da6 100644 --- a/web/tests/iroh-route-handler.test.ts +++ b/web/tests/iroh-route-handler.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import * as Effect from "effect/Effect"; -import { IrohQuotaExceededError } from "../services/iroh/errors"; +import { IrohDatabaseError, IrohQuotaExceededError } from "../services/iroh/errors"; import { handleIrohRoute } from "../services/iroh/routeHandler"; import type { IrohTrustBrokerShape } from "../services/iroh/trustBroker"; import type { AuthedUser } from "../services/vms/auth"; @@ -130,6 +130,21 @@ describe("Iroh route boundary", () => { retry_after_seconds: 417, }); }); + + test("does not expose database implementation details in service failures", async () => { + const response = await handleIrohRoute(authedPost("/api/devices/iroh/challenge", {}), "challenge", { + verify: async () => USER, + broker: broker({ + issueChallenge: () => Effect.fail(new IrohDatabaseError({ + operation: "issue_challenge", + cause: { category: "connection" }, + })), + }), + }); + + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ error: "iroh_service_unavailable" }); + }); }); describe("Iroh retention route", () => { From a287b44952dbcabf10b679c252a22f3a653b8f49 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Fri, 10 Jul 2026 09:29:04 -0700 Subject: [PATCH 16/37] fix(iroh): defer reservation constraint validation --- .../migration.sql | 3 --- web/services/iroh/README.md | 12 ++++++++++++ web/services/iroh/routeHandler.ts | 2 +- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/web/db/migrations/20260710113000_iroh_relay_reservation_expiry/migration.sql b/web/db/migrations/20260710113000_iroh_relay_reservation_expiry/migration.sql index 4d10ce067ee8..f09a324be871 100644 --- a/web/db/migrations/20260710113000_iroh_relay_reservation_expiry/migration.sql +++ b/web/db/migrations/20260710113000_iroh_relay_reservation_expiry/migration.sql @@ -4,6 +4,3 @@ ALTER TABLE "iroh_relay_token_issuances" ALTER TABLE "iroh_relay_token_issuances" ADD CONSTRAINT "iroh_relay_token_issuances_status_check" CHECK ("status" in ('pending', 'succeeded', 'failed', 'expired')) NOT VALID; ---> statement-breakpoint -ALTER TABLE "iroh_relay_token_issuances" - VALIDATE CONSTRAINT "iroh_relay_token_issuances_status_check"; diff --git a/web/services/iroh/README.md b/web/services/iroh/README.md index 0998feeed247..aba96e3931b9 100644 --- a/web/services/iroh/README.md +++ b/web/services/iroh/README.md @@ -58,3 +58,15 @@ Registration stores the earliest path-hint expiry in bounded 500-row `FOR UPDATE SKIP LOCKED` batches for hints, challenges, audit rows, and revoked bindings. Concurrent cron workers can cooperate without a full-table JSON scan. + +The `20260710113000_iroh_relay_reservation_expiry` migration adds the +expanded status constraint with `NOT VALID` so its `ACCESS EXCLUSIVE` lock is +released without scanning existing rows. Drizzle 1.0 applies all pending +Postgres migrations in one transaction, so constraint validation must ship in +a later deployment after this migration is recorded. That follow-up migration +must run: + +```sql +ALTER TABLE "iroh_relay_token_issuances" + VALIDATE CONSTRAINT "iroh_relay_token_issuances_status_check"; +``` diff --git a/web/services/iroh/routeHandler.ts b/web/services/iroh/routeHandler.ts index 17e600c94475..d7d48b478d5a 100644 --- a/web/services/iroh/routeHandler.ts +++ b/web/services/iroh/routeHandler.ts @@ -176,7 +176,7 @@ function expectedErrorResponse(error: ReturnType & obj if (tag === "IrohConfigurationError" || tag === "IrohRelayMintError") { return jsonResponse({ error: "iroh_service_unavailable" }, 503); } - return jsonResponse({ error: "iroh_database_unavailable" }, 503); + return jsonResponse({ error: "iroh_service_unavailable" }, 503); } function irohJsonResponse( From f2c7ed344e4d616a9bd958b3c02519cd2b3def1a Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Fri, 10 Jul 2026 10:06:14 -0700 Subject: [PATCH 17/37] test(iroh): lock registration identity and relay bootstrap --- web/tests/iroh-db-behavior.test.ts | 62 +++++++++++++++++++++++++++++ web/tests/iroh-trust-broker.test.ts | 22 +++++++++- 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/web/tests/iroh-db-behavior.test.ts b/web/tests/iroh-db-behavior.test.ts index 7b6d5b240469..c277e18a13ed 100644 --- a/web/tests/iroh-db-behavior.test.ts +++ b/web/tests/iroh-db-behavior.test.ts @@ -298,6 +298,68 @@ describe("Iroh trust broker database behavior", () => { expect(nextExpiry?.getTime()).toBe(pathHintExpiry.getTime()); }); + dbTest("requires revocation before changing an active binding platform", async () => { + const repo = requiredRepository(); + const userId = "user-platform-change"; + const deviceId = randomUUID(); + const appInstanceId = randomUUID(); + const endpointId = "31".repeat(32); + + const register = async (platform: "mac" | "ios", suffix: string, now: Date) => { + const nonceHash = suffix.repeat(64); + const challenge = await Effect.runPromise(repo.issueChallenge({ + userId, + deviceUuid: deviceId, + appInstanceId, + tag: "stable", + endpointId, + identityGeneration: 1, + payloadSha256: `${suffix}${"0".repeat(63)}`, + nonceHash, + now, + expiresAt: new Date(now.getTime() + 5 * 60 * 1_000), + })); + return repo.consumeChallengeAndRegister({ + userId, + challengeId: challenge.id, + nonceHash, + payload: { + route_contract_version: 1, + deviceId, + appInstanceId, + tag: "stable", + platform, + endpointId, + identityGeneration: 1, + pairingEnabled: platform === "mac", + capabilities: [], + pathHints: [], + }, + now, + deviceLimitOverrideAllowed: false, + }); + }; + + await Effect.runPromise(await register("mac", "5", NOW)); + const changed = await Effect.runPromiseExit(await register( + "ios", + "6", + new Date(NOW.getTime() + 1_000), + )); + expect(changed._tag).toBe("Failure"); + const causeError = changed._tag === "Failure" + ? (changed.cause as unknown as { error?: unknown }).error + : undefined; + expect(causeError).toMatchObject({ + _tag: "IrohConflictError", + code: "binding_replacement_requires_revocation", + }); + const [{ platform }] = await requiredSql()>` + select platform from iroh_endpoint_bindings where app_instance_id = ${appInstanceId} + `; + expect(platform).toBe("mac"); + }); + dbTest("serializes an account-wide registration challenge rate cap", async () => { const userId = "user-challenge-flood"; await requiredSql()` diff --git a/web/tests/iroh-trust-broker.test.ts b/web/tests/iroh-trust-broker.test.ts index 4c1d9c96b1be..82d56c416c4f 100644 --- a/web/tests/iroh-trust-broker.test.ts +++ b/web/tests/iroh-trust-broker.test.ts @@ -58,6 +58,24 @@ describe("Iroh trust broker registration", () => { expect(fixture.repository.bindings).toHaveLength(1); }); + test("does not mint another relay token when refreshing the same binding", async () => { + const fixture = makeFixture(); + await Effect.runPromise(fixture.broker.register( + USER_A, + await fixture.signedRegistration(), + NOW, + )); + + const refreshed = await Effect.runPromise(fixture.broker.register( + USER_A, + await fixture.signedRegistration(), + new Date(NOW.getTime() + 1_000), + )) as { relay: { status: string } }; + + expect(refreshed.relay.status).toBe("not_requested"); + expect(fixture.minter.calls).toBe(1); + }); + test("rejects the wrong key and a changed payload", async () => { const wrongKeyFixture = makeFixture(); const wrongRequest = await wrongKeyFixture.signedRegistration(); @@ -699,13 +717,13 @@ function makeFixture(options: { appInstanceId, deviceId, identityGeneration, - async signedRegistration() { + async signedRegistration(platform: "mac" | "ios" = "mac") { const payload: IrohRegistrationPayload = { route_contract_version: 1, deviceId, appInstanceId, tag: "stable", - platform: "mac", + platform, displayName: "Test Mac", endpointId, identityGeneration, From d186c1824c0863abb1be30c3eb2311ce2cfc714b Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Fri, 10 Jul 2026 10:07:39 -0700 Subject: [PATCH 18/37] fix(iroh): preserve registration trust identity --- web/services/iroh/README.md | 6 ++++++ web/services/iroh/repository.ts | 14 +++++++++----- web/services/iroh/trustBroker.ts | 20 +++++++++++--------- web/tests/iroh-trust-broker.test.ts | 7 ++++--- 4 files changed, 30 insertions(+), 17 deletions(-) diff --git a/web/services/iroh/README.md b/web/services/iroh/README.md index aba96e3931b9..f18023600ecb 100644 --- a/web/services/iroh/README.md +++ b/web/services/iroh/README.md @@ -45,6 +45,12 @@ device-limit override requires a server flag, an exact authenticated user-id allowlist match, and an exact deployment-environment allowlist match; it never raises the 32-binding account limit and records an audit marker on the binding. +Registration bootstraps a relay credential only when it creates a binding. +Signed refreshes of the same binding return `relay.status = "not_requested"`; +clients retain their existing credential or use the dedicated relay-token route +when its refresh window arrives. Platform is part of the immutable binding +identity and requires explicit revocation before it can change. + Every user-scoped mutation acquires the account-deletion advisory fence before any Iroh lock. If the deletion tombstone wins, no challenge, binding, grant, or relay audit state can be created. If an Iroh mutation wins, account deletion diff --git a/web/services/iroh/repository.ts b/web/services/iroh/repository.ts index 93f7456eb056..f9d5701da85c 100644 --- a/web/services/iroh/repository.ts +++ b/web/services/iroh/repository.ts @@ -55,6 +55,10 @@ export type IrohRetentionResult = { export type IrohBindingRecord = typeof irohEndpointBindings.$inferSelect; export type IrohChallengeRecord = typeof irohRegistrationChallenges.$inferSelect; +export type IrohRegistrationCommit = { + readonly binding: IrohBindingRecord; + readonly created: boolean; +}; type CloudDbTransaction = Parameters["transaction"]>[0]>[0]; type RepositoryError = @@ -88,7 +92,7 @@ export type IrohRepositoryShape = { readonly payload: IrohRegistrationPayload; readonly now: Date; readonly deviceLimitOverrideAllowed: boolean; - }) => Effect.Effect; + }) => Effect.Effect; readonly discoverySnapshot: (input: { readonly userId: string; readonly now: Date; @@ -278,14 +282,14 @@ function makeLiveRepository(): IrohRepositoryShape { existingApp.endpointId !== input.payload.endpointId || existingApp.identityGeneration !== input.payload.identityGeneration || existingApp.deviceUuid !== input.payload.deviceId || - existingApp.tag !== input.payload.tag + existingApp.tag !== input.payload.tag || + existingApp.platform !== input.payload.platform ) { throw new IrohConflictError({ code: "binding_replacement_requires_revocation" }); } const [updated] = await tx .update(irohEndpointBindings) .set({ - platform: input.payload.platform, displayName: input.payload.displayName ?? null, pairingEnabled: input.payload.pairingEnabled, capabilities: [...input.payload.capabilities], @@ -301,7 +305,7 @@ function makeLiveRepository(): IrohRepositoryShape { .set({ consumedAt: input.now }) .where(eq(irohRegistrationChallenges.id, challenge.id)); if (!updated) throw new Error("binding update returned no row"); - return updated; + return { binding: updated, created: false }; } const [endpointOwner] = await tx @@ -371,7 +375,7 @@ function makeLiveRepository(): IrohRepositoryShape { eq(irohRegistrationChallenges.id, challenge.id), isNull(irohRegistrationChallenges.consumedAt), )); - return binding; + return { binding, created: true }; }); }), diff --git a/web/services/iroh/trustBroker.ts b/web/services/iroh/trustBroker.ts index 5cdb3b5f0b09..4e25f1eaf9cc 100644 --- a/web/services/iroh/trustBroker.ts +++ b/web/services/iroh/trustBroker.ts @@ -200,7 +200,7 @@ export function makeIrohTrustBroker( payloadSha256: decoded.sha256, signature: request.signature, })); - const binding = yield* repository.consumeChallengeAndRegister({ + const registration = yield* repository.consumeChallengeAndRegister({ userId, challengeId: challenge.id, nonceHash: nonceHash(request.nonce), @@ -209,14 +209,16 @@ export function makeIrohTrustBroker( deviceLimitOverrideAllowed: deviceLimitOverrideAllowed(config, userId), }); - // Registration is already committed before relay minting starts. The - // dedicated relay route can retry independently if this bootstrap mint - // fails, so provider availability can never roll back device identity. - const relay = yield* issueRelayToken(userId, { bindingId: binding.id }, now).pipe( - Effect.map((value) => ({ status: "issued" as const, ...value as object })), - Effect.catchAll(() => Effect.succeed({ status: "unavailable" as const })), - ); - return { binding: publicBinding(binding, now), relay }; + // New registration is already committed before relay minting starts. + // Refreshes keep their existing credential and use the dedicated relay + // route when it expires, so path-hint churn cannot consume mint quotas. + const relay = registration.created + ? yield* issueRelayToken(userId, { bindingId: registration.binding.id }, now).pipe( + Effect.map((value) => ({ status: "issued" as const, ...value as object })), + Effect.catchAll(() => Effect.succeed({ status: "unavailable" as const })), + ) + : { status: "not_requested" as const }; + return { binding: publicBinding(registration.binding, now), relay }; }), discover: (userId, now = new Date()) => Effect.gen(function* () { diff --git a/web/tests/iroh-trust-broker.test.ts b/web/tests/iroh-trust-broker.test.ts index 82d56c416c4f..80a7182e8e15 100644 --- a/web/tests/iroh-trust-broker.test.ts +++ b/web/tests/iroh-trust-broker.test.ts @@ -473,11 +473,12 @@ class MemoryRepository implements IrohRepositoryShape { existing.endpointId !== input.payload.endpointId || existing.identityGeneration !== input.payload.identityGeneration || existing.deviceUuid !== input.payload.deviceId || - existing.tag !== input.payload.tag + existing.tag !== input.payload.tag || + existing.platform !== input.payload.platform ) return Effect.fail(new IrohConflictError({ code: "binding_replacement_requires_revocation" })); challenge.consumedAt = input.now; existing.pathHints = [...input.payload.pathHints]; - return Effect.succeed(existing); + return Effect.succeed({ binding: existing, created: false }); } if (this.bindings.some((row) => row.endpointId === input.payload.endpointId && !row.revokedAt)) { return Effect.fail(new IrohConflictError({ code: "endpoint_already_bound" })); @@ -507,7 +508,7 @@ class MemoryRepository implements IrohRepositoryShape { }); challenge.consumedAt = input.now; this.bindings.push(inserted); - return Effect.succeed(inserted); + return Effect.succeed({ binding: inserted, created: true }); } discoverySnapshot(input: Parameters[0]) { From 4b6d3b034bd9ba734eaa02f18e816670df24ffdd Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Fri, 10 Jul 2026 10:18:17 -0700 Subject: [PATCH 19/37] test(iroh): prevent server path-hint disclosure --- web/tests/devices-route.test.ts | 97 +++++++++++++++ web/tests/iroh-db-behavior.test.ts | 9 +- web/tests/iroh-trust-broker.test.ts | 22 ++++ workers/presence/test/core.test.ts | 46 ++++++++ workers/presence/test/syncPairedMacs.test.ts | 117 +++++++++++++++++++ workers/presence/test/syncStorage.test.ts | 72 ++++++++++++ workers/presence/test/validate.test.ts | 49 ++++++++ 7 files changed, 409 insertions(+), 3 deletions(-) diff --git a/web/tests/devices-route.test.ts b/web/tests/devices-route.test.ts index 59ba071e34b8..71c22b4f60da 100644 --- a/web/tests/devices-route.test.ts +++ b/web/tests/devices-route.test.ts @@ -34,6 +34,39 @@ let sql: Sql | null = null; const DEVICE_A = "11111111-1111-4111-8111-111111111111"; const DEVICE_B = "22222222-2222-4222-8222-222222222222"; +const IROH_ENDPOINT_ID = "a".repeat(64); +const APPROVED_IROH_RELAY = "https://use1-1.relay.lawrence.cmux.iroh.link/"; + +const legacyTailscaleRoute = { + id: "legacy", + kind: "tailscale", + priority: 0, + endpoint: { type: "host_port", host: "100.64.1.2", port: 51001 }, +}; + +const privateIrohRoute = { + id: "iroh-private", + kind: "iroh", + priority: 1, + endpoint: { + type: "peer", + id: IROH_ENDPOINT_ID, + relay_hint: "legacy-private-relay-hint", + relay_url: APPROVED_IROH_RELAY, + direct_addrs: ["192.168.1.20:49152", "100.64.1.2:49152"], + }, +}; + +const publicIrohRoute = { + id: "iroh-private", + kind: "iroh", + priority: 1, + endpoint: { + type: "peer", + id: IROH_ENDPOINT_ID, + relay_url: APPROVED_IROH_RELAY, + }, +}; function authHeaders(teamId?: string): Record { const base: Record = { @@ -254,6 +287,70 @@ describe("device registry route", () => { expect(routes).toHaveLength(2); }); + dbTest("persists and returns only public Iroh rendezvous data while preserving legacy routes", async () => { + if (!sql) throw new Error("test database not initialized"); + + const register = await POST( + registerRequest({ + deviceId: DEVICE_A, + platform: "mac", + tag: "stable", + routes: [ + legacyTailscaleRoute, + privateIrohRoute, + { + ...privateIrohRoute, + id: "iroh-unmanaged-relay", + endpoint: { + ...privateIrohRoute.endpoint, + relay_url: "https://attacker.example/relay", + }, + }, + ], + }), + ); + expect(register.status).toBe(200); + + const [{ routes: storedRoutes }] = await sql<{ routes: unknown[] }[]>` + select routes from device_app_instances + where device_id in (select id from devices where device_uuid = ${DEVICE_A}) + and tag = 'stable' + `; + expect(storedRoutes).toEqual([ + legacyTailscaleRoute, + publicIrohRoute, + { + ...publicIrohRoute, + id: "iroh-unmanaged-relay", + endpoint: { type: "peer", id: IROH_ENDPOINT_ID }, + }, + ]); + expect(JSON.stringify(storedRoutes)).not.toContain("192.168.1.20"); + expect(JSON.stringify(storedRoutes)).not.toContain("legacy-private-relay-hint"); + expect(JSON.stringify(storedRoutes)).not.toContain("attacker.example"); + + // Simulate an unsafe Iroh route written by a pre-hardening deployment. A + // same-team member must not receive its private hints from the GET boundary. + await sql` + update device_app_instances + set routes = ${sql.json([legacyTailscaleRoute, privateIrohRoute])} + where device_id in (select id from devices where device_uuid = ${DEVICE_A}) + and tag = 'stable' + `; + currentUserId = "registry-user-2"; + const listResponse = await GET( + new Request("https://cmux.test/api/devices", { method: "GET", headers: authHeaders() }), + ); + expect(listResponse.status).toBe(200); + const list = (await listResponse.json()) as { + devices: Array<{ instances: Array<{ routes: unknown[] }> }>; + }; + const returnedRoutes = list.devices[0]?.instances[0]?.routes; + expect(returnedRoutes).toEqual([legacyTailscaleRoute, publicIrohRoute]); + expect(JSON.stringify(returnedRoutes)).not.toContain("192.168.1.20"); + expect(JSON.stringify(returnedRoutes)).not.toContain("legacy-private-relay-hint"); + }); + dbTest("registers the same device UUID in two teams the user belongs to", async () => { if (!sql) throw new Error("test database not initialized"); diff --git a/web/tests/iroh-db-behavior.test.ts b/web/tests/iroh-db-behavior.test.ts index c277e18a13ed..c5d93798ab0e 100644 --- a/web/tests/iroh-db-behavior.test.ts +++ b/web/tests/iroh-db-behavior.test.ts @@ -284,18 +284,21 @@ describe("Iroh trust broker database behavior", () => { const results = await Promise.allSettled([register(), register()]); expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1); expect(results.filter((result) => result.status === "rejected")).toHaveLength(1); - const [{ bindings, consumed, nextExpiry }] = await requiredSql()>` select (select count(*)::text from iroh_endpoint_bindings) as bindings, (select count(*)::text from iroh_registration_challenges where consumed_at is not null) as consumed, - (select path_hints_next_expiry from iroh_endpoint_bindings limit 1) as "nextExpiry" + (select path_hints_next_expiry from iroh_endpoint_bindings limit 1) as "nextExpiry", + (select path_hints from iroh_endpoint_bindings limit 1) as "pathHints" `; expect({ bindings, consumed }).toEqual({ bindings: "1", consumed: "1" }); - expect(nextExpiry?.getTime()).toBe(pathHintExpiry.getTime()); + expect(nextExpiry).toBeNull(); + expect(pathHints).toEqual([]); }); dbTest("requires revocation before changing an active binding platform", async () => { diff --git a/web/tests/iroh-trust-broker.test.ts b/web/tests/iroh-trust-broker.test.ts index 80a7182e8e15..3730dad48a73 100644 --- a/web/tests/iroh-trust-broker.test.ts +++ b/web/tests/iroh-trust-broker.test.ts @@ -46,6 +46,7 @@ describe("Iroh trust broker registration", () => { expect(result.binding.endpoint_id).toBe(fixture.endpointId); expect(result.relay.status).toBe("issued"); expect(fixture.repository.bindings).toHaveLength(1); + expect(fixture.repository.bindings[0]?.pathHints).toEqual([]); expect(fixture.minter.calls).toBe(1); }); @@ -183,6 +184,27 @@ describe("Iroh discovery and grants", () => { expect(JSON.stringify(discovered.grant_verification_keys)).not.toContain("PRIVATE KEY"); }); + test("defensively withholds unexpired direct hints from discovery", async () => { + const fixture = makeFixture(); + fixture.repository.bindings.push(binding({ + userId: USER_A, + pathHints: [{ + kind: "direct_address", + value: "10.0.0.2:4433", + source: "lan", + privacy_scope: "local_network", + observed_at: "2026-07-09T19:55:00.000Z", + expires_at: "2026-07-09T20:30:00.000Z", + network_profile: { source: "lan", profile_id: "local" }, + }], + })); + + const discovered = await Effect.runPromise(fixture.broker.discover(USER_A, NOW)) as { + bindings: Array<{ path_hints: unknown[] }>; + }; + expect(discovered.bindings[0]?.path_hints).toEqual([]); + }); + test("does not combine a pre-revocation binding with a post-revocation LAN generation", async () => { const fixture = makeFixture(); const active = binding({ userId: USER_A }); diff --git a/workers/presence/test/core.test.ts b/workers/presence/test/core.test.ts index 02820dbc2410..b268925b6030 100644 --- a/workers/presence/test/core.test.ts +++ b/workers/presence/test/core.test.ts @@ -325,6 +325,28 @@ describe("heartbeat routes", () => { const routeA = { kind: "lan", host: "192.168.1.10", port: 49152 }; const routeB = { kind: "tailscale", host: "mac.tailnet.ts.net", port: 49152 }; const routeMoved = { kind: "lan", host: "192.168.1.10", port: 50000 }; + const privateIrohRoute = { + id: "iroh", + kind: "iroh", + priority: 1, + endpoint: { + type: "peer", + id: "a".repeat(64), + relay_url: "https://use1-1.relay.lawrence.cmux.iroh.link/", + relay_hint: "legacy-private-relay-hint", + direct_addrs: ["192.168.1.20:49152"], + }, + }; + const publicIrohRoute = { + id: "iroh", + kind: "iroh", + priority: 1, + endpoint: { + type: "peer", + id: "a".repeat(64), + relay_url: "https://use1-1.relay.lawrence.cmux.iroh.link/", + }, + }; it("stores routes from the first heartbeat on the online instance", () => { const { instance, events } = applyHeartbeat(undefined, beat({ routes: [routeA, routeB] }), T0); @@ -393,4 +415,28 @@ describe("heartbeat routes", () => { const snapshot = buildSnapshot("team-1", [onlineInstance({ routes: [routeA] })], T0); expect(snapshot.devices[0]?.instances[0]?.routes).toEqual([routeA]); }); + + it("scrubs pre-hardening Iroh hints from stored instances, events, and snapshots", () => { + const existing = onlineInstance({ routes: [routeA, privateIrohRoute] }); + const refreshed = applyHeartbeat(existing, beat(), T0 + HEARTBEAT_INTERVAL_MS); + expect(refreshed.instance.routes).toEqual([routeA, publicIrohRoute]); + + const expired = expireInstances( + [onlineInstance({ routes: [routeA, privateIrohRoute] })], + T0 + OFFLINE_TIMEOUT_MS, + ); + expect(expired.events[0]).toMatchObject({ + type: "offline", + instance: { routes: [routeA, publicIrohRoute] }, + }); + + const snapshot = buildSnapshot( + "team-1", + [onlineInstance({ routes: [routeA, privateIrohRoute] })], + T0, + ); + expect(snapshot.devices[0]?.instances[0]?.routes).toEqual([routeA, publicIrohRoute]); + expect(JSON.stringify(snapshot)).not.toContain("192.168.1.20"); + expect(JSON.stringify(snapshot)).not.toContain("legacy-private-relay-hint"); + }); }); diff --git a/workers/presence/test/syncPairedMacs.test.ts b/workers/presence/test/syncPairedMacs.test.ts index 183f7331d92f..1f110ea49f29 100644 --- a/workers/presence/test/syncPairedMacs.test.ts +++ b/workers/presence/test/syncPairedMacs.test.ts @@ -21,6 +21,7 @@ import { PAIRED_MACS_COLLECTION, PAIRED_MACS_COLLECTION_TOMBSTONE_PREFIXES, parsePairedMacBackup, + sanitizePairedMacSyncFrame, type PairedMacBackupRecord, } from "../src/syncPairedMacs"; import { @@ -28,6 +29,7 @@ import { gcTombstones, listRecords, listTombstonedCollections, + upsertRecord, type SyncStorage, } from "../src/syncStorage"; @@ -113,9 +115,124 @@ describe("parsePairedMacBackup", () => { if (op?.kind !== "upsert") throw new Error("expected an upsert op"); expect(op.record.routes).toEqual([{ id: "ok" }]); }); + + it("strips private Iroh hints from backup ingestion and keeps legacy routes", () => { + const legacy = record("x", "100.64.1.2", 49152).routes[0]; + const parsed = parsePairedMacBackup({ + ops: [{ + macDeviceID: "x", + record: { + ...record("x", "100.64.1.2", 49152), + routes: [ + legacy, + { + id: "iroh", + kind: "iroh", + priority: 1, + endpoint: { + type: "peer", + id: "a".repeat(64), + direct_addrs: ["192.168.1.20:49152"], + relay_hint: "legacy-private-relay-hint", + relay_url: "https://use1-1.relay.lawrence.cmux.iroh.link/", + }, + }, + ], + }, + }], + }); + if (!parsed.ok) throw new Error(parsed.error); + const op = parsed.ops[0]; + if (op?.kind !== "upsert") throw new Error("expected an upsert op"); + expect(op.record.routes).toEqual([ + legacy, + { + id: "iroh", + kind: "iroh", + priority: 1, + endpoint: { + type: "peer", + id: "a".repeat(64), + relay_url: "https://use1-1.relay.lawrence.cmux.iroh.link/", + }, + }, + ]); + }); }); describe("applyBackupOps", () => { + it("sanitizes direct writes, deltas, and legacy stored backup responses", async () => { + const storage = new FakeStorage(); + const unsafe = { + ...record("mac-a", "100.64.1.2", 49152), + routes: [ + record("mac-a", "100.64.1.2", 49152).routes[0], + { + id: "iroh", + kind: "iroh", + endpoint: { + type: "peer", + id: "a".repeat(64), + direct_addrs: ["192.168.1.20:49152"], + relay_hint: "legacy-private-relay-hint", + relay_url: "https://use1-1.relay.lawrence.cmux.iroh.link/", + }, + }, + ], + }; + const deltas = await applyBackupOps( + storage, + "user-1", + [{ kind: "upsert", id: "mac-a", record: unsafe }], + T0, + ); + expect(JSON.stringify(deltas)).not.toContain("192.168.1.20"); + expect(JSON.stringify(deltas)).not.toContain("legacy-private-relay-hint"); + const stored = await listRecords(storage, pairedMacsCollection("user-1")); + expect(JSON.stringify(stored)).not.toContain("192.168.1.20"); + + // Seed an unsafe pre-hardening record directly. Restore must scrub it even + // before the next client write migrates the stored payload. + await upsertRecord( + storage, + pairedMacsCollection("legacy-user"), + "mac-a", + unsafe, + T0, + ); + const restored = await listBackupSnapshot(storage, "legacy-user"); + expect(JSON.stringify(restored)).not.toContain("192.168.1.20"); + expect(JSON.stringify(restored)).not.toContain("legacy-private-relay-hint"); + expect(restored.records[0]?.routes).toEqual([ + unsafe.routes[0], + { + id: "iroh", + kind: "iroh", + endpoint: { + type: "peer", + id: "a".repeat(64), + relay_url: "https://use1-1.relay.lawrence.cmux.iroh.link/", + }, + }, + ]); + + const legacyFrame = sanitizePairedMacSyncFrame({ + type: "sync.delta", + collection: pairedMacsCollection("legacy-user"), + rev: 1, + records: [{ + id: "mac-a", + rev: 1, + updatedAt: T0, + deleted: false, + schemaVersion: 1, + payload: unsafe, + }], + }); + expect(JSON.stringify(legacyFrame)).not.toContain("192.168.1.20"); + expect(JSON.stringify(legacyFrame)).not.toContain("legacy-private-relay-hint"); + }); + it("normalizes optional client scopes into separate per-user collections", async () => { const storage = new FakeStorage(); await applyBackupOps( diff --git a/workers/presence/test/syncStorage.test.ts b/workers/presence/test/syncStorage.test.ts index 9dbbd0819289..7e3428f34bf8 100644 --- a/workers/presence/test/syncStorage.test.ts +++ b/workers/presence/test/syncStorage.test.ts @@ -34,6 +34,7 @@ import { ownersFromList, reconcileDeviceRecords, reconcileSingleDevice, + sanitizeDeviceSyncFrame, type DeviceRecord, } from "../src/syncDevices"; import type { PresenceInstance } from "../src/core"; @@ -580,6 +581,77 @@ describe("deriveDeviceRecord edge cases", () => { expect(rec!.displayName).toBe("Studio"); expect(rec!.instances.map((i) => i.tag)).toEqual(["b", "a"]); // newest-first }); + + it("never projects private Iroh hints into the team device collection", () => { + const legacy = { kind: "tailscale", endpoint: { host: "100.64.1.2", port: 49152 } }; + const rec = deriveDeviceRecord( + "dev-A", + [ + instance({ + routes: [ + legacy, + { + id: "iroh", + kind: "iroh", + endpoint: { + type: "peer", + id: "a".repeat(64), + direct_addrs: ["192.168.1.20:49152"], + relay_hint: "legacy-private-relay-hint", + relay_url: "https://use1-1.relay.lawrence.cmux.iroh.link/", + }, + }, + ], + }), + ], + "user-1", + ); + expect(rec?.instances[0]?.routes).toEqual([ + legacy, + { + id: "iroh", + kind: "iroh", + endpoint: { + type: "peer", + id: "a".repeat(64), + relay_url: "https://use1-1.relay.lawrence.cmux.iroh.link/", + }, + }, + ]); + }); + + it("scrubs pre-hardening Iroh hints from stored team sync frames", () => { + const frame = sanitizeDeviceSyncFrame({ + type: "sync.delta", + collection: "devices", + rev: 4, + records: [{ + id: "dev-A", + rev: 4, + updatedAt: T0, + deleted: false, + schemaVersion: 1, + payload: devicePayload({ + instances: [{ + tag: "default", + lastSeenAtAtRev: T0, + routes: [{ + id: "iroh", + kind: "iroh", + endpoint: { + type: "peer", + id: "a".repeat(64), + direct_addrs: ["192.168.1.20:49152"], + relay_hint: "legacy-private-relay-hint", + }, + }], + }], + }), + }], + }); + expect(JSON.stringify(frame)).not.toContain("192.168.1.20"); + expect(JSON.stringify(frame)).not.toContain("legacy-private-relay-hint"); + }); }); describe("listRecords helper", () => { diff --git a/workers/presence/test/validate.test.ts b/workers/presence/test/validate.test.ts index 668afdd2e949..305bf7b1a9ec 100644 --- a/workers/presence/test/validate.test.ts +++ b/workers/presence/test/validate.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "bun:test"; import { MAX_REQUEST_BYTES, MAX_ROUTES, parseHeartbeat, readBoundedJson } from "../src/validate"; const DEVICE_ID = "11111111-2222-4333-8444-555555555555"; +const IROH_ENDPOINT_ID = "a".repeat(64); +const APPROVED_IROH_RELAY = "https://use1-1.relay.lawrence.cmux.iroh.link/"; function postRequest( body: string | ReadableStream, @@ -201,4 +203,51 @@ describe("parseHeartbeat routes", () => { if (!result.ok) throw new Error(result.error); expect(result.beat.routes).toHaveLength(MAX_ROUTES); }); + + it("strips private Iroh hints at ingestion while preserving legacy routes", () => { + const legacy = { + id: "legacy", + kind: "tailscale", + endpoint: { type: "host_port", host: "100.64.1.2", port: 49152 }, + }; + const result = parseHeartbeat(body([ + legacy, + { + id: "iroh", + kind: "iroh", + priority: 1, + endpoint: { + type: "peer", + id: IROH_ENDPOINT_ID, + direct_addrs: ["192.168.1.20:49152", "100.64.1.2:49152"], + relay_hint: "legacy-private-relay-hint", + relay_url: APPROVED_IROH_RELAY, + }, + }, + { + id: "iroh-unmanaged", + kind: "iroh", + endpoint: { + type: "peer", + id: IROH_ENDPOINT_ID, + relay_url: "https://attacker.example/relay", + }, + }, + ])); + if (!result.ok) throw new Error(result.error); + expect(result.beat.routes).toEqual([ + legacy, + { + id: "iroh", + kind: "iroh", + priority: 1, + endpoint: { type: "peer", id: IROH_ENDPOINT_ID, relay_url: APPROVED_IROH_RELAY }, + }, + { + id: "iroh-unmanaged", + kind: "iroh", + endpoint: { type: "peer", id: IROH_ENDPOINT_ID }, + }, + ]); + }); }); From f03b4bf99660fc51533991e6fabcf1f26b91c150 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Fri, 10 Jul 2026 10:30:17 -0700 Subject: [PATCH 20/37] fix(iroh): keep private paths off server surfaces --- web/app/api/devices/route.ts | 15 ++-- .../migration.sql | 30 +++++++ web/services/iroh/README.md | 31 ++++--- web/services/iroh/model.ts | 10 +-- web/services/iroh/publicationPolicy.ts | 87 +++++++++++++++++++ web/services/iroh/repository.ts | 10 ++- web/services/iroh/trustBroker.ts | 10 ++- workers/presence/README.md | 11 +++ workers/presence/src/core.ts | 32 ++++--- workers/presence/src/do.ts | 35 ++++++-- workers/presence/src/routePrivacy.ts | 75 ++++++++++++++++ workers/presence/src/syncDevices.ts | 35 +++++++- workers/presence/src/syncPairedMacs.ts | 47 +++++++--- workers/presence/src/validate.ts | 8 +- 14 files changed, 364 insertions(+), 72 deletions(-) create mode 100644 web/db/migrations/20260710120000_iroh_server_path_privacy/migration.sql create mode 100644 web/services/iroh/publicationPolicy.ts create mode 100644 workers/presence/src/routePrivacy.ts diff --git a/web/app/api/devices/route.ts b/web/app/api/devices/route.ts index a5f10b478b54..8bd734416868 100644 --- a/web/app/api/devices/route.ts +++ b/web/app/api/devices/route.ts @@ -28,6 +28,7 @@ import { AccountDeletionMutationBlockedError, assertAccountDeletionUserMutationAllowed, } from "../../../services/account/deletionLock"; +import { sanitizeServerPublishedRoutes } from "../../../services/iroh/publicationPolicy"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -109,11 +110,9 @@ function recordOrEmpty(value: unknown): Record { /** * Keep only structurally valid route entries (a plain object), bounded by - * `MAX_ROUTES`. Semantic validation of the `CmxAttachRoute` wire schema is left - * to the typed Mac/iOS clients, so the server stays forward-compatible with new - * route kinds; this only guarantees the stored jsonb is a bounded array of - * objects (never a string, number, or array element that would corrupt the - * column or bloat the row). + * `MAX_ROUTES`. Legacy route semantics remain client-owned so the server stays + * forward-compatible with non-Iroh route kinds. Iroh entries cross the stricter + * publication policy after this structural bound. */ function routesArray(value: unknown): unknown[] { if (!Array.isArray(value)) return []; @@ -153,7 +152,7 @@ export async function POST(request: Request): Promise { const { manual: _ignoredManualLabel, ...labels } = rawLabels; void _ignoredManualLabel; const tag = trimmedString(body.value.tag) || "default"; - const routes = routesArray(body.value.routes); + const routes = sanitizeServerPublishedRoutes(routesArray(body.value.routes)); const instanceLabels = recordOrEmpty(body.value.instanceLabels); // `manual: true` marks a user-initiated remote added through the cmux CLI // (`cmux remotes add`) rather than a Mac self-registering its own live @@ -391,7 +390,9 @@ export async function GET(request: Request): Promise { lastSeenAt: device.lastSeenAt.toISOString(), instances: (instancesByDevice.get(device.id) ?? []).map((instance) => ({ tag: instance.tag, - routes: instance.routes, + routes: sanitizeServerPublishedRoutes( + Array.isArray(instance.routes) ? instance.routes : [], + ), labels: instance.labels, lastSeenAt: instance.lastSeenAt.toISOString(), })), diff --git a/web/db/migrations/20260710120000_iroh_server_path_privacy/migration.sql b/web/db/migrations/20260710120000_iroh_server_path_privacy/migration.sql new file mode 100644 index 000000000000..baf7f8ec947c --- /dev/null +++ b/web/db/migrations/20260710120000_iroh_server_path_privacy/migration.sql @@ -0,0 +1,30 @@ +-- Iroh is not yet a production rendezvous dependency, so remove every legacy +-- server-held path hint before the relay-only publication policy rolls out. +-- New registrations may repopulate exact managed relay URLs, never direct or +-- private addresses. +UPDATE "iroh_endpoint_bindings" +SET "path_hints" = '[]'::jsonb, + "path_hints_next_expiry" = NULL +WHERE "path_hints" <> '[]'::jsonb; +--> statement-breakpoint +-- Existing hosts will republish their EndpointID plus managed relay URL on the +-- next heartbeat. Preserve every non-Iroh route byte-for-byte so LAN, +-- Tailscale, and custom-network reconnects remain available during rollout. +UPDATE "device_app_instances" +SET "routes" = '[]'::jsonb +WHERE jsonb_typeof("routes") IS DISTINCT FROM 'array'; +--> statement-breakpoint +UPDATE "device_app_instances" AS instance +SET "routes" = COALESCE( + ( + SELECT jsonb_agg(route.value ORDER BY route.ordinality) + FROM jsonb_array_elements(instance."routes") WITH ORDINALITY AS route(value, ordinality) + WHERE route.value ->> 'kind' IS DISTINCT FROM 'iroh' + ), + '[]'::jsonb +) +WHERE EXISTS ( + SELECT 1 + FROM jsonb_array_elements(instance."routes") AS route(value) + WHERE route.value ->> 'kind' = 'iroh' +); diff --git a/web/services/iroh/README.md b/web/services/iroh/README.md index f18023600ecb..784fc527e2c7 100644 --- a/web/services/iroh/README.md +++ b/web/services/iroh/README.md @@ -3,17 +3,17 @@ The broker is scoped to the authenticated Stack `user.id`. Team membership and the legacy team device registry never grant Iroh discovery or pairing access. -Registration challenge signatures are currently the only accepted path-hint -mutation. A Stack bearer alone cannot update hints. Address churn therefore -requires a fresh five-minute registration challenge until -`endpoint-signed-monotonic-watch-addr-update-v1` adds a dedicated signed update -route with an endpoint-owned sequence number. Endpoint or identity-generation -replacement also requires explicit revocation and reapproval; a signature from -only the proposed new key cannot replace an active binding. +Registration signatures may carry the native Iroh `watch_addr` shape, but the +server strips every direct/private address before repository persistence. Direct +paths stay device-local or move endpoint-to-endpoint after admission. The broker +persists and publishes only exact managed relay URLs. Endpoint or +identity-generation replacement requires explicit revocation and reapproval; a +signature from only the proposed new key cannot replace an active binding. `relay_fleet` is the server-configured connection preset/allowlist. It is not a -peer address. Each peer's `relay_url` hints come from its signed `watch_addr` -payload and must match that allowlist. +peer address. Each peer's published `relay_url` comes from its signed +`watch_addr` payload and must match that allowlist. Discovery defensively +filters older rows to the same relay-only policy. Discovery runs the user-scoped retention cleanup before reading. It removes expired hints from binding JSON, challenges more than 24 hours past expiry or @@ -59,11 +59,16 @@ lock both exact signed peers at audit insertion, requiring an iOS initiator and a pairable Mac acceptor. Relay credentials are returned only after a second locked active-binding check following the external mint. -Registration stores the earliest path-hint expiry in +Registration stores the earliest managed-relay expiry in `path_hints_next_expiry`. The hourly cleanup uses that indexed scalar and -bounded 500-row `FOR UPDATE SKIP LOCKED` batches for hints, challenges, audit -rows, and revoked bindings. Concurrent cron workers can cooperate without a -full-table JSON scan. +bounded 500-row `FOR UPDATE SKIP LOCKED` batches for relay hints, challenges, +audit rows, and revoked bindings. Concurrent cron workers can cooperate without +a full-table JSON scan. + +The `20260710120000_iroh_server_path_privacy` migration clears pre-policy broker +hints and removes existing Iroh route bodies from the legacy device registry. +Hosts republish sanitized EndpointID/managed-relay routes on their next refresh; +non-Iroh routes are preserved in order. The `20260710113000_iroh_relay_reservation_expiry` migration adds the expanded status constraint with `NOT VALID` so its `ACCESS EXCLUSIVE` lock is diff --git a/web/services/iroh/model.ts b/web/services/iroh/model.ts index bbdfcd109758..21a5b54bd277 100644 --- a/web/services/iroh/model.ts +++ b/web/services/iroh/model.ts @@ -1,6 +1,9 @@ import { createHash } from "node:crypto"; import { isIP } from "node:net"; import { IrohInvalidInputError } from "./errors"; +import { MANAGED_RELAY_URLS } from "./publicationPolicy"; + +export { MANAGED_RELAY_URLS } from "./publicationPolicy"; export const IROH_ALPN = "cmux/mobile/1"; export const IROH_PAIR_SCOPE = "cmux.mobile.attach"; @@ -18,13 +21,6 @@ export const IROH_RELAY_TOKEN_REFRESH_SECONDS = 12 * 60 * 60; export const IROH_ROUTE_CONTRACT_VERSION = 1; export const POSTGRES_INT32_MAX = 2_147_483_647; -export const MANAGED_RELAY_URLS = [ - "https://euc1-1.relay.lawrence.cmux.iroh.link/", - "https://use1-1.relay.lawrence.cmux.iroh.link/", - "https://usw1-1.relay.lawrence.cmux.iroh.link/", - "https://aps1-1.relay.lawrence.cmux.iroh.link/", -] as const; - export type IrohPathHint = { readonly kind: "direct_address" | "relay_url"; readonly value: string; diff --git a/web/services/iroh/publicationPolicy.ts b/web/services/iroh/publicationPolicy.ts new file mode 100644 index 000000000000..4a9a1761997a --- /dev/null +++ b/web/services/iroh/publicationPolicy.ts @@ -0,0 +1,87 @@ +/** + * Server publication policy for Iroh rendezvous data. + * + * Direct addresses, private-network coordinates, and opaque relay hints stay on + * the endpoint. Server surfaces may persist or distribute only a verified Iroh + * EndpointID and an exact managed relay URL. Non-Iroh routes remain opaque for + * compatibility with the existing LAN, Tailscale, and custom-network paths. + */ + +export const MANAGED_RELAY_URLS = [ + "https://euc1-1.relay.lawrence.cmux.iroh.link/", + "https://use1-1.relay.lawrence.cmux.iroh.link/", + "https://usw1-1.relay.lawrence.cmux.iroh.link/", + "https://aps1-1.relay.lawrence.cmux.iroh.link/", +] as const; + +const MANAGED_RELAY_URL_SET: ReadonlySet = new Set(MANAGED_RELAY_URLS); +const ENDPOINT_ID_RE = /^[0-9a-f]{64}$/; +const MAX_ROUTE_ID_LENGTH = 256; + +type PathHintLike = { + readonly kind: string; + readonly value: string; +}; + +/** Keep only endpoint-reported managed relay URLs for server persistence. */ +export function serverPublishedIrohPathHints( + hints: readonly T[], +): T[] { + return hints.filter((hint) => + hint.kind === "relay_url" && MANAGED_RELAY_URL_SET.has(hint.value)); +} + +function plainRecord(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +function sanitizeIrohRoute(route: Record): Record | null { + const routeId = typeof route.id === "string" ? route.id : ""; + const endpoint = plainRecord(route.endpoint); + const endpointId = endpoint?.id; + if ( + routeId.length === 0 || + routeId.length > MAX_ROUTE_ID_LENGTH || + endpoint?.type !== "peer" || + typeof endpointId !== "string" || + !ENDPOINT_ID_RE.test(endpointId) + ) { + return null; + } + + const publicEndpoint: Record = { type: "peer", id: endpointId }; + if (typeof endpoint.relay_url === "string" && MANAGED_RELAY_URL_SET.has(endpoint.relay_url)) { + publicEndpoint.relay_url = endpoint.relay_url; + } + + const published: Record = { + id: routeId, + kind: "iroh", + endpoint: publicEndpoint, + }; + if (typeof route.priority === "number" && Number.isSafeInteger(route.priority)) { + published.priority = route.priority; + } + return published; +} + +/** + * Sanitize route bodies before persistence or publication. Invalid Iroh routes + * are dropped because their EndpointID cannot be authenticated or dialed. + */ +export function sanitizeServerPublishedRoutes(routes: readonly unknown[]): Record[] { + const published: Record[] = []; + for (const value of routes) { + const route = plainRecord(value); + if (!route) continue; + if (route.kind !== "iroh") { + published.push(route); + continue; + } + const sanitized = sanitizeIrohRoute(route); + if (sanitized) published.push(sanitized); + } + return published; +} diff --git a/web/services/iroh/repository.ts b/web/services/iroh/repository.ts index f9d5701da85c..d84e0d51f184 100644 --- a/web/services/iroh/repository.ts +++ b/web/services/iroh/repository.ts @@ -29,6 +29,7 @@ import { type IrohPathHint, type IrohRegistrationPayload, } from "./model"; +import { serverPublishedIrohPathHints } from "./publicationPolicy"; export const IROH_RETENTION_BATCH_SIZE = 500; export const IROH_RETENTION_MAX_ROWS = 10_000; @@ -248,6 +249,7 @@ function makeLiveRepository(): IrohRepositoryShape { consumeChallengeAndRegister: (input) => repositoryEffect("register_binding", async () => { const db = cloudDb(); return await db.transaction(async (tx) => { + const publishedPathHints = serverPublishedIrohPathHints(input.payload.pathHints); await assertIrohUserMutationAllowed(tx, input.userId); await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`iroh:binding:${input.userId}`}, 0))`); await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`iroh:endpoint:${input.payload.endpointId}`}, 0))`); @@ -293,8 +295,8 @@ function makeLiveRepository(): IrohRepositoryShape { displayName: input.payload.displayName ?? null, pairingEnabled: input.payload.pairingEnabled, capabilities: [...input.payload.capabilities], - pathHints: [...input.payload.pathHints], - pathHintsNextExpiry: nextPathHintExpiry(input.payload.pathHints), + pathHints: publishedPathHints, + pathHintsNextExpiry: nextPathHintExpiry(publishedPathHints), lastSeenAt: input.now, updatedAt: input.now, }) @@ -355,8 +357,8 @@ function makeLiveRepository(): IrohRepositoryShape { identityGeneration: input.payload.identityGeneration, pairingEnabled: input.payload.pairingEnabled, capabilities: [...input.payload.capabilities], - pathHints: [...input.payload.pathHints], - pathHintsNextExpiry: nextPathHintExpiry(input.payload.pathHints), + pathHints: publishedPathHints, + pathHintsNextExpiry: nextPathHintExpiry(publishedPathHints), deviceLimitOverrideUsed: usesDeviceOverride, lastSeenAt: input.now, registeredAt: input.now, diff --git a/web/services/iroh/trustBroker.ts b/web/services/iroh/trustBroker.ts index 4e25f1eaf9cc..b210996b287d 100644 --- a/web/services/iroh/trustBroker.ts +++ b/web/services/iroh/trustBroker.ts @@ -64,6 +64,7 @@ import { IrohRelayMinterLive, type IrohRelayMinterShape, } from "./relayMinter"; +import { serverPublishedIrohPathHints } from "./publicationPolicy"; export type IrohTrustBrokerShape = { readonly issueChallenge: ( @@ -204,7 +205,10 @@ export function makeIrohTrustBroker( userId, challengeId: challenge.id, nonceHash: nonceHash(request.nonce), - payload: decoded.payload, + payload: { + ...decoded.payload, + pathHints: serverPublishedIrohPathHints(decoded.payload.pathHints), + }, now, deviceLimitOverrideAllowed: deviceLimitOverrideAllowed(config, userId), }); @@ -411,13 +415,13 @@ function publicBinding(binding: IrohBindingRecord, now: Date): object { identity_generation: binding.identityGeneration, pairing_enabled: binding.pairingEnabled, capabilities: binding.capabilities, - path_hints: binding.pathHints.flatMap((hint): IrohPathHint[] => { + path_hints: serverPublishedIrohPathHints(binding.pathHints.flatMap((hint): IrohPathHint[] => { try { return [parseIrohPathHint(hint, now)]; } catch { return []; } - }), + })), last_seen_at: binding.lastSeenAt.toISOString(), }; } diff --git a/workers/presence/README.md b/workers/presence/README.md index 6fa7726edff1..455d4b78a0f5 100644 --- a/workers/presence/README.md +++ b/workers/presence/README.md @@ -24,6 +24,17 @@ The heartbeat response returns `heartbeatIntervalMs` (15s) and hardcoding it. An instance that misses heartbeats for the timeout window is flipped offline by the Durable Object alarm with `reason: "timeout"`. +Every server boundary reduces Iroh routes to the EndpointID plus an exact +managed relay URL. Direct addresses and private-network hints remain on the +device or move endpoint-to-endpoint after admission. Existing LAN, Tailscale, +and custom-network route bodies pass through unchanged for compatibility. + +Presence still sends the full sanitized route body in `online`, `routes`, and +snapshot frames. Current iOS clients write that body into their paired-Mac store +and reconnect without a registry round trip. Replacing it with a routes-changed +signal requires a new client protocol plus an authenticated fetch or local +rendezvous path, so that protocol change is intentionally separate. + Devices are owner-bound, mirroring the registry route: the first authenticated user to announce a `deviceId` owns it, and a heartbeat for that device from a different team member is rejected with `403 device_owner_mismatch`, so a diff --git a/workers/presence/src/core.ts b/workers/presence/src/core.ts index 526f66785090..83fac526dfb4 100644 --- a/workers/presence/src/core.ts +++ b/workers/presence/src/core.ts @@ -6,6 +6,8 @@ // Object alarm (an explicit event, not just absence). Everything here is pure // and synchronous so it unit-tests without Workers runtime or storage. +import { sanitizePublishedRoutes } from "./routePrivacy"; + /** How often hosts should heartbeat. Returned to clients so the cadence is * server-owned and can change without shipping new host builds. */ export const HEARTBEAT_INTERVAL_MS = 15_000; @@ -23,9 +25,8 @@ export const OFFLINE_TIMEOUT_MS = 45_000; export const PRUNE_AFTER_MS = 24 * 60 * 60 * 1000; /** One attach route as the registry stores it (`device_app_instances.routes` - * jsonb). Opaque to presence, exactly like the registry route: bounded plain - * objects whose semantic schema (`CmxAttachRoute`) is owned by the clients, so - * new route kinds flow through without a worker ship. */ + * jsonb). Legacy kinds remain opaque. Iroh routes cross a server privacy + * boundary and are reduced to EndpointID plus an approved managed relay URL. */ export type PresenceRoute = Record; export interface PresenceInstance { @@ -52,7 +53,7 @@ export interface PresenceInstance { * A live CACHE of the durable registry row (the host writes the same set to * `POST /api/devices`), kept here so subscribers get fresh routes pushed in * realtime instead of polling the registry. Reconciliation on DO cold start - * is the heartbeat itself: hosts re-announce the full set within 15s. */ + * is the heartbeat itself: hosts re-announce the sanitized set within 15s. */ routes?: PresenceRoute[]; } @@ -75,9 +76,9 @@ export type PresenceEvent = | { type: "online"; instance: PresenceInstance } | { type: "offline"; instance: PresenceInstance; reason: "timeout" | "goodbye" } | { type: "seen"; deviceId: string; tag: string; lastSeenAt: number } - /** The instance's attach routes changed while online (new port/IP). Carries - * the full updated instance so subscribers can reconnect on the fresh routes - * without a registry round trip. */ + /** The instance's attach routes changed while online. Carries the full + * sanitized instance so subscribers can reconnect without a registry round + * trip. */ | { type: "routes"; instance: PresenceInstance }; export interface HeartbeatResult { @@ -112,7 +113,9 @@ export function applyHeartbeat( const wasOnline = existing?.online === true; // Absent routes mean "unchanged": keep the previous set so a client that // omits the field (or a future slim keepalive) never wipes pushed routes. - const routes = beat.routes ?? existing?.routes; + const existingRoutes = sanitizePublishedRoutes(existing?.routes); + const beatRoutes = sanitizePublishedRoutes(beat.routes); + const routes = beatRoutes ?? existingRoutes; const instance: PresenceInstance = { deviceId: beat.deviceId, tag: beat.tag, @@ -131,7 +134,7 @@ export function applyHeartbeat( // Already online: a changed route set is the realtime "new port/IP" push; // an unchanged one is just a lightweight liveness tick. const events: PresenceEvent[] = - beat.routes !== undefined && !routesEqual(existing.routes, beat.routes) + beatRoutes !== undefined && !routesEqual(existingRoutes, beatRoutes) ? [{ type: "routes", instance }] : [{ type: "seen", deviceId: instance.deviceId, tag: instance.tag, lastSeenAt: nowMs }]; return { instance, events }; @@ -146,7 +149,7 @@ function applyGoodbye( // Keep the last known routes on the offline record: they are the // best-known rendezvous for "try waking this host", matching the registry // row that outlives the instance going offline. - const routes = beat.routes ?? existing?.routes; + const routes = sanitizePublishedRoutes(beat.routes) ?? sanitizePublishedRoutes(existing?.routes); const instance: PresenceInstance = { deviceId: beat.deviceId, tag: beat.tag, @@ -280,11 +283,13 @@ export function expireInstances( for (const instance of instances) { if (!instance.online) continue; if (nowMs - instance.lastSeenAt < timeoutMs) continue; + const routes = sanitizePublishedRoutes(instance.routes); const updated: PresenceInstance = { ...instance, online: false, onlineSince: undefined, offlineAt: nowMs, + ...(routes !== undefined ? { routes } : {}), }; expired.push(updated); events.push({ type: "offline", instance: updated, reason: "timeout" }); @@ -350,8 +355,13 @@ export function buildSnapshot( ): PresenceSnapshot { const byDevice = new Map(); for (const instance of instances) { + const routes = sanitizePublishedRoutes(instance.routes); + const publishedInstance: PresenceInstance = { + ...instance, + ...(routes !== undefined ? { routes } : {}), + }; const list = byDevice.get(instance.deviceId) ?? []; - list.push(instance); + list.push(publishedInstance); byDevice.set(instance.deviceId, list); } const devices: PresenceDevice[] = []; diff --git a/workers/presence/src/do.ts b/workers/presence/src/do.ts index 35e40814d55e..7619a8b12bb1 100644 --- a/workers/presence/src/do.ts +++ b/workers/presence/src/do.ts @@ -50,6 +50,7 @@ import { ownersFromList, reconcileDeviceRecords, reconcileSingleDevice, + sanitizeDeviceSyncFrame, type DeviceRecord, } from "./syncDevices"; import { @@ -62,9 +63,11 @@ import { PAIRED_MACS_COLLECTION_TOMBSTONE_PREFIXES, relabelDelta, relabelSnapshot, + sanitizePairedMacSyncFrame, type PairedMacBackupOp, type PairedMacBackupRecord, } from "./syncPairedMacs"; +import { sanitizePublishedRoutes } from "./routePrivacy"; const INSTANCE_PREFIX = "inst:"; /** `owner:` -> Stack user id pinned on first heartbeat. Durable: @@ -526,9 +529,11 @@ export class TeamPresence extends DurableObject { epoch ?? 0, ); if (resolved.mode === "snapshot") { - for (const page of resolved.pages) this.sendSync(ws, page); + for (const page of resolved.pages) { + this.sendSync(ws, sanitizeDeviceSyncFrame(page)); + } } else if (resolved.delta !== null) { - this.sendSync(ws, resolved.delta); + this.sendSync(ws, sanitizeDeviceSyncFrame(resolved.delta)); } continue; } @@ -550,9 +555,11 @@ export class TeamPresence extends DurableObject { epoch ?? 0, ); if (resolved.mode === "snapshot") { - for (const page of resolved.pages) this.sendSync(ws, relabelSnapshot(page)); + for (const page of resolved.pages) { + this.sendSync(ws, relabelSnapshot(sanitizePairedMacSyncFrame(page))); + } } else if (resolved.delta !== null) { - this.sendSync(ws, relabelDelta(resolved.delta)); + this.sendSync(ws, relabelDelta(sanitizePairedMacSyncFrame(resolved.delta))); } } // Mark this socket as sync-subscribed so future delta broadcasts reach it. @@ -589,9 +596,12 @@ export class TeamPresence extends DurableObject { * client whose presence decoder throws on unknown message types. WS only; SSE * is presence-only for now. */ private broadcastSync(frame: SyncServerFrame): void { - const collection = frame.collection; + const published = frame.collection === DEVICES_COLLECTION + ? sanitizeDeviceSyncFrame(frame as SyncServerFrame) + : frame; + const collection = published.collection; const now = Date.now(); - const json = JSON.stringify(frame); + const json = JSON.stringify(published); for (const ws of this.ctx.getWebSockets()) { if (wsExpiresAt(ws) <= now) continue; if (!wsSyncCollections(ws).includes(collection)) continue; // not subscribed @@ -610,9 +620,12 @@ export class TeamPresence extends DurableObject { * another user's backup. The connection user id is pinned from the verified * `x-presence-user-id` at subscribe time, never from client input. */ private broadcastSyncToUser(userId: string, frame: SyncServerFrame): void { - const collection = frame.collection; + const published = frame.collection === PAIRED_MACS_COLLECTION + ? sanitizePairedMacSyncFrame(frame as SyncServerFrame) + : frame; + const collection = published.collection; const now = Date.now(); - const json = JSON.stringify(frame); + const json = JSON.stringify(published); for (const ws of this.ctx.getWebSockets()) { if (wsExpiresAt(ws) <= now) continue; if (wsUserId(ws) !== userId) continue; // not this user's socket @@ -690,12 +703,16 @@ export class TeamPresence extends DurableObject { // ---- Internals ---- private heartbeatOk(teamId: string, instance: PresenceInstance): HeartbeatResponse { + const routes = sanitizePublishedRoutes(instance.routes); return { ok: true, teamId, heartbeatIntervalMs: HEARTBEAT_INTERVAL_MS, offlineTimeoutMs: OFFLINE_TIMEOUT_MS, - instance, + instance: { + ...instance, + ...(routes !== undefined ? { routes } : {}), + }, }; } diff --git a/workers/presence/src/routePrivacy.ts b/workers/presence/src/routePrivacy.ts new file mode 100644 index 000000000000..208c4079f53e --- /dev/null +++ b/workers/presence/src/routePrivacy.ts @@ -0,0 +1,75 @@ +// Keep this fleet in lockstep with web/services/iroh/publicationPolicy.ts. +// The Worker cannot import from `web/` because each deployment has its own +// build root. Exact URLs are intentional: arbitrary relay URLs are path hints. +export const APPROVED_IROH_RELAY_URLS = [ + "https://euc1-1.relay.lawrence.cmux.iroh.link/", + "https://use1-1.relay.lawrence.cmux.iroh.link/", + "https://usw1-1.relay.lawrence.cmux.iroh.link/", + "https://aps1-1.relay.lawrence.cmux.iroh.link/", +] as const; + +const APPROVED_IROH_RELAY_URL_SET: ReadonlySet = new Set(APPROVED_IROH_RELAY_URLS); +const ENDPOINT_ID_RE = /^[0-9a-f]{64}$/; +const MAX_ROUTE_ID_LENGTH = 256; + +function plainRecord(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +function sanitizeIrohRoute(route: Record): Record | null { + const routeId = typeof route.id === "string" ? route.id : ""; + const endpoint = plainRecord(route.endpoint); + const endpointId = endpoint?.id; + if ( + routeId.length === 0 || + routeId.length > MAX_ROUTE_ID_LENGTH || + endpoint?.type !== "peer" || + typeof endpointId !== "string" || + !ENDPOINT_ID_RE.test(endpointId) + ) { + return null; + } + + const publicEndpoint: Record = { type: "peer", id: endpointId }; + if ( + typeof endpoint.relay_url === "string" && + APPROVED_IROH_RELAY_URL_SET.has(endpoint.relay_url) + ) { + publicEndpoint.relay_url = endpoint.relay_url; + } + + const published: Record = { + id: routeId, + kind: "iroh", + endpoint: publicEndpoint, + }; + if (typeof route.priority === "number" && Number.isSafeInteger(route.priority)) { + published.priority = route.priority; + } + return published; +} + +/** + * Server-side route publication policy. Iroh direct/private coordinates and + * opaque relay hints stay endpoint-local. Legacy route kinds pass through + * unchanged until their existing clients can migrate to a typed policy. + */ +export function sanitizePublishedRoutes( + routes: readonly unknown[] | undefined, +): Record[] | undefined { + if (routes === undefined) return undefined; + const published: Record[] = []; + for (const value of routes) { + const route = plainRecord(value); + if (!route) continue; + if (route.kind !== "iroh") { + published.push(route); + continue; + } + const sanitized = sanitizeIrohRoute(route); + if (sanitized) published.push(sanitized); + } + return published; +} diff --git a/workers/presence/src/syncDevices.ts b/workers/presence/src/syncDevices.ts index f6ec7fadd9cf..b62f7d6cd260 100644 --- a/workers/presence/src/syncDevices.ts +++ b/workers/presence/src/syncDevices.ts @@ -25,14 +25,19 @@ import { type SyncStorage, type SyncWriteResult, } from "./syncStorage"; -import { buildDelta, type SyncDeltaFrame } from "./sync"; +import { + buildDelta, + type SyncDeltaFrame, + type SyncServerFrame, +} from "./sync"; +import { sanitizePublishedRoutes } from "./routePrivacy"; export const DEVICES_COLLECTION = "devices"; /** One tagged app instance inside a device record. List-shape fields only. */ export interface DeviceInstanceRecord { tag: string; - /** Attach routes (the registry-mirrored set), opaque here as in presence. */ + /** Registry-mirrored routes after the presence publication policy. */ routes: PresenceRoute[]; /** Last-seen epoch ms AS OF the rev this record was stamped at. Not live. */ lastSeenAtAtRev: number; @@ -70,7 +75,7 @@ export function deriveDeviceRecord( lastSeenAtAtRev: newest.lastSeenAt, instances: sorted.map((i) => ({ tag: i.tag, - routes: i.routes ?? [], + routes: sanitizePublishedRoutes(i.routes) ?? [], lastSeenAtAtRev: i.lastSeenAt, })), }; @@ -124,6 +129,30 @@ export function deviceRecordEqual(stored: DeviceRecord, next: DeviceRecord): boo return !deviceShapeChanged(stored, next); } +/** Defensively scrub records read from pre-hardening sync storage. */ +export function sanitizeDeviceRecord(record: DeviceRecord): DeviceRecord { + return { + ...record, + instances: record.instances.map((instance) => ({ + ...instance, + routes: sanitizePublishedRoutes(instance.routes) ?? [], + })), + }; +} + +/** Sanitize stored device snapshots/deltas immediately before publication. */ +export function sanitizeDeviceSyncFrame>( + frame: Frame, +): Frame { + if (frame.type === "sync.tick") return frame; + return { + ...frame, + records: frame.records.map((record) => record.deleted + ? record + : { ...record, payload: sanitizeDeviceRecord(record.payload) }), + } as Frame; +} + /** Reconcile the whole `devices` collection against the DO's current presence * state in one pass. This is the single derivation hook called from BOTH DO * write paths (heartbeat and alarm), since list-shape changes happen on both diff --git a/workers/presence/src/syncPairedMacs.ts b/workers/presence/src/syncPairedMacs.ts index 0744b956df0f..d6553c73e0d3 100644 --- a/workers/presence/src/syncPairedMacs.ts +++ b/workers/presence/src/syncPairedMacs.ts @@ -22,6 +22,7 @@ // posture as sync.ts / syncStorage.ts. import { buildDelta, type SyncDeltaFrame, type SyncRecord, type SyncSnapshotFrame } from "./sync"; +import type { SyncServerFrame } from "./sync"; import { listRecords, readRecord, @@ -29,6 +30,7 @@ import { upsertRecord, type SyncStorage, } from "./syncStorage"; +import { sanitizePublishedRoutes } from "./routePrivacy"; /** Logical collection name the client subscribes to and stores under. */ export const PAIRED_MACS_COLLECTION = "pairedMacs"; @@ -78,10 +80,9 @@ export const MAX_ROUTES_TOTAL_BYTES = 2048; export const MAX_PAIRED_MAC_BACKUP_BYTES = MAX_BACKUP_OPS * (MAX_ROUTES_TOTAL_BYTES + 1024) + 2048; -/** The backup payload — mirrors the iOS `MobilePairedMac` row so a restore is - * lossless. The server bounds it but does not interpret `routes` (route - * validation stays client-owned, exactly like the heartbeat route handling), so - * new route kinds flow through without a worker ship. */ +/** The backup payload mirrors the iOS `MobilePairedMac` row. Legacy routes stay + * opaque, while Iroh routes are reduced to EndpointID plus an approved managed + * relay URL before persistence and again before restore. */ export interface PairedMacBackupRecord { macDeviceID: string; displayName?: string; @@ -261,7 +262,7 @@ export function parsePairedMacBackup(body: Record): PairedMacBa record: { macDeviceID: id, displayName: displayName || undefined, - routes, + routes: sanitizePublishedRoutes(routes) ?? [], createdAt, lastSeenAt, isActive: r.isActive === true, @@ -297,6 +298,26 @@ export function pairedMacShapeEqual(a: PairedMacBackupRecord, b: PairedMacBackup ); } +export function sanitizePairedMacRecord(record: PairedMacBackupRecord): PairedMacBackupRecord { + return { + ...record, + routes: sanitizePublishedRoutes(record.routes) ?? [], + }; +} + +/** Sanitize pre-hardening backup snapshots/deltas immediately before sending. */ +export function sanitizePairedMacSyncFrame>( + frame: Frame, +): Frame { + if (frame.type === "sync.tick") return frame; + return { + ...frame, + records: frame.records.map((record) => record.deleted + ? record + : { ...record, payload: sanitizePairedMacRecord(record.payload) }), + } as Frame; +} + /** Relabel a frame's collection from the physical per-user name back to the * logical `pairedMacs` so the client never sees (or stores under) the user-id * suffix. The rev space is the physical collection's, but each user has exactly @@ -329,7 +350,7 @@ async function seedScopedBackupFromUnscopedIfNeeded( storage, scopedCollection, stored.id, - stored.payload, + sanitizePairedMacRecord(stored.payload), nowMs, pairedMacShapeEqual, (record) => record.lastSeenAt, @@ -426,7 +447,7 @@ export async function applyBackupOps( // and the next restore would clear them. iOS always sends all three keys (it is // authoritative, including a `null` reset-to-Auto), so its uploads keep full // control. Only meaningful when there is a live existing record to inherit from. - const record = { ...op.record }; + const record = sanitizePairedMacRecord(op.record); const provided = op.providedCustom ?? { name: true, color: true, icon: true }; if (existing !== undefined && !existing.deleted) { const prev = existing.payload; @@ -470,7 +491,7 @@ async function clearOtherActiveBackupRecords( const deltas: SyncDeltaFrame[] = []; for (const stored of records) { if (stored.deleted || stored.id === activeID || !stored.payload.isActive) continue; - const next = { ...stored.payload, isActive: false }; + const next = { ...sanitizePairedMacRecord(stored.payload), isActive: false }; const res = await upsertRecord( storage, collection, @@ -511,7 +532,7 @@ export async function listBackupSnapshot( const all = await listRecords(storage, pairedMacsCollection(userId, clientScope)); const records = all .filter((r) => !r.deleted) - .map((r) => r.payload) + .map((r) => sanitizePairedMacRecord(r.payload)) .sort((a, b) => (b?.lastSeenAt ?? 0) - (a?.lastSeenAt ?? 0)); const deletedMacDeviceIDs = all .filter((r) => r.deleted) @@ -524,11 +545,13 @@ function recordWithFreshUnscopedRoutes( scoped: PairedMacBackupRecord, unscoped: PairedMacBackupRecord, ): PairedMacBackupRecord { - if ((unscoped.lastSeenAt ?? 0) <= (scoped.lastSeenAt ?? 0)) return scoped; + if ((unscoped.lastSeenAt ?? 0) <= (scoped.lastSeenAt ?? 0)) { + return sanitizePairedMacRecord(scoped); + } return { - ...scoped, + ...sanitizePairedMacRecord(scoped), displayName: unscoped.displayName, - routes: unscoped.routes, + routes: sanitizePublishedRoutes(unscoped.routes) ?? [], lastSeenAt: unscoped.lastSeenAt, }; } diff --git a/workers/presence/src/validate.ts b/workers/presence/src/validate.ts index f85c7386bcc0..0433c06616da 100644 --- a/workers/presence/src/validate.ts +++ b/workers/presence/src/validate.ts @@ -4,6 +4,7 @@ // presence layer. import type { HeartbeatInput, PresenceRoute } from "./core"; +import { sanitizePublishedRoutes } from "./routePrivacy"; export const MAX_REQUEST_BYTES = 16 * 1024; export const MAX_TAG_LENGTH = 64; @@ -89,9 +90,9 @@ export function parseHeartbeat(body: Record): HeartbeatParse { // rejected rather than coerced like the registry route does, because under // presence semantics a silent coercion would either wipe pushed routes // (treat-as-empty) or mask a client bug (treat-as-absent). Entry filtering - // mirrors the registry: keep only plain objects, bounded by MAX_ROUTES; - // semantic `CmxAttachRoute` validation stays client-owned so new route kinds - // flow through without a worker ship. + // mirrors the registry: keep only plain objects, bounded by MAX_ROUTES. + // Legacy route semantics stay client-owned. Iroh routes are then reduced to + // EndpointID plus an approved managed relay URL. let routes: PresenceRoute[] | undefined; if (body.routes !== undefined) { if (!Array.isArray(body.routes)) return { ok: false, error: "invalid_routes" }; @@ -109,6 +110,7 @@ export function parseHeartbeat(body: Record): HeartbeatParse { if (routeBytes > MAX_ROUTES_TOTAL_BYTES) break; routes.push(entry as PresenceRoute); } + routes = sanitizePublishedRoutes(routes); } return { From 7d90dbb44c1dc1243ee6a2d6afc1c346d2d2da92 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Fri, 10 Jul 2026 10:53:48 -0700 Subject: [PATCH 21/37] test(iroh): cover firewall dependency failures --- web/tests/iroh-route-handler.test.ts | 57 ++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/web/tests/iroh-route-handler.test.ts b/web/tests/iroh-route-handler.test.ts index e4b4584b7da6..3d00a0ecb1aa 100644 --- a/web/tests/iroh-route-handler.test.ts +++ b/web/tests/iroh-route-handler.test.ts @@ -35,6 +35,63 @@ describe("Iroh route boundary", () => { expect(called).toBe(false); }); + test("fails closed when the firewall check rejects", async () => { + let brokerCalled = false; + const dependencies = { + verify: async () => USER, + broker: broker({ + discover: () => { + brokerCalled = true; + return Effect.succeed({ bindings: [] }); + }, + }), + firewall: { + id: "iroh-test-rule", + check: async () => { + throw new Error("firewall unavailable"); + }, + }, + }; + + const response = await handleIrohRoute( + new Request("https://cmux.test/api/devices/iroh"), + "discover", + dependencies, + ); + + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ error: "iroh_service_unavailable" }); + expect(brokerCalled).toBe(false); + }); + + test("bounds a firewall check that never settles", async () => { + let brokerCalled = false; + const dependencies = { + verify: async () => USER, + broker: broker({ + discover: () => { + brokerCalled = true; + return Effect.succeed({ bindings: [] }); + }, + }), + firewall: { + id: "iroh-test-rule", + timeoutMs: 10, + check: () => new Promise(() => {}), + }, + }; + + const response = await handleIrohRoute( + new Request("https://cmux.test/api/devices/iroh"), + "discover", + dependencies, + ); + + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ error: "iroh_service_unavailable" }); + expect(brokerCalled).toBe(false); + }); + test("authenticates before reading an oversized body", async () => { let called = false; const response = await handleIrohRoute(new Request("https://cmux.test/api/devices/iroh/challenge", { From cccb468dcbe2740400265af9846d55988439b1fc Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Fri, 10 Jul 2026 10:55:46 -0700 Subject: [PATCH 22/37] fix(iroh): bound firewall availability checks --- web/services/iroh/routeHandler.ts | 46 +++++++++++++++++++++++++++---- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/web/services/iroh/routeHandler.ts b/web/services/iroh/routeHandler.ts index d7d48b478d5a..dd53c07992e2 100644 --- a/web/services/iroh/routeHandler.ts +++ b/web/services/iroh/routeHandler.ts @@ -13,6 +13,7 @@ import { } from "./trustBroker"; const MAX_BODY_BYTES = 64 * 1_024; +const FIREWALL_TIMEOUT_MS = 2_500; export type IrohRouteOperation = | "challenge" @@ -27,6 +28,11 @@ type RouteDependencies = { readonly verify?: typeof verifyRequest; readonly broker?: IrohTrustBrokerShape; readonly runtime?: Layer.Layer; + readonly firewall?: { + readonly id: string; + readonly check: typeof checkRateLimit; + readonly timeoutMs?: number; + }; }; export async function handleIrohRoute( @@ -43,11 +49,29 @@ export async function handleIrohRoute( } if (!user) return unauthorized(); - if (process.env.VERCEL === "1" && env.CMUX_IROH_RATE_LIMIT_ID) { - const { error, rateLimited } = await checkRateLimit(env.CMUX_IROH_RATE_LIMIT_ID, { - request, - rateLimitKey: createHash("sha256").update(`iroh-rate:${user.id}:${operation}`).digest("hex"), - }); + const firewall = dependencies.firewall ?? ( + process.env.VERCEL === "1" && env.CMUX_IROH_RATE_LIMIT_ID + ? { id: env.CMUX_IROH_RATE_LIMIT_ID, check: checkRateLimit } + : undefined + ); + if (firewall) { + let result: Awaited>; + try { + result = await withTimeout( + firewall.check(firewall.id, { + request, + rateLimitKey: createHash("sha256").update(`iroh-rate:${user.id}:${operation}`).digest("hex"), + }), + firewall.timeoutMs ?? FIREWALL_TIMEOUT_MS, + ); + } catch { + console.error("iroh trust broker firewall unavailable", { + operation, + failure: "request_failed_or_timed_out", + }); + return jsonResponse({ error: "iroh_service_unavailable" }, 503); + } + const { error, rateLimited } = result; if (rateLimited || error === "blocked") { return irohJsonResponse({ error: "rate_limited" }, 429, { "retry-after": "60" }); } @@ -89,6 +113,18 @@ export async function handleIrohRoute( } } +async function withTimeout(work: Promise, timeoutMs: number): Promise { + let timer: ReturnType | undefined; + const deadline = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("firewall_timeout")), timeoutMs); + }); + try { + return await Promise.race([work, deadline]); + } finally { + if (timer) clearTimeout(timer); + } +} + function invoke( broker: IrohTrustBrokerShape, operation: IrohRouteOperation, From 37f827cd4b97b9aed5cca01b91c58027498aae34 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Fri, 10 Jul 2026 11:54:29 -0700 Subject: [PATCH 23/37] test(iroh): cap stalled firewall work --- web/tests/iroh-route-handler.test.ts | 32 +++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/web/tests/iroh-route-handler.test.ts b/web/tests/iroh-route-handler.test.ts index 3d00a0ecb1aa..322167455531 100644 --- a/web/tests/iroh-route-handler.test.ts +++ b/web/tests/iroh-route-handler.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import * as Effect from "effect/Effect"; import { IrohDatabaseError, IrohQuotaExceededError } from "../services/iroh/errors"; -import { handleIrohRoute } from "../services/iroh/routeHandler"; +import { handleIrohRoute, IrohFirewallAdmission } from "../services/iroh/routeHandler"; import type { IrohTrustBrokerShape } from "../services/iroh/trustBroker"; import type { AuthedUser } from "../services/vms/auth"; import { GET as retentionGet } from "../app/api/internal/iroh/retention/route"; @@ -92,6 +92,36 @@ describe("Iroh route boundary", () => { expect(brokerCalled).toBe(false); }); + test("caps timed-out firewall work per identity and across the worker", async () => { + const admission = new IrohFirewallAdmission(2); + let started = 0; + const firewall = { + id: "iroh-test-rule", + timeoutMs: 5, + admission, + check: () => { + started += 1; + return new Promise(() => {}); + }, + }; + const discover = (userId: string) => handleIrohRoute( + new Request("https://cmux.test/api/devices/iroh"), + "discover", + { + verify: async () => ({ ...USER, id: userId }), + broker: broker({ discover: () => Effect.succeed({ bindings: [] }) }), + firewall, + }, + ); + + expect((await discover("user-1")).status).toBe(503); + expect((await discover("user-1")).status).toBe(503); + expect((await discover("user-2")).status).toBe(503); + expect((await discover("user-3")).status).toBe(503); + expect(started).toBe(2); + expect(admission.activeCount).toBe(2); + }); + test("authenticates before reading an oversized body", async () => { let called = false; const response = await handleIrohRoute(new Request("https://cmux.test/api/devices/iroh/challenge", { From ce1c3e54e4980d24641b49372dbb46c12cd658cf Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Fri, 10 Jul 2026 11:57:58 -0700 Subject: [PATCH 24/37] fix(iroh): cap stalled firewall work --- web/services/iroh/routeHandler.ts | 46 +++++++++++++++++++++++++++---- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/web/services/iroh/routeHandler.ts b/web/services/iroh/routeHandler.ts index dd53c07992e2..cbc4695721ed 100644 --- a/web/services/iroh/routeHandler.ts +++ b/web/services/iroh/routeHandler.ts @@ -14,6 +14,38 @@ import { const MAX_BODY_BYTES = 64 * 1_024; const FIREWALL_TIMEOUT_MS = 2_500; +const FIREWALL_MAX_IN_FLIGHT = 64; + +type FirewallCheckResult = Awaited>; + +export class IrohFirewallAdmission { + private readonly active = new Map>(); + + constructor(private readonly maxInFlight: number) { + if (!Number.isSafeInteger(maxInFlight) || maxInFlight <= 0) { + throw new RangeError("maxInFlight must be a positive integer"); + } + } + + get activeCount(): number { + return this.active.size; + } + + run(key: string, start: () => Promise): Promise { + if (this.active.has(key) || this.active.size >= this.maxInFlight) { + throw new Error("firewall_admission_unavailable"); + } + + const work = Promise.resolve().then(start); + const tracked = work.finally(() => { + if (this.active.get(key) === tracked) this.active.delete(key); + }); + this.active.set(key, tracked); + return tracked; + } +} + +const firewallAdmission = new IrohFirewallAdmission(FIREWALL_MAX_IN_FLIGHT); export type IrohRouteOperation = | "challenge" @@ -32,6 +64,7 @@ type RouteDependencies = { readonly id: string; readonly check: typeof checkRateLimit; readonly timeoutMs?: number; + readonly admission?: IrohFirewallAdmission; }; }; @@ -55,13 +88,16 @@ export async function handleIrohRoute( : undefined ); if (firewall) { - let result: Awaited>; + const rateLimitKey = createHash("sha256") + .update(`iroh-rate:${user.id}:${operation}`) + .digest("hex"); + let result: FirewallCheckResult; try { result = await withTimeout( - firewall.check(firewall.id, { - request, - rateLimitKey: createHash("sha256").update(`iroh-rate:${user.id}:${operation}`).digest("hex"), - }), + (firewall.admission ?? firewallAdmission).run( + `${firewall.id}:${rateLimitKey}`, + () => firewall.check(firewall.id, { request, rateLimitKey }), + ), firewall.timeoutMs ?? FIREWALL_TIMEOUT_MS, ); } catch { From 1f5bd3116c25f81d65763caecf0463a6847b6094 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Fri, 10 Jul 2026 12:09:24 -0700 Subject: [PATCH 25/37] test(iroh): require firewall timeout recovery --- web/tests/iroh-route-handler.test.ts | 44 ++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/web/tests/iroh-route-handler.test.ts b/web/tests/iroh-route-handler.test.ts index 322167455531..2586ee18223b 100644 --- a/web/tests/iroh-route-handler.test.ts +++ b/web/tests/iroh-route-handler.test.ts @@ -122,6 +122,50 @@ describe("Iroh route boundary", () => { expect(admission.activeCount).toBe(2); }); + test("aborts timed-out firewall work and admits recovery", async () => { + const admission = new IrohFirewallAdmission(2); + let started = 0; + let aborted = false; + let brokerCalls = 0; + const firewall = { + id: "iroh-test-rule", + timeoutMs: 5, + admission, + check: (_id: string, options?: unknown) => { + started += 1; + if (started > 1) return Promise.resolve({ rateLimited: false }); + const signal = (options as { signal?: AbortSignal } | undefined)?.signal; + return new Promise((_resolve, reject) => { + signal?.addEventListener("abort", () => { + aborted = true; + reject(signal.reason); + }, { once: true }); + }); + }, + }; + const discover = () => handleIrohRoute( + new Request("https://cmux.test/api/devices/iroh"), + "discover", + { + verify: async () => USER, + broker: broker({ + discover: () => { + brokerCalls += 1; + return Effect.succeed({ bindings: [] }); + }, + }), + firewall, + }, + ); + + expect((await discover()).status).toBe(503); + expect(aborted).toBe(true); + expect((await discover()).status).toBe(200); + expect(started).toBe(2); + expect(brokerCalls).toBe(1); + expect(admission.activeCount).toBe(0); + }); + test("authenticates before reading an oversized body", async () => { let called = false; const response = await handleIrohRoute(new Request("https://cmux.test/api/devices/iroh/challenge", { From f339a403612f26816886317ad49c3cada9995f43 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Fri, 10 Jul 2026 12:17:14 -0700 Subject: [PATCH 26/37] fix(iroh): abort stalled firewall checks --- web/services/iroh/firewall.ts | 127 +++++++++++++++++++++++++++ web/services/iroh/routeHandler.ts | 50 +++++------ web/tests/iroh-firewall.test.ts | 90 +++++++++++++++++++ web/tests/iroh-route-handler.test.ts | 40 ++++++--- 4 files changed, 271 insertions(+), 36 deletions(-) create mode 100644 web/services/iroh/firewall.ts create mode 100644 web/tests/iroh-firewall.test.ts diff --git a/web/services/iroh/firewall.ts b/web/services/iroh/firewall.ts new file mode 100644 index 000000000000..288469fce663 --- /dev/null +++ b/web/services/iroh/firewall.ts @@ -0,0 +1,127 @@ +import { createHash } from "node:crypto"; + +export type IrohFirewallCheckResult = { + readonly rateLimited: boolean; + readonly error?: "not-found" | "blocked"; +}; + +export type IrohFirewallCheck = ( + rateLimitId: string, + options: { + readonly request: Request; + readonly rateLimitKey: string; + readonly signal: AbortSignal; + }, +) => Promise; + +type IrohFirewallEnvironment = Readonly<{ + readonly VERCEL_URL?: string; + readonly VERCEL_PROJECT_PRODUCTION_URL?: string; + readonly PUBLIC_VERCEL_FIREWALL_PATH_PREFIX?: string; + readonly NEXT_PUBLIC_VERCEL_FIREWALL_PATH_PREFIX?: string; + readonly VERCEL_AUTOMATION_BYPASS_SECRET?: string; + readonly RATE_LIMIT_SECRET?: string; +}>; + +type IrohFirewallDependencies = Readonly<{ + readonly environment?: IrohFirewallEnvironment; + readonly fetch?: typeof globalThis.fetch; +}>; + +// @vercel/firewall 1.2.1 does not pass an AbortSignal to its internal fetch. +// Keep the documented programmatic endpoint behavior while making cancellation +// part of this boundary's contract so a stalled check cannot outlive its slot. +export function createIrohVercelFirewallCheck( + dependencies: IrohFirewallDependencies = {}, +): IrohFirewallCheck { + const environment: IrohFirewallEnvironment = dependencies.environment ?? { + VERCEL_URL: process.env.VERCEL_URL, + VERCEL_PROJECT_PRODUCTION_URL: process.env.VERCEL_PROJECT_PRODUCTION_URL, + PUBLIC_VERCEL_FIREWALL_PATH_PREFIX: process.env.PUBLIC_VERCEL_FIREWALL_PATH_PREFIX, + NEXT_PUBLIC_VERCEL_FIREWALL_PATH_PREFIX: process.env.NEXT_PUBLIC_VERCEL_FIREWALL_PATH_PREFIX, + VERCEL_AUTOMATION_BYPASS_SECRET: process.env.VERCEL_AUTOMATION_BYPASS_SECRET, + RATE_LIMIT_SECRET: process.env.RATE_LIMIT_SECRET, + }; + const fetcher = dependencies.fetch ?? globalThis.fetch; + return async (rateLimitId, { request, rateLimitKey, signal }) => { + const host = trustedFirewallHost(request, environment); + const pathPrefix = normalizedPathPrefix( + environment.PUBLIC_VERCEL_FIREWALL_PATH_PREFIX + ?? environment.NEXT_PUBLIC_VERCEL_FIREWALL_PATH_PREFIX, + ); + const url = `https://${host}${pathPrefix}/.well-known/vercel/rate-limit-api/${encodeURIComponent(rateLimitId)}`; + const secretSuffix = `${environment.VERCEL_AUTOMATION_BYPASS_SECRET ?? ""}${environment.RATE_LIMIT_SECRET ?? ""}`; + const keyDigest = createHash("sha256") + .update(`${rateLimitKey}${rateLimitId}${secretSuffix}`) + .digest("hex"); + const headers = new Headers({ + "user-agent": "Bot/Vercel Rate Limit Checker", + "x-vercel-rate-limit-api": rateLimitId, + "x-vercel-rate-limit-key": `${rateLimitKey}-${keyDigest}`, + "x-forwarded-for": request.headers.get("x-forwarded-for") ?? "", + "x-real-ip": request.headers.get("x-real-ip") ?? "", + "x-vercel-protection-bypass": environment.VERCEL_AUTOMATION_BYPASS_SECRET ?? "", + }); + const previewJwt = cookieValue(request.headers.get("cookie"), "_vercel_jwt"); + if (previewJwt) headers.set("cookie", `_vercel_jwt=${previewJwt}`); + for (const [name, value] of request.headers.entries()) { + headers.append(`x-rr-${name}`, value); + } + + const response = await fetcher(url, { + method: "GET", + headers, + redirect: "manual", + signal, + }); + await response.body?.cancel().catch(() => {}); + switch (response.status) { + case 204: return { rateLimited: false }; + case 429: return { rateLimited: true }; + case 403: return { rateLimited: true, error: "blocked" }; + case 404: return { rateLimited: false, error: "not-found" }; + default: throw new Error("unexpected_firewall_status"); + } + }; +} + +export const checkIrohVercelFirewall = createIrohVercelFirewallCheck(); + +function trustedFirewallHost(request: Request, environment: IrohFirewallEnvironment): string { + const deploymentHost = normalizedHost(environment.VERCEL_URL); + const productionHost = normalizedHost(environment.VERCEL_PROJECT_PRODUCTION_URL); + if (!deploymentHost && !productionHost) throw new Error("firewall_host_unavailable"); + + const requestedHost = normalizedHost(request.headers.get("host") ?? new URL(request.url).host); + if (requestedHost && (requestedHost === deploymentHost || requestedHost === productionHost)) { + return requestedHost; + } + return deploymentHost ?? productionHost!; +} + +function normalizedHost(value: string | null | undefined): string | undefined { + const candidate = value?.trim().toLowerCase(); + if (!candidate) return undefined; + if (!/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?::[1-9][0-9]{0,4})?$/.test(candidate)) { + throw new Error("invalid_firewall_host"); + } + return candidate; +} + +function normalizedPathPrefix(value: string | undefined): string { + const candidate = value?.trim(); + if (!candidate) return ""; + const prefixed = candidate.startsWith("/") ? candidate : `/${candidate}`; + if (!/^\/[A-Za-z0-9/_-]*$/.test(prefixed)) throw new Error("invalid_firewall_path_prefix"); + return prefixed.replace(/\/$/, ""); +} + +function cookieValue(header: string | null, name: string): string | undefined { + for (const item of header?.split(";") ?? []) { + const separator = item.indexOf("="); + if (separator < 0 || item.slice(0, separator).trim() !== name) continue; + const value = item.slice(separator + 1).trim(); + return value || undefined; + } + return undefined; +} diff --git a/web/services/iroh/routeHandler.ts b/web/services/iroh/routeHandler.ts index cbc4695721ed..facdd602ef64 100644 --- a/web/services/iroh/routeHandler.ts +++ b/web/services/iroh/routeHandler.ts @@ -1,4 +1,3 @@ -import { checkRateLimit } from "@vercel/firewall"; import { createHash } from "node:crypto"; import * as Effect from "effect/Effect"; import type * as Layer from "effect/Layer"; @@ -6,6 +5,11 @@ import { env } from "../../app/env"; import { unauthorized, verifyRequest, type AuthedUser } from "../vms/auth"; import { enforceBrowserMutationProtection, jsonResponse } from "../vms/routeHelpers"; import { irohExpectedError } from "./errors"; +import { + checkIrohVercelFirewall, + type IrohFirewallCheck, + type IrohFirewallCheckResult, +} from "./firewall"; import { IrohTrustBroker, IrohTrustBrokerRuntime, @@ -16,10 +20,8 @@ const MAX_BODY_BYTES = 64 * 1_024; const FIREWALL_TIMEOUT_MS = 2_500; const FIREWALL_MAX_IN_FLIGHT = 64; -type FirewallCheckResult = Awaited>; - export class IrohFirewallAdmission { - private readonly active = new Map>(); + private readonly active = new Map>(); constructor(private readonly maxInFlight: number) { if (!Number.isSafeInteger(maxInFlight) || maxInFlight <= 0) { @@ -31,7 +33,7 @@ export class IrohFirewallAdmission { return this.active.size; } - run(key: string, start: () => Promise): Promise { + run(key: string, start: () => Promise): Promise { if (this.active.has(key) || this.active.size >= this.maxInFlight) { throw new Error("firewall_admission_unavailable"); } @@ -62,7 +64,7 @@ type RouteDependencies = { readonly runtime?: Layer.Layer; readonly firewall?: { readonly id: string; - readonly check: typeof checkRateLimit; + readonly check: IrohFirewallCheck; readonly timeoutMs?: number; readonly admission?: IrohFirewallAdmission; }; @@ -84,21 +86,27 @@ export async function handleIrohRoute( const firewall = dependencies.firewall ?? ( process.env.VERCEL === "1" && env.CMUX_IROH_RATE_LIMIT_ID - ? { id: env.CMUX_IROH_RATE_LIMIT_ID, check: checkRateLimit } + ? { id: env.CMUX_IROH_RATE_LIMIT_ID, check: checkIrohVercelFirewall } : undefined ); if (firewall) { const rateLimitKey = createHash("sha256") .update(`iroh-rate:${user.id}:${operation}`) .digest("hex"); - let result: FirewallCheckResult; + const abortController = new AbortController(); + const timeout = setTimeout( + () => abortController.abort(new Error("firewall_timeout")), + firewall.timeoutMs ?? FIREWALL_TIMEOUT_MS, + ); + let result: IrohFirewallCheckResult; try { - result = await withTimeout( - (firewall.admission ?? firewallAdmission).run( - `${firewall.id}:${rateLimitKey}`, - () => firewall.check(firewall.id, { request, rateLimitKey }), - ), - firewall.timeoutMs ?? FIREWALL_TIMEOUT_MS, + result = await (firewall.admission ?? firewallAdmission).run( + `${firewall.id}:${rateLimitKey}`, + () => firewall.check(firewall.id, { + request, + rateLimitKey, + signal: abortController.signal, + }), ); } catch { console.error("iroh trust broker firewall unavailable", { @@ -106,6 +114,8 @@ export async function handleIrohRoute( failure: "request_failed_or_timed_out", }); return jsonResponse({ error: "iroh_service_unavailable" }, 503); + } finally { + clearTimeout(timeout); } const { error, rateLimited } = result; if (rateLimited || error === "blocked") { @@ -149,18 +159,6 @@ export async function handleIrohRoute( } } -async function withTimeout(work: Promise, timeoutMs: number): Promise { - let timer: ReturnType | undefined; - const deadline = new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error("firewall_timeout")), timeoutMs); - }); - try { - return await Promise.race([work, deadline]); - } finally { - if (timer) clearTimeout(timer); - } -} - function invoke( broker: IrohTrustBrokerShape, operation: IrohRouteOperation, diff --git a/web/tests/iroh-firewall.test.ts b/web/tests/iroh-firewall.test.ts new file mode 100644 index 000000000000..e319740a96d7 --- /dev/null +++ b/web/tests/iroh-firewall.test.ts @@ -0,0 +1,90 @@ +import { createHash } from "node:crypto"; +import { describe, expect, test } from "bun:test"; +import { createIrohVercelFirewallCheck } from "../services/iroh/firewall"; + +describe("Iroh Vercel firewall client", () => { + test("uses a trusted deployment host and preserves the firewall key contract", async () => { + let capturedUrl = ""; + let capturedInit: RequestInit | undefined; + const fetcher = (async (input: RequestInfo | URL, init?: RequestInit) => { + capturedUrl = String(input); + capturedInit = init; + return new Response(null, { status: 204 }); + }) as typeof fetch; + const check = createIrohVercelFirewallCheck({ + environment: { + VERCEL_URL: "cmux-preview.vercel.app", + VERCEL_PROJECT_PRODUCTION_URL: "cmux.com", + VERCEL_AUTOMATION_BYPASS_SECRET: "bypass", + RATE_LIMIT_SECRET: "rate-secret", + }, + fetch: fetcher, + }); + const signal = new AbortController().signal; + const result = await check("iroh-rule", { + request: new Request("https://attacker.example/api/devices/iroh", { + headers: { + host: "attacker.example", + authorization: "Bearer account-token", + cookie: "other=ignored; _vercel_jwt=preview-token", + "x-real-ip": "192.0.2.4", + }, + }), + rateLimitKey: "account-hash", + signal, + }); + + expect(result).toEqual({ rateLimited: false }); + expect(capturedUrl).toBe("https://cmux-preview.vercel.app/.well-known/vercel/rate-limit-api/iroh-rule"); + expect(capturedInit?.redirect).toBe("manual"); + expect(capturedInit?.signal).toBe(signal); + const headers = new Headers(capturedInit?.headers); + const digest = createHash("sha256") + .update("account-hashiroh-rulebypassrate-secret") + .digest("hex"); + expect(headers.get("x-vercel-rate-limit-key")).toBe(`account-hash-${digest}`); + expect(headers.get("x-vercel-protection-bypass")).toBe("bypass"); + expect(headers.get("cookie")).toBe("_vercel_jwt=preview-token"); + expect(headers.get("x-rr-authorization")).toBe("Bearer account-token"); + }); + + test("passes cancellation through to the network fetch", async () => { + let aborted = false; + const fetcher = ((_input: RequestInfo | URL, init?: RequestInit) => new Promise((_resolve, reject) => { + const signal = init?.signal; + if (!signal) return reject(new Error("missing signal")); + signal.addEventListener("abort", () => { + aborted = true; + reject(signal.reason); + }, { once: true }); + })) as typeof fetch; + const check = createIrohVercelFirewallCheck({ + environment: { VERCEL_URL: "cmux-preview.vercel.app" }, + fetch: fetcher, + }); + const controller = new AbortController(); + const pending = check("iroh-rule", { + request: new Request("https://cmux-preview.vercel.app/api/devices/iroh"), + rateLimitKey: "account-hash", + signal: controller.signal, + }); + + controller.abort(new Error("deadline")); + await expect(pending).rejects.toThrow("deadline"); + expect(aborted).toBe(true); + }); + + test("fails closed on unexpected firewall responses", async () => { + const fetcher = (async () => new Response(null, { status: 500 })) as typeof fetch; + const check = createIrohVercelFirewallCheck({ + environment: { VERCEL_URL: "cmux-preview.vercel.app" }, + fetch: fetcher, + }); + + await expect(check("iroh-rule", { + request: new Request("https://cmux-preview.vercel.app/api/devices/iroh"), + rateLimitKey: "account-hash", + signal: new AbortController().signal, + })).rejects.toThrow("unexpected_firewall_status"); + }); +}); diff --git a/web/tests/iroh-route-handler.test.ts b/web/tests/iroh-route-handler.test.ts index 2586ee18223b..f7af5ecb0a5b 100644 --- a/web/tests/iroh-route-handler.test.ts +++ b/web/tests/iroh-route-handler.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import * as Effect from "effect/Effect"; import { IrohDatabaseError, IrohQuotaExceededError } from "../services/iroh/errors"; +import type { IrohFirewallCheck } from "../services/iroh/firewall"; import { handleIrohRoute, IrohFirewallAdmission } from "../services/iroh/routeHandler"; import type { IrohTrustBrokerShape } from "../services/iroh/trustBroker"; import type { AuthedUser } from "../services/vms/auth"; @@ -66,6 +67,13 @@ describe("Iroh route boundary", () => { test("bounds a firewall check that never settles", async () => { let brokerCalled = false; + let aborted = false; + const check: IrohFirewallCheck = (_id, { signal }) => new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => { + aborted = true; + reject(signal.reason); + }, { once: true }); + }); const dependencies = { verify: async () => USER, broker: broker({ @@ -77,7 +85,7 @@ describe("Iroh route boundary", () => { firewall: { id: "iroh-test-rule", timeoutMs: 10, - check: () => new Promise(() => {}), + check, }, }; @@ -90,19 +98,27 @@ describe("Iroh route boundary", () => { expect(response.status).toBe(503); expect(await response.json()).toEqual({ error: "iroh_service_unavailable" }); expect(brokerCalled).toBe(false); + expect(aborted).toBe(true); }); test("caps timed-out firewall work per identity and across the worker", async () => { const admission = new IrohFirewallAdmission(2); let started = 0; + let aborted = 0; + const check: IrohFirewallCheck = (_id, { signal }) => { + started += 1; + return new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => { + aborted += 1; + reject(signal.reason); + }, { once: true }); + }); + }; const firewall = { id: "iroh-test-rule", timeoutMs: 5, admission, - check: () => { - started += 1; - return new Promise(() => {}); - }, + check, }; const discover = (userId: string) => handleIrohRoute( new Request("https://cmux.test/api/devices/iroh"), @@ -114,12 +130,16 @@ describe("Iroh route boundary", () => { }, ); - expect((await discover("user-1")).status).toBe(503); - expect((await discover("user-1")).status).toBe(503); - expect((await discover("user-2")).status).toBe(503); - expect((await discover("user-3")).status).toBe(503); + const responses = await Promise.all([ + discover("user-1"), + discover("user-1"), + discover("user-2"), + discover("user-3"), + ]); + expect(responses.map((response) => response.status)).toEqual([503, 503, 503, 503]); expect(started).toBe(2); - expect(admission.activeCount).toBe(2); + expect(aborted).toBe(2); + expect(admission.activeCount).toBe(0); }); test("aborts timed-out firewall work and admits recovery", async () => { From 881d85430aa0ea455233cfac86497607722cebc2 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Fri, 10 Jul 2026 14:23:00 -0700 Subject: [PATCH 27/37] test(iroh): require retry-safe binding revocation --- web/tests/iroh-db-behavior.test.ts | 17 ++++++++++++ web/tests/iroh-trust-broker.test.ts | 40 +++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/web/tests/iroh-db-behavior.test.ts b/web/tests/iroh-db-behavior.test.ts index c5d93798ab0e..8c2fad74596e 100644 --- a/web/tests/iroh-db-behavior.test.ts +++ b/web/tests/iroh-db-behavior.test.ts @@ -475,6 +475,23 @@ describe("Iroh trust broker database behavior", () => { expect(await Effect.runPromise(repo.revokeBinding({ userId, bindingId: firstBindingId, + now: new Date(NOW.getTime() + 60_000), + }))).toBe(true); + const [retriedBinding] = await requiredSql()>` + select revoked_at as "revokedAt" + from iroh_endpoint_bindings + where id = ${firstBindingId} + `; + expect(retriedBinding?.revokedAt).toEqual(NOW); + expect((await Effect.runPromise(repo.discoverySnapshot({ userId, now: NOW }))).lanDiscoveryGeneration).toBe(2); + expect(await Effect.runPromise(repo.revokeBinding({ + userId: "user-lan-other", + bindingId: firstBindingId, + now: NOW, + }))).toBe(false); + expect(await Effect.runPromise(repo.revokeBinding({ + userId, + bindingId: randomUUID(), now: NOW, }))).toBe(false); diff --git a/web/tests/iroh-trust-broker.test.ts b/web/tests/iroh-trust-broker.test.ts index 3730dad48a73..640db07380e7 100644 --- a/web/tests/iroh-trust-broker.test.ts +++ b/web/tests/iroh-trust-broker.test.ts @@ -142,6 +142,46 @@ describe("Iroh trust broker registration", () => { }); describe("Iroh discovery and grants", () => { + test("makes owned binding revocation retry-safe without rotating LAN state twice", async () => { + const fixture = makeFixture(); + const active = binding({ userId: USER_A }); + fixture.repository.bindings.push(active); + + const first = await Effect.runPromise(fixture.broker.revoke( + USER_A, + { bindingId: active.id }, + NOW, + )); + const firstRevokedAt = active.revokedAt; + expect(first).toEqual({ revoked: true, lan_rendezvous_rotated: true }); + expect(firstRevokedAt).toEqual(NOW); + const firstDiscovery = await Effect.runPromise(fixture.broker.discover(USER_A, NOW)) as { + lan_rendezvous: { generation: number }; + }; + expect(firstDiscovery.lan_rendezvous.generation).toBe(2); + + const retry = await Effect.runPromise(fixture.broker.revoke( + USER_A, + { bindingId: active.id }, + new Date(NOW.getTime() + 60_000), + )); + expect(retry).toEqual(first); + expect(active.revokedAt).toEqual(firstRevokedAt); + const retryDiscovery = await Effect.runPromise(fixture.broker.discover(USER_A, NOW)) as { + lan_rendezvous: { generation: number }; + }; + expect(retryDiscovery.lan_rendezvous.generation).toBe(2); + + await expectEffectFailure( + fixture.broker.revoke(USER_B, { bindingId: active.id }, NOW), + "IrohNotFoundError", + ); + await expectEffectFailure( + fixture.broker.revoke(USER_A, { bindingId: randomUUID() }, NOW), + "IrohNotFoundError", + ); + }); + test("never exposes another user through shared team context", async () => { const fixture = makeFixture(); await Effect.runPromise(fixture.broker.register(USER_A, await fixture.signedRegistration(), NOW)); From 20864b4b5d852e8e7d44efce8346b765bd0f6ecb Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Fri, 10 Jul 2026 14:25:39 -0700 Subject: [PATCH 28/37] fix(iroh): make binding revocation retry-safe --- web/services/iroh/repository.ts | 13 +++++++++++++ web/tests/iroh-trust-broker.test.ts | 3 ++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/web/services/iroh/repository.ts b/web/services/iroh/repository.ts index d84e0d51f184..f8b9664cee0f 100644 --- a/web/services/iroh/repository.ts +++ b/web/services/iroh/repository.ts @@ -105,6 +105,7 @@ export type IrohRepositoryShape = { userId: string, bindingIds: readonly string[], ) => Effect.Effect; + /** Returns true when the exact binding is owned and revoked, including retries. */ readonly revokeBinding: (input: { readonly userId: string; readonly bindingId: string; @@ -428,6 +429,18 @@ function makeLiveRepository(): IrohRepositoryShape { return await cloudDb().transaction(async (tx) => { await assertIrohUserMutationAllowed(tx, input.userId); await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`iroh:binding:${input.userId}`}, 0))`); + const [binding] = await tx + .select({ revokedAt: irohEndpointBindings.revokedAt }) + .from(irohEndpointBindings) + .where(and( + eq(irohEndpointBindings.id, input.bindingId), + eq(irohEndpointBindings.userId, input.userId), + )) + .for("update") + .limit(1); + if (!binding) return false; + if (binding.revokedAt) return true; + const revoked = await tx .update(irohEndpointBindings) .set({ diff --git a/web/tests/iroh-trust-broker.test.ts b/web/tests/iroh-trust-broker.test.ts index 640db07380e7..a4e5d4086139 100644 --- a/web/tests/iroh-trust-broker.test.ts +++ b/web/tests/iroh-trust-broker.test.ts @@ -590,8 +590,9 @@ class MemoryRepository implements IrohRepositoryShape { revokeBinding(input: Parameters[0]) { const row = this.bindings.find((candidate) => - candidate.id === input.bindingId && candidate.userId === input.userId && !candidate.revokedAt); + candidate.id === input.bindingId && candidate.userId === input.userId); if (!row) return Effect.succeed(false); + if (row.revokedAt) return Effect.succeed(true); row.revokedAt = input.now; row.revokedReason = "user_requested"; this.lanGenerations.set(input.userId, (this.lanGenerations.get(input.userId) ?? 1) + 1); From 47dc1a13801a44003a6cadade21e99ee4239343c Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Fri, 10 Jul 2026 15:38:36 -0700 Subject: [PATCH 29/37] test(iroh): require local-only HTTP minter opt-in --- web/tests/client-config-env.test.ts | 81 +++++++++++++++++++++++++++++ web/tests/iroh-model-crypto.test.ts | 26 ++++++++- 2 files changed, 105 insertions(+), 2 deletions(-) diff --git a/web/tests/client-config-env.test.ts b/web/tests/client-config-env.test.ts index e7d1a9087b45..7a582833b37f 100644 --- a/web/tests/client-config-env.test.ts +++ b/web/tests/client-config-env.test.ts @@ -83,6 +83,56 @@ describe("client config env validation", () => { expect(result.stderr).toContain("CMUX_IROH_GRANT_SIGNING_KEY_P8 is required"); expect(result.stderr).toContain("CMUX_IROH_MINT_HMAC_SECRET_B64 is required"); }); + + test("allows an explicitly opted-in loopback HTTP relay minter only in local development", () => { + const result = inspectIrohMinterUrl({ + ...requiredEnv, + NODE_ENV: "development", + CMUX_IROH_DEV_ALLOW_INSECURE_LOOPBACK_MINTER: "1", + CMUX_IROH_MINT_URL: "http://localhost:49152/api/relay-token", + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe("http://localhost:49152/api/relay-token"); + }); + + test("rejects a plaintext non-loopback relay minter in local development", () => { + const result = inspectIrohMinterUrl({ + ...requiredEnv, + NODE_ENV: "development", + CMUX_IROH_DEV_ALLOW_INSECURE_LOOPBACK_MINTER: "1", + CMUX_IROH_MINT_URL: "http://192.168.1.10:49152/api/relay-token", + }); + + expect(result.exitCode).not.toBe(0); + }); + + test("rejects the insecure loopback opt-in in Vercel preview and production", () => { + const preview = inspectIrohMinterUrl({ + ...requiredEnv, + NODE_ENV: "production", + VERCEL: "1", + VERCEL_ENV: "preview", + CMUX_IROH_DEV_ALLOW_INSECURE_LOOPBACK_MINTER: "1", + CMUX_IROH_MINT_URL: "http://localhost:49152/api/relay-token", + }); + expect(preview.exitCode).not.toBe(0); + + const production = inspectIrohMinterUrl({ + ...requiredEnv, + ...requiredIrohProductionEnv, + NODE_ENV: "production", + VERCEL: "1", + VERCEL_ENV: "production", + CMUX_CLIENT_CONFIG_RATE_LIMIT_ID: "client-config-rule", + CMUX_IROH_DEV_ALLOW_INSECURE_LOOPBACK_MINTER: "1", + CMUX_IROH_MINT_URL: "http://localhost:49152/api/relay-token", + }); + expect(production.exitCode).not.toBe(0); + expect(production.stderr).toContain( + "CMUX_IROH_DEV_ALLOW_INSECURE_LOOPBACK_MINTER is only allowed in local development", + ); + }); }); function importEnv(env: Record): { exitCode: number; stderr: string } { @@ -99,3 +149,34 @@ function importEnv(env: Record): { exitCode: number; stderr: str stderr: result.stderr, }; } + +function inspectIrohMinterUrl( + env: Record, +): { exitCode: number; stdout: string; stderr: string } { + const result = spawnSync( + process.execPath, + [ + "-e", + ` + const { irohTrustBrokerConfigFromEnv } = await import('./services/iroh/config'); + const { parseMinterUrl } = await import('./services/iroh/relayMinter'); + const config = irohTrustBrokerConfigFromEnv(); + const url = parseMinterUrl(config.relayMinterUrl, { + allowInsecureLoopback: config.relayMinterInsecureLoopbackOptIn, + deploymentEnvironment: config.deploymentEnvironment, + isVercelDeployment: config.isVercelDeployment, + }); + console.log(url.href); + `, + ], + { + env: env as NodeJS.ProcessEnv, + encoding: "utf8", + }, + ); + return { + exitCode: result.status ?? 1, + stdout: result.stdout.trim(), + stderr: result.stderr, + }; +} diff --git a/web/tests/iroh-model-crypto.test.ts b/web/tests/iroh-model-crypto.test.ts index 8e8f0bd9a056..126975607def 100644 --- a/web/tests/iroh-model-crypto.test.ts +++ b/web/tests/iroh-model-crypto.test.ts @@ -707,16 +707,38 @@ describe("Iroh relay minter response bounds", () => { expect(() => parseMinterHmacSecret(Buffer.alloc(32, 0xff).toString("base64url"))).toThrow(); }); - test("requires HTTPS and the exact isolated minter route", () => { + test("allows plaintext only for opted-in local loopback minters", () => { + const localDevelopment = { + allowInsecureLoopback: true, + deploymentEnvironment: "development", + isVercelDeployment: false, + }; expect(parseMinterUrl("https://minter.cmux.test/api/relay-token").pathname).toBe("/api/relay-token"); + for (const value of [ + "http://localhost:49152/api/relay-token", + "http://127.0.0.1:49152/api/relay-token", + "http://[::1]:49152/api/relay-token", + ]) { + expect(parseMinterUrl(value, localDevelopment).protocol).toBe("http:"); + } for (const value of [ "http://minter.cmux.test/api/relay-token", + "http://192.168.1.10:49152/api/relay-token", "https://minter.cmux.test/api/relay-token/", "https://minter.cmux.test/other", "https://minter.cmux.test/api/relay-token?debug=1", ]) { - expect(() => parseMinterUrl(value)).toThrow(); + expect(() => parseMinterUrl(value, localDevelopment)).toThrow(); } + expect(() => parseMinterUrl("http://localhost:49152/api/relay-token", { + ...localDevelopment, + deploymentEnvironment: "production", + })).toThrow(); + expect(() => parseMinterUrl("http://localhost:49152/api/relay-token", { + ...localDevelopment, + deploymentEnvironment: "preview", + isVercelDeployment: true, + })).toThrow(); }); test("parses a bounded response", async () => { From 7d0a2047feb0221411ac42a0e59f8dd5c38045f6 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Fri, 10 Jul 2026 15:39:37 -0700 Subject: [PATCH 30/37] feat(iroh): add loopback relay minter runner --- services/iroh-relay-minter/Cargo.lock | 1 + services/iroh-relay-minter/Cargo.toml | 2 + services/iroh-relay-minter/README.md | 11 ++++++ .../iroh-relay-minter/examples/loopback.rs | 37 +++++++++++++++++++ 4 files changed, 51 insertions(+) create mode 100644 services/iroh-relay-minter/examples/loopback.rs diff --git a/services/iroh-relay-minter/Cargo.lock b/services/iroh-relay-minter/Cargo.lock index 98e38c21ccc4..c31436a8045b 100644 --- a/services/iroh-relay-minter/Cargo.lock +++ b/services/iroh-relay-minter/Cargo.lock @@ -388,6 +388,7 @@ dependencies = [ "http-body", "http-body-util", "hyper", + "hyper-util", "iroh", "iroh-services", "rcan", diff --git a/services/iroh-relay-minter/Cargo.toml b/services/iroh-relay-minter/Cargo.toml index e9f043180385..f50a71e4290d 100644 --- a/services/iroh-relay-minter/Cargo.toml +++ b/services/iroh-relay-minter/Cargo.toml @@ -34,6 +34,8 @@ zeroize = "1.8.1" [dev-dependencies] futures-util = "0.3.31" +hyper-util = { version = "0.1.17", features = ["server", "http1", "tokio"] } +tokio = { version = "1.47.1", features = ["net"] } [profile.release] codegen-units = 1 diff --git a/services/iroh-relay-minter/README.md b/services/iroh-relay-minter/README.md index 9b5493a24139..9d9858cf0684 100644 --- a/services/iroh-relay-minter/README.md +++ b/services/iroh-relay-minter/README.md @@ -85,3 +85,14 @@ cargo fmt --check cargo clippy --all-targets --locked -- -D warnings cargo test --locked ``` + +For authenticated local dogfood, the example server binds only to loopback and +uses the same request handler as the Vercel function: + +```sh +CMUX_IROH_MINT_DEV_PORT=9460 cargo run --locked --example loopback +``` + +It still requires `IROH_SERVICES_API_SECRET` and +`CMUX_IROH_MINT_HMAC_SECRET_B64` in the process environment. Do not use a +credential copied through chat for a deployed environment; rotate it first. diff --git a/services/iroh-relay-minter/examples/loopback.rs b/services/iroh-relay-minter/examples/loopback.rs new file mode 100644 index 000000000000..2d501715eb9d --- /dev/null +++ b/services/iroh-relay-minter/examples/loopback.rs @@ -0,0 +1,37 @@ +use std::{convert::Infallible, env, sync::Arc, time::SystemTime}; + +use cmux_iroh_relay_minter::{MinterConfig, handle_request}; +use hyper::{Request, body::Incoming, server::conn::http1, service::service_fn}; +use hyper_util::rt::TokioIo; +use tokio::net::TcpListener; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let port = env::var("CMUX_IROH_MINT_DEV_PORT") + .ok() + .map(|value| value.parse::()) + .transpose()? + .unwrap_or(9460); + let listener = TcpListener::bind(("127.0.0.1", port)).await?; + let config = Arc::new(MinterConfig::from_env()?); + eprintln!("Iroh relay minter listening on http://127.0.0.1:{port}"); + + loop { + let (stream, peer) = listener.accept().await?; + if !peer.ip().is_loopback() { + continue; + } + let config = Arc::clone(&config); + tokio::spawn(async move { + let service = service_fn(move |request: Request| { + let config = Arc::clone(&config); + async move { + Ok::<_, Infallible>(handle_request(request, &config, SystemTime::now()).await) + } + }); + let _ = http1::Builder::new() + .serve_connection(TokioIo::new(stream), service) + .await; + }); + } +} From 6987fdd20b538530ae90afc31afbcbb6715e095c Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Fri, 10 Jul 2026 15:43:12 -0700 Subject: [PATCH 31/37] feat(iroh): gate local relay minter HTTP --- web/app/env.ts | 46 +++++++++++++++++++++++++++- web/services/iroh/config.ts | 5 +++ web/services/iroh/minterUrlPolicy.ts | 40 ++++++++++++++++++++++++ web/services/iroh/relayMinter.ts | 35 +++++++++++++-------- web/tests/client-config-env.test.ts | 3 +- web/tests/iroh-model-crypto.test.ts | 8 +++++ web/tests/iroh-trust-broker.test.ts | 4 +++ 7 files changed, 126 insertions(+), 15 deletions(-) create mode 100644 web/services/iroh/minterUrlPolicy.ts diff --git a/web/app/env.ts b/web/app/env.ts index 88e1b0184341..96f4ed4774c7 100644 --- a/web/app/env.ts +++ b/web/app/env.ts @@ -1,5 +1,10 @@ import { createEnv } from "@t3-oss/env-nextjs"; import { z } from "zod"; +import { + insecureLoopbackMinterAllowed, + parseIrohMinterUrl, + type IrohMinterUrlPolicy, +} from "../services/iroh/minterUrlPolicy"; // Trim at the runtimeEnv source so every consumer — including paths that // run when validation is skipped (VERCEL_ENV === "preview") — sees clean @@ -21,6 +26,13 @@ const isVercelNonPreviewDeployment = process.env.VERCEL === "1" && typeof process.env.VERCEL_ENV === "string" && process.env.VERCEL_ENV !== "preview"; +const irohMinterUrlPolicy: IrohMinterUrlPolicy = { + allowInsecureLoopback: + process.env.CMUX_IROH_DEV_ALLOW_INSECURE_LOOPBACK_MINTER === "1", + deploymentEnvironment: + process.env.VERCEL_ENV ?? process.env.NODE_ENV ?? "development", + isVercelDeployment: process.env.VERCEL === "1", +}; const requireVercelNonPreviewValue = ( name: string, schema: z.ZodString = z.string().min(1), @@ -33,6 +45,32 @@ const requireVercelNonPreviewValue = ( }); } }); +const localDevelopmentOptIn = (name: string) => + z.enum(["0", "1"]).optional().superRefine((value, context) => { + if ( + value === "1" && + !insecureLoopbackMinterAllowed({ + ...irohMinterUrlPolicy, + allowInsecureLoopback: true, + }) + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: `${name} is only allowed in local development`, + }); + } + }); +const irohMinterUrl = z.string().url().superRefine((value, context) => { + try { + parseIrohMinterUrl(value, irohMinterUrlPolicy); + } catch { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: + "CMUX_IROH_MINT_URL must use HTTPS, except for an opted-in local loopback development minter", + }); + } +}); const stackEnv = ( value: string | undefined, @@ -122,13 +160,16 @@ export const env = createEnv({ ), CMUX_IROH_MINT_URL: requireVercelNonPreviewValue( "CMUX_IROH_MINT_URL", - z.string().url(), + irohMinterUrl, ), CMUX_IROH_MINT_HMAC_SECRET_B64: requireVercelNonPreviewValue( "CMUX_IROH_MINT_HMAC_SECRET_B64", z.string().max(512).regex(/^[A-Za-z0-9+/]{43,}={0,2}$/), ), CMUX_IROH_RATE_LIMIT_ID: requireVercelNonPreviewValue("CMUX_IROH_RATE_LIMIT_ID"), + CMUX_IROH_DEV_ALLOW_INSECURE_LOOPBACK_MINTER: localDevelopmentOptIn( + "CMUX_IROH_DEV_ALLOW_INSECURE_LOOPBACK_MINTER", + ), CMUX_IROH_DEV_BINDING_OVERRIDE_ENABLED: z.enum(["0", "1"]).optional(), CMUX_IROH_DEV_BINDING_OVERRIDE_USER_IDS: z.string().max(8_192).optional(), CMUX_IROH_DEV_BINDING_OVERRIDE_ENVIRONMENTS: z.string().max(256).optional(), @@ -177,6 +218,9 @@ export const env = createEnv({ CMUX_IROH_MINT_URL: trimEnv(process.env.CMUX_IROH_MINT_URL), CMUX_IROH_MINT_HMAC_SECRET_B64: trimEnv(process.env.CMUX_IROH_MINT_HMAC_SECRET_B64), CMUX_IROH_RATE_LIMIT_ID: trimEnv(process.env.CMUX_IROH_RATE_LIMIT_ID), + CMUX_IROH_DEV_ALLOW_INSECURE_LOOPBACK_MINTER: trimEnv( + process.env.CMUX_IROH_DEV_ALLOW_INSECURE_LOOPBACK_MINTER, + ), CMUX_IROH_DEV_BINDING_OVERRIDE_ENABLED: trimEnv(process.env.CMUX_IROH_DEV_BINDING_OVERRIDE_ENABLED), CMUX_IROH_DEV_BINDING_OVERRIDE_USER_IDS: trimEnv(process.env.CMUX_IROH_DEV_BINDING_OVERRIDE_USER_IDS), CMUX_IROH_DEV_BINDING_OVERRIDE_ENVIRONMENTS: trimEnv(process.env.CMUX_IROH_DEV_BINDING_OVERRIDE_ENVIRONMENTS), diff --git a/web/services/iroh/config.ts b/web/services/iroh/config.ts index ec1bad861c98..f485effd6465 100644 --- a/web/services/iroh/config.ts +++ b/web/services/iroh/config.ts @@ -10,11 +10,13 @@ export type IrohTrustBrokerConfigShape = { readonly grantVerificationKeysJson?: string; readonly relayMinterUrl?: string; readonly relayMinterHmacSecretBase64?: string; + readonly relayMinterInsecureLoopbackOptIn: boolean; readonly rateLimitId?: string; readonly deviceLimitOverrideEnabled: boolean; readonly deviceLimitOverrideUserIds: ReadonlySet; readonly deviceLimitOverrideEnvironments: ReadonlySet; readonly deploymentEnvironment: string; + readonly isVercelDeployment: boolean; }; export class IrohTrustBrokerConfig extends Context.Tag("cmux/IrohTrustBrokerConfig")< @@ -31,11 +33,14 @@ export function irohTrustBrokerConfigFromEnv(): IrohTrustBrokerConfigShape { grantVerificationKeysJson: env.CMUX_IROH_GRANT_VERIFICATION_KEYS_JSON, relayMinterUrl: env.CMUX_IROH_MINT_URL, relayMinterHmacSecretBase64: env.CMUX_IROH_MINT_HMAC_SECRET_B64, + relayMinterInsecureLoopbackOptIn: + env.CMUX_IROH_DEV_ALLOW_INSECURE_LOOPBACK_MINTER === "1", rateLimitId: env.CMUX_IROH_RATE_LIMIT_ID, deviceLimitOverrideEnabled: env.CMUX_IROH_DEV_BINDING_OVERRIDE_ENABLED === "1", deviceLimitOverrideUserIds: csvSet(env.CMUX_IROH_DEV_BINDING_OVERRIDE_USER_IDS), deviceLimitOverrideEnvironments: csvSet(env.CMUX_IROH_DEV_BINDING_OVERRIDE_ENVIRONMENTS), deploymentEnvironment: process.env.VERCEL_ENV ?? process.env.NODE_ENV ?? "development", + isVercelDeployment: process.env.VERCEL === "1", }; } diff --git a/web/services/iroh/minterUrlPolicy.ts b/web/services/iroh/minterUrlPolicy.ts new file mode 100644 index 000000000000..7aac5faac229 --- /dev/null +++ b/web/services/iroh/minterUrlPolicy.ts @@ -0,0 +1,40 @@ +export const IROH_RELAY_MINTER_PATH = "/api/relay-token"; + +export type IrohMinterUrlPolicy = { + readonly allowInsecureLoopback: boolean; + readonly deploymentEnvironment: string; + readonly isVercelDeployment: boolean; +}; + +export function insecureLoopbackMinterAllowed(policy: IrohMinterUrlPolicy): boolean { + return policy.allowInsecureLoopback && + !policy.isVercelDeployment && + policy.deploymentEnvironment === "development"; +} + +export function parseIrohMinterUrl(value: string, policy: IrohMinterUrlPolicy): URL { + const url = new URL(value); + const secureTransport = url.protocol === "https:"; + const allowedDevelopmentTransport = + url.protocol === "http:" && + insecureLoopbackMinterAllowed(policy) && + isCanonicalLoopbackHost(url.hostname); + + if ( + (!secureTransport && !allowedDevelopmentTransport) || + url.username || + url.password || + url.pathname !== IROH_RELAY_MINTER_PATH || + url.search || + url.hash + ) { + throw new Error("invalid Iroh relay minter URL"); + } + return url; +} + +function isCanonicalLoopbackHost(hostname: string): boolean { + return hostname === "localhost" || + hostname === "127.0.0.1" || + hostname === "[::1]"; +} diff --git a/web/services/iroh/relayMinter.ts b/web/services/iroh/relayMinter.ts index aab46f57c4da..8bffeed91bf5 100644 --- a/web/services/iroh/relayMinter.ts +++ b/web/services/iroh/relayMinter.ts @@ -8,10 +8,15 @@ import { IrohRelayMintError, } from "./errors"; import { IrohTrustBrokerConfig } from "./config"; +import { + IROH_RELAY_MINTER_PATH, + parseIrohMinterUrl, + type IrohMinterUrlPolicy, +} from "./minterUrlPolicy"; import { IROH_RELAY_TOKEN_LIFETIME_SECONDS, endpointId } from "./model"; const MAX_MINTER_RESPONSE_BYTES = 32 * 1_024; -export const IROH_RELAY_MINTER_PATH = "/api/relay-token"; +export { IROH_RELAY_MINTER_PATH }; export type IrohRelayMintResult = { readonly token: string; @@ -54,7 +59,11 @@ function mintWithIsolatedService( return Effect.tryPromise({ try: async () => { endpointId(input.endpointId); - const url = parseMinterUrl(config.relayMinterUrl); + const url = parseMinterUrl(config.relayMinterUrl, { + allowInsecureLoopback: config.relayMinterInsecureLoopbackOptIn, + deploymentEnvironment: config.deploymentEnvironment, + isVercelDeployment: config.isVercelDeployment, + }); const secret = parseMinterHmacSecret(config.relayMinterHmacSecretBase64); const body = JSON.stringify({ endpointId: input.endpointId, @@ -162,20 +171,20 @@ export async function readBoundedMinterJson( return object; } -export function parseMinterUrl(value: string | undefined): URL { +export function parseMinterUrl( + value: string | undefined, + policy: IrohMinterUrlPolicy = { + allowInsecureLoopback: false, + deploymentEnvironment: "production", + isVercelDeployment: true, + }, +): URL { if (!value) throw new IrohConfigurationError({ component: "relay_minter" }); - const url = new URL(value); - if ( - url.protocol !== "https:" || - url.username || - url.password || - url.pathname !== IROH_RELAY_MINTER_PATH || - url.search || - url.hash - ) { + try { + return parseIrohMinterUrl(value, policy); + } catch { throw new IrohConfigurationError({ component: "relay_minter" }); } - return url; } export function parseMinterHmacSecret(value: string | undefined): Buffer { diff --git a/web/tests/client-config-env.test.ts b/web/tests/client-config-env.test.ts index 7a582833b37f..3f2e4c103d28 100644 --- a/web/tests/client-config-env.test.ts +++ b/web/tests/client-config-env.test.ts @@ -138,7 +138,7 @@ describe("client config env validation", () => { function importEnv(env: Record): { exitCode: number; stderr: string } { const result = spawnSync( process.execPath, - ["-e", "await import('./app/env')"], + ["--no-env-file", "-e", "await import('./app/env')"], { env: env as NodeJS.ProcessEnv, encoding: "utf8", @@ -156,6 +156,7 @@ function inspectIrohMinterUrl( const result = spawnSync( process.execPath, [ + "--no-env-file", "-e", ` const { irohTrustBrokerConfigFromEnv } = await import('./services/iroh/config'); diff --git a/web/tests/iroh-model-crypto.test.ts b/web/tests/iroh-model-crypto.test.ts index 126975607def..ad3d6fb96da1 100644 --- a/web/tests/iroh-model-crypto.test.ts +++ b/web/tests/iroh-model-crypto.test.ts @@ -617,10 +617,12 @@ describe("Iroh relay minter response bounds", () => { const config: IrohTrustBrokerConfigShape = { relayMinterUrl: "https://minter.cmux.test/api/relay-token", relayMinterHmacSecretBase64: secret.toString("base64"), + relayMinterInsecureLoopbackOptIn: false, deviceLimitOverrideEnabled: false, deviceLimitOverrideUserIds: new Set(), deviceLimitOverrideEnvironments: new Set(), deploymentEnvironment: "test", + isVercelDeployment: false, }; const layer = IrohRelayMinterLive.pipe( Layer.provide(Layer.succeed(IrohTrustBrokerConfig, config)), @@ -659,10 +661,12 @@ describe("Iroh relay minter response bounds", () => { const config: IrohTrustBrokerConfigShape = { relayMinterUrl: "https://minter.cmux.test/api/relay-token", relayMinterHmacSecretBase64: Buffer.alloc(32, 0x63).toString("base64"), + relayMinterInsecureLoopbackOptIn: false, deviceLimitOverrideEnabled: false, deviceLimitOverrideUserIds: new Set(), deviceLimitOverrideEnvironments: new Set(), deploymentEnvironment: "test", + isVercelDeployment: false, }; const layer = IrohRelayMinterLive.pipe( Layer.provide(Layer.succeed(IrohTrustBrokerConfig, config)), @@ -730,6 +734,10 @@ describe("Iroh relay minter response bounds", () => { ]) { expect(() => parseMinterUrl(value, localDevelopment)).toThrow(); } + expect(() => parseMinterUrl("http://localhost:49152/api/relay-token", { + ...localDevelopment, + allowInsecureLoopback: false, + })).toThrow(); expect(() => parseMinterUrl("http://localhost:49152/api/relay-token", { ...localDevelopment, deploymentEnvironment: "production", diff --git a/web/tests/iroh-trust-broker.test.ts b/web/tests/iroh-trust-broker.test.ts index a4e5d4086139..713102644351 100644 --- a/web/tests/iroh-trust-broker.test.ts +++ b/web/tests/iroh-trust-broker.test.ts @@ -467,10 +467,12 @@ describe("Iroh relay quotas", () => { describe("developer binding override", () => { const base: IrohTrustBrokerConfigShape = { + relayMinterInsecureLoopbackOptIn: false, deviceLimitOverrideEnabled: true, deviceLimitOverrideUserIds: new Set([USER_A]), deviceLimitOverrideEnvironments: new Set(["preview"]), deploymentEnvironment: "preview", + isVercelDeployment: true, }; test("requires both an explicit authenticated user and explicit environment", () => { @@ -766,9 +768,11 @@ function makeFixture(options: { ], }), deviceLimitOverrideEnabled: false, + relayMinterInsecureLoopbackOptIn: false, deviceLimitOverrideUserIds: new Set(), deviceLimitOverrideEnvironments: new Set(), deploymentEnvironment: "test", + isVercelDeployment: false, }; const broker = makeIrohTrustBroker(repository, minter, config); From 4ec2c57aa81c6bf170cb27614d97229d5cd3c1c1 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Fri, 10 Jul 2026 15:47:45 -0700 Subject: [PATCH 32/37] fix(iroh): normalize local minter opt-in --- web/app/env.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/app/env.ts b/web/app/env.ts index 96f4ed4774c7..6508f44b311b 100644 --- a/web/app/env.ts +++ b/web/app/env.ts @@ -28,7 +28,7 @@ const isVercelNonPreviewDeployment = process.env.VERCEL_ENV !== "preview"; const irohMinterUrlPolicy: IrohMinterUrlPolicy = { allowInsecureLoopback: - process.env.CMUX_IROH_DEV_ALLOW_INSECURE_LOOPBACK_MINTER === "1", + trimEnv(process.env.CMUX_IROH_DEV_ALLOW_INSECURE_LOOPBACK_MINTER) === "1", deploymentEnvironment: process.env.VERCEL_ENV ?? process.env.NODE_ENV ?? "development", isVercelDeployment: process.env.VERCEL === "1", From ecad50011ff9aaf691373556b7be22e1af9dd156 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 12 Jul 2026 14:32:45 -0700 Subject: [PATCH 33/37] iroh: point the broker relay fleet at the 7 self-hosted relay.cmux.dev URLs Replaces the 4 hosted iroh.link relays with our self-hosted fleet in both allowlists (web MANAGED_RELAY_URLS + presence worker APPROVED_IROH_RELAY_URLS, kept in lockstep) and the tests that referenced hosted URLs. The self-hosted relays run iroh-relay 1.0.2 behind per-region MIG+L4-LB (zero-downtime upgrades), gated by the cmux EdDSA JWT that /api/relay/token (merged, #7879) mints. --- web/services/iroh/publicationPolicy.ts | 11 +++++++---- web/tests/devices-route.test.ts | 2 +- workers/presence/src/routePrivacy.ts | 11 +++++++---- workers/presence/test/core.test.ts | 4 ++-- workers/presence/test/syncPairedMacs.test.ts | 8 ++++---- workers/presence/test/syncStorage.test.ts | 4 ++-- workers/presence/test/validate.test.ts | 2 +- 7 files changed, 24 insertions(+), 18 deletions(-) diff --git a/web/services/iroh/publicationPolicy.ts b/web/services/iroh/publicationPolicy.ts index 4a9a1761997a..89e757264134 100644 --- a/web/services/iroh/publicationPolicy.ts +++ b/web/services/iroh/publicationPolicy.ts @@ -8,10 +8,13 @@ */ export const MANAGED_RELAY_URLS = [ - "https://euc1-1.relay.lawrence.cmux.iroh.link/", - "https://use1-1.relay.lawrence.cmux.iroh.link/", - "https://usw1-1.relay.lawrence.cmux.iroh.link/", - "https://aps1-1.relay.lawrence.cmux.iroh.link/", + "https://usc1.relay.cmux.dev/", + "https://usw1.relay.cmux.dev/", + "https://use4.relay.cmux.dev/", + "https://euw4.relay.cmux.dev/", + "https://apne1.relay.cmux.dev/", + "https://apse1.relay.cmux.dev/", + "https://ape1.relay.cmux.dev/", ] as const; const MANAGED_RELAY_URL_SET: ReadonlySet = new Set(MANAGED_RELAY_URLS); diff --git a/web/tests/devices-route.test.ts b/web/tests/devices-route.test.ts index 71c22b4f60da..2fe5253e5b98 100644 --- a/web/tests/devices-route.test.ts +++ b/web/tests/devices-route.test.ts @@ -35,7 +35,7 @@ let sql: Sql | null = null; const DEVICE_A = "11111111-1111-4111-8111-111111111111"; const DEVICE_B = "22222222-2222-4222-8222-222222222222"; const IROH_ENDPOINT_ID = "a".repeat(64); -const APPROVED_IROH_RELAY = "https://use1-1.relay.lawrence.cmux.iroh.link/"; +const APPROVED_IROH_RELAY = "https://use4.relay.cmux.dev/"; const legacyTailscaleRoute = { id: "legacy", diff --git a/workers/presence/src/routePrivacy.ts b/workers/presence/src/routePrivacy.ts index 208c4079f53e..2b41aaa7663f 100644 --- a/workers/presence/src/routePrivacy.ts +++ b/workers/presence/src/routePrivacy.ts @@ -2,10 +2,13 @@ // The Worker cannot import from `web/` because each deployment has its own // build root. Exact URLs are intentional: arbitrary relay URLs are path hints. export const APPROVED_IROH_RELAY_URLS = [ - "https://euc1-1.relay.lawrence.cmux.iroh.link/", - "https://use1-1.relay.lawrence.cmux.iroh.link/", - "https://usw1-1.relay.lawrence.cmux.iroh.link/", - "https://aps1-1.relay.lawrence.cmux.iroh.link/", + "https://usc1.relay.cmux.dev/", + "https://usw1.relay.cmux.dev/", + "https://use4.relay.cmux.dev/", + "https://euw4.relay.cmux.dev/", + "https://apne1.relay.cmux.dev/", + "https://apse1.relay.cmux.dev/", + "https://ape1.relay.cmux.dev/", ] as const; const APPROVED_IROH_RELAY_URL_SET: ReadonlySet = new Set(APPROVED_IROH_RELAY_URLS); diff --git a/workers/presence/test/core.test.ts b/workers/presence/test/core.test.ts index b268925b6030..1c5cfec61d70 100644 --- a/workers/presence/test/core.test.ts +++ b/workers/presence/test/core.test.ts @@ -332,7 +332,7 @@ describe("heartbeat routes", () => { endpoint: { type: "peer", id: "a".repeat(64), - relay_url: "https://use1-1.relay.lawrence.cmux.iroh.link/", + relay_url: "https://use4.relay.cmux.dev/", relay_hint: "legacy-private-relay-hint", direct_addrs: ["192.168.1.20:49152"], }, @@ -344,7 +344,7 @@ describe("heartbeat routes", () => { endpoint: { type: "peer", id: "a".repeat(64), - relay_url: "https://use1-1.relay.lawrence.cmux.iroh.link/", + relay_url: "https://use4.relay.cmux.dev/", }, }; diff --git a/workers/presence/test/syncPairedMacs.test.ts b/workers/presence/test/syncPairedMacs.test.ts index 1f110ea49f29..754574055ad2 100644 --- a/workers/presence/test/syncPairedMacs.test.ts +++ b/workers/presence/test/syncPairedMacs.test.ts @@ -134,7 +134,7 @@ describe("parsePairedMacBackup", () => { id: "a".repeat(64), direct_addrs: ["192.168.1.20:49152"], relay_hint: "legacy-private-relay-hint", - relay_url: "https://use1-1.relay.lawrence.cmux.iroh.link/", + relay_url: "https://use4.relay.cmux.dev/", }, }, ], @@ -153,7 +153,7 @@ describe("parsePairedMacBackup", () => { endpoint: { type: "peer", id: "a".repeat(64), - relay_url: "https://use1-1.relay.lawrence.cmux.iroh.link/", + relay_url: "https://use4.relay.cmux.dev/", }, }, ]); @@ -175,7 +175,7 @@ describe("applyBackupOps", () => { id: "a".repeat(64), direct_addrs: ["192.168.1.20:49152"], relay_hint: "legacy-private-relay-hint", - relay_url: "https://use1-1.relay.lawrence.cmux.iroh.link/", + relay_url: "https://use4.relay.cmux.dev/", }, }, ], @@ -211,7 +211,7 @@ describe("applyBackupOps", () => { endpoint: { type: "peer", id: "a".repeat(64), - relay_url: "https://use1-1.relay.lawrence.cmux.iroh.link/", + relay_url: "https://use4.relay.cmux.dev/", }, }, ]); diff --git a/workers/presence/test/syncStorage.test.ts b/workers/presence/test/syncStorage.test.ts index 7e3428f34bf8..00cdba349697 100644 --- a/workers/presence/test/syncStorage.test.ts +++ b/workers/presence/test/syncStorage.test.ts @@ -598,7 +598,7 @@ describe("deriveDeviceRecord edge cases", () => { id: "a".repeat(64), direct_addrs: ["192.168.1.20:49152"], relay_hint: "legacy-private-relay-hint", - relay_url: "https://use1-1.relay.lawrence.cmux.iroh.link/", + relay_url: "https://use4.relay.cmux.dev/", }, }, ], @@ -614,7 +614,7 @@ describe("deriveDeviceRecord edge cases", () => { endpoint: { type: "peer", id: "a".repeat(64), - relay_url: "https://use1-1.relay.lawrence.cmux.iroh.link/", + relay_url: "https://use4.relay.cmux.dev/", }, }, ]); diff --git a/workers/presence/test/validate.test.ts b/workers/presence/test/validate.test.ts index 305bf7b1a9ec..ad9ffe4d544f 100644 --- a/workers/presence/test/validate.test.ts +++ b/workers/presence/test/validate.test.ts @@ -3,7 +3,7 @@ import { MAX_REQUEST_BYTES, MAX_ROUTES, parseHeartbeat, readBoundedJson } from " const DEVICE_ID = "11111111-2222-4333-8444-555555555555"; const IROH_ENDPOINT_ID = "a".repeat(64); -const APPROVED_IROH_RELAY = "https://use1-1.relay.lawrence.cmux.iroh.link/"; +const APPROVED_IROH_RELAY = "https://use4.relay.cmux.dev/"; function postRequest( body: string | ReadableStream, From 2bcacee5bbfc091f1f08b9bebed7bbdfef8905cd Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Sun, 12 Jul 2026 22:26:32 -0700 Subject: [PATCH 34/37] test(iroh): allow self-hosted broker without legacy minter --- web/tests/client-config-env.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/web/tests/client-config-env.test.ts b/web/tests/client-config-env.test.ts index 3f2e4c103d28..d3f06f246d79 100644 --- a/web/tests/client-config-env.test.ts +++ b/web/tests/client-config-env.test.ts @@ -58,6 +58,24 @@ describe("client config env validation", () => { expect(result.exitCode).toBe(0); }); + test("accepts the self-hosted relay path without the legacy hosted minter", () => { + const result = importEnv({ + ...requiredEnv, + VERCEL: "1", + VERCEL_ENV: "production", + CMUX_CLIENT_CONFIG_RATE_LIMIT_ID: "client-config-rule", + CMUX_IROH_LAN_DISCOVERY_SECRET_B64: requiredIrohProductionEnv.CMUX_IROH_LAN_DISCOVERY_SECRET_B64, + CMUX_IROH_ACCOUNT_SUBJECT_SECRET_B64: requiredIrohProductionEnv.CMUX_IROH_ACCOUNT_SUBJECT_SECRET_B64, + CMUX_IROH_GRANT_SIGNING_KEY_P8: requiredIrohProductionEnv.CMUX_IROH_GRANT_SIGNING_KEY_P8, + CMUX_IROH_GRANT_SIGNING_KID: requiredIrohProductionEnv.CMUX_IROH_GRANT_SIGNING_KID, + CMUX_IROH_GRANT_VERIFICATION_KEYS_JSON: + requiredIrohProductionEnv.CMUX_IROH_GRANT_VERIFICATION_KEYS_JSON, + CMUX_IROH_RATE_LIMIT_ID: requiredIrohProductionEnv.CMUX_IROH_RATE_LIMIT_ID, + }); + + expect(result.exitCode).toBe(0); + }); + test("requires the Iroh limiter id in explicit Vercel production deployments", () => { const result = importEnv({ ...requiredEnv, From 695de0cc1f100245d1ac3629a9a4fcb0bd2369e6 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Sun, 12 Jul 2026 22:27:07 -0700 Subject: [PATCH 35/37] fix(iroh): make hosted relay minter optional --- web/app/env.ts | 13 +++++-------- web/services/iroh/README.md | 6 ++++++ web/tests/client-config-env.test.ts | 2 +- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/web/app/env.ts b/web/app/env.ts index 6508f44b311b..927d4ed6a506 100644 --- a/web/app/env.ts +++ b/web/app/env.ts @@ -158,14 +158,11 @@ export const env = createEnv({ "CMUX_IROH_GRANT_VERIFICATION_KEYS_JSON", z.string().min(2).max(32_768), ), - CMUX_IROH_MINT_URL: requireVercelNonPreviewValue( - "CMUX_IROH_MINT_URL", - irohMinterUrl, - ), - CMUX_IROH_MINT_HMAC_SECRET_B64: requireVercelNonPreviewValue( - "CMUX_IROH_MINT_HMAC_SECRET_B64", - z.string().max(512).regex(/^[A-Za-z0-9+/]{43,}={0,2}$/), - ), + // Optional compatibility path for n0-hosted relay credentials. The + // self-hosted fleet mints endpoint-bound JWTs through /api/relay/token. + CMUX_IROH_MINT_URL: irohMinterUrl.optional(), + CMUX_IROH_MINT_HMAC_SECRET_B64: + z.string().max(512).regex(/^[A-Za-z0-9+/]{43,}={0,2}$/).optional(), CMUX_IROH_RATE_LIMIT_ID: requireVercelNonPreviewValue("CMUX_IROH_RATE_LIMIT_ID"), CMUX_IROH_DEV_ALLOW_INSECURE_LOOPBACK_MINTER: localDevelopmentOptIn( "CMUX_IROH_DEV_ALLOW_INSECURE_LOOPBACK_MINTER", diff --git a/web/services/iroh/README.md b/web/services/iroh/README.md index 784fc527e2c7..44050af7a18b 100644 --- a/web/services/iroh/README.md +++ b/web/services/iroh/README.md @@ -51,6 +51,12 @@ clients retain their existing credential or use the dedicated relay-token route when its refresh window arrives. Platform is part of the immutable binding identity and requires explicit revocation before it can change. +The n0-hosted relay minter is an optional compatibility path. When +`CMUX_IROH_MINT_URL` and `CMUX_IROH_MINT_HMAC_SECRET_B64` are absent, initial +registration returns `relay.status = "unavailable"` without rolling back the +binding. Current clients obtain endpoint-bound credentials for the self-hosted +fleet from `/api/relay/token`. + Every user-scoped mutation acquires the account-deletion advisory fence before any Iroh lock. If the deletion tombstone wins, no challenge, binding, grant, or relay audit state can be created. If an Iroh mutation wins, account deletion diff --git a/web/tests/client-config-env.test.ts b/web/tests/client-config-env.test.ts index d3f06f246d79..8c6c08fed1fd 100644 --- a/web/tests/client-config-env.test.ts +++ b/web/tests/client-config-env.test.ts @@ -99,7 +99,7 @@ describe("client config env validation", () => { expect(result.exitCode).not.toBe(0); expect(result.stderr).toContain("CMUX_IROH_GRANT_SIGNING_KEY_P8 is required"); - expect(result.stderr).toContain("CMUX_IROH_MINT_HMAC_SECRET_B64 is required"); + expect(result.stderr).not.toContain("CMUX_IROH_MINT_HMAC_SECRET_B64 is required"); }); test("allows an explicitly opted-in loopback HTTP relay minter only in local development", () => { From 13a30e31b735b7c0b407fda899a4678e0e0c63ea Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Sun, 12 Jul 2026 22:57:01 -0700 Subject: [PATCH 36/37] test(iroh): cover public firewall host fallback --- web/tests/iroh-firewall.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/tests/iroh-firewall.test.ts b/web/tests/iroh-firewall.test.ts index e319740a96d7..013feebebcef 100644 --- a/web/tests/iroh-firewall.test.ts +++ b/web/tests/iroh-firewall.test.ts @@ -35,7 +35,7 @@ describe("Iroh Vercel firewall client", () => { }); expect(result).toEqual({ rateLimited: false }); - expect(capturedUrl).toBe("https://cmux-preview.vercel.app/.well-known/vercel/rate-limit-api/iroh-rule"); + expect(capturedUrl).toBe("https://cmux.com/.well-known/vercel/rate-limit-api/iroh-rule"); expect(capturedInit?.redirect).toBe("manual"); expect(capturedInit?.signal).toBe(signal); const headers = new Headers(capturedInit?.headers); From ac4decb85be546f3e655f3ccc743daf3d84660b2 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Sun, 12 Jul 2026 22:57:16 -0700 Subject: [PATCH 37/37] fix(iroh): use public host for firewall checks --- web/services/iroh/firewall.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/web/services/iroh/firewall.ts b/web/services/iroh/firewall.ts index 288469fce663..1c0bbc146eaa 100644 --- a/web/services/iroh/firewall.ts +++ b/web/services/iroh/firewall.ts @@ -96,7 +96,11 @@ function trustedFirewallHost(request: Request, environment: IrohFirewallEnvironm if (requestedHost && (requestedHost === deploymentHost || requestedHost === productionHost)) { return requestedHost; } - return deploymentHost ?? productionHost!; + // A deployment URL can be protected by Vercel Authentication even when the + // public project URL is intentionally reachable. Both values come from + // Vercel, so prefer the stable project URL when the inbound host is not one + // of the trusted values instead of making the rate-limit check hit SSO. + return productionHost ?? deploymentHost!; } function normalizedHost(value: string | null | undefined): string | undefined {