Commit Graph
22 Commits
Author SHA1 Message Date
Bernd RodlerandClaude Opus 4.8 fe77e9f52b docs: file two host-app issues found during the S/MIME spike
1. A 401 from ANY login step is reported as wrong password.
auth-store.ts:61 classifies any error whose message merely contains the
substring 401 as invalid_credentials, and it is fed by a catch-all around
the entire login sequence. Reproduced with admin@sandbox.vnc.de, a
Stalwart administrative principal with no mailbox: POST /api/auth/session
returns 200 (the password IS correct), then the JMAP session fetch
returns 401 and the UI claims the password is wrong. Verified directly:
bernd.rodler gets 200 with a mail capability, admin gets 401.

Cost several minutes re-typing a password that was never wrong. An
admin-only principal, a disabled mailbox and a revoked mail permission
are all indistinguishable from a typo.

2. Page reload signs you out unless stay-signed-in is ticked, which also
silently prevents plugin activation and therefore looks like a plugin
bug. SESSION_SECRET is intact, so not a key rotation.

Neither blocks P1; both deliberately not chased during the spike.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 12:44:53 +02:00
Bernd RodlerandClaude Opus 4.8 a9af816012 docs(smime): record finding 10 (banner race) in the audit
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 12:35:33 +02:00
Bernd RodlerandClaude Opus 4.8 90c1176f93 fix(smime): banner slot can silently miss a resolved signature
Found live, not from a test: sent a genuinely signed+encrypted message
through the real composer, opened it in Sent, and the banner showed
only "Encrypted message" - no signature row at all, despite both Sign
and Encrypt having been checked and the body decrypting correctly.

Root cause is a race, not a crypto bug. onRenderEmailBody (which fetches
the blob, decrypts, verifies the inner signature, and persists the full
status) and EmailBanner (a separate plugin UI slot) mount independently.
The banner read persisted status exactly once, in a useEffect keyed only
on email.id. If that read fired before the async decrypt+verify pipeline
finished writing, the banner fell back to a header-derived guess: it can
see from the OUTER envelope's Content-Type that a message is encrypted,
but has no way to know it is ALSO signed, since that only becomes
knowable after decryption completes.

This is more than cosmetic. The same race could just as easily hide an
INVALID signature - a tampered message or wrong signer - behind the
generic "Encrypted message" banner, purely because of timing, with no
indication anything needs attention.

Fix: track whether the initial read came from a real persisted value or
from the header-only fallback. Only in the fallback case, poll briefly
(150ms x 20 = 3s) for the real result to land - the same pattern
unlockNow already uses after a manual key unlock, generalized to the
initial mount. Once persisted state exists, stop.

Verified in the real browser: re-sent and re-opened the same signed+
encrypted Sent message after this fix, banner now shows both rows -
"Decrypted" and "Valid signature by bernd.rodler@sandbox.vnc.de -
self-signed" (amber, correctly, since the spike cert is self-signed and
fix 1's selfSigned flag is doing its job).

Two source assertions added to verify-fixes.mjs. 51 unit assertions,
28 round-trip assertions, all passing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 12:35:08 +02:00
Bernd RodlerandClaude Opus 4.8 5c1f14fa8b docs(smime): record UI key-import verification
User manually imported bernd.rodler.p12 through the real Settings >
S/MIME > Import key dialog on localhost:3100 - real native file picker,
real PKCS#12 passphrase, real storage passphrase. Succeeded.

This closes the last unverified layer. Every step of the delivery path
is now proven end to end: crypto correctness, parser hardening against
hostile input, admin install, client activation under the B-04 gate,
and now UI key import.

Also fixes a stale line in the audit doc that still listed finding 5
as open after it was fixed in a4155aa3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 12:09:23 +02:00
Bernd RodlerandClaude Opus 4.8 a4155aa342 security(smime): fix finding 5 (parser DoS) and harden finding 4
Finding 5 — the MIME parser runs on attacker-controlled input: the inner
content recovered after decrypt/verify is whatever the sender put there.
Upstream had no depth limit on nested multiparts and no size cap anywhere.

Verified against the unpatched upstream parser with the same input:

  UPSTREAM CRASHED: RangeError - Maximum call stack size exceeded
  UPSTREAM: 65MB accepted (no size cap)

