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:
co-authored by
Claude Opus 4.8
parent
bc5d2a57e8
commit
d047891ded
+2
-1
@@ -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)_
|
||||||
|
|||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user