diff --git a/package-lock.json b/package-lock.json index 8fed374..e49fa06 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@internxt/css-config": "^1.1.0", "@internxt/lib": "^1.4.1", - "@internxt/sdk": "^1.17.11", + "@internxt/sdk": "^1.17.16", "@internxt/ui": "^0.1.25", "@phosphor-icons/react": "^2.1.10", "@reduxjs/toolkit": "^2.11.2", @@ -1455,9 +1455,9 @@ "license": "MIT" }, "node_modules/@internxt/sdk": { - "version": "1.17.11", - "resolved": "https://registry.npmjs.org/@internxt/sdk/-/sdk-1.17.11.tgz", - "integrity": "sha512-t9rTaNNli2GdnFTEJPgcI2MAX2LouKzVbFIBj6QUVlCk7Ivcj+hbkkZqVlHDNTvsw3UObq2ehSNYWmzkMgcbfQ==", + "version": "1.17.16", + "resolved": "https://registry.npmjs.org/@internxt/sdk/-/sdk-1.17.16.tgz", + "integrity": "sha512-z6sdibK6NVDitKZ5AhZJN2RSSTGyooY7e7hkb04CUT8fGmxagzvb5LOJEnjKhOs2raO1LBbg8K5wSDhpEIp7fA==", "license": "MIT", "dependencies": { "axios": "^1.16.0" diff --git a/package.json b/package.json index 54c7d41..60b0157 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "dependencies": { "@internxt/css-config": "^1.1.0", "@internxt/lib": "^1.4.1", - "@internxt/sdk": "^1.17.11", + "@internxt/sdk": "^1.17.16", "@internxt/ui": "^0.1.25", "@phosphor-icons/react": "^2.1.10", "@reduxjs/toolkit": "^2.11.2", diff --git a/src/components/compose-message/hooks/useComposeSend.test.ts b/src/components/compose-message/hooks/useComposeSend.test.ts index 6a22e3b..d0defd9 100644 --- a/src/components/compose-message/hooks/useComposeSend.test.ts +++ b/src/components/compose-message/hooks/useComposeSend.test.ts @@ -35,11 +35,11 @@ const recipient = (email: string): Recipient => ({ id: email, email }); const show = vi.mocked(notificationsService.show); const mockEncryptionBlock = { - version: 'v1', + version: 'v2', encryptedText: 'ct', - encryptedPreview: 'cp', wrappedKeys: [], - attachmentWrappedKeys: [], + encryptedPreview: 'cp', + previewWrappedKeys: [], }; const renderSend = (overrides: Partial[0]> = {}) => { @@ -175,11 +175,31 @@ describe('useComposeSend', () => { expect.any(Uint8Array), ); expect(mocks.sendEmail).toHaveBeenCalledWith( - expect.objectContaining({ encryption: expect.objectContaining({ version: 'v1' }), deliveryMode: 'INTERNXT' }), + expect.objectContaining({ encryption: expect.objectContaining({ version: 'v2' }), deliveryMode: 'INTERNXT' }), ); expect(onSent).toHaveBeenCalled(); }); + test('When all recipients are Internxt and one is Bcc, then the send is blocked', async () => { + const buildSpy = vi.spyOn(MailEncryptionService.instance, 'buildEncryptionBlock'); + + const { result, onSent } = renderSend({ + toRecipients: [recipient('bob@inxt.me')], + bccRecipients: [recipient('carol@inxt.me')], + }); + + expect(result.current.encryptionState).toBe('internxt'); + + await act(async () => { + await result.current.send(); + }); + + expect(show).toHaveBeenCalledWith(expect.objectContaining({ text: 'errors.mail.bccNotSupportedEncrypted' })); + expect(buildSpy).not.toHaveBeenCalled(); + expect(mocks.sendEmail).not.toHaveBeenCalled(); + expect(onSent).not.toHaveBeenCalled(); + }); + describe('external recipients', () => { let getVariable: ReturnType; @@ -226,7 +246,7 @@ describe('useComposeSend', () => { expect.any(Uint8Array), ); expect(mocks.sendEmail).toHaveBeenCalledWith( - expect.objectContaining({ deliveryMode: 'EXTERNAL', encryption: expect.objectContaining({ version: 'v1' }) }), + expect.objectContaining({ deliveryMode: 'EXTERNAL', encryption: expect.objectContaining({ version: 'v2' }) }), ); expect(onSent).toHaveBeenCalled(); }); diff --git a/src/components/compose-message/hooks/useComposeSend.ts b/src/components/compose-message/hooks/useComposeSend.ts index 33a1310..17d590d 100644 --- a/src/components/compose-message/hooks/useComposeSend.ts +++ b/src/components/compose-message/hooks/useComposeSend.ts @@ -96,7 +96,8 @@ export const useComposeSend = ({ if (pendingInherited.length > 0) { const senderKeysForAttachments = MailKeysService.instance.getCurrentKeys(); - if (!senderKeysForAttachments) { + const senderAddress = MailKeysService.instance.getCurrentAddress(); + if (!senderKeysForAttachments || !senderAddress) { notificationsService.show({ text: translate('errors.mail.forwardAttachmentFailed'), type: ToastType.Error, @@ -110,6 +111,7 @@ export const useComposeSend = ({ const originalSessionKey = await MailEncryptionService.instance.decryptAttachmentsSessionKey( item.originalEnvelope, senderKeysForAttachments, + senderAddress, ); const { blob } = await NetworkService.instance.download({ @@ -167,6 +169,12 @@ export const useComposeSend = ({ return; } + // TODO: remove this once per-recipient delivery is implemented + if (encryptionState === 'internxt' && bccRecipients.length > 0) { + notificationsService.show({ text: translate('errors.mail.bccNotSupportedEncrypted'), type: ToastType.Warning }); + return; + } + if (!senderKeys?.address || !senderKeys.publicKey) { notificationsService.show({ text: translate('errors.mail.keyLookupFailed'), type: ToastType.Error }); return; diff --git a/src/components/compose-message/hooks/useDraftMessage.test.ts b/src/components/compose-message/hooks/useDraftMessage.test.ts index 6c0fe2e..36a0bc2 100644 --- a/src/components/compose-message/hooks/useDraftMessage.test.ts +++ b/src/components/compose-message/hooks/useDraftMessage.test.ts @@ -25,11 +25,11 @@ const editor = { getHTML: () => '

