Merge pull request #5021 from Infisical/PKI-54-optional-acme-challenge

feature: add skip dns ownership validation option for acme cert profile
This commit is contained in:
Fang-Pen Lin
2025-12-11 09:17:14 -08:00
committed by GitHub
14 changed files with 221 additions and 83 deletions

View File

@@ -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"

View File

@@ -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")

View File

@@ -0,0 +1,23 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
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<void> {
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");
});
}
}
}

View File

@@ -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<typeof PkiAcmeEnrollmentConfigsSchema>;

View File

@@ -567,6 +567,8 @@ export const pkiAcmeServiceFactory = ({
accountId: string;
payload: TCreateAcmeOrderPayload;
}): Promise<TAcmeResponse<TAcmeOrderResource>> => {
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;
})
);

View File

@@ -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(

View File

@@ -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;

View File

@@ -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(

View File

@@ -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;
});

View File

@@ -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;
};
};

View File

@@ -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<typeof acmeEnrollmentConfigDALFactory>;
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
};
};

View File

@@ -37,4 +37,6 @@ export interface TApiConfigData {
renewBeforeDays?: number;
}
export interface TAcmeConfigData {}
export interface TAcmeConfigData {
skipDnsOwnershipVerification?: boolean;
}

View File

@@ -62,6 +62,7 @@ export type TCertificateProfileWithDetails = TCertificateProfile & {
acmeConfig?: {
id: string;
directoryUrl: string;
skipDnsOwnershipVerification?: boolean;
};
};

View File

@@ -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 = ({
<div className="mb-4 space-y-4">
<Controller
control={control}
name="acmeConfig"
render={({ fieldState: { error } }) => (
name="acmeConfig.skipDnsOwnershipVerification"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<div className="flex items-center gap-2">{/* FIXME: ACME configuration */}</div>
<div className="flex items-center gap-3 rounded-md border border-mineshaft-600 bg-mineshaft-900 p-4">
<Checkbox
id="skipDnsOwnershipVerification"
isChecked={value || false}
onCheckedChange={onChange}
/>
<div className="space-y-1">
<span className="text-sm font-medium text-mineshaft-100">
Skip DNS Ownership Validation
</span>
<p className="text-xs text-bunker-300">
Skip DNS ownership verification during ACME certificate issuance.
</p>
</div>
</div>
</FormControl>
)}
/>