diff --git a/src/externals/asymmetric-encryption/asymmetric-encryption.service.spec.ts b/src/externals/asymmetric-encryption/asymmetric-encryption.service.spec.ts new file mode 100644 index 00000000..e4cd1386 --- /dev/null +++ b/src/externals/asymmetric-encryption/asymmetric-encryption.service.spec.ts @@ -0,0 +1,200 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AsymmetricEncryptionService } from './asymmetric-encryption.service'; +import { KyberBuilder, kyberProvider } from './providers/kyber.provider'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import * as openpgp from './openpgp'; +import * as utils from './utils'; + +jest.mock('./openpgp'); +jest.mock('./utils'); + +describe('AsymmetricEncryptionService', () => { + let service: AsymmetricEncryptionService; + let kyberKem: DeepMocked; + + beforeEach(async () => { + kyberKem = createMock(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AsymmetricEncryptionService, + { + provide: kyberProvider.provide, + useValue: kyberKem, + }, + ], + }).compile(); + + service = module.get( + AsymmetricEncryptionService, + ); + }); + + it('When tests are created, then expected mocks should be created', () => { + expect(service).toBeDefined(); + expect(kyberKem).toBeDefined(); + }); + + describe('generateKyberKeys', () => { + it('When called, should generate a pair of keys', async () => { + const publicKey = 'publicKey'; + const privateKey = 'privateKey'; + + kyberKem.keypair.mockResolvedValue({ + publicKey: Buffer.from(publicKey), + privateKey: Buffer.from(privateKey), + }); + + const keys = await service.generateKyberKeys(); + + expect(keys).toEqual({ + publicKey: Buffer.from(publicKey).toString('base64'), + privateKey: Buffer.from(privateKey).toString('base64'), + }); + }); + }); + + describe('encapsulateWithKyber', () => { + it('When called, then it should encapsulate secret with kyber keys', async () => { + const mockCiphertext = new Uint8Array([1, 2, 3]); + const mockSecret = new Uint8Array([4, 5, 6]); + kyberKem.encapsulate.mockResolvedValue({ + ciphertext: mockCiphertext, + sharedSecret: mockSecret, + }); + + const result = await service.encapsulateWithKyber( + new Uint8Array([7, 8, 9]), + ); + + expect(result).toEqual({ + ciphertext: mockCiphertext, + sharedSecret: mockSecret, + }); + }); + }); + + describe('decapsulateWithKyber', () => { + it('When called, then it should decapsulate secret with kyber keys', async () => { + const mockSecret = new Uint8Array([4, 5, 6]); + kyberKem.decapsulate.mockResolvedValue({ sharedSecret: mockSecret }); + + const result = await service.decapsulateWithKyber( + new Uint8Array([1, 2, 3]), + new Uint8Array([4, 5, 6]), + ); + + expect(result).toEqual(mockSecret); + }); + }); + + describe('hybridEncryptMessageWithPublicKey', () => { + it('When kyber key is passed, then it should encrypt with hybrid mode', async () => { + const mockCipherText = new Uint8Array([4, 5, 6]); + const mockSharedSecret = new Uint8Array([4, 5, 6]); + + jest.spyOn(utils, 'extendSecret').mockResolvedValue('extendedSecret'); + jest.spyOn(utils, 'XORhex').mockReturnValue('xoredMessage'); + jest + .spyOn(openpgp, 'encryptMessageWithPublicKey') + .mockResolvedValue('encryptedECCMessage'); + + jest.spyOn(service, 'encapsulateWithKyber').mockResolvedValueOnce({ + ciphertext: mockCipherText, + sharedSecret: mockSharedSecret, + }); + + const result = await service.hybridEncryptMessageWithPublicKey({ + message: 'Hello', + publicKeyInBase64: 'ECCPublicKey', + publicKyberKeyBase64: 'KyberPublicKey', + }); + + expect(result).toContain('SHlicmlkTW9kZQ=='); + expect(result).toContain('$'); // Hybrid format separator + }); + + it('When no Kyber key is passed, then it should encrypt using ECC only', async () => { + const encryptedMessage = Buffer.from('encryptedECCMessage').toString( + 'binary', + ); + const expectedBase64 = Buffer.from(encryptedMessage, 'binary').toString( + 'base64', + ); + + jest + .spyOn(openpgp, 'encryptMessageWithPublicKey') + .mockResolvedValue(encryptedMessage); + + const result = await service.hybridEncryptMessageWithPublicKey({ + message: 'Hello', + publicKeyInBase64: 'ECCPublicKey', + }); + + expect(result).toContain(expectedBase64); + expect(result).not.toContain('$'); + }); + }); + + describe('hybridDecryptMessageWithPrivateKey', () => { + it('When both ECC and Kyber keys are passed, then it should decrypt using hybrid mode', async () => { + const mockDecryptedMessage = 'decryptedMessage'; + const mockSharedSecret = new Uint8Array([4, 5, 6]); + const mockXoredMessage = + Buffer.from(mockDecryptedMessage).toString('hex'); + const mockDecryptedECCMessage = 'decryptedECCMessage'; + + jest + .spyOn(service, 'decapsulateWithKyber') + .mockResolvedValue(mockSharedSecret); + jest + .spyOn(openpgp, 'decryptMessageWithPrivateKey') + .mockResolvedValue(mockDecryptedECCMessage); + + jest.spyOn(utils, 'extendSecret').mockResolvedValue('extendedSecret'); + jest.spyOn(utils, 'XORhex').mockReturnValue(mockXoredMessage); + + const encryptedMessageInBase64 = + 'SHlicmlkTW9kZQ==$mockKyberCiphertext$mockEccCiphertext'; + + const result = await service.hybridDecryptMessageWithPrivateKey({ + encryptedMessageInBase64, + privateKeyInBase64: 'ECCPrivateKey', + privateKyberKeyInBase64: 'KyberPrivateKey', + }); + + expect(result).toEqual(mockDecryptedMessage); + }); + + it('When no Kyber key is passed, then it should decrypt using ECC only', async () => { + const mockDecryptedECCMessage = 'Any decrypted text'; + + jest + .spyOn(openpgp, 'decryptMessageWithPrivateKey') + .mockResolvedValue(mockDecryptedECCMessage); + + const encryptedMessageInBase64 = 'mockEccCiphertextBase64'; + + const result = await service.hybridDecryptMessageWithPrivateKey({ + encryptedMessageInBase64, + privateKeyInBase64: 'ECCPrivateKey', + }); + + expect(result).toEqual(mockDecryptedECCMessage); + }); + + it('When attempting to decrypt a hybrid message without a Kyber private key, then it should throw error', async () => { + const encryptedMessageInBase64 = + 'SHlicmlkTW9kZQ==$mockKyberCiphertext$mockEccCiphertext'; + + await expect( + service.hybridDecryptMessageWithPrivateKey({ + encryptedMessageInBase64, + privateKeyInBase64: 'ECCPrivateKey', + }), + ).rejects.toThrow( + 'Attempted to decrypt hybrid ciphertex without Kyber key', + ); + }); + }); +}); diff --git a/src/externals/asymmetric-encryption/openpgp.spec.ts b/src/externals/asymmetric-encryption/openpgp.spec.ts new file mode 100644 index 00000000..c40f8346 --- /dev/null +++ b/src/externals/asymmetric-encryption/openpgp.spec.ts @@ -0,0 +1,110 @@ +import { + generateNewKeys, + decryptMessageWithPrivateKey, + encryptMessageWithPublicKey, +} from './openpgp'; +import * as openpgp from 'openpgp'; + +jest.mock('openpgp', () => ({ + generateKey: jest.fn(), + readPrivateKey: jest.fn(), + readMessage: jest.fn(), + decrypt: jest.fn(), + readKey: jest.fn(), + encrypt: jest.fn(), + createMessage: jest.fn(), +})); + +describe('PGP Utils', () => { + describe('generateNewKeys', () => { + it('When generating new keys, then it should return keys in base64 format', async () => { + const mockPrivateKey = 'mockPrivateKey'; + const mockPublicKey = 'mockPublicKey'; + const mockRevocationCert = 'mockRevocationCert'; + + (openpgp.generateKey as jest.Mock).mockResolvedValue({ + privateKey: mockPrivateKey, + publicKey: mockPublicKey, + revocationCertificate: mockRevocationCert, + }); + + const result = await generateNewKeys(); + + expect(openpgp.generateKey).toHaveBeenCalledWith({ + userIDs: [{ email: 'inxt@inxt.com' }], + curve: 'ed25519', + date: undefined, + }); + expect(result.privateKeyArmored).toBe(mockPrivateKey); + expect(result.publicKeyArmored).toBe( + Buffer.from(mockPublicKey).toString('base64'), + ); + expect(result.revocationCertificate).toBe( + Buffer.from(mockRevocationCert).toString('base64'), + ); + }); + }); + + describe('decryptMessageWithPrivateKey', () => { + it('When a valid encrypted message and private key are provided, then it should decrypt the message successfully', async () => { + const mockEncryptedMessage = 'mockEncryptedMessage'; + const mockPrivateKey = 'mockPrivateKeyBase64'; + const mockDecryptedMessage = 'Decrypted message'; + + (openpgp.readPrivateKey as jest.Mock).mockResolvedValue(mockPrivateKey); + (openpgp.readMessage as jest.Mock).mockResolvedValue( + mockEncryptedMessage, + ); + (openpgp.decrypt as jest.Mock).mockResolvedValue({ + data: mockDecryptedMessage, + }); + + const result = await decryptMessageWithPrivateKey({ + encryptedMessage: mockEncryptedMessage, + privateKeyInBase64: mockPrivateKey, + }); + + expect(openpgp.readPrivateKey).toHaveBeenCalledWith({ + armoredKey: mockPrivateKey, + }); + expect(openpgp.readMessage).toHaveBeenCalledWith({ + armoredMessage: mockEncryptedMessage, + }); + expect(openpgp.decrypt).toHaveBeenCalledWith({ + message: mockEncryptedMessage, + decryptionKeys: mockPrivateKey, + }); + expect(result).toBe(mockDecryptedMessage); + }); + }); + + describe('encryptMessageWithPublicKey', () => { + it('When a valid message and public key are provided, then it should encrypt the message successfully', async () => { + const mockMessage = 'Decrypted message'; + const mockPublicKey = 'mockPublicKey'; + const mockPublicKeyBase64 = Buffer.from(mockPublicKey).toString('base64'); + const mockEncryptedMessage = 'mockEncryptedMessage'; + + (openpgp.readKey as jest.Mock).mockResolvedValue(mockPublicKey); + (openpgp.createMessage as jest.Mock).mockResolvedValue({ + text: mockMessage, + }); + (openpgp.encrypt as jest.Mock).mockResolvedValue(mockEncryptedMessage); + + const result = await encryptMessageWithPublicKey({ + message: mockMessage, + publicKeyInBase64: mockPublicKeyBase64, + }); + + expect(openpgp.readKey).toHaveBeenCalledWith({ + armoredKey: mockPublicKey, + }); + expect(openpgp.createMessage).toHaveBeenCalledWith({ text: mockMessage }); + expect(openpgp.encrypt).toHaveBeenCalledWith({ + message: { text: mockMessage }, + encryptionKeys: mockPublicKey, + }); + expect(result).toBe(mockEncryptedMessage); + }); + }); +}); diff --git a/src/externals/asymmetric-encryption/utils.spec.ts b/src/externals/asymmetric-encryption/utils.spec.ts new file mode 100644 index 00000000..dc9c2542 --- /dev/null +++ b/src/externals/asymmetric-encryption/utils.spec.ts @@ -0,0 +1,73 @@ +import { extendSecret, XORhex } from './utils'; + +describe('extendSecret', () => { + it('When given a secret, then it should return a hashed value with the desired bit-length', async () => { + const secret = new Uint8Array([1, 2, 3, 4]); + const bitLength = 256; + + const result = await extendSecret(secret, bitLength); + + expect(typeof result).toBe('string'); + expect(result.length).toBeGreaterThan(0); + }); + + it('When given different inputs, then it should produce different hashes', async () => { + const secret1 = new Uint8Array([1, 2, 3, 4]); + const secret2 = new Uint8Array([5, 6, 7, 8]); + const bitLength = 256; + + const hash1 = await extendSecret(secret1, bitLength); + const hash2 = await extendSecret(secret2, bitLength); + + expect(hash1).not.toEqual(hash2); + }); + + it('When given the same input, then it should return the same hash', async () => { + const secret = new Uint8Array([1, 2, 3, 4]); + const bitLength = 256; + + const hash1 = await extendSecret(secret, bitLength); + const hash2 = await extendSecret(secret, bitLength); + + expect(hash1).toEqual(hash2); + }); +}); + +describe('XORhex', () => { + it('When identical hex strings are XORed, then it should return a string of zeros', () => { + const hexString = 'deadbeef'; + const expectedResult = '00000000'; + + expect(XORhex(hexString, hexString)).toBe(expectedResult); + }); + + it('When a fixed example is provided, it should return the expected result', async () => { + const firstHex = '74686973206973207468652074657374206d657373616765'; + const secondHex = '7468697320697320746865207365636f6e64206d65737361'; + const resultHex = '0000000000000000000000000700101b4e09451e16121404'; + + const xoredMessage = XORhex(firstHex, secondHex); + + expect(xoredMessage).toEqual(resultHex); + }); + + it('When XORing with a zero-filled hex string, then it should return the original string', () => { + const a = '12345678'; + const zeroString = '00000000'; + + expect(XORhex(a, zeroString)).toBe(a); + }); + + it('When input strings have different lengths, then it should throw an error', () => { + const a = '1234'; + const b = 'abcd12'; + + expect(() => XORhex(a, b)).toThrow( + 'Can XOR only strings with identical length', + ); + }); + + it('When both input strings are empty, then it should return an empty string', () => { + expect(XORhex('', '')).toBe(''); + }); +}); diff --git a/src/modules/user/user.usecase.spec.ts b/src/modules/user/user.usecase.spec.ts index 59408e05..5d6ea5e2 100644 --- a/src/modules/user/user.usecase.spec.ts +++ b/src/modules/user/user.usecase.spec.ts @@ -44,10 +44,11 @@ import { newFile, newFolder, newKeyServer, + newPreCreatedUser, } from '../../../test/fixtures'; import { MailTypes } from '../security/mail-limit/mailTypes'; import { SequelizeWorkspaceRepository } from '../workspaces/repositories/workspaces.repository'; -import * as openpgpUtils from '../../lib/assymetric-encryption/openpgp'; +import * as openpgpUtils from '../../externals/asymmetric-encryption/openpgp'; import { SequelizeMailLimitRepository } from '../security/mail-limit/mail-limit.repository'; import { DeviceType, @@ -63,6 +64,22 @@ import { UpdateProfileDto } from './dto/update-profile.dto'; import { UpdatePasswordDto } from './dto/update-password.dto'; import { UserKeysEncryptVersions } from '../keyserver/key-server.domain'; import { KeyServerUseCases } from '../keyserver/key-server.usecase'; +import { SequelizeSharingRepository } from '../sharing/sharing.repository'; +import { SequelizePreCreatedUsersRepository } from './pre-created-users.repository'; +import { AsymmetricEncryptionService } from '../../externals/asymmetric-encryption/asymmetric-encryption.service'; +import { SharingInvite } from '../sharing/sharing.domain'; +import { aes } from '@internxt/lib'; + +jest.mock('../../middlewares/passport', () => { + const originalModule = jest.requireActual('../../middlewares/passport'); + return { + __esModule: true, + ...originalModule, + SignWithCustomDuration: jest.fn((payload, secret, expiresIn) => 'anyToken'), + Sign: jest.fn(() => 'newToken'), + SignEmail: jest.fn(() => 'token'), + }; +}); jest.mock('../../middlewares/passport', () => { const originalModule = jest.requireActual('../../middlewares/passport'); @@ -84,6 +101,10 @@ describe('User use cases', () => { let keyServerRepository: SequelizeKeyServerRepository; let bridgeService: BridgeService; let sharedWorkspaceRepository: SequelizeSharedWorkspaceRepository; + let sharingRepository: SequelizeSharingRepository; + let preCreatedUsersRepository: SequelizePreCreatedUsersRepository; + let asymmetricEncryptionService: AsymmetricEncryptionService; + let cryptoService: CryptoService; let attemptChangeEmailRepository: SequelizeAttemptChangeEmailRepository; let configService: ConfigService; @@ -162,6 +183,16 @@ describe('User use cases', () => { mailerService = moduleRef.get(MailerService); avatarService = moduleRef.get(AvatarService); keyServerUseCases = moduleRef.get(KeyServerUseCases); + sharingRepository = moduleRef.get( + SequelizeSharingRepository, + ); + preCreatedUsersRepository = + moduleRef.get( + SequelizePreCreatedUsersRepository, + ); + asymmetricEncryptionService = moduleRef.get( + AsymmetricEncryptionService, + ); jest.clearAllMocks(); }); @@ -376,6 +407,133 @@ describe('User use cases', () => { }); }); + describe('replacePreCreatedUser', () => { + it('When pre-created user exists, then replace invitations with new user keys and uuid', async () => { + const preCreatedUser = newPreCreatedUser(); + const newUserUuid = v4(); + + const newPublicKey = 'new-public-key'; + const newPublicKyberKey = 'new-public-kyber-key'; + const preCreatedUserDecryptedKey = 'decrypted-private-key'; + const preCreatedUserDecryptedKyberKey = 'decrypted-private-kyber-key'; + + const sharingDecryptedEccKey = 'decrypted-encryption-key-ecc'; + const sharingDecryptedHybridKey = 'decrypted-encryption-key-hybrid'; + const newSharingEncryptedEccKey = 'new-encrypted-encryption-key-ecc'; + const newSharingEncryptedHybridKey = + 'new-encrypted-encryption-key-hybrid'; + + const invites: SharingInvite[] = [ + SharingInvite.build({ + id: v4(), + type: 'OWNER', + roleId: v4(), + createdAt: new Date(), + updatedAt: new Date(), + encryptionAlgorithm: 'ecc', + encryptionKey: 'encrypted-key-1', + sharedWith: preCreatedUser.uuid, + itemId: v4(), + itemType: 'file', + }), + SharingInvite.build({ + id: v4(), + type: 'OWNER', + roleId: v4(), + createdAt: new Date(), + updatedAt: new Date(), + encryptionAlgorithm: 'hybrid', + encryptionKey: 'encrypted-key-1', + sharedWith: preCreatedUser.uuid, + itemId: v4(), + itemType: 'file', + }), + ]; + jest + .spyOn(preCreatedUsersRepository, 'findByUsername') + .mockResolvedValueOnce(preCreatedUser); + + jest.spyOn(configService, 'get').mockReturnValueOnce('default-pass'); + jest + .spyOn(aes, 'decrypt') + .mockReturnValueOnce(preCreatedUserDecryptedKey) + .mockReturnValueOnce(preCreatedUserDecryptedKyberKey); + jest + .spyOn(sharingRepository, 'getInvitesBySharedwith') + .mockResolvedValueOnce(invites); + jest + .spyOn( + asymmetricEncryptionService, + 'hybridDecryptMessageWithPrivateKey', + ) + .mockResolvedValueOnce(sharingDecryptedEccKey) + .mockResolvedValueOnce(sharingDecryptedHybridKey); + + jest + .spyOn(asymmetricEncryptionService, 'hybridEncryptMessageWithPublicKey') + .mockResolvedValueOnce(newSharingEncryptedEccKey) + .mockResolvedValueOnce(newSharingEncryptedHybridKey); + jest.spyOn(sharingRepository, 'bulkUpdate'); + jest.spyOn(userUseCases, 'replacePreCreatedUserWorkspaceInvitations'); + jest.spyOn(preCreatedUsersRepository, 'deleteByUuid'); + + await userUseCases.replacePreCreatedUser( + preCreatedUser.email, + newUserUuid, + newPublicKey, + newPublicKyberKey, + ); + + expect(preCreatedUsersRepository.findByUsername).toHaveBeenCalledWith( + preCreatedUser.email, + ); + expect(sharingRepository.getInvitesBySharedwith).toHaveBeenCalledWith( + preCreatedUser.uuid, + ); + + expect(sharingRepository.bulkUpdate).toHaveBeenCalledWith([ + expect.objectContaining({ + encryptionKey: newSharingEncryptedEccKey, + sharedWith: newUserUuid, + }), + expect.objectContaining({ + encryptionKey: newSharingEncryptedHybridKey, + sharedWith: newUserUuid, + }), + ]); + + expect( + userUseCases.replacePreCreatedUserWorkspaceInvitations, + ).toHaveBeenCalledWith( + preCreatedUser.uuid, + newUserUuid, + preCreatedUserDecryptedKey, + newPublicKey, + ); + expect(preCreatedUsersRepository.deleteByUuid).toHaveBeenCalledWith( + preCreatedUser.uuid, + ); + }); + + it('When pre-created user does not exist, then do nothing', async () => { + jest + .spyOn(preCreatedUsersRepository, 'findByUsername') + .mockResolvedValue(null); + + await userUseCases.replacePreCreatedUser( + 'non-existent-email', + 'new-user-uuid', + 'new-public-key', + 'new-public-kyber-key', + ); + + expect(preCreatedUsersRepository.findByUsername).toHaveBeenCalled(); + expect(sharingRepository.getInvitesBySharedwith).not.toHaveBeenCalled(); + expect(sharingRepository.bulkUpdate).not.toHaveBeenCalled(); + expect(preCreatedUsersRepository.deleteByUuid).not.toHaveBeenCalled(); + }); + }); + describe('Unblocking user account', () => { describe('Request Account unblock', () => { const fixedSystemCurrentDate = new Date('2020-02-19');