Skip to content
Open
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
2 changes: 2 additions & 0 deletions app/backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { FiatRampsModule } from "./fiat-ramps/fiat-ramps.module";
import { RefundsModule } from "./refunds/refunds.module";
import { ExportsModule } from "./exports/exports.module";
import { JobQueueModule } from "./job-queue/job-queue.module";
import { StealthModule } from "./stealth/stealth.module";
import { AuditModule } from "./audit/audit.module";
import { FeatureFlagsModule } from "./feature-flags/feature-flags.module";
import { CustomThrottlerGuard } from "./auth/guards/custom-throttler.guard";
Expand Down Expand Up @@ -75,6 +76,7 @@ type AppImport =
RefundsModule,
ExportsModule,
JobQueueModule,
StealthModule,
AuditModule,
FeatureFlagsModule,
];
Expand Down
46 changes: 45 additions & 1 deletion app/backend/src/ingestion/soroban-event.parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import type {
ContractUpgradedEvent,
EphemeralKeyRegisteredEvent,
StealthWithdrawnEvent,
StealthKeysRegisteredEvent,
CosignerApprovedEvent,
} from "./types/contract-event.types";

/**
Expand Down Expand Up @@ -82,6 +84,10 @@ export class SorobanEventParser {
return this.parseEphemeralKeyRegistered(topics, dataVal, base);
case "StealthWithdrawn":
return this.parseStealthWithdrawn(topics, dataVal, base);
case "StealthKeysRegistered":
return this.parseStealthKeysRegistered(topics, dataVal, base);
case "CosignerApproved":
return this.parseCosignerApproved(topics, dataVal, base);
default:
this.logger.debug(`Unrecognised event name: ${eventName}`);
return null;
Expand Down Expand Up @@ -270,7 +276,6 @@ export class SorobanEventParser {
"eventType" | "stealthAddress" | "recipient" | "token" | "amount"
>,
): StealthWithdrawnEvent {
// Topics: [name, stealth_address, recipient]
const stealthAddress = this.decodeBytes32Hex(topics[1]);
const recipient = this.decodeAddress(topics[2]);
const map = this.dataToMap(data);
Expand All @@ -285,6 +290,45 @@ export class SorobanEventParser {
};
}

private parseStealthKeysRegistered(
topics: xdr.ScVal[],
data: xdr.ScVal,
base: Omit<
StealthKeysRegisteredEvent,
"eventType" | "owner" | "scanPub" | "spendPub"
>,
): StealthKeysRegisteredEvent {
const owner = this.decodeAddress(topics[1]);
const map = this.dataToMap(data);

return {
eventType: "StealthKeysRegistered",
...base,
owner,
scanPub: this.decodeBytes32Hex(map["scan_pub"]),
spendPub: this.decodeBytes32Hex(map["spend_pub"]),
};
}

private parseCosignerApproved(
topics: xdr.ScVal[],
data: xdr.ScVal,
base: Omit<
CosignerApprovedEvent,
"eventType" | "stealthAddress" | "cosigner"
>,
): CosignerApprovedEvent {
const stealthAddress = this.decodeBytes32Hex(topics[1]);
const cosigner = this.decodeAddress(topics[2]);

return {
eventType: "CosignerApproved",
...base,
stealthAddress,
cosigner,
};
}

// ---------------------------------------------------------------------------
// XDR decode helpers
// ---------------------------------------------------------------------------
Expand Down
29 changes: 26 additions & 3 deletions app/backend/src/ingestion/types/contract-event.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ export type SorobanEventType =
| "AdminChanged"
| "ContractUpgraded"
| "EphemeralKeyRegistered"
| "StealthWithdrawn";
| "StealthWithdrawn"
| "StealthKeysRegistered"
| "CosignerApproved";

export interface BaseContractEvent {
eventType: SorobanEventType;
Expand Down Expand Up @@ -94,6 +96,21 @@ export interface StealthWithdrawnEvent extends BaseContractEvent {
amount: bigint;
}

/** Emitted when a recipient publishes their stealth key pair. */
export interface StealthKeysRegisteredEvent extends BaseContractEvent {
eventType: "StealthKeysRegistered";
owner: string;
scanPub: string;
spendPub: string;
}

