Skip to content

Commit 674dca2

Browse files
committed
chore: merge upstream/main (cbd05ad) domain-binding (#118) into SIWE_style_nonce — preserve atomic nonce claim (#116)
Merge upstream/main cbd05ad (fix: domain-bind wallet signature challenges) while retaining atomic nonce claim (5dd4772). Resolves conflicts: - src/modules/auth/auth.service.ts: retains generateNonce message + message_hash/issued_at + buildChallengeMessage from cbd05ad and injects atomic UPDATE ... count:'exact' claim before verify (burn-on-failure) from 5dd4772; removes trailing UPDATE; helpers resolveChallengeMessage/assertChallengeBinding preserved. - test/unit/modules/auth/auth.service.spec.ts: merged suites keep origin domain-binding + HEAD TOCTOU atomicity (parallel double-verify, replay burned, expired burned) and fixes claimResult type. - DTOs/e2e/env/docs/migration synced from upstream. No merge markers, npm run build green, 61 tests pass. Closes #116, incorporates #118.
2 parents 5dd4772 + cbd05ad commit 674dca2

12 files changed

Lines changed: 899 additions & 68 deletions

File tree

.env.example

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,20 @@ JWT_ACCESS_EXPIRATION=15m
2828
JWT_REFRESH_EXPIRATION=7d
2929
NONCE_EXPIRATION=300
3030

31+
# Wallet signature challenges (issue #118)
32+
# Optional: exact host embedded in the challenge envelope's `domain` field
33+
# (defaults to the host of API_URL).
34+
AUTH_CHALLENGE_DOMAIN=
35+
# Legacy raw-nonce signatures (no domain binding) are deprecated. Keep true
36+
# during the migration window; set false to disable immediately. Note the
37+
# scheme is ALSO hard-disabled at runtime once AUTH_LEGACY_SIGNATURES_SUNSET
38+
# (below) has passed, so the flag does not need a manual flip at sunset.
39+
AUTH_ALLOW_LEGACY_RAW_SIGNATURES=true
40+
# Hard cutoff (YYYY-MM-DD) for the legacy raw-nonce scheme. After this date
41+
# legacy signatures are rejected even while AUTH_ALLOW_LEGACY_RAW_SIGNATURES
42+
# is true. Override to close the window early or extend it in an emergency.
43+
AUTH_LEGACY_SIGNATURES_SUNSET=2026-10-31
44+
3145
# Redis
3246
REDIS_URL=redis://localhost:6379
3347
REDIS_DB=0

SECURITY.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,14 @@ When reporting a vulnerability, please provide:
4848

4949
1. **Wallet-Based Authentication**
5050
- Signature verification using Stellar cryptography
51+
- Signatures are bound to a canonical StepFi challenge envelope (domain,
52+
URI, wallet, nonce, issued-at, expires-at, network passphrase); the
53+
nonce row stores a SHA-256 digest of the exact message, so a signature
54+
captured from any other context cannot be replayed here
55+
- Browser wallets verify per SEP-53; the legacy raw-nonce scheme is
56+
deprecated and gated behind `AUTH_ALLOW_LEGACY_RAW_SIGNATURES`, and is
57+
hard-disabled at runtime after `AUTH_LEGACY_SIGNATURES_SUNSET`
58+
(default 2026-10-31) even if the flag is left true
5159
- Nonces expire after 5 minutes
5260
- JWTs expire after 15 minutes (access) / 7 days (refresh)
5361
- Refresh tokens are hashed before storage

context/architecture-context.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,17 @@ Wallet address → `POST /auth/nonce` → client signs nonce with wallet →
8484
`POST /auth/verify` → JWT (access + refresh) issued.
8585
`POST /auth/refresh` rotates tokens.
8686

87-
- SEP-0043 message signing supported for browser wallets (Freighter)
88-
- Raw Ed25519 signature verification for mobile (WalletConnect wallets)
87+
- Every accepted signature signs the canonical StepFi challenge envelope
88+
(domain, URI, wallet, nonce, issued-at, expires-at, network passphrase);
89+
the nonce row stores a SHA-256 digest of the exact message, so verification
90+
only ever runs against the issued challenge (#118)
91+
- Browser wallets (Freighter) sign per SEP-53 (`signatureType: 'sep0043'`);
92+
native clients sign the envelope with raw Ed25519
93+
(`signatureType: 'envelope'`)
94+
- The legacy raw-nonce scheme is deprecated behind
95+
`AUTH_ALLOW_LEGACY_RAW_SIGNATURES` and hard-disabled at runtime after
96+
`AUTH_LEGACY_SIGNATURES_SUNSET` (default 2026-10-31), so the replayable
97+
path closes automatically at the sunset even if the flag is left true
8998
- Nonces are single-use and expired by the `nonce-cleanup` cron
9099

91100
---

docs/api/endpoints.md

Lines changed: 48 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,7 @@ Authorization: Bearer <access_token>
2121

2222
### POST /auth/nonce
2323

24-
Generate a nonce for wallet signature authentication.
25-
26-
**Status**: 🔴 Not Implemented (API-01)
24+
Generate a nonce and the canonical StepFi challenge message for wallet signature authentication.
2725

2826
**Request**:
2927
```json
@@ -32,43 +30,78 @@ Generate a nonce for wallet signature authentication.
3230
}
3331
```
3432

35-
**Response** (200 OK):
33+
**Response** (201 Created):
3634
```json
3735
{
38-
"nonce": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
39-
"expiresAt": "2026-02-13T10:05:00.000Z"
36+
"nonce": "a1b2c3d4e5f67890abcdef1234567890a1b2c3d4e5f67890abcdef1234567890",
37+
"expiresAt": "2026-02-13T10:05:00.000Z",
38+
"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}"
4039
}
4140
```
4241

42+
The `message` field is the exact text the wallet must sign. It binds the
43+
signature to StepFi's domain, URI, wallet address, nonce and network, so a
44+
signature captured from any other context cannot be replayed here. A SHA-256
45+
digest of this message is stored on the nonce row, and verification only ever
46+
accepts a signature over a message whose digest matches the stored challenge.
47+
48+
**Errors**:
49+
- `400`: Invalid wallet format
50+
4351
---
4452

4553
### POST /auth/verify
4654

4755
Verify wallet signature and receive JWT tokens.
4856

49-
**Status**: 🔴 Not Implemented (API-02)
50-
5157
**Request**:
5258
```json
5359
{
5460
"wallet": "GABC...XYZ",
55-
"signature": "MEUCIQ...",
56-
"nonce": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
57-
}
58-
```
61+
"signature": "base64-ed25519-signature",
62+
"nonce": "a1b2c3d4e5f67890abcdef1234567890a1b2c3d4e5f67890abcdef1234567890",
63+
"signatureType": "envelope",
64+
"message": "{\n \"domain\": \"stepfi-api.onrender.com\",\n ... same envelope returned by /auth/nonce ...\n}"
65+
}
66+
```
67+
68+
`signatureType` selects exactly one verification scheme (the server never
69+
tries multiple formats):
70+
71+
- `envelope` — native clients: raw Ed25519 over the canonical envelope UTF-8
72+
text returned by `/auth/nonce`.
73+
- `sep0043` — browser wallets (Freighter): Ed25519 over
74+
`SHA-256("Stellar Signed Message:\n" + envelope)` (SEP-53).
75+
- `raw`**deprecated** legacy scheme: raw Ed25519 over the bare nonce hex.
76+
Only accepted while `AUTH_ALLOW_LEGACY_RAW_SIGNATURES=true` (migration
77+
window, sunset **2026-10-31**). The sunset is enforced at runtime: after
78+
that date legacy signatures are rejected with
79+
`AUTH_LEGACY_SIGNATURE_DISABLED` even if the flag is still true (override
80+
via `AUTH_LEGACY_SIGNATURES_SUNSET`).
81+
82+
`message` is optional: when omitted, the server reconstructs the canonical
83+
challenge from the stored nonce row. Either way the signature is verified
84+
against a message whose digest matches the challenge stored with the nonce —
85+
client-supplied alternatives are rejected (`AUTH_CHALLENGE_MISMATCH`), as are
86+
messages bound to a foreign domain/URI/network
87+
(`AUTH_CHALLENGE_DOMAIN_MISMATCH`, `AUTH_CHALLENGE_URI_MISMATCH`,
88+
`AUTH_CHALLENGE_NETWORK_MISMATCH`) or expired envelopes (`AUTH_NONCE_EXPIRED`).
5989

6090
**Response** (200 OK):
6191
```json
6292
{
6393
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
6494
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
65-
"expiresIn": 900
95+
"expiresIn": 900,
96+
"tokenType": "Bearer"
6697
}
6798
```
6899

69100
**Errors**:
70-
- `400`: Invalid signature or nonce
71-
- `404`: Nonce not found or expired
101+
- `400`: Validation failed (wallet, nonce, signature, or signatureType)
102+
- `401`: Nonce not found/already used (`AUTH_NONCE_NOT_FOUND`), expired
103+
(`AUTH_NONCE_EXPIRED`), or signature invalid
104+
(`AUTH_SIGNATURE_INVALID` / `AUTH_CHALLENGE_*`)
72105

73106
---
74107

docs/setup/environment-variables.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,49 @@ JWT_REFRESH_EXPIRATION=7d
7575
NONCE_EXPIRATION=300
7676
```
7777

78+
### Wallet Signature Challenges (issue #118)
79+
80+
Wallet authentication is bound to a canonical, domain-scoped challenge
81+
envelope signed by the wallet (see `docs/api/endpoints.md`). The envelope's
82+
`domain`, `uri` and `networkPassphrase` fields are derived from these
83+
variables; a signature bound to a different environment is rejected.
84+
85+
```env
86+
# Base URL of the API. Used to derive the challenge envelope's `uri` field
87+
# (and the `domain` field when AUTH_CHALLENGE_DOMAIN is unset).
88+
API_URL=https://stepfi-api.onrender.com
89+
90+
# Optional: exact host embedded in the challenge envelope's `domain` field.
91+
# Defaults to the host of API_URL. Must match the public origin clients
92+
# reach this API from.
93+
AUTH_CHALLENGE_DOMAIN=stepfi-api.onrender.com
94+
95+
# Whether the deprecated legacy raw-nonce signature scheme (signature over
96+
# the bare nonce hex, no domain binding) is still accepted. Defaults to true
97+
# during the documented migration window; set to false to disable it
98+
# immediately. When false, legacy requests fail with
99+
# AUTH_LEGACY_SIGNATURE_DISABLED.
100+
AUTH_ALLOW_LEGACY_RAW_SIGNATURES=true
101+
102+
# Hard cutoff (YYYY-MM-DD) for the legacy raw-nonce scheme. After this date
103+
# legacy signatures are rejected with AUTH_LEGACY_SIGNATURE_DISABLED even
104+
# while AUTH_ALLOW_LEGACY_RAW_SIGNATURES is still true, so the migration
105+
# window closes automatically at the sunset — no manual ops action required.
106+
# Defaults to 2026-10-31. Override to close the window early or (in an
107+
# emergency) to extend it. Malformed values fall back to the default.
108+
AUTH_LEGACY_SIGNATURES_SUNSET=2026-10-31
109+
```
110+
111+
**Migration window**: existing mobile clients sign the bare nonce. They must
112+
be updated to sign the canonical challenge envelope returned by
113+
`POST /auth/nonce` (`signatureType: "envelope"`). Until the sunset date
114+
(**2026-10-31**) the legacy scheme remains accepted while
115+
`AUTH_ALLOW_LEGACY_RAW_SIGNATURES=true`; after that date the legacy scheme
116+
is rejected **at runtime** (the sunset is enforced in code, not just
117+
documented), so only domain-bound signatures are accepted even if the flag
118+
was never flipped. Set `AUTH_ALLOW_LEGACY_RAW_SIGNATURES=false`
119+
immediately if you do not need the migration window at all.
120+
78121
### Redis (Caching)
79122

80123
```env

src/modules/auth/auth.module.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import { SupabaseService } from '../../database/supabase.client';
1111
import { UsersRepository } from '../../database/repositories/users.repository';
1212
import { getJwtConfig } from '../../config/jwt.config';
1313
import { AdminModule } from '../admin/admin.module';
14-
1514
import { RolesGuard } from '../../auth/guards/roles.guard';
1615

1716
@Module({

0 commit comments

Comments
 (0)