Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,20 @@ STELLAR_HORIZON_URL=
# SEP-0010 Web Authentication
# Generate with: node -e "console.log(require('stellar-sdk').Keypair.random().secret())"
SEP10_SERVER_SIGNING_KEY=your_secret_key_here
HOME_DOMAIN=localhost
JWT_SECRET=your_jwt_secret_here

# SEP-10 rate limiting (optional overrides)
SEP10_CHALLENGE_RATE_LIMIT_WINDOW_MS=60000
SEP10_CHALLENGE_RATE_LIMIT_MAX=20
SEP10_VERIFY_RATE_LIMIT_WINDOW_MS=60000
SEP10_VERIFY_RATE_LIMIT_MAX=10

# SEP-12 KYC rate limiting (optional overrides)
SEP12_RATE_LIMIT_WINDOW_MS=900000
SEP12_RATE_LIMIT_MAX=50
SEP12_RATE_LIMIT_WRITE_WINDOW_MS=3600000
SEP12_RATE_LIMIT_WRITE_MAX=10

# Optional asset issuers
USDC_ISSUER=your_usdc_issuer
Expand Down
85 changes: 85 additions & 0 deletions backend/SEP10_AUTH_SECURITY_AUDIT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# SEP-10 Authentication Security Audit

**Module:** `backend/src/lib/sep10-auth.js`, `backend/src/routes/auth.js`
**Issues:** #588 (audit), #587 (error recovery), #733 (rate limiting)
**Date:** 2026-06-23

## Scope

This audit covers the SEP-0010 Web Authentication flow: challenge generation, signed transaction verification, merchant lookup, and session token issuance.

## Threat Model