/** Emitted when a cosigner approves a stealth withdrawal. */
export interface CosignerApprovedEvent extends BaseContractEvent {
eventType: "CosignerApproved";
stealthAddress: string;
cosigner: string;
}

export type QuickExContractEvent =
| EscrowDepositedEvent
| EscrowWithdrawnEvent
Expand All @@ -103,11 +120,17 @@ export type QuickExContractEvent =
| AdminChangedEvent
| ContractUpgradedEvent
| EphemeralKeyRegisteredEvent
| StealthWithdrawnEvent;
| StealthWithdrawnEvent
| StealthKeysRegisteredEvent
| CosignerApprovedEvent;

export type EscrowEvent =
| EscrowDepositedEvent
| EscrowWithdrawnEvent
| EscrowRefundedEvent;

export type StealthEvent = EphemeralKeyRegisteredEvent | StealthWithdrawnEvent;
export type StealthEvent =
| EphemeralKeyRegisteredEvent
| StealthWithdrawnEvent
| StealthKeysRegisteredEvent
| CosignerApprovedEvent;
104 changes: 104 additions & 0 deletions app/backend/src/stealth/dto/stealth.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsHexadecimal,
IsNotEmpty,
IsOptional,
IsString,
Length,
MaxLength,
} from 'class-validator';

export class DeriveStealthAddressDto {
@ApiProperty({ description: 'Ephemeral public key (64-char hex)' })
@IsHexadecimal()
@Length(64, 64)
ephPub: string;

@ApiProperty({ description: 'Recipient scan public key (64-char hex)' })
@IsHexadecimal()
@Length(64, 64)
scanPub: string;

@ApiProperty({ description: 'Recipient spend public key (64-char hex)' })
@IsHexadecimal()
@Length(64, 64)
spendPub: string;
}

export class DeriveStealthAddressResponseDto {
@ApiProperty({ description: 'Derived one-time stealth address (hex)' })
stealthAddress: string;

@ApiProperty({ description: 'Shared secret for memo encryption (hex)' })
sharedSecret: string;
}

export class EncryptMemoDto {
@ApiProperty({ description: 'Plaintext memo to encrypt' })
@IsString()
@IsNotEmpty()
@MaxLength(512)
memo: string;

@ApiProperty({ description: 'Shared secret from DH exchange (64-char hex)' })
@IsHexadecimal()
@Length(64, 64)
sharedSecret: string;
}

export class EncryptMemoResponseDto {
@ApiProperty({ description: 'Encrypted memo (hex)' })
encryptedMemo: string;
}

export class DecryptMemoDto {
@ApiProperty({ description: 'Encrypted memo (hex)' })
@IsHexadecimal()
@IsNotEmpty()
encryptedMemo: string;

@ApiProperty({ description: 'Shared secret from DH exchange (64-char hex)' })
@IsHexadecimal()
@Length(64, 64)
sharedSecret: string;
}

export class DecryptMemoResponseDto {
@ApiProperty({ description: 'Decrypted plaintext memo' })
memo: string;
}

export class ScanStealthEventDto {
@ApiProperty({ description: 'Ephemeral public key from on-chain event (64-char hex)' })
@IsHexadecimal()
@Length(64, 64)
ephPub: string;

@ApiProperty({ description: 'Recipient scan private key (64-char hex)' })
@IsHexadecimal()
@Length(64, 64)
scanPriv: string;

@ApiProperty({ description: 'Recipient spend public key (64-char hex)' })
@IsHexadecimal()
@Length(64, 64)
spendPub: string;

@ApiProperty({ description: 'Stealth address from on-chain event (64-char hex)' })
@IsHexadecimal()
@Length(64, 64)
stealthAddress: string;

@ApiPropertyOptional({ description: 'Encrypted memo from on-chain event (hex)' })
@IsOptional()
@IsHexadecimal()
encryptedMemo?: string;
}

