Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
235 changes: 235 additions & 0 deletions src/app/share/services/share.crypto.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
import { describe, expect, vi, Mock, beforeEach, test } from 'vitest';
import { UserSettings } from '@internxt/sdk/dist/shared/types/userSettings';

import { generateNewKeys, encryptMessageWithPublicKey } from '../../crypto/services/pgp.service';
import encryptedStorageService from 'services/encrypted-storage.service';
import notificationsService from '../../notifications/services/notifications.service';
import {
decryptMnemonic,
encryptMnemonic,
encryptBucketKey,
decryptBucketKey,
decryptSharingKey,
} from './share.crypto';
import { isBucketKeyCiphertext } from '../../crypto/services/pgp.service';
import { generateFileBucketKey } from 'app/network/crypto';

vi.mock('services/error.service', () => ({
default: {
castError: vi.fn().mockImplementation((e) => ({
message: typeof e === 'string' ? e : e.message || 'Default error message',
requestId: 'test-request-id',
})),
reportError: vi.fn(),
},
}));

vi.mock('services/encrypted-storage.service', () => ({
default: {
getUser: vi.fn(),
},
}));

async function getMockUser(
keys: {
privateKeyArmored: string;
publicKeyArmored: string;
publicKyberKeyBase64: string;
privateKyberKeyBase64: string;
},
encryptedMnemonicInBase64: string,
): Promise<UserSettings> {
const mockUser: UserSettings = {
uuid: 'mock-uuid',
email: 'mock@test.com',
mnemonic: encryptedMnemonicInBase64,
userId: 'mock-user-id',
name: 'mock-name',
lastname: 'mock-lastname',
username: 'mock-username',
bridgeUser: 'mock-bridgeUser',
bucket: 'mock-bucket',
backupsBucket: null,
root_folder_id: 0,
rootFolderId: 'mock-rootFolderId',
rootFolderUuid: undefined,
sharedWorkspace: false,
credit: 0,
keys: {
ecc: {
publicKey: keys.publicKeyArmored,
privateKey: Buffer.from(keys.privateKeyArmored).toString('base64'),
},
kyber: {
publicKey: keys.publicKyberKeyBase64,
privateKey: keys.privateKyberKeyBase64,
},
},
appSumoDetails: null,
registerCompleted: false,
hasReferralsProgram: false,
createdAt: new Date(),
avatar: null,
emailVerified: false,
};
return mockUser;
}

describe('should decrypt mnemonics', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
});

const mnemonic =
'truck arch rather sell tilt return warm nurse rack vacuum rubber tribe unfold scissors copper sock panel ozone harsh ahead danger soda legal state';
const bucketId = '0123456789abcdef0123456789abcdef01234567';

test('should decrypt mnemonic encrypted without kyber', async () => {
const keys = await generateNewKeys();
const encriptedMnemonic = await encryptMessageWithPublicKey({
message: mnemonic,
publicKeyInBase64: keys.publicKeyArmored,
});
const encryptedMnemonicInBase64 = btoa(encriptedMnemonic as string);

const mockUser = await getMockUser(keys, encryptedMnemonicInBase64);

(encryptedStorageService.getUser as Mock).mockResolvedValue(mockUser);

const ownerMnemonic = await decryptMnemonic(mockUser.mnemonic);
expect(encryptedStorageService.getUser).toHaveBeenCalled();
expect(ownerMnemonic).toEqual(mnemonic);
});

test('should decrypt mnemonic encrypted with kyber', async () => {
const keys = await generateNewKeys();
const encriptedMnemonic = await encryptMnemonic(mnemonic, keys.publicKeyArmored, keys.publicKyberKeyBase64);

const mockUser = await getMockUser(keys, encriptedMnemonic);

(encryptedStorageService.getUser as Mock).mockResolvedValue(mockUser);

const ownerMnemonic = await decryptMnemonic(mockUser.mnemonic);
expect(encryptedStorageService.getUser).toHaveBeenCalled();
expect(ownerMnemonic).toEqual(mnemonic);
});

