diff --git a/src/app/crypto/services/pgp.service.ts b/src/app/crypto/services/pgp.service.ts index 27b90d12d2..311a729aa9 100644 --- a/src/app/crypto/services/pgp.service.ts +++ b/src/app/crypto/services/pgp.service.ts @@ -5,30 +5,81 @@ import kemBuilder from '@dashlane/pqc-kem-kyber512-browser'; import { extendSecret } from './utils'; const WORDS_HYBRID_MODE_IN_BASE64 = 'SHlicmlkTW9kZQ=='; // 'HybridMode' in BASE64 format +const WORDS_HYBRID_BUCKET_KEY_IN_BASE64 = 'SHlicmlkQnVja2V0S2V5'; // 'HybridBucketKey' in BASE64 format type Data = Uint8Array | string; -export async function getOpenpgp(): Promise { +export const getOpenpgp = async (): Promise => { return import('openpgp'); -} +}; -export function comparePrivateKeyCiphertextIDs(privateKey: PrivateKey, encryptedMessage: Message): boolean { - const messageKeyID = encryptedMessage.getEncryptionKeyIDs()[0].toHex(); - const privateKeyID = privateKey.getSubkeys()[0].getKeyID().toHex(); - return messageKeyID === privateKeyID; -} +export const comparePrivateKeyCiphertextIDs = (privateKey: PrivateKey, encryptedMessage: Message): boolean => { + const [messageKeyId] = encryptedMessage.getEncryptionKeyIDs(); + const [privateSubkey] = privateKey.getSubkeys(); + + if (!messageKeyId || !privateSubkey) { + throw new Error('Cannot compare key IDs: message or private key has no key IDs'); + } + + return messageKeyId.toHex() === privateSubkey.getKeyID().toHex(); +}; + +export const compareKeyPairIDs = (privateKey: PrivateKey, publicKey: PublicKey): boolean => { + const [publicSubkey] = publicKey.getSubkeys(); + const [privateSubkey] = privateKey.getSubkeys(); + + if (!publicSubkey || !privateSubkey) { + throw new Error('Cannot compare key IDs: public or private key has no subkeys'); + } + + return publicSubkey.getKeyID().toHex() === privateSubkey.getKeyID().toHex(); +}; -export function compareKeyPairIDs(privateKey: PrivateKey, publicKey: PublicKey): boolean { - const publiKeyID = publicKey.getSubkeys()[0].getKeyID().toHex(); - const privateKeyID = privateKey.getSubkeys()[0].getKeyID().toHex(); - return publiKeyID === privateKeyID; +export const kyberEncapsulate = async ( + publicKyberKeyBase64: string, +): Promise<{ ciphertextBase64: string; secret: Uint8Array }> => { + const kem = await kemBuilder(); + const publicKyberKey = Buffer.from(publicKyberKeyBase64, 'base64'); + const { ciphertext, sharedSecret } = await kem.encapsulate(new Uint8Array(publicKyberKey)); + return { ciphertextBase64: Buffer.from(ciphertext).toString('base64'), secret: sharedSecret }; +}; + +export const kyberDecapsulate = async ( + kyberCiphertextBase64: string, + privateKyberKeyBase64: string | undefined, +): Promise => { + if (!privateKyberKeyBase64) throw new Error('Attempted to decrypt hybrid ciphertex without Kyber key'); + + const kem = await kemBuilder(); + const privateKyberKey = Buffer.from(privateKyberKeyBase64, 'base64'); + const kyberCiphertext = Buffer.from(kyberCiphertextBase64, 'base64'); + const { sharedSecret } = await kem.decapsulate(new Uint8Array(kyberCiphertext), new Uint8Array(privateKyberKey)); + return sharedSecret; +}; + +interface HybridSplitResult { + kyberCiphertextBase64?: string; + eccCiphertextStr: string; } -export async function generateNewKeys(): Promise<{ +const splitHybridCiphertext = (input: string, hybridPrefix: string): HybridSplitResult => { + const parts = input.split('$'); + const isHybridMode = parts[0] === hybridPrefix; + + if (!isHybridMode) { + return { eccCiphertextStr: input }; + } + if (parts.length !== 3) { + throw new Error('Malformed hybrid ciphertext'); + } + return { kyberCiphertextBase64: parts[1], eccCiphertextStr: parts[2] }; +}; + +export const generateNewKeys = async (): Promise<{ privateKeyArmored: string; publicKeyArmored: string; publicKyberKeyBase64: string; privateKyberKeyBase64: string; -}> { +}> => { const openpgp = await getOpenpgp(); const { privateKey, publicKey } = await openpgp.generateKey({ @@ -45,24 +96,19 @@ export async function generateNewKeys(): Promise<{ publicKyberKeyBase64: Buffer.from(publicKyberKey).toString('base64'), privateKyberKeyBase64: Buffer.from(privateKyberKey).toString('base64'), }; -} +}; /** * XORs two strings of the identical length * @param {string} a - The first string * @param {string} b - The second string - * @returns {string} The result of XOR of strings a and b. + * @returns {Uint8Array} The result of XOR of strings a and b. */ -export function XORhex(a: string, b: string): string { - let res = '', - i = a.length, - j = b.length; - if (i != j) { - throw new Error('Can XOR only strings with identical length'); - } - while (i-- > 0 && j-- > 0) res = (parseInt(a.charAt(i), 16) ^ parseInt(b.charAt(j), 16)).toString(16) + res; - return res; -} +export const XORhex = (a: string, b: string): Uint8Array => { + const aBytes = Buffer.from(a, 'hex'); + const bBytes = Buffer.from(b, 'hex'); + return xorUint8Arrays(new Uint8Array(aBytes), new Uint8Array(bBytes)); +}; /** * Encrypts message using hybrid method (ecc and kyber) if kyber key is given, else uses ecc only @@ -83,18 +129,15 @@ export const hybridEncryptMessageWithPublicKey = async ({ let result = ''; let plaintext = message; if (publicKyberKeyBase64) { - const kem = await kemBuilder(); - - const publicKyberKey = Buffer.from(publicKyberKeyBase64, 'base64'); - const { ciphertext, sharedSecret: secret } = await kem.encapsulate(new Uint8Array(publicKyberKey)); - const kyberCiphertextStr = Buffer.from(ciphertext).toString('base64'); + const { ciphertextBase64, secret } = await kyberEncapsulate(publicKyberKeyBase64); const bits = message.length * 8; const secretHex = await extendSecret(secret, bits); const messageHex = Buffer.from(message).toString('hex'); - plaintext = XORhex(messageHex, secretHex); - result = WORDS_HYBRID_MODE_IN_BASE64.concat('$', kyberCiphertextStr, '$'); + const xored = XORhex(messageHex, secretHex); + plaintext = Buffer.from(xored).toString('hex'); + result = WORDS_HYBRID_MODE_IN_BASE64.concat('$', ciphertextBase64, '$'); } const encryptedMessage = await encryptMessageWithPublicKey({ message: plaintext, publicKeyInBase64 }); @@ -121,36 +164,23 @@ export const hybridDecryptMessageWithPrivateKey = async ({ privateKeyInBase64: string; privateKyberKeyInBase64?: string; }): Promise => { - let eccCiphertextStr = encryptedMessageInBase64; - let kyberSecret; - const ciphertexts = encryptedMessageInBase64.split('$'); - const prefix = ciphertexts[0]; - const isHybridMode = prefix === WORDS_HYBRID_MODE_IN_BASE64; - - if (isHybridMode) { - if (!privateKyberKeyInBase64) { - return Promise.reject(new Error('Attempted to decrypt hybrid ciphertex without Kyber key')); - } - const kem = await kemBuilder(); - - const kyberCiphertextBase64 = ciphertexts[1]; - eccCiphertextStr = ciphertexts[2]; - - const privateKyberKey = Buffer.from(privateKyberKeyInBase64, 'base64'); - const kyberCiphertext = Buffer.from(kyberCiphertextBase64, 'base64'); - const decapsulate = await kem.decapsulate(new Uint8Array(kyberCiphertext), new Uint8Array(privateKyberKey)); - kyberSecret = decapsulate.sharedSecret; - } + const { kyberCiphertextBase64, eccCiphertextStr } = splitHybridCiphertext( + encryptedMessageInBase64, + WORDS_HYBRID_MODE_IN_BASE64, + ); + const decryptedMessage = await decryptMessageWithPrivateKey({ encryptedMessage: atob(eccCiphertextStr), privateKeyInBase64, }); let result = decryptedMessage as string; - if (isHybridMode) { + + if (kyberCiphertextBase64) { + const sharedSecret = await kyberDecapsulate(kyberCiphertextBase64, privateKyberKeyInBase64); const bits = result.length * 4; - const secretHex = await extendSecret(kyberSecret, bits); + const secretHex = await extendSecret(sharedSecret, bits); const xored = XORhex(result, secretHex); - result = Buffer.from(xored, 'hex').toString('utf8'); + result = Buffer.from(xored).toString('utf8'); } return result; @@ -160,7 +190,7 @@ export const encryptMessageWithPublicKey = async ({ message, publicKeyInBase64, }: { - message: string; + message: Data; publicKeyInBase64: string; }): Promise> => { const openpgp = await getOpenpgp(); @@ -168,8 +198,13 @@ export const encryptMessageWithPublicKey = async ({ const publicKeyArmored = Buffer.from(publicKeyInBase64, 'base64').toString(); const publicKey = await openpgp.readKey({ armoredKey: publicKeyArmored }); + const messageToEncrypt = + typeof message === 'string' + ? await openpgp.createMessage({ text: message }) + : await openpgp.createMessage({ binary: message }); + const encryptedMessage = await openpgp.encrypt({ - message: await openpgp.createMessage({ text: message }), + message: messageToEncrypt, encryptionKeys: publicKey, }); @@ -179,9 +214,11 @@ export const encryptMessageWithPublicKey = async ({ export const decryptMessageWithPrivateKey = async ({ encryptedMessage, privateKeyInBase64, + format = 'utf8', }: { encryptedMessage: WebStream; privateKeyInBase64: string; + format?: 'utf8' | 'binary'; }): Promise & WebStream> => { const openpgp = await getOpenpgp(); @@ -193,12 +230,102 @@ export const decryptMessageWithPrivateKey = async ({ }); if (!comparePrivateKeyCiphertextIDs(privateKey, message)) { - return Promise.reject(new Error('The key does not correspond to the ciphertext')); + throw new Error('The key does not correspond to the ciphertext'); } const { data: decryptedMessage } = await openpgp.decrypt({ message, decryptionKeys: privateKey, + format, }); return decryptedMessage; }; + +const xorUint8Arrays = (a: Uint8Array, b: Uint8Array): Uint8Array => { + if (a.length !== b.length) { + throw new Error('Can XOR only identical lengths'); + } + const result = new Uint8Array(a.length); + for (let i = 0; i < a.length; i++) { + result[i] = a[i] ^ b[i]; + } + return result; +}; + +/** + * Encrypts bucket key using hybrid method (ecc and kyber) if kyber key is given, else uses ecc only + * @param {Uint8Array} bucketKey - The bucket key to encrypt + * @param {string} publicKeyInBase64 - The ecc public key in Base64 + * @param {string}[publicKyberKeyBase64] - The kyber public key in Base64 + * @returns {Promise} The encrypted message. + */ +export const encryptBucketKeyHybrid = async ({ + bucketKey, + publicKeyInBase64, + publicKyberKeyBase64, +}: { + bucketKey: Uint8Array; + publicKeyInBase64: string; + publicKyberKeyBase64?: string; +}): Promise => { + let result = ''; + if (bucketKey.length < 32) { + throw new Error('bucketKey must be at least 32 bytes'); + } + let plaintext: Uint8Array = bucketKey.subarray(0, 32); + if (publicKyberKeyBase64) { + const { ciphertextBase64, secret } = await kyberEncapsulate(publicKyberKeyBase64); + + plaintext = xorUint8Arrays(plaintext, secret); + result = WORDS_HYBRID_BUCKET_KEY_IN_BASE64.concat('$', ciphertextBase64, '$'); + } + + const encryptedMessage = await encryptMessageWithPublicKey({ message: plaintext, publicKeyInBase64 }); + const eccCiphertextStr = btoa(encryptedMessage as string); + + result = result.concat(eccCiphertextStr); + + return result; +}; + +/** + * Decrypts ciphertext using hybrid method (ecc and kyber) if kyber key is given, else uses ecc only + * @param {string} encryptedMessageInBase64 - The encrypted message + * @param {string} privateKeyInBase64 - The ecc private key in Base64 + * @param {string}[privateKyberKeyInBase64] - The kyber private key in Base64 + * @returns {Promise} The decrypted bucket key. + */ +export const decryptBucketKeyHybrid = async ({ + encryptedMessageInBase64, + privateKeyInBase64, + privateKyberKeyInBase64, +}: { + encryptedMessageInBase64: string; + privateKeyInBase64: string; + privateKyberKeyInBase64?: string; +}): Promise => { + const { kyberCiphertextBase64, eccCiphertextStr } = splitHybridCiphertext( + encryptedMessageInBase64, + WORDS_HYBRID_BUCKET_KEY_IN_BASE64, + ); + + const decryptedMessage = await decryptMessageWithPrivateKey({ + encryptedMessage: atob(eccCiphertextStr), + privateKeyInBase64, + format: 'binary', + }); + + let result = decryptedMessage as Uint8Array; + + if (kyberCiphertextBase64) { + const sharedSecret = await kyberDecapsulate(kyberCiphertextBase64, privateKyberKeyInBase64); + const xored = xorUint8Arrays(result, sharedSecret); + result = xored; + } + + return result; +}; + +export const isBucketKeyCiphertext = (encryptedMessageInBase64: string): boolean => { + return encryptedMessageInBase64.split('$')[0] === WORDS_HYBRID_BUCKET_KEY_IN_BASE64; +}; diff --git a/test/unit/services/pgp.service.test.ts b/test/unit/services/pgp.service.test.ts index 85dfbb8715..e4ec6fb7e3 100644 --- a/test/unit/services/pgp.service.test.ts +++ b/test/unit/services/pgp.service.test.ts @@ -12,12 +12,18 @@ import { hybridDecryptMessageWithPrivateKey, comparePrivateKeyCiphertextIDs, compareKeyPairIDs, + encryptBucketKeyHybrid, + decryptBucketKeyHybrid, } from '../../../src/app/crypto/services/pgp.service'; export async function getOpenpgp(): Promise { return import('openpgp'); } +const toHex = (buffer: Uint8Array): string => { + return Buffer.from(buffer).toString('hex'); +}; + describe('Encryption and Decryption', () => { it('should generate new keys', async () => { const keys = await generateNewKeys(); @@ -44,43 +50,45 @@ describe('Encryption and Decryption', () => { expect(encryptedMessage).toBeDefined(); }); - it('XOR should throw an error when strings are of different length', async () => { + it('XOR should throw an error when strings are of different length', () => { const messageHex = '74686973206973207468652074657374206d657373616765'; const secretHex = '74686973206973207468652074657374206d65737361676574686973206973207468652074657374206d657373616765'; expect(() => { XORhex(messageHex, secretHex); - }).toThrowError('Can XOR only strings with identical length'); + }).toThrow('Can XOR only identical lengths'); }); - it('XOR should work for the given fixed example', async () => { + it('XOR should work for the given fixed example', () => { const firstHex = '74686973206973207468652074657374206d657373616765'; const secondHex = '7468697320697320746865207365636f6e64206d65737361'; const resultHex = '0000000000000000000000000700101b4e09451e16121404'; - const xoredMessage = await XORhex(firstHex, secondHex); + const xoredMessage = XORhex(firstHex, secondHex); - expect(xoredMessage).toEqual(resultHex); + expect(toHex(xoredMessage)).toEqual(resultHex); }); - it('XOR of two identical strings should result in zero string', async () => { + it('XOR of two identical strings should result in zero string', () => { const strHex = '74686973206973207468652074657374206d657373616765'; const resultHex = '000000000000000000000000000000000000000000000000'; - const xoredMessage = await XORhex(strHex, strHex); + const xoredMessage = XORhex(strHex, strHex); - expect(xoredMessage).toEqual(resultHex); + expect(toHex(xoredMessage)).toEqual(resultHex); }); - it('XOR of str1, str2 and str1 should result in str2', async () => { + it('XOR of str1, str2 and str1 should result in str2', () => { const str1 = '74686973206973207468652074657374206d657373616765'; const str2 = '7468697320697320746865207365636f6e64206d65737361'; - const str3 = await XORhex(str1, str2); - const should_be_str2 = await XORhex(str3, str1); + const xored = XORhex(str1, str2); + + const str3 = toHex(xored); + const should_be_str2 = XORhex(str3, str1); - expect(should_be_str2).toEqual(str2); + expect(toHex(should_be_str2)).toEqual(str2); }); it('should generate keys, encrypt and decrypt a message using hybrid encryption', async () => { @@ -124,7 +132,7 @@ describe('Encryption and Decryption', () => { encryptedMessageInBase64, privateKeyInBase64: Buffer.from(keys.privateKeyArmored).toString('base64'), }), - ).rejects.toThrowError('Attempted to decrypt hybrid ciphertex without Kyber key'); + ).rejects.toThrow('Attempted to decrypt hybrid ciphertex without Kyber key'); }); it('hybrid decryption should decrypt old ciphertexts', async () => { @@ -308,3 +316,24 @@ describe('Encryption and Decryption', () => { expect(decryptedMnemonic).toEqual(testMnemonic); }); }); + +describe('Hybrid encryption and decryption of a bucket key', () => { + it('should encrypt and decrypt a bucket key successfully', async () => { + const bucketKey = crypto.getRandomValues(new Uint8Array(32)); // 256-bit key + const keys = await generateNewKeys(); + + const encryptedMessage = await encryptBucketKeyHybrid({ + bucketKey, + publicKeyInBase64: keys.publicKeyArmored, + publicKyberKeyBase64: keys.publicKyberKeyBase64, + }); + + const decryptedMessage = await decryptBucketKeyHybrid({ + encryptedMessageInBase64: encryptedMessage, + privateKeyInBase64: Buffer.from(keys.privateKeyArmored).toString('base64'), + privateKyberKeyInBase64: keys.privateKyberKeyBase64, + }); + + expect(decryptedMessage).toEqual(bucketKey); + }); +});