Files
infisical/backend/src/ee/services/pki-acme/pki-acme-service.ts
T
2025-11-07 09:18:13 -08:00

275 lines
9.6 KiB
TypeScript

import { getConfig } from "@app/lib/config/env";
import { NotFoundError } from "@app/lib/errors";
import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal";
import { AcmeAccountDoesNotExistError, AcmeBadPublicKeyError, AcmeMalformedError } from "./pki-acme-errors";
import {
EnrollmentType,
TCertificateProfileWithConfigs
} from "@app/services/certificate-profile/certificate-profile-types";
import { flattenedVerify, importJWK, JWK, JWSHeaderParameters } from "jose";
import { ProtectedHeaderSchema } from "./pki-acme-schemas";
import {
TAcmeResponse,
TCreateAcmeAccountPayload,
TCreateAcmeAccountResponse,
TCreateAcmeOrderPayload,
TCreateAcmeOrderResponse,
TDeactivateAcmeAccountPayload,
TDeactivateAcmeAccountResponse,
TFinalizeAcmeOrderPayload,
TFinalizeAcmeOrderResponse,
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">;
};
export const pkiAcmeServiceFactory = ({
certificateProfileDAL,
pkiAcmeAccountDAL
}: TPkiAcmeServiceFactoryDep): TPkiAcmeServiceFactory => {
const validateAcmeProfile = async (profileId: string): Promise<TCertificateProfileWithConfigs> => {
const profile = await certificateProfileDAL.findById(profileId);
if (!profile) {
throw new NotFoundError({ message: "Certificate profile not found" });
}
if (profile.enrollmentType !== EnrollmentType.ACME) {
throw new NotFoundError({ message: "Certificate profile is not configured for ACME enrollment" });
}
return profile;
};
const buildUrl = (path: string): string => {
const appCfg = getConfig();
const baseUrl = appCfg.SITE_URL ?? "";
return `${baseUrl}${path}`;
};
const validateCreateAcmeAccountJwsPayload = 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.jwk === undefined) {
throw new AcmeBadPublicKeyError({ detail: "JWK is required in the protected header" });
}
// For the create account request, the JWK is provided in the protected header.
// Let use it to verify the signature.
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
return { payload, protectedHeader, jwk: protectedHeader.jwk as JsonWebKey };
};
const getAcmeDirectory = async (profileId: string): Promise<TGetAcmeDirectoryResponse> => {
await validateAcmeProfile(profileId);
return {
newNonce: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/new-nonce`),
newAccount: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/new-account`),
newOrder: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/new-order`)
};
};
const getAcmeNewNonce = async (profileId: string): Promise<string> => {
const profile = await validateAcmeProfile(profileId);
// FIXME: Implement ACME new nonce generation
// Generate a new nonce, store it, and return it
return "FIXME-generate-nonce";
};
const createAcmeAccount = async (
profileId: string,
jwk: JWK,
{ onlyReturnExisting, contact }: TCreateAcmeAccountPayload
): Promise<TAcmeResponse<TCreateAcmeAccountResponse>> => {
const profile = await validateAcmeProfile(profileId);
const existingAccount: TPkiAcmeAccounts | null = await pkiAcmeAccountDAL.findByPublicKey(jwk);
if (onlyReturnExisting && !existingAccount) {
throw new AcmeAccountDoesNotExistError({ message: "ACME account not found" });
}
if (existingAccount) {
// With the same public key, we found an existing account, just return it
return {
status: 200,
body: {
status: "valid",
contact: existingAccount.emails,
orders: buildUrl(`/api/v1/pki/acme/profiles/${profile.id}/accounts/${existingAccount.id}/orders`)
},
headers: {
Location: buildUrl(`/api/v1/pki/acme/profiles/${profile.id}/accounts/${existingAccount.id}`)
}
};
}
const newAccount = await pkiAcmeAccountDAL.create({
profileId: profile.id,
publicKey: jwk,
emails: contact ?? []
});
// TODO: check EAB authentication here
return {
status: 201,
body: {
status: "valid",
contact: newAccount.emails,
orders: buildUrl(`/api/v1/pki/acme/profiles/${profile.id}/accounts/${newAccount.id}/orders`)
},
headers: {
Location: buildUrl(`/api/v1/pki/acme/profiles/${profile.id}/accounts/${newAccount.id}`)
}
};
};
const createAcmeOrder = async (
profileId: string,
payload: TCreateAcmeOrderPayload
): Promise<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`)
};
};
const deactivateAcmeAccount = async (
profileId: string,
accountId: string,
payload?: TDeactivateAcmeAccountPayload
): Promise<TDeactivateAcmeAccountResponse> => {
const profile = await validateAcmeProfile(profileId);
// FIXME: Implement ACME account deactivation
return {
status: "deactivated"
};
};
const listAcmeOrders = async (profileId: string, accountId: string): Promise<TListAcmeOrdersResponse> => {
const profile = await validateAcmeProfile(profileId);
// FIXME: Implement ACME list orders
return {
orders: []
};
};
const getAcmeOrder = async (profileId: string, orderId: string): Promise<TGetAcmeOrderResponse> => {
const profile = await validateAcmeProfile(profileId);
// FIXME: Implement ACME get order
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`)
};
};
const finalizeAcmeOrder = async (
profileId: string,
orderId: string,
payload: TFinalizeAcmeOrderPayload
): Promise<TFinalizeAcmeOrderResponse> => {
const profile = await validateAcmeProfile(profileId);
const { csr } = payload;
// FIXME: Implement ACME finalize order
return {
status: "processing",
expires: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
identifiers: [],
authorizations: [],
finalize: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/orders/${orderId}/finalize`),
certificate: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/orders/${orderId}/certificate`)
};
};
const downloadAcmeCertificate = async (profileId: string, orderId: string): Promise<string> => {
const profile = await validateAcmeProfile(profileId);
// FIXME: Implement ACME certificate download
// Return the certificate in PEM format
return "FIXME-certificate-pem";
};
const getAcmeAuthorization = async (profileId: string, authzId: string): Promise<TGetAcmeAuthorizationResponse> => {
const profile = await validateAcmeProfile(profileId);
// FIXME: Implement ACME authorization retrieval
return {
status: "pending",
expires: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
identifier: {
type: "dns",
value: "FIXME-domain-name"
},
challenges: [
{
type: "http-01",
url: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/authorizations/${authzId}/challenges/http-01`),
status: "pending",
token: "FIXME-challenge-token"
}
]
};
};
const respondToAcmeChallenge = async (
profileId: string,
authzId: string
): Promise<TRespondToAcmeChallengeResponse> => {
const profile = await validateAcmeProfile(profileId);
// FIXME: Implement ACME challenge response
// Trigger verification process
return {
type: "http-01",
url: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/authorizations/${authzId}/challenges/http-01`),
status: "pending",
token: "FIXME-challenge-token"
};
};
return {
validateCreateAcmeAccountJwsPayload,
getAcmeDirectory,
getAcmeNewNonce,
createAcmeAccount,
createAcmeOrder,
deactivateAcmeAccount,
listAcmeOrders,
getAcmeOrder,
finalizeAcmeOrder,
downloadAcmeCertificate,
getAcmeAuthorization,
respondToAcmeChallenge
};
};