describe('bucket key encryption/decryption', () => {
beforeEach(() => {
vi.clearAllMocks();
});

test('should encrypt and decrypt a bucket key roundtrip without kyber', async () => {
const keys = await generateNewKeys();

const encrypted = await encryptBucketKey(mnemonic, bucketId, keys.publicKeyArmored, '');

const mockUser = await getMockUser(keys, 'unused');
(encryptedStorageService.getUser as Mock).mockResolvedValue(mockUser);

const decrypted = await decryptBucketKey(encrypted);
const expectedBucketKey = await generateFileBucketKey(mnemonic, bucketId);

expect(decrypted).toBeDefined();
expect(Buffer.from(decrypted as Uint8Array)).toEqual(expectedBucketKey.subarray(0, 32));
});

test('should encrypt and decrypt a bucket key roundtrip with kyber', async () => {
const keys = await generateNewKeys();
const encrypted = await encryptBucketKey(mnemonic, bucketId, keys.publicKeyArmored, keys.publicKyberKeyBase64);

const mockUser = await getMockUser(keys, 'unused');
(encryptedStorageService.getUser as Mock).mockResolvedValue(mockUser);

const decrypted = await decryptBucketKey(encrypted);
const expectedBucketKey = await generateFileBucketKey(mnemonic, bucketId);

expect(decrypted).toBeDefined();
expect(Buffer.from(decrypted as Uint8Array)).toEqual(expectedBucketKey.subarray(0, 32));
});

test('isBucketKeyCiphertext returns true for a hybrid bucket key ciphertext', async () => {
const keys = await generateNewKeys();
const encrypted = await encryptBucketKey(mnemonic, bucketId, keys.publicKeyArmored, keys.publicKyberKeyBase64);

expect(isBucketKeyCiphertext(encrypted)).toBe(true);
});

test('isBucketKeyCiphertext returns false for a plain mnemonic ciphertext', async () => {
const keys = await generateNewKeys();
const encrypted = await encryptMnemonic('some differnt mnemonic', keys.publicKeyArmored);

expect(isBucketKeyCiphertext(btoa(encrypted as unknown as string))).toBe(false);
});

test('decryptBucketKey returns undefined and notifies when user is not found', async () => {
(encryptedStorageService.getUser as Mock).mockResolvedValue(undefined);
const showSpy = vi.spyOn(notificationsService, 'show');

const result = await decryptBucketKey('anything');

expect(result).toBeUndefined();
expect(showSpy).toHaveBeenCalled();
});

test('decryptBucketKey returns undefined and notifies on malformed ciphertext', async () => {
const keys = await generateNewKeys();
const mockUser = await getMockUser(keys, 'unused');
(encryptedStorageService.getUser as Mock).mockResolvedValue(mockUser);
const showSpy = vi.spyOn(notificationsService, 'show');

const result = await decryptBucketKey('not-a-valid-ciphertext');

expect(result).toBeUndefined();
expect(showSpy).toHaveBeenCalled();
});
});

describe('decryptSharingKey', () => {
beforeEach(() => {
vi.clearAllMocks();
});

test('routes to bucket key decryption when ciphertext is a bucket key', async () => {
const keys = await generateNewKeys();
const encrypted = await encryptBucketKey(mnemonic, bucketId, keys.publicKeyArmored, keys.publicKyberKeyBase64);

const mockUser = await getMockUser(keys, 'unused');
(encryptedStorageService.getUser as Mock).mockResolvedValue(mockUser);

const result = await decryptSharingKey(encrypted);

expect(result).toBeDefined();
expect(result?.bucketKey).toBeDefined();
expect(result?.mnemonic).toBeUndefined();
});

test('routes to mnemonic decryption when ciphertext is not a bucket key', async () => {
const keys = await generateNewKeys();
const encrypted = await encryptMnemonic(mnemonic, keys.publicKeyArmored);

const mockUser = await getMockUser(keys, encrypted);
(encryptedStorageService.getUser as Mock).mockResolvedValue(mockUser);

const result = await decryptSharingKey(encrypted);

expect(result).toBeDefined();
expect(result?.mnemonic).toEqual(mnemonic);
expect(result?.bucketKey).toBeUndefined();
});

test('returns undefined when bucket key decryption fails', async () => {
const keys = await generateNewKeys();
const mockUser = await getMockUser(keys, 'unused');
(encryptedStorageService.getUser as Mock).mockResolvedValue(mockUser);
vi.spyOn(notificationsService, 'show');

const malformed = 'SHlicmlkQnVja2V0S2V5$onlyonepart';

const result = await decryptSharingKey(malformed);

expect(result).toBeUndefined();
});
});
});
103 changes: 103 additions & 0 deletions src/app/share/services/share.crypto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import {
hybridDecryptMessageWithPrivateKey,
hybridEncryptMessageWithPublicKey,
encryptBucketKeyHybrid,
decryptBucketKeyHybrid,
isBucketKeyCiphertext,
} from '../../crypto/services/pgp.service';
import encryptedStorageService from 'services/encrypted-storage.service';
import notificationsService, { ToastType } from '../../notifications/services/notifications.service';
import { t } from 'i18next';
import errorService from 'services/error.service';
import { generateFileBucketKey } from 'app/network/crypto';
import { FileKey } from 'app/network/types/helper-types';

