Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f5b133000 | ||
|
|
1f21a5213f | ||
|
|
d4c066622b | ||
|
|
e141abc849 | ||
|
|
1f4afe082b | ||
|
|
e0747c12ee | ||
|
|
f90cd6abc4 | ||
|
|
63e087f3ef | ||
|
|
f8b8e0b108 | ||
|
|
512adab7e3 | ||
|
|
4cdc15fc3c | ||
|
|
5e67671f57 | ||
|
|
155d99a069 | ||
|
|
d863b1fd4b | ||
|
|
70aaf0aac1 |
@@ -1,5 +1,28 @@
|
||||
# Changelog
|
||||
|
||||
## 1.7.6 (2026-06-28)
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- **S/MIME**: The built-in S/MIME implementation has been removed from core and re-delivered through the new generic crypto plugin hooks (privileged same-origin plugin tier). S/MIME signing, encryption, decryption, certificate management, and the related settings UI now live in a plugin rather than the main app. Deployments that relied on built-in S/MIME must install the S/MIME crypto plugin to retain those features.
|
||||
|
||||
### Features
|
||||
|
||||
- **Plugins**: Privileged same-origin plugin tier with a crypto API surface
|
||||
- **Plugins**: Plugin hooks for email details, headers, and source
|
||||
- **Mail**: Option to hide the total message count on folders (#498)
|
||||
|
||||
### Fixes
|
||||
|
||||
- **Mail**: Hide the server scheduled folder when the virtual one is shown (#495)
|
||||
- **Mail**: Stop the unified mailbox from mutating client-returned email objects
|
||||
- **Composer**: HTML-escape sender and subject in the reply/forward quote header (#482)
|
||||
- **Calendar**: Send calendar invites by setting `organizerCalendarAddress`
|
||||
- **Identity**: Sync the default identity (`preferredPrimaryId`) to server settings (#507)
|
||||
- **Auth**: Support MFA login via the structured auth endpoint
|
||||
- **Admin**: Show all built-in themes in the admin theme controls (#496)
|
||||
- **i18n**: Add missing translation keys across 19 locales
|
||||
|
||||
## 1.7.5 (2026-06-24)
|
||||
|
||||
### Features
|
||||
|
||||
@@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
|
||||
|
||||
[](LICENSE)
|
||||
[](https://discord.gg/tYCujymGrT)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://ghcr.io/bulwarkmail/webmail)
|
||||
[](https://grafana.external.bulwarkmail.org/)
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
Tags,
|
||||
HardDrive,
|
||||
BookUser,
|
||||
KeyRound,
|
||||
PanelLeftClose,
|
||||
Bell,
|
||||
Puzzle,
|
||||
@@ -63,7 +62,6 @@ import { AccountSecuritySettings } from '@/components/settings/account-security-
|
||||
import { FilesSettingsComponent } from '@/components/settings/files-settings';
|
||||
import { DownloadsSettings } from '@/components/settings/downloads-settings';
|
||||
import { ContactsSettings } from '@/components/settings/contacts-settings';
|
||||
import { SmimeSettings } from '@/components/settings/smime-settings';
|
||||
import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings';
|
||||
import { NotificationSettings } from '@/components/settings/notification-settings';
|
||||
import { ThemesSettings } from '@/components/settings/themes-settings';
|
||||
@@ -102,7 +100,6 @@ type Tab =
|
||||
| 'folders'
|
||||
| 'keywords'
|
||||
| 'security'
|
||||
| 'encryption'
|
||||
| 'content_senders'
|
||||
| 'calendar'
|
||||
| 'contacts'
|
||||
@@ -139,7 +136,6 @@ const tabIcons: Record<Tab, LucideIcon> = {
|
||||
folders: FolderOpen,
|
||||
keywords: Tags,
|
||||
security: Shield,
|
||||
encryption: KeyRound,
|
||||
content_senders: EyeOff,
|
||||
calendar: Calendar,
|
||||
contacts: BookUser,
|
||||
@@ -217,7 +213,6 @@ const tabSearchPaths: Record<Tab, string[]> = {
|
||||
folders: ['settings.folders'],
|
||||
keywords: ['settings.keywords'],
|
||||
security: ['settings.security'],
|
||||
encryption: ['smime'],
|
||||
content_senders: [
|
||||
'settings.email_behavior.always_light_mode',
|
||||
'settings.email_behavior.external_content',
|
||||
@@ -252,7 +247,6 @@ const tabKeywords: Record<Tab, string> = {
|
||||
folders: 'mailbox subscribe',
|
||||
keywords: 'tags labels colors',
|
||||
security: 'password 2fa two-factor passkey app password mfa',
|
||||
encryption: 's/mime smime certificate pgp gpg',
|
||||
content_senders: 'block sender remote images privacy tracking',
|
||||
calendar: 'event schedule appointment meeting timezone',
|
||||
contacts: 'address book contact',
|
||||
@@ -623,7 +617,6 @@ export default function SettingsPage() {
|
||||
|
||||
// Privacy & Security
|
||||
...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security'), icon: tabIcons.security, group: 'privacy' as TabGroup }] : []),
|
||||
...(isFeatureEnabled('smimeEnabled') ? [{ id: 'encryption' as Tab, label: t('tabs.encryption'), icon: tabIcons.encryption, group: 'privacy' as TabGroup }] : []),
|
||||
{ id: 'content_senders', label: t('tabs.content_senders'), icon: tabIcons.content_senders, group: 'privacy' },
|
||||
|
||||
// Apps
|
||||
@@ -743,7 +736,6 @@ export default function SettingsPage() {
|
||||
{effectiveActiveTab === 'folders' && <FolderSettings />}
|
||||
{effectiveActiveTab === 'keywords' && <KeywordSettings />}
|
||||
{effectiveActiveTab === 'security' && <AccountSecuritySettings />}
|
||||
{effectiveActiveTab === 'encryption' && <SmimeSettings />}
|
||||
{effectiveActiveTab === 'content_senders' && <ContentSendersSettings />}
|
||||
{effectiveActiveTab === 'calendar' && (
|
||||
managedAccountId
|
||||
|
||||
@@ -5,12 +5,11 @@ import { Upload, Trash2, Power, PowerOff, Loader2, Palette, Save, Shield, Lock,
|
||||
import type { SettingsPolicy } from '@/lib/admin/types';
|
||||
import { DEFAULT_POLICY, DEFAULT_THEME_POLICY } from '@/lib/admin/types';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { BUILTIN_THEMES } from '@/lib/builtin-themes';
|
||||
|
||||
const BUILTIN_THEME_OPTIONS = [
|
||||
{ id: 'builtin-nord', name: 'Nord' },
|
||||
{ id: 'builtin-catppuccin', name: 'Catppuccin' },
|
||||
{ id: 'builtin-solarized', name: 'Solarized' },
|
||||
];
|
||||
// Derive from the single source of truth so newly added built-in themes show
|
||||
// up here automatically (was previously a hardcoded subset — see #496).
|
||||
const BUILTIN_THEME_OPTIONS = BUILTIN_THEMES.map(t => ({ id: t.id, name: t.name }));
|
||||
|
||||
interface ThemeEntry {
|
||||
id: string;
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { SandboxRuntime } from '@/lib/plugin-sandbox/runtime';
|
||||
|
||||
// Privileged-tier sandbox route. Identical runtime to /plugin-sandbox, but the
|
||||
// host loads it into a same-origin (`allow-same-origin`) iframe so the bundle
|
||||
// gets real `crypto.subtle` + IndexedDB. The trust gate (signature + admin
|
||||
// approval) is enforced host-side before this route is ever framed; the page
|
||||
// itself carries no extra privilege.
|
||||
//
|
||||
// Must be dynamic so the per-request CSP nonce from proxy.ts is embedded in
|
||||
// Next's injected hydration/chunk scripts.
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default function PrivilegedPluginSandboxPage() {
|
||||
return <SandboxRuntime />;
|
||||
}
|
||||
@@ -183,6 +183,7 @@ export async function POST(request: NextRequest) {
|
||||
author: manifest.author as string,
|
||||
description: (manifest.description as string) || '',
|
||||
type: manifest.type as string,
|
||||
...(manifest.tier === 'privileged' ? { tier: 'privileged' } : {}),
|
||||
permissions: (manifest.permissions as string[]) || [],
|
||||
entrypoint: manifest.entrypoint as string,
|
||||
enabled: true,
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||
import { getDiscoveryValidator } from '@/lib/oauth/token-exchange';
|
||||
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
import { readFileEnv } from '@/lib/read-file-env';
|
||||
@@ -11,81 +9,173 @@ import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
||||
import { parseJmapServers, findServerByUrl, findServerById } from '@/lib/admin/jmap-servers';
|
||||
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||
import { generateCodeVerifier, generateCodeChallenge } from '@/lib/oauth/pkce';
|
||||
|
||||
/**
|
||||
* Exchange basic auth credentials (with TOTP appended) for OAuth tokens.
|
||||
* Exchange a password + (optional) TOTP code for OAuth tokens.
|
||||
*
|
||||
* This allows 2FA users who log in with basic auth + TOTP to upgrade
|
||||
* to token-based auth, avoiding session expiry when the TOTP rotates.
|
||||
* Stalwart 0.16+ no longer accepts the legacy `password$totp` convention over
|
||||
* HTTP Basic auth: its Basic decoder hardcodes `mfa_token: None` and never
|
||||
* splits the secret on `$`, so any TOTP appended to the password is verified
|
||||
* verbatim against the password hash and fails. The MFA token must instead be
|
||||
* supplied as a distinct field through the structured login endpoint.
|
||||
*
|
||||
* Tries three strategies:
|
||||
* 1. ROPC grant with client_id (if OAUTH_CLIENT_ID is set)
|
||||
* 2. ROPC grant without client_id
|
||||
* 3. ROPC grant authenticated via Basic Auth header (Stalwart-style)
|
||||
* This route drives that flow server-side (avoiding browser CORS against the
|
||||
* mail server, same as OAuth discovery):
|
||||
* 1. POST {serverUrl}/api/auth -> authenticate with a separate `mfaToken`,
|
||||
* receiving a short-lived authorization `clientCode`.
|
||||
* 2. POST {serverUrl}/auth/token (grant_type=authorization_code) -> exchange
|
||||
* the code (with PKCE) for access/refresh tokens.
|
||||
*
|
||||
* Token-based auth also survives TOTP rotation, unlike basic auth which embeds
|
||||
* the (≈30s) code in every request.
|
||||
*/
|
||||
|
||||
async function tryTokenRequest(
|
||||
tokenEndpoint: string,
|
||||
params: URLSearchParams,
|
||||
extraHeaders?: Record<string, string>,
|
||||
): Promise<{ ok: true; tokens: { access_token: string; expires_in?: number; refresh_token?: string } } | { ok: false; status: number; error: string }> {
|
||||
try {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/x-www-form-urlencoded', ...extraHeaders };
|
||||
const response = await fetch(tokenEndpoint, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: params.toString(),
|
||||
});
|
||||
// Fallback OAuth client id used when no client is configured. Stalwart accepts
|
||||
// any client id unless `require_client_registration` is enabled (default off);
|
||||
// when it is enabled the admin must configure `oauthClientId` with this
|
||||
// redirect URI registered.
|
||||
const DEFAULT_CLIENT_ID = 'bulwark-webmail';
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
return { ok: false, status: response.status, error: errorText.substring(0, 500) };
|
||||
}
|
||||
|
||||
const tokens = await response.json();
|
||||
if (!tokens.access_token) {
|
||||
return { ok: false, status: 502, error: 'Response missing access_token' };
|
||||
}
|
||||
|
||||
return { ok: true, tokens };
|
||||
} catch (err) {
|
||||
return { ok: false, status: 0, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
interface LoginResult {
|
||||
type?: string;
|
||||
// The response keeps snake_case: only the LoginResponse variant *tags* are
|
||||
// camelCased server-side, not the struct fields (the request fields are).
|
||||
client_code?: string;
|
||||
}
|
||||
|
||||
async function findTokenEndpoint(serverUrl: string, adminTrusted: boolean): Promise<string | null> {
|
||||
// Admin-trusted callers (matched server entry or configured JMAP server URL)
|
||||
// honor the `oauthAllowPrivateEndpoints` opt-in. User-supplied URLs always
|
||||
// go through the SSRF validator regardless of the setting.
|
||||
const validateEndpoint = adminTrusted ? getDiscoveryValidator() : isPublicHttpUrl;
|
||||
// 1. Try OAuth discovery
|
||||
const metadata = await discoverOAuth(serverUrl, { validateEndpoint });
|
||||
if (metadata?.token_endpoint) return metadata.token_endpoint;
|
||||
function trimUrl(url: string): string {
|
||||
return url.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
// 2. Try common Stalwart token endpoint paths directly
|
||||
const candidates = [
|
||||
`${serverUrl}/auth/token`,
|
||||
`${serverUrl}/api/oauth/token`,
|
||||
];
|
||||
async function attemptLogin(
|
||||
upstreamUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
totp: string | undefined,
|
||||
redirectUri: string,
|
||||
slot: number,
|
||||
serverId: string | null,
|
||||
): Promise<NextResponse> {
|
||||
const base = trimUrl(upstreamUrl);
|
||||
|
||||
for (const url of candidates) {
|
||||
try {
|
||||
// A POST with no body should return 400 (bad request) rather than 404 if the endpoint exists
|
||||
const probe = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'grant_type=probe' });
|
||||
if (probe.status !== 404 && probe.status !== 405) {
|
||||
return url;
|
||||
}
|
||||
} catch {
|
||||
// Network error - endpoint not reachable
|
||||
// Per-server OAuth credentials override the global ones when the requested
|
||||
// server entry has its own oauth block configured.
|
||||
const serverList = parseJmapServers(configManager.get<unknown>('jmapServers', []));
|
||||
const entry = findServerById(serverList, serverId);
|
||||
const clientId = entry?.oauth?.clientId
|
||||
|| configManager.get<string>('oauthClientId', '')
|
||||
|| process.env.OAUTH_CLIENT_ID
|
||||
|| DEFAULT_CLIENT_ID;
|
||||
const clientSecret = entry?.oauth?.clientSecret
|
||||
|| configManager.get<string>('oauthClientSecret', '')
|
||||
|| process.env.OAUTH_CLIENT_SECRET
|
||||
|| readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE)
|
||||
|| '';
|
||||
|
||||
// PKCE proves the token exchange originates from the same client that
|
||||
// initiated the login, so no client secret is required for public clients.
|
||||
const verifier = generateCodeVerifier();
|
||||
const challenge = await generateCodeChallenge(verifier);
|
||||
|
||||
// Step 1: structured login with a separate MFA token.
|
||||
let login: LoginResult;
|
||||
try {
|
||||
const loginResponse = await fetch(`${base}/api/auth`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: 'authCode',
|
||||
accountName: username,
|
||||
accountSecret: password,
|
||||
...(totp ? { mfaToken: totp } : {}),
|
||||
clientId,
|
||||
redirectUri,
|
||||
codeChallenge: challenge,
|
||||
codeChallengeMethod: 'S256',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!loginResponse.ok) {
|
||||
const detail = (await loginResponse.text()).substring(0, 500);
|
||||
logger.warn('TOTP login: /api/auth rejected request', { status: loginResponse.status });
|
||||
// A 404 means the server predates the structured login endpoint; let the
|
||||
// caller fall back to the legacy basic-auth path.
|
||||
return NextResponse.json(
|
||||
{ error: loginResponse.status === 404 ? 'login_endpoint_missing' : 'login_failed', detail },
|
||||
{ status: loginResponse.status === 404 ? 404 : 502 },
|
||||
);
|
||||
}
|
||||
|
||||
login = await loginResponse.json();
|
||||
} catch (err) {
|
||||
logger.warn('TOTP login: /api/auth request failed', { error: err instanceof Error ? err.message : String(err) });
|
||||
return NextResponse.json({ error: 'login_unreachable' }, { status: 502 });
|
||||
}
|
||||
|
||||
return null;
|
||||
switch (login.type) {
|
||||
case 'authenticated':
|
||||
break;
|
||||
case 'mfaRequired':
|
||||
return NextResponse.json({ error: 'totp_required' }, { status: 401 });
|
||||
case 'failure':
|
||||
default:
|
||||
return NextResponse.json({ error: 'invalid_credentials' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!login.client_code) {
|
||||
logger.warn('TOTP login: authenticated response missing client_code');
|
||||
return NextResponse.json({ error: 'login_failed' }, { status: 502 });
|
||||
}
|
||||
|
||||
// Step 2: exchange the authorization code for tokens.
|
||||
const tokenParams = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code: login.client_code,
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
code_verifier: verifier,
|
||||
});
|
||||
// Confidential clients still send their secret; harmless for public clients.
|
||||
if (clientSecret) tokenParams.set('client_secret', clientSecret);
|
||||
|
||||
let tokens: { access_token?: string; expires_in?: number; refresh_token?: string };
|
||||
try {
|
||||
const tokenResponse = await fetch(`${base}/auth/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: tokenParams.toString(),
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const detail = (await tokenResponse.text()).substring(0, 500);
|
||||
logger.warn('TOTP login: token exchange failed', { status: tokenResponse.status, detail });
|
||||
return NextResponse.json({ error: 'token_exchange_failed', detail }, { status: 502 });
|
||||
}
|
||||
|
||||
tokens = await tokenResponse.json();
|
||||
} catch (err) {
|
||||
logger.warn('TOTP login: token endpoint failed', { error: err instanceof Error ? err.message : String(err) });
|
||||
return NextResponse.json({ error: 'token_exchange_failed' }, { status: 502 });
|
||||
}
|
||||
|
||||
if (!tokens.access_token) {
|
||||
return NextResponse.json({ error: 'token_exchange_failed', detail: 'Response missing access_token' }, { status: 502 });
|
||||
}
|
||||
|
||||
logger.info('TOTP login succeeded');
|
||||
void recordLogin(username, base);
|
||||
return await storeAndRespond(
|
||||
{ access_token: tokens.access_token, expires_in: tokens.expires_in, refresh_token: tokens.refresh_token },
|
||||
slot,
|
||||
serverId,
|
||||
);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { serverUrl, username, password, slot: bodySlot, server_id: bodyServerId } = await request.json();
|
||||
const { serverUrl, username, password, totp, slot: bodySlot, server_id: bodyServerId, redirectUri: bodyRedirectUri } =
|
||||
await request.json();
|
||||
|
||||
if (!serverUrl || !username || !password) {
|
||||
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
|
||||
@@ -93,6 +183,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot < MAX_ACCOUNT_SLOTS ? bodySlot : 0;
|
||||
const requestedServerId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null;
|
||||
const totpCode = typeof totp === 'string' && totp ? totp : undefined;
|
||||
|
||||
// Pin the upstream URL to a configured JMAP server. The list of allowed
|
||||
// servers is `jmapServerUrl` plus any entry from `jmapServers`. Only when
|
||||
@@ -110,20 +201,17 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
let upstreamUrl: string;
|
||||
let resolvedServerId: string | null = null;
|
||||
let adminTrusted = false;
|
||||
const requestedEntry = findServerById(serverList, requestedServerId);
|
||||
const matchedEntry = requestedEntry || findServerByUrl(serverList, serverUrl);
|
||||
|
||||
if (matchedEntry) {
|
||||
upstreamUrl = matchedEntry.url;
|
||||
resolvedServerId = matchedEntry.id;
|
||||
adminTrusted = true;
|
||||
} else if (configuredServerUrl) {
|
||||
upstreamUrl = configuredServerUrl;
|
||||
adminTrusted = true;
|
||||
} else if (allowCustomEndpoint) {
|
||||
if (!(await isPublicHttpUrl(serverUrl))) {
|
||||
logger.warn('TOTP token exchange: rejected non-public server URL');
|
||||
logger.warn('TOTP login: rejected non-public server URL');
|
||||
return NextResponse.json({ error: 'invalid_server_url' }, { status: 400 });
|
||||
}
|
||||
upstreamUrl = serverUrl;
|
||||
@@ -131,100 +219,22 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'jmap_server_not_configured' }, { status: 500 });
|
||||
}
|
||||
|
||||
const tokenEndpoint = await findTokenEndpoint(upstreamUrl, adminTrusted);
|
||||
if (!tokenEndpoint) {
|
||||
logger.warn('TOTP token exchange: no token endpoint found');
|
||||
return NextResponse.json({ error: 'no_token_endpoint', detail: 'Could not discover OAuth token endpoint on the mail server' }, { status: 404 });
|
||||
}
|
||||
// The redirect URI must be identical in the login and token-exchange steps,
|
||||
// and (when require_client_registration is on) registered for the client.
|
||||
// Prefer the browser-supplied callback URL the OAuth client already uses;
|
||||
// fall back to the upstream URL so the two steps still agree.
|
||||
const redirectUri =
|
||||
typeof bodyRedirectUri === 'string' && /^https?:\/\//.test(bodyRedirectUri)
|
||||
? bodyRedirectUri
|
||||
: trimUrl(upstreamUrl);
|
||||
|
||||
return await attemptAllStrategies(tokenEndpoint, upstreamUrl, username, password, slot, resolvedServerId);
|
||||
return await attemptLogin(upstreamUrl, username, password, totpCode, redirectUri, slot, resolvedServerId);
|
||||
} catch (error) {
|
||||
logger.error('TOTP token exchange error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
logger.error('TOTP login error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
async function attemptAllStrategies(
|
||||
tokenEndpoint: string,
|
||||
serverUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
slot: number,
|
||||
serverId: string | null,
|
||||
): Promise<NextResponse> {
|
||||
logger.info('TOTP token exchange: found token endpoint', { tokenEndpoint });
|
||||
|
||||
// Per-server OAuth credentials override the global ones when the requested
|
||||
// server entry has its own oauth block configured.
|
||||
const serverList = parseJmapServers(configManager.get<unknown>('jmapServers', []));
|
||||
const entry = findServerById(serverList, serverId);
|
||||
const clientId = entry?.oauth?.clientId
|
||||
|| configManager.get<string>('oauthClientId', '')
|
||||
|| process.env.OAUTH_CLIENT_ID;
|
||||
const clientSecret = entry?.oauth?.clientSecret
|
||||
|| configManager.get<string>('oauthClientSecret', '')
|
||||
|| process.env.OAUTH_CLIENT_SECRET
|
||||
|| readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE);
|
||||
const basicAuth = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||
const attempts: Array<{ strategy: string; error: string }> = [];
|
||||
|
||||
// Strategy 1: ROPC with client_id (if configured)
|
||||
if (clientId) {
|
||||
const params = new URLSearchParams({ grant_type: 'password', username, password, client_id: clientId });
|
||||
if (clientSecret) params.set('client_secret', clientSecret);
|
||||
const result = await tryTokenRequest(tokenEndpoint, params);
|
||||
if (result.ok) {
|
||||
logger.info('TOTP token exchange succeeded (ROPC with client_id)');
|
||||
void recordLogin(username, serverUrl);
|
||||
return await storeAndRespond(result.tokens, slot, serverId);
|
||||
}
|
||||
attempts.push({ strategy: 'ROPC with client_id', error: result.error });
|
||||
}
|
||||
|
||||
// Strategy 2: ROPC without client_id
|
||||
{
|
||||
const params = new URLSearchParams({ grant_type: 'password', username, password });
|
||||
const result = await tryTokenRequest(tokenEndpoint, params);
|
||||
if (result.ok) {
|
||||
logger.info('TOTP token exchange succeeded (ROPC without client_id)');
|
||||
void recordLogin(username, serverUrl);
|
||||
return await storeAndRespond(result.tokens, slot, serverId);
|
||||
}
|
||||
attempts.push({ strategy: 'ROPC without client_id', error: result.error });
|
||||
}
|
||||
|
||||
// Strategy 3: Basic Auth header on token endpoint (some servers accept this)
|
||||
{
|
||||
const params = new URLSearchParams({ grant_type: 'password' });
|
||||
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
|
||||
if (result.ok) {
|
||||
logger.info('TOTP token exchange succeeded (Basic Auth header)');
|
||||
void recordLogin(username, serverUrl);
|
||||
return await storeAndRespond(result.tokens, slot, serverId);
|
||||
}
|
||||
attempts.push({ strategy: 'Basic Auth header', error: result.error });
|
||||
}
|
||||
|
||||
// Strategy 4: client_credentials with Basic Auth (last resort)
|
||||
{
|
||||
const params = new URLSearchParams({ grant_type: 'client_credentials' });
|
||||
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
|
||||
if (result.ok) {
|
||||
logger.info('TOTP token exchange succeeded (client_credentials + Basic Auth)');
|
||||
void recordLogin(username, serverUrl);
|
||||
return await storeAndRespond(result.tokens, slot, serverId);
|
||||
}
|
||||
attempts.push({ strategy: 'client_credentials + Basic Auth', error: result.error });
|
||||
}
|
||||
|
||||
logger.warn('TOTP token exchange: all strategies failed', { attempts });
|
||||
return NextResponse.json({
|
||||
error: 'token_exchange_failed',
|
||||
detail: 'All token exchange strategies failed',
|
||||
attempts,
|
||||
}, { status: 502 });
|
||||
}
|
||||
|
||||
async function storeAndRespond(
|
||||
tokens: { access_token: string; expires_in?: number; refresh_token?: string },
|
||||
slot: number,
|
||||
|
||||
@@ -37,6 +37,9 @@ export async function GET() {
|
||||
author: p.author,
|
||||
description: p.description,
|
||||
type: p.type,
|
||||
// Requested execution tier; clients gate the same-origin privileged
|
||||
// sandbox on this (plus signature + approval + consent).
|
||||
tier: p.tier,
|
||||
permissions: p.permissions,
|
||||
entrypoint: p.entrypoint,
|
||||
// Policy is the canonical source for force-enable. The per-plugin field
|
||||
|
||||
@@ -523,9 +523,14 @@ export function EventModal({
|
||||
effectiveAttendees
|
||||
) as Record<string, CalendarParticipant>;
|
||||
data.replyTo = { imip: `mailto:${organizerEmail}` };
|
||||
// Stalwart (calcard) derives the iCalendar ORGANIZER property solely from
|
||||
// organizerCalendarAddress; without it no ORGANIZER is emitted and iTIP
|
||||
// scheduling is silently skipped (NoSchedulingInfo), so no invites are sent.
|
||||
data.organizerCalendarAddress = `mailto:${organizerEmail}`;
|
||||
} else if (effectiveAttendees.length === 0 && event?.participants) {
|
||||
data.participants = null;
|
||||
data.replyTo = null;
|
||||
data.organizerCalendarAddress = null;
|
||||
}
|
||||
|
||||
const shouldSendScheduling = effectiveAttendees.length > 0 && sendInvitations;
|
||||
|
||||
@@ -2,16 +2,13 @@
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, ShieldCheck, ShieldAlert, Download, MoreHorizontal, Printer } from "lucide-react";
|
||||
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, Download, MoreHorizontal, Printer } from "lucide-react";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ContactCard, AnniversaryDate, PartialDate } from "@/lib/jmap/types";
|
||||
import { getContactDisplayName, getContactPrimaryEmail, getContactPhotoUri } from "@/stores/contact-store";
|
||||
import { ContactActivity } from "./contact-activity";
|
||||
import { useSmimeStore } from "@/stores/smime-store";
|
||||
import { parseCertificatePemOrDer, extractCertificateInfo } from "@/lib/smime/certificate-utils";
|
||||
import type { CertificateInfo } from "@/lib/smime/types";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { exportContact } from "./contact-export";
|
||||
import { printContact } from "./contact-print";
|
||||
@@ -119,49 +116,9 @@ function formatDate(dateInput: AnniversaryDate): string {
|
||||
|
||||
export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, onCompose, isMobile, className }: ContactDetailProps) {
|
||||
const t = useTranslations("contacts");
|
||||
const smimeStore = useSmimeStore();
|
||||
const [parsedCerts, setParsedCerts] = useState<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:')) {
|
||||
const commaIdx = key.uri.indexOf(',');
|
||||
if (commaIdx === -1) continue;
|
||||
const b64 = key.uri.substring(commaIdx + 1);
|
||||
const binary = atob(b64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let j = 0; j < binary.length; j++) bytes[j] = binary.charCodeAt(j);
|
||||
derBytes = bytes.buffer;
|
||||
} else if (key.uri.startsWith('-----BEGIN')) {
|
||||
derBytes = key.uri;
|
||||
}
|
||||
if (!derBytes) continue;
|
||||
const cert = parseCertificatePemOrDer(derBytes);
|
||||
const der = typeof derBytes === 'string' ? cert.toSchema(true).toBER(false) : derBytes;
|
||||
const info = await extractCertificateInfo(cert, der);
|
||||
if (!cancelled) results.set(i, info);
|
||||
} catch { /* skip unparseable keys */ }
|
||||
}
|
||||
if (!cancelled) setParsedCerts(results);
|
||||
};
|
||||
if (cryptoKeys.length > 0) {
|
||||
parseCerts();
|
||||
} else {
|
||||
setParsedCerts(new Map());
|
||||
}
|
||||
return () => { cancelled = true; };
|
||||
}, [contact?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (!contact) {
|
||||
return (
|
||||
<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 keywords = contact.keywords ? Object.keys(contact.keywords).filter(k => contact.keywords![k]) : [];
|
||||
|
||||
const handleImportContactCert = async (keyIndex: number) => {
|
||||
const key = cryptoKeys[keyIndex];
|
||||
if (!key?.uri || typeof key.uri !== 'string') return;
|
||||
try {
|
||||
let derBytes: ArrayBuffer | string;
|
||||
if (key.uri.startsWith('data:')) {
|
||||
const commaIdx = key.uri.indexOf(',');
|
||||
if (commaIdx === -1) return;
|
||||
const b64 = key.uri.substring(commaIdx + 1);
|
||||
const binary = atob(b64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let j = 0; j < binary.length; j++) bytes[j] = binary.charCodeAt(j);
|
||||
derBytes = bytes.buffer;
|
||||
} else if (key.uri.startsWith('-----BEGIN')) {
|
||||
derBytes = key.uri;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
await smimeStore.importPublicCert(derBytes, 'contact', contact.id);
|
||||
toast.success(t("detail.cert_imported"));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t("detail.cert_import_failed"));
|
||||
}
|
||||
};
|
||||
const relatedTo = contact.relatedTo ? Object.entries(contact.relatedTo) : [];
|
||||
const preferredLanguages = contact.preferredLanguages ? Object.values(contact.preferredLanguages) : [];
|
||||
const personalInfo = contact.personalInfo ? Object.values(contact.personalInfo) : [];
|
||||
@@ -493,59 +426,20 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
|
||||
{cryptoKeys.length > 0 && (
|
||||
<Section title={t("detail.crypto_keys")}>
|
||||
<div className="space-y-3">
|
||||
{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">
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
{cryptoKeys.map((key, i) => (
|
||||
<div key={i} className="rounded-md border border-border/60 bg-muted/30 p-3 space-y-1">
|
||||
<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" />
|
||||
{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>
|
||||
) : (
|
||||
<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" />
|
||||
{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>
|
||||
<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>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
@@ -67,21 +67,6 @@ vi.mock('@/stores/account-store', () => {
|
||||
return { useAccountStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/smime-store', () => {
|
||||
const state = {
|
||||
certs: [],
|
||||
signingEnabled: false,
|
||||
encryptionEnabled: false,
|
||||
defaultSigningCertId: null,
|
||||
defaultEncryptionCertId: null,
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useSmimeStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/email-store', () => {
|
||||
const state = {
|
||||
draftSaveEnabled: false,
|
||||
@@ -172,12 +157,6 @@ vi.mock('@/lib/signature-utils', () => ({
|
||||
getPlainTextSignature: () => '',
|
||||
}));
|
||||
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
|
||||
vi.mock('@/lib/smime/smime-sign', () => ({ smimeSign: async () => null }));
|
||||
vi.mock('@/lib/smime/smime-encrypt', () => ({ smimeEncrypt: async () => null }));
|
||||
vi.mock('@/lib/smime/mime-builder', () => ({
|
||||
buildMimeMessage: () => null,
|
||||
wrapCmsAsSmimeMessage: () => null,
|
||||
}));
|
||||
vi.mock('@/lib/debug', () => ({ debug: () => {} }));
|
||||
vi.mock('@/components/email/quoted-html', () => ({
|
||||
buildQuotedHtmlBlock: () => '',
|
||||
|
||||
@@ -66,21 +66,6 @@ vi.mock('@/stores/account-store', () => {
|
||||
return { useAccountStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/smime-store', () => {
|
||||
const state = {
|
||||
certs: [],
|
||||
signingEnabled: false,
|
||||
encryptionEnabled: false,
|
||||
defaultSigningCertId: null,
|
||||
defaultEncryptionCertId: null,
|
||||
};
|
||||
const hook = (sel?: (s: typeof state) => unknown) =>
|
||||
typeof sel === 'function' ? sel(state) : state;
|
||||
hook.getState = () => state;
|
||||
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
|
||||
return { useSmimeStore: hook };
|
||||
});
|
||||
|
||||
vi.mock('@/stores/email-store', () => {
|
||||
const state = {
|
||||
draftSaveEnabled: false,
|
||||
@@ -171,12 +156,6 @@ vi.mock('@/lib/signature-utils', () => ({
|
||||
getPlainTextSignature: () => '',
|
||||
}));
|
||||
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
|
||||
vi.mock('@/lib/smime/smime-sign', () => ({ smimeSign: async () => null }));
|
||||
vi.mock('@/lib/smime/smime-encrypt', () => ({ smimeEncrypt: async () => null }));
|
||||
vi.mock('@/lib/smime/mime-builder', () => ({
|
||||
buildMimeMessage: () => null,
|
||||
wrapCmsAsSmimeMessage: () => null,
|
||||
}));
|
||||
vi.mock('@/lib/debug', () => ({ debug: () => {} }));
|
||||
vi.mock('@/components/email/quoted-html', () => ({
|
||||
buildQuotedHtmlBlock: () => '',
|
||||
|
||||
@@ -5,13 +5,13 @@ import { useFocusTrap } from "@/hooks/use-focus-trap";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, ShieldCheck, Lock, CalendarClock, ChevronDown, MailCheck } from "lucide-react";
|
||||
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, CalendarClock, ChevronDown, MailCheck } from "lucide-react";
|
||||
import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
|
||||
import { sanitizeSignatureHtml, sanitizeEmailHtml } from "@/lib/email-sanitization";
|
||||
import { sanitizeSignatureHtml, sanitizeEmailHtml, escapeHtml } from "@/lib/email-sanitization";
|
||||
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
|
||||
import { isFilePreviewable } from "@/lib/file-preview";
|
||||
import { buildQuotedHtmlBlock, serializeEditorContent } from "@/components/email/quoted-html";
|
||||
@@ -22,16 +22,10 @@ import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useIdentityStore } from "@/stores/identity-store";
|
||||
import { useProMultiAccountIdentities, stripCrossAccountIdentityPrefix } from "@/hooks/use-pro-multi-account-identities";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useSmimeStore } from "@/stores/smime-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { buildMimeMessage, wrapCmsAsSmimeMessage } from "@/lib/smime/mime-builder";
|
||||
import type { MimeAttachment } from "@/lib/smime/mime-builder";
|
||||
import { smimeSign } from "@/lib/smime/smime-sign";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||
import { smimeEncrypt } from "@/lib/smime/smime-encrypt";
|
||||
import { useContactStore } from "@/stores/contact-store";
|
||||
import { useTemplateStore } from "@/stores/template-store";
|
||||
import { SubAddressHelper } from "@/components/identity/sub-address-helper";
|
||||
@@ -347,9 +341,8 @@ export function EmailComposer({
|
||||
|
||||
const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : "";
|
||||
const from = replyTo.from?.[0];
|
||||
const fromStr = from ? `${from.name || from.email}` : tCommon('unknown');
|
||||
// Forward "From:" shows the full sender incl. address; reply line keeps
|
||||
// the bare name (reads naturally in the localized "On … wrote:" line).
|
||||
// Forward "From:" and the reply "On … wrote:" line both show the full
|
||||
// sender incl. address ("Name <email>"), like Gmail/Outlook (#482).
|
||||
const fromStrFull = from
|
||||
? (from.name && from.email && from.name !== from.email
|
||||
? `${from.name} <${from.email}>`
|
||||
@@ -377,7 +370,7 @@ export function EmailComposer({
|
||||
if (mode === 'forward') {
|
||||
return `${prefix}${signatureBlock}\n\n${tQuote('forwarded_separator')}\n${tQuote('from_label')}: ${fromStrFull}\n${tQuote('date_label')}: ${date}\n${tQuote('subject_label')}: ${replyTo.subject || ''}\n\n${originalText}`;
|
||||
} else if (mode === 'reply' || mode === 'replyAll') {
|
||||
return `${prefix}${signatureBlock}\n\n${tQuote('reply_line', { date, from: fromStr })}\n${quotedText}`;
|
||||
return `${prefix}${signatureBlock}\n\n${tQuote('reply_line', { date, from: fromStrFull })}\n${quotedText}`;
|
||||
}
|
||||
return prefix;
|
||||
}
|
||||
@@ -399,7 +392,7 @@ export function EmailComposer({
|
||||
|
||||
const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : "";
|
||||
const from = replyTo.from?.[0];
|
||||
const fromStr = from ? `${from.name || from.email}` : tCommon('unknown');
|
||||
// Forward and reply quote lines both show the full "Name <email>" sender (#482).
|
||||
const fromStrFull = from
|
||||
? (from.name && from.email && from.name !== from.email
|
||||
? `${from.name} <${from.email}>`
|
||||
@@ -434,9 +427,11 @@ export function EmailComposer({
|
||||
|
||||
// Build quoted content as HTML
|
||||
if (replyTo.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
|
||||
// HTML-escape user-controlled values: an unescaped sender "Name <email>"
|
||||
// has its "<email>" eaten as a bogus HTML tag by the rich-text editor (#482).
|
||||
const quoteHeader = mode === 'forward'
|
||||
? `${tQuote('forwarded_separator')}<br>${tQuote('from_label')}: ${fromStrFull}<br>${tQuote('date_label')}: ${date}<br>${tQuote('subject_label')}: ${replyTo.subject || ''}<br><br>`
|
||||
: `${tQuote('reply_line', { date, from: fromStr })}<br>`;
|
||||
? `${tQuote('forwarded_separator')}<br>${tQuote('from_label')}: ${escapeHtml(fromStrFull)}<br>${tQuote('date_label')}: ${escapeHtml(date)}<br>${tQuote('subject_label')}: ${escapeHtml(replyTo.subject || '')}<br><br>`
|
||||
: `${tQuote('reply_line', { date: escapeHtml(date), from: escapeHtml(fromStrFull) })}<br>`;
|
||||
// Embed the original as a QuotedHtml island (verbatim, schema-free) so
|
||||
// its layout survives the editor round-trip. Sanitize first to strip
|
||||
// scripts/styles/head; cid rewrite afterwards so data-cid markers
|
||||
@@ -450,9 +445,9 @@ export function EmailComposer({
|
||||
if (replyTo.body) {
|
||||
const escapedOriginal = replyTo.body.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>');
|
||||
if (mode === 'forward') {
|
||||
return `${prefix}${signatureBlock}<br><br>${tQuote('forwarded_separator')}<br>${tQuote('from_label')}: ${fromStrFull}<br>${tQuote('date_label')}: ${date}<br>${tQuote('subject_label')}: ${replyTo.subject || ''}<br><br>${escapedOriginal}`;
|
||||
return `${prefix}${signatureBlock}<br><br>${tQuote('forwarded_separator')}<br>${tQuote('from_label')}: ${escapeHtml(fromStrFull)}<br>${tQuote('date_label')}: ${escapeHtml(date)}<br>${tQuote('subject_label')}: ${escapeHtml(replyTo.subject || '')}<br><br>${escapedOriginal}`;
|
||||
} else if (mode === 'reply' || mode === 'replyAll') {
|
||||
return `${prefix}${signatureBlock}<br><br>${tQuote('reply_line', { date, from: fromStr })}<br><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${escapedOriginal}</blockquote>`;
|
||||
return `${prefix}${signatureBlock}<br><br>${tQuote('reply_line', { date: escapeHtml(date), from: escapeHtml(fromStrFull) })}<br><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${escapedOriginal}</blockquote>`;
|
||||
}
|
||||
}
|
||||
return prefix;
|
||||
@@ -521,11 +516,6 @@ export function EmailComposer({
|
||||
const [showCloseDialog, setShowCloseDialog] = useState(false);
|
||||
const [showAllAttachments, setShowAllAttachments] = useState(false);
|
||||
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 [attachmentWarningKeyword, setAttachmentWarningKeyword] = useState('');
|
||||
const [attachmentWarningDelayedUntil, setAttachmentWarningDelayedUntil] = useState<string | undefined>();
|
||||
@@ -801,34 +791,9 @@ export function EmailComposer({
|
||||
const addTrustedSender = useSettingsStore((s) => s.addTrustedSender);
|
||||
const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook);
|
||||
const addTemplate = useTemplateStore((s) => s.addTemplate);
|
||||
const sendRawEmail = useEmailStore((s) => s.sendRawEmail);
|
||||
const smimeStore = useSmimeStore();
|
||||
|
||||
// Determine S/MIME availability for the selected identity
|
||||
const currentSmimeIdentityId = selectedIdentityId || primaryIdentity?.id;
|
||||
const smimeKeyRecord = currentSmimeIdentityId ? smimeStore.getKeyRecordForIdentity(currentSmimeIdentityId) : undefined;
|
||||
const canSmimeSign = !!smimeKeyRecord;
|
||||
const canSmimeEncrypt = (() => {
|
||||
if (!smimeKeyRecord) return false;
|
||||
const allRecipients = [
|
||||
...withInput(to, toInput),
|
||||
...withInput(cc, ccInput),
|
||||
...withInput(bcc, bccInput),
|
||||
].map(r => r.email);
|
||||
if (allRecipients.length === 0) return false;
|
||||
const { missing } = smimeStore.getRecipientCerts(allRecipients);
|
||||
return missing.length === 0;
|
||||
})();
|
||||
|
||||
// Initialize S/MIME defaults from store when identity changes
|
||||
useEffect(() => {
|
||||
if (currentSmimeIdentityId) {
|
||||
setSmimeSign(!!smimeStore.defaultSignIdentity[currentSmimeIdentityId] && canSmimeSign);
|
||||
}
|
||||
setSmimeEncrypt(smimeStore.defaultEncrypt && canSmimeEncrypt);
|
||||
// Only run when identity changes, not on every recipient edit
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentSmimeIdentityId]);
|
||||
// Sign/encrypt is provided by crypto plugins (S/MIME, PGP) via the
|
||||
// composer-toolbar slot + the onComposeSend hook — the host stays
|
||||
// crypto-agnostic.
|
||||
|
||||
// Serialized recipient strings for ComposerDraftData (string-shaped) and for
|
||||
// by-value dirty comparison. Folds in any uncommitted typed text.
|
||||
@@ -1647,145 +1612,39 @@ export function EmailComposer({
|
||||
const sendAllowed = await emailHooks.onBeforeEmailSend.intercept(sendablePreview);
|
||||
if (!sendAllowed) return;
|
||||
|
||||
// S/MIME send pipeline: build raw MIME → sign → encrypt → sendRawEmail
|
||||
if ((smimeSign_ || smimeEncrypt_) && client && currentIdentity?.id) {
|
||||
// S/MIME keys are scoped to one JMAP account's identity - sending
|
||||
// from a cross-account identity via S/MIME would mix accounts'
|
||||
// certs/clients. Refuse upfront and tell the user to switch.
|
||||
const crossAccount = stripCrossAccountIdentityPrefix(currentIdentity.id);
|
||||
if (crossAccount.localAccountId) {
|
||||
throw new Error('S/MIME sending from another account’s identity is not supported. Switch to that account first.');
|
||||
}
|
||||
// 1. Resolve S/MIME key
|
||||
if (smimeSign_ && !smimeKeyRecord) {
|
||||
throw new Error('No S/MIME key bound to this identity');
|
||||
}
|
||||
// S/MIME binds to the identity's key; sending from an override address
|
||||
// would produce a signature whose Subject differs from the visible
|
||||
// From, which most clients reject or flag. Refuse up front.
|
||||
if (overrideActive) {
|
||||
throw new Error('Cannot use From override with S/MIME - disable one to send.');
|
||||
}
|
||||
|
||||
// 2. Ensure key is unlocked for signing
|
||||
if (smimeSign_ && smimeKeyRecord && !smimeStore.isKeyUnlocked(smimeKeyRecord.id)) {
|
||||
const passphrase = await new Promise<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,
|
||||
// Hand off to a crypto plugin (S/MIME, PGP, …) if one wants to take over
|
||||
// the send: it builds raw MIME, signs/encrypts, and submits via
|
||||
// api.jmap.sendRaw. A handler returning false means "I sent it" — the
|
||||
// host then skips its own plaintext submission but still cleans up the
|
||||
// draft (and fires the scheduled-send callback for a delayed send).
|
||||
const composeSendRequest = {
|
||||
to: toAddresses.map(r => formatRecipient(r.name, r.email)),
|
||||
cc: ccAddresses.map(r => formatRecipient(r.name, r.email)),
|
||||
bcc: bccAddresses.map(r => formatRecipient(r.name, r.email)),
|
||||
subject,
|
||||
htmlBody: finalHtmlBody || '',
|
||||
textBody: finalBody,
|
||||
identityId: currentIdentity?.id || '',
|
||||
fromEmail,
|
||||
fromName,
|
||||
inReplyTo: threadingHeaders?.inReplyTo?.[0],
|
||||
references: threadingHeaders?.references,
|
||||
delayedUntil: effectiveDelayedUntil,
|
||||
attachments: [
|
||||
...attachments
|
||||
.filter(att => att.blobId && !att.uploading && !att.error)
|
||||
.map(a => ({ name: a.name, type: a.type || 'application/octet-stream', size: a.size, blobId: a.blobId })),
|
||||
...inlineAttachments.map(a => ({ name: a.name, type: a.type, size: a.size, blobId: a.blobId, cid: a.cid })),
|
||||
],
|
||||
};
|
||||
const sendHandledByPlugin = (await emailHooks.onComposeSend.intercept(composeSendRequest)) === false;
|
||||
if (sendHandledByPlugin) {
|
||||
if (finalDraftId) {
|
||||
client?.deleteEmail(finalDraftId).catch((err) => {
|
||||
debug.warn('email', 'Plugin handled the send, but draft cleanup failed:', err);
|
||||
});
|
||||
}
|
||||
for (const inline of inlineAttachments) {
|
||||
if (!client) break;
|
||||
const content = await client.fetchBlobArrayBuffer(inline.blobId, inline.name, inline.type);
|
||||
mimeAttachments.push({
|
||||
filename: inline.name,
|
||||
contentType: inline.type,
|
||||
content,
|
||||
cid: inline.cid,
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Build canonical MIME
|
||||
// mime-builder takes inReplyTo as a single ref-form msg-id (with brackets);
|
||||
// references stays an array. threadingHeaders contains bare msg-ids.
|
||||
const mimeInReplyTo = threadingHeaders?.inReplyTo[0]
|
||||
? `<${threadingHeaders.inReplyTo[0]}>`
|
||||
: undefined;
|
||||
const mimeReferences = threadingHeaders?.references.length
|
||||
? threadingHeaders.references.map(id => `<${id}>`)
|
||||
: undefined;
|
||||
const mimeBytes = buildMimeMessage({
|
||||
from: { name: currentIdentity.name || undefined, email: fromEmail || currentIdentity.email },
|
||||
to: toAddresses,
|
||||
cc: ccAddresses.length > 0 ? ccAddresses : undefined,
|
||||
bcc: bccAddresses.length > 0 ? bccAddresses : undefined,
|
||||
subject,
|
||||
inReplyTo: mimeInReplyTo,
|
||||
references: mimeReferences,
|
||||
textBody: finalBody,
|
||||
htmlBody: finalHtmlBody,
|
||||
attachments: mimeAttachments.length > 0 ? mimeAttachments : undefined,
|
||||
});
|
||||
|
||||
let payload: Blob = new Blob([mimeBytes.buffer as ArrayBuffer], { type: 'message/rfc822' });
|
||||
|
||||
const smimeHeaders = {
|
||||
from: { name: currentIdentity.name || undefined, email: fromEmail || currentIdentity.email },
|
||||
to: toAddresses,
|
||||
cc: ccAddresses.length > 0 ? ccAddresses : undefined,
|
||||
subject,
|
||||
inReplyTo: mimeInReplyTo,
|
||||
references: mimeReferences,
|
||||
};
|
||||
|
||||
// 5. Sign if enabled
|
||||
if (smimeSign_ && smimeKeyRecord) {
|
||||
const privateKey = smimeStore.getUnlockedKey(smimeKeyRecord.id);
|
||||
if (!privateKey) throw new Error('S/MIME key is not unlocked');
|
||||
const cmsBlob = await smimeSign(
|
||||
mimeBytes,
|
||||
privateKey,
|
||||
smimeKeyRecord.certificate,
|
||||
smimeKeyRecord.certificateChain || [],
|
||||
);
|
||||
const cmsBytes = new Uint8Array(await cmsBlob.arrayBuffer());
|
||||
payload = wrapCmsAsSmimeMessage(cmsBytes, { ...smimeHeaders, smimeType: 'signed-data' });
|
||||
}
|
||||
|
||||
// 6. Encrypt if enabled
|
||||
if (smimeEncrypt_ && smimeKeyRecord) {
|
||||
const allRecipients = [...toAddresses, ...ccAddresses, ...bccAddresses].map(r => r.email);
|
||||
const { found, missing } = smimeStore.getRecipientCerts(allRecipients);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`Missing certificates for: ${missing.join(', ')}`);
|
||||
}
|
||||
const recipientCertsDer = found.map(c => c.certificate instanceof ArrayBuffer ? c.certificate : new Uint8Array(c.certificate as ArrayBuffer).buffer);
|
||||
const payloadBytes = new Uint8Array(await payload.arrayBuffer());
|
||||
const cmsBlob = await smimeEncrypt(
|
||||
payloadBytes,
|
||||
recipientCertsDer,
|
||||
smimeKeyRecord.certificate,
|
||||
);
|
||||
const cmsBytes = new Uint8Array(await cmsBlob.arrayBuffer());
|
||||
payload = wrapCmsAsSmimeMessage(cmsBytes, { ...smimeHeaders, smimeType: 'enveloped-data' });
|
||||
}
|
||||
|
||||
// 7. Send via raw email path
|
||||
const result = await sendRawEmail(client, payload, currentIdentity.id, effectiveDelayedUntil, [...toAddresses, ...ccAddresses, ...bccAddresses].map(r => r.email));
|
||||
if (effectiveDelayedUntil && finalDraftId) {
|
||||
client.deleteEmail(finalDraftId).catch(err => {
|
||||
debug.warn('email', 'Scheduled S/MIME send created, but plaintext draft cleanup failed:', err);
|
||||
toast.warning(t('schedule_send_cleanup_warning'));
|
||||
});
|
||||
}
|
||||
if (result.scheduled) {
|
||||
await onScheduledSendCreated?.();
|
||||
}
|
||||
if (effectiveDelayedUntil) await onScheduledSendCreated?.();
|
||||
} else {
|
||||
// Standard JMAP send path
|
||||
// Collect uploaded attachment blobIds for the send request
|
||||
@@ -1969,7 +1828,6 @@ export function EmailComposer({
|
||||
showTemplatePicker ||
|
||||
showSaveAsTemplate ||
|
||||
showScheduleDialog ||
|
||||
smimePassphrasePrompt ||
|
||||
showAttachmentWarning ||
|
||||
showCloseDialog
|
||||
) return;
|
||||
@@ -2505,31 +2363,8 @@ 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>
|
||||
</>
|
||||
)}
|
||||
{/* Sign/encrypt controls are contributed by crypto plugins via the
|
||||
composer-toolbar slot (rendered below). */}
|
||||
|
||||
{/* Read-receipt request toggle */}
|
||||
<Button
|
||||
@@ -2668,60 +2503,6 @@ 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>
|
||||
)}
|
||||
|
||||
{showAttachmentWarning && (
|
||||
<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"
|
||||
|
||||
+120
-1053
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { IdentityForm } from './identity-form';
|
||||
import { useIdentityStore } from '@/stores/identity-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
|
||||
function useSyncIdentities() {
|
||||
const syncIdentities = useAuthStore((state) => state.syncIdentities);
|
||||
@@ -206,6 +207,17 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
|
||||
|
||||
const handleSetPrimary = useCallback((identity: Identity) => {
|
||||
setPreferredPrimary(identity.id);
|
||||
// Persist to the synced settings (keyed by username, matching how
|
||||
// loadIdentities reads it back) so the choice survives a new browser /
|
||||
// cleared site data and reaches other devices (#507).
|
||||
const username = useAuthStore.getState().username || '';
|
||||
if (username) {
|
||||
const current = useSettingsStore.getState().preferredIdentityIds;
|
||||
useSettingsStore.getState().updateSetting('preferredIdentityIds', {
|
||||
...current,
|
||||
[username]: identity.id,
|
||||
});
|
||||
}
|
||||
// Re-sort: move the preferred identity to the front
|
||||
const reordered = [identity, ...identities.filter((id) => id.id !== identity.id)];
|
||||
useIdentityStore.getState().setIdentities(reordered);
|
||||
|
||||
@@ -184,8 +184,9 @@ function SidebarRowCounts({
|
||||
isSelected: boolean;
|
||||
onUnreadClick?: () => void;
|
||||
}) {
|
||||
const showFolderTotalCount = useSettingsStore(s => s.showFolderTotalCount);
|
||||
const unreadCount = unread ?? 0;
|
||||
const totalCount = total ?? 0;
|
||||
const totalCount = showFolderTotalCount ? (total ?? 0) : 0;
|
||||
|
||||
if (unreadCount === 0 && totalCount === 0) return null;
|
||||
|
||||
@@ -219,7 +220,7 @@ function SidebarRowCounts({
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<span className="ml-2 flex-shrink-0 flex items-baseline gap-1" title={`${unreadCount} unread / ${totalCount} total`}>
|
||||
<span className="ml-2 flex-shrink-0 flex items-baseline gap-1" title={totalCount > 0 ? `${unreadCount} unread / ${totalCount} total` : `${unreadCount} unread`}>
|
||||
{unreadNode}
|
||||
{unreadCount > 0 && totalCount > 0 && (
|
||||
<span className="text-xs text-muted-foreground/60">/</span>
|
||||
@@ -810,8 +811,14 @@ export function Sidebar({
|
||||
});
|
||||
};
|
||||
|
||||
// When the app renders its own virtual "Scheduled" folder (for delayed
|
||||
// sends, driven by EmailSubmission), hide the server-provided scheduled
|
||||
// mailbox (e.g. Stalwart's auto-created Scheduled folder, role === 'scheduled')
|
||||
// so it does not appear twice. (#495)
|
||||
const isServerScheduledNode = (n: MailboxNode) => showScheduledMailbox && n.role === 'scheduled';
|
||||
|
||||
const mailboxTree = buildMailboxTree(mailboxes);
|
||||
const ownTree = mailboxTree.filter(n => !n.id.startsWith('shared-account-'));
|
||||
const ownTree = mailboxTree.filter(n => !n.id.startsWith('shared-account-') && !isServerScheduledNode(n));
|
||||
const sharedAccounts = mailboxTree.filter(n => n.id.startsWith('shared-account-'));
|
||||
|
||||
// Multi-account mode (Pro shell): render every connected account as its
|
||||
@@ -826,7 +833,7 @@ export function Sidebar({
|
||||
? mailboxes
|
||||
: (accountMailboxes?.[account.id] ?? []);
|
||||
const tree = buildMailboxTree(accountMailboxList).filter(
|
||||
(n) => !n.id.startsWith('shared-account-')
|
||||
(n) => !n.id.startsWith('shared-account-') && !(isActive && isServerScheduledNode(n))
|
||||
);
|
||||
return { account, isActive, tree };
|
||||
})
|
||||
|
||||
@@ -52,6 +52,7 @@ export function PluginIframeSlot({ pluginId, slot, extraProps }: Props) {
|
||||
slot,
|
||||
code: active.code,
|
||||
locale,
|
||||
tier: active.tier,
|
||||
extraProps: extraProps ?? {},
|
||||
hostContainer: wrapperRef.current,
|
||||
onResize: (h) => setHeight(h),
|
||||
|
||||
@@ -118,7 +118,7 @@ function MailLayoutPreview({
|
||||
export function LayoutSettings() {
|
||||
const t = useTranslations('settings.appearance');
|
||||
const tEmail = useTranslations('settings.email_behavior');
|
||||
const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, includeGroupInUnified, enableAllMailView, allMailFolderIds, enableCrossUnreadView, enableCrossStarredView, enableCrossAllView, colorfulSidebarIcons, mailLayout, proInterface, updateSetting } = useSettingsStore();
|
||||
const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, includeGroupInUnified, enableAllMailView, allMailFolderIds, enableCrossUnreadView, enableCrossStarredView, enableCrossAllView, colorfulSidebarIcons, showFolderTotalCount, mailLayout, proInterface, updateSetting } = useSettingsStore();
|
||||
const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore();
|
||||
const accounts = useAccountStore(s => s.accounts);
|
||||
const activeAccountId = useAccountStore(s => s.activeAccountId);
|
||||
@@ -217,6 +217,13 @@ export function LayoutSettings() {
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('show_folder_total_count.label')} description={t('show_folder_total_count.description')}>
|
||||
<ToggleSwitch
|
||||
checked={showFolderTotalCount}
|
||||
onChange={(checked) => updateSetting('showFolderTotalCount', checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{(accounts.length > 1 || hasGroupInboxes) && !isSettingHidden('enableUnifiedMailbox') && (
|
||||
<SettingItem
|
||||
label={t('unified_mailbox.label')}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildQuoteHeader } from '@/lib/quote-header';
|
||||
|
||||
const base = {
|
||||
newTo: [] as string[],
|
||||
newCc: [] as string[],
|
||||
locale: 'en',
|
||||
timeFormat: '24h' as const,
|
||||
unknownLabel: 'Unknown',
|
||||
};
|
||||
|
||||
const sender = { name: 'Display Name', email: 'user@domain.tld' };
|
||||
|
||||
describe('buildQuoteHeader (#482 — sender address survives HTML rendering)', () => {
|
||||
it('forward TEXT keeps the full "Name <email>" sender', async () => {
|
||||
const h = await buildQuoteHeader({
|
||||
mode: 'forward',
|
||||
email: { from: [sender], subject: 'Hello', receivedAt: '2026-01-01T10:00:00Z' },
|
||||
...base,
|
||||
});
|
||||
expect(h.text).toContain('From: Display Name <user@domain.tld>');
|
||||
});
|
||||
|
||||
it('forward HTML escapes the angle brackets so the address is not eaten as a tag', async () => {
|
||||
const h = await buildQuoteHeader({
|
||||
mode: 'forward',
|
||||
email: { from: [sender], subject: 'Hello', receivedAt: '2026-01-01T10:00:00Z' },
|
||||
...base,
|
||||
});
|
||||
// The regression: a raw "<user@domain.tld>" is parsed as an HTML tag by the
|
||||
// rich-text composer and dropped, leaving only "From: Display Name".
|
||||
expect(h.html).toContain('Display Name <user@domain.tld>');
|
||||
expect(h.html).not.toContain('<user@domain.tld>');
|
||||
});
|
||||
|
||||
it('forward HTML escapes a subject containing markup (injection hardening)', async () => {
|
||||
const h = await buildQuoteHeader({
|
||||
mode: 'forward',
|
||||
email: { from: [sender], subject: 'Hi <b>x</b>', receivedAt: '2026-01-01T10:00:00Z' },
|
||||
...base,
|
||||
});
|
||||
expect(h.html).toContain('Hi <b>x</b>');
|
||||
expect(h.html).not.toContain('<b>x</b>');
|
||||
});
|
||||
|
||||
it('forward HTML escapes a malicious display name', async () => {
|
||||
const h = await buildQuoteHeader({
|
||||
mode: 'forward',
|
||||
email: {
|
||||
from: [{ name: '<img src=x onerror=alert(1)>', email: 'evil@x.tld' }],
|
||||
subject: 'Hello',
|
||||
receivedAt: '2026-01-01T10:00:00Z',
|
||||
},
|
||||
...base,
|
||||
});
|
||||
expect(h.html).not.toContain('<img src=x');
|
||||
expect(h.html).toContain('<img src=x');
|
||||
});
|
||||
|
||||
it('reply line includes the full "Name <email>" sender, escaped in HTML', async () => {
|
||||
const h = await buildQuoteHeader({
|
||||
mode: 'reply',
|
||||
email: { from: [sender], subject: 'Hello', receivedAt: '2026-01-01T10:00:00Z' },
|
||||
...base,
|
||||
});
|
||||
// TEXT keeps the real angle brackets ("On <date>, Display Name <user@domain.tld> wrote:").
|
||||
expect(h.text).toContain('Display Name <user@domain.tld> wrote:');
|
||||
// HTML escapes them so the address survives the rich-text editor (#482).
|
||||
expect(h.html).toContain('Display Name <user@domain.tld>');
|
||||
expect(h.html).not.toContain('<user@domain.tld>');
|
||||
});
|
||||
|
||||
it('reply line stays HTML-safe for a display name containing markup', async () => {
|
||||
const evil = await buildQuoteHeader({
|
||||
mode: 'reply',
|
||||
email: { from: [{ name: '<b>x</b>', email: 'e@x.tld' }], subject: 'Hello', receivedAt: '2026-01-01T10:00:00Z' },
|
||||
...base,
|
||||
});
|
||||
expect(evil.html).not.toContain('<b>x</b>');
|
||||
expect(evil.html).toContain('<b>x</b>');
|
||||
});
|
||||
|
||||
it('reply line falls back to bare email when there is no display name', async () => {
|
||||
const h = await buildQuoteHeader({
|
||||
mode: 'reply',
|
||||
email: { from: [{ email: 'noname@x.tld' }], subject: 'Hello', receivedAt: '2026-01-01T10:00:00Z' },
|
||||
...base,
|
||||
});
|
||||
expect(h.text).toContain('noname@x.tld wrote:');
|
||||
expect(h.text).not.toContain('<noname@x.tld>');
|
||||
});
|
||||
});
|
||||
@@ -120,16 +120,17 @@ describe('fetchUnifiedEmails', () => {
|
||||
expect(result).toEqual({ emails: [], total: 0, hasMore: false, errors: new Map() });
|
||||
});
|
||||
|
||||
it('CHARACTERISATION: mutates the source email objects in place (shared reference)', async () => {
|
||||
it('does NOT mutate the source email objects (decorates copies)', async () => {
|
||||
const original = makeEmail('m1', '2026-01-01T00:00:00Z');
|
||||
const acc = makeAccount(
|
||||
{ accountId: 'A', accountLabel: 'Label A', mailboxes: [makeMailbox({ role: 'inbox' })] },
|
||||
{ getEmails: vi.fn(async (): Promise<FetchResult> => ({ emails: [original], total: 1, hasMore: false })) },
|
||||
);
|
||||
await fetchUnifiedEmails([acc], 'inbox', 20, 0);
|
||||
// The very object passed back by the client was mutated, not a copy.
|
||||
expect(original.accountId).toBe('A');
|
||||
expect(original.accountLabel).toBe('Label A');
|
||||
const res = await fetchUnifiedEmails([acc], 'inbox', 20, 0);
|
||||
// The returned email carries the account info, but the client's object is untouched.
|
||||
expect(res.emails[0]).toMatchObject({ id: 'm1', accountId: 'A', accountLabel: 'Label A' });
|
||||
expect('accountId' in original).toBe(false);
|
||||
expect('accountLabel' in original).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import { useFilterStore } from '@/stores/filter-store';
|
||||
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
|
||||
import { useIdentityStore } from '@/stores/identity-store';
|
||||
import { useVacationStore } from '@/stores/vacation-store';
|
||||
import { useSmimeStore } from '@/stores/smime-store';
|
||||
|
||||
// Minimal snapshot shapes - we only capture what we need
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -133,7 +132,6 @@ export function clearAllStores(): void {
|
||||
useVacationStore.getState().clearState();
|
||||
useCalendarStore.getState().clearState();
|
||||
useFilterStore.getState().clearState();
|
||||
useSmimeStore.getState().clearState();
|
||||
}
|
||||
|
||||
/** Evict cached state for one account */
|
||||
|
||||
@@ -47,6 +47,9 @@ export interface ServerPlugin {
|
||||
author: string;
|
||||
description: string;
|
||||
type: string;
|
||||
/** Requested execution tier ('untrusted' | 'privileged'). Privileged plugins
|
||||
* run in a same-origin sandbox and require admin approval + consent. */
|
||||
tier?: string;
|
||||
permissions: string[];
|
||||
entrypoint: string;
|
||||
enabled: boolean;
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
// Shared "View source" renderer. Builds a human-readable dump of a message's
|
||||
// headers, metadata and body from its JMAP Email object. Used both by the
|
||||
// email viewer's source modal and by the plugin projection so plugins see the
|
||||
// exact same text the UI shows. Pure: depends only on the passed `email`.
|
||||
|
||||
import type { Email } from '@/lib/jmap/types';
|
||||
import { formatFileSize } from '@/lib/utils';
|
||||
|
||||
export function generateEmailSource(email: Email): string {
|
||||
let source = '';
|
||||
|
||||
// Headers
|
||||
source += '=== EMAIL HEADERS ===\n\n';
|
||||
if (email.messageId) source += `Message-ID: ${email.messageId}\n`;
|
||||
if (email.from) source += `From: ${email.from.map(a => a.name ? `${a.name} <${a.email}>` : a.email).join(', ')}\n`;
|
||||
if (email.to) source += `To: ${email.to.map(a => a.name ? `${a.name} <${a.email}>` : a.email).join(', ')}\n`;
|
||||
if (email.cc) source += `Cc: ${email.cc.map(a => a.name ? `${a.name} <${a.email}>` : a.email).join(', ')}\n`;
|
||||
if (email.bcc) source += `Bcc: ${email.bcc.map(a => a.name ? `${a.name} <${a.email}>` : a.email).join(', ')}\n`;
|
||||
if (email.replyTo) source += `Reply-To: ${email.replyTo.map(a => a.name ? `${a.name} <${a.email}>` : a.email).join(', ')}\n`;
|
||||
if (email.subject) source += `Subject: ${email.subject}\n`;
|
||||
if (email.sentAt) source += `Date: ${new Date(email.sentAt).toUTCString()}\n`;
|
||||
if (email.receivedAt) source += `Received-At: ${new Date(email.receivedAt).toUTCString()}\n`;
|
||||
if (email.inReplyTo) source += `In-Reply-To: ${email.inReplyTo.join(', ')}\n`;
|
||||
if (email.references) source += `References: ${email.references.join(', ')}\n`;
|
||||
|
||||
// Additional headers
|
||||
if (email.headers) {
|
||||
source += '\n--- Additional Headers ---\n';
|
||||
// Headers should now always be a Record after client processing
|
||||
Object.entries(email.headers).forEach(([key, value]) => {
|
||||
const val = Array.isArray(value) ? value.join('\n ') : String(value);
|
||||
source += `${key}: ${val}\n`;
|
||||
});
|
||||
}
|
||||
|
||||
// Authentication results
|
||||
if (email.authenticationResults) {
|
||||
source += '\n--- Authentication Results ---\n';
|
||||
if (email.authenticationResults.spf) {
|
||||
source += `SPF: ${email.authenticationResults.spf.result}`;
|
||||
if (email.authenticationResults.spf.domain) source += ` (${email.authenticationResults.spf.domain})`;
|
||||
source += '\n';
|
||||
}
|
||||
if (email.authenticationResults.dkim) {
|
||||
source += `DKIM: ${email.authenticationResults.dkim.result}`;
|
||||
if (email.authenticationResults.dkim.domain) source += ` (${email.authenticationResults.dkim.domain})`;
|
||||
source += '\n';
|
||||
}
|
||||
if (email.authenticationResults.dmarc) {
|
||||
source += `DMARC: ${email.authenticationResults.dmarc.result}`;
|
||||
if (email.authenticationResults.dmarc.policy) source += ` policy=${email.authenticationResults.dmarc.policy}`;
|
||||
source += '\n';
|
||||
}
|
||||
}
|
||||
|
||||
if (email.spamScore !== undefined) {
|
||||
source += `Spam Score: ${email.spamScore}`;
|
||||
if (email.spamStatus) source += ` (${email.spamStatus})`;
|
||||
source += '\n';
|
||||
}
|
||||
|
||||
// Metadata
|
||||
source += '\n=== EMAIL METADATA ===\n\n';
|
||||
source += `Email ID: ${email.id}\n`;
|
||||
source += `Thread ID: ${email.threadId}\n`;
|
||||
source += `Size: ${formatFileSize(email.size)}\n`;
|
||||
source += `Has Attachment: ${email.hasAttachment ? 'Yes' : 'No'}\n`;
|
||||
if (email.keywords) {
|
||||
const keywords = Object.entries(email.keywords)
|
||||
.filter(([_, v]) => v)
|
||||
.map(([k]) => k)
|
||||
.join(', ');
|
||||
if (keywords) source += `Keywords: ${keywords}\n`;
|
||||
}
|
||||
|
||||
// Attachments
|
||||
if (email.attachments && email.attachments.length > 0) {
|
||||
source += '\n=== ATTACHMENTS ===\n\n';
|
||||
email.attachments.forEach((att, i) => {
|
||||
source += `[${i + 1}] ${att.name || 'Unnamed'}\n`;
|
||||
source += ` Type: ${att.type}\n`;
|
||||
source += ` Size: ${formatFileSize(att.size)}\n`;
|
||||
source += ` Blob ID: ${att.blobId}\n`;
|
||||
if (att.cid) source += ` Content-ID: ${att.cid}\n`;
|
||||
source += '\n';
|
||||
});
|
||||
}
|
||||
|
||||
// Body content
|
||||
source += '\n=== EMAIL BODY ===\n\n';
|
||||
|
||||
let hasBodyContent = false;
|
||||
|
||||
// Text version
|
||||
if (email.textBody?.[0]?.partId && email.bodyValues?.[email.textBody[0].partId]) {
|
||||
const textValue = email.bodyValues[email.textBody[0].partId].value;
|
||||
if (textValue && textValue.trim()) {
|
||||
source += '--- Plain Text Version ---\n\n';
|
||||
source += textValue;
|
||||
source += '\n\n';
|
||||
hasBodyContent = true;
|
||||
}
|
||||
}
|
||||
|
||||
// HTML version
|
||||
if (email.htmlBody?.[0]?.partId && email.bodyValues?.[email.htmlBody[0].partId]) {
|
||||
const htmlValue = email.bodyValues[email.htmlBody[0].partId].value;
|
||||
if (htmlValue && htmlValue.trim()) {
|
||||
source += '--- HTML Version ---\n\n';
|
||||
source += htmlValue;
|
||||
source += '\n\n';
|
||||
hasBodyContent = true;
|
||||
}
|
||||
}
|
||||
|
||||
// All body values if we haven't found content yet
|
||||
if (!hasBodyContent && email.bodyValues) {
|
||||
const bodyKeys = Object.keys(email.bodyValues);
|
||||
if (bodyKeys.length > 0) {
|
||||
source += '--- Body Parts ---\n\n';
|
||||
bodyKeys.forEach((key, index) => {
|
||||
const bodyValue = email.bodyValues![key].value;
|
||||
if (bodyValue && bodyValue.trim()) {
|
||||
source += `Part ${index + 1} (${key}):\n`;
|
||||
source += bodyValue;
|
||||
source += '\n\n';
|
||||
hasBodyContent = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Preview if no body
|
||||
if (!hasBodyContent && email.preview) {
|
||||
source += '--- Preview Only ---\n\n';
|
||||
source += email.preview;
|
||||
source += '\n';
|
||||
}
|
||||
|
||||
if (!hasBodyContent && !email.preview) {
|
||||
source += '(No body content available)\n';
|
||||
}
|
||||
|
||||
return source;
|
||||
}
|
||||
+4
-1
@@ -702,7 +702,10 @@ export class JMAPClient implements IJMAPClient {
|
||||
if (sessionResponse.status === 402) {
|
||||
try {
|
||||
const body = await sessionResponse.json();
|
||||
if (body?.title?.toLowerCase().includes('totp')) {
|
||||
// Older Stalwart titled this "TOTP code required"; 0.16+ uses the
|
||||
// generic "MFA code required" - accept either to trigger the prompt.
|
||||
const title = body?.title?.toLowerCase() ?? '';
|
||||
if (title.includes('totp') || title.includes('mfa')) {
|
||||
throw new Error('TOTP_REQUIRED');
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -249,6 +249,16 @@ export const emailHooks = {
|
||||
// recipients change. Handler receives a DraftView snapshot. Use for AI
|
||||
// assistants, grammar checkers, etc.
|
||||
onDraftChange: new HookBus(),
|
||||
// Intercept hook - fires at the very TOP of the composer send path, before
|
||||
// the host builds and submits the message. Handler receives a ComposeSend
|
||||
// request (draft fields, recipients, identityId, attachments, and the user's
|
||||
// sign/encrypt intent) and may TAKE OVER sending entirely: build a raw MIME
|
||||
// message, sign/encrypt it, and submit it via `api.jmap.sendRaw`. Returning
|
||||
// false signals "I handled the send" and the host SKIPS its default
|
||||
// submission. Returning anything else (incl. undefined) lets the host send
|
||||
// normally. This is the send-takeover hook used by the S/MIME plugin to
|
||||
// replace the former native sign+encrypt+sendRaw pipeline.
|
||||
onComposeSend: new HookBus(),
|
||||
};
|
||||
|
||||
// §7.2 Calendar Hooks
|
||||
@@ -520,6 +530,18 @@ export const renderHooks = {
|
||||
// Handlers return a new (or extended) badges array.
|
||||
// Rendered by the email list row component next to the subject line.
|
||||
onEmailListItemRender: new HookBus(),
|
||||
// Transform hook - runs when an email is opened, BEFORE the viewer computes
|
||||
// the body it will render. Initial value: RenderableBody { html, text,
|
||||
// attachments, handledBy? }. Second argument: MessageContext { id,
|
||||
// bodyStructure, attachments, blobId, contentType, from }. A handler may
|
||||
// inspect the message (e.g. detect S/MIME), fetch the raw blob via
|
||||
// `api.jmap.fetchBlob`, decrypt/verify in-frame, and return a REPLACED body
|
||||
// with `handledBy` set plus optional `verification` status. Return undefined
|
||||
// (or the unchanged value) to pass through. The host still runs the returned
|
||||
// HTML through its sanitizer — plugin output is not trusted blindly. This is
|
||||
// the render-takeover hook used by the S/MIME plugin to replace the former
|
||||
// native detect/decrypt/verify path in the viewer.
|
||||
onRenderEmailBody: new HookBus(),
|
||||
};
|
||||
|
||||
// ─── Aggregate: remove all handlers for a plugin across all buses ───
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import type { Email } from '@/lib/jmap/types';
|
||||
import type { EmailReadView } from '@/lib/plugin-types';
|
||||
import { generateEmailSource } from '@/lib/email-source';
|
||||
|
||||
// Resolve a message's plain-text body from its JMAP body parts. Plugins that
|
||||
// translate or scan content need the real body, not just the short `preview`
|
||||
@@ -54,6 +55,8 @@ export function emailToReadView(email: Email): EmailReadView {
|
||||
hasAttachment: email.hasAttachment,
|
||||
preview: email.preview || '',
|
||||
text: plainTextFromEmail(email),
|
||||
headers: email.headers,
|
||||
source: generateEmailSource(email),
|
||||
keywords: Object.keys(email.keywords || {}).filter(k => email.keywords[k]),
|
||||
auth: email.authenticationResults,
|
||||
};
|
||||
|
||||
@@ -57,6 +57,10 @@ const PERMISSION_LABELS: Record<string, { title: string; body: string }> = {
|
||||
'email:read': { title: 'Read your email', body: 'Access subjects, senders, recipients, body previews, and message bodies of your messages.' },
|
||||
'email:write': { title: 'Modify your email', body: 'Move, delete, flag, archive, or change keywords on your messages.' },
|
||||
'email:send': { title: 'Send mail and transform drafts', body: 'Compose and send messages, and modify content right before delivery.' },
|
||||
'crypto:full': { title: 'Full cryptographic access (high risk)', body: 'Runs with full cryptographic access in a privileged, same-origin context. It can read your message bodies and private keys, store key material, and sign/encrypt on your behalf. Only enable plugins you fully trust — this is comparable to a full-access browser extension.' },
|
||||
'email:raw-send': { title: 'Send raw messages', body: 'Submit fully-formed (e.g. signed or encrypted) messages on your behalf.' },
|
||||
'email:blob-read': { title: 'Read raw message content', body: 'Fetch the raw bytes of your messages and attachments (needed to decrypt and verify them).' },
|
||||
'email:render-takeover': { title: 'Replace rendered email content', body: 'Replace the displayed content of an opened message (e.g. to show decrypted text and a signature-verification badge).' },
|
||||
'calendar:read': { title: 'Read your calendar', body: 'Access events, calendars, RSVPs, and reminders.' },
|
||||
'calendar:write': { title: 'Modify your calendar', body: 'Create, edit, or delete events.' },
|
||||
'contacts:read': { title: 'Read your contacts', body: 'Access your address book entries.' },
|
||||
|
||||
@@ -6,9 +6,21 @@ import type { InstalledPlugin, Permission } from '../plugin-types';
|
||||
import { IMPLICIT_PERMISSIONS } from '../plugin-types';
|
||||
import { toast as appToast } from '@/stores/toast-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { apiFetch } from '../browser-navigation';
|
||||
import { awaitDialog } from './host-dialog';
|
||||
|
||||
/**
|
||||
* Methods only callable from the privileged (same-origin) tier. These expose
|
||||
* raw message bytes and raw submission, which an untrusted null-origin plugin
|
||||
* must never reach. Enforced in `dispatchApiCall` IN ADDITION to the per-method
|
||||
* permission gate.
|
||||
*/
|
||||
const PRIVILEGED_ONLY_METHODS = new Set<string>([
|
||||
'jmap.fetchBlob',
|
||||
'jmap.sendRaw',
|
||||
]);
|
||||
|
||||
const PERM_PER_METHOD: Record<string, Permission | null> = {
|
||||
// storage is unscoped by the manifest - implicit.
|
||||
'storage.get': null,
|
||||
@@ -23,6 +35,9 @@ const PERM_PER_METHOD: Record<string, Permission | null> = {
|
||||
// http
|
||||
'http.post': 'http:post',
|
||||
'http.fetch': 'http:fetch',
|
||||
// jmap (privileged-tier only; see PRIVILEGED_ONLY_METHODS)
|
||||
'jmap.fetchBlob': 'email:blob-read',
|
||||
'jmap.sendRaw': 'email:raw-send',
|
||||
// admin
|
||||
'admin.getConfig': 'admin:config',
|
||||
'admin.getAllConfig': 'admin:config',
|
||||
@@ -201,6 +216,53 @@ async function doHttpFetch(plugin: InstalledPlugin, rawUrl: string, init?: Plugi
|
||||
};
|
||||
}
|
||||
|
||||
// ─── jmap (privileged tier) ───────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch the raw bytes of a blob by id, using the host's authenticated JMAP
|
||||
* client. The plugin decides WHICH blobId to fetch (e.g. a pkcs7-mime part, or
|
||||
* the full RFC822 message blob) and runs its own detection; the host only
|
||||
* exposes the byte-fetch primitive. Returns a Uint8Array (structured-cloneable
|
||||
* across the postMessage boundary).
|
||||
*/
|
||||
async function doJmapFetchBlob(blobId: string, opts?: { name?: string; type?: string }): Promise<Uint8Array> {
|
||||
if (typeof blobId !== 'string' || !blobId) throw new Error('jmap.fetchBlob: blobId required');
|
||||
const { client } = useAuthStore.getState();
|
||||
if (!client) throw new Error('jmap.fetchBlob: no active session');
|
||||
const buf = await client.fetchBlobArrayBuffer(blobId, opts?.name, opts?.type);
|
||||
return new Uint8Array(buf);
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a fully-formed raw RFC822 message (e.g. one a plugin has signed and/or
|
||||
* encrypted) via the host's raw-send path, which also files it into Sent. The
|
||||
* plugin passes raw bytes; the host wraps them in a Blob.
|
||||
*/
|
||||
async function doJmapSendRaw(
|
||||
rawBytes: ArrayBuffer | ArrayBufferView,
|
||||
identityId: string,
|
||||
opts?: { delayedUntil?: string; envelopeRecipients?: string[] },
|
||||
): Promise<unknown> {
|
||||
if (typeof identityId !== 'string' || !identityId) throw new Error('jmap.sendRaw: identityId required');
|
||||
const { client } = useAuthStore.getState();
|
||||
if (!client) throw new Error('jmap.sendRaw: no active session');
|
||||
const view = rawBytes instanceof ArrayBuffer
|
||||
? new Uint8Array(rawBytes)
|
||||
: new Uint8Array(rawBytes.buffer, rawBytes.byteOffset, rawBytes.byteLength);
|
||||
// Copy into a fresh ArrayBuffer-backed array so the Blob part is definitely
|
||||
// ArrayBuffer (not SharedArrayBuffer) — also detaches from the caller's view.
|
||||
const copy = new Uint8Array(view.byteLength);
|
||||
copy.set(view);
|
||||
const blob = new Blob([copy.buffer], { type: 'message/rfc822' });
|
||||
return useEmailStore.getState().sendRawEmail(
|
||||
client,
|
||||
blob,
|
||||
identityId,
|
||||
opts?.delayedUntil,
|
||||
opts?.envelopeRecipients,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── admin config (same as before) ────────────────────────────
|
||||
|
||||
async function adminGetAll(pluginId: string): Promise<Record<string, unknown>> {
|
||||
@@ -234,7 +296,15 @@ export async function dispatchApiCall(
|
||||
plugin: InstalledPlugin,
|
||||
method: string,
|
||||
args: unknown[],
|
||||
opts?: { privileged?: boolean },
|
||||
): Promise<unknown> {
|
||||
// Tier gate: privileged-only methods are refused for untrusted (null-origin)
|
||||
// instances even if the permission is somehow present. Defence-in-depth on
|
||||
// top of the load-time tier resolution.
|
||||
if (PRIVILEGED_ONLY_METHODS.has(method) && !opts?.privileged) {
|
||||
throw new Error(`Method "${method}" requires the privileged plugin tier`);
|
||||
}
|
||||
|
||||
// Permission gate
|
||||
const requiredPerm = PERM_PER_METHOD[method];
|
||||
if (requiredPerm !== undefined && requiredPerm !== null) {
|
||||
@@ -259,6 +329,13 @@ export async function dispatchApiCall(
|
||||
case 'http.post': return doHttpPost(plugin, args[0] as string, args[1]);
|
||||
case 'http.fetch': return doHttpFetch(plugin, args[0] as string, args[1] as PluginFetchInit | undefined);
|
||||
|
||||
case 'jmap.fetchBlob': return doJmapFetchBlob(args[0] as string, args[1] as { name?: string; type?: string } | undefined);
|
||||
case 'jmap.sendRaw': return doJmapSendRaw(
|
||||
args[0] as ArrayBuffer | ArrayBufferView,
|
||||
args[1] as string,
|
||||
args[2] as { delayedUntil?: string; envelopeRecipients?: string[] } | undefined,
|
||||
);
|
||||
|
||||
case 'admin.getConfig': return adminGet(plugin.id, args[0] as string);
|
||||
case 'admin.getAllConfig': return adminGetAll(plugin.id);
|
||||
case 'admin.setConfig': await adminSet(plugin.id, args[0] as string, args[1]); return undefined;
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
// `event.source === iframe.contentWindow`. The iframe's runtime pins the
|
||||
// parent on the first inbound message.
|
||||
|
||||
import type { InstalledPlugin, SlotName } from '../plugin-types';
|
||||
import type { InstalledPlugin, SlotName, PluginTier } from '../plugin-types';
|
||||
import { dispatchApiCall } from './host-api';
|
||||
import { SANDBOX_PATH } from './protocol';
|
||||
import { SANDBOX_PATH, SANDBOX_PRIVILEGED_PATH } from './protocol';
|
||||
import { withBasePath } from '../browser-navigation';
|
||||
import { snapshotHostTheme, type ThemeSnapshot } from './host-theme';
|
||||
import type {
|
||||
@@ -55,6 +55,8 @@ export interface BackgroundOptions {
|
||||
plugin: InstalledPlugin;
|
||||
code: string;
|
||||
locale: string;
|
||||
/** Resolved execution tier (from `resolvePluginTier`). */
|
||||
tier: PluginTier;
|
||||
/** Where the hidden iframe should attach. Defaults to document.body. */
|
||||
hostContainer?: HTMLElement;
|
||||
}
|
||||
@@ -64,6 +66,8 @@ export interface SlotOptions {
|
||||
slot: SlotName;
|
||||
code: string;
|
||||
locale: string;
|
||||
/** Resolved execution tier (from `resolvePluginTier`). */
|
||||
tier: PluginTier;
|
||||
extraProps: Record<string, unknown>;
|
||||
/** Container element the visible slot iframe is mounted into. */
|
||||
hostContainer: HTMLElement;
|
||||
@@ -83,6 +87,8 @@ export class SandboxInstance {
|
||||
readonly iframe: HTMLIFrameElement;
|
||||
readonly pluginId: string;
|
||||
readonly mode: 'background' | 'slot';
|
||||
/** True for the same-origin privileged tier; gates the origin assertion. */
|
||||
readonly privileged: boolean;
|
||||
|
||||
readyPromise: Promise<void>;
|
||||
initPromise: Promise<InitDoneInfo>;
|
||||
@@ -106,6 +112,7 @@ export class SandboxInstance {
|
||||
) {
|
||||
this.pluginId = plugin.id;
|
||||
this.mode = initPayload.mode;
|
||||
this.privileged = initPayload.tier === 'privileged';
|
||||
|
||||
// Slot iframes get `extraProps`; encode any function values now so the
|
||||
// structured-clone send doesn't drop them.
|
||||
@@ -120,12 +127,15 @@ export class SandboxInstance {
|
||||
});
|
||||
|
||||
this.iframe = document.createElement('iframe');
|
||||
// Dev-only: Next's HMR/dev runtime refuses requests from the opaque
|
||||
// ("null") origin a strict sandbox produces, so the iframe never
|
||||
// hydrates and `sandbox-ready` is never posted. Add allow-same-origin
|
||||
// in dev so the iframe shares the host's origin and HMR works.
|
||||
// Production keeps the strict opaque-origin sandbox.
|
||||
const sandboxFlags = process.env.NODE_ENV === 'development'
|
||||
// Privileged tier: same-origin in BOTH dev and prod so the iframe gets real
|
||||
// `crypto.subtle` + IndexedDB and can run its own bundled crypto libs. The
|
||||
// postMessage RPC membrane still applies; the trust gate is enforced
|
||||
// host-side (signature + admin approval) BEFORE this instance is created.
|
||||
// Untrusted tier: null-origin in prod; dev adds allow-same-origin only
|
||||
// because Next's HMR/dev runtime refuses requests from the opaque ("null")
|
||||
// origin a strict sandbox produces (the iframe would never hydrate and
|
||||
// `sandbox-ready` would never post).
|
||||
const sandboxFlags = this.privileged || process.env.NODE_ENV === 'development'
|
||||
? 'allow-scripts allow-same-origin'
|
||||
: 'allow-scripts';
|
||||
this.iframe.setAttribute('sandbox', sandboxFlags);
|
||||
@@ -148,7 +158,9 @@ export class SandboxInstance {
|
||||
// Prefix with the mount path so the sandbox route resolves under a
|
||||
// subpath deployment (NEXT_PUBLIC_BASE_PATH=/webmail). A bare
|
||||
// "/plugin-sandbox" would hit the origin root and 404, breaking plugins.
|
||||
this.iframe.src = withBasePath(SANDBOX_PATH);
|
||||
// Privileged plugins load the same-origin route so the CSP/allow-same-origin
|
||||
// pairing is consistent.
|
||||
this.iframe.src = withBasePath(this.privileged ? SANDBOX_PRIVILEGED_PATH : SANDBOX_PATH);
|
||||
|
||||
this.listener = (ev) => this.onMessage(ev);
|
||||
window.addEventListener('message', this.listener);
|
||||
@@ -174,6 +186,11 @@ export class SandboxInstance {
|
||||
private onMessage(ev: MessageEvent): void {
|
||||
if (this.destroyed) return;
|
||||
if (ev.source !== this.iframe.contentWindow) return;
|
||||
// Privileged iframes are same-origin, so we can additionally pin the origin
|
||||
// (defence-in-depth on top of the contentWindow check). Untrusted iframes
|
||||
// are null-origin (event.origin === "null") in prod and can't be pinned
|
||||
// this way, so the contentWindow check above is the sole gate for them.
|
||||
if (this.privileged && ev.origin !== window.location.origin) return;
|
||||
const msg = ev.data as SandboxToHost;
|
||||
if (!msg || typeof (msg as { type?: unknown }).type !== 'string') return;
|
||||
|
||||
@@ -194,7 +211,7 @@ export class SandboxInstance {
|
||||
const { id, method, args } = msg;
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await dispatchApiCall(this.plugin, method, args ?? []);
|
||||
const result = await dispatchApiCall(this.plugin, method, args ?? [], { privileged: this.privileged });
|
||||
this.send({ type: 'api-response', id, ok: true, result });
|
||||
} catch (err) {
|
||||
this.send({ type: 'api-response', id, ok: false, error: (err as Error).message ?? String(err) });
|
||||
@@ -318,6 +335,7 @@ export function createBackgroundInstance(opts: BackgroundOptions): SandboxInstan
|
||||
const payload: InitPayload = {
|
||||
mode: 'background',
|
||||
pluginId: opts.plugin.id,
|
||||
tier: opts.tier,
|
||||
manifest: {
|
||||
id: opts.plugin.id,
|
||||
version: opts.plugin.version,
|
||||
@@ -341,6 +359,7 @@ export function createSlotInstance(opts: SlotOptions): SandboxInstance {
|
||||
const payload: InitPayload = {
|
||||
mode: 'slot',
|
||||
pluginId: opts.plugin.id,
|
||||
tier: opts.tier,
|
||||
slot: opts.slot,
|
||||
code: opts.code,
|
||||
manifest: {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '../plugin-hooks';
|
||||
import { verifyBundle } from './bundle-integrity';
|
||||
import { createBackgroundInstance } from './host-bridge';
|
||||
import { resolvePluginTier } from './tier';
|
||||
import { register as registerActive, deregister as deregisterActive, all as allActiveEntries } from './registry';
|
||||
import { cancelPluginDialogs } from './host-api';
|
||||
import { registerShortcuts } from './shortcuts';
|
||||
@@ -93,11 +94,22 @@ export async function loadSandboxedPlugin(plugin: InstalledPlugin): Promise<void
|
||||
|
||||
let background: ReturnType<typeof createBackgroundInstance> | null = null;
|
||||
try {
|
||||
// Decide the execution tier BEFORE creating any iframe. A refused privileged
|
||||
// request is a hard error (never silently downgraded to null-origin).
|
||||
const resolution = resolvePluginTier(plugin);
|
||||
if (resolution.tier === null) {
|
||||
storeAccessor?.setPluginStatus(plugin.id, 'error', resolution.error);
|
||||
console.error(`[plugin-sandbox] "${plugin.id}" tier refused: ${resolution.error}`);
|
||||
return;
|
||||
}
|
||||
const tier = resolution.tier;
|
||||
|
||||
const code = await getBundleCode(plugin);
|
||||
background = createBackgroundInstance({
|
||||
plugin,
|
||||
code,
|
||||
locale: currentLocale,
|
||||
tier,
|
||||
});
|
||||
|
||||
// Wait for the background runtime to evaluate the bundle, register hooks,
|
||||
@@ -138,6 +150,7 @@ export async function loadSandboxedPlugin(plugin: InstalledPlugin): Promise<void
|
||||
registerActive({
|
||||
plugin,
|
||||
code,
|
||||
tier,
|
||||
background: bg,
|
||||
slotOffers: info.slots,
|
||||
hookDisposables,
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
// the boundary must be structured-cloneable: no functions, no DOM nodes, no
|
||||
// class instances.
|
||||
|
||||
import type { SlotName } from '../plugin-types';
|
||||
import type { SlotName, PluginTier } from '../plugin-types';
|
||||
import type { ThemeSnapshot } from './host-theme';
|
||||
|
||||
// ─── Sandbox mode ────────────────────────────────────────────
|
||||
@@ -19,6 +19,12 @@ export type SandboxMode = 'background' | 'slot';
|
||||
export interface BackgroundInit {
|
||||
mode: 'background';
|
||||
pluginId: string;
|
||||
/**
|
||||
* Execution tier. 'privileged' iframes are same-origin (real WebCrypto +
|
||||
* IndexedDB); 'untrusted' iframes are null-origin. Decided host-side by
|
||||
* `resolvePluginTier`; the sandbox itself does not act on this field.
|
||||
*/
|
||||
tier: PluginTier;
|
||||
/** Trimmed manifest visible to the plugin. No host secrets. */
|
||||
manifest: {
|
||||
id: string;
|
||||
@@ -38,6 +44,8 @@ export interface BackgroundInit {
|
||||
export interface SlotInit {
|
||||
mode: 'slot';
|
||||
pluginId: string;
|
||||
/** Execution tier (mirrors `BackgroundInit.tier`). */
|
||||
tier: PluginTier;
|
||||
/** Slot name the iframe should render a component for. */
|
||||
slot: SlotName;
|
||||
/** Same bundle code as the background instance. */
|
||||
@@ -217,13 +225,23 @@ export function isSandboxMessage(value: unknown): value is SandboxToHost {
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────
|
||||
|
||||
/** Path used for the sandbox iframe `src`. Matched in `proxy.ts` for CSP. */
|
||||
/** Path used for the untrusted (null-origin) sandbox iframe `src`. Matched in
|
||||
* `proxy.ts` for CSP. */
|
||||
export const SANDBOX_PATH = '/plugin-sandbox';
|
||||
|
||||
/**
|
||||
* Path used for the privileged (same-origin) sandbox iframe `src`. A distinct
|
||||
* route so the iframe gets `allow-same-origin` (real WebCrypto + IndexedDB)
|
||||
* while keeping the same CSP relaxations as the untrusted sandbox. Matched in
|
||||
* `proxy.ts`. Renders the identical `SandboxRuntime`.
|
||||
*/
|
||||
export const SANDBOX_PRIVILEGED_PATH = '/plugin-sandbox-privileged';
|
||||
|
||||
/** Methods callable by a plugin via api-request. Host enforces permissions. */
|
||||
export const API_METHODS = [
|
||||
'storage.get', 'storage.set', 'storage.remove', 'storage.keys',
|
||||
'http.post', 'http.fetch',
|
||||
'jmap.fetchBlob', 'jmap.sendRaw',
|
||||
'admin.getConfig', 'admin.getAllConfig', 'admin.setConfig', 'admin.deleteConfig',
|
||||
'toast.success', 'toast.error', 'toast.info', 'toast.warning',
|
||||
'ui.confirm', 'ui.alert', 'ui.openExternalUrl',
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// `useSyncExternalStore` sees a stable reference between unrelated renders.
|
||||
// The cache is invalidated whenever the set of active plugins changes.
|
||||
|
||||
import type { Disposable, InstalledPlugin, SlotName } from '../plugin-types';
|
||||
import type { Disposable, InstalledPlugin, SlotName, PluginTier } from '../plugin-types';
|
||||
import type { SandboxInstance } from './host-bridge';
|
||||
|
||||
export interface SlotOffer {
|
||||
@@ -19,6 +19,9 @@ export interface ActivePlugin {
|
||||
plugin: InstalledPlugin;
|
||||
/** Verified bundle source. Reused when spinning up slot iframes. */
|
||||
code: string;
|
||||
/** Resolved execution tier. Slot iframes must use the SAME tier as the
|
||||
* background instance, so `PluginIframeSlot` reads it from here. */
|
||||
tier: PluginTier;
|
||||
background: SandboxInstance;
|
||||
slotOffers: SlotOffer[];
|
||||
hookDisposables: Disposable[];
|
||||
|
||||
@@ -175,6 +175,20 @@ function buildPluginApi(manifest: PluginManifest) {
|
||||
post: (path: string, body: Record<string, unknown>) => callApi('http.post', [path, body]),
|
||||
fetch: (url: string, init?: unknown) => callApi('http.fetch', [url, init]),
|
||||
},
|
||||
// Privileged-tier only (same-origin plugins). Calls throw for untrusted
|
||||
// plugins (the host refuses the method) — these power crypto plugins that
|
||||
// need raw message bytes and raw submission.
|
||||
jmap: {
|
||||
/** Fetch a blob's raw bytes by id. Resolves to a Uint8Array. */
|
||||
fetchBlob: (blobId: string, opts?: { name?: string; type?: string }) =>
|
||||
callApi('jmap.fetchBlob', [blobId, opts]) as Promise<Uint8Array>,
|
||||
/** Submit a fully-formed raw RFC822 message (already signed/encrypted). */
|
||||
sendRaw: (
|
||||
rawBytes: ArrayBuffer | ArrayBufferView,
|
||||
identityId: string,
|
||||
opts?: { delayedUntil?: string; envelopeRecipients?: string[] },
|
||||
) => callApi('jmap.sendRaw', [rawBytes, identityId, opts]),
|
||||
},
|
||||
toast: {
|
||||
success: (m: string) => { void callApi('toast.success', [m]); },
|
||||
error: (m: string) => { void callApi('toast.error', [m]); },
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// Single source of truth for which execution tier a plugin runs in.
|
||||
//
|
||||
// The decision is security-critical: granting 'privileged' creates a
|
||||
// same-origin iframe (full WebCrypto + IndexedDB + access to the host origin),
|
||||
// so it must NEVER be granted to an unsigned or unapproved bundle. This helper
|
||||
// is called by BOTH the loader (load gate, before the same-origin iframe is
|
||||
// created) and the plugin store (enable gate), so the rules live in one place.
|
||||
//
|
||||
// A plugin that *requests* privileged but fails any gate is REFUSED (returns
|
||||
// `{ tier: null, error }`), never silently downgraded — a crypto plugin cannot
|
||||
// run in a null-origin sandbox, and a silent downgrade would mask tampering.
|
||||
|
||||
import type { InstalledPlugin, PluginTier } from '../plugin-types';
|
||||
|
||||
export type TierResolution =
|
||||
| { tier: PluginTier; error?: undefined }
|
||||
| { tier: null; error: string };
|
||||
|
||||
/**
|
||||
* Resolves the execution tier for a plugin. Returns `{ tier }` on success or
|
||||
* `{ tier: null, error }` when a requested tier cannot be granted (the caller
|
||||
* should put the plugin into an error state and NOT create an iframe).
|
||||
*
|
||||
* Privileged tier gates (ALL required):
|
||||
* 1. Manifest declares the umbrella high-risk permission `crypto:full`.
|
||||
* 2. Signed bundle: only bundles delivered through the admin/server channel
|
||||
* are Ed25519-signed (verified at download time — see `verifySignature`
|
||||
* usage in the plugin store). Self-uploaded bundles are unsigned and can
|
||||
* therefore never reach the privileged tier. `managed` is the signal that
|
||||
* the bundle came through that signed channel.
|
||||
* 3. Admin approval pins operator trust in this specific bundle.
|
||||
* 4. Explicit high-risk consent for `crypto:full` (granted via the consent
|
||||
* dialog at enable time; admin-managed plugins are pre-approved).
|
||||
*/
|
||||
export function resolvePluginTier(plugin: InstalledPlugin): TierResolution {
|
||||
if (plugin.tier !== 'privileged') {
|
||||
return { tier: 'untrusted' };
|
||||
}
|
||||
|
||||
// 1. Must declare the umbrella high-risk permission.
|
||||
if (!plugin.permissions.includes('crypto:full')) {
|
||||
return { tier: null, error: 'Privileged tier requires the "crypto:full" permission' };
|
||||
}
|
||||
|
||||
// 2 + 3. Trust root: signed (managed) bundle AND admin approval. A bundle
|
||||
// uploaded by the user directly carries no signature, so it cannot be
|
||||
// privileged regardless of what its manifest claims.
|
||||
if (!plugin.managed) {
|
||||
return { tier: null, error: 'Privileged tier requires a signed bundle delivered through the admin channel' };
|
||||
}
|
||||
if (!(plugin.adminApproved || plugin.managed)) {
|
||||
return { tier: null, error: 'Privileged tier requires administrator approval' };
|
||||
}
|
||||
|
||||
// 4. Explicit high-risk consent. Admin-managed plugins are pre-approved by
|
||||
// the operator and skip the per-user prompt (mirrors the existing consent
|
||||
// gate in the plugin store); otherwise the user must have granted crypto:full.
|
||||
const consented = plugin.managed || (plugin.grantedPermissions ?? []).includes('crypto:full');
|
||||
if (!consented) {
|
||||
return { tier: null, error: 'Privileged tier requires explicit consent for "crypto:full"' };
|
||||
}
|
||||
|
||||
return { tier: 'privileged' };
|
||||
}
|
||||
@@ -7,6 +7,18 @@ export type MaybePromise<T> = T | Promise<T>;
|
||||
|
||||
export type PluginType = 'ui-extension' | 'sidebar-app' | 'hook' | 'theme';
|
||||
export type PluginStatus = 'installed' | 'enabled' | 'running' | 'disabled' | 'error';
|
||||
/**
|
||||
* Execution tier a plugin runs in.
|
||||
* - 'untrusted' (default): null-origin sandbox iframe. No `crypto.subtle`,
|
||||
* IndexedDB, or localStorage in-frame; all capabilities go through the host
|
||||
* RPC. This is the only tier most plugins ever need.
|
||||
* - 'privileged': same-origin sandbox iframe (full WebCrypto + IndexedDB) so a
|
||||
* plugin can bundle its own crypto libs (e.g. pkijs for S/MIME, openpgp for
|
||||
* PGP). Because same-origin == full host access, entering this tier is gated
|
||||
* by a signed bundle + admin approval + high-risk consent — see
|
||||
* `lib/plugin-sandbox/tier.ts` `resolvePluginTier`.
|
||||
*/
|
||||
export type PluginTier = 'untrusted' | 'privileged';
|
||||
export type ThemeVariant = 'light' | 'dark';
|
||||
|
||||
// ─── Manifests ───────────────────────────────────────────────
|
||||
@@ -97,6 +109,13 @@ export interface PluginManifest {
|
||||
author: string;
|
||||
description: string;
|
||||
type: Exclude<PluginType, 'theme'>;
|
||||
/**
|
||||
* Execution tier the plugin requests. Defaults to 'untrusted' when omitted.
|
||||
* Declaring 'privileged' opts into the same-origin tier and requires the
|
||||
* `crypto:full` permission, a signed bundle, and admin approval (enforced by
|
||||
* `resolvePluginTier`). Most plugins should omit this.
|
||||
*/
|
||||
tier?: PluginTier;
|
||||
permissions: string[];
|
||||
entrypoint: string;
|
||||
minAppVersion?: string;
|
||||
@@ -208,6 +227,9 @@ export interface InstalledPlugin {
|
||||
author: string;
|
||||
description: string;
|
||||
type: Exclude<PluginType, 'theme'>;
|
||||
/** Execution tier carried over from the manifest at install time. Defaults
|
||||
* to 'untrusted'. See `PluginTier` and `resolvePluginTier`. */
|
||||
tier?: PluginTier;
|
||||
permissions: string[];
|
||||
entrypoint: string;
|
||||
enabled: boolean;
|
||||
@@ -260,6 +282,7 @@ export type SlotName =
|
||||
| 'composer-sidebar-right'
|
||||
| 'sidebar-widget'
|
||||
| 'email-detail-sidebar'
|
||||
| 'email-details-section'
|
||||
| 'settings-section'
|
||||
| 'context-menu-email'
|
||||
| 'navigation-rail-bottom'
|
||||
@@ -378,6 +401,19 @@ export interface EmailReadView {
|
||||
* text). Empty string when the host hasn't loaded the body. Same
|
||||
* `email:read` sensitivity as the rest of this view. */
|
||||
text: string;
|
||||
/**
|
||||
* Raw parsed header map (header name → value, or values when a header
|
||||
* appears more than once), exactly as JMAP returned it. Absent until the
|
||||
* host has loaded the message's headers. Same `email:read` sensitivity as
|
||||
* the rest of this view.
|
||||
*/
|
||||
headers?: Record<string, string | string[]>;
|
||||
/**
|
||||
* Full, human-readable message source — headers, metadata and body — the
|
||||
* same text the "View source" dialog shows. Empty string when the body
|
||||
* hasn't been fetched. Gated by `email:read` like the rest of this view.
|
||||
*/
|
||||
source: string;
|
||||
/**
|
||||
* Parsed Authentication-Results header (SPF, DKIM, DMARC, reverse-DNS).
|
||||
* Absent on stores that didn't parse the header (e.g. bodies not yet
|
||||
@@ -838,6 +874,19 @@ export interface PluginI18n {
|
||||
|
||||
export const ALL_PERMISSIONS = [
|
||||
'email:read', 'email:write', 'email:send',
|
||||
// ─── Privileged-tier capabilities (require tier: 'privileged') ───
|
||||
// Umbrella high-risk permission gating same-origin crypto execution. A
|
||||
// plugin holding this runs with full cryptographic access and can read
|
||||
// message bodies and private keys; only granted to a signed, admin-approved
|
||||
// privileged bundle after explicit high-risk consent.
|
||||
'crypto:full',
|
||||
// Submit a fully-formed raw RFC822 message via JMAP (used after a plugin
|
||||
// signs/encrypts an outgoing message itself).
|
||||
'email:raw-send',
|
||||
// Fetch a message blob's raw bytes by blobId (for decrypt/verify).
|
||||
'email:blob-read',
|
||||
// Replace the rendered body of an opened email (render-takeover).
|
||||
'email:render-takeover',
|
||||
'calendar:read', 'calendar:write',
|
||||
'contacts:read', 'contacts:write',
|
||||
'files:read', 'files:write',
|
||||
@@ -852,6 +901,7 @@ export const ALL_PERMISSIONS = [
|
||||
'auth:observe',
|
||||
'http:post', 'http:fetch',
|
||||
'ui:observe', 'ui:toolbar', 'ui:app-top-banner', 'ui:email-banner', 'ui:email-footer',
|
||||
'ui:email-details',
|
||||
'ui:composer-toolbar', 'ui:composer-sidebar',
|
||||
'ui:sidebar-widget', 'ui:settings-section',
|
||||
'ui:context-menu', 'ui:navigation-rail', 'ui:keyboard',
|
||||
|
||||
+13
-8
@@ -8,6 +8,7 @@
|
||||
|
||||
import { formatDateTime } from "@/lib/utils";
|
||||
import { emailHooks } from "@/lib/plugin-hooks";
|
||||
import { escapeHtml } from "@/lib/email-sanitization";
|
||||
import type { QuoteHeader, QuoteHeaderContext } from "@/lib/plugin-types";
|
||||
|
||||
// Localized label set the caller passes in. Labels live on the client where
|
||||
@@ -62,10 +63,8 @@ function defaultHeader(args: BuildArgs): QuoteHeader {
|
||||
})
|
||||
: "";
|
||||
const from = email.from?.[0];
|
||||
const fromStr = from ? `${from.name || from.email}` : unknownLabel;
|
||||
// Forward header "From:" shows the full sender incl. address ("Name
|
||||
// <email>"), like every mail client. The reply line keeps the bare name
|
||||
// (reads more naturally in "On … wrote:").
|
||||
// Both the forward "From:" line and the reply "On … wrote:" line show the
|
||||
// full sender incl. address ("Name <email>"), like Gmail/Outlook (#482).
|
||||
const fromStrFull = from
|
||||
? (from.name && from.email && from.name !== from.email
|
||||
? `${from.name} <${from.email}>`
|
||||
@@ -75,13 +74,19 @@ function defaultHeader(args: BuildArgs): QuoteHeader {
|
||||
|
||||
if (mode === "forward") {
|
||||
const text = `${labels.forwardedSeparator}\n${labels.fromLabel}: ${fromStrFull}\n${labels.dateLabel}: ${date}\n${labels.subjectLabel}: ${subject}\n`;
|
||||
const html = `<div>${labels.forwardedSeparator}<br>${labels.fromLabel}: ${fromStrFull}<br>${labels.dateLabel}: ${date}<br>${labels.subjectLabel}: ${subject}<br><br></div>`;
|
||||
// Escape the interpolated values for the HTML variant: the sender string is
|
||||
// "Name <email>", and the unescaped "<email>" would be parsed as an HTML tag
|
||||
// by the rich-text composer and silently dropped (#482). Subject/name are
|
||||
// likewise user-controlled. Label/separator strings are trusted i18n text.
|
||||
const html = `<div>${labels.forwardedSeparator}<br>${labels.fromLabel}: ${escapeHtml(fromStrFull)}<br>${labels.dateLabel}: ${escapeHtml(date)}<br>${labels.subjectLabel}: ${escapeHtml(subject)}<br><br></div>`;
|
||||
return { html, text, wrapInBlockquote: false };
|
||||
}
|
||||
|
||||
const replyLine = labels.formatReplyLine({ date, from: fromStr });
|
||||
const text = `${replyLine}\n`;
|
||||
const html = `<div>${replyLine}<br></div>`;
|
||||
const text = `${labels.formatReplyLine({ date, from: fromStrFull })}\n`;
|
||||
// Escape the interpolated sender/date for the HTML reply line: the sender is
|
||||
// now "Name <email>", and the unescaped "<email>" would be parsed as an HTML
|
||||
// tag by the rich-text composer and dropped (#482). Label template is trusted.
|
||||
const html = `<div>${labels.formatReplyLine({ date: escapeHtml(date), from: escapeHtml(fromStrFull) })}<br></div>`;
|
||||
return { html, text, wrapInBlockquote: true };
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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('');
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+32
-25
@@ -119,16 +119,19 @@ export async function fetchUnifiedEmails(
|
||||
|
||||
const { account, result } = outcome.value;
|
||||
|
||||
// Decorate each email with the source account info.
|
||||
for (const email of result.emails) {
|
||||
email.accountId = account.accountId;
|
||||
email.accountLabel = account.accountLabel;
|
||||
email.sourceClientAccountId = account.clientAccountId;
|
||||
email.sourceAccountId = account.jmapAccountId;
|
||||
email.sourceFolder = resolveSourceFolderName(email, account.mailboxes);
|
||||
}
|
||||
// Decorate each email with the source account info. The per-account client
|
||||
// returns shared object references; decorate shallow copies instead of
|
||||
// mutating them in place so retained callers/snapshots aren't corrupted.
|
||||
const decorated = result.emails.map((email) => ({
|
||||
...email,
|
||||
accountId: account.accountId,
|
||||
accountLabel: account.accountLabel,
|
||||
sourceClientAccountId: account.clientAccountId,
|
||||
sourceAccountId: account.jmapAccountId,
|
||||
sourceFolder: resolveSourceFolderName(email, account.mailboxes),
|
||||
}));
|
||||
|
||||
mergedEmails = mergedEmails.concat(result.emails);
|
||||
mergedEmails = mergedEmails.concat(decorated);
|
||||
totalSum += result.total;
|
||||
if (result.hasMore) {
|
||||
anyHasMore = true;
|
||||
@@ -247,14 +250,16 @@ async function fanOutUnifiedQuery(
|
||||
for (const outcome of results) {
|
||||
if (outcome.status !== 'fulfilled' || outcome.value === null) continue;
|
||||
const { account, result } = outcome.value;
|
||||
for (const email of result.emails) {
|
||||
email.accountId = account.accountId;
|
||||
email.accountLabel = account.accountLabel;
|
||||
email.sourceClientAccountId = account.clientAccountId;
|
||||
email.sourceAccountId = account.jmapAccountId;
|
||||
email.sourceFolder = resolveSourceFolderName(email, account.mailboxes);
|
||||
}
|
||||
mergedEmails = mergedEmails.concat(result.emails);
|
||||
// Decorate shallow copies, not the shared client-returned objects.
|
||||
const decorated = result.emails.map((email) => ({
|
||||
...email,
|
||||
accountId: account.accountId,
|
||||
accountLabel: account.accountLabel,
|
||||
sourceClientAccountId: account.clientAccountId,
|
||||
sourceAccountId: account.jmapAccountId,
|
||||
sourceFolder: resolveSourceFolderName(email, account.mailboxes),
|
||||
}));
|
||||
mergedEmails = mergedEmails.concat(decorated);
|
||||
totalSum += result.total;
|
||||
if (result.hasMore) anyHasMore = true;
|
||||
}
|
||||
@@ -383,14 +388,16 @@ async function fanOutCrossQuery(
|
||||
for (const outcome of results) {
|
||||
if (outcome.status !== 'fulfilled' || outcome.value === null) continue;
|
||||
const { account, result } = outcome.value;
|
||||
for (const email of result.emails) {
|
||||
email.accountId = account.accountId;
|
||||
email.accountLabel = account.accountLabel;
|
||||
email.sourceClientAccountId = account.clientAccountId;
|
||||
email.sourceAccountId = account.jmapAccountId;
|
||||
email.sourceFolder = resolveSourceFolderName(email, account.mailboxes);
|
||||
}
|
||||
mergedEmails = mergedEmails.concat(result.emails);
|
||||
// Decorate shallow copies, not the shared client-returned objects.
|
||||
const decorated = result.emails.map((email) => ({
|
||||
...email,
|
||||
accountId: account.accountId,
|
||||
accountLabel: account.accountLabel,
|
||||
sourceClientAccountId: account.clientAccountId,
|
||||
sourceAccountId: account.jmapAccountId,
|
||||
sourceFolder: resolveSourceFolderName(email, account.mailboxes),
|
||||
}));
|
||||
mergedEmails = mergedEmails.concat(decorated);
|
||||
totalSum += result.total;
|
||||
if (result.hasMore) anyHasMore = true;
|
||||
}
|
||||
|
||||
@@ -933,6 +933,10 @@
|
||||
"label": "Barevné ikony postranního panelu",
|
||||
"description": "Obarví ikony složek a štítků podle typu (modré Doručené, červený Spam, zelené Odeslané atd.). Vypněte pro jednobarevný postranní panel."
|
||||
},
|
||||
"show_folder_total_count": {
|
||||
"label": "Zobrazit celkový počet zpráv",
|
||||
"description": "Zobrazí celkový počet zpráv vedle složek a štítků spolu s počtem nepřečtených. Vypněte pro zobrazení pouze nepřečtených."
|
||||
},
|
||||
"pro_interface": {
|
||||
"label": "Pro rozhraní (experimentální)",
|
||||
"description": "Rozložení pro pokročilé uživatele pouze pro stolní počítače s prohlížením zpráv na více kartách a pracovními postupy napříč účty. Standardní rozhraní zůstává nedotčeno; kdykoli se můžete vrátit.",
|
||||
|
||||
@@ -936,6 +936,10 @@
|
||||
"label": "Farverige sidepane-ikoner",
|
||||
"description": "Farvelæg mappe- og tag-ikoner efter type (blå indbakke, rød spam, grøn sendt osv.). Deaktivér for et monokromt sidepanel."
|
||||
},
|
||||
"show_folder_total_count": {
|
||||
"label": "Vis samlet antal beskeder",
|
||||
"description": "Viser det samlede antal beskeder ud for mapper og tags sammen med antallet af ulæste. Slå fra for kun at vise ulæste."
|
||||
},
|
||||
"pro_interface": {
|
||||
"label": "Pro-grænseflade (eksperimentel)",
|
||||
"description": "Power user-layout kun til skrivebordet med beskedvisning på flere faner og arbejdsforløb på tværs af konti. Standardgrænsefladen påvirkes ikke; du kan skifte tilbage når som helst.",
|
||||
|
||||
@@ -933,6 +933,10 @@
|
||||
"label": "Farbige Seitenleistensymbole",
|
||||
"description": "Ordner- und Tag-Symbole nach Typ einfärben (blauer Posteingang, roter Spam, grüner Gesendet usw.). Für eine monochrome Seitenleiste deaktivieren."
|
||||
},
|
||||
"show_folder_total_count": {
|
||||
"label": "Gesamtzahl der Nachrichten anzeigen",
|
||||
"description": "Zeigt neben Ordnern und Tags die Gesamtzahl der Nachrichten zusätzlich zur Anzahl ungelesener Nachrichten an. Deaktivieren, um nur ungelesene Nachrichten anzuzeigen."
|
||||
},
|
||||
"pro_interface": {
|
||||
"label": "Pro-Oberfläche (experimentell)",
|
||||
"description": "Desktop-Power-User-Layout mit Multi-Tab-Nachrichtenansicht und kontoübergreifenden Workflows. Die Standardoberfläche bleibt unverändert; Sie können jederzeit zurückwechseln.",
|
||||
|
||||
@@ -936,6 +936,10 @@
|
||||
"label": "Colorful Sidebar Icons",
|
||||
"description": "Tint folder and tag icons by type (blue Inbox, red Junk, green Sent, etc.). Disable for a monochrome sidebar."
|
||||
},
|
||||
"show_folder_total_count": {
|
||||
"label": "Show Total Message Count",
|
||||
"description": "Show the total message count next to folders and tags, alongside the unread count. Disable to show only unread counts."
|
||||
},
|
||||
"pro_interface": {
|
||||
"label": "Pro Interface (Experimental)",
|
||||
"description": "Desktop-only power-user layout with multi-tab message browsing and cross-account workflows. The standard interface is unaffected; you can switch back at any time.",
|
||||
|
||||
@@ -933,6 +933,10 @@
|
||||
"label": "Iconos de barra lateral a color",
|
||||
"description": "Colorea los iconos de carpetas y etiquetas según su tipo (azul para Bandeja de entrada, rojo para Spam, verde para Enviados, etc.). Desactívalo para una barra lateral monocroma."
|
||||
},
|
||||
"show_folder_total_count": {
|
||||
"label": "Mostrar el número total de mensajes",
|
||||
"description": "Muestra el número total de mensajes junto a las carpetas y etiquetas, además del número de mensajes no leídos. Desactívalo para mostrar solo los no leídos."
|
||||
},
|
||||
"pro_interface": {
|
||||
"label": "Interfaz Pro (experimental)",
|
||||
"description": "Diseño de escritorio para usuarios avanzados con exploración de mensajes en varias pestañas y flujos de trabajo entre cuentas. La interfaz estándar no se ve afectada; puedes volver en cualquier momento.",
|
||||
|
||||
@@ -936,6 +936,10 @@
|
||||
"label": "آیکونهای رنگی نوار کناری",
|
||||
"description": "رنگآمیزی آیکونهای پوشه و برچسب بر اساس نوع"
|
||||
},
|
||||
"show_folder_total_count": {
|
||||
"label": "نمایش تعداد کل پیامها",
|
||||
"description": "تعداد کل پیامها را در کنار پوشهها و برچسبها، همراه با تعداد خواندهنشدهها نمایش میدهد. برای نمایش فقط تعداد خواندهنشدهها غیرفعال کنید."
|
||||
},
|
||||
"pro_interface": {
|
||||
"label": "رابط حرفهای (آزمایشی)",
|
||||
"description": "چیدمان قدرت-کاربری فقط دسکتاپ",
|
||||
|
||||
@@ -933,6 +933,10 @@
|
||||
"label": "Icônes colorées dans la barre latérale",
|
||||
"description": "Colore les icônes de dossiers et d'étiquettes par type (Boîte de réception en bleu, Indésirable en rouge, Envoyés en vert, etc.). Désactivez pour une barre latérale monochrome."
|
||||
},
|
||||
"show_folder_total_count": {
|
||||
"label": "Afficher le nombre total de messages",
|
||||
"description": "Affiche le nombre total de messages à côté des dossiers et des étiquettes, en plus du nombre de messages non lus. Désactivez pour n'afficher que les messages non lus."
|
||||
},
|
||||
"pro_interface": {
|
||||
"label": "Interface Pro (expérimental)",
|
||||
"description": "Disposition pour utilisateurs avancés (bureau uniquement) avec navigation des messages multi-onglets et flux de travail multi-comptes. L'interface standard n'est pas affectée ; vous pouvez revenir à tout moment.",
|
||||
|
||||
@@ -936,6 +936,10 @@
|
||||
"label": "Színes oldalsáv ikonok",
|
||||
"description": "Mappa és címke ikonok színezése típus szerint (kék Beérkező, piros Spam, zöld Elküldött, stb.). Kapcsold ki az egyszínű oldalsávhoz."
|
||||
},
|
||||
"show_folder_total_count": {
|
||||
"label": "Összes üzenet számának megjelenítése",
|
||||
"description": "Megjeleníti az üzenetek teljes számát a mappák és címkék mellett, az olvasatlanok számán túl. Kapcsolja ki, ha csak az olvasatlanok számát szeretné látni."
|
||||
},
|
||||
"pro_interface": {
|
||||
"label": "Pro felület (Kísérleti)",
|
||||
"description": "Asztali számítógépes erőfelhasználói elrendezés több lapos üzenetböngészéssel és fiókok közötti munkafolyamatokkal. A szabványos felületet nem érinti; bármikor visszaválthatsz.",
|
||||
|
||||
@@ -933,6 +933,10 @@
|
||||
"label": "Icone colorate nella barra laterale",
|
||||
"description": "Colora le icone di cartelle ed etichette per tipo (Posta in arrivo blu, Spam rosso, Inviati verde, ecc.). Disattiva per una barra laterale monocromatica."
|
||||
},
|
||||
"show_folder_total_count": {
|
||||
"label": "Mostra il numero totale di messaggi",
|
||||
"description": "Mostra il numero totale di messaggi accanto a cartelle ed etichette, insieme al conteggio dei non letti. Disattiva per mostrare solo i non letti."
|
||||
},
|
||||
"pro_interface": {
|
||||
"label": "Interfaccia Pro (sperimentale)",
|
||||
"description": "Layout per utenti esperti solo desktop con esplorazione messaggi a più schede e flussi tra account. L'interfaccia standard non è influenzata; puoi tornare indietro in qualsiasi momento.",
|
||||
|
||||
@@ -933,6 +933,10 @@
|
||||
"label": "カラフルなサイドバーアイコン",
|
||||
"description": "フォルダーとタグのアイコンを種類別に色分けします(受信トレイは青、迷惑メールは赤、送信済みは緑など)。モノクロのサイドバーにするには無効にしてください。"
|
||||
},
|
||||
"show_folder_total_count": {
|
||||
"label": "メッセージの総数を表示",
|
||||
"description": "フォルダーやタグの横に、未読数に加えてメッセージの総数を表示します。未読数のみを表示するには無効にします。"
|
||||
},
|
||||
"pro_interface": {
|
||||
"label": "Pro インターフェイス(実験的)",
|
||||
"description": "デスクトップ専用のパワーユーザー向けレイアウトで、マルチタブのメッセージ閲覧やアカウント横断のワークフローに対応します。標準インターフェイスには影響せず、いつでも元に戻せます。",
|
||||
|
||||
@@ -933,6 +933,10 @@
|
||||
"label": "컬러풀한 사이드바 아이콘",
|
||||
"description": "폴더와 태그 아이콘을 유형별로 색상 표시합니다(받은편지함 파란색, 스팸 빨간색, 보낸편지함 녹색 등). 모노크롬 사이드바를 원하면 비활성화하세요."
|
||||
},
|
||||
"show_folder_total_count": {
|
||||
"label": "전체 메시지 수 표시",
|
||||
"description": "읽지 않은 수와 함께 폴더 및 태그 옆에 전체 메시지 수를 표시합니다. 읽지 않은 수만 표시하려면 비활성화하세요."
|
||||
},
|
||||
"pro_interface": {
|
||||
"label": "Pro 인터페이스 (실험적)",
|
||||
"description": "데스크톱 전용 파워 유저 레이아웃으로, 다중 탭 메시지 탐색과 계정 간 워크플로우를 지원합니다. 표준 인터페이스에는 영향이 없으며 언제든지 되돌릴 수 있습니다.",
|
||||
|
||||
@@ -933,6 +933,10 @@
|
||||
"label": "Krāsainas sānjoslas ikonas",
|
||||
"description": "Iekrāsojiet mapju un birku ikonas pēc to veida (zila Iesūtne, sarkana Mēstules, zaļa Nosūtītie utt.). Atspējojiet, lai iegūtu vienkrāsainu sānjoslu."
|
||||
},
|
||||
"show_folder_total_count": {
|
||||
"label": "Rādīt kopējo ziņojumu skaitu",
|
||||
"description": "Rāda kopējo ziņojumu skaitu blakus mapēm un tagiem, kā arī nelasīto ziņojumu skaitu. Atspējojiet, lai rādītu tikai nelasītos."
|
||||
},
|
||||
"pro_interface": {
|
||||
"label": "Pro saskarne (eksperimentāla)",
|
||||
"description": "Tikai darbvirsmas pieredzējušu lietotāju izkārtojums ar ziņojumu pārlūkošanu vairākās cilnēs un kontu pārvaldību. Standarta saskarne netiek ietekmēta; varat jebkurā brīdī pārslēgties atpakaļ.",
|
||||
|
||||
@@ -933,6 +933,10 @@
|
||||
"label": "Gekleurde zijbalkpictogrammen",
|
||||
"description": "Kleur map- en tagpictogrammen op type (blauw Postvak IN, rood Spam, groen Verzonden, enz.). Schakel uit voor een monochrome zijbalk."
|
||||
},
|
||||
"show_folder_total_count": {
|
||||
"label": "Totaal aantal berichten tonen",
|
||||
"description": "Toont het totale aantal berichten naast mappen en labels, naast het aantal ongelezen berichten. Schakel uit om alleen ongelezen aantallen te tonen."
|
||||
},
|
||||
"pro_interface": {
|
||||
"label": "Pro-interface (experimenteel)",
|
||||
"description": "Power user-indeling alleen voor desktop met meertabs berichtweergave en accountoverschrijdende workflows. De standaardinterface blijft onveranderd; u kunt op elk moment terugkeren.",
|
||||
|
||||
@@ -933,6 +933,10 @@
|
||||
"label": "Kolorowe ikony paska bocznego",
|
||||
"description": "Koloruj ikony folderów i tagów według typu (niebieska Skrzynka odbiorcza, czerwona Spam, zielona Wysłane itp.). Wyłącz, aby uzyskać monochromatyczny pasek boczny."
|
||||
},
|
||||
"show_folder_total_count": {
|
||||
"label": "Pokaż łączną liczbę wiadomości",
|
||||
"description": "Pokazuje łączną liczbę wiadomości obok folderów i etykiet, obok liczby nieprzeczytanych. Wyłącz, aby pokazywać tylko nieprzeczytane."
|
||||
},
|
||||
"pro_interface": {
|
||||
"label": "Interfejs Pro (eksperymentalny)",
|
||||
"description": "Układ dla zaawansowanych użytkowników (tylko na komputerze) z przeglądaniem wiadomości w wielu kartach i obiegami pracy między kontami. Standardowy interfejs pozostaje nietknięty; możesz wrócić w dowolnej chwili.",
|
||||
|
||||
@@ -933,6 +933,10 @@
|
||||
"label": "Ícones coloridos na barra lateral",
|
||||
"description": "Colorir ícones de pastas e etiquetas por tipo (Caixa de entrada azul, Spam vermelho, Enviados verde, etc.). Desative para uma barra lateral monocromática."
|
||||
},
|
||||
"show_folder_total_count": {
|
||||
"label": "Mostrar contagem total de mensagens",
|
||||
"description": "Mostra a contagem total de mensagens ao lado de pastas e etiquetas, além da contagem de não lidas. Desative para mostrar apenas as não lidas."
|
||||
},
|
||||
"pro_interface": {
|
||||
"label": "Interface Pro (experimental)",
|
||||
"description": "Layout para utilizadores avançados apenas em desktop com navegação de mensagens em múltiplos separadores e fluxos entre contas. A interface padrão não é afetada; pode voltar a qualquer momento.",
|
||||
|
||||
@@ -936,6 +936,10 @@
|
||||
"label": "Pictograme colorate în bara laterală",
|
||||
"description": "Colorați pictogramele folderelor și etichetelor în funcție de tip (albastru pentru „Inbox”, roșu pentru „Junk”, verde pentru „Sent” etc.). Dezactivați această opțiune pentru o bară laterală monocromă."
|
||||
},
|
||||
"show_folder_total_count": {
|
||||
"label": "Afișează numărul total de mesaje",
|
||||
"description": "Afișează numărul total de mesaje lângă foldere și etichete, pe lângă numărul celor necitite. Dezactivează pentru a afișa doar mesajele necitite."
|
||||
},
|
||||
"pro_interface": {
|
||||
"label": "Interfață Pro (experimentală)",
|
||||
"description": "Aspect destinat utilizatorilor avansați, disponibil doar pe desktop, cu navigare prin mesaje în mai multe file și fluxuri de lucru între conturi. Interfața standard nu este afectată; puteți reveni la aceasta în orice moment.",
|
||||
|
||||
@@ -933,6 +933,10 @@
|
||||
"label": "Цветные значки боковой панели",
|
||||
"description": "Окрашивать значки папок и тегов по типу (синий «Входящие», красный «Спам», зелёный «Отправленные» и т. д.). Отключите для монохромной боковой панели."
|
||||
},
|
||||
"show_folder_total_count": {
|
||||
"label": "Показывать общее количество сообщений",
|
||||
"description": "Показывает общее количество сообщений рядом с папками и метками, наряду с количеством непрочитанных. Отключите, чтобы показывать только непрочитанные."
|
||||
},
|
||||
"pro_interface": {
|
||||
"label": "Pro-интерфейс (экспериментальный)",
|
||||
"description": "Макет для опытных пользователей только для настольных устройств с просмотром сообщений в нескольких вкладках и работой между аккаунтами. Стандартный интерфейс не меняется; вы можете вернуться в любое время.",
|
||||
|
||||
@@ -933,6 +933,10 @@
|
||||
"label": "Renkli Kenar Çubuğu Simgeleri",
|
||||
"description": "Klasör ve etiket simgelerini türe göre renklendir (mavi Gelen Kutusu, kırmızı İstem Dışı vb.). Tek renkli kenar çubuğu için kapatın."
|
||||
},
|
||||
"show_folder_total_count": {
|
||||
"label": "Toplam mesaj sayısını göster",
|
||||
"description": "Klasörlerin ve etiketlerin yanında, okunmamış sayısının yanı sıra toplam mesaj sayısını gösterir. Yalnızca okunmamışları göstermek için devre dışı bırakın."
|
||||
},
|
||||
"pro_interface": {
|
||||
"label": "Pro Arayüz (Deneysel)",
|
||||
"description": "Yalnızca masaüstü için güçlü kullanıcı düzeni: çoklu sekmede ileti gezme ve hesaplar arası iş akışları. Standart arayüz etkilenmez; istediğiniz zaman geri dönebilirsiniz.",
|
||||
|
||||
@@ -933,6 +933,10 @@
|
||||
"label": "Кольорові значки бічної панелі",
|
||||
"description": "Забарвлюйте значки папок і тегів за типом (синя «Вхідні», червоний «Спам», зелена «Надіслані» тощо). Вимкніть для монохромної бічної панелі."
|
||||
},
|
||||
"show_folder_total_count": {
|
||||
"label": "Показувати загальну кількість повідомлень",
|
||||
"description": "Показує загальну кількість повідомлень поруч із теками та мітками, разом із кількістю непрочитаних. Вимкніть, щоб показувати лише непрочитані."
|
||||
},
|
||||
"pro_interface": {
|
||||
"label": "Pro-інтерфейс (експериментальний)",
|
||||
"description": "Розкладка для досвідчених користувачів лише для настільного комп'ютера з переглядом повідомлень у кількох вкладках і робочими процесами між обліковими записами. Стандартний інтерфейс не змінюється; ви можете повернутися будь-коли.",
|
||||
|
||||
@@ -933,6 +933,10 @@
|
||||
"label": "彩色侧边栏图标",
|
||||
"description": "按类型为文件夹和标签图标着色(蓝色收件箱、红色垃圾邮件、绿色已发送等)。禁用以获得单色侧边栏。"
|
||||
},
|
||||
"show_folder_total_count": {
|
||||
"label": "显示邮件总数",
|
||||
"description": "在文件夹和标签旁边显示邮件总数,以及未读数量。停用后仅显示未读数量。"
|
||||
},
|
||||
"pro_interface": {
|
||||
"label": "Pro 界面(实验性)",
|
||||
"description": "仅限桌面的高级用户布局,支持多标签消息浏览和跨账户工作流。标准界面不受影响,您可以随时切换回来。",
|
||||
|
||||
Generated
+2
-17
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bulwark-webmail",
|
||||
"version": "1.7.5",
|
||||
"version": "1.7.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-webmail",
|
||||
"version": "1.7.5",
|
||||
"version": "1.7.6",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@tanstack/react-virtual": "^3.13.24",
|
||||
@@ -2008,9 +2008,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2031,9 +2028,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2054,9 +2048,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2077,9 +2068,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2100,9 +2088,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-webmail",
|
||||
"version": "1.7.5",
|
||||
"version": "1.7.6",
|
||||
"description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server",
|
||||
"author": "Bulwark Webmail <bulwark@rbm.systems>",
|
||||
"license": "AGPL-3.0-only",
|
||||
|
||||
@@ -75,10 +75,17 @@ export async function proxy(request: NextRequest) {
|
||||
const nonce = crypto.randomUUID();
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
// The plugin-sandbox iframe document needs `'unsafe-eval'` to run plugin
|
||||
// bundles via `new Function`. It is null-origin (sandbox="allow-scripts"),
|
||||
// so the relaxation is scoped strictly to that document and never reaches
|
||||
// the main app, plus it must be embeddable from `'self'`.
|
||||
const isSandboxPath = pathname === "/plugin-sandbox" || pathname.startsWith("/plugin-sandbox/");
|
||||
// bundles via `new Function`. The untrusted route is null-origin
|
||||
// (sandbox="allow-scripts"); the privileged route is same-origin
|
||||
// (allow-same-origin) so a vetted plugin gets real WebCrypto + IndexedDB.
|
||||
// Both get the SAME CSP relaxations (unsafe-eval, frame-ancestors 'self');
|
||||
// the privileged route's extra power comes from the iframe sandbox flag the
|
||||
// host sets, gated by signature + admin approval, NOT from a wider CSP.
|
||||
const isSandboxPath =
|
||||
pathname === "/plugin-sandbox" ||
|
||||
pathname.startsWith("/plugin-sandbox/") ||
|
||||
pathname === "/plugin-sandbox-privileged" ||
|
||||
pathname.startsWith("/plugin-sandbox-privileged/");
|
||||
|
||||
const scriptSrc = isSandboxPath
|
||||
? `'self' 'nonce-${nonce}' 'unsafe-eval'`
|
||||
|
||||
+106
-53
@@ -49,6 +49,7 @@ interface AuthState {
|
||||
clearError: () => void;
|
||||
syncIdentities: () => void;
|
||||
refreshIdentities: () => Promise<void>;
|
||||
applyPreferredIdentityOrdering: () => void;
|
||||
getClientForAccount: (accountId: string) => JMAPClient | undefined;
|
||||
getAllConnectedClients: () => Map<string, JMAPClient>;
|
||||
}
|
||||
@@ -171,7 +172,22 @@ function sortIdentities(rawIdentities: Identity[], username: string): Identity[]
|
||||
}
|
||||
|
||||
function loadIdentities(rawIdentities: Identity[], username: string): { identities: Identity[]; primaryIdentity: Identity | null } {
|
||||
const preferredPrimaryId = useIdentityStore.getState().preferredPrimaryId;
|
||||
const settings = useSettingsStore.getState();
|
||||
const preferredMap = settings.preferredIdentityIds || {};
|
||||
let preferredPrimaryId = preferredMap[username] ?? null;
|
||||
|
||||
// One-time migration: builds before #507 stored the preferred identity only
|
||||
// in the browser-local identity-storage (never synced). If the synced
|
||||
// settings have no entry for this account yet, adopt that legacy local value
|
||||
// and write it into the synced settings so it persists across devices.
|
||||
if (preferredPrimaryId == null) {
|
||||
const legacy = useIdentityStore.getState().preferredPrimaryId;
|
||||
if (legacy) {
|
||||
preferredPrimaryId = legacy;
|
||||
settings.updateSetting('preferredIdentityIds', { ...preferredMap, [username]: legacy });
|
||||
}
|
||||
}
|
||||
|
||||
const identities = sortIdentities(rawIdentities, username);
|
||||
|
||||
// If user has a preferred primary, move it to front
|
||||
@@ -185,6 +201,9 @@ function loadIdentities(rawIdentities: Identity[], username: string): { identiti
|
||||
|
||||
const primaryIdentity = identities[0] ?? null;
|
||||
useIdentityStore.getState().setIdentities(identities);
|
||||
// Mirror the resolved choice into the identity store so the identity-manager
|
||||
// UI (the ⭐ marker) reflects the active account's preferred identity.
|
||||
useIdentityStore.setState({ preferredPrimaryId });
|
||||
return { identities, primaryIdentity };
|
||||
}
|
||||
|
||||
@@ -378,20 +397,80 @@ export const useAuthStore = create<AuthState>()(
|
||||
isDemoMode: false,
|
||||
|
||||
login: async (serverUrl, username, password, totp, rememberMe) => {
|
||||
const effectivePassword = totp ? `${password}$${totp}` : password;
|
||||
set({ isLoading: true, error: null, isRateLimited: false, rateLimitUntil: null });
|
||||
|
||||
try {
|
||||
const client = new JMAPClient(serverUrl, username, effectivePassword);
|
||||
await client.connect();
|
||||
|
||||
// Resolve account/slot info up front so writes can start immediately.
|
||||
// Resolve account/slot info up front so the TOTP exchange can target
|
||||
// the right per-account refresh-token cookie slot.
|
||||
const accountStore = useAccountStore.getState();
|
||||
const accountId = generateAccountId(username, serverUrl);
|
||||
const cookieSlot = accountStore.hasAccount(username, serverUrl)
|
||||
? (accountStore.getAccountById(accountId)?.cookieSlot ?? accountStore.getNextCookieSlot())
|
||||
: accountStore.getNextCookieSlot();
|
||||
|
||||
let client: JMAPClient;
|
||||
let upgradedToOAuth = false;
|
||||
let oauthAccessToken: string | null = null;
|
||||
let oauthExpiresIn = 0;
|
||||
|
||||
if (totp) {
|
||||
// Stalwart 0.16+ dropped the `password$totp` basic-auth convention;
|
||||
// the MFA code must be exchanged for tokens via the structured login
|
||||
// endpoint (handled server-side). Token auth also survives TOTP
|
||||
// rotation, unlike basic auth which embeds the ~30s code per request.
|
||||
let bearerToken: string | null = null;
|
||||
try {
|
||||
// The callback URL the OAuth client already registers; the route
|
||||
// needs an identical redirect URI for the login + token-exchange
|
||||
// steps (and registered when require_client_registration is on).
|
||||
const redirectUri = typeof window !== 'undefined'
|
||||
? `${window.location.origin}${getPathPrefix()}/${getLocaleFromPath()}/auth/callback`
|
||||
: '';
|
||||
const tokenRes = await apiFetch('/api/auth/totp-token-exchange', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
// server_id isn't passed - the route looks up the server entry by
|
||||
// serverUrl, so per-server OAuth still applies for password+TOTP.
|
||||
body: JSON.stringify({ serverUrl, username, password, totp, slot: cookieSlot, redirectUri }),
|
||||
});
|
||||
if (tokenRes.ok) {
|
||||
const { access_token, expires_in, has_refresh_token } = await tokenRes.json();
|
||||
bearerToken = access_token;
|
||||
oauthExpiresIn = expires_in;
|
||||
debug.log('auth', 'TOTP login exchanged for token-based auth (has_refresh_token=' + has_refresh_token + ')');
|
||||
} else {
|
||||
const errorBody = await tokenRes.json().catch(() => ({ error: 'unknown' }));
|
||||
// A correct password with a missing/invalid MFA token surfaces as
|
||||
// a TOTP prompt rather than a generic failure.
|
||||
if (errorBody?.error === 'totp_required') {
|
||||
throw new Error('TOTP_REQUIRED');
|
||||
}
|
||||
debug.warn('auth', 'TOTP login exchange failed, trying legacy basic auth:', tokenRes.status, errorBody);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message === 'TOTP_REQUIRED') throw err;
|
||||
debug.warn('auth', 'TOTP login exchange error, trying legacy basic auth:', err);
|
||||
}
|
||||
|
||||
if (bearerToken) {
|
||||
client = JMAPClient.withBearer(serverUrl, bearerToken, username, () => get().refreshAccessToken());
|
||||
await client.connect();
|
||||
oauthAccessToken = bearerToken;
|
||||
upgradedToOAuth = true;
|
||||
} else {
|
||||
// Legacy fallback for pre-0.16 Stalwart, which accepts the TOTP
|
||||
// appended to the password over basic auth.
|
||||
client = new JMAPClient(serverUrl, username, `${password}$${totp}`);
|
||||
await client.connect();
|
||||
const { useTotpReauthStore } = await import('@/stores/totp-reauth-store');
|
||||
client.enableTotpReauth(password, () => useTotpReauthStore.getState().requestTotp());
|
||||
debug.log('auth', 'TOTP re-auth enabled (legacy basic-auth path)');
|
||||
}
|
||||
} else {
|
||||
client = new JMAPClient(serverUrl, username, password);
|
||||
await client.connect();
|
||||
}
|
||||
|
||||
// Snapshot/clear before kicking off any feature-store fetches so they
|
||||
// don't write into stores we're about to wipe.
|
||||
const prevAccountId = get().activeAccountId;
|
||||
@@ -400,54 +479,9 @@ export const useAuthStore = create<AuthState>()(
|
||||
clearAllStores();
|
||||
}
|
||||
|
||||
// Identities can fly in parallel with everything below. JMAPClient
|
||||
// captures the auth header per-request, so the optional TOTP upgrade
|
||||
// doesn't affect this already-issued request.
|
||||
// Identities can fly in parallel with everything below.
|
||||
const identitiesPromise = client.getIdentities();
|
||||
|
||||
// When TOTP was used, try to upgrade to token-based auth so the
|
||||
// session survives TOTP rotation (basic auth embeds the TOTP in
|
||||
// every request, which expires after ~30 seconds). Must complete
|
||||
// before stalwart-context reads the auth header.
|
||||
let upgradedToOAuth = false;
|
||||
let oauthAccessToken: string | null = null;
|
||||
let oauthExpiresIn = 0;
|
||||
|
||||
if (totp) {
|
||||
try {
|
||||
const tokenRes = await apiFetch('/api/auth/totp-token-exchange', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ serverUrl, username, password: effectivePassword, slot: cookieSlot }),
|
||||
// Note: server_id isn't passed here - the route looks up the
|
||||
// server entry by serverUrl, so per-server OAuth still applies
|
||||
// for password+TOTP logins through the dropdown.
|
||||
});
|
||||
if (tokenRes.ok) {
|
||||
const { access_token, expires_in, has_refresh_token } = await tokenRes.json();
|
||||
// Upgrade client to Bearer auth
|
||||
client.upgradeToBearer(access_token, () => get().refreshAccessToken());
|
||||
oauthAccessToken = access_token;
|
||||
oauthExpiresIn = expires_in;
|
||||
upgradedToOAuth = true;
|
||||
debug.log('auth', 'TOTP login upgraded to token-based auth (has_refresh_token=' + has_refresh_token + ')');
|
||||
} else {
|
||||
const errorBody = await tokenRes.json().catch(() => ({ error: 'unknown' }));
|
||||
debug.warn('auth', 'TOTP token exchange failed:', tokenRes.status, errorBody);
|
||||
}
|
||||
} catch (err) {
|
||||
debug.warn('auth', 'TOTP token exchange error:', err);
|
||||
}
|
||||
|
||||
// If token exchange failed, enable TOTP re-auth prompt so the
|
||||
// client can ask for a fresh code on 401 instead of disconnecting.
|
||||
if (!upgradedToOAuth) {
|
||||
const { useTotpReauthStore } = await import('@/stores/totp-reauth-store');
|
||||
client.enableTotpReauth(password, () => useTotpReauthStore.getState().requestTotp());
|
||||
debug.log('auth', 'TOTP re-auth enabled - user will be prompted for fresh codes on session expiry');
|
||||
}
|
||||
}
|
||||
|
||||
const effectiveAuthMode = upgradedToOAuth ? 'oauth' : 'basic';
|
||||
|
||||
// Run the remaining independent requests in parallel. The session
|
||||
@@ -458,7 +492,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
? apiFetch(`/api/auth/session?slot=${cookieSlot}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ serverUrl, username, password: effectivePassword, slot: cookieSlot }),
|
||||
body: JSON.stringify({ serverUrl, username, password, slot: cookieSlot }),
|
||||
}).then((res) => {
|
||||
if (!res.ok) debug.error('Failed to store session: server returned', res.status);
|
||||
}).catch((err) => debug.error('Failed to store session:', err))
|
||||
@@ -1630,6 +1664,25 @@ export const useAuthStore = create<AuthState>()(
|
||||
set({ identities, primaryIdentity });
|
||||
},
|
||||
|
||||
// Re-sort the already-loaded identities to honor the active account's
|
||||
// synced preferred-primary identity, without a network round-trip. Used
|
||||
// after settings load from the server so a fresh browser reflects the
|
||||
// synced default (#507).
|
||||
applyPreferredIdentityOrdering: () => {
|
||||
const { username, identities } = get();
|
||||
if (!username || identities.length === 0) return;
|
||||
const preferredId = useSettingsStore.getState().preferredIdentityIds?.[username] ?? null;
|
||||
useIdentityStore.setState({ preferredPrimaryId: preferredId });
|
||||
if (!preferredId) return;
|
||||
const idx = identities.findIndex((id) => id.id === preferredId);
|
||||
if (idx <= 0) return; // already first, or not present
|
||||
const reordered = [...identities];
|
||||
const [preferred] = reordered.splice(idx, 1);
|
||||
reordered.unshift(preferred);
|
||||
useIdentityStore.getState().setIdentities(reordered);
|
||||
set({ identities: reordered, primaryIdentity: reordered[0] ?? null });
|
||||
},
|
||||
|
||||
refreshIdentities: async () => {
|
||||
const { client, username } = get();
|
||||
if (!client || !username) return;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { InstalledPlugin, PluginStatus } from '@/lib/plugin-types';
|
||||
import type { InstalledPlugin, PluginStatus, PluginTier } from '@/lib/plugin-types';
|
||||
import { pluginStorage } from '@/lib/plugin-storage';
|
||||
import { extractPlugin } from '@/lib/plugin-validator';
|
||||
import { loadPlugin, deactivatePlugin, setPluginStoreAccessor, setupAutoDisable, setSandboxLocale } from '@/lib/plugin-loader';
|
||||
@@ -76,6 +76,7 @@ export const usePluginStore = create<PluginStoreState>()(
|
||||
author: manifest.author,
|
||||
description: manifest.description,
|
||||
type: manifest.type,
|
||||
...(manifest.tier ? { tier: manifest.tier } : {}),
|
||||
permissions: manifest.permissions,
|
||||
entrypoint: manifest.entrypoint,
|
||||
enabled: false, // Start disabled, user must enable
|
||||
@@ -324,6 +325,8 @@ interface ServerPluginInfo {
|
||||
author: string;
|
||||
description: string;
|
||||
type: string;
|
||||
/** Requested execution tier (privileged plugins run same-origin). */
|
||||
tier?: PluginTier;
|
||||
permissions: string[];
|
||||
entrypoint: string;
|
||||
forceEnabled: boolean;
|
||||
@@ -357,6 +360,7 @@ function serverMeta(sp: ServerPluginInfo) {
|
||||
description: sp.description,
|
||||
permissions: sp.permissions,
|
||||
entrypoint: sp.entrypoint,
|
||||
...(sp.tier ? { tier: sp.tier } : {}),
|
||||
managed: true as const,
|
||||
forceEnabled: sp.forceEnabled,
|
||||
bundleHash: sp.bundleHash,
|
||||
|
||||
@@ -168,6 +168,13 @@ interface SettingsState {
|
||||
requestReadReceiptDefault: boolean; // Pre-check "request read receipt" in the composer
|
||||
readReceiptResponse: ReadReceiptResponse; // How to respond to incoming read-receipt requests
|
||||
|
||||
// Identities
|
||||
// Per-account default ("preferred primary") sender identity, keyed by
|
||||
// username (the same key settings sync uses). A JMAP identity id is only
|
||||
// meaningful within its own account, so this must be account-scoped. Synced
|
||||
// so the choice survives a new browser / cleared site data (#507).
|
||||
preferredIdentityIds: Record<string, string | null>;
|
||||
|
||||
// Privacy & Security
|
||||
sessionTimeout: number; // minutes (0 = never)
|
||||
trustedSenders: string[]; // Email addresses that can load external content
|
||||
@@ -245,6 +252,7 @@ interface SettingsState {
|
||||
|
||||
// Sidebar
|
||||
colorfulSidebarIcons: boolean; // Tint folder icons by role (inbox blue, junk red, etc.)
|
||||
showFolderTotalCount: boolean; // Show total message count next to folders/tags (alongside unread)
|
||||
|
||||
// Folders
|
||||
folderIcons: Record<string, string>; // mailboxId -> icon name
|
||||
@@ -371,6 +379,9 @@ const DEFAULT_SETTINGS = {
|
||||
requestReadReceiptDefault: false,
|
||||
readReceiptResponse: 'ask' as ReadReceiptResponse,
|
||||
|
||||
// Identities
|
||||
preferredIdentityIds: {} as Record<string, string | null>,
|
||||
|
||||
// Privacy & Security
|
||||
sessionTimeout: 0, // Never
|
||||
trustedSenders: [] as string[],
|
||||
@@ -437,6 +448,7 @@ const DEFAULT_SETTINGS = {
|
||||
|
||||
// Sidebar
|
||||
colorfulSidebarIcons: true,
|
||||
showFolderTotalCount: true,
|
||||
|
||||
// Folders
|
||||
folderIcons: {} as Record<string, string>,
|
||||
@@ -577,6 +589,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
signatureSeparatorEnabled: state.signatureSeparatorEnabled,
|
||||
requestReadReceiptDefault: state.requestReadReceiptDefault,
|
||||
readReceiptResponse: state.readReceiptResponse,
|
||||
preferredIdentityIds: state.preferredIdentityIds,
|
||||
sessionTimeout: state.sessionTimeout,
|
||||
emailNotificationsEnabled: state.emailNotificationsEnabled,
|
||||
emailNotificationSound: state.emailNotificationSound,
|
||||
@@ -610,6 +623,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
senderFavicons: state.senderFavicons,
|
||||
showAvatarsInJunk: state.showAvatarsInJunk,
|
||||
colorfulSidebarIcons: state.colorfulSidebarIcons,
|
||||
showFolderTotalCount: state.showFolderTotalCount,
|
||||
folderIcons: state.folderIcons,
|
||||
emailKeywords: state.emailKeywords,
|
||||
attachmentReminderEnabled: state.attachmentReminderEnabled,
|
||||
@@ -659,6 +673,11 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
if (key === 'allMailFolderIds' && !isPlainRecord(settings[key])) {
|
||||
return;
|
||||
}
|
||||
// Defensive: a non-record (e.g. a legacy scalar) would break the
|
||||
// per-account map lookups - ignore it.
|
||||
if (key === 'preferredIdentityIds' && !isPlainRecord(settings[key])) {
|
||||
return;
|
||||
}
|
||||
if (DEVICE_LOCAL_SETTING_KEYS.has(key)) {
|
||||
return;
|
||||
}
|
||||
@@ -837,6 +856,14 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
get().importSettings(JSON.stringify(settings));
|
||||
isLoadingFromServer = false;
|
||||
syncLog('Settings loaded from server successfully');
|
||||
// Re-apply the (possibly server-updated) per-account preferred
|
||||
// sender identity to the already-loaded identities, so a fresh
|
||||
// browser reflects the synced default without waiting for the next
|
||||
// identity refresh. Dynamic import avoids a static import cycle
|
||||
// (auth-store imports this store). (#507)
|
||||
import('./auth-store')
|
||||
.then(({ useAuthStore }) => useAuthStore.getState().applyPreferredIdentityOrdering())
|
||||
.catch(() => {});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -888,6 +915,9 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
if (!isPlainRecord(state.allMailFolderIds)) {
|
||||
state.allMailFolderIds = {};
|
||||
}
|
||||
if (!isPlainRecord(state.preferredIdentityIds)) {
|
||||
state.preferredIdentityIds = {};
|
||||
}
|
||||
applyFontSize(state.fontSize);
|
||||
applyDensity(state.density);
|
||||
applyAnimations(state.animationsEnabled);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
Reference in New Issue
Block a user