hi

', getText: () => 'hi', on: vi.fn(), o const recipient = (email: string): Recipient => ({ id: email, email }); const mockEncryptionBlock = { - version: 'v1', + version: 'v2', encryptedText: 'ct', - encryptedPreview: 'cp', wrappedKeys: [], - attachmentWrappedKeys: [], + encryptedPreview: 'cp', + previewWrappedKeys: [], }; const renderDraft = (overrides: Partial[0]> = {}) => { @@ -84,7 +84,7 @@ describe('Draft Message', () => { expect.objectContaining({ subject: 'A subject', to: [{ email: 'bob@inxt.me' }], - encryption: expect.objectContaining({ version: 'v1' }), + encryption: expect.objectContaining({ version: 'v2' }), }), ); }); diff --git a/src/components/compose-message/index.tsx b/src/components/compose-message/index.tsx index 7c133f7..8305df9 100644 --- a/src/components/compose-message/index.tsx +++ b/src/components/compose-message/index.tsx @@ -128,9 +128,10 @@ export const ComposeMessageDialog = () => { const envelope = composeDialogData?.mode === 'draft' ? composeDialogData.draft.encryption : undefined; if (!envelope) return; const keys = MailKeysService.instance.getCurrentKeys(); - if (!keys) return; + const address = MailKeysService.instance.getCurrentAddress(); + if (!keys || !address) return; MailEncryptionService.instance - .decryptAttachmentsSessionKey(envelope as EncryptionBlock, keys) + .decryptAttachmentsSessionKey(envelope as EncryptionBlock, keys, address) .then((key) => { setAttachmentsSessionKey(key); sessionKeyHydratedRef.current = true; diff --git a/src/hooks/mail/useAttachmentsSessionKey.test.tsx b/src/hooks/mail/useAttachmentsSessionKey.test.tsx index 5acbcea..af2b132 100644 --- a/src/hooks/mail/useAttachmentsSessionKey.test.tsx +++ b/src/hooks/mail/useAttachmentsSessionKey.test.tsx @@ -9,16 +9,17 @@ import { MailKeysService } from '@/services/mail-keys'; const KEY = new Uint8Array([1, 2, 3, 4]); const KEYPAIR = { secretKey: new Uint8Array(32), publicKey: new Uint8Array(32) } as unknown as HybridKeyPair; const ENVELOPE = { - version: 'v1', + version: 'v2', encryptedText: 'ct', - encryptedPreview: 'cp', wrappedKeys: [], - attachmentWrappedKeys: [], + encryptedPreview: 'cp', + previewWrappedKeys: [], } as unknown as EncryptionBlock; const setKeys = (keys: HybridKeyPair | null) => { const spy = vi.spyOn(MailKeysService.instance, 'getCurrentKeys'); spy.mockReturnValue(keys); + vi.spyOn(MailKeysService.instance, 'getCurrentAddress').mockReturnValue(keys ? 'me@inxt.me' : null); return spy; }; diff --git a/src/hooks/mail/useAttachmentsSessionKey.ts b/src/hooks/mail/useAttachmentsSessionKey.ts index 9cfa3ab..59a63aa 100644 --- a/src/hooks/mail/useAttachmentsSessionKey.ts +++ b/src/hooks/mail/useAttachmentsSessionKey.ts @@ -16,15 +16,16 @@ export const useAttachmentsSessionKey = ( envelope: EncryptionBlock | null, ): Uint8Array | null => { const keypair = MailKeysService.instance.getCurrentKeys(); + const address = MailKeysService.instance.getCurrentAddress(); const [cache, setCache] = useState>({}); useEffect(() => { - if (!mailId || !envelope || !keypair) return; + if (!mailId || !envelope || !keypair || !address) return; if (cache[mailId]) return; let cancelled = false; MailEncryptionService.instance - .decryptAttachmentsSessionKey(envelope, keypair) + .decryptAttachmentsSessionKey(envelope, keypair, address) .then((key) => { if (!cancelled) setCache((prev) => ({ ...prev, [mailId]: { ok: true, key } })); }) @@ -36,7 +37,7 @@ export const useAttachmentsSessionKey = ( return () => { cancelled = true; }; - }, [mailId, envelope, keypair, cache]); + }, [mailId, envelope, keypair, address, cache]); if (!mailId) return null; const entry = cache[mailId]; diff --git a/src/hooks/mail/useDecryptedPreviews.test.tsx b/src/hooks/mail/useDecryptedPreviews.test.tsx index 2f42e1e..b08e4fc 100644 --- a/src/hooks/mail/useDecryptedPreviews.test.tsx +++ b/src/hooks/mail/useDecryptedPreviews.test.tsx @@ -15,6 +15,7 @@ const plainSummary = (id: string): Summary => ({ id }) as unknown as Summary; const setKeys = (keys: HybridKeyPair | null) => { vi.spyOn(MailKeysService.instance, 'getCurrentKeys').mockReturnValue(keys); + vi.spyOn(MailKeysService.instance, 'getCurrentAddress').mockReturnValue(keys ? 'me@inxt.me' : null); }; const spyOnPreviewDecrypt = () => vi.spyOn(MailEncryptionService.instance, 'decryptSummaryPreview'); diff --git a/src/hooks/mail/useDecryptedPreviews.ts b/src/hooks/mail/useDecryptedPreviews.ts index 5be7182..32bb70e 100644 --- a/src/hooks/mail/useDecryptedPreviews.ts +++ b/src/hooks/mail/useDecryptedPreviews.ts @@ -6,11 +6,19 @@ import { MailKeysService } from '@/services/mail-keys'; type Summary = EmailListResponse['emails'][number]; -const decryptPendingPreviews = async (pending: Summary[], keypair: HybridKeyPair): Promise> => { +const decryptPendingPreviews = async ( + pending: Summary[], + keypair: HybridKeyPair, + address: string, +): Promise> => { const resolved: Record = {}; for (const summary of pending) { try { - resolved[summary.id] = await MailEncryptionService.instance.decryptSummaryPreview(summary.encryption!, keypair); + resolved[summary.id] = await MailEncryptionService.instance.decryptSummaryPreview( + summary.encryption!, + keypair, + address, + ); } catch (error) { console.error('Failed to decrypt mail preview', { mailId: summary.id, error }); } @@ -21,8 +29,8 @@ const decryptPendingPreviews = async (pending: Summary[], keypair: HybridKeyPair /** * Decrypts the preview snippet for the encrypted rows on a list page. The * backend projects an `encryption` block ({ encryptedPreview, wrappedKeys }) - * onto each encrypted summary the caller can read; we trial-decrypt it with the - * caller's keypair, exactly as the full body is decrypted. + * onto each encrypted summary the caller can read; the caller's wrapped key is + * found by its address label, exactly as the full body is decrypted. * * Returns a map of `emailId -> decrypted preview`. Rows are decrypted at most * once (tracked in `attempted`), so re-renders and pagination don't re-run the @@ -30,18 +38,19 @@ const decryptPendingPreviews = async (pending: Summary[], keypair: HybridKeyPair */ export const useDecryptedPreviews = (summaries: Summary[] | undefined): Record => { const senderKeys = MailKeysService.instance.getCurrentKeys(); + const senderAddress = MailKeysService.instance.getCurrentAddress(); const [previews, setPreviews] = useState>({}); const attempted = useRef>(new Set()); useEffect(() => { - if (!senderKeys || !summaries?.length) return; + if (!senderKeys || !senderAddress || !summaries?.length) return; const pending = summaries.filter((s) => s.encryption && !attempted.current.has(s.id)); if (pending.length === 0) return; pending.forEach((s) => attempted.current.add(s.id)); let cancelled = false; - decryptPendingPreviews(pending, senderKeys).then((resolved) => { + decryptPendingPreviews(pending, senderKeys, senderAddress).then((resolved) => { if (!cancelled && Object.keys(resolved).length) { setPreviews((prev) => ({ ...prev, ...resolved })); } @@ -50,7 +59,7 @@ export const useDecryptedPreviews = (summaries: Summary[] | undefined): Record { cancelled = true; }; - }, [summaries, senderKeys]); + }, [summaries, senderKeys, senderAddress]); return previews; }; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index bf7eb5a..e43973f 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -170,6 +170,7 @@ "forwardAttachmentFailed": "Could not prepare an attachment for forwarding. Please try again.", "internxtKeyMissing": "Could not find encryption keys for an Internxt recipient. Please try again later.", "encryptionUnavailable": "Encryption isn't ready yet. Please try again in a moment.", + "bccNotSupportedEncrypted": "Bcc isn't available for encrypted emails yet. Move the recipient to To or Cc.", "draftSaveFailed": "Could not save the draft. Your changes are still here.", "draftDiscardFailed": "Could not delete the draft. Please try again.", "draftOpenFailed": "Could not open the draft. Please try again." diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 4e9a981..0e1b939 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -172,6 +172,7 @@ "forwardAttachmentFailed": "No se pudo preparar un adjunto para reenviar. Inténtalo de nuevo.", "internxtKeyMissing": "No se encontraron claves de cifrado para un destinatario de Internxt. Inténtalo de nuevo más tarde.", "encryptionUnavailable": "El cifrado aún no está listo. Inténtalo de nuevo en un momento.", + "bccNotSupportedEncrypted": "Cco aún no está disponible para correos cifrados. Mueve el destinatario a Para o Cc.", "draftSaveFailed": "No se pudo guardar el borrador. Tus cambios siguen aquí.", "draftDiscardFailed": "No se pudo eliminar el borrador. Inténtalo de nuevo.", "draftOpenFailed": "No se pudo abrir el borrador. Inténtalo de nuevo." diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 5424a93..3f84fff 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -172,6 +172,7 @@ "forwardAttachmentFailed": "Impossible de préparer une pièce jointe pour le transfert. Veuillez réessayer.", "internxtKeyMissing": "Impossible de trouver les clés de chiffrement pour un destinataire Internxt. Veuillez réessayer plus tard.", "encryptionUnavailable": "Le chiffrement n'est pas encore prêt. Veuillez réessayer dans un instant.", + "bccNotSupportedEncrypted": "Le Cci n'est pas encore disponible pour les e-mails chiffrés. Déplacez le destinataire vers À ou Cc.", "draftSaveFailed": "Impossible d'enregistrer le brouillon. Vos modifications sont toujours là.", "draftDiscardFailed": "Impossible de supprimer le brouillon. Veuillez réessayer.", "draftOpenFailed": "Impossible d'ouvrir le brouillon. Veuillez réessayer." diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 0de8647..e6ffd4a 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -172,6 +172,7 @@ "forwardAttachmentFailed": "Impossibile preparare un allegato per l'inoltro. Riprova.", "internxtKeyMissing": "Impossibile trovare le chiavi di cifratura per un destinatario Internxt. Riprova più tardi.", "encryptionUnavailable": "La crittografia non è ancora pronta. Riprova tra un momento.", + "bccNotSupportedEncrypted": "Ccn non è ancora disponibile per le email crittografate. Sposta il destinatario in A o Cc.", "draftSaveFailed": "Impossibile salvare la bozza. Le tue modifiche sono ancora qui.", "draftDiscardFailed": "Impossibile eliminare la bozza. Riprova.", "draftOpenFailed": "Impossibile aprire la bozza. Riprova." diff --git a/src/services/mail-encryption/index.test.ts b/src/services/mail-encryption/index.test.ts index f2a2d99..be9ff87 100644 --- a/src/services/mail-encryption/index.test.ts +++ b/src/services/mail-encryption/index.test.ts @@ -20,11 +20,11 @@ describe('buildEncryptionBlock + decryptEnvelope', () => { const envelope = await mailEncryption.buildEncryptionBlock(content('

hi bob

'), recipients, attachmentsKey()); - expect(envelope.version).toBe('v1'); + expect(envelope.version).toBe('v2'); expect(Array.isArray(envelope.wrappedKeys)).toBe(true); expect(envelope.wrappedKeys).toHaveLength(1); - const text = await mailEncryption.decryptEnvelope(envelope, bob); + const text = await mailEncryption.decryptEnvelope(envelope, bob, 'bob@inxt.me'); expect(text).toBe('

hi bob

'); }); @@ -53,55 +53,46 @@ describe('buildEncryptionBlock + decryptEnvelope', () => { attachmentsKey(), ); - const aliceView = await mailEncryption.decryptEnvelope(envelope, alice); - const bobView = await mailEncryption.decryptEnvelope(envelope, bob); + const aliceView = await mailEncryption.decryptEnvelope(envelope, alice, 'alice@inxt.me'); + const bobView = await mailEncryption.decryptEnvelope(envelope, bob, 'bob@inxt.me'); expect(aliceView).toBe('hey team'); expect(bobView).toBe('hey team'); }); - test('When a multi-recipient message includes a Bcc, then the serialized envelope leaks no recipient address', async () => { - const sender = await generateEmailKeys(); - const to = await generateEmailKeys(); - const cc = await generateEmailKeys(); - const bcc = await generateEmailKeys(); + test('When a message is encrypted, then every wrapped key is labeled with its recipient address', async () => { + const alice = await generateEmailKeys(); + const bob = await generateEmailKeys(); - const addresses = ['sender@inxt.me', 'to@inxt.me', 'cc@inxt.me', 'secret-bcc@inxt.me']; const envelope = await mailEncryption.buildEncryptionBlock( - content('hidden recipients'), + content('labeled'), [ - { address: addresses[0], publicKey: uint8ArrayToBase64(sender.publicKey) }, - { address: addresses[1], publicKey: uint8ArrayToBase64(to.publicKey) }, - { address: addresses[2], publicKey: uint8ArrayToBase64(cc.publicKey) }, - { address: addresses[3], publicKey: uint8ArrayToBase64(bcc.publicKey) }, + { address: 'alice@inxt.me', publicKey: uint8ArrayToBase64(alice.publicKey) }, + { address: 'bob@inxt.me', publicKey: uint8ArrayToBase64(bob.publicKey) }, ], attachmentsKey(), ); - const wire = `${ENCRYPTED_EMAIL_PREFIX}\n${Buffer.from(JSON.stringify(envelope)).toString('base64')}`; - const serialized = JSON.stringify(envelope); - for (const addr of addresses) { - expect(serialized).not.toContain(addr); - expect(wire).not.toContain(addr); - } - expect(serialized).not.toContain('secret-bcc'); + expect(envelope.wrappedKeys.map((k) => k.encryptedForEmail)).toStrictEqual(['alice@inxt.me', 'bob@inxt.me']); + }); - expect(envelope.wrappedKeys).toHaveLength(4); - for (const entry of envelope.wrappedKeys) { - expect(Object.keys(entry).sort()).toStrictEqual(['encryptedKey', 'hybridCiphertext']); - } + test('When the caller address is cased differently from the label, then the wrapped key is still found', async () => { + const bob = await generateEmailKeys(); + + const envelope = await mailEncryption.buildEncryptionBlock( + content('case-insensitive'), + [{ address: 'Bob@Inxt.me', publicKey: uint8ArrayToBase64(bob.publicKey) }], + attachmentsKey(), + ); - expect(await mailEncryption.decryptEnvelope(envelope, bcc)).toBe('hidden recipients'); - expect(await mailEncryption.decryptEnvelope(envelope, sender)).toBe('hidden recipients'); - expect(await mailEncryption.decryptEnvelope(envelope, to)).toBe('hidden recipients'); - expect(await mailEncryption.decryptEnvelope(envelope, cc)).toBe('hidden recipients'); + expect(await mailEncryption.decryptEnvelope(envelope, bob, 'bob@inxt.me')).toBe('case-insensitive'); }); test('When no recipients are provided, then encryption should fail', async () => { await expect(mailEncryption.buildEncryptionBlock(content('t'), [], attachmentsKey())).rejects.toThrow(); }); - test('When decrypting with a key that was not a recipient, then decryption should fail cleanly', async () => { + test('When decrypting with an address that has no wrapped key, then decryption should fail cleanly', async () => { const bob = await generateEmailKeys(); const eve = await generateEmailKeys(); const envelope = await mailEncryption.buildEncryptionBlock( @@ -110,7 +101,46 @@ describe('buildEncryptionBlock + decryptEnvelope', () => { attachmentsKey(), ); - await expect(mailEncryption.decryptEnvelope(envelope, eve)).rejects.toThrow(/not a recipient or wrong key/); + await expect(mailEncryption.decryptEnvelope(envelope, eve, 'eve@inxt.me')).rejects.toThrow( + /not a recipient or wrong key/, + ); + }); +}); + +describe('attachments session key', () => { + test('When a message is encrypted, then each recipient can recover the attachments session key', async () => { + const alice = await generateEmailKeys(); + const bob = await generateEmailKeys(); + const sessionKey = attachmentsKey(); + + const envelope = await mailEncryption.buildEncryptionBlock( + content('with attachments'), + [ + { address: 'alice@inxt.me', publicKey: uint8ArrayToBase64(alice.publicKey) }, + { address: 'bob@inxt.me', publicKey: uint8ArrayToBase64(bob.publicKey) }, + ], + sessionKey, + ); + + expect(await mailEncryption.decryptAttachmentsSessionKey(envelope, alice, 'alice@inxt.me')).toStrictEqual( + sessionKey, + ); + expect(await mailEncryption.decryptAttachmentsSessionKey(envelope, bob, 'bob@inxt.me')).toStrictEqual(sessionKey); + }); + + test('When an address with no wrapped key asks for the attachments session key, then it fails cleanly', async () => { + const bob = await generateEmailKeys(); + const eve = await generateEmailKeys(); + + const envelope = await mailEncryption.buildEncryptionBlock( + content('x'), + [{ address: 'bob@inxt.me', publicKey: uint8ArrayToBase64(bob.publicKey) }], + attachmentsKey(), + ); + + await expect(mailEncryption.decryptAttachmentsSessionKey(envelope, eve, 'eve@inxt.me')).rejects.toThrow( + /not a recipient or wrong key/, + ); }); }); @@ -126,8 +156,9 @@ describe('encrypted preview', () => { ); const preview = await mailEncryption.decryptSummaryPreview( - { encryptedPreview: envelope.encryptedPreview, wrappedKeys: envelope.wrappedKeys }, + { encryptedPreview: envelope.encryptedPreview, wrappedKeys: envelope.previewWrappedKeys }, bob, + 'bob@inxt.me', ); expect(preview.length).toBe(256); @@ -135,7 +166,7 @@ describe('encrypted preview', () => { expect(preview).not.toContain('\n'); }); - test('When a non-recipient tries to read the preview, then it fails cleanly', async () => { + test('When an address with no wrapped key tries to read the preview, then it fails cleanly', async () => { const bob = await generateEmailKeys(); const eve = await generateEmailKeys(); const envelope = await mailEncryption.buildEncryptionBlock( @@ -146,14 +177,16 @@ describe('encrypted preview', () => { expect( await mailEncryption.decryptSummaryPreview( - { encryptedPreview: envelope.encryptedPreview, wrappedKeys: envelope.wrappedKeys }, + { encryptedPreview: envelope.encryptedPreview, wrappedKeys: envelope.previewWrappedKeys }, bob, + 'bob@inxt.me', ), ).toBe('snippet'); await expect( mailEncryption.decryptSummaryPreview( - { encryptedPreview: envelope.encryptedPreview, wrappedKeys: envelope.wrappedKeys }, + { encryptedPreview: envelope.encryptedPreview, wrappedKeys: envelope.previewWrappedKeys }, eve, + 'eve@inxt.me', ), ).rejects.toThrow(/not a recipient or wrong key/); }); @@ -169,10 +202,11 @@ describe('isEncryptedEmailBody / parseEncryptionBlock', () => { test('When the body contains a valid encrypted bundle, then it should parse the encryption block', () => { const block = { - version: 'v1' as const, + version: 'v2' as const, encryptedText: 'et', + wrappedKeys: [{ hybridCiphertext: 'h', encryptedKey: 'k', encryptedForEmail: 'bob@inxt.me' }], encryptedPreview: 'ep', - wrappedKeys: [{ hybridCiphertext: 'h', encryptedKey: 'k' }], + previewWrappedKeys: [{ hybridCiphertext: 'hp', encryptedKey: 'kp', encryptedForEmail: 'bob@inxt.me' }], }; const wire = `${ENCRYPTED_EMAIL_PREFIX}\n${Buffer.from(JSON.stringify(block)).toString('base64')}`; expect(mailEncryption.parseEncryptionBlock(wire)).toStrictEqual(block); diff --git a/src/services/mail-encryption/index.ts b/src/services/mail-encryption/index.ts index 91bcbcb..af5e760 100644 --- a/src/services/mail-encryption/index.ts +++ b/src/services/mail-encryption/index.ts @@ -1,11 +1,5 @@ -import { base64ToUint8Array, encryptSymmetrically, type HybridKeyPair } from 'internxt-crypto'; -import { - decryptEmail, - decryptKeysHybrid, - encryptEmail, - encryptEmailWithKey, - encryptKeysHybrid, -} from 'internxt-crypto/email-crypto'; +import { base64ToUint8Array, uint8ArrayToBase64, encryptSymmetrically, type HybridKeyPair } from 'internxt-crypto'; +import { decryptEmailHybrid, encryptEmailHybridForMultipleRecipients } from 'internxt-crypto/email-crypto'; import type { EmailResponse, EncryptionBlock } from '@internxt/sdk/dist/mail/types'; import { BuildEncryptionBlockError, EnvelopeDecryptionError } from '@/errors/mail'; import { MailKeysService } from '../mail-keys'; @@ -22,6 +16,9 @@ interface EncryptedAttachment { export type DecryptedMailBody = | { ok: true; text: string; envelope: EncryptionBlock | null; isEncrypted: boolean } | { ok: false; decryptError: string; isEncrypted: true; reason: 'no-keys' | 'decrypt-failed' }; + +type EncryptedBodyPayload = { body: string; attachmentsSessionKey: string }; + const PREVIEW_PLAINTEXT_LENGTH = 256; export const ENCRYPTED_EMAIL_PREFIX = 'INTERNXT-ENCRYPTED-EMAIL-v1'; @@ -30,22 +27,6 @@ function buildPreviewSnippet(previewText: string): string { return previewText.replace(/\s+/g, ' ').trim().slice(0, PREVIEW_PLAINTEXT_LENGTH); } -function secureShuffle(items: T[]): T[] { - const rand = new Uint32Array(1); - for (let i = items.length - 1; i > 0; i--) { - const range = i + 1; - const limit = Math.floor(0x1_00_00_00_00 / range) * range; - let value: number; - do { - crypto.getRandomValues(rand); - value = rand[0]; - } while (value >= limit); - const j = value % range; - [items[i], items[j]] = [items[j], items[i]]; - } - return items; -} - export class MailEncryptionService { public static readonly instance: MailEncryptionService = new MailEncryptionService(); @@ -55,14 +36,16 @@ export class MailEncryptionService { * Only the body and preview are encrypted; the subject travels as cleartext so * the backend can index it. * - * Both `wrappedKeys` and `attachmentWrappedKeys` ship as de-identified, - * order-randomized arrays carrying no recipient address, so the envelope - * hides the recipient set (Bcc included) — each recipient finds their entry - * by trial decryption (see `decryptEnvelope`). + * Every wrapped key is labeled with the recipient address it was encrypted + * for (`encryptedForEmail`), so each recipient finds their entry with a + * direct lookup — no trial decryption. Because the labels expose the full + * recipient set to anyone holding the envelope, Bcc recipients must not be + * part of an encrypted send (compose enforces this). * - * `attachmentsSessionKey` is the symmetric key used to encrypt every - * attachment blob in this email. It is wrapped per-recipient in - * `attachmentWrappedKeys`, kept separate from the body key on purpose. + * The body payload carries the attachments session key inside the ciphertext, + * so one wrapped key per recipient covers the body and every attachment. The + * preview snippet is sealed separately (`previewWrappedKeys`) so list + * summaries can travel without the full body ciphertext. */ async buildEncryptionBlock( content: EmailContent, @@ -73,35 +56,33 @@ export class MailEncryptionService { throw new BuildEncryptionBlockError(); } - const { encEmail, encryptionKey } = await encryptEmail({ text: content.body }); + const recipientsWithKeys = recipients.map((r) => ({ + email: r.address, + publicHybridKey: base64ToUint8Array(r.publicKey), + })); - const { encText: encryptedPreview } = await encryptEmailWithKey( - { text: buildPreviewSnippet(content.previewText) }, - encryptionKey, - ); + const payload: EncryptedBodyPayload = { + body: content.body, + attachmentsSessionKey: uint8ArrayToBase64(attachmentsSessionKey), + }; - const [wrappedKeys, attachmentWrappedKeys] = await Promise.all([ - Promise.all(recipients.map((r) => this.wrapKeyForRecipient(encryptionKey, r))).then(secureShuffle), - Promise.all(recipients.map((r) => this.wrapKeyForRecipient(attachmentsSessionKey, r))).then(secureShuffle), + const [encryptedBodies, encryptedPreviews] = await Promise.all([ + encryptEmailHybridForMultipleRecipients({ text: JSON.stringify(payload) }, recipientsWithKeys), + encryptEmailHybridForMultipleRecipients( + { text: buildPreviewSnippet(content.previewText) || ' ' }, + recipientsWithKeys, + ), ]); return { - version: 'v1', - encryptedText: encEmail.encText, - encryptedPreview, - wrappedKeys, - attachmentWrappedKeys, + version: 'v2', + encryptedText: encryptedBodies[0].encEmail.encText, + wrappedKeys: encryptedBodies.map((e) => e.encryptedKey), + encryptedPreview: encryptedPreviews[0].encEmail.encText, + previewWrappedKeys: encryptedPreviews.map((e) => e.encryptedKey), }; } - private async wrapKeyForRecipient(key: Uint8Array, recipient: RecipientPublicKey): Promise { - const enc = await encryptKeysHybrid(key, { - email: recipient.address, - publicHybridKey: base64ToUint8Array(recipient.publicKey), - }); - return { hybridCiphertext: enc.hybridCiphertext, encryptedKey: enc.encryptedKey }; - } - isEncryptedEmailBody(textBody: string | null | undefined): boolean { if (!textBody) return false; return textBody.startsWith(`${ENCRYPTED_EMAIL_PREFIX}\n`); @@ -114,48 +95,50 @@ export class MailEncryptionService { } /** - * Trial-decrypts a ciphertext sealed with the shared body key. The wrapped keys - * carry no recipient identifier, so we try each one and keep the entry whose key - * yields a valid AEAD tag. + * Finds the caller's wrapped key by its `encryptedForEmail` label and + * decrypts a ciphertext sealed with the shared session key. * - * @throws if none decrypt — the caller is not a recipient or holds the wrong key. + * @throws {EnvelopeDecryptionError} if no wrapped key is labeled for the caller. */ - private async trialDecrypt( + private async decryptForCaller( wrappedKeys: WrappedKey[], ciphertextB64: string, keypair: HybridKeyPair, + address: string, ): Promise { - for (const wrapped of wrappedKeys) { - try { - const bodyKey = await decryptKeysHybrid( - { hybridCiphertext: wrapped.hybridCiphertext, encryptedKey: wrapped.encryptedKey, encryptedForEmail: '' }, - keypair.secretKey, - ); - const { text } = await decryptEmail({ encText: ciphertextB64 }, bodyKey); - return text; - } catch { - // No op, try the next one. - } + const normalized = address.toLowerCase(); + const wrapped = wrappedKeys.find((k) => k.encryptedForEmail?.toLowerCase() === normalized); + if (!wrapped) { + throw new EnvelopeDecryptionError(); } - throw new EnvelopeDecryptionError(); + const { text } = await decryptEmailHybrid( + { encryptedKey: wrapped, encEmail: { encText: ciphertextB64 } }, + keypair.secretKey, + ); + return text; + } + + private parseBodyPayload(text: string): EncryptedBodyPayload { + return JSON.parse(text) as EncryptedBodyPayload; } /** * Decrypts the email body from its envelope using the caller's keypair. * @returns the cleartext body. - * @throws {EnvelopeDecryptionError} if the caller is not a recipient (see `trialDecrypt`). + * @throws {EnvelopeDecryptionError} if no wrapped key is labeled for the caller. */ - decryptEnvelope(envelope: EncryptionBlock, keypair: HybridKeyPair): Promise { - return this.trialDecrypt(envelope.wrappedKeys, envelope.encryptedText, keypair); + async decryptEnvelope(envelope: EncryptionBlock, keypair: HybridKeyPair, address: string): Promise { + const text = await this.decryptForCaller(envelope.wrappedKeys, envelope.encryptedText, keypair, address); + return this.parseBodyPayload(text).body; } /** * Decrypts the list preview snippet from an encrypted summary using the caller's keypair. * @returns the cleartext preview snippet. - * @throws {EnvelopeDecryptionError} if the caller is not a recipient (see `trialDecrypt`). + * @throws {EnvelopeDecryptionError} if no wrapped key is labeled for the caller. */ - decryptSummaryPreview(summary: EncryptedSummary, keypair: HybridKeyPair): Promise { - return this.trialDecrypt(summary.wrappedKeys, summary.encryptedPreview, keypair); + decryptSummaryPreview(summary: EncryptedSummary, keypair: HybridKeyPair, address: string): Promise { + return this.decryptForCaller(summary.wrappedKeys, summary.encryptedPreview, keypair, address); } /** @@ -173,13 +156,14 @@ export class MailEncryptionService { } const senderKeys = MailKeysService.instance.getCurrentKeys(); - if (!senderKeys) { + const senderAddress = MailKeysService.instance.getCurrentAddress(); + if (!senderKeys || !senderAddress) { return { ok: false, isEncrypted: true, decryptError: 'No sender keys received', reason: 'no-keys' }; } try { const envelope = this.parseEncryptionBlock(rawBody); - const text = await this.decryptEnvelope(envelope, senderKeys); + const text = await this.decryptEnvelope(envelope, senderKeys, senderAddress); return { ok: true, text, envelope, isEncrypted: true }; } catch (error) { const castedError = ErrorService.instance.castError(error); @@ -188,32 +172,24 @@ export class MailEncryptionService { } } - /** - * Trial-decrypts a wrapped key array and returns the raw key bytes. Same - * de-identified semantics as `trialDecrypt`, but stops at the unwrapped key - * rather than continuing to decrypt a payload with it. - */ - private async trialDecryptKey(wrappedKeys: WrappedKey[], keypair: HybridKeyPair): Promise { - for (const wrapped of wrappedKeys) { - try { - return await decryptKeysHybrid( - { hybridCiphertext: wrapped.hybridCiphertext, encryptedKey: wrapped.encryptedKey, encryptedForEmail: '' }, - keypair.secretKey, - ); - } catch { - // No op, try the next one. - } - } - throw new EnvelopeDecryptionError(); - } - /** * Recovers the symmetric session key used to encrypt every attachment in the - * email. Pair with `decryptSymmetrically` over the downloaded blob bytes. - * @throws {EnvelopeDecryptionError} if the caller is not a recipient. + * email. It travels inside the encrypted body payload, so this decrypts the + * body and extracts it. Pair with `decryptSymmetrically` over the downloaded + * blob bytes. + * @throws {EnvelopeDecryptionError} if no wrapped key is labeled for the caller. */ - decryptAttachmentsSessionKey(envelope: EncryptionBlock, keypair: HybridKeyPair): Promise { - return this.trialDecryptKey(envelope.attachmentWrappedKeys, keypair); + async decryptAttachmentsSessionKey( + envelope: EncryptionBlock, + keypair: HybridKeyPair, + address: string, + ): Promise { + const text = await this.decryptForCaller(envelope.wrappedKeys, envelope.encryptedText, keypair, address); + const { attachmentsSessionKey } = this.parseBodyPayload(text); + if (!attachmentsSessionKey) { + throw new EnvelopeDecryptionError(); + } + return base64ToUint8Array(attachmentsSessionKey); } /** diff --git a/src/services/sdk/mail/mail.service.test.ts b/src/services/sdk/mail/mail.service.test.ts index b8f2021..710b685 100644 --- a/src/services/sdk/mail/mail.service.test.ts +++ b/src/services/sdk/mail/mail.service.test.ts @@ -323,11 +323,11 @@ describe('Mail Service', () => { to: [{ email: 'bob@inxt.me' }], subject: 'Weekly sync notes', encryption: { - version: 'v1' as const, + version: 'v2' as const, encryptedText: 'enc-text', + wrappedKeys: [{ hybridCiphertext: 'ct', encryptedKey: 'ek', encryptedForEmail: 'bob@inxt.me' }], encryptedPreview: 'enc-preview', - wrappedKeys: [{ hybridCiphertext: 'ct', encryptedKey: 'ek' }], - attachmentWrappedKeys: [{ hybridCiphertext: 'ct', encryptedKey: 'ek' }], + previewWrappedKeys: [{ hybridCiphertext: 'ct', encryptedKey: 'ek', encryptedForEmail: 'bob@inxt.me' }], }, }; const mockMailClient = {