From 6ba16e7556bc4238c9edbbb7b4e0d2e19579c263 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Mon, 8 Jun 2026 17:24:27 +0200 Subject: [PATCH 1/3] fix(realunit): forward the signed name/address variant to Aktionariat Aktionariat re-verifies the EIP-712 registration signature against the payload we POST. Wallets sign either the raw UTF-8 fields (legacy software wallets) or the BitBox-safe ASCII transliteration (current app; a BitBox cannot sign non-ASCII bytes), but forwardRegistration always sent the stored UTF-8 fields. ASCII-signed registrations were therefore recovered to a different address and rejected with "Invalid signature", leaving the step in MANUAL_REVIEW. Resolve which variant the signature verifies against and forward exactly those bytes. The UTF-8 originals stay on user_data for PDF/mail. This also unblocks the software-wallet -> hardware-wallet migration path, since both wallet types can produce the ASCII variant. --- .../__tests__/realunit.service.spec.ts | 136 ++++++++++++++++- .../supporting/realunit/realunit.service.ts | 141 ++++++++++-------- 2 files changed, 212 insertions(+), 65 deletions(-) diff --git a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts index 6c88ab96e0..4b23a1696a 100644 --- a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts +++ b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts @@ -1,5 +1,6 @@ import { BadRequestException, ConflictException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; +import { Wallet } from 'ethers'; 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 +33,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 +161,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 +650,127 @@ describe('RealUnitService', () => { await expect((service as any).withPriceSourceGuard(() => Promise.resolve('ok'))).resolves.toBe('ok'); }); }); + + describe('forwardRegistration (Aktionariat signed-variant payload)', () => { + // 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. + 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]; + + beforeEach(() => { + mockEnvironment = 'prd'; + }); + + afterEach(() => { + mockEnvironment = 'loc'; + }); + + it('forwards the raw UTF-8 fields when the wallet signed UTF-8 (legacy software wallet)', 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); + expect(forwardedPayload().name).toBe('Erika Müller'); + expect(forwardedPayload().addressCity).toBe('Zürich'); + expect(forwardedPayload().walletAddress).toBe(wallet); + }); + + 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); + expect(forwardedPayload().name).toBe('Erika Mueller'); + expect(forwardedPayload().addressCity).toBe('Zuerich'); + expect(forwardedPayload().walletAddress).toBe(wallet); + }); + + it('supports the software→hardware switch: a BitBox-signed (ASCII-only) wallet forwards ASCII that matches the signature', 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(payload.walletAddress).toBe(wallet); + }); + + 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..77ebbadf3c 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,47 @@ 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) 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. + 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 +1102,15 @@ export class RealUnitService { const { api } = Config.blockchain.realunit; try { - // forward only Aktionariat fields (exclude kycData to avoid signature verification issues) + // Aktionariat re-verifies the EIP-712 signature against the payload we POST. + // The wallet signed either the raw UTF-8 fields (legacy software wallets) or + // the BitBox-safe ASCII transliteration (current app / any BitBox). Forward the + // exact variant that was signed so the name/address bytes match the signature — + // otherwise Aktionariat rejects with "Invalid signature". kycData is excluded on + // purpose; the UTF-8 originals live on user_data for PDF/mail only. + 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, From d4ae93ac05010f90d9e43646b659d13ab98ad327 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Mon, 8 Jun 2026 17:41:09 +0200 Subject: [PATCH 2/3] refactor(realunit): minimize the Aktionariat forward fix Drop the verify refactor and keep only the behavioral change: transliterate the free-text fields to BitBox-safe ASCII in forwardRegistration so the payload matches the signed bytes. verifyRealUnitRegistrationSignature is left untouched. --- .../__tests__/realunit.service.spec.ts | 73 +++++---- .../supporting/realunit/realunit.service.ts | 145 ++++++++---------- 2 files changed, 113 insertions(+), 105 deletions(-) diff --git a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts index 4b23a1696a..2b852ab12b 100644 --- a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts +++ b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts @@ -1,6 +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'; @@ -651,7 +652,7 @@ describe('RealUnitService', () => { }); }); - describe('forwardRegistration (Aktionariat signed-variant payload)', () => { + describe('forwardRegistration (transliterates Aktionariat payload to ASCII)', () => { // 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). @@ -693,7 +694,7 @@ describe('RealUnitService', () => { walletAddress, }); - // BitBox-safe ASCII transliteration of the same fields. + // BitBox-safe ASCII transliteration of the same fields — what the wallet signs. const asciiFields = (walletAddress: string) => ({ ...utf8Fields(walletAddress), name: 'Erika Mueller', @@ -716,6 +717,29 @@ describe('RealUnitService', () => { 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'; }); @@ -724,34 +748,23 @@ describe('RealUnitService', () => { mockEnvironment = 'loc'; }); - it('forwards the raw UTF-8 fields when the wallet signed UTF-8 (legacy software wallet)', 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); - expect(forwardedPayload().name).toBe('Erika Müller'); - expect(forwardedPayload().addressCity).toBe('Zürich'); - expect(forwardedPayload().walletAddress).toBe(wallet); - }); - - it('forwards the BitBox-safe ASCII fields when the wallet signed ASCII (current app), even though the dto stores UTF-8', async () => { + it('transliterates umlaut name/address so the forwarded payload matches the ASCII signature', async () => { const wallet = softwareWallet.address; + // App signs the ASCII form; the dto still stores the UTF-8 originals. 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); - expect(forwardedPayload().name).toBe('Erika Mueller'); - expect(forwardedPayload().addressCity).toBe('Zuerich'); - expect(forwardedPayload().walletAddress).toBe(wallet); + const payload = forwardedPayload(); + expect(payload.name).toBe('Erika Mueller'); + expect(payload.addressCity).toBe('Zuerich'); + // Aktionariat re-verifies the signature against the payload it receives. + expect(recoverFromForwarded(payload).toLowerCase()).toBe(wallet.toLowerCase()); }); - it('supports the software→hardware switch: a BitBox-signed (ASCII-only) wallet forwards ASCII that matches the signature', async () => { + 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); @@ -762,15 +775,21 @@ describe('RealUnitService', () => { 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(payload.walletAddress).toBe(wallet); + 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); + it('leaves already-ASCII fields unchanged', async () => { + const wallet = softwareWallet.address; + const signature = await softwareWallet._signTypedData(domain, types, asciiFields(wallet)); + const dto = buildDto(asciiFields(wallet), signature); - expect((service as any).resolveSignedRegistrationMessage(dto)).toBeUndefined(); + 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()); }); }); }); diff --git a/src/subdomains/supporting/realunit/realunit.service.ts b/src/subdomains/supporting/realunit/realunit.service.ts index 77ebbadf3c..05f52cc2ed 100644 --- a/src/subdomains/supporting/realunit/realunit.service.ts +++ b/src/subdomains/supporting/realunit/realunit.service.ts @@ -119,45 +119,6 @@ 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); @@ -867,47 +828,65 @@ export class RealUnitService { } private verifyRealUnitRegistrationSignature(data: RealUnitRegistrationDto): boolean { - return this.resolveSignedRegistrationMessage(data) != null; - } + const domain = { + name: 'RealUnitUser', + version: '1', + }; - // 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 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' }, + ], + }; - return { - email: ascii(data.email), - name: ascii(data.name), + const message = { + email: data.email, + name: data.name, type: data.type, - phoneNumber: ascii(data.phoneNumber), - birthday: ascii(data.birthday), + phoneNumber: data.phoneNumber, + birthday: data.birthday, nationality: data.nationality, - addressStreet: ascii(data.addressStreet), - addressPostalCode: ascii(data.addressPostalCode), - addressCity: ascii(data.addressCity), + addressStreet: data.addressStreet, + addressPostalCode: data.addressPostalCode, + addressCity: data.addressCity, addressCountry: data.addressCountry, swissTaxResidence: data.swissTaxResidence, registrationDate: data.registrationDate, walletAddress: data.walletAddress, }; - } - // Returns the EIP-712 fields exactly as the wallet signed them — raw UTF-8 - // (legacy software wallets) 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. - 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; + 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); } async forwardRegistrationToAktionariat(kycStepId: number): Promise { @@ -1102,15 +1081,25 @@ export class RealUnitService { const { api } = Config.blockchain.realunit; try { - // Aktionariat re-verifies the EIP-712 signature against the payload we POST. - // The wallet signed either the raw UTF-8 fields (legacy software wallets) or - // the BitBox-safe ASCII transliteration (current app / any BitBox). Forward the - // exact variant that was signed so the name/address bytes match the signature — - // otherwise Aktionariat rejects with "Invalid signature". kycData is excluded on - // purpose; the UTF-8 originals live on user_data for PDF/mail only. - const signedMessage = this.resolveSignedRegistrationMessage(dto) ?? this.buildRegistrationMessage(dto, false); + // forward only Aktionariat fields (exclude kycData to avoid signature verification issues). + // Transliterate the free-text fields to BitBox-safe ASCII so they match the signed bytes: + // the app signs the ASCII form (a BitBox cannot sign non-ASCII) and Aktionariat re-verifies + // the signature against this payload. Forwarding the UTF-8 originals fails as "Invalid + // signature". The UTF-8 originals stay on user_data for PDF/mail. const payload: AktionariatRegistrationDto = { - ...signedMessage, + email: toBitboxAscii(dto.email), + name: toBitboxAscii(dto.name), + type: dto.type, + phoneNumber: toBitboxAscii(dto.phoneNumber), + birthday: toBitboxAscii(dto.birthday), + nationality: dto.nationality, + addressStreet: toBitboxAscii(dto.addressStreet), + addressPostalCode: toBitboxAscii(dto.addressPostalCode), + addressCity: toBitboxAscii(dto.addressCity), + addressCountry: dto.addressCountry, + swissTaxResidence: dto.swissTaxResidence, + registrationDate: dto.registrationDate, + walletAddress: dto.walletAddress, signature: dto.signature, lang: dto.lang, countryAndTINs: dto.countryAndTINs, From 5433d155db8f248e5cbdf033ea971a3fe1bb4285 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Mon, 8 Jun 2026 18:12:15 +0200 Subject: [PATCH 3/3] fix(realunit): forward the signed name/address variant, not always ASCII The minimal "always transliterate to ASCII" approach regressed legacy wallets that signed the raw UTF-8 fields (still accepted by #3709's verify): forwarding ASCII no longer matched their UTF-8 signature. Resolve which representation the signature verifies against (raw or BitBox-safe ASCII) and forward exactly that, so both legacy UTF-8 signers and current ASCII/BitBox signers keep working. --- .../__tests__/realunit.service.spec.ts | 41 +++-- .../supporting/realunit/realunit.service.ts | 144 ++++++++++-------- 2 files changed, 104 insertions(+), 81 deletions(-) diff --git a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts index 2b852ab12b..ac87826230 100644 --- a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts +++ b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts @@ -652,7 +652,7 @@ describe('RealUnitService', () => { }); }); - describe('forwardRegistration (transliterates Aktionariat payload to ASCII)', () => { + 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). @@ -748,10 +748,28 @@ describe('RealUnitService', () => { mockEnvironment = 'loc'; }); - it('transliterates umlaut name/address so the forwarded payload matches the ASCII signature', async () => { + // 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; - // App signs the ASCII form; the dto still stores the UTF-8 originals. 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); @@ -760,7 +778,6 @@ describe('RealUnitService', () => { const payload = forwardedPayload(); expect(payload.name).toBe('Erika Mueller'); expect(payload.addressCity).toBe('Zuerich'); - // Aktionariat re-verifies the signature against the payload it receives. expect(recoverFromForwarded(payload).toLowerCase()).toBe(wallet.toLowerCase()); }); @@ -778,18 +795,12 @@ describe('RealUnitService', () => { expect(recoverFromForwarded(payload).toLowerCase()).toBe(wallet.toLowerCase()); }); - it('leaves already-ASCII fields unchanged', async () => { - const wallet = softwareWallet.address; - const signature = await softwareWallet._signTypedData(domain, types, asciiFields(wallet)); - const dto = buildDto(asciiFields(wallet), signature); - - const ok = await (service as any).forwardRegistration(fakeKycStep(), dto); + 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(ok).toBe(true); - const payload = forwardedPayload(); - expect(payload.name).toBe('Erika Mueller'); - expect(payload.addressCity).toBe('Zuerich'); - expect(recoverFromForwarded(payload).toLowerCase()).toBe(wallet.toLowerCase()); + 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 05f52cc2ed..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 { @@ -1082,24 +1105,13 @@ export class RealUnitService { try { // forward only Aktionariat fields (exclude kycData to avoid signature verification issues). - // Transliterate the free-text fields to BitBox-safe ASCII so they match the signed bytes: - // the app signs the ASCII form (a BitBox cannot sign non-ASCII) and Aktionariat re-verifies - // the signature against this payload. Forwarding the UTF-8 originals fails as "Invalid - // signature". The UTF-8 originals stay on user_data for PDF/mail. + // 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: toBitboxAscii(dto.email), - name: toBitboxAscii(dto.name), - type: dto.type, - phoneNumber: toBitboxAscii(dto.phoneNumber), - birthday: toBitboxAscii(dto.birthday), - nationality: dto.nationality, - addressStreet: toBitboxAscii(dto.addressStreet), - addressPostalCode: toBitboxAscii(dto.addressPostalCode), - addressCity: toBitboxAscii(dto.addressCity), - addressCountry: dto.addressCountry, - swissTaxResidence: dto.swissTaxResidence, - registrationDate: dto.registrationDate, - walletAddress: dto.walletAddress, + ...signedMessage, signature: dto.signature, lang: dto.lang, countryAndTINs: dto.countryAndTINs,