Add alg column and index

This commit is contained in:
Fang-Pen Lin
2025-10-28 17:37:24 -07:00
parent ebd2c8ae45
commit 4f0bb7fb8a
6 changed files with 313 additions and 17 deletions

View File

@@ -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<void> {
// Create PkiAcmeEnrollmentConfig table
if (!(await knex.schema.hasTable(TableName.PkiAcmeEnrollmentConfig))) {
@@ -56,6 +58,9 @@ export async function up(knex: Knex): Promise<void> {
// 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);
});

View File

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

View File

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

View File

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

View File

@@ -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<TCertificateProfileDALFactory, "findById">;
pkiAcmeAccountDAL: Pick<TPkiAcmeAccountDALFactory, "findByPublicKey" | "create">;
acmeAccountDAL: Pick<TPkiAcmeAccountDALFactory, "findByPublicKey" | "create">;
acmeOrderDAL: Pick<TPkiAcmeOrderDALFactory, "create">;
};
export const pkiAcmeServiceFactory = ({
certificateProfileDAL,
pkiAcmeAccountDAL
acmeAccountDAL
}: TPkiAcmeServiceFactoryDep): TPkiAcmeServiceFactory => {
const validateAcmeProfile = async (profileId: string): Promise<TCertificateProfileWithConfigs> => {
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<TJwsPayloadWithJwk> => {
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<TAcmeResponse<TCreateAcmeAccountResponse>> => {
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<TCreateAcmeOrderResponse> => {
): Promise<TAcmeResponse<TCreateAcmeOrderResponse>> => {
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}`)
}
};
};

View File

@@ -41,6 +41,7 @@ export type TFinalizeAcmeOrderPayload = z.infer<typeof FinalizeAcmeOrderBodySche
export type TJwsPayloadWithJwk = TJwsPayload & {
jwk: JsonWebKey;
alg: string;
};
export type TAcmeResponse<TPayload> = {
status: number;
@@ -57,7 +58,10 @@ export type TPkiAcmeServiceFactory = {
jwk: JsonWebKey,
body: TCreateAcmeAccountPayload
) => Promise<TAcmeResponse<TCreateAcmeAccountResponse>>;
createAcmeOrder: (profileId: string, body: TCreateAcmeOrderPayload) => Promise<TCreateAcmeOrderResponse>;
createAcmeOrder: (
profileId: string,
body: TCreateAcmeOrderPayload
) => Promise<TAcmeResponse<TCreateAcmeOrderResponse>>;
deactivateAcmeAccount: (
profileId: string,
accountId: string,