diff --git a/backend/bdd/features/pki/acme/external-ca.feature b/backend/bdd/features/pki/acme/external-ca.feature index 2a900dd10..5a2cef0cc 100644 --- a/backend/bdd/features/pki/acme/external-ca.feature +++ b/backend/bdd/features/pki/acme/external-ca.feature @@ -369,3 +369,349 @@ Feature: External CA | subject | | {"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 + """ + + """ + Then I add subject alternative name to certificate signing request csr + """ + + """ + 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 "" + + 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": [ + "" + ], + "keyAlgorithm": [ + "" + ] + }, + "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 private key pair as cert_key + And I sign the certificate signing request csr with "" 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 "" + + 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 | diff --git a/backend/bdd/features/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py index 8f0661c71..c0b2fee8f 100644 --- a/backend/bdd/features/steps/pki_acme.py +++ b/backend/bdd/features/steps/pki_acme.py @@ -20,6 +20,10 @@ from josepy.jwk import JWKRSA from josepy import json_util from cryptography.hazmat.primitives import serialization 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.x509.oid import NameOID 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 step_impl(context: Context, rsa_key_var: str): - context.vars[rsa_key_var] = rsa.generate_private_key( - # TODO: make them configurable if we need to - public_exponent=65537, - key_size=2048, +def gen_private_key(key_type: str): + if key_type == "RSA-2048" or key_type == "RSA": + return rsa.generate_private_key( + public_exponent=65537, + 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" ) def step_impl(context: Context, csr_var: str, pk_var: str, pem_var: str): - context.vars[pem_var] = ( - context.vars[csr_var] - .sign(context.vars[pk_var], hashes.SHA256()) - .public_bytes(serialization.Encoding.PEM) + context.vars[pem_var] = sign_csr( + pem=context.vars[csr_var], + pk=context.vars[pk_var], ) @@ -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): order = eval_var(context, var_path, as_json=False) acme_client = context.acme_client - finalized_order = acme_client.poll_and_finalize(order) - context.vars[finalized_var] = finalized_order + try: + 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}") diff --git a/backend/src/ee/services/pki-acme/pki-acme-service.ts b/backend/src/ee/services/pki-acme/pki-acme-service.ts index 45973845b..d9654e50b 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -31,12 +31,17 @@ import { orderCertificate } from "@app/services/certificate-authority/acme/acme- import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal"; import { CaType } from "@app/services/certificate-authority/certificate-authority-enums"; 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 { EnrollmentType, TCertificateProfileWithConfigs } 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 { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TProjectDALFactory } from "@app/services/project/project-dal"; @@ -101,6 +106,7 @@ type TPkiAcmeServiceFactoryDep = { certificateProfileDAL: Pick; certificateBodyDAL: Pick; certificateSecretDAL: Pick; + certificateTemplateV2DAL: Pick; acmeAccountDAL: Pick< TPkiAcmeAccountDALFactory, "findByProjectIdAndAccountId" | "findByProfileIdAndPublicKeyThumbprintAndAlg" | "create" @@ -127,6 +133,7 @@ type TPkiAcmeServiceFactoryDep = { >; licenseService: Pick; certificateV3Service: Pick; + certificateTemplateV2Service: Pick; acmeChallengeService: Pick; pkiAcmeQueueService: Pick; }; @@ -140,6 +147,7 @@ export const pkiAcmeServiceFactory = ({ certificateProfileDAL, certificateBodyDAL, certificateSecretDAL, + certificateTemplateV2DAL, acmeAccountDAL, acmeOrderDAL, acmeAuthDAL, @@ -149,6 +157,7 @@ export const pkiAcmeServiceFactory = ({ kmsService, licenseService, certificateV3Service, + certificateTemplateV2Service, acmeChallengeService, pkiAcmeQueueService }: TPkiAcmeServiceFactoryDep): TPkiAcmeServiceFactory => { @@ -772,8 +781,31 @@ export const pkiAcmeServiceFactory = ({ const { certificateAuthority } = (await certificateProfileDAL.findByIdWithConfigs(profileId, tx))!; const csrObj = new x509.Pkcs10CertificateRequest(csr); 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, // we should queue the certificate issuance to a background job instead const cert = await orderCertificate( @@ -824,6 +856,8 @@ export const pkiAcmeServiceFactory = ({ // TODO: audit log the error if (exp instanceof BadRequestError) { errorToReturn = new AcmeBadCSRError({ message: `Invalid CSR: ${exp.message}` }); + } else if (exp instanceof AcmeError) { + errorToReturn = exp; } else { errorToReturn = new AcmeServerInternalError({ message: "Failed to sign certificate with internal error" }); } diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 31c3cbf4e..914491d3c 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -2301,6 +2301,7 @@ export const registerRoutes = async ( certificateProfileDAL, certificateBodyDAL, certificateSecretDAL, + certificateTemplateV2DAL, acmeAccountDAL, acmeOrderDAL, acmeAuthDAL, @@ -2310,6 +2311,7 @@ export const registerRoutes = async ( kmsService, licenseService, certificateV3Service, + certificateTemplateV2Service, acmeChallengeService, pkiAcmeQueueService });