fix: support 3DES S/MIME decryption by importing legacy RSAES-PKCS1-v1_5 keys #35
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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<string, CryptoKey>;
|
||||
/** Unlocked legacy CryptoKey map: keyRecordId → CryptoKey (RSAES-PKCS1-v1_5 via webcrypto-liner) */
|
||||
legacyUnlockedKeys?: Map<string, CryptoKey>;
|
||||
}
|
||||
|
||||
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<DecryptionResult> {
|
||||
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<DecryptionRe
|
||||
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
|
||||
}
|
||||
|
||||
@@ -61,15 +76,28 @@ export async function smimeDecrypt(input: DecryptionInput): Promise<DecryptionRe
|
||||
keyRecordId: keyRecord.id,
|
||||
};
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user