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 ".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 ".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" 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}"') @given('I have an ACME cert profile with external ACME CA as "{profile_var}"')
def step_impl(context: Context, profile_var: str): def step_impl(context: Context, profile_var: str):
profile_id = context.vars.get("PROFILE_ID") 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(), id: z.string().uuid(),
encryptedEabSecret: zodBuffer, encryptedEabSecret: zodBuffer,
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date() updatedAt: z.date(),
skipDnsOwnershipVerification: z.boolean().default(false)
}); });
export type TPkiAcmeEnrollmentConfigs = z.infer<typeof PkiAcmeEnrollmentConfigsSchema>; export type TPkiAcmeEnrollmentConfigs = z.infer<typeof PkiAcmeEnrollmentConfigsSchema>;

View File

@@ -567,6 +567,8 @@ export const pkiAcmeServiceFactory = ({
accountId: string; accountId: string;
payload: TCreateAcmeOrderPayload; payload: TCreateAcmeOrderPayload;
}): Promise<TAcmeResponse<TAcmeOrderResource>> => { }): 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 // TODO: check and see if we have existing orders for this account that meet the criteria
// if we do, return the existing order // if we do, return the existing order
// TODO: check the identifiers and see if are they even allowed for this profile. // 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( const createdOrder = await acmeOrderDAL.create(
{ {
accountId: account.id, accountId: account.id,
status: AcmeOrderStatus.Pending, status: skipDnsOwnershipVerification ? AcmeOrderStatus.Ready : AcmeOrderStatus.Pending,
notBefore: payload.notBefore ? new Date(payload.notBefore) : undefined, notBefore: payload.notBefore ? new Date(payload.notBefore) : undefined,
notAfter: payload.notAfter ? new Date(payload.notAfter) : undefined, notAfter: payload.notAfter ? new Date(payload.notAfter) : undefined,
// TODO: read config from the profile to get the expiration time instead // TODO: read config from the profile to get the expiration time instead
@@ -611,7 +613,7 @@ export const pkiAcmeServiceFactory = ({
const auth = await acmeAuthDAL.create( const auth = await acmeAuthDAL.create(
{ {
accountId: account.id, accountId: account.id,
status: AcmeAuthStatus.Pending, status: skipDnsOwnershipVerification ? AcmeAuthStatus.Valid : AcmeAuthStatus.Pending,
identifierType: identifier.type, identifierType: identifier.type,
identifierValue: identifier.value, identifierValue: identifier.value,
// RFC 8555 suggests a token with at least 128 bits of entropy // RFC 8555 suggests a token with at least 128 bits of entropy
@@ -623,15 +625,17 @@ export const pkiAcmeServiceFactory = ({
}, },
tx tx
); );
// TODO: support other challenge types here. Currently only HTTP-01 is supported. if (!skipDnsOwnershipVerification) {
await acmeChallengeDAL.create( // TODO: support other challenge types here. Currently only HTTP-01 is supported.
{ await acmeChallengeDAL.create(
authId: auth.id, {
status: AcmeChallengeStatus.Pending, authId: auth.id,
type: AcmeChallengeType.HTTP_01 status: AcmeChallengeStatus.Pending,
}, type: AcmeChallengeType.HTTP_01
tx },
); tx
);
}
return auth; return auth;
}) })
); );

View File

