From ebbceadfee2c9b629cf44dacf3b21865a82992ce Mon Sep 17 00:00:00 2001 From: affy-exe Date: Fri, 16 Oct 2026 14:23:45 +0000 Subject: [PATCH] feat(payments): SEP-10 Stellar Web Authentication server: challenge and verify endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds generic SEP-10 infrastructure (ADR 0005 step 4, issue #77): before a patient or facility wallet can be trusted as payer or payee, the backend must prove the caller controls that Stellar address. - GET /payments/sep10/challenge?account=G… β€” authenticated + verified email; full strkey checksum validation; builds a server-signed challenge via @stellar/stellar-sdk WebAuth helpers (Manage Data nonce, 5-minute time bounds, home domain + web_auth_domain) and records the issuance binding (userId, account, nonce, expiresAt). - POST /payments/sep10/verify β€” walks the SEP-10 server verification list (structure, sequence 0, server source, time bounds, operation shape, server signature), then client signatures: multisig weight thresholds loaded from Horizon via verifyChallengeTxThreshold, or the spec's master-key rule for unfunded accounts via verifyChallengeTxSigners. Bound to the authenticated user and account; nonce consumed with ONE conditional UPDATE so concurrent replays can't both succeed. - New Sep10Challenge model (unique nonce, userId, account, expiresAt, consumedAt, createdAt, index on expiresAt) + migration. - Dedicated SEP10_SIGNING_SECRET config, separate from payer/releaser keys, validated at startup (names vars, never secrets), documented in .env.example. - Horizon access sits behind an injectable seam, so 15 unit + 18 integration tests cover happy path, replay, concurrency, expiry, wrong signer, multisig thresholds, unfunded accounts, user/account binding and tampering with zero network. Docs: ARCHITECTURE.md Β§4.10, ARCHITECTURE_ESSENTIALS.md data-model map, ROADMAP.md Phase 5.5 + changelog, docs/api/README.md endpoint reference. Closes #77 πŸ€– Generated with Codebuff Co-Authored-By: Codebuff --- ARCHITECTURE.md | 30 ++ ARCHITECTURE_ESSENTIALS.md | 2 + ROADMAP.md | 24 +- backend/.env.example | 22 + backend/package-lock.json | 21 +- backend/package.json | 1 + .../migration.sql | 22 + backend/prisma/schema.prisma | 35 ++ backend/src/config/index.ts | 33 ++ .../src/modules/payments/controllers/index.ts | 3 + .../src/modules/payments/controllers/sep10.ts | 22 + .../modules/payments/repositories/index.ts | 26 + backend/src/modules/payments/routes/index.ts | 21 + .../src/modules/payments/services/index.ts | 13 + .../src/modules/payments/services/sep10.ts | 384 ++++++++++++++ .../payments/services/stellar-sdk-loader.ts | 14 + backend/src/modules/payments/validation.ts | 17 + backend/src/server.ts | 19 +- backend/tests/integration/sep10.test.mts | 502 ++++++++++++++++++ backend/tests/unit/sep10.test.mts | 311 +++++++++++ backend/vitest.config.mts | 6 + docs/api/README.md | 71 ++- 22 files changed, 1570 insertions(+), 29 deletions(-) create mode 100644 backend/prisma/migrations/20261016130509_add_sep10_challenges/migration.sql create mode 100644 backend/src/modules/payments/controllers/sep10.ts create mode 100644 backend/src/modules/payments/services/sep10.ts create mode 100644 backend/src/modules/payments/services/stellar-sdk-loader.ts create mode 100644 backend/tests/integration/sep10.test.mts create mode 100644 backend/tests/unit/sep10.test.mts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8fb9ba9..2883ab5 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -197,6 +197,36 @@ LoginAttempt (issue #35) `User` fields added in Phase 8 live in Β§4.1; the `email_verified_at` / `EmailVerificationToken` pair (issue #34) is in Β§4.1 as well. +### 4.10 SEP-10 wallet-ownership proofs (Phase 5.5, issue #77) +``` +Sep10Challenge + id, user_id (FK -> User), account (the G… account the challenge was + issued for), nonce (unique β€” the 48-random-bytes base64 value in the + challenge's " auth" Manage Data op), expires_at, + consumed_at (nullable), created_at + # One row per issued SEP-10 challenge (GET /payments/sep10/challenge); + redeemed exactly once by POST /payments/sep10/verify. Consumption is + a single conditional UPDATE (consumed_at IS NULL AND expires_at > + now), so concurrent replays can't both succeed β€” the same DB-level + shape as Β§5.1's booking guarantee and EmailVerificationToken + redemption (Β§4.1). + # Deliberately no XDR/signature/PHI columns: the challenge is + re-derived from the submitted XDR at verify time; this ledger only + proves "we issued this nonce to this user for this account, once, + within its window". Bound to the issuing user at issue time, so a + signed challenge is never redeemable by another user or for another + account. + # Backs ADR 0005 step 4 (prove a patient/facility controls a Stellar + address before it is trusted as payer/payee). The SEP-10 server + signing key is its own identity (SEP10_SIGNING_SECRET), never the + payer/releaser keys. Owns no new endpoint semantics beyond + /payments/sep10/* β€” see docs/api/README.md. +``` +(`PaymentIntent` / `WalletLink` from Phase 5.5 are documented in +`docs/adr/0004-custodial-payments-v1.md` rather than this section; the +SEP-10 ledger is the first payments model added directly here because it +is a user-identity-adjacent table with a FK to `User`.) + ## 5. Key flows (sequence-level, not code) ### 5.1 Booking (concurrency-safe) diff --git a/ARCHITECTURE_ESSENTIALS.md b/ARCHITECTURE_ESSENTIALS.md index a5393e8..f234540 100644 --- a/ARCHITECTURE_ESSENTIALS.md +++ b/ARCHITECTURE_ESSENTIALS.md @@ -28,12 +28,14 @@ Quick-reference only. Full reasoning lives in `ARCHITECTURE.md`. Full product sc `User` β†’ `EmailVerificationToken` (Phase 8, issue #34 β€” hashed/expiring, single-use; `User.emailVerifiedAt` gates booking + payment mutations, never login/browsing). `LoginAttempt` (Phase 8, issue #35 β€” Postgres-backed login throttle buckets, one per normalised email and per client IP; counters move only via a conditional upsert). `FacilityProfile` β†’ `WalletLink` (payout address) ; `PatientProfile`/`FacilityProfile` β†’ `PaymentIntent` (opaque `orderType`/`orderId` β€” see docs/adr/0004) +`User` β†’ `Sep10Challenge` (SEP-10 wallet-ownership proofs, issue #77 β€” single-use nonce binding a signed challenge to the issuing user + Stellar account; consumed atomically) ## Payments (Phase 5.5 β€” see docs/adr/0004-custodial-payments-v1.md) - **Custodial v1**: `docforum-core` holds its own Stellar payer + releaser identities (`PLATFORM_PAYER_SECRET`/`PLATFORM_RELEASER_SECRET` env vars). Patients never hold or sign with a wallet β€” the only reason this is possible without violating `docforum-web`'s "no direct Stellar calls" hard rule. - `WalletLink` is scoped to the **facility** (the on-chain payee), not the patient. - `PaymentIntent.orderType`/`orderId` are opaque, unvalidated β€” `Prescription`/`LabOrder` aren't real records yet (Phase 4). Don't build on top of these assuming they're FK-validated. - `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 501c0f5..6ad2cb3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -87,6 +87,7 @@ for status and issue links. - [ ] Idempotent escrow funding + on-chain reconcile job (`fundPaymentIntent` is check-then-act β€” docforum-escrow threat model Β§5). Tracked as [issue #24](https://github.com/DocForum/docforum-core/issues/24) (High, 200 pts). - [ ] ADR: releaser key custody β€” multisig, KMS, or per-period rotation (threat model T1/T2). Tracked as [issue #25](https://github.com/DocForum/docforum-core/issues/25) (Medium, 150 pts). - [ ] Checksum-validate facility wallet keys, not just regex. Tracked as [issue #26](https://github.com/DocForum/docforum-core/issues/26) (Trivial, 100 pts). +- [x] SEP-10 Stellar Web Authentication server (ADR 0005 step 4): `GET /payments/sep10/challenge` + `POST /payments/sep10/verify`, full SEP-10 server verification list (incl. multisig thresholds from Horizon and the unfunded-account master-key rule), a single-use `Sep10Challenge` nonce ledger consumed atomically, and a dedicated `SEP10_SIGNING_SECRET` separate from the payer/releaser keys. Generic infrastructure the non-custodial wallet linking builds on β€” the endpoints themselves issue no session and change no custodial behaviour. Tracked as [issue #77](https://github.com/DocForum/docforum-core/issues/77) (High, 200 pts) β€” **Done**. - [ ] Non-custodial patient payments (v2): patients fund escrows from their own Stellar wallet, custodial kept as fallback. **Proposed** in `docs/adr/0005-non-custodial-patient-payments.md`; implementation issues to be opened once it's Accepted. - [ ] Upgrade to `@docforum/escrow-sdk` 0.3.0 and the current testnet contract (`refund_after`, balance-delta check, `getEscrow`) β€” step 1 of ADR 0005's plan, but worth doing regardless. Tracked as [issue #32](https://github.com/DocForum/docforum-core/issues/32) (Medium, 150 pts); depends on `docforum-escrow` publishing `sdk-v0.3.0`. - [ ] Doc reconciliation: PRD Β§8.1.6 (facility fulfillment integrity) β€” not marked resolved; this phase proves the payment *mechanism* works, not the fulfillment-integrity question, which still needs real `FulfillmentRecord`/facility-matching (Phase 5) to actually address. @@ -344,11 +345,24 @@ 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/.env.example b/backend/.env.example index 7846963..2e034af 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -43,3 +43,25 @@ PLATFORM_RELEASER_SECRET= ESCROW_CONTRACT_ID=CAXESEEAJILFFCN3PH2HHNMWAAHLEB64UTJHZ6IP7C5BBICT3S3VODSM # Native XLM SAC on testnet (stellar contract id asset --asset native --network testnet) ESCROW_TOKEN_ID=CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC + +# SEP-10 Stellar Web Authentication (issue #77) β€” GET/POST /payments/sep10/*. +# The SEP10_SIGNING_SECRET is a DEDICATED identity, separate from +# PLATFORM_PAYER_SECRET/PLATFORM_RELEASER_SECRET β€” it only ever signs web-auth +# challenges, so it can be rotated without touching money-moving keys. All four +# SEP10_SIGNING_SECRET/SEP10_HOME_DOMAIN/SEP10_WEB_AUTH_DOMAIN are required at +# server startup (validateSep10Config) β€” the server refuses to boot without +# them. Secrets are never logged. +# Generate a keypair with: node -e "const {Keypair}=require('@stellar/stellar-sdk');console.log(Keypair.random().secret())" +SEP10_SIGNING_SECRET= +# Home domain of the service requiring authentication (the first Manage Data +# op key is " auth"). +SEP10_HOME_DOMAIN=docforum.github.io +# Domain of THIS server that issues the challenges (the "web_auth_domain" +# Manage Data op value) β€” the API host, not the frontend. +SEP10_WEB_AUTH_DOMAIN=docforum-core.onrender.com +# Optional β€” default to testnet, matching this repo's testnet-only payments +# constraint (docforum-escrow hard rule 4): +# SEP10_NETWORK_PASSPHRASE='Test SDF Network ; September 2015' +# SEP10_HORIZON_URL=https://horizon-testnet.stellar.org +# Challenge time bounds in seconds (default 300): +# SEP10_CHALLENGE_TIMEOUT_SECONDS=300 diff --git a/backend/package-lock.json b/backend/package-lock.json index c9c6bca..c073dc9 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@docforum/escrow-sdk": "https://github.com/DocForum/docforum-escrow/releases/download/sdk-v0.2.0/docforum-escrow-sdk-0.2.0.tgz", "@prisma/client": "^6.19.3", + "@stellar/stellar-sdk": "^16.0.1", "bcryptjs": "^3.0.3", "cookie-parser": "^1.4.7", "cors": "^2.8.6", @@ -1387,7 +1388,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1404,7 +1404,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1421,7 +1420,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1438,7 +1436,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1455,7 +1452,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1472,7 +1468,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1489,7 +1484,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1506,7 +1500,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1523,7 +1516,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1540,7 +1532,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1557,7 +1548,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1574,7 +1564,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1591,7 +1580,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1608,7 +1596,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1625,7 +1612,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1642,7 +1628,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1659,7 +1644,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1676,7 +1660,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1693,7 +1676,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1710,7 +1692,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ diff --git a/backend/package.json b/backend/package.json index ebe5a93..a9483bb 100644 --- a/backend/package.json +++ b/backend/package.json @@ -18,6 +18,7 @@ "dependencies": { "@docforum/escrow-sdk": "https://github.com/DocForum/docforum-escrow/releases/download/sdk-v0.2.0/docforum-escrow-sdk-0.2.0.tgz", "@prisma/client": "^6.19.3", + "@stellar/stellar-sdk": "^16.0.1", "bcryptjs": "^3.0.3", "cookie-parser": "^1.4.7", "cors": "^2.8.6", 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 5ceed14..8564d28 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -114,6 +114,7 @@ model User { facilityProfile FacilityProfile? emailVerificationTokens EmailVerificationToken[] + sep10Challenges Sep10Challenge[] @@index([role]) } @@ -440,6 +441,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/config/index.ts b/backend/src/config/index.ts index 1e54027..706bb64 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -118,4 +118,37 @@ export const config = { escrowContractId: process.env.ESCROW_CONTRACT_ID, escrowTokenId: process.env.ESCROW_TOKEN_ID, }, + // SEP-10 Stellar Web Authentication (issue #77, ADR 0005 step 4). + // + // The SEP-10 signing key is deliberately its own identity β€” never + // PLATFORM_PAYER_SECRET or PLATFORM_RELEASER_SECRET. It exists only to + // sign/verify web-auth challenges, so if it ever leaks the blast radius + // is "someone can mint challenge transactions we would accept", not + // "someone can move platform funds". Rotating it doesn't touch the + // money-moving keys and vice versa. + // + // Unlike the payer/releaser keys above, this block IS validated at + // process startup (server.ts calls validateSep10Config()): issue #77 + // explicitly asks for startup validation, and a typo'd/missing web-auth + // key would otherwise only surface on the first challenge request. The + // money keys stay lazily validated per ADR 0004 β€” booting the app for + // auth/booking work must not require payments to be configured. + // + // Secrets are never logged: startup validation reports the ENV VAR NAME + // only, and no code path prints the secret value. + sep10: { + signingSecret: process.env.SEP10_SIGNING_SECRET, + homeDomain: process.env.SEP10_HOME_DOMAIN, + webAuthDomain: process.env.SEP10_WEB_AUTH_DOMAIN, + // Testnet defaults, matching this repo's testnet-only payments + // constraint (docforum-escrow hard rule 4). Overridable per + // environment when a deployment points elsewhere. + networkPassphrase: process.env.SEP10_NETWORK_PASSPHRASE ?? 'Test SDF Network ; September 2015', + horizonUrl: process.env.SEP10_HORIZON_URL ?? 'https://horizon-testnet.stellar.org', + // Challenge time bounds. SEP-10's listed verification step only + // requires *bounded* timebounds; 300s is @stellar/stellar-sdk's own + // default and what issue #77 asks for. (SEP-10's prose recommends + // 900s β€” 300s is the stricter choice and is what we build and test.) + challengeTimeoutSeconds: Number(process.env.SEP10_CHALLENGE_TIMEOUT_SECONDS ?? 300), + }, } as const; diff --git a/backend/src/modules/payments/controllers/index.ts b/backend/src/modules/payments/controllers/index.ts index ebe6a91..fa4dba9 100644 --- a/backend/src/modules/payments/controllers/index.ts +++ b/backend/src/modules/payments/controllers/index.ts @@ -72,3 +72,6 @@ export async function refundIntent(req: Request, res: Response) { const intent = await paymentsService.refundPaymentIntent(id); res.status(200).json(serializeIntent(intent)); } + +// SEP-10 Stellar Web Authentication (issue #77) β€” see ./sep10.ts. +export { sep10Challenge, sep10Verify } from './sep10'; 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/repositories/index.ts b/backend/src/modules/payments/repositories/index.ts index 7958616..7fed3d4 100644 --- a/backend/src/modules/payments/repositories/index.ts +++ b/backend/src/modules/payments/repositories/index.ts @@ -46,3 +46,29 @@ export function markEscrowed(id: string, input: { escrowId: bigint; stellarTxHas export function markStatus(id: string, status: PaymentIntentStatus) { return prisma.paymentIntent.update({ where: { id }, data: { status } }); } + +// --- SEP-10 (Stellar Web Authentication) β€” issue #77 ---------------------- +// One row per issued challenge. The nonce comes from the challenge +// transaction's first Manage Data op; its uniqueness makes a challenge +// single-use at the DB level. + +export function createSep10Challenge(input: { userId: string; account: string; nonce: string; expiresAt: Date }) { + return prisma.sep10Challenge.create({ data: input }); +} + +export function findSep10ChallengeByNonce(nonce: string) { + return prisma.sep10Challenge.findUnique({ where: { nonce } }); +} + +/** Atomically consumes a challenge β€” ONE conditional UPDATE, no + * read-then-write. Only a row that is still unconsumed AND unexpired + * moves to `consumedAt`; the returned `count` tells the caller whether + * THIS request won the race. Two concurrent replays of the same signed + * challenge can never both see `count === 1`. Same DB-level pattern as + * booking's conditional slot update (ARCHITECTURE.md Β§5.1). */ +export function consumeSep10Challenge(nonce: string, consumedAt: Date) { + return prisma.sep10Challenge.updateMany({ + where: { nonce, consumedAt: null, expiresAt: { gt: consumedAt } }, + data: { consumedAt }, + }); +} diff --git a/backend/src/modules/payments/routes/index.ts b/backend/src/modules/payments/routes/index.ts index 7f80503..01131aa 100644 --- a/backend/src/modules/payments/routes/index.ts +++ b/backend/src/modules/payments/routes/index.ts @@ -46,3 +46,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 c36cc05..e90c751 100644 --- a/backend/src/modules/payments/services/index.ts +++ b/backend/src/modules/payments/services/index.ts @@ -14,6 +14,19 @@ import { config } from '../../../config'; import * as paymentsRepo from '../repositories'; import { loadEscrowSdk } 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 { platformPayerSecret, platformReleaserSecret, escrowContractId, escrowTokenId } = config.payments; const missing = Object.entries({ platformPayerSecret, platformReleaserSecret, escrowContractId, 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 3513c8d..4644be4 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).'), @@ -25,3 +27,18 @@ export const createPaymentIntentSchema = z.object({ .regex(/^[1-9][0-9]*$/, 'amountStroops must be a positive integer string.') .transform((value) => BigInt(value)), }); + +// SEP-10 (issue #77). Shape-only gate: the checksum-accurate strkey +// validation (and the 400) happens in services/sep10.ts via +// StrKey.isValidEd25519PublicKey, so the service layer is safe for +// non-HTTP callers too. +export const sep10ChallengeQuerySchema = z.object({ + account: z + .string() + .regex(/^G[A-Z2-7]{55}$/, 'account must be a valid Stellar public key (starts with G, 56 chars).'), +}); + +export const sep10VerifySchema = z.object({ + // base64 TransactionEnvelope XDR of the wallet-signed challenge. + transaction: z.string().min(1), +}); diff --git a/backend/src/server.ts b/backend/src/server.ts index 52cd7e2..f470c82 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -1,12 +1,27 @@ import { createApp } from './app'; import { config } from './config'; import { pruneLoginAttempts } from './modules/auth/services'; +import { validateSep10Config } from './modules/payments/services'; const app = createApp(); -app.listen(config.port, () => { +// Fail fast on a missing/invalid SEP-10 web-auth configuration (issue +// #77): a typo'd signing secret should stop boot, not surface as a 500 +// on the first challenge request. Reports env var NAMES only β€” never a +// secret value. Placed here (not at config import) so test suites, which +// only build the app, aren't forced to boot with payments configured. +async function start(): Promise { + await validateSep10Config(); + app.listen(config.port, () => { + // eslint-disable-next-line no-console + console.log(`docforum-core backend listening on :${config.port}`); + }); +} + +start().catch((err) => { // eslint-disable-next-line no-console - console.log(`docforum-core backend listening on :${config.port}`); + console.error(err instanceof Error ? err.message : err); + process.exit(1); }); // Login-throttle cleanup (Phase 8, issue #35). A plain interval, per 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', + ); + }); +}); diff --git a/backend/vitest.config.mts b/backend/vitest.config.mts index c500601..e99c540 100644 --- a/backend/vitest.config.mts +++ b/backend/vitest.config.mts @@ -10,6 +10,12 @@ export default defineConfig({ env: { JWT_ACCESS_SECRET: 'test-access-secret', JWT_REFRESH_SECRET: 'test-refresh-secret', + // SEP-10 (issue #77) β€” a throwaway keypair used ONLY by the test + // suite; it holds no funds and exists to make the challenge/verify + // code paths deterministic offline. Not a real deployment secret. + SEP10_SIGNING_SECRET: 'SC3Z2YBPD7KGXC73CTBNDRGQOIUSAZUH445QIB2PPKWO7CXULRY75JGJ', + SEP10_HOME_DOMAIN: 'docforum.test', + SEP10_WEB_AUTH_DOMAIN: 'api.docforum.test', // Port 5433, not 5432 β€” tests/integration/booking.test.mts spins up // its own throwaway Postgres cluster on this port so the suite is // self-contained (no pre-existing DB required). A distinct port diff --git a/docs/api/README.md b/docs/api/README.md index ee29323..0a21c2c 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -151,9 +151,76 @@ drift apart. See `docs/adr/0003` for why production needs `SameSite=None`. | `POST /appointments` | patient | `{ availabilitySlotId }` | **The concurrency-critical endpoint** β€” see `ARCHITECTURE.md` Β§5.1 and `backend/tests/integration/booking.test.mts`. `409` if the slot is no longer open. | | `GET /appointments/mine` | patient | β€” | Caller's own appointments, most recent first. | +## Payments (`/payments`) β€” `backend/src/modules/payments` + +Custodial v1 escrow actions (`POST /payments/wallet-link`, +`GET/POST /payments/intents/...`) exist and are admin/facility-gated β€” +see `docs/adr/0004-custodial-payments-v1.md` for why. The table below +covers the SEP-10 endpoints added for the non-custodial flow +(`docs/adr/0005`, issue #77). + +### SEP-10 Stellar Web Authentication (issue #77) + +Proves the caller controls a Stellar address before it can ever be +trusted as a payer or payee. **Both endpoints require a verified-email +account** (same guard as every payments mutation β€” issue #34), and +neither issues a DocForum session: your normal access token +authenticates the *user*; SEP-10 proves control of the *wallet*. + +| Method & path | Auth | Body/query | Notes | +|---|---|---|---| +| `GET /payments/sep10/challenge` | access token (+ verified email) | `?account=G…` | Returns `200 { transaction, expires_in, network_passphrase }` β€” the base64 XDR of a server-signed SEP-10 challenge (5-minute time bounds by default, `SEP10_CHALLENGE_TIMEOUT_SECONDS`). Hand `transaction` to the user's wallet for signing (build/sign/submit all stay server-side per `docforum-web`'s hard rule β€” the browser only relays XDR). `400` if `account` is missing, malformed, or fails the Stellar strkey checksum (`code: 'SEP10_INVALID_ACCOUNT'`). | +| `POST /payments/sep10/verify` | access token (+ verified email) | `{ transaction }` (signed challenge XDR) | Runs the full SEP-10 server verification list plus this server's own single-use/binding rules. Returns `200 { account, signers, userId }` on success, and the nonce is consumed β€” a challenge is single-use, even concurrently. | + +**What verify checks, in order** (SEP-10's server verification list): + +1. XDR deserializes as a transaction; sequence number is 0; source is + the server's SEP-10 signing account; time bounds are present, bounded + and unexpired. +2. The first operation is Manage Data, sourced by `account`, keyed + ` auth`, with a spec-sized (48-random-bytes base64) + nonce; later operations are server-sourced Manage Data including + `web_auth_domain` equal to this server's configured domain. +3. The server's signature on the challenge is valid. +4. The nonce exists in this server's issuance ledger (`Sep10Challenge`), + is still unconsumed and unexpired, and is bound to **the calling user + and the exact account in the challenge** β€” a signed challenge is + never redeemable by another user or for another account. +5. Client signatures: for a **funded** account, signer weights loaded + from Horizon must meet the account's **medium threshold**; + for an **unfunded account** (not yet created on the network β€” no + signers/thresholds exist to weigh), the challenge must be signed by + the account's **master key**, per the SEP-10 spec. A Horizon outage is + a `502`, never silently treated as "unfunded". +6. The nonce is consumed with a single conditional UPDATE β€” of several + concurrent submissions of the same signed challenge, exactly one + succeeds. + +**Errors from `POST /payments/sep10/verify`** (all also carry a `message`): + +| Status | `code` | Meaning | +|---|---|---| +| 400 | `SEP10_INVALID_CHALLENGE` | Not a valid/well-formed SEP-10 challenge: bad XDR, wrong source, non-zero sequence, expired time bounds, wrong home domain or `web_auth_domain`, extra operations, or a missing/wrong server signature. | +| 401 | `SEP10_INVALID_SIGNATURE` | Well-formed challenge, but the client signature is missing, from a non-signer, or below the account's medium threshold (incl. the unfunded-account master-key rule). A failed verification does NOT burn the nonce. | +| 401 | `SEP10_CHALLENGE_UNKNOWN` | The challenge's nonce was never issued by this server (or its record was pruned). | +| 401 | `SEP10_CHALLENGE_EXPIRED` | The issued nonce record is past its expiry (stricter than the transaction time bounds). | +| 401 | `SEP10_CHALLENGE_ALREADY_USED` | Replay β€” the nonce is already consumed. | +| 403 | `SEP10_CHALLENGE_NOT_FOR_CALLER` | The challenge was issued to a different user or for a different account. | +| 401 / 403 | β€” | Standard auth failures: no/invalid access token (`401`), unverified email (`403 EMAIL_NOT_VERIFIED`). | + +**Configuration** (see `.env.example`; the first three are required at +server startup β€” the server refuses to boot without them, and secrets +are never logged): `SEP10_SIGNING_SECRET` (a dedicated key, separate +from `PLATFORM_PAYER_SECRET`/`PLATFORM_RELEASER_SECRET` β€” it only signs +web-auth challenges, so it can be rotated without touching money-moving +keys), `SEP10_HOME_DOMAIN`, `SEP10_WEB_AUTH_DOMAIN`, plus optional +`SEP10_NETWORK_PASSPHRASE`, `SEP10_HORIZON_URL` (both default to +testnet) and `SEP10_CHALLENGE_TIMEOUT_SECONDS` (default 300). + ## Not yet implemented Everything else in `ARCHITECTURE.md` Β§3's module list (`patients`, `facilities`, `consultations`, `referrals`, `prescriptions`, `lab-orders`, -`pharmacy-orders`, `payments`, `notifications`) has no routes yet β€” Phase -2 onward, see `ROADMAP.md`. +`pharmacy-orders`, `notifications`) has no routes yet β€” Phase +2 onward, see `ROADMAP.md`. (`payments` has routes β€” see the Payments +section above.)