diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 5f7e5668..04bf8c68 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -1545,7 +1545,7 @@ export function EmailViewer({ if (detection.type === 'enveloped-data') { // Encrypted message - const { keyRecords, unlockedDecryptionKeys } = smimeStore; + const { keyRecords, unlockedDecryptionKeys, unlockedLegacyDecryptionKeys } = smimeStore; smimeDebug('[S/MIME] decrypt attempt:', { keyRecordCount: keyRecords.length, unlockedKeyCount: unlockedDecryptionKeys.size, keyRecordIds: keyRecords.map(k => k.id) }); // Short-circuit: no keys imported at all @@ -1569,6 +1569,7 @@ export function EmailViewer({ cmsBytes: candidate.normalized, keyRecords, unlockedKeys: unlockedDecryptionKeys, + legacyUnlockedKeys: unlockedLegacyDecryptionKeys, }); smimeDebug('[S/MIME] decrypt success with candidate:', { source: candidate.source, @@ -1804,6 +1805,7 @@ export function EmailViewer({ smimeStore.autoImportSignerCerts, smimeStore.keyRecords, smimeStore.unlockedDecryptionKeys, + smimeStore.unlockedLegacyDecryptionKeys, smimeStore, ]); diff --git a/lib/smime/crypto-engine.ts b/lib/smime/crypto-engine.ts index 1cb7761b..00ff218f 100644 --- a/lib/smime/crypto-engine.ts +++ b/lib/smime/crypto-engine.ts @@ -274,6 +274,12 @@ export function getLinerCryptoEngine(): pkijs.CryptoEngine { 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. diff --git a/lib/smime/pkcs12-import.ts b/lib/smime/pkcs12-import.ts index 6e57b629..3be315bc 100644 --- a/lib/smime/pkcs12-import.ts +++ b/lib/smime/pkcs12-import.ts @@ -5,7 +5,7 @@ import { classifyCapabilities, } from './certificate-utils'; import type { SmimeKeyRecord, Pkcs12ImportResult } from './types'; -import { withLinerEngine } from './crypto-engine'; +import { withLinerEngine, getLinerCrypto } from './crypto-engine'; const KDF_ITERATIONS = 600_000; const AES_KEY_LENGTH = 256; @@ -217,6 +217,8 @@ async function encryptPrivateKey( 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. */ @@ -257,7 +259,22 @@ export async function unlockPrivateKey( } catch { // Key may only support decryption (key-encipherment-only cert) const decryptionKey = await crypto.subtle.importKey('pkcs8', pkcs8Bytes, decryptAlg, false, decryptUsages); - return { signingKey: decryptionKey, decryptionKey }; + 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) @@ -268,7 +285,25 @@ export async function unlockPrivateKey( // Key may only support signing (digitalSignature-only cert) } - return { signingKey, decryptionKey }; + // 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'], + ); + } catch { + // webcrypto-liner may not be available or key format unsupported + } + } + + return { signingKey, decryptionKey, legacyDecryptionKey }; } /** Get decrypted PKCS#8 bytes (for export flow). */ diff --git a/lib/smime/smime-decrypt.ts b/lib/smime/smime-decrypt.ts index 93a8279b..3a641687 100644 --- a/lib/smime/smime-decrypt.ts +++ b/lib/smime/smime-decrypt.ts @@ -15,8 +15,10 @@ export interface DecryptionInput { cmsBytes: ArrayBuffer; /** All imported key records to try matching against */ keyRecords: SmimeKeyRecord[]; - /** Unlocked CryptoKey map: keyRecordId → CryptoKey */ + /** Unlocked CryptoKey map: keyRecordId → CryptoKey (RSA-OAEP) */ unlockedKeys: Map; + /** Unlocked legacy CryptoKey map: keyRecordId → CryptoKey (RSAES-PKCS1-v1_5 via webcrypto-liner) */ + legacyUnlockedKeys?: Map; } export interface DecryptionResult { @@ -34,7 +36,7 @@ export interface DecryptionResult { * @throws Error if no matching key is found, key is locked, or decryption fails */ export async function smimeDecrypt(input: DecryptionInput): Promise { - const { cmsBytes, keyRecords, unlockedKeys } = input; + const { cmsBytes, keyRecords, unlockedKeys, legacyUnlockedKeys } = input; // Parse the CMS ContentInfo wrapper const contentInfo = parseContentInfo(cmsBytes); @@ -51,6 +53,19 @@ export async function smimeDecrypt(input: DecryptionInput): Promise !unlockedKeys.has(m.keyRecord.id)); + 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 => !unlockedKeys.has(m.keyRecord.id))!; + 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, diff --git a/stores/smime-store.ts b/stores/smime-store.ts index 3301dcdc..25b7594c 100644 --- a/stores/smime-store.ts +++ b/stores/smime-store.ts @@ -91,10 +91,12 @@ function clearRememberedUnlocks(): void { async function restoreRememberedKeys(keyRecords: SmimeKeyRecord[]): Promise<{ unlockedKeys: Map; unlockedDecryptionKeys: Map; + unlockedLegacyDecryptionKeys: Map; }> { const rememberedUnlocks = readRememberedUnlocks(); const unlockedKeys = new Map(); const unlockedDecryptionKeys = new Map(); + const unlockedLegacyDecryptionKeys = new Map(); let removedStaleEntries = false; for (const record of keyRecords) { @@ -104,11 +106,14 @@ async function restoreRememberedKeys(keyRecords: SmimeKeyRecord[]): Promise<{ } try { - const { signingKey, decryptionKey } = await unlockPrivateKey(record, passphrase); + const { signingKey, decryptionKey, legacyDecryptionKey } = await unlockPrivateKey(record, passphrase); unlockedKeys.set(record.id, signingKey); if (decryptionKey) { unlockedDecryptionKeys.set(record.id, decryptionKey); } + if (legacyDecryptionKey) { + unlockedLegacyDecryptionKeys.set(record.id, legacyDecryptionKey); + } } catch { delete rememberedUnlocks[record.id]; removedStaleEntries = true; @@ -119,7 +124,7 @@ async function restoreRememberedKeys(keyRecords: SmimeKeyRecord[]): Promise<{ writeRememberedUnlocks(rememberedUnlocks); } - return { unlockedKeys, unlockedDecryptionKeys }; + return { unlockedKeys, unlockedDecryptionKeys, unlockedLegacyDecryptionKeys }; } interface SmimePersistedState { @@ -146,6 +151,7 @@ interface SmimeStore extends SmimePersistedState { // Runtime only — never persisted unlockedKeys: Map; unlockedDecryptionKeys: Map; + unlockedLegacyDecryptionKeys: Map; isLoading: boolean; error: string | null; @@ -189,6 +195,7 @@ export const useSmimeStore = create()( publicCerts: [], unlockedKeys: new Map(), unlockedDecryptionKeys: new Map(), + unlockedLegacyDecryptionKeys: new Map(), isLoading: false, error: null, @@ -231,6 +238,10 @@ export const useSmimeStore = create()( ...state.unlockedDecryptionKeys, ...restoredKeys.unlockedDecryptionKeys, ]), + unlockedLegacyDecryptionKeys: new Map([ + ...state.unlockedLegacyDecryptionKeys, + ...restoredKeys.unlockedLegacyDecryptionKeys, + ]), isLoading: false, })); return; @@ -332,6 +343,8 @@ export const useSmimeStore = create()( 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)) { @@ -346,6 +359,7 @@ export const useSmimeStore = create()( keyRecords: state.keyRecords.filter((k) => k.id !== id), unlockedKeys, unlockedDecryptionKeys, + unlockedLegacyDecryptionKeys, identityKeyBindings: bindings, accountPreferences, }; @@ -363,7 +377,7 @@ export const useSmimeStore = create()( const record = get().keyRecords.find((k) => k.id === id); if (!record) throw new Error('Key record not found'); - const { signingKey, decryptionKey } = await unlockPrivateKey(record, passphrase); + const { signingKey, decryptionKey, legacyDecryptionKey } = await unlockPrivateKey(record, passphrase); if (get().rememberUnlockedKeys) { rememberUnlockedKey(id, passphrase); } @@ -374,7 +388,11 @@ export const useSmimeStore = create()( if (decryptionKey) { unlockedDecryptionKeys.set(id, decryptionKey); } - return { unlockedKeys, unlockedDecryptionKeys }; + const unlockedLegacyDecryptionKeys = new Map(state.unlockedLegacyDecryptionKeys); + if (legacyDecryptionKey) { + unlockedLegacyDecryptionKeys.set(id, legacyDecryptionKey); + } + return { unlockedKeys, unlockedDecryptionKeys, unlockedLegacyDecryptionKeys }; }); }, @@ -385,13 +403,15 @@ export const useSmimeStore = create()( unlockedKeys.delete(id); const unlockedDecryptionKeys = new Map(state.unlockedDecryptionKeys); unlockedDecryptionKeys.delete(id); - return { unlockedKeys, unlockedDecryptionKeys }; + const unlockedLegacyDecryptionKeys = new Map(state.unlockedLegacyDecryptionKeys); + unlockedLegacyDecryptionKeys.delete(id); + return { unlockedKeys, unlockedDecryptionKeys, unlockedLegacyDecryptionKeys }; }); }, lockAllKeys: () => { clearRememberedUnlocks(); - set({ unlockedKeys: new Map(), unlockedDecryptionKeys: new Map() }); + set({ unlockedKeys: new Map(), unlockedDecryptionKeys: new Map(), unlockedLegacyDecryptionKeys: new Map() }); }, getKeyRecordForIdentity: (identityId) => { @@ -476,6 +496,7 @@ export const useSmimeStore = create()( publicCerts: [], unlockedKeys: new Map(), unlockedDecryptionKeys: new Map(), + unlockedLegacyDecryptionKeys: new Map(), identityKeyBindings: {}, defaultSignIdentity: {}, defaultEncrypt: false,