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
174 changes: 93 additions & 81 deletions apps/backend/package-lock.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions apps/backend/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Module } from '@nestjs/common';
import { Module, forwardRef } from '@nestjs/common';
import { JwtModule, JwtModuleOptions } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { TypeOrmModule } from '@nestjs/typeorm';
Expand All @@ -16,7 +16,7 @@ import { RefreshToken } from './entities/refresh-token.entity';
@Module({
imports: [
TypeOrmModule.forFeature([User, PasswordResetToken, RefreshToken]),
UsersModule,
forwardRef(() => UsersModule),
EmailModule,
PassportModule,
JwtModule.registerAsync({
Expand Down
58 changes: 58 additions & 0 deletions apps/backend/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,64 @@ export class AuthService {
};
}

/**
* Verify the signed challenge without creating a user session or issuing JWT.
* Used for secure account linking.
*/
async verifyChallengeOnly(
publicKey: string,
signedChallenge: string,
): Promise<boolean> {
await Promise.resolve();
const storedChallenge = this.challengeStore.get(publicKey);

if (!storedChallenge) {
throw new BadRequestException(
'No challenge found for this public key. Please request a new challenge.',
);
}

if (Date.now() > storedChallenge.expiresAt) {
this.challengeStore.delete(publicKey);
throw new BadRequestException(
'Challenge has expired. Please request a new challenge.',
);
}

const networkPassphrase =
this.stellarNetwork === 'testnet' ? Networks.TESTNET : Networks.PUBLIC;

let transaction: Transaction;

try {
transaction = new Transaction(signedChallenge, networkPassphrase);
} catch {
this.challengeStore.delete(publicKey);
throw new BadRequestException('Invalid transaction format');
}

// Verify the transaction was signed by the user
const userSignature = transaction.signatures.find((sig) => {
try {
const keypair = Keypair.fromPublicKey(publicKey);
return keypair.verify(transaction.hash(), sig.signature());
} catch {
return false;
}
});

if (!userSignature) {
this.challengeStore.delete(publicKey);
throw new BadRequestException(
'Invalid signature. Transaction was not signed by the provided public key.',
);
}

// Remove used challenge
this.challengeStore.delete(publicKey);
return true;
}

/**
* Verify the signed challenge and issue a JWT
*/
Expand Down
7 changes: 3 additions & 4 deletions apps/backend/src/stellar/stellar.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,16 +160,15 @@ export class StellarService {
): Promise<any> {
validateStellarPublicKey(publicKey);
this.logger.debug(`Fetching transactions for account: ${publicKey}`);

try {
const payments = await this.server
.payments()
const operations = await this.server
.operations()
.forAccount(publicKey)
.order('desc')
.limit(limit)
.call();

return payments.records;
return operations.records;
} catch (error: unknown) {
this.logger.error(`Error fetching transactions for ${publicKey}:`, error);
throw new HorizonUnavailableException(
Expand Down
8 changes: 8 additions & 0 deletions apps/backend/src/users/dto/link-stellar-account.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,12 @@ export class LinkStellarAccountDto {
@IsOptional()
@MaxLength(100)
label?: string;

@ApiProperty({
description:
'Signed challenge transaction XDR proving ownership of public key',
example: 'AAAAA...',
})
@IsString()
signedChallenge: string;
}
9 changes: 7 additions & 2 deletions apps/backend/src/users/users.module.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import { Module } from '@nestjs/common';
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';
import { User } from './entities/user.entity';
import { StellarAccount } from './entities/stellar-account.entity';
import { StellarService } from '../stellar/stellar.service';
import { UploadModule } from '../upload/upload.module';
import { AuthModule } from '../auth/auth.module';

@Module({
imports: [TypeOrmModule.forFeature([User, StellarAccount]), UploadModule],
imports: [
TypeOrmModule.forFeature([User, StellarAccount]),
UploadModule,
forwardRef(() => AuthModule),
],
providers: [UsersService, StellarService],
controllers: [UsersController],
exports: [UsersService],
Expand Down
11 changes: 11 additions & 0 deletions apps/backend/src/users/users.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
BadRequestException,
ConflictException,
Logger,
Inject,
forwardRef,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
Expand All @@ -14,6 +16,7 @@ import { LinkStellarAccountDto } from './dto/link-stellar-account.dto';
import { StellarAccountResponseDto } from './dto/stellar-account-response.dto';
import { UpdateStellarAccountLabelDto } from './dto/update-stellar-account-label.dto';
import { UploadService } from '../upload/upload.service';
import { AuthService } from '../auth/auth.service';
import crypto from 'crypto';

@Injectable()
Expand All @@ -27,6 +30,8 @@ export class UsersService {
private stellarAccountRepository: Repository<StellarAccount>,
private stellarService: StellarService,
private uploadService: UploadService,
@Inject(forwardRef(() => AuthService))
private authService: AuthService,
) {}

// --- BASIC CRUD ---
Expand Down Expand Up @@ -78,6 +83,12 @@ export class UsersService {
userId: string,
dto: LinkStellarAccountDto,
): Promise<StellarAccountResponseDto> {
// Verify ownership via challenge-response signature first
await this.authService.verifyChallengeOnly(
dto.publicKey,
dto.signedChallenge,
);

this.stellarService.validatePublicKeyOrThrow(dto.publicKey);

const user = await this.usersRepository.findOne({ where: { id: userId } });
Expand Down
Loading
Loading