export const decryptMnemonic = async (encryptionKey: string): Promise<string | undefined> => {
const user = await encryptedStorageService.getUser();
if (user) {
let decryptedKey;
try {
const privateKeyInBase64 = user.keys.ecc.privateKey;
const privateKyberKeyInBase64 = user.keys.kyber.privateKey;
decryptedKey = await hybridDecryptMessageWithPrivateKey({
encryptedMessageInBase64: encryptionKey,
privateKeyInBase64,
privateKyberKeyInBase64,
});
} catch (err) {
console.error('Mnemonic decryptin failed, falling back to user mnemonic', err);
decryptedKey = user.mnemonic;
}
return decryptedKey;
} else {
handleError('User Not Found', 'error.decryptMnemonic');
}
};

export const encryptMnemonic = async (
mnemonic: string,
publicKeyInBase64: string,
publicKyberKeyBase64?: string,
): Promise<string> => {
return hybridEncryptMessageWithPublicKey({
message: mnemonic,
publicKeyInBase64,
publicKyberKeyBase64,
});
};

export const encryptBucketKey = async (
mnemonic: string,
bucketId: string,
publicKeyInBase64: string,
publicKyberKeyBase64: string,
): Promise<string> => {
const bucketKey = await generateFileBucketKey(mnemonic, bucketId);
return encryptBucketKeyHybrid({
bucketKey,
publicKeyInBase64,
publicKyberKeyBase64,
});
};

const handleError = (err: unknown, keyLabel: string) => {
const error = errorService.castError(err);
errorService.reportError(error);

notificationsService.show({
text: t(keyLabel, { message: error.message }),
type: ToastType.Error,
});
};

export const decryptBucketKey = async (encryptionKey: string): Promise<Uint8Array | undefined> => {
const user = await encryptedStorageService.getUser();
if (user) {
let decryptedKey;
try {
const privateKeyInBase64 = user.keys.ecc.privateKey;
const privateKyberKeyInBase64 = user.keys.kyber.privateKey;
decryptedKey = await decryptBucketKeyHybrid({
encryptedMessageInBase64: encryptionKey,
privateKeyInBase64,
privateKyberKeyInBase64,
});
} catch (err) {
handleError(err, 'error.decryptBucketKey');
decryptedKey = undefined;
}
return decryptedKey;
} else {
handleError('User Not Found', 'error.decryptBucketKey');
}
};

export const decryptSharingKey = async (encryptionKey: string): Promise<FileKey | undefined> => {
if (isBucketKeyCiphertext(encryptionKey)) {
const bucketKey = await decryptBucketKey(encryptionKey);
return bucketKey ? { bucketKey: Buffer.from(bucketKey) } : undefined;
}

const mnemonic = await decryptMnemonic(encryptionKey);
return mnemonic ? { mnemonic } : undefined;
};
2 changes: 1 addition & 1 deletion src/app/share/services/share.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@ import { downloadFile } from 'app/network/download';
import { FlatFolderZip } from 'services/zip.service';
import { AdvancedSharedItem } from '../types';
import shareService, {
decryptMnemonic,
derivePublicSharingKey,
downloadPublicSharedFolder,
downloadPublicSharedItems,
getPublicShareLink,
} from './share.service';
import { decryptMnemonic } from './share.crypto';
import { stringUtils, aes } from '@internxt/lib';
import notificationsService from 'app/notifications/services/notifications.service';
import { SharedFiles, SharingMeta } from '@internxt/sdk/dist/drive/share/types';
Expand Down
Loading
Loading