Add more scheme

This commit is contained in:
Fang-Pen Lin
2025-10-28 13:55:41 -07:00
parent a37f8445ad
commit efabf9ee05
4 changed files with 501 additions and 16 deletions

View File

@@ -0,0 +1,436 @@
/**
* ACME Error Classes based on RFC 8555 Section 6.2
* https://datatracker.ietf.org/doc/html/rfc8555#section-6.2
*/
export interface IAcmeError {
type: string;
detail: string;
status: number;
subproblems?: Array<{ type: string; detail: string; identifier?: { type: string; value: string } }>;
}
export class AcmeError extends Error implements IAcmeError {
type: string;
detail: string;
status: number;
subproblems?: Array<{ type: string; detail: string; identifier?: { type: string; value: string } }>;
error?: unknown;
constructor({
type,
detail,
status,
subproblems,
error,
message
}: {
type: string;
detail: string;
status: number;
subproblems?: Array<{ type: string; detail: string; identifier?: { type: string; value: string } }>;
error?: unknown;
message?: string;
}) {
super(message || detail);
this.type = type;
this.detail = detail;
this.status = status;
this.subproblems = subproblems;
this.error = error;
this.name = "AcmeError";
}
toAcmeResponse(): IAcmeError {
return {
type: this.type,
detail: this.detail,
status: this.status,
subproblems: this.subproblems
};
}
}
/**
* malformed - The request message was malformed (RFC 8555 Section 6.7.1)
*/
export class AcmeMalformedError extends AcmeError {
constructor({
detail = "The request message was malformed",
error,
message
}: {
detail?: string;
error?: unknown;
message?: string;
} = {}) {
super({
type: "malformed",
detail,
status: 400,
error,
message
});
this.name = "AcmeMalformedError";
}
}
/**
* unauthorized - The client lacks sufficient authorization (RFC 8555 Section 6.7.2)
*/
export class AcmeUnauthorizedError extends AcmeError {
constructor({
detail = "The client lacks sufficient authorization",
error,
message
}: {
detail?: string;
error?: unknown;
message?: string;
} = {}) {
super({
type: "unauthorized",
detail,
status: 403,
error,
message
});
this.name = "AcmeUnauthorizedError";
}
}
/**
* accountDoesNotExist - The request specified an account that does not exist
* (RFC 8555 Section 6.7.3)
*/
export class AcmeAccountDoesNotExistError extends AcmeError {
constructor({
detail = "The request specified an account that does not exist",
error,
message
}: {
detail?: string;
error?: unknown;
message?: string;
} = {}) {
super({
type: "accountDoesNotExist",
detail,
status: 400,
error,
message
});
this.name = "AcmeAccountDoesNotExistError";
}
}
/**
* badNonce - The client sent an unacceptable anti-replay nonce (RFC 8555 Section 6.7.4)
*/
export class AcmeBadNonceError extends AcmeError {
constructor({
detail = "The client sent an unacceptable anti-replay nonce",
error,
message
}: {
detail?: string;
error?: unknown;
message?: string;
} = {}) {
super({
type: "badNonce",
detail,
status: 400,
error,
message
});
this.name = "AcmeBadNonceError";
}
}
/**
* badSignature - The JWS signature is invalid (RFC 8555 Section 6.7.5)
*/
export class AcmeBadSignatureError extends AcmeError {
constructor({
detail = "The JWS signature is invalid",
error,
message
}: {
detail?: string;
error?: unknown;
message?: string;
} = {}) {
super({
type: "badSignature",
detail,
status: 401,
error,
message
});
this.name = "AcmeBadSignatureError";
}
}
/**
* badPublicKey - The public key is not acceptable (RFC 8555 Section 6.7.6)
*/
export class AcmeBadPublicKeyError extends AcmeError {
constructor({
detail = "The public key is not acceptable",
error,
message
}: {
detail?: string;
error?: unknown;
message?: string;
} = {}) {
super({
type: "badPublicKey",
detail,
status: 400,
error,
message
});
this.name = "AcmeBadPublicKeyError";
}
}
/**
* badCSR - The CSR is unacceptable (RFC 8555 Section 6.7.7)
*/
export class AcmeBadCsrError extends AcmeError {
constructor({
detail = "The CSR is unacceptable",
error,
message
}: {
detail?: string;
error?: unknown;
message?: string;
} = {}) {
super({
type: "badCSR",
detail,
status: 400,
error,
message
});
this.name = "AcmeBadCsrError";
}
}
/**
* badRevocationReason - The revocation reason provided is not allowed
* (RFC 8555 Section 6.7.8)
*/
export class AcmeBadRevocationReasonError extends AcmeError {
constructor({
detail = "The revocation reason provided is not allowed",
error,
message
}: {
detail?: string;
error?: unknown;
message?: string;
} = {}) {
super({
type: "badRevocationReason",
detail,
status: 400,
error,
message
});
this.name = "AcmeBadRevocationReasonError";
}
}
/**
* rateLimited - The client has exceeded a rate limit (RFC 8555 Section 6.7.9)
*/
export class AcmeRateLimitedError extends AcmeError {
constructor({
detail = "The client has exceeded a rate limit",
error,
message
}: {
detail?: string;
error?: unknown;
message?: string;
} = {}) {
super({
type: "rateLimited",
detail,
status: 429,
error,
message
});
this.name = "AcmeRateLimitedError";
}
}
/**
* rejectedIdentifier - The server will not issue certificates for the identifier
* (RFC 8555 Section 6.7.10)
*/
export class AcmeRejectedIdentifierError extends AcmeError {
constructor({
detail = "The server will not issue certificates for the identifier",
subproblems,
error,
message
}: {
detail?: string;
subproblems?: Array<{ type: string; detail: string; identifier?: { type: string; value: string } }>;
error?: unknown;
message?: string;
} = {}) {
super({
type: "rejectedIdentifier",
detail,
status: 400,
subproblems,
error,
message
});
this.name = "AcmeRejectedIdentifierError";
}
}
/**
* serverInternal - An internal error occurred (RFC 8555 Section 6.7.11)
*/
export class AcmeServerInternalError extends AcmeError {
constructor({
detail = "An internal error occurred",
error,
message
}: {
detail?: string;
error?: unknown;
message?: string;
} = {}) {
super({
type: "serverInternal",
detail,
status: 500,
error,
message
});
this.name = "AcmeServerInternalError";
}
}
/**
* serviceUnavailable - The service is unavailable (RFC 8555 Section 6.7.12)
*/
export class AcmeServiceUnavailableError extends AcmeError {
constructor({
detail = "The service is unavailable",
error,
message
}: {
detail?: string;
error?: unknown;
message?: string;
} = {}) {
super({
type: "serviceUnavailable",
detail,
status: 503,
error,
message
});
this.name = "AcmeServiceUnavailableError";
}
}
/**
* unsupportedContact - A contact URL is of an unsupported type (RFC 8555 Section 6.7.13)
*/
export class AcmeUnsupportedContactError extends AcmeError {
constructor({
detail = "A contact URL is of an unsupported type",
error,
message
}: {
detail?: string;
error?: unknown;
message?: string;
} = {}) {
super({
type: "unsupportedContact",
detail,
status: 400,
error,
message
});
this.name = "AcmeUnsupportedContactError";
}
}
/**
* unsupportedIdentifier - An identifier is of an unsupported type
* (RFC 8555 Section 6.7.14)
*/
export class AcmeUnsupportedIdentifierError extends AcmeError {
constructor({
detail = "An identifier is of an unsupported type",
error,
message
}: {
detail?: string;
error?: unknown;
message?: string;
} = {}) {
super({
type: "unsupportedIdentifier",
detail,
status: 400,
error,
message
});
this.name = "AcmeUnsupportedIdentifierError";
}
}
/**
* userActionRequired - Visit the "instance" URL and take actions specified there
* (RFC 8555 Section 6.7.15)
*/
export class AcmeUserActionRequiredError extends AcmeError {
instance?: string;
constructor({
detail = "Visit the instance URL and take actions specified there",
instance,
error,
message
}: {
detail?: string;
instance?: string;
error?: unknown;
message?: string;
} = {}) {
super({
type: "userActionRequired",
detail,
status: 403,
error,
message
});
this.instance = instance;
this.name = "AcmeUserActionRequiredError";
}
toAcmeResponse(): IAcmeError & { instance?: string } {
return {
...super.toAcmeResponse(),
instance: this.instance
};
}
}