So this was a live decrypt-time DoS reachable by anyone who can send mail.

Caps added: depth 20, parts 500, bytes 64 MB — generous enough that no
legitimate message comes close (real mail nests 3-4 levels). Past a limit a
subtree degrades to a leaf rather than throwing, so one pathological branch
doesn't discard the legitimate parts above it. Oversize input is refused
outright rather than truncated: half a MIME tree parses into misleading
nonsense, and showing part of a message is worse than saying no. Both
bodyStructure walkers in smime-detect.js are capped too — those run on
server-supplied structure BEFORE any decrypt/verify gate.

Finding 4 — hardened, not eliminated, per the agreed scope. Unlocked
CryptoKeys still live in durable IndexedDB rather than memory; moving them
would mean refactoring how the plugin shares state across iframes and
risking the unlock->decrypt path just verified.

What changed instead:

- Removed the lockOnLogout opt-out from the logout/account-switch wipes. A
  non-extractable key cannot be exported but can still be USED, so a handle
  outliving the session lets anyone with the browser profile decrypt mail
  without knowing the passphrase. That is not a preference to toggle off.
- Added a best-effort wipe on pagehide and beforeunload to narrow the window
  in which a usable handle exists on disk. Best-effort by nature: an
  IndexedDB write may not complete during teardown and neither event fires
  on a crash — which is precisely why the boot wipe in activate() remains
  the load-bearing control.
- Deliberately NOT wiping on visibilitychange: tabbing away would drop the
  unlock and force a passphrase re-entry every time, which trains users into
  turning S/MIME off entirely.
- Dropped the now-dead lockOnLogout setting from the manifest. A toggle that
  silently does nothing is worse than no toggle.

Tests: 49 unit assertions + 28 round trip. The round trip now feeds genuinely
hostile MIME through the real parser (5000-level nesting, 5000 siblings,
65 MB) and still confirms a normal multipart/alternative parses correctly.
Full crypto round trip unchanged and passing, so neither fix broke S/MIME.

Findings 6, 7, 8 and 9 remain open.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 11:57:18 +02:00
Bernd RodlerandClaude Opus 4.8 d047891ded 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>
2026-08-04 11:41:07 +02:00
Bernd RodlerandClaude Opus 4.8 bc5d2a57e8 security(smime): fix audit finding 2 — unauthenticated CBC on decrypt
Upstream applied no content-encryption check at all on decrypt, and ran
every decryption through the liner engine — which registers DES-CBC,
3DES-CBC and RC2-CBC. Those OIDs exist in crypto-engine.js for PKCS#12
password-based encryption; the CMS content path merely reused the same
engine and inherited them. A crafted message could therefore be decrypted
under a broken cipher, and unauthenticated plaintext was handed straight
to the renderer — the EFAIL precondition.

The obvious fix would have been wrong. Accepting only AEAD breaks most
real S/MIME mail: RFC 5751 makes AES-128-CBC the MUST-implement content
cipher, Outlook and Thunderbird default to CBC, and AES-GCM in CMS
(RFC 5084) is barely deployed. An AEAD-only allowlist is a functionality
catastrophe wearing a security fix's clothes.

Three layers instead:

1. Allowlist the AES family and refuse everything else, with the gate
   running before any private key is touched. CBC stays for interop;
   DES/3DES/RC2 are refused.

2. Take the mail path off the legacy engine. Normal decryption now uses
   nativeEngine(); the liner engine is reachable only when a genuine
   legacy RSAES-PKCS1-v1_5 key is in play. This removes the weak ciphers
   structurally rather than by policy — native WebCrypto handles RSA-OAEP
   key transport and AES-CBC/GCM content perfectly well.

3. Refuse to render unauthenticated plaintext as HTML. CBC output is
   malleable and HTML is EFAIL's exfiltration channel. The host does block
   remote content by default (allowExternalContent starts false), but that
   is a user/admin setting this plugin cannot observe, so we don't lean on
   it. New renderUnauthenticatedHtml setting (default false) is the
   documented opt-out. Our own encrypt path always uses AES-GCM, so mail
   we send renders fully; only legacy inbound CBC degrades to text.

