Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions web/db/migrations/20260719120000_iroh_direct_ports/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
ALTER TABLE "iroh_endpoint_bindings"
ADD COLUMN "direct_port_v4" integer;
--> statement-breakpoint
ALTER TABLE "iroh_endpoint_bindings"
ADD COLUMN "direct_port_v6" integer;
--> statement-breakpoint
ALTER TABLE "iroh_endpoint_bindings"
ADD CONSTRAINT "iroh_endpoint_bindings_direct_port_v4_check"
CHECK ("direct_port_v4" IS NULL OR "direct_port_v4" BETWEEN 1 AND 65535);
--> statement-breakpoint
ALTER TABLE "iroh_endpoint_bindings"
ADD CONSTRAINT "iroh_endpoint_bindings_direct_port_v6_check"
CHECK ("direct_port_v6" IS NULL OR "direct_port_v6" BETWEEN 1 AND 65535);
4 changes: 4 additions & 0 deletions web/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -723,6 +723,8 @@ export const irohEndpointBindings = pgTable(
identityGeneration: integer("identity_generation").notNull(),
pairingEnabled: boolean("pairing_enabled").notNull().default(false),
capabilities: jsonb("capabilities").$type<string[]>().notNull().default(sql`'[]'::jsonb`),
directPortV4: integer("direct_port_v4"),
directPortV6: integer("direct_port_v6"),
pathHints: jsonb("path_hints").$type<unknown[]>().notNull().default(sql`'[]'::jsonb`),
pathHintsNextExpiry: timestamp("path_hints_next_expiry", { withTimezone: true }),
deviceLimitOverrideUsed: boolean("device_limit_override_used").notNull().default(false),
Expand All @@ -739,6 +741,8 @@ export const irohEndpointBindings = pgTable(
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_direct_port_v4_check", sql`${table.directPortV4} is null or ${table.directPortV4} between 1 and 65535`),
check("iroh_endpoint_bindings_direct_port_v6_check", sql`${table.directPortV6} is null or ${table.directPortV6} between 1 and 65535`),
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)
Expand Down
7 changes: 7 additions & 0 deletions web/services/iroh/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ 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.

A signed registration may publish `directPorts` with independent IPv4 and IPv6
UDP ports. The broker stores only those bounded ports, never a private address,
and returns them as `direct_ports` only inside the authenticated same-account
binding catalog. Clients combine a family-matching port with locally known
addresses after EndpointID authentication. Legacy registrations omit the field
and clear any previously published ports on their next signed refresh.

`relay_fleet` is the server-configured connection preset/allowlist. It is not a
peer address. Each peer's published `relay_url` comes from its signed
`watch_addr` payload and must match that allowlist. Discovery defensively
Expand Down
34 changes: 34 additions & 0 deletions web/services/iroh/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ export type IrohPathHint = {
};
};

export type IrohDirectPorts = {
readonly ipv4?: number;
readonly ipv6?: number;
};

export type IrohRegistrationPayload = {
readonly route_contract_version: typeof IROH_ROUTE_CONTRACT_VERSION;
readonly deviceId: string;
Expand All @@ -43,6 +48,7 @@ export type IrohRegistrationPayload = {
readonly identityGeneration: number;
readonly pairingEnabled: boolean;
readonly capabilities: readonly string[];
readonly directPorts?: IrohDirectPorts;
readonly pathHints: readonly IrohPathHint[];
};

Expand Down Expand Up @@ -157,6 +163,9 @@ export function parseRegistrationPayload(value: unknown, now: Date): IrohRegistr
identityGeneration: positiveInteger(body.identityGeneration, "invalid_identity_generation"),
pairingEnabled: boolean(body.pairingEnabled, "invalid_pairing_enabled"),
capabilities,
...(body.directPorts === undefined
? {}
: { directPorts: parseIrohDirectPorts(body.directPorts) }),
pathHints,
};
rejectUnknownKeys(body, [
Expand All @@ -170,11 +179,29 @@ export function parseRegistrationPayload(value: unknown, now: Date): IrohRegistr
"identityGeneration",
"pairingEnabled",
"capabilities",
"directPorts",
"pathHints",
]);
return payload;
}

