/** * 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); }