From 587a4a1120bbe425d0e985db596ffbc88091ad5f Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Mon, 5 Aug 2024 11:00:22 -0700 Subject: [PATCH] Make progress on ca cert versioning --- .../20240802181855_ca-cert-version.ts | 114 ++++++ .../src/db/schemas/certificate-authorities.ts | 3 +- .../db/schemas/certificate-authority-certs.ts | 4 +- backend/src/db/schemas/dynamic-secrets.ts | 10 +- backend/src/db/schemas/webhooks.ts | 15 +- .../ee/services/audit-log/audit-log-types.ts | 10 + backend/src/lib/api-docs/constants.ts | 8 + .../routes/v1/certificate-authority-router.ts | 110 +++++- .../certificate-authority-fns.ts | 56 ++- .../certificate-authority-service.ts | 366 ++++++++++++++++-- .../certificate-authority-types.ts | 22 ++ frontend/src/components/v2/index.tsx | 1 + frontend/src/hooks/api/ca/constants.tsx | 13 +- frontend/src/hooks/api/ca/enums.tsx | 4 + frontend/src/hooks/api/ca/index.tsx | 8 +- frontend/src/hooks/api/ca/mutations.tsx | 30 +- frontend/src/hooks/api/ca/queries.tsx | 21 +- frontend/src/hooks/api/ca/types.ts | 15 +- .../pages/project/[id]/ca/[caId]/index.tsx | 18 + frontend/src/views/Project/CaPage/CaPage.tsx | 147 +++++++ .../CaCertificatesSection.tsx | 31 ++ .../CaCertificatesTable.tsx | 131 +++++++ .../CaCertificatesSection/index.tsx | 1 + .../CaPage/components/CaDetailsSection.tsx | 141 +++++++ .../CaPage/components/CaRenewalModal.tsx | 215 ++++++++++ .../views/Project/CaPage/components/index.tsx | 3 + frontend/src/views/Project/CaPage/index.tsx | 1 + .../components/CaTab/components/CaTable.tsx | 66 +++- 28 files changed, 1490 insertions(+), 74 deletions(-) create mode 100644 backend/src/db/migrations/20240802181855_ca-cert-version.ts create mode 100644 frontend/src/pages/project/[id]/ca/[caId]/index.tsx create mode 100644 frontend/src/views/Project/CaPage/CaPage.tsx create mode 100644 frontend/src/views/Project/CaPage/components/CaCertificatesSection/CaCertificatesSection.tsx create mode 100644 frontend/src/views/Project/CaPage/components/CaCertificatesSection/CaCertificatesTable.tsx create mode 100644 frontend/src/views/Project/CaPage/components/CaCertificatesSection/index.tsx create mode 100644 frontend/src/views/Project/CaPage/components/CaDetailsSection.tsx create mode 100644 frontend/src/views/Project/CaPage/components/CaRenewalModal.tsx create mode 100644 frontend/src/views/Project/CaPage/components/index.tsx create mode 100644 frontend/src/views/Project/CaPage/index.tsx diff --git a/backend/src/db/migrations/20240802181855_ca-cert-version.ts b/backend/src/db/migrations/20240802181855_ca-cert-version.ts new file mode 100644 index 000000000..b1fffe4a3 --- /dev/null +++ b/backend/src/db/migrations/20240802181855_ca-cert-version.ts @@ -0,0 +1,114 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.CertificateAuthority)) { + const hasActiveCaCertVersionColumn = await knex.schema.hasColumn( + TableName.CertificateAuthority, + "activeCaCertVersion" + ); + if (!hasActiveCaCertVersionColumn) { + await knex.schema.alterTable(TableName.CertificateAuthority, (t) => { + t.integer("activeCaCertVersion").nullable(); + }); + + await knex(TableName.CertificateAuthority).where("status", "active").update({ activeCaCertVersion: 1 }); + } + } + + if (await knex.schema.hasTable(TableName.CertificateAuthorityCert)) { + const hasVersionColumn = await knex.schema.hasColumn(TableName.CertificateAuthorityCert, "version"); + if (!hasVersionColumn) { + await knex.schema.alterTable(TableName.CertificateAuthorityCert, (t) => { + t.integer("version").nullable(); + // t.dropUnique(["caId"]); + }); + + await knex(TableName.CertificateAuthorityCert).update({ version: 1 }).whereNull("version"); + + await knex.schema.alterTable(TableName.CertificateAuthorityCert, (t) => { + t.integer("version").notNullable().alter(); + }); + } + + const hasCaSecretIdColumn = await knex.schema.hasColumn(TableName.CertificateAuthorityCert, "caSecretId"); + if (!hasCaSecretIdColumn) { + await knex.schema.alterTable(TableName.CertificateAuthorityCert, (t) => { + t.uuid("caSecretId").nullable(); + t.foreign("caSecretId").references("id").inTable(TableName.CertificateAuthoritySecret).onDelete("CASCADE"); + }); + + await knex.raw(` + UPDATE "${TableName.CertificateAuthorityCert}" cert + SET "caSecretId" = ( + SELECT sec.id + FROM "${TableName.CertificateAuthoritySecret}" sec + WHERE sec."caId" = cert."caId" + ) + `); + + // await knex.raw(` + // UPDATE ${TableName.CertificateAuthorityCert} cert + // SET caSecretId = ( + // SELECT sec.id + // FROM ${TableName.CertificateAuthoritySecret} sec + // WHERE sec."caId" = cert."caId" + // ) + // `); + + // await knex(TableName.CertificateAuthorityCert).update({ + // caSecretId: knex(TableName.CertificateAuthoritySecret) + // .select("id") + // .whereRaw("?? = ??", ["CertificateAuthoritySecret.caId", "CertificateAuthorityCert.caId"]) + // }); + + // await knex(TableName.CertificateAuthorityCert).update({ + // caSecretId: function () { + // this.select("id") + // .from(TableName.CertificateAuthoritySecret) + // .whereRaw("??.?? = ??.??", [ + // TableName.CertificateAuthoritySecret, + // "caId", + // TableName.CertificateAuthorityCert, + // "caId" + // ]); + // } + // }); + + await knex.schema.alterTable(TableName.CertificateAuthorityCert, (t) => { + t.uuid("caSecretId").notNullable().alter(); + }); + } + } + + // if (await knex.schema.hasTable(TableName.CertificateAuthoritySecret)) { + // await knex.schema.alterTable(TableName.CertificateAuthoritySecret, (t) => { + // t.dropUnique(["caId"]); + // }); + // } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.CertificateAuthority)) { + if (await knex.schema.hasColumn(TableName.CertificateAuthority, "activeCaCertVersion")) { + await knex.schema.alterTable(TableName.CertificateAuthority, (t) => { + t.dropColumn("activeCaCertVersion"); + }); + } + } + + if (await knex.schema.hasTable(TableName.CertificateAuthorityCert)) { + if (await knex.schema.hasColumn(TableName.CertificateAuthorityCert, "version")) { + await knex.schema.alterTable(TableName.CertificateAuthorityCert, (t) => { + t.dropColumn("version"); + }); + } + + if (await knex.schema.hasColumn(TableName.CertificateAuthorityCert, "caSecretId")) { + await knex.schema.alterTable(TableName.CertificateAuthorityCert, (t) => { + t.dropColumn("caSecretId"); + }); + } + } +} diff --git a/backend/src/db/schemas/certificate-authorities.ts b/backend/src/db/schemas/certificate-authorities.ts index 16f303b5c..9255b037e 100644 --- a/backend/src/db/schemas/certificate-authorities.ts +++ b/backend/src/db/schemas/certificate-authorities.ts @@ -27,7 +27,8 @@ export const CertificateAuthoritiesSchema = z.object({ maxPathLength: z.number().nullable().optional(), keyAlgorithm: z.string(), notBefore: z.date().nullable().optional(), - notAfter: z.date().nullable().optional() + notAfter: z.date().nullable().optional(), + activeCaCertVersion: z.number().nullable().optional() }); export type TCertificateAuthorities = z.infer; diff --git a/backend/src/db/schemas/certificate-authority-certs.ts b/backend/src/db/schemas/certificate-authority-certs.ts index 96ad54f00..7074ce409 100644 --- a/backend/src/db/schemas/certificate-authority-certs.ts +++ b/backend/src/db/schemas/certificate-authority-certs.ts @@ -15,7 +15,9 @@ export const CertificateAuthorityCertsSchema = z.object({ updatedAt: z.date(), caId: z.string().uuid(), encryptedCertificate: zodBuffer, - encryptedCertificateChain: zodBuffer + encryptedCertificateChain: zodBuffer, + version: z.number(), + caSecretId: z.string().uuid() }); export type TCertificateAuthorityCerts = z.infer; diff --git a/backend/src/db/schemas/dynamic-secrets.ts b/backend/src/db/schemas/dynamic-secrets.ts index b27da396c..d90f1f7d2 100644 --- a/backend/src/db/schemas/dynamic-secrets.ts +++ b/backend/src/db/schemas/dynamic-secrets.ts @@ -5,6 +5,8 @@ import { z } from "zod"; +import { zodBuffer } from "@app/lib/zod"; + import { TImmutableDBKeys } from "./models"; export const DynamicSecretsSchema = z.object({ @@ -14,16 +16,12 @@ export const DynamicSecretsSchema = z.object({ type: z.string(), defaultTTL: z.string(), maxTTL: z.string().nullable().optional(), - inputIV: z.string(), - inputCiphertext: z.string(), - inputTag: z.string(), - algorithm: z.string().default("aes-256-gcm"), - keyEncoding: z.string().default("utf8"), folderId: z.string().uuid(), status: z.string().nullable().optional(), statusDetails: z.string().nullable().optional(), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + encryptedConfig: zodBuffer }); export type TDynamicSecrets = z.infer; diff --git a/backend/src/db/schemas/webhooks.ts b/backend/src/db/schemas/webhooks.ts index a7aac2933..3f670497f 100644 --- a/backend/src/db/schemas/webhooks.ts +++ b/backend/src/db/schemas/webhooks.ts @@ -5,27 +5,22 @@ import { z } from "zod"; +import { zodBuffer } from "@app/lib/zod"; + import { TImmutableDBKeys } from "./models"; export const WebhooksSchema = z.object({ id: z.string().uuid(), secretPath: z.string().default("/"), - url: z.string(), lastStatus: z.string().nullable().optional(), lastRunErrorMessage: z.string().nullable().optional(), isDisabled: z.boolean().default(false), - encryptedSecretKey: z.string().nullable().optional(), - iv: z.string().nullable().optional(), - tag: z.string().nullable().optional(), - algorithm: z.string().nullable().optional(), - keyEncoding: z.string().nullable().optional(), createdAt: z.date(), updatedAt: z.date(), envId: z.string().uuid(), - urlCipherText: z.string().nullable().optional(), - urlIV: z.string().nullable().optional(), - urlTag: z.string().nullable().optional(), - type: z.string().default("general").nullable().optional() + type: z.string().default("general").nullable().optional(), + encryptedSecretKeyWithKms: zodBuffer.nullable().optional(), + encryptedUrl: zodBuffer }); export type TWebhooks = z.infer; 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 76bf73d8c..61c2ecfa7 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -131,6 +131,7 @@ export enum EventType { UPDATE_CA = "update-certificate-authority", DELETE_CA = "delete-certificate-authority", GET_CA_CSR = "get-certificate-authority-csr", + GET_CA_CERTS = "get-certificate-authority-certs", GET_CA_CERT = "get-certificate-authority-cert", SIGN_INTERMEDIATE = "sign-intermediate", IMPORT_CA_CERT = "import-certificate-authority-cert", @@ -1101,6 +1102,14 @@ interface GetCaCsr { }; } +interface GetCaCerts { + type: EventType.GET_CA_CERTS; + metadata: { + caId: string; + dn: string; + }; +} + interface GetCaCert { type: EventType.GET_CA_CERT; metadata: { @@ -1328,6 +1337,7 @@ export type Event = | UpdateCa | DeleteCa | GetCaCsr + | GetCaCerts | GetCaCert | SignIntermediate | ImportCaCert diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index e151ffb85..575af97f9 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -1048,6 +1048,14 @@ export const CERTIFICATE_AUTHORITIES = { caId: "The ID of the CA to generate CSR from", csr: "The generated CSR from the CA" }, + RENEW_CA_CERT: { + caId: "The ID of the CA to renew the CA certificate for", + type: "The type of behavior to use for the renewal operation. Currently Infisical is only able to renew a CA certificate with the same key pair.", + notAfter: "The expiry date and time for the renewed CA certificatre in YYYY-MM-DDTHH:mm:ss.sssZ format", + certificate: "The renewed CA certificate body", + certificateChain: "The certificate chain of the CA", + serialNumber: "The serial number of the renewed CA certificate" + }, GET_CERT: { caId: "The ID of the CA to get the certificate body and certificate chain from", certificate: "The certificate body of the CA", diff --git a/backend/src/server/routes/v1/certificate-authority-router.ts b/backend/src/server/routes/v1/certificate-authority-router.ts index 533f249f2..ad49bacaf 100644 --- a/backend/src/server/routes/v1/certificate-authority-router.ts +++ b/backend/src/server/routes/v1/certificate-authority-router.ts @@ -8,7 +8,7 @@ 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 { CaRenewalType, CaStatus, CaType } from "@app/services/certificate-authority/certificate-authority-types"; import { validateAltNamesField, validateCaDateField @@ -275,15 +275,119 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "POST", + url: "/:caId/renew", // TODO + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Renew CA certificate for CA", + params: z.object({ + caId: z.string().trim().describe(CERTIFICATE_AUTHORITIES.RENEW_CA_CERT.caId) + }), + body: z.object({ + type: z.nativeEnum(CaRenewalType).describe(CERTIFICATE_AUTHORITIES.RENEW_CA_CERT.type), + notAfter: validateCaDateField.describe(CERTIFICATE_AUTHORITIES.RENEW_CA_CERT.notAfter) + }), + response: { + 200: z.object({ + certificate: z.string().trim().describe(CERTIFICATE_AUTHORITIES.RENEW_CA_CERT.certificate), + certificateChain: z.string().trim().describe(CERTIFICATE_AUTHORITIES.RENEW_CA_CERT.certificateChain), + serialNumber: z.string().trim().describe(CERTIFICATE_AUTHORITIES.RENEW_CA_CERT.serialNumber) + }) + } + }, + handler: async (req) => { + const { certificate, certificateChain, serialNumber } = await server.services.certificateAuthority.renewCaCert({ + 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, + serialNumber + }; + } + }); + server.route({ method: "GET", - url: "/:caId/certificate", + url: "/:caId/ca-certificates", config: { rateLimit: readLimit }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { - description: "Get cert and cert chain of a CA", + description: "Get list of past and current CA certificates for a CA", + params: z.object({ + caId: z.string().trim().describe(CERTIFICATE_AUTHORITIES.GET_CERT.caId) + }), + response: { + 200: z.array( + z.object({ + // TODO: consider not before and not after dates + 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), + version: z.number() + }) + ) + } + }, + handler: async (req) => { + const { caCerts, ca } = await server.services.certificateAuthority.getCaCerts({ + 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_CERTS, + metadata: { + caId: ca.id, + dn: ca.dn + } + } + }); + + return caCerts; + } + }); + + server.route({ + method: "GET", + url: "/:caId/certificate", // TODO: consider updating endpoint structure considering CA certificates + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Get current CA cert and cert chain of a CA", params: z.object({ caId: z.string().trim().describe(CERTIFICATE_AUTHORITIES.GET_CERT.caId) }), diff --git a/backend/src/services/certificate-authority/certificate-authority-fns.ts b/backend/src/services/certificate-authority/certificate-authority-fns.ts index 32ac4bfb1..b58046555 100644 --- a/backend/src/services/certificate-authority/certificate-authority-fns.ts +++ b/backend/src/services/certificate-authority/certificate-authority-fns.ts @@ -5,7 +5,13 @@ 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"; +import { + TDNParts, + TGetCaCertChainDTO, + TGetCaCertChainsDTO, + TGetCaCredentialsDTO, + TRebuildCaCrlDTO +} from "./certificate-authority-types"; export const createDistinguishedName = (parts: TDNParts) => { const dnParts = []; @@ -98,11 +104,59 @@ export const getCaCredentials = async ({ ]); return { + caSecret, caPrivateKey, caPublicKey }; }; +/** + * Return the list of decrypted pem-encoded certificates and certificate chains + * for CA with id [caId]. + */ +export const getCaCertChains = async ({ + caId, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService +}: TGetCaCertChainsDTO) => { + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) throw new BadRequestError({ message: "CA not found" }); + + const keyId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: keyId + }); + + const caCerts = await certificateAuthorityCertDAL.find({ caId: ca.id }, { sort: [["version", "asc"]] }); + + const decryptedChains = await Promise.all( + caCerts.map(async (caCert) => { + const decryptedCaCert = await kmsDecryptor({ + cipherTextBlob: caCert.encryptedCertificate + }); + const caCertObj = new x509.X509Certificate(decryptedCaCert); + const decryptedChain = await kmsDecryptor({ + cipherTextBlob: caCert.encryptedCertificateChain + }); + return { + certificate: caCertObj.toString("pem"), + certificateChain: decryptedChain.toString("utf-8"), + serialNumber: caCertObj.serialNumber, + version: caCert.version + }; + }) + ); + + return decryptedChains; +}; + /** * Return the decrypted pem-encoded certificate and certificate chain * for CA with id [caId]. diff --git a/backend/src/services/certificate-authority/certificate-authority-service.ts b/backend/src/services/certificate-authority/certificate-authority-service.ts index a1390b1f7..2bee10263 100644 --- a/backend/src/services/certificate-authority/certificate-authority-service.ts +++ b/backend/src/services/certificate-authority/certificate-authority-service.ts @@ -20,7 +20,8 @@ import { TCertificateAuthorityCertDALFactory } from "./certificate-authority-cer import { TCertificateAuthorityDALFactory } from "./certificate-authority-dal"; import { createDistinguishedName, - getCaCertChain, + getCaCertChain, // TODO: consider rename + getCaCertChains, getCaCredentials, keyAlgorithmToAlgCfg } from "./certificate-authority-fns"; @@ -32,10 +33,12 @@ import { TCreateCaDTO, TDeleteCaDTO, TGetCaCertDTO, + TGetCaCertsDTO, TGetCaCsrDTO, TGetCaDTO, TImportCertToCaDTO, TIssueCertFromCaDTO, + TRenewCaCertDTO, TSignIntermediateDTO, TUpdateCaDTO } from "./certificate-authority-types"; @@ -46,7 +49,7 @@ type TCertificateAuthorityServiceFactoryDep = { TCertificateAuthorityDALFactory, "transaction" | "create" | "findById" | "updateById" | "deleteById" | "findOne" >; - certificateAuthorityCertDAL: Pick; + certificateAuthorityCertDAL: Pick; certificateAuthoritySecretDAL: Pick; certificateAuthorityCrlDAL: Pick; certificateAuthorityQueue: TCertificateAuthorityQueueFactory; // TODO: Pick @@ -148,7 +151,8 @@ export const certificateAuthorityServiceFactory = ({ maxPathLength, notBefore: notBeforeDate, notAfter: notAfterDate, - serialNumber + serialNumber, + activeCaCertVersion: 1 }) }, tx @@ -163,6 +167,24 @@ export const certificateAuthorityServiceFactory = ({ kmsId: certificateManagerKmsId }); + // https://nodejs.org/api/crypto.html#static-method-keyobjectfromkey + const skObj = KeyObject.from(keys.privateKey); + + const { cipherTextBlob: encryptedPrivateKey } = await kmsEncryptor({ + plainText: skObj.export({ + type: "pkcs8", + format: "der" + }) + }); + + const caSecret = await certificateAuthoritySecretDAL.create( + { + caId: ca.id, + encryptedPrivateKey + }, + tx + ); + if (type === CaType.ROOT) { // note: create self-signed cert only applicable for root CA const cert = await x509.X509CertificateGenerator.createSelfSigned({ @@ -193,7 +215,9 @@ export const certificateAuthorityServiceFactory = ({ { caId: ca.id, encryptedCertificate, - encryptedCertificateChain + encryptedCertificateChain, + version: 1, + caSecretId: caSecret.id }, tx ); @@ -221,24 +245,6 @@ export const certificateAuthorityServiceFactory = ({ tx ); - // https://nodejs.org/api/crypto.html#static-method-keyobjectfromkey - const skObj = KeyObject.from(keys.privateKey); - - const { cipherTextBlob: encryptedPrivateKey } = await kmsEncryptor({ - plainText: skObj.export({ - type: "pkcs8", - format: "der" - }) - }); - - await certificateAuthoritySecretDAL.create( - { - caId: ca.id, - encryptedPrivateKey - }, - tx - ); - return ca; }); @@ -379,7 +385,316 @@ export const certificateAuthorityServiceFactory = ({ }; /** - * Return certificate and certificate chain for CA + * Renew certificate for CA with id [caId] + * Note: Currently implements CA renewal with same key-pair only + */ + const renewCaCert = async ({ caId, notAfter, actorId, actorAuthMethod, actor, actorOrgId }: TRenewCaCertDTO) => { + 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" }); + + // get latest CA certificate + const [caCert] = await certificateAuthorityCertDAL.find({ caId: ca.id }, { sort: [["version", "desc"]] }); + if (!caCert) throw new BadRequestError({ message: "CA does not have a certificate installed" }); + + const serialNumber = crypto.randomBytes(32).toString("hex"); + + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + + const kmsEncryptor = await kmsService.encryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + + const { caPrivateKey, caPublicKey, caSecret } = await getCaCredentials({ + caId: ca.id, + certificateAuthorityDAL, + certificateAuthoritySecretDAL, + projectDAL, + kmsService + }); + + const alg = keyAlgorithmToAlgCfg(ca.keyAlgorithm as CertKeyAlgorithm); + + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + const decryptedCaCert = await kmsDecryptor({ + cipherTextBlob: caCert.encryptedCertificate + }); + + const caCertObj = new x509.X509Certificate(decryptedCaCert); + + let certificate = ""; + let certificateChain = ""; + + switch (ca.type) { + case CaType.ROOT: { + if (new Date(notAfter) <= new Date(caCertObj.notAfter)) { + throw new BadRequestError({ + message: + "New Root CA certificate must have notAfter date that is greater than the current certificate notAfter date" + }); + } + + const notBeforeDate = new Date(); + const cert = await x509.X509CertificateGenerator.createSelfSigned({ + name: ca.dn, + serialNumber, + notBefore: notBeforeDate, + notAfter: new Date(notAfter), + signingAlgorithm: alg, + keys: { + privateKey: caPrivateKey, + publicKey: caPublicKey + }, + extensions: [ + new x509.BasicConstraintsExtension( + true, + ca.maxPathLength === -1 || !ca.maxPathLength ? undefined : ca.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(caPublicKey) + ] + }); + + const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({ + plainText: Buffer.from(new Uint8Array(cert.rawData)) + }); + + const { cipherTextBlob: encryptedCertificateChain } = await kmsEncryptor({ + plainText: Buffer.alloc(0) + }); + + await certificateAuthorityDAL.transaction(async (tx) => { + const newActiveCaCertVersion = caCert.version + 1; + await certificateAuthorityCertDAL.create( + { + caId: ca.id, + encryptedCertificate, + encryptedCertificateChain, + version: newActiveCaCertVersion, + caSecretId: caSecret.id + }, + tx + ); + + await certificateAuthorityDAL.updateById( + ca.id, + { + activeCaCertVersion: newActiveCaCertVersion, + notBefore: notBeforeDate, + notAfter: new Date(notAfter) + }, + tx + ); + }); + + certificate = cert.toString("pem"); + break; + } + case CaType.INTERMEDIATE: { + if (!ca.parentCaId) { + // TODO: look into optimal way to support renewal of intermediate CA with external parent CA + throw new BadRequestError({ + message: "Failed to renew intermediate CA certificate with external parent CA" + }); + } + + const parentCa = await certificateAuthorityDAL.findById(ca.parentCaId); + const { caPrivateKey: parentCaPrivateKey } = await getCaCredentials({ + caId: parentCa.id, + certificateAuthorityDAL, + certificateAuthoritySecretDAL, + projectDAL, + kmsService + }); + + // get latest parent CA certificate + const [parentCaCert] = await certificateAuthorityCertDAL.find( + { caId: parentCa.id }, + { sort: [["version", "desc"]] } + ); + + const decryptedParentCaCert = await kmsDecryptor({ + cipherTextBlob: parentCaCert.encryptedCertificate + }); + + const parentCaCertObj = new x509.X509Certificate(decryptedParentCaCert); + + if (new Date(notAfter) <= new Date(caCertObj.notAfter)) { + throw new BadRequestError({ + message: + "New Intermediate CA certificate must have notAfter date that is greater than the current certificate notAfter date" + }); + } + + if (new Date(notAfter) > new Date(parentCaCertObj.notAfter)) { + throw new BadRequestError({ + message: + "New Intermediate CA certificate must have notAfter date that is equal to or smaller than the notAfter date of the parent CA certificate current certificate notAfter date" + }); + } + + 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")] + }); + + const notBeforeDate = new Date(); + const intermediateCert = await x509.X509CertificateGenerator.create({ + serialNumber, + subject: csrObj.subject, + issuer: caCertObj.subject, + notBefore: notBeforeDate, + notAfter: new Date(notAfter), + signingKey: parentCaPrivateKey, + 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, + ca.maxPathLength === -1 || !ca.maxPathLength ? undefined : ca.maxPathLength, + true + ), + await x509.AuthorityKeyIdentifierExtension.create(caCertObj, false), + await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey) + ] + }); + + const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({ + plainText: Buffer.from(new Uint8Array(intermediateCert.rawData)) + }); + + const { cipherTextBlob: encryptedCertificateChain } = await kmsEncryptor({ + plainText: Buffer.alloc(0) + }); + + await certificateAuthorityDAL.transaction(async (tx) => { + const newActiveCaCertVersion = caCert.version + 1; + await certificateAuthorityCertDAL.create( + { + caId: ca.id, + encryptedCertificate, + encryptedCertificateChain, + version: newActiveCaCertVersion, + caSecretId: caSecret.id + }, + tx + ); + + await certificateAuthorityDAL.updateById( + ca.id, + { + activeCaCertVersion: newActiveCaCertVersion, + notBefore: notBeforeDate, + notAfter: new Date(notAfter) + }, + tx + ); + }); + + const { caCert: issuingCaCertificate, caCertChain } = await getCaCertChain({ + caId, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService + }); + + certificate = intermediateCert.toString("pem"); + certificateChain = `${issuingCaCertificate}\n${caCertChain}`.trim(); + break; + } + default: { + throw new BadRequestError({ + message: "Unrecognized CA type" + }); + } + } + + return { + certificate, + certificateChain, + serialNumber + }; + }; + + const getCaCerts = async ({ caId, actorId, actorAuthMethod, actor, actorOrgId }: TGetCaCertsDTO) => { + 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 caCertChains = await getCaCertChains({ + caId, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService + }); + + return { + ca, + caCerts: caCertChains + }; + }; + + /** + * Return current certificate and certificate chain for CA + * get latest?? ca cert */ const getCaCert = async ({ caId, actorId, actorAuthMethod, actor, actorOrgId }: TGetCaCertDTO) => { const ca = await certificateAuthorityDAL.findById(caId); @@ -628,7 +943,8 @@ export const certificateAuthorityServiceFactory = ({ { caId: ca.id, encryptedCertificate, - encryptedCertificateChain + encryptedCertificateChain, + version: 1 }, tx ); @@ -857,6 +1173,8 @@ export const certificateAuthorityServiceFactory = ({ updateCaById, deleteCaById, getCaCsr, + renewCaCert, + getCaCerts, getCaCert, signIntermediate, importCertToCa, diff --git a/backend/src/services/certificate-authority/certificate-authority-types.ts b/backend/src/services/certificate-authority/certificate-authority-types.ts index 7818c3a3e..b129c2a1b 100644 --- a/backend/src/services/certificate-authority/certificate-authority-types.ts +++ b/backend/src/services/certificate-authority/certificate-authority-types.ts @@ -20,6 +20,10 @@ export enum CaStatus { PENDING_CERTIFICATE = "pending-certificate" } +export enum CaRenewalType { + EXISTING = "existing" +} + export type TCreateCaDTO = { projectSlug: string; type: CaType; @@ -53,6 +57,16 @@ export type TGetCaCsrDTO = { caId: string; } & Omit; +export type TRenewCaCertDTO = { + caId: string; + notAfter: string; + type: CaRenewalType; +} & Omit; + +export type TGetCaCertsDTO = { + caId: string; +} & Omit; + export type TGetCaCertDTO = { caId: string; } & Omit; @@ -98,6 +112,14 @@ export type TGetCaCredentialsDTO = { kmsService: Pick; }; +export type TGetCaCertChainsDTO = { + caId: string; + certificateAuthorityDAL: Pick; + certificateAuthorityCertDAL: Pick; + projectDAL: Pick; + kmsService: Pick; +}; + export type TGetCaCertChainDTO = { caId: string; certificateAuthorityDAL: Pick; diff --git a/frontend/src/components/v2/index.tsx b/frontend/src/components/v2/index.tsx index 3a5cef86b..9c640e84e 100644 --- a/frontend/src/components/v2/index.tsx +++ b/frontend/src/components/v2/index.tsx @@ -1,5 +1,6 @@ export * from "./Accordion"; export * from "./Alert"; +export * from "./Badge"; export * from "./Button"; export * from "./Card"; export * from "./Checkbox"; diff --git a/frontend/src/hooks/api/ca/constants.tsx b/frontend/src/hooks/api/ca/constants.tsx index dbef15ffd..e77dfe2b8 100644 --- a/frontend/src/hooks/api/ca/constants.tsx +++ b/frontend/src/hooks/api/ca/constants.tsx @@ -1,4 +1,4 @@ -import { CaStatus,CaType } from "./enums"; +import { CaStatus, CaType } from "./enums"; export const caTypeToNameMap: { [K in CaType]: string } = { [CaType.ROOT]: "Root", @@ -10,3 +10,14 @@ export const caStatusToNameMap: { [K in CaStatus]: string } = { [CaStatus.DISABLED]: "Disabled", [CaStatus.PENDING_CERTIFICATE]: "Pending Certificate" }; + +export const getStatusBadgeVariant = (status: CaStatus) => { + switch (status) { + case CaStatus.ACTIVE: + return "success"; + case CaStatus.DISABLED: + return "danger"; + default: + return "primary"; + } +}; diff --git a/frontend/src/hooks/api/ca/enums.tsx b/frontend/src/hooks/api/ca/enums.tsx index bdd498a12..35d86c452 100644 --- a/frontend/src/hooks/api/ca/enums.tsx +++ b/frontend/src/hooks/api/ca/enums.tsx @@ -8,3 +8,7 @@ export enum CaStatus { DISABLED = "disabled", PENDING_CERTIFICATE = "pending-certificate" } + +export enum CaRenewalType { + EXISTING = "existing" +} diff --git a/frontend/src/hooks/api/ca/index.tsx b/frontend/src/hooks/api/ca/index.tsx index 60b53478d..45e675d4b 100644 --- a/frontend/src/hooks/api/ca/index.tsx +++ b/frontend/src/hooks/api/ca/index.tsx @@ -1,10 +1,10 @@ -export { CaStatus, CaType } from "./enums"; +export { CaRenewalType,CaStatus, CaType } from "./enums"; export { useCreateCa, useCreateCertificate, useDeleteCa, useImportCaCertificate, + useRenewCa, useSignIntermediate, - useUpdateCa -} from "./mutations"; -export { useGetCaById, useGetCaCert, useGetCaCrl,useGetCaCsr } from "./queries"; + useUpdateCa} from "./mutations"; +export { useGetCaById, useGetCaCert, useGetCaCerts, useGetCaCrl, useGetCaCsr } from "./queries"; diff --git a/frontend/src/hooks/api/ca/mutations.tsx b/frontend/src/hooks/api/ca/mutations.tsx index bb018f71a..54109adfc 100644 --- a/frontend/src/hooks/api/ca/mutations.tsx +++ b/frontend/src/hooks/api/ca/mutations.tsx @@ -3,6 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; import { workspaceKeys } from "../workspace/queries"; +import { caKeys } from "./queries"; import { TCertificateAuthority, TCreateCaDTO, @@ -11,10 +12,11 @@ import { TDeleteCaDTO, TImportCaCertificateDTO, TImportCaCertificateResponse, + TRenewCaDTO, + TRenewCaResponse, TSignIntermediateDTO, TSignIntermediateResponse, - TUpdateCaDTO -} from "./types"; + TUpdateCaDTO} from "./types"; export const useCreateCa = () => { const queryClient = useQueryClient(); @@ -84,8 +86,10 @@ export const useImportCaCertificate = () => { ); return data; }, - onSuccess: (_, { projectSlug }) => { + onSuccess: (_, { caId, projectSlug }) => { queryClient.invalidateQueries(workspaceKeys.getWorkspaceCas({ projectSlug })); + queryClient.invalidateQueries(caKeys.getCaCerts(caId)); + queryClient.invalidateQueries(caKeys.getCaCert(caId)); } }); }; @@ -106,3 +110,23 @@ export const useCreateCertificate = () => { } }); }; + +export const useRenewCa = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (body) => { + const { data } = await apiRequest.post( + `/api/v1/pki/ca/${body.caId}/renew`, + body + ); + return data; + }, + onSuccess: (_, { caId, projectSlug }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceCas({ projectSlug })); + queryClient.invalidateQueries(caKeys.getCaCert(caId)); + queryClient.invalidateQueries(caKeys.getCaCerts(caId)); + queryClient.invalidateQueries(caKeys.getCaCsr(caId)); + queryClient.invalidateQueries(caKeys.getCaCrl(caId)); + } + }); +}; diff --git a/frontend/src/hooks/api/ca/queries.tsx b/frontend/src/hooks/api/ca/queries.tsx index e78274391..278c335ca 100644 --- a/frontend/src/hooks/api/ca/queries.tsx +++ b/frontend/src/hooks/api/ca/queries.tsx @@ -6,6 +6,7 @@ import { TCertificateAuthority } from "./types"; export const caKeys = { getCaById: (caId: string) => [{ caId }, "ca"], + getCaCerts: (caId: string) => [{ caId }, "ca-cert"], getCaCert: (caId: string) => [{ caId }, "ca-cert"], getCaCsr: (caId: string) => [{ caId }, "ca-csr"], getCaCrl: (caId: string) => [{ caId }, "ca-crl"] @@ -24,6 +25,24 @@ export const useGetCaById = (caId: string) => { }); }; +export const useGetCaCerts = (caId: string) => { + return useQuery({ + queryKey: caKeys.getCaCerts(caId), + queryFn: async () => { + const { data } = await apiRequest.get< + { + certificate: string; + certificateChain: string; + serialNumber: string; + version: number; + }[] + >(`/api/v1/pki/ca/${caId}/ca-certificates`); // TODO: consider updating endpoint structure + return data; + }, + enabled: Boolean(caId) + }); +}; + export const useGetCaCert = (caId: string) => { return useQuery({ queryKey: caKeys.getCaCert(caId), @@ -32,7 +51,7 @@ export const useGetCaCert = (caId: string) => { certificate: string; certificateChain: string; serialNumber: string; - }>(`/api/v1/pki/ca/${caId}/certificate`); + }>(`/api/v1/pki/ca/${caId}/certificate`); // TODO: consider updating endpoint structure return data; }, enabled: Boolean(caId) diff --git a/frontend/src/hooks/api/ca/types.ts b/frontend/src/hooks/api/ca/types.ts index 7cb5dbf42..bba7aa699 100644 --- a/frontend/src/hooks/api/ca/types.ts +++ b/frontend/src/hooks/api/ca/types.ts @@ -1,5 +1,5 @@ import { CertKeyAlgorithm } from "../certificates/enums"; -import { CaStatus, CaType } from "./enums"; +import { CaRenewalType,CaStatus, CaType } from "./enums"; export type TCertificateAuthority = { id: string; @@ -94,3 +94,16 @@ export type TCreateCertificateResponse = { privateKey: string; serialNumber: string; }; + +export type TRenewCaDTO = { + projectSlug: string; + caId: string; + type: CaRenewalType; + notAfter: string; +}; + +export type TRenewCaResponse = { + certificate: string; + certificateChain: string; + serialNumber: string; +}; diff --git a/frontend/src/pages/project/[id]/ca/[caId]/index.tsx b/frontend/src/pages/project/[id]/ca/[caId]/index.tsx new file mode 100644 index 000000000..b5f11f55a --- /dev/null +++ b/frontend/src/pages/project/[id]/ca/[caId]/index.tsx @@ -0,0 +1,18 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import Head from "next/head"; + +import { CaPage } from "@app/views/Project/CaPage"; + +export default function Ca() { + return ( + <> + + Certificate Authority + + + + + ); +} + +Ca.requireAuth = true; diff --git a/frontend/src/views/Project/CaPage/CaPage.tsx b/frontend/src/views/Project/CaPage/CaPage.tsx new file mode 100644 index 000000000..b7b015f21 --- /dev/null +++ b/frontend/src/views/Project/CaPage/CaPage.tsx @@ -0,0 +1,147 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { useRouter } from "next/router"; +import { faChevronLeft, faEllipsis } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + Button, + DeleteActionModal, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Tooltip +} from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { withProjectPermission } from "@app/hoc"; +import { useDeleteCa,useGetCaById } from "@app/hooks/api"; +import { usePopUp } from "@app/hooks/usePopUp"; +import { CaModal } from "@app/views/Project/CertificatesPage/components/CaTab/components/CaModal"; + +import { CaInstallCertModal } from "../CertificatesPage/components/CaTab/components/CaInstallCertModal"; +import { TabSections } from "../Types"; +import { CaCertificatesSection, CaDetailsSection, CaRenewalModal } from "./components"; + +export const CaPage = withProjectPermission( + () => { + const router = useRouter(); + const caId = router.query.caId as string; + const { data } = useGetCaById(caId); + + const { currentWorkspace } = useWorkspace(); + const projectId = currentWorkspace?.id || ""; + + const { mutateAsync: deleteCa } = useDeleteCa(); + + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "ca", + "deleteCa", + "installCaCert", + "renewCa" + ] as const); + + const onRemoveCaSubmit = async (caIdToDelete: string) => { + try { + if (!currentWorkspace?.slug) return; + + await deleteCa({ caId: caIdToDelete, projectSlug: currentWorkspace.slug }); + + await createNotification({ + text: "Successfully deleted CA", + type: "success" + }); + + handlePopUpClose("deleteCa"); + router.push(`/project/${projectId}/certificates`); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to delete CA", + type: "error" + }); + } + }; + + return ( +
+ {data && ( +
+ +
+

{data.friendlyName}

+ + +
+ + + +
+
+ + + {(isAllowed) => ( + + handlePopUpOpen("deleteCa", { + caId: data.id, + dn: data.dn + }) + } + disabled={!isAllowed} + > + Delete CA + + )} + + +
+
+
+
+ +
+ +
+
+ )} + + + + handlePopUpToggle("deleteCa", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onRemoveCaSubmit((popUp?.deleteCa?.data as { caId: string })?.caId) + } + /> +
+ ); + }, + { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.CertificateAuthorities } +); diff --git a/frontend/src/views/Project/CaPage/components/CaCertificatesSection/CaCertificatesSection.tsx b/frontend/src/views/Project/CaPage/components/CaCertificatesSection/CaCertificatesSection.tsx new file mode 100644 index 000000000..9b617f5cf --- /dev/null +++ b/frontend/src/views/Project/CaPage/components/CaCertificatesSection/CaCertificatesSection.tsx @@ -0,0 +1,31 @@ +// import { faPlus } from "@fortawesome/free-solid-svg-icons"; +// import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +// import { IconButton } from "@app/components/v2"; +import { CaCertificatesTable } from "./CaCertificatesTable"; + +type Props = { + caId: string; +}; + +export const CaCertificatesSection = ({ caId }: Props) => { + return ( +
+
+

