diff --git a/backend/package-lock.json b/backend/package-lock.json index 47e9014ce..3cd03cd6e 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -132,7 +132,7 @@ "@types/jsrp": "^0.2.6", "@types/libsodium-wrappers": "^0.7.13", "@types/lodash.isequal": "^4.5.8", - "@types/node": "^20.9.5", + "@types/node": "^20.17.30", "@types/nodemailer": "^6.4.14", "@types/passport-github": "^1.1.12", "@types/passport-google-oauth20": "^2.0.14", @@ -9753,11 +9753,12 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "20.9.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.9.5.tgz", - "integrity": "sha512-Uq2xbNq0chGg+/WQEU0LJTSs/1nKxz6u1iemLcGomkSnKokbW1fbLqc3HOqCf2JP7KjlL4QkS7oZZTrOQHQYgQ==", + "version": "20.17.30", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.30.tgz", + "integrity": "sha512-7zf4YyHA+jvBNfVrk2Gtvs6x7E8V+YDW05bNfG2XkWDJfYRXrTiP/DsB2zSYTaHX0bGIujTBQdMVAhb+j7mwpg==", + "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.19.2" } }, "node_modules/@types/node-fetch": { @@ -20081,11 +20082,6 @@ "undici-types": "~6.19.2" } }, - "node_modules/scim-patch/node_modules/undici-types": { - "version": "6.19.8", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", - "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==" - }, "node_modules/scim2-parse-filter": { "version": "0.2.10", "resolved": "https://registry.npmjs.org/scim2-parse-filter/-/scim2-parse-filter-0.2.10.tgz", @@ -22442,9 +22438,9 @@ } }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==" }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.0", diff --git a/backend/package.json b/backend/package.json index c575722fd..66eddcc10 100644 --- a/backend/package.json +++ b/backend/package.json @@ -89,7 +89,7 @@ "@types/jsrp": "^0.2.6", "@types/libsodium-wrappers": "^0.7.13", "@types/lodash.isequal": "^4.5.8", - "@types/node": "^20.9.5", + "@types/node": "^20.17.30", "@types/nodemailer": "^6.4.14", "@types/passport-github": "^1.1.12", "@types/passport-google-oauth20": "^2.0.14", diff --git a/backend/src/db/migrations/20250402000941_add-type-to-kms-keys.ts b/backend/src/db/migrations/20250402000941_add-type-to-kms-keys.ts new file mode 100644 index 000000000..ecc4b9d3b --- /dev/null +++ b/backend/src/db/migrations/20250402000941_add-type-to-kms-keys.ts @@ -0,0 +1,19 @@ +import { Knex } from "knex"; + +import { KmsKeyIntent } from "@app/services/kms/kms-types"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasTypeColumn = await knex.schema.hasColumn(TableName.KmsKey, "type"); + + await knex.schema.alterTable(TableName.KmsKey, (t) => { + if (!hasTypeColumn) t.string("type").notNullable().defaultTo(KmsKeyIntent.ENCRYPT_DECRYPT); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.alterTable(TableName.KmsKey, (t) => { + t.dropColumn("type"); + }); +} diff --git a/backend/src/db/schemas/kms-keys.ts b/backend/src/db/schemas/kms-keys.ts index b56fab7bf..a15b12d11 100644 --- a/backend/src/db/schemas/kms-keys.ts +++ b/backend/src/db/schemas/kms-keys.ts @@ -16,7 +16,8 @@ export const KmsKeysSchema = z.object({ name: z.string(), createdAt: z.date(), updatedAt: z.date(), - projectId: z.string().nullable().optional() + projectId: z.string().nullable().optional(), + type: z.string().default("encrypt-decrypt") }); export type TKmsKeys = z.infer; diff --git a/backend/src/db/schemas/organizations.ts b/backend/src/db/schemas/organizations.ts index 7e5994938..0bcea146b 100644 --- a/backend/src/db/schemas/organizations.ts +++ b/backend/src/db/schemas/organizations.ts @@ -23,10 +23,11 @@ export const OrganizationsSchema = z.object({ defaultMembershipRole: z.string().default("member"), enforceMfa: z.boolean().default(false), selectedMfaMethod: z.string().nullable().optional(), + secretShareSendToAnyone: z.boolean().default(true).nullable().optional(), + allowSecretSharingOutsideOrganization: z.boolean().default(true).nullable().optional(), shouldUseNewPrivilegeSystem: z.boolean().default(true), privilegeUpgradeInitiatedByUsername: z.string().nullable().optional(), - privilegeUpgradeInitiatedAt: z.date().nullable().optional(), - allowSecretSharingOutsideOrganization: z.boolean().default(true).nullable().optional() + privilegeUpgradeInitiatedAt: z.date().nullable().optional() }); export type TOrganizations = z.infer; diff --git a/backend/src/ee/routes/v1/kmip-spec-router.ts b/backend/src/ee/routes/v1/kmip-spec-router.ts index c9899c98e..2980f8de9 100644 --- a/backend/src/ee/routes/v1/kmip-spec-router.ts +++ b/backend/src/ee/routes/v1/kmip-spec-router.ts @@ -2,7 +2,7 @@ import z from "zod"; import { KmsKeysSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; -import { SymmetricEncryption } from "@app/lib/crypto/cipher"; +import { SymmetricKeyEncryptDecrypt } from "@app/lib/crypto/cipher"; import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; @@ -74,7 +74,7 @@ export const registerKmipSpecRouter = async (server: FastifyZodProvider) => { schema: { description: "KMIP endpoint for creating managed objects", body: z.object({ - algorithm: z.nativeEnum(SymmetricEncryption) + algorithm: z.nativeEnum(SymmetricKeyEncryptDecrypt) }), response: { 200: KmsKeysSchema @@ -433,7 +433,7 @@ export const registerKmipSpecRouter = async (server: FastifyZodProvider) => { body: z.object({ key: z.string(), name: z.string(), - algorithm: z.nativeEnum(SymmetricEncryption) + algorithm: z.nativeEnum(SymmetricKeyEncryptDecrypt) }), response: { 200: z.object({ 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 5e7df9444..280fd7142 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -4,7 +4,8 @@ import { } from "@app/ee/services/project-template/project-template-types"; import { SshCaStatus, SshCertType } from "@app/ee/services/ssh/ssh-certificate-authority-types"; import { SshCertTemplateStatus } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-types"; -import { SymmetricEncryption } from "@app/lib/crypto/cipher"; +import { SymmetricKeyEncryptDecrypt } from "@app/lib/crypto/cipher"; +import { AsymmetricKeySignVerify } from "@app/lib/crypto/sign/types"; import { TProjectPermission } from "@app/lib/types"; import { AppConnection } from "@app/services/app-connection/app-connection-enums"; import { TCreateAppConnectionDTO, TUpdateAppConnectionDTO } from "@app/services/app-connection/app-connection-types"; @@ -1897,7 +1898,7 @@ interface CreateCmekEvent { keyId: string; name: string; description?: string; - encryptionAlgorithm: SymmetricEncryption; + encryptionAlgorithm: SymmetricKeyEncryptDecrypt | AsymmetricKeySignVerify; }; } diff --git a/backend/src/ee/services/external-kms/external-kms-service.ts b/backend/src/ee/services/external-kms/external-kms-service.ts index faaace343..d9039a7f0 100644 --- a/backend/src/ee/services/external-kms/external-kms-service.ts +++ b/backend/src/ee/services/external-kms/external-kms-service.ts @@ -7,7 +7,7 @@ import { BadRequestError, InternalServerError, NotFoundError } from "@app/lib/er import { alphaNumericNanoId } from "@app/lib/nanoid"; import { TKmsKeyDALFactory } from "@app/services/kms/kms-key-dal"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; -import { KmsDataKey } from "@app/services/kms/kms-types"; +import { KmsDataKey, KmsKeyIntent } from "@app/services/kms/kms-types"; import { TLicenseServiceFactory } from "../license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; @@ -115,6 +115,7 @@ export const externalKmsServiceFactory = ({ { isReserved: false, description, + type: KmsKeyIntent.ENCRYPT_DECRYPT, name: kmsName, orgId: actorOrgId }, diff --git a/backend/src/ee/services/external-kms/providers/gcp-kms.ts b/backend/src/ee/services/external-kms/providers/gcp-kms.ts index b3b61694b..bee1eb24b 100644 --- a/backend/src/ee/services/external-kms/providers/gcp-kms.ts +++ b/backend/src/ee/services/external-kms/providers/gcp-kms.ts @@ -92,7 +92,7 @@ export const GcpKmsProviderFactory = async ({ inputs }: GcpKmsProviderArgs): Pro plaintext: data }); if (!encryptedText[0].ciphertext) throw new Error("encryption failed"); - return { encryptedBlob: Buffer.from(encryptedText[0].ciphertext) }; + return { encryptedBlob: Buffer.from(encryptedText[0].ciphertext as Uint8Array) }; }; const decrypt = async (encryptedBlob: Buffer) => { @@ -101,7 +101,7 @@ export const GcpKmsProviderFactory = async ({ inputs }: GcpKmsProviderArgs): Pro ciphertext: encryptedBlob }); if (!decryptedText[0].plaintext) throw new Error("decryption failed"); - return { data: Buffer.from(decryptedText[0].plaintext) }; + return { data: Buffer.from(decryptedText[0].plaintext as Uint8Array) }; }; return { diff --git a/backend/src/ee/services/hsm/hsm-service.ts b/backend/src/ee/services/hsm/hsm-service.ts index d35d17a24..0ed4c5faf 100644 --- a/backend/src/ee/services/hsm/hsm-service.ts +++ b/backend/src/ee/services/hsm/hsm-service.ts @@ -258,7 +258,7 @@ export const hsmServiceFactory = ({ hsmModule: { isInitialized, pkcs11 }, envCon const decrypt: { (encryptedBlob: Buffer, providedSession: pkcs11js.Handle): Promise; (encryptedBlob: Buffer): Promise; - } = async (encryptedBlob: Buffer, providedSession?: pkcs11js.Handle) => { + } = async (encryptedBlob: Buffer, providedSession?: pkcs11js.Handle): Promise => { if (!pkcs11 || !isInitialized) { throw new Error("PKCS#11 module is not initialized"); } @@ -309,10 +309,10 @@ export const hsmServiceFactory = ({ hsmModule: { isInitialized, pkcs11 }, envCon pkcs11.C_DecryptInit(sessionHandle, decryptMechanism, aesKey); - const tempBuffer = Buffer.alloc(encryptedData.length); + const tempBuffer: Buffer = Buffer.alloc(encryptedData.length); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const decryptedData = pkcs11.C_Decrypt(sessionHandle, encryptedData, tempBuffer); - // Create a new buffer from the decrypted data return Buffer.from(decryptedData); } catch (error) { logger.error(error, "HSM: Failed to perform decryption"); diff --git a/backend/src/ee/services/kmip/kmip-operation-service.ts b/backend/src/ee/services/kmip/kmip-operation-service.ts index 66c3a1d46..bf976401c 100644 --- a/backend/src/ee/services/kmip/kmip-operation-service.ts +++ b/backend/src/ee/services/kmip/kmip-operation-service.ts @@ -3,6 +3,7 @@ import { ForbiddenError } from "@casl/ability"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { TKmsKeyDALFactory } from "@app/services/kms/kms-key-dal"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { KmsKeyIntent } from "@app/services/kms/kms-types"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { OrgPermissionKmipActions, OrgPermissionSubjects } from "../permission/org-permission"; @@ -403,6 +404,7 @@ export const kmipOperationServiceFactory = ({ algorithm, isReserved: false, projectId, + type: KmsKeyIntent.ENCRYPT_DECRYPT, orgId: project.orgId }); diff --git a/backend/src/ee/services/kmip/kmip-types.ts b/backend/src/ee/services/kmip/kmip-types.ts index a259a79b8..27f9e0bb6 100644 --- a/backend/src/ee/services/kmip/kmip-types.ts +++ b/backend/src/ee/services/kmip/kmip-types.ts @@ -1,4 +1,4 @@ -import { SymmetricEncryption } from "@app/lib/crypto/cipher"; +import { SymmetricKeyEncryptDecrypt } from "@app/lib/crypto/cipher"; import { OrderByDirection, TOrgPermission, TProjectPermission } from "@app/lib/types"; import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types"; @@ -49,7 +49,7 @@ type KmipOperationBaseDTO = { } & Omit; export type TKmipCreateDTO = { - algorithm: SymmetricEncryption; + algorithm: SymmetricKeyEncryptDecrypt; } & KmipOperationBaseDTO; export type TKmipGetDTO = { @@ -77,7 +77,7 @@ export type TKmipLocateDTO = KmipOperationBaseDTO; export type TKmipRegisterDTO = { name: string; key: string; - algorithm: SymmetricEncryption; + algorithm: SymmetricKeyEncryptDecrypt; } & KmipOperationBaseDTO; export type TSetupOrgKmipDTO = { diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index 2637db994..79112afeb 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -32,7 +32,9 @@ export enum ProjectPermissionCmekActions { Edit = "edit", Delete = "delete", Encrypt = "encrypt", - Decrypt = "decrypt" + Decrypt = "decrypt", + Sign = "sign", + Verify = "verify" } export enum ProjectPermissionDynamicSecretActions { @@ -650,7 +652,9 @@ const buildAdminPermissionRules = () => { ProjectPermissionCmekActions.Delete, ProjectPermissionCmekActions.Read, ProjectPermissionCmekActions.Encrypt, - ProjectPermissionCmekActions.Decrypt + ProjectPermissionCmekActions.Decrypt, + ProjectPermissionCmekActions.Sign, + ProjectPermissionCmekActions.Verify ], ProjectPermissionSub.Cmek ); @@ -839,7 +843,9 @@ const buildMemberPermissionRules = () => { ProjectPermissionCmekActions.Delete, ProjectPermissionCmekActions.Read, ProjectPermissionCmekActions.Encrypt, - ProjectPermissionCmekActions.Decrypt + ProjectPermissionCmekActions.Decrypt, + ProjectPermissionCmekActions.Sign, + ProjectPermissionCmekActions.Verify ], ProjectPermissionSub.Cmek ); diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index bd6056359..ac9acf3d1 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -633,7 +633,8 @@ export const FOLDERS = { path: "The path to list folders from.", directory: "The directory to list folders from. (Deprecated in favor of path)", recursive: "Whether or not to fetch all folders from the specified base path, and all of its subdirectories.", - lastSecretModified: "The timestamp used to filter folders with secrets modified after the specified date. The format for this timestamp is ISO 8601 (e.g. 2025-04-01T09:41:45-04:00)" + lastSecretModified: + "The timestamp used to filter folders with secrets modified after the specified date. The format for this timestamp is ISO 8601 (e.g. 2025-04-01T09:41:45-04:00)" }, GET_BY_ID: { folderId: "The ID of the folder to get details." @@ -1590,7 +1591,8 @@ export const KMS = { projectId: "The ID of the project to create the key in.", name: "The name of the key to be created. Must be slug-friendly.", description: "An optional description of the key.", - encryptionAlgorithm: "The algorithm to use when performing cryptographic operations with the key." + encryptionAlgorithm: "The algorithm to use when performing cryptographic operations with the key.", + type: "The type of key to be created, either encrypt-decrypt or sign-verify, based on your intended use for the key." }, UPDATE_KEY: { keyId: "The ID of the key to be updated.", @@ -1623,6 +1625,24 @@ export const KMS = { DECRYPT: { keyId: "The ID of the key to decrypt the data with.", ciphertext: "The ciphertext to be decrypted (base64 encoded)." + }, + + LIST_SIGNING_ALGORITHMS: { + keyId: "The ID of the key to list the signing algorithms for. The key must be for signing and verifying." + }, + + GET_PUBLIC_KEY: { + keyId: "The ID of the key to get the public key for. The key must be for signing and verifying." + }, + + SIGN: { + keyId: "The ID of the key to sign the data with.", + data: "The data in string format to be signed (base64 encoded)." + }, + VERIFY: { + keyId: "The ID of the key to verify the data with.", + data: "The data in string format to be verified (base64 encoded).", + signature: "The signature to be verified (base64 encoded)." } }; diff --git a/backend/src/lib/crypto/cipher/cipher.ts b/backend/src/lib/crypto/cipher/cipher.ts index 7bc16b470..82cd9a2d1 100644 --- a/backend/src/lib/crypto/cipher/cipher.ts +++ b/backend/src/lib/crypto/cipher/cipher.ts @@ -1,6 +1,6 @@ import crypto from "crypto"; -import { SymmetricEncryption, TSymmetricEncryptionFns } from "./types"; +import { SymmetricKeyEncryptDecrypt, TSymmetricEncryptionFns } from "./types"; const getIvLength = () => { return 12; @@ -10,7 +10,9 @@ const getTagLength = () => { return 16; }; -export const symmetricCipherService = (type: SymmetricEncryption): TSymmetricEncryptionFns => { +export const symmetricCipherService = ( + type: SymmetricKeyEncryptDecrypt.AES_GCM_128 | SymmetricKeyEncryptDecrypt.AES_GCM_256 +): TSymmetricEncryptionFns => { const IV_LENGTH = getIvLength(); const TAG_LENGTH = getTagLength(); diff --git a/backend/src/lib/crypto/cipher/index.ts b/backend/src/lib/crypto/cipher/index.ts index 41dbcf639..755f8aa00 100644 --- a/backend/src/lib/crypto/cipher/index.ts +++ b/backend/src/lib/crypto/cipher/index.ts @@ -1,2 +1,2 @@ export { symmetricCipherService } from "./cipher"; -export { SymmetricEncryption } from "./types"; +export { AllowedEncryptionKeyAlgorithms, SymmetricKeyEncryptDecrypt } from "./types"; diff --git a/backend/src/lib/crypto/cipher/types.ts b/backend/src/lib/crypto/cipher/types.ts index f490d6a66..c8dbf11cd 100644 --- a/backend/src/lib/crypto/cipher/types.ts +++ b/backend/src/lib/crypto/cipher/types.ts @@ -1,7 +1,20 @@ -export enum SymmetricEncryption { +import { z } from "zod"; + +import { AsymmetricKeySignVerify } from "../sign/types"; + +// Supported symmetric encrypt/decrypt algorithms +export enum SymmetricKeyEncryptDecrypt { AES_GCM_256 = "aes-256-gcm", AES_GCM_128 = "aes-128-gcm" } +export const SymmetricKeyEncryptDecryptEnum = z.enum( + Object.values(SymmetricKeyEncryptDecrypt) as [string, ...string[]] +).options; + +export const AllowedEncryptionKeyAlgorithms = z.enum([ + ...Object.values(SymmetricKeyEncryptDecrypt), + ...Object.values(AsymmetricKeySignVerify) +] as [string, ...string[]]).options; export type TSymmetricEncryptionFns = { encrypt: (text: Buffer, key: Buffer) => Buffer; diff --git a/backend/src/lib/crypto/sign/index.ts b/backend/src/lib/crypto/sign/index.ts new file mode 100644 index 000000000..372f10c64 --- /dev/null +++ b/backend/src/lib/crypto/sign/index.ts @@ -0,0 +1,2 @@ +export { signingService } from "./signing"; +export { AsymmetricKeySignVerify, SigningAlgorithm } from "./types"; diff --git a/backend/src/lib/crypto/sign/signing.ts b/backend/src/lib/crypto/sign/signing.ts new file mode 100644 index 000000000..5431c28ff --- /dev/null +++ b/backend/src/lib/crypto/sign/signing.ts @@ -0,0 +1,287 @@ +import crypto from "crypto"; + +import { BadRequestError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; + +import { AsymmetricKeySignVerify, SigningAlgorithm, TAsymmetricSignVerifyFns } from "./types"; + +// Map of signing algorithms to their parameters +interface SigningParams { + hashAlgorithm: string; + padding?: number; // Will use crypto.constants values + saltLength?: number; +} + +const SHA256_DIGEST_LENGTH = 32; +const SHA384_DIGEST_LENGTH = 48; +const SHA512_DIGEST_LENGTH = 64; + +/** + * Service for cryptographic signing and verification operations using asymmetric keys + * + * @param algorithm The signing algorithm to use + * @returns Object with sign and verify functions + */ +export const signingService = (algorithm: AsymmetricKeySignVerify): TAsymmetricSignVerifyFns => { + const $getSigningParams = (signingAlgorithm: SigningAlgorithm): SigningParams => { + switch (signingAlgorithm) { + // RSA PSS + case SigningAlgorithm.RSASSA_PSS_SHA_256: + return { + hashAlgorithm: "sha256", + padding: crypto.constants.RSA_PKCS1_PSS_PADDING, + saltLength: SHA256_DIGEST_LENGTH + }; + case SigningAlgorithm.RSASSA_PSS_SHA_384: + return { + hashAlgorithm: "sha384", + padding: crypto.constants.RSA_PKCS1_PSS_PADDING, + saltLength: SHA384_DIGEST_LENGTH + }; + case SigningAlgorithm.RSASSA_PSS_SHA_512: + return { + hashAlgorithm: "sha512", + padding: crypto.constants.RSA_PKCS1_PSS_PADDING, + saltLength: SHA512_DIGEST_LENGTH + }; + + // RSA PKCS#1 v1.5 + case SigningAlgorithm.RSASSA_PKCS1_V1_5_SHA_256: + return { + hashAlgorithm: "sha256", + padding: crypto.constants.RSA_PKCS1_PADDING + }; + case SigningAlgorithm.RSASSA_PKCS1_V1_5_SHA_384: + return { + hashAlgorithm: "sha384", + padding: crypto.constants.RSA_PKCS1_PADDING + }; + case SigningAlgorithm.RSASSA_PKCS1_V1_5_SHA_512: + return { + hashAlgorithm: "sha512", + padding: crypto.constants.RSA_PKCS1_PADDING + }; + + // ECDSA + case SigningAlgorithm.ECDSA_SHA_256: + return { hashAlgorithm: "sha256" }; + case SigningAlgorithm.ECDSA_SHA_384: + return { hashAlgorithm: "sha384" }; + case SigningAlgorithm.ECDSA_SHA_512: + return { hashAlgorithm: "sha512" }; + + default: + throw new Error(`Unsupported signing algorithm: ${signingAlgorithm as string}`); + } + }; + + // For ECC key generation, nodejs has some strange and hardly documented curve naming conventions + const $getEcCurveName = (keyAlgorithm: AsymmetricKeySignVerify): string => { + // We will support more in the future + switch (keyAlgorithm) { + case AsymmetricKeySignVerify.ECC_NIST_P256: + return "prime256v1"; + default: + throw new Error(`Unsupported EC curve: ${keyAlgorithm}`); + } + }; + + const $validateAlgorithmWithKeyType = (signingAlgorithm: SigningAlgorithm) => { + const isRsaKey = algorithm.startsWith("rsa"); + const isEccKey = algorithm.startsWith("ecc"); + + const isRsaAlgorithm = signingAlgorithm.startsWith("RSASSA"); + const isEccAlgorithm = signingAlgorithm.startsWith("ECDSA"); + + if (isRsaKey && !isRsaAlgorithm) { + throw new BadRequestError({ message: `KMS RSA key cannot be used with ${signingAlgorithm}` }); + } + + if (isEccKey && !isEccAlgorithm) { + throw new BadRequestError({ message: `KMS ECC key cannot be used with ${signingAlgorithm}` }); + } + }; + + const generateAsymmetricPrivateKey = async () => { + const { privateKey } = await new Promise<{ privateKey: string }>((resolve, reject) => { + if (algorithm.startsWith("rsa")) { + crypto.generateKeyPair( + "rsa", + { + modulusLength: Number(algorithm.split("-")[1]), + publicKeyEncoding: { type: "spki", format: "pem" }, + privateKeyEncoding: { type: "pkcs8", format: "pem" } + }, + (err, _, pk) => { + if (err) { + reject(err); + } else { + resolve({ privateKey: pk }); + } + } + ); + } else { + const namedCurve = $getEcCurveName(algorithm); + + crypto.generateKeyPair( + "ec", + { + namedCurve, + publicKeyEncoding: { type: "spki", format: "pem" }, + privateKeyEncoding: { type: "pkcs8", format: "pem" } + }, + (err, _, pk) => { + if (err) { + reject(err); + } else { + resolve({ + privateKey: pk + }); + } + } + ); + } + }); + + return Buffer.from(privateKey); + }; + + const getPublicKeyFromPrivateKey = (privateKey: Buffer) => { + if (algorithm.startsWith("rsa")) { + // For RSA keys in PEM format + const privateKeyObj = crypto.createPrivateKey({ + key: privateKey, + format: "pem", + type: "pkcs8" + }); + + const publicKey = crypto.createPublicKey(privateKeyObj).export({ + type: "spki", + format: "pem" + }); + + if (Buffer.isBuffer(publicKey)) { + return publicKey; + } + return Buffer.from(publicKey); + } + + const privateKeyObj = crypto.createPrivateKey({ + key: privateKey, + format: "pem", + type: "pkcs8" + }); + + // Return public key in PEM format for both RSA and EC + const publicKey = crypto.createPublicKey(privateKeyObj).export({ + type: "spki", + format: "pem" + }); + + if (Buffer.isBuffer(publicKey)) { + return publicKey; + } + return Buffer.from(publicKey); + }; + + const sign = (data: Buffer, privateKey: Buffer, signingAlgorithm: SigningAlgorithm): Buffer => { + $validateAlgorithmWithKeyType(signingAlgorithm); + + const { hashAlgorithm, padding, saltLength } = $getSigningParams(signingAlgorithm); + + const privateKeyObject = crypto.createPrivateKey({ + key: privateKey, + format: "pem", + type: "pkcs8" + }); + + // For RSA signatures + if (signingAlgorithm.startsWith("RSASSA")) { + const signer = crypto.createSign(hashAlgorithm); + signer.update(data); + + if (signingAlgorithm.includes("PSS")) { + // For PSS padding + return signer.sign({ + key: privateKeyObject, + padding, + saltLength + }); + } + // For PKCS1 v1.5 padding + return signer.sign({ + key: privateKeyObject + }); + } + if (signingAlgorithm.startsWith("ECDSA")) { + // For ECDSA signatures + const signer = crypto.createSign(hashAlgorithm); + signer.update(data); + return signer.sign({ + key: privateKeyObject, + dsaEncoding: "ieee-p1363" // Based on AWS KMS implementation, where ECDSA signatures follow the ANSI X9.62-2005 format, which is equivalent to the IEEE-P1363 format + }); + } + throw new BadRequestError({ + message: `Signing algorithm ${signingAlgorithm} not implemented` + }); + }; + + const verify = (data: Buffer, signature: Buffer, publicKey: Buffer, signingAlgorithm: SigningAlgorithm): boolean => { + try { + $validateAlgorithmWithKeyType(signingAlgorithm); + + const { hashAlgorithm, padding, saltLength } = $getSigningParams(signingAlgorithm); + + // For RSA signatures + if (signingAlgorithm.startsWith("RSASSA")) { + const verifier = crypto.createVerify(hashAlgorithm); + verifier.update(data); + + if (signingAlgorithm.includes("PSS")) { + // For PSS padding + return verifier.verify( + { + key: publicKey.toString(), + padding, + saltLength + }, + signature + ); + } + // For PKCS1 v1.5 padding + return verifier.verify( + { + key: publicKey.toString() + }, + signature + ); + } + // For ECDSA signatures + if (signingAlgorithm.startsWith("ECDSA")) { + const verifier = crypto.createVerify(hashAlgorithm); + verifier.update(data); + return verifier.verify( + { + key: publicKey.toString(), + dsaEncoding: "ieee-p1363" + }, + signature + ); + } + throw new BadRequestError({ + message: `Verification for algorithm ${signingAlgorithm} not implemented` + }); + } catch (error) { + logger.error(error, "KMS: Failed to verify signature"); + return false; + } + }; + + return { + sign, + verify, + generateAsymmetricPrivateKey, + getPublicKeyFromPrivateKey + }; +}; diff --git a/backend/src/lib/crypto/sign/types.ts b/backend/src/lib/crypto/sign/types.ts new file mode 100644 index 000000000..6f1922d38 --- /dev/null +++ b/backend/src/lib/crypto/sign/types.ts @@ -0,0 +1,39 @@ +import { z } from "zod"; + +export type TAsymmetricSignVerifyFns = { + sign: (data: Buffer, key: Buffer, signingAlgorithm: SigningAlgorithm) => Buffer; + verify: (data: Buffer, signature: Buffer, key: Buffer, signingAlgorithm: SigningAlgorithm) => boolean; + generateAsymmetricPrivateKey: () => Promise; + getPublicKeyFromPrivateKey: (privateKey: Buffer) => Buffer; +}; + +// Supported asymmetric key types +export enum AsymmetricKeySignVerify { + RSA_4096 = "rsa-4096", + ECC_NIST_P256 = "ecc-nist-p256" +} + +export const AsymmetricKeySignVerifyEnum = z.enum( + Object.values(AsymmetricKeySignVerify) as [string, ...string[]] +).options; + +export enum SigningAlgorithm { + // RSA PSS algorithms + // These are NOT deterministic and include randomness. + // This means that the output signature is different each time for the same input. + RSASSA_PSS_SHA_256 = "RSASSA_PSS_SHA_256", + RSASSA_PSS_SHA_384 = "RSASSA_PSS_SHA_384", + RSASSA_PSS_SHA_512 = "RSASSA_PSS_SHA_512", + + // RSA PKCS#1 v1.5 algorithms + // These are deterministic and the output is the same each time for the same input. + RSASSA_PKCS1_V1_5_SHA_256 = "RSASSA_PKCS1_V1_5_SHA_256", + RSASSA_PKCS1_V1_5_SHA_384 = "RSASSA_PKCS1_V1_5_SHA_384", + RSASSA_PKCS1_V1_5_SHA_512 = "RSASSA_PKCS1_V1_5_SHA_512", + + // ECDSA algorithms + // None of these are deterministic and include randomness like RSA PSS. + ECDSA_SHA_256 = "ECDSA_SHA_256", + ECDSA_SHA_384 = "ECDSA_SHA_384", + ECDSA_SHA_512 = "ECDSA_SHA_512" +} diff --git a/backend/src/server/routes/v1/cmek-router.ts b/backend/src/server/routes/v1/cmek-router.ts index 7aecaee37..5ad568fd3 100644 --- a/backend/src/server/routes/v1/cmek-router.ts +++ b/backend/src/server/routes/v1/cmek-router.ts @@ -4,18 +4,22 @@ import { InternalKmsSchema, KmsKeysSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { KMS } from "@app/lib/api-docs"; import { getBase64SizeInBytes, isBase64 } from "@app/lib/base64"; -import { SymmetricEncryption } from "@app/lib/crypto/cipher"; +import { AllowedEncryptionKeyAlgorithms, SymmetricKeyEncryptDecrypt } from "@app/lib/crypto/cipher"; +import { AsymmetricKeySignVerify, SigningAlgorithm } from "@app/lib/crypto/sign"; import { OrderByDirection } from "@app/lib/types"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; -import { CmekOrderBy } from "@app/services/cmek/cmek-types"; +import { CmekOrderBy, TCmekKeyEncryptionAlgorithm } from "@app/services/cmek/cmek-types"; +import { KmsKeyIntent } from "@app/services/kms/kms-types"; const keyNameSchema = slugSchema({ min: 1, max: 32, field: "Name" }); const keyDescriptionSchema = z.string().trim().max(500).optional(); -const CmekSchema = KmsKeysSchema.merge(InternalKmsSchema.pick({ version: true, encryptionAlgorithm: true })).omit({ +const CmekSchema = KmsKeysSchema.merge( + InternalKmsSchema.pick({ version: true, encryptionAlgorithm: true, type: true }) +).omit({ isReserved: true }); @@ -45,16 +49,46 @@ export const registerCmekRouter = async (server: FastifyZodProvider) => { }, schema: { description: "Create KMS key", - body: z.object({ - projectId: z.string().describe(KMS.CREATE_KEY.projectId), - name: keyNameSchema.describe(KMS.CREATE_KEY.name), - description: keyDescriptionSchema.describe(KMS.CREATE_KEY.description), - encryptionAlgorithm: z - .nativeEnum(SymmetricEncryption) - .optional() - .default(SymmetricEncryption.AES_GCM_256) - .describe(KMS.CREATE_KEY.encryptionAlgorithm) // eventually will support others - }), + body: z + .object({ + projectId: z.string().describe(KMS.CREATE_KEY.projectId), + name: keyNameSchema.describe(KMS.CREATE_KEY.name), + description: keyDescriptionSchema.describe(KMS.CREATE_KEY.description), + type: z + .nativeEnum(KmsKeyIntent) + .optional() + .default(KmsKeyIntent.ENCRYPT_DECRYPT) + .describe(KMS.CREATE_KEY.type), + encryptionAlgorithm: z + .enum(AllowedEncryptionKeyAlgorithms) + .optional() + .default(SymmetricKeyEncryptDecrypt.AES_GCM_256) + .describe(KMS.CREATE_KEY.encryptionAlgorithm) + }) + .superRefine((data, ctx) => { + if ( + data.type === KmsKeyIntent.ENCRYPT_DECRYPT && + !Object.values(SymmetricKeyEncryptDecrypt).includes(data.encryptionAlgorithm as SymmetricKeyEncryptDecrypt) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `encryptionAlgorithm must be a valid symmetric encryption algorithm. Valid options are: ${Object.values( + SymmetricKeyEncryptDecrypt + ).join(", ")}` + }); + } + if ( + data.type === KmsKeyIntent.SIGN_VERIFY && + !Object.values(AsymmetricKeySignVerify).includes(data.encryptionAlgorithm as AsymmetricKeySignVerify) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `encryptionAlgorithm must be a valid asymmetric sign-verify algorithm. Valid options are: ${Object.values( + AsymmetricKeySignVerify + ).join(", ")}` + }); + } + }), response: { 200: z.object({ key: CmekSchema @@ -64,12 +98,19 @@ export const registerCmekRouter = async (server: FastifyZodProvider) => { onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { const { - body: { projectId, name, description, encryptionAlgorithm }, + body: { projectId, name, description, encryptionAlgorithm, type }, permission } = req; const cmek = await server.services.cmek.createCmek( - { orgId: permission.orgId, projectId, name, description, encryptionAlgorithm }, + { + orgId: permission.orgId, + projectId, + name, + description, + encryptionAlgorithm: encryptionAlgorithm as TCmekKeyEncryptionAlgorithm, + type + }, permission ); @@ -82,7 +123,7 @@ export const registerCmekRouter = async (server: FastifyZodProvider) => { keyId: cmek.id, name, description, - encryptionAlgorithm + encryptionAlgorithm: encryptionAlgorithm as TCmekKeyEncryptionAlgorithm } } }); @@ -366,6 +407,149 @@ export const registerCmekRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "GET", + url: "/keys/:keyId/public-key", + config: { + rateLimit: readLimit + }, + schema: { + description: "Get public key for a KMS key", + params: z.object({ + keyId: z.string().uuid().describe(KMS.GET_PUBLIC_KEY.keyId) + }), + response: { + 200: z.object({ + publicKey: z.string() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { + params: { keyId }, + permission + } = req; + + const publicKey = await server.services.cmek.getPublicKey({ keyId }, permission); + + return publicKey; + } + }); + + server.route({ + method: "GET", + url: "/keys/:keyId/signing-algorithms", + config: { + rateLimit: readLimit + }, + schema: { + description: "List signing algorithms for a KMS key", + params: z.object({ + keyId: z.string().uuid().describe(KMS.LIST_SIGNING_ALGORITHMS.keyId) + }), + response: { + 200: z.object({ + signingAlgorithms: z.array(z.nativeEnum(SigningAlgorithm)) + }) + } + }, + handler: async (req) => { + const result = await server.services.cmek.listSigningAlgorithms( + { + keyId: req.params.keyId + }, + req.permission + ); + return result; + } + }); + + server.route({ + method: "POST", + url: "/keys/:keyId/sign", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Sign data with KMS key", + params: z.object({ + keyId: z.string().uuid().describe(KMS.SIGN.keyId) + }), + body: z.object({ + signingAlgorithm: z.nativeEnum(SigningAlgorithm), + data: z + .string() + .superRefine((data, ctx) => { + if (!isBase64(data)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "data must be base64 encoded" + }); + } + }) + .describe(KMS.SIGN.data) + }), + response: { + 200: z.object({ + signature: z.string(), + keyId: z.string().uuid(), + signingAlgorithm: z.nativeEnum(SigningAlgorithm) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { + params: { keyId: inputKeyId }, + body: { data, signingAlgorithm }, + permission + } = req; + + const result = await server.services.cmek.cmekSign({ keyId: inputKeyId, data, signingAlgorithm }, permission); + + return result; + } + }); + + server.route({ + method: "POST", + url: "/keys/:keyId/verify", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Verify data with KMS key", + params: z.object({ + keyId: z.string().uuid().describe(KMS.VERIFY.keyId) + }), + body: z.object({ + data: z.string().describe(KMS.VERIFY.data), + signature: z.string().describe(KMS.VERIFY.signature), + signingAlgorithm: z.nativeEnum(SigningAlgorithm) + }), + response: { + 200: z.object({ + signatureValid: z.boolean(), + keyId: z.string().uuid(), + signingAlgorithm: z.nativeEnum(SigningAlgorithm) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { + params: { keyId }, + body: { data, signature, signingAlgorithm }, + permission + } = req; + + const result = await server.services.cmek.cmekVerify({ keyId, data, signature, signingAlgorithm }, permission); + + return result; + } + }); + server.route({ method: "POST", url: "/keys/:keyId/decrypt", diff --git a/backend/src/services/cmek/cmek-service.ts b/backend/src/services/cmek/cmek-service.ts index 5e74a5bac..395018e55 100644 --- a/backend/src/services/cmek/cmek-service.ts +++ b/backend/src/services/cmek/cmek-service.ts @@ -3,12 +3,18 @@ import { ForbiddenError } from "@casl/ability"; import { ActionProjectType, ProjectType } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionCmekActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { SigningAlgorithm } 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 { TCmekDecryptDTO, TCmekEncryptDTO, + TCmekGetPublicKeyDTO, + TCmekKeyEncryptionAlgorithm, + TCmekListSigningAlgorithmsDTO, + TCmekSignDTO, + TCmekVerifyDTO, TCreateCmekDTO, TListCmeksByProjectIdDTO, TUpdabteCmekByIdDTO @@ -16,6 +22,7 @@ import { import { TKmsKeyDALFactory } from "@app/services/kms/kms-key-dal"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { KmsKeyIntent } from "../kms/kms-types"; import { TProjectDALFactory } from "../project/project-dal"; type TCmekServiceFactoryDep = { @@ -224,6 +231,142 @@ export const cmekServiceFactory = ({ kmsService, kmsDAL, permissionService, proj return cipherTextBlob.toString("base64"); }; + const listSigningAlgorithms = async ({ keyId }: TCmekListSigningAlgorithmsDTO, actor: OrgServiceActor) => { + const key = await kmsDAL.findCmekById(keyId); + + if (!key) throw new NotFoundError({ message: `Key with ID "${keyId}" not found` }); + if (!key.projectId || key.isReserved) throw new BadRequestError({ message: "Key is not customer managed" }); + if (key.isDisabled) throw new BadRequestError({ message: "Key is disabled" }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + projectId: key.projectId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.KMS + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionCmekActions.Read, ProjectPermissionSub.Cmek); + + if (key.type !== KmsKeyIntent.SIGN_VERIFY) { + throw new BadRequestError({ message: `Key with ID '${keyId}' is not intended for signing` }); + } + + const encryptionAlgorithm = key.encryptionAlgorithm as TCmekKeyEncryptionAlgorithm; + + const algos = [ + { + keyAlgorithm: "rsa", + signingAlgorithms: Object.values(SigningAlgorithm).filter((algorithm) => + algorithm.toLowerCase().startsWith("rsa") + ) + }, + { + keyAlgorithm: "ecc", + signingAlgorithms: Object.values(SigningAlgorithm).filter((algorithm) => + algorithm.toLowerCase().startsWith("ecdsa") + ) + } + ]; + + const selectedAlgorithm = algos.find((algo) => encryptionAlgorithm.toLowerCase().startsWith(algo.keyAlgorithm)); + + if (!selectedAlgorithm) { + throw new BadRequestError({ message: `Unsupported encryption algorithm: ${encryptionAlgorithm}` }); + } + + return { signingAlgorithms: selectedAlgorithm.signingAlgorithms }; + }; + + const getPublicKey = async ({ keyId }: TCmekGetPublicKeyDTO, actor: OrgServiceActor) => { + const key = await kmsDAL.findCmekById(keyId); + + if (!key) throw new NotFoundError({ message: `Key with ID "${keyId}" not found` }); + if (!key.projectId || key.isReserved) throw new BadRequestError({ message: "Key is not customer managed" }); + if (key.isDisabled) throw new BadRequestError({ message: "Key is disabled" }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + projectId: key.projectId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.KMS + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionCmekActions.Read, ProjectPermissionSub.Cmek); + + const publicKey = await kmsService.getPublicKey({ kmsId: keyId }); + + return { publicKey }; + }; + + const cmekSign = async ({ keyId, data, signingAlgorithm }: TCmekSignDTO, actor: OrgServiceActor) => { + const key = await kmsDAL.findCmekById(keyId); + + if (!key) throw new NotFoundError({ message: `Key with ID "${keyId}" not found` }); + + if (!key.projectId || key.isReserved) throw new BadRequestError({ message: "Key is not customer managed" }); + + if (key.isDisabled) throw new BadRequestError({ message: "Key is disabled" }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + projectId: key.projectId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.KMS + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionCmekActions.Sign, ProjectPermissionSub.Cmek); + + const sign = await kmsService.signWithKmsKey({ kmsId: keyId }); + + const { signature, algorithm } = await sign({ data: Buffer.from(data, "base64"), signingAlgorithm }); + + return { + signature: signature.toString("base64"), + keyId: key.id, + signingAlgorithm: algorithm + }; + }; + + const cmekVerify = async ({ keyId, data, signature, signingAlgorithm }: TCmekVerifyDTO, actor: OrgServiceActor) => { + const key = await kmsDAL.findCmekById(keyId); + + if (!key) throw new NotFoundError({ message: `Key with ID "${keyId}" not found` }); + + if (!key.projectId || key.isReserved) throw new BadRequestError({ message: "Key is not customer managed" }); + + if (key.isDisabled) throw new BadRequestError({ message: "Key is disabled" }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + projectId: key.projectId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.KMS + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionCmekActions.Verify, ProjectPermissionSub.Cmek); + + const verify = await kmsService.verifyWithKmsKey({ kmsId: keyId, signingAlgorithm }); + + const { signatureValid, algorithm } = await verify({ + data: Buffer.from(data, "base64"), + signature: Buffer.from(signature, "base64") + }); + + return { + signatureValid, + keyId: key.id, + signingAlgorithm: algorithm + }; + }; + const cmekDecrypt = async ({ keyId, ciphertext }: TCmekDecryptDTO, actor: OrgServiceActor) => { const key = await kmsDAL.findById(keyId); @@ -259,6 +402,10 @@ export const cmekServiceFactory = ({ kmsService, kmsDAL, permissionService, proj cmekEncrypt, cmekDecrypt, findCmekById, - findCmekByName + findCmekByName, + cmekSign, + cmekVerify, + listSigningAlgorithms, + getPublicKey }; }; diff --git a/backend/src/services/cmek/cmek-types.ts b/backend/src/services/cmek/cmek-types.ts index b99ff1d6e..2d137ba90 100644 --- a/backend/src/services/cmek/cmek-types.ts +++ b/backend/src/services/cmek/cmek-types.ts @@ -1,12 +1,18 @@ -import { SymmetricEncryption } from "@app/lib/crypto/cipher"; +import { SymmetricKeyEncryptDecrypt } from "@app/lib/crypto/cipher"; +import { AsymmetricKeySignVerify, SigningAlgorithm } from "@app/lib/crypto/sign"; import { OrderByDirection } from "@app/lib/types"; +import { KmsKeyIntent } from "../kms/kms-types"; + +export type TCmekKeyEncryptionAlgorithm = SymmetricKeyEncryptDecrypt | AsymmetricKeySignVerify; + export type TCreateCmekDTO = { orgId: string; projectId: string; name: string; description?: string; - encryptionAlgorithm: SymmetricEncryption; + encryptionAlgorithm: TCmekKeyEncryptionAlgorithm; + type: KmsKeyIntent; }; export type TUpdabteCmekByIdDTO = { @@ -38,3 +44,24 @@ export type TCmekDecryptDTO = { export enum CmekOrderBy { Name = "name" } + +export type TCmekListSigningAlgorithmsDTO = { + keyId: string; +}; + +export type TCmekGetPublicKeyDTO = { + keyId: string; +}; + +export type TCmekSignDTO = { + keyId: string; + data: string; + signingAlgorithm: SigningAlgorithm; +}; + +export type TCmekVerifyDTO = { + keyId: string; + data: string; + signature: string; + signingAlgorithm: SigningAlgorithm; +}; diff --git a/backend/src/services/kms/kms-fns.ts b/backend/src/services/kms/kms-fns.ts index 06395272b..f0acb8f22 100644 --- a/backend/src/services/kms/kms-fns.ts +++ b/backend/src/services/kms/kms-fns.ts @@ -1,13 +1,55 @@ -import { SymmetricEncryption } from "@app/lib/crypto/cipher"; +import { SymmetricKeyEncryptDecrypt } from "@app/lib/crypto/cipher"; +import { AsymmetricKeySignVerify } from "@app/lib/crypto/sign"; +import { BadRequestError } from "@app/lib/errors"; + +import { KmsKeyIntent } from "./kms-types"; export const KMS_ROOT_CONFIG_UUID = "00000000-0000-0000-0000-000000000000"; -export const getByteLengthForAlgorithm = (encryptionAlgorithm: SymmetricEncryption) => { +export const getByteLengthForSymmetricEncryptionAlgorithm = (encryptionAlgorithm: SymmetricKeyEncryptDecrypt) => { switch (encryptionAlgorithm) { - case SymmetricEncryption.AES_GCM_128: + case SymmetricKeyEncryptDecrypt.AES_GCM_128: return 16; - case SymmetricEncryption.AES_GCM_256: + case SymmetricKeyEncryptDecrypt.AES_GCM_256: default: return 32; } }; + +export const verifyKeyTypeAndAlgorithm = ( + type: KmsKeyIntent, + algorithm: SymmetricKeyEncryptDecrypt | AsymmetricKeySignVerify, + extra?: { + forceType?: KmsKeyIntent; + } +) => { + if (extra?.forceType && type !== extra.forceType) { + throw new BadRequestError({ + message: `Unsupported key type, expected ${extra.forceType} but got ${type}` + }); + } + + if (type === KmsKeyIntent.ENCRYPT_DECRYPT) { + if (!Object.values(SymmetricKeyEncryptDecrypt).includes(algorithm as SymmetricKeyEncryptDecrypt)) { + throw new BadRequestError({ + message: `Unsupported encryption algorithm for encrypt/decrypt key: ${algorithm as string}` + }); + } + + return true; + } + + if (type === KmsKeyIntent.SIGN_VERIFY) { + if (!Object.values(AsymmetricKeySignVerify).includes(algorithm as AsymmetricKeySignVerify)) { + throw new BadRequestError({ + message: `Unsupported sign/verify algorithm for sign/verify key: ${algorithm as string}` + }); + } + + return true; + } + + throw new BadRequestError({ + message: `Unsupported key type: ${type as string}` + }); +}; diff --git a/backend/src/services/kms/kms-service.ts b/backend/src/services/kms/kms-service.ts index cfd64a89a..c7d76d931 100644 --- a/backend/src/services/kms/kms-service.ts +++ b/backend/src/services/kms/kms-service.ts @@ -1,3 +1,5 @@ +import crypto from "node:crypto"; + import slugify from "@sindresorhus/slugify"; import { Knex } from "knex"; import { z } from "zod"; @@ -15,12 +17,17 @@ import { THsmServiceFactory } from "@app/ee/services/hsm/hsm-service"; import { KeyStorePrefixes, PgSqlLock, TKeyStoreFactory } from "@app/keystore/keystore"; import { TEnvConfig } from "@app/lib/config/env"; import { randomSecureBytes } from "@app/lib/crypto"; -import { symmetricCipherService, SymmetricEncryption } from "@app/lib/crypto/cipher"; +import { symmetricCipherService, SymmetricKeyEncryptDecrypt } from "@app/lib/crypto/cipher"; import { generateHash } from "@app/lib/crypto/encryption"; +import { AsymmetricKeySignVerify, signingService } from "@app/lib/crypto/sign"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { alphaNumericNanoId } from "@app/lib/nanoid"; -import { getByteLengthForAlgorithm, KMS_ROOT_CONFIG_UUID } from "@app/services/kms/kms-fns"; +import { + getByteLengthForSymmetricEncryptionAlgorithm, + KMS_ROOT_CONFIG_UUID, + verifyKeyTypeAndAlgorithm +} from "@app/services/kms/kms-fns"; import { TOrgDALFactory } from "../org/org-dal"; import { TProjectDALFactory } from "../project/project-dal"; @@ -29,6 +36,7 @@ import { TKmsKeyDALFactory } from "./kms-key-dal"; import { TKmsRootConfigDALFactory } from "./kms-root-config-dal"; import { KmsDataKey, + KmsKeyIntent, KmsType, RootKeyEncryptionStrategy, TDecryptWithKeyDTO, @@ -38,8 +46,11 @@ import { TEncryptWithKmsDTO, TGenerateKMSDTO, TGetKeyMaterialDTO, + TGetPublicKeyDTO, TImportKeyMaterialDTO, - TUpdateProjectSecretManagerKmsKeyDTO + TSignWithKmsDTO, + TUpdateProjectSecretManagerKmsKeyDTO, + TVerifyWithKmsDTO } from "./kms-types"; type TKmsServiceFactoryDep = { @@ -83,19 +94,42 @@ export const kmsServiceFactory = ({ tx, name, projectId, - encryptionAlgorithm = SymmetricEncryption.AES_GCM_256, + encryptionAlgorithm = SymmetricKeyEncryptDecrypt.AES_GCM_256, + type = KmsKeyIntent.ENCRYPT_DECRYPT, description }: TGenerateKMSDTO) => { - const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); + // daniel: ensure that the key type (sign/encrypt) and the encryption algorithm are compatible. + verifyKeyTypeAndAlgorithm(type, encryptionAlgorithm); - const kmsKeyMaterial = randomSecureBytes(getByteLengthForAlgorithm(encryptionAlgorithm)); + let kmsKeyMaterial: Buffer | null = null; + if (type === KmsKeyIntent.ENCRYPT_DECRYPT) { + kmsKeyMaterial = randomSecureBytes( + getByteLengthForSymmetricEncryptionAlgorithm(encryptionAlgorithm as SymmetricKeyEncryptDecrypt) + ); + } else if (type === KmsKeyIntent.SIGN_VERIFY) { + const { generateAsymmetricPrivateKey, getPublicKeyFromPrivateKey } = signingService( + encryptionAlgorithm as AsymmetricKeySignVerify + ); + kmsKeyMaterial = await generateAsymmetricPrivateKey(); + // daniel: safety check to ensure we're able to extract the public key from the private key before we proceed to key creation + getPublicKeyFromPrivateKey(kmsKeyMaterial); + } + + if (!kmsKeyMaterial) { + throw new BadRequestError({ + message: `Invalid KMS key type. No key material was created for key type '${type}' using algorithm '${encryptionAlgorithm}'` + }); + } + + const cipher = symmetricCipherService(SymmetricKeyEncryptDecrypt.AES_GCM_256); const encryptedKeyMaterial = cipher.encrypt(kmsKeyMaterial, ROOT_ENCRYPTION_KEY); const sanitizedName = name ? slugify(name) : slugify(alphaNumericNanoId(8).toLowerCase()); const dbQuery = async (db: Knex) => { const kmsDoc = await kmsDAL.create( { name: sanitizedName, + type, orgId, isReserved, projectId, @@ -115,6 +149,7 @@ export const kmsServiceFactory = ({ ); return kmsDoc; }; + if (tx) return dbQuery(tx); const doc = await kmsDAL.transaction(async (tx2) => dbQuery(tx2)); return doc; @@ -134,7 +169,7 @@ export const kmsServiceFactory = ({ */ const encryptWithInputKey = async ({ key }: Omit) => { // akhilmhdh: as more encryption are added do a check here on kmsDoc.encryptionAlgorithm - const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); + const cipher = symmetricCipherService(SymmetricKeyEncryptDecrypt.AES_GCM_256); return ({ plainText }: Pick) => { const encryptedPlainTextBlob = cipher.encrypt(plainText, key); // Buffer#1 encrypted text + Buffer#2 version number @@ -149,7 +184,7 @@ export const kmsServiceFactory = ({ * This can be even later exposed directly as api for encryption as function */ const decryptWithInputKey = async ({ key }: Omit) => { - const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); + const cipher = symmetricCipherService(SymmetricKeyEncryptDecrypt.AES_GCM_256); return ({ cipherTextBlob: versionedCipherTextBlob }: Pick) => { const cipherTextBlob = versionedCipherTextBlob.subarray(0, -KMS_VERSION_BLOB_LENGTH); @@ -227,7 +262,7 @@ export const kmsServiceFactory = ({ }; const encryptWithRootKey = () => { - const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); + const cipher = symmetricCipherService(SymmetricKeyEncryptDecrypt.AES_GCM_256); return (plainTextBuffer: Buffer) => { const encryptedBuffer = cipher.encrypt(plainTextBuffer, ROOT_ENCRYPTION_KEY); @@ -236,7 +271,7 @@ export const kmsServiceFactory = ({ }; const decryptWithRootKey = () => { - const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); + const cipher = symmetricCipherService(SymmetricKeyEncryptDecrypt.AES_GCM_256); return (cipherTextBuffer: Buffer) => { return cipher.decrypt(cipherTextBuffer, ROOT_ENCRYPTION_KEY); @@ -255,6 +290,11 @@ export const kmsServiceFactory = ({ throw new NotFoundError({ message: `KMS with ID '${kmsId}' not found` }); } + const encryptionAlgorithm = kmsDoc.internalKms?.encryptionAlgorithm as SymmetricKeyEncryptDecrypt; + verifyKeyTypeAndAlgorithm(kmsDoc.type as KmsKeyIntent, encryptionAlgorithm, { + forceType: KmsKeyIntent.ENCRYPT_DECRYPT + }); + if (kmsDoc.externalKms) { let externalKms: TExternalKmsProviderFns; @@ -316,8 +356,8 @@ export const kmsServiceFactory = ({ } // internal KMS - const keyCipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); - const dataCipher = symmetricCipherService(kmsDoc.internalKms?.encryptionAlgorithm as SymmetricEncryption); + const keyCipher = symmetricCipherService(SymmetricKeyEncryptDecrypt.AES_GCM_256); + const dataCipher = symmetricCipherService(encryptionAlgorithm); const kmsKey = keyCipher.decrypt(kmsDoc.internalKms?.encryptedKey as Buffer, ROOT_ENCRYPTION_KEY); return ({ cipherTextBlob: versionedCipherTextBlob }: Pick) => { @@ -345,19 +385,22 @@ export const kmsServiceFactory = ({ }); } - const keyCipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); + const keyCipher = symmetricCipherService(SymmetricKeyEncryptDecrypt.AES_GCM_256); const kmsKey = keyCipher.decrypt(kmsDoc.internalKms?.encryptedKey as Buffer, ROOT_ENCRYPTION_KEY); return kmsKey; }; const importKeyMaterial = async ( - { key, algorithm, name, isReserved, projectId, orgId }: TImportKeyMaterialDTO, + { key, algorithm, name, isReserved, projectId, orgId, type }: TImportKeyMaterialDTO, tx?: Knex ) => { - const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); + // daniel: currently we only support imports for encrypt/decrypt keys + verifyKeyTypeAndAlgorithm(type, algorithm, { forceType: KmsKeyIntent.ENCRYPT_DECRYPT }); - const expectedByteLength = getByteLengthForAlgorithm(algorithm); + const cipher = symmetricCipherService(SymmetricKeyEncryptDecrypt.AES_GCM_256); + + const expectedByteLength = getByteLengthForSymmetricEncryptionAlgorithm(algorithm as SymmetricKeyEncryptDecrypt); if (key.byteLength !== expectedByteLength) { throw new BadRequestError({ message: `Invalid key length for ${algorithm}. Expected ${expectedByteLength} bytes but got ${key.byteLength} bytes` @@ -370,6 +413,7 @@ export const kmsServiceFactory = ({ const kmsDoc = await kmsDAL.create( { name: sanitizedName, + type: KmsKeyIntent.ENCRYPT_DECRYPT, orgId, isReserved, projectId @@ -393,12 +437,89 @@ export const kmsServiceFactory = ({ return doc; }; + const getPublicKey = async ({ kmsId }: TGetPublicKeyDTO) => { + const kmsDoc = await kmsDAL.findByIdWithAssociatedKms(kmsId); + if (!kmsDoc) { + throw new NotFoundError({ message: `KMS with ID '${kmsId}' not found` }); + } + + const encryptionAlgorithm = kmsDoc.internalKms?.encryptionAlgorithm as AsymmetricKeySignVerify; + + verifyKeyTypeAndAlgorithm(kmsDoc.type as KmsKeyIntent, encryptionAlgorithm, { + forceType: KmsKeyIntent.SIGN_VERIFY + }); + + const keyCipher = symmetricCipherService(SymmetricKeyEncryptDecrypt.AES_GCM_256); + const kmsKey = keyCipher.decrypt(kmsDoc.internalKms?.encryptedKey as Buffer, ROOT_ENCRYPTION_KEY); + + const publicKeyBuffer = signingService(encryptionAlgorithm).getPublicKeyFromPrivateKey(kmsKey); + + return crypto + .createPublicKey({ + key: publicKeyBuffer, + format: "pem", + type: "spki" + }) + .export({ type: "spki", format: "pem" }) as string; // format 'pem' makes it a string + }; + + const signWithKmsKey = async ({ kmsId }: Pick) => { + const kmsDoc = await kmsDAL.findByIdWithAssociatedKms(kmsId); + if (!kmsDoc) { + throw new NotFoundError({ message: `KMS with ID '${kmsId}' not found` }); + } + + const encryptionAlgorithm = kmsDoc.internalKms?.encryptionAlgorithm as AsymmetricKeySignVerify; + verifyKeyTypeAndAlgorithm(kmsDoc.type as KmsKeyIntent, encryptionAlgorithm, { + forceType: KmsKeyIntent.SIGN_VERIFY + }); + + const keyCipher = symmetricCipherService(SymmetricKeyEncryptDecrypt.AES_GCM_256); + const { sign } = signingService(encryptionAlgorithm); + return ({ data, signingAlgorithm }: Pick) => { + const kmsKey = keyCipher.decrypt(kmsDoc.internalKms?.encryptedKey as Buffer, ROOT_ENCRYPTION_KEY); + const signature = sign(data, kmsKey, signingAlgorithm); + + return Promise.resolve({ signature, algorithm: signingAlgorithm }); + }; + }; + + const verifyWithKmsKey = async ({ + kmsId, + signingAlgorithm + }: Pick) => { + const kmsDoc = await kmsDAL.findByIdWithAssociatedKms(kmsId); + if (!kmsDoc) { + throw new NotFoundError({ message: `KMS with ID '${kmsId}' not found` }); + } + + const encryptionAlgorithm = kmsDoc.internalKms?.encryptionAlgorithm as AsymmetricKeySignVerify; + verifyKeyTypeAndAlgorithm(kmsDoc.type as KmsKeyIntent, encryptionAlgorithm, { + forceType: KmsKeyIntent.SIGN_VERIFY + }); + + const keyCipher = symmetricCipherService(SymmetricKeyEncryptDecrypt.AES_GCM_256); + const { verify, getPublicKeyFromPrivateKey } = signingService(encryptionAlgorithm); + return ({ data, signature }: Pick) => { + const kmsKey = keyCipher.decrypt(kmsDoc.internalKms?.encryptedKey as Buffer, ROOT_ENCRYPTION_KEY); + + const publicKey = getPublicKeyFromPrivateKey(kmsKey); + const signatureValid = verify(data, signature, publicKey, signingAlgorithm); + return Promise.resolve({ signatureValid, algorithm: signingAlgorithm }); + }; + }; + const encryptWithKmsKey = async ({ kmsId }: Omit, tx?: Knex) => { const kmsDoc = await kmsDAL.findByIdWithAssociatedKms(kmsId, tx); if (!kmsDoc) { throw new NotFoundError({ message: `KMS with ID '${kmsId}' not found` }); } + const encryptionAlgorithm = kmsDoc.internalKms?.encryptionAlgorithm as SymmetricKeyEncryptDecrypt; + verifyKeyTypeAndAlgorithm(kmsDoc.type as KmsKeyIntent, encryptionAlgorithm, { + forceType: KmsKeyIntent.ENCRYPT_DECRYPT + }); + if (kmsDoc.externalKms) { let externalKms: TExternalKmsProviderFns; if (!kmsDoc.orgKms.id || !kmsDoc.orgKms.encryptedDataKey) { @@ -454,8 +575,8 @@ export const kmsServiceFactory = ({ } // internal KMS - const keyCipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); - const dataCipher = symmetricCipherService(kmsDoc.internalKms?.encryptionAlgorithm as SymmetricEncryption); + const keyCipher = symmetricCipherService(SymmetricKeyEncryptDecrypt.AES_GCM_256); + const dataCipher = symmetricCipherService(encryptionAlgorithm); return ({ plainText }: Pick) => { const kmsKey = keyCipher.decrypt(kmsDoc.internalKms?.encryptedKey as Buffer, ROOT_ENCRYPTION_KEY); const encryptedPlainTextBlob = dataCipher.encrypt(plainText, kmsKey); @@ -729,7 +850,7 @@ export const kmsServiceFactory = ({ // case 2: root key is encrypted with software encryption if (kmsRootConfig.encryptionStrategy === RootKeyEncryptionStrategy.Software) { - const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); + const cipher = symmetricCipherService(SymmetricKeyEncryptDecrypt.AES_GCM_256); const encryptionKeyBuffer = $getBasicEncryptionKey(); return cipher.decrypt(kmsRootConfig.encryptedRootKey, encryptionKeyBuffer); @@ -749,7 +870,7 @@ export const kmsServiceFactory = ({ } if (strategy === RootKeyEncryptionStrategy.Software) { - const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); + const cipher = symmetricCipherService(SymmetricKeyEncryptDecrypt.AES_GCM_256); const encryptionKeyBuffer = $getBasicEncryptionKey(); return cipher.encrypt(plainKeyBuffer, encryptionKeyBuffer); @@ -765,7 +886,7 @@ export const kmsServiceFactory = ({ const createCipherPairWithDataKey = async (encryptionContext: TEncryptWithKmsDataKeyDTO, trx?: Knex) => { const dataKey = await $getDataKey(encryptionContext, trx); - const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); + const cipher = symmetricCipherService(SymmetricKeyEncryptDecrypt.AES_GCM_256); return { encryptor: ({ plainText }: Pick) => { @@ -966,6 +1087,7 @@ export const kmsServiceFactory = ({ const decryptedRootKey = await $decryptRootKey(kmsRootConfig); logger.info("KMS: Loading ROOT Key into Memory."); + ROOT_ENCRYPTION_KEY = decryptedRootKey; }; @@ -1014,6 +1136,9 @@ export const kmsServiceFactory = ({ getKmsById, createCipherPairWithDataKey, getKeyMaterial, - importKeyMaterial + importKeyMaterial, + signWithKmsKey, + verifyWithKmsKey, + getPublicKey }; }; diff --git a/backend/src/services/kms/kms-types.ts b/backend/src/services/kms/kms-types.ts index 8be0b29fc..163eedc35 100644 --- a/backend/src/services/kms/kms-types.ts +++ b/backend/src/services/kms/kms-types.ts @@ -1,6 +1,7 @@ import { Knex } from "knex"; -import { SymmetricEncryption } from "@app/lib/crypto/cipher"; +import { SymmetricKeyEncryptDecrypt } from "@app/lib/crypto/cipher"; +import { AsymmetricKeySignVerify, SigningAlgorithm } from "@app/lib/crypto/sign/types"; export enum KmsDataKey { Organization, @@ -13,6 +14,11 @@ export enum KmsType { Internal = "internal" } +export enum KmsKeyIntent { + ENCRYPT_DECRYPT = "encrypt-decrypt", + SIGN_VERIFY = "sign-verify" +} + export type TEncryptWithKmsDataKeyDTO = | { type: KmsDataKey.Organization; orgId: string } | { type: KmsDataKey.SecretManager; projectId: string }; @@ -25,7 +31,8 @@ export type TEncryptWithKmsDataKeyDTO = export type TGenerateKMSDTO = { orgId: string; projectId?: string; - encryptionAlgorithm?: SymmetricEncryption; + encryptionAlgorithm?: SymmetricKeyEncryptDecrypt | AsymmetricKeySignVerify; + type?: KmsKeyIntent; isReserved?: boolean; name?: string; description?: string; @@ -37,6 +44,23 @@ export type TEncryptWithKmsDTO = { plainText: Buffer; }; +export type TGetPublicKeyDTO = { + kmsId: string; +}; + +export type TSignWithKmsDTO = { + kmsId: string; + data: Buffer; + signingAlgorithm: SigningAlgorithm; +}; + +export type TVerifyWithKmsDTO = { + kmsId: string; + data: Buffer; + signature: Buffer; + signingAlgorithm: SigningAlgorithm; +}; + export type TEncryptionWithKeyDTO = { key: Buffer; plainText: Buffer; @@ -67,9 +91,10 @@ export type TGetKeyMaterialDTO = { export type TImportKeyMaterialDTO = { key: Buffer; - algorithm: SymmetricEncryption; + algorithm: SymmetricKeyEncryptDecrypt; name?: string; isReserved: boolean; projectId: string; orgId: string; + type: KmsKeyIntent; }; diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index fb74e675c..ac3f13161 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -30,7 +30,9 @@ export enum ProjectPermissionCmekActions { Edit = "edit", Delete = "delete", Encrypt = "encrypt", - Decrypt = "decrypt" + Decrypt = "decrypt", + Sign = "sign", + Verify = "verify" } export enum ProjectPermissionKmipActions { diff --git a/frontend/src/helpers/kms.ts b/frontend/src/helpers/kms.ts new file mode 100644 index 000000000..d94434ee8 --- /dev/null +++ b/frontend/src/helpers/kms.ts @@ -0,0 +1,31 @@ +import { + AsymmetricKeySignVerify, + KmsKeyIntent, + SymmetricKeyEncryptDecrypt +} from "@app/hooks/api/cmeks"; + +export const kmsKeyUsageOptions: Record< + KmsKeyIntent, + { + label: string; + tooltip: string; + } +> = { + [KmsKeyIntent.ENCRYPT_DECRYPT]: { + label: "Encrypt/Decrypt", + tooltip: "Use the key only to encrypt and decrypt data." + }, + [KmsKeyIntent.SIGN_VERIFY]: { + label: "Sign/Verify", + tooltip: + "Key pairs for digital signing. Uses the private key for signing and the public key for verification." + } +}; + +export const keyUsageDefaultOption: Record< + KmsKeyIntent, + SymmetricKeyEncryptDecrypt | AsymmetricKeySignVerify +> = { + [KmsKeyIntent.ENCRYPT_DECRYPT]: SymmetricKeyEncryptDecrypt.AES_GCM_256, + [KmsKeyIntent.SIGN_VERIFY]: AsymmetricKeySignVerify.RSA_4096 +}; diff --git a/frontend/src/hooks/api/cmeks/mutations.tsx b/frontend/src/hooks/api/cmeks/mutations.tsx index 41e70c193..b806e2b44 100644 --- a/frontend/src/hooks/api/cmeks/mutations.tsx +++ b/frontend/src/hooks/api/cmeks/mutations.tsx @@ -8,6 +8,10 @@ import { TCmekDecryptResponse, TCmekEncrypt, TCmekEncryptResponse, + TCmekSign, + TCmekSignResponse, + TCmekVerify, + TCmekVerifyResponse, TCreateCmek, TDeleteCmek, TUpdateCmek @@ -74,6 +78,44 @@ export const useCmekEncrypt = () => { }); }; +export const useCmekSign = () => { + return useMutation({ + mutationFn: async ({ + keyId, + data, + signingAlgorithm, + isBase64Encoded + }: TCmekSign & { isBase64Encoded: boolean }) => { + const res = await apiRequest.post(`/api/v1/kms/keys/${keyId}/sign`, { + data: isBase64Encoded ? data : encodeBase64(Buffer.from(data)), + signingAlgorithm + }); + + return res.data; + } + }); +}; + +export const useCmekVerify = () => { + return useMutation({ + mutationFn: async ({ + keyId, + data, + signature, + signingAlgorithm, + isBase64Encoded + }: TCmekVerify & { isBase64Encoded: boolean }) => { + const res = await apiRequest.post(`/api/v1/kms/keys/${keyId}/verify`, { + data: isBase64Encoded ? data : encodeBase64(Buffer.from(data)), + signature, + signingAlgorithm + }); + + return res.data; + } + }); +}; + export const useCmekDecrypt = () => { return useMutation({ mutationFn: async ({ keyId, ciphertext }: TCmekDecrypt) => { diff --git a/frontend/src/hooks/api/cmeks/types.ts b/frontend/src/hooks/api/cmeks/types.ts index c557c1fc2..485187613 100644 --- a/frontend/src/hooks/api/cmeks/types.ts +++ b/frontend/src/hooks/api/cmeks/types.ts @@ -1,10 +1,18 @@ +import { z } from "zod"; + import { OrderByDirection } from "@app/hooks/api/generic/types"; +export enum KmsKeyIntent { + ENCRYPT_DECRYPT = "encrypt-decrypt", + SIGN_VERIFY = "sign-verify" +} + export type TCmek = { id: string; + type: KmsKeyIntent; name: string; description?: string; - encryptionAlgorithm: EncryptionAlgorithm; + encryptionAlgorithm: AsymmetricKeySignVerify | SymmetricKeyEncryptDecrypt; projectId: string; isDisabled: boolean; isReserved: boolean; @@ -17,7 +25,8 @@ export type TCmek = { type ProjectRef = { projectId: string }; type KeyRef = { keyId: string }; -export type TCreateCmek = Pick & ProjectRef; +export type TCreateCmek = Pick & + ProjectRef; export type TUpdateCmek = KeyRef & Partial> & ProjectRef; @@ -26,6 +35,13 @@ export type TDeleteCmek = KeyRef & ProjectRef; export type TCmekEncrypt = KeyRef & { plaintext: string; isBase64Encoded?: boolean }; export type TCmekDecrypt = KeyRef & { ciphertext: string }; +export type TCmekSign = KeyRef & { data: string; signingAlgorithm: SigningAlgorithm }; +export type TCmekVerify = KeyRef & { + data: string; + signature: string; + signingAlgorithm: SigningAlgorithm; +}; + export type TProjectCmeksList = { keys: TCmek[]; totalCount: number; @@ -44,6 +60,18 @@ export type TCmekEncryptResponse = { ciphertext: string; }; +export type TCmekSignResponse = { + signature: string; + keyId: string; + signingAlgorithm: SigningAlgorithm; +}; + +export type TCmekVerifyResponse = { + signatureValid: boolean; + keyId: string; + signingAlgorithm: SigningAlgorithm; +}; + export type TCmekDecryptResponse = { plaintext: string; }; @@ -52,7 +80,35 @@ export enum CmekOrderBy { Name = "name" } -export enum EncryptionAlgorithm { +export enum AsymmetricKeySignVerify { + RSA_4096 = "rsa-4096", + ECC_NIST_P256 = "ecc-nist-p256" +} + +// Supported symmetric encrypt/decrypt algorithms +export enum SymmetricKeyEncryptDecrypt { AES_GCM_256 = "aes-256-gcm", AES_GCM_128 = "aes-128-gcm" } + +export const AllowedEncryptionKeyAlgorithms = z.enum([ + ...Object.values(SymmetricKeyEncryptDecrypt), + ...Object.values(AsymmetricKeySignVerify) +] as [string, ...string[]]).options; + +export enum SigningAlgorithm { + // RSA PSS algorithms + RSASSA_PSS_SHA_256 = "RSASSA_PSS_SHA_256", + RSASSA_PSS_SHA_384 = "RSASSA_PSS_SHA_384", + RSASSA_PSS_SHA_512 = "RSASSA_PSS_SHA_512", + + // RSA PKCS#1 v1.5 algorithms + RSASSA_PKCS1_V1_5_SHA_256 = "RSASSA_PKCS1_V1_5_SHA_256", + RSASSA_PKCS1_V1_5_SHA_384 = "RSASSA_PKCS1_V1_5_SHA_384", + RSASSA_PKCS1_V1_5_SHA_512 = "RSASSA_PKCS1_V1_5_SHA_512", + + // ECDSA algorithms + ECDSA_SHA_256 = "ECDSA_SHA_256", + ECDSA_SHA_384 = "ECDSA_SHA_384", + ECDSA_SHA_512 = "ECDSA_SHA_512" +} diff --git a/frontend/src/lib/fn/base64.ts b/frontend/src/lib/fn/base64.ts new file mode 100644 index 000000000..bcef1a60b --- /dev/null +++ b/frontend/src/lib/fn/base64.ts @@ -0,0 +1,14 @@ +const base64WithPadding = + /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{4})$/; + +export const isBase64 = (str: string): boolean => { + if (typeof str !== "string") { + throw new TypeError("Expected a string"); + } + + if (str === "") return true; + + const regex = base64WithPadding; + + return regex.test(str); +}; diff --git a/frontend/src/pages/kms/OverviewPage/components/CmekModal.tsx b/frontend/src/pages/kms/OverviewPage/components/CmekModal.tsx index 7598d8e80..d7616dfca 100644 --- a/frontend/src/pages/kms/OverviewPage/components/CmekModal.tsx +++ b/frontend/src/pages/kms/OverviewPage/components/CmekModal.tsx @@ -15,13 +15,23 @@ import { TextArea } from "@app/components/v2"; import { useWorkspace } from "@app/context"; -import { EncryptionAlgorithm, TCmek, useCreateCmek, useUpdateCmek } from "@app/hooks/api/cmeks"; +import { keyUsageDefaultOption, kmsKeyUsageOptions } from "@app/helpers/kms"; +import { + AllowedEncryptionKeyAlgorithms, + AsymmetricKeySignVerify, + KmsKeyIntent, + SymmetricKeyEncryptDecrypt, + TCmek, + useCreateCmek, + useUpdateCmek +} from "@app/hooks/api/cmeks"; import { slugSchema } from "@app/lib/schemas"; const formSchema = z.object({ name: slugSchema({ min: 1, max: 32, field: "Name" }), description: z.string().max(500).optional(), - encryptionAlgorithm: z.nativeEnum(EncryptionAlgorithm) + encryptionAlgorithm: z.enum(AllowedEncryptionKeyAlgorithms), + type: z.nativeEnum(KmsKeyIntent) }); export type FormData = z.infer; @@ -47,24 +57,30 @@ const CmekForm = ({ onComplete, cmek }: FormProps) => { control, handleSubmit, register, + setValue, + watch, formState: { isSubmitting, errors } } = useForm({ resolver: zodResolver(formSchema), defaultValues: { name: cmek?.name, description: cmek?.description, - encryptionAlgorithm: EncryptionAlgorithm.AES_GCM_256 + encryptionAlgorithm: SymmetricKeyEncryptDecrypt.AES_GCM_256, + type: KmsKeyIntent.ENCRYPT_DECRYPT } }); - const handleCreateCmek = async ({ encryptionAlgorithm, name, description }: FormData) => { + const handleCreateCmek = async ({ encryptionAlgorithm, name, description, type }: FormData) => { const mutation = isUpdate ? updateCmek.mutateAsync({ keyId: cmek.id, projectId, name, description }) : createCmek.mutateAsync({ projectId, - encryptionAlgorithm, name, - description + description, + type, + encryptionAlgorithm: encryptionAlgorithm as + | AsymmetricKeySignVerify + | SymmetricKeyEncryptDecrypt }); try { @@ -83,6 +99,8 @@ const CmekForm = ({ onComplete, cmek }: FormProps) => { } }; + const selectedType = watch("type"); + return (
{ > - {!isUpdate && ( - ( - - - - )} - /> - )} +
+ {!isUpdate && ( + <> + ( + + {Object.entries(KmsKeyIntent).map(([key, value]) => ( +
+

{kmsKeyUsageOptions[value].label}

+

{kmsKeyUsageOptions[value].tooltip}

+
+ ))} +
+ } + label="Key Usage" + errorText={error?.message} + isError={Boolean(error)} + > + + + )} + /> + ( + + + + )} + /> + + )} + ; + +type Props = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + cmek: TCmek; +}; + +type FormProps = Pick; + +const SignForm = ({ cmek }: FormProps) => { + const cmekSign = useCmekSign(); + + const { + handleSubmit, + register, + control, + formState: { isSubmitting, errors } + } = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + signingAlgorithm: cmek?.encryptionAlgorithm?.startsWith("rsa") + ? SigningAlgorithm.RSASSA_PSS_SHA_512 + : SigningAlgorithm.ECDSA_SHA_256, + isBase64Encoded: false + } + }); + + const [copySignature, isCopyingSignature, setCopySignature] = useTimedReset({ + initialState: "Copy to Clipboard" + }); + + const handleSignData = async (formData: FormData) => { + try { + await cmekSign.mutateAsync({ ...formData, keyId: cmek.id }); + createNotification({ + text: "Successfully signed data", + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to sign data", + type: "error" + }); + } + }; + + const signature = cmekSign.data?.signature; + + const handleCopyToClipboard = () => { + navigator.clipboard.writeText(signature ?? ""); + + setCopySignature("Copied to Clipboard"); + }; + + const allowedSigningAlgorithms = Object.values(SigningAlgorithm).filter((a) => + cmek?.encryptionAlgorithm?.startsWith("rsa") + ? a.toLowerCase().startsWith("rsa") + : a.toLowerCase().startsWith("ecdsa") + ); + + return ( + + {signature ? ( + +