security(smime): fork upstream plugin and fix two audit findings
S-01 audited bulwarkmail/plugins/smime @ 91085a3 (2,935 lines). Nine findings, two HIGH. No backdoor and no exfiltration path anywhere in the bundle — the problems are trust-model and input-validation gaps. Full report in vnc/audits/SMIME-PLUGIN-AUDIT-2026-08-04.md. Fork is source-only. The upstream smime.zip is a 1.77 MB prebuilt bundle whose manifest reads 1.0.1 while the source reads 1.0.2, so auditing src/ would not audit what that zip installs. We build from source. Finding 1 (HIGH) — certificate substitution. maybeAutoImportSigner gated on signatureValid alone, but smimeVerify runs checkChain:false, so that only proves "signed by whoever holds this key", not that the claimed identity is real. Self-sign a cert asserting victim@example.com, send one signed message, and it was stored as the encryption target for that address — the user's next Encrypt to the victim went to the attacker. Now requires signerEmailMatch === true and !selfSigned. Both values were already computed and displayed as untrusted in the banner; only the import path ignored them. Tests for `true` explicitly so an undefined match (missing From header) fails closed. Finding 3 (MED-HIGH) — CRLF header injection. Escaping reached only Subject and attachment filename; display names, raw addresses, Message-ID, In-Reply-To, References and attachment Content-Type were emitted verbatim, and formatAddress escapes only backslash and quote. In-Reply-To/References/display names are copied from inbound mail when replying or forwarding, so the value is attacker-supplied. Sanitising inside formatHeader covers all 17 call sites by construction; the three headers assembled directly get stripCrlf explicitly. Also adds auth:observe to the manifest. The plugin registers onAfterLogout/onAccountSwitch — real hooks (lib/plugin-hooks.ts:362-363) — without declaring the permission, so under B-09 the session-key wipe would silently stop running. verify-fixes.mjs carries 19 assertions including source checks that fail if either guard is removed or a new unsanitised interpolated header appears. That last one immediately caught the interpolated smime-type Content-Type header, which manual review had dismissed as static. Finding 2 (unauthenticated CBC accepted on decrypt) is NOT fixed. This is not safe for real mail yet — sandbox accounts only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
e9746fcf78
commit
f7e487171c
@@ -0,0 +1,217 @@
|
||||
// X.509 parsing + metadata extraction. Ported from lib/smime/certificate-utils.ts.
|
||||
|
||||
import * as asn1js from 'asn1js';
|
||||
import * as pkijs from 'pkijs';
|
||||
import { Convert } from 'pvtsutils';
|
||||
|
||||
const OID_EMAIL_PROTECTION = '1.3.6.1.5.5.7.3.4';
|
||||
const OID_SAN = '2.5.29.17';
|
||||
|
||||
// ── PEM/DER conversions ──────────────────────────────────────────────
|
||||
|
||||
export function pemToDer(pem) {
|
||||
const lines = pem
|
||||
.replace(/-----BEGIN [^-]+-----/, '')
|
||||
.replace(/-----END [^-]+-----/, '')
|
||||
.replace(/\s/g, '');
|
||||
return Convert.FromBase64(lines);
|
||||
}
|
||||
|
||||
export function derToPem(der, label) {
|
||||
const b64 = Convert.ToBase64(der);
|
||||
const lines = [];
|
||||
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) {
|
||||
return /-----BEGIN (CERTIFICATE|PKCS12|ENCRYPTED PRIVATE KEY|PRIVATE KEY)-----/.test(data);
|
||||
}
|
||||
|
||||
// ── Certificate parsing ──────────────────────────────────────────────
|
||||
|
||||
export function parseCertificateDer(der) {
|
||||
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) {
|
||||
if (typeof data === 'string') {
|
||||
if (isPem(data)) return parseCertificateDer(pemToDer(data));
|
||||
throw new Error('String input is not PEM-encoded');
|
||||
}
|
||||
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) {
|
||||
return rdn.typesAndValues
|
||||
.map((tv) => `${oidToName(tv.type)}=${tv.value.valueBlock.value}`)
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
function oidToName(oid) {
|
||||
const map = {
|
||||
'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) {
|
||||
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) {
|
||||
const algOid = cert.subjectPublicKeyInfo.algorithm.algorithmId;
|
||||
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;
|
||||
const modulus = seq.valueBlock.value[0];
|
||||
const bitLen = (modulus.valueBlock.valueHexView.byteLength - 1) * 8;
|
||||
return `RSA-${bitLen}`;
|
||||
} catch {
|
||||
return 'RSA';
|
||||
}
|
||||
}
|
||||
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 = {
|
||||
'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) {
|
||||
const ext = cert.extensions?.find((e) => e.extnID === '2.5.29.15');
|
||||
if (!ext?.parsedValue) return undefined;
|
||||
const ku = ext.parsedValue;
|
||||
const names = [];
|
||||
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) {
|
||||
const ext = cert.extensions?.find((e) => e.extnID === '2.5.29.37');
|
||||
if (!ext?.parsedValue) return undefined;
|
||||
return ext.parsedValue.keyPurposes;
|
||||
}
|
||||
|
||||
function extractEmailAddresses(cert) {
|
||||
const emails = [];
|
||||
|
||||
for (const tv of cert.subject.typesAndValues) {
|
||||
if (tv.type === '1.2.840.113549.1.9.1') {
|
||||
emails.push(tv.value.valueBlock.value);
|
||||
}
|
||||
}
|
||||
|
||||
const sanExt = cert.extensions?.find((e) => e.extnID === OID_SAN);
|
||||
if (sanExt) {
|
||||
let names;
|
||||
const pv = sanExt.parsedValue;
|
||||
if (pv?.names) {
|
||||
names = pv.names;
|
||||
} else if (sanExt.extnValue) {
|
||||
try {
|
||||
const sanAsn1 = asn1js.fromBER(sanExt.extnValue.valueBlock.valueHexView);
|
||||
if (sanAsn1.offset !== -1) {
|
||||
names = new pkijs.GeneralNames({ schema: sanAsn1.result }).names;
|
||||
}
|
||||
} catch { /* malformed SAN — skip */ }
|
||||
}
|
||||
if (names) {
|
||||
for (const name of names) {
|
||||
if (name.type === 1 && typeof name.value === 'string' && !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) {
|
||||
const ku = extractKeyUsage(cert);
|
||||
const eku = extractExtendedKeyUsage(cert);
|
||||
|
||||
let canSign = true;
|
||||
let canEncrypt = true;
|
||||
|
||||
if (ku) {
|
||||
canSign = ku.includes('digitalSignature') || ku.includes('contentCommitment');
|
||||
canEncrypt = ku.includes('keyEncipherment') || ku.includes('dataEncipherment') || ku.includes('keyAgreement');
|
||||
}
|
||||
|
||||
if (eku && eku.length > 0) {
|
||||
const hasEmailProtection = eku.includes(OID_EMAIL_PROTECTION);
|
||||
if (!hasEmailProtection) {
|
||||
canSign = false;
|
||||
canEncrypt = false;
|
||||
}
|
||||
}
|
||||
|
||||
return { canSign, canEncrypt };
|
||||
}
|
||||
|
||||
/** Extract full metadata from a parsed certificate. */
|
||||
export async function extractCertificateInfo(cert, der) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* Crypto engine backed by webcrypto-liner for legacy algorithm support.
|
||||
*
|
||||
* webcrypto-liner extends native Web Crypto with algorithms like
|
||||
* DES-EDE3-CBC (3DES) that legacy S/MIME clients (Outlook, Thunderbird)
|
||||
* still emit. Native algorithms pass through to the real implementation;
|
||||
* only the missing ones use the software fallback.
|
||||
*
|
||||
* Additionally, pkijs's CryptoEngine.decryptEncryptedContentInfo only
|
||||
* handles PBES2. Many PKCS#12 files use legacy PBE algorithms; we extend
|
||||
* CryptoEngine to handle those via RFC 7292 Appendix B key derivation +
|
||||
* webcrypto-liner's DES-EDE3-CBC support.
|
||||
*
|
||||
* Ported verbatim (TS → JS) from the host's lib/smime/crypto-engine.ts so
|
||||
* the plugin produces byte-identical CMS to the former native pipeline.
|
||||
*/
|
||||
|
||||
import * as asn1js from 'asn1js';
|
||||
import * as pkijs from 'pkijs';
|
||||
// Import the ES build directly: the package "browser" field points at a
|
||||
// shim-only build with no named exports (no setCrypto/Crypto).
|
||||
import * as liner from 'webcrypto-liner/build/index.es.js';
|
||||
|
||||
// ── PKCS#12 legacy PBE OIDs ──────────────────────────────────────────
|
||||
const PBE_SHA1_3DES_3KEY = '1.2.840.113549.1.12.1.3';
|
||||
const PBE_SHA1_3DES_2KEY = '1.2.840.113549.1.12.1.4';
|
||||
const PBE_SHA1_RC2_128 = '1.2.840.113549.1.12.1.5';
|
||||
const PBE_SHA1_RC2_40 = '1.2.840.113549.1.12.1.6';
|
||||
|
||||
const LEGACY_PBE_OIDS = new Set([
|
||||
PBE_SHA1_3DES_3KEY,
|
||||
PBE_SHA1_3DES_2KEY,
|
||||
PBE_SHA1_RC2_128,
|
||||
PBE_SHA1_RC2_40,
|
||||
]);
|
||||
|
||||
function pbeConfig(oid) {
|
||||
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. */
|
||||
async function pkcs12KDF(password, salt, iterations, id, needed) {
|
||||
const v = 64; // SHA-1 block size
|
||||
const u = 20; // SHA-1 output size
|
||||
|
||||
const D = new Uint8Array(v);
|
||||
D.fill(id);
|
||||
|
||||
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];
|
||||
|
||||
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];
|
||||
|
||||
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++) {
|
||||
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) {
|
||||
const B = new Uint8Array(v);
|
||||
for (let j = 0; j < v; j++) B[j] = A[j % u];
|
||||
|
||||
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) {
|
||||
const passView = new Uint8Array(password);
|
||||
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];
|
||||
}
|
||||
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';
|
||||
const OID_DES_CBC = '1.3.14.3.2.7';
|
||||
const OID_RC2_CBC = '1.2.840.113549.3.2';
|
||||
|
||||
class Pkcs12CryptoEngine extends pkijs.CryptoEngine {
|
||||
getAlgorithmByOID(oid, safety, target) {
|
||||
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, safety, target) {
|
||||
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) {
|
||||
const oid = parameters.encryptedContentInfo.contentEncryptionAlgorithm.algorithmId;
|
||||
|
||||
if (!LEGACY_PBE_OIDS.has(oid)) {
|
||||
return super.decryptEncryptedContentInfo(parameters);
|
||||
}
|
||||
|
||||
const algParams = parameters.encryptedContentInfo.contentEncryptionAlgorithm.algorithmParams;
|
||||
if (!algParams) throw new Error('Missing PBE algorithm parameters');
|
||||
|
||||
const paramAsn1 = asn1js.fromBER(algParams.toBER(false));
|
||||
if (paramAsn1.offset === -1) throw new Error('Invalid PBE parameters ASN.1');
|
||||
const seq = paramAsn1.result;
|
||||
const salt = new Uint8Array(seq.valueBlock.value[0].valueBlock.valueHexView);
|
||||
const iterations = seq.valueBlock.value[1].valueBlock.valueDec;
|
||||
|
||||
const { keyLen, ivLen, algName } = pbeConfig(oid);
|
||||
const bmpPassword = passwordToBMP(parameters.password);
|
||||
|
||||
const keyBytes = await pkcs12KDF(bmpPassword, salt, iterations, 1, keyLen);
|
||||
const ivBytes = await pkcs12KDF(bmpPassword, salt, iterations, 2, ivLen);
|
||||
|
||||
const keyData = new Uint8Array(keyBytes.buffer, keyBytes.byteOffset, keyBytes.byteLength);
|
||||
const cryptoKey = await this.importKey(
|
||||
'raw',
|
||||
keyData,
|
||||
{ name: algName, length: keyLen * 8 },
|
||||
false,
|
||||
['decrypt'],
|
||||
);
|
||||
|
||||
const ciphertext = parameters.encryptedContentInfo.getEncryptedContent();
|
||||
return this.decrypt({ name: algName, iv: ivBytes }, cryptoKey, ciphertext);
|
||||
}
|
||||
}
|
||||
|
||||
let linerEngine = null;
|
||||
let linerCryptoInstance = null;
|
||||
|
||||
function ensureLiner() {
|
||||
if (!linerCryptoInstance) {
|
||||
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',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** PKI.js CryptoEngine with 3DES (and other legacy algorithm) support. */
|
||||
export function getLinerCryptoEngine() {
|
||||
ensureLiner();
|
||||
return linerEngine;
|
||||
}
|
||||
|
||||
/** The webcrypto-liner Crypto instance (for importKey with legacy algorithms). */
|
||||
export function getLinerCrypto() {
|
||||
ensureLiner();
|
||||
return linerCryptoInstance;
|
||||
}
|
||||
|
||||
/** Run fn with the global PKI.js engine set to webcrypto-liner, then restore. */
|
||||
export async function withLinerEngine(fn) {
|
||||
ensureLiner();
|
||||
const prev = pkijs.getEngine();
|
||||
pkijs.setEngine('webcrypto-liner', linerCryptoInstance, linerEngine);
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
pkijs.setEngine(prev.name, prev.crypto);
|
||||
}
|
||||
}
|
||||
|
||||
/** A plain native-WebCrypto pkijs engine for sign/verify/encrypt fast paths. */
|
||||
export function nativeEngine() {
|
||||
return new pkijs.CryptoEngine({ crypto, subtle: crypto.subtle, name: 'webcrypto' });
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* IndexedDB persistence for the S/MIME plugin.
|
||||
*
|
||||
* The privileged plugin runs in a same-origin iframe, so all of its iframes
|
||||
* (the hidden background instance that runs hooks + each visible slot) share
|
||||
* one IndexedDB. That's what lets the settings slot unlock a key and the
|
||||
* background send/receive hooks immediately use it.
|
||||
*
|
||||
* Three stores:
|
||||
* - key-records: encrypted-at-rest private keys + certs (durable)
|
||||
* - public-certs: recipient/contact public certificates (durable)
|
||||
* - session-keys: unlocked, NON-EXTRACTABLE CryptoKeys (session-scoped;
|
||||
* wiped on activate() at app boot and on logout)
|
||||
*
|
||||
* CryptoKey objects are structured-cloneable, so IndexedDB can persist the
|
||||
* unlocked handles without ever exposing the raw key material — a
|
||||
* non-extractable key stays non-extractable when read back.
|
||||
*/
|
||||
|
||||
const DB_NAME = 'smime-plugin-store';
|
||||
const DB_VERSION = 1;
|
||||
const KEY_RECORDS_STORE = 'key-records';
|
||||
const PUBLIC_CERTS_STORE = 'public-certs';
|
||||
const SESSION_KEYS_STORE = 'session-keys';
|
||||
|
||||
function openDB() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(KEY_RECORDS_STORE)) {
|
||||
const keyStore = db.createObjectStore(KEY_RECORDS_STORE, { keyPath: 'id' });
|
||||
keyStore.createIndex('email', 'email', { unique: false });
|
||||
keyStore.createIndex('accountId', 'accountId', { unique: false });
|
||||
}
|
||||
if (!db.objectStoreNames.contains(PUBLIC_CERTS_STORE)) {
|
||||
const certStore = db.createObjectStore(PUBLIC_CERTS_STORE, { keyPath: 'id' });
|
||||
certStore.createIndex('email', 'email', { unique: false });
|
||||
certStore.createIndex('accountId', 'accountId', { unique: false });
|
||||
}
|
||||
if (!db.objectStoreNames.contains(SESSION_KEYS_STORE)) {
|
||||
db.createObjectStore(SESSION_KEYS_STORE, { keyPath: 'id' });
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
function txPromise(db, storeName, mode, fn) {
|
||||
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) {
|
||||
const db = await openDB();
|
||||
await txPromise(db, KEY_RECORDS_STORE, 'readwrite', (s) => s.put(record));
|
||||
}
|
||||
|
||||
export async function getKeyRecord(id) {
|
||||
const db = await openDB();
|
||||
return txPromise(db, KEY_RECORDS_STORE, 'readonly', (s) => s.get(id));
|
||||
}
|
||||
|
||||
export async function listKeyRecords(accountId) {
|
||||
const db = await openDB();
|
||||
const all = await txPromise(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) {
|
||||
const db = await openDB();
|
||||
await txPromise(db, KEY_RECORDS_STORE, 'readwrite', (s) => s.delete(id));
|
||||
}
|
||||
|
||||
// ── Public cert CRUD ────────────────────────────────────────────────
|
||||
|
||||
export async function savePublicCert(cert) {
|
||||
const db = await openDB();
|
||||
await txPromise(db, PUBLIC_CERTS_STORE, 'readwrite', (s) => s.put(cert));
|
||||
}
|
||||
|
||||
export async function listPublicCerts(accountId) {
|
||||
const db = await openDB();
|
||||
const all = await txPromise(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) {
|
||||
const db = await openDB();
|
||||
await txPromise(db, PUBLIC_CERTS_STORE, 'readwrite', (s) => s.delete(id));
|
||||
}
|
||||
|
||||
// ── Session (unlocked) key CRUD ─────────────────────────────────────
|
||||
// Each entry: { id, signingKey, decryptionKey?, legacyDecryptionKey? }
|
||||
|
||||
export async function saveSessionKeys(entry) {
|
||||
const db = await openDB();
|
||||
await txPromise(db, SESSION_KEYS_STORE, 'readwrite', (s) => s.put(entry));
|
||||
}
|
||||
|
||||
export async function getSessionKeys(id) {
|
||||
const db = await openDB();
|
||||
return txPromise(db, SESSION_KEYS_STORE, 'readonly', (s) => s.get(id));
|
||||
}
|
||||
|
||||
export async function listSessionKeyIds() {
|
||||
const db = await openDB();
|
||||
const all = await txPromise(db, SESSION_KEYS_STORE, 'readonly', (s) => s.getAllKeys());
|
||||
return all;
|
||||
}
|
||||
|
||||
export async function deleteSessionKeys(id) {
|
||||
const db = await openDB();
|
||||
await txPromise(db, SESSION_KEYS_STORE, 'readwrite', (s) => s.delete(id));
|
||||
}
|
||||
|
||||
export async function clearSessionKeys() {
|
||||
const db = await openDB();
|
||||
await txPromise(db, SESSION_KEYS_STORE, 'readwrite', (s) => s.clear());
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* Minimal, deterministic MIME builder for outgoing S/MIME messages.
|
||||
* Ported from lib/smime/mime-builder.ts. All line endings are CRLF.
|
||||
*/
|
||||
|
||||
import { generateUUID } from './util.js';
|
||||
|
||||
const CRLF = '\r\n';
|
||||
|
||||
/** Build a complete MIME message and return it as a Uint8Array (UTF-8). */
|
||||
export function buildMimeMessage(input) {
|
||||
const boundary = generateBoundary();
|
||||
const lines = [];
|
||||
|
||||
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');
|
||||
|
||||
const hasText = !!input.textBody;
|
||||
const hasHtml = !!input.htmlBody;
|
||||
const hasAttachments = !!input.attachments?.length;
|
||||
|
||||
if (!hasAttachments && hasText && !hasHtml) {
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
lines.push(`Content-Type: multipart/mixed; boundary="${boundary}"`);
|
||||
lines.push('');
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
for (const att of input.attachments) {
|
||||
lines.push(`--${boundary}`);
|
||||
const disposition = att.cid ? 'inline' : 'attachment';
|
||||
// VNC: these two lines are assembled directly rather than via
|
||||
// formatHeader, so stripCrlf has to be applied explicitly. Both carry
|
||||
// inbound values when forwarding a message (the original part's
|
||||
// Content-Type and inline-image Content-ID), so both are attacker-
|
||||
// reachable. `filename` is already neutralised by encodeHeaderValue.
|
||||
lines.push(`Content-Type: ${stripCrlf(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: <${stripCrlf(att.cid)}>`);
|
||||
lines.push('');
|
||||
lines.push(base64Encode(att.content));
|
||||
}
|
||||
lines.push(`--${boundary}--`);
|
||||
} else {
|
||||
lines.push('Content-Type: text/plain; charset=utf-8');
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return new TextEncoder().encode(lines.join(CRLF));
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function generateBoundary() {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
||||
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||
return `----=_Part_${hex}`;
|
||||
}
|
||||
|
||||
function formatAddress(addr) {
|
||||
if (addr.name) {
|
||||
const escaped = addr.name.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
return `"${escaped}" <${addr.email}>`;
|
||||
}
|
||||
return addr.email;
|
||||
}
|
||||
|
||||
// VNC: strip CR/LF from any header value before it reaches the header block.
|
||||
//
|
||||
// Upstream relied on `encodeHeaderValue`, whose Q-encoding neutralises CR/LF as
|
||||
// a side effect — but it was only applied to Subject and attachment filename.
|
||||
// Display names, raw addresses, Message-ID, In-Reply-To, References and
|
||||
// attachment Content-Type all reached `formatHeader` unfiltered, and
|
||||
// `formatAddress` escapes only backslash and quote. `formatHeader` folds long
|
||||
// lines but never sanitises, so an embedded CRLF was emitted verbatim and became
|
||||
// an injected header.
|
||||
//
|
||||
// That is remotely reachable: In-Reply-To, References and display names are
|
||||
// copied from an inbound message when replying or forwarding, so the value is
|
||||
// attacker-supplied.
|
||||
//
|
||||
// Sanitising here rather than at the call sites means every header is covered by
|
||||
// construction — a future header can't reintroduce the hole by forgetting to
|
||||
// wrap its value. Folding still inserts legitimate CRLF afterwards; only CR/LF
|
||||
// arriving *inside* a value is collapsed.
|
||||
function stripCrlf(value) {
|
||||
const s = String(value);
|
||||
// Fold whitespace runs containing CR/LF into a single space: a header value
|
||||
// cannot legally contain a bare line break, and preserving the surrounding
|
||||
// text is friendlier than truncating at the first one.
|
||||
const clean = s.replace(/[\r\n]+[ \t]*/g, ' ');
|
||||
if (clean !== s) {
|
||||
// Loud, because this means something upstream handed us a header value it
|
||||
// should have rejected. Worth seeing in a console during QA.
|
||||
console.warn('[smime] stripped CR/LF from header value');
|
||||
}
|
||||
return clean;
|
||||
}
|
||||
|
||||
function formatHeader(name, rawValue) {
|
||||
const value = stripCrlf(rawValue);
|
||||
const full = `${name}: ${value}`;
|
||||
if (full.length <= 76) return full;
|
||||
const parts = [];
|
||||
let remaining = full;
|
||||
let first = true;
|
||||
while (remaining.length > 76) {
|
||||
let breakAt = 76;
|
||||
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) {
|
||||
if (/^[\x20-\x7e]*$/.test(value)) return value;
|
||||
const encoded = Array.from(new TextEncoder().encode(value))
|
||||
.map((b) => {
|
||||
if ((b >= 0x30 && b <= 0x39) || (b >= 0x41 && b <= 0x5a) || (b >= 0x61 && b <= 0x7a)) {
|
||||
return String.fromCharCode(b);
|
||||
}
|
||||
return '=' + b.toString(16).toUpperCase().padStart(2, '0');
|
||||
})
|
||||
.join('');
|
||||
return `=?UTF-8?Q?${encoded}?=`;
|
||||
}
|
||||
|
||||
function formatDate(date) {
|
||||
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 = String(date.getUTCHours()).padStart(2, '0');
|
||||
const mm = String(date.getUTCMinutes()).padStart(2, '0');
|
||||
const ss = String(date.getUTCSeconds()).padStart(2, '0');
|
||||
return `${d}, ${dd} ${m} ${y} ${hh}:${mm}:${ss} +0000`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a CMS binary blob in a proper RFC 5322 / S/MIME message.
|
||||
* Returns a Blob of type message/rfc822.
|
||||
*/
|
||||
export function wrapCmsAsSmimeMessage(cmsBlob, input) {
|
||||
const lines = [];
|
||||
|
||||
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');
|
||||
// VNC: smimeType is plugin-supplied ('signed-data' / 'enveloped-data') rather
|
||||
// than message-derived, so this is belt-and-braces — but sanitising every
|
||||
// interpolated header value unconditionally is what makes the rule checkable
|
||||
// (see verify-fixes.mjs) instead of resting on a per-case judgement call.
|
||||
lines.push(`Content-Type: application/pkcs7-mime; smime-type=${stripCrlf(input.smimeType)}; name="smime.p7m"`);
|
||||
lines.push('Content-Transfer-Encoding: base64');
|
||||
lines.push('Content-Disposition: attachment; filename="smime.p7m"');
|
||||
|
||||
// Terminate the header block with a BLANK LINE (CRLFCRLF) before the base64
|
||||
// body. The body is concatenated as a separate Blob below, so a trailing ''
|
||||
// in `lines` only yields a single CRLF — gluing the CMS onto the last header.
|
||||
// A strict parser (Stalwart/mail-parser) then reads the base64 as malformed
|
||||
// headers and leaves the pkcs7-mime part empty, which surfaces on the
|
||||
// receiving side as "Invalid ASN.1 data - cannot parse CMS envelope".
|
||||
const headerBytes = new TextEncoder().encode(lines.join(CRLF) + CRLF + CRLF);
|
||||
return new Blob([headerBytes, cmsToBase64Blob(cmsBlob)], { type: 'message/rfc822' });
|
||||
}
|
||||
|
||||
function cmsToBase64Blob(data) {
|
||||
let bytes;
|
||||
if (data instanceof Uint8Array) bytes = data;
|
||||
else if (data instanceof ArrayBuffer) bytes = new Uint8Array(data);
|
||||
else bytes = new Uint8Array(0);
|
||||
const b64 = base64Encode(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength));
|
||||
return new Blob([new TextEncoder().encode(b64 + CRLF)]);
|
||||
}
|
||||
|
||||
/** Encode string as quoted-printable (RFC 2045). */
|
||||
export function quotedPrintableEncode(input) {
|
||||
const bytes = new TextEncoder().encode(input);
|
||||
const lines = [];
|
||||
let line = '';
|
||||
|
||||
for (const b of bytes) {
|
||||
let encoded;
|
||||
if (b === 0x0d || b === 0x0a) {
|
||||
encoded = String.fromCharCode(b);
|
||||
} else if (b === 0x09 || (b >= 0x20 && b <= 0x7e && b !== 0x3d)) {
|
||||
encoded = String.fromCharCode(b);
|
||||
} else {
|
||||
encoded = '=' + b.toString(16).toUpperCase().padStart(2, '0');
|
||||
}
|
||||
|
||||
if (b === 0x0a) {
|
||||
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) {
|
||||
const bytes = new Uint8Array(data);
|
||||
let binary = '';
|
||||
for (const b of bytes) binary += String.fromCharCode(b);
|
||||
const b64 = btoa(binary);
|
||||
const lines = [];
|
||||
for (let i = 0; i < b64.length; i += 76) lines.push(b64.slice(i, i + 76));
|
||||
return lines.join(CRLF);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Minimal RFC 5322 / MIME parser used only for the inner content recovered
|
||||
* after decryption / signature-stripping. We need just enough to pull out the
|
||||
* best-alternative text/html body and any leaf attachments; the host
|
||||
* re-sanitizes returned HTML, so this never has to be a hardened renderer.
|
||||
*/
|
||||
|
||||
const decoder = new TextDecoder('utf-8', { fatal: false });
|
||||
|
||||
/** Parse raw inner MIME bytes into { html, text, attachments }. */
|
||||
export function parseMime(bytes) {
|
||||
const text = binaryString(bytes);
|
||||
const node = parseEntity(text);
|
||||
const out = { html: '', text: '', attachments: [] };
|
||||
collect(node, out);
|
||||
// Fallback for non-MIME inner content (e.g. messages signed/encrypted by
|
||||
// OpenSSL or older clients where the protected payload is raw text with no
|
||||
// Content-Type). If structured parsing produced no renderable body, surface
|
||||
// the decoded bytes as plain text so the message is never shown blank.
|
||||
if (!out.html && !out.text) {
|
||||
const raw = decoder.decode(bytes).trim();
|
||||
if (raw) out.text = raw;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Treat bytes as latin1 so byte boundaries survive; decode per-part by charset.
|
||||
function binaryString(bytes) {
|
||||
let s = '';
|
||||
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
|
||||
return s;
|
||||
}
|
||||
|
||||
function parseEntity(raw) {
|
||||
const sepMatch = raw.match(/\r?\n\r?\n/);
|
||||
const headerText = sepMatch ? raw.slice(0, sepMatch.index) : raw;
|
||||
const body = sepMatch ? raw.slice(sepMatch.index + sepMatch[0].length) : '';
|
||||
|
||||
const headers = parseHeaders(headerText);
|
||||
const ctRaw = headers['content-type'] || 'text/plain';
|
||||
const { type, params } = parseContentType(ctRaw);
|
||||
const cte = (headers['content-transfer-encoding'] || '7bit').trim().toLowerCase();
|
||||
const disposition = (headers['content-disposition'] || '').toLowerCase();
|
||||
|
||||
const node = { type, params, cte, disposition, headers, body, children: [] };
|
||||
|
||||
if (type.startsWith('multipart/') && params.boundary) {
|
||||
node.children = splitMultipart(body, params.boundary).map(parseEntity);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
function parseHeaders(headerText) {
|
||||
const unfolded = headerText.replace(/\r?\n[ \t]+/g, ' ');
|
||||
const headers = {};
|
||||
for (const line of unfolded.split(/\r?\n/)) {
|
||||
const idx = line.indexOf(':');
|
||||
if (idx <= 0) continue;
|
||||
const name = line.slice(0, idx).trim().toLowerCase();
|
||||
const value = line.slice(idx + 1).trim();
|
||||
headers[name] = headers[name] ? `${headers[name]}, ${value}` : value;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
function parseContentType(value) {
|
||||
const parts = value.split(';');
|
||||
const type = parts[0].trim().toLowerCase();
|
||||
const params = {};
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
const eq = parts[i].indexOf('=');
|
||||
if (eq < 0) continue;
|
||||
const k = parts[i].slice(0, eq).trim().toLowerCase();
|
||||
let v = parts[i].slice(eq + 1).trim();
|
||||
if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1);
|
||||
params[k] = v;
|
||||
}
|
||||
return { type, params };
|
||||
}
|
||||
|
||||
function splitMultipart(body, boundary) {
|
||||
const delim = `--${boundary}`;
|
||||
const parts = [];
|
||||
const segments = body.split(delim);
|
||||
for (let i = 1; i < segments.length; i++) {
|
||||
let seg = segments[i];
|
||||
if (seg.startsWith('--')) break; // closing delimiter
|
||||
seg = seg.replace(/^\r?\n/, '').replace(/\r?\n$/, '');
|
||||
parts.push(seg);
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
function decodeBody(node) {
|
||||
const { cte, body } = node;
|
||||
if (cte === 'base64') {
|
||||
const cleaned = body.replace(/[^A-Za-z0-9+/=]/g, '');
|
||||
try {
|
||||
const bin = atob(cleaned);
|
||||
const bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||
return bytes;
|
||||
} catch {
|
||||
return new Uint8Array(0);
|
||||
}
|
||||
}
|
||||
if (cte === 'quoted-printable') {
|
||||
return qpDecode(body);
|
||||
}
|
||||
// 7bit / 8bit / binary — body is a latin1 binary string
|
||||
const bytes = new Uint8Array(body.length);
|
||||
for (let i = 0; i < body.length; i++) bytes[i] = body.charCodeAt(i) & 0xff;
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function qpDecode(input) {
|
||||
const out = [];
|
||||
const cleaned = input.replace(/=\r?\n/g, ''); // soft line breaks
|
||||
for (let i = 0; i < cleaned.length; i++) {
|
||||
const c = cleaned[i];
|
||||
if (c === '=' && i + 2 < cleaned.length) {
|
||||
const hex = cleaned.substr(i + 1, 2);
|
||||
if (/^[0-9A-Fa-f]{2}$/.test(hex)) {
|
||||
out.push(parseInt(hex, 16));
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(cleaned.charCodeAt(i) & 0xff);
|
||||
}
|
||||
return new Uint8Array(out);
|
||||
}
|
||||
|
||||
function decodeText(node) {
|
||||
const bytes = decodeBody(node);
|
||||
const charset = (node.params.charset || 'utf-8').toLowerCase();
|
||||
try {
|
||||
return new TextDecoder(charset, { fatal: false }).decode(bytes);
|
||||
} catch {
|
||||
return decoder.decode(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
function filenameFor(node) {
|
||||
const cd = node.headers['content-disposition'] || '';
|
||||
const m = cd.match(/filename\*?=(?:"([^"]+)"|([^;]+))/i);
|
||||
if (m) return (m[1] || m[2] || '').trim();
|
||||
if (node.params.name) return node.params.name;
|
||||
return 'attachment';
|
||||
}
|
||||
|
||||
function collect(node, out) {
|
||||
const { type, disposition } = node;
|
||||
const isAttachment = disposition.includes('attachment') ||
|
||||
(!type.startsWith('text/') && !type.startsWith('multipart/'));
|
||||
|
||||
if (type.startsWith('multipart/')) {
|
||||
if (type === 'multipart/alternative') {
|
||||
// Prefer the richest alternative; collect text+html, last wins per type.
|
||||
for (const child of node.children) collect(child, out);
|
||||
} else {
|
||||
for (const child of node.children) collect(child, out);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'text/html' && !isAttachment) {
|
||||
out.html = decodeText(node);
|
||||
return;
|
||||
}
|
||||
if (type === 'text/plain' && !isAttachment) {
|
||||
out.text = decodeText(node);
|
||||
return;
|
||||
}
|
||||
|
||||
// Leaf attachment
|
||||
const bytes = decodeBody(node);
|
||||
out.attachments.push({
|
||||
name: filenameFor(node),
|
||||
type: type || 'application/octet-stream',
|
||||
size: bytes.length,
|
||||
dataUrl: bytesToDataUrl(bytes, type || 'application/octet-stream'),
|
||||
});
|
||||
}
|
||||
|
||||
function bytesToDataUrl(bytes, type) {
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
||||
return `data:${type};base64,${btoa(binary)}`;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// Browser shim for the Node "crypto" builtin that webcrypto-liner's dependency
|
||||
// (asmcrypto.js) references in a `typeof process !== 'undefined'` branch that
|
||||
// never executes in a browser iframe. Provides a working randomBytes anyway so
|
||||
// the bundle is correct even if that path is somehow reached.
|
||||
|
||||
export function randomBytes(n) {
|
||||
const b = new Uint8Array(n);
|
||||
(globalThis.crypto || globalThis.self?.crypto).getRandomValues(b);
|
||||
return b;
|
||||
}
|
||||
|
||||
export default { randomBytes };
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* PKCS#12 (.p12/.pfx) import + private-key encryption-at-rest / unlock.
|
||||
* Ported from lib/smime/pkcs12-import.ts.
|
||||
*
|
||||
* Private keys are wrapped with AES-GCM under a PBKDF2(600k, SHA-256) key
|
||||
* derived from a user passphrase. Unlocked keys are imported NON-EXTRACTABLE.
|
||||
*/
|
||||
|
||||
import * as asn1js from 'asn1js';
|
||||
import * as pkijs from 'pkijs';
|
||||
import { generateUUID } from './util.js';
|
||||
import { extractCertificateInfo, classifyCapabilities } from './certificate-utils.js';
|
||||
import { withLinerEngine, getLinerCrypto } from './crypto-engine.js';
|
||||
|
||||
const KDF_ITERATIONS = 600_000;
|
||||
const AES_KEY_LENGTH = 256;
|
||||
|
||||
function stringToAB(str) {
|
||||
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 file and produce an encrypted-at-rest key record. */
|
||||
export async function importPkcs12(p12Bytes, p12Passphrase, storagePassphrase) {
|
||||
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 });
|
||||
|
||||
await withLinerEngine(async () => {
|
||||
await pfx.parseInternalValues({ password: stringToAB(p12Passphrase) });
|
||||
});
|
||||
|
||||
let leafCertDer = null;
|
||||
let leafCert = null;
|
||||
const chainCertsDer = [];
|
||||
let privateKeyInfo = null;
|
||||
|
||||
if (!pfx.parsedValue?.authenticatedSafe) {
|
||||
throw new Error('PKCS#12 file does not contain an authenticated safe');
|
||||
}
|
||||
|
||||
const authSafe = pfx.parsedValue.authenticatedSafe;
|
||||
const safeContentsParams = authSafe.safeContents.map((ci) =>
|
||||
ci.contentType === '1.2.840.113549.1.7.6' ? { password: stringToAB(p12Passphrase) } : {},
|
||||
);
|
||||
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) {
|
||||
switch (safeBag.bagId) {
|
||||
case '1.2.840.113549.1.12.10.1.3': { // CertBag
|
||||
const certBag = safeBag.bagValue;
|
||||
let cert = null;
|
||||
let der = 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) {
|
||||
const certDerBytes = certBag.certValue.valueBlock.valueHexView;
|
||||
const certAsn1 = asn1js.fromBER(certDerBytes);
|
||||
if (certAsn1.offset !== -1) {
|
||||
cert = new pkijs.Certificate({ schema: certAsn1.result });
|
||||
der = new Uint8Array(certDerBytes).buffer;
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
privateKeyInfo = safeBag.bagValue;
|
||||
break;
|
||||
}
|
||||
case '1.2.840.113549.1.12.10.1.2': { // PKCS8ShroudedKeyBag (encrypted)
|
||||
const shroudedBag = safeBag.bagValue;
|
||||
if (shroudedBag.parsedValue) {
|
||||
privateKeyInfo = shroudedBag.parsedValue;
|
||||
} else {
|
||||
await withLinerEngine(async () => {
|
||||
await shroudedBag.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');
|
||||
|
||||
const pkcs8Bytes = privateKeyInfo.toSchema().toBER(false);
|
||||
const { encrypted, salt, iv } = await encryptPrivateKey(pkcs8Bytes, storagePassphrase);
|
||||
|
||||
const certInfo = await extractCertificateInfo(leafCert, leafCertDer);
|
||||
const capabilities = classifyCapabilities(leafCert);
|
||||
const email = certInfo.emailAddresses[0] ?? '';
|
||||
|
||||
const keyRecord = {
|
||||
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, salt, iterations) {
|
||||
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, passphrase) {
|
||||
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 };
|
||||
}
|
||||
|
||||
function ecdsaCurveFromAlg(alg) {
|
||||
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';
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt stored PKCS#8 bytes and import as non-extractable CryptoKeys.
|
||||
* @returns { signingKey, decryptionKey?, legacyDecryptionKey? }
|
||||
*/
|
||||
export async function unlockPrivateKey(record, passphrase) {
|
||||
const wrappingKey = await deriveWrappingKey(passphrase, record.salt, record.kdfIterations);
|
||||
|
||||
let pkcs8Bytes;
|
||||
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 = isEcdsa ? ['deriveBits'] : ['decrypt'];
|
||||
|
||||
let signingKey;
|
||||
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;
|
||||
if (!isEcdsa) {
|
||||
try {
|
||||
legacyDecryptionKey = await getLinerCrypto().subtle.importKey(
|
||||
'pkcs8', pkcs8Bytes, { name: 'RSAES-PKCS1-v1_5' }, false, ['decrypt'],
|
||||
);
|
||||
} catch { /* liner unavailable */ }
|
||||
}
|
||||
return { signingKey: decryptionKey, decryptionKey, legacyDecryptionKey };
|
||||
}
|
||||
|
||||
let decryptionKey;
|
||||
try {
|
||||
decryptionKey = await crypto.subtle.importKey('pkcs8', pkcs8Bytes, decryptAlg, false, decryptUsages);
|
||||
} catch { /* signing-only cert */ }
|
||||
|
||||
let legacyDecryptionKey;
|
||||
if (!isEcdsa) {
|
||||
try {
|
||||
legacyDecryptionKey = await getLinerCrypto().subtle.importKey(
|
||||
'pkcs8', pkcs8Bytes, { name: 'RSAES-PKCS1-v1_5' }, false, ['decrypt'],
|
||||
);
|
||||
} catch { /* liner unavailable */ }
|
||||
}
|
||||
|
||||
return { signingKey, decryptionKey, legacyDecryptionKey };
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* Decrypt CMS EnvelopedData to recover the inner MIME content.
|
||||
* Supports issuerAndSerialNumber and subjectKeyIdentifier recipient IDs.
|
||||
* Ported from lib/smime/smime-decrypt.ts (Buffer → toHex).
|
||||
*/
|
||||
|
||||
import * as pkijs from 'pkijs';
|
||||
import * as asn1js from 'asn1js';
|
||||
import { getLinerCryptoEngine, withLinerEngine } from './crypto-engine.js';
|
||||
import { arraysEqual, toHex } from './util.js';
|
||||
|
||||
export class SmimeKeyLockedError extends Error {
|
||||
constructor(message, keyRecordId) {
|
||||
super(message);
|
||||
this.name = 'SmimeKeyLockedError';
|
||||
this.keyRecordId = keyRecordId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to decrypt CMS EnvelopedData.
|
||||
* @param input { cmsBytes, keyRecords, unlockedKeys: Map, legacyUnlockedKeys?: Map }
|
||||
* @returns { mimeBytes: Uint8Array, keyRecordId: string }
|
||||
*/
|
||||
export async function smimeDecrypt(input) {
|
||||
const { cmsBytes, keyRecords, unlockedKeys, legacyUnlockedKeys } = input;
|
||||
|
||||
const contentInfo = parseContentInfo(cmsBytes);
|
||||
const envelopedData = extractEnvelopedData(contentInfo);
|
||||
|
||||
const matchedRecords = findMatchingKeyRecords(envelopedData, keyRecords);
|
||||
if (matchedRecords.length === 0) {
|
||||
throw new Error('No imported S/MIME key matches any recipient in this encrypted message');
|
||||
}
|
||||
|
||||
for (const { keyRecord, recipientIndex } of matchedRecords) {
|
||||
const privateKey = unlockedKeys.get(keyRecord.id);
|
||||
if (!privateKey) {
|
||||
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;
|
||||
}
|
||||
|
||||
try {
|
||||
const decrypted = await decryptWithKey(envelopedData, recipientIndex, privateKey, keyRecord);
|
||||
return { mimeBytes: new Uint8Array(decrypted), keyRecordId: keyRecord.id };
|
||||
} catch {
|
||||
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 {
|
||||
/* try next record */
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const isUnlocked = (id) => 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');
|
||||
}
|
||||
|
||||
/** Key record IDs that could potentially decrypt a message (to prompt unlock). */
|
||||
export function findDecryptionCandidates(cmsBytes, keyRecords) {
|
||||
try {
|
||||
const contentInfo = parseContentInfo(cmsBytes);
|
||||
const envelopedData = extractEnvelopedData(contentInfo);
|
||||
return findMatchingKeyRecords(envelopedData, keyRecords).map((m) => m.keyRecord.id);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize raw blob bytes into DER-encoded CMS data.
|
||||
* JMAP may return raw DER, base64 DER, a full MIME part, or PEM.
|
||||
*/
|
||||
export function normalizeCmsBytes(raw) {
|
||||
if (raw.byteLength === 0) return raw;
|
||||
|
||||
const bytes = new Uint8Array(raw);
|
||||
if (bytes[0] === 0x30) return raw; // already DER
|
||||
|
||||
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;
|
||||
})();
|
||||
|
||||
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) {
|
||||
text = text.substring(headerEndMatch.index + headerEndMatch[0].length);
|
||||
}
|
||||
|
||||
text = text
|
||||
.replace(/-----BEGIN [A-Z0-9 ]+-----/g, '')
|
||||
.replace(/-----END [A-Z0-9 ]+-----/g, '')
|
||||
.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;
|
||||
} catch { /* not DER, continue */ }
|
||||
|
||||
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 = [];
|
||||
let sectionMatch;
|
||||
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;
|
||||
} catch { /* next section */ }
|
||||
}
|
||||
|
||||
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;
|
||||
} catch { /* next block */ }
|
||||
}
|
||||
}
|
||||
|
||||
return raw;
|
||||
}
|
||||
|
||||
function parseContentInfo(der) {
|
||||
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) {
|
||||
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 });
|
||||
}
|
||||
|
||||
function findMatchingKeyRecords(envelopedData, keyRecords) {
|
||||
const matches = [];
|
||||
|
||||
for (let i = 0; i < envelopedData.recipientInfos.length; i++) {
|
||||
const ri = envelopedData.recipientInfos[i];
|
||||
|
||||
const ktri = ri instanceof pkijs.KeyTransRecipientInfo
|
||||
? ri
|
||||
: ri.variant === 1 && ri.value instanceof pkijs.KeyTransRecipientInfo
|
||||
? ri.value
|
||||
: null;
|
||||
|
||||
if (ktri) {
|
||||
for (const keyRecord of keyRecords) {
|
||||
if (matchesKeyTransRecipient(ktri, keyRecord)) {
|
||||
matches.push({ keyRecord, recipientIndex: i });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
function matchesKeyTransRecipient(recipientInfo, keyRecord) {
|
||||
const rid = recipientInfo.rid;
|
||||
|
||||
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 });
|
||||
|
||||
const ridSerial = toHex(rid.serialNumber.valueBlock.valueHexView);
|
||||
const certSerial = toHex(cert.serialNumber.valueBlock.valueHexView);
|
||||
if (ridSerial !== certSerial) return false;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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 });
|
||||
|
||||
const skiExt = cert.extensions?.find((ext) => ext.extnID === '2.5.29.14');
|
||||
if (!skiExt) return false;
|
||||
|
||||
const skiValue = asn1js.fromBER(skiExt.extnValue.valueBlock.valueHexView);
|
||||
if (skiValue.offset === -1) return false;
|
||||
const ski = skiValue.result.valueBlock.valueHexView;
|
||||
|
||||
return arraysEqual(new Uint8Array(ski), new Uint8Array(rid.valueBlock.valueHexView));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function decryptWithKey(envelopedData, recipientIndex, privateKey, keyRecord) {
|
||||
const certAsn1 = asn1js.fromBER(keyRecord.certificate);
|
||||
const cert = new pkijs.Certificate({ schema: certAsn1.result });
|
||||
|
||||
return withLinerEngine(async () => {
|
||||
const cryptoEngine = getLinerCryptoEngine();
|
||||
return envelopedData.decrypt(
|
||||
recipientIndex,
|
||||
{ recipientCertificate: cert, recipientPrivateKey: privateKey },
|
||||
cryptoEngine,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Detect S/MIME content in an email message. Ported from lib/smime/smime-detect.ts.
|
||||
* Checks Content-Type, JMAP bodyStructure, and attachment metadata.
|
||||
*/
|
||||
|
||||
export function detectSmime(contentType, bodyStructure, attachments) {
|
||||
const noResult = { type: null, supported: false };
|
||||
|
||||
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 };
|
||||
}
|
||||
const part = findCmsPart(bodyStructure, null);
|
||||
if (part) {
|
||||
const partType = inferSmimeTypeFromContentType(part.type || '');
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
if (bodyStructure) {
|
||||
const result = walkBodyStructure(bodyStructure);
|
||||
if (result) return result;
|
||||
}
|
||||
|
||||
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', 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) {
|
||||
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') {
|
||||
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, _smimeType) {
|
||||
if (!bodyStructure) return null;
|
||||
const type = bodyStructure.type?.toLowerCase() || '';
|
||||
if (type.includes('application/pkcs7-mime') || type.includes('application/x-pkcs7-mime')) {
|
||||
return bodyStructure;
|
||||
}
|
||||
if (bodyStructure.subParts) {
|
||||
for (const sub of bodyStructure.subParts) {
|
||||
const found = findCmsPart(sub, _smimeType);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function inferSmimeTypeFromContentType(ct) {
|
||||
const lower = ct.toLowerCase();
|
||||
if (lower.includes('smime-type=enveloped-data')) return 'enveloped-data';
|
||||
if (lower.includes('smime-type=signed-data')) return 'signed-data';
|
||||
if (lower.includes('application/pkcs7-mime') || lower.includes('application/x-pkcs7-mime')) {
|
||||
return 'enveloped-data';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import * as pkijs from 'pkijs';
|
||||
import { parseCertificateDer } from './certificate-utils.js';
|
||||
import { nativeEngine } from './crypto-engine.js';
|
||||
import { toHex } from './util.js';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Ported from lib/smime/smime-encrypt.ts.
|
||||
*/
|
||||
export async function smimeEncrypt(mimeBytes, recipientCertsDer, senderCertDer, useAes128) {
|
||||
const allCertDers = deduplicateCerts([...recipientCertsDer, senderCertDer]);
|
||||
if (allCertDers.length === 0) throw new Error('No recipient certificates provided');
|
||||
|
||||
const recipientCerts = allCertDers.map((der) => parseCertificateDer(der));
|
||||
const cmsEnveloped = new pkijs.EnvelopedData();
|
||||
|
||||
for (const cert of recipientCerts) {
|
||||
cmsEnveloped.addRecipientByCertificate(cert, { oaepHashAlgorithm: 'SHA-256' }, undefined, nativeEngine());
|
||||
}
|
||||
|
||||
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),
|
||||
nativeEngine(),
|
||||
);
|
||||
|
||||
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' });
|
||||
}
|
||||
|
||||
function deduplicateCerts(certs) {
|
||||
const seen = new Set();
|
||||
const result = [];
|
||||
for (const cert of certs) {
|
||||
const key = toHex(cert);
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
result.push(cert);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import * as asn1js from 'asn1js';
|
||||
import * as pkijs from 'pkijs';
|
||||
import { parseCertificateDer } from './certificate-utils.js';
|
||||
import { nativeEngine } from './crypto-engine.js';
|
||||
|
||||
/**
|
||||
* Produce an opaque CMS SignedData wrapping the given MIME content.
|
||||
* Content type: application/pkcs7-mime; smime-type=signed-data.
|
||||
* Ported from lib/smime/smime-sign.ts.
|
||||
*/
|
||||
export async function smimeSign(mimeBytes, privateKey, signerCertDer, chainCertsDer = []) {
|
||||
const signerCert = parseCertificateDer(signerCertDer);
|
||||
const chainCerts = chainCertsDer.map((der) => parseCertificateDer(der));
|
||||
|
||||
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],
|
||||
});
|
||||
|
||||
const hashAlgorithm = 'SHA-256';
|
||||
await cmsSigned.sign(privateKey, 0, hashAlgorithm, undefined, nativeEngine());
|
||||
|
||||
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' });
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Verify CMS SignedData (opaque signed) and extract the inner content.
|
||||
* Ported from lib/smime/smime-verify.ts.
|
||||
*/
|
||||
|
||||
import * as pkijs from 'pkijs';
|
||||
import * as asn1js from 'asn1js';
|
||||
import { extractCertificateInfo } from './certificate-utils.js';
|
||||
import { nativeEngine } from './crypto-engine.js';
|
||||
import { arraysEqual, toHex } from './util.js';
|
||||
|
||||
/**
|
||||
* Verify a CMS SignedData structure and extract the encapsulated content.
|
||||
* @returns { mimeBytes: Uint8Array, status: SmimeStatus }
|
||||
*/
|
||||
export async function smimeVerify(cmsBytes, fromHeader) {
|
||||
const contentInfo = parseContentInfo(cmsBytes);
|
||||
const signedData = extractSignedData(contentInfo);
|
||||
|
||||
const innerContent = extractInnerContent(signedData);
|
||||
|
||||
const signerCert = extractSignerCertificate(signedData);
|
||||
if (!signerCert) {
|
||||
return {
|
||||
mimeBytes: innerContent,
|
||||
status: {
|
||||
isSigned: true,
|
||||
isEncrypted: false,
|
||||
signatureValid: false,
|
||||
signatureError: 'Signer certificate not found in CMS structure',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let signatureValid = false;
|
||||
let signatureError;
|
||||
|
||||
try {
|
||||
// checkChain:false — validate the signature cryptographically. Trust of the
|
||||
// issuer chain is surfaced separately (selfSigned flag + the banner), rather
|
||||
// than collapsing "untrusted issuer" into "invalid signature". This matches
|
||||
// how most S/MIME clients present results and keeps validly-signed mail from
|
||||
// self-signed or non-bundled CAs from showing a scary "invalid" badge.
|
||||
signatureValid = await signedData.verify({ signer: 0, checkChain: false }, nativeEngine());
|
||||
} catch (err) {
|
||||
signatureError = err instanceof Error ? err.message : 'Signature verification failed';
|
||||
}
|
||||
|
||||
const certDer = signerCert.toSchema(true).toBER(false);
|
||||
const certInfo = await extractCertificateInfo(signerCert, certDer);
|
||||
|
||||
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';
|
||||
|
||||
const signerEmail = certInfo.emailAddresses[0] ?? '';
|
||||
const signerPublicCert = {
|
||||
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',
|
||||
};
|
||||
|
||||
let signerEmailMatch;
|
||||
if (fromHeader && signerEmail) {
|
||||
signerEmailMatch = fromHeader.toLowerCase() === signerEmail.toLowerCase();
|
||||
}
|
||||
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
const eContent = signedData.encapContentInfo?.eContent;
|
||||
if (!eContent) {
|
||||
throw new Error('No encapsulated content in SignedData (detached signature not supported)');
|
||||
}
|
||||
|
||||
if (eContent instanceof asn1js.OctetString) {
|
||||
const children = eContent.valueBlock.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;
|
||||
}
|
||||
return new Uint8Array(eContent.valueBlock.valueHexView);
|
||||
}
|
||||
|
||||
throw new Error('Unable to extract content from SignedData');
|
||||
}
|
||||
|
||||
function extractSignerCertificate(signedData) {
|
||||
if (!signedData.signerInfos?.length || !signedData.certificates?.length) return null;
|
||||
|
||||
const signerInfo = signedData.signerInfos[0];
|
||||
const sid = signerInfo.sid;
|
||||
|
||||
if (sid instanceof pkijs.IssuerAndSerialNumber) {
|
||||
for (const certItem of signedData.certificates) {
|
||||
if (!(certItem instanceof pkijs.Certificate)) continue;
|
||||
const cert = certItem;
|
||||
|
||||
const sidSerial = toHex(sid.serialNumber.valueBlock.valueHexView);
|
||||
const certSerial = toHex(cert.serialNumber.valueBlock.valueHexView);
|
||||
if (sidSerial !== certSerial) continue;
|
||||
|
||||
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 (signedData.certificates.length === 1) {
|
||||
const cert = signedData.certificates[0];
|
||||
if (cert instanceof pkijs.Certificate) return cert;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Small browser helpers shared across the S/MIME plugin modules.
|
||||
// (The native app pulled these from @/lib/utils; the sandbox has no host
|
||||
// imports, so we provide local, dependency-free equivalents.)
|
||||
|
||||
/** RFC 4122 v4 UUID using the same crypto.randomUUID the host relies on. */
|
||||
export function generateUUID() {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0'));
|
||||
return (
|
||||
hex.slice(0, 4).join('') +
|
||||
'-' +
|
||||
hex.slice(4, 6).join('') +
|
||||
'-' +
|
||||
hex.slice(6, 8).join('') +
|
||||
'-' +
|
||||
hex.slice(8, 10).join('') +
|
||||
'-' +
|
||||
hex.slice(10, 16).join('')
|
||||
);
|
||||
}
|
||||
|
||||
/** Lower-case hex string for any byte source (replaces Node's Buffer.toString('hex')). */
|
||||
export function toHex(source) {
|
||||
let bytes;
|
||||
if (source instanceof ArrayBuffer) {
|
||||
bytes = new Uint8Array(source);
|
||||
} else if (ArrayBuffer.isView(source)) {
|
||||
bytes = new Uint8Array(source.buffer, source.byteOffset, source.byteLength);
|
||||
} else {
|
||||
bytes = new Uint8Array(source);
|
||||
}
|
||||
let out = '';
|
||||
for (let i = 0; i < bytes.length; i++) out += bytes[i].toString(16).padStart(2, '0');
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Constant-ish byte-array equality. */
|
||||
export function arraysEqual(a, b) {
|
||||
if (a.length !== b.length) return false;
|
||||
let diff = 0;
|
||||
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
/** Copy any ArrayBuffer-ish slice into a standalone ArrayBuffer. */
|
||||
export function toArrayBuffer(view) {
|
||||
if (view instanceof ArrayBuffer) return view;
|
||||
return view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength);
|
||||
}
|
||||
Reference in New Issue
Block a user