Built from source with the repo's own pipeline (esbuild, 1.69 MB) and
packaged to smime-vnc.zip (0.27 MB). All four fixes verified present in
the built bundle. Build output is gitignored — never vendor a prebuilt
bundle, which was the upstream mistake.

Correcting an earlier assumption: this bundle does NOT trip the B-01
pattern scanner (zero matches on all five patterns), so the override is
not needed to install it. B-01 remains correct — it closed a real
entrypoint-only coverage gap — but it isn't load-bearing here.

verify-fixes.mjs now carries 36 assertions covering all three fixes,
including source checks that fail if a guard is removed, if a legacy CBC
OID reappears in the allowlist, or if the mail path stops using the
native engine.

Findings 4 (unlocked keys persisted to IndexedDB), 5 (parser DoS) and 6
(PKCS1v1.5 oracle surface) remain open.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 10:47:43 +02:00
Bernd RodlerandClaude Opus 4.8 f7e487171c 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>
2026-08-04 10:00:11 +02:00
Bernd RodlerandClaude Opus 4.8 e9746fcf78 feat(plugins): admin review panel for scanner findings
The overrideWarnings escape hatch added alongside the bundle scan was
API-only: an admin uploading a crypto plugin through the web form hit a
400 with canOverride and had no way to act on it, which left S/MIME and
PGP bundles uninstallable through the UI.

Hold the rejected file client-side and show the findings — pattern per
file — with "Install anyway" and "Cancel". Proceeding re-posts the same
file with overrideWarnings, so the decision stays explicit and lands in
the audit log. The route now echoes accepted findings back on success so
the confirmation says how many were waved through rather than reporting a
bare install.

Also replaces a dead `data.warnings` read with the live `findings` field;
the route never returned `warnings` on success, so that branch never ran.

Completes B-01.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 09:03:14 +02:00
Bernd RodlerandClaude Opus 4.8 d91db37b34 fix(plugins): scan all bundle scripts, allow audited scanner override
Two problems with the upload scanner, pulling in opposite directions.

It only scanned the entrypoint, so a bundle with `eval()` in a second
file passed outright — verified against a synthetic bundle whose
vendor/openpgp.js tripped three patterns while index.js stayed clean.

At the same time, a hard 400 on eval()/new Function()/innerHTML= makes
every crypto plugin uninstallable: minified openpgp.js and pkijs
legitimately contain all three. That blocks S/MIME and PGP entirely.

Scan every .js/.mjs in the bundle and return structured findings
({file, patterns[]}) plus canOverride, so the admin can see exactly what
tripped and where. An explicit overrideWarnings=true proceeds and writes
a plugin.install.scan_override audit entry recording which patterns in
which files were accepted — not merely that an override happened.

This route is already admin-authenticated, so the scan is defence in
depth against an accidental or compromised upload, not a trust boundary.
Treating it as the latter is what made crypto plugins uninstallable.

Also log the B-04 and B-01 divergences in vnc/VNC-CHANGES.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 08:57:28 +02:00
Bernd RodlerandClaude Opus 4.8 ae19ad888b fix(security): gate plugin hook registration on granted permissions
`info.hooks` is self-reported by the sandboxed bundle, and the loader
registered any recognised hook name without checking permissions. An
untrusted, null-origin plugin could therefore claim `onRenderEmailBody`
and replace the rendered body of any opened email without ever holding
`email:render-takeover` — the permission was enforced only by the
one-time consent dialog, i.e. it gated what the user was *asked*, not
what the host *allowed*.

Add HOOK_PERMISSIONS covering the hooks that can read message content,
alter outgoing mail, or observe key state: render takeover, the three
send-interception hooks, bulk-content hooks, attachment upload, and the
four S/MIME hooks. Hooks absent from the map stay unrestricted (UI
observation, toasts, navigation), so ordinary plugins are unaffected.

Refused hooks fail closed and log the missing permission by name — a
silently inert hook is far harder to diagnose than a refused one.

Export hasPermission() from host-api rather than reimplementing the rule
in the loader, so the hook gate and the RPC gate cannot drift apart.