@@ -47,7 +47,11 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
renewBeforeDays: z.number().min(1).max(30).optional() renewBeforeDays: z.number().min(1).max(30).optional()
}) })
.optional(), .optional(),
acmeConfig: z.object({}).optional(), acmeConfig: z
.object({
skipDnsOwnershipVerification: z.boolean().optional()
})
.optional(),
externalConfigs: ExternalConfigUnionSchema externalConfigs: ExternalConfigUnionSchema
}) })
.refine( .refine(
@@ -245,7 +249,8 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
acmeConfig: z acmeConfig: z
.object({ .object({
id: z.string(), id: z.string(),
directoryUrl: z.string() directoryUrl: z.string(),
skipDnsOwnershipVerification: z.boolean().optional()
}) })
.optional(), .optional(),
externalConfigs: ExternalConfigUnionSchema externalConfigs: ExternalConfigUnionSchema
@@ -434,6 +439,11 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
renewBeforeDays: z.number().min(1).max(30).optional() renewBeforeDays: z.number().min(1).max(30).optional()
}) })
.optional(), .optional(),
acmeConfig: z
.object({
skipDnsOwnershipVerification: z.boolean().optional()
})
.optional(),
externalConfigs: ExternalConfigUnionSchema externalConfigs: ExternalConfigUnionSchema
}) })
.refine( .refine(

View File

@@ -168,7 +168,11 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigAutoRenew"), db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigAutoRenew"),
db.ref("renewBeforeDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigRenewBeforeDays"), db.ref("renewBeforeDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigRenewBeforeDays"),
db.ref("id").withSchema(TableName.PkiAcmeEnrollmentConfig).as("acmeConfigId"), 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) .where(`${TableName.PkiCertificateProfile}.id`, id)
.first(); .first();
@@ -198,7 +202,8 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
const acmeConfig = result.acmeConfigId const acmeConfig = result.acmeConfigId
? ({ ? ({
id: result.acmeConfigId, id: result.acmeConfigId,
encryptedEabSecret: result.acmeConfigEncryptedEabSecret encryptedEabSecret: result.acmeConfigEncryptedEabSecret,
skipDnsOwnershipVerification: result.acmeConfigSkipDnsOwnershipVerification ?? false
} as TCertificateProfileWithConfigs["acmeConfig"]) } as TCertificateProfileWithConfigs["acmeConfig"])
: undefined; : undefined;
@@ -356,7 +361,11 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiId"), db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiId"),
db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiAutoRenew"), db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiAutoRenew"),
db.ref("renewBeforeDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiRenewBeforeDays"), 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) { if (processedRules) {
@@ -393,7 +402,8 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
const acmeConfig = result.acmeId const acmeConfig = result.acmeId
? { ? {
id: result.acmeId as string id: result.acmeId as string,
skipDnsOwnershipVerification: !!result.acmeSkipDnsOwnershipVerification
} }
: undefined; : undefined;

View File

@@ -30,7 +30,11 @@ export const createCertificateProfileSchema = z
renewBeforeDays: z.number().min(1).max(30).optional() renewBeforeDays: z.number().min(1).max(30).optional()
}) })
.optional(), .optional(),
acmeConfig: z.object({}).optional() acmeConfig: z
.object({
skipDnsOwnershipVerification: z.boolean().optional()
})
.optional()
}) })
.refine( .refine(
(data) => { (data) => {
@@ -155,6 +159,11 @@ export const updateCertificateProfileSchema = z
autoRenew: z.boolean().default(false), autoRenew: z.boolean().default(false),
renewBeforeDays: z.number().min(1).max(30).optional() renewBeforeDays: z.number().min(1).max(30).optional()
}) })
.optional(),
acmeConfig: z
.object({
skipDnsOwnershipVerification: z.boolean().optional()
})
.optional() .optional()
}) })
.refine( .refine(

View File

@@ -403,7 +403,13 @@ export const certificateProfileServiceFactory = ({
apiConfigId = apiConfig.id; apiConfigId = apiConfig.id;
} else if (data.enrollmentType === EnrollmentType.ACME && data.acmeConfig) { } else if (data.enrollmentType === EnrollmentType.ACME && data.acmeConfig) {
const { encryptedEabSecret } = await generateAndEncryptAcmeEabSecret(projectId, kmsService, projectDAL); 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; acmeConfigId = acmeConfig.id;
} }
@@ -505,7 +511,7 @@ export const certificateProfileServiceFactory = ({
const updatedData = const updatedData =
finalIssuerType === IssuerType.SELF_SIGNED && existingProfile.caId ? { ...data, caId: null } : data; 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) => { const updatedProfile = await certificateProfileDAL.transaction(async (tx) => {
if (estConfig && existingProfile.estConfigId) { 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); const profileResult = await certificateProfileDAL.updateById(profileId, profileUpdateData, tx);
return profileResult; return profileResult;
}); });

View File

@@ -46,7 +46,9 @@ export type TCertificateProfileUpdate = Omit<
autoRenew?: boolean; autoRenew?: boolean;
renewBeforeDays?: number; renewBeforeDays?: number;
}; };
acmeConfig?: unknown; acmeConfig?: {
skipDnsOwnershipVerification?: boolean;
};
}; };
export type TCertificateProfileWithConfigs = TCertificateProfile & { export type TCertificateProfileWithConfigs = TCertificateProfile & {
@@ -83,6 +85,7 @@ export type TCertificateProfileWithConfigs = TCertificateProfile & {
id: string; id: string;
directoryUrl: string; directoryUrl: string;
encryptedEabSecret?: Buffer; encryptedEabSecret?: Buffer;
skipDnsOwnershipVerification?: boolean;
}; };
}; };

View File

@@ -1,61 +1,13 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db"; import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas"; import { TableName } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { ormify } from "@app/lib/knex"; import { ormify } from "@app/lib/knex";
import { TAcmeEnrollmentConfigInsert, TAcmeEnrollmentConfigUpdate } from "./enrollment-config-types";
export type TAcmeEnrollmentConfigDALFactory = ReturnType<typeof acmeEnrollmentConfigDALFactory>; export type TAcmeEnrollmentConfigDALFactory = ReturnType<typeof acmeEnrollmentConfigDALFactory>;
export const acmeEnrollmentConfigDALFactory = (db: TDbClient) => { export const acmeEnrollmentConfigDALFactory = (db: TDbClient) => {
const acmeEnrollmentConfigOrm = ormify(db, TableName.PkiAcmeEnrollmentConfig); 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 { return {
...acmeEnrollmentConfigOrm, ...acmeEnrollmentConfigOrm
create,
updateById,
findById
}; };
}; };

