diff --git a/backend/src/ee/routes/v1/project-router.ts b/backend/src/ee/routes/v1/project-router.ts index 23b755998..2a78ac348 100644 --- a/backend/src/ee/routes/v1/project-router.ts +++ b/backend/src/ee/routes/v1/project-router.ts @@ -4,7 +4,7 @@ import { AuditLogsSchema, SecretSnapshotsSchema } from "@app/db/schemas"; import { EventType, UserAgentType } from "@app/ee/services/audit-log/audit-log-types"; import { AUDIT_LOGS, PROJECTS } from "@app/lib/api-docs"; import { getLastMidnightDateISO, removeTrailingSlash } from "@app/lib/fn"; -import { readLimit } from "@app/server/config/rateLimiter"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -205,7 +205,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { method: "PATCH", url: "/:workspaceId/kms", config: { - rateLimit: readLimit + rateLimit: writeLimit }, schema: { params: z.object({ @@ -292,4 +292,50 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { return backup; } }); + + server.route({ + method: "POST", + url: "/:workspaceId/kms/backup", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + workspaceId: z.string().trim() + }), + body: z.object({ + backup: z.string().min(1) + }), + response: { + 200: z.object({ + secretManagerKmsKey: z.object({ + id: z.string(), + slug: z.string(), + isExternal: z.boolean() + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const backup = await server.services.project.loadProjectKmsBackup({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: req.params.workspaceId, + backup: req.body.backup + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.params.workspaceId, + event: { + type: EventType.LOAD_PROJECT_KMS_BACKUP + } + }); + + return backup; + } + }); }; 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 71714b2bf..3f9f9b468 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -145,7 +145,8 @@ export enum EventType { DELETE_KMS = "delete-kms", GET_KMS = "get-kms", UPDATE_PROJECT_KMS = "update-project-kms", - GET_PROJECT_KMS_BACKUP = "get-project-kms-backup" + GET_PROJECT_KMS_BACKUP = "get-project-kms-backup", + LOAD_PROJECT_KMS_BACKUP = "load-project-kms-backup" } interface UserActorMetadata { @@ -1228,6 +1229,10 @@ interface GetProjectKmsBackupEvent { type: EventType.GET_PROJECT_KMS_BACKUP; } +interface LoadProjectKmsBackupEvent { + type: EventType.LOAD_PROJECT_KMS_BACKUP; +} + export type Event = | GetSecretsEvent | GetSecretEvent @@ -1335,4 +1340,5 @@ export type Event = | DeleteKmsEvent | GetKmsEvent | UpdateProjectKmsEvent - | GetProjectKmsBackupEvent; + | GetProjectKmsBackupEvent + | LoadProjectKmsBackupEvent; diff --git a/backend/src/lib/crypto/encryption.ts b/backend/src/lib/crypto/encryption.ts index 6af20862b..b7a4e10d2 100644 --- a/backend/src/lib/crypto/encryption.ts +++ b/backend/src/lib/crypto/encryption.ts @@ -116,6 +116,8 @@ export const decryptAsymmetric = ({ ciphertext, nonce, publicKey, privateKey }: export const generateSymmetricKey = (size = 32) => crypto.randomBytes(size).toString("base64"); +export const generateHash = (value: string) => crypto.createHash("sha256").update(value).digest("hex"); + export const generateAsymmetricKeyPair = () => { const pair = nacl.box.keyPair(); diff --git a/backend/src/services/kms/kms-service.ts b/backend/src/services/kms/kms-service.ts index 4c099141c..f977c01d7 100644 --- a/backend/src/services/kms/kms-service.ts +++ b/backend/src/services/kms/kms-service.ts @@ -11,6 +11,7 @@ import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig } from "@app/lib/config/env"; import { randomSecureBytes } from "@app/lib/crypto"; import { symmetricCipherService, SymmetricEncryption } from "@app/lib/crypto/cipher"; +import { generateHash } from "@app/lib/crypto/encryption"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { alphaNumericNanoId } from "@app/lib/nanoid"; @@ -598,16 +599,73 @@ export const kmsServiceFactory = ({ const kmsEncryptor = await encryptWithKmsKey({ kmsId: kmsKeyIdForEncrypt }); const { cipherTextBlob: encryptedSecretManagerDataKey } = await kmsEncryptor({ plainText: secretManagerDataKey }); - // format: version.projectId.kmsFunction.kmsId.Base64(encryptedDataKey) - const secretManagerBackup = `v1.${projectId}.secretManager.${kmsKeyIdForEncrypt}.${encryptedSecretManagerDataKey.toString( + // backup format: version.projectId.kmsFunction.kmsId.Base64(encryptedDataKey).verificationHash + let secretManagerBackup = `v1.${projectId}.secretManager.${kmsKeyIdForEncrypt}.${encryptedSecretManagerDataKey.toString( "base64" )}`; + const verificationHash = generateHash(secretManagerBackup); + secretManagerBackup = `${secretManagerBackup}.${verificationHash}`; + return { secretManager: secretManagerBackup }; }; + const loadProjectKeyBackup = async (projectId: string, backup: string) => { + const project = await projectDAL.findById(projectId); + if (!project) { + throw new NotFoundError({ + message: "Project not found" + }); + } + + const [, backupProjectId, , backupKmsKeyId, backupBase64EncryptedDataKey, backupHash] = backup.split("."); + const computedHash = generateHash(backup.substring(0, backup.lastIndexOf("."))); + if (computedHash !== backupHash) { + throw new BadRequestError({ + message: "Invalid backup" + }); + } + + if (backupProjectId !== projectId) { + throw new BadRequestError({ + message: "Invalid backup for project" + }); + } + + const kmsDecryptor = await decryptWithKmsKey({ kmsId: backupKmsKeyId }); + const dataKey = await kmsDecryptor({ + cipherTextBlob: Buffer.from(backupBase64EncryptedDataKey, "base64") + }); + + const newKms = await kmsDAL.transaction(async (tx) => { + const key = await generateKmsKey({ + isReserved: true, + orgId: project.orgId, + tx + }); + + const kmsEncryptor = await encryptWithKmsKey({ kmsId: key.id }, tx); + const { cipherTextBlob } = await kmsEncryptor({ plainText: dataKey }); + + await projectDAL.updateById( + projectId, + { + kmsSecretManagerKeyId: key.id, + kmsSecretManagerEncryptedDataKey: cipherTextBlob + }, + tx + ); + + return kmsDAL.findByIdWithAssociatedKms(key.id, tx); + }); + + return { + secretManagerKmsKey: newKms + }; + }; + const startService = async () => { const appCfg = getConfig(); // This will switch to a seal process and HMS flow in future @@ -666,6 +724,7 @@ export const kmsServiceFactory = ({ getProjectSecretManagerKmsDataKey, getProjectSecretManagerKmsKey, updateProjectSecretManagerKmsKey, - getProjectKeyBackup + getProjectKeyBackup, + loadProjectKeyBackup }; }; diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index 7cdff7ac1..3a58d1052 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -41,6 +41,7 @@ import { TGetProjectDTO, TListProjectCasDTO, TListProjectCertsDTO, + TLoadProjectKmsBackupDTO, TToggleProjectAutoCapitalizationDTO, TUpdateAuditLogsRetentionDTO, TUpdateProjectDTO, @@ -78,7 +79,10 @@ type TProjectServiceFactoryDep = { licenseService: Pick; orgDAL: Pick; keyStore: Pick; - kmsService: Pick; + kmsService: Pick< + TKmsServiceFactory, + "updateProjectSecretManagerKmsKey" | "getProjectKeyBackup" | "loadProjectKeyBackup" + >; }; export type TProjectServiceFactory = ReturnType; @@ -721,6 +725,35 @@ export const projectServiceFactory = ({ return kmsBackup; }; + const loadProjectKmsBackup = async ({ + projectId, + actor, + actorId, + actorAuthMethod, + actorOrgId, + backup + }: TLoadProjectKmsBackupDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Kms); + + const plan = await licenseService.getPlan(actorOrgId); + if (!plan.externalKms) { + throw new BadRequestError({ + message: "Failed to load KMS backup due to plan restriction. Upgrade to the enterprise plan." + }); + } + + const kmsBackup = await kmsService.loadProjectKeyBackup(projectId, backup); + return kmsBackup; + }; + return { createProject, deleteProject, @@ -736,6 +769,7 @@ export const projectServiceFactory = ({ updateVersionLimit, updateAuditLogsRetention, updateProjectKmsKey, - getProjectKmsBackup + getProjectKmsBackup, + loadProjectKmsBackup }; }; diff --git a/backend/src/services/project/project-types.ts b/backend/src/services/project/project-types.ts index ff0cd0a91..38d436018 100644 --- a/backend/src/services/project/project-types.ts +++ b/backend/src/services/project/project-types.ts @@ -107,3 +107,7 @@ export type TListProjectCertsDTO = { export type TUpdateProjectKmsDTO = { secretManagerKmsKeyId: string; } & TProjectPermission; + +export type TLoadProjectKmsBackupDTO = { + backup: string; +} & TProjectPermission; diff --git a/frontend/src/hooks/api/kms/index.tsx b/frontend/src/hooks/api/kms/index.tsx index 7702b9fcd..027fae1e3 100644 --- a/frontend/src/hooks/api/kms/index.tsx +++ b/frontend/src/hooks/api/kms/index.tsx @@ -1,5 +1,6 @@ export { useAddAwsExternalKms, + useLoadProjectKmsBackup, useRemoveExternalKms, useUpdateAwsExternalKms, useUpdateProjectKms diff --git a/frontend/src/hooks/api/kms/mutations.tsx b/frontend/src/hooks/api/kms/mutations.tsx index 8a92af8d4..7aace42ff 100644 --- a/frontend/src/hooks/api/kms/mutations.tsx +++ b/frontend/src/hooks/api/kms/mutations.tsx @@ -140,3 +140,19 @@ export const useUpdateProjectKms = (projectId: string) => { } }); }; + +export const useLoadProjectKmsBackup = (projectId: string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (backup: string) => { + const { data } = await apiRequest.post(`/api/v1/workspace/${projectId}/kms/backup`, { + backup + }); + + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries(kmsKeys.getActiveProjectKms(projectId)); + } + }); +}; diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/EncryptionTab/EncryptionTab.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/EncryptionTab/EncryptionTab.tsx index 553e89218..0726bb1ff 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/EncryptionTab/EncryptionTab.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/EncryptionTab/EncryptionTab.tsx @@ -24,7 +24,12 @@ import { useWorkspace } from "@app/context"; import { usePopUp } from "@app/hooks"; -import { useGetActiveProjectKms, useGetExternalKmsList, useUpdateProjectKms } from "@app/hooks/api"; +import { + useGetActiveProjectKms, + useGetExternalKmsList, + useLoadProjectKmsBackup, + useUpdateProjectKms +} from "@app/hooks/api"; import { fetchProjectKmsBackup } from "@app/hooks/api/kms/queries"; import { Organization, Workspace } from "@app/hooks/api/types"; @@ -64,14 +69,10 @@ const BackupConfirmationModal = ({ return ( -

- In case of interruptions with your configured external KMS, use this generated backup to +

+ In case of interruptions with your configured external KMS, load the generated backup to set the project's KMS back to the default Infisical KMS.

-

- Note: The project data key will be encrypted the organization's default Infisical - KMS. -

)}