From 683e3dd7be8b20810e1f34c2aea9c34fb19e6636 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Wed, 31 Jul 2024 13:57:47 -0700 Subject: [PATCH] Add sign certificate endpoint --- .../ee/services/audit-log/audit-log-types.ts | 11 + backend/src/lib/api-docs/constants.ts | 17 +- .../routes/v1/certificate-authority-router.ts | 81 ++++++- .../certificate-authority-fns.ts | 37 ++++ .../certificate-authority-service.ts | 208 +++++++++++++++++- .../certificate-authority-types.ts | 11 + .../certificate-authorities/sign-cert.mdx | 4 + .../platform/pki/certificates.mdx | 28 ++- docs/mint.json | 1 + 9 files changed, 391 insertions(+), 7 deletions(-) create mode 100644 docs/api-reference/endpoints/certificate-authorities/sign-cert.mdx diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index 76bf73d8c..bfc1dbf92 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -136,6 +136,7 @@ export enum EventType { IMPORT_CA_CERT = "import-certificate-authority-cert", GET_CA_CRL = "get-certificate-authority-crl", ISSUE_CERT = "issue-cert", + SIGN_CERT = "sign-cert", GET_CERT = "get-cert", DELETE_CERT = "delete-cert", REVOKE_CERT = "revoke-cert", @@ -1143,6 +1144,15 @@ interface IssueCert { }; } +interface SignCert { + type: EventType.SIGN_CERT; + metadata: { + caId: string; + dn: string; + serialNumber: string; + }; +} + interface GetCert { type: EventType.GET_CERT; metadata: { @@ -1333,6 +1343,7 @@ export type Event = | ImportCaCert | GetCaCrl | IssueCert + | SignCert | GetCert | DeleteCert | RevokeCert diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index e151ffb85..aa7d2fc51 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -1056,7 +1056,7 @@ export const CERTIFICATE_AUTHORITIES = { }, SIGN_INTERMEDIATE: { caId: "The ID of the CA to sign the intermediate certificate with", - csr: "The CSR to sign with the CA", + csr: "The pem-encoded CSR to sign with the CA", notBefore: "The date and time when the intermediate CA becomes valid in YYYY-MM-DDTHH:mm:ss.sssZ format", notAfter: "The date and time when the intermediate CA expires in YYYY-MM-DDTHH:mm:ss.sssZ format", maxPathLength: @@ -1086,6 +1086,21 @@ export const CERTIFICATE_AUTHORITIES = { privateKey: "The private key of the issued certificate", serialNumber: "The serial number of the issued certificate" }, + SIGN_CERT: { + caId: "The ID of the CA to issue the certificate from", + csr: "The pem-encoded CSR to sign with the CA to be used for certificate issuance", + friendlyName: "A friendly name for the certificate", + commonName: "The common name (CN) for the certificate", + altNames: + "A comma-delimited list of Subject Alternative Names (SANs) for the certificate; these can be host names or email addresses.", + ttl: "The time to live for the certificate such as 1m, 1h, 1d, 1y, ...", + notBefore: "The date and time when the certificate becomes valid in YYYY-MM-DDTHH:mm:ss.sssZ format", + notAfter: "The date and time when the certificate expires in YYYY-MM-DDTHH:mm:ss.sssZ format", + certificate: "The issued certificate", + issuingCaCertificate: "The certificate of the issuing CA", + certificateChain: "The certificate chain of the issued certificate", + serialNumber: "The serial number of the issued certificate" + }, GET_CRL: { caId: "The ID of the CA to get the certificate revocation list (CRL) for", crl: "The certificate revocation list (CRL) of the CA" diff --git a/backend/src/server/routes/v1/certificate-authority-router.ts b/backend/src/server/routes/v1/certificate-authority-router.ts index 533f249f2..1a6e30302 100644 --- a/backend/src/server/routes/v1/certificate-authority-router.ts +++ b/backend/src/server/routes/v1/certificate-authority-router.ts @@ -337,7 +337,7 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { caId: z.string().trim().describe(CERTIFICATE_AUTHORITIES.SIGN_INTERMEDIATE.caId) }), body: z.object({ - csr: z.string().trim().describe(CERTIFICATE_AUTHORITIES.SIGN_INTERMEDIATE.csr), + csr: z.string().trim().min(1).describe(CERTIFICATE_AUTHORITIES.SIGN_INTERMEDIATE.csr), notBefore: validateCaDateField.optional().describe(CERTIFICATE_AUTHORITIES.SIGN_INTERMEDIATE.notBefore), notAfter: validateCaDateField.describe(CERTIFICATE_AUTHORITIES.SIGN_INTERMEDIATE.notAfter), maxPathLength: z.number().min(-1).default(-1).describe(CERTIFICATE_AUTHORITIES.SIGN_INTERMEDIATE.maxPathLength) @@ -453,7 +453,7 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { }), body: z .object({ - friendlyName: z.string().optional().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.friendlyName), + 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 @@ -516,4 +516,81 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { }; } }); + + server.route({ + method: "POST", + url: "/:caId/sign-certificate", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Sign certificate from CA", + params: z.object({ + caId: z.string().trim().describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.caId) + }), + body: z + .object({ + 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"] + } + ), + 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({ + caId: req.params.caId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.SIGN_CERT, + metadata: { + caId: ca.id, + dn: ca.dn, + serialNumber + } + } + }); + + return { + certificate, + certificateChain, + issuingCaCertificate, + serialNumber + }; + } + }); }; diff --git a/backend/src/services/certificate-authority/certificate-authority-fns.ts b/backend/src/services/certificate-authority/certificate-authority-fns.ts index 32ac4bfb1..e1edfd198 100644 --- a/backend/src/services/certificate-authority/certificate-authority-fns.ts +++ b/backend/src/services/certificate-authority/certificate-authority-fns.ts @@ -18,6 +18,43 @@ export const createDistinguishedName = (parts: TDNParts) => { return dnParts.join(", "); }; +export const parseDistinguishedName = (dn: string): TDNParts => { + const parts: TDNParts = {}; + const dnRegex = /(?:^|,\s*)([A-Z]+)=([^,]+)/g; + let match: RegExpExecArray | null; + + while (true) { + match = dnRegex.exec(dn); + if (match === null) break; + + const [, key, value] = match; + switch (key) { + case "C": + parts.country = value; + break; + case "O": + parts.organization = value; + break; + case "OU": + parts.ou = value; + break; + case "ST": + parts.province = value; + break; + case "CN": + parts.commonName = value; + break; + case "L": + parts.locality = value; + break; + default: + // Ignore unrecognized keys + break; + } + } + return parts; +}; + export const keyAlgorithmToAlgCfg = (keyAlgorithm: CertKeyAlgorithm) => { switch (keyAlgorithm) { case CertKeyAlgorithm.RSA_4096: diff --git a/backend/src/services/certificate-authority/certificate-authority-service.ts b/backend/src/services/certificate-authority/certificate-authority-service.ts index a1390b1f7..49bb209ef 100644 --- a/backend/src/services/certificate-authority/certificate-authority-service.ts +++ b/backend/src/services/certificate-authority/certificate-authority-service.ts @@ -22,7 +22,8 @@ import { createDistinguishedName, getCaCertChain, getCaCredentials, - keyAlgorithmToAlgCfg + keyAlgorithmToAlgCfg, + parseDistinguishedName } from "./certificate-authority-fns"; import { TCertificateAuthorityQueueFactory } from "./certificate-authority-queue"; import { TCertificateAuthoritySecretDALFactory } from "./certificate-authority-secret-dal"; @@ -36,6 +37,7 @@ import { TGetCaDTO, TImportCertToCaDTO, TIssueCertFromCaDTO, + TSignCertFromCaDTO, TSignIntermediateDTO, TUpdateCaDTO } from "./certificate-authority-types"; @@ -651,7 +653,8 @@ export const certificateAuthorityServiceFactory = ({ }; /** - * Return new leaf certificate issued by CA with id [caId] + * Return new leaf certificate issued by CA with id [caId] and private key. + * Note: private key and CSR are generated within Infisical. */ const issueCertFromCa = async ({ caId, @@ -851,6 +854,204 @@ export const certificateAuthorityServiceFactory = ({ }; }; + /** + * Return new leaf certificate issued by CA with id [caId]. + * Note: CSR is generated externally and submitted to Infisical. + */ + const signCertFromCa = async ({ + caId, + csr, + friendlyName, + commonName, + altNames, + ttl, + notBefore, + notAfter, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TSignCertFromCaDTO) => { + 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.Create, ProjectPermissionSub.Certificates); + + if (ca.status === CaStatus.DISABLED) throw new BadRequestError({ message: "CA is disabled" }); + + const caCert = await certificateAuthorityCertDAL.findOne({ caId: ca.id }); + if (!caCert) throw new BadRequestError({ message: "CA does not have a certificate installed" }); + + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + + const decryptedCaCert = await kmsDecryptor({ + cipherTextBlob: caCert.encryptedCertificate + }); + + const caCertObj = new x509.X509Certificate(decryptedCaCert); + + const notBeforeDate = notBefore ? new Date(notBefore) : new Date(); + + let notAfterDate = new Date(new Date().setFullYear(new Date().getFullYear() + 1)); + if (notAfter) { + notAfterDate = new Date(notAfter); + } else if (ttl) { + notAfterDate = new Date(new Date().getTime() + ms(ttl)); + } + + const caCertNotBeforeDate = new Date(caCertObj.notBefore); + const caCertNotAfterDate = new Date(caCertObj.notAfter); + + // check not before constraint + if (notBeforeDate < caCertNotBeforeDate) { + throw new BadRequestError({ message: "notBefore date is before CA certificate's notBefore date" }); + } + + if (notBeforeDate > notAfterDate) throw new BadRequestError({ message: "notBefore date is after notAfter date" }); + + // check not after constraint + if (notAfterDate > caCertNotAfterDate) { + throw new BadRequestError({ message: "notAfter date is after CA certificate's notAfter date" }); + } + + const alg = keyAlgorithmToAlgCfg(ca.keyAlgorithm as CertKeyAlgorithm); + + const csrObj = new x509.Pkcs10CertificateRequest(csr); + + const dn = parseDistinguishedName(csrObj.subject); + const cn = commonName || dn.commonName; + + if (!cn) + throw new BadRequestError({ + message: "A common name (CN) is required in the CSR or as a parameter to this endpoint" + }); + + const { caPrivateKey } = await getCaCredentials({ + caId: ca.id, + certificateAuthorityDAL, + certificateAuthoritySecretDAL, + projectDAL, + kmsService + }); + + const extensions: x509.Extension[] = [ + new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature | x509.KeyUsageFlags.keyEncipherment, true), + new x509.BasicConstraintsExtension(false), + await x509.AuthorityKeyIdentifierExtension.create(caCertObj, false), + await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey) + ]; + + if (altNames) { + const altNamesArray: { + type: "email" | "dns"; + value: string; + }[] = altNames + .split(",") + .map((name) => name.trim()) + .map((altName) => { + // check if the altName is a valid email + if (z.string().email().safeParse(altName).success) { + return { + type: "email", + value: altName + }; + } + + // check if the altName is a valid hostname + if (hostnameRegex.test(altName)) { + return { + type: "dns", + value: altName + }; + } + + // If altName is neither a valid email nor a valid hostname, throw an error or handle it accordingly + throw new Error(`Invalid altName: ${altName}`); + }); + + const altNamesExtension = new x509.SubjectAlternativeNameExtension(altNamesArray, false); + extensions.push(altNamesExtension); + } + + const serialNumber = crypto.randomBytes(32).toString("hex"); + const leafCert = await x509.X509CertificateGenerator.create({ + serialNumber, + subject: csrObj.subject, + issuer: caCertObj.subject, + notBefore: notBeforeDate, + notAfter: notAfterDate, + signingKey: caPrivateKey, + publicKey: csrObj.publicKey, + signingAlgorithm: alg, + extensions + }); + + const kmsEncryptor = await kmsService.encryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({ + plainText: Buffer.from(new Uint8Array(leafCert.rawData)) + }); + + await certificateDAL.transaction(async (tx) => { + const cert = await certificateDAL.create( + { + caId: ca.id, + status: CertStatus.ACTIVE, + friendlyName: friendlyName || csrObj.subject, + commonName: cn, + altNames, + serialNumber, + notBefore: notBeforeDate, + notAfter: notAfterDate + }, + tx + ); + + await certificateBodyDAL.create( + { + certId: cert.id, + encryptedCertificate + }, + tx + ); + + return cert; + }); + + const { caCert: issuingCaCertificate, caCertChain } = await getCaCertChain({ + caId: ca.id, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService + }); + + return { + certificate: leafCert.toString("pem"), + certificateChain: `${issuingCaCertificate}\n${caCertChain}`.trim(), + issuingCaCertificate, + serialNumber, + ca + }; + }; + return { createCa, getCaById, @@ -860,6 +1061,7 @@ export const certificateAuthorityServiceFactory = ({ getCaCert, signIntermediate, importCertToCa, - issueCertFromCa + issueCertFromCa, + signCertFromCa }; }; diff --git a/backend/src/services/certificate-authority/certificate-authority-types.ts b/backend/src/services/certificate-authority/certificate-authority-types.ts index 7818c3a3e..d4c2682d2 100644 --- a/backend/src/services/certificate-authority/certificate-authority-types.ts +++ b/backend/src/services/certificate-authority/certificate-authority-types.ts @@ -81,6 +81,17 @@ export type TIssueCertFromCaDTO = { notAfter?: string; } & Omit; +export type TSignCertFromCaDTO = { + caId: string; + csr: string; + friendlyName?: string; + commonName?: string; + altNames: string; + ttl: string; + notBefore?: string; + notAfter?: string; +} & Omit; + export type TDNParts = { commonName?: string; organization?: string; diff --git a/docs/api-reference/endpoints/certificate-authorities/sign-cert.mdx b/docs/api-reference/endpoints/certificate-authorities/sign-cert.mdx new file mode 100644 index 000000000..95c8d8c65 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-authorities/sign-cert.mdx @@ -0,0 +1,4 @@ +--- +title: "Sign certificate" +openapi: "POST /api/v1/pki/ca/{caId}/sign-certificate" +--- diff --git a/docs/documentation/platform/pki/certificates.mdx b/docs/documentation/platform/pki/certificates.mdx index 7dc3bca88..1efb0c44e 100644 --- a/docs/documentation/platform/pki/certificates.mdx +++ b/docs/documentation/platform/pki/certificates.mdx @@ -74,7 +74,7 @@ In the following steps, we explore how to issue a X.509 certificate under a CA. - To create a certificate, make an API request to the [Create Certificate](/api-reference/endpoints/certificate-authorities/sign-intermediate) API endpoint, + To create a certificate, make an API request to the [Issue Certificate](/api-reference/endpoints/certificates/issue-cert) API endpoint, specifying the issuing CA. ### Sample request @@ -84,6 +84,7 @@ In the following steps, we explore how to issue a X.509 certificate under a CA. --header 'Content-Type: application/json' \ --data-raw '{ "commonName": "My Certificate", + "ttl": "1y", }' ``` @@ -103,6 +104,31 @@ In the following steps, we explore how to issue a X.509 certificate under a CA. Make sure to store the `privateKey` as it is only returned once here at the time of certificate issuance. The `certificate` and `certificateChain` will remain accessible and can be retrieved at any time. + If you have an external private key, you can also create a certificate by making an API request containing a pem-encoded CSR (Certificate Signing Request) to the [Sign Certificate](/api-reference/endpoints/certificates/sign-cert) API endpoint, specifying the issuing CA. + + ### Sample request + + ```bash Request + curl --location --request POST 'https://app.infisical.com/api/v1/pki/ca//issue-certificate' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "csr": "...", + "ttl": "1y", + }' + ``` + + ### Sample response + + ```bash Response + { + certificate: "...", + certificateChain: "...", + issuingCaCertificate: "...", + privateKey: "...", + serialNumber: "..." + } + ``` + diff --git a/docs/mint.json b/docs/mint.json index 5c7012745..97e4bbf13 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -671,6 +671,7 @@ "api-reference/endpoints/certificate-authorities/sign-intermediate", "api-reference/endpoints/certificate-authorities/import-cert", "api-reference/endpoints/certificate-authorities/issue-cert", + "api-reference/endpoints/certificate-authorities/sign-cert", "api-reference/endpoints/certificate-authorities/crl" ] },