Skip to content

feat(auth): SEP-10 challenge auth with short-lived EdDSA JWTs - #573

Merged
james2177 merged 4 commits into
stellar-vortex-protocol:mainfrom
benedictworks-home:feat/sep10-solver-auth
Oct 6, 2026
Merged

james2177 merged 4 commits into
stellar-vortex-protocol:mainfrom
benedictworks-home:feat/sep10-solver-auth

Conversation

@benedictworks-home

@benedictworks-home benedictworks-home commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Implements #442: SEP-10-style challenge authentication for solver bots, plus short-lived EdDSA (Ed25519) session JWTs — so bots no longer need a long-lived shared secret to reach the WebSocket feed and guarded REST endpoints.

What's new

  • GET /api/v1/auth/challenge?account=G... — issues a server-signed SEP-10 challenge bound to this deployment: <home-domain> auth manageData op (single-use 48-byte nonce, source = client account), web_auth_domain op, 5-minute timebounds.
  • POST /api/v1/auth/token — exchanges the client-signed challenge for a 15-minute JWT with sub (G-address) and role (admin > solver > user) claims.
    • Verification uses the SDK's readChallengeTx + verifyChallengeTxThreshold against Horizon signers/thresholds — multisig-aware (weight ≥ max(1, thresholds.medium)), so accounts whose thresholds are all zero still must prove key possession.
    • The nonce is consumed atomically and only after signatures verify: challenges are strictly single-use (replay rejected), but a failed exchange (bad signature / unmet threshold) never burns a legitimate challenge.
    • Client accounts must exist on the network (unfunded/pre-authorized SEP-10 path is out of scope).
  • Single-use nonce store: process-local memory by default; SEP10_NONCE_STORE=redis uses Redis with a Lua GET+DEL so concurrent exchanges across replicas can only succeed once (required for multi-replica deployments).
  • WebSocket gateway (intents.gateway.ts): authenticateJwt tries the EdDSA token first, then falls back to legacy HS256 (AUTH_JWT_SECRET). Works via handshake (?token= / Authorization: Bearer) and first message ({"type":"auth","token":"..."}) — both paths already existed; no protocol change.
  • SolverJwtGuard: same EdDSA-first + HS256 fallback, so [High] Scoped Solver API Credentials with Rotation and Revocation #443's credential endpoints accept SEP-10 tokens.
  • Key material (src/auth/sep10/sep10-keys.ts): challenge signing reuses the existing STELLAR_SIGNER_SECRET_KEY; JWTs are signed with SEP10_JWT_SIGNING_KEY (Ed25519 PKCS#8 — PEM, literal-\n PEM, or base64 DER; must be distinct from the Soroban signer key). Empty values generate ephemeral dev-only keys with a loud warning; invalid non-empty values fail at boot.

New environment variables

Var Default Notes
SEP10_HOME_DOMAIN localhost Home domain + web_auth_domain in challenges; set to the real API domain in production
SEP10_JWT_SIGNING_KEY (empty) Required when NODE_ENV=production; openssl genpkey -algorithm ed25519
SEP10_ADMIN_ACCOUNTS (empty) Comma-separated G-addresses issued role=admin
SEP10_NONCE_STORE memory redis for multi-replica replay protection

Added to src/config/env.validation.ts and all four .env*.example files (keeps check:env-drift green).

Files

  • New: src/auth/sep10/ (keys, nonce store, Horizon account loader, DTOs, service, controller, module) + tests
  • Modified: src/common/jwt.ts (EdDSA sign/verify/key-loading helpers), src/intents/intents.gateway.ts, src/auth/solver-credentials/solver-jwt.guard.ts, src/app.module.ts, config (configuration.ts / env.validation.ts / env examples), docs/solver-onboarding.md (new §2a with the curl flow), e2e SDK mock (WebAuth re-export)

Verification

  • Tests written (run in CI): src/auth/sep10/sep10.service.spec.ts — challenge conformance against the real SDK, roles, multisig threshold (incl. "failed attempt doesn't burn the nonce"), replay, never-issued nonce, expiry via fake timers, wrong home domain, unknown account, plus JWT-helper and key-material edge cases; src/auth/solver-credentials/solver-jwt.guard.spec.ts — EdDSA acceptance/expiry/foreign-key, HS256 fallback, extraction, subject match.
  • Local checks run on merge commit e815544 (Node 24, Windows): tsc --noEmit clean; eslint src test scripts packages … 0 errors (144 warnings, same baseline as main); check:env-drift pass; check:quarantine pass; check:migrations --base c48de27 pass (3 changed migrations, no unsafe DDL, every one has down.sql); jest --config scripts/jest.config.js pass; full unit suite in 4 shards (--shard=n/4 --maxWorkers=3): 1746 passed, 97 skipped, 0 failed.
  • Not run locally: docker-based jobs, psql-backed integration tests, gitleaks, semgrep, and coverage upload (no docker/psql in this environment) — CI is the authority for those.

Merge with upstream/main (e815544)

The branch merges upstream main at c48de27. That tree itself ships ~10 parse-broken files (leaderboard-query.ts, in-memory-intents.repository.spec.ts, intents.types.ts, list-intents.dto.ts, configuration.ts, env.validation.ts, stats.service.ts, intents.controller.ts, intents.gateway.ts, intents.module.ts, stellar-signature.ts) plus 14 zero-byte files (incl. .github/workflows/ci.yml, src/soroban/soroban.service.ts, src/treasury/treasury.service.ts), so conflicts were resolved conservatively:

Fixes made during verification:

(The two fix: commits 055a25d/16e86d0 that the branch started with remain in history; after the merge, shared files follow the repaired 2a922ee content.)

Assumptions

  • Client accounts must already exist on the network; the SEP-10 unfunded-account path is out of scope.
  • Threshold choice: max(1, account.thresholds.medium) — medium is the ecosystem norm, floored at 1 so all-zero-threshold accounts still need one valid signature.
  • Challenge signing key reuses the existing (until now unconsumed) STELLAR_SIGNER_SECRET_KEY; the JWT key is a separate new Ed25519 key.
  • Signed-message endpoints (accept/fill canonical signatures) are untouched; user wallet login UX remains out of scope.

Checklist

  • Tests written for new behaviour
  • TSDoc on public APIs
  • docs/solver-onboarding.md updated
  • New env vars in env.validation.ts + every .env*.example
  • Local parse / duplicate-binding / env-drift checks clean
  • npm run lint / typecheck / test run locally — tsc clean, eslint 0 errors, 1746 unit tests green on the merge commit
  • docker / psql / gitleaks / semgrep / coverage jobs — not runnable in this environment; CI is the authority

Closes #442

A run of merge commits between 09:48 and 11:28 today left 55
tracked files at zero bytes on main, including package.json,
prisma/schema.prisma, src/app.module.ts, src/intents/intents.gateway.ts
and src/config/env.validation.ts. CI on main is red because of it.

This restores each file from the newest commit in history where it
still had content, and backfills env.validation.ts with the 29 keys
that .env*.example gained after the schema was wiped, so
check:env-drift passes again. Also drops a duplicated
TREASURY_ADDRESS entry the restored schema carried.
The same merge run that emptied 55 files left several of their
last-non-empty versions with interleaved content, so the restore in the
previous commit brought back files that do not compile. This repairs
every damaged site found:

- intents.gateway.ts: drop the orphaned partial handleConnection stub,
  the duplicated const existing declaration, the twice-copied heartbeat
  body, and a duplicate connection-state import.
- configuration.ts / solver-registry.service.spec.ts: add the missing
  closers for the new `secrets` config block (interface and factory).
- stellar-signature.ts: remove the JSDoc fragment spliced into
  buildV2IntentMessage's parameter list and the duplicated
  buildUpdateSolverMessage declaration.
- soroban.service.ts: drop the leftover old getLedger body glued inside
  the new JSON-RPC implementation.
- Drop duplicate imports in fill-intent.dto, governance.module,
  tokens.service, solvers.controller, shadow.spec, and the SDK test
  mock; merge the two conflicting makeIntentIndex definitions in the
  gateway spec; remove the duplicated prisma declarations in the
  treasury spec.
- package.json + package-lock.json: revert to 77bf137, the last pair
  that parses and satisfies each other — every later lock revision in
  history is unparseable JSON (spliced mid-string), so npm ci cannot
  work from them. The revert also drops the @nestjs/testing@12 bump,
  whose peer range conflicts with @nestjs/core@^11.

Verified with Node's module.stripTypeScriptTypes (all 50 changed .ts
files parse) plus a duplicate import/declaration scan and JSON.parse on
the lockfile. Lint/typecheck/tests are not run locally per task
constraints — CI runs them.
…ellar-vortex-protocol#442)

