diff --git a/ui/app/utils/parse-pki-cert.js b/ui/app/utils/parse-pki-cert.js index 54dd8609e1..5aca3e7aea 100644 --- a/ui/app/utils/parse-pki-cert.js +++ b/ui/app/utils/parse-pki-cert.js @@ -104,20 +104,27 @@ export function formatValues(subject, extension) { } /* -How to use the verify function for cross-signing: +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. Cross-sign "old-intermediate" against "my-parent-issuer-name" creating a new certificate: "newly-cross-signed-int-name" -3. Generate a leaf certificate from "newly-cross-signed-int-name", let's call it "baby-leaf" -4. Verify that "baby-leaf" validates against both chains: +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" -A valid cross-signing would mean BOTH of the following return true: -verifyCertificates(oldParentCert, oldIntCert, leaf) -verifyCertificates(newParentCert, crossSignedCert, 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) -each arg is the JSON string certificate value */ export async function verifyCertificates(certA, certB, leaf) { const parsedCertA = jsonToCertObject(certA); @@ -131,7 +138,7 @@ export async function verifyCertificates(certA, certB, leaf) { const isEqualB = parsedLeaf.issuer.isEqual(parsedCertB.subject); return chainA && chainB && isEqualA && isEqualB; } - // can be used to validate if a certificate is self-signed, by passing it as both certA and B (i.e. a root cert) + // 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 parsedCertA.verify(parsedCertB)) && parsedCertA.issuer.isEqual(parsedCertB.subject); } diff --git a/ui/tests/acceptance/pki/pki-cross-sign-test.js b/ui/tests/acceptance/pki/pki-cross-sign-test.js new file mode 100644 index 0000000000..f20d9fc9c9 --- /dev/null +++ b/ui/tests/acceptance/pki/pki-cross-sign-test.js @@ -0,0 +1,111 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { module, test } from 'qunit'; +import { visit, click, fillIn, find } from '@ember/test-helpers'; +import { setupApplicationTest } from 'vault/tests/helpers'; +import { v4 as uuidv4 } from 'uuid'; + +import authPage from 'vault/tests/pages/auth'; +import logout from 'vault/tests/pages/logout'; +import enablePage from 'vault/tests/pages/settings/mount-secret-backend'; +import { runCommands } from 'vault/tests/helpers/pki/pki-run-commands'; +import { SELECTORS } from 'vault/tests/helpers/pki/pki-issuer-cross-sign'; +import { verifyCertificates } from 'vault/utils/parse-pki-cert'; +module('Acceptance | pki/pki cross sign', function (hooks) { + setupApplicationTest(hooks); + + hooks.beforeEach(async function () { + await authPage.login(); + this.parentMountPath = `parent-mount-${uuidv4()}`; + this.oldParentIssuerName = 'old-parent-issuer'; // old parent issuer we're transferring from + this.parentIssuerName = 'new-parent-issuer'; // issuer where cross-signing action will begin + this.intMountPath = `intermediate-mount-${uuidv4()}`; // first input box in cross-signing page + this.intIssuerName = 'my-intermediate-issuer'; // second input box in cross-signing page + this.newlySignedIssuer = 'my-newly-signed-int'; // third input + await enablePage.enable('pki', this.parentMountPath); + await enablePage.enable('pki', this.intMountPath); + + await runCommands([ + `write "${this.parentMountPath}/root/generate/internal" common_name="Long-Lived Root X1" ttl=8960h issuer_name="${this.oldParentIssuerName}"`, + `write "${this.parentMountPath}/root/generate/internal" common_name="Long-Lived Root X2" ttl=8960h issuer_name="${this.parentIssuerName}"`, + `write "${this.parentMountPath}/config/issuers" default="${this.parentIssuerName}"`, + ]); + }); + + hooks.afterEach(async function () { + // Cleanup engine + await runCommands([`delete sys/mounts/${this.intMountPath}`]); + await runCommands([`delete sys/mounts/${this.parentMountPath}`]); + await logout.visit(); + }); + + test('it cross-signs an issuer', async function (assert) { + // configure parent and intermediate mounts to make them cross-signable + await visit(`/vault/secrets/${this.intMountPath}/pki/configuration/create`); + await click(SELECTORS.configure.optionByKey('generate-csr')); + await fillIn(SELECTORS.inputByName('type'), 'internal'); + await fillIn(SELECTORS.inputByName('commonName'), 'Short-Lived Int R1'); + await click('[data-test-save]'); + const csr = find(SELECTORS.copyButton('CSR')).getAttribute('data-clipboard-text'); + await visit(`vault/secrets/${this.parentMountPath}/pki/issuers/${this.oldParentIssuerName}/sign`); + await fillIn(SELECTORS.inputByName('csr'), csr); + await fillIn(SELECTORS.inputByName('format'), 'pem_bundle'); + await click('[data-test-pki-sign-intermediate-save]'); + const pemBundle = find(SELECTORS.copyButton('CA Chain')) + .getAttribute('data-clipboard-text') + .replace(/,/, '\n'); + await visit(`vault/secrets/${this.intMountPath}/pki/configuration/create`); + await click(SELECTORS.configure.optionByKey('import')); + await click('[data-test-text-toggle]'); + await fillIn('[data-test-text-file-textarea]', pemBundle); + await click(SELECTORS.configure.importSubmit); + await visit(`vault/secrets/${this.intMountPath}/pki/issuers`); + await click('[data-test-is-default]'); + // name default issuer of intermediate + const oldIntIssuerId = find(SELECTORS.rowValue('Issuer ID')).innerText; + const oldIntCert = find(SELECTORS.copyButton('Certificate')).getAttribute('data-clipboard-text'); + await click(SELECTORS.details.configure); + await fillIn(SELECTORS.inputByName('issuerName'), this.intIssuerName); + await click('[data-test-save]'); + + // perform cross-sign + await visit(`vault/secrets/${this.parentMountPath}/pki/issuers/${this.parentIssuerName}/cross-sign`); + await fillIn(SELECTORS.objectListInput('intermediateMount'), this.intMountPath); + await fillIn(SELECTORS.objectListInput('intermediateIssuer'), this.intIssuerName); + await fillIn(SELECTORS.objectListInput('newCrossSignedIssuer'), this.newlySignedIssuer); + await click(SELECTORS.submitButton); + assert + .dom(`${SELECTORS.signedIssuerCol('intermediateMount')} a`) + .hasAttribute('href', `/ui/vault/secrets/${this.intMountPath}/pki/overview`); + assert + .dom(`${SELECTORS.signedIssuerCol('intermediateIssuer')} a`) + .hasAttribute('href', `/ui/vault/secrets/${this.intMountPath}/pki/issuers/${oldIntIssuerId}/details`); + + // get certificate data of newly signed issuer + await click(`${SELECTORS.signedIssuerCol('newCrossSignedIssuer')} a`); + const newIntCert = find(SELECTORS.copyButton('Certificate')).getAttribute('data-clipboard-text'); + + // verify cross-sign was accurate by creating a role to issue a leaf certificate + const myRole = 'some-role'; + await runCommands([ + `write ${this.intMountPath}/roles/${myRole} \ + issuer_ref=${this.newlySignedIssuer}\ + allow_any_name=true \ + max_ttl="720h"`, + ]); + await visit(`vault/secrets/${this.intMountPath}/pki/roles/${myRole}/generate`); + await fillIn(SELECTORS.inputByName('commonName'), 'my-leaf'); + await fillIn('[data-test-ttl-value="TTL"]', '3600'); + await click('[data-test-pki-generate-button]'); + const myLeafCert = find(SELECTORS.copyButton('Certificate')).getAttribute('data-clipboard-text'); + + // see comments in utils/parse-pki-cert.js for step-by-step explanation of of verifyCertificates method + assert.true( + await verifyCertificates(oldIntCert, newIntCert, myLeafCert), + 'the leaf certificate validates against both intermediate certificates' + ); + }); +}); diff --git a/ui/tests/helpers/pki/pki-issuer-cross-sign.js b/ui/tests/helpers/pki/pki-issuer-cross-sign.js new file mode 100644 index 0000000000..86700e9301 --- /dev/null +++ b/ui/tests/helpers/pki/pki-issuer-cross-sign.js @@ -0,0 +1,23 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { SELECTORS as CONFIGURE } from './pki-configure-create'; +import { SELECTORS as DETAILS } from './pki-issuer-details'; + +export const SELECTORS = { + objectListInput: (key, row = 0) => `[data-test-object-list-input="${key}-${row}"]`, + inputByName: (name) => `[data-test-input="${name}"]`, + addRow: '[data-test-object-list-add-button', + submitButton: '[data-test-cross-sign-submit]', + cancelButton: '[data-test-cross-sign-cancel]', + statusCount: '[data-test-cross-sign-status-count]', + signedIssuerRow: (row = 0) => `[data-test-info-table-row="${row}"]`, + signedIssuerCol: (attr) => `[data-test-info-table-column="${attr}"]`, + // for cross signing acceptance tests + configure: { ...CONFIGURE }, + details: { ...DETAILS }, + rowValue: (attr) => `[data-test-row-value="${attr}"]`, + copyButton: (attr) => `[data-test-value-div="${attr}"] [data-test-copy-button]`, +}; diff --git a/ui/tests/integration/components/pki/pki-issuer-cross-sign-test.js b/ui/tests/integration/components/pki/pki-issuer-cross-sign-test.js index ccb127b58a..a7e75ef91e 100644 --- a/ui/tests/integration/components/pki/pki-issuer-cross-sign-test.js +++ b/ui/tests/integration/components/pki/pki-issuer-cross-sign-test.js @@ -18,16 +18,8 @@ import { parentIssuerCert, unsupportedOids, } from 'vault/tests/helpers/pki/values'; +import { SELECTORS } from 'vault/tests/helpers/pki/pki-issuer-cross-sign'; -const SELECTORS = { - input: (key, row = 0) => `[data-test-object-list-input="${key}-${row}"]`, - addRow: '[data-test-object-list-add-button', - submitButton: '[data-test-cross-sign-submit]', - cancelButton: '[data-test-cross-sign-cancel]', - statusCount: '[data-test-cross-sign-status-count]', - signedIssuerRow: (row = 0) => `[data-test-info-table-row="${row}"]`, - signedIssuerCol: (attr) => `[data-test-info-table-column="${attr}"]`, -}; const FIELDS = [ { label: 'Mount path', @@ -190,7 +182,7 @@ module('Integration | Component | pki issuer cross sign', function (hooks) { }); // fill out form and submit for (const field of FIELDS) { - await fillIn(SELECTORS.input(field.key), this.testInputs[field.key]); + await fillIn(SELECTORS.objectListInput(field.key), this.testInputs[field.key]); } await click(SELECTORS.submitButton); @@ -248,15 +240,15 @@ module('Integration | Component | pki issuer cross sign', function (hooks) { // fill out form and submit for (const field of FIELDS) { - await fillIn(SELECTORS.input(field.key), this.testInputs[field.key]); + await fillIn(SELECTORS.objectListInput(field.key), this.testInputs[field.key]); } await click(SELECTORS.addRow); for (const field of FIELDS) { - await fillIn(SELECTORS.input(field.key, 1), nonexistentIssuer[field.key]); + await fillIn(SELECTORS.objectListInput(field.key, 1), nonexistentIssuer[field.key]); } await click(SELECTORS.addRow); for (const field of FIELDS) { - await fillIn(SELECTORS.input(field.key, 2), unsupportedCert[field.key]); + await fillIn(SELECTORS.objectListInput(field.key, 2), unsupportedCert[field.key]); } await click(SELECTORS.submitButton); @@ -296,7 +288,7 @@ module('Integration | Component | pki issuer cross sign', function (hooks) { // fill out form and submit for (const field of FIELDS) { - await fillIn(SELECTORS.input(field.key), this.testInputs[field.key]); + await fillIn(SELECTORS.objectListInput(field.key), this.testInputs[field.key]); } await click(SELECTORS.submitButton); @@ -329,7 +321,7 @@ module('Integration | Component | pki issuer cross sign', function (hooks) { }); // fill out form and submit for (const field of FIELDS) { - await fillIn(SELECTORS.input(field.key), this.testInputs[field.key]); + await fillIn(SELECTORS.objectListInput(field.key), this.testInputs[field.key]); } await click(SELECTORS.submitButton); assert.dom(SELECTORS.statusCount).hasText('Cross-signing complete (0 successful, 1 error)');