Skip to content
Open
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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ JWT_ACCESS_EXPIRATION=15m
JWT_REFRESH_EXPIRATION=7d
NONCE_EXPIRATION=300

# Wallet signature challenges (issue #118)
# Optional: exact host embedded in the challenge envelope's `domain` field
# (defaults to the host of API_URL).
AUTH_CHALLENGE_DOMAIN=
# Legacy raw-nonce signatures (no domain binding) are deprecated. Keep true
# during the migration window; set false after the 2026-10-31 sunset.
AUTH_ALLOW_LEGACY_RAW_SIGNATURES=true

# Redis
REDIS_URL=redis://localhost:6379
REDIS_DB=0
Expand Down
6 changes: 6 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ When reporting a vulnerability, please provide:

1. **Wallet-Based Authentication**
- Signature verification using Stellar cryptography
- Signatures are bound to a canonical StepFi challenge envelope (domain,
URI, wallet, nonce, issued-at, expires-at, network passphrase); the
nonce row stores a SHA-256 digest of the exact message, so a signature
captured from any other context cannot be replayed here
- Browser wallets verify per SEP-53; the legacy raw-nonce scheme is
deprecated and gated behind `AUTH_ALLOW_LEGACY_RAW_SIGNATURES`
- Nonces expire after 5 minutes
- JWTs expire after 15 minutes (access) / 7 days (refresh)
- Refresh tokens are hashed before storage
Expand Down
11 changes: 9 additions & 2 deletions context/architecture-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,15 @@ Wallet address β†’ `POST /auth/nonce` β†’ client signs nonce with wallet β†’
`POST /auth/verify` β†’ JWT (access + refresh) issued.
`POST /auth/refresh` rotates tokens.

- SEP-0043 message signing supported for browser wallets (Freighter)
- Raw Ed25519 signature verification for mobile (WalletConnect wallets)
- Every accepted signature signs the canonical StepFi challenge envelope
(domain, URI, wallet, nonce, issued-at, expires-at, network passphrase);
the nonce row stores a SHA-256 digest of the exact message, so verification
only ever runs against the issued challenge (#118)
- Browser wallets (Freighter) sign per SEP-53 (`signatureType: 'sep0043'`);
native clients sign the envelope with raw Ed25519
(`signatureType: 'envelope'`)
- The legacy raw-nonce scheme is deprecated behind
`AUTH_ALLOW_LEGACY_RAW_SIGNATURES` (sunset 2026-10-31)
- Nonces are single-use and expired by the `nonce-cleanup` cron

---
Expand Down
27 changes: 27 additions & 0 deletions context/progress-tracker.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,33 @@ pure chore/docs commits). Direct pushes to main must also be logged here.

---

## 2026-08-25