export class ScanStealthEventResponseDto {
@ApiProperty({ description: 'Whether this event is addressed to the recipient' })
isMatch: boolean;

@ApiPropertyOptional({ description: 'Decrypted memo (only present on match with memo)' })
memo?: string;
}
99 changes: 99 additions & 0 deletions app/backend/src/stealth/stealth-crypto.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { Injectable } from '@nestjs/common';
import {
createHash,
createCipheriv,
createDecipheriv,
randomBytes,
hkdfSync,
} from 'crypto';

const AES_KEY_LEN = 32;
const AES_IV_LEN = 12;
const AES_TAG_LEN = 16;
const HKDF_SALT = Buffer.from('quickex-stealth-v2');
const HKDF_INFO_MEMO = Buffer.from('stealth-memo-encryption');

@Injectable()
export class StealthCryptoService {
deriveSharedSecret(ephPub: Buffer, scanPub: Buffer): Buffer {
const payload = Buffer.concat([ephPub, scanPub]);
return createHash('sha256').update(payload).digest();
}

deriveStealthAddress(spendPub: Buffer, sharedSecret: Buffer): Buffer {
const payload = Buffer.concat([spendPub, sharedSecret]);
return createHash('sha256').update(payload).digest();
}

computeStealthAddress(
ephPub: Buffer,
scanPub: Buffer,
spendPub: Buffer,
): Buffer {
const sharedSecret = this.deriveSharedSecret(ephPub, scanPub);
return this.deriveStealthAddress(spendPub, sharedSecret);
}

generateEphemeralKeyPair(): { ephPriv: Buffer; ephPub: Buffer } {
const ephPriv = randomBytes(32);
const ephPub = createHash('sha256').update(ephPriv).digest();
return { ephPriv, ephPub };
}

private deriveEncryptionKey(sharedSecret: Buffer): Buffer {
const derived = hkdfSync(
'sha256',
sharedSecret,
HKDF_SALT,
HKDF_INFO_MEMO,
AES_KEY_LEN,
);
return Buffer.from(derived);
}

encryptMemo(memo: string, sharedSecret: Buffer): Buffer {
const key = this.deriveEncryptionKey(sharedSecret);
const iv = randomBytes(AES_IV_LEN);
const cipher = createCipheriv('aes-256-gcm', key, iv);

const encrypted = Buffer.concat([
cipher.update(Buffer.from(memo, 'utf8')),
cipher.final(),
]);
const authTag = cipher.getAuthTag();

return Buffer.concat([iv, authTag, encrypted]);
}

decryptMemo(ciphertext: Buffer, sharedSecret: Buffer): string {
if (ciphertext.length < AES_IV_LEN + AES_TAG_LEN) {
throw new Error('Ciphertext too short');
}

const key = this.deriveEncryptionKey(sharedSecret);
const iv = ciphertext.subarray(0, AES_IV_LEN);
const authTag = ciphertext.subarray(AES_IV_LEN, AES_IV_LEN + AES_TAG_LEN);
const encrypted = ciphertext.subarray(AES_IV_LEN + AES_TAG_LEN);

const decipher = createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(authTag);

const decrypted = Buffer.concat([
decipher.update(encrypted),
decipher.final(),
]);
return decrypted.toString('utf8');
}

scanForRecipient(
ephPub: Buffer,
scanPriv: Buffer,
spendPub: Buffer,
onChainStealthAddress: Buffer,
): boolean {
const scanPub = createHash('sha256').update(scanPriv).digest();
const sharedSecret = this.deriveSharedSecret(ephPub, scanPub);
const derivedStealth = this.deriveStealthAddress(spendPub, sharedSecret);
return derivedStealth.equals(onChainStealthAddress);
}
}
Loading
Loading