/** * @author robinsandhu * @copyright Crown Copyright 2024 * @license Apache-2.0 */ import r from "jsrsasign"; import Operation from "../Operation.mjs"; import { fromBase64 } from "../lib/Base64.mjs"; import { toHex } from "../lib/Hex.mjs"; import { formatDnObj } from "../lib/PublicKey.mjs"; import OperationError from "../errors/OperationError.mjs"; import Utils from "../Utils.mjs"; /** * Parse X.509 CRL operation */ class ParseX509CRL extends Operation { /** * ParseX509CRL constructor */ constructor() { super(); this.name = "Parse X.509 CRL"; this.module = "PublicKey"; this.description = "Parse Certificate Revocation List (CRL)"; this.infoURL = "https://wikipedia.org/wiki/Certificate_revocation_list"; this.inputType = "string"; this.outputType = "string"; this.args = [ { "name": "Input format", "type": "option", "value": ["PEM", "DER Hex", "Base64", "Raw"] } ]; this.checks = [ { "pattern": "^-+BEGIN X509 CRL-+\\r?\\n[\\da-z+/\\n\\r]+-+END X509 CRL-+\\r?\\n?$", "flags": "i", "args": ["PEM"] } ]; } /** * @param {string} input * @param {Object[]} args * @returns {string} Human-readable description of a Certificate Revocation List (CRL). */ run(input, args) { if (!input.length) { return "No input"; } const inputFormat = args[0]; let undefinedInputFormat = false; try { switch (inputFormat) { case "DER Hex": input = input.replace(/\s/g, "").toLowerCase(); break; case "PEM": break; case "Base64": input = toHex(fromBase64(input, null, "byteArray"), ""); break; case "Raw": input = toHex(Utils.strToArrayBuffer(input), ""); break; default: undefinedInputFormat = true; } } catch (e) { throw "Certificate load error (non-certificate input?)"; } if (undefinedInputFormat) throw "Undefined input format"; const crl = new r.X509CRL(input); let out = `Certificate Revocation List (CRL): Version: ${crl.getVersion() === null ? "1 (0x0)" : "2 (0x1)"} Signature Algorithm: ${crl.getSignatureAlgorithmField()} Issuer:\n${formatDnObj(crl.getIssuer(), 8)} Last Update: ${generalizedDateTimeToUTC(crl.getThisUpdate())} Next Update: ${generalizedDateTimeToUTC(crl.getNextUpdate())}\n`; if (crl.getParam().ext !== undefined) { out += `\tCRL extensions:\n${formatCRLExtensions(crl.getParam().ext, 8)}\n`; } out += `Revoked Certificates:\n${formatRevokedCertificates(crl.getRevCertArray(), 4)} Signature Value:\n${formatCRLSignature(crl.getSignatureValueHex(), 8)}`; return out; } } /** * Generalized date time string to UTC. * @param {string} datetime * @returns UTC datetime string. */ function generalizedDateTimeToUTC(datetime) { // Ensure the string is in the correct format if (!/^\d{12,14}Z$/.test(datetime)) { throw new OperationError(`failed to format datetime string ${datetime}`); } // Extract components let centuary = "20"; if (datetime.length === 15) { centuary = datetime.substring(0, 2); datetime = datetime.slice(2); } const year = centuary + datetime.substring(0, 2); const month = datetime.substring(2, 4); const day = datetime.substring(4, 6); const hour = datetime.substring(6, 8); const minute = datetime.substring(8, 10); const second = datetime.substring(10, 12); // Construct ISO 8601 format string const isoString = `${year}-${month}-${day}T${hour}:${minute}:${second}Z`; // Parse using standard Date object const isoDateTime = new Date(isoString); return isoDateTime.toUTCString(); } /** * Format CRL extensions. * @param {r.ExtParam[] | undefined} extensions * @param {Number} indent * @returns Formatted string detailing CRL extensions. */ function formatCRLExtensions(extensions, indent) { if (Array.isArray(extensions) === false || extensions.length === 0) { return indentString(`No CRL extensions.`, indent); } let out = ``; extensions.sort((a, b) => { if (!Object.hasOwn(a, "extname") || !Object.hasOwn(b, "extname")) { return 0; } if (a.extname < b.extname) { return -1; } else if (a.extname === b.extname) { return 0; } else { return 1; } }); extensions.forEach((ext) => { if (!Object.hasOwn(ext, "extname")) { throw new OperationError(`CRL entry extension object missing 'extname' key: ${ext}`); } switch (ext.extname) { case "authorityKeyIdentifier": out += `X509v3 Authority Key Identifier:\n`; if (Object.hasOwn(ext, "kid")) { out += `\tkeyid:${colonDelimitedHexFormatString(ext.kid.hex.toUpperCase())}\n`; } if (Object.hasOwn(ext, "issuer")) { out += `\tDirName:${ext.issuer.str}\n`; } if (Object.hasOwn(ext, "sn")) { out += `\tserial:${colonDelimitedHexFormatString(ext.sn.hex.toUpperCase())}\n`; } break; case "cRLDistributionPoints": out += `X509v3 CRL Distribution Points:\n`; ext.array.forEach((distPoint) => { const fullName = `Full Name:\n${formatGeneralNames(distPoint.dpname.full, 4)}`; out += indentString(fullName, 4) + "\n"; }); break; case "cRLNumber": if (!Object.hasOwn(ext, "num")) { throw new OperationError(`'cRLNumber' CRL entry extension missing 'num' key: ${ext}`); } out += `X509v3 CRL Number:\n\t${ext.num.hex.toUpperCase()}\n`; break; case "issuerAltName": out += `X509v3 Issuer Alternative Name:\n${formatGeneralNames(ext.array, 4)}\n`; break; default: out += `${ext.extname}:\n`; out += `\tUnsupported CRL extension. Try openssl CLI.\n`; break; } }); return indentString(chop(out), indent); } /** * Format general names array. * @param {Object[]} names * @returns Multi-line formatted string describing all supported general name types. */ function formatGeneralNames(names, indent) { let out = ``; names.forEach((name) => { const key = Object.keys(name)[0]; switch (key) { case "ip": out += `IP:${name.ip}\n`; break; case "dns": out += `DNS:${name.dns}\n`; break; case "uri": out += `URI:${name.uri}\n`; break; case "rfc822": out += `EMAIL:${name.rfc822}\n`; break; case "dn": out += `DIR:${name.dn.str}\n`; break; case "other": out += `OtherName:${name.other.oid}::${Object.values(name.other.value)[0].str}\n`; break; default: out += `${key}: unsupported general name type`; break; } }); return indentString(chop(out), indent); } /** * Colon-delimited hex formatted output. * @param {string} hexString Hex String * @returns String representing input hex string with colon delimiter. */ function colonDelimitedHexFormatString(hexString) { if (hexString.length % 2 !== 0) { hexString = "0" + hexString; } return chop(hexString.replace(/(..)/g, "$&:")); } /** * Format revoked certificates array * @param {r.RevokedCertificate[] | null} revokedCertificates * @param {Number} indent * @returns Multi-line formatted string output of revoked certificates array */ function formatRevokedCertificates(revokedCertificates, indent) { if (Array.isArray(revokedCertificates) === false || revokedCertificates.length === 0) { return indentString("No Revoked Certificates.", indent); } let out=``; revokedCertificates.forEach((revCert) => { if (!Object.hasOwn(revCert, "sn") || !Object.hasOwn(revCert, "date")) { throw new OperationError("invalid revoked certificate object, missing either serial number or date"); } out += `Serial Number: ${revCert.sn.hex.toUpperCase()} Revocation Date: ${generalizedDateTimeToUTC(revCert.date)}\n`; if (Object.hasOwn(revCert, "ext") && Array.isArray(revCert.ext) && revCert.ext.length !== 0) { out += `\tCRL entry extensions:\n${indentString(formatCRLEntryExtensions(revCert.ext), 2*indent)}\n`; } }); return indentString(chop(out), indent); } /** * Format CRL entry extensions. * @param {Object[]} exts * @returns Formatted multi-line string describing CRL entry extensions. */ function formatCRLEntryExtensions(exts) { let out = ``; const crlReasonCodeToReasonMessage = { 0: "Unspecified", 1: "Key Compromise", 2: "CA Compromise", 3: "Affiliation Changed", 4: "Superseded", 5: "Cessation Of Operation", 6: "Certificate Hold", 8: "Remove From CRL", 9: "Privilege Withdrawn", 10: "AA Compromise", }; const holdInstructionOIDToName = { "1.2.840.10040.2.1": "Hold Instruction None", "1.2.840.10040.2.2": "Hold Instruction Call Issuer", "1.2.840.10040.2.3": "Hold Instruction Reject", }; exts.forEach((ext) => { if (!Object.hasOwn(ext, "extname")) { throw new OperationError(`CRL entry extension object missing 'extname' key: ${ext}`); } switch (ext.extname) { case "cRLReason": if (!Object.hasOwn(ext, "code")) { throw new OperationError(`'cRLReason' CRL entry extension missing 'code' key: ${ext}`); } out += `X509v3 CRL Reason Code: ${Object.hasOwn(crlReasonCodeToReasonMessage, ext.code) ? crlReasonCodeToReasonMessage[ext.code] : `invalid reason code: ${ext.code}`}\n`; break; case "2.5.29.23": // Hold instruction out += `Hold Instruction Code:\n\t${Object.hasOwn(holdInstructionOIDToName, ext.extn.oid) ? holdInstructionOIDToName[ext.extn.oid] : `${ext.extn.oid}: unknown hold instruction OID`}\n`; break; case "2.5.29.24": // Invalidity Date out += `Invalidity Date:\n\t${generalizedDateTimeToUTC(ext.extn.gentime.str)}\n`; break; default: out += `${ext.extname}:\n`; out += `\tUnsupported CRL entry extension. Try openssl CLI.\n`; break; } }); return chop(out); } /** * Format CRL signature. * @param {String} sigHex * @param {Number} indent * @returns String representing hex signature value formatted on multiple lines. */ function formatCRLSignature(sigHex, indent) { if (sigHex.length % 2 !== 0) { sigHex = "0" + sigHex; } return indentString(formatMultiLine(chop(sigHex.replace(/(..)/g, "$&:"))), indent); } /** * Format string onto multiple lines. * @param {string} longStr * @returns String as a multi-line string. */ function formatMultiLine(longStr) { const lines = []; for (let remain = longStr ; remain !== "" ; remain = remain.substring(54)) { lines.push(remain.substring(0, 54)); } return lines.join("\n"); } /** * Indent a multi-line string by n spaces. * @param {string} input String * @param {number} spaces How many leading spaces * @returns Indented string. */ function indentString(input, spaces) { const indent = " ".repeat(spaces); return input.replace(/^/gm, indent); } /** * Remove last character from a string. * @param {string} s String * @returns Chopped string. */ function chop(s) { if (s.length < 1) { return s; } return s.substring(0, s.length - 1); } export default ParseX509CRL;