1
0
mirror of https://github.com/Infisical/infisical.git synced 2025-03-21 20:58:13 +00:00

Add preliminary dynamically generated CRLs on fetching CA CRL

This commit is contained in:
Tuan Dang
2024-05-30 15:45:34 -07:00
parent 45fdd4ebc2
commit 2937a46943
25 changed files with 560 additions and 84 deletions

@ -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",

@ -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",

@ -5,7 +5,6 @@ import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
export async function up(knex: Knex): Promise<void> {
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<void> {
}
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<void> {
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
});
}

@ -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<typeof CertificatesSchema>;

@ -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
};
}
});
};

@ -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
};
}
});

@ -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 = [];

@ -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<TCertificateAuthorityCertDALFactory, "create" | "findOne" | "transaction">;
certificateAuthoritySkDAL: Pick<TCertificateAuthoritySkDALFactory, "create" | "findOne">;
certificateDAL: Pick<TCertificateDALFactory, "transaction" | "create">;
certificateDAL: Pick<TCertificateDALFactory, "transaction" | "create" | "find">;
certificateCertDAL: Pick<TCertificateCertDALFactory, "create">;
projectDAL: Pick<TProjectDALFactory, "findProjectBySlug">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
@ -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
};
};

@ -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<TProjectPermission, "projectId">;
export type TGetCrl = {
caId: string;
} & Omit<TProjectPermission, "projectId">;
export type TDNParts = {
commonName?: string;
organization?: string;

@ -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;
}
};

@ -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<TCertificateDALFactory, "findOne" | "deleteById">;
certificateDAL: Pick<TCertificateDALFactory, "findOne" | "deleteById" | "update">;
certificateCertDAL: Pick<TCertificateCertDALFactory, "findOne">;
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findById">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
@ -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) => {

@ -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<TProjectPermission, "projectId">;
@ -15,6 +35,7 @@ export type TDeleteCertDTO = {
export type TRevokeCertDTO = {
serialNumber: string;
revocationReason: CrlReason;
} & Omit<TProjectPermission, "projectId">;
export type TGetCertCertDTO = {

@ -66,3 +66,14 @@ In the following steps, we explore how to issue a X.509 certificate under a CA u
</Note>
</Step>
</Steps>
## FAQ
<AccordionGroup>
<Accordion title="What is the workflow for renewing a certificate?">
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.
</Accordion>
</AccordionGroup>

@ -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";

@ -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)
});
};

@ -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 }
];

@ -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"
}

@ -25,11 +25,14 @@ export const useDeleteCert = () => {
export const useRevokeCert = () => {
const queryClient = useQueryClient();
return useMutation<TCertificate, {}, TRevokeCertDTO>({
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;
},

@ -18,4 +18,5 @@ export type TDeleteCertDTO = {
export type TRevokeCertDTO = {
projectSlug: string;
serialNumber: string;
revocationReason: string;
};

@ -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 (
<Modal
isOpen={popUp?.caCrl?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("caCrl", isOpen);
}}
>
<ModalContent title="CA Certificate Revocation List (CRL)">
<div>
{crl && (
<>
<div className="mb-4 flex items-center justify-between">
<h2>CA CRL</h2>
<div className="flex">
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={() => {
navigator.clipboard.writeText(crl);
setIsCrlCopied.on();
}}
>
<FontAwesomeIcon icon={isCrlCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
Copy
</span>
</IconButton>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative ml-2"
onClick={() => {
downloadTxtFile("crl.pem", crl);
}}
>
<FontAwesomeIcon icon={faDownload} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
Download
</span>
</IconButton>
</div>
</div>
<div className="mb-8 flex items-center justify-between rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 whitespace-pre-wrap break-all">{crl}</p>
</div>
</>
)}
</div>
</ModalContent>
</Modal>
);
};

@ -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 = () => {
<CaModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<CaInstallCertModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<CaCertModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<CaCrlModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<CaTable handlePopUpOpen={handlePopUpOpen} />
<DeleteActionModal
isOpen={popUp.deleteCa.isOpen}

@ -3,6 +3,7 @@ import {
faCertificate,
faEllipsis,
faEye,
faFile,
faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@ -24,15 +25,18 @@ import {
Th,
THead,
Tooltip,
Tr} from "@app/components/v2";
Tr
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { CaStatus, useListWorkspaceCas } from "@app/hooks/api";
import { caStatusToNameMap,caTypeToNameMap } from "@app/hooks/api/ca/constants";
import { caStatusToNameMap, caTypeToNameMap } from "@app/hooks/api/ca/constants";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["installCaCert", "caCert", "ca", "deleteCa", "caStatus"]>,
popUpName: keyof UsePopUpState<
["installCaCert", "caCert", "ca", "deleteCa", "caStatus", "caCrl"]
>,
data?: {
caId?: string;
dn?: string;
@ -129,6 +133,27 @@ export const CaTable = ({ handlePopUpOpen }: Props) => {
)}
</ProjectPermissionCan>
)}
<ProjectPermissionCan
I={ProjectPermissionActions.Read}
a={ProjectPermissionSub.CertificateAuthorities}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={async () =>
handlePopUpOpen("caCrl", {
caId: ca.id
})
}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faFile} />}
>
View CRL
</DropdownMenuItem>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Read}
a={ProjectPermissionSub.CertificateAuthorities}

@ -98,7 +98,7 @@ export const CertificateContent = ({
colorSchema="secondary"
className="group relative ml-2"
onClick={() => {
downloadTxtFile("certificate.txt", certificate);
downloadTxtFile("certificate.pem", certificate);
}}
>
<FontAwesomeIcon icon={faDownload} />
@ -135,7 +135,7 @@ export const CertificateContent = ({
colorSchema="secondary"
className="group relative ml-2"
onClick={() => {
downloadTxtFile("certificate_chain.txt", certificateChain);
downloadTxtFile("chain.pem", certificateChain);
}}
>
<FontAwesomeIcon icon={faDownload} />

@ -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<typeof schema>;
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<FormData>({
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 (
<Modal
isOpen={popUp?.revokeCertificate?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("revokeCertificate", isOpen);
reset();
}}
>
<ModalContent title="Revoke Certificate">
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="revocationReason"
defaultValue={CrlReason.UNSPECIFIED}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Revocation Reason"
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{crlReasons.map(({ label, value }) => (
<SelectItem value={String(value || "")} key={label}>
{label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Revoke
</Button>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};

@ -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 (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex justify-between">
@ -90,6 +69,7 @@ export const CertificatesSection = () => {
<CertificatesTable handlePopUpOpen={handlePopUpOpen} />
<CertificateModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<CertificateCertModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<CertificateRevocationModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal
isOpen={popUp.deleteCertificate.isOpen}
title={`Are you sure want to remove the certificate ${
@ -103,20 +83,6 @@ export const CertificatesSection = () => {
)
}
/>
<DeleteActionModal
isOpen={popUp.revokeCertificate.isOpen}
title={`Are you sure want to revoke the certificate ${
(popUp?.revokeCertificate?.data as { commonName: string })?.commonName || ""
} from the project?`}
subTitle="This action is irreversible and will add the certificate to the CRL"
onChange={(isOpen) => handlePopUpToggle("revokeCertificate", isOpen)}
deleteKey="confirm"
onDeleteApproved={() =>
onRevokeCertificateSubmit(
(popUp?.revokeCertificate?.data as { serialNumber: string }).serialNumber
)
}
/>
</div>
);
};