feat: load project kms backup

This commit is contained in:
Sheen Capadngan
2024-07-19 19:37:25 +08:00
committed by =
parent 32fc254ae1
commit 0c9e979fb8
9 changed files with 207 additions and 19 deletions

View File

@@ -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;
}
});
};

View File

@@ -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;

View File

@@ -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();

View File

@@ -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
};
};

View File

@@ -41,6 +41,7 @@ import {
TGetProjectDTO,
TListProjectCasDTO,
TListProjectCertsDTO,
TLoadProjectKmsBackupDTO,
TToggleProjectAutoCapitalizationDTO,
TUpdateAuditLogsRetentionDTO,
TUpdateProjectDTO,
@@ -78,7 +79,10 @@ type TProjectServiceFactoryDep = {
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
orgDAL: Pick<TOrgDALFactory, "findOne">;
keyStore: Pick<TKeyStoreFactory, "deleteItem">;
kmsService: Pick<TKmsServiceFactory, "updateProjectSecretManagerKmsKey" | "getProjectKeyBackup">;
kmsService: Pick<
TKmsServiceFactory,
"updateProjectSecretManagerKmsKey" | "getProjectKeyBackup" | "loadProjectKeyBackup"
>;
};
export type TProjectServiceFactory = ReturnType<typeof projectServiceFactory>;
@@ -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
};
};

View File

@@ -107,3 +107,7 @@ export type TListProjectCertsDTO = {
export type TUpdateProjectKmsDTO = {
secretManagerKmsKeyId: string;
} & TProjectPermission;
export type TLoadProjectKmsBackupDTO = {
backup: string;
} & TProjectPermission;

View File

@@ -1,5 +1,6 @@
export {
useAddAwsExternalKms,
useLoadProjectKmsBackup,
useRemoveExternalKms,
useUpdateAwsExternalKms,
useUpdateProjectKms

View File

@@ -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));
}
});
};

View File

@@ -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 (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent title="Create KMS backup">
<p className="mb-8 text-bunker-300">
In case of interruptions with your configured external KMS, use this generated backup to
<p className="mb-10 text-bunker-300">
In case of interruptions with your configured external KMS, load the generated backup to
set the project&apos;s KMS back to the default Infisical KMS.
</p>
<p className="mb-8 text-bunker-300">
Note: The project data key will be encrypted the organization&apos;s default Infisical
KMS.
</p>
<Button onClick={downloadKmsBackup}>Continue</Button>
<Button
onClick={() => onOpenChange(false)}
@@ -98,14 +99,26 @@ const LoadBackupModal = ({
workspace?: Workspace;
}) => {
const fileUploadRef = useRef<HTMLInputElement>(null);
const { mutateAsync: loadKmsBackup, isLoading } = useLoadProjectKmsBackup(workspace?.id!);
const [backupContent, setBackupContent] = useState("");
const [backupFileName, setBackupFileName] = useState("");
const uploadKmsBackup = async () => {
if (!workspace || !org) {
// eslint-disable-next-line no-useless-return
return;
}
try {
await loadKmsBackup(backupContent);
createNotification({
text: "Successfully loaded KMS backup",
type: "success"
});
onOpenChange(false);
} catch (err) {
console.error(err);
}
};
const parseFile = (file?: File) => {
@@ -174,9 +187,16 @@ const LoadBackupModal = ({
<FontAwesomeIcon icon={faUpload} size="3x" />
</IconButton>
</div>
{backupFileName && <div className="mt-2 flex justify-center">{backupFileName}</div>}
{backupFileName && (
<div className="mt-2 flex justify-center px-4 text-center">{backupFileName}</div>
)}
{backupContent && (
<Button onClick={uploadKmsBackup} className="mt-10 w-fit">
<Button
onClick={uploadKmsBackup}
className="mt-10 w-fit"
disabled={isLoading}
isLoading={isLoading}
>
Continue
</Button>
)}