diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index 41d5206eb28..0b023c3ad20 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -473,6 +473,8 @@ export enum EventType { CMEK_LIST_SIGNING_ALGORITHMS = "cmek-list-signing-algorithms", CMEK_GET_PUBLIC_KEY = "cmek-get-public-key", CMEK_GET_PRIVATE_KEY = "cmek-get-private-key", + CMEK_BULK_EXPORT_PRIVATE_KEYS = "cmek-bulk-export-private-keys", + CMEK_BULK_IMPORT_KEYS = "cmek-bulk-import-keys", UPDATE_EXTERNAL_GROUP_ORG_ROLE_MAPPINGS = "update-external-group-org-role-mapping", GET_EXTERNAL_GROUP_ORG_ROLE_MAPPINGS = "get-external-group-org-role-mapping", @@ -3648,6 +3650,23 @@ interface CmekGetPrivateKeyEvent { }; } +interface CmekBulkGetPrivateKeysEvent { + type: EventType.CMEK_BULK_EXPORT_PRIVATE_KEYS; + metadata: { + keyIds: string[]; + keyNames: string[]; + }; +} + +interface CmekBulkImportKeysEvent { + type: EventType.CMEK_BULK_IMPORT_KEYS; + metadata: { + keyNames: string[]; + failedKeyNames: string[]; + projectId: string; + }; +} + interface GetExternalGroupOrgRoleMappingsEvent { type: EventType.GET_EXTERNAL_GROUP_ORG_ROLE_MAPPINGS; metadata?: Record; // not needed, based off orgId @@ -6288,6 +6307,8 @@ export type Event = | CmekListSigningAlgorithmsEvent | CmekGetPublicKeyEvent | CmekGetPrivateKeyEvent + | CmekBulkGetPrivateKeysEvent + | CmekBulkImportKeysEvent | GetExternalGroupOrgRoleMappingsEvent | UpdateExternalGroupOrgRoleMappingsEvent | GetProjectTemplatesEvent diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 50a771fab0c..32f1fb992a1 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -2471,6 +2471,10 @@ export const KMS = { keyId: "The ID of the key to export the private key or key material for." }, + BULK_EXPORT_PRIVATE_KEYS: { + keyIds: "An array of KMS key IDs to export. Maximum 100 keys per request." + }, + SIGN: { keyId: "The ID of the key to sign the data with.", data: "The data in string format to be signed (base64 encoded).", diff --git a/backend/src/server/routes/v1/cmek-router.ts b/backend/src/server/routes/v1/cmek-router.ts index bb45738625b..0399c825f8f 100644 --- a/backend/src/server/routes/v1/cmek-router.ts +++ b/backend/src/server/routes/v1/cmek-router.ts @@ -518,6 +518,149 @@ export const registerCmekRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "POST", + url: "/keys/bulk-import", + config: { rateLimit: writeLimit }, + schema: { + hide: false, + operationId: "bulkImportKmsKeys", + tags: [ApiDocsTags.KmsKeys], + description: "Bulk import KMS keys with provided key material into a project.", + body: z.object({ + projectId: z.string().uuid(), + keys: z + .array( + z + .object({ + name: keyNameSchema, + keyUsage: z.nativeEnum(KmsKeyUsage), + encryptionAlgorithm: z.enum(AllowedEncryptionKeyAlgorithms), + keyMaterial: z.string().min(1) + }) + .superRefine((data, ctx) => { + if ( + data.keyUsage === KmsKeyUsage.ENCRYPT_DECRYPT && + !Object.values(SymmetricKeyAlgorithm).includes(data.encryptionAlgorithm as SymmetricKeyAlgorithm) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `encryptionAlgorithm must be a symmetric algorithm for encrypt-decrypt keys` + }); + } + if ( + data.keyUsage === KmsKeyUsage.SIGN_VERIFY && + !Object.values(AsymmetricKeyAlgorithm).includes(data.encryptionAlgorithm as AsymmetricKeyAlgorithm) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `encryptionAlgorithm must be an asymmetric algorithm for sign-verify keys` + }); + } + }) + ) + .min(1) + .max(100) + }), + response: { + 200: z.object({ + keys: z.array(z.object({ id: z.string(), name: z.string() })), + errors: z.array(z.object({ name: z.string(), message: z.string() })) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { + body: { projectId, keys }, + permission + } = req; + + const { keys: importedKeys, errors } = await server.services.cmek.bulkImportKeys( + { + projectId, + keys: keys.map((k) => ({ + name: k.name, + algorithm: k.encryptionAlgorithm as TCmekKeyEncryptionAlgorithm, + keyUsage: k.keyUsage, + keyMaterial: k.keyMaterial + })) + }, + permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.CMEK_BULK_IMPORT_KEYS, + metadata: { + keyNames: importedKeys.map((k) => k.name), + failedKeyNames: errors.map((e) => e.name), + projectId + } + } + }); + + return { keys: importedKeys, errors }; + } + }); + + server.route({ + method: "POST", + url: "/keys/bulk-export-private-keys", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + operationId: "bulkExportKmsKeyPrivateKeys", + tags: [ApiDocsTags.KmsKeys], + description: + "Bulk export multiple KMS keys. For asymmetric keys (sign/verify), both private and public keys are returned. For symmetric keys (encrypt/decrypt), the key material is returned.", + body: z.object({ + keyIds: z.array(z.string().uuid().describe(KMS.BULK_EXPORT_PRIVATE_KEYS.keyIds)).min(1).max(100) + }), + response: { + 200: z.object({ + keys: z.array( + z.object({ + keyId: z.string(), + name: z.string(), + keyUsage: z.string(), + algorithm: z.string(), + privateKey: z.string(), + publicKey: z.string().optional() + }) + ) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { + body: { keyIds }, + permission + } = req; + + const { keys, projectId } = await server.services.cmek.bulkGetPrivateKeys({ keyIds }, permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.CMEK_BULK_EXPORT_PRIVATE_KEYS, + metadata: { + keyIds: keys.map((k) => k.keyId), + keyNames: keys.map((k) => k.name) + } + } + }); + + return { keys }; + } + }); + server.route({ method: "GET", url: "/keys/:keyId/signing-algorithms", diff --git a/backend/src/services/cmek/cmek-service.ts b/backend/src/services/cmek/cmek-service.ts index 2acc95ee6d9..2e758bebc6c 100644 --- a/backend/src/services/cmek/cmek-service.ts +++ b/backend/src/services/cmek/cmek-service.ts @@ -3,11 +3,13 @@ import { ForbiddenError } from "@casl/ability"; import { ActionProjectType } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { ProjectPermissionCmekActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; -import { SigningAlgorithm } from "@app/lib/crypto/sign"; +import { AsymmetricKeyAlgorithm, SigningAlgorithm, signingService } from "@app/lib/crypto/sign"; import { DatabaseErrorCode } from "@app/lib/error-codes"; import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors"; import { OrgServiceActor } from "@app/lib/types"; import { + TCmekBulkGetPrivateKeysDTO, + TCmekBulkImportKeysDTO, TCmekDecryptDTO, TCmekEncryptDTO, TCmekGetPrivateKeyDTO, @@ -318,6 +320,82 @@ export const cmekServiceFactory = ({ kmsService, kmsDAL, permissionService }: TC }; }; + const bulkGetPrivateKeys = async ({ keyIds }: TCmekBulkGetPrivateKeysDTO, actor: OrgServiceActor) => { + if (keyIds.length === 0) throw new BadRequestError({ message: "At least one key ID is required" }); + + const uniqueKeyIds = [...new Set(keyIds)]; + const keys = await kmsDAL.findCmeksByIds(uniqueKeyIds); + + if (keys.length === 0) throw new NotFoundError({ message: "No keys found for the provided IDs" }); + + if (keys.length !== uniqueKeyIds.length) { + const foundIds = new Set(keys.map((k) => k.id)); + const missingIds = uniqueKeyIds.filter((id) => !foundIds.has(id)); + throw new NotFoundError({ message: `Keys not found for IDs: ${missingIds.join(", ")}` }); + } + + const projectIds = new Set(); + for (const key of keys) { + if (!key.projectId || key.isReserved) + throw new BadRequestError({ message: `Key with ID "${key.id}" is not customer managed` }); + if (key.isDisabled) throw new BadRequestError({ message: `Key with ID "${key.id}" is disabled` }); + projectIds.add(key.projectId); + } + + if (projectIds.size > 1) throw new BadRequestError({ message: "All keys must belong to the same project" }); + + const projectId = keys[0].projectId!; + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + projectId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.KMS + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCmekActions.ExportPrivateKey, + ProjectPermissionSub.Cmek + ); + + const bulkMaterials = await kmsService.getBulkKeyMaterial({ kmsIds: uniqueKeyIds }); + + const result = await Promise.all( + keys.map(async (key) => { + const materialEntry = bulkMaterials.find((m) => m.kmsId === key.id); + + if (!materialEntry) { + throw new NotFoundError({ message: `Key material not found for key ID "${key.id}"` }); + } + + const isAsymmetric = Object.values(AsymmetricKeyAlgorithm).includes( + key.encryptionAlgorithm as AsymmetricKeyAlgorithm + ); + + let publicKey: string | undefined; + if (isAsymmetric) { + const pubKeyBuffer = signingService( + key.encryptionAlgorithm as AsymmetricKeyAlgorithm + ).getPublicKeyFromPrivateKey(materialEntry.keyMaterial); + publicKey = pubKeyBuffer.toString("base64"); + } + + return { + keyId: key.id, + name: key.name, + keyUsage: key.keyUsage, + algorithm: key.encryptionAlgorithm, + privateKey: materialEntry.keyMaterial.toString("base64"), + ...(publicKey ? { publicKey } : {}) + }; + }) + ); + + return { keys: result, projectId }; + }; + const cmekSign = async ({ keyId, data, signingAlgorithm, isDigest }: TCmekSignDTO, actor: OrgServiceActor) => { const key = await kmsDAL.findCmekById(keyId); @@ -419,6 +497,59 @@ export const cmekServiceFactory = ({ kmsService, kmsDAL, permissionService }: TC }; }; + const bulkImportKeys = async ({ projectId, keys }: TCmekBulkImportKeysDTO, actor: OrgServiceActor) => { + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + projectId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.KMS + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionCmekActions.Create, ProjectPermissionSub.Cmek); + + const results = await Promise.allSettled( + keys.map(async (entry): Promise<{ id: string; name: string }> => { + const imported = await kmsService.importKeyMaterial({ + key: Buffer.from(entry.keyMaterial, "base64"), + algorithm: entry.algorithm, + name: entry.name, + isReserved: false, + projectId, + orgId: actor.orgId, + keyUsage: entry.keyUsage + }); + return { id: imported.id, name: imported.name }; + }) + ); + + const importedKeys: { id: string; name: string }[] = []; + const errors: { name: string; message: string }[] = []; + + results.forEach((result, i) => { + const entry = keys[i]; + if (!entry) return; + if (result.status === "fulfilled") { + importedKeys.push(result.value); + } else { + const reason = result.reason as Error; + let message = "Failed to import key"; + if ( + reason instanceof DatabaseError && + (reason.error as { code: string })?.code === DatabaseErrorCode.UniqueViolation + ) { + message = `A key with the name "${entry.name}" already exists in this project`; + } else if (reason instanceof BadRequestError) { + message = reason.message; + } + errors.push({ name: entry.name, message }); + } + }); + + return { keys: importedKeys, errors, projectId }; + }; + return { createCmek, updateCmekById, @@ -432,6 +563,8 @@ export const cmekServiceFactory = ({ kmsService, kmsDAL, permissionService }: TC cmekVerify, listSigningAlgorithms, getPublicKey, - getPrivateKey + getPrivateKey, + bulkGetPrivateKeys, + bulkImportKeys }; }; diff --git a/backend/src/services/cmek/cmek-types.ts b/backend/src/services/cmek/cmek-types.ts index eafd39e39fa..3f986f48a9b 100644 --- a/backend/src/services/cmek/cmek-types.ts +++ b/backend/src/services/cmek/cmek-types.ts @@ -57,6 +57,22 @@ export type TCmekGetPrivateKeyDTO = { keyId: string; }; +export type TCmekBulkGetPrivateKeysDTO = { + keyIds: string[]; +}; + +export type TCmekBulkImportKeyEntry = { + name: string; + algorithm: TCmekKeyEncryptionAlgorithm; + keyUsage: KmsKeyUsage; + keyMaterial: string; +}; + +export type TCmekBulkImportKeysDTO = { + projectId: string; + keys: TCmekBulkImportKeyEntry[]; +}; + export type TCmekSignDTO = { keyId: string; data: string; diff --git a/backend/src/services/kms/kms-key-dal.ts b/backend/src/services/kms/kms-key-dal.ts index 36ffa336612..c7554c9c4f4 100644 --- a/backend/src/services/kms/kms-key-dal.ts +++ b/backend/src/services/kms/kms-key-dal.ts @@ -93,6 +93,62 @@ export const kmskeyDALFactory = (db: TDbClient) => { } }; + const findByIdsWithAssociatedKms = async (ids: string[], tx?: Knex) => { + try { + const results = await (tx || db.replicaNode())(TableName.KmsKey) + .whereIn(`${TableName.KmsKey}.id`, ids) + .join(TableName.Organization, `${TableName.KmsKey}.orgId`, `${TableName.Organization}.id`) + .leftJoin(TableName.InternalKms, `${TableName.KmsKey}.id`, `${TableName.InternalKms}.kmsKeyId`) + .leftJoin(TableName.ExternalKms, `${TableName.KmsKey}.id`, `${TableName.ExternalKms}.kmsKeyId`) + .select(selectAllTableCols(TableName.KmsKey)) + .select( + db.ref("id").withSchema(TableName.InternalKms).as("internalKmsId"), + db.ref("encryptedKey").withSchema(TableName.InternalKms).as("internalKmsEncryptedKey"), + db.ref("encryptionAlgorithm").withSchema(TableName.InternalKms).as("internalKmsEncryptionAlgorithm"), + db.ref("version").withSchema(TableName.InternalKms).as("internalKmsVersion") + ) + .select( + db.ref("id").withSchema(TableName.ExternalKms).as("externalKmsId"), + db.ref("provider").withSchema(TableName.ExternalKms).as("externalKmsProvider"), + db.ref("encryptedProviderInputs").withSchema(TableName.ExternalKms).as("externalKmsEncryptedProviderInput"), + db.ref("status").withSchema(TableName.ExternalKms).as("externalKmsStatus"), + db.ref("statusDetails").withSchema(TableName.ExternalKms).as("externalKmsStatusDetails") + ) + .select( + db.ref("kmsDefaultKeyId").withSchema(TableName.Organization).as("orgKmsDefaultKeyId"), + db.ref("kmsEncryptedDataKey").withSchema(TableName.Organization).as("orgKmsEncryptedDataKey") + ); + + return results.map((result) => ({ + ...KmsKeysSchema.parse(result), + isExternal: Boolean(result?.externalKmsId), + orgKms: { + id: result?.orgKmsDefaultKeyId, + encryptedDataKey: result?.orgKmsEncryptedDataKey + }, + externalKms: result?.externalKmsId + ? { + id: result.externalKmsId, + provider: result.externalKmsProvider, + encryptedProviderInput: result.externalKmsEncryptedProviderInput, + status: result.externalKmsStatus, + statusDetails: result.externalKmsStatusDetails + } + : undefined, + internalKms: result?.internalKmsId + ? { + id: result.internalKmsId, + encryptedKey: result.internalKmsEncryptedKey, + encryptionAlgorithm: result.internalKmsEncryptionAlgorithm, + version: result.internalKmsVersion + } + : undefined + })); + } catch (error) { + throw new DatabaseError({ error, name: "Find by ids with associated kms" }); + } + }; + const findProjectCmeks = async (projectId: string, tx?: Knex) => { try { const result = await (tx || db.replicaNode())(TableName.KmsKey) @@ -180,6 +236,14 @@ export const kmskeyDALFactory = (db: TDbClient) => { } }; + const findCmeksByIds = async (ids: string[], tx?: Knex) => { + try { + return await baseCmekQuery({ db, tx }).whereIn(`${TableName.KmsKey}.id`, ids); + } catch (error) { + throw new DatabaseError({ error, name: "Find cmeks by IDs" }); + } + }; + const findCmekByName = async (keyName: string, projectId: string, tx?: Knex) => { try { const key = await baseCmekQuery({ @@ -194,5 +258,14 @@ export const kmskeyDALFactory = (db: TDbClient) => { } }; - return { ...kmsOrm, findByIdWithAssociatedKms, listCmeksByProjectId, findCmekById, findCmekByName, findProjectCmeks }; + return { + ...kmsOrm, + findByIdWithAssociatedKms, + findByIdsWithAssociatedKms, + listCmeksByProjectId, + findCmekById, + findCmeksByIds, + findCmekByName, + findProjectCmeks + }; }; diff --git a/backend/src/services/kms/kms-service.ts b/backend/src/services/kms/kms-service.ts index ba025d9a206..f7cee7ac7a1 100644 --- a/backend/src/services/kms/kms-service.ts +++ b/backend/src/services/kms/kms-service.ts @@ -43,6 +43,7 @@ import { TEncryptWithKmsDataKeyDTO, TEncryptWithKmsDTO, TGenerateKMSDTO, + TGetBulkKeyMaterialDTO, TGetKeyMaterialDTO, TGetPublicKeyDTO, TImportKeyMaterialDTO, @@ -379,12 +380,40 @@ export const kmsServiceFactory = ({ return kmsKey; }; + const getBulkKeyMaterial = async ({ kmsIds }: TGetBulkKeyMaterialDTO) => { + const kmsDocs = await kmsDAL.findByIdsWithAssociatedKms(kmsIds); + + return kmsDocs.map((kmsDoc) => { + if (kmsDoc.isReserved) { + throw new BadRequestError({ message: `Cannot get key material for reserved key [kmsId=${kmsDoc.id}]` }); + } + if (kmsDoc.externalKms) { + throw new BadRequestError({ message: `Cannot get key material for external key [kmsId=${kmsDoc.id}]` }); + } + + const keyCipher = symmetricCipherService(SymmetricKeyAlgorithm.AES_GCM_256); + const keyMaterial = keyCipher.decrypt(kmsDoc.internalKms?.encryptedKey as Buffer, ROOT_ENCRYPTION_KEY); + + return { kmsId: kmsDoc.id, name: kmsDoc.name, keyMaterial }; + }); + }; + const importKeyMaterial = async ( { key, algorithm, name, isReserved, projectId, orgId, keyUsage, kmipMetadata }: TImportKeyMaterialDTO, tx?: Knex ) => { - // daniel: currently we only support imports for encrypt/decrypt keys - verifyKeyTypeAndAlgorithm(keyUsage, algorithm, { forceType: KmsKeyUsage.ENCRYPT_DECRYPT }); + verifyKeyTypeAndAlgorithm(keyUsage, algorithm); + + if (keyUsage === KmsKeyUsage.SIGN_VERIFY) { + const { getPublicKeyFromPrivateKey } = signingService(algorithm as AsymmetricKeyAlgorithm); + try { + getPublicKeyFromPrivateKey(key); + } catch { + throw new BadRequestError({ + message: "Invalid private key material. Expected a PKCS8 PEM-encoded private key." + }); + } + } const cipher = symmetricCipherService(SymmetricKeyAlgorithm.AES_GCM_256); @@ -394,7 +423,7 @@ export const kmsServiceFactory = ({ const kmsDoc = await kmsDAL.create( { name: sanitizedName, - keyUsage: KmsKeyUsage.ENCRYPT_DECRYPT, + keyUsage, orgId, isReserved, projectId, @@ -1085,6 +1114,7 @@ export const kmsServiceFactory = ({ getKmsById, createCipherPairWithDataKey, getKeyMaterial, + getBulkKeyMaterial, importKeyMaterial, signWithKmsKey, verifyWithKmsKey, diff --git a/backend/src/services/kms/kms-types.ts b/backend/src/services/kms/kms-types.ts index 11badd7a510..27c6b457261 100644 --- a/backend/src/services/kms/kms-types.ts +++ b/backend/src/services/kms/kms-types.ts @@ -91,9 +91,13 @@ export type TGetKeyMaterialDTO = { kmsId: string; }; +export type TGetBulkKeyMaterialDTO = { + kmsIds: string[]; +}; + export type TImportKeyMaterialDTO = { key: Buffer; - algorithm: SymmetricKeyAlgorithm; + algorithm: SymmetricKeyAlgorithm | AsymmetricKeyAlgorithm; name?: string; isReserved: boolean; projectId: string; diff --git a/docs/api-reference/endpoints/kms/keys/bulk-export-private-keys.mdx b/docs/api-reference/endpoints/kms/keys/bulk-export-private-keys.mdx new file mode 100644 index 00000000000..e6725cea20b --- /dev/null +++ b/docs/api-reference/endpoints/kms/keys/bulk-export-private-keys.mdx @@ -0,0 +1,4 @@ +--- +title: "Bulk Export Private Keys" +openapi: "POST /api/v1/kms/keys/bulk-export-private-keys" +--- diff --git a/docs/docs.json b/docs/docs.json index f00db502ad8..c6e91b1f6f5 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -3239,7 +3239,8 @@ "api-reference/endpoints/kms/keys/update", "api-reference/endpoints/kms/keys/delete", "api-reference/endpoints/kms/keys/public-key", - "api-reference/endpoints/kms/keys/private-key" + "api-reference/endpoints/kms/keys/private-key", + "api-reference/endpoints/kms/keys/bulk-export-private-keys" ] }, { diff --git a/frontend/src/hooks/api/cmeks/mutations.tsx b/frontend/src/hooks/api/cmeks/mutations.tsx index a32409dffb2..c140b89a41d 100644 --- a/frontend/src/hooks/api/cmeks/mutations.tsx +++ b/frontend/src/hooks/api/cmeks/mutations.tsx @@ -4,6 +4,10 @@ import { encodeBase64 } from "@app/components/utilities/cryptography/crypto"; import { apiRequest } from "@app/config/request"; import { cmekKeys } from "@app/hooks/api/cmeks/queries"; import { + TCmekBulkExportPrivateKeysDTO, + TCmekBulkExportPrivateKeysResponse, + TCmekBulkImportKeysDTO, + TCmekBulkImportKeysResponse, TCmekDecrypt, TCmekDecryptResponse, TCmekEncrypt, @@ -130,3 +134,33 @@ export const useCmekDecrypt = () => { } }); }; + +export const useBulkExportCmekPrivateKeys = () => { + return useMutation({ + mutationFn: async ({ keyIds }: TCmekBulkExportPrivateKeysDTO) => { + const { data } = await apiRequest.post( + "/api/v1/kms/keys/bulk-export-private-keys", + { keyIds } + ); + + return data; + } + }); +}; + +export const useBulkImportCmekKeys = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ projectId, keys }: TCmekBulkImportKeysDTO) => { + const { data } = await apiRequest.post( + "/api/v1/kms/keys/bulk-import", + { projectId, keys } + ); + + return data; + }, + onSuccess: (_, { projectId }) => { + queryClient.invalidateQueries({ queryKey: cmekKeys.getCmeksByProjectId({ projectId }) }); + } + }); +}; diff --git a/frontend/src/hooks/api/cmeks/types.ts b/frontend/src/hooks/api/cmeks/types.ts index f88717bab06..ff18e9fd564 100644 --- a/frontend/src/hooks/api/cmeks/types.ts +++ b/frontend/src/hooks/api/cmeks/types.ts @@ -92,6 +92,40 @@ export type TCmekGetPrivateKeyResponse = { privateKey: string; }; +export type TCmekBulkExportPrivateKeysDTO = { + keyIds: string[]; +}; + +export type TCmekBulkExportedKey = { + keyId: string; + name: string; + keyUsage: KmsKeyUsage; + algorithm: AsymmetricKeyAlgorithm | SymmetricKeyAlgorithm; + privateKey: string; + publicKey?: string; +}; + +export type TCmekBulkExportPrivateKeysResponse = { + keys: TCmekBulkExportedKey[]; +}; + +export type TCmekBulkImportKeyEntry = { + name: string; + keyUsage: KmsKeyUsage; + encryptionAlgorithm: AsymmetricKeyAlgorithm | SymmetricKeyAlgorithm; + keyMaterial: string; +}; + +export type TCmekBulkImportKeysDTO = { + projectId: string; + keys: TCmekBulkImportKeyEntry[]; +}; + +export type TCmekBulkImportKeysResponse = { + keys: { id: string; name: string }[]; + errors: { name: string; message: string }[]; +}; + export enum CmekOrderBy { Name = "name" } diff --git a/frontend/src/pages/kms/OverviewPage/components/CmekBulkImportModal.tsx b/frontend/src/pages/kms/OverviewPage/components/CmekBulkImportModal.tsx new file mode 100644 index 00000000000..3b540627d0f --- /dev/null +++ b/frontend/src/pages/kms/OverviewPage/components/CmekBulkImportModal.tsx @@ -0,0 +1,449 @@ +import { useRef, useState } from "react"; +import { AlertTriangleIcon, UploadIcon } from "lucide-react"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + EmptyMedia, + Tooltip, + TooltipContent, + TooltipTrigger, + UnstableEmpty, + UnstableEmptyDescription, + UnstableEmptyHeader, + UnstableEmptyTitle, + UnstableTable, + UnstableTableBody, + UnstableTableCell, + UnstableTableHead, + UnstableTableHeader, + UnstableTableRow +} from "@app/components/v3"; +import { kmsKeyUsageOptions } from "@app/helpers/kms"; +import { + AsymmetricKeyAlgorithm, + KmsKeyUsage, + SymmetricKeyAlgorithm, + useBulkImportCmekKeys +} from "@app/hooks/api/cmeks"; + +type ParsedKey = { + name: string; + keyType: "encrypt-decrypt" | "sign-verify"; + algorithm: string; + keyMaterial?: string; + privateKey?: string; + publicKey?: string; +}; + +type ValidationError = { + index: number; + message: string; +}; + +const validateEntry = (entry: unknown, index: number): ValidationError | null => { + if (typeof entry !== "object" || entry === null) { + return { index, message: "must be an object" }; + } + const e = entry as Record; + + if (!e.name || typeof e.name !== "string") { + return { index, message: '"name" is required' }; + } + if (e.keyType !== "encrypt-decrypt" && e.keyType !== "sign-verify") { + return { + index, + message: '"keyType" must be "encrypt-decrypt" or "sign-verify"' + }; + } + if (!e.algorithm || typeof e.algorithm !== "string") { + return { index, message: '"algorithm" is required' }; + } + if (e.keyType === "encrypt-decrypt") { + const validSymmetric = Object.values(SymmetricKeyAlgorithm) as string[]; + if (!validSymmetric.includes(e.algorithm)) { + return { + index, + message: `"algorithm" must be one of ${validSymmetric.join(", ")} for encrypt-decrypt keys` + }; + } + if (!e.keyMaterial || typeof e.keyMaterial !== "string") { + return { + index, + message: '"keyMaterial" is required for encrypt-decrypt keys' + }; + } + } + if (e.keyType === "sign-verify") { + const validAsymmetric = Object.values(AsymmetricKeyAlgorithm) as string[]; + if (!validAsymmetric.includes(e.algorithm)) { + return { + index, + message: `"algorithm" must be one of ${validAsymmetric.join(", ")} for sign-verify keys` + }; + } + if (!e.privateKey || typeof e.privateKey !== "string") { + return { + index, + message: '"privateKey" is required for sign-verify keys' + }; + } + } + return null; +}; + +type Props = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + projectId: string; +}; + +type ImportResult = { + succeeded: { id: string; name: string }[]; + failed: { name: string; message: string }[]; +}; + +export const CmekBulkImportModal = ({ isOpen, onOpenChange, projectId }: Props) => { + const fileInputRef = useRef(null); + const [parsedKeys, setParsedKeys] = useState(null); + const [parseError, setParseError] = useState(null); + const [validationErrors, setValidationErrors] = useState([]); + const [importResult, setImportResult] = useState(null); + const [isDragging, setIsDragging] = useState(false); + const bulkImport = useBulkImportCmekKeys(); + + const reset = () => { + setParsedKeys(null); + setParseError(null); + setValidationErrors([]); + setImportResult(null); + if (fileInputRef.current) fileInputRef.current.value = ""; + }; + + const handleOpenChange = (open: boolean) => { + if (!open) reset(); + onOpenChange(open); + }; + + const handleFile = (file: File) => { + setParseError(null); + setValidationErrors([]); + const reader = new FileReader(); + const handleReadFailure = () => { + setParsedKeys(null); + setValidationErrors([]); + setParseError("Failed to read file. Please try again."); + }; + reader.onerror = handleReadFailure; + reader.onabort = handleReadFailure; + reader.onload = (e) => { + try { + const raw = JSON.parse(e.target?.result as string) as unknown; + if (!Array.isArray(raw)) { + setParseError("File must contain a JSON array."); + return; + } + if (raw.length > 100) { + setParseError( + `File contains ${raw.length} keys. A maximum of 100 keys can be imported at once.` + ); + return; + } + const errors = raw + .map((entry, i) => validateEntry(entry, i)) + .filter((err): err is ValidationError => err !== null); + setValidationErrors(errors); + setParsedKeys(raw as ParsedKey[]); + } catch { + setParseError("Could not parse file. Make sure it is valid JSON."); + } + }; + reader.readAsText(file); + }; + + const handleImport = async () => { + if (!parsedKeys) return; + try { + const { keys: imported, errors } = await bulkImport.mutateAsync({ + projectId, + keys: parsedKeys.map((k) => ({ + name: k.name, + keyUsage: k.keyType as KmsKeyUsage, + encryptionAlgorithm: k.algorithm as never, + keyMaterial: k.keyType === "sign-verify" ? (k.privateKey ?? "") : (k.keyMaterial ?? "") + })) + }); + if (errors.length === 0) { + createNotification({ + text: `Successfully imported ${imported.length} key(s)`, + type: "success" + }); + reset(); + onOpenChange(false); + } else { + setImportResult({ succeeded: imported, failed: errors }); + } + } catch { + createNotification({ text: "Failed to import keys", type: "error" }); + } + }; + + const encryptCount = parsedKeys?.filter((k) => k.keyType === "encrypt-decrypt").length ?? 0; + const signCount = parsedKeys?.filter((k) => k.keyType === "sign-verify").length ?? 0; + const errorByIndex = new Map(validationErrors.map((err) => [err.index, err.message])); + const hasErrors = validationErrors.length > 0; + + const renderContent = () => { + if (importResult) { + const total = importResult.succeeded.length + importResult.failed.length; + return ( +
+

+ + {importResult.succeeded.length} of {total} keys imported + + {importResult.failed.length > 0 && ( + — {importResult.failed.length} failed + )} +

+ + {importResult.failed.length > 0 && ( +
+

Failed imports

+
    + {importResult.failed.map((err) => ( +
  • + {err.name} + {" — "} + {err.message} +
  • + ))} +
+
+ )} + + + + + + +
+ ); + } + + if (!parsedKeys) { + return ( +
+ fileInputRef.current?.click()} + onDragOver={(e) => { + e.preventDefault(); + setIsDragging(true); + }} + onDragLeave={() => setIsDragging(false)} + onDrop={(e) => { + e.preventDefault(); + setIsDragging(false); + const file = e.dataTransfer.files[0]; + if (file) handleFile(file); + }} + > + + + + + + {isDragging ? "Drop your file here" : "Upload your keys"} + + + Drag and drop your .json file here, or click to browse + + + + + {parseError && ( +

{parseError}

+ )} + +
+

Expected format

+

The file must be a JSON array. Each entry is one of:

+
{`// Encrypt/Decrypt key
+{
+  "name": "...",
+  "keyType": "encrypt-decrypt",
+  "algorithm": "...",
+  "keyMaterial": ""
+}
+
+// Sign/Verify key
+{
+  "name": "...",
+  "keyType": "sign-verify",
+  "algorithm": "...",
+  "privateKey": "",
+  "publicKey": ""
+}`}
+
+ + + + + + +
+ ); + } + + return ( +
+ + + + + # + + + Name + + + Key Type + + + Algorithm + + + + + {parsedKeys.map((key, i) => { + const errorMsg = errorByIndex.get(i); + return ( + + +
+ {i + 1} + {errorMsg ? ( + + + + + {errorMsg} + + ) : ( + + )} +
+
+ +

{String(key.name ?? "")}

+
+ +

+ {kmsKeyUsageOptions[key.keyType as KmsKeyUsage]?.label ?? key.keyType} +

+
+ +

{String(key.algorithm ?? "")}

+
+
+ ); + })} +
+
+ + {hasErrors && ( +
+ + + {validationErrors.length} validation error + {validationErrors.length > 1 ? "s" : ""} — resolve to proceed + +
+ )} + + + + + + + + +
+ ); + }; + + const getHeaderContent = (): { title: string; description: string } => { + if (importResult) { + const total = importResult.succeeded.length + importResult.failed.length; + return { + title: "Import Results", + description: `${importResult.succeeded.length} of ${total} keys imported.` + }; + } + if (parsedKeys) { + return { + title: "Review & Import Keys", + description: `${parsedKeys.length} key${parsedKeys.length !== 1 ? "s" : ""} found — ${encryptCount} encrypt/decrypt, ${signCount} sign/verify.` + }; + } + return { + title: "Import Keys", + description: + "Upload a JSON file exported from Infisical KMS to import keys into this project." + }; + }; + + const header = getHeaderContent(); + + return ( + + + + {header.title} + {header.description} + + + { + const file = e.target.files?.[0]; + if (file) handleFile(file); + }} + /> + + {renderContent()} + + + ); +}; diff --git a/frontend/src/pages/kms/OverviewPage/components/CmekTable.tsx b/frontend/src/pages/kms/OverviewPage/components/CmekTable.tsx index 1b8eec8297e..410fee9c75d 100644 --- a/frontend/src/pages/kms/OverviewPage/components/CmekTable.tsx +++ b/frontend/src/pages/kms/OverviewPage/components/CmekTable.tsx @@ -1,7 +1,5 @@ +import { useState } from "react"; import { - faArrowDown, - faArrowUp, - faArrowUpRightFromSquare, faCancel, faCheck, faCheckCircle, @@ -9,42 +7,58 @@ import { faDownload, faEdit, faEllipsis, + faFileImport, faFileSignature, - faInfoCircle, - faKey, faLock, faLockOpen, - faMagnifyingGlass, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { motion } from "framer-motion"; +import { ChevronDownIcon, InfoIcon, SearchIcon } from "lucide-react"; +import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; import { ProjectPermissionCan } from "@app/components/permissions"; +import { Spinner } from "@app/components/v2"; import { + Badge, Button, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, - EmptyState, - IconButton, - Input, - Pagination, - Spinner, - Table, - TableContainer, - TableSkeleton, - TBody, - Td, - Th, - THead, + Checkbox, + DocumentationLinkBadge, + InputGroup, + InputGroupAddon, + InputGroupInput, + Skeleton, + TBadgeProps, Tooltip, - Tr -} from "@app/components/v2"; -import { Badge, TBadgeProps } from "@app/components/v3"; + TooltipContent, + TooltipTrigger, + UnstableButtonGroup, + UnstableCard, + UnstableCardAction, + UnstableCardContent, + UnstableCardDescription, + UnstableCardHeader, + UnstableCardTitle, + UnstableDropdownMenu, + UnstableDropdownMenuContent, + UnstableDropdownMenuItem, + UnstableDropdownMenuTrigger, + UnstableEmpty, + UnstableEmptyDescription, + UnstableEmptyHeader, + UnstableEmptyTitle, + UnstableIconButton, + UnstablePagination, + UnstableTable, + UnstableTableBody, + UnstableTableCell, + UnstableTableHead, + UnstableTableHeader, + UnstableTableRow +} from "@app/components/v3"; import { ProjectPermissionActions, ProjectPermissionCmekActions, @@ -59,7 +73,11 @@ import { setUserTablePreference } from "@app/helpers/userTablePreferences"; import { usePagination, usePopUp, useResetPageHelper, useTimedReset } from "@app/hooks"; -import { useGetCmeksByProjectId, useUpdateCmek } from "@app/hooks/api/cmeks"; +import { + useBulkExportCmekPrivateKeys, + useGetCmeksByProjectId, + useUpdateCmek +} from "@app/hooks/api/cmeks"; import { AsymmetricKeyAlgorithm, CmekOrderBy, @@ -68,6 +86,7 @@ import { } from "@app/hooks/api/cmeks/types"; import { OrderByDirection } from "@app/hooks/api/generic/types"; +import { CmekBulkImportModal } from "./CmekBulkImportModal"; import { CmekDecryptModal } from "./CmekDecryptModal"; import { CmekEncryptModal } from "./CmekEncryptModal"; import { CmekExportKeyModal } from "./CmekExportKeyModal"; @@ -75,21 +94,15 @@ import { CmekModal } from "./CmekModal"; import { CmekSignModal } from "./CmekSignModal"; import { CmekVerifyModal } from "./CmekVerifyModal"; import { DeleteCmekModal } from "./DeleteCmekModal"; +import { cmekKeysToExportJSON, downloadJSON } from "./jsonExport"; const getStatusBadgeProps = ( isDisabled: boolean ): { variant: TBadgeProps["variant"]; label: string } => { if (isDisabled) { - return { - variant: "danger", - label: "Disabled" - }; + return { variant: "danger", label: "Disabled" }; } - - return { - variant: "success", - label: "Active" - }; + return { variant: "success", label: "Active" }; }; export const CmekTable = () => { @@ -130,11 +143,12 @@ export const CmekTable = () => { }); const { keys = [], totalCount = 0 } = data ?? {}; - useResetPageHelper({ - totalCount, - offset, - setPage - }); + useResetPageHelper({ totalCount, offset, setPage }); + + const [selectedKeyIds, setSelectedKeyIds] = useState([]); + + const isPageSelected = keys.length > 0 && keys.every((k) => selectedKeyIds.includes(k.id)); + const isPageIndeterminate = !isPageSelected && keys.some((k) => selectedKeyIds.includes(k.id)); const [, isCopyingCiphertext, setCopyCipherText] = useTimedReset({ initialState: "", @@ -148,7 +162,8 @@ export const CmekTable = () => { "decryptData", "signData", "verifyData", - "exportKey" + "exportKey", + "importKeys" ] as const); const handleSort = () => { @@ -158,55 +173,67 @@ export const CmekTable = () => { }; const updateCmek = useUpdateCmek(); + const bulkExportMutation = useBulkExportCmekPrivateKeys(); const handleDisableCmek = async ({ id: keyId, isDisabled }: TCmek) => { - await updateCmek.mutateAsync({ - keyId, - projectId, - isDisabled: !isDisabled - }); - + await updateCmek.mutateAsync({ keyId, projectId, isDisabled: !isDisabled }); createNotification({ text: `Key successfully ${isDisabled ? "enabled" : "disabled"}`, type: "success" }); }; + const handleBulkExport = async () => { + if (selectedKeyIds.length > 100) { + createNotification({ text: "Cannot export more than 100 keys at once", type: "error" }); + return; + } + + const { keys: exportedKeys } = await bulkExportMutation.mutateAsync({ + keyIds: selectedKeyIds + }); + + try { + const exportData = cmekKeysToExportJSON(exportedKeys); + downloadJSON(exportData, `kms-keys-export-${new Date().toISOString().slice(0, 10)}.json`); + setSelectedKeyIds([]); + createNotification({ + text: `Successfully exported ${exportedKeys.length} key(s)`, + type: "success" + }); + } catch { + createNotification({ text: "Failed to export keys", type: "error" }); + } + }; + const cannotEditKey = permission.cannot( ProjectPermissionCmekActions.Edit, ProjectPermissionSub.Cmek ); - const cannotDeleteKey = permission.cannot( ProjectPermissionCmekActions.Delete, ProjectPermissionSub.Cmek ); - const cannotEncryptData = permission.cannot( ProjectPermissionCmekActions.Encrypt, ProjectPermissionSub.Cmek ); - const cannotDecryptData = permission.cannot( ProjectPermissionCmekActions.Decrypt, ProjectPermissionSub.Cmek ); - const cannotSignData = permission.cannot( ProjectPermissionCmekActions.Sign, ProjectPermissionSub.Cmek ); - const cannotVerifyData = permission.cannot( ProjectPermissionCmekActions.Verify, ProjectPermissionSub.Cmek ); - const cannotExportPrivateKey = permission.cannot( ProjectPermissionCmekActions.ExportPrivateKey, ProjectPermissionSub.Cmek ); - const cannotReadKey = permission.cannot( ProjectPermissionCmekActions.Read, ProjectPermissionSub.Cmek @@ -220,397 +247,445 @@ export const CmekTable = () => { animate={{ opacity: 1, translateX: 0 }} exit={{ opacity: 0, translateX: 30 }} > -
-
-

Keys

- - +
0 && "h-16" + )} + > +
+
{selectedKeyIds.length} Selected
+ + {(isAllowed) => ( - + + + + + + + + {isAllowed + ? "Export all selected keys as a JSON file" + : "You don't have permission to export keys"} + + )}
- setSearch(e.target.value)} - leftIcon={} - placeholder="Search keys by name..." - /> - - - - - - - - - - - - - - - {isPending && } - {!isPending && - keys.length > 0 && - keys.map((cmek) => { - const { - name, - id, - version, - description, - encryptionAlgorithm, - isDisabled, - keyUsage - } = cmek; - const { variant, label } = getStatusBadgeProps(isDisabled); + - return ( - { - setCopyCipherText(""); - }} - > - - - - - - - - - ); - })} - -
-
- Name - - - -
-
Key IDKey UsageAlgorithmStatusVersion{isFetching ? : null}
-
- {name} - {description && ( - - - - )} -
-
-
- {id} - { - navigator.clipboard.writeText(id); - setCopyCipherText("Copied"); - }} + + + + Keys + + + + Manage keys and perform cryptographic operations. + + + + + {(isAllowed) => ( + + + + + Access Denied + + )} + + + + + + + + + + {(isAllowed) => ( + + + handlePopUpOpen("importKeys")} + isDisabled={!isAllowed} > - - -
-
-
- {kmsKeyUsageOptions[keyUsage].label} - - - -
-
{encryptionAlgorithm} - {label} - {version} -
- - - - - - - - {keyUsage === KmsKeyUsage.ENCRYPT_DECRYPT && ( - <> - -
- handlePopUpOpen("encryptData", cmek)} - icon={} - iconPos="left" - isDisabled={cannotEncryptData || isDisabled} - > - Encrypt Data - -
-
- -
- handlePopUpOpen("decryptData", cmek)} - icon={} - iconPos="left" - isDisabled={cannotDecryptData || isDisabled} - > - Decrypt Data - -
-
- - )} + + Import Keys + + + Access Restricted + + )} + + + + + + + +
+ + + + + { + setSearch(e.target.value); + }} + placeholder="Search keys by name..." + /> + + {isFetching && } +
- {keyUsage === KmsKeyUsage.SIGN_VERIFY && ( - <> - -
- handlePopUpOpen("signData", cmek)} - icon={} - iconPos="left" - isDisabled={cannotSignData || isDisabled} - > - Sign Data - -
-
- -
- handlePopUpOpen("verifyData", cmek)} - icon={} - iconPos="left" - isDisabled={cannotVerifyData || isDisabled} - > - Verify Data - -
-
- - )} + {!isPending && keys.length === 0 ? ( + + + + {debouncedSearch.trim().length > 0 + ? "No keys match search filter" + : "No keys have been added to this project"} + + + {debouncedSearch.trim().length > 0 + ? "Try a different search term." + : "Add a key to get started."} + + + + ) : ( + + + + + { + if (isPageSelected) { + setSelectedKeyIds((prev) => + prev.filter((id) => !keys.find((k) => k.id === id)) + ); + } else { + setSelectedKeyIds((prev) => { + const merged = [...new Set([...prev, ...keys.map((k) => k.id)])]; + return merged.slice(0, 100); + }); + } + }} + /> + + + Name + + + Key ID + Key Usage + Algorithm + Status + Version + + + + + {isPending && + Array.from({ length: 5 }).map((_, i) => ( + // eslint-disable-next-line react/no-array-index-key + + {Array.from({ length: 8 }).map((__, j) => ( + // eslint-disable-next-line react/no-array-index-key + + + + ))} + + ))} + {!isPending && + keys.map((cmek) => { + const { + name, + id, + version, + description, + encryptionAlgorithm, + isDisabled, + keyUsage + } = cmek; + const { variant, label } = getStatusBadgeProps(isDisabled); + const isSelected = selectedKeyIds.includes(id); - {(() => { - // For asymmetric keys, user can export if they have Read OR ExportPrivateKey permission - // For symmetric keys, user needs ExportPrivateKey permission - const isAsymmetricKey = Object.values( - AsymmetricKeyAlgorithm - ).includes(encryptionAlgorithm as AsymmetricKeyAlgorithm); - const cannotExportKey = isAsymmetricKey - ? cannotExportPrivateKey && cannotReadKey - : cannotExportPrivateKey; + const isAsymmetricKey = Object.values(AsymmetricKeyAlgorithm).includes( + encryptionAlgorithm as AsymmetricKeyAlgorithm + ); + const cannotExportKey = isAsymmetricKey + ? cannotExportPrivateKey && cannotReadKey + : cannotExportPrivateKey; - return ( - -
- handlePopUpOpen("exportKey", cmek)} - icon={} - iconPos="left" - isDisabled={cannotExportKey || isDisabled} - > - Export Key - -
-
- ); - })()} - -
- handlePopUpOpen("upsertKey", cmek)} - icon={} - iconPos="left" - isDisabled={cannotEditKey} - > - Edit Key - -
-
- -
- handleDisableCmek(cmek)} - icon={ - - } - iconPos="left" - isDisabled={cannotEditKey} - > - {isDisabled ? "Enable" : "Disable"} Key - -
-
- -
- handlePopUpOpen("deleteKey", cmek)} - icon={} - iconPos="left" - isDisabled={cannotDeleteKey} - > - Delete Key - -
+ return ( + setCopyCipherText("")} + > + + { + e.stopPropagation(); + if (!isSelected && selectedKeyIds.length >= 100) { + createNotification({ + text: "Cannot select more than 100 keys at once", + type: "error" + }); + return; + } + setSelectedKeyIds((prev) => + isSelected ? prev.filter((k) => k !== id) : [...prev, id] + ); + }} + /> + + +
+ {name} + {description && ( + + + + + {description} - - -
-
+ )} +
+ + +
+ {id} + { + navigator.clipboard.writeText(id); + setCopyCipherText("Copied"); + }} + > + + +
+
+ +
+ {kmsKeyUsageOptions[keyUsage].label} + + + + + + {kmsKeyUsageOptions[keyUsage].tooltip} + + +
+
+ + {encryptionAlgorithm} + + + {label} + + {version} + +
+ + + + + + + + {keyUsage === KmsKeyUsage.ENCRYPT_DECRYPT && ( + <> + handlePopUpOpen("encryptData", cmek)} + isDisabled={cannotEncryptData || isDisabled} + > + + Encrypt Data + + handlePopUpOpen("decryptData", cmek)} + isDisabled={cannotDecryptData || isDisabled} + > + + Decrypt Data + + + )} + {keyUsage === KmsKeyUsage.SIGN_VERIFY && ( + <> + handlePopUpOpen("signData", cmek)} + isDisabled={cannotSignData || isDisabled} + > + + Sign Data + + handlePopUpOpen("verifyData", cmek)} + isDisabled={cannotVerifyData || isDisabled} + > + + Verify Data + + + )} + handlePopUpOpen("exportKey", cmek)} + isDisabled={cannotExportKey || isDisabled} + > + + Export Key + + handlePopUpOpen("upsertKey", cmek)} + isDisabled={cannotEditKey} + > + + Edit Key + + handleDisableCmek(cmek)} + isDisabled={cannotEditKey} + > + + {isDisabled ? "Enable" : "Disable"} Key + + handlePopUpOpen("deleteKey", cmek)} + isDisabled={cannotDeleteKey} + variant="danger" + > + + Delete Key + + + +
+
+ + ); + })} + + + )} + {!isPending && totalCount > 0 && ( - setPage(newPage)} + onChangePage={setPage} onChangePerPage={handlePerPageChange} /> )} - {!isPending && keys.length === 0 && ( - 0 - ? "No keys match search filter" - : "No keys have been added to this project" - } - icon={faKey} - /> - )} - - handlePopUpToggle("deleteKey", isOpen)} - cmek={popUp.deleteKey.data as TCmek} - /> - handlePopUpToggle("upsertKey", isOpen)} - cmek={popUp.upsertKey.data as TCmek | null} - /> - handlePopUpToggle("encryptData", isOpen)} - cmek={popUp.encryptData.data as TCmek} - /> - handlePopUpToggle("decryptData", isOpen)} - cmek={popUp.decryptData.data as TCmek} - /> - handlePopUpToggle("signData", isOpen)} - cmek={popUp.signData.data as TCmek} - /> - handlePopUpToggle("verifyData", isOpen)} - cmek={popUp.verifyData.data as TCmek} - /> - handlePopUpToggle("exportKey", isOpen)} - cmek={popUp.exportKey.data as TCmek} - /> -
+ + + + handlePopUpToggle("deleteKey", isOpen)} + cmek={popUp.deleteKey.data as TCmek} + /> + handlePopUpToggle("upsertKey", isOpen)} + cmek={popUp.upsertKey.data as TCmek | null} + /> + handlePopUpToggle("encryptData", isOpen)} + cmek={popUp.encryptData.data as TCmek} + /> + handlePopUpToggle("decryptData", isOpen)} + cmek={popUp.decryptData.data as TCmek} + /> + handlePopUpToggle("signData", isOpen)} + cmek={popUp.signData.data as TCmek} + /> + handlePopUpToggle("verifyData", isOpen)} + cmek={popUp.verifyData.data as TCmek} + /> + handlePopUpToggle("exportKey", isOpen)} + cmek={popUp.exportKey.data as TCmek} + /> + handlePopUpToggle("importKeys", isOpen)} + projectId={projectId} + /> ); }; diff --git a/frontend/src/pages/kms/OverviewPage/components/jsonExport.ts b/frontend/src/pages/kms/OverviewPage/components/jsonExport.ts new file mode 100644 index 00000000000..d6a4eb1819e --- /dev/null +++ b/frontend/src/pages/kms/OverviewPage/components/jsonExport.ts @@ -0,0 +1,46 @@ +import FileSaver from "file-saver"; + +import { KmsKeyUsage, TCmekBulkExportedKey } from "@app/hooks/api/cmeks/types"; + +type CmekExportEntry = + | { + name: string; + keyType: "encrypt-decrypt"; + algorithm: string; + keyMaterial: string; + } + | { + name: string; + keyType: "sign-verify"; + algorithm: string; + privateKey: string; + publicKey: string; + }; + +export const cmekKeysToExportJSON = (keys: TCmekBulkExportedKey[]): CmekExportEntry[] => { + return keys.map((key) => { + if (key.keyUsage === KmsKeyUsage.SIGN_VERIFY) { + return { + name: key.name, + keyType: "sign-verify" as const, + algorithm: key.algorithm, + privateKey: key.privateKey, + publicKey: key.publicKey ?? "" + }; + } + + return { + name: key.name, + keyType: "encrypt-decrypt" as const, + algorithm: key.algorithm, + keyMaterial: key.privateKey + }; + }); +}; + +export const downloadJSON = (data: unknown, filename: string) => { + const blob = new Blob([JSON.stringify(data, null, 2)], { + type: "application/json;charset=utf-8;" + }); + FileSaver.saveAs(blob, filename); +};