| Threat | Mitigation | Status |
|--------|------------|--------|
| Challenge replay | In-memory nonce cache rejects reused nonces | ✅ Implemented |
| Oversized/malformed XDR | `validateChallengeXdr` enforces size (8 KB) and base64 charset | ✅ Implemented |
| Home-domain spoofing | Challenge and verify both use `getHomeDomain()`; mismatch returns `HOME_DOMAIN_MISMATCH` | ✅ Fixed |
| Missing server/client signatures | Both signatures verified against transaction hash | ✅ Implemented |
| Expired challenges | Time bounds checked against server clock | ✅ Implemented |
| Brute-force challenge/verify | Per-account+IP challenge limits; per-IP verify limits (#733) | ✅ Implemented |
| JWT secret fallback | `JWT_SECRET` required at runtime; no default secret | ✅ Implemented |
| Store outage during verify | Transient Supabase errors retried; retryable 503 returned (#587) | ✅ Implemented |
| Information leakage via errors | Generic `AUTHENTICATION_FAILED` for parse failures; structured codes for known cases | ✅ Implemented |

## Findings & Remediation

### High — Home domain inconsistency (fixed)

**Issue:** Challenge generation defaulted to `localhost` while verification used `process.env.HOME_DOMAIN`, allowing valid-looking challenges to fail verification in production.

**Fix:** Centralized domain resolution in `getHomeDomain()` and used it in both `generateChallenge` and `verifyChallenge`.

### Medium — Missing XDR size guard (fixed)

**Issue:** Unbounded XDR input could be used for DoS via expensive parsing.

**Fix:** `MAX_CHALLENGE_XDR_BYTES` (8192) enforced before Stellar SDK parsing.

### Medium — No structured error recovery on merchant lookup (fixed)

**Issue:** Transient database errors surfaced as opaque 500 responses.

**Fix:** `lookupMerchantByStellarAddress` wraps Supabase calls with `withSep10StoreRecovery`, returning retryable `503 SERVICE_UNAVAILABLE`.

### Low — Generic catch in verifyChallenge (accepted)

**Issue:** Unexpected parse errors return a generic message without leaking SDK internals.

**Status:** Accepted — intentional fail-closed behavior.

## Rate Limiting (#733)

| Endpoint | Key | Default window | Default max |
|----------|-----|----------------|-------------|
| `POST /api/auth/challenge` | `sep10:challenge:{account}:{ip}` | 60s | 20 |
| `POST /api/auth/verify` | `sep10:verify:{ip}` | 60s | 10 |

Redis-backed store (`rl:sep10:` prefix) is used when `REDIS_URL` is available; in-memory fallback otherwise.

### Environment variables

```
SEP10_CHALLENGE_RATE_LIMIT_WINDOW_MS=60000
SEP10_CHALLENGE_RATE_LIMIT_MAX=20
SEP10_VERIFY_RATE_LIMIT_WINDOW_MS=60000
SEP10_VERIFY_RATE_LIMIT_MAX=10
```

## Recommendations (future work)

1. **Distributed nonce store:** Replace in-process nonce cache with Redis for multi-instance deployments.
2. **Audit logging:** Include SEP-10 error codes in login audit events for security monitoring.
3. **Challenge binding:** Optionally bind challenges to a client-supplied `client_domain` query param per SEP-10 spec.

## Test Coverage

- `backend/src/lib/sep10-auth.test.js` — nonce replay, home domain, XDR validation, store recovery
- `backend/src/routes/auth.routes.test.js` — rate limits, retryable 503 on store failure
- `backend/src/lib/rate-limit.test.js` — SEP-10 key generation and limiter factories

## Security Assumptions

- `SEP10_SERVER_SIGNING_KEY` and `JWT_SECRET` are stored securely and rotated periodically.
- `HOME_DOMAIN` matches the domain published in `stellar.toml`.
- Redis (when used) is network-isolated and authenticated.
13 changes: 12 additions & 1 deletion backend/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import createSep12Router from "./routes/sep12.js";
import trustlinesRouter from "./routes/trustlines.js";
import paymentDetailsRouter from "./routes/paymentDetails.js";
import x402Router from "./routes/x402.js";
import authRouter from "./routes/auth.js";
import createAuthRouter from "./routes/auth.js";

import { requireApiKeyAuth } from "./lib/auth.js";
import { isHorizonReachable } from "./lib/stellar.js";
Expand All @@ -33,6 +33,8 @@ import {
createRedisRateLimitStore,
createVerifyPaymentRateLimit,
createMerchantRegistrationRateLimit,
createSep10ChallengeRateLimit,
createSep10VerifyRateLimit,
} from "./lib/rate-limit.js";
import { versionDeprecationMiddleware } from "./lib/version-deprecation.js";

Expand Down Expand Up @@ -252,6 +254,15 @@ export async function createApp({ redisClient }) {
store: redisAvailable ? createRedisRateLimitStore({ client: redisClient }) : undefined,
});

const sep10RateLimitStore = redisAvailable
? createRedisRateLimitStore({ client: redisClient, prefix: "rl:sep10:" })
: undefined;

const authRouter = createAuthRouter({
sep10ChallengeRateLimit: createSep10ChallengeRateLimit({ store: sep10RateLimitStore }),
sep10VerifyRateLimit: createSep10VerifyRateLimit({ store: sep10RateLimitStore }),
});

// x402 pay-per-request on payment creation endpoints (custom middleware flow)
const x402Provider = process.env.X402_PROVIDER_PUBLIC_KEY;
const x402Enabled = Boolean(x402Provider && process.env.X402_JWT_SECRET);
Expand Down
78 changes: 78 additions & 0 deletions backend/src/lib/rate-limit.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ export const VERIFY_PAYMENT_RATE_LIMIT_WINDOW_MS = 60 * 1000;
export const VERIFY_PAYMENT_RATE_LIMIT_MAX = 30;
export const MERCHANT_SECURITY_ACTION_RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000;
export const MERCHANT_SECURITY_ACTION_RATE_LIMIT_MAX = 10;
export const SEP10_CHALLENGE_RATE_LIMIT_WINDOW_MS = Number(
process.env.SEP10_CHALLENGE_RATE_LIMIT_WINDOW_MS || 60 * 1000,
);
export const SEP10_CHALLENGE_RATE_LIMIT_MAX = Number(
process.env.SEP10_CHALLENGE_RATE_LIMIT_MAX || 20,
);
export const SEP10_VERIFY_RATE_LIMIT_WINDOW_MS = Number(
process.env.SEP10_VERIFY_RATE_LIMIT_WINDOW_MS || 60 * 1000,
);
export const SEP10_VERIFY_RATE_LIMIT_MAX = Number(
process.env.SEP10_VERIFY_RATE_LIMIT_MAX || 10,
);

function setStandardRateLimitHeaders(res, rateLimitState) {
if (!res || !rateLimitState) {
Expand Down Expand Up @@ -120,6 +132,72 @@ export function createMerchantSecurityActionRateLimit({
});
}

export function getSep10ChallengeRateLimitKey(req) {
const account =
typeof req?.body?.account === "string" && req.body.account.trim().length > 0
? req.body.account.trim()
: "unknown-account";
const ipKey = ipKeyGenerator(req?.ip ?? req?.socket?.remoteAddress ?? "unknown-ip");
return `sep10:challenge:${account}:${ipKey}`;
}

export function getSep10VerifyRateLimitKey(req) {
const ipKey = ipKeyGenerator(req?.ip ?? req?.socket?.remoteAddress ?? "unknown-ip");
return `sep10:verify:${ipKey}`;
}

export function createSep10ChallengeRateLimit({
store,
rateLimitFactory = rateLimit,
max = SEP10_CHALLENGE_RATE_LIMIT_MAX,
windowMs = SEP10_CHALLENGE_RATE_LIMIT_WINDOW_MS,
} = {}) {
return rateLimitFactory({
windowMs,
max,
message: {
error: "Too many challenge requests, please try again later.",
code: "SEP10_RATE_LIMITED",
},
standardHeaders: true,
legacyHeaders: false,
validate: { ip: false },
keyGenerator: getSep10ChallengeRateLimitKey,
handler: (req, res, _next, options) => {
setStandardRateLimitHeaders(res, req.rateLimit);
res.status(options.statusCode).json(options.message);
},
store,
passOnStoreError: true,
});
}

export function createSep10VerifyRateLimit({
store,
rateLimitFactory = rateLimit,
max = SEP10_VERIFY_RATE_LIMIT_MAX,
windowMs = SEP10_VERIFY_RATE_LIMIT_WINDOW_MS,
} = {}) {
return rateLimitFactory({
windowMs,
max,
message: {
error: "Too many verification attempts, please try again later.",
code: "SEP10_RATE_LIMITED",
},
standardHeaders: true,
legacyHeaders: false,
validate: { ip: false },
keyGenerator: getSep10VerifyRateLimitKey,
handler: (req, res, _next, options) => {
setStandardRateLimitHeaders(res, req.rateLimit);
res.status(options.statusCode).json(options.message);
},
store,
passOnStoreError: true,
});
}

export function createMerchantRegistrationRateLimit({
store,
rateLimitFactory = rateLimit,
Expand Down
31 changes: 31 additions & 0 deletions backend/src/lib/rate-limit.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,18 @@ vi.mock("redis", () => ({
import {
createMerchantSecurityActionRateLimit,
createRedisRateLimitStore,
createSep10ChallengeRateLimit,
createSep10VerifyRateLimit,
createVerifyPaymentRateLimit,
getMerchantSecurityActionRateLimitKey,
getSep10ChallengeRateLimitKey,
getSep10VerifyRateLimitKey,
getVerifyPaymentRateLimitKey,
MERCHANT_SECURITY_ACTION_RATE_LIMIT_MAX,
MERCHANT_SECURITY_ACTION_RATE_LIMIT_WINDOW_MS,
RATE_LIMIT_REDIS_PREFIX,
SEP10_CHALLENGE_RATE_LIMIT_MAX,
SEP10_VERIFY_RATE_LIMIT_MAX,
VERIFY_PAYMENT_RATE_LIMIT_MAX,
VERIFY_PAYMENT_RATE_LIMIT_WINDOW_MS,
} from "./rate-limit.js";
Expand Down Expand Up @@ -150,6 +156,31 @@ describe("getMerchantSecurityActionRateLimitKey", () => {
});
});

describe("SEP-10 rate limiters", () => {
it("builds challenge keys scoped to account and IP", () => {
const key = getSep10ChallengeRateLimitKey({
body: { account: "GABC" },
ip: "198.51.100.2",
});
expect(key).toBe("sep10:challenge:GABC:198.51.100.2");
});

it("builds verify keys scoped to client IP", () => {
const key = getSep10VerifyRateLimitKey({ ip: "198.51.100.2" });
expect(key).toBe("sep10:verify:198.51.100.2");
});

it("creates challenge and verify limiters with configured defaults", () => {
const challenge = createSep10ChallengeRateLimit();
const verify = createSep10VerifyRateLimit();

expect(challenge).toBeDefined();
expect(verify).toBeDefined();
expect(SEP10_CHALLENGE_RATE_LIMIT_MAX).toBeGreaterThan(0);
expect(SEP10_VERIFY_RATE_LIMIT_MAX).toBeGreaterThan(0);
});
});

describe("redis client helpers", () => {
beforeEach(() => {
resetRedisClientForTests();
Expand Down
Loading
Loading