diff --git a/backend/package-lock.json b/backend/package-lock.json index a2a4d0a6e..389d16475 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -25,6 +25,8 @@ "@node-saml/passport-saml": "^4.0.4", "@octokit/rest": "^20.0.2", "@octokit/webhooks-types": "^7.3.1", + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/x509": "^1.10.0", "@serdnam/pino-cloudwatch-transport": "^1.0.4", "@sindresorhus/slugify": "^2.2.1", "@ucast/mongo2js": "^1.3.4", @@ -2459,9 +2461,9 @@ } }, "node_modules/@fastify/session": { - "version": "10.7.0", - "resolved": "https://registry.npmjs.org/@fastify/session/-/session-10.7.0.tgz", - "integrity": "sha512-ECA75gnyaxcyIukgyO2NGT3XdbLReNl/pTKrrkRfDc6pVqNtdptwwfx9KXrIMOfsO4B3m84eF3wZ9GgnebiZ4w==", + "version": "10.9.0", + "resolved": "https://registry.npmjs.org/@fastify/session/-/session-10.9.0.tgz", + "integrity": "sha512-u/c42RuAaxCeEuRCAwK2+/SfGqKOd0NSyRzEvDwFBWySQoKUZQyb9OmmJSWJBbOP1OfaU2OsDrjbPbghE1l/YQ==", "dependencies": { "fastify-plugin": "^4.0.0", "safe-stable-stringify": "^2.3.1" @@ -3299,6 +3301,149 @@ "resolved": "https://registry.npmjs.org/@octokit/webhooks-types/-/webhooks-types-7.1.0.tgz", "integrity": "sha512-y92CpG4kFFtBBjni8LHoV12IegJ+KFxLgKRengrVjKmGE5XMeCuGvlfRe75lTRrgXaG6XIWJlFpIDTlkoJsU8w==" }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.3.8.tgz", + "integrity": "sha512-Wtk9R7yQxGaIaawHorWKP2OOOm/RZzamOmSWwaqGphIuU6TcKYih0slL6asZlSSZtVoYTrBfrddSOD/jTu9vuQ==", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/asn1-x509": "^2.3.8", + "@peculiar/asn1-x509-attr": "^2.3.8", + "asn1js": "^3.0.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-csr": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.3.8.tgz", + "integrity": "sha512-ZmAaP2hfzgIGdMLcot8gHTykzoI+X/S53x1xoGbTmratETIaAbSWMiPGvZmXRA0SNEIydpMkzYtq4fQBxN1u1w==", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/asn1-x509": "^2.3.8", + "asn1js": "^3.0.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.3.8.tgz", + "integrity": "sha512-Ah/Q15y3A/CtxbPibiLM/LKcMbnLTdUdLHUgdpB5f60sSvGkXzxJCu5ezGTFHogZXWNX3KSmYqilCrfdmBc6pQ==", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/asn1-x509": "^2.3.8", + "asn1js": "^3.0.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-pfx": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.3.8.tgz", + "integrity": "sha512-XhdnCVznMmSmgy68B9pVxiZ1XkKoE1BjO4Hv+eUGiY1pM14msLsFZ3N7K46SoITIVZLq92kKkXpGiTfRjlNLyg==", + "dependencies": { + "@peculiar/asn1-cms": "^2.3.8", + "@peculiar/asn1-pkcs8": "^2.3.8", + "@peculiar/asn1-rsa": "^2.3.8", + "@peculiar/asn1-schema": "^2.3.8", + "asn1js": "^3.0.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-pkcs8": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.3.8.tgz", + "integrity": "sha512-rL8k2x59v8lZiwLRqdMMmOJ30GHt6yuHISFIuuWivWjAJjnxzZBVzMTQ72sknX5MeTSSvGwPmEFk2/N8+UztFQ==", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/asn1-x509": "^2.3.8", + "asn1js": "^3.0.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-pkcs9": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.3.8.tgz", + "integrity": "sha512-+nONq5tcK7vm3qdY7ZKoSQGQjhJYMJbwJGbXLFOhmqsFIxEWyQPHyV99+wshOjpOjg0wUSSkEEzX2hx5P6EKeQ==", + "dependencies": { + "@peculiar/asn1-cms": "^2.3.8", + "@peculiar/asn1-pfx": "^2.3.8", + "@peculiar/asn1-pkcs8": "^2.3.8", + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/asn1-x509": "^2.3.8", + "@peculiar/asn1-x509-attr": "^2.3.8", + "asn1js": "^3.0.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.3.8.tgz", + "integrity": "sha512-ES/RVEHu8VMYXgrg3gjb1m/XG0KJWnV4qyZZ7mAg7rrF3VTmRbLxO8mk+uy0Hme7geSMebp+Wvi2U6RLLEs12Q==", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/asn1-x509": "^2.3.8", + "asn1js": "^3.0.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.3.8.tgz", + "integrity": "sha512-ULB1XqHKx1WBU/tTFIA+uARuRoBVZ4pNdOA878RDrRbBfBGcSzi5HBkdScC6ZbHn8z7L8gmKCgPC1LHRrP46tA==", + "dependencies": { + "asn1js": "^3.0.5", + "pvtsutils": "^1.3.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.3.8.tgz", + "integrity": "sha512-voKxGfDU1c6r9mKiN5ZUsZWh3Dy1BABvTM3cimf0tztNwyMJPhiXY94eRTgsMQe6ViLfT6EoXxkWVzcm3mFAFw==", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.8", + "asn1js": "^3.0.5", + "ipaddr.js": "^2.1.0", + "pvtsutils": "^1.3.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-x509-attr": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.3.8.tgz", + "integrity": "sha512-4Z8mSN95MOuX04Aku9BUyMdsMKtVQUqWnr627IheiWnwFoheUhX3R4Y2zh23M7m80r4/WG8MOAckRKc77IRv6g==", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/asn1-x509": "^2.3.8", + "asn1js": "^3.0.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-x509/node_modules/ipaddr.js": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz", + "integrity": "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==", + "engines": { + "node": ">= 10" + } + }, + "node_modules/@peculiar/x509": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.10.0.tgz", + "integrity": "sha512-gdH6H8gWjAYoM4Yr6wPnRbzU77nU7xq/jipqYyyv5/AHTrulN2Z5DlnOSq9jjKrB+Ya0D6YJ2cGGtwkWDK75jA==", + "dependencies": { + "@peculiar/asn1-cms": "^2.3.8", + "@peculiar/asn1-csr": "^2.3.8", + "@peculiar/asn1-ecc": "^2.3.8", + "@peculiar/asn1-pkcs9": "^2.3.8", + "@peculiar/asn1-rsa": "^2.3.8", + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/asn1-x509": "^2.3.8", + "pvtsutils": "^1.3.5", + "reflect-metadata": "^0.2.2", + "tslib": "^2.6.2", + "tsyringe": "^4.8.0" + } + }, "node_modules/@phc/format": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz", @@ -5954,6 +6099,19 @@ "safer-buffer": "~2.1.0" } }, + "node_modules/asn1js": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.5.tgz", + "integrity": "sha512-FVnvrKJwpt9LP2lAMl8qZswRNm3T4q9CON+bxldk2iwk3FFpuwhx2FfinyitizWHsVYyaY+y5JzDR0rCMV5yTQ==", + "dependencies": { + "pvtsutils": "^1.3.2", + "pvutils": "^1.1.3", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/assert-plus": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", @@ -11717,6 +11875,22 @@ "node": ">=6" } }, + "node_modules/pvtsutils": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.5.tgz", + "integrity": "sha512-ARvb14YB9Nm2Xi6nBq1ZX6dAM0FsJnuk+31aUp4TrcZEdKUlSqOqsxJHUPJDNE3qiIp+iUPEIeR6Je/tgV7zsA==", + "dependencies": { + "tslib": "^2.6.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.3.tgz", + "integrity": "sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/qs": { "version": "6.11.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", @@ -11898,6 +12072,11 @@ "node": ">=4" } }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==" + }, "node_modules/regexp.prototype.flags": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.1.tgz", @@ -13681,6 +13860,22 @@ "fsevents": "~2.3.3" } }, + "node_modules/tsyringe": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.8.0.tgz", + "integrity": "sha512-YB1FG+axdxADa3ncEtRnQCFq/M0lALGLxSZeVNbTU8NqhOVc51nnv2CISTcvc1kyv6EGPtXVr0v6lWeDxiijOA==", + "dependencies": { + "tslib": "^1.9.3" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/tsyringe/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + }, "node_modules/tweetnacl": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", diff --git a/backend/package.json b/backend/package.json index 66e720dcf..f2a8582d6 100644 --- a/backend/package.json +++ b/backend/package.json @@ -86,6 +86,8 @@ "@node-saml/passport-saml": "^4.0.4", "@octokit/rest": "^20.0.2", "@octokit/webhooks-types": "^7.3.1", + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/x509": "^1.10.0", "@serdnam/pino-cloudwatch-transport": "^1.0.4", "@sindresorhus/slugify": "^2.2.1", "@ucast/mongo2js": "^1.3.4", diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 126cc0bad..e5cf3f79f 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -6,6 +6,7 @@ import { TAccessApprovalRequestServiceFactory } from "@app/ee/services/access-ap import { TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service"; import { TCreateAuditLogDTO } from "@app/ee/services/audit-log/audit-log-types"; import { TAuditLogStreamServiceFactory } from "@app/ee/services/audit-log-stream/audit-log-stream-service"; +import { TCertificateAuthorityCrlServiceFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-service"; import { TDynamicSecretServiceFactory } from "@app/ee/services/dynamic-secret/dynamic-secret-service"; import { TDynamicSecretLeaseServiceFactory } from "@app/ee/services/dynamic-secret-lease/dynamic-secret-lease-service"; import { TGroupServiceFactory } from "@app/ee/services/group/group-service"; @@ -30,6 +31,8 @@ import { TAuthPasswordFactory } from "@app/services/auth/auth-password-service"; import { TAuthSignupFactory } from "@app/services/auth/auth-signup-service"; import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type"; import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service"; +import { TCertificateServiceFactory } from "@app/services/certificate/certificate-service"; +import { TCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/certificate-authority-service"; import { TGroupProjectServiceFactory } from "@app/services/group-project/group-project-service"; import { TIdentityServiceFactory } from "@app/services/identity/identity-service"; import { TIdentityAccessTokenServiceFactory } from "@app/services/identity-access-token/identity-access-token-service"; @@ -138,6 +141,9 @@ declare module "fastify" { ldap: TLdapConfigServiceFactory; auditLog: TAuditLogServiceFactory; auditLogStream: TAuditLogStreamServiceFactory; + certificate: TCertificateServiceFactory; + certificateAuthority: TCertificateAuthorityServiceFactory; + certificateAuthorityCrl: TCertificateAuthorityCrlServiceFactory; secretScanning: TSecretScanningServiceFactory; license: TLicenseServiceFactory; trustedIp: TTrustedIpServiceFactory; diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index fbcaa3528..79342ea27 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -32,6 +32,27 @@ import { TBackupPrivateKey, TBackupPrivateKeyInsert, TBackupPrivateKeyUpdate, + TCertificateAuthorities, + TCertificateAuthoritiesInsert, + TCertificateAuthoritiesUpdate, + TCertificateAuthorityCerts, + TCertificateAuthorityCertsInsert, + TCertificateAuthorityCertsUpdate, + TCertificateAuthorityCrl, + TCertificateAuthorityCrlInsert, + TCertificateAuthorityCrlUpdate, + TCertificateAuthoritySecret, + TCertificateAuthoritySecretInsert, + TCertificateAuthoritySecretUpdate, + TCertificateBodies, + TCertificateBodiesInsert, + TCertificateBodiesUpdate, + TCertificates, + TCertificateSecrets, + TCertificateSecretsInsert, + TCertificateSecretsUpdate, + TCertificatesInsert, + TCertificatesUpdate, TDynamicSecretLeases, TDynamicSecretLeasesInsert, TDynamicSecretLeasesUpdate, @@ -260,6 +281,37 @@ declare module "knex/types/tables" { interface Tables { [TableName.Users]: Knex.CompositeTableType; [TableName.Groups]: Knex.CompositeTableType; + [TableName.CertificateAuthority]: Knex.CompositeTableType< + TCertificateAuthorities, + TCertificateAuthoritiesInsert, + TCertificateAuthoritiesUpdate + >; + [TableName.CertificateAuthorityCert]: Knex.CompositeTableType< + TCertificateAuthorityCerts, + TCertificateAuthorityCertsInsert, + TCertificateAuthorityCertsUpdate + >; + [TableName.CertificateAuthoritySecret]: Knex.CompositeTableType< + TCertificateAuthoritySecret, + TCertificateAuthoritySecretInsert, + TCertificateAuthoritySecretUpdate + >; + [TableName.CertificateAuthorityCrl]: Knex.CompositeTableType< + TCertificateAuthorityCrl, + TCertificateAuthorityCrlInsert, + TCertificateAuthorityCrlUpdate + >; + [TableName.Certificate]: Knex.CompositeTableType; + [TableName.CertificateBody]: Knex.CompositeTableType< + TCertificateBodies, + TCertificateBodiesInsert, + TCertificateBodiesUpdate + >; + [TableName.CertificateSecret]: Knex.CompositeTableType< + TCertificateSecrets, + TCertificateSecretsInsert, + TCertificateSecretsUpdate + >; [TableName.UserGroupMembership]: Knex.CompositeTableType< TUserGroupMembership, TUserGroupMembershipInsert, diff --git a/backend/src/db/migrations/20240612201737_certificate-mgmt.ts b/backend/src/db/migrations/20240612201737_certificate-mgmt.ts new file mode 100644 index 000000000..a738a6b64 --- /dev/null +++ b/backend/src/db/migrations/20240612201737_certificate-mgmt.ts @@ -0,0 +1,137 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.Project)) { + const doesProjectCertificateKeyIdExist = await knex.schema.hasColumn(TableName.Project, "kmsCertificateKeyId"); + await knex.schema.alterTable(TableName.Project, (t) => { + if (!doesProjectCertificateKeyIdExist) { + t.uuid("kmsCertificateKeyId").nullable(); + t.foreign("kmsCertificateKeyId").references("id").inTable(TableName.KmsKey); + } + }); + } + + if (!(await knex.schema.hasTable(TableName.CertificateAuthority))) { + await knex.schema.createTable(TableName.CertificateAuthority, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.uuid("parentCaId").nullable(); + t.foreign("parentCaId").references("id").inTable(TableName.CertificateAuthority).onDelete("CASCADE"); + t.string("projectId").notNullable(); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + t.string("type").notNullable(); // root / intermediate + t.string("status").notNullable(); // active / pending-certificate + t.string("friendlyName").notNullable(); + t.string("organization").notNullable(); + t.string("ou").notNullable(); + t.string("country").notNullable(); + t.string("province").notNullable(); + t.string("locality").notNullable(); + t.string("commonName").notNullable(); + t.string("dn").notNullable(); + t.string("serialNumber").nullable().unique(); + t.integer("maxPathLength").nullable(); + t.string("keyAlgorithm").notNullable(); + t.datetime("notBefore").nullable(); + t.datetime("notAfter").nullable(); + }); + } + + if (!(await knex.schema.hasTable(TableName.CertificateAuthorityCert))) { + // table to keep track of certificates belonging to CA + await knex.schema.createTable(TableName.CertificateAuthorityCert, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.uuid("caId").notNullable().unique(); + t.foreign("caId").references("id").inTable(TableName.CertificateAuthority).onDelete("CASCADE"); + t.binary("encryptedCertificate").notNullable(); + t.binary("encryptedCertificateChain").notNullable(); + }); + } + + if (!(await knex.schema.hasTable(TableName.CertificateAuthoritySecret))) { + await knex.schema.createTable(TableName.CertificateAuthoritySecret, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.uuid("caId").notNullable().unique(); + t.foreign("caId").references("id").inTable(TableName.CertificateAuthority).onDelete("CASCADE"); + t.binary("encryptedPrivateKey").notNullable(); + }); + } + + if (!(await knex.schema.hasTable(TableName.CertificateAuthorityCrl))) { + await knex.schema.createTable(TableName.CertificateAuthorityCrl, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.uuid("caId").notNullable().unique(); + t.foreign("caId").references("id").inTable(TableName.CertificateAuthority).onDelete("CASCADE"); + t.binary("encryptedCrl").notNullable(); + }); + } + + if (!(await knex.schema.hasTable(TableName.Certificate))) { + await knex.schema.createTable(TableName.Certificate, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.uuid("caId").notNullable(); + t.foreign("caId").references("id").inTable(TableName.CertificateAuthority).onDelete("CASCADE"); + t.string("status").notNullable(); // active / pending-certificate + t.string("serialNumber").notNullable().unique(); + t.string("friendlyName").notNullable(); + t.string("commonName").notNullable(); + t.datetime("notBefore").notNullable(); + t.datetime("notAfter").notNullable(); + t.datetime("revokedAt").nullable(); + t.integer("revocationReason").nullable(); // integer based on crl reason in RFC 5280 + }); + } + + if (!(await knex.schema.hasTable(TableName.CertificateBody))) { + await knex.schema.createTable(TableName.CertificateBody, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.uuid("certId").notNullable().unique(); + t.foreign("certId").references("id").inTable(TableName.Certificate).onDelete("CASCADE"); + t.binary("encryptedCertificate").notNullable(); + }); + } + + await createOnUpdateTrigger(knex, TableName.CertificateAuthority); + await createOnUpdateTrigger(knex, TableName.CertificateAuthorityCert); + await createOnUpdateTrigger(knex, TableName.CertificateAuthoritySecret); + await createOnUpdateTrigger(knex, TableName.Certificate); + await createOnUpdateTrigger(knex, TableName.CertificateBody); +} + +export async function down(knex: Knex): Promise { + // project + if (await knex.schema.hasTable(TableName.Project)) { + const doesProjectCertificateKeyIdExist = await knex.schema.hasColumn(TableName.Project, "kmsCertificateKeyId"); + await knex.schema.alterTable(TableName.Project, (t) => { + if (doesProjectCertificateKeyIdExist) t.dropColumn("kmsCertificateKeyId"); + }); + } + + // certificates + await knex.schema.dropTableIfExists(TableName.CertificateBody); + await dropOnUpdateTrigger(knex, TableName.CertificateBody); + + await knex.schema.dropTableIfExists(TableName.Certificate); + await dropOnUpdateTrigger(knex, TableName.Certificate); + + // certificate authorities + await knex.schema.dropTableIfExists(TableName.CertificateAuthoritySecret); + await dropOnUpdateTrigger(knex, TableName.CertificateAuthoritySecret); + + await knex.schema.dropTableIfExists(TableName.CertificateAuthorityCrl); + await dropOnUpdateTrigger(knex, TableName.CertificateAuthorityCrl); + + await knex.schema.dropTableIfExists(TableName.CertificateAuthorityCert); + await dropOnUpdateTrigger(knex, TableName.CertificateAuthorityCert); + + await knex.schema.dropTableIfExists(TableName.CertificateAuthority); + await dropOnUpdateTrigger(knex, TableName.CertificateAuthority); +} diff --git a/backend/src/db/schemas/certificate-authorities.ts b/backend/src/db/schemas/certificate-authorities.ts new file mode 100644 index 000000000..16f303b5c --- /dev/null +++ b/backend/src/db/schemas/certificate-authorities.ts @@ -0,0 +1,37 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const CertificateAuthoritiesSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + parentCaId: z.string().uuid().nullable().optional(), + projectId: z.string(), + type: z.string(), + status: z.string(), + friendlyName: z.string(), + organization: z.string(), + ou: z.string(), + country: z.string(), + province: z.string(), + locality: z.string(), + commonName: z.string(), + dn: z.string(), + serialNumber: z.string().nullable().optional(), + maxPathLength: z.number().nullable().optional(), + keyAlgorithm: z.string(), + notBefore: z.date().nullable().optional(), + notAfter: z.date().nullable().optional() +}); + +export type TCertificateAuthorities = z.infer; +export type TCertificateAuthoritiesInsert = Omit, TImmutableDBKeys>; +export type TCertificateAuthoritiesUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/certificate-authority-certs.ts b/backend/src/db/schemas/certificate-authority-certs.ts new file mode 100644 index 000000000..96ad54f00 --- /dev/null +++ b/backend/src/db/schemas/certificate-authority-certs.ts @@ -0,0 +1,25 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const CertificateAuthorityCertsSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + caId: z.string().uuid(), + encryptedCertificate: zodBuffer, + encryptedCertificateChain: zodBuffer +}); + +export type TCertificateAuthorityCerts = z.infer; +export type TCertificateAuthorityCertsInsert = Omit, TImmutableDBKeys>; +export type TCertificateAuthorityCertsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/certificate-authority-crl.ts b/backend/src/db/schemas/certificate-authority-crl.ts new file mode 100644 index 000000000..204a0c60c --- /dev/null +++ b/backend/src/db/schemas/certificate-authority-crl.ts @@ -0,0 +1,24 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const CertificateAuthorityCrlSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + caId: z.string().uuid(), + encryptedCrl: zodBuffer +}); + +export type TCertificateAuthorityCrl = z.infer; +export type TCertificateAuthorityCrlInsert = Omit, TImmutableDBKeys>; +export type TCertificateAuthorityCrlUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/certificate-authority-secret.ts b/backend/src/db/schemas/certificate-authority-secret.ts new file mode 100644 index 000000000..36ab1c506 --- /dev/null +++ b/backend/src/db/schemas/certificate-authority-secret.ts @@ -0,0 +1,27 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const CertificateAuthoritySecretSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + caId: z.string().uuid(), + encryptedPrivateKey: zodBuffer +}); + +export type TCertificateAuthoritySecret = z.infer; +export type TCertificateAuthoritySecretInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TCertificateAuthoritySecretUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/certificate-bodies.ts b/backend/src/db/schemas/certificate-bodies.ts new file mode 100644 index 000000000..75afbddbd --- /dev/null +++ b/backend/src/db/schemas/certificate-bodies.ts @@ -0,0 +1,22 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const CertificateBodiesSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + certId: z.string().uuid(), + encryptedCertificate: zodBuffer +}); + +export type TCertificateBodies = z.infer; +export type TCertificateBodiesInsert = Omit, TImmutableDBKeys>; +export type TCertificateBodiesUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/certificate-secrets.ts b/backend/src/db/schemas/certificate-secrets.ts new file mode 100644 index 000000000..f8cad74f1 --- /dev/null +++ b/backend/src/db/schemas/certificate-secrets.ts @@ -0,0 +1,21 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const CertificateSecretsSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + certId: z.string().uuid(), + pk: z.string(), + sk: z.string() +}); + +export type TCertificateSecrets = z.infer; +export type TCertificateSecretsInsert = Omit, TImmutableDBKeys>; +export type TCertificateSecretsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/certificates.ts b/backend/src/db/schemas/certificates.ts new file mode 100644 index 000000000..b635420d5 --- /dev/null +++ b/backend/src/db/schemas/certificates.ts @@ -0,0 +1,27 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const CertificatesSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + caId: z.string().uuid(), + status: z.string(), + serialNumber: z.string(), + friendlyName: z.string(), + commonName: z.string(), + notBefore: z.date(), + notAfter: z.date(), + revokedAt: z.date().nullable().optional(), + revocationReason: z.number().nullable().optional() +}); + +export type TCertificates = z.infer; +export type TCertificatesInsert = Omit, TImmutableDBKeys>; +export type TCertificatesUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 5771cb669..df126b7f0 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -8,6 +8,13 @@ export * from "./audit-logs"; export * from "./auth-token-sessions"; export * from "./auth-tokens"; export * from "./backup-private-key"; +export * from "./certificate-authorities"; +export * from "./certificate-authority-certs"; +export * from "./certificate-authority-crl"; +export * from "./certificate-authority-secret"; +export * from "./certificate-bodies"; +export * from "./certificate-secrets"; +export * from "./certificates"; export * from "./dynamic-secret-leases"; export * from "./dynamic-secrets"; export * from "./git-app-install-sessions"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 5d2213003..d7a5e6de1 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -2,6 +2,13 @@ import { z } from "zod"; export enum TableName { Users = "users", + CertificateAuthority = "certificate_authorities", + CertificateAuthorityCert = "certificate_authority_certs", + CertificateAuthoritySecret = "certificate_authority_secret", + CertificateAuthorityCrl = "certificate_authority_crl", + Certificate = "certificates", + CertificateBody = "certificate_bodies", + CertificateSecret = "certificate_secrets", Groups = "groups", GroupProjectMembership = "group_project_memberships", GroupProjectMembershipRole = "group_project_membership_roles", diff --git a/backend/src/db/schemas/projects.ts b/backend/src/db/schemas/projects.ts index dd7e99990..91035ab8e 100644 --- a/backend/src/db/schemas/projects.ts +++ b/backend/src/db/schemas/projects.ts @@ -17,6 +17,7 @@ export const ProjectsSchema = z.object({ updatedAt: z.date(), version: z.number().default(1), upgradeStatus: z.string().nullable().optional(), + kmsCertificateKeyId: z.string().uuid().nullable().optional(), pitVersionLimit: z.number().default(10) }); diff --git a/backend/src/ee/routes/v1/certificate-authority-crl-router.ts b/backend/src/ee/routes/v1/certificate-authority-crl-router.ts new file mode 100644 index 000000000..10792f508 --- /dev/null +++ b/backend/src/ee/routes/v1/certificate-authority-crl-router.ts @@ -0,0 +1,86 @@ +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { CERTIFICATE_AUTHORITIES } from "@app/lib/api-docs"; +import { readLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerCaCrlRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/:caId/crl", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Get CRL of the CA", + params: z.object({ + caId: z.string().trim().describe(CERTIFICATE_AUTHORITIES.GET_CRL.caId) + }), + response: { + 200: z.object({ + crl: z.string().describe(CERTIFICATE_AUTHORITIES.GET_CRL.crl) + }) + } + }, + handler: async (req) => { + const { crl, ca } = await server.services.certificateAuthorityCrl.getCaCrl({ + caId: req.params.caId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.GET_CA_CRL, + metadata: { + caId: ca.id, + dn: ca.dn + } + } + }); + + return { + crl + }; + } + }); + + // server.route({ + // method: "GET", + // url: "/:caId/crl/rotate", + // config: { + // rateLimit: writeLimit + // }, + // onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + // schema: { + // description: "Rotate CRL of the CA", + // params: z.object({ + // caId: z.string().trim() + // }), + // response: { + // 200: z.object({ + // message: z.string() + // }) + // } + // }, + // handler: async (req) => { + // await server.services.certificateAuthority.rotateCaCrl({ + // caId: req.params.caId, + // actor: req.permission.type, + // actorId: req.permission.id, + // actorAuthMethod: req.permission.authMethod, + // actorOrgId: req.permission.orgId + // }); + // return { + // message: "Successfully rotated CA CRL" + // }; + // } + // }); +}; diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index e7c8f2dfe..d04bd86fd 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -1,6 +1,7 @@ import { registerAccessApprovalPolicyRouter } from "./access-approval-policy-router"; import { registerAccessApprovalRequestRouter } from "./access-approval-request-router"; import { registerAuditLogStreamRouter } from "./audit-log-stream-router"; +import { registerCaCrlRouter } from "./certificate-authority-crl-router"; import { registerDynamicSecretLeaseRouter } from "./dynamic-secret-lease-router"; import { registerDynamicSecretRouter } from "./dynamic-secret-router"; import { registerGroupRouter } from "./group-router"; @@ -56,6 +57,13 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { { prefix: "/dynamic-secrets" } ); + await server.register( + async (pkiRouter) => { + await pkiRouter.register(registerCaCrlRouter, { prefix: "/ca" }); + }, + { prefix: "/pki" } + ); + await server.register(registerSamlRouter, { prefix: "/sso" }); await server.register(registerScimRouter, { prefix: "/scim" }); await server.register(registerLdapRouter, { prefix: "/ldap" }); 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 415814998..8a0d2aef9 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -1,5 +1,6 @@ import { TProjectPermission } from "@app/lib/types"; import { ActorType } from "@app/services/auth/auth-type"; +import { CaStatus } from "@app/services/certificate-authority/certificate-authority-types"; import { TIdentityTrustedIp } from "@app/services/identity/identity-types"; export type TListProjectAuditLogDTO = { @@ -104,7 +105,21 @@ export enum EventType { SECRET_APPROVAL_MERGED = "secret-approval-merged", SECRET_APPROVAL_REQUEST = "secret-approval-request", SECRET_APPROVAL_CLOSED = "secret-approval-closed", - SECRET_APPROVAL_REOPENED = "secret-approval-reopened" + SECRET_APPROVAL_REOPENED = "secret-approval-reopened", + CREATE_CA = "create-certificate-authority", + GET_CA = "get-certificate-authority", + UPDATE_CA = "update-certificate-authority", + DELETE_CA = "delete-certificate-authority", + GET_CA_CSR = "get-certificate-authority-csr", + GET_CA_CERT = "get-certificate-authority-cert", + SIGN_INTERMEDIATE = "sign-intermediate", + IMPORT_CA_CERT = "import-certificate-authority-cert", + GET_CA_CRL = "get-certificate-authority-crl", + ISSUE_CERT = "issue-cert", + GET_CERT = "get-cert", + DELETE_CERT = "delete-cert", + REVOKE_CERT = "revoke-cert", + GET_CERT_BODY = "get-cert-body" } interface UserActorMetadata { @@ -843,6 +858,125 @@ interface SecretApprovalRequest { }; } +interface CreateCa { + type: EventType.CREATE_CA; + metadata: { + caId: string; + dn: string; + }; +} + +interface GetCa { + type: EventType.GET_CA; + metadata: { + caId: string; + dn: string; + }; +} + +interface UpdateCa { + type: EventType.UPDATE_CA; + metadata: { + caId: string; + dn: string; + status: CaStatus; + }; +} + +interface DeleteCa { + type: EventType.DELETE_CA; + metadata: { + caId: string; + dn: string; + }; +} + +interface GetCaCsr { + type: EventType.GET_CA_CSR; + metadata: { + caId: string; + dn: string; + }; +} + +interface GetCaCert { + type: EventType.GET_CA_CERT; + metadata: { + caId: string; + dn: string; + }; +} + +interface SignIntermediate { + type: EventType.SIGN_INTERMEDIATE; + metadata: { + caId: string; + dn: string; + serialNumber: string; + }; +} + +interface ImportCaCert { + type: EventType.IMPORT_CA_CERT; + metadata: { + caId: string; + dn: string; + }; +} + +interface GetCaCrl { + type: EventType.GET_CA_CRL; + metadata: { + caId: string; + dn: string; + }; +} + +interface IssueCert { + type: EventType.ISSUE_CERT; + metadata: { + caId: string; + dn: string; + serialNumber: string; + }; +} + +interface GetCert { + type: EventType.GET_CERT; + metadata: { + certId: string; + cn: string; + serialNumber: string; + }; +} + +interface DeleteCert { + type: EventType.DELETE_CERT; + metadata: { + certId: string; + cn: string; + serialNumber: string; + }; +} + +interface RevokeCert { + type: EventType.REVOKE_CERT; + metadata: { + certId: string; + cn: string; + serialNumber: string; + }; +} + +interface GetCertBody { + type: EventType.GET_CERT_BODY; + metadata: { + certId: string; + cn: string; + serialNumber: string; + }; +} + export type Event = | GetSecretsEvent | GetSecretEvent @@ -910,4 +1044,18 @@ export type Event = | SecretApprovalMerge | SecretApprovalClosed | SecretApprovalRequest - | SecretApprovalReopened; + | SecretApprovalReopened + | CreateCa + | GetCa + | UpdateCa + | DeleteCa + | GetCaCsr + | GetCaCert + | SignIntermediate + | ImportCaCert + | GetCaCrl + | IssueCert + | GetCert + | DeleteCert + | RevokeCert + | GetCertBody; diff --git a/backend/src/ee/services/certificate-authority-crl/certificate-authority-crl-dal.ts b/backend/src/ee/services/certificate-authority-crl/certificate-authority-crl-dal.ts new file mode 100644 index 000000000..d367e1616 --- /dev/null +++ b/backend/src/ee/services/certificate-authority-crl/certificate-authority-crl-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TCertificateAuthorityCrlDALFactory = ReturnType; + +export const certificateAuthorityCrlDALFactory = (db: TDbClient) => { + const caCrlOrm = ormify(db, TableName.CertificateAuthorityCrl); + return caCrlOrm; +}; diff --git a/backend/src/ee/services/certificate-authority-crl/certificate-authority-crl-service.ts b/backend/src/ee/services/certificate-authority-crl/certificate-authority-crl-service.ts new file mode 100644 index 000000000..c8b56561e --- /dev/null +++ b/backend/src/ee/services/certificate-authority-crl/certificate-authority-crl-service.ts @@ -0,0 +1,172 @@ +import { ForbiddenError } from "@casl/ability"; +import * as x509 from "@peculiar/x509"; + +import { TCertificateAuthorityCrlDALFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-dal"; +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 { BadRequestError } from "@app/lib/errors"; +import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; + +import { TGetCrl } from "./certificate-authority-crl-types"; + +type TCertificateAuthorityCrlServiceFactoryDep = { + certificateAuthorityDAL: Pick; + certificateAuthorityCrlDAL: Pick; + projectDAL: Pick; + kmsService: Pick; + permissionService: Pick; + licenseService: Pick; +}; + +export type TCertificateAuthorityCrlServiceFactory = ReturnType; + +export const certificateAuthorityCrlServiceFactory = ({ + certificateAuthorityDAL, + certificateAuthorityCrlDAL, + projectDAL, + kmsService, + permissionService, + licenseService +}: TCertificateAuthorityCrlServiceFactoryDep) => { + /** + * Return the Certificate Revocation List (CRL) for CA with id [caId] + */ + const getCaCrl = async ({ caId, actorId, actorAuthMethod, actor, actorOrgId }: TGetCrl) => { + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) throw new BadRequestError({ message: "CA not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.CertificateAuthorities + ); + + const plan = await licenseService.getPlan(actorOrgId); + if (!plan.caCrl) + throw new BadRequestError({ + message: + "Failed to get CA certificate revocation list (CRL) due to plan restriction. Upgrade plan to get the CA CRL." + }); + + const caCrl = await certificateAuthorityCrlDAL.findOne({ caId: ca.id }); + if (!caCrl) throw new BadRequestError({ message: "CRL not found" }); + + const keyId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + + const decryptedCrl = await kmsService.decrypt({ + kmsId: keyId, + cipherTextBlob: caCrl.encryptedCrl + }); + + const crl = new x509.X509Crl(decryptedCrl); + + const base64crl = crl.toString("base64"); + const crlPem = `-----BEGIN X509 CRL-----\n${base64crl.match(/.{1,64}/g)?.join("\n")}\n-----END X509 CRL-----`; + + return { + crl: crlPem, + ca + }; + }; + + // const rotateCaCrl = async ({ caId, actorId, actorAuthMethod, actor, actorOrgId }: TRotateCrlDTO) => { + // const ca = await certificateAuthorityDAL.findById(caId); + // if (!ca) throw new BadRequestError({ message: "CA not found" }); + + // const { permission } = await permissionService.getProjectPermission( + // actor, + // actorId, + // ca.projectId, + // actorAuthMethod, + // actorOrgId + // ); + + // ForbiddenError.from(permission).throwUnlessCan( + // ProjectPermissionActions.Read, + // ProjectPermissionSub.CertificateAuthorities + // ); + + // const caSecret = await certificateAuthoritySecretDAL.findOne({ caId: ca.id }); + + // const alg = keyAlgorithmToAlgCfg(ca.keyAlgorithm as CertKeyAlgorithm); + + // const keyId = await getProjectKmsCertificateKeyId({ + // projectId: ca.projectId, + // projectDAL, + // kmsService + // }); + + // const privateKey = await kmsService.decrypt({ + // kmsId: keyId, + // cipherTextBlob: caSecret.encryptedPrivateKey + // }); + + // const skObj = crypto.createPrivateKey({ key: privateKey, format: "der", type: "pkcs8" }); + // const sk = await crypto.subtle.importKey("pkcs8", skObj.export({ format: "der", type: "pkcs8" }), alg, true, [ + // "sign" + // ]); + + // const revokedCerts = await certificateDAL.find({ + // caId: ca.id, + // status: CertStatus.REVOKED + // }); + + // const crl = await x509.X509CrlGenerator.create({ + // issuer: ca.dn, + // thisUpdate: new Date(), + // nextUpdate: new Date("2025/12/12"), + // entries: revokedCerts.map((revokedCert) => { + // return { + // serialNumber: revokedCert.serialNumber, + // revocationDate: new Date(revokedCert.revokedAt as Date), + // reason: revokedCert.revocationReason as number, + // invalidity: new Date("2022/01/01"), + // issuer: ca.dn + // }; + // }), + // signingAlgorithm: alg, + // signingKey: sk + // }); + + // const { cipherTextBlob: encryptedCrl } = await kmsService.encrypt({ + // kmsId: keyId, + // plainText: Buffer.from(new Uint8Array(crl.rawData)) + // }); + + // await certificateAuthorityCrlDAL.update( + // { + // caId: ca.id + // }, + // { + // encryptedCrl + // } + // ); + + // const base64crl = crl.toString("base64"); + // const crlPem = `-----BEGIN X509 CRL-----\n${base64crl.match(/.{1,64}/g)?.join("\n")}\n-----END X509 CRL-----`; + + // return { + // crl: crlPem + // }; + // }; + + return { + getCaCrl + // rotateCaCrl + }; +}; diff --git a/backend/src/ee/services/certificate-authority-crl/certificate-authority-crl-types.ts b/backend/src/ee/services/certificate-authority-crl/certificate-authority-crl-types.ts new file mode 100644 index 000000000..fc31e9eef --- /dev/null +++ b/backend/src/ee/services/certificate-authority-crl/certificate-authority-crl-types.ts @@ -0,0 +1,5 @@ +import { TProjectPermission } from "@app/lib/types"; + +export type TGetCrl = { + caId: string; +} & Omit; diff --git a/backend/src/ee/services/license/__mocks__/licence-fns.ts b/backend/src/ee/services/license/__mocks__/licence-fns.ts index b5cbf103e..ddbffba45 100644 --- a/backend/src/ee/services/license/__mocks__/licence-fns.ts +++ b/backend/src/ee/services/license/__mocks__/licence-fns.ts @@ -25,6 +25,7 @@ export const getDefaultOnPremFeatures = () => { trial_end: null, has_used_trial: true, secretApproval: false, - secretRotation: true + secretRotation: true, + caCrl: false }; }; diff --git a/backend/src/ee/services/license/licence-fns.ts b/backend/src/ee/services/license/licence-fns.ts index 189a3c4e0..9d2c5a472 100644 --- a/backend/src/ee/services/license/licence-fns.ts +++ b/backend/src/ee/services/license/licence-fns.ts @@ -34,7 +34,8 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ trial_end: null, has_used_trial: true, secretApproval: false, - secretRotation: true + secretRotation: true, + caCrl: false }); export const setupLicenceRequestWithStore = (baseURL: string, refreshUrl: string, licenseKey: string) => { diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index 0c8fdc197..e23ff2c84 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -52,6 +52,7 @@ export type TFeatureSet = { has_used_trial: true; secretApproval: false; secretRotation: true; + caCrl: false; }; export type TOrgPlansTableDTO = { diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index b24024bd4..4853faf61 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -26,7 +26,9 @@ export enum ProjectPermissionSub { SecretRollback = "secret-rollback", SecretApproval = "secret-approval", SecretRotation = "secret-rotation", - Identity = "identity" + Identity = "identity", + CertificateAuthorities = "certificate-authorities", + Certificates = "certificates" } type SubjectFields = { @@ -53,6 +55,8 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.SecretApproval] | [ProjectPermissionActions, ProjectPermissionSub.SecretRotation] | [ProjectPermissionActions, ProjectPermissionSub.Identity] + | [ProjectPermissionActions, ProjectPermissionSub.CertificateAuthorities] + | [ProjectPermissionActions, ProjectPermissionSub.Certificates] | [ProjectPermissionActions.Delete, ProjectPermissionSub.Project] | [ProjectPermissionActions.Edit, ProjectPermissionSub.Project] | [ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback] @@ -139,6 +143,17 @@ const buildAdminPermissionRules = () => { can(ProjectPermissionActions.Edit, ProjectPermissionSub.IpAllowList); can(ProjectPermissionActions.Delete, ProjectPermissionSub.IpAllowList); + // double check if all CRUD are needed for CA and Certificates + can(ProjectPermissionActions.Read, ProjectPermissionSub.CertificateAuthorities); + can(ProjectPermissionActions.Create, ProjectPermissionSub.CertificateAuthorities); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.CertificateAuthorities); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.CertificateAuthorities); + + can(ProjectPermissionActions.Read, ProjectPermissionSub.Certificates); + can(ProjectPermissionActions.Create, ProjectPermissionSub.Certificates); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.Certificates); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.Certificates); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.Project); can(ProjectPermissionActions.Delete, ProjectPermissionSub.Project); @@ -205,6 +220,14 @@ const buildMemberPermissionRules = () => { can(ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs); can(ProjectPermissionActions.Read, ProjectPermissionSub.IpAllowList); + // double check if all CRUD are needed for CA and Certificates + can(ProjectPermissionActions.Read, ProjectPermissionSub.CertificateAuthorities); + + can(ProjectPermissionActions.Read, ProjectPermissionSub.Certificates); + can(ProjectPermissionActions.Create, ProjectPermissionSub.Certificates); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.Certificates); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.Certificates); + return rules; }; @@ -229,6 +252,8 @@ const buildViewerPermissionRules = () => { can(ProjectPermissionActions.Read, ProjectPermissionSub.Tags); can(ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs); can(ProjectPermissionActions.Read, ProjectPermissionSub.IpAllowList); + can(ProjectPermissionActions.Read, ProjectPermissionSub.CertificateAuthorities); + can(ProjectPermissionActions.Read, ProjectPermissionSub.Certificates); return rules; }; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 5c67c489e..7cbfd88a0 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -730,6 +730,102 @@ export const AUDIT_LOG_STREAMS = { } }; +export const CERTIFICATE_AUTHORITIES = { + CREATE: { + projectSlug: "Slug of the project to create the CA in.", + type: "The type of CA to create", + friendlyName: "A friendly name for the CA", + organization: "The organization (O) for the CA", + ou: "The organization unit (OU) for the CA", + country: "The country name (C) for the CA", + province: "The state of province name for the CA", + locality: "The locality name for the CA", + commonName: "The common name (CN) for the CA", + notBefore: "The date and time when the CA becomes valid in YYYY-MM-DDTHH:mm:ss.sssZ format", + notAfter: "The date and time when the CA expires in YYYY-MM-DDTHH:mm:ss.sssZ format", + maxPathLength: + "The maximum number of intermediate CAs that may follow this CA in the certificate / CA chain. A maxPathLength of -1 implies no path limit on the chain.", + keyAlgorithm: + "The type of public key algorithm and size, in bits, of the key pair for the CA; when you create an intermediate CA, you must use a key algorithm supported by the parent CA." + }, + GET: { + caId: "The ID of the CA to get" + }, + UPDATE: { + caId: "The ID of the CA to update", + status: "The status of the CA to update to. This can be one of active or disabled" + }, + DELETE: { + caId: "The ID of the CA to delete" + }, + GET_CSR: { + caId: "The ID of the CA to generate CSR from", + csr: "The generated CSR from the CA" + }, + GET_CERT: { + caId: "The ID of the CA to get the certificate body and certificate chain from", + certificate: "The certificate body of the CA", + certificateChain: "The certificate chain of the CA", + serialNumber: "The serial number of the CA certificate" + }, + SIGN_INTERMEDIATE: { + caId: "The ID of the CA to sign the intermediate certificate with", + csr: "The CSR to sign with the CA", + notBefore: "The date and time when the intermediate CA becomes valid in YYYY-MM-DDTHH:mm:ss.sssZ format", + notAfter: "The date and time when the intermediate CA expires in YYYY-MM-DDTHH:mm:ss.sssZ format", + maxPathLength: + "The maximum number of intermediate CAs that may follow this CA in the certificate / CA chain. A maxPathLength of -1 implies no path limit on the chain.", + certificate: "The signed intermediate certificate", + certificateChain: "The certificate chain of the intermediate certificate", + issuingCaCertificate: "The certificate of the issuing CA", + serialNumber: "The serial number of the intermediate certificate" + }, + IMPORT_CERT: { + caId: "The ID of the CA to import the certificate for", + certificate: "The certificate body to import", + certificateChain: "The certificate chain to import" + }, + ISSUE_CERT: { + caId: "The ID of the CA to issue the certificate from", + friendlyName: "A friendly name for the certificate", + commonName: "The common name (CN) for the certificate", + ttl: "The time to live for the certificate such as 1m, 1h, 1d, 1y, ...", + notBefore: "The date and time when the certificate becomes valid in YYYY-MM-DDTHH:mm:ss.sssZ format", + notAfter: "The date and time when the certificate expires in YYYY-MM-DDTHH:mm:ss.sssZ format", + certificate: "The issued certificate", + issuingCaCertificate: "The certificate of the issuing CA", + certificateChain: "The certificate chain of the issued certificate", + privateKey: "The private key of the issued certificate", + serialNumber: "The serial number of the issued certificate" + }, + GET_CRL: { + caId: "The ID of the CA to get the certificate revocation list (CRL) for", + crl: "The certificate revocation list (CRL) of the CA" + } +}; + +export const CERTIFICATES = { + GET: { + serialNumber: "The serial number of the certificate to get" + }, + REVOKE: { + serialNumber: + "The serial number of the certificate to revoke. The revoked certificate will be added to the certificate revocation list (CRL) of the CA.", + revocationReason: "The reason for revoking the certificate.", + revokedAt: "The date and time when the certificate was revoked", + serialNumberRes: "The serial number of the revoked certificate." + }, + DELETE: { + serialNumber: "The serial number of the certificate to delete" + }, + GET_CERT: { + serialNumber: "The serial number of the certificate to get the certificate body and certificate chain for", + certificate: "The certificate body of the certificate", + certificateChain: "The certificate chain of the certificate", + serialNumberRes: "The serial number of the certificate" + } +}; + export const PROJECT_ROLE = { CREATE: { projectSlug: "Slug of the project to create the role for.", diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts index 7046058b7..d51a8e683 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -23,6 +23,7 @@ export enum QueueName { SecretPushEventScan = "secret-push-event-scan", UpgradeProjectToGhost = "upgrade-project-to-ghost", DynamicSecretRevocation = "dynamic-secret-revocation", + CaCrlRotation = "ca-crl-rotation", SecretReplication = "secret-replication", SecretSync = "secret-sync" // parent queue to push integration sync, webhook, and secret replication } @@ -41,6 +42,7 @@ export enum QueueJobs { UpgradeProjectToGhost = "upgrade-project-to-ghost-job", DynamicSecretRevocation = "dynamic-secret-revocation", DynamicSecretPruning = "dynamic-secret-pruning", + CaCrlRotation = "ca-crl-rotation-job", SecretReplication = "secret-replication", SecretSync = "secret-sync" // parent queue to push integration sync, webhook, and secret replication } @@ -55,7 +57,6 @@ export type TQueueJobTypes = { }; name: QueueJobs.SecretReminder; }; - [QueueName.SecretRotation]: { payload: { rotationId: string }; name: QueueJobs.SecretRotation; @@ -121,6 +122,12 @@ export type TQueueJobTypes = { dynamicSecretCfgId: string; }; }; + [QueueName.CaCrlRotation]: { + name: QueueJobs.CaCrlRotation; + payload: { + caId: string; + }; + }; [QueueName.SecretReplication]: { name: QueueJobs.SecretReplication; payload: TSyncSecretsDTO; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 395f6fbf4..326fbafa6 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -14,6 +14,8 @@ import { auditLogQueueServiceFactory } from "@app/ee/services/audit-log/audit-lo import { auditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service"; import { auditLogStreamDALFactory } from "@app/ee/services/audit-log-stream/audit-log-stream-dal"; import { auditLogStreamServiceFactory } from "@app/ee/services/audit-log-stream/audit-log-stream-service"; +import { certificateAuthorityCrlDALFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-dal"; +import { certificateAuthorityCrlServiceFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-service"; import { dynamicSecretDALFactory } from "@app/ee/services/dynamic-secret/dynamic-secret-dal"; import { dynamicSecretServiceFactory } from "@app/ee/services/dynamic-secret/dynamic-secret-service"; import { buildDynamicSecretProviders } from "@app/ee/services/dynamic-secret/providers"; @@ -74,6 +76,14 @@ import { authPaswordServiceFactory } from "@app/services/auth/auth-password-serv import { authSignupServiceFactory } from "@app/services/auth/auth-signup-service"; import { tokenDALFactory } from "@app/services/auth-token/auth-token-dal"; import { tokenServiceFactory } from "@app/services/auth-token/auth-token-service"; +import { certificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal"; +import { certificateDALFactory } from "@app/services/certificate/certificate-dal"; +import { certificateServiceFactory } from "@app/services/certificate/certificate-service"; +import { certificateAuthorityCertDALFactory } from "@app/services/certificate-authority/certificate-authority-cert-dal"; +import { certificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal"; +import { certificateAuthorityQueueFactory } from "@app/services/certificate-authority/certificate-authority-queue"; +import { certificateAuthoritySecretDALFactory } from "@app/services/certificate-authority/certificate-authority-secret-dal"; +import { certificateAuthorityServiceFactory } from "@app/services/certificate-authority/certificate-authority-service"; import { groupProjectDALFactory } from "@app/services/group-project/group-project-dal"; import { groupProjectMembershipRoleDALFactory } from "@app/services/group-project/group-project-membership-role-dal"; import { groupProjectServiceFactory } from "@app/services/group-project/group-project-service"; @@ -514,6 +524,58 @@ export const registerRoutes = async ( projectUserMembershipRoleDAL }); + const certificateAuthorityDAL = certificateAuthorityDALFactory(db); + const certificateAuthorityCertDAL = certificateAuthorityCertDALFactory(db); + const certificateAuthoritySecretDAL = certificateAuthoritySecretDALFactory(db); + const certificateAuthorityCrlDAL = certificateAuthorityCrlDALFactory(db); + + const certificateDAL = certificateDALFactory(db); + const certificateBodyDAL = certificateBodyDALFactory(db); + + const certificateService = certificateServiceFactory({ + certificateDAL, + certificateBodyDAL, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + certificateAuthorityCrlDAL, + certificateAuthoritySecretDAL, + projectDAL, + kmsService, + permissionService + }); + + const certificateAuthorityQueue = certificateAuthorityQueueFactory({ + certificateAuthorityCrlDAL, + certificateAuthorityDAL, + certificateAuthoritySecretDAL, + certificateDAL, + projectDAL, + kmsService, + queueService + }); + + const certificateAuthorityService = certificateAuthorityServiceFactory({ + certificateAuthorityDAL, + certificateAuthorityCertDAL, + certificateAuthoritySecretDAL, + certificateAuthorityCrlDAL, + certificateAuthorityQueue, + certificateDAL, + certificateBodyDAL, + projectDAL, + kmsService, + permissionService + }); + + const certificateAuthorityCrlService = certificateAuthorityCrlServiceFactory({ + certificateAuthorityDAL, + certificateAuthorityCrlDAL, + projectDAL, + kmsService, + permissionService, + licenseService + }); + const projectService = projectServiceFactory({ permissionService, projectDAL, @@ -530,6 +592,8 @@ export const registerRoutes = async ( projectMembershipDAL, folderDAL, licenseService, + certificateAuthorityDAL, + certificateDAL, projectUserMembershipRoleDAL, identityProjectMembershipRoleDAL, keyStore @@ -898,6 +962,9 @@ export const registerRoutes = async ( ldap: ldapService, auditLog: auditLogService, auditLogStream: auditLogStreamService, + certificate: certificateService, + certificateAuthority: certificateAuthorityService, + certificateAuthorityCrl: certificateAuthorityCrlService, secretScanning: secretScanningService, license: licenseService, trustedIp: trustedIpService, diff --git a/backend/src/server/routes/v1/certificate-authority-router.ts b/backend/src/server/routes/v1/certificate-authority-router.ts new file mode 100644 index 000000000..7573c0bd2 --- /dev/null +++ b/backend/src/server/routes/v1/certificate-authority-router.ts @@ -0,0 +1,515 @@ +import ms from "ms"; +import { z } from "zod"; + +import { CertificateAuthoritiesSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { CERTIFICATE_AUTHORITIES } from "@app/lib/api-docs"; +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 { CertKeyAlgorithm } from "@app/services/certificate/certificate-types"; +import { CaStatus, CaType } from "@app/services/certificate-authority/certificate-authority-types"; +import { validateCaDateField } from "@app/services/certificate-authority/certificate-authority-validators"; + +export const registerCaRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Create CA", + body: z + .object({ + projectSlug: z.string().trim().describe(CERTIFICATE_AUTHORITIES.CREATE.projectSlug), + type: z.nativeEnum(CaType).describe(CERTIFICATE_AUTHORITIES.CREATE.type), + friendlyName: z.string().optional().describe(CERTIFICATE_AUTHORITIES.CREATE.friendlyName), + commonName: z.string().trim().describe(CERTIFICATE_AUTHORITIES.CREATE.commonName), + organization: z.string().trim().describe(CERTIFICATE_AUTHORITIES.CREATE.organization), + ou: z.string().trim().describe(CERTIFICATE_AUTHORITIES.CREATE.ou), + country: z.string().trim().describe(CERTIFICATE_AUTHORITIES.CREATE.country), + province: z.string().trim().describe(CERTIFICATE_AUTHORITIES.CREATE.province), + locality: z.string().trim().describe(CERTIFICATE_AUTHORITIES.CREATE.locality), + // format: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#date_time_string_format + notBefore: validateCaDateField.optional().describe(CERTIFICATE_AUTHORITIES.CREATE.notBefore), + notAfter: validateCaDateField.optional().describe(CERTIFICATE_AUTHORITIES.CREATE.notAfter), + maxPathLength: z.number().min(-1).default(-1).describe(CERTIFICATE_AUTHORITIES.CREATE.maxPathLength), + keyAlgorithm: z + .nativeEnum(CertKeyAlgorithm) + .default(CertKeyAlgorithm.RSA_2048) + .describe(CERTIFICATE_AUTHORITIES.CREATE.keyAlgorithm) + }) + .refine( + (data) => { + // Check that at least one of the specified fields is non-empty + return [data.commonName, data.organization, data.ou, data.country, data.province, data.locality].some( + (field) => field !== "" + ); + }, + { + message: + "At least one of the fields commonName, organization, ou, country, province, or locality must be non-empty", + path: [] + } + ), + response: { + 200: z.object({ + ca: CertificateAuthoritiesSchema + }) + } + }, + handler: async (req) => { + const ca = await server.services.certificateAuthority.createCa({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.CREATE_CA, + metadata: { + caId: ca.id, + dn: ca.dn + } + } + }); + + return { + ca + }; + } + }); + + server.route({ + method: "GET", + url: "/:caId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Get CA", + params: z.object({ + caId: z.string().trim().describe(CERTIFICATE_AUTHORITIES.GET.caId) + }), + response: { + 200: z.object({ + ca: CertificateAuthoritiesSchema + }) + } + }, + handler: async (req) => { + const ca = await server.services.certificateAuthority.getCaById({ + caId: req.params.caId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.GET_CA, + metadata: { + caId: ca.id, + dn: ca.dn + } + } + }); + + return { + ca + }; + } + }); + + server.route({ + method: "PATCH", + url: "/:caId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Update CA", + params: z.object({ + caId: z.string().trim().describe(CERTIFICATE_AUTHORITIES.UPDATE.caId) + }), + body: z.object({ + status: z.enum([CaStatus.ACTIVE, CaStatus.DISABLED]).optional().describe(CERTIFICATE_AUTHORITIES.UPDATE.status) + }), + response: { + 200: z.object({ + ca: CertificateAuthoritiesSchema + }) + } + }, + handler: async (req) => { + const ca = await server.services.certificateAuthority.updateCaById({ + caId: req.params.caId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.UPDATE_CA, + metadata: { + caId: ca.id, + dn: ca.dn, + status: ca.status as CaStatus + } + } + }); + + return { + ca + }; + } + }); + + server.route({ + method: "DELETE", + url: "/:caId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Delete CA", + params: z.object({ + caId: z.string().trim().describe(CERTIFICATE_AUTHORITIES.DELETE.caId) + }), + response: { + 200: z.object({ + ca: CertificateAuthoritiesSchema + }) + } + }, + handler: async (req) => { + const ca = await server.services.certificateAuthority.deleteCaById({ + caId: req.params.caId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.DELETE_CA, + metadata: { + caId: ca.id, + dn: ca.dn + } + } + }); + + return { + ca + }; + } + }); + + server.route({ + method: "GET", + url: "/:caId/csr", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Get CA CSR", + params: z.object({ + caId: z.string().trim().describe(CERTIFICATE_AUTHORITIES.GET_CSR.caId) + }), + response: { + 200: z.object({ + csr: z.string().describe(CERTIFICATE_AUTHORITIES.GET_CSR.csr) + }) + } + }, + handler: async (req) => { + const { ca, csr } = await server.services.certificateAuthority.getCaCsr({ + caId: req.params.caId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.GET_CA_CSR, + metadata: { + caId: ca.id, + dn: ca.dn + } + } + }); + + return { + csr + }; + } + }); + + server.route({ + method: "GET", + url: "/:caId/certificate", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Get cert and cert chain of a CA", + params: z.object({ + caId: z.string().trim().describe(CERTIFICATE_AUTHORITIES.GET_CERT.caId) + }), + response: { + 200: z.object({ + certificate: z.string().describe(CERTIFICATE_AUTHORITIES.GET_CERT.certificate), + certificateChain: z.string().describe(CERTIFICATE_AUTHORITIES.GET_CERT.certificateChain), + serialNumber: z.string().describe(CERTIFICATE_AUTHORITIES.GET_CERT.serialNumber) + }) + } + }, + handler: async (req) => { + const { certificate, certificateChain, serialNumber, ca } = await server.services.certificateAuthority.getCaCert({ + caId: req.params.caId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.GET_CA_CERT, + metadata: { + caId: ca.id, + dn: ca.dn + } + } + }); + + return { + certificate, + certificateChain, + serialNumber + }; + } + }); + + server.route({ + method: "POST", + url: "/:caId/sign-intermediate", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Create intermediate CA certificate from parent CA", + params: z.object({ + caId: z.string().trim().describe(CERTIFICATE_AUTHORITIES.SIGN_INTERMEDIATE.caId) + }), + body: z.object({ + csr: z.string().trim().describe(CERTIFICATE_AUTHORITIES.SIGN_INTERMEDIATE.csr), + notBefore: validateCaDateField.optional().describe(CERTIFICATE_AUTHORITIES.SIGN_INTERMEDIATE.notBefore), + notAfter: validateCaDateField.describe(CERTIFICATE_AUTHORITIES.SIGN_INTERMEDIATE.notAfter), + maxPathLength: z.number().min(-1).default(-1).describe(CERTIFICATE_AUTHORITIES.SIGN_INTERMEDIATE.maxPathLength) + }), + response: { + 200: z.object({ + certificate: z.string().trim().describe(CERTIFICATE_AUTHORITIES.SIGN_INTERMEDIATE.certificate), + certificateChain: z.string().trim().describe(CERTIFICATE_AUTHORITIES.SIGN_INTERMEDIATE.certificateChain), + issuingCaCertificate: z + .string() + .trim() + .describe(CERTIFICATE_AUTHORITIES.SIGN_INTERMEDIATE.issuingCaCertificate), + serialNumber: z.string().trim().describe(CERTIFICATE_AUTHORITIES.SIGN_INTERMEDIATE.serialNumber) + }) + } + }, + handler: async (req) => { + const { certificate, certificateChain, issuingCaCertificate, serialNumber, ca } = + await server.services.certificateAuthority.signIntermediate({ + caId: req.params.caId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.SIGN_INTERMEDIATE, + metadata: { + caId: ca.id, + dn: ca.dn, + serialNumber + } + } + }); + + return { + certificate, + certificateChain, + issuingCaCertificate, + serialNumber + }; + } + }); + + server.route({ + method: "POST", + url: "/:caId/import-certificate", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Import certificate and chain to CA", + params: z.object({ + caId: z.string().trim().describe(CERTIFICATE_AUTHORITIES.IMPORT_CERT.caId) + }), + body: z.object({ + certificate: z.string().trim().describe(CERTIFICATE_AUTHORITIES.IMPORT_CERT.certificate), + certificateChain: z.string().trim().describe(CERTIFICATE_AUTHORITIES.IMPORT_CERT.certificateChain) + }), + response: { + 200: z.object({ + message: z.string().trim(), + caId: z.string().trim() + }) + } + }, + handler: async (req) => { + const { ca } = await server.services.certificateAuthority.importCertToCa({ + caId: req.params.caId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.IMPORT_CA_CERT, + metadata: { + caId: ca.id, + dn: ca.dn + } + } + }); + + return { + message: "Successfully imported certificate to CA", + caId: req.params.caId + }; + } + }); + + server.route({ + method: "POST", + url: "/:caId/issue-certificate", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Issue certificate from CA", + params: z.object({ + caId: z.string().trim().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.caId) + }), + body: z + .object({ + friendlyName: z.string().optional().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.friendlyName), + commonName: z.string().trim().min(1).describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.commonName), + ttl: z + .string() + .refine((val) => ms(val) > 0, "TTL must be a positive number") + .describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.ttl), + notBefore: validateCaDateField.optional().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.notBefore), + notAfter: validateCaDateField.optional().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.notAfter) + }) + .refine( + (data) => { + const { ttl, notAfter } = data; + return (ttl !== undefined && notAfter === undefined) || (ttl === undefined && notAfter !== undefined); + }, + { + message: "Either ttl or notAfter must be present, but not both", + path: ["ttl", "notAfter"] + } + ), + response: { + 200: z.object({ + certificate: z.string().trim().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.certificate), + issuingCaCertificate: z.string().trim().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.issuingCaCertificate), + certificateChain: z.string().trim().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.certificateChain), + privateKey: z.string().trim().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.privateKey), + serialNumber: z.string().trim().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.serialNumber) + }) + } + }, + handler: async (req) => { + const { certificate, certificateChain, issuingCaCertificate, privateKey, serialNumber, ca } = + await server.services.certificateAuthority.issueCertFromCa({ + caId: req.params.caId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.ISSUE_CERT, + metadata: { + caId: ca.id, + dn: ca.dn, + serialNumber + } + } + }); + + return { + certificate, + certificateChain, + issuingCaCertificate, + privateKey, + serialNumber + }; + } + }); +}; diff --git a/backend/src/server/routes/v1/certificate-router.ts b/backend/src/server/routes/v1/certificate-router.ts new file mode 100644 index 000000000..938fbf7fe --- /dev/null +++ b/backend/src/server/routes/v1/certificate-router.ts @@ -0,0 +1,207 @@ +import { z } from "zod"; + +import { CertificatesSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { CERTIFICATES } from "@app/lib/api-docs"; +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 { CrlReason } from "@app/services/certificate/certificate-types"; + +export const registerCertRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/:serialNumber", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Get certificate", + params: z.object({ + serialNumber: z.string().trim().describe(CERTIFICATES.GET.serialNumber) + }), + response: { + 200: z.object({ + certificate: CertificatesSchema + }) + } + }, + handler: async (req) => { + const { cert, ca } = await server.services.certificate.getCert({ + serialNumber: req.params.serialNumber, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.GET_CERT, + metadata: { + certId: cert.id, + cn: cert.commonName, + serialNumber: cert.serialNumber + } + } + }); + + return { + certificate: cert + }; + } + }); + + server.route({ + method: "POST", + url: "/:serialNumber/revoke", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Revoke", + params: z.object({ + serialNumber: z.string().trim().describe(CERTIFICATES.REVOKE.serialNumber) + }), + body: z.object({ + revocationReason: z.nativeEnum(CrlReason).describe(CERTIFICATES.REVOKE.revocationReason) + }), + response: { + 200: z.object({ + message: z.string().trim(), + serialNumber: z.string().trim().describe(CERTIFICATES.REVOKE.serialNumberRes), + revokedAt: z.date().describe(CERTIFICATES.REVOKE.revokedAt) + }) + } + }, + handler: async (req) => { + const { revokedAt, cert, ca } = await server.services.certificate.revokeCert({ + serialNumber: req.params.serialNumber, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.REVOKE_CERT, + metadata: { + certId: cert.id, + cn: cert.commonName, + serialNumber: cert.serialNumber + } + } + }); + + return { + message: "Successfully revoked certificate", + serialNumber: req.params.serialNumber, + revokedAt + }; + } + }); + + server.route({ + method: "DELETE", + url: "/:serialNumber", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Delete certificate", + params: z.object({ + serialNumber: z.string().trim().describe(CERTIFICATES.DELETE.serialNumber) + }), + response: { + 200: z.object({ + certificate: CertificatesSchema + }) + } + }, + handler: async (req) => { + const { deletedCert, ca } = await server.services.certificate.deleteCert({ + serialNumber: req.params.serialNumber, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.DELETE_CERT, + metadata: { + certId: deletedCert.id, + cn: deletedCert.commonName, + serialNumber: deletedCert.serialNumber + } + } + }); + + return { + certificate: deletedCert + }; + } + }); + + server.route({ + method: "GET", + url: "/:serialNumber/certificate", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Get certificate body of certificate", + params: z.object({ + serialNumber: z.string().trim().describe(CERTIFICATES.GET_CERT.serialNumber) + }), + response: { + 200: z.object({ + certificate: z.string().trim().describe(CERTIFICATES.GET_CERT.certificate), + certificateChain: z.string().trim().describe(CERTIFICATES.GET_CERT.certificateChain), + serialNumber: z.string().trim().describe(CERTIFICATES.GET_CERT.serialNumberRes) + }) + } + }, + handler: async (req) => { + const { certificate, certificateChain, serialNumber, cert, ca } = await server.services.certificate.getCertBody({ + serialNumber: req.params.serialNumber, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.DELETE_CERT, + metadata: { + certId: cert.id, + cn: cert.commonName, + serialNumber: cert.serialNumber + } + } + }); + + return { + certificate, + certificateChain, + serialNumber + }; + } + }); +}; diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index cbf67ce79..eee7dac65 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -1,6 +1,8 @@ import { registerAdminRouter } from "./admin-router"; import { registerAuthRoutes } from "./auth-router"; import { registerProjectBotRouter } from "./bot-router"; +import { registerCaRouter } from "./certificate-authority-router"; +import { registerCertRouter } from "./certificate-router"; import { registerIdentityAccessTokenRouter } from "./identity-access-token-router"; import { registerIdentityAwsAuthRouter } from "./identity-aws-iam-auth-router"; import { registerIdentityAzureAuthRouter } from "./identity-azure-auth-router"; @@ -61,6 +63,14 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { { prefix: "/workspace" } ); + await server.register( + async (pkiRouter) => { + await pkiRouter.register(registerCaRouter, { prefix: "/ca" }); + await pkiRouter.register(registerCertRouter, { prefix: "/certificates" }); + }, + { prefix: "/pki" } + ); + await server.register(registerProjectBotRouter, { prefix: "/bot" }); await server.register(registerIntegrationRouter, { prefix: "/integration" }); await server.register(registerIntegrationAuthRouter, { prefix: "/integration-auth" }); diff --git a/backend/src/server/routes/v2/project-router.ts b/backend/src/server/routes/v2/project-router.ts index a199cf0d4..e1c7c2e69 100644 --- a/backend/src/server/routes/v2/project-router.ts +++ b/backend/src/server/routes/v2/project-router.ts @@ -1,13 +1,14 @@ import slugify from "@sindresorhus/slugify"; import { z } from "zod"; -import { ProjectKeysSchema, ProjectsSchema } from "@app/db/schemas"; +import { CertificateAuthoritiesSchema, CertificatesSchema, ProjectKeysSchema, ProjectsSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { PROJECTS } from "@app/lib/api-docs"; import { creationLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; +import { CaStatus } from "@app/services/certificate-authority/certificate-authority-types"; import { ProjectFilterType } from "@app/services/project/project-types"; import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types"; @@ -307,4 +308,80 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { return project; } }); + + server.route({ + method: "GET", + url: "/:slug/cas", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + slug: slugSchema.describe("The slug of the project to list CAs.") + }), + querystring: z.object({ + status: z.enum([CaStatus.ACTIVE, CaStatus.PENDING_CERTIFICATE]).optional() + }), + response: { + 200: z.object({ + cas: z.array(CertificateAuthoritiesSchema) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const cas = await server.services.project.listProjectCas({ + filter: { + slug: req.params.slug, + orgId: req.permission.orgId, + type: ProjectFilterType.SLUG + }, + status: req.query.status, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type + }); + return { cas }; + } + }); + + server.route({ + method: "GET", + url: "/:slug/certificates", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + slug: slugSchema.describe("The slug of the project to list certificates.") + }), + querystring: z.object({ + offset: z.coerce.number().min(0).max(100).default(0), + limit: z.coerce.number().min(1).max(100).default(25) + }), + response: { + 200: z.object({ + certificates: z.array(CertificatesSchema), + totalCount: z.number() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { certificates, totalCount } = await server.services.project.listProjectCertificates({ + filter: { + slug: req.params.slug, + orgId: req.permission.orgId, + type: ProjectFilterType.SLUG + }, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + ...req.query + }); + return { certificates, totalCount }; + } + }); }; diff --git a/backend/src/services/certificate-authority/certificate-authority-cert-dal.ts b/backend/src/services/certificate-authority/certificate-authority-cert-dal.ts new file mode 100644 index 000000000..763240986 --- /dev/null +++ b/backend/src/services/certificate-authority/certificate-authority-cert-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TCertificateAuthorityCertDALFactory = ReturnType; + +export const certificateAuthorityCertDALFactory = (db: TDbClient) => { + const caCertOrm = ormify(db, TableName.CertificateAuthorityCert); + return caCertOrm; +}; diff --git a/backend/src/services/certificate-authority/certificate-authority-dal.ts b/backend/src/services/certificate-authority/certificate-authority-dal.ts new file mode 100644 index 000000000..1b4b30e73 --- /dev/null +++ b/backend/src/services/certificate-authority/certificate-authority-dal.ts @@ -0,0 +1,48 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify } from "@app/lib/knex"; + +export type TCertificateAuthorityDALFactory = ReturnType; + +export const certificateAuthorityDALFactory = (db: TDbClient) => { + const caOrm = ormify(db, TableName.CertificateAuthority); + + // note: not used + const buildCertificateChain = async (caId: string) => { + try { + const result: { + caId: string; + parentCaId?: string; + encryptedCertificate: Buffer; + }[] = await db + .withRecursive("cte", (cte) => { + void cte + .select("ca.id as caId", "ca.parentCaId", "cert.encryptedCertificate") + .from({ ca: TableName.CertificateAuthority }) + .leftJoin({ cert: TableName.CertificateAuthorityCert }, "ca.id", "cert.caId") + .where("ca.id", caId) + .unionAll((builder) => { + void builder + .select("ca.id as caId", "ca.parentCaId", "cert.encryptedCertificate") + .from({ ca: TableName.CertificateAuthority }) + .leftJoin({ cert: TableName.CertificateAuthorityCert }, "ca.id", "cert.caId") + .innerJoin("cte", "cte.parentCaId", "ca.id"); + }); + }) + .select("*") + .from("cte"); + + // Extract certificates and reverse the order to have the root CA at the end + const certChain: Buffer[] = result.map((row) => row.encryptedCertificate); + return certChain; + } catch (error) { + throw new DatabaseError({ error, name: "BuildCertificateChain" }); + } + }; + + return { + ...caOrm, + buildCertificateChain + }; +}; diff --git a/backend/src/services/certificate-authority/certificate-authority-fns.ts b/backend/src/services/certificate-authority/certificate-authority-fns.ts new file mode 100644 index 000000000..cf42a058e --- /dev/null +++ b/backend/src/services/certificate-authority/certificate-authority-fns.ts @@ -0,0 +1,216 @@ +import * as x509 from "@peculiar/x509"; +import crypto from "crypto"; + +import { BadRequestError } from "@app/lib/errors"; +import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; + +import { CertKeyAlgorithm, CertStatus } from "../certificate/certificate-types"; +import { TDNParts, TGetCaCertChainDTO, TGetCaCredentialsDTO, TRebuildCaCrlDTO } from "./certificate-authority-types"; + +export const createDistinguishedName = (parts: TDNParts) => { + const dnParts = []; + if (parts.country) dnParts.push(`C=${parts.country}`); + if (parts.organization) dnParts.push(`O=${parts.organization}`); + if (parts.ou) dnParts.push(`OU=${parts.ou}`); + if (parts.province) dnParts.push(`ST=${parts.province}`); + if (parts.commonName) dnParts.push(`CN=${parts.commonName}`); + if (parts.locality) dnParts.push(`L=${parts.locality}`); + return dnParts.join(", "); +}; + +export const keyAlgorithmToAlgCfg = (keyAlgorithm: CertKeyAlgorithm) => { + switch (keyAlgorithm) { + case CertKeyAlgorithm.RSA_4096: + return { + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-256", + publicExponent: new Uint8Array([1, 0, 1]), + modulusLength: 4096 + }; + case CertKeyAlgorithm.ECDSA_P256: + return { + name: "ECDSA", + namedCurve: "P-256", + hash: "SHA-256" + }; + case CertKeyAlgorithm.ECDSA_P384: + return { + name: "ECDSA", + namedCurve: "P-384", + hash: "SHA-384" + }; + default: { + // RSA_2048 + return { + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-256", + publicExponent: new Uint8Array([1, 0, 1]), + modulusLength: 2048 + }; + } + } +}; + +/** + * Return the public and private key of CA with id [caId] + * Note: credentials are returned as crypto.webcrypto.CryptoKey + * suitable for use with @peculiar/x509 module + */ +export const getCaCredentials = async ({ + caId, + certificateAuthorityDAL, + certificateAuthoritySecretDAL, + projectDAL, + kmsService +}: TGetCaCredentialsDTO) => { + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) throw new BadRequestError({ message: "CA not found" }); + + const caSecret = await certificateAuthoritySecretDAL.findOne({ caId }); + if (!caSecret) throw new BadRequestError({ message: "CA secret not found" }); + + const keyId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + + const decryptedPrivateKey = await kmsService.decrypt({ + kmsId: keyId, + cipherTextBlob: caSecret.encryptedPrivateKey + }); + + const alg = keyAlgorithmToAlgCfg(ca.keyAlgorithm as CertKeyAlgorithm); + const skObj = crypto.createPrivateKey({ key: decryptedPrivateKey, format: "der", type: "pkcs8" }); + const caPrivateKey = await crypto.subtle.importKey( + "pkcs8", + skObj.export({ format: "der", type: "pkcs8" }), + alg, + true, + ["sign"] + ); + + const pkObj = crypto.createPublicKey(skObj); + const caPublicKey = await crypto.subtle.importKey("spki", pkObj.export({ format: "der", type: "spki" }), alg, true, [ + "verify" + ]); + + return { + caPrivateKey, + caPublicKey + }; +}; + +/** + * Return the decrypted pem-encoded certificate and certificate chain + * for CA with id [caId]. + */ +export const getCaCertChain = async ({ + caId, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService +}: TGetCaCertChainDTO) => { + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) throw new BadRequestError({ message: "CA not found" }); + + const caCert = await certificateAuthorityCertDAL.findOne({ caId: ca.id }); + + const keyId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + + const decryptedCaCert = await kmsService.decrypt({ + kmsId: keyId, + cipherTextBlob: caCert.encryptedCertificate + }); + + const caCertObj = new x509.X509Certificate(decryptedCaCert); + + const decryptedChain = await kmsService.decrypt({ + kmsId: keyId, + cipherTextBlob: caCert.encryptedCertificateChain + }); + + return { + caCert: caCertObj.toString("pem"), + caCertChain: decryptedChain.toString("utf-8"), + serialNumber: caCertObj.serialNumber + }; +}; + +/** + * Rebuilds the certificate revocation list (CRL) + * for CA with id [caId] + */ +export const rebuildCaCrl = async ({ + caId, + certificateAuthorityDAL, + certificateAuthorityCrlDAL, + certificateAuthoritySecretDAL, + projectDAL, + certificateDAL, + kmsService +}: TRebuildCaCrlDTO) => { + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) throw new BadRequestError({ message: "CA not found" }); + + const caSecret = await certificateAuthoritySecretDAL.findOne({ caId: ca.id }); + + const alg = keyAlgorithmToAlgCfg(ca.keyAlgorithm as CertKeyAlgorithm); + + const keyId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + + const privateKey = await kmsService.decrypt({ + kmsId: keyId, + cipherTextBlob: caSecret.encryptedPrivateKey + }); + + const skObj = crypto.createPrivateKey({ key: privateKey, format: "der", type: "pkcs8" }); + const sk = await crypto.subtle.importKey("pkcs8", skObj.export({ format: "der", type: "pkcs8" }), alg, true, [ + "sign" + ]); + + const revokedCerts = await certificateDAL.find({ + caId: ca.id, + status: CertStatus.REVOKED + }); + + const crl = await x509.X509CrlGenerator.create({ + issuer: ca.dn, + thisUpdate: new Date(), + nextUpdate: new Date("2025/12/12"), + entries: revokedCerts.map((revokedCert) => { + return { + serialNumber: revokedCert.serialNumber, + revocationDate: new Date(revokedCert.revokedAt as Date), + reason: revokedCert.revocationReason as number, + invalidity: new Date("2022/01/01"), + issuer: ca.dn + }; + }), + signingAlgorithm: alg, + signingKey: sk + }); + + const { cipherTextBlob: encryptedCrl } = await kmsService.encrypt({ + kmsId: keyId, + plainText: Buffer.from(new Uint8Array(crl.rawData)) + }); + + await certificateAuthorityCrlDAL.update( + { + caId: ca.id + }, + { + encryptedCrl + } + ); +}; diff --git a/backend/src/services/certificate-authority/certificate-authority-queue.ts b/backend/src/services/certificate-authority/certificate-authority-queue.ts new file mode 100644 index 000000000..384f45c09 --- /dev/null +++ b/backend/src/services/certificate-authority/certificate-authority-queue.ts @@ -0,0 +1,145 @@ +import * as x509 from "@peculiar/x509"; +import crypto from "crypto"; + +import { getConfig } from "@app/lib/config/env"; +import { daysToMillisecond, secondsToMillis } from "@app/lib/dates"; +import { BadRequestError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; +import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; +import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; +import { CertKeyAlgorithm, CertStatus } from "@app/services/certificate/certificate-types"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; + +import { TCertificateAuthorityCrlDALFactory } from "../../ee/services/certificate-authority-crl/certificate-authority-crl-dal"; +import { TCertificateAuthorityDALFactory } from "./certificate-authority-dal"; +import { keyAlgorithmToAlgCfg } from "./certificate-authority-fns"; +import { TCertificateAuthoritySecretDALFactory } from "./certificate-authority-secret-dal"; +import { TRotateCaCrlTriggerDTO } from "./certificate-authority-types"; + +type TCertificateAuthorityQueueFactoryDep = { + // TODO: Pick + certificateAuthorityDAL: TCertificateAuthorityDALFactory; + certificateAuthorityCrlDAL: TCertificateAuthorityCrlDALFactory; + certificateAuthoritySecretDAL: TCertificateAuthoritySecretDALFactory; + certificateDAL: TCertificateDALFactory; + projectDAL: Pick; + kmsService: Pick; + queueService: TQueueServiceFactory; +}; +export type TCertificateAuthorityQueueFactory = ReturnType; + +export const certificateAuthorityQueueFactory = ({ + certificateAuthorityCrlDAL, + certificateAuthorityDAL, + certificateAuthoritySecretDAL, + certificateDAL, + projectDAL, + kmsService, + queueService +}: TCertificateAuthorityQueueFactoryDep) => { + // TODO 1: auto-periodic rotation + // TODO 2: manual rotation + + const setCaCrlRotationInterval = async ({ caId, rotationIntervalDays }: TRotateCaCrlTriggerDTO) => { + const appCfg = getConfig(); + + // query for config + // const caCrl = await certificateAuthorityCrlDAL.findOne({ + // caId + // }); + + await queueService.queue( + // TODO: clarify queue + job naming + QueueName.CaCrlRotation, + QueueJobs.CaCrlRotation, + { + caId + }, + { + jobId: `ca-crl-rotation-${caId}`, + repeat: { + // on prod it this will be in days, in development this will be second + every: + appCfg.NODE_ENV === "development" + ? secondsToMillis(rotationIntervalDays) + : daysToMillisecond(rotationIntervalDays), + immediately: true + } + } + ); + }; + + queueService.start(QueueName.CaCrlRotation, async (job) => { + const { caId } = job.data; + logger.info(`secretReminderQueue.process: [secretDocument=${caId}]`); + + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) throw new BadRequestError({ message: "CA not found" }); + + const caSecret = await certificateAuthoritySecretDAL.findOne({ caId: ca.id }); + + const alg = keyAlgorithmToAlgCfg(ca.keyAlgorithm as CertKeyAlgorithm); + + const keyId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + + const privateKey = await kmsService.decrypt({ + kmsId: keyId, + cipherTextBlob: caSecret.encryptedPrivateKey + }); + + const skObj = crypto.createPrivateKey({ key: privateKey, format: "der", type: "pkcs8" }); + const sk = await crypto.subtle.importKey("pkcs8", skObj.export({ format: "der", type: "pkcs8" }), alg, true, [ + "sign" + ]); + + const revokedCerts = await certificateDAL.find({ + caId: ca.id, + status: CertStatus.REVOKED + }); + + const crl = await x509.X509CrlGenerator.create({ + issuer: ca.dn, + thisUpdate: new Date(), + nextUpdate: new Date("2025/12/12"), // TODO: depends on configured rebuild interval + entries: revokedCerts.map((revokedCert) => { + return { + serialNumber: revokedCert.serialNumber, + revocationDate: new Date(revokedCert.revokedAt as Date), + reason: revokedCert.revocationReason as number, + invalidity: new Date("2022/01/01"), + issuer: ca.dn + }; + }), + signingAlgorithm: alg, + signingKey: sk + }); + + const { cipherTextBlob: encryptedCrl } = await kmsService.encrypt({ + kmsId: keyId, + plainText: Buffer.from(new Uint8Array(crl.rawData)) + }); + + await certificateAuthorityCrlDAL.update( + { + caId: ca.id + }, + { + encryptedCrl + } + ); + }); + + queueService.listen(QueueName.CaCrlRotation, "failed", (job, err) => { + logger.error(err, "Failed to rotate CA CRL %s", job?.id); + }); + + return { + setCaCrlRotationInterval + }; +}; diff --git a/backend/src/services/certificate-authority/certificate-authority-secret-dal.ts b/backend/src/services/certificate-authority/certificate-authority-secret-dal.ts new file mode 100644 index 000000000..2ade72e7e --- /dev/null +++ b/backend/src/services/certificate-authority/certificate-authority-secret-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TCertificateAuthoritySecretDALFactory = ReturnType; + +export const certificateAuthoritySecretDALFactory = (db: TDbClient) => { + const caSecretOrm = ormify(db, TableName.CertificateAuthoritySecret); + return caSecretOrm; +}; diff --git a/backend/src/services/certificate-authority/certificate-authority-service.ts b/backend/src/services/certificate-authority/certificate-authority-service.ts new file mode 100644 index 000000000..2345180a3 --- /dev/null +++ b/backend/src/services/certificate-authority/certificate-authority-service.ts @@ -0,0 +1,821 @@ +/* eslint-disable no-bitwise */ +import { ForbiddenError } from "@casl/ability"; +import * as x509 from "@peculiar/x509"; +import crypto, { KeyObject } from "crypto"; +import ms from "ms"; + +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { BadRequestError } from "@app/lib/errors"; +import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal"; +import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; + +import { TCertificateAuthorityCrlDALFactory } from "../../ee/services/certificate-authority-crl/certificate-authority-crl-dal"; +import { CertKeyAlgorithm, CertStatus } from "../certificate/certificate-types"; +import { TCertificateAuthorityCertDALFactory } from "./certificate-authority-cert-dal"; +import { TCertificateAuthorityDALFactory } from "./certificate-authority-dal"; +import { + createDistinguishedName, + getCaCertChain, + getCaCredentials, + keyAlgorithmToAlgCfg +} from "./certificate-authority-fns"; +import { TCertificateAuthorityQueueFactory } from "./certificate-authority-queue"; +import { TCertificateAuthoritySecretDALFactory } from "./certificate-authority-secret-dal"; +import { + CaStatus, + CaType, + TCreateCaDTO, + TDeleteCaDTO, + TGetCaCertDTO, + TGetCaCsrDTO, + TGetCaDTO, + TImportCertToCaDTO, + TIssueCertFromCaDTO, + TSignIntermediateDTO, + TUpdateCaDTO +} from "./certificate-authority-types"; + +type TCertificateAuthorityServiceFactoryDep = { + certificateAuthorityDAL: Pick< + TCertificateAuthorityDALFactory, + "transaction" | "create" | "findById" | "updateById" | "deleteById" | "findOne" + >; + certificateAuthorityCertDAL: Pick; + certificateAuthoritySecretDAL: Pick; + certificateAuthorityCrlDAL: Pick; + certificateAuthorityQueue: TCertificateAuthorityQueueFactory; // TODO: Pick + certificateDAL: Pick; + certificateBodyDAL: Pick; + projectDAL: Pick; + kmsService: Pick; + permissionService: Pick; +}; + +export type TCertificateAuthorityServiceFactory = ReturnType; + +export const certificateAuthorityServiceFactory = ({ + certificateAuthorityDAL, + certificateAuthorityCertDAL, + certificateAuthoritySecretDAL, + certificateAuthorityCrlDAL, + certificateDAL, + certificateBodyDAL, + projectDAL, + kmsService, + permissionService +}: TCertificateAuthorityServiceFactoryDep) => { + /** + * Generates new root or intermediate CA + */ + const createCa = async ({ + projectSlug, + type, + friendlyName, + commonName, + organization, + ou, + country, + province, + locality, + notBefore, + notAfter, + maxPathLength, + keyAlgorithm, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TCreateCaDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + project.id, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.CertificateAuthorities + ); + + const dn = createDistinguishedName({ + commonName, + organization, + ou, + country, + province, + locality + }); + + const alg = keyAlgorithmToAlgCfg(keyAlgorithm); + const keys = await crypto.subtle.generateKey(alg, true, ["sign", "verify"]); + + const newCa = await certificateAuthorityDAL.transaction(async (tx) => { + const notBeforeDate = notBefore ? new Date(notBefore) : new Date(); + + // if undefined, set [notAfterDate] to 10 years from now + const notAfterDate = notAfter + ? new Date(notAfter) + : new Date(new Date().setFullYear(new Date().getFullYear() + 10)); + + const serialNumber = crypto.randomBytes(32).toString("hex"); + + const ca = await certificateAuthorityDAL.create( + { + projectId: project.id, + type, + organization, + ou, + country, + province, + locality, + friendlyName: friendlyName || dn, + commonName, + status: type === CaType.ROOT ? CaStatus.ACTIVE : CaStatus.PENDING_CERTIFICATE, + dn, + keyAlgorithm, + ...(type === CaType.ROOT && { + maxPathLength, + notBefore: notBeforeDate, + notAfter: notAfterDate, + serialNumber + }) + }, + tx + ); + + const keyId = await getProjectKmsCertificateKeyId({ + projectId: project.id, + projectDAL, + kmsService + }); + + if (type === CaType.ROOT) { + // note: create self-signed cert only applicable for root CA + const cert = await x509.X509CertificateGenerator.createSelfSigned({ + name: dn, + serialNumber, + notBefore: notBeforeDate, + notAfter: notAfterDate, + signingAlgorithm: alg, + keys, + extensions: [ + new x509.BasicConstraintsExtension(true, maxPathLength === -1 ? undefined : maxPathLength, true), + new x509.ExtendedKeyUsageExtension(["1.2.3.4.5.6.7", "2.3.4.5.6.7.8"], true), + // eslint-disable-next-line no-bitwise + new x509.KeyUsagesExtension(x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign, true), + await x509.SubjectKeyIdentifierExtension.create(keys.publicKey) + ] + }); + + const { cipherTextBlob: encryptedCertificate } = await kmsService.encrypt({ + kmsId: keyId, + plainText: Buffer.from(new Uint8Array(cert.rawData)) + }); + + const { cipherTextBlob: encryptedCertificateChain } = await kmsService.encrypt({ + kmsId: keyId, + plainText: Buffer.alloc(0) + }); + + await certificateAuthorityCertDAL.create( + { + caId: ca.id, + encryptedCertificate, + encryptedCertificateChain + }, + tx + ); + } + + // create empty CRL + const crl = await x509.X509CrlGenerator.create({ + issuer: ca.dn, + thisUpdate: new Date(), + nextUpdate: new Date("2025/12/12"), // TODO: change + entries: [], + signingAlgorithm: alg, + signingKey: keys.privateKey + }); + + const { cipherTextBlob: encryptedCrl } = await kmsService.encrypt({ + kmsId: keyId, + plainText: Buffer.from(new Uint8Array(crl.rawData)) + }); + + await certificateAuthorityCrlDAL.create( + { + caId: ca.id, + encryptedCrl + }, + tx + ); + + // https://nodejs.org/api/crypto.html#static-method-keyobjectfromkey + const skObj = KeyObject.from(keys.privateKey); + + const { cipherTextBlob: encryptedPrivateKey } = await kmsService.encrypt({ + kmsId: keyId, + plainText: skObj.export({ + type: "pkcs8", + format: "der" + }) + }); + + await certificateAuthoritySecretDAL.create( + { + caId: ca.id, + encryptedPrivateKey + }, + tx + ); + + return ca; + }); + + return newCa; + }; + + /** + * Return CA with id [caId] + */ + const getCaById = async ({ caId, actorId, actorAuthMethod, actor, actorOrgId }: TGetCaDTO) => { + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) throw new BadRequestError({ message: "CA not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.CertificateAuthorities + ); + + return ca; + }; + + /** + * Update CA with id [caId]. + * Note: Used to enable/disable CA + */ + const updateCaById = async ({ caId, status, actorId, actorAuthMethod, actor, actorOrgId }: TUpdateCaDTO) => { + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) throw new BadRequestError({ message: "CA not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + ProjectPermissionSub.CertificateAuthorities + ); + + const updatedCa = await certificateAuthorityDAL.updateById(caId, { status }); + + return updatedCa; + }; + + /** + * Delete CA with id [caId] + */ + const deleteCaById = async ({ caId, actorId, actorAuthMethod, actor, actorOrgId }: TDeleteCaDTO) => { + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) throw new BadRequestError({ message: "CA not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Delete, + ProjectPermissionSub.CertificateAuthorities + ); + + const deletedCa = await certificateAuthorityDAL.deleteById(caId); + + return deletedCa; + }; + + /** + * Return certificate signing request (CSR) made with CA with id [caId] + */ + const getCaCsr = async ({ caId, actorId, actorAuthMethod, actor, actorOrgId }: TGetCaCsrDTO) => { + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) throw new BadRequestError({ message: "CA not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.CertificateAuthorities + ); + + if (ca.type === CaType.ROOT) throw new BadRequestError({ message: "Root CA cannot generate CSR" }); + + const caCert = await certificateAuthorityCertDAL.findOne({ caId: ca.id }); + if (caCert) throw new BadRequestError({ message: "CA already has a certificate installed" }); + + const { caPrivateKey, caPublicKey } = await getCaCredentials({ + caId, + certificateAuthorityDAL, + certificateAuthoritySecretDAL, + projectDAL, + kmsService + }); + + const alg = keyAlgorithmToAlgCfg(ca.keyAlgorithm as CertKeyAlgorithm); + + const csrObj = await x509.Pkcs10CertificateRequestGenerator.create({ + name: ca.dn, + keys: { + privateKey: caPrivateKey, + publicKey: caPublicKey + }, + signingAlgorithm: alg, + extensions: [ + // eslint-disable-next-line no-bitwise + new x509.KeyUsagesExtension( + x509.KeyUsageFlags.keyCertSign | + x509.KeyUsageFlags.cRLSign | + x509.KeyUsageFlags.digitalSignature | + x509.KeyUsageFlags.keyEncipherment + ) + ], + attributes: [new x509.ChallengePasswordAttribute("password")] + }); + + return { + csr: csrObj.toString("pem"), + ca + }; + }; + + /** + * Return certificate and certificate chain for CA + */ + const getCaCert = async ({ caId, actorId, actorAuthMethod, actor, actorOrgId }: TGetCaCertDTO) => { + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) throw new BadRequestError({ message: "CA not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.CertificateAuthorities + ); + + const { caCert, caCertChain, serialNumber } = await getCaCertChain({ + caId, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService + }); + + return { + certificate: caCert, + certificateChain: caCertChain, + serialNumber, + ca + }; + }; + + /** + * Issue certificate to be imported back in for intermediate CA + */ + const signIntermediate = async ({ + caId, + actorId, + actorAuthMethod, + actor, + actorOrgId, + csr, + notBefore, + notAfter, + maxPathLength + }: TSignIntermediateDTO) => { + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) throw new BadRequestError({ message: "CA not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.CertificateAuthorities + ); + + if (ca.status === CaStatus.DISABLED) throw new BadRequestError({ message: "CA is disabled" }); + + const alg = keyAlgorithmToAlgCfg(ca.keyAlgorithm as CertKeyAlgorithm); + + const keyId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + + const caCert = await certificateAuthorityCertDAL.findOne({ caId: ca.id }); + const decryptedCaCert = await kmsService.decrypt({ + kmsId: keyId, + cipherTextBlob: caCert.encryptedCertificate + }); + + const caCertObj = new x509.X509Certificate(decryptedCaCert); + const csrObj = new x509.Pkcs10CertificateRequest(csr); + + // check path length constraint + const caPathLength = caCertObj.getExtension(x509.BasicConstraintsExtension)?.pathLength; + if (caPathLength !== undefined) { + if (caPathLength === 0) + throw new BadRequestError({ + message: "Failed to issue intermediate certificate due to CA path length constraint" + }); + if (maxPathLength >= caPathLength || (maxPathLength === -1 && caPathLength !== -1)) + throw new BadRequestError({ + message: "The requested path length constraint exceeds the CA's allowed path length" + }); + } + + const notBeforeDate = notBefore ? new Date(notBefore) : new Date(); + const notAfterDate = new Date(notAfter); + + const caCertNotBeforeDate = new Date(caCertObj.notBefore); + const caCertNotAfterDate = new Date(caCertObj.notAfter); + + // check not before constraint + if (notBeforeDate < caCertNotBeforeDate) { + throw new BadRequestError({ message: "notBefore date is before CA certificate's notBefore date" }); + } + + if (notBeforeDate > notAfterDate) throw new BadRequestError({ message: "notBefore date is after notAfter date" }); + + // check not after constraint + if (notAfterDate > caCertNotAfterDate) { + throw new BadRequestError({ message: "notAfter date is after CA certificate's notAfter date" }); + } + + const { caPrivateKey } = await getCaCredentials({ + caId: ca.id, + certificateAuthorityDAL, + certificateAuthoritySecretDAL, + projectDAL, + kmsService + }); + + const serialNumber = crypto.randomBytes(32).toString("hex"); + const intermediateCert = await x509.X509CertificateGenerator.create({ + serialNumber, + subject: csrObj.subject, + issuer: caCertObj.subject, + notBefore: notBeforeDate, + notAfter: notAfterDate, + signingKey: caPrivateKey, + publicKey: csrObj.publicKey, + signingAlgorithm: alg, + extensions: [ + new x509.KeyUsagesExtension( + x509.KeyUsageFlags.keyCertSign | + x509.KeyUsageFlags.cRLSign | + x509.KeyUsageFlags.digitalSignature | + x509.KeyUsageFlags.keyEncipherment, + true + ), + new x509.BasicConstraintsExtension(true, maxPathLength === -1 ? undefined : maxPathLength, true), + await x509.AuthorityKeyIdentifierExtension.create(caCertObj, false), + await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey) + ] + }); + + const { caCert: issuingCaCertificate, caCertChain } = await getCaCertChain({ + caId, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService + }); + + return { + certificate: intermediateCert.toString("pem"), + issuingCaCertificate, + certificateChain: `${issuingCaCertificate}\n${caCertChain}`.trim(), + serialNumber: intermediateCert.serialNumber, + ca + }; + }; + + /** + * Import certificate for (un-installed) CA with id [caId]. + * Note: Can be used to import an external certificate and certificate chain + * to be installed into the CA. + */ + const importCertToCa = async ({ + caId, + actorId, + actorAuthMethod, + actor, + actorOrgId, + certificate, + certificateChain + }: TImportCertToCaDTO) => { + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) throw new BadRequestError({ message: "CA not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.CertificateAuthorities + ); + + const caCert = await certificateAuthorityCertDAL.findOne({ caId: ca.id }); + if (caCert) throw new BadRequestError({ message: "CA has already imported a certificate" }); + + const certObj = new x509.X509Certificate(certificate); + const maxPathLength = certObj.getExtension(x509.BasicConstraintsExtension)?.pathLength; + + // validate imported certificate and certificate chain + const certificates = certificateChain + .match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) + ?.map((cert) => new x509.X509Certificate(cert)); + + if (!certificates) throw new BadRequestError({ message: "Failed to parse certificate chain" }); + + const chain = new x509.X509ChainBuilder({ + certificates + }); + + const chainItems = await chain.build(certObj); + + // chain.build() implicitly verifies the chain + if (chainItems.length !== certificates.length + 1) + throw new BadRequestError({ message: "Invalid certificate chain" }); + + const parentCertObj = chainItems[1]; + const parentCertSubject = parentCertObj.subject; + + const parentCa = await certificateAuthorityDAL.findOne({ + projectId: ca.projectId, + dn: parentCertSubject + }); + + const keyId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + + const { cipherTextBlob: encryptedCertificate } = await kmsService.encrypt({ + kmsId: keyId, + plainText: Buffer.from(new Uint8Array(certObj.rawData)) + }); + + const { cipherTextBlob: encryptedCertificateChain } = await kmsService.encrypt({ + kmsId: keyId, + plainText: Buffer.from(certificateChain) + }); + + await certificateAuthorityCertDAL.transaction(async (tx) => { + await certificateAuthorityCertDAL.create( + { + caId: ca.id, + encryptedCertificate, + encryptedCertificateChain + }, + tx + ); + + await certificateAuthorityDAL.updateById( + ca.id, + { + status: CaStatus.ACTIVE, + maxPathLength: maxPathLength === undefined ? -1 : maxPathLength, + notBefore: new Date(certObj.notBefore), + notAfter: new Date(certObj.notAfter), + serialNumber: certObj.serialNumber, + parentCaId: parentCa?.id + }, + tx + ); + }); + + return { ca }; + }; + + /** + * Return new leaf certificate issued by CA with id [caId] + */ + const issueCertFromCa = async ({ + caId, + friendlyName, + commonName, + ttl, + notBefore, + notAfter, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TIssueCertFromCaDTO) => { + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) throw new BadRequestError({ message: "CA not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Certificates); + + if (ca.status === CaStatus.DISABLED) throw new BadRequestError({ message: "CA is disabled" }); + + const caCert = await certificateAuthorityCertDAL.findOne({ caId: ca.id }); + if (!caCert) throw new BadRequestError({ message: "CA does not have a certificate installed" }); + + const keyId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + + const decryptedCaCert = await kmsService.decrypt({ + kmsId: keyId, + cipherTextBlob: caCert.encryptedCertificate + }); + + const caCertObj = new x509.X509Certificate(decryptedCaCert); + + const notBeforeDate = notBefore ? new Date(notBefore) : new Date(); + + let notAfterDate = new Date(new Date().setFullYear(new Date().getFullYear() + 1)); + if (notAfter) { + notAfterDate = new Date(notAfter); + } else if (ttl) { + notAfterDate = new Date(new Date().getTime() + ms(ttl)); + } + + const caCertNotBeforeDate = new Date(caCertObj.notBefore); + const caCertNotAfterDate = new Date(caCertObj.notAfter); + + // check not before constraint + if (notBeforeDate < caCertNotBeforeDate) { + throw new BadRequestError({ message: "notBefore date is before CA certificate's notBefore date" }); + } + + if (notBeforeDate > notAfterDate) throw new BadRequestError({ message: "notBefore date is after notAfter date" }); + + // check not after constraint + if (notAfterDate > caCertNotAfterDate) { + throw new BadRequestError({ message: "notAfter date is after CA certificate's notAfter date" }); + } + + const alg = keyAlgorithmToAlgCfg(ca.keyAlgorithm as CertKeyAlgorithm); + const leafKeys = await crypto.subtle.generateKey(alg, true, ["sign", "verify"]); + + const csrObj = await x509.Pkcs10CertificateRequestGenerator.create({ + name: `CN=${commonName}`, + keys: leafKeys, + signingAlgorithm: alg, + extensions: [ + // eslint-disable-next-line no-bitwise + new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature | x509.KeyUsageFlags.keyEncipherment) + ], + attributes: [new x509.ChallengePasswordAttribute("password")] + }); + + const { caPrivateKey } = await getCaCredentials({ + caId: ca.id, + certificateAuthorityDAL, + certificateAuthoritySecretDAL, + projectDAL, + kmsService + }); + + const serialNumber = crypto.randomBytes(32).toString("hex"); + const leafCert = await x509.X509CertificateGenerator.create({ + serialNumber, + subject: csrObj.subject, + issuer: caCertObj.subject, + notBefore: notBeforeDate, + notAfter: notAfterDate, + signingKey: caPrivateKey, + publicKey: csrObj.publicKey, + signingAlgorithm: alg, + extensions: [ + new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature | x509.KeyUsageFlags.keyEncipherment, true), + new x509.BasicConstraintsExtension(false), + await x509.AuthorityKeyIdentifierExtension.create(caCertObj, false), + await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey) + ] + }); + + const skLeafObj = KeyObject.from(leafKeys.privateKey); + const skLeaf = skLeafObj.export({ format: "pem", type: "pkcs8" }) as string; + + const { cipherTextBlob: encryptedCertificate } = await kmsService.encrypt({ + kmsId: keyId, + plainText: Buffer.from(new Uint8Array(leafCert.rawData)) + }); + + await certificateDAL.transaction(async (tx) => { + const cert = await certificateDAL.create( + { + caId: ca.id, + status: CertStatus.ACTIVE, + friendlyName: friendlyName || commonName, + commonName, + serialNumber, + notBefore: notBeforeDate, + notAfter: notAfterDate + }, + tx + ); + + await certificateBodyDAL.create( + { + certId: cert.id, + encryptedCertificate + }, + tx + ); + + return cert; + }); + + const { caCert: issuingCaCertificate, caCertChain } = await getCaCertChain({ + caId: ca.id, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService + }); + + return { + certificate: leafCert.toString("pem"), + certificateChain: `${issuingCaCertificate}\n${caCertChain}`.trim(), + issuingCaCertificate, + privateKey: skLeaf, + serialNumber, + ca + }; + }; + + return { + createCa, + getCaById, + updateCaById, + deleteCaById, + getCaCsr, + getCaCert, + signIntermediate, + importCertToCa, + issueCertFromCa + }; +}; diff --git a/backend/src/services/certificate-authority/certificate-authority-types.ts b/backend/src/services/certificate-authority/certificate-authority-types.ts new file mode 100644 index 000000000..3ba7624c0 --- /dev/null +++ b/backend/src/services/certificate-authority/certificate-authority-types.ts @@ -0,0 +1,121 @@ +import { TProjectPermission } from "@app/lib/types"; +import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; + +import { TCertificateAuthorityCrlDALFactory } from "../../ee/services/certificate-authority-crl/certificate-authority-crl-dal"; +import { CertKeyAlgorithm } from "../certificate/certificate-types"; +import { TCertificateAuthorityCertDALFactory } from "./certificate-authority-cert-dal"; +import { TCertificateAuthorityDALFactory } from "./certificate-authority-dal"; +import { TCertificateAuthoritySecretDALFactory } from "./certificate-authority-secret-dal"; + +export enum CaType { + ROOT = "root", + INTERMEDIATE = "intermediate" +} + +export enum CaStatus { + ACTIVE = "active", + DISABLED = "disabled", + PENDING_CERTIFICATE = "pending-certificate" +} + +export type TCreateCaDTO = { + projectSlug: string; + type: CaType; + friendlyName?: string; + commonName: string; + organization: string; + ou: string; + country: string; + province: string; + locality: string; + notBefore?: string; + notAfter?: string; + maxPathLength: number; + keyAlgorithm: CertKeyAlgorithm; +} & Omit; + +export type TGetCaDTO = { + caId: string; +} & Omit; + +export type TUpdateCaDTO = { + caId: string; + status?: CaStatus; +} & Omit; + +export type TDeleteCaDTO = { + caId: string; +} & Omit; + +export type TGetCaCsrDTO = { + caId: string; +} & Omit; + +export type TGetCaCertDTO = { + caId: string; +} & Omit; + +export type TSignIntermediateDTO = { + caId: string; + csr: string; + notBefore?: string; + notAfter: string; + maxPathLength: number; +} & Omit; + +export type TImportCertToCaDTO = { + caId: string; + certificate: string; + certificateChain: string; +} & Omit; + +export type TIssueCertFromCaDTO = { + caId: string; + friendlyName?: string; + commonName: string; + ttl: string; + notBefore?: string; + notAfter?: string; +} & Omit; + +export type TDNParts = { + commonName?: string; + organization?: string; + ou?: string; + country?: string; + province?: string; + locality?: string; +}; + +export type TGetCaCredentialsDTO = { + caId: string; + certificateAuthorityDAL: Pick; + certificateAuthoritySecretDAL: Pick; + projectDAL: Pick; + kmsService: Pick; +}; + +export type TGetCaCertChainDTO = { + caId: string; + certificateAuthorityDAL: Pick; + certificateAuthorityCertDAL: Pick; + projectDAL: Pick; + kmsService: Pick; +}; + +export type TRebuildCaCrlDTO = { + caId: string; + certificateAuthorityDAL: Pick; + certificateAuthorityCrlDAL: Pick; + certificateAuthoritySecretDAL: Pick; + projectDAL: Pick; + certificateDAL: Pick; + kmsService: Pick; +}; + +export type TRotateCaCrlTriggerDTO = { + caId: string; + rotationIntervalDays: number; +}; diff --git a/backend/src/services/certificate-authority/certificate-authority-validators.ts b/backend/src/services/certificate-authority/certificate-authority-validators.ts new file mode 100644 index 000000000..77bf9ad2f --- /dev/null +++ b/backend/src/services/certificate-authority/certificate-authority-validators.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +const isValidDate = (dateString: string) => { + const date = new Date(dateString); + return !Number.isNaN(date.getTime()); +}; + +export const validateCaDateField = z.string().trim().refine(isValidDate, { message: "Invalid date format" }); diff --git a/backend/src/services/certificate/certificate-body-dal.ts b/backend/src/services/certificate/certificate-body-dal.ts new file mode 100644 index 000000000..9ddc98966 --- /dev/null +++ b/backend/src/services/certificate/certificate-body-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TCertificateBodyDALFactory = ReturnType; + +export const certificateBodyDALFactory = (db: TDbClient) => { + const certificateBodyOrm = ormify(db, TableName.CertificateBody); + return certificateBodyOrm; +}; diff --git a/backend/src/services/certificate/certificate-dal.ts b/backend/src/services/certificate/certificate-dal.ts new file mode 100644 index 000000000..415bfabf9 --- /dev/null +++ b/backend/src/services/certificate/certificate-dal.ts @@ -0,0 +1,34 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify } from "@app/lib/knex"; + +export type TCertificateDALFactory = ReturnType; + +export const certificateDALFactory = (db: TDbClient) => { + const certificateOrm = ormify(db, TableName.Certificate); + + const countCertificatesInProject = async (projectId: string) => { + try { + interface CountResult { + count: string; + } + + const count = await db(TableName.Certificate) + .join(TableName.CertificateAuthority, `${TableName.Certificate}.caId`, `${TableName.CertificateAuthority}.id`) + .join(TableName.Project, `${TableName.CertificateAuthority}.projectId`, `${TableName.Project}.id`) + .where(`${TableName.Project}.id`, projectId) + .count("*") + .first(); + + return parseInt((count as unknown as CountResult).count || "0", 10); + } catch (error) { + throw new DatabaseError({ error, name: "Count all project certificates" }); + } + }; + + return { + ...certificateOrm, + countCertificatesInProject + }; +}; diff --git a/backend/src/services/certificate/certificate-fns.ts b/backend/src/services/certificate/certificate-fns.ts new file mode 100644 index 000000000..dfbd50551 --- /dev/null +++ b/backend/src/services/certificate/certificate-fns.ts @@ -0,0 +1,26 @@ +import * as x509 from "@peculiar/x509"; + +import { CrlReason } from "./certificate-types"; + +export const revocationReasonToCrlCode = (crlReason: CrlReason) => { + switch (crlReason) { + case CrlReason.KEY_COMPROMISE: + return x509.X509CrlReason.keyCompromise; + case CrlReason.CA_COMPROMISE: + return x509.X509CrlReason.cACompromise; + case CrlReason.AFFILIATION_CHANGED: + return x509.X509CrlReason.affiliationChanged; + case CrlReason.SUPERSEDED: + return x509.X509CrlReason.superseded; + case CrlReason.CESSATION_OF_OPERATION: + return x509.X509CrlReason.cessationOfOperation; + case CrlReason.CERTIFICATE_HOLD: + return x509.X509CrlReason.certificateHold; + case CrlReason.PRIVILEGE_WITHDRAWN: + return x509.X509CrlReason.privilegeWithdrawn; + case CrlReason.A_A_COMPROMISE: + return x509.X509CrlReason.aACompromise; + default: + return x509.X509CrlReason.unspecified; + } +}; diff --git a/backend/src/services/certificate/certificate-service.ts b/backend/src/services/certificate/certificate-service.ts new file mode 100644 index 000000000..ba865caa1 --- /dev/null +++ b/backend/src/services/certificate/certificate-service.ts @@ -0,0 +1,203 @@ +import { ForbiddenError } from "@casl/ability"; +import * as x509 from "@peculiar/x509"; + +import { TCertificateAuthorityCrlDALFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-dal"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal"; +import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; +import { TCertificateAuthorityCertDALFactory } from "@app/services/certificate-authority/certificate-authority-cert-dal"; +import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal"; +import { TCertificateAuthoritySecretDALFactory } from "@app/services/certificate-authority/certificate-authority-secret-dal"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; + +import { getCaCertChain, rebuildCaCrl } from "../certificate-authority/certificate-authority-fns"; +import { revocationReasonToCrlCode } from "./certificate-fns"; +import { CertStatus, TDeleteCertDTO, TGetCertBodyDTO, TGetCertDTO, TRevokeCertDTO } from "./certificate-types"; + +type TCertificateServiceFactoryDep = { + certificateDAL: Pick; + certificateBodyDAL: Pick; + certificateAuthorityDAL: Pick; + certificateAuthorityCertDAL: Pick; + certificateAuthorityCrlDAL: Pick; + certificateAuthoritySecretDAL: Pick; + projectDAL: Pick; + kmsService: Pick; + permissionService: Pick; +}; + +export type TCertificateServiceFactory = ReturnType; + +export const certificateServiceFactory = ({ + certificateDAL, + certificateBodyDAL, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + certificateAuthorityCrlDAL, + certificateAuthoritySecretDAL, + projectDAL, + kmsService, + permissionService +}: TCertificateServiceFactoryDep) => { + /** + * Return details for certificate with serial number [serialNumber] + */ + const getCert = async ({ serialNumber, actorId, actorAuthMethod, actor, actorOrgId }: TGetCertDTO) => { + const cert = await certificateDAL.findOne({ serialNumber }); + const ca = await certificateAuthorityDAL.findById(cert.caId); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Certificates); + + return { + cert, + ca + }; + }; + + /** + * Delete certificate with serial number [serialNumber] + */ + const deleteCert = async ({ serialNumber, actorId, actorAuthMethod, actor, actorOrgId }: TDeleteCertDTO) => { + const cert = await certificateDAL.findOne({ serialNumber }); + const ca = await certificateAuthorityDAL.findById(cert.caId); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Certificates); + + const deletedCert = await certificateDAL.deleteById(cert.id); + + return { + deletedCert, + ca + }; + }; + + /** + * Revoke certificate with serial number [serialNumber]. + * Note: Revoking a certificate adds it to the certificate revocation list (CRL) + * of its issuing CA + */ + const revokeCert = async ({ + serialNumber, + revocationReason, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TRevokeCertDTO) => { + const cert = await certificateDAL.findOne({ serialNumber }); + const ca = await certificateAuthorityDAL.findById(cert.caId); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Certificates); + + if (cert.status === CertStatus.REVOKED) throw new Error("Certificate already revoked"); + + const revokedAt = new Date(); + await certificateDAL.update( + { + id: cert.id + }, + { + status: CertStatus.REVOKED, + revokedAt, + revocationReason: revocationReasonToCrlCode(revocationReason) + } + ); + + // rebuild CRL (TODO: move to interval-based cron job) + await rebuildCaCrl({ + caId: ca.id, + certificateAuthorityDAL, + certificateAuthorityCrlDAL, + certificateAuthoritySecretDAL, + projectDAL, + certificateDAL, + kmsService + }); + + return { revokedAt, cert, ca }; + }; + + /** + * Return certificate body and certificate chain for certificate with + * serial number [serialNumber] + */ + const getCertBody = async ({ serialNumber, actorId, actorAuthMethod, actor, actorOrgId }: TGetCertBodyDTO) => { + const cert = await certificateDAL.findOne({ serialNumber }); + const ca = await certificateAuthorityDAL.findById(cert.caId); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Certificates); + + const certBody = await certificateBodyDAL.findOne({ certId: cert.id }); + + const keyId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + + const decryptedCert = await kmsService.decrypt({ + kmsId: keyId, + cipherTextBlob: certBody.encryptedCertificate + }); + + const certObj = new x509.X509Certificate(decryptedCert); + + const { caCert, caCertChain } = await getCaCertChain({ + caId: ca.id, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService + }); + + return { + certificate: certObj.toString("pem"), + certificateChain: `${caCert}\n${caCertChain}`.trim(), + serialNumber: certObj.serialNumber, + cert, + ca + }; + }; + + return { + getCert, + deleteCert, + revokeCert, + getCertBody + }; +}; diff --git a/backend/src/services/certificate/certificate-types.ts b/backend/src/services/certificate/certificate-types.ts new file mode 100644 index 000000000..93f72afe3 --- /dev/null +++ b/backend/src/services/certificate/certificate-types.ts @@ -0,0 +1,43 @@ +import { TProjectPermission } from "@app/lib/types"; + +export enum CertStatus { + ACTIVE = "active", + REVOKED = "revoked" +} + +export enum CertKeyAlgorithm { + RSA_2048 = "RSA_2048", + RSA_4096 = "RSA_4096", + ECDSA_P256 = "EC_prime256v1", + ECDSA_P384 = "EC_secp384r1" +} + +export enum CrlReason { + UNSPECIFIED = "UNSPECIFIED", + KEY_COMPROMISE = "KEY_COMPROMISE", + CA_COMPROMISE = "CA_COMPROMISE", + AFFILIATION_CHANGED = "AFFILIATION_CHANGED", + SUPERSEDED = "SUPERSEDED", + CESSATION_OF_OPERATION = "CESSATION_OF_OPERATION", + CERTIFICATE_HOLD = "CERTIFICATE_HOLD", + // REMOVE_FROM_CRL = "REMOVE_FROM_CRL", + PRIVILEGE_WITHDRAWN = "PRIVILEGE_WITHDRAWN", + A_A_COMPROMISE = "A_A_COMPROMISE" +} + +export type TGetCertDTO = { + serialNumber: string; +} & Omit; + +export type TDeleteCertDTO = { + serialNumber: string; +} & Omit; + +export type TRevokeCertDTO = { + serialNumber: string; + revocationReason: CrlReason; +} & Omit; + +export type TGetCertBodyDTO = { + serialNumber: string; +} & Omit; diff --git a/backend/src/services/kms/kms-service.ts b/backend/src/services/kms/kms-service.ts index 97d2b29d6..63aba8939 100644 --- a/backend/src/services/kms/kms-service.ts +++ b/backend/src/services/kms/kms-service.ts @@ -29,19 +29,22 @@ export const kmsServiceFactory = ({ kmsDAL, kmsRootConfigDAL, keyStore }: TKmsSe let ROOT_ENCRYPTION_KEY = Buffer.alloc(0); // this is used symmetric encryption - const generateKmsKey = async ({ scopeId, scopeType, isReserved = true }: TGenerateKMSDTO) => { + const generateKmsKey = async ({ scopeId, scopeType, isReserved = true, tx }: TGenerateKMSDTO) => { const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); const kmsKeyMaterial = randomSecureBytes(32); const encryptedKeyMaterial = cipher.encrypt(kmsKeyMaterial, ROOT_ENCRYPTION_KEY); - const { encryptedKey, ...doc } = await kmsDAL.create({ - version: 1, - encryptedKey: encryptedKeyMaterial, - encryptionAlgorithm: SymmetricEncryption.AES_GCM_256, - isReserved, - orgId: scopeType === "org" ? scopeId : undefined, - projectId: scopeType === "project" ? scopeId : undefined - }); + const { encryptedKey, ...doc } = await kmsDAL.create( + { + version: 1, + encryptedKey: encryptedKeyMaterial, + encryptionAlgorithm: SymmetricEncryption.AES_GCM_256, + isReserved, + orgId: scopeType === "org" ? scopeId : undefined, + projectId: scopeType === "project" ? scopeId : undefined + }, + tx + ); return doc; }; diff --git a/backend/src/services/kms/kms-types.ts b/backend/src/services/kms/kms-types.ts index 96ad25f6e..63fdaf484 100644 --- a/backend/src/services/kms/kms-types.ts +++ b/backend/src/services/kms/kms-types.ts @@ -1,7 +1,10 @@ +import { Knex } from "knex"; + export type TGenerateKMSDTO = { scopeType: "project" | "org"; scopeId: string; isReserved?: boolean; + tx?: Knex; }; export type TEncryptWithKmsDTO = { diff --git a/backend/src/services/project/project-fns.ts b/backend/src/services/project/project-fns.ts index 3ac75248d..78c7b442f 100644 --- a/backend/src/services/project/project-fns.ts +++ b/backend/src/services/project/project-fns.ts @@ -1,6 +1,9 @@ import crypto from "crypto"; import { decryptAsymmetric, encryptAsymmetric } from "@app/lib/crypto"; +import { BadRequestError } from "@app/lib/errors"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; import { AddUserToWsDTO } from "./project-types"; @@ -49,3 +52,44 @@ export const createProjectKey = ({ publicKey, privateKey, plainProjectKey }: TCr return { key: encryptedProjectKey, iv: encryptedProjectKeyIv }; }; + +export const getProjectKmsCertificateKeyId = async ({ + projectId, + projectDAL, + kmsService +}: { + projectId: string; + projectDAL: Pick; + kmsService: Pick; +}) => { + const keyId = await projectDAL.transaction(async (tx) => { + const project = await projectDAL.findOne({ id: projectId }, tx); + if (!project) { + throw new BadRequestError({ message: "Project not found" }); + } + + if (!project.kmsCertificateKeyId) { + // create default kms key for certificate service + const key = await kmsService.generateKmsKey({ + scopeId: projectId, + scopeType: "project", + isReserved: true, + tx + }); + + await projectDAL.updateById( + projectId, + { + kmsCertificateKeyId: key.id + }, + tx + ); + + return key.id; + } + + return project.kmsCertificateKeyId; + }); + + return keyId; +}; diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index ac6cfbc00..1a8e65a41 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -16,6 +16,8 @@ import { alphaNumericNanoId } from "@app/lib/nanoid"; import { TProjectPermission } from "@app/lib/types"; import { ActorType } from "../auth/auth-type"; +import { TCertificateDALFactory } from "../certificate/certificate-dal"; +import { TCertificateAuthorityDALFactory } from "../certificate-authority/certificate-authority-dal"; import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; import { TIdentityProjectDALFactory } from "../identity-project/identity-project-dal"; import { TIdentityProjectMembershipRoleDALFactory } from "../identity-project/identity-project-membership-role-dal"; @@ -36,6 +38,8 @@ import { TCreateProjectDTO, TDeleteProjectDTO, TGetProjectDTO, + TListProjectCasDTO, + TListProjectCertsDTO, TToggleProjectAutoCapitalizationDTO, TUpdateProjectDTO, TUpdateProjectNameDTO, @@ -50,6 +54,7 @@ export const DEFAULT_PROJECT_ENVS = [ ]; type TProjectServiceFactoryDep = { + // TODO: Pick projectDAL: TProjectDALFactory; projectQueue: TProjectQueueFactory; userDAL: TUserDALFactory; @@ -63,6 +68,8 @@ type TProjectServiceFactoryDep = { projectMembershipDAL: Pick; projectUserMembershipRoleDAL: Pick; secretBlindIndexDAL: Pick; + certificateAuthorityDAL: Pick; + certificateDAL: Pick; permissionService: TPermissionServiceFactory; orgService: Pick; licenseService: Pick; @@ -90,6 +97,8 @@ export const projectServiceFactory = ({ licenseService, projectUserMembershipRoleDAL, identityProjectMembershipRoleDAL, + certificateAuthorityDAL, + certificateDAL, keyStore }: TProjectServiceFactoryDep) => { /* @@ -523,6 +532,83 @@ export const projectServiceFactory = ({ return project.upgradeStatus || null; }; + /** + * Return list of CAs for project + */ + const listProjectCas = async ({ + status, + actorId, + actorOrgId, + actorAuthMethod, + filter, + actor + }: TListProjectCasDTO) => { + const project = await projectDAL.findProjectByFilter(filter); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + project.id, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.CertificateAuthorities + ); + + const cas = await certificateAuthorityDAL.find({ + projectId: project.id, + ...(status && { status }) + }); + + return cas; + }; + + /** + * Return list of certificates for project + */ + const listProjectCertificates = async ({ + offset, + limit, + actorId, + actorOrgId, + actorAuthMethod, + filter, + actor + }: TListProjectCertsDTO) => { + const project = await projectDAL.findProjectByFilter(filter); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + project.id, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Certificates); + + const cas = await certificateAuthorityDAL.find({ projectId: project.id }); + + const certificates = await certificateDAL.find( + { + $in: { + caId: cas.map((ca) => ca.id) + } + }, + { offset, limit, sort: [["updatedAt", "desc"]] } + ); + + const count = await certificateDAL.countCertificatesInProject(project.id); + + return { + certificates, + totalCount: count + }; + }; + return { createProject, deleteProject, @@ -533,6 +619,8 @@ export const projectServiceFactory = ({ toggleAutoCapitalization, updateName, upgradeProject, + listProjectCas, + listProjectCertificates, updateVersionLimit }; }; diff --git a/backend/src/services/project/project-types.ts b/backend/src/services/project/project-types.ts index fbcfd2d9a..e2d145d3d 100644 --- a/backend/src/services/project/project-types.ts +++ b/backend/src/services/project/project-types.ts @@ -2,6 +2,7 @@ import { ProjectMembershipRole, TProjectKeys } from "@app/db/schemas"; import { TProjectPermission } from "@app/lib/types"; import { ActorAuthMethod, ActorType } from "../auth/auth-type"; +import { CaStatus } from "../certificate-authority/certificate-authority-types"; export enum ProjectFilterType { ID = "id", @@ -80,3 +81,14 @@ export type AddUserToWsDTO = { userPublicKey: string; }[]; }; + +export type TListProjectCasDTO = { + status?: CaStatus; + filter: Filter; +} & Omit; + +export type TListProjectCertsDTO = { + filter: Filter; + offset: number; + limit: number; +} & Omit; diff --git a/docs/api-reference/endpoints/certificate-authorities/cert.mdx b/docs/api-reference/endpoints/certificate-authorities/cert.mdx new file mode 100644 index 000000000..3706e0b11 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-authorities/cert.mdx @@ -0,0 +1,4 @@ +--- +title: "Retrieve certificate / chain" +openapi: "GET /api/v1/pki/ca/{caId}/certificate" +--- diff --git a/docs/api-reference/endpoints/certificate-authorities/create.mdx b/docs/api-reference/endpoints/certificate-authorities/create.mdx new file mode 100644 index 000000000..35e758e4b --- /dev/null +++ b/docs/api-reference/endpoints/certificate-authorities/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/pki/ca" +--- diff --git a/docs/api-reference/endpoints/certificate-authorities/crl.mdx b/docs/api-reference/endpoints/certificate-authorities/crl.mdx new file mode 100644 index 000000000..a7b7755de --- /dev/null +++ b/docs/api-reference/endpoints/certificate-authorities/crl.mdx @@ -0,0 +1,4 @@ +--- +title: "Retrieve CRL" +openapi: "GET /api/v1/pki/ca/{caId}/crl" +--- diff --git a/docs/api-reference/endpoints/certificate-authorities/csr.mdx b/docs/api-reference/endpoints/certificate-authorities/csr.mdx new file mode 100644 index 000000000..2477a629e --- /dev/null +++ b/docs/api-reference/endpoints/certificate-authorities/csr.mdx @@ -0,0 +1,4 @@ +--- +title: "Get CSR" +openapi: "GET /api/v1/pki/ca/{caId}/csr" +--- diff --git a/docs/api-reference/endpoints/certificate-authorities/delete.mdx b/docs/api-reference/endpoints/certificate-authorities/delete.mdx new file mode 100644 index 000000000..f79b8f458 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-authorities/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/pki/ca/{caId}" +--- diff --git a/docs/api-reference/endpoints/certificate-authorities/import-cert.mdx b/docs/api-reference/endpoints/certificate-authorities/import-cert.mdx new file mode 100644 index 000000000..7f0e40f95 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-authorities/import-cert.mdx @@ -0,0 +1,4 @@ +--- +title: "Import certificate" +openapi: "POST /api/v1/pki/ca/{caId}/import-certificate" +--- diff --git a/docs/api-reference/endpoints/certificate-authorities/issue-cert.mdx b/docs/api-reference/endpoints/certificate-authorities/issue-cert.mdx new file mode 100644 index 000000000..045cada58 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-authorities/issue-cert.mdx @@ -0,0 +1,4 @@ +--- +title: "Issue certificate" +openapi: "POST /api/v1/pki/ca/{caId}/issue-certificate" +--- diff --git a/docs/api-reference/endpoints/certificate-authorities/read.mdx b/docs/api-reference/endpoints/certificate-authorities/read.mdx new file mode 100644 index 000000000..54dc26392 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-authorities/read.mdx @@ -0,0 +1,4 @@ +--- +title: "Retrieve" +openapi: "GET /api/v1/pki/ca/{caId}" +--- diff --git a/docs/api-reference/endpoints/certificate-authorities/sign-intermediate.mdx b/docs/api-reference/endpoints/certificate-authorities/sign-intermediate.mdx new file mode 100644 index 000000000..310bbea26 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-authorities/sign-intermediate.mdx @@ -0,0 +1,4 @@ +--- +title: "Sign intermediate certificate" +openapi: "POST /api/v1/pki/ca/{caId}/sign-intermediate" +--- diff --git a/docs/api-reference/endpoints/certificate-authorities/update.mdx b/docs/api-reference/endpoints/certificate-authorities/update.mdx new file mode 100644 index 000000000..d18a728bf --- /dev/null +++ b/docs/api-reference/endpoints/certificate-authorities/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/pki/ca/{caId}" +--- diff --git a/docs/api-reference/endpoints/certificates/cert-body.mdx b/docs/api-reference/endpoints/certificates/cert-body.mdx new file mode 100644 index 000000000..e4c3b0123 --- /dev/null +++ b/docs/api-reference/endpoints/certificates/cert-body.mdx @@ -0,0 +1,4 @@ +--- +title: "Get Certificate Body / Chain" +openapi: "GET /api/v1/pki/certificates/{serialNumber}/certificate" +--- diff --git a/docs/api-reference/endpoints/certificates/delete.mdx b/docs/api-reference/endpoints/certificates/delete.mdx new file mode 100644 index 000000000..27042af42 --- /dev/null +++ b/docs/api-reference/endpoints/certificates/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/pki/certificates/{serialNumber}" +--- diff --git a/docs/api-reference/endpoints/certificates/read.mdx b/docs/api-reference/endpoints/certificates/read.mdx new file mode 100644 index 000000000..ce6463dde --- /dev/null +++ b/docs/api-reference/endpoints/certificates/read.mdx @@ -0,0 +1,4 @@ +--- +title: "Retrieve" +openapi: "GET /api/v1/pki/certificates/{serialNumber}" +--- diff --git a/docs/api-reference/endpoints/certificates/revoke.mdx b/docs/api-reference/endpoints/certificates/revoke.mdx new file mode 100644 index 000000000..e4da73a19 --- /dev/null +++ b/docs/api-reference/endpoints/certificates/revoke.mdx @@ -0,0 +1,4 @@ +--- +title: "Revoke" +openapi: "POST /api/v1/pki/certificates/{serialNumber}/revoke" +--- diff --git a/docs/documentation/platform/pki/certificates.mdx b/docs/documentation/platform/pki/certificates.mdx new file mode 100644 index 000000000..fe5546681 --- /dev/null +++ b/docs/documentation/platform/pki/certificates.mdx @@ -0,0 +1,211 @@ +--- +title: "Certificates" +sidebarTitle: "Certificates" +description: "Learn how to issue X.509 certificates with Infisical." +--- + +## Concept + +Assuming that you've created a Private CA hierarchy with a root CA and an intermediate CA, you can now issue/revoke X.509 certificates using the intermediate CA. + +
+ +```mermaid +graph TD + A[Root CA] + A --> B[Intermediate CA] + A --> C[Intermediate CA] + B --> D[Leaf Certificate] + C --> E[Leaf Certificate] +``` + +
+ +## Workflow + +The typical workflow for managing certificates consists of the following steps: + +1. Issuing a certificate under an intermediate CA with details like name and validity period. +2. Managing certificate lifecycle events such as certificate renewal and revocation. As part of the certificate revocation flow, + you can also query for a Certificate Revocation List [CRL](https://en.wikipedia.org/wiki/Certificate_revocation_list), a time-stamped, signed + data structure issued by a CA containing a list of revoked certificates to check if a certificate has been revoked. + + + Note that this workflow can be executed via the Infisical UI or manually such + as via API. + + +## Guide to Issuing Certificates + +In the following steps, we explore how to issue a X.509 certificate under a CA. + + + + + + + To create a certificate, head to your Project > Internal PKI > Certificates and press **Create Certificate**. + + ![pki issue certificate](/images/platform/pki/cert-issue.png) + + Here, set the **CA** to the CA you want to issue the certificate under and fill out details for the certificate. + + ![pki issue certificate modal](/images/platform/pki/cert-issue-modal.png) + + Here's some guidance on each field: + + - Issuing CA: The CA under which to issue the certificate. + - Friendly Name: A friendly name for the certificate; this is only for display and defaults to the common name of the certificate if left empty. + - Common Name (CN): The (common) name of the certificate. + - TTL: The lifetime of the certificate in seconds. + - Valid Until: The date until which the certificate is valid in the date time string format specified [here](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#date_time_string_format). For example, the following formats would be valid: `YYYY`, `YYYY-MM`, `YYYY-MM-DD`, `YYYY-MM-DDTHH:mm:ss.sssZ`. + + + + Once you have created the certificate from step 1, you'll be presented with the certificate details including the **Certificate Body**, **Certificate Chain**, and **Private Key**. + + ![pki certificate body](/images/platform/pki/cert-body.png) + + + Make sure to download and store the **Private Key** in a secure location as it will only be displayed once at the time of certificate issuance. + The **Certificate Body** and **Certificate Chain** will remain accessible and can be copied at any time. + + + + + + To create a certificate, make an API request to the [Create Certificate](/api-reference/endpoints/certificate-authorities/sign-intermediate) API endpoint, + specifying the issuing CA. + + ### Sample request + + ```bash Request + curl --location --request POST 'https://app.infisical.com/api/v1/pki/ca//issue-certificate' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "commonName": "My Certificate", + }' + ``` + + ### Sample response + + ```bash Response + { + certificate: "...", + certificateChain: "...", + issuingCaCertificate: "...", + privateKey: "...", + serialNumber: "..." + } + ``` + + + Make sure to store the `privateKey` as it is only returned once here at the time of certificate issuance. The `certificate` and `certificateChain` will remain accessible and can be retrieved at any time. + + + + + +## Guide to Revoking Certificates + +In the following steps, we explore how to revoke a X.509 certificate under a CA and obtain a Certificate Revocation List (CRL) for a CA. + + + + + + Assuming that you've issued a certificate under a CA, you can revoke it by + selecting the **Revoke Certificate** option for it and specifying the reason + for revocation. + + ![pki revoke certificate](/images/platform/pki/cert-revoke.png) + + ![pki revoke certificate modal](/images/platform/pki/cert-revoke-modal.png) + + + + In order to check the revocation status of a certificate, you can check it + against the CRL of a CA by selecting the **View CRL** option under the + issuing CA and downloading the CRL file. + + ![pki view crl](/images/platform/pki/ca-crl.png) + + ![pki download crl](/images/platform/pki/ca-crl-modal.png) + + To verify a certificate against the + downloaded CRL with OpenSSL, you can use the following command: + +```bash +openssl verify -crl_check -CAfile chain.pem -CRLfile crl.pem cert.pem +``` + + + + + + + + Assuming that you've issued a certificate under a CA, you can revoke it by making an API request to the [Revoke Certificate](/api-reference/endpoints/certificate-authorities/revoke) API endpoint, + specifying the serial number of the certificate and the reason for revocation. + + ### Sample request + + ```bash Request + curl --location --request POST 'https://app.infisical.com/api/v1/pki/certificates//revoke' \ + --header 'Authorization: Bearer ' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "revocationReason": "UNSPECIFIED" + }' + ``` + + ### Sample response + + ```bash Response + { + message: "Successfully revoked certificate", + serialNumber: "...", + revokedAt: "..." + } + ``` + + + In order to check the revocation status of a certificate, you can check it against the CRL of the issuing CA. + To obtain the CRL of the CA, make an API request to the [Get CRL](/api-reference/endpoints/certificate-authorities/crl) API endpoint. + + ### Sample request + + ```bash Request + curl --location --request GET 'https://app.infisical.com/api/v1/pki/ca//crl' \ + --header 'Authorization: Bearer ' + ``` + + ### Sample response + + ```bash Response + { + crl: "..." + } + ``` + + To verify a certificate against the CRL with OpenSSL, you can use the following command: + + ```bash + openssl verify -crl_check -CAfile chain.pem -CRLfile crl.pem cert.pem + ``` + + + + + + +## FAQ + + + + To renew a certificate, you have to issue a new certificate from the same CA + with the same common name as the old certificate. The original certificate + will continue to be valid through its original TTL unless explicitly + revoked. + + diff --git a/docs/documentation/platform/pki/overview.mdx b/docs/documentation/platform/pki/overview.mdx new file mode 100644 index 000000000..259f15a5d --- /dev/null +++ b/docs/documentation/platform/pki/overview.mdx @@ -0,0 +1,12 @@ +--- +title: "Internal PKI" +sidebarTitle: "Overview" +description: "Learn how to create a Private CA hierarchy and issue X.509 certificates." +--- + +Infisical can be used to create a Private Certificate Authority (CA) hierarchy and issue X.509 certificates for internal use. This allows you to manage your own PKI infrastructure and issue digital certificates for services, applications, and devices. + +Infisical's internal PKI offering is split into two modules: + +- [Private CA](/documentation/platform/pki/private-ca): Infisical lets you create private CAs, including root and intermediary CAs. +- [Certificates](/documentation/platform/pki/certificates): Infisical allows you to issue X.509 certificates using the private CAs you create. diff --git a/docs/documentation/platform/pki/private-ca.mdx b/docs/documentation/platform/pki/private-ca.mdx new file mode 100644 index 000000000..0ebb31e2c --- /dev/null +++ b/docs/documentation/platform/pki/private-ca.mdx @@ -0,0 +1,250 @@ +--- +title: "Private CA" +sidebarTitle: "Private CA" +description: "Learn how to create a Private CA hierarchy with Infisical." +--- + +## Concept + +The first step to creating your Internal PKI is to create a Private Certificate Authority (CA) hierarchy that is a structure of entities +used to issue digital certificates for services, applications, and devices. + +
+ +```mermaid +graph TD + A[Root CA] + A --> B[Intermediate CA] + A --> C[Intermediate CA] +``` + +
+ +## Workflow + +A typical workflow for setting up a Private CA hierarchy consists of the following steps: + +1. Configuring a root CA with details like name, validity period, and path length. +2. Configuring and chaining intermediate CA(s) with details like name, validity period, path length, and imported certificate. +3. Managing the CA lifecycle events such as CA succession. + + + Note that this workflow can be executed via the Infisical UI or manually such + as via API. If manually executing the workflow, you may have to create a + Certificate Signing Request (CSR) for the intermediate CA, create an + intermediate certificate using the root CA private key and CSR, and import the + intermediate certificate back to the intermediate CA as part of Step 2. + + +## Guide + +In the following steps, we explore how to create a simple Private CA hierarchy +consisting of a root CA and an intermediate CA. + + + + + + To create a root CA, head to your Project > Internal PKI > Certificate Authorities and press **Create CA**. + + ![pki create ca](/images/platform/pki/ca-create.png) + + Here, set the **CA Type** to **Root** and fill out details for the root CA. + + ![pki create root ca](/images/platform/pki/ca-create-root.png) + + Here's some guidance on each field: + + - Valid Until: The date until which the CA is valid in the date time string format specified [here](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#date_time_string_format). For example, the following formats would be valid: `YYYY`, `YYYY-MM`, `YYYY-MM-DD`, `YYYY-MM-DDTHH:mm:ss.sssZ`. + - Path Length: The maximum number of intermediate CAs that can be chained to this CA. A path of `-1` implies no limit; a path of `0` implies no intermediate CAs can be chained. + - Key Algorithm: The type of public key algorithm and size, in bits, of the key pair that the CA creates when it issues a certificate. Supported key algorithms are `RSA 2048`, `RSA 4096`, `ECDSA P-256`, and `ECDSA P-384` with the default being `RSA 2048`. + - Friendly Name: A friendly name for the CA; this is only for display and defaults to the subject of the CA if left empty. + - Organization (O): The organization name. + - Country (C): The country code. + - State or Province Name: The state or province. + - Locality Name: The city or locality. + - Common Name: The name of the CA. + + + The Organization, Country, State or Province Name, Locality Name, and Common Name make up the **Distinguished Name (DN)** or **subject** of the CA. + At least one of these fields must be filled out. + + + + 1.1. To create an intermediate CA, press **Create CA** again but this time specifying the **CA Type** to be **Intermediate**. Fill out the details for the intermediate CA. + + ![pki create intermediate ca](/images/platform/pki/ca-create-intermediate.png) + + 1.2. Next, press the **Install Certificate** option on the intermediate CA from step 1.1. + + ![pki install cert opt](/images/platform/pki/ca-install-intermediate-opt.png) + + Here, set the **Parent CA** to the root CA created in step 1 and configure the intended **Valid Until** and **Path Length** fields on the intermediate CA; feel free to use the prefilled values. + + ![pki install cert](/images/platform/pki/ca-install-intermediate.png) + + Here's some guidance on each field: + + - Parent CA: The parent CA to which this intermediate CA will be chained. In this case, it should be the root CA created in step 1. + - Valid Until: The date until which the CA is valid in the date time string format specified [here](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#date_time_string_format). The date must be within the validity period of the parent CA. + - Path Length: The maximum number of intermediate CAs that can be chained to this CA. The path length must be less than the path length of the parent CA. + + Finally, press **Install** to chain the intermediate CA to the root CA; this creates a Certificate Signing Request (CSR) for the intermediate CA, creates an intermediate certificate using the root CA private key and CSR, and imports the signed certificate back to the intermediate CA. + + ![pki cas](/images/platform/pki/cas.png) + + Great! You've successfully created a Private CA hierarchy with a root CA and an intermediate CA. + Now check out the [Certificates](/documentation/platform/pki/certificates) page to learn more about how to issue X.509 certificates using the intermediate CA. + + + + + + + + To create a root CA, make an API request to the [Create CA](/api-reference/endpoints/certificate-authorities/create) API endpoint, specifying the `type` as `root`. + + ### Sample request + + ```bash Request + curl --location --request POST 'https://app.infisical.com/api/v1/pki/ca' \ + --header 'Authorization: Bearer ' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "projectSlug": "", + "type": "root", + "commonName": "My Root CA" + }' + ``` + + ### Sample response + + ```bash Response + { + ca: { + id: "", + type: "root", + commonName: "My Root CA", + ... + } + } + ``` + + By default, Infisical creates a root CA with the `RSA_2048` key algorithm, validity period of 10 years, with no restrictions on path length; + you may override these defaults by specifying your own options when making the API request. + + + + 2.1. To create an intermediate CA, make an API request to the [Create CA](/api-reference/endpoints/certificate-authorities/create) API endpoint, specifying the `type` as `intermediate`. + + ### Sample request + + ```bash Request + curl --location --request POST 'https://app.infisical.com/api/v1/pki/ca' \ + --header 'Authorization: Bearer ' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "projectSlug": "", + "type": "intermediate", + "commonName": "My Intermediate CA" + }' + ``` + + ### Sample response + + ```bash Response + { + ca: { + id: "", + type: "intermediate", + commonName: "My Intermediate CA", + ... + } + } + ``` + + 2.2. Next, get a certificate signing request from the intermediate CA by making an API request to the [Get CSR](/api-reference/endpoints/certificate-authorities/csr) API endpoint. + + ### Sample request + + ```bash Request + curl --location --request GET 'https://app.infisical.com/api/v1/pki/ca//csr' \ + --header 'Authorization: Bearer ' \ + --data-raw '' + ``` + + ### Sample response + + ```bash Response + { + csr: "..." + } + ``` + + 2.3. Next, create an intermediate certificate by making an API request to the [Sign Intermediate](/api-reference/endpoints/certificate-authorities/sign-intermediate) API endpoint + containing the CSR from step 2.2, referencing the root CA created in step 1. + + ### Sample request + + ```bash Request + curl --location --request POST 'https://app.infisical.com/api/v1/pki/ca//sign-intermediate' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "csr": "", + "notAfter": "2029-06-12" + }' + ``` + + ### Sample response + + ```bash Response + { + certificate: "...", + certificateChain: "...", + issuingCaCertificate: "...", + serialNumber: "...", + } + ``` + + + The `notAfter` value must be within the validity period of the root CA that is if the root CA is valid until `2029-06-12`, the intermediate CA must be valid until a date before `2029-06-12`. + + + 2.4. Finally, import the intermediate certificate and certificate chain from step 2.3 back to the intermediate CA by making an API request to the [Import Certificate](/api-reference/endpoints/certificate-authorities/import-cert) API endpoint. + + ### Sample request + + ```bash Request + curl --location --request POST 'https://app.infisical.com/api/v1/pki/ca//import-certificate' \ + --header 'Authorization: Bearer ' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "certificate": "", + "certificateChain": "" + }' + ``` + + ### Sample response + + ```bash Response + { + message: "Successfully imported certificate to CA", + caId: "..." + } + ``` + + Great! You’ve successfully created a Private CA hierarchy with a root CA and an intermediate CA. Now check out the Certificates page to learn more about how to issue X.509 certificates using the intermediate CA. + + + + + + +## FAQ + + + + Infisical supports `RSA 2048`, `RSA 4096`, `ECDSA P-256`, `ECDSA P-384` key + algorithms specified at the time of creating a CA. + + diff --git a/docs/images/platform/pki/ca-create-intermediate.png b/docs/images/platform/pki/ca-create-intermediate.png new file mode 100644 index 000000000..e52e5735c Binary files /dev/null and b/docs/images/platform/pki/ca-create-intermediate.png differ diff --git a/docs/images/platform/pki/ca-create-root.png b/docs/images/platform/pki/ca-create-root.png new file mode 100644 index 000000000..3c954b833 Binary files /dev/null and b/docs/images/platform/pki/ca-create-root.png differ diff --git a/docs/images/platform/pki/ca-create.png b/docs/images/platform/pki/ca-create.png new file mode 100644 index 000000000..35096c721 Binary files /dev/null and b/docs/images/platform/pki/ca-create.png differ diff --git a/docs/images/platform/pki/ca-crl-modal.png b/docs/images/platform/pki/ca-crl-modal.png new file mode 100644 index 000000000..af26b1aca Binary files /dev/null and b/docs/images/platform/pki/ca-crl-modal.png differ diff --git a/docs/images/platform/pki/ca-crl.png b/docs/images/platform/pki/ca-crl.png new file mode 100644 index 000000000..4794034a1 Binary files /dev/null and b/docs/images/platform/pki/ca-crl.png differ diff --git a/docs/images/platform/pki/ca-install-intermediate-opt.png b/docs/images/platform/pki/ca-install-intermediate-opt.png new file mode 100644 index 000000000..2bdcbf306 Binary files /dev/null and b/docs/images/platform/pki/ca-install-intermediate-opt.png differ diff --git a/docs/images/platform/pki/ca-install-intermediate.png b/docs/images/platform/pki/ca-install-intermediate.png new file mode 100644 index 000000000..ca30ad6ff Binary files /dev/null and b/docs/images/platform/pki/ca-install-intermediate.png differ diff --git a/docs/images/platform/pki/cas.png b/docs/images/platform/pki/cas.png new file mode 100644 index 000000000..b532768e2 Binary files /dev/null and b/docs/images/platform/pki/cas.png differ diff --git a/docs/images/platform/pki/cert-body.png b/docs/images/platform/pki/cert-body.png new file mode 100644 index 000000000..8ed67a7ec Binary files /dev/null and b/docs/images/platform/pki/cert-body.png differ diff --git a/docs/images/platform/pki/cert-issue-modal.png b/docs/images/platform/pki/cert-issue-modal.png new file mode 100644 index 000000000..1516ab1cb Binary files /dev/null and b/docs/images/platform/pki/cert-issue-modal.png differ diff --git a/docs/images/platform/pki/cert-issue.png b/docs/images/platform/pki/cert-issue.png new file mode 100644 index 000000000..6b3e5887b Binary files /dev/null and b/docs/images/platform/pki/cert-issue.png differ diff --git a/docs/images/platform/pki/cert-revoke-modal.png b/docs/images/platform/pki/cert-revoke-modal.png new file mode 100644 index 000000000..07bc7fce8 Binary files /dev/null and b/docs/images/platform/pki/cert-revoke-modal.png differ diff --git a/docs/images/platform/pki/cert-revoke.png b/docs/images/platform/pki/cert-revoke.png new file mode 100644 index 000000000..ff7fcc597 Binary files /dev/null and b/docs/images/platform/pki/cert-revoke.png differ diff --git a/docs/images/platform/pki/certs.png b/docs/images/platform/pki/certs.png new file mode 100644 index 000000000..4e1b49959 Binary files /dev/null and b/docs/images/platform/pki/certs.png differ diff --git a/docs/mint.json b/docs/mint.json index 3103bb59a..840649d03 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -102,6 +102,14 @@ "documentation/platform/webhooks" ] }, + { + "group": "Internal PKI", + "pages": [ + "documentation/platform/pki/overview ", + "documentation/platform/pki/private-ca", + "documentation/platform/pki/certificates" + ] + }, { "group": "Identities", "pages": [ @@ -557,6 +565,30 @@ { "group": "Audit Logs", "pages": ["api-reference/endpoints/audit-logs/export-audit-log"] + }, + { + "group": "Certificate Authorities", + "pages": [ + "api-reference/endpoints/certificate-authorities/create", + "api-reference/endpoints/certificate-authorities/read", + "api-reference/endpoints/certificate-authorities/update", + "api-reference/endpoints/certificate-authorities/delete", + "api-reference/endpoints/certificate-authorities/csr", + "api-reference/endpoints/certificate-authorities/cert", + "api-reference/endpoints/certificate-authorities/sign-intermediate", + "api-reference/endpoints/certificate-authorities/import-cert", + "api-reference/endpoints/certificate-authorities/issue-cert", + "api-reference/endpoints/certificate-authorities/crl" + ] + }, + { + "group": "Certificates", + "pages": [ + "api-reference/endpoints/certificates/read", + "api-reference/endpoints/certificates/revoke", + "api-reference/endpoints/certificates/delete", + "api-reference/endpoints/certificates/cert-body" + ] } ] }, diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index 79c8f2d30..113aaff19 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -24,7 +24,9 @@ export enum ProjectPermissionSub { SecretRollback = "secret-rollback", SecretApproval = "secret-approval", SecretRotation = "secret-rotation", - Identity = "identity" + Identity = "identity", + CertificateAuthorities = "certificate-authorities", + Certificates = "certificates" } type SubjectFields = { @@ -51,6 +53,8 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens] | [ProjectPermissionActions, ProjectPermissionSub.SecretApproval] | [ProjectPermissionActions, ProjectPermissionSub.SecretRotation] + | [ProjectPermissionActions, ProjectPermissionSub.CertificateAuthorities] + | [ProjectPermissionActions, ProjectPermissionSub.Certificates] | [ProjectPermissionActions.Delete, ProjectPermissionSub.Workspace] | [ProjectPermissionActions.Edit, ProjectPermissionSub.Workspace] | [ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback] diff --git a/frontend/src/hooks/api/auditLogs/constants.tsx b/frontend/src/hooks/api/auditLogs/constants.tsx index 348700374..082bff02c 100644 --- a/frontend/src/hooks/api/auditLogs/constants.tsx +++ b/frontend/src/hooks/api/auditLogs/constants.tsx @@ -26,7 +26,6 @@ export const eventToNameMap: { [K in EventType]: string } = { [EventType.CREATE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET]: "Create universal auth client secret", [EventType.REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET]: "Revoke universal auth client secret", [EventType.GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS]: "Get universal auth client secrets", - [EventType.GET_IDENTITY_UNIVERSAL_AUTH]: "Get universal auth", [EventType.CREATE_ENVIRONMENT]: "Create environment", [EventType.UPDATE_ENVIRONMENT]: "Update environment", [EventType.DELETE_ENVIRONMENT]: "Delete environment", @@ -43,7 +42,21 @@ export const eventToNameMap: { [K in EventType]: string } = { [EventType.UPDATE_SECRET_IMPORT]: "Update secret import", [EventType.DELETE_SECRET_IMPORT]: "Delete secret import", [EventType.UPDATE_USER_WORKSPACE_DENIED_PERMISSIONS]: "Update denied permissions", - [EventType.UPDATE_USER_WORKSPACE_ROLE]: "Update user role" + [EventType.UPDATE_USER_WORKSPACE_ROLE]: "Update user role", + [EventType.CREATE_CA]: "Create CA", + [EventType.GET_CA]: "Get CA", + [EventType.UPDATE_CA]: "Update CA", + [EventType.DELETE_CA]: "Delete CA", + [EventType.GET_CA_CSR]: "Get CA CSR", + [EventType.GET_CA_CERT]: "Get CA certificate", + [EventType.SIGN_INTERMEDIATE]: "Sign intermediate", + [EventType.IMPORT_CA_CERT]: "Import CA certificate", + [EventType.GET_CA_CRL]: "Get CA CRL", + [EventType.ISSUE_CERT]: "Issue certificate", + [EventType.GET_CERT]: "Get certificate", + [EventType.DELETE_CERT]: "Delete certificate", + [EventType.REVOKE_CERT]: "Revoke certificate", + [EventType.GET_CERT_BODY]: "Get certificate body" }; export const userAgentTTypeoNameMap: { [K in UserAgentType]: string } = { diff --git a/frontend/src/hooks/api/auditLogs/enums.tsx b/frontend/src/hooks/api/auditLogs/enums.tsx index f6ccd8f81..ad49998c5 100644 --- a/frontend/src/hooks/api/auditLogs/enums.tsx +++ b/frontend/src/hooks/api/auditLogs/enums.tsx @@ -56,5 +56,19 @@ export enum EventType { UPDATE_SECRET_IMPORT = "update-secret-import", DELETE_SECRET_IMPORT = "delete-secret-import", UPDATE_USER_WORKSPACE_ROLE = "update-user-workspace-role", - UPDATE_USER_WORKSPACE_DENIED_PERMISSIONS = "update-user-workspace-denied-permissions" + UPDATE_USER_WORKSPACE_DENIED_PERMISSIONS = "update-user-workspace-denied-permissions", + CREATE_CA = "create-certificate-authority", + GET_CA = "get-certificate-authority", + UPDATE_CA = "update-certificate-authority", + DELETE_CA = "delete-certificate-authority", + GET_CA_CSR = "get-certificate-authority-csr", + GET_CA_CERT = "get-certificate-authority-cert", + SIGN_INTERMEDIATE = "sign-intermediate", + IMPORT_CA_CERT = "import-certificate-authority-cert", + GET_CA_CRL = "get-certificate-authority-crl", + ISSUE_CERT = "issue-cert", + GET_CERT = "get-cert", + DELETE_CERT = "delete-cert", + REVOKE_CERT = "revoke-cert", + GET_CERT_BODY = "get-cert-body" } diff --git a/frontend/src/hooks/api/auditLogs/types.tsx b/frontend/src/hooks/api/auditLogs/types.tsx index 92611c814..3b607e596 100644 --- a/frontend/src/hooks/api/auditLogs/types.tsx +++ b/frontend/src/hooks/api/auditLogs/types.tsx @@ -1,3 +1,4 @@ +import { CaStatus } from "../ca"; import { IdentityTrustedIp } from "../identities/types"; import { ActorType, EventType, UserAgentType } from "./enums"; @@ -462,6 +463,125 @@ interface UpdateUserDeniedPermissions { }; } +interface CreateCa { + type: EventType.CREATE_CA; + metadata: { + caId: string; + dn: string; + }; +} + +interface GetCa { + type: EventType.GET_CA; + metadata: { + caId: string; + dn: string; + }; +} + +interface UpdateCa { + type: EventType.UPDATE_CA; + metadata: { + caId: string; + dn: string; + status: CaStatus; + }; +} + +interface DeleteCa { + type: EventType.DELETE_CA; + metadata: { + caId: string; + dn: string; + }; +} + +interface GetCaCsr { + type: EventType.GET_CA_CSR; + metadata: { + caId: string; + dn: string; + }; +} + +interface GetCaCert { + type: EventType.GET_CA_CERT; + metadata: { + caId: string; + dn: string; + }; +} + +interface SignIntermediate { + type: EventType.SIGN_INTERMEDIATE; + metadata: { + caId: string; + dn: string; + serialNumber: string; + }; +} + +interface ImportCaCert { + type: EventType.IMPORT_CA_CERT; + metadata: { + caId: string; + dn: string; + }; +} + +interface GetCaCrl { + type: EventType.GET_CA_CRL; + metadata: { + caId: string; + dn: string; + }; +} + +interface IssueCert { + type: EventType.ISSUE_CERT; + metadata: { + caId: string; + dn: string; + serialNumber: string; + }; +} + +interface GetCert { + type: EventType.GET_CERT; + metadata: { + certId: string; + cn: string; + serialNumber: string; + }; +} + +interface DeleteCert { + type: EventType.DELETE_CERT; + metadata: { + certId: string; + cn: string; + serialNumber: string; + }; +} + +interface RevokeCert { + type: EventType.REVOKE_CERT; + metadata: { + certId: string; + cn: string; + serialNumber: string; + }; +} + +interface GetCertBody { + type: EventType.GET_CERT_BODY; + metadata: { + certId: string; + cn: string; + serialNumber: string; + }; +} + export type Event = | GetSecretsEvent | GetSecretEvent @@ -504,7 +624,21 @@ export type Event = | UpdateSecretImportEvent | DeleteSecretImportEvent | UpdateUserRole - | UpdateUserDeniedPermissions; + | UpdateUserDeniedPermissions + | CreateCa + | GetCa + | UpdateCa + | DeleteCa + | GetCaCsr + | GetCaCert + | SignIntermediate + | ImportCaCert + | GetCaCrl + | IssueCert + | GetCert + | DeleteCert + | RevokeCert + | GetCertBody; export type AuditLog = { id: string; diff --git a/frontend/src/hooks/api/ca/constants.tsx b/frontend/src/hooks/api/ca/constants.tsx new file mode 100644 index 000000000..dbef15ffd --- /dev/null +++ b/frontend/src/hooks/api/ca/constants.tsx @@ -0,0 +1,12 @@ +import { CaStatus,CaType } from "./enums"; + +export const caTypeToNameMap: { [K in CaType]: string } = { + [CaType.ROOT]: "Root", + [CaType.INTERMEDIATE]: "Intermediate" +}; + +export const caStatusToNameMap: { [K in CaStatus]: string } = { + [CaStatus.ACTIVE]: "Active", + [CaStatus.DISABLED]: "Disabled", + [CaStatus.PENDING_CERTIFICATE]: "Pending Certificate" +}; diff --git a/frontend/src/hooks/api/ca/enums.tsx b/frontend/src/hooks/api/ca/enums.tsx new file mode 100644 index 000000000..bdd498a12 --- /dev/null +++ b/frontend/src/hooks/api/ca/enums.tsx @@ -0,0 +1,10 @@ +export enum CaType { + ROOT = "root", + INTERMEDIATE = "intermediate" +} + +export enum CaStatus { + ACTIVE = "active", + DISABLED = "disabled", + PENDING_CERTIFICATE = "pending-certificate" +} diff --git a/frontend/src/hooks/api/ca/index.tsx b/frontend/src/hooks/api/ca/index.tsx new file mode 100644 index 000000000..60b53478d --- /dev/null +++ b/frontend/src/hooks/api/ca/index.tsx @@ -0,0 +1,10 @@ +export { CaStatus, CaType } from "./enums"; +export { + useCreateCa, + useCreateCertificate, + useDeleteCa, + useImportCaCertificate, + useSignIntermediate, + useUpdateCa +} from "./mutations"; +export { useGetCaById, useGetCaCert, useGetCaCrl,useGetCaCsr } from "./queries"; diff --git a/frontend/src/hooks/api/ca/mutations.tsx b/frontend/src/hooks/api/ca/mutations.tsx new file mode 100644 index 000000000..bb018f71a --- /dev/null +++ b/frontend/src/hooks/api/ca/mutations.tsx @@ -0,0 +1,108 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { workspaceKeys } from "../workspace/queries"; +import { + TCertificateAuthority, + TCreateCaDTO, + TCreateCertificateDTO, + TCreateCertificateResponse, + TDeleteCaDTO, + TImportCaCertificateDTO, + TImportCaCertificateResponse, + TSignIntermediateDTO, + TSignIntermediateResponse, + TUpdateCaDTO +} from "./types"; + +export const useCreateCa = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (body) => { + const { + data: { ca } + } = await apiRequest.post<{ ca: TCertificateAuthority }>("/api/v1/pki/ca/", body); + return ca; + }, + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceCas({ projectSlug })); + } + }); +}; + +export const useUpdateCa = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ caId, projectSlug, ...body }) => { + const { + data: { ca } + } = await apiRequest.patch<{ ca: TCertificateAuthority }>(`/api/v1/pki/ca/${caId}`, body); + return ca; + }, + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceCas({ projectSlug })); + } + }); +}; + +export const useDeleteCa = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ caId }) => { + const { + data: { ca } + } = await apiRequest.delete<{ ca: TCertificateAuthority }>(`/api/v1/pki/ca/${caId}`); + return ca; + }, + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceCas({ projectSlug })); + } + }); +}; + +export const useSignIntermediate = () => { + // TODO: consider renaming + return useMutation({ + mutationFn: async (body) => { + const { data } = await apiRequest.post( + `/api/v1/pki/ca/${body.caId}/sign-intermediate`, + body + ); + return data; + } + }); +}; + +export const useImportCaCertificate = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ caId, ...body }) => { + const { data } = await apiRequest.post( + `/api/v1/pki/ca/${caId}/import-certificate`, + body + ); + return data; + }, + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceCas({ projectSlug })); + } + }); +}; + +// consider rename to issue certificate +export const useCreateCertificate = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ caId, ...body }) => { + const { data } = await apiRequest.post( + `/api/v1/pki/ca/${caId}/issue-certificate`, + body + ); + return data; + }, + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries(workspaceKeys.forWorkspaceCertificates(projectSlug)); + } + }); +}; diff --git a/frontend/src/hooks/api/ca/queries.tsx b/frontend/src/hooks/api/ca/queries.tsx new file mode 100644 index 000000000..e78274391 --- /dev/null +++ b/frontend/src/hooks/api/ca/queries.tsx @@ -0,0 +1,70 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TCertificateAuthority } from "./types"; + +export const caKeys = { + getCaById: (caId: string) => [{ caId }, "ca"], + getCaCert: (caId: string) => [{ caId }, "ca-cert"], + getCaCsr: (caId: string) => [{ caId }, "ca-csr"], + getCaCrl: (caId: string) => [{ caId }, "ca-crl"] +}; + +export const useGetCaById = (caId: string) => { + return useQuery({ + queryKey: caKeys.getCaById(caId), + queryFn: async () => { + const { + data: { ca } + } = await apiRequest.get<{ ca: TCertificateAuthority }>(`/api/v1/pki/ca/${caId}`); + return ca; + }, + enabled: Boolean(caId) + }); +}; + +export const useGetCaCert = (caId: string) => { + return useQuery({ + queryKey: caKeys.getCaCert(caId), + queryFn: async () => { + const { data } = await apiRequest.get<{ + certificate: string; + certificateChain: string; + serialNumber: string; + }>(`/api/v1/pki/ca/${caId}/certificate`); + return data; + }, + enabled: Boolean(caId) + }); +}; + +export const useGetCaCsr = (caId: string) => { + return useQuery({ + queryKey: caKeys.getCaCsr(caId), + queryFn: async () => { + const { + data: { csr } + } = await apiRequest.get<{ + csr: string; + }>(`/api/v1/pki/ca/${caId}/csr`); + return csr; + }, + enabled: Boolean(caId) + }); +}; + +export const useGetCaCrl = (caId: string) => { + return useQuery({ + queryKey: caKeys.getCaCrl(caId), + queryFn: async () => { + const { + data: { crl } + } = await apiRequest.get<{ + crl: string; + }>(`/api/v1/pki/ca/${caId}/crl`); + return crl; + }, + enabled: Boolean(caId) + }); +}; diff --git a/frontend/src/hooks/api/ca/types.ts b/frontend/src/hooks/api/ca/types.ts new file mode 100644 index 000000000..64511253b --- /dev/null +++ b/frontend/src/hooks/api/ca/types.ts @@ -0,0 +1,95 @@ +import { CertKeyAlgorithm } from "../certificates/enums"; +import { CaStatus, CaType } from "./enums"; + +export type TCertificateAuthority = { + id: string; + parentCaId?: string; + projectId: string; + type: CaType; + status: CaStatus; + friendlyName: string; + organization: string; + ou: string; + country: string; + province: string; + locality: string; + commonName: string; + dn: string; + maxPathLength?: number; + notAfter?: string; + notBefore?: string; + keyAlgorithm: CertKeyAlgorithm; + createdAt: string; + updatedAt: string; +}; + +export type TCreateCaDTO = { + projectSlug: string; + type: string; + friendlyName?: string; + organization: string; + ou: string; + country: string; + province: string; + locality: string; + commonName: string; + notAfter?: string; + maxPathLength: number; + keyAlgorithm: CertKeyAlgorithm; +}; + +export type TUpdateCaDTO = { + projectSlug: string; + caId: string; + status?: CaStatus; +}; + +export type TDeleteCaDTO = { + projectSlug: string; + caId: string; +}; + +export type TSignIntermediateDTO = { + caId: string; + csr: string; + maxPathLength: number; + notBefore?: string; + notAfter?: string; +}; + +export type TSignIntermediateResponse = { + certificate: string; + certificateChain: string; + issuingCaCertificate: string; + serialNumber: string; +}; + +export type TImportCaCertificateDTO = { + caId: string; + projectSlug: string; + certificate: string; + certificateChain: string; +}; + +export type TImportCaCertificateResponse = { + message: string; + caId: string; +}; + +export type TCreateCertificateDTO = { + projectSlug: string; + caId: string; + friendlyName?: string; + commonName: string; + ttl: string; // string compatible with ms + notBefore?: string; + notAfter?: string; +}; + +export type TCreateCertificateResponse = { + certificate: string; + issuingCertificate: string; + certificateChain: string; + privateKey: string; + serialNumber: string; +}; diff --git a/frontend/src/hooks/api/certificates/constants.tsx b/frontend/src/hooks/api/certificates/constants.tsx new file mode 100644 index 000000000..e5a9b7a43 --- /dev/null +++ b/frontend/src/hooks/api/certificates/constants.tsx @@ -0,0 +1,60 @@ +import { CertKeyAlgorithm, CertStatus,CrlReason } from "./enums"; + +export const certStatusToNameMap: { [K in CertStatus]: string } = { + [CertStatus.ACTIVE]: "Active", + [CertStatus.REVOKED]: "Revoked" +}; + +export const certKeyAlgorithmToNameMap: { [K in CertKeyAlgorithm]: string } = { + [CertKeyAlgorithm.RSA_2048]: "RSA 2048", + [CertKeyAlgorithm.RSA_4096]: "RSA 4096", + [CertKeyAlgorithm.ECDSA_P256]: "ECDSA P256", + [CertKeyAlgorithm.ECDSA_P384]: "ECDSA P384" +}; + +export const certKeyAlgorithms = [ + { label: certKeyAlgorithmToNameMap[CertKeyAlgorithm.RSA_2048], value: CertKeyAlgorithm.RSA_2048 }, + { label: certKeyAlgorithmToNameMap[CertKeyAlgorithm.RSA_4096], value: CertKeyAlgorithm.RSA_4096 }, + { + label: certKeyAlgorithmToNameMap[CertKeyAlgorithm.ECDSA_P256], + value: CertKeyAlgorithm.ECDSA_P256 + }, + { + label: certKeyAlgorithmToNameMap[CertKeyAlgorithm.ECDSA_P384], + value: CertKeyAlgorithm.ECDSA_P384 + } +]; + +export const crlReasonToNameMap: { [K in CrlReason]: string } = { + [CrlReason.UNSPECIFIED]: "Unspecified", + [CrlReason.KEY_COMPROMISE]: "Key Compromise", + [CrlReason.CA_COMPROMISE]: "CA Compromise", + [CrlReason.AFFILIATION_CHANGED]: "Affiliation Changed", + [CrlReason.SUPERSEDED]: "Superseded", + [CrlReason.CESSATION_OF_OPERATION]: "Cessation of Operation", + [CrlReason.CERTIFICATE_HOLD]: "Certificate Hold", + // [CrlReason.REMOVE_FROM_CRL]: "Remove from CRL", + [CrlReason.PRIVILEGE_WITHDRAWN]: "Privilege Withdrawn", + [CrlReason.A_A_COMPROMISE]: "A/A Compromise" +}; + +export const crlReasons = [ + { label: crlReasonToNameMap[CrlReason.UNSPECIFIED], value: CrlReason.UNSPECIFIED }, + { label: crlReasonToNameMap[CrlReason.KEY_COMPROMISE], value: CrlReason.KEY_COMPROMISE }, + { label: crlReasonToNameMap[CrlReason.CA_COMPROMISE], value: CrlReason.CA_COMPROMISE }, + { + label: crlReasonToNameMap[CrlReason.AFFILIATION_CHANGED], + value: CrlReason.AFFILIATION_CHANGED + }, + { label: crlReasonToNameMap[CrlReason.SUPERSEDED], value: CrlReason.SUPERSEDED }, + { + label: crlReasonToNameMap[CrlReason.CESSATION_OF_OPERATION], + value: CrlReason.CESSATION_OF_OPERATION + }, + { label: crlReasonToNameMap[CrlReason.CERTIFICATE_HOLD], value: CrlReason.CERTIFICATE_HOLD }, + { + label: crlReasonToNameMap[CrlReason.PRIVILEGE_WITHDRAWN], + value: CrlReason.PRIVILEGE_WITHDRAWN + }, + { label: crlReasonToNameMap[CrlReason.A_A_COMPROMISE], value: CrlReason.A_A_COMPROMISE } +]; diff --git a/frontend/src/hooks/api/certificates/enums.tsx b/frontend/src/hooks/api/certificates/enums.tsx new file mode 100644 index 000000000..d0da0273a --- /dev/null +++ b/frontend/src/hooks/api/certificates/enums.tsx @@ -0,0 +1,24 @@ +export enum CertStatus { + ACTIVE = "active", + REVOKED = "revoked" +} + +export enum CertKeyAlgorithm { + RSA_2048 = "RSA_2048", + RSA_4096 = "RSA_4096", + ECDSA_P256 = "EC_prime256v1", + ECDSA_P384 = "EC_secp384r1" +} + +export enum CrlReason { + UNSPECIFIED = "UNSPECIFIED", + KEY_COMPROMISE = "KEY_COMPROMISE", + CA_COMPROMISE = "CA_COMPROMISE", + AFFILIATION_CHANGED = "AFFILIATION_CHANGED", + SUPERSEDED = "SUPERSEDED", + CESSATION_OF_OPERATION = "CESSATION_OF_OPERATION", + CERTIFICATE_HOLD = "CERTIFICATE_HOLD", + // REMOVE_FROM_CRL = "REMOVE_FROM_CRL", + PRIVILEGE_WITHDRAWN = "PRIVILEGE_WITHDRAWN", + A_A_COMPROMISE = "A_A_COMPROMISE" +} diff --git a/frontend/src/hooks/api/certificates/index.tsx b/frontend/src/hooks/api/certificates/index.tsx new file mode 100644 index 000000000..dd922fd6a --- /dev/null +++ b/frontend/src/hooks/api/certificates/index.tsx @@ -0,0 +1,2 @@ +export { useDeleteCert, useRevokeCert } from "./mutations"; +export { useGetCert, useGetCertBody } from "./queries"; diff --git a/frontend/src/hooks/api/certificates/mutations.tsx b/frontend/src/hooks/api/certificates/mutations.tsx new file mode 100644 index 000000000..4c0f92339 --- /dev/null +++ b/frontend/src/hooks/api/certificates/mutations.tsx @@ -0,0 +1,43 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { workspaceKeys } from "../workspace/queries"; +import { TCertificate, TDeleteCertDTO, TRevokeCertDTO } from "./types"; + +export const useDeleteCert = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ serialNumber }) => { + const { + data: { certificate } + } = await apiRequest.delete<{ certificate: TCertificate }>( + `/api/v1/pki/certificates/${serialNumber}` + ); + return certificate; + }, + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries(workspaceKeys.forWorkspaceCertificates(projectSlug)); + } + }); +}; + +export const useRevokeCert = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ serialNumber, revocationReason }) => { + const { + data: { certificate } + } = await apiRequest.post<{ certificate: TCertificate }>( + `/api/v1/pki/certificates/${serialNumber}/revoke`, + { + revocationReason + } + ); + return certificate; + }, + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries(workspaceKeys.forWorkspaceCertificates(projectSlug)); + } + }); +}; diff --git a/frontend/src/hooks/api/certificates/queries.tsx b/frontend/src/hooks/api/certificates/queries.tsx new file mode 100644 index 000000000..50c751c06 --- /dev/null +++ b/frontend/src/hooks/api/certificates/queries.tsx @@ -0,0 +1,40 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TCertificate } from "./types"; + +export const certKeys = { + getCertById: (serialNumber: string) => [{ serialNumber }, "cert"], + getCertBody: (serialNumber: string) => [{ serialNumber }, "certBody"] +}; + +export const useGetCert = (serialNumber: string) => { + return useQuery({ + queryKey: certKeys.getCertById(serialNumber), + queryFn: async () => { + const { + data: { certificate } + } = await apiRequest.get<{ certificate: TCertificate }>( + `/api/v1/pki/certificates/${serialNumber}` + ); + return certificate; + }, + enabled: Boolean(serialNumber) + }); +}; + +export const useGetCertBody = (serialNumber: string) => { + return useQuery({ + queryKey: certKeys.getCertBody(serialNumber), + queryFn: async () => { + const { data } = await apiRequest.get<{ + certificate: string; + certificateChain: string; + serialNumber: string; + }>(`/api/v1/pki/certificates/${serialNumber}/certificate`); + return data; + }, + enabled: Boolean(serialNumber) + }); +}; diff --git a/frontend/src/hooks/api/certificates/types.ts b/frontend/src/hooks/api/certificates/types.ts new file mode 100644 index 000000000..2dd600187 --- /dev/null +++ b/frontend/src/hooks/api/certificates/types.ts @@ -0,0 +1,23 @@ +import { CertStatus } from "./enums"; + +export type TCertificate = { + id: string; + caId: string; + status: CertStatus; + friendlyName: string; + commonName: string; + serialNumber: string; + notBefore: string; + notAfter: string; +}; + +export type TDeleteCertDTO = { + projectSlug: string; + serialNumber: string; +}; + +export type TRevokeCertDTO = { + projectSlug: string; + serialNumber: string; + revocationReason: string; +}; diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index 19fd5f594..b87c3f13f 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -5,6 +5,8 @@ export * from "./auditLogs"; export * from "./auditLogStreams"; export * from "./auth"; export * from "./bots"; +export * from "./ca"; +export * from "./certificates"; export * from "./dynamicSecret"; export * from "./dynamicSecretLease"; export * from "./groups"; diff --git a/frontend/src/hooks/api/subscriptions/types.ts b/frontend/src/hooks/api/subscriptions/types.ts index 45414292d..66959ad1b 100644 --- a/frontend/src/hooks/api/subscriptions/types.ts +++ b/frontend/src/hooks/api/subscriptions/types.ts @@ -25,14 +25,15 @@ export type SubscriptionPlan = { ldap: boolean; groups: boolean; status: - | "incomplete" - | "incomplete_expired" - | "trialing" - | "active" - | "past_due" - | "canceled" - | "unpaid" - | null; + | "incomplete" + | "incomplete_expired" + | "trialing" + | "active" + | "past_due" + | "canceled" + | "unpaid" + | null; trial_end: number | null; has_used_trial: boolean; + caCrl: boolean; }; diff --git a/frontend/src/hooks/api/workspace/index.tsx b/frontend/src/hooks/api/workspace/index.tsx index b0cadac23..1daec0fd0 100644 --- a/frontend/src/hooks/api/workspace/index.tsx +++ b/frontend/src/hooks/api/workspace/index.tsx @@ -21,6 +21,8 @@ export { useGetWorkspaceIntegrations, useGetWorkspaceSecrets, useGetWorkspaceUsers, + useListWorkspaceCas, + useListWorkspaceCertificates, useListWorkspaceGroups, useNameWorkspaceSecrets, useRenameWorkspace, @@ -28,5 +30,4 @@ export { useUpdateIdentityWorkspaceRole, useUpdateUserWorkspaceRole, useUpdateWsEnvironment, - useUpgradeProject -} from "./queries"; + useUpgradeProject} from "./queries"; diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index 6462490bb..7f94bb498 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -2,6 +2,9 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; +import { CaStatus } from "../ca/enums"; +import { TCertificateAuthority } from "../ca/types"; +import { TCertificate } from "../certificates/types"; import { TGroupMembership } from "../groups/types"; import { IdentityMembership } from "../identities/types"; import { IntegrationAuth } from "../integrationAuth/types"; @@ -40,7 +43,23 @@ export const workspaceKeys = { getWorkspaceIdentityMemberships: (workspaceId: string) => [{ workspaceId }, "workspace-identity-memberships"] as const, getWorkspaceGroupMemberships: (workspaceId: string) => - [{ workspaceId }, "workspace-groups"] as const + [{ workspaceId }, "workspace-groups"] as const, + getWorkspaceCas: ({ projectSlug }: { projectSlug: string }) => + [{ projectSlug }, "workspace-cas"] as const, + specificWorkspaceCas: ({ projectSlug, status }: { projectSlug: string; status?: CaStatus }) => + [...workspaceKeys.getWorkspaceCas({ projectSlug }), { status }] as const, + allWorkspaceCertificates: () => ["workspace-certificates"] as const, + forWorkspaceCertificates: (slug: string) => + [...workspaceKeys.allWorkspaceCertificates(), slug] as const, + specificWorkspaceCertificates: ({ + slug, + offset, + limit + }: { + slug: string; + offset: number; + limit: number; + }) => [...workspaceKeys.forWorkspaceCertificates(slug), { offset, limit }] as const }; const fetchWorkspaceById = async (workspaceId: string) => { @@ -485,3 +504,70 @@ export const useListWorkspaceGroups = (projectSlug: string) => { enabled: true }); }; + +export const useListWorkspaceCas = ({ + projectSlug, + status +}: { + projectSlug: string; + status?: CaStatus; +}) => { + return useQuery({ + queryKey: workspaceKeys.specificWorkspaceCas({ + projectSlug, + status + }), + queryFn: async () => { + const params = new URLSearchParams({ + ...(status && { status }) + }); + + const { + data: { cas } + } = await apiRequest.get<{ cas: TCertificateAuthority[] }>( + `/api/v2/workspace/${projectSlug}/cas`, + { + params + } + ); + return cas; + }, + enabled: Boolean(projectSlug) + }); +}; + +export const useListWorkspaceCertificates = ({ + projectSlug, + offset, + limit +}: { + projectSlug: string; + offset: number; + limit: number; +}) => { + return useQuery({ + queryKey: workspaceKeys.specificWorkspaceCertificates({ + slug: projectSlug, + offset, + limit + }), + queryFn: async () => { + const params = new URLSearchParams({ + offset: String(offset), + limit: String(limit) + }); + + const { + data: { certificates, totalCount } + } = await apiRequest.get<{ certificates: TCertificate[]; totalCount: number }>( + `/api/v2/workspace/${projectSlug}/certificates`, + { + params + } + ); + + return { certificates, totalCount }; + }, + enabled: Boolean(projectSlug) + }); +}; diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index 1b5df0037..72351df88 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -519,6 +519,18 @@ export const AppLayout = ({ children }: LayoutProps) => { + + + + Internal PKI + + + { Secret Sharing diff --git a/frontend/src/pages/project/[id]/certificates/index.tsx b/frontend/src/pages/project/[id]/certificates/index.tsx new file mode 100644 index 000000000..ede2e75f0 --- /dev/null +++ b/frontend/src/pages/project/[id]/certificates/index.tsx @@ -0,0 +1,23 @@ +import { useTranslation } from "react-i18next"; +import Head from "next/head"; + +import { CertificatesPage } from "@app/views/Project/CertificatesPage"; + +const Certificates = () => { + const { t } = useTranslation(); + + return ( +
+ + {t("common.head-title", { title: "Certificates" })} + + + + +
+ ); +}; + +export default Certificates; + +Certificates.requireAuth = true; diff --git a/frontend/src/views/Project/AuditLogsPage/components/LogsTableRow.tsx b/frontend/src/views/Project/AuditLogsPage/components/LogsTableRow.tsx index 7fc69400e..f4b740e35 100644 --- a/frontend/src/views/Project/AuditLogsPage/components/LogsTableRow.tsx +++ b/frontend/src/views/Project/AuditLogsPage/components/LogsTableRow.tsx @@ -320,6 +320,30 @@ export const LogsTableRow = ({ auditLog }: Props) => { })} ); + case EventType.CREATE_CA: + case EventType.GET_CA: + case EventType.UPDATE_CA: + case EventType.DELETE_CA: + case EventType.GET_CA_CSR: + case EventType.GET_CA_CERT: + case EventType.IMPORT_CA_CERT: + case EventType.GET_CA_CRL: + case EventType.SIGN_INTERMEDIATE: + case EventType.ISSUE_CERT: + return ( + +

{`CA DN: ${event.metadata.dn}`}

+ + ); + case EventType.GET_CERT: + case EventType.DELETE_CERT: + case EventType.REVOKE_CERT: + case EventType.GET_CERT_BODY: + return ( + +

{`Cert CN: ${event.metadata.cn}`}

+ + ); default: return ; } diff --git a/frontend/src/views/Project/CertificatesPage/CertificatesPage.tsx b/frontend/src/views/Project/CertificatesPage/CertificatesPage.tsx new file mode 100644 index 000000000..30e531277 --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/CertificatesPage.tsx @@ -0,0 +1,35 @@ +import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { withProjectPermission } from "@app/hoc"; + +import { CaTab, CertificatesTab } from "./components"; + +enum TabSections { + Ca = "certificate-authorities", + Certificates = "certificates" +} + +export const CertificatesPage = withProjectPermission( + () => { + return ( +
+
+

Internal PKI

+ + + Certificates + Certificate Authorities + + + + + + + + +
+
+ ); + }, + { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.AuditLogs } +); diff --git a/frontend/src/views/Project/CertificatesPage/components/CaTab/CaTab.tsx b/frontend/src/views/Project/CertificatesPage/components/CaTab/CaTab.tsx new file mode 100644 index 000000000..aadca4dfe --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CaTab/CaTab.tsx @@ -0,0 +1,17 @@ +import { motion } from "framer-motion"; + +import { CaSection } from "./components"; + +export const CaTab = () => { + return ( + + + + ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaCertModal.tsx b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaCertModal.tsx new file mode 100644 index 000000000..97970d94f --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaCertModal.tsx @@ -0,0 +1,34 @@ +import { Modal, ModalContent } from "@app/components/v2"; +import { useGetCaCert } from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +import { CertificateContent } from "../../CertificatesTab/components/CertificateContent"; + +type Props = { + popUp: UsePopUpState<["caCert"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["caCert"]>, state?: boolean) => void; +}; + +export const CaCertModal = ({ popUp, handlePopUpToggle }: Props) => { + const { data } = useGetCaCert((popUp?.caCert?.data as { caId: string })?.caId || ""); + return ( + { + handlePopUpToggle("caCert", isOpen); + }} + > + + {data ? ( + + ) : ( +
+ )} + + + ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaCrlModal.tsx b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaCrlModal.tsx new file mode 100644 index 000000000..77e67f32e --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaCrlModal.tsx @@ -0,0 +1,106 @@ +import { useEffect } from "react"; +import { faCheck, faCopy, faDownload } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { IconButton, Modal, ModalContent } from "@app/components/v2"; +import { useToggle } from "@app/hooks"; +import { useGetCaCrl } from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + popUp: UsePopUpState<["caCrl"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["caCrl"]>, state?: boolean) => void; +}; + +export const CaCrlModal = ({ popUp, handlePopUpToggle }: Props) => { + const [isCrlCopied, setIsCrlCopied] = useToggle(false); + const { data: crl } = useGetCaCrl((popUp?.caCrl?.data as { caId: string })?.caId || ""); + + useEffect(() => { + let timer: NodeJS.Timeout; + if (isCrlCopied) { + timer = setTimeout(() => setIsCrlCopied.off(), 2000); + } + + return () => clearTimeout(timer); + }, [isCrlCopied]); + + const downloadTxtFile = (filename: string, content: string) => { + const blob = new Blob([content], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + + return ( + { + handlePopUpToggle("caCrl", isOpen); + }} + > + +
+ {crl && ( + <> + {/*
+

Manual CRL Rotation

+ +
*/} +
+

Certificate Revocation List

+
+ { + navigator.clipboard.writeText(crl); + setIsCrlCopied.on(); + }} + > + + + Copy + + + { + downloadTxtFile("crl.pem", crl); + }} + > + + + Download + + +
+
+
+

{crl}

+
+ + )} +
+
+
+ ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaInstallCertModal/CaInstallCertModal.tsx b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaInstallCertModal/CaInstallCertModal.tsx new file mode 100644 index 000000000..4d2bcb243 --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaInstallCertModal/CaInstallCertModal.tsx @@ -0,0 +1,328 @@ +import { useEffect, useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { format } from "date-fns"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + // DatePicker, + Button, + FormControl, + Input, + Modal, + ModalContent, + Select, + SelectItem +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { + CaStatus, + useGetCaById, + useGetCaCsr, + useImportCaCertificate, + useListWorkspaceCas, + useSignIntermediate +} from "@app/hooks/api"; +import { caTypeToNameMap } from "@app/hooks/api/ca/constants"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const isValidDate = (dateString: string) => { + const date = new Date(dateString); + return !Number.isNaN(date.getTime()); +}; + +const getMiddleDate = (date1: Date, date2: Date) => { + const timestamp1 = date1.getTime(); + const timestamp2 = date2.getTime(); + + const middleTimestamp = (timestamp1 + timestamp2) / 2; + + return new Date(middleTimestamp); +}; + +const schema = z.object({ + parentCaId: z.string(), + notAfter: z.string().trim().refine(isValidDate, { message: "Invalid date format" }), + maxPathLength: z.string() +}); + +export type FormData = z.infer; + +type Props = { + popUp: UsePopUpState<["installCaCert"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["installCaCert"]>, state?: boolean) => void; +}; + +enum ParentCaType { + Internal = "internal", + External = "external" +} + +export const CaInstallCertModal = ({ popUp, handlePopUpToggle }: Props) => { + const [parentCaType] = useState(ParentCaType.Internal); + const { currentWorkspace } = useWorkspace(); + const caId = (popUp?.installCaCert?.data as { caId: string })?.caId || ""; + + // const [isStartDatePickerOpen, setIsStartDatePickerOpen] = useState(false); + const { data: cas } = useListWorkspaceCas({ + projectSlug: currentWorkspace?.slug ?? "", + status: CaStatus.ACTIVE + }); + const { data: ca } = useGetCaById(caId); + const { data: csr } = useGetCaCsr(caId); + + const { mutateAsync: signIntermediate } = useSignIntermediate(); + const { mutateAsync: importCaCertificate } = useImportCaCertificate(); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting }, + setValue, + watch + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + maxPathLength: "0" + } + }); + + useEffect(() => { + if (cas?.length) { + setValue("parentCaId", cas[0].id); + } + }, [cas, setValue]); + + const parentCaId = watch("parentCaId"); + const { data: parentCa } = useGetCaById(parentCaId); + + useEffect(() => { + if (parentCa?.maxPathLength) { + setValue( + "maxPathLength", + (parentCa.maxPathLength === -1 ? 3 : parentCa.maxPathLength - 1).toString() + ); + } + + if (parentCa?.notAfter) { + const parentCaNotAfter = new Date(parentCa.notAfter); + const middleDate = getMiddleDate(new Date(), parentCaNotAfter); + setValue("notAfter", format(middleDate, "yyyy-MM-dd")); + } + }, [parentCa]); + + const onFormSubmit = async ({ notAfter, maxPathLength }: FormData) => { + try { + if (!csr || !caId || !currentWorkspace?.slug) return; + + const { certificate, certificateChain } = await signIntermediate({ + caId: parentCaId, + csr, + maxPathLength: Number(maxPathLength), + notAfter, + notBefore: new Date().toISOString() + }); + + await importCaCertificate({ + caId, + projectSlug: currentWorkspace?.slug, + certificate, + certificateChain + }); + + reset(); + + createNotification({ + text: "Successfully installed certificate for CA", + type: "success" + }); + handlePopUpToggle("installCaCert", false); + } catch (err) { + createNotification({ + text: "Failed to install certificate for CA", + type: "error" + }); + } + }; + + function generatePathLengthOpts(parentCaMaxPathLength: number): number[] { + if (parentCaMaxPathLength === -1) { + return [-1, 0, 1, 2, 3]; + } + + return Array.from({ length: parentCaMaxPathLength }, (_, index) => index); + } + + const renderForm = (parentCaTypeInput: ParentCaType) => { + switch (parentCaTypeInput) { + case ParentCaType.Internal: + return ( +
+ ( + + + + )} + /> + {/* { + return ( + + { + onChange(date); + setIsStartDatePickerOpen(false); + }} + popUpProps={{ + open: isStartDatePickerOpen, + onOpenChange: setIsStartDatePickerOpen + }} + popUpContentProps={{}} + /> + + ); + }} + /> */} + ( + + + + )} + /> + ( + + + + )} + /> +
+ + +
+ + ); + default: + return
External TODO
; + } + }; + + return ( + { + handlePopUpToggle("installCaCert", isOpen); + reset(); + }} + > + + {/* + + */} + {renderForm(parentCaType)} + + + ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaInstallCertModal/index.tsx b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaInstallCertModal/index.tsx new file mode 100644 index 000000000..9ad602c06 --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaInstallCertModal/index.tsx @@ -0,0 +1 @@ +export { CaInstallCertModal } from "./CaInstallCertModal"; diff --git a/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaModal.tsx b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaModal.tsx new file mode 100644 index 000000000..28fd1adbe --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaModal.tsx @@ -0,0 +1,433 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { format } from "date-fns"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + FormControl, + Input, + Modal, + ModalContent, + Select, + SelectItem + // DatePicker +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { CaType, useCreateCa, useGetCaById } from "@app/hooks/api/ca"; +import { certKeyAlgorithms } from "@app/hooks/api/certificates/constants"; +import { CertKeyAlgorithm } from "@app/hooks/api/certificates/enums"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const isValidDate = (dateString: string) => { + const date = new Date(dateString); + return !Number.isNaN(date.getTime()); +}; + +const getDateTenYearsFromToday = () => { + const date = new Date(); + date.setFullYear(date.getFullYear() + 10); + return format(date, "yyyy-MM-dd"); +}; + +const schema = z + .object({ + type: z.enum([CaType.ROOT, CaType.INTERMEDIATE]), + friendlyName: z.string(), + organization: z.string(), + ou: z.string(), + country: z.string(), + province: z.string(), + locality: z.string(), + commonName: z.string(), + notAfter: z.string().trim().refine(isValidDate, { message: "Invalid date format" }), + maxPathLength: z.string(), + keyAlgorithm: z.enum([ + CertKeyAlgorithm.RSA_2048, + CertKeyAlgorithm.RSA_4096, + CertKeyAlgorithm.ECDSA_P256, + CertKeyAlgorithm.ECDSA_P384 + ]) + }) + .required(); + +export type FormData = z.infer; + +type Props = { + popUp: UsePopUpState<["ca"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["ca"]>, state?: boolean) => void; +}; + +const caTypes = [ + { label: "Root", value: CaType.ROOT }, + { label: "Intermediate", value: CaType.INTERMEDIATE } +]; + +export const CaModal = ({ popUp, handlePopUpToggle }: Props) => { + const { currentWorkspace } = useWorkspace(); + // const [isStartDatePickerOpen, setIsStartDatePickerOpen] = useState(false); + + const { data: ca } = useGetCaById((popUp?.ca?.data as { caId: string })?.caId || ""); + const { mutateAsync: createMutateAsync } = useCreateCa(); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting }, + watch + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + type: CaType.ROOT, + friendlyName: "", + organization: "", + ou: "", + country: "", + province: "", + locality: "", + commonName: "", + notAfter: getDateTenYearsFromToday(), + maxPathLength: "-1", + keyAlgorithm: CertKeyAlgorithm.RSA_2048 + } + }); + + const caType = watch("type"); + + useEffect(() => { + if (ca) { + reset({ + type: ca.type, + friendlyName: ca.friendlyName, + organization: ca.organization, + ou: ca.ou, + country: ca.country, + province: ca.province, + locality: ca.locality, + commonName: ca.commonName, + notAfter: ca.notAfter ? format(new Date(ca.notAfter), "yyyy-MM-dd") : "", + maxPathLength: ca.maxPathLength ? String(ca.maxPathLength) : "", + keyAlgorithm: ca.keyAlgorithm + }); + } else { + reset({ + type: CaType.ROOT, + friendlyName: "", + organization: "", + ou: "", + country: "", + province: "", + locality: "", + commonName: "", + notAfter: getDateTenYearsFromToday(), + maxPathLength: "-1", + keyAlgorithm: CertKeyAlgorithm.RSA_2048 + }); + } + }, [ca]); + + const onFormSubmit = async ({ + type, + friendlyName, + commonName, + organization, + ou, + country, + locality, + province, + notAfter, + maxPathLength, + keyAlgorithm + }: FormData) => { + try { + if (!currentWorkspace?.slug) return; + + await createMutateAsync({ + projectSlug: currentWorkspace.slug, + type, + friendlyName, + commonName, + organization, + ou, + country, + province, + locality, + notAfter, + maxPathLength: Number(maxPathLength), + keyAlgorithm + }); + + reset(); + handlePopUpToggle("ca", false); + + createNotification({ + text: "Successfully created CA", + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to create CA", + type: "error" + }); + } + }; + + return ( + { + reset(); + handlePopUpToggle("ca", isOpen); + }} + > + +
+ ( + + + + )} + /> + {caType === CaType.ROOT && ( + <> + {/* { + return ( + + { + onChange(date); + setIsStartDatePickerOpen(false); + }} + popUpProps={{ + open: isStartDatePickerOpen, + onOpenChange: setIsStartDatePickerOpen + }} + popUpContentProps={{}} + /> + + ); + }} + /> */} + ( + + + + )} + /> + ( + + + + )} + /> + + )} + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + {!ca && ( +
+ + +
+ )} + +
+
+ ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaSection.tsx b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaSection.tsx new file mode 100644 index 000000000..86e617a56 --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaSection.tsx @@ -0,0 +1,135 @@ +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { Button, DeleteActionModal, UpgradePlanModal } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { CaStatus, useDeleteCa, useUpdateCa } from "@app/hooks/api"; +import { usePopUp } from "@app/hooks/usePopUp"; + +import { CaCertModal } from "./CaCertModal"; +import { CaCrlModal } from "./CaCrlModal"; +import { CaInstallCertModal } from "./CaInstallCertModal"; +import { CaModal } from "./CaModal"; +import { CaTable } from "./CaTable"; + +export const CaSection = () => { + const { currentWorkspace } = useWorkspace(); + const { mutateAsync: deleteCa } = useDeleteCa(); + const { mutateAsync: updateCa } = useUpdateCa(); + + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "ca", + "caCert", + "installCaCert", + "deleteCa", + "caStatus", // enable / disable + "caCrl", // enable / disable + "upgradePlan" + ] as const); + + const onRemoveCaSubmit = async (caId: string) => { + try { + if (!currentWorkspace?.slug) return; + + await deleteCa({ caId, projectSlug: currentWorkspace.slug }); + + await createNotification({ + text: "Successfully deleted CA", + type: "success" + }); + + handlePopUpClose("deleteCa"); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to delete CA", + type: "error" + }); + } + }; + + const onUpdateCaStatus = async ({ caId, status }: { caId: string; status: CaStatus }) => { + try { + if (!currentWorkspace?.slug) return; + + await updateCa({ caId, projectSlug: currentWorkspace.slug, status }); + + await createNotification({ + text: `Successfully ${status === CaStatus.ACTIVE ? "enabled" : "disabled"} CA`, + type: "success" + }); + + handlePopUpClose("caStatus"); + } catch (err) { + console.error(err); + createNotification({ + text: `Failed to ${status === CaStatus.ACTIVE ? "enabled" : "disabled"} CA`, + type: "error" + }); + } + }; + + return ( +
+
+

Certificate Authorities

+ + {(isAllowed) => ( + + )} + +
+ + + + + + handlePopUpToggle("deleteCa", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => onRemoveCaSubmit((popUp?.deleteCa?.data as { caId: string })?.caId)} + /> + handlePopUpToggle("caStatus", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onUpdateCaStatus(popUp?.caStatus?.data as { caId: string; status: CaStatus }) + } + /> + handlePopUpToggle("upgradePlan", isOpen)} + text={(popUp.upgradePlan?.data as { description: string })?.description} + /> +
+ ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaTable.tsx b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaTable.tsx new file mode 100644 index 000000000..a35a4e432 --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaTable.tsx @@ -0,0 +1,258 @@ +import { + faBan, + faCertificate, + faEllipsis, + faEye, + faFile, + faTrash +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { format } from "date-fns"; +import { twMerge } from "tailwind-merge"; + +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + EmptyState, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tooltip, + Tr +} from "@app/components/v2"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useSubscription, + useWorkspace} from "@app/context"; +import { CaStatus, useListWorkspaceCas } from "@app/hooks/api"; +import { caStatusToNameMap, caTypeToNameMap } from "@app/hooks/api/ca/constants"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + handlePopUpOpen: ( + popUpName: keyof UsePopUpState< + ["installCaCert", "caCert", "ca", "deleteCa", "caStatus", "caCrl", "upgradePlan"] + >, + data?: { + caId?: string; + dn?: string; + status?: CaStatus; + description?: string; + } + ) => void; +}; + +export const CaTable = ({ handlePopUpOpen }: Props) => { + const { subscription } = useSubscription(); + const { currentWorkspace } = useWorkspace(); + const { data, isLoading } = useListWorkspaceCas({ + projectSlug: currentWorkspace?.slug ?? "" + }); + return ( +
+ + + + + + + + + + + + {isLoading && } + {!isLoading && + data && + data.length > 0 && + data.map((ca) => { + return ( + + + + + + + + ); + })} + +
Friendly NameStatusTypeValid Until +
{ca.friendlyName}{caStatusToNameMap[ca.status]}{caTypeToNameMap[ca.type]}{ca.notAfter ? format(new Date(ca.notAfter), "yyyy-MM-dd") : "-"} + + +
+ + + +
+
+ + {ca.status === CaStatus.PENDING_CERTIFICATE && ( + + {(isAllowed) => ( + { + handlePopUpOpen("installCaCert", { + caId: ca.id + }); + }} + disabled={!isAllowed} + icon={} + > + Install Certificate + + )} + + )} + {ca.status !== CaStatus.PENDING_CERTIFICATE && ( + + {(isAllowed) => ( + { + handlePopUpOpen("caCert", { + caId: ca.id + }); + }} + disabled={!isAllowed} + icon={} + > + View Certificate + + )} + + )} + {ca.status !== CaStatus.PENDING_CERTIFICATE && ( + + {(isAllowed) => ( + { + if (!subscription?.caCrl) { + handlePopUpOpen("upgradePlan", { + description: + "You can use the certificate revocation list (CRL) feature if you upgrade your Infisical plan." + }); + } else { + handlePopUpOpen("caCrl", { + caId: ca.id + }); + } + }} + disabled={!isAllowed} + icon={} + > + View CRL + + )} + + )} + + {(isAllowed) => ( + + handlePopUpOpen("ca", { + caId: ca.id + }) + } + disabled={!isAllowed} + icon={} + > + View CA + + )} + + {(ca.status === CaStatus.ACTIVE || ca.status === CaStatus.DISABLED) && ( + + {(isAllowed) => ( + + handlePopUpOpen("caStatus", { + caId: ca.id, + status: + ca.status === CaStatus.ACTIVE + ? CaStatus.DISABLED + : CaStatus.ACTIVE + }) + } + disabled={!isAllowed} + icon={} + > + {`${ca.status === CaStatus.ACTIVE ? "Disable" : "Enable"} CA`} + + )} + + )} + + {(isAllowed) => ( + + handlePopUpOpen("deleteCa", { + caId: ca.id, + dn: ca.dn + }) + } + disabled={!isAllowed} + icon={} + > + Delete CA + + )} + + +
+
+ {!isLoading && data?.length === 0 && ( + + )} +
+
+ ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CaTab/components/index.tsx b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/index.tsx new file mode 100644 index 000000000..1f23813bc --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/index.tsx @@ -0,0 +1 @@ +export { CaSection } from "./CaSection"; diff --git a/frontend/src/views/Project/CertificatesPage/components/CaTab/index.tsx b/frontend/src/views/Project/CertificatesPage/components/CaTab/index.tsx new file mode 100644 index 000000000..9e52be028 --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CaTab/index.tsx @@ -0,0 +1 @@ +export { CaTab } from "./CaTab"; diff --git a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/CertificatesTab.tsx b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/CertificatesTab.tsx new file mode 100644 index 000000000..f054e2546 --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/CertificatesTab.tsx @@ -0,0 +1,17 @@ +import { motion } from "framer-motion"; + +import { CertificatesSection } from "./components"; + +export const CertificatesTab = () => { + return ( + + + + ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateCertModal.tsx b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateCertModal.tsx new file mode 100644 index 000000000..01c79589c --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateCertModal.tsx @@ -0,0 +1,37 @@ +import { Modal, ModalContent } from "@app/components/v2"; +import { useGetCertBody } from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +import { CertificateContent } from "./CertificateContent"; + +type Props = { + popUp: UsePopUpState<["certificateCert"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["certificateCert"]>, state?: boolean) => void; +}; + +export const CertificateCertModal = ({ popUp, handlePopUpToggle }: Props) => { + const { data } = useGetCertBody( + (popUp?.certificateCert?.data as { serialNumber: string })?.serialNumber || "" + ); + + return ( + { + handlePopUpToggle("certificateCert", isOpen); + }} + > + + {data ? ( + + ) : ( +
+ )} + + + ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateContent.tsx b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateContent.tsx new file mode 100644 index 000000000..8c40af74f --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateContent.tsx @@ -0,0 +1,173 @@ +import { faCheck, faCopy, faDownload } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import FileSaver from "file-saver"; + +import { IconButton, Tooltip } from "@app/components/v2"; +import { useTimedReset } from "@app/hooks"; + +type Props = { + serialNumber: string; + certificate: string; + certificateChain: string; + privateKey?: string; +}; + +export const CertificateContent = ({ + serialNumber, + certificate, + certificateChain, + privateKey +}: Props) => { + const [copyTextSerialNumber, isCopyingSerialNumber, setCopyTextSerialNumber] = + useTimedReset({ + initialState: "Copy to clipboard" + }); + const [copyTextCertificate, isCopyingCertificate, setCopyTextCertificate] = useTimedReset( + { + initialState: "Copy to clipboard" + } + ); + const [copyTextCertificateChain, isCopyingCertificateChain, setCopyTextCertificateChain] = + useTimedReset({ + initialState: "Copy to clipboard" + }); + + const [copyTextCertificateSk, isCopyingCertificateSk, setCopyTextCertificateSk] = + useTimedReset({ + initialState: "Copy to clipboard" + }); + + const downloadTxtFile = (filename: string, content: string) => { + const blob = new Blob([content], { type: "text/plain;charset=utf-8" }); + FileSaver.saveAs(blob, filename); + }; + + return ( +
+

Serial Number

+
+

{serialNumber}

+ + { + navigator.clipboard.writeText(serialNumber); + setCopyTextSerialNumber("Copied"); + }} + > + + + +
+
+

Certificate Body

+
+ + { + navigator.clipboard.writeText(certificate); + setCopyTextCertificate("Copied"); + }} + > + + + + + { + downloadTxtFile("cert.pem", certificate); + }} + > + + + +
+
+
+

{certificate}

+
+ {certificateChain && ( + <> +
+

Certificate Chain

+
+ + { + navigator.clipboard.writeText(certificateChain); + setCopyTextCertificateChain("Copied"); + }} + > + + + + + { + downloadTxtFile("chain.pem", certificateChain); + }} + > + + + +
+
+
+

{certificateChain}

+
+ + )} + {privateKey && ( + <> +
+

Certificate Private Key

+
+ + { + navigator.clipboard.writeText(privateKey); + setCopyTextCertificateSk("Copied"); + }} + > + + + + + { + downloadTxtFile("private_key.txt", privateKey); + }} + > + + + +
+
+
+

