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
131 changes: 131 additions & 0 deletions apps/web/app/api/cli-link/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
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',
},
});
}
106 changes: 106 additions & 0 deletions apps/web/lib/cli-link.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
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);
});
114 changes: 114 additions & 0 deletions apps/web/lib/cli-link.ts
Original file line number Diff line number Diff line change
@@ -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 };
2 changes: 2 additions & 0 deletions apps/web/lib/rate-limit-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ export const LIMITS = {
cliPairStart: 12,
/** Verifies an ed25519 signature per call, like `sep10`. */
cliPairComplete: 12,
/** Builds/verifies a CLI-link challenge — same signing cost as `sep10`. */
cliLink: 12,
/** Verifies a signature per call. */
authVerify: 15,
/** Cheap, but the entry point to the sign-in flow. */
Expand Down
Loading