Implement SEP-10-style challenge authentication for solver bots and a
short-lived EdDSA (Ed25519) session JWT:

- GET /api/v1/auth/challenge?account=G... issues a server-signed
  challenge (home-domain + web_auth_domain manageData ops, single-use
  48-byte nonce, 5-minute timebounds).
- POST /api/v1/auth/token exchanges the client-signed challenge for a
  15-minute JWT with sub/role claims. Verification uses the SDK's
  readChallengeTx + verifyChallengeTxThreshold (medium threshold, floored
  at 1) against Horizon signers; the nonce is consumed atomically and
  only after signatures verify, so challenges are single-use but failed
  attempts cannot burn them.
- Roles: admin (SEP10_ADMIN_ACCOUNTS) > solver (registered) > user.
- Nonce store: process-local memory by default, shared Redis
  (SEP10_NONCE_STORE=redis) for multi-replica replay protection.
- WS gateway and SolverJwtGuard accept the EdDSA token first and keep
  HS256 (AUTH_JWT_SECRET) as a fallback for backward compatibility.
- Key material: challenge signing reuses STELLAR_SIGNER_SECRET_KEY;
  JWTs are signed with SEP10_JWT_SIGNING_KEY (Ed25519 PKCS#8, required
  in production, distinct from the Soroban signer key).

