Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions backend/src/ee/services/audit-log/audit-log-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,7 @@ 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",

UPDATE_EXTERNAL_GROUP_ORG_ROLE_MAPPINGS = "update-external-group-org-role-mapping",
GET_EXTERNAL_GROUP_ORG_ROLE_MAPPINGS = "get-external-group-org-role-mapping",
Expand Down Expand Up @@ -3658,6 +3659,13 @@ interface CmekGetPrivateKeyEvent {
};
}

interface CmekBulkGetPrivateKeysEvent {
type: EventType.CMEK_BULK_EXPORT_PRIVATE_KEYS;
metadata: {
keys: { keyId: string; name: string }[];
};
}

interface GetExternalGroupOrgRoleMappingsEvent {
type: EventType.GET_EXTERNAL_GROUP_ORG_ROLE_MAPPINGS;
metadata?: Record<string, never>; // not needed, based off orgId
Expand Down Expand Up @@ -6371,6 +6379,7 @@ export type Event =
| CmekListSigningAlgorithmsEvent
| CmekGetPublicKeyEvent
| CmekGetPrivateKeyEvent
| CmekBulkGetPrivateKeysEvent
| GetExternalGroupOrgRoleMappingsEvent
| UpdateExternalGroupOrgRoleMappingsEvent
| GetProjectTemplatesEvent
Expand Down
4 changes: 4 additions & 0 deletions backend/src/lib/api-docs/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).",
Expand Down
54 changes: 54 additions & 0 deletions backend/src/server/routes/v1/cmek-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,60 @@ export const registerCmekRouter = async (server: FastifyZodProvider) => {
}
});

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: {
keys: keys.map((k) => ({ keyId: k.keyId, name: k.name }))
}
}
});

return { keys };
}
});

server.route({
method: "GET",
url: "/keys/:keyId/signing-algorithms",
Expand Down
79 changes: 77 additions & 2 deletions backend/src/services/cmek/cmek-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@ 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,
TCmekDecryptDTO,
TCmekEncryptDTO,
TCmekGetPrivateKeyDTO,
Expand Down Expand Up @@ -318,6 +319,79 @@ 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<string>();
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(
Comment thread
victorvhs017 marked this conversation as resolved.
ProjectPermissionCmekActions.ExportPrivateKey,
ProjectPermissionSub.Cmek
);

const bulkMaterials = await kmsService.getBulkKeyMaterial({ kmsIds: keys.map((k) => k.id) });

const materialByKmsId = new Map(bulkMaterials.map((m) => [m.kmsId, m]));
const asymmetricAlgorithms = new Set<string>(Object.values(AsymmetricKeyAlgorithm));

const result = keys.map((key) => {
const materialEntry = materialByKmsId.get(key.id);

if (!materialEntry) {
throw new NotFoundError({ message: `Key material not found for key ID "${key.id}"` });
}

let publicKey: string | undefined;
if (asymmetricAlgorithms.has(key.encryptionAlgorithm)) {
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);

Expand Down Expand Up @@ -432,6 +506,7 @@ export const cmekServiceFactory = ({ kmsService, kmsDAL, permissionService }: TC
cmekVerify,
listSigningAlgorithms,
getPublicKey,
getPrivateKey
getPrivateKey,
bulkGetPrivateKeys
};
};
4 changes: 4 additions & 0 deletions backend/src/services/cmek/cmek-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ export type TCmekGetPrivateKeyDTO = {
keyId: string;
};

export type TCmekBulkGetPrivateKeysDTO = {
keyIds: string[];
};

export type TCmekSignDTO = {
keyId: string;
data: string;
Expand Down
80 changes: 78 additions & 2 deletions backend/src/services/kms/kms-key-dal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -136,7 +192,10 @@ export const kmskeyDALFactory = (db: TDbClient) => {
.where("projectId", projectId)
.where((qb) => {
if (search) {
void qb.whereILike("name", `%${search}%`);
const pattern = `%${search}%`;
void qb
.whereILike(`${TableName.KmsKey}.name`, pattern)
.orWhereRaw(`?? ::text ILIKE ?`, [`${TableName.KmsKey}.id`, pattern]);
}
})
.where(`${TableName.KmsKey}.isReserved`, false)
Expand Down Expand Up @@ -180,6 +239,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({
Expand All @@ -194,5 +261,14 @@ export const kmskeyDALFactory = (db: TDbClient) => {
}
};

return { ...kmsOrm, findByIdWithAssociatedKms, listCmeksByProjectId, findCmekById, findCmekByName, findProjectCmeks };
return {
...kmsOrm,
findByIdWithAssociatedKms,
findByIdsWithAssociatedKms,
listCmeksByProjectId,
findCmekById,
findCmeksByIds,
findCmekByName,
findProjectCmeks
};
};
20 changes: 20 additions & 0 deletions backend/src/services/kms/kms-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
TEncryptWithKmsDataKeyDTO,
TEncryptWithKmsDTO,
TGenerateKMSDTO,
TGetBulkKeyMaterialDTO,
TGetKeyMaterialDTO,
TGetPublicKeyDTO,
TImportKeyMaterialDTO,
Expand Down Expand Up @@ -381,6 +382,24 @@ 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
Expand Down Expand Up @@ -1095,6 +1114,7 @@ export const kmsServiceFactory = ({
getKmsById,
createCipherPairWithDataKey,
getKeyMaterial,
getBulkKeyMaterial,
importKeyMaterial,
signWithKmsKey,
verifyWithKmsKey,
Expand Down
4 changes: 4 additions & 0 deletions backend/src/services/kms/kms-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ export type TGetKeyMaterialDTO = {
kmsId: string;
};

export type TGetBulkKeyMaterialDTO = {
kmsIds: string[];
};

export type TImportKeyMaterialDTO = {
key: Buffer;
algorithm: SymmetricKeyAlgorithm;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
title: "Bulk Export Private Keys"
openapi: "POST /api/v1/kms/keys/bulk-export-private-keys"
---
3 changes: 2 additions & 1 deletion docs/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -3264,7 +3264,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"
]
},
{
Expand Down
Loading
Loading