Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 132 additions & 3 deletions src/modules/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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 };
}

/**
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions src/modules/auth/dto/nonce-response.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
31 changes: 24 additions & 7 deletions src/modules/auth/dto/verify-request.dto.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading