Files
SRCmail/lib/smime-ca/ejbca.ts
T
Bernd Rodler 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.
2026-08-05 11:43:55 +02:00

227 lines
8.4 KiB
TypeScript

/**
* EJBCA Community provider (`A-01`).
*
* Talks to the REST API on port 8443 over mutual TLS, using the RA client
* certificate provisioned in `deploy/k8s/ca/README.md` § 5.
*/
import { Agent, request as undiciRequest } from 'undici';
import {
CaError,
type CaProvider,
type EnrollRequest,
type IssuedCertificate,
type RevocationReason,
} from './types';
export interface EjbcaConfig {
/** e.g. `https://ejbca.vnc-ca.svc.cluster.local:8443` */
readonly baseUrl: string;
/** RA client credential, PKCS#12 DER. */
readonly clientPfx: Buffer;
readonly clientPfxPassword: string;
/** PEM chain used to verify EJBCA's server certificate. */
readonly serverCaPem: string;
readonly caName: string;
readonly certificateProfile: string;
readonly endEntityProfile: string;
readonly id: string;
}
/** RFC 5280 reason code numbers, which is what the REST API wants. */
const REASON_CODES: Record<RevocationReason, string> = {
unspecified: 'UNSPECIFIED',
keyCompromise: 'KEY_COMPROMISE',
affiliationChanged: 'AFFILIATION_CHANGED',
superseded: 'SUPERSEDED',
cessationOfOperation: 'CESSATION_OF_OPERATION',
};
const TIMEOUT_MS = 20_000;
export class EjbcaProvider implements CaProvider {
readonly id: string;
private readonly agent: Agent;
constructor(private readonly config: EjbcaConfig) {
this.id = config.id;
this.agent = new Agent({
connect: {
// Mutual TLS. `pfx` + `passphrase` are passed through to tls.connect.
pfx: config.clientPfx,
passphrase: config.clientPfxPassword,
// Pin the CA's own chain rather than trusting the public root store.
// EJBCA serves a self-signed certificate on this listener by design
// (`TLS_SETUP_ENABLED=simple`), so public roots are the wrong anchor —
// and `rejectUnauthorized: false` would be worse than either, since it
// would let anything on the cluster network impersonate the CA and
// harvest CSRs.
ca: config.serverCaPem,
rejectUnauthorized: true,
},
headersTimeout: TIMEOUT_MS,
bodyTimeout: TIMEOUT_MS,
});
}
private async call(path: string, method: 'GET' | 'POST' | 'PUT', body?: unknown) {
let res;
try {
res = await undiciRequest(`${this.config.baseUrl}${path}`, {
method,
dispatcher: this.agent,
headers: { 'content-type': 'application/json', accept: 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
} catch (cause) {
// Transport failures include "the RA certificate was rejected" and "the
// pinned chain does not match". Both are configuration faults on our side,
// not the user's, so they must not surface as a client error.
throw new CaError('certificate authority unreachable', 503, cause);
}
const text = await res.body.text();
if (res.statusCode >= 400) {
// EJBCA error bodies can echo request content. Log server-side, return a
// generic message — an enrolment endpoint should not become a way to probe
// CA configuration.
console.error(`[smime-ca] ${method} ${path} -> ${res.statusCode}: ${text.slice(0, 500)}`);
if (res.statusCode === 401 || res.statusCode === 403) {
throw new CaError('certificate authority rejected our credential', 503);
}
throw new CaError('certificate authority refused the request', 502);
}
try {
return text ? JSON.parse(text) : {};
} catch (cause) {
throw new CaError('unparseable response from certificate authority', 502, cause);
}
}
async enroll(request: EnrollRequest): Promise<IssuedCertificate> {
if (request.addresses.length === 0) {
throw new CaError('no verified address to enrol', 400);
}
// Both forms, deliberately. The SAN `rfc822Name` is authoritative under RFC
// 5280/8550; the DN `emailAddress` attribute is legacy but still read by
// older Outlook. They must agree exactly — see finding 11 in
// `vnc/plugins/smime/`, where preferring the DN value over the SAN made
// genuine signatures read as "signer != From".
const primary = request.addresses[0];
const san = request.addresses.map((a) => `rfc822Name=${a}`).join(', ');
const body = {
certificate_request: request.csrPem,
certificate_profile_name: this.config.certificateProfile,
end_entity_profile_name: this.config.endEntityProfile,
certificate_authority_name: this.config.caName,
username: primary,
// The subject is supplied here, by us, from the verified identity — never
// taken from the CSR. See the note on `EnrollRequest.csrPem`.
subject_dn: `CN=${escapeDn(request.commonName)},E=${escapeDn(primary)},O=VNC AG,C=CH`,
subject_alt_name: san,
email: primary,
include_chain: true,
};
const data = await this.call(
'/ejbca/ejbca-rest-api/v1/certificate/pkcs10enroll',
'POST',
body,
);
const cert = pemFromBase64(data?.certificate, 'CERTIFICATE');
if (!cert) throw new CaError('certificate authority returned no certificate', 502);
const chain: string[] = Array.isArray(data?.certificate_chain)
? data.certificate_chain
.map((c: unknown) => pemFromBase64(c, 'CERTIFICATE'))
.filter((c: string | null): c is string => !!c)
: [];
return {
certificatePem: cert,
chainPem: chain,
serialNumber: String(data?.serial_number ?? ''),
issuerDn: String(data?.issuer_dn ?? ''),
notAfter: String(data?.expire_date ?? ''),
};
}
async revoke(serialNumber: string, reason: RevocationReason): Promise<void> {
if (!/^[0-9a-fA-F:]+$/.test(serialNumber)) {
throw new CaError('invalid serial number', 400);
}
const serial = serialNumber.replace(/:/g, '').toLowerCase();
const issuer = encodeURIComponent(await this.issuerDn());
await this.call(
`/ejbca/ejbca-rest-api/v1/certificate/${issuer}/${serial}/revoke`
+ `?reason=${REASON_CODES[reason]}`,
'PUT',
);
}
private cachedIssuerDn: string | null = null;
private async issuerDn(): Promise<string> {
if (this.cachedIssuerDn) return this.cachedIssuerDn;
const data = await this.call('/ejbca/ejbca-rest-api/v1/ca', 'GET');
const list: unknown[] = Array.isArray(data?.certificate_authorities)
? data.certificate_authorities
: [];
const match = list.find(
(ca) => (ca as { name?: string })?.name === this.config.caName,
) as { subject_dn?: string } | undefined;
if (!match?.subject_dn) {
throw new CaError(`CA "${this.config.caName}" not found`, 502);
}
this.cachedIssuerDn = match.subject_dn;
return match.subject_dn;
}
async getChain(): Promise<readonly string[]> {
const issuer = encodeURIComponent(await this.issuerDn());
const data = await this.call(
`/ejbca/ejbca-rest-api/v1/ca/${issuer}/certificate/download`,
'GET',
);
const chain: string[] = Array.isArray(data?.certificate_chain)
? data.certificate_chain
.map((c: unknown) => pemFromBase64(c, 'CERTIFICATE'))
.filter((c: string | null): c is string => !!c)
: [];
if (chain.length === 0) throw new CaError('certificate authority returned no chain', 502);
return chain;
}
}
/**
* Escape a DN component value per RFC 4514.
*
* The CN comes from a display name, which is attacker-influenced in the general
* case: an unescaped `,` or `+` would let it inject additional RDNs and change
* what the certificate asserts. The addresses are already constrained to the
* verified set, but escaping them costs nothing and removes the need to reason
* about whether a mail server could ever report an address containing a comma.
*/
function escapeDn(value: string): string {
return value
.replace(/([\\,+"<>;=])/g, '\\$1')
.replace(/^([ #])/, '\\$1')
.replace(/ $/, '\\ ')
// Control characters have no legitimate place in a DN — the class below
// is intentional, not a typo.
// eslint-disable-next-line no-control-regex
.replace(/[\x00-\x1f\x7f]/g, '');
}
function pemFromBase64(value: unknown, label: string): string | null {
if (typeof value !== 'string' || value.length === 0) return null;
if (value.includes('-----BEGIN')) return value.trim();
if (!/^[A-Za-z0-9+/=\s]+$/.test(value)) return null;
const b64 = value.replace(/\s+/g, '');
const lines = b64.match(/.{1,64}/g) ?? [];
return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----`;
}