Add challenge service

This commit is contained in:
Fang-Pen Lin
2025-10-31 10:00:24 -07:00
parent 92a1d1f413
commit 1fe458e23b
8 changed files with 117 additions and 11 deletions

View File

@@ -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<void> {
// Create PkiAcmeEnrollmentConfig table
@@ -56,12 +56,14 @@ export async function up(knex: Knex): Promise<void> {
// 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);
});

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<TPkiAcmeChallengeDALFactory, "transaction" | "findByIdWithAuthForUpdate">;
};
export const pkiAcmeChallengeServiceFactory = ({
acmeChallengeDAL
}: TPkiAcmeChallengeServiceFactoryDep): TPkiAcmeChallengeServiceFactory => {
const appCfg = getConfig();
const validateChallengeResponse = async (challengeId: string): Promise<void> => {
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 };
};

View File

@@ -174,3 +174,5 @@ export type TPkiAcmeServiceFactory = {
challengeId: string;
}) => Promise<TAcmeResponse<TRespondToAcmeChallengeResponse>>;
};
export type TPkiAcmeChallengeServiceFactory = {};

View File

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