From f8b8e0b10813c23f6314911c824fd7f45b78ff64 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 28 Jun 2026 19:13:02 +0200 Subject: [PATCH] refactor: move S/MIME to generic crypto plugin hooks --- app/(main)/[locale]/settings/page.tsx | 8 - components/contacts/contact-detail.tsx | 130 +-- .../__tests__/recipient-chip-drag.test.tsx | 21 - .../email/__tests__/recipient-paste.test.tsx | 21 - components/email/email-composer.tsx | 294 +----- components/email/email-viewer.tsx | 999 ++---------------- components/email/smime-status-banner.tsx | 138 --- .../settings/smime-certificate-modal.tsx | 117 -- .../settings/smime-passphrase-dialog.tsx | 169 --- components/settings/smime-settings.tsx | 549 ---------- lib/account-state-manager.ts | 2 - lib/smime/__tests__/certificate-utils.test.ts | 214 ---- lib/smime/__tests__/key-storage.test.ts | 197 ---- lib/smime/__tests__/mime-builder.test.ts | 259 ----- lib/smime/__tests__/pkcs12.test.ts | 219 ---- lib/smime/__tests__/smime-crypto.test.ts | 361 ------- lib/smime/__tests__/smime-detect.test.ts | 193 ---- lib/smime/__tests__/smime-store.test.ts | 360 ------- lib/smime/certificate-utils.ts | 263 ----- lib/smime/crypto-engine.ts | 301 ------ lib/smime/key-storage.ts | 118 --- lib/smime/mime-builder.ts | 339 ------ lib/smime/pkcs12-export.ts | 157 --- lib/smime/pkcs12-import.ts | 341 ------ lib/smime/smime-decrypt.ts | 422 -------- lib/smime/smime-detect.ts | 194 ---- lib/smime/smime-encrypt.ts | 80 -- lib/smime/smime-sign.ts | 70 -- lib/smime/smime-verify.ts | 225 ---- lib/smime/types.ts | 84 -- package-lock.json | 15 - stores/smime-store.ts | 386 ------- 32 files changed, 133 insertions(+), 7113 deletions(-) delete mode 100644 components/email/smime-status-banner.tsx delete mode 100644 components/settings/smime-certificate-modal.tsx delete mode 100644 components/settings/smime-passphrase-dialog.tsx delete mode 100644 components/settings/smime-settings.tsx delete mode 100644 lib/smime/__tests__/certificate-utils.test.ts delete mode 100644 lib/smime/__tests__/key-storage.test.ts delete mode 100644 lib/smime/__tests__/mime-builder.test.ts delete mode 100644 lib/smime/__tests__/pkcs12.test.ts delete mode 100644 lib/smime/__tests__/smime-crypto.test.ts delete mode 100644 lib/smime/__tests__/smime-detect.test.ts delete mode 100644 lib/smime/__tests__/smime-store.test.ts delete mode 100644 lib/smime/certificate-utils.ts delete mode 100644 lib/smime/crypto-engine.ts delete mode 100644 lib/smime/key-storage.ts delete mode 100644 lib/smime/mime-builder.ts delete mode 100644 lib/smime/pkcs12-export.ts delete mode 100644 lib/smime/pkcs12-import.ts delete mode 100644 lib/smime/smime-decrypt.ts delete mode 100644 lib/smime/smime-detect.ts delete mode 100644 lib/smime/smime-encrypt.ts delete mode 100644 lib/smime/smime-sign.ts delete mode 100644 lib/smime/smime-verify.ts delete mode 100644 lib/smime/types.ts delete mode 100644 stores/smime-store.ts diff --git a/app/(main)/[locale]/settings/page.tsx b/app/(main)/[locale]/settings/page.tsx index f2137685..2a10f986 100644 --- a/app/(main)/[locale]/settings/page.tsx +++ b/app/(main)/[locale]/settings/page.tsx @@ -21,7 +21,6 @@ import { Tags, HardDrive, BookUser, - KeyRound, PanelLeftClose, Bell, Puzzle, @@ -63,7 +62,6 @@ import { AccountSecuritySettings } from '@/components/settings/account-security- import { FilesSettingsComponent } from '@/components/settings/files-settings'; import { DownloadsSettings } from '@/components/settings/downloads-settings'; import { ContactsSettings } from '@/components/settings/contacts-settings'; -import { SmimeSettings } from '@/components/settings/smime-settings'; import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings'; import { NotificationSettings } from '@/components/settings/notification-settings'; import { ThemesSettings } from '@/components/settings/themes-settings'; @@ -102,7 +100,6 @@ type Tab = | 'folders' | 'keywords' | 'security' - | 'encryption' | 'content_senders' | 'calendar' | 'contacts' @@ -139,7 +136,6 @@ const tabIcons: Record = { folders: FolderOpen, keywords: Tags, security: Shield, - encryption: KeyRound, content_senders: EyeOff, calendar: Calendar, contacts: BookUser, @@ -217,7 +213,6 @@ const tabSearchPaths: Record = { folders: ['settings.folders'], keywords: ['settings.keywords'], security: ['settings.security'], - encryption: ['smime'], content_senders: [ 'settings.email_behavior.always_light_mode', 'settings.email_behavior.external_content', @@ -252,7 +247,6 @@ const tabKeywords: Record = { folders: 'mailbox subscribe', keywords: 'tags labels colors', security: 'password 2fa two-factor passkey app password mfa', - encryption: 's/mime smime certificate pgp gpg', content_senders: 'block sender remote images privacy tracking', calendar: 'event schedule appointment meeting timezone', contacts: 'address book contact', @@ -623,7 +617,6 @@ export default function SettingsPage() { // Privacy & Security ...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security'), icon: tabIcons.security, group: 'privacy' as TabGroup }] : []), - ...(isFeatureEnabled('smimeEnabled') ? [{ id: 'encryption' as Tab, label: t('tabs.encryption'), icon: tabIcons.encryption, group: 'privacy' as TabGroup }] : []), { id: 'content_senders', label: t('tabs.content_senders'), icon: tabIcons.content_senders, group: 'privacy' }, // Apps @@ -743,7 +736,6 @@ export default function SettingsPage() { {effectiveActiveTab === 'folders' && } {effectiveActiveTab === 'keywords' && } {effectiveActiveTab === 'security' && } - {effectiveActiveTab === 'encryption' && } {effectiveActiveTab === 'content_senders' && } {effectiveActiveTab === 'calendar' && ( managedAccountId diff --git a/components/contacts/contact-detail.tsx b/components/contacts/contact-detail.tsx index 2dc70b10..24292071 100644 --- a/components/contacts/contact-detail.tsx +++ b/components/contacts/contact-detail.tsx @@ -2,16 +2,13 @@ import { useState, useEffect, useRef } from "react"; import { useTranslations } from "next-intl"; -import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, ShieldCheck, ShieldAlert, Download, MoreHorizontal, Printer } from "lucide-react"; +import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, Download, MoreHorizontal, Printer } from "lucide-react"; import { Avatar } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import type { ContactCard, AnniversaryDate, PartialDate } from "@/lib/jmap/types"; import { getContactDisplayName, getContactPrimaryEmail, getContactPhotoUri } from "@/stores/contact-store"; import { ContactActivity } from "./contact-activity"; -import { useSmimeStore } from "@/stores/smime-store"; -import { parseCertificatePemOrDer, extractCertificateInfo } from "@/lib/smime/certificate-utils"; -import type { CertificateInfo } from "@/lib/smime/types"; import { toast } from "@/stores/toast-store"; import { exportContact } from "./contact-export"; import { printContact } from "./contact-print"; @@ -119,49 +116,9 @@ function formatDate(dateInput: AnniversaryDate): string { export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, onCompose, isMobile, className }: ContactDetailProps) { const t = useTranslations("contacts"); - const smimeStore = useSmimeStore(); - const [parsedCerts, setParsedCerts] = useState>(new Map()); const cryptoKeys = contact?.cryptoKeys ? Object.values(contact.cryptoKeys) : []; - useEffect(() => { - if (!contact) return; - let cancelled = false; - const parseCerts = async () => { - const results = new Map(); - for (let i = 0; i < cryptoKeys.length; i++) { - const key = cryptoKeys[i]; - if (typeof key.uri !== 'string') continue; - try { - let derBytes: ArrayBuffer | string | null = null; - if (key.uri.startsWith('data:')) { - const commaIdx = key.uri.indexOf(','); - if (commaIdx === -1) continue; - const b64 = key.uri.substring(commaIdx + 1); - const binary = atob(b64); - const bytes = new Uint8Array(binary.length); - for (let j = 0; j < binary.length; j++) bytes[j] = binary.charCodeAt(j); - derBytes = bytes.buffer; - } else if (key.uri.startsWith('-----BEGIN')) { - derBytes = key.uri; - } - if (!derBytes) continue; - const cert = parseCertificatePemOrDer(derBytes); - const der = typeof derBytes === 'string' ? cert.toSchema(true).toBER(false) : derBytes; - const info = await extractCertificateInfo(cert, der); - if (!cancelled) results.set(i, info); - } catch { /* skip unparseable keys */ } - } - if (!cancelled) setParsedCerts(results); - }; - if (cryptoKeys.length > 0) { - parseCerts(); - } else { - setParsedCerts(new Map()); - } - return () => { cancelled = true; }; - }, [contact?.id]); // eslint-disable-line react-hooks/exhaustive-deps - if (!contact) { return (
@@ -207,30 +164,6 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli const anniversaries = contact.anniversaries ? Object.values(contact.anniversaries) : []; const keywords = contact.keywords ? Object.keys(contact.keywords).filter(k => contact.keywords![k]) : []; - const handleImportContactCert = async (keyIndex: number) => { - const key = cryptoKeys[keyIndex]; - if (!key?.uri || typeof key.uri !== 'string') return; - try { - let derBytes: ArrayBuffer | string; - if (key.uri.startsWith('data:')) { - const commaIdx = key.uri.indexOf(','); - if (commaIdx === -1) return; - const b64 = key.uri.substring(commaIdx + 1); - const binary = atob(b64); - const bytes = new Uint8Array(binary.length); - for (let j = 0; j < binary.length; j++) bytes[j] = binary.charCodeAt(j); - derBytes = bytes.buffer; - } else if (key.uri.startsWith('-----BEGIN')) { - derBytes = key.uri; - } else { - return; - } - await smimeStore.importPublicCert(derBytes, 'contact', contact.id); - toast.success(t("detail.cert_imported")); - } catch (err) { - toast.error(err instanceof Error ? err.message : t("detail.cert_import_failed")); - } - }; const relatedTo = contact.relatedTo ? Object.entries(contact.relatedTo) : []; const preferredLanguages = contact.preferredLanguages ? Object.values(contact.preferredLanguages) : []; const personalInfo = contact.personalInfo ? Object.values(contact.personalInfo) : []; @@ -493,59 +426,20 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli {cryptoKeys.length > 0 && (
- {cryptoKeys.map((key, i) => { - const certInfo = parsedCerts.get(i); - const isExpired = certInfo ? new Date(certInfo.notAfter) < new Date() : false; - const alreadyImported = certInfo?.emailAddresses?.[0] - ? !!smimeStore.getPublicCertForEmail(certInfo.emailAddresses[0]) - : false; - - return ( -
- {certInfo ? ( - <> -
- {isExpired ? ( - - ) : ( - - )} - {certInfo.subject} -
-
-

{t("detail.cert_issuer")}: {certInfo.issuer}

-

- {t("detail.cert_expires")}: {new Date(certInfo.notAfter).toLocaleDateString()} - {isExpired && ({t("detail.cert_expired")})} -

-

{t("detail.cert_fingerprint")}: {certInfo.fingerprint.substring(0, 20)}...

- {certInfo.algorithm &&

{t("detail.cert_algorithm")}: {certInfo.algorithm}

} -
- {!alreadyImported && ( - - )} - {alreadyImported && ( -

{t("detail.cert_already_imported")}

- )} - + {cryptoKeys.map((key, i) => ( +
+
+ + {typeof key.uri === 'string' && key.uri.startsWith("http") ? ( + + {key.uri} + ) : ( -
- - {typeof key.uri === 'string' && key.uri.startsWith("http") ? ( - - {key.uri} - - ) : ( - {typeof key.uri === 'string' ? `${key.uri.substring(0, 80)}${key.uri.length > 80 ? "…" : ""}` : String(key.uri ?? '')} - )} -
+ {typeof key.uri === 'string' ? `${key.uri.substring(0, 80)}${key.uri.length > 80 ? "…" : ""}` : String(key.uri ?? '')} )}
- ); - })} +
+ ))}
)} diff --git a/components/email/__tests__/recipient-chip-drag.test.tsx b/components/email/__tests__/recipient-chip-drag.test.tsx index 10ff1f33..461b4452 100644 --- a/components/email/__tests__/recipient-chip-drag.test.tsx +++ b/components/email/__tests__/recipient-chip-drag.test.tsx @@ -67,21 +67,6 @@ vi.mock('@/stores/account-store', () => { return { useAccountStore: hook }; }); -vi.mock('@/stores/smime-store', () => { - const state = { - certs: [], - signingEnabled: false, - encryptionEnabled: false, - defaultSigningCertId: null, - defaultEncryptionCertId: null, - }; - const hook = (sel?: (s: typeof state) => unknown) => - typeof sel === 'function' ? sel(state) : state; - hook.getState = () => state; - hook.setState = (p: Partial) => Object.assign(state, p); - return { useSmimeStore: hook }; -}); - vi.mock('@/stores/email-store', () => { const state = { draftSaveEnabled: false, @@ -172,12 +157,6 @@ vi.mock('@/lib/signature-utils', () => ({ getPlainTextSignature: () => '', })); vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' })); -vi.mock('@/lib/smime/smime-sign', () => ({ smimeSign: async () => null })); -vi.mock('@/lib/smime/smime-encrypt', () => ({ smimeEncrypt: async () => null })); -vi.mock('@/lib/smime/mime-builder', () => ({ - buildMimeMessage: () => null, - wrapCmsAsSmimeMessage: () => null, -})); vi.mock('@/lib/debug', () => ({ debug: () => {} })); vi.mock('@/components/email/quoted-html', () => ({ buildQuotedHtmlBlock: () => '', diff --git a/components/email/__tests__/recipient-paste.test.tsx b/components/email/__tests__/recipient-paste.test.tsx index aa07bf78..1a3ccef6 100644 --- a/components/email/__tests__/recipient-paste.test.tsx +++ b/components/email/__tests__/recipient-paste.test.tsx @@ -66,21 +66,6 @@ vi.mock('@/stores/account-store', () => { return { useAccountStore: hook }; }); -vi.mock('@/stores/smime-store', () => { - const state = { - certs: [], - signingEnabled: false, - encryptionEnabled: false, - defaultSigningCertId: null, - defaultEncryptionCertId: null, - }; - const hook = (sel?: (s: typeof state) => unknown) => - typeof sel === 'function' ? sel(state) : state; - hook.getState = () => state; - hook.setState = (p: Partial) => Object.assign(state, p); - return { useSmimeStore: hook }; -}); - vi.mock('@/stores/email-store', () => { const state = { draftSaveEnabled: false, @@ -171,12 +156,6 @@ vi.mock('@/lib/signature-utils', () => ({ getPlainTextSignature: () => '', })); vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' })); -vi.mock('@/lib/smime/smime-sign', () => ({ smimeSign: async () => null })); -vi.mock('@/lib/smime/smime-encrypt', () => ({ smimeEncrypt: async () => null })); -vi.mock('@/lib/smime/mime-builder', () => ({ - buildMimeMessage: () => null, - wrapCmsAsSmimeMessage: () => null, -})); vi.mock('@/lib/debug', () => ({ debug: () => {} })); vi.mock('@/components/email/quoted-html', () => ({ buildQuotedHtmlBlock: () => '', diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 9456d5c2..23b24ae8 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -5,7 +5,7 @@ import { useFocusTrap } from "@/hooks/use-focus-trap"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, ShieldCheck, Lock, CalendarClock, ChevronDown, MailCheck } from "lucide-react"; +import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, CalendarClock, ChevronDown, MailCheck } from "lucide-react"; import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils"; import { debug } from "@/lib/debug"; import { toast } from "@/stores/toast-store"; @@ -22,16 +22,10 @@ import { useAuthStore } from "@/stores/auth-store"; import { useIdentityStore } from "@/stores/identity-store"; import { useProMultiAccountIdentities, stripCrossAccountIdentityPrefix } from "@/hooks/use-pro-multi-account-identities"; import { useAccountStore } from "@/stores/account-store"; -import { useSmimeStore } from "@/stores/smime-store"; -import { useEmailStore } from "@/stores/email-store"; import { useSettingsStore } from "@/stores/settings-store"; -import { buildMimeMessage, wrapCmsAsSmimeMessage } from "@/lib/smime/mime-builder"; -import type { MimeAttachment } from "@/lib/smime/mime-builder"; -import { smimeSign } from "@/lib/smime/smime-sign"; import { PluginSlot } from "@/components/plugins/plugin-slot"; import { Avatar } from "@/components/ui/avatar"; import { FilePreviewModal } from "@/components/files/file-preview-modal"; -import { smimeEncrypt } from "@/lib/smime/smime-encrypt"; import { useContactStore } from "@/stores/contact-store"; import { useTemplateStore } from "@/stores/template-store"; import { SubAddressHelper } from "@/components/identity/sub-address-helper"; @@ -522,11 +516,6 @@ export function EmailComposer({ const [showCloseDialog, setShowCloseDialog] = useState(false); const [showAllAttachments, setShowAllAttachments] = useState(false); const [previewAttachment, setPreviewAttachment] = useState(null); - const [smimeSign_, setSmimeSign] = useState(false); - const [smimeEncrypt_, setSmimeEncrypt] = useState(false); - const [smimePassphrasePrompt, setSmimePassphrasePrompt] = useState<{ keyId: string; resolve: (passphrase: string) => void; reject: () => void } | null>(null); - const [smimePassphraseInput, setSmimePassphraseInput] = useState(''); - const [smimePassphraseError, setSmimePassphraseError] = useState(''); const [showAttachmentWarning, setShowAttachmentWarning] = useState(false); const [attachmentWarningKeyword, setAttachmentWarningKeyword] = useState(''); const [attachmentWarningDelayedUntil, setAttachmentWarningDelayedUntil] = useState(); @@ -802,34 +791,9 @@ export function EmailComposer({ const addTrustedSender = useSettingsStore((s) => s.addTrustedSender); const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook); const addTemplate = useTemplateStore((s) => s.addTemplate); - const sendRawEmail = useEmailStore((s) => s.sendRawEmail); - const smimeStore = useSmimeStore(); - - // Determine S/MIME availability for the selected identity - const currentSmimeIdentityId = selectedIdentityId || primaryIdentity?.id; - const smimeKeyRecord = currentSmimeIdentityId ? smimeStore.getKeyRecordForIdentity(currentSmimeIdentityId) : undefined; - const canSmimeSign = !!smimeKeyRecord; - const canSmimeEncrypt = (() => { - if (!smimeKeyRecord) return false; - const allRecipients = [ - ...withInput(to, toInput), - ...withInput(cc, ccInput), - ...withInput(bcc, bccInput), - ].map(r => r.email); - if (allRecipients.length === 0) return false; - const { missing } = smimeStore.getRecipientCerts(allRecipients); - return missing.length === 0; - })(); - - // Initialize S/MIME defaults from store when identity changes - useEffect(() => { - if (currentSmimeIdentityId) { - setSmimeSign(!!smimeStore.defaultSignIdentity[currentSmimeIdentityId] && canSmimeSign); - } - setSmimeEncrypt(smimeStore.defaultEncrypt && canSmimeEncrypt); - // Only run when identity changes, not on every recipient edit - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [currentSmimeIdentityId]); + // Sign/encrypt is provided by crypto plugins (S/MIME, PGP) via the + // composer-toolbar slot + the onComposeSend hook — the host stays + // crypto-agnostic. // Serialized recipient strings for ComposerDraftData (string-shaped) and for // by-value dirty comparison. Folds in any uncommitted typed text. @@ -1648,145 +1612,39 @@ export function EmailComposer({ const sendAllowed = await emailHooks.onBeforeEmailSend.intercept(sendablePreview); if (!sendAllowed) return; - // S/MIME send pipeline: build raw MIME → sign → encrypt → sendRawEmail - if ((smimeSign_ || smimeEncrypt_) && client && currentIdentity?.id) { - // S/MIME keys are scoped to one JMAP account's identity - sending - // from a cross-account identity via S/MIME would mix accounts' - // certs/clients. Refuse upfront and tell the user to switch. - const crossAccount = stripCrossAccountIdentityPrefix(currentIdentity.id); - if (crossAccount.localAccountId) { - throw new Error('S/MIME sending from another account’s identity is not supported. Switch to that account first.'); - } - // 1. Resolve S/MIME key - if (smimeSign_ && !smimeKeyRecord) { - throw new Error('No S/MIME key bound to this identity'); - } - // S/MIME binds to the identity's key; sending from an override address - // would produce a signature whose Subject differs from the visible - // From, which most clients reject or flag. Refuse up front. - if (overrideActive) { - throw new Error('Cannot use From override with S/MIME - disable one to send.'); - } - - // 2. Ensure key is unlocked for signing - if (smimeSign_ && smimeKeyRecord && !smimeStore.isKeyUnlocked(smimeKeyRecord.id)) { - const passphrase = await new Promise((resolve, reject) => { - setSmimePassphrasePrompt({ keyId: smimeKeyRecord.id, resolve, reject }); - }); - try { - await smimeStore.unlockKey(smimeKeyRecord.id, passphrase); - } finally { - setSmimePassphrasePrompt(null); - setSmimePassphraseInput(''); - setSmimePassphraseError(''); - } - } - - // 3. Resolve attachments as ArrayBuffers - const mimeAttachments: MimeAttachment[] = []; - for (const att of attachments) { - if (att.error || att.uploading) continue; - let content: ArrayBuffer; - if (att.file && att.file.size > 0) { - content = await att.file.arrayBuffer(); - } else if (att.blobId && client) { - content = await client.fetchBlobArrayBuffer(att.blobId, att.name, att.type); - } else { - continue; - } - mimeAttachments.push({ - filename: att.name, - contentType: att.type || 'application/octet-stream', - content, + // Hand off to a crypto plugin (S/MIME, PGP, …) if one wants to take over + // the send: it builds raw MIME, signs/encrypts, and submits via + // api.jmap.sendRaw. A handler returning false means "I sent it" — the + // host then skips its own plaintext submission but still cleans up the + // draft (and fires the scheduled-send callback for a delayed send). + const composeSendRequest = { + to: toAddresses.map(r => formatRecipient(r.name, r.email)), + cc: ccAddresses.map(r => formatRecipient(r.name, r.email)), + bcc: bccAddresses.map(r => formatRecipient(r.name, r.email)), + subject, + htmlBody: finalHtmlBody || '', + textBody: finalBody, + identityId: currentIdentity?.id || '', + fromEmail, + fromName, + inReplyTo: threadingHeaders?.inReplyTo?.[0], + references: threadingHeaders?.references, + delayedUntil: effectiveDelayedUntil, + attachments: [ + ...attachments + .filter(att => att.blobId && !att.uploading && !att.error) + .map(a => ({ name: a.name, type: a.type || 'application/octet-stream', size: a.size, blobId: a.blobId })), + ...inlineAttachments.map(a => ({ name: a.name, type: a.type, size: a.size, blobId: a.blobId, cid: a.cid })), + ], + }; + const sendHandledByPlugin = (await emailHooks.onComposeSend.intercept(composeSendRequest)) === false; + if (sendHandledByPlugin) { + if (finalDraftId) { + client?.deleteEmail(finalDraftId).catch((err) => { + debug.warn('email', 'Plugin handled the send, but draft cleanup failed:', err); }); } - for (const inline of inlineAttachments) { - if (!client) break; - const content = await client.fetchBlobArrayBuffer(inline.blobId, inline.name, inline.type); - mimeAttachments.push({ - filename: inline.name, - contentType: inline.type, - content, - cid: inline.cid, - }); - } - - // 4. Build canonical MIME - // mime-builder takes inReplyTo as a single ref-form msg-id (with brackets); - // references stays an array. threadingHeaders contains bare msg-ids. - const mimeInReplyTo = threadingHeaders?.inReplyTo[0] - ? `<${threadingHeaders.inReplyTo[0]}>` - : undefined; - const mimeReferences = threadingHeaders?.references.length - ? threadingHeaders.references.map(id => `<${id}>`) - : undefined; - const mimeBytes = buildMimeMessage({ - from: { name: currentIdentity.name || undefined, email: fromEmail || currentIdentity.email }, - to: toAddresses, - cc: ccAddresses.length > 0 ? ccAddresses : undefined, - bcc: bccAddresses.length > 0 ? bccAddresses : undefined, - subject, - inReplyTo: mimeInReplyTo, - references: mimeReferences, - textBody: finalBody, - htmlBody: finalHtmlBody, - attachments: mimeAttachments.length > 0 ? mimeAttachments : undefined, - }); - - let payload: Blob = new Blob([mimeBytes.buffer as ArrayBuffer], { type: 'message/rfc822' }); - - const smimeHeaders = { - from: { name: currentIdentity.name || undefined, email: fromEmail || currentIdentity.email }, - to: toAddresses, - cc: ccAddresses.length > 0 ? ccAddresses : undefined, - subject, - inReplyTo: mimeInReplyTo, - references: mimeReferences, - }; - - // 5. Sign if enabled - if (smimeSign_ && smimeKeyRecord) { - const privateKey = smimeStore.getUnlockedKey(smimeKeyRecord.id); - if (!privateKey) throw new Error('S/MIME key is not unlocked'); - const cmsBlob = await smimeSign( - mimeBytes, - privateKey, - smimeKeyRecord.certificate, - smimeKeyRecord.certificateChain || [], - ); - const cmsBytes = new Uint8Array(await cmsBlob.arrayBuffer()); - payload = wrapCmsAsSmimeMessage(cmsBytes, { ...smimeHeaders, smimeType: 'signed-data' }); - } - - // 6. Encrypt if enabled - if (smimeEncrypt_ && smimeKeyRecord) { - const allRecipients = [...toAddresses, ...ccAddresses, ...bccAddresses].map(r => r.email); - const { found, missing } = smimeStore.getRecipientCerts(allRecipients); - if (missing.length > 0) { - throw new Error(`Missing certificates for: ${missing.join(', ')}`); - } - const recipientCertsDer = found.map(c => c.certificate instanceof ArrayBuffer ? c.certificate : new Uint8Array(c.certificate as ArrayBuffer).buffer); - const payloadBytes = new Uint8Array(await payload.arrayBuffer()); - const cmsBlob = await smimeEncrypt( - payloadBytes, - recipientCertsDer, - smimeKeyRecord.certificate, - ); - const cmsBytes = new Uint8Array(await cmsBlob.arrayBuffer()); - payload = wrapCmsAsSmimeMessage(cmsBytes, { ...smimeHeaders, smimeType: 'enveloped-data' }); - } - - // 7. Send via raw email path - const result = await sendRawEmail(client, payload, currentIdentity.id, effectiveDelayedUntil, [...toAddresses, ...ccAddresses, ...bccAddresses].map(r => r.email)); - if (effectiveDelayedUntil && finalDraftId) { - client.deleteEmail(finalDraftId).catch(err => { - debug.warn('email', 'Scheduled S/MIME send created, but plaintext draft cleanup failed:', err); - toast.warning(t('schedule_send_cleanup_warning')); - }); - } - if (result.scheduled) { - await onScheduledSendCreated?.(); - } + if (effectiveDelayedUntil) await onScheduledSendCreated?.(); } else { // Standard JMAP send path // Collect uploaded attachment blobIds for the send request @@ -1970,7 +1828,6 @@ export function EmailComposer({ showTemplatePicker || showSaveAsTemplate || showScheduleDialog || - smimePassphrasePrompt || showAttachmentWarning || showCloseDialog ) return; @@ -2506,31 +2363,8 @@ export function EmailComposer({ > - {/* S/MIME toggles */} - {canSmimeSign && ( - <> -
- - - - )} + {/* Sign/encrypt controls are contributed by crypto plugins via the + composer-toolbar slot (rendered below). */} {/* Read-receipt request toggle */} - -
-
- - )} - {showAttachmentWarning && (
{ - const headers = new Map(); - const lines = headerText.split(/\r?\n/); - let currentKey: string | null = null; - - for (const line of lines) { - if (!line) continue; - if (/^[ \t]/.test(line) && currentKey) { - headers.set(currentKey, `${headers.get(currentKey) || ''} ${line.trim()}`.trim()); - continue; - } - - const separatorIndex = line.indexOf(':'); - if (separatorIndex <= 0) continue; - - currentKey = line.slice(0, separatorIndex).trim().toLowerCase(); - headers.set(currentKey, line.slice(separatorIndex + 1).trim()); - } - - return headers; -} - -function getMimeBoundary(contentType: string): string | null { - const match = contentType.match(/boundary=(?:"([^"]+)"|([^;\s]+))/i); - return match?.[1] || match?.[2] || null; -} - -function decodeQuotedPrintableUtf8(input: string): string { - const normalized = input.replace(/=(\r?\n)/g, ''); - const bytes: number[] = []; - - for (let index = 0; index < normalized.length; index++) { - if (normalized[index] === '=' && /^[0-9A-Fa-f]{2}$/.test(normalized.slice(index + 1, index + 3))) { - bytes.push(parseInt(normalized.slice(index + 1, index + 3), 16)); - index += 2; - continue; - } - bytes.push(normalized.charCodeAt(index) & 0xff); - } - - return new TextDecoder().decode(new Uint8Array(bytes)); -} - -function decodeBase64Utf8(input: string): string { - const cleaned = input.replace(/\s/g, ''); - if (!cleaned) return ''; - try { - const binary = atob(cleaned); - const bytes = new Uint8Array(binary.length); - for (let index = 0; index < binary.length; index++) { - bytes[index] = binary.charCodeAt(index); - } - return new TextDecoder().decode(bytes); - } catch { - return input; - } -} - function decodeBase64Bytes(input: string): Uint8Array | null { const cleaned = input.replace(/\s/g, ''); if (!cleaned) return null; @@ -327,21 +262,6 @@ function decodeBase64Bytes(input: string): Uint8Array | null { } } -function splitMimeHeadersAndBody(rawText: string): { headerText: string; bodyText: string } { - const separatorMatch = rawText.match(/\r?\n\r?\n/); - const separatorIndex = separatorMatch?.index ?? -1; - const separator = separatorMatch?.[0] ?? ''; - - if (separatorIndex < 0) { - return { headerText: '', bodyText: rawText }; - } - - return { - headerText: rawText.slice(0, separatorIndex), - bodyText: rawText.slice(separatorIndex + separator.length), - }; -} - function getAttachmentContentBytes(attachment: { content?: ArrayBuffer | Uint8Array | string; encoding?: 'base64' | 'utf8'; @@ -366,89 +286,6 @@ function getAttachmentContentBytes(attachment: { return null; } -function extractNestedSignedDataCandidate( - parsed: { attachments?: Array; headers?: Array<{ key: string; value: string }> }, - rawBytes: Uint8Array, -): { source: string; bytes: ArrayBuffer } | null { - const topLevelContentType = (parsed.headers?.find(h => h.key === 'content-type')?.value || '').toLowerCase(); - if (topLevelContentType.includes('application/pkcs7-mime') && topLevelContentType.includes('signed-data')) { - const rawText = new TextDecoder().decode(rawBytes); - const { bodyText } = splitMimeHeadersAndBody(rawText); - const topLevelTransferEncoding = ( - parsed.headers?.find(h => h.key === 'content-transfer-encoding')?.value || '' - ).toLowerCase(); - - if (topLevelTransferEncoding.includes('base64')) { - const decoded = decodeBase64Bytes(bodyText); - if (decoded) { - return { - source: 'top-level-content-type-body', - bytes: decoded.buffer.slice(decoded.byteOffset, decoded.byteOffset + decoded.byteLength) as ArrayBuffer, - }; - } - } - - const bodyBytes = new TextEncoder().encode(bodyText); - return { - source: 'top-level-content-type-body-text', - bytes: bodyBytes.buffer.slice(bodyBytes.byteOffset, bodyBytes.byteOffset + bodyBytes.byteLength) as ArrayBuffer, - }; - } - - const rawText = new TextDecoder().decode(rawBytes); - const messageContent = splitMimeHeadersAndBody(rawText).bodyText; - const { headerText, bodyText } = splitMimeHeadersAndBody(messageContent); - const bodyHeaders = parseMimeHeaders(headerText); - const bodyContentType = (bodyHeaders.get('content-type') || '').toLowerCase(); - const bodyTransferEncoding = (bodyHeaders.get('content-transfer-encoding') || '').toLowerCase(); - - if (bodyContentType.includes('application/pkcs7-mime') && bodyContentType.includes('signed-data')) { - if (bodyTransferEncoding.includes('base64')) { - const decoded = decodeBase64Bytes(bodyText); - if (decoded) { - return { - source: 'message-body-signed-data', - bytes: decoded.buffer.slice(decoded.byteOffset, decoded.byteOffset + decoded.byteLength) as ArrayBuffer, - }; - } - } - - const bodyBytes = new TextEncoder().encode(bodyText); - return { - source: 'message-body-signed-data-text', - bytes: bodyBytes.buffer.slice(bodyBytes.byteOffset, bodyBytes.byteOffset + bodyBytes.byteLength) as ArrayBuffer, - }; - } - - const nestedAttachment = parsed.attachments?.find(attachment => { - const mimeType = ((attachment as { mimeType?: string }).mimeType || '').toLowerCase(); - const filename = ((attachment as { filename?: string | null }).filename || '').toLowerCase(); - return mimeType.includes('application/pkcs7-mime') || filename.endsWith('.p7m'); - }) as { - filename?: string | null; - mimeType?: string; - encoding?: 'base64' | 'utf8'; - content?: ArrayBuffer | Uint8Array | string; - } | undefined; - - if (!nestedAttachment) { - return null; - } - - const attachmentBytes = getAttachmentContentBytes(nestedAttachment); - if (!attachmentBytes) { - return null; - } - - return { - source: nestedAttachment.mimeType || nestedAttachment.filename || 'attachment-signed-data', - bytes: attachmentBytes.buffer.slice( - attachmentBytes.byteOffset, - attachmentBytes.byteOffset + attachmentBytes.byteLength, - ) as ArrayBuffer, - }; -} - /** * Check if an HTML body string is effectively empty (just boilerplate/whitespace). * Outlook often generates HTML bodies with Word CSS +   but no real text. @@ -464,106 +301,6 @@ function isHtmlBodyEffectivelyEmpty(html: string): boolean { return textContent.length === 0; } -function extractMimePartContent(rawText: string, depth = 0): { html: string | null; text: string | null } { - if (depth > 6) { - const trimmed = rawText.trim(); - return { html: null, text: trimmed || null }; - } - - const separatorMatch = rawText.match(/\r?\n\r?\n/); - const separatorIndex = separatorMatch?.index ?? -1; - const separator = separatorMatch?.[0] ?? ''; - - const headerText = separatorIndex >= 0 ? rawText.slice(0, separatorIndex) : ''; - const bodyText = separatorIndex >= 0 ? rawText.slice(separatorIndex + separator.length) : rawText; - const headers = parseMimeHeaders(headerText); - const contentType = (headers.get('content-type') || '').toLowerCase(); - const transferEncoding = (headers.get('content-transfer-encoding') || '').toLowerCase(); - - if (contentType.includes('multipart/')) { - const boundary = getMimeBoundary(contentType); - if (boundary) { - const boundaryMarker = `--${boundary}`; - const sections = bodyText.split(boundaryMarker); - let bestHtml: string | null = null; - let bestText: string | null = null; - - for (const section of sections) { - const trimmedSection = section.trim(); - if (!trimmedSection || trimmedSection === '--') continue; - const normalizedSection = trimmedSection.endsWith('--') - ? trimmedSection.slice(0, -2).trim() - : trimmedSection; - const extracted = extractMimePartContent(normalizedSection, depth + 1); - if (extracted.html && !bestHtml) { - bestHtml = extracted.html; - } - if (extracted.text && !bestText) { - bestText = extracted.text; - } - if (bestHtml && bestText) break; - } - - return { html: bestHtml, text: bestText }; - } - } - - if (contentType.includes('message/rfc822')) { - return extractMimePartContent(bodyText, depth + 1); - } - - let decodedBody = bodyText; - if (transferEncoding.includes('quoted-printable')) { - decodedBody = decodeQuotedPrintableUtf8(bodyText); - } else if (transferEncoding.includes('base64')) { - decodedBody = decodeBase64Utf8(bodyText); - } - - const trimmedBody = decodedBody.trim(); - if (!trimmedBody) { - return { html: null, text: null }; - } - - if (contentType.includes('text/html')) { - return { html: decodedBody, text: null }; - } - - if (contentType.includes('text/plain')) { - return { html: null, text: decodedBody }; - } - - if (/^\s* }, - rawBytes: Uint8Array, -): { html: string | null; text: string | null; fallbackUsed: boolean } { - const parsedHtml = parsed.html?.trim() ? parsed.html : null; - const parsedText = parsed.text?.trim() ? parsed.text : null; - - if (parsedHtml || parsedText) { - return { html: parsedHtml, text: parsedText, fallbackUsed: false }; - } - - const rawText = new TextDecoder().decode(rawBytes); - const fallback = extractMimePartContent(rawText); - if (fallback.html || fallback.text) { - return { html: fallback.html, text: fallback.text, fallbackUsed: true }; - } - - const trimmed = rawText.trim(); - return { - html: null, - text: trimmed || null, - fallbackUsed: !!trimmed, - }; -} - interface EffectiveAttachment { id: string; name: string | null; @@ -911,7 +648,6 @@ export function EmailViewer({ const tComposer = useTranslations('email_composer'); const tNotifications = useTranslations('notifications'); const tCommon = useTranslations('common'); - const tSmime = useTranslations('smime'); const tFiles = useTranslations('files'); const tDemoWelcome = useTranslations('demo_welcome'); const tWelcome = useTranslations('welcome'); @@ -1029,15 +765,12 @@ export function EmailViewer({ const currentColors = getCurrentColors(email?.keywords); const currentColor = currentColors[0] ?? null; - // S/MIME state - const [smimeStatus, setSmimeStatus] = useState(null); - const [smimeDecryptedHtml, setSmimeDecryptedHtml] = useState(null); - const [smimeDecryptedText, setSmimeDecryptedText] = useState(null); - const [smimeDecryptedAttachments, setSmimeDecryptedAttachments] = useState([]); - const [smimeUnlockDialogOpen, setSmimeUnlockDialogOpen] = useState(false); - const [smimeUnlockTargetId, setSmimeUnlockTargetId] = useState(null); - const [smimeUnlockError, setSmimeUnlockError] = useState(null); - const smimeStore = useSmimeStore(); + // Crypto-plugin rendered body (S/MIME, PGP, …) — populated by the generic + // onRenderEmailBody hook. Verification/decryption status UI is provided by the + // crypto plugin's own email-banner slot, so the host keeps no S/MIME state. + const [pluginRenderedHtml, setPluginRenderedHtml] = useState(null); + const [pluginRenderedText, setPluginRenderedText] = useState(null); + const [pluginRenderedAttachments, setPluginRenderedAttachments] = useState([]); // TNEF (winmail.dat) support const [tnefHtml, setTnefHtml] = useState(null); @@ -1077,11 +810,6 @@ export function EmailViewer({ try { localStorage.setItem('emailDetailSidebarWidth', String(detailSidebarWidth)); } catch { /* ignore */ } }, [detailSidebarWidth]); - // Ensure S/MIME key records are loaded from IndexedDB - useLayoutEffect(() => { - smimeStore.load(activeAccountId ?? undefined); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [activeAccountId]); // Build mailbox tree for move-to dropdown const moveTargetIds = useMemo(() => new Set( @@ -1352,13 +1080,9 @@ export function EmailViewer({ setIsQuickReplyFocused(false); setShowSourceModal(false); setEmailViewDarkOverride(null); - setSmimeStatus(null); - setSmimeDecryptedHtml(null); - setSmimeDecryptedText(null); - setSmimeDecryptedAttachments([]); - setSmimeUnlockDialogOpen(false); - setSmimeUnlockTargetId(null); - setSmimeUnlockError(null); + setPluginRenderedHtml(null); + setPluginRenderedText(null); + setPluginRenderedAttachments([]); setTnefHtml(null); setTnefText(null); setTnefAttachments([]); @@ -1368,613 +1092,82 @@ export function EmailViewer({ setEmbeddedEmailUnwrapped(false); }, [email?.id, externalContentPolicy]); - const prepareSmimeUnlock = useCallback((keyRecordId: string) => { - setSmimeUnlockTargetId(keyRecordId); - setSmimeUnlockError(null); - }, []); - - const openSmimeUnlockDialog = useCallback(() => { - if (!smimeUnlockTargetId) { - return; - } - - setSmimeUnlockDialogOpen(true); - }, [smimeUnlockTargetId]); - - const handleSmimeUnlockSubmit = useCallback(async (passphrase: string) => { - if (!smimeUnlockTargetId) { - return; - } - - try { - await smimeStore.unlockKey(smimeUnlockTargetId, passphrase); - setSmimeUnlockDialogOpen(false); - setSmimeUnlockTargetId(null); - setSmimeUnlockError(null); - } catch (error) { - setSmimeUnlockError(error instanceof Error ? error.message : 'Unlock failed'); - } - }, [smimeStore, smimeUnlockTargetId]); - - // S/MIME detection and processing + // Crypto-plugin body takeover (S/MIME, PGP, …). A privileged crypto plugin + // can fetch the raw message via api.jmap.fetchBlob, decrypt/verify it, and + // return a replaced body through the onRenderEmailBody hook. The host stays + // crypto-agnostic; the plugin renders its own verification/encryption status + // via its email-banner slot. Falls through to normal rendering otherwise. useEffect(() => { - if (!email || !client) return; - - const smimeDebug = (...args: unknown[]) => { - if (useSettingsStore.getState().debugMode) { - console.debug(...args); - } - }; - - const smimeWarn = (...args: unknown[]) => { - if (useSettingsStore.getState().debugMode) { - console.warn(...args); - } - }; - - const smimeError = (...args: unknown[]) => { - console.error(...args); - }; - - const rawContentType = email.headers?.['content-type'] || email.headers?.['Content-Type']; - const contentType = Array.isArray(rawContentType) ? rawContentType[0] : rawContentType; - const detection = detectSmime( - contentType, - email.bodyStructure as Parameters[1], - email.attachments as Parameters[2], - ); - - smimeDebug('[S/MIME] detection:', { contentType, bodyStructure: email.bodyStructure, attachments: email.attachments, detection }); - - if (!detection.type) return; - - // Unsupported type (e.g., detached signature) - if (!detection.supported) { - setSmimeStatus({ - isSigned: detection.type === 'detached-sig', - isEncrypted: false, - unsupportedReason: 'Detached S/MIME signatures are not yet supported', - }); - return; - } - - if (!detection.blobId) return; - + if (!email) return; let cancelled = false; - async function processSmime() { + const dataUrlToBytes = (dataUrl: string): Uint8Array | null => { try { - const toHex = (bytes: Uint8Array, count: number) => - Array.from(bytes.slice(0, count)).map(b => b.toString(16).padStart(2, '0')).join(' '); + const comma = dataUrl.indexOf(','); + if (comma < 0) return null; + const meta = dataUrl.slice(0, comma); + const data = dataUrl.slice(comma + 1); + if (meta.includes(';base64')) { + const bin = atob(data); + const u8 = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i); + return u8; + } + return new TextEncoder().encode(decodeURIComponent(data)); + } catch { + return null; + } + }; - const toAsciiPreview = (bytes: Uint8Array, count: number) => { - try { - return new TextDecoder().decode(bytes.slice(0, count)); - } catch { - return ''; - } + (async () => { + try { + const rawContentType = email.headers?.['content-type'] || email.headers?.['Content-Type']; + const contentType = Array.isArray(rawContentType) ? rawContentType[0] : rawContentType; + const initialBody = { html: '', text: '', attachments: [] as unknown[] }; + const ctx = { + id: email.id, + contentType, + bodyStructure: email.bodyStructure, + attachments: email.attachments, + blobId: email.blobId, + from: email.from, }; - - const toExactArrayBuffer = (view: Uint8Array): ArrayBuffer => - view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength) as ArrayBuffer; - - const cmsCandidates: Array<{ source: string; raw: ArrayBuffer }> = []; - - const findPartById = ( - part: Parameters[1], - targetPartId: string, - ): Parameters[1] | undefined => { - if (!part) return undefined; - if (part.partId === targetPartId) return part; - if (part.subParts) { - for (const sub of part.subParts) { - const found = findPartById(sub as Parameters[1], targetPartId); - if (found) return found; - } - } - return undefined; + const result = await renderHooks.onRenderEmailBody.transform(initialBody, ctx) as { + html?: string; + text?: string; + attachments?: Array<{ name?: string; type?: string; size?: number; dataUrl?: string; cid?: string }>; + handledBy?: string; }; - - const detectedPart = detection.partId - ? findPartById(email!.bodyStructure as Parameters[1], detection.partId) - : undefined; - const detectedPartName = detectedPart?.name || 'smime.p7m'; - const detectedPartType = detectedPart?.type || 'application/pkcs7-mime'; - - const detectedPartSize = (detectedPart as { size?: number } | undefined)?.size; - if (detectedPartSize === 0) { - smimeWarn('[S/MIME] detected part has size=0; trying multiple blob fetch variants', { - partId: detection.partId, - blobId: detection.blobId, - name: detectedPartName, - type: detectedPartType, - }); - } - - // Primary source: Blob/download endpoint - try { - const blobBytes = await client!.fetchBlobArrayBuffer(detection.blobId!); - if (blobBytes.byteLength > 0) { - cmsCandidates.push({ source: 'blob-default', raw: blobBytes }); - } - smimeWarn('[S/MIME] blob-default fetch result:', { - byteLength: blobBytes.byteLength, - }); - } catch (error) { - smimeWarn('[S/MIME] blob fetch failed:', error); - // Fallback sources below may still work - } - - // Variant source: same blob with explicit part name/type in URL template - try { - const typedBlobBytes = await client!.fetchBlobArrayBuffer( - detection.blobId!, - detectedPartName, - detectedPartType, - ); - if (typedBlobBytes.byteLength > 0) { - cmsCandidates.push({ source: 'blob-typed', raw: typedBlobBytes }); - } - smimeWarn('[S/MIME] blob-typed fetch result:', { - byteLength: typedBlobBytes.byteLength, - name: detectedPartName, - type: detectedPartType, - }); - } catch (error) { - smimeWarn('[S/MIME] typed blob fetch failed:', error); - } - - // Fallback source: bodyValues entry for the detected S/MIME part - const bodyValue = detection.partId ? email!.bodyValues?.[detection.partId]?.value : undefined; - const bodyValueMeta = detection.partId ? email!.bodyValues?.[detection.partId] : undefined; - smimeWarn('[S/MIME] bodyValues candidate:', { - partId: detection.partId, - exists: !!bodyValueMeta, - valueLength: bodyValue?.length ?? 0, - isTruncated: bodyValueMeta?.isTruncated ?? false, - isEncodingProblem: bodyValueMeta?.isEncodingProblem ?? false, - }); - if (bodyValue) { - const bodyValueBytes = new TextEncoder().encode(bodyValue); - cmsCandidates.push({ source: 'bodyValues', raw: toExactArrayBuffer(bodyValueBytes) }); - } - - // Fallback source: fetch full RFC822 blob and extract CMS bytes from message body - // Some servers return empty bytes for part blobId=0 while Email.blobId still has full content. - if (email!.blobId) { - try { - const fullMessageBytes = await client!.fetchBlobArrayBuffer( - email!.blobId, - 'message.eml', - 'message/rfc822', - ); - if (fullMessageBytes.byteLength > 0) { - cmsCandidates.push({ source: 'email-blob', raw: fullMessageBytes }); - } - smimeWarn('[S/MIME] email-blob fetch result:', { - blobId: email!.blobId, - byteLength: fullMessageBytes.byteLength, - }); - } catch (error) { - smimeWarn('[S/MIME] email-blob fetch failed:', error); - } - } else { - smimeWarn('[S/MIME] email-blob unavailable: Email.blobId not present'); - } - - if (cmsCandidates.length === 0) { - throw new Error('No usable CMS bytes found (blob-default/blob-typed/bodyValues/email-blob all empty)'); - } - - const expandedCandidates: Array<{ source: string; raw: ArrayBuffer }> = []; - - for (const candidate of cmsCandidates) { - expandedCandidates.push(candidate); - - if (candidate.source === 'email-blob') { - // Candidate 1: raw message body (strip RFC822 headers) - try { - const fullText = new TextDecoder().decode(candidate.raw); - const headerEnd = fullText.search(/\r?\n\r?\n/); - if (headerEnd >= 0) { - const headerSep = fullText.slice(headerEnd).match(/^\r?\n\r?\n/)?.[0] ?? '\r\n\r\n'; - const bodyText = fullText.slice(headerEnd + headerSep.length); - if (bodyText.trim().length > 0) { - const bodyBytes = new TextEncoder().encode(bodyText); - expandedCandidates.push({ - source: 'email-blob-body', - raw: toExactArrayBuffer(bodyBytes), - }); - } - } - } catch { - // ignore extraction failures - } - - // Candidate 2: parse MIME and extract pkcs7 attachment content - try { - const { default: PostalMime } = await import('postal-mime'); - const parser = new PostalMime(); - const parsedFull = await parser.parse(candidate.raw); - const smimeAttachment = parsedFull.attachments?.find(att => { - const mimeType = ((att as { mimeType?: string }).mimeType || '').toLowerCase(); - const filename = ((att as { filename?: string }).filename || '').toLowerCase(); - return mimeType.includes('application/pkcs7-mime') || filename.endsWith('.p7m'); - }); - - if (smimeAttachment) { - const content = (smimeAttachment as { content?: unknown }).content; - if (content instanceof Uint8Array) { - expandedCandidates.push({ - source: 'email-blob-attachment', - raw: toExactArrayBuffer(content), - }); - } else if (content instanceof ArrayBuffer) { - expandedCandidates.push({ - source: 'email-blob-attachment', - raw: content, - }); - } else if (typeof content === 'string') { - const contentBytes = new TextEncoder().encode(content); - expandedCandidates.push({ - source: 'email-blob-attachment', - raw: toExactArrayBuffer(contentBytes), - }); - } - } - } catch (error) { - smimeWarn('[S/MIME] email-blob MIME parse/extract failed:', error); - } - } - } - - const normalizedCandidates = expandedCandidates.map(candidate => ({ - source: candidate.source, - raw: candidate.raw, - normalized: normalizeCmsBytes(candidate.raw), - })); - - const candidateSummaries = normalizedCandidates.map((candidate, index) => { - const rawBytes = new Uint8Array(candidate.raw); - const normalizedBytes = new Uint8Array(candidate.normalized); - return { - index, - source: candidate.source, - rawLength: candidate.raw.byteLength, - normalizedLength: candidate.normalized.byteLength, - rawFirstBytesHex: toHex(rawBytes, 24), - normalizedFirstBytesHex: toHex(normalizedBytes, 24), - rawAsciiPreview: toAsciiPreview(rawBytes, 180), - }; - }); - - smimeWarn('[S/MIME] CMS candidates:', { - detection, - candidateCount: candidateSummaries.length, - candidates: candidateSummaries, - }); - - if (useSettingsStore.getState().debugMode && typeof window !== 'undefined') { - const debugPayload = { - emailId: email!.id, - detection, - generatedAt: new Date().toISOString(), - candidates: candidateSummaries, - }; - - const exportCandidate = (index = 0, normalized = true) => { - const candidate = normalizedCandidates[index]; - if (!candidate) { - throw new Error(`Invalid candidate index: ${index}`); - } - const bytes = normalized ? candidate.normalized : candidate.raw; - const mode = normalized ? 'normalized' : 'raw'; - const filename = `smime-${email!.id}-${candidate.source}-${index}-${mode}.p7m`; - const blob = new Blob([bytes], { type: 'application/pkcs7-mime' }); - const url = URL.createObjectURL(blob); - const anchor = document.createElement('a'); - anchor.href = url; - anchor.download = filename; - document.body.appendChild(anchor); - anchor.click(); - anchor.remove(); - setTimeout(() => URL.revokeObjectURL(url), 1000); - return { filename, byteLength: bytes.byteLength, source: candidate.source, mode }; - }; - - (window as unknown as { - __smimeDebugLast?: unknown; - __smimeDebugExport?: (index?: number, normalized?: boolean) => unknown; - }).__smimeDebugLast = debugPayload; - (window as unknown as { - __smimeDebugLast?: unknown; - __smimeDebugExport?: (index?: number, normalized?: boolean) => unknown; - }).__smimeDebugExport = exportCandidate; - - smimeWarn('[S/MIME] debug helpers ready: window.__smimeDebugLast, window.__smimeDebugExport(index, normalized=true)'); - } - - const isCmsParseError = (error: unknown) => { - if (!(error instanceof Error)) return false; - return ( - error.message.includes('Invalid ASN.1 data') || - error.message.includes('Unexpected CMS content type') || - error.message.includes('Object\'s schema was not verified against input data for ContentInfo') - ); - }; - - const fromEmail = email!.from?.[0]?.email; - - if (detection.type === 'enveloped-data') { - // Encrypted message - const { keyRecords, unlockedDecryptionKeys, unlockedLegacyDecryptionKeys } = smimeStore; - smimeDebug('[S/MIME] decrypt attempt:', { - keyRecordCount: keyRecords.length, - unlockedKeyCount: unlockedDecryptionKeys.size, - legacyKeyCount: unlockedLegacyDecryptionKeys.size, - keyRecordIds: keyRecords.map(k => k.id), - }); - - // Short-circuit: no keys imported at all - if (keyRecords.length === 0) { - smimeDebug('[S/MIME] no key records available, skipping decrypt'); - setSmimeStatus({ - isSigned: false, - isEncrypted: true, - decryptionError: 'no-key', - }); - return; - } - - try { - let result: Awaited> | null = null; - let lastError: unknown = null; - - for (const candidate of normalizedCandidates) { - try { - result = await smimeDecrypt({ - cmsBytes: candidate.normalized, - keyRecords, - unlockedKeys: unlockedDecryptionKeys, - legacyUnlockedKeys: unlockedLegacyDecryptionKeys, - }); - smimeDebug('[S/MIME] decrypt success with candidate:', { - source: candidate.source, - byteLength: candidate.normalized.byteLength, - }); - break; - } catch (error) { - lastError = error; - smimeWarn('[S/MIME] decrypt candidate failed:', { - source: candidate.source, - error: error instanceof Error ? error.message : String(error), - }); - // SmimeKeyLockedError should bubble up immediately so the UI can prompt for passphrase - if (error instanceof SmimeKeyLockedError) { - throw error; - } - // For other errors (CMS parse, decrypt failure), try the next candidate - } - } - - if (!result) { - throw lastError instanceof Error ? lastError : new Error('Decryption failed'); - } - - if (cancelled) return; - - // Parse inner MIME - const { default: PostalMime } = await import('postal-mime'); - const parser = new PostalMime(); - const parsed = await parser.parse(result.mimeBytes); - if (cancelled) return; - const parsedContent = getRenderableSmimeContent(parsed, result.mimeBytes); - smimeDebug('[S/MIME] decrypted MIME parsed:', { - subject: parsed.subject, - htmlLength: parsed.html?.length ?? 0, - textLength: parsed.text?.length ?? 0, - attachmentCount: parsed.attachments?.length ?? 0, - fallbackUsed: parsedContent.fallbackUsed, - renderHtmlLength: parsedContent.html?.length ?? 0, - renderTextLength: parsedContent.text?.length ?? 0, - }); - - // Check if inner content is also signed - const nestedSignedData = extractNestedSignedDataCandidate(parsed, result.mimeBytes); - if (nestedSignedData) { - // Nested sign-then-encrypt - verify inner signature - const innerBytes = normalizeCmsBytes(nestedSignedData.bytes); - smimeDebug('[S/MIME] nested signed-data candidate:', { - source: nestedSignedData.source, - byteLength: innerBytes.byteLength, - }); - try { - const verifyResult = await smimeVerify(innerBytes, fromEmail); - if (cancelled) return; - // Parse the verified inner content - const innerParsed = await new PostalMime().parse(verifyResult.mimeBytes); - if (cancelled) return; - const innerParsedContent = getRenderableSmimeContent(innerParsed, verifyResult.mimeBytes); - smimeDebug('[S/MIME] verified inner MIME parsed:', { - subject: innerParsed.subject, - htmlLength: innerParsed.html?.length ?? 0, - textLength: innerParsed.text?.length ?? 0, - attachmentCount: innerParsed.attachments?.length ?? 0, - fallbackUsed: innerParsedContent.fallbackUsed, - renderHtmlLength: innerParsedContent.html?.length ?? 0, - renderTextLength: innerParsedContent.text?.length ?? 0, - }); - setSmimeDecryptedHtml(innerParsedContent.html); - setSmimeDecryptedText(innerParsedContent.text); - setSmimeDecryptedAttachments(innerParsed.attachments ?? []); - setSmimeStatus({ - ...verifyResult.status, - isEncrypted: true, - decryptionSuccess: true, - }); - // Auto-import signer cert if enabled - if (smimeStore.autoImportSignerCerts && verifyResult.status.signatureValid && verifyResult.status.signerCert) { - const existing = smimeStore.getPublicCertForEmail(verifyResult.status.signerCert.email); - if (!existing) { - try { - await smimeStore.importPublicCert(verifyResult.status.signerCert.certificate, 'signed-email'); - smimeDebug('[S/MIME] auto-imported signer cert:', { email: verifyResult.status.signerCert.email, fingerprint: verifyResult.status.signerCert.fingerprint }); - } catch (importErr) { - smimeError('[S/MIME] auto-import signer cert failed:', importErr); - } - } else { - smimeDebug('[S/MIME] signer cert already imported:', { email: existing.email, fingerprint: existing.fingerprint }); - } - } else if (verifyResult.status.signatureValid && verifyResult.status.signerCert) { - smimeDebug('[S/MIME] auto-import disabled, skipping signer cert:', { email: verifyResult.status.signerCert.email }); - } - } catch (error) { - smimeError('[S/MIME] nested signature verify failed:', { - source: nestedSignedData.source, - error: error instanceof Error ? error.message : String(error), - }); - // Verification failed but decryption worked - setSmimeDecryptedHtml(parsedContent.html); - setSmimeDecryptedText(parsedContent.text); - setSmimeDecryptedAttachments((parsed.attachments ?? []) as PostalMimeAttachment[]); - setSmimeStatus({ - isSigned: false, - isEncrypted: true, - decryptionSuccess: true, - }); - } - } else { - setSmimeDecryptedHtml(parsedContent.html); - setSmimeDecryptedText(parsedContent.text); - setSmimeDecryptedAttachments((parsed.attachments ?? []) as PostalMimeAttachment[]); - setSmimeStatus({ - isSigned: false, - isEncrypted: true, - decryptionSuccess: true, - }); - } - } catch (err) { - if (cancelled) return; - smimeError('[S/MIME] decrypt error:', err); - if (err instanceof SmimeKeyLockedError) { - prepareSmimeUnlock(err.keyRecordId); - setSmimeStatus({ - isSigned: false, - isEncrypted: true, - decryptionError: 'locked', - }); - } else { - const errMsg = err instanceof Error ? err.message : 'Decryption failed'; - const isNoKeyError = errMsg.includes('No imported S/MIME key matches'); - setSmimeStatus({ - isSigned: false, - isEncrypted: true, - decryptionError: isNoKeyError ? 'no-key' : errMsg, - }); - } - } - } else if (detection.type === 'signed-data') { - // Signed message - try { - let result: Awaited> | null = null; - let lastError: unknown = null; - - for (const candidate of normalizedCandidates) { - try { - result = await smimeVerify(candidate.normalized, fromEmail); - smimeDebug('[S/MIME] verify success with candidate:', { - source: candidate.source, - byteLength: candidate.normalized.byteLength, - }); - break; - } catch (error) { - lastError = error; - smimeWarn('[S/MIME] verify candidate failed:', { - source: candidate.source, - error: error instanceof Error ? error.message : String(error), - }); - if (!isCmsParseError(error)) { - throw error; - } - } - } - - if (!result) { - throw lastError instanceof Error ? lastError : new Error('Verification failed'); - } - - if (cancelled) return; - - // Parse inner MIME - const { default: PostalMime } = await import('postal-mime'); - const parser = new PostalMime(); - const parsed = await parser.parse(result.mimeBytes); - if (cancelled) return; - const parsedContent = getRenderableSmimeContent(parsed, result.mimeBytes); - smimeDebug('[S/MIME] verified MIME parsed:', { - subject: parsed.subject, - htmlLength: parsed.html?.length ?? 0, - textLength: parsed.text?.length ?? 0, - attachmentCount: parsed.attachments?.length ?? 0, - fallbackUsed: parsedContent.fallbackUsed, - renderHtmlLength: parsedContent.html?.length ?? 0, - renderTextLength: parsedContent.text?.length ?? 0, - }); - - setSmimeDecryptedHtml(parsedContent.html); - setSmimeDecryptedText(parsedContent.text); - setSmimeDecryptedAttachments((parsed.attachments ?? []) as PostalMimeAttachment[]); - setSmimeStatus(result.status); - // Auto-import signer cert if enabled - if (smimeStore.autoImportSignerCerts && result.status.signatureValid && result.status.signerCert) { - const existing = smimeStore.getPublicCertForEmail(result.status.signerCert.email); - if (!existing) { - try { - await smimeStore.importPublicCert(result.status.signerCert.certificate, 'signed-email'); - smimeDebug('[S/MIME] auto-imported signer cert:', { email: result.status.signerCert.email, fingerprint: result.status.signerCert.fingerprint }); - } catch (importErr) { - smimeError('[S/MIME] auto-import signer cert failed:', importErr); - } - } else { - smimeDebug('[S/MIME] signer cert already imported:', { email: existing.email, fingerprint: existing.fingerprint }); - } - } else if (result.status.signatureValid && result.status.signerCert) { - smimeDebug('[S/MIME] auto-import disabled, skipping signer cert:', { email: result.status.signerCert.email }); - } - } catch (err) { - if (cancelled) return; - setSmimeStatus({ - isSigned: true, - isEncrypted: false, - signatureValid: false, - signatureError: err instanceof Error ? err.message : 'Verification failed', - }); - } + if (cancelled) return; + if (!result || !result.handledBy) { + setPluginRenderedHtml(null); + setPluginRenderedText(null); + setPluginRenderedAttachments([]); + return; } + setPluginRenderedHtml(typeof result.html === 'string' && result.html ? result.html : null); + setPluginRenderedText(typeof result.text === 'string' && result.text ? result.text : null); + // Normalise the plugin's attachment shape into the PostalMime-like shape + // the viewer's download / inline-image machinery already understands. + const atts = Array.isArray(result.attachments) ? result.attachments : []; + const decoded = atts.map((a) => ({ + filename: a.name ?? null, + mimeType: a.type || 'application/octet-stream', + contentId: a.cid, + content: (a.dataUrl ? dataUrlToBytes(a.dataUrl) : null) ?? new Uint8Array(0), + } as unknown as PostalMimeAttachment)); + setPluginRenderedAttachments(decoded); } catch (err) { if (cancelled) return; - smimeError('[S/MIME] processing failed before decrypt/verify:', err); - // Failed to fetch CMS blob - setSmimeStatus({ - isSigned: false, - isEncrypted: detection.type === 'enveloped-data', - decryptionError: err instanceof Error ? err.message : 'Failed to fetch encrypted content', - }); + debug.error('onRenderEmailBody hook failed:', err); + setPluginRenderedHtml(null); + setPluginRenderedText(null); + setPluginRenderedAttachments([]); } - } + })(); - processSmime(); return () => { cancelled = true; }; - }, [ - email, - client, - prepareSmimeUnlock, - smimeStore.autoImportSignerCerts, - smimeStore.keyRecords, - smimeStore.unlockedDecryptionKeys, - smimeStore.unlockedLegacyDecryptionKeys, - smimeStore, - ]); + }, [email]); // TNEF (winmail.dat) detection and processing useEffect(() => { @@ -2147,7 +1340,7 @@ export function EmailViewer({ let cancelled = false; const objectUrls: string[] = []; - const decryptedCidAttachments = smimeDecryptedAttachments.filter(att => att.contentId); + const decryptedCidAttachments = pluginRenderedAttachments.filter(att => att.contentId); if (decryptedCidAttachments.length > 0) { const urls: Record = {}; @@ -2208,11 +1401,11 @@ export function EmailViewer({ cancelled = true; objectUrls.forEach(url => URL.revokeObjectURL(url)); }; - }, [client, email?.id, smimeDecryptedAttachments, email?.attachments]); + }, [client, email?.id, pluginRenderedAttachments, email?.attachments]); const effectiveAttachments = useMemo(() => { - if (smimeDecryptedAttachments.length > 0) { - return smimeDecryptedAttachments + if (pluginRenderedAttachments.length > 0) { + return pluginRenderedAttachments .filter(att => !(hideInlineImageAttachments && att.contentId && (att.mimeType || '').startsWith('image/'))) .map((attachment, index) => ({ id: `smime-${index}-${attachment.filename || attachment.mimeType}`, @@ -2274,7 +1467,7 @@ export function EmailViewer({ // attachment list — and its downstream layout measurement — on every email // field change. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [email?.attachments, smimeDecryptedAttachments, tnefHtml, tnefText, tnefAttachments, embeddedEmailUnwrapped, embeddedEmailAttachments, calendarInvitationParsingEnabled, hideInlineImageAttachments]); + }, [email?.attachments, pluginRenderedAttachments, tnefHtml, tnefText, tnefAttachments, embeddedEmailUnwrapped, embeddedEmailAttachments, calendarInvitationParsingEnabled, hideInlineImageAttachments]); // Measure attachment chips in the below-header row to determine how many fit // on a single line; the rest collapse into a "+N attachments" overflow pill. @@ -2488,18 +1681,18 @@ export function EmailViewer({ // Override email content with S/MIME decrypted content when available const effectiveEmailContent = useMemo(() => { - if (smimeDecryptedHtml) { - const htmlWithCidUrls = smimeDecryptedHtml.replace( + if (pluginRenderedHtml) { + const htmlWithCidUrls = pluginRenderedHtml.replace( /\bcid:([^"'\s)]+)/gi, (_match, cidRef) => { return cidBlobUrls[cidRef] || 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; } ); const cleanHtml = DOMPurify.sanitize(htmlWithCidUrls, EMAIL_IFRAME_SANITIZE_CONFIG); - return { html: cleanHtml, isHtml: true, hasStyleTag: /]/i.test(smimeDecryptedHtml), externalBlocked: false }; + return { html: cleanHtml, isHtml: true, hasStyleTag: /]/i.test(pluginRenderedHtml), externalBlocked: false }; } - if (smimeDecryptedText) { - return { html: plainTextToSafeHtml(smimeDecryptedText), isHtml: false, hasStyleTag: false, externalBlocked: false }; + if (pluginRenderedText) { + return { html: plainTextToSafeHtml(pluginRenderedText), isHtml: false, hasStyleTag: false, externalBlocked: false }; } // TNEF (winmail.dat) extracted content if (tnefHtml) { @@ -2518,7 +1711,7 @@ export function EmailViewer({ return { html: plainTextToSafeHtml(embeddedEmailText), isHtml: false, hasStyleTag: false, externalBlocked: false }; } return emailContent; - }, [cidBlobUrls, emailContent, smimeDecryptedHtml, smimeDecryptedText, tnefHtml, tnefText, embeddedEmailHtml, embeddedEmailText]); + }, [cidBlobUrls, emailContent, pluginRenderedHtml, pluginRenderedText, tnefHtml, tnefText, embeddedEmailHtml, embeddedEmailText]); const resolveAttachmentName = useCallback( (attachment: EffectiveAttachment) => { @@ -4994,18 +4187,6 @@ export function EmailViewer({ ); })()} - {/* S/MIME Status Banner */} - {smimeStatus && ( -
-
- -
-
- )} - {/* Scheduled Banner */} {isScheduled && (
@@ -5064,18 +4245,6 @@ export function EmailViewer({
)} - { - setSmimeUnlockDialogOpen(false); - setSmimeUnlockError(null); - }} - onSubmit={handleSmimeUnlockSubmit} - title={tSmime('unlock_key')} - description={tSmime('unlock_key_desc')} - error={smimeUnlockError} - /> - {/* Unified Notification Banner - External Content + Calendar Invitation + Read Receipt */} {((hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') || hasCalendarInvitation || diff --git a/components/email/smime-status-banner.tsx b/components/email/smime-status-banner.tsx deleted file mode 100644 index b17b0fd4..00000000 --- a/components/email/smime-status-banner.tsx +++ /dev/null @@ -1,138 +0,0 @@ -"use client"; - -import React from "react"; -import { ShieldCheck, ShieldAlert, ShieldX, Lock, LockOpen, AlertTriangle, Info } from "lucide-react"; -import { cn } from "@/lib/utils"; -import { useTranslations } from "next-intl"; -import type { SmimeStatus } from "@/lib/smime/types"; - -interface SmimeStatusBannerProps { - status: SmimeStatus; - onUnlockKey?: () => void; - className?: string; -} - -type SmimeVariant = 'success' | 'warning' | 'error' | 'info'; - -const variantTone: Record = { - success: 'bg-success/15 text-success', - warning: 'bg-warning/15 text-warning', - error: 'bg-destructive/15 text-destructive', - info: 'bg-info/15 text-info', -}; - -export function SmimeStatusBanner({ status, onUnlockKey, className }: SmimeStatusBannerProps) { - const t = useTranslations('smime'); - - const items: Array<{ - icon: React.ReactNode; - text: string; - variant: SmimeVariant; - }> = []; - - // Encryption status - if (status.isEncrypted) { - if (status.decryptionError) { - if (status.decryptionError === 'locked') { - items.push({ - icon: , - text: t('unlock_key_desc'), - variant: 'warning', - }); - } else if (status.decryptionError === 'no-key') { - items.push({ - icon: , - text: t('status_encrypted_no_key'), - variant: 'warning', - }); - } else { - items.push({ - icon: , - text: t('status_encrypted_failed'), - variant: 'error', - }); - } - } else { - items.push({ - icon: , - text: t('status_encrypted_ok'), - variant: 'success', - }); - } - } - - // Signature status - if (status.isSigned) { - if (status.signatureValid === true) { - if (status.selfSigned) { - items.push({ - icon: , - text: t('status_signed_self_signed'), - variant: 'warning', - }); - } else if (status.signerEmailMatch === false) { - items.push({ - icon: , - text: t('status_signed_mismatch'), - variant: 'warning', - }); - } else { - items.push({ - icon: , - text: t('status_signed_valid'), - variant: 'success', - }); - } - } else if (status.signatureValid === false) { - items.push({ - icon: , - text: status.signatureError || t('status_signed_invalid'), - variant: 'error', - }); - } - } - - // Unsupported S/MIME - if (status.unsupportedReason) { - items.push({ - icon: , - text: t('status_unsupported'), - variant: 'info', - }); - } - - if (items.length === 0) return null; - - return ( -
- {items.map((item, i) => ( -
-
- {item.icon} -
-
-
-
- S/MIME -
-
- {item.text} -
-
- {item.variant === 'warning' && status.decryptionError === 'locked' && onUnlockKey && ( - - )} -
-
- ))} -
- ); -} diff --git a/components/settings/smime-certificate-modal.tsx b/components/settings/smime-certificate-modal.tsx deleted file mode 100644 index 5a08551c..00000000 --- a/components/settings/smime-certificate-modal.tsx +++ /dev/null @@ -1,117 +0,0 @@ -"use client"; - -import { useId } from "react"; -import { useFocusTrap } from "@/hooks/use-focus-trap"; -import { useTranslations } from "next-intl"; -import { Button } from "@/components/ui/button"; -import { ShieldCheck, X } from "lucide-react"; -import type { SmimeKeyRecord, SmimePublicCert } from "@/lib/smime/types"; - -interface SmimeCertificateModalProps { - isOpen: boolean; - onClose: () => void; - record: SmimeKeyRecord | SmimePublicCert | null; - type: "private" | "public"; -} - -export function SmimeCertificateModal({ - isOpen, - onClose, - record, - type: _type, -}: SmimeCertificateModalProps) { - const t = useTranslations("smime"); - const id = useId(); - - const dialogRef = useFocusTrap({ - isActive: isOpen, - onEscape: onClose, - restoreFocus: true, - }); - - if (!isOpen || !record) return null; - - const isExpired = new Date(record.notAfter) < new Date(); - const isNotYetValid = new Date(record.notBefore) > new Date(); - - const rows: { label: string; value: string }[] = [ - { label: t("cert_subject"), value: record.subject ?? "" }, - { label: t("cert_issuer"), value: record.issuer ?? "" }, - { label: t("cert_email"), value: record.email }, - { - label: t("cert_validity"), - value: `${new Date(record.notBefore).toLocaleDateString()} - ${new Date(record.notAfter).toLocaleDateString()}`, - }, - { label: t("cert_fingerprint"), value: record.fingerprint }, - ]; - - if ("serialNumber" in record) { - rows.splice(2, 0, { label: t("cert_serial"), value: record.serialNumber }); - } - - if ("algorithm" in record) { - rows.push({ label: t("cert_algorithm"), value: record.algorithm }); - } - - if ("capabilities" in record) { - const caps: string[] = []; - if (record.capabilities.canSign) caps.push(t("cap_sign")); - if (record.capabilities.canEncrypt) caps.push(t("cap_encrypt")); - rows.push({ label: t("cert_capabilities"), value: caps.join(", ") || t("cap_none") }); - } - - if ("source" in record) { - rows.push({ label: t("cert_source"), value: record.source }); - } - - return ( -
-
-
-
-
- -
-

- {t("certificate_details")} -

-
- -
- -
- {(isExpired || isNotYetValid) && ( -
- {isExpired ? t("cert_expired") : t("cert_not_yet_valid")} -
- )} - - {rows.map(({ label, value }) => ( -
-
- {label} -
-
- {value} -
-
- ))} -
- -
- -
-
-
- ); -} diff --git a/components/settings/smime-passphrase-dialog.tsx b/components/settings/smime-passphrase-dialog.tsx deleted file mode 100644 index b2183cf6..00000000 --- a/components/settings/smime-passphrase-dialog.tsx +++ /dev/null @@ -1,169 +0,0 @@ -"use client"; - -import { useState, useId } from "react"; -import { useFocusTrap } from "@/hooks/use-focus-trap"; -import { useTranslations } from "next-intl"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { KeyRound, Eye, EyeOff } from "lucide-react"; - -interface SmimePassphraseDialogProps { - isOpen: boolean; - onClose: () => void; - onSubmit: (passphrase: string) => void | Promise; - title: string; - description?: string; - submitText?: string; - error?: string | null; - /** Show a second passphrase field for import/export confirmation. */ - showConfirm?: boolean; -} - -export function SmimePassphraseDialog({ - isOpen, - onClose, - onSubmit, - title, - description, - submitText, - error, - showConfirm = false, -}: SmimePassphraseDialogProps) { - const t = useTranslations("smime"); - const id = useId(); - const [passphrase, setPassphrase] = useState(""); - const [confirm, setConfirm] = useState(""); - const [showPassword, setShowPassword] = useState(false); - const [isSubmitting, setIsSubmitting] = useState(false); - - const dialogRef = useFocusTrap({ - isActive: isOpen, - onEscape: onClose, - restoreFocus: true, - }); - - if (!isOpen) return null; - - const mismatch = showConfirm && passphrase !== confirm && confirm.length > 0; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (!passphrase || (showConfirm && passphrase !== confirm)) return; - setIsSubmitting(true); - try { - await onSubmit(passphrase); - } finally { - setIsSubmitting(false); - } - }; - - const handleClose = () => { - setPassphrase(""); - setConfirm(""); - setShowPassword(false); - onClose(); - }; - - return ( -
-
-
-
-
-
- -
-
-

- {title} -

- {description && ( -

- {description} -

- )} -
-
- -
-
- setPassphrase(e.target.value)} - placeholder={t("passphrase_placeholder")} - autoFocus - className="pr-10" - autoComplete="off" - /> - -
- - {showConfirm && ( -
- setConfirm(e.target.value)} - placeholder={t("confirm_passphrase_placeholder")} - autoComplete="off" - /> - {mismatch && ( -

- {t("passphrase_mismatch")} -

- )} -
- )} - - {error && ( -

{error}

- )} -
-
- -
- - -
-
-
-
- ); -} diff --git a/components/settings/smime-settings.tsx b/components/settings/smime-settings.tsx deleted file mode 100644 index 38939403..00000000 --- a/components/settings/smime-settings.tsx +++ /dev/null @@ -1,549 +0,0 @@ -"use client"; - -import { useState, useEffect, useRef } from "react"; -import { useTranslations } from "next-intl"; -import { - Upload, - Trash2, - Eye, - Lock, - Unlock, - Download, - ShieldCheck, - ShieldAlert, - Users, -} from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { SettingsSection, SettingItem, ToggleSwitch } from "@/components/settings/settings-section"; -import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-dialog"; -import { SmimeCertificateModal } from "@/components/settings/smime-certificate-modal"; -import { useSmimeStore } from "@/stores/smime-store"; -import { useIdentityStore } from "@/stores/identity-store"; -import { useAuthStore } from "@/stores/auth-store"; -import { exportPkcs12, downloadPkcs12 } from "@/lib/smime/pkcs12-export"; -import type { SmimeKeyRecord, SmimePublicCert } from "@/lib/smime/types"; - -export function SmimeSettings() { - const t = useTranslations("smime"); - const { - keyRecords, - publicCerts, - identityKeyBindings, - defaultSignIdentity, - defaultEncrypt, - autoImportSignerCerts, - isLoading, - error, - load, - importPKCS12, - removeKeyRecord, - removePublicCert, - bindIdentityToKey, - unlockKey, - lockKey, - setSignDefault, - setEncryptDefault, - setAutoImportSignerCerts, - isKeyUnlocked, - setError, - } = useSmimeStore(); - - const { identities } = useIdentityStore(); - const activeAccountId = useAuthStore((s) => s.activeAccountId); - - // Local UI state - const [importDialogOpen, setImportDialogOpen] = useState(false); - const [unlockDialogOpen, setUnlockDialogOpen] = useState(false); - const [unlockTargetId, setUnlockTargetId] = useState(null); - const [certModalRecord, setCertModalRecord] = useState(null); - const [certModalType, setCertModalType] = useState<"private" | "public">("private"); - const [importError, setImportError] = useState(null); - const [unlockError, setUnlockError] = useState(null); - const [pendingFile, setPendingFile] = useState(null); - const [pendingP12Pass, setPendingP12Pass] = useState(""); - const fileInputRef = useRef(null); - const pubCertInputRef = useRef(null); - - // State for the two-step PKCS#12 flow - const [importStep, setImportStep] = useState<"p12" | "storage">("p12"); - - // Export flow state - const [exportDialogOpen, setExportDialogOpen] = useState(false); - const [exportTargetRecord, setExportTargetRecord] = useState(null); - const [exportStep, setExportStep] = useState<"storage" | "export">("storage"); - const [exportStoragePass, setExportStoragePass] = useState(""); - const [exportError, setExportError] = useState(null); - - useEffect(() => { - load(activeAccountId ?? undefined); - }, [load, activeAccountId]); - - // ── PKCS#12 import flow ──────────────────────────────────────── - - const handleFileSelect = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - const reader = new FileReader(); - reader.onload = () => { - setPendingFile(reader.result as ArrayBuffer); - setImportStep("p12"); - setImportError(null); - setImportDialogOpen(true); - }; - reader.readAsArrayBuffer(file); - // Reset so same file can be re-selected - e.target.value = ""; - }; - - const handleImportSubmit = async (passphrase: string) => { - if (importStep === "p12") { - setPendingP12Pass(passphrase); - setImportStep("storage"); - setImportError(null); - return; - } - - // Storage passphrase step - if (!pendingFile) return; - try { - await importPKCS12(pendingFile, pendingP12Pass, passphrase); - setImportDialogOpen(false); - setPendingFile(null); - setPendingP12Pass(""); - setImportError(null); - } catch (err) { - setImportError(err instanceof Error ? err.message : "Import failed"); - } - }; - - // ── Public cert import ───────────────────────────────────────── - - const handlePublicCertFile = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - const reader = new FileReader(); - reader.onload = async () => { - try { - const store = useSmimeStore.getState(); - await store.importPublicCert(reader.result as ArrayBuffer, "manual"); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to import certificate"); - } - }; - reader.readAsArrayBuffer(file); - e.target.value = ""; - }; - - // ── Unlock ───────────────────────────────────────────────────── - - const handleUnlockRequest = (id: string) => { - setUnlockTargetId(id); - setUnlockError(null); - setUnlockDialogOpen(true); - }; - - const handleUnlockSubmit = async (passphrase: string) => { - if (!unlockTargetId) return; - try { - await unlockKey(unlockTargetId, passphrase); - setUnlockDialogOpen(false); - setUnlockTargetId(null); - setUnlockError(null); - } catch (err) { - setUnlockError(err instanceof Error ? err.message : "Unlock failed"); - } - }; - - // ── Export flow ──────────────────────────────────────────────── - - const handleExportRequest = (record: SmimeKeyRecord) => { - setExportTargetRecord(record); - setExportStep("storage"); - setExportStoragePass(""); - setExportError(null); - setExportDialogOpen(true); - }; - - const handleExportSubmit = async (passphrase: string) => { - if (!exportTargetRecord) return; - - if (exportStep === "storage") { - // Verify storage passphrase by attempting to decrypt - try { - const { decryptPrivateKeyBytes } = await import("@/lib/smime/pkcs12-import"); - await decryptPrivateKeyBytes(exportTargetRecord, passphrase); - setExportStoragePass(passphrase); - setExportStep("export"); - setExportError(null); - } catch { - setExportError(t("incorrect_passphrase")); - } - return; - } - - // Export passphrase step - try { - const p12Bytes = await exportPkcs12(exportTargetRecord, exportStoragePass, passphrase); - const filename = `${exportTargetRecord.email.replace(/[^a-zA-Z0-9.-]/g, '_')}.p12`; - downloadPkcs12(p12Bytes, filename); - setExportDialogOpen(false); - setExportTargetRecord(null); - setExportStoragePass(""); - setExportError(null); - } catch (err) { - setExportError(err instanceof Error ? err.message : "Export failed"); - } - }; - - // ── Helpers ──────────────────────────────────────────────────── - - const isExpired = (dateStr: string) => new Date(dateStr) < new Date(); - - const formatDate = (dateStr: string) => { - try { - return new Date(dateStr).toLocaleDateString(); - } catch { - return dateStr; - } - }; - - const getBoundIdentityNames = (keyId: string): string[] => { - return Object.entries(identityKeyBindings) - .filter(([, kId]) => kId === keyId) - .map(([identityId]) => { - const identity = identities.find((i) => i.id === identityId); - return identity?.email ?? identityId; - }); - }; - - return ( -
- {error && ( -
- {error} -
- )} - - {/* ── Your Certificates ──────────────────────────────────── */} - -
- {keyRecords.map((record) => { - const expired = isExpired(record.notAfter); - const unlocked = isKeyUnlocked(record.id); - const boundIdentities = getBoundIdentityNames(record.id); - - return ( -
-
-
- {expired ? ( - - ) : ( - - )} -
-
-

- {record.email || record.subject} -

-

- {record.issuer} · {t("expires")} {formatDate(record.notAfter)} - {expired && ({t("expired")})} -

- {boundIdentities.length > 0 && ( -

- {t("bound_to")}: {boundIdentities.join(", ")} -

- )} -
-
-
- {unlocked ? ( - - ) : ( - - )} - - - -
-
- ); - })} - - {keyRecords.length === 0 && !isLoading && ( -

- {t("no_certificates")} -

- )} -
- - - -
- - {/* ── Recipient Certificates ─────────────────────────────── */} - -
- {publicCerts.map((cert) => { - const expired = isExpired(cert.notAfter); - - return ( -
-
-
- -
-
-

- {cert.email || cert.subject} -

-

- {cert.issuer} · {cert.source} - {expired && ({t("expired")})} -

-
-
-
- - -
-
- ); - })} - - {publicCerts.length === 0 && !isLoading && ( -

- {t("no_recipient_certs")} -

- )} -
- - - -
- - {/* ── Identity Bindings ──────────────────────────────────── */} - {identities.length > 0 && keyRecords.length > 0 && ( - - {identities.map((identity) => { - const boundKeyId = identityKeyBindings[identity.id]; - return ( - - - - ); - })} - - )} - - {/* ── Defaults ───────────────────────────────────────────── */} - - - - - - - - - - {identities.map((identity) => { - const bound = identityKeyBindings[identity.id]; - if (!bound) return null; - return ( - - setSignDefault(identity.id, v)} - /> - - ); - })} - - - {/* ── Dialogs ────────────────────────────────────────────── */} - { - setImportDialogOpen(false); - setPendingFile(null); - setPendingP12Pass(""); - setImportError(null); - setImportStep("p12"); - }} - onSubmit={handleImportSubmit} - title={importStep === "p12" ? t("enter_p12_passphrase") : t("enter_storage_passphrase")} - description={importStep === "p12" ? t("p12_passphrase_desc") : t("storage_passphrase_desc")} - submitText={importStep === "p12" ? t("next") : t("import")} - error={importError} - showConfirm={importStep === "storage"} - /> - - { - setUnlockDialogOpen(false); - setUnlockTargetId(null); - setUnlockError(null); - }} - onSubmit={handleUnlockSubmit} - title={t("unlock_key")} - description={t("unlock_key_desc")} - error={unlockError} - /> - - setCertModalRecord(null)} - record={certModalRecord} - type={certModalType} - /> - - { - setExportDialogOpen(false); - setExportTargetRecord(null); - setExportStoragePass(""); - setExportError(null); - setExportStep("storage"); - }} - onSubmit={handleExportSubmit} - title={exportStep === "storage" ? t("enter_storage_passphrase") : t("enter_export_passphrase")} - description={exportStep === "storage" ? t("export_storage_desc") : t("export_passphrase_desc")} - submitText={exportStep === "storage" ? t("next") : t("export")} - error={exportError} - showConfirm={exportStep === "export"} - /> -
- ); -} diff --git a/lib/account-state-manager.ts b/lib/account-state-manager.ts index 10dc9fe8..036cf6d5 100644 --- a/lib/account-state-manager.ts +++ b/lib/account-state-manager.ts @@ -11,7 +11,6 @@ import { useFilterStore } from '@/stores/filter-store'; import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils'; import { useIdentityStore } from '@/stores/identity-store'; import { useVacationStore } from '@/stores/vacation-store'; -import { useSmimeStore } from '@/stores/smime-store'; // Minimal snapshot shapes - we only capture what we need // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -133,7 +132,6 @@ export function clearAllStores(): void { useVacationStore.getState().clearState(); useCalendarStore.getState().clearState(); useFilterStore.getState().clearState(); - useSmimeStore.getState().clearState(); } /** Evict cached state for one account */ diff --git a/lib/smime/__tests__/certificate-utils.test.ts b/lib/smime/__tests__/certificate-utils.test.ts deleted file mode 100644 index b02c2125..00000000 --- a/lib/smime/__tests__/certificate-utils.test.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { describe, it, expect, beforeAll } from 'vitest'; -import { - pemToDer, - derToPem, - isPem, - parseCertificateDer, - parseCertificatePemOrDer, - computeFingerprint, - classifyCapabilities, - extractCertificateInfo, -} from '../certificate-utils'; -import * as pkijs from 'pkijs'; -import * as asn1js from 'asn1js'; - -// Generate a self-signed test certificate using Web Crypto + pkijs -let testCertDer: ArrayBuffer; -let testCert: pkijs.Certificate; -let testKeyPair: globalThis.CryptoKeyPair; - -beforeAll(async () => { - const cryptoEngine = new pkijs.CryptoEngine({ - crypto: crypto, - subtle: crypto.subtle, - name: 'webcrypto', - }); - pkijs.setEngine('test', crypto, cryptoEngine); - - // Generate RSA key pair - testKeyPair = await crypto.subtle.generateKey( - { name: 'RSASSA-PKCS1-v1_5', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, - true, - ['sign', 'verify'], - ); - - // Build a minimal self-signed X.509 certificate - testCert = new pkijs.Certificate(); - testCert.version = 2; // v3 - testCert.serialNumber = new asn1js.Integer({ value: 1 }); - - testCert.issuer.typesAndValues.push(new pkijs.AttributeTypeAndValue({ - type: '2.5.4.3', // CN - value: new asn1js.Utf8String({ value: 'Test CA' }), - })); - - testCert.subject.typesAndValues.push(new pkijs.AttributeTypeAndValue({ - type: '2.5.4.3', // CN - value: new asn1js.Utf8String({ value: 'Test User' }), - })); - - testCert.subject.typesAndValues.push(new pkijs.AttributeTypeAndValue({ - type: '1.2.840.113549.1.9.1', // emailAddress - value: new asn1js.IA5String({ value: 'test@example.com' }), - })); - - testCert.notBefore.value = new Date('2024-01-01T00:00:00Z'); - testCert.notAfter.value = new Date('2030-12-31T23:59:59Z'); - - await testCert.subjectPublicKeyInfo.importKey(testKeyPair.publicKey, cryptoEngine); - - // Add KeyUsage extension: digitalSignature + keyEncipherment - const bitArray = new ArrayBuffer(1); - const bitView = new Uint8Array(bitArray); - bitView[0] = 0b10100000; // digitalSignature (bit 0) + keyEncipherment (bit 2) - - testCert.extensions = [ - new pkijs.Extension({ - extnID: '2.5.29.15', // keyUsage - critical: true, - extnValue: new asn1js.OctetString({ - valueHex: new Uint8Array(new asn1js.BitString({ - valueHex: bitArray, - unusedBits: 3, - }).toBER(false)), - }).toBER(false) as ArrayBuffer, - parsedValue: { - digitalSignature: true, - contentCommitment: false, - keyEncipherment: true, - dataEncipherment: false, - keyAgreement: false, - keyCertSign: false, - cRLSign: false, - encipherOnly: false, - decipherOnly: false, - }, - }), - ]; - - await testCert.sign(testKeyPair.privateKey, 'SHA-256', cryptoEngine); - - // toBER may return a non-standard ArrayBuffer in jsdom; normalize it - const rawDer = testCert.toSchema(true).toBER(false); - testCertDer = new Uint8Array(rawDer).buffer; -}); - -describe('certificate-utils', () => { - describe('pemToDer / derToPem roundtrip', () => { - it('converts PEM to DER and back', () => { - const pem = derToPem(testCertDer, 'CERTIFICATE'); - expect(pem).toContain('-----BEGIN CERTIFICATE-----'); - expect(pem).toContain('-----END CERTIFICATE-----'); - - const der2 = pemToDer(pem); - expect(new Uint8Array(der2)).toEqual(new Uint8Array(testCertDer)); - }); - - it('derToPem wraps lines at 64 chars', () => { - const pem = derToPem(testCertDer, 'CERTIFICATE'); - const lines = pem.split('\n'); - // All content lines (not headers) should be <= 64 chars - for (const line of lines) { - if (!line.startsWith('-----')) { - expect(line.length).toBeLessThanOrEqual(64); - } - } - }); - }); - - describe('isPem', () => { - it('returns true for certificate PEM', () => { - expect(isPem('-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----')).toBe(true); - }); - - it('returns true for PKCS12 PEM', () => { - expect(isPem('-----BEGIN PKCS12-----\ndata\n-----END PKCS12-----')).toBe(true); - }); - - it('returns true for private key PEM', () => { - expect(isPem('-----BEGIN PRIVATE KEY-----\ndata\n-----END PRIVATE KEY-----')).toBe(true); - }); - - it('returns true for encrypted private key PEM', () => { - expect(isPem('-----BEGIN ENCRYPTED PRIVATE KEY-----\ndata\n-----END ENCRYPTED PRIVATE KEY-----')).toBe(true); - }); - - it('returns false for non-PEM data', () => { - expect(isPem('hello world')).toBe(false); - expect(isPem('')).toBe(false); - expect(isPem('MIIB...')).toBe(false); - }); - }); - - describe('parseCertificateDer', () => { - it('parses a valid DER certificate', () => { - const cert = parseCertificateDer(testCertDer); - expect(cert).toBeInstanceOf(pkijs.Certificate); - }); - - it('throws on invalid DER data', () => { - const garbage = new Uint8Array([0, 1, 2, 3]).buffer; - expect(() => parseCertificateDer(garbage)).toThrow(); - }); - }); - - describe('parseCertificatePemOrDer', () => { - it('parses DER ArrayBuffer', () => { - const cert = parseCertificatePemOrDer(testCertDer); - expect(cert).toBeInstanceOf(pkijs.Certificate); - }); - - it('parses PEM string', () => { - const pem = derToPem(testCertDer, 'CERTIFICATE'); - const cert = parseCertificatePemOrDer(pem); - expect(cert).toBeInstanceOf(pkijs.Certificate); - }); - - it('throws on non-PEM string', () => { - expect(() => parseCertificatePemOrDer('not a pem')).toThrow('String input is not PEM-encoded'); - }); - }); - - describe('computeFingerprint', () => { - it('returns hex fingerprint with colons', async () => { - const fp = await computeFingerprint(testCertDer); - expect(fp).toMatch(/^[0-9a-f]{2}(:[0-9a-f]{2}){31}$/); - }); - - it('is deterministic', async () => { - const fp1 = await computeFingerprint(testCertDer); - const fp2 = await computeFingerprint(testCertDer); - expect(fp1).toBe(fp2); - }); - }); - - describe('classifyCapabilities', () => { - it('detects sign + encrypt from KeyUsage', () => { - const caps = classifyCapabilities(testCert); - expect(caps.canSign).toBe(true); - expect(caps.canEncrypt).toBe(true); - }); - }); - - describe('extractCertificateInfo', () => { - it('extracts full certificate metadata', async () => { - const info = await extractCertificateInfo(testCert, testCertDer); - - expect(info.subject).toContain('CN=Test User'); - expect(info.issuer).toContain('CN=Test CA'); - expect(info.notBefore).toBe('2024-01-01T00:00:00.000Z'); - expect(info.notAfter).toBe('2030-12-31T23:59:59.000Z'); - expect(info.fingerprint).toMatch(/^[0-9a-f]{2}(:[0-9a-f]{2}){31}$/); - expect(info.algorithm).toMatch(/^RSA/); - expect(info.emailAddresses).toContain('test@example.com'); - expect(info.capabilities.canSign).toBe(true); - expect(info.capabilities.canEncrypt).toBe(true); - }); - - it('returns serialNumber as hex', async () => { - const info = await extractCertificateInfo(testCert, testCertDer); - // Serial number 1 → should be hex string - expect(info.serialNumber).toBeTruthy(); - }); - }); -}); diff --git a/lib/smime/__tests__/key-storage.test.ts b/lib/smime/__tests__/key-storage.test.ts deleted file mode 100644 index bd194330..00000000 --- a/lib/smime/__tests__/key-storage.test.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import 'fake-indexeddb/auto'; - -// Each test file gets a fresh global indexedDB via fake-indexeddb/auto. -// Since openDB() caches connections implicitly, we re-import the module for each test. -// However, to keep it simple, we'll just test in order and accept cumulative state, -// or we can test with unique IDs. - -import { - saveKeyRecord, - getKeyRecord, - getKeyRecordForEmail, - listKeyRecords, - deleteKeyRecord, - savePublicCert, - getPublicCertForEmail, - listPublicCerts, - deletePublicCert, -} from '../key-storage'; -import type { SmimeKeyRecord, SmimePublicCert } from '../types'; - -function makeKeyRecord(overrides: Partial = {}): SmimeKeyRecord { - return { - id: 'key-1', - email: 'user@example.com', - certificate: new ArrayBuffer(10), - certificateChain: [], - encryptedPrivateKey: new ArrayBuffer(32), - salt: new ArrayBuffer(16), - iv: new ArrayBuffer(12), - kdfIterations: 600000, - issuer: 'CN=Test CA', - subject: 'CN=Test User', - serialNumber: '01', - notBefore: '2024-01-01T00:00:00Z', - notAfter: '2030-12-31T23:59:59Z', - fingerprint: 'aa:bb:cc', - algorithm: 'RSA-2048', - capabilities: { canSign: true, canEncrypt: true }, - ...overrides, - }; -} - -function makePublicCert(overrides: Partial = {}): SmimePublicCert { - return { - id: 'cert-1', - email: 'recipient@example.com', - certificate: new ArrayBuffer(10), - issuer: 'CN=Test CA', - subject: 'CN=Recipient', - notBefore: '2024-01-01T00:00:00Z', - notAfter: '2030-12-31T23:59:59Z', - fingerprint: 'dd:ee:ff', - source: 'manual', - ...overrides, - }; -} - -// Use unique IDs for each test to avoid state leakage -let testCounter = 0; -function uid() { return `test-${++testCounter}-${Date.now()}`; } - -describe('key-storage', () => { - describe('key records', () => { - it('saves and retrieves a key record by id', async () => { - const id = uid(); - const record = makeKeyRecord({ id }); - await saveKeyRecord(record); - const retrieved = await getKeyRecord(id); - expect(retrieved).toBeDefined(); - expect(retrieved!.id).toBe(id); - expect(retrieved!.email).toBe('user@example.com'); - }); - - it('returns undefined for non-existent key record', async () => { - const result = await getKeyRecord('absolutely-non-existent-' + uid()); - expect(result).toBeUndefined(); - }); - - it('retrieves key record by email', async () => { - const id = uid(); - const email = `alice-${id}@example.com`; - const record = makeKeyRecord({ id, email }); - await saveKeyRecord(record); - const result = await getKeyRecordForEmail(email); - expect(result).toBeDefined(); - expect(result!.email).toBe(email); - }); - - it('lists key records (includes previously saved)', async () => { - const id1 = uid(); - const id2 = uid(); - await saveKeyRecord(makeKeyRecord({ id: id1, email: `${id1}@example.com` })); - await saveKeyRecord(makeKeyRecord({ id: id2, email: `${id2}@example.com` })); - const records = await listKeyRecords(); - expect(records.length).toBeGreaterThanOrEqual(2); - expect(records.find(r => r.id === id1)).toBeDefined(); - expect(records.find(r => r.id === id2)).toBeDefined(); - }); - - it('deletes a key record', async () => { - const id = uid(); - const record = makeKeyRecord({ id }); - await saveKeyRecord(record); - await deleteKeyRecord(id); - const result = await getKeyRecord(id); - expect(result).toBeUndefined(); - }); - - it('updates existing record with same id', async () => { - const id = uid(); - const record1 = makeKeyRecord({ id, email: 'old@example.com' }); - await saveKeyRecord(record1); - const record2 = makeKeyRecord({ id, email: 'new@example.com' }); - await saveKeyRecord(record2); - const retrieved = await getKeyRecord(id); - expect(retrieved!.email).toBe('new@example.com'); - }); - }); - - describe('public certs', () => { - it('saves and retrieves by email', async () => { - const id = uid(); - const email = `recipient-${id}@example.com`; - const cert = makePublicCert({ id, email }); - await savePublicCert(cert); - const result = await getPublicCertForEmail(email); - expect(result).toBeDefined(); - expect(result!.email).toBe(email); - }); - - it('lists public certs (includes previously saved)', async () => { - const id1 = uid(); - const id2 = uid(); - await savePublicCert(makePublicCert({ id: id1, email: `${id1}@test.com` })); - await savePublicCert(makePublicCert({ id: id2, email: `${id2}@test.com` })); - const certs = await listPublicCerts(); - expect(certs.find(c => c.id === id1)).toBeDefined(); - expect(certs.find(c => c.id === id2)).toBeDefined(); - }); - - it('deletes a public cert', async () => { - const id = uid(); - const cert = makePublicCert({ id }); - await savePublicCert(cert); - await deletePublicCert(id); - const certs = await listPublicCerts(); - expect(certs.find(c => c.id === id)).toBeUndefined(); - }); - }); - - describe('accountId filtering', () => { - it('listKeyRecords filters by accountId', async () => { - const id1 = uid(); - const id2 = uid(); - await saveKeyRecord(makeKeyRecord({ id: id1, email: `${id1}@a.com`, accountId: 'acct-1' })); - await saveKeyRecord(makeKeyRecord({ id: id2, email: `${id2}@b.com`, accountId: 'acct-2' })); - - const acct1Records = await listKeyRecords('acct-1'); - expect(acct1Records.find(r => r.id === id1)).toBeDefined(); - expect(acct1Records.find(r => r.id === id2)).toBeUndefined(); - }); - - it('listKeyRecords includes records without accountId when filtering', async () => { - const id1 = uid(); - const id2 = uid(); - await saveKeyRecord(makeKeyRecord({ id: id1, email: `${id1}@a.com` })); - await saveKeyRecord(makeKeyRecord({ id: id2, email: `${id2}@b.com`, accountId: 'acct-1' })); - - const acct1Records = await listKeyRecords('acct-1'); - expect(acct1Records.find(r => r.id === id1)).toBeDefined(); - expect(acct1Records.find(r => r.id === id2)).toBeDefined(); - }); - - it('listPublicCerts filters by accountId', async () => { - const id1 = uid(); - const id2 = uid(); - await savePublicCert(makePublicCert({ id: id1, email: `${id1}@a.com`, accountId: 'acct-1' })); - await savePublicCert(makePublicCert({ id: id2, email: `${id2}@b.com`, accountId: 'acct-2' })); - - const acct1Certs = await listPublicCerts('acct-1'); - expect(acct1Certs.find(c => c.id === id1)).toBeDefined(); - expect(acct1Certs.find(c => c.id === id2)).toBeUndefined(); - }); - - it('listPublicCerts includes certs without accountId when filtering', async () => { - const id1 = uid(); - const id2 = uid(); - await savePublicCert(makePublicCert({ id: id1, email: `${id1}@a.com` })); - await savePublicCert(makePublicCert({ id: id2, email: `${id2}@b.com`, accountId: 'acct-1' })); - - const acct1Certs = await listPublicCerts('acct-1'); - expect(acct1Certs.find(c => c.id === id1)).toBeDefined(); - expect(acct1Certs.find(c => c.id === id2)).toBeDefined(); - }); - }); -}); diff --git a/lib/smime/__tests__/mime-builder.test.ts b/lib/smime/__tests__/mime-builder.test.ts deleted file mode 100644 index c98bca1b..00000000 --- a/lib/smime/__tests__/mime-builder.test.ts +++ /dev/null @@ -1,259 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { buildMimeMessage, quotedPrintableEncode, base64Encode } from '../mime-builder'; - -// Mock crypto.randomUUID and crypto.getRandomValues for deterministic tests -beforeEach(() => { - let uuidCounter = 0; - vi.spyOn(crypto, 'randomUUID').mockImplementation( - () => `00000000-0000-0000-0000-${String(++uuidCounter).padStart(12, '0')}` as `${string}-${string}-${string}-${string}-${string}`, - ); - - vi.spyOn(crypto, 'getRandomValues').mockImplementation((array: T): T => { - if (array) { - const u8 = new Uint8Array((array as unknown as Uint8Array).buffer); - for (let i = 0; i < u8.length; i++) u8[i] = i; - } - return array; - }); -}); - -describe('mime-builder', () => { - describe('buildMimeMessage', () => { - it('builds a text-only message', () => { - const msg = buildMimeMessage({ - from: { name: 'Alice', email: 'alice@example.com' }, - to: [{ email: 'bob@example.com' }], - subject: 'Hello', - textBody: 'Hi Bob!', - date: new Date('2024-06-15T12:00:00Z'), - }); - - const text = new TextDecoder().decode(msg); - expect(text).toContain('From: "Alice" '); - expect(text).toContain('To: bob@example.com'); - expect(text).toContain('Subject: Hello'); - expect(text).toContain('Content-Type: text/plain; charset=utf-8'); - expect(text).toContain('MIME-Version: 1.0'); - expect(text).toContain('Hi Bob!'); - }); - - it('builds a text + HTML multipart/alternative', () => { - const msg = buildMimeMessage({ - from: { email: 'alice@example.com' }, - to: [{ email: 'bob@example.com' }], - subject: 'Test', - textBody: 'Plain text', - htmlBody: '

