Repository navigation
web: relay token minting endpoint (POST /api/relay/token) #7879
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
c8fb208
5b95538
dc281be
da3779a
e1d2e55
14e93b5
907488f
399e124
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,145 @@ | ||
| // Mint a short-lived relay access token for the private cmux iroh relay fleet. | ||
| // | ||
| // A signed-in cmux endpoint POSTs here and gets a short-TTL EdDSA JWT plus the | ||
| // fleet RelayMap in one round-trip; it presents the JWT as the iroh relay auth | ||
| // token. The relay verifies it offline against the baked public key. If the | ||
| // signing key is not provisioned this returns 503, so it is safe to ship before | ||
| // the secret is set. Token-minting logic lives in services/relay/token.ts. | ||
| // | ||
| // Auth: native-only (Stack Bearer + X-Stack-Refresh-Token, no browser cookie), | ||
| // since the minted token is exported to the native client — same posture as | ||
| // /api/devices. The request handler takes its auth/key/clock dependencies as a | ||
| // parameter so route behavior is unit-testable without leaking module mocks. | ||
|
|
||
| import type { KeyObject } from "node:crypto"; | ||
|
|
||
| import { checkRateLimit } from "@vercel/firewall"; | ||
|
|
||
| import { | ||
| unauthorized, | ||
| verifyRequest, | ||
| type AuthedUser, | ||
| } from "../../../../services/vms/auth"; | ||
| import { jsonResponse } from "../../../../services/vms/routeHelpers"; | ||
| import { readBoundedJsonObject } from "../../../../services/apns/routePolicy"; | ||
| import { | ||
| RELAY_TOKEN_TTL_SECONDS, | ||
| isValidEndpointId, | ||
| mintRelayToken, | ||
| relaySigningKey, | ||
| relayUrls, | ||
| } from "../../../../services/relay/token"; | ||
|
|
||
| export const runtime = "nodejs"; | ||
| export const dynamic = "force-dynamic"; | ||
|
|
||
| const MAX_BODY_BYTES = 4 * 1024; | ||
|
|
||
| type RateLimitCheck = ( | ||
| id: string, | ||
| options: { request: Request; rateLimitKey?: string }, | ||
| ) => Promise<{ rateLimited: boolean; error?: string }>; | ||
|
|
||
| export interface RelayTokenDeps { | ||
| verifyRequest: (request: Request) => Promise<AuthedUser | null>; | ||
| signingKey: () => KeyObject | null; | ||
| nowSeconds: () => number; | ||
| checkRateLimit: RateLimitCheck; | ||
| } | ||
|
|
||
| const productionDeps: RelayTokenDeps = { | ||
| verifyRequest: (request) => verifyRequest(request, { allowCookie: false }), | ||
| signingKey: relaySigningKey, | ||
| nowSeconds: () => Math.floor(Date.now() / 1000), | ||
| checkRateLimit, | ||
| }; | ||
|
|
||
| export async function handleRelayTokenRequest( | ||
| request: Request, | ||
| deps: RelayTokenDeps, | ||
| ): Promise<Response> { | ||
| const user = await deps.verifyRequest(request); | ||
| if (!user) return unauthorized(); | ||
|
|
||
| // Per-account issuance rate limit (Vercel firewall rule keyed by user id). | ||
| // Bounds how fast one account can mint tokens / register endpoint keys. On | ||
| // Vercel this is MANDATORY and FAILS CLOSED: a missing/not-found/errored rule | ||
| // returns 503 rather than silently dropping the only abuse control on a | ||
| // security-sensitive minting endpoint (mirrors /api/client-config). Local dev | ||
| // (no VERCEL env) bypasses it. The complementary per-relay connection cap | ||
| // lives in the relay itself (separate repo). | ||
| if (process.env.VERCEL === "1") { | ||
| const rateLimitId = process.env.CMUX_RELAY_TOKEN_RATE_LIMIT_ID?.trim(); | ||
| if (!rateLimitId) { | ||
| console.error("relay-token.route.rate_limit_not_configured"); | ||
| return jsonResponse({ error: "relay_token_unavailable" }, 503); | ||
| } | ||
| let result: { rateLimited: boolean; error?: string }; | ||
| try { | ||
| // @vercel/firewall exposes no abort signal, so a wrapper cannot cancel the | ||
| // underlying fetch; adding one only abandons an in-flight request. Follow | ||
| // the repo convention (client-config / waitlist / feedback) of a plain | ||
| // awaited call — the serverless platform bounds request duration. We DO | ||
| // fail closed on a rejection (network failure / unexpected status), which | ||
| // @vercel/firewall surfaces by rejecting rather than via `error`, so a | ||
| // limiter outage returns a controlled 503 instead of an uncaught 500 that | ||
| // would bypass the issuance bound. | ||
| result = await deps.checkRateLimit(rateLimitId, { | ||
| request, | ||
| rateLimitKey: user.id, | ||
| }); | ||
| } catch (err) { | ||
| console.error("relay-token.route.rate_limit_threw", err); | ||
| return jsonResponse({ error: "relay_token_unavailable" }, 503); | ||
| } | ||
| const { error, rateLimited } = result; | ||
| if (rateLimited || error === "blocked") { | ||
| return jsonResponse({ error: "rate_limited" }, 429); | ||
| } | ||
| if (error === "not-found") { | ||
| console.error("relay-token.route.rate_limit_not_found", rateLimitId); | ||
| return jsonResponse({ error: "relay_token_unavailable" }, 503); | ||
| } else if (error) { | ||
| console.error("relay-token.route.rate_limit_error", error); | ||
| return jsonResponse({ error: "relay_token_unavailable" }, 503); | ||
| } | ||
| } | ||
|
cursor[bot] marked this conversation as resolved.
|
||
|
|
||
| const key = deps.signingKey(); | ||
| if (!key) { | ||
| // The private signing key is not provisioned in this environment. | ||
| return jsonResponse({ error: "relay_token_not_configured" }, 503); | ||
| } | ||
|
|
||
| // Streams and cancels at MAX_BODY_BYTES, treats an empty body as {}, and | ||
| // rejects non-object JSON (null / arrays / primitives). | ||
| const body = await readBoundedJsonObject(request, MAX_BODY_BYTES); | ||
| if (!body.ok) { | ||
| const status = body.error === "request_too_large" ? 413 : 400; | ||
| return jsonResponse({ error: body.error }, status); | ||
| } | ||
|
|
||
| // endpoint_id is REQUIRED: every token is bound to the caller's iroh endpoint | ||
| // key so a leaked token cannot be replayed from a different generated key. | ||
| const rawEndpointId = body.value.endpointId; | ||
| if (typeof rawEndpointId !== "string" || !isValidEndpointId(rawEndpointId)) { | ||
| return jsonResponse({ error: "invalid_endpoint_id" }, 400); | ||
| } | ||
|
|
||
| const { token, expiresAt } = mintRelayToken({ | ||
| sub: user.id, | ||
| endpointId: rawEndpointId, | ||
| key, | ||
| nowSeconds: deps.nowSeconds(), | ||
| }); | ||
| return jsonResponse({ | ||
| token, | ||
| expiresAt, | ||
| ttlSeconds: RELAY_TOKEN_TTL_SECONDS, | ||
| relays: relayUrls(), | ||
| }); | ||
| } | ||
|
|
||
| export function POST(request: Request): Promise<Response> { | ||
| return handleRelayTokenRequest(request, productionDeps); | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,124 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Pure token-minting logic for the private cmux iroh relay fleet, kept separate | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // from the HTTP route so it is testable without the auth/DB/telemetry graph. | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // The web API is the token issuer: it holds the Ed25519 PRIVATE signing key | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // (`CMUX_RELAY_JWT_PRIVATE_KEY_PEM`); every relay VM holds only the matching | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // PUBLIC key and verifies tokens offline. A minted token is a short-TTL EdDSA | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // JWT with `iss=cmux`, `aud=cmux-relay`, `sub=<user>`, and an optional | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // `endpoint_id` binding (so a leaked token cannot be replayed from another key). | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { createPrivateKey, sign as edSign, type KeyObject } from "node:crypto"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| export const RELAY_TOKEN_ISS = "cmux"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| export const RELAY_TOKEN_AUD = "cmux-relay"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| export const RELAY_TOKEN_TTL_SECONDS = 300; // short-lived; the client refreshes | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| // iroh EndpointId is a 32-byte Ed25519 public key. The cmux relay parses the | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // JWT claim with `EndpointId::from_str`, which (in iroh-base 1.0.0-rc.1) accepts | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // EXACTLY 64-char lowercase hex OR 52-char RFC 4648 base32 (A-Z2-7, | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // case-insensitive; `to_string()` emits hex). z-base-32 is a SEPARATE from_z32 | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // API the relay does not use, so it must NOT be accepted here. Anything the | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // parser rejects would be a signed-but-useless 200, so fail fast with 400. | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // (We lowercase before matching; hex is minted lowercase to satisfy HEXLOWER, | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // and the relay uppercases base32 internally.) | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const HEX_ENDPOINT_ID_RE = /^[0-9a-f]{64}$/; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // A 52-char RFC 4648 base32 encoding of exactly 32 bytes has 4 trailing zero | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // bits, so the final symbol carries 1 data bit + 4 zero bits and can only be | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // `a` (0) or `q` (16). Other final symbols have non-zero trailing bits, which | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // iroh's BASE32_NOPAD decoder rejects — so require the canonical final symbol. | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const BASE32_ENDPOINT_ID_RE = /^[a-z2-7]{51}[aq]$/; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| // The relay fleet the client should probe (nearest wins). Overridable via env | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // (comma-separated) so the RelayMap can change without a code deploy. | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const DEFAULT_RELAYS = [ | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| "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", | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| ]; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| export function relayUrls(): string[] { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const raw = process.env.CMUX_RELAY_URLS; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (raw && raw.trim()) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const urls = raw | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| .split(",") | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| .map((s) => s.trim()) | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| .filter(Boolean); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (urls.length > 0) return urls; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| return DEFAULT_RELAYS; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Note: this checks the exact encoding shape, not that the 32 bytes decode to a | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // valid Ed25519 curve point (e.g. 64 `f`s pass here but are not on the curve). | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Full on-curve validation is intentionally left to the relay, which is the | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // authoritative validator at connect time: the endpoint_id is the CALLER'S OWN | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // iroh public key, so a legitimate client always sends a valid point, and a | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // crafted-but-invalid id only yields a token bound to a key nobody holds (the | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // relay requires the token's endpoint_id to equal the handshake-authenticated | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // key), i.e. self-harm with no replay or availability impact. Adding a curve | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // library here to reject a self-defeating input is not worth the dependency. | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| export function isValidEndpointId(value: string): boolean { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const v = value.toLowerCase(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| return HEX_ENDPOINT_ID_RE.test(v) || BASE32_ENDPOINT_ID_RE.test(v); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Parse the signing key once and cache it keyed on the PEM value, so | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // `createPrivateKey` (not free) runs only when the configured key changes. | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| let cached: { pem: string; key: KeyObject } | null = null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| export function relaySigningKey(): KeyObject | null { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const pem = process.env.CMUX_RELAY_JWT_PRIVATE_KEY_PEM; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!pem || !pem.includes("BEGIN")) return null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (cached && cached.pem === pem) return cached.key; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const key = createPrivateKey(pem); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // The fleet's baked public key is Ed25519; a misconfigured RSA/EC/Ed448 key | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // would sign a token no relay can verify. Treat it as unconfigured (-> 503) | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| // rather than minting an unusable token or throwing at sign time. | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (key.asymmetricKeyType !== "ed25519") return null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| cached = { pem, key }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| return key; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| return null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. PEM newline escapes ignoredMedium Severity
Reviewed by Cursor Bugbot for commit 5b95538. Configure here. |
||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+73
to
+88
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Log parse errors in The 🛠️ Proposed fix try {
const key = createPrivateKey(pem);
cached = { pem, key };
return key;
- } catch {
+ } catch (error) {
+ console.error("[relay] Failed to parse signing key:", error);
return null;
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| function b64url(input: Buffer | string): string { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| return Buffer.from(input).toString("base64url"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Mint a compact EdDSA (Ed25519) JWT. Ed25519 signs the raw message (no prehash), | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| * so the digest passed to `sign` is `null`. The output is byte-for-byte what | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| * `jsonwebtoken`/`jose` produce and what the relay's verifier accepts. | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| * `endpointId` is REQUIRED: every issued token is bound to the caller's iroh | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| * endpoint key so a leaked token cannot be replayed from a different key. | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| export function mintRelayToken(params: { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| sub: string; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| endpointId: string; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| key: KeyObject; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| nowSeconds: number; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| }): { token: string; expiresAt: number } { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const { sub, endpointId, key, nowSeconds } = params; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const expiresAt = nowSeconds + RELAY_TOKEN_TTL_SECONDS; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const header = { alg: "EdDSA", typ: "JWT" }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const payload: Record<string, unknown> = { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| iss: RELAY_TOKEN_ISS, | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| aud: RELAY_TOKEN_AUD, | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| sub, | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| iat: nowSeconds, | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| exp: expiresAt, | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| endpoint_id: endpointId.toLowerCase(), | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const signingInput = `${b64url(JSON.stringify(header))}.${b64url( | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| JSON.stringify(payload), | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| )}`; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| const signature = edSign(null, Buffer.from(signingInput), key); | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| return { token: `${signingInput}.${b64url(signature)}`, expiresAt }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||


Uh oh!
There was an error while loading. Please reload this page.