diff --git a/src/services/auth.service.test.ts b/src/services/auth.service.test.ts index 253a7457d1..cb2980713e 100644 --- a/src/services/auth.service.test.ts +++ b/src/services/auth.service.test.ts @@ -3,19 +3,19 @@ */ import { aes } from '@internxt/lib'; import { UserSettings } from '@internxt/sdk/dist/shared/types/userSettings'; -import envService from 'services/env.service'; -import localStorageService from 'services/local-storage.service'; +import { SdkFactory } from 'app/core/factory/sdk'; +import { LocalStorageItem } from 'app/core/types'; import * as keysService from 'app/crypto/services/keys.service'; import * as pgpService from 'app/crypto/services/pgp.service'; import { encryptText, encryptTextWithKey } from 'app/crypto/services/utils'; import { userActions } from 'app/store/slices/user'; -import { BackupData } from 'utils/backupKeyUtils'; import { validateMnemonic } from 'bip39'; import { Buffer } from 'node:buffer'; +import envService from 'services/env.service'; +import localStorageService from 'services/local-storage.service'; +import { BackupData } from 'utils/backupKeyUtils'; import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; -import { SdkFactory } from 'app/core/factory/sdk'; import * as authService from './auth.service'; -import { LocalStorageItem } from 'app/core/types'; const mockSecret = '123456789QWERTY'; const mockApi = 'https://mock'; @@ -691,7 +691,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 = @@ -728,10 +728,11 @@ describe('updateCredentialsWithToken', () => { expect(keys).toBeDefined(); expect(keys.private.ecc).toBe('mock-encrypted-data'); + expect(keys.public).toBeUndefined(); expect(keys.private.kyber).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 = @@ -743,6 +744,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); @@ -769,6 +774,10 @@ describe('updateCredentialsWithToken', () => { 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 eb5df88c41..ecc872ba07 100644 --- a/src/services/auth.service.ts +++ b/src/services/auth.service.ts @@ -7,6 +7,7 @@ import { SecurityDetails, TwoFactorAuthQR, } from '@internxt/sdk/dist/auth'; +import { RecoveryKeys } from '@internxt/sdk/dist/auth/types'; import { StorageTypes } from '@internxt/sdk/dist/drive'; import { ChangePasswordPayloadNew } from '@internxt/sdk/dist/drive/users/types'; import { UserSettings } from '@internxt/sdk/dist/shared/types/userSettings'; @@ -340,12 +341,13 @@ export const updateCredentialsWithToken = async ( const authClient = SdkFactory.getNewApiInstance().createAuthClient(); - const keys = - encryptedEccPrivateKey || encryptedKyberPrivateKey - ? { - private: { ecc: encryptedEccPrivateKey, kyber: encryptedKyberPrivateKey }, - } - : undefined; + const hasPrivateKeys = encryptedEccPrivateKey || encryptedKyberPrivateKey; + const keys: RecoveryKeys | undefined = hasPrivateKeys + ? { + private: { ecc: encryptedEccPrivateKey, kyber: encryptedKyberPrivateKey }, + public: backupData?.publicKeys, + } + : undefined; return authClient.changePasswordWithLinkV2( token, diff --git a/src/services/local-storage.service.test.ts b/src/services/local-storage.service.test.ts index 122fab6815..3409658809 100644 --- a/src/services/local-storage.service.test.ts +++ b/src/services/local-storage.service.test.ts @@ -331,7 +331,6 @@ describe('Testing the local storage service', () => { }); }); - describe('Clearing local storage', () => { it('When clear storage is requested, then removes all keys', () => { const expectedKeysToRemove = [ diff --git a/src/services/local-storage.service.ts b/src/services/local-storage.service.ts index 0ad330e530..12837e22dc 100644 --- a/src/services/local-storage.service.ts +++ b/src/services/local-storage.service.ts @@ -51,7 +51,6 @@ function hasCompletedTutorial(id?: string): boolean { return localStorage.getItem(STORAGE_KEYS.TUTORIAL_COMPLETED_ID) === id; } - function clear(): void { localStorage.setItem('theme', 'system'); diff --git a/src/utils/backupKeyUtils.test.ts b/src/utils/backupKeyUtils.test.ts index 525d5d62c4..c67aca2515 100644 --- a/src/utils/backupKeyUtils.test.ts +++ b/src/utils/backupKeyUtils.test.ts @@ -1,19 +1,19 @@ import { UserSettings } from '@internxt/sdk/dist/shared/types/userSettings'; -import localStorageService from 'services/local-storage.service'; +import { LocalStorageItem } from 'app/core/types'; import { getKeys } from 'app/crypto/services/keys.service'; +import { encryptMessageWithPublicKey, hybridEncryptMessageWithPublicKey } from 'app/crypto/services/pgp.service'; import { encryptText, encryptTextWithKey, passToHash } from 'app/crypto/services/utils'; import notificationsService, { ToastType } from 'app/notifications/services/notifications.service'; import { validateMnemonic } from 'bip39'; import { saveAs } from 'file-saver'; +import localStorageService from 'services/local-storage.service'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { encryptMessageWithPublicKey, hybridEncryptMessageWithPublicKey } from 'app/crypto/services/pgp.service'; import { BackupData, detectBackupKeyFormat, handleExportBackupKey, prepareOldBackupRecoverPayloadForBackend, } from './backupKeyUtils'; -import { LocalStorageItem } from 'app/core/types'; vi.mock('file-saver', async () => { const actual = await vi.importActual('file-saver'); @@ -66,7 +66,68 @@ 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 = { + privateKey: 'test-private-key', + 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', + 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; + 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 = { @@ -114,7 +175,10 @@ describe('backupKeyUtils', () => { 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'), @@ -122,6 +186,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); @@ -193,6 +307,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 49a2c6cbf3..a52a3e359c 100644 --- a/src/utils/backupKeyUtils.ts +++ b/src/utils/backupKeyUtils.ts @@ -17,6 +17,9 @@ import { LocalStorageItem } from 'app/core/types'; * @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; @@ -25,6 +28,10 @@ export interface BackupData { ecc: string; kyber: string; }; + publicKeys?: { + ecc?: string; + kyber?: string; + }; } /** @@ -43,6 +50,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, @@ -50,6 +59,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); @@ -78,6 +93,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, @@ -85,6 +102,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', diff --git a/src/views/Checkout/hooks/useAuthCheckout.test.ts b/src/views/Checkout/hooks/useAuthCheckout.test.ts index 4eaee80f20..93fab986bd 100644 --- a/src/views/Checkout/hooks/useAuthCheckout.test.ts +++ b/src/views/Checkout/hooks/useAuthCheckout.test.ts @@ -115,7 +115,7 @@ describe('Authentication Checkout Custom hook', () => { expect(mockedAuthenticateUserProps.onAuthenticationFail).toHaveBeenCalled(); }); - test('When the user wants to log out, then all services are cleared and the auth method is set to \'sign up\'', async () => { + test("When the user wants to log out, then all services are cleared and the auth method is set to 'sign up'", async () => { const changeAuthMethod = vi.fn(); const stopRealTimeServiceSpy = vi.spyOn(RealtimeService.prototype, 'stop').mockResolvedValue();