diff --git a/backend/src/db/migrations/20251029234547_add-pki-acme.ts b/backend/src/db/migrations/20251029234547_add-pki-acme.ts index aa334cfba..ed9353bce 100644 --- a/backend/src/db/migrations/20251029234547_add-pki-acme.ts +++ b/backend/src/db/migrations/20251029234547_add-pki-acme.ts @@ -10,7 +10,7 @@ import { dropConstraintIfExists } from "@app/db/migrations/utils/dropConstraintI const OLD_ENROLLMENT_TYPE_CHECK_CONSTRAINT = "pki_certificate_profiles_enrollmentType_check"; const NEW_ENROLLMENT_TYPE_CHECK_CONSTRAINT = "pki_certificate_profiles_enrollment_type_check"; -const PUBLIC_KEY_ALG_INDEX = "pki_acme_accounts_publicKey_alg_index"; +const PUBLIC_KEY_THUMBPRINT_ALG_INDEX = "pki_acme_accounts_publicKey_thumbprint_alg_index"; export async function up(knex: Knex): Promise { // Create PkiAcmeEnrollmentConfig table @@ -56,12 +56,14 @@ export async function up(knex: Knex): Promise { // Multi-value emails array t.specificType("emails", "text[]").notNullable(); - // TODO: make public key a string instead of jsonb to make indexing much easier // Public key (JWK format) t.jsonb("publicKey").notNullable(); + // Public key thumbprint + t.string("publicKeyThumbprint").notNullable(); // The JWS algorithm used to sign the public key when creating the account, e.g. "RS256", "ES256", "PS256", etc. t.string("alg").notNullable(); - t.index(["publicKey", "alg"], PUBLIC_KEY_ALG_INDEX); + // We may need to look up existing accounts by public key thumbprint and algorithm, so we index on both of them. + t.index(["publicKeyThumbprint", "alg"], PUBLIC_KEY_THUMBPRINT_ALG_INDEX); t.timestamps(true, true, true); }); diff --git a/backend/src/db/schemas/pki-acme-accounts.ts b/backend/src/db/schemas/pki-acme-accounts.ts index 275ff7e7e..69b1ffe49 100644 --- a/backend/src/db/schemas/pki-acme-accounts.ts +++ b/backend/src/db/schemas/pki-acme-accounts.ts @@ -12,6 +12,7 @@ export const PkiAcmeAccountsSchema = z.object({ profileId: z.string().uuid(), emails: z.string().array(), publicKey: z.unknown(), + publicKeyThumbprint: z.string(), alg: z.string(), createdAt: z.date(), updatedAt: z.date() diff --git a/backend/src/db/schemas/pki-acme-auths.ts b/backend/src/db/schemas/pki-acme-auths.ts index 15c7a7c55..6d91bb387 100644 --- a/backend/src/db/schemas/pki-acme-auths.ts +++ b/backend/src/db/schemas/pki-acme-auths.ts @@ -11,13 +11,13 @@ export const PkiAcmeAuthsSchema = z.object({ id: z.string().uuid(), accountId: z.string().uuid(), status: z.string(), + token: z.date().nullable().optional(), identifierType: z.string(), identifierValue: z.string(), expiresAt: z.date(), certificateId: z.string().uuid().nullable().optional(), createdAt: z.date(), - updatedAt: z.date(), - token: z.string().nullable().optional() + updatedAt: z.date() }); export type TPkiAcmeAuths = z.infer; diff --git a/backend/src/db/schemas/pki-acme-orders.ts b/backend/src/db/schemas/pki-acme-orders.ts index 6d5274f06..738155f46 100644 --- a/backend/src/db/schemas/pki-acme-orders.ts +++ b/backend/src/db/schemas/pki-acme-orders.ts @@ -10,12 +10,12 @@ import { TImmutableDBKeys } from "./models"; export const PkiAcmeOrdersSchema = z.object({ id: z.string().uuid(), accountId: z.string().uuid(), - status: z.string(), - createdAt: z.date(), - updatedAt: z.date(), notBefore: z.date().nullable().optional(), notAfter: z.date().nullable().optional(), - expiresAt: z.date() + expiresAt: z.date(), + status: z.string(), + createdAt: z.date(), + updatedAt: z.date() }); export type TPkiAcmeOrders = z.infer; diff --git a/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts b/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts index b8c909497..ae5c1f994 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts @@ -1,7 +1,7 @@ import { TDbClient } from "@app/db"; import { TableName } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; -import { ormify, selectAllTableCols } from "@app/lib/knex"; +import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; import { Knex } from "knex"; export type TPkiAcmeChallengeDALFactory = ReturnType; @@ -34,8 +34,49 @@ export const pkiAcmeChallengeDALFactory = (db: TDbClient) => { throw new DatabaseError({ error, name: "Find PKI ACME challenge by account id, auth id and challenge id" }); } }; + const findByIdWithAuthForUpdate = async (id: string, tx?: Knex) => { + const rows = await (tx || db)(TableName.PkiAcmeChallenge) + .join(TableName.PkiAcmeAuth, `${TableName.PkiAcmeChallenge}.authId`, `${TableName.PkiAcmeAuth}.id`) + .select( + selectAllTableCols(TableName.PkiAcmeChallenge), + db.ref("id").withSchema(TableName.PkiAcmeAuth).as("authId"), + db.ref("token").withSchema(TableName.PkiAcmeAuth).as("authToken"), + db.ref("status").withSchema(TableName.PkiAcmeAuth).as("authStatus"), + db.ref("identifierType").withSchema(TableName.PkiAcmeAuth).as("authIdentifierType"), + db.ref("identifierValue").withSchema(TableName.PkiAcmeAuth).as("authIdentifierValue"), + db.ref("expiresAt").withSchema(TableName.PkiAcmeAuth).as("authExpiresAt") + ) + // For all challenges, acquire update lock on the auth to avoid race conditions + .forUpdate(TableName.PkiAcmeAuth) + .where(`${TableName.PkiAcmeChallenge}.id`, id); + + if (rows.length === 0) { + return null; + } + return sqlNestRelationships({ + data: rows, + key: "id", + parentMapper: (row) => row, + childrenMapper: [ + { + key: "authId", + label: "auth" as const, + mapper: ({ authId, authToken, authStatus, authIdentifierType, authIdentifierValue, authExpiresAt }) => ({ + id: authId, + token: authToken, + status: authStatus, + identifierType: authIdentifierType, + identifierValue: authIdentifierValue, + expiresAt: authExpiresAt + }) + } + ] + })?.[0]; + }; + return { ...pkiAcmeChallengeOrm, - findByAccountAuthAndChallengeIdWithToken + findByAccountAuthAndChallengeIdWithToken, + findByIdWithAuthForUpdate }; }; diff --git a/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts new file mode 100644 index 000000000..6e077d0c4 --- /dev/null +++ b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts @@ -0,0 +1,57 @@ +import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { TPkiAcmeChallengeDALFactory } from "./pki-acme-challenge-dal"; +import { AcmeAuthStatus, AcmeChallengeStatus, AcmeChallengeType } from "./pki-acme-schemas"; +import { TPkiAcmeChallengeServiceFactory } from "./pki-acme-types"; +import { getConfig } from "@app/lib/config/env"; +import { calculateJwkThumbprint } from "jose"; + +type TPkiAcmeChallengeServiceFactoryDep = { + acmeChallengeDAL: Pick; +}; + +export const pkiAcmeChallengeServiceFactory = ({ + acmeChallengeDAL +}: TPkiAcmeChallengeServiceFactoryDep): TPkiAcmeChallengeServiceFactory => { + const appCfg = getConfig(); + + const validateChallengeResponse = async (challengeId: string): Promise => { + return await acmeChallengeDAL.transaction(async (tx) => { + const challenge = await acmeChallengeDAL.findByIdWithAuthForUpdate(challengeId, tx); + if (!challenge) { + throw new NotFoundError({ message: "ACME challenge not found" }); + } + if (challenge.status !== AcmeChallengeStatus.Processing) { + throw new BadRequestError({ + message: `ACME challenge is ${challenge.status} instead of ${AcmeChallengeStatus.Processing}` + }); + } + if (challenge.auth.expiresAt < new Date()) { + throw new BadRequestError({ message: "ACME auth has expired" }); + } + if (challenge.auth.status !== AcmeAuthStatus.Pending) { + throw new BadRequestError({ + message: `ACME auth status is ${challenge.auth.status} instead of ${AcmeAuthStatus.Pending}` + }); + } + + // TODO: support other challenge types here. Currently only HTTP-01 is supported + if (challenge.type !== AcmeChallengeType.HTTP_01) { + throw new BadRequestError({ message: "Only HTTP-01 challenges are supported for now" }); + } + const baseUrl = `http://${challenge.auth.identifierValue}`; + const actualBaseUrl = appCfg.isAcmeDevelopmentMode + ? `${baseUrl}:${appCfg.ACME_DEVELOPMENT_HTTP01_CHALLENGE_PORT}` + : baseUrl; + + const challengeUrl = new URL(`/.well-known/acme-challenge/${challenge.token}`, actualBaseUrl); + const challengeResponse = await fetch(challengeUrl); + if (challengeResponse.status !== 200) { + throw new BadRequestError({ message: "ACME challenge response is not 200" }); + } + const challengeResponseBody = await challengeResponse.text(); + const expectedChallengeResponseBody = `${challenge.token}.${challenge.auth.identifierValue}`; + }); + }; + + return { validateChallengeResponse }; +}; diff --git a/backend/src/ee/services/pki-acme/pki-acme-types.ts b/backend/src/ee/services/pki-acme/pki-acme-types.ts index d2f96deb4..bb9a15cbb 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-types.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-types.ts @@ -174,3 +174,5 @@ export type TPkiAcmeServiceFactory = { challengeId: string; }) => Promise>; }; + +export type TPkiAcmeChallengeServiceFactory = {}; diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 9fc4cff92..7477bc032 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -106,6 +106,8 @@ const envSchema = z HTTPS_ENABLED: zodStrBool, ROTATION_DEVELOPMENT_MODE: zodStrBool.default("false").optional(), DAILY_RESOURCE_CLEAN_UP_DEVELOPMENT_MODE: zodStrBool.default("false").optional(), + ACME_DEVELOPMENT_MODE: zodStrBool.default("false").optional(), + ACME_DEVELOPMENT_HTTP01_CHALLENGE_PORT: z.coerce.number().default(8087), // smtp options SMTP_HOST: zpStr(z.string().optional()), SMTP_IGNORE_TLS: zodStrBool.default("false"), @@ -384,6 +386,7 @@ const envSchema = z (data.NODE_ENV === "development" && data.ROTATION_DEVELOPMENT_MODE) || data.NODE_ENV === "test", isDailyResourceCleanUpDevelopmentMode: data.NODE_ENV === "development" && data.DAILY_RESOURCE_CLEAN_UP_DEVELOPMENT_MODE, + isAcmeDevelopmentMode: data.NODE_ENV === "development" && data.ACME_DEVELOPMENT_MODE, isProductionMode: data.NODE_ENV === "production" || IS_PACKAGED, isRedisSentinelMode: Boolean(data.REDIS_SENTINEL_HOSTS), REDIS_SENTINEL_HOSTS: data.REDIS_SENTINEL_HOSTS?.trim()