easy-ocsp
Version:
An easy-to-use OCSP client for Node.js
466 lines (459 loc) • 17.2 kB
JavaScript
// src/index.ts
import * as pkijs3 from "pkijs";
// src/convert.ts
import * as pkijs from "pkijs";
import { fromBER } from "asn1js";
import { X509Certificate } from "crypto";
function pemToCert(pem) {
try {
const base64 = pem.replace(/(-----(BEGIN|END) CERTIFICATE-----|[\n\r])/g, "");
const der = Buffer.from(base64, "base64");
const asn1 = fromBER(new Uint8Array(der).buffer);
return new pkijs.Certificate({ schema: asn1.result });
} catch {
throw new Error("The certificate is not a valid PEM encoded X.509 certificate string");
}
}
function convertToPkijsCert(cert) {
if (typeof cert === "string") {
return pemToCert(cert);
}
if (cert instanceof X509Certificate) {
return pemToCert(cert.toString());
}
if (cert instanceof Buffer) {
return pkijs.Certificate.fromBER(bufferToArrayBuffer(cert));
}
if (cert instanceof ArrayBuffer) {
return pkijs.Certificate.fromBER(cert);
}
if (cert instanceof pkijs.Certificate) {
return cert;
}
throw new Error("Invalid certificate type. Expected string, Buffer, X509Certificate or pkijs.Certificate");
}
function convertPkijsCertToPem(cert) {
const der = Buffer.from(cert.toSchema().toBER(false));
const base64 = der.toString("base64");
return `-----BEGIN CERTIFICATE-----
${base64.match(/.{1,64}/g)?.join("\n")}
-----END CERTIFICATE-----
`;
}
function bufferToArrayBuffer(b) {
if (b.buffer instanceof SharedArrayBuffer) {
throw new Error("Passing a Node.js Buffer that is backed by a SharedArrayBuffer is not supported");
}
return b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength);
}
function typedArrayToBuffer(array) {
if (array.buffer instanceof SharedArrayBuffer) {
throw new Error("Passing a typed array that is backed by a SharedArrayBuffer is not supported");
}
return array.buffer.slice(array.byteOffset, array.byteLength + array.byteOffset);
}
// src/ocsp.ts
import { webcrypto } from "crypto";
import { Constructed, Enumerated, GeneralizedTime, OctetString, UTCTime } from "asn1js";
import * as pkijs2 from "pkijs";
var cryptoEngine = new pkijs2.CryptoEngine({
crypto: webcrypto
});
pkijs2.setEngine("crypto", cryptoEngine);
async function buildOCSPRequest(cert, issuerCert, config) {
const ocspReq = new pkijs2.OCSPRequest();
await ocspReq.createForCertificate(cert, {
hashAlgorithm: "SHA-1",
issuerCertificate: issuerCert
});
let nonce = null;
if (config.enableNonce) {
nonce = new OctetString({
valueHex: pkijs2.getRandomValues(new Uint8Array(32))
}).toBER();
ocspReq.tbsRequest.requestExtensions = [
new pkijs2.Extension({
extnID: "1.3.6.1.5.5.7.48.1.2",
// nonce
extnValue: nonce
})
];
}
return {
ocspReq: ocspReq.toSchema(true).toBER(),
nonce
};
}
function getCAInfoUrls(cert) {
if (!cert.extensions) {
throw new Error("Certificate does not contain any extensions");
}
const authorityInfoAccessExtension = cert.extensions.find((ext) => ext.extnID === "1.3.6.1.5.5.7.1.1");
if (!authorityInfoAccessExtension || !authorityInfoAccessExtension.parsedValue || !authorityInfoAccessExtension.parsedValue.accessDescriptions) {
throw new Error("Certificate does not contain authority information access extension");
}
const ocsp = authorityInfoAccessExtension.parsedValue.accessDescriptions.find(
(ext) => ext.accessMethod === "1.3.6.1.5.5.7.48.1"
);
if (!ocsp || !ocsp.accessLocation || !ocsp.accessLocation.value) {
throw new Error("Certificate does not contain OCSP url");
}
const issuer = authorityInfoAccessExtension.parsedValue.accessDescriptions.find(
(ext) => ext.accessMethod === "1.3.6.1.5.5.7.48.2"
);
if (!issuer || !issuer.accessLocation || !issuer.accessLocation.value) {
throw new Error("Certificate does not contain issuer url");
}
return {
ocspUrl: ocsp.accessLocation.value,
issuerUrl: issuer.accessLocation.value
};
}
async function parseOCSPResponse(responseData, certificate, issuerCertificate, config, nonce) {
const ocspResponse = pkijs2.OCSPResponse.fromBER(responseData);
const responseCode = ocspResponse.responseStatus.valueBlock.valueDec;
if (responseCode !== 0) {
switch (responseCode) {
case 1:
throw new Error("OCSP server response: malformedRequest");
case 2:
throw new Error("OCSP server response: internalError");
case 3:
throw new Error("OCSP server response: tryLater");
case 5:
throw new Error("OCSP server response: sigRequired");
case 6:
throw new Error("OCSP server response: unauthorized");
default:
throw new Error("OCSP server response: unknown");
}
}
if (!ocspResponse.responseBytes) {
throw new Error("OCSP server response does not contain response bytes");
}
if (ocspResponse.responseBytes.responseType !== "1.3.6.1.5.5.7.48.1.1") {
throw new Error("Unknown ocsp response type");
}
const basicResponse = pkijs2.BasicOCSPResponse.fromBER(typedArrayToBuffer(ocspResponse.responseBytes.response.valueBlock.valueHexView));
if (!Array.isArray(basicResponse.tbsResponseData.responses)) {
throw new Error("OCSP response does not contain any response data");
}
if (basicResponse.tbsResponseData.responses.length !== 1) {
throw new Error("OCSP response does not contain exactly one response");
}
if (!(basicResponse.tbsResponseData.responses[0] instanceof pkijs2.SingleResponse)) {
throw new Error("OCSP response is not a pkijs.SingleResponse");
}
const cryptoEngine2 = pkijs2.getEngine();
if (!cryptoEngine2 || !cryptoEngine2.crypto) {
throw new Error("No pkijs crypto engine");
}
if (config.validateSignature) {
if (!await verifySignature(basicResponse, issuerCertificate, nonce, config, cryptoEngine2)) {
throw new Error("OCSP response signature verification failed");
}
}
const singleResponse = basicResponse.tbsResponseData.responses[0];
const hashAlgorithm = cryptoEngine2.crypto.getAlgorithmByOID(
singleResponse.certID.hashAlgorithm.algorithmId,
true,
"CertID.hashAlgorithm"
);
const certID = new pkijs2.CertID();
await certID.createForCertificate(
certificate,
{
hashAlgorithm: hashAlgorithm.name,
issuerCertificate
},
cryptoEngine2.crypto
);
if (!singleResponse.certID.isEqual(certID)) {
throw new Error("OCSP response does not match certificate");
}
let status = "unknown";
if (singleResponse.certStatus.idBlock.isConstructed) {
if (singleResponse.certStatus.idBlock.tagNumber === 1) {
status = "revoked";
}
} else {
if (singleResponse.certStatus.idBlock.tagNumber === 0) {
status = "good";
} else if (singleResponse.certStatus.idBlock.tagNumber !== 2) {
throw new Error(`OCSP response certStatus is not good, revoked or unknown: ${singleResponse.certStatus.idBlock.tagNumber}`);
}
}
const result = {
status,
ocspUrl: config.ocspUrl
};
if (basicResponse.tbsResponseData.producedAt instanceof Date) {
result.producedAt = basicResponse.tbsResponseData.producedAt;
}
if (singleResponse.nextUpdate instanceof Date) {
result.nextUpdate = singleResponse.nextUpdate;
}
if (singleResponse.thisUpdate instanceof Date) {
result.thisUpdate = singleResponse.thisUpdate;
}
if (status === "revoked" && Array.isArray(singleResponse.certStatus?.valueBlock?.value)) {
for (const v of singleResponse.certStatus.valueBlock.value) {
if (v instanceof GeneralizedTime) {
result.revocationTime = v.toDate();
}
if (v instanceof UTCTime) {
result.revocationTime = v.toDate();
}
if (v instanceof Constructed) {
if (Array.isArray(v.valueBlock.value) && v.valueBlock.value.length === 1) {
const vBlock = v.valueBlock.value[0];
if (vBlock instanceof Enumerated) {
result.revocationReason = vBlock.valueBlock.valueDec;
}
}
}
}
}
if (config.rawResponse === true) {
result.rawResponse = Buffer.from(responseData);
}
return result;
}
async function verifySignature(basicOcspResponse, trustedCert, nonce, config, cryptoEngine2 = pkijs2.getEngine()) {
let signatureCert = null;
if (!cryptoEngine2 || !cryptoEngine2.crypto) {
throw new Error("No pkijs crypto engine");
}
if (config.ocspCertificate) {
signatureCert = convertToPkijsCert(config.ocspCertificate);
} else if (basicOcspResponse.tbsResponseData.responderID instanceof pkijs2.RelativeDistinguishedNames) {
if (trustedCert.subject.isEqual(basicOcspResponse.tbsResponseData.responderID)) {
signatureCert = trustedCert;
}
} else if (basicOcspResponse.tbsResponseData.responderID instanceof OctetString) {
const hash = await webcrypto.subtle.digest(
{ name: "sha-1" },
trustedCert.subjectPublicKeyInfo.subjectPublicKey.valueBlock.valueHexView
);
if (Buffer.compare(Buffer.from(hash), basicOcspResponse.tbsResponseData.responderID.valueBlock.valueHexView) === 0) {
signatureCert = trustedCert;
}
} else {
throw new Error("Responder ID is unknown");
}
if (!signatureCert) {
if (!Array.isArray(basicOcspResponse.certs) || !basicOcspResponse.certs.length) {
throw new Error("OCSP response is not signed by trusted certificate and does not contain additional certificates");
}
for (const cert of basicOcspResponse.certs) {
if (basicOcspResponse.tbsResponseData.responderID instanceof pkijs2.RelativeDistinguishedNames) {
if (cert.subject.isEqual(basicOcspResponse.tbsResponseData.responderID)) {
signatureCert = cert;
break;
}
} else if (basicOcspResponse.tbsResponseData.responderID instanceof OctetString) {
const hash = await webcrypto.subtle.digest(
{ name: "sha-1" },
cert.subjectPublicKeyInfo.subjectPublicKey.valueBlock.valueHexView
);
if (Buffer.compare(Buffer.from(hash), basicOcspResponse.tbsResponseData.responderID.valueBlock.valueHexView) === 0) {
signatureCert = cert;
}
}
}
if (!signatureCert) {
throw new Error("OCSP response is not signed by trusted certificate or additional response certificates");
}
const chain = new pkijs2.CertificateChainValidationEngine({
certs: basicOcspResponse.certs,
trustedCerts: [trustedCert]
});
const verificationResult = await chain.verify({}, cryptoEngine2.crypto);
if (!verificationResult.result) {
throw new Error("Validation of OCSP response certificate chain failed");
}
}
if (config.enableNonce && nonce && Array.isArray(basicOcspResponse.tbsResponseData.responseExtensions)) {
const nonceExtension = basicOcspResponse.tbsResponseData.responseExtensions.find((e) => e.extnID === "1.3.6.1.5.5.7.48.1.2");
if (nonceExtension && Buffer.compare(Buffer.from(nonce), nonceExtension.extnValue.valueBlock.valueHexView) !== 0) {
throw new Error("OCSP response nonce does not match request nonce");
}
}
return cryptoEngine2.crypto.verifyWithPublicKey(
typedArrayToBuffer(basicOcspResponse.tbsResponseData.tbsView),
basicOcspResponse.signature,
signatureCert.subjectPublicKeyInfo,
basicOcspResponse.signatureAlgorithm
);
}
// src/tls.ts
import { connect as tlsConnect } from "tls";
function downloadCert(hostname, timeout = 6e3) {
return new Promise((resolve, reject) => {
const options = {
port: 443,
host: hostname,
servername: hostname,
timeout
};
const socket = tlsConnect(options, () => {
const cert = socket.getPeerCertificate();
if (!cert || !cert.raw) {
reject(new Error(`No certificate found for host ${hostname}`));
}
resolve(cert.raw);
socket.end();
});
socket.on("error", (err) => {
reject(err);
});
socket.on("timeout", () => {
reject(new Error(`Timeout while connecting to host ${hostname}`));
});
});
}
// src/types.ts
var OCSPRevocationReason = /* @__PURE__ */ ((OCSPRevocationReason2) => {
OCSPRevocationReason2[OCSPRevocationReason2["unspecified"] = 0] = "unspecified";
OCSPRevocationReason2[OCSPRevocationReason2["keyCompromise"] = 1] = "keyCompromise";
OCSPRevocationReason2[OCSPRevocationReason2["caCompromise"] = 2] = "caCompromise";
OCSPRevocationReason2[OCSPRevocationReason2["affiliationChanged"] = 3] = "affiliationChanged";
OCSPRevocationReason2[OCSPRevocationReason2["superseded"] = 4] = "superseded";
OCSPRevocationReason2[OCSPRevocationReason2["cessationOfOperation"] = 5] = "cessationOfOperation";
OCSPRevocationReason2[OCSPRevocationReason2["certificateHold"] = 6] = "certificateHold";
OCSPRevocationReason2[OCSPRevocationReason2["removeFromCRL"] = 8] = "removeFromCRL";
OCSPRevocationReason2[OCSPRevocationReason2["privilegeWithdrawn"] = 9] = "privilegeWithdrawn";
OCSPRevocationReason2[OCSPRevocationReason2["aACompromise"] = 10] = "aACompromise";
return OCSPRevocationReason2;
})(OCSPRevocationReason || {});
// src/fetchWrapper.ts
async function fetchWrapper(url, options, timeout, errorPrefix) {
const ac = new AbortController();
const timeoutId = setTimeout(() => ac.abort(), timeout);
options.signal = ac.signal;
try {
return await fetch(url, options);
} catch (error) {
if (!(error instanceof Error)) {
throw new Error(`${errorPrefix}: ${String(error)}`);
}
if (error.name === "AbortError") {
throw new Error(`${errorPrefix}: Operation timed out after ${timeout}ms`);
}
throw new Error(`${errorPrefix}: ${error.message}${error.cause ? ` (${error.cause})` : ""}`);
} finally {
clearTimeout(timeoutId);
}
}
// src/index.ts
async function downloadIssuerCert(cert, timeout) {
let _timeoutMs = defaultConfig.timeout;
if (typeof timeout === "number") {
_timeoutMs = timeout;
}
const { issuerUrl } = getCAInfoUrls(convertToPkijsCert(cert));
const res = await fetchWrapper(issuerUrl, {}, _timeoutMs, `Failed to download issuer certificate`);
if (!res.ok) {
throw new Error(`Issuer certificate download failed with status ${res.status} ${res.statusText} ${issuerUrl}`);
}
const rawResponse = Buffer.from(await res.arrayBuffer());
try {
return convertToPkijsCert(rawResponse);
} catch (err) {
if (err instanceof pkijs3.AsnError) {
const txt = rawResponse.toString("ascii");
if (txt.includes("BEGIN CERTIFICATE")) {
return convertToPkijsCert(txt);
}
throw new Error("The issuer certificate is not a valid DER or PEM encoded X.509 certificate");
}
throw err;
}
}
var defaultConfig = {
validateSignature: true,
enableNonce: true,
timeout: 6e3
};
async function sendOCSPRequest(cert, config) {
const certificate = convertToPkijsCert(cert);
if (certificate.notAfter.value.getTime() < Date.now()) {
throw new Error("The certificate is already expired");
}
if (!config.ocspUrl) {
config.ocspUrl = getCAInfoUrls(certificate).ocspUrl;
}
let issuerCertificate;
if (!config.ca) {
issuerCertificate = await downloadIssuerCert(certificate, config.timeout);
} else {
issuerCertificate = convertToPkijsCert(config.ca);
}
const { ocspReq, nonce } = await buildOCSPRequest(certificate, issuerCertificate, config);
const res = await fetchWrapper(
config.ocspUrl,
{
method: "POST",
headers: {
"Content-Type": "application/ocsp-request"
},
body: ocspReq
},
config.timeout ?? defaultConfig.timeout,
"Failed to send OCSP request"
);
if (!res.ok) {
throw new Error(`OCSP request failed with http status ${res.status} ${res.statusText}`);
}
return {
response: await res.arrayBuffer(),
certificate,
issuerCertificate,
nonce
};
}
async function getCertStatus(cert, config) {
const _config = { ...defaultConfig, ...config };
const { response, certificate, issuerCertificate, nonce } = await sendOCSPRequest(cert, _config);
return parseOCSPResponse(response, certificate, issuerCertificate, _config, nonce);
}
async function getCertStatusByDomain(domain, config) {
let _domain = domain;
let timeout = 6e3;
if (config && typeof config.timeout === "number") {
timeout = config.timeout;
}
if (_domain.includes("/")) {
try {
const url = new URL(_domain);
_domain = url.hostname;
} catch {
throw new Error("Invalid URL");
}
}
return getCertStatus(await downloadCert(_domain, timeout), config);
}
async function getRawOCSPResponse(cert, config) {
const _config = { ...defaultConfig, ...config };
const { response, issuerCertificate, nonce } = await sendOCSPRequest(cert, _config);
return {
rawResponse: Buffer.from(response),
nonce: nonce ? Buffer.from(nonce) : void 0,
issuerCert: convertPkijsCertToPem(issuerCertificate)
};
}
function getCertURLs(cert) {
return getCAInfoUrls(convertToPkijsCert(cert));
}
export {
OCSPRevocationReason,
convertPkijsCertToPem,
convertToPkijsCert,
downloadCert,
downloadIssuerCert,
getCertStatus,
getCertStatusByDomain,
getCertURLs,
getRawOCSPResponse,
parseOCSPResponse
};