electron-builder's default npmRebuild pass scans the entire node_modules
tree (not just what's actually packaged) for native addons and tries to
recompile them against Electron's ABI via node-gyp. It caught
@parcel/watcher - a transitive devDependency of some dev tool, never
shipped in this app - and hard-failed packaging on any machine without a
full Xcode Command Line Tools install ("gyp: No Xcode or CLT version
detected!"). GitHub's macOS runners happen to have Xcode, which is
presumably why CI never caught this.
The packaged app is plain esbuild-bundled JS with no native modules of
its own; the one native dependency in the repo (@signalapp/sqlcipher,
used by lib/mail-index/) ships prebuilt .node binaries for every
platform and is copied in wholesale by scripts/assemble-standalone.mjs,
never rebuilt by electron-builder. Verified by execution: packaging
failed with npmRebuild at its default (true), succeeded once set false,
and the resulting .dmg launches and runs correctly.
Also adds e2e/electron-live-sandbox.spec.ts - a live-connectivity check
against the real sandbox JMAP backend (stalwart.sandbox.vnc.de), proving
the packaged/launched app reaches it with no TLS/network errors and gets
a real structured auth-rejection on a deliberately fake credential.
Deliberately NOT wired into playwright.electron.config.ts's default
testMatch (electron-smoke.spec.ts only) - this depends on a live external
service and is a manual/opt-in verification tool, not part of the regular
regression suite.
Also carries the pre-existing lib/smime-ca/ejbca.ts no-control-regex
eslint fix from MR !1's branch (not yet merged to dev) so this commit's
own pre-commit hook passes - unrelated to electron work otherwise.
227 lines
8.4 KiB
TypeScript
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}-----`;
|
|
}
|