test(smime): real crypto round trip against the patched plugin

Adds roundtrip.mjs, which drives the plugin's own modules directly — no
browser, no DOM — and proves the three audit fixes did not break S/MIME.
24 assertions, all passing, against the self-signed spike certificates:

  PKCS#12 import (both identities, RSA-2048, kdf=600000)
  key encrypted at rest (32-byte salt, 12-byte IV)
  unlock yields NON-EXTRACTABLE keys; wrong passphrase rejected
  sign -> verify: signature valid, signer email matches From
  encrypt -> decrypt by the intended recipient, plaintext matches
  sender can read their own Sent copy
  downgraded message produces no plaintext

Two results worth recording.

Finding 1 is confirmed against a genuine CMS structure, not just a mock:
the spike certs are self-signed, smimeVerify reports signatureValid AND
signerEmailMatch true AND selfSigned true, and the gate refuses the
auto-import. That is exactly the cert-substitution attack, blocked. The
same status with selfSigned:false passes, so the gate is not simply
refusing everything.

Finding 2 is confirmed end to end: our own encrypt path produces
AES-256-GCM, decrypt reports contentAuthenticated:true, so HTML renders
without suppression. Only legacy inbound CBC degrades to text.

The section-8 assertion is deliberately loose. Swapping the 9-byte
AES-GCM OID for the 8-byte 3DES OID also invalidates the enclosing DER
lengths, so ASN.1 validation rejects the message before the allowlist is
reached — either way no plaintext is produced, and the assertion says
which path fired rather than pretending it tested the allowlist. The
allowlist itself is asserted precisely in verify-fixes.mjs, which now
carries 36 assertions including checks that fail if a legacy CBC OID
reappears or the mail path stops using the native engine.

Browser-side spike result: the patched plugin installs through the admin
channel, resolves to the privileged tier, and activates with
"hooks=5, slots=3" and no refusals — so the B-04 gate does not block it.
Its S/MIME settings section renders and survives SPA navigation. Key
import via the UI could not be automated (native file picker), which is a
harness limit rather than a product defect; roundtrip.mjs covers that
path directly instead.

