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,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