refactor: move S/MIME to generic crypto plugin hooks

This commit is contained in:
Linus Rath
2026-06-28 19:13:02 +02:00
parent 512adab7e3
commit f8b8e0b108
32 changed files with 133 additions and 7113 deletions
-8
View File
@@ -21,7 +21,6 @@ import {
Tags, Tags,
HardDrive, HardDrive,
BookUser, BookUser,
KeyRound,
PanelLeftClose, PanelLeftClose,
Bell, Bell,
Puzzle, Puzzle,
@@ -63,7 +62,6 @@ import { AccountSecuritySettings } from '@/components/settings/account-security-
import { FilesSettingsComponent } from '@/components/settings/files-settings'; import { FilesSettingsComponent } from '@/components/settings/files-settings';
import { DownloadsSettings } from '@/components/settings/downloads-settings'; import { DownloadsSettings } from '@/components/settings/downloads-settings';
import { ContactsSettings } from '@/components/settings/contacts-settings'; import { ContactsSettings } from '@/components/settings/contacts-settings';
import { SmimeSettings } from '@/components/settings/smime-settings';
import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings'; import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings';
import { NotificationSettings } from '@/components/settings/notification-settings'; import { NotificationSettings } from '@/components/settings/notification-settings';
import { ThemesSettings } from '@/components/settings/themes-settings'; import { ThemesSettings } from '@/components/settings/themes-settings';
@@ -102,7 +100,6 @@ type Tab =
| 'folders' | 'folders'
| 'keywords' | 'keywords'
| 'security' | 'security'
| 'encryption'
| 'content_senders' | 'content_senders'
| 'calendar' | 'calendar'
| 'contacts' | 'contacts'
@@ -139,7 +136,6 @@ const tabIcons: Record<Tab, LucideIcon> = {
folders: FolderOpen, folders: FolderOpen,
keywords: Tags, keywords: Tags,
security: Shield, security: Shield,
encryption: KeyRound,
content_senders: EyeOff, content_senders: EyeOff,
calendar: Calendar, calendar: Calendar,
contacts: BookUser, contacts: BookUser,
@@ -217,7 +213,6 @@ const tabSearchPaths: Record<Tab, string[]> = {
folders: ['settings.folders'], folders: ['settings.folders'],
keywords: ['settings.keywords'], keywords: ['settings.keywords'],
security: ['settings.security'], security: ['settings.security'],
encryption: ['smime'],
content_senders: [ content_senders: [
'settings.email_behavior.always_light_mode', 'settings.email_behavior.always_light_mode',
'settings.email_behavior.external_content', 'settings.email_behavior.external_content',
@@ -252,7 +247,6 @@ const tabKeywords: Record<Tab, string> = {
folders: 'mailbox subscribe', folders: 'mailbox subscribe',
keywords: 'tags labels colors', keywords: 'tags labels colors',
security: 'password 2fa two-factor passkey app password mfa', security: 'password 2fa two-factor passkey app password mfa',
encryption: 's/mime smime certificate pgp gpg',
content_senders: 'block sender remote images privacy tracking', content_senders: 'block sender remote images privacy tracking',
calendar: 'event schedule appointment meeting timezone', calendar: 'event schedule appointment meeting timezone',
contacts: 'address book contact', contacts: 'address book contact',
@@ -623,7 +617,6 @@ export default function SettingsPage() {
// Privacy & Security // Privacy & Security
...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security'), icon: tabIcons.security, group: 'privacy' as TabGroup }] : []), ...(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' }, { id: 'content_senders', label: t('tabs.content_senders'), icon: tabIcons.content_senders, group: 'privacy' },
// Apps // Apps
@@ -743,7 +736,6 @@ export default function SettingsPage() {
{effectiveActiveTab === 'folders' && <FolderSettings />} {effectiveActiveTab === 'folders' && <FolderSettings />}
{effectiveActiveTab === 'keywords' && <KeywordSettings />} {effectiveActiveTab === 'keywords' && <KeywordSettings />}
{effectiveActiveTab === 'security' && <AccountSecuritySettings />} {effectiveActiveTab === 'security' && <AccountSecuritySettings />}
{effectiveActiveTab === 'encryption' && <SmimeSettings />}
{effectiveActiveTab === 'content_senders' && <ContentSendersSettings />} {effectiveActiveTab === 'content_senders' && <ContentSendersSettings />}
{effectiveActiveTab === 'calendar' && ( {effectiveActiveTab === 'calendar' && (
managedAccountId managedAccountId
+3 -109
View File
@@ -2,16 +2,13 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { useTranslations } from "next-intl"; 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 { Avatar } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { ContactCard, AnniversaryDate, PartialDate } from "@/lib/jmap/types"; import type { ContactCard, AnniversaryDate, PartialDate } from "@/lib/jmap/types";
import { getContactDisplayName, getContactPrimaryEmail, getContactPhotoUri } from "@/stores/contact-store"; import { getContactDisplayName, getContactPrimaryEmail, getContactPhotoUri } from "@/stores/contact-store";
import { ContactActivity } from "./contact-activity"; 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 { toast } from "@/stores/toast-store";
import { exportContact } from "./contact-export"; import { exportContact } from "./contact-export";
import { printContact } from "./contact-print"; 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) { export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, onCompose, isMobile, className }: ContactDetailProps) {
const t = useTranslations("contacts"); const t = useTranslations("contacts");
const smimeStore = useSmimeStore();
const [parsedCerts, setParsedCerts] = useState<Map<number, CertificateInfo>>(new Map());
const cryptoKeys = contact?.cryptoKeys ? Object.values(contact.cryptoKeys) : []; const cryptoKeys = contact?.cryptoKeys ? Object.values(contact.cryptoKeys) : [];
useEffect(() => {
if (!contact) return;
let cancelled = false;
const parseCerts = async () => {
const results = new Map<number, CertificateInfo>();
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) { if (!contact) {
return ( return (
<div className={cn("flex flex-col items-center justify-center h-full text-muted-foreground", className)}> <div className={cn("flex flex-col items-center justify-center h-full text-muted-foreground", className)}>
@@ -207,30 +164,6 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
const anniversaries = contact.anniversaries ? Object.values(contact.anniversaries) : []; const anniversaries = contact.anniversaries ? Object.values(contact.anniversaries) : [];
const keywords = contact.keywords ? Object.keys(contact.keywords).filter(k => contact.keywords![k]) : []; 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 relatedTo = contact.relatedTo ? Object.entries(contact.relatedTo) : [];
const preferredLanguages = contact.preferredLanguages ? Object.values(contact.preferredLanguages) : []; const preferredLanguages = contact.preferredLanguages ? Object.values(contact.preferredLanguages) : [];
const personalInfo = contact.personalInfo ? Object.values(contact.personalInfo) : []; const personalInfo = contact.personalInfo ? Object.values(contact.personalInfo) : [];
@@ -493,45 +426,8 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
{cryptoKeys.length > 0 && ( {cryptoKeys.length > 0 && (
<Section title={t("detail.crypto_keys")}> <Section title={t("detail.crypto_keys")}>
<div className="space-y-3"> <div className="space-y-3">
{cryptoKeys.map((key, i) => { {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 (
<div key={i} className="rounded-md border border-border/60 bg-muted/30 p-3 space-y-1"> <div key={i} className="rounded-md border border-border/60 bg-muted/30 p-3 space-y-1">
{certInfo ? (
<>
<div className="flex items-center gap-2">
{isExpired ? (
<ShieldAlert className="w-4 h-4 text-destructive flex-shrink-0" />
) : (
<ShieldCheck className="w-4 h-4 text-primary flex-shrink-0" />
)}
<span className="text-sm font-medium truncate">{certInfo.subject}</span>
</div>
<div className="text-xs text-muted-foreground space-y-0.5 pl-6">
<p>{t("detail.cert_issuer")}: {certInfo.issuer}</p>
<p>
{t("detail.cert_expires")}: {new Date(certInfo.notAfter).toLocaleDateString()}
{isExpired && <span className="text-destructive ml-1">({t("detail.cert_expired")})</span>}
</p>
<p>{t("detail.cert_fingerprint")}: {certInfo.fingerprint.substring(0, 20)}...</p>
{certInfo.algorithm && <p>{t("detail.cert_algorithm")}: {certInfo.algorithm}</p>}
</div>
{!alreadyImported && (
<Button variant="ghost" size="sm" className="ml-4 mt-1" onClick={() => handleImportContactCert(i)}>
<Download className="w-3 h-3 mr-1" />
{t("detail.import_to_smime")}
</Button>
)}
{alreadyImported && (
<p className="text-xs text-green-600 pl-6 mt-1">{t("detail.cert_already_imported")}</p>
)}
</>
) : (
<div className="flex items-start gap-2 text-sm break-all"> <div className="flex items-start gap-2 text-sm break-all">
<KeyRound className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" /> <KeyRound className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
{typeof key.uri === 'string' && key.uri.startsWith("http") ? ( {typeof key.uri === 'string' && key.uri.startsWith("http") ? (
@@ -542,10 +438,8 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
<span className="text-muted-foreground">{typeof key.uri === 'string' ? `${key.uri.substring(0, 80)}${key.uri.length > 80 ? "…" : ""}` : String(key.uri ?? '')}</span> <span className="text-muted-foreground">{typeof key.uri === 'string' ? `${key.uri.substring(0, 80)}${key.uri.length > 80 ? "…" : ""}` : String(key.uri ?? '')}</span>
)} )}
</div> </div>
)}
</div> </div>
); ))}
})}
</div> </div>
</Section> </Section>
)} )}
@@ -67,21 +67,6 @@ vi.mock('@/stores/account-store', () => {
return { useAccountStore: hook }; 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<typeof state>) => Object.assign(state, p);
return { useSmimeStore: hook };
});
vi.mock('@/stores/email-store', () => { vi.mock('@/stores/email-store', () => {
const state = { const state = {
draftSaveEnabled: false, draftSaveEnabled: false,
@@ -172,12 +157,6 @@ vi.mock('@/lib/signature-utils', () => ({
getPlainTextSignature: () => '', getPlainTextSignature: () => '',
})); }));
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' })); 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('@/lib/debug', () => ({ debug: () => {} }));
vi.mock('@/components/email/quoted-html', () => ({ vi.mock('@/components/email/quoted-html', () => ({
buildQuotedHtmlBlock: () => '', buildQuotedHtmlBlock: () => '',
@@ -66,21 +66,6 @@ vi.mock('@/stores/account-store', () => {
return { useAccountStore: hook }; 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<typeof state>) => Object.assign(state, p);
return { useSmimeStore: hook };
});
vi.mock('@/stores/email-store', () => { vi.mock('@/stores/email-store', () => {
const state = { const state = {
draftSaveEnabled: false, draftSaveEnabled: false,
@@ -171,12 +156,6 @@ vi.mock('@/lib/signature-utils', () => ({
getPlainTextSignature: () => '', getPlainTextSignature: () => '',
})); }));
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' })); 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('@/lib/debug', () => ({ debug: () => {} }));
vi.mock('@/components/email/quoted-html', () => ({ vi.mock('@/components/email/quoted-html', () => ({
buildQuotedHtmlBlock: () => '', buildQuotedHtmlBlock: () => '',
+34 -254
View File
@@ -5,7 +5,7 @@ import { useFocusTrap } from "@/hooks/use-focus-trap";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; 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 { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
import { debug } from "@/lib/debug"; import { debug } from "@/lib/debug";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
@@ -22,16 +22,10 @@ import { useAuthStore } from "@/stores/auth-store";
import { useIdentityStore } from "@/stores/identity-store"; import { useIdentityStore } from "@/stores/identity-store";
import { useProMultiAccountIdentities, stripCrossAccountIdentityPrefix } from "@/hooks/use-pro-multi-account-identities"; import { useProMultiAccountIdentities, stripCrossAccountIdentityPrefix } from "@/hooks/use-pro-multi-account-identities";
import { useAccountStore } from "@/stores/account-store"; 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 { 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 { PluginSlot } from "@/components/plugins/plugin-slot";
import { Avatar } from "@/components/ui/avatar"; import { Avatar } from "@/components/ui/avatar";
import { FilePreviewModal } from "@/components/files/file-preview-modal"; import { FilePreviewModal } from "@/components/files/file-preview-modal";
import { smimeEncrypt } from "@/lib/smime/smime-encrypt";
import { useContactStore } from "@/stores/contact-store"; import { useContactStore } from "@/stores/contact-store";
import { useTemplateStore } from "@/stores/template-store"; import { useTemplateStore } from "@/stores/template-store";
import { SubAddressHelper } from "@/components/identity/sub-address-helper"; import { SubAddressHelper } from "@/components/identity/sub-address-helper";
@@ -522,11 +516,6 @@ export function EmailComposer({
const [showCloseDialog, setShowCloseDialog] = useState(false); const [showCloseDialog, setShowCloseDialog] = useState(false);
const [showAllAttachments, setShowAllAttachments] = useState(false); const [showAllAttachments, setShowAllAttachments] = useState(false);
const [previewAttachment, setPreviewAttachment] = useState<ComposerAttachment | null>(null); const [previewAttachment, setPreviewAttachment] = useState<ComposerAttachment | null>(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 [showAttachmentWarning, setShowAttachmentWarning] = useState(false);
const [attachmentWarningKeyword, setAttachmentWarningKeyword] = useState(''); const [attachmentWarningKeyword, setAttachmentWarningKeyword] = useState('');
const [attachmentWarningDelayedUntil, setAttachmentWarningDelayedUntil] = useState<string | undefined>(); const [attachmentWarningDelayedUntil, setAttachmentWarningDelayedUntil] = useState<string | undefined>();
@@ -802,34 +791,9 @@ export function EmailComposer({
const addTrustedSender = useSettingsStore((s) => s.addTrustedSender); const addTrustedSender = useSettingsStore((s) => s.addTrustedSender);
const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook); const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook);
const addTemplate = useTemplateStore((s) => s.addTemplate); const addTemplate = useTemplateStore((s) => s.addTemplate);
const sendRawEmail = useEmailStore((s) => s.sendRawEmail); // Sign/encrypt is provided by crypto plugins (S/MIME, PGP) via the
const smimeStore = useSmimeStore(); // composer-toolbar slot + the onComposeSend hook — the host stays
// crypto-agnostic.
// 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]);
// Serialized recipient strings for ComposerDraftData (string-shaped) and for // Serialized recipient strings for ComposerDraftData (string-shaped) and for
// by-value dirty comparison. Folds in any uncommitted typed text. // by-value dirty comparison. Folds in any uncommitted typed text.
@@ -1648,145 +1612,39 @@ export function EmailComposer({
const sendAllowed = await emailHooks.onBeforeEmailSend.intercept(sendablePreview); const sendAllowed = await emailHooks.onBeforeEmailSend.intercept(sendablePreview);
if (!sendAllowed) return; if (!sendAllowed) return;
// S/MIME send pipeline: build raw MIME → sign → encrypt → sendRawEmail // Hand off to a crypto plugin (S/MIME, PGP, …) if one wants to take over
if ((smimeSign_ || smimeEncrypt_) && client && currentIdentity?.id) { // the send: it builds raw MIME, signs/encrypts, and submits via
// S/MIME keys are scoped to one JMAP account's identity - sending // api.jmap.sendRaw. A handler returning false means "I sent it" — the
// from a cross-account identity via S/MIME would mix accounts' // host then skips its own plaintext submission but still cleans up the
// certs/clients. Refuse upfront and tell the user to switch. // draft (and fires the scheduled-send callback for a delayed send).
const crossAccount = stripCrossAccountIdentityPrefix(currentIdentity.id); const composeSendRequest = {
if (crossAccount.localAccountId) { to: toAddresses.map(r => formatRecipient(r.name, r.email)),
throw new Error('S/MIME sending from another accounts identity is not supported. Switch to that account first.'); cc: ccAddresses.map(r => formatRecipient(r.name, r.email)),
} bcc: bccAddresses.map(r => formatRecipient(r.name, r.email)),
// 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<string>((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,
});
}
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, subject,
inReplyTo: mimeInReplyTo, htmlBody: finalHtmlBody || '',
references: mimeReferences,
textBody: finalBody, textBody: finalBody,
htmlBody: finalHtmlBody, identityId: currentIdentity?.id || '',
attachments: mimeAttachments.length > 0 ? mimeAttachments : undefined, fromEmail,
}); fromName,
inReplyTo: threadingHeaders?.inReplyTo?.[0],
let payload: Blob = new Blob([mimeBytes.buffer as ArrayBuffer], { type: 'message/rfc822' }); references: threadingHeaders?.references,
delayedUntil: effectiveDelayedUntil,
const smimeHeaders = { attachments: [
from: { name: currentIdentity.name || undefined, email: fromEmail || currentIdentity.email }, ...attachments
to: toAddresses, .filter(att => att.blobId && !att.uploading && !att.error)
cc: ccAddresses.length > 0 ? ccAddresses : undefined, .map(a => ({ name: a.name, type: a.type || 'application/octet-stream', size: a.size, blobId: a.blobId })),
subject, ...inlineAttachments.map(a => ({ name: a.name, type: a.type, size: a.size, blobId: a.blobId, cid: a.cid })),
inReplyTo: mimeInReplyTo, ],
references: mimeReferences,
}; };
const sendHandledByPlugin = (await emailHooks.onComposeSend.intercept(composeSendRequest)) === false;
// 5. Sign if enabled if (sendHandledByPlugin) {
if (smimeSign_ && smimeKeyRecord) { if (finalDraftId) {
const privateKey = smimeStore.getUnlockedKey(smimeKeyRecord.id); client?.deleteEmail(finalDraftId).catch((err) => {
if (!privateKey) throw new Error('S/MIME key is not unlocked'); debug.warn('email', 'Plugin handled the send, but draft cleanup failed:', err);
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) { if (effectiveDelayedUntil) await onScheduledSendCreated?.();
await onScheduledSendCreated?.();
}
} else { } else {
// Standard JMAP send path // Standard JMAP send path
// Collect uploaded attachment blobIds for the send request // Collect uploaded attachment blobIds for the send request
@@ -1970,7 +1828,6 @@ export function EmailComposer({
showTemplatePicker || showTemplatePicker ||
showSaveAsTemplate || showSaveAsTemplate ||
showScheduleDialog || showScheduleDialog ||
smimePassphrasePrompt ||
showAttachmentWarning || showAttachmentWarning ||
showCloseDialog showCloseDialog
) return; ) return;
@@ -2506,31 +2363,8 @@ export function EmailComposer({
> >
<BookmarkPlus className="w-4 h-4" /> <BookmarkPlus className="w-4 h-4" />
</Button> </Button>
{/* S/MIME toggles */} {/* Sign/encrypt controls are contributed by crypto plugins via the
{canSmimeSign && ( composer-toolbar slot (rendered below). */}
<>
<div className="w-px h-5 bg-border mx-1" />
<Button
variant="ghost"
size="icon"
onClick={() => setSmimeSign(v => !v)}
className={cn("h-9 w-9", smimeSign_ && "bg-primary/10 text-primary")}
title={smimeSign_ ? t('smime_sign_on') : t('smime_sign_off')}
>
<ShieldCheck className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setSmimeEncrypt(v => !v)}
disabled={!canSmimeEncrypt}
className={cn("h-9 w-9", smimeEncrypt_ && "bg-primary/10 text-primary")}
title={smimeEncrypt_ ? t('smime_encrypt_on') : canSmimeEncrypt ? t('smime_encrypt_off') : t('smime_encrypt_unavailable')}
>
<Lock className="w-4 h-4" />
</Button>
</>
)}
{/* Read-receipt request toggle */} {/* Read-receipt request toggle */}
<Button <Button
@@ -2669,60 +2503,6 @@ export function EmailComposer({
</div> </div>
)} )}
{/* S/MIME passphrase prompt */}
{smimePassphrasePrompt && (
<div
className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150"
>
<div
role="dialog"
aria-modal="true"
onClick={(e) => e.stopPropagation()}
className="bg-background border border-border rounded-lg shadow-xl w-full max-w-sm animate-in zoom-in-95 duration-200"
>
<div className="p-6">
<h2 className="text-lg font-semibold text-foreground">{t('smime_unlock_title')}</h2>
<p className="mt-2 text-sm text-muted-foreground">{t('smime_unlock_message')}</p>
<input
type="password"
autoFocus
value={smimePassphraseInput}
onChange={(e) => {
setSmimePassphraseInput(e.target.value);
setSmimePassphraseError('');
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && smimePassphraseInput) {
smimePassphrasePrompt.resolve(smimePassphraseInput);
}
}}
placeholder={t('smime_passphrase_placeholder')}
className="mt-3 w-full px-3 py-2 border border-border rounded-md text-sm bg-background text-foreground outline-none focus:ring-2 focus:ring-primary"
/>
{smimePassphraseError && (
<p className="mt-1 text-xs text-red-500">{smimePassphraseError}</p>
)}
</div>
<div className="flex items-center justify-end gap-3 px-6 pb-6">
<Button variant="outline" onClick={() => {
smimePassphrasePrompt.reject();
setSmimePassphrasePrompt(null);
setSmimePassphraseInput('');
setSmimePassphraseError('');
}}>
{t('cancel')}
</Button>
<Button
disabled={!smimePassphraseInput}
onClick={() => smimePassphrasePrompt.resolve(smimePassphraseInput)}
>
{t('smime_unlock_button')}
</Button>
</div>
</div>
</div>
)}
{showAttachmentWarning && ( {showAttachmentWarning && (
<div <div
className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150" className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150"
File diff suppressed because it is too large Load Diff
-138
View File
@@ -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<SmimeVariant, string> = {
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: <Lock className="w-5 h-5" />,
text: t('unlock_key_desc'),
variant: 'warning',
});
} else if (status.decryptionError === 'no-key') {
items.push({
icon: <Lock className="w-5 h-5" />,
text: t('status_encrypted_no_key'),
variant: 'warning',
});
} else {
items.push({
icon: <ShieldX className="w-5 h-5" />,
text: t('status_encrypted_failed'),
variant: 'error',
});
}
} else {
items.push({
icon: <LockOpen className="w-5 h-5" />,
text: t('status_encrypted_ok'),
variant: 'success',
});
}
}
// Signature status
if (status.isSigned) {
if (status.signatureValid === true) {
if (status.selfSigned) {
items.push({
icon: <AlertTriangle className="w-5 h-5" />,
text: t('status_signed_self_signed'),
variant: 'warning',
});
} else if (status.signerEmailMatch === false) {
items.push({
icon: <AlertTriangle className="w-5 h-5" />,
text: t('status_signed_mismatch'),
variant: 'warning',
});
} else {
items.push({
icon: <ShieldCheck className="w-5 h-5" />,
text: t('status_signed_valid'),
variant: 'success',
});
}
} else if (status.signatureValid === false) {
items.push({
icon: <ShieldAlert className="w-5 h-5" />,
text: status.signatureError || t('status_signed_invalid'),
variant: 'error',
});
}
}
// Unsupported S/MIME
if (status.unsupportedReason) {
items.push({
icon: <Info className="w-5 h-5" />,
text: t('status_unsupported'),
variant: 'info',
});
}
if (items.length === 0) return null;
return (
<div className={cn("flex flex-col gap-3 py-1", className)}>
{items.map((item, i) => (
<div key={i} className="flex items-start gap-3">
<div className={cn(
"w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 shadow-sm",
variantTone[item.variant],
)}>
{item.icon}
</div>
<div className="flex-1 min-w-0 flex items-center justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
S/MIME
</div>
<div className="text-sm font-medium text-foreground break-words">
{item.text}
</div>
</div>
{item.variant === 'warning' && status.decryptionError === 'locked' && onUnlockKey && (
<button
onClick={onUnlockKey}
className="text-xs font-medium underline hover:no-underline flex-shrink-0"
>
{t('unlock_key')}
</button>
)}
</div>
</div>
))}
</div>
);
}
@@ -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 (
<div className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150">
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby={`${id}-title`}
className="bg-background border border-border rounded-lg shadow-xl w-full max-w-lg animate-in zoom-in-95 duration-200"
>
<div className="flex items-center justify-between p-6 pb-4 border-b border-border">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-full bg-primary/10 flex items-center justify-center">
<ShieldCheck className="w-5 h-5 text-primary" />
</div>
<h2 id={`${id}-title`} className="text-lg font-semibold text-foreground">
{t("certificate_details")}
</h2>
</div>
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="w-4 h-4" />
</Button>
</div>
<div className="p-6 space-y-3 max-h-[60vh] overflow-y-auto">
{(isExpired || isNotYetValid) && (
<div className="px-3 py-2 rounded-md bg-destructive/10 text-destructive text-sm">
{isExpired ? t("cert_expired") : t("cert_not_yet_valid")}
</div>
)}
{rows.map(({ label, value }) => (
<div key={label}>
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
{label}
</dt>
<dd className="text-sm text-foreground mt-0.5 break-all font-mono">
{value}
</dd>
</div>
))}
</div>
<div className="flex justify-end px-6 pb-6">
<Button variant="ghost" onClick={onClose}>
{t("close")}
</Button>
</div>
</div>
</div>
);
}
@@ -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<void>;
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 (
<div className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150">
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby={`${id}-title`}
aria-describedby={description ? `${id}-desc` : undefined}
className="bg-background border border-border rounded-lg shadow-xl w-full max-w-md animate-in zoom-in-95 duration-200"
>
<form onSubmit={handleSubmit}>
<div className="p-6">
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
<KeyRound className="w-5 h-5 text-primary" />
</div>
<div className="flex-1 min-w-0">
<h2
id={`${id}-title`}
className="text-lg font-semibold text-foreground"
>
{title}
</h2>
{description && (
<p
id={`${id}-desc`}
className="text-sm text-muted-foreground mt-1"
>
{description}
</p>
)}
</div>
</div>
<div className="mt-4 space-y-3">
<div className="relative">
<Input
type={showPassword ? "text" : "password"}
value={passphrase}
onChange={(e) => setPassphrase(e.target.value)}
placeholder={t("passphrase_placeholder")}
autoFocus
className="pr-10"
autoComplete="off"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground"
aria-label={showPassword ? t("hide_passphrase") : t("show_passphrase")}
>
{showPassword ? (
<EyeOff className="w-4 h-4" />
) : (
<Eye className="w-4 h-4" />
)}
</button>
</div>
{showConfirm && (
<div>
<Input
type={showPassword ? "text" : "password"}
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
placeholder={t("confirm_passphrase_placeholder")}
autoComplete="off"
/>
{mismatch && (
<p className="text-xs text-destructive mt-1">
{t("passphrase_mismatch")}
</p>
)}
</div>
)}
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
</div>
</div>
<div className="flex justify-end gap-2 px-6 pb-6">
<Button
type="button"
variant="ghost"
onClick={handleClose}
disabled={isSubmitting}
>
{t("cancel")}
</Button>
<Button
type="submit"
disabled={!passphrase || isSubmitting || (showConfirm && passphrase !== confirm)}
>
{isSubmitting ? t("processing") : (submitText ?? t("unlock"))}
</Button>
</div>
</form>
</div>
</div>
);
}
-549
View File
@@ -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<string | null>(null);
const [certModalRecord, setCertModalRecord] = useState<SmimeKeyRecord | SmimePublicCert | null>(null);
const [certModalType, setCertModalType] = useState<"private" | "public">("private");
const [importError, setImportError] = useState<string | null>(null);
const [unlockError, setUnlockError] = useState<string | null>(null);
const [pendingFile, setPendingFile] = useState<ArrayBuffer | null>(null);
const [pendingP12Pass, setPendingP12Pass] = useState("");
const fileInputRef = useRef<HTMLInputElement>(null);
const pubCertInputRef = useRef<HTMLInputElement>(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<SmimeKeyRecord | null>(null);
const [exportStep, setExportStep] = useState<"storage" | "export">("storage");
const [exportStoragePass, setExportStoragePass] = useState("");
const [exportError, setExportError] = useState<string | null>(null);
useEffect(() => {
load(activeAccountId ?? undefined);
}, [load, activeAccountId]);
// ── PKCS#12 import flow ────────────────────────────────────────
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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 (
<div className="space-y-8">
{error && (
<div className="px-4 py-3 rounded-md bg-destructive/10 text-destructive text-sm">
{error}
</div>
)}
{/* ── Your Certificates ──────────────────────────────────── */}
<SettingsSection
title={t("your_certificates")}
description={t("your_certificates_desc")}
>
<div className="space-y-2">
{keyRecords.map((record) => {
const expired = isExpired(record.notAfter);
const unlocked = isKeyUnlocked(record.id);
const boundIdentities = getBoundIdentityNames(record.id);
return (
<div
key={record.id}
className="flex items-center justify-between p-3 rounded-lg border border-border"
>
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${expired ? "bg-destructive/10" : "bg-primary/10"}`}>
{expired ? (
<ShieldAlert className="w-4 h-4 text-destructive" />
) : (
<ShieldCheck className="w-4 h-4 text-primary" />
)}
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-foreground truncate">
{record.email || record.subject}
</p>
<p className="text-xs text-muted-foreground">
{record.issuer} · {t("expires")} {formatDate(record.notAfter)}
{expired && <span className="text-destructive ml-1">({t("expired")})</span>}
</p>
{boundIdentities.length > 0 && (
<p className="text-xs text-muted-foreground">
{t("bound_to")}: {boundIdentities.join(", ")}
</p>
)}
</div>
</div>
<div className="flex items-center gap-1">
{unlocked ? (
<Button
variant="ghost"
size="icon"
onClick={() => lockKey(record.id)}
title={t("lock")}
>
<Unlock className="w-4 h-4 text-green-600" />
</Button>
) : (
<Button
variant="ghost"
size="icon"
onClick={() => handleUnlockRequest(record.id)}
title={t("unlock")}
>
<Lock className="w-4 h-4" />
</Button>
)}
<Button
variant="ghost"
size="icon"
onClick={() => {
setCertModalRecord(record);
setCertModalType("private");
}}
title={t("details")}
>
<Eye className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleExportRequest(record)}
title={t("export")}
>
<Download className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => removeKeyRecord(record.id)}
title={t("delete")}
>
<Trash2 className="w-4 h-4 text-destructive" />
</Button>
</div>
</div>
);
})}
{keyRecords.length === 0 && !isLoading && (
<p className="text-sm text-muted-foreground py-4 text-center">
{t("no_certificates")}
</p>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept=".p12,.pfx"
className="hidden"
onChange={handleFileSelect}
/>
<Button
variant="outline"
onClick={() => fileInputRef.current?.click()}
disabled={isLoading}
className="mt-2"
>
<Upload className="w-4 h-4 mr-2" />
{t("import_pkcs12")}
</Button>
</SettingsSection>
{/* ── Recipient Certificates ─────────────────────────────── */}
<SettingsSection
title={t("recipient_certificates")}
description={t("recipient_certificates_desc")}
>
<div className="space-y-2">
{publicCerts.map((cert) => {
const expired = isExpired(cert.notAfter);
return (
<div
key={cert.id}
className="flex items-center justify-between p-3 rounded-lg border border-border"
>
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center">
<Users className="w-4 h-4 text-muted-foreground" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-foreground truncate">
{cert.email || cert.subject}
</p>
<p className="text-xs text-muted-foreground">
{cert.issuer} · {cert.source}
{expired && <span className="text-destructive ml-1">({t("expired")})</span>}
</p>
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => {
setCertModalRecord(cert);
setCertModalType("public");
}}
title={t("details")}
>
<Eye className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => removePublicCert(cert.id)}
title={t("delete")}
>
<Trash2 className="w-4 h-4 text-destructive" />
</Button>
</div>
</div>
);
})}
{publicCerts.length === 0 && !isLoading && (
<p className="text-sm text-muted-foreground py-4 text-center">
{t("no_recipient_certs")}
</p>
)}
</div>
<input
ref={pubCertInputRef}
type="file"
accept=".pem,.cer,.crt,.der"
className="hidden"
onChange={handlePublicCertFile}
/>
<Button
variant="outline"
onClick={() => pubCertInputRef.current?.click()}
disabled={isLoading}
className="mt-2"
>
<Upload className="w-4 h-4 mr-2" />
{t("import_public_cert")}
</Button>
</SettingsSection>
{/* ── Identity Bindings ──────────────────────────────────── */}
{identities.length > 0 && keyRecords.length > 0 && (
<SettingsSection
title={t("identity_bindings")}
description={t("identity_bindings_desc")}
>
{identities.map((identity) => {
const boundKeyId = identityKeyBindings[identity.id];
return (
<SettingItem key={identity.id} label={identity.email}>
<select
value={boundKeyId ?? ""}
onChange={(e) =>
bindIdentityToKey(identity.id, e.target.value || null)
}
className="text-sm bg-background border border-border rounded-md px-2 py-1"
>
<option value="">{t("no_key_bound")}</option>
{keyRecords.map((kr) => (
<option key={kr.id} value={kr.id}>
{kr.email} ({kr.algorithm})
</option>
))}
</select>
</SettingItem>
);
})}
</SettingsSection>
)}
{/* ── Defaults ───────────────────────────────────────────── */}
<SettingsSection
title={t("defaults_title")}
description={t("defaults_desc")}
>
<SettingItem
label={t("encrypt_by_default")}
description={t("encrypt_by_default_desc")}
>
<ToggleSwitch
checked={defaultEncrypt}
onChange={setEncryptDefault}
/>
</SettingItem>
<SettingItem
label={t("auto_import_signer_certs")}
description={t("auto_import_signer_certs_desc")}
>
<ToggleSwitch
checked={autoImportSignerCerts}
onChange={setAutoImportSignerCerts}
/>
</SettingItem>
{identities.map((identity) => {
const bound = identityKeyBindings[identity.id];
if (!bound) return null;
return (
<SettingItem
key={identity.id}
label={`${t("sign_default_for")} ${identity.email}`}
>
<ToggleSwitch
checked={defaultSignIdentity[identity.id] ?? false}
onChange={(v) => setSignDefault(identity.id, v)}
/>
</SettingItem>
);
})}
</SettingsSection>
{/* ── Dialogs ────────────────────────────────────────────── */}
<SmimePassphraseDialog
isOpen={importDialogOpen}
onClose={() => {
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"}
/>
<SmimePassphraseDialog
isOpen={unlockDialogOpen}
onClose={() => {
setUnlockDialogOpen(false);
setUnlockTargetId(null);
setUnlockError(null);
}}
onSubmit={handleUnlockSubmit}
title={t("unlock_key")}
description={t("unlock_key_desc")}
error={unlockError}
/>
<SmimeCertificateModal
isOpen={!!certModalRecord}
onClose={() => setCertModalRecord(null)}
record={certModalRecord}
type={certModalType}
/>
<SmimePassphraseDialog
isOpen={exportDialogOpen}
onClose={() => {
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"}
/>
</div>
);
}
-2
View File
@@ -11,7 +11,6 @@ import { useFilterStore } from '@/stores/filter-store';
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils'; import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
import { useIdentityStore } from '@/stores/identity-store'; import { useIdentityStore } from '@/stores/identity-store';
import { useVacationStore } from '@/stores/vacation-store'; import { useVacationStore } from '@/stores/vacation-store';
import { useSmimeStore } from '@/stores/smime-store';
// Minimal snapshot shapes - we only capture what we need // Minimal snapshot shapes - we only capture what we need
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -133,7 +132,6 @@ export function clearAllStores(): void {
useVacationStore.getState().clearState(); useVacationStore.getState().clearState();
useCalendarStore.getState().clearState(); useCalendarStore.getState().clearState();
useFilterStore.getState().clearState(); useFilterStore.getState().clearState();
useSmimeStore.getState().clearState();
} }
/** Evict cached state for one account */ /** Evict cached state for one account */
@@ -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();
});
});
});
-197
View File
@@ -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> = {}): 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> = {}): 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();
});
});
});
-259
View File
@@ -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(<T extends ArrayBufferView | null>(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" <alice@example.com>');
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: '<p>HTML body</p>',
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('<p>HTML body</p>');
});
it('builds HTML-only message', () => {
const msg = buildMimeMessage({
from: { email: 'alice@example.com' },
to: [{ email: 'bob@example.com' }],
subject: 'HTML only',
htmlBody: '<h1>Hello</h1>',
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('<h1>Hello</h1>');
});
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: '<img src="cid:img1">',
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: <img1>');
});
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" <charlie@example.com>');
});
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: '<msg1@example.com>',
references: ['<msg0@example.com>', '<msg1@example.com>'],
date: new Date('2024-06-15T12:00:00Z'),
});
const text = new TextDecoder().decode(msg);
expect(text).toContain('In-Reply-To: <msg1@example.com>');
expect(text).toContain('References: <msg0@example.com> <msg1@example.com>');
});
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\\"" <bob@example.com>');
});
});
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);
}
});
});
});
-219
View File
@@ -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<typeof keyBag.makeInternalValues>[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<ReturnType<typeof buildTestP12>>;
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');
});
});
-361
View File
@@ -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<SmimeKeyRecord> {
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<string, CryptoKey>();
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);
});
});
-193
View File
@@ -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');
});
});
});
-360
View File
@@ -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();
});
});
});
-263
View File
@@ -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<string, string> = {
'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<string> {
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<string, string> = {
'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<CertificateInfo> {
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,
};
}
-301
View File
@@ -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<string, never>;
};
// ── 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<Uint8Array> {
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<pkijs.CryptoEngine['decryptEncryptedContentInfo']>[0],
): Promise<ArrayBuffer> {
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<T>(fn: () => Promise<T>): Promise<T> {
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);
}
}
-118
View File
@@ -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<IDBDatabase> {
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<T>(
db: IDBDatabase,
storeName: string,
mode: globalThis.IDBTransactionMode,
fn: (store: IDBObjectStore) => IDBRequest<T>,
): Promise<T> {
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<void> {
const db = await openDB();
await txPromise(db, KEY_RECORDS_STORE, 'readwrite', (s) => s.put(record));
}
export async function getKeyRecord(id: string): Promise<SmimeKeyRecord | undefined> {
const db = await openDB();
return txPromise(db, KEY_RECORDS_STORE, 'readonly', (s) => s.get(id));
}
export async function getKeyRecordForEmail(email: string): Promise<SmimeKeyRecord | undefined> {
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<SmimeKeyRecord[]> {
const db = await openDB();
const all = await txPromise<SmimeKeyRecord[]>(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<void> {
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<void> {
const db = await openDB();
await txPromise(db, PUBLIC_CERTS_STORE, 'readwrite', (s) => s.put(cert));
}
export async function getPublicCertForEmail(email: string): Promise<SmimePublicCert | undefined> {
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<SmimePublicCert[]> {
const db = await openDB();
const all = await txPromise<SmimePublicCert[]>(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<void> {
const db = await openDB();
await txPromise(db, PUBLIC_CERTS_STORE, 'readwrite', (s) => s.delete(id));
}
-339
View File
@@ -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);
}
-157
View File
@@ -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<ArrayBuffer> {
// 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<typeof keyBag.makeInternalValues>[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);
}
-341
View File
@@ -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<Pkcs12ImportResult> {
// 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<void> }).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<CryptoKey> {
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<UnlockedKeyPair> {
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<ArrayBuffer> {
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';
}
-422
View File
@@ -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<string, CryptoKey>;
/** Unlocked legacy CryptoKey map: keyRecordId → CryptoKey (RSAES-PKCS1-v1_5 via webcrypto-liner) */
legacyUnlockedKeys?: Map<string, CryptoKey>;
}
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<DecryptionResult> {
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<ArrayBuffer> {
// 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,
);
});
}
-194
View File
@@ -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;
}
-80
View File
@@ -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<Blob> {
// 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<string>();
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('');
}
-70
View File
@@ -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<Blob> {
// 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' });
}
-225
View File
@@ -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<VerificationResult> {
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;
}
-84
View File
@@ -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;
}
-15
View File
@@ -2008,9 +2008,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2031,9 +2028,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2054,9 +2048,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2077,9 +2068,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2100,9 +2088,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
-386
View File
@@ -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<string, {
identityKeyBindings: Record<string, string>;
defaultSignIdentity: Record<string, boolean>;
defaultEncrypt: boolean;
}>;
autoImportSignerCerts: boolean;
}
interface SmimeStore extends SmimePersistedState {
// Current account scope
currentAccountId: string | null;
// Account-scoped convenience accessors (derived from accountPreferences + currentAccountId)
identityKeyBindings: Record<string, string>;
defaultSignIdentity: Record<string, boolean>;
defaultEncrypt: boolean;
// Loaded from IndexedDB
keyRecords: SmimeKeyRecord[];
publicCerts: SmimePublicCert[];
// Runtime only - never persisted
unlockedKeys: Map<string, CryptoKey>;
unlockedDecryptionKeys: Map<string, CryptoKey>;
unlockedLegacyDecryptionKeys: Map<string, CryptoKey>;
isLoading: boolean;
error: string | null;
// Actions
load: (accountId?: string) => Promise<void>;
clearState: () => void;
importPKCS12: (file: ArrayBuffer, p12Passphrase: string, storagePassphrase: string) => Promise<SmimeKeyRecord>;
importPublicCert: (data: ArrayBuffer | string, source: SmimePublicCert['source'], contactId?: string) => Promise<SmimePublicCert>;
bindIdentityToKey: (identityId: string, keyRecordId: string | null) => void;
removeKeyRecord: (id: string) => Promise<void>;
removePublicCert: (id: string) => Promise<void>;
unlockKey: (id: string, passphrase: string) => Promise<void>;
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<SmimeStore>()(
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<SmimePersistedState & { identityKeyBindings?: Record<string, string>; defaultSignIdentity?: Record<string, boolean>; defaultEncrypt?: boolean }>;
return {
...current,
// Migrate legacy flat preferences into accountPreferences
accountPreferences: p?.accountPreferences ?? {},
autoImportSignerCerts: p?.autoImportSignerCerts ?? true,
};
},
},
),
);