New env vars: SEP10_HOME_DOMAIN, SEP10_JWT_SIGNING_KEY,
SEP10_ADMIN_ACCOUNTS, SEP10_NONCE_STORE (env.validation.ts + all four
.env*.example files).

Tests: sep10.service.spec.ts (challenge conformance, roles, multisig
threshold, replay, unknown nonce, expiry via fake timers, wrong home
domain, unknown account, JWT helper + key-material edge cases) and
solver-jwt.guard.spec.ts. Docs: solver-onboarding.md §2a.

Lint/typecheck/tests were not run locally (no node_modules); local
checks: stripTypeScriptTypes parse (62 files), duplicate-binding scan,
env-drift port — all clean.
Resolve all 39 conflicted paths against upstream main (c48de27).

Resolution strategy:

- Upstream main itself ships torn files (leaderboard-query.ts,
  in-memory-intents.repository.spec.ts and roughly ten more with parse
  errors), so main's blob cannot be taken wholesale. Shared files that
  issue stellar-vortex-protocol#442 does not own are resynced to the simulation-harness tree
  (2a922ee, verified with tsc, eslint and 1682 passing tests), which is
  main plus those files repaired.
- Issue stellar-vortex-protocol#442-owned files keep the branch content merged with main's
  additions (SEP-10 auth, its config/env/docs/gateway touchpoints).
- prisma/schema.prisma is a hybrid: the harness schema plus main's
  @@index([solver]).
- tools/* and tsconfig.tools.json intentionally stay absent
  (harness-only files).

Repairs applied on top:

- intents.gateway.ts: rebased onto the simulation-harness gateway
  (2a922ee) and re-applied only issue stellar-vortex-protocol#442's delta (+17/-3): the SEP-10
  EdDSA public key plus the WS handshake JWT path (Authorization:
  Bearer / ?token= / first auth message) documented in
  docs/solver-onboarding.md. Config/feed stay @optional() so direct
  unit construction and DI graphs both resolve.
- Restored torn leaderboard-query.ts and the in-memory intents spec from
  2a922ee, plus the processed_events / intent_state_pending_variants
  migration files missing after the merge.
- stellar-sdk mock: re-exported WebAuth/Utils/Config for the SEP-10
  challenge paths.
- .env.example: backfilled the solver-reputation (stellar-vortex-protocol#444) variables so
  check:env-drift passes.
- common/jwt.ts: load Ed25519 private keys from DER directly instead of
  re-wrapping PEM (OpenSSL 3 rejected the double-newline encoding).
- env.validation.spec.ts: production fixture now carries
  SEP10_JWT_SIGNING_KEY (required in production since stellar-vortex-protocol#442).

Local verification: tsc --noEmit clean, eslint clean (warnings only),
check:env-drift and check:quarantine pass, scripts jest config green,
full jest suite green in 4 shards (1746 passed, 97 skipped, 0 failed).
check:migrations runs post-commit with --base c48de27 (Windows cmd
cannot parse HEAD^1).
@james2177
james2177 merged commit 1eb7978 into stellar-vortex-protocol:main Oct 6, 2026
19 of 32 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[High] SEP-10-Style Challenge Authentication for Solvers with Short-Lived JWTs

2 participants