diff --git a/README.md b/README.md index 74c9a4036..7e3f40d52 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,11 @@ We're on a mission to make security tooling more accessible to everyone, not jus - **[Infisical PKI Issuer for Kubernetes](https://infisical.com/docs/documentation/platform/pki/pki-issuer)**: Deliver TLS certificates to your Kubernetes workloads with automatic renewal. - **[Enrollment over Secure Transport](https://infisical.com/docs/documentation/platform/pki/est)**: Enroll and manage certificates via EST protocol. +### Key Management (KMS): + +- **[Cryptograhic Keys](https://infisical.com/docs/documentation/platform/kms)**: Centrally manage keys across projects through a user-friendly interface or via the API. +- **[Encrypt and Decrypt Data](https://infisical.com/docs/documentation/platform/kms#guide-to-encrypting-data)**: Use symmetric keys to encrypt and decrypt data. + ### General Platform: - **Authentication Methods**: Authenticate machine identities with Infisical using a cloud-native or platform agnostic authentication method ([Kubernetes Auth](https://infisical.com/docs/documentation/platform/identities/kubernetes-auth), [GCP Auth](https://infisical.com/docs/documentation/platform/identities/gcp-auth), [Azure Auth](https://infisical.com/docs/documentation/platform/identities/azure-auth), [AWS Auth](https://infisical.com/docs/documentation/platform/identities/aws-auth), [OIDC Auth](https://infisical.com/docs/documentation/platform/identities/oidc-auth/general), [Universal Auth](https://infisical.com/docs/documentation/platform/identities/universal-auth)). - **[Access Controls](https://infisical.com/docs/documentation/platform/access-controls/overview)**: Define advanced authorization controls for users and machine identities with [RBAC](https://infisical.com/docs/documentation/platform/access-controls/role-based-access-controls), [additional privileges](https://infisical.com/docs/documentation/platform/access-controls/additional-privileges), [temporary access](https://infisical.com/docs/documentation/platform/access-controls/temporary-access), [access requests](https://infisical.com/docs/documentation/platform/access-controls/access-requests), [approval workflows](https://infisical.com/docs/documentation/platform/pr-workflows), and more. diff --git a/backend/package-lock.json b/backend/package-lock.json index 6ec28ed63..31521fb7d 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -61,6 +61,7 @@ "jwks-rsa": "^3.1.0", "knex": "^3.0.1", "ldapjs": "^3.0.7", + "ldif": "^0.5.1", "libsodium-wrappers": "^0.7.13", "lodash.isequal": "^4.5.0", "mongodb": "^6.8.1", @@ -13017,6 +13018,12 @@ "verror": "^1.10.1" } }, + "node_modules/ldif": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/ldif/-/ldif-0.5.1.tgz", + "integrity": "sha512-8s46m/r2lSFO2+DqMxqWiJ10iiL4tuR5LC/KndV+E5//OAOzOx5s3HS5O34PJ5+kyaCA+K2oCaEPaDRfXUnQow==", + "license": "MIT" + }, "node_modules/leven": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-2.1.0.tgz", diff --git a/backend/package.json b/backend/package.json index 181a3a868..886b80893 100644 --- a/backend/package.json +++ b/backend/package.json @@ -165,6 +165,7 @@ "jwks-rsa": "^3.1.0", "knex": "^3.0.1", "ldapjs": "^3.0.7", + "ldif": "0.5.1", "libsodium-wrappers": "^0.7.13", "lodash.isequal": "^4.5.0", "mongodb": "^6.8.1", diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 10be1c4c9..65da73072 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -38,6 +38,7 @@ import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-se import { TCertificateServiceFactory } from "@app/services/certificate/certificate-service"; import { TCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/certificate-authority-service"; import { TCertificateTemplateServiceFactory } from "@app/services/certificate-template/certificate-template-service"; +import { TCmekServiceFactory } from "@app/services/cmek/cmek-service"; import { TExternalMigrationServiceFactory } from "@app/services/external-migration/external-migration-service"; import { TGroupProjectServiceFactory } from "@app/services/group-project/group-project-service"; import { TIdentityServiceFactory } from "@app/services/identity/identity-service"; @@ -182,6 +183,7 @@ declare module "fastify" { orgAdmin: TOrgAdminServiceFactory; slack: TSlackServiceFactory; workflowIntegration: TWorkflowIntegrationServiceFactory; + cmek: TCmekServiceFactory; migration: TExternalMigrationServiceFactory; }; // this is exclusive use for middlewares in which we need to inject data diff --git a/backend/src/@types/ldif.d.ts b/backend/src/@types/ldif.d.ts new file mode 100644 index 000000000..7ba40fba5 --- /dev/null +++ b/backend/src/@types/ldif.d.ts @@ -0,0 +1,4 @@ +declare module "ldif" { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Untyped, the function returns `any`. + function parse(input: string, ...args: any[]): any; +} diff --git a/backend/src/db/migrations/20240925100349_managed-secret-sharing.ts b/backend/src/db/migrations/20240925100349_managed-secret-sharing.ts new file mode 100644 index 000000000..56784d314 --- /dev/null +++ b/backend/src/db/migrations/20240925100349_managed-secret-sharing.ts @@ -0,0 +1,30 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.SecretSharing)) { + await knex.schema.alterTable(TableName.SecretSharing, (t) => { + t.string("iv").nullable().alter(); + t.string("tag").nullable().alter(); + t.string("encryptedValue").nullable().alter(); + + t.binary("encryptedSecret").nullable(); + t.string("hashedHex").nullable().alter(); + + t.string("identifier", 64).nullable(); + t.unique("identifier"); + t.index("identifier"); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.SecretSharing)) { + await knex.schema.alterTable(TableName.SecretSharing, (t) => { + t.dropColumn("encryptedSecret"); + + t.dropColumn("identifier"); + }); + } +} diff --git a/backend/src/db/migrations/20241003220151_kms-key-cmek-alterations.ts b/backend/src/db/migrations/20241003220151_kms-key-cmek-alterations.ts new file mode 100644 index 000000000..6b701eea4 --- /dev/null +++ b/backend/src/db/migrations/20241003220151_kms-key-cmek-alterations.ts @@ -0,0 +1,46 @@ +import { Knex } from "knex"; + +import { dropConstraintIfExists } from "@app/db/migrations/utils/dropConstraintIfExists"; +import { TableName } from "@app/db/schemas"; + +export async function up(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.KmsKey)) { + const hasOrgId = await knex.schema.hasColumn(TableName.KmsKey, "orgId"); + const hasSlug = await knex.schema.hasColumn(TableName.KmsKey, "slug"); + + // drop constraint if exists (won't exist if rolled back, see below) + await dropConstraintIfExists(TableName.KmsKey, "kms_keys_orgid_slug_unique", knex); + + // projectId for CMEK functionality + await knex.schema.alterTable(TableName.KmsKey, (table) => { + table.string("projectId").nullable().references("id").inTable(TableName.Project).onDelete("CASCADE"); + + if (hasOrgId) { + table.unique(["orgId", "projectId", "slug"]); + } + + if (hasSlug) { + table.renameColumn("slug", "name"); + } + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.KmsKey)) { + const hasOrgId = await knex.schema.hasColumn(TableName.KmsKey, "orgId"); + const hasName = await knex.schema.hasColumn(TableName.KmsKey, "name"); + + // remove projectId for CMEK functionality + await knex.schema.alterTable(TableName.KmsKey, (table) => { + if (hasName) { + table.renameColumn("name", "slug"); + } + + if (hasOrgId) { + table.dropUnique(["orgId", "projectId", "slug"]); + } + table.dropColumn("projectId"); + }); + } +} diff --git a/backend/src/db/migrations/utils/dropConstraintIfExists.ts b/backend/src/db/migrations/utils/dropConstraintIfExists.ts new file mode 100644 index 000000000..bfe487d49 --- /dev/null +++ b/backend/src/db/migrations/utils/dropConstraintIfExists.ts @@ -0,0 +1,6 @@ +import { Knex } from "knex"; + +import { TableName } from "@app/db/schemas"; + +export const dropConstraintIfExists = (tableName: TableName, constraintName: string, knex: Knex) => + knex.raw(`ALTER TABLE ${tableName} DROP CONSTRAINT IF EXISTS ${constraintName};`); diff --git a/backend/src/db/migrations/utils/kms.ts b/backend/src/db/migrations/utils/kms.ts index b4698a42d..9ed090978 100644 --- a/backend/src/db/migrations/utils/kms.ts +++ b/backend/src/db/migrations/utils/kms.ts @@ -54,7 +54,7 @@ export const getSecretManagerDataKey = async (knex: Knex, projectId: string) => } else { const [kmsDoc] = await knex(TableName.KmsKey) .insert({ - slug: slugify(alphaNumericNanoId(8).toLowerCase()), + name: slugify(alphaNumericNanoId(8).toLowerCase()), orgId: project.orgId, isReserved: false }) diff --git a/backend/src/db/schemas/kms-keys.ts b/backend/src/db/schemas/kms-keys.ts index 60cbb2e3b..b56fab7bf 100644 --- a/backend/src/db/schemas/kms-keys.ts +++ b/backend/src/db/schemas/kms-keys.ts @@ -13,9 +13,10 @@ export const KmsKeysSchema = z.object({ isDisabled: z.boolean().default(false).nullable().optional(), isReserved: z.boolean().default(true).nullable().optional(), orgId: z.string().uuid(), - slug: z.string(), + name: z.string(), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + projectId: z.string().nullable().optional() }); export type TKmsKeys = z.infer; diff --git a/backend/src/db/schemas/secret-sharing.ts b/backend/src/db/schemas/secret-sharing.ts index 2d0fc5eb5..d47f288b2 100644 --- a/backend/src/db/schemas/secret-sharing.ts +++ b/backend/src/db/schemas/secret-sharing.ts @@ -5,14 +5,16 @@ import { z } from "zod"; +import { zodBuffer } from "@app/lib/zod"; + import { TImmutableDBKeys } from "./models"; export const SecretSharingSchema = z.object({ id: z.string().uuid(), - encryptedValue: z.string(), - iv: z.string(), - tag: z.string(), - hashedHex: z.string(), + encryptedValue: z.string().nullable().optional(), + iv: z.string().nullable().optional(), + tag: z.string().nullable().optional(), + hashedHex: z.string().nullable().optional(), expiresAt: z.date(), userId: z.string().uuid().nullable().optional(), orgId: z.string().uuid().nullable().optional(), @@ -22,7 +24,9 @@ export const SecretSharingSchema = z.object({ accessType: z.string().default("anyone"), name: z.string().nullable().optional(), lastViewedAt: z.date().nullable().optional(), - password: z.string().nullable().optional() + password: z.string().nullable().optional(), + encryptedSecret: zodBuffer.nullable().optional(), + identifier: z.string().nullable().optional() }); export type TSecretSharing = z.infer; diff --git a/backend/src/ee/routes/v1/external-kms-router.ts b/backend/src/ee/routes/v1/external-kms-router.ts index a029e1cb8..4e43d6ed9 100644 --- a/backend/src/ee/routes/v1/external-kms-router.ts +++ b/backend/src/ee/routes/v1/external-kms-router.ts @@ -26,7 +26,7 @@ const sanitizedExternalSchemaForGetAll = KmsKeysSchema.pick({ isDisabled: true, createdAt: true, updatedAt: true, - slug: true + name: true }) .extend({ externalKms: ExternalKmsSchema.pick({ @@ -57,7 +57,7 @@ export const registerExternalKmsRouter = async (server: FastifyZodProvider) => { }, schema: { body: z.object({ - slug: z.string().min(1).trim().toLowerCase(), + name: z.string().min(1).trim().toLowerCase(), description: z.string().trim().optional(), provider: ExternalKmsInputSchema }), @@ -74,7 +74,7 @@ export const registerExternalKmsRouter = async (server: FastifyZodProvider) => { actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, - slug: req.body.slug, + name: req.body.name, provider: req.body.provider, description: req.body.description }); @@ -87,7 +87,7 @@ export const registerExternalKmsRouter = async (server: FastifyZodProvider) => { metadata: { kmsId: externalKms.id, provider: req.body.provider.type, - slug: req.body.slug, + name: req.body.name, description: req.body.description } } @@ -108,7 +108,7 @@ export const registerExternalKmsRouter = async (server: FastifyZodProvider) => { id: z.string().trim().min(1) }), body: z.object({ - slug: z.string().min(1).trim().toLowerCase().optional(), + name: z.string().min(1).trim().toLowerCase().optional(), description: z.string().trim().optional(), provider: ExternalKmsInputUpdateSchema }), @@ -125,7 +125,7 @@ export const registerExternalKmsRouter = async (server: FastifyZodProvider) => { actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, - slug: req.body.slug, + name: req.body.name, provider: req.body.provider, description: req.body.description, id: req.params.id @@ -139,7 +139,7 @@ export const registerExternalKmsRouter = async (server: FastifyZodProvider) => { metadata: { kmsId: externalKms.id, provider: req.body.provider.type, - slug: req.body.slug, + name: req.body.name, description: req.body.description } } @@ -182,7 +182,7 @@ export const registerExternalKmsRouter = async (server: FastifyZodProvider) => { type: EventType.DELETE_KMS, metadata: { kmsId: externalKms.id, - slug: externalKms.slug + name: externalKms.name } } }); @@ -224,7 +224,7 @@ export const registerExternalKmsRouter = async (server: FastifyZodProvider) => { type: EventType.GET_KMS, metadata: { kmsId: externalKms.id, - slug: externalKms.slug + name: externalKms.name } } }); @@ -260,13 +260,13 @@ export const registerExternalKmsRouter = async (server: FastifyZodProvider) => { server.route({ method: "GET", - url: "/slug/:slug", + url: "/name/:name", config: { rateLimit: readLimit }, schema: { params: z.object({ - slug: z.string().trim().min(1) + name: z.string().trim().min(1) }), response: { 200: z.object({ @@ -276,12 +276,12 @@ export const registerExternalKmsRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const externalKms = await server.services.externalKms.findBySlug({ + const externalKms = await server.services.externalKms.findByName({ actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, - slug: req.params.slug + name: req.params.name }); return { externalKms }; } diff --git a/backend/src/ee/routes/v1/project-router.ts b/backend/src/ee/routes/v1/project-router.ts index fccfbd158..e3956731e 100644 --- a/backend/src/ee/routes/v1/project-router.ts +++ b/backend/src/ee/routes/v1/project-router.ts @@ -203,7 +203,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { 200: z.object({ secretManagerKmsKey: z.object({ id: z.string(), - slug: z.string(), + name: z.string(), isExternal: z.boolean() }) }) @@ -243,7 +243,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { 200: z.object({ secretManagerKmsKey: z.object({ id: z.string(), - slug: z.string(), + name: z.string(), isExternal: z.boolean() }) }) @@ -268,7 +268,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { metadata: { secretManagerKmsKey: { id: secretManagerKmsKey.id, - slug: secretManagerKmsKey.slug + name: secretManagerKmsKey.name } } } @@ -336,7 +336,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { 200: z.object({ secretManagerKmsKey: z.object({ id: z.string(), - slug: z.string(), + name: z.string(), isExternal: z.boolean() }) }) 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 542471fac..9a2875f69 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -1,3 +1,4 @@ +import { SymmetricEncryption } from "@app/lib/crypto/cipher"; import { TProjectPermission } from "@app/lib/types"; import { ActorType } from "@app/services/auth/auth-type"; import { CaStatus } from "@app/services/certificate-authority/certificate-authority-types"; @@ -182,7 +183,13 @@ export enum EventType { DELETE_SLACK_INTEGRATION = "delete-slack-integration", GET_PROJECT_SLACK_CONFIG = "get-project-slack-config", UPDATE_PROJECT_SLACK_CONFIG = "update-project-slack-config", - INTEGRATION_SYNCED = "integration-synced" + INTEGRATION_SYNCED = "integration-synced", + CREATE_CMEK = "create-cmek", + UPDATE_CMEK = "update-cmek", + DELETE_CMEK = "delete-cmek", + GET_CMEKS = "get-cmeks", + CMEK_ENCRYPT = "cmek-encrypt", + CMEK_DECRYPT = "cmek-decrypt" } interface UserActorMetadata { @@ -1350,7 +1357,7 @@ interface CreateKmsEvent { metadata: { kmsId: string; provider: string; - slug: string; + name: string; description?: string; }; } @@ -1359,7 +1366,7 @@ interface DeleteKmsEvent { type: EventType.DELETE_KMS; metadata: { kmsId: string; - slug: string; + name: string; }; } @@ -1368,7 +1375,7 @@ interface UpdateKmsEvent { metadata: { kmsId: string; provider: string; - slug?: string; + name?: string; description?: string; }; } @@ -1377,7 +1384,7 @@ interface GetKmsEvent { type: EventType.GET_KMS; metadata: { kmsId: string; - slug: string; + name: string; }; } @@ -1386,7 +1393,7 @@ interface UpdateProjectKmsEvent { metadata: { secretManagerKmsKey: { id: string; - slug: string; + name: string; }; }; } @@ -1541,6 +1548,53 @@ interface IntegrationSyncedEvent { }; } +interface CreateCmekEvent { + type: EventType.CREATE_CMEK; + metadata: { + keyId: string; + name: string; + description?: string; + encryptionAlgorithm: SymmetricEncryption; + }; +} + +interface DeleteCmekEvent { + type: EventType.DELETE_CMEK; + metadata: { + keyId: string; + }; +} + +interface UpdateCmekEvent { + type: EventType.UPDATE_CMEK; + metadata: { + keyId: string; + name?: string; + description?: string; + }; +} + +interface GetCmeksEvent { + type: EventType.GET_CMEKS; + metadata: { + keyIds: string[]; + }; +} + +interface CmekEncryptEvent { + type: EventType.CMEK_ENCRYPT; + metadata: { + keyId: string; + }; +} + +interface CmekDecryptEvent { + type: EventType.CMEK_DECRYPT; + metadata: { + keyId: string; + }; +} + export type Event = | GetSecretsEvent | GetSecretEvent @@ -1680,4 +1734,10 @@ export type Event = | GetSlackIntegration | UpdateProjectSlackConfig | GetProjectSlackConfig - | IntegrationSyncedEvent; + | IntegrationSyncedEvent + | CreateCmekEvent + | UpdateCmekEvent + | DeleteCmekEvent + | GetCmeksEvent + | CmekEncryptEvent + | CmekDecryptEvent; diff --git a/backend/src/ee/services/dynamic-secret/providers/index.ts b/backend/src/ee/services/dynamic-secret/providers/index.ts index 8f2dbdc13..b3c243b5e 100644 --- a/backend/src/ee/services/dynamic-secret/providers/index.ts +++ b/backend/src/ee/services/dynamic-secret/providers/index.ts @@ -3,6 +3,7 @@ import { AwsIamProvider } from "./aws-iam"; import { AzureEntraIDProvider } from "./azure-entra-id"; import { CassandraProvider } from "./cassandra"; import { ElasticSearchProvider } from "./elastic-search"; +import { LdapProvider } from "./ldap"; import { DynamicSecretProviders } from "./models"; import { MongoAtlasProvider } from "./mongo-atlas"; import { MongoDBProvider } from "./mongo-db"; @@ -20,5 +21,6 @@ export const buildDynamicSecretProviders = () => ({ [DynamicSecretProviders.MongoDB]: MongoDBProvider(), [DynamicSecretProviders.ElasticSearch]: ElasticSearchProvider(), [DynamicSecretProviders.RabbitMq]: RabbitMqProvider(), - [DynamicSecretProviders.AzureEntraID]: AzureEntraIDProvider() + [DynamicSecretProviders.AzureEntraID]: AzureEntraIDProvider(), + [DynamicSecretProviders.Ldap]: LdapProvider() }); diff --git a/backend/src/ee/services/dynamic-secret/providers/ldap.ts b/backend/src/ee/services/dynamic-secret/providers/ldap.ts new file mode 100644 index 000000000..056bb582a --- /dev/null +++ b/backend/src/ee/services/dynamic-secret/providers/ldap.ts @@ -0,0 +1,235 @@ +import { compile } from "handlebars"; +import ldapjs from "ldapjs"; +import ldif from "ldif"; +import { customAlphabet } from "nanoid"; +import { z } from "zod"; + +import { BadRequestError } from "@app/lib/errors"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; + +import { LdapSchema, TDynamicProviderFns } from "./models"; + +const generatePassword = () => { + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#"; + return customAlphabet(charset, 64)(); +}; + +const encodePassword = (password?: string) => { + const quotedPassword = `"${password}"`; + const utf16lePassword = Buffer.from(quotedPassword, "utf16le"); + const base64Password = utf16lePassword.toString("base64"); + return base64Password; +}; + +const generateUsername = () => { + return alphaNumericNanoId(20); +}; + +const generateLDIF = ({ + username, + password, + ldifTemplate +}: { + username: string; + password?: string; + ldifTemplate: string; +}): string => { + const data = { + Username: username, + Password: password, + EncodedPassword: encodePassword(password) + }; + + const renderTemplate = compile(ldifTemplate); + const renderedLdif = renderTemplate(data); + + return renderedLdif; +}; + +export const LdapProvider = (): TDynamicProviderFns => { + const validateProviderInputs = async (inputs: unknown) => { + const providerInputs = await LdapSchema.parseAsync(inputs); + return providerInputs; + }; + + const getClient = async (providerInputs: z.infer): Promise => { + return new Promise((resolve, reject) => { + const client = ldapjs.createClient({ + url: providerInputs.url, + tlsOptions: { + ca: providerInputs.ca ? providerInputs.ca : null, + rejectUnauthorized: !!providerInputs.ca + }, + reconnect: true, + bindDN: providerInputs.binddn, + bindCredentials: providerInputs.bindpass + }); + + client.on("error", (err: Error) => { + client.unbind(); + reject(new BadRequestError({ message: err.message })); + }); + + client.bind(providerInputs.binddn, providerInputs.bindpass, (err) => { + if (err) { + client.unbind(); + reject(new BadRequestError({ message: err.message })); + } else { + resolve(client); + } + }); + }); + }; + + const validateConnection = async (inputs: unknown) => { + const providerInputs = await validateProviderInputs(inputs); + const client = await getClient(providerInputs); + return client.connected; + }; + + const executeLdif = async (client: ldapjs.Client, ldif_file: string) => { + type TEntry = { + dn: string; + type: string; + + changes: { + operation?: string; + attribute: { + attribute: string; + }; + value: { + value: string; + }; + values: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Untyped, can be any for ldapjs.Change.modification.values + value: any; + }[]; + }[]; + }; + + let parsedEntries: TEntry[]; + + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + parsedEntries = ldif.parse(ldif_file).entries as TEntry[]; + } catch (err) { + throw new BadRequestError({ + message: "Invalid LDIF format, refer to the documentation at Dynamic secrets > LDAP > LDIF Entries." + }); + } + + const dnArray: string[] = []; + + for await (const entry of parsedEntries) { + const { dn } = entry; + let responseDn: string; + + if (entry.type === "add") { + const attributes: Record = {}; + + entry.changes.forEach((change) => { + const attrName = change.attribute.attribute; + const attrValue = change.value.value; + + attributes[attrName] = Array.isArray(attrValue) ? attrValue : [attrValue]; + }); + + responseDn = await new Promise((resolve, reject) => { + client.add(dn, attributes, (err) => { + if (err) { + reject(new BadRequestError({ message: err.message })); + } else { + resolve(dn); + } + }); + }); + } else if (entry.type === "modify") { + const changes: ldapjs.Change[] = []; + + entry.changes.forEach((change) => { + changes.push( + new ldapjs.Change({ + operation: change.operation || "replace", + modification: { + type: change.attribute.attribute, + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + values: change.values.map((value) => value.value) + } + }) + ); + }); + + responseDn = await new Promise((resolve, reject) => { + client.modify(dn, changes, (err) => { + if (err) { + reject(new BadRequestError({ message: err.message })); + } else { + resolve(dn); + } + }); + }); + } else if (entry.type === "delete") { + responseDn = await new Promise((resolve, reject) => { + client.del(dn, (err) => { + if (err) { + reject(new BadRequestError({ message: err.message })); + } else { + resolve(dn); + } + }); + }); + } else { + client.unbind(); + throw new BadRequestError({ message: `Unsupported operation type ${entry.type}` }); + } + + dnArray.push(responseDn); + } + client.unbind(); + return dnArray; + }; + + const create = async (inputs: unknown) => { + const providerInputs = await validateProviderInputs(inputs); + const client = await getClient(providerInputs); + + const username = generateUsername(); + const password = generatePassword(); + const generatedLdif = generateLDIF({ username, password, ldifTemplate: providerInputs.creationLdif }); + + try { + const dnArray = await executeLdif(client, generatedLdif); + + return { entityId: username, data: { DN_ARRAY: dnArray, USERNAME: username, PASSWORD: password } }; + } catch (err) { + if (providerInputs.rollbackLdif) { + const rollbackLdif = generateLDIF({ username, password, ldifTemplate: providerInputs.rollbackLdif }); + await executeLdif(client, rollbackLdif); + } + throw new BadRequestError({ message: (err as Error).message }); + } + }; + + const revoke = async (inputs: unknown, entityId: string) => { + const providerInputs = await validateProviderInputs(inputs); + const connection = await getClient(providerInputs); + const revocationLdif = generateLDIF({ username: entityId, ldifTemplate: providerInputs.revocationLdif }); + + await executeLdif(connection, revocationLdif); + + return { entityId }; + }; + + const renew = async (inputs: unknown, entityId: string) => { + // Do nothing + return { entityId }; + }; + + return { + validateProviderInputs, + validateConnection, + create, + revoke, + renew + }; +}; diff --git a/backend/src/ee/services/dynamic-secret/providers/models.ts b/backend/src/ee/services/dynamic-secret/providers/models.ts index 18a7b3dc9..c204333ce 100644 --- a/backend/src/ee/services/dynamic-secret/providers/models.ts +++ b/backend/src/ee/services/dynamic-secret/providers/models.ts @@ -174,6 +174,17 @@ export const AzureEntraIDSchema = z.object({ clientSecret: z.string().trim().min(1) }); +export const LdapSchema = z.object({ + url: z.string().trim().min(1), + binddn: z.string().trim().min(1), + bindpass: z.string().trim().min(1), + ca: z.string().optional(), + + creationLdif: z.string().min(1), + revocationLdif: z.string().min(1), + rollbackLdif: z.string().optional() +}); + export enum DynamicSecretProviders { SqlDatabase = "sql-database", Cassandra = "cassandra", @@ -184,7 +195,8 @@ export enum DynamicSecretProviders { ElasticSearch = "elastic-search", MongoDB = "mongo-db", RabbitMq = "rabbit-mq", - AzureEntraID = "azure-entra-id" + AzureEntraID = "azure-entra-id", + Ldap = "ldap" } export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [ @@ -197,7 +209,8 @@ export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal(DynamicSecretProviders.ElasticSearch), inputs: DynamicSecretElasticSearchSchema }), z.object({ type: z.literal(DynamicSecretProviders.MongoDB), inputs: DynamicSecretMongoDBSchema }), z.object({ type: z.literal(DynamicSecretProviders.RabbitMq), inputs: DynamicSecretRabbitMqSchema }), - z.object({ type: z.literal(DynamicSecretProviders.AzureEntraID), inputs: AzureEntraIDSchema }) + z.object({ type: z.literal(DynamicSecretProviders.AzureEntraID), inputs: AzureEntraIDSchema }), + z.object({ type: z.literal(DynamicSecretProviders.Ldap), inputs: LdapSchema }) ]); export type TDynamicProviderFns = { diff --git a/backend/src/ee/services/external-kms/external-kms-dal.ts b/backend/src/ee/services/external-kms/external-kms-dal.ts index 7077b4aa9..595ccb810 100644 --- a/backend/src/ee/services/external-kms/external-kms-dal.ts +++ b/backend/src/ee/services/external-kms/external-kms-dal.ts @@ -30,7 +30,7 @@ export const externalKmsDALFactory = (db: TDbClient) => { isDisabled: el.isDisabled, isReserved: el.isReserved, orgId: el.orgId, - slug: el.slug, + name: el.name, createdAt: el.createdAt, updatedAt: el.updatedAt, externalKms: { 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 49191b13a..9300b42d8 100644 --- a/backend/src/ee/services/external-kms/external-kms-service.ts +++ b/backend/src/ee/services/external-kms/external-kms-service.ts @@ -43,7 +43,7 @@ export const externalKmsServiceFactory = ({ provider, description, actor, - slug, + name, actorId, actorOrgId, actorAuthMethod @@ -64,7 +64,7 @@ export const externalKmsServiceFactory = ({ }); } - const kmsSlug = slug ? slugify(slug) : slugify(alphaNumericNanoId(8).toLowerCase()); + const kmsName = name ? slugify(name) : slugify(alphaNumericNanoId(8).toLowerCase()); let sanitizedProviderInput = ""; switch (provider.type) { @@ -96,7 +96,7 @@ export const externalKmsServiceFactory = ({ { isReserved: false, description, - slug: kmsSlug, + name: kmsName, orgId: actorOrgId }, tx @@ -120,7 +120,7 @@ export const externalKmsServiceFactory = ({ description, actor, id: kmsId, - slug, + name, actorId, actorOrgId, actorAuthMethod @@ -142,7 +142,7 @@ export const externalKmsServiceFactory = ({ }); } - const kmsSlug = slug ? slugify(slug) : undefined; + const kmsName = name ? slugify(name) : undefined; const externalKmsDoc = await externalKmsDAL.findOne({ kmsKeyId: kmsDoc.id }); if (!externalKmsDoc) throw new NotFoundError({ message: "External kms not found" }); @@ -188,7 +188,7 @@ export const externalKmsServiceFactory = ({ kmsDoc.id, { description, - slug: kmsSlug + name: kmsName }, tx ); @@ -280,14 +280,14 @@ export const externalKmsServiceFactory = ({ } }; - const findBySlug = async ({ + const findByName = async ({ actor, actorId, actorOrgId, actorAuthMethod, - slug: kmsSlug + name: kmsName }: TGetExternalKmsBySlugDTO) => { - const kmsDoc = await kmsDAL.findOne({ slug: kmsSlug, orgId: actorOrgId }); + const kmsDoc = await kmsDAL.findOne({ name: kmsName, orgId: actorOrgId }); const { permission } = await permissionService.getOrgPermission( actor, actorId, @@ -327,6 +327,6 @@ export const externalKmsServiceFactory = ({ deleteById, list, findById, - findBySlug + findByName }; }; diff --git a/backend/src/ee/services/external-kms/external-kms-types.ts b/backend/src/ee/services/external-kms/external-kms-types.ts index 6254a80ef..850108ac4 100644 --- a/backend/src/ee/services/external-kms/external-kms-types.ts +++ b/backend/src/ee/services/external-kms/external-kms-types.ts @@ -3,14 +3,14 @@ import { TOrgPermission } from "@app/lib/types"; import { TExternalKmsInputSchema, TExternalKmsInputUpdateSchema } from "./providers/model"; export type TCreateExternalKmsDTO = { - slug?: string; + name?: string; description?: string; provider: TExternalKmsInputSchema; } & Omit; export type TUpdateExternalKmsDTO = { id: string; - slug?: string; + name?: string; description?: string; provider?: TExternalKmsInputUpdateSchema; } & Omit; @@ -26,5 +26,5 @@ export type TGetExternalKmsByIdDTO = { } & Omit; export type TGetExternalKmsBySlugDTO = { - slug: string; + name: string; } & Omit; diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index 7c9e75ded..b2b34e488 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -14,6 +14,15 @@ export enum ProjectPermissionActions { Delete = "delete" } +export enum ProjectPermissionCmekActions { + Read = "read", + Create = "create", + Edit = "edit", + Delete = "delete", + Encrypt = "encrypt", + Decrypt = "decrypt" +} + export enum ProjectPermissionSub { Role = "role", Member = "member", @@ -38,7 +47,8 @@ export enum ProjectPermissionSub { CertificateTemplates = "certificate-templates", PkiAlerts = "pki-alerts", PkiCollections = "pki-collections", - Kms = "kms" + Kms = "kms", + Cmek = "cmek" } export type SecretSubjectFields = { @@ -95,6 +105,7 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.CertificateTemplates] | [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts] | [ProjectPermissionActions, ProjectPermissionSub.PkiCollections] + | [ProjectPermissionCmekActions, ProjectPermissionSub.Cmek] | [ProjectPermissionActions.Delete, ProjectPermissionSub.Project] | [ProjectPermissionActions.Edit, ProjectPermissionSub.Project] | [ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback] @@ -282,6 +293,12 @@ export const ProjectPermissionSchema = z.discriminatedUnion("subject", [ action: CASL_ACTION_SCHEMA_ENUM([ProjectPermissionActions.Read]).describe( "Describe what action an entity can take." ) + }), + z.object({ + subject: z.literal(ProjectPermissionSub.Cmek).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionCmekActions).describe( + "Describe what action an entity can take." + ) }) ]); @@ -325,6 +342,17 @@ const buildAdminPermissionRules = () => { can([ProjectPermissionActions.Edit, ProjectPermissionActions.Delete], ProjectPermissionSub.Project); can([ProjectPermissionActions.Read, ProjectPermissionActions.Create], ProjectPermissionSub.SecretRollback); can([ProjectPermissionActions.Edit], ProjectPermissionSub.Kms); + can( + [ + ProjectPermissionCmekActions.Create, + ProjectPermissionCmekActions.Edit, + ProjectPermissionCmekActions.Delete, + ProjectPermissionCmekActions.Read, + ProjectPermissionCmekActions.Encrypt, + ProjectPermissionCmekActions.Decrypt + ], + ProjectPermissionSub.Cmek + ); return rules; }; @@ -444,6 +472,18 @@ const buildMemberPermissionRules = () => { can([ProjectPermissionActions.Read], ProjectPermissionSub.PkiAlerts); can([ProjectPermissionActions.Read], ProjectPermissionSub.PkiCollections); + can( + [ + ProjectPermissionCmekActions.Create, + ProjectPermissionCmekActions.Edit, + ProjectPermissionCmekActions.Delete, + ProjectPermissionCmekActions.Read, + ProjectPermissionCmekActions.Encrypt, + ProjectPermissionCmekActions.Decrypt + ], + ProjectPermissionSub.Cmek + ); + return rules; }; @@ -470,6 +510,7 @@ const buildViewerPermissionRules = () => { can(ProjectPermissionActions.Read, ProjectPermissionSub.IpAllowList); can(ProjectPermissionActions.Read, ProjectPermissionSub.CertificateAuthorities); can(ProjectPermissionActions.Read, ProjectPermissionSub.Certificates); + can(ProjectPermissionCmekActions.Read, ProjectPermissionSub.Cmek); return rules; }; diff --git a/backend/src/keystore/keystore.ts b/backend/src/keystore/keystore.ts index 3b19012e7..5b2c7fba9 100644 --- a/backend/src/keystore/keystore.ts +++ b/backend/src/keystore/keystore.ts @@ -16,6 +16,9 @@ export const KeyStorePrefixes = { WaitUntilReadyKmsOrgKeyCreation: "wait-until-ready-kms-org-key-creation-", WaitUntilReadyKmsOrgDataKeyCreation: "wait-until-ready-kms-org-data-key-creation-", + WaitUntilReadyProjectEnvironmentOperation: (projectId: string) => + `wait-until-ready-project-environments-operation-${projectId}`, + ProjectEnvironmentLock: (projectId: string) => `project-environment-lock-${projectId}` as const, SyncSecretIntegrationLock: (projectId: string, environmentSlug: string, secretPath: string) => `sync-integration-mutex-${projectId}-${environmentSlug}-${secretPath}` as const, SyncSecretIntegrationLastRunTimestamp: (projectId: string, environmentSlug: string, secretPath: string) => diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 75c3dc32a..d15b4fd86 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -1347,3 +1347,37 @@ export const PROJECT_ROLE = { projectSlug: "The slug of the project to list the roles of." } }; + +export const KMS = { + CREATE_KEY: { + 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." + }, + UPDATE_KEY: { + keyId: "The ID of the key to be updated.", + name: "The updated name of this key. Must be slug-friendly.", + description: "The updated description of this key.", + isDisabled: "The flag to enable or disable this key." + }, + DELETE_KEY: { + keyId: "The ID of the key to be deleted." + }, + LIST_KEYS: { + projectId: "The ID of the project to list keys from.", + offset: "The offset to start from. If you enter 10, it will start from the 10th key.", + limit: "The number of keys to return.", + orderBy: "The column to order keys by.", + orderDirection: "The direction to order keys in.", + search: "The text string to filter key names by." + }, + ENCRYPT: { + keyId: "The ID of the key to encrypt the data with.", + plaintext: "The plaintext to be encrypted (base64 encoded)." + }, + DECRYPT: { + keyId: "The ID of the key to decrypt the data with.", + ciphertext: "The ciphertext to be decrypted (base64 encoded)." + } +}; diff --git a/backend/src/lib/base64/index.ts b/backend/src/lib/base64/index.ts new file mode 100644 index 000000000..cfc0fde3f --- /dev/null +++ b/backend/src/lib/base64/index.ts @@ -0,0 +1,28 @@ +// Credit: https://github.com/miguelmota/is-base64 +export const isBase64 = ( + v: string, + opts = { allowEmpty: false, mimeRequired: false, allowMime: true, paddingRequired: false } +) => { + if (opts.allowEmpty === false && v === "") { + return false; + } + + let regex = "(?:[A-Za-z0-9+\\/]{4})*(?:[A-Za-z0-9+\\/]{2}==|[A-Za-z0-9+/]{3}=)?"; + const mimeRegex = "(data:\\w+\\/[a-zA-Z\\+\\-\\.]+;base64,)"; + + if (opts.mimeRequired === true) { + regex = mimeRegex + regex; + } else if (opts.allowMime === true) { + regex = `${mimeRegex}?${regex}`; + } + + if (opts.paddingRequired === false) { + regex = "(?:[A-Za-z0-9+\\/]{4})*(?:[A-Za-z0-9+\\/]{2}(==)?|[A-Za-z0-9+\\/]{3}=?)?"; + } + + return new RegExp(`^${regex}$`, "gi").test(v); +}; + +export const getBase64SizeInBytes = (base64String: string) => { + return Buffer.from(base64String, "base64").length; +}; diff --git a/backend/src/lib/casl/index.ts b/backend/src/lib/casl/index.ts index 9e5cb29d3..71625e181 100644 --- a/backend/src/lib/casl/index.ts +++ b/backend/src/lib/casl/index.ts @@ -23,8 +23,19 @@ export const conditionsMatcher = buildMongoQueryMatcher({ $glob }, { glob }); /** * Extracts and formats permissions from a CASL Ability object or a raw permission set. */ -const extractPermissions = (ability: MongoAbility) => - ability.rules.map((permission) => `${permission.action as string}_${permission.subject as string}`); +const extractPermissions = (ability: MongoAbility) => { + const permissions: string[] = []; + ability.rules.forEach((permission) => { + if (typeof permission.action === "string") { + permissions.push(`${permission.action}_${permission.subject as string}`); + } else { + permission.action.forEach((permissionAction) => { + permissions.push(`${permissionAction}_${permission.subject as string}`); + }); + } + }); + return permissions; +}; /** * Compares two sets of permissions to determine if the first set is at least as privileged as the second set. diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 30c56faa4..22eddad9e 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -96,6 +96,7 @@ import { certificateAuthorityServiceFactory } from "@app/services/certificate-au import { certificateTemplateDALFactory } from "@app/services/certificate-template/certificate-template-dal"; import { certificateTemplateEstConfigDALFactory } from "@app/services/certificate-template/certificate-template-est-config-dal"; import { certificateTemplateServiceFactory } from "@app/services/certificate-template/certificate-template-service"; +import { cmekServiceFactory } from "@app/services/cmek/cmek-service"; import { externalMigrationServiceFactory } from "@app/services/external-migration/external-migration-service"; import { groupProjectDALFactory } from "@app/services/group-project/group-project-dal"; import { groupProjectMembershipRoleDALFactory } from "@app/services/group-project/group-project-membership-role-dal"; @@ -749,6 +750,7 @@ export const registerRoutes = async ( const projectEnvService = projectEnvServiceFactory({ permissionService, projectEnvDAL, + keyStore, licenseService, projectDAL, folderDAL @@ -924,7 +926,8 @@ export const registerRoutes = async ( const secretSharingService = secretSharingServiceFactory({ permissionService, secretSharingDAL, - orgDAL + orgDAL, + kmsService }); const accessApprovalPolicyService = accessApprovalPolicyServiceFactory({ @@ -1193,6 +1196,12 @@ export const registerRoutes = async ( workflowIntegrationDAL }); + const cmekService = cmekServiceFactory({ + kmsDAL, + kmsService, + permissionService + }); + const migrationService = externalMigrationServiceFactory({ projectService, orgService, @@ -1282,6 +1291,7 @@ export const registerRoutes = async ( secretSharing: secretSharingService, userEngagement: userEngagementService, externalKms: externalKmsService, + cmek: cmekService, orgAdmin: orgAdminService, slack: slackService, workflowIntegration: workflowIntegrationService, diff --git a/backend/src/server/routes/v1/cmek-router.ts b/backend/src/server/routes/v1/cmek-router.ts new file mode 100644 index 000000000..b07d7b490 --- /dev/null +++ b/backend/src/server/routes/v1/cmek-router.ts @@ -0,0 +1,331 @@ +import slugify from "@sindresorhus/slugify"; +import { z } from "zod"; + +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 { OrderByDirection } from "@app/lib/types"; +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"; +import { CmekOrderBy } from "@app/services/cmek/cmek-types"; + +const keyNameSchema = z + .string() + .trim() + .min(1) + .max(32) + .toLowerCase() + .refine((v) => slugify(v) === v, { + message: "Name must be slug friendly" + }); +const keyDescriptionSchema = z.string().trim().max(500).optional(); + +const base64Schema = z.string().superRefine((val, ctx) => { + if (!isBase64(val)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "plaintext must be base64 encoded" + }); + } + + if (getBase64SizeInBytes(val) > 4096) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "data cannot exceed 4096 bytes" + }); + } +}); + +export const registerCmekRouter = async (server: FastifyZodProvider) => { + // create encryption key + server.route({ + method: "POST", + url: "/keys", + config: { + rateLimit: writeLimit + }, + 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 + }), + response: { + 200: z.object({ + key: KmsKeysSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { + body: { projectId, name, description, encryptionAlgorithm }, + permission + } = req; + + const cmek = await server.services.cmek.createCmek( + { orgId: permission.orgId, projectId, name, description, encryptionAlgorithm }, + permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.CREATE_CMEK, + metadata: { + keyId: cmek.id, + name, + description, + encryptionAlgorithm + } + } + }); + + return { key: cmek }; + } + }); + + // update KMS key + server.route({ + method: "PATCH", + url: "/keys/:keyId", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Update KMS key", + params: z.object({ + keyId: z.string().uuid().describe(KMS.UPDATE_KEY.keyId) + }), + body: z.object({ + name: keyNameSchema.optional().describe(KMS.UPDATE_KEY.name), + isDisabled: z.boolean().optional().describe(KMS.UPDATE_KEY.isDisabled), + description: keyDescriptionSchema.describe(KMS.UPDATE_KEY.description) + }), + response: { + 200: z.object({ + key: KmsKeysSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { + params: { keyId }, + body, + permission + } = req; + + const cmek = await server.services.cmek.updateCmekById({ keyId, ...body }, permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: permission.orgId, + event: { + type: EventType.UPDATE_CMEK, + metadata: { + keyId, + ...body + } + } + }); + + return { key: cmek }; + } + }); + + // delete KMS key + server.route({ + method: "DELETE", + url: "/keys/:keyId", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Delete KMS key", + params: z.object({ + keyId: z.string().uuid().describe(KMS.DELETE_KEY.keyId) + }), + response: { + 200: z.object({ + key: KmsKeysSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { + params: { keyId }, + permission + } = req; + + const cmek = await server.services.cmek.deleteCmekById(keyId, permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: permission.orgId, + event: { + type: EventType.DELETE_CMEK, + metadata: { + keyId + } + } + }); + + return { key: cmek }; + } + }); + + // list KMS keys + server.route({ + method: "GET", + url: "/keys", + config: { + rateLimit: readLimit + }, + schema: { + description: "List KMS keys", + querystring: z.object({ + projectId: z.string().describe(KMS.LIST_KEYS.projectId), + offset: z.coerce.number().min(0).optional().default(0).describe(KMS.LIST_KEYS.offset), + limit: z.coerce.number().min(1).max(100).optional().default(100).describe(KMS.LIST_KEYS.limit), + orderBy: z.nativeEnum(CmekOrderBy).optional().default(CmekOrderBy.Name).describe(KMS.LIST_KEYS.orderBy), + orderDirection: z + .nativeEnum(OrderByDirection) + .optional() + .default(OrderByDirection.ASC) + .describe(KMS.LIST_KEYS.orderDirection), + search: z.string().trim().optional().describe(KMS.LIST_KEYS.search) + }), + response: { + 200: z.object({ + keys: KmsKeysSchema.merge(InternalKmsSchema.pick({ version: true, encryptionAlgorithm: true })).array(), + totalCount: z.number() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { + query: { projectId, ...dto }, + permission + } = req; + + const { cmeks, totalCount } = await server.services.cmek.listCmeksByProjectId({ projectId, ...dto }, permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.GET_CMEKS, + metadata: { + keyIds: cmeks.map((key) => key.id) + } + } + }); + + return { keys: cmeks, totalCount }; + } + }); + + // encrypt data + server.route({ + method: "POST", + url: "/keys/:keyId/encrypt", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Encrypt data with KMS key", + params: z.object({ + keyId: z.string().uuid().describe(KMS.ENCRYPT.keyId) + }), + body: z.object({ + plaintext: base64Schema.describe(KMS.ENCRYPT.plaintext) + }), + response: { + 200: z.object({ + ciphertext: z.string() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { + params: { keyId }, + body: { plaintext }, + permission + } = req; + + const ciphertext = await server.services.cmek.cmekEncrypt({ keyId, plaintext }, permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: permission.orgId, + event: { + type: EventType.CMEK_ENCRYPT, + metadata: { + keyId + } + } + }); + + return { ciphertext }; + } + }); + + server.route({ + method: "POST", + url: "/keys/:keyId/decrypt", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Decrypt data with KMS key", + params: z.object({ + keyId: z.string().uuid().describe(KMS.ENCRYPT.keyId) + }), + body: z.object({ + ciphertext: base64Schema.describe(KMS.ENCRYPT.plaintext) + }), + response: { + 200: z.object({ + plaintext: z.string() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { + params: { keyId }, + body: { ciphertext }, + permission + } = req; + + const plaintext = await server.services.cmek.cmekDecrypt({ keyId, ciphertext }, permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: permission.orgId, + event: { + type: EventType.CMEK_DECRYPT, + metadata: { + keyId + } + } + }); + + return { plaintext }; + } + }); +}; diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index d46b361b3..55b323656 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -1,3 +1,4 @@ +import { registerCmekRouter } from "@app/server/routes/v1/cmek-router"; import { registerDashboardRouter } from "@app/server/routes/v1/dashboard-router"; import { registerAdminRouter } from "./admin-router"; @@ -103,6 +104,6 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { await server.register(registerIdentityRouter, { prefix: "/identities" }); await server.register(registerSecretSharingRouter, { prefix: "/secret-sharing" }); await server.register(registerUserEngagementRouter, { prefix: "/user-engagement" }); - await server.register(registerDashboardRouter, { prefix: "/dashboard" }); + await server.register(registerCmekRouter, { prefix: "/kms" }); }; diff --git a/backend/src/server/routes/v1/integration-router.ts b/backend/src/server/routes/v1/integration-router.ts index 202b5cf8c..2a09da526 100644 --- a/backend/src/server/routes/v1/integration-router.ts +++ b/backend/src/server/routes/v1/integration-router.ts @@ -131,9 +131,9 @@ export const registerIntegrationRouter = async (server: FastifyZodProvider) => { .default("/") .transform(removeTrailingSlash) .describe(INTEGRATION.UPDATE.secretPath), - targetEnvironment: z.string().trim().describe(INTEGRATION.UPDATE.targetEnvironment), - owner: z.string().trim().describe(INTEGRATION.UPDATE.owner), - environment: z.string().trim().describe(INTEGRATION.UPDATE.environment), + targetEnvironment: z.string().trim().optional().describe(INTEGRATION.UPDATE.targetEnvironment), + owner: z.string().trim().optional().describe(INTEGRATION.UPDATE.owner), + environment: z.string().trim().optional().describe(INTEGRATION.UPDATE.environment), metadata: IntegrationMetadataSchema.optional() }), response: { diff --git a/backend/src/server/routes/v1/secret-sharing-router.ts b/backend/src/server/routes/v1/secret-sharing-router.ts index 7a909cae4..3363cc6c0 100644 --- a/backend/src/server/routes/v1/secret-sharing-router.ts +++ b/backend/src/server/routes/v1/secret-sharing-router.ts @@ -55,10 +55,10 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) => }, schema: { params: z.object({ - id: z.string().uuid() + id: z.string() }), body: z.object({ - hashedHex: z.string().min(1), + hashedHex: z.string().min(1).optional(), password: z.string().optional() }), response: { @@ -73,7 +73,8 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) => accessType: true }) .extend({ - orgName: z.string().optional() + orgName: z.string().optional(), + secretValue: z.string().optional() }) .optional() }) @@ -99,17 +100,14 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) => }, schema: { body: z.object({ - encryptedValue: z.string(), + secretValue: z.string().max(10_000), password: z.string().optional(), - hashedHex: z.string(), - iv: z.string(), - tag: z.string(), expiresAt: z.string(), expiresAfterViews: z.number().min(1).optional() }), response: { 200: z.object({ - id: z.string().uuid() + id: z.string() }) } }, @@ -132,17 +130,14 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) => body: z.object({ name: z.string().max(50).optional(), password: z.string().optional(), - encryptedValue: z.string(), - hashedHex: z.string(), - iv: z.string(), - tag: z.string(), + secretValue: z.string(), expiresAt: z.string(), expiresAfterViews: z.number().min(1).optional(), accessType: z.nativeEnum(SecretSharingAccessType).default(SecretSharingAccessType.Organization) }), response: { 200: z.object({ - id: z.string().uuid() + id: z.string() }) } }, @@ -168,7 +163,7 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) => }, schema: { params: z.object({ - sharedSecretId: z.string().uuid() + sharedSecretId: z.string() }), response: { 200: SecretSharingSchema diff --git a/backend/src/services/cmek/cmek-service.ts b/backend/src/services/cmek/cmek-service.ts new file mode 100644 index 000000000..1f9c19dc9 --- /dev/null +++ b/backend/src/services/cmek/cmek-service.ts @@ -0,0 +1,169 @@ +import { ForbiddenError } from "@casl/ability"; +import { FastifyRequest } from "fastify"; + +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { ProjectPermissionCmekActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { + TCmekDecryptDTO, + TCmekEncryptDTO, + TCreateCmekDTO, + TListCmeksByProjectIdDTO, + TUpdabteCmekByIdDTO +} from "@app/services/cmek/cmek-types"; +import { TKmsKeyDALFactory } from "@app/services/kms/kms-key-dal"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; + +type TCmekServiceFactoryDep = { + kmsService: TKmsServiceFactory; + kmsDAL: TKmsKeyDALFactory; + permissionService: TPermissionServiceFactory; +}; + +export type TCmekServiceFactory = ReturnType; + +export const cmekServiceFactory = ({ kmsService, kmsDAL, permissionService }: TCmekServiceFactoryDep) => { + const createCmek = async ({ projectId, ...dto }: TCreateCmekDTO, actor: FastifyRequest["permission"]) => { + const { permission } = await permissionService.getProjectPermission( + actor.type, + actor.id, + projectId, + actor.authMethod, + actor.orgId + ); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionCmekActions.Create, ProjectPermissionSub.Cmek); + + const cmek = await kmsService.generateKmsKey({ + ...dto, + projectId, + isReserved: false + }); + + return cmek; + }; + + const updateCmekById = async ({ keyId, ...data }: TUpdabteCmekByIdDTO, actor: FastifyRequest["permission"]) => { + const key = await kmsDAL.findById(keyId); + + if (!key) throw new NotFoundError({ message: "Key not found" }); + + if (!key.projectId || key.isReserved) throw new BadRequestError({ message: "Key is not customer managed" }); + + const { permission } = await permissionService.getProjectPermission( + actor.type, + actor.id, + key.projectId, + actor.authMethod, + actor.orgId + ); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionCmekActions.Edit, ProjectPermissionSub.Cmek); + + const cmek = await kmsDAL.updateById(keyId, data); + + return cmek; + }; + + const deleteCmekById = async (keyId: string, actor: FastifyRequest["permission"]) => { + const key = await kmsDAL.findById(keyId); + + if (!key) throw new NotFoundError({ message: "Key not found" }); + + if (!key.projectId || key.isReserved) throw new BadRequestError({ message: "Key is not customer managed" }); + + const { permission } = await permissionService.getProjectPermission( + actor.type, + actor.id, + key.projectId, + actor.authMethod, + actor.orgId + ); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionCmekActions.Delete, ProjectPermissionSub.Cmek); + + const cmek = kmsDAL.deleteById(keyId); + + return cmek; + }; + + const listCmeksByProjectId = async ( + { projectId, ...filters }: TListCmeksByProjectIdDTO, + actor: FastifyRequest["permission"] + ) => { + const { permission } = await permissionService.getProjectPermission( + actor.type, + actor.id, + projectId, + actor.authMethod, + actor.orgId + ); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionCmekActions.Read, ProjectPermissionSub.Cmek); + + const { keys: cmeks, totalCount } = await kmsDAL.findKmsKeysByProjectId({ projectId, ...filters }); + + return { cmeks, totalCount }; + }; + + const cmekEncrypt = async ({ keyId, plaintext }: TCmekEncryptDTO, actor: FastifyRequest["permission"]) => { + const key = await kmsDAL.findById(keyId); + + if (!key) throw new NotFoundError({ message: "Key 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.type, + actor.id, + key.projectId, + actor.authMethod, + actor.orgId + ); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionCmekActions.Encrypt, ProjectPermissionSub.Cmek); + + const encrypt = await kmsService.encryptWithKmsKey({ kmsId: keyId }); + + const { cipherTextBlob } = await encrypt({ plainText: Buffer.from(plaintext, "base64") }); + + return cipherTextBlob.toString("base64"); + }; + + const cmekDecrypt = async ({ keyId, ciphertext }: TCmekDecryptDTO, actor: FastifyRequest["permission"]) => { + const key = await kmsDAL.findById(keyId); + + if (!key) throw new NotFoundError({ message: "Key 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.type, + actor.id, + key.projectId, + actor.authMethod, + actor.orgId + ); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionCmekActions.Decrypt, ProjectPermissionSub.Cmek); + + const decrypt = await kmsService.decryptWithKmsKey({ kmsId: keyId }); + + const plaintextBlob = await decrypt({ cipherTextBlob: Buffer.from(ciphertext, "base64") }); + + return plaintextBlob.toString("base64"); + }; + + return { + createCmek, + updateCmekById, + deleteCmekById, + listCmeksByProjectId, + cmekEncrypt, + cmekDecrypt + }; +}; diff --git a/backend/src/services/cmek/cmek-types.ts b/backend/src/services/cmek/cmek-types.ts new file mode 100644 index 000000000..b99ff1d6e --- /dev/null +++ b/backend/src/services/cmek/cmek-types.ts @@ -0,0 +1,40 @@ +import { SymmetricEncryption } from "@app/lib/crypto/cipher"; +import { OrderByDirection } from "@app/lib/types"; + +export type TCreateCmekDTO = { + orgId: string; + projectId: string; + name: string; + description?: string; + encryptionAlgorithm: SymmetricEncryption; +}; + +export type TUpdabteCmekByIdDTO = { + keyId: string; + name?: string; + isDisabled?: boolean; + description?: string; +}; + +export type TListCmeksByProjectIdDTO = { + projectId: string; + offset?: number; + limit?: number; + orderBy?: CmekOrderBy; + orderDirection?: OrderByDirection; + search?: string; +}; + +export type TCmekEncryptDTO = { + keyId: string; + plaintext: string; +}; + +export type TCmekDecryptDTO = { + keyId: string; + ciphertext: string; +}; + +export enum CmekOrderBy { + Name = "name" +} diff --git a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts index de7b7fd04..83a02d079 100644 --- a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts +++ b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts @@ -1,5 +1,5 @@ import { ForbiddenError } from "@casl/ability"; -import axios from "axios"; +import axios, { AxiosError } from "axios"; import https from "https"; import jwt from "jsonwebtoken"; @@ -107,32 +107,54 @@ export const identityKubernetesAuthServiceFactory = ({ }); } - const { data }: { data: TCreateTokenReviewResponse } = await axios.post( - `${identityKubernetesAuth.kubernetesHost}/apis/authentication.k8s.io/v1/tokenreviews`, - { - apiVersion: "authentication.k8s.io/v1", - kind: "TokenReview", - spec: { - token: serviceAccountJwt - } - }, - { - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${tokenReviewerJwt}` + const { data } = await axios + .post( + `${identityKubernetesAuth.kubernetesHost}/apis/authentication.k8s.io/v1/tokenreviews`, + { + apiVersion: "authentication.k8s.io/v1", + kind: "TokenReview", + spec: { + token: serviceAccountJwt + } }, - httpsAgent: new https.Agent({ - ca: caCert, - rejectUnauthorized: !!caCert - }) - } - ); + { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${tokenReviewerJwt}` + }, - if ("error" in data.status) throw new UnauthorizedError({ message: data.status.error }); + // if ca cert, rejectUnauthorized: true + httpsAgent: new https.Agent({ + ca: caCert, + rejectUnauthorized: !!caCert + }) + } + ) + .catch((err) => { + if (err instanceof AxiosError) { + if (err.response) { + const { message } = err?.response?.data as unknown as { message?: string }; + + if (message) { + throw new UnauthorizedError({ + message, + name: "KubernetesTokenReviewRequestError" + }); + } + } + } + throw err; + }); + + if ("error" in data.status) + throw new UnauthorizedError({ message: data.status.error, name: "KubernetesTokenReviewError" }); // check the response to determine if the token is valid if (!(data.status && data.status.authenticated)) - throw new UnauthorizedError({ message: "Kubernetes token not authenticated" }); + throw new UnauthorizedError({ + message: "Kubernetes token not authenticated", + name: "KubernetesTokenReviewError" + }); const { namespace: targetNamespace, name: targetName } = extractK8sUsername(data.status.user.username); diff --git a/backend/src/services/identity/identity-org-dal.ts b/backend/src/services/identity/identity-org-dal.ts index 0422a4b5d..1bea4c9f4 100644 --- a/backend/src/services/identity/identity-org-dal.ts +++ b/backend/src/services/identity/identity-org-dal.ts @@ -5,7 +5,7 @@ import { TableName, TIdentityOrgMemberships } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; import { OrderByDirection } from "@app/lib/types"; -import { TListOrgIdentitiesByOrgIdDTO } from "@app/services/identity/identity-types"; +import { OrgIdentityOrderBy, TListOrgIdentitiesByOrgIdDTO } from "@app/services/identity/identity-types"; export type TIdentityOrgDALFactory = ReturnType; @@ -33,7 +33,7 @@ export const identityOrgDALFactory = (db: TDbClient) => { { limit, offset = 0, - orderBy, + orderBy = OrgIdentityOrderBy.Name, orderDirection = OrderByDirection.ASC, search, ...filter @@ -43,12 +43,16 @@ export const identityOrgDALFactory = (db: TDbClient) => { ) => { try { const paginatedFetchIdentity = (tx || db.replicaNode())(TableName.Identity) - .where((queryBuilder) => { - if (limit) { - void queryBuilder.offset(offset).limit(limit); - } - }) - .as(TableName.Identity); + .as(TableName.Identity) + .orderBy(`${TableName.Identity}.${orderBy}`, orderDirection); + + if (search?.length) { + void paginatedFetchIdentity.whereILike(`${TableName.Identity}.name`, `%${search}%`); + } + + if (limit) { + void paginatedFetchIdentity.offset(offset).limit(limit); + } const query = (tx || db.replicaNode())(TableName.IdentityOrgMembership) .where(filter) @@ -78,24 +82,8 @@ export const identityOrgDALFactory = (db: TDbClient) => { db.ref("id").withSchema(TableName.IdentityMetadata).as("metadataId"), db.ref("key").withSchema(TableName.IdentityMetadata).as("metadataKey"), db.ref("value").withSchema(TableName.IdentityMetadata).as("metadataValue") - ); - - if (orderBy) { - switch (orderBy) { - case "name": - void query.orderBy(`${TableName.Identity}.${orderBy}`, orderDirection); - break; - case "role": - void query.orderBy(`${TableName.IdentityOrgMembership}.${orderBy}`, orderDirection); - break; - default: - // do nothing - } - } - - if (search?.length) { - void query.whereILike(`${TableName.Identity}.name`, `%${search}%`); - } + ) + .orderBy(`${TableName.Identity}.${orderBy}`, orderDirection); const docs = await query; const formattedDocs = sqlNestRelationships({ diff --git a/backend/src/services/identity/identity-types.ts b/backend/src/services/identity/identity-types.ts index 23110c3bd..ceaf3ecfc 100644 --- a/backend/src/services/identity/identity-types.ts +++ b/backend/src/services/identity/identity-types.ts @@ -41,6 +41,6 @@ export type TListOrgIdentitiesByOrgIdDTO = { } & TOrgPermission; export enum OrgIdentityOrderBy { - Name = "name", - Role = "role" + Name = "name" + // Role = "role" } diff --git a/backend/src/services/integration/integration-service.ts b/backend/src/services/integration/integration-service.ts index 5e77f4349..4f4b26e24 100644 --- a/backend/src/services/integration/integration-service.ts +++ b/backend/src/services/integration/integration-service.ts @@ -150,12 +150,17 @@ export const integrationServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Integrations); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { environment, secretPath }) - ); + const newEnvironment = environment || integration.environment.slug; + const newSecretPath = secretPath || integration.secretPath; - const folder = await folderDAL.findBySecretPath(integration.projectId, environment, secretPath); + if (environment || secretPath) { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { environment: newEnvironment, secretPath: newSecretPath }) + ); + } + + const folder = await folderDAL.findBySecretPath(integration.projectId, newEnvironment, newSecretPath); if (!folder) throw new NotFoundError({ message: "Folder path not found" }); const updatedIntegration = await integrationDAL.updateById(id, { @@ -174,7 +179,7 @@ export const integrationServiceFactory = ({ await secretQueueService.syncIntegrations({ environment: folder.environment.slug, - secretPath, + secretPath: newSecretPath, projectId: folder.projectId }); @@ -184,6 +189,12 @@ export const integrationServiceFactory = ({ const getIntegration = async ({ id, actor, actorAuthMethod, actorId, actorOrgId }: TGetIntegrationDTO) => { const integration = await integrationDAL.findById(id); + if (!integration) { + throw new NotFoundError({ + message: "Integration not found" + }); + } + const { permission } = await permissionService.getProjectPermission( actor, actorId, diff --git a/backend/src/services/integration/integration-types.ts b/backend/src/services/integration/integration-types.ts index 5c76159de..a27c4f6ac 100644 --- a/backend/src/services/integration/integration-types.ts +++ b/backend/src/services/integration/integration-types.ts @@ -48,10 +48,10 @@ export type TUpdateIntegrationDTO = { app?: string; appId?: string; isActive?: boolean; - secretPath: string; - targetEnvironment: string; - owner: string; - environment: string; + secretPath?: string; + targetEnvironment?: string; + owner?: string; + environment?: string; metadata?: { secretPrefix?: string; secretSuffix?: string; diff --git a/backend/src/services/kms/kms-fns.ts b/backend/src/services/kms/kms-fns.ts new file mode 100644 index 000000000..96196e1af --- /dev/null +++ b/backend/src/services/kms/kms-fns.ts @@ -0,0 +1,11 @@ +import { SymmetricEncryption } from "@app/lib/crypto/cipher"; + +export const getByteLengthForAlgorithm = (encryptionAlgorithm: SymmetricEncryption) => { + switch (encryptionAlgorithm) { + case SymmetricEncryption.AES_GCM_128: + return 16; + case SymmetricEncryption.AES_GCM_256: + default: + return 32; + } +}; diff --git a/backend/src/services/kms/kms-key-dal.ts b/backend/src/services/kms/kms-key-dal.ts index 004348722..e0246c096 100644 --- a/backend/src/services/kms/kms-key-dal.ts +++ b/backend/src/services/kms/kms-key-dal.ts @@ -1,9 +1,11 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; -import { KmsKeysSchema, TableName } from "@app/db/schemas"; +import { KmsKeysSchema, TableName, TInternalKms, TKmsKeys } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; import { ormify, selectAllTableCols } from "@app/lib/knex"; +import { OrderByDirection } from "@app/lib/types"; +import { CmekOrderBy, TListCmeksByProjectIdDTO } from "@app/services/cmek/cmek-types"; export type TKmsKeyDALFactory = ReturnType; @@ -71,5 +73,50 @@ export const kmskeyDALFactory = (db: TDbClient) => { } }; - return { ...kmsOrm, findByIdWithAssociatedKms }; + const findKmsKeysByProjectId = async ( + { + projectId, + offset = 0, + limit, + orderBy = CmekOrderBy.Name, + orderDirection = OrderByDirection.ASC, + search + }: TListCmeksByProjectIdDTO, + tx?: Knex + ) => { + try { + const query = (tx || db.replicaNode())(TableName.KmsKey) + .where("projectId", projectId) + .where((qb) => { + if (search) { + void qb.whereILike("name", `%${search}%`); + } + }) + .join(TableName.InternalKms, `${TableName.InternalKms}.kmsKeyId`, `${TableName.KmsKey}.id`) + .select< + (TKmsKeys & + Pick & { + total_count: number; + })[] + >( + selectAllTableCols(TableName.KmsKey), + db.raw(`count(*) OVER() as total_count`), + db.ref("encryptionAlgorithm").withSchema(TableName.InternalKms), + db.ref("version").withSchema(TableName.InternalKms) + ) + .orderBy(orderBy, orderDirection); + + if (limit) { + void query.limit(limit).offset(offset); + } + + const data = await query; + + return { keys: data, totalCount: Number(data?.[0]?.total_count ?? 0) }; + } catch (error) { + throw new DatabaseError({ error, name: "Find kms keys by project id" }); + } + }; + + return { ...kmsOrm, findByIdWithAssociatedKms, findKmsKeysByProjectId }; }; diff --git a/backend/src/services/kms/kms-service.ts b/backend/src/services/kms/kms-service.ts index ad06906df..50dc0bd08 100644 --- a/backend/src/services/kms/kms-service.ts +++ b/backend/src/services/kms/kms-service.ts @@ -17,6 +17,7 @@ import { generateHash } from "@app/lib/crypto/encryption"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { getByteLengthForAlgorithm } from "@app/services/kms/kms-fns"; import { TOrgDALFactory } from "../org/org-dal"; import { TProjectDALFactory } from "../project/project-dal"; @@ -71,17 +72,29 @@ export const kmsServiceFactory = ({ * This function is responsibile for generating the infisical internal KMS for various entities * Like for secret manager, cert manager or for organization */ - const generateKmsKey = async ({ orgId, isReserved = true, tx, slug }: TGenerateKMSDTO) => { + const generateKmsKey = async ({ + orgId, + isReserved = true, + tx, + name, + projectId, + encryptionAlgorithm = SymmetricEncryption.AES_GCM_256, + description + }: TGenerateKMSDTO) => { const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); - const kmsKeyMaterial = randomSecureBytes(32); + + const kmsKeyMaterial = randomSecureBytes(getByteLengthForAlgorithm(encryptionAlgorithm)); + const encryptedKeyMaterial = cipher.encrypt(kmsKeyMaterial, ROOT_ENCRYPTION_KEY); - const sanitizedSlug = slug ? slugify(slug) : slugify(alphaNumericNanoId(8).toLowerCase()); + const sanitizedName = name ? slugify(name) : slugify(alphaNumericNanoId(8).toLowerCase()); const dbQuery = async (db: Knex) => { const kmsDoc = await kmsDAL.create( { - slug: sanitizedSlug, + name: sanitizedName, orgId, - isReserved + isReserved, + projectId, + description }, db ); @@ -90,7 +103,7 @@ export const kmsServiceFactory = ({ { version: 1, encryptedKey: encryptedKeyMaterial, - encryptionAlgorithm: SymmetricEncryption.AES_GCM_256, + encryptionAlgorithm, kmsKeyId: kmsDoc.id }, db @@ -208,20 +221,20 @@ export const kmsServiceFactory = ({ return org.kmsDefaultKeyId; }; - const encryptWithRootKey = async () => { + const encryptWithRootKey = () => { const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); - return ({ plainText }: { plainText: Buffer }) => { - const encryptedPlainTextBlob = cipher.encrypt(plainText, ROOT_ENCRYPTION_KEY); - return Promise.resolve({ cipherTextBlob: encryptedPlainTextBlob }); + return (plainTextBuffer: Buffer) => { + const encryptedBuffer = cipher.encrypt(plainTextBuffer, ROOT_ENCRYPTION_KEY); + return encryptedBuffer; }; }; - const decryptWithRootKey = async () => { + const decryptWithRootKey = () => { const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); - return ({ cipherTextBlob }: { cipherTextBlob: Buffer }) => { - const decryptedBlob = cipher.decrypt(cipherTextBlob, ROOT_ENCRYPTION_KEY); - return Promise.resolve(decryptedBlob); + + return (cipherTextBuffer: Buffer) => { + return cipher.decrypt(cipherTextBuffer, ROOT_ENCRYPTION_KEY); }; }; @@ -286,12 +299,13 @@ export const kmsServiceFactory = ({ } // internal KMS - const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); - const kmsKey = cipher.decrypt(kmsDoc.internalKms?.encryptedKey as Buffer, ROOT_ENCRYPTION_KEY); + const keyCipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); + const dataCipher = symmetricCipherService(kmsDoc.internalKms?.encryptionAlgorithm as SymmetricEncryption); + const kmsKey = keyCipher.decrypt(kmsDoc.internalKms?.encryptedKey as Buffer, ROOT_ENCRYPTION_KEY); return ({ cipherTextBlob: versionedCipherTextBlob }: Pick) => { const cipherTextBlob = versionedCipherTextBlob.subarray(0, -KMS_VERSION_BLOB_LENGTH); - const decryptedBlob = cipher.decrypt(cipherTextBlob, kmsKey); + const decryptedBlob = dataCipher.decrypt(cipherTextBlob, kmsKey); return Promise.resolve(decryptedBlob); }; }; @@ -347,11 +361,11 @@ export const kmsServiceFactory = ({ } // internal KMS - // akhilmhdh: as more encryption are added do a check here on kmsDoc.encryptionAlgorithm - const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); + const keyCipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); + const dataCipher = symmetricCipherService(kmsDoc.internalKms?.encryptionAlgorithm as SymmetricEncryption); return ({ plainText }: Pick) => { - const kmsKey = cipher.decrypt(kmsDoc.internalKms?.encryptedKey as Buffer, ROOT_ENCRYPTION_KEY); - const encryptedPlainTextBlob = cipher.encrypt(plainText, kmsKey); + const kmsKey = keyCipher.decrypt(kmsDoc.internalKms?.encryptedKey as Buffer, ROOT_ENCRYPTION_KEY); + const encryptedPlainTextBlob = dataCipher.encrypt(plainText, kmsKey); // Buffer#1 encrypted text + Buffer#2 version number const versionBlob = Buffer.from(KMS_VERSION, "utf8"); // length is 3 @@ -767,8 +781,8 @@ export const kmsServiceFactory = ({ message: "KMS not found" }); } - const { id, slug, orgId, isExternal } = kms; - return { id, slug, orgId, isExternal }; + const { id, name, orgId, isExternal } = kms; + return { id, name, orgId, isExternal }; }; // akhilmhdh: a copy of this is made in migrations/utils/kms diff --git a/backend/src/services/kms/kms-types.ts b/backend/src/services/kms/kms-types.ts index dc7de10a4..5d5b77a09 100644 --- a/backend/src/services/kms/kms-types.ts +++ b/backend/src/services/kms/kms-types.ts @@ -1,5 +1,7 @@ import { Knex } from "knex"; +import { SymmetricEncryption } from "@app/lib/crypto/cipher"; + export enum KmsDataKey { Organization, SecretManager @@ -22,8 +24,11 @@ export type TEncryptWithKmsDataKeyDTO = export type TGenerateKMSDTO = { orgId: string; + projectId?: string; + encryptionAlgorithm?: SymmetricEncryption; isReserved?: boolean; - slug?: string; + name?: string; + description?: string; tx?: Knex; }; diff --git a/backend/src/services/project-env/project-env-service.ts b/backend/src/services/project-env/project-env-service.ts index b39518066..645f34ade 100644 --- a/backend/src/services/project-env/project-env-service.ts +++ b/backend/src/services/project-env/project-env-service.ts @@ -3,7 +3,9 @@ import { ForbiddenError } from "@casl/ability"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; import { TProjectDALFactory } from "../project/project-dal"; import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; @@ -16,6 +18,7 @@ type TProjectEnvServiceFactoryDep = { projectDAL: Pick; permissionService: Pick; licenseService: Pick; + keyStore: Pick; }; export type TProjectEnvServiceFactory = ReturnType; @@ -24,6 +27,7 @@ export const projectEnvServiceFactory = ({ projectEnvDAL, permissionService, licenseService, + keyStore, projectDAL, folderDAL }: TProjectEnvServiceFactoryDep) => { @@ -45,32 +49,56 @@ export const projectEnvServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Environments); - const envs = await projectEnvDAL.find({ projectId }); - const existingEnv = envs.find(({ slug: envSlug }) => envSlug === slug); - if (existingEnv) - throw new BadRequestError({ - message: "Environment with slug already exist", - name: "CreateEnvironment" + const lock = await keyStore + .acquireLock([KeyStorePrefixes.ProjectEnvironmentLock(projectId)], 5000) + .catch(() => null); + + try { + if (!lock) { + await keyStore.waitTillReady({ + key: KeyStorePrefixes.WaitUntilReadyProjectEnvironmentOperation(projectId), + keyCheckCb: (val) => val === "true", + waitingCb: () => logger.debug("Create project environment. Waiting for "), + delay: 500 + }); + } + + const envs = await projectEnvDAL.find({ projectId }); + const existingEnv = envs.find(({ slug: envSlug }) => envSlug === slug); + if (existingEnv) + throw new BadRequestError({ + message: "Environment with slug already exist", + name: "CreateEnvironment" + }); + + const project = await projectDAL.findById(projectId); + const plan = await licenseService.getPlan(project.orgId); + if (plan.environmentLimit !== null && envs.length >= plan.environmentLimit) { + // case: limit imposed on number of environments allowed + // case: number of environments used exceeds the number of environments allowed + throw new BadRequestError({ + message: + "Failed to create environment due to environment limit reached. Upgrade plan to create more environments." + }); + } + + const env = await projectEnvDAL.transaction(async (tx) => { + const lastPos = await projectEnvDAL.findLastEnvPosition(projectId, tx); + const doc = await projectEnvDAL.create({ slug, name, projectId, position: lastPos + 1 }, tx); + await folderDAL.create({ name: "root", parentId: null, envId: doc.id, version: 1 }, tx); + return doc; }); - const project = await projectDAL.findById(projectId); - const plan = await licenseService.getPlan(project.orgId); - if (plan.environmentLimit !== null && envs.length >= plan.environmentLimit) { - // case: limit imposed on number of environments allowed - // case: number of environments used exceeds the number of environments allowed - throw new BadRequestError({ - message: - "Failed to create environment due to environment limit reached. Upgrade plan to create more environments." - }); + await keyStore.setItemWithExpiry( + KeyStorePrefixes.WaitUntilReadyProjectEnvironmentOperation(projectId), + 10, + "true" + ); + + return env; + } finally { + await lock?.release(); } - - const env = await projectEnvDAL.transaction(async (tx) => { - const lastPos = await projectEnvDAL.findLastEnvPosition(projectId, tx); - const doc = await projectEnvDAL.create({ slug, name, projectId, position: lastPos + 1 }, tx); - await folderDAL.create({ name: "root", parentId: null, envId: doc.id, version: 1 }, tx); - return doc; - }); - return env; }; const updateEnvironment = async ({ @@ -93,26 +121,50 @@ export const projectEnvServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Environments); - const oldEnv = await projectEnvDAL.findOne({ id, projectId }); - if (!oldEnv) throw new NotFoundError({ message: "Environment not found" }); + const lock = await keyStore + .acquireLock([KeyStorePrefixes.ProjectEnvironmentLock(projectId)], 5000) + .catch(() => null); - if (slug) { - const existingEnv = await projectEnvDAL.findOne({ slug, projectId }); - if (existingEnv && existingEnv.id !== id) { - throw new BadRequestError({ - message: "Environment with slug already exist", - name: "UpdateEnvironment" + try { + if (!lock) { + await keyStore.waitTillReady({ + key: KeyStorePrefixes.WaitUntilReadyProjectEnvironmentOperation(projectId), + keyCheckCb: (val) => val === "true", + waitingCb: () => logger.debug("Update project environment. Waiting for project environment update"), + delay: 500 }); } - } - const env = await projectEnvDAL.transaction(async (tx) => { - if (position) { - await projectEnvDAL.updateAllPosition(projectId, oldEnv.position, position, tx); + const oldEnv = await projectEnvDAL.findOne({ id, projectId }); + if (!oldEnv) throw new NotFoundError({ message: "Environment not found", name: "UpdateEnvironment" }); + + if (slug) { + const existingEnv = await projectEnvDAL.findOne({ slug, projectId }); + if (existingEnv && existingEnv.id !== id) { + throw new BadRequestError({ + message: "Environment with slug already exist", + name: "UpdateEnvironment" + }); + } } - return projectEnvDAL.updateById(oldEnv.id, { name, slug, position }, tx); - }); - return { environment: env, old: oldEnv }; + + const env = await projectEnvDAL.transaction(async (tx) => { + if (position) { + await projectEnvDAL.updateAllPosition(projectId, oldEnv.position, position, tx); + } + return projectEnvDAL.updateById(oldEnv.id, { name, slug, position }, tx); + }); + + await keyStore.setItemWithExpiry( + KeyStorePrefixes.WaitUntilReadyProjectEnvironmentOperation(projectId), + 10, + "true" + ); + + return { environment: env, old: oldEnv }; + } finally { + await lock?.release(); + } }; const deleteEnvironment = async ({ projectId, actor, actorId, actorOrgId, actorAuthMethod, id }: TDeleteEnvDTO) => { @@ -125,18 +177,42 @@ export const projectEnvServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Environments); - const env = await projectEnvDAL.transaction(async (tx) => { - const [doc] = await projectEnvDAL.delete({ id, projectId }, tx); - if (!doc) - throw new NotFoundError({ - message: "Env doesn't exist", - name: "DeleteEnvironment" - }); + const lock = await keyStore + .acquireLock([KeyStorePrefixes.ProjectEnvironmentLock(projectId)], 5000) + .catch(() => null); - await projectEnvDAL.updateAllPosition(projectId, doc.position, -1, tx); - return doc; - }); - return env; + try { + if (!lock) { + await keyStore.waitTillReady({ + key: KeyStorePrefixes.WaitUntilReadyProjectEnvironmentOperation(projectId), + keyCheckCb: (val) => val === "true", + waitingCb: () => logger.debug("Delete project environment. Waiting for "), + delay: 500 + }); + } + + const env = await projectEnvDAL.transaction(async (tx) => { + const [doc] = await projectEnvDAL.delete({ id, projectId }, tx); + if (!doc) + throw new NotFoundError({ + message: "Environment doesn't exist", + name: "DeleteEnvironment" + }); + + await projectEnvDAL.updateAllPosition(projectId, doc.position, -1, tx); + return doc; + }); + + await keyStore.setItemWithExpiry( + KeyStorePrefixes.WaitUntilReadyProjectEnvironmentOperation(projectId), + 10, + "true" + ); + + return env; + } finally { + await lock?.release(); + } }; const getEnvironmentById = async ({ projectId, actor, actorId, actorOrgId, actorAuthMethod, id }: TGetEnvDTO) => { diff --git a/backend/src/services/secret-sharing/secret-sharing-service.ts b/backend/src/services/secret-sharing/secret-sharing-service.ts index 47eefcf6e..0f7ee20b4 100644 --- a/backend/src/services/secret-sharing/secret-sharing-service.ts +++ b/backend/src/services/secret-sharing/secret-sharing-service.ts @@ -1,10 +1,14 @@ +import crypto from "node:crypto"; + import bcrypt from "bcrypt"; +import { z } from "zod"; import { TSecretSharing } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; import { SecretSharingAccessType } from "@app/lib/types"; +import { TKmsServiceFactory } from "../kms/kms-service"; import { TOrgDALFactory } from "../org/org-dal"; import { TSecretSharingDALFactory } from "./secret-sharing-dal"; import { @@ -19,14 +23,18 @@ type TSecretSharingServiceFactoryDep = { permissionService: Pick; secretSharingDAL: TSecretSharingDALFactory; orgDAL: TOrgDALFactory; + kmsService: TKmsServiceFactory; }; export type TSecretSharingServiceFactory = ReturnType; +const isUuidV4 = (uuid: string) => z.string().uuid().safeParse(uuid).success; + export const secretSharingServiceFactory = ({ permissionService, secretSharingDAL, - orgDAL + orgDAL, + kmsService }: TSecretSharingServiceFactoryDep) => { const createSharedSecret = async ({ actor, @@ -34,10 +42,7 @@ export const secretSharingServiceFactory = ({ orgId, actorAuthMethod, actorOrgId, - encryptedValue, - hashedHex, - iv, - tag, + secretValue, name, password, accessType, @@ -59,19 +64,25 @@ export const secretSharingServiceFactory = ({ throw new BadRequestError({ message: "Expiration date cannot be more than 30 days" }); } - // Limit Input ciphertext length to 13000 (equivalent to 10,000 characters of Plaintext) - if (encryptedValue.length > 13000) { + if (secretValue.length > 10_000) { throw new BadRequestError({ message: "Shared secret value too long" }); } + const encryptWithRoot = kmsService.encryptWithRootKey(); + + const encryptedSecret = encryptWithRoot(Buffer.from(secretValue)); + + const id = crypto.randomBytes(32).toString("hex"); const hashedPassword = password ? await bcrypt.hash(password, 10) : null; + const newSharedSecret = await secretSharingDAL.create({ + identifier: id, + iv: null, + tag: null, + encryptedValue: null, + encryptedSecret, name, password: hashedPassword, - encryptedValue, - hashedHex, - iv, - tag, expiresAt: new Date(expiresAt), expiresAfterViews, userId: actorId, @@ -79,15 +90,14 @@ export const secretSharingServiceFactory = ({ accessType }); - return { id: newSharedSecret.id }; + const idToReturn = `${Buffer.from(newSharedSecret.identifier!, "hex").toString("base64url")}`; + + return { id: idToReturn }; }; const createPublicSharedSecret = async ({ password, - encryptedValue, - hashedHex, - iv, - tag, + secretValue, expiresAt, expiresAfterViews, accessType @@ -104,24 +114,25 @@ export const secretSharingServiceFactory = ({ throw new BadRequestError({ message: "Expiration date cannot exceed more than 30 days" }); } - // Limit Input ciphertext length to 13000 (equivalent to 10,000 characters of Plaintext) - if (encryptedValue.length > 13000) { - throw new BadRequestError({ message: "Shared secret value too long" }); - } + const encryptWithRoot = kmsService.encryptWithRootKey(); + const encryptedSecret = encryptWithRoot(Buffer.from(secretValue)); + const id = crypto.randomBytes(32).toString("hex"); const hashedPassword = password ? await bcrypt.hash(password, 10) : null; + const newSharedSecret = await secretSharingDAL.create({ + identifier: id, + encryptedValue: null, + iv: null, + tag: null, + encryptedSecret, password: hashedPassword, - encryptedValue, - hashedHex, - iv, - tag, expiresAt: new Date(expiresAt), expiresAfterViews, accessType }); - return { id: newSharedSecret.id }; + return { id: `${Buffer.from(newSharedSecret.identifier!, "hex").toString("base64url")}` }; }; const getSharedSecrets = async ({ @@ -162,25 +173,30 @@ export const secretSharingServiceFactory = ({ }; }; - const $decrementSecretViewCount = async (sharedSecret: TSecretSharing, sharedSecretId: string) => { + const $decrementSecretViewCount = async (sharedSecret: TSecretSharing) => { const { expiresAfterViews } = sharedSecret; if (expiresAfterViews) { // decrement view count if view count expiry set - await secretSharingDAL.updateById(sharedSecretId, { $decr: { expiresAfterViews: 1 } }); + await secretSharingDAL.updateById(sharedSecret.id, { $decr: { expiresAfterViews: 1 } }); } - await secretSharingDAL.updateById(sharedSecretId, { + await secretSharingDAL.updateById(sharedSecret.id, { lastViewedAt: new Date() }); }; - /** Get's passwordless secret. validates all secret's requested (must be fresh). */ + /** Get's password-less secret. validates all secret's requested (must be fresh). */ const getSharedSecretById = async ({ sharedSecretId, hashedHex, orgId, password }: TGetActiveSharedSecretByIdDTO) => { - const sharedSecret = await secretSharingDAL.findOne({ - id: sharedSecretId, - hashedHex - }); + const sharedSecret = isUuidV4(sharedSecretId) + ? await secretSharingDAL.findOne({ + id: sharedSecretId, + hashedHex + }) + : await secretSharingDAL.findOne({ + identifier: Buffer.from(sharedSecretId, "base64url").toString("hex") + }); + if (!sharedSecret) throw new NotFoundError({ message: "Shared secret not found" @@ -222,13 +238,23 @@ export const secretSharingServiceFactory = ({ } } + // If encryptedSecret is set, we know that this secret has been encrypted using KMS, and we can therefore do server-side decryption. + let decryptedSecretValue: Buffer | undefined; + if (sharedSecret.encryptedSecret) { + const decryptWithRoot = kmsService.decryptWithRootKey(); + decryptedSecretValue = decryptWithRoot(sharedSecret.encryptedSecret); + } + // decrement when we are sure the user will view secret. - await $decrementSecretViewCount(sharedSecret, sharedSecretId); + await $decrementSecretViewCount(sharedSecret); return { isPasswordProtected, secret: { ...sharedSecret, + ...(decryptedSecretValue && { + secretValue: Buffer.from(decryptedSecretValue).toString() + }), orgName: sharedSecret.accessType === SecretSharingAccessType.Organization && orgId === sharedSecret.orgId ? orgName @@ -241,7 +267,16 @@ export const secretSharingServiceFactory = ({ const { actor, actorId, orgId, actorAuthMethod, actorOrgId, sharedSecretId } = deleteSharedSecretInput; const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); if (!permission) throw new ForbiddenRequestError({ name: "User does not belong to the specified organization" }); + + const sharedSecret = isUuidV4(sharedSecretId) + ? await secretSharingDAL.findById(sharedSecretId) + : await secretSharingDAL.findOne({ identifier: sharedSecretId }); + const deletedSharedSecret = await secretSharingDAL.deleteById(sharedSecretId); + + if (sharedSecret.orgId && sharedSecret.orgId !== orgId) + throw new ForbiddenRequestError({ message: "User does not have permission to delete shared secret" }); + return deletedSharedSecret; }; diff --git a/backend/src/services/secret-sharing/secret-sharing-types.ts b/backend/src/services/secret-sharing/secret-sharing-types.ts index 794d99a33..1d9efa1e3 100644 --- a/backend/src/services/secret-sharing/secret-sharing-types.ts +++ b/backend/src/services/secret-sharing/secret-sharing-types.ts @@ -19,10 +19,7 @@ export type TSharedSecretPermission = { }; export type TCreatePublicSharedSecretDTO = { - encryptedValue: string; - hashedHex: string; - iv: string; - tag: string; + secretValue: string; expiresAt: string; expiresAfterViews?: number; password?: string; @@ -31,7 +28,7 @@ export type TCreatePublicSharedSecretDTO = { export type TGetActiveSharedSecretByIdDTO = { sharedSecretId: string; - hashedHex: string; + hashedHex?: string; orgId?: string; password?: string; }; diff --git a/backend/src/services/slack/slack-service.ts b/backend/src/services/slack/slack-service.ts index d4a3ebe4d..43d7bb176 100644 --- a/backend/src/services/slack/slack-service.ts +++ b/backend/src/services/slack/slack-service.ts @@ -141,16 +141,14 @@ export const slackServiceFactory = ({ let slackClientId = appCfg.WORKFLOW_SLACK_CLIENT_ID as string; let slackClientSecret = appCfg.WORKFLOW_SLACK_CLIENT_SECRET as string; - const decrypt = await kmsService.decryptWithRootKey(); + const decrypt = kmsService.decryptWithRootKey(); if (serverCfg.encryptedSlackClientId) { - slackClientId = (await decrypt({ cipherTextBlob: Buffer.from(serverCfg.encryptedSlackClientId) })).toString(); + slackClientId = decrypt(Buffer.from(serverCfg.encryptedSlackClientId)).toString(); } if (serverCfg.encryptedSlackClientSecret) { - slackClientSecret = ( - await decrypt({ cipherTextBlob: Buffer.from(serverCfg.encryptedSlackClientSecret) }) - ).toString(); + slackClientSecret = decrypt(Buffer.from(serverCfg.encryptedSlackClientSecret)).toString(); } if (!slackClientId || !slackClientSecret) { diff --git a/backend/src/services/super-admin/super-admin-service.ts b/backend/src/services/super-admin/super-admin-service.ts index b4f77df05..a3acd6751 100644 --- a/backend/src/services/super-admin/super-admin-service.ts +++ b/backend/src/services/super-admin/super-admin-service.ts @@ -122,20 +122,16 @@ export const superAdminServiceFactory = ({ } } - const encryptWithRoot = await kmsService.encryptWithRootKey(); + const encryptWithRoot = kmsService.encryptWithRootKey(); if (data.slackClientId) { - const { cipherTextBlob: encryptedClientId } = await encryptWithRoot({ - plainText: Buffer.from(data.slackClientId) - }); + const encryptedClientId = encryptWithRoot(Buffer.from(data.slackClientId)); updatedData.encryptedSlackClientId = encryptedClientId; updatedData.slackClientId = undefined; } if (data.slackClientSecret) { - const { cipherTextBlob: encryptedClientSecret } = await encryptWithRoot({ - plainText: Buffer.from(data.slackClientSecret) - }); + const encryptedClientSecret = encryptWithRoot(Buffer.from(data.slackClientSecret)); updatedData.encryptedSlackClientSecret = encryptedClientSecret; updatedData.slackClientSecret = undefined; @@ -270,14 +266,14 @@ export const superAdminServiceFactory = ({ let clientId = ""; let clientSecret = ""; - const decrypt = await kmsService.decryptWithRootKey(); + const decrypt = kmsService.decryptWithRootKey(); if (serverCfg.encryptedSlackClientId) { - clientId = (await decrypt({ cipherTextBlob: serverCfg.encryptedSlackClientId })).toString(); + clientId = decrypt(serverCfg.encryptedSlackClientId).toString(); } if (serverCfg.encryptedSlackClientSecret) { - clientSecret = (await decrypt({ cipherTextBlob: serverCfg.encryptedSlackClientSecret })).toString(); + clientSecret = decrypt(serverCfg.encryptedSlackClientSecret).toString(); } return { diff --git a/docs/api-reference/endpoints/kms/keys/create.mdx b/docs/api-reference/endpoints/kms/keys/create.mdx new file mode 100644 index 000000000..194d466bf --- /dev/null +++ b/docs/api-reference/endpoints/kms/keys/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create Key" +openapi: "POST /api/v1/kms/keys" +--- diff --git a/docs/api-reference/endpoints/kms/keys/decrypt.mdx b/docs/api-reference/endpoints/kms/keys/decrypt.mdx new file mode 100644 index 000000000..2ab8ce4ab --- /dev/null +++ b/docs/api-reference/endpoints/kms/keys/decrypt.mdx @@ -0,0 +1,4 @@ +--- +title: "Decrypt Data" +openapi: "POST /api/v1/kms/keys/{keyId}/decrypt" +--- diff --git a/docs/api-reference/endpoints/kms/keys/delete.mdx b/docs/api-reference/endpoints/kms/keys/delete.mdx new file mode 100644 index 000000000..91739d362 --- /dev/null +++ b/docs/api-reference/endpoints/kms/keys/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete Key" +openapi: "DELETE /api/v1/kms/keys/{keyId}" +--- diff --git a/docs/api-reference/endpoints/kms/keys/encrypt.mdx b/docs/api-reference/endpoints/kms/keys/encrypt.mdx new file mode 100644 index 000000000..6d9db8006 --- /dev/null +++ b/docs/api-reference/endpoints/kms/keys/encrypt.mdx @@ -0,0 +1,4 @@ +--- +title: "Encrypt Data" +openapi: "POST /api/v1/kms/keys/{keyId}/encrypt" +--- diff --git a/docs/api-reference/endpoints/kms/keys/list.mdx b/docs/api-reference/endpoints/kms/keys/list.mdx new file mode 100644 index 000000000..983c98ae3 --- /dev/null +++ b/docs/api-reference/endpoints/kms/keys/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List Keys" +openapi: "Get /api/v1/kms/keys" +--- diff --git a/docs/api-reference/endpoints/kms/keys/update.mdx b/docs/api-reference/endpoints/kms/keys/update.mdx new file mode 100644 index 000000000..7e44420b1 --- /dev/null +++ b/docs/api-reference/endpoints/kms/keys/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update Key" +openapi: "PATCH /api/v1/kms/keys/{keyId}" +--- diff --git a/docs/documentation/platform/dynamic-secrets/ldap.mdx b/docs/documentation/platform/dynamic-secrets/ldap.mdx new file mode 100644 index 000000000..2dc51d74e --- /dev/null +++ b/docs/documentation/platform/dynamic-secrets/ldap.mdx @@ -0,0 +1,168 @@ +--- +title: "LDAP" +description: "Learn how to dynamically generate user credentials via LDAP." +--- + +The Infisical LDAP dynamic secret allows you to generate user credentials on demand via LDAP. The integration is general to any LDAP implementation but has been tested with OpenLDAP and Active directory as of now. + +## Prerequisites + +1. Create a user with the necessary permissions to create users in your LDAP server. +2. Ensure your LDAP server is reachable via Infisical instance. + +## Set up Dynamic Secrets with LDAP + + + + Open the Secret Overview dashboard and select the environment in which you would like to add a dynamic secret. + + + ![Add Dynamic Secret Button](../../../images/platform/dynamic-secrets/add-dynamic-secret-button.png) + + + ![Dynamic Secret Modal](../../../images/platform/dynamic-secrets/dynamic-secret-ldap-select.png) + + + + + Name by which you want the secret to be referenced + + + + Default time-to-live for a generated secret (it is possible to modify this value when a secret is generate) + + + + Maximum time-to-live for a generated secret. + + + + LDAP url to connect to. _(Example: ldap://your-ldap-ip:389 or ldaps://domain:636)_ + + + + DN to bind to. This should have permissions to create a new users. + + + + Password for the given DN. + + + + CA certificate to use for TLS in case of a secure connection. + + + + LDIF to run while creating a user in LDAP. This can include extra steps to assign the user to groups or set permissions. + Here `{{Username}}`, `{{Password}}` and `{{EncodedPassword}}` are templatized variables for the username and password generated by the dynamic secret. + + `{{EncodedPassword}}` is the encoded password required for the `unicodePwd` field in Active Directory as described [here](https://learn.microsoft.com/en-us/troubleshoot/windows-server/active-directory/change-windows-active-directory-user-password). + + **OpenLDAP** Example: + ``` + dn: uid={{Username}},dc=infisical,dc=com + changetype: add + objectClass: top + objectClass: person + objectClass: organizationalPerson + objectClass: inetOrgPerson + cn: John Doe + sn: Doe + uid: jdoe + mail: jdoe@infisical.com + userPassword: {{Password}} + ``` + + **Active Directory** Example: + ``` + dn: CN={{Username}},OU=Test Create,DC=infisical,DC=com + changetype: add + objectClass: top + objectClass: person + objectClass: organizationalPerson + objectClass: user + userPrincipalName: {{Username}}@infisical.com + sAMAccountName: {{Username}} + unicodePwd::{{EncodedPassword}} + userAccountControl: 66048 + + dn: CN=test-group,OU=Test Create,DC=infisical,DC=com + changetype: modify + add: member + member: CN={{Username}},OU=Test Create,DC=infisical,DC=com + - + ``` + + + + LDIF to run while revoking a user in LDAP. This can include extra steps to remove the user from groups or set permissions. + Here `{{Username}}` is a templatized variable for the username generated by the dynamic secret. + + **OpenLDAP / Active Directory** Example: + ``` + dn: CN={{Username}},OU=Test Create,DC=infisical,DC=com + changetype: delete + ``` + + + + LDIF to run incase Creation LDIF fails midway. + + For the creation example shown above, if the user is created successfully but not added to a group, this LDIF can be used to remove the user. + Here `{{Username}}`, `{{Password}}` and `{{EncodedPassword}}` are templatized variables for the username generated by the dynamic secret. + + **OpenLDAP / Active Directory** Example: + ``` + dn: CN={{Username}},OU=Test Create,DC=infisical,DC=com + changetype: delete + ``` + + + + + + After submitting the form, you will see a dynamic secret created in the dashboard. + + + Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials. + To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item. + Alternatively, you can initiate the creation of a new lease by selecting 'New Lease' from the dynamic secret lease list section. + + ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-generate-redis.png) + ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-lease-empty-redis.png) + + When generating these secrets, it's important to specify a Time-to-Live (TTL) duration. This will dictate how long the credentials are valid for. + + ![Provision Lease](/images/platform/dynamic-secrets/provision-lease.png) + + + Ensure that the TTL for the lease fall within the maximum TTL defined when configuring the dynamic secret. + + + + Once you click the `Submit` button, a new secret lease will be generated and the credentials from it will be shown to you with an array of DN's altered depending on the Creation LDIF. + + ![Provision Lease](/images/platform/dynamic-secrets/dynamic-secret-ldap-lease.png) + + + + + +## Active Directory Integration + +- Passwords in Active Directory are set using the `unicodePwd` field. This must be proceeded by two colons `::` as shown in the example. [Source](https://learn.microsoft.com/en-us/troubleshoot/windows-server/active-directory/change-windows-active-directory-user-password) +- Active directory uses the `userAccountControl` field to enable account. [Read More](https://learn.microsoft.com/en-us/troubleshoot/windows-server/active-directory/useraccountcontrol-manipulate-account-properties) + - `userAccountControl` set to `512` enables a user. + - To disable AD's password expiration for this dynamic user account. The `userAccountControl` value for this is: `65536`. + - Since `userAccountControl` flag is cumulative set it to `512 + 65536` = `66048` to do both. +- Active Directory does not permit direct modification of a user's `memberOf` attribute. The member attribute of a group and the `memberOf` attribute of a user are [linked attributes](https://learn.microsoft.com/en-us/windows/win32/ad/linked-attributes), where the member attribute represents the forward link, which can be modified. In the context of AD group membership, the group's `member` attribute serves as the forward link. Therefore, to add a newly created dynamic user to a group, a modification request must be issued to the desired group, updating its membership to include the new user. + +## LDIF Entries + +User account management is handled through **LDIF entries**. + +#### Things to Remember + +- **No trailing spaces:** Ensure there are no trailing spaces on any line, including blank lines. +- **Empty lines before modify blocks:** Every modify block must be preceded by an empty line. +- **Multiple modifications:** You can define multiple modifications for a DN within a single modify block. Each modification should end with a single dash (`-`). diff --git a/docs/documentation/platform/identities/aws-auth.mdx b/docs/documentation/platform/identities/aws-auth.mdx index 5c82a3807..494606ccd 100644 --- a/docs/documentation/platform/identities/aws-auth.mdx +++ b/docs/documentation/platform/identities/aws-auth.mdx @@ -7,7 +7,7 @@ description: "Learn how to authenticate with Infisical for EC2 instances, Lambda ## Diagram -The following sequence digram illustrates the AWS Auth workflow for authenticating AWS IAM principals with Infisical. +The following sequence diagram illustrates the AWS Auth workflow for authenticating AWS IAM principals with Infisical. ```mermaid sequenceDiagram diff --git a/docs/documentation/platform/identities/azure-auth.mdx b/docs/documentation/platform/identities/azure-auth.mdx index 910ca1014..03d997ffb 100644 --- a/docs/documentation/platform/identities/azure-auth.mdx +++ b/docs/documentation/platform/identities/azure-auth.mdx @@ -7,7 +7,7 @@ description: "Learn how to authenticate with Infisical for services on Azure" ## Diagram -The following sequence digram illustrates the Azure Auth workflow for authenticating Azure [service principals](https://learn.microsoft.com/en-us/entra/identity-platform/app-objects-and-service-principals?tabs=browser) with Infisical. +The following sequence diagram illustrates the Azure Auth workflow for authenticating Azure [service principals](https://learn.microsoft.com/en-us/entra/identity-platform/app-objects-and-service-principals?tabs=browser) with Infisical. ```mermaid sequenceDiagram diff --git a/docs/documentation/platform/identities/gcp-auth.mdx b/docs/documentation/platform/identities/gcp-auth.mdx index 09b41852d..6573544de 100644 --- a/docs/documentation/platform/identities/gcp-auth.mdx +++ b/docs/documentation/platform/identities/gcp-auth.mdx @@ -13,7 +13,7 @@ description: "Learn how to authenticate with Infisical for services on Google Cl ## Diagram - The following sequence digram illustrates the GCP ID Token Auth workflow for authenticating GCP resources with Infisical. + The following sequence diagram illustrates the GCP ID Token Auth workflow for authenticating GCP resources with Infisical. ```mermaid sequenceDiagram @@ -182,7 +182,7 @@ access the Infisical API using the GCP ID Token authentication method. ## Diagram - The following sequence digram illustrates the GCP IAM Auth workflow for authenticating GCP IAM service accounts with Infisical. + The following sequence diagram illustrates the GCP IAM Auth workflow for authenticating GCP IAM service accounts with Infisical. ```mermaid sequenceDiagram diff --git a/docs/documentation/platform/identities/kubernetes-auth.mdx b/docs/documentation/platform/identities/kubernetes-auth.mdx index 153b0f4a3..b4d7cc1ac 100644 --- a/docs/documentation/platform/identities/kubernetes-auth.mdx +++ b/docs/documentation/platform/identities/kubernetes-auth.mdx @@ -7,7 +7,7 @@ description: "Learn how to authenticate with Infisical in Kubernetes" ## Diagram - The following sequence digram illustrates the Kubernetes Auth workflow for authenticating applications running in pods with Infisical. + The following sequence diagram illustrates the Kubernetes Auth workflow for authenticating applications running in pods with Infisical. ```mermaid sequenceDiagram diff --git a/docs/documentation/platform/identities/token-auth.mdx b/docs/documentation/platform/identities/token-auth.mdx index 61906542f..59c5f9abf 100644 --- a/docs/documentation/platform/identities/token-auth.mdx +++ b/docs/documentation/platform/identities/token-auth.mdx @@ -7,7 +7,7 @@ description: "Learn how to authenticate to Infisical from any platform or enviro ## Diagram -The following sequence digram illustrates the Token Auth workflow for authenticating clients with Infisical. +The following sequence diagram illustrates the Token Auth workflow for authenticating clients with Infisical. ```mermaid sequenceDiagram diff --git a/docs/documentation/platform/identities/universal-auth.mdx b/docs/documentation/platform/identities/universal-auth.mdx index 32e916631..597978093 100644 --- a/docs/documentation/platform/identities/universal-auth.mdx +++ b/docs/documentation/platform/identities/universal-auth.mdx @@ -7,7 +7,7 @@ description: "Learn how to authenticate to Infisical from any platform or enviro ## Diagram -The following sequence digram illustrates the Universal Auth workflow for authenticating clients with Infisical. +The following sequence diagram illustrates the Universal Auth workflow for authenticating clients with Infisical. ```mermaid sequenceDiagram diff --git a/docs/documentation/platform/kms/aws-hsm.mdx b/docs/documentation/platform/kms-configuration/aws-hsm.mdx similarity index 100% rename from docs/documentation/platform/kms/aws-hsm.mdx rename to docs/documentation/platform/kms-configuration/aws-hsm.mdx diff --git a/docs/documentation/platform/kms/aws-kms.mdx b/docs/documentation/platform/kms-configuration/aws-kms.mdx similarity index 100% rename from docs/documentation/platform/kms/aws-kms.mdx rename to docs/documentation/platform/kms-configuration/aws-kms.mdx diff --git a/docs/documentation/platform/kms/overview.mdx b/docs/documentation/platform/kms-configuration/overview.mdx similarity index 95% rename from docs/documentation/platform/kms/overview.mdx rename to docs/documentation/platform/kms-configuration/overview.mdx index 99cfa134f..327481bc4 100644 --- a/docs/documentation/platform/kms/overview.mdx +++ b/docs/documentation/platform/kms-configuration/overview.mdx @@ -1,5 +1,5 @@ --- -title: "Key Management Service (KMS)" +title: "Key Management Service (KMS) Configuration" sidebarTitle: "Overview" description: "Learn how to configure your project's encryption" --- @@ -25,4 +25,4 @@ For existing projects, you can configure the KMS from the Project Settings page. ## External KMS -Infisical supports the use of external KMS solutions to enhance security and compliance. You can configure your project to use services like [AWS Key Management Service](./aws-kms) for managing encryption. +Infisical supports the use of external KMS solutions to enhance security and compliance. You can configure your project to use services like [AWS Key Management Service](./aws-kms) for managing encryption. \ No newline at end of file diff --git a/docs/documentation/platform/kms.mdx b/docs/documentation/platform/kms.mdx new file mode 100644 index 000000000..eb4479597 --- /dev/null +++ b/docs/documentation/platform/kms.mdx @@ -0,0 +1,208 @@ +--- +title: "Key Management Service (KMS)" +sidebarTitle: "Key Management (KMS)" +description: "Learn how to manage and use cryptographic keys with Infisical." +--- + +## Concept + +Infisical can be used as a Key Management System (KMS), referred to as Infisical KMS, to centralize management of keys to be used for cryptographic operations like encryption/decryption. + + + Keys managed in KMS are not extractable from the platform. Additionally, data + is never stored when performing cryptographic operations. + + +## Workflow + +The typical workflow for using Infisical KMS consists of the following steps: + +1. Creating a KMS key. As part of this step, you specify a name for the key and the encryption algorithm meant to be used for it (e.g. `AES-GCM-128`, `AES-GCM-256`). +2. Encryption: To encrypt data, you would make a request to the Infisical KMS API endpoint, specifying the base64-encoded plaintext and the intended key to use for encryption; the API would return the base64-encoded ciphertext. +3. Decryption: To decrypt data, you would make a request to the Infisical KMS API endpoint, specifying the base64-encoded ciphertext and the intended key to use for decryption; the API would return the base64-encoded plaintext. + + + Note that this workflow can be executed via the Infisical UI or manually such + as via API. + + +## Guide to Encrypting Data + +In the following steps, we explore how to generate a key and use it to encrypt data. + + + + + + Navigate to Project > Key Management and tap on the **Add Key** button. + ![kms add key button](/images/platform/kms/infisical-kms/kms-add-key.png) + + Specify your key details. Here's some guidance on each field: + + - Name: A slug-friendly name for the key. + - Type: The encryption algorithm associated with the key (e.g. `AES-GCM-256`). + - Description: An optional description of what the intended usage is for the key. + + ![kms add key modal](/images/platform/kms/infisical-kms/kms-add-key-modal.png) + + + Once your key is generated, open the options menu for the newly created key and select encrypt data. + ![kms key options](/images/platform/kms/infisical-kms/kms-key-options.png) + + Populate the text area with your data and tap on the Encrypt button. + ![kms encrypt data](/images/platform/kms/infisical-kms/kms-encrypt-data.png) + + + If your data is already Base64 encoded make sure to toggle the respective switch on to avoid + redundant encoding. + + + Copy and store the encrypted data. + ![kms encrypted data](/images/platform/kms/infisical-kms/kms-encrypted-data.png) + + + + + + + To create a cryptographic key, make an API request to the [Create KMS + Key](/api-reference/endpoints/kms/keys/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/kms/keys \ + --header 'Content-Type: application/json' \ + --data '{ + "projectId": "", + "name": "my-secret-key", + "description": "...", + "encryptionAlgorithm": "aes-256-gcm" + }' + ``` + + ### Sample response + + ```bash Response + { + "key": { + "id": "", + "description": "...", + "isDisabled": false, + "isReserved": false, + "orgId": "", + "name": "my-secret-key", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "projectId": "" + } + } + ``` + + + To encrypt data, make an API request to the [Encrypt + Data](/api-reference/endpoints/kms/keys/encrypt) API endpoint, + specifying the key to use. + + + Make sure your data is Base64 encoded + + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/kms/keys//encrypt \ + --header 'Content-Type: application/json' \ + --data '{ + "plaintext": "lUFHM5Ggwo6TOfpuN1S==" // base64 encoded plaintext + }' + ``` + + ### Sample response + + ```bash Response + { + "ciphertext": "HwFHwSFHwlMF6TOfp==" // base64 encoded ciphertext + } + ``` + + + + + + +## Guide to Decrypting Data + +In the following steps, we explore how to use decrypt data using an existing key in Infisical KMS. + + + + + + Navigate to Project > Key Management and open the options menu for the key used to encrypt the data + you want to decrypt. + ![kms key options](/images/platform/kms/infisical-kms/kms-decrypt-options.png) + + + + Paste your encrypted data into the text area and tap on the Decrypt button. Optionally, if your data was + originally plain text, enable the decode Base64 switch. + ![kms decrypt data](/images/platform/kms/infisical-kms/kms-decrypt-data.png) + + Your decrypted data will be displayed and can be copied for use. + ![kms decrypted data](/images/platform/kms/infisical-kms/kms-decrypted-data.png) + + + + + + + + To decrypt data, make an API request to the [Decrypt + Data](/api-reference/endpoints/kms/keys/decrypt) API endpoint, + specifying the key to use. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/kms/keys//decrypt \ + --header 'Content-Type: application/json' \ + --data '{ + "ciphertext": "HwFHwSFHwlMF6TOfp==" // base64 encoded ciphertext + }' + ``` + + ### Sample response + + ```bash Response + { + "plaintext": "lUFHM5Ggwo6TOfpuN1S==" // base64 encoded plaintext + } + ``` + + + + + + + +## FAQ + + + + No. Infisical's KMS only provides cryptographic services and does not store + any encrypted or decrypted data. + + + No. Infisical's KMS will never expose your keys, encrypted or decrypted, to + external sources. + + + Currently, Infisical only supports `AES-128-GCM` and `AES-256-GCM` for + encryption operations. We anticipate supporting more algorithms and + cryptographic operations in the coming months. + + diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-ldap-lease.png b/docs/images/platform/dynamic-secrets/dynamic-secret-ldap-lease.png new file mode 100644 index 000000000..e053a8ddc Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-ldap-lease.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-ldap-select.png b/docs/images/platform/dynamic-secrets/dynamic-secret-ldap-select.png new file mode 100644 index 000000000..7694a3fef Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-ldap-select.png differ diff --git a/docs/images/platform/kms/infisical-kms/kms-add-key-modal.png b/docs/images/platform/kms/infisical-kms/kms-add-key-modal.png new file mode 100644 index 000000000..c1738eb43 Binary files /dev/null and b/docs/images/platform/kms/infisical-kms/kms-add-key-modal.png differ diff --git a/docs/images/platform/kms/infisical-kms/kms-add-key.png b/docs/images/platform/kms/infisical-kms/kms-add-key.png new file mode 100644 index 000000000..4fdc44898 Binary files /dev/null and b/docs/images/platform/kms/infisical-kms/kms-add-key.png differ diff --git a/docs/images/platform/kms/infisical-kms/kms-decrypt-data.png b/docs/images/platform/kms/infisical-kms/kms-decrypt-data.png new file mode 100644 index 000000000..63d4e2aee Binary files /dev/null and b/docs/images/platform/kms/infisical-kms/kms-decrypt-data.png differ diff --git a/docs/images/platform/kms/infisical-kms/kms-decrypt-options.png b/docs/images/platform/kms/infisical-kms/kms-decrypt-options.png new file mode 100644 index 000000000..e07a70196 Binary files /dev/null and b/docs/images/platform/kms/infisical-kms/kms-decrypt-options.png differ diff --git a/docs/images/platform/kms/infisical-kms/kms-decrypted-data.png b/docs/images/platform/kms/infisical-kms/kms-decrypted-data.png new file mode 100644 index 000000000..0d9ad82b8 Binary files /dev/null and b/docs/images/platform/kms/infisical-kms/kms-decrypted-data.png differ diff --git a/docs/images/platform/kms/infisical-kms/kms-encrypt-data.png b/docs/images/platform/kms/infisical-kms/kms-encrypt-data.png new file mode 100644 index 000000000..d73f1cc3b Binary files /dev/null and b/docs/images/platform/kms/infisical-kms/kms-encrypt-data.png differ diff --git a/docs/images/platform/kms/infisical-kms/kms-encrypted-data.png b/docs/images/platform/kms/infisical-kms/kms-encrypted-data.png new file mode 100644 index 000000000..33f896183 Binary files /dev/null and b/docs/images/platform/kms/infisical-kms/kms-encrypted-data.png differ diff --git a/docs/images/platform/kms/infisical-kms/kms-key-options.png b/docs/images/platform/kms/infisical-kms/kms-key-options.png new file mode 100644 index 000000000..b1e45e356 Binary files /dev/null and b/docs/images/platform/kms/infisical-kms/kms-key-options.png differ diff --git a/docs/mint.json b/docs/mint.json index d2ca12605..65f03f3c7 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -113,6 +113,15 @@ "documentation/platform/pki/alerting" ] }, + "documentation/platform/kms", + { + "group": "KMS Configuration", + "pages": [ + "documentation/platform/kms-configuration/overview", + "documentation/platform/kms-configuration/aws-kms", + "documentation/platform/kms-configuration/aws-hsm" + ] + }, { "group": "Identities", "pages": [ @@ -168,15 +177,8 @@ "documentation/platform/dynamic-secrets/aws-iam", "documentation/platform/dynamic-secrets/mongo-atlas", "documentation/platform/dynamic-secrets/mongo-db", - "documentation/platform/dynamic-secrets/azure-entra-id" - ] - }, - { - "group": "Key Management (KMS)", - "pages": [ - "documentation/platform/kms/overview", - "documentation/platform/kms/aws-kms", - "documentation/platform/kms/aws-hsm" + "documentation/platform/dynamic-secrets/azure-entra-id", + "documentation/platform/dynamic-secrets/ldap" ] }, { @@ -789,6 +791,22 @@ } ] }, + { + "group": "Infisical KMS", + "pages": [ + { + "group": "Keys", + "pages": [ + "api-reference/endpoints/kms/keys/list", + "api-reference/endpoints/kms/keys/create", + "api-reference/endpoints/kms/keys/update", + "api-reference/endpoints/kms/keys/delete", + "api-reference/endpoints/kms/keys/encrypt", + "api-reference/endpoints/kms/keys/decrypt" + ] + } + ] + }, { "group": "Internals", "pages": [ diff --git a/frontend/src/components/v2/Dropdown/Dropdown.tsx b/frontend/src/components/v2/Dropdown/Dropdown.tsx index 3870cc74e..a2d5f0322 100644 --- a/frontend/src/components/v2/Dropdown/Dropdown.tsx +++ b/frontend/src/components/v2/Dropdown/Dropdown.tsx @@ -86,13 +86,15 @@ export const DropdownMenuItem = ({ icon, as: Item = "button", iconPos = "left", + isDisabled = false, ...props -}: DropdownMenuItemProps & ComponentPropsWithRef) => ( +}: DropdownMenuItemProps & ComponentPropsWithRef & { isDisabled?: boolean }) => ( diff --git a/frontend/src/components/v2/InfisicalSecretInput/InfisicalSecretInput.tsx b/frontend/src/components/v2/InfisicalSecretInput/InfisicalSecretInput.tsx index 629d94a37..45b45b01a 100644 --- a/frontend/src/components/v2/InfisicalSecretInput/InfisicalSecretInput.tsx +++ b/frontend/src/components/v2/InfisicalSecretInput/InfisicalSecretInput.tsx @@ -77,7 +77,7 @@ export const InfisicalSecretInput = forwardRef( const { currentWorkspace } = useWorkspace(); const workspaceId = currentWorkspace?.id || ""; - const debouncedValue = useDebounce(value, 500); + const [debouncedValue] = useDebounce(value, 500); const [highlightedIndex, setHighlightedIndex] = useState(-1); diff --git a/frontend/src/components/v2/Pagination/Pagination.tsx b/frontend/src/components/v2/Pagination/Pagination.tsx index cc1f7df95..51eed6396 100644 --- a/frontend/src/components/v2/Pagination/Pagination.tsx +++ b/frontend/src/components/v2/Pagination/Pagination.tsx @@ -49,7 +49,7 @@ export const Pagination = ({ return (
diff --git a/frontend/src/components/v2/SecretPathInput/SecretPathInput.tsx b/frontend/src/components/v2/SecretPathInput/SecretPathInput.tsx index b453456d7..bbe63bc6e 100644 --- a/frontend/src/components/v2/SecretPathInput/SecretPathInput.tsx +++ b/frontend/src/components/v2/SecretPathInput/SecretPathInput.tsx @@ -33,7 +33,7 @@ export const SecretPathInput = ({ const [suggestions, setSuggestions] = useState([]); const [isInputFocused, setIsInputFocus] = useState(false); const [highlightedIndex, setHighlightedIndex] = useState(-1); - const debouncedInputValue = useDebounce(inputValue, 200); + const [debouncedInputValue] = useDebounce(inputValue, 200); const { currentWorkspace } = useWorkspace(); const workspaceId = currentWorkspace?.id || ""; diff --git a/frontend/src/components/v2/Switch/Switch.tsx b/frontend/src/components/v2/Switch/Switch.tsx index a54657fc6..ce955354a 100644 --- a/frontend/src/components/v2/Switch/Switch.tsx +++ b/frontend/src/components/v2/Switch/Switch.tsx @@ -8,6 +8,7 @@ export type SwitchProps = Omit ( -
+
- + Role + {/*
Role { />
- + */} {isFetching ? : null} @@ -277,9 +286,9 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => { })} - {!isLoading && data && data.totalCount > 0 && ( + {!isLoading && data && totalCount > 0 && ( setPage(newPage)} diff --git a/frontend/src/views/OrgAdminPage/components/OrgAdminProjects/OrgAdminProjects.tsx b/frontend/src/views/OrgAdminPage/components/OrgAdminProjects/OrgAdminProjects.tsx index 516f248f0..1731e6dd5 100644 --- a/frontend/src/views/OrgAdminPage/components/OrgAdminProjects/OrgAdminProjects.tsx +++ b/frontend/src/views/OrgAdminPage/components/OrgAdminProjects/OrgAdminProjects.tsx @@ -37,7 +37,7 @@ export const OrgAdminProjects = withPermission( () => { const [page, setPage] = useState(1); const [search, setSearch] = useState(""); - const debouncedSearch = useDebounce(search); + const [debouncedSearch] = useDebounce(search); const [perPage, setPerPage] = useState(25); const router = useRouter(); const orgAdminAccessProject = useOrgAdminAccessProject(); diff --git a/frontend/src/views/Project/CertificatesPage/CertificatesPage.tsx b/frontend/src/views/Project/CertificatesPage/CertificatesPage.tsx index 82fa42871..fd2d48225 100644 --- a/frontend/src/views/Project/CertificatesPage/CertificatesPage.tsx +++ b/frontend/src/views/Project/CertificatesPage/CertificatesPage.tsx @@ -2,7 +2,7 @@ import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { withProjectPermission } from "@app/hoc"; -import { CaTab, CertificatesTab,PkiAlertsTab } from "./components"; +import { CaTab, CertificatesTab, PkiAlertsTab } from "./components"; enum TabSections { Ca = "certificate-authorities", @@ -36,5 +36,5 @@ export const CertificatesPage = withProjectPermission(
); }, - { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.AuditLogs } + { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Certificates } ); diff --git a/frontend/src/views/Project/KmsPage/components/CmekDecryptModal.tsx b/frontend/src/views/Project/KmsPage/components/CmekDecryptModal.tsx new file mode 100644 index 000000000..aa0943f73 --- /dev/null +++ b/frontend/src/views/Project/KmsPage/components/CmekDecryptModal.tsx @@ -0,0 +1,169 @@ +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { faCheckCircle, faCopy, faInfoCircle, faLockOpen } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { decodeBase64 } from "tweetnacl-util"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + FormControl, + Modal, + ModalClose, + ModalContent, + Switch, + TextArea, + Tooltip +} from "@app/components/v2"; +import { useTimedReset } from "@app/hooks"; +import { TCmek, useCmekDecrypt } from "@app/hooks/api/cmeks"; + +const formSchema = z.object({ + ciphertext: z.string() +}); + +export type FormData = z.infer; + +type Props = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + cmek: TCmek; +}; + +type FormProps = Pick; + +const DecryptForm = ({ cmek }: FormProps) => { + const cmekDecrypt = useCmekDecrypt(); + const [shouldDecode, setShouldDecode] = useState(false); + const [plaintext, setPlaintext] = useState(""); + + const { + handleSubmit, + register, + formState: { isSubmitting, errors } + } = useForm({ + resolver: zodResolver(formSchema) + }); + + const [copyCiphertext, isCopyingCiphertext, setCopyCipherText] = useTimedReset({ + initialState: "Copy to Clipboard" + }); + + const handleDecryptData = async (formData: FormData) => { + try { + const data = await cmekDecrypt.mutateAsync({ ...formData, keyId: cmek.id }); + createNotification({ + text: "Successfully decrypted data", + type: "success" + }); + + setPlaintext( + shouldDecode ? Buffer.from(decodeBase64(data.plaintext)).toString("utf8") : data.plaintext + ); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to decrypt data", + type: "error" + }); + } + }; + + useEffect(() => { + const text = cmekDecrypt.data?.plaintext; + if (!text) return; + + setPlaintext(shouldDecode ? Buffer.from(decodeBase64(text)).toString("utf8") : text); + }, [shouldDecode]); + + const handleCopyToClipboard = () => { + navigator.clipboard.writeText(plaintext ?? ""); + + setCopyCipherText("Copied to Clipboard"); + }; + + return ( +
+ {plaintext ? ( + +