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.
71 lines
2.6 KiB
TypeScript
71 lines
2.6 KiB
TypeScript
import { readFileSync } from 'node:fs';
|
|
import { EjbcaProvider } from './ejbca';
|
|
import { LocalDevCaProvider } from './local-dev-provider';
|
|
import type { CaProvider } from './types';
|
|
|
|
export * from './types';
|
|
|
|
/**
|
|
* Build the configured provider, or `null` when S/MIME enrolment is not set up.
|
|
*
|
|
* `null` rather than a throw, so an unconfigured deployment answers 503 on the
|
|
* enrolment route and is otherwise unaffected. Enrolment is an opt-in feature of
|
|
* a mail client; a missing CA secret must not stop anyone reading their mail.
|
|
*/
|
|
let cached: CaProvider | null | undefined;
|
|
|
|
export function getCaProvider(): CaProvider | null {
|
|
if (cached !== undefined) return cached;
|
|
cached = build();
|
|
return cached;
|
|
}
|
|
|
|
function build(): CaProvider | null {
|
|
const baseUrl = process.env.SMIME_CA_URL;
|
|
if (!baseUrl) {
|
|
// Explicit opt-in only, never a silent fallback: the real EJBCA needs a
|
|
// client mTLS credential this environment doesn't have and lives on the
|
|
// private dev-k8s network, unreachable from here tonight - see
|
|
// local-dev-provider.ts's module header for exactly what this is (and
|
|
// isn't) a substitute for.
|
|
if (process.env.SMIME_CA_DEV_LOCAL === 'true') {
|
|
return new LocalDevCaProvider();
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Read from the mounted secret by default (see deploy/k8s/ca/README.md § 5.3).
|
|
// Paths are overridable for local development against a throwaway CA.
|
|
const pfxPath = process.env.SMIME_CA_CLIENT_PFX_PATH ?? '/etc/smime-ca/client.p12';
|
|
const caPath = process.env.SMIME_CA_CHAIN_PATH ?? '/etc/smime-ca/ca-chain.pem';
|
|
const password = process.env.SMIME_CA_CLIENT_PFX_PASSWORD;
|
|
|
|
if (!password) {
|
|
console.error('[smime-ca] SMIME_CA_URL is set but SMIME_CA_CLIENT_PFX_PASSWORD is not');
|
|
return null;
|
|
}
|
|
|
|
let clientPfx: Buffer;
|
|
let serverCaPem: string;
|
|
try {
|
|
clientPfx = readFileSync(pfxPath);
|
|
serverCaPem = readFileSync(caPath, 'utf8');
|
|
} catch (cause) {
|
|
// Loud, because the symptom otherwise is "enrolment returns 503" with no
|
|
// indication that a file is simply not mounted.
|
|
console.error(`[smime-ca] cannot read RA credential (${pfxPath} / ${caPath}):`, cause);
|
|
return null;
|
|
}
|
|
|
|
return new EjbcaProvider({
|
|
id: process.env.SMIME_CA_ID ?? 'ejbca',
|
|
baseUrl: baseUrl.replace(/\/$/, ''),
|
|
clientPfx,
|
|
clientPfxPassword: password,
|
|
serverCaPem,
|
|
caName: process.env.SMIME_CA_NAME ?? 'VNC S/MIME Issuing CA Sandbox R1',
|
|
certificateProfile: process.env.SMIME_CA_CERT_PROFILE ?? 'VNC S/MIME 1y',
|
|
endEntityProfile: process.env.SMIME_CA_EE_PROFILE ?? 'VNC S/MIME User',
|
|
});
|
|
}
|