From 5f4b3ee769ca2e9becef8147494995755c6aae98 Mon Sep 17 00:00:00 2001 From: Andrei Haiducu Date: Tue, 12 May 2026 17:07:31 +0300 Subject: [PATCH] Issue 21 fix --- web-ui/package-lock.json | 79 ++- web-ui/package.json | 4 +- web-ui/specs.md | 33 + .../components/CertificateDetailsDialog.jsx | 2 +- .../ImportCertificateChainDialogContent.jsx | 41 +- web-ui/src/hooks/useCertificateImport.js | 2 +- web-ui/src/utils/certificateUtils.js | 643 ++++++------------ web-ui/src/utils/pkiHelpers.js | 495 ++++++++++++++ web-ui/src/utils/verificationUtils.js | 579 ++++++---------- 9 files changed, 1047 insertions(+), 831 deletions(-) create mode 100644 web-ui/src/utils/pkiHelpers.js diff --git a/web-ui/package-lock.json b/web-ui/package-lock.json index 03d7a94..215edc3 100644 --- a/web-ui/package-lock.json +++ b/web-ui/package-lock.json @@ -12,11 +12,13 @@ "@emotion/styled": "^11.14.1", "@mui/icons-material": "^7.3.2", "@mui/material": "^7.3.2", + "asn1js": "^3.0.10", "axios": "^1.15.0", "dayjs": "^1.11.18", "express": "^5.1.0", "http-proxy-middleware": "^3.0.5", - "jsrsasign": "^11.1.1", + "pkijs": "^3.4.0", + "pvutils": "^1.1.5", "react": "^19.1.1", "react-dom": "^19.1.1", "react-router-dom": "^7.12.0" @@ -1345,6 +1347,17 @@ } } }, + "node_modules/@noble/hashes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", + "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -2108,6 +2121,19 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true }, + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -2236,6 +2262,14 @@ "node": ">= 0.8" } }, + "node_modules/bytestreamjs": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/bytestreamjs/-/bytestreamjs-2.0.1.tgz", + "integrity": "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -3499,12 +3533,6 @@ "node": ">=6" } }, - "node_modules/jsrsasign": { - "version": "11.1.2", - "resolved": "https://registry.npmjs.org/jsrsasign/-/jsrsasign-11.1.2.tgz", - "integrity": "sha512-GJuqiU/Grs6BaBBXMAZM9kxhsBrksZE0pF3qIfpkopMd7OMJ9zZmE/+CpV//97srfEyyyq1Ec0ELQtSlW/gPTA==", - "license": "MIT" - }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -4152,6 +4180,22 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pkijs": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.0.tgz", + "integrity": "sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==", + "dependencies": { + "@noble/hashes": "1.4.0", + "asn1js": "^3.0.6", + "bytestreamjs": "^2.0.1", + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -4234,6 +4278,22 @@ "node": ">=6" } }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", + "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/qs": { "version": "6.15.1", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", @@ -4796,6 +4856,11 @@ "node": ">=0.6" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/web-ui/package.json b/web-ui/package.json index eb92f33..b61fb60 100644 --- a/web-ui/package.json +++ b/web-ui/package.json @@ -16,11 +16,13 @@ "@emotion/styled": "^11.14.1", "@mui/icons-material": "^7.3.2", "@mui/material": "^7.3.2", + "asn1js": "^3.0.10", "axios": "^1.15.0", "dayjs": "^1.11.18", "express": "^5.1.0", "http-proxy-middleware": "^3.0.5", - "jsrsasign": "^11.1.1", + "pkijs": "^3.4.0", + "pvutils": "^1.1.5", "react": "^19.1.1", "react-dom": "^19.1.1", "react-router-dom": "^7.12.0" diff --git a/web-ui/specs.md b/web-ui/specs.md index a48c69b..a0eb81b 100644 --- a/web-ui/specs.md +++ b/web-ui/specs.md @@ -214,3 +214,36 @@ A React-based certificate management dashboard built with Vite, Material-UI, and --- *This specification document provides a comprehensive overview of the Settings Dashboard project, covering architecture, features, implementation details, and development guidelines.* + +--- + +## 2026-05-12 - Replace jsrsasign with pkijs + +**Decision:** Replaced the now-EOL `jsrsasign` library with `pkijs` + `asn1js` + the browser's Web Crypto API (`crypto.subtle`). + +**Why:** +- `jsrsasign` reached end-of-support on 2026-Apr-10 with unfixed open vulnerabilities; staying on it is no longer viable. +- `pkijs` (by PeculiarVentures) covers everything we use today (X.509 parsing, chain verification, private-key matching) AND the upcoming **OCSP / CRL** feature work, so we avoid bringing in a second PKI dependency later. +- Web Crypto handles all hashing and signing/verification natively, with no JS-implemented crypto primitives. + +**Affected files:** +- New: `web-ui/src/utils/pkiHelpers.js` - PEM<->DER, OID-to-name maps, KeyUsage bit decoding, SAN flattening, RSA modulus bit-length, EC curve detection, private key import for PKCS#8 / PKCS#1 / SEC1. +- Rewritten: `web-ui/src/utils/certificateUtils.js`, `web-ui/src/utils/verificationUtils.js`. +- Patched: `web-ui/src/components/CertificateDetailsDialog.jsx`, `web-ui/src/components/ImportCertificateChainDialogContent.jsx`, `web-ui/src/hooks/useCertificateImport.js` - now `await` the previously-sync `verifyCertificate`, `parseCertificate`, `parseCertificateChainFromPem`. +- `web-ui/package.json` - removed `jsrsasign`, added `pkijs`, `asn1js`, `pvutils`. + +**Behavioral changes:** +- `parseCertificate`, `parseCertificateChainFromPem`, `verifyCertificate`, `validateCertificateChain`, `validatePrivateKey`, `getFingerprint` are now `async`. `isValidPemCertificate` and `isValidPemPrivateKey` remain sync (structural validation only). +- **DSA private keys are no longer supported.** Web Crypto cannot import or sign with DSA. The PEM header is rejected by `isValidPemPrivateKey`, and any path that reaches `importPrivateKeyFromPem` throws a clear `"DSA private keys are not supported in this environment"` error. DSA was never a product requirement (no fixtures, tests, or product flows used it), only a side-effect of `jsrsasign`'s capabilities. +- Supported private key formats: PKCS#8 (`-----BEGIN PRIVATE KEY-----`), PKCS#1 RSA (`-----BEGIN RSA PRIVATE KEY-----`), SEC1 EC (`-----BEGIN EC PRIVATE KEY-----`, with or without a preceding `EC PARAMETERS` block as emitted by OpenSSL). +- Supported public key algorithms in certificates: RSA (`RSASSA-PKCS1-v1_5` with SHA-256) and ECDSA on P-256 / P-384 / P-521. + +**Future work covered by the same dependency:** +- OCSP responder check via `pkijs.OCSPRequest` / `OCSPResponse`, AIA URL discovery from extension OID `1.3.6.1.5.5.7.1.1`. +- CRL distribution-point check via `pkijs.CertificateRevocationList`, URLs from extension OID `2.5.29.31`. +- Both will live in a new `web-ui/src/utils/revocationUtils.js` and reuse the `pkijs.Certificate` instances already produced by `parseCertificateChain`. + +**Cryptography (replaces the original entry on line 15):** +- `pkijs` + `asn1js` for X.509 / PKI structure handling +- `crypto.subtle` (Web Crypto) for all hashing, signing, verification, and key import +- Supports RSA and ECDSA. DSA and Ed25519 are not supported in this environment. diff --git a/web-ui/src/components/CertificateDetailsDialog.jsx b/web-ui/src/components/CertificateDetailsDialog.jsx index 5300239..d2d062c 100644 --- a/web-ui/src/components/CertificateDetailsDialog.jsx +++ b/web-ui/src/components/CertificateDetailsDialog.jsx @@ -106,7 +106,7 @@ export default function CertificateDetailsDialog({ open, onClose, certificate }) ? base64ToPrivateKeyPem(certificate.rawPrivateKey) : null - const result = verifyCertificate(pemCertificate, privateKeyPem) + const result = await verifyCertificate(pemCertificate, privateKeyPem) setVerificationResult(result) } catch (error) { setVerificationResult({ diff --git a/web-ui/src/components/ImportCertificateChainDialogContent.jsx b/web-ui/src/components/ImportCertificateChainDialogContent.jsx index d649f58..8020066 100644 --- a/web-ui/src/components/ImportCertificateChainDialogContent.jsx +++ b/web-ui/src/components/ImportCertificateChainDialogContent.jsx @@ -42,22 +42,33 @@ export default function ImportCertificateChainDialogContent({ // Parse certificate chain when PEM text changes useEffect(() => { + let cancelled = false if (pemText.trim()) { - const parsed = parseCertificateChainFromPem(pemText) - if (parsed.length === 0) { - setParseError('No valid certificates found in the provided text') - setCertificates([]) - setSelectedCertificateIndex(null) - setSelectedCertificatePem(null) - } else { - setParseError(null) - setCertificates(parsed) - // Auto-select first certificate if available - if (parsed.length > 0) { - setSelectedCertificateIndex(0) - setSelectedCertificatePem(parsed[0].certificate) - } - } + parseCertificateChainFromPem(pemText) + .then(parsed => { + if (cancelled) return + if (parsed.length === 0) { + setParseError('No valid certificates found in the provided text') + setCertificates([]) + setSelectedCertificateIndex(null) + setSelectedCertificatePem(null) + } else { + setParseError(null) + setCertificates(parsed) + if (parsed.length > 0) { + setSelectedCertificateIndex(0) + setSelectedCertificatePem(parsed[0].certificate) + } + } + }) + .catch(err => { + if (cancelled) return + setParseError(`Failed to parse certificates: ${err.message}`) + setCertificates([]) + setSelectedCertificateIndex(null) + setSelectedCertificatePem(null) + }) + return () => { cancelled = true } } else { setParseError(null) setCertificates([]) diff --git a/web-ui/src/hooks/useCertificateImport.js b/web-ui/src/hooks/useCertificateImport.js index 4c31f34..40a4b9e 100644 --- a/web-ui/src/hooks/useCertificateImport.js +++ b/web-ui/src/hooks/useCertificateImport.js @@ -97,7 +97,7 @@ export const useCertificateImport = (targetStore, currentCertificates = null) => } try { - const details = parseCertificate(pemText) + const details = await parseCertificate(pemText) setCertificateDetails(details) // Clear any previous errors diff --git a/web-ui/src/utils/certificateUtils.js b/web-ui/src/utils/certificateUtils.js index 96f0c6e..05082d9 100644 --- a/web-ui/src/utils/certificateUtils.js +++ b/web-ui/src/utils/certificateUtils.js @@ -1,42 +1,60 @@ -import { X509, KEYUTIL, KJUR, zulutodate } from 'jsrsasign' +import { + bytesToHex, + decodeAltNames, + decodeKeyUsageBits, + digestHex, + findExtensionByOid, + OID, + parsePemCertificate, + pemToDer, + rdnToString, +} from './pkiHelpers.js' import { parseCertificateChain } from './verificationUtils.js' import { notificationService } from '../services/notificationService.js' /** - * Convert X509 time string to Date object using jsrsasign utility - * @param {string} timeStr - X509 time format string (YYYYMMDDHHmmssZ or YYMMDDHHmmssZ) - * @returns {Date} Date object + * Convert an X.509 time string (YYMMDDhhmmssZ / YYYYMMDDhhmmssZ) to a Date. + * Kept for backward compatibility with any external callers - internal code + * paths now read Date objects directly off pkijs.Certificate.notBefore/notAfter. + * + * @param {string} timeStr + * @returns {Date|null} */ export function convertX509TimeToDate(timeStr) { if (!timeStr) return null - try { - // Convert the YYMMDDhhmmssZ string to an ISO-like format - const isoString = zulutodate(timeStr) - - // The t2d output is in 'YYYY/MM/DD hh:mm:ss GMT' format, which Date() can parse - return new Date(isoString) - } catch (error) { + const m = /^(\d{2}|\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})Z$/.exec(timeStr) + if (!m) return null + let year = parseInt(m[1], 10) + if (m[1].length === 2) { + year += year >= 50 ? 1900 : 2000 + } + const month = parseInt(m[2], 10) - 1 + const day = parseInt(m[3], 10) + const hour = parseInt(m[4], 10) + const minute = parseInt(m[5], 10) + const second = parseInt(m[6], 10) + return new Date(Date.UTC(year, month, day, hour, minute, second)) + } catch { return null } } /** - * Parse Distinguished Name string to object + * Parse a Distinguished Name string into an attribute object. * Input: "CN=example.com, O=Org, C=US" * Output: { CN: "example.com", O: "Org", C: "US" } - * @param {string} dnString - DN string - * @returns {Object} Parsed DN object + * @param {string} dnString + * @returns {Object} */ export function parseDNString(dnString) { const attrs = {} if (!dnString) return attrs - - // Split by comma, but handle quoted values + const parts = [] let current = '' let inQuotes = false - + for (let i = 0; i < dnString.length; i++) { const char = dnString[i] if (char === '"') { @@ -49,204 +67,165 @@ export function parseDNString(dnString) { current += char } } - if (current.trim()) { - parts.push(current.trim()) - } - + if (current.trim()) parts.push(current.trim()) + parts.forEach(part => { const equalIndex = part.indexOf('=') if (equalIndex > 0) { const key = part.substring(0, equalIndex).trim() let value = part.substring(equalIndex + 1).trim() - // Remove quotes if present if (value.startsWith('"') && value.endsWith('"')) { value = value.slice(1, -1) } attrs[key] = value } }) - + return attrs } /** - * Format DN object to string (for compatibility) - * @param {Object} dnObj - DN object with attributes - * @returns {string} Formatted DN string + * Format a parsed DN object back into the canonical string used by the UI. + * @param {Object} dnObj + * @returns {string} */ export function formatDNFromObject(dnObj) { if (!dnObj || typeof dnObj !== 'object') return 'Unknown' - + const parts = [] - const attributes = ['CN', 'OU', 'O', 'L', 'ST', 'C', 'emailAddress'] - - // Add preferred attributes in order - for (const attr of attributes) { - if (dnObj[attr]) { - parts.push(`${attr}=${dnObj[attr]}`) - } + const ordered = ['CN', 'OU', 'O', 'L', 'ST', 'C', 'emailAddress'] + + for (const attr of ordered) { + if (dnObj[attr]) parts.push(`${attr}=${dnObj[attr]}`) } - - // Add any remaining attributes + Object.keys(dnObj).forEach(key => { - if (!attributes.includes(key) && dnObj[key]) { + if (!ordered.includes(key) && dnObj[key]) { parts.push(`${key}=${dnObj[key]}`) } }) - + return parts.length > 0 ? parts.join(', ') : 'Unknown' } /** - * Parse a Base64-encoded PEM certificate and extract relevant information - * @param {string} base64Pem - Base64-encoded PEM certificate - * @returns {Object} Parsed certificate information + * Determine certificate type based on extensions and self-signed-ness. + * Returns one of: 'Root CA', 'Intermediate', 'Server Certificate', + * 'Client Certificate', 'End-entity'. + * + * @param {pkijs.Certificate} cert */ -export function parseCertificate(base64Pem) { +function determineCertificateType(cert) { try { - const pemString = base64Pem - - // Parse the PEM certificate - const cert = new X509() - cert.readCertPEM(pemString) - - // Extract subject information - const subjectStr = cert.getSubjectString() - const subject = parseDNString(subjectStr) - const subjectFormatted = formatDNFromObject(subject) - - // Extract issuer information - const issuerStr = cert.getIssuerString() - const issuer = parseDNString(issuerStr) - const issuerFormatted = formatDNFromObject(issuer) - - // Determine certificate type + const bcExt = findExtensionByOid(cert, OID.EXT_BASIC_CONSTRAINTS) + const cA = !!(bcExt && bcExt.parsedValue && bcExt.parsedValue.cA) + + const kuExt = findExtensionByOid(cert, OID.EXT_KEY_USAGE) + const keyUsageNames = kuExt ? decodeKeyUsageBits(kuExt.parsedValue) : [] + const isCA = cA || keyUsageNames.includes('keyCertSign') + + if (isCA) { + const subject = rdnToString(cert.subject) + const issuer = rdnToString(cert.issuer) + return subject === issuer ? 'Root CA' : 'Intermediate' + } + + const ekuExt = findExtensionByOid(cert, OID.EXT_EXTENDED_KEY_USAGE) + const keyPurposes = ekuExt && ekuExt.parsedValue && Array.isArray(ekuExt.parsedValue.keyPurposes) + ? ekuExt.parsedValue.keyPurposes + : [] + + if (keyPurposes.includes(OID.EKU_SERVER_AUTH)) return 'Server Certificate' + if (keyPurposes.includes(OID.EKU_CLIENT_AUTH)) return 'Client Certificate' + } catch (error) { + console.warn('Error determining certificate type:', error) + } + return 'End-entity' +} + +function formatDate(date) { + if (!date) return 'Unknown' + const dateObj = date instanceof Date ? date : new Date(date) + if (Number.isNaN(dateObj.getTime())) return 'Unknown' + const year = dateObj.getFullYear() + const month = String(dateObj.getMonth() + 1).padStart(2, '0') + const day = String(dateObj.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +/** + * Parse a PEM (or bare-base64) certificate and extract the fields the UI relies on. + * + * @param {string} base64Pem - PEM string or bare base64 of a single certificate + * @returns {Promise} parsed certificate description + */ +export async function parseCertificate(base64Pem) { + try { + const der = pemToDer(base64Pem) + const cert = parsePemCertificate(base64Pem) + + const subjectFormatted = rdnToString(cert.subject) + const subject = parseDNString(subjectFormatted) + + const issuerFormatted = rdnToString(cert.issuer) + const issuer = parseDNString(issuerFormatted) + const type = determineCertificateType(cert) - - // Format validity dates - const notBefore = convertX509TimeToDate(cert.getNotBefore()) - const notAfter = convertX509TimeToDate(cert.getNotAfter()) + + const notBefore = cert.notBefore?.value || null + const notAfter = cert.notAfter?.value || null const validFrom = formatDate(notBefore) const validTo = formatDate(notAfter) - - // Calculate SHA-1 fingerprint - const derHex = cert.hex // DER-encoded certificate as hex string - const fingerprintSha1 = KJUR.crypto.Util.hashHex(derHex, 'sha1').toUpperCase() - - // Get serial number - const serialNumber = cert.getSerialNumberHex() || cert.getSerialNumber() - - // Get version - const version = cert.getVersion() - - // Get extensions (for compatibility, create a simplified structure) - // Wrap extension access in try-catch since some certificates may not have all extensions + + const fingerprintSha1 = await digestHex(der, 'SHA-1') + + const serialNumberHex = bytesToHex(cert.serialNumber.valueBlock.valueHexView) + const serialNumber = serialNumberHex || cert.serialNumber.valueBlock.valueDec + + const version = cert.version + const extensions = [] - - let basicConstraints = null - try { - basicConstraints = cert.getExtBasicConstraints() - if (basicConstraints) { - extensions.push({ name: basicConstraints.extname, cA: basicConstraints.cA, critical: basicConstraints.critical }) - } - } catch (e) { - // Extension doesn't exist or can't be read - skip - } - - let keyUsage = null - try { - keyUsage = cert.getExtKeyUsage() - - if (keyUsage && keyUsage.names && Array.isArray(keyUsage.names)) { - extensions.push({ name: keyUsage.extname, names: keyUsage.names, critical: keyUsage.critical }) - } - } catch (e) { - // Extension doesn't exist or can't be read - skip + + const bcExt = findExtensionByOid(cert, OID.EXT_BASIC_CONSTRAINTS) + if (bcExt && bcExt.parsedValue) { + extensions.push({ + name: 'basicConstraints', + cA: !!bcExt.parsedValue.cA, + critical: !!bcExt.critical, + }) } - - let extKeyUsage = null - try { - extKeyUsage = cert.getExtExtKeyUsage() - // console.log(extKeyUsage, fingerprintSha1) - if (extKeyUsage) { - extensions.push({ - name: extKeyUsage.extname, - names: extKeyUsage.array, - critical: extKeyUsage.critical + + const kuExt = findExtensionByOid(cert, OID.EXT_KEY_USAGE) + if (kuExt && kuExt.parsedValue) { + const names = decodeKeyUsageBits(kuExt.parsedValue) + if (names.length > 0) { + extensions.push({ + name: 'keyUsage', + names, + critical: !!kuExt.critical, }) } - } catch (e) { - // Extension doesn't exist or can't be read - skip - } - - // Extract Subject Alternative Names (SAN) - let subjectAltName = null - let subjectAltNames = { - dns: [], - ip: [], - uri: [], - email: [], - dn: [] } - try { - subjectAltName = cert.getExtSubjectAltName() - if (subjectAltName && subjectAltName.array && Array.isArray(subjectAltName.array)) { - // Process SAN array and group by type - // GeneralName is a union type: { dns: string } | { ip: string } | { uri: string } | { rfc822: string } | { dn: X500Name } | { other: ... } | undefined - subjectAltName.array.forEach(item => { - // Each item is a GeneralName union type - check each possible property - if (item && typeof item === 'object') { - if (item.dns) { - // DNS name - subjectAltNames.dns.push(item.dns) - } else if (item.ip) { - // IP address - subjectAltNames.ip.push(item.ip) - } else if (item.uri) { - // URI - subjectAltNames.uri.push(item.uri) - } else if (item.rfc822) { - // RFC 822 email address - subjectAltNames.email.push(item.rfc822) - } else if (item.dn) { - // Distinguished Name (X500Name object) - // X500Name has a str property for string representation - if (item.dn.str) { - subjectAltNames.dn.push(item.dn.str) - } else if (typeof item.dn === 'string') { - // Fallback if it's already a string - subjectAltNames.dn.push(item.dn) - } else if (item.dn.array && Array.isArray(item.dn.array)) { - // Format DN from array structure if str is not available - const dnParts = [] - item.dn.array.forEach(dnPart => { - if (Array.isArray(dnPart) && dnPart.length > 0) { - const dnObj = dnPart[0] - if (dnObj && dnObj.value) { - const attrName = dnObj.type || 'UNKNOWN' - dnParts.push(`${attrName}=${dnObj.value}`) - } - } - }) - if (dnParts.length > 0) { - subjectAltNames.dn.push(dnParts.join(', ')) - } - } - } - // Note: item.other is not currently handled, but could be added if needed - } - }) - - } - } catch (e) { - // Extension doesn't exist or can't be read - skip + + const ekuExt = findExtensionByOid(cert, OID.EXT_EXTENDED_KEY_USAGE) + if (ekuExt && ekuExt.parsedValue && Array.isArray(ekuExt.parsedValue.keyPurposes)) { + extensions.push({ + name: 'extKeyUsage', + names: ekuExt.parsedValue.keyPurposes, + critical: !!ekuExt.critical, + }) } - + + const sanExt = findExtensionByOid(cert, OID.EXT_SUBJECT_ALT_NAME) + const subjectAltNames = sanExt && sanExt.parsedValue + ? decodeAltNames(sanExt.parsedValue.altNames) + : { dns: [], ip: [], uri: [], email: [], dn: [] } + return { subject, - subjectStr: subjectFormatted, + subjectStr: formatDNFromObject(subject), issuer, - issuerStr: issuerFormatted, + issuerStr: formatDNFromObject(issuer), type, validFrom, validTo, @@ -255,7 +234,7 @@ export function parseCertificate(base64Pem) { version, extensions, subjectAltNames, - raw: cert + raw: cert, } } catch (error) { return { @@ -267,327 +246,155 @@ export function parseCertificate(base64Pem) { validFrom: 'Unknown', validTo: 'Unknown', fingerprintSha1: 'Unknown', - error: error.message - } - } -} - -/** - * Format a Distinguished Name (DN) object to string - * @param {Object} dn - Distinguished Name object (from node-forge or parsed DN object) - * @returns {string} Formatted DN string - */ -function formatDN(dn) { - if (!dn) return 'Unknown' - - // If it's a string, return it directly - if (typeof dn === 'string') { - return dn - } - - // If it has getField method (node-forge style), use that - if (typeof dn.getField === 'function') { - const parts = [] - const attributes = ['CN', 'OU', 'O', 'L', 'ST', 'C', 'emailAddress'] - - for (const attr of attributes) { - const field = dn.getField(attr) - if (field) { - const value = field.value || field - parts.push(`${attr}=${value}`) - } - } - - const allAttrs = dn.attributes || [] - for (const attr of allAttrs) { - if (!attributes.includes(attr.name)) { - parts.push(`${attr.name}=${attr.value}`) - } - } - - return parts.join(', ') - } - - // Otherwise, treat as parsed DN object - return formatDNFromObject(dn) -} - -/** - * Determine certificate type based on extensions and usage - * @param {Object} cert - Certificate object (X509 from jsrsasign) - * @returns {string} Certificate type - */ -function determineCertificateType(cert) { - try { - // Check for CA certificate - let basicConstraints = null - try { - basicConstraints = cert.getExtBasicConstraints() - } catch (e) { - // Extension doesn't exist or can't be read - continue - } - - // Check for keyUsage with keyCertSign - let keyUsage = null - try { - keyUsage = cert.getExtKeyUsage() - } catch (e) { - // Extension doesn't exist or can't be read - continue - } - - const isCA = (basicConstraints && basicConstraints.ca) || - (keyUsage && keyUsage.names && Array.isArray(keyUsage.names) && keyUsage.names.includes('keyCertSign')) - - if (isCA) { - // Determine if Root CA or Intermediate by checking if self-signed - const subject = cert.getSubjectString() - const issuer = cert.getIssuerString() - - if (subject === issuer) { - return 'Root CA' - } - return 'Intermediate' - } - - // Check for server certificate - // getExtExtKeyUsage() returns array of OID strings - let extKeyUsage = null - try { - extKeyUsage = cert.getExtExtKeyUsage() - } catch (e) { - // Extension doesn't exist or can't be read - continue + error: error.message, } - - if (extKeyUsage && Array.isArray(extKeyUsage)) { - if (extKeyUsage.includes('1.3.6.1.5.5.7.3.1')) { // serverAuth OID - return 'Server Certificate' - } - // Check for client certificate - if (extKeyUsage.includes('1.3.6.1.5.5.7.3.2')) { // clientAuth OID - return 'Client Certificate' - } - } - } catch (error) { - // If any unexpected error occurs, log it and return default - console.warn('Error determining certificate type:', error) } - - return 'End-entity' } - /** - * Format a date object to YYYY-MM-DD string - * @param {Date|string} date - Date object or ASN1 time string - * @returns {string} Formatted date string - */ -function formatDate(date) { - if (!date) return 'Unknown' - - // If it's a string (X509 time), convert to Date first - let dateObj = date - if (typeof date === 'string') { - dateObj = convertX509TimeToDate(date) - if (!dateObj) return 'Unknown' - } - - const year = dateObj.getFullYear() - const month = String(dateObj.getMonth() + 1).padStart(2, '0') - const day = String(dateObj.getDate()).padStart(2, '0') - - return `${year}-${month}-${day}` -} - -/** - * Validate if a string contains valid PEM certificate data - * @param {string} pemString - PEM certificate string - * @returns {boolean} True if valid PEM certificate + * Validate that a string contains a parseable PEM certificate. Synchronous so + * components can short-circuit before kicking off async verification. + * @param {string} pemString */ export function isValidPemCertificate(pemString) { try { - // Check if it contains certificate markers - if (!pemString.includes('-----BEGIN CERTIFICATE-----') || + if (!pemString.includes('-----BEGIN CERTIFICATE-----') || !pemString.includes('-----END CERTIFICATE-----')) { return false } - - // Try to parse it - const cert = new X509() - cert.readCertPEM(pemString) - + parsePemCertificate(pemString) return true - } catch (error) { + } catch { return false } } /** - * Validate if a string contains valid PEM private key data - * @param {string} pemString - PEM private key string - * @returns {boolean} True if valid PEM private key + * Validate that a string looks like a supported PEM private key. We do + * structural validation only (header presence + base64 sanity); algorithm + * compatibility with Web Crypto is checked later via importPrivateKeyFromPem. + * + * NOTE: DSA private keys are no longer supported - Web Crypto cannot import + * them. A clear failure surfaces the next time the key is used for matching. + * + * @param {string} pemString */ export function isValidPemPrivateKey(pemString) { + if (!pemString) return false + + const accepted = ( + (pemString.includes('-----BEGIN PRIVATE KEY-----') && pemString.includes('-----END PRIVATE KEY-----')) || + (pemString.includes('-----BEGIN RSA PRIVATE KEY-----') && pemString.includes('-----END RSA PRIVATE KEY-----')) || + (pemString.includes('-----BEGIN EC PRIVATE KEY-----') && pemString.includes('-----END EC PRIVATE KEY-----')) + ) + + if (!accepted) return false + try { - // Check if it contains private key markers (support multiple formats) - const hasPrivateKeyMarkers = ( - (pemString.includes('-----BEGIN PRIVATE KEY-----') && pemString.includes('-----END PRIVATE KEY-----')) || - (pemString.includes('-----BEGIN RSA PRIVATE KEY-----') && pemString.includes('-----END RSA PRIVATE KEY-----')) || - (pemString.includes('-----BEGIN EC PRIVATE KEY-----') && pemString.includes('-----END EC PRIVATE KEY-----')) || - (pemString.includes('-----BEGIN DSA PRIVATE KEY-----') && pemString.includes('-----END DSA PRIVATE KEY-----')) - ) - - if (!hasPrivateKeyMarkers) { - return false - } - - // Try to parse it as a private key - const privateKey = KEYUTIL.getKey(pemString) - - return !!privateKey - } catch (error) { + pemToDer(pemString) + return true + } catch { return false } } -/** - * Convert PEM string to Base64-encoded format - * @param {string} pemString - PEM certificate string - * @returns {string} Base64-encoded certificate - */ export function pemToBase64(pemString) { try { - // Remove PEM headers and footers - const base64Content = pemString + return pemString .replace(/-----BEGIN CERTIFICATE-----/g, '') .replace(/-----END CERTIFICATE-----/g, '') - .replace(/\s/g, '') // Remove whitespace - - return base64Content - } catch (error) { + .replace(/\s/g, '') + } catch { const errorMessage = 'Invalid PEM format' notificationService.showError(errorMessage) throw new Error(errorMessage) } } -/** - * Convert PEM private key string to Base64-encoded format - * @param {string} pemString - PEM private key string - * @returns {string} Base64-encoded private key - */ export function privateKeyPemToBase64(pemString) { try { - // Remove all possible private key headers and footers - const base64Content = pemString + return pemString .replace(/-----BEGIN PRIVATE KEY-----/g, '') .replace(/-----END PRIVATE KEY-----/g, '') .replace(/-----BEGIN RSA PRIVATE KEY-----/g, '') .replace(/-----END RSA PRIVATE KEY-----/g, '') .replace(/-----BEGIN EC PRIVATE KEY-----/g, '') .replace(/-----END EC PRIVATE KEY-----/g, '') - .replace(/-----BEGIN DSA PRIVATE KEY-----/g, '') - .replace(/-----END DSA PRIVATE KEY-----/g, '') - .replace(/\s/g, '') // Remove whitespace - - return base64Content - } catch (error) { + .replace(/\s/g, '') + } catch { const errorMessage = 'Invalid private key PEM format' notificationService.showError(errorMessage) throw new Error(errorMessage) } } -/** - * Convert Base64-encoded certificate to PEM format - * @param {string} base64Cert - Base64-encoded certificate - * @returns {string} PEM certificate string - */ export function base64ToPem(base64Cert) { try { - // Add PEM headers - const pemString = `-----BEGIN CERTIFICATE-----\n${base64Cert}\n-----END CERTIFICATE-----` - return pemString - } catch (error) { + if (typeof base64Cert === 'string' && base64Cert.includes('-----BEGIN ')) { + return base64Cert + } + return `-----BEGIN CERTIFICATE-----\n${base64Cert}\n-----END CERTIFICATE-----` + } catch { const errorMessage = 'Invalid Base64 format' notificationService.showError(errorMessage) throw new Error(errorMessage) } } -/** - * Convert Base64-encoded private key to PEM format - * @param {string} base64Key - Base64-encoded private key - * @returns {string} PEM private key string - */ export function base64ToPrivateKeyPem(base64Key) { try { - // Add PEM headers for private key - const pemString = `-----BEGIN PRIVATE KEY-----\n${base64Key}\n-----END PRIVATE KEY-----` - return pemString - } catch (error) { + if (typeof base64Key === 'string' && base64Key.includes('-----BEGIN ')) { + return base64Key + } + return `-----BEGIN PRIVATE KEY-----\n${base64Key}\n-----END PRIVATE KEY-----` + } catch { const errorMessage = 'Invalid Base64 private key format' notificationService.showError(errorMessage) throw new Error(errorMessage) } } - -// Get suggested alias from certificate details +/** + * Suggest an alias from a parseCertificate() result: prefer subject CN, then + * any DNS SAN, then the first DN component. + */ export function getSuggestedAlias(details) { if (!details) return null - - // Try to get CN from subject + const subjectStr = details.subjectStr || '' const cnMatch = subjectStr.match(/CN=([^,]+)/) - if (cnMatch && cnMatch[1]) { - return cnMatch[1].trim() - } - - // Try to get first DNS name from SAN - if (details.raw && details.raw.extensions) { - const sanExtension = details.raw.extensions.find(ext => ext.name === 'subjectAltName') - if (sanExtension && sanExtension.altNames) { - const dnsName = sanExtension.altNames.find(altName => altName.type === 2) // DNS type - if (dnsName && dnsName.value) { - return dnsName.value.trim() - } - } - } - - // Fallback to first part of subject + if (cnMatch && cnMatch[1]) return cnMatch[1].trim() + + const dnsName = details.subjectAltNames?.dns?.[0] + if (dnsName) return dnsName.trim() + const firstPart = subjectStr.split(',')[0] if (firstPart && firstPart.includes('=')) { return firstPart.split('=')[1]?.trim() } - return null } /** - * Parse a certificate chain from PEM text and return array of certificate objects - * @param {string} pemText - PEM certificate text (can contain multiple certificates) - * @returns {Array} Array of certificate objects with structure: { certificate: pem, alias, subject, issuer, ... } + * Parse a chain of certificates from PEM text and return an array of + * UI-friendly descriptors. Async because parseCertificate is async. + * + * @param {string} pemText + * @returns {Promise} */ -export function parseCertificateChainFromPem(pemText) { - if (!pemText || !pemText.trim()) { - return [] - } +export async function parseCertificateChainFromPem(pemText) { + if (!pemText || !pemText.trim()) return [] try { - // Parse the certificate chain using verificationUtils const chainCertificates = parseCertificateChain(pemText) - - if (chainCertificates.length === 0) { - return [] - } + if (chainCertificates.length === 0) return [] - // Parse each certificate to get details const certificates = [] - chainCertificates.forEach((chainCert, index) => { + for (let index = 0; index < chainCertificates.length; index++) { + const chainCert = chainCertificates[index] try { - const parsed = parseCertificate(chainCert.pem) - - // Handle parse errors gracefully + const parsed = await parseCertificate(chainCert.pem) + if (parsed.error) { certificates.push({ certificate: chainCert.pem, @@ -600,9 +407,9 @@ export function parseCertificateChainFromPem(pemText) { validTo: 'Unknown', fingerprintSha1: 'Unknown', parsedCertificate: parsed, - error: parsed.error + error: parsed.error, }) - return + continue } certificates.push({ @@ -615,7 +422,7 @@ export function parseCertificateChainFromPem(pemText) { validFrom: parsed.validFrom, validTo: parsed.validTo, fingerprintSha1: parsed.fingerprintSha1, - parsedCertificate: parsed + parsedCertificate: parsed, }) } catch (parseError) { notificationService.showWarning(`Failed to parse certificate ${index + 1} in chain: ${parseError.message}`) @@ -629,13 +436,13 @@ export function parseCertificateChainFromPem(pemText) { validFrom: 'Unknown', validTo: 'Unknown', fingerprintSha1: 'Unknown', - error: parseError.message + error: parseError.message, }) } - }) + } return certificates - } catch (error) { + } catch { return [] } } diff --git a/web-ui/src/utils/pkiHelpers.js b/web-ui/src/utils/pkiHelpers.js new file mode 100644 index 0000000..dc3fdb8 --- /dev/null +++ b/web-ui/src/utils/pkiHelpers.js @@ -0,0 +1,495 @@ +import * as asn1js from 'asn1js' +import * as pkijs from 'pkijs' + +/** + * Shared helpers used by certificateUtils.js and verificationUtils.js. + * + * pkijs operates on DER buffers and ASN.1 structures, so most of these helpers + * exist to bridge between PEM strings / hex strings used by the rest of the app + * and the structures pkijs produces. + */ + +const PEM_HEADER_RE = /-----BEGIN ([A-Z0-9 ]+)-----([\s\S]*?)-----END \1-----/ + +const OID_TO_NAME = { + '2.5.4.3': 'CN', + '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.5': 'serialNumber', + '2.5.4.12': 'T', + '2.5.4.42': 'GN', + '2.5.4.43': 'I', + '2.5.4.4': 'SN', + '0.9.2342.19200300.100.1.25': 'DC', + '0.9.2342.19200300.100.1.1': 'UID', + '1.2.840.113549.1.9.1': 'emailAddress', +} + +const CURVE_OID_TO_NAME = { + '1.2.840.10045.3.1.7': 'P-256', + '1.3.132.0.34': 'P-384', + '1.3.132.0.35': 'P-521', +} + +const CURVE_NAME_TO_BITS = { + 'P-256': 256, + 'P-384': 384, + 'P-521': 521, +} + +const SIG_ALGORITHM_OID_NAMES = { + '1.2.840.113549.1.1.5': 'sha1WithRSAEncryption', + '1.2.840.113549.1.1.11': 'sha256WithRSAEncryption', + '1.2.840.113549.1.1.12': 'sha384WithRSAEncryption', + '1.2.840.113549.1.1.13': 'sha512WithRSAEncryption', + '1.2.840.113549.1.1.10': 'rsassa-pss', + '1.2.840.10045.4.1': 'ecdsa-with-SHA1', + '1.2.840.10045.4.3.2': 'ecdsa-with-SHA256', + '1.2.840.10045.4.3.3': 'ecdsa-with-SHA384', + '1.2.840.10045.4.3.4': 'ecdsa-with-SHA512', + '1.3.101.112': 'Ed25519', + '1.3.101.113': 'Ed448', +} + +export const OID = { + RSA_ENCRYPTION: '1.2.840.113549.1.1.1', + EC_PUBLIC_KEY: '1.2.840.10045.2.1', + EXT_BASIC_CONSTRAINTS: '2.5.29.19', + EXT_KEY_USAGE: '2.5.29.15', + EXT_EXTENDED_KEY_USAGE: '2.5.29.37', + EXT_SUBJECT_ALT_NAME: '2.5.29.17', + EKU_SERVER_AUTH: '1.3.6.1.5.5.7.3.1', + EKU_CLIENT_AUTH: '1.3.6.1.5.5.7.3.2', +} + +const KEY_USAGE_NAMES = [ + 'digitalSignature', + 'nonRepudiation', + 'keyEncipherment', + 'dataEncipherment', + 'keyAgreement', + 'keyCertSign', + 'cRLSign', + 'encipherOnly', + 'decipherOnly', +] + +/** + * Decode a PEM block (any kind) into a DER ArrayBuffer. + * + * Lenient on input: bare base64, a single PEM block, or even doubly-wrapped + * PEM (which can happen when a value that is already PEM is passed through + * `base64ToPem` again) all decode correctly. Every `-----BEGIN/END FOO-----` + * marker line is stripped, then the residual base64 is decoded. + * + * @param {string} pem + * @returns {ArrayBuffer} + */ +export function pemToDer(pem) { + const b64 = pem + .replace(/-----(?:BEGIN|END)[^-]+-----/g, '') + .replace(/\s+/g, '') + const bin = atob(b64) + const buf = new ArrayBuffer(bin.length) + const view = new Uint8Array(buf) + for (let i = 0; i < bin.length; i++) view[i] = bin.charCodeAt(i) + return buf +} + +/** + * Read just the PEM type (e.g. "CERTIFICATE", "PRIVATE KEY", "EC PRIVATE KEY"). + * @param {string} pem + * @returns {string|null} + */ +export function pemType(pem) { + const match = PEM_HEADER_RE.exec(pem) + return match ? match[1] : null +} + +/** + * Convert a Uint8Array or ArrayBuffer to an uppercase hex string (no separators). + * @param {Uint8Array|ArrayBuffer} bytes + * @returns {string} + */ +export function bytesToHex(bytes) { + const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes) + let out = '' + for (let i = 0; i < view.length; i++) { + out += view[i].toString(16).padStart(2, '0') + } + return out.toUpperCase() +} + +/** + * Format hex into colon-separated pairs (AA:BB:CC...). + * @param {string} hex + */ +export function hexWithColons(hex) { + return hex.toUpperCase().replace(/(.{2})(?!$)/g, '$1:') +} + +/** + * Render a pkijs RelativeDistinguishedNames object as an RFC-4514-ish string. + * @param {Object} rdn - cert.subject or cert.issuer from pkijs.Certificate + * @returns {string} + */ +export function rdnToString(rdn) { + if (!rdn || !Array.isArray(rdn.typesAndValues)) return '' + return rdn.typesAndValues + .map(tv => { + const name = OID_TO_NAME[tv.type] || tv.type + const value = tv.value && tv.value.valueBlock && tv.value.valueBlock.value + return value !== undefined ? `${name}=${value}` : `${name}=` + }) + .join(', ') +} + +/** + * Get all certificate PEM blocks from a string that may contain a chain. + * @param {string} text + * @returns {string[]} + */ +export function extractCertificatePems(text) { + const re = /-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g + return text.match(re) || [] +} + +/** + * Parse a single PEM CERTIFICATE block into a pkijs.Certificate. + * @param {string} pem + * @returns {pkijs.Certificate} + */ +export function parsePemCertificate(pem) { + const der = pemToDer(pem) + return pkijs.Certificate.fromBER(der) +} + +/** + * Decode the IP address bytes from a SAN GeneralName of type 7 into a printable + * dotted (IPv4) or colon-hex (IPv6) string. + * @param {Uint8Array|ArrayBuffer} bytes + * @returns {string} + */ +function ipBytesToString(bytes) { + const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes) + if (view.length === 4) { + return Array.from(view).join('.') + } + if (view.length === 16) { + const parts = [] + for (let i = 0; i < 16; i += 2) { + parts.push(((view[i] << 8) | view[i + 1]).toString(16)) + } + return parts.join(':') + } + return bytesToHex(view) +} + +/** + * Convert a pkijs AltName.altNames array (GeneralName[]) into the shape used + * throughout the UI: { dns, ip, uri, email, dn }. + * @param {Array} altNames + */ +export function decodeAltNames(altNames) { + const out = { dns: [], ip: [], uri: [], email: [], dn: [] } + if (!Array.isArray(altNames)) return out + + for (const gn of altNames) { + if (!gn) continue + switch (gn.type) { + case 1: // rfc822Name + if (typeof gn.value === 'string') out.email.push(gn.value) + break + case 2: // dNSName + if (typeof gn.value === 'string') out.dns.push(gn.value) + break + case 6: // uniformResourceIdentifier + if (typeof gn.value === 'string') out.uri.push(gn.value) + break + case 7: { // iPAddress (OctetString) + const hex = gn.value && gn.value.valueBlock && gn.value.valueBlock.valueHexView + if (hex) out.ip.push(ipBytesToString(hex)) + break + } + case 4: { // directoryName + const dirName = rdnToString(gn.value) + if (dirName) out.dn.push(dirName) + break + } + default: + break + } + } + return out +} + +/** + * Decode a KeyUsage BIT STRING (extnValue parsedValue) into the usage name list. + * @param {Object} parsedBitString - asn1js.BitString instance + * @returns {string[]} + */ +export function decodeKeyUsageBits(parsedBitString) { + if (!parsedBitString || !parsedBitString.valueBlock) return [] + const bytes = parsedBitString.valueBlock.valueHexView + || new Uint8Array(parsedBitString.valueBlock.valueHex || new ArrayBuffer(0)) + const unusedBits = parsedBitString.valueBlock.unusedBits || 0 + if (!bytes || bytes.length === 0) return [] + const totalBits = bytes.length * 8 - unusedBits + const out = [] + for (let i = 0; i < totalBits && i < KEY_USAGE_NAMES.length; i++) { + const byteIdx = Math.floor(i / 8) + const bitInByte = 7 - (i % 8) + if ((bytes[byteIdx] >> bitInByte) & 1) { + out.push(KEY_USAGE_NAMES[i]) + } + } + return out +} + +/** + * Find a parsed extension value by extension OID. + * Returns the parsedValue object (e.g. pkijs.BasicConstraints) or null. + */ +export function findExtensionByOid(cert, oid) { + if (!cert || !Array.isArray(cert.extensions)) return null + return cert.extensions.find(ext => ext.extnID === oid) || null +} + +/** + * Resolve an EC curve OID to a Web Crypto namedCurve string. + * @param {string} oid + * @returns {string|null} + */ +export function curveOidToName(oid) { + return CURVE_OID_TO_NAME[oid] || null +} + +/** + * Resolve the size in bits of a Web Crypto named EC curve. + */ +export function curveNameToBits(name) { + return CURVE_NAME_TO_BITS[name] || null +} + +/** + * Friendly name for a signature algorithm OID, falling back to the OID itself. + */ +export function sigAlgorithmName(oid) { + if (!oid) return 'Unknown' + return SIG_ALGORITHM_OID_NAMES[oid] || oid +} + +/** + * Inspect a pkijs.Certificate's SubjectPublicKeyInfo and return: + * { type: 'RSA' | 'EC', namedCurve?: 'P-256' | 'P-384' | 'P-521', bits?: number } + * Returns null for unsupported algorithms. + */ +export function describePublicKey(cert) { + const algoOid = cert?.subjectPublicKeyInfo?.algorithm?.algorithmId + if (!algoOid) return null + + if (algoOid === OID.RSA_ENCRYPTION) { + let bits + try { + const view = cert.subjectPublicKeyInfo.subjectPublicKey.valueBlock.valueHexView + const buf = new ArrayBuffer(view.byteLength) + new Uint8Array(buf).set(view) + const rsa = pkijs.RSAPublicKey.fromBER(buf) + const mod = rsa.modulus.valueBlock.valueHexView + let lead = 0 + while (lead < mod.length && mod[lead] === 0) lead++ + bits = (mod.length - lead) * 8 + } catch { + bits = undefined + } + return { type: 'RSA', bits } + } + + if (algoOid === OID.EC_PUBLIC_KEY) { + const params = cert.subjectPublicKeyInfo.algorithm.algorithmParams + const curveOid = params && typeof params.valueBlock?.toString === 'function' + ? params.valueBlock.toString() + : null + const namedCurve = curveOidToName(curveOid) + return { type: 'EC', namedCurve, bits: namedCurve ? curveNameToBits(namedCurve) : undefined } + } + + return null +} + +/** + * Import the public key from a pkijs.Certificate as a Web Crypto CryptoKey + * suitable for signature verification. We pin SHA-256 for RSA and rely on the + * certificate's EC named curve for ECDSA so we can deterministically pair this + * key with a private key imported by importPrivateKeyFromPem(). + * + * @param {pkijs.Certificate} cert + * @returns {Promise} + */ +export async function importCertPublicKey(cert) { + const desc = describePublicKey(cert) + if (!desc) throw new Error('Unsupported certificate public key algorithm') + + if (desc.type === 'RSA') { + return cert.getPublicKey({ + algorithm: { + algorithm: { name: 'RSASSA-PKCS1-v1_5', hash: { name: 'SHA-256' } }, + usages: ['verify'], + }, + }) + } + + if (desc.type === 'EC') { + if (!desc.namedCurve) throw new Error('Unsupported EC named curve in certificate') + return cert.getPublicKey({ + algorithm: { + algorithm: { name: 'ECDSA', namedCurve: desc.namedCurve }, + usages: ['verify'], + }, + }) + } + + throw new Error(`Unsupported public key type: ${desc.type}`) +} + +/** + * Build a PKCS#8 DER buffer from a parsed RSAPrivateKey (PKCS#1 form). + */ +function wrapRsaPkcs1AsPkcs8(rsaKeyDer) { + const wrapped = new pkijs.PrivateKeyInfo({ + version: 0, + privateKeyAlgorithm: new pkijs.AlgorithmIdentifier({ + algorithmId: OID.RSA_ENCRYPTION, + algorithmParams: new asn1js.Null(), + }), + privateKey: new asn1js.OctetString({ valueHex: rsaKeyDer }), + }) + return wrapped.toSchema().toBER(false) +} + +/** + * Build a PKCS#8 DER buffer from a parsed ECPrivateKey (SEC1 form). + */ +function wrapEcSec1AsPkcs8(sec1Der, curveOid) { + const wrapped = new pkijs.PrivateKeyInfo({ + version: 0, + privateKeyAlgorithm: new pkijs.AlgorithmIdentifier({ + algorithmId: OID.EC_PUBLIC_KEY, + algorithmParams: new asn1js.ObjectIdentifier({ value: curveOid }), + }), + privateKey: new asn1js.OctetString({ valueHex: sec1Der }), + }) + return wrapped.toSchema().toBER(false) +} + +/** + * Extract the *private key* PEM block from a string that may contain other + * leading blocks (notably "EC PARAMETERS" emitted by OpenSSL alongside an EC + * private key). Returns the first matching block, or null. + * + * @param {string} pem + */ +function extractPrivateKeyBlock(pem) { + const re = /-----BEGIN ([A-Z0-9 ]*PRIVATE KEY)-----[\s\S]*?-----END \1-----/g + const match = re.exec(pem) + return match ? match[0] : null +} + +/** + * Import a PEM-encoded private key (PKCS#8, PKCS#1 RSA, or SEC1 EC) as a Web + * Crypto CryptoKey usable for SHA-256 signing. + * + * Returns: { key: CryptoKey, type: 'RSA'|'EC', namedCurve?: string } + * Throws on unsupported formats (notably DSA, which Web Crypto does not support). + * + * @param {string} pem + */ +export async function importPrivateKeyFromPem(pem) { + const block = extractPrivateKeyBlock(pem) || pem + const type = pemType(block) + if (!type) throw new Error('Not a PEM private key') + + if (type === 'DSA PRIVATE KEY') { + throw new Error('DSA private keys are not supported in this environment') + } + + if (type === 'PRIVATE KEY') { + const der = pemToDer(block) + const pki = pkijs.PrivateKeyInfo.fromBER(der) + const algoOid = pki.privateKeyAlgorithm.algorithmId + + if (algoOid === OID.RSA_ENCRYPTION) { + const key = await crypto.subtle.importKey( + 'pkcs8', der, + { name: 'RSASSA-PKCS1-v1_5', hash: { name: 'SHA-256' } }, + true, ['sign'] + ) + return { key, type: 'RSA' } + } + + if (algoOid === OID.EC_PUBLIC_KEY) { + const params = pki.privateKeyAlgorithm.algorithmParams + const curveOid = params && typeof params.valueBlock?.toString === 'function' + ? params.valueBlock.toString() + : null + const namedCurve = curveOidToName(curveOid) + if (!namedCurve) throw new Error(`Unsupported EC curve OID: ${curveOid}`) + const key = await crypto.subtle.importKey( + 'pkcs8', der, + { name: 'ECDSA', namedCurve }, + true, ['sign'] + ) + return { key, type: 'EC', namedCurve } + } + + throw new Error(`Unsupported private key algorithm: ${algoOid}`) + } + + if (type === 'RSA PRIVATE KEY') { + const der = pemToDer(block) + const pkcs8 = wrapRsaPkcs1AsPkcs8(der) + const key = await crypto.subtle.importKey( + 'pkcs8', pkcs8, + { name: 'RSASSA-PKCS1-v1_5', hash: { name: 'SHA-256' } }, + true, ['sign'] + ) + return { key, type: 'RSA' } + } + + if (type === 'EC PRIVATE KEY') { + const der = pemToDer(block) + const ec = pkijs.ECPrivateKey.fromBER(der) + const curveOid = ec.namedCurve + if (!curveOid) throw new Error('EC private key has no namedCurve OID') + const namedCurve = curveOidToName(curveOid) + if (!namedCurve) throw new Error(`Unsupported EC curve OID: ${curveOid}`) + const pkcs8 = wrapEcSec1AsPkcs8(der, curveOid) + const key = await crypto.subtle.importKey( + 'pkcs8', pkcs8, + { name: 'ECDSA', namedCurve }, + true, ['sign'] + ) + return { key, type: 'EC', namedCurve } + } + + throw new Error(`Unsupported PEM private key type: ${type}`) +} + +/** + * Compute a hash over a buffer and return the result as a hex string. + * @param {ArrayBuffer|Uint8Array} buffer + * @param {'SHA-1'|'SHA-256'|'SHA-384'|'SHA-512'} algorithm + * @returns {Promise} uppercase hex + */ +export async function digestHex(buffer, algorithm) { + const data = buffer instanceof Uint8Array ? buffer.buffer.slice( + buffer.byteOffset, + buffer.byteOffset + buffer.byteLength + ) : buffer + const hash = await crypto.subtle.digest(algorithm, data) + return bytesToHex(new Uint8Array(hash)) +} diff --git a/web-ui/src/utils/verificationUtils.js b/web-ui/src/utils/verificationUtils.js index b69e95d..298f35e 100644 --- a/web-ui/src/utils/verificationUtils.js +++ b/web-ui/src/utils/verificationUtils.js @@ -1,62 +1,71 @@ -import { X509, KEYUTIL, KJUR, RSAKey } from 'jsrsasign' -import { convertX509TimeToDate, parseDNString, isValidPemCertificate, isValidPemPrivateKey } from './certificateUtils.js' +import * as pkijs from 'pkijs' +import { + bytesToHex, + decodeAltNames, + describePublicKey, + digestHex, + extractCertificatePems, + findExtensionByOid, + hexWithColons, + importCertPublicKey, + importPrivateKeyFromPem, + OID, + pemToDer, + rdnToString, + sigAlgorithmName, +} from './pkiHelpers.js' +import { isValidPemCertificate, isValidPemPrivateKey, parseDNString } from './certificateUtils.js' /** - * Parse a certificate chain from PEM text (supports multiple certificates) - * @param {string} certText - PEM certificate text (can contain multiple certificates) - * @returns {Array} Array of certificate objects with pem and cert properties + * Parse a PEM blob (single cert or chain) into an array of + * { pem, cert, der } records, where `cert` is a pkijs.Certificate. + * + * Synchronous because pkijs.Certificate.fromBER is synchronous - only the + * Web Crypto operations (verify, getPublicKey, importKey) are async. + * + * @param {string} certText + * @returns {Array<{pem: string, cert: pkijs.Certificate, der: ArrayBuffer}>} */ export function parseCertificateChain(certText) { const certificates = [] - const certRegex = /-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g - const matches = certText.match(certRegex) - - if (matches) { - matches.forEach(certPem => { - try { - const cert = new X509() - cert.readCertPEM(certPem) - certificates.push({ pem: certPem, cert: cert }) - } catch (e) { - // Failed to parse certificate - skip it - } - }) + const pems = extractCertificatePems(certText || '') + for (const pem of pems) { + try { + const der = pemToDer(pem) + const cert = pkijs.Certificate.fromBER(der) + certificates.push({ pem, cert, der }) + } catch { + // Failed to parse certificate - skip it (matches previous behavior) + } } - return certificates } /** - * Validate a certificate chain for proper ordering and signatures - * @param {Array} certificates - Array of certificate objects - * @returns {Object} Validation result with isValid, errors, warnings, and details + * Validate ordering, issuer linkage, signatures, and basic constraints + * across an array of parsed certificates (output of parseCertificateChain). + * + * Async because pkijs.Certificate.verify(parent) is async. + * + * @param {Array<{cert: pkijs.Certificate}>} certificates + * @returns {Promise<{isValid: boolean, errors: string[], warnings: string[], details: string[]}>} */ -export function validateCertificateChain(certificates) { - const validation = { - isValid: true, - errors: [], - warnings: [], - details: [] - } +export async function validateCertificateChain(certificates) { + const validation = { isValid: true, errors: [], warnings: [], details: [] } - if (certificates.length === 1) { + if (!certificates || certificates.length <= 1) { validation.details.push('Single certificate provided - no chain validation needed') return validation } - // Check chain order and signatures for (let i = 0; i < certificates.length - 1; i++) { const cert = certificates[i].cert const issuerCert = certificates[i + 1].cert - const certPem = certificates[i].pem - const issuerPem = certificates[i + 1].pem validation.details.push(`Checking certificate ${i + 1} against issuer certificate ${i + 2}`) - // Check if issuer name matches - const certIssuer = cert.getIssuerString() - const issuerSubject = issuerCert.getSubjectString() - + const certIssuer = rdnToString(cert.issuer) + const issuerSubject = rdnToString(issuerCert.subject) if (certIssuer !== issuerSubject) { validation.isValid = false validation.errors.push(`Certificate ${i + 1} issuer "${certIssuer}" does not match certificate ${i + 2} subject "${issuerSubject}"`) @@ -64,9 +73,8 @@ export function validateCertificateChain(certificates) { validation.details.push(`✓ Issuer names match for certificates ${i + 1} and ${i + 2}`) } - // Verify signature try { - const isSignatureValid = cert.verifySignature(issuerPem) // jsrsasign requires PEM string + const isSignatureValid = await cert.verify(issuerCert) if (isSignatureValid) { validation.details.push(`✓ Certificate ${i + 1} signature verified by certificate ${i + 2}`) } else { @@ -78,29 +86,26 @@ export function validateCertificateChain(certificates) { validation.errors.push(`Error verifying certificate ${i + 1} signature: ${error.message}`) } - // Check validity periods - const certNotBefore = convertX509TimeToDate(cert.getNotBefore()) - const certNotAfter = convertX509TimeToDate(cert.getNotAfter()) - const issuerNotBefore = convertX509TimeToDate(issuerCert.getNotBefore()) - const issuerNotAfter = convertX509TimeToDate(issuerCert.getNotAfter()) + const certNotBefore = cert.notBefore?.value + const certNotAfter = cert.notAfter?.value + const issuerNotBefore = issuerCert.notBefore?.value + const issuerNotAfter = issuerCert.notAfter?.value - if (certNotBefore < issuerNotBefore) { + if (certNotBefore && issuerNotBefore && certNotBefore < issuerNotBefore) { validation.warnings.push(`Certificate ${i + 1} valid from date is before its issuer's valid from date`) } - if (certNotAfter > issuerNotAfter) { + if (certNotAfter && issuerNotAfter && certNotAfter > issuerNotAfter) { validation.warnings.push(`Certificate ${i + 1} expires after its issuer certificate ${i + 2}`) } } - // Check if root is self-signed - const rootCert = certificates[certificates.length - 1].cert - const rootPem = certificates[certificates.length - 1].pem - const rootIssuer = rootCert.getIssuerString() - const rootSubject = rootCert.getSubjectString() + const root = certificates[certificates.length - 1].cert + const rootIssuer = rdnToString(root.issuer) + const rootSubject = rdnToString(root.subject) if (rootIssuer === rootSubject) { try { - const isSelfSigned = rootCert.verifySignature(rootPem) + const isSelfSigned = await root.verify(root) if (isSelfSigned) { validation.details.push('✓ Root certificate is properly self-signed') } else { @@ -113,32 +118,30 @@ export function validateCertificateChain(certificates) { validation.warnings.push('Root certificate is not self-signed - chain may be incomplete') } - // Check certificate purposes and constraints certificates.forEach((certObj, index) => { const cert = certObj.cert - let basicConstraints = null - try { - basicConstraints = cert.getExtBasicConstraints() - } catch (e) { - // Extension doesn't exist or can't be read - continue with null - } + const bcExt = findExtensionByOid(cert, OID.EXT_BASIC_CONSTRAINTS) + const bc = bcExt ? bcExt.parsedValue : null if (index === 0) { - // End entity certificate - if (basicConstraints && basicConstraints.ca) { // lowercase 'ca' in jsrsasign + if (bc && bc.cA) { validation.warnings.push('End entity certificate has CA flag set to true') } } else { - // CA certificates - if (!basicConstraints || !basicConstraints.ca) { // lowercase 'ca' in jsrsasign + if (!bc || !bc.cA) { validation.warnings.push(`Certificate ${index + 1} should be a CA but basicConstraints CA flag is not set`) } - if (basicConstraints && typeof basicConstraints.pathLenConstraint === 'number') { - const remainingCAs = certificates.length - index - 2 // Exclude self and count remaining CAs - if (remainingCAs > basicConstraints.pathLenConstraint) { - validation.errors.push(`Certificate ${index + 1} pathLenConstraint (${basicConstraints.pathLenConstraint}) exceeded by chain depth`) - validation.isValid = false + if (bc && bc.pathLenConstraint !== undefined) { + const pathLen = typeof bc.pathLenConstraint === 'number' + ? bc.pathLenConstraint + : (bc.pathLenConstraint?.valueBlock?.valueDec ?? null) + if (pathLen !== null) { + const remainingCAs = certificates.length - index - 2 + if (remainingCAs > pathLen) { + validation.errors.push(`Certificate ${index + 1} pathLenConstraint (${pathLen}) exceeded by chain depth`) + validation.isValid = false + } } } } @@ -148,394 +151,198 @@ export function validateCertificateChain(certificates) { } /** - * Validate if a private key matches a certificate - * @param {Object} certObj - Certificate object with cert property (X509 object) - * @param {string} keyPem - PEM private key string - * @returns {Object} Validation result with isValid and message + * Verify that a private key matches a certificate by performing a sign+verify + * round-trip with Web Crypto. + * + * @param {{cert: pkijs.Certificate}} certObj + * @param {string} keyPem + * @returns {Promise<{isValid: boolean, message: string}>} */ -export function validatePrivateKey(certObj, keyPem) { +export async function validatePrivateKey(certObj, keyPem) { try { - // Parse private key using KEYUTIL (handles all formats automatically) - const privateKey = KEYUTIL.getKey(keyPem) - if (!privateKey) { - return { isValid: false, message: 'Failed to parse private key' } + let imported + try { + imported = await importPrivateKeyFromPem(keyPem) + } catch (e) { + return { isValid: false, message: `Failed to parse private key: ${e.message}` } } - // Get public key from certificate - const certPubKeyPem = certObj.cert.getPublicKey() - const certPubKey = KEYUTIL.getKey(certPubKeyPem) - if (!certPubKey) { - return { isValid: false, message: 'Failed to parse certificate public key' } + let certPubKey + try { + certPubKey = await importCertPublicKey(certObj.cert) + } catch (e) { + return { isValid: false, message: `Failed to parse certificate public key: ${e.message}` } } - // Extract public key from private key object - // For RSA: private key has n and e (public components) - // For EC: private key has x and y (public point coordinates) - let pubKeyFromPrivate = null - try { - // Try to create a public key PEM from the private key - // For RSA keys, we can construct a public key object from n and e - if (privateKey.n && privateKey.e) { - // RSA key - construct public key object - pubKeyFromPrivate = new RSAKey() - pubKeyFromPrivate.setPublic(privateKey.n, privateKey.e) - const pubKeyPemFromPrivate = KEYUTIL.getPEM(pubKeyFromPrivate) - - // Compare public keys (PEM format comparison) - if (certPubKeyPem === pubKeyPemFromPrivate) { - return { isValid: true, message: 'Private key matches the certificate!' } - } - } else if (privateKey.curve && privateKey.x && privateKey.y) { - // EC key - construct public key object - pubKeyFromPrivate = new KJUR.crypto.ECDSA({ curve: privateKey.curve, pub: { x: privateKey.x, y: privateKey.y } }) - const pubKeyPemFromPrivate = KEYUTIL.getPEM(pubKeyFromPrivate) - - // Compare public keys (PEM format comparison) - if (certPubKeyPem === pubKeyPemFromPrivate) { - return { isValid: true, message: 'Private key matches the certificate!' } - } - } - } catch (constructError) { - // If constructing public key fails, fall through to signature verification - console.debug('Could not construct public key from private key:', constructError) + if (certPubKey.algorithm.name !== imported.key.algorithm.name) { + return { isValid: false, message: 'Private key does not match the certificate (algorithm mismatch)' } } - // Primary method: Signature verification (works for both RSA and EC) - try { - const testData = 'test-data-for-validation' - - // Determine signature algorithm based on key type - let sigAlg = 'SHA256withRSA' - if (privateKey.curve) { - // EC key - use ECDSA - sigAlg = 'SHA256withECDSA' - } - - const sig = new KJUR.crypto.Signature({ alg: sigAlg }) - sig.init(privateKey) - sig.updateString(testData) - const signature = sig.sign() - - const verifier = new KJUR.crypto.Signature({ alg: sigAlg }) - verifier.init(certPubKey) - verifier.updateString(testData) - const isValid = verifier.verify(signature) - - if (isValid) { - return { isValid: true, message: 'Private key matches the certificate!' } - } else { - return { isValid: false, message: 'Private key does not match the certificate' } - } - } catch (signError) { - // If signature verification fails, try fingerprint comparison - const certKeyFingerprint = getPublicKeyFingerprint(certPubKey) - const privateKeyFingerprint = getPrivateKeyFingerprint(privateKey) + if (imported.type === 'EC' && imported.namedCurve !== certPubKey.algorithm.namedCurve) { + return { isValid: false, message: 'Private key does not match the certificate (EC curve mismatch)' } + } - if (certKeyFingerprint === privateKeyFingerprint) { - return { isValid: true, message: 'Private key matches the certificate!' } - } else { - return { isValid: false, message: 'Private key does not match the certificate' } - } + const data = new TextEncoder().encode('test-data-for-validation') + let signOpts + if (imported.type === 'RSA') { + signOpts = { name: 'RSASSA-PKCS1-v1_5' } + } else if (imported.type === 'EC') { + signOpts = { name: 'ECDSA', hash: { name: 'SHA-256' } } + } else { + return { isValid: false, message: `Unsupported key type: ${imported.type}` } } + const signature = await crypto.subtle.sign(signOpts, imported.key, data) + const ok = await crypto.subtle.verify(signOpts, certPubKey, signature, data) + + return ok + ? { isValid: true, message: 'Private key matches the certificate!' } + : { isValid: false, message: 'Private key does not match the certificate' } } catch (error) { return { isValid: false, message: `Error validating private key: ${error.message}` } } } /** - * Get certificate status (valid, expired, not yet valid) - * @param {Object} cert - Certificate object (X509 from jsrsasign) - * @returns {string} Status message + * Return a human status string based on validity period. + * @param {pkijs.Certificate} cert */ export function getCertStatus(cert) { const now = new Date() - const notBefore = convertX509TimeToDate(cert.getNotBefore()) - const notAfter = convertX509TimeToDate(cert.getNotAfter()) - - if (now < notBefore) { - return '⏳ Not yet valid' - } else if (now > notAfter) { - return '⚠️ Expired' - } else { - return '✅ Valid' - } + const notBefore = cert.notBefore?.value + const notAfter = cert.notAfter?.value + + if (notBefore && now < notBefore) return '⏳ Not yet valid' + if (notAfter && now > notAfter) return '⚠️ Expired' + return '✅ Valid' } /** - * Get certificate fingerprint - * @param {Object} cert - Certificate object (X509 from jsrsasign) - * @param {string} algorithm - Hash algorithm ('sha1' or 'sha256') - * @returns {string} Formatted fingerprint + * Compute a colon-separated fingerprint of a certificate's DER encoding. + * + * @param {ArrayBuffer|Uint8Array} der - DER bytes of the certificate + * @param {'sha1'|'sha256'|'sha384'|'sha512'} algorithm + * @returns {Promise} */ -export function getFingerprint(cert, algorithm = 'sha1') { - const derHex = cert.hex // DER-encoded certificate as hex string - const fingerprint = KJUR.crypto.Util.hashHex(derHex, algorithm) - return fingerprint.toUpperCase().replace(/(.{2})/g, '$1:').slice(0, -1) +export async function getFingerprint(der, algorithm = 'sha1') { + const algMap = { sha1: 'SHA-1', sha256: 'SHA-256', sha384: 'SHA-384', sha512: 'SHA-512' } + const hex = await digestHex(der, algMap[algorithm] || 'SHA-1') + return hexWithColons(hex) } /** - * Get Subject Alternative Names from certificate - * @param {Object} cert - Certificate object (X509 from jsrsasign) - * @returns {Array} Array of SAN strings + * Format SAN entries from a certificate as a flat array of strings, e.g. + * ['DNS: example.com', 'IP: 10.0.0.1', 'Email: a@b.com']. + * @param {pkijs.Certificate} cert */ export function getSANs(cert) { - try { - const sans = cert.getExtSubjectAltName() - if (!sans || !Array.isArray(sans)) { - return [] - } - - // jsrsasign returns array of arrays: [[type, value], ...] - // type: 2=DNS, 7=IP, 1=Email - return sans.map((sanEntry) => { - const [type, value] = Array.isArray(sanEntry) ? sanEntry : [sanEntry.type, sanEntry.value] - switch (type) { - case 2: return 'DNS: ' + value - case 7: return 'IP: ' + value - case 1: return 'Email: ' + value - default: return 'Other: ' + value - } - }) - } catch (error) { + try { + const sanExt = findExtensionByOid(cert, OID.EXT_SUBJECT_ALT_NAME) + if (!sanExt || !sanExt.parsedValue) return [] + const grouped = decodeAltNames(sanExt.parsedValue.altNames) + const out = [] + grouped.dns.forEach(v => out.push(`DNS: ${v}`)) + grouped.ip.forEach(v => out.push(`IP: ${v}`)) + grouped.email.forEach(v => out.push(`Email: ${v}`)) + grouped.uri.forEach(v => out.push(`URI: ${v}`)) + grouped.dn.forEach(v => out.push(`DN: ${v}`)) + return out + } catch { return [] } } /** - * Get key size from certificate - * @param {Object} cert - Certificate object (X509 from jsrsasign) - * @returns {number|string} Key size in bits or 'Unknown' + * Get key size in bits or 'Unknown' for a certificate's public key. + * @param {pkijs.Certificate} cert */ export function getKeySize(cert) { - try { - const pubKeyPem = cert.getPublicKey() - const pubKeyObj = KEYUTIL.getKey(pubKeyPem) - - if (pubKeyObj) { - // For RSA - if (pubKeyObj.n) { - return pubKeyObj.n.bitLength() - } - // For EC - if (pubKeyObj.curve) { - // Map curve names to bit sizes - const curveMap = { - 'secp256r1': 256, - 'secp384r1': 384, - 'secp521r1': 521, - 'secp256k1': 256, - 'prime256v1': 256, - 'P-256': 256, - 'P-384': 384, - 'P-521': 521, - } - return curveMap[pubKeyObj.curve] || 'Unknown' - } - } - } catch (error) { - // Error getting key size - return unknown - } - return 'Unknown' + const desc = describePublicKey(cert) + if (!desc) return 'Unknown' + return desc.bits || 'Unknown' } /** - * Get Distinguished Name as string - * @param {string} dnString - Distinguished Name string (from X509.getSubjectString() or getIssuerString()) - * @returns {string} Formatted DN string - */ -function getDistinguishedName(dnString) { - // Already a string from jsrsasign, just return it - return dnString || 'Unknown' -} - -/** - * Get public key fingerprint - * @param {Object} publicKey - Public key object (from KEYUTIL) - * @returns {string} SHA-256 fingerprint - */ -function getPublicKeyFingerprint(publicKey) { - try { - // Convert public key to PEM and then to hex for hashing - const pubKeyPem = KEYUTIL.getPEM(publicKey) - // Remove PEM headers and whitespace, then convert base64 to hex - const base64Content = pubKeyPem - .replace(/-----BEGIN PUBLIC KEY-----/g, '') - .replace(/-----END PUBLIC KEY-----/g, '') - .replace(/\s/g, '') - - // Convert base64 to hex - const hexContent = KJUR.crypto.Util.b64toHex(base64Content) - const fingerprint = KJUR.crypto.Util.hashHex(hexContent, 'sha256') - return fingerprint - } catch (error) { - // Fallback: use key object properties - try { - let keyHex = '' - if (publicKey.n && publicKey.e) { - // RSA key - keyHex = publicKey.n.toString(16) + publicKey.e.toString(16) - } else if (publicKey.x && publicKey.y) { - // EC key - keyHex = publicKey.x.toString(16) + publicKey.y.toString(16) - } - return KJUR.crypto.Util.hashHex(keyHex, 'sha256') - } catch (e) { - return '' - } - } -} - -/** - * Get private key fingerprint (by extracting public key components) - * @param {Object} privateKey - Private key object (from KEYUTIL) - * @returns {string} SHA-256 fingerprint - */ -function getPrivateKeyFingerprint(privateKey) { - try { - // Extract public key components from private key - let publicKeyComponents = null - - if (privateKey.n && privateKey.e) { - // RSA key - extract public components - publicKeyComponents = new RSAKey() - publicKeyComponents.setPublic(privateKey.n, privateKey.e) - } else if (privateKey.curve && privateKey.x && privateKey.y) { - // EC key - extract public point - publicKeyComponents = new KJUR.crypto.ECDSA({ curve: privateKey.curve, pub: { x: privateKey.x, y: privateKey.y } }) - } - - if (publicKeyComponents) { - return getPublicKeyFingerprint(publicKeyComponents) - } - - // Fallback: use private key PEM directly - const privateKeyPem = KEYUTIL.getPEM(privateKey, 'PKCS8PRV') - const base64Content = privateKeyPem - .replace(/-----BEGIN PRIVATE KEY-----/g, '') - .replace(/-----END PRIVATE KEY-----/g, '') - .replace(/\s/g, '') - const hexContent = KJUR.crypto.Util.b64toHex(base64Content) - return KJUR.crypto.Util.hashHex(hexContent, 'sha256') - } catch (error) { - return '' - } -} - -/** - * Get subject field value from certificate - * @param {Object} cert - Certificate object (X509 from jsrsasign) - * @param {string} field - Field name (CN, O, C, etc.) - * @param {string} type - 'subject' or 'issuer' - * @returns {string} Field value or 'Not specified' + * Pull a single field (e.g. CN) out of the cert's subject or issuer DN. + * @param {pkijs.Certificate} cert + * @param {string} field + * @param {'subject'|'issuer'} type */ export function getSubjectField(cert, field, type = 'subject') { - const dnString = type === 'subject' ? cert.getSubjectString() : cert.getIssuerString() + const dnString = type === 'subject' ? rdnToString(cert.subject) : rdnToString(cert.issuer) const attrs = parseDNString(dnString) return attrs[field] || 'Not specified' } /** - * Comprehensive certificate verification - * @param {string} certText - PEM certificate text - * @param {string} keyText - Optional PEM private key text - * @returns {Object} Complete verification results + * Top-level orchestrator: parse cert text, validate the chain, optionally + * validate a private key against the leaf, and return the result shape the UI + * components expect. + * + * @param {string} certText + * @param {string|null} keyText */ -export function verifyCertificate(certText, keyText = null) { +export async function verifyCertificate(certText, keyText = null) { try { - // First, validate certificate format if (!certText || !certText.trim()) { - return { - success: false, - error: 'Invalid certificate. Make sure the file is a .pem.' - } + return { success: false, error: 'Invalid certificate. Make sure the file is a .pem.' } } - if (!isValidPemCertificate(certText)) { - return { - success: false, - error: 'Invalid certificate. Make sure the file is a .pem.' - } + return { success: false, error: 'Invalid certificate. Make sure the file is a .pem.' } } - - // If private key is provided, validate its format first - if (keyText && keyText.trim()) { - if (!isValidPemPrivateKey(keyText)) { - return { - success: false, - error: 'Invalid private key. Make sure the file is a .key.' - } - } + if (keyText && keyText.trim() && !isValidPemPrivateKey(keyText)) { + return { success: false, error: 'Invalid private key. Make sure the file is a .key.' } } - // Parse certificates const certificates = parseCertificateChain(certText) - if (certificates.length === 0) { - return { - success: false, - error: 'Invalid certificate. Make sure the file is a .pem.' - } + return { success: false, error: 'Invalid certificate. Make sure the file is a .pem.' } } - // Get certificate details - const primaryCert = certificates[0].cert - const notBefore = convertX509TimeToDate(primaryCert.getNotBefore()) - const notAfter = convertX509TimeToDate(primaryCert.getNotAfter()) - - // Get signature algorithm - const sigAlg = primaryCert.getSignatureAlgorithmName() || 'Unknown' - - // Get public key algorithm - const pubKeyPem = primaryCert.getPublicKey() - const pubKeyObj = KEYUTIL.getKey(pubKeyPem) - let pubKeyAlg = 'RSA' - if (pubKeyObj) { - if (pubKeyObj.curve) { - pubKeyAlg = 'ECDSA' - } else if (pubKeyObj.alg && pubKeyObj.alg.includes('ECDSA')) { - pubKeyAlg = 'ECDSA' - } - } - + const primary = certificates[0].cert + const primaryDer = certificates[0].der + const notBefore = primary.notBefore?.value + const notAfter = primary.notAfter?.value + + const sigAlg = sigAlgorithmName(primary.signatureAlgorithm?.algorithmId) + const keyDesc = describePublicKey(primary) + const pubKeyAlg = keyDesc?.type === 'EC' ? 'ECDSA' : (keyDesc?.type === 'RSA' ? 'RSA' : 'Unknown') + + const serial = bytesToHex(primary.serialNumber.valueBlock.valueHexView) + || primary.serialNumber.valueBlock.valueDec + const certDetails = { - subject: getSubjectField(primaryCert, 'CN'), - issuer: getSubjectField(primaryCert, 'CN', 'issuer'), - serialNumber: primaryCert.getSerialNumberHex() || primaryCert.getSerialNumber(), + subject: getSubjectField(primary, 'CN'), + issuer: getSubjectField(primary, 'CN', 'issuer'), + serialNumber: serial, validFrom: notBefore ? notBefore.toISOString() : 'Unknown', validTo: notAfter ? notAfter.toISOString() : 'Unknown', - status: getCertStatus(primaryCert), + status: getCertStatus(primary), signatureAlgorithm: sigAlg, publicKeyAlgorithm: pubKeyAlg, - keySize: getKeySize(primaryCert), - fingerprintSha1: getFingerprint(primaryCert, 'sha1'), - fingerprintSha256: getFingerprint(primaryCert, 'sha256'), - sans: getSANs(primaryCert) + keySize: getKeySize(primary), + fingerprintSha1: await getFingerprint(primaryDer, 'sha1'), + fingerprintSha256: await getFingerprint(primaryDer, 'sha256'), + sans: getSANs(primary), } - // Validate certificate chain - const chainValidation = validateCertificateChain(certificates) + const chainValidation = await validateCertificateChain(certificates) - // Validate private key if provided let keyValidation = null if (keyText && keyText.trim()) { - keyValidation = validatePrivateKey(certificates[0], keyText) - - // Check if private key matches certificate + keyValidation = await validatePrivateKey(certificates[0], keyText) if (!keyValidation || !keyValidation.isValid) { - // Private key format is already validated above, so if validation fails here, it's a mismatch return { success: false, certificates, certDetails, chainValidation, keyValidation, - error: 'Certificate does not match private key.' + error: 'Certificate does not match private key.', } } } - // Determine overall success based on validation results const chainValid = chainValidation && chainValidation.isValid const keyValid = !keyText || !keyText.trim() || (keyValidation && keyValidation.isValid) const overallSuccess = chainValid && keyValid @@ -547,26 +354,22 @@ export function verifyCertificate(certText, keyText = null) { chainValidation, keyValidation, chainDetails: certificates.length > 1 ? certificates.map((certObj, index) => { - const certNotBefore = convertX509TimeToDate(certObj.cert.getNotBefore()) - const certNotAfter = convertX509TimeToDate(certObj.cert.getNotAfter()) + const certNotBefore = certObj.cert.notBefore?.value + const certNotAfter = certObj.cert.notAfter?.value return { index: index + 1, type: index === 0 ? 'End Entity' : index === certificates.length - 1 ? 'Root CA' : 'Intermediate CA', subject: getSubjectField(certObj.cert, 'CN'), issuer: getSubjectField(certObj.cert, 'CN', 'issuer'), validFrom: certNotBefore ? certNotBefore.toDateString() : 'Unknown', - validTo: certNotAfter ? certNotAfter.toDateString() : 'Unknown' + validTo: certNotAfter ? certNotAfter.toDateString() : 'Unknown', } }) : null, - error: !overallSuccess ? - (!chainValid ? 'Certificate chain validation failed' : 'Private key validation failed') : - null + error: !overallSuccess + ? (!chainValid ? 'Certificate chain validation failed' : 'Private key validation failed') + : null, } - } catch (error) { - return { - success: false, - error: `Error parsing certificate: ${error.message}` - } + return { success: false, error: `Error parsing certificate: ${error.message}` } } }