export function parseIrohDirectPorts(value: unknown): IrohDirectPorts {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new IrohInvalidInputError({ code: "invalid_direct_ports" });
}
const ports = value as Record<string, unknown>;
rejectUnknownKeys(ports, ["ipv4", "ipv6"]);
const ipv4 = ports.ipv4 === undefined ? undefined : udpPort(ports.ipv4);
const ipv6 = ports.ipv6 === undefined ? undefined : udpPort(ports.ipv6);
if (ipv4 === undefined && ipv6 === undefined) {
throw new IrohInvalidInputError({ code: "invalid_direct_ports" });
}
return {
...(ipv4 === undefined ? {} : { ipv4 }),
...(ipv6 === undefined ? {} : { ipv6 }),
};
}

export function parseBindingIdBody(value: unknown): { readonly bindingId: string } {
const body = record(value);
const result = { bindingId: uuid(body.bindingId, "invalid_binding_id") };
Expand Down Expand Up @@ -547,6 +574,13 @@ function positiveInteger(value: unknown, code: string): number {
return value as number;
}

function udpPort(value: unknown): number {
if (!Number.isInteger(value) || (value as number) < 1 || (value as number) > 65_535) {
throw new IrohInvalidInputError({ code: "invalid_direct_ports" });
}
return value as number;
}
Comment on lines +577 to +582

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Every other numeric validator in this file (positiveInteger) uses Number.isSafeInteger. While the upper bound > 65_535 makes Number.isInteger functionally equivalent here (no unsafe integer can be ≤ 65535), the inconsistency could mislead future maintainers into using Number.isInteger for a larger bounded range where unsafe integers could slip through.

Suggested change
function udpPort(value: unknown): number {
if (!Number.isInteger(value) || (value as number) < 1 || (value as number) > 65_535) {
throw new IrohInvalidInputError({ code: "invalid_direct_ports" });
}
return value as number;
}
function udpPort(value: unknown): number {
if (!Number.isSafeInteger(value) || (value as number) < 1 || (value as number) > 65_535) {
throw new IrohInvalidInputError({ code: "invalid_direct_ports" });
}
return value as number;
}

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


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 });
Expand Down
14 changes: 13 additions & 1 deletion web/services/iroh/repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,8 @@ function makeLiveRepository(): IrohRepositoryShape {
displayName: input.payload.displayName ?? null,
pairingEnabled: input.payload.pairingEnabled,
capabilities: [...input.payload.capabilities],
directPortV4: input.payload.directPorts?.ipv4 ?? null,
directPortV6: input.payload.directPorts?.ipv6 ?? null,
pathHints: accountPrivatePathHints,
pathHintsNextExpiry: nextPathHintExpiry(accountPrivatePathHints),
lastSeenAt: input.now,
Expand Down Expand Up @@ -401,6 +403,8 @@ function makeLiveRepository(): IrohRepositoryShape {
identityGeneration: input.payload.identityGeneration,
pairingEnabled: input.payload.pairingEnabled,
capabilities: [...input.payload.capabilities],
directPortV4: input.payload.directPorts?.ipv4 ?? null,
directPortV6: input.payload.directPorts?.ipv6 ?? null,
pathHints: accountPrivatePathHints,
pathHintsNextExpiry: nextPathHintExpiry(accountPrivatePathHints),
deviceLimitOverrideUsed: usesDeviceOverride,
Expand Down Expand Up @@ -933,6 +937,8 @@ async function revokeActiveBindings(
.set({
revokedAt: input.now,
revokedReason: input.reason,
directPortV4: null,
directPortV6: null,
pathHints: [],
pathHintsNextExpiry: null,
updatedAt: input.now,
Expand Down Expand Up @@ -1008,14 +1014,20 @@ async function drainIrohRetention(input: {
select id
from iroh_endpoint_bindings
where revoked_at is not null
and path_hints_next_expiry is not null
and (
path_hints_next_expiry is not null
or direct_port_v4 is not null
or direct_port_v6 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,
direct_port_v4 = null,
direct_port_v6 = null,
updated_at = ${nowIso}::timestamptz
from candidates
where binding.id = candidates.id
Expand Down
8 changes: 8 additions & 0 deletions web/services/iroh/trustBroker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,14 @@ function publicBinding(
identity_generation: binding.identityGeneration,
pairing_enabled: binding.pairingEnabled,
capabilities: binding.capabilities,
...(binding.directPortV4 === null && binding.directPortV6 === null
? {}
: {
direct_ports: {
...(binding.directPortV4 === null ? {} : { ipv4: binding.directPortV4 }),
...(binding.directPortV6 === null ? {} : { ipv6: binding.directPortV6 }),
},
}),
path_hints: accountPrivateIrohPathHints(binding.pathHints.flatMap((hint): IrohPathHint[] => {
try {
return [parseIrohPathHint(hint, now)];
Expand Down
181 changes: 181 additions & 0 deletions web/tests/iroh-db-behavior.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,97 @@ describe("Iroh trust broker database behavior", () => {
expect(stored?.nextExpiry).toEqual(directExpiry);
});

dbTest("persists, updates, and clears family-specific direct ports", async () => {
const repo = requiredRepository();
const userId = "user-direct-ports";
const deviceId = randomUUID();
const appInstanceId = randomUUID();
const endpointId = "4f".repeat(32);
type DirectPorts = { readonly ipv4?: number; readonly ipv6?: number };

const register = async (
directPorts: DirectPorts | undefined,
sequence: number,
): Promise<void> => {
const now = new Date(NOW.getTime() + sequence * 1_000);
const nonceHash = sequence.toString(16).padStart(64, "0");
const challenge = await Effect.runPromise(repo.issueChallenge({
userId,
deviceUuid: deviceId,
appInstanceId,
tag: "stable",
endpointId,
identityGeneration: 1,
payloadSha256: (sequence + 10).toString(16).padStart(64, "0"),
nonceHash,
now,
expiresAt: new Date(now.getTime() + 5 * 60 * 1_000),
}));
const payload: Parameters<
IrohRepositoryShape["consumeChallengeAndRegister"]
>[0]["payload"] & { readonly directPorts?: DirectPorts } = {
route_contract_version: 1,
deviceId,
appInstanceId,
tag: "stable",
platform: "mac",
endpointId,
identityGeneration: 1,
pairingEnabled: true,
capabilities: [],
...(directPorts ? { directPorts } : {}),
pathHints: [],
};
await Effect.runPromise(repo.consumeChallengeAndRegister({
userId,
challengeId: challenge.id,
nonceHash,
payload,
now,
bindingQuota: { account: 32, device: 8, baselineDevice: 8, staleAfterMs: null },
}));
};

await register({ ipv4: 49_152, ipv6: 49_153 }, 1);
let [stored] = await requiredSql()<Array<{
directPortV4: number | null;
directPortV6: number | null;
}>>`
select
direct_port_v4 as "directPortV4",
direct_port_v6 as "directPortV6"
from iroh_endpoint_bindings
where app_instance_id = ${appInstanceId}
`;
expect(stored).toEqual({ directPortV4: 49_152, directPortV6: 49_153 });

await register({ ipv6: 50_000 }, 2);
[stored] = await requiredSql()<Array<{
directPortV4: number | null;
directPortV6: number | null;
}>>`
select
direct_port_v4 as "directPortV4",
direct_port_v6 as "directPortV6"
from iroh_endpoint_bindings
where app_instance_id = ${appInstanceId}
`;
expect(stored).toEqual({ directPortV4: null, directPortV6: 50_000 });

await register(undefined, 3);
[stored] = await requiredSql()<Array<{
directPortV4: number | null;
directPortV6: number | null;
}>>`
select
direct_port_v4 as "directPortV4",
direct_port_v6 as "directPortV6"
from iroh_endpoint_bindings
where app_instance_id = ${appInstanceId}
`;
expect(stored).toEqual({ directPortV4: null, directPortV6: null });
});

dbTest("requires revocation before changing an active binding platform", async () => {
const repo = requiredRepository();
const userId = "user-platform-change";
Expand Down Expand Up @@ -785,6 +876,73 @@ describe("Iroh trust broker database behavior", () => {
`, "22003");
});

dbTest("enforces the UDP port range for each direct-address family", async () => {
const bindingId = await insertBinding({
userId: "user-direct-port-checks",
endpointId: "4e".repeat(32),
});
await expectPostgresError(requiredSql()`
update iroh_endpoint_bindings set direct_port_v4 = 0 where id = ${bindingId}
`, "23514");
await expectPostgresError(requiredSql()`
update iroh_endpoint_bindings set direct_port_v4 = 65536 where id = ${bindingId}
`, "23514");
await expectPostgresError(requiredSql()`
update iroh_endpoint_bindings set direct_port_v6 = 0 where id = ${bindingId}
`, "23514");
await expectPostgresError(requiredSql()`
update iroh_endpoint_bindings set direct_port_v6 = 65536 where id = ${bindingId}
`, "23514");

await requiredSql()`
update iroh_endpoint_bindings
set direct_port_v4 = 1, direct_port_v6 = 65535
where id = ${bindingId}
`;
const [stored] = await requiredSql()<Array<{
directPortV4: number | null;
directPortV6: number | null;
}>>`
select
direct_port_v4 as "directPortV4",
direct_port_v6 as "directPortV6"
from iroh_endpoint_bindings
where id = ${bindingId}
`;
expect(stored).toEqual({ directPortV4: 1, directPortV6: 65_535 });
});

dbTest("scrubs direct ports when a binding is revoked", async () => {
const repo = requiredRepository();
const bindingId = await insertBinding({
userId: "user-revoked-direct-ports",
endpointId: "4d".repeat(32),
});
await requiredSql()`
update iroh_endpoint_bindings
set direct_port_v4 = 49_152, direct_port_v6 = 49_153
where id = ${bindingId}
`;

expect(await Effect.runPromise(repo.revokeBinding({
userId: "user-revoked-direct-ports",
bindingId,
now: NOW,
}))).toBe(true);

const [stored] = await requiredSql()<Array<{
directPortV4: number | null;
directPortV6: number | null;
}>>`
select
direct_port_v4 as "directPortV4",
direct_port_v6 as "directPortV6"
from iroh_endpoint_bindings
where id = ${bindingId}
`;
expect(stored).toEqual({ directPortV4: null, directPortV6: null });
});

dbTest("keeps LAN discovery account-scoped and coherent across binding revocation", async () => {
const repo = requiredRepository();
const userId = "user-lan-revoke";
Expand Down Expand Up @@ -1186,11 +1344,23 @@ describe("Iroh trust broker database behavior", () => {
userId: "user-retention",
endpointId: "83".repeat(32),
});
const legacyRevokedId = await insertBinding({
userId: "user-retention",
endpointId: "84".repeat(32),
});
await requiredSql()`
update iroh_endpoint_bindings
set revoked_at = ${new Date(NOW.getTime() - 31 * 24 * 60 * 60 * 1_000)}
where id = ${oldRevokedId}
`;
await requiredSql()`
update iroh_endpoint_bindings
set
revoked_at = ${NOW},
direct_port_v4 = 49_152,
direct_port_v6 = 49_153
where id = ${legacyRevokedId}
`;
const [untouchedBefore] = await requiredSql()<Array<{ updatedAt: Date }>>`
select updated_at as "updatedAt" from iroh_endpoint_bindings where id = ${untouchedId}
`;
Expand All @@ -1213,6 +1383,17 @@ describe("Iroh trust broker database behavior", () => {
`;
expect(rows.find((row) => row.id === activeId)?.pathHints).toHaveLength(1);
expect(rows.find((row) => row.id === revokedId)?.pathHints).toEqual([]);
const [legacyRevoked] = await requiredSql()<Array<{
directPortV4: number | null;
directPortV6: number | null;
}>>`
select
direct_port_v4 as "directPortV4",
direct_port_v6 as "directPortV6"
from iroh_endpoint_bindings
where id = ${legacyRevokedId}
`;
expect(legacyRevoked).toEqual({ directPortV4: null, directPortV6: null });
const [grant] = await requiredSql()<Array<{ revokedAt: Date | null }>>`
select revoked_at as "revokedAt" from iroh_pair_grant_issuances where acceptor_binding_id = ${revokedId}
`;
Expand Down
Loading