Commits the offline-client architecture analysis doc that was sitting untracked in docs/ — its own header already warns this exact thing happened once before (~/vncmail-plus is a shared checkout; an earlier untracked copy was lost to a concurrent branch switch). Confirmed the hazard is still live: vnc/VNC-CHANGES.md itself was found deleted from disk mid-edit by this session, by something else touching the checkout concurrently, and had to be restored with `git checkout --` before this commit. Committing on sight is the only defense against that, not a process improvement for later. Also: - .DS_Store added to .gitignore (was untracked in docs/) - introduces a VNC-side feature version, separate from package.json's upstream-tracking version (1.7.8, must stay that way per the fork's own rule 4 - bumping it would turn merging upstream releases into a diffing exercise). Retroactively bucketed at the milestone boundaries the commit history already has: v0.1.0 fork bootstrap, v0.2.0 S/MIME plugin audit+fixes, v0.3.0 the internal-CA foundation just landed. Tagged vnc-v0.3.0 on this commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
80 lines
10 KiB
Markdown
80 lines
10 KiB
Markdown
# VNC-CHANGES — VNCmail+ divergence log
|
|
|
|
VNCmail+ is a fork of [bulwarkmail/webmail](https://github.com/bulwarkmail/webmail)
|
|
(AGPL-3.0). This file records **every** intentional divergence from upstream so
|
|
that merging new upstream releases stays a triage exercise, not an archaeology dig.
|
|
|
|
## VNC feature version
|
|
|
|
`package.json`'s version tracks **upstream** (currently `1.7.8`) and must stay
|
|
that way per rule 4 below — bumping it would turn merging upstream releases into
|
|
a diffing exercise instead of a fast-forward. The VNC-side feature set gets its
|
|
own counter instead, tagged `vnc-vX.Y.Z` on `dev`, bumped whenever a milestone
|
|
below closes:
|
|
|
|
| Version | Date | Milestone |
|
|
|---|---|---|
|
|
| `v0.1.0` | 2026-08-03 | Fork bootstrap: VNClagoon + SRC brand themes, per-theme logos, k8s deploy (after Vercel was abandoned — Bulwark writes to a local data dir, serverless fs is read-only), 6h session cookie |
|
|
| `v0.2.0` | 2026-08-04 | SRC theme MD3 componentry; plugin-sandbox hardening (`B-01` scanner bypass, `B-04` unpermissioned hook registration); S/MIME plugin forked, audited (S-01, 9 findings), 3 shipping-blockers fixed + 2 hardened, verified end-to-end on real mail (sign, encrypt, decrypt, banner) |
|
|
| `v0.3.0` | 2026-08-04 | Internal CA foundation (`P1`): EJBCA Community manifests + root-ceremony runbook (`A-01`/`A-06`), server-side `CaProvider` + enrolment route (`A-02`, `C-08` server half), finding 11 (certificate address binding). **No certificate has been issued yet** — the browser half of `C-08` (in-browser CSR generation) and a live EJBCA are both still outstanding. |
|
|
|
|
## Rules of the fork
|
|
|
|
1. **Keep upstream files unmodified whenever possible.** Prefer env vars
|
|
(branding), the `vnc/overrides/` layer, and additive files over editing files
|
|
inside `app/`, `components/`, `lib/`, `stores/`.
|
|
2. **Every edit to an upstream file gets a one-line entry below** — path, what,
|
|
why. No silent edits.
|
|
3. **Branches:** `main` tracks upstream releases (kept clean); `dev` is the VNC
|
|
integration + Vercel deploy branch; `vnc/*` are feature branches.
|
|
4. **Syncing upstream:** `git fetch upstream && git merge upstream/main` onto
|
|
`main`, then merge `main` into `dev`. Resolve using this log.
|
|
|
|
## Divergences
|
|
|
|
| Date | File / area | Change | Why |
|
|
|------|-------------|--------|-----|
|
|
| 2026-08-03 | `vnc/` (new) | Added VNC customization dir + this log | Fork bootstrap |
|
|
| 2026-08-03 | `VNCMAIL-SETUP.md` (new) | Vercel deploy runbook | Deploy on Vercel as project "VNCmail+" |
|
|
|
|
| 2026-08-03 | `deploy/k8s/` (new) | k8s manifests + runbook for microk8s deploy | Bulwark is stateful → runs as a container w/ persistent volumes, not Vercel serverless |
|
|
| 2026-08-03 | `.gitignore` | ignore `deploy/k8s/secret.yaml` | keep the real env secret out of git |
|
|
| 2026-08-03 | `public/branding/*.svg` (new) | VNCmail wordmark (on-dark + on-light) | VNC logo (placeholder — swap official SVG) |
|
|
| 2026-08-03 | `public/fonts/*.woff2` (new) | DM Sans 400/500/700 + Syne 700/800 (OFL, self-hosted) | VNClagoon typography |
|
|
| 2026-08-03 | `lib/builtin-themes.ts` | add `builtin-vnclagoon` theme (navy+cyan, DM Sans/Syne, @font-face) | VNClagoon brand theme |
|
|
| 2026-08-03 | `lib/admin/types.ts` | `DEFAULT_THEME_POLICY.defaultThemeId` → `builtin-vnclagoon` | make VNClagoon the default theme |
|
|
| 2026-08-03 | `stores/theme-store.ts` | default `theme`/`resolvedTheme` → `dark` | dark-first per VNClagoon styleguide |
|
|
| 2026-08-03 | `lib/builtin-themes.ts` | add `builtin-src` theme (Swiss red on white, light-first) | 2nd brand theme (SRC Advisory); VNClagoon stays default |
|
|
| 2026-08-03 | `public/branding/src-logo.svg` (new) | SRC mountain mark | SRC brand (placeholder — swap official) |
|
|
| 2026-08-03 | `lib/plugin-types.ts` | add optional `logoLightUrl`/`logoDarkUrl` to InstalledTheme | per-theme brand logos |
|
|
| 2026-08-03 | `lib/theme-logo.ts` (new) | `resolveThemeLogo()` helper | pick active theme's logo, fall back to global |
|
|
| 2026-08-03 | `lib/builtin-themes.ts` | set logos on vnclagoon (wordmark) + src (mark) | logo switches with the brand theme |
|
|
| 2026-08-03 | `app/(main)/[locale]/login/page.tsx` | login logo uses active theme's logo | brand-switch on login |
|
|
| 2026-08-03 | `components/layout/navigation-rail.tsx` | nav-rail logo uses active theme's logo | brand-switch in app |
|
|
|
|
_Note: Vercel was tried and **abandoned** on 2026-08-03. Bulwark writes to a local
|
|
data dir (`/app/data/*`); Vercel serverless has a read-only filesystem → crash
|
|
(`ENOENT /var/task/data`). VNCmail+ now deploys as a Docker image
|
|
(`ghcr.io/brvncde-dotcom/vncmail-plus-*`) on Kubernetes (microk8s) at
|
|
`vncmail.sandbox.vnc.de`, with 4 persistent volumes — see `deploy/k8s/`. A
|
|
microfrontends integration was also added and reverted the same day._
|
|
|
|
| 2026-08-03 | `lib/stalwart/auth-context.ts` | give `jmap_stalwart_ctx` a 6-hour maxAge (was session-cookie → expired on tab close) | session survival across browser restarts |
|
|
| 2026-08-03 | `lib/builtin-themes.ts` | add `srcSkin` (MD3 component overrides: shape scale, filled buttons, text fields, cards, dialogs, state layers, switches, login card); add @font-face + typography to `builtin-src`; bump to v1.1.0 | SRC theme: keep colors + fonts, apply MD3 design system |
|
|
| 2026-08-04 | `lib/plugin-sandbox/loader.ts` | **B-04 security fix** — gate hook registration on granted permissions via new `HOOK_PERMISSIONS` map; refused hooks are skipped, logged and counted | `info.hooks` is self-reported by the sandbox, so an untrusted plugin could claim `onRenderEmailBody` and replace any rendered email body without holding `email:render-takeover`. Consent copy gated what the user was *asked*, not what the host *allowed*. |
|
|
| 2026-08-04 | `lib/plugin-sandbox/host-api.ts` | export `hasPermission()` (was module-private) | one source of truth for the permission rule — the loader gate and the RPC gate must not drift apart |
|
|
| 2026-08-04 | `app/api/admin/plugins/route.ts` | **B-01** — scan all `.js`/`.mjs` in the bundle (was entrypoint only); return structured `findings` + `canOverride`; allow admin `overrideWarnings=true` with a `plugin.install.scan_override` audit entry; echo accepted `findings` on success | hard-reject on `eval(`/`new Function(`/`innerHTML =` made every crypto plugin uninstallable (minified openpgp.js/pkijs trip it), while only scanning the entrypoint left a trivial bypass. Route is already admin-authenticated, so the scan is defence-in-depth, not a trust boundary. |
|
|
| 2026-08-04 | `vnc/audits/SMIME-PLUGIN-AUDIT-2026-08-04.md` (new) | **S-01** security audit of `bulwarkmail/plugins/smime` @ `91085a3` — 9 findings (2 HIGH, 1 MED-HIGH), verdict: fork and fix before shipping | privileged same-origin plugin that handles users' private keys; verdict must precede any deploy |
|
|
| 2026-08-04 | `vnc/plugins/smime/` (new) | fork of the upstream S/MIME plugin, **source only — upstream `smime.zip` deliberately NOT vendored** | shipped zip is a 1.77 MB bundle at manifest 1.0.1 while source is 1.0.2, so auditing `src/` would not audit what the zip installs. We build from source via `npm run package`. |
|
|
| 2026-08-04 | `vnc/plugins/smime/src/index.js` | **audit fix 1 (HIGH)** — `maybeAutoImportSigner` now requires `signerEmailMatch === true` and `!selfSigned` before trusting a signer cert | upstream gated on `signatureValid` alone, but `smimeVerify` runs `checkChain:false`, so a self-signed cert asserting any address was silently stored as the ENCRYPTION TARGET for it. Both values were already computed and ignored. |
|
|
| 2026-08-04 | `vnc/plugins/smime/src/mime-builder.js` | **audit fix 3 (MED-HIGH)** — `stripCrlf()` applied inside `formatHeader` + the 3 directly-assembled headers (`att.contentType`, `att.cid`, `smimeType`) | CRLF escaping reached only Subject and filename; display names, Message-ID, In-Reply-To and References were raw — and those are copied from inbound mail on reply/forward, making it remotely reachable header injection |
|
|
| 2026-08-04 | `vnc/plugins/smime/src/smime-decrypt.js` | **audit fix 2 (HIGH)** — content-encryption allowlist (AES-CBC + AES-GCM only, gate runs before any key use); normal decrypt moved to `nativeEngine()` so the liner engine is reachable only for a genuine legacy RSAES-PKCS1-v1_5 key; return `contentAuthenticated` | upstream applied NO algorithm check and ran every decrypt through the liner engine, which registers DES-CBC/3DES-CBC/RC2-CBC for PKCS#12 password encryption — the CMS path inherited them. AEAD-only would break interop (RFC 5751 mandates AES-128-CBC), so CBC stays and the weak ciphers go. |
|
|
| 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 | `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) | 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. |
|
|
|
|
_(append new rows as you diverge)_
|