diff --git a/src/services/auth.service.test.ts b/src/services/auth.service.test.ts index 26f6e0b0c..324cbb26a 100644 --- a/src/services/auth.service.test.ts +++ b/src/services/auth.service.test.ts @@ -690,7 +690,7 @@ describe('updateCredentialsWithToken', () => { expect(keys).toBeUndefined(); }); - it('should successfully update credentials with token and with backup data (ECC only)', 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 = @@ -726,11 +726,11 @@ describe('updateCredentialsWithToken', () => { expect(encryptedMnemonic).toBeDefined(); expect(keys).toBeDefined(); - expect(keys.ecc).toBe('mock-encrypted-data'); - expect(keys.kyber).toBeUndefined(); + expect(keys.private.ecc).toBe('mock-encrypted-data'); + expect(keys.public).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 = @@ -742,6 +742,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); @@ -766,8 +770,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 a5023d097..a81a15d55 100644 --- a/src/services/auth.service.ts +++ b/src/services/auth.service.ts @@ -340,7 +340,7 @@ export const updateCredentialsWithToken = async ( const authClient = SdkFactory.getNewApiInstance().createAuthClient(); - const keys = + const privateKeys = encryptedEccPrivateKey || encryptedKyberPrivateKey ? { ecc: encryptedEccPrivateKey, @@ -348,6 +348,13 @@ export const updateCredentialsWithToken = async ( } : undefined; + const keys = privateKeys + ? { + private: privateKeys, + ...(backupData?.publicKeys && { public: backupData.publicKeys }), + } + : undefined; + return authClient.changePasswordWithLinkV2( token, encryptedHashedNewPassword, diff --git a/src/utils/backupKeyUtils.test.ts b/src/utils/backupKeyUtils.test.ts index ffeb534c2..9eb424def 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 3ff8b3e15..6b82ec9eb 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; + }; } /** @@ -42,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, @@ -49,6 +58,12 @@ export function handleExportBackupKey(translate) { ecc: user.keys?.ecc?.privateKey || user.privateKey, kyber: user.keys?.kyber?.privateKey || '', }, + ...(hasPublicKeys && { + publicKeys: { + ecc: user.keys.ecc.publicKey, + kyber: user.keys.kyber.publicKey, + }, + }), }; const backupContent = JSON.stringify(backupData, null, 2); @@ -77,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, @@ -84,6 +101,12 @@ export const detectBackupKeyFormat = ( ecc: parsedData.keys.ecc, kyber: parsedData.keys.kyber, }, + ...(hasPublicKeys && { + publicKeys: { + ecc: parsedData.publicKeys.ecc, + kyber: parsedData.publicKeys.kyber, + }, + }), }; return { type: 'new',