From e93b5d5bd299bc20b297726777cf178e92c6498a Mon Sep 17 00:00:00 2001 From: EmeditWeb Date: Tue, 29 Sep 2026 15:42:10 +0100 Subject: [PATCH] feat(auth): add SEP-10-style challenge-transaction signature scheme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a 'sep0010' verification scheme to POST /auth/verify so wallets that only expose transaction signing (mobile Lobstr over WalletConnect stellar_signXDR) can authenticate, not just wallets that sign arbitrary messages. POST /auth/nonce now also returns `challengeXdr`: an unsigned SEP-10-style challenge transaction whose source is the user's own wallet at sequence 0 (built on an Account seeded at "-1") with a single manageData operation whose value equals the SHA-256 hash already stored on the nonce row. The wallet signs this XDR and returns it as `signedXdr` with `signatureType: 'sep0010'`. Verification asserts the signed transaction is exactly the issued challenge — matching source, sequence 0, single manageData op with the expected name and a value equal to the stored challenge hash, non-expired timebounds — and carries a valid wallet signature over the transaction hash (the network passphrase is bound implicitly through tx.hash()). Deliberate deviation from strict SEP-10: the challenge is server-issued but not server-signed, so no server SIGNING_KEY secret is introduced. Forgery/replay is already prevented by the existing single-use nonce row, the stored message_hash binding, and the timebounds. Strict SEP-10 (server co-signature + WEB_AUTH_DOMAIN) remains a documented follow-up. The legacy raw/sep0043/envelope message schemes are unchanged. - nonce-response.dto: add challengeXdr - verify-request.dto: add 'sep0010' to signatureType; add optional signedXdr; make signature conditional (not required for sep0010) - tests: real-SDK round-trip spec (happy path + tamper/expiry/unsigned/ wrong-source/wrong-name/consumed-nonce), extend existing mock --- src/modules/auth/auth.service.ts | 135 ++++++++- src/modules/auth/dto/nonce-response.dto.ts | 11 + src/modules/auth/dto/verify-request.dto.ts | 31 +- .../modules/auth/auth.service.sep0010.spec.ts | 282 ++++++++++++++++++ test/unit/modules/auth/auth.service.spec.ts | 16 +- 5 files changed, 464 insertions(+), 11 deletions(-) create mode 100644 test/unit/modules/auth/auth.service.sep0010.spec.ts diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index f94b9da..bb69453 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -8,7 +8,7 @@ import { import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; import { createHash, randomBytes, randomUUID } from 'crypto'; -import { Keypair, StrKey } from 'stellar-sdk'; +import { Keypair, StrKey, Account, Operation, TransactionBuilder, Transaction, BASE_FEE } from 'stellar-sdk'; import { SupabaseService } from '../../database/supabase.client'; import { UsersRepository, UploadedAvatarFile } from '../../database/repositories/users.repository'; import { NonceResponseDto } from './dto/nonce-response.dto'; @@ -33,6 +33,12 @@ const CHALLENGE_STATEMENT = const DEFAULT_NETWORK_PASSPHRASE = 'Test SDF Network ; September 2015'; export const LEGACY_RAW_SIGNATURES_SUNSET = '2026-10-31'; +// Name of the single manageData operation carried by the SEP-10-style +// challenge transaction. Its value binds the transaction to the nonce row's +// stored challenge hash, so a signed challenge can only ever authenticate the +// exact nonce it was issued for. +const CHALLENGE_DATA_NAME = 'stepfi_auth_challenge'; + interface StoredNonce { id: string; expires_at: string; @@ -155,6 +161,7 @@ export class AuthService { 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 challengeXdr = this.buildChallengeTransaction({ wallet, issuedAt, expiresAt, messageHash }); const client = this.supabaseService.getServiceRoleClient(); const { error } = await client.from('nonces').insert({ wallet_address: wallet, @@ -166,7 +173,7 @@ export class AuthService { if (error) { throw new InternalServerErrorException({ code: 'DATABASE_NONCE_INSERT_FAILED', message: 'Failed to generate nonce.' }); } - return { nonce, expiresAt: expiresAt.toISOString(), message }; + return { nonce, expiresAt: expiresAt.toISOString(), message, challengeXdr }; } /** @@ -236,11 +243,24 @@ 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'; + if (signatureType === 'sep0010') { + // SEP-10-style scheme: the wallet signs a server-issued challenge + // TRANSACTION (not a message), so wallets that only expose + // stellar_signXDR (e.g. mobile Lobstr over WalletConnect) can still + // authenticate. The signature is carried inside the signed XDR. + this.verifySep0010Challenge(dto, nonceRecord as StoredNonce, keypair); + return; + } + + if (!dto.signature) { + throw new UnauthorizedException({ code: 'AUTH_SIGNATURE_INVALID', message: 'Invalid signature.' }); + } + const signatureBuffer = Buffer.from(dto.signature, 'base64'); + if (signatureType === 'raw') { // Legacy mobile scheme: signature over the bare nonce hex bytes. // Deprecated — no domain binding, gated behind a config flag. @@ -269,6 +289,115 @@ export class AuthService { } } + /** + * Builds the SEP-10-style challenge transaction the wallet must sign. + * + * Deviation from strict SEP-10: the transaction is server-ISSUED but not + * server-SIGNED, and its source is the user's own wallet with sequence 0 + * (built on an Account seeded at "-1"). We do not run a server signing key; + * forgery/replay is instead prevented by the single-use nonce row, the + * stored `message_hash` binding carried in the manageData value, and the + * transaction timebounds. A `.build()`ed transaction with a source account + * of sequence 0 can never be submitted to the network, so this is a pure + * authentication artifact. + */ + private buildChallengeTransaction(opts: { + wallet: string; + issuedAt: Date; + expiresAt: Date; + messageHash: string; + }): string { + // Account seeded at "-1" so the first (and only) built transaction has + // sequence 0 — asserted on verification. + const account = new Account(opts.wallet, '-1'); + const transaction = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: this.networkPassphrase, + timebounds: { + minTime: Math.floor(opts.issuedAt.getTime() / 1000), + maxTime: Math.floor(opts.expiresAt.getTime() / 1000), + }, + }) + .addOperation( + Operation.manageData({ + name: CHALLENGE_DATA_NAME, + value: Buffer.from(opts.messageHash, 'hex'), + }), + ) + .build(); + return transaction.toXDR(); + } + + /** + * Verifies a signed SEP-10-style challenge transaction. Asserts the parsed + * transaction is exactly the challenge we issued for this nonce — same + * source wallet, sequence 0, a single `manageData` op whose value equals the + * stored challenge hash, valid (non-expired) timebounds — and that it carries + * a valid signature from the wallet over the transaction hash. The network + * passphrase is bound implicitly: the signature is over `tx.hash()`, which + * only matches when the client signed for this exact network. + */ + private verifySep0010Challenge(dto: VerifyRequestDto, stored: StoredNonce, keypair: Keypair): void { + if (!dto.signedXdr) { + throw new UnauthorizedException({ code: 'AUTH_SIGNATURE_INVALID', message: 'Invalid signature.' }); + } + if (!stored.message_hash) { + throw new UnauthorizedException({ code: 'AUTH_SIGNATURE_INVALID', message: 'Invalid signature.' }); + } + + let transaction: Transaction; + try { + transaction = new Transaction(dto.signedXdr, this.networkPassphrase); + } catch { + throw new UnauthorizedException({ code: 'AUTH_SIGNATURE_INVALID', message: 'Invalid signature.' }); + } + + if (transaction.source !== dto.wallet || transaction.sequence !== '0') { + throw new UnauthorizedException({ + code: 'AUTH_CHALLENGE_MISMATCH', + message: 'Signed transaction does not match the issued challenge.', + }); + } + + if (transaction.operations.length !== 1) { + throw new UnauthorizedException({ + code: 'AUTH_CHALLENGE_MISMATCH', + message: 'Signed transaction does not match the issued challenge.', + }); + } + const [operation] = transaction.operations; + if (operation.type !== 'manageData' || operation.name !== CHALLENGE_DATA_NAME) { + throw new UnauthorizedException({ + code: 'AUTH_CHALLENGE_MISMATCH', + message: 'Signed transaction does not match the issued challenge.', + }); + } + const value = operation.value; + if (!value || Buffer.from(value).toString('hex') !== stored.message_hash) { + throw new UnauthorizedException({ + code: 'AUTH_CHALLENGE_MISMATCH', + message: 'Signed transaction does not match the issued challenge.', + }); + } + + const timeBounds = transaction.timeBounds; + if (!timeBounds || Number(timeBounds.maxTime) * 1000 <= Date.now()) { + throw new UnauthorizedException({ code: 'AUTH_NONCE_EXPIRED', message: 'Challenge has expired.' }); + } + + const hash = transaction.hash(); + const signed = transaction.signatures.some((sig) => { + try { + return keypair.verify(hash, sig.signature()); + } catch { + return false; + } + }); + if (!signed) { + throw new UnauthorizedException({ code: 'AUTH_SIGNATURE_INVALID', message: 'Invalid signature.' }); + } + } + /** * Resolves the exact bytes to verify the signature against. Prefers the * message the client echoes back (must still hash-match the stored diff --git a/src/modules/auth/dto/nonce-response.dto.ts b/src/modules/auth/dto/nonce-response.dto.ts index f2ae35b..7db3643 100644 --- a/src/modules/auth/dto/nonce-response.dto.ts +++ b/src/modules/auth/dto/nonce-response.dto.ts @@ -26,4 +26,15 @@ export class NonceResponseDto { '{\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; + + @ApiProperty({ + description: + 'Base64-encoded SEP-10-style challenge transaction (unsigned envelope XDR) bound to ' + + 'this nonce. Wallets that only expose transaction signing (e.g. mobile Lobstr over ' + + 'WalletConnect stellar_signXDR) sign this and return it as `signedXdr` with ' + + '`signatureType: "sep0010"` in POST /auth/verify. Its single manageData operation ' + + 'value equals the SHA-256 hash of `message`, binding the transaction to this challenge.', + example: 'AAAAAgAAAAD...==', + }) + challengeXdr: string; } diff --git a/src/modules/auth/dto/verify-request.dto.ts b/src/modules/auth/dto/verify-request.dto.ts index fcc3493..bca95a6 100644 --- a/src/modules/auth/dto/verify-request.dto.ts +++ b/src/modules/auth/dto/verify-request.dto.ts @@ -1,4 +1,4 @@ -import { IsString, IsNotEmpty, Matches, Length, IsOptional, IsIn, MaxLength } from 'class-validator'; +import { IsString, IsNotEmpty, Matches, Length, IsOptional, IsIn, MaxLength, ValidateIf } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; /** @@ -43,24 +43,41 @@ export class VerifyRequestDto { @ApiProperty({ description: - 'Base64-encoded Ed25519 signature over the challenge message (or, for the deprecated raw scheme, over the nonce bytes)', + 'Base64-encoded Ed25519 signature over the challenge message (or, for the deprecated raw scheme, over the nonce bytes). ' + + 'Not used for signatureType sep0010, where the signature is carried inside signedXdr.', example: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + required: false, }) + @ValidateIf((o: VerifyRequestDto) => o.signatureType !== 'sep0010') @IsString() @IsNotEmpty({ message: 'Signature is required' }) - signature: string; + signature?: string; @ApiProperty({ 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).", + "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. 'sep0010' — SEP-10-style challenge transaction, wallet signs the server-issued challengeXdr and returns it as signedXdr (works with transaction-only wallets such as mobile Lobstr). 'raw' — legacy, signature over the bare nonce hex (deprecated, flag-gated).", example: 'envelope', required: false, - enum: ['raw', 'sep0043', 'envelope'], + enum: ['raw', 'sep0043', 'envelope', 'sep0010'], }) @IsOptional() @IsString() - @IsIn(['raw', 'sep0043', 'envelope']) - signatureType?: 'raw' | 'sep0043' | 'envelope' = 'raw'; + @IsIn(['raw', 'sep0043', 'envelope', 'sep0010']) + signatureType?: 'raw' | 'sep0043' | 'envelope' | 'sep0010' = 'raw'; + + @ApiProperty({ + description: + 'Base64-encoded signed challenge transaction envelope XDR (required for signatureType sep0010). ' + + 'This is the challengeXdr from POST /auth/nonce after signing it with the wallet. The server ' + + 'verifies the transaction matches the issued challenge and carries a valid wallet signature.', + example: 'AAAAAgAAAAD...==', + required: false, + }) + @ValidateIf((o: VerifyRequestDto) => o.signatureType === 'sep0010') + @IsString() + @IsNotEmpty({ message: 'Signed transaction is required for sep0010' }) + @MaxLength(8192, { message: 'Signed transaction must be at most 8192 characters' }) + signedXdr?: string; @ApiProperty({ description: diff --git a/test/unit/modules/auth/auth.service.sep0010.spec.ts b/test/unit/modules/auth/auth.service.sep0010.spec.ts new file mode 100644 index 0000000..17497d2 --- /dev/null +++ b/test/unit/modules/auth/auth.service.sep0010.spec.ts @@ -0,0 +1,282 @@ +// A manual mock at test/__mocks__/stellar-sdk.js is auto-applied to every test +// file (jest auto-mocks node modules that have a manual mock under roots). This +// spec needs the REAL SDK to exercise the genuine sign/verify round-trip, so we +// opt out explicitly. jest.unmock is hoisted above the imports by ts-jest. +jest.unmock('stellar-sdk'); + +import { InternalServerErrorException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; +import { Account, BASE_FEE, Keypair, Operation, Transaction, TransactionBuilder } from 'stellar-sdk'; +import { AuthService } from '../../../../src/modules/auth/auth.service'; +import { SupabaseService } from '../../../../src/database/supabase.client'; +import { UsersRepository } from '../../../../src/database/repositories/users.repository'; +import { AuditService } from '../../../../src/modules/admin/audit.service'; +import { VerifyRequestDto } from '../../../../src/modules/auth/dto/verify-request.dto'; + +// This spec intentionally does NOT mock stellar-sdk. jest.mock is per-file, so +// the message-scheme spec (auth.service.spec.ts) can keep its lightweight mock +// while this file exercises the real SEP-10 challenge round-trip: build an +// unsigned challenge transaction on the server, sign it with a throwaway +// Keypair (exactly what a signXDR-only wallet like mobile Lobstr does), and +// prove POST /auth/verify accepts it and rejects every tamper. + +const NETWORK_PASSPHRASE = 'Test SDF Network ; September 2015'; +const CHALLENGE_DATA_NAME = 'stepfi_auth_challenge'; + +describe('AuthService — SEP-10 challenge transaction (sep0010)', () => { + const wallet = Keypair.random(); + const walletAddress = wallet.publicKey(); + + // Captures the row generateNonce inserts, so the verify SELECT can return the + // exact message_hash the challenge was bound to. + let insertedNonce: { nonce: string; expires_at: string; issued_at: string; message_hash: string } | null; + const mockInsert = jest.fn((row: typeof insertedNonce) => { + insertedNonce = row; + return Promise.resolve({ error: null }); + }); + + // Overridable results for the verify-path nonces builder. + let nonceSelectResult: { data: unknown; error: unknown }; + let claimResult: { data: unknown[] | null; error: unknown; count: number | null }; + + const mockFrom = jest.fn((table: string) => { + if (table !== 'nonces') { + return { insert: jest.fn().mockResolvedValue({ error: null }) }; + } + const builder: Record = { + insert: mockInsert as unknown as jest.Mock, + select: jest.fn(), + eq: jest.fn(), + is: jest.fn(), + single: jest.fn().mockImplementation(() => Promise.resolve(nonceSelectResult)), + update: jest.fn(), + }; + let operation: 'select' | 'update' = 'select'; + builder.select.mockImplementation(() => { + if (operation === 'update') return Promise.resolve(claimResult); + return builder; + }); + builder.update.mockImplementation(() => { + operation = 'update'; + return builder; + }); + builder.eq.mockReturnValue(builder); + builder.is.mockReturnValue(builder); + return builder; + }); + + const mockSupabaseService = { + getServiceRoleClient: jest.fn(() => ({ from: mockFrom })), + }; + + function createService(): AuthService { + const config = { + 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; + default: + return undefined; + } + }), + }; + return new AuthService( + mockSupabaseService as unknown as SupabaseService, + { sign: jest.fn().mockReturnValue('mock.jwt.token'), verify: jest.fn() } as unknown as JwtService, + config as unknown as ConfigService, + {} as unknown as UsersRepository, + { log: jest.fn(), logWithBeforeAfter: jest.fn() } as unknown as AuditService, + ); + } + + let service: AuthService; + + beforeEach(() => { + jest.clearAllMocks(); + insertedNonce = null; + claimResult = { data: [{ id: 'nonce-uuid' }], error: null, count: 1 }; + service = createService(); + }); + + /** Issues a real challenge via generateNonce and wires the verify SELECT to it. */ + async function issueChallenge(): Promise<{ nonce: string; challengeXdr: string }> { + const result = await service.generateNonce(walletAddress); + // The verify SELECT must observe the same row generateNonce persisted. + nonceSelectResult = { + data: { + id: 'nonce-uuid', + expires_at: insertedNonce!.expires_at, + issued_at: insertedNonce!.issued_at, + message_hash: insertedNonce!.message_hash, + }, + error: null, + }; + return { nonce: result.nonce, challengeXdr: result.challengeXdr }; + } + + function signXdr(xdr: string, signer: Keypair = wallet): string { + const tx = new Transaction(xdr, NETWORK_PASSPHRASE); + tx.sign(signer); + return tx.toXDR(); + } + + /** Builds a custom (tampered) challenge transaction bound to the given hash. */ + function buildTx(opts: { + source?: string; + accountSeq?: string; + dataName?: string; + dataValue: Buffer; + minTime: number; + maxTime: number; + }): Transaction { + const account = new Account(opts.source ?? walletAddress, opts.accountSeq ?? '-1'); + return new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: NETWORK_PASSPHRASE, + timebounds: { minTime: opts.minTime, maxTime: opts.maxTime }, + }) + .addOperation(Operation.manageData({ name: opts.dataName ?? CHALLENGE_DATA_NAME, value: opts.dataValue })) + .build(); + } + + function verifyDto(signedXdr: string, nonce: string): VerifyRequestDto { + return { wallet: walletAddress, nonce, signatureType: 'sep0010', signedXdr }; + } + + it('generateNonce returns a challengeXdr whose manageData value equals the stored hash', async () => { + const { challengeXdr } = await issueChallenge(); + const tx = new Transaction(challengeXdr, NETWORK_PASSPHRASE); + + expect(tx.source).toBe(walletAddress); + expect(tx.sequence).toBe('0'); + expect(tx.operations).toHaveLength(1); + const op = tx.operations[0] as { type: string; name: string; value: Buffer }; + expect(op.type).toBe('manageData'); + expect(op.name).toBe(CHALLENGE_DATA_NAME); + expect(Buffer.from(op.value).toString('hex')).toBe(insertedNonce!.message_hash); + }); + + it('accepts a correctly signed challenge transaction', async () => { + const { nonce, challengeXdr } = await issueChallenge(); + const signedXdr = signXdr(challengeXdr); + + await expect(service.verifySignature(verifyDto(signedXdr, nonce))).resolves.toBeUndefined(); + }); + + it('rejects a challenge with no wallet signature (AUTH_SIGNATURE_INVALID)', async () => { + const { nonce, challengeXdr } = await issueChallenge(); + + // Submit the unsigned challenge as-is. + await expect(service.verifySignature(verifyDto(challengeXdr, nonce))).rejects.toMatchObject({ + response: { code: 'AUTH_SIGNATURE_INVALID' }, + }); + }); + + it('rejects a signature from a different keypair (AUTH_SIGNATURE_INVALID)', async () => { + const { nonce, challengeXdr } = await issueChallenge(); + const signedXdr = signXdr(challengeXdr, Keypair.random()); + + await expect(service.verifySignature(verifyDto(signedXdr, nonce))).rejects.toMatchObject({ + response: { code: 'AUTH_SIGNATURE_INVALID' }, + }); + }); + + it('rejects a tampered manageData value (AUTH_CHALLENGE_MISMATCH)', async () => { + const { nonce } = await issueChallenge(); + const now = Math.floor(Date.now() / 1000); + const tampered = buildTx({ + dataValue: Buffer.alloc(32, 1), // wrong 32 bytes + minTime: now - 60, + maxTime: now + 300, + }); + tampered.sign(wallet); + + await expect(service.verifySignature(verifyDto(tampered.toXDR(), nonce))).rejects.toMatchObject({ + response: { code: 'AUTH_CHALLENGE_MISMATCH' }, + }); + }); + + it('rejects a wrong source account (AUTH_CHALLENGE_MISMATCH)', async () => { + const { nonce } = await issueChallenge(); + const other = Keypair.random(); + const now = Math.floor(Date.now() / 1000); + const wrongSource = buildTx({ + source: other.publicKey(), + dataValue: Buffer.from(insertedNonce!.message_hash, 'hex'), + minTime: now - 60, + maxTime: now + 300, + }); + wrongSource.sign(other); + + await expect(service.verifySignature(verifyDto(wrongSource.toXDR(), nonce))).rejects.toMatchObject({ + response: { code: 'AUTH_CHALLENGE_MISMATCH' }, + }); + }); + + it('rejects a wrong manageData name (AUTH_CHALLENGE_MISMATCH)', async () => { + const { nonce } = await issueChallenge(); + const now = Math.floor(Date.now() / 1000); + const wrongName = buildTx({ + dataName: 'not_stepfi_auth', + dataValue: Buffer.from(insertedNonce!.message_hash, 'hex'), + minTime: now - 60, + maxTime: now + 300, + }); + wrongName.sign(wallet); + + await expect(service.verifySignature(verifyDto(wrongName.toXDR(), nonce))).rejects.toMatchObject({ + response: { code: 'AUTH_CHALLENGE_MISMATCH' }, + }); + }); + + it('rejects an expired challenge transaction (AUTH_NONCE_EXPIRED)', async () => { + const { nonce } = await issueChallenge(); + const now = Math.floor(Date.now() / 1000); + const expired = buildTx({ + dataValue: Buffer.from(insertedNonce!.message_hash, 'hex'), + minTime: now - 600, + maxTime: now - 300, // already past + }); + expired.sign(wallet); + + await expect(service.verifySignature(verifyDto(expired.toXDR(), nonce))).rejects.toMatchObject({ + response: { code: 'AUTH_NONCE_EXPIRED' }, + }); + }); + + it('rejects malformed signedXdr (AUTH_SIGNATURE_INVALID)', async () => { + const { nonce } = await issueChallenge(); + + await expect(service.verifySignature(verifyDto('not-a-valid-xdr', nonce))).rejects.toMatchObject({ + response: { code: 'AUTH_SIGNATURE_INVALID' }, + }); + }); + + it('rejects when signedXdr is missing (AUTH_SIGNATURE_INVALID)', async () => { + const { nonce } = await issueChallenge(); + + await expect( + service.verifySignature({ wallet: walletAddress, nonce, signatureType: 'sep0010' }), + ).rejects.toMatchObject({ response: { code: 'AUTH_SIGNATURE_INVALID' } }); + }); + + it('burns the nonce before verification — a consumed nonce is rejected', async () => { + const { nonce, challengeXdr } = await issueChallenge(); + claimResult = { data: [], error: null, count: 0 }; // claim races/loses + const signedXdr = signXdr(challengeXdr); + + await expect(service.verifySignature(verifyDto(signedXdr, nonce))).rejects.toMatchObject({ + response: { code: 'AUTH_NONCE_NOT_FOUND' }, + }); + }); + + it('surfaces a nonce insert failure from generateNonce', async () => { + mockInsert.mockResolvedValueOnce({ error: { message: 'boom' } }); + await expect(service.generateNonce(walletAddress)).rejects.toThrow(InternalServerErrorException); + }); +}); diff --git a/test/unit/modules/auth/auth.service.spec.ts b/test/unit/modules/auth/auth.service.spec.ts index 1ba0f74..1b0e9f1 100644 --- a/test/unit/modules/auth/auth.service.spec.ts +++ b/test/unit/modules/auth/auth.service.spec.ts @@ -9,10 +9,23 @@ import { UsersRepository } from '../../../../src/database/repositories/users.rep import { AuditService } from '../../../../src/modules/admin/audit.service'; import { VerifyRequestDto } from '../../../../src/modules/auth/dto/verify-request.dto'; -// Mock Stellar SDK to avoid real crypto operations in unit tests +// Mock Stellar SDK to avoid real crypto operations in unit tests. +// The message-scheme tests only exercise Keypair/StrKey; the SEP-10 challenge +// builder needs Account/Operation/TransactionBuilder/BASE_FEE so generateNonce +// can produce a (mock) challenge XDR without real SDK crypto. Real-SDK SEP-10 +// verification is covered in auth.service.sep0010.spec.ts (separate module +// registry, no mock). jest.mock('stellar-sdk', () => ({ Keypair: { fromPublicKey: jest.fn() }, StrKey: { isValidEd25519PublicKey: jest.fn().mockReturnValue(true) }, + Account: jest.fn(), + BASE_FEE: '100', + Operation: { manageData: jest.fn(() => ({ type: 'manageData' })) }, + TransactionBuilder: jest.fn(() => ({ + addOperation: jest.fn().mockReturnThis(), + build: jest.fn(() => ({ toXDR: () => 'MOCK_CHALLENGE_XDR' })), + })), + Transaction: jest.fn(), })); import { Keypair, StrKey } from 'stellar-sdk'; @@ -211,6 +224,7 @@ describe('AuthService', () => { expect(result).toHaveProperty('nonce'); expect(result).toHaveProperty('expiresAt'); expect(result).toHaveProperty('message'); + expect(result).toHaveProperty('challengeXdr'); expect(typeof result.nonce).toBe('string'); expect(result.nonce).toHaveLength(64); expect(/^[a-f0-9]+$/.test(result.nonce)).toBe(true);