diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 2b07ea9ce..3e1f7402f 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -38,6 +38,9 @@ import { TCertificateAuthorityCerts, TCertificateAuthorityCertsInsert, TCertificateAuthorityCertsUpdate, + TCertificateAuthorityCrl, + TCertificateAuthorityCrlInsert, + TCertificateAuthorityCrlUpdate, TCertificateAuthoritySk, TCertificateAuthoritySkInsert, TCertificateAuthoritySkUpdate, @@ -279,6 +282,11 @@ declare module "knex/types/tables" { TCertificateAuthoritySkInsert, TCertificateAuthoritySkUpdate >; + [TableName.CertificateAuthorityCrl]: Knex.CompositeTableType< + TCertificateAuthorityCrl, + TCertificateAuthorityCrlInsert, + TCertificateAuthorityCrlUpdate + >; [TableName.Certificate]: Knex.CompositeTableType; [TableName.CertificateCert]: Knex.CompositeTableType< TCertificateCerts, diff --git a/backend/src/db/migrations/20240530163136_certificate-mgmt.ts b/backend/src/db/migrations/20240530163136_certificate-mgmt.ts index ec1de2390..f97549066 100644 --- a/backend/src/db/migrations/20240530163136_certificate-mgmt.ts +++ b/backend/src/db/migrations/20240530163136_certificate-mgmt.ts @@ -54,6 +54,19 @@ export async function up(knex: Knex): Promise { }); } + if (!(await knex.schema.hasTable(TableName.CertificateAuthorityCrl))) { + await knex.schema.createTable(TableName.CertificateAuthorityCrl, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.uuid("caId").notNullable().unique(); + t.foreign("caId").references("id").inTable(TableName.CertificateAuthority).onDelete("CASCADE"); + t.text("crl").notNullable(); // TODO: encrypt + t.integer("ttl").notNullable(); // in minutes + // TODO: consider type (crl or delta) + // TODO: rebuild interval + }); + } + if (!(await knex.schema.hasTable(TableName.Certificate))) { await knex.schema.createTable(TableName.Certificate, (t) => { t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); @@ -100,6 +113,9 @@ export async function down(knex: Knex): Promise { await knex.schema.dropTableIfExists(TableName.CertificateAuthoritySk); await dropOnUpdateTrigger(knex, TableName.CertificateAuthoritySk); + await knex.schema.dropTableIfExists(TableName.CertificateAuthorityCrl); + await dropOnUpdateTrigger(knex, TableName.CertificateAuthorityCrl); + await knex.schema.dropTableIfExists(TableName.CertificateAuthorityCert); await dropOnUpdateTrigger(knex, TableName.CertificateAuthorityCert); diff --git a/backend/src/db/schemas/certificate-authority-crl.ts b/backend/src/db/schemas/certificate-authority-crl.ts new file mode 100644 index 000000000..2f30d0f91 --- /dev/null +++ b/backend/src/db/schemas/certificate-authority-crl.ts @@ -0,0 +1,23 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const CertificateAuthorityCrlSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + caId: z.string().uuid(), + crl: z.string(), + ttl: z.number() +}); + +export type TCertificateAuthorityCrl = z.infer; +export type TCertificateAuthorityCrlInsert = Omit, TImmutableDBKeys>; +export type TCertificateAuthorityCrlUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 3415f4049..0b4f1ff60 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -10,6 +10,7 @@ export * from "./auth-tokens"; export * from "./backup-private-key"; export * from "./certificate-authorities"; export * from "./certificate-authority-certs"; +export * from "./certificate-authority-crl"; export * from "./certificate-authority-sk"; export * from "./certificate-certs"; export * from "./certificate-secrets"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 62ed0e4d5..d085c9415 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -5,6 +5,7 @@ export enum TableName { CertificateAuthority = "certificate_authorities", CertificateAuthorityCert = "certificate_authority_certs", CertificateAuthoritySk = "certificate_authority_sk", + CertificateAuthorityCrl = "certificate_authority_crl", Certificate = "certificates", CertificateCert = "certificate_certs", CertificateSecret = "certificate_secrets", diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts index 9d85b6015..602651e79 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -21,7 +21,8 @@ export enum QueueName { SecretFullRepoScan = "secret-full-repo-scan", SecretPushEventScan = "secret-push-event-scan", UpgradeProjectToGhost = "upgrade-project-to-ghost", - DynamicSecretRevocation = "dynamic-secret-revocation" + DynamicSecretRevocation = "dynamic-secret-revocation", + CaCrlRotation = "ca-crl-rotation" } export enum QueueJobs { @@ -37,7 +38,8 @@ export enum QueueJobs { SecretScan = "secret-scan", UpgradeProjectToGhost = "upgrade-project-to-ghost-job", DynamicSecretRevocation = "dynamic-secret-revocation", - DynamicSecretPruning = "dynamic-secret-pruning" + DynamicSecretPruning = "dynamic-secret-pruning", + CaCrlRotation = "ca-crl-rotation-job" } export type TQueueJobTypes = { @@ -50,7 +52,6 @@ export type TQueueJobTypes = { }; name: QueueJobs.SecretReminder; }; - [QueueName.SecretRotation]: { payload: { rotationId: string }; name: QueueJobs.SecretRotation; @@ -116,6 +117,12 @@ export type TQueueJobTypes = { dynamicSecretCfgId: string; }; }; + [QueueName.CaCrlRotation]: { + name: QueueJobs.CaCrlRotation; + payload: { + caId: string; + }; + }; }; export type TQueueServiceFactory = ReturnType; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 446ff69fc..2974b6b96 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -74,7 +74,9 @@ import { certificateCertDALFactory } from "@app/services/certificate/certificate import { certificateDALFactory } from "@app/services/certificate/certificate-dal"; import { certificateServiceFactory } from "@app/services/certificate/certificate-service"; import { certificateAuthorityCertDALFactory } from "@app/services/certificate-authority/certificate-authority-cert-dal"; +import { certificateAuthorityCrlDALFactory } from "@app/services/certificate-authority/certificate-authority-crl-dal"; import { certificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal"; +import { certificateAuthorityQueueFactory } from "@app/services/certificate-authority/certificate-authority-queue"; import { certificateAuthorityServiceFactory } from "@app/services/certificate-authority/certificate-authority-service"; import { certificateAuthoritySkDALFactory } from "@app/services/certificate-authority/certificate-authority-sk-dal"; import { groupProjectDALFactory } from "@app/services/group-project/group-project-dal"; @@ -503,6 +505,7 @@ export const registerRoutes = async ( const certificateAuthorityDAL = certificateAuthorityDALFactory(db); const certificateAuthorityCertDAL = certificateAuthorityCertDALFactory(db); const certificateAuthoritySkDAL = certificateAuthoritySkDALFactory(db); + const certificateAuthorityCrlDAL = certificateAuthorityCrlDALFactory(db); const certificateDAL = certificateDALFactory(db); const certificateCertDAL = certificateCertDALFactory(db); @@ -514,10 +517,20 @@ export const registerRoutes = async ( permissionService }); + const certificateAuthorityQueue = certificateAuthorityQueueFactory({ + certificateAuthorityCrlDAL, + certificateAuthorityDAL, + certificateAuthoritySkDAL, + certificateDAL, + queueService + }); + const certificateAuthorityService = certificateAuthorityServiceFactory({ certificateAuthorityDAL, certificateAuthorityCertDAL, certificateAuthoritySkDAL, + certificateAuthorityCrlDAL, + certificateAuthorityQueue, certificateDAL, certificateCertDAL, projectDAL, diff --git a/backend/src/server/routes/v1/certificate-authority-router.ts b/backend/src/server/routes/v1/certificate-authority-router.ts index 729755b51..433c02cb8 100644 --- a/backend/src/server/routes/v1/certificate-authority-router.ts +++ b/backend/src/server/routes/v1/certificate-authority-router.ts @@ -419,4 +419,36 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { }; } }); + + server.route({ + method: "GET", + url: "/:caId/crl/rotate", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Rotate CRL of the CA", + params: z.object({ + caId: z.string().trim() + }), + response: { + 200: z.object({ + message: z.string() + }) + } + }, + handler: async (req) => { + await server.services.certificateAuthority.rotateCaCrl({ + caId: req.params.caId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + return { + message: "Successfully rotated CA CRL" + }; + } + }); }; diff --git a/backend/src/services/certificate-authority/certificate-authority-crl-dal.ts b/backend/src/services/certificate-authority/certificate-authority-crl-dal.ts new file mode 100644 index 000000000..d367e1616 --- /dev/null +++ b/backend/src/services/certificate-authority/certificate-authority-crl-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TCertificateAuthorityCrlDALFactory = ReturnType; + +export const certificateAuthorityCrlDALFactory = (db: TDbClient) => { + const caCrlOrm = ormify(db, TableName.CertificateAuthorityCrl); + return caCrlOrm; +}; diff --git a/backend/src/services/certificate-authority/certificate-authority-queue.ts b/backend/src/services/certificate-authority/certificate-authority-queue.ts new file mode 100644 index 000000000..231314656 --- /dev/null +++ b/backend/src/services/certificate-authority/certificate-authority-queue.ts @@ -0,0 +1,124 @@ +import * as x509 from "@peculiar/x509"; +import crypto from "crypto"; + +import { getConfig } from "@app/lib/config/env"; +import { daysToMillisecond, secondsToMillis } from "@app/lib/dates"; +import { BadRequestError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; +import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; +import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; +import { CertKeyAlgorithm, CertStatus } from "@app/services/certificate/certificate-types"; + +import { TCertificateAuthorityCrlDALFactory } from "./certificate-authority-crl-dal"; +import { TCertificateAuthorityDALFactory } from "./certificate-authority-dal"; +import { keyAlgorithmToAlgCfg } from "./certificate-authority-fns"; +import { TCertificateAuthoritySkDALFactory } from "./certificate-authority-sk-dal"; +import { TRotateCaCrlTriggerDTO } from "./certificate-authority-types"; + +type TCertificateAuthorityQueueFactoryDep = { + // TODO: Pick + certificateAuthorityDAL: TCertificateAuthorityDALFactory; + certificateAuthorityCrlDAL: TCertificateAuthorityCrlDALFactory; + certificateAuthoritySkDAL: TCertificateAuthoritySkDALFactory; + certificateDAL: TCertificateDALFactory; + queueService: TQueueServiceFactory; +}; +export type TCertificateAuthorityQueueFactory = ReturnType; + +export const certificateAuthorityQueueFactory = ({ + certificateAuthorityCrlDAL, + certificateAuthorityDAL, + certificateAuthoritySkDAL, + certificateDAL, + queueService +}: TCertificateAuthorityQueueFactoryDep) => { + // TODO 1: auto-periodic rotation + // TODO 2: manual rotation + + const setCaCrlRotationInterval = async ({ caId, rotationIntervalDays }: TRotateCaCrlTriggerDTO) => { + const appCfg = getConfig(); + + // query for config + // const caCrl = await certificateAuthorityCrlDAL.findOne({ + // caId + // }); + + await queueService.queue( + // TODO: clarify queue + job naming + QueueName.CaCrlRotation, + QueueJobs.CaCrlRotation, + { + caId + }, + { + jobId: `ca-crl-rotation-${caId}`, + repeat: { + // on prod it this will be in days, in development this will be second + every: + appCfg.NODE_ENV === "development" + ? secondsToMillis(rotationIntervalDays) + : daysToMillisecond(rotationIntervalDays), + immediately: true + } + } + ); + }; + + queueService.start(QueueName.CaCrlRotation, async (job) => { + const { caId } = job.data; + logger.info(`secretReminderQueue.process: [secretDocument=${caId}]`); + + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) throw new BadRequestError({ message: "CA not found" }); + + const 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"), // TODO: depends on configured rebuild interval + entries: revokedCerts.map((revokedCert) => { + return { + serialNumber: revokedCert.serialNumber, + revocationDate: new Date(revokedCert.revokedAt as Date), + reason: revokedCert.revocationReason as number, + invalidity: new Date("2022/01/01"), + issuer: ca.dn + }; + }), + signingAlgorithm: alg, + signingKey: sk + }); + + const base64crl = crl.toString("base64"); + const crlPem = `-----BEGIN X509 CRL-----\n${base64crl.match(/.{1,64}/g)?.join("\n")}\n-----END X509 CRL-----`; + + await certificateAuthorityCrlDAL.update( + { + caId: ca.id + }, + { + crl: crlPem // TODO: encrypt + } + ); + }); + + queueService.listen(QueueName.CaCrlRotation, "failed", (job, err) => { + logger.error(err, "Failed to rotate CA CRL %s", job?.id); + }); + + return { + setCaCrlRotationInterval + }; +}; diff --git a/backend/src/services/certificate-authority/certificate-authority-service.ts b/backend/src/services/certificate-authority/certificate-authority-service.ts index c10994559..ca72c2656 100644 --- a/backend/src/services/certificate-authority/certificate-authority-service.ts +++ b/backend/src/services/certificate-authority/certificate-authority-service.ts @@ -12,8 +12,10 @@ import { TProjectDALFactory } from "@app/services/project/project-dal"; import { CertKeyAlgorithm, CertStatus } from "../certificate/certificate-types"; import { TCertificateAuthorityCertDALFactory } from "./certificate-authority-cert-dal"; +import { TCertificateAuthorityCrlDALFactory } from "./certificate-authority-crl-dal"; import { TCertificateAuthorityDALFactory } from "./certificate-authority-dal"; import { createDistinguishedName, keyAlgorithmToAlgCfg } from "./certificate-authority-fns"; +import { TCertificateAuthorityQueueFactory } from "./certificate-authority-queue"; import { TCertificateAuthoritySkDALFactory } from "./certificate-authority-sk-dal"; import { CaStatus, @@ -26,6 +28,7 @@ import { TGetCrl, TImportCertToCaDTO, TIssueCertFromCaDTO, + TRotateCrlDTO, TSignIntermediateDTO, TUpdateCaDTO } from "./certificate-authority-types"; @@ -37,6 +40,8 @@ type TCertificateAuthorityServiceFactoryDep = { >; certificateAuthorityCertDAL: Pick; certificateAuthoritySkDAL: Pick; + certificateAuthorityCrlDAL: Pick; + certificateAuthorityQueue: TCertificateAuthorityQueueFactory; // TODO: Pick certificateDAL: Pick; certificateCertDAL: Pick; projectDAL: Pick; @@ -49,6 +54,7 @@ export const certificateAuthorityServiceFactory = ({ certificateAuthorityDAL, certificateAuthorityCertDAL, certificateAuthoritySkDAL, + certificateAuthorityCrlDAL, certificateDAL, certificateCertDAL, projectDAL, @@ -141,6 +147,8 @@ export const certificateAuthorityServiceFactory = ({ tx ); + // TODO: create CRL + if (type === CaType.ROOT) { // note: self-signed cert only applicable for root CA @@ -168,6 +176,15 @@ export const certificateAuthorityServiceFactory = ({ }, tx ); + + await certificateAuthorityCrlDAL.create( + { + caId: ca.id, + crl: "", // TODO: encrypt + ttl: 60 // in minutes + }, + tx + ); } await certificateAuthoritySkDAL.create( @@ -721,6 +738,70 @@ export const certificateAuthorityServiceFactory = ({ }; }; + const rotateCaCrl = async ({ caId, actorId, actorAuthMethod, actor, actorOrgId }: TRotateCrlDTO) => { + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) throw new BadRequestError({ message: "CA not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.CertificateAuthorities + ); + + const 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-----`; + + await certificateAuthorityCrlDAL.update( + { + caId: ca.id + }, + { + crl: crlPem // TODO: encrypt + } + ); + + return { + crl: crlPem + }; + }; + return { createCa, getCaById, @@ -731,6 +812,7 @@ export const certificateAuthorityServiceFactory = ({ signIntermediate, importCertToCa, issueCertFromCa, - getCaCrl + getCaCrl, + rotateCaCrl }; }; diff --git a/backend/src/services/certificate-authority/certificate-authority-types.ts b/backend/src/services/certificate-authority/certificate-authority-types.ts index 146ab5d58..8d4746d77 100644 --- a/backend/src/services/certificate-authority/certificate-authority-types.ts +++ b/backend/src/services/certificate-authority/certificate-authority-types.ts @@ -75,6 +75,10 @@ export type TGetCrl = { caId: string; } & Omit; +export type TRotateCrlDTO = { + caId: string; +} & Omit; + export type TDNParts = { commonName?: string; organization?: string; @@ -83,3 +87,8 @@ export type TDNParts = { province?: string; locality?: string; }; + +export type TRotateCaCrlTriggerDTO = { + caId: string; + rotationIntervalDays: number; +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaCrlModal.tsx b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaCrlModal.tsx index 459f17a25..77e67f32e 100644 --- a/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaCrlModal.tsx +++ b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaCrlModal.tsx @@ -2,7 +2,7 @@ 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 { IconButton, Modal, ModalContent } from "@app/components/v2"; import { useToggle } from "@app/hooks"; import { useGetCaCrl } from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; @@ -48,8 +48,22 @@ export const CaCrlModal = ({ popUp, handlePopUpToggle }: Props) => {
{crl && ( <> + {/*
+

Manual CRL Rotation

+ +
*/}
-

CA CRL

+

Certificate Revocation List