diff --git a/ARCHITECTURE_ESSENTIALS.md b/ARCHITECTURE_ESSENTIALS.md index ce21c38..f755897 100644 --- a/ARCHITECTURE_ESSENTIALS.md +++ b/ARCHITECTURE_ESSENTIALS.md @@ -38,6 +38,7 @@ Account deletion (Phase 8, issue #41 — docs/adr/0010): anonymise in place (`Us - `WalletLink` is scoped to the **facility** (the on-chain payee), not the patient. - Each issued order stores its selected facility and clinician-entered total in XLM stroops; a database check requires exactly one PaymentIntent order FK. - `release`/`refund` are admin-triggered HTTP actions in v1 (`POST /payments/intents/:id/{release,refund}`) — there's no `FulfillmentRecord.status == fulfilled` to trigger off of yet. Don't treat this as the final design. +- **SEP-10 wallet-ownership proof** (issue #77): `GET /payments/sep10/challenge` + `POST /payments/sep10/verify` (see docs/api/README.md) with a dedicated `SEP10_SIGNING_SECRET` (never the payer/releaser keys). The `Sep10Challenge` nonce ledger is single-use via one conditional UPDATE — no app-level check-then-write. This is generic infrastructure for ADR 0005's wallet linking; it issues no session, DocForum's own JWT still authenticates the caller. - The actual Stellar contract calls go through `@docforum/escrow-sdk` (`backend/src/modules/payments/services/escrow-sdk-loader.ts` — dynamic `import()`, the SDK is ESM-only and this backend is CommonJS). Never reimplement contract-calling logic in this repo. ## Known unresolved design gaps (do not silently "fix" without flagging in PR description) diff --git a/ROADMAP.md b/ROADMAP.md index c9a4d94..fa251c1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -373,11 +373,24 @@ roadmap. See each phase section below for status and issue links. sponsorship. Custodial v1 stays as a fallback. Status: Proposed, not yet Accepted. Added to the docs site. - 2026-10-09 — Opened issue #32: upgrade to escrow SDK 0.3.0 and the - current testnet contract. -- 2026-10-15 — Started Phase 8's auth-hardening work, scoped as issues - #34 (email verification), #35 (login brute-force throttling), #37 (CSRF - for the cookie-authenticated endpoints + per-environment CORS allowlist) - and #39 (admin account lifecycle). All four are independent of Phases + current testnet contract.- 2026-10-16 — Implemented issue #77 (SEP-10 Stellar Web Authentication server, Phase 5.5 / ADR 0005 step 4): `GET + /payments/sep10/challenge` builds a server-signed challenge (SDK + helper — Manage Data nonce, 5-minute time bounds, home domain + + web_auth_domain) and records the issuance binding; `POST + /payments/sep10/verify` walks the full SEP-10 server verification + list (structure, time bounds, server signature, client signatures + with multisig weight thresholds loaded from Horizon, and the + spec's master-key rule for unfunded accounts), then consumes the + nonce with ONE conditional UPDATE so concurrent replays can't both + succeed — same DB-level pattern as booking. New `Sep10Challenge` + model (unique nonce, userId, account, expiry) with a migration; a + dedicated `SEP10_SIGNING_SECRET` (never the payer/releaser keys, + validated at startup, never logged). Horizon is behind an injectable + seam so the 15 unit + 18 integration tests run with zero network. + Docs updated: ARCHITECTURE §4.10, essentials data-model map, + docs/api/README.md (endpoints + errors + unfunded behaviour). +- 2026-10-15 — Started Phase 8's auth-hardening work, scoped as issues #34 (email verification), #35 (login brute-force throttling), #37 (CSRF + for the cookie-authenticated endpoints + per-environment CORS allowlist) and #39 (admin account lifecycle). All four are independent of Phases 3–7 and of each other. #34 merged as PR #137: `User.emailVerifiedAt` + a hashed/expiring/single-use `EmailVerificationToken` table (backfilled as verified for existing rows), `POST /auth/verify-email`, diff --git a/backend/prisma/migrations/20261016130509_add_sep10_challenges/migration.sql b/backend/prisma/migrations/20261016130509_add_sep10_challenges/migration.sql new file mode 100644 index 0000000..b222c39 --- /dev/null +++ b/backend/prisma/migrations/20261016130509_add_sep10_challenges/migration.sql @@ -0,0 +1,22 @@ +-- CreateTable +CREATE TABLE "Sep10Challenge" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "account" TEXT NOT NULL, + "nonce" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "consumedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Sep10Challenge_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Sep10Challenge_nonce_key" ON "Sep10Challenge"("nonce"); + +-- CreateIndex +CREATE INDEX "Sep10Challenge_expiresAt_idx" ON "Sep10Challenge"("expiresAt"); + +-- AddForeignKey +ALTER TABLE "Sep10Challenge" ADD CONSTRAINT "Sep10Challenge_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index b52f32d..1607ae2 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -845,6 +845,40 @@ model PaymentIntent { @@index([status]) } +// SEP-10 (Stellar Web Authentication) challenge issuance ledger — +// one row per issued challenge, created by GET /payments/sep10/challenge +// and redeemed exactly once by POST /payments/sep10/verify (issue #77, +// ADR 0005 step 4: proving the caller controls a Stellar address before +// it is ever trusted as payer or payee). +// +// The nonce is the random 64-byte value embedded in the challenge +// transaction's `" auth"` Manage Data operation. Storing it +// (unique) is what makes a challenge single-use: verify consumes the row +// with ONE conditional UPDATE (`consumedAt IS NULL AND expiresAt > now`), +// so two concurrent replays can't both win — the same DB-level pattern +// as booking's conditional slot update (ARCHITECTURE.md §5.1). +// +// Deliberately no other fields: no XDR, no signature, no PHI. The +// challenge itself is re-derivable from the submitted XDR at verify +// time; this table only proves "we issued this nonce to this user for +// this account, it hasn't been used, and it hasn't expired." +model Sep10Challenge { + id String @id @default(uuid()) + userId String + account String // the G... (or M...) account the challenge was issued for + nonce String @unique // hex/utf8 nonce from the challenge's Manage Data op + expiresAt DateTime // matches the challenge's timebounds maxTime (+ record TTL) + consumedAt DateTime? + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + // Pruning and the "is this row still redeemable" check both scan by + // expiry — same reason LoginAttempt indexes its window. Lookups at + // verify time go through the unique `nonce` index. + @@index([expiresAt]) +} + model WalletLink { id String @id @default(uuid()) // Scoped to the facility being paid, not the patient — v1 is a diff --git a/backend/src/modules/payments/controllers/sep10.ts b/backend/src/modules/payments/controllers/sep10.ts new file mode 100644 index 0000000..4bf8813 --- /dev/null +++ b/backend/src/modules/payments/controllers/sep10.ts @@ -0,0 +1,22 @@ +// SEP-10 controllers (issue #77) — thin HTTP layer over services/sep10. +// Route-level `authenticate`/`requireVerifiedEmail` middleware runs first +// (see routes/index.ts); these handlers only parse input, call the +// service, and shape the response. +import type { Request, Response } from 'express'; +import * as paymentsService from '../services'; +import { sep10ChallengeQuerySchema, sep10VerifySchema } from '../validation'; +import { HttpError } from '../../../http-error'; + +export async function sep10Challenge(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const { account } = sep10ChallengeQuerySchema.parse(req.query); + const challenge = await paymentsService.createSep10Challenge(req.user.id, account); + res.status(200).json(challenge); +} + +export async function sep10Verify(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const { transaction } = sep10VerifySchema.parse(req.body); + const result = await paymentsService.verifySep10Challenge(req.user.id, transaction); + res.status(200).json({ ...result, userId: req.user.id }); +} diff --git a/backend/src/modules/payments/routes/index.ts b/backend/src/modules/payments/routes/index.ts index 94aa979..3e99a55 100644 --- a/backend/src/modules/payments/routes/index.ts +++ b/backend/src/modules/payments/routes/index.ts @@ -39,3 +39,24 @@ paymentsRouter.post( requireVerifiedEmail, paymentsController.refundIntent, ); + +// SEP-10 Stellar Web Authentication (issue #77, ADR 0005 step 4): prove +// the caller controls a Stellar address before it is ever trusted as a +// payer or payee. Any authenticated role may link a wallet (patient +// wallets become the payer in v2; facility payout wallets replace the +// type-the-address form), so no role gate here — but a VERIFIED address +// is required, same as every other payments mutation in this module +// (issue #34): a wallet link gated only by a possibly-unverified email +// would be a bypass of the guard POST /payments/wallet-link already has. +paymentsRouter.get( + '/sep10/challenge', + authenticate, + requireVerifiedEmail, + paymentsController.sep10Challenge, +); +paymentsRouter.post( + '/sep10/verify', + authenticate, + requireVerifiedEmail, + paymentsController.sep10Verify, +); diff --git a/backend/src/modules/payments/services/index.ts b/backend/src/modules/payments/services/index.ts index de56fae..27ea2dd 100644 --- a/backend/src/modules/payments/services/index.ts +++ b/backend/src/modules/payments/services/index.ts @@ -30,6 +30,19 @@ export { setEscrowSdkForTests } from './escrow-sdk-loader'; export { listEscrowEvents } from './escrow-events'; export type { EscrowSdkModule } from './escrow-sdk-loader'; +// SEP-10 Stellar Web Authentication (issue #77). Re-exported through +// this module's service surface (not imported file-by-file from outside) +// per the AGENTS.md module-boundary rule — same pattern as the auth +// module's token/throttle re-exports. +export { + createSep10Challenge, + verifySep10Challenge, + resetSep10AccountFetcher, + setSep10AccountFetcher, + validateSep10Config, +} from './sep10'; +export type { Sep10AccountFetcher, Sep10AccountInfo, Sep10ChallengeResponse, Sep10Signer, Sep10VerifyResult } from './sep10'; + async function requirePaymentsConfig() { const { activeContractId, escrowTokenId } = config.payments; const missing = Object.entries({ activeContractId, escrowTokenId }) diff --git a/backend/src/modules/payments/services/sep10.ts b/backend/src/modules/payments/services/sep10.ts new file mode 100644 index 0000000..4379b3b --- /dev/null +++ b/backend/src/modules/payments/services/sep10.ts @@ -0,0 +1,384 @@ +// SEP-10 Stellar Web Authentication server (issue #77, ADR 0005 step 4). +// +// Before a patient or facility wallet can be trusted as payer or payee, +// the backend must prove the caller controls that Stellar address. SEP-10 +// is the standard flow: the server issues a challenge transaction, the +// user's wallet signs it server-side (the backend builds and submits +// everything; the browser only relays XDR — docforum-web hard rule 1), +// and the server verifies the signature. This file is generic +// infrastructure; wallet-link storage (patient/facility scoped links) is +// future work on top of it. +// +// What verifies what — the SEP-10 server verification list, in order: +// 1. base64 XDR deserializes as a Transaction → readChallengeTx +// 2. sequence number == 0 → readChallengeTx +// 3. tx source == server signing account → readChallengeTx +// 4. bounded, unexpired timebounds → readChallengeTx (SDK +// allows 5 min of clock-drift grace; our DB record enforces the issued +// timeout strictly, so the two layers together are tighter than either) +// 5. >= 1 operation; first op is Manage Data with the client account as +// source; key is " auth"; value is a 48-byte-base64 +// nonce; subsequent ops are Manage Data sourced by the server with +// web_auth_domain == ours → readChallengeTx +// 6. server signature present and valid → readChallengeTx +// (steps 1-6 = WebAuth.readChallengeTx) +// 7. client signatures: for a FUNDED account, signer weights loaded from +// Horizon must meet the account's medium threshold +// (verifyChallengeTxThreshold); for an UNFUNDED account (per spec — +// see below) the master key must sign +// (verifyChallengeTxSigners). +// Plus our own additions on top of the spec list: the challenge's nonce +// must exist in OUR issuance ledger, still unconsumed and unexpired +// (single-use, consumed atomically), and must be bound to the +// authenticated caller and the exact account in the challenge. +import { HttpError } from '../../../http-error'; +import { config } from '../../../config'; +import * as paymentsRepo from '../repositories'; +import { loadStellarSdk } from './stellar-sdk-loader'; +import type { Keypair, Transaction } from '@stellar/stellar-sdk' with { 'resolution-mode': 'import' }; + +// --- Configuration ------------------------------------------------------- + +export interface Sep10Config { + serverKeypair: Keypair; + serverAccountId: string; + homeDomain: string; + webAuthDomain: string; + networkPassphrase: string; + horizonUrl: string; + timeoutSeconds: number; +} + +/** Human-readable configuration problems, naming ENV VAR NAMES only — + * never the secret value. Used by both the startup check and the lazy + * per-request check. */ +export async function sep10ConfigProblems(): Promise { + const problems: string[] = []; + if (!config.sep10.signingSecret) problems.push('SEP10_SIGNING_SECRET'); + if (!config.sep10.homeDomain) problems.push('SEP10_HOME_DOMAIN'); + if (!config.sep10.webAuthDomain) problems.push('SEP10_WEB_AUTH_DOMAIN'); + if (!Number.isInteger(config.sep10.challengeTimeoutSeconds) || config.sep10.challengeTimeoutSeconds <= 0) { + problems.push('SEP10_CHALLENGE_TIMEOUT_SECONDS (must be a positive integer)'); + } + if (config.sep10.signingSecret) { + const { Keypair } = await loadStellarSdk(); + try { + Keypair.fromSecret(config.sep10.signingSecret); + } catch { + // The parse error itself would echo the bad secret — report the var + // name, never the value. + problems.push('SEP10_SIGNING_SECRET (not a valid Stellar secret key)'); + } + } + return problems; +} + +/** Startup validation (issue #77: "validate at startup"). Called from + * src/server.ts before the listener starts, so a deployment with a + * missing/typo'd web-auth key fails to boot instead of 500ing on the + * first challenge request. Deliberately NOT called at module import: + * test suites build the app without importing server.ts. */ +export async function validateSep10Config(): Promise { + const problems = await sep10ConfigProblems(); + if (problems.length > 0) { + throw new Error(`SEP-10 configuration invalid — missing or invalid env var(s): ${problems.join(', ')}.`); + } +} + +/** Lazy per-request check for paths that need the config (mirrors + * requirePaymentsConfig in the sibling payments services). Throws a 500 + * HttpError, not a boot failure — this is the second line of defence for + * environments that don't run server.ts. */ +async function requireSep10Config(): Promise { + const problems = await sep10ConfigProblems(); + if (problems.length > 0) { + throw new HttpError(500, `SEP-10 is not configured — missing or invalid env var(s): ${problems.join(', ')}.`); + } + const { Keypair } = await loadStellarSdk(); + const serverKeypair = Keypair.fromSecret(config.sep10.signingSecret!); + return { + serverKeypair, + serverAccountId: serverKeypair.publicKey(), + homeDomain: config.sep10.homeDomain!, + webAuthDomain: config.sep10.webAuthDomain!, + networkPassphrase: config.sep10.networkPassphrase, + horizonUrl: config.sep10.horizonUrl, + timeoutSeconds: config.sep10.challengeTimeoutSeconds, + }; +} + +// --- Horizon seam (tests inject a fake; no network in the test suite) ---- + +export interface Sep10Signer { + key: string; + weight: number; + type: string; +} + +export interface Sep10AccountInfo { + accountId: string; + thresholds: { low_threshold: number; med_threshold: number; high_threshold: number }; + signers: Sep10Signer[]; +} + +/** Returns null when the account does not exist on the network yet + * (Horizon 404 — the SEP-10 "unfunded" case). Throws on any other + * Horizon failure. */ +export type Sep10AccountFetcher = (accountId: string) => Promise; + +let accountFetcherOverride: Sep10AccountFetcher | undefined; + +/** Test seam, same pattern as auth's setEmailSender: unit/integration + * tests replace the outbound Horizon call through this module's service + * surface instead of mocking a network client. Never called with an + * override in production code. */ +export function setSep10AccountFetcher(fetcher: Sep10AccountFetcher): void { + accountFetcherOverride = fetcher; +} + +export function resetSep10AccountFetcher(): void { + accountFetcherOverride = undefined; +} + +function isHorizonNotFound(err: unknown): boolean { + if (typeof err !== 'object' || err === null) return false; + const candidate = err as { name?: string; status?: number; response?: { status?: number } }; + return candidate.name === 'NotFoundError' || candidate.status === 404 || candidate.response?.status === 404; +} + +async function defaultAccountFetcher(accountId: string): Promise { + const { Horizon } = await loadStellarSdk(); + const horizon = new Horizon.Server(config.sep10.horizonUrl); + try { + const account = await horizon.accounts().accountId(accountId).call(); + return { + accountId: account.id, + thresholds: { + low_threshold: account.thresholds.low_threshold, + med_threshold: account.thresholds.med_threshold, + high_threshold: account.thresholds.high_threshold, + }, + signers: account.signers.map((signer) => ({ key: signer.key, weight: signer.weight, type: signer.type })), + }; + } catch (err) { + if (isHorizonNotFound(err)) return null; + throw err; + } +} + +async function fetchAccountInfo(accountId: string): Promise { + try { + return await (accountFetcherOverride ?? defaultAccountFetcher)(accountId); + } catch (err) { + if (err instanceof HttpError) throw err; + if (isHorizonNotFound(err)) return null; + // Don't forward Horizon/axios internals (URLs, request config) to the + // client — the fact that we couldn't load signers is all they get. + throw new HttpError(502, 'Could not load the account from Horizon to verify its signers.', 'SEP10_HORIZON_UNAVAILABLE'); + } +} + +// --- Challenge building -------------------------------------------------- + +/** The nonce is the base64 string in the first Manage Data op's value + * (" auth", source = client account): 48 random bytes + * base64-encoded to 64 chars, per SEP-10. That exact string is what gets + * stored as Sep10Challenge.nonce — the single-use key. */ +function challengeNonce(tx: Transaction): string { + const [firstOp] = tx.operations; + if (!firstOp || firstOp.type !== 'manageData') { + throw new HttpError(500, 'Built challenge is malformed (first operation is not Manage Data).'); + } + // Parsed Manage Data values arrive as a string for valid UTF-8 (which + // a base64 nonce always is) and as a Buffer otherwise; normalise to + // the exact string form SEP-10 specifies. + const value = firstOp.value; + return Buffer.isBuffer(value) ? value.toString('utf8') : String(value); +} + +/** Parses a submitted challenge XDR just enough to pull the nonce out. + * Assumes the caller already ran WebAuth.readChallengeTx (structure, + * timebounds and server signature are its job, not this helper's). */ +function signedChallengeNonce(tx: Transaction): string { + return challengeNonce(tx); +} + +export interface Sep10ChallengeResponse { + transaction: string; + expires_in: number; + network_passphrase: string; +} + +/** GET /payments/sep10/challenge — builds a server-signed SEP-10 + * challenge for `account` and records the issuance binding + * (userId, account, nonce, expiresAt) so a verified signature can only + * ever be redeemed by that user, for that account, once. */ +export async function createSep10Challenge(userId: string, account: string): Promise { + const { StrKey, WebAuth } = await loadStellarSdk(); + + // Full strkey validation (base32 + CRC16 checksum), not just the G… + // shape — a checksum-invalid key would otherwise be discovered only at + // wallet-signing time. + if (!StrKey.isValidEd25519PublicKey(account)) { + throw new HttpError(400, 'account must be a valid Stellar public key (G…).', 'SEP10_INVALID_ACCOUNT'); + } + + const cfg = await requireSep10Config(); + + let transaction: string; + try { + // Server-signed, Manage Data nonce, bounded timebounds, home domain + // and web_auth_domain — all handled by the SDK helper. + transaction = WebAuth.buildChallengeTx( + cfg.serverKeypair, + account, + cfg.homeDomain, + cfg.timeoutSeconds, + cfg.networkPassphrase, + cfg.webAuthDomain, + ); + } catch (err) { + // No secret can appear here (the keypair was already validated), but + // keep the message generic anyway. + throw new HttpError(500, `Could not build the SEP-10 challenge: ${err instanceof Error ? err.message : 'unknown error'}`); + } + + // Read our own challenge back through the same validation path a client + // submission takes — if it doesn't round-trip, we'd be handing out a + // challenge we would later reject. + const read = WebAuth.readChallengeTx(transaction, cfg.serverAccountId, cfg.networkPassphrase, cfg.homeDomain, cfg.webAuthDomain); + const nonce = challengeNonce(read.tx); + const maxTimeSeconds = Number(read.tx.timeBounds?.maxTime ?? NaN); + if (!Number.isFinite(maxTimeSeconds)) { + throw new HttpError(500, 'Built challenge has no time bounds.'); + } + const expiresAt = new Date(maxTimeSeconds * 1000); + + await paymentsRepo.createSep10Challenge({ userId, account, nonce, expiresAt }); + + return { + transaction, + expires_in: cfg.timeoutSeconds, + network_passphrase: cfg.networkPassphrase, + }; +} + +// --- Challenge verification --------------------------------------------- + +export interface Sep10VerifyResult { + account: string; + signers: string[]; +} + +/** POST /payments/sep10/verify — the full SEP-10 server verification + * list plus DocForum's own single-use/binding rules. On success the + * caller has proven control of `account` (fundamentally: the same proof + * of control the Stellar network itself would honour for this + * transaction), and the challenge is consumed. + * + * UNFUNDED ACCOUNTS (SEP-10 spec): an account that does not exist on the + * network has no signers and no thresholds on record, so weights cannot + * be checked. The spec's rule for that case is that the challenge must be + * signed by the account's MASTER key, and by exactly one other signature + * (the server's) on the envelope — verifyChallengeTxSigners enforces + * both. We detect the unfunded case by a Horizon 404; any other Horizon + * error is a 502, never silently treated as unfunded (that would downgrade + * multisig accounts to master-key-only verification). + * + * This endpoint does NOT mint a DocForum session JWT: DocForum's own + * auth (Bearer access token) already authenticates the caller — SEP-10's + * job here is proving wallet control, not issuing a session. */ +export async function verifySep10Challenge(userId: string, challengeTx: string): Promise { + const cfg = await requireSep10Config(); + const { WebAuth } = await loadStellarSdk(); + + // Steps 1-6 of the SEP-10 list: deserialize, sequence == 0, source == + // server account, bounded/unexpired timebounds, operation shape + // (first op Manage Data sourced by the client with " auth" + // key and a valid nonce, later ops Manage Data sourced by the server, + // web_auth_domain == ours), and the server signature. + let read: { tx: Transaction; clientAccountID: string }; + try { + read = WebAuth.readChallengeTx( + challengeTx, + cfg.serverAccountId, + cfg.networkPassphrase, + cfg.homeDomain, + cfg.webAuthDomain, + ); + } catch (err) { + throw new HttpError(400, `Invalid challenge: ${err instanceof Error ? err.message : 'could not parse transaction'}`, 'SEP10_INVALID_CHALLENGE'); + } + + const nonce = signedChallengeNonce(read.tx); + + // Our own ledger: was this challenge issued by us, is it still good, + // and is it THIS caller's, for THIS account? Checked before the Horizon + // round trip so a mismatched submission can't make us fetch signers. + const record = await paymentsRepo.findSep10ChallengeByNonce(nonce); + if (!record) { + throw new HttpError(401, 'Challenge was not issued by this server.', 'SEP10_CHALLENGE_UNKNOWN'); + } + if (record.userId !== userId || record.account !== read.clientAccountID) { + // A signed challenge is never transferable: not to another user, and + // not for another account than the one the challenge was issued for. + throw new HttpError(403, 'This challenge was issued to a different user or account.', 'SEP10_CHALLENGE_NOT_FOR_CALLER'); + } + if (record.consumedAt) { + throw new HttpError(401, 'This challenge has already been used.', 'SEP10_CHALLENGE_ALREADY_USED'); + } + // Strict check against OUR clock, without the SDK's 5-minute drift + // grace — a challenge is good for exactly the window we issued it for. + if (record.expiresAt.getTime() <= Date.now()) { + throw new HttpError(401, 'This challenge has expired.', 'SEP10_CHALLENGE_EXPIRED'); + } + + // Steps 7/8 of the SEP-10 list: client signatures. + const accountInfo = await fetchAccountInfo(read.clientAccountID); + let matchedSigners: string[]; + try { + if (accountInfo === null) { + // Unfunded account — no on-chain signers/thresholds exist; the + // master key must sign (documented in the API docs as well). + matchedSigners = WebAuth.verifyChallengeTxSigners( + challengeTx, + cfg.serverAccountId, + cfg.networkPassphrase, + [read.clientAccountID], + cfg.homeDomain, + cfg.webAuthDomain, + ); + } else { + // Multisig: load signers + thresholds from Horizon and enforce the + // account's MEDIUM threshold — SEP-10's guidance is medium for + // authority aligned with the ability to move funds, which is + // exactly what a verified wallet link gates. The server's own + // signature contributes no client weight (enforced by the SDK). + matchedSigners = WebAuth.verifyChallengeTxThreshold( + challengeTx, + cfg.serverAccountId, + cfg.networkPassphrase, + accountInfo.thresholds.med_threshold, + accountInfo.signers, + cfg.homeDomain, + cfg.webAuthDomain, + ); + } + } catch (err) { + // A structurally sound challenge whose client signature is missing, + // unknown, or below threshold. + throw new HttpError(401, `Challenge signature verification failed: ${err instanceof Error ? err.message : 'unknown error'}`, 'SEP10_INVALID_SIGNATURE'); + } + + // Step 9 — single-use, consumed atomically and LAST: a bad signature + // never burns a legitimately issued nonce, and two concurrent replays + // of the same signed challenge race here — exactly one conditional + // UPDATE wins (`WHERE consumedAt IS NULL AND expiresAt > now`). + const consumedAt = new Date(); + const { count } = await paymentsRepo.consumeSep10Challenge(nonce, consumedAt); + if (count !== 1) { + throw new HttpError(401, 'This challenge has already been used.', 'SEP10_CHALLENGE_ALREADY_USED'); + } + + return { account: read.clientAccountID, signers: matchedSigners }; +} diff --git a/backend/src/modules/payments/services/stellar-sdk-loader.ts b/backend/src/modules/payments/services/stellar-sdk-loader.ts new file mode 100644 index 0000000..50d10a8 --- /dev/null +++ b/backend/src/modules/payments/services/stellar-sdk-loader.ts @@ -0,0 +1,14 @@ +// @stellar/stellar-sdk (v16) ships both CJS and ESM builds, but its +// package `types` resolve as ECMAScript-module declarations — a static +// import in this CommonJS backend trips TS1479/TS1541 under `module: +// node16`. A dynamic `import()` is required instead, exactly like the +// existing escrow-sdk-loader.ts (ESM-only @docforum/escrow-sdk) and the +// embedded-postgres interop. One cached module promise per process. +type StellarSdkModule = typeof import('@stellar/stellar-sdk', { with: { 'resolution-mode': 'import' } }); + +let modulePromise: Promise | undefined; + +export function loadStellarSdk(): Promise { + modulePromise ??= import('@stellar/stellar-sdk'); + return modulePromise; +} diff --git a/backend/src/modules/payments/validation.ts b/backend/src/modules/payments/validation.ts index c09918d..9eeee35 100644 --- a/backend/src/modules/payments/validation.ts +++ b/backend/src/modules/payments/validation.ts @@ -4,6 +4,8 @@ export const linkWalletSchema = z.object({ // Stellar public keys are 56 chars, start with 'G'. Not a full // checksum validation (that's what create_escrow/the network itself // will reject) — just a sanity bound against obvious garbage input. + // (The SEP-10 challenge endpoint DOES checksum-validate via + // StrKey.isValidEd25519PublicKey — see services/sep10.ts.) stellarPublicKey: z .string() .regex(/^G[A-Z2-7]{55}$/, 'stellarPublicKey must be a valid Stellar public key (starts with G, 56 chars).'), diff --git a/backend/tests/integration/sep10.test.mts b/backend/tests/integration/sep10.test.mts new file mode 100644 index 0000000..441c890 --- /dev/null +++ b/backend/tests/integration/sep10.test.mts @@ -0,0 +1,502 @@ +// Integration tests for the SEP-10 Stellar Web Authentication server +// (issue #77) — real embedded Postgres (nonce ledger, atomic consume), +// real HTTP through the Express app, and a FAKE Horizon (no network: the +// account/signer lookup is injected via setSep10AccountFetcher, same +// service-surface seam pattern as auth's setEmailSender). +// +// What lives here (DB- or HTTP-level): the happy path, replay +// (sequential and genuinely concurrent), expiry (both the challenge's +// time bounds and an expired nonce record), multisig thresholds with +// differing weights, unfunded accounts (master-key rule), user/account +// binding, unknown nonces, and the endpoint-level auth/validation cases. +// The structural verification list (wrong source, bad sequence, wrong +// domains, extra ops, wrong server signature) is unit-tested in +// tests/unit/sep10.test.mts against the same service functions. +// +// Self-contained embedded Postgres, own port (5438) so it runs alongside +// the other integration files (5433 booking, 5435/5436/5437 others). +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import request from 'supertest'; +import EmbeddedPostgres from 'embedded-postgres'; +import { Keypair, TransactionBuilder, WebAuth } from '@stellar/stellar-sdk'; +import type { Transaction } from '@stellar/stellar-sdk'; +import type { Sep10AccountInfo } from '../../src/modules/payments/services/sep10.js'; + +const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const PORT = 5438; +const runId = Date.now(); +const PASSWORD = 'longenough123'; + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-sep10-pg-')); +const pg = new EmbeddedPostgres({ + databaseDir: dataDir, + user: 'docforum', + password: 'docforum', + port: PORT, + persistent: false, +}); +const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_test`; + +type App = ReturnType; +type Prisma = typeof import('../../src/db/prisma-client.js').prisma; +type Sep10Services = typeof import('../../src/modules/payments/services/index.js'); + +describe('SEP-10 Stellar Web Authentication server', () => { + let app: App; + let prisma: Prisma; + let sep10: Sep10Services; + let serverKp: Keypair; + let homeDomain: string; + let webAuthDomain: string; + let networkPassphrase: string; + + // Fake Horizon state: per-account overrides; anything not overridden is + // treated as a funded account whose master key is the only signer + // (weight 1, med threshold 1) — the plain single-signer case. + const accountOverrides = new Map(); + function fundedSingleSigner(accountId: string) { + return { + accountId, + thresholds: { low_threshold: 0, med_threshold: 1, high_threshold: 1 }, + signers: [{ key: accountId, weight: 1, type: 'ed25519_public_key' }], + }; + } + + beforeAll(async () => { + await pg.initialise(); + await pg.start(); + await pg.createDatabase('docforum_test'); + + execFileSync('npx', ['prisma', 'migrate', 'deploy'], { + cwd: BACKEND_ROOT, + env: { ...process.env, DATABASE_URL: databaseUrl }, + stdio: 'pipe', + }); + + process.env.DATABASE_URL = databaseUrl; + + const appModule = await import('../../src/app.js'); + const dbModule = await import('../../src/db/prisma-client.js'); + const servicesModule = await import('../../src/modules/payments/services/index.js'); + const configModule = await import('../../src/config/index.js'); + app = appModule.createApp(); + prisma = dbModule.prisma; + sep10 = servicesModule; + + serverKp = Keypair.fromSecret(configModule.config.sep10.signingSecret!); + homeDomain = configModule.config.sep10.homeDomain!; + webAuthDomain = configModule.config.sep10.webAuthDomain!; + networkPassphrase = configModule.config.sep10.networkPassphrase; + + sep10.setSep10AccountFetcher((accountId) => + Promise.resolve(accountOverrides.has(accountId) ? accountOverrides.get(accountId)! : fundedSingleSigner(accountId)), + ); + }); + + afterAll(async () => { + sep10?.resetSep10AccountFetcher(); + await prisma?.$disconnect(); + await pg.stop(); + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + afterEach(() => { + accountOverrides.clear(); + }); + + // --- helpers ----------------------------------------------------------- + + async function signupVerified(label: string): Promise<{ userId: string; accessToken: string }> { + const email = `sep10-${label}-${runId}@example.com`; + const res = await request(app) + .post('/auth/signup') + .send({ role: 'patient', email, password: PASSWORD, fullName: `SEP10 ${label}` }); + expect(res.status).toBe(200); + const userId = res.body.user.id as string; + // The route requires a verified address (issue #34 guard) — mark it + // verified directly, same as the other payments integration suites. + await prisma.user.update({ where: { id: userId }, data: { emailVerifiedAt: new Date() } }); + return { userId, accessToken: res.body.accessToken as string }; + } + + function getChallenge(accessToken: string, account: string) { + return request(app).get('/payments/sep10/challenge').set('Authorization', `Bearer ${accessToken}`).query({ account }); + } + + function postVerify(accessToken: string, transaction: string) { + return request(app) + .post('/payments/sep10/verify') + .set('Authorization', `Bearer ${accessToken}`) + .send({ transaction }); + } + + function signChallenge(challengeXdr: string, ...keypairs: Keypair[]): string { + const tx = TransactionBuilder.fromXDR(challengeXdr, networkPassphrase) as Transaction; + for (const kp of keypairs) tx.sign(kp); + return tx.toEnvelope().toXDR('base64').toString(); + } + + function challengeNonce(challengeXdr: string): string { + const read = WebAuth.readChallengeTx(challengeXdr, serverKp.publicKey(), networkPassphrase, homeDomain, webAuthDomain); + const [firstOp] = read.tx.operations; + if (!firstOp || firstOp.type !== 'manageData') throw new Error('challenge has no leading Manage Data operation'); + return String(firstOp.value); + } + + /** Issues a challenge over HTTP for `account` and signs it with the + * given keypairs, returning both the raw challenge and the signed XDR. */ + async function issueAndSign(accessToken: string, account: string, ...keypairs: Keypair[]) { + const res = await getChallenge(accessToken, account); + expect(res.status).toBe(200); + return { challenge: res.body.transaction as string, signed: signChallenge(res.body.transaction, ...keypairs) }; + } + + // --- challenge endpoint ------------------------------------------------ + + it('issues a well-formed challenge bound to the caller and the requested account', async () => { + const { accessToken, userId } = await signupVerified('shape'); + const clientKp = Keypair.random(); + + const res = await getChallenge(accessToken, clientKp.publicKey()); + expect(res.status).toBe(200); + expect(res.body.expires_in).toBe(300); + expect(res.body.network_passphrase).toBe(networkPassphrase); + + // Structure per SEP-10: server-signed, sequence 0, bounded ~5-min + // timebounds, Manage Data nonce sourced by the client, web_auth_domain. + const read = WebAuth.readChallengeTx( + res.body.transaction, + serverKp.publicKey(), + networkPassphrase, + homeDomain, + webAuthDomain, + ); + expect(read.clientAccountID).toBe(clientKp.publicKey()); + const tx = read.tx; + expect(Number(tx.sequence)).toBe(0); + expect(Number(tx.timeBounds!.maxTime) - Number(tx.timeBounds!.minTime)).toBe(300); + + // The issuance ledger recorded the binding. + const row = await prisma.sep10Challenge.findUnique({ where: { nonce: challengeNonce(res.body.transaction) } }); + expect(row).not.toBeNull(); + expect(row!.userId).toBe(userId); + expect(row!.account).toBe(clientKp.publicKey()); + expect(row!.consumedAt).toBeNull(); + expect(row!.expiresAt.getTime()).toBe(Number(tx.timeBounds!.maxTime) * 1000); + }); + + it('rejects an invalid account parameter with 400 (shape and checksum)', async () => { + const { accessToken } = await signupVerified('bad-account'); + const rowsBefore = await prisma.sep10Challenge.count(); + const tooShort = await getChallenge(accessToken, 'GNOTAKEY'); + expect(tooShort.status).toBe(400); + expect(tooShort.body.message).toMatch(/valid Stellar public key/); + + // Right shape, corrupted checksum. + const valid = Keypair.random().publicKey(); + const corrupted = valid.slice(0, -1) + (valid.endsWith('A') ? 'B' : 'A'); + const badChecksum = await getChallenge(accessToken, corrupted); + expect(badChecksum.status).toBe(400); + expect(badChecksum.body.message).toMatch(/valid Stellar public key/); + + const missing = await request(app).get('/payments/sep10/challenge').set('Authorization', `Bearer ${accessToken}`); + expect(missing.status).toBe(400); + + // Nothing was recorded for any of them. + expect(await prisma.sep10Challenge.count()).toBe(rowsBefore); + }); + + it('rejects unauthenticated requests to both endpoints', async () => { + const noAuthGet = await request(app).get('/payments/sep10/challenge').query({ account: Keypair.random().publicKey() }); + expect(noAuthGet.status).toBe(401); + + const noAuthPost = await request(app).post('/payments/sep10/verify').send({ transaction: 'x' }); + expect(noAuthPost.status).toBe(401); + }); + + it('rejects an unauthenticated-email account with 403 EMAIL_NOT_VERIFIED', async () => { + const email = `sep10-unverified-${runId}@example.com`; + const signup = await request(app) + .post('/auth/signup') + .send({ role: 'patient', email, password: PASSWORD, fullName: 'SEP10 Unverified' }); + expect(signup.status).toBe(200); + const token = signup.body.accessToken as string; + + const res = await getChallenge(token, Keypair.random().publicKey()); + expect(res.status).toBe(403); + expect(res.body.code).toBe('EMAIL_NOT_VERIFIED'); + }); + + // --- happy path + single use ------------------------------------------- + + it('verifies a signed challenge end-to-end and consumes the nonce', async () => { + const { accessToken, userId } = await signupVerified('happy'); + const clientKp = Keypair.random(); + + const { signed } = await issueAndSign(accessToken, clientKp.publicKey(), clientKp); + + const verify = await postVerify(accessToken, signed); + expect(verify.status).toBe(200); + expect(verify.body).toEqual({ account: clientKp.publicKey(), signers: [clientKp.publicKey()], userId }); + + const row = await prisma.sep10Challenge.findUnique({ where: { nonce: challengeNonce(signed) } }); + expect(row!.consumedAt).not.toBeNull(); + }); + + it('rejects a replayed challenge', async () => { + const { accessToken } = await signupVerified('replay'); + const clientKp = Keypair.random(); + const { signed } = await issueAndSign(accessToken, clientKp.publicKey(), clientKp); + + expect((await postVerify(accessToken, signed)).status).toBe(200); + + const replay = await postVerify(accessToken, signed); + expect(replay.status).toBe(401); + expect(replay.body.code).toBe('SEP10_CHALLENGE_ALREADY_USED'); + }); + + it('lets exactly ONE of several concurrent verifies succeed', async () => { + const { accessToken } = await signupVerified('concurrent'); + const clientKp = Keypair.random(); + const { signed } = await issueAndSign(accessToken, clientKp.publicKey(), clientKp); + + // Genuinely concurrent: all in flight before any response is read. + // The nonce is consumed by a single conditional UPDATE, so exactly one + // request can win — the same DB-level pattern as double-booking. + const results = await Promise.all(Array.from({ length: 8 }, () => postVerify(accessToken, signed))); + + const succeeded = results.filter((r) => r.status === 200); + const rejected = results.filter((r) => r.status === 401); + expect(succeeded).toHaveLength(1); + expect(rejected).toHaveLength(7); + for (const r of rejected) expect(r.body.code).toBe('SEP10_CHALLENGE_ALREADY_USED'); + }); + + // --- expiry ----------------------------------------------------------- + + it('rejects a challenge whose time bounds have expired', async () => { + const { accessToken } = await signupVerified('expired-bounds'); + const clientKp = Keypair.random(); + + // Build (with the real server key) a challenge that expired long ago — + // beyond the SDK's 5-minute clock-drift grace window. + const { Account, BASE_FEE, Operation, TransactionBuilder: TB } = await import('@stellar/stellar-sdk'); + const now = Math.floor(Date.now() / 1000); + const expired = new TB(new Account(serverKp.publicKey(), '-1'), { + fee: BASE_FEE, + networkPassphrase, + timebounds: { minTime: now - 1800, maxTime: now - 1200 }, + }) + .addOperation( + Operation.manageData({ name: `${homeDomain} auth`, value: 'a'.repeat(64), source: clientKp.publicKey() }), + ) + .addOperation(Operation.manageData({ name: 'web_auth_domain', value: webAuthDomain, source: serverKp.publicKey() })) + .build(); + expired.sign(serverKp); + + const res = await postVerify(accessToken, signChallenge(expired.toEnvelope().toXDR('base64'), clientKp)); + expect(res.status).toBe(400); + expect(res.body.code).toBe('SEP10_INVALID_CHALLENGE'); + expect(res.body.message).toMatch(/expired/); + }); + + it('rejects a challenge whose nonce record has expired', async () => { + const { accessToken } = await signupVerified('expired-record'); + const clientKp = Keypair.random(); + const { signed } = await issueAndSign(accessToken, clientKp.publicKey(), clientKp); + + // The tx time bounds still allow the SDK's drift grace, but OUR + // ledger row is past its issued expiry — the stricter check wins. + await prisma.sep10Challenge.update({ + where: { nonce: challengeNonce(signed) }, + data: { expiresAt: new Date(Date.now() - 60_000) }, + }); + + const res = await postVerify(accessToken, signed); + expect(res.status).toBe(401); + expect(res.body.code).toBe('SEP10_CHALLENGE_EXPIRED'); + }); + + // --- signatures: wrong signer, multisig, unfunded ---------------------- + + it('rejects a challenge signed by a key that is not a signer', async () => { + const { accessToken } = await signupVerified('wrong-signer'); + const clientKp = Keypair.random(); + const attackerKp = Keypair.random(); + + const { signed } = await issueAndSign(accessToken, clientKp.publicKey(), attackerKp); + + const res = await postVerify(accessToken, signed); + expect(res.status).toBe(401); + expect(res.body.code).toBe('SEP10_INVALID_SIGNATURE'); + // The nonce was NOT burned by a failed verification. + const row = await prisma.sep10Challenge.findUnique({ where: { nonce: challengeNonce(signed) } }); + expect(row!.consumedAt).toBeNull(); + }); + + it('rejects a challenge carrying an extra unrecognized signature', async () => { + const { accessToken } = await signupVerified('extra-sig'); + const clientKp = Keypair.random(); + + const { signed } = await issueAndSign(accessToken, clientKp.publicKey(), clientKp, Keypair.random()); + + const res = await postVerify(accessToken, signed); + expect(res.status).toBe(401); + expect(res.body.code).toBe('SEP10_INVALID_SIGNATURE'); + }); + + it('accepts a multisig account when signer weights meet the medium threshold', async () => { + const { accessToken } = await signupVerified('multisig-ok'); + const clientKp = Keypair.random(); + const signer1 = Keypair.random(); + const signer2 = Keypair.random(); + // threshold 3; weights 1 + 2 — only BOTH signatures together pass. + accountOverrides.set(clientKp.publicKey(), { + accountId: clientKp.publicKey(), + thresholds: { low_threshold: 0, med_threshold: 3, high_threshold: 3 }, + signers: [ + { key: clientKp.publicKey(), weight: 1, type: 'ed25519_public_key' }, + { key: signer1.publicKey(), weight: 1, type: 'ed25519_public_key' }, + { key: signer2.publicKey(), weight: 2, type: 'ed25519_public_key' }, + ], + }); + + const { signed } = await issueAndSign(accessToken, clientKp.publicKey(), signer1, signer2); + + const res = await postVerify(accessToken, signed); + expect(res.status).toBe(200); + expect(new Set(res.body.signers)).toEqual(new Set([signer1.publicKey(), signer2.publicKey()])); + }); + + it('rejects a multisig challenge below the threshold, including single signers of differing weights', async () => { + const { accessToken } = await signupVerified('multisig-below'); + const clientKp = Keypair.random(); + const signer1 = Keypair.random(); // weight 1 + const signer2 = Keypair.random(); // weight 2 — still below threshold 3 alone + accountOverrides.set(clientKp.publicKey(), { + accountId: clientKp.publicKey(), + thresholds: { low_threshold: 0, med_threshold: 3, high_threshold: 3 }, + signers: [ + { key: clientKp.publicKey(), weight: 1, type: 'ed25519_public_key' }, + { key: signer1.publicKey(), weight: 1, type: 'ed25519_public_key' }, + { key: signer2.publicKey(), weight: 2, type: 'ed25519_public_key' }, + ], + }); + + // The heaviest single signer (weight 2) still misses threshold 3… + const heavyOnly = await issueAndSign(accessToken, clientKp.publicKey(), signer2); + const heavyRes = await postVerify(accessToken, heavyOnly.signed); + expect(heavyRes.status).toBe(401); + expect(heavyRes.body.code).toBe('SEP10_INVALID_SIGNATURE'); + expect(heavyRes.body.message).toMatch(/threshold/); + + // …and so does the lightest one. + const lightOnly = await issueAndSign(accessToken, clientKp.publicKey(), signer1); + const lightRes = await postVerify(accessToken, lightOnly.signed); + expect(lightRes.status).toBe(401); + expect(lightRes.body.code).toBe('SEP10_INVALID_SIGNATURE'); + }); + + it('accepts an UNFUNDED account only when the master key signs (SEP-10 spec)', async () => { + const { accessToken } = await signupVerified('unfunded'); + const unfundedKp = Keypair.random(); + accountOverrides.set(unfundedKp.publicKey(), null); // Horizon 404 — not yet created + + // Spec rule: no signers/thresholds exist on-chain yet, so the master + // key of the client account must sign the challenge. + const { signed } = await issueAndSign(accessToken, unfundedKp.publicKey(), unfundedKp); + const ok = await postVerify(accessToken, signed); + expect(ok.status).toBe(200); + expect(ok.body.signers).toEqual([unfundedKp.publicKey()]); + + // A different key standing in for an unfunded account must fail. + const another = await issueAndSign(accessToken, unfundedKp.publicKey(), Keypair.random()); + const bad = await postVerify(accessToken, another.signed); + expect(bad.status).toBe(401); + expect(bad.body.code).toBe('SEP10_INVALID_SIGNATURE'); + }); + + // --- binding and the nonce ledger -------------------------------------- + + it('rejects a challenge issued to user A when user B submits it', async () => { + const a = await signupVerified('user-a'); + const b = await signupVerified('user-b'); + const clientKp = Keypair.random(); + + const { signed } = await issueAndSign(a.accessToken, clientKp.publicKey(), clientKp); + expect((await postVerify(a.accessToken, signed)).status).toBe(200); + + // Even ignoring consumption: B can never redeem A's challenge. Reissue + // a fresh one to demonstrate the binding, not the single-use rule. + const fresh = await issueAndSign(a.accessToken, clientKp.publicKey(), clientKp); + const res = await postVerify(b.accessToken, fresh.signed); + expect(res.status).toBe(403); + expect(res.body.code).toBe('SEP10_CHALLENGE_NOT_FOR_CALLER'); + }); + + it('rejects a challenge whose recorded account does not match the challenge tx', async () => { + const { accessToken } = await signupVerified('account-mismatch'); + const clientKp = Keypair.random(); + const otherKp = Keypair.random(); + + const { signed } = await issueAndSign(accessToken, clientKp.publicKey(), clientKp); + // Simulate a ledger row whose account binding no longer matches the + // transaction's client account (e.g. tampering or a bad migration). + await prisma.sep10Challenge.update({ + where: { nonce: challengeNonce(signed) }, + data: { account: otherKp.publicKey() }, + }); + + const res = await postVerify(accessToken, signed); + expect(res.status).toBe(403); + expect(res.body.code).toBe('SEP10_CHALLENGE_NOT_FOR_CALLER'); + }); + + it('rejects a structurally valid challenge that was never issued by this server', async () => { + const { accessToken } = await signupVerified('unknown-nonce'); + const clientKp = Keypair.random(); + + // Perfectly signed by the real server key — but its nonce is not in + // our issuance ledger (nobody issued it, or the row was pruned). + const forged = WebAuth.buildChallengeTx(serverKp, clientKp.publicKey(), homeDomain, 300, networkPassphrase, webAuthDomain); + + const res = await postVerify(accessToken, signChallenge(forged, clientKp)); + expect(res.status).toBe(401); + expect(res.body.code).toBe('SEP10_CHALLENGE_UNKNOWN'); + }); + + it('rejects a tampered challenge that has extra client-sourced operations', async () => { + const { accessToken } = await signupVerified('tampered'); + const clientKp = Keypair.random(); + + const challengeRes = await getChallenge(accessToken, clientKp.publicKey()); + const challenge = challengeRes.body.transaction as string; + + // Rebuild the challenge with an added client-sourced op (invalid per + // SEP-10: extra ops must be server-sourced Manage Data), re-signed by + // the server key so only the structural rule can catch it. + const { Account, BASE_FEE, Operation, TransactionBuilder: TB } = await import('@stellar/stellar-sdk'); + const original = TransactionBuilder.fromXDR(challenge, networkPassphrase) as Transaction; + const tampered = new TB(new Account(serverKp.publicKey(), '-1'), { + fee: BASE_FEE, + networkPassphrase, + timebounds: { minTime: Number(original.timeBounds!.minTime), maxTime: Number(original.timeBounds!.maxTime) }, + }) + .addOperation( + Operation.manageData({ name: `${homeDomain} auth`, value: 'a'.repeat(64), source: clientKp.publicKey() }), + ) + .addOperation(Operation.manageData({ name: 'web_auth_domain', value: webAuthDomain, source: serverKp.publicKey() })) + .addOperation(Operation.manageData({ name: 'sneaky', value: 'x', source: clientKp.publicKey() })) + .build(); + tampered.sign(serverKp); + + const res = await postVerify(accessToken, signChallenge(tampered.toEnvelope().toXDR('base64'), clientKp)); + expect(res.status).toBe(400); + expect(res.body.code).toBe('SEP10_INVALID_CHALLENGE'); + }); +}); diff --git a/backend/tests/unit/sep10.test.mts b/backend/tests/unit/sep10.test.mts new file mode 100644 index 0000000..74f8915 --- /dev/null +++ b/backend/tests/unit/sep10.test.mts @@ -0,0 +1,311 @@ +// Unit tests for the SEP-10 server (issue #77) — no database, no network. +// +// Two groups: +// 1. Configuration validation (startup check reports var NAMES, never +// secret values). +// 2. The structural half of the SEP-10 server verification list — the +// cases WebAuth.readChallengeTx rejects (malformed XDR, wrong source, +// bad sequence, bad timebounds, wrong domains, extra operations, +// missing/wrong server signature). These fail BEFORE any DB access, +// so they run without the embedded Postgres the integration suite +// uses. Signature/threshold/binding/replay cases live in +// tests/integration/sep10.test.mts. +import { describe, expect, it, vi } from 'vitest'; +import { Keypair, StrKey, WebAuth } from '@stellar/stellar-sdk'; +import { HttpError } from '../../src/http-error.js'; +import { config } from '../../src/config/index.js'; +import { + createSep10Challenge, + sep10ConfigProblems, + validateSep10Config, + verifySep10Challenge, +} from '../../src/modules/payments/services/sep10.js'; + +const HOME_DOMAIN = config.sep10.homeDomain!; +const WEB_AUTH_DOMAIN = config.sep10.webAuthDomain!; +const NETWORK_PASSPHRASE = config.sep10.networkPassphrase; +const SERVER_KP = Keypair.fromSecret(config.sep10.signingSecret!); +const TIMEOUT = 300; + +async function expectHttpError(promise: Promise, status: number, code: string): Promise { + try { + await promise; + } catch (err) { + expect(err).toBeInstanceOf(HttpError); + const httpErr = err as HttpError; + expect(httpErr.status).toBe(status); + expect(httpErr.code).toBe(code); + return httpErr; + } + throw new Error(`expected the promise to reject with ${status} ${code}`); +} + +describe('SEP-10 config validation (issue #77)', () => { + it('passes with the test suite configuration', async () => { + await expect(validateSep10Config()).resolves.toBeUndefined(); + await expect(sep10ConfigProblems()).resolves.toEqual([]); + }); + + it('names each missing env var, and never echoes a secret value', async () => { + vi.resetModules(); + vi.stubEnv('SEP10_SIGNING_SECRET', ''); + vi.stubEnv('SEP10_HOME_DOMAIN', ''); + vi.stubEnv('SEP10_WEB_AUTH_DOMAIN', ''); + vi.stubEnv('SEP10_CHALLENGE_TIMEOUT_SECONDS', 'not-a-number'); + + const { sep10ConfigProblems: freshProblems } = await import('../../src/modules/payments/services/sep10.js'); + const problems = await freshProblems(); + + expect(problems).toContain('SEP10_SIGNING_SECRET'); + expect(problems).toContain('SEP10_HOME_DOMAIN'); + expect(problems).toContain('SEP10_WEB_AUTH_DOMAIN'); + expect(problems.join(' ')).toContain('SEP10_CHALLENGE_TIMEOUT_SECONDS'); + + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + it('flags a malformed signing secret without printing it', async () => { + vi.resetModules(); + vi.stubEnv('SEP10_SIGNING_SECRET', 'Sdefinitelynotarealsecretkey'); + + const { sep10ConfigProblems: freshProblems } = await import('../../src/modules/payments/services/sep10.js'); + const problems = await freshProblems(); + + expect(problems).toContain('SEP10_SIGNING_SECRET (not a valid Stellar secret key)'); + expect(problems.join(' ')).not.toContain('Sdefinitelynotarealsecretkey'); + + vi.unstubAllEnvs(); + vi.resetModules(); + }); +}); + +describe('SEP-10 challenge account validation', () => { + it('rejects a G-shaped key that fails the strkey checksum', async () => { + // Valid public key with the last character flipped — right shape, + // wrong CRC16 checksum. Must be caught server-side (400), not at + // wallet-signing time. + const valid = Keypair.random().publicKey(); + const corrupted = valid.slice(0, -1) + (valid.endsWith('A') ? 'B' : 'A'); + expect(StrKey.isValidEd25519PublicKey(corrupted)).toBe(false); + + const err = await expectHttpError(createSep10Challenge('user-id-unused', corrupted), 400, 'SEP10_INVALID_ACCOUNT'); + expect(err.message).toContain('valid Stellar public key'); + }); + + it('rejects non-G input (muxed M…, lowercase, wrong length)', async () => { + for (const bad of ['GSHORT', 'g' + 'A'.repeat(55), Keypair.random().publicKey().toLowerCase(), 'M' + 'A'.repeat(55)]) { + await expectHttpError(createSep10Challenge('user-id-unused', bad), 400, 'SEP10_INVALID_ACCOUNT'); + } + }); +}); + +describe('SEP-10 server verification list — structural checks (unit)', () => { + const clientKp = Keypair.random(); + + it('rejects garbage input that is not XDR', async () => { + await expectHttpError(verifySep10Challenge('user-id-unused', 'not-an-xdr-string'), 400, 'SEP10_INVALID_CHALLENGE'); + }); + + it('rejects a transaction whose source is not the server signing account', async () => { + // Built and signed by an attacker-controlled "server" key: the tx + // source is the attacker's account, so it is not our challenge. + const attackerServerKp = Keypair.random(); + const forged = WebAuth.buildChallengeTx( + attackerServerKp, + clientKp.publicKey(), + HOME_DOMAIN, + TIMEOUT, + NETWORK_PASSPHRASE, + WEB_AUTH_DOMAIN, + ); + const err = await expectHttpError(verifySep10Challenge('user-id-unused', forged), 400, 'SEP10_INVALID_CHALLENGE'); + expect(err.message).toContain('source account'); + }); + + it('rejects a challenge not signed by the server (wrong/missing server signature)', async () => { + // Correct source account, but signed by a key that is NOT the + // configured server key — the server signature check must fail. + const { Account, BASE_FEE, Operation, TransactionBuilder } = await import('@stellar/stellar-sdk'); + const impostorKp = Keypair.random(); + const now = Math.floor(Date.now() / 1000); + const forged = new TransactionBuilder(new Account(SERVER_KP.publicKey(), '-1'), { + fee: BASE_FEE, + networkPassphrase: NETWORK_PASSPHRASE, + timebounds: { minTime: now, maxTime: now + TIMEOUT }, + }) + .addOperation( + Operation.manageData({ + name: `${HOME_DOMAIN} auth`, + value: 'a'.repeat(64), + source: clientKp.publicKey(), + }), + ) + .addOperation(Operation.manageData({ name: 'web_auth_domain', value: WEB_AUTH_DOMAIN, source: SERVER_KP.publicKey() })) + .build(); + forged.sign(impostorKp); + + const err = await expectHttpError( + verifySep10Challenge('user-id-unused', forged.toEnvelope().toXDR('base64')), + 400, + 'SEP10_INVALID_CHALLENGE', + ); + expect(err.message).toContain('signed by server'); + }); + + it('rejects a non-zero sequence number', async () => { + const { Account, BASE_FEE, Operation, TransactionBuilder } = await import('@stellar/stellar-sdk'); + const now = Math.floor(Date.now() / 1000); + const forged = new TransactionBuilder(new Account(SERVER_KP.publicKey(), '0'), { + fee: BASE_FEE, + networkPassphrase: NETWORK_PASSPHRASE, + timebounds: { minTime: now, maxTime: now + TIMEOUT }, + }) + .addOperation( + Operation.manageData({ + name: `${HOME_DOMAIN} auth`, + value: 'a'.repeat(64), + source: clientKp.publicKey(), + }), + ) + .addOperation(Operation.manageData({ name: 'web_auth_domain', value: WEB_AUTH_DOMAIN, source: SERVER_KP.publicKey() })) + .build(); + forged.sign(SERVER_KP); + + const err = await expectHttpError( + verifySep10Challenge('user-id-unused', forged.toEnvelope().toXDR('base64')), + 400, + 'SEP10_INVALID_CHALLENGE', + ); + expect(err.message).toContain('sequence number'); + }); + + it('rejects expired time bounds', async () => { + const { Account, BASE_FEE, Operation, TransactionBuilder } = await import('@stellar/stellar-sdk'); + const now = Math.floor(Date.now() / 1000); + // Expired well beyond the SDK's 5-minute clock-drift grace window. + const expired = new TransactionBuilder(new Account(SERVER_KP.publicKey(), '-1'), { + fee: BASE_FEE, + networkPassphrase: NETWORK_PASSPHRASE, + timebounds: { minTime: now - 1800, maxTime: now - 1200 }, + }) + .addOperation( + Operation.manageData({ + name: `${HOME_DOMAIN} auth`, + value: 'a'.repeat(64), + source: clientKp.publicKey(), + }), + ) + .addOperation(Operation.manageData({ name: 'web_auth_domain', value: WEB_AUTH_DOMAIN, source: SERVER_KP.publicKey() })) + .build(); + expired.sign(SERVER_KP); + + const err = await expectHttpError( + verifySep10Challenge('user-id-unused', expired.toEnvelope().toXDR('base64')), + 400, + 'SEP10_INVALID_CHALLENGE', + ); + expect(err.message).toContain('expired'); + }); + + it('rejects a challenge whose Manage Data key uses a different home domain', async () => { + const forged = WebAuth.buildChallengeTx( + SERVER_KP, + clientKp.publicKey(), + 'evil.example', + TIMEOUT, + NETWORK_PASSPHRASE, + WEB_AUTH_DOMAIN, + ); + const err = await expectHttpError(verifySep10Challenge('user-id-unused', forged), 400, 'SEP10_INVALID_CHALLENGE'); + expect(err.message).toContain('home domain'); + }); + + it('rejects a challenge whose web_auth_domain operation value is wrong', async () => { + const forged = WebAuth.buildChallengeTx( + SERVER_KP, + clientKp.publicKey(), + HOME_DOMAIN, + TIMEOUT, + NETWORK_PASSPHRASE, + 'evil.example', // web_auth_domain op will carry this, not ours + ); + await expectHttpError(verifySep10Challenge('user-id-unused', forged), 400, 'SEP10_INVALID_CHALLENGE'); + }); + + it('rejects extra operations that are not server-sourced Manage Data', async () => { + const { Account, BASE_FEE, Operation, TransactionBuilder } = await import('@stellar/stellar-sdk'); + const now = Math.floor(Date.now() / 1000); + const forged = new TransactionBuilder(new Account(SERVER_KP.publicKey(), '-1'), { + fee: BASE_FEE, + networkPassphrase: NETWORK_PASSPHRASE, + timebounds: { minTime: now, maxTime: now + TIMEOUT }, + }) + .addOperation( + Operation.manageData({ + name: `${HOME_DOMAIN} auth`, + value: 'a'.repeat(64), + source: clientKp.publicKey(), + }), + ) + .addOperation(Operation.manageData({ name: 'web_auth_domain', value: WEB_AUTH_DOMAIN, source: SERVER_KP.publicKey() })) + // A client-sourced extra op — a SEP-10 challenge only ever allows + // server-sourced additional operations (plus client_domain). + .addOperation( + Operation.manageData({ name: 'sneaky', value: 'x', source: clientKp.publicKey() }), + ) + .build(); + forged.sign(SERVER_KP); + + await expectHttpError( + verifySep10Challenge('user-id-unused', forged.toEnvelope().toXDR('base64')), + 400, + 'SEP10_INVALID_CHALLENGE', + ); + }); + + it('rejects a challenge whose first operation is not Manage Data', async () => { + const { Account, BASE_FEE, Operation, TransactionBuilder } = await import('@stellar/stellar-sdk'); + const now = Math.floor(Date.now() / 1000); + const forged = new TransactionBuilder(new Account(SERVER_KP.publicKey(), '-1'), { + fee: BASE_FEE, + networkPassphrase: NETWORK_PASSPHRASE, + timebounds: { minTime: now, maxTime: now + TIMEOUT }, + }) + .addOperation(Operation.bumpSequence({ bumpTo: '1', source: clientKp.publicKey() })) + .build(); + forged.sign(SERVER_KP); + + await expectHttpError( + verifySep10Challenge('user-id-unused', forged.toEnvelope().toXDR('base64')), + 400, + 'SEP10_INVALID_CHALLENGE', + ); + }); + + it('rejects a challenge with a nonce that is not 48-random-bytes base64', async () => { + const { Account, BASE_FEE, Operation, TransactionBuilder } = await import('@stellar/stellar-sdk'); + const now = Math.floor(Date.now() / 1000); + const forged = new TransactionBuilder(new Account(SERVER_KP.publicKey(), '-1'), { + fee: BASE_FEE, + networkPassphrase: NETWORK_PASSPHRASE, + timebounds: { minTime: now, maxTime: now + TIMEOUT }, + }) + .addOperation( + Operation.manageData({ + name: `${HOME_DOMAIN} auth`, + value: 'too-short', // not 48 bytes once base64-decoded + source: clientKp.publicKey(), + }), + ) + .addOperation(Operation.manageData({ name: 'web_auth_domain', value: WEB_AUTH_DOMAIN, source: SERVER_KP.publicKey() })) + .build(); + forged.sign(SERVER_KP); + + await expectHttpError( + verifySep10Challenge('user-id-unused', forged.toEnvelope().toXDR('base64')), + 400, + 'SEP10_INVALID_CHALLENGE', + ); + }); +});