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
14 changes: 14 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,20 @@ 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 to disable immediately. Note the
# scheme is ALSO hard-disabled at runtime once AUTH_LEGACY_SIGNATURES_SUNSET
# (below) has passed, so the flag does not need a manual flip at sunset.
AUTH_ALLOW_LEGACY_RAW_SIGNATURES=true
# Hard cutoff (YYYY-MM-DD) for the legacy raw-nonce scheme. After this date
# legacy signatures are rejected even while AUTH_ALLOW_LEGACY_RAW_SIGNATURES
# is true. Override to close the window early or extend it in an emergency.
AUTH_LEGACY_SIGNATURES_SUNSET=2026-10-31

# Redis
REDIS_URL=redis://localhost:6379
REDIS_DB=0
Expand Down
8 changes: 8 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ 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`, and is
hard-disabled at runtime after `AUTH_LEGACY_SIGNATURES_SUNSET`
(default 2026-10-31) even if the flag is left true
- Nonces expire after 5 minutes
- JWTs expire after 15 minutes (access) / 7 days (refresh)
- Refresh tokens are hashed before storage
Expand Down
13 changes: 11 additions & 2 deletions context/architecture-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,17 @@ 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` and hard-disabled at runtime after
`AUTH_LEGACY_SIGNATURES_SUNSET` (default 2026-10-31), so the replayable
path closes automatically at the sunset even if the flag is left true
- Nonces are single-use and expired by the `nonce-cleanup` cron

---
Expand Down
63 changes: 48 additions & 15 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,78 @@ 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**). The sunset is enforced at runtime: after
that date legacy signatures are rejected with
`AUTH_LEGACY_SIGNATURE_DISABLED` even if the flag is still true (override
via `AUTH_LEGACY_SIGNATURES_SUNSET`).

`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
43 changes: 43 additions & 0 deletions docs/setup/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,49 @@ 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; set to false to disable it
# immediately. When false, legacy requests fail with
# AUTH_LEGACY_SIGNATURE_DISABLED.
AUTH_ALLOW_LEGACY_RAW_SIGNATURES=true

# Hard cutoff (YYYY-MM-DD) for the legacy raw-nonce scheme. After this date
# legacy signatures are rejected with AUTH_LEGACY_SIGNATURE_DISABLED even
# while AUTH_ALLOW_LEGACY_RAW_SIGNATURES is still true, so the migration
# window closes automatically at the sunset β€” no manual ops action required.
# Defaults to 2026-10-31. Override to close the window early or (in an
# emergency) to extend it. Malformed values fall back to the default.
AUTH_LEGACY_SIGNATURES_SUNSET=2026-10-31
```

**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 legacy scheme
is rejected **at runtime** (the sunset is enforced in code, not just
documented), so only domain-bound signatures are accepted even if the flag
was never flipped. Set `AUTH_ALLOW_LEGACY_RAW_SIGNATURES=false`
immediately if you do not need the migration window at all.

### Redis (Caching)

```env
Expand Down
1 change: 0 additions & 1 deletion src/modules/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import { SupabaseService } from '../../database/supabase.client';
import { UsersRepository } from '../../database/repositories/users.repository';
import { getJwtConfig } from '../../config/jwt.config';
import { AdminModule } from '../admin/admin.module';

import { RolesGuard } from '../../auth/guards/roles.guard';

@Module({
Expand Down
Loading
Loading