| /** |
| * Copyright (c) HashiCorp, Inc. |
| * SPDX-License-Identifier: MPL-2.0 |
| */ |
| |
| import * as asn1js from 'asn1js'; |
| import { fromBase64, stringToArrayBuffer } from 'pvutils'; |
| import { Certificate } from 'pkijs'; |
| import { differenceInHours, getUnixTime } from 'date-fns'; |
| import { |
| EXTENSION_OIDs, |
| OTHER_OIDs, |
| KEY_USAGE_BITS, |
| SAN_TYPES, |
| SIGNATURE_ALGORITHM_OIDs, |
| SUBJECT_OIDs, |
| } from './parse-pki-cert-oids'; |
| |
| /* |
| It may be helpful to visualize a certificate's SEQUENCE structure alongside this parsing file. |
| You can do so by decoding a certificate here: https://lapo.it/asn1js/# |
| |
| A certificate is encoded in ASN.1 data - a SEQUENCE is how you define structures in ASN.1. |
| GeneralNames, Extension, AlgorithmIdentifier are all examples of SEQUENCEs |
| |
| * Error handling: |
| { can_parse: false } -> returned if the external library cannot convert the certificate |
| { parsing_errors: [] } -> returned if the certificate was converted, but there's ANY problem parsing certificate details. |
| This means we cannot cross-sign in the UI and prompt the user to do so manually using the CLI. |
| */ |
| |
| export function jsonToCertObject(jsonString) { |
| const cert_base64 = jsonString.replace(/(-----(BEGIN|END) CERTIFICATE-----|\n)/g, ''); |
| const cert_der = fromBase64(cert_base64); |
| const cert_asn1 = asn1js.fromBER(stringToArrayBuffer(cert_der)); |
| return new Certificate({ schema: cert_asn1.result }); |
| } |
| |
| export function parseCertificate(certificateContent) { |
| let cert; |
| try { |
| cert = jsonToCertObject(certificateContent); |
| } catch (error) { |
| console.debug('DEBUG: Converting Certificate', error); // eslint-disable-line |
| return { can_parse: false }; |
| } |
| |
| let parsedCertificateValues; |
| try { |
| const subjectValues = parseSubject(cert?.subject?.typesAndValues); |
| const extensionValues = parseExtensions(cert?.extensions); |
| const [signature_bits, use_pss] = mapSignatureBits(cert?.signatureAlgorithm); |
| const formattedValues = formatValues(subjectValues, extensionValues); |
| parsedCertificateValues = { ...formattedValues, signature_bits, use_pss }; |
| } catch (error) { |
| console.debug('DEBUG: Parsing Certificate', error); // eslint-disable-line |
| parsedCertificateValues = { parsing_errors: [new Error('error parsing certificate values')] }; |
| } |
| |
| const expiryDate = cert?.notAfter?.value; |
| const issueDate = cert?.notBefore?.value; |
| const ttl = `${differenceInHours(expiryDate, issueDate)}h`; |
| |
| return { |
| ...parsedCertificateValues, |
| can_parse: true, |
| not_valid_after: getUnixTime(expiryDate), |
| not_valid_before: getUnixTime(issueDate), |
| ttl, |
| }; |
| } |
| |
| export function parsePkiCert(model) { |
| // model has to be the responseJSON from PKI serializer |
| // return if no certificate or if the "certificate" is actually a CRL |
| if (!model.certificate || model.certificate.includes('BEGIN X509 CRL')) { |
| return; |
| } |
| return parseCertificate(model.certificate); |
| } |
| |
| export function formatValues(subject, extension) { |
| if (!subject || !extension) { |
| return { parsing_errors: [new Error('error formatting certificate values')] }; |
| } |
| const { subjValues, subjErrors } = subject; |
| const { extValues, extErrors } = extension; |
| const parsing_errors = [...subjErrors, ...extErrors]; |
| const exclude_cn_from_sans = |
| extValues.alt_names?.length > 0 && !extValues.alt_names?.includes(subjValues?.common_name) ? true : false; |
| // now that we've finished parsing data, join all extension arrays |
| for (const ext in extValues) { |
| if (Array.isArray(extValues[ext])) { |
| extValues[ext] = extValues[ext].length !== 0 ? extValues[ext].join(', ') : null; |
| } |
| } |
| |
| return { |
| ...subjValues, |
| ...extValues, |
| parsing_errors, |
| exclude_cn_from_sans, |
| }; |
| } |
| |
| /* |
| Explanation of cross-signing and how to use the verify function: |
| (See setup script here: https://github.com/hashicorp/vault-tools/blob/main/vault-ui/pki/pki-cross-sign-config.sh) |
| 1. A trust chain exists between "old-parent-issuer-name" -> "old-intermediate" |
| 2. We create a new root, "my-parent-issuer-name" to phase out the old one |
| |
| * cross-signing step performed in the UI * |
| 3. Cross-sign "old-intermediate" against new root "my-parent-issuer-name" which generates a new intermediate issuer, |
| "newly-cross-signed-int-name", to phase out the old intermediate |
| |
| * validate cross-signing accurately copied the old intermediate issuer * |
| 4. Generate a leaf certificate from "newly-cross-signed-int-name", let's call it "baby-leaf" |
| 5. Verify that "baby-leaf" validates against both chains: |
| "old-parent-issuer-name" -> "old-intermediate" -> "baby-leaf" |
| "my-parent-issuer-name" -> "newly-cross-signed-int-name" -> "baby-leaf" |
| |
| We're just concerned with the link between the leaf and both intermediates |
| to confirm the UI performed the cross-sign correctly |
| (which already assumes the link between each parent and intermediate is valid) |
| |
| verifyCertificates(oldIntermediate, crossSignedCert, leaf) |
| |
| */ |
| export async function verifyCertificates(certA, certB, leaf) { |
| const parsedCertA = jsonToCertObject(certA); |
| const parsedCertB = jsonToCertObject(certB); |
| if (leaf) { |
| const parsedLeaf = jsonToCertObject(leaf); |
| const chainA = await verifySignature(parsedCertA, parsedLeaf); |
| const chainB = await verifySignature(parsedCertB, parsedLeaf); |
| // the leaf's issuer should be equal to the subject data of the intermediate certs |
| const isEqualA = parsedLeaf.issuer.isEqual(parsedCertA.subject); |
| const isEqualB = parsedLeaf.issuer.isEqual(parsedCertB.subject); |
| return chainA && chainB && isEqualA && isEqualB; |
| } |
| // can be used to validate if a certificate is self-signed (i.e. a root cert), by passing it as both certA and B |
| return (await verifySignature(parsedCertA, parsedCertB)) && parsedCertA.issuer.isEqual(parsedCertB.subject); |
| } |
| |
| export async function verifySignature(parent, child) { |
| try { |
| return await child.verify(parent); |
| } catch (error) { |
| // ed25519 is an unsupported signature algorithm and so verify() errors |
| // SKID (subject key ID) is the byte array of the key identifier |
| // AKID (authority key ID) is a SEQUENCE-type extension that includes the key identifier and potentially other information. |
| const skidExtension = parent.extensions.find((ext) => ext.extnID === OTHER_OIDs.subject_key_identifier); |
| const akidExtension = parent.extensions.find((ext) => ext.extnID === OTHER_OIDs.authority_key_identifier); |
| // return false if either extension is missing |
| // this could mean a false-negative but that's okay for our use-case |
| if (!skidExtension || !akidExtension) return false; |
| const skid = new Uint8Array(skidExtension.parsedValue.valueBlock.valueHex); |
| const akid = new Uint8Array(akidExtension.extnValue.valueBlock.valueHex); |
| // Check that AKID includes the SKID, which saves us from parsing the AKID and is unlikely to return false-positives. |
| return akid.toString().includes(skid.toString()); |
| } |
| } |
| |
| //* PARSING HELPERS |
| /* |
| We wish to get each SUBJECT_OIDs (see utils/parse-pki-cert-oids.js) out of this certificate's subject. |
| A subject is a list of RDNs, where each RDN is a (type, value) tuple |
| and where a type is an OID. The OID for CN can be found here: |
| |
| https://datatracker.ietf.org/doc/html/rfc5280#page-112 |
| |
| Each value is then encoded as another ASN.1 object; in the case of a |
| CommonName field, this is usually a PrintableString, BMPString, or a |
| UTF8String. Regardless of encoding, it should be present in the |
| valueBlock's value field if it is renderable. |
| */ |
| export function parseSubject(subject) { |
| if (!subject) return null; |
| const values = {}; |
| const errors = []; |
| |
| const isUnexpectedSubjectOid = (rdn) => !Object.values(SUBJECT_OIDs).includes(rdn.type); |
| if (subject.any(isUnexpectedSubjectOid)) { |
| const unknown = subject.filter(isUnexpectedSubjectOid).map((rdn) => rdn.type); |
| errors.push(new Error('certificate contains unsupported subject OIDs: ' + unknown.join(', '))); |
| } |
| |
| const returnValues = (OID) => { |
| const values = subject.filter((rdn) => rdn?.type === OID).map((rdn) => rdn?.value?.valueBlock?.value); |
| // Theoretically, there might be multiple (or no) CommonNames -- but Vault |
| // presently refuses to issue certificates without CommonNames in most |
| // cases. For now, return the first CommonName we find. Alternatively, we |
| // might update our callers to handle multiple and return a string array |
| return values ? (values?.length ? values[0] : null) : null; |
| }; |
| Object.keys(SUBJECT_OIDs).forEach((key) => (values[key] = returnValues(SUBJECT_OIDs[key]))); |
| return { subjValues: values, subjErrors: errors }; |
| } |
| |
| export function parseExtensions(extensions) { |
| if (!extensions) return null; |
| const values = {}; |
| const errors = []; |
| const allowedOids = Object.values({ ...EXTENSION_OIDs, ...OTHER_OIDs }); |
| const isUnknownExtension = (ext) => !allowedOids.includes(ext.extnID); |
| if (extensions.any(isUnknownExtension)) { |
| const unknown = extensions.filter(isUnknownExtension).map((ext) => ext.extnID); |
| errors.push(new Error('certificate contains unsupported extension OIDs: ' + unknown.join(', '))); |
| } |
| |
| // make each extension its own key/value pair |
| for (const attrName in EXTENSION_OIDs) { |
| values[attrName] = extensions.find((ext) => ext.extnID === EXTENSION_OIDs[attrName])?.parsedValue; |
| } |
| |
| if (values.subject_alt_name) { |
| // we only support SANs of type 2 (altNames), 6 (uri) and 7 (ipAddress) |
| const supportedTypes = Object.values(SAN_TYPES); |
| const supportedNames = Object.keys(SAN_TYPES); |
| const sans = values.subject_alt_name?.altNames; |
| if (!sans) { |
| errors.push(new Error('certificate contains an unsupported subjectAltName construction')); |
| } else if (sans.any((san) => !supportedTypes.includes(san.type))) { |
| // pass along error that unsupported values exist |
| errors.push(new Error('subjectAltName contains unsupported types')); |
| // still check and parse any supported values |
| if (sans.any((san) => supportedTypes.includes(san.type))) { |
| supportedNames.forEach((attrName) => { |
| values[attrName] = sans |
| .filter((gn) => gn.type === Number(SAN_TYPES[attrName])) |
| .map((gn) => gn.value); |
| }); |
| } |
| } else if (sans.every((san) => supportedTypes.includes(san.type))) { |
| supportedNames.forEach((attrName) => { |
| values[attrName] = sans.filter((gn) => gn.type === Number(SAN_TYPES[attrName])).map((gn) => gn.value); |
| }); |
| } else { |
| errors.push(new Error('unsupported subjectAltName values')); |
| } |
| } |
| |
| // permitted_dns_domains |
| if (values.name_constraints) { |
| // we only support Name Constraints of dnsName (type 2), this value lives in the permittedSubtree of the Name Constraints sequence |
| // permittedSubtrees contain an array of subtree objects, each object has a 'base' key and EITHER a 'minimum' or 'maximum' key |
| // GeneralSubtree { "base": { "type": 2, "value": "dnsname1.com" }, minimum: 0 } |
| const nameConstraints = values.name_constraints; |
| if (Object.keys(nameConstraints).includes('excludedSubtrees')) { |
| errors.push(new Error('nameConstraints contains excludedSubtrees')); |
| } else if (nameConstraints.permittedSubtrees.any((subtree) => subtree.minimum !== 0)) { |
| errors.push(new Error('nameConstraints permittedSubtree contains non-zero minimums')); |
| } else if (nameConstraints.permittedSubtrees.any((subtree) => subtree.maximum)) { |
| errors.push(new Error('nameConstraints permittedSubtree contains maximum')); |
| } else if (nameConstraints.permittedSubtrees.any((subtree) => subtree.base.type !== 2)) { |
| errors.push(new Error('nameConstraints permittedSubtree can only contain dnsName (type 2)')); |
| // still check and parse any supported values |
| if (nameConstraints.permittedSubtrees.any((subtree) => subtree.base.type === 2)) { |
| values.permitted_dns_domains = nameConstraints.permittedSubtrees |
| .filter((gn) => gn.base.type === 2) |
| .map((gn) => gn.base.value); |
| } |
| } else if (nameConstraints.permittedSubtrees.every((subtree) => subtree.base.type === 2)) { |
| values.permitted_dns_domains = nameConstraints.permittedSubtrees.map((gn) => gn.base.value); |
| } else { |
| errors.push(new Error('unsupported nameConstraints values')); |
| } |
| } |
| |
| if (values.basic_constraints) { |
| values.max_path_length = values.basic_constraints?.pathLenConstraint; |
| } |
| |
| if (values.ip_sans) { |
| const parsed_ips = []; |
| for (const ip_san of values.ip_sans) { |
| const unused = ip_san.valueBlock.unusedBits; |
| if (unused !== undefined && unused !== null && unused !== 0) { |
| errors.push(new Error('unsupported ip_san value: non-zero unused bits in encoding')); |
| continue; |
| } |
| |
| const ip = new Uint8Array(ip_san.valueBlock.valueHex); |
| |
| // Length of the IP determines the type: 4 bytes for IPv4, 16 bytes for |
| // IPv6. |
| if (ip.length === 4) { |
| const ip_addr = ip.join('.'); |
| parsed_ips.push(ip_addr); |
| } else if (ip.length === 16) { |
| const src = new Array(...ip); |
| const hex = src.map((value) => '0' + new Number(value).toString(16)); |
| const trimmed = hex.map((value) => value.slice(value.length - 2, 3)); |
| // add a colon after every other number (those with an odd index) |
| let ip_addr = trimmed.map((value, index) => (index % 2 === 0 ? value : value + ':')).join(''); |
| // Remove trailing :, if any. |
| ip_addr = ip_addr.slice(-1) === ':' ? ip_addr.slice(0, -1) : ip_addr; |
| parsed_ips.push(ip_addr); |
| } else { |
| errors.push( |
| new Error( |
| 'unsupported ip_san value: unknown IP address size (should be 4 or 16 bytes, was ' + |
| parseInt(ip.length / 2) + |
| ')' |
| ) |
| ); |
| } |
| } |
| values.ip_sans = parsed_ips; |
| } |
| |
| if (values.key_usage) { |
| // KeyUsage is a big-endian bit-packed enum. Unused right-most bits are |
| // truncated. So, a KeyUsage with CertSign+CRLSign would be "000001100", |
| // with the right two bits truncated, and packed into an 8-bit, one-byte |
| // string ("00000011"), introducing a leading zero. unused indicates that |
| // this bit can be discard, shifting our result over by one, to go back |
| // to its original form (minus trailing zeros). |
| // |
| // We can thus take our enumeration (KEY_USAGE_BITS), check whether the |
| // bits are asserted, and push in our pretty names as appropriate. |
| const unused = values.key_usage.valueBlock.unusedBits; |
| const keyUsage = new Uint8Array(values.key_usage.valueBlock.valueHex); |
| |
| const computedKeyUsages = []; |
| for (const enumIndex in KEY_USAGE_BITS) { |
| // May span two bytes. |
| const byteIndex = parseInt(enumIndex / 8); |
| const bitIndex = parseInt(enumIndex % 8); |
| const enumName = KEY_USAGE_BITS[enumIndex]; |
| const mask = 1 << (8 - bitIndex); // Big endian. |
| if (byteIndex >= keyUsage.length) { |
| // DecipherOnly is rare and would push into a second byte, but we |
| // don't have one so exit. |
| break; |
| } |
| |
| let enumByte = keyUsage[byteIndex]; |
| const needsAdjust = byteIndex + 1 === keyUsage.length && unused > 0; |
| if (needsAdjust) { |
| enumByte = parseInt(enumByte << unused); |
| } |
| |
| const isSet = (mask & enumByte) === mask; |
| if (isSet) { |
| computedKeyUsages.push(enumName); |
| } |
| } |
| |
| // Vault currently doesn't allow setting key_usage during issuer |
| // generation, but will allow it if it comes in via an externally |
| // generated CSR. Validate that key_usage matches expectations and |
| // prune accordingly. |
| const expectedUsages = ['CertSign', 'CRLSign']; |
| const isUnexpectedKeyUsage = (ext) => !expectedUsages.includes(ext); |
| |
| if (computedKeyUsages.any(isUnexpectedKeyUsage)) { |
| const unknown = computedKeyUsages.filter(isUnexpectedKeyUsage); |
| errors.push(new Error('unsupported key usage value on issuer certificate: ' + unknown.join(', '))); |
| } |
| |
| values.key_usage = computedKeyUsages; |
| } |
| |
| if (values.other_sans) { |
| // We need to parse these into their server-side values. |
| const parsed_sans = []; |
| for (const san of values.other_sans) { |
| let [objectId, constructed] = san.valueBlock.value; |
| objectId = objectId.toJSON().valueBlock.value; |
| constructed = constructed.valueBlock.value[0].toJSON(); // can I just grab the first element here? |
| const { blockName } = constructed; |
| const value = constructed.valueBlock.value; |
| parsed_sans.push(`${objectId};${blockName.replace('String', '')}:${value}`); |
| } |
| values.other_sans = parsed_sans; |
| } |
| |
| delete values.subject_alt_name; |
| delete values.basic_constraints; |
| delete values.name_constraints; |
| return { extValues: values, extErrors: errors }; |
| /* |
| values is an object with keys from EXTENSION_OIDs and SAN_TYPES |
| values = { |
| "alt_names": string[], |
| "uri_sans": string[], |
| "permitted_dns_domains": string[], |
| "max_path_length": int, |
| "key_usage": ['CertSign', 'CRLSign'], |
| "ip_sans": ['192.158.1.38', '1234:fd2:5621:1:89::4500'], |
| } |
| */ |
| } |
| |
| function mapSignatureBits(sigAlgo) { |
| const { algorithmId } = sigAlgo; |
| |
| // use_pss is true, additional OIDs need to be mapped |
| if (algorithmId === '1.2.840.113549.1.1.10') { |
| // object identifier for PSS is very nested |
| const objId = sigAlgo.algorithmParams?.valueBlock?.value[0]?.valueBlock?.value[0]?.valueBlock?.value[0] |
| .toString() |
| .split(' : ')[1]; |
| return [SIGNATURE_ALGORITHM_OIDs[algorithmId][objId], true]; |
| } |
| return [SIGNATURE_ALGORITHM_OIDs[algorithmId], false]; |
| } |