HTML body

', - date: new Date('2024-06-15T12:00:00Z'), - }); - - const text = new TextDecoder().decode(msg); - expect(text).toContain('Content-Type: multipart/alternative'); - expect(text).toContain('Content-Type: text/plain; charset=utf-8'); - expect(text).toContain('Content-Type: text/html; charset=utf-8'); - expect(text).toContain('Plain text'); - expect(text).toContain('

HTML body

'); - }); - - it('builds HTML-only message', () => { - const msg = buildMimeMessage({ - from: { email: 'alice@example.com' }, - to: [{ email: 'bob@example.com' }], - subject: 'HTML only', - htmlBody: '

Hello

', - date: new Date('2024-06-15T12:00:00Z'), - }); - - const text = new TextDecoder().decode(msg); - expect(text).toContain('Content-Type: text/html; charset=utf-8'); - expect(text).toContain('

Hello

'); - }); - - it('builds message with attachments', () => { - const attachment = { - filename: 'test.txt', - contentType: 'text/plain', - content: new TextEncoder().encode('file content').buffer, - }; - - const msg = buildMimeMessage({ - from: { email: 'alice@example.com' }, - to: [{ email: 'bob@example.com' }], - subject: 'With attachment', - textBody: 'See attached', - attachments: [attachment], - date: new Date('2024-06-15T12:00:00Z'), - }); - - const text = new TextDecoder().decode(msg); - expect(text).toContain('Content-Type: multipart/mixed'); - expect(text).toContain('Content-Disposition: attachment; filename="test.txt"'); - expect(text).toContain('Content-Transfer-Encoding: base64'); - }); - - it('builds message with inline attachment (cid)', () => { - const inline = { - filename: 'image.png', - contentType: 'image/png', - content: new Uint8Array([0x89, 0x50, 0x4E, 0x47]).buffer, - cid: 'img1', - }; - - const msg = buildMimeMessage({ - from: { email: 'alice@example.com' }, - to: [{ email: 'bob@example.com' }], - subject: 'Inline', - htmlBody: '', - attachments: [inline], - date: new Date('2024-06-15T12:00:00Z'), - }); - - const text = new TextDecoder().decode(msg); - expect(text).toContain('Content-Disposition: inline; filename="image.png"'); - expect(text).toContain('Content-ID: '); - }); - - it('includes CC header when provided', () => { - const msg = buildMimeMessage({ - from: { email: 'alice@example.com' }, - to: [{ email: 'bob@example.com' }], - cc: [{ name: 'Charlie', email: 'charlie@example.com' }], - subject: 'CC test', - textBody: 'Hello', - date: new Date('2024-06-15T12:00:00Z'), - }); - - const text = new TextDecoder().decode(msg); - expect(text).toContain('Cc: "Charlie" '); - }); - - it('omits BCC from MIME headers', () => { - const msg = buildMimeMessage({ - from: { email: 'alice@example.com' }, - to: [{ email: 'bob@example.com' }], - bcc: [{ email: 'secret@example.com' }], - subject: 'BCC test', - textBody: 'Hello', - date: new Date('2024-06-15T12:00:00Z'), - }); - - const text = new TextDecoder().decode(msg); - expect(text).not.toContain('Bcc'); - expect(text).not.toContain('secret@example.com'); - }); - - it('includes In-Reply-To and References', () => { - const msg = buildMimeMessage({ - from: { email: 'alice@example.com' }, - to: [{ email: 'bob@example.com' }], - subject: 'Re: Thread', - textBody: 'reply', - inReplyTo: '', - references: ['', ''], - date: new Date('2024-06-15T12:00:00Z'), - }); - - const text = new TextDecoder().decode(msg); - expect(text).toContain('In-Reply-To: '); - expect(text).toContain('References: '); - }); - - it('encodes non-ASCII subject with RFC 2047', () => { - const msg = buildMimeMessage({ - from: { email: 'alice@example.com' }, - to: [{ email: 'bob@example.com' }], - subject: 'Ünïcödé', - textBody: 'test', - date: new Date('2024-06-15T12:00:00Z'), - }); - - const text = new TextDecoder().decode(msg); - expect(text).toContain('=?UTF-8?Q?'); - }); - - it('uses CRLF line endings', () => { - const msg = buildMimeMessage({ - from: { email: 'alice@example.com' }, - to: [{ email: 'bob@example.com' }], - subject: 'CRLF', - textBody: 'test', - date: new Date('2024-06-15T12:00:00Z'), - }); - - const text = new TextDecoder().decode(msg); - // Should contain CRLF before the body - expect(text).toContain('\r\n'); - // Should not contain bare LF without preceding CR (except within QP encoding) - const lines = text.split('\r\n'); - expect(lines.length).toBeGreaterThan(1); - }); - - it('builds empty body message', () => { - const msg = buildMimeMessage({ - from: { email: 'alice@example.com' }, - to: [{ email: 'bob@example.com' }], - subject: 'Empty', - date: new Date('2024-06-15T12:00:00Z'), - }); - - const text = new TextDecoder().decode(msg); - expect(text).toContain('Content-Type: text/plain; charset=utf-8'); - }); - - it('escapes display name in From header', () => { - const msg = buildMimeMessage({ - from: { name: 'O\'Brien, "Bob"', email: 'bob@example.com' }, - to: [{ email: 'alice@example.com' }], - subject: 'Name test', - textBody: 'test', - date: new Date('2024-06-15T12:00:00Z'), - }); - - const text = new TextDecoder().decode(msg); - expect(text).toContain('From: "O\'Brien, \\"Bob\\"" '); - }); - }); - - describe('quotedPrintableEncode', () => { - it('passes through ASCII text unchanged', () => { - const result = quotedPrintableEncode('Hello World'); - expect(result).toBe('Hello World'); - }); - - it('encodes non-ASCII characters', () => { - const result = quotedPrintableEncode('Héllo'); - expect(result).toContain('='); - }); - - it('encodes equals sign', () => { - const result = quotedPrintableEncode('a=b'); - expect(result).toContain('=3D'); - }); - - it('wraps long lines with soft line break', () => { - const longLine = 'a'.repeat(100); - const result = quotedPrintableEncode(longLine); - const lines = result.split('\r\n'); - for (const line of lines) { - expect(line.length).toBeLessThanOrEqual(76); - } - }); - }); - - describe('base64Encode', () => { - it('encodes binary data to base64', () => { - const data = new Uint8Array([72, 101, 108, 108, 111]).buffer; // "Hello" - const result = base64Encode(data); - expect(result).toBe('SGVsbG8='); - }); - - it('wraps long lines at 76 chars', () => { - const data = new Uint8Array(200).buffer; - const result = base64Encode(data); - const lines = result.split('\r\n'); - for (const line of lines) { - expect(line.length).toBeLessThanOrEqual(76); - } - }); - }); -}); diff --git a/lib/smime/__tests__/pkcs12.test.ts b/lib/smime/__tests__/pkcs12.test.ts deleted file mode 100644 index 790b5cd0..00000000 --- a/lib/smime/__tests__/pkcs12.test.ts +++ /dev/null @@ -1,219 +0,0 @@ -// @vitest-environment node -import { describe, it, expect, beforeAll } from 'vitest'; -import * as pkijs from 'pkijs'; -import * as asn1js from 'asn1js'; -import { importPkcs12, unlockPrivateKey, decryptPrivateKeyBytes } from '../pkcs12-import'; -import { exportPkcs12 } from '../pkcs12-export'; - -const cryptoEngine = new pkijs.CryptoEngine({ - crypto: crypto, - subtle: crypto.subtle, - name: 'webcrypto', -}); - -function stringToAB(str: string): ArrayBuffer { - const buf = new ArrayBuffer(str.length); - const view = new Uint8Array(buf); - for (let i = 0; i < str.length; i++) { - view[i] = str.charCodeAt(i); - } - return buf; -} - -/** - * Build a minimal real PKCS#12 (.p12) blob for testing. - */ -async function buildTestP12( - email: string, - cn: string, - p12Password: string, -): Promise<{ p12Bytes: ArrayBuffer; keyPair: globalThis.CryptoKeyPair; certDer: ArrayBuffer }> { - // Generate RSA key pair (signing) - const keyPair = await crypto.subtle.generateKey( - { - name: 'RSASSA-PKCS1-v1_5', - modulusLength: 2048, - publicExponent: new Uint8Array([1, 0, 1]), - hash: 'SHA-256', - }, - true, - ['sign', 'verify'], - ); - - // Self-signed certificate - const cert = new pkijs.Certificate(); - cert.version = 2; - cert.serialNumber = new asn1js.Integer({ value: 42 }); - - cert.issuer.typesAndValues.push( - new pkijs.AttributeTypeAndValue({ - type: '2.5.4.3', - value: new asn1js.Utf8String({ value: cn }), - }), - ); - cert.subject.typesAndValues.push( - new pkijs.AttributeTypeAndValue({ - type: '2.5.4.3', - value: new asn1js.Utf8String({ value: cn }), - }), - ); - cert.subject.typesAndValues.push( - new pkijs.AttributeTypeAndValue({ - type: '1.2.840.113549.1.9.1', - value: new asn1js.IA5String({ value: email }), - }), - ); - cert.notBefore.value = new Date('2024-01-01T00:00:00Z'); - cert.notAfter.value = new Date('2030-12-31T23:59:59Z'); - - await cert.subjectPublicKeyInfo.importKey(keyPair.publicKey, cryptoEngine); - await cert.sign(keyPair.privateKey, 'SHA-256', cryptoEngine); - - const certDer = cert.toSchema(true).toBER(false); - - // Export private key as PKCS#8 - const pkcs8Bytes = await crypto.subtle.exportKey('pkcs8', keyPair.privateKey); - - // Build PKCS#12 structure - const keyBag = new pkijs.PKCS8ShroudedKeyBag({ - parsedValue: pkijs.PrivateKeyInfo.fromBER(pkcs8Bytes), - }); - - const passwordBuf = stringToAB(p12Password); - - await keyBag.makeInternalValues({ - password: passwordBuf, - contentEncryptionAlgorithm: { - name: 'AES-CBC', - length: 256, - } as Parameters[0]['contentEncryptionAlgorithm'], - hmacHashAlgorithm: 'SHA-256', - iterationCount: 2048, - }); - - const keyBagSafe = new pkijs.SafeBag({ - bagId: '1.2.840.113549.1.12.10.1.2', - bagValue: keyBag, - }); - - const certBagSafe = new pkijs.SafeBag({ - bagId: '1.2.840.113549.1.12.10.1.3', - bagValue: new pkijs.CertBag({ parsedValue: cert }), - }); - - const authenticatedSafe = new pkijs.AuthenticatedSafe({ - parsedValue: { - safeContents: [ - { privacyMode: 0, value: new pkijs.SafeContents({ safeBags: [keyBagSafe] }) }, - { privacyMode: 0, value: new pkijs.SafeContents({ safeBags: [certBagSafe] }) }, - ], - }, - }); - - await authenticatedSafe.makeInternalValues({ safeContents: [{}, {}] }); - - const pfx = new pkijs.PFX({ - parsedValue: { - integrityMode: 0, - authenticatedSafe, - }, - }); - - await pfx.makeInternalValues({ - password: passwordBuf, - iterations: 2048, - pbkdf2HashAlgorithm: 'SHA-256', - hmacHashAlgorithm: 'SHA-256', - }); - - const p12Bytes = pfx.toSchema().toBER(false); - return { p12Bytes, keyPair, certDer }; -} - -let testP12: Awaited>; - -beforeAll(async () => { - pkijs.setEngine('test', crypto, cryptoEngine); - testP12 = await buildTestP12('alice@example.com', 'Alice Test', 'p12pass'); -}); - -describe('importPkcs12', () => { - it('imports a valid PKCS#12 file and produces a key record', async () => { - const result = await importPkcs12(testP12.p12Bytes, 'p12pass', 'storagepass'); - - expect(result.keyRecord).toBeDefined(); - expect(result.keyRecord.email).toBe('alice@example.com'); - expect(result.keyRecord.subject).toContain('Alice Test'); - expect(result.keyRecord.certificate).toBeDefined(); - expect(result.keyRecord.encryptedPrivateKey.byteLength).toBeGreaterThan(0); - expect(result.keyRecord.salt.byteLength).toBeGreaterThan(0); - expect(result.keyRecord.iv.byteLength).toBeGreaterThan(0); - expect(result.keyRecord.kdfIterations).toBe(600_000); - expect(result.keyRecord.fingerprint).toBeTruthy(); - - expect(result.certInfo).toBeDefined(); - expect(result.certInfo.emailAddresses).toContain('alice@example.com'); - }); - - it('throws on invalid ASN.1 data', async () => { - const garbage = new Uint8Array([0, 1, 2, 3]).buffer; - await expect(importPkcs12(garbage, 'pass', 'store')).rejects.toThrow(); - }); -}); - -describe('unlockPrivateKey', () => { - it('unlocks and returns signing and decryption keys', async () => { - const result = await importPkcs12(testP12.p12Bytes, 'p12pass', 'storagepass'); - const { signingKey, decryptionKey } = await unlockPrivateKey(result.keyRecord, 'storagepass'); - - expect(signingKey).toBeDefined(); - expect(signingKey.type).toBe('private'); - expect(signingKey.extractable).toBe(false); - - expect(decryptionKey).toBeDefined(); - expect(decryptionKey!.type).toBe('private'); - expect(decryptionKey!.extractable).toBe(false); - }); - - it('throws on incorrect passphrase', async () => { - const result = await importPkcs12(testP12.p12Bytes, 'p12pass', 'storagepass'); - await expect(unlockPrivateKey(result.keyRecord, 'wrongpass')).rejects.toThrow('Incorrect passphrase'); - }); -}); - -describe('decryptPrivateKeyBytes', () => { - it('returns raw PKCS#8 bytes', async () => { - const result = await importPkcs12(testP12.p12Bytes, 'p12pass', 'storagepass'); - const pkcs8 = await decryptPrivateKeyBytes(result.keyRecord, 'storagepass'); - - expect(pkcs8).toBeInstanceOf(ArrayBuffer); - expect(pkcs8.byteLength).toBeGreaterThan(0); - }); - - it('throws on incorrect passphrase', async () => { - const result = await importPkcs12(testP12.p12Bytes, 'p12pass', 'storagepass'); - await expect(decryptPrivateKeyBytes(result.keyRecord, 'bad')).rejects.toThrow('Incorrect passphrase'); - }); -}); - -describe('exportPkcs12', () => { - it('produces a valid PKCS#12 that can be re-imported', async () => { - const imported = await importPkcs12(testP12.p12Bytes, 'p12pass', 'storagepass'); - - // Export - const p12Out = await exportPkcs12(imported.keyRecord, 'storagepass', 'exportpass'); - expect(p12Out).toBeInstanceOf(ArrayBuffer); - expect(p12Out.byteLength).toBeGreaterThan(0); - - // Re-import - const reimported = await importPkcs12(p12Out, 'exportpass', 'newstoragepass'); - expect(reimported.keyRecord.email).toBe('alice@example.com'); - expect(reimported.keyRecord.subject).toContain('Alice Test'); - expect(reimported.keyRecord.fingerprint).toBe(imported.keyRecord.fingerprint); - }); - - it('throws on incorrect storage passphrase', async () => { - const imported = await importPkcs12(testP12.p12Bytes, 'p12pass', 'storagepass'); - await expect(exportPkcs12(imported.keyRecord, 'wrong', 'exportpass')).rejects.toThrow('Incorrect passphrase'); - }); -}); diff --git a/lib/smime/__tests__/smime-crypto.test.ts b/lib/smime/__tests__/smime-crypto.test.ts deleted file mode 100644 index 8a840a91..00000000 --- a/lib/smime/__tests__/smime-crypto.test.ts +++ /dev/null @@ -1,361 +0,0 @@ -// @vitest-environment node -import { describe, it, expect, beforeAll } from 'vitest'; -import * as pkijs from 'pkijs'; -import * as asn1js from 'asn1js'; -import { smimeSign } from '../smime-sign'; -import { smimeEncrypt } from '../smime-encrypt'; -import { smimeDecrypt, SmimeKeyLockedError, findDecryptionCandidates, normalizeCmsBytes } from '../smime-decrypt'; -import { smimeVerify } from '../smime-verify'; -import { extractCertificateInfo } from '../certificate-utils'; -import type { SmimeKeyRecord } from '../types'; - -// ─── KNOWN ISSUE: skipped (pre-existing, not a logical test failure) ────────── -// This suite OOMs its Vitest worker: during the encrypt/decrypt roundtrip the -// heap climbs past ~4 GB and the worker dies with -// "FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of -// memory". The cause is excessive allocation in the S/MIME crypto path -// (real 2048-bit RSA via pkijs/asn1js under the Node webcrypto engine), not the -// assertions themselves. It reproduces on main, independent of any branch. -// -// Skipped so the rest of the suite stays green and CI workers don't crash. -// To work on it: flip the flag below to false and run only this file, e.g. -// npx vitest run lib/smime/__tests__/smime-crypto.test.ts -// Likely directions: investigate the pkijs CMS allocation growth / retained -// buffers, reuse a single generated key set, or split into smaller cases. -const SKIP_SMIME_CRYPTO_OOM = true; -const describeSmime = SKIP_SMIME_CRYPTO_OOM ? describe.skip : describe; - -/** - * Integration tests for S/MIME sign→verify and encrypt→decrypt roundtrips. - * Uses Node.js crypto (not jsdom) for accurate Web Crypto behavior. - */ - -const testMimeBytes = new TextEncoder().encode( - 'Content-Type: text/plain; charset=utf-8\r\n\r\nHello, World!', -); - -const cryptoEngine = new pkijs.CryptoEngine({ - crypto: crypto, - subtle: crypto.subtle, - name: 'webcrypto', -}); - -async function buildCert( - cn: string, - email: string, - publicKey: CryptoKey, - signingPrivateKey: CryptoKey, -): Promise<{ cert: pkijs.Certificate; certDer: ArrayBuffer }> { - const cert = new pkijs.Certificate(); - cert.version = 2; - cert.serialNumber = new asn1js.Integer({ value: Math.floor(Math.random() * 100000) }); - - cert.issuer.typesAndValues.push( - new pkijs.AttributeTypeAndValue({ - type: '2.5.4.3', - value: new asn1js.Utf8String({ value: cn }), - }), - ); - cert.subject.typesAndValues.push( - new pkijs.AttributeTypeAndValue({ - type: '2.5.4.3', - value: new asn1js.Utf8String({ value: cn }), - }), - ); - cert.subject.typesAndValues.push( - new pkijs.AttributeTypeAndValue({ - type: '1.2.840.113549.1.9.1', - value: new asn1js.IA5String({ value: email }), - }), - ); - cert.notBefore.value = new Date('2024-01-01T00:00:00Z'); - cert.notAfter.value = new Date('2030-12-31T23:59:59Z'); - - await cert.subjectPublicKeyInfo.importKey(publicKey, cryptoEngine); - - await cert.sign(signingPrivateKey, 'SHA-256', cryptoEngine); - - const certDer = cert.toSchema(true).toBER(false); - return { cert, certDer }; -} - -async function makeKeyRecord( - id: string, - email: string, - certDer: ArrayBuffer, -): Promise { - const cert = new pkijs.Certificate({ - schema: asn1js.fromBER(certDer).result, - }); - const info = await extractCertificateInfo(cert, certDer); - return { - id, - email: email.toLowerCase(), - certificate: certDer, - certificateChain: [], - encryptedPrivateKey: new ArrayBuffer(0), - salt: new ArrayBuffer(0), - iv: new ArrayBuffer(0), - kdfIterations: 600000, - issuer: info.issuer, - subject: info.subject, - serialNumber: info.serialNumber, - notBefore: info.notBefore, - notAfter: info.notAfter, - fingerprint: info.fingerprint, - algorithm: info.algorithm, - capabilities: info.capabilities, - }; -} - -// Signing key pair and cert (RSASSA-PKCS1-v1_5 public key embedded in cert) -let signKeyPair: globalThis.CryptoKeyPair; -let signCertDer: ArrayBuffer; - -// Encryption key pair and cert (RSA-OAEP public key embedded in cert) -let encKeyPair: globalThis.CryptoKeyPair; -let encCertDer: ArrayBuffer; -let encKeyRecord: SmimeKeyRecord; - -// Second encryption identity for cross-recipient tests -let bobEncKeyPair: globalThis.CryptoKeyPair; -let bobEncCertDer: ArrayBuffer; -let bobKeyRecord: SmimeKeyRecord; - -beforeAll(async () => { - // Suite is skipped (see SKIP_SMIME_CRYPTO_OOM); bail before the expensive RSA - // key generation so the skipped file stays fast. - if (SKIP_SMIME_CRYPTO_OOM) return; - pkijs.setEngine('test', crypto, cryptoEngine); - - // --- Signing identity --- - signKeyPair = await crypto.subtle.generateKey( - { name: 'RSASSA-PKCS1-v1_5', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, - true, - ['sign', 'verify'], - ); - const signResult = await buildCert('Alice Signer', 'alice@example.com', signKeyPair.publicKey, signKeyPair.privateKey); - signCertDer = signResult.certDer; - - // --- Encryption identity (Alice) --- - encKeyPair = await crypto.subtle.generateKey( - { name: 'RSA-OAEP', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, - true, - ['encrypt', 'decrypt', 'wrapKey', 'unwrapKey'], - ); - // Self-sign with a temporary signing key - const tempSignKey = await crypto.subtle.generateKey( - { name: 'RSASSA-PKCS1-v1_5', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, - true, - ['sign', 'verify'], - ); - const encResult = await buildCert('Alice', 'alice@example.com', encKeyPair.publicKey, tempSignKey.privateKey); - encCertDer = encResult.certDer; - encKeyRecord = await makeKeyRecord('key-alice-enc', 'alice@example.com', encCertDer); - - // --- Bob encryption identity --- - bobEncKeyPair = await crypto.subtle.generateKey( - { name: 'RSA-OAEP', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, - true, - ['encrypt', 'decrypt', 'wrapKey', 'unwrapKey'], - ); - const bobTempSignKey = await crypto.subtle.generateKey( - { name: 'RSASSA-PKCS1-v1_5', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, - true, - ['sign', 'verify'], - ); - const bobResult = await buildCert('Bob', 'bob@example.com', bobEncKeyPair.publicKey, bobTempSignKey.privateKey); - bobEncCertDer = bobResult.certDer; - bobKeyRecord = await makeKeyRecord('key-bob-enc', 'bob@example.com', bobEncCertDer); -}); - -describeSmime('smimeSign + smimeVerify roundtrip', () => { - it('signs and verifies a message successfully', async () => { - const signedBlob = await smimeSign(testMimeBytes, signKeyPair.privateKey, signCertDer); - expect(signedBlob).toBeInstanceOf(Blob); - expect(signedBlob.type).toContain('application/pkcs7-mime'); - - const cmsBytes = await signedBlob.arrayBuffer(); - const result = await smimeVerify(cmsBytes, 'alice@example.com'); - - expect(result.status.isSigned).toBe(true); - expect(result.status.signatureValid).toBe(true); - expect(result.status.signerEmailMatch).toBe(true); - expect(result.status.signerCert).toBeDefined(); - expect(result.status.signerCert!.email).toBe('alice@example.com'); - - const innerText = new TextDecoder().decode(result.mimeBytes); - expect(innerText).toContain('Hello, World!'); - }); - - it('reports email mismatch when From differs from signer', async () => { - const signedBlob = await smimeSign(testMimeBytes, signKeyPair.privateKey, signCertDer); - const cmsBytes = await signedBlob.arrayBuffer(); - const result = await smimeVerify(cmsBytes, 'evil@attacker.com'); - - expect(result.status.isSigned).toBe(true); - expect(result.status.signerEmailMatch).toBe(false); - }); -}); - -describeSmime('smimeEncrypt + smimeDecrypt roundtrip', () => { - it('encrypts and decrypts a message', async () => { - const encryptedBlob = await smimeEncrypt( - testMimeBytes, - [encCertDer], - encCertDer, - ); - expect(encryptedBlob).toBeInstanceOf(Blob); - expect(encryptedBlob.type).toContain('application/pkcs7-mime'); - - const cmsBytes = await encryptedBlob.arrayBuffer(); - const unlockedKeys = new Map(); - unlockedKeys.set(encKeyRecord.id, encKeyPair.privateKey); - - const result = await smimeDecrypt({ - cmsBytes, - keyRecords: [encKeyRecord], - unlockedKeys, - }); - - expect(result.mimeBytes).toBeDefined(); - const decryptedText = new TextDecoder().decode(result.mimeBytes); - expect(decryptedText).toContain('Hello, World!'); - expect(result.keyRecordId).toBe(encKeyRecord.id); - }); - - it('throws when no matching key is available', async () => { - const encryptedBlob = await smimeEncrypt( - testMimeBytes, - [encCertDer], - encCertDer, - ); - const cmsBytes = await encryptedBlob.arrayBuffer(); - - // Bob's key record doesn't match Alice's encrypted message - await expect( - smimeDecrypt({ - cmsBytes, - keyRecords: [bobKeyRecord], - unlockedKeys: new Map(), - }), - ).rejects.toThrow('No imported S/MIME key matches'); - }); -}); - -describeSmime('SmimeKeyLockedError', () => { - it('has correct name and keyRecordId', () => { - const err = new SmimeKeyLockedError('test', 'key-1'); - expect(err.name).toBe('SmimeKeyLockedError'); - expect(err.keyRecordId).toBe('key-1'); - expect(err.message).toBe('test'); - expect(err).toBeInstanceOf(Error); - }); -}); - -describeSmime('findDecryptionCandidates', () => { - it('returns empty array for invalid CMS data', () => { - const garbage = new Uint8Array([0, 1, 2, 3]).buffer; - const result = findDecryptionCandidates(garbage, [encKeyRecord]); - expect(result).toEqual([]); - }); -}); - -describeSmime('smimeVerify edge cases', () => { - it('throws on invalid ASN.1 data', async () => { - const garbage = new Uint8Array([0, 1, 2, 3]).buffer; - await expect(smimeVerify(garbage)).rejects.toThrow(); - }); -}); - -describeSmime('normalizeCmsBytes', () => { - // Helper: a minimal DER-encoded ASN.1 SEQUENCE (0x30 tag) - const derBytes = new Uint8Array([0x30, 0x03, 0x02, 0x01, 0x05]); - - it('passes through raw DER unchanged', () => { - const result = new Uint8Array(normalizeCmsBytes(derBytes.buffer as ArrayBuffer)); - expect(result).toEqual(derBytes); - }); - - it('passes through empty buffer unchanged', () => { - const result = normalizeCmsBytes(new ArrayBuffer(0)); - expect(result.byteLength).toBe(0); - }); - - it('decodes plain base64 content', () => { - const b64 = btoa(String.fromCharCode(...derBytes)); - const input = new TextEncoder().encode(b64).buffer as ArrayBuffer; - const result = new Uint8Array(normalizeCmsBytes(input)); - expect(result).toEqual(derBytes); - }); - - it('decodes base64 content with MIME headers', () => { - const b64 = btoa(String.fromCharCode(...derBytes)); - const mime = - 'Content-Type: application/pkcs7-mime\r\n' + - 'Content-Transfer-Encoding: base64\r\n' + - '\r\n' + - b64 + '\r\n'; - const input = new TextEncoder().encode(mime).buffer as ArrayBuffer; - const result = new Uint8Array(normalizeCmsBytes(input)); - expect(result).toEqual(derBytes); - }); - - it('decodes PEM-wrapped content', () => { - const b64 = btoa(String.fromCharCode(...derBytes)); - const pem = '-----BEGIN PKCS7-----\n' + b64 + '\n-----END PKCS7-----\n'; - const input = new TextEncoder().encode(pem).buffer as ArrayBuffer; - const result = new Uint8Array(normalizeCmsBytes(input)); - expect(result).toEqual(derBytes); - }); - - it('decodes MIME headers with unix line endings', () => { - const b64 = btoa(String.fromCharCode(...derBytes)); - const mime = - 'Content-Type: application/pkcs7-mime\n' + - 'Content-Transfer-Encoding: base64\n' + - '\n' + - b64 + '\n'; - const input = new TextEncoder().encode(mime).buffer as ArrayBuffer; - const result = new Uint8Array(normalizeCmsBytes(input)); - expect(result).toEqual(derBytes); - }); - - it('decodes base64 when MIME headers are very long', () => { - const b64 = btoa(String.fromCharCode(...derBytes)); - const longHeader = 'X-Long-Header: ' + 'A'.repeat(3000) + '\r\n'; - const mime = - longHeader + - 'Content-Type: application/pkcs7-mime\r\n' + - 'Content-Transfer-Encoding: base64\r\n' + - '\r\n' + - b64 + '\r\n'; - const input = new TextEncoder().encode(mime).buffer as ArrayBuffer; - const result = new Uint8Array(normalizeCmsBytes(input)); - expect(result).toEqual(derBytes); - }); - - it('extracts largest base64 block from multipart-like text', () => { - const b64 = btoa(String.fromCharCode(...derBytes)); - const multipartLike = - 'Content-Type: multipart/mixed; boundary="b"\r\n\r\n' + - '--b\r\n' + - 'Content-Type: text/plain\r\n\r\n' + - 'hello\r\n' + - '--b\r\n' + - 'Content-Type: application/pkcs7-mime\r\n' + - 'Content-Transfer-Encoding: base64\r\n\r\n' + - b64 + '\r\n' + - '--b--\r\n'; - const input = new TextEncoder().encode(multipartLike).buffer as ArrayBuffer; - const result = new Uint8Array(normalizeCmsBytes(input)); - expect(result).toEqual(derBytes); - }); - - it('returns original when content is not decodable', () => { - const garbage = new Uint8Array([0x01, 0x02, 0xFF, 0xFE]); - const result = normalizeCmsBytes(garbage.buffer as ArrayBuffer); - // Should return original since it can\'t be decoded - expect(result.byteLength).toBeGreaterThan(0); - }); -}); diff --git a/lib/smime/__tests__/smime-detect.test.ts b/lib/smime/__tests__/smime-detect.test.ts deleted file mode 100644 index 80541013..00000000 --- a/lib/smime/__tests__/smime-detect.test.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { detectSmime } from '../smime-detect'; - -describe('detectSmime', () => { - describe('no S/MIME content', () => { - it('returns null type when no arguments provided', () => { - const result = detectSmime(); - expect(result.type).toBeNull(); - expect(result.supported).toBe(false); - }); - - it('returns null type for plain text content', () => { - const result = detectSmime('text/plain'); - expect(result.type).toBeNull(); - expect(result.supported).toBe(false); - }); - - it('returns null type for multipart/mixed without S/MIME', () => { - const result = detectSmime('multipart/mixed; boundary="abc"'); - expect(result.type).toBeNull(); - expect(result.supported).toBe(false); - }); - }); - - describe('Content-Type header detection', () => { - it('detects enveloped-data from Content-Type', () => { - const ct = 'application/pkcs7-mime; smime-type=enveloped-data; name="smime.p7m"'; - const body = { partId: '1', blobId: 'blob1', type: ct }; - const result = detectSmime(ct, body); - expect(result.type).toBe('enveloped-data'); - expect(result.supported).toBe(true); - expect(result.blobId).toBe('blob1'); - expect(result.partId).toBe('1'); - }); - - it('detects signed-data from Content-Type', () => { - const ct = 'application/pkcs7-mime; smime-type=signed-data; name="smime.p7m"'; - const body = { partId: '2', blobId: 'blob2', type: ct }; - const result = detectSmime(ct, body); - expect(result.type).toBe('signed-data'); - expect(result.supported).toBe(true); - expect(result.blobId).toBe('blob2'); - }); - - it('detects x-pkcs7-mime variant', () => { - const ct = 'application/x-pkcs7-mime; smime-type=enveloped-data'; - const body = { partId: '1', blobId: 'blob1', type: ct }; - const result = detectSmime(ct, body); - expect(result.type).toBe('enveloped-data'); - expect(result.supported).toBe(true); - }); - - it('detects detached signature via multipart/signed', () => { - const ct = 'multipart/signed; protocol="application/pkcs7-signature"; micalg=sha-256'; - const result = detectSmime(ct); - expect(result.type).toBe('detached-sig'); - expect(result.supported).toBe(false); - }); - - it('handles generic pkcs7-mime without smime-type', () => { - const ct = 'application/pkcs7-mime; name="smime.p7m"'; - const body = { partId: '1', blobId: 'blob1', type: ct }; - const result = detectSmime(ct, body); - // Should default to enveloped-data for generic pkcs7-mime - expect(result.type).toBe('enveloped-data'); - expect(result.blobId).toBe('blob1'); - }); - - it('is case-insensitive for Content-Type', () => { - const ct = 'Application/PKCS7-MIME; smime-type=Enveloped-Data'; - const body = { partId: '1', blobId: 'b1', type: ct }; - const result = detectSmime(ct, body); - expect(result.type).toBe('enveloped-data'); - expect(result.supported).toBe(true); - }); - }); - - describe('bodyStructure detection', () => { - it('finds pkcs7-mime part in bodyStructure tree', () => { - const body = { - type: 'multipart/mixed', - subParts: [ - { partId: '1', type: 'text/plain', blobId: 'text-blob' }, - { - partId: '2', - type: 'application/pkcs7-mime; smime-type=enveloped-data', - blobId: 'cms-blob', - }, - ], - }; - const result = detectSmime(undefined, body); - expect(result.type).toBe('enveloped-data'); - expect(result.supported).toBe(true); - expect(result.blobId).toBe('cms-blob'); - expect(result.partId).toBe('2'); - }); - - it('detects detached sig in multipart/signed bodyStructure', () => { - const body = { - type: 'multipart/signed', - subParts: [ - { partId: '1', type: 'text/plain', blobId: 'text-blob' }, - { partId: '2', type: 'application/pkcs7-signature', blobId: 'sig-blob' }, - ], - }; - const result = detectSmime(undefined, body); - expect(result.type).toBe('detached-sig'); - expect(result.supported).toBe(false); - }); - - it('walks nested bodyStructure', () => { - const body = { - type: 'multipart/mixed', - subParts: [ - { - type: 'multipart/alternative', - subParts: [ - { partId: '1.1', type: 'text/plain', blobId: 'txt' }, - { partId: '1.2', type: 'text/html', blobId: 'html' }, - ], - }, - { - partId: '2', - type: 'application/pkcs7-mime; smime-type=signed-data', - blobId: 'sig-blob', - }, - ], - }; - const result = detectSmime(undefined, body); - expect(result.type).toBe('signed-data'); - expect(result.supported).toBe(true); - expect(result.blobId).toBe('sig-blob'); - }); - }); - - describe('attachment detection', () => { - it('detects .p7m attachment', () => { - const attachments = [ - { partId: '3', blobId: 'att-blob', name: 'message.p7m', type: 'application/octet-stream' }, - ]; - const result = detectSmime(undefined, null, attachments); - expect(result.type).toBe('enveloped-data'); - expect(result.supported).toBe(true); - expect(result.blobId).toBe('att-blob'); - }); - - it('detects .p7s attachment as detached-sig', () => { - const attachments = [ - { partId: '3', blobId: 'sig-blob', name: 'smime.p7s', type: 'application/octet-stream' }, - ]; - const result = detectSmime(undefined, null, attachments); - expect(result.type).toBe('detached-sig'); - expect(result.supported).toBe(false); - }); - - it('detects pkcs7-mime attachment type', () => { - const attachments = [ - { - partId: '2', - blobId: 'enc-blob', - name: 'encrypted.bin', - type: 'application/pkcs7-mime; smime-type=enveloped-data', - }, - ]; - const result = detectSmime(undefined, null, attachments); - expect(result.type).toBe('enveloped-data'); - expect(result.supported).toBe(true); - }); - - it('skips non-S/MIME attachments', () => { - const attachments = [ - { partId: '2', blobId: 'pdf-blob', name: 'document.pdf', type: 'application/pdf' }, - ]; - const result = detectSmime(undefined, null, attachments); - expect(result.type).toBeNull(); - expect(result.supported).toBe(false); - }); - }); - - describe('priority order', () => { - it('Content-Type takes precedence over bodyStructure', () => { - const ct = 'application/pkcs7-mime; smime-type=enveloped-data'; - const body = { - partId: '1', - blobId: 'from-ct', - type: ct, - }; - const result = detectSmime(ct, body); - expect(result.type).toBe('enveloped-data'); - expect(result.blobId).toBe('from-ct'); - }); - }); -}); diff --git a/lib/smime/__tests__/smime-store.test.ts b/lib/smime/__tests__/smime-store.test.ts deleted file mode 100644 index a9963156..00000000 --- a/lib/smime/__tests__/smime-store.test.ts +++ /dev/null @@ -1,360 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -// Mock IndexedDB storage functions before importing store -vi.mock('@/lib/smime/key-storage', () => ({ - saveKeyRecord: vi.fn().mockResolvedValue(undefined), - listKeyRecords: vi.fn().mockResolvedValue([]), - deleteKeyRecord: vi.fn().mockResolvedValue(undefined), - savePublicCert: vi.fn().mockResolvedValue(undefined), - listPublicCerts: vi.fn().mockResolvedValue([]), - deletePublicCert: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock('@/lib/smime/pkcs12-import', () => ({ - importPkcs12: vi.fn(), - unlockPrivateKey: vi.fn(), -})); - -vi.mock('@/lib/smime/certificate-utils', () => ({ - parseCertificatePemOrDer: vi.fn(), - extractCertificateInfo: vi.fn(), -})); - -import { useSmimeStore } from '@/stores/smime-store'; -import { listKeyRecords, listPublicCerts, saveKeyRecord, deleteKeyRecord, deletePublicCert } from '@/lib/smime/key-storage'; -import { importPkcs12, unlockPrivateKey } from '@/lib/smime/pkcs12-import'; -import type { SmimeKeyRecord, SmimePublicCert } from '@/lib/smime/types'; - -const mockKeyRecord: SmimeKeyRecord = { - id: 'key-1', - email: 'user@example.com', - certificate: new ArrayBuffer(10), - certificateChain: [], - encryptedPrivateKey: new ArrayBuffer(32), - salt: new ArrayBuffer(16), - iv: new ArrayBuffer(12), - kdfIterations: 600000, - issuer: 'CN=Test CA', - subject: 'CN=Test User', - serialNumber: '01', - notBefore: '2024-01-01T00:00:00Z', - notAfter: '2030-12-31T23:59:59Z', - fingerprint: 'aa:bb:cc', - algorithm: 'RSA-2048', - capabilities: { canSign: true, canEncrypt: true }, -}; - -beforeEach(() => { - localStorage.clear(); - sessionStorage.clear(); - // Reset store state - useSmimeStore.setState({ - keyRecords: [], - publicCerts: [], - unlockedKeys: new Map(), - unlockedDecryptionKeys: new Map(), - identityKeyBindings: {}, - defaultSignIdentity: {}, - defaultEncrypt: false, - autoImportSignerCerts: true, - accountPreferences: {}, - currentAccountId: null, - isLoading: false, - error: null, - }); - vi.clearAllMocks(); -}); - -describe('smime-store', () => { - describe('load', () => { - it('loads key records and public certs from IndexedDB', async () => { - const records = [mockKeyRecord]; - const certs: SmimePublicCert[] = []; - vi.mocked(listKeyRecords).mockResolvedValue(records); - vi.mocked(listPublicCerts).mockResolvedValue(certs); - - await useSmimeStore.getState().load(); - - const state = useSmimeStore.getState(); - expect(state.keyRecords).toEqual(records); - expect(state.publicCerts).toEqual(certs); - expect(state.isLoading).toBe(false); - }); - - it('does not auto-unlock keys on load (security: no persisted passphrases)', async () => { - const records = [mockKeyRecord]; - // Simulate a stale legacy entry written by an older build. - sessionStorage.setItem('smime-unlocked-session', JSON.stringify({ 'key-1': 'passphrase' })); - vi.mocked(listKeyRecords).mockResolvedValue(records); - vi.mocked(listPublicCerts).mockResolvedValue([]); - - await useSmimeStore.getState().load(); - - expect(unlockPrivateKey).not.toHaveBeenCalled(); - expect(useSmimeStore.getState().isKeyUnlocked('key-1')).toBe(false); - }); - - it('sets error on failure', async () => { - vi.mocked(listKeyRecords).mockRejectedValue(new Error('DB failed')); - - await useSmimeStore.getState().load(); - - expect(useSmimeStore.getState().error).toBe('DB failed'); - expect(useSmimeStore.getState().isLoading).toBe(false); - }); - }); - - describe('importPKCS12', () => { - it('imports and adds key record', async () => { - vi.mocked(importPkcs12).mockResolvedValue({ - keyRecord: mockKeyRecord, - certInfo: {} as unknown as import('@/lib/smime/types').CertificateInfo, - }); - - const result = await useSmimeStore.getState().importPKCS12( - new ArrayBuffer(10), - 'p12pass', - 'storagepass', - ); - - expect(result.id).toBe('key-1'); - expect(saveKeyRecord).toHaveBeenCalledWith(mockKeyRecord); - expect(useSmimeStore.getState().keyRecords).toHaveLength(1); - }); - - it('sets error on import failure', async () => { - vi.mocked(importPkcs12).mockRejectedValue(new Error('Bad password')); - - await expect( - useSmimeStore.getState().importPKCS12(new ArrayBuffer(10), 'wrong', 'pass'), - ).rejects.toThrow('Bad password'); - - expect(useSmimeStore.getState().error).toBe('Bad password'); - }); - }); - - describe('removeKeyRecord', () => { - it('removes key record and clears bindings', async () => { - useSmimeStore.setState({ - keyRecords: [mockKeyRecord], - identityKeyBindings: { 'identity-1': 'key-1' }, - unlockedKeys: new Map([['key-1', {} as CryptoKey]]), - unlockedDecryptionKeys: new Map([['key-1', {} as CryptoKey]]), - }); - - await useSmimeStore.getState().removeKeyRecord('key-1'); - - expect(deleteKeyRecord).toHaveBeenCalledWith('key-1'); - expect(useSmimeStore.getState().keyRecords).toHaveLength(0); - expect(useSmimeStore.getState().identityKeyBindings).toEqual({}); - expect(useSmimeStore.getState().unlockedKeys.has('key-1')).toBe(false); - expect(useSmimeStore.getState().unlockedDecryptionKeys.has('key-1')).toBe(false); - }); - }); - - describe('removePublicCert', () => { - it('removes public cert', async () => { - const cert: SmimePublicCert = { - id: 'cert-1', - email: 'recipient@example.com', - certificate: new ArrayBuffer(10), - issuer: 'CN=CA', - subject: 'CN=Recipient', - notBefore: '2024-01-01T00:00:00Z', - notAfter: '2030-12-31T23:59:59Z', - fingerprint: 'aa:bb', - source: 'manual', - }; - useSmimeStore.setState({ publicCerts: [cert] }); - - await useSmimeStore.getState().removePublicCert('cert-1'); - - expect(deletePublicCert).toHaveBeenCalledWith('cert-1'); - expect(useSmimeStore.getState().publicCerts).toHaveLength(0); - }); - }); - - describe('unlockKey + lockKey', () => { - it('unlocks a key', async () => { - const mockSigningKey = {} as CryptoKey; - const mockDecryptionKey = {} as CryptoKey; - vi.mocked(unlockPrivateKey).mockResolvedValue({ signingKey: mockSigningKey, decryptionKey: mockDecryptionKey }); - useSmimeStore.setState({ keyRecords: [mockKeyRecord] }); - - await useSmimeStore.getState().unlockKey('key-1', 'passphrase'); - - expect(useSmimeStore.getState().isKeyUnlocked('key-1')).toBe(true); - expect(useSmimeStore.getState().getUnlockedKey('key-1')).toBe(mockSigningKey); - expect(useSmimeStore.getState().unlockedDecryptionKeys.get('key-1')).toBe(mockDecryptionKey); - }); - - it('never persists the passphrase to sessionStorage', async () => { - const mockSigningKey = {} as CryptoKey; - vi.mocked(unlockPrivateKey).mockResolvedValue({ signingKey: mockSigningKey }); - useSmimeStore.setState({ keyRecords: [mockKeyRecord] }); - - await useSmimeStore.getState().unlockKey('key-1', 'passphrase'); - - expect(sessionStorage.getItem('smime-unlocked-session')).toBeNull(); - }); - - it('stores only the signing key when no decryption key is available', async () => { - const mockSigningKey = {} as CryptoKey; - vi.mocked(unlockPrivateKey).mockResolvedValue({ signingKey: mockSigningKey }); - useSmimeStore.setState({ keyRecords: [mockKeyRecord] }); - - await useSmimeStore.getState().unlockKey('key-1', 'passphrase'); - - expect(useSmimeStore.getState().getUnlockedKey('key-1')).toBe(mockSigningKey); - expect(useSmimeStore.getState().unlockedDecryptionKeys.has('key-1')).toBe(false); - }); - - it('throws for non-existent key record', async () => { - await expect( - useSmimeStore.getState().unlockKey('non-existent', 'pass'), - ).rejects.toThrow('Key record not found'); - }); - - it('locks a key', () => { - useSmimeStore.setState({ - unlockedKeys: new Map([['key-1', {} as CryptoKey]]), - unlockedDecryptionKeys: new Map([['key-1', {} as CryptoKey]]), - }); - - useSmimeStore.getState().lockKey('key-1'); - - expect(useSmimeStore.getState().isKeyUnlocked('key-1')).toBe(false); - expect(useSmimeStore.getState().unlockedDecryptionKeys.has('key-1')).toBe(false); - }); - - it('locks all keys', () => { - useSmimeStore.setState({ - unlockedKeys: new Map([ - ['key-1', {} as CryptoKey], - ['key-2', {} as CryptoKey], - ]), - unlockedDecryptionKeys: new Map([ - ['key-1', {} as CryptoKey], - ['key-2', {} as CryptoKey], - ]), - }); - - useSmimeStore.getState().lockAllKeys(); - - expect(useSmimeStore.getState().unlockedKeys.size).toBe(0); - expect(useSmimeStore.getState().unlockedDecryptionKeys.size).toBe(0); - }); - }); - - describe('identity bindings', () => { - it('binds an identity to a key', () => { - useSmimeStore.getState().bindIdentityToKey('identity-1', 'key-1'); - expect(useSmimeStore.getState().identityKeyBindings['identity-1']).toBe('key-1'); - }); - - it('unbinds an identity', () => { - useSmimeStore.setState({ identityKeyBindings: { 'identity-1': 'key-1' } }); - useSmimeStore.getState().bindIdentityToKey('identity-1', null); - expect(useSmimeStore.getState().identityKeyBindings['identity-1']).toBeUndefined(); - }); - - it('getKeyRecordForIdentity returns the bound record', () => { - useSmimeStore.setState({ - keyRecords: [mockKeyRecord], - identityKeyBindings: { 'identity-1': 'key-1' }, - }); - - const record = useSmimeStore.getState().getKeyRecordForIdentity('identity-1'); - expect(record?.id).toBe('key-1'); - }); - - it('getKeyRecordForIdentity returns undefined for unbound identity', () => { - const record = useSmimeStore.getState().getKeyRecordForIdentity('identity-2'); - expect(record).toBeUndefined(); - }); - }); - - describe('getPublicCertForEmail', () => { - it('finds cert by email (case-insensitive)', () => { - const cert: SmimePublicCert = { - id: 'c1', - email: 'bob@example.com', - certificate: new ArrayBuffer(10), - issuer: 'CN=CA', - subject: 'CN=Bob', - notBefore: '2024-01-01', - notAfter: '2030-12-31', - fingerprint: 'ff', - source: 'manual', - }; - useSmimeStore.setState({ publicCerts: [cert] }); - - expect(useSmimeStore.getState().getPublicCertForEmail('Bob@Example.COM')?.id).toBe('c1'); - }); - - it('returns undefined when not found', () => { - expect(useSmimeStore.getState().getPublicCertForEmail('nobody@test.com')).toBeUndefined(); - }); - }); - - describe('getRecipientCerts', () => { - it('partitions emails into found and missing', () => { - const cert: SmimePublicCert = { - id: 'c1', - email: 'known@example.com', - certificate: new ArrayBuffer(10), - issuer: '', - subject: '', - notBefore: '', - notAfter: '', - fingerprint: '', - source: 'manual', - }; - useSmimeStore.setState({ publicCerts: [cert] }); - - const { found, missing } = useSmimeStore.getState().getRecipientCerts([ - 'known@example.com', - 'unknown@example.com', - ]); - - expect(found).toHaveLength(1); - expect(found[0].id).toBe('c1'); - expect(missing).toEqual(['unknown@example.com']); - }); - }); - - describe('preferences', () => { - it('sets sign default for identity', () => { - useSmimeStore.getState().setSignDefault('identity-1', true); - expect(useSmimeStore.getState().defaultSignIdentity['identity-1']).toBe(true); - }); - - it('sets encrypt default', () => { - useSmimeStore.getState().setEncryptDefault(true); - expect(useSmimeStore.getState().defaultEncrypt).toBe(true); - }); - - it('wipes any legacy persisted passphrases on module load', () => { - // Module already loaded by the import above; simulate a stale entry and - // re-import to confirm the cleanup runs. We use the same key the legacy - // build used and assert it stays absent because the store's module-level - // cleanup has already executed. - expect(sessionStorage.getItem('smime-unlocked-session')).toBeNull(); - }); - - it('sets auto import signer certs', () => { - useSmimeStore.getState().setAutoImportSignerCerts(true); - expect(useSmimeStore.getState().autoImportSignerCerts).toBe(true); - }); - }); - - describe('setError', () => { - it('sets and clears error', () => { - useSmimeStore.getState().setError('Something went wrong'); - expect(useSmimeStore.getState().error).toBe('Something went wrong'); - - useSmimeStore.getState().setError(null); - expect(useSmimeStore.getState().error).toBeNull(); - }); - }); -}); diff --git a/lib/smime/certificate-utils.ts b/lib/smime/certificate-utils.ts deleted file mode 100644 index bef1dd95..00000000 --- a/lib/smime/certificate-utils.ts +++ /dev/null @@ -1,263 +0,0 @@ -import * as asn1js from 'asn1js'; -import * as pkijs from 'pkijs'; -import { Convert } from 'pvtsutils'; -import type { CertificateInfo, SmimeKeyCapabilities } from './types'; - -/** OID for id-kp-emailProtection (S/MIME) */ -const OID_EMAIL_PROTECTION = '1.3.6.1.5.5.7.3.4'; - -/** OID for SubjectAlternativeName */ -const OID_SAN = '2.5.29.17'; - -// ── PEM/DER conversions ────────────────────────────────────────────── - -export function pemToDer(pem: string): ArrayBuffer { - const lines = pem - .replace(/-----BEGIN [^-]+-----/, '') - .replace(/-----END [^-]+-----/, '') - .replace(/\s/g, ''); - return Convert.FromBase64(lines); -} - -export function derToPem(der: ArrayBuffer, label: string): string { - const b64 = Convert.ToBase64(der); - const lines: string[] = []; - for (let i = 0; i < b64.length; i += 64) { - lines.push(b64.slice(i, i + 64)); - } - return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----`; -} - -export function isPem(data: string): boolean { - return /-----BEGIN (CERTIFICATE|PKCS12|ENCRYPTED PRIVATE KEY|PRIVATE KEY)-----/.test(data); -} - -// ── Certificate parsing ────────────────────────────────────────────── - -export function parseCertificateDer(der: ArrayBuffer): pkijs.Certificate { - const asn1 = asn1js.fromBER(der); - if (asn1.offset === -1) { - throw new Error('Invalid DER data: ASN.1 parsing failed'); - } - return new pkijs.Certificate({ schema: asn1.result }); -} - -export function parseCertificatePemOrDer(data: ArrayBuffer | string): pkijs.Certificate { - if (typeof data === 'string') { - if (isPem(data)) { - return parseCertificateDer(pemToDer(data)); - } - throw new Error('String input is not PEM-encoded'); - } - // ArrayBuffer might contain PEM text rather than DER binary - // PEM files start with "-----BEGIN " (0x2D 0x2D 0x2D 0x2D 0x2D 0x42) - const header = new Uint8Array(data, 0, Math.min(20, data.byteLength)); - const maybePem = String.fromCharCode(...header); - if (maybePem.startsWith('-----BEGIN ')) { - const text = new TextDecoder().decode(data); - return parseCertificateDer(pemToDer(text)); - } - return parseCertificateDer(data); -} - -// ── Metadata extraction ────────────────────────────────────────────── - -function rdnToString(rdn: pkijs.RelativeDistinguishedNames): string { - return rdn.typesAndValues - .map((tv) => { - const oid = tv.type; - const val = tv.value.valueBlock.value; - const name = oidToName(oid); - return `${name}=${val}`; - }) - .join(', '); -} - -function oidToName(oid: string): string { - const map: Record = { - '2.5.4.3': 'CN', - '2.5.4.6': 'C', - '2.5.4.7': 'L', - '2.5.4.8': 'ST', - '2.5.4.10': 'O', - '2.5.4.11': 'OU', - '1.2.840.113549.1.9.1': 'E', - }; - return map[oid] ?? oid; -} - -export async function computeFingerprint(der: ArrayBuffer): Promise { - const hash = await crypto.subtle.digest('SHA-256', new Uint8Array(der)); - return Array.from(new Uint8Array(hash)) - .map((b) => b.toString(16).padStart(2, '0')) - .join(':'); -} - -function extractAlgorithm(cert: pkijs.Certificate): string { - const algOid = cert.subjectPublicKeyInfo.algorithm.algorithmId; - // RSA - if (algOid === '1.2.840.113549.1.1.1') { - const pubKey = cert.subjectPublicKeyInfo; - try { - const asn1Pub = asn1js.fromBER(pubKey.subjectPublicKey.valueBlock.valueHexView); - const seq = asn1Pub.result as asn1js.Sequence; - const modulus = seq.valueBlock.value[0] as asn1js.Integer; - const bitLen = (modulus.valueBlock.valueHexView.byteLength - 1) * 8; - return `RSA-${bitLen}`; - } catch { - return 'RSA'; - } - } - // ECDSA - if (algOid === '1.2.840.10045.2.1') { - const params = cert.subjectPublicKeyInfo.algorithm.algorithmParams; - if (params instanceof asn1js.ObjectIdentifier) { - const curveOid = params.valueBlock.toString(); - const curves: Record = { - '1.2.840.10045.3.1.7': 'ECDSA-P256', - '1.3.132.0.34': 'ECDSA-P384', - '1.3.132.0.35': 'ECDSA-P521', - }; - return curves[curveOid] ?? 'ECDSA'; - } - return 'ECDSA'; - } - return algOid; -} - -function extractKeyUsage(cert: pkijs.Certificate): string[] | undefined { - const ext = cert.extensions?.find((e) => e.extnID === '2.5.29.15'); - if (!ext?.parsedValue) return undefined; - const ku = ext.parsedValue as { - digitalSignature?: boolean; - contentCommitment?: boolean; - keyEncipherment?: boolean; - dataEncipherment?: boolean; - keyAgreement?: boolean; - keyCertSign?: boolean; - cRLSign?: boolean; - encipherOnly?: boolean; - decipherOnly?: boolean; - }; - const names: string[] = []; - if (ku.digitalSignature) names.push('digitalSignature'); - if (ku.contentCommitment) names.push('contentCommitment'); - if (ku.keyEncipherment) names.push('keyEncipherment'); - if (ku.dataEncipherment) names.push('dataEncipherment'); - if (ku.keyAgreement) names.push('keyAgreement'); - if (ku.keyCertSign) names.push('keyCertSign'); - if (ku.cRLSign) names.push('cRLSign'); - if (ku.encipherOnly) names.push('encipherOnly'); - if (ku.decipherOnly) names.push('decipherOnly'); - return names; -} - -function extractExtendedKeyUsage(cert: pkijs.Certificate): string[] | undefined { - const ext = cert.extensions?.find((e) => e.extnID === '2.5.29.37'); - if (!ext?.parsedValue) return undefined; - const eku = ext.parsedValue as pkijs.ExtKeyUsage; - return eku.keyPurposes; -} - -function extractEmailAddresses(cert: pkijs.Certificate): string[] { - const emails: string[] = []; - - // From subject emailAddress attribute - for (const tv of cert.subject.typesAndValues) { - if (tv.type === '1.2.840.113549.1.9.1') { - emails.push(tv.value.valueBlock.value as string); - } - } - - // From SubjectAlternativeName - const sanExt = cert.extensions?.find((e) => e.extnID === OID_SAN); - if (sanExt) { - let names: pkijs.GeneralName[] | undefined; - - // parsedValue may be a GeneralNames with .names, or a raw ASN.1 object - const pv = sanExt.parsedValue as pkijs.GeneralNames | undefined; - if (pv?.names) { - names = pv.names; - } else if (sanExt.extnValue) { - // Manually parse the extension value as a SEQUENCE OF GeneralName - try { - const sanAsn1 = asn1js.fromBER(sanExt.extnValue.valueBlock.valueHexView); - if (sanAsn1.offset !== -1) { - const gn = new pkijs.GeneralNames({ schema: sanAsn1.result }); - names = gn.names; - } - } catch { - // Malformed SAN - skip gracefully - } - } - - if (names) { - for (const name of names) { - // type 1 = rfc822Name - if (name.type === 1 && typeof name.value === 'string') { - if (!emails.includes(name.value)) { - emails.push(name.value); - } - } - } - } - } - - return emails; -} - -/** Determine signing/encryption capabilities from KU / EKU. Tolerant of absent extensions. */ -export function classifyCapabilities(cert: pkijs.Certificate): SmimeKeyCapabilities { - const ku = extractKeyUsage(cert); - const eku = extractExtendedKeyUsage(cert); - - let canSign = true; - let canEncrypt = true; - - // If KeyUsage is present, check explicit bits - if (ku) { - canSign = ku.includes('digitalSignature') || ku.includes('contentCommitment'); - canEncrypt = ku.includes('keyEncipherment') || ku.includes('dataEncipherment') || ku.includes('keyAgreement'); - } - - // If EKU is present, only reject if it explicitly excludes emailProtection - if (eku && eku.length > 0) { - const hasEmailProtection = eku.includes(OID_EMAIL_PROTECTION); - // Only restrict if EKU is present and does NOT include emailProtection - if (!hasEmailProtection) { - canSign = false; - canEncrypt = false; - } - } - - return { canSign, canEncrypt }; -} - -/** Extract full metadata from a parsed certificate. */ -export async function extractCertificateInfo( - cert: pkijs.Certificate, - der: ArrayBuffer, -): Promise { - const fingerprint = await computeFingerprint(der); - const ku = extractKeyUsage(cert); - const eku = extractExtendedKeyUsage(cert); - const capabilities = classifyCapabilities(cert); - - return { - subject: rdnToString(cert.subject), - issuer: rdnToString(cert.issuer), - serialNumber: cert.serialNumber.valueBlock.valueHexView - ? Array.from(new Uint8Array(cert.serialNumber.valueBlock.valueHexView)) - .map((b) => b.toString(16).padStart(2, '0')) - .join(':') - : cert.serialNumber.valueBlock.toString(), - notBefore: cert.notBefore.value.toISOString(), - notAfter: cert.notAfter.value.toISOString(), - fingerprint, - algorithm: extractAlgorithm(cert), - keyUsage: ku, - extendedKeyUsage: eku, - emailAddresses: extractEmailAddresses(cert), - capabilities, - }; -} diff --git a/lib/smime/crypto-engine.ts b/lib/smime/crypto-engine.ts deleted file mode 100644 index 792b7a3d..00000000 --- a/lib/smime/crypto-engine.ts +++ /dev/null @@ -1,301 +0,0 @@ -/** - * Crypto engine backed by webcrypto-liner for legacy algorithm support. - * - * webcrypto-liner extends the native Web Crypto API with algorithms - * like DES-EDE3-CBC (3DES) that are commonly found in S/MIME messages - * and PKCS#12 files produced by legacy clients (Outlook, Thunderbird, etc.). - * - * Native Web Crypto calls are passed through to the real implementation; - * liner only intercepts algorithms that the browser doesn't natively support. - * - * Additionally, pkijs's CryptoEngine.decryptEncryptedContentInfo only - * handles PBES2 (OID 1.2.840.113549.1.5.13). Many PKCS#12 files use - * legacy PBE algorithms (e.g. pbeWithSHAAnd3-KeyTripleDES-CBC). We - * extend CryptoEngine to handle those via RFC 7292 Appendix B key - * derivation + webcrypto-liner's DES-EDE3-CBC support. - */ - -import * as asn1js from 'asn1js'; -import * as pkijs from 'pkijs'; - -// webcrypto-liner exports a Crypto constructor at runtime that extends native -// Web Crypto with legacy algorithms (3DES, etc.). Its type declarations only -// expose the type alias, so we import the module dynamically and cast. -// Import the ES module build directly - the package's "browser" field points -// to a shim-only build that has no named exports (no setCrypto, Crypto, etc.). -// eslint-disable-next-line @typescript-eslint/no-require-imports -const liner = require('webcrypto-liner/build/index.es.js') as { - Crypto: { new (): Crypto }; - setCrypto: (subtle: SubtleCrypto) => void; - nativeCrypto: Crypto | Record; -}; - -// ── PKCS#12 legacy PBE OIDs ────────────────────────────────────────── -const PBE_SHA1_3DES_3KEY = '1.2.840.113549.1.12.1.3'; // pbeWithSHAAnd3-KeyTripleDES-CBC -const PBE_SHA1_3DES_2KEY = '1.2.840.113549.1.12.1.4'; // pbeWithSHAAnd2-KeyTripleDES-CBC -const PBE_SHA1_RC2_128 = '1.2.840.113549.1.12.1.5'; // pbeWithSHAAnd128BitRC2-CBC -const PBE_SHA1_RC2_40 = '1.2.840.113549.1.12.1.6'; // pbeWithSHAAnd40BitRC2-CBC - -const LEGACY_PBE_OIDS = new Set([ - PBE_SHA1_3DES_3KEY, - PBE_SHA1_3DES_2KEY, - PBE_SHA1_RC2_128, - PBE_SHA1_RC2_40, -]); - -/** Algorithm config for each legacy PBE OID. */ -function pbeConfig(oid: string): { keyLen: number; ivLen: number; algName: string } { - switch (oid) { - case PBE_SHA1_3DES_3KEY: return { keyLen: 24, ivLen: 8, algName: 'DES-EDE3-CBC' }; - case PBE_SHA1_3DES_2KEY: return { keyLen: 16, ivLen: 8, algName: 'DES-EDE3-CBC' }; - case PBE_SHA1_RC2_128: return { keyLen: 16, ivLen: 8, algName: 'RC2-CBC' }; - case PBE_SHA1_RC2_40: return { keyLen: 5, ivLen: 8, algName: 'RC2-CBC' }; - default: throw new Error(`Unsupported legacy PBE OID: ${oid}`); - } -} - -/** - * PKCS#12 key derivation - RFC 7292, Appendix B. - * - * @param password BMP-encoded password (with trailing 0x00 0x00) - * @param salt raw salt bytes - * @param iterations PBKDF iteration count - * @param id 1 = key material, 2 = IV, 3 = MAC key - * @param needed number of bytes to derive - */ -async function pkcs12KDF( - password: Uint8Array, - salt: Uint8Array, - iterations: number, - id: number, - needed: number, -): Promise { - const v = 64; // SHA-1 block size - const u = 20; // SHA-1 output size - - // Step 1: diversifier D = v bytes of 'id' - const D = new Uint8Array(v); - D.fill(id); - - // Step 2: fill S from salt, padded/repeated to v-byte boundary - const sLen = salt.length === 0 ? 0 : v * Math.ceil(salt.length / v); - const S = new Uint8Array(sLen); - for (let i = 0; i < sLen; i++) S[i] = salt[i % salt.length]; - - // Step 3: fill P from password, padded/repeated to v-byte boundary - const pLen = password.length === 0 ? 0 : v * Math.ceil(password.length / v); - const P = new Uint8Array(pLen); - for (let i = 0; i < pLen; i++) P[i] = password[i % password.length]; - - // I = S || P - const I = new Uint8Array(sLen + pLen); - I.set(S, 0); - I.set(P, sLen); - - const c = Math.ceil(needed / u); - const result = new Uint8Array(c * u); - - for (let i = 0; i < c; i++) { - // Aj = Hash^iterations(D || I) - const buf = new Uint8Array(v + I.length); - buf.set(D, 0); - buf.set(I, v); - - let A = new Uint8Array(await crypto.subtle.digest('SHA-1', buf)); - for (let j = 1; j < iterations; j++) { - A = new Uint8Array(await crypto.subtle.digest('SHA-1', A)); - } - - result.set(A, i * u); - - if (i + 1 < c) { - // Build B by repeating A to fill v bytes - const B = new Uint8Array(v); - for (let j = 0; j < v; j++) B[j] = A[j % u]; - - // I[j] = (I[j] + B + 1) mod 2^v for each v-byte block - for (let j = 0; j < I.length; j += v) { - let carry = 1; - for (let k = v - 1; k >= 0; k--) { - const sum = I[j + k] + B[k] + carry; - I[j + k] = sum & 0xff; - carry = sum >> 8; - } - } - } - } - - return result.slice(0, needed); -} - -/** Encode a password as BMP string with trailing NUL pair (RFC 7292 §B.1). */ -function passwordToBMP(password: ArrayBuffer): Uint8Array { - const passView = new Uint8Array(password); - // If already BMP-encoded (even length, every odd byte is 0x00 for ASCII), - // or empty, use as-is. Otherwise convert char codes to big-endian UCS-2. - // pkijs passes the password as a raw ArrayBuffer of char codes. - const bmp = new Uint8Array(passView.length * 2 + 2); - for (let i = 0; i < passView.length; i++) { - bmp[i * 2] = 0; - bmp[i * 2 + 1] = passView[i]; - } - // trailing 0x00 0x00 - bmp[bmp.length - 2] = 0; - bmp[bmp.length - 1] = 0; - return bmp; -} - -// ── CMS content encryption OIDs (for EnvelopedData decryption) ───── -const OID_DES_EDE3_CBC = '1.2.840.113549.3.7'; // des-EDE3-CBC (3DES) -const OID_DES_CBC = '1.3.14.3.2.7'; // desCBC -const OID_RC2_CBC = '1.2.840.113549.3.2'; // rc2CBC - -/** - * Extended CryptoEngine that handles legacy algorithms (3DES, etc.) - * not recognized by pkijs's default CryptoEngine. - * - * - Adds OID→algorithm mappings for DES-EDE3-CBC so that - * EnvelopedData.decrypt() can process 3DES-encrypted S/MIME messages. - * - Handles legacy PKCS#12 PBE algorithms via custom KDF. - */ -class Pkcs12CryptoEngine extends pkijs.CryptoEngine { - /** - * Extend OID→algorithm mapping with legacy algorithms that webcrypto-liner - * supports but pkijs does not know about. - */ - getAlgorithmByOID(oid: string, safety?: boolean, target?: string): object { - switch (oid) { - case OID_DES_EDE3_CBC: - return { name: 'DES-EDE3-CBC', length: 192 }; - case OID_DES_CBC: - return { name: 'DES-CBC', length: 64 }; - case OID_RC2_CBC: - return { name: 'RC2-CBC', length: 128 }; - default: - return super.getAlgorithmByOID(oid, safety, target); - } - } - - getOIDByAlgorithm(algorithm: { name: string; length?: number }, safety?: boolean, target?: string): string { - switch (algorithm.name.toUpperCase()) { - case 'DES-EDE3-CBC': - return OID_DES_EDE3_CBC; - case 'DES-CBC': - return OID_DES_CBC; - case 'RC2-CBC': - return OID_RC2_CBC; - default: - return super.getOIDByAlgorithm(algorithm, safety, target); - } - } - - async decryptEncryptedContentInfo( - parameters: Parameters[0], - ): Promise { - const oid = parameters.encryptedContentInfo.contentEncryptionAlgorithm.algorithmId; - - if (!LEGACY_PBE_OIDS.has(oid)) { - // Delegate to base CryptoEngine (handles PBES2) - return super.decryptEncryptedContentInfo(parameters); - } - - const algParams = parameters.encryptedContentInfo.contentEncryptionAlgorithm.algorithmParams; - if (!algParams) { - throw new Error('Missing PBE algorithm parameters'); - } - - // Parse PBEParameter ::= SEQUENCE { salt OCTET STRING, iterationCount INTEGER } - const paramAsn1 = asn1js.fromBER(algParams.toBER(false)); - if (paramAsn1.offset === -1) { - throw new Error('Invalid PBE parameters ASN.1'); - } - const seq = paramAsn1.result as asn1js.Sequence; - const salt = new Uint8Array((seq.valueBlock.value[0] as asn1js.OctetString).valueBlock.valueHexView); - const iterations = (seq.valueBlock.value[1] as asn1js.Integer).valueBlock.valueDec; - - const { keyLen, ivLen, algName } = pbeConfig(oid); - const bmpPassword = passwordToBMP(parameters.password); - - // Derive key (id=1) and IV (id=2) using PKCS#12 KDF - const keyBytes = await pkcs12KDF(bmpPassword, salt, iterations, 1, keyLen); - const ivBytes = await pkcs12KDF(bmpPassword, salt, iterations, 2, ivLen); - - // Import key via webcrypto-liner (supports DES-EDE3-CBC) - const keyData = new Uint8Array(keyBytes.buffer as ArrayBuffer, keyBytes.byteOffset, keyBytes.byteLength); - const cryptoKey = await this.importKey( - 'raw', - keyData, - { name: algName, length: keyLen * 8 } as Algorithm, - false, - ['decrypt'], - ); - - // Decrypt - const ciphertext = parameters.encryptedContentInfo.getEncryptedContent(); - return this.decrypt( - { name: algName, iv: ivBytes } as Algorithm, - cryptoKey, - ciphertext, - ); - } -} - -let linerEngine: Pkcs12CryptoEngine | null = null; -let linerCryptoInstance: Crypto | null = null; - -function ensureLiner() { - if (!linerCryptoInstance) { - // In Node.js, webcrypto-liner can't auto-detect the native crypto - // (it looks for self.crypto which doesn't exist). Feed it manually - // so that native algorithms (RSA, AES, etc.) stay hardware-accelerated - // and only truly missing algorithms (3DES) use the software fallback. - if ( - typeof liner.nativeCrypto?.getRandomValues !== 'function' && - typeof globalThis.crypto?.subtle !== 'undefined' - ) { - liner.setCrypto(globalThis.crypto.subtle); - } - linerCryptoInstance = new liner.Crypto(); - } - if (!linerEngine) { - linerEngine = new Pkcs12CryptoEngine({ - crypto: linerCryptoInstance, - subtle: linerCryptoInstance.subtle, - name: 'webcrypto-liner', - }); - } -} - -/** Get a PKI.js CryptoEngine with 3DES (and other legacy algorithm) support. */ -export function getLinerCryptoEngine(): pkijs.CryptoEngine { - ensureLiner(); - return linerEngine!; -} - -/** Get the webcrypto-liner Crypto instance (for importKey with legacy algorithms). */ -export function getLinerCrypto(): Crypto { - ensureLiner(); - return linerCryptoInstance!; -} - -/** - * Run an async operation with the global PKI.js engine set to webcrypto-liner, - * then restore the previous engine afterwards. - * - * Required for operations that use the global engine internally - * (e.g. PFX.parseInternalValues for PKCS#12 import). - */ -export async function withLinerEngine(fn: () => Promise): Promise { - ensureLiner(); - - // Save the current global engine so we can restore it - const prev = pkijs.getEngine(); - - pkijs.setEngine('webcrypto-liner', linerCryptoInstance!, linerEngine!); - try { - return await fn(); - } finally { - // Restore the previous engine - pkijs.setEngine(prev.name, prev.crypto as unknown as pkijs.CryptoEngine); - } -} diff --git a/lib/smime/key-storage.ts b/lib/smime/key-storage.ts deleted file mode 100644 index 30a1a33e..00000000 --- a/lib/smime/key-storage.ts +++ /dev/null @@ -1,118 +0,0 @@ -import type { SmimeKeyRecord, SmimePublicCert } from './types'; - -const DB_NAME = 'smime-store'; -const DB_VERSION = 2; -const KEY_RECORDS_STORE = 'key-records'; -const PUBLIC_CERTS_STORE = 'public-certs'; - -function openDB(): Promise { - return new Promise((resolve, reject) => { - const request = indexedDB.open(DB_NAME, DB_VERSION); - request.onupgradeneeded = (event) => { - const db = request.result; - const oldVersion = event.oldVersion; - if (oldVersion < 1) { - const keyStore = db.createObjectStore(KEY_RECORDS_STORE, { keyPath: 'id' }); - keyStore.createIndex('email', 'email', { unique: false }); - keyStore.createIndex('accountId', 'accountId', { unique: false }); - const certStore = db.createObjectStore(PUBLIC_CERTS_STORE, { keyPath: 'id' }); - certStore.createIndex('email', 'email', { unique: false }); - certStore.createIndex('accountId', 'accountId', { unique: false }); - } - if (oldVersion >= 1 && oldVersion < 2) { - // Add accountId index to existing stores - const tx = request.transaction!; - const keyStore = tx.objectStore(KEY_RECORDS_STORE); - if (!keyStore.indexNames.contains('accountId')) { - keyStore.createIndex('accountId', 'accountId', { unique: false }); - } - const certStore = tx.objectStore(PUBLIC_CERTS_STORE); - if (!certStore.indexNames.contains('accountId')) { - certStore.createIndex('accountId', 'accountId', { unique: false }); - } - } - }; - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error); - }); -} - -function txPromise( - db: IDBDatabase, - storeName: string, - mode: globalThis.IDBTransactionMode, - fn: (store: IDBObjectStore) => IDBRequest, -): Promise { - return new Promise((resolve, reject) => { - const tx = db.transaction(storeName, mode); - const store = tx.objectStore(storeName); - const req = fn(store); - req.onsuccess = () => resolve(req.result); - req.onerror = () => reject(req.error); - }); -} - -// ── Key record CRUD ───────────────────────────────────────────────── - -export async function saveKeyRecord(record: SmimeKeyRecord): Promise { - const db = await openDB(); - await txPromise(db, KEY_RECORDS_STORE, 'readwrite', (s) => s.put(record)); -} - -export async function getKeyRecord(id: string): Promise { - const db = await openDB(); - return txPromise(db, KEY_RECORDS_STORE, 'readonly', (s) => s.get(id)); -} - -export async function getKeyRecordForEmail(email: string): Promise { - const db = await openDB(); - return new Promise((resolve, reject) => { - const tx = db.transaction(KEY_RECORDS_STORE, 'readonly'); - const idx = tx.objectStore(KEY_RECORDS_STORE).index('email'); - const req = idx.get(email.toLowerCase()); - req.onsuccess = () => resolve(req.result ?? undefined); - req.onerror = () => reject(req.error); - }); -} - -export async function listKeyRecords(accountId?: string): Promise { - const db = await openDB(); - const all = await txPromise(db, KEY_RECORDS_STORE, 'readonly', (s) => s.getAll()); - if (!accountId) return all; - return all.filter((r) => r.accountId === accountId || !r.accountId); -} - -export async function deleteKeyRecord(id: string): Promise { - const db = await openDB(); - await txPromise(db, KEY_RECORDS_STORE, 'readwrite', (s) => s.delete(id)); -} - -// ── Public cert CRUD ──────────────────────────────────────────────── - -export async function savePublicCert(cert: SmimePublicCert): Promise { - const db = await openDB(); - await txPromise(db, PUBLIC_CERTS_STORE, 'readwrite', (s) => s.put(cert)); -} - -export async function getPublicCertForEmail(email: string): Promise { - const db = await openDB(); - return new Promise((resolve, reject) => { - const tx = db.transaction(PUBLIC_CERTS_STORE, 'readonly'); - const idx = tx.objectStore(PUBLIC_CERTS_STORE).index('email'); - const req = idx.get(email.toLowerCase()); - req.onsuccess = () => resolve(req.result ?? undefined); - req.onerror = () => reject(req.error); - }); -} - -export async function listPublicCerts(accountId?: string): Promise { - const db = await openDB(); - const all = await txPromise(db, PUBLIC_CERTS_STORE, 'readonly', (s) => s.getAll()); - if (!accountId) return all; - return all.filter((c) => c.accountId === accountId || !c.accountId); -} - -export async function deletePublicCert(id: string): Promise { - const db = await openDB(); - await txPromise(db, PUBLIC_CERTS_STORE, 'readwrite', (s) => s.delete(id)); -} diff --git a/lib/smime/mime-builder.ts b/lib/smime/mime-builder.ts deleted file mode 100644 index 7f30c791..00000000 --- a/lib/smime/mime-builder.ts +++ /dev/null @@ -1,339 +0,0 @@ -/** - * Minimal, deterministic MIME builder for outgoing S/MIME messages. - * - * Produces canonical text suitable for CMS signing/encryption. - * All line endings are CRLF per RFC 5322. - */ - -import { generateUUID } from '@/lib/utils'; - -const CRLF = '\r\n'; - -export interface MimeAttachment { - filename: string; - contentType: string; - content: ArrayBuffer; - cid?: string; // for inline images -} - -export interface MimeMessageInput { - from: { name?: string; email: string }; - to: { name?: string; email: string }[]; - cc?: { name?: string; email: string }[]; - bcc?: { name?: string; email: string }[]; - subject: string; - date?: Date; - messageId?: string; - inReplyTo?: string; - references?: string[]; - textBody?: string; - htmlBody?: string; - attachments?: MimeAttachment[]; -} - -/** Build a complete MIME message and return it as a Uint8Array (UTF-8). */ -export function buildMimeMessage(input: MimeMessageInput): Uint8Array { - const boundary = generateBoundary(); - const lines: string[] = []; - - // Headers - lines.push(formatHeader('From', formatAddress(input.from))); - lines.push(formatHeader('To', input.to.map(formatAddress).join(', '))); - if (input.cc?.length) { - lines.push(formatHeader('Cc', input.cc.map(formatAddress).join(', '))); - } - // BCC is intentionally omitted from the MIME headers per RFC 5322 - lines.push(formatHeader('Subject', encodeHeaderValue(input.subject))); - lines.push(formatHeader('Date', formatDate(input.date ?? new Date()))); - lines.push(formatHeader('Message-ID', input.messageId ?? `<${generateUUID()}@smime.local>`)); - if (input.inReplyTo) { - lines.push(formatHeader('In-Reply-To', input.inReplyTo)); - } - if (input.references?.length) { - lines.push(formatHeader('References', input.references.join(' '))); - } - lines.push('MIME-Version: 1.0'); - - const hasText = !!input.textBody; - const hasHtml = !!input.htmlBody; - const hasAttachments = !!input.attachments?.length; - - if (!hasAttachments && hasText && !hasHtml) { - // text/plain only - lines.push('Content-Type: text/plain; charset=utf-8'); - lines.push('Content-Transfer-Encoding: quoted-printable'); - lines.push(''); - lines.push(quotedPrintableEncode(input.textBody!)); - } else if (!hasAttachments && hasText && hasHtml) { - // multipart/alternative - const altBoundary = generateBoundary(); - lines.push(`Content-Type: multipart/alternative; boundary="${altBoundary}"`); - lines.push(''); - lines.push(`--${altBoundary}`); - lines.push('Content-Type: text/plain; charset=utf-8'); - lines.push('Content-Transfer-Encoding: quoted-printable'); - lines.push(''); - lines.push(quotedPrintableEncode(input.textBody!)); - lines.push(`--${altBoundary}`); - lines.push('Content-Type: text/html; charset=utf-8'); - lines.push('Content-Transfer-Encoding: quoted-printable'); - lines.push(''); - lines.push(quotedPrintableEncode(input.htmlBody!)); - lines.push(`--${altBoundary}--`); - } else if (!hasAttachments && !hasText && hasHtml) { - // html only - lines.push('Content-Type: text/html; charset=utf-8'); - lines.push('Content-Transfer-Encoding: quoted-printable'); - lines.push(''); - lines.push(quotedPrintableEncode(input.htmlBody!)); - } else if (hasAttachments) { - // multipart/mixed - lines.push(`Content-Type: multipart/mixed; boundary="${boundary}"`); - lines.push(''); - - // Body part - if (hasText && hasHtml) { - const altBoundary = generateBoundary(); - lines.push(`--${boundary}`); - lines.push(`Content-Type: multipart/alternative; boundary="${altBoundary}"`); - lines.push(''); - lines.push(`--${altBoundary}`); - lines.push('Content-Type: text/plain; charset=utf-8'); - lines.push('Content-Transfer-Encoding: quoted-printable'); - lines.push(''); - lines.push(quotedPrintableEncode(input.textBody!)); - lines.push(`--${altBoundary}`); - lines.push('Content-Type: text/html; charset=utf-8'); - lines.push('Content-Transfer-Encoding: quoted-printable'); - lines.push(''); - lines.push(quotedPrintableEncode(input.htmlBody!)); - lines.push(`--${altBoundary}--`); - } else if (hasText) { - lines.push(`--${boundary}`); - lines.push('Content-Type: text/plain; charset=utf-8'); - lines.push('Content-Transfer-Encoding: quoted-printable'); - lines.push(''); - lines.push(quotedPrintableEncode(input.textBody!)); - } else if (hasHtml) { - lines.push(`--${boundary}`); - lines.push('Content-Type: text/html; charset=utf-8'); - lines.push('Content-Transfer-Encoding: quoted-printable'); - lines.push(''); - lines.push(quotedPrintableEncode(input.htmlBody!)); - } - - // Attachments - for (const att of input.attachments!) { - lines.push(`--${boundary}`); - const disposition = att.cid ? 'inline' : 'attachment'; - lines.push(`Content-Type: ${att.contentType}; name="${encodeHeaderValue(att.filename)}"`); - lines.push(`Content-Disposition: ${disposition}; filename="${encodeHeaderValue(att.filename)}"`); - lines.push('Content-Transfer-Encoding: base64'); - if (att.cid) { - lines.push(`Content-ID: <${att.cid}>`); - } - lines.push(''); - lines.push(base64Encode(att.content)); - } - lines.push(`--${boundary}--`); - } else { - // Empty body - lines.push('Content-Type: text/plain; charset=utf-8'); - lines.push(''); - } - - const raw = lines.join(CRLF); - return new TextEncoder().encode(raw); -} - -// ── Helpers ────────────────────────────────────────────────────────── - -function generateBoundary(): string { - const bytes = crypto.getRandomValues(new Uint8Array(16)); - const hex = Array.from(bytes) - .map((b) => b.toString(16).padStart(2, '0')) - .join(''); - return `----=_Part_${hex}`; -} - -function formatAddress(addr: { name?: string; email: string }): string { - if (addr.name) { - // RFC 5322 quoted-string for display name - const escaped = addr.name.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); - return `"${escaped}" <${addr.email}>`; - } - return addr.email; -} - -function formatHeader(name: string, value: string): string { - const full = `${name}: ${value}`; - // RFC 5322 line length limit: fold at 76 chars - if (full.length <= 76) return full; - const parts: string[] = []; - let remaining = full; - let first = true; - while (remaining.length > 76) { - let breakAt = 76; - // Find a space to break at - const spaceIdx = remaining.lastIndexOf(' ', 76); - if (spaceIdx > (first ? name.length + 2 : 1)) { - breakAt = spaceIdx; - } - parts.push(remaining.slice(0, breakAt)); - remaining = ' ' + remaining.slice(breakAt).trimStart(); - first = false; - } - parts.push(remaining); - return parts.join(CRLF); -} - -function encodeHeaderValue(value: string): string { - // Use RFC 2047 encoded-word if non-ASCII - if (/^[\x20-\x7e]*$/.test(value)) return value; - const encoded = Array.from(new TextEncoder().encode(value)) - .map((b) => { - if ( - (b >= 0x30 && b <= 0x39) || // 0-9 - (b >= 0x41 && b <= 0x5a) || // A-Z - (b >= 0x61 && b <= 0x7a) // a-z - ) { - return String.fromCharCode(b); - } - return '=' + b.toString(16).toUpperCase().padStart(2, '0'); - }) - .join(''); - return `=?UTF-8?Q?${encoded}?=`; -} - -function formatDate(date: Date): string { - // RFC 5322 date format - const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; - const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; - const d = days[date.getUTCDay()]; - const dd = date.getUTCDate(); - const m = months[date.getUTCMonth()]; - const y = date.getUTCFullYear(); - const hh = date.getUTCHours().toString().padStart(2, '0'); - const mm = date.getUTCMinutes().toString().padStart(2, '0'); - const ss = date.getUTCSeconds().toString().padStart(2, '0'); - return `${d}, ${dd} ${m} ${y} ${hh}:${mm}:${ss} +0000`; -} - -export interface SmimeWrapInput { - from: { name?: string; email: string }; - to: { name?: string; email: string }[]; - cc?: { name?: string; email: string }[]; - subject: string; - date?: Date; - messageId?: string; - inReplyTo?: string; - references?: string[]; - smimeType: 'signed-data' | 'enveloped-data'; -} - -/** - * Wrap a CMS binary blob in a proper RFC 5322 / S/MIME message. - * - * The server needs RFC 5322 headers (From, To, Subject, etc.) to route - * the message; the CMS blob becomes the base64-encoded body. - */ -export function wrapCmsAsSmimeMessage(cmsBlob: Blob | ArrayBuffer | Uint8Array, input: SmimeWrapInput): Blob { - const lines: string[] = []; - - lines.push(formatHeader('From', formatAddress(input.from))); - lines.push(formatHeader('To', input.to.map(formatAddress).join(', '))); - if (input.cc?.length) { - lines.push(formatHeader('Cc', input.cc.map(formatAddress).join(', '))); - } - lines.push(formatHeader('Subject', encodeHeaderValue(input.subject))); - lines.push(formatHeader('Date', formatDate(input.date ?? new Date()))); - lines.push(formatHeader('Message-ID', input.messageId ?? `<${generateUUID()}@smime.local>`)); - if (input.inReplyTo) { - lines.push(formatHeader('In-Reply-To', input.inReplyTo)); - } - if (input.references?.length) { - lines.push(formatHeader('References', input.references.join(' '))); - } - lines.push('MIME-Version: 1.0'); - lines.push(`Content-Type: application/pkcs7-mime; smime-type=${input.smimeType}; name="smime.p7m"`); - lines.push('Content-Transfer-Encoding: base64'); - lines.push('Content-Disposition: attachment; filename="smime.p7m"'); - lines.push(''); - - const headerPart = lines.join(CRLF); - - // We'll combine header bytes + base64 body - const headerBytes = new TextEncoder().encode(headerPart); - - return new Blob([headerBytes, cmsToBase64Blob(cmsBlob)], { type: 'message/rfc822' }); -} - -function cmsToBase64Blob(data: Blob | ArrayBuffer | Uint8Array): Blob { - let bytes: Uint8Array; - if (data instanceof Uint8Array) { - bytes = data; - } else if (data instanceof ArrayBuffer) { - bytes = new Uint8Array(data); - } else { - // Blob - we need sync; caller should have converted. Fallback to empty. - bytes = new Uint8Array(0); - } - const b64 = base64Encode(bytes.buffer as ArrayBuffer); - return new Blob([new TextEncoder().encode(b64 + CRLF)]); -} - -/** Encode string as quoted-printable (RFC 2045). */ -export function quotedPrintableEncode(input: string): string { - const bytes = new TextEncoder().encode(input); - const lines: string[] = []; - let line = ''; - - for (const b of bytes) { - let encoded: string; - if (b === 0x0d || b === 0x0a) { - // Pass through CRLF as-is (handled below) - encoded = String.fromCharCode(b); - } else if ( - b === 0x09 || // tab - (b >= 0x20 && b <= 0x7e && b !== 0x3d) // printable, not '=' - ) { - encoded = String.fromCharCode(b); - } else { - encoded = '=' + b.toString(16).toUpperCase().padStart(2, '0'); - } - - if (b === 0x0a) { - // End current line (strip any trailing \r already added) - if (line.endsWith('\r')) { - line = line.slice(0, -1); - } - lines.push(line); - line = ''; - continue; - } - - if (line.length + encoded.length > 75) { - lines.push(line + '='); - line = encoded; - } else { - line += encoded; - } - } - lines.push(line); - return lines.join(CRLF); -} - -/** Encode ArrayBuffer as base64 with line breaks at 76 chars. */ -export function base64Encode(data: ArrayBuffer): string { - const bytes = new Uint8Array(data); - let binary = ''; - for (const b of bytes) { - binary += String.fromCharCode(b); - } - const b64 = btoa(binary); - const lines: string[] = []; - for (let i = 0; i < b64.length; i += 76) { - lines.push(b64.slice(i, i + 76)); - } - return lines.join(CRLF); -} diff --git a/lib/smime/pkcs12-export.ts b/lib/smime/pkcs12-export.ts deleted file mode 100644 index 07263e74..00000000 --- a/lib/smime/pkcs12-export.ts +++ /dev/null @@ -1,157 +0,0 @@ -import * as asn1js from 'asn1js'; -import * as pkijs from 'pkijs'; -import { decryptPrivateKeyBytes } from './pkcs12-import'; -import type { SmimeKeyRecord } from './types'; - -function stringToArrayBuffer(str: string): ArrayBuffer { - const buf = new ArrayBuffer(str.length); - const view = new Uint8Array(buf); - for (let i = 0; i < str.length; i++) { - view[i] = str.charCodeAt(i); - } - return buf; -} - -/** - * Export an S/MIME key record as a PKCS#12 (.p12) file. - * - * Flow: - * 1. Decrypt the stored PKCS#8 private key bytes using the storage passphrase. - * 2. Build a PKCS#12 container with the private key, leaf cert, and chain. - * 3. Protect the PKCS#12 with the export passphrase. - * 4. Return the resulting bytes for browser download. - */ -export async function exportPkcs12( - record: SmimeKeyRecord, - storagePassphrase: string, - exportPassphrase: string, -): Promise { - // Step 1: Decrypt the stored private key - const pkcs8Bytes = await decryptPrivateKeyBytes(record, storagePassphrase); - - // Step 2: Parse the leaf certificate - const leafCertAsn1 = asn1js.fromBER(record.certificate); - if (leafCertAsn1.offset === -1) { - throw new Error('Failed to parse leaf certificate'); - } - const leafCert = new pkijs.Certificate({ schema: leafCertAsn1.result }); - - // Parse chain certificates - const chainCerts = record.certificateChain.map((chainDer) => { - const chainAsn1 = asn1js.fromBER(chainDer); - if (chainAsn1.offset === -1) { - throw new Error('Failed to parse chain certificate'); - } - return new pkijs.Certificate({ schema: chainAsn1.result }); - }); - - const passwordBuf = stringToArrayBuffer(exportPassphrase); - - // Step 3: Build the PKCS#12 structure - // Create key bag - const keyBag = new pkijs.PKCS8ShroudedKeyBag({ - parsedValue: pkijs.PrivateKeyInfo.fromBER(pkcs8Bytes), - }); - - await keyBag.makeInternalValues({ - password: passwordBuf, - contentEncryptionAlgorithm: { - name: 'AES-CBC', - length: 256, - } as unknown as Parameters[0]['contentEncryptionAlgorithm'], - hmacHashAlgorithm: 'SHA-256', - iterationCount: 100_000, - }); - - const keyBagSafe = new pkijs.SafeBag({ - bagId: '1.2.840.113549.1.12.10.1.2', // pkcs8ShroudedKeyBag - bagValue: keyBag, - bagAttributes: [ - new pkijs.Attribute({ - type: '1.2.840.113549.1.9.20', // friendlyName - values: [new asn1js.BmpString({ value: record.email })], - }), - ], - }); - - // Create cert bags - const certBags = [ - new pkijs.SafeBag({ - bagId: '1.2.840.113549.1.12.10.1.3', // certBag - bagValue: new pkijs.CertBag({ - parsedValue: leafCert, - }), - bagAttributes: [ - new pkijs.Attribute({ - type: '1.2.840.113549.1.9.20', - values: [new asn1js.BmpString({ value: record.email })], - }), - ], - }), - ...chainCerts.map( - (cert) => - new pkijs.SafeBag({ - bagId: '1.2.840.113549.1.12.10.1.3', - bagValue: new pkijs.CertBag({ - parsedValue: cert, - }), - }), - ), - ]; - - // Build authenticated safe with two SafeContents: - // 1. Key bag (password-encrypted) - // 2. Cert bags (unencrypted) - const authenticatedSafe = new pkijs.AuthenticatedSafe({ - parsedValue: { - safeContents: [ - { - privacyMode: 0, // no extra encryption - key bag is already shrouded - value: new pkijs.SafeContents({ - safeBags: [keyBagSafe], - }), - }, - { - privacyMode: 0, - value: new pkijs.SafeContents({ - safeBags: certBags, - }), - }, - ], - }, - }); - - await authenticatedSafe.makeInternalValues({ - safeContents: [{}, {}], - }); - - const pfx = new pkijs.PFX({ - parsedValue: { - integrityMode: 0, - authenticatedSafe, - }, - }); - - await pfx.makeInternalValues({ - password: passwordBuf, - iterations: 100_000, - pbkdf2HashAlgorithm: 'SHA-256', - hmacHashAlgorithm: 'SHA-256', - }); - - // Step 4: Serialize to DER - return pfx.toSchema().toBER(false); -} - -/** Trigger a browser download of the PKCS#12 file. */ -export function downloadPkcs12(p12Bytes: ArrayBuffer, filename: string): void { - const blob = new Blob([p12Bytes], { type: 'application/x-pkcs12' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); -} diff --git a/lib/smime/pkcs12-import.ts b/lib/smime/pkcs12-import.ts deleted file mode 100644 index d9df58c4..00000000 --- a/lib/smime/pkcs12-import.ts +++ /dev/null @@ -1,341 +0,0 @@ -import * as asn1js from 'asn1js'; -import * as pkijs from 'pkijs'; -import { generateUUID } from '@/lib/utils'; -import { - extractCertificateInfo, - classifyCapabilities, -} from './certificate-utils'; -import type { SmimeKeyRecord, Pkcs12ImportResult } from './types'; -import { withLinerEngine, getLinerCrypto } from './crypto-engine'; - -const KDF_ITERATIONS = 600_000; -const AES_KEY_LENGTH = 256; - -function stringToAB(str: string): ArrayBuffer { - const buf = new ArrayBuffer(str.length); - const view = new Uint8Array(buf); - for (let i = 0; i < str.length; i++) { - view[i] = str.charCodeAt(i); - } - return buf; -} - -/** Parse a PKCS#12 (.p12/.pfx) file and produce an encrypted-at-rest key record. */ -export async function importPkcs12( - p12Bytes: ArrayBuffer, - p12Passphrase: string, - storagePassphrase: string, -): Promise { - // Parse PKCS#12 container - const asn1 = asn1js.fromBER(p12Bytes); - if (asn1.offset === -1) { - throw new Error('Invalid PKCS#12 file: ASN.1 parsing failed'); - } - - const pfx = new pkijs.PFX({ schema: asn1.result }); - - // Verify MAC if present - if (pfx.macData) { - // PKIjs handles MAC verification internally during parseInternalValues - } - - // Use webcrypto-liner as the global engine for 3DES support. - // Many PKCS#12 files use pbeWithSHAAnd3-KeyTripleDES-CBC internally. - await withLinerEngine(async () => { - await pfx.parseInternalValues({ - password: stringToAB(p12Passphrase), - }); - }); - - // Extract certificates and private key from parsed PKCS#12 - let leafCertDer: ArrayBuffer | null = null; - let leafCert: pkijs.Certificate | null = null; - const chainCertsDer: ArrayBuffer[] = []; - let privateKeyInfo: pkijs.PrivateKeyInfo | null = null; - - if (!pfx.parsedValue?.authenticatedSafe) { - throw new Error('PKCS#12 file does not contain an authenticated safe'); - } - - // Parse the authenticated safe contents (inner SafeContents) - const authSafe = pfx.parsedValue.authenticatedSafe; - const safeContentsParams = authSafe.safeContents.map((ci: pkijs.ContentInfo) => { - // encryptedData (1.2.840.113549.1.7.6) needs the password - if (ci.contentType === '1.2.840.113549.1.7.6') { - return { password: stringToAB(p12Passphrase) }; - } - return {}; - }); - await withLinerEngine(async () => { - await authSafe.parseInternalValues({ safeContents: safeContentsParams }); - }); - - for (const safeContent of authSafe.parsedValue.safeContents) { - const sc = safeContent.value ?? safeContent.parsedValue; - if (!sc) continue; - - for (const safeBag of sc.safeBags) { - // PKCS#12 bag types - switch (safeBag.bagId) { - case '1.2.840.113549.1.12.10.1.3': { - // CertBag - const certBag = safeBag.bagValue as pkijs.CertBag; - - // parsedValue may already be a Certificate (built in-memory) - let cert: pkijs.Certificate | null = null; - let der: ArrayBuffer | null = null; - - if (certBag.parsedValue instanceof pkijs.Certificate) { - cert = certBag.parsedValue; - der = cert.toSchema(true).toBER(false); - } else if (certBag.certId === '1.2.840.113549.1.9.22.1' && certBag.certValue) { - // x509Certificate - extract DER from the OCTET STRING - const certDerBytes = (certBag.certValue as asn1js.OctetString).valueBlock.valueHexView; - const certAsn1 = asn1js.fromBER(certDerBytes); - if (certAsn1.offset !== -1) { - cert = new pkijs.Certificate({ schema: certAsn1.result }); - der = new Uint8Array(certDerBytes).buffer as ArrayBuffer; - } - } - - if (cert && der) { - if (!leafCertDer) { - leafCertDer = der; - leafCert = cert; - } else { - chainCertsDer.push(der); - } - } - break; - } - case '1.2.840.113549.1.12.10.1.1': { - // KeyBag (unencrypted private key) - privateKeyInfo = safeBag.bagValue as pkijs.PrivateKeyInfo; - break; - } - case '1.2.840.113549.1.12.10.1.2': { - // PKCS8ShroudedKeyBag (encrypted private key) - const shroudedBag = safeBag.bagValue as pkijs.PKCS8ShroudedKeyBag; - if (shroudedBag.parsedValue) { - privateKeyInfo = shroudedBag.parsedValue; - } else { - // Decrypt shrouded key bag to get private key info - await withLinerEngine(async () => { - await (shroudedBag as unknown as { parseInternalValues(params: { password: ArrayBuffer }): Promise }).parseInternalValues({ - password: stringToAB(p12Passphrase), - }); - }); - if (shroudedBag.parsedValue) { - privateKeyInfo = shroudedBag.parsedValue; - } - } - break; - } - } - } - } - - if (!leafCert || !leafCertDer) { - throw new Error('No certificate found in PKCS#12 file'); - } - if (!privateKeyInfo) { - throw new Error('No private key found in PKCS#12 file'); - } - - // Extract PKCS#8 private key bytes - const pkcs8Bytes = privateKeyInfo.toSchema().toBER(false); - - // Encrypt the private key for at-rest storage - const { encrypted, salt, iv } = await encryptPrivateKey(pkcs8Bytes, storagePassphrase); - - // Extract certificate metadata - const certInfo = await extractCertificateInfo(leafCert, leafCertDer); - const capabilities = classifyCapabilities(leafCert); - - const email = certInfo.emailAddresses[0] ?? ''; - - const keyRecord: SmimeKeyRecord = { - id: generateUUID(), - email: email.toLowerCase(), - certificate: leafCertDer, - certificateChain: chainCertsDer, - encryptedPrivateKey: encrypted, - salt, - iv, - kdfIterations: KDF_ITERATIONS, - issuer: certInfo.issuer, - subject: certInfo.subject, - serialNumber: certInfo.serialNumber, - notBefore: certInfo.notBefore, - notAfter: certInfo.notAfter, - fingerprint: certInfo.fingerprint, - algorithm: certInfo.algorithm, - capabilities, - }; - - return { keyRecord, certInfo }; -} - -// ── Private key encryption / decryption ────────────────────────────── - -async function deriveWrappingKey( - passphrase: string, - salt: ArrayBuffer, - iterations: number, -): Promise { - const enc = new TextEncoder(); - const keyMaterial = await crypto.subtle.importKey( - 'raw', - enc.encode(passphrase), - 'PBKDF2', - false, - ['deriveKey'], - ); - return crypto.subtle.deriveKey( - { name: 'PBKDF2', salt, iterations, hash: 'SHA-256' }, - keyMaterial, - { name: 'AES-GCM', length: AES_KEY_LENGTH }, - false, - ['encrypt', 'decrypt'], - ); -} - -async function encryptPrivateKey( - pkcs8Bytes: ArrayBuffer, - passphrase: string, -): Promise<{ encrypted: ArrayBuffer; salt: ArrayBuffer; iv: ArrayBuffer }> { - const salt = crypto.getRandomValues(new Uint8Array(32)).buffer; - const iv = crypto.getRandomValues(new Uint8Array(12)).buffer; - const wrappingKey = await deriveWrappingKey(passphrase, salt, KDF_ITERATIONS); - const encrypted = await crypto.subtle.encrypt( - { name: 'AES-GCM', iv }, - wrappingKey, - pkcs8Bytes, - ); - return { encrypted, salt, iv }; -} - -export interface UnlockedKeyPair { - signingKey: CryptoKey; - decryptionKey?: CryptoKey; - /** Key imported via webcrypto-liner as RSAES-PKCS1-v1_5 for legacy S/MIME (3DES) messages */ - legacyDecryptionKey?: CryptoKey; -} - -/** Decrypt stored PKCS#8 bytes and import as non-extractable CryptoKeys for signing and decryption. */ -export async function unlockPrivateKey( - record: SmimeKeyRecord, - passphrase: string, -): Promise { - const wrappingKey = await deriveWrappingKey( - passphrase, - record.salt, - record.kdfIterations, - ); - - let pkcs8Bytes: ArrayBuffer; - try { - pkcs8Bytes = await crypto.subtle.decrypt( - { name: 'AES-GCM', iv: record.iv }, - wrappingKey, - record.encryptedPrivateKey, - ); - } catch { - throw new Error('Incorrect passphrase'); - } - - const isEcdsa = record.algorithm.startsWith('ECDSA'); - const signAlg = isEcdsa - ? { name: 'ECDSA', namedCurve: ecdsaCurveFromAlg(record.algorithm) } - : { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }; - const decryptAlg = isEcdsa - ? { name: 'ECDH', namedCurve: ecdsaCurveFromAlg(record.algorithm) } - : { name: 'RSA-OAEP', hash: 'SHA-256' }; - const decryptUsages: globalThis.KeyUsage[] = isEcdsa ? ['deriveBits'] : ['decrypt']; - - // Import for signing - let signingKey: CryptoKey; - try { - signingKey = await crypto.subtle.importKey('pkcs8', pkcs8Bytes, signAlg, false, ['sign']); - } catch { - // Key may only support decryption (key-encipherment-only cert) - const decryptionKey = await crypto.subtle.importKey('pkcs8', pkcs8Bytes, decryptAlg, false, decryptUsages); - let legacyDecryptionKey: CryptoKey | undefined; - if (!isEcdsa) { - try { - const linerCrypto = getLinerCrypto(); - legacyDecryptionKey = await linerCrypto.subtle.importKey( - 'pkcs8', - pkcs8Bytes, - { name: 'RSAES-PKCS1-v1_5' }, - false, - ['decrypt'], - ); - } catch { - // webcrypto-liner may not be available - } - } - return { signingKey: decryptionKey, decryptionKey, legacyDecryptionKey }; - } - - // Also import for decryption (separate CryptoKey handle required by Web Crypto) - let decryptionKey: CryptoKey | undefined; - try { - decryptionKey = await crypto.subtle.importKey('pkcs8', pkcs8Bytes, decryptAlg, false, decryptUsages); - } catch { - // Key may only support signing (digitalSignature-only cert) - } - - // Import a legacy decryption key via webcrypto-liner for RSAES-PKCS1-v1_5 key transport - // (used by older S/MIME messages encrypted with 3DES, RC2, etc.) - let legacyDecryptionKey: CryptoKey | undefined; - if (!isEcdsa) { - try { - const linerCrypto = getLinerCrypto(); - legacyDecryptionKey = await linerCrypto.subtle.importKey( - 'pkcs8', - pkcs8Bytes, - { name: 'RSAES-PKCS1-v1_5' }, - false, - ['decrypt'], - ); - console.debug('[S/MIME] legacy RSAES-PKCS1-v1_5 key imported successfully:', { - algorithm: legacyDecryptionKey.algorithm, - usages: legacyDecryptionKey.usages, - }); - } catch (err) { - console.warn('[S/MIME] legacy RSAES-PKCS1-v1_5 key import failed:', err); - } - } - - return { signingKey, decryptionKey, legacyDecryptionKey }; -} - -/** Get decrypted PKCS#8 bytes (for export flow). */ -export async function decryptPrivateKeyBytes( - record: SmimeKeyRecord, - passphrase: string, -): Promise { - const wrappingKey = await deriveWrappingKey( - passphrase, - record.salt, - record.kdfIterations, - ); - - try { - return await crypto.subtle.decrypt( - { name: 'AES-GCM', iv: record.iv }, - wrappingKey, - record.encryptedPrivateKey, - ); - } catch { - throw new Error('Incorrect passphrase'); - } -} - -function ecdsaCurveFromAlg(alg: string): string { - if (alg.includes('P256') || alg.includes('P-256')) return 'P-256'; - if (alg.includes('P384') || alg.includes('P-384')) return 'P-384'; - if (alg.includes('P521') || alg.includes('P-521')) return 'P-521'; - return 'P-256'; -} diff --git a/lib/smime/smime-decrypt.ts b/lib/smime/smime-decrypt.ts deleted file mode 100644 index a389991e..00000000 --- a/lib/smime/smime-decrypt.ts +++ /dev/null @@ -1,422 +0,0 @@ -/** - * Decrypt CMS EnvelopedData to recover the inner MIME content. - * - * Supports both issuerAndSerialNumber and subjectKeyIdentifier - * recipient identifier types per RFC 8551. - */ - -import * as pkijs from 'pkijs'; -import * as asn1js from 'asn1js'; -import type { SmimeKeyRecord } from './types'; -import { getLinerCryptoEngine, withLinerEngine } from './crypto-engine'; - -export interface DecryptionInput { - /** Raw CMS EnvelopedData bytes (DER) */ - cmsBytes: ArrayBuffer; - /** All imported key records to try matching against */ - keyRecords: SmimeKeyRecord[]; - /** Unlocked CryptoKey map: keyRecordId → CryptoKey (RSA-OAEP) */ - unlockedKeys: Map; - /** Unlocked legacy CryptoKey map: keyRecordId → CryptoKey (RSAES-PKCS1-v1_5 via webcrypto-liner) */ - legacyUnlockedKeys?: Map; -} - -export interface DecryptionResult { - /** The decrypted inner MIME bytes */ - mimeBytes: Uint8Array; - /** The key record that was used to decrypt */ - keyRecordId: string; -} - -/** - * Attempt to decrypt CMS EnvelopedData. - * - * Tries each matching key record against the recipient infos in the CMS structure. - * - * @throws Error if no matching key is found, key is locked, or decryption fails - */ -export async function smimeDecrypt(input: DecryptionInput): Promise { - const { cmsBytes, keyRecords, unlockedKeys, legacyUnlockedKeys } = input; - - // Parse the CMS ContentInfo wrapper - const contentInfo = parseContentInfo(cmsBytes); - const envelopedData = extractEnvelopedData(contentInfo); - - // Log CMS algorithm details for diagnostics - const contentEncOid = envelopedData.encryptedContentInfo?.contentEncryptionAlgorithm?.algorithmId; - const recipientAlgs = envelopedData.recipientInfos?.map((ri) => - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (ri as any).value?.keyEncryptionAlgorithm?.algorithmId as string | undefined, - ); - console.debug('[S/MIME] CMS algorithms:', { - contentEncryption: contentEncOid, - keyTransport: recipientAlgs, - legacyKeysAvailable: legacyUnlockedKeys?.size ?? 0, - }); - - // Find matching key records - const matchedRecords = findMatchingKeyRecords(envelopedData, keyRecords); - - if (matchedRecords.length === 0) { - throw new Error('No imported S/MIME key matches any recipient in this encrypted message'); - } - - // Try each matched record - for (const { keyRecord, recipientIndex } of matchedRecords) { - const privateKey = unlockedKeys.get(keyRecord.id); - if (!privateKey) { - // Try legacy key (RSAES-PKCS1-v1_5) if no RSA-OAEP key - const legacyKey = legacyUnlockedKeys?.get(keyRecord.id); - if (legacyKey) { - try { - const decrypted = await decryptWithKey(envelopedData, recipientIndex, legacyKey, keyRecord); - return { - mimeBytes: new Uint8Array(decrypted), - keyRecordId: keyRecord.id, - }; - } catch { - continue; - } - } - continue; // Key exists but isn't unlocked - skip, caller should unlock first - } - - try { - const decrypted = await decryptWithKey(envelopedData, recipientIndex, privateKey, keyRecord); - return { - mimeBytes: new Uint8Array(decrypted), - keyRecordId: keyRecord.id, - }; - } catch (oaepError) { - // RSA-OAEP key didn't work, try legacy RSAES-PKCS1-v1_5 key - console.debug('[S/MIME] RSA-OAEP decrypt failed:', oaepError instanceof Error ? oaepError.message : oaepError); - const legacyKey = legacyUnlockedKeys?.get(keyRecord.id); - console.debug('[S/MIME] legacy key available:', !!legacyKey, legacyKey ? { algorithm: (legacyKey as CryptoKey).algorithm } : undefined); - if (legacyKey) { - try { - const decrypted = await decryptWithKey(envelopedData, recipientIndex, legacyKey, keyRecord); - return { - mimeBytes: new Uint8Array(decrypted), - keyRecordId: keyRecord.id, - }; - } catch (legacyError) { - // Legacy key also didn't work, try the next record - console.debug('[S/MIME] RSAES-PKCS1-v1_5 decrypt also failed:', legacyError instanceof Error ? legacyError.message : legacyError); - } - } - continue; - } - } - - // Check if we had matching records but none were unlocked - const isUnlocked = (id: string) => unlockedKeys.has(id) || (legacyUnlockedKeys?.has(id) ?? false); - const hasLockedMatch = matchedRecords.some(m => !isUnlocked(m.keyRecord.id)); - if (hasLockedMatch) { - const lockedRecord = matchedRecords.find(m => !isUnlocked(m.keyRecord.id))!; - throw new SmimeKeyLockedError( - 'S/MIME key is locked. Unlock it to decrypt this message.', - lockedRecord.keyRecord.id, - ); - } - - throw new Error('Failed to decrypt message with any available key'); -} - -/** - * Get the key record IDs that could potentially decrypt a message. - * Useful for prompting the user to unlock the right key. - */ -export function findDecryptionCandidates( - cmsBytes: ArrayBuffer, - keyRecords: SmimeKeyRecord[], -): string[] { - try { - const contentInfo = parseContentInfo(cmsBytes); - const envelopedData = extractEnvelopedData(contentInfo); - const matches = findMatchingKeyRecords(envelopedData, keyRecords); - return matches.map(m => m.keyRecord.id); - } catch { - return []; - } -} - -export class SmimeKeyLockedError extends Error { - constructor( - message: string, - public readonly keyRecordId: string, - ) { - super(message); - this.name = 'SmimeKeyLockedError'; - } -} - -// --- Internal helpers --- - -/** - * Normalize raw blob bytes into DER-encoded CMS data. - * - * JMAP servers may return the CMS blob in various formats: - * - Raw DER binary (starts with 0x30 ASN.1 SEQUENCE tag) - * - Base64-encoded DER - * - Full MIME part with headers followed by base64 body - * - PEM-wrapped (-----BEGIN PKCS7-----) - * - * This function detects the format and returns raw DER bytes. - */ -export function normalizeCmsBytes(raw: ArrayBuffer): ArrayBuffer { - if (raw.byteLength === 0) { - return raw; - } - - const bytes = new Uint8Array(raw); - - // Already valid DER - starts with ASN.1 SEQUENCE tag - if (bytes[0] === 0x30) { - return raw; - } - - let text = new TextDecoder().decode(raw); - - const looksMostlyText = (() => { - const sample = text.slice(0, Math.min(text.length, 2048)); - if (sample.length === 0) return false; - let printable = 0; - for (let i = 0; i < sample.length; i++) { - const code = sample.charCodeAt(i); - if ( - code === 0x09 || - code === 0x0a || - code === 0x0d || - (code >= 0x20 && code <= 0x7e) - ) { - printable++; - } - } - return printable / sample.length > 0.85; - })(); - - // Check if the blob contains MIME headers (e.g., server returned full part - // including Content-Transfer-Encoding header) - const headerEndMatch = text.match(/\r?\n\r?\n/); - const hasMimeHeaderHints = /content-type:|content-transfer-encoding:|mime-version:/i.test(text.slice(0, Math.min(text.length, 8192))); - if (looksMostlyText && headerEndMatch && headerEndMatch.index !== undefined && hasMimeHeaderHints) { - // Strip everything before the blank line separating headers from body - text = text.substring(headerEndMatch.index + headerEndMatch[0].length); - } - - // Strip PEM armour if present - text = text - .replace(/-----BEGIN [A-Z0-9 ]+-----/g, '') - .replace(/-----END [A-Z0-9 ]+-----/g, ''); - - // Remove all whitespace and try base64 decode - text = text.replace(/\s/g, ''); - - if (text.length === 0) { - return raw; - } - - try { - const binary = atob(text); - const decoded = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) decoded[i] = binary.charCodeAt(i); - if (decoded.length > 0 && decoded[0] === 0x30) { - return decoded.buffer as ArrayBuffer; - } - } catch { /* non-DER data, continue to fallback */ } - - // Fallback: parse explicit MIME base64 sections - if (looksMostlyText) { - const originalText = new TextDecoder().decode(raw); - const sectionRegex = /content-transfer-encoding:\s*base64[\s\S]*?\r?\n\r?\n([\s\S]*?)(?:\r?\n--[^\r\n]+|$)/ig; - const sectionBlocks: string[] = []; - let sectionMatch: RegExpExecArray | null = null; - while ((sectionMatch = sectionRegex.exec(originalText)) !== null) { - sectionBlocks.push(sectionMatch[1]); - } - - for (const block of sectionBlocks) { - const cleaned = block.replace(/\s/g, ''); - if (cleaned.length < 8 || !/^[A-Za-z0-9+/=]+$/.test(cleaned)) continue; - try { - const binary = atob(cleaned); - const decoded = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) decoded[i] = binary.charCodeAt(i); - if (decoded.length > 0 && decoded[0] === 0x30) { - return decoded.buffer as ArrayBuffer; - } - } catch { - // try next section - } - } - - // Last resort: find base64-like blocks and keep only DER-looking decodes - const base64Blocks = originalText.match(/[A-Za-z0-9+/=\r\n]{128,}/g) || []; - const cleaned = base64Blocks - .map(block => block.replace(/\s/g, '')) - .filter(block => block.length >= 128 && /^[A-Za-z0-9+/=]+$/.test(block)); - - cleaned.sort((a, b) => b.length - a.length); - - for (const block of cleaned) { - try { - const binary = atob(block); - const decoded = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) decoded[i] = binary.charCodeAt(i); - if (decoded.length > 0 && decoded[0] === 0x30) { - return decoded.buffer as ArrayBuffer; - } - } catch { - // try next block - } - } - } - - // Not decodable - return original bytes - return raw; -} - -function parseContentInfo(der: ArrayBuffer): pkijs.ContentInfo { - const asn1 = asn1js.fromBER(der); - if (asn1.offset === -1) { - throw new Error('Invalid ASN.1 data - cannot parse CMS envelope'); - } - try { - return new pkijs.ContentInfo({ schema: asn1.result }); - } catch { - throw new Error('Invalid ASN.1 data - cannot parse CMS envelope'); - } -} - -function extractEnvelopedData(contentInfo: pkijs.ContentInfo): pkijs.EnvelopedData { - // OID 1.2.840.113549.1.7.3 = enveloped-data - if (contentInfo.contentType !== '1.2.840.113549.1.7.3') { - throw new Error(`Unexpected CMS content type: ${contentInfo.contentType}`); - } - return new pkijs.EnvelopedData({ schema: contentInfo.content }); -} - -interface RecipientMatch { - keyRecord: SmimeKeyRecord; - recipientIndex: number; -} - -function findMatchingKeyRecords( - envelopedData: pkijs.EnvelopedData, - keyRecords: SmimeKeyRecord[], -): RecipientMatch[] { - const matches: RecipientMatch[] = []; - - for (let i = 0; i < envelopedData.recipientInfos.length; i++) { - const ri = envelopedData.recipientInfos[i]; - - // RecipientInfo is a wrapper: variant=1 → KeyTransRecipientInfo - const ktri = ri instanceof pkijs.KeyTransRecipientInfo - ? ri - : (ri as { variant?: number; value?: unknown }).variant === 1 && (ri as { value?: unknown }).value instanceof pkijs.KeyTransRecipientInfo - ? (ri as { value: pkijs.KeyTransRecipientInfo }).value - : null; - - if (ktri) { - for (const keyRecord of keyRecords) { - if (matchesKeyTransRecipient(ktri, keyRecord)) { - matches.push({ keyRecord, recipientIndex: i }); - } - } - } - } - - return matches; -} - -function matchesKeyTransRecipient( - recipientInfo: pkijs.KeyTransRecipientInfo, - keyRecord: SmimeKeyRecord, -): boolean { - const rid = recipientInfo.rid; - - // IssuerAndSerialNumber matching - if (rid instanceof pkijs.IssuerAndSerialNumber) { - try { - const certAsn1 = asn1js.fromBER(keyRecord.certificate); - if (certAsn1.offset === -1) return false; - const cert = new pkijs.Certificate({ schema: certAsn1.result }); - - // Compare serial numbers - const ridSerial = Buffer.from(rid.serialNumber.valueBlock.valueHexView).toString('hex'); - const certSerial = Buffer.from(cert.serialNumber.valueBlock.valueHexView).toString('hex'); - if (ridSerial !== certSerial) return false; - - // Compare issuers (compare DER encoding) - const ridIssuerDer = rid.issuer.toSchema().toBER(false); - const certIssuerDer = cert.issuer.toSchema().toBER(false); - return arraysEqual(new Uint8Array(ridIssuerDer), new Uint8Array(certIssuerDer)); - } catch { - return false; - } - } - - // SubjectKeyIdentifier matching - if (rid instanceof asn1js.OctetString) { - try { - const certAsn1 = asn1js.fromBER(keyRecord.certificate); - if (certAsn1.offset === -1) return false; - const cert = new pkijs.Certificate({ schema: certAsn1.result }); - - // Find the SubjectKeyIdentifier extension - const skiExt = cert.extensions?.find( - ext => ext.extnID === '2.5.29.14', // id-ce-subjectKeyIdentifier - ); - if (!skiExt) return false; - - const skiValue = asn1js.fromBER(skiExt.extnValue.valueBlock.valueHexView); - if (skiValue.offset === -1) return false; - const ski = (skiValue.result as asn1js.OctetString).valueBlock.valueHexView; - - return arraysEqual( - new Uint8Array(ski), - new Uint8Array(rid.valueBlock.valueHexView), - ); - } catch { - return false; - } - } - - return false; -} - -function arraysEqual(a: Uint8Array, b: Uint8Array): boolean { - if (a.length !== b.length) return false; - for (let i = 0; i < a.length; i++) { - if (a[i] !== b[i]) return false; - } - return true; -} - -async function decryptWithKey( - envelopedData: pkijs.EnvelopedData, - recipientIndex: number, - privateKey: CryptoKey, - keyRecord: SmimeKeyRecord, -): Promise { - // Parse the certificate for pkijs - const certAsn1 = asn1js.fromBER(keyRecord.certificate); - const cert = new pkijs.Certificate({ schema: certAsn1.result }); - - // Use withLinerEngine to set the global pkijs engine to webcrypto-liner. - // This is required because pkijs internally may use getEngine() for - // OID lookups and crypto operations. Without this, 3DES-encrypted - // messages fail because the default engine doesn't know about DES-EDE3-CBC. - return withLinerEngine(async () => { - const cryptoEngine = getLinerCryptoEngine(); - - return envelopedData.decrypt( - recipientIndex, - { - recipientCertificate: cert, - recipientPrivateKey: privateKey, - }, - cryptoEngine, - ); - }); -} diff --git a/lib/smime/smime-detect.ts b/lib/smime/smime-detect.ts deleted file mode 100644 index ef584cc3..00000000 --- a/lib/smime/smime-detect.ts +++ /dev/null @@ -1,194 +0,0 @@ -/** - * Detect S/MIME content in an email message. - * - * Checks Content-Type headers, bodyStructure, and attachment metadata - * to determine if a message contains CMS signed or encrypted content. - */ - -export type SmimeContentType = - | 'enveloped-data' // encrypted - | 'signed-data' // opaque signed - | 'detached-sig' // multipart/signed (deferred in v1) - | null; - -export interface SmimeDetectionResult { - /** Primary S/MIME content type detected, or null if none */ - type: SmimeContentType; - /** The blobId to fetch for CMS processing (enveloped-data or signed-data) */ - blobId?: string; - /** The partId containing the CMS data */ - partId?: string; - /** Whether this is a v1-supported type */ - supported: boolean; -} - -interface EmailBodyPart { - partId?: string; - blobId?: string; - type?: string; - name?: string; - disposition?: string; - subParts?: EmailBodyPart[]; - headers?: Array<{ name: string; value: string }>; -} - -/** - * Detect S/MIME content from email metadata. - * - * @param contentType - The top-level Content-Type header value - * @param bodyStructure - The JMAP bodyStructure tree - * @param attachments - Flat list of attachment parts (from `attachments` property) - */ -export function detectSmime( - contentType?: string, - bodyStructure?: EmailBodyPart | null, - attachments?: EmailBodyPart[], -): SmimeDetectionResult { - const noResult: SmimeDetectionResult = { type: null, supported: false }; - - // 1. Check top-level Content-Type header - if (contentType) { - const ct = contentType.toLowerCase(); - - if (ct.includes('application/pkcs7-mime') || ct.includes('application/x-pkcs7-mime')) { - if (ct.includes('smime-type=enveloped-data')) { - const part = findCmsPart(bodyStructure, 'enveloped-data'); - return { - type: 'enveloped-data', - blobId: part?.blobId, - partId: part?.partId, - supported: true, - }; - } - if (ct.includes('smime-type=signed-data')) { - const part = findCmsPart(bodyStructure, 'signed-data'); - return { - type: 'signed-data', - blobId: part?.blobId, - partId: part?.partId, - supported: true, - }; - } - // Generic pkcs7-mime without explicit smime-type - try bodyStructure - const part = findCmsPart(bodyStructure, null); - if (part) { - const partType = inferSmimeType(part); - return { - type: partType, - blobId: part.blobId, - partId: part.partId, - supported: partType === 'enveloped-data' || partType === 'signed-data', - }; - } - } - - if (ct.includes('multipart/signed') && ct.includes('application/pkcs7-signature')) { - return { type: 'detached-sig', supported: false }; - } - } - - // 2. Walk bodyStructure tree - if (bodyStructure) { - const result = walkBodyStructure(bodyStructure); - if (result) return result; - } - - // 3. Check attachment list for .p7m files - if (attachments) { - for (const att of attachments) { - const type = att.type?.toLowerCase() || ''; - const name = att.name?.toLowerCase() || ''; - - if (type.includes('application/pkcs7-mime') || type.includes('application/x-pkcs7-mime')) { - const smimeType = inferSmimeTypeFromContentType(type); - return { - type: smimeType, - blobId: att.blobId, - partId: att.partId, - supported: smimeType === 'enveloped-data' || smimeType === 'signed-data', - }; - } - - if (name.endsWith('.p7m')) { - return { - type: 'enveloped-data', // .p7m is ambiguous but commonly encrypted - blobId: att.blobId, - partId: att.partId, - supported: true, - }; - } - - if (name.endsWith('.p7s')) { - return { type: 'detached-sig', blobId: att.blobId, partId: att.partId, supported: false }; - } - } - } - - return noResult; -} - -function walkBodyStructure(part: EmailBodyPart): SmimeDetectionResult | null { - const type = part.type?.toLowerCase() || ''; - - if (type.includes('application/pkcs7-mime') || type.includes('application/x-pkcs7-mime')) { - const smimeType = inferSmimeTypeFromContentType(type); - return { - type: smimeType, - blobId: part.blobId, - partId: part.partId, - supported: smimeType === 'enveloped-data' || smimeType === 'signed-data', - }; - } - - if (type === 'multipart/signed') { - // Check for pkcs7-signature protocol in subparts - if (part.subParts?.some(sp => sp.type?.toLowerCase().includes('application/pkcs7-signature'))) { - return { type: 'detached-sig', supported: false }; - } - } - - if (part.subParts) { - for (const sub of part.subParts) { - const result = walkBodyStructure(sub); - if (result) return result; - } - } - - return null; -} - -function findCmsPart(bodyStructure: EmailBodyPart | null | undefined, smimeType: string | null): EmailBodyPart | null { - if (!bodyStructure) return null; - - const type = bodyStructure.type?.toLowerCase() || ''; - if (type.includes('application/pkcs7-mime') || type.includes('application/x-pkcs7-mime')) { - // JMAP bodyStructure.type may not include smime-type parameter, - // so accept any pkcs7-mime part when the smime-type was already - // determined from the Content-Type header. - return bodyStructure; - } - - if (bodyStructure.subParts) { - for (const sub of bodyStructure.subParts) { - const found = findCmsPart(sub, smimeType); - if (found) return found; - } - } - - return null; -} - -function inferSmimeType(part: EmailBodyPart): SmimeContentType { - return inferSmimeTypeFromContentType(part.type || ''); -} - -function inferSmimeTypeFromContentType(ct: string): SmimeContentType { - const lower = ct.toLowerCase(); - if (lower.includes('smime-type=enveloped-data')) return 'enveloped-data'; - if (lower.includes('smime-type=signed-data')) return 'signed-data'; - // Default for generic pkcs7-mime: assume enveloped-data (most common) - if (lower.includes('application/pkcs7-mime') || lower.includes('application/x-pkcs7-mime')) { - return 'enveloped-data'; - } - return null; -} diff --git a/lib/smime/smime-encrypt.ts b/lib/smime/smime-encrypt.ts deleted file mode 100644 index a2db37f6..00000000 --- a/lib/smime/smime-encrypt.ts +++ /dev/null @@ -1,80 +0,0 @@ -import * as pkijs from 'pkijs'; -import { parseCertificateDer } from './certificate-utils'; - -/** - * Produce CMS EnvelopedData for the given MIME content. - * - * Content type: application/pkcs7-mime; smime-type=enveloped-data - * - * Always includes the sender's cert so the sender can decrypt their Sent mail. - */ -export async function smimeEncrypt( - mimeBytes: Uint8Array, - recipientCertsDer: ArrayBuffer[], - senderCertDer: ArrayBuffer, - useAes128?: boolean, -): Promise { - // Combine recipient + sender certs, deduplicate by DER bytes - const allCertDers = deduplicateCerts([...recipientCertsDer, senderCertDer]); - - if (allCertDers.length === 0) { - throw new Error('No recipient certificates provided'); - } - - // Parse all certificates - const recipientCerts = allCertDers.map((der) => parseCertificateDer(der)); - - // Build EnvelopedData - const cmsEnveloped = new pkijs.EnvelopedData(); - - // Add recipient info for each certificate - for (const cert of recipientCerts) { - cmsEnveloped.addRecipientByCertificate(cert, { - oaepHashAlgorithm: 'SHA-256', - }, undefined, new pkijs.CryptoEngine({ - crypto: crypto, - subtle: crypto.subtle, - name: 'webcrypto', - })); - } - - // Encrypt the content - const contentEncryptionAlgorithm = useAes128 - ? { name: 'AES-GCM', length: 128 } - : { name: 'AES-GCM', length: 256 }; - - await cmsEnveloped.encrypt(contentEncryptionAlgorithm, mimeBytes.buffer.slice(mimeBytes.byteOffset, mimeBytes.byteOffset + mimeBytes.byteLength) as ArrayBuffer, new pkijs.CryptoEngine({ - crypto: crypto, - subtle: crypto.subtle, - name: 'webcrypto', - })); - - // Wrap in ContentInfo - const cms = new pkijs.ContentInfo({ - contentType: '1.2.840.113549.1.7.3', // id-envelopedData - content: cmsEnveloped.toSchema(), - }); - - const cmsBytes = cms.toSchema().toBER(false); - return new Blob([cmsBytes], { type: 'application/pkcs7-mime; smime-type=enveloped-data' }); -} - -/** Remove duplicate DER-encoded certificates based on byte equality. */ -function deduplicateCerts(certs: ArrayBuffer[]): ArrayBuffer[] { - const seen = new Set(); - const result: ArrayBuffer[] = []; - for (const cert of certs) { - const key = arrayBufferToHex(cert); - if (!seen.has(key)) { - seen.add(key); - result.push(cert); - } - } - return result; -} - -function arrayBufferToHex(buf: ArrayBuffer): string { - return Array.from(new Uint8Array(buf)) - .map((b) => b.toString(16).padStart(2, '0')) - .join(''); -} diff --git a/lib/smime/smime-sign.ts b/lib/smime/smime-sign.ts deleted file mode 100644 index f95b46c2..00000000 --- a/lib/smime/smime-sign.ts +++ /dev/null @@ -1,70 +0,0 @@ -import * as asn1js from 'asn1js'; -import * as pkijs from 'pkijs'; -import { parseCertificateDer } from './certificate-utils'; - -/** - * Produce an opaque CMS SignedData wrapping the given MIME content. - * - * Content type: application/pkcs7-mime; smime-type=signed-data - * This is the "opaque" form - the content is embedded inside the CMS structure. - */ -export async function smimeSign( - mimeBytes: Uint8Array, - privateKey: CryptoKey, - signerCertDer: ArrayBuffer, - chainCertsDer: ArrayBuffer[] = [], -): Promise { - // Parse signer certificate - const signerCert = parseCertificateDer(signerCertDer); - - // Parse chain certificates - const chainCerts = chainCertsDer.map((der) => parseCertificateDer(der)); - - // Build CMS SignedData - const cmsSigned = new pkijs.SignedData({ - version: 1, - encapContentInfo: new pkijs.EncapsulatedContentInfo({ - eContentType: '1.2.840.113549.1.7.1', // id-data - eContent: new asn1js.OctetString({ valueHex: new Uint8Array(mimeBytes.buffer.slice(mimeBytes.byteOffset, mimeBytes.byteOffset + mimeBytes.byteLength)) }), - }), - signerInfos: [ - new pkijs.SignerInfo({ - version: 1, - sid: new pkijs.IssuerAndSerialNumber({ - issuer: signerCert.issuer, - serialNumber: signerCert.serialNumber, - }), - }), - ], - certificates: [signerCert, ...chainCerts], - }); - - // Determine signing algorithm from the key - const algorithm = privateKey.algorithm; - const hashAlgorithm = 'SHA-256'; - - let _signAlg: string; - if (algorithm.name === 'RSASSA-PKCS1-v1_5' || algorithm.name === 'RSA-PSS') { - _signAlg = algorithm.name; - } else if (algorithm.name === 'ECDSA') { - _signAlg = 'ECDSA'; - } else { - _signAlg = 'RSASSA-PKCS1-v1_5'; - } - - // Sign - await cmsSigned.sign(privateKey, 0, hashAlgorithm, undefined, new pkijs.CryptoEngine({ - crypto: crypto, - subtle: crypto.subtle, - name: 'webcrypto', - })); - - // Wrap in ContentInfo - const cms = new pkijs.ContentInfo({ - contentType: '1.2.840.113549.1.7.2', // id-signedData - content: cmsSigned.toSchema(true), - }); - - const cmsBytes = cms.toSchema().toBER(false); - return new Blob([cmsBytes], { type: 'application/pkcs7-mime; smime-type=signed-data' }); -} diff --git a/lib/smime/smime-verify.ts b/lib/smime/smime-verify.ts deleted file mode 100644 index 5ba55b4c..00000000 --- a/lib/smime/smime-verify.ts +++ /dev/null @@ -1,225 +0,0 @@ -/** - * Verify CMS SignedData (opaque signed) and extract the inner content. - * - * Performs cryptographic signature validation, cert validity checks, - * and trust-chain verification. - */ - -import * as pkijs from 'pkijs'; -import * as asn1js from 'asn1js'; -import { extractCertificateInfo } from './certificate-utils'; -import type { SmimeStatus, SmimePublicCert } from './types'; - -export interface VerificationResult { - /** The inner MIME bytes extracted from the opaque SignedData */ - mimeBytes: Uint8Array; - /** Full S/MIME status for display */ - status: SmimeStatus; -} - -/** - * Verify a CMS SignedData structure and extract the encapsulated content. - * - * @param cmsBytes - Raw DER-encoded CMS SignedData - * @param fromHeader - The From header email address for signer identity matching - */ -export async function smimeVerify( - cmsBytes: ArrayBuffer, - fromHeader?: string, -): Promise { - const contentInfo = parseContentInfo(cmsBytes); - const signedData = extractSignedData(contentInfo); - - // Extract inner content - const innerContent = extractInnerContent(signedData); - - // Extract signer certificate - const signerCert = extractSignerCertificate(signedData); - if (!signerCert) { - return { - mimeBytes: innerContent, - status: { - isSigned: true, - isEncrypted: false, - signatureValid: false, - signatureError: 'Signer certificate not found in CMS structure', - }, - }; - } - - // Verify the signature cryptographically - let signatureValid = false; - let signatureError: string | undefined; - - try { - const cryptoEngine = new pkijs.CryptoEngine({ - crypto: crypto, - subtle: crypto.subtle, - name: 'webcrypto', - }); - - const verifyResult = await signedData.verify( - { - signer: 0, - checkChain: true, - }, - cryptoEngine, - ); - signatureValid = verifyResult; - } catch (err) { - signatureError = err instanceof Error ? err.message : 'Signature verification failed'; - } - - // Extract certificate info for display - const certDer = signerCert.toSchema(true).toBER(false); - const certInfo = await extractCertificateInfo(signerCert, certDer); - - // Check certificate validity period - const now = new Date(); - const notBefore = new Date(certInfo.notBefore); - const notAfter = new Date(certInfo.notAfter); - const certExpired = now > notAfter; - const certNotYetValid = now < notBefore; - - if (certExpired && !signatureError) { - signatureError = 'Signer certificate has expired'; - } - if (certNotYetValid && !signatureError) { - signatureError = 'Signer certificate is not yet valid'; - } - - // Build the signer public cert object - const signerEmail = certInfo.emailAddresses[0] ?? ''; - const signerPublicCert: SmimePublicCert = { - id: `signer-${certInfo.fingerprint}`, - email: signerEmail.toLowerCase(), - certificate: certDer, - issuer: certInfo.issuer, - subject: certInfo.subject, - notBefore: certInfo.notBefore, - notAfter: certInfo.notAfter, - fingerprint: certInfo.fingerprint, - source: 'signed-email', - }; - - // Check signer identity vs From header - let signerEmailMatch: boolean | undefined; - if (fromHeader && signerEmail) { - signerEmailMatch = fromHeader.toLowerCase() === signerEmail.toLowerCase(); - } - - // Detect self-signed certificates (issuer === subject) - const issuerDer = new Uint8Array(signerCert.issuer.toSchema().toBER(false)); - const subjectDer = new Uint8Array(signerCert.subject.toSchema().toBER(false)); - const selfSigned = arraysEqual(issuerDer, subjectDer); - - return { - mimeBytes: innerContent, - status: { - isSigned: true, - isEncrypted: false, - signatureValid: signatureValid && !certExpired && !certNotYetValid, - signatureError, - signerCert: signerPublicCert, - signerEmailMatch, - selfSigned, - }, - }; -} - -// --- Internal helpers --- - -function parseContentInfo(der: ArrayBuffer): pkijs.ContentInfo { - const asn1 = asn1js.fromBER(der); - if (asn1.offset === -1) { - throw new Error('Invalid ASN.1 data - cannot parse CMS structure'); - } - return new pkijs.ContentInfo({ schema: asn1.result }); -} - -function extractSignedData(contentInfo: pkijs.ContentInfo): pkijs.SignedData { - // OID 1.2.840.113549.1.7.2 = signed-data - if (contentInfo.contentType !== '1.2.840.113549.1.7.2') { - throw new Error(`Unexpected CMS content type: ${contentInfo.contentType}`); - } - return new pkijs.SignedData({ schema: contentInfo.content }); -} - -function extractInnerContent(signedData: pkijs.SignedData): Uint8Array { - const eContent = signedData.encapContentInfo?.eContent; - if (!eContent) { - throw new Error('No encapsulated content in SignedData (detached signature not supported)'); - } - - if (eContent instanceof asn1js.OctetString) { - // Constructed OCTET STRING: data lives in child OctetStrings - const children = (eContent.valueBlock as unknown as { value?: asn1js.OctetString[] }).value; - if (children?.length) { - const chunks = children.map(c => new Uint8Array(c.valueBlock.valueHexView)); - const total = chunks.reduce((sum, c) => sum + c.length, 0); - const result = new Uint8Array(total); - let offset = 0; - for (const chunk of chunks) { - result.set(chunk, offset); - offset += chunk.length; - } - return result; - } - // Primitive OCTET STRING: data is directly in valueHexView - return new Uint8Array(eContent.valueBlock.valueHexView); - } - - throw new Error('Unable to extract content from SignedData'); -} - -function extractSignerCertificate(signedData: pkijs.SignedData): pkijs.Certificate | null { - if (!signedData.signerInfos?.length || !signedData.certificates?.length) { - return null; - } - - const signerInfo = signedData.signerInfos[0]; - const sid = signerInfo.sid; - - // IssuerAndSerialNumber matching - if (sid instanceof pkijs.IssuerAndSerialNumber) { - for (const certItem of signedData.certificates) { - if (!(certItem instanceof pkijs.Certificate)) continue; - const cert = certItem; - - // Compare serial numbers - const sidSerial = toHex(sid.serialNumber.valueBlock.valueHexView); - const certSerial = toHex(cert.serialNumber.valueBlock.valueHexView); - if (sidSerial !== certSerial) continue; - - // Compare issuers - const sidIssuerDer = new Uint8Array(sid.issuer.toSchema().toBER(false)); - const certIssuerDer = new Uint8Array(cert.issuer.toSchema().toBER(false)); - if (arraysEqual(sidIssuerDer, certIssuerDer)) { - return cert; - } - } - } - - // If only one certificate is present, use it as fallback - if (signedData.certificates.length === 1) { - const cert = signedData.certificates[0]; - if (cert instanceof pkijs.Certificate) return cert; - } - - return null; -} - -function toHex(buffer: ArrayBuffer | ArrayBufferView): string { - const bytes = buffer instanceof ArrayBuffer - ? new Uint8Array(buffer) - : new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength); - return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(''); -} - -function arraysEqual(a: Uint8Array, b: Uint8Array): boolean { - if (a.length !== b.length) return false; - for (let i = 0; i < a.length; i++) { - if (a[i] !== b[i]) return false; - } - return true; -} diff --git a/lib/smime/types.ts b/lib/smime/types.ts deleted file mode 100644 index a3e50f3a..00000000 --- a/lib/smime/types.ts +++ /dev/null @@ -1,84 +0,0 @@ -/** Stored record for an imported S/MIME private key + certificate. */ -export interface SmimeKeyRecord { - id: string; - accountId?: string; - email: string; - certificate: ArrayBuffer; // DER-encoded X.509 leaf cert - certificateChain: ArrayBuffer[]; // DER-encoded intermediates - encryptedPrivateKey: ArrayBuffer; // AES-GCM wrapped PKCS#8 bytes - salt: ArrayBuffer; // PBKDF2 salt - iv: ArrayBuffer; // AES-GCM IV - kdfIterations: number; - issuer: string; - subject: string; - serialNumber: string; - notBefore: string; // ISO 8601 - notAfter: string; // ISO 8601 - fingerprint: string; // SHA-256 hex of DER cert - algorithm: string; // e.g. "RSA-2048", "RSA-4096", "ECDSA-P256" - capabilities: SmimeKeyCapabilities; -} - -/** What a certificate can be used for based on KeyUsage/ExtendedKeyUsage. */ -export interface SmimeKeyCapabilities { - canSign: boolean; - canEncrypt: boolean; -} - -/** Runtime-only unlocked private key handle (never persisted). */ -export interface SmimeUnlockedKey { - id: string; - email: string; - privateKey: CryptoKey; // imported as non-extractable -} - -/** A recipient or contact public certificate. */ -export interface SmimePublicCert { - id: string; - accountId?: string; - email: string; - certificate: ArrayBuffer; // DER-encoded X.509 - issuer: string; - subject: string; - notBefore: string; - notAfter: string; - fingerprint: string; - source: 'manual' | 'contact' | 'signed-email'; - contactId?: string; -} - -/** Status of S/MIME processing for a single email message. */ -export interface SmimeStatus { - isSigned: boolean; - isEncrypted: boolean; - signatureValid?: boolean; - signatureError?: string; - signerCert?: SmimePublicCert; - signerEmailMatch?: boolean; - /** True when the signer certificate is self-signed (not chained to a trusted CA). */ - selfSigned?: boolean; - decryptionSuccess?: boolean; - decryptionError?: string; - unsupportedReason?: string; -} - -/** Metadata extracted from a parsed X.509 certificate. */ -export interface CertificateInfo { - subject: string; - issuer: string; - serialNumber: string; - notBefore: string; - notAfter: string; - fingerprint: string; - algorithm: string; - keyUsage?: string[]; - extendedKeyUsage?: string[]; - emailAddresses: string[]; - capabilities: SmimeKeyCapabilities; -} - -/** Result of PKCS#12 import parsing. */ -export interface Pkcs12ImportResult { - keyRecord: SmimeKeyRecord; - certInfo: CertificateInfo; -} diff --git a/package-lock.json b/package-lock.json index 494808e6..ed51ec03 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2008,9 +2008,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2031,9 +2028,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2054,9 +2048,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2077,9 +2068,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2100,9 +2088,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/stores/smime-store.ts b/stores/smime-store.ts deleted file mode 100644 index a0b99b94..00000000 --- a/stores/smime-store.ts +++ /dev/null @@ -1,386 +0,0 @@ -import { create } from 'zustand'; -import { persist } from 'zustand/middleware'; -import type { SmimeKeyRecord, SmimePublicCert } from '@/lib/smime/types'; -import { generateUUID } from '@/lib/utils'; -import { - saveKeyRecord, - listKeyRecords, - deleteKeyRecord as deleteKeyRecordDB, - savePublicCert, - listPublicCerts, - deletePublicCert as deletePublicCertDB, -} from '@/lib/smime/key-storage'; -import { importPkcs12, unlockPrivateKey } from '@/lib/smime/pkcs12-import'; -import { - parseCertificatePemOrDer, - extractCertificateInfo, -} from '@/lib/smime/certificate-utils'; - -// Legacy storage key used by an earlier build that persisted unlock passphrases -// in sessionStorage. Wipe on module load so any in-flight tab upgrading to this -// version doesn't leave plaintext key material sitting around. New code never -// writes here - unlocked CryptoKey handles live only in the in-memory Map below. -const LEGACY_REMEMBERED_UNLOCKS_KEY = 'smime-unlocked-session'; -if (typeof window !== 'undefined') { - try { window.sessionStorage.removeItem(LEGACY_REMEMBERED_UNLOCKS_KEY); } catch { /* ignore */ } -} - -interface SmimePersistedState { - /** Account-scoped preferences: accountId → { identityKeyBindings, defaultSignIdentity, defaultEncrypt } */ - accountPreferences: Record; - defaultSignIdentity: Record; - defaultEncrypt: boolean; - }>; - autoImportSignerCerts: boolean; -} - -interface SmimeStore extends SmimePersistedState { - // Current account scope - currentAccountId: string | null; - // Account-scoped convenience accessors (derived from accountPreferences + currentAccountId) - identityKeyBindings: Record; - defaultSignIdentity: Record; - defaultEncrypt: boolean; - // Loaded from IndexedDB - keyRecords: SmimeKeyRecord[]; - publicCerts: SmimePublicCert[]; - // Runtime only - never persisted - unlockedKeys: Map; - unlockedDecryptionKeys: Map; - unlockedLegacyDecryptionKeys: Map; - isLoading: boolean; - error: string | null; - - // Actions - load: (accountId?: string) => Promise; - clearState: () => void; - importPKCS12: (file: ArrayBuffer, p12Passphrase: string, storagePassphrase: string) => Promise; - importPublicCert: (data: ArrayBuffer | string, source: SmimePublicCert['source'], contactId?: string) => Promise; - bindIdentityToKey: (identityId: string, keyRecordId: string | null) => void; - removeKeyRecord: (id: string) => Promise; - removePublicCert: (id: string) => Promise; - unlockKey: (id: string, passphrase: string) => Promise; - lockKey: (id: string) => void; - lockAllKeys: () => void; - getKeyRecordForIdentity: (identityId: string) => SmimeKeyRecord | undefined; - getPublicCertForEmail: (email: string) => SmimePublicCert | undefined; - getRecipientCerts: (emails: string[]) => { found: SmimePublicCert[]; missing: string[] }; - setSignDefault: (identityId: string, value: boolean) => void; - setEncryptDefault: (value: boolean) => void; - setAutoImportSignerCerts: (value: boolean) => void; - isKeyUnlocked: (id: string) => boolean; - getUnlockedKey: (id: string) => CryptoKey | undefined; - setError: (error: string | null) => void; -} - -export const useSmimeStore = create()( - persist( - (set, get) => ({ - // Persisted preferences - accountPreferences: {}, - autoImportSignerCerts: true, - - // Runtime state - currentAccountId: null, - identityKeyBindings: {}, - defaultSignIdentity: {}, - defaultEncrypt: false, - keyRecords: [], - publicCerts: [], - unlockedKeys: new Map(), - unlockedDecryptionKeys: new Map(), - unlockedLegacyDecryptionKeys: new Map(), - isLoading: false, - error: null, - - load: async (accountId) => { - const acctId = accountId ?? get().currentAccountId; - set({ isLoading: true, error: null, currentAccountId: acctId }); - - // Restore account-scoped preferences - const prefs = acctId ? get().accountPreferences[acctId] : undefined; - if (prefs) { - set({ - identityKeyBindings: prefs.identityKeyBindings, - defaultSignIdentity: prefs.defaultSignIdentity, - defaultEncrypt: prefs.defaultEncrypt, - }); - } else { - set({ - identityKeyBindings: {}, - defaultSignIdentity: {}, - defaultEncrypt: false, - }); - } - - try { - const [keyRecords, publicCerts] = await Promise.all([ - listKeyRecords(acctId ?? undefined), - listPublicCerts(acctId ?? undefined), - ]); - - set({ keyRecords, publicCerts, isLoading: false }); - } catch (err) { - set({ - error: err instanceof Error ? err.message : 'Failed to load S/MIME data', - isLoading: false, - }); - } - }, - - importPKCS12: async (file, p12Passphrase, storagePassphrase) => { - set({ isLoading: true, error: null }); - try { - const { keyRecord } = await importPkcs12(file, p12Passphrase, storagePassphrase); - const acctId = get().currentAccountId; - if (acctId) keyRecord.accountId = acctId; - await saveKeyRecord(keyRecord); - set((state) => ({ - keyRecords: [...state.keyRecords, keyRecord], - isLoading: false, - })); - return keyRecord; - } catch (err) { - set({ - error: err instanceof Error ? err.message : 'Failed to import PKCS#12', - isLoading: false, - }); - throw err; - } - }, - - importPublicCert: async (data, source, contactId) => { - set({ isLoading: true, error: null }); - try { - const cert = parseCertificatePemOrDer(data); - // Always re-encode to DER - input might be PEM text (string or ArrayBuffer) - const der = cert.toSchema(true).toBER(false); - const info = await extractCertificateInfo(cert, der); - const email = info.emailAddresses[0] ?? ''; - - const publicCert: SmimePublicCert = { - id: generateUUID(), - accountId: get().currentAccountId ?? undefined, - email: email.toLowerCase(), - certificate: der, - issuer: info.issuer, - subject: info.subject, - notBefore: info.notBefore, - notAfter: info.notAfter, - fingerprint: info.fingerprint, - source, - contactId, - }; - - await savePublicCert(publicCert); - set((state) => ({ - publicCerts: [...state.publicCerts, publicCert], - isLoading: false, - })); - return publicCert; - } catch (err) { - set({ - error: err instanceof Error ? err.message : 'Failed to import certificate', - isLoading: false, - }); - throw err; - } - }, - - bindIdentityToKey: (identityId, keyRecordId) => { - set((state) => { - const bindings = { ...state.identityKeyBindings }; - if (keyRecordId === null) { - delete bindings[identityId]; - } else { - bindings[identityId] = keyRecordId; - } - const accountPreferences = { ...state.accountPreferences }; - const acctId = state.currentAccountId; - if (acctId) { - accountPreferences[acctId] = { - ...(accountPreferences[acctId] ?? { identityKeyBindings: {}, defaultSignIdentity: {}, defaultEncrypt: false }), - identityKeyBindings: bindings, - }; - } - return { identityKeyBindings: bindings, accountPreferences }; - }); - }, - - removeKeyRecord: async (id) => { - await deleteKeyRecordDB(id); - set((state) => { - const unlockedKeys = new Map(state.unlockedKeys); - unlockedKeys.delete(id); - const unlockedDecryptionKeys = new Map(state.unlockedDecryptionKeys); - unlockedDecryptionKeys.delete(id); - const unlockedLegacyDecryptionKeys = new Map(state.unlockedLegacyDecryptionKeys); - unlockedLegacyDecryptionKeys.delete(id); - // Remove any identity bindings pointing to this key - const bindings = { ...state.identityKeyBindings }; - for (const [identityId, keyId] of Object.entries(bindings)) { - if (keyId === id) delete bindings[identityId]; - } - const accountPreferences = { ...state.accountPreferences }; - const acctId = state.currentAccountId; - if (acctId && accountPreferences[acctId]) { - accountPreferences[acctId] = { ...accountPreferences[acctId], identityKeyBindings: bindings }; - } - return { - keyRecords: state.keyRecords.filter((k) => k.id !== id), - unlockedKeys, - unlockedDecryptionKeys, - unlockedLegacyDecryptionKeys, - identityKeyBindings: bindings, - accountPreferences, - }; - }); - }, - - removePublicCert: async (id) => { - await deletePublicCertDB(id); - set((state) => ({ - publicCerts: state.publicCerts.filter((c) => c.id !== id), - })); - }, - - unlockKey: async (id, passphrase) => { - const record = get().keyRecords.find((k) => k.id === id); - if (!record) throw new Error('Key record not found'); - - const { signingKey, decryptionKey, legacyDecryptionKey } = await unlockPrivateKey(record, passphrase); - set((state) => { - const unlockedKeys = new Map(state.unlockedKeys); - unlockedKeys.set(id, signingKey); - const unlockedDecryptionKeys = new Map(state.unlockedDecryptionKeys); - if (decryptionKey) { - unlockedDecryptionKeys.set(id, decryptionKey); - } - const unlockedLegacyDecryptionKeys = new Map(state.unlockedLegacyDecryptionKeys); - if (legacyDecryptionKey) { - unlockedLegacyDecryptionKeys.set(id, legacyDecryptionKey); - } - return { unlockedKeys, unlockedDecryptionKeys, unlockedLegacyDecryptionKeys }; - }); - }, - - lockKey: (id) => { - set((state) => { - const unlockedKeys = new Map(state.unlockedKeys); - unlockedKeys.delete(id); - const unlockedDecryptionKeys = new Map(state.unlockedDecryptionKeys); - unlockedDecryptionKeys.delete(id); - const unlockedLegacyDecryptionKeys = new Map(state.unlockedLegacyDecryptionKeys); - unlockedLegacyDecryptionKeys.delete(id); - return { unlockedKeys, unlockedDecryptionKeys, unlockedLegacyDecryptionKeys }; - }); - }, - - lockAllKeys: () => { - set({ unlockedKeys: new Map(), unlockedDecryptionKeys: new Map(), unlockedLegacyDecryptionKeys: new Map() }); - }, - - getKeyRecordForIdentity: (identityId) => { - const { identityKeyBindings, keyRecords } = get(); - const keyId = identityKeyBindings[identityId]; - if (!keyId) return undefined; - return keyRecords.find((k) => k.id === keyId); - }, - - getPublicCertForEmail: (email) => { - return get().publicCerts.find( - (c) => c.email.toLowerCase() === email.toLowerCase(), - ); - }, - - getRecipientCerts: (emails) => { - const { publicCerts } = get(); - const found: SmimePublicCert[] = []; - const missing: string[] = []; - for (const email of emails) { - const cert = publicCerts.find( - (c) => c.email.toLowerCase() === email.toLowerCase(), - ); - if (cert) { - found.push(cert); - } else { - missing.push(email); - } - } - return { found, missing }; - }, - - setSignDefault: (identityId, value) => { - set((state) => { - const defaultSignIdentity = { ...state.defaultSignIdentity, [identityId]: value }; - const accountPreferences = { ...state.accountPreferences }; - const acctId = state.currentAccountId; - if (acctId) { - accountPreferences[acctId] = { - ...(accountPreferences[acctId] ?? { identityKeyBindings: {}, defaultSignIdentity: {}, defaultEncrypt: false }), - defaultSignIdentity, - }; - } - return { defaultSignIdentity, accountPreferences }; - }); - }, - - setEncryptDefault: (value) => { - set((state) => { - const accountPreferences = { ...state.accountPreferences }; - const acctId = state.currentAccountId; - if (acctId) { - accountPreferences[acctId] = { - ...(accountPreferences[acctId] ?? { identityKeyBindings: {}, defaultSignIdentity: {}, defaultEncrypt: false }), - defaultEncrypt: value, - }; - } - return { defaultEncrypt: value, accountPreferences }; - }); - }, - - setAutoImportSignerCerts: (value) => { - set({ autoImportSignerCerts: value }); - }, - - isKeyUnlocked: (id) => get().unlockedKeys.has(id), - - getUnlockedKey: (id) => get().unlockedKeys.get(id), - - clearState: () => { - set({ - keyRecords: [], - publicCerts: [], - unlockedKeys: new Map(), - unlockedDecryptionKeys: new Map(), - unlockedLegacyDecryptionKeys: new Map(), - identityKeyBindings: {}, - defaultSignIdentity: {}, - defaultEncrypt: false, - currentAccountId: null, - isLoading: false, - error: null, - }); - }, - - setError: (error) => set({ error }), - }), - { - name: 'smime-preferences', - partialize: (state): SmimePersistedState => ({ - accountPreferences: state.accountPreferences, - autoImportSignerCerts: state.autoImportSignerCerts, - }), - merge: (persisted, current) => { - const p = persisted as Partial; defaultSignIdentity?: Record; defaultEncrypt?: boolean }>; - return { - ...current, - // Migrate legacy flat preferences into accountPreferences - accountPreferences: p?.accountPreferences ?? {}, - autoImportSignerCerts: p?.autoImportSignerCerts ?? true, - }; - }, - }, - ), -);