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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions .github/workflows/sonar-analysis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
2 changes: 1 addition & 1 deletion .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@

yarn run lint:fix; npx lint-staged; yarn run test
yarn run lint:fix; npx lint-staged --allow-empty; yarn run test
88 changes: 15 additions & 73 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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
Expand All @@ -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);

```
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
100 changes: 51 additions & 49 deletions src/email-crypto/core.ts
Original file line number Diff line number Diff line change
@@ -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<EmailBodyEncrypted> {
): Promise<EmailEncrypted> {
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<EmailBody> {
): Promise<Email> {
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));
}
}

Expand All @@ -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));
}
}

Expand All @@ -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));
}
}

Expand All @@ -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));
}
}

Expand All @@ -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));
}
}
Loading
Loading