Skip to content
20 changes: 14 additions & 6 deletions src/services/auth.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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 =
Expand All @@ -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);
Expand All @@ -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 () => {
Expand Down
9 changes: 8 additions & 1 deletion src/services/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,14 +340,21 @@ export const updateCredentialsWithToken = async (

const authClient = SdkFactory.getNewApiInstance().createAuthClient();

const keys =
const privateKeys =
encryptedEccPrivateKey || encryptedKyberPrivateKey
? {
ecc: encryptedEccPrivateKey,
kyber: encryptedKyberPrivateKey,
}
: undefined;

const keys = privateKeys
? {
private: privateKeys,
...(backupData?.publicKeys && { public: backupData.publicKeys }),
}
: undefined;

return authClient.changePasswordWithLinkV2(
token,
encryptedHashedNewPassword,
Expand Down
201 changes: 197 additions & 4 deletions src/utils/backupKeyUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,17 +65,19 @@ 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',
Expand Down Expand Up @@ -107,20 +109,129 @@ 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'),
type: ToastType.Success,
});
});

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);
Expand Down Expand Up @@ -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',
Expand Down
23 changes: 23 additions & 0 deletions src/utils/backupKeyUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -24,6 +27,10 @@ export interface BackupData {
ecc: string;
kyber: string;
};
publicKeys?: {
ecc?: string;
kyber?: string;
};
}

/**
Expand All @@ -42,13 +49,21 @@ 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,
keys: {
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);
Expand Down Expand Up @@ -77,13 +92,21 @@ 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,
keys: {
ecc: parsedData.keys.ecc,
kyber: parsedData.keys.kyber,
},
...(hasPublicKeys && {
publicKeys: {
ecc: parsedData.publicKeys.ecc,
kyber: parsedData.publicKeys.kyber,
},
}),
};
return {
type: 'new',
Expand Down
Loading