diff --git a/src/modules/user/dto/recover-account.dto.ts b/src/modules/user/dto/recover-account.dto.ts index cab15aeea..da0501380 100644 --- a/src/modules/user/dto/recover-account.dto.ts +++ b/src/modules/user/dto/recover-account.dto.ts @@ -4,8 +4,10 @@ import { IsEmail, IsNotEmpty, IsOptional, + IsString, ValidateNested, } from 'class-validator'; +import { Type } from 'class-transformer'; class PrivateKeysDto { @ApiProperty() @@ -14,6 +16,19 @@ class PrivateKeysDto { @ApiProperty() kyber: string; } + +class PublicKeysDto { + @ApiProperty() + @IsString() + @IsNotEmpty() + ecc: string; + + @ApiProperty() + @IsString() + @IsNotEmpty() + kyber: string; +} + export class RecoverAccountDto { @ApiProperty({ example: 'some_hashed_pass', @@ -53,6 +68,12 @@ export class RecoverAccountDto { @IsOptional() @ValidateNested() privateKeys?: PrivateKeysDto; + + @ApiProperty() + @IsOptional() + @ValidateNested() + @Type(() => PublicKeysDto) + publicKeys?: PublicKeysDto; } export class DeprecatedRecoverAccountDto { diff --git a/src/modules/user/user.controller.spec.ts b/src/modules/user/user.controller.spec.ts index 93fc25e3a..1846c039c 100644 --- a/src/modules/user/user.controller.spec.ts +++ b/src/modules/user/user.controller.spec.ts @@ -1348,6 +1348,7 @@ describe('User Controller', () => { mnemonic: mockRecoverAccountNoKeys.mnemonic, password: mockRecoverAccountNoKeys.password, salt: mockRecoverAccountNoKeys.salt, + publicKeys: undefined, }, true, ); @@ -1369,6 +1370,7 @@ describe('User Controller', () => { password: mockRecoverAccountDto.password, salt: mockRecoverAccountDto.salt, privateKeys: mockRecoverAccountDto.privateKeys, + publicKeys: undefined, }, false, ); @@ -1404,6 +1406,7 @@ describe('User Controller', () => { password: mockRecoverAccountDto.password, salt: mockRecoverAccountDto.salt, privateKeys: mockRecoverAccountDto.privateKeys, + publicKeys: undefined, }, false, ); @@ -1437,6 +1440,7 @@ describe('User Controller', () => { password: mockRecoverAccountDto.password, salt: mockRecoverAccountDto.salt, privateKeys: mockRecoverAccountDto.privateKeys, + publicKeys: undefined, }, false, ); diff --git a/src/modules/user/user.controller.ts b/src/modules/user/user.controller.ts index 898b709ab..9f5007b48 100644 --- a/src/modules/user/user.controller.ts +++ b/src/modules/user/user.controller.ts @@ -884,6 +884,7 @@ export class UserController { ...(shouldResetAccount ? undefined : { privateKeys: body.privateKeys }), + publicKeys: body.publicKeys, }, shouldResetAccount, ); diff --git a/src/modules/user/user.usecase.spec.ts b/src/modules/user/user.usecase.spec.ts index 40ce1cbd3..9c8e2870b 100644 --- a/src/modules/user/user.usecase.spec.ts +++ b/src/modules/user/user.usecase.spec.ts @@ -2658,6 +2658,10 @@ describe('User use cases', () => { describe('updateCredentials', () => { const mockUser = newUser(); + const mockPublicKeys = { + ecc: 'existing_ecc_public_key', + kyber: 'existing_kyber_public_key', + }; const mockCredentials = { mnemonic: 'encrypted_mnemonic', password: 'encrypted_password', @@ -2666,6 +2670,7 @@ describe('User use cases', () => { ecc: 'encrypted_ecc_key', kyber: 'encrypted_kyber_key', }, + publicKeys: mockPublicKeys, }; const decryptedPassword = 'decrypted_password'; @@ -2673,6 +2678,9 @@ describe('User use cases', () => { beforeEach(() => { jest.clearAllMocks(); + jest + .spyOn(keyServerUseCases, 'getPublicKeys') + .mockResolvedValue(mockPublicKeys); jest.spyOn(cryptoService, 'decryptText').mockImplementation((text) => { if (text === mockCredentials.password) return decryptedPassword; if (text === mockCredentials.salt) return decryptedSalt; @@ -2743,6 +2751,113 @@ describe('User use cases', () => { ), ).rejects.toThrow(BadRequestException); }); + + it('When publicKeys are provided and match existing keys, then it should succeed', async () => { + const existingPublicKeys = { + ecc: 'existing_ecc_public_key', + kyber: 'existing_kyber_public_key', + }; + + jest + .spyOn(keyServerUseCases, 'getPublicKeys') + .mockResolvedValue(existingPublicKeys); + + const credentialsWithPublicKeys = { + ...mockCredentials, + publicKeys: existingPublicKeys, + }; + + await userUseCases.updateCredentials( + mockUser.uuid, + credentialsWithPublicKeys, + ); + + expect(keyServerUseCases.getPublicKeys).toHaveBeenCalledWith(mockUser.id); + expect(userRepository.updateByUuid).toHaveBeenCalled(); + }); + + it('When publicKeys.ecc does not match existing key, then it should throw', async () => { + const existingPublicKeys = { + ecc: 'existing_ecc_public_key', + kyber: 'existing_kyber_public_key', + }; + + jest + .spyOn(keyServerUseCases, 'getPublicKeys') + .mockResolvedValue(existingPublicKeys); + + const credentialsWithWrongEcc = { + ...mockCredentials, + publicKeys: { + ecc: 'wrong_ecc_public_key', + kyber: 'existing_kyber_public_key', + }, + }; + + await expect( + userUseCases.updateCredentials(mockUser.uuid, credentialsWithWrongEcc), + ).rejects.toThrow(BadRequestException); + }); + + it('When publicKeys.kyber does not match existing key, then it should throw', async () => { + const existingPublicKeys = { + ecc: 'existing_ecc_public_key', + kyber: 'existing_kyber_public_key', + }; + + jest + .spyOn(keyServerUseCases, 'getPublicKeys') + .mockResolvedValue(existingPublicKeys); + + const credentialsWithWrongKyber = { + ...mockCredentials, + publicKeys: { + ecc: 'existing_ecc_public_key', + kyber: 'wrong_kyber_public_key', + }, + }; + + await expect( + userUseCases.updateCredentials( + mockUser.uuid, + credentialsWithWrongKyber, + ), + ).rejects.toThrow(BadRequestException); + }); + + it('When publicKeys are not provided and not resetting, then it should throw', async () => { + const credentialsWithoutPublicKeys = { + mnemonic: mockCredentials.mnemonic, + password: mockCredentials.password, + salt: mockCredentials.salt, + privateKeys: mockCredentials.privateKeys, + }; + + await expect( + userUseCases.updateCredentials( + mockUser.uuid, + credentialsWithoutPublicKeys, + ), + ).rejects.toThrow(BadRequestException); + }); + + it('When publicKeys are not provided but resetting account, then it should succeed', async () => { + jest.spyOn(keyServerUseCases, 'getPublicKeys'); + + const credentialsWithoutPublicKeys = { + mnemonic: mockCredentials.mnemonic, + password: mockCredentials.password, + salt: mockCredentials.salt, + }; + + await userUseCases.updateCredentials( + mockUser.uuid, + credentialsWithoutPublicKeys, + true, + ); + + expect(keyServerUseCases.getPublicKeys).not.toHaveBeenCalled(); + }); }); describe('updateCredentialsOld', () => { @@ -3461,6 +3576,10 @@ describe('User use cases', () => { describe('updateCredentials', () => { const mockUser = newUser(); + const mockPublicKeys = { + ecc: 'existing_ecc_public_key', + kyber: 'existing_kyber_public_key', + }; const mockCredentials = { mnemonic: 'encrypted_mnemonic', password: 'encrypted_password', @@ -3469,6 +3588,7 @@ describe('User use cases', () => { ecc: 'encrypted_ecc_key', kyber: 'encrypted_kyber_key', }, + publicKeys: mockPublicKeys, }; const decryptedPassword = 'decrypted_password'; @@ -3476,6 +3596,9 @@ describe('User use cases', () => { beforeEach(() => { jest.clearAllMocks(); + jest + .spyOn(keyServerUseCases, 'getPublicKeys') + .mockResolvedValue(mockPublicKeys); jest.spyOn(cryptoService, 'decryptText').mockImplementation((text) => { if (text === mockCredentials.password) return decryptedPassword; if (text === mockCredentials.salt) return decryptedSalt; diff --git a/src/modules/user/user.usecase.ts b/src/modules/user/user.usecase.ts index 7a84db634..01830acff 100644 --- a/src/modules/user/user.usecase.ts +++ b/src/modules/user/user.usecase.ts @@ -1075,10 +1075,15 @@ export class UserUseCases { ecc: string; kyber: string; }; + publicKeys?: { + ecc?: string; + kyber?: string; + }; }, withReset = false, ): Promise { - const { mnemonic, password, salt, privateKeys } = newCredentials; + const { mnemonic, password, salt, privateKeys, publicKeys } = + newCredentials; const shouldUpdateKeys = privateKeys && Object.keys(privateKeys).length > 0; @@ -1088,8 +1093,25 @@ export class UserUseCases { ); } + if (!withReset && !publicKeys) { + throw new BadRequestException('Invalid keys'); + } + const user = await this.userRepository.findByUuid(userUuid); + if (publicKeys) { + const existingKeys = await this.keyServerUseCases.getPublicKeys(user.id); + const eccMismatch = existingKeys.ecc !== publicKeys.ecc; + const kyberMismatch = existingKeys.kyber !== publicKeys.kyber; + + if (eccMismatch) { + throw new BadRequestException('Invalid ECC public key'); + } + if (kyberMismatch) { + throw new BadRequestException('Invalid Kyber public key'); + } + } + if (shouldUpdateKeys) { for (const [version, privateKey] of Object.entries(privateKeys)) { await this.keyServerUseCases.updateByUserAndEncryptVersion(