CA Certificates

+ {/* { + // handlePopUpOpen("addIdentityToProject"); + }} + > + + */} +
+
+ +
+
+ ); +}; diff --git a/frontend/src/views/Project/CaPage/components/CaCertificatesSection/CaCertificatesTable.tsx b/frontend/src/views/Project/CaPage/components/CaCertificatesSection/CaCertificatesTable.tsx new file mode 100644 index 000000000..d1451fbf5 --- /dev/null +++ b/frontend/src/views/Project/CaPage/components/CaCertificatesSection/CaCertificatesTable.tsx @@ -0,0 +1,131 @@ +import { faCertificate, faEllipsis } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + EmptyState, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr} from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { useGetCaCerts } from "@app/hooks/api"; + +type Props = { + caId: string; +}; + +// TODO: not before + +// created at +// expires on + +export const CaCertificatesTable = ({ caId }: Props) => { + const { data: caCerts, isLoading } = useGetCaCerts(caId); + console.log("CaCertificatesTable data: ", caCerts); + return ( + + + + + + + + + + + {isLoading && } + {!isLoading && + caCerts?.map((caCert) => { + // console.log("caCert index: ", index); + // const isLastItem = index === caCerts.length - 1; + return ( + + + + + + + ); + })} + +
NameCreated AtValid Until +
+
+ Certificate {caCert.version} + {/* + Current + */} +
+
TestTest + + +
+ +
+
+ + + {(isAllowed) => ( + { + e.stopPropagation(); + // TODO + // router.push(`/org/${orgId}/identities/${id}`); + }} + disabled={!isAllowed} + > + Download CA Certificate + + )} + + + {(isAllowed) => ( + { + e.stopPropagation(); + // TODO + // handlePopUpOpen("deleteIdentity", { + // identityId: id, + // name + // }); + }} + disabled={!isAllowed} + > + Download CA Certificate Chain + + )} + + +
+
+ {!isLoading && !caCerts?.length && ( + + )} +
+ ); +}; diff --git a/frontend/src/views/Project/CaPage/components/CaCertificatesSection/index.tsx b/frontend/src/views/Project/CaPage/components/CaCertificatesSection/index.tsx new file mode 100644 index 000000000..14f5dbbb9 --- /dev/null +++ b/frontend/src/views/Project/CaPage/components/CaCertificatesSection/index.tsx @@ -0,0 +1 @@ +export { CaCertificatesSection } from "./CaCertificatesSection"; diff --git a/frontend/src/views/Project/CaPage/components/CaDetailsSection.tsx b/frontend/src/views/Project/CaPage/components/CaDetailsSection.tsx new file mode 100644 index 000000000..bc939b5d3 --- /dev/null +++ b/frontend/src/views/Project/CaPage/components/CaDetailsSection.tsx @@ -0,0 +1,141 @@ +import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { format } from "date-fns"; + +import { ProjectPermissionCan } from "@app/components/permissions"; +import { Button, IconButton, Tooltip } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { useTimedReset } from "@app/hooks"; +import { CaStatus,useGetCaById } from "@app/hooks/api"; +import { caStatusToNameMap, caTypeToNameMap } from "@app/hooks/api/ca/constants"; +import { certKeyAlgorithmToNameMap } from "@app/hooks/api/certificates/constants"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + caId: string; + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["ca", "renewCa", "installCaCert"]>, + data?: {} + ) => void; +}; + +export const CaDetailsSection = ({ caId, handlePopUpOpen }: Props) => { + const [copyTextId, isCopyingId, setCopyTextId] = useTimedReset({ + initialState: "Copy ID to clipboard" + }); + + const { data: ca } = useGetCaById(caId); + + return ca ? ( +
+
+

CA Details

+
+
+
+

CA ID

+
+

{ca.id}

+
+ + { + navigator.clipboard.writeText(ca.id); + setCopyTextId("Copied"); + }} + > + + + +
+
+
+
+

Friendly Name

+

{ca.friendlyName}

+
+
+

CA Type

+

{caTypeToNameMap[ca.type]}

+
+
+

Status

+

{caStatusToNameMap[ca.status]}

+
+
+

Key Algorithm

+

{certKeyAlgorithmToNameMap[ca.keyAlgorithm]}

+
+
+

Max Path Length

+

{ca.maxPathLength ?? "-"}

+
+
+

Not Before

+

+ {ca.notBefore ? format(new Date(ca.notBefore), "yyyy-MM-dd") : "-"} +

+
+
+

Not After

+

+ {ca.notAfter ? format(new Date(ca.notAfter), "yyyy-MM-dd") : "-"} +

+
+ {ca.status === CaStatus.ACTIVE && ( + + {(isAllowed) => { + return ( + + ); + }} + + )} + {ca.status === CaStatus.PENDING_CERTIFICATE && ( + + {(isAllowed) => { + return ( + + ); + }} + + )} +
+
+ ) : ( +
+ ); +}; diff --git a/frontend/src/views/Project/CaPage/components/CaRenewalModal.tsx b/frontend/src/views/Project/CaPage/components/CaRenewalModal.tsx new file mode 100644 index 000000000..a4742aa2c --- /dev/null +++ b/frontend/src/views/Project/CaPage/components/CaRenewalModal.tsx @@ -0,0 +1,215 @@ +import { useEffect } 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 { + CaRenewalType, + // CaType, + CaStatus, + useGetCaById, + useRenewCa} from "@app/hooks/api/ca"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const caRenewalTypes = [{ label: "Renew with same key pair", value: CaRenewalType.EXISTING }]; + +const isValidDate = (dateString: string) => { + const date = new Date(dateString); + return !Number.isNaN(date.getTime()); +}; + +// const getMiddleDate = (date1: Date, date2: Date): Date => { +// const timestamp1 = date1.getTime(); +// const timestamp2 = date2.getTime(); +// const middleTimestamp = (timestamp1 + timestamp2) / 2; +// return new Date(middleTimestamp); +// }; + +const schema = z + .object({ + type: z.enum([CaRenewalType.EXISTING]), + notAfter: z.string().trim().refine(isValidDate, { message: "Invalid date format" }) + }) + .required(); + +export type FormData = z.infer; + +type Props = { + popUp: UsePopUpState<["renewCa"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["renewCa"]>, state?: boolean) => void; +}; + +export const CaRenewalModal = ({ popUp, handlePopUpToggle }: Props) => { + const { currentWorkspace } = useWorkspace(); + const projectSlug = currentWorkspace?.slug || ""; + + const popUpData = popUp?.renewCa?.data as { + caId: string; + }; + + const { data: ca } = useGetCaById(popUpData?.caId || ""); + const { data: parentCa } = useGetCaById(ca?.parentCaId || ""); + const { mutateAsync: renewCa } = useRenewCa(); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting }, + setValue + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + type: CaRenewalType.EXISTING, + notAfter: "" // TODO: set sensible default + } + }); + + useEffect(() => { + if (ca && ca.status === CaStatus.ACTIVE) { + // if (ca.type === CaType.ROOT) { + // // extend Root CA validity by the same amount of time + // const notBeforeDate = new Date(ca.notBefore as string); + // const notAfterDate = new Date(ca.notAfter as string); + + // const newNotAfterDate = new Date( + // notAfterDate.getTime() + notAfterDate.getTime() - notBeforeDate.getTime() + // ); + + // setValue("notAfter", newNotAfterDate.toISOString().split("T")[0]); + // } else if (ca.type === CaType.INTERMEDIATE && parentCa) { + // } + // extend Root CA validity by the same amount of time + const notBeforeDate = new Date(ca.notBefore as string); + const notAfterDate = new Date(ca.notAfter as string); + + const newNotAfterDate = new Date( + notAfterDate.getTime() + notAfterDate.getTime() - notBeforeDate.getTime() + ); + + setValue("notAfter", newNotAfterDate.toISOString().split("T")[0]); + } + + // if (ca && parentCa) { + // // intermediate CA + // } else if (ca && ca.notAfter && !parentCa) { + // // root CA + // const timeDifference = new Date(ca.).getTime() - startDate.getTime(); + // } + }, [ca, parentCa]); + + const onFormSubmit = async ({ type, notAfter }: FormData) => { + try { + if (!projectSlug || !popUpData.caId) return; + + await renewCa({ + projectSlug, + caId: popUpData.caId, + notAfter, + type + }); + + handlePopUpToggle("renewCa", false); + + createNotification({ + text: "Successfully renewed CA", + type: "success" + }); + + reset(); + } catch (err) { + console.error(err); + const error = err as any; + const text = error?.response?.data?.message ?? "Failed to renew CA"; + + createNotification({ + text, + type: "error" + }); + } + }; + + return ( + { + handlePopUpToggle("renewCa", isOpen); + reset(); + }} + > + +
+ ( + + + + )} + /> + ( + + + + )} + /> +
+ + +
+ +
+
+ ); +}; diff --git a/frontend/src/views/Project/CaPage/components/index.tsx b/frontend/src/views/Project/CaPage/components/index.tsx new file mode 100644 index 000000000..78889e9b4 --- /dev/null +++ b/frontend/src/views/Project/CaPage/components/index.tsx @@ -0,0 +1,3 @@ +export { CaCertificatesSection } from "./CaCertificatesSection/CaCertificatesSection"; +export { CaDetailsSection } from "./CaDetailsSection"; +export { CaRenewalModal } from "./CaRenewalModal"; diff --git a/frontend/src/views/Project/CaPage/index.tsx b/frontend/src/views/Project/CaPage/index.tsx new file mode 100644 index 000000000..18703bd1b --- /dev/null +++ b/frontend/src/views/Project/CaPage/index.tsx @@ -0,0 +1 @@ +export { CaPage } from "./CaPage"; diff --git a/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaTable.tsx b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaTable.tsx index a35a4e432..f719365eb 100644 --- a/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaTable.tsx +++ b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaTable.tsx @@ -1,3 +1,4 @@ +import { useRouter } from "next/router"; import { faBan, faCertificate, @@ -12,6 +13,7 @@ import { twMerge } from "tailwind-merge"; import { ProjectPermissionCan } from "@app/components/permissions"; import { + Badge, DropdownMenu, DropdownMenuContent, DropdownMenuItem, @@ -31,9 +33,14 @@ import { ProjectPermissionActions, ProjectPermissionSub, useSubscription, - useWorkspace} from "@app/context"; + useWorkspace +} from "@app/context"; import { CaStatus, useListWorkspaceCas } from "@app/hooks/api"; -import { caStatusToNameMap, caTypeToNameMap } from "@app/hooks/api/ca/constants"; +import { + caStatusToNameMap, + caTypeToNameMap, + getStatusBadgeVariant +} from "@app/hooks/api/ca/constants"; import { UsePopUpState } from "@app/hooks/usePopUp"; type Props = { @@ -51,11 +58,13 @@ type Props = { }; export const CaTable = ({ handlePopUpOpen }: Props) => { + const router = useRouter(); const { subscription } = useSubscription(); const { currentWorkspace } = useWorkspace(); const { data, isLoading } = useListWorkspaceCas({ projectSlug: currentWorkspace?.slug ?? "" }); + return (
@@ -76,11 +85,26 @@ export const CaTable = ({ handlePopUpOpen }: Props) => { data.length > 0 && data.map((ca) => { return ( - + router.push(`/project/${currentWorkspace?.id}/ca/${ca.id}`)} + > {ca.friendlyName} - {caStatusToNameMap[ca.status]} + + + {caStatusToNameMap[ca.status]} + + {caTypeToNameMap[ca.type]} - {ca.notAfter ? format(new Date(ca.notAfter), "yyyy-MM-dd") : "-"} + +
+

{ca.notAfter ? format(new Date(ca.notAfter), "yyyy-MM-dd") : "-"}

+ {/* + Expires Soon + */} +
+ @@ -102,7 +126,8 @@ export const CaTable = ({ handlePopUpOpen }: Props) => { !isAllowed && "pointer-events-none cursor-not-allowed opacity-50" )} - onClick={async () => { + onClick={(e) => { + e.stopPropagation(); handlePopUpOpen("installCaCert", { caId: ca.id }); @@ -110,7 +135,7 @@ export const CaTable = ({ handlePopUpOpen }: Props) => { disabled={!isAllowed} icon={} > - Install Certificate + Install CA Certificate )} @@ -126,7 +151,8 @@ export const CaTable = ({ handlePopUpOpen }: Props) => { !isAllowed && "pointer-events-none cursor-not-allowed opacity-50" )} - onClick={async () => { + onClick={(e) => { + e.stopPropagation(); handlePopUpOpen("caCert", { caId: ca.id }); @@ -150,7 +176,8 @@ export const CaTable = ({ handlePopUpOpen }: Props) => { !isAllowed && "pointer-events-none cursor-not-allowed opacity-50" )} - onClick={async () => { + onClick={(e) => { + e.stopPropagation(); if (!subscription?.caCrl) { handlePopUpOpen("upgradePlan", { description: @@ -179,11 +206,12 @@ export const CaTable = ({ handlePopUpOpen }: Props) => { className={twMerge( !isAllowed && "pointer-events-none cursor-not-allowed opacity-50" )} - onClick={async () => + onClick={(e) => { + e.stopPropagation(); handlePopUpOpen("ca", { caId: ca.id - }) - } + }); + }} disabled={!isAllowed} icon={} > @@ -202,15 +230,16 @@ export const CaTable = ({ handlePopUpOpen }: Props) => { !isAllowed && "pointer-events-none cursor-not-allowed opacity-50" )} - onClick={async () => + onClick={(e) => { + e.stopPropagation(); handlePopUpOpen("caStatus", { caId: ca.id, status: ca.status === CaStatus.ACTIVE ? CaStatus.DISABLED : CaStatus.ACTIVE - }) - } + }); + }} disabled={!isAllowed} icon={} > @@ -228,12 +257,13 @@ export const CaTable = ({ handlePopUpOpen }: Props) => { className={twMerge( !isAllowed && "pointer-events-none cursor-not-allowed opacity-50" )} - onClick={async () => + onClick={(e) => { + e.stopPropagation(); handlePopUpOpen("deleteCa", { caId: ca.id, dn: ca.dn - }) - } + }); + }} disabled={!isAllowed} icon={} >