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 8dd84ceabac..bdc63c0a31a 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -476,6 +476,7 @@ export enum EventType { 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", @@ -3675,6 +3676,15 @@ interface CmekBulkGetPrivateKeysEvent { }; } +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 @@ -6453,6 +6463,7 @@ export type Event = | CmekGetPublicKeyEvent | CmekGetPrivateKeyEvent | CmekBulkGetPrivateKeysEvent + | CmekBulkImportKeysEvent | GetExternalGroupOrgRoleMappingsEvent | UpdateExternalGroupOrgRoleMappingsEvent | GetProjectTemplatesEvent diff --git a/backend/src/server/routes/v1/cmek-router.ts b/backend/src/server/routes/v1/cmek-router.ts index 0a6ded8679e..d1c8481f491 100644 --- a/backend/src/server/routes/v1/cmek-router.ts +++ b/backend/src/server/routes/v1/cmek-router.ts @@ -518,6 +518,101 @@ 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` + }); + } + if (!isBase64(data.keyMaterial)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["keyMaterial"], + message: "keyMaterial must be base64 encoded" + }); + } + }) + ) + .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", diff --git a/backend/src/services/cmek/cmek-service.ts b/backend/src/services/cmek/cmek-service.ts index 0a176ebf212..53123c17e83 100644 --- a/backend/src/services/cmek/cmek-service.ts +++ b/backend/src/services/cmek/cmek-service.ts @@ -9,6 +9,8 @@ import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors"; import { OrgServiceActor } from "@app/lib/types"; import { TCmekBulkGetPrivateKeysDTO, + TCmekBulkImportKeysDTO, + TCmekBulkImportKeysResult, TCmekDecryptDTO, TCmekEncryptDTO, TCmekGetPrivateKeyDTO, @@ -493,6 +495,62 @@ export const cmekServiceFactory = ({ kmsService, kmsDAL, permissionService }: TC }; }; + const bulkImportKeys = async ( + { projectId, keys }: TCmekBulkImportKeysDTO, + actor: OrgServiceActor + ): Promise => { + 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, @@ -507,6 +565,7 @@ export const cmekServiceFactory = ({ kmsService, kmsDAL, permissionService }: TC listSigningAlgorithms, getPublicKey, getPrivateKey, - bulkGetPrivateKeys + bulkGetPrivateKeys, + bulkImportKeys }; }; diff --git a/backend/src/services/cmek/cmek-types.ts b/backend/src/services/cmek/cmek-types.ts index d1b3267182c..ff5c7b16f6a 100644 --- a/backend/src/services/cmek/cmek-types.ts +++ b/backend/src/services/cmek/cmek-types.ts @@ -61,6 +61,24 @@ 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 TCmekBulkImportKeysResult = { + keys: { id: string; name: string }[]; + errors: { name: string; message: string }[]; + projectId: string; +}; + export type TCmekSignDTO = { keyId: string; data: string; diff --git a/backend/src/services/kms/kms-service.ts b/backend/src/services/kms/kms-service.ts index 28ddbdad7bd..3c232c2c798 100644 --- a/backend/src/services/kms/kms-service.ts +++ b/backend/src/services/kms/kms-service.ts @@ -404,8 +404,27 @@ export const kmsServiceFactory = ({ { 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.ENCRYPT_DECRYPT) { + const expectedLength = getByteLengthForSymmetricEncryptionAlgorithm(algorithm as SymmetricKeyAlgorithm); + if (key.length !== expectedLength) { + throw new BadRequestError({ + message: `Invalid key material length for ${algorithm}. Expected ${expectedLength} bytes, got ${key.length}.` + }); + } + } + + 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); @@ -415,7 +434,7 @@ export const kmsServiceFactory = ({ const kmsDoc = await kmsDAL.create( { name: sanitizedName, - keyUsage: KmsKeyUsage.ENCRYPT_DECRYPT, + keyUsage, orgId, isReserved, projectId, diff --git a/backend/src/services/kms/kms-types.ts b/backend/src/services/kms/kms-types.ts index f2c05b6ad89..27c6b457261 100644 --- a/backend/src/services/kms/kms-types.ts +++ b/backend/src/services/kms/kms-types.ts @@ -97,7 +97,7 @@ export type TGetBulkKeyMaterialDTO = { export type TImportKeyMaterialDTO = { key: Buffer; - algorithm: SymmetricKeyAlgorithm; + algorithm: SymmetricKeyAlgorithm | AsymmetricKeyAlgorithm; name?: string; isReserved: boolean; projectId: string; diff --git a/docs/documentation/platform/kms/overview.mdx b/docs/documentation/platform/kms/overview.mdx index bf2cf69f040..9ae7150e947 100644 --- a/docs/documentation/platform/kms/overview.mdx +++ b/docs/documentation/platform/kms/overview.mdx @@ -354,6 +354,57 @@ In the following steps, we explore how to verify data using an existing key in I +## Bulk Export & Import + +Infisical KMS supports bulk export and bulk import of key material, allowing you to export multiple keys at once or import keys in bulk from an external file. + +### Bulk Export + +1. Navigate to **Project > Key Management**. +2. Use the checkboxes to select the keys you want to export (up to 100 at a time). +3. Click the **Export** button that appears in the action bar above the table. +![kms bulk export](/images/platform/kms/infisical-kms/kms-bulk-export.png) +4. A `.json` file is downloaded containing the key metadata and key material. + +The exported file is a JSON array where each entry is one of the following shapes: + +```json +// Encrypt/Decrypt key +{ + "name": "my-aes-key", + "keyType": "encrypt-decrypt", + "algorithm": "aes-256-gcm", + "keyMaterial": "" +} + +// Sign/Verify key +{ + "name": "my-rsa-key", + "keyType": "sign-verify", + "algorithm": "rsa-4096", + "privateKey": "", + "publicKey": "" +} +``` + + + The exported file contains sensitive cryptographic key material. Store it securely and do not share it. + + +### Bulk Import + +1. Navigate to **Project > Key Management**. +2. In the top-right of the keys table, click the dropdown arrow next to **Add Key** and select **Import Keys**. +3. Upload a `.json` file matching the export format described above (drag and drop, or click to browse). +![kms import modal](/images/platform/kms/infisical-kms/kms-import-modal.png) +4. A preview of the keys to be imported is shown, along with any validation errors. +![kms import preview](/images/platform/kms/infisical-kms/kms-import-preview.png) +5. Click **Import** to create the keys in the project. + + + Importing creates new key entries. It does not overwrite existing keys with the same name. + + ## FAQ diff --git a/docs/images/platform/kms/infisical-kms/kms-add-key-modal.png b/docs/images/platform/kms/infisical-kms/kms-add-key-modal.png index c1738eb4379..9d27f80aa46 100644 Binary files a/docs/images/platform/kms/infisical-kms/kms-add-key-modal.png and b/docs/images/platform/kms/infisical-kms/kms-add-key-modal.png differ diff --git a/docs/images/platform/kms/infisical-kms/kms-add-key.png b/docs/images/platform/kms/infisical-kms/kms-add-key.png index 4fdc448986d..cbad968d52f 100644 Binary files a/docs/images/platform/kms/infisical-kms/kms-add-key.png and b/docs/images/platform/kms/infisical-kms/kms-add-key.png differ diff --git a/docs/images/platform/kms/infisical-kms/kms-bulk-export.png b/docs/images/platform/kms/infisical-kms/kms-bulk-export.png new file mode 100644 index 00000000000..22302ac3836 Binary files /dev/null and b/docs/images/platform/kms/infisical-kms/kms-bulk-export.png differ diff --git a/docs/images/platform/kms/infisical-kms/kms-decrypt-data.png b/docs/images/platform/kms/infisical-kms/kms-decrypt-data.png index 63d4e2aee7e..608b05dd515 100644 Binary files a/docs/images/platform/kms/infisical-kms/kms-decrypt-data.png and b/docs/images/platform/kms/infisical-kms/kms-decrypt-data.png differ diff --git a/docs/images/platform/kms/infisical-kms/kms-decrypt-options.png b/docs/images/platform/kms/infisical-kms/kms-decrypt-options.png index e07a701962e..ea20e721298 100644 Binary files a/docs/images/platform/kms/infisical-kms/kms-decrypt-options.png and b/docs/images/platform/kms/infisical-kms/kms-decrypt-options.png differ diff --git a/docs/images/platform/kms/infisical-kms/kms-decrypted-data.png b/docs/images/platform/kms/infisical-kms/kms-decrypted-data.png index 0d9ad82b8f0..ec0ad6d50de 100644 Binary files a/docs/images/platform/kms/infisical-kms/kms-decrypted-data.png and b/docs/images/platform/kms/infisical-kms/kms-decrypted-data.png differ diff --git a/docs/images/platform/kms/infisical-kms/kms-encrypt-data.png b/docs/images/platform/kms/infisical-kms/kms-encrypt-data.png index d73f1cc3b6c..665c08452eb 100644 Binary files a/docs/images/platform/kms/infisical-kms/kms-encrypt-data.png and b/docs/images/platform/kms/infisical-kms/kms-encrypt-data.png differ diff --git a/docs/images/platform/kms/infisical-kms/kms-encrypted-data.png b/docs/images/platform/kms/infisical-kms/kms-encrypted-data.png index 33f896183f3..fa3cb5f5500 100644 Binary files a/docs/images/platform/kms/infisical-kms/kms-encrypted-data.png and b/docs/images/platform/kms/infisical-kms/kms-encrypted-data.png differ diff --git a/docs/images/platform/kms/infisical-kms/kms-import-modal.png b/docs/images/platform/kms/infisical-kms/kms-import-modal.png new file mode 100644 index 00000000000..49077fcf531 Binary files /dev/null and b/docs/images/platform/kms/infisical-kms/kms-import-modal.png differ diff --git a/docs/images/platform/kms/infisical-kms/kms-import-preview.png b/docs/images/platform/kms/infisical-kms/kms-import-preview.png new file mode 100644 index 00000000000..6408ee63380 Binary files /dev/null and b/docs/images/platform/kms/infisical-kms/kms-import-preview.png differ diff --git a/docs/images/platform/kms/infisical-kms/kms-key-options.png b/docs/images/platform/kms/infisical-kms/kms-key-options.png index b1e45e35695..81df27c79bf 100644 Binary files a/docs/images/platform/kms/infisical-kms/kms-key-options.png and b/docs/images/platform/kms/infisical-kms/kms-key-options.png differ diff --git a/docs/images/platform/kms/infisical-kms/signing/add-new-rsa-key.png b/docs/images/platform/kms/infisical-kms/signing/add-new-rsa-key.png index 97d7ca246a0..5dca98fa885 100644 Binary files a/docs/images/platform/kms/infisical-kms/signing/add-new-rsa-key.png and b/docs/images/platform/kms/infisical-kms/signing/add-new-rsa-key.png differ diff --git a/docs/images/platform/kms/infisical-kms/signing/copy-signature.png b/docs/images/platform/kms/infisical-kms/signing/copy-signature.png index 2644b358b36..658d113084d 100644 Binary files a/docs/images/platform/kms/infisical-kms/signing/copy-signature.png and b/docs/images/platform/kms/infisical-kms/signing/copy-signature.png differ diff --git a/docs/images/platform/kms/infisical-kms/signing/sign-data-modal.png b/docs/images/platform/kms/infisical-kms/signing/sign-data-modal.png index da8a01438a8..09465da941f 100644 Binary files a/docs/images/platform/kms/infisical-kms/signing/sign-data-modal.png and b/docs/images/platform/kms/infisical-kms/signing/sign-data-modal.png differ diff --git a/docs/images/platform/kms/infisical-kms/signing/sign-options.png b/docs/images/platform/kms/infisical-kms/signing/sign-options.png index 7129c1d5b73..0ffa2030ec6 100644 Binary files a/docs/images/platform/kms/infisical-kms/signing/sign-options.png and b/docs/images/platform/kms/infisical-kms/signing/sign-options.png differ diff --git a/docs/images/platform/kms/infisical-kms/signing/signature-verified.png b/docs/images/platform/kms/infisical-kms/signing/signature-verified.png index 70b7856b660..ef2a79cef76 100644 Binary files a/docs/images/platform/kms/infisical-kms/signing/signature-verified.png and b/docs/images/platform/kms/infisical-kms/signing/signature-verified.png differ diff --git a/docs/images/platform/kms/infisical-kms/signing/verify-data-modal.png b/docs/images/platform/kms/infisical-kms/signing/verify-data-modal.png index 6d3683c9fe6..69f90d05630 100644 Binary files a/docs/images/platform/kms/infisical-kms/signing/verify-data-modal.png and b/docs/images/platform/kms/infisical-kms/signing/verify-data-modal.png differ diff --git a/frontend/src/hooks/api/cmeks/mutations.tsx b/frontend/src/hooks/api/cmeks/mutations.tsx index f322ff9e11e..c140b89a41d 100644 --- a/frontend/src/hooks/api/cmeks/mutations.tsx +++ b/frontend/src/hooks/api/cmeks/mutations.tsx @@ -6,6 +6,8 @@ import { cmekKeys } from "@app/hooks/api/cmeks/queries"; import { TCmekBulkExportPrivateKeysDTO, TCmekBulkExportPrivateKeysResponse, + TCmekBulkImportKeysDTO, + TCmekBulkImportKeysResponse, TCmekDecrypt, TCmekDecryptResponse, TCmekEncrypt, @@ -145,3 +147,20 @@ export const useBulkExportCmekPrivateKeys = () => { } }); }; + +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 35ff6a01402..ff18e9fd564 100644 --- a/frontend/src/hooks/api/cmeks/types.ts +++ b/frontend/src/hooks/api/cmeks/types.ts @@ -109,6 +109,23 @@ 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..f4cca1207b6 --- /dev/null +++ b/frontend/src/pages/kms/OverviewPage/components/CmekBulkImportModal.tsx @@ -0,0 +1,511 @@ +import { useRef, useState } from "react"; +import slugify from "@sindresorhus/slugify"; +import { AlertTriangleIcon, InfoIcon, UploadIcon } from "lucide-react"; + +import { createNotification } from "@app/components/notifications"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, + Alert, + AlertTitle, + Button, + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + Tooltip, + TooltipContent, + TooltipTrigger +} 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 KEY_NAME_MAX_LENGTH = 32; + +const SYMMETRIC_KEY_BYTE_LENGTHS: Record = { + [SymmetricKeyAlgorithm.AES_GCM_128]: 16, + [SymmetricKeyAlgorithm.AES_GCM_256]: 32 +}; + +const BASE64_REGEX = + /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{4})$/; + +const isBase64 = (value: string) => + value.length > 0 && value.length % 4 === 0 && BASE64_REGEX.test(value); + +const getBase64PaddingLength = (value: string) => { + if (value.endsWith("==")) return 2; + if (value.endsWith("=")) return 1; + return 0; +}; + +const getBase64ByteLength = (value: string) => { + if (!isBase64(value)) return null; + return (value.length * 3) / 4 - getBase64PaddingLength(value); +}; + +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.name.length > KEY_NAME_MAX_LENGTH) { + return { + index, + message: `"name" must be at most ${KEY_NAME_MAX_LENGTH} characters` + }; + } + if (slugify(e.name, { lowercase: true }) !== e.name) { + return { + index, + message: '"name" can only contain lowercase letters, numbers, and hyphens' + }; + } + 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 (!isBase64(e.keyMaterial)) { + return { index, message: '"keyMaterial" must be base64 encoded' }; + } + const expectedLength = SYMMETRIC_KEY_BYTE_LENGTHS[e.algorithm as SymmetricKeyAlgorithm]; + const actualLength = getBase64ByteLength(e.keyMaterial); + if (actualLength !== expectedLength) { + return { + index, + message: `"keyMaterial" must decode to ${expectedLength} bytes for ${e.algorithm} (got ${actualLength ?? "invalid"})` + }; + } + } + 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' + }; + } + if (!isBase64(e.privateKey)) { + return { index, message: '"privateKey" must be base64 encoded' }; + } + } + 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 ecee0beb7a5..26c264b7bc7 100644 --- a/frontend/src/pages/kms/OverviewPage/components/CmekTable.tsx +++ b/frontend/src/pages/kms/OverviewPage/components/CmekTable.tsx @@ -7,6 +7,7 @@ import { faDownload, faEdit, faEllipsis, + faFileImport, faFileSignature, faLock, faLockOpen, @@ -24,6 +25,7 @@ import { Spinner } from "@app/components/v2"; import { Badge, Button, + ButtonGroup, Card, CardAction, CardContent, @@ -84,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"; @@ -166,7 +169,8 @@ export const CmekTable = () => { "decryptData", "signData", "verifyData", - "exportKey" + "exportKey", + "importKeys" ] as const); const handleSort = () => { @@ -308,18 +312,57 @@ export const CmekTable = () => { Manage keys and perform cryptographic operations. - - {(isAllowed) => ( - - )} - + + + {(isAllowed) => ( + + + + + Access Denied + + )} + + + + + + + + + + {(isAllowed) => ( + + + handlePopUpOpen("importKeys")} + isDisabled={!isAllowed} + > + + Import Keys + + + Access Restricted + + )} + + + + @@ -662,6 +705,11 @@ export const CmekTable = () => { onOpenChange={(isOpen) => handlePopUpToggle("exportKey", isOpen)} cmek={popUp.exportKey.data as TCmek} /> + handlePopUpToggle("importKeys", isOpen)} + projectId={projectId} + /> ); };