diff --git a/backend/package-lock.json b/backend/package-lock.json index 77a18b567..189150178 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -25,6 +25,7 @@ "@node-saml/passport-saml": "^4.0.4", "@octokit/rest": "^20.0.2", "@octokit/webhooks-types": "^7.3.1", + "@peculiar/asn1-schema": "^2.3.8", "@peculiar/x509": "^1.10.0", "@serdnam/pino-cloudwatch-transport": "^1.0.4", "@sindresorhus/slugify": "^2.2.1", diff --git a/backend/package.json b/backend/package.json index 720efb34b..67f95cd63 100644 --- a/backend/package.json +++ b/backend/package.json @@ -86,6 +86,7 @@ "@node-saml/passport-saml": "^4.0.4", "@octokit/rest": "^20.0.2", "@octokit/webhooks-types": "^7.3.1", + "@peculiar/asn1-schema": "^2.3.8", "@peculiar/x509": "^1.10.0", "@serdnam/pino-cloudwatch-transport": "^1.0.4", "@sindresorhus/slugify": "^2.2.1", diff --git a/backend/src/db/migrations/20240530163136_certificate-mgmt.ts b/backend/src/db/migrations/20240530163136_certificate-mgmt.ts index 56fdff775..ec1de2390 100644 --- a/backend/src/db/migrations/20240530163136_certificate-mgmt.ts +++ b/backend/src/db/migrations/20240530163136_certificate-mgmt.ts @@ -5,7 +5,6 @@ import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; export async function up(knex: Knex): Promise { if (!(await knex.schema.hasTable(TableName.CertificateAuthority))) { - // TODO: add algo deets await knex.schema.createTable(TableName.CertificateAuthority, (t) => { t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); t.timestamps(true, true, true); @@ -56,7 +55,6 @@ export async function up(knex: Knex): Promise { } if (!(await knex.schema.hasTable(TableName.Certificate))) { - // TODO: consider adding serialNumber await knex.schema.createTable(TableName.Certificate, (t) => { t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); t.timestamps(true, true, true); @@ -67,6 +65,8 @@ export async function up(knex: Knex): Promise { t.string("commonName").notNullable(); t.datetime("notBefore").notNullable(); t.datetime("notAfter").notNullable(); + t.datetime("revokedAt").nullable(); + t.integer("revocationReason").nullable(); // integer based on crl reason in RFC 5280 }); } diff --git a/backend/src/db/schemas/certificates.ts b/backend/src/db/schemas/certificates.ts index d64b17d8e..b487483d0 100644 --- a/backend/src/db/schemas/certificates.ts +++ b/backend/src/db/schemas/certificates.ts @@ -16,7 +16,9 @@ export const CertificatesSchema = z.object({ serialNumber: z.string(), commonName: z.string(), notBefore: z.date(), - notAfter: z.date() + notAfter: z.date(), + revokedAt: z.date().nullable().optional(), + revocationReason: z.number().nullable().optional() }); export type TCertificates = z.infer; diff --git a/backend/src/server/routes/v1/certificate-authority-router.ts b/backend/src/server/routes/v1/certificate-authority-router.ts index d52e0f7f4..729755b51 100644 --- a/backend/src/server/routes/v1/certificate-authority-router.ts +++ b/backend/src/server/routes/v1/certificate-authority-router.ts @@ -4,7 +4,8 @@ import { CertificateAuthoritiesSchema } from "@app/db/schemas"; 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 { CaStatus, CaType, CertKeyAlgorithm } from "@app/services/certificate-authority/certificate-authority-types"; +import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types"; +import { CaStatus, CaType } from "@app/services/certificate-authority/certificate-authority-types"; import { validateCaDateField } from "@app/services/certificate-authority/certificate-authority-validators"; export const registerCaRouter = async (server: FastifyZodProvider) => { @@ -386,4 +387,36 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { }; } }); + + server.route({ + method: "GET", + url: "/:caId/crl", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Get CRL of the CA", + params: z.object({ + caId: z.string().trim() + }), + response: { + 200: z.object({ + crl: z.string() + }) + } + }, + handler: async (req) => { + const { crl } = await server.services.certificateAuthority.getCaCrl({ + caId: req.params.caId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + return { + crl + }; + } + }); }; diff --git a/backend/src/server/routes/v1/certificate-router.ts b/backend/src/server/routes/v1/certificate-router.ts index 8963c3940..408cd4d64 100644 --- a/backend/src/server/routes/v1/certificate-router.ts +++ b/backend/src/server/routes/v1/certificate-router.ts @@ -4,6 +4,7 @@ import { CertificatesSchema } from "@app/db/schemas"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; +import { CrlReason } from "@app/services/certificate/certificate-types"; export const registerCertRouter = async (server: FastifyZodProvider) => { server.route({ @@ -50,6 +51,19 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { params: z.object({ serialNumber: z.string().trim() }), + body: z.object({ + revocationReason: z.enum([ + CrlReason.UNSPECIFIED, + CrlReason.KEY_COMPROMISE, + CrlReason.CA_COMPROMISE, + CrlReason.AFFILIATION_CHANGED, + CrlReason.SUPERSEDED, + CrlReason.CESSATION_OF_OPERATION, + CrlReason.CERTIFICATE_HOLD, + CrlReason.PRIVILEGE_WITHDRAWN, + CrlReason.A_A_COMPROMISE + ]) + }), response: { 200: z.object({ message: z.string().trim(), @@ -59,17 +73,18 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { } }, handler: async (req) => { - await server.services.certificate.revokeCert({ + const { revokedAt } = await server.services.certificate.revokeCert({ serialNumber: req.params.serialNumber, actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId + actorOrgId: req.permission.orgId, + ...req.body }); return { message: "Successfully revoked certificate", serialNumber: req.params.serialNumber, - revokedAt: new Date() + revokedAt }; } }); diff --git a/backend/src/services/certificate-authority/certificate-authority-fns.ts b/backend/src/services/certificate-authority/certificate-authority-fns.ts index 7b906a3f9..393729672 100644 --- a/backend/src/services/certificate-authority/certificate-authority-fns.ts +++ b/backend/src/services/certificate-authority/certificate-authority-fns.ts @@ -1,4 +1,5 @@ -import { CertKeyAlgorithm, TDNParts } from "./certificate-authority-types"; +import { CertKeyAlgorithm } from "../certificate/certificate-types"; +import { TDNParts } from "./certificate-authority-types"; export const createDistinguishedName = (parts: TDNParts) => { const dnParts = []; diff --git a/backend/src/services/certificate-authority/certificate-authority-service.ts b/backend/src/services/certificate-authority/certificate-authority-service.ts index a3288fe22..c10994559 100644 --- a/backend/src/services/certificate-authority/certificate-authority-service.ts +++ b/backend/src/services/certificate-authority/certificate-authority-service.ts @@ -10,7 +10,7 @@ import { TCertificateCertDALFactory } from "@app/services/certificate/certificat import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal"; -import { TCertStatus } from "../certificate/certificate-types"; +import { CertKeyAlgorithm, CertStatus } from "../certificate/certificate-types"; import { TCertificateAuthorityCertDALFactory } from "./certificate-authority-cert-dal"; import { TCertificateAuthorityDALFactory } from "./certificate-authority-dal"; import { createDistinguishedName, keyAlgorithmToAlgCfg } from "./certificate-authority-fns"; @@ -18,12 +18,12 @@ import { TCertificateAuthoritySkDALFactory } from "./certificate-authority-sk-da import { CaStatus, CaType, - CertKeyAlgorithm, TCreateCaDTO, TDeleteCaDTO, TGetCaCertDTO, TGetCaCsrDTO, TGetCaDTO, + TGetCrl, TImportCertToCaDTO, TIssueCertFromCaDTO, TSignIntermediateDTO, @@ -37,7 +37,7 @@ type TCertificateAuthorityServiceFactoryDep = { >; certificateAuthorityCertDAL: Pick; certificateAuthoritySkDAL: Pick; - certificateDAL: Pick; + certificateDAL: Pick; certificateCertDAL: Pick; projectDAL: Pick; permissionService: Pick; @@ -633,7 +633,7 @@ export const certificateAuthorityServiceFactory = ({ const cert = await certificateDAL.create( { caId: ca.id, - status: TCertStatus.ACTIVE, + status: CertStatus.ACTIVE, commonName, serialNumber, notBefore: notBeforeDate, @@ -663,6 +663,64 @@ export const certificateAuthorityServiceFactory = ({ }; }; + /** + * Return the Certificate Revocation List (CRL) for the CA + */ + const getCaCrl = async ({ caId, actorId, actorAuthMethod, actor, actorOrgId }: TGetCrl) => { + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) throw new BadRequestError({ message: "CA not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.CertificateAuthorities + ); + + const caKeys = await certificateAuthoritySkDAL.findOne({ caId: ca.id }); + + const alg = keyAlgorithmToAlgCfg(ca.keyAlgorithm as CertKeyAlgorithm); + const skObj = crypto.createPrivateKey({ key: caKeys.sk, format: "pem", type: "pkcs8" }); + const sk = await crypto.subtle.importKey("pkcs8", skObj.export({ format: "der", type: "pkcs8" }), alg, true, [ + "sign" + ]); + + const revokedCerts = await certificateDAL.find({ + caId: ca.id, + status: CertStatus.REVOKED + }); + + const crl = await x509.X509CrlGenerator.create({ + issuer: ca.dn, + thisUpdate: new Date(), + nextUpdate: new Date("2025/12/12"), + entries: revokedCerts.map((revokedCert) => { + return { + serialNumber: revokedCert.serialNumber, + revocationDate: new Date(revokedCert.revokedAt as Date), + reason: revokedCert.revocationReason as number, + invalidity: new Date("2022/01/01"), + issuer: ca.dn + }; + }), + signingAlgorithm: alg, + signingKey: sk + }); + + const base64crl = crl.toString("base64"); + const crlPem = `-----BEGIN X509 CRL-----\n${base64crl.match(/.{1,64}/g)?.join("\n")}\n-----END X509 CRL-----`; + + return { + crl: crlPem + }; + }; + return { createCa, getCaById, @@ -672,6 +730,7 @@ export const certificateAuthorityServiceFactory = ({ getCaCert, signIntermediate, importCertToCa, - issueCertFromCa + issueCertFromCa, + getCaCrl }; }; diff --git a/backend/src/services/certificate-authority/certificate-authority-types.ts b/backend/src/services/certificate-authority/certificate-authority-types.ts index 6764000d8..146ab5d58 100644 --- a/backend/src/services/certificate-authority/certificate-authority-types.ts +++ b/backend/src/services/certificate-authority/certificate-authority-types.ts @@ -1,5 +1,7 @@ import { TProjectPermission } from "@app/lib/types"; +import { CertKeyAlgorithm } from "../certificate/certificate-types"; + export enum CaType { ROOT = "root", INTERMEDIATE = "intermediate" @@ -11,13 +13,6 @@ export enum CaStatus { PENDING_CERTIFICATE = "pending-certificate" } -export enum CertKeyAlgorithm { - RSA_2048 = "RSA_2048", - RSA_4096 = "RSA_4096", - ECDSA_P256 = "EC_prime256v1", - ECDSA_P384 = "EC_secp384r1" -} - export type TCreateCaDTO = { projectSlug: string; type: CaType; @@ -76,6 +71,10 @@ export type TIssueCertFromCaDTO = { notAfter?: string; } & Omit; +export type TGetCrl = { + caId: string; +} & Omit; + export type TDNParts = { commonName?: string; organization?: string; diff --git a/backend/src/services/certificate/certificate-fns.ts b/backend/src/services/certificate/certificate-fns.ts new file mode 100644 index 000000000..dfbd50551 --- /dev/null +++ b/backend/src/services/certificate/certificate-fns.ts @@ -0,0 +1,26 @@ +import * as x509 from "@peculiar/x509"; + +import { CrlReason } from "./certificate-types"; + +export const revocationReasonToCrlCode = (crlReason: CrlReason) => { + switch (crlReason) { + case CrlReason.KEY_COMPROMISE: + return x509.X509CrlReason.keyCompromise; + case CrlReason.CA_COMPROMISE: + return x509.X509CrlReason.cACompromise; + case CrlReason.AFFILIATION_CHANGED: + return x509.X509CrlReason.affiliationChanged; + case CrlReason.SUPERSEDED: + return x509.X509CrlReason.superseded; + case CrlReason.CESSATION_OF_OPERATION: + return x509.X509CrlReason.cessationOfOperation; + case CrlReason.CERTIFICATE_HOLD: + return x509.X509CrlReason.certificateHold; + case CrlReason.PRIVILEGE_WITHDRAWN: + return x509.X509CrlReason.privilegeWithdrawn; + case CrlReason.A_A_COMPROMISE: + return x509.X509CrlReason.aACompromise; + default: + return x509.X509CrlReason.unspecified; + } +}; diff --git a/backend/src/services/certificate/certificate-service.ts b/backend/src/services/certificate/certificate-service.ts index fee486144..50baaa1bc 100644 --- a/backend/src/services/certificate/certificate-service.ts +++ b/backend/src/services/certificate/certificate-service.ts @@ -7,10 +7,11 @@ import { TCertificateCertDALFactory } from "@app/services/certificate/certificat import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal"; -import { TDeleteCertDTO, TGetCertCertDTO, TGetCertDTO, TRevokeCertDTO } from "./certificate-types"; +import { revocationReasonToCrlCode } from "./certificate-fns"; +import { CertStatus, TDeleteCertDTO, TGetCertCertDTO, TGetCertDTO, TRevokeCertDTO } from "./certificate-types"; type TCertificateServiceFactoryDep = { - certificateDAL: Pick; + certificateDAL: Pick; certificateCertDAL: Pick; certificateAuthorityDAL: Pick; permissionService: Pick; @@ -59,7 +60,14 @@ export const certificateServiceFactory = ({ return deletedCert; }; - const revokeCert = async ({ serialNumber, actorId, actorAuthMethod, actor, actorOrgId }: TRevokeCertDTO) => { + const revokeCert = async ({ + serialNumber, + revocationReason, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TRevokeCertDTO) => { const cert = await certificateDAL.findOne({ serialNumber }); const ca = await certificateAuthorityDAL.findById(cert.caId); @@ -72,14 +80,22 @@ export const certificateServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Certificates); - // WIP - // const revocationDate = new Date(); + if (cert.status === CertStatus.REVOKED) throw new Error("Certificate already revoked"); - // const serialNumber2 = crypto.randomBytes(16).toString("hex"); - // const crlEntry = new x509.X509CrlEntry(serialNumber2, new Date(), []); + const revokedAt = new Date(); + await certificateDAL.update( + { + id: cert.id + }, + { + status: CertStatus.REVOKED, + revokedAt, + revocationReason: revocationReasonToCrlCode(revocationReason) + } + ); - return {}; + return { revokedAt }; }; const getCertCert = async ({ serialNumber, actorId, actorAuthMethod, actor, actorOrgId }: TGetCertCertDTO) => { diff --git a/backend/src/services/certificate/certificate-types.ts b/backend/src/services/certificate/certificate-types.ts index 6ba8b796e..cb7cf5466 100644 --- a/backend/src/services/certificate/certificate-types.ts +++ b/backend/src/services/certificate/certificate-types.ts @@ -1,10 +1,30 @@ import { TProjectPermission } from "@app/lib/types"; -export enum TCertStatus { +export enum CertStatus { ACTIVE = "active", REVOKED = "revoked" } +export enum CertKeyAlgorithm { + RSA_2048 = "RSA_2048", + RSA_4096 = "RSA_4096", + ECDSA_P256 = "EC_prime256v1", + ECDSA_P384 = "EC_secp384r1" +} + +export enum CrlReason { + UNSPECIFIED = "UNSPECIFIED", + KEY_COMPROMISE = "KEY_COMPROMISE", + CA_COMPROMISE = "CA_COMPROMISE", + AFFILIATION_CHANGED = "AFFILIATION_CHANGED", + SUPERSEDED = "SUPERSEDED", + CESSATION_OF_OPERATION = "CESSATION_OF_OPERATION", + CERTIFICATE_HOLD = "CERTIFICATE_HOLD", + // REMOVE_FROM_CRL = "REMOVE_FROM_CRL", + PRIVILEGE_WITHDRAWN = "PRIVILEGE_WITHDRAWN", + A_A_COMPROMISE = "A_A_COMPROMISE" +} + export type TGetCertDTO = { serialNumber: string; } & Omit; @@ -15,6 +35,7 @@ export type TDeleteCertDTO = { export type TRevokeCertDTO = { serialNumber: string; + revocationReason: CrlReason; } & Omit; export type TGetCertCertDTO = { diff --git a/docs/documentation/platform/pki/certificates.mdx b/docs/documentation/platform/pki/certificates.mdx index 560799936..12fa7c481 100644 --- a/docs/documentation/platform/pki/certificates.mdx +++ b/docs/documentation/platform/pki/certificates.mdx @@ -66,3 +66,14 @@ In the following steps, we explore how to issue a X.509 certificate under a CA u + +## FAQ + + + + To renew a certificate, you have to issue a new certificate from the same CA + with the same common name as the old certificate. The original certificate + will continue to be valid through its original TTL unless explicitly + revoked. + + diff --git a/frontend/src/hooks/api/ca/index.tsx b/frontend/src/hooks/api/ca/index.tsx index b39680b53..60b53478d 100644 --- a/frontend/src/hooks/api/ca/index.tsx +++ b/frontend/src/hooks/api/ca/index.tsx @@ -1,9 +1,10 @@ -export { CaStatus,CaType } from "./enums"; +export { CaStatus, CaType } from "./enums"; export { useCreateCa, useCreateCertificate, useDeleteCa, useImportCaCertificate, useSignIntermediate, - useUpdateCa} from "./mutations"; -export { useGetCaById, useGetCaCert, useGetCaCsr } from "./queries"; + useUpdateCa +} from "./mutations"; +export { useGetCaById, useGetCaCert, useGetCaCrl,useGetCaCsr } from "./queries"; diff --git a/frontend/src/hooks/api/ca/queries.tsx b/frontend/src/hooks/api/ca/queries.tsx index a0e64fd4e..e78274391 100644 --- a/frontend/src/hooks/api/ca/queries.tsx +++ b/frontend/src/hooks/api/ca/queries.tsx @@ -7,7 +7,8 @@ import { TCertificateAuthority } from "./types"; export const caKeys = { getCaById: (caId: string) => [{ caId }, "ca"], getCaCert: (caId: string) => [{ caId }, "ca-cert"], - getCaCsr: (caId: string) => [{ caId }, "ca-csr"] + getCaCsr: (caId: string) => [{ caId }, "ca-csr"], + getCaCrl: (caId: string) => [{ caId }, "ca-crl"] }; export const useGetCaById = (caId: string) => { @@ -52,3 +53,18 @@ export const useGetCaCsr = (caId: string) => { enabled: Boolean(caId) }); }; + +export const useGetCaCrl = (caId: string) => { + return useQuery({ + queryKey: caKeys.getCaCrl(caId), + queryFn: async () => { + const { + data: { crl } + } = await apiRequest.get<{ + crl: string; + }>(`/api/v1/pki/ca/${caId}/crl`); + return crl; + }, + enabled: Boolean(caId) + }); +}; diff --git a/frontend/src/hooks/api/certificates/constants.tsx b/frontend/src/hooks/api/certificates/constants.tsx index 32cf08886..e5a9b7a43 100644 --- a/frontend/src/hooks/api/certificates/constants.tsx +++ b/frontend/src/hooks/api/certificates/constants.tsx @@ -1,4 +1,4 @@ -import { CertKeyAlgorithm,CertStatus } from "./enums"; +import { CertKeyAlgorithm, CertStatus,CrlReason } from "./enums"; export const certStatusToNameMap: { [K in CertStatus]: string } = { [CertStatus.ACTIVE]: "Active", @@ -13,8 +13,48 @@ export const certKeyAlgorithmToNameMap: { [K in CertKeyAlgorithm]: string } = { }; export const certKeyAlgorithms = [ - { label: "RSA 2048", value: CertKeyAlgorithm.RSA_2048 }, - { label: "RSA 4096", value: CertKeyAlgorithm.RSA_4096 }, - { label: "ECDSA P256", value: CertKeyAlgorithm.ECDSA_P256 }, - { label: "ECDSA P384", value: CertKeyAlgorithm.ECDSA_P384 } + { label: certKeyAlgorithmToNameMap[CertKeyAlgorithm.RSA_2048], value: CertKeyAlgorithm.RSA_2048 }, + { label: certKeyAlgorithmToNameMap[CertKeyAlgorithm.RSA_4096], value: CertKeyAlgorithm.RSA_4096 }, + { + label: certKeyAlgorithmToNameMap[CertKeyAlgorithm.ECDSA_P256], + value: CertKeyAlgorithm.ECDSA_P256 + }, + { + label: certKeyAlgorithmToNameMap[CertKeyAlgorithm.ECDSA_P384], + value: CertKeyAlgorithm.ECDSA_P384 + } +]; + +export const crlReasonToNameMap: { [K in CrlReason]: string } = { + [CrlReason.UNSPECIFIED]: "Unspecified", + [CrlReason.KEY_COMPROMISE]: "Key Compromise", + [CrlReason.CA_COMPROMISE]: "CA Compromise", + [CrlReason.AFFILIATION_CHANGED]: "Affiliation Changed", + [CrlReason.SUPERSEDED]: "Superseded", + [CrlReason.CESSATION_OF_OPERATION]: "Cessation of Operation", + [CrlReason.CERTIFICATE_HOLD]: "Certificate Hold", + // [CrlReason.REMOVE_FROM_CRL]: "Remove from CRL", + [CrlReason.PRIVILEGE_WITHDRAWN]: "Privilege Withdrawn", + [CrlReason.A_A_COMPROMISE]: "A/A Compromise" +}; + +export const crlReasons = [ + { label: crlReasonToNameMap[CrlReason.UNSPECIFIED], value: CrlReason.UNSPECIFIED }, + { label: crlReasonToNameMap[CrlReason.KEY_COMPROMISE], value: CrlReason.KEY_COMPROMISE }, + { label: crlReasonToNameMap[CrlReason.CA_COMPROMISE], value: CrlReason.CA_COMPROMISE }, + { + label: crlReasonToNameMap[CrlReason.AFFILIATION_CHANGED], + value: CrlReason.AFFILIATION_CHANGED + }, + { label: crlReasonToNameMap[CrlReason.SUPERSEDED], value: CrlReason.SUPERSEDED }, + { + label: crlReasonToNameMap[CrlReason.CESSATION_OF_OPERATION], + value: CrlReason.CESSATION_OF_OPERATION + }, + { label: crlReasonToNameMap[CrlReason.CERTIFICATE_HOLD], value: CrlReason.CERTIFICATE_HOLD }, + { + label: crlReasonToNameMap[CrlReason.PRIVILEGE_WITHDRAWN], + value: CrlReason.PRIVILEGE_WITHDRAWN + }, + { label: crlReasonToNameMap[CrlReason.A_A_COMPROMISE], value: CrlReason.A_A_COMPROMISE } ]; diff --git a/frontend/src/hooks/api/certificates/enums.tsx b/frontend/src/hooks/api/certificates/enums.tsx index f8d8d3851..d0da0273a 100644 --- a/frontend/src/hooks/api/certificates/enums.tsx +++ b/frontend/src/hooks/api/certificates/enums.tsx @@ -9,3 +9,16 @@ export enum CertKeyAlgorithm { ECDSA_P256 = "EC_prime256v1", ECDSA_P384 = "EC_secp384r1" } + +export enum CrlReason { + UNSPECIFIED = "UNSPECIFIED", + KEY_COMPROMISE = "KEY_COMPROMISE", + CA_COMPROMISE = "CA_COMPROMISE", + AFFILIATION_CHANGED = "AFFILIATION_CHANGED", + SUPERSEDED = "SUPERSEDED", + CESSATION_OF_OPERATION = "CESSATION_OF_OPERATION", + CERTIFICATE_HOLD = "CERTIFICATE_HOLD", + // REMOVE_FROM_CRL = "REMOVE_FROM_CRL", + PRIVILEGE_WITHDRAWN = "PRIVILEGE_WITHDRAWN", + A_A_COMPROMISE = "A_A_COMPROMISE" +} diff --git a/frontend/src/hooks/api/certificates/mutations.tsx b/frontend/src/hooks/api/certificates/mutations.tsx index ae2a6a745..cb366e4e1 100644 --- a/frontend/src/hooks/api/certificates/mutations.tsx +++ b/frontend/src/hooks/api/certificates/mutations.tsx @@ -25,11 +25,14 @@ export const useDeleteCert = () => { export const useRevokeCert = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async ({ serialNumber }) => { + mutationFn: async ({ serialNumber, revocationReason }) => { const { data: { certificate } } = await apiRequest.post<{ certificate: TCertificate }>( - `/api/v1/pki/certificates/${serialNumber}/revoke` + `/api/v1/pki/certificates/${serialNumber}/revoke`, + { + revocationReason + } ); return certificate; }, diff --git a/frontend/src/hooks/api/certificates/types.ts b/frontend/src/hooks/api/certificates/types.ts index 0ea0d6323..abbb373c4 100644 --- a/frontend/src/hooks/api/certificates/types.ts +++ b/frontend/src/hooks/api/certificates/types.ts @@ -18,4 +18,5 @@ export type TDeleteCertDTO = { export type TRevokeCertDTO = { projectSlug: string; serialNumber: string; + revocationReason: string; }; diff --git a/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaCrlModal.tsx b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaCrlModal.tsx new file mode 100644 index 000000000..459f17a25 --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaCrlModal.tsx @@ -0,0 +1,92 @@ +import { useEffect } from "react"; +import { faCheck, faCopy, faDownload } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { IconButton,Modal, ModalContent } from "@app/components/v2"; +import { useToggle } from "@app/hooks"; +import { useGetCaCrl } from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + popUp: UsePopUpState<["caCrl"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["caCrl"]>, state?: boolean) => void; +}; + +export const CaCrlModal = ({ popUp, handlePopUpToggle }: Props) => { + const [isCrlCopied, setIsCrlCopied] = useToggle(false); + const { data: crl } = useGetCaCrl((popUp?.caCrl?.data as { caId: string })?.caId || ""); + + useEffect(() => { + let timer: NodeJS.Timeout; + if (isCrlCopied) { + timer = setTimeout(() => setIsCrlCopied.off(), 2000); + } + + return () => clearTimeout(timer); + }, [isCrlCopied]); + + const downloadTxtFile = (filename: string, content: string) => { + const blob = new Blob([content], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + + return ( + { + handlePopUpToggle("caCrl", isOpen); + }} + > + +
+ {crl && ( + <> +
+

CA CRL

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

{crl}

+
+ + )} +
+
+
+ ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaSection.tsx b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaSection.tsx index 63144db04..ef18f9d57 100644 --- a/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaSection.tsx +++ b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaSection.tsx @@ -5,10 +5,11 @@ import { createNotification } from "@app/components/notifications"; import { ProjectPermissionCan } from "@app/components/permissions"; import { Button, DeleteActionModal } from "@app/components/v2"; import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; -import { CaStatus,useDeleteCa, useUpdateCa } from "@app/hooks/api"; +import { CaStatus, useDeleteCa, useUpdateCa } from "@app/hooks/api"; import { usePopUp } from "@app/hooks/usePopUp"; import { CaCertModal } from "./CaCertModal"; +import { CaCrlModal } from "./CaCrlModal"; import { CaInstallCertModal } from "./CaInstallCertModal"; import { CaModal } from "./CaModal"; import { CaTable } from "./CaTable"; @@ -23,7 +24,8 @@ export const CaSection = () => { "caCert", "installCaCert", "deleteCa", - "caStatus" // enable / disable + "caStatus", // enable / disable + "caCrl" // enable / disable ] as const); const onRemoveCaSubmit = async (caId: string) => { @@ -92,6 +94,7 @@ export const CaSection = () => { + , + popUpName: keyof UsePopUpState< + ["installCaCert", "caCert", "ca", "deleteCa", "caStatus", "caCrl"] + >, data?: { caId?: string; dn?: string; @@ -129,6 +133,27 @@ export const CaTable = ({ handlePopUpOpen }: Props) => { )} )} + + {(isAllowed) => ( + + handlePopUpOpen("caCrl", { + caId: ca.id + }) + } + disabled={!isAllowed} + icon={} + > + View CRL + + )} + { - downloadTxtFile("certificate.txt", certificate); + downloadTxtFile("certificate.pem", certificate); }} > @@ -135,7 +135,7 @@ export const CertificateContent = ({ colorSchema="secondary" className="group relative ml-2" onClick={() => { - downloadTxtFile("certificate_chain.txt", certificateChain); + downloadTxtFile("chain.pem", certificateChain); }} > diff --git a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateRevocationModal.tsx b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateRevocationModal.tsx new file mode 100644 index 000000000..cd5e2118c --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateRevocationModal.tsx @@ -0,0 +1,131 @@ +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FormControl, Modal, ModalContent, Select, SelectItem } from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { useRevokeCert } from "@app/hooks/api"; +import { crlReasons } from "@app/hooks/api/certificates/constants"; +import { CrlReason } from "@app/hooks/api/certificates/enums"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const schema = z.object({ + revocationReason: z.enum([ + CrlReason.UNSPECIFIED, + CrlReason.KEY_COMPROMISE, + CrlReason.CA_COMPROMISE, + CrlReason.AFFILIATION_CHANGED, + CrlReason.SUPERSEDED, + CrlReason.CESSATION_OF_OPERATION, + CrlReason.CERTIFICATE_HOLD, + CrlReason.PRIVILEGE_WITHDRAWN, + CrlReason.A_A_COMPROMISE + ]) +}); + +export type FormData = z.infer; + +type Props = { + popUp: UsePopUpState<["revokeCertificate"]>; + handlePopUpToggle: ( + popUpName: keyof UsePopUpState<["revokeCertificate"]>, + state?: boolean + ) => void; +}; + +export const CertificateRevocationModal = ({ popUp, handlePopUpToggle }: Props) => { + const { currentWorkspace } = useWorkspace(); + const { mutateAsync: revokeCertificate } = useRevokeCert(); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema) + }); + + const onFormSubmit = async ({ revocationReason }: FormData) => { + try { + if (!currentWorkspace?.slug) return; + + const {serialNumber} = popUp.revokeCertificate.data as { serialNumber: string }; + + await revokeCertificate({ + projectSlug: currentWorkspace.slug, + serialNumber, + revocationReason + }); + + reset(); + handlePopUpToggle("revokeCertificate", false); + + createNotification({ + text: "Successfully revoked certificate", + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to revoke certificate", + type: "error" + }); + } + }; + + return ( + { + handlePopUpToggle("revokeCertificate", isOpen); + reset(); + }} + > + +
+ ( + + + + )} + /> +
+ + +
+ +
+
+ ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificatesSection.tsx b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificatesSection.tsx index cf6a338c2..58bb8ef4c 100644 --- a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificatesSection.tsx +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificatesSection.tsx @@ -5,17 +5,17 @@ import { createNotification } from "@app/components/notifications"; import { ProjectPermissionCan } from "@app/components/permissions"; import { Button, DeleteActionModal } from "@app/components/v2"; import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; -import { useDeleteCert, useRevokeCert } from "@app/hooks/api"; +import { useDeleteCert } from "@app/hooks/api"; import { usePopUp } from "@app/hooks/usePopUp"; import { CertificateCertModal } from "./CertificateCertModal"; import { CertificateModal } from "./CertificateModal"; +import { CertificateRevocationModal } from "./CertificateRevocationModal"; import { CertificatesTable } from "./CertificatesTable"; export const CertificatesSection = () => { const { currentWorkspace } = useWorkspace(); const { mutateAsync: deleteCert } = useDeleteCert(); - const { mutateAsync: revokeCert } = useRevokeCert(); const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ "certificate", @@ -45,27 +45,6 @@ export const CertificatesSection = () => { } }; - const onRevokeCertificateSubmit = async (serialNumber: string) => { - try { - if (!currentWorkspace?.slug) return; - - await revokeCert({ serialNumber, projectSlug: currentWorkspace.slug }); - - await createNotification({ - text: "Successfully revoked certificate", - type: "success" - }); - - handlePopUpClose("revokeCertificate"); - } catch (err) { - console.error(err); - createNotification({ - text: "Failed to revoke certificate", - type: "error" - }); - } - }; - return (
@@ -90,6 +69,7 @@ export const CertificatesSection = () => { + { ) } /> - handlePopUpToggle("revokeCertificate", isOpen)} - deleteKey="confirm" - onDeleteApproved={() => - onRevokeCertificateSubmit( - (popUp?.revokeCertificate?.data as { serialNumber: string }).serialNumber - ) - } - />
); };