fix: support 3DES S/MIME decryption by importing legacy RSAES-PKCS1-v1_5 keys #35
This commit is contained in:
@@ -1545,7 +1545,7 @@ export function EmailViewer({
|
|||||||
|
|
||||||
if (detection.type === 'enveloped-data') {
|
if (detection.type === 'enveloped-data') {
|
||||||
// Encrypted message
|
// 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) });
|
smimeDebug('[S/MIME] decrypt attempt:', { keyRecordCount: keyRecords.length, unlockedKeyCount: unlockedDecryptionKeys.size, keyRecordIds: keyRecords.map(k => k.id) });
|
||||||
|
|
||||||
// Short-circuit: no keys imported at all
|
// Short-circuit: no keys imported at all
|
||||||
@@ -1569,6 +1569,7 @@ export function EmailViewer({
|
|||||||
cmsBytes: candidate.normalized,
|
cmsBytes: candidate.normalized,
|
||||||
keyRecords,
|
keyRecords,
|
||||||
unlockedKeys: unlockedDecryptionKeys,
|
unlockedKeys: unlockedDecryptionKeys,
|
||||||
|
legacyUnlockedKeys: unlockedLegacyDecryptionKeys,
|
||||||
});
|
});
|
||||||
smimeDebug('[S/MIME] decrypt success with candidate:', {
|
smimeDebug('[S/MIME] decrypt success with candidate:', {
|
||||||
source: candidate.source,
|
source: candidate.source,
|
||||||
@@ -1804,6 +1805,7 @@ export function EmailViewer({
|
|||||||
smimeStore.autoImportSignerCerts,
|
smimeStore.autoImportSignerCerts,
|
||||||
smimeStore.keyRecords,
|
smimeStore.keyRecords,
|
||||||
smimeStore.unlockedDecryptionKeys,
|
smimeStore.unlockedDecryptionKeys,
|
||||||
|
smimeStore.unlockedLegacyDecryptionKeys,
|
||||||
smimeStore,
|
smimeStore,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -274,6 +274,12 @@ export function getLinerCryptoEngine(): pkijs.CryptoEngine {
|
|||||||
return linerEngine!;
|
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,
|
* Run an async operation with the global PKI.js engine set to webcrypto-liner,
|
||||||
* then restore the previous engine afterwards.
|
* then restore the previous engine afterwards.
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
classifyCapabilities,
|
classifyCapabilities,
|
||||||
} from './certificate-utils';
|
} from './certificate-utils';
|
||||||
import type { SmimeKeyRecord, Pkcs12ImportResult } from './types';
|
import type { SmimeKeyRecord, Pkcs12ImportResult } from './types';
|
||||||
import { withLinerEngine } from './crypto-engine';
|
import { withLinerEngine, getLinerCrypto } from './crypto-engine';
|
||||||
|
|
||||||
const KDF_ITERATIONS = 600_000;
|
const KDF_ITERATIONS = 600_000;
|
||||||
const AES_KEY_LENGTH = 256;
|
const AES_KEY_LENGTH = 256;
|
||||||
@@ -217,6 +217,8 @@ async function encryptPrivateKey(
|
|||||||
export interface UnlockedKeyPair {
|
export interface UnlockedKeyPair {
|
||||||
signingKey: CryptoKey;
|
signingKey: CryptoKey;
|
||||||
decryptionKey?: 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. */
|
/** Decrypt stored PKCS#8 bytes and import as non-extractable CryptoKeys for signing and decryption. */
|
||||||
@@ -257,7 +259,22 @@ export async function unlockPrivateKey(
|
|||||||
} catch {
|
} catch {
|
||||||
// Key may only support decryption (key-encipherment-only cert)
|
// Key may only support decryption (key-encipherment-only cert)
|
||||||
const decryptionKey = await crypto.subtle.importKey('pkcs8', pkcs8Bytes, decryptAlg, false, decryptUsages);
|
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)
|
// 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)
|
// 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). */
|
/** Get decrypted PKCS#8 bytes (for export flow). */
|
||||||
|
|||||||
@@ -15,8 +15,10 @@ export interface DecryptionInput {
|
|||||||
cmsBytes: ArrayBuffer;
|
cmsBytes: ArrayBuffer;
|
||||||
/** All imported key records to try matching against */
|
/** All imported key records to try matching against */
|
||||||
keyRecords: SmimeKeyRecord[];
|
keyRecords: SmimeKeyRecord[];
|
||||||
/** Unlocked CryptoKey map: keyRecordId → CryptoKey */
|
/** Unlocked CryptoKey map: keyRecordId → CryptoKey (RSA-OAEP) */
|
||||||
unlockedKeys: Map<string, CryptoKey>;
|
unlockedKeys: Map<string, CryptoKey>;
|
||||||
|
/** Unlocked legacy CryptoKey map: keyRecordId → CryptoKey (RSAES-PKCS1-v1_5 via webcrypto-liner) */
|
||||||
|
legacyUnlockedKeys?: Map<string, CryptoKey>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DecryptionResult {
|
export interface DecryptionResult {
|
||||||
@@ -34,7 +36,7 @@ export interface DecryptionResult {
|
|||||||
* @throws Error if no matching key is found, key is locked, or decryption fails
|
* @throws Error if no matching key is found, key is locked, or decryption fails
|
||||||
*/
|
*/
|
||||||
export async function smimeDecrypt(input: DecryptionInput): Promise<DecryptionResult> {
|
export async function smimeDecrypt(input: DecryptionInput): Promise<DecryptionResult> {
|
||||||
const { cmsBytes, keyRecords, unlockedKeys } = input;
|
const { cmsBytes, keyRecords, unlockedKeys, legacyUnlockedKeys } = input;
|
||||||
|
|
||||||
// Parse the CMS ContentInfo wrapper
|
// Parse the CMS ContentInfo wrapper
|
||||||
const contentInfo = parseContentInfo(cmsBytes);
|
const contentInfo = parseContentInfo(cmsBytes);
|
||||||
@@ -51,6 +53,19 @@ export async function smimeDecrypt(input: DecryptionInput): Promise<DecryptionRe
|
|||||||
for (const { keyRecord, recipientIndex } of matchedRecords) {
|
for (const { keyRecord, recipientIndex } of matchedRecords) {
|
||||||
const privateKey = unlockedKeys.get(keyRecord.id);
|
const privateKey = unlockedKeys.get(keyRecord.id);
|
||||||
if (!privateKey) {
|
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
|
continue; // Key exists but isn't unlocked — skip, caller should unlock first
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,15 +76,28 @@ export async function smimeDecrypt(input: DecryptionInput): Promise<DecryptionRe
|
|||||||
keyRecordId: keyRecord.id,
|
keyRecordId: keyRecord.id,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
// This key didn't work, try the next one
|
// RSA-OAEP key didn't work, try legacy RSAES-PKCS1-v1_5 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 {
|
||||||
|
// Legacy key also didn't work, try the next record
|
||||||
|
}
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we had matching records but none were unlocked
|
// Check if we had matching records but none were unlocked
|
||||||
const hasLockedMatch = matchedRecords.some(m => !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) {
|
if (hasLockedMatch) {
|
||||||
const lockedRecord = matchedRecords.find(m => !unlockedKeys.has(m.keyRecord.id))!;
|
const lockedRecord = matchedRecords.find(m => !isUnlocked(m.keyRecord.id))!;
|
||||||
throw new SmimeKeyLockedError(
|
throw new SmimeKeyLockedError(
|
||||||
'S/MIME key is locked. Unlock it to decrypt this message.',
|
'S/MIME key is locked. Unlock it to decrypt this message.',
|
||||||
lockedRecord.keyRecord.id,
|
lockedRecord.keyRecord.id,
|
||||||
|
|||||||
+27
-6
@@ -91,10 +91,12 @@ function clearRememberedUnlocks(): void {
|
|||||||
async function restoreRememberedKeys(keyRecords: SmimeKeyRecord[]): Promise<{
|
async function restoreRememberedKeys(keyRecords: SmimeKeyRecord[]): Promise<{
|
||||||
unlockedKeys: Map<string, CryptoKey>;
|
unlockedKeys: Map<string, CryptoKey>;
|
||||||
unlockedDecryptionKeys: Map<string, CryptoKey>;
|
unlockedDecryptionKeys: Map<string, CryptoKey>;
|
||||||
|
unlockedLegacyDecryptionKeys: Map<string, CryptoKey>;
|
||||||
}> {
|
}> {
|
||||||
const rememberedUnlocks = readRememberedUnlocks();
|
const rememberedUnlocks = readRememberedUnlocks();
|
||||||
const unlockedKeys = new Map<string, CryptoKey>();
|
const unlockedKeys = new Map<string, CryptoKey>();
|
||||||
const unlockedDecryptionKeys = new Map<string, CryptoKey>();
|
const unlockedDecryptionKeys = new Map<string, CryptoKey>();
|
||||||
|
const unlockedLegacyDecryptionKeys = new Map<string, CryptoKey>();
|
||||||
let removedStaleEntries = false;
|
let removedStaleEntries = false;
|
||||||
|
|
||||||
for (const record of keyRecords) {
|
for (const record of keyRecords) {
|
||||||
@@ -104,11 +106,14 @@ async function restoreRememberedKeys(keyRecords: SmimeKeyRecord[]): Promise<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { signingKey, decryptionKey } = await unlockPrivateKey(record, passphrase);
|
const { signingKey, decryptionKey, legacyDecryptionKey } = await unlockPrivateKey(record, passphrase);
|
||||||
unlockedKeys.set(record.id, signingKey);
|
unlockedKeys.set(record.id, signingKey);
|
||||||
if (decryptionKey) {
|
if (decryptionKey) {
|
||||||
unlockedDecryptionKeys.set(record.id, decryptionKey);
|
unlockedDecryptionKeys.set(record.id, decryptionKey);
|
||||||
}
|
}
|
||||||
|
if (legacyDecryptionKey) {
|
||||||
|
unlockedLegacyDecryptionKeys.set(record.id, legacyDecryptionKey);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
delete rememberedUnlocks[record.id];
|
delete rememberedUnlocks[record.id];
|
||||||
removedStaleEntries = true;
|
removedStaleEntries = true;
|
||||||
@@ -119,7 +124,7 @@ async function restoreRememberedKeys(keyRecords: SmimeKeyRecord[]): Promise<{
|
|||||||
writeRememberedUnlocks(rememberedUnlocks);
|
writeRememberedUnlocks(rememberedUnlocks);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { unlockedKeys, unlockedDecryptionKeys };
|
return { unlockedKeys, unlockedDecryptionKeys, unlockedLegacyDecryptionKeys };
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SmimePersistedState {
|
interface SmimePersistedState {
|
||||||
@@ -146,6 +151,7 @@ interface SmimeStore extends SmimePersistedState {
|
|||||||
// Runtime only — never persisted
|
// Runtime only — never persisted
|
||||||
unlockedKeys: Map<string, CryptoKey>;
|
unlockedKeys: Map<string, CryptoKey>;
|
||||||
unlockedDecryptionKeys: Map<string, CryptoKey>;
|
unlockedDecryptionKeys: Map<string, CryptoKey>;
|
||||||
|
unlockedLegacyDecryptionKeys: Map<string, CryptoKey>;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
|
||||||
@@ -189,6 +195,7 @@ export const useSmimeStore = create<SmimeStore>()(
|
|||||||
publicCerts: [],
|
publicCerts: [],
|
||||||
unlockedKeys: new Map(),
|
unlockedKeys: new Map(),
|
||||||
unlockedDecryptionKeys: new Map(),
|
unlockedDecryptionKeys: new Map(),
|
||||||
|
unlockedLegacyDecryptionKeys: new Map(),
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
|
||||||
@@ -231,6 +238,10 @@ export const useSmimeStore = create<SmimeStore>()(
|
|||||||
...state.unlockedDecryptionKeys,
|
...state.unlockedDecryptionKeys,
|
||||||
...restoredKeys.unlockedDecryptionKeys,
|
...restoredKeys.unlockedDecryptionKeys,
|
||||||
]),
|
]),
|
||||||
|
unlockedLegacyDecryptionKeys: new Map([
|
||||||
|
...state.unlockedLegacyDecryptionKeys,
|
||||||
|
...restoredKeys.unlockedLegacyDecryptionKeys,
|
||||||
|
]),
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
}));
|
}));
|
||||||
return;
|
return;
|
||||||
@@ -332,6 +343,8 @@ export const useSmimeStore = create<SmimeStore>()(
|
|||||||
unlockedKeys.delete(id);
|
unlockedKeys.delete(id);
|
||||||
const unlockedDecryptionKeys = new Map(state.unlockedDecryptionKeys);
|
const unlockedDecryptionKeys = new Map(state.unlockedDecryptionKeys);
|
||||||
unlockedDecryptionKeys.delete(id);
|
unlockedDecryptionKeys.delete(id);
|
||||||
|
const unlockedLegacyDecryptionKeys = new Map(state.unlockedLegacyDecryptionKeys);
|
||||||
|
unlockedLegacyDecryptionKeys.delete(id);
|
||||||
// Remove any identity bindings pointing to this key
|
// Remove any identity bindings pointing to this key
|
||||||
const bindings = { ...state.identityKeyBindings };
|
const bindings = { ...state.identityKeyBindings };
|
||||||
for (const [identityId, keyId] of Object.entries(bindings)) {
|
for (const [identityId, keyId] of Object.entries(bindings)) {
|
||||||
@@ -346,6 +359,7 @@ export const useSmimeStore = create<SmimeStore>()(
|
|||||||
keyRecords: state.keyRecords.filter((k) => k.id !== id),
|
keyRecords: state.keyRecords.filter((k) => k.id !== id),
|
||||||
unlockedKeys,
|
unlockedKeys,
|
||||||
unlockedDecryptionKeys,
|
unlockedDecryptionKeys,
|
||||||
|
unlockedLegacyDecryptionKeys,
|
||||||
identityKeyBindings: bindings,
|
identityKeyBindings: bindings,
|
||||||
accountPreferences,
|
accountPreferences,
|
||||||
};
|
};
|
||||||
@@ -363,7 +377,7 @@ export const useSmimeStore = create<SmimeStore>()(
|
|||||||
const record = get().keyRecords.find((k) => k.id === id);
|
const record = get().keyRecords.find((k) => k.id === id);
|
||||||
if (!record) throw new Error('Key record not found');
|
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) {
|
if (get().rememberUnlockedKeys) {
|
||||||
rememberUnlockedKey(id, passphrase);
|
rememberUnlockedKey(id, passphrase);
|
||||||
}
|
}
|
||||||
@@ -374,7 +388,11 @@ export const useSmimeStore = create<SmimeStore>()(
|
|||||||
if (decryptionKey) {
|
if (decryptionKey) {
|
||||||
unlockedDecryptionKeys.set(id, 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<SmimeStore>()(
|
|||||||
unlockedKeys.delete(id);
|
unlockedKeys.delete(id);
|
||||||
const unlockedDecryptionKeys = new Map(state.unlockedDecryptionKeys);
|
const unlockedDecryptionKeys = new Map(state.unlockedDecryptionKeys);
|
||||||
unlockedDecryptionKeys.delete(id);
|
unlockedDecryptionKeys.delete(id);
|
||||||
return { unlockedKeys, unlockedDecryptionKeys };
|
const unlockedLegacyDecryptionKeys = new Map(state.unlockedLegacyDecryptionKeys);
|
||||||
|
unlockedLegacyDecryptionKeys.delete(id);
|
||||||
|
return { unlockedKeys, unlockedDecryptionKeys, unlockedLegacyDecryptionKeys };
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
lockAllKeys: () => {
|
lockAllKeys: () => {
|
||||||
clearRememberedUnlocks();
|
clearRememberedUnlocks();
|
||||||
set({ unlockedKeys: new Map(), unlockedDecryptionKeys: new Map() });
|
set({ unlockedKeys: new Map(), unlockedDecryptionKeys: new Map(), unlockedLegacyDecryptionKeys: new Map() });
|
||||||
},
|
},
|
||||||
|
|
||||||
getKeyRecordForIdentity: (identityId) => {
|
getKeyRecordForIdentity: (identityId) => {
|
||||||
@@ -476,6 +496,7 @@ export const useSmimeStore = create<SmimeStore>()(
|
|||||||
publicCerts: [],
|
publicCerts: [],
|
||||||
unlockedKeys: new Map(),
|
unlockedKeys: new Map(),
|
||||||
unlockedDecryptionKeys: new Map(),
|
unlockedDecryptionKeys: new Map(),
|
||||||
|
unlockedLegacyDecryptionKeys: new Map(),
|
||||||
identityKeyBindings: {},
|
identityKeyBindings: {},
|
||||||
defaultSignIdentity: {},
|
defaultSignIdentity: {},
|
||||||
defaultEncrypt: false,
|
defaultEncrypt: false,
|
||||||
|
|||||||
Reference in New Issue
Block a user