From 4f0bb7fb8ac4a14baf15f24ff62262302f561f0c Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 28 Oct 2025 17:37:24 -0700 Subject: [PATCH] Add alg column and index --- .../migrations/20251027234547_add-pki-acme.ts | 5 + .../services/pki-acme/pki-acme-account-dal.ts | 6 +- .../ee/services/pki-acme/pki-acme-auth-dal.ts | 138 ++++++++++++++++++ .../services/pki-acme/pki-acme-order-dal.ts | 116 +++++++++++++++ .../ee/services/pki-acme/pki-acme-service.ts | 59 ++++++-- .../ee/services/pki-acme/pki-acme-types.ts | 6 +- 6 files changed, 313 insertions(+), 17 deletions(-) create mode 100644 backend/src/ee/services/pki-acme/pki-acme-auth-dal.ts create mode 100644 backend/src/ee/services/pki-acme/pki-acme-order-dal.ts diff --git a/backend/src/db/migrations/20251027234547_add-pki-acme.ts b/backend/src/db/migrations/20251027234547_add-pki-acme.ts index c0cf0db4f..781fe040e 100644 --- a/backend/src/db/migrations/20251027234547_add-pki-acme.ts +++ b/backend/src/db/migrations/20251027234547_add-pki-acme.ts @@ -10,6 +10,8 @@ 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"; + export async function up(knex: Knex): Promise { // Create PkiAcmeEnrollmentConfig table if (!(await knex.schema.hasTable(TableName.PkiAcmeEnrollmentConfig))) { @@ -56,6 +58,9 @@ export async function up(knex: Knex): Promise { // Public key (JWK format) t.jsonb("publicKey").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); t.timestamps(true, true, true); }); diff --git a/backend/src/ee/services/pki-acme/pki-acme-account-dal.ts b/backend/src/ee/services/pki-acme/pki-acme-account-dal.ts index 93f7b7055..0050a1139 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-account-dal.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-account-dal.ts @@ -61,13 +61,13 @@ export const pkiAcmeAccountDALFactory = (db: TDbClient) => { } }; - const findByPublicKey = async (publicKey: unknown, tx?: Knex) => { + const findByPublicKey = async (publicKey: unknown, alg: string, tx?: Knex) => { try { - const account = await (tx || db)(TableName.PkiAcmeAccount).where({ publicKey }).first(); + const account = await (tx || db)(TableName.PkiAcmeAccount).where({ publicKey, alg }).first(); return account || null; } catch (error) { - throw new DatabaseError({ error, name: "Find PKI ACME account by public key" }); + throw new DatabaseError({ error, name: "Find PKI ACME account by public key and alg" }); } }; diff --git a/backend/src/ee/services/pki-acme/pki-acme-auth-dal.ts b/backend/src/ee/services/pki-acme/pki-acme-auth-dal.ts new file mode 100644 index 000000000..c06954f4a --- /dev/null +++ b/backend/src/ee/services/pki-acme/pki-acme-auth-dal.ts @@ -0,0 +1,138 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { TPkiAcmeAuthsInsert, TPkiAcmeAuthsUpdate } from "@app/db/schemas/pki-acme-auths"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify } from "@app/lib/knex"; + +export type TPkiAcmeAuthDALFactory = ReturnType; + +export const pkiAcmeAuthDALFactory = (db: TDbClient) => { + const pkiAcmeAuthOrm = ormify(db, TableName.PkiAcmeAuth); + + const create = async (data: TPkiAcmeAuthsInsert, tx?: Knex) => { + try { + const result = await (tx || db)(TableName.PkiAcmeAuth).insert(data).returning("*"); + const [auth] = result; + + if (!auth) { + throw new Error("Failed to create PKI ACME auth"); + } + + return auth; + } catch (error) { + throw new DatabaseError({ error, name: "Create PKI ACME auth" }); + } + }; + + const updateById = async (id: string, data: TPkiAcmeAuthsUpdate, tx?: Knex) => { + try { + const result = await (tx || db)(TableName.PkiAcmeAuth).where({ id }).update(data).returning("*"); + const [auth] = result; + + if (!auth) { + return null; + } + + return auth; + } catch (error) { + throw new DatabaseError({ error, name: "Update PKI ACME auth" }); + } + }; + + const findById = async (id: string, tx?: Knex) => { + try { + const auth = await (tx || db)(TableName.PkiAcmeAuth).where({ id }).first(); + + return auth || null; + } catch (error) { + throw new DatabaseError({ error, name: "Find PKI ACME auth by id" }); + } + }; + + const findByAccountId = async (accountId: string, tx?: Knex) => { + try { + const auths = await (tx || db)(TableName.PkiAcmeAuth).where({ accountId }); + + return auths; + } catch (error) { + throw new DatabaseError({ error, name: "Find PKI ACME auths by account id" }); + } + }; + + const findByStatus = async (status: string, tx?: Knex) => { + try { + const auths = await (tx || db)(TableName.PkiAcmeAuth).where({ status }); + + return auths; + } catch (error) { + throw new DatabaseError({ error, name: "Find PKI ACME auths by status" }); + } + }; + + const findByAccountIdAndStatus = async (accountId: string, status: string, tx?: Knex) => { + try { + const auths = await (tx || db)(TableName.PkiAcmeAuth).where({ accountId, status }); + + return auths; + } catch (error) { + throw new DatabaseError({ error, name: "Find PKI ACME auths by account id and status" }); + } + }; + + const findByIdentifier = async (identifierType: string, identifierValue: string, tx?: Knex) => { + try { + const auths = await (tx || db)(TableName.PkiAcmeAuth).where({ identifierType, identifierValue }); + + return auths; + } catch (error) { + throw new DatabaseError({ error, name: "Find PKI ACME auths by identifier" }); + } + }; + + const findByCertificateId = async (certificateId: string, tx?: Knex) => { + try { + const auths = await (tx || db)(TableName.PkiAcmeAuth).where({ certificateId }); + + return auths; + } catch (error) { + throw new DatabaseError({ error, name: "Find PKI ACME auths by certificate id" }); + } + }; + + const deleteById = async (id: string, tx?: Knex) => { + try { + const result = await (tx || db)(TableName.PkiAcmeAuth).where({ id }).delete().returning("*"); + const [auth] = result; + + return auth || null; + } catch (error) { + throw new DatabaseError({ error, name: "Delete PKI ACME auth by id" }); + } + }; + + const deleteByAccountId = async (accountId: string, tx?: Knex) => { + try { + const result = await (tx || db)(TableName.PkiAcmeAuth).where({ accountId }).delete().returning("*"); + + return result; + } catch (error) { + throw new DatabaseError({ error, name: "Delete PKI ACME auths by account id" }); + } + }; + + return { + ...pkiAcmeAuthOrm, + create, + updateById, + findById, + findByAccountId, + findByStatus, + findByAccountIdAndStatus, + findByIdentifier, + findByCertificateId, + deleteById, + deleteByAccountId + }; +}; diff --git a/backend/src/ee/services/pki-acme/pki-acme-order-dal.ts b/backend/src/ee/services/pki-acme/pki-acme-order-dal.ts new file mode 100644 index 000000000..fabaaf2a1 --- /dev/null +++ b/backend/src/ee/services/pki-acme/pki-acme-order-dal.ts @@ -0,0 +1,116 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { TPkiAcmeOrdersInsert, TPkiAcmeOrdersUpdate } from "@app/db/schemas/pki-acme-orders"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify } from "@app/lib/knex"; + +export type TPkiAcmeOrderDALFactory = ReturnType; + +export const pkiAcmeOrderDALFactory = (db: TDbClient) => { + const pkiAcmeOrderOrm = ormify(db, TableName.PkiAcmeOrder); + + const create = async (data: TPkiAcmeOrdersInsert, tx?: Knex) => { + try { + const result = await (tx || db)(TableName.PkiAcmeOrder).insert(data).returning("*"); + const [order] = result; + + if (!order) { + throw new Error("Failed to create PKI ACME order"); + } + + return order; + } catch (error) { + throw new DatabaseError({ error, name: "Create PKI ACME order" }); + } + }; + + const updateById = async (id: string, data: TPkiAcmeOrdersUpdate, tx?: Knex) => { + try { + const result = await (tx || db)(TableName.PkiAcmeOrder).where({ id }).update(data).returning("*"); + const [order] = result; + + if (!order) { + return null; + } + + return order; + } catch (error) { + throw new DatabaseError({ error, name: "Update PKI ACME order" }); + } + }; + + const findById = async (id: string, tx?: Knex) => { + try { + const order = await (tx || db)(TableName.PkiAcmeOrder).where({ id }).first(); + + return order || null; + } catch (error) { + throw new DatabaseError({ error, name: "Find PKI ACME order by id" }); + } + }; + + const findByAccountId = async (accountId: string, tx?: Knex) => { + try { + const orders = await (tx || db)(TableName.PkiAcmeOrder).where({ accountId }); + + return orders; + } catch (error) { + throw new DatabaseError({ error, name: "Find PKI ACME orders by account id" }); + } + }; + + const findByStatus = async (status: string, tx?: Knex) => { + try { + const orders = await (tx || db)(TableName.PkiAcmeOrder).where({ status }); + + return orders; + } catch (error) { + throw new DatabaseError({ error, name: "Find PKI ACME orders by status" }); + } + }; + + const findByAccountIdAndStatus = async (accountId: string, status: string, tx?: Knex) => { + try { + const orders = await (tx || db)(TableName.PkiAcmeOrder).where({ accountId, status }); + + return orders; + } catch (error) { + throw new DatabaseError({ error, name: "Find PKI ACME orders by account id and status" }); + } + }; + + const deleteById = async (id: string, tx?: Knex) => { + try { + const result = await (tx || db)(TableName.PkiAcmeOrder).where({ id }).delete().returning("*"); + const [order] = result; + + return order || null; + } catch (error) { + throw new DatabaseError({ error, name: "Delete PKI ACME order by id" }); + } + }; + + const deleteByAccountId = async (accountId: string, tx?: Knex) => { + try { + const result = await (tx || db)(TableName.PkiAcmeOrder).where({ accountId }).delete().returning("*"); + + return result; + } catch (error) { + throw new DatabaseError({ error, name: "Delete PKI ACME orders by account id" }); + } + }; + + return { + ...pkiAcmeOrderOrm, + create, + updateById, + findById, + findByAccountId, + findByStatus, + findByAccountIdAndStatus, + deleteById, + deleteByAccountId + }; +}; 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 7a08a95f7..cf239b16a 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -5,11 +5,14 @@ import { TCertificateProfileDALFactory } from "@app/services/certificate-profile import { AcmeAccountDoesNotExistError, AcmeBadPublicKeyError, AcmeMalformedError } from "./pki-acme-errors"; +import { TPkiAcmeAccounts } from "@app/db/schemas/pki-acme-accounts"; import { EnrollmentType, TCertificateProfileWithConfigs } from "@app/services/certificate-profile/certificate-profile-types"; import { flattenedVerify, importJWK, JWK, JWSHeaderParameters } from "jose"; +import { TPkiAcmeAccountDALFactory } from "./pki-acme-account-dal"; +import { TPkiAcmeOrderDALFactory } from "./pki-acme-order-dal"; import { ProtectedHeaderSchema } from "./pki-acme-schemas"; import { TAcmeResponse, @@ -24,24 +27,22 @@ import { TGetAcmeAuthorizationResponse, TGetAcmeDirectoryResponse, TGetAcmeOrderResponse, - TJwsPayload, TJwsPayloadWithJwk, TListAcmeOrdersResponse, TPkiAcmeServiceFactory, TRawJwsPayload, TRespondToAcmeChallengeResponse } from "./pki-acme-types"; -import { TPkiAcmeAccounts } from "@app/db/schemas/pki-acme-accounts"; -import { TPkiAcmeAccountDALFactory } from "./pki-acme-account-dal"; type TPkiAcmeServiceFactoryDep = { certificateProfileDAL: Pick; - pkiAcmeAccountDAL: Pick; + acmeAccountDAL: Pick; + acmeOrderDAL: Pick; }; export const pkiAcmeServiceFactory = ({ certificateProfileDAL, - pkiAcmeAccountDAL + acmeAccountDAL }: TPkiAcmeServiceFactoryDep): TPkiAcmeServiceFactory => { const validateAcmeProfile = async (profileId: string): Promise => { const profile = await certificateProfileDAL.findById(profileId); @@ -81,6 +82,32 @@ export const pkiAcmeServiceFactory = ({ throw new AcmeMalformedError({ detail: "Invalid protected header" }); } + const decoder = new TextDecoder(); + const payload = JSON.parse(decoder.decode(rawPayload)) as TCreateAcmeAccountPayload; + // TODO: also consume the nonce here + return { payload, protectedHeader, jwk: protectedHeader.jwk as JsonWebKey, alg: protectedHeader.alg }; + }; + + const validateJwsPayload = async (rawJwsPayload: TRawJwsPayload): Promise => { + const { payload: rawPayload, protectedHeader: rawProtectedHeader } = await flattenedVerify( + rawJwsPayload, + async (protectedHeader: JWSHeaderParameters | undefined) => { + if (protectedHeader === undefined) { + throw new AcmeMalformedError({ detail: "Protected header is required" }); + } + if (protectedHeader.kid === undefined) { + throw new AcmeBadPublicKeyError({ detail: "Kid is required in the protected header" }); + } + + const imported = await importJWK(protectedHeader.jwk as JWK, protectedHeader.alg); + return imported; + } + ); + const { success, data: protectedHeader } = ProtectedHeaderSchema.safeParse(rawProtectedHeader); + if (!success) { + throw new AcmeMalformedError({ detail: "Invalid protected header" }); + } + const decoder = new TextDecoder(); const payload = JSON.parse(decoder.decode(rawPayload)) as TCreateAcmeAccountPayload; // TODO: also consume the nonce here @@ -109,7 +136,7 @@ export const pkiAcmeServiceFactory = ({ { onlyReturnExisting, contact }: TCreateAcmeAccountPayload ): Promise> => { const profile = await validateAcmeProfile(profileId); - const existingAccount: TPkiAcmeAccounts | null = await pkiAcmeAccountDAL.findByPublicKey(jwk); + const existingAccount: TPkiAcmeAccounts | null = await acmeAccountDAL.findByPublicKey(jwk, alg); if (onlyReturnExisting && !existingAccount) { throw new AcmeAccountDoesNotExistError({ message: "ACME account not found" }); } @@ -128,7 +155,7 @@ export const pkiAcmeServiceFactory = ({ }; } - const newAccount = await pkiAcmeAccountDAL.create({ + const newAccount = await acmeAccountDAL.create({ profileId: profile.id, publicKey: jwk, emails: contact ?? [] @@ -150,17 +177,23 @@ export const pkiAcmeServiceFactory = ({ const createAcmeOrder = async ( profileId: string, payload: TCreateAcmeOrderPayload - ): Promise => { + ): Promise> => { const profile = await validateAcmeProfile(profileId); // FIXME: Implement ACME new order creation const orderId = "FIXME-order-id"; return { - status: "pending", - expires: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), - identifiers: [], - authorizations: [], - finalize: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/orders/${orderId}/finalize`) + status: 201, + body: { + status: "pending", + expires: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), + identifiers: [], + authorizations: [], + finalize: buildUrl(`/api/v1/pki/acme/profiles/${profile.id}/orders/${orderId}/finalize`) + }, + headers: { + Location: buildUrl(`/api/v1/pki/acme/profiles/${profile.id}/orders/${orderId}`) + } }; }; 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 fe1105ff8..2006f38a2 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-types.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-types.ts @@ -41,6 +41,7 @@ export type TFinalizeAcmeOrderPayload = z.infer = { status: number; @@ -57,7 +58,10 @@ export type TPkiAcmeServiceFactory = { jwk: JsonWebKey, body: TCreateAcmeAccountPayload ) => Promise>; - createAcmeOrder: (profileId: string, body: TCreateAcmeOrderPayload) => Promise; + createAcmeOrder: ( + profileId: string, + body: TCreateAcmeOrderPayload + ) => Promise>; deactivateAcmeAccount: ( profileId: string, accountId: string,