diff --git a/.env.example b/.env.example index 6f87915..b7206b0 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/SECURITY.md b/SECURITY.md index 5442519..56b1a64 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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 diff --git a/context/architecture-context.md b/context/architecture-context.md index e6d7f74..48b7693 100644 --- a/context/architecture-context.md +++ b/context/architecture-context.md @@ -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 --- diff --git a/context/progress-tracker.md b/context/progress-tracker.md index eac5d06..efd4a96 100644 --- a/context/progress-tracker.md +++ b/context/progress-tracker.md @@ -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. diff --git a/docs/api/endpoints.md b/docs/api/endpoints.md index f9984b6..1fb0395 100644 --- a/docs/api/endpoints.md +++ b/docs/api/endpoints.md @@ -21,9 +21,7 @@ Authorization: Bearer ### 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 @@ -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_*`) --- diff --git a/docs/setup/environment-variables.md b/docs/setup/environment-variables.md index a460387..c873107 100644 --- a/docs/setup/environment-variables.md +++ b/docs/setup/environment-variables.md @@ -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 diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 55d5419..d087949 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -3,6 +3,7 @@ import { InternalServerErrorException, UnauthorizedException, ConflictException, + Logger, } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; @@ -23,6 +24,59 @@ import { const NONCE_EXPIRATION_SECONDS = 300; +/** + * Canonical prefix defined by SEP-53 ("Sign and Verify Messages"). Browser + * wallets (Freighter etc.) sign SHA-256("Stellar Signed Message:\n" + message). + */ +const SEP_53_PREFIX = 'Stellar Signed Message:\n'; + +/** Version embedded in the canonical StepFi challenge envelope. */ +const CHALLENGE_VERSION = '1.0.0'; + +/** Human-readable statement embedded in the canonical StepFi challenge envelope. */ +const CHALLENGE_STATEMENT = + 'StepFi requests that you sign this message to authenticate your wallet. ' + + 'This message does not trigger any blockchain transaction.'; + +/** Fallback network passphrase — matches the rest of the codebase. */ +const DEFAULT_NETWORK_PASSPHRASE = 'Test SDF Network ; September 2015'; + +/** + * End of the documented migration window for the legacy raw-nonce signature + * scheme (issue #118). After this date AUTH_ALLOW_LEGACY_RAW_SIGNATURES must + * be disabled; see docs/setup/environment-variables.md. + */ +export const LEGACY_RAW_SIGNATURES_SUNSET = '2026-10-31'; + +/** Shape of a nonces row as read by verifySignature. */ +interface StoredNonce { + id: string; + expires_at: string; + issued_at: string | null; + message_hash: string | null; +} + +/** Parsed fields of the canonical challenge envelope used for validation. */ +interface ChallengeEnvelope { + domain: string; + address: string; + uri: string; + version: string; + nonce: string; + issuedAt: string; + expirationTime: string; + networkPassphrase: string; +} + +/** + * Parses a boolean-ish environment value. Returns `defaultValue` when the + * value is unset or empty; accepts true/1/yes/on (case-insensitive). + */ +function parseBooleanEnv(value: string | undefined, defaultValue: boolean): boolean { + if (value === undefined || value.trim() === '') return defaultValue; + return ['true', '1', 'yes', 'on'].includes(value.trim().toLowerCase()); +} + export interface RegisterResponse extends AuthResponseDto { user: { id: string; @@ -36,12 +90,41 @@ export interface RegisterResponse extends AuthResponseDto { @Injectable() export class AuthService { + private readonly logger = new Logger(AuthService.name); + + /** Host that must appear in the challenge envelope's `domain` field. */ + private readonly challengeDomain: string; + + /** API URI that must appear in the challenge envelope's `uri` field. */ + private readonly challengeUri: string; + + /** Stellar network passphrase bound into the challenge envelope. */ + private readonly networkPassphrase: string; + + /** + * Whether the deprecated raw-nonce signature scheme (no domain binding) is + * still accepted during the migration window. Defaults to true for + * mobile-client compatibility; MUST be disabled at the documented sunset. + */ + private readonly allowLegacyRawSignatures: boolean; + constructor( private readonly supabaseService: SupabaseService, private readonly jwtService: JwtService, private readonly configService: ConfigService, private readonly usersRepository: UsersRepository, - ) {} + ) { + const apiUrl = (this.configService.get('API_URL') ?? 'http://localhost:3000').replace(/\/+$/, ''); + const apiPrefix = this.configService.get('API_PREFIX') ?? 'api/v1'; + this.challengeDomain = this.configService.get('AUTH_CHALLENGE_DOMAIN') ?? this.resolveHost(apiUrl); + this.challengeUri = `${apiUrl}/${apiPrefix}/auth/verify`; + this.networkPassphrase = + this.configService.get('STELLAR_NETWORK_PASSPHRASE') ?? DEFAULT_NETWORK_PASSPHRASE; + this.allowLegacyRawSignatures = parseBooleanEnv( + this.configService.get('AUTH_ALLOW_LEGACY_RAW_SIGNATURES'), + true, + ); + } async register(dto: RegisterRequestDto, profileImage?: UploadedAvatarFile): Promise { const existingWallet = await this.usersRepository.findByWallet(dto.walletAddress); @@ -76,26 +159,46 @@ export class AuthService { }; } + /** + * Issues a single-use nonce together with the canonical, domain-bound + * challenge message the wallet must sign. A SHA-256 digest of the exact + * message is stored on the nonce row so verification can only ever run + * against that message — never against client-supplied alternatives. + */ async generateNonce(wallet: string): Promise { const nonce = randomBytes(32).toString('hex'); + const issuedAt = new Date(); const expiresAt = new Date(Date.now() + NONCE_EXPIRATION_SECONDS * 1000); + const message = this.buildChallengeMessage({ wallet, nonce, issuedAt, expiresAt }); + const messageHash = createHash('sha256').update(message, 'utf8').digest('hex'); const client = this.supabaseService.getServiceRoleClient(); const { error } = await client.from('nonces').insert({ wallet_address: wallet, nonce, expires_at: expiresAt.toISOString(), + issued_at: issuedAt.toISOString(), + message_hash: messageHash, }); if (error) { throw new InternalServerErrorException({ code: 'DATABASE_NONCE_INSERT_FAILED', message: 'Failed to generate nonce.' }); } - return { nonce, expiresAt: expiresAt.toISOString() }; + return { nonce, expiresAt: expiresAt.toISOString(), message }; } + /** + * Verifies the wallet signature and marks the nonce used. + * + * Security model (issue #118): the server never tries multiple message + * formats. Exactly one scheme is used per request, selected by + * `signatureType`, and every canonical scheme verifies the signature + * against the exact message bound to the nonce row (SHA-256 digest stored + * at issue time) plus strict domain/URI/network/expiry validation. + */ async verifySignature(dto: VerifyRequestDto): Promise { const client = this.supabaseService.getServiceRoleClient(); const { data: nonceRecord, error: nonceError } = await client .from('nonces') - .select('id, expires_at') + .select('id, expires_at, issued_at, message_hash') .eq('wallet_address', dto.wallet) .eq('nonce', dto.nonce) .is('used_at', null) @@ -111,29 +214,33 @@ export class AuthService { } try { const keypair = Keypair.fromPublicKey(dto.wallet); + const signatureBuffer = Buffer.from(dto.signature, 'base64'); + // The DTO default ('raw') is applied by the validation layer; the + // service treats an absent value the same way for direct callers. + const signatureType = dto.signatureType ?? 'raw'; - let isValid = false; - - // First attempt: raw Ed25519 signature (mobile clients) - try { - isValid = keypair.verify(Buffer.from(dto.nonce), Buffer.from(dto.signature, 'base64')); - } catch (e) { - isValid = false; - } + if (signatureType === 'raw') { + // Legacy mobile scheme: signature over the bare nonce hex bytes. + // Deprecated — no domain binding, gated behind a config flag. + this.verifyLegacyRawSignature(keypair, dto.nonce, signatureBuffer); + } else { + const message = this.resolveChallengeMessage(dto, nonceRecord); + this.assertChallengeBinding(message, nonceRecord, dto); - // If raw verification failed, try SEP-0043 (browser wallets like Freighter) - if (!isValid) { - try { - const sepMessage = 'Stellar Signing Key: ' + dto.nonce; - isValid = keypair.verify(Buffer.from(sepMessage), Buffer.from(dto.signature, 'base64')); - } catch (e) { - isValid = false; + if (signatureType === 'sep0043') { + // Browser wallets (SEP-53): signature over SHA-256 of + // "Stellar Signed Message:\n" + envelope. + const digest = createHash('sha256').update(SEP_53_PREFIX + message, 'utf8').digest(); + if (!keypair.verify(digest, signatureBuffer)) { + throw new UnauthorizedException({ code: 'AUTH_SIGNATURE_INVALID', message: 'Invalid signature.' }); + } + } else { + // Native clients: raw Ed25519 over the envelope UTF-8 bytes. + if (!keypair.verify(Buffer.from(message, 'utf8'), signatureBuffer)) { + throw new UnauthorizedException({ code: 'AUTH_SIGNATURE_INVALID', message: 'Invalid signature.' }); + } } } - - if (!isValid) { - throw new UnauthorizedException({ code: 'AUTH_SIGNATURE_INVALID', message: 'Invalid signature.' }); - } } catch (err) { if (err instanceof UnauthorizedException) throw err; throw new UnauthorizedException({ code: 'AUTH_SIGNATURE_INVALID', message: 'Invalid signature.' }); @@ -141,6 +248,156 @@ export class AuthService { await client.from('nonces').update({ used_at: new Date().toISOString() }).eq('id', nonceRecord.id); } + /** + * Resolves the exact bytes to verify the signature against. Prefers the + * message the client echoes back (must still hash-match the stored + * challenge); falls back to reconstructing the canonical message from the + * stored nonce row. + */ + private resolveChallengeMessage(dto: VerifyRequestDto, stored: StoredNonce): string { + if (dto.message) { + return dto.message; + } + if (!stored.issued_at || !stored.message_hash) { + throw new UnauthorizedException({ code: 'AUTH_SIGNATURE_INVALID', message: 'Invalid signature.' }); + } + return this.buildChallengeMessage({ + wallet: dto.wallet, + nonce: dto.nonce, + issuedAt: new Date(stored.issued_at), + expiresAt: new Date(stored.expires_at), + }); + } + + /** + * Enforces that the message being verified is exactly the challenge bound + * to the nonce row (stored SHA-256 digest) and that its envelope matches + * this environment and is not expired. + */ + private assertChallengeBinding(message: string, stored: StoredNonce, dto: VerifyRequestDto): void { + if (!stored.message_hash) { + throw new UnauthorizedException({ code: 'AUTH_SIGNATURE_INVALID', message: 'Invalid signature.' }); + } + const messageHash = createHash('sha256').update(message, 'utf8').digest('hex'); + if (messageHash !== stored.message_hash) { + throw new UnauthorizedException({ + code: 'AUTH_CHALLENGE_MISMATCH', + message: 'Signed message does not match the issued challenge.', + }); + } + + const envelope = this.parseChallengeEnvelope(message); + + if (envelope.domain !== this.challengeDomain) { + throw new UnauthorizedException({ + code: 'AUTH_CHALLENGE_DOMAIN_MISMATCH', + message: 'Challenge domain does not match this environment.', + }); + } + if (envelope.uri !== this.challengeUri) { + throw new UnauthorizedException({ + code: 'AUTH_CHALLENGE_URI_MISMATCH', + message: 'Challenge URI does not match this environment.', + }); + } + if (envelope.networkPassphrase !== this.networkPassphrase) { + throw new UnauthorizedException({ + code: 'AUTH_CHALLENGE_NETWORK_MISMATCH', + message: 'Challenge network does not match this environment.', + }); + } + if (envelope.address !== dto.wallet || envelope.nonce !== dto.nonce || envelope.version !== CHALLENGE_VERSION) { + throw new UnauthorizedException({ + code: 'AUTH_CHALLENGE_MISMATCH', + message: 'Signed message does not match the issued challenge.', + }); + } + const expirationTime = Date.parse(envelope.expirationTime); + if (Number.isNaN(expirationTime) || expirationTime <= Date.now()) { + throw new UnauthorizedException({ code: 'AUTH_NONCE_EXPIRED', message: 'Challenge has expired.' }); + } + } + + /** + * Legacy verification: raw Ed25519 over the nonce hex bytes. No domain + * binding — accepted only while AUTH_ALLOW_LEGACY_RAW_SIGNATURES is + * enabled (migration window; see LEGACY_RAW_SIGNATURES_SUNSET). + */ + private verifyLegacyRawSignature(keypair: Keypair, nonce: string, signatureBuffer: Buffer): void { + if (!this.allowLegacyRawSignatures) { + throw new UnauthorizedException({ + code: 'AUTH_LEGACY_SIGNATURE_DISABLED', + message: + 'Legacy raw nonce signatures are no longer accepted. ' + + 'Please sign the canonical challenge message returned by POST /auth/nonce.', + }); + } + this.logger.warn( + `Legacy raw nonce signature accepted — AUTH_ALLOW_LEGACY_RAW_SIGNATURES is still enabled. ` + + `Disable it after ${LEGACY_RAW_SIGNATURES_SUNSET}.`, + ); + if (!keypair.verify(Buffer.from(nonce), signatureBuffer)) { + throw new UnauthorizedException({ code: 'AUTH_SIGNATURE_INVALID', message: 'Invalid signature.' }); + } + } + + /** + * Builds the canonical StepFi challenge envelope. Deterministic: fixed key + * order and 2-space indentation, so the server can reproduce the exact + * bytes it issued (and clients sign exactly what they received). + */ + private buildChallengeMessage(opts: { wallet: string; nonce: string; issuedAt: Date; expiresAt: Date }): string { + const envelope = { + domain: this.challengeDomain, + address: opts.wallet, + statement: CHALLENGE_STATEMENT, + uri: this.challengeUri, + version: CHALLENGE_VERSION, + nonce: opts.nonce, + issuedAt: opts.issuedAt.toISOString(), + expirationTime: opts.expiresAt.toISOString(), + networkPassphrase: this.networkPassphrase, + }; + return JSON.stringify(envelope, null, 2); + } + + /** Strictly parses the challenge envelope, rejecting malformed messages. */ + private parseChallengeEnvelope(message: string): ChallengeEnvelope { + let parsed: unknown; + try { + parsed = JSON.parse(message); + } catch { + throw new UnauthorizedException({ code: 'AUTH_SIGNATURE_INVALID', message: 'Invalid signature.' }); + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new UnauthorizedException({ code: 'AUTH_SIGNATURE_INVALID', message: 'Invalid signature.' }); + } + const obj = parsed as Record; + const { domain, address, uri, version, nonce, issuedAt, expirationTime, networkPassphrase } = obj; + if ( + typeof domain !== 'string' || + typeof address !== 'string' || + typeof uri !== 'string' || + typeof version !== 'string' || + typeof nonce !== 'string' || + typeof issuedAt !== 'string' || + typeof expirationTime !== 'string' || + typeof networkPassphrase !== 'string' + ) { + throw new UnauthorizedException({ code: 'AUTH_SIGNATURE_INVALID', message: 'Invalid signature.' }); + } + return { domain, address, uri, version, nonce, issuedAt, expirationTime, networkPassphrase }; + } + + /** Extracts the host from an API URL, tolerating bare hosts. */ + private resolveHost(apiUrl: string): string { + try { + return new URL(apiUrl).host; + } catch { + return apiUrl.split('/')[0] || 'localhost'; + } + } + private async findOrCreateUser(wallet: string): Promise<{ id: string; role: string | null }> { const client = this.supabaseService.getServiceRoleClient(); const { data: user, error } = await client diff --git a/src/modules/auth/dto/nonce-response.dto.ts b/src/modules/auth/dto/nonce-response.dto.ts index 79575f7..f2ae35b 100644 --- a/src/modules/auth/dto/nonce-response.dto.ts +++ b/src/modules/auth/dto/nonce-response.dto.ts @@ -16,4 +16,14 @@ export class NonceResponseDto { example: '2026-02-13T10:05:00.000Z', }) expiresAt: string; + + @ApiProperty({ + description: + 'Canonical StepFi challenge message the wallet must sign. Contains domain, address, ' + + 'statement, uri, version, nonce, issuedAt, expirationTime and networkPassphrase, ' + + 'binding the signature to this API and environment. Echo it back in POST /auth/verify.', + example: + '{\n "domain": "stepfi-api.onrender.com",\n "address": "G...",\n "statement": "StepFi requests...",\n "uri": "https://stepfi-api.onrender.com/api/v1/auth/verify",\n "version": "1.0.0",\n "nonce": "a1b2c3d4e5f67890abcdef1234567890a1b2c3d4e5f67890abcdef1234567890",\n "issuedAt": "2026-08-25T12:00:00.000Z",\n "expirationTime": "2026-08-25T12:05:00.000Z",\n "networkPassphrase": "Test SDF Network ; September 2015"\n}', + }) + message: string; } diff --git a/src/modules/auth/dto/verify-request.dto.ts b/src/modules/auth/dto/verify-request.dto.ts index 1057b1b..fcc3493 100644 --- a/src/modules/auth/dto/verify-request.dto.ts +++ b/src/modules/auth/dto/verify-request.dto.ts @@ -1,10 +1,16 @@ -import { IsString, IsNotEmpty, Matches, Length, IsOptional, IsIn } from 'class-validator'; +import { IsString, IsNotEmpty, Matches, Length, IsOptional, IsIn, MaxLength } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; /** * DTO for verifying a Stellar wallet signature and issuing JWT tokens. - * The client must first request a nonce via POST /auth/nonce, sign it - * with their wallet private key, then submit it here. + * The client must first request a nonce via POST /auth/nonce, sign the + * returned challenge message with their wallet private key, then submit + * it here. + * + * Every accepted signature must be over the domain-bound challenge message + * issued by POST /auth/nonce (bound to the nonce row via a stored hash). + * The legacy 'raw' scheme (signing the bare nonce hex) is deprecated and + * only accepted while AUTH_ALLOW_LEGACY_RAW_SIGNATURES is enabled. */ export class VerifyRequestDto { @ApiProperty({ @@ -37,7 +43,7 @@ export class VerifyRequestDto { @ApiProperty({ description: - 'Base64-encoded Ed25519 signature of the nonce bytes, signed with the wallet private key', + 'Base64-encoded Ed25519 signature over the challenge message (or, for the deprecated raw scheme, over the nonce bytes)', example: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', }) @IsString() @@ -45,13 +51,29 @@ export class VerifyRequestDto { signature: string; @ApiProperty({ - description: "Signature type — 'raw' for raw Ed25519 or 'sep0043' for browser wallets", - example: 'raw', + description: + "Signature scheme. 'sep0043' — browser wallets (SEP-53: SHA-256 of \"Stellar Signed Message:\\n\" + envelope). 'envelope' — native clients signing the canonical envelope with raw Ed25519. 'raw' — legacy, signature over the bare nonce hex (deprecated, flag-gated).", + example: 'envelope', + required: false, + enum: ['raw', 'sep0043', 'envelope'], + }) + @IsOptional() + @IsString() + @IsIn(['raw', 'sep0043', 'envelope']) + signatureType?: 'raw' | 'sep0043' | 'envelope' = 'raw'; + + @ApiProperty({ + description: + 'Exact challenge message returned by POST /auth/nonce (required for signatureType sep0043/envelope). ' + + 'When omitted, the server reconstructs the canonical challenge from the stored nonce row. ' + + 'The server only verifies signatures against a message whose digest matches the stored challenge hash.', + example: + '{\n "domain": "stepfi-api.onrender.com",\n "address": "G...",\n "statement": "StepFi requests...",\n "uri": "https://stepfi-api.onrender.com/api/v1/auth/verify",\n "version": "1.0.0",\n "nonce": "a1b2c3d4e5f67890abcdef1234567890a1b2c3d4e5f67890abcdef1234567890",\n "issuedAt": "2026-08-25T12:00:00.000Z",\n "expirationTime": "2026-08-25T12:05:00.000Z",\n "networkPassphrase": "Test SDF Network ; September 2015"\n}', required: false, - enum: ['raw', 'sep0043'], }) @IsOptional() @IsString() - @IsIn(['raw', 'sep0043']) - signatureType?: 'raw' | 'sep0043' = 'raw'; + @IsNotEmpty({ message: 'Message must not be empty when provided' }) + @MaxLength(2048, { message: 'Message must be at most 2048 characters' }) + message?: string; } diff --git a/supabase/migrations/20260825000000_add_nonce_message_binding.sql b/supabase/migrations/20260825000000_add_nonce_message_binding.sql new file mode 100644 index 0000000..bfb1055 --- /dev/null +++ b/supabase/migrations/20260825000000_add_nonce_message_binding.sql @@ -0,0 +1,27 @@ +-- #118: bind nonce rows to the exact challenge message wallets must sign. +-- +-- Previously a nonce row only stored the random value, and the server accepted +-- signatures over several ad-hoc payloads (raw nonce hex, "Stellar Signing +-- Key: "), none of which bound the signature to StepFi. This made +-- captured (nonce, signature) pairs from other contexts replayable here. +-- +-- New columns: +-- issued_at — the ISO timestamp embedded in the canonical challenge +-- envelope as "issuedAt" (server time at issue time, so the +-- envelope can be reconstructed byte-for-byte). +-- message_hash — SHA-256 hex digest of the exact challenge message text the +-- wallet must sign. Verification only ever runs against a +-- message whose digest matches this value, so a nonce can +-- never be redeemed with client-supplied alternative content. +-- +-- Both columns are nullable so pre-existing (legacy) rows keep working during +-- the documented migration window; new rows always populate them. + +ALTER TABLE public.nonces + ADD COLUMN issued_at TIMESTAMPTZ, + ADD COLUMN message_hash TEXT; + +COMMENT ON COLUMN public.nonces.issued_at IS + 'ISO timestamp embedded in the canonical challenge envelope (issuedAt). Null for pre-migration rows.'; +COMMENT ON COLUMN public.nonces.message_hash IS + 'SHA-256 hex digest of the exact challenge message the wallet must sign. Binds the nonce row to its challenge content.'; diff --git a/test/e2e/modules/auth/auth.e2e-spec.ts b/test/e2e/modules/auth/auth.e2e-spec.ts index 576087c..8eec3db 100644 --- a/test/e2e/modules/auth/auth.e2e-spec.ts +++ b/test/e2e/modules/auth/auth.e2e-spec.ts @@ -2,6 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { INestApplication, ValidationPipe } from '@nestjs/common'; import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify'; import * as request from 'supertest'; +import { createHash } from 'crypto'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { AuthModule } from '../../../../src/modules/auth/auth.module'; import { UsersModule } from '../../../../src/modules/users/users.module'; @@ -10,6 +11,12 @@ import { SupabaseService } from '../../../../src/database/supabase.client'; import { createTestKeypair, signMessage } from '../../../helpers'; import { createMockRegisterRequest } from '../../../fixtures'; +/** SEP-53 message signing: signature over SHA-256 of "Stellar Signed Message:\n" + message. */ +function signSep53(keypair: ReturnType, message: string): string { + const digest = createHash('sha256').update('Stellar Signed Message:\n' + message, 'utf8').digest(); + return keypair.sign(digest).toString('base64'); +} + describe('AuthController (e2e)', () => { let app: NestFastifyApplication; let supabaseService: SupabaseService; @@ -104,11 +111,30 @@ describe('AuthController (e2e)', () => { expect(response.body).toHaveProperty('nonce'); expect(response.body).toHaveProperty('expiresAt'); + expect(response.body).toHaveProperty('message'); expect(typeof response.body.nonce).toBe('string'); expect(response.body.nonce).toHaveLength(64); expect(new Date(response.body.expiresAt).getTime()).toBeGreaterThan(Date.now()); }); + it('should return a canonical challenge message bound to the wallet and nonce', async () => { + const wallet = createTestKeypair().publicKey(); + testWallets.push(wallet); + + const response = await request(app.getHttpServer()) + .post('/auth/nonce') + .send({ wallet }) + .expect(201); + + const envelope = JSON.parse(response.body.message); + expect(envelope.address).toBe(wallet); + expect(envelope.nonce).toBe(response.body.nonce); + expect(envelope.domain).toBeTruthy(); + expect(envelope.uri).toBeTruthy(); + expect(envelope.expirationTime).toBe(response.body.expiresAt); + expect(envelope.networkPassphrase).toBeTruthy(); + }); + it('should return 400 with invalid wallet format (too short)', async () => { await request(app.getHttpServer()) .post('/auth/nonce') @@ -280,6 +306,99 @@ describe('AuthController (e2e)', () => { .send({ wallet, nonce, signature }) .expect(401); }); + + it('should complete full auth flow with the canonical envelope (native, signatureType envelope)', async () => { + const keypair = createTestKeypair(); + const wallet = keypair.publicKey(); + testWallets.push(wallet); + + const nonceResponse = await request(app.getHttpServer()) + .post('/auth/nonce') + .send({ wallet }) + .expect(201); + + const { nonce, message } = nonceResponse.body; + // Native clients sign the exact challenge message with raw Ed25519. + const signature = signMessage(keypair, message); + + const verifyResponse = await request(app.getHttpServer()) + .post('/auth/verify') + .send({ wallet, nonce, signature, signatureType: 'envelope', message }) + .expect(200); + + expect(verifyResponse.body).toHaveProperty('accessToken'); + expect(verifyResponse.body).toHaveProperty('refreshToken'); + + await request(app.getHttpServer()) + .get('/users/me') + .set('Authorization', `Bearer ${verifyResponse.body.accessToken}`) + .expect(200); + }); + + it('should complete full auth flow with a SEP-53 browser signature (signatureType sep0043)', async () => { + const keypair = createTestKeypair(); + const wallet = keypair.publicKey(); + testWallets.push(wallet); + + const nonceResponse = await request(app.getHttpServer()) + .post('/auth/nonce') + .send({ wallet }) + .expect(201); + + const { nonce, message } = nonceResponse.body; + // Browser wallets (Freighter) sign SHA-256("Stellar Signed Message:\n" + message). + const signature = signSep53(keypair, message); + + const verifyResponse = await request(app.getHttpServer()) + .post('/auth/verify') + .send({ wallet, nonce, signature, signatureType: 'sep0043', message }) + .expect(200); + + expect(verifyResponse.body).toHaveProperty('accessToken'); + expect(verifyResponse.body).toHaveProperty('refreshToken'); + }); + + it('should reject a tampered challenge message (not the one issued for the nonce)', async () => { + const keypair = createTestKeypair(); + const wallet = keypair.publicKey(); + testWallets.push(wallet); + + const nonceResponse = await request(app.getHttpServer()) + .post('/auth/nonce') + .send({ wallet }) + .expect(201); + + const { nonce, message } = nonceResponse.body; + const tampered = message.replace('authenticate your wallet', 'authenticate'); + const signature = signMessage(keypair, tampered); + + await request(app.getHttpServer()) + .post('/auth/verify') + .send({ wallet, nonce, signature, signatureType: 'envelope', message: tampered }) + .expect(401); + }); + + it('should reject a challenge message bound to a foreign domain', async () => { + const keypair = createTestKeypair(); + const wallet = keypair.publicKey(); + testWallets.push(wallet); + + const nonceResponse = await request(app.getHttpServer()) + .post('/auth/nonce') + .send({ wallet }) + .expect(201); + + const { nonce, message } = nonceResponse.body; + const envelope = JSON.parse(message); + envelope.domain = 'evil.example.com'; + const foreign = JSON.stringify(envelope, null, 2); + const signature = signMessage(keypair, foreign); + + await request(app.getHttpServer()) + .post('/auth/verify') + .send({ wallet, nonce, signature, signatureType: 'envelope', message: foreign }) + .expect(401); + }); }); describe('POST /auth/register', () => { diff --git a/test/unit/modules/auth/auth.service.spec.ts b/test/unit/modules/auth/auth.service.spec.ts index 074188b..40c68c1 100644 --- a/test/unit/modules/auth/auth.service.spec.ts +++ b/test/unit/modules/auth/auth.service.spec.ts @@ -2,9 +2,11 @@ import { Test, TestingModule } from '@nestjs/testing'; import { InternalServerErrorException, ConflictException, UnauthorizedException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; +import { createHash } from 'crypto'; import { AuthService } from '../../../../src/modules/auth/auth.service'; import { SupabaseService } from '../../../../src/database/supabase.client'; import { UsersRepository } from '../../../../src/database/repositories/users.repository'; +import { VerifyRequestDto } from '../../../../src/modules/auth/dto/verify-request.dto'; // Mock Stellar SDK to avoid real crypto operations in unit tests jest.mock('stellar-sdk', () => ({ @@ -14,6 +16,36 @@ jest.mock('stellar-sdk', () => ({ import { Keypair, StrKey } from 'stellar-sdk'; +// Env values the service resolves in its constructor (matching the mocked +// ConfigService below) so challenge envelopes can be reproduced in tests. +// URL.host includes the port, so localhost:3000 is the challenge domain. +const CHALLENGE_DOMAIN = 'localhost:3000'; +const CHALLENGE_URI = 'http://localhost:3000/api/v1/auth/verify'; +const NETWORK_PASSPHRASE = 'Test SDF Network ; September 2015'; +const CHALLENGE_STATEMENT = + 'StepFi requests that you sign this message to authenticate your wallet. ' + + 'This message does not trigger any blockchain transaction.'; +const SEP_53_PREFIX = 'Stellar Signed Message:\n'; + +/** Reproduces the service's canonical challenge envelope serialization. */ +function buildChallengeMessage(wallet: string, nonce: string, issuedAt: Date, expiresAt: Date): string { + return JSON.stringify( + { + domain: CHALLENGE_DOMAIN, + address: wallet, + statement: CHALLENGE_STATEMENT, + uri: CHALLENGE_URI, + version: '1.0.0', + nonce, + issuedAt: issuedAt.toISOString(), + expirationTime: expiresAt.toISOString(), + networkPassphrase: NETWORK_PASSPHRASE, + }, + null, + 2, + ); +} + describe('AuthService', () => { let service: AuthService; @@ -31,7 +63,7 @@ describe('AuthService', () => { }; const mockConfigService = { - get: jest.fn().mockReturnValue('mock-secret'), + get: jest.fn(), }; const mockUsersRepository = { @@ -43,7 +75,29 @@ describe('AuthService', () => { const validWallet = 'GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVW'; + /** Config mock matching the constructor's expectations. */ + function configureConfigService() { + mockConfigService.get.mockImplementation((key: string) => { + switch (key) { + case 'API_URL': + return 'http://localhost:3000'; + case 'API_PREFIX': + return 'api/v1'; + case 'STELLAR_NETWORK_PASSPHRASE': + return NETWORK_PASSPHRASE; + case 'AUTH_CHALLENGE_DOMAIN': + return undefined; + case 'AUTH_ALLOW_LEGACY_RAW_SIGNATURES': + return undefined; // default: legacy accepted during migration window + default: + return 'mock-secret'; + } + }); + } + beforeEach(async () => { + configureConfigService(); + const module: TestingModule = await Test.createTestingModule({ providers: [ AuthService, @@ -59,7 +113,7 @@ describe('AuthService', () => { jest.clearAllMocks(); mockInsert.mockResolvedValue({ error: null }); mockJwtService.sign.mockReturnValue('mock.jwt.token'); - mockConfigService.get.mockReturnValue('mock-secret'); + configureConfigService(); mockFrom.mockImplementation((table: string) => { if (table === 'users') { const chain: Record = { @@ -102,11 +156,12 @@ describe('AuthService', () => { // generateNonce // --------------------------------------------------------------------------- describe('generateNonce', () => { - it('should return nonce and expiresAt', async () => { + it('should return nonce, expiresAt and a canonical challenge message', async () => { const result = await service.generateNonce(validWallet); expect(result).toHaveProperty('nonce'); expect(result).toHaveProperty('expiresAt'); + expect(result).toHaveProperty('message'); expect(typeof result.nonce).toBe('string'); expect(result.nonce).toHaveLength(64); expect(/^[a-f0-9]+$/.test(result.nonce)).toBe(true); @@ -132,16 +187,33 @@ describe('AuthService', () => { expect(expiresAtTime).toBeLessThanOrEqual(after + fiveMinutes + tolerance); }); - it('should store nonce in database with correct data', async () => { - await service.generateNonce(validWallet); + it('should return a challenge message bound to this environment, wallet and nonce', async () => { + const result = await service.generateNonce(validWallet); + + const envelope = JSON.parse(result.message); + expect(envelope.domain).toBe(CHALLENGE_DOMAIN); + expect(envelope.address).toBe(validWallet); + expect(envelope.uri).toBe(CHALLENGE_URI); + expect(envelope.nonce).toBe(result.nonce); + expect(envelope.version).toBe('1.0.0'); + expect(envelope.issuedAt).toBeDefined(); + expect(envelope.expirationTime).toBe(result.expiresAt); + expect(envelope.networkPassphrase).toBe(NETWORK_PASSPHRASE); + }); + + it('should store nonce in database with the exact challenge message hash', async () => { + const result = await service.generateNonce(validWallet); expect(mockSupabaseService.getServiceRoleClient).toHaveBeenCalled(); expect(mockFrom).toHaveBeenCalledWith('nonces'); + const expectedHash = createHash('sha256').update(result.message, 'utf8').digest('hex'); expect(mockInsert).toHaveBeenCalledWith( expect.objectContaining({ wallet_address: validWallet, nonce: expect.any(String), expires_at: expect.any(String), + issued_at: expect.any(String), + message_hash: expectedHash, }), ); }); @@ -156,7 +228,7 @@ describe('AuthService', () => { }); // --------------------------------------------------------------------------- - // verifySignature — validates nonce + Ed25519 signature, marks nonce used + // verifySignature — domain-bound challenge verification // --------------------------------------------------------------------------- describe('verifySignature', () => { const validNonce = 'a1b2c3d4e5f67890abcdef1234567890a1b2c3d4e5f67890abcdef1234567890'; @@ -165,11 +237,39 @@ describe('AuthService', () => { const defaultNonceRecord = { id: 'nonce-uuid', expires_at: futureExpiry }; + type NonceResult = { data: object | null; error: { message: string } | null }; + + interface TestNonceRecord { + id: string; + expires_at: string; + issued_at: string; + message_hash: string; + } + + /** Nonce record for a challenge issued ~1 minute ago, valid for 5 more. */ + function buildNonceRecord(overrides: Partial = {}): TestNonceRecord { + const issuedAt = new Date(Date.now() - 60 * 1000); + const expiresAt = new Date(Date.now() + 5 * 60 * 1000); + const message = buildChallengeMessage(validWallet, validNonce, issuedAt, expiresAt); + return { + id: 'nonce-uuid', + expires_at: expiresAt.toISOString(), + issued_at: issuedAt.toISOString(), + message_hash: createHash('sha256').update(message, 'utf8').digest('hex'), + ...overrides, + }; + } + function setupMocks({ nonceResult = { data: defaultNonceRecord, error: null }, markUsedResult = { error: null }, signatureValid = true, strKeyValid = true, + }: { + nonceResult?: NonceResult; + markUsedResult?: { error: unknown }; + signatureValid?: boolean; + strKeyValid?: boolean; } = {}) { const mockKeypair = { verify: jest.fn().mockReturnValue(signatureValid) }; (Keypair.fromPublicKey as jest.Mock).mockReturnValue(mockKeypair); @@ -196,9 +296,11 @@ describe('AuthService', () => { return { mockKeypair }; } - const validDto = { wallet: validWallet, nonce: validNonce, signature: validSignature }; + const validDto: VerifyRequestDto = { wallet: validWallet, nonce: validNonce, signature: validSignature }; - it('should resolve without error when nonce and signature are valid', async () => { + // --- legacy raw scheme (deprecated, migration window) ------------------- + + it('should resolve without error when nonce and signature are valid (legacy raw)', async () => { setupMocks(); await expect(service.verifySignature(validDto)).resolves.toBeUndefined(); }); @@ -239,7 +341,7 @@ describe('AuthService', () => { }); }); - it('should throw UnauthorizedException (AUTH_SIGNATURE_INVALID) when signature does not verify', async () => { + it('should throw UnauthorizedException (AUTH_SIGNATURE_INVALID) when legacy signature does not verify', async () => { setupMocks({ signatureValid: false }); await expect(service.verifySignature(validDto)).rejects.toMatchObject({ @@ -258,7 +360,7 @@ describe('AuthService', () => { }); }); - it('should verify signature using Stellar Keypair with nonce bytes and base64 signature', async () => { + it('should verify a legacy signature using Stellar Keypair with nonce bytes and base64 signature', async () => { const { mockKeypair } = setupMocks(); await service.verifySignature(validDto); @@ -269,23 +371,158 @@ describe('AuthService', () => { ); }); - it('should verify using SEP-0043 if raw verification fails', async () => { - const { mockKeypair } = setupMocks({ signatureValid: false }); - // First call (raw) returns false, second call (sep0043) should return true - mockKeypair.verify.mockImplementationOnce(() => false).mockImplementationOnce(() => true); + it('should reject legacy raw nonce signatures when AUTH_ALLOW_LEGACY_RAW_SIGNATURES is false', async () => { + const flagOffConfig = { + get: jest.fn((key: string) => { + switch (key) { + case 'API_URL': + return 'http://localhost:3000'; + case 'API_PREFIX': + return 'api/v1'; + case 'STELLAR_NETWORK_PASSPHRASE': + return NETWORK_PASSPHRASE; + case 'AUTH_ALLOW_LEGACY_RAW_SIGNATURES': + return 'false'; + default: + return 'mock-secret'; + } + }), + }; + const serviceWithLegacyOff = new AuthService( + mockSupabaseService as unknown as SupabaseService, + mockJwtService as unknown as JwtService, + flagOffConfig as unknown as ConfigService, + mockUsersRepository as unknown as UsersRepository, + ); + setupMocks(); - await expect(service.verifySignature(validDto)).resolves.toBeUndefined(); + await expect(serviceWithLegacyOff.verifySignature(validDto)).rejects.toMatchObject({ + response: { code: 'AUTH_LEGACY_SIGNATURE_DISABLED' }, + }); + }); - expect(Keypair.fromPublicKey).toHaveBeenCalledWith(validWallet); - expect(mockKeypair.verify).toHaveBeenCalledWith(Buffer.from(validNonce), Buffer.from(validSignature, 'base64')); + // --- canonical envelope: native (signatureType 'envelope') -------------- + + it('should accept a native signature over the canonical envelope (signatureType envelope)', async () => { + const record = buildNonceRecord(); + const { mockKeypair } = setupMocks({ nonceResult: { data: record, error: null } }); + const message = buildChallengeMessage(validWallet, validNonce, new Date(record.issued_at), new Date(record.expires_at)); + const dto: VerifyRequestDto = { ...validDto, signatureType: 'envelope', message }; + + await expect(service.verifySignature(dto)).resolves.toBeUndefined(); + + expect(mockKeypair.verify).toHaveBeenCalledWith( + Buffer.from(message, 'utf8'), + Buffer.from(validSignature, 'base64'), + ); + }); + + it('should verify a canonical signature against the reconstructed message when the client omits message', async () => { + const record = buildNonceRecord(); + const { mockKeypair } = setupMocks({ nonceResult: { data: record, error: null } }); + const dto: VerifyRequestDto = { ...validDto, signatureType: 'envelope' }; + + await expect(service.verifySignature(dto)).resolves.toBeUndefined(); + + const message = buildChallengeMessage(validWallet, validNonce, new Date(record.issued_at), new Date(record.expires_at)); expect(mockKeypair.verify).toHaveBeenCalledWith( - Buffer.from('Stellar Signing Key: ' + validNonce), + Buffer.from(message, 'utf8'), Buffer.from(validSignature, 'base64'), ); }); + it('should reject a client-supplied message that does not match the stored challenge hash', async () => { + const record = buildNonceRecord(); + setupMocks({ nonceResult: { data: record, error: null } }); + const message = buildChallengeMessage(validWallet, validNonce, new Date(record.issued_at), new Date(record.expires_at)); + const tampered = message.replace('authenticate your wallet', 'authenticate'); + const dto: VerifyRequestDto = { ...validDto, signatureType: 'envelope', message: tampered }; + + await expect(service.verifySignature(dto)).rejects.toMatchObject({ + response: { code: 'AUTH_CHALLENGE_MISMATCH' }, + }); + }); + + it('should reject a canonical challenge whose envelope expirationTime has passed', async () => { + const issuedAt = new Date(Date.now() - 10 * 60 * 1000); + const expiresAt = new Date(Date.now() - 5 * 60 * 1000); // envelope expired + const message = buildChallengeMessage(validWallet, validNonce, issuedAt, expiresAt); + const record = buildNonceRecord({ + expires_at: futureExpiry, // DB row still valid — envelope expiry is enforced separately + issued_at: issuedAt.toISOString(), + message_hash: createHash('sha256').update(message, 'utf8').digest('hex'), + }); + setupMocks({ nonceResult: { data: record, error: null } }); + const dto: VerifyRequestDto = { ...validDto, signatureType: 'envelope', message }; + + await expect(service.verifySignature(dto)).rejects.toMatchObject({ + response: { code: 'AUTH_NONCE_EXPIRED' }, + }); + }); + + it('should reject canonical verification when the nonce row has no stored challenge hash', async () => { + setupMocks({ + nonceResult: { + data: { id: 'nonce-uuid', expires_at: futureExpiry, issued_at: null, message_hash: null }, + error: null, + }, + }); + const message = buildChallengeMessage(validWallet, validNonce, new Date(), new Date(Date.now() + 5 * 60 * 1000)); + const dto: VerifyRequestDto = { ...validDto, signatureType: 'envelope', message }; + + await expect(service.verifySignature(dto)).rejects.toMatchObject({ + response: { code: 'AUTH_SIGNATURE_INVALID' }, + }); + }); + + // --- canonical envelope: browser (signatureType 'sep0043', SEP-53) ------ + + it('should accept a SEP-53 browser signature over the canonical envelope (signatureType sep0043)', async () => { + const record = buildNonceRecord(); + const { mockKeypair } = setupMocks({ nonceResult: { data: record, error: null } }); + const message = buildChallengeMessage(validWallet, validNonce, new Date(record.issued_at), new Date(record.expires_at)); + const dto: VerifyRequestDto = { ...validDto, signatureType: 'sep0043', message }; + + await expect(service.verifySignature(dto)).resolves.toBeUndefined(); + + const digest = createHash('sha256').update(SEP_53_PREFIX + message, 'utf8').digest(); + expect(mockKeypair.verify).toHaveBeenCalledWith(digest, Buffer.from(validSignature, 'base64')); + }); + + it('should reject a SEP-53 signature whose envelope domain does not match our host', async () => { + const record = buildNonceRecord(); + const message = buildChallengeMessage(validWallet, validNonce, new Date(record.issued_at), new Date(record.expires_at)); + const foreignMessage = message.replace('"domain": "localhost:3000"', '"domain": "evil.example.com"'); + // Simulates a nonce row whose stored binding points at a foreign domain + // (e.g. a challenge issued by another environment being replayed here). + const tamperedRecord = buildNonceRecord({ + message_hash: createHash('sha256').update(foreignMessage, 'utf8').digest('hex'), + }); + setupMocks({ nonceResult: { data: tamperedRecord, error: null } }); + const dto: VerifyRequestDto = { ...validDto, signatureType: 'sep0043', message: foreignMessage }; + + await expect(service.verifySignature(dto)).rejects.toMatchObject({ + response: { code: 'AUTH_CHALLENGE_DOMAIN_MISMATCH' }, + }); + }); + + it('should reject a canonical signature when the envelope network passphrase does not match', async () => { + const record = buildNonceRecord(); + const message = buildChallengeMessage(validWallet, validNonce, new Date(record.issued_at), new Date(record.expires_at)); + const foreignMessage = message.replace(NETWORK_PASSPHRASE, 'Public Global Stellar Network ; September 2015'); + const tamperedRecord = buildNonceRecord({ + message_hash: createHash('sha256').update(foreignMessage, 'utf8').digest('hex'), + }); + setupMocks({ nonceResult: { data: tamperedRecord, error: null } }); + const dto: VerifyRequestDto = { ...validDto, signatureType: 'sep0043', message: foreignMessage }; + + await expect(service.verifySignature(dto)).rejects.toMatchObject({ + response: { code: 'AUTH_CHALLENGE_NETWORK_MISMATCH' }, + }); + }); + it('should mark nonce as used after successful verification', async () => { - const { } = setupMocks(); + setupMocks(); await service.verifySignature(validDto); expect(mockFrom).toHaveBeenCalledWith('nonces');