From f9e3889b50e36f346fd11fe16240a095f6f00447 Mon Sep 17 00:00:00 2001 From: Mamavee001 <307202201+Mamavee001@users.noreply.github.com> Date: Sun, 30 Aug 2026 16:49:21 +0100 Subject: [PATCH] feat(web): give CLI-link challenges their own domain, separate from sign-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both web sign-in (apps/web/lib/sep10.ts) and CLI linking would otherwise verify a SEP-10 challenge transaction using the same home domain and web_auth_domain. Without separation, a challenge signed to sign in to the website is also valid proof for attaching a deploy wallet, and a signature captured from either context becomes universally useful against the other. Add apps/web/lib/cli-link.ts: buildCliLinkChallenge/verifyCliLinkChallenge, built on the same @stellar/stellar-sdk WebAuth primitives sep10.ts uses, but with a distinct home domain (cli.). SEP-10's own domain check in WebAuth.readChallengeTx is what makes the two purposes reject each other automatically — no separate bookkeeping needed. Also binds the requested Stellar network into the challenge via assertNetworkMatches, rejecting (naming both networks) a request whose network doesn't match this deployment's configured one, before a challenge is ever built. Exposed at /api/cli-link (GET challenge, POST verify), mirroring the existing /api/auth/sep10 route's shape and rate limiting. This does not yet attach a verified wallet to a profile: doing that safely requires proving the caller is also authorized to modify the target handle's profile, which proof-of-possession of a deploy key alone does not establish. That's a separate mechanism, deliberately left for a follow-up rather than shipping a half-built authorization check. Tests cover the round trip, wrong-signer rejection, a web sign-in challenge rejected as link proof and vice versa (#269's acceptance), and network-match/mismatch (#263's acceptance). --- apps/web/app/api/cli-link/route.ts | 122 +++++++++++++++++++++++++++++ apps/web/lib/cli-link.test.ts | 107 +++++++++++++++++++++++++ apps/web/lib/cli-link.ts | 114 +++++++++++++++++++++++++++ apps/web/lib/rate-limit-http.ts | 2 + 4 files changed, 345 insertions(+) create mode 100644 apps/web/app/api/cli-link/route.ts create mode 100644 apps/web/lib/cli-link.test.ts create mode 100644 apps/web/lib/cli-link.ts diff --git a/apps/web/app/api/cli-link/route.ts b/apps/web/app/api/cli-link/route.ts new file mode 100644 index 0000000..82a1f09 --- /dev/null +++ b/apps/web/app/api/cli-link/route.ts @@ -0,0 +1,122 @@ +import { NextResponse } from 'next/server'; +import { WebAuth } from '@stellar/stellar-sdk'; +import { + buildCliLinkChallenge, + verifyCliLinkChallenge, + getConfiguredNetwork, + CliLinkError, + CliLinkConfigError, +} from '@/lib/cli-link'; +import { getNetworkPassphrase, Sep10Error } from '@/lib/sep10'; +import { isValidStellarAddress } from '@/lib/stellar-address'; +import { LIMITS, enforceRateLimit } from '@/lib/rate-limit-http'; +import { logger } from '@/lib/logger'; + +export const runtime = 'nodejs'; + +/** + * `signet link`'s challenge/verify endpoint — the CLI's own SEP-10-shaped + * exchange, kept on a separate path from `/api/auth/sep10` (web sign-in) so + * the two purposes never share a challenge shape. See `lib/cli-link.ts`. + * + * This verifies that the caller controls the deploy wallet's private key and + * that its declared network matches this deployment's configured one. It + * does *not* attach the wallet to a profile: doing that safely requires + * proving the CALLER is also authorized to modify the target handle's + * profile (proof of possessing a deploy key alone isn't authorization to + * attach it to someone else's handle) — a separate mechanism this endpoint + * intentionally leaves for a follow-up rather than shipping a half-built + * authorization check. + */ +const CORS_HEADERS = { 'Access-Control-Allow-Origin': '*' }; + +function withCors(res: NextResponse): NextResponse { + for (const [key, value] of Object.entries(CORS_HEADERS)) res.headers.set(key, value); + return res; +} + +export async function GET(req: Request) { + // Same reasoning as sep10: unauthenticated, cross-origin (a CLI has no + // browser origin at all), and signs a transaction on every call. + const limited = await enforceRateLimit(req, 'cli-link:challenge', LIMITS.cliLink); + if (limited) return withCors(limited); + + const { searchParams } = new URL(req.url); + const account = searchParams.get('account'); + const network = searchParams.get('network'); + + if (!account || !isValidStellarAddress(account)) { + return NextResponse.json( + { error: 'account is required and must be a valid Stellar address' }, + { status: 400, headers: CORS_HEADERS }, + ); + } + if (!network) { + return NextResponse.json( + { error: 'network is required (e.g. "testnet" or "mainnet")' }, + { status: 400, headers: CORS_HEADERS }, + ); + } + + try { + const transaction = buildCliLinkChallenge(account, network); + return NextResponse.json( + { transaction, network_passphrase: getNetworkPassphrase() }, + { headers: { ...CORS_HEADERS, 'cache-control': 'no-store' } }, + ); + } catch (err) { + if (err instanceof CliLinkConfigError) { + logger.error({ err: err.message }, 'cliLink.misconfigured'); + return NextResponse.json({ error: err.message }, { status: 503, headers: CORS_HEADERS }); + } + if (err instanceof CliLinkError) { + // A network mismatch names both networks — the caller needs both to + // fix a --network flag or point at the right deployment. + logger.warn( + { requested: network, configured: getConfiguredNetwork(), error: err.message }, + 'cliLink.networkMismatch', + ); + return NextResponse.json({ error: err.message }, { status: 400, headers: CORS_HEADERS }); + } + return NextResponse.json({ error: 'Could not build challenge' }, { status: 400, headers: CORS_HEADERS }); + } +} + +export async function POST(req: Request) { + const limited = await enforceRateLimit(req, 'cli-link:verify', LIMITS.cliLink); + if (limited) return withCors(limited); + + const { transaction } = (await req.json().catch(() => ({}))) as { transaction?: string }; + if (!transaction) { + return NextResponse.json({ error: 'transaction is required' }, { status: 400, headers: CORS_HEADERS }); + } + + let clientAccountId: string; + try { + clientAccountId = verifyCliLinkChallenge(transaction); + } catch (err) { + if (err instanceof CliLinkConfigError) { + logger.error({ err: err.message }, 'cliLink.misconfigured'); + return NextResponse.json({ error: err.message }, { status: 503, headers: CORS_HEADERS }); + } + const message = + err instanceof Sep10Error || err instanceof WebAuth.InvalidChallengeError + ? err.message + : 'Invalid challenge transaction'; + logger.warn({ error: message }, 'cliLink.verifyRejected'); + return NextResponse.json({ error: message }, { status: 401, headers: CORS_HEADERS }); + } + + logger.info({ address: clientAccountId }, 'cliLink.verified'); + return NextResponse.json({ verified: true, publicKey: clientAccountId }, { headers: CORS_HEADERS }); +} + +export function OPTIONS() { + return new NextResponse(null, { + headers: { + ...CORS_HEADERS, + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + }, + }); +} diff --git a/apps/web/lib/cli-link.test.ts b/apps/web/lib/cli-link.test.ts new file mode 100644 index 0000000..bf645b9 --- /dev/null +++ b/apps/web/lib/cli-link.test.ts @@ -0,0 +1,107 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { Keypair, TransactionBuilder, WebAuth } from '@stellar/stellar-sdk'; +import { + buildCliLinkChallenge, + verifyCliLinkChallenge, + assertNetworkMatches, + getConfiguredNetwork, + getCliLinkDomain, + CliLinkError, +} from './cli-link.ts'; +import { buildChallenge, verifyChallenge, getServerKeypair, getNetworkPassphrase } from './sep10.ts'; + +// `getServerKeypair()` caches on first call, so this must be set before any +// test invokes it (directly or indirectly via the build/verify functions). +process.env.SEP10_SIGNING_SECRET = Keypair.random().secret(); +process.env.NEXT_PUBLIC_ROOT_DOMAIN = 'signet.dev'; +process.env.NEXT_PUBLIC_STELLAR_NETWORK = 'testnet'; + +function sign(challengeXdr: string, client: Keypair): string { + const tx = TransactionBuilder.fromXDR(challengeXdr, getNetworkPassphrase()); + tx.sign(client); + return tx.toEnvelope().toXDR('base64'); +} + +test('getCliLinkDomain differs from the web sign-in domain', () => { + assert.notEqual(getCliLinkDomain(), 'signet.dev'); + assert.ok(getCliLinkDomain().includes('signet.dev')); +}); + +test('buildCliLinkChallenge + client signature round-trips through verifyCliLinkChallenge', () => { + const client = Keypair.random(); + const challenge = buildCliLinkChallenge(client.publicKey(), 'testnet'); + const signed = sign(challenge, client); + + assert.equal(verifyCliLinkChallenge(signed), client.publicKey()); +}); + +test('rejects a challenge with no client signature', () => { + const client = Keypair.random(); + const challenge = buildCliLinkChallenge(client.publicKey(), 'testnet'); + assert.throws(() => verifyCliLinkChallenge(challenge)); +}); + +test('rejects a challenge signed by the wrong keypair', () => { + const client = Keypair.random(); + const impostor = Keypair.random(); + const challenge = buildCliLinkChallenge(client.publicKey(), 'testnet'); + const signed = sign(challenge, impostor); + + assert.throws(() => verifyCliLinkChallenge(signed)); +}); + +// ─── Domain separation from web sign-in (#269) ────────────────────────────── + +test('a web sign-in challenge is rejected as CLI-link proof', () => { + const client = Keypair.random(); + const signInChallenge = buildChallenge(client.publicKey()); + const signed = sign(signInChallenge, client); + + assert.throws( + () => verifyCliLinkChallenge(signed), + /home domain|InvalidChallenge/i, + ); +}); + +test('a CLI-link challenge is rejected as a sign-in proof', () => { + const client = Keypair.random(); + const linkChallenge = buildCliLinkChallenge(client.publicKey(), 'testnet'); + const signed = sign(linkChallenge, client); + + assert.throws( + () => verifyChallenge(signed), + /home domain|InvalidChallenge/i, + ); +}); + +test('a signed sign-in challenge still verifies fine as a sign-in proof (sanity check)', () => { + const client = Keypair.random(); + const signInChallenge = buildChallenge(client.publicKey()); + const signed = sign(signInChallenge, client); + assert.equal(verifyChallenge(signed), client.publicKey()); +}); + +// ─── Network binding (#263) ───────────────────────────────────────────────── + +test('assertNetworkMatches is a no-op when the requested network matches', () => { + assert.doesNotThrow(() => assertNetworkMatches(getConfiguredNetwork())); + assert.doesNotThrow(() => assertNetworkMatches('testnet')); +}); + +test('assertNetworkMatches rejects a mismatched network, naming both', () => { + assert.throws( + () => assertNetworkMatches('mainnet'), + (err: unknown) => { + assert.ok(err instanceof CliLinkError); + assert.match((err as Error).message, /mainnet/); + assert.match((err as Error).message, /testnet/); + return true; + }, + ); +}); + +test('buildCliLinkChallenge refuses to build a challenge for a mismatched network', () => { + const client = Keypair.random(); + assert.throws(() => buildCliLinkChallenge(client.publicKey(), 'mainnet'), CliLinkError); +}); diff --git a/apps/web/lib/cli-link.ts b/apps/web/lib/cli-link.ts new file mode 100644 index 0000000..ba0d660 --- /dev/null +++ b/apps/web/lib/cli-link.ts @@ -0,0 +1,114 @@ +import { WebAuth } from '@stellar/stellar-sdk'; +import { isMainnetNetwork } from './network-guard.ts'; +import { + getHomeDomain, + getNetworkPassphrase, + getServerKeypair, + Sep10ConfigError, + Sep10Error, +} from './sep10.ts'; + +/** + * SEP-10-shaped challenge for the CLI's `signet link` — a separate purpose + * from web sign-in (`sep10.ts` / `/api/auth/sep10`), so a signature captured + * from one context is never valid proof for the other. + * + * A SEP-10 challenge's `home_domain` Manage Data operation *is* the spec's + * own domain-separation mechanism: `WebAuth.readChallengeTx` rejects a + * challenge whose home domain doesn't match what the verifier expects. Using + * a distinct home domain here — rather than reusing `sep10.ts`'s — is what + * makes a web sign-in challenge fail CLI-link verification, and a CLI-link + * challenge fail sign-in verification, with no extra bookkeeping: the SDK + * enforces it as part of reading the transaction. + * + * The network passphrase is likewise a required argument to both building and + * verifying the challenge (it's baked into the transaction's network ID hash), + * so a challenge built for one network cannot be replayed as proof against a + * deployment configured for the other — see `assertNetworkMatches`, which + * rejects the *request* itself before a challenge naming the wrong network is + * ever built. + */ + +const CLI_LINK_TIMEOUT_SECONDS = 5 * 60; + +/** + * The distinguishing home domain for CLI-link challenges — deliberately + * different from `sep10.ts`'s `getHomeDomain()`/`getWebAuthDomain()`, which + * back web sign-in. Not expected to resolve in DNS; SEP-10's domain check + * here is a string match, not a lookup. + */ +export function getCliLinkDomain(): string { + return `cli.${getHomeDomain()}`; +} + +/** The Stellar network this deployment is configured for (e.g. `"testnet"`). */ +export function getConfiguredNetwork(): string { + return process.env.NEXT_PUBLIC_STELLAR_NETWORK ?? 'testnet'; +} + +export class CliLinkError extends Error {} + +/** + * Reject a CLI-requested network that doesn't match this deployment's + * configured one, naming both — a testnet deploy key linked under a mainnet + * profile would present worthless testnet contracts as real career history. + */ +export function assertNetworkMatches(requestedNetwork: string): void { + const configured = getConfiguredNetwork(); + if (isMainnetNetwork(requestedNetwork) !== isMainnetNetwork(configured)) { + throw new CliLinkError( + `Network mismatch: the CLI requested "${requestedNetwork}" but this deployment is configured for "${configured}".`, + ); + } +} + +/** + * Build a CLI-link challenge transaction for `clientAccountId` (the deploy + * wallet's public key), after checking `requestedNetwork` against this + * deployment's configured network. + */ +export function buildCliLinkChallenge(clientAccountId: string, requestedNetwork: string): string { + assertNetworkMatches(requestedNetwork); + const domain = getCliLinkDomain(); + return WebAuth.buildChallengeTx( + getServerKeypair(), + clientAccountId, + domain, + CLI_LINK_TIMEOUT_SECONDS, + getNetworkPassphrase(), + domain, + ); +} + +/** + * Verify a signed CLI-link challenge transaction and return the authenticated + * client account id (the deploy wallet). Throws `Sep10Error` (or a + * `WebAuth.InvalidChallengeError`) on any failure — including a challenge + * built for web sign-in instead of CLI linking, since its home domain won't + * match `getCliLinkDomain()`. + */ +export function verifyCliLinkChallenge(transactionXdr: string): string { + const domain = getCliLinkDomain(); + const serverAccountId = getServerKeypair().publicKey(); + const { clientAccountID } = WebAuth.readChallengeTx( + transactionXdr, + serverAccountId, + getNetworkPassphrase(), + domain, + domain, + ); + const signers = WebAuth.verifyChallengeTxSigners( + transactionXdr, + serverAccountId, + getNetworkPassphrase(), + [clientAccountID], + domain, + domain, + ); + if (!signers.includes(clientAccountID)) { + throw new Sep10Error('Challenge was not signed by the client account'); + } + return clientAccountID; +} + +export { Sep10ConfigError as CliLinkConfigError }; diff --git a/apps/web/lib/rate-limit-http.ts b/apps/web/lib/rate-limit-http.ts index b6165f3..91538f5 100644 --- a/apps/web/lib/rate-limit-http.ts +++ b/apps/web/lib/rate-limit-http.ts @@ -31,6 +31,8 @@ export const LIMITS = { authChallenge: 20, /** Sign-out-everywhere: authenticated and rare, so a tight bucket is plenty. */ authRevoke: 10, + /** Builds/verifies a CLI-link challenge — same signing cost as sep10. */ + cliLink: 12, /** Plain reads; generous, still bounded. */ read: 60, /**