Remaining ~200 hooks are tracked as B-09.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 08:57:14 +02:00
Bernd Rodler f0e63de09b feat(theme): SRC theme v1.1.0 — MD3 components (shape scale, buttons, cards, dialogs, state layers) 2026-08-03 19:13:07 +02:00
Bernd RodlerandClaude Opus 4.8 88670d4bcf feat(theme): per-theme brand logos (VNClagoon wordmark ↔ SRC mark)
Add optional logoLightUrl/logoDarkUrl to InstalledTheme + resolveThemeLogo()
helper; set logos on vnclagoon + src themes; login page and nav-rail prefer the
active theme's logo, falling back to the global config logo. So switching theme
switches the whole brand. 0 type errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 18:23:42 +02:00
Bernd RodlerandClaude Opus 4.8 1bc5a6a0ce feat(theme): add SRC brand theme (Swiss red/white), keep VNClagoon default
Second builtin theme builtin-src (red #D52B1E light-first, #EF4444 dark) + SRC
mountain logo asset. VNClagoon remains the default theme. Placeholder logo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 18:16:54 +02:00
Bernd RodlerandClaude Opus 4.8 be7bfd1f02 feat(theme): VNClagoon login card — navy card, cyan hairline + top accent + glow
Extend the vnclagoon skin: solid navy login card, cyan hairline border, a thin
cyan accent strip on top, soft cyan glow (dark), and an ambient cyan wash behind
the card on the login page. Scoped to the login card's unique class combo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 17:45:09 +02:00
Bernd RodlerandClaude Opus 4.8 22e5e97da9 feat(theme): VNClagoon brand theme (navy + cyan) as default, dark-first
Add builtin-vnclagoon theme (cyan #00D4FF accent on navy #0A0E1A, DM Sans body
+ Syne headings, self-hosted OFL fonts); set as default theme policy; default
mode dark. Add VNCmail wordmark SVGs (on-dark/on-light) + wire logo/company via
k8s secret template. Placeholder wordmark — swap official styleguide SVG.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 17:42:04 +02:00
Bernd RodlerandClaude Opus 4.8 476c76c420 docs(deploy): sharpen k8s admin runbook — inventory, pre-flight, ordered apply
Self-contained guide: exactly what to deploy (7 objects + image), 3 cluster
values to match against bulwark, copy-paste apply order, verify, update/rollback,
troubleshooting table. Plain kubectl apply (no GitOps).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 17:13:51 +02:00
Bernd RodlerandClaude Opus 4.8 a3d551b640 feat(deploy): k8s manifests for microk8s (vncmail.sandbox.vnc.de)
Bulwark is stateful (local /app/data) — Vercel serverless (read-only fs)
crashes it. Deploy as a container with 4 persistent volumes on microk8s,
alongside bulwark.sandbox.vnc.de. Adds deploy/k8s/ (namespace, pvc, deployment,
service, ingress, secret template, runbook) + rewrites setup doc off Vercel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 17:09:54 +02:00
Bernd RodlerandClaude Opus 4.8 83a9c5a809 docs(setup): dev-first workflow — main=production, dev=preview
Fix contradiction: production branch is main (Vercel default), dev auto-deploys
previews, promote = ff-only merge dev→main on explicit go-live. Upstream synced
into dev, not main.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 16:46:55 +02:00
Bernd RodlerandClaude Opus 4.8 fb4e8f3591 revert(mf): drop microfrontends — VNCmail+ is a standalone project
Remove withMicrofrontends wrap + @vercel/microfrontends dep. Grouping is
organizational (separate Vercel team), not a microfrontends group. App
serves at its own root again (NEXT_PUBLIC_BASE_PATH removed on Vercel).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 16:28:12 +02:00
Bernd RodlerandClaude Opus 4.8 0189935e7b feat(mf): join VNClagoon Suite microfrontends group
Wrap next.config with withMicrofrontends; add @vercel/microfrontends.
Served at /mail under the suite shell (via NEXT_PUBLIC_BASE_PATH set on the
Vercel project). Logged in vnc/VNC-CHANGES.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 16:04:40 +02:00
Bernd RodlerandClaude Opus 4.8 eace443fdf chore(vnc): bootstrap VNCmail+ fork — vnc/ layer + Vercel runbook
Fork of bulwarkmail/webmail for deploy on Vercel as project vncmail-plus.
Adds vnc/ customization layer (branding, overrides, VNC-CHANGES log),
Vercel env template, and VNCMAIL-SETUP.md runbook. No upstream files touched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 15:42:45 +02:00