feat: add S/MIME store for managing key records and public certificates

- Implemented Zustand store for S/MIME functionality, including state management for key records and public certificates.
- Added methods for importing PKCS#12 files and public certificates, binding identities to keys, and managing unlocked keys.
- Introduced session storage for remembering unlocked keys across sessions.
- Enhanced error handling and loading states during data operations.
This commit is contained in:
Linus Rath
2026-03-17 20:14:01 +01:00
parent 7c5785e9e8
commit de35d1d8e8
45 changed files with 24687 additions and 64 deletions
+131 -13
View File
@@ -1,12 +1,16 @@
"use client";
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, Tag, KeyRound, Link, Users, Briefcase, Heart, Languages, MessageCircle, User, Calendar, UserCircle } from "lucide-react";
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, Tag, KeyRound, Link, Users, Briefcase, Heart, Languages, MessageCircle, User, Calendar, UserCircle, ShieldCheck, ShieldAlert, Download } from "lucide-react";
import { Avatar } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { ContactCard } from "@/lib/jmap/types";
import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
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";
interface ContactDetailProps {
@@ -56,6 +60,50 @@ function formatDate(dateInput: string | Record<string, unknown>): string {
export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }: ContactDetailProps) {
const t = useTranslations("contacts");
const smimeStore = useSmimeStore();
const [parsedCerts, setParsedCerts] = useState<Map<number, CertificateInfo>>(new Map());
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:')) {
// data URI — extract base64 content
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')) {
// PEM-encoded certificate inline
derBytes = key.uri;
}
if (!derBytes) continue;
const cert = parseCertificatePemOrDer(derBytes);
const der = typeof derBytes === 'string' ? cert.toSchema(true).toBER(false) : derBytes;
const info = await extractCertificateInfo(cert, der);
if (!cancelled) results.set(i, info);
} catch { /* skip unparseable keys */ }
}
if (!cancelled) setParsedCerts(results);
};
if (cryptoKeys.length > 0) {
parseCerts();
} else {
setParsedCerts(new Map());
}
return () => { cancelled = true; };
}, [contact?.id]); // eslint-disable-line react-hooks/exhaustive-deps
if (!contact) {
return (
@@ -79,7 +127,31 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
const onlineServices = contact.onlineServices ? Object.values(contact.onlineServices) : [];
const anniversaries = contact.anniversaries ? Object.values(contact.anniversaries) : [];
const keywords = contact.keywords ? Object.keys(contact.keywords).filter(k => contact.keywords![k]) : [];
const cryptoKeys = contact.cryptoKeys ? Object.values(contact.cryptoKeys) : [];
const handleImportContactCert = async (keyIndex: number) => {
const key = cryptoKeys[keyIndex];
if (!key?.uri || typeof key.uri !== 'string') return;
try {
let derBytes: ArrayBuffer | string;
if (key.uri.startsWith('data:')) {
const commaIdx = key.uri.indexOf(',');
if (commaIdx === -1) return;
const b64 = key.uri.substring(commaIdx + 1);
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let j = 0; j < binary.length; j++) bytes[j] = binary.charCodeAt(j);
derBytes = bytes.buffer;
} else if (key.uri.startsWith('-----BEGIN')) {
derBytes = key.uri;
} else {
return;
}
await smimeStore.importPublicCert(derBytes, 'contact', contact.id);
toast.success(t("detail.cert_imported"));
} catch (err) {
toast.error(err instanceof Error ? err.message : t("detail.cert_import_failed"));
}
};
const relatedTo = contact.relatedTo ? Object.entries(contact.relatedTo) : [];
const preferredLanguages = contact.preferredLanguages ? Object.values(contact.preferredLanguages) : [];
const personalInfo = contact.personalInfo ? Object.values(contact.personalInfo) : [];
@@ -331,17 +403,63 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
{cryptoKeys.length > 0 && (
<Section icon={KeyRound} title={t("detail.crypto_keys")} category="digital">
{cryptoKeys.map((key, i) => (
<div key={i} className="text-sm break-all">
{typeof key.uri === 'string' && key.uri.startsWith("http") ? (
<a href={key.uri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
{key.uri}
</a>
) : (
<span className="text-muted-foreground">{typeof key.uri === 'string' ? `${key.uri.substring(0, 80)}${key.uri.length > 80 ? "…" : ""}` : String(key.uri ?? '')}</span>
)}
</div>
))}
{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="p-3 rounded-lg border border-border 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="text-sm break-all">
{typeof key.uri === 'string' && key.uri.startsWith("http") ? (
<a href={key.uri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
{key.uri}
</a>
) : (
<span className="text-muted-foreground">{typeof key.uri === 'string' ? `${key.uri.substring(0, 80)}${key.uri.length > 80 ? "…" : ""}` : String(key.uri ?? '')}</span>
)}
</div>
)}
</div>
);
})}
</Section>
)}
+229 -13
View File
@@ -5,13 +5,19 @@ import { useFocusTrap } from "@/hooks/use-focus-trap";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus } from "lucide-react";
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, ShieldCheck, Lock } from "lucide-react";
import { cn, formatFileSize } from "@/lib/utils";
import { debug } from "@/lib/debug";
import { toast } from "@/stores/toast-store";
import { sanitizeEmailHtml } from "@/lib/email-sanitization";
import { useAuthStore } from "@/stores/auth-store";
import { useIdentityStore } from "@/stores/identity-store";
import { useSmimeStore } from "@/stores/smime-store";
import { useEmailStore } from "@/stores/email-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 { smimeEncrypt } from "@/lib/smime/smime-encrypt";
import { useContactStore } from "@/stores/contact-store";
import { useTemplateStore } from "@/stores/template-store";
import { SubAddressHelper } from "@/components/identity/sub-address-helper";
@@ -169,6 +175,11 @@ export function EmailComposer({
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false);
const [showCloseDialog, setShowCloseDialog] = useState(false);
const [showAllAttachments, setShowAllAttachments] = useState(false);
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 saveTemplateModalRef = useFocusTrap({
isActive: showSaveAsTemplate,
@@ -187,6 +198,33 @@ export function EmailComposer({
const primaryIdentity = identities[0] ?? null;
const getAutocomplete = useContactStore((s) => s.getAutocomplete);
const addTemplate = useTemplateStore((s) => s.addTemplate);
const sendRawEmail = useEmailStore((s) => s.sendRawEmail);
const smimeStore = useSmimeStore();
// Determine S/MIME availability for the selected identity
const currentSmimeIdentityId = selectedIdentityId || primaryIdentity?.id;
const smimeKeyRecord = currentSmimeIdentityId ? smimeStore.getKeyRecordForIdentity(currentSmimeIdentityId) : undefined;
const canSmimeSign = !!smimeKeyRecord;
const canSmimeEncrypt = (() => {
if (!smimeKeyRecord) return false;
const toAddrs = to.split(',').map(e => e.trim()).filter(Boolean);
const ccAddrs = cc.split(',').map(e => e.trim()).filter(Boolean);
const bccAddrs = bcc.split(',').map(e => e.trim()).filter(Boolean);
const allRecipients = [...toAddrs, ...ccAddrs, ...bccAddrs];
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]);
// Keep a ref to current state for the unmount save
const stateRef = useRef({ to, cc, bcc, subject, body, showCc, showBcc, selectedIdentityId, subAddressTag, draftId });
@@ -642,18 +680,116 @@ export function EmailComposer({
}
try {
await onSend?.({
to: toAddresses,
cc: ccAddresses,
bcc: bccAddresses,
subject,
body: finalBody,
htmlBody: finalHtmlBody,
draftId: finalDraftId || undefined,
fromEmail,
fromName: currentIdentity?.name || undefined,
identityId: currentIdentity?.id,
});
// S/MIME send pipeline: build raw MIME → sign → encrypt → sendRawEmail
if ((smimeSign_ || smimeEncrypt_) && client && currentIdentity?.id) {
// 1. Resolve S/MIME key
if (smimeSign_ && !smimeKeyRecord) {
throw new Error('No S/MIME key bound to this identity');
}
// 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.size > 0) {
content = await att.file.arrayBuffer();
} else if (att.blobId && client) {
content = await client.fetchBlobArrayBuffer(att.blobId, att.file.name, att.file.type);
} else {
continue;
}
mimeAttachments.push({
filename: att.file.name,
contentType: att.file.type || 'application/octet-stream',
content,
});
}
// 4. Build canonical MIME
const mimeBytes = buildMimeMessage({
from: { name: currentIdentity.name || undefined, email: fromEmail || currentIdentity.email },
to: toAddresses.map(e => ({ email: e })),
cc: ccAddresses.length > 0 ? ccAddresses.map(e => ({ email: e })) : undefined,
bcc: bccAddresses.length > 0 ? bccAddresses.map(e => ({ email: e })) : undefined,
subject,
textBody: finalBody,
htmlBody: finalHtmlBody,
attachments: mimeAttachments.length > 0 ? mimeAttachments : undefined,
});
let payload: Blob = new Blob([mimeBytes.buffer as ArrayBuffer], { type: 'message/rfc822' });
const smimeHeaders = {
from: { name: currentIdentity.name || undefined, email: fromEmail || currentIdentity.email },
to: toAddresses.map(e => ({ email: e })),
cc: ccAddresses.length > 0 ? ccAddresses.map(e => ({ email: e })) : undefined,
subject,
};
// 5. Sign if enabled
if (smimeSign_ && smimeKeyRecord) {
const privateKey = smimeStore.getUnlockedKey(smimeKeyRecord.id);
if (!privateKey) throw new Error('S/MIME key is not unlocked');
const cmsBlob = await smimeSign(
mimeBytes,
privateKey,
smimeKeyRecord.certificate,
smimeKeyRecord.certificateChain || [],
);
const cmsBytes = new Uint8Array(await cmsBlob.arrayBuffer());
payload = wrapCmsAsSmimeMessage(cmsBytes, { ...smimeHeaders, smimeType: 'signed-data' });
}
// 6. Encrypt if enabled
if (smimeEncrypt_ && smimeKeyRecord) {
const allRecipients = [...toAddresses, ...ccAddresses, ...bccAddresses];
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
await sendRawEmail(client, payload, currentIdentity.id);
} else {
// Standard JMAP send path
await onSend?.({
to: toAddresses,
cc: ccAddresses,
bcc: bccAddresses,
subject,
body: finalBody,
htmlBody: finalHtmlBody,
draftId: finalDraftId || undefined,
fromEmail,
fromName: currentIdentity?.name || undefined,
identityId: currentIdentity?.id,
});
}
setTo("");
setCc("");
@@ -1059,6 +1195,32 @@ export function EmailComposer({
>
<BookmarkPlus className="w-4 h-4" />
</Button>
{/* S/MIME toggles */}
{canSmimeSign && (
<>
<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>
</>
)}
</div>
{/* Right side - Discard + Send (desktop) */}
@@ -1117,6 +1279,60 @@ export function EmailComposer({
</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>
)}
{showCloseDialog && (
<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"
File diff suppressed because it is too large Load Diff
+116
View File
@@ -0,0 +1,116 @@
"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;
}
export function SmimeStatusBanner({ status, onUnlockKey, className }: SmimeStatusBannerProps) {
const t = useTranslations('smime');
const items: Array<{
icon: React.ReactNode;
text: string;
variant: 'success' | 'warning' | 'error' | 'info';
}> = [];
// Encryption status
if (status.isEncrypted) {
if (status.decryptionError) {
if (status.decryptionError === 'locked') {
items.push({
icon: <Lock className="w-4 h-4" />,
text: t('unlock_key_desc'),
variant: 'warning',
});
} else {
items.push({
icon: <ShieldX className="w-4 h-4" />,
text: t('status_encrypted_failed'),
variant: 'error',
});
}
} else {
items.push({
icon: <LockOpen className="w-4 h-4" />,
text: t('status_encrypted_ok'),
variant: 'success',
});
}
}
// Signature status
if (status.isSigned) {
if (status.signatureValid === true) {
if (status.signerEmailMatch === false) {
items.push({
icon: <AlertTriangle className="w-4 h-4" />,
text: t('status_signed_mismatch'),
variant: 'warning',
});
} else {
items.push({
icon: <ShieldCheck className="w-4 h-4" />,
text: t('status_signed_valid'),
variant: 'success',
});
}
} else if (status.signatureValid === false) {
items.push({
icon: <ShieldAlert className="w-4 h-4" />,
text: status.signatureError || t('status_signed_invalid'),
variant: 'error',
});
}
}
// Unsupported S/MIME
if (status.unsupportedReason) {
items.push({
icon: <Info className="w-4 h-4" />,
text: t('status_unsupported'),
variant: 'info',
});
}
if (items.length === 0) return null;
const variantStyles = {
success: 'bg-green-50 dark:bg-green-950/30 text-green-700 dark:text-green-400 border-green-200 dark:border-green-800',
warning: 'bg-yellow-50 dark:bg-yellow-950/30 text-yellow-700 dark:text-yellow-400 border-yellow-200 dark:border-yellow-800',
error: 'bg-red-50 dark:bg-red-950/30 text-red-700 dark:text-red-400 border-red-200 dark:border-red-800',
info: 'bg-blue-50 dark:bg-blue-950/30 text-blue-700 dark:text-blue-400 border-blue-200 dark:border-blue-800',
};
return (
<div className={cn("flex flex-col gap-1.5 py-1", className)}>
{items.map((item, i) => (
<div
key={i}
className={cn(
"flex items-center gap-2 px-3 py-1.5 rounded-md text-sm border",
variantStyles[item.variant],
)}
>
{item.icon}
<span className="flex-1">{item.text}</span>
{item.variant === 'warning' && status.decryptionError === 'locked' && onUnlockKey && (
<button
onClick={onUnlockKey}
className="text-xs font-medium underline hover:no-underline"
>
{t('unlock_key')}
</button>
)}
</div>
))}
</div>
);
}
@@ -0,0 +1,117 @@
"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,
}: 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>
);
}
@@ -0,0 +1,169 @@
"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>
);
}
+559
View File
@@ -0,0 +1,559 @@
"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 { 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,
rememberUnlockedKeys,
autoImportSignerCerts,
isLoading,
error,
load,
importPKCS12,
removeKeyRecord,
removePublicCert,
bindIdentityToKey,
unlockKey,
lockKey,
setSignDefault,
setEncryptDefault,
setRememberUnlockedKeys,
setAutoImportSignerCerts,
isKeyUnlocked,
setError,
} = useSmimeStore();
const { identities } = useIdentityStore();
// 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();
}, [load]);
// ── 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("remember_unlocked")}
description={t("remember_unlocked_desc")}
>
<ToggleSwitch
checked={rememberUnlockedKeys}
onChange={setRememberUnlockedKeys}
/>
</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>
);
}