Merge pull request #4967 from Infisical/PKI-49-check-template-for-external-ca-with-acme

improvement(api): check template for external ca with acme
This commit is contained in:
Fang-Pen Lin
2025-12-03 10:56:31 -08:00
committed by GitHub
4 changed files with 449 additions and 15 deletions

View File

@@ -369,3 +369,349 @@ Feature: External CA
| subject | | subject |
| {"COMMON_NAME": "localhost"} | | {"COMMON_NAME": "localhost"} |
| {} | | {} |
Scenario Outline: Issue a certificate with bad CSR names disallowed by the template
Given I create a Cloudflare connection as cloudflare
Then I memorize cloudflare with jq ".appConnection.id" as app_conn_id
Given I create a external ACME CA with the following config as ext_ca
"""
{
"dnsProviderConfig": {
"provider": "cloudflare",
"hostedZoneId": "MOCK_ZONE_ID"
},
"directoryUrl": "{PEBBLE_URL}",
"accountEmail": "fangpen@infisical.com",
"dnsAppConnectionId": "{app_conn_id}",
"eabKid": "",
"eabHmacKey": ""
}
"""
Then I memorize ext_ca with jq ".id" as ext_ca_id
Given I create a certificate template with the following config as cert_template
"""
{
"subject": [
{
"type": "common_name",
"allowed": [
"example.com"
]
}
],
"sans": [
{
"type": "dns_name",
"allowed": [
"infisical.com"
]
}
],
"keyUsages": {
"required": [],
"allowed": [
"digital_signature",
"key_encipherment",
"non_repudiation",
"data_encipherment",
"key_agreement",
"key_cert_sign",
"crl_sign",
"encipher_only",
"decipher_only"
]
},
"extendedKeyUsages": {
"required": [],
"allowed": [
"client_auth",
"server_auth",
"code_signing",
"email_protection",
"ocsp_signing",
"time_stamping"
]
},
"algorithms": {
"signature": [
"SHA256-RSA",
"SHA512-RSA",
"SHA384-ECDSA",
"SHA384-RSA",
"SHA256-ECDSA",
"SHA512-ECDSA"
],
"keyAlgorithm": [
"RSA-2048",
"RSA-4096",
"ECDSA-P384",
"RSA-3072",
"ECDSA-P256",
"ECDSA-P521"
]
},
"validity": {
"max": "365d"
}
}
"""
Then I memorize cert_template with jq ".certificateTemplate.id" as cert_template_id
Given I create an ACME profile with ca {ext_ca_id} and template {cert_template_id} as "acme_profile"
When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/directory"
Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account
When I create certificate signing request as csr
Then I add names to certificate signing request csr
"""
<subject>
"""
Then I add subject alternative name to certificate signing request csr
"""
<san>
"""
And I create a RSA private key pair as cert_key
And I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format
And I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order
And I pass all challenges with type http-01 for order in order
Given I intercept outgoing requests
"""
[
{
"scope": "https://api.cloudflare.com:443",
"method": "POST",
"path": "/client/v4/zones/MOCK_ZONE_ID/dns_records",
"status": 200,
"response": {
"result": {
"id": "A2A6347F-88B5-442D-9798-95E408BC7701",
"name": "Mock Account",
"type": "standard",
"settings": {
"enforce_twofactor": false,
"api_access_enabled": null,
"access_approval_expiry": null,
"abuse_contact_email": null,
"user_groups_ui_beta": false
},
"legacy_flags": {
"enterprise_zone_quota": {
"maximum": 0,
"current": 0,
"available": 0
}
},
"created_on": "2013-04-18T00:41:02.215243Z"
},
"success": true,
"errors": [],
"messages": []
},
"responseIsBinary": false
},
{
"scope": "https://api.cloudflare.com:443",
"method": "GET",
"path": {
"regex": "/client/v4/zones/[^/]+/dns_records\\?"
},
"status": 200,
"response": {
"result": [],
"success": true,
"errors": [],
"messages": [],
"result_info": {
"page": 1,
"per_page": 100,
"count": 0,
"total_count": 0,
"total_pages": 1
}
},
"responseIsBinary": false
}
]
"""
Then I poll and finalize the ACME order order as finalized_order
And the value error.typ should be equal to "urn:ietf:params:acme:error:badCSR"
And the value error.detail should be equal to "<err_detail>"
Examples:
| subject | san | err_detail |
| {"COMMON_NAME": "localhost"} | [] | Invalid CSR: common_name value 'localhost' is not in allowed values list |
| {"COMMON_NAME": "localhost"} | ["infisical.com"] | Invalid CSR: common_name value 'localhost' is not in allowed values list |
| {} | ["localhost"] | Invalid CSR: dns_name SAN value 'localhost' is not in allowed values list |
| {} | ["infisical.com", "localhost"] | Invalid CSR: dns_name SAN value 'localhost' is not in allowed values list |
| {"COMMON_NAME": "example.com"} | ["infisical.com", "localhost"] | Invalid CSR: dns_name SAN value 'localhost' is not in allowed values list |
Scenario Outline: Issue a certificate with algorithms disallowed by the template
Given I create a Cloudflare connection as cloudflare
Then I memorize cloudflare with jq ".appConnection.id" as app_conn_id
Given I create a external ACME CA with the following config as ext_ca
"""
{
"dnsProviderConfig": {
"provider": "cloudflare",
"hostedZoneId": "MOCK_ZONE_ID"
},
"directoryUrl": "{PEBBLE_URL}",
"accountEmail": "fangpen@infisical.com",
"dnsAppConnectionId": "{app_conn_id}",
"eabKid": "",
"eabHmacKey": ""
}
"""
Then I memorize ext_ca with jq ".id" as ext_ca_id
Given I create a certificate template with the following config as cert_template
"""
{
"subject": [
{
"type": "common_name",
"allowed": [
"*"
]
}
],
"sans": [
{
"type": "dns_name",
"allowed": [
"*"
]
}
],
"keyUsages": {
"required": [],
"allowed": [
"digital_signature",
"key_encipherment",
"non_repudiation",
"data_encipherment",
"key_agreement",
"key_cert_sign",
"crl_sign",
"encipher_only",
"decipher_only"
]
},
"extendedKeyUsages": {
"required": [],
"allowed": [
"client_auth",
"server_auth",
"code_signing",
"email_protection",
"ocsp_signing",
"time_stamping"
]
},
"algorithms": {
"signature": [
"<allowed_signature>"
],
"keyAlgorithm": [
"<allowed_alg>"
]
},
"validity": {
"max": "365d"
}
}
"""
Then I memorize cert_template with jq ".certificateTemplate.id" as cert_template_id
Given I create an ACME profile with ca {ext_ca_id} and template {cert_template_id} as "acme_profile"
When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/directory"
Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account
When I create certificate signing request as csr
Then I add names to certificate signing request csr
"""
{}
"""
Then I add subject alternative name to certificate signing request csr
"""
[
"localhost"
]
"""
And I create a <key_type> private key pair as cert_key
And I sign the certificate signing request csr with "<hash_type>" hash and private key cert_key and output it as csr_pem in PEM format
And I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order
And I pass all challenges with type http-01 for order in order
Given I intercept outgoing requests
"""
[
{
"scope": "https://api.cloudflare.com:443",
"method": "POST",
"path": "/client/v4/zones/MOCK_ZONE_ID/dns_records",
"status": 200,
"response": {
"result": {
"id": "A2A6347F-88B5-442D-9798-95E408BC7701",
"name": "Mock Account",
"type": "standard",
"settings": {
"enforce_twofactor": false,
"api_access_enabled": null,
"access_approval_expiry": null,
"abuse_contact_email": null,
"user_groups_ui_beta": false
},
"legacy_flags": {
"enterprise_zone_quota": {
"maximum": 0,
"current": 0,
"available": 0
}
},
"created_on": "2013-04-18T00:41:02.215243Z"
},
"success": true,
"errors": [],
"messages": []
},
"responseIsBinary": false
},
{
"scope": "https://api.cloudflare.com:443",
"method": "GET",
"path": {
"regex": "/client/v4/zones/[^/]+/dns_records\\?"
},
"status": 200,
"response": {
"result": [],
"success": true,
"errors": [],
"messages": [],
"result_info": {
"page": 1,
"per_page": 100,
"count": 0,
"total_count": 0,
"total_pages": 1
}
},
"responseIsBinary": false
}
]
"""
Then I poll and finalize the ACME order order as finalized_order
And the value error.typ should be equal to "urn:ietf:params:acme:error:badCSR"
And the value error.detail should be equal to "<err_detail>"
Examples:
| allowed_alg | allowed_signature | key_type | hash_type | err_detail |
| RSA-4096 | SHA512-RSA | RSA-2048 | SHA512 | Invalid CSR: Key algorithm 'RSA_2048' is not allowed by template policy |
| RSA-4096 | SHA512-RSA | RSA-3072 | SHA512 | Invalid CSR: Key algorithm 'RSA_3072' is not allowed by template policy |
| RSA-4096 | ECDSA-SHA512 | ECDSA-P256 | SHA512 | Invalid CSR: Key algorithm 'EC_prime256v1' is not allowed by template policy |
| RSA-4096 | ECDSA-SHA512 | ECDSA-P384 | SHA512 | Invalid CSR: Key algorithm 'EC_secp384r1' is not allowed by template policy |
| RSA-4096 | ECDSA-SHA512 | ECDSA-P521 | SHA512 | Invalid CSR: Key algorithm 'EC_secp521r1' is not allowed by template policy |
| RSA-2048 | SHA512-RSA | RSA-2048 | SHA384 | Invalid CSR: Signature algorithm 'RSA-SHA384' is not allowed by template policy |
| RSA-2048 | SHA512-RSA | RSA-2048 | SHA256 | Invalid CSR: Signature algorithm 'RSA-SHA256' is not allowed by template policy |
| ECDSA-P256 | SHA512-RSA | ECDSA-P256 | SHA256 | Invalid CSR: Signature algorithm 'ECDSA-SHA256' is not allowed by template policy |
| ECDSA-P384 | SHA512-RSA | ECDSA-P384 | SHA256 | Invalid CSR: Signature algorithm 'ECDSA-SHA256' is not allowed by template policy |
| ECDSA-P521 | SHA512-RSA | ECDSA-P521 | SHA256 | Invalid CSR: Signature algorithm 'ECDSA-SHA256' is not allowed by template policy |
| RSA-2048 | SHA512-RSA | RSA-2048 | SHA256 | Invalid CSR: Signature algorithm 'RSA-SHA256' is not allowed by template policy |
| RSA-2048 | SHA512-RSA | RSA-4096 | SHA256 | Invalid CSR: Signature algorithm 'RSA-SHA256' is not allowed by template policy, Key algorithm 'RSA_4096' is not allowed by template policy |

