feat: integrate webcrypto-liner for legacy algorithm support in S/MIME handling

This commit is contained in:
Linus Rath
2026-03-18 14:29:29 +01:00
parent 84abfe79dd
commit 8a2ef5a6c5
6 changed files with 281 additions and 12 deletions
+76
View File
@@ -0,0 +1,76 @@
/**
* 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.
*/
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.
// eslint-disable-next-line @typescript-eslint/no-require-imports
const liner = require('webcrypto-liner') as {
Crypto: { new (): Crypto };
setCrypto: (subtle: SubtleCrypto) => void;
nativeCrypto: Crypto | Record<string, never>;
};
let linerEngine: pkijs.CryptoEngine | 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 pkijs.CryptoEngine({
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!;
}
/**
* 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);
}
}
+14 -6
View File
@@ -6,6 +6,7 @@ import {
classifyCapabilities,
} from './certificate-utils';
import type { SmimeKeyRecord, Pkcs12ImportResult } from './types';
import { withLinerEngine } from './crypto-engine';
const KDF_ITERATIONS = 600_000;
const AES_KEY_LENGTH = 256;
@@ -39,9 +40,12 @@ export async function importPkcs12(
// PKIjs handles MAC verification internally during parseInternalValues
}
// Parse internal values
await pfx.parseInternalValues({
password: stringToAB(p12Passphrase),
// 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
@@ -63,7 +67,9 @@ export async function importPkcs12(
}
return {};
});
await authSafe.parseInternalValues({ safeContents: safeContentsParams });
await withLinerEngine(async () => {
await authSafe.parseInternalValues({ safeContents: safeContentsParams });
});
for (const safeContent of authSafe.parsedValue.safeContents) {
const sc = safeContent.value ?? safeContent.parsedValue;
@@ -115,8 +121,10 @@ export async function importPkcs12(
privateKeyInfo = shroudedBag.parsedValue;
} else {
// Decrypt shrouded key bag to get private key info
await (shroudedBag as unknown as { parseInternalValues(params: { password: ArrayBuffer }): Promise<void> }).parseInternalValues({
password: stringToAB(p12Passphrase),
await withLinerEngine(async () => {
await (shroudedBag as unknown as { parseInternalValues(params: { password: ArrayBuffer }): Promise<void> }).parseInternalValues({
password: stringToAB(p12Passphrase),
});
});
if (shroudedBag.parsedValue) {
privateKeyInfo = shroudedBag.parsedValue;
+3 -5
View File
@@ -8,6 +8,7 @@
import * as pkijs from 'pkijs';
import * as asn1js from 'asn1js';
import type { SmimeKeyRecord } from './types';
import { getLinerCryptoEngine } from './crypto-engine';
export interface DecryptionInput {
/** Raw CMS EnvelopedData bytes (DER) */
@@ -359,11 +360,8 @@ async function decryptWithKey(
const certAsn1 = asn1js.fromBER(keyRecord.certificate);
const cert = new pkijs.Certificate({ schema: certAsn1.result });
const cryptoEngine = new pkijs.CryptoEngine({
crypto: crypto,
subtle: crypto.subtle,
name: 'webcrypto',
});
// Use webcrypto-liner engine for legacy algorithm support (e.g. 3DES)
const cryptoEngine = getLinerCryptoEngine();
const result = await envelopedData.decrypt(
recipientIndex,