main
3
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
c2c07293b7 |
feat(smime): real local dev CA (LocalDevCaProvider), CSR issuance verified
The production EJBCA needs a client mTLS certificate + password this session doesn't have, and lives on the private dev-k8s network - genuinely unreachable from here tonight (confirmed, not assumed - see lib/smime-ca/index.ts's build() and the memory on the EJBCA CA project). lib/smime-ca/local-dev-provider.ts implements the same CaProvider seam (lib/smime-ca/types.ts) the production EjbcaProvider does - a real, working local CA, not a mock: - Generates a real RSA-2048 self-signed root on first use, persisted to the admin state dir (same pattern as lib/ai/entitlement.ts). - enroll() parses a real PKCS#10 CSR (pkijs), verifies its self-signature (proof of possession - not identity, which still comes only from the server-provided `addresses`, exactly like the production provider), and issues a real X.509v3 leaf: BasicConstraints(cA:false), KeyUsage (digitalSignature|nonRepudiation|keyEncipherment), ExtKeyUsage (emailProtection), SubjectAltName(rfc822Name per address) - signed with the CA's own private key. - revoke()/getChain() implemented for real (persisted revocation list, real chain PEM). - Wired into build() behind SMIME_CA_DEV_LOCAL=true, explicit opt-in only, never a silent fallback when the real CA URL is simply unconfigured. 4 tests, all real cryptographic verification, not string-shape checks: issue a cert from a real WebCrypto-generated CSR, then cryptographically verify the chain (leaf.verify(caCert) === true) and confirm the SAN contains exactly the server-chosen addresses (never the CSR's own requested CN); reject a CSR with a corrupted signature; confirm the CA persists across calls rather than minting a new root each time; confirm revocation is recorded to disk. Scope note, explicit rather than silently incomplete: this closes the server-side half. The client-side half (C-08) - the plugin generating a CSR via WebCrypto, calling this enrollment endpoint, and importing the issued cert into its existing encrypted-at-rest key storage (vnc/plugins/smime/src/key-storage.js, matching the AES-GCM+PBKDF2(600k) wrapping pkcs12.js already uses for imports) - was NOT built tonight. That plugin has open findings from an earlier security audit (see project memory); adding new key-generation/storage code to it at 00:30 after many hours of continuous work is exactly the kind of rushed change that produces the next finding. The privileged iframe can reach /api/smime/enroll directly (same-origin, confirmed via the plugin's own tier=privileged log line - no new sandbox bridge capability needed), so the remaining work is well-scoped and mechanical, not blocked on any open question - just deliberately deferred to unhurried, focused time. Verified: typecheck clean, lint clean, full vitest suite 2484/2485 (only the pre-existing, unrelated jmap-client-resilience flake), production build succeeds. |
||
|
|
3512f935d1 |
feat(ci): GitLab CI/CD dev→prod pipeline, kustomize base+overlays
Multiple developers now work on this repo, and the only working deploy
trigger required pushing to GitHub - which contradicts the standing
GitLab-canonical policy for this repo - while every actual deploy was a
manual kubectl run against one environment (no prod exists at all).
Restructures deploy/k8s/ into base/ + overlays/{dev,prod}: overlays/dev
is a verified byte-for-byte no-op for the live sandbox (kubectl kustomize
diff against the old flat layout is empty), overlays/prod is scaffolded
but inert (placeholder hostname + JMAP_SERVER_URL, since neither a prod
hostname decision nor a prod Stalwart exist yet). deploy/k8s/ca/ (the
EJBCA internal CA) is untouched and never referenced by either overlay.
Adds .gitlab-ci.yml: verify (MR gate, no push/deploy) -> build+deploy-dev
(automatic on push to dev, one image name/tag-only environments, fixing
the old -dev/-beta naming split) -> promote (manual, protected
`production` environment, retags the exact dev digest via
`docker buildx imagetools create` - never rebuilds - and is left as a
documented TODO for the actual `kubectl apply` until prod is real).
Updates VNCMAIL-SETUP.md and deploy/k8s/README.md to describe the new
flow and correct the aspirational promotion description that assumed a
"production image" CI never actually built.
Also fixes a pre-existing lint error (no-control-regex false positive on
an intentional DN-sanitizing character class in lib/smime-ca/ejbca.ts)
that was blocking this commit's pre-commit hook - unrelated to this
change otherwise, confirmed already present on dev before this branch.
Runner/RBAC/registry setup is an infra prerequisite this commit cannot
provide - documented in the pipeline plan, not part of this diff.
|
||
|
|
3afa7ce012 |
feat(smime): CaProvider seam + server-side enrolment route (A-02, C-08 half)
Corrects an architecture call I got wrong earlier in the session. I had said
CaProvider would live in the plugin. It cannot, for two independent reasons:
1. EJBCA's REST API authenticates with a CLIENT CERTIFICATE. A browser
cannot present one from fetch, and must not hold one anyway - the RA
credential is the authority to mint certificates, so putting it
anywhere script-reachable turns any XSS into a certificate factory.
2. Only the server can answer "does this person actually own this
address?" A browser asserting its own identity to a CA is not
authentication.
So: the plugin generates the keypair and CSR (private key never leaves the
device), and this layer decides which addresses the certificate may assert.
api.http.post is the bridge, and the fact that it forwards the user's JMAP
auth header is what makes the identity check possible at all.
The design decision worth calling out: the CSR is NOT trusted for identity,
and the route does not parse it to police what it asks for. It doesn't need
to. The route supplies the subject and the rfc822Name SAN itself from
addresses it verified independently; the CSR contributes only a public key
and proof of possession. A CSR hand-crafted to claim the CEO's address does
not have to be detected and rejected - the extension it asks for simply
never reaches the certificate.
That property depends entirely on EJBCA ignoring CSR-supplied subjects and
extensions, which is three checkboxes in the certificate profile. Added to
the runbook as the most important line in it, with a concrete verification
using a hostile CSR - because with those overrides ON, the enrolment route
still looks correct in review while issuing certificates for any address.
Identity comes from Stalwart via Identity/get, not from the auth cookie's
username. The cookie is encrypted and server-minted so it cannot be forged,
but it is still the wrong authority: the right answer to "may this person
have a signing certificate for this address" is held by the mail server
that already decides "may this person send from this address". Anything else
invents a second, weaker answer to a settled question.
It also handles two cases the cookie cannot:
- an alias the account legitimately sends as, which belongs ON the
certificate and which the cookie does not know about
- an administrative principal with no mailbox, which must get NOTHING.
Not hypothetical: admin@sandbox.vnc.de authenticates successfully and
has no mail session, so trusting the cookie would have issued it a
certificate for an address it cannot send from.
Wildcard identities (*@domain) are filtered out. Stalwart can legitimately
report one for an account allowed to send as anything in a domain, but it is
a capability, not an address - and a rfc822Name SAN of *@vnc.de is either
rejected by clients or, worse, honoured.
Other deliberate choices:
- Pins EJBCA's own chain for the mTLS connection instead of the public root
store. EJBCA serves a self-signed cert on that listener by design, and
rejectUnauthorized:false would be worse than either option - it would let
anything on the cluster network impersonate the CA and harvest CSRs.
- CA error bodies are logged server-side and replaced with generic messages.
An enrolment endpoint should not double as a way to probe CA config.
- DN component values are RFC 4514 escaped. The CN comes from a display
name; an unescaped comma or plus would inject additional RDNs.
- getCaProvider() returns null rather than throwing when unconfigured, so
the route 503s and nothing else is affected. Enrolment is opt-in; a
missing CA secret must not stop anyone reading their mail.
- revoke() is documented as needing to work when enrolment is broken. It is
the incident-response path, and a design that can only revoke through the
same path that issues is one outage from being unable to answer a key
compromise.
Typechecks clean. Not yet exercised against a live CA - the browser half of
C-08 (keypair + CSR generation in the plugin) and a real EJBCA to enrol
against are both still outstanding, so nothing here has issued a
certificate yet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|