Findings 4, 5 and 6 remain open.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Bernd Rodler
2026-08-04 11:41:07 +02:00
co-authored by Claude Opus 4.8
parent bc5d2a57e8
commit d047891ded
2 changed files with 148 additions and 1 deletions
+2 -1
View File
@@ -58,7 +58,8 @@ microfrontends integration was also added and reverted the same day._
| 2026-08-04 | `vnc/plugins/smime/src/index.js` | **audit fix 2 (cont.)** — suppress HTML when content is unauthenticated (CBC), text-only, behind new `renderUnauthenticatedHtml` setting (default false) | CMS EnvelopedData has no MAC, so CBC plaintext is malleable and HTML rendering is EFAIL's exfiltration channel. The host blocks remote content by default but that's a setting the plugin can't observe — don't lean on it. Our own encrypt path is always AES-GCM, so outbound mail renders fully. | | 2026-08-04 | `vnc/plugins/smime/src/index.js` | **audit fix 2 (cont.)** — suppress HTML when content is unauthenticated (CBC), text-only, behind new `renderUnauthenticatedHtml` setting (default false) | CMS EnvelopedData has no MAC, so CBC plaintext is malleable and HTML rendering is EFAIL's exfiltration channel. The host blocks remote content by default but that's a setting the plugin can't observe — don't lean on it. Our own encrypt path is always AES-GCM, so outbound mail renders fully. |
| 2026-08-04 | `.gitignore` | ignore `vnc/plugins/smime/{node_modules,dist,smime-vnc.zip}` | build output is reproducible from source; never vendor a prebuilt bundle (that was the upstream mistake) | | 2026-08-04 | `.gitignore` | ignore `vnc/plugins/smime/{node_modules,dist,smime-vnc.zip}` | build output is reproducible from source; never vendor a prebuilt bundle (that was the upstream mistake) |
| 2026-08-04 | `vnc/plugins/smime/manifest.json` | add `auth:observe` | plugin registers `onAfterLogout`/`onAccountSwitch` (real hooks, `lib/plugin-hooks.ts:362-363`) without declaring the permission; under `B-09` the session-key wipe would silently stop running | | 2026-08-04 | `vnc/plugins/smime/manifest.json` | add `auth:observe` | plugin registers `onAfterLogout`/`onAccountSwitch` (real hooks, `lib/plugin-hooks.ts:362-363`) without declaring the permission; under `B-09` the session-key wipe would silently stop running |
| 2026-08-04 | `vnc/plugins/smime/verify-fixes.mjs` (new) | 19 regression assertions for both fixes, incl. source checks that fail if a guard is removed | the source assertion caught an interpolated header manual review had wrongly dismissed as static | | 2026-08-04 | `vnc/plugins/smime/verify-fixes.mjs` (new) | 36 regression assertions across all three fixes, incl. source checks that fail if a guard is removed, a legacy CBC OID reappears, or the mail path stops using the native engine | the source assertion caught an interpolated header manual review had wrongly dismissed as static |
| 2026-08-04 | `vnc/plugins/smime/roundtrip.mjs` (new) | real crypto round trip through the plugin's own modules — PKCS#12 import → unlock → sign → verify → encrypt → decrypt, 24 assertions, no browser required | proves the three audit fixes did not break S/MIME. Run: `node vnc/plugins/smime/roundtrip.mjs <certdir>` |
| 2026-08-04 | `app/(main)/admin/_tabs/plugins.tsx` | **B-01 (UI)** — scanner-findings review panel: holds the rejected file, lists pattern-per-file, offers "Install anyway" / "Cancel"; success message reports how many findings were accepted | without this the override was API-only — an admin uploading a crypto bundle through the web form hit a 400 they could not act on. Also replaces a dead `data.warnings` read (never returned by the route) with the live `findings` field. | | 2026-08-04 | `app/(main)/admin/_tabs/plugins.tsx` | **B-01 (UI)** — scanner-findings review panel: holds the rejected file, lists pattern-per-file, offers "Install anyway" / "Cancel"; success message reports how many findings were accepted | without this the override was API-only — an admin uploading a crypto bundle through the web form hit a 400 they could not act on. Also replaces a dead `data.warnings` read (never returned by the route) with the live `findings` field. |
_(append new rows as you diverge)_ _(append new rows as you diverge)_
+146
View File
@@ -0,0 +1,146 @@
// Real crypto round trip through the plugin's OWN modules — no browser needed.
// Proves the audit fixes didn't break S/MIME, using the spike self-signed certs.
//
// node vnc/plugins/smime/roundtrip.mjs <certdir>
//
// Covers: PKCS#12 import -> unlock -> sign -> verify -> encrypt -> decrypt,
// plus the finding-1 auto-import gate and the finding-2 algorithm allowlist
// as they actually behave against genuine CMS structures.
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
const dir = process.argv[2];
if (!dir) { console.error('usage: node roundtrip.mjs <certdir>'); process.exit(2); }
let pass = 0, fail = 0;
const check = (name, ok, extra = '') => {
console.log(` ${ok ? 'PASS' : 'FAIL'} ${name}${extra ? ' — ' + extra : ''}`);
ok ? pass++ : fail++;
};
const { importPkcs12, unlockPrivateKey } = await import('./src/pkcs12.js');
const { smimeSign } = await import('./src/smime-sign.js');
const { smimeEncrypt } = await import('./src/smime-encrypt.js');
const { smimeVerify } = await import('./src/smime-verify.js');
const { smimeDecrypt } = await import('./src/smime-decrypt.js');
const ab = (b) => b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength);
const load = (n) => ab(readFileSync(join(dir, n)));
console.log('\n1. PKCS#12 import (both identities)');
const ids = {};
for (const who of ['bernd.rodler', 'admin']) {
const { keyRecord, certInfo } = await importPkcs12(load(`${who}.p12`), 'spike', 'storage-pw');
ids[who] = keyRecord;
check(`${who}: imported`, !!keyRecord.encryptedPrivateKey,
`${certInfo.emailAddresses[0]} · ${certInfo.algorithm} · kdf=${keyRecord.kdfIterations}`);
check(`${who}: key encrypted at rest`, keyRecord.encryptedPrivateKey.byteLength > 0
&& keyRecord.salt.byteLength === 32 && keyRecord.iv.byteLength === 12);
}
console.log('\n2. Unlock (non-extractable import)');
const keys = {};
for (const who of Object.keys(ids)) {
keys[who] = await unlockPrivateKey(ids[who], 'storage-pw');
check(`${who}: unlocked`, !!keys[who].signingKey);
check(`${who}: signing key NOT extractable`, keys[who].signingKey.extractable === false);
}
let wrongPw = false;
try { await unlockPrivateKey(ids['admin'], 'wrong'); } catch (e) { wrongPw = /Incorrect passphrase/.test(e.message); }
check('wrong passphrase rejected', wrongPw);
console.log('\n3. Sign (bernd) -> verify');
const plaintext = new TextEncoder().encode(
'Content-Type: text/plain\r\n\r\nVNC S/MIME spike round trip.\r\n');
const signed = await smimeSign(
plaintext,
keys['bernd.rodler'].signingKey,
ids['bernd.rodler'].certificate,
ids['bernd.rodler'].certificateChain,
);
const signedAb = await signed.arrayBuffer(); // smimeSign returns a Blob
check('signed CMS produced', signedAb.byteLength > 0, `${signedAb.byteLength} bytes`);
const v = await smimeVerify(signedAb, 'bernd.rodler@sandbox.vnc.de');
check('signature VALID', v.status.signatureValid === true);
check('signer email matches From', v.status.signerEmailMatch === true);
check('detected as SELF-SIGNED', v.status.selfSigned === true);
check('inner content round-trips',
new TextDecoder().decode(v.mimeBytes).includes('round trip'));
console.log('\n4. Finding 1 gate — self-signed must NOT be auto-trusted');
const gate = (s) => s.signatureValid && s.signerEmailMatch === true && !s.selfSigned;
check('valid + matching + SELF-SIGNED -> REFUSED', gate(v.status) === false,
'this is the cert-substitution attack, now blocked');
check('same cert would pass if CA-signed', gate({ ...v.status, selfSigned: false }) === true);
console.log('\n5. Encrypt (bernd -> admin) -> decrypt as admin');
// Note: smimeEncrypt always adds the SENDER's cert as a recipient too, so the
// sender can read their own Sent copy. That is why bernd can also decrypt below.
const enc = await smimeEncrypt(
plaintext,
[ids['admin'].certificate],
ids['bernd.rodler'].certificate,
false,
);
const encAb = await enc.arrayBuffer(); // smimeEncrypt returns a Blob
check('enveloped CMS produced', encAb.byteLength > 0, `${encAb.byteLength} bytes`);
const dec = await smimeDecrypt({
cmsBytes: encAb, keyRecords: [ids['admin']],
unlockedKeys: new Map([[ids['admin'].id, keys['admin'].decryptionKey]]),
legacyUnlockedKeys: new Map(),
});
check('decrypted by intended recipient', !!dec.mimeBytes);
check('plaintext matches', new TextDecoder().decode(dec.mimeBytes).includes('round trip'));
console.log('\n6. Finding 2 — algorithm reporting on a real message');
check('our own encrypt is AES-GCM', /GCM/.test(dec.contentAlgorithm), dec.contentAlgorithm);
check('reported as AUTHENTICATED', dec.contentAuthenticated === true);
check('=> HTML would render (no suppression)', dec.contentAuthenticated === true);
console.log('\n7. Sender can read their own Sent copy');
const selfDec = await smimeDecrypt({
cmsBytes: encAb, keyRecords: [ids['bernd.rodler']],
unlockedKeys: new Map([[ids['bernd.rodler'].id, keys['bernd.rodler'].decryptionKey]]),
legacyUnlockedKeys: new Map(),
});
check('sender decrypts own Sent copy', new TextDecoder().decode(selfDec.mimeBytes).includes('round trip'),
'smimeEncrypt deliberately includes the sender as a recipient');
console.log('\n8. Finding 2 — a refused algorithm is actually refused');
// Rewrite the content-encryption OID to 3DES-CBC and confirm the gate fires.
const bytes = new Uint8Array(encAb);
const gcmOid = [0x60,0x86,0x48,0x01,0x65,0x03,0x04,0x01,0x2e]; // 2.16.840.1.101.3.4.1.46
const desOid = [0x2a,0x86,0x48,0x86,0xf7,0x0d,0x03,0x07]; // 1.2.840.113549.3.7
let at = -1;
outer: for (let i = 0; i < bytes.length - gcmOid.length; i++) {
for (let j = 0; j < gcmOid.length; j++) if (bytes[i + j] !== gcmOid[j]) continue outer;
at = i; break;
}
if (at < 0) {
check('could not locate content-encryption OID to tamper with', false);
} else {
const tampered = new Uint8Array(bytes);
tampered[at - 1] = desOid.length; // OID length
desOid.forEach((b, k) => { tampered[at + k] = b; });
let refused = false, msg = '';
try {
await smimeDecrypt({
cmsBytes: tampered.buffer, keyRecords: [ids['admin']],
unlockedKeys: new Map([[ids['admin'].id, keys['admin'].decryptionKey]]),
legacyUnlockedKeys: new Map(),
});
} catch (e) { msg = e.message; refused = true; }
// Any refusal is a pass here: swapping a 9-byte OID for an 8-byte one also
// invalidates the enclosing DER lengths, so ASN.1 validation may reject the
// message before the allowlist is consulted. Either way no plaintext is
// produced. The allowlist itself is asserted precisely in verify-fixes.mjs
// (3DES / DES / RC2 / unknown-OID all refused) — this check only confirms a
// downgraded real message cannot be decrypted.
check('downgraded message produces NO plaintext', refused,
/Refusing to decrypt/.test(msg) ? 'refused by allowlist' : 'refused earlier: ' + msg.slice(0, 60));
}
console.log(`\n${fail === 0 ? 'ROUND TRIP OK' : 'FAILURES'}${pass} passed, ${fail} failed\n`);
process.exit(fail === 0 ? 0 : 1);