- Fixed cross-service signature replay (#118): `verifySignature()` now accepts
exactly one scheme per request and every accepted signature provably signs a
StepFi-bound challenge.
- `generateNonce()` issues a canonical challenge envelope (domain, address,
statement, uri, version, nonce, issuedAt, expirationTime,
networkPassphrase) and stores a SHA-256 digest of the exact message on the
nonce row (`issued_at`, `message_hash` columns via migration
`20260825000000_add_nonce_message_binding.sql`).
- Verification runs only against a message whose digest matches the stored
challenge hash (`AUTH_CHALLENGE_MISMATCH` otherwise), with strict
domain/URI/network/expiry checks (`AUTH_CHALLENGE_DOMAIN_MISMATCH`,
`AUTH_CHALLENGE_URI_MISMATCH`, `AUTH_CHALLENGE_NETWORK_MISMATCH`,
`AUTH_NONCE_EXPIRED`). The old "try raw, then 'Stellar Signing Key: '"
fallback is gone β€” the weakest format no longer defines the security floor.
- Browser wallets verify per SEP-53 (SHA-256 of
"Stellar Signed Message:\n" + envelope, `signatureType: 'sep0043'`);
native clients sign the envelope with raw Ed25519
(`signatureType: 'envelope'`).
- The legacy raw-nonce scheme is deprecated behind
`AUTH_ALLOW_LEGACY_RAW_SIGNATURES` (default true for mobile-client
compatibility) with a documented sunset date of **2026-10-31**; when
disabled, legacy requests fail with `AUTH_LEGACY_SIGNATURE_DISABLED`.
- Added `AUTH_CHALLENGE_DOMAIN` env (defaults to `API_URL` host); envelope
`uri` is derived from `API_URL` + `API_PREFIX`.

## 2026-07-23

- Added GitHub Actions health check workflow (`health-check.yml`) to ping the Render API every 6 hours to prevent the free tier instance from sleeping. Auto-creates or comments on issues with the `incident` label if the ping fails, preventing silent outages.
Expand Down
57 changes: 44 additions & 13 deletions docs/api/endpoints.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,7 @@ Authorization: Bearer <access_token>

### POST /auth/nonce

Generate a nonce for wallet signature authentication.

**Status**: πŸ”΄ Not Implemented (API-01)
Generate a nonce and the canonical StepFi challenge message for wallet signature authentication.

**Request**:
```json
Expand All @@ -32,43 +30,76 @@ Generate a nonce for wallet signature authentication.
}
```

**Response** (200 OK):
**Response** (201 Created):
```json
{
"nonce": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"expiresAt": "2026-02-13T10:05:00.000Z"
"nonce": "a1b2c3d4e5f67890abcdef1234567890a1b2c3d4e5f67890abcdef1234567890",
"expiresAt": "2026-02-13T10:05:00.000Z",
"message": "{\n \"domain\": \"stepfi-api.onrender.com\",\n \"address\": \"GABC...XYZ\",\n \"statement\": \"StepFi requests that you sign this message to authenticate your wallet. This message does not trigger any blockchain transaction.\",\n \"uri\": \"https://stepfi-api.onrender.com/api/v1/auth/verify\",\n \"version\": \"1.0.0\",\n \"nonce\": \"a1b2c3d4e5f67890abcdef1234567890a1b2c3d4e5f67890abcdef1234567890\",\n \"issuedAt\": \"2026-02-13T10:00:00.000Z\",\n \"expirationTime\": \"2026-02-13T10:05:00.000Z\",\n \"networkPassphrase\": \"Test SDF Network ; September 2015\"\n}"
}
```

The `message` field is the exact text the wallet must sign. It binds the
signature to StepFi's domain, URI, wallet address, nonce and network, so a
signature captured from any other context cannot be replayed here. A SHA-256
digest of this message is stored on the nonce row, and verification only ever
accepts a signature over a message whose digest matches the stored challenge.

**Errors**:
- `400`: Invalid wallet format

---

### POST /auth/verify

Verify wallet signature and receive JWT tokens.

**Status**: πŸ”΄ Not Implemented (API-02)

**Request**:
```json
{
"wallet": "GABC...XYZ",
"signature": "MEUCIQ...",
"nonce": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
"signature": "base64-ed25519-signature",
"nonce": "a1b2c3d4e5f67890abcdef1234567890a1b2c3d4e5f67890abcdef1234567890",
"signatureType": "envelope",
"message": "{\n \"domain\": \"stepfi-api.onrender.com\",\n ... same envelope returned by /auth/nonce ...\n}"
}
```

`signatureType` selects exactly one verification scheme (the server never
tries multiple formats):

- `envelope` β€” native clients: raw Ed25519 over the canonical envelope UTF-8
text returned by `/auth/nonce`.
- `sep0043` β€” browser wallets (Freighter): Ed25519 over
`SHA-256("Stellar Signed Message:\n" + envelope)` (SEP-53).
- `raw` β€” **deprecated** legacy scheme: raw Ed25519 over the bare nonce hex.
Only accepted while `AUTH_ALLOW_LEGACY_RAW_SIGNATURES=true` (migration
window, sunset **2026-10-31**). Once disabled, requests using it fail with
`AUTH_LEGACY_SIGNATURE_DISABLED`.

`message` is optional: when omitted, the server reconstructs the canonical
challenge from the stored nonce row. Either way the signature is verified
against a message whose digest matches the challenge stored with the nonce β€”
client-supplied alternatives are rejected (`AUTH_CHALLENGE_MISMATCH`), as are
messages bound to a foreign domain/URI/network
(`AUTH_CHALLENGE_DOMAIN_MISMATCH`, `AUTH_CHALLENGE_URI_MISMATCH`,
`AUTH_CHALLENGE_NETWORK_MISMATCH`) or expired envelopes (`AUTH_NONCE_EXPIRED`).

**Response** (200 OK):
```json
{
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"expiresIn": 900
"expiresIn": 900,
"tokenType": "Bearer"
}
```

**Errors**:
- `400`: Invalid signature or nonce
- `404`: Nonce not found or expired
- `400`: Validation failed (wallet, nonce, signature, or signatureType)
- `401`: Nonce not found/already used (`AUTH_NONCE_NOT_FOUND`), expired
(`AUTH_NONCE_EXPIRED`), or signature invalid
(`AUTH_SIGNATURE_INVALID` / `AUTH_CHALLENGE_*`)

---

Expand Down
33 changes: 33 additions & 0 deletions docs/setup/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,39 @@ JWT_REFRESH_EXPIRATION=7d
NONCE_EXPIRATION=300
```

### Wallet Signature Challenges (issue #118)

Wallet authentication is bound to a canonical, domain-scoped challenge
envelope signed by the wallet (see `docs/api/endpoints.md`). The envelope's
`domain`, `uri` and `networkPassphrase` fields are derived from these
variables; a signature bound to a different environment is rejected.

```env
# Base URL of the API. Used to derive the challenge envelope's `uri` field
# (and the `domain` field when AUTH_CHALLENGE_DOMAIN is unset).
API_URL=https://stepfi-api.onrender.com

# Optional: exact host embedded in the challenge envelope's `domain` field.
# Defaults to the host of API_URL. Must match the public origin clients
# reach this API from.
AUTH_CHALLENGE_DOMAIN=stepfi-api.onrender.com

# Whether the deprecated legacy raw-nonce signature scheme (signature over
# the bare nonce hex, no domain binding) is still accepted. Defaults to true
# during the documented migration window; MUST be set to false after the
# sunset date (2026-10-31). When false, legacy requests fail with
# AUTH_LEGACY_SIGNATURE_DISABLED.
AUTH_ALLOW_LEGACY_RAW_SIGNATURES=true
```

**Migration window**: existing mobile clients sign the bare nonce. They must
be updated to sign the canonical challenge envelope returned by
`POST /auth/nonce` (`signatureType: "envelope"`). Until the sunset date
(**2026-10-31**) the legacy scheme remains accepted while
`AUTH_ALLOW_LEGACY_RAW_SIGNATURES=true`; after that date the flag must be
flipped to `false` (or removed) and only domain-bound signatures are
accepted.

### Redis (Caching)

```env
Expand Down
Loading
Loading