From b9bd518aa6d4d3f57df1b28eee4ef752b35f9ec0 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Fri, 16 Aug 2024 16:32:47 +0800 Subject: [PATCH] feat: initial enforcement of template policy --- backend/package-lock.json | 24 +++ backend/package.json | 2 + backend/src/@types/knex.d.ts | 8 +- backend/src/db/schemas/index.ts | 1 + backend/src/lib/api-docs/constants.ts | 1 + backend/src/server/routes/index.ts | 1 + .../server/routes/v1/certificate-router.ts | 184 +++++++++++++++++- .../routes/v1/certificate-template-router.ts | 19 +- .../src/server/routes/v2/project-router.ts | 5 +- .../certificate-authority-service.ts | 73 ++++++- .../certificate-authority-types.ts | 6 +- .../certificate-template-fns.ts | 28 +++ .../certificate-template-validators.ts | 14 ++ frontend/src/hooks/api/ca/mutations.tsx | 7 +- frontend/src/hooks/api/ca/types.ts | 5 +- .../hooks/api/certificateTemplates/types.ts | 1 + .../components/CertificateModal.tsx | 75 ++++++- .../components/CertificateTemplateModal.tsx | 10 +- 18 files changed, 427 insertions(+), 37 deletions(-) create mode 100644 backend/src/services/certificate-template/certificate-template-fns.ts create mode 100644 backend/src/services/certificate-template/certificate-template-validators.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index ffe9cfcb0..93bea2cd4 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -75,6 +75,7 @@ "pino": "^8.16.2", "posthog-node": "^3.6.2", "probot": "^13.0.0", + "safe-regex": "^2.1.1", "smee-client": "^2.0.0", "tedious": "^18.2.1", "tweetnacl": "^1.0.3", @@ -106,6 +107,7 @@ "@types/picomatch": "^2.3.3", "@types/prompt-sync": "^4.2.3", "@types/resolve": "^1.20.6", + "@types/safe-regex": "^1.1.6", "@types/uuid": "^9.0.7", "@typescript-eslint/eslint-plugin": "^6.20.0", "@typescript-eslint/parser": "^6.20.0", @@ -9774,6 +9776,12 @@ "integrity": "sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ==", "dev": true }, + "node_modules/@types/safe-regex": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@types/safe-regex/-/safe-regex-1.1.6.tgz", + "integrity": "sha512-CQ/uPB9fLOPKwDsrTeVbNIkwfUthTWOx0l6uIGwVFjZxv7e68pCW5gtTYFzdJi3EBJp8h8zYhJbTasAbX7gEMQ==", + "dev": true + }, "node_modules/@types/semver": { "version": "7.5.6", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz", @@ -17778,6 +17786,14 @@ "@babel/runtime": "^7.8.4" } }, + "node_modules/regexp-tree": { + "version": "0.1.27", + "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", + "integrity": "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==", + "bin": { + "regexp-tree": "bin/regexp-tree" + } + }, "node_modules/regexp.prototype.flags": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.1.tgz", @@ -18094,6 +18110,14 @@ } ] }, + "node_modules/safe-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-2.1.1.tgz", + "integrity": "sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==", + "dependencies": { + "regexp-tree": "~0.1.1" + } + }, "node_modules/safe-regex-test": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.0.tgz", diff --git a/backend/package.json b/backend/package.json index 1328db4dd..cc190556f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -78,6 +78,7 @@ "@types/picomatch": "^2.3.3", "@types/prompt-sync": "^4.2.3", "@types/resolve": "^1.20.6", + "@types/safe-regex": "^1.1.6", "@types/uuid": "^9.0.7", "@typescript-eslint/eslint-plugin": "^6.20.0", "@typescript-eslint/parser": "^6.20.0", @@ -171,6 +172,7 @@ "pino": "^8.16.2", "posthog-node": "^3.6.2", "probot": "^13.0.0", + "safe-regex": "^2.1.1", "smee-client": "^2.0.0", "tedious": "^18.2.1", "tweetnacl": "^1.0.3", diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 008fee382..6a2cdfcb9 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -53,6 +53,9 @@ import { TCertificateSecretsUpdate, TCertificatesInsert, TCertificatesUpdate, + TCertificateTemplates, + TCertificateTemplatesInsert, + TCertificateTemplatesUpdate, TDynamicSecretLeases, TDynamicSecretLeasesInsert, TDynamicSecretLeasesUpdate, @@ -318,11 +321,6 @@ import { TWebhooksInsert, TWebhooksUpdate } from "@app/db/schemas"; -import { - TCertificateTemplates, - TCertificateTemplatesInsert, - TCertificateTemplatesUpdate -} from "@app/db/schemas/certificate-templates"; import { TSecretV2TagJunction, TSecretV2TagJunctionInsert, diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 03146f204..bfa3a0a0e 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -14,6 +14,7 @@ export * from "./certificate-authority-crl"; export * from "./certificate-authority-secret"; export * from "./certificate-bodies"; export * from "./certificate-secrets"; +export * from "./certificate-templates"; export * from "./certificates"; export * from "./dynamic-secret-leases"; export * from "./dynamic-secrets"; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 336776dbf..31079f318 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -1088,6 +1088,7 @@ export const CERTIFICATE_AUTHORITIES = { }, ISSUE_CERT: { caId: "The ID of the CA to issue the certificate from", + certificateTemplateId: "The ID of the certificate template to issue the certificate from", friendlyName: "A friendly name for the certificate", commonName: "The common name (CN) for the certificate", altNames: diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 46e241075..76eedecdf 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -622,6 +622,7 @@ export const registerRoutes = async ( certificateAuthorityCertDAL, certificateAuthoritySecretDAL, certificateAuthorityCrlDAL, + certificateTemplateDAL, certificateAuthorityQueue, certificateDAL, certificateBodyDAL, diff --git a/backend/src/server/routes/v1/certificate-router.ts b/backend/src/server/routes/v1/certificate-router.ts index 938fbf7fe..989e79448 100644 --- a/backend/src/server/routes/v1/certificate-router.ts +++ b/backend/src/server/routes/v1/certificate-router.ts @@ -1,12 +1,17 @@ +import ms from "ms"; import { z } from "zod"; import { CertificatesSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; -import { CERTIFICATES } from "@app/lib/api-docs"; +import { CERTIFICATE_AUTHORITIES, CERTIFICATES } from "@app/lib/api-docs"; 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"; +import { + validateAltNamesField, + validateCaDateField +} from "@app/services/certificate-authority/certificate-authority-validators"; export const registerCertRouter = async (server: FastifyZodProvider) => { server.route({ @@ -55,6 +60,183 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "POST", + url: "/issue-certificate", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Issue certificate", + body: z + .object({ + caId: z.string().trim().optional().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.caId), + certificateTemplateId: z + .string() + .trim() + .optional() + .describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.certificateTemplateId), + friendlyName: z.string().trim().optional().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.friendlyName), + commonName: z.string().trim().min(1).describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.commonName), + altNames: validateAltNamesField.describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.altNames), + ttl: z + .string() + .refine((val) => ms(val) > 0, "TTL must be a positive number") + .describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.ttl), + notBefore: validateCaDateField.optional().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.notBefore), + notAfter: validateCaDateField.optional().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.notAfter) + }) + .refine( + (data) => { + const { ttl, notAfter } = data; + return (ttl !== undefined && notAfter === undefined) || (ttl === undefined && notAfter !== undefined); + }, + { + message: "Either ttl or notAfter must be present, but not both", + path: ["ttl", "notAfter"] + } + ) + .refine( + (data) => + (data.caId !== undefined && data.certificateTemplateId === undefined) || + (data.caId === undefined && data.certificateTemplateId !== undefined), + { + message: "Either CA ID or Certificate Template ID must be present, but not both", + path: ["caId", "certificateTemplateId"] + } + ), + response: { + 200: z.object({ + certificate: z.string().trim().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.certificate), + issuingCaCertificate: z.string().trim().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.issuingCaCertificate), + certificateChain: z.string().trim().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.certificateChain), + privateKey: z.string().trim().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.privateKey), + serialNumber: z.string().trim().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.serialNumber) + }) + } + }, + handler: async (req) => { + const { certificate, certificateChain, issuingCaCertificate, privateKey, serialNumber, ca } = + await server.services.certificateAuthority.issueCertFromCa({ + 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.ISSUE_CERT, + metadata: { + caId: ca.id, + dn: ca.dn, + serialNumber + } + } + }); + + return { + certificate, + certificateChain, + issuingCaCertificate, + privateKey, + serialNumber + }; + } + }); + + server.route({ + method: "POST", + url: "/sign-certificate", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Sign certificate", + body: z + .object({ + caId: z.string().trim().optional().describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.caId), + certificateTemplateId: z + .string() + .trim() + .optional() + .describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.certificateTemplateId), + csr: z.string().trim().min(1).describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.csr), + friendlyName: z.string().trim().optional().describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.friendlyName), + commonName: z.string().trim().min(1).optional().describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.commonName), + altNames: validateAltNamesField.describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.altNames), + ttl: z + .string() + .refine((val) => ms(val) > 0, "TTL must be a positive number") + .describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.ttl), + notBefore: validateCaDateField.optional().describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.notBefore), + notAfter: validateCaDateField.optional().describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.notAfter) + }) + .refine( + (data) => { + const { ttl, notAfter } = data; + return (ttl !== undefined && notAfter === undefined) || (ttl === undefined && notAfter !== undefined); + }, + { + message: "Either ttl or notAfter must be present, but not both", + path: ["ttl", "notAfter"] + } + ) + .refine( + (data) => + (data.caId !== undefined && data.certificateTemplateId === undefined) || + (data.caId === undefined && data.certificateTemplateId !== undefined), + { + message: "Either CA ID or Certificate Template ID must be present, but not both", + path: ["caId", "certificateTemplateId"] + } + ), + response: { + 200: z.object({ + certificate: z.string().trim().describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.certificate), + issuingCaCertificate: z.string().trim().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.issuingCaCertificate), + certificateChain: z.string().trim().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.certificateChain), + serialNumber: z.string().trim().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.serialNumber) + }) + } + }, + handler: async (req) => { + const { certificate, certificateChain, issuingCaCertificate, serialNumber, ca } = + await server.services.certificateAuthority.signCertFromCa({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.SIGN_CERT, + metadata: { + caId: ca.id, + dn: ca.dn, + serialNumber + } + } + }); + + return { + certificate, + certificateChain, + issuingCaCertificate, + serialNumber + }; + } + }); + server.route({ method: "POST", url: "/:serialNumber/revoke", diff --git a/backend/src/server/routes/v1/certificate-template-router.ts b/backend/src/server/routes/v1/certificate-template-router.ts index 7b71cbbd1..668dde2c0 100644 --- a/backend/src/server/routes/v1/certificate-template-router.ts +++ b/backend/src/server/routes/v1/certificate-template-router.ts @@ -1,9 +1,11 @@ +import ms from "ms"; import { z } from "zod"; -import { CertificateTemplatesSchema } from "@app/db/schemas/certificate-templates"; +import { CertificateTemplatesSchema } 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 { validateTemplateRegexField } from "@app/services/certificate-template/certificate-template-validators"; const sanitizedCertificateTemplate = CertificateTemplatesSchema.pick({ id: true, @@ -58,9 +60,9 @@ export const registerCertificateTemplateRouter = async (server: FastifyZodProvid schema: { body: z.object({ caId: z.string(), - name: z.string(), - commonName: z.string(), - ttl: z.string() + name: z.string().min(1), + commonName: validateTemplateRegexField, + ttl: z.string().refine((val) => ms(val) > 0, "TTL must be a positive number") }), response: { 200: z.object({ @@ -91,9 +93,12 @@ export const registerCertificateTemplateRouter = async (server: FastifyZodProvid schema: { body: z.object({ caId: z.string().optional(), - name: z.string().optional(), - commonName: z.string().optional(), - ttl: z.string().optional() + name: z.string().min(1).optional(), + commonName: validateTemplateRegexField.optional(), + ttl: z + .string() + .refine((val) => ms(val) > 0, "TTL must be a positive number") + .optional() }), params: z.object({ certificateTemplateId: z.string() diff --git a/backend/src/server/routes/v2/project-router.ts b/backend/src/server/routes/v2/project-router.ts index d3a49fb69..bc5dfdfbb 100644 --- a/backend/src/server/routes/v2/project-router.ts +++ b/backend/src/server/routes/v2/project-router.ts @@ -4,11 +4,11 @@ import { z } from "zod"; import { CertificateAuthoritiesSchema, CertificatesSchema, + CertificateTemplatesSchema, PkiAlertsSchema, PkiCollectionsSchema, ProjectKeysSchema } from "@app/db/schemas"; -import { CertificateTemplatesSchema } from "@app/db/schemas/certificate-templates"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { PROJECTS } from "@app/lib/api-docs"; import { creationLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter"; @@ -478,7 +478,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { name: true }).merge( z.object({ - caName: z.string() + caName: z.string(), + caId: z.string() }) ) ) diff --git a/backend/src/services/certificate-authority/certificate-authority-service.ts b/backend/src/services/certificate-authority/certificate-authority-service.ts index 52cfa8039..91f841221 100644 --- a/backend/src/services/certificate-authority/certificate-authority-service.ts +++ b/backend/src/services/certificate-authority/certificate-authority-service.ts @@ -5,9 +5,10 @@ import crypto, { KeyObject } from "crypto"; import ms from "ms"; import { z } from "zod"; +import { TCertificateAuthorities, TCertificateTemplates } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; -import { BadRequestError } from "@app/lib/errors"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal"; import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; @@ -16,6 +17,8 @@ import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns import { TCertificateAuthorityCrlDALFactory } from "../../ee/services/certificate-authority-crl/certificate-authority-crl-dal"; import { CertKeyAlgorithm, CertStatus } from "../certificate/certificate-types"; +import { TCertificateTemplateDALFactory } from "../certificate-template/certificate-template-dal"; +import { validateCertificateDetailsAgainstTemplate } from "../certificate-template/certificate-template-fns"; import { TCertificateAuthorityCertDALFactory } from "./certificate-authority-cert-dal"; import { TCertificateAuthorityDALFactory } from "./certificate-authority-dal"; import { @@ -54,6 +57,7 @@ type TCertificateAuthorityServiceFactoryDep = { certificateAuthorityCertDAL: Pick; certificateAuthoritySecretDAL: Pick; certificateAuthorityCrlDAL: Pick; + certificateTemplateDAL: Pick; certificateAuthorityQueue: TCertificateAuthorityQueueFactory; // TODO: Pick certificateDAL: Pick; certificateBodyDAL: Pick; @@ -69,6 +73,7 @@ export const certificateAuthorityServiceFactory = ({ certificateAuthorityCertDAL, certificateAuthoritySecretDAL, certificateAuthorityCrlDAL, + certificateTemplateDAL, certificateDAL, certificateBodyDAL, projectDAL, @@ -1001,6 +1006,7 @@ export const certificateAuthorityServiceFactory = ({ */ const issueCertFromCa = async ({ caId, + certificateTemplateId, friendlyName, commonName, altNames, @@ -1012,8 +1018,25 @@ export const certificateAuthorityServiceFactory = ({ actor, actorOrgId }: TIssueCertFromCaDTO) => { - const ca = await certificateAuthorityDAL.findById(caId); - if (!ca) throw new BadRequestError({ message: "CA not found" }); + let ca: TCertificateAuthorities | undefined; + let certificateTemplate: TCertificateTemplates | undefined; + + if (caId) { + ca = await certificateAuthorityDAL.findById(caId); + } else if (certificateTemplateId) { + certificateTemplate = await certificateTemplateDAL.getById(certificateTemplateId); + if (!certificateTemplate) { + throw new NotFoundError({ + message: "Certificate template not found" + }); + } + + ca = await certificateAuthorityDAL.findById(certificateTemplate.caId); + } + + if (!ca) { + throw new BadRequestError({ message: "CA not found" }); + } const { permission } = await permissionService.getProjectPermission( actor, @@ -1073,6 +1096,17 @@ export const certificateAuthorityServiceFactory = ({ throw new BadRequestError({ message: "notAfter date is after CA certificate's notAfter date" }); } + if (certificateTemplate) { + validateCertificateDetailsAgainstTemplate( + { + commonName, + notBeforeDate, + notAfterDate + }, + certificateTemplate + ); + } + const alg = keyAlgorithmToAlgCfg(ca.keyAlgorithm as CertKeyAlgorithm); const leafKeys = await crypto.subtle.generateKey(alg, true, ["sign", "verify"]); @@ -1207,6 +1241,7 @@ export const certificateAuthorityServiceFactory = ({ */ const signCertFromCa = async ({ caId, + certificateTemplateId, csr, friendlyName, commonName, @@ -1219,8 +1254,25 @@ export const certificateAuthorityServiceFactory = ({ actor, actorOrgId }: TSignCertFromCaDTO) => { - const ca = await certificateAuthorityDAL.findById(caId); - if (!ca) throw new BadRequestError({ message: "CA not found" }); + let ca: TCertificateAuthorities | undefined; + let certificateTemplate: TCertificateTemplates | undefined; + + if (caId) { + ca = await certificateAuthorityDAL.findById(caId); + } else if (certificateTemplateId) { + certificateTemplate = await certificateTemplateDAL.getById(certificateTemplateId); + if (!certificateTemplate) { + throw new NotFoundError({ + message: "Certificate template not found" + }); + } + + ca = await certificateAuthorityDAL.findById(certificateTemplate.caId); + } + + if (!ca) { + throw new BadRequestError({ message: "CA not found" }); + } const { permission } = await permissionService.getProjectPermission( actor, @@ -1293,6 +1345,17 @@ export const certificateAuthorityServiceFactory = ({ message: "A common name (CN) is required in the CSR or as a parameter to this endpoint" }); + if (certificateTemplate) { + validateCertificateDetailsAgainstTemplate( + { + commonName: cn, + notBeforeDate, + notAfterDate + }, + certificateTemplate + ); + } + const { caPrivateKey } = await getCaCredentials({ caId: ca.id, certificateAuthorityDAL, diff --git a/backend/src/services/certificate-authority/certificate-authority-types.ts b/backend/src/services/certificate-authority/certificate-authority-types.ts index f97844870..e04e0b8a9 100644 --- a/backend/src/services/certificate-authority/certificate-authority-types.ts +++ b/backend/src/services/certificate-authority/certificate-authority-types.ts @@ -86,7 +86,8 @@ export type TImportCertToCaDTO = { } & Omit; export type TIssueCertFromCaDTO = { - caId: string; + caId?: string; + certificateTemplateId?: string; friendlyName?: string; commonName: string; altNames: string; @@ -96,8 +97,9 @@ export type TIssueCertFromCaDTO = { } & Omit; export type TSignCertFromCaDTO = { - caId: string; + caId?: string; csr: string; + certificateTemplateId?: string; friendlyName?: string; commonName?: string; altNames: string; diff --git a/backend/src/services/certificate-template/certificate-template-fns.ts b/backend/src/services/certificate-template/certificate-template-fns.ts new file mode 100644 index 000000000..8b62020ca --- /dev/null +++ b/backend/src/services/certificate-template/certificate-template-fns.ts @@ -0,0 +1,28 @@ +import ms from "ms"; + +import { BadRequestError } from "@app/lib/errors"; + +export const validateCertificateDetailsAgainstTemplate = ( + cert: { + commonName: string; + notBeforeDate: Date; + notAfterDate: Date; + }, + template: { + commonName: string; + ttl: string; + } +) => { + const commonNameRegex = new RegExp(template.commonName); + if (!commonNameRegex.test(cert.commonName)) { + throw new BadRequestError({ + message: "Invalid common name based on template policy" + }); + } + + if (cert.notAfterDate.getTime() - cert.notBeforeDate.getTime() > ms(template.ttl)) { + throw new BadRequestError({ + message: "Invalid validity date based on template policy" + }); + } +}; diff --git a/backend/src/services/certificate-template/certificate-template-validators.ts b/backend/src/services/certificate-template/certificate-template-validators.ts new file mode 100644 index 000000000..41a06b05d --- /dev/null +++ b/backend/src/services/certificate-template/certificate-template-validators.ts @@ -0,0 +1,14 @@ +import safe from "safe-regex"; +import z from "zod"; + +export const validateTemplateRegexField = z + .string() + .min(1) + .max(100) + .regex(/^[a-zA-Z0-9 *@\-\\.\\]+$/, { + message: "Invalid pattern: only alphanumeric characters, spaces, *, ., @, -, and \\ are allowed." + }) + // we ensure that the inputted pattern is computationally safe by limiting star height to 1 + .refine((v) => safe(v), { + message: "Unsafe REGEX pattern" + }); diff --git a/frontend/src/hooks/api/ca/mutations.tsx b/frontend/src/hooks/api/ca/mutations.tsx index 54109adfc..035911e4a 100644 --- a/frontend/src/hooks/api/ca/mutations.tsx +++ b/frontend/src/hooks/api/ca/mutations.tsx @@ -16,7 +16,8 @@ import { TRenewCaResponse, TSignIntermediateDTO, TSignIntermediateResponse, - TUpdateCaDTO} from "./types"; + TUpdateCaDTO +} from "./types"; export const useCreateCa = () => { const queryClient = useQueryClient(); @@ -98,9 +99,9 @@ export const useImportCaCertificate = () => { export const useCreateCertificate = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async ({ caId, ...body }) => { + mutationFn: async (body) => { const { data } = await apiRequest.post( - `/api/v1/pki/ca/${caId}/issue-certificate`, + "/api/v1/pki/certificates/issue-certificate", body ); return data; diff --git a/frontend/src/hooks/api/ca/types.ts b/frontend/src/hooks/api/ca/types.ts index bba7aa699..697e0e84c 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 { CaRenewalType,CaStatus, CaType } from "./enums"; +import { CaRenewalType, CaStatus, CaType } from "./enums"; export type TCertificateAuthority = { id: string; @@ -78,7 +78,8 @@ export type TImportCaCertificateResponse = { export type TCreateCertificateDTO = { projectSlug: string; - caId: string; + caId?: string; + certificateTemplateId?: string; friendlyName?: string; commonName: string; altNames: string; // sans diff --git a/frontend/src/hooks/api/certificateTemplates/types.ts b/frontend/src/hooks/api/certificateTemplates/types.ts index 2938edbca..c440ea6fb 100644 --- a/frontend/src/hooks/api/certificateTemplates/types.ts +++ b/frontend/src/hooks/api/certificateTemplates/types.ts @@ -2,6 +2,7 @@ export type TCertificateTemplateListEntry = { id: string; name: string; caName: string; + caId: string; }; export type TCertificateTemplate = { diff --git a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateModal.tsx b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateModal.tsx index 12bd8c47c..86009c6b6 100644 --- a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateModal.tsx +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateModal.tsx @@ -14,13 +14,21 @@ import { SelectItem } from "@app/components/v2"; import { useWorkspace } from "@app/context"; -import { CaStatus, useCreateCertificate, useGetCert, useListWorkspaceCas } from "@app/hooks/api"; +import { + CaStatus, + useCreateCertificate, + useGetCert, + useGetCertTemplate, + useListWorkspaceCas, + useListWorkspaceCertificateTemplates +} from "@app/hooks/api"; import { caTypeToNameMap } from "@app/hooks/api/ca/constants"; import { UsePopUpState } from "@app/hooks/usePopUp"; import { CertificateContent } from "./CertificateContent"; const schema = z.object({ + certificateTemplateId: z.string().optional(), caId: z.string(), friendlyName: z.string(), commonName: z.string().trim().min(1), @@ -42,6 +50,8 @@ type TCertificateDetails = { privateKey: string; }; +const CERT_TEMPLATE_NONE_VALUE = "none"; + export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => { const [certificateDetails, setCertificateDetails] = useState(null); const { currentWorkspace } = useWorkspace(); @@ -54,6 +64,10 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => { status: CaStatus.ACTIVE }); + const { data: templatesData } = useListWorkspaceCertificateTemplates({ + workspaceId: currentWorkspace?.id || "" + }); + const { mutateAsync: createCertificate } = useCreateCertificate(); const { @@ -61,11 +75,20 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => { handleSubmit, reset, formState: { isSubmitting }, - setValue + setValue, + watch } = useForm({ resolver: zodResolver(schema) }); + const selectedCertTemplateId = watch("certificateTemplateId"); + const hasCertTemplateSelected = + selectedCertTemplateId !== "" && selectedCertTemplateId !== CERT_TEMPLATE_NONE_VALUE; + + const { data: selectedCertTemplate } = useGetCertTemplate( + hasCertTemplateSelected ? (selectedCertTemplateId as string) : "" + ); + useEffect(() => { if (cert) { reset({ @@ -81,7 +104,8 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => { friendlyName: "", commonName: "", altNames: "", - ttl: "" + ttl: "", + certificateTemplateId: CERT_TEMPLATE_NONE_VALUE }); } }, [cert]); @@ -91,8 +115,9 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => { if (!currentWorkspace?.slug) return; const { serialNumber, certificate, certificateChain, privateKey } = await createCertificate({ + caId: !selectedCertTemplate ? caId : undefined, + certificateTemplateId: selectedCertTemplate ? selectedCertTemplateId : undefined, projectSlug: currentWorkspace.slug, - caId, friendlyName, commonName, altNames, @@ -141,11 +166,11 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => {
( { className="w-full" isDisabled={Boolean(cert)} > - {(cas || []).map(({ id, type, dn }) => ( - - {`${caTypeToNameMap[type]}: ${dn}`} + + None + + {(templatesData?.certificateTemplates || []).map(({ id, name }) => ( + + {name} ))} )} /> + {(!selectedCertTemplateId || selectedCertTemplateId === CERT_TEMPLATE_NONE_VALUE) && ( + ( + + + + )} + /> + )}