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..24eca185d --- /dev/null +++ b/backend/src/db/migrations/20240802181855_ca-cert-version.ts @@ -0,0 +1,117 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.CertificateAuthority)) { + const hasActiveCaCertIdColumn = await knex.schema.hasColumn(TableName.CertificateAuthority, "activeCaCertId"); + if (!hasActiveCaCertIdColumn) { + await knex.schema.alterTable(TableName.CertificateAuthority, (t) => { + t.uuid("activeCaCertId").nullable(); + t.foreign("activeCaCertId").references("id").inTable(TableName.CertificateAuthorityCert); + }); + + await knex.raw(` + UPDATE "${TableName.CertificateAuthority}" ca + SET "activeCaCertId" = cac.id + FROM "${TableName.CertificateAuthorityCert}" cac + WHERE ca.id = cac."caId" + `); + } + } + + 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.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"]); + }); + } + + if (await knex.schema.hasTable(TableName.Certificate)) { + await knex.schema.alterTable(TableName.Certificate, (t) => { + t.uuid("caCertId").nullable(); + t.foreign("caCertId").references("id").inTable(TableName.CertificateAuthorityCert); + }); + + await knex.raw(` + UPDATE "${TableName.Certificate}" cert + SET "caCertId" = ( + SELECT caCert.id + FROM "${TableName.CertificateAuthorityCert}" caCert + WHERE caCert."caId" = cert."caId" + ) + `); + + await knex.schema.alterTable(TableName.Certificate, (t) => { + t.uuid("caCertId").notNullable().alter(); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.CertificateAuthority)) { + if (await knex.schema.hasColumn(TableName.CertificateAuthority, "activeCaCertId")) { + await knex.schema.alterTable(TableName.CertificateAuthority, (t) => { + t.dropColumn("activeCaCertId"); + }); + } + } + + 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"); + }); + } + } + + if (await knex.schema.hasTable(TableName.Certificate)) { + if (await knex.schema.hasColumn(TableName.Certificate, "caCertId")) { + await knex.schema.alterTable(TableName.Certificate, (t) => { + t.dropColumn("caCertId"); + }); + } + } +} diff --git a/backend/src/db/schemas/certificate-authorities.ts b/backend/src/db/schemas/certificate-authorities.ts index 16f303b5c..e59a9225c 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(), + activeCaCertId: z.string().uuid().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/certificates.ts b/backend/src/db/schemas/certificates.ts index 833396fb1..cb14d05f9 100644 --- a/backend/src/db/schemas/certificates.ts +++ b/backend/src/db/schemas/certificates.ts @@ -20,7 +20,8 @@ export const CertificatesSchema = z.object({ notAfter: z.date(), revokedAt: z.date().nullable().optional(), revocationReason: z.number().nullable().optional(), - altNames: z.string().default("").nullable().optional() + altNames: z.string().default("").nullable().optional(), + caCertId: z.string().uuid() }); export type TCertificates = 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 830c17faa..d5eb9ef43 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -130,7 +130,9 @@ export enum EventType { GET_CA = "get-certificate-authority", UPDATE_CA = "update-certificate-authority", DELETE_CA = "delete-certificate-authority", + RENEW_CA = "renew-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", @@ -1096,6 +1098,14 @@ interface DeleteCa { }; } +interface RenewCa { + type: EventType.RENEW_CA; + metadata: { + caId: string; + dn: string; + }; +} + interface GetCaCsr { type: EventType.GET_CA_CSR; metadata: { @@ -1104,6 +1114,14 @@ interface GetCaCsr { }; } +interface GetCaCerts { + type: EventType.GET_CA_CERTS; + metadata: { + caId: string; + dn: string; + }; +} + interface GetCaCert { type: EventType.GET_CA_CERT; metadata: { @@ -1349,7 +1367,9 @@ export type Event = | GetCa | UpdateCa | DeleteCa + | RenewCa | GetCaCsr + | GetCaCerts | GetCaCert | SignIntermediate | ImportCaCert diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 43c40d154..ab6a8e7ef 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -1049,12 +1049,27 @@ 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 certificate 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", certificateChain: "The certificate chain of the CA", serialNumber: "The serial number of the CA certificate" }, + GET_CA_CERTS: { + caId: "The ID of the CA to get the CA certificates for", + certificate: "The certificate body of the CA certificate", + certificateChain: "The certificate chain of the CA certificate", + serialNumber: "The serial number of the CA certificate", + version: "The version of the CA certificate. The version is incremented for each CA renewal operation." + }, SIGN_INTERMEDIATE: { caId: "The ID of the CA to sign the intermediate certificate with", csr: "The pem-encoded CSR to sign with 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 1a6e30302..103d430c0 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,118 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "POST", + url: "/:caId/renew", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Perform CA certificate renewal", + 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, ca } = + 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.RENEW_CA, + metadata: { + caId: ca.id, + dn: ca.dn + } + } + }); + + 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_CA_CERTS.caId) + }), + response: { + 200: z.array( + z.object({ + certificate: z.string().describe(CERTIFICATE_AUTHORITIES.GET_CA_CERTS.certificate), + certificateChain: z.string().describe(CERTIFICATE_AUTHORITIES.GET_CA_CERTS.certificateChain), + serialNumber: z.string().describe(CERTIFICATE_AUTHORITIES.GET_CA_CERTS.serialNumber), + version: z.number().describe(CERTIFICATE_AUTHORITIES.GET_CA_CERTS.version) + }) + ) + } + }, + 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 397aa4552..4e3df1173 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 = []; @@ -89,6 +95,8 @@ export const keyAlgorithmToAlgCfg = (keyAlgorithm: CertKeyAlgorithm) => { * 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 + * + * TODO: Update to get latest CA Secret once support for CA renewal with new key pair is added */ export const getCaCredentials = async ({ caId, @@ -132,26 +140,73 @@ export const getCaCredentials = async ({ ]); return { + caSecret, caPrivateKey, caPublicKey }; }; /** - * Return the decrypted pem-encoded certificate and certificate chain + * Return the list of decrypted pem-encoded certificates and certificate chains * for CA with id [caId]. */ -export const getCaCertChain = async ({ +export const getCaCertChains = async ({ caId, certificateAuthorityDAL, certificateAuthorityCertDAL, projectDAL, kmsService -}: TGetCaCertChainDTO) => { +}: TGetCaCertChainsDTO) => { 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 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 + * corresponding to CA certificate with id [caCertId]. + */ +export const getCaCertChain = async ({ + caCertId, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService +}: TGetCaCertChainDTO) => { + const caCert = await certificateAuthorityCertDAL.findById(caCertId); + if (!caCert) throw new BadRequestError({ message: "CA certificate not found" }); + const ca = await certificateAuthorityDAL.findById(caCert.caId); const keyId = await getProjectKmsCertificateKeyId({ projectId: ca.projectId, diff --git a/backend/src/services/certificate-authority/certificate-authority-service.ts b/backend/src/services/certificate-authority/certificate-authority-service.ts index 49bb209ef..b63ec9ba9 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, parseDistinguishedName @@ -33,10 +34,12 @@ import { TCreateCaDTO, TDeleteCaDTO, TGetCaCertDTO, + TGetCaCertsDTO, TGetCaCsrDTO, TGetCaDTO, TImportCertToCaDTO, TIssueCertFromCaDTO, + TRenewCaCertDTO, TSignCertFromCaDTO, TSignIntermediateDTO, TUpdateCaDTO @@ -48,7 +51,10 @@ type TCertificateAuthorityServiceFactoryDep = { TCertificateAuthorityDALFactory, "transaction" | "create" | "findById" | "updateById" | "deleteById" | "findOne" >; - certificateAuthorityCertDAL: Pick; + certificateAuthorityCertDAL: Pick< + TCertificateAuthorityCertDALFactory, + "create" | "findOne" | "transaction" | "find" | "findById" + >; certificateAuthoritySecretDAL: Pick; certificateAuthorityCrlDAL: Pick; certificateAuthorityQueue: TCertificateAuthorityQueueFactory; // TODO: Pick @@ -165,6 +171,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({ @@ -191,11 +215,21 @@ export const certificateAuthorityServiceFactory = ({ plainText: Buffer.alloc(0) }); - await certificateAuthorityCertDAL.create( + const caCert = await certificateAuthorityCertDAL.create( { caId: ca.id, encryptedCertificate, - encryptedCertificateChain + encryptedCertificateChain, + version: 1, + caSecretId: caSecret.id + }, + tx + ); + + await certificateAuthorityDAL.updateById( + ca.id, + { + activeCaCertId: caCert.id }, tx ); @@ -223,24 +257,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; }); @@ -341,9 +357,7 @@ export const certificateAuthorityServiceFactory = ({ ); 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" }); + if (ca.activeCaCertId) throw new BadRequestError({ message: "CA already has a certificate installed" }); const { caPrivateKey, caPublicKey } = await getCaCredentials({ caId, @@ -381,9 +395,283 @@ 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 getCaCert = async ({ caId, actorId, actorAuthMethod, actor, actorOrgId }: TGetCaCertDTO) => { + 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" }); + + if (!ca.activeCaCertId) throw new BadRequestError({ message: "CA does not have a certificate installed" }); + + 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.findById(ca.activeCaCertId); + + 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 newCaCert = await certificateAuthorityCertDAL.create( + { + caId: ca.id, + encryptedCertificate, + encryptedCertificateChain, + version: caCert.version + 1, + caSecretId: caSecret.id + }, + tx + ); + + await certificateAuthorityDAL.updateById( + ca.id, + { + activeCaCertId: newCaCert.id, + 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 + if (!parentCa.activeCaCertId) + throw new BadRequestError({ message: "Parent CA does not have a certificate installed" }); + const parentCaCert = await certificateAuthorityCertDAL.findById(parentCa.activeCaCertId); + + 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: parentCaCertObj.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(parentCaCertObj, false), + await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey) + ] + }); + + const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({ + plainText: Buffer.from(new Uint8Array(intermediateCert.rawData)) + }); + + const { caCert: parentCaCertificate, caCertChain: parentCaCertChain } = await getCaCertChain({ + caCertId: parentCa.activeCaCertId, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService + }); + + certificateChain = `${parentCaCertificate}\n${parentCaCertChain}`.trim(); + + const { cipherTextBlob: encryptedCertificateChain } = await kmsEncryptor({ + plainText: Buffer.from(certificateChain) + }); + + await certificateAuthorityDAL.transaction(async (tx) => { + const newCaCert = await certificateAuthorityCertDAL.create( + { + caId: ca.id, + encryptedCertificate, + encryptedCertificateChain, + version: caCert.version + 1, + caSecretId: caSecret.id + }, + tx + ); + + await certificateAuthorityDAL.updateById( + ca.id, + { + activeCaCertId: newCaCert.id, + notBefore: notBeforeDate, + notAfter: new Date(notAfter) + }, + tx + ); + }); + + certificate = intermediateCert.toString("pem"); + break; + } + default: { + throw new BadRequestError({ + message: "Unrecognized CA type" + }); + } + } + + return { + certificate, + certificateChain, + serialNumber, + ca + }; + }; + + const getCaCerts = async ({ caId, actorId, actorAuthMethod, actor, actorOrgId }: TGetCaCertsDTO) => { const ca = await certificateAuthorityDAL.findById(caId); if (!ca) throw new BadRequestError({ message: "CA not found" }); @@ -400,7 +688,7 @@ export const certificateAuthorityServiceFactory = ({ ProjectPermissionSub.CertificateAuthorities ); - const { caCert, caCertChain, serialNumber } = await getCaCertChain({ + const caCertChains = await getCaCertChains({ caId, certificateAuthorityDAL, certificateAuthorityCertDAL, @@ -408,6 +696,41 @@ export const certificateAuthorityServiceFactory = ({ kmsService }); + return { + ca, + caCerts: caCertChains + }; + }; + + /** + * Return current 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" }); + if (!ca.activeCaCertId) throw new BadRequestError({ message: "CA does not have a certificate installed" }); + + 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({ + caCertId: ca.activeCaCertId, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService + }); + return { certificate: caCert, certificateChain: caCertChain, @@ -447,6 +770,13 @@ export const certificateAuthorityServiceFactory = ({ ); if (ca.status === CaStatus.DISABLED) throw new BadRequestError({ message: "CA is disabled" }); + if (!ca.activeCaCertId) throw new BadRequestError({ message: "CA does not have a certificate installed" }); + + const caCert = await certificateAuthorityCertDAL.findById(ca.activeCaCertId); + + if (ca.notAfter && new Date() > new Date(ca.notAfter)) { + throw new BadRequestError({ message: "CA is expired" }); + } const alg = keyAlgorithmToAlgCfg(ca.keyAlgorithm as CertKeyAlgorithm); @@ -459,7 +789,6 @@ export const certificateAuthorityServiceFactory = ({ kmsId: certificateManagerKmsId }); - const caCert = await certificateAuthorityCertDAL.findOne({ caId: ca.id }); const decryptedCaCert = await kmsDecryptor({ cipherTextBlob: caCert.encryptedCertificate }); @@ -531,7 +860,7 @@ export const certificateAuthorityServiceFactory = ({ }); const { caCert: issuingCaCertificate, caCertChain } = await getCaCertChain({ - caId, + caCertId: ca.activeCaCertId, certificateAuthorityDAL, certificateAuthorityCertDAL, projectDAL, @@ -577,8 +906,7 @@ export const certificateAuthorityServiceFactory = ({ ProjectPermissionSub.CertificateAuthorities ); - const caCert = await certificateAuthorityCertDAL.findOne({ caId: ca.id }); - if (caCert) throw new BadRequestError({ message: "CA has already imported a certificate" }); + if (ca.activeCaCertId) throw new BadRequestError({ message: "CA has already imported a certificate" }); const certObj = new x509.X509Certificate(certificate); const maxPathLength = certObj.getExtension(x509.BasicConstraintsExtension)?.pathLength; @@ -625,12 +953,32 @@ export const certificateAuthorityServiceFactory = ({ plainText: Buffer.from(certificateChain) }); + // TODO: validate that latest key-pair of CA is used to sign the certificate + // once renewal with new key pair is supported + const { caSecret, caPublicKey } = await getCaCredentials({ + caId: ca.id, + certificateAuthorityDAL, + certificateAuthoritySecretDAL, + projectDAL, + kmsService + }); + + const isCaAndCertPublicKeySame = Buffer.from(await crypto.subtle.exportKey("spki", caPublicKey)).equals( + Buffer.from(certObj.publicKey.rawData) + ); + + if (!isCaAndCertPublicKeySame) { + throw new BadRequestError({ message: "CA and certificate public key do not match" }); + } + await certificateAuthorityCertDAL.transaction(async (tx) => { - await certificateAuthorityCertDAL.create( + const newCaCert = await certificateAuthorityCertDAL.create( { caId: ca.id, encryptedCertificate, - encryptedCertificateChain + encryptedCertificateChain, + version: 1, + caSecretId: caSecret.id }, tx ); @@ -643,7 +991,8 @@ export const certificateAuthorityServiceFactory = ({ notBefore: new Date(certObj.notBefore), notAfter: new Date(certObj.notAfter), serialNumber: certObj.serialNumber, - parentCaId: parentCa?.id + parentCaId: parentCa?.id, + activeCaCertId: newCaCert.id }, tx ); @@ -683,9 +1032,12 @@ export const certificateAuthorityServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Certificates); if (ca.status === CaStatus.DISABLED) throw new BadRequestError({ message: "CA is disabled" }); + if (!ca.activeCaCertId) throw new BadRequestError({ message: "CA does not have a certificate installed" }); + const caCert = await certificateAuthorityCertDAL.findById(ca.activeCaCertId); - const caCert = await certificateAuthorityCertDAL.findOne({ caId: ca.id }); - if (!caCert) throw new BadRequestError({ message: "CA does not have a certificate installed" }); + if (ca.notAfter && new Date() > new Date(ca.notAfter)) { + throw new BadRequestError({ message: "CA is expired" }); + } const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ projectId: ca.projectId, @@ -814,6 +1166,7 @@ export const certificateAuthorityServiceFactory = ({ const cert = await certificateDAL.create( { caId: ca.id, + caCertId: caCert.id, status: CertStatus.ACTIVE, friendlyName: friendlyName || commonName, commonName, @@ -837,7 +1190,7 @@ export const certificateAuthorityServiceFactory = ({ }); const { caCert: issuingCaCertificate, caCertChain } = await getCaCertChain({ - caId: ca.id, + caCertId: caCert.id, certificateAuthorityDAL, certificateAuthorityCertDAL, projectDAL, @@ -886,9 +1239,13 @@ export const certificateAuthorityServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Certificates); if (ca.status === CaStatus.DISABLED) throw new BadRequestError({ message: "CA is disabled" }); + if (!ca.activeCaCertId) throw new BadRequestError({ message: "CA does not have a certificate installed" }); - const caCert = await certificateAuthorityCertDAL.findOne({ caId: ca.id }); - if (!caCert) throw new BadRequestError({ message: "CA does not have a certificate installed" }); + const caCert = await certificateAuthorityCertDAL.findById(ca.activeCaCertId); + + if (ca.notAfter && new Date() > new Date(ca.notAfter)) { + throw new BadRequestError({ message: "CA is expired" }); + } const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ projectId: ca.projectId, @@ -1013,6 +1370,7 @@ export const certificateAuthorityServiceFactory = ({ const cert = await certificateDAL.create( { caId: ca.id, + caCertId: caCert.id, status: CertStatus.ACTIVE, friendlyName: friendlyName || csrObj.subject, commonName: cn, @@ -1036,7 +1394,7 @@ export const certificateAuthorityServiceFactory = ({ }); const { caCert: issuingCaCertificate, caCertChain } = await getCaCertChain({ - caId: ca.id, + caCertId: ca.activeCaCertId, certificateAuthorityDAL, certificateAuthorityCertDAL, projectDAL, @@ -1058,6 +1416,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 d4c2682d2..31a6e1629 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; @@ -109,10 +123,18 @@ export type TGetCaCredentialsDTO = { kmsService: Pick; }; -export type TGetCaCertChainDTO = { +export type TGetCaCertChainsDTO = { caId: string; certificateAuthorityDAL: Pick; - certificateAuthorityCertDAL: Pick; + certificateAuthorityCertDAL: Pick; + projectDAL: Pick; + kmsService: Pick; +}; + +export type TGetCaCertChainDTO = { + caCertId: string; + certificateAuthorityDAL: Pick; + certificateAuthorityCertDAL: Pick; projectDAL: Pick; kmsService: Pick; }; diff --git a/backend/src/services/certificate/certificate-service.ts b/backend/src/services/certificate/certificate-service.ts index f05ed8a87..8dc2de901 100644 --- a/backend/src/services/certificate/certificate-service.ts +++ b/backend/src/services/certificate/certificate-service.ts @@ -21,7 +21,7 @@ type TCertificateServiceFactoryDep = { certificateDAL: Pick; certificateBodyDAL: Pick; certificateAuthorityDAL: Pick; - certificateAuthorityCertDAL: Pick; + certificateAuthorityCertDAL: Pick; certificateAuthorityCrlDAL: Pick; certificateAuthoritySecretDAL: Pick; projectDAL: Pick; @@ -180,7 +180,7 @@ export const certificateServiceFactory = ({ const certObj = new x509.X509Certificate(decryptedCert); const { caCert, caCertChain } = await getCaCertChain({ - caId: ca.id, + caCertId: cert.caCertId, certificateAuthorityDAL, certificateAuthorityCertDAL, projectDAL, diff --git a/docs/api-reference/endpoints/certificate-authorities/list-ca-certs.mdx b/docs/api-reference/endpoints/certificate-authorities/list-ca-certs.mdx new file mode 100644 index 000000000..ce253807c --- /dev/null +++ b/docs/api-reference/endpoints/certificate-authorities/list-ca-certs.mdx @@ -0,0 +1,4 @@ +--- +title: "List CA certificates" +openapi: "GET /api/v1/pki/ca/{caId}/ca-certificates" +--- diff --git a/docs/api-reference/endpoints/certificate-authorities/renew.mdx b/docs/api-reference/endpoints/certificate-authorities/renew.mdx new file mode 100644 index 000000000..901811f2d --- /dev/null +++ b/docs/api-reference/endpoints/certificate-authorities/renew.mdx @@ -0,0 +1,4 @@ +--- +title: "Renew" +openapi: "POST /api/v1/pki/ca/{caId}/renew" +--- diff --git a/docs/documentation/platform/pki/private-ca.mdx b/docs/documentation/platform/pki/private-ca.mdx index 0ebb31e2c..3a7191a1d 100644 --- a/docs/documentation/platform/pki/private-ca.mdx +++ b/docs/documentation/platform/pki/private-ca.mdx @@ -36,7 +36,7 @@ A typical workflow for setting up a Private CA hierarchy consists of the followi intermediate certificate back to the intermediate CA as part of Step 2. -## Guide +## Guide to Creating a CA Hierarchy In the following steps, we explore how to create a simple Private CA hierarchy consisting of a root CA and an intermediate CA. @@ -240,6 +240,51 @@ consisting of a root CA and an intermediate CA. +## Guide to CA Renewal + +In the following steps, we explore how to renew a CA certificate via same key pair. + + + + Head to the CA Page of the CA you wish you renew and press **Renew CA** on + the left side. ![pki ca renewal + page](/images/platform/pki/ca-renewal-page.png) Input a new **Valid Until** + date to be used for the renewed CA certificate and press **Renew** to renew + the CA. ![pki ca renewal. modal](/images/platform/pki/ca-renewal-modal.png) + + The new **Valid Until** date must be within the validity period of the + parent CA. + + + + + To renew a CA certificate, make an API request to the [Renew CA](/api-reference/endpoints/certificate-authorities/renew) API endpoint, specifying the new `notAfter` date for the CA. + + ### Sample request + + ```bash Request + curl --location --request POST 'https://app.infisical.com/api/v1/pki/ca//renew' \ + --header 'Authorization: Bearer ' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "type": "existing", + "notAfter": "2029-06-12" + }' + ``` + + ### Sample response + + ```bash Response + { + certificate: "...", + certificateChain: "...", + serialNumber: "..." + } + ``` + + + + ## FAQ @@ -247,4 +292,8 @@ consisting of a root CA and an intermediate CA. Infisical supports `RSA 2048`, `RSA 4096`, `ECDSA P-256`, `ECDSA P-384` key algorithms specified at the time of creating a CA. + + At the moment, Infisical only supports CA renewal via same key pair. We + anticipate supporting CA renewal via new key pair in the coming month. + diff --git a/docs/images/platform/pki/ca-renewal-modal.png b/docs/images/platform/pki/ca-renewal-modal.png new file mode 100644 index 000000000..c86d944f3 Binary files /dev/null and b/docs/images/platform/pki/ca-renewal-modal.png differ diff --git a/docs/images/platform/pki/ca-renewal-page.png b/docs/images/platform/pki/ca-renewal-page.png new file mode 100644 index 000000000..43c690ae7 Binary files /dev/null and b/docs/images/platform/pki/ca-renewal-page.png differ diff --git a/docs/mint.json b/docs/mint.json index bf0f396d2..defe3c201 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -682,6 +682,8 @@ "api-reference/endpoints/certificate-authorities/read", "api-reference/endpoints/certificate-authorities/update", "api-reference/endpoints/certificate-authorities/delete", + "api-reference/endpoints/certificate-authorities/renew", + "api-reference/endpoints/certificate-authorities/list-ca-certs", "api-reference/endpoints/certificate-authorities/csr", "api-reference/endpoints/certificate-authorities/cert", "api-reference/endpoints/certificate-authorities/sign-intermediate", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6007a282d..74f00b74d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -22,6 +22,7 @@ "@headlessui/react": "^1.7.7", "@hookform/resolvers": "^2.9.10", "@octokit/rest": "^19.0.7", + "@peculiar/x509": "^1.11.0", "@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-alert-dialog": "^1.0.5", "@radix-ui/react-checkbox": "^1.0.4", @@ -4520,6 +4521,149 @@ "@octokit/openapi-types": "^18.0.0" } }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.3.13.tgz", + "integrity": "sha512-joqu8A7KR2G85oLPq+vB+NFr2ro7Ls4ol13Zcse/giPSzUNN0n2k3v8kMpf6QdGUhI13e5SzQYN8AKP8sJ8v4w==", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.13", + "@peculiar/asn1-x509": "^2.3.13", + "@peculiar/asn1-x509-attr": "^2.3.13", + "asn1js": "^3.0.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-csr": { + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.3.13.tgz", + "integrity": "sha512-+JtFsOUWCw4zDpxp1LbeTYBnZLlGVOWmHHEhoFdjM5yn4wCn+JiYQ8mghOi36M2f6TPQ17PmhNL6/JfNh7/jCA==", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.13", + "@peculiar/asn1-x509": "^2.3.13", + "asn1js": "^3.0.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.3.13.tgz", + "integrity": "sha512-3dF2pQcrN/WJEMq+9qWLQ0gqtn1G81J4rYqFl6El6QV367b4IuhcRv+yMA84tNNyHOJn9anLXV5radnpPiG3iA==", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.13", + "@peculiar/asn1-x509": "^2.3.13", + "asn1js": "^3.0.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-pfx": { + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.3.13.tgz", + "integrity": "sha512-fypYxjn16BW+5XbFoY11Rm8LhZf6euqX/C7BTYpqVvLem1GvRl7A+Ro1bO/UPwJL0z+1mbvXEnkG0YOwbwz2LA==", + "dependencies": { + "@peculiar/asn1-cms": "^2.3.13", + "@peculiar/asn1-pkcs8": "^2.3.13", + "@peculiar/asn1-rsa": "^2.3.13", + "@peculiar/asn1-schema": "^2.3.13", + "asn1js": "^3.0.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-pkcs8": { + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.3.13.tgz", + "integrity": "sha512-VP3PQzbeSSjPjKET5K37pxyf2qCdM0dz3DJ56ZCsol3FqAXGekb4sDcpoL9uTLGxAh975WcdvUms9UcdZTuGyQ==", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.13", + "@peculiar/asn1-x509": "^2.3.13", + "asn1js": "^3.0.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-pkcs9": { + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.3.13.tgz", + "integrity": "sha512-rIwQXmHpTo/dgPiWqUgby8Fnq6p1xTJbRMxCiMCk833kQCeZrC5lbSKg6NDnJTnX2kC6IbXBB9yCS2C73U2gJg==", + "dependencies": { + "@peculiar/asn1-cms": "^2.3.13", + "@peculiar/asn1-pfx": "^2.3.13", + "@peculiar/asn1-pkcs8": "^2.3.13", + "@peculiar/asn1-schema": "^2.3.13", + "@peculiar/asn1-x509": "^2.3.13", + "@peculiar/asn1-x509-attr": "^2.3.13", + "asn1js": "^3.0.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.3.13.tgz", + "integrity": "sha512-wBNQqCyRtmqvXkGkL4DR3WxZhHy8fDiYtOjTeCd7SFE5F6GBeafw3EJ94PX/V0OJJrjQ40SkRY2IZu3ZSyBqcg==", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.13", + "@peculiar/asn1-x509": "^2.3.13", + "asn1js": "^3.0.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.3.13.tgz", + "integrity": "sha512-3Xq3a01WkHRZL8X04Zsfg//mGaA21xlL4tlVn4v2xGT0JStiztATRkMwa5b+f/HXmY2smsiLXYK46Gwgzvfg3g==", + "dependencies": { + "asn1js": "^3.0.5", + "pvtsutils": "^1.3.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.3.13.tgz", + "integrity": "sha512-PfeLQl2skXmxX2/AFFCVaWU8U6FKW1Db43mgBhShCOFS1bVxqtvusq1hVjfuEcuSQGedrLdCSvTgabluwN/M9A==", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.13", + "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.13", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.3.13.tgz", + "integrity": "sha512-WpEos6CcnUzJ6o2Qb68Z7Dz5rSjRGv/DtXITCNBtjZIRWRV12yFVci76SVfOX8sisL61QWMhpLKQibrG8pi2Pw==", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.13", + "@peculiar/asn1-x509": "^2.3.13", + "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.11.0", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.11.0.tgz", + "integrity": "sha512-8rdxE//tsWLb2Yo2TYO2P8gieStbrHK/huFMV5PPfwX8I5HmtOus+Ox6nTKrPA9o+WOPaa5xKenee+QdmHBd5g==", + "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/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -9870,6 +10014,19 @@ "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==", "dev": true }, + "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": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz", @@ -20320,6 +20477,22 @@ "async-limiter": "~1.0.0" } }, + "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.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.2.tgz", @@ -21186,6 +21359,11 @@ "redux": "^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/reflect.getprototypeof": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.4.tgz", @@ -23573,6 +23751,22 @@ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", "dev": true }, + "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/tty-browserify": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index acbf220e1..994485252 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -30,6 +30,7 @@ "@headlessui/react": "^1.7.7", "@hookform/resolvers": "^2.9.10", "@octokit/rest": "^19.0.7", + "@peculiar/x509": "^1.11.0", "@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-alert-dialog": "^1.0.5", "@radix-ui/react-checkbox": "^1.0.4", 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..27d03248f 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,6 +12,8 @@ import { TDeleteCaDTO, TImportCaCertificateDTO, TImportCaCertificateResponse, + TRenewCaDTO, + TRenewCaResponse, TSignIntermediateDTO, TSignIntermediateResponse, TUpdateCaDTO @@ -84,8 +87,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 +111,24 @@ 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.getCaById(caId)); + 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..7513070c8 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; @@ -19,6 +19,7 @@ export type TCertificateAuthority = { notAfter?: string; notBefore?: string; keyAlgorithm: CertKeyAlgorithm; + activeCaCertId?: string; createdAt: string; updatedAt: string; }; @@ -94,3 +95,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..7edc99af9 --- /dev/null +++ b/frontend/src/views/Project/CaPage/CaPage.tsx @@ -0,0 +1,145 @@ +/* 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..9eb008706 --- /dev/null +++ b/frontend/src/views/Project/CaPage/components/CaCertificatesSection/CaCertificatesTable.tsx @@ -0,0 +1,133 @@ +import { faCertificate, faEllipsis } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import * as x509 from "@peculiar/x509"; +import { format } from "date-fns"; +import FileSaver from "file-saver"; +import { twMerge } from "tailwind-merge"; + +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + Badge, + 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; +}; + +export const CaCertificatesTable = ({ caId }: Props) => { + const { data: caCerts, isLoading } = useGetCaCerts(caId); + + const downloadTxtFile = (filename: string, content: string) => { + const blob = new Blob([content], { type: "text/plain;charset=utf-8" }); + FileSaver.saveAs(blob, filename); + }; + + return ( + + + + + + + + + + + {isLoading && } + {!isLoading && + caCerts?.map((caCert, index) => { + const isLastItem = index === caCerts.length - 1; + const caCertObj = new x509.X509Certificate(caCert.certificate); + return ( + + + + + + + ); + })} + +
CA Certificate #Not BeforeNot After +
+
+ CA Certificate {caCert.version} + {isLastItem && ( + + Current + + )} +
+
{format(new Date(caCertObj.notBefore), "yyyy-MM-dd")}{format(new Date(caCertObj.notAfter), "yyyy-MM-dd")} + + +
+ +
+
+ + + {(isAllowed) => ( + { + e.stopPropagation(); + downloadTxtFile("cert.pem", caCert.certificate); + }} + disabled={!isAllowed} + > + Download CA Certificate + + )} + + + {(isAllowed) => ( + { + e.stopPropagation(); + downloadTxtFile("chain.pem", caCert.certificateChain); + }} + 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..eb8ccf86f --- /dev/null +++ b/frontend/src/views/Project/CaPage/components/CaDetailsSection.tsx @@ -0,0 +1,167 @@ +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 [copyTextParentId, isCopyingParentId, setCopyTextParentId] = 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"); + }} + > + + + +
+
+
+ {ca.parentCaId && ( +
+

Parent CA ID

+
+

{ca.parentCaId}

+
+ + { + navigator.clipboard.writeText(ca.parentCaId as string); + setCopyTextParentId("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..6f9cf3a1c --- /dev/null +++ b/frontend/src/views/Project/CaPage/components/CaRenewalModal.tsx @@ -0,0 +1,182 @@ +// 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, + useRenewCa + // useGetCaById, + // CaType, + // CaStatus +} 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 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: consider setting a default value + } + }); + + // useEffect(() => { + // if (ca && ca.status === CaStatus.ACTIVE) { + // 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]); + // } + // }, [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); + } + }; + + 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={} >