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>
292 lines
12 KiB
JavaScript
292 lines
12 KiB
JavaScript
/**
|
|
* 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);
|
|
}
|