/** * Copyright (c) HashiCorp, Inc. * SPDX-License-Identifier: MPL-2.0 */ import Component from '@glimmer/component'; import { action } from '@ember/object'; import { task } from 'ember-concurrency'; import { inject as service } from '@ember/service'; import { tracked } from '@glimmer/tracking'; import errorMessage from 'vault/utils/error-message'; import { waitFor } from '@ember/test-waiters'; import { parseCertificate } from 'vault/utils/parse-pki-cert'; /** * @module PkiIssuerCrossSign * PkiIssuerCrossSign components render from a parent issuer's details page to cross-sign an intermediate issuer (from a different mount). * The component reads an existing intermediate issuer, cross-signs it with a parent issuer and imports the new * issuer into the existing intermediate mount using three inputs from the user: * intermediateMount (the mount path where the issuer to be cross signed lives) * intermediateIssuer (the name of the intermediate issuer, located in the above mount) * newCrossSignedIssuer (the name of the to-be-cross-signed, new issuer) * * The requests involved and how those inputs are used: * 1. Read an existing intermediate issuer * -> GET /:intermediateMount/issuer/:intermediateIssuer * 2. Create a new CSR based on this existing issuer ID * -> POST /:intermediateMount/intermediate/generate/existing * 3. Sign it with the new parent issuer, minting a new certificate. * -> POST /this.args.parentIssuer.backend/issuer/this.args.parentIssuer.issuerRef/sign-intermediate * 4. Import it back into the existing mount * -> POST /:intermediateMount/issuers/import/bundle * 5. Read the imported issuer * -> GET /:intermediateMount/issuer/:issuer_id * 6. Update this issuer with the newCrossSignedIssuer * -> POST /:intermediateMount/issuer/:issuer_id * * @example * ```js * * ``` * @param {object} parentIssuer - the model of the issuing certificate that will sign the issuer to-be cross-signed */ export default class PkiIssuerCrossSign extends Component { @service store; @tracked formData = []; @tracked signedIssuers = []; @tracked intermediateIssuers = {}; @tracked validationErrors = []; inputFields = [ { label: 'Mount path', key: 'intermediateMount', placeholder: 'Mount path', helpText: 'The mount in which your new certificate can be found.', }, { label: "Issuer's current name", key: 'intermediateIssuer', placeholder: 'Current issuer name', helpText: 'The API name of the previous intermediate which was cross-signed.', }, { label: 'New issuer name', key: 'newCrossSignedIssuer', placeholder: 'Enter a new issuer name', helpText: `This is your new issuer’s name in the API.`, }, ]; get statusCount() { const error = this.signedIssuers.filter((issuer) => issuer.hasError).length; const success = this.signedIssuers.length - error; return `${success} successful, ${error} ${error === 1 ? 'error' : 'errors'}`; } @task @waitFor *submit(e) { e.preventDefault(); this.signedIssuers = []; this.validationErrors = []; // Validate name input for new issuer does not already exist in mount for (let row = 0; row < this.formData.length; row++) { const { intermediateMount, newCrossSignedIssuer } = this.formData[row]; const issuers = yield this.store .query('pki/issuer', { backend: intermediateMount }) .then((resp) => resp.map(({ issuerName, issuerId }) => ({ issuerName, issuerId }))) .catch(() => []); // for cross-signing error handling we want to record the list of issuers before the process starts this.intermediateIssuers[intermediateMount] = issuers; this.validationErrors.addObject({ newCrossSignedIssuer: this.nameValidation(newCrossSignedIssuer, issuers), }); } if (this.validationErrors.any((row) => !row.newCrossSignedIssuer.isValid)) return; // iterate through submitted data and cross-sign each certificate for (let row = 0; row < this.formData.length; row++) { const { intermediateMount, intermediateIssuer, newCrossSignedIssuer } = this.formData[row]; try { // returns data from existing and newly cross-signed issuers // { intermediateIssuer: existingIssuer, newCrossSignedIssuer: crossSignedIssuer, intermediateMount: intMount } const data = yield this.crossSignIntermediate( intermediateMount, intermediateIssuer, newCrossSignedIssuer ); this.signedIssuers.addObject({ ...data, hasError: false }); } catch (error) { this.signedIssuers.addObject({ ...this.formData[row], hasError: errorMessage(error), hasUnsupportedParams: error.cause ? error.cause.map((e) => e.message).join(', ') : null, }); } } } @action async crossSignIntermediate(intMount, intName, newCrossSignedIssuer) { // 1. Fetch issuer we want to sign // What/Recovery: any failure is early enough that you can bail safely/normally. const existingIssuer = await this.store.queryRecord('pki/issuer', { backend: intMount, id: intName, }); // Return if user is attempting to self-sign issuer if (existingIssuer.issuerId === this.args.parentIssuer.issuerId) { throw new Error('Cross-signing a root issuer with itself must be performed manually using the CLI.'); } // Translate certificate values to API parameters to pass along: CSR -> Signed CSR -> Cross-Signed issuer // some of these values do not apply to a CSR, but pass anyway. If there is any issue parsing the certificate, // (ex. the certificate contains unsupported values) direct user to manually cross-sign via CLI const certData = parseCertificate(existingIssuer.certificate); if (certData.parsing_errors.length > 0) { throw new Error('Certificate must be manually cross-signed using the CLI.', { cause: certData.parsing_errors, }); } // 2. Create the new CSR // What/Recovery: any failure is early enough that you can bail safely/normally. const newCsr = await this.store .createRecord('pki/action', { keyRef: existingIssuer.keyId, commonName: existingIssuer.commonName, type: 'existing', ...certData, }) .save({ adapterOptions: { actionType: 'generate-csr', mount: intMount, useIssuer: false }, }) .then(({ csr }) => csr); // 3. Sign newCSR with correct parent to create cross-signed cert, "issuing" // an intermediate certificate. // What/Recovery: any failure is early enough that you can bail safely/normally. const signedCaChain = await this.store .createRecord('pki/action', { csr: newCsr, commonName: existingIssuer.commonName, ...certData, }) .save({ adapterOptions: { actionType: 'sign-intermediate', mount: this.args.parentIssuer.backend, issuerRef: this.args.parentIssuer.issuerRef, }, }) .then(({ caChain }) => caChain.join('\n')); // 4. Import the newly cross-signed cert to become an issuer // What/Recovery: // 1. Permission issue -> give the cert (`signedCaChain`) to the user, // let them import & name. (Issue you have is that you already issued // it (step 3) and so "undo" would mean revoking the cert, which // you might not have permissions to do either). // // 2. CRL rebuilding fails ("the CRL" in error message). Server returns // an error, we wanted the CRL rebuilt -- but the issuer was still // imported anyways. Only way to detect would be to do a list issuers // before and after. Recovery would be on the operator in this case; // reproduce the error and let them deal with it. // // End result: user should solve this issue, but we shouldn't undo anything // either. // // -> For 1 though, make sure to give the `signedCaChain` in the // error message for them. // -> For 2, you could list before and after to find the id of the // new issuer(s) so they can name them and fix any issues with // them. // // If its not a permissions error _and_ you did two lists, not finding // a new issuer... // // -> Unknown error. Could give them `signedCaChain` and serial of // the newly issued intermediate CA, so that they can do recovery // as they'd like. const issuerId = await this.store .createRecord('pki/action', { pemBundle: signedCaChain }) .save({ adapterOptions: { actionType: 'import', mount: intMount, useIssuer: true } }) .then((importedIssuer) => { return Object.keys(importedIssuer.mapping).find( // matching key is the issuer_id (key) => importedIssuer.mapping[key] === existingIssuer.keyId ); }) .catch((e) => { console.debug('CA_CHAIN \n', signedCaChain); // eslint-disable-line throw new Error(`${errorMessage(e)} See console for signed ca_chain data.`); }); // 5. Fetch issuer imported above by issuer_id, name and save // Recovery: cosmetic issue; can let the user deal with it. Usually // fails because the name is in use. // Pre-fix: list all issuers, check the desired name isn't either // an existing issuer_id or an issuer_name. const crossSignedIssuer = await this.store.queryRecord('pki/issuer', { backend: intMount, id: issuerId }); crossSignedIssuer.issuerName = newCrossSignedIssuer; await crossSignedIssuer.save({ adapterOptions: { mount: intMount } }); // 6. Return the data to our caller. return { intermediateIssuer: existingIssuer, newCrossSignedIssuer: crossSignedIssuer, intermediateMount: intMount, }; } @action reset() { this.signedIssuers = []; this.validationErrors = []; this.formData = []; } nameValidation(nameInput, existing) { if (existing.any((i) => i.issuerName === nameInput || i.issuerId === nameInput)) return { errors: [`Issuer reference '${nameInput}' already exists in this mount.`], isValid: false, }; return { errors: [], isValid: true }; } }