View File

@@ -20,6 +20,10 @@ from josepy.jwk import JWKRSA
from josepy import json_util from josepy import json_util
from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.types import (
CertificateIssuerPrivateKeyTypes,
)
from cryptography import x509 from cryptography import x509
from cryptography.x509.oid import NameOID from cryptography.x509.oid import NameOID
from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives import hashes
@@ -597,12 +601,57 @@ def step_impl(context: Context, csr_var: str):
) )
@then("I create a RSA private key pair as {rsa_key_var}") def gen_private_key(key_type: str):
def step_impl(context: Context, rsa_key_var: str): if key_type == "RSA-2048" or key_type == "RSA":
context.vars[rsa_key_var] = rsa.generate_private_key( return rsa.generate_private_key(
# TODO: make them configurable if we need to public_exponent=65537,
public_exponent=65537, key_size=2048,
key_size=2048, )
elif key_type == "RSA-3072":
return rsa.generate_private_key(
public_exponent=65537,
key_size=3072,
)
elif key_type == "RSA-4096":
return rsa.generate_private_key(
public_exponent=65537,
key_size=4096,
)
elif key_type == "ECDSA-P256":
return ec.generate_private_key(curve=ec.SECP256R1())
elif key_type == "ECDSA-P384":
return ec.generate_private_key(curve=ec.SECP384R1())
elif key_type == "ECDSA-P521":
return ec.generate_private_key(curve=ec.SECP521R1())
else:
raise Exception(f"Unknown key type {key_type}")
@then("I create a {key_type} private key pair as {rsa_key_var}")
def step_impl(context: Context, key_type: str, rsa_key_var: str):
context.vars[rsa_key_var] = gen_private_key(key_type)
def sign_csr(
pem: x509.CertificateSigningRequestBuilder,
pk: CertificateIssuerPrivateKeyTypes,
hash_type: str = "SHA256",
):
return pem.sign(pk, getattr(hashes, hash_type)()).public_bytes(
serialization.Encoding.PEM
)
@then(
'I sign the certificate signing request {csr_var} with "{hash_type}" hash and private key {pk_var} and output it as {pem_var} in PEM format'
)
def step_impl(
context: Context, csr_var: str, hash_type: str, pk_var: str, pem_var: str
):
context.vars[pem_var] = sign_csr(
pem=context.vars[csr_var],
pk=context.vars[pk_var],
hash_type=hash_type,
) )
@@ -610,10 +659,9 @@ def step_impl(context: Context, rsa_key_var: str):
"I sign the certificate signing request {csr_var} with private key {pk_var} and output it as {pem_var} in PEM format" "I sign the certificate signing request {csr_var} with private key {pk_var} and output it as {pem_var} in PEM format"
) )
def step_impl(context: Context, csr_var: str, pk_var: str, pem_var: str): def step_impl(context: Context, csr_var: str, pk_var: str, pem_var: str):
context.vars[pem_var] = ( context.vars[pem_var] = sign_csr(
context.vars[csr_var] pem=context.vars[csr_var],
.sign(context.vars[pk_var], hashes.SHA256()) pk=context.vars[pk_var],
.public_bytes(serialization.Encoding.PEM)
) )
@@ -993,8 +1041,12 @@ def step_impl(context: Context, uri: str, resp_var: str):
def step_impl(context: Context, var_path: str, finalized_var: str): def step_impl(context: Context, var_path: str, finalized_var: str):
order = eval_var(context, var_path, as_json=False) order = eval_var(context, var_path, as_json=False)
acme_client = context.acme_client acme_client = context.acme_client
finalized_order = acme_client.poll_and_finalize(order) try:
context.vars[finalized_var] = finalized_order finalized_order = acme_client.poll_and_finalize(order)
context.vars[finalized_var] = finalized_order
except Exception as exp:
logger.error(f"Failed to finalize order: {exp}", exc_info=True)
context.vars["error"] = exp
@then("I parse the full-chain certificate from order {order_var_path} as {cert_var}") @then("I parse the full-chain certificate from order {order_var_path} as {cert_var}")