{privateKey}

+
+ + )} +
+ ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateModal.tsx b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateModal.tsx new file mode 100644 index 000000000..03e70a80c --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateModal.tsx @@ -0,0 +1,241 @@ +import { useEffect, useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + FormControl, + Input, + Modal, + ModalContent, + Select, + SelectItem +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { CaStatus, useCreateCertificate, useGetCert, useListWorkspaceCas } from "@app/hooks/api"; +import { caTypeToNameMap } from "@app/hooks/api/ca/constants"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +import { CertificateContent } from "./CertificateContent"; + +const schema = z.object({ + caId: z.string(), + friendlyName: z.string(), + commonName: z.string().trim().min(1), + ttl: z.string().trim() +}); + +export type FormData = z.infer; + +type Props = { + popUp: UsePopUpState<["certificate"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["certificate"]>, state?: boolean) => void; +}; + +type TCertificateDetails = { + serialNumber: string; + certificate: string; + certificateChain: string; + privateKey: string; +}; + +export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => { + const [certificateDetails, setCertificateDetails] = useState(null); + const { currentWorkspace } = useWorkspace(); + const { data: cert } = useGetCert( + (popUp?.certificate?.data as { serialNumber: string })?.serialNumber || "" + ); + + const { data: cas } = useListWorkspaceCas({ + projectSlug: currentWorkspace?.slug ?? "", + status: CaStatus.ACTIVE + }); + + const { mutateAsync: createCertificate } = useCreateCertificate(); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting }, + setValue + } = useForm({ + resolver: zodResolver(schema) + }); + + useEffect(() => { + if (cert) { + reset({ + caId: cert.caId, + friendlyName: cert.friendlyName, + commonName: cert.commonName, + ttl: "" + }); + } else { + reset({ + caId: "", + friendlyName: "", + commonName: "", + ttl: "" + }); + } + }, [cert]); + + const onFormSubmit = async ({ caId, friendlyName, commonName, ttl }: FormData) => { + try { + if (!currentWorkspace?.slug) return; + + const { serialNumber, certificate, certificateChain, privateKey } = await createCertificate({ + projectSlug: currentWorkspace.slug, + caId, + friendlyName, + commonName, + ttl + }); + + reset(); + + setCertificateDetails({ + serialNumber, + certificate, + certificateChain, + privateKey + }); + + createNotification({ + text: "Successfully created certificate", + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to create certificate", + type: "error" + }); + } + }; + + useEffect(() => { + if (cas?.length) { + setValue("caId", cas[0].id); + } + }, [cas]); + + return ( + { + handlePopUpToggle("certificate", isOpen); + reset(); + setCertificateDetails(null); + }} + > + + {!certificateDetails ? ( +
+ ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + {!cert && ( +
+ + +
+ )} + + ) : ( + + )} +
+
+ ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateRevocationModal.tsx b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateRevocationModal.tsx new file mode 100644 index 000000000..cd5e2118c --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateRevocationModal.tsx @@ -0,0 +1,131 @@ +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FormControl, Modal, ModalContent, Select, SelectItem } from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { useRevokeCert } from "@app/hooks/api"; +import { crlReasons } from "@app/hooks/api/certificates/constants"; +import { CrlReason } from "@app/hooks/api/certificates/enums"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const schema = z.object({ + revocationReason: z.enum([ + CrlReason.UNSPECIFIED, + CrlReason.KEY_COMPROMISE, + CrlReason.CA_COMPROMISE, + CrlReason.AFFILIATION_CHANGED, + CrlReason.SUPERSEDED, + CrlReason.CESSATION_OF_OPERATION, + CrlReason.CERTIFICATE_HOLD, + CrlReason.PRIVILEGE_WITHDRAWN, + CrlReason.A_A_COMPROMISE + ]) +}); + +export type FormData = z.infer; + +type Props = { + popUp: UsePopUpState<["revokeCertificate"]>; + handlePopUpToggle: ( + popUpName: keyof UsePopUpState<["revokeCertificate"]>, + state?: boolean + ) => void; +}; + +export const CertificateRevocationModal = ({ popUp, handlePopUpToggle }: Props) => { + const { currentWorkspace } = useWorkspace(); + const { mutateAsync: revokeCertificate } = useRevokeCert(); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema) + }); + + const onFormSubmit = async ({ revocationReason }: FormData) => { + try { + if (!currentWorkspace?.slug) return; + + const {serialNumber} = popUp.revokeCertificate.data as { serialNumber: string }; + + await revokeCertificate({ + projectSlug: currentWorkspace.slug, + serialNumber, + revocationReason + }); + + reset(); + handlePopUpToggle("revokeCertificate", false); + + createNotification({ + text: "Successfully revoked certificate", + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to revoke certificate", + type: "error" + }); + } + }; + + return ( + { + handlePopUpToggle("revokeCertificate", isOpen); + reset(); + }} + > + +
+ ( + + + + )} + /> +
+ + +
+ +
+
+ ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificatesSection.tsx b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificatesSection.tsx new file mode 100644 index 000000000..58bb8ef4c --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificatesSection.tsx @@ -0,0 +1,88 @@ +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { Button, DeleteActionModal } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { useDeleteCert } from "@app/hooks/api"; +import { usePopUp } from "@app/hooks/usePopUp"; + +import { CertificateCertModal } from "./CertificateCertModal"; +import { CertificateModal } from "./CertificateModal"; +import { CertificateRevocationModal } from "./CertificateRevocationModal"; +import { CertificatesTable } from "./CertificatesTable"; + +export const CertificatesSection = () => { + const { currentWorkspace } = useWorkspace(); + const { mutateAsync: deleteCert } = useDeleteCert(); + + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "certificate", + "certificateCert", + "deleteCertificate", + "revokeCertificate" + ] as const); + + const onRemoveCertificateSubmit = async (serialNumber: string) => { + try { + if (!currentWorkspace?.slug) return; + + await deleteCert({ serialNumber, projectSlug: currentWorkspace.slug }); + + await createNotification({ + text: "Successfully deleted certificate", + type: "success" + }); + + handlePopUpClose("deleteCertificate"); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to delete certificate", + type: "error" + }); + } + }; + + return ( +
+
+

Certificates

+ + {(isAllowed) => ( + + )} + +
+ + + + + handlePopUpToggle("deleteCertificate", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onRemoveCertificateSubmit( + (popUp?.deleteCertificate?.data as { serialNumber: string })?.serialNumber + ) + } + /> +
+ ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificatesTable.tsx b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificatesTable.tsx new file mode 100644 index 000000000..f23299aa8 --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificatesTable.tsx @@ -0,0 +1,205 @@ +import { useState } from "react"; +import { + faBan, + faCertificate, + faEllipsis, + faEye, + faFileExport, + faTrash +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { format } from "date-fns"; +import { twMerge } from "tailwind-merge"; + +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + EmptyState, + Pagination, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tooltip, + Tr +} from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { useListWorkspaceCertificates } from "@app/hooks/api"; +import { certStatusToNameMap } from "@app/hooks/api/certificates/constants"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + handlePopUpOpen: ( + popUpName: keyof UsePopUpState< + ["certificate", "deleteCertificate", "revokeCertificate", "certificateCert"] + >, + data?: { + serialNumber?: string; + commonName?: string; + } + ) => void; +}; + +const PER_PAGE_INIT = 25; + +export const CertificatesTable = ({ handlePopUpOpen }: Props) => { + const [page, setPage] = useState(1); + const [perPage, setPerPage] = useState(PER_PAGE_INIT); + + const { currentWorkspace } = useWorkspace(); + const { data, isLoading } = useListWorkspaceCertificates({ + projectSlug: currentWorkspace?.slug ?? "", + offset: (page - 1) * perPage, + limit: perPage + }); + + return ( +
+ + + + + + + + + + + {isLoading && } + {!isLoading && + data?.certificates.map((certificate) => { + return ( + + + + + + + ); + })} + +
Friendly NameStatusValid Until +
{certificate.friendlyName}{certStatusToNameMap[certificate.status]} + {certificate.notAfter + ? format(new Date(certificate.notAfter), "yyyy-MM-dd") + : "-"} + + + +
+ + + +
+
+ + + {(isAllowed) => ( + + handlePopUpOpen("certificateCert", { + serialNumber: certificate.serialNumber + }) + } + disabled={!isAllowed} + icon={} + > + Export Certificate + + )} + + + {(isAllowed) => ( + + handlePopUpOpen("certificate", { + serialNumber: certificate.serialNumber + }) + } + disabled={!isAllowed} + icon={} + > + View Details + + )} + + + {(isAllowed) => ( + + handlePopUpOpen("revokeCertificate", { + serialNumber: certificate.serialNumber + }) + } + disabled={!isAllowed} + icon={} + > + Revoke Certificate + + )} + + + {(isAllowed) => ( + + handlePopUpOpen("deleteCertificate", { + serialNumber: certificate.serialNumber, + commonName: certificate.commonName + }) + } + disabled={!isAllowed} + icon={} + > + Delete Certificate + + )} + + +
+
+ {!isLoading && data?.totalCount !== undefined && data.totalCount >= PER_PAGE_INIT && ( + setPage(newPage)} + onChangePerPage={(newPerPage) => setPerPage(newPerPage)} + /> + )} + {!isLoading && !data?.certificates?.length && ( + + )} +
+
+ ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/index.tsx b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/index.tsx new file mode 100644 index 000000000..7854a6f8b --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/index.tsx @@ -0,0 +1 @@ +export { CertificatesSection } from "./CertificatesSection"; diff --git a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/index.tsx b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/index.tsx new file mode 100644 index 000000000..277134d56 --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/index.tsx @@ -0,0 +1 @@ +export { CertificatesTab } from "./CertificatesTab"; diff --git a/frontend/src/views/Project/CertificatesPage/components/index.tsx b/frontend/src/views/Project/CertificatesPage/components/index.tsx new file mode 100644 index 000000000..9dbd8694f --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/index.tsx @@ -0,0 +1,2 @@ +export { CaTab } from "./CaTab"; +export { CertificatesTab } from "./CertificatesTab"; diff --git a/frontend/src/views/Project/CertificatesPage/index.tsx b/frontend/src/views/Project/CertificatesPage/index.tsx new file mode 100644 index 000000000..a72556644 --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/index.tsx @@ -0,0 +1 @@ +export { CertificatesPage } from "./CertificatesPage"; diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx index cd2eaeee0..2140887de 100644 --- a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx @@ -4,6 +4,7 @@ import { faAnchorLock, faArrowLeft, faBook, + faCertificate, faCog, faKey, faLock, @@ -13,8 +14,7 @@ import { faShield, faTags, faUser, - faUsers -} from "@fortawesome/free-solid-svg-icons"; + faUsers} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -117,6 +117,18 @@ const SINGLE_PERMISSION_LIST = [ subtitle: "IP allowlist management control", icon: faNetworkWired, formName: "ip-allowlist" + }, + { + title: "Certificate Authorities", + subtitle: "CA management control", + icon: faCertificate, + formName: "certificate-authorities" + }, + { + title: "Certificates", + subtitle: "Certificate management control", + icon: faCertificate, + formName: "certificates" } ] as const; diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.utils.ts b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.utils.ts index 4d4d45e64..0b534347d 100644 --- a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.utils.ts +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.utils.ts @@ -48,6 +48,8 @@ export const formSchema = z.object({ tags: generalPermissionSchema, "audit-logs": generalPermissionSchema, "ip-allowlist": generalPermissionSchema, + "certificate-authorities": generalPermissionSchema, + certificates: generalPermissionSchema, // akhilmhdh: refactor all keys like below [ProjectPermissionSub.SecretApproval]: generalPermissionSchema, workspace: z diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/SingleProjectPermission.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/SingleProjectPermission.tsx index 51a17d7fe..763aa32f4 100644 --- a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/SingleProjectPermission.tsx +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/SingleProjectPermission.tsx @@ -25,6 +25,8 @@ type Props = { | "audit-logs" | "ip-allowlist" | "identity" + | "certificate-authorities" + | "certificates" | ProjectPermissionSub.SecretApproval; isNonEditable?: boolean; setValue: UseFormSetValue;