View File

@@ -1,5 +1,25 @@
import { z } from "zod";
export const ProtectedHeaderSchema = z.object({
alg: z.string(),
nonce: z.string(),
url: z.string(),
kid: z.string().optional(),
jwk: z.record(z.string(), z.string()).optional()
});
// Raw JWS payload schema before parsing and verification
export const RawJwsPayloadSchema = z.object({
protected: z.string(),
payload: z.string(),
signature: z.string()
});
export const JwsPayloadSchema = z.object({
protectedHeader: ProtectedHeaderSchema,
payload: z.any()
});
// Directory endpoint
export const GetAcmeDirectorySchema = z.object({
params: z.object({

View File

@@ -1,8 +1,10 @@
import { getConfig } from "@app/lib/config/env";
import { NotFoundError } from "@app/lib/errors";
import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal";
import { AcmeMalformedError, AcmeBadPublicKeyError } from "./pki-acme-errors";
import {
EnrollmentType,
TCertificateProfileWithConfigs
@@ -19,18 +21,20 @@ import {
TGetAcmeAuthorizationResponse,
TGetAcmeDirectoryResponse,
TGetAcmeOrderResponse,
TRawJwsPayload,
TListAcmeOrdersResponse,
TPkiAcmeServiceFactory,
TRespondToAcmeChallengeResponse
TRespondToAcmeChallengeResponse,
TJwsPayload,
TProtectedHeader
} from "./pki-acme-types";
import { flattenedVerify, importJWK, JWK, JWSHeaderParameters } from "jose";
type TPkiAcmeServiceFactoryDep = {
certificateProfileDAL: Pick<TCertificateProfileDALFactory, "findById">;
};
export const pkiAcmeServiceFactory = ({ certificateProfileDAL }: TPkiAcmeServiceFactoryDep): TPkiAcmeServiceFactory => {
const appCfg = getConfig();
const validateAcmeProfile = async (profileId: string): Promise<TCertificateProfileWithConfigs> => {
const profile = await certificateProfileDAL.findById(profileId);
if (!profile) {
@@ -43,18 +47,35 @@ export const pkiAcmeServiceFactory = ({ certificateProfileDAL }: TPkiAcmeService
};
const buildUrl = (path: string): string => {
const appCfg = getConfig();
const baseUrl = appCfg.SITE_URL ?? "";
return `${baseUrl}${path}`;
};
const validateCreateAcmeAccountJwsPayload = async (rawPayload: TRawJwsPayload): Promise<TJwsPayload> => {
const { payload, protectedHeader } = await flattenedVerify(
rawPayload,
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 decoder = new TextDecoder();
const parsedPayload = JSON.parse(decoder.decode(payload)) as TCreateAcmeAccountPayload;
// TODO: also consume the nonce here
return { payload: parsedPayload, protectedHeader: protectedHeader as TProtectedHeader };
};
const getAcmeDirectory = async (profileId: string): Promise<TGetAcmeDirectoryResponse> => {
// FIXME: Implement ACME directory endpoint
// Validate profile exists and is for ACME enrollment
const profile = await validateAcmeProfile(profileId);
// FIXME: Validate profile is configured for ACME enrollment
// Return absolute URLs using SITE_URL
await validateAcmeProfile(profileId);
return {
newNonce: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/new-nonce`),
newAccount: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/new-account`),
@@ -71,7 +92,7 @@ export const pkiAcmeServiceFactory = ({ certificateProfileDAL }: TPkiAcmeService
const createAcmeAccount = async (
profileId: string,
body: TCreateAcmeAccountPayload
payload: TCreateAcmeAccountPayload
): Promise<TCreateAcmeAccountResponse> => {
const profile = await validateAcmeProfile(profileId);
// FIXME: Implement ACME new account registration
@@ -88,7 +109,7 @@ export const pkiAcmeServiceFactory = ({ certificateProfileDAL }: TPkiAcmeService
const createAcmeOrder = async (
profileId: string,
body: TCreateAcmeOrderPayload
payload: TCreateAcmeOrderPayload
): Promise<TCreateAcmeOrderResponse> => {
const profile = await validateAcmeProfile(profileId);
// FIXME: Implement ACME new order creation
@@ -105,7 +126,7 @@ export const pkiAcmeServiceFactory = ({ certificateProfileDAL }: TPkiAcmeService
const deactivateAcmeAccount = async (
profileId: string,
accountId: string,
body?: TDeactivateAcmeAccountPayload
payload?: TDeactivateAcmeAccountPayload
): Promise<TDeactivateAcmeAccountResponse> => {
const profile = await validateAcmeProfile(profileId);
// FIXME: Implement ACME account deactivation
@@ -137,10 +158,10 @@ export const pkiAcmeServiceFactory = ({ certificateProfileDAL }: TPkiAcmeService
const finalizeAcmeOrder = async (
profileId: string,
orderId: string,
body: TFinalizeAcmeOrderPayload
payload: TFinalizeAcmeOrderPayload
): Promise<TFinalizeAcmeOrderResponse> => {
const profile = await validateAcmeProfile(profileId);
const { csr } = body;
const { csr } = payload;
// FIXME: Implement ACME finalize order
return {
status: "processing",
@@ -196,6 +217,7 @@ export const pkiAcmeServiceFactory = ({ certificateProfileDAL }: TPkiAcmeService
};
return {
validateCreateAcmeAccountJwsPayload,
getAcmeDirectory,
getAcmeNewNonce,
createAcmeAccount,

View File

@@ -12,7 +12,10 @@ import {
GetAcmeAuthorizationResponseSchema,
GetAcmeDirectoryResponseSchema,
GetAcmeOrderResponseSchema,
JwsPayloadSchema,
ListAcmeOrdersResponseSchema,
ProtectedHeaderSchema,
RawJwsPayloadSchema,
RespondToAcmeChallengeResponseSchema
} from "./pki-acme-schemas";
@@ -28,12 +31,16 @@ export type TGetAcmeAuthorizationResponse = z.infer<typeof GetAcmeAuthorizationR
export type TRespondToAcmeChallengeResponse = z.infer<typeof RespondToAcmeChallengeResponseSchema>;
// Payload types
export type TRawJwsPayload = z.infer<typeof RawJwsPayloadSchema>;
export type TJwsPayload = z.infer<typeof JwsPayloadSchema>;
export type TProtectedHeader = z.infer<typeof ProtectedHeaderSchema>;
export type TCreateAcmeAccountPayload = z.infer<typeof CreateAcmeAccountBodySchema>;
export type TCreateAcmeOrderPayload = z.infer<typeof CreateAcmeOrderBodySchema>;
export type TDeactivateAcmeAccountPayload = z.infer<typeof DeactivateAcmeAccountBodySchema>;
export type TFinalizeAcmeOrderPayload = z.infer<typeof FinalizeAcmeOrderBodySchema>;
export type TPkiAcmeServiceFactory = {
validateCreateAcmeAccountJwsPayload(body: TRawJwsPayload): Promise<TJwsPayload>;
getAcmeDirectory: (profileId: string) => Promise<TGetAcmeDirectoryResponse>;
getAcmeNewNonce: (profileId: string) => Promise<string>;
createAcmeAccount: (profileId: string, body: TCreateAcmeAccountPayload) => Promise<TCreateAcmeAccountResponse>;