From 59f430b47fd8e31132c081c224fa83839805a1fd Mon Sep 17 00:00:00 2001 From: dougama Date: Thu, 8 Jan 2026 09:48:40 +0100 Subject: [PATCH 1/3] feat(recovery): send backup public keys for validation during account recovery --- package.json | 2 +- src/services/auth.service.test.ts | 21 +++++++++++++-------- src/services/auth.service.ts | 10 +++++++++- src/utils/backupKeyUtils.ts | 17 +++++++++++++++++ yarn.lock | 8 ++++---- 5 files changed, 44 insertions(+), 14 deletions(-) diff --git a/package.json b/package.json index 9199929c9e..370e286d18 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "@iconscout/react-unicons": "^1.1.6", "@internxt/css-config": "1.1.0", "@internxt/lib": "1.4.1", - "@internxt/sdk": "=1.11.17", + "@internxt/sdk": "=1.12.0", "@internxt/ui": "0.1.1", "@phosphor-icons/react": "^2.1.7", "@popperjs/core": "^2.11.6", diff --git a/src/services/auth.service.test.ts b/src/services/auth.service.test.ts index 610bbb5b61..e313650417 100644 --- a/src/services/auth.service.test.ts +++ b/src/services/auth.service.test.ts @@ -687,7 +687,7 @@ describe('updateCredentialsWithToken', () => { expect(keys).toBeUndefined(); }); - it('should successfully update credentials with token and with backup data (ECC only)', async () => { + it('should not send keys when backup data has no publicKeys (legacy backup)', async () => { const mockToken = 'test-reset-token'; const mockNewPassword = 'newPassword123'; const mockMnemonic = @@ -721,13 +721,10 @@ describe('updateCredentialsWithToken', () => { expect(encryptedPassword).toBeDefined(); expect(encryptedSalt).toBeDefined(); expect(encryptedMnemonic).toBeDefined(); - expect(keys).toBeDefined(); - - expect(keys.ecc).toBe('mock-encrypted-data'); - expect(keys.kyber).toBeUndefined(); + expect(keys).toBeUndefined(); }); - it('should successfully update credentials with token and with backup data (ECC and Kyber)', async () => { + it('should send both private and public keys when backup data has publicKeys', async () => { const mockToken = 'test-reset-token'; const mockNewPassword = 'newPassword123'; const mockMnemonic = @@ -739,6 +736,10 @@ describe('updateCredentialsWithToken', () => { ecc: 'test-ecc-private-key', kyber: 'test-kyber-private-key', }, + publicKeys: { + ecc: 'test-ecc-public-key', + kyber: 'test-kyber-public-key', + }, }; (validateMnemonic as any).mockReturnValue(true); @@ -763,8 +764,12 @@ describe('updateCredentialsWithToken', () => { expect(encryptedMnemonic).toBeDefined(); expect(keys).toBeDefined(); - expect(keys.ecc).toBe('mock-encrypted-data'); - expect(keys.kyber).toBe('mock-encrypted-data'); + expect(keys.private.ecc).toBe('mock-encrypted-data'); + expect(keys.private.kyber).toBe('mock-encrypted-data'); + expect(keys.public).toEqual({ + ecc: 'test-ecc-public-key', + kyber: 'test-kyber-public-key', + }); }); it('should throw an error when mnemonic is invalid', async () => { diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts index 30a6ef37f0..1189358901 100644 --- a/src/services/auth.service.ts +++ b/src/services/auth.service.ts @@ -339,7 +339,7 @@ export const updateCredentialsWithToken = async ( const authClient = SdkFactory.getNewApiInstance().createAuthClient(); - const keys = + const privateKeys = encryptedEccPrivateKey || encryptedKyberPrivateKey ? { ecc: encryptedEccPrivateKey, @@ -347,6 +347,14 @@ export const updateCredentialsWithToken = async ( } : undefined; + const keys = + privateKeys && backupData?.publicKeys + ? { + private: privateKeys, + public: backupData?.publicKeys, + } + : undefined; + return authClient.changePasswordWithLinkV2( token, encryptedHashedNewPassword, diff --git a/src/utils/backupKeyUtils.ts b/src/utils/backupKeyUtils.ts index 3ff8b3e159..5a881fda7f 100644 --- a/src/utils/backupKeyUtils.ts +++ b/src/utils/backupKeyUtils.ts @@ -16,6 +16,9 @@ import { encryptMessageWithPublicKey, hybridEncryptMessageWithPublicKey } from ' * @property {Object} keys - The user's encryption keys * @property {string} keys.ecc - The user's ECC private key * @property {string} keys.kyber - The user's Kyber private key + * @property {Object} [publicKeys] - The user's public keys (for backup validation) + * @property {string} [publicKeys.ecc] - The user's ECC public key + * @property {string} [publicKeys.kyber] - The user's Kyber public key */ export interface BackupData { mnemonic: string; @@ -24,6 +27,10 @@ export interface BackupData { ecc: string; kyber: string; }; + publicKeys?: { + ecc?: string; + kyber?: string; + }; } /** @@ -49,6 +56,10 @@ export function handleExportBackupKey(translate) { ecc: user.keys?.ecc?.privateKey || user.privateKey, kyber: user.keys?.kyber?.privateKey || '', }, + publicKeys: { + ecc: user.keys?.ecc?.publicKey || '', + kyber: user.keys?.kyber?.publicKey || '', + }, }; const backupContent = JSON.stringify(backupData, null, 2); @@ -84,6 +95,12 @@ export const detectBackupKeyFormat = ( ecc: parsedData.keys.ecc, kyber: parsedData.keys.kyber, }, + publicKeys: parsedData.publicKeys + ? { + ecc: parsedData.publicKeys.ecc, + kyber: parsedData.publicKeys.kyber, + } + : undefined, }; return { type: 'new', diff --git a/yarn.lock b/yarn.lock index 63bb1ea9ea..00a1413f82 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1906,10 +1906,10 @@ version "1.0.2" resolved "https://codeload.github.com/internxt/prettier-config/tar.gz/9fa74e9a2805e1538b50c3809324f1c9d0f3e4f9" -"@internxt/sdk@=1.11.17": - version "1.11.17" - resolved "https://registry.yarnpkg.com/@internxt/sdk/-/sdk-1.11.17.tgz#2f5bdada5d3cbf5cfc685a21c24b5df3ff51d8c8" - integrity sha512-91iEUvZizlwX6KBEFJ3JdFiGrhMBQ9R54sTc3Pei9QtV2FYTU8nTVEPYAg39tLOGzT/kVuplYOtBxfk6wFtSDA== +"@internxt/sdk@1.12.0": + version "1.12.0" + resolved "https://registry.yarnpkg.com/@internxt/sdk/-/sdk-1.12.0.tgz#cf9c2f0ca8864a688e4c161f470e171997bff7bb" + integrity sha512-QrjH2yJP7MjxAVvkOe6quqX7RYzC6e3M0XcXralJEFybDpimJBJbvRTPUe7+9XRQ6gHdmYi1u3ySDVoZyZpkug== dependencies: axios "1.13.2" uuid "11.1.0" From 933b69d00fefd23d119b48496946623cf92dbebc Mon Sep 17 00:00:00 2001 From: dougama Date: Thu, 8 Jan 2026 19:49:47 +0100 Subject: [PATCH 2/3] fix(backup): only export publicKeys when both ecc and kyber are present --- src/utils/backupKeyUtils.test.ts | 201 ++++++++++++++++++++++++++++++- src/utils/backupKeyUtils.ts | 26 ++-- 2 files changed, 213 insertions(+), 14 deletions(-) diff --git a/src/utils/backupKeyUtils.test.ts b/src/utils/backupKeyUtils.test.ts index ffeb534c2c..9eb424def0 100644 --- a/src/utils/backupKeyUtils.test.ts +++ b/src/utils/backupKeyUtils.test.ts @@ -65,7 +65,7 @@ describe('backupKeyUtils', () => { }); describe('handleExportBackupKey', () => { - it('should export backup key successfully', () => { + it('When user has valid public keys, then backup should include publicKeys', async () => { const mockMnemonic = 'whip pipe sphere rail witness sting hawk project east return unhappy focus shop dry midnight frog critic lion horror slide luxury consider vibrant timber'; const mockUser = { @@ -73,9 +73,11 @@ describe('backupKeyUtils', () => { keys: { ecc: { privateKey: 'test-ecc-private-key', + publicKey: 'test-ecc-public-key', }, kyber: { privateKey: 'test-kyber-private-key', + publicKey: 'test-kyber-public-key', }, }, userId: 'test-user-id', @@ -107,13 +109,72 @@ describe('backupKeyUtils', () => { handleExportBackupKey(mockTranslate); - expect(localStorageService.get).toHaveBeenCalledWith('xMnemonic'); - expect(localStorageService.getUser).toHaveBeenCalled(); + expect(saveAs).toHaveBeenCalledWith(expect.any(Blob), 'INTERNXT-BACKUP-KEY.txt'); + + const blobCall = vi.mocked(saveAs).mock.calls[0][0] as Blob; + const blobContent = await blobCall.text(); + const parsedBackup = JSON.parse(blobContent); + + expect(parsedBackup.publicKeys).toEqual({ + ecc: 'test-ecc-public-key', + kyber: 'test-kyber-public-key', + }); + + expect(notificationsService.show).toHaveBeenCalledWith({ + text: mockTranslate('views.account.tabs.security.backupKey.success'), + type: ToastType.Success, + }); + }); + + it('When user has no public keys, then backup should not include publicKeys', async () => { + const mockMnemonic = + 'whip pipe sphere rail witness sting hawk project east return unhappy focus shop dry midnight frog critic lion horror slide luxury consider vibrant timber'; + const mockUser = { + privateKey: 'test-private-key', + keys: { + ecc: { + privateKey: 'test-ecc-private-key', + }, + kyber: { + privateKey: 'test-kyber-private-key', + }, + }, + userId: 'test-user-id', + uuid: 'test-uuid', + email: 'test@example.com', + name: 'Test User', + lastname: 'User', + username: 'testuser', + bridgeUser: 'test-bridge-user', + bucket: 'test-bucket', + backupsBucket: null, + root_folder_id: 0, + rootFolderId: 'test-root-folder-id', + rootFolderUuid: 'test-root-folder-uuid', + sharedWorkspace: false, + credit: 0, + publicKey: 'test-public-key', + revocationKey: 'test-revocation-key', + appSumoDetails: null, + registerCompleted: false, + hasReferralsProgram: false, + createdAt: new Date(), + avatar: null, + emailVerified: false, + } as UserSettings; + + vi.mocked(localStorageService.get).mockReturnValue(mockMnemonic); + vi.mocked(localStorageService.getUser).mockReturnValue(mockUser); + + handleExportBackupKey(mockTranslate); expect(saveAs).toHaveBeenCalledWith(expect.any(Blob), 'INTERNXT-BACKUP-KEY.txt'); const blobCall = vi.mocked(saveAs).mock.calls[0][0] as Blob; - expect(blobCall.type).toBe('text/plain'); + const blobContent = await blobCall.text(); + const parsedBackup = JSON.parse(blobContent); + + expect(parsedBackup.publicKeys).toBeUndefined(); expect(notificationsService.show).toHaveBeenCalledWith({ text: mockTranslate('views.account.tabs.security.backupKey.success'), @@ -121,6 +182,56 @@ describe('backupKeyUtils', () => { }); }); + it('When user has only ecc public key, then backup should not include publicKeys', async () => { + const mockMnemonic = + 'whip pipe sphere rail witness sting hawk project east return unhappy focus shop dry midnight frog critic lion horror slide luxury consider vibrant timber'; + const mockUser = { + privateKey: 'test-private-key', + keys: { + ecc: { + privateKey: 'test-ecc-private-key', + publicKey: 'test-ecc-public-key', + }, + kyber: { + privateKey: 'test-kyber-private-key', + }, + }, + userId: 'test-user-id', + uuid: 'test-uuid', + email: 'test@example.com', + name: 'Test User', + lastname: 'User', + username: 'testuser', + bridgeUser: 'test-bridge-user', + bucket: 'test-bucket', + backupsBucket: null, + root_folder_id: 0, + rootFolderId: 'test-root-folder-id', + rootFolderUuid: 'test-root-folder-uuid', + sharedWorkspace: false, + credit: 0, + publicKey: 'test-public-key', + revocationKey: 'test-revocation-key', + appSumoDetails: null, + registerCompleted: false, + hasReferralsProgram: false, + createdAt: new Date(), + avatar: null, + emailVerified: false, + } as UserSettings; + + vi.mocked(localStorageService.get).mockReturnValue(mockMnemonic); + vi.mocked(localStorageService.getUser).mockReturnValue(mockUser); + + handleExportBackupKey(mockTranslate); + + const blobCall = vi.mocked(saveAs).mock.calls[0][0] as Blob; + const blobContent = await blobCall.text(); + const parsedBackup = JSON.parse(blobContent); + + expect(parsedBackup.publicKeys).toBeUndefined(); + }); + it('should handle missing mnemonic', () => { vi.mocked(localStorageService.get).mockReturnValue(null); vi.mocked(localStorageService.getUser).mockReturnValue({} as any); @@ -192,6 +303,88 @@ describe('backupKeyUtils', () => { }); describe('detectBackupKeyFormat', () => { + it('When backup has valid publicKeys, then result should include publicKeys', () => { + const mockBackupData = { + mnemonic: 'test mnemonic', + privateKey: 'test-private-key', + keys: { + ecc: 'test-ecc-key', + kyber: 'test-kyber-key', + }, + publicKeys: { + ecc: 'test-ecc-public-key', + kyber: 'test-kyber-public-key', + }, + }; + + const backupKeyContent = JSON.stringify(mockBackupData); + + const result = detectBackupKeyFormat(backupKeyContent); + + expect(result.backupData?.publicKeys).toEqual({ + ecc: 'test-ecc-public-key', + kyber: 'test-kyber-public-key', + }); + }); + + it('When backup has no publicKeys, then result should not include publicKeys', () => { + const mockBackupData: BackupData = { + mnemonic: 'test mnemonic', + privateKey: 'test-private-key', + keys: { + ecc: 'test-ecc-key', + kyber: 'test-kyber-key', + }, + }; + + const backupKeyContent = JSON.stringify(mockBackupData); + + const result = detectBackupKeyFormat(backupKeyContent); + + expect(result.backupData?.publicKeys).toBeUndefined(); + }); + + it('When backup has only ecc publicKey, then result should not include publicKeys', () => { + const mockBackupData = { + mnemonic: 'test mnemonic', + privateKey: 'test-private-key', + keys: { + ecc: 'test-ecc-key', + kyber: 'test-kyber-key', + }, + publicKeys: { + ecc: 'test-ecc-public-key', + }, + }; + + const backupKeyContent = JSON.stringify(mockBackupData); + + const result = detectBackupKeyFormat(backupKeyContent); + + expect(result.backupData?.publicKeys).toBeUndefined(); + }); + + it('When backup has empty publicKeys, then result should not include publicKeys', () => { + const mockBackupData = { + mnemonic: 'test mnemonic', + privateKey: 'test-private-key', + keys: { + ecc: 'test-ecc-key', + kyber: 'test-kyber-key', + }, + publicKeys: { + ecc: '', + kyber: '', + }, + }; + + const backupKeyContent = JSON.stringify(mockBackupData); + + const result = detectBackupKeyFormat(backupKeyContent); + + expect(result.backupData?.publicKeys).toBeUndefined(); + }); + it('should detect new backup key format with full data', () => { const mockBackupData: BackupData = { mnemonic: 'test mnemonic', diff --git a/src/utils/backupKeyUtils.ts b/src/utils/backupKeyUtils.ts index 5a881fda7f..6b82ec9eb1 100644 --- a/src/utils/backupKeyUtils.ts +++ b/src/utils/backupKeyUtils.ts @@ -49,6 +49,8 @@ export function handleExportBackupKey(translate) { type: ToastType.Error, }); } else { + const hasPublicKeys = user.keys?.ecc?.publicKey && user.keys?.kyber?.publicKey; + const backupData: BackupData = { mnemonic, privateKey: user.privateKey, @@ -56,10 +58,12 @@ export function handleExportBackupKey(translate) { ecc: user.keys?.ecc?.privateKey || user.privateKey, kyber: user.keys?.kyber?.privateKey || '', }, - publicKeys: { - ecc: user.keys?.ecc?.publicKey || '', - kyber: user.keys?.kyber?.publicKey || '', - }, + ...(hasPublicKeys && { + publicKeys: { + ecc: user.keys.ecc.publicKey, + kyber: user.keys.kyber.publicKey, + }, + }), }; const backupContent = JSON.stringify(backupData, null, 2); @@ -88,6 +92,8 @@ export const detectBackupKeyFormat = ( try { const parsedData = JSON.parse(backupKeyContent); if (parsedData?.mnemonic && parsedData.privateKey && parsedData?.keys?.ecc && parsedData?.keys?.kyber) { + const hasPublicKeys = parsedData.publicKeys?.ecc && parsedData.publicKeys?.kyber; + const backupData: BackupData = { mnemonic: parsedData.mnemonic, privateKey: parsedData.privateKey, @@ -95,12 +101,12 @@ export const detectBackupKeyFormat = ( ecc: parsedData.keys.ecc, kyber: parsedData.keys.kyber, }, - publicKeys: parsedData.publicKeys - ? { - ecc: parsedData.publicKeys.ecc, - kyber: parsedData.publicKeys.kyber, - } - : undefined, + ...(hasPublicKeys && { + publicKeys: { + ecc: parsedData.publicKeys.ecc, + kyber: parsedData.publicKeys.kyber, + }, + }), }; return { type: 'new', From f025d73977acb1303b5c3be61ef3144c3ad7a60a Mon Sep 17 00:00:00 2001 From: dougama Date: Mon, 26 Jan 2026 18:19:59 +0100 Subject: [PATCH 3/3] fix(auth): send privateKeys even when publicKeys are missing for backward compatibility --- src/services/auth.service.test.ts | 7 +++++-- src/services/auth.service.ts | 13 ++++++------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/services/auth.service.test.ts b/src/services/auth.service.test.ts index e313650417..ad8d67aa04 100644 --- a/src/services/auth.service.test.ts +++ b/src/services/auth.service.test.ts @@ -687,7 +687,7 @@ describe('updateCredentialsWithToken', () => { expect(keys).toBeUndefined(); }); - it('should not send keys when backup data has no publicKeys (legacy backup)', async () => { + it('When backup data has no publicKeys (legacy backup), then it should send only privateKeys', async () => { const mockToken = 'test-reset-token'; const mockNewPassword = 'newPassword123'; const mockMnemonic = @@ -721,7 +721,10 @@ describe('updateCredentialsWithToken', () => { expect(encryptedPassword).toBeDefined(); expect(encryptedSalt).toBeDefined(); expect(encryptedMnemonic).toBeDefined(); - expect(keys).toBeUndefined(); + expect(keys).toBeDefined(); + + expect(keys.private.ecc).toBe('mock-encrypted-data'); + expect(keys.public).toBeUndefined(); }); it('should send both private and public keys when backup data has publicKeys', async () => { diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts index 1189358901..ce1e9c925e 100644 --- a/src/services/auth.service.ts +++ b/src/services/auth.service.ts @@ -347,13 +347,12 @@ export const updateCredentialsWithToken = async ( } : undefined; - const keys = - privateKeys && backupData?.publicKeys - ? { - private: privateKeys, - public: backupData?.publicKeys, - } - : undefined; + const keys = privateKeys + ? { + private: privateKeys, + ...(backupData?.publicKeys && { public: backupData.publicKeys }), + } + : undefined; return authClient.changePasswordWithLinkV2( token,