diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 79a8954..1a17117 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: '24.x' + node-version: '22.x' cache: yarn - name: Add .npmrc diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 0284f6e..83f5d94 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -19,7 +19,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: '24.x' + node-version: '22.x' cache: yarn - name: Install dependencies diff --git a/.github/workflows/sonar-analysis.yml b/.github/workflows/sonar-analysis.yml index 2334c95..6e0dc40 100644 --- a/.github/workflows/sonar-analysis.yml +++ b/.github/workflows/sonar-analysis.yml @@ -16,11 +16,13 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node_version: [24.x] + node_version: [22.x] steps: - name: Checkout uses: actions/checkout@v4 + with: + fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis - name: Setup Node uses: actions/setup-node@v4 @@ -38,5 +40,4 @@ jobs: - name: Analyze with SonarCloud uses: SonarSource/sonarqube-scan-action@v5.0.0 env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} # Generate a token on Sonarcloud.io, add it to the secrets of this repo with the name SONAR_TOKEN (Settings > Secrets > Actions > add new repository secret) diff --git a/.husky/pre-commit b/.husky/pre-commit index 85cf02d..4861384 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,2 +1,2 @@ -yarn run lint:fix; npx lint-staged; yarn run test \ No newline at end of file +yarn run lint:fix; npx lint-staged --allow-empty; yarn run test \ No newline at end of file diff --git a/README.md b/README.md index 2f401ab..3a43ab0 100644 --- a/README.md +++ b/README.md @@ -107,9 +107,9 @@ const password = 'your password'; const { key, salt } = await getKeyFromPassword(password); // Hybrid email encryption -const email: EmailBody = { +const email: Email = { text: 'email text', - subject: 'email subject', + attachments: ['email attachements'], }; const { secretKey: bobPrivateKeys, publicKey: bobPublicKeys } = await generateEmailKeys(); const bobWithPublicKeys = { @@ -119,8 +119,19 @@ const bobWithPublicKeys = { const encryptedEmail = await encryptEmailHybrid(email, bobWithPublicKeys); const decryptedEmail = await decryptEmailHybrid(encryptedEmail, bobPrivateKeys); -expect(encryptedEmail.encEmailBody.encSubject).not.toBe(email.subject); -expect(decryptedEmailBody).toStrictEqual(email); +expect(decryptedEmail).toStrictEqual(email); + +// Hybrid email and subject encryption +const emailAndSubject: EmailAndSubject = { + text: 'email text', + subject: 'email subject' + attachments: ['email attachements'], +}; +const encryptedEmailAndSubject = await encryptEmailAndSubjectHybrid(emailAndSubject, bobWithPublicKeys); +const decryptedEmailAndSubject = await decryptEmailAndSubjectHybrid(encryptedEmailAndSubject, bobPrivateKeys); + +expect(encryptedEmailAndSubject.encEmail.encSubject).not.toBe(emailAndSubject.subject); +expect(decryptedEmailAndSubject).toStrictEqual(emailAndSubject); // password-protected email @@ -141,73 +152,4 @@ const resultRec = await openRecoveryKeystore(recoveryCodes, recoveryKeystore); expect(resultEnc).toStrictEqual(resultRec); -// Email storage and search - -// Between sessions emails are stored encrypted in IndexedDB. The encryption key is derived from user's mnemonic -// During the session, all emails are decrypted and stored in the cache (up to 600 MB, if excides - we delete oldests emails) -// For search, we build a search index from cache, then use Flexsearch for the search. -// The search is doen separately for email content, subject, sender and recivers. - -// Open IndexedDB database -const userID = 'user ID'; -const db = await openDatabase(userID); -const mnemonic = genMnemonic(); - -// Derive key for encrypting emails before storing them in a local database -const key = await deriveDatabaseKey(mnemonic); - -// Derive key for encrypting email draft -const key = await deriveDatabaseKey(mnemonic); - -// Encrypt and store one or several emails -await encryptAndStoreEmail(email, key, db); -await encryptAndStoreManyEmail(emails, key, db); - -// Delete given email by its ID -await deleteEmail(emailID, db); - -// Delete oldests emails -const number = 5; -await deleteOldestEmails(number, db); - -// Get all emails with or without sorting -const allEmails = await getAndDecryptAllEmails(key, db); -const newestFirst = await getAllEmailsSortedNewestFirst(db, key); -const oldestFirst = await getAllEmailsSortedOldestFirst(db, key); - -// Get the number of stored emails -const count = await getEmailCount(db); - -// Close IndexedDB database -closeDatabase(db); - -// Delete IndexedDB database -await deleteDatabase(userID); - -// Create email cache -const esCache = await createCacheFromDB(key, db); - -// Add one or multiple emails to cache -const result = addEmailToCache(email, esCache); -expect(result.success).toBe(true); - -const result = addEmailsToCache(emails, esCache); -expect(result.success).toBe(true); - -// Get email from cache by its ID -const email = await getEmailFromCache(emailID, esCache); - -// Delete email from cache by its ID -await deleteEmailFromCache(emailID, esCache); - -// Create search index and search by query - const searchIndex = await buildSearchIndexFromCache(esCache); - const query = 'keywords to search'; - const options = { - fields: ['subject'], // in which fields to search, all by deafult (subject, body, from, to) - limit: 5, // result limit, 50 by default - boost: { subject: 3, body: 1, from: 2, to: 2 }, // custom waights for matches in different email parts - }; - const result: EmailSearchResult = await searchEmails(query, esCache, searchIndex); - ``` \ No newline at end of file diff --git a/package.json b/package.json index ab8e7cb..ed8683a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "internxt-crypto", - "version": "1.3.1", + "version": "1.4.0", "main": "dist/index.js", "types": "dist/index.d.ts", "module": "dist/index.js", diff --git a/src/email-crypto/core.ts b/src/email-crypto/core.ts index 357a7f0..9f4c138 100644 --- a/src/email-crypto/core.ts +++ b/src/email-crypto/core.ts @@ -1,106 +1,108 @@ -import { HybridEncKey, PwdProtectedKey, EmailBody, EmailBodyEncrypted, RecipientWithPublicKey } from '../types'; +import { HybridEncKey, PwdProtectedKey, Email, RecipientWithPublicKey, EmailEncrypted } from '../types'; import { encryptSymmetrically, decryptSymmetrically, genSymmetricKey } from '../symmetric-crypto'; import { encapsulateHybrid, decapsulateHybrid } from '../hybrid-crypto'; import { wrapKey, unwrapKey } from '../key-wrapper'; import { getKeyFromPassword, getKeyFromPasswordAndSalt } from '../derive-password'; import { UTF8ToUint8, base64ToUint8Array, uint8ArrayToBase64, uint8ToUTF8 } from '../utils'; +import { + EmailHybridDecryptionError, + EmailHybridEncryptionError, + InvalidInputEmail, + EmailSymmetricDecryptionError, + EmailSymmetricEncryptionError, + EmailPasswordOpenError, + EmailPasswordProtectError, +} from './errors'; /** - * Symmetrically encrypts email body. + * Symmetrically encrypts email. * - * @param body - The email body to encrypt. + * @param email - The email to encrypt. * @param aux - An optional auxilary sting for AEAD (e.g., email ID or timestamp). - * @returns The resulting encrypted email body and symmetric key used for encryption + * @returns The resulting encrypted email and symmetric key used for encryption */ -export async function encryptEmailBody( - body: EmailBody, +export async function encryptEmail( + email: Email, aux?: Uint8Array, ): Promise<{ - encEmailBody: EmailBodyEncrypted; + encEmail: EmailEncrypted; encryptionKey: Uint8Array; }> { + if (!email.text) { + throw new InvalidInputEmail(); + } try { - if (!body.text || !body.subject) { - throw new Error('Invalid input'); - } const encryptionKey = genSymmetricKey(); - const encEmailBody = await encryptEmailBodyWithKey(body, encryptionKey, aux); + const encEmail = await encryptEmailWithKey(email, encryptionKey, aux); - return { encEmailBody, encryptionKey }; + return { encEmail, encryptionKey }; } catch (error) { - throw new Error('Failed to symmetrically encrypt email body', { cause: error }); + throw new EmailSymmetricEncryptionError(error instanceof Error ? error.message : String(error)); } } /** - * Symmetrically encrypts email body with the given key. + * Symmetrically encrypts email with the given key. * - * @param body - The email body to encrypt. + * @param email - The email to encrypt. * @param encryptionKey - The symmetric key to encrypt the email. * @param aux - An optional auxilary sting for AEAD (e.g., email ID or timestamp). - * @returns The resulting encrypted email body and symmetric key used for encryption + * @returns The resulting encrypted email and symmetric key used for encryption */ -export async function encryptEmailBodyWithKey( - body: EmailBody, +export async function encryptEmailWithKey( + email: Email, encryptionKey: Uint8Array, aux?: Uint8Array, -): Promise { +): Promise { try { - const text = UTF8ToUint8(body.text); - const subject = UTF8ToUint8(body.subject); - const subjectEnc = await encryptSymmetrically(encryptionKey, subject, aux); + const text = UTF8ToUint8(email.text); + const encryptedText = await encryptSymmetrically(encryptionKey, text, aux); const encText = uint8ArrayToBase64(encryptedText); - const encSubject = uint8ArrayToBase64(subjectEnc); - const enc: EmailBodyEncrypted = { encText, encSubject }; - - if (body.attachments) { - const promises = body.attachments.map((attachment) => { + const enc: EmailEncrypted = { encText }; + if (email.attachments) { + const promises = email.attachments.map((attachment) => { const binaryAttachment = UTF8ToUint8(attachment); return encryptSymmetrically(encryptionKey, binaryAttachment, aux); }); const encryptedAttachments = await Promise.all(promises); enc.encAttachments = encryptedAttachments?.map(uint8ArrayToBase64); } - return enc; } catch (error) { - throw new Error('Failed to encrypt email body', { cause: error }); + throw new EmailSymmetricEncryptionError(error instanceof Error ? error.message : String(error)); } } /** - * Decrypts symmetrically encrypted email body. + * Decrypts symmetrically encrypted email. * - * @param encEmailBody - The email body to decrypt. + * @param encEmail - The email to decrypt. * @param encryptionKey - The symmetric key to decrypt the email. * @param aux - An optional auxilary sting for AEAD (e.g., email ID or timestamp). - * @returns The resulting decrypted email body + * @returns The resulting decrypted email */ -export async function decryptEmailBody( - encEmailBody: EmailBodyEncrypted, +export async function decryptEmail( + encEmail: EmailEncrypted, encryptionKey: Uint8Array, aux?: Uint8Array, -): Promise { +): Promise { try { - const encSubject = base64ToUint8Array(encEmailBody.encSubject); - const subjectArray = await decryptSymmetrically(encryptionKey, encSubject, aux); - const subject = uint8ToUTF8(subjectArray); - const encText = base64ToUint8Array(encEmailBody.encText); + const encText = base64ToUint8Array(encEmail.encText); const textArray = await decryptSymmetrically(encryptionKey, encText, aux); const text = uint8ToUTF8(textArray); - const body: EmailBody = { text, subject }; + const email: Email = { text }; - if (encEmailBody.encAttachments) { - const encAttachments = encEmailBody.encAttachments?.map(base64ToUint8Array); + if (encEmail.encAttachments) { + const encAttachments = encEmail.encAttachments?.map(base64ToUint8Array); const promises = encAttachments?.map((encAtt) => decryptSymmetrically(encryptionKey, encAtt, aux)); const decryptedAttachments = await Promise.all(promises); - body.attachments = decryptedAttachments?.map((att) => uint8ToUTF8(att)); + email.attachments = decryptedAttachments?.map((att) => uint8ToUTF8(att)); } - return body; + return email; } catch (error) { - throw new Error('Failed to symmetrically decrypt email body', { cause: error }); + throw new EmailSymmetricDecryptionError(error instanceof Error ? error.message : String(error)); } } @@ -127,7 +129,7 @@ export async function encryptKeysHybrid( encryptedForEmail: recipient.email, }; } catch (error) { - throw new Error('Failed to encrypt email key using hybrid encryption', { cause: error }); + throw new EmailHybridEncryptionError(error instanceof Error ? error.message : String(error)); } } @@ -149,7 +151,7 @@ export async function decryptKeysHybrid( const encryptionKey = await unwrapKey(encKey, sharedSecret); return encryptionKey; } catch (error) { - throw new Error('Failed to decrypt email key encrypted via hybrid encryption', { cause: error }); + throw new EmailHybridDecryptionError(error instanceof Error ? error.message : String(error)); } } @@ -168,7 +170,7 @@ export async function passwordProtectKey(emailEncryptionKey: Uint8Array, passwor const encryptedKeyStr = uint8ArrayToBase64(encryptedKey); return { encryptedKey: encryptedKeyStr, salt: saltStr }; } catch (error) { - throw new Error('Failed to password-protect email key', { cause: error }); + throw new EmailPasswordProtectError(error instanceof Error ? error.message : String(error)); } } @@ -190,6 +192,6 @@ export async function removePasswordProtection( const encryptionKey = await unwrapKey(encryptedKey, key); return encryptionKey; } catch (error) { - throw new Error('Failed to remove password-protection from email key', { cause: error }); + throw new EmailPasswordOpenError(error instanceof Error ? error.message : String(error)); } } diff --git a/src/email-crypto/coreSubject.ts b/src/email-crypto/coreSubject.ts new file mode 100644 index 0000000..f0002eb --- /dev/null +++ b/src/email-crypto/coreSubject.ts @@ -0,0 +1,82 @@ +import { EmailAndSubject, EmailAndSubjectEncrypted } from '../types'; +import { encryptSymmetrically, decryptSymmetrically, genSymmetricKey } from '../symmetric-crypto'; +import { encryptEmailWithKey, decryptEmail } from './core'; +import { UTF8ToUint8, base64ToUint8Array, uint8ArrayToBase64, uint8ToUTF8 } from '../utils'; +import { InvalidInputEmail, EmailSymmetricDecryptionError, EmailSymmetricEncryptionError } from './errors'; + +/** + * Symmetrically encrypts email and subject. + * + * @param email - The email and subject to encrypt. + * @param aux - An optional auxilary sting for AEAD (e.g., email ID or timestamp). + * @returns The resulting encrypted email and symmetric key used for encryption + */ +export async function encryptEmailAndSubject( + email: EmailAndSubject, + aux?: Uint8Array, +): Promise<{ + encEmail: EmailAndSubjectEncrypted; + encryptionKey: Uint8Array; +}> { + if (!email.text || !email.subject) { + throw new InvalidInputEmail(); + } + try { + const encryptionKey = genSymmetricKey(); + const encEmail = await encryptEmailAndSubjectWithKey(email, encryptionKey, aux); + + return { encEmail, encryptionKey }; + } catch (error) { + throw new EmailSymmetricEncryptionError(error instanceof Error ? error.message : String(error)); + } +} + +/** + * Symmetrically encrypts email and subject with the given key. + * + * @param email - The email and subject to encrypt. + * @param encryptionKey - The symmetric key to encrypt the email. + * @param aux - An optional auxilary sting for AEAD (e.g., email ID or timestamp). + * @returns The resulting encrypted email and symmetric key used for encryption + */ +export async function encryptEmailAndSubjectWithKey( + email: EmailAndSubject, + encryptionKey: Uint8Array, + aux?: Uint8Array, +): Promise { + try { + const enc = await encryptEmailWithKey(email, encryptionKey, aux); + const subject = UTF8ToUint8(email.subject); + const subjectEnc = await encryptSymmetrically(encryptionKey, subject, aux); + const encSubject = uint8ArrayToBase64(subjectEnc); + + return { ...enc, encSubject }; + } catch (error) { + throw new EmailSymmetricEncryptionError(error instanceof Error ? error.message : String(error)); + } +} + +/** + * Decrypts symmetrically encrypted email and email subject. + * + * @param encEmail - The encrypted email and subject to decrypt. + * @param encryptionKey - The symmetric key to decrypt the email. + * @param aux - An optional auxilary sting for AEAD (e.g., email ID or timestamp). + * @returns The resulting decrypted email and subject + */ +export async function decryptEmailAndSubject( + encEmail: EmailAndSubjectEncrypted, + encryptionKey: Uint8Array, + aux?: Uint8Array, +): Promise { + try { + const encSubject = base64ToUint8Array(encEmail.encSubject); + const subjectArray = await decryptSymmetrically(encryptionKey, encSubject, aux); + const subject = uint8ToUTF8(subjectArray); + const email = await decryptEmail(encEmail, encryptionKey, aux); + + return { ...email, subject }; + } catch (error) { + throw new EmailSymmetricDecryptionError(error instanceof Error ? error.message : String(error)); + } +} diff --git a/src/email-crypto/errors.ts b/src/email-crypto/errors.ts new file mode 100644 index 0000000..ea52cf8 --- /dev/null +++ b/src/email-crypto/errors.ts @@ -0,0 +1,71 @@ +export class FailedToEncryptEmail extends Error { + constructor(errorMsg?: string) { + super('Failed to encrypt email: ' + errorMsg); + + Object.setPrototypeOf(this, FailedToEncryptEmail.prototype); + } +} + +export class EmailSymmetricEncryptionError extends Error { + constructor(errorMsg?: string) { + super('Failed to symmetrically encrypt email: ' + errorMsg); + + Object.setPrototypeOf(this, EmailSymmetricEncryptionError.prototype); + } +} + +export class EmailSymmetricDecryptionError extends Error { + constructor(errorMsg?: string) { + super('Failed to symmetrically decrypt email: ' + errorMsg); + + Object.setPrototypeOf(this, EmailSymmetricDecryptionError.prototype); + } +} + +export class EmailHybridEncryptionError extends Error { + constructor(errorMsg?: string) { + super('Failed to hybridly encrypt the key: ' + errorMsg); + + Object.setPrototypeOf(this, EmailHybridEncryptionError.prototype); + } +} + +export class EmailHybridDecryptionError extends Error { + constructor(errorMsg?: string) { + super('Failed to hybridly decrypt the key: ' + errorMsg); + + Object.setPrototypeOf(this, EmailHybridDecryptionError.prototype); + } +} + +export class EmailPasswordProtectError extends Error { + constructor(errorMsg?: string) { + super('Failed to password-protect the key: ' + errorMsg); + + Object.setPrototypeOf(this, EmailPasswordProtectError.prototype); + } +} + +export class EmailPasswordOpenError extends Error { + constructor(errorMsg?: string) { + super('Failed to open password-protected key: ' + errorMsg); + + Object.setPrototypeOf(this, EmailPasswordOpenError.prototype); + } +} + +export class InvalidInputEmail extends Error { + constructor() { + super('Invalid input'); + + Object.setPrototypeOf(this, InvalidInputEmail.prototype); + } +} + +export class FailedToDecryptEmail extends Error { + constructor(errorMsg?: string) { + super('Failed to decrypt email: ' + errorMsg); + + Object.setPrototypeOf(this, FailedToDecryptEmail.prototype); + } +} diff --git a/src/email-crypto/hybridEncryptedEmailAndSubject.ts b/src/email-crypto/hybridEncryptedEmailAndSubject.ts new file mode 100644 index 0000000..180a011 --- /dev/null +++ b/src/email-crypto/hybridEncryptedEmailAndSubject.ts @@ -0,0 +1,97 @@ +import { RecipientWithPublicKey, EmailAndSubject, HybridEncryptedEmailAndSubject } from '../types'; +import { encryptKeysHybrid, decryptKeysHybrid } from './core'; +import { encryptEmailAndSubject, decryptEmailAndSubject } from './coreSubject'; +import { + FailedToDecryptEmail, + FailedToEncryptEmail, + EmailHybridDecryptionError, + EmailHybridEncryptionError, + InvalidInputEmail, + EmailSymmetricDecryptionError, + EmailSymmetricEncryptionError, +} from './errors'; + +/** + * Encrypts the email and its subject using hybrid encryption. + * + * @param email - The email and subject to encrypt. + * @param recipientPublicKeys - The public keys of the recipient. + * @param aux - An optional auxilary sting for AEAD (e.g., email ID or timestamp). + * @returns The encrypted email and subject + */ +export async function encryptEmailAndSubjectHybrid( + email: EmailAndSubject, + recipient: RecipientWithPublicKey, + aux?: Uint8Array, +): Promise { + try { + const { encryptionKey, encEmail } = await encryptEmailAndSubject(email, aux); + const encryptedKey = await encryptKeysHybrid(encryptionKey, recipient); + return { encEmail, encryptedKey }; + } catch (error) { + if (error instanceof InvalidInputEmail) throw error; + if (error instanceof EmailSymmetricEncryptionError) throw error; + if (error instanceof EmailHybridEncryptionError) throw error; + throw new FailedToEncryptEmail(error instanceof Error ? error.message : String(error)); + } +} + +/** + * Encrypts the email and its subject using hybrid encryption for multiple recipients. + * + * @param email - The email and subject to encrypt for multiple recipients. + * @param recipients - The recipients with corresponding public keys. + * @param aux - An optional auxilary sting for AEAD (e.g., email ID or timestamp). + * @returns The set of encrypted emails and subjects + */ +export async function encryptEmailAndSubjectHybridForMultipleRecipients( + email: EmailAndSubject, + recipients: RecipientWithPublicKey[], + aux?: Uint8Array, +): Promise { + try { + if (!recipients || recipients.length === 0) { + throw new InvalidInputEmail(); + } + const { encryptionKey, encEmail } = await encryptEmailAndSubject(email, aux); + + const encryptedEmails: HybridEncryptedEmailAndSubject[] = []; + for (const recipient of recipients) { + const encryptedKey = await encryptKeysHybrid(encryptionKey, recipient); + encryptedEmails.push({ + encEmail, + encryptedKey, + }); + } + return encryptedEmails; + } catch (error) { + if (error instanceof InvalidInputEmail) throw error; + if (error instanceof EmailSymmetricEncryptionError) throw error; + if (error instanceof EmailHybridEncryptionError) throw error; + throw new FailedToEncryptEmail(error instanceof Error ? error.message : String(error)); + } +} + +/** + * Decrypts the email and its subject using hybrid encryption. + * + * @param hybridEmail - The encrypted email and subject. + * @param recipientPrivateHybridKeys - The private key of the recipient. + * @param aux - An optional auxilary sting for AEAD (e.g., email ID or timestamp). + * @returns The decrypted email and subject + */ +export async function decryptEmailAndSubjectHybrid( + hybridEmail: HybridEncryptedEmailAndSubject, + recipientPrivateHybridKeys: Uint8Array, + aux?: Uint8Array, +): Promise { + try { + const encryptionKey = await decryptKeysHybrid(hybridEmail.encryptedKey, recipientPrivateHybridKeys); + return await decryptEmailAndSubject(hybridEmail.encEmail, encryptionKey, aux); + } catch (error) { + if (error instanceof InvalidInputEmail) throw error; + if (error instanceof EmailHybridDecryptionError) throw error; + if (error instanceof EmailSymmetricDecryptionError) throw error; + throw new FailedToDecryptEmail(error instanceof Error ? error.message : String(error)); + } +} diff --git a/src/email-crypto/hybridEncyptedEmail.ts b/src/email-crypto/hybridEncyptedEmail.ts index f20aa39..75be566 100644 --- a/src/email-crypto/hybridEncyptedEmail.ts +++ b/src/email-crypto/hybridEncyptedEmail.ts @@ -1,76 +1,94 @@ -import { HybridEncryptedEmail, EmailBody, RecipientWithPublicKey } from '../types'; -import { decryptEmailBody, encryptKeysHybrid, decryptKeysHybrid, encryptEmailBody } from './core'; - +import { HybridEncryptedEmail, Email, RecipientWithPublicKey } from '../types'; +import { decryptEmail, encryptKeysHybrid, decryptKeysHybrid, encryptEmail } from './core'; +import { + FailedToDecryptEmail, + FailedToEncryptEmail, + EmailHybridDecryptionError, + EmailHybridEncryptionError, + InvalidInputEmail, + EmailSymmetricDecryptionError, + EmailSymmetricEncryptionError, +} from './errors'; /** - * Encrypts the email body using hybrid encryption. + * Encrypts the email using hybrid encryption. * - * @param body - The email body to encrypt. + * @param email - The email to encrypt. * @param recipientPublicKeys - The public keys of the recipient. * @param aux - An optional auxilary sting for AEAD (e.g., email ID or timestamp). - * @returns The encrypted email body + * @returns The encrypted email */ export async function encryptEmailHybrid( - body: EmailBody, + email: Email, recipient: RecipientWithPublicKey, aux?: Uint8Array, ): Promise { try { - const { encryptionKey, encEmailBody } = await encryptEmailBody(body, aux); + const { encryptionKey, encEmail } = await encryptEmail(email, aux); const encryptedKey = await encryptKeysHybrid(encryptionKey, recipient); - return { encEmailBody, encryptedKey }; + return { encEmail, encryptedKey }; } catch (error) { - throw new Error('Failed to encrypt email body with hybrid encryption', { cause: error }); + if (error instanceof InvalidInputEmail) throw error; + if (error instanceof EmailSymmetricEncryptionError) throw error; + if (error instanceof EmailHybridEncryptionError) throw error; + throw new FailedToEncryptEmail(error instanceof Error ? error.message : String(error)); } } /** - * Encrypts the email body using hybrid encryption for multiple recipients. + * Encrypts the email using hybrid encryption for multiple recipients. * - * @param body - The email body to encrypt for multiple recipients. + * @param email - The email to encrypt for multiple recipients. * @param recipients - The recipients with corresponding public keys. * @param aux - An optional auxilary sting for AEAD (e.g., email ID or timestamp). - * @returns The set of encrypted email bodies + * @returns The set of encrypted emails */ export async function encryptEmailHybridForMultipleRecipients( - body: EmailBody, + email: Email, recipients: RecipientWithPublicKey[], aux?: Uint8Array, ): Promise { try { - const { encryptionKey, encEmailBody } = await encryptEmailBody(body, aux); + if (!recipients || recipients.length === 0) { + throw new InvalidInputEmail(); + } + const { encryptionKey, encEmail } = await encryptEmail(email, aux); const encryptedEmails: HybridEncryptedEmail[] = []; for (const recipient of recipients) { const encryptedKey = await encryptKeysHybrid(encryptionKey, recipient); encryptedEmails.push({ - encEmailBody: encEmailBody, + encEmail, encryptedKey, }); } return encryptedEmails; } catch (error) { - throw new Error('Failed to encrypt email to multiple recipients with hybrid encryption', { cause: error }); + if (error instanceof InvalidInputEmail) throw error; + if (error instanceof EmailSymmetricEncryptionError) throw error; + if (error instanceof EmailHybridEncryptionError) throw error; + throw new FailedToEncryptEmail(error instanceof Error ? error.message : String(error)); } } /** * Decrypts the email using hybrid encryption. * - * @param encEmailBody - The encrypted email. + * @param encEmail - The encrypted email. * @param recipientPrivateHybridKeys - The private key of the recipient. * @param aux - An optional auxilary sting for AEAD (e.g., email ID or timestamp). - * @returns The decrypted email body + * @returns The decrypted email */ export async function decryptEmailHybrid( - encEmailBody: HybridEncryptedEmail, + encEmail: HybridEncryptedEmail, recipientPrivateHybridKeys: Uint8Array, aux?: Uint8Array, -): Promise { +): Promise { try { - const encryptionKey = await decryptKeysHybrid(encEmailBody.encryptedKey, recipientPrivateHybridKeys); - const body = await decryptEmailBody(encEmailBody.encEmailBody, encryptionKey, aux); - return body; + const encryptionKey = await decryptKeysHybrid(encEmail.encryptedKey, recipientPrivateHybridKeys); + return await decryptEmail(encEmail.encEmail, encryptionKey, aux); } catch (error) { - throw new Error('Failed to decrypt email with hybrid encryption', { cause: error }); + if (error instanceof EmailHybridDecryptionError) throw error; + if (error instanceof EmailSymmetricDecryptionError) throw error; + throw new FailedToDecryptEmail(error instanceof Error ? error.message : String(error)); } } diff --git a/src/email-crypto/index.ts b/src/email-crypto/index.ts index 31aa0c7..080fa78 100644 --- a/src/email-crypto/index.ts +++ b/src/email-crypto/index.ts @@ -1,4 +1,8 @@ export * from './hybridEncyptedEmail'; export * from './pwdProtectedEmail'; +export * from './hybridEncryptedEmailAndSubject'; +export * from './pwdProtectedEmailAndSubject'; export * from './emailKeys'; export * from './core'; +export * from './coreSubject'; +export * from './errors'; diff --git a/src/email-crypto/pwdProtectedEmail.ts b/src/email-crypto/pwdProtectedEmail.ts index eddabe5..ef2d3df 100644 --- a/src/email-crypto/pwdProtectedEmail.ts +++ b/src/email-crypto/pwdProtectedEmail.ts @@ -1,6 +1,14 @@ -import { PwdProtectedEmail, EmailBody } from '../types'; -import { decryptEmailBody, passwordProtectKey, removePasswordProtection, encryptEmailBody } from './core'; - +import { PwdProtectedEmail, Email } from '../types'; +import { decryptEmail, passwordProtectKey, removePasswordProtection, encryptEmail } from './core'; +import { + EmailSymmetricEncryptionError, + FailedToDecryptEmail, + FailedToEncryptEmail, + EmailPasswordProtectError, + EmailSymmetricDecryptionError, + EmailPasswordOpenError, + InvalidInputEmail, +} from './errors'; /** * Creates a password-protected email. * @@ -10,17 +18,20 @@ import { decryptEmailBody, passwordProtectKey, removePasswordProtection, encrypt * @returns The password-protected email */ export async function createPwdProtectedEmail( - emailBody: EmailBody, + email: Email, password: string, aux?: Uint8Array, ): Promise { try { - const { encryptionKey, encEmailBody } = await encryptEmailBody(emailBody, aux); + const { encryptionKey, encEmail } = await encryptEmail(email, aux); const encryptedKey = await passwordProtectKey(encryptionKey, password); - return { encEmailBody, encryptedKey }; + return { encEmail, encryptedKey }; } catch (error) { - throw new Error('Failed to password-protect email', { cause: error }); + if (error instanceof InvalidInputEmail) throw error; + if (error instanceof EmailSymmetricEncryptionError) throw error; + if (error instanceof EmailPasswordProtectError) throw error; + throw new FailedToEncryptEmail(error instanceof Error ? error.message : String(error)); } } @@ -30,18 +41,20 @@ export async function createPwdProtectedEmail( * @param encryptedEmail - The encrypted email * @param password - The secret password shared among recipients. * @param aux - An optional auxilary sting for AEAD (e.g., email ID or timestamp). - * @returns The decrypted email body + * @returns The decrypted email */ export async function decryptPwdProtectedEmail( encryptedEmail: PwdProtectedEmail, password: string, aux?: Uint8Array, -): Promise { +): Promise { try { const encryptionKey = await removePasswordProtection(encryptedEmail.encryptedKey, password); - const body = await decryptEmailBody(encryptedEmail.encEmailBody, encryptionKey, aux); - return body; + return await decryptEmail(encryptedEmail.encEmail, encryptionKey, aux); } catch (error) { - throw new Error('Failed to decrypt password-protect email', { cause: error }); + if (error instanceof InvalidInputEmail) throw error; + if (error instanceof EmailPasswordOpenError) throw error; + if (error instanceof EmailSymmetricDecryptionError) throw error; + throw new FailedToDecryptEmail(error instanceof Error ? error.message : String(error)); } } diff --git a/src/email-crypto/pwdProtectedEmailAndSubject.ts b/src/email-crypto/pwdProtectedEmailAndSubject.ts new file mode 100644 index 0000000..2bdf596 --- /dev/null +++ b/src/email-crypto/pwdProtectedEmailAndSubject.ts @@ -0,0 +1,62 @@ +import { EmailAndSubject, PwdProtectedEmailAndSubject } from '../types'; +import { passwordProtectKey, removePasswordProtection } from './core'; +import { encryptEmailAndSubject, decryptEmailAndSubject } from './coreSubject'; +import { + FailedToDecryptEmail, + FailedToEncryptEmail, + InvalidInputEmail, + EmailPasswordOpenError, + EmailPasswordProtectError, + EmailSymmetricDecryptionError, + EmailSymmetricEncryptionError, +} from './errors'; + +/** + * Creates a password-protected email and subject. + * + * @param email - The email and subject to password-protect + * @param password - The secret password shared among recipients + * @param aux - An optional auxilary sting for AEAD (e.g., email ID or timestamp). + * @returns The password-protected email + */ +export async function createPwdProtectedEmailAndSubject( + email: EmailAndSubject, + password: string, + aux?: Uint8Array, +): Promise { + try { + const { encryptionKey, encEmail } = await encryptEmailAndSubject(email, aux); + const encryptedKey = await passwordProtectKey(encryptionKey, password); + + return { encEmail, encryptedKey }; + } catch (error) { + if (error instanceof InvalidInputEmail) throw error; + if (error instanceof EmailSymmetricEncryptionError) throw error; + if (error instanceof EmailPasswordProtectError) throw error; + throw new FailedToEncryptEmail(error instanceof Error ? error.message : String(error)); + } +} + +/** + * Opens a password-protected email and subject. + * + * @param encryptedEmail - The encrypted email and subject + * @param password - The secret password shared among recipients. + * @param aux - An optional auxilary sting for AEAD (e.g., email ID or timestamp). + * @returns The decrypted email and subject + */ +export async function decryptPwdProtectedEmailAndSubject( + encryptedEmail: PwdProtectedEmailAndSubject, + password: string, + aux?: Uint8Array, +): Promise { + try { + const encryptionKey = await removePasswordProtection(encryptedEmail.encryptedKey, password); + return await decryptEmailAndSubject(encryptedEmail.encEmail, encryptionKey, aux); + } catch (error) { + if (error instanceof InvalidInputEmail) throw error; + if (error instanceof EmailPasswordOpenError) throw error; + if (error instanceof EmailSymmetricDecryptionError) throw error; + throw new FailedToDecryptEmail(error instanceof Error ? error.message : String(error)); + } +} diff --git a/src/index.ts b/src/index.ts index 117c378..c3ec703 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,14 +5,31 @@ export { encryptEmailHybrid, encryptEmailHybridForMultipleRecipients, decryptEmailHybrid, + encryptEmailAndSubjectHybrid, + encryptEmailAndSubjectHybridForMultipleRecipients, + decryptEmailAndSubjectHybrid, createPwdProtectedEmail, decryptPwdProtectedEmail, + createPwdProtectedEmailAndSubject, + decryptPwdProtectedEmailAndSubject, generateEmailKeys, - decryptEmailBody, - encryptEmailBody, - encryptEmailBodyWithKey, + decryptEmail, + encryptEmail, + encryptEmailWithKey, + encryptEmailAndSubject, + encryptEmailAndSubjectWithKey, + decryptEmailAndSubject, deriveDatabaseKey, deriveEmailDraftKey, + FailedToEncryptEmail, + FailedToDecryptEmail, + InvalidInputEmail, + EmailSymmetricEncryptionError, + EmailSymmetricDecryptionError, + EmailHybridEncryptionError, + EmailHybridDecryptionError, + EmailPasswordProtectError, + EmailPasswordOpenError, } from './email-crypto'; export { hashDataArray, diff --git a/src/types.ts b/src/types.ts index c3c7f0d..47e7843 100644 --- a/src/types.ts +++ b/src/types.ts @@ -17,12 +17,22 @@ export type HybridKeyPair = { export type HybridEncryptedEmail = { encryptedKey: HybridEncKey; - encEmailBody: EmailBodyEncrypted; + encEmail: EmailEncrypted; +}; + +export type HybridEncryptedEmailAndSubject = { + encryptedKey: HybridEncKey; + encEmail: EmailAndSubjectEncrypted; }; export type PwdProtectedEmail = { encryptedKey: PwdProtectedKey; - encEmailBody: EmailBodyEncrypted; + encEmail: EmailEncrypted; +}; + +export type PwdProtectedEmailAndSubject = { + encryptedKey: PwdProtectedKey; + encEmail: EmailAndSubjectEncrypted; }; export type HybridEncKey = { @@ -36,18 +46,24 @@ export type PwdProtectedKey = { salt: string; }; -export type EmailBodyEncrypted = { +export type EmailEncrypted = { encText: string; - encSubject: string; encAttachments?: string[]; }; -export type EmailBody = { +export type EmailAndSubjectEncrypted = EmailEncrypted & { + encSubject: string; +}; + +export type Email = { text: string; - subject: string; attachments?: string[]; }; +export type EmailAndSubject = Email & { + subject: string; +}; + export enum KeystoreType { ENCRYPTION = 'Encryption', RECOVERY = 'Recovery', diff --git a/tests/email-crypto/core.test.ts b/tests/email-crypto/core.test.ts index 1b513ea..015b34e 100644 --- a/tests/email-crypto/core.test.ts +++ b/tests/email-crypto/core.test.ts @@ -1,41 +1,61 @@ import { describe, expect, it } from 'vitest'; -import { EmailBody } from '../../src/types'; -import { decryptEmailBody, encryptEmailBody, deriveDatabaseKey, deriveEmailDraftKey } from '../../src/email-crypto'; -import { generateUuid, genMnemonic } from '../../src/utils'; +import { Email, EmailAndSubject } from '../../src/types'; +import { + decryptEmail, + encryptEmail, + deriveDatabaseKey, + deriveEmailDraftKey, + encryptEmailAndSubject, + decryptEmailAndSubject, +} from '../../src/email-crypto'; +import { genMnemonic } from '../../src/utils'; import { genSymmetricKey } from '../../src/symmetric-crypto'; import { AES_KEY_BYTE_LENGTH } from '../../src/constants'; +import { EmailSymmetricDecryptionError, InvalidInputEmail } from '../../src/email-crypto/errors'; describe('Test email crypto functions', () => { - const emailBody: EmailBody = { - text: 'test body', - subject: 'test subject', + const email: Email = { + text: 'test email', + }; + + const emailAndSubject: EmailAndSubject = { + text: 'test email text', + subject: 'test email subject', }; const aux = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]); - it('should generate email id', async () => { - const result1 = generateUuid(); - const result2 = generateUuid(); - expect(result1).not.toEqual(result2); - expect(result1).toHaveLength(36); + it('should encrypt and decrypt email', async () => { + const { encEmail, encryptionKey } = await encryptEmail(email, aux); + const result = await decryptEmail(encEmail, encryptionKey, aux); + expect(result).toEqual(email); }); - it('should encrypt and decrypt email', async () => { - const { encEmailBody, encryptionKey } = await encryptEmailBody(emailBody, aux); - const result = await decryptEmailBody(encEmailBody, encryptionKey, aux); - expect(result).toEqual(emailBody); + it('should encrypt and decrypt email with subject', async () => { + const { encEmail, encryptionKey } = await encryptEmailAndSubject(emailAndSubject, aux); + const result = await decryptEmailAndSubject(encEmail, encryptionKey, aux); + expect(result).toEqual(emailAndSubject); + }); + + it('should throw an error if decryption fails', async () => { + const { encEmail, encryptionKey } = await encryptEmail(email, aux); + const badEncryptionKey = await genSymmetricKey(); + await expect(decryptEmail(encEmail, badEncryptionKey, aux)).rejects.toThrow(EmailSymmetricDecryptionError); + + const badAux = new Uint8Array([4, 5, 6, 7, 8]); + await expect(decryptEmail(encEmail, encryptionKey, badAux)).rejects.toThrow(EmailSymmetricDecryptionError); }); it('should throw an error if decryption fails', async () => { - const { encEmailBody, encryptionKey } = await encryptEmailBody(emailBody, aux); + const { encEmail, encryptionKey } = await encryptEmailAndSubject(emailAndSubject, aux); const badEncryptionKey = await genSymmetricKey(); - await expect(decryptEmailBody(encEmailBody, badEncryptionKey, aux)).rejects.toThrowError( - /Failed to symmetrically decrypt email body/, + await expect(decryptEmailAndSubject(encEmail, badEncryptionKey, aux)).rejects.toThrow( + EmailSymmetricDecryptionError, ); const badAux = new Uint8Array([4, 5, 6, 7, 8]); - await expect(decryptEmailBody(encEmailBody, encryptionKey, badAux)).rejects.toThrowError( - /Failed to symmetrically decrypt email body/, + await expect(decryptEmailAndSubject(encEmail, encryptionKey, badAux)).rejects.toThrow( + EmailSymmetricDecryptionError, ); }); @@ -43,7 +63,8 @@ describe('Test email crypto functions', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const badEmail: any = {}; badEmail.self = badEmail; - await expect(encryptEmailBody(badEmail, aux)).rejects.toThrowError(/Failed to symmetrically encrypt email body/); + await expect(encryptEmail(badEmail, aux)).rejects.toThrow(InvalidInputEmail); + await expect(encryptEmailAndSubject(badEmail, aux)).rejects.toThrow(InvalidInputEmail); }); it('should derive symmetric key for database encryption', async () => { diff --git a/tests/email-crypto/hybridEmail.test.ts b/tests/email-crypto/hybridEmail.test.ts index dcd137f..d76ccbf 100644 --- a/tests/email-crypto/hybridEmail.test.ts +++ b/tests/email-crypto/hybridEmail.test.ts @@ -4,14 +4,34 @@ import { decryptEmailHybrid, encryptEmailHybridForMultipleRecipients, generateEmailKeys, + encryptEmailAndSubjectHybrid, + decryptEmailAndSubjectHybrid, + encryptEmailAndSubjectHybridForMultipleRecipients, } from '../../src/email-crypto'; -import { EmailBody, HybridEncryptedEmail, HybridEncKey } from '../../src/types'; +import { + Email, + HybridEncryptedEmail, + HybridEncKey, + EmailAndSubject, + HybridEncryptedEmailAndSubject, + RecipientWithPublicKey, +} from '../../src/types'; +import { + EmailHybridDecryptionError, + EmailHybridEncryptionError, + EmailSymmetricDecryptionError, + InvalidInputEmail, +} from '../../src/email-crypto/errors'; describe('Test email crypto functions', async () => { - const email: EmailBody = { - text: 'test body', - subject: 'test subject', + const email: Email = { + text: 'test email text', + }; + + const emailAndSubject: EmailAndSubject = { + text: 'test email text', + subject: 'test email subject', }; const { secretKey: alicePrivateKeys, publicKey: alicePublicKeys } = await generateEmailKeys(); @@ -28,30 +48,36 @@ describe('Test email crypto functions', async () => { it('should encrypt and decrypt email sucessfully', async () => { const encryptedEmail = await encryptEmailHybrid(email, bobWithPublicKeys); - - expect(encryptedEmail.encEmailBody.encSubject).not.toBe(email.subject); const decryptedEmail = await decryptEmailHybrid(encryptedEmail, bobPrivateKeys); expect(decryptedEmail).toStrictEqual(email); }); + it('should encrypt and decrypt email and subject sucessfully', async () => { + const encryptedEmail = await encryptEmailAndSubjectHybrid(emailAndSubject, bobWithPublicKeys); + + expect(encryptedEmail.encEmail?.encSubject).not.toBe(emailAndSubject.subject); + const decryptedEmail = await decryptEmailAndSubjectHybrid(encryptedEmail, bobPrivateKeys); + + expect(decryptedEmail).toStrictEqual(emailAndSubject); + }); + it('should throw an error if public key is given instead of the secret one', async () => { const badRecipient = { email: 'alice email', publicHybridKey: alicePrivateKeys, }; - await expect(encryptEmailHybrid(email, badRecipient)).rejects.toThrowError( - /Failed to encrypt email body with hybrid encryption/, + await expect(encryptEmailHybrid(email, badRecipient)).rejects.toThrow(EmailHybridEncryptionError); + await expect(encryptEmailAndSubjectHybrid(emailAndSubject, badRecipient)).rejects.toThrow( + EmailHybridEncryptionError, ); }); it('should throw an error if not intended recipient', async () => { const encryptedEmail = await encryptEmailHybrid(email, bobWithPublicKeys); - await expect(decryptEmailHybrid(encryptedEmail, alicePrivateKeys)).rejects.toThrowError( - /Failed to decrypt email with hybrid encryption/, - ); + await expect(decryptEmailHybrid(encryptedEmail, alicePrivateKeys)).rejects.toThrow(EmailHybridDecryptionError); }); it('should throw an error if hybrid email decryption fails', async () => { @@ -62,14 +88,23 @@ describe('Test email crypto functions', async () => { }; const badEncryptedEmail: HybridEncryptedEmail = { encryptedKey: encKey, - encEmailBody: { + encEmail: { + encText: 'mock encrypted text', + }, + }; + + const badEncryptedEmailAndSubject: HybridEncryptedEmailAndSubject = { + encryptedKey: encKey, + encEmail: { encText: 'mock encrypted text', encSubject: 'mock encrypted subject', }, }; - await expect(decryptEmailHybrid(badEncryptedEmail, bobPrivateKeys)).rejects.toThrowError( - /Failed to decrypt email with hybrid encryption/, + await expect(decryptEmailHybrid(badEncryptedEmail, bobPrivateKeys)).rejects.toThrow(EmailHybridDecryptionError); + + await expect(decryptEmailAndSubjectHybrid(badEncryptedEmailAndSubject, bobPrivateKeys)).rejects.toThrow( + EmailHybridDecryptionError, ); }); @@ -80,7 +115,22 @@ describe('Test email crypto functions', async () => { ]); expect(encryptedEmail.length).toBe(2); - expect(encryptedEmail[0].encEmailBody).toBe(encryptedEmail[1].encEmailBody); + expect(encryptedEmail[0].encEmail).toBe(encryptedEmail[1].encEmail); + }); + + it('should encrypt email and subject to multiple senders sucessfully', async () => { + const encryptedEmail = await encryptEmailAndSubjectHybridForMultipleRecipients(emailAndSubject, [ + bobWithPublicKeys, + aliceWithPublicKeys, + ]); + + expect(encryptedEmail.length).toBe(2); + expect(encryptedEmail[0].encEmail).toBe(encryptedEmail[1].encEmail); + + const emailDecryptedByBob = await decryptEmailAndSubjectHybrid(encryptedEmail[0], bobPrivateKeys); + const emailDecryptedByAlice = await decryptEmailAndSubjectHybrid(encryptedEmail[1], alicePrivateKeys); + + expect(emailDecryptedByBob).toStrictEqual(emailDecryptedByAlice); }); it('should throw an error if encryption to multiple recipients fails', async () => { @@ -93,6 +143,84 @@ describe('Test email crypto functions', async () => { }; await expect( encryptEmailHybridForMultipleRecipients(email, [bobWithPublicKeys, badEveWithPublicKeys]), - ).rejects.toThrowError(/Failed to encrypt email to multiple recipients with hybrid encryption/); + ).rejects.toThrow(EmailHybridEncryptionError); + + await expect( + encryptEmailAndSubjectHybridForMultipleRecipients(emailAndSubject, [bobWithPublicKeys, badEveWithPublicKeys]), + ).rejects.toThrow(EmailHybridEncryptionError); + }); + + it('should throw an error if no recipients are provided', async () => { + await expect(encryptEmailHybridForMultipleRecipients(email, [])).rejects.toThrow(InvalidInputEmail); + + await expect( + encryptEmailHybridForMultipleRecipients(email, undefined as unknown as RecipientWithPublicKey[]), + ).rejects.toThrow(InvalidInputEmail); + + await expect(encryptEmailAndSubjectHybridForMultipleRecipients(emailAndSubject, [])).rejects.toThrow( + InvalidInputEmail, + ); + + await expect( + encryptEmailAndSubjectHybridForMultipleRecipients( + emailAndSubject, + undefined as unknown as RecipientWithPublicKey[], + ), + ).rejects.toThrow(InvalidInputEmail); + }); + + it('should throw an error if input is invalid', async () => { + await expect(encryptEmailHybrid({} as Email, bobWithPublicKeys)).rejects.toThrow(InvalidInputEmail); + + await expect(encryptEmailAndSubjectHybrid({} as EmailAndSubject, bobWithPublicKeys)).rejects.toThrow( + InvalidInputEmail, + ); + + await expect( + encryptEmailHybridForMultipleRecipients({} as Email, [bobWithPublicKeys, aliceWithPublicKeys]), + ).rejects.toThrow(InvalidInputEmail); + + await expect( + encryptEmailAndSubjectHybridForMultipleRecipients({} as EmailAndSubject, [ + bobWithPublicKeys, + aliceWithPublicKeys, + ]), + ).rejects.toThrow(InvalidInputEmail); + + await expect(decryptEmailHybrid({} as HybridEncryptedEmail, bobPrivateKeys)).rejects.toThrow( + EmailHybridDecryptionError, + ); + + await expect(decryptEmailAndSubjectHybrid({} as HybridEncryptedEmailAndSubject, bobPrivateKeys)).rejects.toThrow( + EmailHybridDecryptionError, + ); + }); + + it('should throw an error if encrypted email is modified', async () => { + const encryptedEmail = await encryptEmailHybrid(emailAndSubject, bobWithPublicKeys); + + const modifiedCiphertext = encryptedEmail; + modifiedCiphertext.encEmail.encText += 'modified ciphertext'; + await expect(decryptEmailHybrid(modifiedCiphertext, bobPrivateKeys)).rejects.toThrow(EmailSymmetricDecryptionError); + + const modifiedKey = encryptedEmail; + modifiedKey.encryptedKey.encryptedKey += 'modified key'; + await expect(decryptEmailHybrid(modifiedCiphertext, bobPrivateKeys)).rejects.toThrow(EmailHybridDecryptionError); + }); + + it('should throw an error if encrypted email and subject are modified', async () => { + const encryptedEmail = await encryptEmailAndSubjectHybrid(emailAndSubject, bobWithPublicKeys); + + const modifiedCiphertext = encryptedEmail; + modifiedCiphertext.encEmail.encText += 'modified ciphertext'; + await expect(decryptEmailAndSubjectHybrid(modifiedCiphertext, bobPrivateKeys)).rejects.toThrow( + EmailSymmetricDecryptionError, + ); + + const modifiedKey = encryptedEmail; + modifiedKey.encryptedKey.encryptedKey += 'modified key'; + await expect(decryptEmailAndSubjectHybrid(modifiedCiphertext, bobPrivateKeys)).rejects.toThrow( + EmailHybridDecryptionError, + ); }); }); diff --git a/tests/email-crypto/pwdProtectedEmail.test.ts b/tests/email-crypto/pwdProtectedEmail.test.ts index 9389a56..dadb3c4 100644 --- a/tests/email-crypto/pwdProtectedEmail.test.ts +++ b/tests/email-crypto/pwdProtectedEmail.test.ts @@ -1,9 +1,21 @@ import { describe, expect, it } from 'vitest'; -import { createPwdProtectedEmail, decryptPwdProtectedEmail } from '../../src/email-crypto'; -import { EmailBody } from '../../src/types'; +import { + createPwdProtectedEmail, + createPwdProtectedEmailAndSubject, + decryptPwdProtectedEmail, + decryptPwdProtectedEmailAndSubject, + EmailPasswordOpenError, + EmailSymmetricDecryptionError, + InvalidInputEmail, +} from '../../src/email-crypto'; +import { Email, EmailAndSubject } from '../../src/types'; describe('Test email crypto functions', () => { - const email: EmailBody = { + const email: Email = { + text: 'Hi Bob, This is a test message. -Alice.', + }; + + const emailAndSubject: EmailAndSubject = { text: 'Hi Bob, This is a test message. -Alice.', subject: 'test subject', }; @@ -16,18 +28,47 @@ describe('Test email crypto functions', () => { expect(decryptedEmail).toStrictEqual(email); }); + it('should encrypt and decrypt email and subjectsucessfully', async () => { + const encryptedEmail = await createPwdProtectedEmailAndSubject(emailAndSubject, sharedSecret); + const decryptedEmail = await decryptPwdProtectedEmailAndSubject(encryptedEmail, sharedSecret); + expect(decryptedEmail).toStrictEqual(emailAndSubject); + }); + it('should throw an error if encryption fails', async () => { - const badEmail = {} as unknown as EmailBody; - await expect(createPwdProtectedEmail(badEmail, sharedSecret)).rejects.toThrowError( - /Failed to password-protect email/, + await expect(createPwdProtectedEmail({} as unknown as Email, sharedSecret)).rejects.toThrow(InvalidInputEmail); + await expect(createPwdProtectedEmailAndSubject({} as unknown as EmailAndSubject, sharedSecret)).rejects.toThrow( + InvalidInputEmail, ); }); it('should throw an error if a different secret used for decryption', async () => { const encryptedEmail = await createPwdProtectedEmail(email, sharedSecret); const wrongSecret = 'different secret'; - await expect(decryptPwdProtectedEmail(encryptedEmail, wrongSecret)).rejects.toThrowError( - /Failed to decrypt password-protect email/, + await expect(decryptPwdProtectedEmail(encryptedEmail, wrongSecret)).rejects.toThrow(EmailPasswordOpenError); + + const encryptedEmailAndSubject = await createPwdProtectedEmailAndSubject(emailAndSubject, sharedSecret); + await expect(decryptPwdProtectedEmailAndSubject(encryptedEmailAndSubject, wrongSecret)).rejects.toThrow( + EmailPasswordOpenError, + ); + }); + + it('should throw an error if password-protected email is modified', async () => { + const encryptedEmail = await createPwdProtectedEmail(email, sharedSecret); + + const modifiedCiphertext = encryptedEmail; + modifiedCiphertext.encEmail.encText += 'modified ciphertext'; + await expect(decryptPwdProtectedEmail(modifiedCiphertext, sharedSecret)).rejects.toThrow( + EmailSymmetricDecryptionError, + ); + }); + + it('should throw an error if password-protected email and subject are modified', async () => { + const encryptedEmail = await createPwdProtectedEmailAndSubject(emailAndSubject, sharedSecret); + + const modifiedCiphertext = encryptedEmail; + modifiedCiphertext.encEmail.encText += 'modified ciphertext'; + await expect(decryptPwdProtectedEmailAndSubject(modifiedCiphertext, sharedSecret)).rejects.toThrow( + EmailSymmetricDecryptionError, ); }); }); diff --git a/tests/email-crypto/pwdProtectedEmailCoreErrors.test.ts b/tests/email-crypto/pwdProtectedEmailCoreErrors.test.ts new file mode 100644 index 0000000..792eb7e --- /dev/null +++ b/tests/email-crypto/pwdProtectedEmailCoreErrors.test.ts @@ -0,0 +1,66 @@ +import { vi, it, describe, beforeEach, expect } from 'vitest'; +import { + createPwdProtectedEmail, + createPwdProtectedEmailAndSubject, + decryptPwdProtectedEmail, + decryptPwdProtectedEmailAndSubject, +} from '../../src/email-crypto'; +import { FailedToDecryptEmail, FailedToEncryptEmail } from '../../src/email-crypto/errors'; +import { Email, EmailAndSubject, PwdProtectedEmail, PwdProtectedEmailAndSubject } from '../../src/types'; +import * as core from '../../src/email-crypto/core'; + +vi.mock('../../src/email-crypto/core', async () => { + const actual = await vi.importActual('../../src/email-crypto/core'); + + return { + ...actual, + passwordProtectKey: vi.fn(), + removePasswordProtection: vi.fn(), + }; +}); + +describe('Test email crypto functions', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + const email: Email = { + text: 'Hi Bob, This is a test message. -Alice.', + attachments: ['file1.txt', 'file2.txt'], + }; + + const emailAndSubject: EmailAndSubject = { + text: 'Hi Bob, This is a test message. -Alice.', + attachments: ['file1.txt', 'file2.txt'], + subject: 'test subject', + }; + + const sharedSecret = 'test shared secret'; + + it('throws FailedToEncryptEmail when encryption fails', async () => { + const spy = vi.spyOn(core, 'passwordProtectKey'); + + spy.mockRejectedValue(new Error('passwordProtectKey: unexpected failure')); + + await expect(createPwdProtectedEmail(email, sharedSecret)).rejects.toBeInstanceOf(FailedToEncryptEmail); + + await expect(createPwdProtectedEmailAndSubject(emailAndSubject, sharedSecret)).rejects.toBeInstanceOf( + FailedToEncryptEmail, + ); + }); + + it('throws FailedToDecryptEmail when decryption fails', async () => { + const encryptedEmail = {} as PwdProtectedEmail; + const encryptedEmailAndSubject = {} as PwdProtectedEmailAndSubject; + + const spy = vi.spyOn(core, 'removePasswordProtection'); + + spy.mockRejectedValue(new Error('removePasswordProtection: unexpected failure')); + + await expect(decryptPwdProtectedEmail(encryptedEmail, sharedSecret)).rejects.toBeInstanceOf(FailedToDecryptEmail); + + await expect(decryptPwdProtectedEmailAndSubject(encryptedEmailAndSubject, sharedSecret)).rejects.toBeInstanceOf( + FailedToDecryptEmail, + ); + }); +}); diff --git a/tests/email-crypto/pwdProtectedEmailNobleErrors.test.ts b/tests/email-crypto/pwdProtectedEmailNobleErrors.test.ts new file mode 100644 index 0000000..b9d176f --- /dev/null +++ b/tests/email-crypto/pwdProtectedEmailNobleErrors.test.ts @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + createPwdProtectedEmail, + createPwdProtectedEmailAndSubject, + EmailPasswordProtectError, + EmailSymmetricEncryptionError, +} from '../../src/email-crypto'; +import { Email, EmailAndSubject } from '../../src/types'; +import * as nobleUtils from '@noble/hashes/utils.js'; +import * as nobleWrapper from '@noble/ciphers/aes.js'; + +// Noble is ESM module and doesn't work with spyOn directly (module namespace is not configurable in ESM), must be mocked before. +// To mock it but keep the original implementation for most tests, we use importActual. +// vi.resetAllMocks(); before each test is a must to reset the mock back to importActual. +vi.mock('@noble/hashes/utils.js', async () => { + const actual = await vi.importActual('@noble/hashes/utils.js'); + + return { + ...actual, + randomBytes: vi.fn(actual.randomBytes), + }; +}); + +vi.mock('@noble/ciphers/aes.js', async () => { + const actual = await vi.importActual('@noble/ciphers/aes.js'); + + return { + ...actual, + aeskw: vi.fn(actual.aeskw), + }; +}); + +describe('Test email crypto functions', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + const email: Email = { + text: 'Hi Bob, This is a test message. -Alice.', + }; + + const emailAndSubject: EmailAndSubject = { + text: 'Hi Bob, This is a test message. -Alice.', + subject: 'test subject', + }; + + const sharedSecret = 'test shared secret'; + + it('throws EmailSymmetricEncryptionError when symmetric encryption fails', async () => { + vi.spyOn(nobleUtils, 'randomBytes').mockImplementation(() => { + throw new Error('randomBytes: unexpected crypto failure'); + }); + + await expect(createPwdProtectedEmail(email, sharedSecret)).rejects.toThrow(EmailSymmetricEncryptionError); + await expect(createPwdProtectedEmailAndSubject(emailAndSubject, sharedSecret)).rejects.toThrow( + EmailSymmetricEncryptionError, + ); + }); + + it('throws EmailPasswordProtectError when key wrapping fails', async () => { + vi.spyOn(nobleWrapper, 'aeskw').mockImplementation(() => { + throw new Error('aeskw: unexpected crypto failure'); + }); + + await expect(createPwdProtectedEmail(email, sharedSecret)).rejects.toThrow(EmailPasswordProtectError); + await expect(createPwdProtectedEmailAndSubject(emailAndSubject, sharedSecret)).rejects.toThrow( + EmailPasswordProtectError, + ); + }); +}); diff --git a/tests/utils/converters.test.ts b/tests/utils/converters.test.ts index 4903b29..1194764 100644 --- a/tests/utils/converters.test.ts +++ b/tests/utils/converters.test.ts @@ -53,4 +53,11 @@ describe('test uuid and mnemonic generation', () => { expect(result).toBe(mnemonic); }); + + it('should generate unique id', async () => { + const result1 = generateUuid(); + const result2 = generateUuid(); + expect(result1).not.toEqual(result2); + expect(result1).toHaveLength(36); + }); });