diff --git a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts index 6c88ab96e0..ac87826230 100644 --- a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts +++ b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts @@ -1,5 +1,7 @@ import { BadRequestException, ConflictException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; +import { Wallet } from 'ethers'; +import { verifyTypedData } from 'ethers/lib/utils'; import { EthereumService } from 'src/integration/blockchain/ethereum/ethereum.service'; import { BrokerbotCurrency } from 'src/integration/blockchain/realunit/dto/realunit-broker.dto'; import { RealUnitBlockchainService } from 'src/integration/blockchain/realunit/realunit-blockchain.service'; @@ -32,9 +34,16 @@ import { RealUnitDevService } from '../realunit-dev.service'; import { PriceSourceUnavailableException } from '../exceptions/price-source-unavailable.exception'; import { RealUnitService } from '../realunit.service'; +let mockEnvironment = 'loc'; + jest.mock('src/config/config', () => ({ get Config() { - return { environment: 'loc' }; + return { + environment: mockEnvironment, + blockchain: { + realunit: { api: { url: 'https://mock-api.example.com', key: 'mock-key' } }, + }, + }; }, Environment: { LOC: 'loc', @@ -153,11 +162,12 @@ describe('RealUnitService', () => { provide: KycService, useValue: { createCustomKycStep: jest.fn(), + saveKycStepUpdate: jest.fn(), }, }, { provide: CountryService, useValue: {} }, { provide: LanguageService, useValue: {} }, - { provide: HttpService, useValue: {} }, + { provide: HttpService, useValue: { post: jest.fn() } }, { provide: FiatService, useValue: {} }, { provide: BuyService, useValue: {} }, { @@ -641,4 +651,156 @@ describe('RealUnitService', () => { await expect((service as any).withPriceSourceGuard(() => Promise.resolve('ok'))).resolves.toBe('ok'); }); }); + + describe('forwardRegistration (forwards the signed representation to Aktionariat)', () => { + // Hardhat test accounts — synthetic keys, never real user wallets. + const softwareWallet = new Wallet('0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d'); + // Stands in for a BitBox the user adds later (hardware can only sign ASCII). + const hardwareWallet = new Wallet('0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a'); + + const domain = { name: 'RealUnitUser', version: '1' }; + const types = { + RealUnitUser: [ + { name: 'email', type: 'string' }, + { name: 'name', type: 'string' }, + { name: 'type', type: 'string' }, + { name: 'phoneNumber', type: 'string' }, + { name: 'birthday', type: 'string' }, + { name: 'nationality', type: 'string' }, + { name: 'addressStreet', type: 'string' }, + { name: 'addressPostalCode', type: 'string' }, + { name: 'addressCity', type: 'string' }, + { name: 'addressCountry', type: 'string' }, + { name: 'swissTaxResidence', type: 'bool' }, + { name: 'registrationDate', type: 'string' }, + { name: 'walletAddress', type: 'address' }, + ], + }; + + // UTF-8 originals as persisted on the KYC step / user_data. + const utf8Fields = (walletAddress: string) => ({ + email: 'erika.example@example.com', + name: 'Erika Müller', + type: 'HUMAN', + phoneNumber: '+41790000000', + birthday: '1990-01-01', + nationality: 'CH', + addressStreet: 'Bahnhofstrasse 1', + addressPostalCode: '8001', + addressCity: 'Zürich', + addressCountry: 'CH', + swissTaxResidence: true, + registrationDate: '2026-06-08', + walletAddress, + }); + + // BitBox-safe ASCII transliteration of the same fields — what the wallet signs. + const asciiFields = (walletAddress: string) => ({ + ...utf8Fields(walletAddress), + name: 'Erika Mueller', + addressCity: 'Zuerich', + }); + + const buildDto = (fields: Record, signature: string): any => ({ + ...fields, + signature, + lang: 'DE', + kycData: {}, + }); + + const fakeKycStep = (): any => ({ + id: 1, + userData: { kycLevel: 999 }, + complete: jest.fn().mockReturnValue([1, {}]), + manualReview: jest.fn().mockReturnValue([1, {}]), + }); + + const forwardedPayload = (): any => ((service as any).http.post as jest.Mock).mock.calls[0][1]; + + // What Aktionariat does: recover the signer from the forwarded payload and compare to walletAddress. + const recoverFromForwarded = (p: any): string => + verifyTypedData( + domain, + types, + { + email: p.email, + name: p.name, + type: p.type, + phoneNumber: p.phoneNumber, + birthday: p.birthday, + nationality: p.nationality, + addressStreet: p.addressStreet, + addressPostalCode: p.addressPostalCode, + addressCity: p.addressCity, + addressCountry: p.addressCountry, + swissTaxResidence: p.swissTaxResidence, + registrationDate: p.registrationDate, + walletAddress: p.walletAddress, + }, + p.signature, + ); + + beforeEach(() => { + mockEnvironment = 'prd'; + }); + + afterEach(() => { + mockEnvironment = 'loc'; + }); + + // REGRESSION GUARD: a legacy software wallet that signed the raw UTF-8 fields + // (still accepted by verifyRealUnitRegistrationSignature) must keep working — + // the forward must stay UTF-8, not be transliterated, or Aktionariat rejects it. + it('forwards the raw UTF-8 fields unchanged when the wallet signed UTF-8 (legacy app)', async () => { + const wallet = softwareWallet.address; + const signature = await softwareWallet._signTypedData(domain, types, utf8Fields(wallet)); + const dto = buildDto(utf8Fields(wallet), signature); + + const ok = await (service as any).forwardRegistration(fakeKycStep(), dto); + + expect(ok).toBe(true); + const payload = forwardedPayload(); + expect(payload.name).toBe('Erika Müller'); + expect(payload.addressCity).toBe('Zürich'); + // Aktionariat re-verifies the signature against the payload it receives. + expect(recoverFromForwarded(payload).toLowerCase()).toBe(wallet.toLowerCase()); + }); + + it('forwards the BitBox-safe ASCII fields when the wallet signed ASCII (current app), even though the dto stores UTF-8', async () => { + const wallet = softwareWallet.address; + const signature = await softwareWallet._signTypedData(domain, types, asciiFields(wallet)); + // dto carries the UTF-8 originals as stored; only the signature is over ASCII. + const dto = buildDto(utf8Fields(wallet), signature); + + const ok = await (service as any).forwardRegistration(fakeKycStep(), dto); + + expect(ok).toBe(true); + const payload = forwardedPayload(); + expect(payload.name).toBe('Erika Mueller'); + expect(payload.addressCity).toBe('Zuerich'); + expect(recoverFromForwarded(payload).toLowerCase()).toBe(wallet.toLowerCase()); + }); + + it('supports the software→hardware switch: a BitBox-signed (ASCII-only) wallet verifies against the forwarded payload', async () => { + const wallet = hardwareWallet.address; + const signature = await hardwareWallet._signTypedData(domain, types, asciiFields(wallet)); + const dto = buildDto(utf8Fields(wallet), signature); + + const ok = await (service as any).forwardRegistration(fakeKycStep(), dto); + + expect(ok).toBe(true); + const [url, payload] = ((service as any).http.post as jest.Mock).mock.calls[0]; + expect(url).toContain('/registerUser'); + expect(payload.name).toBe('Erika Mueller'); + expect(recoverFromForwarded(payload).toLowerCase()).toBe(wallet.toLowerCase()); + }); + + it('resolveSignedRegistrationMessage returns undefined when a valid signature does not belong to the claimed wallet', async () => { + // Valid signature from the software wallet, but the dto claims a different wallet address. + const signature = await softwareWallet._signTypedData(domain, types, asciiFields(softwareWallet.address)); + const dto = buildDto(utf8Fields(hardwareWallet.address), signature); + + expect((service as any).resolveSignedRegistrationMessage(dto)).toBeUndefined(); + }); + }); }); diff --git a/src/subdomains/supporting/realunit/realunit.service.ts b/src/subdomains/supporting/realunit/realunit.service.ts index 6066c066e1..de4390e88b 100644 --- a/src/subdomains/supporting/realunit/realunit.service.ts +++ b/src/subdomains/supporting/realunit/realunit.service.ts @@ -119,6 +119,45 @@ function matchesSignedField(kycValue: string | undefined, signedValue: string | return toBitboxAscii(kycValue) === signedValue; } +const REGISTRATION_EIP712_DOMAIN = { name: 'RealUnitUser', version: '1' }; + +const REGISTRATION_EIP712_TYPES = { + RealUnitUser: [ + { name: 'email', type: 'string' }, + { name: 'name', type: 'string' }, + { name: 'type', type: 'string' }, + { name: 'phoneNumber', type: 'string' }, + { name: 'birthday', type: 'string' }, + { name: 'nationality', type: 'string' }, + { name: 'addressStreet', type: 'string' }, + { name: 'addressPostalCode', type: 'string' }, + { name: 'addressCity', type: 'string' }, + { name: 'addressCountry', type: 'string' }, + { name: 'swissTaxResidence', type: 'bool' }, + { name: 'registrationDate', type: 'string' }, + { name: 'walletAddress', type: 'address' }, + ], +}; + +// The EIP-712 fields a registration signature is computed over, in the exact +// representation that was signed (raw UTF-8 or BitBox-safe ASCII). +type SignedRegistrationMessage = Pick< + AktionariatRegistrationDto, + | 'email' + | 'name' + | 'type' + | 'phoneNumber' + | 'birthday' + | 'nationality' + | 'addressStreet' + | 'addressPostalCode' + | 'addressCity' + | 'addressCountry' + | 'swissTaxResidence' + | 'registrationDate' + | 'walletAddress' +>; + @Injectable() export class RealUnitService { private readonly logger = new DfxLogger(RealUnitService); @@ -828,65 +867,49 @@ export class RealUnitService { } private verifyRealUnitRegistrationSignature(data: RealUnitRegistrationDto): boolean { - const domain = { - name: 'RealUnitUser', - version: '1', - }; + return this.resolveSignedRegistrationMessage(data) != null; + } - const types = { - RealUnitUser: [ - { name: 'email', type: 'string' }, - { name: 'name', type: 'string' }, - { name: 'type', type: 'string' }, - { name: 'phoneNumber', type: 'string' }, - { name: 'birthday', type: 'string' }, - { name: 'nationality', type: 'string' }, - { name: 'addressStreet', type: 'string' }, - { name: 'addressPostalCode', type: 'string' }, - { name: 'addressCity', type: 'string' }, - { name: 'addressCountry', type: 'string' }, - { name: 'swissTaxResidence', type: 'bool' }, - { name: 'registrationDate', type: 'string' }, - { name: 'walletAddress', type: 'address' }, - ], - }; + // Builds the EIP-712 message in either the raw or the BitBox-safe ASCII + // representation. Only the free-text fields carry diacritics, so only those + // are transliterated — mirrors realunit-app's signing path (Krüger → Krueger). + private buildRegistrationMessage(data: RealUnitRegistrationDto, transliterate: boolean): SignedRegistrationMessage { + const ascii = (value: string): string => (transliterate ? toBitboxAscii(value) : value); - const message = { - email: data.email, - name: data.name, + return { + email: ascii(data.email), + name: ascii(data.name), type: data.type, - phoneNumber: data.phoneNumber, - birthday: data.birthday, + phoneNumber: ascii(data.phoneNumber), + birthday: ascii(data.birthday), nationality: data.nationality, - addressStreet: data.addressStreet, - addressPostalCode: data.addressPostalCode, - addressCity: data.addressCity, + addressStreet: ascii(data.addressStreet), + addressPostalCode: ascii(data.addressPostalCode), + addressCity: ascii(data.addressCity), addressCountry: data.addressCountry, swissTaxResidence: data.swissTaxResidence, registrationDate: data.registrationDate, walletAddress: data.walletAddress, }; + } - const signatureToUse = data.signature.startsWith('0x') ? data.signature : `0x${data.signature}`; - const recoveredAddress = verifyTypedData(domain, types, message, signatureToUse); - if (Util.equalsIgnoreCase(recoveredAddress, data.walletAddress)) return true; - - // Backwards-compat: app v0.0.3+ signs BitBox-safe ASCII. If the stored - // accountData still holds UTF-8 from a pre-transliteration registration, - // retry verify with the same fields transliterated so re-login (add new - // wallet) keeps working for those users. - const asciiMessage = { - ...message, - email: toBitboxAscii(message.email), - name: toBitboxAscii(message.name), - phoneNumber: toBitboxAscii(message.phoneNumber), - birthday: toBitboxAscii(message.birthday), - addressStreet: toBitboxAscii(message.addressStreet), - addressPostalCode: toBitboxAscii(message.addressPostalCode), - addressCity: toBitboxAscii(message.addressCity), - }; - const asciiRecovered = verifyTypedData(domain, types, asciiMessage, signatureToUse); - return Util.equalsIgnoreCase(asciiRecovered, data.walletAddress); + // Returns the EIP-712 fields exactly as the wallet signed them — raw UTF-8 + // (legacy software wallets, kept working by #3709) or BitBox-safe ASCII + // (current app / any BitBox, whose firmware rejects non-ASCII bytes). Returns + // undefined if the signature matches neither. Aktionariat re-verifies the + // signature against the payload we POST in forwardRegistration, so the + // forwarded bytes must be exactly these — forwarding any other variant fails + // as "Invalid signature". + private resolveSignedRegistrationMessage(data: RealUnitRegistrationDto): SignedRegistrationMessage | undefined { + const signature = data.signature.startsWith('0x') ? data.signature : `0x${data.signature}`; + + for (const transliterate of [false, true]) { + const message = this.buildRegistrationMessage(data, transliterate); + const recovered = verifyTypedData(REGISTRATION_EIP712_DOMAIN, REGISTRATION_EIP712_TYPES, message, signature); + if (Util.equalsIgnoreCase(recovered, data.walletAddress)) return message; + } + + return undefined; } async forwardRegistrationToAktionariat(kycStepId: number): Promise { @@ -1081,21 +1104,14 @@ export class RealUnitService { const { api } = Config.blockchain.realunit; try { - // forward only Aktionariat fields (exclude kycData to avoid signature verification issues) + // forward only Aktionariat fields (exclude kycData to avoid signature verification issues). + // Aktionariat re-verifies the EIP-712 signature against this payload, so send back the exact + // representation that was signed — raw UTF-8 (legacy software wallets) or BitBox-safe ASCII + // (current app / BitBox). Forwarding the wrong variant fails as "Invalid signature". The + // UTF-8 originals stay on user_data for PDF/mail. + const signedMessage = this.resolveSignedRegistrationMessage(dto) ?? this.buildRegistrationMessage(dto, false); const payload: AktionariatRegistrationDto = { - email: dto.email, - name: dto.name, - type: dto.type, - phoneNumber: dto.phoneNumber, - birthday: dto.birthday, - nationality: dto.nationality, - addressStreet: dto.addressStreet, - addressPostalCode: dto.addressPostalCode, - addressCity: dto.addressCity, - addressCountry: dto.addressCountry, - swissTaxResidence: dto.swissTaxResidence, - registrationDate: dto.registrationDate, - walletAddress: dto.walletAddress, + ...signedMessage, signature: dto.signature, lang: dto.lang, countryAndTINs: dto.countryAndTINs,