View File

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

View File

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

View File

@@ -79,7 +79,11 @@ const createSchema = z
renewBeforeDays: z.number().min(1).max(365).optional() renewBeforeDays: z.number().min(1).max(365).optional()
}) })
.optional(), .optional(),
acmeConfig: z.object({}).optional(), acmeConfig: z
.object({
skipDnsOwnershipVerification: z.boolean().optional()
})
.optional(),
externalConfigs: z externalConfigs: z
.object({ .object({
template: z.string().min(1, "Azure ADCS template is required") 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() renewBeforeDays: z.number().min(1).max(365).optional()
}) })
.optional(), .optional(),
acmeConfig: z.object({}).optional(), acmeConfig: z
.object({
skipDnsOwnershipVerification: z.boolean().optional()
})
.optional(),
externalConfigs: z externalConfigs: z
.object({ .object({
template: z.string().optional() template: z.string().optional()
@@ -406,7 +414,13 @@ export const CreateProfileModal = ({
renewBeforeDays: profile.apiConfig?.renewBeforeDays || 30 renewBeforeDays: profile.apiConfig?.renewBeforeDays || 30
} }
: undefined, : undefined,
acmeConfig: profile.enrollmentType === EnrollmentType.ACME ? {} : undefined, acmeConfig:
profile.enrollmentType === EnrollmentType.ACME
? {
skipDnsOwnershipVerification:
profile.acmeConfig?.skipDnsOwnershipVerification || false
}
: undefined,
externalConfigs: profile.externalConfigs externalConfigs: profile.externalConfigs
? { ? {
template: template:
@@ -429,7 +443,9 @@ export const CreateProfileModal = ({
autoRenew: false, autoRenew: false,
renewBeforeDays: 30 renewBeforeDays: 30
}, },
acmeConfig: {}, acmeConfig: {
skipDnsOwnershipVerification: false
},
externalConfigs: undefined externalConfigs: undefined
} }
}); });
@@ -476,7 +492,13 @@ export const CreateProfileModal = ({
renewBeforeDays: profile.apiConfig?.renewBeforeDays || 30 renewBeforeDays: profile.apiConfig?.renewBeforeDays || 30
} }
: undefined, : undefined,
acmeConfig: profile.enrollmentType === EnrollmentType.ACME ? {} : undefined, acmeConfig:
profile.enrollmentType === EnrollmentType.ACME
? {
skipDnsOwnershipVerification:
profile.acmeConfig?.skipDnsOwnershipVerification || false
}
: undefined,
externalConfigs: profile.externalConfigs externalConfigs: profile.externalConfigs
? { ? {
template: template:
@@ -667,7 +689,9 @@ export const CreateProfileModal = ({
renewBeforeDays: 30 renewBeforeDays: 30
}); });
setValue("estConfig", undefined); setValue("estConfig", undefined);
setValue("acmeConfig", undefined); setValue("acmeConfig", {
skipDnsOwnershipVerification: false
});
} }
onChange(value); onChange(value);
}} }}
@@ -797,7 +821,9 @@ export const CreateProfileModal = ({
} else if (watchedEnrollmentType === "acme") { } else if (watchedEnrollmentType === "acme") {
setValue("estConfig", undefined); setValue("estConfig", undefined);
setValue("apiConfig", undefined); setValue("apiConfig", undefined);
setValue("acmeConfig", {}); setValue("acmeConfig", {
skipDnsOwnershipVerification: false
});
} }
onChange(value); onChange(value);
}} }}
@@ -846,7 +872,9 @@ export const CreateProfileModal = ({
} else if (value === "acme") { } else if (value === "acme") {
setValue("apiConfig", undefined); setValue("apiConfig", undefined);
setValue("estConfig", undefined); setValue("estConfig", undefined);
setValue("acmeConfig", {}); setValue("acmeConfig", {
skipDnsOwnershipVerification: false
});
} }
onChange(value); onChange(value);
}} }}
@@ -975,10 +1003,24 @@ export const CreateProfileModal = ({
<div className="mb-4 space-y-4"> <div className="mb-4 space-y-4">
<Controller <Controller
control={control} control={control}
name="acmeConfig" name="acmeConfig.skipDnsOwnershipVerification"
render={({ fieldState: { error } }) => ( render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message}> <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> </FormControl>
)} )}
/> />