View File

@@ -31,12 +31,17 @@ import { orderCertificate } from "@app/services/certificate-authority/acme/acme-
import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal"; import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal";
import { CaType } from "@app/services/certificate-authority/certificate-authority-enums"; import { CaType } from "@app/services/certificate-authority/certificate-authority-enums";
import { TExternalCertificateAuthorityDALFactory } from "@app/services/certificate-authority/external-certificate-authority-dal"; import { TExternalCertificateAuthorityDALFactory } from "@app/services/certificate-authority/external-certificate-authority-dal";
import { extractCertificateRequestFromCSR } from "@app/services/certificate-common/certificate-csr-utils"; import {
extractAlgorithmsFromCSR,
extractCertificateRequestFromCSR
} from "@app/services/certificate-common/certificate-csr-utils";
import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal";
import { import {
EnrollmentType, EnrollmentType,
TCertificateProfileWithConfigs TCertificateProfileWithConfigs
} from "@app/services/certificate-profile/certificate-profile-types"; } from "@app/services/certificate-profile/certificate-profile-types";
import { TCertificateTemplateV2DALFactory } from "@app/services/certificate-template-v2/certificate-template-v2-dal";
import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service";
import { TCertificateV3ServiceFactory } from "@app/services/certificate-v3/certificate-v3-service"; import { TCertificateV3ServiceFactory } from "@app/services/certificate-v3/certificate-v3-service";
import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal";
@@ -101,6 +106,7 @@ type TPkiAcmeServiceFactoryDep = {
certificateProfileDAL: Pick<TCertificateProfileDALFactory, "findByIdWithOwnerOrgId" | "findByIdWithConfigs">; certificateProfileDAL: Pick<TCertificateProfileDALFactory, "findByIdWithOwnerOrgId" | "findByIdWithConfigs">;
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "findOne" | "create">; certificateBodyDAL: Pick<TCertificateBodyDALFactory, "findOne" | "create">;
certificateSecretDAL: Pick<TCertificateSecretDALFactory, "findOne" | "create">; certificateSecretDAL: Pick<TCertificateSecretDALFactory, "findOne" | "create">;
certificateTemplateV2DAL: Pick<TCertificateTemplateV2DALFactory, "findById">;
acmeAccountDAL: Pick< acmeAccountDAL: Pick<
TPkiAcmeAccountDALFactory, TPkiAcmeAccountDALFactory,
"findByProjectIdAndAccountId" | "findByProfileIdAndPublicKeyThumbprintAndAlg" | "create" "findByProjectIdAndAccountId" | "findByProfileIdAndPublicKeyThumbprintAndAlg" | "create"
@@ -127,6 +133,7 @@ type TPkiAcmeServiceFactoryDep = {
>; >;
licenseService: Pick<TLicenseServiceFactory, "getPlan">; licenseService: Pick<TLicenseServiceFactory, "getPlan">;
certificateV3Service: Pick<TCertificateV3ServiceFactory, "signCertificateFromProfile">; certificateV3Service: Pick<TCertificateV3ServiceFactory, "signCertificateFromProfile">;
certificateTemplateV2Service: Pick<TCertificateTemplateV2ServiceFactory, "validateCertificateRequest">;
acmeChallengeService: Pick<TPkiAcmeChallengeServiceFactory, "markChallengeAsReady">; acmeChallengeService: Pick<TPkiAcmeChallengeServiceFactory, "markChallengeAsReady">;
pkiAcmeQueueService: Pick<TPkiAcmeQueueServiceFactory, "queueChallengeValidation">; pkiAcmeQueueService: Pick<TPkiAcmeQueueServiceFactory, "queueChallengeValidation">;
}; };
@@ -140,6 +147,7 @@ export const pkiAcmeServiceFactory = ({
certificateProfileDAL, certificateProfileDAL,
certificateBodyDAL, certificateBodyDAL,
certificateSecretDAL, certificateSecretDAL,
certificateTemplateV2DAL,
acmeAccountDAL, acmeAccountDAL,
acmeOrderDAL, acmeOrderDAL,
acmeAuthDAL, acmeAuthDAL,
@@ -149,6 +157,7 @@ export const pkiAcmeServiceFactory = ({
kmsService, kmsService,
licenseService, licenseService,
certificateV3Service, certificateV3Service,
certificateTemplateV2Service,
acmeChallengeService, acmeChallengeService,
pkiAcmeQueueService pkiAcmeQueueService
}: TPkiAcmeServiceFactoryDep): TPkiAcmeServiceFactory => { }: TPkiAcmeServiceFactoryDep): TPkiAcmeServiceFactory => {
@@ -772,8 +781,31 @@ export const pkiAcmeServiceFactory = ({
const { certificateAuthority } = (await certificateProfileDAL.findByIdWithConfigs(profileId, tx))!; const { certificateAuthority } = (await certificateProfileDAL.findByIdWithConfigs(profileId, tx))!;
const csrObj = new x509.Pkcs10CertificateRequest(csr); const csrObj = new x509.Pkcs10CertificateRequest(csr);
const csrPem = csrObj.toString("pem"); const csrPem = csrObj.toString("pem");
// TODO: for internal CA, we rely on the internal certificate authority service to check CSR against the template
// we should check the CSR against the template here const { keyAlgorithm: extractedKeyAlgorithm, signatureAlgorithm: extractedSignatureAlgorithm } =
extractAlgorithmsFromCSR(csr);
certificateRequest.keyAlgorithm = extractedKeyAlgorithm;
certificateRequest.signatureAlgorithm = extractedSignatureAlgorithm;
if (finalizingOrder.notAfter) {
const notBefore = finalizingOrder.notBefore ? new Date(finalizingOrder.notBefore) : new Date();
const notAfter = new Date(finalizingOrder.notAfter);
const diffMs = notAfter.getTime() - notBefore.getTime();
const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24));
certificateRequest.validity = { ttl: `${diffDays}d` };
}
const template = await certificateTemplateV2DAL.findById(profile.certificateTemplateId);
if (!template) {
throw new NotFoundError({ message: "Certificate template not found" });
}
const validationResult = await certificateTemplateV2Service.validateCertificateRequest(
template.id,
certificateRequest
);
if (!validationResult.isValid) {
throw new AcmeBadCSRError({ message: `Invalid CSR: ${validationResult.errors.join(", ")}` });
}
// TODO: this is pretty slow, and we are holding the transaction open for a long time, // TODO: this is pretty slow, and we are holding the transaction open for a long time,
// we should queue the certificate issuance to a background job instead // we should queue the certificate issuance to a background job instead
const cert = await orderCertificate( const cert = await orderCertificate(
@@ -824,6 +856,8 @@ export const pkiAcmeServiceFactory = ({
// TODO: audit log the error // TODO: audit log the error
if (exp instanceof BadRequestError) { if (exp instanceof BadRequestError) {
errorToReturn = new AcmeBadCSRError({ message: `Invalid CSR: ${exp.message}` }); errorToReturn = new AcmeBadCSRError({ message: `Invalid CSR: ${exp.message}` });
} else if (exp instanceof AcmeError) {
errorToReturn = exp;
} else { } else {
errorToReturn = new AcmeServerInternalError({ message: "Failed to sign certificate with internal error" }); errorToReturn = new AcmeServerInternalError({ message: "Failed to sign certificate with internal error" });
} }

View File

@@ -2301,6 +2301,7 @@ export const registerRoutes = async (
certificateProfileDAL, certificateProfileDAL,
certificateBodyDAL, certificateBodyDAL,
certificateSecretDAL, certificateSecretDAL,
certificateTemplateV2DAL,
acmeAccountDAL, acmeAccountDAL,
acmeOrderDAL, acmeOrderDAL,
acmeAuthDAL, acmeAuthDAL,
@@ -2310,6 +2311,7 @@ export const registerRoutes = async (
kmsService, kmsService,
licenseService, licenseService,
certificateV3Service, certificateV3Service,
certificateTemplateV2Service,
acmeChallengeService, acmeChallengeService,
pkiAcmeQueueService pkiAcmeQueueService
}); });