node-opcua-pki
Version:
PKI management for node-opcua
1 lines • 341 kB
Source Map (JSON)
{"version":3,"sources":["../lib/ca/certificate_authority.ts","../lib/pki/toolbox_pfx.ts","../lib/toolbox/common.ts","../lib/toolbox/common2.ts","../lib/toolbox/config.ts","../lib/toolbox/debug.ts","../lib/toolbox/with_openssl/execute_openssl.ts","../lib/toolbox/with_openssl/_env.ts","../lib/toolbox/with_openssl/install_prerequisite.ts","../lib/toolbox/display.ts","../lib/toolbox/with_openssl/index.ts","../lib/toolbox/with_openssl/create_certificate_signing_request.ts","../lib/misc/subject.ts","../lib/toolbox/with_openssl/toolbox.ts","../lib/pki/templates/simple_config_template.cnf.ts","../lib/ca/templates/ca_config_template.cnf.ts","../lib/pki/certificate_manager.ts","../lib/toolbox/without_openssl/create_certificate_signing_request.ts","../lib/toolbox/without_openssl/create_self_signed_certificate.ts"],"sourcesContent":["// ---------------------------------------------------------------------------------------------------------------------\n// node-opcua\n// ---------------------------------------------------------------------------------------------------------------------\n// Copyright (c) 2014-2026 - Etienne Rossignon - etienne.rossignon (at) gadz.org\n// Copyright (c) 2022-2026 - Sterfive.com\n// ---------------------------------------------------------------------------------------------------------------------\n//\n// This project is licensed under the terms of the MIT license.\n//\n// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated\n// documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the\n// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to\n// permit persons to whom the Software is furnished to do so, subject to the following conditions:\n//\n// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the\n// Software.\n//\n// THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE\n// WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR\n// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR\n// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n// ---------------------------------------------------------------------------------------------------------------------\n// tslint:disable:no-shadowed-variable\nimport assert from \"node:assert\";\nimport fs from \"node:fs\";\nimport os from \"node:os\";\nimport path from \"node:path\";\nimport chalk from \"chalk\";\nimport {\n CertificatePurpose,\n certificateMatchesPrivateKey,\n convertPEMtoDER,\n exploreCertificate,\n exploreCertificateSigningRequest,\n generatePrivateKeyFile,\n type PrivateKey,\n readCertificatePEM,\n readCertificateSigningRequest,\n readPrivateKey,\n Subject,\n type SubjectOptions,\n toPem\n} from \"node-opcua-crypto\";\nimport { createPFX } from \"../pki/toolbox_pfx\";\nimport {\n adjustApplicationUri,\n adjustDate,\n certificateFileExist,\n debugLog,\n displaySubtitle,\n displayTitle,\n type Filename,\n type KeySize,\n makePath,\n mkdirRecursiveSync,\n type Params,\n type ProcessAltNamesParam,\n quote\n} from \"../toolbox\";\nimport {\n createCertificateSigningRequestWithOpenSSL,\n type ExecuteOpenSSLOptions,\n type ExecuteOptions,\n ensure_openssl_installed,\n execute_openssl,\n execute_openssl_no_failure,\n generateStaticConfig,\n processAltNames,\n setEnv,\n unsetEnv,\n x509Date\n} from \"../toolbox/with_openssl\";\n\n/** Default X.500 subject used when no custom subject is provided. */\nexport const defaultSubject = \"/C=FR/ST=IDF/L=Paris/O=Local NODE-OPCUA Certificate Authority/CN=NodeOPCUA-CA\";\n\nimport _simple_config_template from \"../pki/templates/simple_config_template.cnf\";\nimport _ca_config_template from \"./templates/ca_config_template.cnf\";\n\n// tslint:disable-next-line:variable-name\nexport const configurationFileTemplate: string = _ca_config_template;\nconst configurationFileSimpleTemplate: string = _simple_config_template;\n\nconst config = {\n certificateDir: \"INVALID\",\n forceCA: false,\n pkiDir: \"INVALID\"\n};\n\nconst n = makePath;\nconst q = quote;\n\n// convert 'c07b9179' to \"192.123.145.121\"\nfunction octetStringToIpAddress(a: string) {\n return (\n parseInt(a.substring(0, 2), 16).toString() +\n \".\" +\n parseInt(a.substring(2, 4), 16).toString() +\n \".\" +\n parseInt(a.substring(4, 6), 16).toString() +\n \".\" +\n parseInt(a.substring(6, 8), 16).toString()\n );\n}\nassert(octetStringToIpAddress(\"c07b9179\") === \"192.123.145.121\");\nasync function construct_CertificateAuthority(certificateAuthority: CertificateAuthority): Promise<void> {\n // create the CA directory store\n // create the CA directory store\n //\n // PKI/CA\n // |\n // +-+> private\n // |\n // +-+> public\n // |\n // +-+> certs\n // |\n // +-+> crl\n // |\n // +-+> conf\n // |\n // +-f: serial\n // +-f: crlNumber\n // +-f: index.txt\n //\n\n const subject = certificateAuthority.subject;\n\n const caRootDir = path.resolve(certificateAuthority.rootDir);\n\n async function make_folders() {\n mkdirRecursiveSync(caRootDir);\n mkdirRecursiveSync(path.join(caRootDir, \"private\"));\n mkdirRecursiveSync(path.join(caRootDir, \"public\"));\n // xx execute(\"chmod 700 private\");\n mkdirRecursiveSync(path.join(caRootDir, \"certs\"));\n mkdirRecursiveSync(path.join(caRootDir, \"crl\"));\n mkdirRecursiveSync(path.join(caRootDir, \"conf\"));\n }\n await make_folders();\n\n async function construct_default_files() {\n const serial = path.join(caRootDir, \"serial\");\n if (!fs.existsSync(serial)) {\n await fs.promises.writeFile(serial, \"1000\");\n }\n\n const crlNumber = path.join(caRootDir, \"crlnumber\");\n if (!fs.existsSync(crlNumber)) {\n await fs.promises.writeFile(crlNumber, \"1000\");\n }\n\n const indexFile = path.join(caRootDir, \"index.txt\");\n if (!fs.existsSync(indexFile)) {\n await fs.promises.writeFile(indexFile, \"\");\n }\n }\n\n await construct_default_files();\n\n const caKeyExists = fs.existsSync(path.join(caRootDir, \"private/cakey.pem\"));\n const caCertExists = fs.existsSync(path.join(caRootDir, \"public/cacert.pem\"));\n if (caKeyExists && caCertExists && !config.forceCA) {\n // CA is fully initialized => do not overwrite\n debugLog(\"CA private key and certificate already exist ... skipping\");\n return;\n }\n if (caKeyExists && !caCertExists) {\n // Partial init: key exists but certificate does not.\n // This can happen when a previous CA creation failed\n // (e.g. OpenSSL 3.5 authorityKeyIdentifier error).\n // Remove the stale key so the CA is rebuilt from scratch.\n debugLog(\"CA private key exists but cacert.pem is missing — rebuilding CA\");\n fs.unlinkSync(path.join(caRootDir, \"private/cakey.pem\"));\n // Also remove the stale CSR if present\n const staleCsr = path.join(caRootDir, \"private/cakey.csr\");\n if (fs.existsSync(staleCsr)) {\n fs.unlinkSync(staleCsr);\n }\n }\n\n // tslint:disable:no-empty\n displayTitle(\"Create Certificate Authority (CA)\");\n\n const indexFileAttr = path.join(caRootDir, \"index.txt.attr\");\n if (!fs.existsSync(indexFileAttr)) {\n await fs.promises.writeFile(indexFileAttr, \"unique_subject = no\");\n }\n\n const caConfigFile = certificateAuthority.configFile;\n if (1 || !fs.existsSync(caConfigFile)) {\n let data = configurationFileTemplate; // inlineText(configurationFile);\n data = makePath(data.replace(/%%ROOT_FOLDER%%/, caRootDir));\n\n await fs.promises.writeFile(caConfigFile, data);\n }\n\n // http://www.akadia.com/services/ssh_test_certificate.html\n const subjectOpt = ` -subj \"${subject.toString()}\" `;\n\n // OPC UA validators (UaExpert, compliance tools) require every\n // certificate — including the root/intermediate CA — to carry a\n // SubjectAltName extension. RFC 5280 does not strictly mandate it\n // on CA certificates but the OPC UA Profile does. Build a stable\n // URI SAN derived from the CA CommonName so the resulting CA cert\n // passes the spec check.\n const caCommonName = subject.commonName || \"NodeOPCUA-CA\";\n setEnv(\"ALTNAME\", `URI:urn:${caCommonName}`);\n // Wire opt-in CDP / AIA env vars (US-202) so the conditional\n // blocks in `[ v3_ca ]` are kept (and substituted) when the CA\n // is configured with URLs, stripped otherwise.\n certificateAuthority._wireRevocationEnvVars();\n\n const options = { cwd: caRootDir };\n const configFile = generateStaticConfig(\"conf/caconfig.cnf\", options);\n const configOption = ` -config ${q(n(configFile))}`;\n\n const keySize = certificateAuthority.keySize;\n\n const privateKeyFilename = path.join(caRootDir, \"private/cakey.pem\");\n const csrFilename = path.join(caRootDir, \"private/cakey.csr\");\n\n displayTitle(`Generate the CA private Key - ${keySize}`);\n // The first step is to create your RSA Private Key.\n // This key is a 1025,2048,3072 or 2038 bit RSA key which is encrypted using\n // Triple-DES and stored in a PEM format so that it is readable as ASCII text.\n await generatePrivateKeyFile(privateKeyFilename, keySize);\n displayTitle(\"Generate a certificate request for the CA key\");\n // Once the private key is generated a Certificate Signing Request can be generated.\n // The CSR is then used in one of two ways. Ideally, the CSR will be sent to a Certificate Authority, such as\n // Thawte or Verisign who will verify the identity of the requestor and issue a signed certificate.\n // The second option is to self-sign the CSR, which will be demonstrated in the next section\n await execute_openssl(\n \"req -new\" +\n \" -sha256 \" +\n \" -text \" +\n \" -extensions v3_ca_req\" +\n configOption +\n \" -key \" +\n q(n(privateKeyFilename)) +\n \" -out \" +\n q(n(csrFilename)) +\n \" \" +\n subjectOpt,\n options\n );\n\n // xx // Step 3: Remove Passphrase from Key\n // xx execute(\"cp private/cakey.pem private/cakey.pem.org\");\n // xx execute(openssl_path + \" rsa -in private/cakey.pem.org -out private/cakey.pem -passin pass:\"+paraphrase);\n\n const issuerCA = certificateAuthority._issuerCA;\n if (issuerCA) {\n // Subordinate (intermediate) CA — signed by the parent CA\n displayTitle(\"Generate CA Certificate (signed by issuer CA)\");\n const issuerCert = path.resolve(issuerCA.caCertificate);\n const issuerKey = path.resolve(issuerCA.rootDir, \"private/cakey.pem\");\n const issuerSerial = path.resolve(issuerCA.rootDir, \"serial\");\n await execute_openssl(\n \" x509 -sha256 -req -days 3650 \" +\n \" -text \" +\n \" -extensions v3_ca\" +\n \" -extfile \" +\n q(n(configFile)) +\n \" -in private/cakey.csr \" +\n \" -CA \" +\n q(n(issuerCert)) +\n \" -CAkey \" +\n q(n(issuerKey)) +\n \" -CAserial \" +\n q(n(issuerSerial)) +\n \" -out public/cacert.pem\",\n options\n );\n } else {\n // Root CA — self-signed\n displayTitle(\"Generate CA Certificate (self-signed)\");\n await execute_openssl(\n \" x509 -sha256 -req -days 3650 \" +\n \" -text \" +\n \" -extensions v3_ca\" +\n \" -extfile \" +\n q(n(configFile)) +\n \" -in private/cakey.csr \" +\n \" -signkey \" +\n q(n(privateKeyFilename)) +\n \" -out public/cacert.pem\",\n options\n );\n }\n displaySubtitle(\"generate initial CRL (Certificate Revocation List)\");\n await regenerateCrl(certificateAuthority.revocationList, configOption, options);\n displayTitle(\"Create Certificate Authority (CA) ---> DONE\");\n}\n\nasync function regenerateCrl(revocationList: string, configOption: string, options: ExecuteOpenSSLOptions) {\n // produce a CRL in PEM format\n displaySubtitle(\"regenerate CRL (Certificate Revocation List)\");\n await execute_openssl(`ca -gencrl ${configOption} -out crl/revocation_list.crl`, options);\n await execute_openssl(\"crl \" + \" -in crl/revocation_list.crl -out crl/revocation_list.der \" + \" -outform der\", options);\n\n displaySubtitle(\"Display (Certificate Revocation List)\");\n await execute_openssl(`crl -in ${q(n(revocationList))} -text -noout`, options);\n}\n\n/**\n * Result of {@link CertificateAuthority.initializeCSR}.\n *\n * - `\"ready\"` — the CA certificate already exists and is valid.\n * - `\"pending\"` — key + CSR exist but no cert; waiting for external signing.\n * - `\"created\"` — a fresh key + CSR were just generated.\n * - `\"expired\"` — the CA certificate has expired (or will expire within\n * the configured threshold). A new CSR has been generated for renewal\n * while preserving the existing private key.\n */\nexport type InitializeCSRResult =\n | { status: \"ready\" }\n | { status: \"pending\"; csrPath: string }\n | { status: \"created\"; csrPath: string }\n | { status: \"expired\"; csrPath: string; expiryDate: Date };\n\n/**\n * Result of {@link CertificateAuthority.installCACertificate}.\n *\n * - `\"success\"` — the certificate was installed and CRL generated.\n * - `\"error\"` — the certificate was rejected (see `reason`).\n */\nexport type InstallCACertificateResult = { status: \"success\" } | { status: \"error\"; reason: string; message: string };\n\n/**\n * Options for creating a {@link CertificateAuthority}.\n */\nexport interface CertificateAuthorityOptions {\n /** RSA key size for the CA private key. */\n keySize: KeySize;\n /** Filesystem path where the CA directory structure is stored. */\n location: string;\n /**\n * X.500 subject for the CA certificate.\n * Accepts a slash-delimited string (e.g. `\"/CN=My CA/O=Acme\"`) or\n * a structured {@link SubjectOptions} object.\n *\n * @defaultValue {@link defaultSubject}\n */\n subject?: string | SubjectOptions;\n /**\n * Parent CA that will sign this CA's certificate.\n * If omitted, the CA is self-signed (root CA).\n * The parent CA must be initialized before this CA.\n */\n issuerCA?: CertificateAuthority;\n /**\n * Public URL (http/https) where the CRL produced by this CA is\n * reachable. When set, every issued certificate carries an\n * X.509v3 `crlDistributionPoints` extension pointing at this URL.\n *\n * Leave undefined to omit the extension entirely (opt-in — see\n * US-202). Validated synchronously at construction / setter call.\n */\n crlDistributionUrl?: string;\n /**\n * Public URL of the OCSP responder. When set, every issued cert\n * carries an `authorityInfoAccess` extension with an `OCSP` leg\n * pointing at this URL. Leave undefined to omit (US-202).\n */\n ocspResponderUrl?: string;\n /**\n * Public URL where the issuer's certificate can be fetched.\n * When set, the `authorityInfoAccess` extension on every issued\n * cert carries a `caIssuers` leg pointing at this URL (chain\n * repair). Leave undefined to omit (US-202).\n */\n caIssuersUrl?: string;\n}\n\n/**\n * An OpenSSL-based Certificate Authority (CA) that can create,\n * sign, and revoke X.509 certificates.\n *\n * The CA maintains a standard OpenSSL directory layout under\n * {@link CertificateAuthority.rootDir | rootDir}:\n *\n * ```\n * <location>/\n * ├── conf/ OpenSSL configuration\n * ├── private/ CA private key (cakey.pem)\n * ├── public/ CA certificate (cacert.pem)\n * ├── certs/ Signed certificates\n * ├── crl/ Revocation lists\n * ├── serial Next serial number\n * ├── crlnumber Next CRL number\n * └── index.txt Certificate database\n * ```\n *\n * @example\n * ```ts\n * const ca = new CertificateAuthority({\n * keySize: 2048,\n * location: \"/var/pki/CA\"\n * });\n * await ca.initialize();\n * ```\n */\n\n// ---------------------------------------------------------------\n// Certificate database types (US-057)\n// ---------------------------------------------------------------\n\n/**\n * A record from the OpenSSL CA certificate database\n * (`index.txt`).\n */\nexport interface IssuedCertificateRecord {\n /** Hex-encoded serial number (e.g. `\"1000\"`). */\n serial: string;\n /** Certificate status. */\n status: \"valid\" | \"revoked\" | \"expired\";\n /** X.500 subject string (slash-delimited). */\n subject: string;\n /** Certificate expiry date as ISO-8601 string. */\n expiryDate: string;\n /**\n * Revocation date as ISO-8601 string.\n * Only present when `status === \"revoked\"`.\n */\n revocationDate?: string;\n}\n\n/**\n * Parse an OpenSSL date string (`YYMMDDHHmmssZ`) into an\n * ISO-8601 string.\n */\nfunction parseOpenSSLDate(dateStr: string): string {\n // Revocation dates may have a reason suffix: \"YYMMDDHHmmssZ,reason\"\n // Strip anything after the first comma.\n const raw = dateStr?.split(\",\")[0] ?? \"\";\n if (raw.length < 12) return \"\";\n // OpenSSL uses 2-digit year; 70+ is 19xx, <70 is 20xx\n const yy = parseInt(raw.substring(0, 2), 10);\n const year = yy >= 70 ? 1900 + yy : 2000 + yy;\n const month = raw.substring(2, 4);\n const day = raw.substring(4, 6);\n const hour = raw.substring(6, 8);\n const min = raw.substring(8, 10);\n const sec = raw.substring(10, 12);\n return `${year}-${month}-${day}T${hour}:${min}:${sec}Z`;\n}\n\n/**\n * Options for {@link CertificateAuthority.signCertificateRequestFromDER}.\n *\n * All fields are optional. When provided, they override the\n * corresponding values from the CSR.\n */\nexport interface SignCertificateOptions {\n /** Certificate validity in days (default: 365). */\n validity?: number;\n /**\n * Certificate validity in milliseconds.\n *\n * When provided, takes precedence over {@link validity} and enables\n * sub-day validity (e.g. 10-minute certificates for renewal demos).\n */\n validityMs?: number;\n /** Override the certificate start date. */\n startDate?: Date;\n /** Override DNS SANs. */\n dns?: string[];\n /** Override IP SANs. */\n ip?: string[];\n /** Override the application URI SAN. */\n applicationUri?: string;\n /** Override the X.500 subject. */\n subject?: SubjectOptions | string;\n}\n\n/**\n * Capabilities advertised by a PKI backend (or by this\n * {@link CertificateAuthority}) so consumers can clamp requested\n * validity to the limits the backend can actually honor.\n *\n * Useful for the GDS Pull / Push management flows, where the CA may\n * be supplied by an external service (step-ca, EJBCA, …) with its\n * own minimum / maximum / granularity constraints.\n *\n * @see CertificateAuthority.getCapabilities\n */\nexport interface PkiBackendCapabilities {\n /** Smallest validity this backend can issue, in milliseconds. */\n minValidityMs: number;\n /** Largest validity this backend will issue, in milliseconds. */\n maxValidityMs: number;\n /**\n * Validity is rounded up to the nearest multiple of this many\n * milliseconds. For `node-opcua-pki`'s OpenSSL-based CA this is\n * 1 000 ms (one second — the X.509 floor per RFC 5280 §4.1.2.5).\n */\n validityGranularityMs: number;\n /**\n * Native unit the backend works in. Diagnostic only — callers\n * always pass `validityMs` (US-208 / US-210).\n */\n nativeUnit: \"second\" | \"minute\" | \"hour\" | \"day\";\n}\n\n/**\n * Options for {@link CertificateAuthority.generateKeyPairAndSignDER}.\n */\nexport interface GenerateKeyPairAndSignOptions {\n /** OPC UA application URI (required). */\n applicationUri: string;\n /** X.500 subject for the certificate (e.g. \"CN=MyApp\"). */\n subject?: SubjectOptions | string;\n /** DNS host names for the SAN extension. */\n dns?: string[];\n /** IP addresses for the SAN extension. */\n ip?: string[];\n /** Certificate validity in days (default: 365). */\n validity?: number;\n /**\n * Certificate validity in milliseconds.\n *\n * When provided, takes precedence over {@link validity} and enables\n * sub-day validity (e.g. 10-minute certificates for renewal demos).\n */\n validityMs?: number;\n /** Certificate start date (default: now). */\n startDate?: Date;\n /** RSA key size in bits (default: 2048). */\n keySize?: KeySize;\n}\n\n/**\n * Options for {@link CertificateAuthority.generateKeyPairAndSignPFX}.\n *\n * Extends the DER options with an optional `passphrase` to protect\n * the PFX bundle.\n */\nexport interface GenerateKeyPairAndSignPFXOptions extends GenerateKeyPairAndSignOptions {\n /**\n * Passphrase to protect the PFX file.\n * If omitted, the PFX is created without a password.\n */\n passphrase?: string;\n}\n\n/**\n * Synchronously validate a revocation-related URL (CDP / OCSP /\n * caIssuers) before it is stored on a {@link CertificateAuthority}.\n *\n * Rules:\n * - `undefined` is a valid input — the matching extension is omitted.\n * - Empty string throws (almost always a config bug — pass `undefined`).\n * - Must parse via `new URL(s)`.\n * - Protocol must be `http:` or `https:`.\n * - Must include a non-trivial path (not `\"\"` or `\"/\"`).\n * - Loopback hostname produces a warning but does not throw — useful\n * for tests and local dev where pki-server and relying party share\n * a host.\n *\n * @see US-202\n */\nfunction validateRevocationUrl(url: string | undefined, fieldName: string): string | undefined {\n if (url === undefined) {\n return undefined;\n }\n if (url === \"\") {\n throw new Error(`${fieldName} must not be empty — pass undefined to disable the extension`);\n }\n let parsed: URL;\n try {\n parsed = new URL(url);\n } catch {\n throw new Error(`${fieldName} is not a valid URL: ${url}`);\n }\n if (parsed.protocol !== \"http:\" && parsed.protocol !== \"https:\") {\n throw new Error(`${fieldName} must use http: or https: (got ${parsed.protocol} in ${url})`);\n }\n if (!parsed.pathname || parsed.pathname === \"/\") {\n throw new Error(`${fieldName} must include a path component (got ${url})`);\n }\n const isLoopback = parsed.hostname === \"localhost\" || parsed.hostname === \"::1\" || parsed.hostname.startsWith(\"127.\");\n if (isLoopback) {\n // eslint-disable-next-line no-console\n console.warn(\n `[node-opcua-pki] ${fieldName} points at loopback (${url}) — ` +\n \"certificates issued with this URL will be unreachable from any other host.\"\n );\n }\n return url;\n}\n\nexport class CertificateAuthority {\n /** RSA key size used when generating the CA private key. */\n public readonly keySize: KeySize;\n /** Root filesystem path of the CA directory structure. */\n public readonly location: string;\n /** X.500 subject of the CA certificate. */\n public readonly subject: Subject;\n\n /** @internal Parent CA (undefined for root CAs). */\n readonly _issuerCA?: CertificateAuthority;\n\n /** @internal Configured CDP / AIA URLs (US-202). */\n private _crlDistributionUrl?: string;\n private _ocspResponderUrl?: string;\n private _caIssuersUrl?: string;\n\n constructor(options: CertificateAuthorityOptions) {\n assert(Object.prototype.hasOwnProperty.call(options, \"location\"));\n assert(Object.prototype.hasOwnProperty.call(options, \"keySize\"));\n this.location = options.location;\n this.keySize = options.keySize || 2048;\n this.subject = new Subject(options.subject || defaultSubject);\n this._issuerCA = options.issuerCA;\n if (options.crlDistributionUrl !== undefined) {\n this.setCrlDistributionUrl(options.crlDistributionUrl);\n }\n if (options.ocspResponderUrl !== undefined) {\n this.setOcspResponderUrl(options.ocspResponderUrl);\n }\n if (options.caIssuersUrl !== undefined) {\n this.setCaIssuersUrl(options.caIssuersUrl);\n }\n }\n\n /**\n * Public URL where the CRL produced by this CA is reachable, or\n * `undefined` if no CDP extension should be emitted on issued certs.\n */\n public get crlDistributionUrl(): string | undefined {\n return this._crlDistributionUrl;\n }\n\n /**\n * Public URL of the OCSP responder, or `undefined` if no AIA OCSP\n * leg should be emitted on issued certs.\n */\n public get ocspResponderUrl(): string | undefined {\n return this._ocspResponderUrl;\n }\n\n /**\n * Public URL where the issuer's certificate can be fetched, or\n * `undefined` if no AIA caIssuers leg should be emitted.\n */\n public get caIssuersUrl(): string | undefined {\n return this._caIssuersUrl;\n }\n\n /**\n * Configure the URL embedded as `crlDistributionPoints` in every\n * subsequently-issued certificate. Pass `undefined` to disable\n * the extension entirely. Validated synchronously — throws on\n * empty string, non-http(s) protocol, missing path. Warns (does\n * not throw) when the URL points at loopback.\n *\n * @see US-202\n */\n public setCrlDistributionUrl(url: string | undefined): void {\n this._crlDistributionUrl = validateRevocationUrl(url, \"crlDistributionUrl\");\n }\n\n /**\n * Configure the OCSP responder URL embedded as the `OCSP` leg of\n * the `authorityInfoAccess` extension on every subsequently-issued\n * certificate. Pass `undefined` to disable.\n *\n * @see US-202\n */\n public setOcspResponderUrl(url: string | undefined): void {\n this._ocspResponderUrl = validateRevocationUrl(url, \"ocspResponderUrl\");\n }\n\n /**\n * Configure the caIssuers URL embedded as the `caIssuers` leg of\n * the `authorityInfoAccess` extension on every subsequently-issued\n * certificate. Pass `undefined` to disable.\n *\n * @see US-202\n */\n public setCaIssuersUrl(url: string | undefined): void {\n this._caIssuersUrl = validateRevocationUrl(url, \"caIssuersUrl\");\n }\n\n /**\n * @internal\n * Populate the OpenSSL config substitution env vars (`CDP_URL` and\n * `AIA_VALUE`) from the configured URLs, or unset them so the\n * matching `{{#KEY}}...{{/KEY}}` blocks in the templates are\n * stripped. MUST be called before every `generateStaticConfig`\n * invocation that signs a certificate.\n */\n public _wireRevocationEnvVars(): void {\n unsetEnv(\"CDP_URL\");\n unsetEnv(\"AIA_VALUE\");\n if (this._crlDistributionUrl) {\n setEnv(\"CDP_URL\", this._crlDistributionUrl);\n }\n const aiaLegs: string[] = [];\n if (this._ocspResponderUrl) {\n aiaLegs.push(`OCSP;URI:${this._ocspResponderUrl}`);\n }\n if (this._caIssuersUrl) {\n aiaLegs.push(`caIssuers;URI:${this._caIssuersUrl}`);\n }\n if (aiaLegs.length > 0) {\n setEnv(\"AIA_VALUE\", aiaLegs.join(\",\"));\n }\n }\n\n /** Absolute path to the CA root directory (alias for {@link location}). */\n public get rootDir() {\n return this.location;\n }\n\n /** Path to the OpenSSL configuration file (`conf/caconfig.cnf`). */\n public get configFile() {\n return path.normalize(path.join(this.rootDir, \"./conf/caconfig.cnf\"));\n }\n\n /** Path to the CA certificate in PEM format (`public/cacert.pem`). */\n public get caCertificate() {\n // the Certificate Authority Certificate\n return makePath(this.rootDir, \"./public/cacert.pem\");\n }\n\n /**\n * Path to the issuer certificate chain (`public/issuer_chain.pem`).\n *\n * This file is created by {@link installCACertificate} when the\n * provided cert file contains additional issuer certificates\n * (e.g. intermediate + root). It is appended to signed certs\n * by {@link constructCertificateChain} to produce a full chain\n * per OPC UA Part 6 §6.2.6.\n */\n public get issuerCertificateChain() {\n return makePath(this.rootDir, \"./public/issuer_chain.pem\");\n }\n\n /**\n * Path to the current Certificate Revocation List in DER format.\n * (`crl/revocation_list.der`)\n */\n public get revocationListDER() {\n return makePath(this.rootDir, \"./crl/revocation_list.der\");\n }\n\n /**\n * Path to the current Certificate Revocation List in PEM format.\n * (`crl/revocation_list.crl`)\n */\n public get revocationList() {\n return makePath(this.rootDir, \"./crl/revocation_list.crl\");\n }\n\n /**\n * Path to the concatenated CA certificate + CRL file.\n * Used by OpenSSL for CRL-based verification.\n */\n public get caCertificateWithCrl() {\n return makePath(this.rootDir, \"./public/cacertificate_with_crl.pem\");\n }\n\n // ---------------------------------------------------------------\n // Buffer-based accessors (US-059)\n // ---------------------------------------------------------------\n\n /**\n * Return the CA certificate as a DER-encoded buffer.\n *\n * @throws if the CA certificate file does not exist\n * (call {@link initialize} first).\n */\n public getCACertificateDER(): Buffer {\n const pem = readCertificatePEM(this.caCertificate);\n return convertPEMtoDER(pem);\n }\n\n /**\n * Return the CA certificate as a PEM-encoded string.\n *\n * @throws if the CA certificate file does not exist\n * (call {@link initialize} first).\n */\n public getCACertificatePEM(): string {\n const raw = readCertificatePEM(this.caCertificate);\n // OpenSSL CA cert files may include a human-readable text\n // dump before the PEM block — strip it.\n const beginMarker = \"-----BEGIN CERTIFICATE-----\";\n const idx = raw.indexOf(beginMarker);\n if (idx > 0) {\n return raw.substring(idx);\n }\n return raw;\n }\n\n /**\n * Return the current Certificate Revocation List as a\n * DER-encoded buffer.\n *\n * Returns an empty buffer if no CRL has been generated yet.\n */\n public getCRLDER(): Buffer {\n const crlPath = this.revocationListDER;\n if (!fs.existsSync(crlPath)) {\n return Buffer.alloc(0);\n }\n return fs.readFileSync(crlPath);\n }\n\n /**\n * Return the current Certificate Revocation List as a\n * PEM-encoded string.\n *\n * Returns an empty string if no CRL has been generated yet.\n */\n public getCRLPEM(): string {\n const crlPath = this.revocationList;\n if (!fs.existsSync(crlPath)) {\n return \"\";\n }\n const raw = fs.readFileSync(crlPath, \"utf-8\");\n // OpenSSL CRL files may include a human-readable text\n // dump before the PEM block — strip it.\n const beginMarker = \"-----BEGIN X509 CRL-----\";\n const idx = raw.indexOf(beginMarker);\n if (idx > 0) {\n return raw.substring(idx);\n }\n return raw;\n }\n\n // ---------------------------------------------------------------\n // Certificate database API (US-057)\n // ---------------------------------------------------------------\n\n /**\n * Return a list of all issued certificates recorded in the\n * OpenSSL `index.txt` database.\n *\n * Each entry includes the serial number, subject, status,\n * expiry date, and (for revoked certs) the revocation date.\n */\n public getIssuedCertificates(): IssuedCertificateRecord[] {\n return this._parseIndexTxt();\n }\n\n /**\n * Return the total number of certificates recorded in\n * `index.txt`.\n */\n public getIssuedCertificateCount(): number {\n return this._parseIndexTxt().length;\n }\n\n /**\n * Return the status of a certificate by its serial number.\n *\n * @param serial - hex-encoded serial number (e.g. `\"1000\"`)\n * @returns `\"valid\"`, `\"revoked\"`, `\"expired\"`, or\n * `undefined` if not found\n */\n public getCertificateStatus(serial: string): \"valid\" | \"revoked\" | \"expired\" | undefined {\n const upper = serial.toUpperCase();\n const record = this._parseIndexTxt().find((r) => r.serial.toUpperCase() === upper);\n return record?.status;\n }\n\n /**\n * Read a specific issued certificate by serial number and\n * return its content as a DER-encoded buffer.\n *\n * OpenSSL stores signed certificates in the `certs/`\n * directory using the naming convention `<SERIAL>.pem`.\n *\n * @param serial - hex-encoded serial number (e.g. `\"1000\"`)\n * @returns the DER buffer, or `undefined` if not found\n */\n public getCertificateBySerial(serial: string): Buffer | undefined {\n const upper = serial.toUpperCase();\n const certFile = path.join(this.rootDir, \"certs\", `${upper}.pem`);\n if (!fs.existsSync(certFile)) {\n return undefined;\n }\n const pem = readCertificatePEM(certFile);\n return convertPEMtoDER(pem);\n }\n\n /**\n * Path to the OpenSSL certificate database file.\n */\n public get indexFile(): string {\n return path.join(this.rootDir, \"index.txt\");\n }\n\n /**\n * Parse the OpenSSL `index.txt` certificate database.\n *\n * Each line has tab-separated fields:\n * ```\n * status expiry [revocationDate] serial unknown subject\n * ```\n *\n * - status: `V` (valid), `R` (revoked), `E` (expired)\n * - expiry: `YYMMDDHHmmssZ`\n * - revocationDate: present only for revoked certs\n * - serial: hex string\n * - unknown: always `\"unknown\"`\n * - subject: X.500 slash-delimited string\n */\n private _parseIndexTxt(): IssuedCertificateRecord[] {\n const indexPath = this.indexFile;\n if (!fs.existsSync(indexPath)) {\n return [];\n }\n\n const content = fs.readFileSync(indexPath, \"utf-8\");\n const lines = content.split(\"\\n\").filter((l) => l.trim().length > 0);\n const records: IssuedCertificateRecord[] = [];\n\n for (const line of lines) {\n const fields = line.split(\"\\t\");\n if (fields.length < 4) continue;\n\n const statusChar = fields[0];\n const expiryStr = fields[1];\n\n let serial: string;\n let subject: string;\n let revocationDate: string | undefined;\n\n if (statusChar === \"R\") {\n // Revoked: status expiry revocationDate serial unknown subject\n revocationDate = fields[2];\n serial = fields[3];\n subject = fields.length >= 6 ? fields[5] : \"\";\n } else {\n // Valid/Expired: status expiry (empty) serial unknown subject\n serial = fields[3];\n subject = fields.length >= 6 ? fields[5] : \"\";\n }\n\n let status: \"valid\" | \"revoked\" | \"expired\";\n switch (statusChar) {\n case \"V\":\n status = \"valid\";\n break;\n case \"R\":\n status = \"revoked\";\n break;\n case \"E\":\n status = \"expired\";\n break;\n default:\n continue; // skip unknown status\n }\n\n records.push({\n serial,\n status,\n subject,\n expiryDate: parseOpenSSLDate(expiryStr),\n revocationDate: revocationDate ? parseOpenSSLDate(revocationDate) : undefined\n });\n }\n\n return records;\n }\n\n // ---------------------------------------------------------------\n // Buffer-based CA operations (US-058)\n // ---------------------------------------------------------------\n\n /**\n * Sign a DER-encoded Certificate Signing Request and return\n * the signed certificate as a DER buffer.\n *\n * This method handles temp-file creation and cleanup\n * internally so that callers can work with in-memory\n * buffers only.\n *\n * The CA can override fields from the CSR by passing\n * `options.dns`, `options.ip`, `options.applicationUri`,\n * `options.startDate`, or `options.subject`.\n *\n * @param csrDer - the CSR as a DER-encoded buffer\n * @param options - signing options and CA overrides\n * @returns the signed certificate as a DER-encoded buffer\n */\n public async signCertificateRequestFromDER(csrDer: Buffer, options?: SignCertificateOptions): Promise<Buffer> {\n const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), \"pki-sign-\"));\n\n try {\n const csrFile = path.join(tmpDir, \"request.csr\");\n const certFile = path.join(tmpDir, \"certificate.pem\");\n\n // Write CSR as PEM\n const csrPem = toPem(csrDer, \"CERTIFICATE REQUEST\");\n await fs.promises.writeFile(csrFile, csrPem, \"utf-8\");\n\n // Build signing parameters — CA overrides take precedence.\n // validityMs (sub-day capable) overrides validity (days) when\n // both are provided; adjustDate() handles precedence.\n const signingParams: Params = {};\n if (options?.validityMs !== undefined) signingParams.validityMs = options.validityMs;\n else signingParams.validity = options?.validity ?? 365;\n if (options?.startDate) signingParams.startDate = options.startDate;\n if (options?.dns) signingParams.dns = options.dns;\n if (options?.ip) signingParams.ip = options.ip;\n if (options?.applicationUri) signingParams.applicationUri = options.applicationUri;\n if (options?.subject) signingParams.subject = options.subject;\n\n // Delegate to the existing file-based method\n await this.signCertificateRequest(certFile, csrFile, signingParams);\n\n // Read the signed certificate and convert to DER\n const certPem = readCertificatePEM(certFile);\n return convertPEMtoDER(certPem);\n } finally {\n await fs.promises.rm(tmpDir, {\n recursive: true,\n force: true\n });\n }\n }\n\n /**\n * Advertise the validity limits this CA can honor.\n *\n * Consumers (notably the GDS server in [`cert_auth.ts`](https://github.com/sterfive/node-opcua-gds))\n * clamp a requested validity against these bounds before calling\n * {@link signCertificateRequestFromDER}, so a misconfigured\n * `defaultCertValidity` cannot ask the CA for something it cannot\n * produce.\n *\n * Defaults match the OpenSSL-backed implementation:\n * - `minValidityMs = 60_000` (1 minute) — practical floor; the\n * X.509 spec floor is 1 second but very short certs are rarely\n * useful and pathological for any real deployment.\n * - `maxValidityMs = 10 * 365 * 86_400_000` (≈ 10 years) — long\n * enough for root CAs.\n * - `validityGranularityMs = 1_000` (1 second) — RFC 5280 §4.1.2.5\n * floor on `notBefore` / `notAfter`.\n * - `nativeUnit = \"second\"` — what `x509Date()` actually encodes.\n *\n * @see US-208 — the consumer-side capability story.\n */\n public getCapabilities(): PkiBackendCapabilities {\n return {\n minValidityMs: 60_000,\n maxValidityMs: 10 * 365 * 86_400_000,\n validityGranularityMs: 1_000,\n nativeUnit: \"second\"\n };\n }\n\n /**\n * Generate a new RSA key pair, create an internal CSR, sign it\n * with this CA, and return both the certificate and private key\n * as DER-encoded buffers.\n *\n * The private key is **never stored** by the CA — it exists only\n * in a temporary directory that is cleaned up after the operation.\n *\n * This is used by `StartNewKeyPairRequest` (OPC UA Part 12) for\n * constrained devices that cannot generate their own keys.\n *\n * @param options - key generation and certificate parameters\n * @returns `{ certificateDer, privateKey }` — certificate as DER,\n * private key as a branded `PrivateKey` buffer\n */\n public async generateKeyPairAndSignDER(options: GenerateKeyPairAndSignOptions): Promise<{\n certificateDer: Buffer;\n privateKey: PrivateKey;\n }> {\n const keySize = options.keySize ?? 2048;\n const startDate = options.startDate ?? new Date();\n const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), \"pki-keygen-\"));\n\n try {\n // 1. Generate ephemeral private key\n const privateKeyFile = path.join(tmpDir, \"private_key.pem\");\n await generatePrivateKeyFile(privateKeyFile, keySize);\n\n // 2. Create a minimal OpenSSL config for CSR generation\n const configFile = path.join(tmpDir, \"openssl.cnf\");\n await fs.promises.writeFile(configFile, configurationFileSimpleTemplate, \"utf-8\");\n\n // 3. Create CSR using the ephemeral key\n const csrFile = path.join(tmpDir, \"request.csr\");\n await createCertificateSigningRequestWithOpenSSL(csrFile, {\n rootDir: tmpDir,\n configFile,\n privateKey: privateKeyFile,\n applicationUri: options.applicationUri,\n subject: options.subject,\n dns: options.dns ?? [],\n ip: options.ip ?? [],\n purpose: CertificatePurpose.ForApplication\n });\n\n // 4. Sign the CSR with this CA — validityMs takes precedence\n // over validity when both are provided (adjustDate handles it).\n const certFile = path.join(tmpDir, \"certificate.pem\");\n const signingParams: Params = {\n applicationUri: options.applicationUri,\n dns: options.dns,\n ip: options.ip,\n startDate\n };\n if (options.validityMs !== undefined) signingParams.validityMs = options.validityMs;\n else signingParams.validity = options.validity ?? 365;\n await this.signCertificateRequest(certFile, csrFile, signingParams);\n\n // 5. Read results\n const certPem = readCertificatePEM(certFile);\n const certificateDer = convertPEMtoDER(certPem);\n const privateKey = readPrivateKey(privateKeyFile);\n\n return { certificateDer, privateKey };\n } finally {\n // 6. Securely clean up — private key is never persisted\n await fs.promises.rm(tmpDir, {\n recursive: true,\n force: true\n });\n }\n }\n\n /**\n * Generate a new RSA key pair, create an internal CSR, sign it\n * with this CA, and return the result as a PKCS#12 (PFX)\n * buffer bundling the certificate, private key, and CA chain.\n *\n * The private key is **never stored** by the CA — it exists only\n * in a temporary directory that is cleaned up after the operation.\n *\n * @param options - key generation, certificate, and PFX options\n * @returns the PFX as a `Buffer`\n */\n public async generateKeyPairAndSignPFX(options: GenerateKeyPairAndSignPFXOptions): Promise<Buffer> {\n const keySize = options.keySize ?? 2048;\n const startDate = options.startDate ?? new Date();\n const passphrase = options.passphrase ?? \"\";\n const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), \"pki-keygen-pfx-\"));\n\n try {\n // 1. Generate ephemeral private key\n const privateKeyFile = path.join(tmpDir, \"private_key.pem\");\n await generatePrivateKeyFile(privateKeyFile, keySize);\n\n // 2. Create a minimal OpenSSL config for CSR generation\n const configFile = path.join(tmpDir, \"openssl.cnf\");\n await fs.promises.writeFile(configFile, configurationFileSimpleTemplate, \"utf-8\");\n\n // 3. Create CSR using the ephemeral key\n const csrFile = path.join(tmpDir, \"request.csr\");\n await createCertificateSigningRequestWithOpenSSL(csrFile, {\n rootDir: tmpDir,\n configFile,\n privateKey: privateKeyFile,\n applicationUri: options.applicationUri,\n subject: options.subject,\n dns: options.dns ?? [],\n ip: options.ip ?? [],\n purpose: CertificatePurpose.ForApplication\n });\n\n // 4. Sign the CSR with this CA — validityMs takes precedence\n // over validity when both are provided (adjustDate handles it).\n const certFile = path.join(tmpDir, \"certificate.pem\");\n const signingParams: Params = {\n applicationUri: options.applicationUri,\n dns: options.dns,\n ip: options.ip,\n startDate\n };\n if (options.validityMs !== undefined) signingParams.validityMs = options.validityMs;\n else signingParams.validity = options.validity ?? 365;\n await this.signCertificateRequest(certFile, csrFile, signingParams);\n\n // 5. Bundle into PFX (include CA cert chain)\n const pfxFile = path.join(tmpDir, \"bundle.pfx\");\n await createPFX({\n certificateFile: certFile,\n privateKeyFile,\n outputFile: pfxFile,\n passphrase,\n caCertificateFiles: [this.caCertificate]\n });\n\n // 6. Read the PFX buffer\n return await fs.promises.readFile(pfxFile);\n } finally {\n // 7. Securely clean up — private key is never persisted\n await fs.promises.rm(tmpDir, {\n recursive: true,\n force: true\n });\n }\n }\n\n /**\n * Revoke a DER-encoded certificate and regenerate the CRL.\n *\n * Extracts the serial number from the certificate, then\n * uses the stored cert file at `certs/<serial>.pem` for\n * revocation — avoiding temp-file PEM format mismatches.\n *\n * @param certDer - the certificate as a DER-encoded buffer\n * @param reason - CRL reason code\n * (default: `\"keyCompromise\"`)\n * @throws if the certificate's serial is not found in the CA\n */\n public async revokeCertificateDER(certDer: Buffer, reason?: string): Promise<void> {\n // 1. Extract serial from the DER certificate\n const info = exploreCertificate(certDer);\n // exploreCertificate returns serial as \"10:00\" (colon-hex)\n // openssl stores cert files as \"1000.pem\" (plain hex upper)\n const serial = info.tbsCertificate.serialNumber.replace(/:/g, \"\").toUpperCase();\n\n // 2. Use the cert file that openssl ca already stored\n const storedCertFile = path.join(this.rootDir, \"certs\", `${serial}.pem`);\n if (!fs.existsSync(storedCertFile)) {\n throw new Error(`Cannot revoke: no stored certificate found for serial ${serial} at ${storedCertFile}`);\n }\n\n // 3. Delegate to the existing file-based method\n await this.revokeCertificate(storedCertFile, {\n reason: reason ?? \"keyCompromise\"\n });\n }\n\n /**\n * Initialize the CA directory structure, generate the CA\n * private key and self-signed certificate if they do not\n * already exist.\n */\n public async initialize(): Promise<void> {\n await construct_CertificateAuthority(this);\n }\n\n /**\n * Initialize the CA directory structure and generate the\n * private key + CSR **without signing**.\n *\n * Use this when the CA certificate will be signed by an\n * external (third-party) root CA. After receiving the signed\n * certificate, call {@link installCACertificate} to complete\n * the setup.\n *\n * **Idempotent / restart-safe:**\n * - If the CA certificate exists and is valid → `{ status: \"ready\" }`\n * - If the