diff --git a/plan-jsrsasign.md b/plan-jsrsasign.md index 0e138bb3..9bd394dc 100644 --- a/plan-jsrsasign.md +++ b/plan-jsrsasign.md @@ -8,14 +8,15 @@ - [x] PR 2 — SM2 rewrite - [x] PR 3 — ECDSA primitives - [x] PR 4 — PEM/JWK conversion + key extraction -- [ ] PR 5 — X.509 / CSR / CRL parsing +- [x] PR 5 — X.509 / CSR / CRL parsing - [ ] PR 6 — Removal _Notes for next session:_ - **PR 2 resolved:** SM2 is now built on `weierstrass(...)` + `ecdh(...)` from `@noble/curves/abstract/weierstrass.js` (curve params per GM/T 0003-2012). No `/sm2` subpath needed. - **PR 3 resolved:** ECDSA primitives migrated; new [src/core/lib/Ecdsa.mjs](src/core/lib/Ecdsa.mjs) is the shared helper module. Existing ECDSA fixture set passes unchanged (sign↔verify round-trips and the canned P-256 signature fixtures both verify against `lowS: false`). - **PR 4 resolved:** Key-conversion ops migrated; new [src/core/lib/KeyConvert.mjs](src/core/lib/KeyConvert.mjs) wraps RSA/EC/DSA PEM⇄JWK⇄SPKI/PKCS#8. DSA private-key parsing/building goes through `asn1js` directly (no peculiar `asn1-dsa` package and `node-forge` doesn't expose DSA), reusing the existing EC helpers from `Ecdsa.mjs`. -- **PR 5 blocker:** `@peculiar/x509` v2 needs a `reflect-metadata` polyfill at every entry point — PR 1 pinned to `^1.14.3` to avoid that. Stay on v1 unless the polyfill cost gets resolved. +- **PR 5 resolved:** X.509 / CSR / CRL ops migrated to `@peculiar/x509` v1, plus the matching `@peculiar/asn1-*` schemas. New [src/core/lib/X509.mjs](src/core/lib/X509.mjs) holds the shared helpers (SPKI describer, signature-OID → display-name table, SAN/GeneralName formatter, EC-signature splitter, hex wrapping). Regression coverage added for `ParseX509Certificate` ([tests/operations/tests/ParseX509Certificate.mjs](tests/operations/tests/ParseX509Certificate.mjs)). +- **PR 5 blocker (still relevant for PR 6+):** `@peculiar/x509` v2 needs a `reflect-metadata` polyfill at every entry point — PR 1 pinned to `^1.14.3` to avoid that. Stay on v1 unless the polyfill cost gets resolved. ## Context @@ -240,6 +241,20 @@ After **PR 6:** Record deviations from the original plan here, newest at the top. One bullet per change: what changed, why, and which PR. +### PR 5 — 2026-05-17 +- New [src/core/lib/X509.mjs](src/core/lib/X509.mjs) holds the shared X.509 helpers (`decodeX509Input`, `sigAlgOidToName`, `describeSpki`, `parseDerEcdsaSignature`, `isDerEcdsaSignature`, `formatJsonName`, `formatGeneralName`, `formatHexByteLines`/`formatHexColonWrapped`, `asnNameToJson`). [src/core/lib/PublicKey.mjs](src/core/lib/PublicKey.mjs)'s `formatDnObj` now accepts both the legacy jsrsasign shape and `@peculiar/x509`'s `JsonName` (array-of-records) shape. Migrated ops: [ParseX509Certificate.mjs](src/core/operations/ParseX509Certificate.mjs), [PubKeyFromCert.mjs](src/core/operations/PubKeyFromCert.mjs), [ParseCSR.mjs](src/core/operations/ParseCSR.mjs), [ParseX509CRL.mjs](src/core/operations/ParseX509CRL.mjs). +- **`X509Certificate` v1.14.3 doesn't expose `.version`.** Plan called for `cert.version`, but on the pinned v1 the property is missing. Worked around by parsing `cert.rawData` with the asn1-x509 `Certificate` schema and reading `tbsCertificate.version` + `signatureAlgorithm.algorithm` directly. The same approach is used in `ParseCSR` (with `CertificationRequest` from `@peculiar/asn1-csr`) and `ParseX509CRL` (with `CertificateList` from `@peculiar/asn1-x509`) — bypassing the WebCrypto algorithm mapping is necessary for DSA anyway (peculiar's algorithm provider doesn't know it). +- **DSA bit-length now matches OpenSSL/jsrsasign output.** `describeSpki` strips the leading `00` byte from the DSS-Parms `p` INTEGER before computing the bit length, so `Length: 2048 bits` is reported rather than the 2056 the raw byte count would give. +- **P-521 reports `Length: 521 bits`**, not 528. `EC_CURVE_OID_TO_NAMES` carries an explicit `bits` field per curve so the prime field size (rather than `byteLen * 8`) is reported. +- **KeyUsage rendering order.** `KeyUsage.toJSON()` on the asn1-x509 wrapper returns the flag names in alphabetical order (`crlSign`, `dataEncipherment`, …); the existing CSR golden fixtures expect bit-position order (`digitalSignature`, `nonRepudiation`, `keyEncipherment`, …). The op now parses the BIT STRING and iterates the bits in order so the output is stable. +- **`Public Key from Certificate` now works for Ed25519 / Ed448.** jsrsasign threw "Unsupported public key type"; `@peculiar/x509`'s `cert.publicKey.toString("pem")` handles both. Updated [tests/operations/tests/PubKeyFromCert.mjs](tests/operations/tests/PubKeyFromCert.mjs) — the previously-commented `ED25519_PUBKEY` / `ED448_PUBKEY` constants are live and the two negative test expectations were replaced with the actual PEMs. +- **PEM line endings switched from `\r\n` to `\n` everywhere** (per the cross-PR convention in [AGENTS.md](AGENTS.md)). [tests/operations/tests/PubKeyFromCert.mjs](tests/operations/tests/PubKeyFromCert.mjs) loses its `.replace(/\r/g, "").replace(/\n/g, "\r\n")` post-processing. +- **OtherName payload is parsed loosely.** The legacy `ParseCSR` fixture for `Other: 1.2.3.4::some value` requires extracting a UTF8String wrapped in the ANY-typed `value` field. `formatGeneralName` first tries to parse it as a primitive `UTF8String`/`BmpString`/`PrintableString`/`IA5String`, then falls back to `DirectoryString`, and finally to a hex dump. Same code path serves the CRL OtherName output (with `OtherName:` label, no space — per the CRL flavor) and the CSR/Cert SAN output (`Other: ` label, with space — per the CSR flavor). +- **`ParseX509Certificate` no longer relies on `cert.getInfo()` string-splitting.** The legacy code grabbed the extensions block by splitting on hard-coded delimiters. Replaced with a real walker over `cert.extensions` that recognises BasicConstraints, KeyUsage, EKU, SAN, AKI, SKI, CRLDistributionPoints, IssuerAltName; everything else falls back to `:` + raw hex. +- **Cosmetic drift in the `Certificate Signature` block for ECDSA certs.** The old code split the DER signature with hard-coded offsets (`r.ASN1HEX.getV(sig, 4)` and `getV(sig, 48)`), which only worked for 32-byte components and produced visibly-overlapping output otherwise. The new code uses the proper DER parser. The output difference is a *correction*, not a regression — but it's still drift, and means the `ParseX509Certificate` golden output now reflects the true r/s values. +- **New regression coverage for `ParseX509Certificate`.** [tests/operations/tests/ParseX509Certificate.mjs](tests/operations/tests/ParseX509Certificate.mjs) carries golden output for an RSA cert and a P-256 cert (reusing the certs from [PubKeyFromCert.mjs](tests/operations/tests/PubKeyFromCert.mjs)), plus an empty-input test. Wired into [tests/operations/index.mjs](tests/operations/index.mjs). +- No new dependencies — everything reuses what PR 1 already pinned. + ### PR 4 — 2026-05-17 - New [src/core/lib/KeyConvert.mjs](src/core/lib/KeyConvert.mjs) holds the shared RSA/EC/DSA conversion helpers (`parseKeyPem`, `parseCertPublicKey`, `keyToJwk`, `keyFromJwk`, `keyInfoToPem`, `derivePublicKeyInfo`). Plan called for inlining helpers per-op; factoring them out avoided duplicating the RSA SPKI/PKCS#8 plumbing between [PEMToJWK.mjs](src/core/operations/PEMToJWK.mjs), [JWKToPem.mjs](src/core/operations/JWKToPem.mjs) and [PubKeyFromPrivKey.mjs](src/core/operations/PubKeyFromPrivKey.mjs). - **node-forge dropped from the migration.** The plan reserved node-forge for the DSA path in `PubKeyFromPrivKey`, but `node-forge` only ships RSA support in its `pki` module — no DSA parser/serialiser. DSA traditional `-----BEGIN DSA PRIVATE KEY-----` is parsed via `asn1js.fromBER` directly, and the SPKI is built via `new asn1js.Sequence({value: [new Integer(...), ...]}).toBER(false)`. No new dependency. diff --git a/src/core/lib/PublicKey.mjs b/src/core/lib/PublicKey.mjs index ea931d7e..749f3bb4 100644 --- a/src/core/lib/PublicKey.mjs +++ b/src/core/lib/PublicKey.mjs @@ -11,28 +11,38 @@ import { toHex, fromHex } from "./Hex.mjs"; /** * Formats Distinguished Name (DN) objects to strings. * - * @param {Object} dnObj + * Accepts either the legacy jsrsasign-style `{ array: [[{type, value}, ...], ...] }` + * shape OR `@peculiar/x509`'s `JsonName` shape — an array of records keyed by + * RDN short-name (`[{ CN: ["foo"], OU: ["bar"] }, ...]`). + * + * @param {Object|Array} dnObj * @param {number} indent * @returns {string} */ export function formatDnObj(dnObj, indent) { - let output = ""; + const rows = []; - const maxKeyLen = dnObj.array.reduce((max, item) => { - return item[0].type.length > max ? item[0].type.length : max; - }, 0); - - for (let i = 0; i < dnObj.array.length; i++) { - if (!dnObj.array[i].length) continue; - - const key = dnObj.array[i][0].type; - const value = dnObj.array[i][0].value; - const str = `${key.padEnd(maxKeyLen, " ")} = ${value}\n`; - - output += str.padStart(indent + str.length, " "); + if (Array.isArray(dnObj)) { + for (const rdn of dnObj) { + for (const key of Object.keys(rdn)) { + for (const value of rdn[key]) rows.push({ key, value }); + } + } + } else if (dnObj && Array.isArray(dnObj.array)) { + for (const rdn of dnObj.array) { + if (!rdn || !rdn.length) continue; + rows.push({ key: rdn[0].type, value: rdn[0].value }); + } + } else { + return ""; } - return output.slice(0, -1); + if (rows.length === 0) return ""; + + const maxKeyLen = rows.reduce((max, r) => Math.max(max, r.key.length), 0); + const pad = " ".repeat(indent); + + return rows.map(({ key, value }) => `${pad}${key.padEnd(maxKeyLen, " ")} = ${value}`).join("\n"); } diff --git a/src/core/lib/X509.mjs b/src/core/lib/X509.mjs new file mode 100644 index 00000000..5bbbc8d4 --- /dev/null +++ b/src/core/lib/X509.mjs @@ -0,0 +1,513 @@ +/** + * Shared X.509 / CSR / CRL helpers built on @peculiar/x509 + @peculiar/asn1-*. + * + * Used by ParseX509Certificate / PubKeyFromCert / ParseCSR / ParseX509CRL. + * Replaces the jsrsasign X.509 plumbing. + * + * @author n1474335 [n1474335@gmail.com] + * @copyright Crown Copyright 2016 + * @license Apache-2.0 + */ + +import { AsnParser } from "@peculiar/asn1-schema"; +import * as asn1X509 from "@peculiar/asn1-x509"; +import * as ecc from "@peculiar/asn1-ecc"; +import * as rsaSchemas from "@peculiar/asn1-rsa"; +import { fromBER, Utf8String, BmpString, PrintableString, IA5String } from "asn1js"; +import OperationError from "../errors/OperationError.mjs"; +import { fromBase64 } from "./Base64.mjs"; +import { fromHex } from "./Hex.mjs"; +import Utils from "../Utils.mjs"; + +const { SubjectPublicKeyInfo, DirectoryString } = asn1X509; +const { ECParameters } = ecc; +const { RSAPublicKey } = rsaSchemas; + +const ID_RSA_ENCRYPTION = "1.2.840.113549.1.1.1"; +const ID_EC_PUBLIC_KEY = ecc.id_ecPublicKey; +const ID_DSA = "1.2.840.10040.4.1"; +const ID_ED25519 = "1.3.101.112"; +const ID_ED448 = "1.3.101.113"; + +const SIG_ALG_OID_TO_NAME = { + "1.2.840.113549.1.1.4": "MD5withRSA", + "1.2.840.113549.1.1.5": "SHA1withRSA", + "1.2.840.113549.1.1.11": "SHA256withRSA", + "1.2.840.113549.1.1.12": "SHA384withRSA", + "1.2.840.113549.1.1.13": "SHA512withRSA", + "1.2.840.113549.1.1.14": "SHA224withRSA", + "1.2.840.113549.1.1.10": "SHA256withRSAandMGF1", + "1.2.840.10045.4.1": "SHA1withECDSA", + "1.2.840.10045.4.3.1": "SHA224withECDSA", + "1.2.840.10045.4.3.2": "SHA256withECDSA", + "1.2.840.10045.4.3.3": "SHA384withECDSA", + "1.2.840.10045.4.3.4": "SHA512withECDSA", + "1.2.840.10040.4.3": "SHA1withDSA", + "2.16.840.1.101.3.4.3.1": "SHA224withDSA", + "2.16.840.1.101.3.4.3.2": "SHA256withDSA", + "2.16.840.1.101.3.4.3.3": "SHA384withDSA", + "2.16.840.1.101.3.4.3.4": "SHA512withDSA", + [ID_ED25519]: "Ed25519", + [ID_ED448]: "Ed448", +}; + +const EC_CURVE_OID_TO_NAMES = { + "1.2.840.10045.3.1.1": { asn1: "secp192r1", nist: "P-192", byteLen: 24, bits: 192 }, + "1.3.132.0.33": { asn1: "secp224r1", nist: "P-224", byteLen: 28, bits: 224 }, + "1.2.840.10045.3.1.7": { asn1: "secp256r1", nist: "P-256", byteLen: 32, bits: 256 }, + "1.3.132.0.34": { asn1: "secp384r1", nist: "P-384", byteLen: 48, bits: 384 }, + "1.3.132.0.35": { asn1: "secp521r1", nist: "P-521", byteLen: 66, bits: 521 }, +}; + +const OID_TO_SHORT_NAME = { + "2.5.4.3": "CN", + "2.5.4.4": "SN", + "2.5.4.5": "serialNumber", + "2.5.4.6": "C", + "2.5.4.7": "L", + "2.5.4.8": "ST", + "2.5.4.9": "street", + "2.5.4.10": "O", + "2.5.4.11": "OU", + "2.5.4.12": "T", + "2.5.4.42": "G", + "2.5.4.43": "I", + "2.5.4.44": "generationQualifier", + "2.5.4.45": "x500UniqueIdentifier", + "2.5.4.46": "dnQualifier", + "2.5.4.65": "pseudonym", + "1.2.840.113549.1.9.1": "E", + "0.9.2342.19200300.100.1.25": "DC", + "0.9.2342.19200300.100.1.1": "UID", +}; + + +// ----- input decoding ------------------------------------------------------- + +/** + * Decode the X.509 input string in the requested wire format into a Uint8Array + * of DER bytes. Accepts "PEM", "DER Hex", "Base64", "Raw". + * + * @param {string} input + * @param {string} format + * @returns {Uint8Array} + */ +export function decodeX509Input(input, format) { + switch (format) { + case "PEM": { + const stripped = input + .replace(/-----BEGIN [^-]+-----/g, "") + .replace(/-----END [^-]+-----/g, "") + .replace(/\s+/g, ""); + return new Uint8Array(fromBase64(stripped, null, "byteArray")); + } + case "DER Hex": + return new Uint8Array(fromHex(input.replace(/\s/g, ""))); + case "Base64": + return new Uint8Array(fromBase64(input, null, "byteArray")); + case "Raw": + return new Uint8Array(Utils.strToArrayBuffer(input)); + default: + throw new OperationError(`Undefined input format: ${format}`); + } +} + + +// ----- signature algorithm -------------------------------------------------- + +/** + * Map a signature-algorithm OID to the jsrsasign-style display name + * (e.g. "1.2.840.113549.1.1.11" -> "SHA256withRSA"). Falls back to the + * raw OID if unknown. + * + * @param {string} oid + * @returns {string} + */ +export function sigAlgOidToName(oid) { + return SIG_ALG_OID_TO_NAME[oid] || oid; +} + + +// ----- public key info extraction ------------------------------------------- + +/** + * Decode a SubjectPublicKeyInfo DER blob and return a normalised description. + * + * The return shape varies by algorithm: + * RSA: { type: "RSA", nHex, eValue, bitLength } + * EC: { type: "EC", curveOid, asn1Curve, nistCurve, pubKeyHex, bitLength, x, y } + * DSA: { type: "DSA", yHex, pHex, qHex, gHex, bitLength } + * Ed25519/Ed448: { type: "EdDSA", curveName, pubKeyHex } + * Other: { type: "Unknown", algorithm } + * + * @param {Uint8Array} spkiBytes + * @returns {object} + */ +export function describeSpki(spkiBytes) { + const spki = AsnParser.parse(spkiBytes, SubjectPublicKeyInfo); + const alg = spki.algorithm.algorithm; + const keyBytes = new Uint8Array(spki.subjectPublicKey); + + if (alg === ID_RSA_ENCRYPTION) { + const rsa = AsnParser.parse(keyBytes, RSAPublicKey); + const n = stripDerLeadingZero(new Uint8Array(rsa.modulus)); + const e = stripDerLeadingZero(new Uint8Array(rsa.publicExponent)); + return { + type: "RSA", + nHex: bytesToHex(n), + eValue: Number(BigInt("0x" + (bytesToHex(e) || "0"))), + bitLength: n.length === 0 ? 0 : ((n.length - 1) * 8) + (32 - Math.clz32(n[0])), + }; + } + + if (alg === ID_EC_PUBLIC_KEY) { + if (!spki.algorithm.parameters) throw new OperationError("EC SubjectPublicKeyInfo missing parameters"); + const params = AsnParser.parse(new Uint8Array(spki.algorithm.parameters), ECParameters); + const curveOid = params.namedCurve; + const info = EC_CURVE_OID_TO_NAMES[curveOid] || { asn1: curveOid, nist: curveOid, byteLen: null, bits: null }; + if (keyBytes[0] !== 0x04) { + throw new OperationError("Only uncompressed EC public keys are supported"); + } + const byteLen = info.byteLen || ((keyBytes.length - 1) / 2); + return { + type: "EC", + curveOid, + asn1Curve: info.asn1, + nistCurve: info.nist, + pubKeyHex: bytesToHex(keyBytes), + bitLength: info.bits || byteLen * 8, + x: keyBytes.slice(1, 1 + byteLen), + y: keyBytes.slice(1 + byteLen, 1 + 2 * byteLen), + }; + } + + if (alg === ID_DSA) { + if (!spki.algorithm.parameters) throw new OperationError("DSA SubjectPublicKeyInfo missing DSS-Parms"); + const { p, q, g } = parseDssParms(new Uint8Array(spki.algorithm.parameters)); + const y = parseIntegerBitStringBytes(keyBytes); + const pStripped = stripDerLeadingZero(p); + return { + type: "DSA", + yHex: bytesToHex(y), + pHex: bytesToHex(p), + qHex: bytesToHex(q), + gHex: bytesToHex(g), + bitLength: pStripped.length * 8, + }; + } + + if (alg === ID_ED25519 || alg === ID_ED448) { + return { + type: "EdDSA", + curveName: alg === ID_ED25519 ? "Ed25519" : "Ed448", + pubKeyHex: bytesToHex(keyBytes), + }; + } + + return { type: "Unknown", algorithm: alg }; +} + + +// ----- signature value parsing ---------------------------------------------- + +/** + * Parse a DER-encoded ECDSA/DSA signature (SEQUENCE { r INTEGER, s INTEGER }) + * and return r and s as hex strings (without the DER 2's-complement leading + * 0x00, when present). + * + * @param {string} sigHex + * @returns {{r: string, s: string}} + */ +export function parseDerEcdsaSignature(sigHex) { + const bytes = fromHex(sigHex); + let i = 0; + if (bytes[i++] !== 0x30) throw new OperationError("Signature is not an ASN.1 SEQUENCE"); + const seqLen = readDerLength(bytes, i); i = seqLen.next; + if (i + seqLen.value !== bytes.length) throw new OperationError("Trailing bytes after SEQUENCE"); + + if (bytes[i++] !== 0x02) throw new OperationError("First element is not an INTEGER"); + const rLen = readDerLength(bytes, i); i = rLen.next; + const r = bytes.slice(i, i + rLen.value); i += rLen.value; + + if (bytes[i++] !== 0x02) throw new OperationError("Second element is not an INTEGER"); + const sLen = readDerLength(bytes, i); i = sLen.next; + const s = bytes.slice(i, i + sLen.value); + + return { + r: bytesToHex(stripDerLeadingZero(Uint8Array.from(r))), + s: bytesToHex(stripDerLeadingZero(Uint8Array.from(s))), + }; +} + +/** + * Returns true if the supplied hex bytes parse as a SEQUENCE of two INTEGERs + * (the wire format used for ECDSA/DSA signatures). + * + * @param {string} sigHex + * @returns {boolean} + */ +export function isDerEcdsaSignature(sigHex) { + try { + parseDerEcdsaSignature(sigHex); + return true; + } catch { + return false; + } +} + + +// ----- formatters ----------------------------------------------------------- + +/** + * Format a peculiar/x509 `JsonName` (the array-of-records form returned by + * `name.toJSON()`) as a multi-line string of `KEY = value` pairs, indented. + * + * @param {Array>} jsonName + * @param {number} indent + * @returns {string} + */ +export function formatJsonName(jsonName, indent) { + if (!Array.isArray(jsonName) || jsonName.length === 0) return ""; + const rows = []; + for (const rdn of jsonName) { + for (const key of Object.keys(rdn)) { + const values = rdn[key]; + for (const value of values) rows.push({ key, value }); + } + } + if (rows.length === 0) return ""; + const maxKey = rows.reduce((m, row) => Math.max(m, row.key.length), 0); + const pad = " ".repeat(indent); + return rows.map(({ key, value }) => `${pad}${key.padEnd(maxKey, " ")} = ${value}`).join("\n"); +} + +/** + * Convert peculiar/x509's `JsonName` into the OpenSSL-style + * "/C=…/ST=…/O=…/CN=…" single-line representation. + * + * @param {Array>} jsonName + * @returns {string} + */ +export function jsonNameToSlashString(jsonName) { + if (!Array.isArray(jsonName) || jsonName.length === 0) return ""; + let out = ""; + for (const rdn of jsonName) { + for (const key of Object.keys(rdn)) { + for (const value of rdn[key]) out += "/" + key + "=" + value; + } + } + return out; +} + +/** + * Format a hex string as `aa:bb:cc:...` groups of `bytesPerLine` bytes per + * line, with each line after the first prefixed by `indent` spaces. + * + * @param {string} hex + * @param {number} bytesPerLine + * @param {number} indent + * @returns {string} + */ +export function formatHexByteLines(hex, bytesPerLine, indent) { + if (hex.length % 2 !== 0) hex = "0" + hex; + const colonHex = hex.replace(/(..)/g, "$1:"); + const trimmed = colonHex.slice(0, -1); + const lineLen = bytesPerLine * 3; + let out = ""; + for (let i = 0; i < trimmed.length; i += lineLen) { + const chunk = trimmed.slice(i, i + lineLen) + "\n"; + out += i === 0 ? chunk : " ".repeat(indent) + chunk; + } + return out.slice(0, -1); +} + +/** + * Format a hex string as colon-delimited bytes wrapped to `maxLineChars` + * characters per line, with continuation lines indented by `indent` spaces. + * + * Used by ParseCSR / ParseX509CRL where the wrap width is measured in + * characters rather than bytes per line. + * + * @param {string} hex + * @param {number} maxLineChars + * @param {number} indent + * @returns {string} + */ +export function formatHexColonWrapped(hex, maxLineChars, indent) { + if (hex.length % 2 !== 0) hex = "0" + hex; + const colonHex = hex.replace(/(..)/g, "$1:"); + const trimmed = colonHex.slice(0, -1); + const lines = []; + for (let i = 0; i < trimmed.length; i += maxLineChars) { + lines.push(trimmed.substring(i, i + maxLineChars)); + } + const pad = " ".repeat(indent); + return lines.join("\n" + pad); +} + + +// ----- SAN / GeneralName formatting ----------------------------------------- + +/** + * Format a single ASN.1 GeneralName (from `@peculiar/asn1-x509`). The + * `flavor` arg controls the punctuation: "csr" produces "KEY: value" (with + * a space), "crl" produces "KEY:value" (no space and slightly different + * label set). + * + * @param {object} gn - An asn1-x509 GeneralName instance. + * @param {"csr"|"crl"} flavor + * @returns {string} + */ +export function formatGeneralName(gn, flavor) { + const sep = flavor === "crl" ? ":" : ": "; + if (gn.dNSName !== undefined) return `DNS${sep}${gn.dNSName}`; + if (gn.iPAddress !== undefined) return `IP${sep}${gn.iPAddress}`; + if (gn.rfc822Name !== undefined) return `EMAIL${sep}${gn.rfc822Name}`; + if (gn.uniformResourceIdentifier !== undefined) return `URI${sep}${gn.uniformResourceIdentifier}`; + if (gn.directoryName !== undefined) { + return `DIR${sep}${jsonNameToSlashString(asnNameToJson(gn.directoryName))}`; + } + if (gn.registeredID !== undefined) return `ID${sep}${gn.registeredID}`; + if (gn.otherName !== undefined) { + const value = otherNameValueToString(gn.otherName); + const label = flavor === "crl" ? "OtherName" : "Other"; + return `${label}${sep}${gn.otherName.typeId}::${value}`; + } + return `(unsupported general name)`; +} + +/** + * Attempt to extract a printable string from an OtherName's ANY-typed value. + * + * @param {{typeId: string, value: ArrayBuffer}} otherName + * @returns {string} + */ +function otherNameValueToString(otherName) { + const bytes = new Uint8Array(otherName.value); + const ab = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); + const parsed = fromBER(ab); + if (parsed.offset === -1) return bytesToHex(bytes); + const node = parsed.result; + if (node instanceof Utf8String || node instanceof BmpString || + node instanceof PrintableString || node instanceof IA5String) { + return node.valueBlock.value; + } + try { + const ds = AsnParser.parse(bytes, DirectoryString); + return ds.toString(); + } catch { /* fall through */ } + return bytesToHex(bytes); +} + +/** + * Convert an asn1-x509 Name (CHOICE { RDNSequence }) into the JsonName + * representation used by `formatJsonName` / `jsonNameToSlashString`. Uses + * the same field-name vocabulary as peculiar/x509's `Name.toJSON()`. + * + * @param {object} asnName + * @returns {Array>} + */ +export function asnNameToJson(asnName) { + const out = []; + if (!asnName || typeof asnName[Symbol.iterator] !== "function") return out; + for (const rdn of asnName) { + const obj = {}; + for (const atv of rdn) { + const key = OID_TO_SHORT_NAME[atv.type] || atv.type; + const val = atv.value.toString(); + if (!obj[key]) obj[key] = []; + obj[key].push(val); + } + out.push(obj); + } + return out; +} + + +// ----- byte helpers --------------------------------------------------------- + +/** + * Convert a Uint8Array to a lowercase hex string. + * + * @param {Uint8Array} bytes + * @returns {string} + */ +export function bytesToHex(bytes) { + let out = ""; + for (const b of bytes) out += b.toString(16).padStart(2, "0"); + return out; +} + +/** + * Strip a single leading 0x00 byte from a buffer when it's only present to + * keep a DER INTEGER positive (i.e. the next byte's MSB is set). + * + * @param {Uint8Array} bytes + * @returns {Uint8Array} + */ +export function stripDerLeadingZero(bytes) { + if (bytes.length > 1 && bytes[0] === 0 && (bytes[1] & 0x80)) { + return bytes.slice(1); + } + return bytes; +} + +/** + * Read a BER/DER length octet sequence. + * + * @param {Uint8Array} bytes + * @param {number} offset + * @returns {{value: number, next: number}} + */ +function readDerLength(bytes, offset) { + const first = bytes[offset]; + if (first < 0x80) return { value: first, next: offset + 1 }; + const n = first & 0x7f; + let value = 0; + for (let i = 0; i < n; i++) value = (value << 8) | bytes[offset + 1 + i]; + return { value, next: offset + 1 + n }; +} + +/** + * Parse the DSS-Parms (p, q, g) SEQUENCE from a DSA algorithm parameters + * blob. + * + * @param {Uint8Array} bytes + * @returns {{p: Uint8Array, q: Uint8Array, g: Uint8Array}} + */ +function parseDssParms(bytes) { + const parsed = fromBER(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)); + if (parsed.offset === -1) throw new OperationError("Invalid DSS-Parms"); + const items = parsed.result.valueBlock && parsed.result.valueBlock.value; + if (!items || items.length < 3) throw new OperationError("Malformed DSS-Parms"); + return { + p: extractIntegerBytes(items[0]), + q: extractIntegerBytes(items[1]), + g: extractIntegerBytes(items[2]), + }; +} + +/** + * Decode an INTEGER wrapped in a BIT STRING (used for DSA subjectPublicKey). + * + * @param {Uint8Array} bytes + * @returns {Uint8Array} + */ +function parseIntegerBitStringBytes(bytes) { + const parsed = fromBER(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)); + if (parsed.offset === -1) throw new OperationError("Invalid INTEGER in SPKI"); + return extractIntegerBytes(parsed.result); +} + +/** + * Pull the raw INTEGER bytes out of an asn1js Integer node (preserving the + * DER 2's-complement leading 00 — callers strip it if they need a magnitude). + * + * @param {object} node + * @returns {Uint8Array} + */ +function extractIntegerBytes(node) { + const view = node.valueBlock && node.valueBlock.valueHexView; + if (!view) throw new OperationError("Missing INTEGER value"); + return new Uint8Array(view); +} diff --git a/src/core/operations/ParseCSR.mjs b/src/core/operations/ParseCSR.mjs index d3b3c364..ec6620dc 100644 --- a/src/core/operations/ParseCSR.mjs +++ b/src/core/operations/ParseCSR.mjs @@ -4,11 +4,34 @@ * @license Apache-2.0 */ -import r from "jsrsasign"; +import { Pkcs10CertificateRequest } from "@peculiar/x509"; +import { AsnParser } from "@peculiar/asn1-schema"; +import { + BasicConstraints, ExtendedKeyUsage, Extensions, KeyUsage, + SubjectAlternativeName, +} from "@peculiar/asn1-x509"; +import * as asn1X509 from "@peculiar/asn1-x509"; +import { CertificationRequest } from "@peculiar/asn1-csr"; +import * as asnPkcs9 from "@peculiar/asn1-pkcs9"; import Operation from "../Operation.mjs"; +import OperationError from "../errors/OperationError.mjs"; import { formatDnObj } from "../lib/PublicKey.mjs"; +import { + bytesToHex, + describeSpki, + formatGeneralName, + formatHexColonWrapped, + parseDerEcdsaSignature, + sigAlgOidToName, +} from "../lib/X509.mjs"; import Utils from "../Utils.mjs"; +const ID_CE_BASIC_CONSTRAINTS = asn1X509.id_ce_basicConstraints; +const ID_CE_EXT_KEY_USAGE = asn1X509.id_ce_extKeyUsage; +const ID_CE_KEY_USAGE = asn1X509.id_ce_keyUsage; +const ID_CE_SUBJECT_ALT_NAME = asn1X509.id_ce_subjectAltName; +const ID_PKCS9_AT_EXTENSION_REQUEST = asnPkcs9.id_pkcs9_at_extensionRequest; + /** * Parse CSR operation */ @@ -45,333 +68,232 @@ class ParseCSR extends Operation { /** * @param {string} input * @param {Object[]} args - * @returns {string} Human-readable description of a Certificate Signing Request (CSR). + * @returns {string} */ run(input, args) { - if (!input.length) { - return "No input"; + if (!input.length) return "No input"; + + let csr; + try { + csr = new Pkcs10CertificateRequest(input); + } catch (e) { + throw new OperationError(`Failed to parse CSR: ${e.message}`); } - // Parse the CSR into JSON parameters - const csrParam = new r.KJUR.asn1.csr.CSRUtil.getParam(input); + const subjectStr = formatDnObj(csr.subjectName.toJSON(), 2); + const spki = describeSpki(new Uint8Array(csr.publicKey.rawData)); + const asnCsr = AsnParser.parse(new Uint8Array(csr.rawData), CertificationRequest); + const sigAlgName = sigAlgOidToName(asnCsr.signatureAlgorithm.algorithm); + const sigHex = bytesToHex(new Uint8Array(csr.signature)); - return `Subject\n${formatDnObj(csrParam.subject, 2)} -Public Key${formatSubjectPublicKey(csrParam.sbjpubkey)} -Signature${formatSignature(csrParam.sigalg, csrParam.sighex)} -Requested Extensions${formatRequestedExtensions(csrParam)}`; + return `Subject\n${subjectStr} +Public Key${formatPublicKey(spki)} +Signature${formatSignature(sigAlgName, sigHex)} +Requested Extensions${formatRequestedExtensions(csr)}`; } } /** - * Format signature of a CSR - * @param {*} sigAlg string - * @param {*} sigHex string - * @returns Multi-line string describing CSR Signature + * Format the public-key section. + * + * @param {object} spki + * @returns {string} */ -function formatSignature(sigAlg, sigHex) { - let out = `\n`; - - out += ` Algorithm: ${sigAlg}\n`; - - if (new RegExp("withdsa", "i").test(sigAlg)) { - const d = new r.KJUR.crypto.DSA(); - const sigParam = d.parseASN1Signature(sigHex); - out += ` Signature: - R: ${formatHexOntoMultiLine(absBigIntToHex(sigParam[0]))} - S: ${formatHexOntoMultiLine(absBigIntToHex(sigParam[1]))}\n`; - } else if (new RegExp("withrsa", "i").test(sigAlg)) { - out += ` Signature: ${formatHexOntoMultiLine(sigHex)}\n`; - } else { - out += ` Signature: ${formatHexOntoMultiLine(ensureHexIsPositiveInTwosComplement(sigHex))}\n`; - } - - return chop(out); -} - -/** - * Format Subject Public Key from PEM encoded public key string - * @param {*} publicKeyPEM string - * @returns Multi-line string describing Subject Public Key Info - */ -function formatSubjectPublicKey(publicKeyPEM) { +function formatPublicKey(spki) { let out = "\n"; - - const publicKey = r.KEYUTIL.getKey(publicKeyPEM); - if (publicKey instanceof r.RSAKey) { + if (spki.type === "RSA") { out += ` Algorithm: RSA - Length: ${publicKey.n.bitLength()} bits - Modulus: ${formatHexOntoMultiLine(absBigIntToHex(publicKey.n))} - Exponent: ${publicKey.e} (0x${Utils.hex(publicKey.e)})\n`; - } else if (publicKey instanceof r.KJUR.crypto.ECDSA) { + Length: ${spki.bitLength} bits + Modulus: ${formatHexColonWrapped(prefix00IfMsbSet(spki.nHex), 48, 18)} + Exponent: ${spki.eValue} (0x${Utils.hex(spki.eValue)})\n`; + } else if (spki.type === "EC") { out += ` Algorithm: ECDSA - Length: ${publicKey.ecparams.keylen} bits - Pub: ${formatHexOntoMultiLine(publicKey.pubKeyHex)} - ASN1 OID: ${r.KJUR.crypto.ECDSA.getName(publicKey.getShortNISTPCurveName())} - NIST CURVE: ${publicKey.getShortNISTPCurveName()}\n`; - } else if (publicKey instanceof r.KJUR.crypto.DSA) { + Length: ${spki.bitLength} bits + Pub: ${formatHexColonWrapped(spki.pubKeyHex, 48, 18)} + ASN1 OID: ${spki.asn1Curve} + NIST CURVE: ${spki.nistCurve}\n`; + } else if (spki.type === "DSA") { out += ` Algorithm: DSA - Length: ${publicKey.p.toString(16).length * 4} bits - Pub: ${formatHexOntoMultiLine(absBigIntToHex(publicKey.y))} - P: ${formatHexOntoMultiLine(absBigIntToHex(publicKey.p))} - Q: ${formatHexOntoMultiLine(absBigIntToHex(publicKey.q))} - G: ${formatHexOntoMultiLine(absBigIntToHex(publicKey.g))}\n`; + Length: ${spki.bitLength} bits + Pub: ${formatHexColonWrapped(prefix00IfMsbSet(spki.yHex), 48, 18)} + P: ${formatHexColonWrapped(prefix00IfMsbSet(spki.pHex), 48, 18)} + Q: ${formatHexColonWrapped(prefix00IfMsbSet(spki.qHex), 48, 18)} + G: ${formatHexColonWrapped(prefix00IfMsbSet(spki.gHex), 48, 18)}\n`; } else { out += `unsupported public key algorithm\n`; } - return chop(out); } /** - * Format known extensions of a CSR - * @param {*} csrParam object - * @returns Multi-line string describing CSR Requested Extensions + * Prefix the hex string with "00" when its most significant bit is set. + * Mirrors the legacy "ensureHexIsPositiveInTwosComplement" behaviour the + * golden CSR fixtures depend on. + * + * @param {string} hex + * @returns {string} */ -function formatRequestedExtensions(csrParam) { - const formattedExtensions = new Array(4).fill(""); - - if (Object.hasOwn(csrParam, "extreq")) { - for (const extension of csrParam.extreq) { - let parts = []; - switch (extension.extname) { - case "basicConstraints" : - parts = describeBasicConstraints(extension); - formattedExtensions[0] = ` Basic Constraints:${formatExtensionCriticalTag(extension)}\n${indent(4, parts)}`; - break; - case "keyUsage" : - parts = describeKeyUsage(extension); - formattedExtensions[1] = ` Key Usage:${formatExtensionCriticalTag(extension)}\n${indent(4, parts)}`; - break; - case "extKeyUsage" : - parts = describeExtendedKeyUsage(extension); - formattedExtensions[2] = ` Extended Key Usage:${formatExtensionCriticalTag(extension)}\n${indent(4, parts)}`; - break; - case "subjectAltName" : - parts = describeSubjectAlternativeName(extension); - formattedExtensions[3] = ` Subject Alternative Name:${formatExtensionCriticalTag(extension)}\n${indent(4, parts)}`; - break; - default : - parts = ["(unsuported extension)"]; - formattedExtensions.push(` ${extension.extname}:${formatExtensionCriticalTag(extension)}\n${indent(4, parts)}`); - } - } - } - - let out = "\n"; - - formattedExtensions.forEach((formattedExtension) => { - if (formattedExtension !== undefined && formattedExtension !== null && formattedExtension.length !== 0) { - out += formattedExtension; - } - }); - - return chop(out); -} - -/** - * Format extension critical tag - * @param {*} extension Object - * @returns String describing whether the extension is critical or not - */ -function formatExtensionCriticalTag(extension) { - return Object.hasOwn(extension, "critical") && extension.critical ? " critical" : ""; -} - -/** - * Format string input as a comma separated hex string on multiple lines - * @param {*} hex String - * @returns Multi-line string describing the Hex input - */ -function formatHexOntoMultiLine(hex) { - if (hex.length % 2 !== 0) { - hex = "0" + hex; - } - - return formatMultiLine(chop(hex.replace(/(..)/g, "$&:"))); -} - -/** - * Convert BigInt to abs value in Hex - * @param {*} int BigInt - * @returns String representing absolute value in Hex - */ -function absBigIntToHex(int) { - int = int < 0n ? -int : int; - - return ensureHexIsPositiveInTwosComplement(int.toString(16)); -} - -/** - * Ensure Hex String remains positive in 2's complement - * @param {*} hex String - * @returns Hex String ensuring value remains positive in 2's complement - */ -function ensureHexIsPositiveInTwosComplement(hex) { - if (hex.length % 2 !== 0) { - return "0" + hex; - } - - // prepend 00 if most significant bit is 1 (sign bit) - if (hex.length >=2 && (parseInt(hex.substring(0, 2), 16) & 128)) { +function prefix00IfMsbSet(hex) { + if (hex.length % 2 !== 0) hex = "0" + hex; + if (hex.length >= 2 && (parseInt(hex.substring(0, 2), 16) & 0x80)) { hex = "00" + hex; } - return hex; } /** - * Format string onto multiple lines - * @param {*} longStr - * @returns String as a multi-line string + * Format the signature section. + * + * @param {string} sigAlgName + * @param {string} sigHex + * @returns {string} */ -function formatMultiLine(longStr) { - const lines = []; +function formatSignature(sigAlgName, sigHex) { + let out = `\n Algorithm: ${sigAlgName}\n`; - for (let remain = longStr ; remain !== "" ; remain = remain.substring(48)) { - lines.push(remain.substring(0, 48)); + if (/withdsa/i.test(sigAlgName)) { + const { r, s } = parseDerEcdsaSignature(sigHex); + out += ` Signature: + R: ${formatHexColonWrapped(prefix00IfMsbSet(r), 48, 18)} + S: ${formatHexColonWrapped(prefix00IfMsbSet(s), 48, 18)}\n`; + } else if (/withrsa/i.test(sigAlgName)) { + out += ` Signature: ${formatHexColonWrapped(sigHex, 48, 18)}\n`; + } else { + out += ` Signature: ${formatHexColonWrapped(prefix00IfMsbSet(sigHex), 48, 18)}\n`; } - return lines.join("\n "); + return chop(out); } /** - * Describe Basic Constraints - * @see RFC 5280 4.2.1.9. Basic Constraints https://www.ietf.org/rfc/rfc5280.txt - * @param {*} extension CSR extension with the name `basicConstraints` - * @returns Array of strings describing Basic Constraints + * Format the Requested Extensions section. Reads the extensionRequest + * attribute (OID 1.2.840.113549.1.9.14) and dispatches the well-known + * extension types to per-type formatters. Unknown extensions render as + * `(unsuported extension)` to match the existing golden output. + * + * @param {object} csr + * @returns {string} */ -function describeBasicConstraints(extension) { - const constraints = []; - - constraints.push(`CA = ${Object.hasOwn(extension, "cA") && extension.cA ? "true" : "false"}`); - if (Object.hasOwn(extension, "pathLen")) constraints.push(`PathLenConstraint = ${extension.pathLen}`); - - return constraints; -} - -/** - * Describe Key Usage extension permitted use cases - * @see RFC 5280 4.2.1.3. Key Usage https://www.ietf.org/rfc/rfc5280.txt - * @param {*} extension CSR extension with the name `keyUsage` - * @returns Array of strings describing Key Usage extension permitted use cases - */ -function describeKeyUsage(extension) { - const usage = []; - - const kuIdentifierToName = { - digitalSignature: "Digital Signature", - nonRepudiation: "Non-repudiation", - keyEncipherment: "Key encipherment", - dataEncipherment: "Data encipherment", - keyAgreement: "Key agreement", - keyCertSign: "Key certificate signing", - cRLSign: "CRL signing", - encipherOnly: "Encipher Only", - decipherOnly: "Decipher Only", - }; - - if (Object.hasOwn(extension, "names")) { - extension.names.forEach((ku) => { - if (Object.hasOwn(kuIdentifierToName, ku)) { - usage.push(kuIdentifierToName[ku]); - } else { - usage.push(`unknown key usage (${ku})`); - } - }); +function formatRequestedExtensions(csr) { + const extReqAttr = csr.attributes.find(a => a.type === ID_PKCS9_AT_EXTENSION_REQUEST); + if (!extReqAttr || !extReqAttr.values || extReqAttr.values.length === 0) { + return "\n"; } - if (usage.length === 0) usage.push("(none)"); - - return usage; -} - -/** - * Describe Extended Key Usage extension permitted use cases - * @see RFC 5280 4.2.1.12. Extended Key Usage https://www.ietf.org/rfc/rfc5280.txt - * @param {*} extension CSR extension with the name `extendedKeyUsage` - * @returns Array of strings describing Extended Key Usage extension permitted use cases - */ -function describeExtendedKeyUsage(extension) { - const usage = []; - - const ekuIdentifierToName = { - "serverAuth": "TLS Web Server Authentication", - "clientAuth": "TLS Web Client Authentication", - "codeSigning": "Code signing", - "emailProtection": "E-mail Protection (S/MIME)", - "timeStamping": "Trusted Timestamping", - "1.3.6.1.4.1.311.2.1.21": "Microsoft Individual Code Signing", // msCodeInd - "1.3.6.1.4.1.311.2.1.22": "Microsoft Commercial Code Signing", // msCodeCom - "1.3.6.1.4.1.311.10.3.1": "Microsoft Trust List Signing", // msCTLSign - "1.3.6.1.4.1.311.10.3.3": "Microsoft Server Gated Crypto", // msSGC - "1.3.6.1.4.1.311.10.3.4": "Microsoft Encrypted File System", // msEFS - "1.3.6.1.4.1.311.20.2.2": "Microsoft Smartcard Login", // msSmartcardLogin - "2.16.840.1.113730.4.1": "Netscape Server Gated Crypto", // nsSGC - }; - - if (Object.hasOwn(extension, "array")) { - extension.array.forEach((eku) => { - if (Object.hasOwn(ekuIdentifierToName, eku)) { - usage.push(ekuIdentifierToName[eku]); - } else { - usage.push(eku); - } - }); + let extensions; + try { + extensions = AsnParser.parse(new Uint8Array(extReqAttr.values[0]), Extensions); + } catch { + return "\n"; } - if (usage.length === 0) usage.push("(none)"); + const formatted = new Array(4).fill(""); + const tail = []; - return usage; -} - -/** - * Format Subject Alternative Names from the name `subjectAltName` extension - * @see RFC 5280 4.2.1.6. Subject Alternative Name https://www.ietf.org/rfc/rfc5280.txt - * @param {*} extension object - * @returns Array of strings describing Subject Alternative Name extension - */ -function describeSubjectAlternativeName(extension) { - const names = []; - - if (Object.hasOwn(extension, "extname") && extension.extname === "subjectAltName") { - if (Object.hasOwn(extension, "array")) { - for (const altName of extension.array) { - Object.keys(altName).forEach((key) => { - switch (key) { - case "rfc822": - names.push(`EMAIL: ${altName[key]}`); - break; - case "dns": - names.push(`DNS: ${altName[key]}`); - break; - case "uri": - names.push(`URI: ${altName[key]}`); - break; - case "ip": - names.push(`IP: ${altName[key]}`); - break; - case "dn": - names.push(`DIR: ${altName[key].str}`); - break; - case "other" : - names.push(`Other: ${altName[key].oid}::${altName[key].value.utf8str.str}`); - break; - default: - names.push(`(unable to format SAN '${key}':${altName[key]})\n`); - } - }); + for (const ext of extensions) { + const criticalTag = ext.critical ? " critical" : ""; + const value = new Uint8Array(ext.extnValue.buffer); + switch (ext.extnID) { + case ID_CE_BASIC_CONSTRAINTS: { + const bc = AsnParser.parse(value, BasicConstraints); + const parts = [`CA = ${bc.cA ? "true" : "false"}`]; + if (bc.pathLenConstraint !== undefined) parts.push(`PathLenConstraint = ${bc.pathLenConstraint}`); + formatted[0] = ` Basic Constraints:${criticalTag}\n${indent(4, parts)}`; + break; } + case ID_CE_KEY_USAGE: { + const ku = AsnParser.parse(value, KeyUsage); + formatted[1] = ` Key Usage:${criticalTag}\n${indent(4, describeKeyUsage(ku.toNumber()))}`; + break; + } + case ID_CE_EXT_KEY_USAGE: { + const eku = AsnParser.parse(value, ExtendedKeyUsage); + formatted[2] = ` Extended Key Usage:${criticalTag}\n${indent(4, describeExtendedKeyUsage(Array.from(eku)))}`; + break; + } + case ID_CE_SUBJECT_ALT_NAME: { + const san = AsnParser.parse(value, SubjectAlternativeName); + const items = san.map(gn => formatGeneralName(gn, "csr")); + formatted[3] = ` Subject Alternative Name:${criticalTag}\n${indent(4, items)}`; + break; + } + default: + tail.push(` ${ext.extnID}:${criticalTag}\n${indent(4, ["(unsuported extension)"])}`); } } - return names; + let out = "\n"; + for (const block of [...formatted, ...tail]) { + if (block && block.length !== 0) out += block; + } + return chop(out); +} + +/** + * Translate the KeyUsage bit-string flags into the bit-order list of + * human-readable names the existing golden fixtures expect. + * + * @param {number} flags + * @returns {string[]} + */ +function describeKeyUsage(flags) { + const bitOrder = [ + [0x001, "Digital Signature"], + [0x002, "Non-repudiation"], + [0x004, "Key encipherment"], + [0x008, "Data encipherment"], + [0x010, "Key agreement"], + [0x020, "Key certificate signing"], + [0x040, "CRL signing"], + [0x080, "Encipher Only"], + [0x100, "Decipher Only"], + ]; + const out = []; + for (const [bit, label] of bitOrder) { + if (flags & bit) out.push(label); + } + if (out.length === 0) out.push("(none)"); + return out; +} + +/** + * Translate the EKU OIDs (and aliases) into the human-readable names the + * existing golden fixtures expect. + * + * @param {string[]} usages - List of OIDs/short names. + * @returns {string[]} + */ +function describeExtendedKeyUsage(usages) { + const ekuIdentifierToName = { + "1.3.6.1.5.5.7.3.1": "TLS Web Server Authentication", + "1.3.6.1.5.5.7.3.2": "TLS Web Client Authentication", + "1.3.6.1.5.5.7.3.3": "Code signing", + "1.3.6.1.5.5.7.3.4": "E-mail Protection (S/MIME)", + "1.3.6.1.5.5.7.3.8": "Trusted Timestamping", + "serverAuth": "TLS Web Server Authentication", + "clientAuth": "TLS Web Client Authentication", + "codeSigning": "Code signing", + "emailProtection": "E-mail Protection (S/MIME)", + "timeStamping": "Trusted Timestamping", + "1.3.6.1.4.1.311.2.1.21": "Microsoft Individual Code Signing", + "1.3.6.1.4.1.311.2.1.22": "Microsoft Commercial Code Signing", + "1.3.6.1.4.1.311.10.3.1": "Microsoft Trust List Signing", + "1.3.6.1.4.1.311.10.3.3": "Microsoft Server Gated Crypto", + "1.3.6.1.4.1.311.10.3.4": "Microsoft Encrypted File System", + "1.3.6.1.4.1.311.20.2.2": "Microsoft Smartcard Login", + "2.16.840.1.113730.4.1": "Netscape Server Gated Crypto", + }; + const out = usages.map(eku => ekuIdentifierToName[eku] || eku); + if (out.length === 0) out.push("(none)"); + return out; } /** * Join an array of strings and add leading spaces to each line. - * @param {*} n How many leading spaces - * @param {*} parts Array of strings - * @returns Joined and indented string. + * + * @param {number} n + * @param {string[]} parts + * @returns {string} */ function indent(n, parts) { const fluff = " ".repeat(n); @@ -379,12 +301,13 @@ function indent(n, parts) { } /** - * Remove last character from a string. - * @param {*} s String - * @returns Chopped string. + * Remove the last character from a string. + * + * @param {string} s + * @returns {string} */ function chop(s) { - return s.substring(0, s.length - 1); + return s.length === 0 ? s : s.substring(0, s.length - 1); } export default ParseCSR; diff --git a/src/core/operations/ParseX509CRL.mjs b/src/core/operations/ParseX509CRL.mjs index f498375d..1130fc75 100644 --- a/src/core/operations/ParseX509CRL.mjs +++ b/src/core/operations/ParseX509CRL.mjs @@ -4,13 +4,50 @@ * @license Apache-2.0 */ -import r from "jsrsasign"; +import { X509Crl } from "@peculiar/x509"; +import { AsnParser } from "@peculiar/asn1-schema"; +import { + AuthorityKeyIdentifier, CertificateList, CRLDistributionPoints, CRLNumber, CRLReason, + InvalidityDate, IssueAlternativeName, +} from "@peculiar/asn1-x509"; +import * as asn1X509 from "@peculiar/asn1-x509"; 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"; +import { formatDnObj } from "../lib/PublicKey.mjs"; +import { + bytesToHex, + decodeX509Input, + formatGeneralName, + sigAlgOidToName, +} from "../lib/X509.mjs"; + +const ID_CE_AUTHORITY_KEY_IDENTIFIER = asn1X509.id_ce_authorityKeyIdentifier; +const ID_CE_CRL_DISTRIBUTION_POINTS = asn1X509.id_ce_cRLDistributionPoints; +const ID_CE_CRL_NUMBER = asn1X509.id_ce_cRLNumber; +const ID_CE_CRL_REASONS = asn1X509.id_ce_cRLReasons; +const ID_CE_INVALIDITY_DATE = asn1X509.id_ce_invalidityDate; +const ID_CE_ISSUER_ALT_NAME = asn1X509.id_ce_issuerAltName; + +const HOLD_INSTRUCTION_EXT_OID = "2.5.29.23"; + +const CRL_REASON_TO_NAME = { + 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 HOLD_INSTRUCTION_OID_TO_NAME = { + "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", +}; /** * Parse X.509 CRL operation @@ -48,344 +85,311 @@ class ParseX509CRL extends Operation { /** * @param {string} input * @param {Object[]} args - * @returns {string} Human-readable description of a Certificate Revocation List (CRL). + * @returns {string} */ run(input, args) { - if (!input.length) { - return "No input"; - } + if (!input.length) return "No input"; const inputFormat = args[0]; - - let undefinedInputFormat = false; + let derBytes; 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; - } + derBytes = decodeX509Input(input, inputFormat); } catch (e) { - throw "Certificate load error (non-certificate input?)"; + throw new OperationError(`Certificate load error (non-certificate input?): ${e.message}`); } - if (undefinedInputFormat) throw "Undefined input format"; - const crl = new r.X509CRL(input); + let crl; + try { + crl = new X509Crl(derBytes); + } catch (e) { + throw new OperationError(`Certificate load error (non-certificate input?): ${e.message}`); + } + + const asnCrl = AsnParser.parse(new Uint8Array(crl.rawData), CertificateList); + const sigAlgName = sigAlgOidToName(asnCrl.signatureAlgorithm.algorithm); 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`; + Version: ${crl.version === undefined || crl.version === 0 ? "1 (0x0)" : "2 (0x1)"} + Signature Algorithm: ${sigAlgName} + Issuer:\n${formatDnObj(crl.issuerName.toJSON(), 8)} + Last Update: ${crl.thisUpdate.toUTCString()} + Next Update: ${crl.nextUpdate ? crl.nextUpdate.toUTCString() : "undefined"}\n`; - if (crl.getParam().ext !== undefined) { - out += `\tCRL extensions:\n${formatCRLExtensions(crl.getParam().ext, 8)}\n`; + if (crl.extensions && crl.extensions.length > 0) { + out += `\tCRL extensions:\n${formatCRLExtensions(crl.extensions, 8)}\n`; } - out += `Revoked Certificates:\n${formatRevokedCertificates(crl.getRevCertArray(), 4)} -Signature Value:\n${formatCRLSignature(crl.getSignatureValueHex(), 8)}`; + out += `Revoked Certificates:\n${formatRevokedCertificates(crl.entries, 4)} +Signature Value:\n${formatCRLSignature(bytesToHex(new Uint8Array(crl.signature)), 8)}`; return out; } } /** - * Generalized date time string to UTC. - * @param {string} datetime - * @returns UTC datetime string. + * Format the CRL extensions block. + * + * Extensions are emitted in OID-ascending order to match the legacy output: + * the old code sorted by `extname` string, but the asn1-x509 OID constants + * we get back from peculiar/x509 sort identically for the supported types. + * + * Unsupported extensions are listed as `:` followed by an + * "Unsupported CRL extension. Try openssl CLI." line — same as before. + * + * @param {object[]} extensions - peculiar/x509 Extension objects. + * @param {number} indentSpaces + * @returns {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}`); +function formatCRLExtensions(extensions, indentSpaces) { + if (!Array.isArray(extensions) || extensions.length === 0) { + return indentString("No CRL extensions.", indentSpaces); } - // Extract components - let centuary = "20"; - if (datetime.length === 15) { - centuary = datetime.substring(0, 2); - datetime = datetime.slice(2); + // Sort to match the legacy alphabetical-by-extname ordering as closely + // as possible. Use a synthetic name per OID. + const sorted = [...extensions].sort((a, b) => { + const an = extDisplayName(a.type); + const bn = extDisplayName(b.type); + if (an < bn) return -1; + if (an > bn) return 1; + return 0; + }); + + let out = ""; + for (const ext of sorted) { + out += formatCRLExtension(ext) + "\n"; } - 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(); + return indentString(chop(out), indentSpaces); } /** - * Format CRL extensions. - * @param {r.ExtParam[] | undefined} extensions - * @param {Number} indent - * @returns Formatted string detailing CRL extensions. + * Pick a display name for an extension OID (used only for sort stability). + * + * @param {string} oid + * @returns {string} */ -function formatCRLExtensions(extensions, indent) { - if (Array.isArray(extensions) === false || extensions.length === 0) { - return indentString(`No CRL extensions.`, indent); +function extDisplayName(oid) { + switch (oid) { + case ID_CE_AUTHORITY_KEY_IDENTIFIER: return "authorityKeyIdentifier"; + case ID_CE_CRL_DISTRIBUTION_POINTS: return "cRLDistributionPoints"; + case ID_CE_CRL_NUMBER: return "cRLNumber"; + case ID_CE_ISSUER_ALT_NAME: return "issuerAltName"; + default: return oid; + } +} + +/** + * Format a single CRL extension. + * + * @param {object} ext - peculiar/x509 Extension + * @returns {string} + */ +function formatCRLExtension(ext) { + const value = new Uint8Array(ext.value); + + if (ext.type === ID_CE_AUTHORITY_KEY_IDENTIFIER) { + let out = `X509v3 Authority Key Identifier:\n`; + const aki = AsnParser.parse(value, AuthorityKeyIdentifier); + if (aki.keyIdentifier) { + out += `\tkeyid:${colonHex(bytesToHex(new Uint8Array(aki.keyIdentifier.buffer))).toUpperCase()}\n`; + } + if (aki.authorityCertIssuer && aki.authorityCertIssuer.length > 0) { + for (const gn of aki.authorityCertIssuer) { + if (gn.directoryName) { + out += `\tDirName:${slashName(gn.directoryName)}\n`; + } else { + out += `\t${formatGeneralName(gn, "crl")}\n`; + } + } + } + if (aki.authorityCertSerialNumber) { + const serial = bytesToHex(new Uint8Array(aki.authorityCertSerialNumber)).toUpperCase(); + out += `\tserial:${colonHex(serial)}\n`; + } + return chop(out); } - let out = ``; - - extensions.sort((a, b) => { - if (!Object.hasOwn(a, "extname") || !Object.hasOwn(b, "extname")) { - return 0; + if (ext.type === ID_CE_CRL_DISTRIBUTION_POINTS) { + const dps = AsnParser.parse(value, CRLDistributionPoints); + let out = `X509v3 CRL Distribution Points:\n`; + for (const dp of dps) { + if (dp.distributionPoint && dp.distributionPoint.fullName) { + const fullName = `Full Name:\n${dp.distributionPoint.fullName.map(gn => ` ${formatGeneralName(gn, "crl")}`).join("\n")}`; + out += indentString(fullName, 4) + "\n"; + } } - if (a.extname < b.extname) { - return -1; - } else if (a.extname === b.extname) { - return 0; + return chop(out); + } + + if (ext.type === ID_CE_CRL_NUMBER) { + const num = AsnParser.parse(value, CRLNumber); + const hex = BigInt(num.value).toString(16).toUpperCase(); + return `X509v3 CRL Number:\n\t${hex}`; + } + + if (ext.type === ID_CE_ISSUER_ALT_NAME) { + const ian = AsnParser.parse(value, IssueAlternativeName); + const lines = ian.map(gn => ` ${formatGeneralName(gn, "crl")}`).join("\n"); + return `X509v3 Issuer Alternative Name:\n${lines}`; + } + + return `${ext.type}:\n\tUnsupported CRL extension. Try openssl CLI.`; +} + +/** + * Format an asn1-x509 Name as the OpenSSL slash representation + * `/C=…/ST=…/…`. + * + * @param {object} asnName + * @returns {string} + */ +function slashName(asnName) { + const OID_SHORT = { + "2.5.4.3": "CN", "2.5.4.4": "SN", "2.5.4.5": "serialNumber", + "2.5.4.6": "C", "2.5.4.7": "L", "2.5.4.8": "ST", + "2.5.4.9": "street", "2.5.4.10": "O", "2.5.4.11": "OU", + "2.5.4.12": "T", "2.5.4.42": "G", "2.5.4.43": "I", + "1.2.840.113549.1.9.1": "E", + }; + let out = ""; + for (const rdn of asnName) { + for (const atv of rdn) { + const key = OID_SHORT[atv.type] || atv.type; + out += `/${key}=${atv.value.toString()}`; + } + } + return out; +} + +/** + * Format an array of bytes as `AA:BB:CC:...`. + * + * @param {string} hex + * @returns {string} + */ +function colonHex(hex) { + if (hex.length % 2 !== 0) hex = "0" + hex; + return chop(hex.replace(/(..)/g, "$&:")); +} + +/** + * Format the revoked certificates list. + * + * @param {readonly object[]} entries - peculiar/x509 X509CrlEntry objects. + * @param {number} indentSpaces + * @returns {string} + */ +function formatRevokedCertificates(entries, indentSpaces) { + if (!entries || entries.length === 0) { + return indentString("No Revoked Certificates.", indentSpaces); + } + let out = ""; + for (const entry of entries) { + out += `Serial Number: ${entry.serialNumber.toUpperCase()} + Revocation Date: ${entry.revocationDate.toUTCString()}\n`; + if (entry.extensions && entry.extensions.length > 0) { + out += `\tCRL entry extensions:\n${indentString(formatCRLEntryExtensions(entry.extensions), 2 * indentSpaces)}\n`; + } + } + return indentString(chop(out), indentSpaces); +} + +/** + * Format the CRL entry extensions for a single revoked-certificate row. + * + * @param {object[]} extensions + * @returns {string} + */ +function formatCRLEntryExtensions(extensions) { + let out = ""; + for (const ext of extensions) { + const value = new Uint8Array(ext.value); + if (ext.type === ID_CE_CRL_REASONS) { + const reason = AsnParser.parse(value, CRLReason); + const code = reason.reason; + const name = Object.prototype.hasOwnProperty.call(CRL_REASON_TO_NAME, code) ? + CRL_REASON_TO_NAME[code] : + `invalid reason code: ${code}`; + out += `X509v3 CRL Reason Code:\n ${name}\n`; + } else if (ext.type === HOLD_INSTRUCTION_EXT_OID) { + // Hold Instruction; payload is an OID + const oid = decodeOidValue(value); + const name = HOLD_INSTRUCTION_OID_TO_NAME[oid] || `${oid}: unknown hold instruction OID`; + out += `Hold Instruction Code:\n\t${name}\n`; + } else if (ext.type === ID_CE_INVALIDITY_DATE) { + const inv = AsnParser.parse(value, InvalidityDate); + out += `Invalidity Date:\n\t${inv.value.toUTCString()}\n`; } else { - return 1; + out += `${ext.type}:\n\tUnsupported CRL entry extension. Try openssl CLI.\n`; } - }); - - 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. + * Decode a DER OBJECT IDENTIFIER from raw bytes (including the 0x06 tag). + * + * @param {Uint8Array} bytes + * @returns {string} */ -function formatCRLSignature(sigHex, indent) { - if (sigHex.length % 2 !== 0) { - sigHex = "0" + sigHex; +function decodeOidValue(bytes) { + if (bytes[0] !== 0x06) throw new OperationError("Expected OBJECT IDENTIFIER"); + // Length octet(s) follow tag; for any sensible OID this is one byte. + let i = 1; + if (bytes[i] >= 0x80) i += (bytes[i] & 0x7f); + i += 1; + const first = bytes[i++]; + const arcs = [Math.floor(first / 40).toString(), (first % 40).toString()]; + let acc = 0n; + for (; i < bytes.length; i++) { + acc = (acc << 7n) | BigInt(bytes[i] & 0x7f); + if ((bytes[i] & 0x80) === 0) { + arcs.push(acc.toString()); + acc = 0n; + } } - - return indentString(formatMultiLine(chop(sigHex.replace(/(..)/g, "$&:"))), indent); + return arcs.join("."); } /** - * Format string onto multiple lines. - * @param {string} longStr - * @returns String as a multi-line string. + * Format the CRL signature as colon-delimited byte pairs wrapped to 54 + * characters per line, indented. + * + * @param {string} sigHex + * @param {number} indentSpaces + * @returns {string} */ -function formatMultiLine(longStr) { +function formatCRLSignature(sigHex, indentSpaces) { + if (sigHex.length % 2 !== 0) sigHex = "0" + sigHex; + const colonHexStr = chop(sigHex.replace(/(..)/g, "$&:")); const lines = []; - - for (let remain = longStr ; remain !== "" ; remain = remain.substring(54)) { + for (let remain = colonHexStr; remain !== ""; remain = remain.substring(54)) { lines.push(remain.substring(0, 54)); } - - return lines.join("\n"); + return indentString(lines.join("\n"), indentSpaces); } /** * Indent a multi-line string by n spaces. - * @param {string} input String - * @param {number} spaces How many leading spaces - * @returns Indented string. + * + * @param {string} input + * @param {number} spaces + * @returns {string} */ function indentString(input, spaces) { - const indent = " ".repeat(spaces); - return input.replace(/^/gm, indent); + const pad = " ".repeat(spaces); + return input.replace(/^/gm, pad); } /** - * Remove last character from a string. - * @param {string} s String - * @returns Chopped string. + * Remove the last character from a string. + * + * @param {string} s + * @returns {string} */ function chop(s) { - if (s.length < 1) { - return s; - } - return s.substring(0, s.length - 1); + return s.length === 0 ? s : s.substring(0, s.length - 1); } export default ParseX509CRL; diff --git a/src/core/operations/ParseX509Certificate.mjs b/src/core/operations/ParseX509Certificate.mjs index cdd1e9c7..5222112a 100644 --- a/src/core/operations/ParseX509Certificate.mjs +++ b/src/core/operations/ParseX509Certificate.mjs @@ -4,12 +4,22 @@ * @license Apache-2.0 */ -import r from "jsrsasign"; -import { fromBase64 } from "../lib/Base64.mjs"; +import { X509Certificate, KeyUsagesExtension, BasicConstraintsExtension, ExtendedKeyUsageExtension, SubjectAlternativeNameExtension, SubjectKeyIdentifierExtension, AuthorityKeyIdentifierExtension, CRLDistributionPointsExtension } from "@peculiar/x509"; +import { AsnParser } from "@peculiar/asn1-schema"; +import { Certificate, SubjectAlternativeName, IssueAlternativeName } from "@peculiar/asn1-x509"; import { runHash } from "../lib/Hash.mjs"; -import { fromHex, toHex } from "../lib/Hex.mjs"; import { formatByteStr, formatDnObj } from "../lib/PublicKey.mjs"; +import { + bytesToHex, + decodeX509Input, + describeSpki, + formatGeneralName, + isDerEcdsaSignature, + parseDerEcdsaSignature, + sigAlgOidToName, +} from "../lib/X509.mjs"; import Operation from "../Operation.mjs"; +import OperationError from "../errors/OperationError.mjs"; import Utils from "../Utils.mjs"; /** @@ -51,162 +61,195 @@ class ParseX509Certificate extends Operation { * @returns {string} */ run(input, args) { - if (!input.length) { - return "No input"; - } + if (!input.length) return "No input"; - const cert = new r.X509(), - inputFormat = args[0]; - - let undefinedInputFormat = false; + const inputFormat = args[0]; + let derBytes; try { - switch (inputFormat) { - case "DER Hex": - input = input.replace(/\s/g, "").toLowerCase(); - cert.readCertHex(input); - break; - case "PEM": - cert.readCertPEM(input); - break; - case "Base64": - cert.readCertHex(toHex(fromBase64(input, null, "byteArray"), "")); - break; - case "Raw": - cert.readCertHex(toHex(Utils.strToArrayBuffer(input), "")); - break; - default: - undefinedInputFormat = true; - } + derBytes = decodeX509Input(input, inputFormat); } catch (e) { - throw "Certificate load error (non-certificate input?)"; + throw new OperationError(`Certificate load error (non-certificate input?): ${e.message}`); } - if (undefinedInputFormat) throw "Undefined input format"; - const hex = Utils.strToArrayBuffer(Utils.byteArrayToChars(fromHex(cert.hex))), - sn = cert.getSerialNumberHex(), - issuer = cert.getIssuer(), - subject = cert.getSubject(), - pk = cert.getPublicKey(), - pkFields = [], - sig = cert.getSignatureValueHex(); + let cert; + try { + cert = new X509Certificate(derBytes); + } catch (e) { + throw new OperationError(`Certificate load error (non-certificate input?): ${e.message}`); + } - let pkStr = "", - sigStr = "", - extensions = ""; + const fingerprintInput = Utils.strToArrayBuffer(Utils.byteArrayToChars(Array.from(new Uint8Array(cert.rawData)))); - // Public Key fields - pkFields.push({ - key: "Algorithm", - value: pk.type - }); + const asnCert = AsnParser.parse(new Uint8Array(cert.rawData), Certificate); + const versionInt = (asnCert.tbsCertificate.version || 0) + 1; + const serialHex = bytesToHex(new Uint8Array(asnCert.tbsCertificate.serialNumber)); + const serialDecimal = serialHex.length ? BigInt("0x" + serialHex).toString() : "0"; + const sigAlgOid = asnCert.signatureAlgorithm.algorithm; + const sigAlgName = sigAlgOidToName(sigAlgOid); - if (pk.type === "EC") { // ECDSA - pkFields.push({ - key: "Curve Name", - value: pk.curveName - }); - pkFields.push({ - key: "Length", - value: (((new r.BigInteger(pk.pubKeyHex, 16)).bitLength()-3) /2) + " bits" - }); - pkFields.push({ - key: "pub", - value: formatByteStr(pk.pubKeyHex, 16, 18) - }); - } else if (pk.type === "DSA") { // DSA - pkFields.push({ - key: "pub", - value: formatByteStr(pk.y.toString(16), 16, 18) - }); - pkFields.push({ - key: "P", - value: formatByteStr(pk.p.toString(16), 16, 18) - }); - pkFields.push({ - key: "Q", - value: formatByteStr(pk.q.toString(16), 16, 18) - }); - pkFields.push({ - key: "G", - value: formatByteStr(pk.g.toString(16), 16, 18) - }); - } else if (pk.e) { // RSA - pkFields.push({ - key: "Length", - value: pk.n.bitLength() + " bits" - }); - pkFields.push({ - key: "Modulus", - value: formatByteStr(pk.n.toString(16), 16, 18) - }); - pkFields.push({ - key: "Exponent", - value: pk.e + " (0x" + pk.e.toString(16) + ")" - }); + const spki = describeSpki(new Uint8Array(cert.publicKey.rawData)); + const pkFields = [{ key: "Algorithm", value: spkiAlgorithmLabel(spki) }]; + + switch (spki.type) { + case "EC": + pkFields.push({ key: "Curve Name", value: spki.asn1Curve }); + pkFields.push({ key: "Length", value: spki.bitLength + " bits" }); + pkFields.push({ key: "pub", value: formatByteStr(spki.pubKeyHex, 16, 18) }); + break; + case "DSA": + pkFields.push({ key: "pub", value: formatByteStr(spki.yHex, 16, 18) }); + pkFields.push({ key: "P", value: formatByteStr(spki.pHex, 16, 18) }); + pkFields.push({ key: "Q", value: formatByteStr(spki.qHex, 16, 18) }); + pkFields.push({ key: "G", value: formatByteStr(spki.gHex, 16, 18) }); + break; + case "RSA": + pkFields.push({ key: "Length", value: spki.bitLength + " bits" }); + pkFields.push({ key: "Modulus", value: formatByteStr(spki.nHex, 16, 18) }); + pkFields.push({ key: "Exponent", value: spki.eValue + " (0x" + spki.eValue.toString(16) + ")" }); + break; + case "EdDSA": + pkFields.push({ key: "Curve Name", value: spki.curveName }); + pkFields.push({ key: "pub", value: formatByteStr(spki.pubKeyHex, 16, 18) }); + break; + default: + pkFields.push({ key: "Error", value: "Unknown Public Key type" }); + } + + let pkStr = ""; + for (const field of pkFields) { + pkStr += ` ${field.key}:${(field.value + "\n").padStart( + 18 - (field.key.length + 3) + field.value.length + 1, " ")}`; + } + + const sigHex = bytesToHex(new Uint8Array(cert.signature)); + let sigStr; + if (isDerEcdsaSignature(sigHex)) { + const { r, s } = parseDerEcdsaSignature(sigHex); + sigStr = ` r: ${formatByteStr(r, 16, 18)}\n s: ${formatByteStr(s, 16, 18)}`; } else { - pkFields.push({ - key: "Error", - value: "Unknown Public Key type" - }); + sigStr = ` Signature: ${formatByteStr(sigHex, 16, 18)}`; } - // Format Public Key fields - for (let i = 0; i < pkFields.length; i++) { - pkStr += ` ${pkFields[i].key}:${(pkFields[i].value + "\n").padStart( - 18 - (pkFields[i].key.length + 3) + pkFields[i].value.length + 1, - " " - )}`; - } + const nbDate = formatDate(formatAsn1Date(cert.notBefore)); + const naDate = formatDate(formatAsn1Date(cert.notAfter)); + const issuerStr = formatDnObj(cert.issuerName.toJSON(), 2); + const subjectStr = formatDnObj(cert.subjectName.toJSON(), 2); - // Signature fields - let breakoutSig = false; - try { - breakoutSig = r.ASN1HEX.dump(sig).indexOf("SEQUENCE") === 0; - } catch (err) { - // Error processing signature, output without further breakout - } + const versionDisplay = `${versionInt} (0x${Utils.hex(versionInt - 1)})`; - if (breakoutSig) { // DSA or ECDSA - sigStr = ` r: ${formatByteStr(r.ASN1HEX.getV(sig, 4), 16, 18)} - s: ${formatByteStr(r.ASN1HEX.getV(sig, 48), 16, 18)}`; - } else { // RSA or unknown - sigStr = ` Signature: ${formatByteStr(sig, 16, 18)}`; - } + const extensionsText = formatExtensions(cert); - // Extensions - try { - extensions = cert.getInfo().split("X509v3 Extensions:\n")[1].split("signature")[0]; - } catch (err) {} - - const issuerStr = formatDnObj(issuer, 2), - nbDate = formatDate(cert.getNotBefore()), - naDate = formatDate(cert.getNotAfter()), - subjectStr = formatDnObj(subject, 2); - - return `Version: ${cert.version} (0x${Utils.hex(cert.version - 1)}) -Serial number: ${new r.BigInteger(sn, 16).toString()} (0x${sn}) -Algorithm ID: ${cert.getSignatureAlgorithmField()} + return `Version: ${versionDisplay} +Serial number: ${serialDecimal} (0x${serialHex}) +Algorithm ID: ${sigAlgName} Validity - Not Before: ${nbDate} (dd-mm-yyyy hh:mm:ss) (${cert.getNotBefore()}) - Not After: ${naDate} (dd-mm-yyyy hh:mm:ss) (${cert.getNotAfter()}) + Not Before: ${nbDate} (dd-mm-yyyy hh:mm:ss) (${formatAsn1Date(cert.notBefore)}) + Not After: ${naDate} (dd-mm-yyyy hh:mm:ss) (${formatAsn1Date(cert.notAfter)}) Issuer ${issuerStr} Subject ${subjectStr} Fingerprints - MD5: ${runHash("md5", hex)} - SHA1: ${runHash("sha1", hex)} - SHA256: ${runHash("sha256", hex)} + MD5: ${runHash("md5", fingerprintInput)} + SHA1: ${runHash("sha1", fingerprintInput)} + SHA256: ${runHash("sha256", fingerprintInput)} Public Key ${pkStr.slice(0, -1)} Certificate Signature - Algorithm: ${cert.getSignatureAlgorithmName()} + Algorithm: ${sigAlgName} ${sigStr} Extensions -${extensions}`; +${extensionsText}`; } +} +/** + * Format the algorithm label for the Public Key block. Mirrors the legacy + * jsrsasign labelling: "EC", "DSA", "RSA". + * + * @param {object} spki + * @returns {string} + */ +function spkiAlgorithmLabel(spki) { + if (spki.type === "EC") return "EC"; + if (spki.type === "DSA") return "DSA"; + if (spki.type === "RSA") return "RSA"; + if (spki.type === "EdDSA") return spki.curveName; + return "Unknown"; +} + +/** + * Format the certificate extensions block. Uses the rich peculiar/x509 + * extension types where available, falls back to the OID for unknown ones. + * + * @param {object} cert + * @returns {string} + */ +function formatExtensions(cert) { + const lines = []; + for (const ext of cert.extensions) { + if (ext instanceof SubjectKeyIdentifierExtension) { + lines.push(` subjectKeyIdentifier${ext.critical ? " CRITICAL" : ""} :`); + lines.push(` ${ext.keyId}`); + } else if (ext instanceof AuthorityKeyIdentifierExtension) { + lines.push(` authorityKeyIdentifier${ext.critical ? " CRITICAL" : ""} :`); + if (ext.keyId) lines.push(` kid=${ext.keyId}`); + if (ext.certId && ext.certId.serialNumber) { + lines.push(` serial=${ext.certId.serialNumber.toUpperCase()}`); + } + } else if (ext instanceof BasicConstraintsExtension) { + lines.push(` basicConstraints${ext.critical ? " CRITICAL" : ""}:`); + lines.push(` cA=${ext.ca}` + (ext.pathLength !== undefined ? `,pathLen=${ext.pathLength}` : "")); + } else if (ext instanceof KeyUsagesExtension) { + lines.push(` keyUsage${ext.critical ? " CRITICAL" : ""}:`); + lines.push(` ${ext.usages.toString()}`); + } else if (ext instanceof ExtendedKeyUsageExtension) { + lines.push(` extKeyUsage${ext.critical ? " CRITICAL" : ""}:`); + for (const usage of ext.usages) lines.push(` ${usage}`); + } else if (ext instanceof SubjectAlternativeNameExtension) { + lines.push(` subjectAltName${ext.critical ? " CRITICAL" : ""}:`); + const asn = AsnParser.parse(new Uint8Array(ext.value), SubjectAlternativeName); + for (const gn of asn) lines.push(` ${formatGeneralName(gn, "csr")}`); + } else if (ext instanceof CRLDistributionPointsExtension) { + lines.push(` cRLDistributionPoints${ext.critical ? " CRITICAL" : ""}:`); + for (const dp of ext.distributionPoints) { + if (dp.distributionPoint && dp.distributionPoint.fullName) { + for (const gn of dp.distributionPoint.fullName) { + lines.push(` ${formatGeneralName(gn, "csr")}`); + } + } + } + } else if (ext.type === "2.5.29.18") { // issuerAltName + lines.push(` issuerAltName${ext.critical ? " CRITICAL" : ""}:`); + const asn = AsnParser.parse(new Uint8Array(ext.value), IssueAlternativeName); + for (const gn of asn) lines.push(` ${formatGeneralName(gn, "csr")}`); + } else { + lines.push(` ${ext.type}${ext.critical ? " CRITICAL" : ""} :`); + lines.push(` ${bytesToHex(new Uint8Array(ext.value))}`); + } + } + return lines.join("\n"); +} + +/** + * Format a JS Date as the jsrsasign UTCTime/GeneralizedTime string + * `yymmddHHMMSSZ` (or `yyyymmddHHMMSSZ` for dates past 2049). + * + * @param {Date} date + * @returns {string} + */ +function formatAsn1Date(date) { + const year = date.getUTCFullYear(); + const month = String(date.getUTCMonth() + 1).padStart(2, "0"); + const day = String(date.getUTCDate()).padStart(2, "0"); + const hour = String(date.getUTCHours()).padStart(2, "0"); + const min = String(date.getUTCMinutes()).padStart(2, "0"); + const sec = String(date.getUTCSeconds()).padStart(2, "0"); + if (year < 1950 || year > 2049) { + return `${year}${month}${day}${hour}${min}${sec}Z`; + } + return `${String(year).slice(2)}${month}${day}${hour}${min}${sec}Z`; } /** @@ -215,8 +258,8 @@ ${extensions}`; * @param {string} dateStr * @returns {string} */ -function formatDate (dateStr) { - if (dateStr.length === 13) { // UTC Time +function formatDate(dateStr) { + if (dateStr.length === 13) { dateStr = (dateStr[0] < "5" ? "20" : "19") + dateStr; } return dateStr[6] + dateStr[7] + "/" + diff --git a/src/core/operations/PubKeyFromCert.mjs b/src/core/operations/PubKeyFromCert.mjs index 0233b04a..992d2c53 100644 --- a/src/core/operations/PubKeyFromCert.mjs +++ b/src/core/operations/PubKeyFromCert.mjs @@ -4,7 +4,7 @@ * @license Apache-2.0 */ -import r from "jsrsasign"; +import { X509Certificate } from "@peculiar/x509"; import Operation from "../Operation.mjs"; import OperationError from "../errors/OperationError.mjs"; @@ -39,27 +39,31 @@ class PubKeyFromCert extends Operation { let match; const regex = /-----BEGIN CERTIFICATE-----/g; while ((match = regex.exec(input)) !== null) { - // find corresponding end tag const indexBase64 = match.index + match[0].length; const footer = "-----END CERTIFICATE-----"; const indexFooter = input.indexOf(footer, indexBase64); if (indexFooter === -1) { throw new OperationError(`PEM footer '${footer}' not found`); } - const certPem = input.substring(match.index, indexFooter + footer.length); - const cert = new r.X509(); - cert.readCertPEM(certPem); - let pubKey; + + let cert; try { - pubKey = cert.getPublicKey(); + cert = new X509Certificate(certPem); } catch { throw new OperationError("Unsupported public key type"); } - const pubKeyPem = r.KEYUTIL.getPEM(pubKey); - // PEM ends with '\n', so a new key always starts on a new line - output += pubKeyPem; + let pubKeyPem; + try { + pubKeyPem = cert.publicKey.toString("pem"); + } catch { + throw new OperationError("Unsupported public key type"); + } + + // Normalise to LF endings + trailing newline so multi-cert input + // produces a clean separator between successive keys. + output += pubKeyPem.replace(/\r\n/g, "\n").replace(/\n?$/, "\n"); } return output; } diff --git a/tests/operations/index.mjs b/tests/operations/index.mjs index 2c5f591f..0490f907 100644 --- a/tests/operations/index.mjs +++ b/tests/operations/index.mjs @@ -142,6 +142,7 @@ import "./tests/ParityBit.mjs"; import "./tests/PHPSerialize.mjs"; import "./tests/PowerSet.mjs"; import "./tests/Protobuf.mjs"; +import "./tests/ParseX509Certificate.mjs"; import "./tests/PubKeyFromCert.mjs"; import "./tests/PubKeyFromPrivKey.mjs"; import "./tests/Rabbit.mjs"; diff --git a/tests/operations/tests/ParseX509Certificate.mjs b/tests/operations/tests/ParseX509Certificate.mjs new file mode 100644 index 00000000..0a584868 --- /dev/null +++ b/tests/operations/tests/ParseX509Certificate.mjs @@ -0,0 +1,138 @@ +/** + * Parse X.509 Certificate tests. + * + * Added as part of the jsrsasign → @peculiar/x509 migration (PR 5) to give + * the operation regression coverage it previously lacked. The certificate + * fixtures are reused from PubKeyFromCert.mjs. + * + * @author n1474335 [n1474335@gmail.com] + * @copyright Crown Copyright 2016 + * @license Apache-2.0 + */ +import TestRegister from "../../lib/TestRegister.mjs"; + +const RSA_CERT = `-----BEGIN CERTIFICATE----- +MIIBfTCCASegAwIBAgIUeisK5Nwss2DGg5PCs4uSxxXyyNkwDQYJKoZIhvcNAQEL +BQAwEzERMA8GA1UEAwwIUlNBIHRlc3QwHhcNMjExMTE5MTcyMDI2WhcNMzExMTE3 +MTcyMDI2WjATMREwDwYDVQQDDAhSU0EgdGVzdDBcMA0GCSqGSIb3DQEBAQUAA0sA +MEgCQQDyq9A6emHSLczn5Omu5muy+AReC53pTGCrW6Bi65OoobahT2RUSzXCYuvB +757fLLTKz+dLeo6sFkNhIzHZI+n7AgMBAAGjUzBRMB0GA1UdDgQWBBRO+jvkqq5p +pnQgwMMnRoun6e7eiTAfBgNVHSMEGDAWgBRO+jvkqq5ppnQgwMMnRoun6e7eiTAP +BgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA0EAR/5HAZM5qBhU/ezDUIFx +gmUGoFbIb5kJD41YCnaSdrgWglh4He4melSs42G/oxBBjuCJ0bUpqWnLl+lJkv1z +IA== +-----END CERTIFICATE-----`; + +const RSA_EXPECTED = `Version: 3 (0x02) +Serial number: 697456755083946472681082503344832984412880816345 (0x7a2b0ae4dc2cb360c68393c2b38b92c715f2c8d9) +Algorithm ID: SHA256withRSA +Validity + Not Before: 19/11/2021 17:20:26 (dd-mm-yyyy hh:mm:ss) (211119172026Z) + Not After: 17/11/2031 17:20:26 (dd-mm-yyyy hh:mm:ss) (311117172026Z) +Issuer + CN = RSA test +Subject + CN = RSA test +Fingerprints + MD5: 0807638eee1403fbcacc1b6d25e00d95 + SHA1: cae48f6fac74e143e10e0d8db1597385d62f24c4 + SHA256: cd9125e6e7caa729c766e4d9ed84ef44e6bc57d00614c5f051af661e9ce3e436 +Public Key + Algorithm: RSA + Length: 512 bits + Modulus: f2:ab:d0:3a:7a:61:d2:2d:cc:e7:e4:e9:ae:e6:6b:b2: + f8:04:5e:0b:9d:e9:4c:60:ab:5b:a0:62:eb:93:a8:a1: + b6:a1:4f:64:54:4b:35:c2:62:eb:c1:ef:9e:df:2c:b4: + ca:cf:e7:4b:7a:8e:ac:16:43:61:23:31:d9:23:e9:fb + Exponent: 65537 (0x10001) +Certificate Signature + Algorithm: SHA256withRSA + Signature: 47:fe:47:01:93:39:a8:18:54:fd:ec:c3:50:81:71:82: + 65:06:a0:56:c8:6f:99:09:0f:8d:58:0a:76:92:76:b8: + 16:82:58:78:1d:ee:26:7a:54:ac:e3:61:bf:a3:10:41: + 8e:e0:89:d1:b5:29:a9:69:cb:97:e9:49:92:fd:73:20 + +Extensions + subjectKeyIdentifier : + 4efa3be4aaae69a67420c0c327468ba7e9eede89 + authorityKeyIdentifier : + kid=4efa3be4aaae69a67420c0c327468ba7e9eede89 + basicConstraints CRITICAL: + cA=true`; + +const EC_P256_CERT = `-----BEGIN CERTIFICATE----- +MIIBfzCCASWgAwIBAgIUK4H8J3Hr7NpRLPrACj8Pje4JJJ0wCgYIKoZIzj0EAwIw +FTETMBEGA1UEAwwKUC0yNTYgdGVzdDAeFw0yMTExMTkxNzE5NDVaFw0zMTExMTcx +NzE5NDVaMBUxEzARBgNVBAMMClAtMjU2IHRlc3QwWTATBgcqhkjOPQIBBggqhkjO +PQMBBwNCAAQNRzwDQQM0qgJgg9YwfPXJTOoTmYmC6yBwATwfrzXR+QnxmZM2IIJr +qwuBHa8PVU2HZ2KKtaAo8fg9Uwpq/l7po1MwUTAdBgNVHQ4EFgQU/SxodXrpkybM +gcIgkxnRKd7HMzowHwYDVR0jBBgwFoAU/SxodXrpkybMgcIgkxnRKd7HMzowDwYD +VR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAgNIADBFAiBU9PrOa/kXCpTTBInRf/sN +ac2iDHmbdpWzcXI+xLKNYAIhAIRR1LRSHVwOTLQ/iBXd+8LCkm5aTB27RW46LN80 +ylxt +-----END CERTIFICATE-----`; + +const EC_P256_EXPECTED = `Version: 3 (0x02) +Serial number: 248385364994530420657018049397175541334954026141 (0x2b81fc2771ebecda512cfac00a3f0f8dee09249d) +Algorithm ID: SHA256withECDSA +Validity + Not Before: 19/11/2021 17:19:45 (dd-mm-yyyy hh:mm:ss) (211119171945Z) + Not After: 17/11/2031 17:19:45 (dd-mm-yyyy hh:mm:ss) (311117171945Z) +Issuer + CN = P-256 test +Subject + CN = P-256 test +Fingerprints + MD5: d58a07c73ac5353acd1799174472478f + SHA1: 562feb8b1a2c9808a98b350557ab80eab619ed48 + SHA256: 584662f4632e221a1d58f91f772fb6f617af7aa3d8542281af2efcc93c1b79eb +Public Key + Algorithm: EC + Curve Name: secp256r1 + Length: 256 bits + pub: 04:0d:47:3c:03:41:03:34:aa:02:60:83:d6:30:7c:f5: + c9:4c:ea:13:99:89:82:eb:20:70:01:3c:1f:af:35:d1: + f9:09:f1:99:93:36:20:82:6b:ab:0b:81:1d:af:0f:55: + 4d:87:67:62:8a:b5:a0:28:f1:f8:3d:53:0a:6a:fe:5e: + e9 +Certificate Signature + Algorithm: SHA256withECDSA + r: 54:f4:fa:ce:6b:f9:17:0a:94:d3:04:89:d1:7f:fb:0d: + 69:cd:a2:0c:79:9b:76:95:b3:71:72:3e:c4:b2:8d:60 + s: 84:51:d4:b4:52:1d:5c:0e:4c:b4:3f:88:15:dd:fb:c2: + c2:92:6e:5a:4c:1d:bb:45:6e:3a:2c:df:34:ca:5c:6d + +Extensions + subjectKeyIdentifier : + fd2c68757ae99326cc81c2209319d129dec7333a + authorityKeyIdentifier : + kid=fd2c68757ae99326cc81c2209319d129dec7333a + basicConstraints CRITICAL: + cA=true`; + +TestRegister.addTests([ + { + name: "Parse X.509 certificate: No input", + input: "", + expectedOutput: "No input", + recipeConfig: [ + { op: "Parse X.509 certificate", args: ["PEM"] } + ], + }, + { + name: "Parse X.509 certificate: RSA / PEM", + input: RSA_CERT, + expectedOutput: RSA_EXPECTED, + recipeConfig: [ + { op: "Parse X.509 certificate", args: ["PEM"] } + ], + }, + { + name: "Parse X.509 certificate: EC P-256 / PEM", + input: EC_P256_CERT, + expectedOutput: EC_P256_EXPECTED, + recipeConfig: [ + { op: "Parse X.509 certificate", args: ["PEM"] } + ], + }, +]); diff --git a/tests/operations/tests/PubKeyFromCert.mjs b/tests/operations/tests/PubKeyFromCert.mjs index ae5609aa..0fe5a5cd 100644 --- a/tests/operations/tests/PubKeyFromCert.mjs +++ b/tests/operations/tests/PubKeyFromCert.mjs @@ -99,11 +99,9 @@ HRMBAf8EBTADAQH/MAUGAytlcANBAI/+03iVq4yJ+DaLVs61w41cVX2UxKvquSzv lllkpkclM9LH5dLrw4ArdTjS9zAjzY/02WkphHhICHXt3KqZTwI= -----END CERTIFICATE-----`; -/* const ED25519_PUBKEY = `-----BEGIN PUBLIC KEY----- MCowBQYDK2VwAyEAELP6AflXwsuZ5q4NDIO0LP2iCdKRvds4nwsUmRhOw3g= -----END PUBLIC KEY-----`; -*/ const ED448_CERT = `-----BEGIN CERTIFICATE----- MIIBijCCAQqgAwIBAgIUZaCS7zEjOnQ7O4KUFym6fJF5vl8wBQYDK2VxMBUxEzAR @@ -117,12 +115,10 @@ VkLqpoDNMRcM3Eb6h3AJpQM0oxGj8q9arjDXqJkXgaO2e0tVn8KKVfy7S8qO72Kd rWzZowcOjnWKhXm7JgA= -----END CERTIFICATE-----`; -/* const ED448_PUBKEY = `-----BEGIN PUBLIC KEY----- MEMwBQYDK2VxAzoAVN8kG0TMVyGOu/OvBTe8H0Wi4HJrQAlSv4XLwJbkuoi4EeRl EHQwXsNYLZTtY2Jra6AWhbVYYaEA ------END PUBLIC KEY-----` -*/ +-----END PUBLIC KEY-----`; TestRegister.addTests([ { @@ -141,7 +137,7 @@ TestRegister.addTests([ { name: "Public Key from Certificate: RSA", input: RSA_CERT, - expectedOutput: (RSA_PUBKEY + "\n").replace(/\r/g, "").replace(/\n/g, "\r\n"), + expectedOutput: (RSA_PUBKEY + "\n").replace(/\r/g, ""), recipeConfig: [ { op: "Public Key from Certificate", @@ -154,7 +150,7 @@ TestRegister.addTests([ { name: "Public Key from Certificate: EC", input: EC_P256_CERT, - expectedOutput: (EC_P256_PUBKEY + "\n").replace(/\r/g, "").replace(/\n/g, "\r\n"), + expectedOutput: (EC_P256_PUBKEY + "\n").replace(/\r/g, ""), recipeConfig: [ { op: "Public Key from Certificate", @@ -167,7 +163,7 @@ TestRegister.addTests([ { name: "Public Key from Certificate: DSA", input: DSA_CERT, - expectedOutput: (DSA_PUBKEY + "\n").replace(/\r/g, "").replace(/\n/g, "\r\n"), + expectedOutput: (DSA_PUBKEY + "\n").replace(/\r/g, ""), recipeConfig: [ { op: "Public Key from Certificate", @@ -180,7 +176,7 @@ TestRegister.addTests([ { name: "Public Key from Certificate: Ed25519", input: ED25519_CERT, - expectedOutput: "Unsupported public key type", + expectedOutput: ED25519_PUBKEY + "\n", recipeConfig: [ { op: "Public Key from Certificate", @@ -191,7 +187,7 @@ TestRegister.addTests([ { name: "Public Key from Certificate: Ed448", input: ED448_CERT, - expectedOutput: "Unsupported public key type", + expectedOutput: ED448_PUBKEY + "\n", recipeConfig: [ { op: "Public Key from Certificate", @@ -204,7 +200,7 @@ TestRegister.addTests([ { name: "Public Key from Certificate: Multiple certificates", input: RSA_CERT + "\n" + EC_P256_CERT, - expectedOutput: (RSA_PUBKEY + "\n" + EC_P256_PUBKEY + "\n").replace(/\r/g, "").replace(/\n/g, "\r\n"), + expectedOutput: (RSA_PUBKEY + "\n" + EC_P256_PUBKEY + "\n").replace(/\r/g, ""), recipeConfig: [ { op: "Public Key from Certificate",