- {{#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>