diff --git a/backend/bdd/features/pki/acme/challenge.feature b/backend/bdd/features/pki/acme/challenge.feature index 80f6fed6cb..d02eabe817 100644 --- a/backend/bdd/features/pki/acme/challenge.feature +++ b/backend/bdd/features/pki/acme/challenge.feature @@ -192,3 +192,28 @@ Feature: Challenge And the value response with jq ".status" should be equal to 400 And the value response with jq ".type" should be equal to "urn:ietf:params:acme:error:badCSR" And the value response with jq ".detail" should be equal to "Invalid CSR: Common name + SANs mismatch with order identifiers" + + Scenario: Get certificate without passing challenge when skip DNS ownership verification is enabled + Given I create an ACME profile with config as "acme_profile" + """ + { + "skipDnsOwnershipVerification": true + } + """ + 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 + """ + { + "COMMON_NAME": "localhost" + } + """ + 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 the value order.body with jq ".status" should be equal to "ready" + And I poll and finalize the ACME order order as finalized_order + And the value finalized_order.body with jq ".status" should be equal to "valid" + And I parse the full-chain certificate from order finalized_order as cert + And the value cert with jq ".subject.common_name" should be equal to "localhost" diff --git a/backend/bdd/features/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py index c0b2fee8f0..1ce839638f 100644 --- a/backend/bdd/features/steps/pki_acme.py +++ b/backend/bdd/features/steps/pki_acme.py @@ -266,6 +266,46 @@ def step_impl(context: Context, ca_id: str, template_id: str, profile_var: str): ) +@given( + 'I create an ACME profile with config as "{profile_var}"' +) +def step_impl(context: Context, profile_var: str): + profile_slug = faker.slug() + jwt_token = context.vars["AUTH_TOKEN"] + acme_config = replace_vars(json.loads(context.text), context.vars) + response = context.http_client.post( + "/api/v1/cert-manager/certificate-profiles", + headers=dict(authorization="Bearer {}".format(jwt_token)), + json={ + "projectId": context.vars["PROJECT_ID"], + "slug": profile_slug, + "description": "ACME Profile created by BDD test", + "enrollmentType": "acme", + "caId": context.vars["CERT_CA_ID"], + "certificateTemplateId": context.vars["CERT_TEMPLATE_ID"], + "acmeConfig": acme_config, + }, + ) + response.raise_for_status() + resp_json = response.json() + profile_id = resp_json["certificateProfile"]["id"] + kid = profile_id + + response = context.http_client.get( + f"/api/v1/cert-manager/certificate-profiles/{profile_id}/acme/eab-secret/reveal", + headers=dict(authorization="Bearer {}".format(jwt_token)), + ) + response.raise_for_status() + resp_json = response.json() + secret = resp_json["eabSecret"] + + context.vars[profile_var] = AcmeProfile( + profile_id, + eab_kid=kid, + eab_secret=secret, + ) + + @given('I have an ACME cert profile with external ACME CA as "{profile_var}"') def step_impl(context: Context, profile_var: str): profile_id = context.vars.get("PROFILE_ID") diff --git a/backend/src/db/migrations/20251210113242_add-skip-dns-ownership-verification-to-acme-config.ts b/backend/src/db/migrations/20251210113242_add-skip-dns-ownership-verification-to-acme-config.ts new file mode 100644 index 0000000000..6ae5b40397 --- /dev/null +++ b/backend/src/db/migrations/20251210113242_add-skip-dns-ownership-verification-to-acme-config.ts @@ -0,0 +1,23 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.PkiAcmeEnrollmentConfig)) { + if (!(await knex.schema.hasColumn(TableName.PkiAcmeEnrollmentConfig, "skipDnsOwnershipVerification"))) { + await knex.schema.alterTable(TableName.PkiAcmeEnrollmentConfig, (t) => { + t.boolean("skipDnsOwnershipVerification").defaultTo(false).notNullable(); + }); + } + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.PkiAcmeEnrollmentConfig)) { + if (await knex.schema.hasColumn(TableName.PkiAcmeEnrollmentConfig, "skipDnsOwnershipVerification")) { + await knex.schema.alterTable(TableName.PkiAcmeEnrollmentConfig, (t) => { + t.dropColumn("skipDnsOwnershipVerification"); + }); + } + } +} diff --git a/backend/src/db/schemas/pki-acme-enrollment-configs.ts b/backend/src/db/schemas/pki-acme-enrollment-configs.ts index f0592319bf..4f4460986f 100644 --- a/backend/src/db/schemas/pki-acme-enrollment-configs.ts +++ b/backend/src/db/schemas/pki-acme-enrollment-configs.ts @@ -13,7 +13,8 @@ export const PkiAcmeEnrollmentConfigsSchema = z.object({ id: z.string().uuid(), encryptedEabSecret: zodBuffer, createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + skipDnsOwnershipVerification: z.boolean().default(false) }); export type TPkiAcmeEnrollmentConfigs = z.infer; 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 d9654e50b4..c86f0efd12 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -567,6 +567,8 @@ export const pkiAcmeServiceFactory = ({ accountId: string; payload: TCreateAcmeOrderPayload; }): Promise> => { + const profile = await validateAcmeProfile(profileId); + const skipDnsOwnershipVerification = profile.acmeConfig?.skipDnsOwnershipVerification ?? false; // TODO: check and see if we have existing orders for this account that meet the criteria // if we do, return the existing order // TODO: check the identifiers and see if are they even allowed for this profile. @@ -592,7 +594,7 @@ export const pkiAcmeServiceFactory = ({ const createdOrder = await acmeOrderDAL.create( { accountId: account.id, - status: AcmeOrderStatus.Pending, + status: skipDnsOwnershipVerification ? AcmeOrderStatus.Ready : AcmeOrderStatus.Pending, notBefore: payload.notBefore ? new Date(payload.notBefore) : undefined, notAfter: payload.notAfter ? new Date(payload.notAfter) : undefined, // TODO: read config from the profile to get the expiration time instead @@ -611,7 +613,7 @@ export const pkiAcmeServiceFactory = ({ const auth = await acmeAuthDAL.create( { accountId: account.id, - status: AcmeAuthStatus.Pending, + status: skipDnsOwnershipVerification ? AcmeAuthStatus.Valid : AcmeAuthStatus.Pending, identifierType: identifier.type, identifierValue: identifier.value, // RFC 8555 suggests a token with at least 128 bits of entropy @@ -623,15 +625,17 @@ export const pkiAcmeServiceFactory = ({ }, tx ); - // TODO: support other challenge types here. Currently only HTTP-01 is supported. - await acmeChallengeDAL.create( - { - authId: auth.id, - status: AcmeChallengeStatus.Pending, - type: AcmeChallengeType.HTTP_01 - }, - tx - ); + if (!skipDnsOwnershipVerification) { + // TODO: support other challenge types here. Currently only HTTP-01 is supported. + await acmeChallengeDAL.create( + { + authId: auth.id, + status: AcmeChallengeStatus.Pending, + type: AcmeChallengeType.HTTP_01 + }, + tx + ); + } return auth; }) ); diff --git a/backend/src/server/routes/v1/certificate-profiles-router.ts b/backend/src/server/routes/v1/certificate-profiles-router.ts index ff770326d6..a3402bd858 100644 --- a/backend/src/server/routes/v1/certificate-profiles-router.ts +++ b/backend/src/server/routes/v1/certificate-profiles-router.ts @@ -47,7 +47,11 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid renewBeforeDays: z.number().min(1).max(30).optional() }) .optional(), - acmeConfig: z.object({}).optional(), + acmeConfig: z + .object({ + skipDnsOwnershipVerification: z.boolean().optional() + }) + .optional(), externalConfigs: ExternalConfigUnionSchema }) .refine( @@ -245,7 +249,8 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid acmeConfig: z .object({ id: z.string(), - directoryUrl: z.string() + directoryUrl: z.string(), + skipDnsOwnershipVerification: z.boolean().optional() }) .optional(), externalConfigs: ExternalConfigUnionSchema @@ -434,6 +439,11 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid renewBeforeDays: z.number().min(1).max(30).optional() }) .optional(), + acmeConfig: z + .object({ + skipDnsOwnershipVerification: z.boolean().optional() + }) + .optional(), externalConfigs: ExternalConfigUnionSchema }) .refine( diff --git a/backend/src/services/certificate-profile/certificate-profile-dal.ts b/backend/src/services/certificate-profile/certificate-profile-dal.ts index 3a8f99f1ae..1572746830 100644 --- a/backend/src/services/certificate-profile/certificate-profile-dal.ts +++ b/backend/src/services/certificate-profile/certificate-profile-dal.ts @@ -168,7 +168,11 @@ export const certificateProfileDALFactory = (db: TDbClient) => { db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigAutoRenew"), db.ref("renewBeforeDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigRenewBeforeDays"), db.ref("id").withSchema(TableName.PkiAcmeEnrollmentConfig).as("acmeConfigId"), - db.ref("encryptedEabSecret").withSchema(TableName.PkiAcmeEnrollmentConfig).as("acmeConfigEncryptedEabSecret") + db.ref("encryptedEabSecret").withSchema(TableName.PkiAcmeEnrollmentConfig).as("acmeConfigEncryptedEabSecret"), + db + .ref("skipDnsOwnershipVerification") + .withSchema(TableName.PkiAcmeEnrollmentConfig) + .as("acmeConfigSkipDnsOwnershipVerification") ) .where(`${TableName.PkiCertificateProfile}.id`, id) .first(); @@ -198,7 +202,8 @@ export const certificateProfileDALFactory = (db: TDbClient) => { const acmeConfig = result.acmeConfigId ? ({ id: result.acmeConfigId, - encryptedEabSecret: result.acmeConfigEncryptedEabSecret + encryptedEabSecret: result.acmeConfigEncryptedEabSecret, + skipDnsOwnershipVerification: result.acmeConfigSkipDnsOwnershipVerification ?? false } as TCertificateProfileWithConfigs["acmeConfig"]) : undefined; @@ -356,7 +361,11 @@ export const certificateProfileDALFactory = (db: TDbClient) => { db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiId"), db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiAutoRenew"), db.ref("renewBeforeDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiRenewBeforeDays"), - db.ref("id").withSchema(TableName.PkiAcmeEnrollmentConfig).as("acmeId") + db.ref("id").withSchema(TableName.PkiAcmeEnrollmentConfig).as("acmeId"), + db + .ref("skipDnsOwnershipVerification") + .withSchema(TableName.PkiAcmeEnrollmentConfig) + .as("acmeSkipDnsOwnershipVerification") ); if (processedRules) { @@ -393,7 +402,8 @@ export const certificateProfileDALFactory = (db: TDbClient) => { const acmeConfig = result.acmeId ? { - id: result.acmeId as string + id: result.acmeId as string, + skipDnsOwnershipVerification: !!result.acmeSkipDnsOwnershipVerification } : undefined; diff --git a/backend/src/services/certificate-profile/certificate-profile-schemas.ts b/backend/src/services/certificate-profile/certificate-profile-schemas.ts index e6b574dea5..3ac4def572 100644 --- a/backend/src/services/certificate-profile/certificate-profile-schemas.ts +++ b/backend/src/services/certificate-profile/certificate-profile-schemas.ts @@ -30,7 +30,11 @@ export const createCertificateProfileSchema = z renewBeforeDays: z.number().min(1).max(30).optional() }) .optional(), - acmeConfig: z.object({}).optional() + acmeConfig: z + .object({ + skipDnsOwnershipVerification: z.boolean().optional() + }) + .optional() }) .refine( (data) => { @@ -155,6 +159,11 @@ export const updateCertificateProfileSchema = z autoRenew: z.boolean().default(false), renewBeforeDays: z.number().min(1).max(30).optional() }) + .optional(), + acmeConfig: z + .object({ + skipDnsOwnershipVerification: z.boolean().optional() + }) .optional() }) .refine( diff --git a/backend/src/services/certificate-profile/certificate-profile-service.ts b/backend/src/services/certificate-profile/certificate-profile-service.ts index 59e3afbd99..4cb60a5229 100644 --- a/backend/src/services/certificate-profile/certificate-profile-service.ts +++ b/backend/src/services/certificate-profile/certificate-profile-service.ts @@ -403,7 +403,13 @@ export const certificateProfileServiceFactory = ({ apiConfigId = apiConfig.id; } else if (data.enrollmentType === EnrollmentType.ACME && data.acmeConfig) { const { encryptedEabSecret } = await generateAndEncryptAcmeEabSecret(projectId, kmsService, projectDAL); - const acmeConfig = await acmeEnrollmentConfigDAL.create({ encryptedEabSecret }, tx); + const acmeConfig = await acmeEnrollmentConfigDAL.create( + { + skipDnsOwnershipVerification: data.acmeConfig.skipDnsOwnershipVerification ?? false, + encryptedEabSecret + }, + tx + ); acmeConfigId = acmeConfig.id; } @@ -505,7 +511,7 @@ export const certificateProfileServiceFactory = ({ const updatedData = finalIssuerType === IssuerType.SELF_SIGNED && existingProfile.caId ? { ...data, caId: null } : data; - const { estConfig, apiConfig, ...profileUpdateData } = updatedData; + const { estConfig, apiConfig, acmeConfig, ...profileUpdateData } = updatedData; const updatedProfile = await certificateProfileDAL.transaction(async (tx) => { if (estConfig && existingProfile.estConfigId) { @@ -547,6 +553,16 @@ export const certificateProfileServiceFactory = ({ ); } + if (acmeConfig && existingProfile.acmeConfigId) { + await acmeEnrollmentConfigDAL.updateById( + existingProfile.acmeConfigId, + { + skipDnsOwnershipVerification: acmeConfig.skipDnsOwnershipVerification ?? false + }, + tx + ); + } + const profileResult = await certificateProfileDAL.updateById(profileId, profileUpdateData, tx); return profileResult; }); diff --git a/backend/src/services/certificate-profile/certificate-profile-types.ts b/backend/src/services/certificate-profile/certificate-profile-types.ts index 3eca249cd0..5b1d62387f 100644 --- a/backend/src/services/certificate-profile/certificate-profile-types.ts +++ b/backend/src/services/certificate-profile/certificate-profile-types.ts @@ -46,7 +46,9 @@ export type TCertificateProfileUpdate = Omit< autoRenew?: boolean; renewBeforeDays?: number; }; - acmeConfig?: unknown; + acmeConfig?: { + skipDnsOwnershipVerification?: boolean; + }; }; export type TCertificateProfileWithConfigs = TCertificateProfile & { @@ -83,6 +85,7 @@ export type TCertificateProfileWithConfigs = TCertificateProfile & { id: string; directoryUrl: string; encryptedEabSecret?: Buffer; + skipDnsOwnershipVerification?: boolean; }; }; diff --git a/backend/src/services/enrollment-config/acme-enrollment-config-dal.ts b/backend/src/services/enrollment-config/acme-enrollment-config-dal.ts index afa8f17ef5..758c08491d 100644 --- a/backend/src/services/enrollment-config/acme-enrollment-config-dal.ts +++ b/backend/src/services/enrollment-config/acme-enrollment-config-dal.ts @@ -1,61 +1,13 @@ -import { Knex } from "knex"; - import { TDbClient } from "@app/db"; import { TableName } from "@app/db/schemas"; -import { DatabaseError } from "@app/lib/errors"; import { ormify } from "@app/lib/knex"; -import { TAcmeEnrollmentConfigInsert, TAcmeEnrollmentConfigUpdate } from "./enrollment-config-types"; - export type TAcmeEnrollmentConfigDALFactory = ReturnType; export const acmeEnrollmentConfigDALFactory = (db: TDbClient) => { const acmeEnrollmentConfigOrm = ormify(db, TableName.PkiAcmeEnrollmentConfig); - const create = async (data: TAcmeEnrollmentConfigInsert, tx?: Knex) => { - try { - const result = await (tx || db)(TableName.PkiAcmeEnrollmentConfig).insert(data).returning("*"); - const [acmeConfig] = result; - - if (!acmeConfig) { - throw new Error("Failed to create ACME enrollment config"); - } - - return acmeConfig; - } catch (error) { - throw new DatabaseError({ error, name: "Create ACME enrollment config" }); - } - }; - - const updateById = async (id: string, data: TAcmeEnrollmentConfigUpdate, tx?: Knex) => { - try { - const result = await (tx || db)(TableName.PkiAcmeEnrollmentConfig).where({ id }).update(data).returning("*"); - const [acmeConfig] = result; - - if (!acmeConfig) { - return null; - } - - return acmeConfig; - } catch (error) { - throw new DatabaseError({ error, name: "Update ACME enrollment config" }); - } - }; - - const findById = async (id: string, tx?: Knex) => { - try { - const acmeConfig = await (tx || db)(TableName.PkiAcmeEnrollmentConfig).where({ id }).first(); - - return acmeConfig || null; - } catch (error) { - throw new DatabaseError({ error, name: "Find ACME enrollment config by id" }); - } - }; - return { - ...acmeEnrollmentConfigOrm, - create, - updateById, - findById + ...acmeEnrollmentConfigOrm }; }; diff --git a/backend/src/services/enrollment-config/enrollment-config-types.ts b/backend/src/services/enrollment-config/enrollment-config-types.ts index 7fe5a475d0..2ea68faa7f 100644 --- a/backend/src/services/enrollment-config/enrollment-config-types.ts +++ b/backend/src/services/enrollment-config/enrollment-config-types.ts @@ -37,4 +37,6 @@ export interface TApiConfigData { renewBeforeDays?: number; } -export interface TAcmeConfigData {} +export interface TAcmeConfigData { + skipDnsOwnershipVerification?: boolean; +} diff --git a/frontend/src/hooks/api/certificateProfiles/types.ts b/frontend/src/hooks/api/certificateProfiles/types.ts index a4f6236595..e79c384d31 100644 --- a/frontend/src/hooks/api/certificateProfiles/types.ts +++ b/frontend/src/hooks/api/certificateProfiles/types.ts @@ -62,6 +62,7 @@ export type TCertificateProfileWithDetails = TCertificateProfile & { acmeConfig?: { id: string; directoryUrl: string; + skipDnsOwnershipVerification?: boolean; }; }; diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx index 138e60de3a..43de8c6b23 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx @@ -79,7 +79,11 @@ const createSchema = z renewBeforeDays: z.number().min(1).max(365).optional() }) .optional(), - acmeConfig: z.object({}).optional(), + acmeConfig: z + .object({ + skipDnsOwnershipVerification: z.boolean().optional() + }) + .optional(), externalConfigs: z .object({ template: z.string().min(1, "Azure ADCS template is required") @@ -219,7 +223,11 @@ const editSchema = z renewBeforeDays: z.number().min(1).max(365).optional() }) .optional(), - acmeConfig: z.object({}).optional(), + acmeConfig: z + .object({ + skipDnsOwnershipVerification: z.boolean().optional() + }) + .optional(), externalConfigs: z .object({ template: z.string().optional() @@ -406,7 +414,13 @@ export const CreateProfileModal = ({ renewBeforeDays: profile.apiConfig?.renewBeforeDays || 30 } : undefined, - acmeConfig: profile.enrollmentType === EnrollmentType.ACME ? {} : undefined, + acmeConfig: + profile.enrollmentType === EnrollmentType.ACME + ? { + skipDnsOwnershipVerification: + profile.acmeConfig?.skipDnsOwnershipVerification || false + } + : undefined, externalConfigs: profile.externalConfigs ? { template: @@ -429,7 +443,9 @@ export const CreateProfileModal = ({ autoRenew: false, renewBeforeDays: 30 }, - acmeConfig: {}, + acmeConfig: { + skipDnsOwnershipVerification: false + }, externalConfigs: undefined } }); @@ -476,7 +492,13 @@ export const CreateProfileModal = ({ renewBeforeDays: profile.apiConfig?.renewBeforeDays || 30 } : undefined, - acmeConfig: profile.enrollmentType === EnrollmentType.ACME ? {} : undefined, + acmeConfig: + profile.enrollmentType === EnrollmentType.ACME + ? { + skipDnsOwnershipVerification: + profile.acmeConfig?.skipDnsOwnershipVerification || false + } + : undefined, externalConfigs: profile.externalConfigs ? { template: @@ -667,7 +689,9 @@ export const CreateProfileModal = ({ renewBeforeDays: 30 }); setValue("estConfig", undefined); - setValue("acmeConfig", undefined); + setValue("acmeConfig", { + skipDnsOwnershipVerification: false + }); } onChange(value); }} @@ -797,7 +821,9 @@ export const CreateProfileModal = ({ } else if (watchedEnrollmentType === "acme") { setValue("estConfig", undefined); setValue("apiConfig", undefined); - setValue("acmeConfig", {}); + setValue("acmeConfig", { + skipDnsOwnershipVerification: false + }); } onChange(value); }} @@ -846,7 +872,9 @@ export const CreateProfileModal = ({ } else if (value === "acme") { setValue("apiConfig", undefined); setValue("estConfig", undefined); - setValue("acmeConfig", {}); + setValue("acmeConfig", { + skipDnsOwnershipVerification: false + }); } onChange(value); }} @@ -975,10 +1003,24 @@ export const CreateProfileModal = ({
( + name="acmeConfig.skipDnsOwnershipVerification" + render={({ field: { value, onChange }, fieldState: { error } }) => ( -
{/* FIXME: ACME configuration */}
+
+ +
+ + Skip DNS Ownership Validation + +

+ Skip DNS ownership verification during ACME certificate issuance. +

+
+
)} />