diff --git a/ui/app/components/transit-edit.hbs b/ui/app/components/transit-edit.hbs index 8b063b8c7c..8a48783240 100644 --- a/ui/app/components/transit-edit.hbs +++ b/ui/app/components/transit-edit.hbs @@ -10,36 +10,16 @@ {{#if (eq this.mode "create")}} - + {{else if (eq this.mode "edit")}} - + {{else if (eq this.mode "show")}} {{else}} diff --git a/ui/app/components/transit-edit.js b/ui/app/components/transit-edit.js index d81b3b8044..a8767d673f 100644 --- a/ui/app/components/transit-edit.js +++ b/ui/app/components/transit-edit.js @@ -45,25 +45,25 @@ export default Component.extend(FocusOnInsertMixin, { route: 'vault.cluster.secrets', }, { - label: this.key.backend, + label: this.form.data.backend, route: 'vault.cluster.secrets.backend.list-root', - model: this.key.backend, + model: this.form.data.backend, }, ]; if (this.mode === 'show') { return [ ...baseCrumbs, { - label: this.key.id, + label: this.form.data.id, }, ]; } else if (this.mode === 'edit') { return [ ...baseCrumbs, { - label: this.key.id, + label: this.form.data.id, route: 'vault.cluster.secrets.backend.show', - models: [this.key.backend, this.key.id], + models: [this.form.data.backend, this.form.data.id], query: { tab: 'details' }, }, { label: 'Edit' }, @@ -87,7 +87,7 @@ export default Component.extend(FocusOnInsertMixin, { get subtitle() { if (this.mode === 'create' || this.mode === 'edit') return ''; - return this.key?.id; + return this.form.data.id; }, waitForKeyUp: task(function* () { diff --git a/ui/app/components/transit-form-create.hbs b/ui/app/components/transit-form-create.hbs index 22c8c5f5b4..a8709c8099 100644 --- a/ui/app/components/transit-form-create.hbs +++ b/ui/app/components/transit-form-create.hbs @@ -3,141 +3,38 @@ SPDX-License-Identifier: BUSL-1.1 }} -
-
- + +
+ -
- - -
-
- -
-
- -
-
- -
-
-
-
-
- + {{#if (eq attr.name "auto_rotate_period")}} +
+ +
+ {{/if}} + + {{/each}} + +
+ + + - -
+
- {{#if - (or - (eq @key.type "aes128-gcm96") - (eq @key.type "aes256-gcm96") - (eq @key.type "chacha20-poly1305") - (eq @key.type "ed25519") - ) - }} -
-
- - -
-
- {{/if}} - {{#if (or (eq @key.type "aes128-gcm96") (eq @key.type "aes256-gcm96") (eq @key.type "chacha20-poly1305"))}} -
-
- - -
-
- {{/if}} -
-
- - - -
\ No newline at end of file diff --git a/ui/app/components/transit-form-create.ts b/ui/app/components/transit-form-create.ts new file mode 100644 index 0000000000..f0f115c5a9 --- /dev/null +++ b/ui/app/components/transit-form-create.ts @@ -0,0 +1,60 @@ +/** + * Copyright IBM Corp. 2016, 2025 + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Component from '@glimmer/component'; +import { service } from '@ember/service'; +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; +import { HTMLElementEvent } from 'vault/forms'; +import FlashMessageService from 'vault/services/flash-messages'; +import ApiService from 'vault/services/api'; +import RouterService from '@ember/routing/router-service'; +import TransitKeyForm from 'vault/forms/transit/key'; +import { ValidationMap } from 'vault/vault/app-types'; +import { task } from 'ember-concurrency'; + +interface Args { + form: TransitKeyForm; +} +export default class TransitFormCreate extends Component { + @service declare readonly router: RouterService; + @service declare readonly flashMessages: FlashMessageService; + @service declare readonly api: ApiService; + + @tracked errorBanner = ''; + @tracked modelValidations: ValidationMap | null = null; + + @action + handleAutoRotateChange(ttlObj: { enabled: boolean; goSafeTimeString: string }) { + const { data } = this.args.form; + if (ttlObj.enabled) { + data.auto_rotate_period = ttlObj.goSafeTimeString; + } else { + data.auto_rotate_period = '0s'; + } + } + + @task + *save(event: HTMLElementEvent) { + event.preventDefault(); + + try { + const { data, isValid, state } = this.args.form.toJSON(); + this.modelValidations = isValid ? null : state; + + if (isValid) { + yield this.api.secrets.transitCreateKey(data['name'] as string, data['backend'] as string, data); + + this.flashMessages.success('Key successfully created.'); + this.router.transitionTo('vault.cluster.secrets.backend.show', data['backend'], data['name'], { + queryParams: { tab: 'details' }, + }); + } + } catch (error) { + const { message } = yield this.api.parseError(error); + this.errorBanner = message; + } + } +} diff --git a/ui/app/components/transit-form-edit.hbs b/ui/app/components/transit-form-edit.hbs index 0b3f816ae9..0d620d5537 100644 --- a/ui/app/components/transit-form-edit.hbs +++ b/ui/app/components/transit-form-edit.hbs @@ -2,101 +2,85 @@ Copyright IBM Corp. 2016, 2025 SPDX-License-Identifier: BUSL-1.1 }} - -
+
- + -
-
- - -
-
-
- -
-
- -
-
- -
-
-

- The minimum decryption version required to reverse transformations performed with the cryptographic key. Results from - lower key versions may be rewrapped with the new key version using the - rewrap - action. -

-
-
- -
-
- -
-
-

- The minimum version of the key that can be used to encrypt plaintext, sign payloads, or generate HMACs. You will be - able to specify which version of the key to use for each of these actions. The value specified here must be greater - than or equal to that specified in the - Minimum Decryption Version - selection above. -

-
+ + {{#each @form.formFields as |attr|}} + + {{#if (eq attr.name "auto_rotate_period")}} +
+ +
+ {{/if}} + {{#if (eq attr.name "min_decryption_version")}} +
+
+ +
+
+

+ The minimum decryption version required to reverse transformations performed with the cryptographic key. Results + from lower key versions may be rewrapped with the new key version using the + rewrap + action. +

+ {{/if}} + {{#if (eq attr.name "min_encryption_version")}} +
+
+ +
+
+

+ The minimum version of the key that can be used to encrypt plaintext, sign payloads, or generate HMACs. You will + be able to specify which version of the key to use for each of these actions. The value specified here must be + greater than or equal to that specified in the + Minimum Decryption Version + selection above. +

+ {{/if}} +
+ {{/each}}
- {{#if @model.canUpdate}} + {{#if @capabilities.canUpdate}}
- +
{{/if}}
@@ -104,13 +88,13 @@ @text="Cancel" @color="secondary" @route="vault.cluster.secrets.backend.show" - @models={{array @key.backend @key.id}} + @models={{array @form.data.backend @form.data.id}} @query={{hash tab="details"}} />
- {{#if @model.canDelete}} - + {{#if @capabilities.canDelete}} + {{/if}}
\ No newline at end of file diff --git a/ui/app/components/transit-form-edit.ts b/ui/app/components/transit-form-edit.ts new file mode 100644 index 0000000000..5a43362c25 --- /dev/null +++ b/ui/app/components/transit-form-edit.ts @@ -0,0 +1,93 @@ +/** + * Copyright IBM Corp. 2016, 2025 + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Component from '@glimmer/component'; +import { service } from '@ember/service'; +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; +import ApiService from 'vault/services/api'; +import FlashMessageService from 'vault/services/flash-messages'; +import RouterService from '@ember/routing/router-service'; +import TransitKeyForm from 'vault/forms/transit/key'; +import { ValidationMap } from 'vault/vault/app-types'; +import { HTMLElementEvent } from 'vault/forms'; + +type TransitKeyConfig = { + name?: string; + backend?: string; + auto_rotate_period?: string; + min_decryption_version?: number; + min_encryption_version?: number; + deletion_allowed?: boolean; +}; + +interface Args { + form: TransitKeyForm; +} +export default class TransitFormEdit extends Component { + @service declare readonly router: RouterService; + @service declare readonly flashMessages: FlashMessageService; + @service declare readonly api: ApiService; + + @tracked errorBanner = ''; + @tracked modelValidations: ValidationMap | null = null; + + @action + handleAutoRotateChange(ttlObj: { enabled: boolean; goSafeTimeString: string }) { + const { data } = this.args.form; + if (ttlObj.enabled) { + data.auto_rotate_period = ttlObj.goSafeTimeString; + } else { + data.auto_rotate_period = '0s'; + } + } + + @action async deleteKey() { + const { backend, id } = this.args.form.data; + try { + await this.api.secrets.transitDeleteKey(id as string, backend as string); + this.flashMessages.success(`'${id}' key deleted.`); + this.router.transitionTo('vault.cluster.secrets.backend.list-root'); + } catch (error) { + const { message } = await this.api.parseError(error); + this.errorBanner = message; + } + } + + @action async save(event: HTMLElementEvent) { + event.preventDefault(); + + try { + const { data, isValid, state } = this.args.form.toJSON(); + this.modelValidations = isValid ? null : state; + + if (isValid) { + const { + name, + backend, + auto_rotate_period, + min_decryption_version, + min_encryption_version, + deletion_allowed, + }: TransitKeyConfig = data; + + await this.api.secrets.transitConfigureKey(name as string, backend as string, { + auto_rotate_period, + min_decryption_version, + min_encryption_version, + deletion_allowed, + }); + + this.flashMessages.success('Key successfully updated.'); + this.router.transitionTo('vault.cluster.secrets.backend.show', backend, name, { + queryParams: { tab: 'details' }, + }); + } + } catch (error) { + const { message } = await this.api.parseError(error); + this.errorBanner = message; + } + } +} diff --git a/ui/app/components/transit-form-show.hbs b/ui/app/components/transit-form-show.hbs index c6396de51e..23b347eb43 100644 --- a/ui/app/components/transit-form-show.hbs +++ b/ui/app/components/transit-form-show.hbs @@ -8,9 +8,9 @@
  • - {{#if (and (eq @tab "versions") @key.canRotate)}} + {{#if (and (eq @tab "versions") @capabilities.canRotate)}} {{/if}} {{#if (eq @mode "show")}} - {{#if (or @model.canUpdate @model.canDelete)}} - + {{#if (or @capabilities.canUpdate @capabilities.canDelete)}} + Edit key {{/if}} @@ -75,9 +75,9 @@ {{#if (eq @tab "actions")}}
    - {{#each @model.supportedActions as |supportedAction|}} + {{#each @form.data.supportedActions as |supportedAction|}}
    @@ -105,8 +105,10 @@ {{/each}}
    {{else if (eq @tab "versions")}} - {{#if (or (eq @key.type "aes256-gcm96") (eq @key.type "chacha20-poly1305") (eq @key.type "aes128-gcm96"))}} - {{#each-in @key.keys as |version creationTimestamp|}} + {{#if + (or (eq @form.data.type "aes256-gcm96") (eq @form.data.type "chacha20-poly1305") (eq @form.data.type "aes128-gcm96")) + }} + {{#each-in @form.data.keys as |version creationTimestamp|}}
    @@ -118,13 +120,13 @@
    - {{date-from-now creationTimestamp addSuffix=true}} + {{this.getTimestamp creationTimestamp}}
    - {{#if (coerce-eq @key.minDecryptionVersion version)}} + {{#if (coerce-eq @form.data.min_decryption_version version)}}

    Current minimum decryption version @@ -136,7 +138,7 @@

    {{/each-in}} {{else}} - {{#each-in @key.keys as |version meta|}} + {{#each-in @form.data.keys as |version meta|}}
    @@ -154,7 +156,7 @@
    - {{#if (coerce-eq @key.minDecryptionVersion version)}} + {{#if (coerce-eq @form.data.min_decryption_version version)}}

    Current minimum decryption version @@ -178,15 +180,15 @@ {{/each-in}} {{/if}} {{else}} - + - + - {{#if @key.derived}} - - + {{#if @form.data.derived}} + + {{/if}} {{/if}} \ No newline at end of file diff --git a/ui/app/components/transit-form-show.js b/ui/app/components/transit-form-show.js index b5c297fac6..388b5edac4 100644 --- a/ui/app/components/transit-form-show.js +++ b/ui/app/components/transit-form-show.js @@ -6,28 +6,30 @@ import Component from '@glimmer/component'; import { service } from '@ember/service'; import { action } from '@ember/object'; -import { buildWaiter } from '@ember/test-waiters'; -import errorMessage from 'vault/utils/error-message'; - -const waiter = buildWaiter('transit-form-show'); +import { dateFromNow } from 'core/helpers/date-from-now'; export default class TransitFormShow extends Component { - @service store; @service router; @service flashMessages; + @service api; @action async rotateKey() { - const waiterToken = waiter.beginAsync(); - const { backend, id } = this.args.key; + const { backend, id } = this.args.form.data; try { - await this.store.adapterFor('transit-key').keyAction('rotate', { backend, id }); + await this.api.secrets.transitRotateKey(id, backend, {}); this.flashMessages.success('Key rotated.'); // must refresh to see the updated versions, a model refresh does not trigger the change. await this.router.refresh(); } catch (e) { - this.flashMessages.danger(errorMessage(e)); - } finally { - waiter.endAsync(waiterToken); + const { message } = this.api.parseError(e); + this.flashMessages.danger(message); } } + + // Investigate - possibly a bug? + // api returns the same value for creation time as ember data (eg. 1633024800) - but the date isn't rendering the same (ie. ED model pipe returns correct time but api value returns as 56 years ago) + // not sure why the ED model data is taken as milliseconds and the api value is taken as seconds, but this is a workaround to get the correct time. + getTimestamp(time) { + return dateFromNow([time * 1000], { addSuffix: true }); + } } diff --git a/ui/app/components/transit-key-actions.js b/ui/app/components/transit-key-actions.js index 7ddfd6bd36..7f5ba04f3f 100644 --- a/ui/app/components/transit-key-actions.js +++ b/ui/app/components/transit-key-actions.js @@ -11,7 +11,6 @@ import { tracked } from '@glimmer/tracking'; import { task } from 'ember-concurrency'; import { waitFor } from '@ember/test-waiters'; import { encodeString } from 'vault/utils/b64'; -import errorMessage from 'vault/utils/error-message'; /** * @module TransitKeyActions @@ -90,9 +89,9 @@ const SUCCESS_MESSAGE_FOR_ACTION = { }; export default class TransitKeyActions extends Component { - @service store; @service flashMessages; @service router; + @service api; @tracked isModalActive = false; @tracked errors = null; @@ -126,7 +125,7 @@ export default class TransitKeyActions extends Component { this.props = { ...this.props, ...resp.data }; // While we do not pass ciphertext as a value to the JsonEditor, so that navigating from rewrap to decrypt will not show ciphertext in the editor, we still want to clear it from the props after rewrapping. - if (action === 'rewrap' && !this.args.key.supportsEncryption) { + if (action === 'rewrap' && !this.args.key.supports_encryption) { this.props.ciphertext = null; } } @@ -211,13 +210,43 @@ export default class TransitKeyActions extends Component { const payload = formData ? this.compactData(formData) : null; try { - const resp = yield this.store - .adapterFor('transit-key') - .keyAction(action, { backend, id, payload }, options); - + let resp; + if (action === 'encrypt') { + resp = yield this.api.secrets.transitEncrypt(id, backend, payload); + } else if (action === 'decrypt') { + resp = yield this.api.secrets.transitDecrypt(id, backend, payload); + } else if (action === 'datakey') { + resp = yield this.api.secrets.transitGenerateDataKey(id, payload.param, backend, { + ...payload, + }); + } else if (action === 'rewrap') { + resp = yield this.api.secrets.transitRewrap(id, backend, payload); + } else if (action === 'sign') { + resp = yield this.api.secrets.transitSign(id, backend, payload); + } else if (action === 'verify') { + resp = yield this.api.secrets.transitVerify(id, backend, payload); + } else if (action === 'hmac') { + resp = yield this.api.secrets.transitGenerateHmac(id, backend, payload); + } else if (action === 'export') { + const [type, version] = payload.param; + // if wrap ttl is present, the header needs to be set on the request for the api service to return the wrapped token in response - otherwise it would ignore and return data keys + const overrideOptions = options.wrapTTL + ? { headers: { 'X-Vault-Wrap-TTL': options.wrapTTL }, wrapTTL: options.wrapTTL } + : {}; + resp = version + ? yield this.api.secrets.transitExportKeyVersion( + id, + `${type}-key`, + version, + backend, + overrideOptions + ) + : yield this.api.secrets.transitExportKey(id, `${type}-key`, backend, overrideOptions); + } this.handleSuccess(resp, options, action); } catch (e) { - this.errors = errorMessage(e); + const { message } = yield this.api.parseError(e); + this.errors = message; } } } diff --git a/ui/app/forms/transit/key.ts b/ui/app/forms/transit/key.ts new file mode 100644 index 0000000000..9e8dfcfb2d --- /dev/null +++ b/ui/app/forms/transit/key.ts @@ -0,0 +1,133 @@ +/** + * Copyright IBM Corp. 2016, 2026 + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Form from 'vault/forms/form'; +import FormField from 'vault/utils/forms/field'; +import type { TransitCreateKeyRequest } from '@hashicorp/vault-client-typescript'; +import { Validations } from 'vault/vault/app-types'; +import { durationToSeconds } from 'core/utils/duration-utils'; + +type TransitKeyData = TransitCreateKeyRequest & { + backend?: string; + id?: string; + name?: string; +}; + +export default class TransitKeyForm extends Form { + // these set funcs just exist on edit + set convergentEncryptionValue(val: boolean | undefined) { + if (val === true) { + this.data.derived = val; + } + this.data.convergent_encryption = val; + } + + set derivedValue(val: boolean | undefined) { + if (val === false) { + this.data.convergent_encryption = val; + } + this.data.derived = val; + } + + get formFields() { + let fields = []; + if (this.isNew) { + fields.push( + ...[ + new FormField('name', 'string', { + editDisabled: !this.isNew, + }), + new FormField('auto_rotate_period', undefined, { + editType: 'yield', + label: ' ', + }), + new FormField('type', 'string', { + possibleValues: [ + 'aes128-gcm96', + 'aes256-gcm96', + 'chacha20-poly1305', + 'ecdsa-p256', + 'ecdsa-p384', + 'ecdsa-p521', + 'ed25519', + 'rsa-2048', + 'rsa-3072', + 'rsa-4096', + ], + }), + new FormField('exportable', 'boolean', { + label: 'Exportable', + editType: 'checkbox', + }), + new FormField('derived', 'boolean', { + label: 'Derived', + editType: 'checkbox', + }), + new FormField('convergent_encryption', 'boolean', { + label: 'Enable convergent encryption', + editType: 'checkbox', + }), + ] + ); + if (this.data.type?.startsWith('ecdsa') || this.data.type?.startsWith('rsa')) { + fields = fields.filter((field) => !['convergent_encryption', 'derived'].includes(field.name)); + } else if (this.data.type === 'ed25519') { + fields = fields.filter((field) => field.name !== 'convergent_encryption'); + } + } else { + fields.push( + ...[ + new FormField('deletion_allowed', 'boolean', { + label: 'Allow deletion', + editType: 'checkbox', + }), + new FormField('auto_rotate_period', undefined, { + label: ' ', + editType: 'yield', + }), + new FormField('min_decryption_version', undefined, { + label: 'Minimum decryption version', + editType: 'yield', + }), + new FormField('min_encryption_version', undefined, { + label: 'Minimum encryption version', + editType: 'yield', + }), + ] + ); + } + return fields; + } + + validations: Validations = { + name: [{ type: 'presence', message: 'Name is required' }], + auto_rotate_period: [ + { + validator(data: TransitKeyData) { + const { auto_rotate_period } = data; + if (auto_rotate_period === undefined) { + return true; + } else { + const duration = durationToSeconds(auto_rotate_period); + // regardless of generateSigningKey, if one key is set they both need to be set. + return duration === 0 || duration >= 3600; + } + }, + message: 'Duration must be longer than 1 hour or set to 0 to disable auto-rotation.', + }, + ], + }; + + toJSON() { + const { isValid, state, invalidFormMessage } = super.toJSON(); + const data = { ...this.data } as Record; + // set auto_rotate_period to 0 if it's false to avoid api validation error since the form field is a string and the api expects a number + if (data['auto_rotate_period'] === false) { + data['auto_rotate_period'] = 0; + } + + return { isValid, state, invalidFormMessage, data }; + } +} diff --git a/ui/app/routes/vault/cluster/secrets/backend/actions.js b/ui/app/routes/vault/cluster/secrets/backend/actions.js index 22633b05c7..7e9ce81df1 100644 --- a/ui/app/routes/vault/cluster/secrets/backend/actions.js +++ b/ui/app/routes/vault/cluster/secrets/backend/actions.js @@ -30,21 +30,21 @@ export default EditBase.extend({ setupController(controller, model) { this._super(...arguments); const { selectedAction } = this.paramsFor(this.routeName); - controller.set('selectedAction', selectedAction || model.secret.supportedActions[0]); + controller.set('selectedAction', selectedAction || model.secret.data.supportedActions[0]); controller.set('breadcrumbs', [ { label: 'Secrets', route: 'vault.cluster.secrets', }, { - label: model.secret.backend, + label: model.secret.data.backend, route: 'vault.cluster.secrets.backend.list-root', - model: model.secret.backend, + model: model.secret.data.backend, }, { - label: model.secret.id, + label: model.secret.data.id, route: 'vault.cluster.secrets.backend.show', - models: [model.secret.backend, model.secret.id], + models: [model.secret.data.backend, model.secret.data.id], }, { label: 'Actions', diff --git a/ui/app/routes/vault/cluster/secrets/backend/create-root.js b/ui/app/routes/vault/cluster/secrets/backend/create-root.js index e94b41fd11..3588bf372d 100644 --- a/ui/app/routes/vault/cluster/secrets/backend/create-root.js +++ b/ui/app/routes/vault/cluster/secrets/backend/create-root.js @@ -9,6 +9,7 @@ import EditBase from './secret-edit'; import KeymgmtKeyForm from 'vault/forms/keymgmt/key'; import KeymgmtProviderForm from 'vault/forms/keymgmt/provider'; import TotpKeyForm from 'vault/forms/totp/key'; +import TransitKeyForm from 'vault/forms/transit/key'; import SshRoleForm from 'vault/forms/ssh/role'; import AlphabetForm from 'vault/forms/transform/alphabet'; import TemplateForm from 'vault/forms/transform/template'; @@ -65,6 +66,17 @@ export default EditBase.extend({ ); } + if (modelType === 'transit-key') { + return new TransitKeyForm( + { + backend, + type: 'aes256-gcm96', + auto_rotate_period: '0s', + }, + { isNew: true } + ); + } + if (modelType === 'role-ssh') { return new SshRoleForm( { backend, key_type: 'ca', not_before_duration: '30s', port: 22 }, diff --git a/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js b/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js index 3f1cfe8707..554fb05eba 100644 --- a/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js +++ b/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js @@ -3,7 +3,7 @@ * SPDX-License-Identifier: BUSL-1.1 */ -import { set } from '@ember/object'; +import { set, get } from '@ember/object'; import Ember from 'ember'; import { resolve } from 'rsvp'; import { service } from '@ember/service'; @@ -17,6 +17,9 @@ import { isValidProvider } from 'vault/utils/keymgmt-provider-utils'; import KeymgmtKeyForm from 'vault/forms/keymgmt/key'; import KeymgmtProviderForm from 'vault/forms/keymgmt/provider'; import TotpKeyForm from 'vault/forms/totp/key'; +import clamp from 'vault/utils/clamp'; +import TransitKeyForm from 'vault/forms/transit/key'; + import SshRoleForm from 'vault/forms/ssh/role'; import AlphabetForm from 'vault/forms/transform/alphabet'; import TemplateForm from 'vault/forms/transform/template'; @@ -28,6 +31,50 @@ import { SecretsApiTransformListRolesListEnum, } from '@hashicorp/vault-client-typescript'; +// TODO: Move this into a util file or class +const ACTION_VALUES = { + encrypt: { + isSupported: 'supports_encryption', + description: 'Looks up wrapping properties for the given token.', + glyph: 'lock-fill', + }, + decrypt: { + isSupported: 'supports_decryption', + description: 'Decrypts the provided ciphertext using this key.', + glyph: 'mail-open', + }, + datakey: { + isSupported: 'supports_encryption', + description: 'Generates a new key and value encrypted with this key.', + glyph: 'key', + }, + rewrap: { + isSupported: 'supports_encryption', + description: 'Rewraps the ciphertext using the latest version of the named key.', + glyph: 'reload', + }, + sign: { + isSupported: 'supports_signing', + description: 'Get the cryptographic signature of the given data.', + glyph: 'pencil-tool', + }, + hmac: { + isSupported: true, + description: 'Generate a data digest using a hash algorithm.', + glyph: 'shuffle', + }, + verify: { + isSupported: true, + description: 'Validate the provided signature for the given data.', + glyph: 'check-circle', + }, + export: { + isSupported: 'exportable', + description: 'Get the named key.', + glyph: 'external-link', + }, +}; + /** * @type Class */ @@ -261,6 +308,104 @@ export default Route.extend({ }; }, + async fetchTransitKey(name, backend) { + const res = await this.api.secrets.transitReadKey(name, backend); + + const transitModel = { + backend, + id: name, + ...res, + ...this.transitEncryptionKeyVersions( + res.keys, + res.min_decryption_version, + res.min_encryption_version, + res.latest_version, + res.supports_signing, + res.supports_encryption + ), + }; + + return transitModel; + }, + + async fetchTransitKeyCapabilities(backend, name, secretModel) { + const rotatePath = this.capabilitiesService.pathFor('transitKeyRotate', { backend, name }); + const keyPath = this.capabilitiesService.pathFor('transitKey', { backend, name }); + + const capabilities = await this.capabilitiesService.fetch([rotatePath, keyPath]); + + return { + canRotate: capabilities[rotatePath]?.canUpdate, + canRead: capabilities[keyPath]?.canRead, + canUpdate: capabilities[keyPath]?.canUpdate, + canDelete: capabilities[keyPath]?.canDelete !== false && secretModel.deletion_allowed !== false, // check deletion_allowed from the model in addition to the capability since both are required to allow deletion + }; + }, + + // TODO: Move these into separate classes or utils + transitEncryptionKeyVersions( + keys, + minDecryptionVersion, + minEncryptionVersion, + latestVersion, + supportsSigning, + supportsEncryption + ) { + const validKeys = Object.keys(keys); + const keyVersions = []; + const keysForEncryption = []; + + // get keyVersions + let maxVersion = Math.max(...validKeys); + while (maxVersion > 0) { + keyVersions.unshift(maxVersion); + maxVersion--; + } + + // get encryptionKeyVersions using keyVersions + const encryptionKeyVersions = keyVersions + .filter((version) => { + return version >= minDecryptionVersion; + }) + .reverse(); + + // get keysForEncryption + const minVersion = clamp(minEncryptionVersion - 1, 0, latestVersion); + while (latestVersion > minVersion) { + keysForEncryption.push(latestVersion); + latestVersion--; + } + + // get exportKeyTypes + const exportKeyTypes = ['hmac']; + if (supportsSigning) { + exportKeyTypes.unshift('signing'); + } + if (supportsEncryption) { + exportKeyTypes.unshift('encryption'); + } + + return { + encryptionKeyVersions, + keyVersions, + keysForEncryption, + exportKeyTypes, + validKeyVersions: Object.keys(keys), + }; + }, + + transitSupportedActions(secretModel) { + return Object.keys(ACTION_VALUES) + .filter((name) => { + const { isSupported } = ACTION_VALUES[name]; + return typeof isSupported === 'boolean' || get(secretModel, isSupported); + }) + .map((name) => { + const { description, glyph } = ACTION_VALUES[name]; + return { name, description, glyph }; + }); + }, + async fetchKeymgmtKey(backend, name) { const { data } = await this.api.secrets.keyManagementReadKey(name, backend); @@ -525,6 +670,12 @@ export default Route.extend({ if (modelType === 'totp-key') { secretModel = await this.fetchTotpKey(backend, secret); capabilities = await this.fetchTotpKeyCapabilities(backend, secret); + } else if (modelType === 'transit-key') { + secretModel = await this.fetchTransitKey(secret, backend); + capabilities = await this.fetchTransitKeyCapabilities(backend, secret, secretModel); + secretModel.supportedActions = this.transitSupportedActions(secretModel); + // replace secretModel with form + secretModel = new TransitKeyForm(secretModel, { isNew: false }); } else if (modelType === 'keymgmt/key') { secretModel = await this.fetchKeymgmtKey(backend, secret); capabilities = await this.fetchKeymgmtKeyCapabilities(backend, secret); diff --git a/ui/app/services/api.ts b/ui/app/services/api.ts index 2160b5deae..ec1c8593bf 100644 --- a/ui/app/services/api.ts +++ b/ui/app/services/api.ts @@ -125,8 +125,14 @@ export default class ApiService extends Service { } // if the requested path is locked by a control group we need to create a new error response const json = await this.readJson(response); + + // derive from REQUEST, not response - transit export is making an api request but trying to also set vault header on the request, not the response - causing a control group error. + const wasWrapTTLRequested = (context.init.headers as Headers)?.get?.('X-Vault-Wrap-TTL'); const wrapTtl = headers.get('X-Vault-Wrap-TTL'); - const isLockedByControlGroup = this.controlGroup.isRequestedPathLocked(json, wrapTtl); + const isLockedByControlGroup = this.controlGroup.isRequestedPathLocked( + json, + wasWrapTTLRequested ? wasWrapTTLRequested : wrapTtl + ); if (isLockedByControlGroup && json) { const error = { diff --git a/ui/app/templates/vault/cluster/secrets/backend/transit-actions-layout.hbs b/ui/app/templates/vault/cluster/secrets/backend/transit-actions-layout.hbs index 1117e4db5c..8b391893e9 100644 --- a/ui/app/templates/vault/cluster/secrets/backend/transit-actions-layout.hbs +++ b/ui/app/templates/vault/cluster/secrets/backend/transit-actions-layout.hbs @@ -12,8 +12,8 @@ <:actions>

    - +
    \ No newline at end of file diff --git a/ui/app/utils/constants/capabilities.ts b/ui/app/utils/constants/capabilities.ts index 900bc55d37..8331d65758 100644 --- a/ui/app/utils/constants/capabilities.ts +++ b/ui/app/utils/constants/capabilities.ts @@ -90,6 +90,8 @@ export const PATH_MAP = { syncSetAssociation: apiPath`sys/sync/destinations/${'type'}/${'name'}/associations/set`, totpKey: apiPath`${'backend'}/keys/${'name'}`, totpKeys: apiPath`${'backend'}/keys`, + transitKeyRotate: apiPath`${'backend'}/keys/${'name'}/rotate`, + transitKey: apiPath`${'backend'}/keys/${'name'}`, transformAlphabet: apiPath`${'backend'}/alphabet/${'name'}`, transformAlphabets: apiPath`${'backend'}/alphabet`, transformRole: apiPath`${'backend'}/role/${'name'}`, diff --git a/ui/tests/acceptance/transit-test.js b/ui/tests/acceptance/transit-test.js index 971c93d918..04f404fd62 100644 --- a/ui/tests/acceptance/transit-test.js +++ b/ui/tests/acceptance/transit-test.js @@ -3,7 +3,17 @@ * SPDX-License-Identifier: BUSL-1.1 */ -import { click, fillIn, find, currentURL, settled, visit, findAll, waitFor } from '@ember/test-helpers'; +import { + click, + fillIn, + find, + currentURL, + settled, + visit, + findAll, + waitFor, + waitUntil, +} from '@ember/test-helpers'; import { module, test } from 'qunit'; import { setupApplicationTest } from 'ember-qunit'; import { v4 as uuidv4 } from 'uuid'; @@ -233,14 +243,15 @@ module('Acceptance | transit', function (hooks) { const name = `test-generate-${this.uid}`; await click(SES.createSecretLink); - await fillIn(SELECTORS.form('name'), name); - await fillIn(SELECTORS.form('type'), type); - await click(SELECTORS.form('exportable')); - await click(SELECTORS.form('derived')); - await click(SELECTORS.form('convergent-encryption')); + await fillIn(GENERAL.inputByAttr('name'), name); + await fillIn(GENERAL.inputByAttr('type'), type); + await click(GENERAL.inputByAttr('exportable')); + await click(GENERAL.inputByAttr('derived')); + await click(GENERAL.inputByAttr('convergent_encryption')); await click(GENERAL.ttl.toggle('Auto-rotation period')); await click(SELECTORS.form('create')); + await waitUntil(() => currentURL() === `/vault/secrets-engines/${this.path}/show/${name}?tab=details`); assert.strictEqual( currentURL(), `/vault/secrets-engines/${this.path}/show/${name}?tab=details`, @@ -305,30 +316,30 @@ module('Acceptance | transit', function (hooks) { }, { type: 'aes128-gcm96', - 'convergent-encryption': true, + convergent_encryption: true, derived: true, exportable: true, }, { type: 'aes256-gcm96', - 'convergent-encryption': true, + convergent_encryption: true, derived: true, exportable: true, }, { type: 'chacha20-poly1305', - 'convergent-encryption': true, + convergent_encryption: true, derived: true, exportable: true, }, ]; for (const key of KEY_OPTIONS) { const { type } = key; - await fillIn(SELECTORS.form('type'), type); + await fillIn(GENERAL.inputByAttr('type'), type); - for (const checkbox of ['exportable', 'derived', 'convergent-encryption']) { + for (const checkbox of ['exportable', 'derived', 'convergent_encryption']) { const assertion = key[checkbox] ? 'exists' : 'doesNotExist'; - assert.dom(SELECTORS.form(checkbox))[assertion](`${type} ${checkbox} ${assertion}`); + assert.dom(GENERAL.inputByAttr(checkbox))[assertion](`${type} ${checkbox} ${assertion}`); } } }); diff --git a/ui/tests/integration/components/transit-edit-test.js b/ui/tests/integration/components/transit-edit-test.js index b095836e1f..c3aada157a 100644 --- a/ui/tests/integration/components/transit-edit-test.js +++ b/ui/tests/integration/components/transit-edit-test.js @@ -9,6 +9,7 @@ import { click, fillIn, render } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; import { setupMirage } from 'ember-cli-mirage/test-support'; import { capabilitiesStub } from 'vault/tests/helpers/stubs'; +import TransitKeyForm from 'vault/forms/transit/key'; const SELECTORS = { createForm: '[data-test-transit-create-form]', @@ -25,20 +26,20 @@ module('Integration | Component | transit-edit', function (hooks) { this.server.post('/sys/capabilities-self', () => capabilitiesStub('transit-backend/keys/some-key', ['sudo']) ); - this.model = this.store.createRecord('transit-key', { backend: 'transit-backend', id: 'some-key' }); + this.model = { backend: 'transit', id: 'some-key' }; this.backendCrumb = { label: 'transit', text: 'transit', path: 'vault.cluster.secrets.backend.list-root', model: 'transit', }; + this.form = new TransitKeyForm(this.model, { isNew: false }); }); test('it renders in create mode and updates model', async function (assert) { await render(hbs` 0 - ? rootResp - : { - data: { ...self.get('keyActionReturnVal') }, - }; - return resolve(resp); - }, - }; - }, -}); +import sinon from 'sinon'; module('Integration | Component | transit key actions', function (hooks) { setupRenderingTest(hooks); - hooks.beforeEach(function () { - run(() => { - this.owner.unregister('service:store'); - this.owner.register('service:store', storeStub); - this.storeService = this.owner.lookup('service:store'); - }); - }); - test('it requires `key`', async function (assert) { const promise = waitForError(); render(hbs` @@ -141,120 +108,144 @@ module('Integration | Component | transit key actions', function (hooks) { }); async function doEncrypt(assert, actions = [], keyattrs = {}) { - const keyDefaults = { backend: 'transit', id: 'akey', supportedActions: ['encrypt'].concat(actions) }; + const keyDefaults = { + backend: 'transit', + id: 'akey', + supportedActions: ['encrypt'].concat(actions), + }; const key = { ...keyDefaults, ...keyattrs }; this.set('key', key); this.set('selectedAction', 'encrypt'); - this.set('storeService.keyActionReturnVal', { ciphertext: 'secret' }); + + this.apiStub = sinon.stub(this.owner.lookup('service:api').secrets, 'transitEncrypt').resolves({ + data: { ciphertext: 'secret' }, + }); + await render(hbs` - `); + + `); let editor; await waitFor('.cm-editor'); editor = codemirror('#plaintext-control'); setCodeEditorValue(editor, 'plaintext'); + await click('button[type="submit"]'); - assert.deepEqual( - this.storeService.callArgs, - { - action: 'encrypt', - backend: 'transit', - id: 'akey', - payload: { - plaintext: encodeString('plaintext'), - }, - }, - 'passes expected args to the adapter' + + assert.true(this.apiStub.calledOnce, 'calls the API to encrypt'); + + assert.true( + this.apiStub.calledWith('akey', 'transit', { + plaintext: encodeString('plaintext'), + }), + 'passes expected args to transitEncrypt' ); assert.strictEqual(find('[data-test-encrypted-value="ciphertext"]').innerText, 'secret'); // exit modal await click('dialog button'); + // Encrypt again, with pre-encoded value and checkbox selected const preEncodedValue = encodeString('plaintext'); + await waitFor('.cm-editor'); editor = codemirror('#plaintext-control'); setCodeEditorValue(editor, preEncodedValue); + await click('input[data-test-transit-input="encodedBase64"]'); await click('button[type="submit"]'); - assert.deepEqual( - this.storeService.callArgs, - { - action: 'encrypt', - backend: 'transit', - id: 'akey', - payload: { - plaintext: preEncodedValue, - }, - }, - 'passes expected args to the adapter' + assert.strictEqual(this.apiStub.callCount, 2, 'calls the API to encrypt again'); + + assert.true( + this.apiStub.secondCall.calledWith('akey', 'transit', { + plaintext: preEncodedValue, + }), + 'passes pre-encoded value without re-encoding' ); + await click('dialog button'); } test('it encrypts', doEncrypt); test('it shows key version selection', async function (assert) { - const keyDefaults = { backend: 'transit', id: 'akey', supportedActions: ['encrypt'].concat([]) }; - const keyattrs = { keysForEncryption: [3, 2, 1], latestVersion: 3 }; + const keyDefaults = { + backend: 'transit', + id: 'akey', + supportedActions: ['encrypt'], + }; + const keyattrs = { + keysForEncryption: [3, 2, 1], + latestVersion: 3, + }; + const key = { ...keyDefaults, ...keyattrs }; this.set('key', key); - this.set('storeService.keyActionReturnVal', { ciphertext: 'secret' }); + const encryptStub = sinon.stub(this.owner.lookup('service:api').secrets, 'transitEncrypt').resolves({ + data: { ciphertext: 'secret' }, + }); + await render(hbs` - `); + + `); await waitFor('.cm-editor'); + const editor = codemirror(); setCodeEditorValue(editor, 'plaintext'); + assert.dom('#key_version').exists({ count: 1 }, 'it renders the key version selector'); await triggerEvent('#key_version', 'change'); await click('button[type="submit"]'); - assert.deepEqual( - this.storeService.callArgs, - { - action: 'encrypt', - backend: 'transit', - id: 'akey', - payload: { - plaintext: encodeString('plaintext'), - key_version: '0', - }, - }, + + assert.true(encryptStub.calledOnce, 'calls transitEncrypt'); + + assert.true( + encryptStub.calledWith('akey', 'transit', { + plaintext: encodeString('plaintext'), + key_version: '0', + }), 'includes key_version in the payload' ); }); test('it hides key version selection', async function (assert) { - const keyDefaults = { backend: 'transit', id: 'akey', supportedActions: ['encrypt'].concat([]) }; + const keyDefaults = { + backend: 'transit', + id: 'akey', + supportedActions: ['encrypt'], + }; + const keyattrs = { keysForEncryption: [1] }; const key = { ...keyDefaults, ...keyattrs }; + this.set('key', key); - this.set('storeService.keyActionReturnVal', { ciphertext: 'secret' }); + await render(hbs` - `); + + `); await waitFor('.cm-editor'); + const editor = codemirror('#plaintext-control'); setCodeEditorValue(editor, 'plaintext'); + assert.dom('#key_version').doesNotExist('it does not render the selector when there is only one key'); }); - test('it does not carry ciphertext value over to decrypt', async function (assert) { - assert.expect(4); - const plaintext = 'not so secret'; - await doEncrypt.call(this, assert, ['decrypt']); - - this.set('storeService.keyActionReturnVal', { plaintext }); - this.set('selectedAction', 'decrypt'); - await waitFor('.cm-editor'); - const editor = codemirror('#ciphertext-control'); - assert.strictEqual(getCodeEditorValue(editor), '', 'does not prefill ciphertext value'); - }); - const setupExport = async function () { this.set('key', { backend: 'transit', @@ -268,23 +259,30 @@ module('Integration | Component | transit key actions', function (hooks) { }; test('it can export a key:default behavior', async function (assert) { - this.set('storeService.rootKeyActionReturnVal', { wrap_info: { token: 'wrapped-token' } }); + const exportStub = sinon.stub(this.owner.lookup('service:api').secrets, 'transitExportKey').resolves({ + wrap_info: { token: 'wrapped-token' }, + }); + await setupExport.call(this); await click('button[type="submit"]'); + assert.true(exportStub.calledOnce); assert.deepEqual( - this.storeService.callArgs, - { - action: 'export', - backend: 'transit', - id: 'akey', - payload: { - param: ['encryption'], + exportStub.firstCall.args, + [ + 'akey', + 'encryption-key', + 'transit', + { + headers: { + 'X-Vault-Wrap-TTL': '30m', + }, + wrapTTL: '30m', }, - }, - 'passes expected args to the adapter' + ], + 'passes expected args to api service' ); - assert.strictEqual(this.storeService.callArgsOptions.wrapTTL, '30m', 'passes value for wrapTTL'); + assert.strictEqual( find('[data-test-encrypted-value="export"]').innerText, 'wrapped-token', @@ -293,77 +291,112 @@ module('Integration | Component | transit key actions', function (hooks) { }); test('it can export a key:unwrapped behavior', async function (assert) { - const response = { keys: { a: 'key' } }; - this.set('storeService.keyActionReturnVal', response); + const response = { + data: { + keys: { a: 'key' }, + type: 'encryption', + name: 'akey', + }, + }; + sinon.stub(this.owner.lookup('service:api').secrets, 'transitExportKey').resolves(response); + await setupExport.call(this); await click('[data-test-toggle-label="Wrap response"]'); await click(GENERAL.submitButton); + assert.dom('#transit-export-modal').exists('Modal opens after export'); assert.deepEqual( JSON.parse(find('[data-test-encrypted-value="export"]').innerText), - response, + { + keys: { a: 'key' }, + type: 'encryption', + name: 'akey', + }, 'prints json response' ); }); test('it can export a key: unwrapped, single version', async function (assert) { - const response = { keys: { a: 'key' } }; - this.set('storeService.keyActionReturnVal', response); + const response = { + data: { + keys: { a: 'key' }, + type: 'encryption', + name: 'akey', + }, + }; + const exportVersionStub = sinon + .stub(this.owner.lookup('service:api').secrets, 'transitExportKeyVersion') + .resolves(response); + await setupExport.call(this); await click('[data-test-toggle-label="Wrap response"]'); await click('#exportVersion'); await triggerEvent('#exportVersion', 'change'); await click(GENERAL.submitButton); + assert.dom('#transit-export-modal').exists('Modal opens after export'); assert.deepEqual( JSON.parse(find('[data-test-encrypted-value="export"]').innerText), - response, + { + keys: { a: 'key' }, + type: 'encryption', + name: 'akey', + }, 'prints json response' ); + + assert.true(exportVersionStub.calledOnce); assert.deepEqual( - this.storeService.callArgs, - { - action: 'export', - backend: 'transit', - id: 'akey', - payload: { - param: ['encryption', 1], - }, - }, - 'passes expected args to the adapter' + exportVersionStub.firstCall.args, + ['akey', 'encryption-key', 1, 'transit', {}], + 'passes expected args to api service' ); }); test('it includes algorithm param for HMAC', async function (assert) { - // Return mocked data so a11y-testing doesn't get mad about empty copy button contents - this.set('storeService.rootKeyActionReturnVal', { data: { hmac: 'vault:v1:hmac-token' } }); + const hmacStub = sinon.stub(this.owner.lookup('service:api').secrets, 'transitGenerateHmac').resolves({ + data: { + hmac: 'vault:v1:hmac-token', + }, + }); + this.set('key', { backend: 'transit', id: 'akey', supportedActions: ['hmac'], validKeyVersions: [1], }); + await render(hbs` - `); + + `); + await fillIn('#algorithm', 'sha2-384'); await blur('#algorithm'); + await waitFor('.cm-editor'); const editor = codemirror(); setCodeEditorValue(editor, 'plaintext'); + await click('input[data-test-transit-input="encodedBase64"]'); await click(GENERAL.submitButton); + + assert.true(hmacStub.calledOnce, 'calls transitGenerateHmac'); + assert.deepEqual( - this.storeService.callArgs, - { - action: 'hmac', - backend: 'transit', - id: 'akey', - payload: { + hmacStub.firstCall.args, + [ + 'akey', + 'transit', + { algorithm: 'sha2-384', input: 'plaintext', }, - }, - 'passes expected args to the adapter' + ], + 'passes expected args to the API' ); }); });