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.
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { webcrypto } from 'node:crypto';
|
||||
import * as asn1js from 'asn1js';
|
||||
import * as pkijs from 'pkijs';
|
||||
import { LocalDevCaProvider } from '../local-dev-provider';
|
||||
|
||||
function pemToBer(pem: string): ArrayBuffer {
|
||||
const b64 = pem.replace(/-----BEGIN[^-]+-----/, '').replace(/-----END[^-]+-----/, '').replace(/\s+/g, '');
|
||||
const bin = Buffer.from(b64, 'base64');
|
||||
return bin.buffer.slice(bin.byteOffset, bin.byteOffset + bin.byteLength);
|
||||
}
|
||||
|
||||
function berToPem(der: ArrayBuffer, label: string): string {
|
||||
const b64 = Buffer.from(der).toString('base64');
|
||||
const lines = b64.match(/.{1,64}/g) ?? [];
|
||||
return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----`;
|
||||
}
|
||||
|
||||
/** Builds a real, validly-self-signed CSR — the same shape a browser's
|
||||
* WebCrypto-based plugin code would produce, just done here in Node so the
|
||||
* test needs no browser. */
|
||||
async function buildRealCsr(commonName: string): Promise<string> {
|
||||
const keys = await webcrypto.subtle.generateKey(
|
||||
{ name: 'RSASSA-PKCS1-v1_5', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' },
|
||||
true,
|
||||
['sign', 'verify'],
|
||||
) as CryptoKeyPair;
|
||||
|
||||
const csr = new pkijs.CertificationRequest();
|
||||
csr.version = 0;
|
||||
csr.subject.typesAndValues = [
|
||||
new pkijs.AttributeTypeAndValue({ type: '2.5.4.3', value: new asn1js.Utf8String({ value: commonName }) }),
|
||||
];
|
||||
await csr.subjectPublicKeyInfo.importKey(keys.publicKey);
|
||||
await csr.sign(keys.privateKey, 'SHA-256');
|
||||
return berToPem(csr.toSchema().toBER(false), 'CERTIFICATE REQUEST');
|
||||
}
|
||||
|
||||
describe('LocalDevCaProvider', () => {
|
||||
let stateDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
stateDir = await mkdtemp(path.join(tmpdir(), 'smime-dev-ca-test-'));
|
||||
process.env.ADMIN_STATE_DIR = stateDir;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
delete process.env.ADMIN_STATE_DIR;
|
||||
await rm(stateDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('issues a certificate signed by its own CA, honouring server-chosen addresses only', async () => {
|
||||
const provider = new LocalDevCaProvider();
|
||||
const csrPem = await buildRealCsr('CN the CSR asked for, should be ignored for SAN purposes');
|
||||
|
||||
const issued = await provider.enroll({
|
||||
csrPem,
|
||||
addresses: ['alice@example.com', 'alice.alt@example.com'],
|
||||
commonName: 'Alice Example',
|
||||
});
|
||||
|
||||
expect(issued.certificatePem).toContain('BEGIN CERTIFICATE');
|
||||
expect(issued.chainPem).toHaveLength(1);
|
||||
expect(issued.serialNumber).toMatch(/^[0-9a-f]+$/i);
|
||||
|
||||
const leaf = pkijs.Certificate.fromBER(pemToBer(issued.certificatePem));
|
||||
const caCert = pkijs.Certificate.fromBER(pemToBer(issued.chainPem[0]));
|
||||
|
||||
// Real cryptographic chain verification, not just "a string looks like a cert".
|
||||
const chainVerified = await leaf.verify(caCert);
|
||||
expect(chainVerified).toBe(true);
|
||||
|
||||
// The certificate must assert exactly the server-provided addresses,
|
||||
// never anything from the CSR's own (ignored) subject. rfc822Name SAN
|
||||
// entries are IA5String (plain ASCII) - checking the raw extension
|
||||
// bytes contain exactly these addresses, and nothing the CSR's own
|
||||
// subject claimed, is a robust check without fighting pkijs's
|
||||
// re-parse-from-schema API for a value this code itself just built.
|
||||
const sanExt = leaf.extensions?.find((e) => e.extnID === '2.5.29.17');
|
||||
expect(sanExt).toBeDefined();
|
||||
const sanRaw = Buffer.from(sanExt!.extnValue.valueBlock.valueHexView).toString('latin1');
|
||||
expect(sanRaw).toContain('alice@example.com');
|
||||
expect(sanRaw).toContain('alice.alt@example.com');
|
||||
expect(sanRaw).not.toContain('CN the CSR asked for');
|
||||
|
||||
// extKeyUsage must include emailProtection (OID 1.3.6.1.5.5.7.3.4,
|
||||
// DER-encoded as the raw bytes below) - otherwise no real S/MIME client
|
||||
// accepts the certificate for signing/encryption.
|
||||
const ekuExt = leaf.extensions?.find((e) => e.extnID === '2.5.29.37');
|
||||
expect(ekuExt).toBeDefined();
|
||||
const emailProtectionOidDer = Buffer.from([0x06, 0x08, 0x2b, 0x06, 0x01, 0x05, 0x05, 0x07, 0x03, 0x04]);
|
||||
expect(Buffer.from(ekuExt!.extnValue.valueBlock.valueHexView).includes(emailProtectionOidDer)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a CSR with a forged/mismatched signature', async () => {
|
||||
const provider = new LocalDevCaProvider();
|
||||
const validPem = await buildRealCsr('Whatever');
|
||||
// Corrupt one byte in the middle of the base64 body to break the signature
|
||||
// without breaking PEM framing.
|
||||
const lines = validPem.split('\n');
|
||||
const bodyIdx = Math.floor(lines.length / 2);
|
||||
lines[bodyIdx] = lines[bodyIdx].slice(0, -4) + (lines[bodyIdx].slice(-4) === 'AAAA' ? 'BBBB' : 'AAAA');
|
||||
const tamperedPem = lines.join('\n');
|
||||
|
||||
await expect(
|
||||
provider.enroll({ csrPem: tamperedPem, addresses: ['x@example.com'], commonName: 'X' }),
|
||||
).rejects.toThrow(/parsed|signature/i);
|
||||
});
|
||||
|
||||
it('persists the same CA across calls (does not mint a new root every time)', async () => {
|
||||
const provider = new LocalDevCaProvider();
|
||||
const chain1 = await provider.getChain();
|
||||
const chain2 = await provider.getChain();
|
||||
expect(chain1[0]).toBe(chain2[0]);
|
||||
});
|
||||
|
||||
it('records a revocation', async () => {
|
||||
const provider = new LocalDevCaProvider();
|
||||
const csrPem = await buildRealCsr('Bob');
|
||||
const issued = await provider.enroll({ csrPem, addresses: ['bob@example.com'], commonName: 'Bob' });
|
||||
await expect(provider.revoke(issued.serialNumber, 'keyCompromise')).resolves.toBeUndefined();
|
||||
|
||||
const stateFile = path.join(stateDir, 'smime-dev-ca.json');
|
||||
const stored = JSON.parse(await (await import('node:fs/promises')).readFile(stateFile, 'utf-8'));
|
||||
expect(stored.revokedSerials).toContain(issued.serialNumber);
|
||||
});
|
||||
});
|
||||
+12
-1
@@ -1,5 +1,6 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { EjbcaProvider } from './ejbca';
|
||||
import { LocalDevCaProvider } from './local-dev-provider';
|
||||
import type { CaProvider } from './types';
|
||||
|
||||
export * from './types';
|
||||
@@ -21,7 +22,17 @@ export function getCaProvider(): CaProvider | null {
|
||||
|
||||
function build(): CaProvider | null {
|
||||
const baseUrl = process.env.SMIME_CA_URL;
|
||||
if (!baseUrl) return null;
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
// A real, working local CA implementing the same `CaProvider` seam as the
|
||||
// production EJBCA integration (lib/smime-ca/ejbca.ts) — for when the real
|
||||
// CA isn't reachable (it needs a client mTLS certificate + password this
|
||||
// dev environment doesn't have; the real CA lives on the private dev-k8s
|
||||
// network anyway). This is NOT a mock: it generates a real RSA-2048 root
|
||||
// key, signs real CSRs into real, correctly-extensioned X.509 certificates
|
||||
// using pkijs, and every plugin crypto operation (sign/encrypt/decrypt/
|
||||
// verify) that runs against a cert issued here is exercising the exact same
|
||||
// code path it would against a production EJBCA-issued cert. The ONLY
|
||||
// difference from production is who signed the leaf.
|
||||
//
|
||||
// Loudly NOT for production: the root key is generated on first use and
|
||||
// persisted in the app's own state dir, unprotected by an HSM or even a
|
||||
// passphrase — exactly the kind of shortcut a real CA (§4 of
|
||||
// deploy/k8s/ca/README.md) exists to avoid. `build()` in lib/smime-ca/index.ts
|
||||
// only reaches for this when SMIME_CA_DEV_LOCAL=true is explicitly set,
|
||||
// never as a silent fallback.
|
||||
import { readFile, writeFile, rename } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import * as asn1js from 'asn1js';
|
||||
import * as pkijs from 'pkijs';
|
||||
import { webcrypto } from 'node:crypto';
|
||||
import { getStatePath, ensureStateDir } from '@/lib/admin/paths';
|
||||
import { CaError, type CaProvider, type EnrollRequest, type IssuedCertificate, type RevocationReason } from './types';
|
||||
|
||||
pkijs.setEngine('node-webcrypto', new pkijs.CryptoEngine({ name: 'node-webcrypto', crypto: webcrypto as Crypto }));
|
||||
|
||||
const CA_STATE_FILE = 'smime-dev-ca.json';
|
||||
const CERT_VALIDITY_DAYS = 397; // matches typical public-CA S/MIME leaf lifetimes
|
||||
const CA_VALIDITY_YEARS = 5;
|
||||
|
||||
interface StoredCa {
|
||||
privateKeyPkcs8Base64: string;
|
||||
certificatePem: string;
|
||||
revokedSerials: string[];
|
||||
}
|
||||
|
||||
function pemToBer(pem: string): ArrayBuffer {
|
||||
const b64 = pem.replace(/-----BEGIN[^-]+-----/, '').replace(/-----END[^-]+-----/, '').replace(/\s+/g, '');
|
||||
const bin = Buffer.from(b64, 'base64');
|
||||
return bin.buffer.slice(bin.byteOffset, bin.byteOffset + bin.byteLength);
|
||||
}
|
||||
|
||||
function berToPem(der: ArrayBuffer, label: string): string {
|
||||
const b64 = Buffer.from(der).toString('base64');
|
||||
const lines = b64.match(/.{1,64}/g) ?? [];
|
||||
return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----`;
|
||||
}
|
||||
|
||||
function randomSerial(): asn1js.Integer {
|
||||
const bytes = webcrypto.getRandomValues(new Uint8Array(16));
|
||||
bytes[0] &= 0x7f; // keep it a positive INTEGER
|
||||
return new asn1js.Integer({ valueHex: bytes.buffer });
|
||||
}
|
||||
|
||||
function serialToHex(serial: asn1js.Integer): string {
|
||||
return Buffer.from(serial.valueBlock.valueHexView).toString('hex');
|
||||
}
|
||||
|
||||
function buildName(commonName: string, org: string): pkijs.AttributeTypeAndValue[] {
|
||||
return [
|
||||
new pkijs.AttributeTypeAndValue({
|
||||
type: '2.5.4.3', // commonName
|
||||
value: new asn1js.Utf8String({ value: commonName }),
|
||||
}),
|
||||
new pkijs.AttributeTypeAndValue({
|
||||
type: '2.5.4.10', // organizationName
|
||||
value: new asn1js.Utf8String({ value: org }),
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
async function generateCaKeyPair(): Promise<webcrypto.CryptoKeyPair> {
|
||||
return webcrypto.subtle.generateKey(
|
||||
{ name: 'RSASSA-PKCS1-v1_5', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' },
|
||||
true,
|
||||
['sign', 'verify'],
|
||||
) as Promise<webcrypto.CryptoKeyPair>;
|
||||
}
|
||||
|
||||
async function createSelfSignedCa(): Promise<{ cert: pkijs.Certificate; keys: webcrypto.CryptoKeyPair }> {
|
||||
const keys = await generateCaKeyPair();
|
||||
const cert = new pkijs.Certificate();
|
||||
cert.version = 2;
|
||||
cert.serialNumber = randomSerial();
|
||||
cert.issuer.typesAndValues = buildName('VNCmail+ LOCAL DEV S/MIME CA — NOT FOR PRODUCTION', 'VNCmail+ dev');
|
||||
cert.subject.typesAndValues = buildName('VNCmail+ LOCAL DEV S/MIME CA — NOT FOR PRODUCTION', 'VNCmail+ dev');
|
||||
const now = new Date();
|
||||
cert.notBefore.value = now;
|
||||
cert.notAfter.value = new Date(now.getTime() + CA_VALIDITY_YEARS * 365 * 24 * 60 * 60 * 1000);
|
||||
|
||||
// pkijs's own type declarations expect the DOM CryptoKey type; Node's
|
||||
// webcrypto.CryptoKey is structurally compatible at runtime (verified by
|
||||
// the passing round-trip tests) but nominally distinct (KeyUsage union
|
||||
// differs), hence the boundary casts at every pkijs call below.
|
||||
await cert.subjectPublicKeyInfo.importKey(keys.publicKey as unknown as CryptoKey);
|
||||
|
||||
cert.extensions = [
|
||||
new pkijs.Extension({
|
||||
extnID: '2.5.29.19', // basicConstraints
|
||||
critical: true,
|
||||
extnValue: new pkijs.BasicConstraints({ cA: true, pathLenConstraint: 0 }).toSchema().toBER(false),
|
||||
}),
|
||||
new pkijs.Extension({
|
||||
extnID: '2.5.29.15', // keyUsage: keyCertSign, cRLSign
|
||||
critical: true,
|
||||
extnValue: new asn1js.BitString({ valueHex: new Uint8Array([0b00000110]).buffer }).toBER(false),
|
||||
}),
|
||||
];
|
||||
|
||||
await cert.sign(keys.privateKey as unknown as CryptoKey, 'SHA-256');
|
||||
return { cert, keys };
|
||||
}
|
||||
|
||||
async function loadOrCreateCa(): Promise<{ cert: pkijs.Certificate; privateKey: webcrypto.CryptoKey; certificatePem: string; revokedSerials: string[] }> {
|
||||
const path = getStatePath(CA_STATE_FILE);
|
||||
if (existsSync(path)) {
|
||||
const stored = JSON.parse(await readFile(path, 'utf-8')) as StoredCa;
|
||||
const privateKey = await webcrypto.subtle.importKey(
|
||||
'pkcs8',
|
||||
Buffer.from(stored.privateKeyPkcs8Base64, 'base64'),
|
||||
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign'],
|
||||
);
|
||||
const cert = pkijs.Certificate.fromBER(pemToBer(stored.certificatePem));
|
||||
return { cert, privateKey, certificatePem: stored.certificatePem, revokedSerials: stored.revokedSerials };
|
||||
}
|
||||
|
||||
const { cert, keys } = await createSelfSignedCa();
|
||||
const certificatePem = berToPem(cert.toSchema().toBER(false), 'CERTIFICATE');
|
||||
const pkcs8 = await webcrypto.subtle.exportKey('pkcs8', keys.privateKey);
|
||||
const stored: StoredCa = {
|
||||
privateKeyPkcs8Base64: Buffer.from(pkcs8).toString('base64'),
|
||||
certificatePem,
|
||||
revokedSerials: [],
|
||||
};
|
||||
await ensureStateDir();
|
||||
const tmp = path + '.tmp';
|
||||
await writeFile(tmp, JSON.stringify(stored, null, 2), 'utf-8');
|
||||
await rename(tmp, path);
|
||||
return { cert, privateKey: keys.privateKey, certificatePem, revokedSerials: [] };
|
||||
}
|
||||
|
||||
async function persistRevocation(serial: string): Promise<void> {
|
||||
const path = getStatePath(CA_STATE_FILE);
|
||||
const stored = JSON.parse(await readFile(path, 'utf-8')) as StoredCa;
|
||||
if (!stored.revokedSerials.includes(serial)) stored.revokedSerials.push(serial);
|
||||
await ensureStateDir();
|
||||
const tmp = path + '.tmp';
|
||||
await writeFile(tmp, JSON.stringify(stored, null, 2), 'utf-8');
|
||||
await rename(tmp, path);
|
||||
}
|
||||
|
||||
export class LocalDevCaProvider implements CaProvider {
|
||||
readonly id = 'local-dev';
|
||||
|
||||
async enroll(request: EnrollRequest): Promise<IssuedCertificate> {
|
||||
const ca = await loadOrCreateCa();
|
||||
|
||||
let csr: pkijs.CertificationRequest;
|
||||
try {
|
||||
csr = pkijs.CertificationRequest.fromBER(pemToBer(request.csrPem));
|
||||
} catch (cause) {
|
||||
throw new CaError('CSR could not be parsed', 400, cause);
|
||||
}
|
||||
|
||||
// Proof of possession: the CSR must be signed by the private key
|
||||
// matching its own public key. This is NOT identity verification (the
|
||||
// interface's whole point is that identity comes from `request.addresses`,
|
||||
// never the CSR) - it only confirms the requester actually holds the
|
||||
// key they're asking to be certified, same as any CA would check.
|
||||
const verified = await csr.verify().catch(() => false);
|
||||
if (!verified) {
|
||||
throw new CaError('CSR signature does not verify against its own public key', 400);
|
||||
}
|
||||
|
||||
const leaf = new pkijs.Certificate();
|
||||
leaf.version = 2;
|
||||
leaf.serialNumber = randomSerial();
|
||||
leaf.issuer.typesAndValues = ca.cert.subject.typesAndValues;
|
||||
leaf.subject.typesAndValues = buildName(request.commonName, 'VNCmail+ dev');
|
||||
const now = new Date();
|
||||
leaf.notBefore.value = now;
|
||||
leaf.notAfter.value = new Date(now.getTime() + CERT_VALIDITY_DAYS * 24 * 60 * 60 * 1000);
|
||||
leaf.subjectPublicKeyInfo = csr.subjectPublicKeyInfo;
|
||||
|
||||
const sanNames = request.addresses.map((address) => new pkijs.GeneralName({ type: 1, value: address })); // type 1 = rfc822Name
|
||||
leaf.extensions = [
|
||||
new pkijs.Extension({
|
||||
extnID: '2.5.29.19',
|
||||
critical: true,
|
||||
extnValue: new pkijs.BasicConstraints({ cA: false }).toSchema().toBER(false),
|
||||
}),
|
||||
new pkijs.Extension({
|
||||
// digitalSignature + nonRepudiation + keyEncipherment
|
||||
extnID: '2.5.29.15',
|
||||
critical: true,
|
||||
extnValue: new asn1js.BitString({ valueHex: new Uint8Array([0b11100000]).buffer }).toBER(false),
|
||||
}),
|
||||
new pkijs.Extension({
|
||||
extnID: '2.5.29.37', // extKeyUsage
|
||||
critical: false,
|
||||
extnValue: new pkijs.ExtKeyUsage({ keyPurposes: ['1.3.6.1.5.5.7.3.4'] }).toSchema().toBER(false), // emailProtection
|
||||
}),
|
||||
new pkijs.Extension({
|
||||
extnID: '2.5.29.17', // subjectAltName
|
||||
critical: false,
|
||||
extnValue: new pkijs.GeneralNames({ names: sanNames }).toSchema().toBER(false),
|
||||
}),
|
||||
];
|
||||
|
||||
await leaf.sign(ca.privateKey as unknown as CryptoKey, 'SHA-256');
|
||||
|
||||
return {
|
||||
certificatePem: berToPem(leaf.toSchema().toBER(false), 'CERTIFICATE'),
|
||||
chainPem: [ca.certificatePem],
|
||||
serialNumber: serialToHex(leaf.serialNumber),
|
||||
issuerDn: 'CN=VNCmail+ LOCAL DEV S/MIME CA — NOT FOR PRODUCTION,O=VNCmail+ dev',
|
||||
notAfter: leaf.notAfter.value.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async revoke(serialNumber: string, _reason: RevocationReason): Promise<void> {
|
||||
await persistRevocation(serialNumber);
|
||||
}
|
||||
|
||||
async getChain(): Promise<readonly string[]> {
|
||||
const ca = await loadOrCreateCa();
|
||||
return [ca.certificatePem];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user