From 860af49de8d032aa8699c268d1523cdbc1e00884 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 24 Oct 2025 16:31:26 -0700 Subject: [PATCH 001/231] Add jose as the deps for up coming ACME stuff --- backend/package-lock.json | 26 +++++++++++++++++++++++--- backend/package.json | 1 + 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index a6cef3888..9cdaa764b 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -83,6 +83,7 @@ "ioredis": "^5.3.2", "isomorphic-dompurify": "^2.22.0", "jmespath": "^0.16.0", + "jose": "^6.1.0", "js-yaml": "^4.1.0", "jsonwebtoken": "^9.0.2", "jsrp": "^0.2.4", @@ -23236,9 +23237,10 @@ } }, "node_modules/jose": { - "version": "4.15.5", - "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.5.tgz", - "integrity": "sha512-jc7BFxgKPKi94uOvEmzlSWFFe2+vASyXaKUpdQKatWAESU2MWjDfFf0fdfc83CDKcA5QecabZeNLyfhe3yKNkg==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.0.tgz", + "integrity": "sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==", + "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" } @@ -23638,6 +23640,15 @@ } } }, + "node_modules/jwks-rsa/node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/jwks-rsa/node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -27590,6 +27601,15 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/openid-client/node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/openssl-wrapper": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/openssl-wrapper/-/openssl-wrapper-0.3.4.tgz", diff --git a/backend/package.json b/backend/package.json index 9bcd63cf1..fa4ee2f5f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -210,6 +210,7 @@ "ioredis": "^5.3.2", "isomorphic-dompurify": "^2.22.0", "jmespath": "^0.16.0", + "jose": "^6.1.0", "js-yaml": "^4.1.0", "jsonwebtoken": "^9.0.2", "jsrp": "^0.2.4", From 47ce6f1bb84e42b1b2cfd3a88df64b9e63b678aa Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Mon, 27 Oct 2025 10:01:00 -0700 Subject: [PATCH 002/231] Add boilerplate API endpoint code --- backend/src/ee/routes/v1/index.ts | 2 + backend/src/ee/routes/v1/pki-acme-router.ts | 439 ++++++++++++++++++++ 2 files changed, 441 insertions(+) create mode 100644 backend/src/ee/routes/v1/pki-acme-router.ts diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index 31847b503..a22cd4583 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -5,6 +5,7 @@ import { registerAccessApprovalRequestRouter } from "./access-approval-request-r import { registerAssumePrivilegeRouter } from "./assume-privilege-router"; import { AUDIT_LOG_STREAM_REGISTER_ROUTER_MAP, registerAuditLogStreamRouter } from "./audit-log-stream-routers"; import { registerCaCrlRouter } from "./certificate-authority-crl-router"; +import { registerPkiAcmeRouter } from "./pki-acme-router"; import { registerDeprecatedProjectRoleRouter } from "./deprecated-project-role-router"; import { registerDeprecatedProjectRouter } from "./deprecated-project-router"; import { registerDeprecatedSecretApprovalPolicyRouter } from "./deprecated-secret-approval-policy-router"; @@ -107,6 +108,7 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { await server.register( async (pkiRouter) => { await pkiRouter.register(registerCaCrlRouter, { prefix: "/crl" }); + await pkiRouter.register(registerPkiAcmeRouter, { prefix: "/acme" }); }, { prefix: "/pki" } ); diff --git a/backend/src/ee/routes/v1/pki-acme-router.ts b/backend/src/ee/routes/v1/pki-acme-router.ts new file mode 100644 index 000000000..a6b4ad8ae --- /dev/null +++ b/backend/src/ee/routes/v1/pki-acme-router.ts @@ -0,0 +1,439 @@ +/* eslint-disable @typescript-eslint/no-floating-promises */ +import { z } from "zod"; + +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; + +export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { + // GET /api/v1/pki/acme/profiles//directory + // Directory (RFC 8555 Section 7.1.1) + server.route({ + method: "GET", + url: "/profiles/:profileId/directory", + config: { + rateLimit: readLimit + }, + schema: { + description: "ACME Directory - provides URLs for the client to make API calls to", + params: z.object({ + profileId: z.string().uuid() + }), + response: { + 200: z.object({ + newNonce: z.string(), + newAccount: z.string(), + newOrder: z.string(), + revokeCert: z.string() + }) + } + }, + handler: async (req) => { + // FIXME: Implement ACME directory endpoint + // This endpoint should return the base URLs for ACME operations + return { + newNonce: `/api/v1/pki/acme/profiles/${req.params.profileId}/new-nonce`, + newAccount: `/api/v1/pki/acme/profiles/${req.params.profileId}/new-account`, + newOrder: `/api/v1/pki/acme/profiles/${req.params.profileId}/new-order`, + revokeCert: `/api/v1/pki/acme/profiles/${req.params.profileId}/revoke-cert` + }; + } + }); + + // HEAD /api/v1/pki/acme/profiles//new-nonce + // New Nonce (RFC 8555 Section 7.2) + server.route({ + method: "HEAD", + url: "/profiles/:profileId/new-nonce", + config: { + rateLimit: readLimit + }, + schema: { + description: "ACME New Nonce - generate a new nonce and return in Replay-Nonce header", + params: z.object({ + profileId: z.string().uuid() + }), + response: { + 200: z.object({}) + } + }, + handler: async (req, res) => { + // FIXME: Implement ACME new nonce generation + // Generate a new nonce, store it, and return it in the Replay-Nonce header + const nonce = "FIXME-generate-nonce"; + res.header("Replay-Nonce", nonce); + return {}; + } + }); + + // POST /api/v1/pki/acme/profiles//new-account + // New Account (RFC 8555 Section 7.3) + server.route({ + method: "POST", + url: "/profiles/:profileId/new-account", + config: { + rateLimit: writeLimit + }, + schema: { + description: "ACME New Account - register a new account or find existing one", + params: z.object({ + profileId: z.string().uuid() + }), + body: z.object({ + contact: z.array(z.string()).optional(), + termsOfServiceAgreed: z.boolean().optional(), + onlyReturnExisting: z.boolean().optional(), + externalAccountBinding: z + .object({ + protected: z.string(), + payload: z.string(), + signature: z.string() + }) + .optional() + }), + response: { + 201: z.object({ + status: z.string(), + contact: z.array(z.string()).optional(), + orders: z.string().optional(), + accountUrl: z.string() + }) + } + }, + handler: async (req) => { + // FIXME: Implement ACME new account registration + // Use EAB authentication to find corresponding Infisical machine identity + // Check permissions and return account information + return { + status: "valid", + accountUrl: `/api/v1/pki/acme/profiles/${req.params.profileId}/accounts/FIXME-account-id`, + contact: req.body.contact, + orders: `/api/v1/pki/acme/profiles/${req.params.profileId}/accounts/FIXME-account-id/orders` + }; + } + }); + + // POST /api/v1/pki/acme/profiles//new-order + // New Certificate Order (RFC 8555 Section 7.4) + server.route({ + method: "POST", + url: "/profiles/:profileId/new-order", + config: { + rateLimit: writeLimit + }, + schema: { + description: "ACME New Order - apply for a new certificate", + params: z.object({ + profileId: z.string().uuid() + }), + body: z.object({ + identifiers: z.array( + z.object({ + type: z.string(), + value: z.string() + }) + ), + notBefore: z.string().optional(), + notAfter: z.string().optional() + }), + response: { + 201: z.object({ + status: z.string(), + expires: z.string(), + identifiers: z.array( + z.object({ + type: z.string(), + value: z.string() + }) + ), + authorizations: z.array(z.string()), + finalize: z.string(), + certificate: z.string().optional() + }) + } + }, + handler: async (req) => { + // 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: req.body.identifiers, + authorizations: req.body.identifiers.map( + (id) => `/api/v1/pki/acme/profiles/${req.params.profileId}/authorizations/FIXME-authz-${id.value}` + ), + finalize: `/api/v1/pki/acme/profiles/${req.params.profileId}/orders/${orderId}/finalize` + }; + } + }); + + // POST /api/v1/pki/acme/profiles//accounts/ + // Account Deactivation (RFC 8555 Section 7.3.6) + server.route({ + method: "POST", + url: "/profiles/:profileId/accounts/:accountId", + config: { + rateLimit: writeLimit + }, + schema: { + description: "ACME Account Deactivation", + params: z.object({ + profileId: z.string().uuid(), + accountId: z.string() + }), + body: z.object({ + status: z.literal("deactivated") + }), + response: { + 200: z.object({ + status: z.string() + }) + } + }, + handler: async (req) => { + // FIXME: Implement ACME account deactivation + return { + status: "deactivated" + }; + } + }); + + // POST /api/v1/pki/acme/profiles//accounts//orders + // List Orders (RFC 8555 Section 7.1.2.1) + server.route({ + method: "POST", + url: "/profiles/:profileId/accounts/:accountId/orders", + config: { + rateLimit: readLimit + }, + schema: { + description: "ACME List Orders - get existing orders from current account", + params: z.object({ + profileId: z.string().uuid(), + accountId: z.string() + }), + response: { + 200: z.object({ + orders: z.array(z.string()) + }) + } + }, + handler: async (req) => { + // FIXME: Implement ACME list orders + return { + orders: [] + }; + } + }); + + // POST /api/v1/pki/acme/profiles//orders/ + // Get Order (RFC 8555 Section 7.1.3) + server.route({ + method: "POST", + url: "/profiles/:profileId/orders/:orderId", + config: { + rateLimit: readLimit + }, + schema: { + description: "ACME Get Order - return status and details of the order", + params: z.object({ + profileId: z.string().uuid(), + orderId: z.string() + }), + response: { + 200: z.object({ + status: z.string(), + expires: z.string().optional(), + identifiers: z.array( + z.object({ + type: z.string(), + value: z.string() + }) + ), + authorizations: z.array(z.string()), + finalize: z.string(), + certificate: z.string().optional() + }) + } + }, + handler: async (req) => { + // FIXME: Implement ACME get order + return { + status: "pending", + expires: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), + identifiers: [], + authorizations: [], + finalize: `/api/v1/pki/acme/profiles/${req.params.profileId}/orders/${req.params.orderId}/finalize` + }; + } + }); + + // POST /api/v1/pki/acme/profiles//orders//finalize + // Applying for Certificate Issuance (RFC 8555 Section 7.4) + server.route({ + method: "POST", + url: "/profiles/:profileId/orders/:orderId/finalize", + config: { + rateLimit: writeLimit + }, + schema: { + description: "ACME Finalize Order - finalize cert order by providing CSR", + params: z.object({ + profileId: z.string().uuid(), + orderId: z.string() + }), + body: z.object({ + csr: z.string() + }), + response: { + 200: z.object({ + status: z.string(), + expires: z.string().optional(), + identifiers: z.array( + z.object({ + type: z.string(), + value: z.string() + }) + ), + authorizations: z.array(z.string()), + finalize: z.string(), + certificate: z.string().optional() + }) + } + }, + handler: async (req) => { + // FIXME: Implement ACME finalize order + return { + status: "processing", + expires: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), + identifiers: [], + authorizations: [], + finalize: `/api/v1/pki/acme/profiles/${req.params.profileId}/orders/${req.params.orderId}/finalize`, + certificate: `/api/v1/pki/acme/profiles/${req.params.profileId}/orders/${req.params.orderId}/certificate` + }; + } + }); + + // POST /api/v1/pki/acme/profiles//orders//certificate + // Download Certificate (RFC 8555 Section 7.4.2) + server.route({ + method: "POST", + url: "/profiles/:profileId/orders/:orderId/certificate", + config: { + rateLimit: readLimit + }, + schema: { + description: "ACME Download Certificate - download certificate when ready", + params: z.object({ + profileId: z.string().uuid(), + orderId: z.string() + }), + response: { + 200: z.string() + } + }, + handler: async (req, res) => { + // FIXME: Implement ACME certificate download + // Return the certificate in PEM format + const certificate = "FIXME-certificate-pem"; + res.header("Content-Type", "application/pem-certificate-chain"); + return certificate; + } + }); + + // POST /api/v1/pki/acme/profiles//authorizations/ + // Identifier Authorization (RFC 8555 Section 7.5) + server.route({ + method: "POST", + url: "/profiles/:profileId/authorizations/:authzId", + config: { + rateLimit: readLimit + }, + schema: { + description: "ACME Identifier Authorization - get authorization info (challenges)", + params: z.object({ + profileId: z.string().uuid(), + authzId: z.string() + }), + response: { + 200: z.object({ + status: z.string(), + expires: z.string().optional(), + identifier: z.object({ + type: z.string(), + value: z.string() + }), + challenges: z.array( + z.object({ + type: z.string(), + url: z.string(), + status: z.string(), + token: z.string(), + validated: z.string().optional() + }) + ) + }) + } + }, + handler: async (req) => { + // 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: `/api/v1/pki/acme/profiles/${req.params.profileId}/authorizations/${req.params.authzId}/challenges/http-01`, + status: "pending", + token: "FIXME-challenge-token" + } + ] + }; + } + }); + + // POST /api/v1/pki/acme/profiles//authorizations//challenges/http-01 + // Respond to Challenge (RFC 8555 Section 7.5.1) + server.route({ + method: "POST", + url: "/profiles/:profileId/authorizations/:authzId/challenges/http-01", + config: { + rateLimit: writeLimit + }, + schema: { + description: "ACME Respond to Challenge - let ACME server know challenge is ready", + params: z.object({ + profileId: z.string().uuid(), + authzId: z.string() + }), + response: { + 200: z.object({ + type: z.string(), + url: z.string(), + status: z.string(), + token: z.string(), + validated: z.string().optional(), + error: z + .object({ + type: z.string(), + detail: z.string(), + status: z.number() + }) + .optional() + }) + } + }, + handler: async (req) => { + // FIXME: Implement ACME challenge response + // Trigger verification process + return { + type: "http-01", + url: `/api/v1/pki/acme/profiles/${req.params.profileId}/authorizations/${req.params.authzId}/challenges/http-01`, + status: "pending", + token: "FIXME-challenge-token" + }; + } + }); +}; From e8cc0c5d065a2242c869924da9848adeaef3d910 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Mon, 27 Oct 2025 10:09:36 -0700 Subject: [PATCH 003/231] Add API tag --- backend/src/ee/routes/v1/pki-acme-router.ts | 23 +++++++++++++++++++++ backend/src/lib/api-docs/constants.ts | 1 + 2 files changed, 24 insertions(+) diff --git a/backend/src/ee/routes/v1/pki-acme-router.ts b/backend/src/ee/routes/v1/pki-acme-router.ts index a6b4ad8ae..ee6c07f0a 100644 --- a/backend/src/ee/routes/v1/pki-acme-router.ts +++ b/backend/src/ee/routes/v1/pki-acme-router.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-floating-promises */ import { z } from "zod"; +import { ApiDocsTags } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { @@ -13,6 +14,8 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { rateLimit: readLimit }, schema: { + hide: false, + tags: [ApiDocsTags.PkiAcme], description: "ACME Directory - provides URLs for the client to make API calls to", params: z.object({ profileId: z.string().uuid() @@ -47,6 +50,8 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { rateLimit: readLimit }, schema: { + hide: false, + tags: [ApiDocsTags.PkiAcme], description: "ACME New Nonce - generate a new nonce and return in Replay-Nonce header", params: z.object({ profileId: z.string().uuid() @@ -73,6 +78,8 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { rateLimit: writeLimit }, schema: { + hide: false, + tags: [ApiDocsTags.PkiAcme], description: "ACME New Account - register a new account or find existing one", params: z.object({ profileId: z.string().uuid() @@ -120,6 +127,8 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { rateLimit: writeLimit }, schema: { + hide: false, + tags: [ApiDocsTags.PkiAcme], description: "ACME New Order - apply for a new certificate", params: z.object({ profileId: z.string().uuid() @@ -174,6 +183,8 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { rateLimit: writeLimit }, schema: { + hide: false, + tags: [ApiDocsTags.PkiAcme], description: "ACME Account Deactivation", params: z.object({ profileId: z.string().uuid(), @@ -205,6 +216,8 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { rateLimit: readLimit }, schema: { + hide: false, + tags: [ApiDocsTags.PkiAcme], description: "ACME List Orders - get existing orders from current account", params: z.object({ profileId: z.string().uuid(), @@ -233,6 +246,8 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { rateLimit: readLimit }, schema: { + hide: false, + tags: [ApiDocsTags.PkiAcme], description: "ACME Get Order - return status and details of the order", params: z.object({ profileId: z.string().uuid(), @@ -275,6 +290,8 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { rateLimit: writeLimit }, schema: { + hide: false, + tags: [ApiDocsTags.PkiAcme], description: "ACME Finalize Order - finalize cert order by providing CSR", params: z.object({ profileId: z.string().uuid(), @@ -321,6 +338,8 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { rateLimit: readLimit }, schema: { + hide: false, + tags: [ApiDocsTags.PkiAcme], description: "ACME Download Certificate - download certificate when ready", params: z.object({ profileId: z.string().uuid(), @@ -348,6 +367,8 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { rateLimit: readLimit }, schema: { + hide: false, + tags: [ApiDocsTags.PkiAcme], description: "ACME Identifier Authorization - get authorization info (challenges)", params: z.object({ profileId: z.string().uuid(), @@ -403,6 +424,8 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { rateLimit: writeLimit }, schema: { + hide: false, + tags: [ApiDocsTags.PkiAcme], description: "ACME Respond to Challenge - let ACME server know challenge is ready", params: z.object({ profileId: z.string().uuid(), diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 0cb606cbf..2a8fbadac 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -62,6 +62,7 @@ export enum ApiDocsTags { PkiCertificateCollections = "PKI Certificate Collections", PkiAlerting = "PKI Alerting", PkiSubscribers = "PKI Subscribers", + PkiAcme = "PKI ACME", SshCertificates = "SSH Certificates", SshCertificateAuthorities = "SSH Certificate Authorities", SshCertificateTemplates = "SSH Certificate Templates", From ad73e421e240f76891833d6b682bed910919e77d Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Mon, 27 Oct 2025 10:24:25 -0700 Subject: [PATCH 004/231] Add TODO --- backend/src/ee/routes/v1/pki-acme-router.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/src/ee/routes/v1/pki-acme-router.ts b/backend/src/ee/routes/v1/pki-acme-router.ts index ee6c07f0a..b308c8d58 100644 --- a/backend/src/ee/routes/v1/pki-acme-router.ts +++ b/backend/src/ee/routes/v1/pki-acme-router.ts @@ -47,6 +47,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { method: "HEAD", url: "/profiles/:profileId/new-nonce", config: { + // TODO: probably a different rate limit for nonce creation rateLimit: readLimit }, schema: { From 936b54073a9a9a747054ba971479a1d332c75568 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Mon, 27 Oct 2025 10:40:49 -0700 Subject: [PATCH 005/231] Extract things as service instead --- backend/src/@types/fastify.d.ts | 2 + backend/src/ee/routes/v1/pki-acme-router.ts | 315 ++++-------------- .../ee/services/pki-acme/pki-acme-schemas.ts | 210 ++++++++++++ .../ee/services/pki-acme/pki-acme-service.ts | 168 ++++++++++ .../ee/services/pki-acme/pki-acme-types.ts | 38 +++ backend/src/server/routes/index.ts | 6 + 6 files changed, 497 insertions(+), 242 deletions(-) create mode 100644 backend/src/ee/services/pki-acme/pki-acme-schemas.ts create mode 100644 backend/src/ee/services/pki-acme/pki-acme-service.ts create mode 100644 backend/src/ee/services/pki-acme/pki-acme-types.ts diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index a2bc332f8..be484b5fd 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -105,6 +105,7 @@ import { TPkiCollectionServiceFactory } from "@app/services/pki-collection/pki-c import { TPkiSubscriberServiceFactory } from "@app/services/pki-subscriber/pki-subscriber-service"; import { TPkiSyncServiceFactory } from "@app/services/pki-sync/pki-sync-service"; import { TPkiTemplatesServiceFactory } from "@app/services/pki-templates/pki-templates-service"; +import { TPkiAcmeServiceFactory } from "@app/ee/services/pki-acme/pki-acme-types"; import { TProjectServiceFactory } from "@app/services/project/project-service"; import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service"; import { TProjectEnvServiceFactory } from "@app/services/project-env/project-env-service"; @@ -294,6 +295,7 @@ declare module "fastify" { certificateAuthority: TCertificateAuthorityServiceFactory; certificateAuthorityCrl: TCertificateAuthorityCrlServiceFactory; certificateEst: TCertificateEstServiceFactory; + pkiAcme: TPkiAcmeServiceFactory; certificateEstV3: TCertificateEstV3ServiceFactory; pkiCollection: TPkiCollectionServiceFactory; pkiSubscriber: TPkiSubscriberServiceFactory; diff --git a/backend/src/ee/routes/v1/pki-acme-router.ts b/backend/src/ee/routes/v1/pki-acme-router.ts index b308c8d58..f9342a825 100644 --- a/backend/src/ee/routes/v1/pki-acme-router.ts +++ b/backend/src/ee/routes/v1/pki-acme-router.ts @@ -3,6 +3,28 @@ import { z } from "zod"; import { ApiDocsTags } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { + CreateAcmeAccountResponseSchema, + CreateAcmeAccountSchema, + CreateAcmeOrderResponseSchema, + CreateAcmeOrderSchema, + DeactivateAcmeAccountResponseSchema, + DeactivateAcmeAccountSchema, + DownloadAcmeCertificateSchema, + FinalizeAcmeOrderResponseSchema, + FinalizeAcmeOrderSchema, + GetAcmeAuthorizationResponseSchema, + GetAcmeAuthorizationSchema, + GetAcmeDirectoryResponseSchema, + GetAcmeDirectorySchema, + GetAcmeNewNonceSchema, + GetAcmeOrderResponseSchema, + GetAcmeOrderSchema, + ListAcmeOrdersResponseSchema, + ListAcmeOrdersSchema, + RespondToAcmeChallengeResponseSchema, + RespondToAcmeChallengeSchema +} from "@app/ee/services/pki-acme/pki-acme-schemas"; export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { // GET /api/v1/pki/acme/profiles//directory @@ -17,27 +39,14 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { hide: false, tags: [ApiDocsTags.PkiAcme], description: "ACME Directory - provides URLs for the client to make API calls to", - params: z.object({ - profileId: z.string().uuid() - }), + ...GetAcmeDirectorySchema.shape, response: { - 200: z.object({ - newNonce: z.string(), - newAccount: z.string(), - newOrder: z.string(), - revokeCert: z.string() - }) + 200: GetAcmeDirectoryResponseSchema } }, handler: async (req) => { - // FIXME: Implement ACME directory endpoint - // This endpoint should return the base URLs for ACME operations - return { - newNonce: `/api/v1/pki/acme/profiles/${req.params.profileId}/new-nonce`, - newAccount: `/api/v1/pki/acme/profiles/${req.params.profileId}/new-account`, - newOrder: `/api/v1/pki/acme/profiles/${req.params.profileId}/new-order`, - revokeCert: `/api/v1/pki/acme/profiles/${req.params.profileId}/revoke-cert` - }; + const directory = await server.services.pkiAcme.getAcmeDirectory(req.params.profileId); + return directory; } }); @@ -54,17 +63,13 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { hide: false, tags: [ApiDocsTags.PkiAcme], description: "ACME New Nonce - generate a new nonce and return in Replay-Nonce header", - params: z.object({ - profileId: z.string().uuid() - }), + ...GetAcmeNewNonceSchema.shape, response: { 200: z.object({}) } }, handler: async (req, res) => { - // FIXME: Implement ACME new nonce generation - // Generate a new nonce, store it, and return it in the Replay-Nonce header - const nonce = "FIXME-generate-nonce"; + const nonce = await server.services.pkiAcme.getAcmeNewNonce(req.params.profileId); res.header("Replay-Nonce", nonce); return {}; } @@ -82,40 +87,15 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { hide: false, tags: [ApiDocsTags.PkiAcme], description: "ACME New Account - register a new account or find existing one", - params: z.object({ - profileId: z.string().uuid() - }), - body: z.object({ - contact: z.array(z.string()).optional(), - termsOfServiceAgreed: z.boolean().optional(), - onlyReturnExisting: z.boolean().optional(), - externalAccountBinding: z - .object({ - protected: z.string(), - payload: z.string(), - signature: z.string() - }) - .optional() - }), + ...CreateAcmeAccountSchema.shape, response: { - 201: z.object({ - status: z.string(), - contact: z.array(z.string()).optional(), - orders: z.string().optional(), - accountUrl: z.string() - }) + 201: CreateAcmeAccountResponseSchema } }, - handler: async (req) => { - // FIXME: Implement ACME new account registration - // Use EAB authentication to find corresponding Infisical machine identity - // Check permissions and return account information - return { - status: "valid", - accountUrl: `/api/v1/pki/acme/profiles/${req.params.profileId}/accounts/FIXME-account-id`, - contact: req.body.contact, - orders: `/api/v1/pki/acme/profiles/${req.params.profileId}/accounts/FIXME-account-id/orders` - }; + handler: async (req, res) => { + const account = await server.services.pkiAcme.createAcmeAccount(req.params.profileId, req.body); + res.code(201); + return account; } }); @@ -131,47 +111,15 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { hide: false, tags: [ApiDocsTags.PkiAcme], description: "ACME New Order - apply for a new certificate", - params: z.object({ - profileId: z.string().uuid() - }), - body: z.object({ - identifiers: z.array( - z.object({ - type: z.string(), - value: z.string() - }) - ), - notBefore: z.string().optional(), - notAfter: z.string().optional() - }), + ...CreateAcmeOrderSchema.shape, response: { - 201: z.object({ - status: z.string(), - expires: z.string(), - identifiers: z.array( - z.object({ - type: z.string(), - value: z.string() - }) - ), - authorizations: z.array(z.string()), - finalize: z.string(), - certificate: z.string().optional() - }) + 201: CreateAcmeOrderResponseSchema } }, - handler: async (req) => { - // 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: req.body.identifiers, - authorizations: req.body.identifiers.map( - (id) => `/api/v1/pki/acme/profiles/${req.params.profileId}/authorizations/FIXME-authz-${id.value}` - ), - finalize: `/api/v1/pki/acme/profiles/${req.params.profileId}/orders/${orderId}/finalize` - }; + handler: async (req, res) => { + const order = await server.services.pkiAcme.createAcmeOrder(req.params.profileId, req.body); + res.code(201); + return order; } }); @@ -187,24 +135,14 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { hide: false, tags: [ApiDocsTags.PkiAcme], description: "ACME Account Deactivation", - params: z.object({ - profileId: z.string().uuid(), - accountId: z.string() - }), - body: z.object({ - status: z.literal("deactivated") - }), + ...DeactivateAcmeAccountSchema.shape, response: { - 200: z.object({ - status: z.string() - }) + 200: DeactivateAcmeAccountResponseSchema } }, handler: async (req) => { - // FIXME: Implement ACME account deactivation - return { - status: "deactivated" - }; + const result = await server.services.pkiAcme.deactivateAcmeAccount(req.params.profileId, req.params.accountId); + return result; } }); @@ -220,21 +158,14 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { hide: false, tags: [ApiDocsTags.PkiAcme], description: "ACME List Orders - get existing orders from current account", - params: z.object({ - profileId: z.string().uuid(), - accountId: z.string() - }), + ...ListAcmeOrdersSchema.shape, response: { - 200: z.object({ - orders: z.array(z.string()) - }) + 200: ListAcmeOrdersResponseSchema } }, handler: async (req) => { - // FIXME: Implement ACME list orders - return { - orders: [] - }; + const orders = await server.services.pkiAcme.listAcmeOrders(req.params.profileId, req.params.accountId); + return orders; } }); @@ -250,35 +181,14 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { hide: false, tags: [ApiDocsTags.PkiAcme], description: "ACME Get Order - return status and details of the order", - params: z.object({ - profileId: z.string().uuid(), - orderId: z.string() - }), + ...GetAcmeOrderSchema.shape, response: { - 200: z.object({ - status: z.string(), - expires: z.string().optional(), - identifiers: z.array( - z.object({ - type: z.string(), - value: z.string() - }) - ), - authorizations: z.array(z.string()), - finalize: z.string(), - certificate: z.string().optional() - }) + 200: GetAcmeOrderResponseSchema } }, handler: async (req) => { - // FIXME: Implement ACME get order - return { - status: "pending", - expires: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), - identifiers: [], - authorizations: [], - finalize: `/api/v1/pki/acme/profiles/${req.params.profileId}/orders/${req.params.orderId}/finalize` - }; + const order = await server.services.pkiAcme.getAcmeOrder(req.params.profileId, req.params.orderId); + return order; } }); @@ -294,39 +204,18 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { hide: false, tags: [ApiDocsTags.PkiAcme], description: "ACME Finalize Order - finalize cert order by providing CSR", - params: z.object({ - profileId: z.string().uuid(), - orderId: z.string() - }), - body: z.object({ - csr: z.string() - }), + ...FinalizeAcmeOrderSchema.shape, response: { - 200: z.object({ - status: z.string(), - expires: z.string().optional(), - identifiers: z.array( - z.object({ - type: z.string(), - value: z.string() - }) - ), - authorizations: z.array(z.string()), - finalize: z.string(), - certificate: z.string().optional() - }) + 200: FinalizeAcmeOrderResponseSchema } }, handler: async (req) => { - // FIXME: Implement ACME finalize order - return { - status: "processing", - expires: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), - identifiers: [], - authorizations: [], - finalize: `/api/v1/pki/acme/profiles/${req.params.profileId}/orders/${req.params.orderId}/finalize`, - certificate: `/api/v1/pki/acme/profiles/${req.params.profileId}/orders/${req.params.orderId}/certificate` - }; + const order = await server.services.pkiAcme.finalizeAcmeOrder( + req.params.profileId, + req.params.orderId, + req.body.csr + ); + return order; } }); @@ -342,18 +231,16 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { hide: false, tags: [ApiDocsTags.PkiAcme], description: "ACME Download Certificate - download certificate when ready", - params: z.object({ - profileId: z.string().uuid(), - orderId: z.string() - }), + ...DownloadAcmeCertificateSchema.shape, response: { 200: z.string() } }, handler: async (req, res) => { - // FIXME: Implement ACME certificate download - // Return the certificate in PEM format - const certificate = "FIXME-certificate-pem"; + const certificate = await server.services.pkiAcme.downloadAcmeCertificate( + req.params.profileId, + req.params.orderId + ); res.header("Content-Type", "application/pem-certificate-chain"); return certificate; } @@ -371,48 +258,14 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { hide: false, tags: [ApiDocsTags.PkiAcme], description: "ACME Identifier Authorization - get authorization info (challenges)", - params: z.object({ - profileId: z.string().uuid(), - authzId: z.string() - }), + ...GetAcmeAuthorizationSchema.shape, response: { - 200: z.object({ - status: z.string(), - expires: z.string().optional(), - identifier: z.object({ - type: z.string(), - value: z.string() - }), - challenges: z.array( - z.object({ - type: z.string(), - url: z.string(), - status: z.string(), - token: z.string(), - validated: z.string().optional() - }) - ) - }) + 200: GetAcmeAuthorizationResponseSchema } }, handler: async (req) => { - // 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: `/api/v1/pki/acme/profiles/${req.params.profileId}/authorizations/${req.params.authzId}/challenges/http-01`, - status: "pending", - token: "FIXME-challenge-token" - } - ] - }; + const authz = await server.services.pkiAcme.getAcmeAuthorization(req.params.profileId, req.params.authzId); + return authz; } }); @@ -428,36 +281,14 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { hide: false, tags: [ApiDocsTags.PkiAcme], description: "ACME Respond to Challenge - let ACME server know challenge is ready", - params: z.object({ - profileId: z.string().uuid(), - authzId: z.string() - }), + ...RespondToAcmeChallengeSchema.shape, response: { - 200: z.object({ - type: z.string(), - url: z.string(), - status: z.string(), - token: z.string(), - validated: z.string().optional(), - error: z - .object({ - type: z.string(), - detail: z.string(), - status: z.number() - }) - .optional() - }) + 200: RespondToAcmeChallengeResponseSchema } }, handler: async (req) => { - // FIXME: Implement ACME challenge response - // Trigger verification process - return { - type: "http-01", - url: `/api/v1/pki/acme/profiles/${req.params.profileId}/authorizations/${req.params.authzId}/challenges/http-01`, - status: "pending", - token: "FIXME-challenge-token" - }; + const challenge = await server.services.pkiAcme.respondToAcmeChallenge(req.params.profileId, req.params.authzId); + return challenge; } }); }; diff --git a/backend/src/ee/services/pki-acme/pki-acme-schemas.ts b/backend/src/ee/services/pki-acme/pki-acme-schemas.ts new file mode 100644 index 000000000..6cfee8ecc --- /dev/null +++ b/backend/src/ee/services/pki-acme/pki-acme-schemas.ts @@ -0,0 +1,210 @@ +import { z } from "zod"; + +// Directory endpoint +export const GetAcmeDirectorySchema = z.object({ + params: z.object({ + profileId: z.string().uuid() + }) +}); + +export const GetAcmeDirectoryResponseSchema = z.object({ + newNonce: z.string(), + newAccount: z.string(), + newOrder: z.string(), + revokeCert: z.string() +}); + +// New Nonce endpoint +export const GetAcmeNewNonceSchema = z.object({ + params: z.object({ + profileId: z.string().uuid() + }) +}); + +// New Account endpoint +export const CreateAcmeAccountSchema = z.object({ + params: z.object({ + profileId: z.string().uuid() + }), + body: z.object({ + contact: z.array(z.string()).optional(), + termsOfServiceAgreed: z.boolean().optional(), + onlyReturnExisting: z.boolean().optional(), + externalAccountBinding: z + .object({ + protected: z.string(), + payload: z.string(), + signature: z.string() + }) + .optional() + }) +}); + +export const CreateAcmeAccountResponseSchema = z.object({ + status: z.string(), + contact: z.array(z.string()).optional(), + orders: z.string().optional(), + accountUrl: z.string() +}); + +// New Order endpoint +export const CreateAcmeOrderSchema = z.object({ + params: z.object({ + profileId: z.string().uuid() + }), + body: z.object({ + identifiers: z.array( + z.object({ + type: z.string(), + value: z.string() + }) + ), + notBefore: z.string().optional(), + notAfter: z.string().optional() + }) +}); + +export const CreateAcmeOrderResponseSchema = z.object({ + status: z.string(), + expires: z.string(), + identifiers: z.array( + z.object({ + type: z.string(), + value: z.string() + }) + ), + authorizations: z.array(z.string()), + finalize: z.string(), + certificate: z.string().optional() +}); + +// Account Deactivation endpoint +export const DeactivateAcmeAccountSchema = z.object({ + params: z.object({ + profileId: z.string().uuid(), + accountId: z.string() + }), + body: z.object({ + status: z.literal("deactivated") + }) +}); + +export const DeactivateAcmeAccountResponseSchema = z.object({ + status: z.string() +}); + +// List Orders endpoint +export const ListAcmeOrdersSchema = z.object({ + params: z.object({ + profileId: z.string().uuid(), + accountId: z.string() + }) +}); + +export const ListAcmeOrdersResponseSchema = z.object({ + orders: z.array(z.string()) +}); + +// Get Order endpoint +export const GetAcmeOrderSchema = z.object({ + params: z.object({ + profileId: z.string().uuid(), + orderId: z.string() + }) +}); + +export const GetAcmeOrderResponseSchema = z.object({ + status: z.string(), + expires: z.string().optional(), + identifiers: z.array( + z.object({ + type: z.string(), + value: z.string() + }) + ), + authorizations: z.array(z.string()), + finalize: z.string(), + certificate: z.string().optional() +}); + +// Finalize Order endpoint +export const FinalizeAcmeOrderSchema = z.object({ + params: z.object({ + profileId: z.string().uuid(), + orderId: z.string() + }), + body: z.object({ + csr: z.string() + }) +}); + +export const FinalizeAcmeOrderResponseSchema = z.object({ + status: z.string(), + expires: z.string().optional(), + identifiers: z.array( + z.object({ + type: z.string(), + value: z.string() + }) + ), + authorizations: z.array(z.string()), + finalize: z.string(), + certificate: z.string().optional() +}); + +// Download Certificate endpoint +export const DownloadAcmeCertificateSchema = z.object({ + params: z.object({ + profileId: z.string().uuid(), + orderId: z.string() + }) +}); + +// Get Authorization endpoint +export const GetAcmeAuthorizationSchema = z.object({ + params: z.object({ + profileId: z.string().uuid(), + authzId: z.string() + }) +}); + +export const GetAcmeAuthorizationResponseSchema = z.object({ + status: z.string(), + expires: z.string().optional(), + identifier: z.object({ + type: z.string(), + value: z.string() + }), + challenges: z.array( + z.object({ + type: z.string(), + url: z.string(), + status: z.string(), + token: z.string(), + validated: z.string().optional() + }) + ) +}); + +// Respond to Challenge endpoint +export const RespondToAcmeChallengeSchema = z.object({ + params: z.object({ + profileId: z.string().uuid(), + authzId: z.string() + }) +}); + +export const RespondToAcmeChallengeResponseSchema = z.object({ + type: z.string(), + url: z.string(), + status: z.string(), + token: z.string(), + validated: z.string().optional(), + error: z + .object({ + type: z.string(), + detail: z.string(), + status: z.number() + }) + .optional() +}); diff --git a/backend/src/ee/services/pki-acme/pki-acme-service.ts b/backend/src/ee/services/pki-acme/pki-acme-service.ts new file mode 100644 index 000000000..1ece387e0 --- /dev/null +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -0,0 +1,168 @@ +import { NotFoundError } from "@app/lib/errors"; + +import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; + +import { + TCreateAcmeAccountResponse, + TCreateAcmeOrderResponse, + TDeactivateAcmeAccountResponse, + TDownloadAcmeCertificateDTO, + TFinalizeAcmeOrderResponse, + TGetAcmeAuthorizationResponse, + TGetAcmeDirectoryResponse, + TGetAcmeOrderResponse, + TListAcmeOrdersResponse, + TPkiAcmeServiceFactory, + TRespondToAcmeChallengeResponse +} from "./pki-acme-types"; + +type TPkiAcmeServiceFactoryDep = { + certificateProfileDAL: Pick; +}; + +export const pkiAcmeServiceFactory = ({ certificateProfileDAL }: TPkiAcmeServiceFactoryDep): TPkiAcmeServiceFactory => { + const getAcmeDirectory = async (profileId: string): Promise => { + // FIXME: Implement ACME directory endpoint + // Validate profile exists and is for ACME enrollment + const profile = await certificateProfileDAL.findById(profileId); + if (!profile) { + throw new NotFoundError({ message: "Certificate profile not found" }); + } + + // FIXME: Validate profile is configured for ACME enrollment + return { + newNonce: `/api/v1/pki/acme/profiles/${profileId}/new-nonce`, + newAccount: `/api/v1/pki/acme/profiles/${profileId}/new-account`, + newOrder: `/api/v1/pki/acme/profiles/${profileId}/new-order`, + revokeCert: `/api/v1/pki/acme/profiles/${profileId}/revoke-cert` + }; + }; + + const getAcmeNewNonce = async (profileId: string): Promise => { + // FIXME: Implement ACME new nonce generation + // Generate a new nonce, store it, and return it + return "FIXME-generate-nonce"; + }; + + const createAcmeAccount = async (profileId: string, body: unknown): Promise => { + // FIXME: Implement ACME new account registration + // Use EAB authentication to find corresponding Infisical machine identity + // Check permissions and return account information + return { + status: "valid", + accountUrl: `/api/v1/pki/acme/profiles/${profileId}/accounts/FIXME-account-id`, + contact: [], + orders: `/api/v1/pki/acme/profiles/${profileId}/accounts/FIXME-account-id/orders` + }; + }; + + const createAcmeOrder = async (profileId: string, body: unknown): Promise => { + // 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: `/api/v1/pki/acme/profiles/${profileId}/orders/${orderId}/finalize` + }; + }; + + const deactivateAcmeAccount = async ( + profileId: string, + accountId: string + ): Promise => { + // FIXME: Implement ACME account deactivation + return { + status: "deactivated" + }; + }; + + const listAcmeOrders = async (profileId: string, accountId: string): Promise => { + // FIXME: Implement ACME list orders + return { + orders: [] + }; + }; + + const getAcmeOrder = async (profileId: string, orderId: string): Promise => { + // FIXME: Implement ACME get order + return { + status: "pending", + expires: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), + identifiers: [], + authorizations: [], + finalize: `/api/v1/pki/acme/profiles/${profileId}/orders/${orderId}/finalize` + }; + }; + + const finalizeAcmeOrder = async ( + profileId: string, + orderId: string, + csr: string + ): Promise => { + // FIXME: Implement ACME finalize order + return { + status: "processing", + expires: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), + identifiers: [], + authorizations: [], + finalize: `/api/v1/pki/acme/profiles/${profileId}/orders/${orderId}/finalize`, + certificate: `/api/v1/pki/acme/profiles/${profileId}/orders/${orderId}/certificate` + }; + }; + + const downloadAcmeCertificate = async (profileId: string, orderId: string): Promise => { + // FIXME: Implement ACME certificate download + // Return the certificate in PEM format + return "FIXME-certificate-pem"; + }; + + const getAcmeAuthorization = async (profileId: string, authzId: string): Promise => { + // 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: `/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 => { + // FIXME: Implement ACME challenge response + // Trigger verification process + return { + type: "http-01", + url: `/api/v1/pki/acme/profiles/${profileId}/authorizations/${authzId}/challenges/http-01`, + status: "pending", + token: "FIXME-challenge-token" + }; + }; + + return { + getAcmeDirectory, + getAcmeNewNonce, + createAcmeAccount, + createAcmeOrder, + deactivateAcmeAccount, + listAcmeOrders, + getAcmeOrder, + finalizeAcmeOrder, + downloadAcmeCertificate, + getAcmeAuthorization, + respondToAcmeChallenge + }; +}; diff --git a/backend/src/ee/services/pki-acme/pki-acme-types.ts b/backend/src/ee/services/pki-acme/pki-acme-types.ts new file mode 100644 index 000000000..4a80ab0f6 --- /dev/null +++ b/backend/src/ee/services/pki-acme/pki-acme-types.ts @@ -0,0 +1,38 @@ +import { z } from "zod"; + +import { + CreateAcmeAccountResponseSchema, + CreateAcmeOrderResponseSchema, + DeactivateAcmeAccountResponseSchema, + FinalizeAcmeOrderResponseSchema, + GetAcmeAuthorizationResponseSchema, + GetAcmeDirectoryResponseSchema, + GetAcmeOrderResponseSchema, + ListAcmeOrdersResponseSchema, + RespondToAcmeChallengeResponseSchema +} from "./pki-acme-schemas"; + +export type TGetAcmeDirectoryResponse = z.infer; +export type TCreateAcmeAccountResponse = z.infer; +export type TCreateAcmeOrderResponse = z.infer; +export type TDeactivateAcmeAccountResponse = z.infer; +export type TListAcmeOrdersResponse = z.infer; +export type TGetAcmeOrderResponse = z.infer; +export type TFinalizeAcmeOrderResponse = z.infer; +export type TDownloadAcmeCertificateDTO = string; +export type TGetAcmeAuthorizationResponse = z.infer; +export type TRespondToAcmeChallengeResponse = z.infer; + +export type TPkiAcmeServiceFactory = { + getAcmeDirectory: (profileId: string) => Promise; + getAcmeNewNonce: (profileId: string) => Promise; + createAcmeAccount: (profileId: string, body: unknown) => Promise; + createAcmeOrder: (profileId: string, body: unknown) => Promise; + deactivateAcmeAccount: (profileId: string, accountId: string) => Promise; + listAcmeOrders: (profileId: string, accountId: string) => Promise; + getAcmeOrder: (profileId: string, orderId: string) => Promise; + finalizeAcmeOrder: (profileId: string, orderId: string, csr: string) => Promise; + downloadAcmeCertificate: (profileId: string, orderId: string) => Promise; + getAcmeAuthorization: (profileId: string, authzId: string) => Promise; + respondToAcmeChallenge: (profileId: string, authzId: string) => Promise; +}; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index e2009e7fa..0e25e9aee 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -25,6 +25,7 @@ import { auditLogStreamServiceFactory } from "@app/ee/services/audit-log-stream/ import { certificateAuthorityCrlDALFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-dal"; import { certificateAuthorityCrlServiceFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-service"; import { certificateEstServiceFactory } from "@app/ee/services/certificate-est/certificate-est-service"; +import { pkiAcmeServiceFactory } from "@app/ee/services/pki-acme/pki-acme-service"; import { dynamicSecretDALFactory } from "@app/ee/services/dynamic-secret/dynamic-secret-dal"; import { dynamicSecretServiceFactory } from "@app/ee/services/dynamic-secret/dynamic-secret-service"; import { buildDynamicSecretProviders } from "@app/ee/services/dynamic-secret/providers"; @@ -1161,6 +1162,10 @@ export const registerRoutes = async ( projectDAL }); + const pkiAcmeService = pkiAcmeServiceFactory({ + certificateProfileDAL + }); + const pkiAlertService = pkiAlertServiceFactory({ pkiAlertDAL, pkiCollectionDAL, @@ -2436,6 +2441,7 @@ export const registerRoutes = async ( certificateProfile: certificateProfileService, certificateAuthorityCrl: certificateAuthorityCrlService, certificateEst: certificateEstService, + pkiAcme: pkiAcmeService, pit: pitService, pkiAlert: pkiAlertService, pkiCollection: pkiCollectionService, From 375a74fc261de0d44c5f30fd769a2f767a9050f4 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Mon, 27 Oct 2025 11:55:39 -0700 Subject: [PATCH 006/231] Add bdd stuff --- backend/bdd/.python-version | 1 + backend/bdd/README.md | 0 backend/bdd/features/environment.py | 5 + .../bdd/features/pki/nonce/pki-acme.feature | 5 + backend/bdd/features/steps/pki_acme.py | 35 ++++ backend/bdd/pyproject.toml | 10 + backend/bdd/uv.lock | 180 ++++++++++++++++++ 7 files changed, 236 insertions(+) create mode 100644 backend/bdd/.python-version create mode 100644 backend/bdd/README.md create mode 100644 backend/bdd/features/environment.py create mode 100644 backend/bdd/features/pki/nonce/pki-acme.feature create mode 100644 backend/bdd/features/steps/pki_acme.py create mode 100644 backend/bdd/pyproject.toml create mode 100644 backend/bdd/uv.lock diff --git a/backend/bdd/.python-version b/backend/bdd/.python-version new file mode 100644 index 000000000..e4fba2183 --- /dev/null +++ b/backend/bdd/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/backend/bdd/README.md b/backend/bdd/README.md new file mode 100644 index 000000000..e69de29bb diff --git a/backend/bdd/features/environment.py b/backend/bdd/features/environment.py new file mode 100644 index 000000000..33d5238a0 --- /dev/null +++ b/backend/bdd/features/environment.py @@ -0,0 +1,5 @@ +from behave.runner import Context + + +def before_all(context: Context): + context.vars = {} diff --git a/backend/bdd/features/pki/nonce/pki-acme.feature b/backend/bdd/features/pki/nonce/pki-acme.feature new file mode 100644 index 000000000..f8a1bd890 --- /dev/null +++ b/backend/bdd/features/pki/nonce/pki-acme.feature @@ -0,0 +1,5 @@ +Feature: Nonce + Scenario: Generate a new nonce + Given I have a PKI project as "pki_project" + When I send a HEAD request to "/v1/pki/acme/profiles/{pki_project.id}/new-nonce" + Then the response header "Replay-Nonce" should contains non-empty value diff --git a/backend/bdd/features/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py new file mode 100644 index 000000000..dd173a927 --- /dev/null +++ b/backend/bdd/features/steps/pki_acme.py @@ -0,0 +1,35 @@ +from behave.runner import Context + +import httpx +from behave import given +from behave import when +from behave import then + + +BASE_URL = "http://localhost:8080" + + +class PkiProject: + def __init__(self, id: str): + self.id = id + + +@given('I have a PKI project as "{project_var}"') +def step_impl(context: Context, project_var: str): + # TODO: Fixed value for now, just to make test much easier, + # we should call infisical API to create such project instead + # in the future + project_id = "c051e74c-48a7-4724-832c-d5b496698546" + context.vars[project_var] = PkiProject(project_id) + + +@when('I send a {method} request to "{url}"') +def step_impl(context: Context, method: str, url: str): + with httpx.Client(base_url=BASE_URL) as client: + context.response = client.request(method, url.format(**context.vars)) + + +@then('the response header "{header}" should contains non-empty value') +def step_impl(context: Context, header: str): + header_value = context.response.headers.get(header) + assert header_value diff --git a/backend/bdd/pyproject.toml b/backend/bdd/pyproject.toml new file mode 100644 index 000000000..dbc38df6c --- /dev/null +++ b/backend/bdd/pyproject.toml @@ -0,0 +1,10 @@ +[project] +name = "bdd" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "behave>=1.3.3", + "httpx>=0.28.1", +] diff --git a/backend/bdd/uv.lock b/backend/bdd/uv.lock new file mode 100644 index 000000000..26c3fbb5e --- /dev/null +++ b/backend/bdd/uv.lock @@ -0,0 +1,180 @@ +version = 1 +revision = 2 +requires-python = ">=3.12" + +[[package]] +name = "anyio" +version = "4.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, +] + +[[package]] +name = "bdd" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "behave" }, + { name = "httpx" }, +] + +[package.metadata] +requires-dist = [ + { name = "behave", specifier = ">=1.3.3" }, + { name = "httpx", specifier = ">=0.28.1" }, +] + +[[package]] +name = "behave" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama" }, + { name = "cucumber-expressions" }, + { name = "cucumber-tag-expressions" }, + { name = "parse" }, + { name = "parse-type" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/62/51/f37442fe648b3e35ecf69bee803fa6db3f74c5b46d6c882d0bc5654185a2/behave-1.3.3.tar.gz", hash = "sha256:2b8f4b64ed2ea756a5a2a73e23defc1c4631e9e724c499e46661778453ebaf51", size = 892639, upload-time = "2025-09-04T12:12:02.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/71/06f74ffed6d74525c5cd6677c97bd2df0b7649e47a249cf6a0c2038083b2/behave-1.3.3-py2.py3-none-any.whl", hash = "sha256:89bdb62af8fb9f147ce245736a5de69f025e5edfb66f1fbe16c5007493f842c0", size = 223594, upload-time = "2025-09-04T12:12:00.3Z" }, +] + +[[package]] +name = "certifi" +version = "2025.10.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", size = 164519, upload-time = "2025-10-05T04:12:15.808Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cucumber-expressions" +version = "18.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c6/7d/f4e231167b23b3d7348aa1c90117ce8854fae186d6984ad66d705df24061/cucumber_expressions-18.0.1.tar.gz", hash = "sha256:86ce41bf28ee520408416f38022e5a083d815edf04a0bd1dae46d474ca597c60", size = 22232, upload-time = "2024-10-28T11:38:48.672Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/e0/31ce90dad5234c3d52432bfce7562aa11cda4848aea90936a4be6c67d7ab/cucumber_expressions-18.0.1-py3-none-any.whl", hash = "sha256:86230d503cdda7ef35a1f2072a882d7d57c740aa4c163c82b07f039b6bc60c42", size = 20211, upload-time = "2024-10-28T11:38:47.101Z" }, +] + +[[package]] +name = "cucumber-tag-expressions" +version = "8.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/77/b8868653e9c7d432433d4d4d5e99d5923b309b89c8b08bc7f0cb5657ba0b/cucumber_tag_expressions-8.0.0.tar.gz", hash = "sha256:4af80282ff0349918c332428176089094019af6e2a381a2fd8f1c62a7a6bb7e8", size = 8427, upload-time = "2025-10-14T17:01:27.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/51/51ae3ab3b8553ec61f6558e9a0a9e8c500a9db844f9cf00a732b19c9a6ea/cucumber_tag_expressions-8.0.0-py3-none-any.whl", hash = "sha256:bfe552226f62a4462ee91c9643582f524af84ac84952643fb09057580cbb110a", size = 9726, upload-time = "2025-10-14T17:01:26.098Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "parse" +version = "1.20.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/78/d9b09ba24bb36ef8b83b71be547e118d46214735b6dfb39e4bfde0e9b9dd/parse-1.20.2.tar.gz", hash = "sha256:b41d604d16503c79d81af5165155c0b20f6c8d6c559efa66b4b695c3e5a0a0ce", size = 29391, upload-time = "2024-06-11T04:41:57.34Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/31/ba45bf0b2aa7898d81cbbfac0e88c267befb59ad91a19e36e1bc5578ddb1/parse-1.20.2-py2.py3-none-any.whl", hash = "sha256:967095588cb802add9177d0c0b6133b5ba33b1ea9007ca800e526f42a85af558", size = 20126, upload-time = "2024-06-11T04:41:55.057Z" }, +] + +[[package]] +name = "parse-type" +version = "0.6.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parse" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/ea/42ba6ce0abba04ab6e0b997dcb9b528a4661b62af1fe1b0d498120d5ea78/parse_type-0.6.6.tar.gz", hash = "sha256:513a3784104839770d690e04339a8b4d33439fcd5dd99f2e4580f9fc1097bfb2", size = 98012, upload-time = "2025-08-11T22:53:48.066Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/8d/eef3d8cdccc32abdd91b1286884c99b8c3a6d3b135affcc2a7a0f383bb32/parse_type-0.6.6-py2.py3-none-any.whl", hash = "sha256:3ca79bbe71e170dfccc8ec6c341edfd1c2a0fc1e5cfd18330f93af938de2348c", size = 27085, upload-time = "2025-08-11T22:53:46.396Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] From d3539e1d3ae55afceea7fd78ac94c5342ccaf797 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Mon, 27 Oct 2025 12:10:33 -0700 Subject: [PATCH 007/231] Add more steps --- backend/bdd/features/environment.py | 9 +++++++++ .../bdd/features/pki/nonce/pki-acme.feature | 3 ++- backend/bdd/features/steps/pki_acme.py | 20 ++++++++++++------- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/backend/bdd/features/environment.py b/backend/bdd/features/environment.py index 33d5238a0..20ffe31c6 100644 --- a/backend/bdd/features/environment.py +++ b/backend/bdd/features/environment.py @@ -1,5 +1,14 @@ +import os + +import httpx from behave.runner import Context +BASE_URL = os.environ.get("INFISICAL_API_URL", "http://localhost:8080") +AUTH_TOKEN = os.environ.get("INFISICAL_TOKEN") + def before_all(context: Context): context.vars = {} + context.http_client = httpx.Client( + base_url=BASE_URL, headers={"Authorization": f"Bearer {AUTH_TOKEN}"} + ) diff --git a/backend/bdd/features/pki/nonce/pki-acme.feature b/backend/bdd/features/pki/nonce/pki-acme.feature index f8a1bd890..77f408ba3 100644 --- a/backend/bdd/features/pki/nonce/pki-acme.feature +++ b/backend/bdd/features/pki/nonce/pki-acme.feature @@ -1,5 +1,6 @@ Feature: Nonce Scenario: Generate a new nonce Given I have a PKI project as "pki_project" - When I send a HEAD request to "/v1/pki/acme/profiles/{pki_project.id}/new-nonce" + When I send a HEAD request to "/api/v1/pki/acme/profiles/{pki_project.id}/new-nonce" + Then the response status code should be "200" Then the response header "Replay-Nonce" should contains non-empty value diff --git a/backend/bdd/features/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py index dd173a927..d1ead5c23 100644 --- a/backend/bdd/features/steps/pki_acme.py +++ b/backend/bdd/features/steps/pki_acme.py @@ -1,14 +1,10 @@ from behave.runner import Context -import httpx from behave import given from behave import when from behave import then -BASE_URL = "http://localhost:8080" - - class PkiProject: def __init__(self, id: str): self.id = id @@ -25,11 +21,21 @@ def step_impl(context: Context, project_var: str): @when('I send a {method} request to "{url}"') def step_impl(context: Context, method: str, url: str): - with httpx.Client(base_url=BASE_URL) as client: - context.response = client.request(method, url.format(**context.vars)) + context.response = context.http_client.request(method, url.format(**context.vars)) + + +@then('the response status code should be "{expected_status_code}"') +def step_impl(context: Context, expected_status_code: int): + assert context.response.status_code == expected_status_code, ( + f"{context.response.status_code} != {expected_status_code}" + ) @then('the response header "{header}" should contains non-empty value') def step_impl(context: Context, header: str): header_value = context.response.headers.get(header) - assert header_value + print(context.response.headers) + assert header_value is not None, f"Header {header} not found in response" + assert header_value, ( + f"Header {header} found in response, but value {header_value:!r} is empty" + ) From 6b220030d1445abdc2e9fc9599cc5de799ebc1fe Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Mon, 27 Oct 2025 12:12:48 -0700 Subject: [PATCH 008/231] Fix test --- backend/bdd/features/environment.py | 2 +- backend/bdd/features/steps/pki_acme.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/bdd/features/environment.py b/backend/bdd/features/environment.py index 20ffe31c6..abad8f85e 100644 --- a/backend/bdd/features/environment.py +++ b/backend/bdd/features/environment.py @@ -10,5 +10,5 @@ AUTH_TOKEN = os.environ.get("INFISICAL_TOKEN") def before_all(context: Context): context.vars = {} context.http_client = httpx.Client( - base_url=BASE_URL, headers={"Authorization": f"Bearer {AUTH_TOKEN}"} + base_url=BASE_URL, # headers={"Authorization": f"Bearer {AUTH_TOKEN}"} ) diff --git a/backend/bdd/features/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py index d1ead5c23..d92842c5a 100644 --- a/backend/bdd/features/steps/pki_acme.py +++ b/backend/bdd/features/steps/pki_acme.py @@ -24,7 +24,7 @@ def step_impl(context: Context, method: str, url: str): context.response = context.http_client.request(method, url.format(**context.vars)) -@then('the response status code should be "{expected_status_code}"') +@then('the response status code should be "{expected_status_code:d}"') def step_impl(context: Context, expected_status_code: int): assert context.response.status_code == expected_status_code, ( f"{context.response.status_code} != {expected_status_code}" From c4ced367673aa56a21a492ae51af66407cba4f47 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Mon, 27 Oct 2025 13:31:06 -0700 Subject: [PATCH 009/231] Add new account --- backend/bdd/features/environment.py | 4 ++- .../bdd/features/pki/acme/dicrectory.feature | 13 +++++++ .../bdd/features/pki/acme/new-account.feature | 4 +++ .../pki-acme.feature => acme/nonce.feature} | 4 +-- backend/bdd/features/steps/pki_acme.py | 36 ++++++++++++++----- backend/bdd/pyproject.toml | 1 + backend/bdd/uv.lock | 30 ++++++++++++++++ 7 files changed, 81 insertions(+), 11 deletions(-) create mode 100644 backend/bdd/features/pki/acme/dicrectory.feature create mode 100644 backend/bdd/features/pki/acme/new-account.feature rename backend/bdd/features/pki/{nonce/pki-acme.feature => acme/nonce.feature} (54%) diff --git a/backend/bdd/features/environment.py b/backend/bdd/features/environment.py index abad8f85e..5b94d0415 100644 --- a/backend/bdd/features/environment.py +++ b/backend/bdd/features/environment.py @@ -8,7 +8,9 @@ AUTH_TOKEN = os.environ.get("INFISICAL_TOKEN") def before_all(context: Context): - context.vars = {} + context.vars = { + "BASE_URL": BASE_URL, + } context.http_client = httpx.Client( base_url=BASE_URL, # headers={"Authorization": f"Bearer {AUTH_TOKEN}"} ) diff --git a/backend/bdd/features/pki/acme/dicrectory.feature b/backend/bdd/features/pki/acme/dicrectory.feature new file mode 100644 index 000000000..04eade31e --- /dev/null +++ b/backend/bdd/features/pki/acme/dicrectory.feature @@ -0,0 +1,13 @@ +Feature: Directory + Scenario: Get the directory of ACME service urls + Given I have an ACME cert profile as "acme_profile" + When I send a GET request to "/api/v1/pki/acme/profiles/{acme_profile.id}/directory" + Then the response status code should be "200" + Then the response body should match JSON value + """ + { + "newNonce": "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-nonce", + "newAccount": "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-account", + "newOrder": "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-order" + } + """ diff --git a/backend/bdd/features/pki/acme/new-account.feature b/backend/bdd/features/pki/acme/new-account.feature new file mode 100644 index 000000000..7e84391bd --- /dev/null +++ b/backend/bdd/features/pki/acme/new-account.feature @@ -0,0 +1,4 @@ +Feature: New Account + Scenario: Create a new account + Given I have an ACME cert profile as "acme_profile" + When I register a new ACME account diff --git a/backend/bdd/features/pki/nonce/pki-acme.feature b/backend/bdd/features/pki/acme/nonce.feature similarity index 54% rename from backend/bdd/features/pki/nonce/pki-acme.feature rename to backend/bdd/features/pki/acme/nonce.feature index 77f408ba3..0d62f7b46 100644 --- a/backend/bdd/features/pki/nonce/pki-acme.feature +++ b/backend/bdd/features/pki/acme/nonce.feature @@ -1,6 +1,6 @@ Feature: Nonce Scenario: Generate a new nonce - Given I have a PKI project as "pki_project" - When I send a HEAD request to "/api/v1/pki/acme/profiles/{pki_project.id}/new-nonce" + Given I have an ACME cert profile as "acme_profile" + When I send a HEAD request to "/api/v1/pki/acme/profiles/{acme_profile.id}/new-nonce" Then the response status code should be "200" Then the response header "Replay-Nonce" should contains non-empty value diff --git a/backend/bdd/features/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py index d92842c5a..cc2bb2e08 100644 --- a/backend/bdd/features/steps/pki_acme.py +++ b/backend/bdd/features/steps/pki_acme.py @@ -1,22 +1,35 @@ -from behave.runner import Context +import json +from behave.runner import Context from behave import given from behave import when from behave import then -class PkiProject: +class AcmeProfile: def __init__(self, id: str): self.id = id -@given('I have a PKI project as "{project_var}"') -def step_impl(context: Context, project_var: str): +def replace_vars(payload: dict, vars: dict): + for key, value in payload.items(): + if isinstance(value, dict): + replace_vars(value, vars) + elif isinstance(value, list): + payload[key] = [replace_vars(item, vars) for item in value] + elif isinstance(value, str): + payload[key] = value.format(**vars) + else: + payload[key] = value + + +@given('I have an ACME cert profile as "{profile_var}"') +def step_impl(context: Context, profile_var: str): # TODO: Fixed value for now, just to make test much easier, - # we should call infisical API to create such project instead + # we should call infisical API to create such profile instead # in the future - project_id = "c051e74c-48a7-4724-832c-d5b496698546" - context.vars[project_var] = PkiProject(project_id) + profile_id = "c051e74c-48a7-4724-832c-d5b496698546" + context.vars[profile_var] = AcmeProfile(profile_id) @when('I send a {method} request to "{url}"') @@ -34,8 +47,15 @@ def step_impl(context: Context, expected_status_code: int): @then('the response header "{header}" should contains non-empty value') def step_impl(context: Context, header: str): header_value = context.response.headers.get(header) - print(context.response.headers) assert header_value is not None, f"Header {header} not found in response" assert header_value, ( f"Header {header} found in response, but value {header_value:!r} is empty" ) + + +@then("the response body should match JSON value") +def step_impl(context: Context): + payload = context.response.json() + expected = json.loads(context.text) + replace_vars(expected, context.vars) + assert payload == expected, f"{payload} != {expected}" diff --git a/backend/bdd/pyproject.toml b/backend/bdd/pyproject.toml index dbc38df6c..a8ece06db 100644 --- a/backend/bdd/pyproject.toml +++ b/backend/bdd/pyproject.toml @@ -7,4 +7,5 @@ requires-python = ">=3.12" dependencies = [ "behave>=1.3.3", "httpx>=0.28.1", + "jq>=1.10.0", ] diff --git a/backend/bdd/uv.lock b/backend/bdd/uv.lock index 26c3fbb5e..35a5a117d 100644 --- a/backend/bdd/uv.lock +++ b/backend/bdd/uv.lock @@ -23,12 +23,14 @@ source = { virtual = "." } dependencies = [ { name = "behave" }, { name = "httpx" }, + { name = "jq" }, ] [package.metadata] requires-dist = [ { name = "behave", specifier = ">=1.3.3" }, { name = "httpx", specifier = ">=0.28.1" }, + { name = "jq", specifier = ">=1.10.0" }, ] [[package]] @@ -130,6 +132,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[package]] +name = "jq" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/86/6935afb6c1789d4c6ba5343607e2d2f473069eaac29fac555dbbd154c2d7/jq-1.10.0.tar.gz", hash = "sha256:fc38803075dbf1867e1b4ed268fef501feecb0c50f3555985a500faedfa70f08", size = 2031308, upload-time = "2025-07-14T18:54:53.679Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/d9/b9e2b7004a2cb646507c082ea5e975ac37e6265353ec4c24779a1701c54a/jq-1.10.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe636cfa95b7027e7b43da83ecfd61431c0de80c3e0aa4946534b087149dcb4c", size = 420103, upload-time = "2025-07-14T18:52:39.016Z" }, + { url = "https://files.pythonhosted.org/packages/75/ad/d6780c218040789ed3ddbfa3b1743aaf824f80be5ebd7d5f885224c5bb08/jq-1.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:947fc7e1baaa7e95833b950e5a66b3e13a5cff028bff2d009b8c320124d9e69b", size = 426325, upload-time = "2025-07-14T18:52:40.654Z" }, + { url = "https://files.pythonhosted.org/packages/e9/42/5cfc8de34e976112e1b835a83264c7a0bab2cf8f20dc703f1257aa9e07ea/jq-1.10.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9382f85a347623afa521c43f8f09439e68906fd5b3492016f969a29219796bb9", size = 738212, upload-time = "2025-07-14T18:52:42.637Z" }, + { url = "https://files.pythonhosted.org/packages/84/0a/eff78a2329967bda38a98580c6fb77c59696b2b7d589e97db232ca42f5c4/jq-1.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c376aab525d0a1debe403d3bc2f19fda9473696a1eda56bafc88248fc4ae6e7e", size = 757068, upload-time = "2025-07-14T18:52:44.709Z" }, + { url = "https://files.pythonhosted.org/packages/f3/62/353d4c0a9f363ccb2a9b5ea205f079a4ee43642622c25250d95c0fafb7ca/jq-1.10.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:206f230c67a46776f848858c66b9c377a8e40c2b16195552edd96fd7b45f9a52", size = 744259, upload-time = "2025-07-14T18:52:47.308Z" }, + { url = "https://files.pythonhosted.org/packages/4f/46/0faead425cc3a720c7cd999146f4b5f50aaf394800457efb27746c10832c/jq-1.10.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:06986456ebc95ccb9e9c2a1f0e842bc9d441225a554a9f9d4370ad95a19ac000", size = 740075, upload-time = "2025-07-14T18:52:50.038Z" }, + { url = "https://files.pythonhosted.org/packages/10/0c/8e0823c5a329d735cff9f3746e0f7d74e7eea4ed9b0e75f90f942d1c455a/jq-1.10.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d02c0be958ddb4d9254ff251b045df2f8ee5995137702eeab4ffa81158bcdbe0", size = 766475, upload-time = "2025-07-14T18:52:53.047Z" }, + { url = "https://files.pythonhosted.org/packages/06/0c/9b5aae9081fe6620915aa0e0ca76fd016e5b9d399b80c8615852413f4404/jq-1.10.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:37cf6fd2ebd2453e75ceef207d5a95a39fcbda371a9b8916db0bd42e8737a621", size = 770416, upload-time = "2025-07-14T18:52:55.858Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e7/8f4e1cc3102de31d71e6298bcbdb15d1439e2bc466f4dcf18bc3694ba61d/jq-1.10.0-cp312-cp312-win32.whl", hash = "sha256:655d75d54a343944a9b011f568156cdc29ae0b35d2fdeefb001f459a4e4fc313", size = 410113, upload-time = "2025-07-14T18:52:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/6efe0a2b69910643b80d7da39fbded8225749dee4b79ebe23d522109a310/jq-1.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:1d67c2653ae41eab48f8888c213c9e1807b43167f26ac623c9f3e00989d3edee", size = 422316, upload-time = "2025-07-14T18:52:59.605Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fe/eeede83103e90e8f5fd9b610514a4c714957d6575e03987ebeb77aafeafa/jq-1.10.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b11d6e115ebad15d738d49932c3a8b9bb302b928e0fb79acc80987598d147a43", size = 419325, upload-time = "2025-07-14T18:53:01.854Z" }, + { url = "https://files.pythonhosted.org/packages/09/12/8b39293715d7721b2999facd4a05ca3328fe4a68cf1c094667789867aac1/jq-1.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df278904c5727dfe5bc678131a0636d731cd944879d890adf2fc6de35214b19b", size = 425344, upload-time = "2025-07-14T18:53:03.528Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f4/ace0c853d4462f1d28798d5696619d2fb68c8e1db228ef5517365a0f3c1c/jq-1.10.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab4c1ec69fd7719fb1356e2ade7bd2b5a63d6f0eaf5a90fdc5c9f6145f0474ce", size = 735874, upload-time = "2025-07-14T18:53:05.406Z" }, + { url = "https://files.pythonhosted.org/packages/2a/b0/7882035062771686bd7e62db019fa0900fd9a3720b7ad8f7af65ee628484/jq-1.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd24dc21c8afcbe5aa812878251cfafa6f1dc6e1126c35d460cc7e67eb331018", size = 754355, upload-time = "2025-07-14T18:53:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/df/7d/b759a764c5d05c6829e95733a8b26f7e9b14df245ec2a325c0de049393ca/jq-1.10.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8c0d3e89cd239c340c3a54e145ddf52fe63de31866cb73368d22a66bfe7e823f", size = 742546, upload-time = "2025-07-14T18:53:11.756Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6b/483ddb82939d4f2f9b0486887666c67a966434cc8bc72acd851fc8063f50/jq-1.10.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76710b280e4c464395c3d8e656b849e2704bd06e950a4ebd767860572bbf67df", size = 738777, upload-time = "2025-07-14T18:53:14.856Z" }, + { url = "https://files.pythonhosted.org/packages/0c/72/4d0fc965a8e57f55291763bb236a5aee91430f97c844ee328667b34af19e/jq-1.10.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b11a56f1fb6e2985fd3627dbd8a0637f62b1a704f7b19705733d461dafa26429", size = 765307, upload-time = "2025-07-14T18:53:17.611Z" }, + { url = "https://files.pythonhosted.org/packages/0b/a6/aca82622d8d20ea02bbcac8aaa92daaadd55a18c2a3ca54b2e63d98336d2/jq-1.10.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ac05ae44d9aa1e462329e1510e0b5139ac4446de650c7bdfdab226aafdc978ec", size = 769830, upload-time = "2025-07-14T18:53:19.937Z" }, + { url = "https://files.pythonhosted.org/packages/0e/e3/a19aeada32dde0839e3a4d77f2f0d63f2764c579b57f405ff4b91a58a8db/jq-1.10.0-cp313-cp313-win32.whl", hash = "sha256:0bad90f5734e2fc9d09c4116ae9102c357a4d75efa60a85758b0ba633774eddb", size = 410285, upload-time = "2025-07-14T18:53:21.631Z" }, + { url = "https://files.pythonhosted.org/packages/d6/32/df4eb81cf371654d91b6779d3f0005e86519977e19068638c266a9c88af7/jq-1.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:4ec3fbca80a9dfb5349cdc2531faf14dd832e1847499513cf1fc477bcf46a479", size = 423094, upload-time = "2025-07-14T18:53:23.687Z" }, +] + [[package]] name = "parse" version = "1.20.2" From a3c3ab10d1e54726c4455cd01851cad7175889d4 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Mon, 27 Oct 2025 14:58:41 -0700 Subject: [PATCH 010/231] Implement acme client setup --- .../bdd/features/pki/acme/new-account.feature | 3 +- backend/bdd/features/steps/pki_acme.py | 34 +++ backend/bdd/pyproject.toml | 2 + backend/bdd/uv.lock | 257 ++++++++++++++++++ 4 files changed, 295 insertions(+), 1 deletion(-) diff --git a/backend/bdd/features/pki/acme/new-account.feature b/backend/bdd/features/pki/acme/new-account.feature index 7e84391bd..8c0a9e78b 100644 --- a/backend/bdd/features/pki/acme/new-account.feature +++ b/backend/bdd/features/pki/acme/new-account.feature @@ -1,4 +1,5 @@ Feature: New Account Scenario: Create a new account Given I have an ACME cert profile as "acme_profile" - When I register a new ACME account + When I have an ACME client connecting to {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory + Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account diff --git a/backend/bdd/features/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py index cc2bb2e08..f38410f0d 100644 --- a/backend/bdd/features/steps/pki_acme.py +++ b/backend/bdd/features/steps/pki_acme.py @@ -1,9 +1,17 @@ import json +from acme import client +from acme import messages from behave.runner import Context from behave import given from behave import when from behave import then +from josepy.jwk import JWKRSA +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa + +ACC_KEY_BITS = 2048 +ACC_KEY_PUBLIC_EXPONENT = 65537 class AcmeProfile: @@ -37,6 +45,23 @@ def step_impl(context: Context, method: str, url: str): context.response = context.http_client.request(method, url.format(**context.vars)) +@when("I have an ACME client connecting to {url}") +def step_impl(context: Context, url: str): + private_key = rsa.generate_private_key( + public_exponent=ACC_KEY_PUBLIC_EXPONENT, key_size=ACC_KEY_BITS + ) + pem_bytes = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + acc_jwk = JWKRSA.load(pem_bytes) + net = client.ClientNetwork(acc_jwk) + directory_url = url.format(**context.vars) + directory = client.ClientV2.get_directory(directory_url, net) + context.acme_client = client.ClientV2(directory, net=net) + + @then('the response status code should be "{expected_status_code:d}"') def step_impl(context: Context, expected_status_code: int): assert context.response.status_code == expected_status_code, ( @@ -59,3 +84,12 @@ def step_impl(context: Context): expected = json.loads(context.text) replace_vars(expected, context.vars) assert payload == expected, f"{payload} != {expected}" + + +@then( + "I register a new ACME account with email {email} and EAB key id {kid} with secret {secret} as {account_var}" +) +def step_impl(context: Context, email: str, kid: str, secret: str, account_var: str): + # TODO: add EAB info here + registration = messages.NewRegistration.from_data(email=email) + context.var[account_var] = context.acme_client.new_account(registration) diff --git a/backend/bdd/pyproject.toml b/backend/bdd/pyproject.toml index a8ece06db..98b1c2f89 100644 --- a/backend/bdd/pyproject.toml +++ b/backend/bdd/pyproject.toml @@ -5,7 +5,9 @@ description = "Add your description here" readme = "README.md" requires-python = ">=3.12" dependencies = [ + "acme>=5.1.0", "behave>=1.3.3", "httpx>=0.28.1", + "josepy>=2.2.0", "jq>=1.10.0", ] diff --git a/backend/bdd/uv.lock b/backend/bdd/uv.lock index 35a5a117d..a05b55420 100644 --- a/backend/bdd/uv.lock +++ b/backend/bdd/uv.lock @@ -2,6 +2,22 @@ version = 1 revision = 2 requires-python = ">=3.12" +[[package]] +name = "acme" +version = "5.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "josepy" }, + { name = "pyopenssl" }, + { name = "pyrfc3339" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/f6/897be0abeb0e64f0e6136a8a6369a54d2a603a44cb7a411f6d77dbafb4ac/acme-5.1.0.tar.gz", hash = "sha256:7b97820857d9baffed98bca50ab82bb6a636e447865d7a013a7bdd7972f03cda", size = 89982, upload-time = "2025-10-07T17:30:38.579Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/0b/4d0421412bb063f4393ae7ebf3a9a6fde621aed187a1140ccf7f9e22b823/acme-5.1.0-py3-none-any.whl", hash = "sha256:80e9c315d82302bb97279f4516ff31230d29195ab9d4a6c9411ceec20481b61e", size = 94151, upload-time = "2025-10-07T17:30:15.994Z" }, +] + [[package]] name = "anyio" version = "4.11.0" @@ -21,15 +37,19 @@ name = "bdd" version = "0.1.0" source = { virtual = "." } dependencies = [ + { name = "acme" }, { name = "behave" }, { name = "httpx" }, + { name = "josepy" }, { name = "jq" }, ] [package.metadata] requires-dist = [ + { name = "acme", specifier = ">=5.1.0" }, { name = "behave", specifier = ">=1.3.3" }, { name = "httpx", specifier = ">=0.28.1" }, + { name = "josepy", specifier = ">=2.2.0" }, { name = "jq", specifier = ">=1.10.0" }, ] @@ -59,6 +79,120 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" }, ] +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -68,6 +202,62 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "cryptography" +version = "46.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" }, + { url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" }, + { url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" }, + { url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" }, + { url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" }, + { url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" }, + { url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" }, + { url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" }, + { url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" }, + { url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" }, + { url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" }, + { url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" }, + { url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" }, + { url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" }, + { url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" }, + { url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" }, + { url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" }, + { url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" }, + { url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" }, + { url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" }, + { url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" }, + { url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" }, + { url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" }, + { url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" }, + { url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" }, + { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" }, + { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" }, + { url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" }, + { url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" }, + { url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" }, + { url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" }, + { url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" }, + { url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" }, +] + [[package]] name = "cucumber-expressions" version = "18.0.1" @@ -132,6 +322,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[package]] +name = "josepy" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/ad/6f520aee9cc9618d33430380741e9ef859b2c560b1e7915e755c084f6bc0/josepy-2.2.0.tar.gz", hash = "sha256:74c033151337c854f83efe5305a291686cef723b4b970c43cfe7270cf4a677a9", size = 56500, upload-time = "2025-10-14T14:54:42.108Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/b2/b5caed897fbb1cc286c62c01feca977e08d99a17230ff3055b9a98eccf1d/josepy-2.2.0-py3-none-any.whl", hash = "sha256:63e9dd116d4078778c25ca88f880cc5d95f1cab0099bebe3a34c2e299f65d10b", size = 29211, upload-time = "2025-10-14T14:54:41.144Z" }, +] + [[package]] name = "jq" version = "1.10.0" @@ -182,6 +384,52 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/85/8d/eef3d8cdccc32abdd91b1286884c99b8c3a6d3b135affcc2a7a0f383bb32/parse_type-0.6.6-py2.py3-none-any.whl", hash = "sha256:3ca79bbe71e170dfccc8ec6c341edfd1c2a0fc1e5cfd18330f93af938de2348c", size = 27085, upload-time = "2025-08-11T22:53:46.396Z" }, ] +[[package]] +name = "pycparser" +version = "2.23" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, +] + +[[package]] +name = "pyopenssl" +version = "25.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/be/97b83a464498a79103036bc74d1038df4a7ef0e402cfaf4d5e113fb14759/pyopenssl-25.3.0.tar.gz", hash = "sha256:c981cb0a3fd84e8602d7afc209522773b94c1c2446a3c710a75b06fe1beae329", size = 184073, upload-time = "2025-09-17T00:32:21.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/81/ef2b1dfd1862567d573a4fdbc9f969067621764fbb74338496840a1d2977/pyopenssl-25.3.0-py3-none-any.whl", hash = "sha256:1fda6fc034d5e3d179d39e59c1895c9faeaf40a79de5fc4cbbfbe0d36f4a77b6", size = 57268, upload-time = "2025-09-17T00:32:19.474Z" }, +] + +[[package]] +name = "pyrfc3339" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/7f/3c194647ecb80ada6937c38a162ab3edba85a8b6a58fa2919405f4de2509/pyrfc3339-2.1.0.tar.gz", hash = "sha256:c569a9714faf115cdb20b51e830e798c1f4de8dabb07f6ff25d221b5d09d8d7f", size = 12589, upload-time = "2025-08-23T16:40:31.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/90/0200184d2124484f918054751ef997ed6409cb05b7e8dcbf5a22da4c4748/pyrfc3339-2.1.0-py3-none-any.whl", hash = "sha256:560f3f972e339f579513fe1396974352fd575ef27caff160a38b312252fcddf3", size = 6758, upload-time = "2025-08-23T16:40:30.49Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + [[package]] name = "six" version = "1.17.0" @@ -208,3 +456,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac8 wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] + +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, +] From 4875add7118ec8fa9e5092468fe56cc62ace6505 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Mon, 27 Oct 2025 15:29:39 -0700 Subject: [PATCH 011/231] Acme stuff --- backend/bdd/features/steps/pki_acme.py | 2 +- backend/src/ee/routes/v1/pki-acme-router.ts | 17 +++++++ .../ee/services/pki-acme/pki-acme-schemas.ts | 2 +- .../ee/services/pki-acme/pki-acme-service.ts | 44 ++++++++++++------- 4 files changed, 47 insertions(+), 18 deletions(-) diff --git a/backend/bdd/features/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py index f38410f0d..9a84b0dc8 100644 --- a/backend/bdd/features/steps/pki_acme.py +++ b/backend/bdd/features/steps/pki_acme.py @@ -92,4 +92,4 @@ def step_impl(context: Context): def step_impl(context: Context, email: str, kid: str, secret: str, account_var: str): # TODO: add EAB info here registration = messages.NewRegistration.from_data(email=email) - context.var[account_var] = context.acme_client.new_account(registration) + context.vars[account_var] = context.acme_client.new_account(registration) diff --git a/backend/src/ee/routes/v1/pki-acme-router.ts b/backend/src/ee/routes/v1/pki-acme-router.ts index f9342a825..2a82bfeb3 100644 --- a/backend/src/ee/routes/v1/pki-acme-router.ts +++ b/backend/src/ee/routes/v1/pki-acme-router.ts @@ -27,6 +27,20 @@ import { } from "@app/ee/services/pki-acme/pki-acme-schemas"; export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { + server.addContentTypeParser("application/jose+json", { parseAs: "string" }, (_, body, done) => { + try { + const strBody = body instanceof Buffer ? body.toString() : body; + if (!strBody) { + done(null, undefined); + } + const json: unknown = JSON.parse(strBody as string); + // TODO: deal with JWS payload here + done(null, json); + } catch (err) { + const error = err as Error; + done(error, undefined); + } + }); // GET /api/v1/pki/acme/profiles//directory // Directory (RFC 8555 Section 7.1.1) server.route({ @@ -94,7 +108,10 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { }, handler: async (req, res) => { const account = await server.services.pkiAcme.createAcmeAccount(req.params.profileId, req.body); + // TODO: deal with existing account case here res.code(201); + const nonce = await server.services.pkiAcme.getAcmeNewNonce(req.params.profileId); + res.header("Replay-Nonce", nonce); return account; } }); diff --git a/backend/src/ee/services/pki-acme/pki-acme-schemas.ts b/backend/src/ee/services/pki-acme/pki-acme-schemas.ts index 6cfee8ecc..2f470f834 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-schemas.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-schemas.ts @@ -11,7 +11,7 @@ export const GetAcmeDirectoryResponseSchema = z.object({ newNonce: z.string(), newAccount: z.string(), newOrder: z.string(), - revokeCert: z.string() + revokeCert: z.string().optional() }); // New Nonce endpoint 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 1ece387e0..1148dda3d 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -1,3 +1,4 @@ +import { getConfig } from "@app/lib/config/env"; import { NotFoundError } from "@app/lib/errors"; import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; @@ -21,20 +22,24 @@ type TPkiAcmeServiceFactoryDep = { }; export const pkiAcmeServiceFactory = ({ certificateProfileDAL }: TPkiAcmeServiceFactoryDep): TPkiAcmeServiceFactory => { + const appCfg = getConfig(); + const getAcmeDirectory = async (profileId: string): Promise => { // FIXME: Implement ACME directory endpoint // Validate profile exists and is for ACME enrollment - const profile = await certificateProfileDAL.findById(profileId); - if (!profile) { - throw new NotFoundError({ message: "Certificate profile not found" }); - } + // const profile = await certificateProfileDAL.findById(profileId); + // if (!profile) { + // throw new NotFoundError({ message: "Certificate profile not found" }); + // } // FIXME: Validate profile is configured for ACME enrollment + + // Return absolute URLs using SITE_URL + const baseUrl = appCfg.SITE_URL ?? ""; return { - newNonce: `/api/v1/pki/acme/profiles/${profileId}/new-nonce`, - newAccount: `/api/v1/pki/acme/profiles/${profileId}/new-account`, - newOrder: `/api/v1/pki/acme/profiles/${profileId}/new-order`, - revokeCert: `/api/v1/pki/acme/profiles/${profileId}/revoke-cert` + newNonce: `${baseUrl}/api/v1/pki/acme/profiles/${profileId}/new-nonce`, + newAccount: `${baseUrl}/api/v1/pki/acme/profiles/${profileId}/new-account`, + newOrder: `${baseUrl}/api/v1/pki/acme/profiles/${profileId}/new-order` }; }; @@ -48,23 +53,26 @@ export const pkiAcmeServiceFactory = ({ certificateProfileDAL }: TPkiAcmeService // FIXME: Implement ACME new account registration // Use EAB authentication to find corresponding Infisical machine identity // Check permissions and return account information + const baseUrl = appCfg.SITE_URL || ""; + const accountId = "FIXME-account-id"; return { status: "valid", - accountUrl: `/api/v1/pki/acme/profiles/${profileId}/accounts/FIXME-account-id`, + accountUrl: `${baseUrl}/api/v1/pki/acme/profiles/${profileId}/accounts/${accountId}`, contact: [], - orders: `/api/v1/pki/acme/profiles/${profileId}/accounts/FIXME-account-id/orders` + orders: `${baseUrl}/api/v1/pki/acme/profiles/${profileId}/accounts/${accountId}/orders` }; }; const createAcmeOrder = async (profileId: string, body: unknown): Promise => { // FIXME: Implement ACME new order creation const orderId = "FIXME-order-id"; + const baseUrl = appCfg.SITE_URL || ""; return { status: "pending", expires: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), identifiers: [], authorizations: [], - finalize: `/api/v1/pki/acme/profiles/${profileId}/orders/${orderId}/finalize` + finalize: `${baseUrl}/api/v1/pki/acme/profiles/${profileId}/orders/${orderId}/finalize` }; }; @@ -87,12 +95,13 @@ export const pkiAcmeServiceFactory = ({ certificateProfileDAL }: TPkiAcmeService const getAcmeOrder = async (profileId: string, orderId: string): Promise => { // FIXME: Implement ACME get order + const baseUrl = appCfg.SITE_URL || ""; return { status: "pending", expires: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), identifiers: [], authorizations: [], - finalize: `/api/v1/pki/acme/profiles/${profileId}/orders/${orderId}/finalize` + finalize: `${baseUrl}/api/v1/pki/acme/profiles/${profileId}/orders/${orderId}/finalize` }; }; @@ -102,13 +111,14 @@ export const pkiAcmeServiceFactory = ({ certificateProfileDAL }: TPkiAcmeService csr: string ): Promise => { // FIXME: Implement ACME finalize order + const baseUrl = appCfg.SITE_URL || ""; return { status: "processing", expires: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), identifiers: [], authorizations: [], - finalize: `/api/v1/pki/acme/profiles/${profileId}/orders/${orderId}/finalize`, - certificate: `/api/v1/pki/acme/profiles/${profileId}/orders/${orderId}/certificate` + finalize: `${baseUrl}/api/v1/pki/acme/profiles/${profileId}/orders/${orderId}/finalize`, + certificate: `${baseUrl}/api/v1/pki/acme/profiles/${profileId}/orders/${orderId}/certificate` }; }; @@ -120,6 +130,7 @@ export const pkiAcmeServiceFactory = ({ certificateProfileDAL }: TPkiAcmeService const getAcmeAuthorization = async (profileId: string, authzId: string): Promise => { // FIXME: Implement ACME authorization retrieval + const baseUrl = appCfg.SITE_URL || ""; return { status: "pending", expires: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), @@ -130,7 +141,7 @@ export const pkiAcmeServiceFactory = ({ certificateProfileDAL }: TPkiAcmeService challenges: [ { type: "http-01", - url: `/api/v1/pki/acme/profiles/${profileId}/authorizations/${authzId}/challenges/http-01`, + url: `${baseUrl}/api/v1/pki/acme/profiles/${profileId}/authorizations/${authzId}/challenges/http-01`, status: "pending", token: "FIXME-challenge-token" } @@ -144,9 +155,10 @@ export const pkiAcmeServiceFactory = ({ certificateProfileDAL }: TPkiAcmeService ): Promise => { // FIXME: Implement ACME challenge response // Trigger verification process + const baseUrl = appCfg.SITE_URL || ""; return { type: "http-01", - url: `/api/v1/pki/acme/profiles/${profileId}/authorizations/${authzId}/challenges/http-01`, + url: `${baseUrl}/api/v1/pki/acme/profiles/${profileId}/authorizations/${authzId}/challenges/http-01`, status: "pending", token: "FIXME-challenge-token" }; From 2f4066cafb14874a8d52497d5f88d95ba9c2d46f Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Mon, 27 Oct 2025 15:55:27 -0700 Subject: [PATCH 012/231] Extract types --- backend/src/ee/routes/v1/pki-acme-router.ts | 23 +++++-- .../ee/services/pki-acme/pki-acme-schemas.ts | 68 +++++++++++-------- .../ee/services/pki-acme/pki-acme-service.ts | 20 ++++-- .../ee/services/pki-acme/pki-acme-types.ts | 26 +++++-- 4 files changed, 94 insertions(+), 43 deletions(-) diff --git a/backend/src/ee/routes/v1/pki-acme-router.ts b/backend/src/ee/routes/v1/pki-acme-router.ts index 2a82bfeb3..49cc81021 100644 --- a/backend/src/ee/routes/v1/pki-acme-router.ts +++ b/backend/src/ee/routes/v1/pki-acme-router.ts @@ -1,8 +1,6 @@ /* eslint-disable @typescript-eslint/no-floating-promises */ import { z } from "zod"; -import { ApiDocsTags } from "@app/lib/api-docs"; -import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { CreateAcmeAccountResponseSchema, CreateAcmeAccountSchema, @@ -25,8 +23,13 @@ import { RespondToAcmeChallengeResponseSchema, RespondToAcmeChallengeSchema } from "@app/ee/services/pki-acme/pki-acme-schemas"; +import { ApiDocsTags } from "@app/lib/api-docs"; +import { getConfig } from "@app/lib/config/env"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { + const appCfg = getConfig(); + server.addContentTypeParser("application/jose+json", { parseAs: "string" }, (_, body, done) => { try { const strBody = body instanceof Buffer ? body.toString() : body; @@ -107,11 +110,21 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { } }, handler: async (req, res) => { + // TODO: check nonce here + // TODO: check signature here + const account = await server.services.pkiAcme.createAcmeAccount(req.params.profileId, req.body); // TODO: deal with existing account case here res.code(201); + res.header( + "Location", + `${appCfg.SITE_URL}/api/v1/pki/acme/profiles/${req.params.profileId}/accounts/${account.accountUrl}` + ); + + // TODO: DRY const nonce = await server.services.pkiAcme.getAcmeNewNonce(req.params.profileId); res.header("Replay-Nonce", nonce); + res.header("Cache-Control", "no-store"); return account; } }); @@ -227,11 +240,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { } }, handler: async (req) => { - const order = await server.services.pkiAcme.finalizeAcmeOrder( - req.params.profileId, - req.params.orderId, - req.body.csr - ); + const order = await server.services.pkiAcme.finalizeAcmeOrder(req.params.profileId, req.params.orderId, req.body); return order; } }); diff --git a/backend/src/ee/services/pki-acme/pki-acme-schemas.ts b/backend/src/ee/services/pki-acme/pki-acme-schemas.ts index 2f470f834..cdce6e9b8 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-schemas.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-schemas.ts @@ -21,23 +21,26 @@ export const GetAcmeNewNonceSchema = z.object({ }) }); +// New Account payload schema +export const CreateAcmeAccountBodySchema = z.object({ + contact: z.array(z.string()).optional(), + termsOfServiceAgreed: z.boolean().optional(), + onlyReturnExisting: z.boolean().optional(), + externalAccountBinding: z + .object({ + protected: z.string(), + payload: z.string(), + signature: z.string() + }) + .optional() +}); + // New Account endpoint export const CreateAcmeAccountSchema = z.object({ params: z.object({ profileId: z.string().uuid() }), - body: z.object({ - contact: z.array(z.string()).optional(), - termsOfServiceAgreed: z.boolean().optional(), - onlyReturnExisting: z.boolean().optional(), - externalAccountBinding: z - .object({ - protected: z.string(), - payload: z.string(), - signature: z.string() - }) - .optional() - }) + body: CreateAcmeAccountBodySchema }); export const CreateAcmeAccountResponseSchema = z.object({ @@ -47,21 +50,24 @@ export const CreateAcmeAccountResponseSchema = z.object({ accountUrl: z.string() }); +// New Order payload schema +export const CreateAcmeOrderBodySchema = z.object({ + identifiers: z.array( + z.object({ + type: z.string(), + value: z.string() + }) + ), + notBefore: z.string().optional(), + notAfter: z.string().optional() +}); + // New Order endpoint export const CreateAcmeOrderSchema = z.object({ params: z.object({ profileId: z.string().uuid() }), - body: z.object({ - identifiers: z.array( - z.object({ - type: z.string(), - value: z.string() - }) - ), - notBefore: z.string().optional(), - notAfter: z.string().optional() - }) + body: CreateAcmeOrderBodySchema }); export const CreateAcmeOrderResponseSchema = z.object({ @@ -78,15 +84,18 @@ export const CreateAcmeOrderResponseSchema = z.object({ certificate: z.string().optional() }); +// Account Deactivation payload schema +export const DeactivateAcmeAccountBodySchema = z.object({ + status: z.literal("deactivated") +}); + // Account Deactivation endpoint export const DeactivateAcmeAccountSchema = z.object({ params: z.object({ profileId: z.string().uuid(), accountId: z.string() }), - body: z.object({ - status: z.literal("deactivated") - }) + body: DeactivateAcmeAccountBodySchema }); export const DeactivateAcmeAccountResponseSchema = z.object({ @@ -127,15 +136,18 @@ export const GetAcmeOrderResponseSchema = z.object({ certificate: z.string().optional() }); +// Finalize Order payload schema +export const FinalizeAcmeOrderBodySchema = z.object({ + csr: z.string() +}); + // Finalize Order endpoint export const FinalizeAcmeOrderSchema = z.object({ params: z.object({ profileId: z.string().uuid(), orderId: z.string() }), - body: z.object({ - csr: z.string() - }) + body: FinalizeAcmeOrderBodySchema }); export const FinalizeAcmeOrderResponseSchema = z.object({ 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 1148dda3d..9e9fce099 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -4,10 +4,14 @@ import { NotFoundError } from "@app/lib/errors"; import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; import { + TCreateAcmeAccountPayload, TCreateAcmeAccountResponse, + TCreateAcmeOrderPayload, TCreateAcmeOrderResponse, + TDeactivateAcmeAccountPayload, TDeactivateAcmeAccountResponse, TDownloadAcmeCertificateDTO, + TFinalizeAcmeOrderPayload, TFinalizeAcmeOrderResponse, TGetAcmeAuthorizationResponse, TGetAcmeDirectoryResponse, @@ -49,7 +53,10 @@ export const pkiAcmeServiceFactory = ({ certificateProfileDAL }: TPkiAcmeService return "FIXME-generate-nonce"; }; - const createAcmeAccount = async (profileId: string, body: unknown): Promise => { + const createAcmeAccount = async ( + profileId: string, + body: TCreateAcmeAccountPayload + ): Promise => { // FIXME: Implement ACME new account registration // Use EAB authentication to find corresponding Infisical machine identity // Check permissions and return account information @@ -63,7 +70,10 @@ export const pkiAcmeServiceFactory = ({ certificateProfileDAL }: TPkiAcmeService }; }; - const createAcmeOrder = async (profileId: string, body: unknown): Promise => { + const createAcmeOrder = async ( + profileId: string, + body: TCreateAcmeOrderPayload + ): Promise => { // FIXME: Implement ACME new order creation const orderId = "FIXME-order-id"; const baseUrl = appCfg.SITE_URL || ""; @@ -78,7 +88,8 @@ export const pkiAcmeServiceFactory = ({ certificateProfileDAL }: TPkiAcmeService const deactivateAcmeAccount = async ( profileId: string, - accountId: string + accountId: string, + body?: TDeactivateAcmeAccountPayload ): Promise => { // FIXME: Implement ACME account deactivation return { @@ -108,8 +119,9 @@ export const pkiAcmeServiceFactory = ({ certificateProfileDAL }: TPkiAcmeService const finalizeAcmeOrder = async ( profileId: string, orderId: string, - csr: string + body: TFinalizeAcmeOrderPayload ): Promise => { + const { csr } = body; // FIXME: Implement ACME finalize order const baseUrl = appCfg.SITE_URL || ""; return { 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 4a80ab0f6..f004e5102 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-types.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-types.ts @@ -1,9 +1,13 @@ import { z } from "zod"; import { + CreateAcmeAccountBodySchema, CreateAcmeAccountResponseSchema, + CreateAcmeOrderBodySchema, CreateAcmeOrderResponseSchema, + DeactivateAcmeAccountBodySchema, DeactivateAcmeAccountResponseSchema, + FinalizeAcmeOrderBodySchema, FinalizeAcmeOrderResponseSchema, GetAcmeAuthorizationResponseSchema, GetAcmeDirectoryResponseSchema, @@ -23,15 +27,29 @@ export type TDownloadAcmeCertificateDTO = string; export type TGetAcmeAuthorizationResponse = z.infer; export type TRespondToAcmeChallengeResponse = z.infer; +// Payload types +export type TCreateAcmeAccountPayload = z.infer; +export type TCreateAcmeOrderPayload = z.infer; +export type TDeactivateAcmeAccountPayload = z.infer; +export type TFinalizeAcmeOrderPayload = z.infer; + export type TPkiAcmeServiceFactory = { getAcmeDirectory: (profileId: string) => Promise; getAcmeNewNonce: (profileId: string) => Promise; - createAcmeAccount: (profileId: string, body: unknown) => Promise; - createAcmeOrder: (profileId: string, body: unknown) => Promise; - deactivateAcmeAccount: (profileId: string, accountId: string) => Promise; + createAcmeAccount: (profileId: string, body: TCreateAcmeAccountPayload) => Promise; + createAcmeOrder: (profileId: string, body: TCreateAcmeOrderPayload) => Promise; + deactivateAcmeAccount: ( + profileId: string, + accountId: string, + body?: TDeactivateAcmeAccountPayload + ) => Promise; listAcmeOrders: (profileId: string, accountId: string) => Promise; getAcmeOrder: (profileId: string, orderId: string) => Promise; - finalizeAcmeOrder: (profileId: string, orderId: string, csr: string) => Promise; + finalizeAcmeOrder: ( + profileId: string, + orderId: string, + body: TFinalizeAcmeOrderPayload + ) => Promise; downloadAcmeCertificate: (profileId: string, orderId: string) => Promise; getAcmeAuthorization: (profileId: string, authzId: string) => Promise; respondToAcmeChallenge: (profileId: string, authzId: string) => Promise; From 167466a1591b544622fd04281bae489b3b3e0f98 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Mon, 27 Oct 2025 16:12:27 -0700 Subject: [PATCH 013/231] Add ACME stuff --- .../bdd/features/pki/acme/new-account.feature | 2 +- backend/src/@types/knex.d.ts | 8 +++ backend/src/db/schemas/models.ts | 1 + .../db/schemas/pki-acme-enrollment-configs.ts | 20 ++++++ backend/src/ee/routes/v1/pki-acme-router.ts | 16 +++++ .../acme-enrollment-config-dal.ts | 61 +++++++++++++++++++ .../enrollment-config-types.ts | 13 ++++ 7 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 backend/src/db/schemas/pki-acme-enrollment-configs.ts create mode 100644 backend/src/services/enrollment-config/acme-enrollment-config-dal.ts diff --git a/backend/bdd/features/pki/acme/new-account.feature b/backend/bdd/features/pki/acme/new-account.feature index 8c0a9e78b..09e616412 100644 --- a/backend/bdd/features/pki/acme/new-account.feature +++ b/backend/bdd/features/pki/acme/new-account.feature @@ -2,4 +2,4 @@ Feature: New Account Scenario: Create a new account Given I have an ACME cert profile as "acme_profile" When I have an ACME client connecting to {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory - Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account + Then I register a new ACME account with email fangpen@infisical.com and EAB key id {acme_profile.eab_kid} with secret {acme_profile.eab_secret} as acme_account diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 7ff31ed99..2be2af84c 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -287,6 +287,9 @@ import { TPkiEstEnrollmentConfigs, TPkiEstEnrollmentConfigsInsert, TPkiEstEnrollmentConfigsUpdate, + TPkiAcmeEnrollmentConfigs, + TPkiAcmeEnrollmentConfigsInsert, + TPkiAcmeEnrollmentConfigsUpdate, TPkiSubscribers, TPkiSubscribersInsert, TPkiSubscribersUpdate, @@ -709,6 +712,11 @@ declare module "knex/types/tables" { TPkiApiEnrollmentConfigsInsert, TPkiApiEnrollmentConfigsUpdate >; + [TableName.PkiAcmeEnrollmentConfig]: KnexOriginal.CompositeTableType< + TPkiAcmeEnrollmentConfigs, + TPkiAcmeEnrollmentConfigsInsert, + TPkiAcmeEnrollmentConfigsUpdate + >; [TableName.CertificateTemplateEstConfig]: KnexOriginal.CompositeTableType< TCertificateTemplateEstConfigs, TCertificateTemplateEstConfigsInsert, diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index e10c6dcbe..6012247ac 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -27,6 +27,7 @@ export enum TableName { PkiCertificateProfile = "pki_certificate_profiles", PkiEstEnrollmentConfig = "pki_est_enrollment_configs", PkiApiEnrollmentConfig = "pki_api_enrollment_configs", + PkiAcmeEnrollmentConfig = "pki_acme_enrollment_configs", PkiSubscriber = "pki_subscribers", PkiAlert = "pki_alerts", PkiCollection = "pki_collections", diff --git a/backend/src/db/schemas/pki-acme-enrollment-configs.ts b/backend/src/db/schemas/pki-acme-enrollment-configs.ts new file mode 100644 index 000000000..19058b00b --- /dev/null +++ b/backend/src/db/schemas/pki-acme-enrollment-configs.ts @@ -0,0 +1,20 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const PkiAcmeEnrollmentConfigsSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TPkiAcmeEnrollmentConfigs = z.infer; +export type TPkiAcmeEnrollmentConfigsInsert = Omit, TImmutableDBKeys>; +export type TPkiAcmeEnrollmentConfigsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/ee/routes/v1/pki-acme-router.ts b/backend/src/ee/routes/v1/pki-acme-router.ts index 49cc81021..5f19359a6 100644 --- a/backend/src/ee/routes/v1/pki-acme-router.ts +++ b/backend/src/ee/routes/v1/pki-acme-router.ts @@ -146,6 +146,8 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { 201: CreateAcmeOrderResponseSchema } }, + // TODO: replace with verify ACME signature here instead + // onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req, res) => { const order = await server.services.pkiAcme.createAcmeOrder(req.params.profileId, req.body); res.code(201); @@ -170,6 +172,8 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { 200: DeactivateAcmeAccountResponseSchema } }, + // TODO: replace with verify ACME signature here instead + // onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { const result = await server.services.pkiAcme.deactivateAcmeAccount(req.params.profileId, req.params.accountId); return result; @@ -193,6 +197,8 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { 200: ListAcmeOrdersResponseSchema } }, + // TODO: replace with verify ACME signature here instead + // onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { const orders = await server.services.pkiAcme.listAcmeOrders(req.params.profileId, req.params.accountId); return orders; @@ -216,6 +222,8 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { 200: GetAcmeOrderResponseSchema } }, + // TODO: replace with verify ACME signature here instead + // onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { const order = await server.services.pkiAcme.getAcmeOrder(req.params.profileId, req.params.orderId); return order; @@ -239,6 +247,8 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { 200: FinalizeAcmeOrderResponseSchema } }, + // TODO: replace with verify ACME signature here instead + // onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { const order = await server.services.pkiAcme.finalizeAcmeOrder(req.params.profileId, req.params.orderId, req.body); return order; @@ -262,6 +272,8 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { 200: z.string() } }, + // TODO: replace with verify ACME signature here instead + // onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req, res) => { const certificate = await server.services.pkiAcme.downloadAcmeCertificate( req.params.profileId, @@ -289,6 +301,8 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { 200: GetAcmeAuthorizationResponseSchema } }, + // TODO: replace with verify ACME signature here instead + // onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { const authz = await server.services.pkiAcme.getAcmeAuthorization(req.params.profileId, req.params.authzId); return authz; @@ -312,6 +326,8 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { 200: RespondToAcmeChallengeResponseSchema } }, + // TODO: replace with verify ACME signature here instead + // onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { const challenge = await server.services.pkiAcme.respondToAcmeChallenge(req.params.profileId, req.params.authzId); return challenge; diff --git a/backend/src/services/enrollment-config/acme-enrollment-config-dal.ts b/backend/src/services/enrollment-config/acme-enrollment-config-dal.ts new file mode 100644 index 000000000..afa8f17ef --- /dev/null +++ b/backend/src/services/enrollment-config/acme-enrollment-config-dal.ts @@ -0,0 +1,61 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify } from "@app/lib/knex"; + +import { TAcmeEnrollmentConfigInsert, TAcmeEnrollmentConfigUpdate } from "./enrollment-config-types"; + +export type TAcmeEnrollmentConfigDALFactory = ReturnType; + +export const acmeEnrollmentConfigDALFactory = (db: TDbClient) => { + const acmeEnrollmentConfigOrm = ormify(db, TableName.PkiAcmeEnrollmentConfig); + + const create = async (data: TAcmeEnrollmentConfigInsert, tx?: Knex) => { + try { + const result = await (tx || db)(TableName.PkiAcmeEnrollmentConfig).insert(data).returning("*"); + const [acmeConfig] = result; + + if (!acmeConfig) { + throw new Error("Failed to create ACME enrollment config"); + } + + return acmeConfig; + } catch (error) { + throw new DatabaseError({ error, name: "Create ACME enrollment config" }); + } + }; + + const updateById = async (id: string, data: TAcmeEnrollmentConfigUpdate, tx?: Knex) => { + try { + const result = await (tx || db)(TableName.PkiAcmeEnrollmentConfig).where({ id }).update(data).returning("*"); + const [acmeConfig] = result; + + if (!acmeConfig) { + return null; + } + + return acmeConfig; + } catch (error) { + throw new DatabaseError({ error, name: "Update ACME enrollment config" }); + } + }; + + const findById = async (id: string, tx?: Knex) => { + try { + const acmeConfig = await (tx || db)(TableName.PkiAcmeEnrollmentConfig).where({ id }).first(); + + return acmeConfig || null; + } catch (error) { + throw new DatabaseError({ error, name: "Find ACME enrollment config by id" }); + } + }; + + return { + ...acmeEnrollmentConfigOrm, + create, + updateById, + findById + }; +}; diff --git a/backend/src/services/enrollment-config/enrollment-config-types.ts b/backend/src/services/enrollment-config/enrollment-config-types.ts index d2e03e4da..17fd0a633 100644 --- a/backend/src/services/enrollment-config/enrollment-config-types.ts +++ b/backend/src/services/enrollment-config/enrollment-config-types.ts @@ -8,6 +8,11 @@ import { TPkiEstEnrollmentConfigsInsert, TPkiEstEnrollmentConfigsUpdate } from "@app/db/schemas/pki-est-enrollment-configs"; +import { + TPkiAcmeEnrollmentConfigs, + TPkiAcmeEnrollmentConfigsInsert, + TPkiAcmeEnrollmentConfigsUpdate +} from "@app/db/schemas/pki-acme-enrollment-configs"; export type TEstEnrollmentConfig = TPkiEstEnrollmentConfigs; export type TEstEnrollmentConfigInsert = TPkiEstEnrollmentConfigsInsert; @@ -17,6 +22,10 @@ export type TApiEnrollmentConfig = TPkiApiEnrollmentConfigs; export type TApiEnrollmentConfigInsert = TPkiApiEnrollmentConfigsInsert; export type TApiEnrollmentConfigUpdate = TPkiApiEnrollmentConfigsUpdate; +export type TAcmeEnrollmentConfig = TPkiAcmeEnrollmentConfigs; +export type TAcmeEnrollmentConfigInsert = TPkiAcmeEnrollmentConfigsInsert; +export type TAcmeEnrollmentConfigUpdate = TPkiAcmeEnrollmentConfigsUpdate; + export interface TEstConfigData { disableBootstrapCaValidation: boolean; passphrase: string; @@ -27,3 +36,7 @@ export interface TApiConfigData { autoRenew: boolean; renewBeforeDays?: number; } + +export interface TAcmeConfigData { + // TODO: we don't provide any config for ACME right now, but maybe in the future +} From 2832884ff75ebea0e8c7cdcb2d337a381246aa91 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Mon, 27 Oct 2025 16:18:41 -0700 Subject: [PATCH 014/231] More ACME db stuff # Conflicts: # backend/src/services/certificate-profile/certificate-profile-types.ts --- backend/src/db/schemas/pki-certificate-profiles.ts | 1 + backend/src/server/routes/index.ts | 2 ++ .../certificate-profile/certificate-profile-dal.ts | 14 +++++++++++++- .../certificate-profile-types.ts | 8 +++++++- 4 files changed, 23 insertions(+), 2 deletions(-) diff --git a/backend/src/db/schemas/pki-certificate-profiles.ts b/backend/src/db/schemas/pki-certificate-profiles.ts index 368770c3e..8141363bd 100644 --- a/backend/src/db/schemas/pki-certificate-profiles.ts +++ b/backend/src/db/schemas/pki-certificate-profiles.ts @@ -17,6 +17,7 @@ export const PkiCertificateProfilesSchema = z.object({ enrollmentType: z.string(), estConfigId: z.string().uuid().nullable().optional(), apiConfigId: z.string().uuid().nullable().optional(), + acmeConfigId: z.string().uuid().nullable().optional(), createdAt: z.date(), updatedAt: z.date() }); diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 0e25e9aee..e9ab34b17 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -185,6 +185,7 @@ import { cmekServiceFactory } from "@app/services/cmek/cmek-service"; import { convertorServiceFactory } from "@app/services/convertor/convertor-service"; import { apiEnrollmentConfigDALFactory } from "@app/services/enrollment-config/api-enrollment-config-dal"; import { estEnrollmentConfigDALFactory } from "@app/services/enrollment-config/est-enrollment-config-dal"; +import { acmeEnrollmentConfigDALFactory } from "@app/services/enrollment-config/acme-enrollment-config-dal"; import { externalGroupOrgRoleMappingDALFactory } from "@app/services/external-group-org-role-mapping/external-group-org-role-mapping-dal"; import { externalGroupOrgRoleMappingServiceFactory } from "@app/services/external-group-org-role-mapping/external-group-org-role-mapping-service"; import { externalMigrationQueueFactory } from "@app/services/external-migration/external-migration-queue"; @@ -1062,6 +1063,7 @@ export const registerRoutes = async ( const certificateProfileDAL = certificateProfileDALFactory(db); const apiEnrollmentConfigDAL = apiEnrollmentConfigDALFactory(db); const estEnrollmentConfigDAL = estEnrollmentConfigDALFactory(db); + const acmeEnrollmentConfigDAL = acmeEnrollmentConfigDALFactory(db); const certificateDAL = certificateDALFactory(db); const certificateBodyDAL = certificateBodyDALFactory(db); diff --git a/backend/src/services/certificate-profile/certificate-profile-dal.ts b/backend/src/services/certificate-profile/certificate-profile-dal.ts index b66475bbf..7029d4b37 100644 --- a/backend/src/services/certificate-profile/certificate-profile-dal.ts +++ b/backend/src/services/certificate-profile/certificate-profile-dal.ts @@ -88,6 +88,11 @@ export const certificateProfileDALFactory = (db: TDbClient) => { `${TableName.PkiCertificateProfile}.apiConfigId`, `${TableName.PkiApiEnrollmentConfig}.id` ) + .leftJoin( + TableName.PkiAcmeEnrollmentConfig, + `${TableName.PkiCertificateProfile}.acmeConfigId`, + `${TableName.PkiAcmeEnrollmentConfig}.id` + ) .select(selectAllTableCols(TableName.PkiCertificateProfile)) .select( db.ref("id").withSchema(TableName.CertificateAuthority).as("caId"), @@ -107,7 +112,8 @@ export const certificateProfileDALFactory = (db: TDbClient) => { db.ref("encryptedCaChain").withSchema(TableName.PkiEstEnrollmentConfig).as("estConfigEncryptedCaChain"), db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigId"), db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigAutoRenew"), - db.ref("renewBeforeDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigRenewBeforeDays") + db.ref("renewBeforeDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigRenewBeforeDays"), + db.ref("id").withSchema(TableName.PkiAcmeEnrollmentConfig).as("acmeConfigId") ) .where(`${TableName.PkiCertificateProfile}.id`, id) .first(); @@ -134,6 +140,10 @@ export const certificateProfileDALFactory = (db: TDbClient) => { } as TCertificateProfileWithConfigs["apiConfig"]) : undefined; + const acmeConfig = result.acmeConfigId + ? ({ id: result.acmeConfigId } as TCertificateProfileWithConfigs["acmeConfig"]) + : undefined; + const certificateAuthority = result.caId && result.caProjectId && result.caStatus && result.caName ? ({ @@ -164,10 +174,12 @@ export const certificateProfileDALFactory = (db: TDbClient) => { enrollmentType: result.enrollmentType as EnrollmentType, estConfigId: result.estConfigId, apiConfigId: result.apiConfigId, + acmeConfigId: result.acmeConfigId, createdAt: result.createdAt, updatedAt: result.updatedAt, estConfig, apiConfig, + acmeConfig, certificateAuthority, certificateTemplate }; diff --git a/backend/src/services/certificate-profile/certificate-profile-types.ts b/backend/src/services/certificate-profile/certificate-profile-types.ts index 5dac470c8..6e1d64fb5 100644 --- a/backend/src/services/certificate-profile/certificate-profile-types.ts +++ b/backend/src/services/certificate-profile/certificate-profile-types.ts @@ -6,7 +6,8 @@ import { export enum EnrollmentType { API = "api", - EST = "est" + EST = "est", + ACME = "acme" } export type TCertificateProfile = Omit & { @@ -28,6 +29,7 @@ export type TCertificateProfileUpdate = Omit Date: Mon, 27 Oct 2025 16:52:57 -0700 Subject: [PATCH 015/231] Add more db models --- .../migrations/20251027234547_add-pki-acme.ts | 50 +++++++++++++++++++ backend/src/db/schemas/index.ts | 2 + backend/src/db/schemas/models.ts | 1 + backend/src/db/schemas/pki-acme-accounts.ts | 21 ++++++++ 4 files changed, 74 insertions(+) create mode 100644 backend/src/db/migrations/20251027234547_add-pki-acme.ts create mode 100644 backend/src/db/schemas/pki-acme-accounts.ts diff --git a/backend/src/db/migrations/20251027234547_add-pki-acme.ts b/backend/src/db/migrations/20251027234547_add-pki-acme.ts new file mode 100644 index 000000000..d92d71951 --- /dev/null +++ b/backend/src/db/migrations/20251027234547_add-pki-acme.ts @@ -0,0 +1,50 @@ +import { Knex } from "knex"; +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + // Create PkiAcmeEnrollmentConfig table + if (!(await knex.schema.hasTable(TableName.PkiAcmeEnrollmentConfig))) { + await knex.schema.createTable(TableName.PkiAcmeEnrollmentConfig, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + + t.timestamps(true, true, true); + }); + + await createOnUpdateTrigger(knex, TableName.PkiAcmeEnrollmentConfig); + } + + // Create PkiAcmeAccount table + if (!(await knex.schema.hasTable(TableName.PkiAcmeAccount))) { + await knex.schema.createTable(TableName.PkiAcmeAccount, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + + // Foreign key to PkiCertificateProfile + t.uuid("profileId").notNullable(); + t.foreign("profileId").references("id").inTable(TableName.PkiCertificateProfile).onDelete("CASCADE"); + + // Multi-value emails array + t.specificType("emails", "text[]").notNullable(); + + // Public key (PEM format) + t.text("publicKey").notNullable(); + + t.timestamps(true, true, true); + }); + + await createOnUpdateTrigger(knex, TableName.PkiAcmeAccount); + } +} + +export async function down(knex: Knex): Promise { + // Drop PkiAcmeAccount table first (depends on PkiAcmeEnrollmentConfig) + if (await knex.schema.hasTable(TableName.PkiAcmeAccount)) { + await knex.schema.dropTable(TableName.PkiAcmeAccount); + await dropOnUpdateTrigger(knex, TableName.PkiAcmeAccount); + } + + if (await knex.schema.hasTable(TableName.PkiAcmeEnrollmentConfig)) { + await knex.schema.dropTable(TableName.PkiAcmeEnrollmentConfig); + await dropOnUpdateTrigger(knex, TableName.PkiAcmeEnrollmentConfig); + } +} diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index fba195746..aba550083 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -92,6 +92,8 @@ export * from "./pam-accounts"; export * from "./pam-folders"; export * from "./pam-resources"; export * from "./pam-sessions"; +export * from "./pki-acme-accounts"; +export * from "./pki-acme-enrollment-configs"; export * from "./pki-alerts"; export * from "./pki-api-enrollment-configs"; export * from "./pki-certificate-profiles"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 6012247ac..686f8035f 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -28,6 +28,7 @@ export enum TableName { PkiEstEnrollmentConfig = "pki_est_enrollment_configs", PkiApiEnrollmentConfig = "pki_api_enrollment_configs", PkiAcmeEnrollmentConfig = "pki_acme_enrollment_configs", + PkiAcmeAccount = "pki_acme_accounts", PkiSubscriber = "pki_subscribers", PkiAlert = "pki_alerts", PkiCollection = "pki_collections", diff --git a/backend/src/db/schemas/pki-acme-accounts.ts b/backend/src/db/schemas/pki-acme-accounts.ts new file mode 100644 index 000000000..739e7057b --- /dev/null +++ b/backend/src/db/schemas/pki-acme-accounts.ts @@ -0,0 +1,21 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const PkiAcmeAccountsSchema = z.object({ + id: z.string().uuid(), + profileId: z.string().uuid(), + emails: z.string().array(), + publicKey: z.string(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TPkiAcmeAccounts = z.infer; +export type TPkiAcmeAccountsInsert = Omit, TImmutableDBKeys>; +export type TPkiAcmeAccountsUpdate = Partial, TImmutableDBKeys>>; From 59926127b0732380bcede46a7ce542606e7349b3 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Mon, 27 Oct 2025 16:58:28 -0700 Subject: [PATCH 016/231] Add more db schema --- .../migrations/20251027234547_add-pki-acme.ts | 94 ++++++++++++++++++- backend/src/db/schemas/index.ts | 3 + backend/src/db/schemas/models.ts | 3 + backend/src/db/schemas/pki-acme-auths.ts | 24 +++++ backend/src/db/schemas/pki-acme-challenges.ts | 22 +++++ backend/src/db/schemas/pki-acme-orders.ts | 20 ++++ 6 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 backend/src/db/schemas/pki-acme-auths.ts create mode 100644 backend/src/db/schemas/pki-acme-challenges.ts create mode 100644 backend/src/db/schemas/pki-acme-orders.ts diff --git a/backend/src/db/migrations/20251027234547_add-pki-acme.ts b/backend/src/db/migrations/20251027234547_add-pki-acme.ts index d92d71951..81a3ea82b 100644 --- a/backend/src/db/migrations/20251027234547_add-pki-acme.ts +++ b/backend/src/db/migrations/20251027234547_add-pki-acme.ts @@ -34,15 +34,107 @@ export async function up(knex: Knex): Promise { await createOnUpdateTrigger(knex, TableName.PkiAcmeAccount); } + + // Create PkiAcmeOrder table + if (!(await knex.schema.hasTable(TableName.PkiAcmeOrder))) { + await knex.schema.createTable(TableName.PkiAcmeOrder, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + + // Foreign key to PkiAcmeAccount + t.uuid("accountId").notNullable(); + t.foreign("accountId").references("id").inTable(TableName.PkiAcmeAccount).onDelete("CASCADE"); + + // Order status + t.string("status").notNullable(); // pending, ready, processing, valid, invalid + + t.timestamps(true, true, true); + }); + + await createOnUpdateTrigger(knex, TableName.PkiAcmeOrder); + } + + // Create PkiAcmeAuth table + if (!(await knex.schema.hasTable(TableName.PkiAcmeAuth))) { + await knex.schema.createTable(TableName.PkiAcmeAuth, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + + // Foreign key to PkiAcmeAccount + t.uuid("accountId").notNullable(); + t.foreign("accountId").references("id").inTable(TableName.PkiAcmeAccount).onDelete("CASCADE"); + + // Authorization status + t.string("status").notNullable(); // pending, valid, invalid, deactivated, expired, revoked + + // Identifier type and value + t.string("identifierType").notNullable(); // dns + t.string("identifierValue").notNullable(); // domain name + + // Expiration timestamp + t.timestamp("expiresAt").notNullable(); + + // Optional link to issued certificate + t.uuid("certificateId").nullable(); + t.foreign("certificateId").references("id").inTable(TableName.Certificate).onDelete("SET NULL"); + + t.timestamps(true, true, true); + }); + + await createOnUpdateTrigger(knex, TableName.PkiAcmeAuth); + } + + // Create PkiAcmeChallenge table + if (!(await knex.schema.hasTable(TableName.PkiAcmeChallenge))) { + await knex.schema.createTable(TableName.PkiAcmeChallenge, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + + // Foreign key to PkiAcmeAuth + t.uuid("authId").notNullable(); + t.foreign("authId").references("id").inTable(TableName.PkiAcmeAuth).onDelete("CASCADE"); + + // Challenge type + t.string("type").notNullable(); // http-01, dns-01, tls-alpn-01 + + // Challenge status + t.string("status").notNullable(); // pending, processing, valid, invalid + + // Validation timestamp + t.timestamp("validatedAt").nullable(); + + t.timestamps(true, true, true); + }); + + await createOnUpdateTrigger(knex, TableName.PkiAcmeChallenge); + } } export async function down(knex: Knex): Promise { - // Drop PkiAcmeAccount table first (depends on PkiAcmeEnrollmentConfig) + // Drop tables in reverse dependency order + + // Drop PkiAcmeChallenge first (depends on PkiAcmeAuth) + if (await knex.schema.hasTable(TableName.PkiAcmeChallenge)) { + await knex.schema.dropTable(TableName.PkiAcmeChallenge); + await dropOnUpdateTrigger(knex, TableName.PkiAcmeChallenge); + } + + // Drop PkiAcmeAuth (depends on PkiAcmeAccount and Certificate) + if (await knex.schema.hasTable(TableName.PkiAcmeAuth)) { + await knex.schema.dropTable(TableName.PkiAcmeAuth); + await dropOnUpdateTrigger(knex, TableName.PkiAcmeAuth); + } + + // Drop PkiAcmeOrder (depends on PkiAcmeAccount) + if (await knex.schema.hasTable(TableName.PkiAcmeOrder)) { + await knex.schema.dropTable(TableName.PkiAcmeOrder); + await dropOnUpdateTrigger(knex, TableName.PkiAcmeOrder); + } + + // Drop PkiAcmeAccount (depends on PkiCertificateProfile) if (await knex.schema.hasTable(TableName.PkiAcmeAccount)) { await knex.schema.dropTable(TableName.PkiAcmeAccount); await dropOnUpdateTrigger(knex, TableName.PkiAcmeAccount); } + // Drop PkiAcmeEnrollmentConfig if (await knex.schema.hasTable(TableName.PkiAcmeEnrollmentConfig)) { await knex.schema.dropTable(TableName.PkiAcmeEnrollmentConfig); await dropOnUpdateTrigger(knex, TableName.PkiAcmeEnrollmentConfig); diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index aba550083..3eef75d16 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -93,7 +93,10 @@ export * from "./pam-folders"; export * from "./pam-resources"; export * from "./pam-sessions"; export * from "./pki-acme-accounts"; +export * from "./pki-acme-auths"; +export * from "./pki-acme-challenges"; export * from "./pki-acme-enrollment-configs"; +export * from "./pki-acme-orders"; export * from "./pki-alerts"; export * from "./pki-api-enrollment-configs"; export * from "./pki-certificate-profiles"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 686f8035f..0fcda08d7 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -29,6 +29,9 @@ export enum TableName { PkiApiEnrollmentConfig = "pki_api_enrollment_configs", PkiAcmeEnrollmentConfig = "pki_acme_enrollment_configs", PkiAcmeAccount = "pki_acme_accounts", + PkiAcmeOrder = "pki_acme_orders", + PkiAcmeAuth = "pki_acme_auths", + PkiAcmeChallenge = "pki_acme_challenges", PkiSubscriber = "pki_subscribers", PkiAlert = "pki_alerts", PkiCollection = "pki_collections", diff --git a/backend/src/db/schemas/pki-acme-auths.ts b/backend/src/db/schemas/pki-acme-auths.ts new file mode 100644 index 000000000..de883356d --- /dev/null +++ b/backend/src/db/schemas/pki-acme-auths.ts @@ -0,0 +1,24 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const PkiAcmeAuthsSchema = z.object({ + id: z.string().uuid(), + accountId: z.string().uuid(), + status: z.string(), + identifierType: z.string(), + identifierValue: z.string(), + expiresAt: z.date(), + certificateId: z.string().uuid().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TPkiAcmeAuths = z.infer; +export type TPkiAcmeAuthsInsert = Omit, TImmutableDBKeys>; +export type TPkiAcmeAuthsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/pki-acme-challenges.ts b/backend/src/db/schemas/pki-acme-challenges.ts new file mode 100644 index 000000000..17282da06 --- /dev/null +++ b/backend/src/db/schemas/pki-acme-challenges.ts @@ -0,0 +1,22 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const PkiAcmeChallengesSchema = z.object({ + id: z.string().uuid(), + authId: z.string().uuid(), + type: z.string(), + status: z.string(), + validatedAt: z.date().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TPkiAcmeChallenges = z.infer; +export type TPkiAcmeChallengesInsert = Omit, TImmutableDBKeys>; +export type TPkiAcmeChallengesUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/pki-acme-orders.ts b/backend/src/db/schemas/pki-acme-orders.ts new file mode 100644 index 000000000..d52c30662 --- /dev/null +++ b/backend/src/db/schemas/pki-acme-orders.ts @@ -0,0 +1,20 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +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() +}); + +export type TPkiAcmeOrders = z.infer; +export type TPkiAcmeOrdersInsert = Omit, TImmutableDBKeys>; +export type TPkiAcmeOrdersUpdate = Partial, TImmutableDBKeys>>; From b542c5589c1d6e5d1955f2155e07a70e8606568b Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Mon, 27 Oct 2025 17:00:42 -0700 Subject: [PATCH 017/231] Add encrypted eab secret for auth --- backend/src/db/migrations/20251027234547_add-pki-acme.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/src/db/migrations/20251027234547_add-pki-acme.ts b/backend/src/db/migrations/20251027234547_add-pki-acme.ts index 81a3ea82b..a5aba5d25 100644 --- a/backend/src/db/migrations/20251027234547_add-pki-acme.ts +++ b/backend/src/db/migrations/20251027234547_add-pki-acme.ts @@ -7,6 +7,7 @@ export async function up(knex: Knex): Promise { if (!(await knex.schema.hasTable(TableName.PkiAcmeEnrollmentConfig))) { await knex.schema.createTable(TableName.PkiAcmeEnrollmentConfig, (t) => { t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.binary("encryptedEabSecret").notNullable(); t.timestamps(true, true, true); }); From 2bed3622593a1d7a25f256f8591b4a2be96e895e Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Mon, 27 Oct 2025 18:00:25 -0700 Subject: [PATCH 018/231] More columns --- backend/src/db/migrations/20251027234547_add-pki-acme.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/backend/src/db/migrations/20251027234547_add-pki-acme.ts b/backend/src/db/migrations/20251027234547_add-pki-acme.ts index a5aba5d25..255d6a9a4 100644 --- a/backend/src/db/migrations/20251027234547_add-pki-acme.ts +++ b/backend/src/db/migrations/20251027234547_add-pki-acme.ts @@ -15,6 +15,15 @@ export async function up(knex: Knex): Promise { await createOnUpdateTrigger(knex, TableName.PkiAcmeEnrollmentConfig); } + if (!(await knex.schema.hasColumn(TableName.PkiCertificateProfile, "acmeConfigId"))) { + await knex.schema.alterTable(TableName.PkiCertificateProfile, (t) => { + t.uuid("acmeConfigId"); + t.foreign("acmeConfigId").references("id").inTable(TableName.PkiAcmeEnrollmentConfig).onDelete("SET NULL"); + t.index("acmeConfigId"); + }); + // TODO: should update (or add?) the constraints to check at least one of the enrollment config id is set + } + // Create PkiAcmeAccount table if (!(await knex.schema.hasTable(TableName.PkiAcmeAccount))) { await knex.schema.createTable(TableName.PkiAcmeAccount, (t) => { From acbf0015cb0881fd6ae446618a78b7ff4893dc97 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Mon, 27 Oct 2025 18:28:30 -0700 Subject: [PATCH 019/231] Add missing downgrade --- .../src/db/migrations/20251027234547_add-pki-acme.ts | 11 ++++++++++- backend/src/db/schemas/pki-acme-enrollment-configs.ts | 3 +++ backend/src/db/schemas/pki-certificate-profiles.ts | 4 ++-- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/backend/src/db/migrations/20251027234547_add-pki-acme.ts b/backend/src/db/migrations/20251027234547_add-pki-acme.ts index 255d6a9a4..01483938b 100644 --- a/backend/src/db/migrations/20251027234547_add-pki-acme.ts +++ b/backend/src/db/migrations/20251027234547_add-pki-acme.ts @@ -21,7 +21,8 @@ export async function up(knex: Knex): Promise { t.foreign("acmeConfigId").references("id").inTable(TableName.PkiAcmeEnrollmentConfig).onDelete("SET NULL"); t.index("acmeConfigId"); }); - // TODO: should update (or add?) the constraints to check at least one of the enrollment config id is set + // TODO: should update (or add?) the constraints to check at least one of + // the enrollment config id is set and it matches the enrollment type? } // Create PkiAcmeAccount table @@ -120,6 +121,14 @@ export async function up(knex: Knex): Promise { export async function down(knex: Knex): Promise { // Drop tables in reverse dependency order + if (await knex.schema.hasColumn(TableName.PkiCertificateProfile, "acmeConfigId")) { + await knex.schema.alterTable(TableName.PkiCertificateProfile, (t) => { + t.dropForeign(["acmeConfigId"]); + t.dropIndex("acmeConfigId"); + t.dropColumn("acmeConfigId"); + }); + } + // Drop PkiAcmeChallenge first (depends on PkiAcmeAuth) if (await knex.schema.hasTable(TableName.PkiAcmeChallenge)) { await knex.schema.dropTable(TableName.PkiAcmeChallenge); diff --git a/backend/src/db/schemas/pki-acme-enrollment-configs.ts b/backend/src/db/schemas/pki-acme-enrollment-configs.ts index 19058b00b..f0592319b 100644 --- a/backend/src/db/schemas/pki-acme-enrollment-configs.ts +++ b/backend/src/db/schemas/pki-acme-enrollment-configs.ts @@ -5,10 +5,13 @@ import { z } from "zod"; +import { zodBuffer } from "@app/lib/zod"; + import { TImmutableDBKeys } from "./models"; export const PkiAcmeEnrollmentConfigsSchema = z.object({ id: z.string().uuid(), + encryptedEabSecret: zodBuffer, createdAt: z.date(), updatedAt: z.date() }); diff --git a/backend/src/db/schemas/pki-certificate-profiles.ts b/backend/src/db/schemas/pki-certificate-profiles.ts index 8141363bd..04560bec6 100644 --- a/backend/src/db/schemas/pki-certificate-profiles.ts +++ b/backend/src/db/schemas/pki-certificate-profiles.ts @@ -17,9 +17,9 @@ export const PkiCertificateProfilesSchema = z.object({ enrollmentType: z.string(), estConfigId: z.string().uuid().nullable().optional(), apiConfigId: z.string().uuid().nullable().optional(), - acmeConfigId: z.string().uuid().nullable().optional(), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + acmeConfigId: z.string().uuid().nullable().optional() }); export type TPkiCertificateProfiles = z.infer; From f6573c4c23a95ee1bd1c869123a5d98834991941 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Mon, 27 Oct 2025 19:17:01 -0700 Subject: [PATCH 020/231] Enroll acme config --- .../certificate-profile-service.ts | 56 +++++++++++++++++-- .../enrollment-config-types.ts | 2 +- 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/backend/src/services/certificate-profile/certificate-profile-service.ts b/backend/src/services/certificate-profile/certificate-profile-service.ts index f858a8d4f..d9b9565e1 100644 --- a/backend/src/services/certificate-profile/certificate-profile-service.ts +++ b/backend/src/services/certificate-profile/certificate-profile-service.ts @@ -13,10 +13,10 @@ import { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { ActorAuthMethod, ActorType } from "../auth/auth-type"; -import { isCertChainValid } from "../certificate/certificate-fns"; import { TCertificateTemplateV2DALFactory } from "../certificate-template-v2/certificate-template-v2-dal"; +import { isCertChainValid } from "../certificate/certificate-fns"; import { TApiEnrollmentConfigDALFactory } from "../enrollment-config/api-enrollment-config-dal"; -import { TApiConfigData, TEstConfigData } from "../enrollment-config/enrollment-config-types"; +import { TAcmeConfigData, TApiConfigData, TEstConfigData } from "../enrollment-config/enrollment-config-types"; import { TEstEnrollmentConfigDALFactory } from "../enrollment-config/est-enrollment-config-dal"; import { TKmsServiceFactory } from "../kms/kms-service"; import { TProjectDALFactory } from "../project/project-dal"; @@ -30,6 +30,37 @@ import { TCertificateProfileUpdate, TCertificateProfileWithConfigs } from "./certificate-profile-types"; +import { TAcmeEnrollmentConfigDALFactory } from "../enrollment-config/acme-enrollment-config-dal"; + +const generateAndEncryptAcmeEabSecret = async ( + projectId: string, + kmsService: Pick, + projectDAL: Pick +) => { + try { + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId, + projectDAL, + kmsService + }); + + const kmsEncryptor = await kmsService.encryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + + const appCfg = getConfig(); + const secret = crypto.randomBytes(32).toString("hex"); + const secretHash = await crypto.hashing().createHash(secret, appCfg.SALT_ROUNDS); + + const { cipherTextBlob } = await kmsEncryptor({ + plainText: Buffer.from(secretHash) + }); + + return { encryptedEabSecret: cipherTextBlob }; + } catch (error) { + throw new BadRequestError({ message: `Failed to generate ACME EAB secret: ${(error as Error).message}` }); + } +}; const validateAndEncryptPemCaChain = async ( caChain: string, @@ -95,9 +126,13 @@ const decryptCaChain = async ( } }; -export type TCertificateProfileCreateData = Omit & { +export type TCertificateProfileCreateData = Omit< + TCertificateProfileInsert, + "estConfigId" | "apiConfigId" | "acmeConfigId" +> & { estConfig?: TEstConfigData; apiConfig?: TApiConfigData; + acmeConfig?: TAcmeConfigData; }; type TCertificateProfileServiceFactoryDep = { @@ -105,6 +140,7 @@ type TCertificateProfileServiceFactoryDep = { certificateTemplateV2DAL: TCertificateTemplateV2DALFactory; apiEnrollmentConfigDAL: TApiEnrollmentConfigDALFactory; estEnrollmentConfigDAL: TEstEnrollmentConfigDALFactory; + acmeEnrollmentConfigDAL: TAcmeEnrollmentConfigDALFactory; permissionService: Pick; kmsService: Pick; projectDAL: Pick; @@ -124,6 +160,7 @@ export const certificateProfileServiceFactory = ({ certificateTemplateV2DAL, apiEnrollmentConfigDAL, estEnrollmentConfigDAL, + acmeEnrollmentConfigDAL, permissionService, kmsService, projectDAL @@ -188,11 +225,17 @@ export const certificateProfileServiceFactory = ({ message: "API enrollment requires API configuration" }); } + if (data.enrollmentType === EnrollmentType.ACME && !data.acmeConfig) { + throw new ForbiddenRequestError({ + message: "ACME enrollment requires ACME configuration" + }); + } // Create enrollment configs and profile const profile = await certificateProfileDAL.transaction(async (tx) => { let estConfigId: string | null = null; let apiConfigId: string | null = null; + let acmeConfigId: string | null = null; if (data.enrollmentType === EnrollmentType.EST && data.estConfig) { const appCfg = getConfig(); @@ -228,6 +271,10 @@ export const certificateProfileServiceFactory = ({ tx ); apiConfigId = apiConfig.id; + } else if (data.enrollmentType === EnrollmentType.ACME && data.acmeConfig) { + const { encryptedEabSecret } = await generateAndEncryptAcmeEabSecret(projectId, kmsService, projectDAL); + const acmeConfig = await acmeEnrollmentConfigDAL.create({ encryptedEabSecret }, tx); + acmeConfigId = acmeConfig.id; } // Create the profile with the created config IDs @@ -237,7 +284,8 @@ export const certificateProfileServiceFactory = ({ ...profileData, projectId, estConfigId, - apiConfigId + apiConfigId, + acmeConfigId }, tx ); diff --git a/backend/src/services/enrollment-config/enrollment-config-types.ts b/backend/src/services/enrollment-config/enrollment-config-types.ts index 17fd0a633..ff017e409 100644 --- a/backend/src/services/enrollment-config/enrollment-config-types.ts +++ b/backend/src/services/enrollment-config/enrollment-config-types.ts @@ -38,5 +38,5 @@ export interface TApiConfigData { } export interface TAcmeConfigData { - // TODO: we don't provide any config for ACME right now, but maybe in the future + eabSecret: string; } From 687873a95dec4faa68847332685fae1b7d57aff3 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Mon, 27 Oct 2025 19:23:26 -0700 Subject: [PATCH 021/231] Add acme dropdown option --- .../components/CertificateProfilesTab/CreateProfileModal.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx index 9f13acb63..267f5e780 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx @@ -439,6 +439,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" } > API EST + ACME )} From 5b8b133756cf8c0b5a1e086a75db79a278c151d3 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Mon, 27 Oct 2025 19:26:40 -0700 Subject: [PATCH 022/231] Add acme option --- .../components/CertificateProfilesTab/CreateProfileModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx index 267f5e780..245eadacc 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx @@ -45,7 +45,7 @@ const createSchema = z .trim() .max(1000, "Description must be less than 1000 characters") .optional(), - enrollmentType: z.enum(["api", "est"]), + enrollmentType: z.enum(["api", "est", "acme"]), certificateAuthorityId: z.string().min(1, "Certificate Authority is required"), certificateTemplateId: z.string().min(1, "Certificate Template is required"), estConfig: z From 5366136693db5ad95e400998de31c4594346c842 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 28 Oct 2025 09:48:02 -0700 Subject: [PATCH 023/231] Try to fix constraint --- .../migrations/20251027234547_add-pki-acme.ts | 37 ++++++++++++++----- .../utils/dropConstraintIfExists.ts | 2 +- 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/backend/src/db/migrations/20251027234547_add-pki-acme.ts b/backend/src/db/migrations/20251027234547_add-pki-acme.ts index 01483938b..5352f71fa 100644 --- a/backend/src/db/migrations/20251027234547_add-pki-acme.ts +++ b/backend/src/db/migrations/20251027234547_add-pki-acme.ts @@ -1,6 +1,9 @@ import { Knex } from "knex"; import { TableName } from "../schemas"; import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; +import { dropConstraintIfExists } from "@app/db/migrations/utils/dropConstraintIfExists"; + +const ENROLLMENT_TYPE_CHECK_CONSTRAINT = "pki_certificate_profiles_enrollmentType_check"; export async function up(knex: Knex): Promise { // Create PkiAcmeEnrollmentConfig table @@ -21,8 +24,13 @@ export async function up(knex: Knex): Promise { t.foreign("acmeConfigId").references("id").inTable(TableName.PkiAcmeEnrollmentConfig).onDelete("SET NULL"); t.index("acmeConfigId"); }); - // TODO: should update (or add?) the constraints to check at least one of - // the enrollment config id is set and it matches the enrollment type? + } + + await dropConstraintIfExists(TableName.PkiCertificateProfile, ENROLLMENT_TYPE_CHECK_CONSTRAINT, knex); + if (await knex.schema.hasColumn(TableName.PkiCertificateProfile, "enrollmentType")) { + await knex.schema.alterTable(TableName.PkiCertificateProfile, (t) => { + t.string("enrollmentType").checkIn(["api", "est", "acme"], ENROLLMENT_TYPE_CHECK_CONSTRAINT).alter(); + }); } // Create PkiAcmeAccount table @@ -121,14 +129,6 @@ export async function up(knex: Knex): Promise { export async function down(knex: Knex): Promise { // Drop tables in reverse dependency order - if (await knex.schema.hasColumn(TableName.PkiCertificateProfile, "acmeConfigId")) { - await knex.schema.alterTable(TableName.PkiCertificateProfile, (t) => { - t.dropForeign(["acmeConfigId"]); - t.dropIndex("acmeConfigId"); - t.dropColumn("acmeConfigId"); - }); - } - // Drop PkiAcmeChallenge first (depends on PkiAcmeAuth) if (await knex.schema.hasTable(TableName.PkiAcmeChallenge)) { await knex.schema.dropTable(TableName.PkiAcmeChallenge); @@ -153,6 +153,23 @@ export async function down(knex: Knex): Promise { await dropOnUpdateTrigger(knex, TableName.PkiAcmeAccount); } + // Change enrollmentType check constraint to only allow api and est + await dropConstraintIfExists(TableName.PkiCertificateProfile, ENROLLMENT_TYPE_CHECK_CONSTRAINT, knex); + if (await knex.schema.hasColumn(TableName.PkiCertificateProfile, "enrollmentType")) { + await knex.schema.alterTable(TableName.PkiCertificateProfile, (t) => { + t.string("enrollmentType").checkIn(["api", "est"], ENROLLMENT_TYPE_CHECK_CONSTRAINT).alter(); + }); + } + + // Drop acmeConfigId column + if (await knex.schema.hasColumn(TableName.PkiCertificateProfile, "acmeConfigId")) { + await knex.schema.alterTable(TableName.PkiCertificateProfile, (t) => { + t.dropForeign(["acmeConfigId"]); + t.dropIndex("acmeConfigId"); + t.dropColumn("acmeConfigId"); + }); + } + // Drop PkiAcmeEnrollmentConfig if (await knex.schema.hasTable(TableName.PkiAcmeEnrollmentConfig)) { await knex.schema.dropTable(TableName.PkiAcmeEnrollmentConfig); diff --git a/backend/src/db/migrations/utils/dropConstraintIfExists.ts b/backend/src/db/migrations/utils/dropConstraintIfExists.ts index bfe487d49..93985ca76 100644 --- a/backend/src/db/migrations/utils/dropConstraintIfExists.ts +++ b/backend/src/db/migrations/utils/dropConstraintIfExists.ts @@ -3,4 +3,4 @@ import { Knex } from "knex"; import { TableName } from "@app/db/schemas"; export const dropConstraintIfExists = (tableName: TableName, constraintName: string, knex: Knex) => - knex.raw(`ALTER TABLE ${tableName} DROP CONSTRAINT IF EXISTS ${constraintName};`); + knex.raw("ALTER TABLE ?? DROP CONSTRAINT IF EXISTS ??;", [tableName, constraintName]); From 9c199cdbb8799af643dc643b7af7523e3d88f81b Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 28 Oct 2025 10:30:09 -0700 Subject: [PATCH 024/231] Fix db migration --- .../migrations/20251027234547_add-pki-acme.ts | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/backend/src/db/migrations/20251027234547_add-pki-acme.ts b/backend/src/db/migrations/20251027234547_add-pki-acme.ts index 5352f71fa..3b012c9ed 100644 --- a/backend/src/db/migrations/20251027234547_add-pki-acme.ts +++ b/backend/src/db/migrations/20251027234547_add-pki-acme.ts @@ -3,7 +3,12 @@ import { TableName } from "../schemas"; import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; import { dropConstraintIfExists } from "@app/db/migrations/utils/dropConstraintIfExists"; -const ENROLLMENT_TYPE_CHECK_CONSTRAINT = "pki_certificate_profiles_enrollmentType_check"; +// Notice: the old constraint name is "enrollmentType_check" instead of "enrollment_type_check" +// with psql, if there's no quote around an identifier, it will be lowercased. +// this may cause issues in migrations as Knex sometimes generates identifiers without quotes. +// to avoid this, we use a new constraint name that contains only lowercase letters and underscores. +const OLD_ENROLLMENT_TYPE_CHECK_CONSTRAINT = "pki_certificate_profiles_enrollmentType_check"; +const NEW_ENROLLMENT_TYPE_CHECK_CONSTRAINT = "pki_certificate_profiles_enrollment_type_check"; export async function up(knex: Knex): Promise { // Create PkiAcmeEnrollmentConfig table @@ -26,10 +31,11 @@ export async function up(knex: Knex): Promise { }); } - await dropConstraintIfExists(TableName.PkiCertificateProfile, ENROLLMENT_TYPE_CHECK_CONSTRAINT, knex); + await dropConstraintIfExists(TableName.PkiCertificateProfile, OLD_ENROLLMENT_TYPE_CHECK_CONSTRAINT, knex); if (await knex.schema.hasColumn(TableName.PkiCertificateProfile, "enrollmentType")) { + // Notice: it's okay to use `.checkIn(...).alter();` here because the constraint name is all lowercase. await knex.schema.alterTable(TableName.PkiCertificateProfile, (t) => { - t.string("enrollmentType").checkIn(["api", "est", "acme"], ENROLLMENT_TYPE_CHECK_CONSTRAINT).alter(); + t.string("enrollmentType").checkIn(["api", "est", "acme"], NEW_ENROLLMENT_TYPE_CHECK_CONSTRAINT).alter(); }); } @@ -153,12 +159,20 @@ export async function down(knex: Knex): Promise { await dropOnUpdateTrigger(knex, TableName.PkiAcmeAccount); } - // Change enrollmentType check constraint to only allow api and est - await dropConstraintIfExists(TableName.PkiCertificateProfile, ENROLLMENT_TYPE_CHECK_CONSTRAINT, knex); + // Change enrollmentType check constraint to allow acme + await dropConstraintIfExists(TableName.PkiCertificateProfile, NEW_ENROLLMENT_TYPE_CHECK_CONSTRAINT, knex); if (await knex.schema.hasColumn(TableName.PkiCertificateProfile, "enrollmentType")) { - await knex.schema.alterTable(TableName.PkiCertificateProfile, (t) => { - t.string("enrollmentType").checkIn(["api", "est"], ENROLLMENT_TYPE_CHECK_CONSTRAINT).alter(); - }); + // Notice: DO NOT USE + // + // `t.string("enrollmentType").checkIn(["api", "est"], OLD_ENROLLMENT_TYPE_CHECK_CONSTRAINT).alter();` + // + // here because knex will generate a constraint name without quotes, and it will be treated as lowercased and causing problems. + await knex.raw( + `ALTER TABLE ?? + ADD CONSTRAINT ?? CHECK (enrollmentType IN ('api', 'est')); + `, + [TableName.PkiCertificateProfile, OLD_ENROLLMENT_TYPE_CHECK_CONSTRAINT] + ); } // Drop acmeConfigId column From 3916d48ba41d3e80628f7b8fd8ad233e5c98f569 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 28 Oct 2025 10:32:14 -0700 Subject: [PATCH 025/231] Fix rollback constraint --- backend/src/db/migrations/20251027234547_add-pki-acme.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/src/db/migrations/20251027234547_add-pki-acme.ts b/backend/src/db/migrations/20251027234547_add-pki-acme.ts index 3b012c9ed..a81b377e3 100644 --- a/backend/src/db/migrations/20251027234547_add-pki-acme.ts +++ b/backend/src/db/migrations/20251027234547_add-pki-acme.ts @@ -169,9 +169,9 @@ export async function down(knex: Knex): Promise { // here because knex will generate a constraint name without quotes, and it will be treated as lowercased and causing problems. await knex.raw( `ALTER TABLE ?? - ADD CONSTRAINT ?? CHECK (enrollmentType IN ('api', 'est')); + ADD CONSTRAINT ?? CHECK (?? IN ('api', 'est')); `, - [TableName.PkiCertificateProfile, OLD_ENROLLMENT_TYPE_CHECK_CONSTRAINT] + [TableName.PkiCertificateProfile, OLD_ENROLLMENT_TYPE_CHECK_CONSTRAINT, "enrollmentType"] ); } From 2d8126c0111f34c9ad260b6042f1182d5c231e19 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 28 Oct 2025 11:00:50 -0700 Subject: [PATCH 026/231] Add more missing ACME stuff # Conflicts: # frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileRow.tsx # Conflicts: # frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx --- .../certificate-profile-service.ts | 7 ++--- .../hooks/api/certificateProfiles/types.ts | 8 +++-- .../CreateProfileModal.tsx | 31 ++++++++++++++++--- .../CertificateProfilesTab/ProfileRow.tsx | 3 +- 4 files changed, 36 insertions(+), 13 deletions(-) diff --git a/backend/src/services/certificate-profile/certificate-profile-service.ts b/backend/src/services/certificate-profile/certificate-profile-service.ts index d9b9565e1..0de9d6c55 100644 --- a/backend/src/services/certificate-profile/certificate-profile-service.ts +++ b/backend/src/services/certificate-profile/certificate-profile-service.ts @@ -225,11 +225,8 @@ export const certificateProfileServiceFactory = ({ message: "API enrollment requires API configuration" }); } - if (data.enrollmentType === EnrollmentType.ACME && !data.acmeConfig) { - throw new ForbiddenRequestError({ - message: "ACME enrollment requires ACME configuration" - }); - } + // TODO: acme type currently doesn't require config obj, but add a check in the future if + // we have options // Create enrollment configs and profile const profile = await certificateProfileDAL.transaction(async (tx) => { diff --git a/frontend/src/hooks/api/certificateProfiles/types.ts b/frontend/src/hooks/api/certificateProfiles/types.ts index f3584b12d..94d8d0c6d 100644 --- a/frontend/src/hooks/api/certificateProfiles/types.ts +++ b/frontend/src/hooks/api/certificateProfiles/types.ts @@ -5,7 +5,7 @@ export type TCertificateProfile = { certificateTemplateId: string; slug: string; description?: string; - enrollmentType: "api" | "est"; + enrollmentType: "api" | "est" | "acme"; estConfigId?: string; apiConfigId?: string; createdAt: string; @@ -44,7 +44,7 @@ export type TCreateCertificateProfileDTO = { certificateTemplateId: string; slug: string; description?: string; - enrollmentType: "api" | "est"; + enrollmentType: "api" | "est" | "acme"; estConfig?: { disableBootstrapCaValidation?: boolean; passphrase: string; @@ -54,6 +54,7 @@ export type TCreateCertificateProfileDTO = { autoRenew?: boolean; renewBeforeDays?: number; }; + acmeConfig?: {}; }; export type TUpdateCertificateProfileDTO = { @@ -69,6 +70,7 @@ export type TUpdateCertificateProfileDTO = { autoRenew?: boolean; renewBeforeDays?: number; }; + acmeConfig?: {}; }; export type TDeleteCertificateProfileDTO = { @@ -81,7 +83,7 @@ export type TListCertificateProfilesDTO = { offset?: number; search?: string; includeConfigs?: boolean; - enrollmentType?: "api" | "est"; + enrollmentType?: "api" | "est" | "acme"; }; export type TGetCertificateProfileByIdDTO = { diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx index 245eadacc..eefc76a1d 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx @@ -72,7 +72,8 @@ const createSchema = z autoRenew: z.boolean().optional(), renewBeforeDays: z.number().min(1).max(365).optional() }) - .optional() + .optional(), + acmeConfig: z.object({}).optional() }) .refine( (data) => { @@ -82,6 +83,9 @@ const createSchema = z if (data.enrollmentType === "api" && !data.apiConfig) { return false; } + if (data.enrollmentType === "acme" && !data.acmeConfig) { + return false; + } return true; }, { @@ -188,7 +192,8 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" } autoRenew: profile.apiConfig?.autoRenew || false, renewBeforeDays: profile.apiConfig?.renewBeforeDays || 30 } - : undefined + : undefined, + acmeConfig: profile.enrollmentType === "acme" ? {} : undefined } : { slug: "", @@ -199,7 +204,8 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" } apiConfig: { autoRenew: false, renewBeforeDays: 30 - } + }, + acmeConfig: {} } }); @@ -230,7 +236,8 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" } autoRenew: profile.apiConfig?.autoRenew || false, renewBeforeDays: profile.apiConfig?.renewBeforeDays || 30 } - : undefined + : undefined, + acmeConfig: profile.enrollmentType === "acme" ? {} : undefined }); } }, [isEdit, profile, reset]); @@ -249,6 +256,8 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" } updateData.estConfig = data.estConfig; } else if (data.enrollmentType === "api" && data.apiConfig) { updateData.apiConfig = data.apiConfig; + } else if (data.enrollmentType === "acme" && data.acmeConfig) { + updateData.acmeConfig = data.acmeConfig; } await updateProfile.mutateAsync(updateData); @@ -549,6 +558,20 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" } )} + {/* ACME Configuration */} + {watchedEnrollmentType === "acme" && ( +
+ ( + +
FIXME: ACME configuration
+
+ )} + /> +
+ )} {watchedAutoRenew && (
{ const config = { api: { variant: "ghost" as const, label: "API" }, - est: { variant: "ghost" as const, label: "EST" } + est: { variant: "ghost" as const, label: "EST" }, + acme: { variant: "ghost" as const, label: "ACME" } } as const; const configKey = Object.keys(config).includes(enrollmentType) From 806e11a5b21d2538f0dbc2c33b386738c4a1f584 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 28 Oct 2025 11:09:49 -0700 Subject: [PATCH 027/231] Make column not nullable --- backend/bdd/features/steps/pki_acme.py | 2 +- backend/src/db/migrations/20251027234547_add-pki-acme.ts | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/backend/bdd/features/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py index 9a84b0dc8..0f04849e6 100644 --- a/backend/bdd/features/steps/pki_acme.py +++ b/backend/bdd/features/steps/pki_acme.py @@ -36,7 +36,7 @@ def step_impl(context: Context, profile_var: str): # TODO: Fixed value for now, just to make test much easier, # we should call infisical API to create such profile instead # in the future - profile_id = "c051e74c-48a7-4724-832c-d5b496698546" + profile_id = "40dd4794-66e3-4bd3-9875-c1f819804bb2" context.vars[profile_var] = AcmeProfile(profile_id) diff --git a/backend/src/db/migrations/20251027234547_add-pki-acme.ts b/backend/src/db/migrations/20251027234547_add-pki-acme.ts index a81b377e3..a2e1ab777 100644 --- a/backend/src/db/migrations/20251027234547_add-pki-acme.ts +++ b/backend/src/db/migrations/20251027234547_add-pki-acme.ts @@ -35,7 +35,10 @@ export async function up(knex: Knex): Promise { if (await knex.schema.hasColumn(TableName.PkiCertificateProfile, "enrollmentType")) { // Notice: it's okay to use `.checkIn(...).alter();` here because the constraint name is all lowercase. await knex.schema.alterTable(TableName.PkiCertificateProfile, (t) => { - t.string("enrollmentType").checkIn(["api", "est", "acme"], NEW_ENROLLMENT_TYPE_CHECK_CONSTRAINT).alter(); + t.string("enrollmentType") + .notNullable() + .checkIn(["api", "est", "acme"], NEW_ENROLLMENT_TYPE_CHECK_CONSTRAINT) + .alter(); }); } From a37f8445ad40299e0248e063bc0e8168fafd7b3b Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 28 Oct 2025 11:13:55 -0700 Subject: [PATCH 028/231] Extract common stuff --- .../ee/services/pki-acme/pki-acme-service.ts | 65 ++++++++++++------- 1 file changed, 42 insertions(+), 23 deletions(-) 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 9e9fce099..d95f1f11a 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -3,6 +3,10 @@ import { NotFoundError } from "@app/lib/errors"; import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; +import { + EnrollmentType, + TCertificateProfileWithConfigs +} from "@app/services/certificate-profile/certificate-profile-types"; import { TCreateAcmeAccountPayload, TCreateAcmeAccountResponse, @@ -10,7 +14,6 @@ import { TCreateAcmeOrderResponse, TDeactivateAcmeAccountPayload, TDeactivateAcmeAccountResponse, - TDownloadAcmeCertificateDTO, TFinalizeAcmeOrderPayload, TFinalizeAcmeOrderResponse, TGetAcmeAuthorizationResponse, @@ -28,26 +31,39 @@ type TPkiAcmeServiceFactoryDep = { export const pkiAcmeServiceFactory = ({ certificateProfileDAL }: TPkiAcmeServiceFactoryDep): TPkiAcmeServiceFactory => { const appCfg = getConfig(); + const validateAcmeProfile = async (profileId: string): Promise => { + 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 baseUrl = appCfg.SITE_URL ?? ""; + return `${baseUrl}${path}`; + }; + const getAcmeDirectory = async (profileId: string): Promise => { // FIXME: Implement ACME directory endpoint // Validate profile exists and is for ACME enrollment - // const profile = await certificateProfileDAL.findById(profileId); - // if (!profile) { - // throw new NotFoundError({ message: "Certificate profile not found" }); - // } + const profile = await validateAcmeProfile(profileId); // FIXME: Validate profile is configured for ACME enrollment // Return absolute URLs using SITE_URL - const baseUrl = appCfg.SITE_URL ?? ""; return { - newNonce: `${baseUrl}/api/v1/pki/acme/profiles/${profileId}/new-nonce`, - newAccount: `${baseUrl}/api/v1/pki/acme/profiles/${profileId}/new-account`, - newOrder: `${baseUrl}/api/v1/pki/acme/profiles/${profileId}/new-order` + 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 => { + const profile = await validateAcmeProfile(profileId); // FIXME: Implement ACME new nonce generation // Generate a new nonce, store it, and return it return "FIXME-generate-nonce"; @@ -57,16 +73,16 @@ export const pkiAcmeServiceFactory = ({ certificateProfileDAL }: TPkiAcmeService profileId: string, body: TCreateAcmeAccountPayload ): Promise => { + const profile = await validateAcmeProfile(profileId); // FIXME: Implement ACME new account registration // Use EAB authentication to find corresponding Infisical machine identity // Check permissions and return account information - const baseUrl = appCfg.SITE_URL || ""; const accountId = "FIXME-account-id"; return { status: "valid", - accountUrl: `${baseUrl}/api/v1/pki/acme/profiles/${profileId}/accounts/${accountId}`, + accountUrl: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/accounts/${accountId}`), contact: [], - orders: `${baseUrl}/api/v1/pki/acme/profiles/${profileId}/accounts/${accountId}/orders` + orders: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/accounts/${accountId}/orders`) }; }; @@ -74,15 +90,15 @@ export const pkiAcmeServiceFactory = ({ certificateProfileDAL }: TPkiAcmeService profileId: string, body: TCreateAcmeOrderPayload ): Promise => { + const profile = await validateAcmeProfile(profileId); // FIXME: Implement ACME new order creation const orderId = "FIXME-order-id"; - const baseUrl = appCfg.SITE_URL || ""; return { status: "pending", expires: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), identifiers: [], authorizations: [], - finalize: `${baseUrl}/api/v1/pki/acme/profiles/${profileId}/orders/${orderId}/finalize` + finalize: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/orders/${orderId}/finalize`) }; }; @@ -91,6 +107,7 @@ export const pkiAcmeServiceFactory = ({ certificateProfileDAL }: TPkiAcmeService accountId: string, body?: TDeactivateAcmeAccountPayload ): Promise => { + const profile = await validateAcmeProfile(profileId); // FIXME: Implement ACME account deactivation return { status: "deactivated" @@ -98,6 +115,7 @@ export const pkiAcmeServiceFactory = ({ certificateProfileDAL }: TPkiAcmeService }; const listAcmeOrders = async (profileId: string, accountId: string): Promise => { + const profile = await validateAcmeProfile(profileId); // FIXME: Implement ACME list orders return { orders: [] @@ -105,14 +123,14 @@ export const pkiAcmeServiceFactory = ({ certificateProfileDAL }: TPkiAcmeService }; const getAcmeOrder = async (profileId: string, orderId: string): Promise => { + const profile = await validateAcmeProfile(profileId); // FIXME: Implement ACME get order - const baseUrl = appCfg.SITE_URL || ""; return { status: "pending", expires: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), identifiers: [], authorizations: [], - finalize: `${baseUrl}/api/v1/pki/acme/profiles/${profileId}/orders/${orderId}/finalize` + finalize: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/orders/${orderId}/finalize`) }; }; @@ -121,28 +139,29 @@ export const pkiAcmeServiceFactory = ({ certificateProfileDAL }: TPkiAcmeService orderId: string, body: TFinalizeAcmeOrderPayload ): Promise => { + const profile = await validateAcmeProfile(profileId); const { csr } = body; // FIXME: Implement ACME finalize order - const baseUrl = appCfg.SITE_URL || ""; return { status: "processing", expires: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), identifiers: [], authorizations: [], - finalize: `${baseUrl}/api/v1/pki/acme/profiles/${profileId}/orders/${orderId}/finalize`, - certificate: `${baseUrl}/api/v1/pki/acme/profiles/${profileId}/orders/${orderId}/certificate` + 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 => { + 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 => { + const profile = await validateAcmeProfile(profileId); // FIXME: Implement ACME authorization retrieval - const baseUrl = appCfg.SITE_URL || ""; return { status: "pending", expires: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), @@ -153,7 +172,7 @@ export const pkiAcmeServiceFactory = ({ certificateProfileDAL }: TPkiAcmeService challenges: [ { type: "http-01", - url: `${baseUrl}/api/v1/pki/acme/profiles/${profileId}/authorizations/${authzId}/challenges/http-01`, + url: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/authorizations/${authzId}/challenges/http-01`), status: "pending", token: "FIXME-challenge-token" } @@ -165,12 +184,12 @@ export const pkiAcmeServiceFactory = ({ certificateProfileDAL }: TPkiAcmeService profileId: string, authzId: string ): Promise => { + const profile = await validateAcmeProfile(profileId); // FIXME: Implement ACME challenge response // Trigger verification process - const baseUrl = appCfg.SITE_URL || ""; return { type: "http-01", - url: `${baseUrl}/api/v1/pki/acme/profiles/${profileId}/authorizations/${authzId}/challenges/http-01`, + url: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/authorizations/${authzId}/challenges/http-01`), status: "pending", token: "FIXME-challenge-token" }; From efabf9ee05b78f61ab0de2522e591930fe4901ed Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 28 Oct 2025 13:55:41 -0700 Subject: [PATCH 029/231] Add more scheme --- .../ee/services/pki-acme/pki-acme-errors.ts | 436 ++++++++++++++++++ .../ee/services/pki-acme/pki-acme-schemas.ts | 20 + .../ee/services/pki-acme/pki-acme-service.ts | 54 ++- .../ee/services/pki-acme/pki-acme-types.ts | 7 + 4 files changed, 501 insertions(+), 16 deletions(-) create mode 100644 backend/src/ee/services/pki-acme/pki-acme-errors.ts diff --git a/backend/src/ee/services/pki-acme/pki-acme-errors.ts b/backend/src/ee/services/pki-acme/pki-acme-errors.ts new file mode 100644 index 000000000..85de5cb1c --- /dev/null +++ b/backend/src/ee/services/pki-acme/pki-acme-errors.ts @@ -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 + }; + } +} diff --git a/backend/src/ee/services/pki-acme/pki-acme-schemas.ts b/backend/src/ee/services/pki-acme/pki-acme-schemas.ts index cdce6e9b8..8530ad16f 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-schemas.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-schemas.ts @@ -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({ 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 d95f1f11a..2ce87b37a 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -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; }; export const pkiAcmeServiceFactory = ({ certificateProfileDAL }: TPkiAcmeServiceFactoryDep): TPkiAcmeServiceFactory => { - const appCfg = getConfig(); - const validateAcmeProfile = async (profileId: string): Promise => { 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 => { + 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 => { - // 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 => { 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 => { 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 => { 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 => { 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, 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 f004e5102..ffe5df0d3 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-types.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-types.ts @@ -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; // Payload types +export type TRawJwsPayload = z.infer; +export type TJwsPayload = z.infer; +export type TProtectedHeader = z.infer; export type TCreateAcmeAccountPayload = z.infer; export type TCreateAcmeOrderPayload = z.infer; export type TDeactivateAcmeAccountPayload = z.infer; export type TFinalizeAcmeOrderPayload = z.infer; export type TPkiAcmeServiceFactory = { + validateCreateAcmeAccountJwsPayload(body: TRawJwsPayload): Promise; getAcmeDirectory: (profileId: string) => Promise; getAcmeNewNonce: (profileId: string) => Promise; createAcmeAccount: (profileId: string, body: TCreateAcmeAccountPayload) => Promise; From f06f6108f4fa09a7edc38eccd5d38aef724a3ff4 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 28 Oct 2025 14:04:06 -0700 Subject: [PATCH 030/231] More for validate jws payload --- .../ee/services/pki-acme/pki-acme-schemas.ts | 19 ++++++++----- .../ee/services/pki-acme/pki-acme-service.ts | 28 +++++++++++-------- 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/backend/src/ee/services/pki-acme/pki-acme-schemas.ts b/backend/src/ee/services/pki-acme/pki-acme-schemas.ts index 8530ad16f..16862ca4a 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-schemas.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-schemas.ts @@ -1,12 +1,17 @@ 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() -}); +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() + }) + .refine((data) => data.kid || data.jwk, { + message: "Either kid or jwk must be provided", + path: ["kid", "jwk"] + }); // Raw JWS payload schema before parsing and verification export const RawJwsPayloadSchema = z.object({ 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 2ce87b37a..78ed98369 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -1,14 +1,16 @@ import { getConfig } from "@app/lib/config/env"; -import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { NotFoundError } from "@app/lib/errors"; import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; -import { AcmeMalformedError, AcmeBadPublicKeyError } from "./pki-acme-errors"; +import { 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 { TCreateAcmeAccountPayload, TCreateAcmeAccountResponse, @@ -21,14 +23,12 @@ import { TGetAcmeAuthorizationResponse, TGetAcmeDirectoryResponse, TGetAcmeOrderResponse, - TRawJwsPayload, + TJwsPayload, TListAcmeOrdersResponse, TPkiAcmeServiceFactory, - TRespondToAcmeChallengeResponse, - TJwsPayload, - TProtectedHeader + TRawJwsPayload, + TRespondToAcmeChallengeResponse } from "./pki-acme-types"; -import { flattenedVerify, importJWK, JWK, JWSHeaderParameters } from "jose"; type TPkiAcmeServiceFactoryDep = { certificateProfileDAL: Pick; @@ -52,9 +52,9 @@ export const pkiAcmeServiceFactory = ({ certificateProfileDAL }: TPkiAcmeService return `${baseUrl}${path}`; }; - const validateCreateAcmeAccountJwsPayload = async (rawPayload: TRawJwsPayload): Promise => { - const { payload, protectedHeader } = await flattenedVerify( - rawPayload, + const validateCreateAcmeAccountJwsPayload = 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" }); @@ -68,10 +68,14 @@ export const pkiAcmeServiceFactory = ({ certificateProfileDAL }: TPkiAcmeService return imported; } ); + const { success, data: protectedHeader } = ProtectedHeaderSchema.safeParse(rawProtectedHeader); + if (!success) { + throw new AcmeMalformedError({ detail: "Invalid protected header" }); + } const decoder = new TextDecoder(); - const parsedPayload = JSON.parse(decoder.decode(payload)) as TCreateAcmeAccountPayload; + const payload = JSON.parse(decoder.decode(rawPayload)) as TCreateAcmeAccountPayload; // TODO: also consume the nonce here - return { payload: parsedPayload, protectedHeader: protectedHeader as TProtectedHeader }; + return { payload, protectedHeader }; }; const getAcmeDirectory = async (profileId: string): Promise => { From a29029c192eacbbad5ad5b5118f7fc8b097bbf2a Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 28 Oct 2025 15:30:51 -0700 Subject: [PATCH 031/231] Keep public key as a jwk json obj instead --- .../db/migrations/20251027234547_add-pki-acme.ts | 4 ++-- backend/src/db/schemas/pki-acme-accounts.ts | 2 +- backend/src/ee/routes/v1/pki-acme-router.ts | 14 +++++++++++--- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/backend/src/db/migrations/20251027234547_add-pki-acme.ts b/backend/src/db/migrations/20251027234547_add-pki-acme.ts index a2e1ab777..c0cf0db4f 100644 --- a/backend/src/db/migrations/20251027234547_add-pki-acme.ts +++ b/backend/src/db/migrations/20251027234547_add-pki-acme.ts @@ -54,8 +54,8 @@ export async function up(knex: Knex): Promise { // Multi-value emails array t.specificType("emails", "text[]").notNullable(); - // Public key (PEM format) - t.text("publicKey").notNullable(); + // Public key (JWK format) + t.jsonb("publicKey").notNullable(); t.timestamps(true, true, true); }); diff --git a/backend/src/db/schemas/pki-acme-accounts.ts b/backend/src/db/schemas/pki-acme-accounts.ts index 739e7057b..b599cec07 100644 --- a/backend/src/db/schemas/pki-acme-accounts.ts +++ b/backend/src/db/schemas/pki-acme-accounts.ts @@ -11,7 +11,7 @@ export const PkiAcmeAccountsSchema = z.object({ id: z.string().uuid(), profileId: z.string().uuid(), emails: z.string().array(), - publicKey: z.string(), + publicKey: z.unknown(), createdAt: z.date(), updatedAt: z.date() }); diff --git a/backend/src/ee/routes/v1/pki-acme-router.ts b/backend/src/ee/routes/v1/pki-acme-router.ts index 5f19359a6..c8ca31186 100644 --- a/backend/src/ee/routes/v1/pki-acme-router.ts +++ b/backend/src/ee/routes/v1/pki-acme-router.ts @@ -20,12 +20,14 @@ import { GetAcmeOrderSchema, ListAcmeOrdersResponseSchema, ListAcmeOrdersSchema, + RawJwsPayloadSchema, RespondToAcmeChallengeResponseSchema, RespondToAcmeChallengeSchema } from "@app/ee/services/pki-acme/pki-acme-schemas"; import { ApiDocsTags } from "@app/lib/api-docs"; import { getConfig } from "@app/lib/config/env"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { TRawJwsPayload } from "@app/ee/services/pki-acme/pki-acme-types"; export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { const appCfg = getConfig(); @@ -104,16 +106,22 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { hide: false, tags: [ApiDocsTags.PkiAcme], description: "ACME New Account - register a new account or find existing one", - ...CreateAcmeAccountSchema.shape, + ...RawJwsPayloadSchema.shape, response: { 201: CreateAcmeAccountResponseSchema } }, handler: async (req, res) => { // TODO: check nonce here - // TODO: check signature here + const { payload, protectedHeader, jwk } = await server.services.pkiAcme.validateCreateAcmeAccountJwsPayload( + req.body as TRawJwsPayload + ); - const account = await server.services.pkiAcme.createAcmeAccount(req.params.profileId, req.body); + const account = await server.services.pkiAcme.createAcmeAccount( + req.params.profileId, + jwk, + payload as TCreateAcmeAccountPayload + ); // TODO: deal with existing account case here res.code(201); res.header( From 4883c3ead33c76a0a7eafcf9300ec76a2be5b8b6 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 28 Oct 2025 16:04:26 -0700 Subject: [PATCH 032/231] Implement account creation --- .../services/pki-acme/pki-acme-account-dal.ts | 105 ++++++++++++++++++ .../ee/services/pki-acme/pki-acme-schemas.ts | 5 +- .../ee/services/pki-acme/pki-acme-service.ts | 64 ++++++++--- .../ee/services/pki-acme/pki-acme-types.ts | 18 ++- 4 files changed, 174 insertions(+), 18 deletions(-) create mode 100644 backend/src/ee/services/pki-acme/pki-acme-account-dal.ts 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 new file mode 100644 index 000000000..93f7b7055 --- /dev/null +++ b/backend/src/ee/services/pki-acme/pki-acme-account-dal.ts @@ -0,0 +1,105 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { TPkiAcmeAccountsInsert, TPkiAcmeAccountsUpdate } from "@app/db/schemas/pki-acme-accounts"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify } from "@app/lib/knex"; + +export type TPkiAcmeAccountDALFactory = ReturnType; + +export const pkiAcmeAccountDALFactory = (db: TDbClient) => { + const pkiAcmeAccountOrm = ormify(db, TableName.PkiAcmeAccount); + + const create = async (data: TPkiAcmeAccountsInsert, tx?: Knex) => { + try { + const result = await (tx || db)(TableName.PkiAcmeAccount).insert(data).returning("*"); + const [account] = result; + + if (!account) { + throw new Error("Failed to create PKI ACME account"); + } + + return account; + } catch (error) { + throw new DatabaseError({ error, name: "Create PKI ACME account" }); + } + }; + + const updateById = async (id: string, data: TPkiAcmeAccountsUpdate, tx?: Knex) => { + try { + const result = await (tx || db)(TableName.PkiAcmeAccount).where({ id }).update(data).returning("*"); + const [account] = result; + + if (!account) { + return null; + } + + return account; + } catch (error) { + throw new DatabaseError({ error, name: "Update PKI ACME account" }); + } + }; + + const findById = async (id: string, tx?: Knex) => { + try { + const account = await (tx || db)(TableName.PkiAcmeAccount).where({ id }).first(); + + return account || null; + } catch (error) { + throw new DatabaseError({ error, name: "Find PKI ACME account by id" }); + } + }; + + const findByProfileId = async (profileId: string, tx?: Knex) => { + try { + const account = await (tx || db)(TableName.PkiAcmeAccount).where({ profileId }).first(); + + return account || null; + } catch (error) { + throw new DatabaseError({ error, name: "Find PKI ACME account by profile id" }); + } + }; + + const findByPublicKey = async (publicKey: unknown, tx?: Knex) => { + try { + const account = await (tx || db)(TableName.PkiAcmeAccount).where({ publicKey }).first(); + + return account || null; + } catch (error) { + throw new DatabaseError({ error, name: "Find PKI ACME account by public key" }); + } + }; + + const findManyByProfileId = async (profileId: string, tx?: Knex) => { + try { + const accounts = await (tx || db)(TableName.PkiAcmeAccount).where({ profileId }); + + return accounts; + } catch (error) { + throw new DatabaseError({ error, name: "Find many PKI ACME accounts by profile id" }); + } + }; + + const deleteById = async (id: string, tx?: Knex) => { + try { + const result = await (tx || db)(TableName.PkiAcmeAccount).where({ id }).delete().returning("*"); + const [account] = result; + + return account || null; + } catch (error) { + throw new DatabaseError({ error, name: "Delete PKI ACME account by id" }); + } + }; + + return { + ...pkiAcmeAccountOrm, + create, + updateById, + findById, + findByProfileId, + findByPublicKey, + findManyByProfileId, + deleteById + }; +}; diff --git a/backend/src/ee/services/pki-acme/pki-acme-schemas.ts b/backend/src/ee/services/pki-acme/pki-acme-schemas.ts index 16862ca4a..599203dae 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-schemas.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-schemas.ts @@ -22,7 +22,7 @@ export const RawJwsPayloadSchema = z.object({ export const JwsPayloadSchema = z.object({ protectedHeader: ProtectedHeaderSchema, - payload: z.any() + payload: z.unknown() }); // Directory endpoint @@ -71,8 +71,7 @@ export const CreateAcmeAccountSchema = z.object({ export const CreateAcmeAccountResponseSchema = z.object({ status: z.string(), contact: z.array(z.string()).optional(), - orders: z.string().optional(), - accountUrl: z.string() + orders: z.string().optional() }); // New Order payload schema 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 78ed98369..7f1c07e7f 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -3,7 +3,7 @@ import { NotFoundError } from "@app/lib/errors"; import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; -import { AcmeBadPublicKeyError, AcmeMalformedError } from "./pki-acme-errors"; +import { AcmeAccountDoesNotExistError, AcmeBadPublicKeyError, AcmeMalformedError } from "./pki-acme-errors"; import { EnrollmentType, @@ -12,6 +12,7 @@ import { import { flattenedVerify, importJWK, JWK, JWSHeaderParameters } from "jose"; import { ProtectedHeaderSchema } from "./pki-acme-schemas"; import { + TAcmeResponse, TCreateAcmeAccountPayload, TCreateAcmeAccountResponse, TCreateAcmeOrderPayload, @@ -24,17 +25,23 @@ import { TGetAcmeDirectoryResponse, TGetAcmeOrderResponse, TJwsPayload, + TJwsPayloadWithJwk, TListAcmeOrdersResponse, TPkiAcmeServiceFactory, TRawJwsPayload, TRespondToAcmeChallengeResponse } from "./pki-acme-types"; +import { TPkiAcmeAccountDALFactory } from "./pki-acme-account-dal"; type TPkiAcmeServiceFactoryDep = { certificateProfileDAL: Pick; + pkiAcmeAccountDAL: Pick; }; -export const pkiAcmeServiceFactory = ({ certificateProfileDAL }: TPkiAcmeServiceFactoryDep): TPkiAcmeServiceFactory => { +export const pkiAcmeServiceFactory = ({ + certificateProfileDAL, + pkiAcmeAccountDAL +}: TPkiAcmeServiceFactoryDep): TPkiAcmeServiceFactory => { const validateAcmeProfile = async (profileId: string): Promise => { const profile = await certificateProfileDAL.findById(profileId); if (!profile) { @@ -52,7 +59,7 @@ export const pkiAcmeServiceFactory = ({ certificateProfileDAL }: TPkiAcmeService return `${baseUrl}${path}`; }; - const validateCreateAcmeAccountJwsPayload = async (rawJwsPayload: TRawJwsPayload): Promise => { + const validateCreateAcmeAccountJwsPayload = async (rawJwsPayload: TRawJwsPayload): Promise => { const { payload: rawPayload, protectedHeader: rawProtectedHeader } = await flattenedVerify( rawJwsPayload, async (protectedHeader: JWSHeaderParameters | undefined) => { @@ -72,10 +79,11 @@ export const pkiAcmeServiceFactory = ({ certificateProfileDAL }: TPkiAcmeService 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 }; + return { payload, protectedHeader, jwk: protectedHeader.jwk as JsonWebKey }; }; const getAcmeDirectory = async (profileId: string): Promise => { @@ -96,18 +104,47 @@ export const pkiAcmeServiceFactory = ({ certificateProfileDAL }: TPkiAcmeService const createAcmeAccount = async ( profileId: string, + jwk: JWK, payload: TCreateAcmeAccountPayload - ): Promise => { + ): Promise> => { const profile = await validateAcmeProfile(profileId); - // FIXME: Implement ACME new account registration - // Use EAB authentication to find corresponding Infisical machine identity - // Check permissions and return account information - const accountId = "FIXME-account-id"; + // TODO: the jwk as json obj may not be the best idea for indexing. + // Maybe we should find a way to serialize the jwk deterministically. + let account = await pkiAcmeAccountDAL.findByPublicKey(jwk); + if (payload.onlyReturnExisting && !account) { + throw new AcmeAccountDoesNotExistError({ message: "ACME account not found" }); + } + if (account) { + // With the same public key, we found an existing account, just return it + return { + status: 200, + payload: { + status: "valid", + contact: account.emails, + orders: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/accounts/${account.id}/orders`) + }, + headers: { + Location: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/accounts/${account.id}`) + } + }; + } + + account = await pkiAcmeAccountDAL.create({ + profileId, + publicKey: jwk, + emails: payload.contact ?? [] + }); + // TODO: check EAB authentication here return { - status: "valid", - accountUrl: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/accounts/${accountId}`), - contact: [], - orders: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/accounts/${accountId}/orders`) + status: 201, + payload: { + status: "valid", + contact: account.emails, + orders: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/accounts/${account.id}/orders`) + }, + headers: { + Location: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/accounts/${account.id}`) + } }; }; @@ -116,6 +153,7 @@ export const pkiAcmeServiceFactory = ({ certificateProfileDAL }: TPkiAcmeService payload: TCreateAcmeOrderPayload ): Promise => { const profile = await validateAcmeProfile(profileId); + // FIXME: Implement ACME new order creation const orderId = "FIXME-order-id"; return { 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 ffe5df0d3..9009bc0a7 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-types.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-types.ts @@ -1,5 +1,6 @@ import { z } from "zod"; +import { JWK } from "jose"; import { CreateAcmeAccountBodySchema, CreateAcmeAccountResponseSchema, @@ -39,11 +40,24 @@ export type TCreateAcmeOrderPayload = z.infer; export type TDeactivateAcmeAccountPayload = z.infer; export type TFinalizeAcmeOrderPayload = z.infer; +export type TJwsPayloadWithJwk = TJwsPayload & { + jwk: JsonWebKey; +}; +export type TAcmeResponse = { + status: number; + headers: Record; + payload: TPayload; +}; + export type TPkiAcmeServiceFactory = { - validateCreateAcmeAccountJwsPayload(body: TRawJwsPayload): Promise; + validateCreateAcmeAccountJwsPayload(body: TRawJwsPayload): Promise; getAcmeDirectory: (profileId: string) => Promise; getAcmeNewNonce: (profileId: string) => Promise; - createAcmeAccount: (profileId: string, body: TCreateAcmeAccountPayload) => Promise; + createAcmeAccount: ( + profileId: string, + jwk: JsonWebKey, + body: TCreateAcmeAccountPayload + ) => Promise>; createAcmeOrder: (profileId: string, body: TCreateAcmeOrderPayload) => Promise; deactivateAcmeAccount: ( profileId: string, From a84303588d2abec35bc802c1203465335a9e0f86 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 28 Oct 2025 16:26:02 -0700 Subject: [PATCH 033/231] More on the account --- backend/src/ee/routes/v1/pki-acme-router.ts | 22 ++++++++----------- .../ee/services/pki-acme/pki-acme-types.ts | 3 +-- 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/backend/src/ee/routes/v1/pki-acme-router.ts b/backend/src/ee/routes/v1/pki-acme-router.ts index c8ca31186..e0762b7f3 100644 --- a/backend/src/ee/routes/v1/pki-acme-router.ts +++ b/backend/src/ee/routes/v1/pki-acme-router.ts @@ -3,7 +3,6 @@ import { z } from "zod"; import { CreateAcmeAccountResponseSchema, - CreateAcmeAccountSchema, CreateAcmeOrderResponseSchema, CreateAcmeOrderSchema, DeactivateAcmeAccountResponseSchema, @@ -24,10 +23,10 @@ import { RespondToAcmeChallengeResponseSchema, RespondToAcmeChallengeSchema } from "@app/ee/services/pki-acme/pki-acme-schemas"; +import { TCreateAcmeAccountPayload, TRawJwsPayload } from "@app/ee/services/pki-acme/pki-acme-types"; import { ApiDocsTags } from "@app/lib/api-docs"; import { getConfig } from "@app/lib/config/env"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; -import { TRawJwsPayload } from "@app/ee/services/pki-acme/pki-acme-types"; export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { const appCfg = getConfig(); @@ -112,28 +111,25 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { } }, handler: async (req, res) => { - // TODO: check nonce here - const { payload, protectedHeader, jwk } = await server.services.pkiAcme.validateCreateAcmeAccountJwsPayload( + const { payload, jwk } = await server.services.pkiAcme.validateCreateAcmeAccountJwsPayload( req.body as TRawJwsPayload ); - - const account = await server.services.pkiAcme.createAcmeAccount( + const { status, body, headers } = await server.services.pkiAcme.createAcmeAccount( req.params.profileId, jwk, payload as TCreateAcmeAccountPayload ); - // TODO: deal with existing account case here - res.code(201); - res.header( - "Location", - `${appCfg.SITE_URL}/api/v1/pki/acme/profiles/${req.params.profileId}/accounts/${account.accountUrl}` - ); + // TODO: DRY + res.code(status); + for (const [key, value] of Object.entries(headers)) { + res.header(key, value); + } // TODO: DRY const nonce = await server.services.pkiAcme.getAcmeNewNonce(req.params.profileId); res.header("Replay-Nonce", nonce); res.header("Cache-Control", "no-store"); - return account; + return body; } }); 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 9009bc0a7..fe1105ff8 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-types.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-types.ts @@ -1,6 +1,5 @@ import { z } from "zod"; -import { JWK } from "jose"; import { CreateAcmeAccountBodySchema, CreateAcmeAccountResponseSchema, @@ -46,7 +45,7 @@ export type TJwsPayloadWithJwk = TJwsPayload & { export type TAcmeResponse = { status: number; headers: Record; - payload: TPayload; + body: TPayload; }; export type TPkiAcmeServiceFactory = { From 23d233659496e68a3012e0d0c3e7c60699daa809 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 28 Oct 2025 16:39:13 -0700 Subject: [PATCH 034/231] Bind ACME stuff --- backend/bdd/features/steps/pki_acme.py | 2 +- .../ee/services/pki-acme/pki-acme-service.ts | 13 +- backend/src/server/routes/index.ts | 120 +++++++++--------- 3 files changed, 70 insertions(+), 65 deletions(-) diff --git a/backend/bdd/features/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py index 0f04849e6..eb8a04315 100644 --- a/backend/bdd/features/steps/pki_acme.py +++ b/backend/bdd/features/steps/pki_acme.py @@ -36,7 +36,7 @@ def step_impl(context: Context, profile_var: str): # TODO: Fixed value for now, just to make test much easier, # we should call infisical API to create such profile instead # in the future - profile_id = "40dd4794-66e3-4bd3-9875-c1f819804bb2" + profile_id = "0e96a01b-017e-4660-8b3d-ff26018fe0ce" context.vars[profile_var] = AcmeProfile(profile_id) 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 7f1c07e7f..0040f143b 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -31,6 +31,7 @@ import { TRawJwsPayload, TRespondToAcmeChallengeResponse } from "./pki-acme-types"; +import { TPkiAcmeAccount } from "@app/db/schemas/pki-acme-accounts"; import { TPkiAcmeAccountDALFactory } from "./pki-acme-account-dal"; type TPkiAcmeServiceFactoryDep = { @@ -105,20 +106,20 @@ export const pkiAcmeServiceFactory = ({ const createAcmeAccount = async ( profileId: string, jwk: JWK, - payload: TCreateAcmeAccountPayload + { onlyReturnExisting, contact }: TCreateAcmeAccountPayload ): Promise> => { const profile = await validateAcmeProfile(profileId); // TODO: the jwk as json obj may not be the best idea for indexing. // Maybe we should find a way to serialize the jwk deterministically. - let account = await pkiAcmeAccountDAL.findByPublicKey(jwk); - if (payload.onlyReturnExisting && !account) { + let account: TPkiAcmeAccount | null = await pkiAcmeAccountDAL.findByPublicKey(jwk); + if (onlyReturnExisting && !account) { throw new AcmeAccountDoesNotExistError({ message: "ACME account not found" }); } if (account) { // With the same public key, we found an existing account, just return it return { status: 200, - payload: { + body: { status: "valid", contact: account.emails, orders: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/accounts/${account.id}/orders`) @@ -132,12 +133,12 @@ export const pkiAcmeServiceFactory = ({ account = await pkiAcmeAccountDAL.create({ profileId, publicKey: jwk, - emails: payload.contact ?? [] + emails: contact ?? [] }); // TODO: check EAB authentication here return { status: 201, - payload: { + body: { status: "valid", contact: account.emails, orders: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/accounts/${account.id}/orders`) diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index e9ab34b17..b1e4b9b7c 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -17,31 +17,30 @@ import { accessApprovalRequestDALFactory } from "@app/ee/services/access-approva import { accessApprovalRequestReviewerDALFactory } from "@app/ee/services/access-approval-request/access-approval-request-reviewer-dal"; import { accessApprovalRequestServiceFactory } from "@app/ee/services/access-approval-request/access-approval-request-service"; import { assumePrivilegeServiceFactory } from "@app/ee/services/assume-privilege/assume-privilege-service"; +import { auditLogStreamDALFactory } from "@app/ee/services/audit-log-stream/audit-log-stream-dal"; +import { auditLogStreamServiceFactory } from "@app/ee/services/audit-log-stream/audit-log-stream-service"; import { auditLogDALFactory } from "@app/ee/services/audit-log/audit-log-dal"; import { auditLogQueueServiceFactory } from "@app/ee/services/audit-log/audit-log-queue"; import { auditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service"; -import { auditLogStreamDALFactory } from "@app/ee/services/audit-log-stream/audit-log-stream-dal"; -import { auditLogStreamServiceFactory } from "@app/ee/services/audit-log-stream/audit-log-stream-service"; import { certificateAuthorityCrlDALFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-dal"; import { certificateAuthorityCrlServiceFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-service"; import { certificateEstServiceFactory } from "@app/ee/services/certificate-est/certificate-est-service"; -import { pkiAcmeServiceFactory } from "@app/ee/services/pki-acme/pki-acme-service"; -import { dynamicSecretDALFactory } from "@app/ee/services/dynamic-secret/dynamic-secret-dal"; -import { dynamicSecretServiceFactory } from "@app/ee/services/dynamic-secret/dynamic-secret-service"; -import { buildDynamicSecretProviders } from "@app/ee/services/dynamic-secret/providers"; import { dynamicSecretLeaseDALFactory } from "@app/ee/services/dynamic-secret-lease/dynamic-secret-lease-dal"; import { dynamicSecretLeaseQueueServiceFactory } from "@app/ee/services/dynamic-secret-lease/dynamic-secret-lease-queue"; import { dynamicSecretLeaseServiceFactory } from "@app/ee/services/dynamic-secret-lease/dynamic-secret-lease-service"; +import { dynamicSecretDALFactory } from "@app/ee/services/dynamic-secret/dynamic-secret-dal"; +import { dynamicSecretServiceFactory } from "@app/ee/services/dynamic-secret/dynamic-secret-service"; +import { buildDynamicSecretProviders } from "@app/ee/services/dynamic-secret/providers"; import { eventBusFactory } from "@app/ee/services/event/event-bus-service"; import { sseServiceFactory } from "@app/ee/services/event/event-sse-service"; import { externalKmsDALFactory } from "@app/ee/services/external-kms/external-kms-dal"; import { externalKmsServiceFactory } from "@app/ee/services/external-kms/external-kms-service"; -import { gatewayDALFactory } from "@app/ee/services/gateway/gateway-dal"; -import { gatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; -import { orgGatewayConfigDALFactory } from "@app/ee/services/gateway/org-gateway-config-dal"; import { gatewayV2DalFactory } from "@app/ee/services/gateway-v2/gateway-v2-dal"; import { gatewayV2ServiceFactory } from "@app/ee/services/gateway-v2/gateway-v2-service"; import { orgGatewayConfigV2DalFactory } from "@app/ee/services/gateway-v2/org-gateway-config-v2-dal"; +import { gatewayDALFactory } from "@app/ee/services/gateway/gateway-dal"; +import { gatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; +import { orgGatewayConfigDALFactory } from "@app/ee/services/gateway/org-gateway-config-dal"; import { githubOrgSyncDALFactory } from "@app/ee/services/github-org-sync/github-org-sync-dal"; import { githubOrgSyncServiceFactory } from "@app/ee/services/github-org-sync/github-org-sync-service"; import { groupDALFactory } from "@app/ee/services/group/group-dal"; @@ -75,6 +74,7 @@ import { pamSessionServiceFactory } from "@app/ee/services/pam-session/pam-sessi import { permissionDALFactory } from "@app/ee/services/permission/permission-dal"; import { permissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { pitServiceFactory } from "@app/ee/services/pit/pit-service"; +import { pkiAcmeServiceFactory } from "@app/ee/services/pki-acme/pki-acme-service"; import { projectTemplateDALFactory } from "@app/ee/services/project-template/project-template-dal"; import { projectTemplateServiceFactory } from "@app/ee/services/project-template/project-template-service"; import { rateLimitDALFactory } from "@app/ee/services/rate-limit/rate-limit-dal"; @@ -99,39 +99,39 @@ import { secretApprovalRequestReviewerDALFactory } from "@app/ee/services/secret import { secretApprovalRequestSecretDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-secret-dal"; import { secretApprovalRequestServiceFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-service"; import { secretReplicationServiceFactory } from "@app/ee/services/secret-replication/secret-replication-service"; -import { secretRotationDALFactory } from "@app/ee/services/secret-rotation/secret-rotation-dal"; -import { secretRotationQueueFactory } from "@app/ee/services/secret-rotation/secret-rotation-queue"; -import { secretRotationServiceFactory } from "@app/ee/services/secret-rotation/secret-rotation-service"; import { secretRotationV2DALFactory } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-dal"; import { secretRotationV2QueueServiceFactory } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-queue"; import { secretRotationV2ServiceFactory } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-service"; +import { secretRotationDALFactory } from "@app/ee/services/secret-rotation/secret-rotation-dal"; +import { secretRotationQueueFactory } from "@app/ee/services/secret-rotation/secret-rotation-queue"; +import { secretRotationServiceFactory } from "@app/ee/services/secret-rotation/secret-rotation-service"; +import { secretScanningV2DALFactory } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-dal"; +import { secretScanningV2QueueServiceFactory } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-queue"; +import { secretScanningV2ServiceFactory } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-service"; import { gitAppDALFactory } from "@app/ee/services/secret-scanning/git-app-dal"; import { gitAppInstallSessionDALFactory } from "@app/ee/services/secret-scanning/git-app-install-session-dal"; import { secretScanningDALFactory } from "@app/ee/services/secret-scanning/secret-scanning-dal"; import { secretScanningQueueFactory } from "@app/ee/services/secret-scanning/secret-scanning-queue"; import { secretScanningServiceFactory } from "@app/ee/services/secret-scanning/secret-scanning-service"; -import { secretScanningV2DALFactory } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-dal"; -import { secretScanningV2QueueServiceFactory } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-queue"; -import { secretScanningV2ServiceFactory } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-service"; import { secretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/secret-snapshot-service"; import { snapshotDALFactory } from "@app/ee/services/secret-snapshot/snapshot-dal"; import { snapshotFolderDALFactory } from "@app/ee/services/secret-snapshot/snapshot-folder-dal"; import { snapshotSecretDALFactory } from "@app/ee/services/secret-snapshot/snapshot-secret-dal"; import { snapshotSecretV2DALFactory } from "@app/ee/services/secret-snapshot/snapshot-secret-v2-dal"; -import { sshCertificateAuthorityDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-dal"; -import { sshCertificateAuthoritySecretDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-secret-dal"; -import { sshCertificateAuthorityServiceFactory } from "@app/ee/services/ssh/ssh-certificate-authority-service"; -import { sshCertificateBodyDALFactory } from "@app/ee/services/ssh-certificate/ssh-certificate-body-dal"; -import { sshCertificateDALFactory } from "@app/ee/services/ssh-certificate/ssh-certificate-dal"; import { sshCertificateTemplateDALFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-dal"; import { sshCertificateTemplateServiceFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-service"; +import { sshCertificateBodyDALFactory } from "@app/ee/services/ssh-certificate/ssh-certificate-body-dal"; +import { sshCertificateDALFactory } from "@app/ee/services/ssh-certificate/ssh-certificate-dal"; +import { sshHostGroupDALFactory } from "@app/ee/services/ssh-host-group/ssh-host-group-dal"; +import { sshHostGroupMembershipDALFactory } from "@app/ee/services/ssh-host-group/ssh-host-group-membership-dal"; +import { sshHostGroupServiceFactory } from "@app/ee/services/ssh-host-group/ssh-host-group-service"; import { sshHostDALFactory } from "@app/ee/services/ssh-host/ssh-host-dal"; import { sshHostLoginUserMappingDALFactory } from "@app/ee/services/ssh-host/ssh-host-login-user-mapping-dal"; import { sshHostServiceFactory } from "@app/ee/services/ssh-host/ssh-host-service"; import { sshHostLoginUserDALFactory } from "@app/ee/services/ssh-host/ssh-login-user-dal"; -import { sshHostGroupDALFactory } from "@app/ee/services/ssh-host-group/ssh-host-group-dal"; -import { sshHostGroupMembershipDALFactory } from "@app/ee/services/ssh-host-group/ssh-host-group-membership-dal"; -import { sshHostGroupServiceFactory } from "@app/ee/services/ssh-host-group/ssh-host-group-service"; +import { sshCertificateAuthorityDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-dal"; +import { sshCertificateAuthoritySecretDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-secret-dal"; +import { sshCertificateAuthorityServiceFactory } from "@app/ee/services/ssh/ssh-certificate-authority-service"; import { subOrgServiceFactory } from "@app/ee/services/sub-org/sub-org-service"; import { trustedIpDALFactory } from "@app/ee/services/trusted-ip/trusted-ip-dal"; import { trustedIpServiceFactory } from "@app/ee/services/trusted-ip/trusted-ip-service"; @@ -151,16 +151,12 @@ import { apiKeyDALFactory } from "@app/services/api-key/api-key-dal"; import { apiKeyServiceFactory } from "@app/services/api-key/api-key-service"; import { appConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; import { appConnectionServiceFactory } from "@app/services/app-connection/app-connection-service"; +import { tokenDALFactory } from "@app/services/auth-token/auth-token-dal"; +import { tokenServiceFactory } from "@app/services/auth-token/auth-token-service"; import { authDALFactory } from "@app/services/auth/auth-dal"; import { authLoginServiceFactory } from "@app/services/auth/auth-login-service"; import { authPaswordServiceFactory } from "@app/services/auth/auth-password-service"; import { authSignupServiceFactory } from "@app/services/auth/auth-signup-service"; -import { tokenDALFactory } from "@app/services/auth-token/auth-token-dal"; -import { tokenServiceFactory } from "@app/services/auth-token/auth-token-service"; -import { certificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal"; -import { certificateDALFactory } from "@app/services/certificate/certificate-dal"; -import { certificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal"; -import { certificateServiceFactory } from "@app/services/certificate/certificate-service"; import { certificateAuthorityCertDALFactory } from "@app/services/certificate-authority/certificate-authority-cert-dal"; import { certificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal"; import { certificateAuthorityQueueFactory } from "@app/services/certificate-authority/certificate-authority-queue"; @@ -173,39 +169,39 @@ import { internalCertificateAuthorityServiceFactory } from "@app/services/certif import { certificateEstV3ServiceFactory } from "@app/services/certificate-est-v3/certificate-est-v3-service"; import { certificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; import { certificateProfileServiceFactory } from "@app/services/certificate-profile/certificate-profile-service"; +import { certificateTemplateV2DALFactory } from "@app/services/certificate-template-v2/certificate-template-v2-dal"; +import { certificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service"; import { certificateSyncDALFactory } from "@app/services/certificate-sync/certificate-sync-dal"; import { certificateTemplateDALFactory } from "@app/services/certificate-template/certificate-template-dal"; import { certificateTemplateEstConfigDALFactory } from "@app/services/certificate-template/certificate-template-est-config-dal"; import { certificateTemplateServiceFactory } from "@app/services/certificate-template/certificate-template-service"; -import { certificateTemplateV2DALFactory } from "@app/services/certificate-template-v2/certificate-template-v2-dal"; -import { certificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service"; import { certificateV3QueueServiceFactory } from "@app/services/certificate-v3/certificate-v3-queue"; import { certificateV3ServiceFactory } from "@app/services/certificate-v3/certificate-v3-service"; +import { certificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal"; +import { certificateDALFactory } from "@app/services/certificate/certificate-dal"; +import { certificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal"; +import { certificateServiceFactory } from "@app/services/certificate/certificate-service"; import { cmekServiceFactory } from "@app/services/cmek/cmek-service"; import { convertorServiceFactory } from "@app/services/convertor/convertor-service"; +import { acmeEnrollmentConfigDALFactory } from "@app/services/enrollment-config/acme-enrollment-config-dal"; import { apiEnrollmentConfigDALFactory } from "@app/services/enrollment-config/api-enrollment-config-dal"; import { estEnrollmentConfigDALFactory } from "@app/services/enrollment-config/est-enrollment-config-dal"; -import { acmeEnrollmentConfigDALFactory } from "@app/services/enrollment-config/acme-enrollment-config-dal"; import { externalGroupOrgRoleMappingDALFactory } from "@app/services/external-group-org-role-mapping/external-group-org-role-mapping-dal"; import { externalGroupOrgRoleMappingServiceFactory } from "@app/services/external-group-org-role-mapping/external-group-org-role-mapping-service"; import { externalMigrationQueueFactory } from "@app/services/external-migration/external-migration-queue"; import { externalMigrationServiceFactory } from "@app/services/external-migration/external-migration-service"; import { vaultExternalMigrationConfigDALFactory } from "@app/services/external-migration/vault-external-migration-config-dal"; -import { folderCheckpointDALFactory } from "@app/services/folder-checkpoint/folder-checkpoint-dal"; import { folderCheckpointResourcesDALFactory } from "@app/services/folder-checkpoint-resources/folder-checkpoint-resources-dal"; +import { folderCheckpointDALFactory } from "@app/services/folder-checkpoint/folder-checkpoint-dal"; +import { folderCommitChangesDALFactory } from "@app/services/folder-commit-changes/folder-commit-changes-dal"; import { folderCommitDALFactory } from "@app/services/folder-commit/folder-commit-dal"; import { folderCommitQueueServiceFactory } from "@app/services/folder-commit/folder-commit-queue"; import { folderCommitServiceFactory } from "@app/services/folder-commit/folder-commit-service"; -import { folderCommitChangesDALFactory } from "@app/services/folder-commit-changes/folder-commit-changes-dal"; -import { folderTreeCheckpointDALFactory } from "@app/services/folder-tree-checkpoint/folder-tree-checkpoint-dal"; import { folderTreeCheckpointResourcesDALFactory } from "@app/services/folder-tree-checkpoint-resources/folder-tree-checkpoint-resources-dal"; +import { folderTreeCheckpointDALFactory } from "@app/services/folder-tree-checkpoint/folder-tree-checkpoint-dal"; import { groupProjectDALFactory } from "@app/services/group-project/group-project-dal"; import { groupProjectServiceFactory } from "@app/services/group-project/group-project-service"; import { healthAlertServiceFactory } from "@app/services/health-alert/health-alert-queue"; -import { identityDALFactory } from "@app/services/identity/identity-dal"; -import { identityMetadataDALFactory } from "@app/services/identity/identity-metadata-dal"; -import { identityOrgDALFactory } from "@app/services/identity/identity-org-dal"; -import { identityServiceFactory } from "@app/services/identity/identity-service"; import { identityAccessTokenDALFactory } from "@app/services/identity-access-token/identity-access-token-dal"; import { identityAccessTokenServiceFactory } from "@app/services/identity-access-token/identity-access-token-service"; import { identityAliCloudAuthDALFactory } from "@app/services/identity-alicloud-auth/identity-alicloud-auth-dal"; @@ -235,23 +231,27 @@ import { identityTokenAuthServiceFactory } from "@app/services/identity-token-au import { identityUaClientSecretDALFactory } from "@app/services/identity-ua/identity-ua-client-secret-dal"; import { identityUaDALFactory } from "@app/services/identity-ua/identity-ua-dal"; import { identityUaServiceFactory } from "@app/services/identity-ua/identity-ua-service"; -import { integrationDALFactory } from "@app/services/integration/integration-dal"; -import { integrationServiceFactory } from "@app/services/integration/integration-service"; +import { identityDALFactory } from "@app/services/identity/identity-dal"; +import { identityMetadataDALFactory } from "@app/services/identity/identity-metadata-dal"; +import { identityOrgDALFactory } from "@app/services/identity/identity-org-dal"; +import { identityServiceFactory } from "@app/services/identity/identity-service"; import { integrationAuthDALFactory } from "@app/services/integration-auth/integration-auth-dal"; import { integrationAuthServiceFactory } from "@app/services/integration-auth/integration-auth-service"; +import { integrationDALFactory } from "@app/services/integration/integration-dal"; +import { integrationServiceFactory } from "@app/services/integration/integration-service"; import { internalKmsDALFactory } from "@app/services/kms/internal-kms-dal"; import { kmskeyDALFactory } from "@app/services/kms/kms-key-dal"; import { TKmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; import { kmsServiceFactory } from "@app/services/kms/kms-service"; import { RootKeyEncryptionStrategy } from "@app/services/kms/kms-types"; -import { membershipDALFactory } from "@app/services/membership/membership-dal"; -import { membershipRoleDALFactory } from "@app/services/membership/membership-role-dal"; import { membershipGroupDALFactory } from "@app/services/membership-group/membership-group-dal"; import { membershipGroupServiceFactory } from "@app/services/membership-group/membership-group-service"; import { membershipIdentityDALFactory } from "@app/services/membership-identity/membership-identity-dal"; import { membershipIdentityServiceFactory } from "@app/services/membership-identity/membership-identity-service"; import { membershipUserDALFactory } from "@app/services/membership-user/membership-user-dal"; import { membershipUserServiceFactory } from "@app/services/membership-user/membership-user-service"; +import { membershipDALFactory } from "@app/services/membership/membership-dal"; +import { membershipRoleDALFactory } from "@app/services/membership/membership-role-dal"; import { microsoftTeamsIntegrationDALFactory } from "@app/services/microsoft-teams/microsoft-teams-integration-dal"; import { microsoftTeamsServiceFactory } from "@app/services/microsoft-teams/microsoft-teams-service"; import { projectMicrosoftTeamsConfigDALFactory } from "@app/services/microsoft-teams/project-microsoft-teams-config-dal"; @@ -260,11 +260,11 @@ import { notificationServiceFactory } from "@app/services/notification/notificat import { userNotificationDALFactory } from "@app/services/notification/user-notification-dal"; import { offlineUsageReportDALFactory } from "@app/services/offline-usage-report/offline-usage-report-dal"; import { offlineUsageReportServiceFactory } from "@app/services/offline-usage-report/offline-usage-report-service"; +import { orgAdminServiceFactory } from "@app/services/org-admin/org-admin-service"; +import { orgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; import { incidentContactDALFactory } from "@app/services/org/incident-contacts-dal"; import { orgDALFactory } from "@app/services/org/org-dal"; import { orgServiceFactory } from "@app/services/org/org-service"; -import { orgAdminServiceFactory } from "@app/services/org-admin/org-admin-service"; -import { orgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; import { pamAccountRotationServiceFactory } from "@app/services/pam-account-rotation/pam-account-rotation-queue"; import { dailyExpiringPkiItemAlertQueueServiceFactory } from "@app/services/pki-alert/expiring-pki-item-alert-queue"; import { pkiAlertDALFactory } from "@app/services/pki-alert/pki-alert-dal"; @@ -281,10 +281,6 @@ import { pkiSyncQueueFactory } from "@app/services/pki-sync/pki-sync-queue"; import { pkiSyncServiceFactory } from "@app/services/pki-sync/pki-sync-service"; import { pkiTemplatesDALFactory } from "@app/services/pki-templates/pki-templates-dal"; import { pkiTemplatesServiceFactory } from "@app/services/pki-templates/pki-templates-service"; -import { projectDALFactory } from "@app/services/project/project-dal"; -import { projectQueueFactory } from "@app/services/project/project-queue"; -import { projectServiceFactory } from "@app/services/project/project-service"; -import { projectSshConfigDALFactory } from "@app/services/project/project-ssh-config-dal"; import { projectBotDALFactory } from "@app/services/project-bot/project-bot-dal"; import { projectBotServiceFactory } from "@app/services/project-bot/project-bot-service"; import { projectEnvDALFactory } from "@app/services/project-env/project-env-dal"; @@ -293,19 +289,18 @@ import { projectKeyDALFactory } from "@app/services/project-key/project-key-dal" import { projectKeyServiceFactory } from "@app/services/project-key/project-key-service"; import { projectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; import { projectMembershipServiceFactory } from "@app/services/project-membership/project-membership-service"; +import { projectDALFactory } from "@app/services/project/project-dal"; +import { projectQueueFactory } from "@app/services/project/project-queue"; +import { projectServiceFactory } from "@app/services/project/project-service"; +import { projectSshConfigDALFactory } from "@app/services/project/project-ssh-config-dal"; +import { reminderRecipientDALFactory } from "@app/services/reminder-recipients/reminder-recipient-dal"; import { reminderDALFactory } from "@app/services/reminder/reminder-dal"; import { dailyReminderQueueServiceFactory } from "@app/services/reminder/reminder-queue"; import { reminderServiceFactory } from "@app/services/reminder/reminder-service"; -import { reminderRecipientDALFactory } from "@app/services/reminder-recipients/reminder-recipient-dal"; import { dailyResourceCleanUpQueueServiceFactory } from "@app/services/resource-cleanup/resource-cleanup-queue"; import { resourceMetadataDALFactory } from "@app/services/resource-metadata/resource-metadata-dal"; import { roleDALFactory } from "@app/services/role/role-dal"; import { roleServiceFactory } from "@app/services/role/role-service"; -import { secretDALFactory } from "@app/services/secret/secret-dal"; -import { secretQueueFactory } from "@app/services/secret/secret-queue"; -import { secretServiceFactory } from "@app/services/secret/secret-service"; -import { secretVersionDALFactory } from "@app/services/secret/secret-version-dal"; -import { secretVersionTagDALFactory } from "@app/services/secret/secret-version-tag-dal"; import { secretBlindIndexDALFactory } from "@app/services/secret-blind-index/secret-blind-index-dal"; import { secretBlindIndexServiceFactory } from "@app/services/secret-blind-index/secret-blind-index-service"; import { secretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; @@ -325,6 +320,11 @@ import { secretV2BridgeDALFactory } from "@app/services/secret-v2-bridge/secret- import { secretV2BridgeServiceFactory } from "@app/services/secret-v2-bridge/secret-v2-bridge-service"; import { secretVersionV2BridgeDALFactory } from "@app/services/secret-v2-bridge/secret-version-dal"; import { secretVersionV2TagBridgeDALFactory } from "@app/services/secret-v2-bridge/secret-version-tag-dal"; +import { secretDALFactory } from "@app/services/secret/secret-dal"; +import { secretQueueFactory } from "@app/services/secret/secret-queue"; +import { secretServiceFactory } from "@app/services/secret/secret-service"; +import { secretVersionDALFactory } from "@app/services/secret/secret-version-dal"; +import { secretVersionTagDALFactory } from "@app/services/secret/secret-version-tag-dal"; import { serviceTokenDALFactory } from "@app/services/service-token/service-token-dal"; import { serviceTokenServiceFactory } from "@app/services/service-token/service-token-service"; import { projectSlackConfigDALFactory } from "@app/services/slack/project-slack-config-dal"; @@ -340,15 +340,16 @@ import { telemetryServiceFactory } from "@app/services/telemetry/telemetry-servi import { totpConfigDALFactory } from "@app/services/totp/totp-config-dal"; import { totpServiceFactory } from "@app/services/totp/totp-service"; import { upgradePathServiceFactory } from "@app/services/upgrade-path/upgrade-path-service"; -import { userDALFactory } from "@app/services/user/user-dal"; -import { userServiceFactory } from "@app/services/user/user-service"; import { userAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; import { userEngagementServiceFactory } from "@app/services/user-engagement/user-engagement-service"; +import { userDALFactory } from "@app/services/user/user-dal"; +import { userServiceFactory } from "@app/services/user/user-service"; import { webhookDALFactory } from "@app/services/webhook/webhook-dal"; import { webhookServiceFactory } from "@app/services/webhook/webhook-service"; import { workflowIntegrationDALFactory } from "@app/services/workflow-integration/workflow-integration-dal"; import { workflowIntegrationServiceFactory } from "@app/services/workflow-integration/workflow-integration-service"; +import { pkiAcmeAccountDALFactory } from "@app/ee/services/pki-acme/pki-acme-account-dal"; import { injectAuditLogInfo } from "../plugins/audit-log"; import { injectAssumePrivilege } from "../plugins/auth/inject-assume-privilege"; import { injectIdentity } from "../plugins/auth/inject-identity"; @@ -1064,6 +1065,7 @@ export const registerRoutes = async ( const apiEnrollmentConfigDAL = apiEnrollmentConfigDALFactory(db); const estEnrollmentConfigDAL = estEnrollmentConfigDALFactory(db); const acmeEnrollmentConfigDAL = acmeEnrollmentConfigDALFactory(db); + const pkiAcmeAccountDAL = pkiAcmeAccountDALFactory(db); const certificateDAL = certificateDALFactory(db); const certificateBodyDAL = certificateBodyDALFactory(db); @@ -1159,13 +1161,15 @@ export const registerRoutes = async ( certificateTemplateV2DAL, apiEnrollmentConfigDAL, estEnrollmentConfigDAL, + acmeEnrollmentConfigDAL, permissionService, kmsService, projectDAL }); const pkiAcmeService = pkiAcmeServiceFactory({ - certificateProfileDAL + certificateProfileDAL, + pkiAcmeAccountDAL }); const pkiAlertService = pkiAlertServiceFactory({ From 5585621a065f78bf0185e780866b2090c4cd517c Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 28 Oct 2025 16:39:24 -0700 Subject: [PATCH 035/231] Add acme type --- backend/src/@types/knex.d.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 2be2af84c..7dcd63c0e 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -717,6 +717,11 @@ declare module "knex/types/tables" { TPkiAcmeEnrollmentConfigsInsert, TPkiAcmeEnrollmentConfigsUpdate >; + [TableName.PkiAcmeAccount]: KnexOriginal.CompositeTableType< + TPkiAcmeAccounts, + TPkiAcmeAccountsInsert, + TPkiAcmeAccountsUpdate + >; [TableName.CertificateTemplateEstConfig]: KnexOriginal.CompositeTableType< TCertificateTemplateEstConfigs, TCertificateTemplateEstConfigsInsert, From f0d2c30384b18d670756347fa58dfad3ed18ddaa Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 28 Oct 2025 16:44:10 -0700 Subject: [PATCH 036/231] Use profile obj instead --- backend/src/ee/services/pki-acme/pki-acme-service.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) 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 0040f143b..b73195282 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -109,8 +109,6 @@ export const pkiAcmeServiceFactory = ({ { onlyReturnExisting, contact }: TCreateAcmeAccountPayload ): Promise> => { const profile = await validateAcmeProfile(profileId); - // TODO: the jwk as json obj may not be the best idea for indexing. - // Maybe we should find a way to serialize the jwk deterministically. let account: TPkiAcmeAccount | null = await pkiAcmeAccountDAL.findByPublicKey(jwk); if (onlyReturnExisting && !account) { throw new AcmeAccountDoesNotExistError({ message: "ACME account not found" }); @@ -122,16 +120,16 @@ export const pkiAcmeServiceFactory = ({ body: { status: "valid", contact: account.emails, - orders: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/accounts/${account.id}/orders`) + orders: buildUrl(`/api/v1/pki/acme/profiles/${profile.id}/accounts/${account.id}/orders`) }, headers: { - Location: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/accounts/${account.id}`) + Location: buildUrl(`/api/v1/pki/acme/profiles/${profile.id}/accounts/${account.id}`) } }; } account = await pkiAcmeAccountDAL.create({ - profileId, + profileId: profile.id, publicKey: jwk, emails: contact ?? [] }); @@ -141,10 +139,10 @@ export const pkiAcmeServiceFactory = ({ body: { status: "valid", contact: account.emails, - orders: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/accounts/${account.id}/orders`) + orders: buildUrl(`/api/v1/pki/acme/profiles/${profile.id}/accounts/${account.id}/orders`) }, headers: { - Location: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/accounts/${account.id}`) + Location: buildUrl(`/api/v1/pki/acme/profiles/${profile.id}/accounts/${account.id}`) } }; }; From ebd2c8ae45dfd439863f6acc555bf932b7c3c9ad Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 28 Oct 2025 17:10:30 -0700 Subject: [PATCH 037/231] Refine code --- .../ee/services/pki-acme/pki-acme-service.ts | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) 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 b73195282..7a08a95f7 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -31,7 +31,7 @@ import { TRawJwsPayload, TRespondToAcmeChallengeResponse } from "./pki-acme-types"; -import { TPkiAcmeAccount } from "@app/db/schemas/pki-acme-accounts"; +import { TPkiAcmeAccounts } from "@app/db/schemas/pki-acme-accounts"; import { TPkiAcmeAccountDALFactory } from "./pki-acme-account-dal"; type TPkiAcmeServiceFactoryDep = { @@ -109,26 +109,26 @@ export const pkiAcmeServiceFactory = ({ { onlyReturnExisting, contact }: TCreateAcmeAccountPayload ): Promise> => { const profile = await validateAcmeProfile(profileId); - let account: TPkiAcmeAccount | null = await pkiAcmeAccountDAL.findByPublicKey(jwk); - if (onlyReturnExisting && !account) { + const existingAccount: TPkiAcmeAccounts | null = await pkiAcmeAccountDAL.findByPublicKey(jwk); + if (onlyReturnExisting && !existingAccount) { throw new AcmeAccountDoesNotExistError({ message: "ACME account not found" }); } - if (account) { + if (existingAccount) { // With the same public key, we found an existing account, just return it return { status: 200, body: { status: "valid", - contact: account.emails, - orders: buildUrl(`/api/v1/pki/acme/profiles/${profile.id}/accounts/${account.id}/orders`) + 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/${account.id}`) + Location: buildUrl(`/api/v1/pki/acme/profiles/${profile.id}/accounts/${existingAccount.id}`) } }; } - account = await pkiAcmeAccountDAL.create({ + const newAccount = await pkiAcmeAccountDAL.create({ profileId: profile.id, publicKey: jwk, emails: contact ?? [] @@ -138,11 +138,11 @@ export const pkiAcmeServiceFactory = ({ status: 201, body: { status: "valid", - contact: account.emails, - orders: buildUrl(`/api/v1/pki/acme/profiles/${profile.id}/accounts/${account.id}/orders`) + 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/${account.id}`) + Location: buildUrl(`/api/v1/pki/acme/profiles/${profile.id}/accounts/${newAccount.id}`) } }; }; From 4f0bb7fb8ac4a14baf15f24ff62262302f561f0c Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 28 Oct 2025 17:37:24 -0700 Subject: [PATCH 038/231] 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, From 540521588dc65e5ce290110f848fee70d61d8fd4 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 28 Oct 2025 19:02:17 -0700 Subject: [PATCH 039/231] Refactor validate jws method --- .../services/pki-acme/pki-acme-account-dal.ts | 4 +- .../ee/services/pki-acme/pki-acme-service.ts | 89 ++++++++++--------- .../ee/services/pki-acme/pki-acme-types.ts | 1 + backend/src/server/routes/index.ts | 7 +- 4 files changed, 55 insertions(+), 46 deletions(-) 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 0050a1139..15c07b0e1 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,9 +61,9 @@ export const pkiAcmeAccountDALFactory = (db: TDbClient) => { } }; - const findByPublicKey = async (publicKey: unknown, alg: string, tx?: Knex) => { + const findByPublicKey = async (profileId: string, alg: string, publicKey: unknown, tx?: Knex) => { try { - const account = await (tx || db)(TableName.PkiAcmeAccount).where({ publicKey, alg }).first(); + const account = await (tx || db)(TableName.PkiAcmeAccount).where({ profileId, alg, publicKey }).first(); return account || null; } catch (error) { 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 cf239b16a..57aa504b3 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -3,14 +3,19 @@ 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 { + AcmeAccountDoesNotExistError, + AcmeBadPublicKeyError, + AcmeMalformedError, + AcmeServerInternalError +} 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 { flattenedVerify, FlattenedVerifyResult, 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"; @@ -27,12 +32,16 @@ import { TGetAcmeAuthorizationResponse, TGetAcmeDirectoryResponse, TGetAcmeOrderResponse, + TJwsPayload, TJwsPayloadWithJwk, TListAcmeOrdersResponse, TPkiAcmeServiceFactory, TRawJwsPayload, TRespondToAcmeChallengeResponse } from "./pki-acme-types"; +import { JWSInvalid } from "jose/dist/types/util/errors"; +import { logger } from "@app/lib/logger"; +import { z, ZodError } from "zod"; type TPkiAcmeServiceFactoryDep = { certificateProfileDAL: Pick; @@ -61,57 +70,52 @@ export const pkiAcmeServiceFactory = ({ return `${baseUrl}${path}`; }; - const validateCreateAcmeAccountJwsPayload = async (rawJwsPayload: TRawJwsPayload): Promise => { - const { payload: rawPayload, protectedHeader: rawProtectedHeader } = await flattenedVerify( - rawJwsPayload, - async (protectedHeader: JWSHeaderParameters | undefined) => { + const validateJwsPayload = async ( + rawJwsPayload: TRawJwsPayload, + getJWK: (protectedHeader: JWSHeaderParameters) => Promise, + schema: z.ZodSchema + ): Promise => { + let result: FlattenedVerifyResult; + try { + result = 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; + ProtectedHeaderSchema.parse(protectedHeader); + const jwk = await getJWK(protectedHeader); + return await importJWK(jwk, protectedHeader.alg); + }); + } catch (error) { + if (error instanceof ZodError) { + throw new AcmeMalformedError({ detail: `Invalid JWS payload: ${error.message}` }); } - ); + if (error instanceof JWSInvalid) { + throw new AcmeBadPublicKeyError({ detail: "Invalid JWS payload" }); + } + logger.error(error, "Unexpected error while verifying JWS payload"); + throw new AcmeServerInternalError({ detail: "Failed to verify JWS payload" }); + } + const { payload: rawPayload, protectedHeader: rawProtectedHeader } = result!; 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, 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 jsonPayload = JSON.parse(decoder.decode(rawPayload)); + try { + const payload = schema.parse(jsonPayload); + return { + protectedHeader, + payload + }; + } catch (error) { + if (error instanceof ZodError) { + throw new AcmeMalformedError({ detail: `Invalid JWS payload: ${error.message}` }); } - ); - const { success, data: protectedHeader } = ProtectedHeaderSchema.safeParse(rawProtectedHeader); - if (!success) { - throw new AcmeMalformedError({ detail: "Invalid protected header" }); + logger.error(error, "Unexpected error while parsing JWS payload"); + throw new AcmeServerInternalError({ detail: "Failed to verify JWS payload" }); } - - 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 => { @@ -132,11 +136,12 @@ export const pkiAcmeServiceFactory = ({ const createAcmeAccount = async ( profileId: string, + alg: string, jwk: JWK, { onlyReturnExisting, contact }: TCreateAcmeAccountPayload ): Promise> => { const profile = await validateAcmeProfile(profileId); - const existingAccount: TPkiAcmeAccounts | null = await acmeAccountDAL.findByPublicKey(jwk, alg); + const existingAccount: TPkiAcmeAccounts | null = await acmeAccountDAL.findByPublicKey(profileId, alg, jwk); if (onlyReturnExisting && !existingAccount) { throw new AcmeAccountDoesNotExistError({ message: "ACME account not found" }); } 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 2006f38a2..4bf7411c4 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-types.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-types.ts @@ -55,6 +55,7 @@ export type TPkiAcmeServiceFactory = { getAcmeNewNonce: (profileId: string) => Promise; createAcmeAccount: ( profileId: string, + alg: string, jwk: JsonWebKey, body: TCreateAcmeAccountPayload ) => Promise>; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index b1e4b9b7c..49ac7750d 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -361,6 +361,7 @@ import { initializeOauthConfigSync } from "./v1/sso-router"; import { registerV2Routes } from "./v2"; import { registerV3Routes } from "./v3"; import { registerV4Routes } from "./v4"; +import { pkiAcmeOrderDALFactory } from "@app/ee/services/pki-acme/pki-acme-order-dal"; const histogram = monitorEventLoopDelay({ resolution: 20 }); histogram.enable(); @@ -1065,7 +1066,8 @@ export const registerRoutes = async ( const apiEnrollmentConfigDAL = apiEnrollmentConfigDALFactory(db); const estEnrollmentConfigDAL = estEnrollmentConfigDALFactory(db); const acmeEnrollmentConfigDAL = acmeEnrollmentConfigDALFactory(db); - const pkiAcmeAccountDAL = pkiAcmeAccountDALFactory(db); + const acmeAccountDAL = pkiAcmeAccountDALFactory(db); + const acmeOrderDAL = pkiAcmeOrderDALFactory(db); const certificateDAL = certificateDALFactory(db); const certificateBodyDAL = certificateBodyDALFactory(db); @@ -1169,7 +1171,8 @@ export const registerRoutes = async ( const pkiAcmeService = pkiAcmeServiceFactory({ certificateProfileDAL, - pkiAcmeAccountDAL + acmeAccountDAL, + acmeOrderDAL }); const pkiAlertService = pkiAlertServiceFactory({ From 223835d1411c4e90d9fe7a0d0d73b576f3b9cdcf Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 28 Oct 2025 19:08:51 -0700 Subject: [PATCH 040/231] New approach to validate jws --- backend/src/@types/knex.d.ts | 11 +++++++++++ .../migrations/20251027234547_add-pki-acme.ts | 1 + backend/src/ee/routes/v1/pki-acme-router.ts | 19 ++++++++++++++++--- .../ee/services/pki-acme/pki-acme-schemas.ts | 5 ----- .../ee/services/pki-acme/pki-acme-service.ts | 3 ++- .../ee/services/pki-acme/pki-acme-types.ts | 16 ++++++++++------ 6 files changed, 40 insertions(+), 15 deletions(-) diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 7dcd63c0e..8a19670f0 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -722,6 +722,17 @@ declare module "knex/types/tables" { TPkiAcmeAccountsInsert, TPkiAcmeAccountsUpdate >; + [TableName.PkiAcmeOrder]: KnexOriginal.CompositeTableType< + TPkiAcmeOrders, + TPkiAcmeOrdersInsert, + TPkiAcmeOrdersUpdate + >; + [TableName.PkiAcmeAuth]: KnexOriginal.CompositeTableType; + [TableName.PkiAcmeChallenge]: KnexOriginal.CompositeTableType< + TPkiAcmeChallenges, + TPkiAcmeChallengesInsert, + TPkiAcmeChallengesUpdate + >; [TableName.CertificateTemplateEstConfig]: KnexOriginal.CompositeTableType< TCertificateTemplateEstConfigs, TCertificateTemplateEstConfigsInsert, diff --git a/backend/src/db/migrations/20251027234547_add-pki-acme.ts b/backend/src/db/migrations/20251027234547_add-pki-acme.ts index 781fe040e..285676c8f 100644 --- a/backend/src/db/migrations/20251027234547_add-pki-acme.ts +++ b/backend/src/db/migrations/20251027234547_add-pki-acme.ts @@ -56,6 +56,7 @@ export async function up(knex: Knex): Promise { // 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(); // The JWS algorithm used to sign the public key when creating the account, e.g. "RS256", "ES256", "PS256", etc. diff --git a/backend/src/ee/routes/v1/pki-acme-router.ts b/backend/src/ee/routes/v1/pki-acme-router.ts index e0762b7f3..8361fd330 100644 --- a/backend/src/ee/routes/v1/pki-acme-router.ts +++ b/backend/src/ee/routes/v1/pki-acme-router.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { + CreateAcmeAccountBodySchema, CreateAcmeAccountResponseSchema, CreateAcmeOrderResponseSchema, CreateAcmeOrderSchema, @@ -27,6 +28,7 @@ import { TCreateAcmeAccountPayload, TRawJwsPayload } from "@app/ee/services/pki- import { ApiDocsTags } from "@app/lib/api-docs"; import { getConfig } from "@app/lib/config/env"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { AcmeBadPublicKeyError } from "@app/ee/services/pki-acme/pki-acme-errors"; export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { const appCfg = getConfig(); @@ -111,13 +113,24 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { } }, handler: async (req, res) => { - const { payload, jwk } = await server.services.pkiAcme.validateCreateAcmeAccountJwsPayload( - req.body as TRawJwsPayload + const { payload, protectedHeader, jwk } = await server.services.pkiAcme.validateJwsPayload( + req.body as TRawJwsPayload, + async (protectedHeader) => { + if (!protectedHeader.jwk) { + throw new AcmeBadPublicKeyError({ detail: "JWK is required in the protected header" }); + } + return protectedHeader.jwk as unknown as JsonWebKey; + }, + CreateAcmeAccountBodySchema ); + if (!jwk) { + throw new AcmeBadPublicKeyError({ detail: "JWK is required in the protected header" }); + } const { status, body, headers } = await server.services.pkiAcme.createAcmeAccount( req.params.profileId, + protectedHeader.alg, jwk, - payload as TCreateAcmeAccountPayload + payload ); // TODO: DRY res.code(status); diff --git a/backend/src/ee/services/pki-acme/pki-acme-schemas.ts b/backend/src/ee/services/pki-acme/pki-acme-schemas.ts index 599203dae..7e50b2108 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-schemas.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-schemas.ts @@ -20,11 +20,6 @@ export const RawJwsPayloadSchema = z.object({ signature: z.string() }); -export const JwsPayloadSchema = z.object({ - protectedHeader: ProtectedHeaderSchema, - payload: z.unknown() -}); - // Directory endpoint export const GetAcmeDirectorySchema = z.object({ params: z.object({ 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 57aa504b3..b75a76979 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -81,7 +81,8 @@ export const pkiAcmeServiceFactory = ({ if (protectedHeader === undefined) { throw new AcmeMalformedError({ detail: "Protected header is required" }); } - ProtectedHeaderSchema.parse(protectedHeader); + const parsedHeader = ProtectedHeaderSchema.parse(protectedHeader); + // TODO: consume the nonce here const jwk = await getJWK(protectedHeader); return await importJWK(jwk, protectedHeader.alg); }); 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 4bf7411c4..35571b0cb 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-types.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-types.ts @@ -1,5 +1,6 @@ import { z } from "zod"; +import { JWSHeaderParameters } from "jose"; import { CreateAcmeAccountBodySchema, CreateAcmeAccountResponseSchema, @@ -12,7 +13,6 @@ import { GetAcmeAuthorizationResponseSchema, GetAcmeDirectoryResponseSchema, GetAcmeOrderResponseSchema, - JwsPayloadSchema, ListAcmeOrdersResponseSchema, ProtectedHeaderSchema, RawJwsPayloadSchema, @@ -32,16 +32,16 @@ export type TRespondToAcmeChallengeResponse = z.infer; -export type TJwsPayload = z.infer; export type TProtectedHeader = z.infer; export type TCreateAcmeAccountPayload = z.infer; export type TCreateAcmeOrderPayload = z.infer; export type TDeactivateAcmeAccountPayload = z.infer; export type TFinalizeAcmeOrderPayload = z.infer; -export type TJwsPayloadWithJwk = TJwsPayload & { - jwk: JsonWebKey; - alg: string; +export type TJwsPayload = { + protectedHeader: TProtectedHeader; + jwk?: JsonWebKey; + payload: T; }; export type TAcmeResponse = { status: number; @@ -50,7 +50,11 @@ export type TAcmeResponse = { }; export type TPkiAcmeServiceFactory = { - validateCreateAcmeAccountJwsPayload(body: TRawJwsPayload): Promise; + validateJwsPayload: ( + rawJwsPayload: TRawJwsPayload, + getJWK: (protectedHeader: JWSHeaderParameters) => Promise, + schema: z.ZodSchema + ) => Promise>; getAcmeDirectory: (profileId: string) => Promise; getAcmeNewNonce: (profileId: string) => Promise; createAcmeAccount: ( From 6064b39c05783c40eb429eb8cfeb4ca4ee887085 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 28 Oct 2025 19:14:37 -0700 Subject: [PATCH 041/231] Refactor --- backend/src/ee/routes/v1/pki-acme-router.ts | 7 ++----- .../ee/services/pki-acme/pki-acme-service.ts | 20 +++++++------------ 2 files changed, 9 insertions(+), 18 deletions(-) diff --git a/backend/src/ee/routes/v1/pki-acme-router.ts b/backend/src/ee/routes/v1/pki-acme-router.ts index 8361fd330..cda0fc478 100644 --- a/backend/src/ee/routes/v1/pki-acme-router.ts +++ b/backend/src/ee/routes/v1/pki-acme-router.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-floating-promises */ import { z } from "zod"; +import { AcmeBadPublicKeyError } from "@app/ee/services/pki-acme/pki-acme-errors"; import { CreateAcmeAccountBodySchema, CreateAcmeAccountResponseSchema, @@ -24,15 +25,11 @@ import { RespondToAcmeChallengeResponseSchema, RespondToAcmeChallengeSchema } from "@app/ee/services/pki-acme/pki-acme-schemas"; -import { TCreateAcmeAccountPayload, TRawJwsPayload } from "@app/ee/services/pki-acme/pki-acme-types"; +import { TRawJwsPayload } from "@app/ee/services/pki-acme/pki-acme-types"; import { ApiDocsTags } from "@app/lib/api-docs"; -import { getConfig } from "@app/lib/config/env"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; -import { AcmeBadPublicKeyError } from "@app/ee/services/pki-acme/pki-acme-errors"; export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { - const appCfg = getConfig(); - server.addContentTypeParser("application/jose+json", { parseAs: "string" }, (_, body, done) => { try { const strBody = body instanceof Buffer ? body.toString() : body; 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 b75a76979..316328784 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -33,7 +33,6 @@ import { TGetAcmeDirectoryResponse, TGetAcmeOrderResponse, TJwsPayload, - TJwsPayloadWithJwk, TListAcmeOrdersResponse, TPkiAcmeServiceFactory, TRawJwsPayload, @@ -74,15 +73,13 @@ export const pkiAcmeServiceFactory = ({ rawJwsPayload: TRawJwsPayload, getJWK: (protectedHeader: JWSHeaderParameters) => Promise, schema: z.ZodSchema - ): Promise => { + ): Promise> => { let result: FlattenedVerifyResult; try { result = await flattenedVerify(rawJwsPayload, async (protectedHeader: JWSHeaderParameters | undefined) => { if (protectedHeader === undefined) { throw new AcmeMalformedError({ detail: "Protected header is required" }); } - const parsedHeader = ProtectedHeaderSchema.parse(protectedHeader); - // TODO: consume the nonce here const jwk = await getJWK(protectedHeader); return await importJWK(jwk, protectedHeader.alg); }); @@ -96,15 +93,12 @@ export const pkiAcmeServiceFactory = ({ logger.error(error, "Unexpected error while verifying JWS payload"); throw new AcmeServerInternalError({ detail: "Failed to verify JWS payload" }); } - const { payload: rawPayload, protectedHeader: rawProtectedHeader } = result!; - const { success, data: protectedHeader } = ProtectedHeaderSchema.safeParse(rawProtectedHeader); - if (!success) { - throw new AcmeMalformedError({ detail: "Invalid protected header" }); - } - - const decoder = new TextDecoder(); - const jsonPayload = JSON.parse(decoder.decode(rawPayload)); + const { protectedHeader: rawProtectedHeader, payload: rawPayload } = result; try { + const protectedHeader = ProtectedHeaderSchema.parse(rawProtectedHeader); + // TODO: consume the nonce here + const decoder = new TextDecoder(); + const jsonPayload = JSON.parse(decoder.decode(rawPayload)); const payload = schema.parse(jsonPayload); return { protectedHeader, @@ -297,7 +291,7 @@ export const pkiAcmeServiceFactory = ({ }; return { - validateCreateAcmeAccountJwsPayload, + validateJwsPayload, getAcmeDirectory, getAcmeNewNonce, createAcmeAccount, From 457c5ea9c82d5e66e6e94cf74c262132830bf490 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 28 Oct 2025 19:16:25 -0700 Subject: [PATCH 042/231] Fix import --- backend/src/ee/services/pki-acme/pki-acme-service.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) 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 316328784..1c191b206 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -11,11 +11,13 @@ import { } from "./pki-acme-errors"; import { TPkiAcmeAccounts } from "@app/db/schemas/pki-acme-accounts"; +import { logger } from "@app/lib/logger"; import { EnrollmentType, TCertificateProfileWithConfigs } from "@app/services/certificate-profile/certificate-profile-types"; -import { flattenedVerify, FlattenedVerifyResult, importJWK, JWK, JWSHeaderParameters } from "jose"; +import { errors, flattenedVerify, FlattenedVerifyResult, importJWK, JWK, JWSHeaderParameters } from "jose"; +import { z, ZodError } from "zod"; import { TPkiAcmeAccountDALFactory } from "./pki-acme-account-dal"; import { TPkiAcmeOrderDALFactory } from "./pki-acme-order-dal"; import { ProtectedHeaderSchema } from "./pki-acme-schemas"; @@ -38,9 +40,6 @@ import { TRawJwsPayload, TRespondToAcmeChallengeResponse } from "./pki-acme-types"; -import { JWSInvalid } from "jose/dist/types/util/errors"; -import { logger } from "@app/lib/logger"; -import { z, ZodError } from "zod"; type TPkiAcmeServiceFactoryDep = { certificateProfileDAL: Pick; @@ -87,7 +86,7 @@ export const pkiAcmeServiceFactory = ({ if (error instanceof ZodError) { throw new AcmeMalformedError({ detail: `Invalid JWS payload: ${error.message}` }); } - if (error instanceof JWSInvalid) { + if (error instanceof errors.JWSInvalid) { throw new AcmeBadPublicKeyError({ detail: "Invalid JWS payload" }); } logger.error(error, "Unexpected error while verifying JWS payload"); From 9c748bf0ee3c07d0ed4475a54b8ced5cf9a9032b Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 28 Oct 2025 19:24:53 -0700 Subject: [PATCH 043/231] Fix new account again --- backend/src/db/schemas/pki-acme-accounts.ts | 1 + backend/src/ee/routes/v1/pki-acme-router.ts | 10 ++++------ backend/src/ee/services/pki-acme/pki-acme-service.ts | 1 + backend/src/ee/services/pki-acme/pki-acme-types.ts | 1 - 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/backend/src/db/schemas/pki-acme-accounts.ts b/backend/src/db/schemas/pki-acme-accounts.ts index b599cec07..275ff7e7e 100644 --- a/backend/src/db/schemas/pki-acme-accounts.ts +++ b/backend/src/db/schemas/pki-acme-accounts.ts @@ -12,6 +12,7 @@ export const PkiAcmeAccountsSchema = z.object({ profileId: z.string().uuid(), emails: z.string().array(), publicKey: z.unknown(), + alg: z.string(), createdAt: z.date(), updatedAt: z.date() }); diff --git a/backend/src/ee/routes/v1/pki-acme-router.ts b/backend/src/ee/routes/v1/pki-acme-router.ts index cda0fc478..b60b13dde 100644 --- a/backend/src/ee/routes/v1/pki-acme-router.ts +++ b/backend/src/ee/routes/v1/pki-acme-router.ts @@ -110,7 +110,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { } }, handler: async (req, res) => { - const { payload, protectedHeader, jwk } = await server.services.pkiAcme.validateJwsPayload( + const { payload, protectedHeader } = await server.services.pkiAcme.validateJwsPayload( req.body as TRawJwsPayload, async (protectedHeader) => { if (!protectedHeader.jwk) { @@ -120,13 +120,11 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { }, CreateAcmeAccountBodySchema ); - if (!jwk) { - throw new AcmeBadPublicKeyError({ detail: "JWK is required in the protected header" }); - } + const { alg, jwk } = protectedHeader; const { status, body, headers } = await server.services.pkiAcme.createAcmeAccount( req.params.profileId, - protectedHeader.alg, - jwk, + alg, + jwk!, payload ); // TODO: DRY 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 1c191b206..1ef8f4c6e 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -156,6 +156,7 @@ export const pkiAcmeServiceFactory = ({ const newAccount = await acmeAccountDAL.create({ profileId: profile.id, + alg, publicKey: jwk, emails: contact ?? [] }); 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 35571b0cb..f5908a922 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-types.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-types.ts @@ -40,7 +40,6 @@ export type TFinalizeAcmeOrderPayload = z.infer = { protectedHeader: TProtectedHeader; - jwk?: JsonWebKey; payload: T; }; export type TAcmeResponse = { From ac82e8071d48e332b7683bff537d542cdef0843a Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 28 Oct 2025 19:48:27 -0700 Subject: [PATCH 044/231] Refactor --- .../ee/services/pki-acme/pki-acme-service.ts | 41 +++++++++++++++---- .../ee/services/pki-acme/pki-acme-types.ts | 18 +++++--- 2 files changed, 44 insertions(+), 15 deletions(-) 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 1ef8f4c6e..46f67ac17 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -16,11 +16,11 @@ import { EnrollmentType, TCertificateProfileWithConfigs } from "@app/services/certificate-profile/certificate-profile-types"; -import { errors, flattenedVerify, FlattenedVerifyResult, importJWK, JWK, JWSHeaderParameters } from "jose"; +import { errors, flattenedVerify, FlattenedVerifyResult, importJWK, JWSHeaderParameters } from "jose"; import { z, ZodError } from "zod"; import { TPkiAcmeAccountDALFactory } from "./pki-acme-account-dal"; import { TPkiAcmeOrderDALFactory } from "./pki-acme-order-dal"; -import { ProtectedHeaderSchema } from "./pki-acme-schemas"; +import { CreateAcmeAccountBodySchema, ProtectedHeaderSchema } from "./pki-acme-schemas"; import { TAcmeResponse, TCreateAcmeAccountPayload, @@ -49,7 +49,8 @@ type TPkiAcmeServiceFactoryDep = { export const pkiAcmeServiceFactory = ({ certificateProfileDAL, - acmeAccountDAL + acmeAccountDAL, + acmeOrderDAL }: TPkiAcmeServiceFactoryDep): TPkiAcmeServiceFactory => { const validateAcmeProfile = async (profileId: string): Promise => { const profile = await certificateProfileDAL.findById(profileId); @@ -112,6 +113,21 @@ export const pkiAcmeServiceFactory = ({ } }; + const validateNewAccountJwsPayload = async ( + rawJwsPayload: TRawJwsPayload + ): Promise> => { + return await validateJwsPayload( + rawJwsPayload, + async (protectedHeader) => { + if (!protectedHeader.jwk) { + throw new AcmeBadPublicKeyError({ detail: "JWK is required in the protected header" }); + } + return protectedHeader.jwk as unknown as JsonWebKey; + }, + CreateAcmeAccountBodySchema + ); + }; + const getAcmeDirectory = async (profileId: string): Promise => { await validateAcmeProfile(profileId); return { @@ -128,12 +144,17 @@ export const pkiAcmeServiceFactory = ({ return "FIXME-generate-nonce"; }; - const createAcmeAccount = async ( - profileId: string, - alg: string, - jwk: JWK, - { onlyReturnExisting, contact }: TCreateAcmeAccountPayload - ): Promise> => { + const createAcmeAccount = async ({ + profileId, + alg, + jwk, + payload: { onlyReturnExisting, contact } + }: { + profileId: string; + alg: string; + jwk: JsonWebKey; + payload: TCreateAcmeAccountPayload; + }): Promise> => { const profile = await validateAcmeProfile(profileId); const existingAccount: TPkiAcmeAccounts | null = await acmeAccountDAL.findByPublicKey(profileId, alg, jwk); if (onlyReturnExisting && !existingAccount) { @@ -176,6 +197,7 @@ export const pkiAcmeServiceFactory = ({ const createAcmeOrder = async ( profileId: string, + account: TPkiAcmeAccounts, payload: TCreateAcmeOrderPayload ): Promise> => { const profile = await validateAcmeProfile(profileId); @@ -292,6 +314,7 @@ export const pkiAcmeServiceFactory = ({ return { validateJwsPayload, + validateNewAccountJwsPayload, getAcmeDirectory, getAcmeNewNonce, createAcmeAccount, 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 f5908a922..3bb03f6a6 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-types.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-types.ts @@ -54,14 +54,20 @@ export type TPkiAcmeServiceFactory = { getJWK: (protectedHeader: JWSHeaderParameters) => Promise, schema: z.ZodSchema ) => Promise>; + validateNewAccountJwsPayload: (rawJwsPayload: TRawJwsPayload) => Promise>; getAcmeDirectory: (profileId: string) => Promise; getAcmeNewNonce: (profileId: string) => Promise; - createAcmeAccount: ( - profileId: string, - alg: string, - jwk: JsonWebKey, - body: TCreateAcmeAccountPayload - ) => Promise>; + createAcmeAccount: ({ + profileId, + alg, + jwk, + payload + }: { + profileId: string; + alg: string; + jwk: JsonWebKey; + payload: TCreateAcmeAccountPayload; + }) => Promise>; createAcmeOrder: ( profileId: string, body: TCreateAcmeOrderPayload From 2b8a6b801deea31fc67ff3a074c345e16337da63 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 28 Oct 2025 20:13:38 -0700 Subject: [PATCH 045/231] Work on order --- backend/bdd/features/steps/pki_acme.py | 2 +- backend/src/ee/routes/v1/pki-acme-router.ts | 39 ++++++----- .../services/pki-acme/pki-acme-account-dal.ts | 54 +-------------- .../ee/services/pki-acme/pki-acme-service.ts | 68 ++++++++++++++++--- .../ee/services/pki-acme/pki-acme-types.ts | 21 ++++-- 5 files changed, 100 insertions(+), 84 deletions(-) diff --git a/backend/bdd/features/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py index eb8a04315..8546cb217 100644 --- a/backend/bdd/features/steps/pki_acme.py +++ b/backend/bdd/features/steps/pki_acme.py @@ -36,7 +36,7 @@ def step_impl(context: Context, profile_var: str): # TODO: Fixed value for now, just to make test much easier, # we should call infisical API to create such profile instead # in the future - profile_id = "0e96a01b-017e-4660-8b3d-ff26018fe0ce" + profile_id = "dd6e09c8-d5b8-4bfd-b436-4ab5c93d5d7e" context.vars[profile_var] = AcmeProfile(profile_id) diff --git a/backend/src/ee/routes/v1/pki-acme-router.ts b/backend/src/ee/routes/v1/pki-acme-router.ts index b60b13dde..13bad0a6a 100644 --- a/backend/src/ee/routes/v1/pki-acme-router.ts +++ b/backend/src/ee/routes/v1/pki-acme-router.ts @@ -1,12 +1,10 @@ /* eslint-disable @typescript-eslint/no-floating-promises */ import { z } from "zod"; -import { AcmeBadPublicKeyError } from "@app/ee/services/pki-acme/pki-acme-errors"; import { - CreateAcmeAccountBodySchema, CreateAcmeAccountResponseSchema, + CreateAcmeOrderBodySchema, CreateAcmeOrderResponseSchema, - CreateAcmeOrderSchema, DeactivateAcmeAccountResponseSchema, DeactivateAcmeAccountSchema, DownloadAcmeCertificateSchema, @@ -25,7 +23,6 @@ import { RespondToAcmeChallengeResponseSchema, RespondToAcmeChallengeSchema } from "@app/ee/services/pki-acme/pki-acme-schemas"; -import { TRawJwsPayload } from "@app/ee/services/pki-acme/pki-acme-types"; import { ApiDocsTags } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; @@ -104,29 +101,23 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { hide: false, tags: [ApiDocsTags.PkiAcme], description: "ACME New Account - register a new account or find existing one", - ...RawJwsPayloadSchema.shape, + params: z.object({ + profileId: z.string().uuid() + }), + body: RawJwsPayloadSchema, response: { 201: CreateAcmeAccountResponseSchema } }, handler: async (req, res) => { - const { payload, protectedHeader } = await server.services.pkiAcme.validateJwsPayload( - req.body as TRawJwsPayload, - async (protectedHeader) => { - if (!protectedHeader.jwk) { - throw new AcmeBadPublicKeyError({ detail: "JWK is required in the protected header" }); - } - return protectedHeader.jwk as unknown as JsonWebKey; - }, - CreateAcmeAccountBodySchema - ); + const { payload, protectedHeader } = await server.services.pkiAcme.validateNewAccountJwsPayload(req.body); const { alg, jwk } = protectedHeader; - const { status, body, headers } = await server.services.pkiAcme.createAcmeAccount( - req.params.profileId, + const { status, body, headers } = await server.services.pkiAcme.createAcmeAccount({ + profileId: req.params.profileId, alg, - jwk!, + jwk: jwk!, payload - ); + }); // TODO: DRY res.code(status); for (const [key, value] of Object.entries(headers)) { @@ -153,7 +144,10 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { hide: false, tags: [ApiDocsTags.PkiAcme], description: "ACME New Order - apply for a new certificate", - ...CreateAcmeOrderSchema.shape, + params: z.object({ + profileId: z.string().uuid() + }), + body: RawJwsPayloadSchema, response: { 201: CreateAcmeOrderResponseSchema } @@ -161,6 +155,11 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { // TODO: replace with verify ACME signature here instead // onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req, res) => { + const { payload, protectedHeader, accountId } = await server.services.pkiAcme.validateExistingAccountJwsPayload( + req.params.profileId, + req.body, + CreateAcmeOrderBodySchema + ); const order = await server.services.pkiAcme.createAcmeOrder(req.params.profileId, req.body); res.code(201); return order; 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 15c07b0e1..713cb4350 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 @@ -26,24 +26,9 @@ export const pkiAcmeAccountDALFactory = (db: TDbClient) => { } }; - const updateById = async (id: string, data: TPkiAcmeAccountsUpdate, tx?: Knex) => { + const findById = async (profileId: string, id: string, tx?: Knex) => { try { - const result = await (tx || db)(TableName.PkiAcmeAccount).where({ id }).update(data).returning("*"); - const [account] = result; - - if (!account) { - return null; - } - - return account; - } catch (error) { - throw new DatabaseError({ error, name: "Update PKI ACME account" }); - } - }; - - const findById = async (id: string, tx?: Knex) => { - try { - const account = await (tx || db)(TableName.PkiAcmeAccount).where({ id }).first(); + const account = await (tx || db)(TableName.PkiAcmeAccount).where({ profileId, id }).first(); return account || null; } catch (error) { @@ -51,16 +36,6 @@ export const pkiAcmeAccountDALFactory = (db: TDbClient) => { } }; - const findByProfileId = async (profileId: string, tx?: Knex) => { - try { - const account = await (tx || db)(TableName.PkiAcmeAccount).where({ profileId }).first(); - - return account || null; - } catch (error) { - throw new DatabaseError({ error, name: "Find PKI ACME account by profile id" }); - } - }; - const findByPublicKey = async (profileId: string, alg: string, publicKey: unknown, tx?: Knex) => { try { const account = await (tx || db)(TableName.PkiAcmeAccount).where({ profileId, alg, publicKey }).first(); @@ -71,35 +46,12 @@ export const pkiAcmeAccountDALFactory = (db: TDbClient) => { } }; - const findManyByProfileId = async (profileId: string, tx?: Knex) => { - try { - const accounts = await (tx || db)(TableName.PkiAcmeAccount).where({ profileId }); - - return accounts; - } catch (error) { - throw new DatabaseError({ error, name: "Find many PKI ACME accounts by profile id" }); - } - }; - - const deleteById = async (id: string, tx?: Knex) => { - try { - const result = await (tx || db)(TableName.PkiAcmeAccount).where({ id }).delete().returning("*"); - const [account] = result; - - return account || null; - } catch (error) { - throw new DatabaseError({ error, name: "Delete PKI ACME account by id" }); - } - }; - return { ...pkiAcmeAccountOrm, create, updateById, findById, findByProfileId, - findByPublicKey, - findManyByProfileId, - deleteById + findByPublicKey }; }; 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 46f67ac17..a6d4b63ac 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -23,6 +23,7 @@ import { TPkiAcmeOrderDALFactory } from "./pki-acme-order-dal"; import { CreateAcmeAccountBodySchema, ProtectedHeaderSchema } from "./pki-acme-schemas"; import { TAcmeResponse, + TAuthenciatedJwsPayload, TCreateAcmeAccountPayload, TCreateAcmeAccountResponse, TCreateAcmeOrderPayload, @@ -43,7 +44,7 @@ import { type TPkiAcmeServiceFactoryDep = { certificateProfileDAL: Pick; - acmeAccountDAL: Pick; + acmeAccountDAL: Pick; acmeOrderDAL: Pick; }; @@ -69,6 +70,14 @@ export const pkiAcmeServiceFactory = ({ return `${baseUrl}${path}`; }; + const extractAccountIdFromKid = (kid: string, profileId: string): string => { + const kidPrefix = buildUrl(`/api/v1/pki/acme/profiles/${profileId}/accounts/`); + if (!kid.startsWith(kidPrefix)) { + throw new AcmeMalformedError({ detail: "KID must start with the profile account URL" }); + } + return kid.slice(kidPrefix.length); + }; + const validateJwsPayload = async ( rawJwsPayload: TRawJwsPayload, getJWK: (protectedHeader: JWSHeaderParameters) => Promise, @@ -120,7 +129,7 @@ export const pkiAcmeServiceFactory = ({ rawJwsPayload, async (protectedHeader) => { if (!protectedHeader.jwk) { - throw new AcmeBadPublicKeyError({ detail: "JWK is required in the protected header" }); + throw new AcmeMalformedError({ detail: "JWK is required in the protected header" }); } return protectedHeader.jwk as unknown as JsonWebKey; }, @@ -128,6 +137,36 @@ export const pkiAcmeServiceFactory = ({ ); }; + const validateExistingAccountJwsPayload = async ( + profileId: string, + rawJwsPayload: TRawJwsPayload, + schema: z.ZodSchema + ): Promise> => { + const profile = await validateAcmeProfile(profileId); + const result = await validateJwsPayload( + rawJwsPayload, + async (protectedHeader) => { + if (!protectedHeader.kid) { + throw new AcmeMalformedError({ detail: "KID is required in the protected header" }); + } + const accountId = extractAccountIdFromKid(protectedHeader.kid, profileId); + const account = await acmeAccountDAL.findById(profile.id, accountId); + if (!account) { + throw new AcmeAccountDoesNotExistError({ message: "ACME account not found" }); + } + if (account.alg !== protectedHeader.alg) { + throw new AcmeMalformedError({ detail: "ACME account algorithm mismatch" }); + } + return account.publicKey as JsonWebKey; + }, + schema + ); + return { + ...result, + accountId: extractAccountIdFromKid(result.protectedHeader.kid!, profileId) + }; + }; + const getAcmeDirectory = async (profileId: string): Promise => { await validateAcmeProfile(profileId); return { @@ -195,13 +234,25 @@ export const pkiAcmeServiceFactory = ({ }; }; - const createAcmeOrder = async ( - profileId: string, - account: TPkiAcmeAccounts, - payload: TCreateAcmeOrderPayload - ): Promise> => { - const profile = await validateAcmeProfile(profileId); + const createAcmeOrder = async ({ + profileId, + accountId, + payload + }: { + profileId: string; + accountId: string; + payload: TCreateAcmeOrderPayload; + }): Promise> => { + const account = await acmeAccountDAL.findById(profileId, accountId)!; + // TODO: check and see if we have existing orders for this account that meet the criteria + // if we do, return the existing order + + orders = await acmeOrderDAL.create({ + profileId, + accountId, + status: "pending" + }); // FIXME: Implement ACME new order creation const orderId = "FIXME-order-id"; return { @@ -315,6 +366,7 @@ export const pkiAcmeServiceFactory = ({ return { validateJwsPayload, validateNewAccountJwsPayload, + validateExistingAccountJwsPayload, getAcmeDirectory, getAcmeNewNonce, createAcmeAccount, 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 3bb03f6a6..7051222fc 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-types.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-types.ts @@ -42,6 +42,9 @@ export type TJwsPayload = { protectedHeader: TProtectedHeader; payload: T; }; +export type TAuthenciatedJwsPayload = TJwsPayload & { + accountId: string; +}; export type TAcmeResponse = { status: number; headers: Record; @@ -55,6 +58,11 @@ export type TPkiAcmeServiceFactory = { schema: z.ZodSchema ) => Promise>; validateNewAccountJwsPayload: (rawJwsPayload: TRawJwsPayload) => Promise>; + validateExistingAccountJwsPayload: ( + profileId: string, + rawJwsPayload: TRawJwsPayload, + schema: z.ZodSchema + ) => Promise>; getAcmeDirectory: (profileId: string) => Promise; getAcmeNewNonce: (profileId: string) => Promise; createAcmeAccount: ({ @@ -68,10 +76,15 @@ export type TPkiAcmeServiceFactory = { jwk: JsonWebKey; payload: TCreateAcmeAccountPayload; }) => Promise>; - createAcmeOrder: ( - profileId: string, - body: TCreateAcmeOrderPayload - ) => Promise>; + createAcmeOrder: ({ + profileId, + accountId, + payload + }: { + profileId: string; + accountId: string; + payload: TCreateAcmeOrderPayload; + }) => Promise>; deactivateAcmeAccount: ( profileId: string, accountId: string, From 3a7ba8d1382d759358c54d152e6f1841cd7012cc Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Wed, 29 Oct 2025 12:17:25 -0700 Subject: [PATCH 046/231] Remove unused stuff --- backend/src/ee/services/pki-acme/pki-acme-account-dal.ts | 2 -- 1 file changed, 2 deletions(-) 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 713cb4350..e7979826d 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 @@ -49,9 +49,7 @@ export const pkiAcmeAccountDALFactory = (db: TDbClient) => { return { ...pkiAcmeAccountOrm, create, - updateById, findById, - findByProfileId, findByPublicKey }; }; From 80904b1dd1253881b4f34d0a8d3de72542c4020b Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Wed, 29 Oct 2025 14:13:07 -0700 Subject: [PATCH 047/231] Refine routers --- backend/src/ee/routes/v1/pki-acme-router.ts | 121 +++++++++++------- .../ee/services/pki-acme/pki-acme-schemas.ts | 112 ++++------------ .../ee/services/pki-acme/pki-acme-service.ts | 49 +++++-- backend/src/server/routes/index.ts | 8 +- 4 files changed, 144 insertions(+), 146 deletions(-) diff --git a/backend/src/ee/routes/v1/pki-acme-router.ts b/backend/src/ee/routes/v1/pki-acme-router.ts index 13bad0a6a..24ae75ff5 100644 --- a/backend/src/ee/routes/v1/pki-acme-router.ts +++ b/backend/src/ee/routes/v1/pki-acme-router.ts @@ -1,32 +1,38 @@ /* eslint-disable @typescript-eslint/no-floating-promises */ +import type { TAcmeResponse } from "@app/ee/services/pki-acme/pki-acme-types"; +import { FastifyReply } from "fastify"; import { z } from "zod"; import { CreateAcmeAccountResponseSchema, CreateAcmeOrderBodySchema, CreateAcmeOrderResponseSchema, + DeactivateAcmeAccountBodySchema, DeactivateAcmeAccountResponseSchema, - DeactivateAcmeAccountSchema, - DownloadAcmeCertificateSchema, - FinalizeAcmeOrderResponseSchema, - FinalizeAcmeOrderSchema, + FinalizeAcmeOrderBodySchema, GetAcmeAuthorizationResponseSchema, - GetAcmeAuthorizationSchema, GetAcmeDirectoryResponseSchema, - GetAcmeDirectorySchema, - GetAcmeNewNonceSchema, GetAcmeOrderResponseSchema, - GetAcmeOrderSchema, ListAcmeOrdersResponseSchema, - ListAcmeOrdersSchema, RawJwsPayloadSchema, - RespondToAcmeChallengeResponseSchema, - RespondToAcmeChallengeSchema + RespondToAcmeChallengeResponseSchema } from "@app/ee/services/pki-acme/pki-acme-schemas"; import { ApiDocsTags } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { + const sendAcmeResponse = async (res: FastifyReply, profileId: string, response: TAcmeResponse): Promise => { + res.code(response.status); + for (const [key, value] of Object.entries(response.headers)) { + res.header(key, value); + } + + const nonce = await server.services.pkiAcme.getAcmeNewNonce(profileId); + res.header("Replay-Nonce", nonce); + res.header("Cache-Control", "no-store"); + return response.body; + }; + server.addContentTypeParser("application/jose+json", { parseAs: "string" }, (_, body, done) => { try { const strBody = body instanceof Buffer ? body.toString() : body; @@ -53,7 +59,9 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { hide: false, tags: [ApiDocsTags.PkiAcme], description: "ACME Directory - provides URLs for the client to make API calls to", - ...GetAcmeDirectorySchema.shape, + params: z.object({ + profileId: z.string().uuid() + }), response: { 200: GetAcmeDirectoryResponseSchema } @@ -77,15 +85,17 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { hide: false, tags: [ApiDocsTags.PkiAcme], description: "ACME New Nonce - generate a new nonce and return in Replay-Nonce header", - ...GetAcmeNewNonceSchema.shape, + params: z.object({ + profileId: z.string().uuid() + }), response: { - 200: z.object({}) + 200: z.string().length(0) } }, handler: async (req, res) => { const nonce = await server.services.pkiAcme.getAcmeNewNonce(req.params.profileId); res.header("Replay-Nonce", nonce); - return {}; + return ""; } }); @@ -112,23 +122,16 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { handler: async (req, res) => { const { payload, protectedHeader } = await server.services.pkiAcme.validateNewAccountJwsPayload(req.body); const { alg, jwk } = protectedHeader; - const { status, body, headers } = await server.services.pkiAcme.createAcmeAccount({ - profileId: req.params.profileId, - alg, - jwk: jwk!, - payload - }); - // TODO: DRY - res.code(status); - for (const [key, value] of Object.entries(headers)) { - res.header(key, value); - } - - // TODO: DRY - const nonce = await server.services.pkiAcme.getAcmeNewNonce(req.params.profileId); - res.header("Replay-Nonce", nonce); - res.header("Cache-Control", "no-store"); - return body; + return sendAcmeResponse( + res, + req.params.profileId, + await server.services.pkiAcme.createAcmeAccount({ + profileId: req.params.profileId, + alg, + jwk: jwk!, + payload + }) + ); } }); @@ -155,14 +158,20 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { // TODO: replace with verify ACME signature here instead // onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req, res) => { - const { payload, protectedHeader, accountId } = await server.services.pkiAcme.validateExistingAccountJwsPayload( + const { payload, accountId } = await server.services.pkiAcme.validateExistingAccountJwsPayload( req.params.profileId, req.body, CreateAcmeOrderBodySchema ); - const order = await server.services.pkiAcme.createAcmeOrder(req.params.profileId, req.body); - res.code(201); - return order; + return sendAcmeResponse( + res, + req.params.profileId, + await server.services.pkiAcme.createAcmeOrder({ + profileId: req.params.profileId, + accountId, + payload + }) + ); } }); @@ -178,7 +187,11 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { hide: false, tags: [ApiDocsTags.PkiAcme], description: "ACME Account Deactivation", - ...DeactivateAcmeAccountSchema.shape, + params: z.object({ + profileId: z.string().uuid(), + accountId: z.string() + }), + body: DeactivateAcmeAccountBodySchema, response: { 200: DeactivateAcmeAccountResponseSchema } @@ -203,7 +216,10 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { hide: false, tags: [ApiDocsTags.PkiAcme], description: "ACME List Orders - get existing orders from current account", - ...ListAcmeOrdersSchema.shape, + params: z.object({ + profileId: z.string().uuid(), + accountId: z.string() + }), response: { 200: ListAcmeOrdersResponseSchema } @@ -228,7 +244,10 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { hide: false, tags: [ApiDocsTags.PkiAcme], description: "ACME Get Order - return status and details of the order", - ...GetAcmeOrderSchema.shape, + params: z.object({ + profileId: z.string().uuid(), + orderId: z.string().uuid() + }), response: { 200: GetAcmeOrderResponseSchema } @@ -253,10 +272,11 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { hide: false, tags: [ApiDocsTags.PkiAcme], description: "ACME Finalize Order - finalize cert order by providing CSR", - ...FinalizeAcmeOrderSchema.shape, - response: { - 200: FinalizeAcmeOrderResponseSchema - } + params: z.object({ + profileId: z.string().uuid(), + orderId: z.string().uuid() + }), + body: FinalizeAcmeOrderBodySchema }, // TODO: replace with verify ACME signature here instead // onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), @@ -278,7 +298,10 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { hide: false, tags: [ApiDocsTags.PkiAcme], description: "ACME Download Certificate - download certificate when ready", - ...DownloadAcmeCertificateSchema.shape, + params: z.object({ + profileId: z.string().uuid(), + orderId: z.string().uuid() + }), response: { 200: z.string() } @@ -307,7 +330,10 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { hide: false, tags: [ApiDocsTags.PkiAcme], description: "ACME Identifier Authorization - get authorization info (challenges)", - ...GetAcmeAuthorizationSchema.shape, + params: z.object({ + profileId: z.string().uuid(), + authzId: z.string().uuid() + }), response: { 200: GetAcmeAuthorizationResponseSchema } @@ -332,7 +358,10 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { hide: false, tags: [ApiDocsTags.PkiAcme], description: "ACME Respond to Challenge - let ACME server know challenge is ready", - ...RespondToAcmeChallengeSchema.shape, + params: z.object({ + profileId: z.string().uuid(), + authzId: z.string().uuid() + }), response: { 200: RespondToAcmeChallengeResponseSchema } diff --git a/backend/src/ee/services/pki-acme/pki-acme-schemas.ts b/backend/src/ee/services/pki-acme/pki-acme-schemas.ts index 7e50b2108..935776a8e 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-schemas.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-schemas.ts @@ -1,5 +1,26 @@ import { z } from "zod"; +export enum AcmeIdentifierType { + DNS = "dns" +} + +export enum AcmeOrderStatus { + Pending = "pending", + Processing = "processing", + Ready = "ready", + Valid = "valid", + Invalid = "invalid" +} + +export enum AcmeAuthStatus { + Pending = "pending", + Valid = "valid", + Invalid = "invalid", + Deactivated = "deactivated", + Expired = "expired", + Revoked = "revoked" +} + export const ProtectedHeaderSchema = z .object({ alg: z.string(), @@ -20,13 +41,6 @@ export const RawJwsPayloadSchema = z.object({ signature: z.string() }); -// Directory endpoint -export const GetAcmeDirectorySchema = z.object({ - params: z.object({ - profileId: z.string().uuid() - }) -}); - export const GetAcmeDirectoryResponseSchema = z.object({ newNonce: z.string(), newAccount: z.string(), @@ -34,13 +48,6 @@ export const GetAcmeDirectoryResponseSchema = z.object({ revokeCert: z.string().optional() }); -// New Nonce endpoint -export const GetAcmeNewNonceSchema = z.object({ - params: z.object({ - profileId: z.string().uuid() - }) -}); - // New Account payload schema export const CreateAcmeAccountBodySchema = z.object({ contact: z.array(z.string()).optional(), @@ -73,22 +80,16 @@ export const CreateAcmeAccountResponseSchema = z.object({ export const CreateAcmeOrderBodySchema = z.object({ identifiers: z.array( z.object({ - type: z.string(), - value: z.string() + type: z + .string() + .regex(/^(?!-)[A-Za-z0-9-]{1,63}(?; acmeAccountDAL: Pick; acmeOrderDAL: Pick; + acmeAuthDAL: Pick; }; export const pkiAcmeServiceFactory = ({ certificateProfileDAL, acmeAccountDAL, - acmeOrderDAL + acmeOrderDAL, + acmeAuthDAL }: TPkiAcmeServiceFactoryDep): TPkiAcmeServiceFactory => { const validateAcmeProfile = async (profileId: string): Promise => { const profile = await certificateProfileDAL.findById(profileId); @@ -168,11 +177,11 @@ export const pkiAcmeServiceFactory = ({ }; const getAcmeDirectory = async (profileId: string): Promise => { - await validateAcmeProfile(profileId); + const profile = 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`) + newNonce: buildUrl(`/api/v1/pki/acme/profiles/${profile.id}/new-nonce`), + newAccount: buildUrl(`/api/v1/pki/acme/profiles/${profile.id}/new-account`), + newOrder: buildUrl(`/api/v1/pki/acme/profiles/${profile.id}/new-order`) }; }; @@ -244,15 +253,29 @@ export const pkiAcmeServiceFactory = ({ payload: TCreateAcmeOrderPayload; }): Promise> => { const account = await acmeAccountDAL.findById(profileId, accountId)!; - // TODO: check and see if we have existing orders for this account that meet the criteria // if we do, return the existing order - orders = await acmeOrderDAL.create({ - profileId, - accountId, - status: "pending" + const order = await acmeOrderDAL.create({ + accountId: account.id, + status: AcmeOrderStatus.Pending }); + payload.identifiers.forEach(async (identifier) => { + if (identifier.type === AcmeIdentifierType.DNS) { + // TODO: reuse existing authorizations for this identifier if they exist + const auth = await acmeAuthDAL.create({ + accountId: account.id, + status: AcmeAuthStatus.Pending, + identifierType: identifier.type, + identifierValue: identifier.value, + // TODO: read config from the profile to get the expiration time instead + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000) + }); + } else { + throw new AcmeMalformedError({ detail: "Only DNS identifiers are supported" }); + } + }); + // FIXME: Implement ACME new order creation const orderId = "FIXME-order-id"; return { @@ -262,10 +285,10 @@ export const pkiAcmeServiceFactory = ({ expires: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), identifiers: [], authorizations: [], - finalize: buildUrl(`/api/v1/pki/acme/profiles/${profile.id}/orders/${orderId}/finalize`) + finalize: buildUrl(`/api/v1/pki/acme/profiles/${account.profileId}/orders/${orderId}/finalize`) }, headers: { - Location: buildUrl(`/api/v1/pki/acme/profiles/${profile.id}/orders/${orderId}`) + Location: buildUrl(`/api/v1/pki/acme/profiles/${account.profileId}/orders/${orderId}`) } }; }; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 49ac7750d..376baaaae 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -74,6 +74,7 @@ import { pamSessionServiceFactory } from "@app/ee/services/pam-session/pam-sessi import { permissionDALFactory } from "@app/ee/services/permission/permission-dal"; import { permissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { pitServiceFactory } from "@app/ee/services/pit/pit-service"; +import { pkiAcmeAuthDALFactory } from "@app/ee/services/pki-acme/pki-acme-auth-dal"; import { pkiAcmeServiceFactory } from "@app/ee/services/pki-acme/pki-acme-service"; import { projectTemplateDALFactory } from "@app/ee/services/project-template/project-template-dal"; import { projectTemplateServiceFactory } from "@app/ee/services/project-template/project-template-service"; @@ -350,6 +351,7 @@ import { workflowIntegrationDALFactory } from "@app/services/workflow-integratio import { workflowIntegrationServiceFactory } from "@app/services/workflow-integration/workflow-integration-service"; import { pkiAcmeAccountDALFactory } from "@app/ee/services/pki-acme/pki-acme-account-dal"; +import { pkiAcmeOrderDALFactory } from "@app/ee/services/pki-acme/pki-acme-order-dal"; import { injectAuditLogInfo } from "../plugins/audit-log"; import { injectAssumePrivilege } from "../plugins/auth/inject-assume-privilege"; import { injectIdentity } from "../plugins/auth/inject-identity"; @@ -361,7 +363,6 @@ import { initializeOauthConfigSync } from "./v1/sso-router"; import { registerV2Routes } from "./v2"; import { registerV3Routes } from "./v3"; import { registerV4Routes } from "./v4"; -import { pkiAcmeOrderDALFactory } from "@app/ee/services/pki-acme/pki-acme-order-dal"; const histogram = monitorEventLoopDelay({ resolution: 20 }); histogram.enable(); @@ -1068,7 +1069,7 @@ export const registerRoutes = async ( const acmeEnrollmentConfigDAL = acmeEnrollmentConfigDALFactory(db); const acmeAccountDAL = pkiAcmeAccountDALFactory(db); const acmeOrderDAL = pkiAcmeOrderDALFactory(db); - + const acmeAuthDAL = pkiAcmeAuthDALFactory(db); const certificateDAL = certificateDALFactory(db); const certificateBodyDAL = certificateBodyDALFactory(db); const certificateSecretDAL = certificateSecretDALFactory(db); @@ -1172,7 +1173,8 @@ export const registerRoutes = async ( const pkiAcmeService = pkiAcmeServiceFactory({ certificateProfileDAL, acmeAccountDAL, - acmeOrderDAL + acmeOrderDAL, + acmeAuthDAL }); const pkiAlertService = pkiAlertServiceFactory({ From d880c4c6c9e97834f6c9669760841dee33241937 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Wed, 29 Oct 2025 14:17:06 -0700 Subject: [PATCH 048/231] Get all authorizations --- .../ee/services/pki-acme/pki-acme-service.ts | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) 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 20cc5f21d..9e1167aec 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -260,21 +260,23 @@ export const pkiAcmeServiceFactory = ({ accountId: account.id, status: AcmeOrderStatus.Pending }); - payload.identifiers.forEach(async (identifier) => { - if (identifier.type === AcmeIdentifierType.DNS) { - // TODO: reuse existing authorizations for this identifier if they exist - const auth = await acmeAuthDAL.create({ - accountId: account.id, - status: AcmeAuthStatus.Pending, - identifierType: identifier.type, - identifierValue: identifier.value, - // TODO: read config from the profile to get the expiration time instead - expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000) - }); - } else { - throw new AcmeMalformedError({ detail: "Only DNS identifiers are supported" }); - } - }); + const authorizations = await Promise.all( + payload.identifiers.map(async (identifier) => { + if (identifier.type === AcmeIdentifierType.DNS) { + // TODO: reuse existing authorizations for this identifier if they exist + return await acmeAuthDAL.create({ + accountId: account.id, + status: AcmeAuthStatus.Pending, + identifierType: identifier.type, + identifierValue: identifier.value, + // TODO: read config from the profile to get the expiration time instead + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000) + }); + } else { + throw new AcmeMalformedError({ detail: "Only DNS identifiers are supported" }); + } + }) + ); // FIXME: Implement ACME new order creation const orderId = "FIXME-order-id"; From 76505939f3389d98adb395125f9ffe4271c880a2 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Wed, 29 Oct 2025 16:13:51 -0700 Subject: [PATCH 049/231] Add acme order table --- backend/src/@types/knex.d.ts | 5 ++++ ...acme.ts => 20251029234547_add-pki-acme.ts} | 25 +++++++++++++++++ backend/src/db/schemas/index.ts | 1 + backend/src/db/schemas/models.ts | 1 + .../src/db/schemas/pki-acme-order-auths.ts | 20 ++++++++++++++ .../pki-acme/pki-acme-order-auth-dal.ts | 27 +++++++++++++++++++ .../ee/services/pki-acme/pki-acme-service.ts | 15 +++++++++-- backend/src/server/routes/index.ts | 5 +++- 8 files changed, 96 insertions(+), 3 deletions(-) rename backend/src/db/migrations/{20251027234547_add-pki-acme.ts => 20251029234547_add-pki-acme.ts} (89%) create mode 100644 backend/src/db/schemas/pki-acme-order-auths.ts create mode 100644 backend/src/ee/services/pki-acme/pki-acme-order-auth-dal.ts diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 8a19670f0..1daf722a1 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -728,6 +728,11 @@ declare module "knex/types/tables" { TPkiAcmeOrdersUpdate >; [TableName.PkiAcmeAuth]: KnexOriginal.CompositeTableType; + [TableName.PkiAcmeOrderAuth]: KnexOriginal.CompositeTableType< + TPkiAcmeOrderAuths, + TPkiAcmeOrderAuthsInsert, + TPkiAcmeOrderAuthsUpdate + >; [TableName.PkiAcmeChallenge]: KnexOriginal.CompositeTableType< TPkiAcmeChallenges, TPkiAcmeChallengesInsert, diff --git a/backend/src/db/migrations/20251027234547_add-pki-acme.ts b/backend/src/db/migrations/20251029234547_add-pki-acme.ts similarity index 89% rename from backend/src/db/migrations/20251027234547_add-pki-acme.ts rename to backend/src/db/migrations/20251029234547_add-pki-acme.ts index 285676c8f..6390e5812 100644 --- a/backend/src/db/migrations/20251027234547_add-pki-acme.ts +++ b/backend/src/db/migrations/20251029234547_add-pki-acme.ts @@ -116,6 +116,25 @@ export async function up(knex: Knex): Promise { await createOnUpdateTrigger(knex, TableName.PkiAcmeAuth); } + // Create PkiAcmeOrderAuth table + if (!(await knex.schema.hasTable(TableName.PkiAcmeOrderAuth))) { + await knex.schema.createTable(TableName.PkiAcmeOrderAuth, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + + // Foreign key to PkiAcmeOrder + t.uuid("orderId").notNullable(); + t.foreign("orderId").references("id").inTable(TableName.PkiAcmeOrder).onDelete("CASCADE"); + + // Foreign key to PkiAcmeAuth + t.uuid("authId").notNullable(); + t.foreign("authId").references("id").inTable(TableName.PkiAcmeAuth).onDelete("CASCADE"); + + t.timestamps(true, true, true); + }); + + await createOnUpdateTrigger(knex, TableName.PkiAcmeOrderAuth); + } + // Create PkiAcmeChallenge table if (!(await knex.schema.hasTable(TableName.PkiAcmeChallenge))) { await knex.schema.createTable(TableName.PkiAcmeChallenge, (t) => { @@ -150,6 +169,12 @@ export async function down(knex: Knex): Promise { await dropOnUpdateTrigger(knex, TableName.PkiAcmeChallenge); } + // Drop PkiAcmeOrderAuth (depends on PkiAcmeOrder and PkiAcmeAuth) + if (await knex.schema.hasTable(TableName.PkiAcmeOrderAuth)) { + await knex.schema.dropTable(TableName.PkiAcmeOrderAuth); + await dropOnUpdateTrigger(knex, TableName.PkiAcmeOrderAuth); + } + // Drop PkiAcmeAuth (depends on PkiAcmeAccount and Certificate) if (await knex.schema.hasTable(TableName.PkiAcmeAuth)) { await knex.schema.dropTable(TableName.PkiAcmeAuth); diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 3eef75d16..e3db789ac 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -97,6 +97,7 @@ export * from "./pki-acme-auths"; export * from "./pki-acme-challenges"; export * from "./pki-acme-enrollment-configs"; export * from "./pki-acme-orders"; +export * from "./pki-acme-order-auths"; export * from "./pki-alerts"; export * from "./pki-api-enrollment-configs"; export * from "./pki-certificate-profiles"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 0fcda08d7..a031c83b9 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -30,6 +30,7 @@ export enum TableName { PkiAcmeEnrollmentConfig = "pki_acme_enrollment_configs", PkiAcmeAccount = "pki_acme_accounts", PkiAcmeOrder = "pki_acme_orders", + PkiAcmeOrderAuth = "pki_acme_order_auths", PkiAcmeAuth = "pki_acme_auths", PkiAcmeChallenge = "pki_acme_challenges", PkiSubscriber = "pki_subscribers", diff --git a/backend/src/db/schemas/pki-acme-order-auths.ts b/backend/src/db/schemas/pki-acme-order-auths.ts new file mode 100644 index 000000000..66f8704f2 --- /dev/null +++ b/backend/src/db/schemas/pki-acme-order-auths.ts @@ -0,0 +1,20 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const PkiAcmeOrderAuthsSchema = z.object({ + id: z.string().uuid(), + orderId: z.string().uuid(), + authId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TPkiAcmeOrderAuths = z.infer; +export type TPkiAcmeOrderAuthsInsert = Omit, TImmutableDBKeys>; +export type TPkiAcmeOrderAuthsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/ee/services/pki-acme/pki-acme-order-auth-dal.ts b/backend/src/ee/services/pki-acme/pki-acme-order-auth-dal.ts new file mode 100644 index 000000000..87b6f8211 --- /dev/null +++ b/backend/src/ee/services/pki-acme/pki-acme-order-auth-dal.ts @@ -0,0 +1,27 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { TPkiAcmeOrderAuthsInsert } from "@app/db/schemas/pki-acme-order-auths"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify } from "@app/lib/knex"; + +export type TPkiAcmeOrderAuthDALFactory = ReturnType; + +export const pkiAcmeOrderAuthDALFactory = (db: TDbClient) => { + const pkiAcmeOrderAuthOrm = ormify(db, TableName.PkiAcmeOrderAuth); + + const insertMany = async (rows: TPkiAcmeOrderAuthsInsert[], tx?: Knex) => { + try { + const result = await (tx || db)(TableName.PkiAcmeOrderAuth).insert(rows).returning("*"); + return result; + } catch (error) { + throw new DatabaseError({ error, name: "Insert many PKI ACME order auths" }); + } + }; + + return { + ...pkiAcmeOrderAuthOrm, + insertMany + }; +}; 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 9e1167aec..e8967a9ac 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -11,6 +11,7 @@ import { } from "./pki-acme-errors"; import { TPkiAcmeAccounts } from "@app/db/schemas/pki-acme-accounts"; +import { TPkiAcmeAuths } from "@app/db/schemas/pki-acme-auths"; import { logger } from "@app/lib/logger"; import { EnrollmentType, @@ -21,6 +22,7 @@ import { z, ZodError } from "zod"; import { TPkiAcmeAccountDALFactory } from "./pki-acme-account-dal"; import { TPkiAcmeAuthDALFactory } from "./pki-acme-auth-dal"; import { TPkiAcmeOrderDALFactory } from "./pki-acme-order-dal"; +import { TPkiAcmeOrderAuthDALFactory } from "./pki-acme-order-auth-dal"; import { AcmeAuthStatus, AcmeIdentifierType, @@ -54,13 +56,15 @@ type TPkiAcmeServiceFactoryDep = { acmeAccountDAL: Pick; acmeOrderDAL: Pick; acmeAuthDAL: Pick; + acmeOrderAuthDAL: Pick; }; export const pkiAcmeServiceFactory = ({ certificateProfileDAL, acmeAccountDAL, acmeOrderDAL, - acmeAuthDAL + acmeAuthDAL, + acmeOrderAuthDAL }: TPkiAcmeServiceFactoryDep): TPkiAcmeServiceFactory => { const validateAcmeProfile = async (profileId: string): Promise => { const profile = await certificateProfileDAL.findById(profileId); @@ -260,7 +264,7 @@ export const pkiAcmeServiceFactory = ({ accountId: account.id, status: AcmeOrderStatus.Pending }); - const authorizations = await Promise.all( + const authorizations: TPkiAcmeAuths[] = await Promise.all( payload.identifiers.map(async (identifier) => { if (identifier.type === AcmeIdentifierType.DNS) { // TODO: reuse existing authorizations for this identifier if they exist @@ -278,6 +282,13 @@ export const pkiAcmeServiceFactory = ({ }) ); + await acmeOrderAuthDAL.insertMany( + authorizations.map((auth) => ({ + orderId: order.id, + authId: auth.id + })) + ); + // FIXME: Implement ACME new order creation const orderId = "FIXME-order-id"; return { diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 376baaaae..40ffe4f5d 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -76,6 +76,7 @@ import { permissionServiceFactory } from "@app/ee/services/permission/permission import { pitServiceFactory } from "@app/ee/services/pit/pit-service"; import { pkiAcmeAuthDALFactory } from "@app/ee/services/pki-acme/pki-acme-auth-dal"; import { pkiAcmeServiceFactory } from "@app/ee/services/pki-acme/pki-acme-service"; +import { pkiAcmeOrderAuthDALFactory } from "@app/ee/services/pki-acme/pki-acme-order-auth-dal"; import { projectTemplateDALFactory } from "@app/ee/services/project-template/project-template-dal"; import { projectTemplateServiceFactory } from "@app/ee/services/project-template/project-template-service"; import { rateLimitDALFactory } from "@app/ee/services/rate-limit/rate-limit-dal"; @@ -1070,6 +1071,7 @@ export const registerRoutes = async ( const acmeAccountDAL = pkiAcmeAccountDALFactory(db); const acmeOrderDAL = pkiAcmeOrderDALFactory(db); const acmeAuthDAL = pkiAcmeAuthDALFactory(db); + const acmeOrderAuthDAL = pkiAcmeOrderAuthDALFactory(db); const certificateDAL = certificateDALFactory(db); const certificateBodyDAL = certificateBodyDALFactory(db); const certificateSecretDAL = certificateSecretDALFactory(db); @@ -1174,7 +1176,8 @@ export const registerRoutes = async ( certificateProfileDAL, acmeAccountDAL, acmeOrderDAL, - acmeAuthDAL + acmeAuthDAL, + acmeOrderAuthDAL }); const pkiAlertService = pkiAlertServiceFactory({ From e2916cb0f7cd916f9336b17b36f37bb45c047e3d Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Wed, 29 Oct 2025 16:40:46 -0700 Subject: [PATCH 050/231] Fix acme config creation --- backend/bdd/features/steps/pki_acme.py | 2 +- .../routes/v1/certificate-profiles-router.ts | 22 +++++++++++++++++-- .../certificate-profile-service.ts | 2 +- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/backend/bdd/features/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py index 8546cb217..7e1cad457 100644 --- a/backend/bdd/features/steps/pki_acme.py +++ b/backend/bdd/features/steps/pki_acme.py @@ -36,7 +36,7 @@ def step_impl(context: Context, profile_var: str): # TODO: Fixed value for now, just to make test much easier, # we should call infisical API to create such profile instead # in the future - profile_id = "dd6e09c8-d5b8-4bfd-b436-4ab5c93d5d7e" + profile_id = "322be4ee-fe20-41c0-ba7c-bdbdfeee2ba8" context.vars[profile_var] = AcmeProfile(profile_id) diff --git a/backend/src/server/routes/v1/certificate-profiles-router.ts b/backend/src/server/routes/v1/certificate-profiles-router.ts index 08f532bc4..7ad7aaeb1 100644 --- a/backend/src/server/routes/v1/certificate-profiles-router.ts +++ b/backend/src/server/routes/v1/certificate-profiles-router.ts @@ -44,7 +44,8 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid autoRenew: z.boolean().default(false), renewBeforeDays: z.number().min(1).max(30).optional() }) - .optional() + .optional(), + acmeConfig: z.object({}).optional() }) .refine( (data) => { @@ -55,6 +56,9 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid if (data.apiConfig) { return false; } + if (data.acmeConfig) { + return false; + } } if (data.enrollmentType === EnrollmentType.API) { if (!data.apiConfig) { @@ -63,12 +67,26 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid if (data.estConfig) { return false; } + if (data.acmeConfig) { + return false; + } + } + if (data.enrollmentType === EnrollmentType.ACME) { + if (!data.acmeConfig) { + return false; + } + if (data.estConfig) { + return false; + } + if (data.apiConfig) { + return false; + } } return true; }, { message: - "EST enrollment type requires EST configuration and cannot have API configuration. API enrollment type requires API configuration and cannot have EST configuration." + "EST enrollment type requires EST configuration and cannot have API or ACME configuration. API enrollment type requires API configuration and cannot have EST or ACME configuration. ACME enrollment type requires ACME configuration and cannot have EST or API configuration." } ), response: { diff --git a/backend/src/services/certificate-profile/certificate-profile-service.ts b/backend/src/services/certificate-profile/certificate-profile-service.ts index 0de9d6c55..527ed3c96 100644 --- a/backend/src/services/certificate-profile/certificate-profile-service.ts +++ b/backend/src/services/certificate-profile/certificate-profile-service.ts @@ -275,7 +275,7 @@ export const certificateProfileServiceFactory = ({ } // Create the profile with the created config IDs - const { estConfig, apiConfig, ...profileData } = data; + const { estConfig, apiConfig, acmeConfig, ...profileData } = data; const profileResult = await certificateProfileDAL.create( { ...profileData, From 53555892d4c1efb4ab031cb9fcd7bd8c894d14ca Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Wed, 29 Oct 2025 17:35:57 -0700 Subject: [PATCH 051/231] Add new order feature --- .../bdd/features/pki/acme/new-order.feature | 17 ++++++ backend/bdd/features/steps/pki_acme.py | 53 +++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 backend/bdd/features/pki/acme/new-order.feature diff --git a/backend/bdd/features/pki/acme/new-order.feature b/backend/bdd/features/pki/acme/new-order.feature new file mode 100644 index 000000000..5ec9620e4 --- /dev/null +++ b/backend/bdd/features/pki/acme/new-order.feature @@ -0,0 +1,17 @@ +Feature: New Order + + Scenario: Create a new order +# Given I have an ACME cert profile as "acme_profile" +# When I have an ACME client connecting to {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory +# # TODO: make it I have an account already instead? +# Then I register a new ACME account with email fangpen@infisical.com and EAB key id {acme_profile.eab_kid} with secret {acme_profile.eab_secret} as acme_account + When I create certificate signing request as csr + Then I add names to certificate signing request csr + """ + { + "ORGANIZATION_NAME": "Infisical Inc", + "COMMON_NAME": "localhost" + } + """ + Then I create a RSA private key pair as cert_key + Then I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format diff --git a/backend/bdd/features/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py index 7e1cad457..d3f0c109e 100644 --- a/backend/bdd/features/steps/pki_acme.py +++ b/backend/bdd/features/steps/pki_acme.py @@ -9,6 +9,9 @@ from behave import then from josepy.jwk import JWKRSA from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography import x509 +from cryptography.x509.oid import NameOID +from cryptography.hazmat.primitives import hashes ACC_KEY_BITS = 2048 ACC_KEY_PUBLIC_EXPONENT = 65537 @@ -93,3 +96,53 @@ def step_impl(context: Context, email: str, kid: str, secret: str, account_var: # TODO: add EAB info here registration = messages.NewRegistration.from_data(email=email) context.vars[account_var] = context.acme_client.new_account(registration) + + +@when("I create certificate signing request as {csr_var}") +def step_impl(context: Context, csr_var: str): + context.vars[csr_var] = x509.CertificateSigningRequestBuilder() + + +@then("I add names to certificate signing request {csr_var}") +def step_impl(context: Context, csr_var: str): + names = json.loads(context.text) + builder: x509.CertificateSigningRequestBuilder = context.vars[csr_var] + builder.subject_name( + x509.Name( + [ + x509.NameAttribute(getattr(NameOID, name), value) + for name, value in names.items() + ] + ) + ) + + +@then("I add subject alternative name to certificate signing request {csr_var}") +def step_impl(context: Context, csr_var: str): + names = json.loads(context.text) + builder: x509.CertificateSigningRequestBuilder = context.vars[csr_var] + builder.add_extension( + x509.SubjectAlternativeName([x509.DNSName(name) for name in names]), + critical=False, + ) + + +@then("I create a RSA private key pair as {rsa_key_var}") +def step_impl(context: Context, rsa_key_var: str): + context.vars[rsa_key_var] = rsa.generate_private_key( + # TODO: make them configurable if we need to + public_exponent=65537, + key_size=2048, + ) + + +@then( + "I sign the certificate signing request {csr_var} with private key {pk_var} and output it as {pem_var} in PEM format" +) +def step_impl(context: Context, csr_var: str, pk_var: str, pem_var: str): + context.vars[pem_var] = ( + context.vars[csr_var] + .sign(context.vars[pk_var], hashes.SHA256()) + .public_bytes(serialization.Encoding.PEM) + .decode("utf-8") + ) From 9ee9aa32615d46181ac46139d13a5d52d730c70e Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Wed, 29 Oct 2025 17:42:34 -0700 Subject: [PATCH 052/231] More for new order --- .../bdd/features/pki/acme/new-order.feature | 7 +- backend/bdd/features/steps/pki_acme.py | 8 +- .../ee/services/pki-acme/pki-acme-service.ts | 85 +++++++++++-------- 3 files changed, 61 insertions(+), 39 deletions(-) diff --git a/backend/bdd/features/pki/acme/new-order.feature b/backend/bdd/features/pki/acme/new-order.feature index 5ec9620e4..ebc0961ee 100644 --- a/backend/bdd/features/pki/acme/new-order.feature +++ b/backend/bdd/features/pki/acme/new-order.feature @@ -1,10 +1,10 @@ Feature: New Order Scenario: Create a new order -# Given I have an ACME cert profile as "acme_profile" -# When I have an ACME client connecting to {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory + Given I have an ACME cert profile as "acme_profile" + When I have an ACME client connecting to {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory # # TODO: make it I have an account already instead? -# Then I register a new ACME account with email fangpen@infisical.com and EAB key id {acme_profile.eab_kid} with secret {acme_profile.eab_secret} as acme_account + Then I register a new ACME account with email fangpen@infisical.com and EAB key id {acme_profile.eab_kid} with secret {acme_profile.eab_secret} as acme_account When I create certificate signing request as csr Then I add names to certificate signing request csr """ @@ -15,3 +15,4 @@ Feature: New Order """ Then I create a RSA private key pair as cert_key Then I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format + Then I submit the certificate signing request PEM csr_pem certificate order to the ACME server diff --git a/backend/bdd/features/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py index d3f0c109e..254ea4af4 100644 --- a/backend/bdd/features/steps/pki_acme.py +++ b/backend/bdd/features/steps/pki_acme.py @@ -98,6 +98,13 @@ def step_impl(context: Context, email: str, kid: str, secret: str, account_var: context.vars[account_var] = context.acme_client.new_account(registration) +@then( + "I submit the certificate signing request PEM {pem_var} certificate order to the ACME server" +) +def step_impl(context: Context, pem_var: str): + context.acme_order = context.acme_client.new_order(context.vars[pem_var]) + + @when("I create certificate signing request as {csr_var}") def step_impl(context: Context, csr_var: str): context.vars[csr_var] = x509.CertificateSigningRequestBuilder() @@ -144,5 +151,4 @@ def step_impl(context: Context, csr_var: str, pk_var: str, pem_var: str): context.vars[csr_var] .sign(context.vars[pk_var], hashes.SHA256()) .public_bytes(serialization.Encoding.PEM) - .decode("utf-8") ) 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 e8967a9ac..098046ffc 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -54,7 +54,7 @@ import { type TPkiAcmeServiceFactoryDep = { certificateProfileDAL: Pick; acmeAccountDAL: Pick; - acmeOrderDAL: Pick; + acmeOrderDAL: Pick; acmeAuthDAL: Pick; acmeOrderAuthDAL: Pick; }; @@ -256,52 +256,67 @@ export const pkiAcmeServiceFactory = ({ accountId: string; payload: TCreateAcmeOrderPayload; }): Promise> => { - const account = await acmeAccountDAL.findById(profileId, accountId)!; // TODO: check and see if we have existing orders for this account that meet the criteria // if we do, return the existing order - const order = await acmeOrderDAL.create({ - accountId: account.id, - status: AcmeOrderStatus.Pending + const order = await acmeOrderDAL.transaction(async (tx) => { + const account = await acmeAccountDAL.findById(profileId, accountId)!; + const createdOrder = await acmeOrderDAL.create( + { + accountId: account.id, + status: AcmeOrderStatus.Pending + }, + tx + ); + const authorizations: TPkiAcmeAuths[] = await Promise.all( + payload.identifiers.map(async (identifier) => { + if (identifier.type === AcmeIdentifierType.DNS) { + // TODO: reuse existing authorizations for this identifier if they exist + return await acmeAuthDAL.create({ + accountId: account.id, + status: AcmeAuthStatus.Pending, + identifierType: identifier.type, + identifierValue: identifier.value, + // TODO: read config from the profile to get the expiration time instead + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000) + }); + } else { + throw new AcmeMalformedError({ detail: "Only DNS identifiers are supported" }); + } + }) + ); + + await acmeOrderAuthDAL.insertMany( + authorizations.map((auth) => ({ + orderId: order.id, + authId: auth.id + })) + ); + return { ...createdOrder, authorizations, account }; }); - const authorizations: TPkiAcmeAuths[] = await Promise.all( - payload.identifiers.map(async (identifier) => { - if (identifier.type === AcmeIdentifierType.DNS) { - // TODO: reuse existing authorizations for this identifier if they exist - return await acmeAuthDAL.create({ - accountId: account.id, - status: AcmeAuthStatus.Pending, - identifierType: identifier.type, - identifierValue: identifier.value, - // TODO: read config from the profile to get the expiration time instead - expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000) - }); - } else { - throw new AcmeMalformedError({ detail: "Only DNS identifiers are supported" }); - } - }) - ); - await acmeOrderAuthDAL.insertMany( - authorizations.map((auth) => ({ - orderId: order.id, - authId: auth.id - })) - ); - - // FIXME: Implement ACME new order creation - const orderId = "FIXME-order-id"; return { status: 201, body: { status: "pending", + // TODO: read config from the profile to get the expiration time instead expires: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), - identifiers: [], - authorizations: [], - finalize: buildUrl(`/api/v1/pki/acme/profiles/${account.profileId}/orders/${orderId}/finalize`) + identifiers: order.authorizations.map((auth) => ({ + type: auth.identifierType, + value: auth.identifierValue + })), + authorizations: order.authorizations.map((auth) => ({ + id: auth.id, + status: auth.status, + identifier: { + type: auth.identifierType, + value: auth.identifierValue + } + })), + finalize: buildUrl(`/api/v1/pki/acme/profiles/${order.account.profileId}/orders/${order.id}/finalize`) }, headers: { - Location: buildUrl(`/api/v1/pki/acme/profiles/${account.profileId}/orders/${orderId}`) + Location: buildUrl(`/api/v1/pki/acme/profiles/${order.account.profileId}/orders/${order.id}`) } }; }; From 3d58702a4f097b891e25ffde0506a332e86d220a Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Wed, 29 Oct 2025 17:48:29 -0700 Subject: [PATCH 053/231] Fix bdd --- backend/bdd/features/steps/pki_acme.py | 4 ++-- backend/src/ee/services/pki-acme/pki-acme-schemas.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/backend/bdd/features/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py index 254ea4af4..e08212b9e 100644 --- a/backend/bdd/features/steps/pki_acme.py +++ b/backend/bdd/features/steps/pki_acme.py @@ -114,7 +114,7 @@ def step_impl(context: Context, csr_var: str): def step_impl(context: Context, csr_var: str): names = json.loads(context.text) builder: x509.CertificateSigningRequestBuilder = context.vars[csr_var] - builder.subject_name( + context.vars[csr_var] = builder.subject_name( x509.Name( [ x509.NameAttribute(getattr(NameOID, name), value) @@ -128,7 +128,7 @@ def step_impl(context: Context, csr_var: str): def step_impl(context: Context, csr_var: str): names = json.loads(context.text) builder: x509.CertificateSigningRequestBuilder = context.vars[csr_var] - builder.add_extension( + context[csr_var] = builder.add_extension( x509.SubjectAlternativeName([x509.DNSName(name) for name in names]), critical=False, ) diff --git a/backend/src/ee/services/pki-acme/pki-acme-schemas.ts b/backend/src/ee/services/pki-acme/pki-acme-schemas.ts index 935776a8e..ec2197466 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-schemas.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-schemas.ts @@ -80,10 +80,10 @@ export const CreateAcmeAccountResponseSchema = z.object({ export const CreateAcmeOrderBodySchema = z.object({ identifiers: z.array( z.object({ - type: z + type: z.enum(Object.values(AcmeIdentifierType) as [string, ...string[]]), + value: z .string() - .regex(/^(?!-)[A-Za-z0-9-]{1,63}(? Date: Wed, 29 Oct 2025 18:05:24 -0700 Subject: [PATCH 054/231] Add error handling --- backend/src/ee/services/pki-acme/pki-acme-service.ts | 2 +- backend/src/server/plugins/error-handler.ts | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) 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 098046ffc..bcb3f07c7 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -288,7 +288,7 @@ export const pkiAcmeServiceFactory = ({ await acmeOrderAuthDAL.insertMany( authorizations.map((auth) => ({ - orderId: order.id, + orderId: createdOrder.id, authId: auth.id })) ); diff --git a/backend/src/server/plugins/error-handler.ts b/backend/src/server/plugins/error-handler.ts index 8d10a8630..50251377d 100644 --- a/backend/src/server/plugins/error-handler.ts +++ b/backend/src/server/plugins/error-handler.ts @@ -5,6 +5,7 @@ import fastifyPlugin from "fastify-plugin"; import jwt from "jsonwebtoken"; import { ZodError } from "zod"; +import { AcmeError } from "@app/ee/services/pki-acme/pki-acme-errors"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError, @@ -242,6 +243,16 @@ export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider error: "TokenError", message: errorMessage }); + } else if (error instanceof AcmeError) { + void res + .type("application/problem+json") + .status(error.status) + .send({ + status: error.status, + type: `urn:ietf:params:acme:error:${error.type}`, + detail: error.detail + // TODO: add subproblems if they exist + }); } else { void res.status(HttpStatusCodes.InternalServerError).send({ reqId: req.id, From 96cb47319283b6e2a527ae880310b77771460c3b Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Wed, 29 Oct 2025 18:25:26 -0700 Subject: [PATCH 055/231] Fix new order resp --- .../ee/services/pki-acme/pki-acme-service.ts | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) 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 bcb3f07c7..f6400e181 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -208,6 +208,7 @@ export const pkiAcmeServiceFactory = ({ payload: TCreateAcmeAccountPayload; }): Promise> => { const profile = await validateAcmeProfile(profileId); + // TODO: ensure unique account per public key const existingAccount: TPkiAcmeAccounts | null = await acmeAccountDAL.findByPublicKey(profileId, alg, jwk); if (onlyReturnExisting && !existingAccount) { throw new AcmeAccountDoesNotExistError({ message: "ACME account not found" }); @@ -272,14 +273,17 @@ export const pkiAcmeServiceFactory = ({ payload.identifiers.map(async (identifier) => { if (identifier.type === AcmeIdentifierType.DNS) { // TODO: reuse existing authorizations for this identifier if they exist - return await acmeAuthDAL.create({ - accountId: account.id, - status: AcmeAuthStatus.Pending, - identifierType: identifier.type, - identifierValue: identifier.value, - // TODO: read config from the profile to get the expiration time instead - expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000) - }); + return await acmeAuthDAL.create( + { + accountId: account.id, + status: AcmeAuthStatus.Pending, + identifierType: identifier.type, + identifierValue: identifier.value, + // TODO: read config from the profile to get the expiration time instead + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000) + }, + tx + ); } else { throw new AcmeMalformedError({ detail: "Only DNS identifiers are supported" }); } @@ -290,7 +294,8 @@ export const pkiAcmeServiceFactory = ({ authorizations.map((auth) => ({ orderId: createdOrder.id, authId: auth.id - })) + })), + tx ); return { ...createdOrder, authorizations, account }; }); @@ -301,18 +306,13 @@ export const pkiAcmeServiceFactory = ({ status: "pending", // TODO: read config from the profile to get the expiration time instead expires: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), - identifiers: order.authorizations.map((auth) => ({ + identifiers: order.authorizations.map((auth: TPkiAcmeAuths) => ({ type: auth.identifierType, value: auth.identifierValue })), - authorizations: order.authorizations.map((auth) => ({ - id: auth.id, - status: auth.status, - identifier: { - type: auth.identifierType, - value: auth.identifierValue - } - })), + authorizations: order.authorizations.map((auth: TPkiAcmeAuths) => + buildUrl(`/api/v1/pki/acme/profiles/${order.account.profileId}/authorizations/${auth.id}`) + ), finalize: buildUrl(`/api/v1/pki/acme/profiles/${order.account.profileId}/orders/${order.id}/finalize`) }, headers: { From 61af9734b4359c8e539b54ae28915e0aad1bd8ee Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Wed, 29 Oct 2025 18:33:41 -0700 Subject: [PATCH 056/231] Refactor other endpoints and service methods --- .../ee/services/pki-acme/pki-acme-service.ts | 183 ++++++++++++------ .../ee/services/pki-acme/pki-acme-types.ts | 54 +++++- 2 files changed, 173 insertions(+), 64 deletions(-) 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 f6400e181..a72ef2b0f 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -321,96 +321,171 @@ export const pkiAcmeServiceFactory = ({ }; }; - const deactivateAcmeAccount = async ( - profileId: string, - accountId: string, - payload?: TDeactivateAcmeAccountPayload - ): Promise => { + const deactivateAcmeAccount = async ({ + profileId, + accountId, + payload: { status } = { status: "deactivated" } + }: { + profileId: string; + accountId: string; + payload?: TDeactivateAcmeAccountPayload; + }): Promise> => { const profile = await validateAcmeProfile(profileId); // FIXME: Implement ACME account deactivation return { - status: "deactivated" + status: 200, + body: { + status: "deactivated" + }, + headers: { + Location: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/accounts/${accountId}`) + } }; }; - const listAcmeOrders = async (profileId: string, accountId: string): Promise => { + const listAcmeOrders = async ({ + profileId, + accountId + }: { + profileId: string; + accountId: string; + }): Promise> => { const profile = await validateAcmeProfile(profileId); // FIXME: Implement ACME list orders return { - orders: [] + status: 200, + body: { + orders: [] + }, + headers: { + Location: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/accounts/${accountId}/orders`) + } }; }; - const getAcmeOrder = async (profileId: string, orderId: string): Promise => { + const getAcmeOrder = async ({ + profileId, + orderId + }: { + profileId: string; + orderId: string; + }): Promise> => { 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`) + status: 200, + body: { + 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`) + }, + headers: { Location: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/orders/${orderId}`) } }; }; - const finalizeAcmeOrder = async ( - profileId: string, - orderId: string, - payload: TFinalizeAcmeOrderPayload - ): Promise => { + const finalizeAcmeOrder = async ({ + profileId, + orderId, + payload + }: { + profileId: string; + orderId: string; + payload: TFinalizeAcmeOrderPayload; + }): Promise> => { 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`) + status: 200, + body: { + 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`) + }, + headers: { + Location: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/orders/${orderId}`) + } }; }; - const downloadAcmeCertificate = async (profileId: string, orderId: string): Promise => { + const downloadAcmeCertificate = async ({ + profileId, + orderId + }: { + profileId: string; + orderId: string; + }): Promise> => { 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 => { - 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" - } - ] + status: 200, + body: "FIXME-certificate-pem", + headers: { + Location: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/orders/${orderId}/certificate`) + } }; }; - const respondToAcmeChallenge = async ( - profileId: string, - authzId: string - ): Promise => { + const getAcmeAuthorization = async ({ + profileId, + authzId + }: { + profileId: string; + authzId: string; + }): Promise> => { + const profile = await validateAcmeProfile(profileId); + // FIXME: Implement ACME authorization retrieval + return { + status: 200, + body: { + 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" + } + ] + }, + headers: { + Location: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/authorizations/${authzId}`) + } + }; + }; + + const respondToAcmeChallenge = async ({ + profileId, + authzId + }: { + profileId: string; + authzId: string; + }): Promise> => { 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" + status: 200, + body: { + type: "http-01", + url: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/authorizations/${authzId}/challenges/http-01`), + status: "pending", + token: "FIXME-challenge-token" + }, + headers: { + Location: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/authorizations/${authzId}/challenges/http-01`) + } }; }; 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 7051222fc..2a923d1a5 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-types.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-types.ts @@ -90,14 +90,48 @@ export type TPkiAcmeServiceFactory = { accountId: string, body?: TDeactivateAcmeAccountPayload ) => Promise; - listAcmeOrders: (profileId: string, accountId: string) => Promise; - getAcmeOrder: (profileId: string, orderId: string) => Promise; - finalizeAcmeOrder: ( - profileId: string, - orderId: string, - body: TFinalizeAcmeOrderPayload - ) => Promise; - downloadAcmeCertificate: (profileId: string, orderId: string) => Promise; - getAcmeAuthorization: (profileId: string, authzId: string) => Promise; - respondToAcmeChallenge: (profileId: string, authzId: string) => Promise; + listAcmeOrders: ({ + profileId, + accountId + }: { + profileId: string; + accountId: string; + }) => Promise>; + getAcmeOrder: ({ + profileId, + orderId + }: { + profileId: string; + orderId: string; + }) => Promise>; + finalizeAcmeOrder: ({ + profileId, + orderId, + payload + }: { + profileId: string; + orderId: string; + payload: TFinalizeAcmeOrderPayload; + }) => Promise>; + downloadAcmeCertificate: ({ + profileId, + orderId + }: { + profileId: string; + orderId: string; + }) => Promise>; + getAcmeAuthorization: ({ + profileId, + authzId + }: { + profileId: string; + authzId: string; + }) => Promise>; + respondToAcmeChallenge: ({ + profileId, + authzId + }: { + profileId: string; + authzId: string; + }) => Promise>; }; From dd5bd35ad405a1620390cb01bdf4c41bdb6c42ac Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Wed, 29 Oct 2025 18:36:32 -0700 Subject: [PATCH 057/231] Fix ts --- backend/src/ee/routes/v1/pki-acme-router.ts | 13 ++++++++++--- .../src/ee/services/pki-acme/pki-acme-service.ts | 5 ++--- backend/src/ee/services/pki-acme/pki-acme-types.ts | 14 +++++++++----- 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/backend/src/ee/routes/v1/pki-acme-router.ts b/backend/src/ee/routes/v1/pki-acme-router.ts index 24ae75ff5..e576eb306 100644 --- a/backend/src/ee/routes/v1/pki-acme-router.ts +++ b/backend/src/ee/routes/v1/pki-acme-router.ts @@ -198,9 +198,16 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { }, // TODO: replace with verify ACME signature here instead // onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), - handler: async (req) => { - const result = await server.services.pkiAcme.deactivateAcmeAccount(req.params.profileId, req.params.accountId); - return result; + handler: async (req, res) => { + return sendAcmeResponse( + res, + req.params.profileId, + await server.services.pkiAcme.deactivateAcmeAccount({ + profileId: req.params.profileId, + accountId: req.params.accountId, + payload: req.body + }) + ); } }); 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 a72ef2b0f..6f35aa15d 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -303,9 +303,8 @@ export const pkiAcmeServiceFactory = ({ return { status: 201, body: { - status: "pending", - // TODO: read config from the profile to get the expiration time instead - expires: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), + status: order.status, + expires: order.expiresAt.toISOString(), identifiers: order.authorizations.map((auth: TPkiAcmeAuths) => ({ type: auth.identifierType, value: auth.identifierValue 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 2a923d1a5..811d121ad 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-types.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-types.ts @@ -85,11 +85,15 @@ export type TPkiAcmeServiceFactory = { accountId: string; payload: TCreateAcmeOrderPayload; }) => Promise>; - deactivateAcmeAccount: ( - profileId: string, - accountId: string, - body?: TDeactivateAcmeAccountPayload - ) => Promise; + deactivateAcmeAccount: ({ + profileId, + accountId, + payload + }: { + profileId: string; + accountId: string; + payload?: TDeactivateAcmeAccountPayload; + }) => Promise>; listAcmeOrders: ({ profileId, accountId From ea4b36b0aef1afe6b4b68f8a2ed02c5b9348344d Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Wed, 29 Oct 2025 18:50:49 -0700 Subject: [PATCH 058/231] Refactor --- .../services/pki-acme/pki-acme-account-dal.ts | 4 +-- .../ee/services/pki-acme/pki-acme-schemas.ts | 2 ++ .../ee/services/pki-acme/pki-acme-service.ts | 25 +++++++++++++------ .../ee/services/pki-acme/pki-acme-types.ts | 16 ++++++++---- 4 files changed, 32 insertions(+), 15 deletions(-) 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 e7979826d..a460ddacc 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 @@ -26,7 +26,7 @@ export const pkiAcmeAccountDALFactory = (db: TDbClient) => { } }; - const findById = async (profileId: string, id: string, tx?: Knex) => { + const findByProjectIdAndAccountId = async (profileId: string, id: string, tx?: Knex) => { try { const account = await (tx || db)(TableName.PkiAcmeAccount).where({ profileId, id }).first(); @@ -49,7 +49,7 @@ export const pkiAcmeAccountDALFactory = (db: TDbClient) => { return { ...pkiAcmeAccountOrm, create, - findById, + findByProjectIdAndAccountId, findByPublicKey }; }; diff --git a/backend/src/ee/services/pki-acme/pki-acme-schemas.ts b/backend/src/ee/services/pki-acme/pki-acme-schemas.ts index ec2197466..a10476ca4 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-schemas.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-schemas.ts @@ -114,6 +114,8 @@ export const DeactivateAcmeAccountResponseSchema = z.object({ }); // List Orders endpoint +export const ListAcmeOrdersPayloadSchema = z.object({}).strict(); + export const ListAcmeOrdersResponseSchema = z.object({ orders: z.array(z.string()) }); 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 6f35aa15d..ba9197b33 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -53,7 +53,7 @@ import { type TPkiAcmeServiceFactoryDep = { certificateProfileDAL: Pick; - acmeAccountDAL: Pick; + acmeAccountDAL: Pick; acmeOrderDAL: Pick; acmeAuthDAL: Pick; acmeOrderAuthDAL: Pick; @@ -150,11 +150,17 @@ export const pkiAcmeServiceFactory = ({ ); }; - const validateExistingAccountJwsPayload = async ( - profileId: string, - rawJwsPayload: TRawJwsPayload, - schema: z.ZodSchema - ): Promise> => { + const validateExistingAccountJwsPayload = async ({ + profileId, + rawJwsPayload, + schema, + expectedAccountId + }: { + profileId: string; + rawJwsPayload: TRawJwsPayload; + schema: z.ZodSchema; + expectedAccountId?: string; + }): Promise> => { const profile = await validateAcmeProfile(profileId); const result = await validateJwsPayload( rawJwsPayload, @@ -163,7 +169,10 @@ export const pkiAcmeServiceFactory = ({ throw new AcmeMalformedError({ detail: "KID is required in the protected header" }); } const accountId = extractAccountIdFromKid(protectedHeader.kid, profileId); - const account = await acmeAccountDAL.findById(profile.id, accountId); + if (expectedAccountId && accountId !== expectedAccountId) { + throw new AcmeAccountDoesNotExistError({ message: "ACME account ID mismatch" }); + } + const account = await acmeAccountDAL.findByProjectIdAndAccountId(profile.id, accountId); if (!account) { throw new AcmeAccountDoesNotExistError({ message: "ACME account not found" }); } @@ -261,7 +270,7 @@ export const pkiAcmeServiceFactory = ({ // if we do, return the existing order const order = await acmeOrderDAL.transaction(async (tx) => { - const account = await acmeAccountDAL.findById(profileId, accountId)!; + const account = await acmeAccountDAL.findByProjectIdAndAccountId(profileId, accountId)!; const createdOrder = await acmeOrderDAL.create( { accountId: account.id, 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 811d121ad..827e28c7e 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-types.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-types.ts @@ -58,11 +58,17 @@ export type TPkiAcmeServiceFactory = { schema: z.ZodSchema ) => Promise>; validateNewAccountJwsPayload: (rawJwsPayload: TRawJwsPayload) => Promise>; - validateExistingAccountJwsPayload: ( - profileId: string, - rawJwsPayload: TRawJwsPayload, - schema: z.ZodSchema - ) => Promise>; + validateExistingAccountJwsPayload: ({ + profileId, + rawJwsPayload, + schema, + expectedAccountId + }: { + profileId: string; + rawJwsPayload: TRawJwsPayload; + schema: z.ZodSchema; + expectedAccountId?: string; + }) => Promise>; getAcmeDirectory: (profileId: string) => Promise; getAcmeNewNonce: (profileId: string) => Promise; createAcmeAccount: ({ From c6f51cbd106353e934469b81ded04082722e5de6 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Wed, 29 Oct 2025 20:31:41 -0700 Subject: [PATCH 059/231] Implement get acme order --- .../services/pki-acme/pki-acme-order-dal.ts | 81 +++++++------------ .../ee/services/pki-acme/pki-acme-service.ts | 31 ++++--- .../ee/services/pki-acme/pki-acme-types.ts | 2 + 3 files changed, 52 insertions(+), 62 deletions(-) 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 index fabaaf2a1..2c7ea6aee 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-order-dal.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-order-dal.ts @@ -1,10 +1,10 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; -import { TableName } from "@app/db/schemas"; +import { TableName, TPkiAcmeAuths } 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"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; export type TPkiAcmeOrderDALFactory = ReturnType; @@ -51,54 +51,35 @@ export const pkiAcmeOrderDALFactory = (db: TDbClient) => { } }; - const findByAccountId = async (accountId: string, tx?: Knex) => { + const findByIdWithAuthorizations = async (id: string, tx?: Knex) => { try { - const orders = await (tx || db)(TableName.PkiAcmeOrder).where({ accountId }); + const order = await (tx || db)(TableName.PkiAcmeOrder) + .join(TableName.PkiAcmeOrderAuth, `${TableName.PkiAcmeOrderAuth}.orderId`, `${TableName.PkiAcmeOrder}.id`) + .join(TableName.PkiAcmeAuth, `${TableName.PkiAcmeOrderAuth}.authId`, `${TableName.PkiAcmeAuth}.id`) + .select( + selectAllTableCols(TableName.PkiAcmeOrder), + db.ref("id").withSchema(TableName.PkiAcmeAuth).as("authId"), + db.ref("identifierType").withSchema(TableName.PkiAcmeAuth).as("identifierType"), + db.ref("identifierValue").withSchema(TableName.PkiAcmeAuth).as("identifierValue"), + db.ref("expiresAt").withSchema(TableName.PkiAcmeAuth).as("expiresAt") + ) + .where(`${TableName.PkiAcmeOrder}.id`, id) + .first(); - return orders; + if (!order) { + return null; + } + return { + ...order, + authorizations: order.authorizations.map((auth: TPkiAcmeAuths) => ({ + id: auth.id, + identifierType: auth.identifierType, + identifierValue: auth.identifierValue, + expiresAt: auth.expiresAt + })) + }; } 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" }); + throw new DatabaseError({ error, name: "Find PKI ACME order by id" }); } }; @@ -107,10 +88,6 @@ export const pkiAcmeOrderDALFactory = (db: TDbClient) => { create, updateById, findById, - findByAccountId, - findByStatus, - findByAccountIdAndStatus, - deleteById, - deleteByAccountId + findByIdWithAuthorizations }; }; 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 ba9197b33..9b6f9d4e5 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -7,7 +7,9 @@ import { AcmeAccountDoesNotExistError, AcmeBadPublicKeyError, AcmeMalformedError, - AcmeServerInternalError + AcmeServerInternalError, + AcmeUnauthorizedError, + AcmeUnsupportedIdentifierError } from "./pki-acme-errors"; import { TPkiAcmeAccounts } from "@app/db/schemas/pki-acme-accounts"; @@ -54,7 +56,7 @@ import { type TPkiAcmeServiceFactoryDep = { certificateProfileDAL: Pick; acmeAccountDAL: Pick; - acmeOrderDAL: Pick; + acmeOrderDAL: Pick; acmeAuthDAL: Pick; acmeOrderAuthDAL: Pick; }; @@ -170,7 +172,7 @@ export const pkiAcmeServiceFactory = ({ } const accountId = extractAccountIdFromKid(protectedHeader.kid, profileId); if (expectedAccountId && accountId !== expectedAccountId) { - throw new AcmeAccountDoesNotExistError({ message: "ACME account ID mismatch" }); + throw new NotFoundError({ message: "ACME resource not found" }); } const account = await acmeAccountDAL.findByProjectIdAndAccountId(profile.id, accountId); if (!account) { @@ -294,7 +296,7 @@ export const pkiAcmeServiceFactory = ({ tx ); } else { - throw new AcmeMalformedError({ detail: "Only DNS identifiers are supported" }); + throw new AcmeUnsupportedIdentifierError({ detail: "Only DNS identifiers are supported" }); } }) ); @@ -373,20 +375,29 @@ export const pkiAcmeServiceFactory = ({ const getAcmeOrder = async ({ profileId, + accountId, orderId }: { profileId: string; + accountId: string; orderId: string; }): Promise> => { - const profile = await validateAcmeProfile(profileId); - // FIXME: Implement ACME get order + const order = await acmeOrderDAL.findByIdWithAuthorizations(orderId); + if (!order || order.accountId !== accountId) { + throw new NotFoundError({ message: "ACME order not found" }); + } return { status: 200, body: { - status: "pending", - expires: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), - identifiers: [], - authorizations: [], + status: order.status, + expires: order.expiresAt.toISOString(), + identifiers: order.authorizations.map((auth: TPkiAcmeAuths) => ({ + type: auth.identifierType, + value: auth.identifierValue + })), + authorizations: order.authorizations.map((auth: TPkiAcmeAuths) => + buildUrl(`/api/v1/pki/acme/profiles/${profileId}/authorizations/${auth.id}`) + ), finalize: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/orders/${orderId}/finalize`) }, headers: { Location: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/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 827e28c7e..e9fa4ffa0 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-types.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-types.ts @@ -109,9 +109,11 @@ export type TPkiAcmeServiceFactory = { }) => Promise>; getAcmeOrder: ({ profileId, + accountId, orderId }: { profileId: string; + accountId: string; orderId: string; }) => Promise>; finalizeAcmeOrder: ({ From 47075852944d328f360fe614fe8c46419d5ced4a Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Wed, 29 Oct 2025 20:57:18 -0700 Subject: [PATCH 060/231] Implement more stuff --- backend/src/ee/routes/v1/pki-acme-router.ts | 133 ++++++++++++++---- .../ee/services/pki-acme/pki-acme-service.ts | 29 +++- .../ee/services/pki-acme/pki-acme-types.ts | 17 ++- 3 files changed, 138 insertions(+), 41 deletions(-) diff --git a/backend/src/ee/routes/v1/pki-acme-router.ts b/backend/src/ee/routes/v1/pki-acme-router.ts index e576eb306..362c8b38e 100644 --- a/backend/src/ee/routes/v1/pki-acme-router.ts +++ b/backend/src/ee/routes/v1/pki-acme-router.ts @@ -10,15 +10,18 @@ import { DeactivateAcmeAccountBodySchema, DeactivateAcmeAccountResponseSchema, FinalizeAcmeOrderBodySchema, + FinalizeAcmeOrderResponseSchema, GetAcmeAuthorizationResponseSchema, GetAcmeDirectoryResponseSchema, GetAcmeOrderResponseSchema, + ListAcmeOrdersPayloadSchema, ListAcmeOrdersResponseSchema, RawJwsPayloadSchema, RespondToAcmeChallengeResponseSchema } from "@app/ee/services/pki-acme/pki-acme-schemas"; import { ApiDocsTags } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { AcmeAccountDoesNotExistError, AcmeMalformedError } from "@app/ee/services/pki-acme/pki-acme-errors"; export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { const sendAcmeResponse = async (res: FastifyReply, profileId: string, response: TAcmeResponse): Promise => { @@ -158,16 +161,16 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { // TODO: replace with verify ACME signature here instead // onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req, res) => { - const { payload, accountId } = await server.services.pkiAcme.validateExistingAccountJwsPayload( - req.params.profileId, - req.body, - CreateAcmeOrderBodySchema - ); + const { profileId, accountId, payload } = await server.services.pkiAcme.validateExistingAccountJwsPayload({ + profileId: req.params.profileId, + rawJwsPayload: req.body, + schema: CreateAcmeOrderBodySchema + }); return sendAcmeResponse( res, - req.params.profileId, + profileId, await server.services.pkiAcme.createAcmeOrder({ - profileId: req.params.profileId, + profileId, accountId, payload }) @@ -191,7 +194,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { profileId: z.string().uuid(), accountId: z.string() }), - body: DeactivateAcmeAccountBodySchema, + body: RawJwsPayloadSchema, response: { 200: DeactivateAcmeAccountResponseSchema } @@ -199,13 +202,19 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { // TODO: replace with verify ACME signature here instead // onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req, res) => { + const { payload, profileId, accountId } = await server.services.pkiAcme.validateExistingAccountJwsPayload({ + profileId: req.params.profileId, + rawJwsPayload: req.body, + schema: DeactivateAcmeAccountBodySchema, + expectedAccountId: req.params.accountId + }); return sendAcmeResponse( res, - req.params.profileId, + profileId, await server.services.pkiAcme.deactivateAcmeAccount({ - profileId: req.params.profileId, - accountId: req.params.accountId, - payload: req.body + profileId, + accountId, + payload }) ); } @@ -227,15 +236,28 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { profileId: z.string().uuid(), accountId: z.string() }), + body: RawJwsPayloadSchema, response: { 200: ListAcmeOrdersResponseSchema } }, // TODO: replace with verify ACME signature here instead // onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), - handler: async (req) => { - const orders = await server.services.pkiAcme.listAcmeOrders(req.params.profileId, req.params.accountId); - return orders; + handler: async (req, res) => { + const { profileId, accountId } = await server.services.pkiAcme.validateExistingAccountJwsPayload({ + profileId: req.params.profileId, + rawJwsPayload: req.body, + schema: ListAcmeOrdersPayloadSchema, + expectedAccountId: req.params.accountId + }); + return sendAcmeResponse( + res, + profileId, + await server.services.pkiAcme.listAcmeOrders({ + profileId, + accountId + }) + ); } }); @@ -255,15 +277,27 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { profileId: z.string().uuid(), orderId: z.string().uuid() }), + body: RawJwsPayloadSchema, response: { 200: GetAcmeOrderResponseSchema } }, // TODO: replace with verify ACME signature here instead // onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), - handler: async (req) => { - const order = await server.services.pkiAcme.getAcmeOrder(req.params.profileId, req.params.orderId); - return order; + handler: async (req, res) => { + const { profileId, accountId } = await server.services.pkiAcme.validateExistingAccountJwsPayload({ + profileId: req.params.profileId, + rawJwsPayload: req.body + }); + return sendAcmeResponse( + res, + profileId, + await server.services.pkiAcme.getAcmeOrder({ + profileId, + accountId, + orderId: req.params.orderId + }) + ); } }); @@ -283,13 +317,29 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { profileId: z.string().uuid(), orderId: z.string().uuid() }), - body: FinalizeAcmeOrderBodySchema + body: RawJwsPayloadSchema, + response: { + 200: FinalizeAcmeOrderResponseSchema + } }, // TODO: replace with verify ACME signature here instead // onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), - handler: async (req) => { - const order = await server.services.pkiAcme.finalizeAcmeOrder(req.params.profileId, req.params.orderId, req.body); - return order; + handler: async (req, res) => { + const { profileId, accountId, payload } = await server.services.pkiAcme.validateExistingAccountJwsPayload({ + profileId: req.params.profileId, + rawJwsPayload: req.body + schema: FinalizeAcmeOrderBodySchema, + }); + return sendAcmeResponse( + res, + profileId, + await server.services.pkiAcme.finalizeAcmeOrder({ + profileId, + accountId, + orderId: req.params.orderId, + payload + }) + ); } }); @@ -309,6 +359,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { profileId: z.string().uuid(), orderId: z.string().uuid() }), + body: RawJwsPayloadSchema, response: { 200: z.string() } @@ -316,12 +367,19 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { // TODO: replace with verify ACME signature here instead // onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req, res) => { - const certificate = await server.services.pkiAcme.downloadAcmeCertificate( - req.params.profileId, - req.params.orderId + const { profileId, accountId } = await server.services.pkiAcme.validateExistingAccountJwsPayload({ + profileId: req.params.profileId, + rawJwsPayload: req.body + }); + return sendAcmeResponse( + res, + profileId, + await server.services.pkiAcme.downloadAcmeCertificate({ + profileId, + accountId, + orderId: req.params.orderId + }) ); - res.header("Content-Type", "application/pem-certificate-chain"); - return certificate; } }); @@ -341,15 +399,30 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { profileId: z.string().uuid(), authzId: z.string().uuid() }), + body: RawJwsPayloadSchema, response: { 200: GetAcmeAuthorizationResponseSchema } }, // TODO: replace with verify ACME signature here instead // onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), - handler: async (req) => { - const authz = await server.services.pkiAcme.getAcmeAuthorization(req.params.profileId, req.params.authzId); - return authz; + handler: async (req, res) => { + const { profileId, accountId, payload } = await server.services.pkiAcme.validateExistingAccountJwsPayload({ + profileId: req.params.profileId, + rawJwsPayload: req.body + }); + if (payload !== "") { + throw new AcmeMalformedError({ detail: "Payload should be empty" }); + } + return sendAcmeResponse( + res, + profileId, + await server.services.pkiAcme.getAcmeAuthorization({ + profileId, + accountId, + authzId: req.params.authzId + }) + ); } }); 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 9b6f9d4e5..4d51e31dd 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -93,10 +93,13 @@ export const pkiAcmeServiceFactory = ({ return kid.slice(kidPrefix.length); }; - const validateJwsPayload = async ( + const validateJwsPayload = async < + TSchema extends z.ZodSchema | undefined = undefined, + T = TSchema extends z.ZodSchema ? R : string + >( rawJwsPayload: TRawJwsPayload, getJWK: (protectedHeader: JWSHeaderParameters) => Promise, - schema: z.ZodSchema + schema?: TSchema ): Promise> => { let result: FlattenedVerifyResult; try { @@ -122,8 +125,8 @@ export const pkiAcmeServiceFactory = ({ const protectedHeader = ProtectedHeaderSchema.parse(rawProtectedHeader); // TODO: consume the nonce here const decoder = new TextDecoder(); - const jsonPayload = JSON.parse(decoder.decode(rawPayload)); - const payload = schema.parse(jsonPayload); + const textPayload = decoder.decode(rawPayload); + const payload = schema ? schema.parse(JSON.parse(textPayload)) : textPayload; return { protectedHeader, payload @@ -152,7 +155,10 @@ export const pkiAcmeServiceFactory = ({ ); }; - const validateExistingAccountJwsPayload = async ({ + const validateExistingAccountJwsPayload = async < + TSchema extends z.ZodSchema | undefined = undefined, + T = TSchema extends z.ZodSchema ? R : string + >({ profileId, rawJwsPayload, schema, @@ -160,7 +166,7 @@ export const pkiAcmeServiceFactory = ({ }: { profileId: string; rawJwsPayload: TRawJwsPayload; - schema: z.ZodSchema; + schema?: TSchema; expectedAccountId?: string; }): Promise> => { const profile = await validateAcmeProfile(profileId); @@ -187,7 +193,8 @@ export const pkiAcmeServiceFactory = ({ ); return { ...result, - accountId: extractAccountIdFromKid(result.protectedHeader.kid!, profileId) + accountId: extractAccountIdFromKid(result.protectedHeader.kid!, profileId), + profileId }; }; @@ -406,10 +413,12 @@ export const pkiAcmeServiceFactory = ({ const finalizeAcmeOrder = async ({ profileId, + accountId, orderId, payload }: { profileId: string; + accountId: string; orderId: string; payload: TFinalizeAcmeOrderPayload; }): Promise> => { @@ -453,12 +462,18 @@ export const pkiAcmeServiceFactory = ({ const getAcmeAuthorization = async ({ profileId, + accountId, authzId }: { profileId: string; + accountId: string; authzId: string; }): Promise> => { const profile = await validateAcmeProfile(profileId); + const order = await acmeOrderDAL.findByIdWithAuthorizations(orderId); + if (!order || order.accountId !== accountId) { + throw new NotFoundError({ message: "ACME order not found" }); + } // FIXME: Implement ACME authorization retrieval return { status: 200, 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 e9fa4ffa0..a1f65d8f8 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-types.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-types.ts @@ -43,6 +43,7 @@ export type TJwsPayload = { payload: T; }; export type TAuthenciatedJwsPayload = TJwsPayload & { + profileId: string; accountId: string; }; export type TAcmeResponse = { @@ -52,13 +53,19 @@ export type TAcmeResponse = { }; export type TPkiAcmeServiceFactory = { - validateJwsPayload: ( + validateJwsPayload: < + TSchema extends z.ZodSchema | undefined = undefined, + T = TSchema extends z.ZodSchema ? R : string + >( rawJwsPayload: TRawJwsPayload, getJWK: (protectedHeader: JWSHeaderParameters) => Promise, - schema: z.ZodSchema + schema?: TSchema ) => Promise>; validateNewAccountJwsPayload: (rawJwsPayload: TRawJwsPayload) => Promise>; - validateExistingAccountJwsPayload: ({ + validateExistingAccountJwsPayload: < + TSchema extends z.ZodSchema | undefined = undefined, + T = TSchema extends z.ZodSchema ? R : string + >({ profileId, rawJwsPayload, schema, @@ -66,7 +73,7 @@ export type TPkiAcmeServiceFactory = { }: { profileId: string; rawJwsPayload: TRawJwsPayload; - schema: z.ZodSchema; + schema?: TSchema; expectedAccountId?: string; }) => Promise>; getAcmeDirectory: (profileId: string) => Promise; @@ -118,10 +125,12 @@ export type TPkiAcmeServiceFactory = { }) => Promise>; finalizeAcmeOrder: ({ profileId, + accountId, orderId, payload }: { profileId: string; + accountId: string; orderId: string; payload: TFinalizeAcmeOrderPayload; }) => Promise>; From 68e4e6b36ab5d7742d9ec4a7b47de6f64e94a525 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Wed, 29 Oct 2025 20:59:10 -0700 Subject: [PATCH 061/231] Add missing stuff --- backend/src/ee/services/pki-acme/pki-acme-service.ts | 3 +-- backend/src/ee/services/pki-acme/pki-acme-types.ts | 2 ++ 2 files changed, 3 insertions(+), 2 deletions(-) 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 4d51e31dd..c7138bc27 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -8,7 +8,6 @@ import { AcmeBadPublicKeyError, AcmeMalformedError, AcmeServerInternalError, - AcmeUnauthorizedError, AcmeUnsupportedIdentifierError } from "./pki-acme-errors"; @@ -23,8 +22,8 @@ import { errors, flattenedVerify, FlattenedVerifyResult, importJWK, JWSHeaderPar import { z, ZodError } from "zod"; import { TPkiAcmeAccountDALFactory } from "./pki-acme-account-dal"; import { TPkiAcmeAuthDALFactory } from "./pki-acme-auth-dal"; -import { TPkiAcmeOrderDALFactory } from "./pki-acme-order-dal"; import { TPkiAcmeOrderAuthDALFactory } from "./pki-acme-order-auth-dal"; +import { TPkiAcmeOrderDALFactory } from "./pki-acme-order-dal"; import { AcmeAuthStatus, AcmeIdentifierType, 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 a1f65d8f8..93f14de17 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-types.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-types.ts @@ -143,9 +143,11 @@ export type TPkiAcmeServiceFactory = { }) => Promise>; getAcmeAuthorization: ({ profileId, + accountId, authzId }: { profileId: string; + accountId: string; authzId: string; }) => Promise>; respondToAcmeChallenge: ({ From 011b0df4145d4c40504e11761788478754ed9976 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Wed, 29 Oct 2025 21:10:42 -0700 Subject: [PATCH 062/231] Implement a bit of get auth --- .../ee/services/pki-acme/pki-acme-auth-dal.ts | 80 +------------------ .../ee/services/pki-acme/pki-acme-service.ts | 19 +++-- 2 files changed, 10 insertions(+), 89 deletions(-) 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 index c06954f4a..97d258450 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-auth-dal.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-auth-dal.ts @@ -51,88 +51,10 @@ export const pkiAcmeAuthDALFactory = (db: TDbClient) => { } }; - 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 + findById }; }; 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 c7138bc27..d15992c99 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -56,7 +56,7 @@ type TPkiAcmeServiceFactoryDep = { certificateProfileDAL: Pick; acmeAccountDAL: Pick; acmeOrderDAL: Pick; - acmeAuthDAL: Pick; + acmeAuthDAL: Pick; acmeOrderAuthDAL: Pick; }; @@ -468,22 +468,21 @@ export const pkiAcmeServiceFactory = ({ accountId: string; authzId: string; }): Promise> => { - const profile = await validateAcmeProfile(profileId); - const order = await acmeOrderDAL.findByIdWithAuthorizations(orderId); - if (!order || order.accountId !== accountId) { - throw new NotFoundError({ message: "ACME order not found" }); + const auth = await acmeAuthDAL.findById(authzId); + if (!auth || auth.accountId !== accountId) { + throw new NotFoundError({ message: "ACME authorization not found" }); } - // FIXME: Implement ACME authorization retrieval return { status: 200, body: { - status: "pending", - expires: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), + status: auth.status, + expires: auth.expiresAt.toISOString(), identifier: { - type: "dns", - value: "FIXME-domain-name" + type: auth.identifierType, + value: auth.identifierValue }, challenges: [ + // TODO: fixme { type: "http-01", url: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/authorizations/${authzId}/challenges/http-01`), From 6b85ab54a4f12b57ed3bcf121805b55ed7484470 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Thu, 30 Oct 2025 09:45:15 -0700 Subject: [PATCH 063/231] Refactor code --- backend/src/db/schemas/pki-acme-orders.ts | 4 +- backend/src/ee/routes/v1/pki-acme-router.ts | 119 ++++++----- .../ee/services/pki-acme/pki-acme-schemas.ts | 36 +--- .../ee/services/pki-acme/pki-acme-service.ts | 188 ++++++++++-------- .../ee/services/pki-acme/pki-acme-types.ts | 38 ++-- 5 files changed, 188 insertions(+), 197 deletions(-) diff --git a/backend/src/db/schemas/pki-acme-orders.ts b/backend/src/db/schemas/pki-acme-orders.ts index d52c30662..5f18a3b8c 100644 --- a/backend/src/db/schemas/pki-acme-orders.ts +++ b/backend/src/db/schemas/pki-acme-orders.ts @@ -12,7 +12,9 @@ export const PkiAcmeOrdersSchema = z.object({ accountId: z.string().uuid(), status: z.string(), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + notBefore: z.date().nullable().optional(), + notAfter: z.date().nullable().optional() }); export type TPkiAcmeOrders = z.infer; diff --git a/backend/src/ee/routes/v1/pki-acme-router.ts b/backend/src/ee/routes/v1/pki-acme-router.ts index 362c8b38e..0cb39657d 100644 --- a/backend/src/ee/routes/v1/pki-acme-router.ts +++ b/backend/src/ee/routes/v1/pki-acme-router.ts @@ -4,16 +4,14 @@ import { FastifyReply } from "fastify"; import { z } from "zod"; import { + AcmeOrderResourceSchema, CreateAcmeAccountResponseSchema, CreateAcmeOrderBodySchema, - CreateAcmeOrderResponseSchema, DeactivateAcmeAccountBodySchema, DeactivateAcmeAccountResponseSchema, FinalizeAcmeOrderBodySchema, - FinalizeAcmeOrderResponseSchema, GetAcmeAuthorizationResponseSchema, GetAcmeDirectoryResponseSchema, - GetAcmeOrderResponseSchema, ListAcmeOrdersPayloadSchema, ListAcmeOrdersResponseSchema, RawJwsPayloadSchema, @@ -138,46 +136,6 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { } }); - // POST /api/v1/pki/acme/profiles//new-order - // New Certificate Order (RFC 8555 Section 7.4) - server.route({ - method: "POST", - url: "/profiles/:profileId/new-order", - config: { - rateLimit: writeLimit - }, - schema: { - hide: false, - tags: [ApiDocsTags.PkiAcme], - description: "ACME New Order - apply for a new certificate", - params: z.object({ - profileId: z.string().uuid() - }), - body: RawJwsPayloadSchema, - response: { - 201: CreateAcmeOrderResponseSchema - } - }, - // TODO: replace with verify ACME signature here instead - // onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), - handler: async (req, res) => { - const { profileId, accountId, payload } = await server.services.pkiAcme.validateExistingAccountJwsPayload({ - profileId: req.params.profileId, - rawJwsPayload: req.body, - schema: CreateAcmeOrderBodySchema - }); - return sendAcmeResponse( - res, - profileId, - await server.services.pkiAcme.createAcmeOrder({ - profileId, - accountId, - payload - }) - ); - } - }); - // POST /api/v1/pki/acme/profiles//accounts/ // Account Deactivation (RFC 8555 Section 7.3.6) server.route({ @@ -220,42 +178,41 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { } }); - // POST /api/v1/pki/acme/profiles//accounts//orders - // List Orders (RFC 8555 Section 7.1.2.1) + // POST /api/v1/pki/acme/profiles//new-order + // New Certificate Order (RFC 8555 Section 7.4) server.route({ method: "POST", - url: "/profiles/:profileId/accounts/:accountId/orders", + url: "/profiles/:profileId/new-order", config: { - rateLimit: readLimit + rateLimit: writeLimit }, schema: { hide: false, tags: [ApiDocsTags.PkiAcme], - description: "ACME List Orders - get existing orders from current account", + description: "ACME New Order - apply for a new certificate", params: z.object({ - profileId: z.string().uuid(), - accountId: z.string() + profileId: z.string().uuid() }), body: RawJwsPayloadSchema, response: { - 200: ListAcmeOrdersResponseSchema + 201: AcmeOrderResourceSchema } }, // TODO: replace with verify ACME signature here instead // onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req, res) => { - const { profileId, accountId } = await server.services.pkiAcme.validateExistingAccountJwsPayload({ + const { profileId, accountId, payload } = await server.services.pkiAcme.validateExistingAccountJwsPayload({ profileId: req.params.profileId, rawJwsPayload: req.body, - schema: ListAcmeOrdersPayloadSchema, - expectedAccountId: req.params.accountId + schema: CreateAcmeOrderBodySchema }); return sendAcmeResponse( res, profileId, - await server.services.pkiAcme.listAcmeOrders({ + await server.services.pkiAcme.createAcmeOrder({ profileId, - accountId + accountId, + payload }) ); } @@ -279,7 +236,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { }), body: RawJwsPayloadSchema, response: { - 200: GetAcmeOrderResponseSchema + 200: AcmeOrderResourceSchema } }, // TODO: replace with verify ACME signature here instead @@ -319,16 +276,16 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { }), body: RawJwsPayloadSchema, response: { - 200: FinalizeAcmeOrderResponseSchema + 200: AcmeOrderResourceSchema } }, // TODO: replace with verify ACME signature here instead // onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req, res) => { - const { profileId, accountId, payload } = await server.services.pkiAcme.validateExistingAccountJwsPayload({ + const { profileId, accountId, payload } = await server.services.pkiAcme.validateExistingAccountJwsPayload({ profileId: req.params.profileId, - rawJwsPayload: req.body - schema: FinalizeAcmeOrderBodySchema, + rawJwsPayload: req.body, + schema: FinalizeAcmeOrderBodySchema }); return sendAcmeResponse( res, @@ -342,6 +299,46 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { ); } }); + // POST /api/v1/pki/acme/profiles//accounts//orders + // List Orders (RFC 8555 Section 7.1.2.1) + server.route({ + method: "POST", + url: "/profiles/:profileId/accounts/:accountId/orders", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiAcme], + description: "ACME List Orders - get existing orders from current account", + params: z.object({ + profileId: z.string().uuid(), + accountId: z.string() + }), + body: RawJwsPayloadSchema, + response: { + 200: ListAcmeOrdersResponseSchema + } + }, + // TODO: replace with verify ACME signature here instead + // onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req, res) => { + const { profileId, accountId } = await server.services.pkiAcme.validateExistingAccountJwsPayload({ + profileId: req.params.profileId, + rawJwsPayload: req.body, + schema: ListAcmeOrdersPayloadSchema, + expectedAccountId: req.params.accountId + }); + return sendAcmeResponse( + res, + profileId, + await server.services.pkiAcme.listAcmeOrders({ + profileId, + accountId + }) + ); + } + }); // POST /api/v1/pki/acme/profiles//orders//certificate // Download Certificate (RFC 8555 Section 7.4.2) diff --git a/backend/src/ee/services/pki-acme/pki-acme-schemas.ts b/backend/src/ee/services/pki-acme/pki-acme-schemas.ts index a10476ca4..ad40ea17b 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-schemas.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-schemas.ts @@ -90,9 +90,11 @@ export const CreateAcmeOrderBodySchema = z.object({ notAfter: z.string().optional() }); -export const CreateAcmeOrderResponseSchema = z.object({ - status: z.string(), - expires: z.string(), +export const AcmeOrderResourceSchema = z.object({ + status: z.enum(Object.values(AcmeOrderStatus) as [string, ...string[]]), + expires: z.string().optional(), + notBefore: z.string().optional(), + notAfter: z.string().optional(), identifiers: z.array( z.object({ type: z.string(), @@ -120,39 +122,11 @@ export const ListAcmeOrdersResponseSchema = z.object({ orders: z.array(z.string()) }); -export const GetAcmeOrderResponseSchema = z.object({ - status: z.enum(Object.values(AcmeOrderStatus) as [string, ...string[]]), - expires: z.string().optional(), - identifiers: z.array( - z.object({ - type: z.string(), - value: z.string() - }) - ), - authorizations: z.array(z.string()), - finalize: z.string(), - certificate: z.string().optional() -}); - // Finalize Order payload schema export const FinalizeAcmeOrderBodySchema = z.object({ csr: z.string() }); -export const FinalizeAcmeOrderResponseSchema = z.object({ - status: z.enum(Object.values(AcmeOrderStatus) as [string, ...string[]]), - expires: z.string().optional(), - identifiers: z.array( - z.object({ - type: z.string(), - value: z.string() - }) - ), - authorizations: z.array(z.string()), - finalize: z.string(), - certificate: z.string().optional() -}); - export const GetAcmeAuthorizationResponseSchema = z.object({ status: z.enum(Object.values(AcmeAuthStatus) as [string, ...string[]]), expires: z.string().optional(), 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 d15992c99..49dfb72ef 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -37,16 +37,14 @@ import { TCreateAcmeAccountPayload, TCreateAcmeAccountResponse, TCreateAcmeOrderPayload, - TCreateAcmeOrderResponse, TDeactivateAcmeAccountPayload, TDeactivateAcmeAccountResponse, TFinalizeAcmeOrderPayload, - TFinalizeAcmeOrderResponse, TGetAcmeAuthorizationResponse, TGetAcmeDirectoryResponse, - TGetAcmeOrderResponse, TJwsPayload, TListAcmeOrdersResponse, + TAcmeOrderResource, TPkiAcmeServiceFactory, TRawJwsPayload, TRespondToAcmeChallengeResponse @@ -206,6 +204,36 @@ export const pkiAcmeServiceFactory = ({ }; }; + const buildAcmeOrderResource = ({ + profileId, + order + }: { + order: { + id: string; + status: string; + expiresAt: Date; + notBefore?: Date | null; + notAfter?: Date | null; + authorizations: TPkiAcmeAuths[]; + }; + profileId: string; + }) => { + return { + status: order.status, + expires: order.expiresAt.toISOString(), + notBefore: order.notBefore?.toISOString(), + notAfter: order.notAfter?.toISOString(), + identifiers: order.authorizations.map((auth: TPkiAcmeAuths) => ({ + type: auth.identifierType, + value: auth.identifierValue + })), + authorizations: order.authorizations.map((auth: TPkiAcmeAuths) => + buildUrl(`/api/v1/pki/acme/profiles/${profileId}/authorizations/${auth.id}`) + ), + finalize: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/orders/${order.id}/finalize`) + }; + }; + const getAcmeNewNonce = async (profileId: string): Promise => { const profile = await validateAcmeProfile(profileId); // FIXME: Implement ACME new nonce generation @@ -213,6 +241,9 @@ export const pkiAcmeServiceFactory = ({ return "FIXME-generate-nonce"; }; + /** -------------------------------------------------------------- + * ACME Account + * -------------------------------------------------------------- */ const createAcmeAccount = async ({ profileId, alg, @@ -251,6 +282,7 @@ export const pkiAcmeServiceFactory = ({ publicKey: jwk, emails: contact ?? [] }); + // TODO: create audit log here // TODO: check EAB authentication here return { status: 201, @@ -265,6 +297,31 @@ export const pkiAcmeServiceFactory = ({ }; }; + const deactivateAcmeAccount = async ({ + profileId, + accountId, + payload: { status } = { status: "deactivated" } + }: { + profileId: string; + accountId: string; + payload?: TDeactivateAcmeAccountPayload; + }): Promise> => { + const profile = await validateAcmeProfile(profileId); + // FIXME: Implement ACME account deactivation + return { + status: 200, + body: { + status: "deactivated" + }, + headers: { + Location: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/accounts/${accountId}`) + } + }; + }; + + /** -------------------------------------------------------------- + * ACME Order + * -------------------------------------------------------------- */ const createAcmeOrder = async ({ profileId, accountId, @@ -273,7 +330,7 @@ export const pkiAcmeServiceFactory = ({ profileId: string; accountId: string; payload: TCreateAcmeOrderPayload; - }): Promise> => { + }): Promise> => { // TODO: check and see if we have existing orders for this account that meet the criteria // if we do, return the existing order @@ -314,47 +371,68 @@ export const pkiAcmeServiceFactory = ({ })), tx ); + // TODO: create audit log here return { ...createdOrder, authorizations, account }; }); return { status: 201, - body: { - status: order.status, - expires: order.expiresAt.toISOString(), - identifiers: order.authorizations.map((auth: TPkiAcmeAuths) => ({ - type: auth.identifierType, - value: auth.identifierValue - })), - authorizations: order.authorizations.map((auth: TPkiAcmeAuths) => - buildUrl(`/api/v1/pki/acme/profiles/${order.account.profileId}/authorizations/${auth.id}`) - ), - finalize: buildUrl(`/api/v1/pki/acme/profiles/${order.account.profileId}/orders/${order.id}/finalize`) - }, + body: buildAcmeOrderResource({ + profileId, + order + }), headers: { Location: buildUrl(`/api/v1/pki/acme/profiles/${order.account.profileId}/orders/${order.id}`) } }; }; - const deactivateAcmeAccount = async ({ + const getAcmeOrder = async ({ profileId, accountId, - payload: { status } = { status: "deactivated" } + orderId }: { profileId: string; accountId: string; - payload?: TDeactivateAcmeAccountPayload; - }): Promise> => { + orderId: string; + }): Promise> => { + const order = await acmeOrderDAL.findByIdWithAuthorizations(orderId); + if (!order || order.accountId !== accountId) { + throw new NotFoundError({ message: "ACME order not found" }); + } + return { + status: 200, + body: buildAcmeOrderResource({ profileId, order }), + headers: { Location: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/orders/${orderId}`) } + }; + }; + + const finalizeAcmeOrder = async ({ + profileId, + accountId, + orderId, + payload + }: { + profileId: string; + accountId: string; + orderId: string; + payload: TFinalizeAcmeOrderPayload; + }): Promise> => { const profile = await validateAcmeProfile(profileId); - // FIXME: Implement ACME account deactivation + const { csr } = payload; + // FIXME: Implement ACME finalize order return { status: 200, body: { - status: "deactivated" + 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`) }, headers: { - Location: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/accounts/${accountId}`) + Location: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/orders/${orderId}`) } }; }; @@ -379,67 +457,6 @@ export const pkiAcmeServiceFactory = ({ }; }; - const getAcmeOrder = async ({ - profileId, - accountId, - orderId - }: { - profileId: string; - accountId: string; - orderId: string; - }): Promise> => { - const order = await acmeOrderDAL.findByIdWithAuthorizations(orderId); - if (!order || order.accountId !== accountId) { - throw new NotFoundError({ message: "ACME order not found" }); - } - return { - status: 200, - body: { - status: order.status, - expires: order.expiresAt.toISOString(), - identifiers: order.authorizations.map((auth: TPkiAcmeAuths) => ({ - type: auth.identifierType, - value: auth.identifierValue - })), - authorizations: order.authorizations.map((auth: TPkiAcmeAuths) => - buildUrl(`/api/v1/pki/acme/profiles/${profileId}/authorizations/${auth.id}`) - ), - finalize: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/orders/${orderId}/finalize`) - }, - headers: { Location: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/orders/${orderId}`) } - }; - }; - - const finalizeAcmeOrder = async ({ - profileId, - accountId, - orderId, - payload - }: { - profileId: string; - accountId: string; - orderId: string; - payload: TFinalizeAcmeOrderPayload; - }): Promise> => { - const profile = await validateAcmeProfile(profileId); - const { csr } = payload; - // FIXME: Implement ACME finalize order - return { - status: 200, - body: { - 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`) - }, - headers: { - Location: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/orders/${orderId}`) - } - }; - }; - const downloadAcmeCertificate = async ({ profileId, orderId @@ -459,6 +476,9 @@ export const pkiAcmeServiceFactory = ({ }; }; + /** -------------------------------------------------------------- + * ACME Authorization + * -------------------------------------------------------------- */ const getAcmeAuthorization = async ({ profileId, accountId, 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 93f14de17..672300f18 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-types.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-types.ts @@ -2,17 +2,15 @@ import { z } from "zod"; import { JWSHeaderParameters } from "jose"; import { + AcmeOrderResourceSchema, CreateAcmeAccountBodySchema, CreateAcmeAccountResponseSchema, CreateAcmeOrderBodySchema, - CreateAcmeOrderResponseSchema, DeactivateAcmeAccountBodySchema, DeactivateAcmeAccountResponseSchema, FinalizeAcmeOrderBodySchema, - FinalizeAcmeOrderResponseSchema, GetAcmeAuthorizationResponseSchema, GetAcmeDirectoryResponseSchema, - GetAcmeOrderResponseSchema, ListAcmeOrdersResponseSchema, ProtectedHeaderSchema, RawJwsPayloadSchema, @@ -21,11 +19,9 @@ import { export type TGetAcmeDirectoryResponse = z.infer; export type TCreateAcmeAccountResponse = z.infer; -export type TCreateAcmeOrderResponse = z.infer; +export type TAcmeOrderResource = z.infer; export type TDeactivateAcmeAccountResponse = z.infer; export type TListAcmeOrdersResponse = z.infer; -export type TGetAcmeOrderResponse = z.infer; -export type TFinalizeAcmeOrderResponse = z.infer; export type TDownloadAcmeCertificateDTO = string; export type TGetAcmeAuthorizationResponse = z.infer; export type TRespondToAcmeChallengeResponse = z.infer; @@ -89,15 +85,6 @@ export type TPkiAcmeServiceFactory = { jwk: JsonWebKey; payload: TCreateAcmeAccountPayload; }) => Promise>; - createAcmeOrder: ({ - profileId, - accountId, - payload - }: { - profileId: string; - accountId: string; - payload: TCreateAcmeOrderPayload; - }) => Promise>; deactivateAcmeAccount: ({ profileId, accountId, @@ -107,13 +94,15 @@ export type TPkiAcmeServiceFactory = { accountId: string; payload?: TDeactivateAcmeAccountPayload; }) => Promise>; - listAcmeOrders: ({ + createAcmeOrder: ({ profileId, - accountId + accountId, + payload }: { profileId: string; accountId: string; - }) => Promise>; + payload: TCreateAcmeOrderPayload; + }) => Promise>; getAcmeOrder: ({ profileId, accountId, @@ -122,7 +111,7 @@ export type TPkiAcmeServiceFactory = { profileId: string; accountId: string; orderId: string; - }) => Promise>; + }) => Promise>; finalizeAcmeOrder: ({ profileId, accountId, @@ -133,12 +122,21 @@ export type TPkiAcmeServiceFactory = { accountId: string; orderId: string; payload: TFinalizeAcmeOrderPayload; - }) => Promise>; + }) => Promise>; + listAcmeOrders: ({ + profileId, + accountId + }: { + profileId: string; + accountId: string; + }) => Promise>; downloadAcmeCertificate: ({ profileId, + accountId, orderId }: { profileId: string; + accountId: string; orderId: string; }) => Promise>; getAcmeAuthorization: ({ From 0ed464308f49c5e631795887bc7f3f8b6d204d67 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Thu, 30 Oct 2025 09:55:59 -0700 Subject: [PATCH 064/231] Use the same find by account and order id to always have valid obj ownership check --- backend/src/ee/routes/v1/pki-acme-router.ts | 6 +- .../services/pki-acme/pki-acme-order-dal.ts | 7 ++- .../ee/services/pki-acme/pki-acme-service.ts | 63 ++++++++++--------- .../ee/services/pki-acme/pki-acme-types.ts | 14 ++--- 4 files changed, 44 insertions(+), 46 deletions(-) diff --git a/backend/src/ee/routes/v1/pki-acme-router.ts b/backend/src/ee/routes/v1/pki-acme-router.ts index 0cb39657d..4794a125a 100644 --- a/backend/src/ee/routes/v1/pki-acme-router.ts +++ b/backend/src/ee/routes/v1/pki-acme-router.ts @@ -371,11 +371,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { return sendAcmeResponse( res, profileId, - await server.services.pkiAcme.downloadAcmeCertificate({ - profileId, - accountId, - orderId: req.params.orderId - }) + await server.services.pkiAcme.downloadAcmeCertificate({ profileId, accountId, orderId: req.params.orderId }) ); } }); 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 index 2c7ea6aee..78a46b665 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-order-dal.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-order-dal.ts @@ -51,7 +51,7 @@ export const pkiAcmeOrderDALFactory = (db: TDbClient) => { } }; - const findByIdWithAuthorizations = async (id: string, tx?: Knex) => { + const findByAccountAndOrderIdWithAuthorizations = async (accountId: string, orderId: string, tx?: Knex) => { try { const order = await (tx || db)(TableName.PkiAcmeOrder) .join(TableName.PkiAcmeOrderAuth, `${TableName.PkiAcmeOrderAuth}.orderId`, `${TableName.PkiAcmeOrder}.id`) @@ -63,7 +63,8 @@ export const pkiAcmeOrderDALFactory = (db: TDbClient) => { db.ref("identifierValue").withSchema(TableName.PkiAcmeAuth).as("identifierValue"), db.ref("expiresAt").withSchema(TableName.PkiAcmeAuth).as("expiresAt") ) - .where(`${TableName.PkiAcmeOrder}.id`, id) + .where(`${TableName.PkiAcmeOrder}.id`, orderId) + .where(`${TableName.PkiAcmeOrder}.accountId`, accountId) .first(); if (!order) { @@ -88,6 +89,6 @@ export const pkiAcmeOrderDALFactory = (db: TDbClient) => { create, updateById, findById, - findByIdWithAuthorizations + findByAccountAndOrderIdWithAuthorizations }; }; 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 49dfb72ef..c68bb3895 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -53,7 +53,7 @@ import { type TPkiAcmeServiceFactoryDep = { certificateProfileDAL: Pick; acmeAccountDAL: Pick; - acmeOrderDAL: Pick; + acmeOrderDAL: Pick; acmeAuthDAL: Pick; acmeOrderAuthDAL: Pick; }; @@ -396,8 +396,8 @@ export const pkiAcmeServiceFactory = ({ accountId: string; orderId: string; }): Promise> => { - const order = await acmeOrderDAL.findByIdWithAuthorizations(orderId); - if (!order || order.accountId !== accountId) { + const order = await acmeOrderDAL.findByAccountAndOrderIdWithAuthorizations(accountId, orderId); + if (!order) { throw new NotFoundError({ message: "ACME order not found" }); } return { @@ -418,25 +418,45 @@ export const pkiAcmeServiceFactory = ({ orderId: string; payload: TFinalizeAcmeOrderPayload; }): Promise> => { - const profile = await validateAcmeProfile(profileId); + const order = await acmeOrderDAL.findByAccountAndOrderIdWithAuthorizations(accountId, orderId); + if (!order) { + throw new NotFoundError({ message: "ACME order not found" }); + } const { csr } = payload; // FIXME: Implement ACME finalize order return { status: 200, - body: { - 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`) - }, + body: buildAcmeOrderResource({ profileId, order }), headers: { Location: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/orders/${orderId}`) } }; }; + const downloadAcmeCertificate = async ({ + profileId, + accountId, + orderId + }: { + profileId: string; + accountId: string; + orderId: string; + }): Promise> => { + const order = await acmeOrderDAL.findByAccountAndOrderIdWithAuthorizations(accountId, orderId); + if (!order) { + throw new NotFoundError({ message: "ACME order not found" }); + } + // FIXME: Implement ACME certificate download + // Return the certificate in PEM format + return { + status: 200, + body: "FIXME-certificate-pem", + headers: { + Location: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/orders/${orderId}/certificate`) + } + }; + }; + const listAcmeOrders = async ({ profileId, accountId @@ -457,25 +477,6 @@ export const pkiAcmeServiceFactory = ({ }; }; - const downloadAcmeCertificate = async ({ - profileId, - orderId - }: { - profileId: string; - orderId: string; - }): Promise> => { - const profile = await validateAcmeProfile(profileId); - // FIXME: Implement ACME certificate download - // Return the certificate in PEM format - return { - status: 200, - body: "FIXME-certificate-pem", - headers: { - Location: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/orders/${orderId}/certificate`) - } - }; - }; - /** -------------------------------------------------------------- * ACME Authorization * -------------------------------------------------------------- */ 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 672300f18..8fded06d4 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-types.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-types.ts @@ -123,13 +123,6 @@ export type TPkiAcmeServiceFactory = { orderId: string; payload: TFinalizeAcmeOrderPayload; }) => Promise>; - listAcmeOrders: ({ - profileId, - accountId - }: { - profileId: string; - accountId: string; - }) => Promise>; downloadAcmeCertificate: ({ profileId, accountId, @@ -139,6 +132,13 @@ export type TPkiAcmeServiceFactory = { accountId: string; orderId: string; }) => Promise>; + listAcmeOrders: ({ + profileId, + accountId + }: { + profileId: string; + accountId: string; + }) => Promise>; getAcmeAuthorization: ({ profileId, accountId, From 9196e74437a3325309db98a6e2291f23199ec1ef Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Thu, 30 Oct 2025 10:07:31 -0700 Subject: [PATCH 065/231] Add more missing columns --- backend/src/db/migrations/20251029234547_add-pki-acme.ts | 5 +++++ backend/src/db/schemas/pki-acme-orders.ts | 3 ++- backend/src/ee/services/pki-acme/pki-acme-service.ts | 6 +++++- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/backend/src/db/migrations/20251029234547_add-pki-acme.ts b/backend/src/db/migrations/20251029234547_add-pki-acme.ts index 6390e5812..fdc481a04 100644 --- a/backend/src/db/migrations/20251029234547_add-pki-acme.ts +++ b/backend/src/db/migrations/20251029234547_add-pki-acme.ts @@ -78,6 +78,11 @@ export async function up(knex: Knex): Promise { t.uuid("accountId").notNullable(); t.foreign("accountId").references("id").inTable(TableName.PkiAcmeAccount).onDelete("CASCADE"); + t.timestamp("notBefore").nullable(); + t.timestamp("notAfter").nullable(); + + t.timestamp("expiresAt").notNullable(); + // Order status t.string("status").notNullable(); // pending, ready, processing, valid, invalid diff --git a/backend/src/db/schemas/pki-acme-orders.ts b/backend/src/db/schemas/pki-acme-orders.ts index 5f18a3b8c..5ed58e4e9 100644 --- a/backend/src/db/schemas/pki-acme-orders.ts +++ b/backend/src/db/schemas/pki-acme-orders.ts @@ -14,7 +14,8 @@ export const PkiAcmeOrdersSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), notBefore: z.date().nullable().optional(), - notAfter: z.date().nullable().optional() + notAfter: z.date().nullable().optional(), + expiresAt: z.date().nullable().optional() }); export type TPkiAcmeOrders = z.infer; 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 c68bb3895..e5336b69c 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -339,7 +339,11 @@ export const pkiAcmeServiceFactory = ({ const createdOrder = await acmeOrderDAL.create( { accountId: account.id, - status: AcmeOrderStatus.Pending + status: AcmeOrderStatus.Pending, + notBefore: payload.notBefore ? new Date(payload.notBefore) : undefined, + notAfter: payload.notAfter ? new Date(payload.notAfter) : undefined, + // TODO: read config from the profile to get the expiration time instead + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000) }, tx ); From a7f773b78fa00da56bfe13f164143e9226a848e3 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Thu, 30 Oct 2025 10:10:09 -0700 Subject: [PATCH 066/231] expires should be required --- backend/src/db/schemas/pki-acme-orders.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/db/schemas/pki-acme-orders.ts b/backend/src/db/schemas/pki-acme-orders.ts index 5ed58e4e9..6d5274f06 100644 --- a/backend/src/db/schemas/pki-acme-orders.ts +++ b/backend/src/db/schemas/pki-acme-orders.ts @@ -15,7 +15,7 @@ export const PkiAcmeOrdersSchema = z.object({ updatedAt: z.date(), notBefore: z.date().nullable().optional(), notAfter: z.date().nullable().optional(), - expiresAt: z.date().nullable().optional() + expiresAt: z.date() }); export type TPkiAcmeOrders = z.infer; From 1319ccc0d3fb527ced418bd9d5d886bf95bd50fb Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Thu, 30 Oct 2025 12:02:39 -0700 Subject: [PATCH 067/231] Add challenge token --- .../migrations/20251029234547_add-pki-acme.ts | 3 ++ backend/src/db/schemas/index.ts | 2 +- backend/src/db/schemas/pki-acme-auths.ts | 3 +- .../ee/services/pki-acme/pki-acme-service.ts | 31 ++++++++++--------- 4 files changed, 23 insertions(+), 16 deletions(-) diff --git a/backend/src/db/migrations/20251029234547_add-pki-acme.ts b/backend/src/db/migrations/20251029234547_add-pki-acme.ts index fdc481a04..aa334cfba 100644 --- a/backend/src/db/migrations/20251029234547_add-pki-acme.ts +++ b/backend/src/db/migrations/20251029234547_add-pki-acme.ts @@ -104,6 +104,9 @@ export async function up(knex: Knex): Promise { // Authorization status t.string("status").notNullable(); // pending, valid, invalid, deactivated, expired, revoked + // Token used to validate the authorization through ACME challenge + t.timestamp("token").nullable(); + // Identifier type and value t.string("identifierType").notNullable(); // dns t.string("identifierValue").notNullable(); // domain name diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index e3db789ac..3513b95a7 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -96,8 +96,8 @@ export * from "./pki-acme-accounts"; export * from "./pki-acme-auths"; export * from "./pki-acme-challenges"; export * from "./pki-acme-enrollment-configs"; -export * from "./pki-acme-orders"; export * from "./pki-acme-order-auths"; +export * from "./pki-acme-orders"; export * from "./pki-alerts"; export * from "./pki-api-enrollment-configs"; export * from "./pki-certificate-profiles"; diff --git a/backend/src/db/schemas/pki-acme-auths.ts b/backend/src/db/schemas/pki-acme-auths.ts index de883356d..15c7a7c55 100644 --- a/backend/src/db/schemas/pki-acme-auths.ts +++ b/backend/src/db/schemas/pki-acme-auths.ts @@ -16,7 +16,8 @@ export const PkiAcmeAuthsSchema = z.object({ expiresAt: z.date(), certificateId: z.string().uuid().nullable().optional(), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + token: z.string().nullable().optional() }); export type TPkiAcmeAuths = z.infer; 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 e5336b69c..35f668a8d 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -1,19 +1,11 @@ -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, - AcmeServerInternalError, - AcmeUnsupportedIdentifierError -} from "./pki-acme-errors"; - import { TPkiAcmeAccounts } from "@app/db/schemas/pki-acme-accounts"; import { TPkiAcmeAuths } from "@app/db/schemas/pki-acme-auths"; +import { getConfig } from "@app/lib/config/env"; +import { crypto } from "@app/lib/crypto/cryptography"; +import { NotFoundError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; +import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; + import { EnrollmentType, TCertificateProfileWithConfigs @@ -22,6 +14,13 @@ import { errors, flattenedVerify, FlattenedVerifyResult, importJWK, JWSHeaderPar import { z, ZodError } from "zod"; import { TPkiAcmeAccountDALFactory } from "./pki-acme-account-dal"; import { TPkiAcmeAuthDALFactory } from "./pki-acme-auth-dal"; +import { + AcmeAccountDoesNotExistError, + AcmeBadPublicKeyError, + AcmeMalformedError, + AcmeServerInternalError, + AcmeUnsupportedIdentifierError +} from "./pki-acme-errors"; import { TPkiAcmeOrderAuthDALFactory } from "./pki-acme-order-auth-dal"; import { TPkiAcmeOrderDALFactory } from "./pki-acme-order-dal"; import { @@ -32,6 +31,7 @@ import { ProtectedHeaderSchema } from "./pki-acme-schemas"; import { + TAcmeOrderResource, TAcmeResponse, TAuthenciatedJwsPayload, TCreateAcmeAccountPayload, @@ -44,7 +44,6 @@ import { TGetAcmeDirectoryResponse, TJwsPayload, TListAcmeOrdersResponse, - TAcmeOrderResource, TPkiAcmeServiceFactory, TRawJwsPayload, TRespondToAcmeChallengeResponse @@ -357,6 +356,10 @@ export const pkiAcmeServiceFactory = ({ status: AcmeAuthStatus.Pending, identifierType: identifier.type, identifierValue: identifier.value, + // RFC 8555 suggests a token with at least 128 bits of entropy + // We are using 256 bits of entropy here, should be enough for now + // ref: https://datatracker.ietf.org/doc/html/rfc8555#section-11.3 + token: crypto.randomBytes(32).toString("base64"), // TODO: read config from the profile to get the expiration time instead expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000) }, From a3f2ee47f6b31f8cf73c5f6458c01746a3e01111 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Thu, 30 Oct 2025 12:23:22 -0700 Subject: [PATCH 068/231] Return auth token --- backend/bdd/features/pki/acme/new-order.feature | 2 +- backend/bdd/features/steps/pki_acme.py | 6 +++--- backend/src/ee/services/pki-acme/pki-acme-service.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/backend/bdd/features/pki/acme/new-order.feature b/backend/bdd/features/pki/acme/new-order.feature index ebc0961ee..e165fafee 100644 --- a/backend/bdd/features/pki/acme/new-order.feature +++ b/backend/bdd/features/pki/acme/new-order.feature @@ -15,4 +15,4 @@ Feature: New Order """ Then I create a RSA private key pair as cert_key Then I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format - Then I submit the certificate signing request PEM csr_pem certificate order to the ACME server + Then I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order diff --git a/backend/bdd/features/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py index e08212b9e..9180d00fa 100644 --- a/backend/bdd/features/steps/pki_acme.py +++ b/backend/bdd/features/steps/pki_acme.py @@ -99,10 +99,10 @@ def step_impl(context: Context, email: str, kid: str, secret: str, account_var: @then( - "I submit the certificate signing request PEM {pem_var} certificate order to the ACME server" + "I submit the certificate signing request PEM {pem_var} certificate order to the ACME server as {order_var}" ) -def step_impl(context: Context, pem_var: str): - context.acme_order = context.acme_client.new_order(context.vars[pem_var]) +def step_impl(context: Context, pem_var: str, order_var: str): + context.vars[order_var] = context.acme_client.new_order(context.vars[pem_var]) @when("I create certificate signing request as {csr_var}") 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 35f668a8d..fe823dceb 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -515,7 +515,7 @@ export const pkiAcmeServiceFactory = ({ type: "http-01", url: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/authorizations/${authzId}/challenges/http-01`), status: "pending", - token: "FIXME-challenge-token" + token: auth.token } ] }, From 6b1147aef9c1027468e268f6dab39c480e8d8e02 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Thu, 30 Oct 2025 12:33:04 -0700 Subject: [PATCH 069/231] Improve var replacement --- backend/bdd/features/steps/pki_acme.py | 29 ++++++++++++++------------ 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/backend/bdd/features/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py index 9180d00fa..0974771c3 100644 --- a/backend/bdd/features/steps/pki_acme.py +++ b/backend/bdd/features/steps/pki_acme.py @@ -22,16 +22,18 @@ class AcmeProfile: self.id = id -def replace_vars(payload: dict, vars: dict): - for key, value in payload.items(): - if isinstance(value, dict): - replace_vars(value, vars) - elif isinstance(value, list): - payload[key] = [replace_vars(item, vars) for item in value] - elif isinstance(value, str): - payload[key] = value.format(**vars) - else: - payload[key] = value +def replace_vars(payload: dict | list | int | float | str, vars: dict): + if isinstance(payload, dict): + return { + replace_vars(key, vars): replace_vars(value, vars) + for key, value in payload.items() + } + elif isinstance(payload, list): + return [replace_vars(item, vars) for item in payload] + elif isinstance(payload, str): + return payload.format(**vars) + else: + return payload @given('I have an ACME cert profile as "{profile_var}"') @@ -85,8 +87,8 @@ def step_impl(context: Context, header: str): def step_impl(context: Context): payload = context.response.json() expected = json.loads(context.text) - replace_vars(expected, context.vars) - assert payload == expected, f"{payload} != {expected}" + replaced = replace_vars(expected, context.vars) + assert payload == replaced, f"{payload} != {replaced}" @then( @@ -102,7 +104,8 @@ def step_impl(context: Context, email: str, kid: str, secret: str, account_var: "I submit the certificate signing request PEM {pem_var} certificate order to the ACME server as {order_var}" ) def step_impl(context: Context, pem_var: str, order_var: str): - context.vars[order_var] = context.acme_client.new_order(context.vars[pem_var]) + order = context.acme_client.new_order(context.vars[pem_var]) + context.vars[order_var] = order @when("I create certificate signing request as {csr_var}") From 3a965bb43fd8cad88b53794936c58d2cff5070c6 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Thu, 30 Oct 2025 12:42:46 -0700 Subject: [PATCH 070/231] Make bdd assert --- backend/bdd/features/pki/acme/new-order.feature | 1 + backend/bdd/features/steps/pki_acme.py | 14 ++++++++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/backend/bdd/features/pki/acme/new-order.feature b/backend/bdd/features/pki/acme/new-order.feature index e165fafee..215933bf0 100644 --- a/backend/bdd/features/pki/acme/new-order.feature +++ b/backend/bdd/features/pki/acme/new-order.feature @@ -16,3 +16,4 @@ Feature: New Order Then I create a RSA private key pair as cert_key Then I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format Then I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order + Then the value order.uri should be true for startswith("foo") diff --git a/backend/bdd/features/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py index 0974771c3..2a845d95b 100644 --- a/backend/bdd/features/steps/pki_acme.py +++ b/backend/bdd/features/steps/pki_acme.py @@ -1,5 +1,6 @@ import json +import jq from acme import client from acme import messages from behave.runner import Context @@ -104,8 +105,7 @@ def step_impl(context: Context, email: str, kid: str, secret: str, account_var: "I submit the certificate signing request PEM {pem_var} certificate order to the ACME server as {order_var}" ) def step_impl(context: Context, pem_var: str, order_var: str): - order = context.acme_client.new_order(context.vars[pem_var]) - context.vars[order_var] = order + context.vars[order_var] = context.acme_client.new_order(context.vars[pem_var]) @when("I create certificate signing request as {csr_var}") @@ -155,3 +155,13 @@ def step_impl(context: Context, csr_var: str, pk_var: str, pem_var: str): .sign(context.vars[pk_var], hashes.SHA256()) .public_bytes(serialization.Encoding.PEM) ) + + +@then("the value {var_path} should be true for {query}") +def step_impl(context: Context, var_path: str, query: str): + parts = var_path.split(".") + value = context.vars[parts[0]] + for part in parts[1:]: + value = getattr(value, part) + result = jq.compile(query).input_value(value).first() + assert result, f"{value} does not match {query}" From 1b827439b34227dc3ecd6ecb770316111b2704ef Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Thu, 30 Oct 2025 12:44:23 -0700 Subject: [PATCH 071/231] Add some asserts --- backend/bdd/features/pki/acme/new-order.feature | 2 +- backend/bdd/features/steps/pki_acme.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/bdd/features/pki/acme/new-order.feature b/backend/bdd/features/pki/acme/new-order.feature index 215933bf0..a80c3ed58 100644 --- a/backend/bdd/features/pki/acme/new-order.feature +++ b/backend/bdd/features/pki/acme/new-order.feature @@ -16,4 +16,4 @@ Feature: New Order Then I create a RSA private key pair as cert_key Then I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format Then I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order - Then the value order.uri should be true for startswith("foo") + Then the value order.uri should be true for startswith("{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/orders/") diff --git a/backend/bdd/features/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py index 2a845d95b..b06a36f9c 100644 --- a/backend/bdd/features/steps/pki_acme.py +++ b/backend/bdd/features/steps/pki_acme.py @@ -163,5 +163,5 @@ def step_impl(context: Context, var_path: str, query: str): value = context.vars[parts[0]] for part in parts[1:]: value = getattr(value, part) - result = jq.compile(query).input_value(value).first() + result = jq.compile(replace_vars(query, context.vars)).input_value(value).first() assert result, f"{value} does not match {query}" From 69f93db5196637b0a56ba893586344058663926d Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Thu, 30 Oct 2025 12:50:56 -0700 Subject: [PATCH 072/231] Use glom instead --- backend/bdd/features/steps/pki_acme.py | 7 ++-- backend/bdd/pyproject.toml | 1 + backend/bdd/uv.lock | 46 ++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/backend/bdd/features/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py index b06a36f9c..7cf5c4980 100644 --- a/backend/bdd/features/steps/pki_acme.py +++ b/backend/bdd/features/steps/pki_acme.py @@ -7,6 +7,7 @@ from behave.runner import Context from behave import given from behave import when from behave import then +import glom from josepy.jwk import JWKRSA from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa @@ -159,9 +160,9 @@ def step_impl(context: Context, csr_var: str, pk_var: str, pem_var: str): @then("the value {var_path} should be true for {query}") def step_impl(context: Context, var_path: str, query: str): - parts = var_path.split(".") + parts = var_path.split(".", 1) value = context.vars[parts[0]] - for part in parts[1:]: - value = getattr(value, part) + if len(parts) == 2: + value = glom.glom(value, parts[1]) result = jq.compile(replace_vars(query, context.vars)).input_value(value).first() assert result, f"{value} does not match {query}" diff --git a/backend/bdd/pyproject.toml b/backend/bdd/pyproject.toml index 98b1c2f89..80e1979a1 100644 --- a/backend/bdd/pyproject.toml +++ b/backend/bdd/pyproject.toml @@ -7,6 +7,7 @@ requires-python = ">=3.12" dependencies = [ "acme>=5.1.0", "behave>=1.3.3", + "glom>=24.11.0", "httpx>=0.28.1", "josepy>=2.2.0", "jq>=1.10.0", diff --git a/backend/bdd/uv.lock b/backend/bdd/uv.lock index a05b55420..beceb0f26 100644 --- a/backend/bdd/uv.lock +++ b/backend/bdd/uv.lock @@ -32,6 +32,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, ] +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + [[package]] name = "bdd" version = "0.1.0" @@ -39,6 +48,7 @@ source = { virtual = "." } dependencies = [ { name = "acme" }, { name = "behave" }, + { name = "glom" }, { name = "httpx" }, { name = "josepy" }, { name = "jq" }, @@ -48,6 +58,7 @@ dependencies = [ requires-dist = [ { name = "acme", specifier = ">=5.1.0" }, { name = "behave", specifier = ">=1.3.3" }, + { name = "glom", specifier = ">=24.11.0" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "josepy", specifier = ">=2.2.0" }, { name = "jq", specifier = ">=1.10.0" }, @@ -70,6 +81,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/71/06f74ffed6d74525c5cd6677c97bd2df0b7649e47a249cf6a0c2038083b2/behave-1.3.3-py2.py3-none-any.whl", hash = "sha256:89bdb62af8fb9f147ce245736a5de69f025e5edfb66f1fbe16c5007493f842c0", size = 223594, upload-time = "2025-09-04T12:12:00.3Z" }, ] +[[package]] +name = "boltons" +version = "25.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/63/54/71a94d8e02da9a865587fb3fff100cb0fc7aa9f4d5ed9ed3a591216ddcc7/boltons-25.0.0.tar.gz", hash = "sha256:e110fbdc30b7b9868cb604e3f71d4722dd8f4dcb4a5ddd06028ba8f1ab0b5ace", size = 246294, upload-time = "2025-02-03T05:57:59.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/7f/0e961cf3908bc4c1c3e027de2794f867c6c89fb4916fc7dba295a0e80a2d/boltons-25.0.0-py3-none-any.whl", hash = "sha256:dc9fb38bf28985715497d1b54d00b62ea866eca3938938ea9043e254a3a6ca62", size = 194210, upload-time = "2025-02-03T05:57:56.705Z" }, +] + [[package]] name = "certifi" version = "2025.10.5" @@ -276,6 +296,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/51/51ae3ab3b8553ec61f6558e9a0a9e8c500a9db844f9cf00a732b19c9a6ea/cucumber_tag_expressions-8.0.0-py3-none-any.whl", hash = "sha256:bfe552226f62a4462ee91c9643582f524af84ac84952643fb09057580cbb110a", size = 9726, upload-time = "2025-10-14T17:01:26.098Z" }, ] +[[package]] +name = "face" +version = "24.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "boltons" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/79/2484075a8549cd64beae697a8f664dee69a5ccf3a7439ee40c8f93c1978a/face-24.0.0.tar.gz", hash = "sha256:611e29a01ac5970f0077f9c577e746d48c082588b411b33a0dd55c4d872949f6", size = 62732, upload-time = "2024-11-02T05:24:26.095Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/47/21867c2e5fd006c8d36a560df9e32cb4f1f566b20c5dd41f5f8a2124f7de/face-24.0.0-py3-none-any.whl", hash = "sha256:0e2c17b426fa4639a4e77d1de9580f74a98f4869ba4c7c8c175b810611622cd3", size = 54742, upload-time = "2024-11-02T05:24:24.939Z" }, +] + +[[package]] +name = "glom" +version = "24.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "boltons" }, + { name = "face" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/89/b57cfbc448189426f2e01b244fbe9226b059ef5423a9d49c1d335a1f1026/glom-24.11.0.tar.gz", hash = "sha256:4325f96759a912044af7b6c6bd0dba44ad8c1eb6038aab057329661d2021bb27", size = 195120, upload-time = "2024-11-02T23:17:50.405Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/a2/75fd80784ec33da8d39cf885e8811a4fbc045a90db5e336b8e345e66dbb2/glom-24.11.0-py3-none-any.whl", hash = "sha256:991db7fcb4bfa9687010aa519b7b541bbe21111e70e58fdd2d7e34bbaa2c1fbd", size = 102690, upload-time = "2024-11-02T23:17:46.468Z" }, +] + [[package]] name = "h11" version = "0.16.0" From e4655532a8cb21d547ea7781a2f48b2b0fe3eaba Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Thu, 30 Oct 2025 12:55:24 -0700 Subject: [PATCH 073/231] More asserts --- .../bdd/features/pki/acme/new-order.feature | 3 ++- backend/bdd/features/steps/pki_acme.py | 22 ++++++++++++++----- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/backend/bdd/features/pki/acme/new-order.feature b/backend/bdd/features/pki/acme/new-order.feature index a80c3ed58..dec1c7c42 100644 --- a/backend/bdd/features/pki/acme/new-order.feature +++ b/backend/bdd/features/pki/acme/new-order.feature @@ -16,4 +16,5 @@ Feature: New Order Then I create a RSA private key pair as cert_key Then I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format Then I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order - Then the value order.uri should be true for startswith("{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/orders/") + Then the value order.uri should be true for jq startswith("{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/orders/") + Then the value order.body.status.name should be "pending" diff --git a/backend/bdd/features/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py index 7cf5c4980..cb8ee88c9 100644 --- a/backend/bdd/features/steps/pki_acme.py +++ b/backend/bdd/features/steps/pki_acme.py @@ -38,6 +38,14 @@ def replace_vars(payload: dict | list | int | float | str, vars: dict): return payload +def eval_var(context: Context, var_path: str): + parts = var_path.split(".", 1) + value = context.vars[parts[0]] + if len(parts) == 2: + value = glom.glom(value, parts[1]) + return value + + @given('I have an ACME cert profile as "{profile_var}"') def step_impl(context: Context, profile_var: str): # TODO: Fixed value for now, just to make test much easier, @@ -158,11 +166,15 @@ def step_impl(context: Context, csr_var: str, pk_var: str, pem_var: str): ) -@then("the value {var_path} should be true for {query}") +@then("the value {var_path} should be true for jq {query}") def step_impl(context: Context, var_path: str, query: str): - parts = var_path.split(".", 1) - value = context.vars[parts[0]] - if len(parts) == 2: - value = glom.glom(value, parts[1]) + value = eval_var(context, var_path) result = jq.compile(replace_vars(query, context.vars)).input_value(value).first() assert result, f"{value} does not match {query}" + + +@then("the value {var_path} should be {expected}") +def step_impl(context: Context, var_path: str, expected: str): + value = eval_var(context, var_path) + expected_value = json.loads(expected) + assert value == expected_value, f"{value:!r} does not match {expected_value:!r}" From dca819615f5c92c519cc02b4885d2d00fd50d506 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Thu, 30 Oct 2025 14:02:03 -0700 Subject: [PATCH 074/231] Provide more asserts --- backend/bdd/features/steps/pki_acme.py | 33 +++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/backend/bdd/features/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py index cb8ee88c9..8e82a95a0 100644 --- a/backend/bdd/features/steps/pki_acme.py +++ b/backend/bdd/features/steps/pki_acme.py @@ -9,6 +9,7 @@ from behave import when from behave import then import glom from josepy.jwk import JWKRSA +from josepy import JSONObjectWithFields from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa from cryptography import x509 @@ -38,11 +39,14 @@ def replace_vars(payload: dict | list | int | float | str, vars: dict): return payload -def eval_var(context: Context, var_path: str): +def eval_var(context: Context, var_path: str, as_json: bool = True): parts = var_path.split(".", 1) value = context.vars[parts[0]] if len(parts) == 2: value = glom.glom(value, parts[1]) + if as_json: + if isinstance(value, JSONObjectWithFields): + value = value.to_json() return value @@ -173,8 +177,31 @@ def step_impl(context: Context, var_path: str, query: str): assert result, f"{value} does not match {query}" -@then("the value {var_path} should be {expected}") +def match_value_with_jq(context: Context, var_path: str, jq_query: str, expected: str): + value = eval_var(context, var_path) + result = jq.compile(replace_vars(jq_query, context.vars)).input_value(value).first() + expected_value = json.loads(expected) + assert result == expected_value, ( + f"{json.dumps(value)!r} with jq {jq_query!r}, the result {json.dumps(result)!r} does not match {json.dumps(expected_value)!r}" + ) + + +@then("the value {var_path} with jq {jq_query} should be equal to json") +def step_impl(context: Context, var_path: str, jq_query: str): + return match_value_with_jq( + context=context, var_path=var_path, jq_query=jq_query, expected=context.text + ) + + +@then("the value {var_path} with jq {jq_query} should be equal to {expected}") +def step_impl(context: Context, var_path: str, jq_query: str, expected: str): + return match_value_with_jq( + context=context, var_path=var_path, jq_query=jq_query, expected=expected + ) + + +@then("the value {var_path} should be equal to {expected}") def step_impl(context: Context, var_path: str, expected: str): value = eval_var(context, var_path) expected_value = json.loads(expected) - assert value == expected_value, f"{value:!r} does not match {expected_value:!r}" + assert value == expected_value, f"{value!r} does not match {expected_value!r}" From bdc8d783f1a0e425f9afa61af6017b0c431f06c2 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Thu, 30 Oct 2025 14:05:11 -0700 Subject: [PATCH 075/231] More asserts --- backend/bdd/features/pki/acme/new-order.feature | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/bdd/features/pki/acme/new-order.feature b/backend/bdd/features/pki/acme/new-order.feature index dec1c7c42..35d1f23fe 100644 --- a/backend/bdd/features/pki/acme/new-order.feature +++ b/backend/bdd/features/pki/acme/new-order.feature @@ -17,4 +17,6 @@ Feature: New Order Then I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format Then I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order Then the value order.uri should be true for jq startswith("{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/orders/") - Then the value order.body.status.name should be "pending" + Then the value order.body with jq .status should be equal to "pending" + Then the value order.body with jq .identifiers should be equal to [{"type": "dns", "value": "localhost"}] + Then the value order.body with jq all(.authorizations[]; startswith("{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/authorizations/")) should be equal to true From 0f3dce40265bfe8e5798f13443cbeb46a9d69b0c Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Thu, 30 Oct 2025 16:19:25 -0700 Subject: [PATCH 076/231] Add test for fetch order --- .../bdd/features/pki/acme/new-order.feature | 8 ++- backend/bdd/features/steps/pki_acme.py | 62 +++++++++++++++---- 2 files changed, 57 insertions(+), 13 deletions(-) diff --git a/backend/bdd/features/pki/acme/new-order.feature b/backend/bdd/features/pki/acme/new-order.feature index 35d1f23fe..fee66245f 100644 --- a/backend/bdd/features/pki/acme/new-order.feature +++ b/backend/bdd/features/pki/acme/new-order.feature @@ -16,7 +16,13 @@ Feature: New Order Then I create a RSA private key pair as cert_key Then I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format Then I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order - Then the value order.uri should be true for jq startswith("{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/orders/") + Then the value order.uri with jq . should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/orders/(.+) Then the value order.body with jq .status should be equal to "pending" Then the value order.body with jq .identifiers should be equal to [{"type": "dns", "value": "localhost"}] + Then the value order.body with jq .finalize should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/orders/(.+)/finalize Then the value order.body with jq all(.authorizations[]; startswith("{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/authorizations/")) should be equal to true + Then I send an ACME post-as-get to order.uri as fetched_order + Then the value fetched_order with jq .status should be equal to "pending" + Then the value fetched_order with jq .identifiers should be equal to [{"type": "dns", "value": "localhost"}] + Then the value fetched_order with jq .finalize should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/orders/(.+)/finalize + Then the value fetched_order with jq all(.authorizations[]; startswith("{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/authorizations/")) should be equal to true diff --git a/backend/bdd/features/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py index 8e82a95a0..6167faaa9 100644 --- a/backend/bdd/features/steps/pki_acme.py +++ b/backend/bdd/features/steps/pki_acme.py @@ -1,6 +1,8 @@ import json +import re import jq +import requests from acme import client from acme import messages from behave.runner import Context @@ -47,6 +49,8 @@ def eval_var(context: Context, var_path: str, as_json: bool = True): if as_json: if isinstance(value, JSONObjectWithFields): value = value.to_json() + elif isinstance(value, requests.Response): + value = value.json() return value @@ -121,6 +125,12 @@ def step_impl(context: Context, pem_var: str, order_var: str): context.vars[order_var] = context.acme_client.new_order(context.vars[pem_var]) +@then("I send an ACME post-as-get to {uri_path} as {res_var}") +def step_impl(context: Context, uri_path: str, res_var: str): + uri_value = eval_var(context, uri_path) + context.vars[res_var] = context.acme_client._post_as_get(uri_value) + + @when("I create certificate signing request as {csr_var}") def step_impl(context: Context, csr_var: str): context.vars[csr_var] = x509.CertificateSigningRequestBuilder() @@ -177,26 +187,48 @@ def step_impl(context: Context, var_path: str, query: str): assert result, f"{value} does not match {query}" -def match_value_with_jq(context: Context, var_path: str, jq_query: str, expected: str): +def apply_value_with_jq(context: Context, var_path: str, jq_query: str): value = eval_var(context, var_path) - result = jq.compile(replace_vars(jq_query, context.vars)).input_value(value).first() + return value, jq.compile(replace_vars(jq_query, context.vars)).input_value( + value + ).first() + + +@then("the value {var_path} with jq {jq_query} should be equal to json") +def step_impl(context: Context, var_path: str, jq_query: str): + value, result = apply_value_with_jq( + context=context, + var_path=var_path, + jq_query=jq_query, + ) + expected_value = json.loads(context.text) + assert result == expected_value, ( + f"{json.dumps(value)!r} with jq {jq_query!r}, the result {json.dumps(result)!r} does not match {json.dumps(expected_value)!r}" + ) + + +@then("the value {var_path} with jq {jq_query} should be equal to {expected}") +def step_impl(context: Context, var_path: str, jq_query: str, expected: str): + value, result = apply_value_with_jq( + context=context, + var_path=var_path, + jq_query=jq_query, + ) expected_value = json.loads(expected) assert result == expected_value, ( f"{json.dumps(value)!r} with jq {jq_query!r}, the result {json.dumps(result)!r} does not match {json.dumps(expected_value)!r}" ) -@then("the value {var_path} with jq {jq_query} should be equal to json") -def step_impl(context: Context, var_path: str, jq_query: str): - return match_value_with_jq( - context=context, var_path=var_path, jq_query=jq_query, expected=context.text +@then("the value {var_path} with jq {jq_query} should match pattern {regex}") +def step_impl(context: Context, var_path: str, jq_query: str, regex: str): + value, result = apply_value_with_jq( + context=context, + var_path=var_path, + jq_query=jq_query, ) - - -@then("the value {var_path} with jq {jq_query} should be equal to {expected}") -def step_impl(context: Context, var_path: str, jq_query: str, expected: str): - return match_value_with_jq( - context=context, var_path=var_path, jq_query=jq_query, expected=expected + assert re.match(replace_vars(regex, context.vars), result), ( + f"{json.dumps(value)!r} with jq {jq_query!r}, the result {json.dumps(result)!r} does not match {regex!r}" ) @@ -205,3 +237,9 @@ def step_impl(context: Context, var_path: str, expected: str): value = eval_var(context, var_path) expected_value = json.loads(expected) assert value == expected_value, f"{value!r} does not match {expected_value!r}" + + +@then("I print the value {var_path}") +def step_impl(context: Context, var_path: str): + value = eval_var(context, var_path) + print(json.dumps(value.json(), indent=2)) From 3442ef190593f0abef6bd7202194c3de6994669c Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Thu, 30 Oct 2025 16:22:51 -0700 Subject: [PATCH 077/231] Fix DAL --- .../bdd/features/pki/acme/new-order.feature | 17 +++++++++ .../services/pki-acme/pki-acme-order-dal.ts | 38 +++++++++++-------- 2 files changed, 40 insertions(+), 15 deletions(-) diff --git a/backend/bdd/features/pki/acme/new-order.feature b/backend/bdd/features/pki/acme/new-order.feature index fee66245f..2b8115580 100644 --- a/backend/bdd/features/pki/acme/new-order.feature +++ b/backend/bdd/features/pki/acme/new-order.feature @@ -21,6 +21,23 @@ Feature: New Order Then the value order.body with jq .identifiers should be equal to [{"type": "dns", "value": "localhost"}] Then the value order.body with jq .finalize should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/orders/(.+)/finalize Then the value order.body with jq all(.authorizations[]; startswith("{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/authorizations/")) should be equal to true + + Scenario: Fetch an order + Given I have an ACME cert profile as "acme_profile" + When I have an ACME client connecting to {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory +# # TODO: make it I have an account already instead? + Then I register a new ACME account with email fangpen@infisical.com and EAB key id {acme_profile.eab_kid} with secret {acme_profile.eab_secret} as acme_account + When I create certificate signing request as csr + Then I add names to certificate signing request csr + """ + { + "ORGANIZATION_NAME": "Infisical Inc", + "COMMON_NAME": "localhost" + } + """ + Then I create a RSA private key pair as cert_key + Then I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format + Then I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order Then I send an ACME post-as-get to order.uri as fetched_order Then the value fetched_order with jq .status should be equal to "pending" Then the value fetched_order with jq .identifiers should be equal to [{"type": "dns", "value": "localhost"}] 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 index 78a46b665..70089c757 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-order-dal.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-order-dal.ts @@ -1,10 +1,10 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; -import { TableName, TPkiAcmeAuths } from "@app/db/schemas"; +import { TableName } from "@app/db/schemas"; import { TPkiAcmeOrdersInsert, TPkiAcmeOrdersUpdate } from "@app/db/schemas/pki-acme-orders"; import { DatabaseError } from "@app/lib/errors"; -import { ormify, selectAllTableCols } from "@app/lib/knex"; +import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; export type TPkiAcmeOrderDALFactory = ReturnType; @@ -53,7 +53,7 @@ export const pkiAcmeOrderDALFactory = (db: TDbClient) => { const findByAccountAndOrderIdWithAuthorizations = async (accountId: string, orderId: string, tx?: Knex) => { try { - const order = await (tx || db)(TableName.PkiAcmeOrder) + const rows = await (tx || db)(TableName.PkiAcmeOrder) .join(TableName.PkiAcmeOrderAuth, `${TableName.PkiAcmeOrderAuth}.orderId`, `${TableName.PkiAcmeOrder}.id`) .join(TableName.PkiAcmeAuth, `${TableName.PkiAcmeOrderAuth}.authId`, `${TableName.PkiAcmeAuth}.id`) .select( @@ -61,24 +61,32 @@ export const pkiAcmeOrderDALFactory = (db: TDbClient) => { db.ref("id").withSchema(TableName.PkiAcmeAuth).as("authId"), db.ref("identifierType").withSchema(TableName.PkiAcmeAuth).as("identifierType"), db.ref("identifierValue").withSchema(TableName.PkiAcmeAuth).as("identifierValue"), - db.ref("expiresAt").withSchema(TableName.PkiAcmeAuth).as("expiresAt") + db.ref("expiresAt").withSchema(TableName.PkiAcmeAuth).as("authExpiresAt") ) .where(`${TableName.PkiAcmeOrder}.id`, orderId) .where(`${TableName.PkiAcmeOrder}.accountId`, accountId) - .first(); + .orderBy(`${TableName.PkiAcmeAuth}.identifierValue`, "asc"); - if (!order) { + if (rows.length === 0) { return null; } - return { - ...order, - authorizations: order.authorizations.map((auth: TPkiAcmeAuths) => ({ - id: auth.id, - identifierType: auth.identifierType, - identifierValue: auth.identifierValue, - expiresAt: auth.expiresAt - })) - }; + return sqlNestRelationships({ + data: rows, + key: "id", + parentMapper: (row) => row, + childrenMapper: [ + { + key: "authId", + label: "authorizations" as const, + mapper: ({ authId, identifierType, identifierValue, authExpiresAt }) => ({ + id: authId, + identifierType: identifierType, + identifierValue: identifierValue, + expiresAt: authExpiresAt + }) + } + ] + })?.[0]; } catch (error) { throw new DatabaseError({ error, name: "Find PKI ACME order by id" }); } From 8c2e1379fe77c8e0ca60f39b723f0e0f2149ddfc Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Thu, 30 Oct 2025 16:24:29 -0700 Subject: [PATCH 078/231] Rename --- .../bdd/features/pki/acme/{new-order.feature => order.feature} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename backend/bdd/features/pki/acme/{new-order.feature => order.feature} (99%) diff --git a/backend/bdd/features/pki/acme/new-order.feature b/backend/bdd/features/pki/acme/order.feature similarity index 99% rename from backend/bdd/features/pki/acme/new-order.feature rename to backend/bdd/features/pki/acme/order.feature index 2b8115580..bf16c2b03 100644 --- a/backend/bdd/features/pki/acme/new-order.feature +++ b/backend/bdd/features/pki/acme/order.feature @@ -1,4 +1,4 @@ -Feature: New Order +Feature: Order Scenario: Create a new order Given I have an ACME cert profile as "acme_profile" From a1835c0624e969af095d959d207e3b7f5dbf5eab Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Thu, 30 Oct 2025 16:29:26 -0700 Subject: [PATCH 079/231] More features --- backend/bdd/features/pki/acme/order.feature | 32 +++++++++++++++++++++ backend/bdd/features/steps/pki_acme.py | 7 +++++ 2 files changed, 39 insertions(+) diff --git a/backend/bdd/features/pki/acme/order.feature b/backend/bdd/features/pki/acme/order.feature index bf16c2b03..6632b39a8 100644 --- a/backend/bdd/features/pki/acme/order.feature +++ b/backend/bdd/features/pki/acme/order.feature @@ -22,6 +22,38 @@ Feature: Order Then the value order.body with jq .finalize should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/orders/(.+)/finalize Then the value order.body with jq all(.authorizations[]; startswith("{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/authorizations/")) should be equal to true + Scenario: Create a new order with SANs + Given I have an ACME cert profile as "acme_profile" + When I have an ACME client connecting to {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory +# # TODO: make it I have an account already instead? + Then I register a new ACME account with email fangpen@infisical.com and EAB key id {acme_profile.eab_kid} with secret {acme_profile.eab_secret} as acme_account + When I create certificate signing request as csr + Then I add names to certificate signing request csr + """ + { + "ORGANIZATION_NAME": "Infisical Inc", + "COMMON_NAME": "localhost" + } + """ + Then I add subject alternative name to certificate signing request csr + """ + [ + "example.com", + "infisical.com" + ] + """ + Then I create a RSA private key pair as cert_key + Then I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format + Then I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order + Then the value order.body with jq .identifiers should be equal to json + """ + [ + {"type": "dns", "value": "localhost"}, + {"type": "dns", "value": "example.com"}, + {"type": "dns", "value": "infisical.com"} + ] + """ + Scenario: Fetch an order Given I have an ACME cert profile as "acme_profile" When I have an ACME client connecting to {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory diff --git a/backend/bdd/features/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py index 6167faaa9..9e364e247 100644 --- a/backend/bdd/features/steps/pki_acme.py +++ b/backend/bdd/features/steps/pki_acme.py @@ -232,6 +232,13 @@ def step_impl(context: Context, var_path: str, jq_query: str, regex: str): ) +@then("the value {var_path} should be equal to json") +def step_impl(context: Context, var_path: str): + value = eval_var(context, var_path) + expected_value = json.loads(context.text) + assert value == expected_value, f"{value!r} does not match {expected_value!r}" + + @then("the value {var_path} should be equal to {expected}") def step_impl(context: Context, var_path: str, expected: str): value = eval_var(context, var_path) From 51e6170abf895cad80c31006150735a9ddbcc908 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Thu, 30 Oct 2025 16:31:25 -0700 Subject: [PATCH 080/231] Fix tests --- backend/bdd/features/steps/pki_acme.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/bdd/features/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py index 9e364e247..2df1adfa0 100644 --- a/backend/bdd/features/steps/pki_acme.py +++ b/backend/bdd/features/steps/pki_acme.py @@ -154,7 +154,7 @@ def step_impl(context: Context, csr_var: str): def step_impl(context: Context, csr_var: str): names = json.loads(context.text) builder: x509.CertificateSigningRequestBuilder = context.vars[csr_var] - context[csr_var] = builder.add_extension( + context.vars[csr_var] = builder.add_extension( x509.SubjectAlternativeName([x509.DNSName(name) for name in names]), critical=False, ) From 8cab927cf2f79cc5458eed1a74651f6a72a4180b Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Thu, 30 Oct 2025 16:32:50 -0700 Subject: [PATCH 081/231] Sort values --- backend/bdd/features/pki/acme/order.feature | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/bdd/features/pki/acme/order.feature b/backend/bdd/features/pki/acme/order.feature index 6632b39a8..d9cf5c268 100644 --- a/backend/bdd/features/pki/acme/order.feature +++ b/backend/bdd/features/pki/acme/order.feature @@ -45,12 +45,12 @@ Feature: Order Then I create a RSA private key pair as cert_key Then I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format Then I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order - Then the value order.body with jq .identifiers should be equal to json + Then the value order.body with jq .identifiers | sort_by(.value) should be equal to json """ [ - {"type": "dns", "value": "localhost"}, {"type": "dns", "value": "example.com"}, - {"type": "dns", "value": "infisical.com"} + {"type": "dns", "value": "infisical.com"}, + {"type": "dns", "value": "localhost"} ] """ From 4970428f1d95e642088e03f019ac6f3f92718189 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Thu, 30 Oct 2025 16:33:44 -0700 Subject: [PATCH 082/231] Rename --- .../features/pki/acme/{new-account.feature => account.feature} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename backend/bdd/features/pki/acme/{new-account.feature => account.feature} (94%) diff --git a/backend/bdd/features/pki/acme/new-account.feature b/backend/bdd/features/pki/acme/account.feature similarity index 94% rename from backend/bdd/features/pki/acme/new-account.feature rename to backend/bdd/features/pki/acme/account.feature index 09e616412..1a291bfdb 100644 --- a/backend/bdd/features/pki/acme/new-account.feature +++ b/backend/bdd/features/pki/acme/account.feature @@ -1,4 +1,4 @@ -Feature: New Account +Feature: Account Scenario: Create a new account Given I have an ACME cert profile as "acme_profile" When I have an ACME client connecting to {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory From 31a70bc781985228280eb721c642aefd28abf8b7 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Thu, 30 Oct 2025 16:46:52 -0700 Subject: [PATCH 083/231] Add url check --- backend/src/ee/routes/v1/pki-acme-router.ts | 12 +++- .../ee/services/pki-acme/pki-acme-service.ts | 66 ++++++++++++------- .../ee/services/pki-acme/pki-acme-types.ts | 26 ++++++-- 3 files changed, 73 insertions(+), 31 deletions(-) diff --git a/backend/src/ee/routes/v1/pki-acme-router.ts b/backend/src/ee/routes/v1/pki-acme-router.ts index 4794a125a..a35e54cc1 100644 --- a/backend/src/ee/routes/v1/pki-acme-router.ts +++ b/backend/src/ee/routes/v1/pki-acme-router.ts @@ -121,7 +121,10 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { } }, handler: async (req, res) => { - const { payload, protectedHeader } = await server.services.pkiAcme.validateNewAccountJwsPayload(req.body); + const { payload, protectedHeader } = await server.services.pkiAcme.validateNewAccountJwsPayload({ + url: req.url, + rawJwsPayload: req.body + }); const { alg, jwk } = protectedHeader; return sendAcmeResponse( res, @@ -161,6 +164,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { // onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req, res) => { const { payload, profileId, accountId } = await server.services.pkiAcme.validateExistingAccountJwsPayload({ + url: req.url, profileId: req.params.profileId, rawJwsPayload: req.body, schema: DeactivateAcmeAccountBodySchema, @@ -202,6 +206,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { // onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req, res) => { const { profileId, accountId, payload } = await server.services.pkiAcme.validateExistingAccountJwsPayload({ + url: req.url, profileId: req.params.profileId, rawJwsPayload: req.body, schema: CreateAcmeOrderBodySchema @@ -243,6 +248,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { // onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req, res) => { const { profileId, accountId } = await server.services.pkiAcme.validateExistingAccountJwsPayload({ + url: req.url, profileId: req.params.profileId, rawJwsPayload: req.body }); @@ -283,6 +289,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { // onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req, res) => { const { profileId, accountId, payload } = await server.services.pkiAcme.validateExistingAccountJwsPayload({ + url: req.url, profileId: req.params.profileId, rawJwsPayload: req.body, schema: FinalizeAcmeOrderBodySchema @@ -324,6 +331,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { // onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req, res) => { const { profileId, accountId } = await server.services.pkiAcme.validateExistingAccountJwsPayload({ + url: req.url, profileId: req.params.profileId, rawJwsPayload: req.body, schema: ListAcmeOrdersPayloadSchema, @@ -365,6 +373,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { // onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req, res) => { const { profileId, accountId } = await server.services.pkiAcme.validateExistingAccountJwsPayload({ + url: req.url, profileId: req.params.profileId, rawJwsPayload: req.body }); @@ -401,6 +410,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { // onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req, res) => { const { profileId, accountId, payload } = await server.services.pkiAcme.validateExistingAccountJwsPayload({ + url: req.url, profileId: req.params.profileId, rawJwsPayload: req.body }); 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 fe823dceb..97e08d757 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -19,6 +19,7 @@ import { AcmeBadPublicKeyError, AcmeMalformedError, AcmeServerInternalError, + AcmeUnauthorizedError, AcmeUnsupportedIdentifierError } from "./pki-acme-errors"; import { TPkiAcmeOrderAuthDALFactory } from "./pki-acme-order-auth-dal"; @@ -92,11 +93,17 @@ export const pkiAcmeServiceFactory = ({ const validateJwsPayload = async < TSchema extends z.ZodSchema | undefined = undefined, T = TSchema extends z.ZodSchema ? R : string - >( - rawJwsPayload: TRawJwsPayload, - getJWK: (protectedHeader: JWSHeaderParameters) => Promise, - schema?: TSchema - ): Promise> => { + >({ + url, + rawJwsPayload, + getJWK, + schema + }: { + url: string; + rawJwsPayload: TRawJwsPayload; + getJWK: (protectedHeader: JWSHeaderParameters) => Promise; + schema?: TSchema; + }): Promise> => { let result: FlattenedVerifyResult; try { result = await flattenedVerify(rawJwsPayload, async (protectedHeader: JWSHeaderParameters | undefined) => { @@ -119,6 +126,9 @@ export const pkiAcmeServiceFactory = ({ const { protectedHeader: rawProtectedHeader, payload: rawPayload } = result; try { const protectedHeader = ProtectedHeaderSchema.parse(rawProtectedHeader); + if (protectedHeader.url !== url) { + throw new AcmeUnauthorizedError({ detail: "URL mismatch in the protected header" }); + } // TODO: consume the nonce here const decoder = new TextDecoder(); const textPayload = decoder.decode(rawPayload); @@ -136,39 +146,47 @@ export const pkiAcmeServiceFactory = ({ } }; - const validateNewAccountJwsPayload = async ( - rawJwsPayload: TRawJwsPayload - ): Promise> => { - return await validateJwsPayload( + const validateNewAccountJwsPayload = async ({ + url, + rawJwsPayload + }: { + url: string; + rawJwsPayload: TRawJwsPayload; + }): Promise> => { + return await validateJwsPayload({ + url, rawJwsPayload, - async (protectedHeader) => { + getJWK: async (protectedHeader) => { if (!protectedHeader.jwk) { throw new AcmeMalformedError({ detail: "JWK is required in the protected header" }); } return protectedHeader.jwk as unknown as JsonWebKey; }, - CreateAcmeAccountBodySchema - ); + schema: CreateAcmeAccountBodySchema + }); }; const validateExistingAccountJwsPayload = async < TSchema extends z.ZodSchema | undefined = undefined, T = TSchema extends z.ZodSchema ? R : string >({ + url, profileId, rawJwsPayload, schema, expectedAccountId }: { + url: string; profileId: string; rawJwsPayload: TRawJwsPayload; schema?: TSchema; expectedAccountId?: string; }): Promise> => { const profile = await validateAcmeProfile(profileId); - const result = await validateJwsPayload( + const result = await validateJwsPayload({ + url, rawJwsPayload, - async (protectedHeader) => { + getJWK: async (protectedHeader) => { if (!protectedHeader.kid) { throw new AcmeMalformedError({ detail: "KID is required in the protected header" }); } @@ -186,7 +204,7 @@ export const pkiAcmeServiceFactory = ({ return account.publicKey as JsonWebKey; }, schema - ); + }); return { ...result, accountId: extractAccountIdFromKid(result.protectedHeader.kid!, profileId), @@ -194,15 +212,6 @@ export const pkiAcmeServiceFactory = ({ }; }; - const getAcmeDirectory = async (profileId: string): Promise => { - const profile = await validateAcmeProfile(profileId); - return { - newNonce: buildUrl(`/api/v1/pki/acme/profiles/${profile.id}/new-nonce`), - newAccount: buildUrl(`/api/v1/pki/acme/profiles/${profile.id}/new-account`), - newOrder: buildUrl(`/api/v1/pki/acme/profiles/${profile.id}/new-order`) - }; - }; - const buildAcmeOrderResource = ({ profileId, order @@ -233,6 +242,15 @@ export const pkiAcmeServiceFactory = ({ }; }; + const getAcmeDirectory = async (profileId: string): Promise => { + const profile = await validateAcmeProfile(profileId); + return { + newNonce: buildUrl(`/api/v1/pki/acme/profiles/${profile.id}/new-nonce`), + newAccount: buildUrl(`/api/v1/pki/acme/profiles/${profile.id}/new-account`), + newOrder: buildUrl(`/api/v1/pki/acme/profiles/${profile.id}/new-order`) + }; + }; + const getAcmeNewNonce = async (profileId: string): Promise => { const profile = await validateAcmeProfile(profileId); // FIXME: Implement ACME new nonce generation 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 8fded06d4..1f962a220 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-types.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-types.ts @@ -52,21 +52,35 @@ export type TPkiAcmeServiceFactory = { validateJwsPayload: < TSchema extends z.ZodSchema | undefined = undefined, T = TSchema extends z.ZodSchema ? R : string - >( - rawJwsPayload: TRawJwsPayload, - getJWK: (protectedHeader: JWSHeaderParameters) => Promise, - schema?: TSchema - ) => Promise>; - validateNewAccountJwsPayload: (rawJwsPayload: TRawJwsPayload) => Promise>; + >({ + url, + rawJwsPayload, + getJWK, + schema + }: { + url: string; + rawJwsPayload: TRawJwsPayload; + getJWK: (protectedHeader: JWSHeaderParameters) => Promise; + schema?: z.ZodSchema; + }) => Promise>; + validateNewAccountJwsPayload: ({ + url, + rawJwsPayload + }: { + url: string; + rawJwsPayload: TRawJwsPayload; + }) => Promise>; validateExistingAccountJwsPayload: < TSchema extends z.ZodSchema | undefined = undefined, T = TSchema extends z.ZodSchema ? R : string >({ + url, profileId, rawJwsPayload, schema, expectedAccountId }: { + url: string; profileId: string; rawJwsPayload: TRawJwsPayload; schema?: TSchema; From 1d19fb4788dde06045f6cb92a7e0578f4cdec283 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Thu, 30 Oct 2025 16:54:09 -0700 Subject: [PATCH 084/231] Remove not needed dal methods --- .../services/pki-acme/pki-acme-account-dal.ts | 17 ----- .../ee/services/pki-acme/pki-acme-auth-dal.ts | 68 +++++++++---------- .../pki-acme/pki-acme-order-auth-dal.ts | 16 +---- .../services/pki-acme/pki-acme-order-dal.ts | 44 ------------ 4 files changed, 32 insertions(+), 113 deletions(-) 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 a460ddacc..bbfe042ee 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 @@ -2,7 +2,6 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; import { TableName } from "@app/db/schemas"; -import { TPkiAcmeAccountsInsert, TPkiAcmeAccountsUpdate } from "@app/db/schemas/pki-acme-accounts"; import { DatabaseError } from "@app/lib/errors"; import { ormify } from "@app/lib/knex"; @@ -11,21 +10,6 @@ export type TPkiAcmeAccountDALFactory = ReturnType { const pkiAcmeAccountOrm = ormify(db, TableName.PkiAcmeAccount); - const create = async (data: TPkiAcmeAccountsInsert, tx?: Knex) => { - try { - const result = await (tx || db)(TableName.PkiAcmeAccount).insert(data).returning("*"); - const [account] = result; - - if (!account) { - throw new Error("Failed to create PKI ACME account"); - } - - return account; - } catch (error) { - throw new DatabaseError({ error, name: "Create PKI ACME account" }); - } - }; - const findByProjectIdAndAccountId = async (profileId: string, id: string, tx?: Knex) => { try { const account = await (tx || db)(TableName.PkiAcmeAccount).where({ profileId, id }).first(); @@ -48,7 +32,6 @@ export const pkiAcmeAccountDALFactory = (db: TDbClient) => { return { ...pkiAcmeAccountOrm, - create, findByProjectIdAndAccountId, findByPublicKey }; 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 index 97d258450..d03275f9b 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-auth-dal.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-auth-dal.ts @@ -2,59 +2,53 @@ 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"; +import { ormify, selectAllTableCols, sqlNestRelationships } 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) => { + const findByAccountIdAndAuthIdWithChallenges = async (accountId: string, authId: string, tx?: Knex) => { try { - const result = await (tx || db)(TableName.PkiAcmeAuth).insert(data).returning("*"); - const [auth] = result; + const rows = await (tx || db)(TableName.PkiAcmeAuth) + .join(TableName.PkiAcmeChallenge, `${TableName.PkiAcmeChallenge}.authId`, `${TableName.PkiAcmeAuth}.id`) + .select( + selectAllTableCols(TableName.PkiAcmeAuth), + db.ref("id").withSchema(TableName.PkiAcmeChallenge).as("challengeId"), + db.ref("token").withSchema(TableName.PkiAcmeChallenge).as("challengeToken"), + db.ref("status").withSchema(TableName.PkiAcmeChallenge).as("challengeStatus") + ) + .where(`${TableName.PkiAcmeAuth}.accountId`, accountId) + .where(`${TableName.PkiAcmeAuth}.id`, authId); - 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) { + if (rows.length === 0) { return null; } - - return auth; + return sqlNestRelationships({ + data: rows, + key: "id", + parentMapper: (row) => row, + childrenMapper: [ + { + key: "challengeId", + label: "challenges" as const, + mapper: ({ challengeId, challengeToken, challengeStatus }) => ({ + id: challengeId, + token: challengeToken, + status: challengeStatus + }) + } + ] + })?.[0]; } 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" }); + throw new DatabaseError({ error, name: "Find PKI ACME auth by account id and auth id with challenges" }); } }; return { ...pkiAcmeAuthOrm, - create, - updateById, - findById + findByAccountIdAndAuthIdWithChallenges }; }; diff --git a/backend/src/ee/services/pki-acme/pki-acme-order-auth-dal.ts b/backend/src/ee/services/pki-acme/pki-acme-order-auth-dal.ts index 87b6f8211..5a91e6fea 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-order-auth-dal.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-order-auth-dal.ts @@ -1,9 +1,5 @@ -import { Knex } from "knex"; - import { TDbClient } from "@app/db"; import { TableName } from "@app/db/schemas"; -import { TPkiAcmeOrderAuthsInsert } from "@app/db/schemas/pki-acme-order-auths"; -import { DatabaseError } from "@app/lib/errors"; import { ormify } from "@app/lib/knex"; export type TPkiAcmeOrderAuthDALFactory = ReturnType; @@ -11,17 +7,7 @@ export type TPkiAcmeOrderAuthDALFactory = ReturnType { const pkiAcmeOrderAuthOrm = ormify(db, TableName.PkiAcmeOrderAuth); - const insertMany = async (rows: TPkiAcmeOrderAuthsInsert[], tx?: Knex) => { - try { - const result = await (tx || db)(TableName.PkiAcmeOrderAuth).insert(rows).returning("*"); - return result; - } catch (error) { - throw new DatabaseError({ error, name: "Insert many PKI ACME order auths" }); - } - }; - return { - ...pkiAcmeOrderAuthOrm, - insertMany + ...pkiAcmeOrderAuthOrm }; }; 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 index 70089c757..d3b7466e4 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-order-dal.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-order-dal.ts @@ -2,7 +2,6 @@ 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, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; @@ -11,46 +10,6 @@ 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 findByAccountAndOrderIdWithAuthorizations = async (accountId: string, orderId: string, tx?: Knex) => { try { const rows = await (tx || db)(TableName.PkiAcmeOrder) @@ -94,9 +53,6 @@ export const pkiAcmeOrderDALFactory = (db: TDbClient) => { return { ...pkiAcmeOrderOrm, - create, - updateById, - findById, findByAccountAndOrderIdWithAuthorizations }; }; From e2799b934ad3c246c15d1a50b5680e650c716da2 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Thu, 30 Oct 2025 16:59:21 -0700 Subject: [PATCH 085/231] Implement getting auth --- .../ee/services/pki-acme/pki-acme-auth-dal.ts | 6 ++--- .../ee/services/pki-acme/pki-acme-service.ts | 24 ++++++++++--------- 2 files changed, 16 insertions(+), 14 deletions(-) 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 index d03275f9b..880d54dc9 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-auth-dal.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-auth-dal.ts @@ -17,7 +17,7 @@ export const pkiAcmeAuthDALFactory = (db: TDbClient) => { .select( selectAllTableCols(TableName.PkiAcmeAuth), db.ref("id").withSchema(TableName.PkiAcmeChallenge).as("challengeId"), - db.ref("token").withSchema(TableName.PkiAcmeChallenge).as("challengeToken"), + db.ref("type").withSchema(TableName.PkiAcmeChallenge).as("challengeType"), db.ref("status").withSchema(TableName.PkiAcmeChallenge).as("challengeStatus") ) .where(`${TableName.PkiAcmeAuth}.accountId`, accountId) @@ -34,9 +34,9 @@ export const pkiAcmeAuthDALFactory = (db: TDbClient) => { { key: "challengeId", label: "challenges" as const, - mapper: ({ challengeId, challengeToken, challengeStatus }) => ({ + mapper: ({ challengeId, challengeType, challengeStatus }) => ({ id: challengeId, - token: challengeToken, + type: challengeType, status: challengeStatus }) } 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 97e08d757..0658886a4 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -49,12 +49,13 @@ import { TRawJwsPayload, TRespondToAcmeChallengeResponse } from "./pki-acme-types"; +import { TPkiAcmeChallenges } from "@app/db/schemas"; type TPkiAcmeServiceFactoryDep = { certificateProfileDAL: Pick; acmeAccountDAL: Pick; acmeOrderDAL: Pick; - acmeAuthDAL: Pick; + acmeAuthDAL: Pick; acmeOrderAuthDAL: Pick; }; @@ -514,8 +515,8 @@ export const pkiAcmeServiceFactory = ({ accountId: string; authzId: string; }): Promise> => { - const auth = await acmeAuthDAL.findById(authzId); - if (!auth || auth.accountId !== accountId) { + const auth = await acmeAuthDAL.findByAccountIdAndAuthIdWithChallenges(accountId, authzId); + if (!auth) { throw new NotFoundError({ message: "ACME authorization not found" }); } return { @@ -527,15 +528,16 @@ export const pkiAcmeServiceFactory = ({ type: auth.identifierType, value: auth.identifierValue }, - challenges: [ - // TODO: fixme - { - type: "http-01", - url: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/authorizations/${authzId}/challenges/http-01`), - status: "pending", + challenges: auth.challenges.map((challenge: TPkiAcmeChallenges) => { + return { + type: challenge.type, + url: buildUrl( + `/api/v1/pki/acme/profiles/${profileId}/authorizations/${authzId}/challenges/${challenge.id}` + ), + status: challenge.status, token: auth.token - } - ] + }; + }) }, headers: { Location: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/authorizations/${authzId}`) From 744b02ee005dba58febfb4ce77a3198e2c736dd9 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Thu, 30 Oct 2025 17:33:42 -0700 Subject: [PATCH 086/231] Refactor the code a bit --- backend/bdd/features/pki/acme/auth.feature | 77 +++++++++++++++++++ backend/src/ee/routes/v1/pki-acme-router.ts | 76 +++++++++--------- .../ee/services/pki-acme/pki-acme-service.ts | 17 ++-- .../ee/services/pki-acme/pki-acme-types.ts | 10 +-- backend/src/server/plugins/error-handler.ts | 1 + 5 files changed, 136 insertions(+), 45 deletions(-) create mode 100644 backend/bdd/features/pki/acme/auth.feature diff --git a/backend/bdd/features/pki/acme/auth.feature b/backend/bdd/features/pki/acme/auth.feature new file mode 100644 index 000000000..d9cf5c268 --- /dev/null +++ b/backend/bdd/features/pki/acme/auth.feature @@ -0,0 +1,77 @@ +Feature: Order + + Scenario: Create a new order + Given I have an ACME cert profile as "acme_profile" + When I have an ACME client connecting to {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory +# # TODO: make it I have an account already instead? + Then I register a new ACME account with email fangpen@infisical.com and EAB key id {acme_profile.eab_kid} with secret {acme_profile.eab_secret} as acme_account + When I create certificate signing request as csr + Then I add names to certificate signing request csr + """ + { + "ORGANIZATION_NAME": "Infisical Inc", + "COMMON_NAME": "localhost" + } + """ + Then I create a RSA private key pair as cert_key + Then I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format + Then I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order + Then the value order.uri with jq . should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/orders/(.+) + Then the value order.body with jq .status should be equal to "pending" + Then the value order.body with jq .identifiers should be equal to [{"type": "dns", "value": "localhost"}] + Then the value order.body with jq .finalize should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/orders/(.+)/finalize + Then the value order.body with jq all(.authorizations[]; startswith("{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/authorizations/")) should be equal to true + + Scenario: Create a new order with SANs + Given I have an ACME cert profile as "acme_profile" + When I have an ACME client connecting to {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory +# # TODO: make it I have an account already instead? + Then I register a new ACME account with email fangpen@infisical.com and EAB key id {acme_profile.eab_kid} with secret {acme_profile.eab_secret} as acme_account + When I create certificate signing request as csr + Then I add names to certificate signing request csr + """ + { + "ORGANIZATION_NAME": "Infisical Inc", + "COMMON_NAME": "localhost" + } + """ + Then I add subject alternative name to certificate signing request csr + """ + [ + "example.com", + "infisical.com" + ] + """ + Then I create a RSA private key pair as cert_key + Then I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format + Then I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order + Then the value order.body with jq .identifiers | sort_by(.value) should be equal to json + """ + [ + {"type": "dns", "value": "example.com"}, + {"type": "dns", "value": "infisical.com"}, + {"type": "dns", "value": "localhost"} + ] + """ + + Scenario: Fetch an order + Given I have an ACME cert profile as "acme_profile" + When I have an ACME client connecting to {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory +# # TODO: make it I have an account already instead? + Then I register a new ACME account with email fangpen@infisical.com and EAB key id {acme_profile.eab_kid} with secret {acme_profile.eab_secret} as acme_account + When I create certificate signing request as csr + Then I add names to certificate signing request csr + """ + { + "ORGANIZATION_NAME": "Infisical Inc", + "COMMON_NAME": "localhost" + } + """ + Then I create a RSA private key pair as cert_key + Then I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format + Then I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order + Then I send an ACME post-as-get to order.uri as fetched_order + Then the value fetched_order with jq .status should be equal to "pending" + Then the value fetched_order with jq .identifiers should be equal to [{"type": "dns", "value": "localhost"}] + Then the value fetched_order with jq .finalize should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/orders/(.+)/finalize + Then the value fetched_order with jq all(.authorizations[]; startswith("{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/authorizations/")) should be equal to true diff --git a/backend/src/ee/routes/v1/pki-acme-router.ts b/backend/src/ee/routes/v1/pki-acme-router.ts index a35e54cc1..37df5aa36 100644 --- a/backend/src/ee/routes/v1/pki-acme-router.ts +++ b/backend/src/ee/routes/v1/pki-acme-router.ts @@ -1,8 +1,9 @@ /* eslint-disable @typescript-eslint/no-floating-promises */ -import type { TAcmeResponse } from "@app/ee/services/pki-acme/pki-acme-types"; -import { FastifyReply } from "fastify"; +import type { TAcmeResponse, TAuthenciatedJwsPayload, TRawJwsPayload } from "@app/ee/services/pki-acme/pki-acme-types"; +import { FastifyReply, FastifyRequest } from "fastify"; import { z } from "zod"; +import { AcmeMalformedError } from "@app/ee/services/pki-acme/pki-acme-errors"; import { AcmeOrderResourceSchema, CreateAcmeAccountResponseSchema, @@ -19,9 +20,27 @@ import { } from "@app/ee/services/pki-acme/pki-acme-schemas"; import { ApiDocsTags } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; -import { AcmeAccountDoesNotExistError, AcmeMalformedError } from "@app/ee/services/pki-acme/pki-acme-errors"; export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { + const validateExistingAccount = async < + TSchema extends z.ZodSchema | undefined = undefined, + T = TSchema extends z.ZodSchema ? R : string + >({ + req, + schema + }: { + req: FastifyRequest<{ Params: { profileId: string; accountId?: string }; Body: TRawJwsPayload }>; + schema?: TSchema; + }): Promise> => { + return await server.services.pkiAcme.validateExistingAccountJwsPayload({ + url: new URL(req.url, `${req.protocol}://${req.hostname}`), + profileId: req.params.profileId, + rawJwsPayload: req.body, + schema, + expectedAccountId: req.params.accountId + }); + }; + const sendAcmeResponse = async (res: FastifyReply, profileId: string, response: TAcmeResponse): Promise => { res.code(response.status); for (const [key, value] of Object.entries(response.headers)) { @@ -163,12 +182,9 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { // TODO: replace with verify ACME signature here instead // onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req, res) => { - const { payload, profileId, accountId } = await server.services.pkiAcme.validateExistingAccountJwsPayload({ - url: req.url, - profileId: req.params.profileId, - rawJwsPayload: req.body, - schema: DeactivateAcmeAccountBodySchema, - expectedAccountId: req.params.accountId + const { payload, profileId, accountId } = await validateExistingAccount({ + req, + schema: DeactivateAcmeAccountBodySchema }); return sendAcmeResponse( res, @@ -205,10 +221,8 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { // TODO: replace with verify ACME signature here instead // onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req, res) => { - const { profileId, accountId, payload } = await server.services.pkiAcme.validateExistingAccountJwsPayload({ - url: req.url, - profileId: req.params.profileId, - rawJwsPayload: req.body, + const { profileId, accountId, payload } = await validateExistingAccount({ + req, schema: CreateAcmeOrderBodySchema }); return sendAcmeResponse( @@ -247,10 +261,9 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { // TODO: replace with verify ACME signature here instead // onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req, res) => { - const { profileId, accountId } = await server.services.pkiAcme.validateExistingAccountJwsPayload({ - url: req.url, - profileId: req.params.profileId, - rawJwsPayload: req.body + const { profileId, accountId } = await validateExistingAccount({ + req, + schema: FinalizeAcmeOrderBodySchema }); return sendAcmeResponse( res, @@ -288,10 +301,8 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { // TODO: replace with verify ACME signature here instead // onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req, res) => { - const { profileId, accountId, payload } = await server.services.pkiAcme.validateExistingAccountJwsPayload({ - url: req.url, - profileId: req.params.profileId, - rawJwsPayload: req.body, + const { profileId, accountId, payload } = await validateExistingAccount({ + req, schema: FinalizeAcmeOrderBodySchema }); return sendAcmeResponse( @@ -330,12 +341,9 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { // TODO: replace with verify ACME signature here instead // onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req, res) => { - const { profileId, accountId } = await server.services.pkiAcme.validateExistingAccountJwsPayload({ - url: req.url, - profileId: req.params.profileId, - rawJwsPayload: req.body, - schema: ListAcmeOrdersPayloadSchema, - expectedAccountId: req.params.accountId + const { profileId, accountId } = await validateExistingAccount({ + req, + schema: ListAcmeOrdersPayloadSchema }); return sendAcmeResponse( res, @@ -372,10 +380,9 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { // TODO: replace with verify ACME signature here instead // onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req, res) => { - const { profileId, accountId } = await server.services.pkiAcme.validateExistingAccountJwsPayload({ - url: req.url, - profileId: req.params.profileId, - rawJwsPayload: req.body + const { profileId, accountId } = await validateExistingAccount({ + req, + schema: FinalizeAcmeOrderBodySchema }); return sendAcmeResponse( res, @@ -409,10 +416,9 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { // TODO: replace with verify ACME signature here instead // onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req, res) => { - const { profileId, accountId, payload } = await server.services.pkiAcme.validateExistingAccountJwsPayload({ - url: req.url, - profileId: req.params.profileId, - rawJwsPayload: req.body + const { profileId, accountId, payload } = await validateExistingAccount({ + req, + schema: GetAcmeAuthorizationBodySchema }); if (payload !== "") { throw new AcmeMalformedError({ detail: "Payload should be empty" }); 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 0658886a4..3508d8c93 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -6,6 +6,7 @@ import { NotFoundError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; +import { TPkiAcmeChallenges } from "@app/db/schemas"; import { EnrollmentType, TCertificateProfileWithConfigs @@ -17,6 +18,7 @@ import { TPkiAcmeAuthDALFactory } from "./pki-acme-auth-dal"; import { AcmeAccountDoesNotExistError, AcmeBadPublicKeyError, + AcmeError, AcmeMalformedError, AcmeServerInternalError, AcmeUnauthorizedError, @@ -49,7 +51,6 @@ import { TRawJwsPayload, TRespondToAcmeChallengeResponse } from "./pki-acme-types"; -import { TPkiAcmeChallenges } from "@app/db/schemas"; type TPkiAcmeServiceFactoryDep = { certificateProfileDAL: Pick; @@ -100,7 +101,7 @@ export const pkiAcmeServiceFactory = ({ getJWK, schema }: { - url: string; + url: URL; rawJwsPayload: TRawJwsPayload; getJWK: (protectedHeader: JWSHeaderParameters) => Promise; schema?: TSchema; @@ -115,6 +116,9 @@ export const pkiAcmeServiceFactory = ({ return await importJWK(jwk, protectedHeader.alg); }); } catch (error) { + if (error instanceof AcmeError) { + throw error; + } if (error instanceof ZodError) { throw new AcmeMalformedError({ detail: `Invalid JWS payload: ${error.message}` }); } @@ -127,7 +131,7 @@ export const pkiAcmeServiceFactory = ({ const { protectedHeader: rawProtectedHeader, payload: rawPayload } = result; try { const protectedHeader = ProtectedHeaderSchema.parse(rawProtectedHeader); - if (protectedHeader.url !== url) { + if (new URL(protectedHeader.url).href !== url.href) { throw new AcmeUnauthorizedError({ detail: "URL mismatch in the protected header" }); } // TODO: consume the nonce here @@ -139,6 +143,9 @@ export const pkiAcmeServiceFactory = ({ payload }; } catch (error) { + if (error instanceof AcmeError) { + throw error; + } if (error instanceof ZodError) { throw new AcmeMalformedError({ detail: `Invalid JWS payload: ${error.message}` }); } @@ -151,7 +158,7 @@ export const pkiAcmeServiceFactory = ({ url, rawJwsPayload }: { - url: string; + url: URL; rawJwsPayload: TRawJwsPayload; }): Promise> => { return await validateJwsPayload({ @@ -177,7 +184,7 @@ export const pkiAcmeServiceFactory = ({ schema, expectedAccountId }: { - url: string; + url: URL; profileId: string; rawJwsPayload: TRawJwsPayload; schema?: TSchema; 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 1f962a220..082078f2a 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-types.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-types.ts @@ -58,16 +58,16 @@ export type TPkiAcmeServiceFactory = { getJWK, schema }: { - url: string; + url: URL; rawJwsPayload: TRawJwsPayload; getJWK: (protectedHeader: JWSHeaderParameters) => Promise; - schema?: z.ZodSchema; - }) => Promise>; + schema?: TSchema; + }) => Promise>; validateNewAccountJwsPayload: ({ url, rawJwsPayload }: { - url: string; + url: URL; rawJwsPayload: TRawJwsPayload; }) => Promise>; validateExistingAccountJwsPayload: < @@ -80,7 +80,7 @@ export type TPkiAcmeServiceFactory = { schema, expectedAccountId }: { - url: string; + url: URL; profileId: string; rawJwsPayload: TRawJwsPayload; schema?: TSchema; diff --git a/backend/src/server/plugins/error-handler.ts b/backend/src/server/plugins/error-handler.ts index 50251377d..8f6b63141 100644 --- a/backend/src/server/plugins/error-handler.ts +++ b/backend/src/server/plugins/error-handler.ts @@ -248,6 +248,7 @@ export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider .type("application/problem+json") .status(error.status) .send({ + reqId: req.id, status: error.status, type: `urn:ietf:params:acme:error:${error.type}`, detail: error.detail From 1677909416e2edb07396bd6a6aaef9023a4af688 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Thu, 30 Oct 2025 18:16:06 -0700 Subject: [PATCH 087/231] Fix ts --- backend/src/ee/routes/v1/pki-acme-router.ts | 49 ++++++++++----------- 1 file changed, 24 insertions(+), 25 deletions(-) diff --git a/backend/src/ee/routes/v1/pki-acme-router.ts b/backend/src/ee/routes/v1/pki-acme-router.ts index 37df5aa36..545794323 100644 --- a/backend/src/ee/routes/v1/pki-acme-router.ts +++ b/backend/src/ee/routes/v1/pki-acme-router.ts @@ -21,23 +21,33 @@ import { import { ApiDocsTags } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +const SharedParamsSchema = z.object({ + profileId: z.string().uuid() +}); + +export interface MyRequestInterface { + Params: { profileId: string; accountId?: string }; + Body: TRawJwsPayload; +} + export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { const validateExistingAccount = async < + R extends FastifyRequest, TSchema extends z.ZodSchema | undefined = undefined, T = TSchema extends z.ZodSchema ? R : string >({ req, schema }: { - req: FastifyRequest<{ Params: { profileId: string; accountId?: string }; Body: TRawJwsPayload }>; + req: R; schema?: TSchema; }): Promise> => { return await server.services.pkiAcme.validateExistingAccountJwsPayload({ url: new URL(req.url, `${req.protocol}://${req.hostname}`), - profileId: req.params.profileId, - rawJwsPayload: req.body, + profileId: (req.params as { profileId: string }).profileId, + rawJwsPayload: req.body as TRawJwsPayload, schema, - expectedAccountId: req.params.accountId + expectedAccountId: (req.params as { accountId?: string }).accountId }); }; @@ -131,9 +141,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { hide: false, tags: [ApiDocsTags.PkiAcme], description: "ACME New Account - register a new account or find existing one", - params: z.object({ - profileId: z.string().uuid() - }), + params: SharedParamsSchema, body: RawJwsPayloadSchema, response: { 201: CreateAcmeAccountResponseSchema @@ -141,7 +149,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { }, handler: async (req, res) => { const { payload, protectedHeader } = await server.services.pkiAcme.validateNewAccountJwsPayload({ - url: req.url, + url: new URL(req.url, `${req.protocol}://${req.hostname}`), rawJwsPayload: req.body }); const { alg, jwk } = protectedHeader; @@ -170,8 +178,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { hide: false, tags: [ApiDocsTags.PkiAcme], description: "ACME Account Deactivation", - params: z.object({ - profileId: z.string().uuid(), + params: SharedParamsSchema.extend({ accountId: z.string() }), body: RawJwsPayloadSchema, @@ -210,9 +217,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { hide: false, tags: [ApiDocsTags.PkiAcme], description: "ACME New Order - apply for a new certificate", - params: z.object({ - profileId: z.string().uuid() - }), + params: SharedParamsSchema, body: RawJwsPayloadSchema, response: { 201: AcmeOrderResourceSchema @@ -249,8 +254,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { hide: false, tags: [ApiDocsTags.PkiAcme], description: "ACME Get Order - return status and details of the order", - params: z.object({ - profileId: z.string().uuid(), + params: SharedParamsSchema.extend({ orderId: z.string().uuid() }), body: RawJwsPayloadSchema, @@ -289,8 +293,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { hide: false, tags: [ApiDocsTags.PkiAcme], description: "ACME Finalize Order - finalize cert order by providing CSR", - params: z.object({ - profileId: z.string().uuid(), + params: SharedParamsSchema.extend({ orderId: z.string().uuid() }), body: RawJwsPayloadSchema, @@ -329,8 +332,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { hide: false, tags: [ApiDocsTags.PkiAcme], description: "ACME List Orders - get existing orders from current account", - params: z.object({ - profileId: z.string().uuid(), + params: SharedParamsSchema.extend({ accountId: z.string() }), body: RawJwsPayloadSchema, @@ -368,8 +370,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { hide: false, tags: [ApiDocsTags.PkiAcme], description: "ACME Download Certificate - download certificate when ready", - params: z.object({ - profileId: z.string().uuid(), + params: SharedParamsSchema.extend({ orderId: z.string().uuid() }), body: RawJwsPayloadSchema, @@ -404,8 +405,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { hide: false, tags: [ApiDocsTags.PkiAcme], description: "ACME Identifier Authorization - get authorization info (challenges)", - params: z.object({ - profileId: z.string().uuid(), + params: SharedParamsSchema.extend({ authzId: z.string().uuid() }), body: RawJwsPayloadSchema, @@ -447,8 +447,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { hide: false, tags: [ApiDocsTags.PkiAcme], description: "ACME Respond to Challenge - let ACME server know challenge is ready", - params: z.object({ - profileId: z.string().uuid(), + params: SharedParamsSchema.extend({ authzId: z.string().uuid() }), response: { From 33e140d6412ae96200e1f1f05e9da4858d36a4c7 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Thu, 30 Oct 2025 18:17:23 -0700 Subject: [PATCH 088/231] Add auth feature --- backend/bdd/features/pki/acme/auth.feature | 62 +-------------------- backend/src/ee/routes/v1/pki-acme-router.ts | 5 +- 2 files changed, 3 insertions(+), 64 deletions(-) diff --git a/backend/bdd/features/pki/acme/auth.feature b/backend/bdd/features/pki/acme/auth.feature index d9cf5c268..401891b6e 100644 --- a/backend/bdd/features/pki/acme/auth.feature +++ b/backend/bdd/features/pki/acme/auth.feature @@ -1,6 +1,6 @@ Feature: Order - Scenario: Create a new order + Scenario: Get authorization Given I have an ACME cert profile as "acme_profile" When I have an ACME client connecting to {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory # # TODO: make it I have an account already instead? @@ -16,62 +16,4 @@ Feature: Order Then I create a RSA private key pair as cert_key Then I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format Then I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order - Then the value order.uri with jq . should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/orders/(.+) - Then the value order.body with jq .status should be equal to "pending" - Then the value order.body with jq .identifiers should be equal to [{"type": "dns", "value": "localhost"}] - Then the value order.body with jq .finalize should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/orders/(.+)/finalize - Then the value order.body with jq all(.authorizations[]; startswith("{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/authorizations/")) should be equal to true - - Scenario: Create a new order with SANs - Given I have an ACME cert profile as "acme_profile" - When I have an ACME client connecting to {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory -# # TODO: make it I have an account already instead? - Then I register a new ACME account with email fangpen@infisical.com and EAB key id {acme_profile.eab_kid} with secret {acme_profile.eab_secret} as acme_account - When I create certificate signing request as csr - Then I add names to certificate signing request csr - """ - { - "ORGANIZATION_NAME": "Infisical Inc", - "COMMON_NAME": "localhost" - } - """ - Then I add subject alternative name to certificate signing request csr - """ - [ - "example.com", - "infisical.com" - ] - """ - Then I create a RSA private key pair as cert_key - Then I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format - Then I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order - Then the value order.body with jq .identifiers | sort_by(.value) should be equal to json - """ - [ - {"type": "dns", "value": "example.com"}, - {"type": "dns", "value": "infisical.com"}, - {"type": "dns", "value": "localhost"} - ] - """ - - Scenario: Fetch an order - Given I have an ACME cert profile as "acme_profile" - When I have an ACME client connecting to {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory -# # TODO: make it I have an account already instead? - Then I register a new ACME account with email fangpen@infisical.com and EAB key id {acme_profile.eab_kid} with secret {acme_profile.eab_secret} as acme_account - When I create certificate signing request as csr - Then I add names to certificate signing request csr - """ - { - "ORGANIZATION_NAME": "Infisical Inc", - "COMMON_NAME": "localhost" - } - """ - Then I create a RSA private key pair as cert_key - Then I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format - Then I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order - Then I send an ACME post-as-get to order.uri as fetched_order - Then the value fetched_order with jq .status should be equal to "pending" - Then the value fetched_order with jq .identifiers should be equal to [{"type": "dns", "value": "localhost"}] - Then the value fetched_order with jq .finalize should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/orders/(.+)/finalize - Then the value fetched_order with jq all(.authorizations[]; startswith("{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/authorizations/")) should be equal to true + Then the value order.authorizations[0].uri with jq . should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/authorizations/(.+) diff --git a/backend/src/ee/routes/v1/pki-acme-router.ts b/backend/src/ee/routes/v1/pki-acme-router.ts index 545794323..95ef93d18 100644 --- a/backend/src/ee/routes/v1/pki-acme-router.ts +++ b/backend/src/ee/routes/v1/pki-acme-router.ts @@ -416,10 +416,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { // TODO: replace with verify ACME signature here instead // onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req, res) => { - const { profileId, accountId, payload } = await validateExistingAccount({ - req, - schema: GetAcmeAuthorizationBodySchema - }); + const { profileId, accountId, payload } = await validateExistingAccount({ req }); if (payload !== "") { throw new AcmeMalformedError({ detail: "Payload should be empty" }); } From cb62ed86a59be15826f66ac7865a7555180b78e1 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Thu, 30 Oct 2025 18:29:35 -0700 Subject: [PATCH 089/231] Fix auth dal --- backend/src/ee/services/pki-acme/pki-acme-auth-dal.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 880d54dc9..cef4d619f 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-auth-dal.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-auth-dal.ts @@ -13,7 +13,7 @@ export const pkiAcmeAuthDALFactory = (db: TDbClient) => { const findByAccountIdAndAuthIdWithChallenges = async (accountId: string, authId: string, tx?: Knex) => { try { const rows = await (tx || db)(TableName.PkiAcmeAuth) - .join(TableName.PkiAcmeChallenge, `${TableName.PkiAcmeChallenge}.authId`, `${TableName.PkiAcmeAuth}.id`) + .leftJoin(TableName.PkiAcmeChallenge, `${TableName.PkiAcmeChallenge}.authId`, `${TableName.PkiAcmeAuth}.id`) .select( selectAllTableCols(TableName.PkiAcmeAuth), db.ref("id").withSchema(TableName.PkiAcmeChallenge).as("challengeId"), From 9bc22f8b3c13e06cc43a20f9be0d5b1ef394b744 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Thu, 30 Oct 2025 19:19:23 -0700 Subject: [PATCH 090/231] Better glom path --- backend/bdd/features/pki/acme/auth.feature | 3 +- backend/bdd/features/steps/pki_acme.py | 57 +++++++++++++++++++++- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/backend/bdd/features/pki/acme/auth.feature b/backend/bdd/features/pki/acme/auth.feature index 401891b6e..6a542ea63 100644 --- a/backend/bdd/features/pki/acme/auth.feature +++ b/backend/bdd/features/pki/acme/auth.feature @@ -16,4 +16,5 @@ Feature: Order Then I create a RSA private key pair as cert_key Then I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format Then I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order - Then the value order.authorizations[0].uri with jq . should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/authorizations/(.+) + Then the value order.authorizations[0] with jq .uri should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/authorizations/(.+) + Then the value order.authorizations[0] with jq . should be equal to {} diff --git a/backend/bdd/features/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py index 2df1adfa0..c5f8e068b 100644 --- a/backend/bdd/features/steps/pki_acme.py +++ b/backend/bdd/features/steps/pki_acme.py @@ -3,13 +3,13 @@ import re import jq import requests +import glom from acme import client from acme import messages from behave.runner import Context from behave import given from behave import when from behave import then -import glom from josepy.jwk import JWKRSA from josepy import JSONObjectWithFields from cryptography.hazmat.primitives import serialization @@ -41,11 +41,64 @@ def replace_vars(payload: dict | list | int | float | str, vars: dict): return payload +def parse_glom_path(path_str: str): + """ + Parse a glom path string with 'attr[index]' syntax into a Path object. + + Examples: + >>> parse_glom_path('authorizations[0].name') + Path('authorizations', 0, 'name') + + >>> parse_glom_path('items[1].user[0].id') + Path('items', 1, 'user', 0, 'id') + + >>> parse_glom_path('simple_attr') + Path('simple_attr') + + >>> parse_glom_path('nested.attr') + Path('nested', 'attr') + """ + # Pattern to match attr[index] or just attr + # Groups: (attr_name)(?:\[(index)\])? + pattern = r"([a-zA-Z_][a-zA-Z0-9_]*)(?:\[(\d+)\])?" + + # Split on dots, but preserve the parts + parts = [] + current_pos = 0 + + # Find all matches in the string + for match in re.finditer(pattern, path_str): + # Add any literal text before this match + if match.start() > current_pos: + before = path_str[current_pos : match.start()] + raise ValueError(f"Invalid path syntax: unexpected text '{before}'") + + attr_name = match.group(1) + index = match.group(2) + + # Add the attribute name + parts.append(attr_name) + + # Add index if present + if index is not None: + parts.append(int(index)) + + current_pos = match.end() + + # Check for trailing text + if current_pos < len(path_str): + after = path_str[current_pos:] + if after != ".": + raise ValueError(f"Invalid path syntax: unexpected text '{after}'") + + return glom.Path(*parts) + + def eval_var(context: Context, var_path: str, as_json: bool = True): parts = var_path.split(".", 1) value = context.vars[parts[0]] if len(parts) == 2: - value = glom.glom(value, parts[1]) + value = glom.glom(value, parse_glom_path(parts[1])) if as_json: if isinstance(value, JSONObjectWithFields): value = value.to_json() From b50d7157b94f2b18c041ac35208cf65ae75729aa Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Thu, 30 Oct 2025 19:23:14 -0700 Subject: [PATCH 091/231] More asserts --- backend/bdd/features/pki/acme/auth.feature | 11 +++- backend/bdd/features/steps/pki_acme.py | 74 +++++++++++----------- 2 files changed, 45 insertions(+), 40 deletions(-) diff --git a/backend/bdd/features/pki/acme/auth.feature b/backend/bdd/features/pki/acme/auth.feature index 6a542ea63..e81d3dd61 100644 --- a/backend/bdd/features/pki/acme/auth.feature +++ b/backend/bdd/features/pki/acme/auth.feature @@ -16,5 +16,12 @@ Feature: Order Then I create a RSA private key pair as cert_key Then I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format Then I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order - Then the value order.authorizations[0] with jq .uri should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/authorizations/(.+) - Then the value order.authorizations[0] with jq . should be equal to {} + Then the value order.authorizations[0].uri with jq . should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/authorizations/(.+) + Then the value order.authorizations[0].body with jq .status should be equal to "pending" + Then the value order.authorizations[0].body with jq .identifier should be equal to json + """ + { + "type": "dns", + "value": "localhost" + } + """ diff --git a/backend/bdd/features/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py index c5f8e068b..db9e8a28a 100644 --- a/backend/bdd/features/steps/pki_acme.py +++ b/backend/bdd/features/steps/pki_acme.py @@ -41,55 +41,53 @@ def replace_vars(payload: dict | list | int | float | str, vars: dict): return payload -def parse_glom_path(path_str: str): +def parse_glom_path(path_str: str) -> glom.Path: """ Parse a glom path string with 'attr[index]' syntax into a Path object. Examples: - >>> parse_glom_path('authorizations[0].name') - Path('authorizations', 0, 'name') - - >>> parse_glom_path('items[1].user[0].id') - Path('items', 1, 'user', 0, 'id') - - >>> parse_glom_path('simple_attr') - Path('simple_attr') - - >>> parse_glom_path('nested.attr') - Path('nested', 'attr') + >>> parse_glom_path('authorizations[0]') == Path('authorizations', 0) + True + >>> parse_glom_path('data.items[1].name') == Path('data', 'items', 1, 'name') + True + >>> parse_glom_path('user.addresses[0].street') == Path('user', 'addresses', 0, 'street') + True """ - # Pattern to match attr[index] or just attr - # Groups: (attr_name)(?:\[(index)\])? - pattern = r"([a-zA-Z_][a-zA-Z0-9_]*)(?:\[(\d+)\])?" - - # Split on dots, but preserve the parts parts = [] - current_pos = 0 - # Find all matches in the string - for match in re.finditer(pattern, path_str): - # Add any literal text before this match - if match.start() > current_pos: - before = path_str[current_pos : match.start()] - raise ValueError(f"Invalid path syntax: unexpected text '{before}'") + # Split by dots, but preserve bracketed content + tokens = re.split(r"(? Date: Thu, 30 Oct 2025 19:29:49 -0700 Subject: [PATCH 092/231] Add challenge --- backend/bdd/features/pki/acme/auth.feature | 1 + backend/src/ee/routes/v1/pki-acme-router.ts | 26 +++++++------------ .../pki-acme/pki-acme-challenge-dal.ts | 13 ++++++++++ .../ee/services/pki-acme/pki-acme-schemas.ts | 10 +++++-- .../ee/services/pki-acme/pki-acme-service.ts | 17 ++++++++++-- 5 files changed, 47 insertions(+), 20 deletions(-) create mode 100644 backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts diff --git a/backend/bdd/features/pki/acme/auth.feature b/backend/bdd/features/pki/acme/auth.feature index e81d3dd61..7252d868f 100644 --- a/backend/bdd/features/pki/acme/auth.feature +++ b/backend/bdd/features/pki/acme/auth.feature @@ -18,6 +18,7 @@ Feature: Order Then I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order Then the value order.authorizations[0].uri with jq . should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/authorizations/(.+) Then the value order.authorizations[0].body with jq .status should be equal to "pending" + Then the value order.authorizations[0].body with jq .challenge should be equal to "pending" Then the value order.authorizations[0].body with jq .identifier should be equal to json """ { diff --git a/backend/src/ee/routes/v1/pki-acme-router.ts b/backend/src/ee/routes/v1/pki-acme-router.ts index 95ef93d18..6935150ac 100644 --- a/backend/src/ee/routes/v1/pki-acme-router.ts +++ b/backend/src/ee/routes/v1/pki-acme-router.ts @@ -186,8 +186,6 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { 200: DeactivateAcmeAccountResponseSchema } }, - // TODO: replace with verify ACME signature here instead - // onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req, res) => { const { payload, profileId, accountId } = await validateExistingAccount({ req, @@ -223,8 +221,6 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { 201: AcmeOrderResourceSchema } }, - // TODO: replace with verify ACME signature here instead - // onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req, res) => { const { profileId, accountId, payload } = await validateExistingAccount({ req, @@ -262,8 +258,6 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { 200: AcmeOrderResourceSchema } }, - // TODO: replace with verify ACME signature here instead - // onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req, res) => { const { profileId, accountId } = await validateExistingAccount({ req, @@ -301,8 +295,6 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { 200: AcmeOrderResourceSchema } }, - // TODO: replace with verify ACME signature here instead - // onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req, res) => { const { profileId, accountId, payload } = await validateExistingAccount({ req, @@ -378,8 +370,6 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { 200: z.string() } }, - // TODO: replace with verify ACME signature here instead - // onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req, res) => { const { profileId, accountId } = await validateExistingAccount({ req, @@ -413,8 +403,6 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { 200: GetAcmeAuthorizationResponseSchema } }, - // TODO: replace with verify ACME signature here instead - // onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req, res) => { const { profileId, accountId, payload } = await validateExistingAccount({ req }); if (payload !== "") { @@ -451,10 +439,16 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { 200: RespondToAcmeChallengeResponseSchema } }, - // TODO: replace with verify ACME signature here instead - // onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), - handler: async (req) => { - const challenge = await server.services.pkiAcme.respondToAcmeChallenge(req.params.profileId, req.params.authzId); + handler: async (req, res) => { + const { profileId, accountId, payload } = await validateExistingAccount({ req }); + if (payload !== "") { + throw new AcmeMalformedError({ detail: "Payload should be empty" }); + } + return sendAcmeResponse( + res, + profileId, + await server.services.pkiAcme.respondToAcmeChallenge({ profileId, authzId: req.params.authzId }) + ); return challenge; } }); diff --git a/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts b/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts new file mode 100644 index 000000000..77a585efd --- /dev/null +++ b/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts @@ -0,0 +1,13 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TPkiAcmeChallengeDALFactory = ReturnType; + +export const pkiAcmeChallengeDALFactory = (db: TDbClient) => { + const pkiAcmeChallengeOrm = ormify(db, TableName.PkiAcmeChallenge); + + return { + ...pkiAcmeChallengeOrm + }; +}; diff --git a/backend/src/ee/services/pki-acme/pki-acme-schemas.ts b/backend/src/ee/services/pki-acme/pki-acme-schemas.ts index ad40ea17b..e09aa81c8 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-schemas.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-schemas.ts @@ -21,6 +21,12 @@ export enum AcmeAuthStatus { Revoked = "revoked" } +export enum AcmeChallengeType { + HTTP_01 = "http-01", + DNS_01 = "dns-01", + TLS_ALPN_01 = "tls-alpn-01" +} + export const ProtectedHeaderSchema = z .object({ alg: z.string(), @@ -136,7 +142,7 @@ export const GetAcmeAuthorizationResponseSchema = z.object({ }), challenges: z.array( z.object({ - type: z.string(), + type: z.enum(Object.values(AcmeChallengeType) as [string, ...string[]]), url: z.string(), status: z.string(), token: z.string(), @@ -146,7 +152,7 @@ export const GetAcmeAuthorizationResponseSchema = z.object({ }); export const RespondToAcmeChallengeResponseSchema = z.object({ - type: z.string(), + type: z.enum(Object.values(AcmeChallengeType) as [string, ...string[]]), url: z.string(), status: z.string(), token: z.string(), 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 3508d8c93..679457814 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -15,6 +15,7 @@ import { errors, flattenedVerify, FlattenedVerifyResult, importJWK, JWSHeaderPar import { z, ZodError } from "zod"; import { TPkiAcmeAccountDALFactory } from "./pki-acme-account-dal"; import { TPkiAcmeAuthDALFactory } from "./pki-acme-auth-dal"; +import { TPkiAcmeChallengeDALFactory } from "./pki-acme-challenge-dal"; import { AcmeAccountDoesNotExistError, AcmeBadPublicKeyError, @@ -28,6 +29,7 @@ import { TPkiAcmeOrderAuthDALFactory } from "./pki-acme-order-auth-dal"; import { TPkiAcmeOrderDALFactory } from "./pki-acme-order-dal"; import { AcmeAuthStatus, + AcmeChallengeType, AcmeIdentifierType, AcmeOrderStatus, CreateAcmeAccountBodySchema, @@ -58,6 +60,7 @@ type TPkiAcmeServiceFactoryDep = { acmeOrderDAL: Pick; acmeAuthDAL: Pick; acmeOrderAuthDAL: Pick; + acmeChallengeDAL: Pick; }; export const pkiAcmeServiceFactory = ({ @@ -65,7 +68,8 @@ export const pkiAcmeServiceFactory = ({ acmeAccountDAL, acmeOrderDAL, acmeAuthDAL, - acmeOrderAuthDAL + acmeOrderAuthDAL, + acmeChallengeDAL }: TPkiAcmeServiceFactoryDep): TPkiAcmeServiceFactory => { const validateAcmeProfile = async (profileId: string): Promise => { const profile = await certificateProfileDAL.findById(profileId); @@ -376,7 +380,7 @@ export const pkiAcmeServiceFactory = ({ payload.identifiers.map(async (identifier) => { if (identifier.type === AcmeIdentifierType.DNS) { // TODO: reuse existing authorizations for this identifier if they exist - return await acmeAuthDAL.create( + const auth = await acmeAuthDAL.create( { accountId: account.id, status: AcmeAuthStatus.Pending, @@ -391,6 +395,15 @@ export const pkiAcmeServiceFactory = ({ }, tx ); + // TODO: support other challenge types here. Currently only HTTP-01 is supported. + await acmeChallengeDAL.create( + { + authId: auth.id, + type: AcmeChallengeType.HTTP_01 + }, + tx + ); + return auth; } else { throw new AcmeUnsupportedIdentifierError({ detail: "Only DNS identifiers are supported" }); } From 9ad75c2a2db52424ad0fce95f1b34a2bdeb395fb Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Thu, 30 Oct 2025 19:35:49 -0700 Subject: [PATCH 093/231] DRY --- .../ee/services/pki-acme/pki-acme-service.ts | 48 +++++++++---------- 1 file changed, 23 insertions(+), 25 deletions(-) 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 679457814..b199f5105 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -82,18 +82,18 @@ export const pkiAcmeServiceFactory = ({ return profile; }; - const buildUrl = (path: string): string => { + const buildUrl = (profileId: string, path: string): string => { const appCfg = getConfig(); const baseUrl = appCfg.SITE_URL ?? ""; - return `${baseUrl}${path}`; + return `${baseUrl}/api/v1/pki/acme/profiles/${profileId}${path}`; }; const extractAccountIdFromKid = (kid: string, profileId: string): string => { - const kidPrefix = buildUrl(`/api/v1/pki/acme/profiles/${profileId}/accounts/`); + const kidPrefix = buildUrl(profileId, "/accounts/"); if (!kid.startsWith(kidPrefix)) { throw new AcmeMalformedError({ detail: "KID must start with the profile account URL" }); } - return kid.slice(kidPrefix.length); + return z.string().uuid().parse(kid.slice(kidPrefix.length)); }; const validateJwsPayload = async < @@ -248,18 +248,18 @@ export const pkiAcmeServiceFactory = ({ value: auth.identifierValue })), authorizations: order.authorizations.map((auth: TPkiAcmeAuths) => - buildUrl(`/api/v1/pki/acme/profiles/${profileId}/authorizations/${auth.id}`) + buildUrl(profileId, `/authorizations/${auth.id}`) ), - finalize: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/orders/${order.id}/finalize`) + finalize: buildUrl(profileId, `/orders/${order.id}/finalize`) }; }; const getAcmeDirectory = async (profileId: string): Promise => { const profile = await validateAcmeProfile(profileId); return { - newNonce: buildUrl(`/api/v1/pki/acme/profiles/${profile.id}/new-nonce`), - newAccount: buildUrl(`/api/v1/pki/acme/profiles/${profile.id}/new-account`), - newOrder: buildUrl(`/api/v1/pki/acme/profiles/${profile.id}/new-order`) + newNonce: buildUrl(profile.id, "/new-nonce"), + newAccount: buildUrl(profile.id, "/new-account"), + newOrder: buildUrl(profile.id, "/new-order") }; }; @@ -297,10 +297,10 @@ export const pkiAcmeServiceFactory = ({ body: { status: "valid", contact: existingAccount.emails, - orders: buildUrl(`/api/v1/pki/acme/profiles/${profile.id}/accounts/${existingAccount.id}/orders`) + orders: buildUrl(profile.id, `/accounts/${existingAccount.id}/orders`) }, headers: { - Location: buildUrl(`/api/v1/pki/acme/profiles/${profile.id}/accounts/${existingAccount.id}`) + Location: buildUrl(profile.id, `/accounts/${existingAccount.id}`) } }; } @@ -318,10 +318,10 @@ export const pkiAcmeServiceFactory = ({ body: { status: "valid", contact: newAccount.emails, - orders: buildUrl(`/api/v1/pki/acme/profiles/${profile.id}/accounts/${newAccount.id}/orders`) + orders: buildUrl(profile.id, `/accounts/${newAccount.id}/orders`) }, headers: { - Location: buildUrl(`/api/v1/pki/acme/profiles/${profile.id}/accounts/${newAccount.id}`) + Location: buildUrl(profile.id, `/accounts/${newAccount.id}`) } }; }; @@ -343,7 +343,7 @@ export const pkiAcmeServiceFactory = ({ status: "deactivated" }, headers: { - Location: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/accounts/${accountId}`) + Location: buildUrl(profileId, `/accounts/${accountId}`) } }; }; @@ -428,7 +428,7 @@ export const pkiAcmeServiceFactory = ({ order }), headers: { - Location: buildUrl(`/api/v1/pki/acme/profiles/${order.account.profileId}/orders/${order.id}`) + Location: buildUrl(profileId, `/orders/${order.id}`) } }; }; @@ -449,7 +449,7 @@ export const pkiAcmeServiceFactory = ({ return { status: 200, body: buildAcmeOrderResource({ profileId, order }), - headers: { Location: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/orders/${orderId}`) } + headers: { Location: buildUrl(profileId, `/orders/${orderId}`) } }; }; @@ -474,7 +474,7 @@ export const pkiAcmeServiceFactory = ({ status: 200, body: buildAcmeOrderResource({ profileId, order }), headers: { - Location: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/orders/${orderId}`) + Location: buildUrl(profileId, `/orders/${orderId}`) } }; }; @@ -498,7 +498,7 @@ export const pkiAcmeServiceFactory = ({ status: 200, body: "FIXME-certificate-pem", headers: { - Location: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/orders/${orderId}/certificate`) + Location: buildUrl(profileId, `/orders/${orderId}/certificate`) } }; }; @@ -518,7 +518,7 @@ export const pkiAcmeServiceFactory = ({ orders: [] }, headers: { - Location: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/accounts/${accountId}/orders`) + Location: buildUrl(profileId, `/accounts/${accountId}/orders`) } }; }; @@ -551,16 +551,14 @@ export const pkiAcmeServiceFactory = ({ challenges: auth.challenges.map((challenge: TPkiAcmeChallenges) => { return { type: challenge.type, - url: buildUrl( - `/api/v1/pki/acme/profiles/${profileId}/authorizations/${authzId}/challenges/${challenge.id}` - ), + url: buildUrl(profileId, `/authorizations/${authzId}/challenges/${challenge.id}`), status: challenge.status, token: auth.token }; }) }, headers: { - Location: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/authorizations/${authzId}`) + Location: buildUrl(profileId, `/authorizations/${authzId}`) } }; }; @@ -579,12 +577,12 @@ export const pkiAcmeServiceFactory = ({ status: 200, body: { type: "http-01", - url: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/authorizations/${authzId}/challenges/http-01`), + url: buildUrl(profileId, `/authorizations/${authzId}/challenges/http-01`), status: "pending", token: "FIXME-challenge-token" }, headers: { - Location: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/authorizations/${authzId}/challenges/http-01`) + Location: buildUrl(profileId, `/authorizations/${authzId}/challenges/http-01`) } }; }; From f2e6d598f667d48cccdcf6292e46ec7894be6a80 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Thu, 30 Oct 2025 20:45:54 -0700 Subject: [PATCH 094/231] Add missing status --- backend/src/ee/services/pki-acme/pki-acme-schemas.ts | 7 +++++++ backend/src/ee/services/pki-acme/pki-acme-service.ts | 2 ++ backend/src/server/routes/index.ts | 7 +++++-- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/backend/src/ee/services/pki-acme/pki-acme-schemas.ts b/backend/src/ee/services/pki-acme/pki-acme-schemas.ts index e09aa81c8..25a3db3f4 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-schemas.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-schemas.ts @@ -21,6 +21,13 @@ export enum AcmeAuthStatus { Revoked = "revoked" } +export enum AcmeChallengeStatus { + Pending = "pending", + Processing = "processing", + Valid = "valid", + Invalid = "invalid" +} + export enum AcmeChallengeType { HTTP_01 = "http-01", DNS_01 = "dns-01", 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 b199f5105..bbff4a9c4 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -29,6 +29,7 @@ import { TPkiAcmeOrderAuthDALFactory } from "./pki-acme-order-auth-dal"; import { TPkiAcmeOrderDALFactory } from "./pki-acme-order-dal"; import { AcmeAuthStatus, + AcmeChallengeStatus, AcmeChallengeType, AcmeIdentifierType, AcmeOrderStatus, @@ -399,6 +400,7 @@ export const pkiAcmeServiceFactory = ({ await acmeChallengeDAL.create( { authId: auth.id, + status: AcmeChallengeStatus.Pending, type: AcmeChallengeType.HTTP_01 }, tx diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 40ffe4f5d..d3ee4caae 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -75,8 +75,8 @@ import { permissionDALFactory } from "@app/ee/services/permission/permission-dal import { permissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { pitServiceFactory } from "@app/ee/services/pit/pit-service"; import { pkiAcmeAuthDALFactory } from "@app/ee/services/pki-acme/pki-acme-auth-dal"; -import { pkiAcmeServiceFactory } from "@app/ee/services/pki-acme/pki-acme-service"; import { pkiAcmeOrderAuthDALFactory } from "@app/ee/services/pki-acme/pki-acme-order-auth-dal"; +import { pkiAcmeServiceFactory } from "@app/ee/services/pki-acme/pki-acme-service"; import { projectTemplateDALFactory } from "@app/ee/services/project-template/project-template-dal"; import { projectTemplateServiceFactory } from "@app/ee/services/project-template/project-template-service"; import { rateLimitDALFactory } from "@app/ee/services/rate-limit/rate-limit-dal"; @@ -352,6 +352,7 @@ import { workflowIntegrationDALFactory } from "@app/services/workflow-integratio import { workflowIntegrationServiceFactory } from "@app/services/workflow-integration/workflow-integration-service"; import { pkiAcmeAccountDALFactory } from "@app/ee/services/pki-acme/pki-acme-account-dal"; +import { pkiAcmeChallengeDALFactory } from "@app/ee/services/pki-acme/pki-acme-challenge-dal"; import { pkiAcmeOrderDALFactory } from "@app/ee/services/pki-acme/pki-acme-order-dal"; import { injectAuditLogInfo } from "../plugins/audit-log"; import { injectAssumePrivilege } from "../plugins/auth/inject-assume-privilege"; @@ -1072,6 +1073,7 @@ export const registerRoutes = async ( const acmeOrderDAL = pkiAcmeOrderDALFactory(db); const acmeAuthDAL = pkiAcmeAuthDALFactory(db); const acmeOrderAuthDAL = pkiAcmeOrderAuthDALFactory(db); + const acmeChallengeDAL = pkiAcmeChallengeDALFactory(db); const certificateDAL = certificateDALFactory(db); const certificateBodyDAL = certificateBodyDALFactory(db); const certificateSecretDAL = certificateSecretDALFactory(db); @@ -1177,7 +1179,8 @@ export const registerRoutes = async ( acmeAccountDAL, acmeOrderDAL, acmeAuthDAL, - acmeOrderAuthDAL + acmeOrderAuthDAL, + acmeChallengeDAL }); const pkiAlertService = pkiAlertServiceFactory({ From f6955da9caaa44cb15cdbaf74c6c16c35ecbcc22 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Thu, 30 Oct 2025 20:56:01 -0700 Subject: [PATCH 095/231] More auth feature --- backend/bdd/features/pki/acme/auth.feature | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/backend/bdd/features/pki/acme/auth.feature b/backend/bdd/features/pki/acme/auth.feature index 7252d868f..b1b8ee5ae 100644 --- a/backend/bdd/features/pki/acme/auth.feature +++ b/backend/bdd/features/pki/acme/auth.feature @@ -1,4 +1,4 @@ -Feature: Order +Feature: Authorization Scenario: Get authorization Given I have an ACME cert profile as "acme_profile" @@ -18,7 +18,16 @@ Feature: Order Then I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order Then the value order.authorizations[0].uri with jq . should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/authorizations/(.+) Then the value order.authorizations[0].body with jq .status should be equal to "pending" - Then the value order.authorizations[0].body with jq .challenge should be equal to "pending" + Then the value order.authorizations[0].body with jq .challenges | map(pick(.type, .status)) | sort_by(.type) should be equal to json + """ + [ + { + "type": "http-01", + "status": "pending" + } + ] + """ + Then the value order.authorizations[0].body with jq .challenges | map(.status) | sort should be equal to ["pending"] Then the value order.authorizations[0].body with jq .identifier should be equal to json """ { From 6cf08622ed4a1aa2c471b410582344972f6f33cb Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Thu, 30 Oct 2025 21:10:46 -0700 Subject: [PATCH 096/231] Add challenge --- .../bdd/features/pki/acme/challenge.feature | 20 ++++++++++++++++++ backend/bdd/features/steps/pki_acme.py | 21 +++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 backend/bdd/features/pki/acme/challenge.feature diff --git a/backend/bdd/features/pki/acme/challenge.feature b/backend/bdd/features/pki/acme/challenge.feature new file mode 100644 index 000000000..72573150c --- /dev/null +++ b/backend/bdd/features/pki/acme/challenge.feature @@ -0,0 +1,20 @@ +Feature: Challenge + + Scenario: Validate challenge + Given I have an ACME cert profile as "acme_profile" + When I have an ACME client connecting to {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory +# # TODO: make it I have an account already instead? + Then I register a new ACME account with email fangpen@infisical.com and EAB key id {acme_profile.eab_kid} with secret {acme_profile.eab_secret} as acme_account + When I create certificate signing request as csr + Then I add names to certificate signing request csr + """ + { + "ORGANIZATION_NAME": "Infisical Inc", + "COMMON_NAME": "localhost" + } + """ + Then I create a RSA private key pair as cert_key + Then I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format + Then I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order + Then I select challenge with type http-01 from order as challenge + diff --git a/backend/bdd/features/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py index db9e8a28a..294b8cd1f 100644 --- a/backend/bdd/features/steps/pki_acme.py +++ b/backend/bdd/features/steps/pki_acme.py @@ -301,3 +301,24 @@ def step_impl(context: Context, var_path: str, expected: str): def step_impl(context: Context, var_path: str): value = eval_var(context, var_path) print(json.dumps(value.json(), indent=2)) + + +@then( + "I select challenge with type {challenge_type} from {var_path} as {challenge_var}" +) +def step_impl(context: Context, challenge_type: str, var_path: str, challenge_var: str): + order = eval_var(context, var_path) + if not isinstance(order, messages.OrderResource): + raise ValueError( + f"Expected OrderResource but got {(type(order),)!r} at {var_path!r}" + ) + auths = list(filter(lambda a: a.type == challenge_type, order.authorizations)) + if not auths: + raise ValueError( + f"Authorization type {challenge_type!r} not found in {var_path!r}" + ) + if len(auths) > 1: + raise ValueError( + f"More than one authorization for type {challenge_type!r} found in {var_path!r}" + ) + context.vars[challenge_var] = auths[0] From 5d31627afd9c1cafa3aad1834ac9c10a1af77799 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Thu, 30 Oct 2025 21:17:59 -0700 Subject: [PATCH 097/231] Add challenge selection --- .../bdd/features/pki/acme/challenge.feature | 2 +- backend/bdd/features/steps/pki_acme.py | 31 +++++++++++++++---- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/backend/bdd/features/pki/acme/challenge.feature b/backend/bdd/features/pki/acme/challenge.feature index 72573150c..0bf568123 100644 --- a/backend/bdd/features/pki/acme/challenge.feature +++ b/backend/bdd/features/pki/acme/challenge.feature @@ -16,5 +16,5 @@ Feature: Challenge Then I create a RSA private key pair as cert_key Then I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format Then I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order - Then I select challenge with type http-01 from order as challenge + Then I select challenge with type http-01 for domain localhost from order at order as challenge diff --git a/backend/bdd/features/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py index 294b8cd1f..ca90c5415 100644 --- a/backend/bdd/features/steps/pki_acme.py +++ b/backend/bdd/features/steps/pki_acme.py @@ -304,20 +304,39 @@ def step_impl(context: Context, var_path: str): @then( - "I select challenge with type {challenge_type} from {var_path} as {challenge_var}" + "I select challenge with type {challenge_type} for domain {domain} from order at {var_path} as {challenge_var}" ) -def step_impl(context: Context, challenge_type: str, var_path: str, challenge_var: str): - order = eval_var(context, var_path) +def step_impl( + context: Context, + challenge_type: str, + domain: str, + var_path: str, + challenge_var: str, +): + order = eval_var(context, var_path, as_json=False) if not isinstance(order, messages.OrderResource): raise ValueError( - f"Expected OrderResource but got {(type(order),)!r} at {var_path!r}" + f"Expected OrderResource but got {type(order)!r} at {var_path!r}" ) - auths = list(filter(lambda a: a.type == challenge_type, order.authorizations)) + auths = list( + filter(lambda o: o.body.identifier.value == domain, order.authorizations) + ) if not auths: + raise ValueError( + f"Authorization for domain {domain!r} not found in {var_path!r}" + ) + if len(auths) > 1: + raise ValueError( + f"More than one order for domain {domain!r} found in {var_path!r}" + ) + auth = auths[0] + + challenges = list(filter(lambda a: a.typ == challenge_type, auth.body.challenges)) + if not challenges: raise ValueError( f"Authorization type {challenge_type!r} not found in {var_path!r}" ) - if len(auths) > 1: + if len(challenges) > 1: raise ValueError( f"More than one authorization for type {challenge_type!r} found in {var_path!r}" ) From 3a05ad8cd18a2906843d33ead8cf8e998a789125 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Thu, 30 Oct 2025 21:27:22 -0700 Subject: [PATCH 098/231] Serve challenge server --- .../bdd/features/pki/acme/challenge.feature | 1 + backend/bdd/features/steps/pki_acme.py | 20 ++++++++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/backend/bdd/features/pki/acme/challenge.feature b/backend/bdd/features/pki/acme/challenge.feature index 0bf568123..2079b9a57 100644 --- a/backend/bdd/features/pki/acme/challenge.feature +++ b/backend/bdd/features/pki/acme/challenge.feature @@ -17,4 +17,5 @@ Feature: Challenge Then I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format Then I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order Then I select challenge with type http-01 for domain localhost from order at order as challenge + Then I serve challenge response for challenge at localhost diff --git a/backend/bdd/features/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py index ca90c5415..f1219b801 100644 --- a/backend/bdd/features/steps/pki_acme.py +++ b/backend/bdd/features/steps/pki_acme.py @@ -6,6 +6,7 @@ import requests import glom from acme import client from acme import messages +from acme import standalone from behave.runner import Context from behave import given from behave import when @@ -340,4 +341,21 @@ def step_impl( raise ValueError( f"More than one authorization for type {challenge_type!r} found in {var_path!r}" ) - context.vars[challenge_var] = auths[0] + context.vars[challenge_var] = challenges[0] + + +@then("I serve challenge response for {var_path} at {hostname}") +def step_impl(context: Context, var_path: str, hostname: str): + if hostname != "localhost": + raise ValueError("Currently only localhost is supported") + challenge = eval_var(context, var_path, as_json=False) + acme_challenge = challenge.chall + response, validation = acme_challenge.response_and_validation( + context.acme_client.net.key + ) + resource = standalone.HTTP01RequestHandler.HTTP01Resource( + chall=acme_challenge, response=response, validation=validation + ) + servers = standalone.HTTP01DualNetworkedServers(("", 8087), resource) + # Start client standalone web server. + servers.serve_forever() From 42706c205bb2ef7f7faa27687c1b1144625f4494 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Thu, 30 Oct 2025 21:29:57 -0700 Subject: [PATCH 099/231] Run server with thread --- backend/bdd/features/steps/pki_acme.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/backend/bdd/features/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py index f1219b801..1c88b0c23 100644 --- a/backend/bdd/features/steps/pki_acme.py +++ b/backend/bdd/features/steps/pki_acme.py @@ -1,5 +1,6 @@ import json import re +import threading import jq import requests @@ -356,6 +357,10 @@ def step_impl(context: Context, var_path: str, hostname: str): resource = standalone.HTTP01RequestHandler.HTTP01Resource( chall=acme_challenge, response=response, validation=validation ) + # TODO: make port configurable servers = standalone.HTTP01DualNetworkedServers(("", 8087), resource) # Start client standalone web server. - servers.serve_forever() + web_server = threading.Thread(name="web_server", target=servers.serve_forever) + web_server.daemon = True + web_server.start() + context.web_server = web_server From b989777ed60aff385fe07e48d64e32942b408ca4 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Thu, 30 Oct 2025 21:34:44 -0700 Subject: [PATCH 100/231] Tell server challenge is ready --- backend/bdd/features/pki/acme/challenge.feature | 1 + backend/bdd/features/steps/pki_acme.py | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/backend/bdd/features/pki/acme/challenge.feature b/backend/bdd/features/pki/acme/challenge.feature index 2079b9a57..e6b0b7885 100644 --- a/backend/bdd/features/pki/acme/challenge.feature +++ b/backend/bdd/features/pki/acme/challenge.feature @@ -18,4 +18,5 @@ Feature: Challenge Then I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order Then I select challenge with type http-01 for domain localhost from order at order as challenge Then I serve challenge response for challenge at localhost + Then I tell ACME server that challenge is ready to be verified diff --git a/backend/bdd/features/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py index 1c88b0c23..7562cfd2c 100644 --- a/backend/bdd/features/steps/pki_acme.py +++ b/backend/bdd/features/steps/pki_acme.py @@ -364,3 +364,12 @@ def step_impl(context: Context, var_path: str, hostname: str): web_server.daemon = True web_server.start() context.web_server = web_server + + +@then("I tell ACME server that {var_path} is ready to be verified") +def step_impl(context: Context, var_path: str): + challenge = eval_var(context, var_path, as_json=False) + acme_challenge = challenge.chall + acme_client = challenge.acme_client + response, validation = acme_challenge.response_and_validation(acme_client.net.key) + acme_client.answer_challenge(acme_challenge, response) From d14d64e11e4e2c5d55e309b4adcc779508b2a5c7 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Thu, 30 Oct 2025 21:37:41 -0700 Subject: [PATCH 101/231] Fix tell ready --- backend/bdd/features/pki/acme/challenge.feature | 1 - backend/bdd/features/steps/pki_acme.py | 7 +++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/backend/bdd/features/pki/acme/challenge.feature b/backend/bdd/features/pki/acme/challenge.feature index e6b0b7885..8e7dad523 100644 --- a/backend/bdd/features/pki/acme/challenge.feature +++ b/backend/bdd/features/pki/acme/challenge.feature @@ -19,4 +19,3 @@ Feature: Challenge Then I select challenge with type http-01 for domain localhost from order at order as challenge Then I serve challenge response for challenge at localhost Then I tell ACME server that challenge is ready to be verified - diff --git a/backend/bdd/features/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py index 7562cfd2c..c5627bb8b 100644 --- a/backend/bdd/features/steps/pki_acme.py +++ b/backend/bdd/features/steps/pki_acme.py @@ -369,7 +369,6 @@ def step_impl(context: Context, var_path: str, hostname: str): @then("I tell ACME server that {var_path} is ready to be verified") def step_impl(context: Context, var_path: str): challenge = eval_var(context, var_path, as_json=False) - acme_challenge = challenge.chall - acme_client = challenge.acme_client - response, validation = acme_challenge.response_and_validation(acme_client.net.key) - acme_client.answer_challenge(acme_challenge, response) + acme_client = context.acme_client + response, validation = challenge.response_and_validation(acme_client.net.key) + acme_client.answer_challenge(challenge, response) From ea96cc87bbefc78ac6b772351a9a797ce81aac57 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Thu, 30 Oct 2025 21:56:00 -0700 Subject: [PATCH 102/231] Add link headers --- backend/src/ee/routes/v1/pki-acme-router.ts | 13 ++- .../pki-acme/pki-acme-challenge-dal.ts | 32 ++++++- .../ee/services/pki-acme/pki-acme-service.ts | 93 +++++++++++-------- .../ee/services/pki-acme/pki-acme-types.ts | 8 +- 4 files changed, 101 insertions(+), 45 deletions(-) diff --git a/backend/src/ee/routes/v1/pki-acme-router.ts b/backend/src/ee/routes/v1/pki-acme-router.ts index 6935150ac..53bb81224 100644 --- a/backend/src/ee/routes/v1/pki-acme-router.ts +++ b/backend/src/ee/routes/v1/pki-acme-router.ts @@ -424,7 +424,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { // Respond to Challenge (RFC 8555 Section 7.5.1) server.route({ method: "POST", - url: "/profiles/:profileId/authorizations/:authzId/challenges/http-01", + url: "/profiles/:profileId/authorizations/:authzId/challenges/:challengeId", config: { rateLimit: writeLimit }, @@ -433,7 +433,8 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { tags: [ApiDocsTags.PkiAcme], description: "ACME Respond to Challenge - let ACME server know challenge is ready", params: SharedParamsSchema.extend({ - authzId: z.string().uuid() + authzId: z.string().uuid(), + challengeId: z.string().uuid() }), response: { 200: RespondToAcmeChallengeResponseSchema @@ -447,9 +448,13 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { return sendAcmeResponse( res, profileId, - await server.services.pkiAcme.respondToAcmeChallenge({ profileId, authzId: req.params.authzId }) + await server.services.pkiAcme.respondToAcmeChallenge({ + profileId, + accountId, + authzId: req.params.authzId, + challengeId: req.params.challengeId + }) ); - return challenge; } }); }; diff --git a/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts b/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts index 77a585efd..d23827a37 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts @@ -1,13 +1,41 @@ import { TDbClient } from "@app/db"; import { TableName } from "@app/db/schemas"; -import { ormify } from "@app/lib/knex"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; +import { Knex } from "knex"; export type TPkiAcmeChallengeDALFactory = ReturnType; export const pkiAcmeChallengeDALFactory = (db: TDbClient) => { const pkiAcmeChallengeOrm = ormify(db, TableName.PkiAcmeChallenge); + const findByAccountAuthAndChallengeIdWithToken = async ( + accountId: string, + authId: string, + challengeId: string, + tx?: Knex + ) => { + try { + const challenge = await (tx || db)(TableName.PkiAcmeChallenge) + .join(TableName.PkiAcmeAuth, `${TableName.PkiAcmeChallenge}.authId`, `${TableName.PkiAcmeAuth}.id`) + .select( + selectAllTableCols(TableName.PkiAcmeChallenge), + db.ref("token").withSchema(TableName.PkiAcmeChallenge).as("token") + ) + .where(`${TableName.PkiAcmeChallenge}.id`, challengeId) + .where(`${TableName.PkiAcmeChallenge}.authId`, authId) + .where(`${TableName.PkiAcmeAuth}.accountId`, accountId) + .first(); + if (!challenge) { + return null; + } + return challenge; + } catch (error) { + throw new DatabaseError({ error, name: "Find PKI ACME challenge by account id, auth id and challenge id" }); + } + }; return { - ...pkiAcmeChallengeOrm + ...pkiAcmeChallengeOrm, + findByAccountAuthAndChallengeIdWithToken }; }; 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 bbff4a9c4..c55a46542 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -61,7 +61,7 @@ type TPkiAcmeServiceFactoryDep = { acmeOrderDAL: Pick; acmeAuthDAL: Pick; acmeOrderAuthDAL: Pick; - acmeChallengeDAL: Pick; + acmeChallengeDAL: Pick; }; export const pkiAcmeServiceFactory = ({ @@ -300,9 +300,10 @@ export const pkiAcmeServiceFactory = ({ contact: existingAccount.emails, orders: buildUrl(profile.id, `/accounts/${existingAccount.id}/orders`) }, - headers: { - Location: buildUrl(profile.id, `/accounts/${existingAccount.id}`) - } + headers: [ + ["Location", buildUrl(profile.id, `/accounts/${existingAccount.id}`)], + ["Link", `<${buildUrl(profile.id, "/directory")}>;rel="index"`] + ] }; } @@ -321,9 +322,10 @@ export const pkiAcmeServiceFactory = ({ contact: newAccount.emails, orders: buildUrl(profile.id, `/accounts/${newAccount.id}/orders`) }, - headers: { - Location: buildUrl(profile.id, `/accounts/${newAccount.id}`) - } + headers: [ + ["Location", buildUrl(profile.id, `/accounts/${newAccount.id}`)], + ["Link", `<${buildUrl(profile.id, "/directory")}>;rel="index"`] + ] }; }; @@ -343,9 +345,10 @@ export const pkiAcmeServiceFactory = ({ body: { status: "deactivated" }, - headers: { - Location: buildUrl(profileId, `/accounts/${accountId}`) - } + headers: [ + ["Location", buildUrl(profileId, `/accounts/${accountId}`)], + ["Link", `<${buildUrl(profileId, "/directory")}>;rel="index"`] + ] }; }; @@ -429,9 +432,10 @@ export const pkiAcmeServiceFactory = ({ profileId, order }), - headers: { - Location: buildUrl(profileId, `/orders/${order.id}`) - } + headers: [ + ["Location", buildUrl(profileId, `/orders/${order.id}`)], + ["Link", `<${buildUrl(profileId, "/directory")}>;rel="index"`] + ] }; }; @@ -451,7 +455,10 @@ export const pkiAcmeServiceFactory = ({ return { status: 200, body: buildAcmeOrderResource({ profileId, order }), - headers: { Location: buildUrl(profileId, `/orders/${orderId}`) } + headers: [ + ["Location", buildUrl(profileId, `/orders/${orderId}`)], + ["Link", `<${buildUrl(profileId, "/directory")}>;rel="index"`] + ] }; }; @@ -475,9 +482,10 @@ export const pkiAcmeServiceFactory = ({ return { status: 200, body: buildAcmeOrderResource({ profileId, order }), - headers: { - Location: buildUrl(profileId, `/orders/${orderId}`) - } + headers: [ + ["Location", buildUrl(profileId, `/orders/${orderId}`)], + ["Link", `<${buildUrl(profileId, "/directory")}>;rel="index"`] + ] }; }; @@ -499,9 +507,10 @@ export const pkiAcmeServiceFactory = ({ return { status: 200, body: "FIXME-certificate-pem", - headers: { - Location: buildUrl(profileId, `/orders/${orderId}/certificate`) - } + headers: [ + ["Location", buildUrl(profileId, `/orders/${orderId}/certificate`)], + ["Link", `<${buildUrl(profileId, "/directory")}>;rel="index"`] + ] }; }; @@ -519,9 +528,10 @@ export const pkiAcmeServiceFactory = ({ body: { orders: [] }, - headers: { - Location: buildUrl(profileId, `/accounts/${accountId}/orders`) - } + headers: [ + ["Location", buildUrl(profileId, `/accounts/${accountId}/orders`)], + ["Link", `<${buildUrl(profileId, "/directory")}>;rel="index"`] + ] }; }; @@ -559,33 +569,42 @@ export const pkiAcmeServiceFactory = ({ }; }) }, - headers: { - Location: buildUrl(profileId, `/authorizations/${authzId}`) - } + headers: [ + ["Location", buildUrl(profileId, `/authorizations/${authzId}`)], + ["Link", `<${buildUrl(profileId, "/directory")}>;rel="index"`] + ] }; }; const respondToAcmeChallenge = async ({ profileId, - authzId + accountId, + authzId, + challengeId }: { profileId: string; + accountId: string; authzId: string; + challengeId: string; }): Promise> => { - const profile = await validateAcmeProfile(profileId); - // FIXME: Implement ACME challenge response - // Trigger verification process + const challenge = await acmeChallengeDAL.findByAccountAuthAndChallengeIdWithToken(accountId, authzId, challengeId); + if (!challenge) { + throw new NotFoundError({ message: "ACME challenge not found" }); + } + // TODO: Implement ACME challenge response return { status: 200, body: { - type: "http-01", - url: buildUrl(profileId, `/authorizations/${authzId}/challenges/http-01`), - status: "pending", - token: "FIXME-challenge-token" + type: challenge.type, + url: buildUrl(profileId, `/authorizations/${authzId}/challenges/${challengeId}`), + status: challenge.status, + token: challenge.token }, - headers: { - Location: buildUrl(profileId, `/authorizations/${authzId}/challenges/http-01`) - } + headers: [ + ["Location", buildUrl(profileId, `/authorizations/${authzId}/challenges/http-01`)], + ["Link", `<${buildUrl(profileId, `/authorizations/${authzId}`)}>;rel="up"`], + ["Link", `<${buildUrl(profileId, "/directory")}>;rel="index"`] + ] }; }; 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 082078f2a..dbd1ad25f 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-types.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-types.ts @@ -44,7 +44,7 @@ export type TAuthenciatedJwsPayload = TJwsPayload & { }; export type TAcmeResponse = { status: number; - headers: Record; + headers: [string, string][]; body: TPayload; }; @@ -164,9 +164,13 @@ export type TPkiAcmeServiceFactory = { }) => Promise>; respondToAcmeChallenge: ({ profileId, - authzId + accountId, + authzId, + challengeId }: { profileId: string; + accountId: string; authzId: string; + challengeId: string; }) => Promise>; }; From 3c3d89035051cd561dd843c0bf741ba0fe94ded4 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Thu, 30 Oct 2025 22:00:19 -0700 Subject: [PATCH 103/231] Fix sending headers --- backend/src/ee/routes/v1/pki-acme-router.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/ee/routes/v1/pki-acme-router.ts b/backend/src/ee/routes/v1/pki-acme-router.ts index 53bb81224..2fb52695c 100644 --- a/backend/src/ee/routes/v1/pki-acme-router.ts +++ b/backend/src/ee/routes/v1/pki-acme-router.ts @@ -53,7 +53,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { const sendAcmeResponse = async (res: FastifyReply, profileId: string, response: TAcmeResponse): Promise => { res.code(response.status); - for (const [key, value] of Object.entries(response.headers)) { + for (const [key, value] of response.headers) { res.header(key, value); } From 2c53e744c23d2606c09347e136c5b12bf9bf59a7 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 31 Oct 2025 09:12:14 -0700 Subject: [PATCH 104/231] Fix link header overriding issue and challenge dal find method bug --- .../pki-acme/pki-acme-challenge-dal.ts | 2 +- .../ee/services/pki-acme/pki-acme-schemas.ts | 2 + .../ee/services/pki-acme/pki-acme-service.ts | 84 ++++++++++--------- .../ee/services/pki-acme/pki-acme-types.ts | 2 +- 4 files changed, 47 insertions(+), 43 deletions(-) diff --git a/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts b/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts index d23827a37..b8c909497 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts @@ -20,7 +20,7 @@ export const pkiAcmeChallengeDALFactory = (db: TDbClient) => { .join(TableName.PkiAcmeAuth, `${TableName.PkiAcmeChallenge}.authId`, `${TableName.PkiAcmeAuth}.id`) .select( selectAllTableCols(TableName.PkiAcmeChallenge), - db.ref("token").withSchema(TableName.PkiAcmeChallenge).as("token") + db.ref("token").withSchema(TableName.PkiAcmeAuth).as("token") ) .where(`${TableName.PkiAcmeChallenge}.id`, challengeId) .where(`${TableName.PkiAcmeChallenge}.authId`, authId) diff --git a/backend/src/ee/services/pki-acme/pki-acme-schemas.ts b/backend/src/ee/services/pki-acme/pki-acme-schemas.ts index 25a3db3f4..0c9015700 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-schemas.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-schemas.ts @@ -158,6 +158,8 @@ export const GetAcmeAuthorizationResponseSchema = z.object({ ) }); +export const RespondToAcmeChallengeBodySchema = z.object({}); + export const RespondToAcmeChallengeResponseSchema = z.object({ type: z.enum(Object.values(AcmeChallengeType) as [string, ...string[]]), url: z.string(), 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 c55a46542..d47d480f0 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -300,10 +300,10 @@ export const pkiAcmeServiceFactory = ({ contact: existingAccount.emails, orders: buildUrl(profile.id, `/accounts/${existingAccount.id}/orders`) }, - headers: [ - ["Location", buildUrl(profile.id, `/accounts/${existingAccount.id}`)], - ["Link", `<${buildUrl(profile.id, "/directory")}>;rel="index"`] - ] + headers: { + Location: buildUrl(profile.id, `/accounts/${existingAccount.id}`), + Link: `<${buildUrl(profile.id, "/directory")}>;rel="index"` + } }; } @@ -322,10 +322,10 @@ export const pkiAcmeServiceFactory = ({ contact: newAccount.emails, orders: buildUrl(profile.id, `/accounts/${newAccount.id}/orders`) }, - headers: [ - ["Location", buildUrl(profile.id, `/accounts/${newAccount.id}`)], - ["Link", `<${buildUrl(profile.id, "/directory")}>;rel="index"`] - ] + headers: { + Location: buildUrl(profile.id, `/accounts/${newAccount.id}`), + Link: `<${buildUrl(profile.id, "/directory")}>;rel="index"` + } }; }; @@ -345,10 +345,10 @@ export const pkiAcmeServiceFactory = ({ body: { status: "deactivated" }, - headers: [ - ["Location", buildUrl(profileId, `/accounts/${accountId}`)], - ["Link", `<${buildUrl(profileId, "/directory")}>;rel="index"`] - ] + headers: { + Location: buildUrl(profileId, `/accounts/${accountId}`), + Link: `<${buildUrl(profileId, "/directory")}>;rel="index"` + } }; }; @@ -432,10 +432,10 @@ export const pkiAcmeServiceFactory = ({ profileId, order }), - headers: [ - ["Location", buildUrl(profileId, `/orders/${order.id}`)], - ["Link", `<${buildUrl(profileId, "/directory")}>;rel="index"`] - ] + headers: { + Location: buildUrl(profileId, `/orders/${order.id}`), + Link: `<${buildUrl(profileId, "/directory")}>;rel="index"` + } }; }; @@ -455,10 +455,10 @@ export const pkiAcmeServiceFactory = ({ return { status: 200, body: buildAcmeOrderResource({ profileId, order }), - headers: [ - ["Location", buildUrl(profileId, `/orders/${orderId}`)], - ["Link", `<${buildUrl(profileId, "/directory")}>;rel="index"`] - ] + headers: { + Location: buildUrl(profileId, `/orders/${orderId}`), + Link: `<${buildUrl(profileId, "/directory")}>;rel="index"` + } }; }; @@ -482,10 +482,10 @@ export const pkiAcmeServiceFactory = ({ return { status: 200, body: buildAcmeOrderResource({ profileId, order }), - headers: [ - ["Location", buildUrl(profileId, `/orders/${orderId}`)], - ["Link", `<${buildUrl(profileId, "/directory")}>;rel="index"`] - ] + headers: { + Location: buildUrl(profileId, `/orders/${orderId}`), + Link: `<${buildUrl(profileId, "/directory")}>;rel="index"` + } }; }; @@ -507,10 +507,10 @@ export const pkiAcmeServiceFactory = ({ return { status: 200, body: "FIXME-certificate-pem", - headers: [ - ["Location", buildUrl(profileId, `/orders/${orderId}/certificate`)], - ["Link", `<${buildUrl(profileId, "/directory")}>;rel="index"`] - ] + headers: { + Location: buildUrl(profileId, `/orders/${orderId}/certificate`), + Link: `<${buildUrl(profileId, "/directory")}>;rel="index"` + } }; }; @@ -528,10 +528,10 @@ export const pkiAcmeServiceFactory = ({ body: { orders: [] }, - headers: [ - ["Location", buildUrl(profileId, `/accounts/${accountId}/orders`)], - ["Link", `<${buildUrl(profileId, "/directory")}>;rel="index"`] - ] + headers: { + Location: buildUrl(profileId, `/accounts/${accountId}/orders`), + Link: `<${buildUrl(profileId, "/directory")}>;rel="index"` + } }; }; @@ -569,10 +569,10 @@ export const pkiAcmeServiceFactory = ({ }; }) }, - headers: [ - ["Location", buildUrl(profileId, `/authorizations/${authzId}`)], - ["Link", `<${buildUrl(profileId, "/directory")}>;rel="index"`] - ] + headers: { + Location: buildUrl(profileId, `/authorizations/${authzId}`), + Link: `<${buildUrl(profileId, "/directory")}>;rel="index"` + } }; }; @@ -600,11 +600,13 @@ export const pkiAcmeServiceFactory = ({ status: challenge.status, token: challenge.token }, - headers: [ - ["Location", buildUrl(profileId, `/authorizations/${authzId}/challenges/http-01`)], - ["Link", `<${buildUrl(profileId, `/authorizations/${authzId}`)}>;rel="up"`], - ["Link", `<${buildUrl(profileId, "/directory")}>;rel="index"`] - ] + headers: { + Location: buildUrl(profileId, `/authorizations/${authzId}/challenges/${challengeId}`), + Link: [ + `<${buildUrl(profileId, `/authorizations/${authzId}`)}>;rel="up"`, + `<${buildUrl(profileId, "/directory")}>;rel="index"` + ] + } }; }; 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 dbd1ad25f..d2f96deb4 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-types.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-types.ts @@ -44,7 +44,7 @@ export type TAuthenciatedJwsPayload = TJwsPayload & { }; export type TAcmeResponse = { status: number; - headers: [string, string][]; + headers: Record; body: TPayload; }; From 92a1d1f41324fd16eee8267dbf1b3ceb9caa0813 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 31 Oct 2025 09:13:56 -0700 Subject: [PATCH 105/231] Send headers the right way --- backend/src/ee/routes/v1/pki-acme-router.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/backend/src/ee/routes/v1/pki-acme-router.ts b/backend/src/ee/routes/v1/pki-acme-router.ts index 2fb52695c..6344809d0 100644 --- a/backend/src/ee/routes/v1/pki-acme-router.ts +++ b/backend/src/ee/routes/v1/pki-acme-router.ts @@ -16,6 +16,7 @@ import { ListAcmeOrdersPayloadSchema, ListAcmeOrdersResponseSchema, RawJwsPayloadSchema, + RespondToAcmeChallengeBodySchema, RespondToAcmeChallengeResponseSchema } from "@app/ee/services/pki-acme/pki-acme-schemas"; import { ApiDocsTags } from "@app/lib/api-docs"; @@ -53,7 +54,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { const sendAcmeResponse = async (res: FastifyReply, profileId: string, response: TAcmeResponse): Promise => { res.code(response.status); - for (const [key, value] of response.headers) { + for (const [key, value] of Object.entries(response.headers)) { res.header(key, value); } @@ -441,10 +442,10 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { } }, handler: async (req, res) => { - const { profileId, accountId, payload } = await validateExistingAccount({ req }); - if (payload !== "") { - throw new AcmeMalformedError({ detail: "Payload should be empty" }); - } + const { profileId, accountId } = await validateExistingAccount({ + req, + schema: RespondToAcmeChallengeBodySchema + }); return sendAcmeResponse( res, profileId, From 1fe458e23ba8baf6f96bbe560e4452ce854cfbe8 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 31 Oct 2025 10:00:24 -0700 Subject: [PATCH 106/231] Add challenge service --- .../migrations/20251029234547_add-pki-acme.ts | 8 ++- backend/src/db/schemas/pki-acme-accounts.ts | 1 + backend/src/db/schemas/pki-acme-auths.ts | 4 +- backend/src/db/schemas/pki-acme-orders.ts | 8 +-- .../pki-acme/pki-acme-challenge-dal.ts | 45 ++++++++++++++- .../pki-acme/pki-acme-challenge-service.ts | 57 +++++++++++++++++++ .../ee/services/pki-acme/pki-acme-types.ts | 2 + backend/src/lib/config/env.ts | 3 + 8 files changed, 117 insertions(+), 11 deletions(-) create mode 100644 backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts diff --git a/backend/src/db/migrations/20251029234547_add-pki-acme.ts b/backend/src/db/migrations/20251029234547_add-pki-acme.ts index aa334cfba..ed9353bce 100644 --- a/backend/src/db/migrations/20251029234547_add-pki-acme.ts +++ b/backend/src/db/migrations/20251029234547_add-pki-acme.ts @@ -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 { // Create PkiAcmeEnrollmentConfig table @@ -56,12 +56,14 @@ export async function up(knex: Knex): Promise { // 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); }); diff --git a/backend/src/db/schemas/pki-acme-accounts.ts b/backend/src/db/schemas/pki-acme-accounts.ts index 275ff7e7e..69b1ffe49 100644 --- a/backend/src/db/schemas/pki-acme-accounts.ts +++ b/backend/src/db/schemas/pki-acme-accounts.ts @@ -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() diff --git a/backend/src/db/schemas/pki-acme-auths.ts b/backend/src/db/schemas/pki-acme-auths.ts index 15c7a7c55..6d91bb387 100644 --- a/backend/src/db/schemas/pki-acme-auths.ts +++ b/backend/src/db/schemas/pki-acme-auths.ts @@ -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; diff --git a/backend/src/db/schemas/pki-acme-orders.ts b/backend/src/db/schemas/pki-acme-orders.ts index 6d5274f06..738155f46 100644 --- a/backend/src/db/schemas/pki-acme-orders.ts +++ b/backend/src/db/schemas/pki-acme-orders.ts @@ -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; diff --git a/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts b/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts index b8c909497..ae5c1f994 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts @@ -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; @@ -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 }; }; diff --git a/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts new file mode 100644 index 000000000..6e077d0c4 --- /dev/null +++ b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts @@ -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; +}; + +export const pkiAcmeChallengeServiceFactory = ({ + acmeChallengeDAL +}: TPkiAcmeChallengeServiceFactoryDep): TPkiAcmeChallengeServiceFactory => { + const appCfg = getConfig(); + + const validateChallengeResponse = async (challengeId: string): Promise => { + 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 }; +}; 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 d2f96deb4..bb9a15cbb 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-types.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-types.ts @@ -174,3 +174,5 @@ export type TPkiAcmeServiceFactory = { challengeId: string; }) => Promise>; }; + +export type TPkiAcmeChallengeServiceFactory = {}; diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 9fc4cff92..7477bc032 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -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() From 27e88d186d5841741ab9482e14ec7ef3419d6c85 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 31 Oct 2025 10:04:00 -0700 Subject: [PATCH 107/231] Use thumbprint instead --- .../services/pki-acme/pki-acme-account-dal.ts | 14 +++++++---- .../ee/services/pki-acme/pki-acme-service.ts | 23 +++++++++++++++---- 2 files changed, 28 insertions(+), 9 deletions(-) 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 bbfe042ee..685d07f2b 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 @@ -20,19 +20,23 @@ export const pkiAcmeAccountDALFactory = (db: TDbClient) => { } }; - const findByPublicKey = async (profileId: string, alg: string, publicKey: unknown, tx?: Knex) => { + const findByProfileIdAndPublicKeyThumbprintAndAlg = async ( + profileId: string, + alg: string, + publicKeyThumbprint: string, + tx?: Knex + ) => { try { - const account = await (tx || db)(TableName.PkiAcmeAccount).where({ profileId, alg, publicKey }).first(); - + const account = await (tx || db)(TableName.PkiAcmeAccount).where({ profileId, alg, publicKeyThumbprint }).first(); return account || null; } catch (error) { - throw new DatabaseError({ error, name: "Find PKI ACME account by public key and alg" }); + throw new DatabaseError({ error, name: "Find PKI ACME account by profile id, public key thumbprint and alg" }); } }; return { ...pkiAcmeAccountOrm, findByProjectIdAndAccountId, - findByPublicKey + findByProfileIdAndPublicKeyThumbprintAndAlg }; }; 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 d47d480f0..b165df639 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -11,7 +11,14 @@ import { EnrollmentType, TCertificateProfileWithConfigs } from "@app/services/certificate-profile/certificate-profile-types"; -import { errors, flattenedVerify, FlattenedVerifyResult, importJWK, JWSHeaderParameters } from "jose"; +import { + calculateJwkThumbprint, + errors, + flattenedVerify, + FlattenedVerifyResult, + importJWK, + JWSHeaderParameters +} from "jose"; import { z, ZodError } from "zod"; import { TPkiAcmeAccountDALFactory } from "./pki-acme-account-dal"; import { TPkiAcmeAuthDALFactory } from "./pki-acme-auth-dal"; @@ -57,7 +64,10 @@ import { type TPkiAcmeServiceFactoryDep = { certificateProfileDAL: Pick; - acmeAccountDAL: Pick; + acmeAccountDAL: Pick< + TPkiAcmeAccountDALFactory, + "findByProjectIdAndAccountId" | "findByProfileIdAndPublicKeyThumbprintAndAlg" | "create" + >; acmeOrderDAL: Pick; acmeAuthDAL: Pick; acmeOrderAuthDAL: Pick; @@ -286,8 +296,12 @@ export const pkiAcmeServiceFactory = ({ payload: TCreateAcmeAccountPayload; }): Promise> => { const profile = await validateAcmeProfile(profileId); - // TODO: ensure unique account per public key - const existingAccount: TPkiAcmeAccounts | null = await acmeAccountDAL.findByPublicKey(profileId, alg, jwk); + const publicKeyThumbprint = await calculateJwkThumbprint(jwk, "sha256"); + const existingAccount: TPkiAcmeAccounts | null = await acmeAccountDAL.findByProfileIdAndPublicKeyThumbprintAndAlg( + profileId, + alg, + publicKeyThumbprint + ); if (onlyReturnExisting && !existingAccount) { throw new AcmeAccountDoesNotExistError({ message: "ACME account not found" }); } @@ -311,6 +325,7 @@ export const pkiAcmeServiceFactory = ({ profileId: profile.id, alg, publicKey: jwk, + publicKeyThumbprint, emails: contact ?? [] }); // TODO: create audit log here From 2cb38dae055298f93d67fcb2b1001c6ef90b0179 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 31 Oct 2025 10:47:45 -0700 Subject: [PATCH 108/231] Add missing imports --- backend/src/@types/knex.d.ts | 21 ++++++++++++-- backend/src/db/schemas/models.ts | 14 +++++---- .../pki-acme/pki-acme-challenge-dal.ts | 29 +++++++++++++++---- .../pki-acme/pki-acme-challenge-service.ts | 4 +-- .../services/pki-acme/pki-acme-order-dal.ts | 14 +++++++-- 5 files changed, 62 insertions(+), 20 deletions(-) diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 1daf722a1..580da4749 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -266,6 +266,24 @@ import { TOrgRoles, TOrgRolesInsert, TOrgRolesUpdate, + TPkiAcmeAccounts, + TPkiAcmeAccountsInsert, + TPkiAcmeAccountsUpdate, + TPkiAcmeAuths, + TPkiAcmeAuthsInsert, + TPkiAcmeAuthsUpdate, + TPkiAcmeChallenges, + TPkiAcmeChallengesInsert, + TPkiAcmeChallengesUpdate, + TPkiAcmeEnrollmentConfigs, + TPkiAcmeEnrollmentConfigsInsert, + TPkiAcmeEnrollmentConfigsUpdate, + TPkiAcmeOrderAuths, + TPkiAcmeOrderAuthsInsert, + TPkiAcmeOrderAuthsUpdate, + TPkiAcmeOrders, + TPkiAcmeOrdersInsert, + TPkiAcmeOrdersUpdate, TPkiAlerts, TPkiAlertsInsert, TPkiAlertsUpdate, @@ -287,9 +305,6 @@ import { TPkiEstEnrollmentConfigs, TPkiEstEnrollmentConfigsInsert, TPkiEstEnrollmentConfigsUpdate, - TPkiAcmeEnrollmentConfigs, - TPkiAcmeEnrollmentConfigsInsert, - TPkiAcmeEnrollmentConfigsUpdate, TPkiSubscribers, TPkiSubscribersInsert, TPkiSubscribersUpdate, diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index a031c83b9..1170138a3 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -28,11 +28,6 @@ export enum TableName { PkiEstEnrollmentConfig = "pki_est_enrollment_configs", PkiApiEnrollmentConfig = "pki_api_enrollment_configs", PkiAcmeEnrollmentConfig = "pki_acme_enrollment_configs", - PkiAcmeAccount = "pki_acme_accounts", - PkiAcmeOrder = "pki_acme_orders", - PkiAcmeOrderAuth = "pki_acme_order_auths", - PkiAcmeAuth = "pki_acme_auths", - PkiAcmeChallenge = "pki_acme_challenges", PkiSubscriber = "pki_subscribers", PkiAlert = "pki_alerts", PkiCollection = "pki_collections", @@ -216,7 +211,14 @@ export enum TableName { PamAccount = "pam_accounts", PamSession = "pam_sessions", - VaultExternalMigrationConfig = "vault_external_migration_configs" + VaultExternalMigrationConfig = "vault_external_migration_configs", + + // PKI ACME + PkiAcmeAccount = "pki_acme_accounts", + PkiAcmeOrder = "pki_acme_orders", + PkiAcmeOrderAuth = "pki_acme_order_auths", + PkiAcmeAuth = "pki_acme_auths", + PkiAcmeChallenge = "pki_acme_challenges" } export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt" | "commitId"; diff --git a/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts b/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts index ae5c1f994..3103fec7e 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts @@ -1,5 +1,5 @@ import { TDbClient } from "@app/db"; -import { TableName } from "@app/db/schemas"; +import { TableName, TPkiAcmeAccounts, TPkiAcmeAuths } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; import { Knex } from "knex"; @@ -34,9 +34,14 @@ 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 findByIdForChallengeValidation = async (id: string, tx?: Knex) => { const rows = await (tx || db)(TableName.PkiAcmeChallenge) - .join(TableName.PkiAcmeAuth, `${TableName.PkiAcmeChallenge}.authId`, `${TableName.PkiAcmeAuth}.id`) + .join(TableName.PkiAcmeAuth, `${TableName.PkiAcmeChallenge}.authId`, `${TableName.PkiAcmeAuth}.id`) + .join( + TableName.PkiAcmeAccount, + `${TableName.PkiAcmeAuth}.accountId`, + `${TableName.PkiAcmeAccount}.id` + ) .select( selectAllTableCols(TableName.PkiAcmeChallenge), db.ref("id").withSchema(TableName.PkiAcmeAuth).as("authId"), @@ -44,7 +49,9 @@ export const pkiAcmeChallengeDALFactory = (db: TDbClient) => { 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") + db.ref("expiresAt").withSchema(TableName.PkiAcmeAuth).as("authExpiresAt"), + db.ref("id").withSchema(TableName.PkiAcmeAccount).as("accountId"), + db.ref("publicKeyThumbprint").withSchema(TableName.PkiAcmeAccount).as("accountPublicKeyThumbprint") ) // For all challenges, acquire update lock on the auth to avoid race conditions .forUpdate(TableName.PkiAcmeAuth) @@ -68,7 +75,17 @@ export const pkiAcmeChallengeDALFactory = (db: TDbClient) => { identifierType: authIdentifierType, identifierValue: authIdentifierValue, expiresAt: authExpiresAt - }) + }), + childrenMapper: [ + { + key: "accountId", + label: "account" as const, + mapper: ({ accountId, accountPublicKeyThumbprint }) => ({ + id: accountId, + publicKeyThumbprint: accountPublicKeyThumbprint + }) + } + ] } ] })?.[0]; @@ -77,6 +94,6 @@ export const pkiAcmeChallengeDALFactory = (db: TDbClient) => { return { ...pkiAcmeChallengeOrm, findByAccountAuthAndChallengeIdWithToken, - findByIdWithAuthForUpdate + findByIdForChallengeValidation }; }; diff --git a/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts index 6e077d0c4..feaceb92f 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts @@ -6,7 +6,7 @@ import { getConfig } from "@app/lib/config/env"; import { calculateJwkThumbprint } from "jose"; type TPkiAcmeChallengeServiceFactoryDep = { - acmeChallengeDAL: Pick; + acmeChallengeDAL: Pick; }; export const pkiAcmeChallengeServiceFactory = ({ @@ -16,7 +16,7 @@ export const pkiAcmeChallengeServiceFactory = ({ const validateChallengeResponse = async (challengeId: string): Promise => { return await acmeChallengeDAL.transaction(async (tx) => { - const challenge = await acmeChallengeDAL.findByIdWithAuthForUpdate(challengeId, tx); + const challenge = await acmeChallengeDAL.findByIdForChallengeValidation(challengeId, tx); if (!challenge) { throw new NotFoundError({ message: "ACME challenge not found" }); } 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 index d3b7466e4..cf544876c 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-order-dal.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-order-dal.ts @@ -1,7 +1,7 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; -import { TableName } from "@app/db/schemas"; +import { TableName, TPkiAcmeAuths, TPkiAcmeOrderAuths } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; @@ -13,8 +13,16 @@ export const pkiAcmeOrderDALFactory = (db: TDbClient) => { const findByAccountAndOrderIdWithAuthorizations = async (accountId: string, orderId: string, tx?: Knex) => { try { const rows = await (tx || db)(TableName.PkiAcmeOrder) - .join(TableName.PkiAcmeOrderAuth, `${TableName.PkiAcmeOrderAuth}.orderId`, `${TableName.PkiAcmeOrder}.id`) - .join(TableName.PkiAcmeAuth, `${TableName.PkiAcmeOrderAuth}.authId`, `${TableName.PkiAcmeAuth}.id`) + .join( + TableName.PkiAcmeOrderAuth, + `${TableName.PkiAcmeOrderAuth}.orderId`, + `${TableName.PkiAcmeOrder}.id` + ) + .join( + TableName.PkiAcmeAuth, + `${TableName.PkiAcmeOrderAuth}.authId`, + `${TableName.PkiAcmeAuth}.id` + ) .select( selectAllTableCols(TableName.PkiAcmeOrder), db.ref("id").withSchema(TableName.PkiAcmeAuth).as("authId"), From bc00710df303a43bda28d80d6754850ed349beb5 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 31 Oct 2025 13:11:03 -0700 Subject: [PATCH 109/231] Add queue stuff --- .../migrations/20251029234547_add-pki-acme.ts | 2 +- backend/src/db/schemas/pki-acme-auths.ts | 2 +- .../pki-acme/pki-acme-challenge-dal.ts | 83 ++++++++----------- .../pki-acme/pki-acme-challenge-queue.ts | 56 +++++++++++++ .../pki-acme/pki-acme-challenge-service.ts | 30 +++++-- .../services/pki-acme/pki-acme-order-dal.ts | 12 +-- .../ee/services/pki-acme/pki-acme-service.ts | 47 +++++++++-- .../ee/services/pki-acme/pki-acme-types.ts | 4 +- backend/src/queue/queue-service.ts | 11 ++- 9 files changed, 171 insertions(+), 76 deletions(-) create mode 100644 backend/src/ee/services/pki-acme/pki-acme-challenge-queue.ts diff --git a/backend/src/db/migrations/20251029234547_add-pki-acme.ts b/backend/src/db/migrations/20251029234547_add-pki-acme.ts index ed9353bce..c91a179a8 100644 --- a/backend/src/db/migrations/20251029234547_add-pki-acme.ts +++ b/backend/src/db/migrations/20251029234547_add-pki-acme.ts @@ -107,7 +107,7 @@ export async function up(knex: Knex): Promise { t.string("status").notNullable(); // pending, valid, invalid, deactivated, expired, revoked // Token used to validate the authorization through ACME challenge - t.timestamp("token").nullable(); + t.string("token").nullable(); // Identifier type and value t.string("identifierType").notNullable(); // dns diff --git a/backend/src/db/schemas/pki-acme-auths.ts b/backend/src/db/schemas/pki-acme-auths.ts index 6d91bb387..7f20e0f24 100644 --- a/backend/src/db/schemas/pki-acme-auths.ts +++ b/backend/src/db/schemas/pki-acme-auths.ts @@ -11,7 +11,7 @@ export const PkiAcmeAuthsSchema = z.object({ id: z.string().uuid(), accountId: z.string().uuid(), status: z.string(), - token: z.date().nullable().optional(), + token: z.string().nullable().optional(), identifierType: z.string(), identifierValue: z.string(), expiresAt: z.date(), diff --git a/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts b/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts index 3103fec7e..28d1c318a 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts @@ -9,19 +9,11 @@ export type TPkiAcmeChallengeDALFactory = ReturnType { const pkiAcmeChallengeOrm = ormify(db, TableName.PkiAcmeChallenge); - const findByAccountAuthAndChallengeIdWithToken = async ( - accountId: string, - authId: string, - challengeId: string, - tx?: Knex - ) => { + const findByAccountAuthAndChallengeId = async (accountId: string, authId: string, challengeId: string, tx?: Knex) => { try { const challenge = await (tx || db)(TableName.PkiAcmeChallenge) .join(TableName.PkiAcmeAuth, `${TableName.PkiAcmeChallenge}.authId`, `${TableName.PkiAcmeAuth}.id`) - .select( - selectAllTableCols(TableName.PkiAcmeChallenge), - db.ref("token").withSchema(TableName.PkiAcmeAuth).as("token") - ) + .select(selectAllTableCols(TableName.PkiAcmeChallenge)) .where(`${TableName.PkiAcmeChallenge}.id`, challengeId) .where(`${TableName.PkiAcmeChallenge}.authId`, authId) .where(`${TableName.PkiAcmeAuth}.accountId`, accountId) @@ -34,14 +26,11 @@ export const pkiAcmeChallengeDALFactory = (db: TDbClient) => { throw new DatabaseError({ error, name: "Find PKI ACME challenge by account id, auth id and challenge id" }); } }; + const findByIdForChallengeValidation = async (id: string, tx?: Knex) => { - const rows = await (tx || db)(TableName.PkiAcmeChallenge) - .join(TableName.PkiAcmeAuth, `${TableName.PkiAcmeChallenge}.authId`, `${TableName.PkiAcmeAuth}.id`) - .join( - TableName.PkiAcmeAccount, - `${TableName.PkiAcmeAuth}.accountId`, - `${TableName.PkiAcmeAccount}.id` - ) + const result = await (tx || db)(TableName.PkiAcmeChallenge) + .join(TableName.PkiAcmeAuth, `${TableName.PkiAcmeChallenge}.authId`, `${TableName.PkiAcmeAuth}.id`) + .join(TableName.PkiAcmeAccount, `${TableName.PkiAcmeAuth}.accountId`, `${TableName.PkiAcmeAccount}.id`) .select( selectAllTableCols(TableName.PkiAcmeChallenge), db.ref("id").withSchema(TableName.PkiAcmeAuth).as("authId"), @@ -55,45 +44,41 @@ export const pkiAcmeChallengeDALFactory = (db: TDbClient) => { ) // 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) { + .where(`${TableName.PkiAcmeChallenge}.id`, id) + .first(); + if (!result) { 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 - }), - childrenMapper: [ - { - key: "accountId", - label: "account" as const, - mapper: ({ accountId, accountPublicKeyThumbprint }) => ({ - id: accountId, - publicKeyThumbprint: accountPublicKeyThumbprint - }) - } - ] + const { + authId, + authToken, + authStatus, + authIdentifierType, + authIdentifierValue, + authExpiresAt, + accountId, + accountPublicKeyThumbprint, + ...challenge + } = result; + return { + ...challenge, + auth: { + token: authToken, + status: authStatus, + identifierType: authIdentifierType, + identifierValue: authIdentifierValue, + expiresAt: authExpiresAt, + account: { + id: accountId, + publicKeyThumbprint: accountPublicKeyThumbprint } - ] - })?.[0]; + } + }; }; return { ...pkiAcmeChallengeOrm, - findByAccountAuthAndChallengeIdWithToken, + findByAccountAuthAndChallengeId, findByIdForChallengeValidation }; }; diff --git a/backend/src/ee/services/pki-acme/pki-acme-challenge-queue.ts b/backend/src/ee/services/pki-acme/pki-acme-challenge-queue.ts new file mode 100644 index 000000000..1c42f6327 --- /dev/null +++ b/backend/src/ee/services/pki-acme/pki-acme-challenge-queue.ts @@ -0,0 +1,56 @@ +import { Knex } from "knex"; + +import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; + +import { TPkiAcmeAuthDALFactory } from "./pki-acme-auth-dal"; +import { TPkiAcmeChallengeDALFactory } from "./pki-acme-challenge-dal"; +import { TPkiAcmeChallengeServiceFactory } from "./pki-acme-types"; + +// Define types for job data +export type TValidateAcmeChallengeResponseDTO = { + challengeId: string; +}; + +type TChallengeQueueServiceFactoryDep = { + queueService: Pick; + acmeChallengeDAL: Pick; + acmeAuthDAL: Pick; + acmeChallengeService: TPkiAcmeChallengeServiceFactory; +}; + +export type TFolderCommitQueueServiceFactory = ReturnType; + +export const challengeQueueServiceFactory = ({ + queueService, + acmeChallengeService +}: TChallengeQueueServiceFactoryDep) => { + const scheduleChallengeValidation = async (payload: TValidateAcmeChallengeResponseDTO) => { + const { challengeId } = payload; + await queueService.queuePg(QueueJobs.ValidateAcmeChallengeResponse, payload, { + // TODO: maybe we should retry, but let's keep it simple for now + }); + }; + + const validateAcmeChallengeResponse = async (jobData: TValidateAcmeChallengeResponseDTO, tx?: Knex) => { + const { challengeId } = jobData; + await acmeChallengeService.validateChallengeResponse(challengeId); + }; + + const init = async () => { + await queueService.startPg( + QueueJobs.ValidateAcmeChallengeResponse, + async ([job]) => { + await validateAcmeChallengeResponse(job.data as TValidateAcmeChallengeResponseDTO); + }, + { + workerCount: 5, + pollingIntervalSeconds: 30 + } + ); + }; + + return { + scheduleChallengeValidation, + init + }; +}; diff --git a/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts index feaceb92f..03a5c1d30 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts @@ -1,21 +1,25 @@ +import { Knex } from "knex"; + +import { getConfig } from "@app/lib/config/env"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { TPkiAcmeAuthDALFactory } from "./pki-acme-auth-dal"; 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; + acmeAuthDAL: Pick; + acmeChallengeDAL: Pick; }; export const pkiAcmeChallengeServiceFactory = ({ + acmeAuthDAL, acmeChallengeDAL }: TPkiAcmeChallengeServiceFactoryDep): TPkiAcmeChallengeServiceFactory => { const appCfg = getConfig(); const validateChallengeResponse = async (challengeId: string): Promise => { - return await acmeChallengeDAL.transaction(async (tx) => { + return await acmeChallengeDAL.transaction(async (tx: Knex) => { const challenge = await acmeChallengeDAL.findByIdForChallengeValidation(challengeId, tx); if (!challenge) { throw new NotFoundError({ message: "ACME challenge not found" }); @@ -43,13 +47,27 @@ export const pkiAcmeChallengeServiceFactory = ({ ? `${baseUrl}:${appCfg.ACME_DEVELOPMENT_HTTP01_CHALLENGE_PORT}` : baseUrl; - const challengeUrl = new URL(`/.well-known/acme-challenge/${challenge.token}`, actualBaseUrl); + const challengeUrl = new URL(`/.well-known/acme-challenge/${challenge.auth.token}`, actualBaseUrl); + // Notice: well, we are in a transaction, ideally we should not hold transaction and perform + // a long running operation for long time. But assuming we are not performing a tons of + // challenge validation at the same time, it should be fine. + // TODO: bound it with timeout of the fetch request 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}`; + const thumbprint = Buffer.from(challenge.auth.account.publicKeyThumbprint, "utf-8").toString("base64url"); + const expectedChallengeResponseBody = `${challenge.auth.token}.${thumbprint}`; + if (challengeResponseBody !== expectedChallengeResponseBody) { + throw new BadRequestError({ message: "ACME challenge response is not correct" }); + } + await acmeChallengeDAL.updateById( + challengeId, + { status: AcmeChallengeStatus.Valid, validatedAt: new Date() }, + tx + ); + await acmeAuthDAL.updateById(challenge.auth.account.id, { status: AcmeAuthStatus.Valid }, tx); }); }; 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 index cf544876c..47d72a261 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-order-dal.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-order-dal.ts @@ -13,16 +13,8 @@ export const pkiAcmeOrderDALFactory = (db: TDbClient) => { const findByAccountAndOrderIdWithAuthorizations = async (accountId: string, orderId: string, tx?: Knex) => { try { const rows = await (tx || db)(TableName.PkiAcmeOrder) - .join( - TableName.PkiAcmeOrderAuth, - `${TableName.PkiAcmeOrderAuth}.orderId`, - `${TableName.PkiAcmeOrder}.id` - ) - .join( - TableName.PkiAcmeAuth, - `${TableName.PkiAcmeOrderAuth}.authId`, - `${TableName.PkiAcmeAuth}.id` - ) + .join(TableName.PkiAcmeOrderAuth, `${TableName.PkiAcmeOrderAuth}.orderId`, `${TableName.PkiAcmeOrder}.id`) + .join(TableName.PkiAcmeAuth, `${TableName.PkiAcmeOrderAuth}.authId`, `${TableName.PkiAcmeAuth}.id`) .select( selectAllTableCols(TableName.PkiAcmeOrder), db.ref("id").withSchema(TableName.PkiAcmeAuth).as("authId"), 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 b165df639..335af2825 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -2,7 +2,7 @@ import { TPkiAcmeAccounts } from "@app/db/schemas/pki-acme-accounts"; import { TPkiAcmeAuths } from "@app/db/schemas/pki-acme-auths"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; -import { NotFoundError } from "@app/lib/errors"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; @@ -71,7 +71,10 @@ type TPkiAcmeServiceFactoryDep = { acmeOrderDAL: Pick; acmeAuthDAL: Pick; acmeOrderAuthDAL: Pick; - acmeChallengeDAL: Pick; + acmeChallengeDAL: Pick< + TPkiAcmeChallengeDALFactory, + "create" | "transaction" | "updateById" | "findByAccountAuthAndChallengeId" | "findByIdForChallengeValidation" + >; }; export const pkiAcmeServiceFactory = ({ @@ -575,7 +578,7 @@ export const pkiAcmeServiceFactory = ({ type: auth.identifierType, value: auth.identifierValue }, - challenges: auth.challenges.map((challenge: TPkiAcmeChallenges) => { + challenges: auth.challenges.map((challenge) => { return { type: challenge.type, url: buildUrl(profileId, `/authorizations/${authzId}/challenges/${challenge.id}`), @@ -602,10 +605,42 @@ export const pkiAcmeServiceFactory = ({ authzId: string; challengeId: string; }): Promise> => { - const challenge = await acmeChallengeDAL.findByAccountAuthAndChallengeIdWithToken(accountId, authzId, challengeId); - if (!challenge) { + const result = await acmeChallengeDAL.findByAccountAuthAndChallengeId(accountId, authzId, challengeId); + if (!result) { throw new NotFoundError({ message: "ACME challenge not found" }); } + const challenge = await acmeChallengeDAL.transaction(async (tx) => { + const challenge = await acmeChallengeDAL.findByIdForChallengeValidation(challengeId, tx); + if (!challenge) { + throw new NotFoundError({ message: "ACME challenge not found" }); + } + if (challenge.status !== AcmeChallengeStatus.Pending) { + // Ideally this should be an ACME error, but RFC 8555 doesn't say much about corner cases like this... + throw new BadRequestError({ + message: `ACME challenge is ${challenge.status} instead of ${AcmeChallengeStatus.Pending}` + }); + } + 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}` + }); + } + if (!challenge.auth.token) { + throw new AcmeServerInternalError({ message: "ACME challenge token is required" }); + } + const updatedChallenge = await acmeChallengeDAL.updateById( + challengeId, + { status: AcmeChallengeStatus.Pending }, + tx + ); + return { + ...challenge, + ...updatedChallenge + }; + }); // TODO: Implement ACME challenge response return { status: 200, @@ -613,7 +648,7 @@ export const pkiAcmeServiceFactory = ({ type: challenge.type, url: buildUrl(profileId, `/authorizations/${authzId}/challenges/${challengeId}`), status: challenge.status, - token: challenge.token + token: challenge.auth.token! }, headers: { Location: buildUrl(profileId, `/authorizations/${authzId}/challenges/${challengeId}`), 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 bb9a15cbb..c993acf64 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-types.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-types.ts @@ -175,4 +175,6 @@ export type TPkiAcmeServiceFactory = { }) => Promise>; }; -export type TPkiAcmeChallengeServiceFactory = {}; +export type TPkiAcmeChallengeServiceFactory = { + validateChallengeResponse: (challengeId: string) => Promise; +}; diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts index 3e6b1dd19..e0bd0204c 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -42,6 +42,7 @@ import { } from "@app/services/secret-sync/secret-sync-types"; import { CacheType } from "@app/services/super-admin/super-admin-types"; import { TWebhookPayloads } from "@app/services/webhook/webhook-types"; +import { TValidateAcmeChallengeResponseDTO } from "@app/ee/services/pki-acme/pki-acme-challenge-queue"; export enum QueueName { SecretRotation = "secret-rotation", @@ -79,7 +80,8 @@ export enum QueueName { UserNotification = "user-notification", HealthAlert = "health-alert", CertificateV3AutoRenewal = "certificate-v3-auto-renewal", - PamAccountRotation = "pam-account-rotation" + PamAccountRotation = "pam-account-rotation", + PkiAcmeChallengeValidation = "pki-acme-challenge-validation" } export enum QueueJobs { @@ -130,7 +132,8 @@ export enum QueueJobs { UserNotification = "user-notification-job", HealthAlert = "health-alert", CertificateV3DailyAutoRenewal = "certificate-v3-daily-auto-renewal", - PamAccountRotation = "pam-account-rotation" + PamAccountRotation = "pam-account-rotation", + ValidateAcmeChallengeResponse = "validate-acme-challenge-response" } export type TQueueJobTypes = { @@ -369,6 +372,10 @@ export type TQueueJobTypes = { name: QueueJobs.PamAccountRotation; payload: undefined; }; + [QueueName.PkiAcmeChallengeValidation]: { + name: QueueJobs.ValidateAcmeChallengeResponse; + payload: TValidateAcmeChallengeResponseDTO; + }; }; const SECRET_SCANNING_JOBS = [ From 0613a497950a365191b398783113d3ef989645d7 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 31 Oct 2025 13:23:30 -0700 Subject: [PATCH 110/231] More challenge logic --- .../pki-acme/pki-acme-challenge-service.ts | 56 +++++++++++-------- .../ee/services/pki-acme/pki-acme-service.ts | 3 + 2 files changed, 36 insertions(+), 23 deletions(-) diff --git a/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts index 03a5c1d30..9091b6926 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts @@ -2,6 +2,7 @@ import { Knex } from "knex"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; import { TPkiAcmeAuthDALFactory } from "./pki-acme-auth-dal"; import { TPkiAcmeChallengeDALFactory } from "./pki-acme-challenge-dal"; import { AcmeAuthStatus, AcmeChallengeStatus, AcmeChallengeType } from "./pki-acme-schemas"; @@ -18,15 +19,16 @@ export const pkiAcmeChallengeServiceFactory = ({ }: TPkiAcmeChallengeServiceFactoryDep): TPkiAcmeChallengeServiceFactory => { const appCfg = getConfig(); - const validateChallengeResponse = async (challengeId: string): Promise => { + const validateChallengeResponse = async (challengeId: string, tx?: Knex): Promise => { return await acmeChallengeDAL.transaction(async (tx: Knex) => { + logger.info({ challengeId }, "Validating ACME challenge response"); const challenge = await acmeChallengeDAL.findByIdForChallengeValidation(challengeId, tx); if (!challenge) { throw new NotFoundError({ message: "ACME challenge not found" }); } - if (challenge.status !== AcmeChallengeStatus.Processing) { + if (challenge.status !== AcmeChallengeStatus.Pending) { throw new BadRequestError({ - message: `ACME challenge is ${challenge.status} instead of ${AcmeChallengeStatus.Processing}` + message: `ACME challenge is ${challenge.status} instead of ${AcmeChallengeStatus.Pending}` }); } if (challenge.auth.expiresAt < new Date()) { @@ -46,28 +48,36 @@ export const pkiAcmeChallengeServiceFactory = ({ const actualBaseUrl = appCfg.isAcmeDevelopmentMode ? `${baseUrl}:${appCfg.ACME_DEVELOPMENT_HTTP01_CHALLENGE_PORT}` : baseUrl; - const challengeUrl = new URL(`/.well-known/acme-challenge/${challenge.auth.token}`, actualBaseUrl); - // Notice: well, we are in a transaction, ideally we should not hold transaction and perform - // a long running operation for long time. But assuming we are not performing a tons of - // challenge validation at the same time, it should be fine. - // TODO: bound it with timeout of the fetch request - const challengeResponse = await fetch(challengeUrl); - if (challengeResponse.status !== 200) { - throw new BadRequestError({ message: "ACME challenge response is not 200" }); + try { + // Notice: well, we are in a transaction, ideally we should not hold transaction and perform + // a long running operation for long time. But assuming we are not performing a tons of + // challenge validation at the same time, it should be fine. + // TODO: bound it with timeout of the fetch request + 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 thumbprint = Buffer.from(challenge.auth.account.publicKeyThumbprint, "utf-8").toString("base64url"); + const expectedChallengeResponseBody = `${challenge.auth.token}.${thumbprint}`; + if (challengeResponseBody !== expectedChallengeResponseBody) { + throw new BadRequestError({ message: "ACME challenge response is not correct" }); + } + await acmeChallengeDAL.updateById( + challengeId, + { status: AcmeChallengeStatus.Valid, validatedAt: new Date() }, + tx + ); + await acmeAuthDAL.updateById(challenge.auth.account.id, { status: AcmeAuthStatus.Valid }, tx); + await acmeAuthDAL.updateById(challenge.auth.account.id, { status: AcmeAuthStatus.Valid }, tx); + } catch (error) { + logger.error(error, "Error validating ACME challenge response"); + // TODO: we should retry the challenge validation a few times, but let's keep it simple for now + await acmeChallengeDAL.updateById(challengeId, { status: AcmeChallengeStatus.Invalid }, tx); + await acmeAuthDAL.updateById(challenge.auth.account.id, { status: AcmeAuthStatus.Invalid }, tx); + throw error; } - const challengeResponseBody = await challengeResponse.text(); - const thumbprint = Buffer.from(challenge.auth.account.publicKeyThumbprint, "utf-8").toString("base64url"); - const expectedChallengeResponseBody = `${challenge.auth.token}.${thumbprint}`; - if (challengeResponseBody !== expectedChallengeResponseBody) { - throw new BadRequestError({ message: "ACME challenge response is not correct" }); - } - await acmeChallengeDAL.updateById( - challengeId, - { status: AcmeChallengeStatus.Valid, validatedAt: new Date() }, - tx - ); - await acmeAuthDAL.updateById(challenge.auth.account.id, { status: AcmeAuthStatus.Valid }, tx); }); }; 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 335af2825..8cc96c1b0 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -631,6 +631,9 @@ export const pkiAcmeServiceFactory = ({ if (!challenge.auth.token) { throw new AcmeServerInternalError({ message: "ACME challenge token is required" }); } + if (challenge.type !== AcmeChallengeType.HTTP_01) { + throw new BadRequestError({ message: "Only HTTP-01 challenges are supported for now" }); + } const updatedChallenge = await acmeChallengeDAL.updateById( challengeId, { status: AcmeChallengeStatus.Pending }, From aa62e465a0a2d49c4e84897c89390c7713c2ecdd Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 31 Oct 2025 13:32:29 -0700 Subject: [PATCH 111/231] Revert queue stuff --- .../pki-acme/pki-acme-challenge-queue.ts | 56 ------------------- .../pki-acme/pki-acme-challenge-service.ts | 6 +- .../ee/services/pki-acme/pki-acme-errors.ts | 24 ++++++++ .../ee/services/pki-acme/pki-acme-service.ts | 34 ++--------- backend/src/queue/queue-service.ts | 11 +--- 5 files changed, 34 insertions(+), 97 deletions(-) delete mode 100644 backend/src/ee/services/pki-acme/pki-acme-challenge-queue.ts diff --git a/backend/src/ee/services/pki-acme/pki-acme-challenge-queue.ts b/backend/src/ee/services/pki-acme/pki-acme-challenge-queue.ts deleted file mode 100644 index 1c42f6327..000000000 --- a/backend/src/ee/services/pki-acme/pki-acme-challenge-queue.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Knex } from "knex"; - -import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; - -import { TPkiAcmeAuthDALFactory } from "./pki-acme-auth-dal"; -import { TPkiAcmeChallengeDALFactory } from "./pki-acme-challenge-dal"; -import { TPkiAcmeChallengeServiceFactory } from "./pki-acme-types"; - -// Define types for job data -export type TValidateAcmeChallengeResponseDTO = { - challengeId: string; -}; - -type TChallengeQueueServiceFactoryDep = { - queueService: Pick; - acmeChallengeDAL: Pick; - acmeAuthDAL: Pick; - acmeChallengeService: TPkiAcmeChallengeServiceFactory; -}; - -export type TFolderCommitQueueServiceFactory = ReturnType; - -export const challengeQueueServiceFactory = ({ - queueService, - acmeChallengeService -}: TChallengeQueueServiceFactoryDep) => { - const scheduleChallengeValidation = async (payload: TValidateAcmeChallengeResponseDTO) => { - const { challengeId } = payload; - await queueService.queuePg(QueueJobs.ValidateAcmeChallengeResponse, payload, { - // TODO: maybe we should retry, but let's keep it simple for now - }); - }; - - const validateAcmeChallengeResponse = async (jobData: TValidateAcmeChallengeResponseDTO, tx?: Knex) => { - const { challengeId } = jobData; - await acmeChallengeService.validateChallengeResponse(challengeId); - }; - - const init = async () => { - await queueService.startPg( - QueueJobs.ValidateAcmeChallengeResponse, - async ([job]) => { - await validateAcmeChallengeResponse(job.data as TValidateAcmeChallengeResponseDTO); - }, - { - workerCount: 5, - pollingIntervalSeconds: 30 - } - ); - }; - - return { - scheduleChallengeValidation, - init - }; -}; diff --git a/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts index 9091b6926..be46de35a 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts @@ -5,6 +5,7 @@ import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { TPkiAcmeAuthDALFactory } from "./pki-acme-auth-dal"; import { TPkiAcmeChallengeDALFactory } from "./pki-acme-challenge-dal"; +import { AcmeIncorrectResponseError } from "./pki-acme-errors"; import { AcmeAuthStatus, AcmeChallengeStatus, AcmeChallengeType } from "./pki-acme-schemas"; import { TPkiAcmeChallengeServiceFactory } from "./pki-acme-types"; @@ -62,7 +63,7 @@ export const pkiAcmeChallengeServiceFactory = ({ const thumbprint = Buffer.from(challenge.auth.account.publicKeyThumbprint, "utf-8").toString("base64url"); const expectedChallengeResponseBody = `${challenge.auth.token}.${thumbprint}`; if (challengeResponseBody !== expectedChallengeResponseBody) { - throw new BadRequestError({ message: "ACME challenge response is not correct" }); + throw new AcmeIncorrectResponseError({ message: "ACME challenge response is not correct" }); } await acmeChallengeDAL.updateById( challengeId, @@ -70,12 +71,13 @@ export const pkiAcmeChallengeServiceFactory = ({ tx ); await acmeAuthDAL.updateById(challenge.auth.account.id, { status: AcmeAuthStatus.Valid }, tx); - await acmeAuthDAL.updateById(challenge.auth.account.id, { status: AcmeAuthStatus.Valid }, tx); + // TODO: trigger a check for order status as well } catch (error) { logger.error(error, "Error validating ACME challenge response"); // TODO: we should retry the challenge validation a few times, but let's keep it simple for now await acmeChallengeDAL.updateById(challengeId, { status: AcmeChallengeStatus.Invalid }, tx); await acmeAuthDAL.updateById(challenge.auth.account.id, { status: AcmeAuthStatus.Invalid }, tx); + // TODO: trigger a check for order status as well throw error; } }); diff --git a/backend/src/ee/services/pki-acme/pki-acme-errors.ts b/backend/src/ee/services/pki-acme/pki-acme-errors.ts index 85de5cb1c..b09499346 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-errors.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-errors.ts @@ -434,3 +434,27 @@ export class AcmeUserActionRequiredError extends AcmeError { }; } } + +/** + * incorrectResponse - The response is incorrect (RFC 8555 Section 6.7.16) + */ +export class AcmeIncorrectResponseError extends AcmeError { + constructor({ + detail = "The response is incorrect", + error, + message + }: { + detail?: string; + error?: unknown; + message?: string; + } = {}) { + super({ + type: "incorrectResponse", + detail, + status: 400, + error, + message + }); + this.name = "AcmeIncorrectResponseError"; + } +} 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 8cc96c1b0..a4916c555 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -75,6 +75,7 @@ type TPkiAcmeServiceFactoryDep = { TPkiAcmeChallengeDALFactory, "create" | "transaction" | "updateById" | "findByAccountAuthAndChallengeId" | "findByIdForChallengeValidation" >; + acmeChallengeService: TPkiAcmeChallengeServiceFactory; }; export const pkiAcmeServiceFactory = ({ @@ -83,7 +84,8 @@ export const pkiAcmeServiceFactory = ({ acmeOrderDAL, acmeAuthDAL, acmeOrderAuthDAL, - acmeChallengeDAL + acmeChallengeDAL, + acmeChallengeService }: TPkiAcmeServiceFactoryDep): TPkiAcmeServiceFactory => { const validateAcmeProfile = async (profileId: string): Promise => { const profile = await certificateProfileDAL.findById(profileId); @@ -610,35 +612,7 @@ export const pkiAcmeServiceFactory = ({ throw new NotFoundError({ message: "ACME challenge not found" }); } const challenge = await acmeChallengeDAL.transaction(async (tx) => { - const challenge = await acmeChallengeDAL.findByIdForChallengeValidation(challengeId, tx); - if (!challenge) { - throw new NotFoundError({ message: "ACME challenge not found" }); - } - if (challenge.status !== AcmeChallengeStatus.Pending) { - // Ideally this should be an ACME error, but RFC 8555 doesn't say much about corner cases like this... - throw new BadRequestError({ - message: `ACME challenge is ${challenge.status} instead of ${AcmeChallengeStatus.Pending}` - }); - } - 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}` - }); - } - if (!challenge.auth.token) { - throw new AcmeServerInternalError({ message: "ACME challenge token is required" }); - } - if (challenge.type !== AcmeChallengeType.HTTP_01) { - throw new BadRequestError({ message: "Only HTTP-01 challenges are supported for now" }); - } - const updatedChallenge = await acmeChallengeDAL.updateById( - challengeId, - { status: AcmeChallengeStatus.Pending }, - tx - ); + await acmeChallengeService.validateChallengeResponse(challengeId, tx); return { ...challenge, ...updatedChallenge diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts index e0bd0204c..3e6b1dd19 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -42,7 +42,6 @@ import { } from "@app/services/secret-sync/secret-sync-types"; import { CacheType } from "@app/services/super-admin/super-admin-types"; import { TWebhookPayloads } from "@app/services/webhook/webhook-types"; -import { TValidateAcmeChallengeResponseDTO } from "@app/ee/services/pki-acme/pki-acme-challenge-queue"; export enum QueueName { SecretRotation = "secret-rotation", @@ -80,8 +79,7 @@ export enum QueueName { UserNotification = "user-notification", HealthAlert = "health-alert", CertificateV3AutoRenewal = "certificate-v3-auto-renewal", - PamAccountRotation = "pam-account-rotation", - PkiAcmeChallengeValidation = "pki-acme-challenge-validation" + PamAccountRotation = "pam-account-rotation" } export enum QueueJobs { @@ -132,8 +130,7 @@ export enum QueueJobs { UserNotification = "user-notification-job", HealthAlert = "health-alert", CertificateV3DailyAutoRenewal = "certificate-v3-daily-auto-renewal", - PamAccountRotation = "pam-account-rotation", - ValidateAcmeChallengeResponse = "validate-acme-challenge-response" + PamAccountRotation = "pam-account-rotation" } export type TQueueJobTypes = { @@ -372,10 +369,6 @@ export type TQueueJobTypes = { name: QueueJobs.PamAccountRotation; payload: undefined; }; - [QueueName.PkiAcmeChallengeValidation]: { - name: QueueJobs.ValidateAcmeChallengeResponse; - payload: TValidateAcmeChallengeResponseDTO; - }; }; const SECRET_SCANNING_JOBS = [ From 9e8f3843a632c2cd054857814031dfcb09321156 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 31 Oct 2025 14:13:47 -0700 Subject: [PATCH 112/231] Implement mark valid cascade --- .../pki-acme/pki-acme-challenge-dal.ts | 52 ++++++++++++++++++- .../pki-acme/pki-acme-challenge-service.ts | 13 ++--- 2 files changed, 55 insertions(+), 10 deletions(-) diff --git a/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts b/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts index 28d1c318a..6cc465d99 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts @@ -1,14 +1,61 @@ import { TDbClient } from "@app/db"; -import { TableName, TPkiAcmeAccounts, TPkiAcmeAuths } from "@app/db/schemas"; +import { TableName, TPkiAcmeChallenges } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; -import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; import { Knex } from "knex"; +import { AcmeAuthStatus, AcmeChallengeStatus, AcmeOrderStatus } from "./pki-acme-schemas"; export type TPkiAcmeChallengeDALFactory = ReturnType; export const pkiAcmeChallengeDALFactory = (db: TDbClient) => { const pkiAcmeChallengeOrm = ormify(db, TableName.PkiAcmeChallenge); + const markAsValidCascadeById = async (id: string, tx?: Knex): Promise => { + try { + const [challenge] = (await (tx || db)(TableName.PkiAcmeChallenge) + .where({ id }) + .update({ status: AcmeChallengeStatus.Valid, validatedAt: new Date() }) + .returning("*")) as [TPkiAcmeChallenges]; + + // Update pending auth to valid as well + const updatedAuths = await (tx || db)(TableName.PkiAcmeAuth) + .where({ id: challenge.authId, status: AcmeAuthStatus.Pending }) + .update({ status: AcmeAuthStatus.Valid }) + .returning("id"); + + if (updatedAuths.length > 0) { + // Update status for pending orders that have all auths valid + await (tx || db)(TableName.PkiAcmeOrder) + .whereIn("id", (qb) => { + qb.select("id") + .from(TableName.PkiAcmeOrder) + .join(TableName.PkiAcmeOrderAuth, `${TableName.PkiAcmeOrder}.id`, `${TableName.PkiAcmeOrderAuth}.orderId`) + .join(TableName.PkiAcmeAuth, `${TableName.PkiAcmeOrderAuth}.authId`, `${TableName.PkiAcmeAuth}.id`) + .groupBy(`${TableName.PkiAcmeOrder}.id`) + // All auths should be valid for the order to be ready + .havingRaw( + `SUM(CASE WHEN :authTable:.status = :authStatus: THEN 1 ELSE 0 END) = COUNT(DISTINCT :authTable:.id)`, + { + authTable: TableName.PkiAcmeAuth, + authStatus: AcmeAuthStatus.Valid + } + ) + // We only update orders that are pending + .where(`${TableName.PkiAcmeOrder}.status`, AcmeOrderStatus.Pending) + .whereIn( + `${TableName.PkiAcmeAuth}.id`, + updatedAuths.map((auth) => auth.id) + ); + }) + .update({ status: AcmeOrderStatus.Ready }); + } + + return challenge; + } catch (error) { + throw new DatabaseError({ error, name: "Update certificate profile" }); + } + }; + const findByAccountAuthAndChallengeId = async (accountId: string, authId: string, challengeId: string, tx?: Knex) => { try { const challenge = await (tx || db)(TableName.PkiAcmeChallenge) @@ -78,6 +125,7 @@ export const pkiAcmeChallengeDALFactory = (db: TDbClient) => { return { ...pkiAcmeChallengeOrm, + markAsValidCascadeById, findByAccountAuthAndChallengeId, findByIdForChallengeValidation }; diff --git a/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts index be46de35a..6cb04f2d5 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts @@ -11,7 +11,10 @@ import { TPkiAcmeChallengeServiceFactory } from "./pki-acme-types"; type TPkiAcmeChallengeServiceFactoryDep = { acmeAuthDAL: Pick; - acmeChallengeDAL: Pick; + acmeChallengeDAL: Pick< + TPkiAcmeChallengeDALFactory, + "transaction" | "findByIdForChallengeValidation" | "markAsValidCascadeById" + >; }; export const pkiAcmeChallengeServiceFactory = ({ @@ -65,13 +68,7 @@ export const pkiAcmeChallengeServiceFactory = ({ if (challengeResponseBody !== expectedChallengeResponseBody) { throw new AcmeIncorrectResponseError({ message: "ACME challenge response is not correct" }); } - await acmeChallengeDAL.updateById( - challengeId, - { status: AcmeChallengeStatus.Valid, validatedAt: new Date() }, - tx - ); - await acmeAuthDAL.updateById(challenge.auth.account.id, { status: AcmeAuthStatus.Valid }, tx); - // TODO: trigger a check for order status as well + await acmeChallengeDAL.markAsValidCascadeById(challengeId, tx); } catch (error) { logger.error(error, "Error validating ACME challenge response"); // TODO: we should retry the challenge validation a few times, but let's keep it simple for now From 737bbd3e08cab5cc620b13a48caec20e1755de1b Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 31 Oct 2025 14:21:48 -0700 Subject: [PATCH 113/231] Try to make challenge works --- .../ee/services/pki-acme/pki-acme-challenge-dal.ts | 14 ++++++++++++++ .../pki-acme/pki-acme-challenge-service.ts | 9 ++++----- .../src/ee/services/pki-acme/pki-acme-service.ts | 10 ++-------- backend/src/ee/services/pki-acme/pki-acme-types.ts | 1 + 4 files changed, 21 insertions(+), 13 deletions(-) diff --git a/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts b/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts index 6cc465d99..48bc1dfac 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts @@ -56,6 +56,19 @@ export const pkiAcmeChallengeDALFactory = (db: TDbClient) => { } }; + const markAsInvalidCascadeById = async (id: string, tx?: Knex): Promise => { + try { + const [challenge] = (await (tx || db)(TableName.PkiAcmeChallenge) + .where({ id }) + .update({ status: AcmeChallengeStatus.Valid, validatedAt: new Date() }) + .returning("*")) as [TPkiAcmeChallenges]; + // TODO: + return challenge; + } catch (error) { + throw new DatabaseError({ error, name: "Update certificate profile" }); + } + }; + const findByAccountAuthAndChallengeId = async (accountId: string, authId: string, challengeId: string, tx?: Knex) => { try { const challenge = await (tx || db)(TableName.PkiAcmeChallenge) @@ -126,6 +139,7 @@ export const pkiAcmeChallengeDALFactory = (db: TDbClient) => { return { ...pkiAcmeChallengeOrm, markAsValidCascadeById, + markAsInvalidCascadeById, findByAccountAuthAndChallengeId, findByIdForChallengeValidation }; diff --git a/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts index 6cb04f2d5..af0ea0ebe 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts @@ -8,6 +8,7 @@ import { TPkiAcmeChallengeDALFactory } from "./pki-acme-challenge-dal"; import { AcmeIncorrectResponseError } from "./pki-acme-errors"; import { AcmeAuthStatus, AcmeChallengeStatus, AcmeChallengeType } from "./pki-acme-schemas"; import { TPkiAcmeChallengeServiceFactory } from "./pki-acme-types"; +import { TPkiAcmeChallenges } from "@app/db/schemas"; type TPkiAcmeChallengeServiceFactoryDep = { acmeAuthDAL: Pick; @@ -23,8 +24,8 @@ export const pkiAcmeChallengeServiceFactory = ({ }: TPkiAcmeChallengeServiceFactoryDep): TPkiAcmeChallengeServiceFactory => { const appCfg = getConfig(); - const validateChallengeResponse = async (challengeId: string, tx?: Knex): Promise => { - return await acmeChallengeDAL.transaction(async (tx: Knex) => { + const validateChallengeResponse = async (challengeId: string): Promise => { + return await acmeChallengeDAL.transaction(async (tx) => { logger.info({ challengeId }, "Validating ACME challenge response"); const challenge = await acmeChallengeDAL.findByIdForChallengeValidation(challengeId, tx); if (!challenge) { @@ -72,9 +73,7 @@ export const pkiAcmeChallengeServiceFactory = ({ } catch (error) { logger.error(error, "Error validating ACME challenge response"); // TODO: we should retry the challenge validation a few times, but let's keep it simple for now - await acmeChallengeDAL.updateById(challengeId, { status: AcmeChallengeStatus.Invalid }, tx); - await acmeAuthDAL.updateById(challenge.auth.account.id, { status: AcmeAuthStatus.Invalid }, tx); - // TODO: trigger a check for order status as well + await acmeChallengeDAL.markAsValidCascadeById(challengeId, tx); throw error; } }); 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 a4916c555..6dad459c2 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -611,14 +611,8 @@ export const pkiAcmeServiceFactory = ({ if (!result) { throw new NotFoundError({ message: "ACME challenge not found" }); } - const challenge = await acmeChallengeDAL.transaction(async (tx) => { - await acmeChallengeService.validateChallengeResponse(challengeId, tx); - return { - ...challenge, - ...updatedChallenge - }; - }); - // TODO: Implement ACME challenge response + await acmeChallengeService.validateChallengeResponse(challengeId); + const challenge = (await acmeChallengeDAL.findByIdForChallengeValidation(challengeId))!; return { status: 200, body: { 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 c993acf64..e9154fb5d 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-types.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-types.ts @@ -1,5 +1,6 @@ import { z } from "zod"; +import { TPkiAcmeChallenges } from "@app/db/schemas"; import { JWSHeaderParameters } from "jose"; import { AcmeOrderResourceSchema, From 8bc7c8b79667cce9e4ffb4444e427158f299088d Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 31 Oct 2025 14:35:32 -0700 Subject: [PATCH 114/231] Fix mark valid logic --- .../pki-acme/pki-acme-challenge-dal.ts | 44 ++++++++++++++++--- 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts b/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts index 48bc1dfac..7d77f51bd 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts @@ -24,6 +24,16 @@ export const pkiAcmeChallengeDALFactory = (db: TDbClient) => { .returning("id"); if (updatedAuths.length > 0) { + // Find all the orders that are involved in the challenge validation + const involvedOrderIds = await (tx || db)(TableName.PkiAcmeOrder) + .distinct("id") + .join(TableName.PkiAcmeOrderAuth, `${TableName.PkiAcmeOrder}.id`, `${TableName.PkiAcmeOrderAuth}.orderId`) + .join(TableName.PkiAcmeAuth, `${TableName.PkiAcmeOrderAuth}.authId`, `${TableName.PkiAcmeAuth}.id`) + .whereIn( + `${TableName.PkiAcmeAuth}.id`, + updatedAuths.map((auth) => auth.id) + ) + .as("involvedOrderIds"); // Update status for pending orders that have all auths valid await (tx || db)(TableName.PkiAcmeOrder) .whereIn("id", (qb) => { @@ -40,12 +50,7 @@ export const pkiAcmeChallengeDALFactory = (db: TDbClient) => { authStatus: AcmeAuthStatus.Valid } ) - // We only update orders that are pending - .where(`${TableName.PkiAcmeOrder}.status`, AcmeOrderStatus.Pending) - .whereIn( - `${TableName.PkiAcmeAuth}.id`, - updatedAuths.map((auth) => auth.id) - ); + .whereIn(`${TableName.PkiAcmeOrder}.id`, involvedOrderIds); }) .update({ status: AcmeOrderStatus.Ready }); } @@ -62,7 +67,32 @@ export const pkiAcmeChallengeDALFactory = (db: TDbClient) => { .where({ id }) .update({ status: AcmeChallengeStatus.Valid, validatedAt: new Date() }) .returning("*")) as [TPkiAcmeChallenges]; - // TODO: + + // Update pending auth to valid as well + const updatedAuths = await (tx || db)(TableName.PkiAcmeAuth) + .where({ id: challenge.authId, status: AcmeAuthStatus.Pending }) + .update({ status: AcmeAuthStatus.Invalid }) + .returning("id"); + + if (updatedAuths.length > 0) { + // Update status for pending orders that have all auths valid + await (tx || db)(TableName.PkiAcmeOrder) + .whereIn("id", (qb) => { + qb.select("id") + .from(TableName.PkiAcmeOrder) + .join(TableName.PkiAcmeOrderAuth, `${TableName.PkiAcmeOrder}.id`, `${TableName.PkiAcmeOrderAuth}.orderId`) + .join(TableName.PkiAcmeAuth, `${TableName.PkiAcmeOrderAuth}.authId`, `${TableName.PkiAcmeAuth}.id`) + // We only update orders that are pending + .where(`${TableName.PkiAcmeOrder}.status`, AcmeOrderStatus.Pending) + .whereIn( + `${TableName.PkiAcmeAuth}.id`, + updatedAuths.map((auth) => auth.id) + ); + }) + .update({ status: AcmeOrderStatus.Invalid }); + } + + // TODO: update order status to invalid as well return challenge; } catch (error) { throw new DatabaseError({ error, name: "Update certificate profile" }); From 31801455f019074cc924021d1c1bf6e5617d10d1 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 31 Oct 2025 14:36:34 -0700 Subject: [PATCH 115/231] Add cond --- backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts b/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts index 7d77f51bd..03d309c03 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts @@ -50,6 +50,8 @@ export const pkiAcmeChallengeDALFactory = (db: TDbClient) => { authStatus: AcmeAuthStatus.Valid } ) + // Only update orders that are pending + .where(`${TableName.PkiAcmeOrder}.status`, AcmeOrderStatus.Pending) .whereIn(`${TableName.PkiAcmeOrder}.id`, involvedOrderIds); }) .update({ status: AcmeOrderStatus.Ready }); From 941e857b0b8d1fc8a66096e3d0b859226b4549ec Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 31 Oct 2025 14:37:15 -0700 Subject: [PATCH 116/231] Fix syntax --- backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts b/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts index 03d309c03..08f52a790 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts @@ -44,7 +44,7 @@ export const pkiAcmeChallengeDALFactory = (db: TDbClient) => { .groupBy(`${TableName.PkiAcmeOrder}.id`) // All auths should be valid for the order to be ready .havingRaw( - `SUM(CASE WHEN :authTable:.status = :authStatus: THEN 1 ELSE 0 END) = COUNT(DISTINCT :authTable:.id)`, + "SUM(CASE WHEN :authTable:.status = :authStatus THEN 1 ELSE 0 END) = COUNT(DISTINCT :authTable:.id)", { authTable: TableName.PkiAcmeAuth, authStatus: AcmeAuthStatus.Valid From e7a5e130852e550c656a614ef860d105bfb1cbc3 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 31 Oct 2025 14:43:32 -0700 Subject: [PATCH 117/231] Add missing deps --- backend/src/ee/services/pki-acme/pki-acme-service.ts | 4 ++-- backend/src/server/routes/index.ts | 8 +++++++- 2 files changed, 9 insertions(+), 3 deletions(-) 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 6dad459c2..cca7c0957 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -2,11 +2,10 @@ import { TPkiAcmeAccounts } from "@app/db/schemas/pki-acme-accounts"; import { TPkiAcmeAuths } from "@app/db/schemas/pki-acme-auths"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; -import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { NotFoundError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; -import { TPkiAcmeChallenges } from "@app/db/schemas"; import { EnrollmentType, TCertificateProfileWithConfigs @@ -57,6 +56,7 @@ import { TGetAcmeDirectoryResponse, TJwsPayload, TListAcmeOrdersResponse, + TPkiAcmeChallengeServiceFactory, TPkiAcmeServiceFactory, TRawJwsPayload, TRespondToAcmeChallengeResponse diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index d3ee4caae..a3355ada3 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -75,6 +75,7 @@ import { permissionDALFactory } from "@app/ee/services/permission/permission-dal import { permissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { pitServiceFactory } from "@app/ee/services/pit/pit-service"; import { pkiAcmeAuthDALFactory } from "@app/ee/services/pki-acme/pki-acme-auth-dal"; +import { pkiAcmeChallengeServiceFactory } from "@app/ee/services/pki-acme/pki-acme-challenge-service"; import { pkiAcmeOrderAuthDALFactory } from "@app/ee/services/pki-acme/pki-acme-order-auth-dal"; import { pkiAcmeServiceFactory } from "@app/ee/services/pki-acme/pki-acme-service"; import { projectTemplateDALFactory } from "@app/ee/services/project-template/project-template-dal"; @@ -1174,13 +1175,18 @@ export const registerRoutes = async ( projectDAL }); + const acmeChallengeService = pkiAcmeChallengeServiceFactory({ + acmeAuthDAL, + acmeChallengeDAL + }); const pkiAcmeService = pkiAcmeServiceFactory({ certificateProfileDAL, acmeAccountDAL, acmeOrderDAL, acmeAuthDAL, acmeOrderAuthDAL, - acmeChallengeDAL + acmeChallengeDAL, + acmeChallengeService }); const pkiAlertService = pkiAlertServiceFactory({ From a07775664b9aeec2417f4fcaf07ea6b8f63e3d16 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 31 Oct 2025 15:35:52 -0700 Subject: [PATCH 118/231] Fix the query --- .../pki-acme/pki-acme-challenge-dal.ts | 37 ++++++++----------- 1 file changed, 16 insertions(+), 21 deletions(-) diff --git a/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts b/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts index 08f52a790..05925154e 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts @@ -25,34 +25,29 @@ export const pkiAcmeChallengeDALFactory = (db: TDbClient) => { if (updatedAuths.length > 0) { // Find all the orders that are involved in the challenge validation - const involvedOrderIds = await (tx || db)(TableName.PkiAcmeOrder) - .distinct("id") - .join(TableName.PkiAcmeOrderAuth, `${TableName.PkiAcmeOrder}.id`, `${TableName.PkiAcmeOrderAuth}.orderId`) - .join(TableName.PkiAcmeAuth, `${TableName.PkiAcmeOrderAuth}.authId`, `${TableName.PkiAcmeAuth}.id`) + const involvedOrderIds = (tx || db)({ o: TableName.PkiAcmeOrder }) + .distinct("o.id") + .join({ oa: TableName.PkiAcmeOrderAuth }, "o.id", "oa.orderId") + .join({ a: TableName.PkiAcmeAuth }, "oa.authId", `a.id`) .whereIn( - `${TableName.PkiAcmeAuth}.id`, + "a.id", updatedAuths.map((auth) => auth.id) - ) - .as("involvedOrderIds"); + ); // Update status for pending orders that have all auths valid await (tx || db)(TableName.PkiAcmeOrder) .whereIn("id", (qb) => { - qb.select("id") - .from(TableName.PkiAcmeOrder) - .join(TableName.PkiAcmeOrderAuth, `${TableName.PkiAcmeOrder}.id`, `${TableName.PkiAcmeOrderAuth}.orderId`) - .join(TableName.PkiAcmeAuth, `${TableName.PkiAcmeOrderAuth}.authId`, `${TableName.PkiAcmeAuth}.id`) - .groupBy(`${TableName.PkiAcmeOrder}.id`) + qb.select("o2.id") + .from({ o2: TableName.PkiAcmeOrder }) + .join({ oa2: TableName.PkiAcmeOrderAuth }, "o2.id", "oa2.orderId") + .join({ a2: TableName.PkiAcmeAuth }, "oa2.authId", "a2.id") + .groupBy("o2.id") // All auths should be valid for the order to be ready - .havingRaw( - "SUM(CASE WHEN :authTable:.status = :authStatus THEN 1 ELSE 0 END) = COUNT(DISTINCT :authTable:.id)", - { - authTable: TableName.PkiAcmeAuth, - authStatus: AcmeAuthStatus.Valid - } - ) + .havingRaw("SUM(CASE WHEN a2.status = ? THEN 1 ELSE 0 END) = COUNT(DISTINCT a2.id)", [ + AcmeAuthStatus.Valid + ]) // Only update orders that are pending - .where(`${TableName.PkiAcmeOrder}.status`, AcmeOrderStatus.Pending) - .whereIn(`${TableName.PkiAcmeOrder}.id`, involvedOrderIds); + .where("o2.status", AcmeOrderStatus.Pending) + .whereIn("o2.id", involvedOrderIds); }) .update({ status: AcmeOrderStatus.Ready }); } From f02adcf70cf36b2401301179436fc667e48bbc44 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 31 Oct 2025 15:38:38 -0700 Subject: [PATCH 119/231] Fix invalid update --- .../src/ee/services/pki-acme/pki-acme-challenge-dal.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts b/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts index 05925154e..665eb1f9e 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts @@ -75,12 +75,12 @@ export const pkiAcmeChallengeDALFactory = (db: TDbClient) => { // Update status for pending orders that have all auths valid await (tx || db)(TableName.PkiAcmeOrder) .whereIn("id", (qb) => { - qb.select("id") - .from(TableName.PkiAcmeOrder) - .join(TableName.PkiAcmeOrderAuth, `${TableName.PkiAcmeOrder}.id`, `${TableName.PkiAcmeOrderAuth}.orderId`) + qb.select("o.id") + .from({ o: TableName.PkiAcmeOrder }) + .join(TableName.PkiAcmeOrderAuth, "o.id", `${TableName.PkiAcmeOrderAuth}.orderId`) .join(TableName.PkiAcmeAuth, `${TableName.PkiAcmeOrderAuth}.authId`, `${TableName.PkiAcmeAuth}.id`) // We only update orders that are pending - .where(`${TableName.PkiAcmeOrder}.status`, AcmeOrderStatus.Pending) + .where("o.status", AcmeOrderStatus.Pending) .whereIn( `${TableName.PkiAcmeAuth}.id`, updatedAuths.map((auth) => auth.id) From 9314348b316760f4389d0e2587e373dfd489966b Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 31 Oct 2025 16:32:15 -0700 Subject: [PATCH 120/231] More error handling --- .../pki-acme/pki-acme-challenge-service.ts | 28 +++- .../ee/services/pki-acme/pki-acme-errors.ts | 141 ++++++++++++------ .../ee/services/pki-acme/pki-acme-schemas.ts | 2 +- 3 files changed, 119 insertions(+), 52 deletions(-) diff --git a/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts index af0ea0ebe..e6464d69c 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts @@ -5,7 +5,7 @@ import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { TPkiAcmeAuthDALFactory } from "./pki-acme-auth-dal"; import { TPkiAcmeChallengeDALFactory } from "./pki-acme-challenge-dal"; -import { AcmeIncorrectResponseError } from "./pki-acme-errors"; +import { AcmeConnectionError, AcmeDnsFailureError, AcmeIncorrectResponseError } from "./pki-acme-errors"; import { AcmeAuthStatus, AcmeChallengeStatus, AcmeChallengeType } from "./pki-acme-schemas"; import { TPkiAcmeChallengeServiceFactory } from "./pki-acme-types"; import { TPkiAcmeChallenges } from "@app/db/schemas"; @@ -25,7 +25,7 @@ export const pkiAcmeChallengeServiceFactory = ({ const appCfg = getConfig(); const validateChallengeResponse = async (challengeId: string): Promise => { - return await acmeChallengeDAL.transaction(async (tx) => { + const error = await acmeChallengeDAL.transaction(async (tx) => { logger.info({ challengeId }, "Validating ACME challenge response"); const challenge = await acmeChallengeDAL.findByIdForChallengeValidation(challengeId, tx); if (!challenge) { @@ -54,6 +54,7 @@ export const pkiAcmeChallengeServiceFactory = ({ ? `${baseUrl}:${appCfg.ACME_DEVELOPMENT_HTTP01_CHALLENGE_PORT}` : baseUrl; const challengeUrl = new URL(`/.well-known/acme-challenge/${challenge.auth.token}`, actualBaseUrl); + logger.info({ challengeUrl }, "Performing ACME HTTP-01 challenge validation"); try { // Notice: well, we are in a transaction, ideally we should not hold transaction and perform // a long running operation for long time. But assuming we are not performing a tons of @@ -66,17 +67,34 @@ export const pkiAcmeChallengeServiceFactory = ({ const challengeResponseBody = await challengeResponse.text(); const thumbprint = Buffer.from(challenge.auth.account.publicKeyThumbprint, "utf-8").toString("base64url"); const expectedChallengeResponseBody = `${challenge.auth.token}.${thumbprint}`; - if (challengeResponseBody !== expectedChallengeResponseBody) { + if (challengeResponseBody.trimEnd() !== expectedChallengeResponseBody) { throw new AcmeIncorrectResponseError({ message: "ACME challenge response is not correct" }); } await acmeChallengeDAL.markAsValidCascadeById(challengeId, tx); } catch (error) { - logger.error(error, "Error validating ACME challenge response"); // TODO: we should retry the challenge validation a few times, but let's keep it simple for now await acmeChallengeDAL.markAsValidCascadeById(challengeId, tx); - throw error; + // Properly type and inspect the error + if (error instanceof TypeError && error.message.includes("fetch failed")) { + const cause = error.cause as AggregateError; + if (cause?.errors?.[0]?.code === "ECONNREFUSED") { + logger.error(error, "Connection refused."); + return new AcmeConnectionError({ message: "Connection refused." }); + } else if (cause?.errors?.[0]?.code === "ENOTFOUND") { + logger.error(error, "Hostname could not be resolved (DNS failure)."); + return new AcmeDnsFailureError({ message: "Hostname could not be resolved (DNS failure)." }); + } + } else if (error instanceof Error) { + logger.error(error, "Error validating ACME challenge response"); + } else { + logger.error(error, "Unknown error validating ACME challenge response"); + } + return error; } }); + if (error) { + throw error; + } }; return { validateChallengeResponse }; diff --git a/backend/src/ee/services/pki-acme/pki-acme-errors.ts b/backend/src/ee/services/pki-acme/pki-acme-errors.ts index b09499346..8b5a19149 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-errors.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-errors.ts @@ -3,15 +3,43 @@ * https://datatracker.ietf.org/doc/html/rfc8555#section-6.2 */ +// RFC 8555 Section 6.7 - Error Types +export enum AcmeErrorType { + AccountDoesNotExist = "accountDoesNotExist", + AlreadyRevoked = "alreadyRevoked", + BadCsr = "badCSR", + BadNonce = "badNonce", + BadPublicKey = "badPublicKey", + BadRevocationReason = "badRevocationReason", + BadSignatureAlgorithm = "badSignatureAlgorithm", + CAA = "CAA", + Compound = "compound", + Connection = "connection", + DNS = "DNS", + ExternalAccountRequired = "externalAccountRequired", + IncorrectResponse = "incorrectResponse", + IncorrectContact = "incorrectContact", + Malformed = "malformed", + OrderNotReady = "orderNotReady", + RateLimited = "rateLimited", + RejectedIdentifier = "rejectedIdentifier", + ServerInternal = "serverInternal", + TLS = "tls", + Unauthorized = "unauthorized", + UnsupportedContact = "unsupportedContact", + UnsupportedIdentifier = "unsupportedIdentifier", + UserActionRequired = "userActionRequired" +} + export interface IAcmeError { - type: string; + type: AcmeErrorType; detail: string; status: number; subproblems?: Array<{ type: string; detail: string; identifier?: { type: string; value: string } }>; } export class AcmeError extends Error implements IAcmeError { - type: string; + type: AcmeErrorType; detail: string; @@ -29,7 +57,7 @@ export class AcmeError extends Error implements IAcmeError { error, message }: { - type: string; + type: AcmeErrorType; detail: string; status: number; subproblems?: Array<{ type: string; detail: string; identifier?: { type: string; value: string } }>; @@ -69,7 +97,7 @@ export class AcmeMalformedError extends AcmeError { message?: string; } = {}) { super({ - type: "malformed", + type: AcmeErrorType.Malformed, detail, status: 400, error, @@ -93,7 +121,7 @@ export class AcmeUnauthorizedError extends AcmeError { message?: string; } = {}) { super({ - type: "unauthorized", + type: AcmeErrorType.Unauthorized, detail, status: 403, error, @@ -118,7 +146,7 @@ export class AcmeAccountDoesNotExistError extends AcmeError { message?: string; } = {}) { super({ - type: "accountDoesNotExist", + type: AcmeErrorType.AccountDoesNotExist, detail, status: 400, error, @@ -142,7 +170,7 @@ export class AcmeBadNonceError extends AcmeError { message?: string; } = {}) { super({ - type: "badNonce", + type: AcmeErrorType.BadNonce, detail, status: 400, error, @@ -153,11 +181,11 @@ export class AcmeBadNonceError extends AcmeError { } /** - * badSignature - The JWS signature is invalid (RFC 8555 Section 6.7.5) + * badSignatureAlgorithm - The signature algorithm is invalid (RFC 8555 Section 6.7.5) */ -export class AcmeBadSignatureError extends AcmeError { +export class AcmeBadSignatureAlgorithmError extends AcmeError { constructor({ - detail = "The JWS signature is invalid", + detail = "The signature algorithm is invalid", error, message }: { @@ -166,13 +194,13 @@ export class AcmeBadSignatureError extends AcmeError { message?: string; } = {}) { super({ - type: "badSignature", + type: AcmeErrorType.BadSignatureAlgorithm, detail, status: 401, error, message }); - this.name = "AcmeBadSignatureError"; + this.name = "AcmeBadSignatureAlgorithmError"; } } @@ -190,7 +218,7 @@ export class AcmeBadPublicKeyError extends AcmeError { message?: string; } = {}) { super({ - type: "badPublicKey", + type: AcmeErrorType.BadPublicKey, detail, status: 400, error, @@ -214,7 +242,7 @@ export class AcmeBadCsrError extends AcmeError { message?: string; } = {}) { super({ - type: "badCSR", + type: AcmeErrorType.BadCsr, detail, status: 400, error, @@ -239,7 +267,7 @@ export class AcmeBadRevocationReasonError extends AcmeError { message?: string; } = {}) { super({ - type: "badRevocationReason", + type: AcmeErrorType.BadRevocationReason, detail, status: 400, error, @@ -263,7 +291,7 @@ export class AcmeRateLimitedError extends AcmeError { message?: string; } = {}) { super({ - type: "rateLimited", + type: AcmeErrorType.RateLimited, detail, status: 429, error, @@ -290,7 +318,7 @@ export class AcmeRejectedIdentifierError extends AcmeError { message?: string; } = {}) { super({ - type: "rejectedIdentifier", + type: AcmeErrorType.RejectedIdentifier, detail, status: 400, subproblems, @@ -315,7 +343,7 @@ export class AcmeServerInternalError extends AcmeError { message?: string; } = {}) { super({ - type: "serverInternal", + type: AcmeErrorType.ServerInternal, detail, status: 500, error, @@ -325,30 +353,6 @@ export class AcmeServerInternalError extends AcmeError { } } -/** - * 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) */ @@ -363,7 +367,7 @@ export class AcmeUnsupportedContactError extends AcmeError { message?: string; } = {}) { super({ - type: "unsupportedContact", + type: AcmeErrorType.UnsupportedContact, detail, status: 400, error, @@ -388,7 +392,7 @@ export class AcmeUnsupportedIdentifierError extends AcmeError { message?: string; } = {}) { super({ - type: "unsupportedIdentifier", + type: AcmeErrorType.UnsupportedIdentifier, detail, status: 400, error, @@ -417,7 +421,7 @@ export class AcmeUserActionRequiredError extends AcmeError { message?: string; } = {}) { super({ - type: "userActionRequired", + type: AcmeErrorType.UserActionRequired, detail, status: 403, error, @@ -449,7 +453,7 @@ export class AcmeIncorrectResponseError extends AcmeError { message?: string; } = {}) { super({ - type: "incorrectResponse", + type: AcmeErrorType.IncorrectResponse, detail, status: 400, error, @@ -458,3 +462,48 @@ export class AcmeIncorrectResponseError extends AcmeError { this.name = "AcmeIncorrectResponseError"; } } + +/** + * connectionError - A connection error occurred (RFC 8555 Section 6.7.17) + */ +export class AcmeConnectionError extends AcmeError { + constructor({ + detail = "A connection error occurred", + error, + message + }: { + detail?: string; + error?: unknown; + message?: string; + } = {}) { + super({ + type: AcmeErrorType.Connection, + detail, + status: 400, + error, + message + }); + this.name = "AcmeConnectionError"; + } +} + +export class AcmeDnsFailureError extends AcmeError { + constructor({ + detail = "Hostname could not be resolved (DNS failure)", + error, + message + }: { + detail?: string; + error?: unknown; + message?: string; + } = {}) { + super({ + type: AcmeErrorType.DNS, + detail, + status: 400, + error, + message + }); + this.name = "AcmeDnsFailureError"; + } +} diff --git a/backend/src/ee/services/pki-acme/pki-acme-schemas.ts b/backend/src/ee/services/pki-acme/pki-acme-schemas.ts index 0c9015700..7298dd944 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-schemas.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-schemas.ts @@ -158,7 +158,7 @@ export const GetAcmeAuthorizationResponseSchema = z.object({ ) }); -export const RespondToAcmeChallengeBodySchema = z.object({}); +export const RespondToAcmeChallengeBodySchema = z.object({}).strict(); export const RespondToAcmeChallengeResponseSchema = z.object({ type: z.enum(Object.values(AcmeChallengeType) as [string, ...string[]]), From 8aa7594f0351fca819e2ae468dd248873f47e922 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 31 Oct 2025 16:36:12 -0700 Subject: [PATCH 121/231] Fix types --- backend/src/ee/routes/v1/pki-acme-router.ts | 2 +- backend/src/ee/services/pki-acme/pki-acme-service.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/src/ee/routes/v1/pki-acme-router.ts b/backend/src/ee/routes/v1/pki-acme-router.ts index 6344809d0..329a381e3 100644 --- a/backend/src/ee/routes/v1/pki-acme-router.ts +++ b/backend/src/ee/routes/v1/pki-acme-router.ts @@ -421,7 +421,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { } }); - // POST /api/v1/pki/acme/profiles//authorizations//challenges/http-01 + // POST /api/v1/pki/acme/profiles//authorizations//challenges/ // Respond to Challenge (RFC 8555 Section 7.5.1) server.route({ method: "POST", 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 cca7c0957..d6cf92a48 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -388,7 +388,7 @@ export const pkiAcmeServiceFactory = ({ // if we do, return the existing order const order = await acmeOrderDAL.transaction(async (tx) => { - const account = await acmeAccountDAL.findByProjectIdAndAccountId(profileId, accountId)!; + const account = (await acmeAccountDAL.findByProjectIdAndAccountId(profileId, accountId))!; const createdOrder = await acmeOrderDAL.create( { accountId: account.id, @@ -585,7 +585,7 @@ export const pkiAcmeServiceFactory = ({ type: challenge.type, url: buildUrl(profileId, `/authorizations/${authzId}/challenges/${challenge.id}`), status: challenge.status, - token: auth.token + token: auth.token! }; }) }, From 453dc578299b89c01c88711b427f5b6658ae4eaa Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 31 Oct 2025 16:47:20 -0700 Subject: [PATCH 122/231] Fix status handling --- backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts | 2 +- backend/src/server/plugins/error-handler.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts b/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts index 665eb1f9e..424651aea 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts @@ -62,7 +62,7 @@ export const pkiAcmeChallengeDALFactory = (db: TDbClient) => { try { const [challenge] = (await (tx || db)(TableName.PkiAcmeChallenge) .where({ id }) - .update({ status: AcmeChallengeStatus.Valid, validatedAt: new Date() }) + .update({ status: AcmeChallengeStatus.Invalid }) .returning("*")) as [TPkiAcmeChallenges]; // Update pending auth to valid as well diff --git a/backend/src/server/plugins/error-handler.ts b/backend/src/server/plugins/error-handler.ts index 8f6b63141..1142694b5 100644 --- a/backend/src/server/plugins/error-handler.ts +++ b/backend/src/server/plugins/error-handler.ts @@ -251,7 +251,8 @@ export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider reqId: req.id, status: error.status, type: `urn:ietf:params:acme:error:${error.type}`, - detail: error.detail + detail: error.detail, + message: error.message // TODO: add subproblems if they exist }); } else { From c7cf1cac9f5bd71955a6c33bbf412297d81d376d Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 31 Oct 2025 16:52:26 -0700 Subject: [PATCH 123/231] Try to fix error handling --- backend/src/server/plugins/error-handler.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/src/server/plugins/error-handler.ts b/backend/src/server/plugins/error-handler.ts index 1142694b5..ecaa60c99 100644 --- a/backend/src/server/plugins/error-handler.ts +++ b/backend/src/server/plugins/error-handler.ts @@ -249,7 +249,8 @@ export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider .status(error.status) .send({ reqId: req.id, - status: error.status, + error: error.name, + statusCode: error.status, type: `urn:ietf:params:acme:error:${error.type}`, detail: error.detail, message: error.message From 4d9b74c85bf0c7b2db164d49f05cca5848deda20 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 31 Oct 2025 16:59:41 -0700 Subject: [PATCH 124/231] Fix error schema validation issue --- .../src/server/plugins/add-errors-to-response-schemas.ts | 9 ++++++++- backend/src/server/plugins/error-handler.ts | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/backend/src/server/plugins/add-errors-to-response-schemas.ts b/backend/src/server/plugins/add-errors-to-response-schemas.ts index 8eb358a1b..6337bae0f 100644 --- a/backend/src/server/plugins/add-errors-to-response-schemas.ts +++ b/backend/src/server/plugins/add-errors-to-response-schemas.ts @@ -6,9 +6,16 @@ import { DefaultResponseErrorsSchema } from "../routes/sanitizedSchemas"; const isScimRoutes = (pathname: string) => pathname.startsWith("/api/v1/scim/Users") || pathname.startsWith("/api/v1/scim/Groups"); +const isAcmeRoutes = (pathname: string) => pathname.startsWith("/api/v1/pki/acme/"); + export const addErrorsToResponseSchemas = fp(async (server) => { server.addHook("onRoute", (routeOptions) => { - if (routeOptions.schema && routeOptions.schema.response && !isScimRoutes(routeOptions.path)) { + if ( + routeOptions.schema && + routeOptions.schema.response && + !isScimRoutes(routeOptions.path) && + !isAcmeRoutes(routeOptions.path) + ) { routeOptions.schema.response = { ...DefaultResponseErrorsSchema, ...routeOptions.schema.response diff --git a/backend/src/server/plugins/error-handler.ts b/backend/src/server/plugins/error-handler.ts index ecaa60c99..4b29f6930 100644 --- a/backend/src/server/plugins/error-handler.ts +++ b/backend/src/server/plugins/error-handler.ts @@ -250,7 +250,7 @@ export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider .send({ reqId: req.id, error: error.name, - statusCode: error.status, + status: error.status, type: `urn:ietf:params:acme:error:${error.type}`, detail: error.detail, message: error.message From d80a5414a5712f477d5777496fec8fd0e68dfed0 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 31 Oct 2025 17:09:01 -0700 Subject: [PATCH 125/231] Override host for acme challenge --- .../pki-acme/pki-acme-challenge-service.ts | 14 +++++++++----- backend/src/lib/config/env.ts | 11 ++++++++++- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts index e6464d69c..26dc65aaf 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts @@ -49,11 +49,15 @@ export const pkiAcmeChallengeServiceFactory = ({ 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.auth.token}`, actualBaseUrl); + let host = challenge.auth.identifierValue; + if (appCfg.isAcmeDevelopmentMode && appCfg.ACME_DEVELOPMENT_HTTP01_CHALLENGE_HOST_OVERRIDES[host]) { + host = appCfg.ACME_DEVELOPMENT_HTTP01_CHALLENGE_HOST_OVERRIDES[host]; + logger.warn( + { srcHost: challenge.auth.identifierValue, dstHost: host }, + "Using ACME development HTTP-01 challenge host override" + ); + } + const challengeUrl = new URL(`/.well-known/acme-challenge/${challenge.auth.token}`, `http://${host}`); logger.info({ challengeUrl }, "Performing ACME HTTP-01 challenge validation"); try { // Notice: well, we are in a transaction, ideally we should not hold transaction and perform diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 7477bc032..6f0502184 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -107,7 +107,16 @@ const envSchema = z 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), + ACME_DEVELOPMENT_HTTP01_CHALLENGE_HOST_OVERRIDES: zpStr( + z + .string() + .optional() + .transform((val) => { + if (!val) return {}; + return JSON.parse(val) as Record; + }) + .default("{}") + ), // smtp options SMTP_HOST: zpStr(z.string().optional()), SMTP_IGNORE_TLS: zodStrBool.default("false"), From b410d886baa58a422686ce9d924b1ed2e2c68639 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 31 Oct 2025 17:49:32 -0700 Subject: [PATCH 126/231] B64url --- backend/bdd/features/steps/pki_acme.py | 7 +++---- backend/src/ee/services/pki-acme/pki-acme-service.ts | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/backend/bdd/features/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py index c5627bb8b..02074f92c 100644 --- a/backend/bdd/features/steps/pki_acme.py +++ b/backend/bdd/features/steps/pki_acme.py @@ -350,15 +350,14 @@ def step_impl(context: Context, var_path: str, hostname: str): if hostname != "localhost": raise ValueError("Currently only localhost is supported") challenge = eval_var(context, var_path, as_json=False) - acme_challenge = challenge.chall - response, validation = acme_challenge.response_and_validation( + response, validation = challenge.response_and_validation( context.acme_client.net.key ) resource = standalone.HTTP01RequestHandler.HTTP01Resource( - chall=acme_challenge, response=response, validation=validation + chall=challenge, response=response, validation=validation ) # TODO: make port configurable - servers = standalone.HTTP01DualNetworkedServers(("", 8087), resource) + servers = standalone.HTTP01DualNetworkedServers(("0.0.0.0", 8087), resource) # Start client standalone web server. web_server = threading.Thread(name="web_server", target=servers.serve_forever) web_server.daemon = True 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 d6cf92a48..f1d60b241 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -413,7 +413,7 @@ export const pkiAcmeServiceFactory = ({ // RFC 8555 suggests a token with at least 128 bits of entropy // We are using 256 bits of entropy here, should be enough for now // ref: https://datatracker.ietf.org/doc/html/rfc8555#section-11.3 - token: crypto.randomBytes(32).toString("base64"), + token: crypto.randomBytes(32).toString("base64url"), // TODO: read config from the profile to get the expiration time instead expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000) }, From 7a6ba788f9897389f93ab34f2216c693abb2f432 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 31 Oct 2025 17:56:00 -0700 Subject: [PATCH 127/231] Fix bdd's http-01 server --- backend/bdd/features/steps/pki_acme.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/bdd/features/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py index 02074f92c..39d8db3e8 100644 --- a/backend/bdd/features/steps/pki_acme.py +++ b/backend/bdd/features/steps/pki_acme.py @@ -354,10 +354,10 @@ def step_impl(context: Context, var_path: str, hostname: str): context.acme_client.net.key ) resource = standalone.HTTP01RequestHandler.HTTP01Resource( - chall=challenge, response=response, validation=validation + chall=challenge.chall, response=response, validation=validation ) # TODO: make port configurable - servers = standalone.HTTP01DualNetworkedServers(("0.0.0.0", 8087), resource) + servers = standalone.HTTP01DualNetworkedServers(("0.0.0.0", 8087), {resource}) # Start client standalone web server. web_server = threading.Thread(name="web_server", target=servers.serve_forever) web_server.daemon = True From e33a314600d5cff4e0c22e4ea9b3fb3f231cdda8 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 31 Oct 2025 18:01:42 -0700 Subject: [PATCH 128/231] Well... mark as invalid --- backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts index 26dc65aaf..d2c4e52b5 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts @@ -77,7 +77,7 @@ export const pkiAcmeChallengeServiceFactory = ({ await acmeChallengeDAL.markAsValidCascadeById(challengeId, tx); } catch (error) { // TODO: we should retry the challenge validation a few times, but let's keep it simple for now - await acmeChallengeDAL.markAsValidCascadeById(challengeId, tx); + await acmeChallengeDAL.markAsInvalidCascadeById(challengeId, tx); // Properly type and inspect the error if (error instanceof TypeError && error.message.includes("fetch failed")) { const cause = error.cause as AggregateError; From 7edde542811ed6179e0c2591d173a6e0e12be62e Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 31 Oct 2025 18:01:53 -0700 Subject: [PATCH 129/231] And import --- .../src/ee/services/pki-acme/pki-acme-challenge-service.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts index d2c4e52b5..2ece06427 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts @@ -1,5 +1,3 @@ -import { Knex } from "knex"; - import { getConfig } from "@app/lib/config/env"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; @@ -8,13 +6,12 @@ import { TPkiAcmeChallengeDALFactory } from "./pki-acme-challenge-dal"; import { AcmeConnectionError, AcmeDnsFailureError, AcmeIncorrectResponseError } from "./pki-acme-errors"; import { AcmeAuthStatus, AcmeChallengeStatus, AcmeChallengeType } from "./pki-acme-schemas"; import { TPkiAcmeChallengeServiceFactory } from "./pki-acme-types"; -import { TPkiAcmeChallenges } from "@app/db/schemas"; type TPkiAcmeChallengeServiceFactoryDep = { acmeAuthDAL: Pick; acmeChallengeDAL: Pick< TPkiAcmeChallengeDALFactory, - "transaction" | "findByIdForChallengeValidation" | "markAsValidCascadeById" + "transaction" | "findByIdForChallengeValidation" | "markAsValidCascadeById" | "markAsInvalidCascadeById" >; }; From 181faff20b79e1b40879c3302f647e507fc7d804 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 31 Oct 2025 18:17:18 -0700 Subject: [PATCH 130/231] Better error handling --- .../pki-acme/pki-acme-challenge-service.ts | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts index 2ece06427..a7703f130 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts @@ -77,13 +77,21 @@ export const pkiAcmeChallengeServiceFactory = ({ await acmeChallengeDAL.markAsInvalidCascadeById(challengeId, tx); // Properly type and inspect the error if (error instanceof TypeError && error.message.includes("fetch failed")) { - const cause = error.cause as AggregateError; - if (cause?.errors?.[0]?.code === "ECONNREFUSED") { - logger.error(error, "Connection refused."); - return new AcmeConnectionError({ message: "Connection refused." }); - } else if (cause?.errors?.[0]?.code === "ENOTFOUND") { - logger.error(error, "Hostname could not be resolved (DNS failure)."); - return new AcmeDnsFailureError({ message: "Hostname could not be resolved (DNS failure)." }); + const cause = error.cause; + if (cause instanceof Error) { + if (cause.message.includes("ECONNREFUSED")) { + return new AcmeConnectionError({ message: "Connection refused" }); + } else if (cause.message.includes("ENOTFOUND")) { + return new AcmeDnsFailureError({ message: "Hostname could not be resolved (DNS failure)" }); + } + } else if (cause instanceof AggregateError) { + // TODO: handle multiple errors + const firstError = cause.errors?.[0]; + if (firstError?.code === "ECONNREFUSED") { + return new AcmeConnectionError({ message: "Connection refused" }); + } else if (firstError?.code === "ENOTFOUND") { + return new AcmeDnsFailureError({ message: "Hostname could not be resolved (DNS failure)" }); + } } } else if (error instanceof Error) { logger.error(error, "Error validating ACME challenge response"); From c2a33992915fa7715ba829166aa98e33b7fe5a6a Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 31 Oct 2025 18:19:28 -0700 Subject: [PATCH 131/231] improve err handing --- .../pki-acme/pki-acme-challenge-service.ts | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts index a7703f130..f9be0a60a 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts @@ -78,18 +78,12 @@ export const pkiAcmeChallengeServiceFactory = ({ // Properly type and inspect the error if (error instanceof TypeError && error.message.includes("fetch failed")) { const cause = error.cause; - if (cause instanceof Error) { - if (cause.message.includes("ECONNREFUSED")) { + const errors = cause instanceof AggregateError ? cause.errors : cause instanceof Error ? [cause] : []; + for (const err of errors) { + // TODO: handle multiple errors, return a compound error instead of just the first error + if (err?.code === "ECONNREFUSED" || err?.message?.includes("ECONNREFUSED")) { return new AcmeConnectionError({ message: "Connection refused" }); - } else if (cause.message.includes("ENOTFOUND")) { - return new AcmeDnsFailureError({ message: "Hostname could not be resolved (DNS failure)" }); - } - } else if (cause instanceof AggregateError) { - // TODO: handle multiple errors - const firstError = cause.errors?.[0]; - if (firstError?.code === "ECONNREFUSED") { - return new AcmeConnectionError({ message: "Connection refused" }); - } else if (firstError?.code === "ENOTFOUND") { + } else if (err?.code === "ENOTFOUND" || err?.message?.includes("ENOTFOUND")) { return new AcmeDnsFailureError({ message: "Hostname could not be resolved (DNS failure)" }); } } From 4b10b4ec336c8ce19790bd3161002ffdf7944681 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 31 Oct 2025 19:30:25 -0700 Subject: [PATCH 132/231] Fix challenge match --- backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts index f9be0a60a..fa34000cc 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts @@ -66,7 +66,7 @@ export const pkiAcmeChallengeServiceFactory = ({ throw new BadRequestError({ message: "ACME challenge response is not 200" }); } const challengeResponseBody = await challengeResponse.text(); - const thumbprint = Buffer.from(challenge.auth.account.publicKeyThumbprint, "utf-8").toString("base64url"); + const thumbprint = challenge.auth.account.publicKeyThumbprint; const expectedChallengeResponseBody = `${challenge.auth.token}.${thumbprint}`; if (challengeResponseBody.trimEnd() !== expectedChallengeResponseBody) { throw new AcmeIncorrectResponseError({ message: "ACME challenge response is not correct" }); From f3852638e57f4ee5ec8f210450942d2474531e9b Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 31 Oct 2025 19:33:49 -0700 Subject: [PATCH 133/231] Poll and finalize --- backend/bdd/features/steps/pki_acme.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/backend/bdd/features/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py index 39d8db3e8..3156d4a71 100644 --- a/backend/bdd/features/steps/pki_acme.py +++ b/backend/bdd/features/steps/pki_acme.py @@ -371,3 +371,10 @@ def step_impl(context: Context, var_path: str): acme_client = context.acme_client response, validation = challenge.response_and_validation(acme_client.net.key) acme_client.answer_challenge(challenge, response) + + +@then("I poll and finalize the ACME order {var_path}") +def step_impl(context: Context, var_path: str): + order = eval_var(context, var_path, as_json=False) + acme_client = context.acme_client + acme_client.poll_and_finalize(order) From b4bd05cbd0d43746f53609f15b6203f725e85bd8 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 31 Oct 2025 20:16:26 -0700 Subject: [PATCH 134/231] issuing cert --- .../bdd/features/pki/acme/challenge.feature | 1 + .../migrations/20251029234547_add-pki-acme.ts | 9 ++++ .../pki-acme/pki-acme-challenge-service.ts | 3 -- .../ee/services/pki-acme/pki-acme-errors.ts | 21 +++++++++ .../services/pki-acme/pki-acme-order-dal.ts | 10 +++++ .../ee/services/pki-acme/pki-acme-service.ts | 44 +++++++++++++++++-- 6 files changed, 82 insertions(+), 6 deletions(-) diff --git a/backend/bdd/features/pki/acme/challenge.feature b/backend/bdd/features/pki/acme/challenge.feature index 8e7dad523..960842ebf 100644 --- a/backend/bdd/features/pki/acme/challenge.feature +++ b/backend/bdd/features/pki/acme/challenge.feature @@ -19,3 +19,4 @@ Feature: Challenge Then I select challenge with type http-01 for domain localhost from order at order as challenge Then I serve challenge response for challenge at localhost Then I tell ACME server that challenge is ready to be verified + Then I poll and finalize the ACME order order diff --git a/backend/src/db/migrations/20251029234547_add-pki-acme.ts b/backend/src/db/migrations/20251029234547_add-pki-acme.ts index c91a179a8..ee5ba9f36 100644 --- a/backend/src/db/migrations/20251029234547_add-pki-acme.ts +++ b/backend/src/db/migrations/20251029234547_add-pki-acme.ts @@ -85,6 +85,12 @@ export async function up(knex: Knex): Promise { t.timestamp("expiresAt").notNullable(); + t.string("csr").nullable(); + t.string("certificate").nullable(); + t.string("certificateChain").nullable(); + + t.string("error").nullable(); + // Order status t.string("status").notNullable(); // pending, ready, processing, valid, invalid @@ -160,6 +166,9 @@ export async function up(knex: Knex): Promise { // Challenge status t.string("status").notNullable(); // pending, processing, valid, invalid + // Error message when the challenge fails + t.string("error").nullable(); + // Validation timestamp t.timestamp("validatedAt").nullable(); diff --git a/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts index fa34000cc..3b858e427 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts @@ -1,14 +1,12 @@ import { getConfig } from "@app/lib/config/env"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; -import { TPkiAcmeAuthDALFactory } from "./pki-acme-auth-dal"; import { TPkiAcmeChallengeDALFactory } from "./pki-acme-challenge-dal"; import { AcmeConnectionError, AcmeDnsFailureError, AcmeIncorrectResponseError } from "./pki-acme-errors"; import { AcmeAuthStatus, AcmeChallengeStatus, AcmeChallengeType } from "./pki-acme-schemas"; import { TPkiAcmeChallengeServiceFactory } from "./pki-acme-types"; type TPkiAcmeChallengeServiceFactoryDep = { - acmeAuthDAL: Pick; acmeChallengeDAL: Pick< TPkiAcmeChallengeDALFactory, "transaction" | "findByIdForChallengeValidation" | "markAsValidCascadeById" | "markAsInvalidCascadeById" @@ -16,7 +14,6 @@ type TPkiAcmeChallengeServiceFactoryDep = { }; export const pkiAcmeChallengeServiceFactory = ({ - acmeAuthDAL, acmeChallengeDAL }: TPkiAcmeChallengeServiceFactoryDep): TPkiAcmeChallengeServiceFactory => { const appCfg = getConfig(); diff --git a/backend/src/ee/services/pki-acme/pki-acme-errors.ts b/backend/src/ee/services/pki-acme/pki-acme-errors.ts index 8b5a19149..924a7b0ca 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-errors.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-errors.ts @@ -507,3 +507,24 @@ export class AcmeDnsFailureError extends AcmeError { this.name = "AcmeDnsFailureError"; } } + +export class AcmeOrderNotReadyError extends AcmeError { + constructor({ + detail = "The order is not ready", + error, + message + }: { + detail?: string; + error?: unknown; + message?: string; + } = {}) { + super({ + type: AcmeErrorType.OrderNotReady, + detail, + status: 403, + error, + message + }); + this.name = "AcmeOrderNotReadyError"; + } +} 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 index 47d72a261..8600079df 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-order-dal.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-order-dal.ts @@ -10,6 +10,15 @@ export type TPkiAcmeOrderDALFactory = ReturnType; export const pkiAcmeOrderDALFactory = (db: TDbClient) => { const pkiAcmeOrderOrm = ormify(db, TableName.PkiAcmeOrder); + const findByIdForFinalization = async (id: string, tx?: Knex) => { + try { + const order = await (tx || db)(TableName.PkiAcmeOrder).forUpdate().where({ id }).first(); + return order || null; + } catch (error) { + throw new DatabaseError({ error, name: "Find PKI ACME order by id for finalization" }); + } + }; + const findByAccountAndOrderIdWithAuthorizations = async (accountId: string, orderId: string, tx?: Knex) => { try { const rows = await (tx || db)(TableName.PkiAcmeOrder) @@ -53,6 +62,7 @@ export const pkiAcmeOrderDALFactory = (db: TDbClient) => { return { ...pkiAcmeOrderOrm, + findByIdForFinalization, findByAccountAndOrderIdWithAuthorizations }; }; 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 f1d60b241..1e4af96e5 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -6,6 +6,7 @@ import { NotFoundError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; +import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service"; import { EnrollmentType, TCertificateProfileWithConfigs @@ -27,6 +28,7 @@ import { AcmeBadPublicKeyError, AcmeError, AcmeMalformedError, + AcmeOrderNotReadyError, AcmeServerInternalError, AcmeUnauthorizedError, AcmeUnsupportedIdentifierError @@ -64,11 +66,15 @@ import { type TPkiAcmeServiceFactoryDep = { certificateProfileDAL: Pick; + internalCertificateAuthorityService: Pick; acmeAccountDAL: Pick< TPkiAcmeAccountDALFactory, "findByProjectIdAndAccountId" | "findByProfileIdAndPublicKeyThumbprintAndAlg" | "create" >; - acmeOrderDAL: Pick; + acmeOrderDAL: Pick< + TPkiAcmeOrderDALFactory, + "create" | "transaction" | "updateById" | "findByAccountAndOrderIdWithAuthorizations" | "findByIdForFinalization" + >; acmeAuthDAL: Pick; acmeOrderAuthDAL: Pick; acmeChallengeDAL: Pick< @@ -80,6 +86,7 @@ type TPkiAcmeServiceFactoryDep = { export const pkiAcmeServiceFactory = ({ certificateProfileDAL, + internalCertificateAuthorityService, acmeAccountDAL, acmeOrderDAL, acmeAuthDAL, @@ -497,8 +504,39 @@ export const pkiAcmeServiceFactory = ({ if (!order) { throw new NotFoundError({ message: "ACME order not found" }); } - const { csr } = payload; - // FIXME: Implement ACME finalize order + if (order.status === AcmeOrderStatus.Ready) { + await acmeOrderDAL.transaction(async (tx) => { + const order = (await acmeOrderDAL.findByIdForFinalization(orderId, tx))!; + const profile = (await certificateProfileDAL.findById(profileId, tx))!; + if (order.status !== AcmeOrderStatus.Ready) { + throw new AcmeOrderNotReadyError({ message: "ACME order is not ready" }); + } + if (order.expiresAt < new Date()) { + throw new AcmeOrderNotReadyError({ message: "ACME order has expired" }); + } + const { csr } = payload; + // TODO: validate the CSR and return badCSR error if it's invalid + const { certificate, certificateChain } = await internalCertificateAuthorityService.signCertFromCa({ + isInternal: true, + certificateTemplateId: profile.certificateTemplateId, + csr, + notBefore: order.notBefore?.toISOString(), + notAfter: order.notAfter?.toISOString() + }); + await acmeOrderDAL.updateById( + orderId, + { + status: AcmeOrderStatus.Valid, + csr, + certificate, + certificateChain + }, + tx + ); + }); + } else if (order.status !== AcmeOrderStatus.Valid) { + throw new AcmeOrderNotReadyError({ message: "ACME order is not ready" }); + } return { status: 200, body: buildAcmeOrderResource({ profileId, order }), From bbdb026beb328c91a7532d09986a43d2f5f5cd61 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 31 Oct 2025 20:16:36 -0700 Subject: [PATCH 135/231] Add new columns --- backend/src/db/schemas/pki-acme-orders.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/backend/src/db/schemas/pki-acme-orders.ts b/backend/src/db/schemas/pki-acme-orders.ts index 738155f46..4aa37606f 100644 --- a/backend/src/db/schemas/pki-acme-orders.ts +++ b/backend/src/db/schemas/pki-acme-orders.ts @@ -15,7 +15,10 @@ export const PkiAcmeOrdersSchema = z.object({ expiresAt: z.date(), status: z.string(), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + csr: z.string().nullable().optional(), + certificate: z.string().nullable().optional(), + certificatechain: z.string().nullable().optional() }); export type TPkiAcmeOrders = z.infer; From e652c2a5a383bf9f138ffac1521a3ef9b5ad40dd Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 31 Oct 2025 20:24:57 -0700 Subject: [PATCH 136/231] Issue cert --- backend/src/db/schemas/pki-acme-orders.ts | 2 +- backend/src/ee/services/pki-acme/pki-acme-service.ts | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/backend/src/db/schemas/pki-acme-orders.ts b/backend/src/db/schemas/pki-acme-orders.ts index 4aa37606f..61a15b156 100644 --- a/backend/src/db/schemas/pki-acme-orders.ts +++ b/backend/src/db/schemas/pki-acme-orders.ts @@ -18,7 +18,7 @@ export const PkiAcmeOrdersSchema = z.object({ updatedAt: z.date(), csr: z.string().nullable().optional(), certificate: z.string().nullable().optional(), - certificatechain: z.string().nullable().optional() + certificateChain: z.string().nullable().optional() }); export type TPkiAcmeOrders = z.infer; 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 1e4af96e5..458beefae 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -500,12 +500,12 @@ export const pkiAcmeServiceFactory = ({ orderId: string; payload: TFinalizeAcmeOrderPayload; }): Promise> => { - const order = await acmeOrderDAL.findByAccountAndOrderIdWithAuthorizations(accountId, orderId); + let order = await acmeOrderDAL.findByAccountAndOrderIdWithAuthorizations(accountId, orderId); if (!order) { throw new NotFoundError({ message: "ACME order not found" }); } if (order.status === AcmeOrderStatus.Ready) { - await acmeOrderDAL.transaction(async (tx) => { + order = await acmeOrderDAL.transaction(async (tx) => { const order = (await acmeOrderDAL.findByIdForFinalization(orderId, tx))!; const profile = (await certificateProfileDAL.findById(profileId, tx))!; if (order.status !== AcmeOrderStatus.Ready) { @@ -528,11 +528,12 @@ export const pkiAcmeServiceFactory = ({ { status: AcmeOrderStatus.Valid, csr, - certificate, - certificateChain + certificateChain, + certificate: certificate.toString("pem") }, tx ); + return await acmeOrderDAL.findByAccountAndOrderIdWithAuthorizations(accountId, orderId, tx); }); } else if (order.status !== AcmeOrderStatus.Valid) { throw new AcmeOrderNotReadyError({ message: "ACME order is not ready" }); From 47ee70c6a0c87011661097f87a7b96a12101096e Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 31 Oct 2025 21:32:27 -0700 Subject: [PATCH 137/231] Issue cert --- .../ee/services/pki-acme/pki-acme-service.ts | 33 +++++++++++++------ backend/src/server/routes/index.ts | 28 ++++++++-------- .../server/routes/v3/certificates-router.ts | 16 +++++---- backend/src/services/auth/auth-type.ts | 1 + .../certificate-profile-dal.ts | 18 ++++++++++ .../certificate-v3-service.test.ts | 3 ++ .../certificate-v3/certificate-v3-service.ts | 12 +++++-- .../certificate-v3/certificate-v3-types.ts | 4 ++- 8 files changed, 81 insertions(+), 34 deletions(-) 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 458beefae..a3c00a513 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -63,10 +63,12 @@ import { TRawJwsPayload, TRespondToAcmeChallengeResponse } from "./pki-acme-types"; +import { TCertificateV3ServiceFactory } from "@app/services/certificate-v3/certificate-v3-service"; +import { ActorType, AuthMode } from "@app/services/auth/auth-type"; type TPkiAcmeServiceFactoryDep = { - certificateProfileDAL: Pick; - internalCertificateAuthorityService: Pick; + certificateProfileDAL: Pick; + certificateV3Service: Pick; acmeAccountDAL: Pick< TPkiAcmeAccountDALFactory, "findByProjectIdAndAccountId" | "findByProfileIdAndPublicKeyThumbprintAndAlg" | "create" @@ -86,7 +88,7 @@ type TPkiAcmeServiceFactoryDep = { export const pkiAcmeServiceFactory = ({ certificateProfileDAL, - internalCertificateAuthorityService, + certificateV3Service, acmeAccountDAL, acmeOrderDAL, acmeAuthDAL, @@ -507,7 +509,8 @@ export const pkiAcmeServiceFactory = ({ if (order.status === AcmeOrderStatus.Ready) { order = await acmeOrderDAL.transaction(async (tx) => { const order = (await acmeOrderDAL.findByIdForFinalization(orderId, tx))!; - const profile = (await certificateProfileDAL.findById(profileId, tx))!; + // TODO: ideally, this should be doen with onRequest: verifyAuth([AuthMode.ACME_JWS_SIGNATURE]), instead + const { ownerOrgId: actorOrgId } = (await certificateProfileDAL.findByIdWithOwnerOrgId(profileId, tx))!; if (order.status !== AcmeOrderStatus.Ready) { throw new AcmeOrderNotReadyError({ message: "ACME order is not ready" }); } @@ -516,20 +519,30 @@ export const pkiAcmeServiceFactory = ({ } const { csr } = payload; // TODO: validate the CSR and return badCSR error if it's invalid - const { certificate, certificateChain } = await internalCertificateAuthorityService.signCertFromCa({ - isInternal: true, - certificateTemplateId: profile.certificateTemplateId, + // TODO: this should be the same transaction? + const { certificate, certificateChain, certificateId } = await certificateV3Service.signCertificateFromProfile({ + actor: ActorType.ACME_ACCOUNT, + actorId: accountId, + actorAuthMethod: null, + actorOrgId, + profileId, csr, - notBefore: order.notBefore?.toISOString(), - notAfter: order.notAfter?.toISOString() + notBefore: order.notBefore ? new Date(order.notBefore) : undefined, + notAfter: order.notAfter ? new Date(order.notAfter) : undefined, + validity: { + // TODO: read config from the profile to get the expiration time instead + ttl: (24 * 60 * 60 * 1000).toString() + }, + enrollmentType: EnrollmentType.ACME }); + // TODO: associate the certificate with the order await acmeOrderDAL.updateById( orderId, { status: AcmeOrderStatus.Valid, csr, certificateChain, - certificate: certificate.toString("pem") + certificate }, tx ); diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index a3355ada3..13a5aee2c 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -1175,20 +1175,6 @@ export const registerRoutes = async ( projectDAL }); - const acmeChallengeService = pkiAcmeChallengeServiceFactory({ - acmeAuthDAL, - acmeChallengeDAL - }); - const pkiAcmeService = pkiAcmeServiceFactory({ - certificateProfileDAL, - acmeAccountDAL, - acmeOrderDAL, - acmeAuthDAL, - acmeOrderAuthDAL, - acmeChallengeDAL, - acmeChallengeService - }); - const pkiAlertService = pkiAlertServiceFactory({ pkiAlertDAL, pkiCollectionDAL, @@ -2209,6 +2195,20 @@ export const registerRoutes = async ( estEnrollmentConfigDAL }); + const acmeChallengeService = pkiAcmeChallengeServiceFactory({ + acmeChallengeDAL + }); + const pkiAcmeService = pkiAcmeServiceFactory({ + certificateV3Service, + certificateProfileDAL, + acmeAccountDAL, + acmeOrderDAL, + acmeAuthDAL, + acmeOrderAuthDAL, + acmeChallengeDAL, + acmeChallengeService + }); + const pkiSubscriberService = pkiSubscriberServiceFactory({ pkiSubscriberDAL, certificateAuthorityDAL, diff --git a/backend/src/server/routes/v3/certificates-router.ts b/backend/src/server/routes/v3/certificates-router.ts index d2d696596..9aa4b198b 100644 --- a/backend/src/server/routes/v3/certificates-router.ts +++ b/backend/src/server/routes/v3/certificates-router.ts @@ -6,12 +6,6 @@ import { ms } from "@app/lib/ms"; import { writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; -import { - ACMESANType, - CertificateOrderStatus, - CertKeyAlgorithm, - CertSignatureAlgorithm -} from "@app/services/certificate/certificate-types"; import { validateCaDateField } from "@app/services/certificate-authority/certificate-authority-validators"; import { CertExtendedKeyUsageType, @@ -20,7 +14,14 @@ import { } from "@app/services/certificate-common/certificate-constants"; import { extractCertificateRequestFromCSR } from "@app/services/certificate-common/certificate-csr-utils"; import { mapEnumsForValidation } from "@app/services/certificate-common/certificate-utils"; +import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types"; import { validateTemplateRegexField } from "@app/services/certificate-template/certificate-template-validators"; +import { + ACMESANType, + CertificateOrderStatus, + CertKeyAlgorithm, + CertSignatureAlgorithm +} from "@app/services/certificate/certificate-types"; interface CertificateRequestForService { commonName?: string; @@ -204,7 +205,8 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => ttl: req.body.ttl }, notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined, - notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined + notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined, + enrollmentType: EnrollmentType.API }); await server.services.auditLog.createAuditLog({ diff --git a/backend/src/services/auth/auth-type.ts b/backend/src/services/auth/auth-type.ts index 497414a60..ef54ac0be 100644 --- a/backend/src/services/auth/auth-type.ts +++ b/backend/src/services/auth/auth-type.ts @@ -41,6 +41,7 @@ export enum ActorType { // would extend to AWS, Azure, ... IDENTITY = "identity", Machine = "machine", SCIM_CLIENT = "scimClient", + ACME_ACCOUNT = "acmeAccount", UNKNOWN_USER = "unknownUser" } diff --git a/backend/src/services/certificate-profile/certificate-profile-dal.ts b/backend/src/services/certificate-profile/certificate-profile-dal.ts index 7029d4b37..dcf6ca4e6 100644 --- a/backend/src/services/certificate-profile/certificate-profile-dal.ts +++ b/backend/src/services/certificate-profile/certificate-profile-dal.ts @@ -65,6 +65,23 @@ export const certificateProfileDALFactory = (db: TDbClient) => { } }; + const findByIdWithOwnerOrgId = async ( + id: string, + tx?: Knex + ): Promise<(TCertificateProfile & { ownerOrgId: string }) | undefined> => { + try { + const certificateProfile = (await (tx || db)(TableName.PkiCertificateProfile) + .join(TableName.Project, `${TableName.PkiCertificateProfile}.projectId`, `${TableName.Project}.id`) + .select(selectAllTableCols(TableName.PkiCertificateProfile)) + .select(db.ref("orgId").withSchema(TableName.Project).as("ownerOrgId")) + .where({ id }) + .first()) as (TCertificateProfile & { ownerOrgId: string }) | undefined; + return certificateProfile; + } catch (error) { + throw new DatabaseError({ error, name: "Find certificate profile by id with owner org id" }); + } + }; + const findByIdWithConfigs = async (id: string, tx?: Knex): Promise => { try { const query = (tx || db)(TableName.PkiCertificateProfile) @@ -444,6 +461,7 @@ export const certificateProfileDALFactory = (db: TDbClient) => { updateById, deleteById, findById, + findByIdWithOwnerOrgId, findByIdWithConfigs, findBySlugAndProjectId, findByProjectId, diff --git a/backend/src/services/certificate-v3/certificate-v3-service.test.ts b/backend/src/services/certificate-v3/certificate-v3-service.test.ts index d11cce056..36823671f 100644 --- a/backend/src/services/certificate-v3/certificate-v3-service.test.ts +++ b/backend/src/services/certificate-v3/certificate-v3-service.test.ts @@ -697,6 +697,7 @@ describe("CertificateV3Service", () => { profileId, csr: mockCSR, validity: mockValidity, + enrollmentType: EnrollmentType.API, ...mockActor }); @@ -731,6 +732,7 @@ describe("CertificateV3Service", () => { profileId, csr: mockCSR, validity: mockValidity, + enrollmentType: EnrollmentType.API, ...mockActor }) ).rejects.toThrow(ForbiddenRequestError); @@ -740,6 +742,7 @@ describe("CertificateV3Service", () => { profileId, csr: mockCSR, validity: mockValidity, + enrollmentType: EnrollmentType.API, ...mockActor }) ).rejects.toThrow("Profile is not configured for api enrollment"); diff --git a/backend/src/services/certificate-v3/certificate-v3-service.ts b/backend/src/services/certificate-v3/certificate-v3-service.ts index 51c79f135..5ccd02698 100644 --- a/backend/src/services/certificate-v3/certificate-v3-service.ts +++ b/backend/src/services/certificate-v3/certificate-v3-service.ts @@ -107,6 +107,13 @@ const validateProfileAndPermissions = async ( }); } + // XXX: NOT SURE IF THIS IS SECURE TO BY PASS THE PERMISSION CHECK FOR ACME ACCOUNTS + // may need to consider this carefully + // TODO: check actor/profile ownership as well + if (actor === ActorType.ACME_ACCOUNT && requiredEnrollmentType === EnrollmentType.ACME) { + return profile; + } + const { permission } = await permissionService.getProjectPermission({ actor, actorId, @@ -484,7 +491,8 @@ export const certificateV3ServiceFactory = ({ actor, actorId, actorAuthMethod, - actorOrgId + actorOrgId, + enrollmentType }: TSignCertificateFromProfileDTO): Promise> => { const profile = await validateProfileAndPermissions( profileId, @@ -494,7 +502,7 @@ export const certificateV3ServiceFactory = ({ actorOrgId, certificateProfileDAL, permissionService, - EnrollmentType.API + enrollmentType ); const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(profile.caId); diff --git a/backend/src/services/certificate-v3/certificate-v3-types.ts b/backend/src/services/certificate-v3/certificate-v3-types.ts index a62a25b73..8685d5f30 100644 --- a/backend/src/services/certificate-v3/certificate-v3-types.ts +++ b/backend/src/services/certificate-v3/certificate-v3-types.ts @@ -1,11 +1,12 @@ import { TProjectPermission } from "@app/lib/types"; -import { ACMESANType, CertificateOrderStatus } from "../certificate/certificate-types"; import { CertExtendedKeyUsageType, CertKeyUsageType, CertSubjectAlternativeNameType } from "../certificate-common/certificate-constants"; +import { EnrollmentType } from "../certificate-profile/certificate-profile-types"; +import { ACMESANType, CertificateOrderStatus } from "../certificate/certificate-types"; export type TIssueCertificateFromProfileDTO = { profileId: string; @@ -35,6 +36,7 @@ export type TSignCertificateFromProfileDTO = { }; notBefore?: Date; notAfter?: Date; + enrollmentType: EnrollmentType; } & Omit; export type TOrderCertificateFromProfileDTO = { From e5db20d06254bff577d5c16d9824474810e07edc Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 31 Oct 2025 21:43:53 -0700 Subject: [PATCH 138/231] Handle errors --- backend/src/db/schemas/pki-acme-orders.ts | 3 +- .../ee/services/pki-acme/pki-acme-errors.ts | 21 ++++++ .../ee/services/pki-acme/pki-acme-service.ts | 74 ++++++++++++------- .../certificate-profile-dal.ts | 2 +- 4 files changed, 71 insertions(+), 29 deletions(-) diff --git a/backend/src/db/schemas/pki-acme-orders.ts b/backend/src/db/schemas/pki-acme-orders.ts index 61a15b156..eee8c96f0 100644 --- a/backend/src/db/schemas/pki-acme-orders.ts +++ b/backend/src/db/schemas/pki-acme-orders.ts @@ -18,7 +18,8 @@ export const PkiAcmeOrdersSchema = z.object({ updatedAt: z.date(), csr: z.string().nullable().optional(), certificate: z.string().nullable().optional(), - certificateChain: z.string().nullable().optional() + certificateChain: z.string().nullable().optional(), + error: z.string().nullable().optional() }); export type TPkiAcmeOrders = z.infer; diff --git a/backend/src/ee/services/pki-acme/pki-acme-errors.ts b/backend/src/ee/services/pki-acme/pki-acme-errors.ts index 924a7b0ca..73f17e061 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-errors.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-errors.ts @@ -528,3 +528,24 @@ export class AcmeOrderNotReadyError extends AcmeError { this.name = "AcmeOrderNotReadyError"; } } + +export class AcmeBadCSRError extends AcmeError { + constructor({ + detail = "The CSR is unacceptable", + error, + message + }: { + detail?: string; + error?: unknown; + message?: string; + } = {}) { + super({ + type: AcmeErrorType.BadCsr, + detail, + status: 400, + error, + message + }); + this.name = "AcmeBadCSRError"; + } +} 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 a3c00a513..43754d74f 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -2,7 +2,7 @@ import { TPkiAcmeAccounts } from "@app/db/schemas/pki-acme-accounts"; import { TPkiAcmeAuths } from "@app/db/schemas/pki-acme-auths"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; -import { NotFoundError } from "@app/lib/errors"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; @@ -25,6 +25,7 @@ import { TPkiAcmeAuthDALFactory } from "./pki-acme-auth-dal"; import { TPkiAcmeChallengeDALFactory } from "./pki-acme-challenge-dal"; import { AcmeAccountDoesNotExistError, + AcmeBadCSRError, AcmeBadPublicKeyError, AcmeError, AcmeMalformedError, @@ -520,32 +521,51 @@ export const pkiAcmeServiceFactory = ({ const { csr } = payload; // TODO: validate the CSR and return badCSR error if it's invalid // TODO: this should be the same transaction? - const { certificate, certificateChain, certificateId } = await certificateV3Service.signCertificateFromProfile({ - actor: ActorType.ACME_ACCOUNT, - actorId: accountId, - actorAuthMethod: null, - actorOrgId, - profileId, - csr, - notBefore: order.notBefore ? new Date(order.notBefore) : undefined, - notAfter: order.notAfter ? new Date(order.notAfter) : undefined, - validity: { - // TODO: read config from the profile to get the expiration time instead - ttl: (24 * 60 * 60 * 1000).toString() - }, - enrollmentType: EnrollmentType.ACME - }); - // TODO: associate the certificate with the order - await acmeOrderDAL.updateById( - orderId, - { - status: AcmeOrderStatus.Valid, - csr, - certificateChain, - certificate - }, - tx - ); + try { + const { certificate, certificateChain, certificateId } = + await certificateV3Service.signCertificateFromProfile({ + actor: ActorType.ACME_ACCOUNT, + actorId: accountId, + actorAuthMethod: null, + actorOrgId, + profileId, + csr, + notBefore: order.notBefore ? new Date(order.notBefore) : undefined, + notAfter: order.notAfter ? new Date(order.notAfter) : undefined, + validity: { + // TODO: read config from the profile to get the expiration time instead + ttl: (24 * 60 * 60 * 1000).toString() + }, + enrollmentType: EnrollmentType.ACME + }); + // TODO: associate the certificate with the order + await acmeOrderDAL.updateById( + orderId, + { + status: AcmeOrderStatus.Valid, + csr, + certificateChain, + certificate + }, + tx + ); + } catch (error) { + await acmeOrderDAL.updateById( + orderId, + { + csr, + status: AcmeOrderStatus.Invalid, + error: error instanceof Error ? error.message : "Unknown error" + }, + tx + ); + // TODO: log the error + // TODO: audit log the error + if (error instanceof BadRequestError) { + throw new AcmeBadCSRError({ detail: `Invalid CSR: ${error.message}` }); + } + throw new AcmeServerInternalError({ detail: "Failed to sign certificate" }); + } return await acmeOrderDAL.findByAccountAndOrderIdWithAuthorizations(accountId, orderId, tx); }); } else if (order.status !== AcmeOrderStatus.Valid) { diff --git a/backend/src/services/certificate-profile/certificate-profile-dal.ts b/backend/src/services/certificate-profile/certificate-profile-dal.ts index dcf6ca4e6..beea084e3 100644 --- a/backend/src/services/certificate-profile/certificate-profile-dal.ts +++ b/backend/src/services/certificate-profile/certificate-profile-dal.ts @@ -74,7 +74,7 @@ export const certificateProfileDALFactory = (db: TDbClient) => { .join(TableName.Project, `${TableName.PkiCertificateProfile}.projectId`, `${TableName.Project}.id`) .select(selectAllTableCols(TableName.PkiCertificateProfile)) .select(db.ref("orgId").withSchema(TableName.Project).as("ownerOrgId")) - .where({ id }) + .where(`${TableName.PkiCertificateProfile}.id`, id) .first()) as (TCertificateProfile & { ownerOrgId: string }) | undefined; return certificateProfile; } catch (error) { From bba265d21ea73d560dca8692e84421ad4be58bf0 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 31 Oct 2025 21:53:15 -0700 Subject: [PATCH 139/231] Keep db update --- .../migrations/20251029234547_add-pki-acme.ts | 8 ++++---- .../ee/services/pki-acme/pki-acme-service.ts | 17 +++++++++++++---- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/backend/src/db/migrations/20251029234547_add-pki-acme.ts b/backend/src/db/migrations/20251029234547_add-pki-acme.ts index ee5ba9f36..e76587159 100644 --- a/backend/src/db/migrations/20251029234547_add-pki-acme.ts +++ b/backend/src/db/migrations/20251029234547_add-pki-acme.ts @@ -85,11 +85,11 @@ export async function up(knex: Knex): Promise { t.timestamp("expiresAt").notNullable(); - t.string("csr").nullable(); - t.string("certificate").nullable(); - t.string("certificateChain").nullable(); + t.text("csr").nullable(); + t.text("certificate").nullable(); + t.text("certificateChain").nullable(); - t.string("error").nullable(); + t.text("error").nullable(); // Order status t.string("status").notNullable(); // pending, ready, processing, valid, invalid 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 43754d74f..568f3d852 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -508,7 +508,7 @@ export const pkiAcmeServiceFactory = ({ throw new NotFoundError({ message: "ACME order not found" }); } if (order.status === AcmeOrderStatus.Ready) { - order = await acmeOrderDAL.transaction(async (tx) => { + const { order: updatedOrder, error } = await acmeOrderDAL.transaction(async (tx) => { const order = (await acmeOrderDAL.findByIdForFinalization(orderId, tx))!; // TODO: ideally, this should be doen with onRequest: verifyAuth([AuthMode.ACME_JWS_SIGNATURE]), instead const { ownerOrgId: actorOrgId } = (await certificateProfileDAL.findByIdWithOwnerOrgId(profileId, tx))!; @@ -521,6 +521,7 @@ export const pkiAcmeServiceFactory = ({ const { csr } = payload; // TODO: validate the CSR and return badCSR error if it's invalid // TODO: this should be the same transaction? + let error: Error | undefined; try { const { certificate, certificateChain, certificateId } = await certificateV3Service.signCertificateFromProfile({ @@ -562,12 +563,20 @@ export const pkiAcmeServiceFactory = ({ // TODO: log the error // TODO: audit log the error if (error instanceof BadRequestError) { - throw new AcmeBadCSRError({ detail: `Invalid CSR: ${error.message}` }); + error = new AcmeBadCSRError({ detail: `Invalid CSR: ${error.message}` }); + } else { + error = new AcmeServerInternalError({ detail: "Failed to sign certificate" }); } - throw new AcmeServerInternalError({ detail: "Failed to sign certificate" }); } - return await acmeOrderDAL.findByAccountAndOrderIdWithAuthorizations(accountId, orderId, tx); + return { + order: (await acmeOrderDAL.findByAccountAndOrderIdWithAuthorizations(accountId, orderId, tx))!, + error + }; }); + if (error) { + throw error; + } + order = updatedOrder; } else if (order.status !== AcmeOrderStatus.Valid) { throw new AcmeOrderNotReadyError({ message: "ACME order is not ready" }); } From a0c0bd6a40e065aa6b560d005721c3fdea2a345d Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 31 Oct 2025 22:04:03 -0700 Subject: [PATCH 140/231] Fix returning errors --- backend/src/ee/services/pki-acme/pki-acme-service.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 568f3d852..84f28c7ac 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -521,7 +521,7 @@ export const pkiAcmeServiceFactory = ({ const { csr } = payload; // TODO: validate the CSR and return badCSR error if it's invalid // TODO: this should be the same transaction? - let error: Error | undefined; + let errorToReturn: Error | undefined; try { const { certificate, certificateChain, certificateId } = await certificateV3Service.signCertificateFromProfile({ @@ -563,14 +563,14 @@ export const pkiAcmeServiceFactory = ({ // TODO: log the error // TODO: audit log the error if (error instanceof BadRequestError) { - error = new AcmeBadCSRError({ detail: `Invalid CSR: ${error.message}` }); + errorToReturn = new AcmeBadCSRError({ detail: `Invalid CSR: ${error.message}` }); } else { - error = new AcmeServerInternalError({ detail: "Failed to sign certificate" }); + errorToReturn = new AcmeServerInternalError({ detail: "Failed to sign certificate with internal error" }); } } return { order: (await acmeOrderDAL.findByAccountAndOrderIdWithAuthorizations(accountId, orderId, tx))!, - error + error: errorToReturn }; }); if (error) { From 4bb19dd73061ca86fc808acc1df608d376da3f28 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 31 Oct 2025 22:17:42 -0700 Subject: [PATCH 141/231] Check empty payload --- backend/src/ee/routes/v1/pki-acme-router.ts | 9 ++++++--- backend/src/ee/services/pki-acme/pki-acme-service.ts | 7 ++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/backend/src/ee/routes/v1/pki-acme-router.ts b/backend/src/ee/routes/v1/pki-acme-router.ts index 329a381e3..8b6c7c817 100644 --- a/backend/src/ee/routes/v1/pki-acme-router.ts +++ b/backend/src/ee/routes/v1/pki-acme-router.ts @@ -10,6 +10,7 @@ import { CreateAcmeOrderBodySchema, DeactivateAcmeAccountBodySchema, DeactivateAcmeAccountResponseSchema, + DownloadAcmeCertificateBodySchema, FinalizeAcmeOrderBodySchema, GetAcmeAuthorizationResponseSchema, GetAcmeDirectoryResponseSchema, @@ -372,10 +373,12 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { } }, handler: async (req, res) => { - const { profileId, accountId } = await validateExistingAccount({ - req, - schema: FinalizeAcmeOrderBodySchema + const { profileId, accountId, payload } = await validateExistingAccount({ + req }); + if (payload !== "") { + throw new AcmeMalformedError({ detail: "Payload should be empty" }); + } return sendAcmeResponse( res, profileId, 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 84f28c7ac..134645fa5 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -603,11 +603,12 @@ export const pkiAcmeServiceFactory = ({ if (!order) { throw new NotFoundError({ message: "ACME order not found" }); } - // FIXME: Implement ACME certificate download - // Return the certificate in PEM format + if (order.status !== AcmeOrderStatus.Valid) { + throw new AcmeOrderNotReadyError({ message: "ACME order is not valid" }); + } return { status: 200, - body: "FIXME-certificate-pem", + body: order.certificateChain! + "\n" + order.certificate!, headers: { Location: buildUrl(profileId, `/orders/${orderId}/certificate`), Link: `<${buildUrl(profileId, "/directory")}>;rel="index"` From 5b87011e0124cd81fb20dce8eb703617d434026d Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 31 Oct 2025 22:34:45 -0700 Subject: [PATCH 142/231] Add missing cert link for order --- backend/src/ee/routes/v1/pki-acme-router.ts | 8 +++++--- backend/src/ee/services/pki-acme/pki-acme-service.ts | 4 +++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/backend/src/ee/routes/v1/pki-acme-router.ts b/backend/src/ee/routes/v1/pki-acme-router.ts index 8b6c7c817..1e06a93e6 100644 --- a/backend/src/ee/routes/v1/pki-acme-router.ts +++ b/backend/src/ee/routes/v1/pki-acme-router.ts @@ -261,10 +261,12 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { } }, handler: async (req, res) => { - const { profileId, accountId } = await validateExistingAccount({ - req, - schema: FinalizeAcmeOrderBodySchema + const { profileId, accountId, payload } = await validateExistingAccount({ + req }); + if (payload !== "") { + throw new AcmeMalformedError({ detail: "Payload should be empty" }); + } return sendAcmeResponse( res, profileId, 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 134645fa5..a1c719912 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -276,7 +276,9 @@ export const pkiAcmeServiceFactory = ({ authorizations: order.authorizations.map((auth: TPkiAcmeAuths) => buildUrl(profileId, `/authorizations/${auth.id}`) ), - finalize: buildUrl(profileId, `/orders/${order.id}/finalize`) + finalize: buildUrl(profileId, `/orders/${order.id}/finalize`), + certificate: + order.status === AcmeOrderStatus.Valid ? buildUrl(profileId, `/orders/${order.id}/certificate`) : undefined }; }; From 07f51e767283bc80f6b52e8b452ff384987fcc58 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 31 Oct 2025 22:35:56 -0700 Subject: [PATCH 143/231] Add type --- backend/src/ee/services/pki-acme/pki-acme-service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 a1c719912..6e23ef2bc 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -263,7 +263,7 @@ export const pkiAcmeServiceFactory = ({ authorizations: TPkiAcmeAuths[]; }; profileId: string; - }) => { + }): TAcmeOrderResource => { return { status: order.status, expires: order.expiresAt.toISOString(), From 3a84e9a50e08aa55d0e17e57d02a1cddb7be996a Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 31 Oct 2025 22:36:29 -0700 Subject: [PATCH 144/231] Org import --- backend/src/ee/services/pki-acme/pki-acme-service.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) 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 6e23ef2bc..a478a1721 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -6,11 +6,12 @@ import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; -import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service"; +import { ActorType } from "@app/services/auth/auth-type"; import { EnrollmentType, TCertificateProfileWithConfigs } from "@app/services/certificate-profile/certificate-profile-types"; +import { TCertificateV3ServiceFactory } from "@app/services/certificate-v3/certificate-v3-service"; import { calculateJwkThumbprint, errors, @@ -64,8 +65,6 @@ import { TRawJwsPayload, TRespondToAcmeChallengeResponse } from "./pki-acme-types"; -import { TCertificateV3ServiceFactory } from "@app/services/certificate-v3/certificate-v3-service"; -import { ActorType, AuthMode } from "@app/services/auth/auth-type"; type TPkiAcmeServiceFactoryDep = { certificateProfileDAL: Pick; From ebb42603869f7ca9725242006fc13c1860e8939c Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Mon, 3 Nov 2025 12:40:55 -0800 Subject: [PATCH 145/231] Add ACME cert profile creation feature --- backend/bdd/features/environment.py | 8 ++++++ .../features/pki/acme/cert-profile.feature | 23 +++++++++++++++++ backend/bdd/features/steps/pki_acme.py | 25 +++++++++++++++++++ backend/bdd/pyproject.toml | 1 + backend/bdd/uv.lock | 23 +++++++++++++++++ 5 files changed, 80 insertions(+) create mode 100644 backend/bdd/features/pki/acme/cert-profile.feature diff --git a/backend/bdd/features/environment.py b/backend/bdd/features/environment.py index 5b94d0415..8aa78e276 100644 --- a/backend/bdd/features/environment.py +++ b/backend/bdd/features/environment.py @@ -4,12 +4,20 @@ import httpx from behave.runner import Context BASE_URL = os.environ.get("INFISICAL_API_URL", "http://localhost:8080") +PROJECT_ID = os.environ.get("PROJECT_ID", "c051e74c-48a7-4724-832c-d5b496698546") +CERT_CA_ID = os.environ.get("CERT_CA_ID", "2f0d9820-e5a8-48bb-aac8-deed9d868a1e") +CERT_TEMPLATE_ID = os.environ.get( + "CERT_TEMPLATE_ID", "4dbf6bb0-6e86-4ee6-8550-9171428c8e82" +) AUTH_TOKEN = os.environ.get("INFISICAL_TOKEN") def before_all(context: Context): context.vars = { "BASE_URL": BASE_URL, + "PROJECT_ID": PROJECT_ID, + "CERT_CA_ID": CERT_CA_ID, + "CERT_TEMPLATE_ID": CERT_TEMPLATE_ID, } context.http_client = httpx.Client( base_url=BASE_URL, # headers={"Authorization": f"Bearer {AUTH_TOKEN}"} diff --git a/backend/bdd/features/pki/acme/cert-profile.feature b/backend/bdd/features/pki/acme/cert-profile.feature new file mode 100644 index 000000000..4af911a93 --- /dev/null +++ b/backend/bdd/features/pki/acme/cert-profile.feature @@ -0,0 +1,23 @@ +Feature: ACME Cert Profile + + Scenario: Create a cert profile + Given I make a random slug as profile_slug + When I send a POST request to "/api/v1/pki/certificate-profiles" with JSON payload + """ + { + "projectId": "{PROJECT_ID}", + "slug": "{profile_slug}", + "description": "", + "enrollmentType": "acme", + "caId": "{CA_ID}", + "certificateTemplateId": "{CERT_TEMPLATE_ID}", + "acmeConfig": {} + } + """ + Then the value response.status should be equal to 201 + Then the value response with jq .eab_kid should be present + Then the value response with jq .eab_secret should be present + Then the value response with jq .slug should be equal to {profile_slug} + Then the value response with jq .caId should be equal to {CA_ID} + Then the value response with jq .certificateTemplateId should be equal to {CERT_TEMPLATE_ID} + Then the value response with jq .enrollmentTYpe should be equal to acme diff --git a/backend/bdd/features/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py index 3156d4a71..81b4b6023 100644 --- a/backend/bdd/features/steps/pki_acme.py +++ b/backend/bdd/features/steps/pki_acme.py @@ -2,6 +2,7 @@ import json import re import threading +import faker import jq import requests import glom @@ -107,6 +108,11 @@ def eval_var(context: Context, var_path: str, as_json: bool = True): return value +@given("I make a random {faker_type} as {var_name}") +def step_impl(context: Context, faker_type: str, var_name: str): + context.vars[var_name] = getattr(faker, faker_type)() + + @given('I have an ACME cert profile as "{profile_var}"') def step_impl(context: Context, profile_var: str): # TODO: Fixed value for now, just to make test much easier, @@ -121,6 +127,13 @@ def step_impl(context: Context, method: str, url: str): context.response = context.http_client.request(method, url.format(**context.vars)) +@when('I send a {method} request to "{url}" with JSON payload') +def step_impl(context: Context, method: str, url: str): + context.response = context.http_client.request( + method, url.format(**context.vars), json_payload=context.text + ) + + @when("I have an ACME client connecting to {url}") def step_impl(context: Context, url: str): private_key = rsa.generate_private_key( @@ -260,6 +273,18 @@ def step_impl(context: Context, var_path: str, jq_query: str): ) +@then("the value {var_path} with jq {jq_query} should be present") +def step_impl(context: Context, var_path: str, jq_query: str): + value, result = apply_value_with_jq( + context=context, + var_path=var_path, + jq_query=jq_query, + ) + assert result, ( + f"{json.dumps(value)!r} with jq {jq_query!r}, the result {json.dumps(result)!r} is not present" + ) + + @then("the value {var_path} with jq {jq_query} should be equal to {expected}") def step_impl(context: Context, var_path: str, jq_query: str, expected: str): value, result = apply_value_with_jq( diff --git a/backend/bdd/pyproject.toml b/backend/bdd/pyproject.toml index 80e1979a1..2decf1967 100644 --- a/backend/bdd/pyproject.toml +++ b/backend/bdd/pyproject.toml @@ -7,6 +7,7 @@ requires-python = ">=3.12" dependencies = [ "acme>=5.1.0", "behave>=1.3.3", + "faker>=37.12.0", "glom>=24.11.0", "httpx>=0.28.1", "josepy>=2.2.0", diff --git a/backend/bdd/uv.lock b/backend/bdd/uv.lock index beceb0f26..17e9a4d28 100644 --- a/backend/bdd/uv.lock +++ b/backend/bdd/uv.lock @@ -48,6 +48,7 @@ source = { virtual = "." } dependencies = [ { name = "acme" }, { name = "behave" }, + { name = "faker" }, { name = "glom" }, { name = "httpx" }, { name = "josepy" }, @@ -58,6 +59,7 @@ dependencies = [ requires-dist = [ { name = "acme", specifier = ">=5.1.0" }, { name = "behave", specifier = ">=1.3.3" }, + { name = "faker", specifier = ">=37.12.0" }, { name = "glom", specifier = ">=24.11.0" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "josepy", specifier = ">=2.2.0" }, @@ -308,6 +310,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/47/21867c2e5fd006c8d36a560df9e32cb4f1f566b20c5dd41f5f8a2124f7de/face-24.0.0-py3-none-any.whl", hash = "sha256:0e2c17b426fa4639a4e77d1de9580f74a98f4869ba4c7c8c175b810611622cd3", size = 54742, upload-time = "2024-11-02T05:24:24.939Z" }, ] +[[package]] +name = "faker" +version = "37.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/84/e95acaa848b855e15c83331d0401ee5f84b2f60889255c2e055cb4fb6bdf/faker-37.12.0.tar.gz", hash = "sha256:7505e59a7e02fa9010f06c3e1e92f8250d4cfbb30632296140c2d6dbef09b0fa", size = 1935741, upload-time = "2025-10-24T15:19:58.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/98/2c050dec90e295a524c9b65c4cb9e7c302386a296b2938710448cbd267d5/faker-37.12.0-py3-none-any.whl", hash = "sha256:afe7ccc038da92f2fbae30d8e16d19d91e92e242f8401ce9caf44de892bab4c4", size = 1975461, upload-time = "2025-10-24T15:19:55.739Z" }, +] + [[package]] name = "glom" version = "24.11.0" @@ -503,6 +517,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "tzdata" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, +] + [[package]] name = "urllib3" version = "2.5.0" From 79e4231ddfcbfeaab8c6fe7c3071d2358923ae43 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Mon, 3 Nov 2025 13:10:04 -0800 Subject: [PATCH 146/231] Add auth token --- backend/bdd/features/environment.py | 7 ++++ .../features/pki/acme/cert-profile.feature | 3 +- backend/bdd/features/steps/pki_acme.py | 33 ++++++++++++++++--- backend/bdd/pyproject.toml | 1 + backend/bdd/uv.lock | 22 +++++++++++++ 5 files changed, 61 insertions(+), 5 deletions(-) diff --git a/backend/bdd/features/environment.py b/backend/bdd/features/environment.py index 8aa78e276..e5c5664c1 100644 --- a/backend/bdd/features/environment.py +++ b/backend/bdd/features/environment.py @@ -2,6 +2,12 @@ import os import httpx from behave.runner import Context +from dotenv import load_dotenv +import logging + +logging.getLogger("httpx").setLevel(logging.DEBUG) + +load_dotenv() BASE_URL = os.environ.get("INFISICAL_API_URL", "http://localhost:8080") PROJECT_ID = os.environ.get("PROJECT_ID", "c051e74c-48a7-4724-832c-d5b496698546") @@ -18,6 +24,7 @@ def before_all(context: Context): "PROJECT_ID": PROJECT_ID, "CERT_CA_ID": CERT_CA_ID, "CERT_TEMPLATE_ID": CERT_TEMPLATE_ID, + "AUTH_TOKEN": AUTH_TOKEN, } context.http_client = httpx.Client( base_url=BASE_URL, # headers={"Authorization": f"Bearer {AUTH_TOKEN}"} diff --git a/backend/bdd/features/pki/acme/cert-profile.feature b/backend/bdd/features/pki/acme/cert-profile.feature index 4af911a93..7cdfe3127 100644 --- a/backend/bdd/features/pki/acme/cert-profile.feature +++ b/backend/bdd/features/pki/acme/cert-profile.feature @@ -2,6 +2,7 @@ Feature: ACME Cert Profile Scenario: Create a cert profile Given I make a random slug as profile_slug + Given I use AUTH_TOKEN for authentication When I send a POST request to "/api/v1/pki/certificate-profiles" with JSON payload """ { @@ -14,7 +15,7 @@ Feature: ACME Cert Profile "acmeConfig": {} } """ - Then the value response.status should be equal to 201 + Then the value response.status_code should be equal to 201 Then the value response with jq .eab_kid should be present Then the value response with jq .eab_secret should be present Then the value response with jq .slug should be equal to {profile_slug} diff --git a/backend/bdd/features/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py index 81b4b6023..2f397b883 100644 --- a/backend/bdd/features/steps/pki_acme.py +++ b/backend/bdd/features/steps/pki_acme.py @@ -1,11 +1,12 @@ import json +import logging import re import threading -import faker import jq import requests import glom +from faker import Faker from acme import client from acme import messages from acme import standalone @@ -23,6 +24,8 @@ from cryptography.hazmat.primitives import hashes ACC_KEY_BITS = 2048 ACC_KEY_PUBLIC_EXPONENT = 65537 +logger = logging.getLogger(__name__) +faker = Faker() class AcmeProfile: @@ -108,6 +111,16 @@ def eval_var(context: Context, var_path: str, as_json: bool = True): return value +def prepare_headers(context: Context) -> dict | None: + headers = {} + auth_token = getattr(context, "auth_token", None) + if auth_token is not None: + headers["authorization"] = "Bearer {}".format(auth_token) + if not headers: + return None + return headers + + @given("I make a random {faker_type} as {var_name}") def step_impl(context: Context, faker_type: str, var_name: str): context.vars[var_name] = getattr(faker, faker_type)() @@ -122,16 +135,28 @@ def step_impl(context: Context, profile_var: str): context.vars[profile_var] = AcmeProfile(profile_id) +@given("I use {token_var} for authentication") +def step_impl(context: Context, token_var: str): + context.auth_token = eval_var(context, token_var) + + @when('I send a {method} request to "{url}"') def step_impl(context: Context, method: str, url: str): - context.response = context.http_client.request(method, url.format(**context.vars)) + context.response = context.http_client.request( + method, url.format(**context.vars), headers=prepare_headers(context) + ) @when('I send a {method} request to "{url}" with JSON payload') def step_impl(context: Context, method: str, url: str): - context.response = context.http_client.request( - method, url.format(**context.vars), json_payload=context.text + response = context.http_client.request( + method, + url.format(**context.vars), + headers=prepare_headers(context), + json=json.loads(context.text), ) + context.response = response + context.vars["response"] = response @when("I have an ACME client connecting to {url}") diff --git a/backend/bdd/pyproject.toml b/backend/bdd/pyproject.toml index 2decf1967..4f19cb0de 100644 --- a/backend/bdd/pyproject.toml +++ b/backend/bdd/pyproject.toml @@ -7,6 +7,7 @@ requires-python = ">=3.12" dependencies = [ "acme>=5.1.0", "behave>=1.3.3", + "dotenv>=0.9.9", "faker>=37.12.0", "glom>=24.11.0", "httpx>=0.28.1", diff --git a/backend/bdd/uv.lock b/backend/bdd/uv.lock index 17e9a4d28..8ae5b6c01 100644 --- a/backend/bdd/uv.lock +++ b/backend/bdd/uv.lock @@ -48,6 +48,7 @@ source = { virtual = "." } dependencies = [ { name = "acme" }, { name = "behave" }, + { name = "dotenv" }, { name = "faker" }, { name = "glom" }, { name = "httpx" }, @@ -59,6 +60,7 @@ dependencies = [ requires-dist = [ { name = "acme", specifier = ">=5.1.0" }, { name = "behave", specifier = ">=1.3.3" }, + { name = "dotenv", specifier = ">=0.9.9" }, { name = "faker", specifier = ">=37.12.0" }, { name = "glom", specifier = ">=24.11.0" }, { name = "httpx", specifier = ">=0.28.1" }, @@ -298,6 +300,17 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/51/51ae3ab3b8553ec61f6558e9a0a9e8c500a9db844f9cf00a732b19c9a6ea/cucumber_tag_expressions-8.0.0-py3-none-any.whl", hash = "sha256:bfe552226f62a4462ee91c9643582f524af84ac84952643fb09057580cbb110a", size = 9726, upload-time = "2025-10-14T17:01:26.098Z" }, ] +[[package]] +name = "dotenv" +version = "0.9.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dotenv" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/b7/545d2c10c1fc15e48653c91efde329a790f2eecfbbf2bd16003b5db2bab0/dotenv-0.9.9-py2.py3-none-any.whl", hash = "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9", size = 1892, upload-time = "2025-02-19T22:15:01.647Z" }, +] + [[package]] name = "face" version = "24.0.0" @@ -475,6 +488,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/34/90/0200184d2124484f918054751ef997ed6409cb05b7e8dcbf5a22da4c4748/pyrfc3339-2.1.0-py3-none-any.whl", hash = "sha256:560f3f972e339f579513fe1396974352fd575ef27caff160a38b312252fcddf3", size = 6758, upload-time = "2025-08-23T16:40:30.49Z" }, ] +[[package]] +name = "python-dotenv" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, +] + [[package]] name = "requests" version = "2.32.5" From 2cee9cf3cc2232a0c73ecd3f279bcd92d1d733e6 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Mon, 3 Nov 2025 13:13:57 -0800 Subject: [PATCH 147/231] More tests --- backend/bdd/features/pki/acme/cert-profile.feature | 2 +- backend/bdd/features/steps/pki_acme.py | 12 +++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/backend/bdd/features/pki/acme/cert-profile.feature b/backend/bdd/features/pki/acme/cert-profile.feature index 7cdfe3127..7e4dbdc84 100644 --- a/backend/bdd/features/pki/acme/cert-profile.feature +++ b/backend/bdd/features/pki/acme/cert-profile.feature @@ -10,7 +10,7 @@ Feature: ACME Cert Profile "slug": "{profile_slug}", "description": "", "enrollmentType": "acme", - "caId": "{CA_ID}", + "caId": "{CERT_CA_ID}", "certificateTemplateId": "{CERT_TEMPLATE_ID}", "acmeConfig": {} } diff --git a/backend/bdd/features/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py index 2f397b883..0bd55e397 100644 --- a/backend/bdd/features/steps/pki_acme.py +++ b/backend/bdd/features/steps/pki_acme.py @@ -149,14 +149,24 @@ def step_impl(context: Context, method: str, url: str): @when('I send a {method} request to "{url}" with JSON payload') def step_impl(context: Context, method: str, url: str): + json_payload = json.loads(context.text) + json_payload = replace_vars(json_payload, context.vars) + logger.debug( + "Sending %s request to %s with JSON payload: %s", + method, + url, + json.dumps(json_payload), + ) response = context.http_client.request( method, url.format(**context.vars), headers=prepare_headers(context), - json=json.loads(context.text), + json=json_payload, ) context.response = response context.vars["response"] = response + logger.debug("Response status: %r", response.status_code) + logger.debug("Response JSON payload: %r", response.json()) @when("I have an ACME client connecting to {url}") From 52ae835d0fe707714c0c48650aac80e37ba386a4 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Mon, 3 Nov 2025 13:19:34 -0800 Subject: [PATCH 148/231] More tests --- backend/bdd/features/pki/acme/cert-profile.feature | 14 +++++++------- backend/bdd/features/steps/pki_acme.py | 5 ++++- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/backend/bdd/features/pki/acme/cert-profile.feature b/backend/bdd/features/pki/acme/cert-profile.feature index 7e4dbdc84..1fc6f0dae 100644 --- a/backend/bdd/features/pki/acme/cert-profile.feature +++ b/backend/bdd/features/pki/acme/cert-profile.feature @@ -15,10 +15,10 @@ Feature: ACME Cert Profile "acmeConfig": {} } """ - Then the value response.status_code should be equal to 201 - Then the value response with jq .eab_kid should be present - Then the value response with jq .eab_secret should be present - Then the value response with jq .slug should be equal to {profile_slug} - Then the value response with jq .caId should be equal to {CA_ID} - Then the value response with jq .certificateTemplateId should be equal to {CERT_TEMPLATE_ID} - Then the value response with jq .enrollmentTYpe should be equal to acme + Then the value response.status_code should be equal to 200 + Then the value response with jq .certificateProfile.slug should be equal to "{profile_slug}" + Then the value response with jq .certificateProfile.caId should be equal to "{CERT_CA_ID}" + Then the value response with jq .certificateProfile.certificateTemplateId should be equal to "{CERT_TEMPLATE_ID}" + Then the value response with jq .certificateProfile.enrollmentType should be equal to "acme" + Then the value response with jq .certificateProfile.eab_kid should be present + Then the value response with jq .certificateProfile.eab_secret should be present diff --git a/backend/bdd/features/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py index 0bd55e397..f2008e867 100644 --- a/backend/bdd/features/steps/pki_acme.py +++ b/backend/bdd/features/steps/pki_acme.py @@ -3,6 +3,7 @@ import logging import re import threading +import httpx import jq import requests import glom @@ -108,6 +109,8 @@ def eval_var(context: Context, var_path: str, as_json: bool = True): value = value.to_json() elif isinstance(value, requests.Response): value = value.json() + elif isinstance(value, httpx.Response): + value = value.json() return value @@ -327,7 +330,7 @@ def step_impl(context: Context, var_path: str, jq_query: str, expected: str): var_path=var_path, jq_query=jq_query, ) - expected_value = json.loads(expected) + expected_value = replace_vars(json.loads(expected), context.vars) assert result == expected_value, ( f"{json.dumps(value)!r} with jq {jq_query!r}, the result {json.dumps(result)!r} does not match {json.dumps(expected_value)!r}" ) From 6c6c5f803d503d4f7fb95651796bc03a6eecfd4c Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Mon, 3 Nov 2025 13:23:11 -0800 Subject: [PATCH 149/231] Naming style --- backend/bdd/features/pki/acme/cert-profile.feature | 4 ++-- backend/src/ee/routes/v1/pki-acme-router.ts | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/backend/bdd/features/pki/acme/cert-profile.feature b/backend/bdd/features/pki/acme/cert-profile.feature index 1fc6f0dae..1d6dea0c5 100644 --- a/backend/bdd/features/pki/acme/cert-profile.feature +++ b/backend/bdd/features/pki/acme/cert-profile.feature @@ -20,5 +20,5 @@ Feature: ACME Cert Profile Then the value response with jq .certificateProfile.caId should be equal to "{CERT_CA_ID}" Then the value response with jq .certificateProfile.certificateTemplateId should be equal to "{CERT_TEMPLATE_ID}" Then the value response with jq .certificateProfile.enrollmentType should be equal to "acme" - Then the value response with jq .certificateProfile.eab_kid should be present - Then the value response with jq .certificateProfile.eab_secret should be present + Then the value response with jq .certificateProfile.eabKid should be present + Then the value response with jq .certificateProfile.eabSecret should be present diff --git a/backend/src/ee/routes/v1/pki-acme-router.ts b/backend/src/ee/routes/v1/pki-acme-router.ts index 1e06a93e6..f628c8d36 100644 --- a/backend/src/ee/routes/v1/pki-acme-router.ts +++ b/backend/src/ee/routes/v1/pki-acme-router.ts @@ -10,7 +10,6 @@ import { CreateAcmeOrderBodySchema, DeactivateAcmeAccountBodySchema, DeactivateAcmeAccountResponseSchema, - DownloadAcmeCertificateBodySchema, FinalizeAcmeOrderBodySchema, GetAcmeAuthorizationResponseSchema, GetAcmeDirectoryResponseSchema, From fd6128aa65284492c38723696c2c0460bc2c7d4a Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Mon, 3 Nov 2025 14:11:01 -0800 Subject: [PATCH 150/231] Implement acme secret reveal --- .../features/pki/acme/cert-profile.feature | 2 - .../ee/services/permission/default-roles.ts | 4 +- .../services/permission/project-permission.ts | 4 +- .../routes/v1/certificate-profiles-router.ts | 33 ++++++++++- .../certificate-profile-service.ts | 56 ++++++++++++++++++- .../certificate-profile-types.ts | 1 + 6 files changed, 94 insertions(+), 6 deletions(-) diff --git a/backend/bdd/features/pki/acme/cert-profile.feature b/backend/bdd/features/pki/acme/cert-profile.feature index 1d6dea0c5..f25d0eaf2 100644 --- a/backend/bdd/features/pki/acme/cert-profile.feature +++ b/backend/bdd/features/pki/acme/cert-profile.feature @@ -20,5 +20,3 @@ Feature: ACME Cert Profile Then the value response with jq .certificateProfile.caId should be equal to "{CERT_CA_ID}" Then the value response with jq .certificateProfile.certificateTemplateId should be equal to "{CERT_TEMPLATE_ID}" Then the value response with jq .certificateProfile.enrollmentType should be equal to "acme" - Then the value response with jq .certificateProfile.eabKid should be present - Then the value response with jq .certificateProfile.eabSecret should be present diff --git a/backend/src/ee/services/permission/default-roles.ts b/backend/src/ee/services/permission/default-roles.ts index 34876f739..5e7025f05 100644 --- a/backend/src/ee/services/permission/default-roles.ts +++ b/backend/src/ee/services/permission/default-roles.ts @@ -106,7 +106,9 @@ const buildAdminPermissionRules = () => { ProjectPermissionCertificateProfileActions.Edit, ProjectPermissionCertificateProfileActions.Create, ProjectPermissionCertificateProfileActions.Delete, - ProjectPermissionCertificateProfileActions.IssueCert + ProjectPermissionCertificateProfileActions.IssueCert, + ProjectPermissionCertificateProfileActions.RevealAcmeEabSecret, + ProjectPermissionCertificateProfileActions.RotateAcmeEabSecret ], ProjectPermissionSub.CertificateProfiles ); diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index bb62440c1..74e4554ed 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -116,7 +116,9 @@ export enum ProjectPermissionCertificateProfileActions { Create = "create", Edit = "edit", Delete = "delete", - IssueCert = "issue-cert" + IssueCert = "issue-cert", + RevealAcmeEabSecret = "reveal-acme-eab-secret", + RotateAcmeEabSecret = "rotate-acme-eab-secret" } export enum ProjectPermissionSecretSyncActions { diff --git a/backend/src/server/routes/v1/certificate-profiles-router.ts b/backend/src/server/routes/v1/certificate-profiles-router.ts index 7ad7aaeb1..1a670c254 100644 --- a/backend/src/server/routes/v1/certificate-profiles-router.ts +++ b/backend/src/server/routes/v1/certificate-profiles-router.ts @@ -7,8 +7,8 @@ import { ApiDocsTags } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; -import { CertStatus } from "@app/services/certificate/certificate-types"; import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types"; +import { CertStatus } from "@app/services/certificate/certificate-types"; export const registerCertificateProfilesRouter = async (server: FastifyZodProvider) => { server.route({ @@ -491,4 +491,35 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid return { certificates }; } }); + + server.route({ + method: "GET", + url: "/:id/acme/eab-secret/reveal", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateProfiles], + params: z.object({ + id: z.string().uuid() + }), + response: { + 200: z.object({ + eabSecret: z.string() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const eabSecret = await server.services.certificateProfile.revealAcmeEabSecret({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + profileId: req.params.id + }); + return { eabSecret }; + } + }); }; diff --git a/backend/src/services/certificate-profile/certificate-profile-service.ts b/backend/src/services/certificate-profile/certificate-profile-service.ts index 527ed3c96..f896bb071 100644 --- a/backend/src/services/certificate-profile/certificate-profile-service.ts +++ b/backend/src/services/certificate-profile/certificate-profile-service.ts @@ -782,6 +782,59 @@ export const certificateProfileServiceFactory = ({ }; }; + const revealAcmeEabSecret = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + profileId + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + profileId: string; + }) => { + const profile = await certificateProfileDAL.findByIdWithConfigs(profileId); + if (!profile) { + throw new NotFoundError({ message: "Certificate profile not found" }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: profile.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateProfileActions.RevealAcmeEabSecret, + ProjectPermissionSub.CertificateProfiles + ); + + if (profile.enrollmentType !== EnrollmentType.ACME) { + throw new ForbiddenRequestError({ + message: "Profile is not configured for ACME enrollment" + }); + } + if (!profile.acmeConfig) { + throw new NotFoundError({ message: "ACME configuration not found for this profile" }); + } + + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId: profile.projectId, + projectDAL, + kmsService + }); + + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + const eabSecret = await kmsDecryptor({ cipherTextBlob: profile.acmeConfig.encryptedEabSecret }); + return eabSecret.toString(); + }; + return { createProfile, updateProfile, @@ -791,6 +844,7 @@ export const certificateProfileServiceFactory = ({ listProfiles, deleteProfile, getProfileCertificates, - getEstConfigurationByProfile + getEstConfigurationByProfile, + revealAcmeEabSecret }; }; diff --git a/backend/src/services/certificate-profile/certificate-profile-types.ts b/backend/src/services/certificate-profile/certificate-profile-types.ts index 6e1d64fb5..8a89b4d0d 100644 --- a/backend/src/services/certificate-profile/certificate-profile-types.ts +++ b/backend/src/services/certificate-profile/certificate-profile-types.ts @@ -58,6 +58,7 @@ export type TCertificateProfileWithConfigs = TCertificateProfile & { }; acmeConfig?: { id: string; + encryptedEabSecret: Buffer; }; metrics?: TCertificateProfileMetrics; }; From 9ed4600d1d45debdf5b05f3c1c74faada29a38df Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Mon, 3 Nov 2025 14:35:47 -0800 Subject: [PATCH 151/231] More test cases --- .../features/pki/acme/cert-profile.feature | 21 +++++++++++++++++ backend/bdd/features/steps/pki_acme.py | 23 +++++++++++++++++-- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/backend/bdd/features/pki/acme/cert-profile.feature b/backend/bdd/features/pki/acme/cert-profile.feature index f25d0eaf2..c14a7a3ad 100644 --- a/backend/bdd/features/pki/acme/cert-profile.feature +++ b/backend/bdd/features/pki/acme/cert-profile.feature @@ -16,7 +16,28 @@ Feature: ACME Cert Profile } """ Then the value response.status_code should be equal to 200 + Then the value response with jq .certificateProfile.id should be present Then the value response with jq .certificateProfile.slug should be equal to "{profile_slug}" Then the value response with jq .certificateProfile.caId should be equal to "{CERT_CA_ID}" Then the value response with jq .certificateProfile.certificateTemplateId should be equal to "{CERT_TEMPLATE_ID}" Then the value response with jq .certificateProfile.enrollmentType should be equal to "acme" + + Scenario: Reveal EAB secret + Given I make a random slug as profile_slug + Given I use AUTH_TOKEN for authentication + When I send a POST request to "/api/v1/pki/certificate-profiles" with JSON payload + """ + { + "projectId": "{PROJECT_ID}", + "slug": "{profile_slug}", + "description": "", + "enrollmentType": "acme", + "caId": "{CERT_CA_ID}", + "certificateTemplateId": "{CERT_TEMPLATE_ID}", + "acmeConfig": {} + } + """ + Then the value response.status_code should be equal to 200 + And I memorize response with jq ".certificateProfile.id" as profile_id + When I send a GET request to "/api/v1/pki/certificate-profiles/{profile_id}/acme/eab-secret/reveal" + Then the value response.status_code should be equal to 200 diff --git a/backend/bdd/features/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py index f2008e867..4c33b42b1 100644 --- a/backend/bdd/features/steps/pki_acme.py +++ b/backend/bdd/features/steps/pki_acme.py @@ -145,9 +145,13 @@ def step_impl(context: Context, token_var: str): @when('I send a {method} request to "{url}"') def step_impl(context: Context, method: str, url: str): - context.response = context.http_client.request( + logger.debug("Sending %s request to %s", method, url) + response = context.http_client.request( method, url.format(**context.vars), headers=prepare_headers(context) ) + context.vars["response"] = response + logger.debug("Response status: %r", response.status_code) + logger.debug("Response JSON payload: %r", response.json()) @when('I send a {method} request to "{url}" with JSON payload') @@ -166,7 +170,6 @@ def step_impl(context: Context, method: str, url: str): headers=prepare_headers(context), json=json_payload, ) - context.response = response context.vars["response"] = response logger.debug("Response status: %r", response.status_code) logger.debug("Response JSON payload: %r", response.json()) @@ -362,6 +365,22 @@ def step_impl(context: Context, var_path: str, expected: str): assert value == expected_value, f"{value!r} does not match {expected_value!r}" +@then('I memorize {var_path} with jq "{jq_query}" as {var_name}') +def step_impl(context: Context, var_path: str, jq_query, var_name: str): + _, value = apply_value_with_jq( + context=context, + var_path=var_path, + jq_query=jq_query, + ) + context.vars[var_name] = value + + +@then("I memorize {var_path} as {var_name}") +def step_impl(context: Context, var_path: str, var_name: str): + value = eval_var(context, var_path) + context.vars[var_name] = value + + @then("I print the value {var_path}") def step_impl(context: Context, var_path: str): value = eval_var(context, var_path) From 49ad8ab630667d50ad351d1d353d928d91e4566b Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Mon, 3 Nov 2025 14:45:24 -0800 Subject: [PATCH 152/231] Return eab kid as well --- .../src/server/routes/v1/certificate-profiles-router.ts | 5 +++-- .../certificate-profile/certificate-profile-dal.ts | 8 ++++++-- .../certificate-profile/certificate-profile-service.ts | 2 +- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/backend/src/server/routes/v1/certificate-profiles-router.ts b/backend/src/server/routes/v1/certificate-profiles-router.ts index 1a670c254..f85f8a677 100644 --- a/backend/src/server/routes/v1/certificate-profiles-router.ts +++ b/backend/src/server/routes/v1/certificate-profiles-router.ts @@ -506,20 +506,21 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid }), response: { 200: z.object({ + eabKid: z.string(), eabSecret: z.string() }) } }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const eabSecret = await server.services.certificateProfile.revealAcmeEabSecret({ + const { eabKid, eabSecret } = await server.services.certificateProfile.revealAcmeEabSecret({ actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, profileId: req.params.id }); - return { eabSecret }; + return { eabKid, eabSecret }; } }); }; diff --git a/backend/src/services/certificate-profile/certificate-profile-dal.ts b/backend/src/services/certificate-profile/certificate-profile-dal.ts index beea084e3..f8f511ab4 100644 --- a/backend/src/services/certificate-profile/certificate-profile-dal.ts +++ b/backend/src/services/certificate-profile/certificate-profile-dal.ts @@ -130,7 +130,8 @@ export const certificateProfileDALFactory = (db: TDbClient) => { db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigId"), db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigAutoRenew"), db.ref("renewBeforeDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigRenewBeforeDays"), - db.ref("id").withSchema(TableName.PkiAcmeEnrollmentConfig).as("acmeConfigId") + db.ref("id").withSchema(TableName.PkiAcmeEnrollmentConfig).as("acmeConfigId"), + db.ref("encryptedEabSecret").withSchema(TableName.PkiAcmeEnrollmentConfig).as("acmeConfigEncryptedEabSecret") ) .where(`${TableName.PkiCertificateProfile}.id`, id) .first(); @@ -158,7 +159,10 @@ export const certificateProfileDALFactory = (db: TDbClient) => { : undefined; const acmeConfig = result.acmeConfigId - ? ({ id: result.acmeConfigId } as TCertificateProfileWithConfigs["acmeConfig"]) + ? ({ + id: result.acmeConfigId, + encryptedEabSecret: result.acmeConfigEncryptedEabSecret + } as TCertificateProfileWithConfigs["acmeConfig"]) : undefined; const certificateAuthority = diff --git a/backend/src/services/certificate-profile/certificate-profile-service.ts b/backend/src/services/certificate-profile/certificate-profile-service.ts index f896bb071..2e92c4b02 100644 --- a/backend/src/services/certificate-profile/certificate-profile-service.ts +++ b/backend/src/services/certificate-profile/certificate-profile-service.ts @@ -832,7 +832,7 @@ export const certificateProfileServiceFactory = ({ kmsId: certificateManagerKmsId }); const eabSecret = await kmsDecryptor({ cipherTextBlob: profile.acmeConfig.encryptedEabSecret }); - return eabSecret.toString(); + return { eabKid: profile.id, eabSecret: eabSecret.toString() }; }; return { From 4f5e1760d47e6d0b01e1351f9bdbc455ab3397dc Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Mon, 3 Nov 2025 14:48:35 -0800 Subject: [PATCH 153/231] Assert eab --- backend/bdd/features/pki/acme/cert-profile.feature | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/bdd/features/pki/acme/cert-profile.feature b/backend/bdd/features/pki/acme/cert-profile.feature index c14a7a3ad..042f86e39 100644 --- a/backend/bdd/features/pki/acme/cert-profile.feature +++ b/backend/bdd/features/pki/acme/cert-profile.feature @@ -41,3 +41,5 @@ Feature: ACME Cert Profile And I memorize response with jq ".certificateProfile.id" as profile_id When I send a GET request to "/api/v1/pki/certificate-profiles/{profile_id}/acme/eab-secret/reveal" Then the value response.status_code should be equal to 200 + Then the value response with jq .eabKid should be equal to "{profile_id}" + Then the value response with jq .eabSecret should be present From 5b2c0583e080850c84765f71d128ce1549a12498 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Mon, 3 Nov 2025 14:56:30 -0800 Subject: [PATCH 154/231] Add quote for jq --- backend/bdd/features/pki/acme/auth.feature | 8 ++++---- backend/bdd/features/pki/acme/cert-profile.feature | 14 +++++++------- backend/bdd/features/pki/acme/order.feature | 10 +++++----- backend/bdd/features/steps/pki_acme.py | 6 +++--- 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/backend/bdd/features/pki/acme/auth.feature b/backend/bdd/features/pki/acme/auth.feature index b1b8ee5ae..dd824d18f 100644 --- a/backend/bdd/features/pki/acme/auth.feature +++ b/backend/bdd/features/pki/acme/auth.feature @@ -17,8 +17,8 @@ Feature: Authorization Then I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format Then I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order Then the value order.authorizations[0].uri with jq . should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/authorizations/(.+) - Then the value order.authorizations[0].body with jq .status should be equal to "pending" - Then the value order.authorizations[0].body with jq .challenges | map(pick(.type, .status)) | sort_by(.type) should be equal to json + Then the value order.authorizations[0].body with jq ".status" should be equal to "pending" + Then the value order.authorizations[0].body with jq ".challenges | map(pick(.type, .status)) | sort_by(.type)" should be equal to json """ [ { @@ -27,8 +27,8 @@ Feature: Authorization } ] """ - Then the value order.authorizations[0].body with jq .challenges | map(.status) | sort should be equal to ["pending"] - Then the value order.authorizations[0].body with jq .identifier should be equal to json + Then the value order.authorizations[0].body with jq ".challenges | map(.status) | sort" should be equal to ["pending"] + Then the value order.authorizations[0].body with jq ".identifier" should be equal to json """ { "type": "dns", diff --git a/backend/bdd/features/pki/acme/cert-profile.feature b/backend/bdd/features/pki/acme/cert-profile.feature index 042f86e39..564b06013 100644 --- a/backend/bdd/features/pki/acme/cert-profile.feature +++ b/backend/bdd/features/pki/acme/cert-profile.feature @@ -16,11 +16,11 @@ Feature: ACME Cert Profile } """ Then the value response.status_code should be equal to 200 - Then the value response with jq .certificateProfile.id should be present - Then the value response with jq .certificateProfile.slug should be equal to "{profile_slug}" - Then the value response with jq .certificateProfile.caId should be equal to "{CERT_CA_ID}" - Then the value response with jq .certificateProfile.certificateTemplateId should be equal to "{CERT_TEMPLATE_ID}" - Then the value response with jq .certificateProfile.enrollmentType should be equal to "acme" + Then the value response with jq ".certificateProfile.id" should be present + Then the value response with jq ".certificateProfile.slug" should be equal to "{profile_slug}" + Then the value response with jq ".certificateProfile.caId" should be equal to "{CERT_CA_ID}" + Then the value response with jq ".certificateProfile.certificateTemplateId" should be equal to "{CERT_TEMPLATE_ID}" + Then the value response with jq ".certificateProfile.enrollmentType" should be equal to "acme" Scenario: Reveal EAB secret Given I make a random slug as profile_slug @@ -41,5 +41,5 @@ Feature: ACME Cert Profile And I memorize response with jq ".certificateProfile.id" as profile_id When I send a GET request to "/api/v1/pki/certificate-profiles/{profile_id}/acme/eab-secret/reveal" Then the value response.status_code should be equal to 200 - Then the value response with jq .eabKid should be equal to "{profile_id}" - Then the value response with jq .eabSecret should be present + Then the value response with jq ".eabKid" should be equal to "{profile_id}" + Then the value response with jq ".eabSecret" should be present diff --git a/backend/bdd/features/pki/acme/order.feature b/backend/bdd/features/pki/acme/order.feature index d9cf5c268..1ba2c3570 100644 --- a/backend/bdd/features/pki/acme/order.feature +++ b/backend/bdd/features/pki/acme/order.feature @@ -45,7 +45,7 @@ Feature: Order Then I create a RSA private key pair as cert_key Then I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format Then I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order - Then the value order.body with jq .identifiers | sort_by(.value) should be equal to json + Then the value order.body with jq ".identifiers | sort_by(.value)" should be equal to json """ [ {"type": "dns", "value": "example.com"}, @@ -71,7 +71,7 @@ Feature: Order Then I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format Then I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order Then I send an ACME post-as-get to order.uri as fetched_order - Then the value fetched_order with jq .status should be equal to "pending" - Then the value fetched_order with jq .identifiers should be equal to [{"type": "dns", "value": "localhost"}] - Then the value fetched_order with jq .finalize should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/orders/(.+)/finalize - Then the value fetched_order with jq all(.authorizations[]; startswith("{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/authorizations/")) should be equal to true + Then the value fetched_order with jq ".status" should be equal to "pending" + Then the value fetched_order with jq ".identifiers" should be equal to [{"type": "dns", "value": "localhost"}] + Then the value fetched_order with jq ".finalize" should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/orders/(.+)/finalize + Then the value fetched_order with jq "all(.authorizations[]; startswith('{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/authorizations/'))" should be equal to true diff --git a/backend/bdd/features/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py index 4c33b42b1..855195ac5 100644 --- a/backend/bdd/features/steps/pki_acme.py +++ b/backend/bdd/features/steps/pki_acme.py @@ -314,7 +314,7 @@ def step_impl(context: Context, var_path: str, jq_query: str): ) -@then("the value {var_path} with jq {jq_query} should be present") +@then('the value {var_path} with jq "{jq_query}" should be present') def step_impl(context: Context, var_path: str, jq_query: str): value, result = apply_value_with_jq( context=context, @@ -326,7 +326,7 @@ def step_impl(context: Context, var_path: str, jq_query: str): ) -@then("the value {var_path} with jq {jq_query} should be equal to {expected}") +@then('the value {var_path} with jq "{jq_query}" should be equal to {expected}') def step_impl(context: Context, var_path: str, jq_query: str, expected: str): value, result = apply_value_with_jq( context=context, @@ -339,7 +339,7 @@ def step_impl(context: Context, var_path: str, jq_query: str, expected: str): ) -@then("the value {var_path} with jq {jq_query} should match pattern {regex}") +@then('the value {var_path} with jq "{jq_query}" should match pattern {regex}') def step_impl(context: Context, var_path: str, jq_query: str, regex: str): value, result = apply_value_with_jq( context=context, From e595e947e28df0f5bdf6f2b46242992d42929ada Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Mon, 3 Nov 2025 14:58:26 -0800 Subject: [PATCH 155/231] Use eab to sign up --- backend/bdd/features/pki/acme/cert-profile.feature | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/bdd/features/pki/acme/cert-profile.feature b/backend/bdd/features/pki/acme/cert-profile.feature index 564b06013..e090d0581 100644 --- a/backend/bdd/features/pki/acme/cert-profile.feature +++ b/backend/bdd/features/pki/acme/cert-profile.feature @@ -43,3 +43,7 @@ Feature: ACME Cert Profile Then the value response.status_code should be equal to 200 Then the value response with jq ".eabKid" should be equal to "{profile_id}" Then the value response with jq ".eabSecret" should be present + And I memorize response with jq ".eabKid" as eab_kid + And I memorize response with jq ".eabSecret" as eab_secret + When I have an ACME client connecting to {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory + Then I register a new ACME account with email fangpen@infisical.com and EAB key id {eab_kid} with secret {eab_secret} as acme_account From a5363cb7ea40773f6908d5478d454cb09dbb22c4 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Mon, 3 Nov 2025 15:26:31 -0800 Subject: [PATCH 156/231] Implement EAB verification --- backend/bdd/features/steps/pki_acme.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/backend/bdd/features/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py index 855195ac5..c78c45ede 100644 --- a/backend/bdd/features/steps/pki_acme.py +++ b/backend/bdd/features/steps/pki_acme.py @@ -220,9 +220,20 @@ def step_impl(context: Context): "I register a new ACME account with email {email} and EAB key id {kid} with secret {secret} as {account_var}" ) def step_impl(context: Context, email: str, kid: str, secret: str, account_var: str): - # TODO: add EAB info here - registration = messages.NewRegistration.from_data(email=email) - context.vars[account_var] = context.acme_client.new_account(registration) + acme_client = context.acme_client + account_public_key = acme_client.net.key.public_key() + eab = messages.ExternalAccountBinding.from_data( + account_public_key=account_public_key, + kid=kid, + hmac_key=secret, + directory=acme_client.directory, + hmac_alg="HS256", + ) + registration = messages.NewRegistration.from_data( + email=email, + external_account_binding=eab, + ) + context.vars[account_var] = acme_client.new_account(registration) @then( From fe55483eeb877928f8b9998a50f35208041955d5 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Mon, 3 Nov 2025 15:29:38 -0800 Subject: [PATCH 157/231] Implement eab verification --- .../ee/services/pki-acme/pki-acme-errors.ts | 21 +++++++ .../ee/services/pki-acme/pki-acme-schemas.ts | 8 +-- .../ee/services/pki-acme/pki-acme-service.ts | 58 +++++++++++++++++-- 3 files changed, 74 insertions(+), 13 deletions(-) diff --git a/backend/src/ee/services/pki-acme/pki-acme-errors.ts b/backend/src/ee/services/pki-acme/pki-acme-errors.ts index 73f17e061..329cb1989 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-errors.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-errors.ts @@ -549,3 +549,24 @@ export class AcmeBadCSRError extends AcmeError { this.name = "AcmeBadCSRError"; } } + +export class AcmeExternalAccountRequiredError extends AcmeError { + constructor({ + detail = "External account binding is required", + error, + message + }: { + detail?: string; + error?: unknown; + message?: string; + } = {}) { + super({ + type: AcmeErrorType.ExternalAccountRequired, + detail, + status: 400, + error, + message + }); + this.name = "AcmeExternalAccountRequiredError"; + } +} diff --git a/backend/src/ee/services/pki-acme/pki-acme-schemas.ts b/backend/src/ee/services/pki-acme/pki-acme-schemas.ts index 7298dd944..1d2f95fdf 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-schemas.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-schemas.ts @@ -66,13 +66,7 @@ export const CreateAcmeAccountBodySchema = z.object({ contact: z.array(z.string()).optional(), termsOfServiceAgreed: z.boolean().optional(), onlyReturnExisting: z.boolean().optional(), - externalAccountBinding: z - .object({ - protected: z.string(), - payload: z.string(), - signature: z.string() - }) - .optional() + externalAccountBinding: RawJwsPayloadSchema.optional() }); // New Account endpoint 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 a478a1721..16d917fae 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -2,7 +2,7 @@ import { TPkiAcmeAccounts } from "@app/db/schemas/pki-acme-accounts"; import { TPkiAcmeAuths } from "@app/db/schemas/pki-acme-auths"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; -import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { BadRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; @@ -12,6 +12,9 @@ import { TCertificateProfileWithConfigs } from "@app/services/certificate-profile/certificate-profile-types"; import { TCertificateV3ServiceFactory } from "@app/services/certificate-v3/certificate-v3-service"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; import { calculateJwkThumbprint, errors, @@ -29,6 +32,7 @@ import { AcmeBadCSRError, AcmeBadPublicKeyError, AcmeError, + AcmeExternalAccountRequiredError, AcmeMalformedError, AcmeOrderNotReadyError, AcmeServerInternalError, @@ -67,8 +71,8 @@ import { } from "./pki-acme-types"; type TPkiAcmeServiceFactoryDep = { - certificateProfileDAL: Pick; - certificateV3Service: Pick; + projectDAL: Pick; + certificateProfileDAL: Pick; acmeAccountDAL: Pick< TPkiAcmeAccountDALFactory, "findByProjectIdAndAccountId" | "findByProfileIdAndPublicKeyThumbprintAndAlg" | "create" @@ -83,21 +87,25 @@ type TPkiAcmeServiceFactoryDep = { TPkiAcmeChallengeDALFactory, "create" | "transaction" | "updateById" | "findByAccountAuthAndChallengeId" | "findByIdForChallengeValidation" >; + kmsService: Pick; + certificateV3Service: Pick; acmeChallengeService: TPkiAcmeChallengeServiceFactory; }; export const pkiAcmeServiceFactory = ({ + projectDAL, certificateProfileDAL, - certificateV3Service, acmeAccountDAL, acmeOrderDAL, acmeAuthDAL, acmeOrderAuthDAL, acmeChallengeDAL, + kmsService, + certificateV3Service, acmeChallengeService }: TPkiAcmeServiceFactoryDep): TPkiAcmeServiceFactory => { const validateAcmeProfile = async (profileId: string): Promise => { - const profile = await certificateProfileDAL.findById(profileId); + const profile = await certificateProfileDAL.findByIdWithConfigs(profileId); if (!profile) { throw new NotFoundError({ message: "Certificate profile not found" }); } @@ -304,7 +312,7 @@ export const pkiAcmeServiceFactory = ({ profileId, alg, jwk, - payload: { onlyReturnExisting, contact } + payload: { onlyReturnExisting, contact, externalAccountBinding } }: { profileId: string; alg: string; @@ -312,6 +320,44 @@ export const pkiAcmeServiceFactory = ({ payload: TCreateAcmeAccountPayload; }): Promise> => { const profile = await validateAcmeProfile(profileId); + if (!externalAccountBinding) { + throw new AcmeExternalAccountRequiredError({ detail: "External account binding is required" }); + } + + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId: profile.projectId, + projectDAL, + kmsService + }); + + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + const eabSecret = await kmsDecryptor({ cipherTextBlob: profile.acmeConfig!.encryptedEabSecret }); + const encodedSecret = new TextEncoder().encode(eabSecret.toString()); + try { + const { payload: eabPayload, protectedHeader: eabProtectedHeader } = await flattenedVerify( + externalAccountBinding, + encodedSecret + ); + const alg = eabProtectedHeader!.alg!; + if (!["HS256", "HS384", "HS512"].includes(alg)) { + throw new AcmeMalformedError({ detail: "Invalid algorithm for external account binding JWS payload" }); + } + if ((eabPayload as unknown as { kid: string }).kid !== profile.id) { + throw new UnauthorizedError({ message: "External account binding KID mismatch" }); + } + } catch (error) { + if (error instanceof errors.JWSInvalid) { + throw new AcmeMalformedError({ detail: "Invalid external account binding JWS payload" }); + } + if (error instanceof AcmeError) { + throw error; + } + logger.error(error, "Unexpected error while verifying EAB JWS payload"); + throw new AcmeServerInternalError({ detail: "Failed to verify EAB JWS payload" }); + } + const publicKeyThumbprint = await calculateJwkThumbprint(jwk, "sha256"); const existingAccount: TPkiAcmeAccounts | null = await acmeAccountDAL.findByProfileIdAndPublicKeyThumbprintAndAlg( profileId, From a8cd3619bd8beb81cb169969e2248adf1b4017aa Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Mon, 3 Nov 2025 15:34:09 -0800 Subject: [PATCH 158/231] Add quote --- backend/bdd/features/pki/acme/account.feature | 2 +- backend/bdd/features/pki/acme/auth.feature | 4 ++-- .../bdd/features/pki/acme/cert-profile.feature | 4 ++-- backend/bdd/features/pki/acme/challenge.feature | 2 +- backend/bdd/features/pki/acme/order.feature | 16 ++++++++-------- backend/bdd/features/steps/pki_acme.py | 2 +- 6 files changed, 15 insertions(+), 15 deletions(-) diff --git a/backend/bdd/features/pki/acme/account.feature b/backend/bdd/features/pki/acme/account.feature index 1a291bfdb..463e6b04f 100644 --- a/backend/bdd/features/pki/acme/account.feature +++ b/backend/bdd/features/pki/acme/account.feature @@ -2,4 +2,4 @@ Feature: Account Scenario: Create a new account Given I have an ACME cert profile as "acme_profile" When I have an ACME client connecting to {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory - Then I register a new ACME account with email fangpen@infisical.com and EAB key id {acme_profile.eab_kid} with secret {acme_profile.eab_secret} as acme_account + Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account diff --git a/backend/bdd/features/pki/acme/auth.feature b/backend/bdd/features/pki/acme/auth.feature index dd824d18f..b70599b66 100644 --- a/backend/bdd/features/pki/acme/auth.feature +++ b/backend/bdd/features/pki/acme/auth.feature @@ -4,7 +4,7 @@ Feature: Authorization Given I have an ACME cert profile as "acme_profile" When I have an ACME client connecting to {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory # # TODO: make it I have an account already instead? - Then I register a new ACME account with email fangpen@infisical.com and EAB key id {acme_profile.eab_kid} with secret {acme_profile.eab_secret} as acme_account + Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account When I create certificate signing request as csr Then I add names to certificate signing request csr """ @@ -16,7 +16,7 @@ Feature: Authorization Then I create a RSA private key pair as cert_key Then I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format Then I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order - Then the value order.authorizations[0].uri with jq . should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/authorizations/(.+) + Then the value order.authorizations[0].uri with jq "." should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/authorizations/(.+) Then the value order.authorizations[0].body with jq ".status" should be equal to "pending" Then the value order.authorizations[0].body with jq ".challenges | map(pick(.type, .status)) | sort_by(.type)" should be equal to json """ diff --git a/backend/bdd/features/pki/acme/cert-profile.feature b/backend/bdd/features/pki/acme/cert-profile.feature index e090d0581..7ce1ecc8c 100644 --- a/backend/bdd/features/pki/acme/cert-profile.feature +++ b/backend/bdd/features/pki/acme/cert-profile.feature @@ -45,5 +45,5 @@ Feature: ACME Cert Profile Then the value response with jq ".eabSecret" should be present And I memorize response with jq ".eabKid" as eab_kid And I memorize response with jq ".eabSecret" as eab_secret - When I have an ACME client connecting to {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory - Then I register a new ACME account with email fangpen@infisical.com and EAB key id {eab_kid} with secret {eab_secret} as acme_account + When I have an ACME client connecting to {BASE_URL}/api/v1/pki/acme/profiles/{profile_id}/directory + Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{eab_kid}" with secret "{eab_secret}" as acme_account diff --git a/backend/bdd/features/pki/acme/challenge.feature b/backend/bdd/features/pki/acme/challenge.feature index 960842ebf..a5d94aff5 100644 --- a/backend/bdd/features/pki/acme/challenge.feature +++ b/backend/bdd/features/pki/acme/challenge.feature @@ -4,7 +4,7 @@ Feature: Challenge Given I have an ACME cert profile as "acme_profile" When I have an ACME client connecting to {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory # # TODO: make it I have an account already instead? - Then I register a new ACME account with email fangpen@infisical.com and EAB key id {acme_profile.eab_kid} with secret {acme_profile.eab_secret} as acme_account + Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account When I create certificate signing request as csr Then I add names to certificate signing request csr """ diff --git a/backend/bdd/features/pki/acme/order.feature b/backend/bdd/features/pki/acme/order.feature index 1ba2c3570..49f72e485 100644 --- a/backend/bdd/features/pki/acme/order.feature +++ b/backend/bdd/features/pki/acme/order.feature @@ -4,7 +4,7 @@ Feature: Order Given I have an ACME cert profile as "acme_profile" When I have an ACME client connecting to {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory # # TODO: make it I have an account already instead? - Then I register a new ACME account with email fangpen@infisical.com and EAB key id {acme_profile.eab_kid} with secret {acme_profile.eab_secret} as acme_account + Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account When I create certificate signing request as csr Then I add names to certificate signing request csr """ @@ -16,17 +16,17 @@ Feature: Order Then I create a RSA private key pair as cert_key Then I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format Then I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order - Then the value order.uri with jq . should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/orders/(.+) - Then the value order.body with jq .status should be equal to "pending" - Then the value order.body with jq .identifiers should be equal to [{"type": "dns", "value": "localhost"}] - Then the value order.body with jq .finalize should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/orders/(.+)/finalize - Then the value order.body with jq all(.authorizations[]; startswith("{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/authorizations/")) should be equal to true + Then the value order.uri with jq "." should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/orders/(.+) + Then the value order.body with jq ".status" should be equal to "pending" + Then the value order.body with jq ".identifiers" should be equal to [{"type": "dns", "value": "localhost"}] + Then the value order.body with jq ".finalize" should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/orders/(.+)/finalize + Then the value order.body with jq "all(.authorizations[]; startswith('{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/authorizations/'))" should be equal to true Scenario: Create a new order with SANs Given I have an ACME cert profile as "acme_profile" When I have an ACME client connecting to {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory # # TODO: make it I have an account already instead? - Then I register a new ACME account with email fangpen@infisical.com and EAB key id {acme_profile.eab_kid} with secret {acme_profile.eab_secret} as acme_account + Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account When I create certificate signing request as csr Then I add names to certificate signing request csr """ @@ -58,7 +58,7 @@ Feature: Order Given I have an ACME cert profile as "acme_profile" When I have an ACME client connecting to {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory # # TODO: make it I have an account already instead? - Then I register a new ACME account with email fangpen@infisical.com and EAB key id {acme_profile.eab_kid} with secret {acme_profile.eab_secret} as acme_account + Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account When I create certificate signing request as csr Then I add names to certificate signing request csr """ diff --git a/backend/bdd/features/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py index c78c45ede..167eab20d 100644 --- a/backend/bdd/features/steps/pki_acme.py +++ b/backend/bdd/features/steps/pki_acme.py @@ -217,7 +217,7 @@ def step_impl(context: Context): @then( - "I register a new ACME account with email {email} and EAB key id {kid} with secret {secret} as {account_var}" + 'I register a new ACME account with email {email} and EAB key id "{kid}" with secret "{secret}" as {account_var}' ) def step_impl(context: Context, email: str, kid: str, secret: str, account_var: str): acme_client = context.acme_client From ce3d70c30a459ee3395216ad02a98ea36ed10940 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Mon, 3 Nov 2025 15:36:04 -0800 Subject: [PATCH 159/231] Replace secret and kid --- backend/bdd/features/steps/pki_acme.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/bdd/features/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py index 167eab20d..e005803da 100644 --- a/backend/bdd/features/steps/pki_acme.py +++ b/backend/bdd/features/steps/pki_acme.py @@ -224,8 +224,8 @@ def step_impl(context: Context, email: str, kid: str, secret: str, account_var: account_public_key = acme_client.net.key.public_key() eab = messages.ExternalAccountBinding.from_data( account_public_key=account_public_key, - kid=kid, - hmac_key=secret, + kid=replace_vars(kid, context.vars), + hmac_key=replace_vars(secret, context.vars), directory=acme_client.directory, hmac_alg="HS256", ) From e7f098e63ebc61ae44cf2802e5f76737e839c5f7 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Mon, 3 Nov 2025 16:07:19 -0800 Subject: [PATCH 160/231] Implement more checks --- .../ee/services/pki-acme/pki-acme-service.ts | 30 +++++++++++++++---- .../certificate-profile-service.ts | 2 +- 2 files changed, 25 insertions(+), 7 deletions(-) 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 16d917fae..930e22872 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -324,26 +324,45 @@ export const pkiAcmeServiceFactory = ({ throw new AcmeExternalAccountRequiredError({ detail: "External account binding is required" }); } + const publicKeyThumbprint = await calculateJwkThumbprint(jwk, "sha256"); const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ projectId: profile.projectId, projectDAL, kmsService }); - const kmsDecryptor = await kmsService.decryptWithKmsKey({ kmsId: certificateManagerKmsId }); const eabSecret = await kmsDecryptor({ cipherTextBlob: profile.acmeConfig!.encryptedEabSecret }); - const encodedSecret = new TextEncoder().encode(eabSecret.toString()); try { const { payload: eabPayload, protectedHeader: eabProtectedHeader } = await flattenedVerify( externalAccountBinding, - encodedSecret + eabSecret ); - const alg = eabProtectedHeader!.alg!; - if (!["HS256", "HS384", "HS512"].includes(alg)) { + const eabAlg = eabProtectedHeader!.alg!; + if (!["HS256", "HS384", "HS512"].includes(eabAlg)) { throw new AcmeMalformedError({ detail: "Invalid algorithm for external account binding JWS payload" }); } + // Make sure the URL matches the expected URL + const url = eabProtectedHeader!.url!; + if (url !== buildUrl(profile.id, "/new-account")) { + throw new UnauthorizedError({ message: "External account binding URL mismatch" }); + } + + // Make sure the JWK in the EAB payload matches the one provided in the outer JWS payload + const decoder = new TextDecoder(); + const decodedEabPayload = decoder.decode(eabPayload); + const eabPayloadJson = JSON.parse(decodedEabPayload); + const eabPayloadJwkThumbprint = await calculateJwkThumbprint( + eabPayloadJson.jwk as JsonWebKey, + alg as "sha256" | "sha384" | "sha512" + ); + if (eabPayloadJwkThumbprint !== publicKeyThumbprint || eabAlg !== alg) { + throw new AcmeBadPublicKeyError({ + message: "External account binding public key thumbprint or algorithm mismatch" + }); + } + // Make sure the KID in the EAB payload matches the profile ID if ((eabPayload as unknown as { kid: string }).kid !== profile.id) { throw new UnauthorizedError({ message: "External account binding KID mismatch" }); } @@ -358,7 +377,6 @@ export const pkiAcmeServiceFactory = ({ throw new AcmeServerInternalError({ detail: "Failed to verify EAB JWS payload" }); } - const publicKeyThumbprint = await calculateJwkThumbprint(jwk, "sha256"); const existingAccount: TPkiAcmeAccounts | null = await acmeAccountDAL.findByProfileIdAndPublicKeyThumbprintAndAlg( profileId, alg, diff --git a/backend/src/services/certificate-profile/certificate-profile-service.ts b/backend/src/services/certificate-profile/certificate-profile-service.ts index 2e92c4b02..be7a0a253 100644 --- a/backend/src/services/certificate-profile/certificate-profile-service.ts +++ b/backend/src/services/certificate-profile/certificate-profile-service.ts @@ -832,7 +832,7 @@ export const certificateProfileServiceFactory = ({ kmsId: certificateManagerKmsId }); const eabSecret = await kmsDecryptor({ cipherTextBlob: profile.acmeConfig.encryptedEabSecret }); - return { eabKid: profile.id, eabSecret: eabSecret.toString() }; + return { eabKid: profile.id, eabSecret: eabSecret.toString("base64url") }; }; return { From 72717cdc584aa4224a025ddc66b420db0858c5c1 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Mon, 3 Nov 2025 16:10:04 -0800 Subject: [PATCH 161/231] Fix EAB cal --- backend/src/ee/services/pki-acme/pki-acme-service.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) 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 930e22872..2dee5e7f1 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -352,11 +352,8 @@ export const pkiAcmeServiceFactory = ({ // Make sure the JWK in the EAB payload matches the one provided in the outer JWS payload const decoder = new TextDecoder(); const decodedEabPayload = decoder.decode(eabPayload); - const eabPayloadJson = JSON.parse(decodedEabPayload); - const eabPayloadJwkThumbprint = await calculateJwkThumbprint( - eabPayloadJson.jwk as JsonWebKey, - alg as "sha256" | "sha384" | "sha512" - ); + const eabJWK = JSON.parse(decodedEabPayload); + const eabPayloadJwkThumbprint = await calculateJwkThumbprint(eabJWK, "sha256"); if (eabPayloadJwkThumbprint !== publicKeyThumbprint || eabAlg !== alg) { throw new AcmeBadPublicKeyError({ message: "External account binding public key thumbprint or algorithm mismatch" From 4d9833acd6a0162b5ee983be1b5c8faa7b294a4f Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Mon, 3 Nov 2025 16:18:49 -0800 Subject: [PATCH 162/231] Not same type of alg --- backend/src/ee/services/pki-acme/pki-acme-service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 2dee5e7f1..498537c82 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -354,7 +354,7 @@ export const pkiAcmeServiceFactory = ({ const decodedEabPayload = decoder.decode(eabPayload); const eabJWK = JSON.parse(decodedEabPayload); const eabPayloadJwkThumbprint = await calculateJwkThumbprint(eabJWK, "sha256"); - if (eabPayloadJwkThumbprint !== publicKeyThumbprint || eabAlg !== alg) { + if (eabPayloadJwkThumbprint !== publicKeyThumbprint) { throw new AcmeBadPublicKeyError({ message: "External account binding public key thumbprint or algorithm mismatch" }); From 5256b11402cd74dc74b101c3d1a8d67200dbd5a9 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Mon, 3 Nov 2025 16:21:21 -0800 Subject: [PATCH 163/231] Fix KID match --- .../src/ee/services/pki-acme/pki-acme-service.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) 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 498537c82..cc3df7ce7 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -339,10 +339,15 @@ export const pkiAcmeServiceFactory = ({ externalAccountBinding, eabSecret ); - const eabAlg = eabProtectedHeader!.alg!; - if (!["HS256", "HS384", "HS512"].includes(eabAlg)) { + const { alg: eabAlg, kid: eabKid } = eabProtectedHeader!; + if (!["HS256", "HS384", "HS512"].includes(eabAlg!)) { throw new AcmeMalformedError({ detail: "Invalid algorithm for external account binding JWS payload" }); } + // Make sure the KID in the EAB payload matches the profile ID + if (eabKid !== profile.id) { + throw new UnauthorizedError({ message: "External account binding KID mismatch" }); + } + // Make sure the URL matches the expected URL const url = eabProtectedHeader!.url!; if (url !== buildUrl(profile.id, "/new-account")) { @@ -359,10 +364,6 @@ export const pkiAcmeServiceFactory = ({ message: "External account binding public key thumbprint or algorithm mismatch" }); } - // Make sure the KID in the EAB payload matches the profile ID - if ((eabPayload as unknown as { kid: string }).kid !== profile.id) { - throw new UnauthorizedError({ message: "External account binding KID mismatch" }); - } } catch (error) { if (error instanceof errors.JWSInvalid) { throw new AcmeMalformedError({ detail: "Invalid external account binding JWS payload" }); From 393fa0157094380726815a01ee3cd8642c4865f8 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Mon, 3 Nov 2025 16:23:12 -0800 Subject: [PATCH 164/231] Add deps --- backend/src/server/routes/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 13a5aee2c..9af799ba6 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -2199,13 +2199,15 @@ export const registerRoutes = async ( acmeChallengeDAL }); const pkiAcmeService = pkiAcmeServiceFactory({ - certificateV3Service, + projectDAL, certificateProfileDAL, acmeAccountDAL, acmeOrderDAL, acmeAuthDAL, acmeOrderAuthDAL, acmeChallengeDAL, + kmsService, + certificateV3Service, acmeChallengeService }); From 59ce408ec13c81e7e286a7cd324a1ea4b6bbeaea Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Mon, 3 Nov 2025 16:30:56 -0800 Subject: [PATCH 165/231] Reveal --- .../context/ProjectPermissionContext/types.ts | 3 ++- .../CertificateProfilesTab.tsx | 13 ++++++++- .../CertificateProfilesTab/ProfileList.tsx | 8 +++++- .../CertificateProfilesTab/ProfileRow.tsx | 27 +++++++++++++++++-- 4 files changed, 46 insertions(+), 5 deletions(-) diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index 0236113eb..b6fd85f44 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -129,7 +129,8 @@ export enum ProjectPermissionCertificateProfileActions { Create = "create", Edit = "edit", Delete = "delete", - IssueCert = "issue-cert" + IssueCert = "issue-cert", + RevealAcmeEabSecret = "reveal-acme-eab-secret" } export enum ProjectPermissionSecretRotationActions { diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CertificateProfilesTab.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CertificateProfilesTab.tsx index 7938d04f5..974b49279 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CertificateProfilesTab.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CertificateProfilesTab.tsx @@ -23,6 +23,8 @@ export const CertificateProfilesTab = () => { const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isEditModalOpen, setIsEditModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isRevealProfileAcmeEabSecretModalOpen, setIsRevealProfileAcmeEabSecretModalOpen] = + useState(false); const [selectedProfile, setSelectedProfile] = useState( null ); @@ -43,6 +45,11 @@ export const CertificateProfilesTab = () => { setIsEditModalOpen(true); }; + const handleRevealProfileAcmeEabSecret = (profile: TCertificateProfileWithDetails) => { + setSelectedProfile(profile); + setIsRevealProfileAcmeEabSecretModalOpen(true); + }; + const handleDeleteProfile = (profile: TCertificateProfileWithDetails) => { setSelectedProfile(profile); setIsDeleteModalOpen(true); @@ -85,7 +92,11 @@ export const CertificateProfilesTab = () => { )}
- + setIsCreateModalOpen(false)} /> diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileList.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileList.tsx index b3fc0e22f..7b1ac187a 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileList.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileList.tsx @@ -20,9 +20,14 @@ import { ProfileRow } from "./ProfileRow"; interface Props { onEditProfile: (profile: TCertificateProfileWithDetails) => void; onDeleteProfile: (profile: TCertificateProfileWithDetails) => void; + onRevealProfileAcmeEabSecret: (profile: TCertificateProfileWithDetails) => void; } -export const ProfileList = ({ onEditProfile, onDeleteProfile }: Props) => { +export const ProfileList = ({ + onEditProfile, + onRevealProfileAcmeEabSecret, + onDeleteProfile +}: Props) => { const { currentProject } = useProject(); const { data, isLoading } = useListCertificateProfiles({ @@ -88,6 +93,7 @@ export const ProfileList = ({ onEditProfile, onDeleteProfile }: Props) => { key={profile.id} profile={profile} onEditProfile={onEditProfile} + onRevealProfileAcmeEabSecret={onRevealProfileAcmeEabSecret} onDeleteProfile={onDeleteProfile} /> ))} diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileRow.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileRow.tsx index 19638483b..222800364 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileRow.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileRow.tsx @@ -1,14 +1,15 @@ -import { useCallback } from "react"; import { faCheck, faCircleInfo, faCopy, faEdit, faEllipsis, + faEye, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useCallback } from "react"; import { createNotification } from "@app/components/notifications"; import { @@ -36,10 +37,16 @@ import { CertificateIssuanceModal } from "@app/pages/cert-manager/CertificatesPa interface Props { profile: TCertificateProfile; onEditProfile: (profile: TCertificateProfile) => void; + onRevealProfileAcmeEabSecret: (profile: TCertificateProfile) => void; onDeleteProfile: (profile: TCertificateProfile) => void; } -export const ProfileRow = ({ profile, onEditProfile, onDeleteProfile }: Props) => { +export const ProfileRow = ({ + profile, + onEditProfile, + onRevealProfileAcmeEabSecret, + onDeleteProfile +}: Props) => { const { permission } = useProjectPermission(); const { data: caData } = useGetCaById(profile.caId); @@ -69,6 +76,11 @@ export const ProfileRow = ({ profile, onEditProfile, onDeleteProfile }: Props) = ProjectPermissionSub.CertificateAuthorities ); + const canRevealProfileAcmeEabSecret = permission.can( + ProjectPermissionCertificateProfileActions.RevealAcmeEabSecret, + ProjectPermissionSub.CertificateProfiles + ); + const canIssueCertificate = permission.can( ProjectPermissionCertificateProfileActions.IssueCert, ProjectPermissionSub.CertificateProfiles @@ -144,6 +156,17 @@ export const ProfileRow = ({ profile, onEditProfile, onDeleteProfile }: Props) = Edit Profile )} + {canRevealProfileAcmeEabSecret && profile.enrollmentType === "acme" && ( + { + e.stopPropagation(); + onRevealProfileAcmeEabSecret(profile); + }} + icon={} + > + Reveal ACME EAB Secret + + )} {canIssueCertificate && profile.enrollmentType === "api" && ( { From ab47ef9e6e6db35e7734da4948542c2ac4cdca75 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Mon, 3 Nov 2025 16:37:44 -0800 Subject: [PATCH 166/231] Reveal --- .../CertificateProfilesTab.tsx | 12 ++++++++- .../RevealAcmeEabSecretModal.tsx | 25 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/RevealAcmeEabSecretModal.tsx diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CertificateProfilesTab.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CertificateProfilesTab.tsx index 974b49279..9ee238743 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CertificateProfilesTab.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CertificateProfilesTab.tsx @@ -1,6 +1,6 @@ -import { useState } from "react"; import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useState } from "react"; import { createNotification } from "@app/components/notifications"; import { Button, DeleteActionModal } from "@app/components/v2"; @@ -16,6 +16,7 @@ import { import { CreateProfileModal } from "./CreateProfileModal"; import { ProfileList } from "./ProfileList"; +import { RevealAcmeEabSecretModal } from "./RevealAcmeEabSecretModal"; export const CertificateProfilesTab = () => { const { permission } = useProjectPermission(); @@ -112,6 +113,15 @@ export const CertificateProfilesTab = () => { mode="edit" /> + { + setIsRevealProfileAcmeEabSecretModalOpen(false); + setSelectedProfile(null); + }} + profile={selectedProfile} + /> + void; + profile: TCertificateProfileWithDetails; +}; + +export const RevealAcmeEabSecretModal = ({ isOpen, onClose, profile }: Props) => { + return ( + { + if (!open) { + onClose(); + } + }} + > + +
Reveal ACME EAB Secret
+
+
+ ); +}; From f2fa429458b717f4449a519e95af3cf94d71eda2 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Mon, 3 Nov 2025 17:01:08 -0800 Subject: [PATCH 167/231] Improve UI --- .../CertificateProfilesTab/ProfileRow.tsx | 2 +- .../RevealAcmeEabSecretModal.tsx | 78 ++++++++++++++++++- 2 files changed, 76 insertions(+), 4 deletions(-) diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileRow.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileRow.tsx index 222800364..2a06063c4 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileRow.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileRow.tsx @@ -164,7 +164,7 @@ export const ProfileRow = ({ }} icon={} > - Reveal ACME EAB Secret + Reveal EAB Secret
)} {canIssueCertificate && profile.enrollmentType === "api" && ( diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/RevealAcmeEabSecretModal.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/RevealAcmeEabSecretModal.tsx index 9596dd37c..076f36fb3 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/RevealAcmeEabSecretModal.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/RevealAcmeEabSecretModal.tsx @@ -1,5 +1,8 @@ -import { Modal, ModalContent } from "@app/components/v2"; +import { FormLabel, IconButton, Input, Modal, ModalContent } from "@app/components/v2"; +import { useToggle } from "@app/hooks"; import { TCertificateProfileWithDetails } from "@app/hooks/api/certificateProfiles"; +import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; type Props = { isOpen: boolean; @@ -8,6 +11,13 @@ type Props = { }; export const RevealAcmeEabSecretModal = ({ isOpen, onClose, profile }: Props) => { + const [isAcmeDirectoryUrlCopied, setIsAcmeDirectoryUrlCopied] = useToggle(false); + const [isEabKidCopied, setIsEabKidCopied] = useToggle(false); + const [isEabSecretCopied, setIsEabSecretCopied] = useToggle(false); + + const acmeDirectoryUrl = "http://FIXME.com/directory"; + const eabKid = profile.id; + const eabSecret = "FIXME"; return ( } }} > - -
Reveal ACME EAB Secret
+ + + +
+ { + navigator.clipboard.writeText(acmeDirectoryUrl); + setIsAcmeDirectoryUrlCopied.on(); + }} + className="w-10" + > + + +
+ + +
+ + { + navigator.clipboard.writeText(eabKid); + setIsEabKidCopied.on(); + }} + className="w-10" + > + + +
+ + +
+ + { + navigator.clipboard.writeText(eabSecret); + setIsEabSecretCopied.on(); + }} + className="w-10" + > + + +
); From 01a2871a6ebb39a4c42380fa5ae95fd9a1eab3d9 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Mon, 3 Nov 2025 17:45:09 -0800 Subject: [PATCH 168/231] UI adjustment --- .../CertificateProfilesTab/RevealAcmeEabSecretModal.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/RevealAcmeEabSecretModal.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/RevealAcmeEabSecretModal.tsx index 076f36fb3..31ccb1d9b 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/RevealAcmeEabSecretModal.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/RevealAcmeEabSecretModal.tsx @@ -29,7 +29,7 @@ export const RevealAcmeEabSecretModal = ({ isOpen, onClose, profile }: Props) => >
@@ -74,7 +74,7 @@ export const RevealAcmeEabSecretModal = ({ isOpen, onClose, profile }: Props) =>
From c928b6abf715333b35538038a078991ec591d104 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Mon, 3 Nov 2025 17:47:57 -0800 Subject: [PATCH 169/231] ui --- .../CertificateProfilesTab/RevealAcmeEabSecretModal.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/RevealAcmeEabSecretModal.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/RevealAcmeEabSecretModal.tsx index 31ccb1d9b..729b3e896 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/RevealAcmeEabSecretModal.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/RevealAcmeEabSecretModal.tsx @@ -33,10 +33,10 @@ export const RevealAcmeEabSecretModal = ({ isOpen, onClose, profile }: Props) => > -
+
From e931d1936fce9f5040122a54c4e446e442e4bac1 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Mon, 3 Nov 2025 18:07:04 -0800 Subject: [PATCH 170/231] Return directory url --- .../certificate-profile-service.ts | 15 +++++++++++++-- .../certificate-profile-types.ts | 4 ++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/backend/src/services/certificate-profile/certificate-profile-service.ts b/backend/src/services/certificate-profile/certificate-profile-service.ts index be7a0a253..f1cee1761 100644 --- a/backend/src/services/certificate-profile/certificate-profile-service.ts +++ b/backend/src/services/certificate-profile/certificate-profile-service.ts @@ -31,6 +31,7 @@ import { TCertificateProfileWithConfigs } from "./certificate-profile-types"; import { TAcmeEnrollmentConfigDALFactory } from "../enrollment-config/acme-enrollment-config-dal"; +import { buildUrl } from "@app/ee/services/pki-acme/pki-acme-fns"; const generateAndEncryptAcmeEabSecret = async ( projectId: string, @@ -484,6 +485,12 @@ export const certificateProfileServiceFactory = ({ profile.estConfig.caChain = ""; } } + if (profile.enrollmentType === EnrollmentType.ACME && profile.acmeConfig) { + profile.acmeConfig.directoryUrl = buildUrl(profile.id, "/directory"); + if (profile.acmeConfig.encryptedEabSecret) { + profile.acmeConfig.encryptedEabSecret = undefined; + } + } return { ...profile, @@ -619,7 +626,11 @@ export const certificateProfileServiceFactory = ({ const result: TCertificateProfileWithConfigs = { ...converted, estConfig: decryptedEstConfig, - apiConfig: profileWithConfigs.apiConfig + apiConfig: profileWithConfigs.apiConfig, + acmeConfig: + profile.enrollmentType === EnrollmentType.ACME + ? { id: profile.id, directoryUrl: buildUrl(profile.id, "/directory") } + : undefined }; return result; @@ -831,7 +842,7 @@ export const certificateProfileServiceFactory = ({ const kmsDecryptor = await kmsService.decryptWithKmsKey({ kmsId: certificateManagerKmsId }); - const eabSecret = await kmsDecryptor({ cipherTextBlob: profile.acmeConfig.encryptedEabSecret }); + const eabSecret = await kmsDecryptor({ cipherTextBlob: profile.acmeConfig.encryptedEabSecret! }); return { eabKid: profile.id, eabSecret: eabSecret.toString("base64url") }; }; diff --git a/backend/src/services/certificate-profile/certificate-profile-types.ts b/backend/src/services/certificate-profile/certificate-profile-types.ts index 8a89b4d0d..82ba0f378 100644 --- a/backend/src/services/certificate-profile/certificate-profile-types.ts +++ b/backend/src/services/certificate-profile/certificate-profile-types.ts @@ -58,9 +58,9 @@ export type TCertificateProfileWithConfigs = TCertificateProfile & { }; acmeConfig?: { id: string; - encryptedEabSecret: Buffer; + directoryUrl: string; + encryptedEabSecret?: Buffer; }; - metrics?: TCertificateProfileMetrics; }; export interface TCertificateProfileCertificate { From b5a555fee55e2b4b3b78e0dff5cb5b4974346241 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Mon, 3 Nov 2025 18:30:01 -0800 Subject: [PATCH 171/231] Expose directory url --- .../routes/v1/certificate-profiles-router.ts | 6 + .../certificate-profile-dal.ts | 17 +- .../certificate-profile-service.ts | 7 +- .../enrollment-config-types.ts | 4 +- .../hooks/api/certificateProfiles/queries.tsx | 22 ++- .../hooks/api/certificateProfiles/types.ts | 8 + .../RevealAcmeEabSecretModal.tsx | 152 ++++++++++-------- 7 files changed, 143 insertions(+), 73 deletions(-) diff --git a/backend/src/server/routes/v1/certificate-profiles-router.ts b/backend/src/server/routes/v1/certificate-profiles-router.ts index f85f8a677..7d4e04773 100644 --- a/backend/src/server/routes/v1/certificate-profiles-router.ts +++ b/backend/src/server/routes/v1/certificate-profiles-router.ts @@ -168,6 +168,12 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid autoRenew: z.boolean(), renewBeforeDays: z.number().optional() }) + .optional(), + acmeConfig: z + .object({ + id: z.string(), + directoryUrl: z.string() + }) .optional() }).array(), totalCount: z.number() diff --git a/backend/src/services/certificate-profile/certificate-profile-dal.ts b/backend/src/services/certificate-profile/certificate-profile-dal.ts index f8f511ab4..5296cb172 100644 --- a/backend/src/services/certificate-profile/certificate-profile-dal.ts +++ b/backend/src/services/certificate-profile/certificate-profile-dal.ts @@ -274,6 +274,11 @@ export const certificateProfileDALFactory = (db: TDbClient) => { `${TableName.PkiCertificateProfile}.apiConfigId`, `${TableName.PkiApiEnrollmentConfig}.id` ) + .leftJoin( + TableName.PkiAcmeEnrollmentConfig, + `${TableName.PkiCertificateProfile}.acmeConfigId`, + `${TableName.PkiAcmeEnrollmentConfig}.id` + ) .select(selectAllTableCols(TableName.PkiCertificateProfile)) .select( db.ref("id").withSchema(TableName.PkiEstEnrollmentConfig).as("estId"), @@ -285,7 +290,8 @@ export const certificateProfileDALFactory = (db: TDbClient) => { db.ref("encryptedCaChain").withSchema(TableName.PkiEstEnrollmentConfig).as("estEncryptedCaChain"), db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiId"), db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiAutoRenew"), - db.ref("renewBeforeDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiRenewBeforeDays") + db.ref("renewBeforeDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiRenewBeforeDays"), + db.ref("id").withSchema(TableName.PkiAcmeEnrollmentConfig).as("acmeId") ); const results = (await query @@ -312,6 +318,12 @@ export const certificateProfileDALFactory = (db: TDbClient) => { } : undefined; + const acmeConfig = result.acmeId + ? { + id: result.acmeId as string + } + : undefined; + const baseProfile = { id: result.id, projectId: result.projectId, @@ -325,7 +337,8 @@ export const certificateProfileDALFactory = (db: TDbClient) => { createdAt: result.createdAt, updatedAt: result.updatedAt, estConfig, - apiConfig + apiConfig, + acmeConfig }; return baseProfile as TCertificateProfileWithConfigs; diff --git a/backend/src/services/certificate-profile/certificate-profile-service.ts b/backend/src/services/certificate-profile/certificate-profile-service.ts index f1cee1761..8adbf2c45 100644 --- a/backend/src/services/certificate-profile/certificate-profile-service.ts +++ b/backend/src/services/certificate-profile/certificate-profile-service.ts @@ -627,10 +627,9 @@ export const certificateProfileServiceFactory = ({ ...converted, estConfig: decryptedEstConfig, apiConfig: profileWithConfigs.apiConfig, - acmeConfig: - profile.enrollmentType === EnrollmentType.ACME - ? { id: profile.id, directoryUrl: buildUrl(profile.id, "/directory") } - : undefined + acmeConfig: profileWithConfigs.acmeConfig + ? { ...profileWithConfigs.acmeConfig, directoryUrl: buildUrl(profile.id, "/directory") } + : undefined }; return result; diff --git a/backend/src/services/enrollment-config/enrollment-config-types.ts b/backend/src/services/enrollment-config/enrollment-config-types.ts index ff017e409..ed2f921c4 100644 --- a/backend/src/services/enrollment-config/enrollment-config-types.ts +++ b/backend/src/services/enrollment-config/enrollment-config-types.ts @@ -37,6 +37,4 @@ export interface TApiConfigData { renewBeforeDays?: number; } -export interface TAcmeConfigData { - eabSecret: string; -} +export interface TAcmeConfigData {} diff --git a/frontend/src/hooks/api/certificateProfiles/queries.tsx b/frontend/src/hooks/api/certificateProfiles/queries.tsx index 19859b02f..1e0fe3b9b 100644 --- a/frontend/src/hooks/api/certificateProfiles/queries.tsx +++ b/frontend/src/hooks/api/certificateProfiles/queries.tsx @@ -10,7 +10,8 @@ import { TGetProfileCertificatesDTO, TGetProfileMetricsDTO, TListCertificateProfilesDTO, - TProfileCertificate + TProfileCertificate, + TRevealAcmeEabSecretDTO } from "./types"; export const certificateProfileKeys = { @@ -41,6 +42,11 @@ export const certificateProfileKeys = { "metrics", profileId, params + ], + revealAcmeEabSecret: (profileId: string) => [ + "certificate-profiles", + "reveal-acme-eab-secret", + profileId ] }; @@ -112,6 +118,20 @@ export const useGetCertificateProfileBySlug = ({ }); }; +export const useRevealAcmeEabSecret = ({ profileId }: TRevealAcmeEabSecretDTO) => { + return useQuery({ + queryKey: certificateProfileKeys.revealAcmeEabSecret(profileId), + queryFn: async () => { + const { data } = await apiRequest.get<{ + eabKid: string; + eabSecret: string; + }>(`/api/v1/pki/certificate-profiles/${profileId}/acme/eab-secret/reveal`); + return data; + }, + enabled: Boolean(profileId) + }); +}; + export const useGetProfileCertificates = ({ profileId, offset = 0, diff --git a/frontend/src/hooks/api/certificateProfiles/types.ts b/frontend/src/hooks/api/certificateProfiles/types.ts index 94d8d0c6d..7acb6fef9 100644 --- a/frontend/src/hooks/api/certificateProfiles/types.ts +++ b/frontend/src/hooks/api/certificateProfiles/types.ts @@ -36,6 +36,10 @@ export type TCertificateProfileWithDetails = TCertificateProfile & { autoRenew: boolean; renewBeforeDays?: number; }; + acmeConfig?: { + id: string; + directoryUrl: string; + }; }; export type TCreateCertificateProfileDTO = { @@ -95,6 +99,10 @@ export type TGetCertificateProfileBySlugDTO = { slug: string; }; +export type TRevealAcmeEabSecretDTO = { + profileId: string; +}; + export type TProfileCertificate = { id: string; serialNumber: string; diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/RevealAcmeEabSecretModal.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/RevealAcmeEabSecretModal.tsx index 729b3e896..7156fc7e4 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/RevealAcmeEabSecretModal.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/RevealAcmeEabSecretModal.tsx @@ -1,6 +1,16 @@ -import { FormLabel, IconButton, Input, Modal, ModalContent } from "@app/components/v2"; +import { + Alert, + AlertDescription, + FormLabel, + IconButton, + Input, + Modal, + ModalContent, + Spinner +} from "@app/components/v2"; import { useToggle } from "@app/hooks"; import { TCertificateProfileWithDetails } from "@app/hooks/api/certificateProfiles"; +import { useRevealAcmeEabSecret } from "@app/hooks/api/certificateProfiles/queries"; import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; @@ -14,10 +24,12 @@ export const RevealAcmeEabSecretModal = ({ isOpen, onClose, profile }: Props) => const [isAcmeDirectoryUrlCopied, setIsAcmeDirectoryUrlCopied] = useToggle(false); const [isEabKidCopied, setIsEabKidCopied] = useToggle(false); const [isEabSecretCopied, setIsEabSecretCopied] = useToggle(false); + const revealAcmeEabSecret = useRevealAcmeEabSecret({ profileId: profile.id }); + const { data, isLoading, isError, error } = revealAcmeEabSecret; + + const { directoryUrl } = profile.acmeConfig!; + const { eabKid, eabSecret } = data ?? { eabKid: "", eabSecret: "" }; - const acmeDirectoryUrl = "http://FIXME.com/directory"; - const eabKid = profile.id; - const eabSecret = "FIXME"; return ( title="Reveal EAB Secret" subTitle="To issue certificates automatically, your ACME client needs the following details." > - -
- - { - navigator.clipboard.writeText(acmeDirectoryUrl); - setIsAcmeDirectoryUrlCopied.on(); - }} - className="w-10" - > - - -
+ {isLoading && ( +
+ +
+ )} + {isError && ( + + Failed to reveal EAB secret: {error.message} + + )} + {data && ( + <> + +
+ + { + navigator.clipboard.writeText(directoryUrl); + setIsAcmeDirectoryUrlCopied.on(); + }} + className="w-10" + > + + +
- -
- - { - navigator.clipboard.writeText(eabKid); - setIsEabKidCopied.on(); - }} - className="w-10" - > - - -
+ +
+ + { + navigator.clipboard.writeText(eabKid); + setIsEabKidCopied.on(); + }} + className="w-10" + > + + +
- -
- - { - navigator.clipboard.writeText(eabSecret); - setIsEabSecretCopied.on(); - }} - className="w-10" - > - - -
+ +
+ + { + navigator.clipboard.writeText(eabSecret); + setIsEabSecretCopied.on(); + }} + className="w-10" + > + + +
+ + )}
); From 431fc222a129cbba268b6b2a415aa743688dce5c Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Mon, 3 Nov 2025 18:33:02 -0800 Subject: [PATCH 172/231] Extract fns --- .../src/ee/services/pki-acme/pki-acme-fns.ts | 17 +++++++++++++++++ .../ee/services/pki-acme/pki-acme-service.ts | 16 +--------------- 2 files changed, 18 insertions(+), 15 deletions(-) create mode 100644 backend/src/ee/services/pki-acme/pki-acme-fns.ts diff --git a/backend/src/ee/services/pki-acme/pki-acme-fns.ts b/backend/src/ee/services/pki-acme/pki-acme-fns.ts new file mode 100644 index 000000000..0763a8ed0 --- /dev/null +++ b/backend/src/ee/services/pki-acme/pki-acme-fns.ts @@ -0,0 +1,17 @@ +import { getConfig } from "@app/lib/config/env"; +import { z } from "zod"; +import { AcmeMalformedError } from "./pki-acme-errors"; + +export const buildUrl = (profileId: string, path: string): string => { + const appCfg = getConfig(); + const baseUrl = appCfg.SITE_URL ?? ""; + return `${baseUrl}/api/v1/pki/acme/profiles/${profileId}${path}`; +}; + +export const extractAccountIdFromKid = (kid: string, profileId: string): string => { + const kidPrefix = buildUrl(profileId, "/accounts/"); + if (!kid.startsWith(kidPrefix)) { + throw new AcmeMalformedError({ detail: "KID must start with the profile account URL" }); + } + return z.string().uuid().parse(kid.slice(kidPrefix.length)); +}; 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 cc3df7ce7..43c77b3d1 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -1,6 +1,5 @@ import { TPkiAcmeAccounts } from "@app/db/schemas/pki-acme-accounts"; import { TPkiAcmeAuths } from "@app/db/schemas/pki-acme-auths"; -import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; @@ -39,6 +38,7 @@ import { AcmeUnauthorizedError, AcmeUnsupportedIdentifierError } from "./pki-acme-errors"; +import { buildUrl, extractAccountIdFromKid } from "./pki-acme-fns"; import { TPkiAcmeOrderAuthDALFactory } from "./pki-acme-order-auth-dal"; import { TPkiAcmeOrderDALFactory } from "./pki-acme-order-dal"; import { @@ -115,20 +115,6 @@ export const pkiAcmeServiceFactory = ({ return profile; }; - const buildUrl = (profileId: string, path: string): string => { - const appCfg = getConfig(); - const baseUrl = appCfg.SITE_URL ?? ""; - return `${baseUrl}/api/v1/pki/acme/profiles/${profileId}${path}`; - }; - - const extractAccountIdFromKid = (kid: string, profileId: string): string => { - const kidPrefix = buildUrl(profileId, "/accounts/"); - if (!kid.startsWith(kidPrefix)) { - throw new AcmeMalformedError({ detail: "KID must start with the profile account URL" }); - } - return z.string().uuid().parse(kid.slice(kidPrefix.length)); - }; - const validateJwsPayload = async < TSchema extends z.ZodSchema | undefined = undefined, T = TSchema extends z.ZodSchema ? R : string From 3c887522ec94fcf73394bb234edeedfb4581e497 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Mon, 3 Nov 2025 18:39:09 -0800 Subject: [PATCH 173/231] Add cert foreign key --- backend/src/db/migrations/20251029234547_add-pki-acme.ts | 4 ++++ backend/src/ee/services/pki-acme/pki-acme-service.ts | 3 +-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/backend/src/db/migrations/20251029234547_add-pki-acme.ts b/backend/src/db/migrations/20251029234547_add-pki-acme.ts index e76587159..f9db5c2ac 100644 --- a/backend/src/db/migrations/20251029234547_add-pki-acme.ts +++ b/backend/src/db/migrations/20251029234547_add-pki-acme.ts @@ -80,6 +80,10 @@ export async function up(knex: Knex): Promise { t.uuid("accountId").notNullable(); t.foreign("accountId").references("id").inTable(TableName.PkiAcmeAccount).onDelete("CASCADE"); + // Foreign key to certificate + t.uuid("certificateId").nullable(); + t.foreign("certificateId").references("id").inTable(TableName.Certificate).onDelete("CASCADE"); + t.timestamp("notBefore").nullable(); t.timestamp("notAfter").nullable(); 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 43c77b3d1..86c5607d3 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -319,7 +319,7 @@ export const pkiAcmeServiceFactory = ({ const kmsDecryptor = await kmsService.decryptWithKmsKey({ kmsId: certificateManagerKmsId }); - const eabSecret = await kmsDecryptor({ cipherTextBlob: profile.acmeConfig!.encryptedEabSecret }); + const eabSecret = await kmsDecryptor({ cipherTextBlob: profile.acmeConfig!.encryptedEabSecret! }); try { const { payload: eabPayload, protectedHeader: eabProtectedHeader } = await flattenedVerify( externalAccountBinding, @@ -393,7 +393,6 @@ export const pkiAcmeServiceFactory = ({ emails: contact ?? [] }); // TODO: create audit log here - // TODO: check EAB authentication here return { status: 201, body: { From 68300ed018292d13b9ff4557fef4a087156506f8 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Mon, 3 Nov 2025 18:43:49 -0800 Subject: [PATCH 174/231] Assoicate cert id with order --- backend/src/db/schemas/pki-acme-orders.ts | 3 ++- backend/src/ee/services/pki-acme/pki-acme-service.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/backend/src/db/schemas/pki-acme-orders.ts b/backend/src/db/schemas/pki-acme-orders.ts index eee8c96f0..da4c640a8 100644 --- a/backend/src/db/schemas/pki-acme-orders.ts +++ b/backend/src/db/schemas/pki-acme-orders.ts @@ -19,7 +19,8 @@ export const PkiAcmeOrdersSchema = z.object({ csr: z.string().nullable().optional(), certificate: z.string().nullable().optional(), certificateChain: z.string().nullable().optional(), - error: z.string().nullable().optional() + error: z.string().nullable().optional(), + certificateid: z.string().uuid().nullable().optional() }); export type TPkiAcmeOrders = z.infer; 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 86c5607d3..7fae7ea92 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -594,7 +594,8 @@ export const pkiAcmeServiceFactory = ({ status: AcmeOrderStatus.Valid, csr, certificateChain, - certificate + certificate, + certificateId }, tx ); From 6a721e6624e3babdae3aa4e3758d4d5bd9019604 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Mon, 3 Nov 2025 18:45:37 -0800 Subject: [PATCH 175/231] Fix db schema --- backend/src/db/schemas/pki-acme-orders.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/db/schemas/pki-acme-orders.ts b/backend/src/db/schemas/pki-acme-orders.ts index da4c640a8..67396f2d9 100644 --- a/backend/src/db/schemas/pki-acme-orders.ts +++ b/backend/src/db/schemas/pki-acme-orders.ts @@ -20,7 +20,7 @@ export const PkiAcmeOrdersSchema = z.object({ certificate: z.string().nullable().optional(), certificateChain: z.string().nullable().optional(), error: z.string().nullable().optional(), - certificateid: z.string().uuid().nullable().optional() + certificateId: z.string().uuid().nullable().optional() }); export type TPkiAcmeOrders = z.infer; From ef98b1b3bdd2efcdc21c3371cf17a7a76a64009d Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Mon, 3 Nov 2025 18:50:06 -0800 Subject: [PATCH 176/231] Add eab secrets and kid for bdd --- backend/bdd/features/steps/pki_acme.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/backend/bdd/features/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py index e005803da..ca265699e 100644 --- a/backend/bdd/features/steps/pki_acme.py +++ b/backend/bdd/features/steps/pki_acme.py @@ -1,5 +1,6 @@ import json import logging +import os import re import threading @@ -30,8 +31,10 @@ faker = Faker() class AcmeProfile: - def __init__(self, id: str): + def __init__(self, id: str, eab_kid: str, eab_secret: str): self.id = id + self.eab_kid = eab_kid + self.eab_secret = eab_secret def replace_vars(payload: dict | list | int | float | str, vars: dict): @@ -134,8 +137,14 @@ def step_impl(context: Context, profile_var: str): # TODO: Fixed value for now, just to make test much easier, # we should call infisical API to create such profile instead # in the future - profile_id = "322be4ee-fe20-41c0-ba7c-bdbdfeee2ba8" - context.vars[profile_var] = AcmeProfile(profile_id) + profile_id = "9fda66ee-03f0-4b7c-95ad-542feff77177" + kid = profile_id + secret = os.getenv("EAB_SECRET") + context.vars[profile_var] = AcmeProfile( + profile_id, + eab_kid=kid, + eab_secret=secret, + ) @given("I use {token_var} for authentication") From 0d883af30b125e84b0e70f967e33ec5b37cf30e3 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Mon, 3 Nov 2025 18:59:31 -0800 Subject: [PATCH 177/231] Implement nonce gen and check --- .../ee/services/pki-acme/pki-acme-service.ts | 27 ++++++++++++++++--- backend/src/keystore/keystore.ts | 4 ++- 2 files changed, 26 insertions(+), 5 deletions(-) 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 7fae7ea92..49142d5c1 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -5,6 +5,7 @@ import { BadRequestError, NotFoundError, UnauthorizedError } from "@app/lib/erro import { logger } from "@app/lib/logger"; import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; +import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore"; import { ActorType } from "@app/services/auth/auth-type"; import { EnrollmentType, @@ -29,6 +30,7 @@ import { TPkiAcmeChallengeDALFactory } from "./pki-acme-challenge-dal"; import { AcmeAccountDoesNotExistError, AcmeBadCSRError, + AcmeBadNonceError, AcmeBadPublicKeyError, AcmeError, AcmeExternalAccountRequiredError, @@ -87,6 +89,7 @@ type TPkiAcmeServiceFactoryDep = { TPkiAcmeChallengeDALFactory, "create" | "transaction" | "updateById" | "findByAccountAuthAndChallengeId" | "findByIdForChallengeValidation" >; + keyStore: Pick; kmsService: Pick; certificateV3Service: Pick; acmeChallengeService: TPkiAcmeChallengeServiceFactory; @@ -100,6 +103,7 @@ export const pkiAcmeServiceFactory = ({ acmeAuthDAL, acmeOrderAuthDAL, acmeChallengeDAL, + keyStore, kmsService, certificateV3Service, acmeChallengeService @@ -157,6 +161,14 @@ export const pkiAcmeServiceFactory = ({ if (new URL(protectedHeader.url).href !== url.href) { throw new AcmeUnauthorizedError({ detail: "URL mismatch in the protected header" }); } + if (!protectedHeader.nonce) { + throw new AcmeMalformedError({ detail: "Nonce is required in the protected header" }); + } + const deleted = await keyStore.deleteItem(KeyStorePrefixes.PkiAcmeNonce(protectedHeader.nonce)); + if (deleted !== 1) { + throw new AcmeBadNonceError({ detail: "Invalid nonce" }); + } + // TODO: consume the nonce here const decoder = new TextDecoder(); const textPayload = decoder.decode(rawPayload); @@ -285,10 +297,17 @@ export const pkiAcmeServiceFactory = ({ }; const getAcmeNewNonce = async (profileId: string): Promise => { - const profile = await validateAcmeProfile(profileId); - // FIXME: Implement ACME new nonce generation - // Generate a new nonce, store it, and return it - return "FIXME-generate-nonce"; + await validateAcmeProfile(profileId); + const nonce = crypto.randomBytes(32).toString("base64url"); + const nonceKey = KeyStorePrefixes.PkiAcmeNonce(nonce); + await keyStore.setItemWithExpiry( + nonceKey, + // Expire in 5 minutes. + // TODO: read config from the profile to get the expiration time instead + 60 * 5, + nonce + ); + return nonce; }; /** -------------------------------------------------------------- diff --git a/backend/src/keystore/keystore.ts b/backend/src/keystore/keystore.ts index 3155fe05c..204952a92 100644 --- a/backend/src/keystore/keystore.ts +++ b/backend/src/keystore/keystore.ts @@ -77,7 +77,9 @@ export const KeyStorePrefixes = { UserProjectPermissionPattern: (userId: string) => `project-permission:*:*:USER:${userId}:*` as const, IdentityProjectPermissionPattern: (identityId: string) => `project-permission:*:*:IDENTITY:${identityId}:*` as const, GroupMemberProjectPermissionPattern: (projectId: string, groupId: string) => - `group-member-project-permission:${projectId}:${groupId}:*` as const + `group-member-project-permission:${projectId}:${groupId}:*` as const, + + PkiAcmeNonce: (nonce: string) => `pki-acme-nonce:${nonce}` as const }; export const KeyStoreTtls = { From 4ab3e2cb97c4210aa84542f917b8f64d80b656b8 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Mon, 3 Nov 2025 19:00:54 -0800 Subject: [PATCH 178/231] comments --- backend/src/ee/services/pki-acme/pki-acme-service.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 49142d5c1..237eb2061 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -158,9 +158,11 @@ export const pkiAcmeServiceFactory = ({ const { protectedHeader: rawProtectedHeader, payload: rawPayload } = result; try { const protectedHeader = ProtectedHeaderSchema.parse(rawProtectedHeader); + // Validate the URL if (new URL(protectedHeader.url).href !== url.href) { throw new AcmeUnauthorizedError({ detail: "URL mismatch in the protected header" }); } + // Consume the nonce if (!protectedHeader.nonce) { throw new AcmeMalformedError({ detail: "Nonce is required in the protected header" }); } @@ -169,7 +171,7 @@ export const pkiAcmeServiceFactory = ({ throw new AcmeBadNonceError({ detail: "Invalid nonce" }); } - // TODO: consume the nonce here + // Parse the payload const decoder = new TextDecoder(); const textPayload = decoder.decode(rawPayload); const payload = schema ? schema.parse(JSON.parse(textPayload)) : textPayload; From ec855e8952dd5af2d78f86ae89e536ccd52cd131 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Mon, 3 Nov 2025 19:02:33 -0800 Subject: [PATCH 179/231] Add missing ks --- backend/src/server/routes/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 9af799ba6..bc19e6146 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -2206,6 +2206,7 @@ export const registerRoutes = async ( acmeAuthDAL, acmeOrderAuthDAL, acmeChallengeDAL, + keyStore, kmsService, certificateV3Service, acmeChallengeService From 25a08d28d09110d44199474b201aafd453f7de11 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Mon, 3 Nov 2025 19:15:34 -0800 Subject: [PATCH 180/231] Fix type errors --- .../src/ee/services/pki-acme/pki-acme-service.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) 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 237eb2061..90c5a4ddc 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -267,7 +267,12 @@ export const pkiAcmeServiceFactory = ({ expiresAt: Date; notBefore?: Date | null; notAfter?: Date | null; - authorizations: TPkiAcmeAuths[]; + authorizations: { + id: string; + identifierType: string; + identifierValue: string; + expiresAt: Date; + }[]; }; profileId: string; }): TAcmeOrderResource => { @@ -276,13 +281,11 @@ export const pkiAcmeServiceFactory = ({ expires: order.expiresAt.toISOString(), notBefore: order.notBefore?.toISOString(), notAfter: order.notAfter?.toISOString(), - identifiers: order.authorizations.map((auth: TPkiAcmeAuths) => ({ + identifiers: order.authorizations.map((auth) => ({ type: auth.identifierType, value: auth.identifierValue })), - authorizations: order.authorizations.map((auth: TPkiAcmeAuths) => - buildUrl(profileId, `/authorizations/${auth.id}`) - ), + authorizations: order.authorizations.map((auth) => buildUrl(profileId, `/authorizations/${auth.id}`)), finalize: buildUrl(profileId, `/orders/${order.id}/finalize`), certificate: order.status === AcmeOrderStatus.Valid ? buildUrl(profileId, `/orders/${order.id}/certificate`) : undefined @@ -630,7 +633,7 @@ export const pkiAcmeServiceFactory = ({ }, tx ); - // TODO: log the error + logger.error(error, "Failed to sign certificate"); // TODO: audit log the error if (error instanceof BadRequestError) { errorToReturn = new AcmeBadCSRError({ detail: `Invalid CSR: ${error.message}` }); From 675b4f5a26e12c37600236495e86dd1e5bd26e9c Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Mon, 3 Nov 2025 19:49:03 -0800 Subject: [PATCH 181/231] Add TODO --- backend/src/ee/services/pki-acme/pki-acme-service.ts | 2 ++ 1 file changed, 2 insertions(+) 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 90c5a4ddc..757e60e86 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -617,6 +617,8 @@ export const pkiAcmeServiceFactory = ({ { status: AcmeOrderStatus.Valid, csr, + // TODO: we actually don't need to store the certificate and certificate chain here + // It appears that the certificate and certificate chain are stored in the certificate_body table already certificateChain, certificate, certificateId From 50bc8ad891141232ebe34fafa8be922a5d82c330 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Mon, 3 Nov 2025 19:57:27 -0800 Subject: [PATCH 182/231] Fix wrong cert order --- backend/src/ee/services/pki-acme/pki-acme-service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 757e60e86..e4cdc74c9 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -683,7 +683,7 @@ export const pkiAcmeServiceFactory = ({ } return { status: 200, - body: order.certificateChain! + "\n" + order.certificate!, + body: order.certificate! + "\n" + order.certificateChain!, headers: { Location: buildUrl(profileId, `/orders/${orderId}/certificate`), Link: `<${buildUrl(profileId, "/directory")}>;rel="index"` From 79c7320140cf0695ad15e8f8b09b25f2fd9a9d8f Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Mon, 3 Nov 2025 20:32:40 -0800 Subject: [PATCH 183/231] Fix cert format --- backend/bdd/features/pki/acme/challenge.feature | 3 ++- backend/bdd/features/steps/pki_acme.py | 7 ++++--- backend/src/ee/routes/v1/pki-acme-router.ts | 1 + backend/src/ee/services/pki-acme/pki-acme-service.ts | 8 +++++++- 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/backend/bdd/features/pki/acme/challenge.feature b/backend/bdd/features/pki/acme/challenge.feature index a5d94aff5..155cc64f2 100644 --- a/backend/bdd/features/pki/acme/challenge.feature +++ b/backend/bdd/features/pki/acme/challenge.feature @@ -19,4 +19,5 @@ Feature: Challenge Then I select challenge with type http-01 for domain localhost from order at order as challenge Then I serve challenge response for challenge at localhost Then I tell ACME server that challenge is ready to be verified - Then I poll and finalize the ACME order order + Then I poll and finalize the ACME order order as finalized_order + # TODO: check the content of the order diff --git a/backend/bdd/features/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py index ca265699e..0ccf71ff8 100644 --- a/backend/bdd/features/steps/pki_acme.py +++ b/backend/bdd/features/steps/pki_acme.py @@ -475,8 +475,9 @@ def step_impl(context: Context, var_path: str): acme_client.answer_challenge(challenge, response) -@then("I poll and finalize the ACME order {var_path}") -def step_impl(context: Context, var_path: str): +@then("I poll and finalize the ACME order {var_path} as {finalized_var}") +def step_impl(context: Context, var_path: str, finalized_var: str): order = eval_var(context, var_path, as_json=False) acme_client = context.acme_client - acme_client.poll_and_finalize(order) + finalized_order = acme_client.poll_and_finalize(order) + context.vars[finalized_var] = finalized_order diff --git a/backend/src/ee/routes/v1/pki-acme-router.ts b/backend/src/ee/routes/v1/pki-acme-router.ts index f628c8d36..4a516dae2 100644 --- a/backend/src/ee/routes/v1/pki-acme-router.ts +++ b/backend/src/ee/routes/v1/pki-acme-router.ts @@ -380,6 +380,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { if (payload !== "") { throw new AcmeMalformedError({ detail: "Payload should be empty" }); } + res.type("application/pem-certificate-chain"); return sendAcmeResponse( res, profileId, 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 e4cdc74c9..f1ce7019d 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -683,7 +683,13 @@ export const pkiAcmeServiceFactory = ({ } return { status: 200, - body: order.certificate! + "\n" + order.certificateChain!, + body: + order.certificate!.trim().replace("\n", "\r\n") + + "\r\n" + + order.certificateChain!.trim().replace("\n", "\r\n") + + // The final line is needed, otherwise some clients will not parse the certificate chain correctly + // ref: https://github.com/certbot/certbot/blob/4d5d5f7ae8164884c841969e46caed8db1ad34af/certbot/src/certbot/crypto_util.py#L506-L514 + "\r\n", headers: { Location: buildUrl(profileId, `/orders/${orderId}/certificate`), Link: `<${buildUrl(profileId, "/directory")}>;rel="index"` From f555c4ea7dd04bc2b7699d32a2a0be599c67e21d Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 4 Nov 2025 09:21:04 -0800 Subject: [PATCH 184/231] Format --- backend/bdd/features/pki/acme/account.feature | 5 +++-- backend/bdd/features/pki/acme/dicrectory.feature | 7 ++++--- backend/bdd/features/pki/acme/nonce.feature | 7 ++++--- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/backend/bdd/features/pki/acme/account.feature b/backend/bdd/features/pki/acme/account.feature index 463e6b04f..7e1d67a93 100644 --- a/backend/bdd/features/pki/acme/account.feature +++ b/backend/bdd/features/pki/acme/account.feature @@ -1,5 +1,6 @@ Feature: Account + Scenario: Create a new account Given I have an ACME cert profile as "acme_profile" - When I have an ACME client connecting to {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory - Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account + When I have an ACME client connecting to {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory + Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account diff --git a/backend/bdd/features/pki/acme/dicrectory.feature b/backend/bdd/features/pki/acme/dicrectory.feature index 04eade31e..481a3337a 100644 --- a/backend/bdd/features/pki/acme/dicrectory.feature +++ b/backend/bdd/features/pki/acme/dicrectory.feature @@ -1,9 +1,10 @@ Feature: Directory + Scenario: Get the directory of ACME service urls Given I have an ACME cert profile as "acme_profile" - When I send a GET request to "/api/v1/pki/acme/profiles/{acme_profile.id}/directory" - Then the response status code should be "200" - Then the response body should match JSON value + When I send a GET request to "/api/v1/pki/acme/profiles/{acme_profile.id}/directory" + Then the response status code should be "200" + Then the response body should match JSON value """ { "newNonce": "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-nonce", diff --git a/backend/bdd/features/pki/acme/nonce.feature b/backend/bdd/features/pki/acme/nonce.feature index 0d62f7b46..7bbeb3b9d 100644 --- a/backend/bdd/features/pki/acme/nonce.feature +++ b/backend/bdd/features/pki/acme/nonce.feature @@ -1,6 +1,7 @@ Feature: Nonce + Scenario: Generate a new nonce Given I have an ACME cert profile as "acme_profile" - When I send a HEAD request to "/api/v1/pki/acme/profiles/{acme_profile.id}/new-nonce" - Then the response status code should be "200" - Then the response header "Replay-Nonce" should contains non-empty value + When I send a HEAD request to "/api/v1/pki/acme/profiles/{acme_profile.id}/new-nonce" + Then the response status code should be "200" + Then the response header "Replay-Nonce" should contains non-empty value From ba16793de5c7a74ee08d518683b063158ba7f342 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 4 Nov 2025 09:22:06 -0800 Subject: [PATCH 185/231] Update todo --- backend/bdd/features/pki/acme/challenge.feature | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/bdd/features/pki/acme/challenge.feature b/backend/bdd/features/pki/acme/challenge.feature index 155cc64f2..8a0237631 100644 --- a/backend/bdd/features/pki/acme/challenge.feature +++ b/backend/bdd/features/pki/acme/challenge.feature @@ -20,4 +20,4 @@ Feature: Challenge Then I serve challenge response for challenge at localhost Then I tell ACME server that challenge is ready to be verified Then I poll and finalize the ACME order order as finalized_order - # TODO: check the content of the order + # TODO: check the fullchain pem content of the order From 088059b3050e0b29a608202cb43fddae45598b49 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 4 Nov 2025 09:24:21 -0800 Subject: [PATCH 186/231] Fix BDD test --- backend/bdd/features/steps/pki_acme.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/bdd/features/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py index 0ccf71ff8..30482c5b3 100644 --- a/backend/bdd/features/steps/pki_acme.py +++ b/backend/bdd/features/steps/pki_acme.py @@ -321,7 +321,7 @@ def apply_value_with_jq(context: Context, var_path: str, jq_query: str): ).first() -@then("the value {var_path} with jq {jq_query} should be equal to json") +@then('the value {var_path} with jq "{jq_query}" should be equal to json') def step_impl(context: Context, var_path: str, jq_query: str): value, result = apply_value_with_jq( context=context, From c9f6cebd7bb72ea8936da5fc2975e87551640366 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 4 Nov 2025 09:27:11 -0800 Subject: [PATCH 187/231] Remove org name in csr for now --- backend/bdd/features/pki/acme/auth.feature | 1 - backend/bdd/features/pki/acme/challenge.feature | 2 +- backend/bdd/features/pki/acme/order.feature | 3 --- 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/backend/bdd/features/pki/acme/auth.feature b/backend/bdd/features/pki/acme/auth.feature index b70599b66..4605e2eef 100644 --- a/backend/bdd/features/pki/acme/auth.feature +++ b/backend/bdd/features/pki/acme/auth.feature @@ -9,7 +9,6 @@ Feature: Authorization Then I add names to certificate signing request csr """ { - "ORGANIZATION_NAME": "Infisical Inc", "COMMON_NAME": "localhost" } """ diff --git a/backend/bdd/features/pki/acme/challenge.feature b/backend/bdd/features/pki/acme/challenge.feature index 8a0237631..3f3690838 100644 --- a/backend/bdd/features/pki/acme/challenge.feature +++ b/backend/bdd/features/pki/acme/challenge.feature @@ -9,7 +9,6 @@ Feature: Challenge Then I add names to certificate signing request csr """ { - "ORGANIZATION_NAME": "Infisical Inc", "COMMON_NAME": "localhost" } """ @@ -20,4 +19,5 @@ Feature: Challenge Then I serve challenge response for challenge at localhost Then I tell ACME server that challenge is ready to be verified Then I poll and finalize the ACME order order as finalized_order + Then the value finalized_order.body.status should be equal to valid # TODO: check the fullchain pem content of the order diff --git a/backend/bdd/features/pki/acme/order.feature b/backend/bdd/features/pki/acme/order.feature index 49f72e485..2a85dc84c 100644 --- a/backend/bdd/features/pki/acme/order.feature +++ b/backend/bdd/features/pki/acme/order.feature @@ -9,7 +9,6 @@ Feature: Order Then I add names to certificate signing request csr """ { - "ORGANIZATION_NAME": "Infisical Inc", "COMMON_NAME": "localhost" } """ @@ -31,7 +30,6 @@ Feature: Order Then I add names to certificate signing request csr """ { - "ORGANIZATION_NAME": "Infisical Inc", "COMMON_NAME": "localhost" } """ @@ -63,7 +61,6 @@ Feature: Order Then I add names to certificate signing request csr """ { - "ORGANIZATION_NAME": "Infisical Inc", "COMMON_NAME": "localhost" } """ From efe923d2a07ad3e6bcb4278678e1fdc8ed4de5f0 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 4 Nov 2025 09:28:48 -0800 Subject: [PATCH 188/231] Fix test --- backend/bdd/features/pki/acme/challenge.feature | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/bdd/features/pki/acme/challenge.feature b/backend/bdd/features/pki/acme/challenge.feature index 3f3690838..ba9970e43 100644 --- a/backend/bdd/features/pki/acme/challenge.feature +++ b/backend/bdd/features/pki/acme/challenge.feature @@ -19,5 +19,5 @@ Feature: Challenge Then I serve challenge response for challenge at localhost Then I tell ACME server that challenge is ready to be verified Then I poll and finalize the ACME order order as finalized_order - Then the value finalized_order.body.status should be equal to valid + Then the value finalized_order.body with jq ".status" should be equal to "valid" # TODO: check the fullchain pem content of the order From 65b61514ae9e4f3950d7c39964a876a160722f12 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 4 Nov 2025 09:32:59 -0800 Subject: [PATCH 189/231] Fix all broken tests --- backend/bdd/features/pki/acme/order.feature | 4 ++-- backend/bdd/features/steps/pki_acme.py | 13 ++++++++----- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/backend/bdd/features/pki/acme/order.feature b/backend/bdd/features/pki/acme/order.feature index 2a85dc84c..046dcda55 100644 --- a/backend/bdd/features/pki/acme/order.feature +++ b/backend/bdd/features/pki/acme/order.feature @@ -19,7 +19,7 @@ Feature: Order Then the value order.body with jq ".status" should be equal to "pending" Then the value order.body with jq ".identifiers" should be equal to [{"type": "dns", "value": "localhost"}] Then the value order.body with jq ".finalize" should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/orders/(.+)/finalize - Then the value order.body with jq "all(.authorizations[]; startswith('{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/authorizations/'))" should be equal to true + Then the value order.body with jq "all(.authorizations[]; startswith("{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/authorizations/"))" should be equal to true Scenario: Create a new order with SANs Given I have an ACME cert profile as "acme_profile" @@ -71,4 +71,4 @@ Feature: Order Then the value fetched_order with jq ".status" should be equal to "pending" Then the value fetched_order with jq ".identifiers" should be equal to [{"type": "dns", "value": "localhost"}] Then the value fetched_order with jq ".finalize" should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/orders/(.+)/finalize - Then the value fetched_order with jq "all(.authorizations[]; startswith('{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/authorizations/'))" should be equal to true + Then the value fetched_order with jq "all(.authorizations[]; startswith("{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/authorizations/"))" should be equal to true diff --git a/backend/bdd/features/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py index 30482c5b3..3441b851a 100644 --- a/backend/bdd/features/steps/pki_acme.py +++ b/backend/bdd/features/steps/pki_acme.py @@ -160,7 +160,10 @@ def step_impl(context: Context, method: str, url: str): ) context.vars["response"] = response logger.debug("Response status: %r", response.status_code) - logger.debug("Response JSON payload: %r", response.json()) + try: + logger.debug("Response JSON payload: %r", response.json()) + except json.decoder.JSONDecodeError: + pass @when('I send a {method} request to "{url}" with JSON payload') @@ -203,14 +206,14 @@ def step_impl(context: Context, url: str): @then('the response status code should be "{expected_status_code:d}"') def step_impl(context: Context, expected_status_code: int): - assert context.response.status_code == expected_status_code, ( - f"{context.response.status_code} != {expected_status_code}" + assert context.vars["response"].status_code == expected_status_code, ( + f"{context.vars['response'].status_code} != {expected_status_code}" ) @then('the response header "{header}" should contains non-empty value') def step_impl(context: Context, header: str): - header_value = context.response.headers.get(header) + header_value = context.vars["response"].headers.get(header) assert header_value is not None, f"Header {header} not found in response" assert header_value, ( f"Header {header} found in response, but value {header_value:!r} is empty" @@ -219,7 +222,7 @@ def step_impl(context: Context, header: str): @then("the response body should match JSON value") def step_impl(context: Context): - payload = context.response.json() + payload = context.vars["response"].json() expected = json.loads(context.text) replaced = replace_vars(expected, context.vars) assert payload == replaced, f"{payload} != {replaced}" From 7f69674e2c34c62542a9a1b9e4c0ee3e43a0009a Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 4 Nov 2025 10:50:40 -0800 Subject: [PATCH 190/231] Use existing cert obj --- .../ee/services/pki-acme/pki-acme-service.ts | 32 +++++++++++++++++-- backend/src/server/routes/index.ts | 1 + 2 files changed, 31 insertions(+), 2 deletions(-) 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 f1ce7019d..8ec786ec9 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -4,6 +4,7 @@ import { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; +import * as x509 from "@peculiar/x509"; import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore"; import { ActorType } from "@app/services/auth/auth-type"; @@ -12,6 +13,7 @@ import { TCertificateProfileWithConfigs } from "@app/services/certificate-profile/certificate-profile-types"; import { TCertificateV3ServiceFactory } from "@app/services/certificate-v3/certificate-v3-service"; +import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; @@ -75,6 +77,7 @@ import { type TPkiAcmeServiceFactoryDep = { projectDAL: Pick; certificateProfileDAL: Pick; + certificateBodyDAL: Pick; acmeAccountDAL: Pick< TPkiAcmeAccountDALFactory, "findByProjectIdAndAccountId" | "findByProfileIdAndPublicKeyThumbprintAndAlg" | "create" @@ -98,6 +101,7 @@ type TPkiAcmeServiceFactoryDep = { export const pkiAcmeServiceFactory = ({ projectDAL, certificateProfileDAL, + certificateBodyDAL, acmeAccountDAL, acmeOrderDAL, acmeAuthDAL, @@ -674,6 +678,7 @@ export const pkiAcmeServiceFactory = ({ accountId: string; orderId: string; }): Promise> => { + const profile = await validateAcmeProfile(profileId); const order = await acmeOrderDAL.findByAccountAndOrderIdWithAuthorizations(accountId, orderId); if (!order) { throw new NotFoundError({ message: "ACME order not found" }); @@ -681,12 +686,35 @@ export const pkiAcmeServiceFactory = ({ if (order.status !== AcmeOrderStatus.Valid) { throw new AcmeOrderNotReadyError({ message: "ACME order is not valid" }); } + if (!order.certificateId) { + throw new NotFoundError({ message: "The underlying certificate no longer exists" }); + } + + const certBody = await certificateBodyDAL.findOne({ certId: order.certificateId }); + const certificateManagerKeyId = await getProjectKmsCertificateKeyId({ + projectId: profile.projectId, + projectDAL, + kmsService + }); + + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: certificateManagerKeyId + }); + const decryptedCert = await kmsDecryptor({ + cipherTextBlob: certBody.encryptedCertificate + }); + const certObj = new x509.X509Certificate(decryptedCert); + const decryptedCertChain = await kmsDecryptor({ + cipherTextBlob: certBody.encryptedCertificateChain! + }); + const certificateChain = decryptedCertChain.toString(); + return { status: 200, body: - order.certificate!.trim().replace("\n", "\r\n") + + certObj.toString("pem").trim().replace("\n", "\r\n") + "\r\n" + - order.certificateChain!.trim().replace("\n", "\r\n") + + certificateChain.trim().replace("\n", "\r\n") + // The final line is needed, otherwise some clients will not parse the certificate chain correctly // ref: https://github.com/certbot/certbot/blob/4d5d5f7ae8164884c841969e46caed8db1ad34af/certbot/src/certbot/crypto_util.py#L506-L514 "\r\n", diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index bc19e6146..cd8fac508 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -2201,6 +2201,7 @@ export const registerRoutes = async ( const pkiAcmeService = pkiAcmeServiceFactory({ projectDAL, certificateProfileDAL, + certificateBodyDAL, acmeAccountDAL, acmeOrderDAL, acmeAuthDAL, From f85d913017480fddd034316286c898e93903c717 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 4 Nov 2025 10:54:46 -0800 Subject: [PATCH 191/231] Remove not needed columns --- backend/src/db/migrations/20251029234547_add-pki-acme.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/backend/src/db/migrations/20251029234547_add-pki-acme.ts b/backend/src/db/migrations/20251029234547_add-pki-acme.ts index f9db5c2ac..c6b95009b 100644 --- a/backend/src/db/migrations/20251029234547_add-pki-acme.ts +++ b/backend/src/db/migrations/20251029234547_add-pki-acme.ts @@ -90,11 +90,7 @@ export async function up(knex: Knex): Promise { t.timestamp("expiresAt").notNullable(); t.text("csr").nullable(); - t.text("certificate").nullable(); - t.text("certificateChain").nullable(); - t.text("error").nullable(); - // Order status t.string("status").notNullable(); // pending, ready, processing, valid, invalid From 089157b237dba613f481fc5b1f10861b3ae8c0c7 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 4 Nov 2025 11:09:01 -0800 Subject: [PATCH 192/231] Update db schema, load cert from cert body instead --- ...acme.ts => 20251104234547_add-pki-acme.ts} | 0 backend/src/db/schemas/pki-acme-challenges.ts | 1 + backend/src/db/schemas/pki-acme-orders.ts | 10 +++--- .../ee/services/pki-acme/pki-acme-service.ts | 35 ++++++++----------- 4 files changed, 20 insertions(+), 26 deletions(-) rename backend/src/db/migrations/{20251029234547_add-pki-acme.ts => 20251104234547_add-pki-acme.ts} (100%) diff --git a/backend/src/db/migrations/20251029234547_add-pki-acme.ts b/backend/src/db/migrations/20251104234547_add-pki-acme.ts similarity index 100% rename from backend/src/db/migrations/20251029234547_add-pki-acme.ts rename to backend/src/db/migrations/20251104234547_add-pki-acme.ts diff --git a/backend/src/db/schemas/pki-acme-challenges.ts b/backend/src/db/schemas/pki-acme-challenges.ts index 17282da06..18245bb76 100644 --- a/backend/src/db/schemas/pki-acme-challenges.ts +++ b/backend/src/db/schemas/pki-acme-challenges.ts @@ -12,6 +12,7 @@ export const PkiAcmeChallengesSchema = z.object({ authId: z.string().uuid(), type: z.string(), status: z.string(), + error: z.string().nullable().optional(), validatedAt: z.date().nullable().optional(), createdAt: z.date(), updatedAt: z.date() diff --git a/backend/src/db/schemas/pki-acme-orders.ts b/backend/src/db/schemas/pki-acme-orders.ts index 67396f2d9..928753d8c 100644 --- a/backend/src/db/schemas/pki-acme-orders.ts +++ b/backend/src/db/schemas/pki-acme-orders.ts @@ -10,17 +10,15 @@ import { TImmutableDBKeys } from "./models"; export const PkiAcmeOrdersSchema = z.object({ id: z.string().uuid(), accountId: z.string().uuid(), + certificateId: z.string().uuid().nullable().optional(), notBefore: z.date().nullable().optional(), notAfter: z.date().nullable().optional(), expiresAt: z.date(), + csr: z.string().nullable().optional(), + error: z.string().nullable().optional(), status: z.string(), createdAt: z.date(), - updatedAt: z.date(), - csr: z.string().nullable().optional(), - certificate: z.string().nullable().optional(), - certificateChain: z.string().nullable().optional(), - error: z.string().nullable().optional(), - certificateId: z.string().uuid().nullable().optional() + updatedAt: z.date() }); export type TPkiAcmeOrders = z.infer; 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 8ec786ec9..546bd6757 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -599,32 +599,27 @@ export const pkiAcmeServiceFactory = ({ // TODO: this should be the same transaction? let errorToReturn: Error | undefined; try { - const { certificate, certificateChain, certificateId } = - await certificateV3Service.signCertificateFromProfile({ - actor: ActorType.ACME_ACCOUNT, - actorId: accountId, - actorAuthMethod: null, - actorOrgId, - profileId, - csr, - notBefore: order.notBefore ? new Date(order.notBefore) : undefined, - notAfter: order.notAfter ? new Date(order.notAfter) : undefined, - validity: { - // TODO: read config from the profile to get the expiration time instead - ttl: (24 * 60 * 60 * 1000).toString() - }, - enrollmentType: EnrollmentType.ACME - }); + const { certificateId } = await certificateV3Service.signCertificateFromProfile({ + actor: ActorType.ACME_ACCOUNT, + actorId: accountId, + actorAuthMethod: null, + actorOrgId, + profileId, + csr, + notBefore: order.notBefore ? new Date(order.notBefore) : undefined, + notAfter: order.notAfter ? new Date(order.notAfter) : undefined, + validity: { + // TODO: read config from the profile to get the expiration time instead + ttl: (24 * 60 * 60 * 1000).toString() + }, + enrollmentType: EnrollmentType.ACME + }); // TODO: associate the certificate with the order await acmeOrderDAL.updateById( orderId, { status: AcmeOrderStatus.Valid, csr, - // TODO: we actually don't need to store the certificate and certificate chain here - // It appears that the certificate and certificate chain are stored in the certificate_body table already - certificateChain, - certificate, certificateId }, tx From 82d03a1129e5a5ad260545440813d65900098069 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 4 Nov 2025 11:09:25 -0800 Subject: [PATCH 193/231] Update text --- backend/src/ee/services/pki-acme/pki-acme-service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 546bd6757..0f537731c 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -682,7 +682,7 @@ export const pkiAcmeServiceFactory = ({ throw new AcmeOrderNotReadyError({ message: "ACME order is not valid" }); } if (!order.certificateId) { - throw new NotFoundError({ message: "The underlying certificate no longer exists" }); + throw new NotFoundError({ message: "The certificate for this ACME order no longer exists" }); } const certBody = await certificateBodyDAL.findOne({ certId: order.certificateId }); From bf39f5eb173438bcf1b054cf8cfc42598b083b3a Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 4 Nov 2025 11:19:06 -0800 Subject: [PATCH 194/231] Implement list order --- .../ee/services/pki-acme/pki-acme-order-dal.ts | 12 +++++++++++- .../src/ee/services/pki-acme/pki-acme-service.ts | 15 ++++++++------- 2 files changed, 19 insertions(+), 8 deletions(-) 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 index 8600079df..ca02b09bb 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-order-dal.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-order-dal.ts @@ -60,9 +60,19 @@ export const pkiAcmeOrderDALFactory = (db: TDbClient) => { } }; + const listByAccountId = async (accountId: string, tx?: Knex) => { + try { + const orders = await (tx || db)(TableName.PkiAcmeOrder).where({ accountId }).orderBy("createdAt", "desc"); + return orders; + } catch (error) { + throw new DatabaseError({ error, name: "List PKI ACME orders by account id" }); + } + }; + return { ...pkiAcmeOrderOrm, findByIdForFinalization, - findByAccountAndOrderIdWithAuthorizations + findByAccountAndOrderIdWithAuthorizations, + listByAccountId }; }; 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 0f537731c..5bfbf2120 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -84,7 +84,12 @@ type TPkiAcmeServiceFactoryDep = { >; acmeOrderDAL: Pick< TPkiAcmeOrderDALFactory, - "create" | "transaction" | "updateById" | "findByAccountAndOrderIdWithAuthorizations" | "findByIdForFinalization" + | "create" + | "transaction" + | "updateById" + | "findByAccountAndOrderIdWithAuthorizations" + | "findByIdForFinalization" + | "listByAccountId" >; acmeAuthDAL: Pick; acmeOrderAuthDAL: Pick; @@ -727,15 +732,11 @@ export const pkiAcmeServiceFactory = ({ profileId: string; accountId: string; }): Promise> => { - const profile = await validateAcmeProfile(profileId); - // FIXME: Implement ACME list orders + const orders = await acmeOrderDAL.listByAccountId(accountId); return { status: 200, - body: { - orders: [] - }, + body: { orders: orders.map((order) => buildUrl(profileId, `/orders/${order.id}`)) }, headers: { - Location: buildUrl(profileId, `/accounts/${accountId}/orders`), Link: `<${buildUrl(profileId, "/directory")}>;rel="index"` } }; From 10054b3e4bb18e0ba3655d5dd4e1bd03eede5907 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 4 Nov 2025 11:30:43 -0800 Subject: [PATCH 195/231] Refine eab payload checking logic --- .../ee/services/pki-acme/pki-acme-service.ts | 62 +++++++++---------- 1 file changed, 30 insertions(+), 32 deletions(-) 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 5bfbf2120..caf6c53cd 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -353,46 +353,44 @@ export const pkiAcmeServiceFactory = ({ kmsId: certificateManagerKmsId }); const eabSecret = await kmsDecryptor({ cipherTextBlob: profile.acmeConfig!.encryptedEabSecret! }); + let eabPayload: Uint8Array | undefined; + let eabProtectedHeader: JWSHeaderParameters | undefined; try { - const { payload: eabPayload, protectedHeader: eabProtectedHeader } = await flattenedVerify( - externalAccountBinding, - eabSecret - ); - const { alg: eabAlg, kid: eabKid } = eabProtectedHeader!; - if (!["HS256", "HS384", "HS512"].includes(eabAlg!)) { - throw new AcmeMalformedError({ detail: "Invalid algorithm for external account binding JWS payload" }); - } - // Make sure the KID in the EAB payload matches the profile ID - if (eabKid !== profile.id) { - throw new UnauthorizedError({ message: "External account binding KID mismatch" }); - } - - // Make sure the URL matches the expected URL - const url = eabProtectedHeader!.url!; - if (url !== buildUrl(profile.id, "/new-account")) { - throw new UnauthorizedError({ message: "External account binding URL mismatch" }); - } - - // Make sure the JWK in the EAB payload matches the one provided in the outer JWS payload - const decoder = new TextDecoder(); - const decodedEabPayload = decoder.decode(eabPayload); - const eabJWK = JSON.parse(decodedEabPayload); - const eabPayloadJwkThumbprint = await calculateJwkThumbprint(eabJWK, "sha256"); - if (eabPayloadJwkThumbprint !== publicKeyThumbprint) { - throw new AcmeBadPublicKeyError({ - message: "External account binding public key thumbprint or algorithm mismatch" - }); - } + const result = await flattenedVerify(externalAccountBinding, eabSecret); + eabPayload = result.payload; + eabProtectedHeader = result.protectedHeader; } catch (error) { if (error instanceof errors.JWSInvalid) { throw new AcmeMalformedError({ detail: "Invalid external account binding JWS payload" }); } - if (error instanceof AcmeError) { - throw error; - } logger.error(error, "Unexpected error while verifying EAB JWS payload"); throw new AcmeServerInternalError({ detail: "Failed to verify EAB JWS payload" }); } + const { alg: eabAlg, kid: eabKid } = eabProtectedHeader!; + if (!["HS256", "HS384", "HS512"].includes(eabAlg!)) { + throw new AcmeMalformedError({ detail: "Invalid algorithm for external account binding JWS payload" }); + } + // Make sure the KID in the EAB payload matches the profile ID + if (eabKid !== profile.id) { + throw new UnauthorizedError({ message: "External account binding KID mismatch" }); + } + + // Make sure the URL matches the expected URL + const url = eabProtectedHeader!.url!; + if (url !== buildUrl(profile.id, "/new-account")) { + throw new UnauthorizedError({ message: "External account binding URL mismatch" }); + } + + // Make sure the JWK in the EAB payload matches the one provided in the outer JWS payload + const decoder = new TextDecoder(); + const decodedEabPayload = decoder.decode(eabPayload); + const eabJWK = JSON.parse(decodedEabPayload); + const eabPayloadJwkThumbprint = await calculateJwkThumbprint(eabJWK, "sha256"); + if (eabPayloadJwkThumbprint !== publicKeyThumbprint) { + throw new AcmeBadPublicKeyError({ + message: "External account binding public key thumbprint or algorithm mismatch" + }); + } const existingAccount: TPkiAcmeAccounts | null = await acmeAccountDAL.findByProfileIdAndPublicKeyThumbprintAndAlg( profileId, From 708b8f9f65f7451699a985571905034743ecab3b Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 4 Nov 2025 11:32:23 -0800 Subject: [PATCH 196/231] Refine --- .../ee/services/pki-acme/pki-acme-service.ts | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) 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 caf6c53cd..373063925 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -353,19 +353,22 @@ export const pkiAcmeServiceFactory = ({ kmsId: certificateManagerKmsId }); const eabSecret = await kmsDecryptor({ cipherTextBlob: profile.acmeConfig!.encryptedEabSecret! }); - let eabPayload: Uint8Array | undefined; - let eabProtectedHeader: JWSHeaderParameters | undefined; - try { - const result = await flattenedVerify(externalAccountBinding, eabSecret); - eabPayload = result.payload; - eabProtectedHeader = result.protectedHeader; - } catch (error) { - if (error instanceof errors.JWSInvalid) { - throw new AcmeMalformedError({ detail: "Invalid external account binding JWS payload" }); + const { eabPayload, eabProtectedHeader } = await (async () => { + try { + const { payload: eabPayload, protectedHeader: eabProtectedHeader } = await flattenedVerify( + externalAccountBinding, + eabSecret + ); + return { eabPayload, eabProtectedHeader }; + } catch (error) { + if (error instanceof errors.JWSInvalid) { + throw new AcmeMalformedError({ detail: "Invalid external account binding JWS payload" }); + } + logger.error(error, "Unexpected error while verifying EAB JWS payload"); + throw new AcmeServerInternalError({ detail: "Failed to verify EAB JWS payload" }); } - logger.error(error, "Unexpected error while verifying EAB JWS payload"); - throw new AcmeServerInternalError({ detail: "Failed to verify EAB JWS payload" }); - } + })(); + const { alg: eabAlg, kid: eabKid } = eabProtectedHeader!; if (!["HS256", "HS384", "HS512"].includes(eabAlg!)) { throw new AcmeMalformedError({ detail: "Invalid algorithm for external account binding JWS payload" }); From 1adcd721fe79d90c8927b2db532e300d9698233e Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 4 Nov 2025 11:35:11 -0800 Subject: [PATCH 197/231] Use the right error type --- backend/src/ee/services/pki-acme/pki-acme-service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 373063925..f78aa93fc 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -158,7 +158,7 @@ export const pkiAcmeServiceFactory = ({ if (error instanceof ZodError) { throw new AcmeMalformedError({ detail: `Invalid JWS payload: ${error.message}` }); } - if (error instanceof errors.JWSInvalid) { + if (error instanceof errors.JWSSignatureVerificationFailed) { throw new AcmeBadPublicKeyError({ detail: "Invalid JWS payload" }); } logger.error(error, "Unexpected error while verifying JWS payload"); @@ -361,7 +361,7 @@ export const pkiAcmeServiceFactory = ({ ); return { eabPayload, eabProtectedHeader }; } catch (error) { - if (error instanceof errors.JWSInvalid) { + if (error instanceof errors.JWSSignatureVerificationFailed) { throw new AcmeMalformedError({ detail: "Invalid external account binding JWS payload" }); } logger.error(error, "Unexpected error while verifying EAB JWS payload"); From 52870c4bdcccbeeb1b4bd7a5d802784aad59ce39 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 4 Nov 2025 11:37:40 -0800 Subject: [PATCH 198/231] wording --- backend/src/ee/services/pki-acme/pki-acme-service.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 f78aa93fc..985c87063 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -362,10 +362,10 @@ export const pkiAcmeServiceFactory = ({ return { eabPayload, eabProtectedHeader }; } catch (error) { if (error instanceof errors.JWSSignatureVerificationFailed) { - throw new AcmeMalformedError({ detail: "Invalid external account binding JWS payload" }); + throw new AcmeMalformedError({ detail: "Invalid external account binding JWS signature" }); } - logger.error(error, "Unexpected error while verifying EAB JWS payload"); - throw new AcmeServerInternalError({ detail: "Failed to verify EAB JWS payload" }); + logger.error(error, "Unexpected error while verifying EAB JWS signature"); + throw new AcmeServerInternalError({ detail: "Failed to verify EAB JWS signature" }); } })(); From 582f0bc067f20a84f8c64205bc65eb4b3a2b21d7 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 4 Nov 2025 11:42:24 -0800 Subject: [PATCH 199/231] Add env for bdd --- backend/bdd/.env.example | 14 ++++++++++++++ backend/bdd/features/environment.py | 8 +++----- backend/bdd/features/steps/pki_acme.py | 2 +- 3 files changed, 18 insertions(+), 6 deletions(-) create mode 100644 backend/bdd/.env.example diff --git a/backend/bdd/.env.example b/backend/bdd/.env.example new file mode 100644 index 000000000..8e59c033d --- /dev/null +++ b/backend/bdd/.env.example @@ -0,0 +1,14 @@ +# API URL to the Infisical server +INFISICAL_API_URL="http://localhost:8080" +# JWT token with admin permission for the cert projects +INFISICAL_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdXRoTWV0aG9kIjoiZW1haWwiLCJhdXRoVG9rZW5UeXBlIjoiYWNjZXNzVG9rZW4iLCJ1c2VySWQiOiJkOWZlMzMwZi00OTQwLTQ3ZmYtYmE4Yy0zZGUxYTVlYjYzNGEiLCJ0b2tlblZlcnNpb25JZCI6ImM4MWZhODY1LTFjYTAtNGNmZS1iNjM5LThlMDI3M2E2N2JjYyIsImFjY2Vzc1ZlcnNpb24iOjEsIm9yZ2FuaXphdGlvbklkIjoiM2I5OTRkNTktMjE5Ny00MjcwLWE3MGMtOTczMzdmZjlmYTRkIiwiaWF0IjoxNzYyMjAzMjQwLCJleHAiOjE3NjMwNjcyNDB9.KFYeMYAv3Ceis0hp-pTa8fsLLWbT-JcqhuWyIY0DWU0" +# PKI project id +PROJECT_ID="c051e74c-48a7-4724-832c-d5b496698546" +# Certificate CA id +CERT_CA_ID="2f0d9820-e5a8-48bb-aac8-deed9d868a1e" +# Certificate template id +CERT_TEMPLATE_ID="4dbf6bb0-6e86-4ee6-8550-9171428c8e82" +# ACME profile ID +PROFILE_ID="108c6303-ab8c-4986-ab88-eefe11bb5553" +# ACME profile EAB secret +EAB_SECRET="JHYxJDEwJFJldE9tb3dkUU9XVnJLZWFia3IxVC94L1pIbHRoQnJsNVRKZWFoV1hpNTczVHpwMFNGZzU4OGtuU3NVK1crVGM" diff --git a/backend/bdd/features/environment.py b/backend/bdd/features/environment.py index e5c5664c1..366790e63 100644 --- a/backend/bdd/features/environment.py +++ b/backend/bdd/features/environment.py @@ -10,11 +10,9 @@ logging.getLogger("httpx").setLevel(logging.DEBUG) load_dotenv() BASE_URL = os.environ.get("INFISICAL_API_URL", "http://localhost:8080") -PROJECT_ID = os.environ.get("PROJECT_ID", "c051e74c-48a7-4724-832c-d5b496698546") -CERT_CA_ID = os.environ.get("CERT_CA_ID", "2f0d9820-e5a8-48bb-aac8-deed9d868a1e") -CERT_TEMPLATE_ID = os.environ.get( - "CERT_TEMPLATE_ID", "4dbf6bb0-6e86-4ee6-8550-9171428c8e82" -) +PROJECT_ID = os.environ.get("PROJECT_ID") +CERT_CA_ID = os.environ.get("CERT_CA_ID") +CERT_TEMPLATE_ID = os.environ.get("CERT_TEMPLATE_ID") AUTH_TOKEN = os.environ.get("INFISICAL_TOKEN") diff --git a/backend/bdd/features/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py index 3441b851a..d0fa627cd 100644 --- a/backend/bdd/features/steps/pki_acme.py +++ b/backend/bdd/features/steps/pki_acme.py @@ -137,7 +137,7 @@ def step_impl(context: Context, profile_var: str): # TODO: Fixed value for now, just to make test much easier, # we should call infisical API to create such profile instead # in the future - profile_id = "9fda66ee-03f0-4b7c-95ad-542feff77177" + profile_id = os.getenv("PROFILE_ID") kid = profile_id secret = os.getenv("EAB_SECRET") context.vars[profile_var] = AcmeProfile( From 5218195ed4faded10fdf06274118f2fb3e9ee7f2 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 4 Nov 2025 12:14:52 -0800 Subject: [PATCH 200/231] Update for review --- backend/src/ee/routes/v1/pki-acme-router.ts | 10 ++-------- backend/src/ee/services/pki-acme/pki-acme-errors.ts | 4 ++-- backend/src/ee/services/pki-acme/pki-acme-service.ts | 4 +++- 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/backend/src/ee/routes/v1/pki-acme-router.ts b/backend/src/ee/routes/v1/pki-acme-router.ts index 4a516dae2..a66967bbc 100644 --- a/backend/src/ee/routes/v1/pki-acme-router.ts +++ b/backend/src/ee/routes/v1/pki-acme-router.ts @@ -71,7 +71,6 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { done(null, undefined); } const json: unknown = JSON.parse(strBody as string); - // TODO: deal with JWS payload here done(null, json); } catch (err) { const error = err as Error; @@ -97,10 +96,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { 200: GetAcmeDirectoryResponseSchema } }, - handler: async (req) => { - const directory = await server.services.pkiAcme.getAcmeDirectory(req.params.profileId); - return directory; - } + handler: async (req) => server.services.pkiAcme.getAcmeDirectory(req.params.profileId) }); // HEAD /api/v1/pki/acme/profiles//new-nonce @@ -109,7 +105,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { method: "HEAD", url: "/profiles/:profileId/new-nonce", config: { - // TODO: probably a different rate limit for nonce creation + // TODO: probably a different rate limit for nonce creation? rateLimit: readLimit }, schema: { @@ -335,8 +331,6 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { 200: ListAcmeOrdersResponseSchema } }, - // TODO: replace with verify ACME signature here instead - // onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req, res) => { const { profileId, accountId } = await validateExistingAccount({ req, diff --git a/backend/src/ee/services/pki-acme/pki-acme-errors.ts b/backend/src/ee/services/pki-acme/pki-acme-errors.ts index 329cb1989..ed075d63b 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-errors.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-errors.ts @@ -1,6 +1,6 @@ /** - * ACME Error Classes based on RFC 8555 Section 6.2 - * https://datatracker.ietf.org/doc/html/rfc8555#section-6.2 + * ACME Error Classes based on RFC 8555 Section 6.7 + * https://datatracker.ietf.org/doc/html/rfc8555#section-6.7 */ // RFC 8555 Section 6.7 - Error Types 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 985c87063..b9a983ac8 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -478,6 +478,8 @@ export const pkiAcmeServiceFactory = ({ }): Promise> => { // TODO: check and see if we have existing orders for this account that meet the criteria // if we do, return the existing order + // TODO: check the identifiers and see if are they even allowed for this profile. + // if not, we may be able to reject it early with an unsupportedIdentifier error. const order = await acmeOrderDAL.transaction(async (tx) => { const account = (await acmeAccountDAL.findByProjectIdAndAccountId(profileId, accountId))!; @@ -592,7 +594,7 @@ export const pkiAcmeServiceFactory = ({ if (order.status === AcmeOrderStatus.Ready) { const { order: updatedOrder, error } = await acmeOrderDAL.transaction(async (tx) => { const order = (await acmeOrderDAL.findByIdForFinalization(orderId, tx))!; - // TODO: ideally, this should be doen with onRequest: verifyAuth([AuthMode.ACME_JWS_SIGNATURE]), instead + // TODO: ideally, this should be doen with onRequest: verifyAuth([AuthMode.ACME_JWS_SIGNATURE]), instead? const { ownerOrgId: actorOrgId } = (await certificateProfileDAL.findByIdWithOwnerOrgId(profileId, tx))!; if (order.status !== AcmeOrderStatus.Ready) { throw new AcmeOrderNotReadyError({ message: "ACME order is not ready" }); From 8a8f683c4d5c8e32270eebd17b2632ffcb5a7f0e Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 4 Nov 2025 12:28:26 -0800 Subject: [PATCH 201/231] Hide fixme from user --- .../components/CertificateProfilesTab/CreateProfileModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx index eefc76a1d..b15d5daa6 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx @@ -566,7 +566,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" } name="acmeConfig" render={({ field, fieldState: { error } }) => ( -
FIXME: ACME configuration
+
{/* FIXME: ACME configuration */}
)} /> From cc749d5513c4b9b20cfa2d04ad8e701a3707bdb9 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 4 Nov 2025 12:33:05 -0800 Subject: [PATCH 202/231] Add feature flag to gate the feature --- backend/src/ee/routes/v1/index.ts | 8 ++++++-- backend/src/lib/config/env.ts | 4 ++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index a22cd4583..2228c43bd 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -1,11 +1,11 @@ import { registerProjectTemplateRouter } from "@app/ee/routes/v1/project-template-router"; +import { getConfig } from "@app/lib/config/env"; import { registerAccessApprovalPolicyRouter } from "./access-approval-policy-router"; import { registerAccessApprovalRequestRouter } from "./access-approval-request-router"; import { registerAssumePrivilegeRouter } from "./assume-privilege-router"; import { AUDIT_LOG_STREAM_REGISTER_ROUTER_MAP, registerAuditLogStreamRouter } from "./audit-log-stream-routers"; import { registerCaCrlRouter } from "./certificate-authority-crl-router"; -import { registerPkiAcmeRouter } from "./pki-acme-router"; import { registerDeprecatedProjectRoleRouter } from "./deprecated-project-role-router"; import { registerDeprecatedProjectRouter } from "./deprecated-project-router"; import { registerDeprecatedSecretApprovalPolicyRouter } from "./deprecated-secret-approval-policy-router"; @@ -31,6 +31,7 @@ import { PAM_RESOURCE_REGISTER_ROUTER_MAP } from "./pam-resource-routers"; import { registerPamResourceRouter } from "./pam-resource-routers/pam-resource-router"; import { registerPamSessionRouter } from "./pam-session-router"; import { registerPITRouter } from "./pit-router"; +import { registerPkiAcmeRouter } from "./pki-acme-router"; import { registerProjectRoleRouter } from "./project-role-router"; import { registerProjectRouter } from "./project-router"; import { registerRateLimitRouter } from "./rate-limit-router"; @@ -108,7 +109,10 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { await server.register( async (pkiRouter) => { await pkiRouter.register(registerCaCrlRouter, { prefix: "/crl" }); - await pkiRouter.register(registerPkiAcmeRouter, { prefix: "/acme" }); + // Notice: current this feature is still in development and is not yet ready for production. + if (getConfig().ACME_FEATURE_ENABLED === true) { + await pkiRouter.register(registerPkiAcmeRouter, { prefix: "/acme" }); + } }, { prefix: "/pki" } ); diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 6f0502184..94ce79e94 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -106,6 +106,10 @@ const envSchema = z HTTPS_ENABLED: zodStrBool, ROTATION_DEVELOPMENT_MODE: zodStrBool.default("false").optional(), DAILY_RESOURCE_CLEAN_UP_DEVELOPMENT_MODE: zodStrBool.default("false").optional(), + // Note: The ACME feature is still in development and is not yet ready for production. + // This is the feature flag to enable/disable the ACME feature. + // It's not intended to be used by users outside of the development team yet. + ACME_FEATURE_ENABLED: zodStrBool.default("false").optional(), ACME_DEVELOPMENT_MODE: zodStrBool.default("false").optional(), ACME_DEVELOPMENT_HTTP01_CHALLENGE_HOST_OVERRIDES: zpStr( z From f6db76a23c400a70b300e7091acc3f63a398436f Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 4 Nov 2025 12:44:19 -0800 Subject: [PATCH 203/231] Expose feature flag to frontend as well --- backend/src/ee/routes/v1/index.ts | 2 +- backend/src/lib/config/env.ts | 1 + backend/src/server/plugins/serve-ui.ts | 5 ++++- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index 2228c43bd..e104a8355 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -110,7 +110,7 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { async (pkiRouter) => { await pkiRouter.register(registerCaCrlRouter, { prefix: "/crl" }); // Notice: current this feature is still in development and is not yet ready for production. - if (getConfig().ACME_FEATURE_ENABLED === true) { + if (getConfig().isAcmeFeatureEnabled === true) { await pkiRouter.register(registerPkiAcmeRouter, { prefix: "/acme" }); } }, diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 94ce79e94..b60971b1f 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -399,6 +399,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, + isAcmeFeatureEnabled: data.NODE_ENV === "development" && data.ACME_FEATURE_ENABLED === true, isAcmeDevelopmentMode: data.NODE_ENV === "development" && data.ACME_DEVELOPMENT_MODE, isProductionMode: data.NODE_ENV === "production" || IS_PACKAGED, isRedisSentinelMode: Boolean(data.REDIS_SENTINEL_HOSTS), diff --git a/backend/src/server/plugins/serve-ui.ts b/backend/src/server/plugins/serve-ui.ts index b71451b6e..4330f9397 100644 --- a/backend/src/server/plugins/serve-ui.ts +++ b/backend/src/server/plugins/serve-ui.ts @@ -31,7 +31,10 @@ export const registerServeUI = async ( CAPTCHA_SITE_KEY: appCfg.CAPTCHA_SITE_KEY, POSTHOG_API_KEY: appCfg.POSTHOG_PROJECT_API_KEY, INTERCOM_ID: appCfg.INTERCOM_ID, - TELEMETRY_CAPTURING_ENABLED: appCfg.TELEMETRY_ENABLED + TELEMETRY_CAPTURING_ENABLED: appCfg.TELEMETRY_ENABLED, + // The feature flag to enable/disable the ACME feature. + // Will be removed once the feature is ready for production. + ACME_FEATURE_ENABLED: appCfg.isAcmeFeatureEnabled }; const js = `window.__INFISICAL_RUNTIME_ENV__ = Object.freeze(${JSON.stringify(config)});`; return res.send(js); From e8eb238744b6dcc32311a4dbbfba1b13fd04966a Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 4 Nov 2025 13:04:09 -0800 Subject: [PATCH 204/231] Put acme behind the flag --- .../CertificateProfilesTab/CreateProfileModal.tsx | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx index b15d5daa6..bc4aa19be 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx @@ -1,8 +1,8 @@ -import { useEffect } from "react"; -import { Controller, useForm } from "react-hook-form"; import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; @@ -18,6 +18,7 @@ import { TextArea, Tooltip } from "@app/components/v2"; +import { envConfig } from "@app/config/env"; import { useProject } from "@app/context"; import { useListCasByProjectId } from "@app/hooks/api/ca/queries"; import { @@ -109,7 +110,7 @@ const editSchema = z .trim() .max(1000, "Description must be less than 1000 characters") .optional(), - enrollmentType: z.enum(["api", "est"]), + enrollmentType: z.enum(["api", "est", "acme"]), certificateAuthorityId: z.string().optional(), certificateTemplateId: z.string().optional(), estConfig: z @@ -124,7 +125,8 @@ const editSchema = z autoRenew: z.boolean().optional(), renewBeforeDays: z.number().min(1).max(365).optional() }) - .optional() + .optional(), + acmeConfig: z.object({}).optional() }) .refine( (data) => { @@ -134,6 +136,9 @@ const editSchema = z if (data.enrollmentType === "api" && !data.apiConfig) { return false; } + if (data.enrollmentType === "acme" && !data.acmeConfig) { + return false; + } return true; }, { @@ -448,7 +453,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" } > API EST - ACME + {envConfig.isAcmeFeatureEnabled && ACME} )} From 18c13317aef9761148fe7eb130af32c60caa3a10 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 4 Nov 2025 13:08:01 -0800 Subject: [PATCH 205/231] Add feature flag for frontend --- frontend/src/config/env.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/src/config/env.ts b/frontend/src/config/env.ts index 63446c95f..c8100bd16 100644 --- a/frontend/src/config/env.ts +++ b/frontend/src/config/env.ts @@ -26,6 +26,9 @@ export const envConfig = { import.meta.env.VITE_TELEMETRY_CAPTURING_ENABLED === true ); }, + get ACME_FEATURE_ENABLED() { + return window?.__INFISICAL_RUNTIME_ENV__?.ACME_FEATURE_ENABLED ?? false; + }, get PLATFORM_VERSION() { return import.meta.env.VITE_INFISICAL_PLATFORM_VERSION; From c38985a533a196aefad0109a33f4f6a2142a2f52 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 4 Nov 2025 13:14:03 -0800 Subject: [PATCH 206/231] Wrong feature flag --- .../components/CertificateProfilesTab/CreateProfileModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx index bc4aa19be..4395dbb9d 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx @@ -453,7 +453,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" } > API EST - {envConfig.isAcmeFeatureEnabled && ACME} + {envConfig.ACME_FEATURE_ENABLED && ACME} )} From dd30028c5dc284936bf04a1950bf6fc935bb8541 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 4 Nov 2025 13:16:31 -0800 Subject: [PATCH 207/231] Add missing acme stuff --- .../CertificateProfilesTab/CreateProfileModal.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx index 4395dbb9d..2e1705f17 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx @@ -438,12 +438,16 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" } disableBootstrapCaValidation: false, passphrase: "" }); - } else { + } else if (value === "api") { setValue("estConfig", undefined); setValue("apiConfig", { autoRenew: false, renewBeforeDays: 30 }); + } else if (value === "acme") { + setValue("apiConfig", undefined); + setValue("estConfig", undefined); + setValue("acmeConfig", {}); } onChange(value); }} From 4b0f7a80d7d5c967f48ca80280c983e369e2e929 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 4 Nov 2025 13:17:49 -0800 Subject: [PATCH 208/231] Add more missing stuff --- .../CertificateProfilesTab/CreateProfileModal.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx index 2e1705f17..3ad2290b2 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx @@ -390,17 +390,23 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" } {...field} onValueChange={(value) => { if (watchedEnrollmentType === "est") { + setValue("apiConfig", undefined); setValue("estConfig", { disableBootstrapCaValidation: false, passphrase: "" }); - setValue("apiConfig", undefined); - } else { + setValue("acmeConfig", undefined); + } else if (watchedEnrollmentType === "api") { setValue("apiConfig", { autoRenew: false, renewBeforeDays: 30 }); setValue("estConfig", undefined); + setValue("acmeConfig", undefined); + } else if (watchedEnrollmentType === "acme") { + setValue("estConfig", undefined); + setValue("apiConfig", undefined); + setValue("acmeConfig", {}); } onChange(value); }} @@ -438,12 +444,14 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" } disableBootstrapCaValidation: false, passphrase: "" }); + setValue("acmeConfig", undefined); } else if (value === "api") { - setValue("estConfig", undefined); setValue("apiConfig", { autoRenew: false, renewBeforeDays: 30 }); + setValue("estConfig", undefined); + setValue("acmeConfig", undefined); } else if (value === "acme") { setValue("apiConfig", undefined); setValue("estConfig", undefined); From 3ac3f44f1a9eb5a177a22dde09020585bbf338be Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 4 Nov 2025 13:26:10 -0800 Subject: [PATCH 209/231] Fix UI --- frontend/src/global.d.ts | 1 + .../CertificateProfilesTab.tsx | 18 ++++++++++-------- .../RevealAcmeEabSecretModal.tsx | 1 - 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/frontend/src/global.d.ts b/frontend/src/global.d.ts index 30b80cb70..a6de7ac8c 100644 --- a/frontend/src/global.d.ts +++ b/frontend/src/global.d.ts @@ -7,6 +7,7 @@ declare global { POSTHOG_API_KEY?: string; INTERCOM_ID?: string; TELEMETRY_CAPTURING_ENABLED: string; + ACME_FEATURE_ENABLED?: boolean; }; } } diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CertificateProfilesTab.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CertificateProfilesTab.tsx index 9ee238743..78517b49f 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CertificateProfilesTab.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CertificateProfilesTab.tsx @@ -113,14 +113,16 @@ export const CertificateProfilesTab = () => { mode="edit" /> - { - setIsRevealProfileAcmeEabSecretModalOpen(false); - setSelectedProfile(null); - }} - profile={selectedProfile} - /> + {selectedProfile.enrollmentType === "acme" && ( + { + setIsRevealProfileAcmeEabSecretModalOpen(false); + setSelectedProfile(null); + }} + profile={selectedProfile} + /> + )} const [isEabSecretCopied, setIsEabSecretCopied] = useToggle(false); const revealAcmeEabSecret = useRevealAcmeEabSecret({ profileId: profile.id }); const { data, isLoading, isError, error } = revealAcmeEabSecret; - const { directoryUrl } = profile.acmeConfig!; const { eabKid, eabSecret } = data ?? { eabKid: "", eabSecret: "" }; From 69381fa6ddedb3202912c20e47bbf52bd30b7ef1 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 4 Nov 2025 13:39:43 -0800 Subject: [PATCH 210/231] Add missing stuff for ui --- .../certificate-profile-schemas.ts | 20 ++++++++++++++++++- .../CreateProfileModal.tsx | 2 ++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/backend/src/services/certificate-profile/certificate-profile-schemas.ts b/backend/src/services/certificate-profile/certificate-profile-schemas.ts index 8ac494fe6..bf88593bd 100644 --- a/backend/src/services/certificate-profile/certificate-profile-schemas.ts +++ b/backend/src/services/certificate-profile/certificate-profile-schemas.ts @@ -27,7 +27,8 @@ export const createCertificateProfileSchema = z autoRenew: z.boolean().default(false), renewBeforeDays: z.number().min(1).max(30).optional() }) - .optional() + .optional(), + acmeConfig: z.object({}).optional() }) .refine( (data) => { @@ -38,6 +39,9 @@ export const createCertificateProfileSchema = z if (data.apiConfig) { return false; } + if (data.acmeConfig) { + return false; + } } if (data.enrollmentType === EnrollmentType.API) { if (!data.apiConfig) { @@ -46,6 +50,20 @@ export const createCertificateProfileSchema = z if (data.estConfig) { return false; } + if (data.acmeConfig) { + return false; + } + } + if (data.enrollmentType === EnrollmentType.ACME) { + if (!data.acmeConfig) { + return false; + } + if (data.estConfig) { + return false; + } + if (data.apiConfig) { + return false; + } } return true; }, diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx index 3ad2290b2..c3ed8478a 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx @@ -288,6 +288,8 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" } }; } else if (data.enrollmentType === "api" && data.apiConfig) { createData.apiConfig = data.apiConfig; + } else if (data.enrollmentType === "acme" && data.acmeConfig) { + createData.acmeConfig = data.acmeConfig; } await createProfile.mutateAsync(createData); From 7faac62c8ad55405fb72d4f82ad13e32c6fcdeb8 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 4 Nov 2025 14:22:10 -0800 Subject: [PATCH 211/231] Fix linter errors --- backend/bdd/features/environment.py | 3 --- .../certificate-profile-service.test.ts | 13 +++++++++++++ .../CertificateProfilesTab/CreateProfileModal.tsx | 2 +- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/backend/bdd/features/environment.py b/backend/bdd/features/environment.py index 366790e63..b355f98ae 100644 --- a/backend/bdd/features/environment.py +++ b/backend/bdd/features/environment.py @@ -3,9 +3,6 @@ import os import httpx from behave.runner import Context from dotenv import load_dotenv -import logging - -logging.getLogger("httpx").setLevel(logging.DEBUG) load_dotenv() diff --git a/backend/src/services/certificate-profile/certificate-profile-service.test.ts b/backend/src/services/certificate-profile/certificate-profile-service.test.ts index bb30b8d5c..bb869d16f 100644 --- a/backend/src/services/certificate-profile/certificate-profile-service.test.ts +++ b/backend/src/services/certificate-profile/certificate-profile-service.test.ts @@ -10,6 +10,7 @@ import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { ActorType, AuthMethod } from "../auth/auth-type"; import type { TCertificateTemplateV2DALFactory } from "../certificate-template-v2/certificate-template-v2-dal"; +import { TAcmeEnrollmentConfigDALFactory } from "../enrollment-config/acme-enrollment-config-dal"; import type { TApiEnrollmentConfigDALFactory } from "../enrollment-config/api-enrollment-config-dal"; import type { TEstEnrollmentConfigDALFactory } from "../enrollment-config/est-enrollment-config-dal"; import type { TKmsServiceFactory } from "../kms/kms-service"; @@ -142,6 +143,17 @@ describe("CertificateProfileService", () => { delete: vi.fn() } as unknown as TEstEnrollmentConfigDALFactory; + const mockAcmeEnrollmentConfigDAL = { + create: vi.fn().mockResolvedValue({ id: "acme-config-123" }), + findById: vi.fn(), + updateById: vi.fn(), + transaction: vi.fn(), + find: vi.fn(), + findOne: vi.fn(), + update: vi.fn(), + delete: vi.fn() + } as unknown as TAcmeEnrollmentConfigDALFactory; + const mockPermissionService = { getProjectPermission: vi.fn().mockResolvedValue({ permission: { @@ -182,6 +194,7 @@ describe("CertificateProfileService", () => { certificateTemplateV2DAL: mockCertificateTemplateV2DAL, apiEnrollmentConfigDAL: mockApiEnrollmentConfigDAL, estEnrollmentConfigDAL: mockEstEnrollmentConfigDAL, + acmeEnrollmentConfigDAL: mockAcmeEnrollmentConfigDAL, permissionService: mockPermissionService, kmsService: mockKmsService, projectDAL: mockProjectDAL diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx index c3ed8478a..40bc1f671 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx @@ -583,7 +583,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" } ( + render={({ fieldState: { error } }) => (
{/* FIXME: ACME configuration */}
From 4791b16b00a256fabd22c4e85d0c18e40a8a6798 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 4 Nov 2025 16:05:54 -0800 Subject: [PATCH 212/231] Fix lint --- .../pki-acme/pki-acme-challenge-service.ts | 30 ++++++++++++------- .../ee/services/pki-acme/pki-acme-service.ts | 18 +++++------ 2 files changed, 28 insertions(+), 20 deletions(-) diff --git a/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts index 3b858e427..52a277c31 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts @@ -6,6 +6,10 @@ import { AcmeConnectionError, AcmeDnsFailureError, AcmeIncorrectResponseError } import { AcmeAuthStatus, AcmeChallengeStatus, AcmeChallengeType } from "./pki-acme-schemas"; import { TPkiAcmeChallengeServiceFactory } from "./pki-acme-types"; +type FetchError = Error & { + code?: string; +}; + type TPkiAcmeChallengeServiceFactoryDep = { acmeChallengeDAL: Pick< TPkiAcmeChallengeDALFactory, @@ -69,27 +73,33 @@ export const pkiAcmeChallengeServiceFactory = ({ throw new AcmeIncorrectResponseError({ message: "ACME challenge response is not correct" }); } await acmeChallengeDAL.markAsValidCascadeById(challengeId, tx); - } catch (error) { + } catch (exp) { // TODO: we should retry the challenge validation a few times, but let's keep it simple for now await acmeChallengeDAL.markAsInvalidCascadeById(challengeId, tx); // Properly type and inspect the error - if (error instanceof TypeError && error.message.includes("fetch failed")) { - const cause = error.cause; - const errors = cause instanceof AggregateError ? cause.errors : cause instanceof Error ? [cause] : []; + if (exp instanceof TypeError && exp.message.includes("fetch failed")) { + const { cause } = exp; + let errors: Error[] = []; + if (cause instanceof AggregateError) { + errors = cause.errors; + } else if (cause instanceof Error) { + errors = [cause]; + } for (const err of errors) { // TODO: handle multiple errors, return a compound error instead of just the first error - if (err?.code === "ECONNREFUSED" || err?.message?.includes("ECONNREFUSED")) { + const fetchError = err as FetchError; + if (fetchError.code === "ECONNREFUSED" || fetchError.message.includes("ECONNREFUSED")) { return new AcmeConnectionError({ message: "Connection refused" }); - } else if (err?.code === "ENOTFOUND" || err?.message?.includes("ENOTFOUND")) { + } else if (fetchError.code === "ENOTFOUND" || fetchError.message.includes("ENOTFOUND")) { return new AcmeDnsFailureError({ message: "Hostname could not be resolved (DNS failure)" }); } } - } else if (error instanceof Error) { - logger.error(error, "Error validating ACME challenge response"); + } else if (exp instanceof Error) { + logger.error(exp, "Error validating ACME challenge response"); } else { - logger.error(error, "Unknown error validating ACME challenge response"); + logger.error(exp, "Unknown error validating ACME challenge response"); } - return error; + return exp; } }); if (error) { 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 b9a983ac8..9f848748e 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -129,7 +129,7 @@ export const pkiAcmeServiceFactory = ({ }; const validateJwsPayload = async < - TSchema extends z.ZodSchema | undefined = undefined, + TSchema extends z.ZodSchema | undefined = undefined, T = TSchema extends z.ZodSchema ? R : string >({ url, @@ -149,7 +149,8 @@ export const pkiAcmeServiceFactory = ({ throw new AcmeMalformedError({ detail: "Protected header is required" }); } const jwk = await getJWK(protectedHeader); - return await importJWK(jwk, protectedHeader.alg); + const key = await importJWK(jwk, protectedHeader.alg); + return key; }); } catch (error) { if (error instanceof AcmeError) { @@ -186,7 +187,7 @@ export const pkiAcmeServiceFactory = ({ const payload = schema ? schema.parse(JSON.parse(textPayload)) : textPayload; return { protectedHeader, - payload + payload: payload as T }; } catch (error) { if (error instanceof AcmeError) { @@ -200,14 +201,14 @@ export const pkiAcmeServiceFactory = ({ } }; - const validateNewAccountJwsPayload = async ({ + const validateNewAccountJwsPayload = ({ url, rawJwsPayload }: { url: URL; rawJwsPayload: TRawJwsPayload; }): Promise> => { - return await validateJwsPayload({ + return validateJwsPayload({ url, rawJwsPayload, getJWK: async (protectedHeader) => { @@ -355,11 +356,8 @@ export const pkiAcmeServiceFactory = ({ const eabSecret = await kmsDecryptor({ cipherTextBlob: profile.acmeConfig!.encryptedEabSecret! }); const { eabPayload, eabProtectedHeader } = await (async () => { try { - const { payload: eabPayload, protectedHeader: eabProtectedHeader } = await flattenedVerify( - externalAccountBinding, - eabSecret - ); - return { eabPayload, eabProtectedHeader }; + const result = await flattenedVerify(externalAccountBinding, eabSecret); + return { eabPayload: result.payload, eabProtectedHeader: result.protectedHeader }; } catch (error) { if (error instanceof errors.JWSSignatureVerificationFailed) { throw new AcmeMalformedError({ detail: "Invalid external account binding JWS signature" }); From e02afc5eefed6b60c53518824c172c78d9de9978 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 4 Nov 2025 16:15:00 -0800 Subject: [PATCH 213/231] Linter --- backend/src/ee/services/pki-acme/pki-acme-errors.ts | 2 ++ backend/src/ee/services/pki-acme/pki-acme-order-dal.ts | 2 +- backend/src/ee/services/pki-acme/pki-acme-types.ts | 5 ++--- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/backend/src/ee/services/pki-acme/pki-acme-errors.ts b/backend/src/ee/services/pki-acme/pki-acme-errors.ts index ed075d63b..5872addae 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-errors.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-errors.ts @@ -3,6 +3,8 @@ * https://datatracker.ietf.org/doc/html/rfc8555#section-6.7 */ +/* eslint-disable max-classes-per-file */ + // RFC 8555 Section 6.7 - Error Types export enum AcmeErrorType { AccountDoesNotExist = "accountDoesNotExist", 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 index ca02b09bb..bb3671daa 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-order-dal.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-order-dal.ts @@ -1,7 +1,7 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; -import { TableName, TPkiAcmeAuths, TPkiAcmeOrderAuths } from "@app/db/schemas"; +import { TableName } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; 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 e9154fb5d..3132c9812 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-types.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-types.ts @@ -1,6 +1,5 @@ import { z } from "zod"; -import { TPkiAcmeChallenges } from "@app/db/schemas"; import { JWSHeaderParameters } from "jose"; import { AcmeOrderResourceSchema, @@ -51,7 +50,7 @@ export type TAcmeResponse = { export type TPkiAcmeServiceFactory = { validateJwsPayload: < - TSchema extends z.ZodSchema | undefined = undefined, + TSchema extends z.ZodSchema | undefined = undefined, T = TSchema extends z.ZodSchema ? R : string >({ url, @@ -72,7 +71,7 @@ export type TPkiAcmeServiceFactory = { rawJwsPayload: TRawJwsPayload; }) => Promise>; validateExistingAccountJwsPayload: < - TSchema extends z.ZodSchema | undefined = undefined, + TSchema extends z.ZodSchema | undefined = undefined, T = TSchema extends z.ZodSchema ? R : string >({ url, From a7b6c97dbc26994c6e2fb533a3fc184572108da6 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 4 Nov 2025 16:17:29 -0800 Subject: [PATCH 214/231] Try to fix tests --- .../certificate-profile/certificate-profile-service.test.ts | 1 + .../services/certificate-profile/certificate-profile-types.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/src/services/certificate-profile/certificate-profile-service.test.ts b/backend/src/services/certificate-profile/certificate-profile-service.test.ts index bb869d16f..e79c0cf3e 100644 --- a/backend/src/services/certificate-profile/certificate-profile-service.test.ts +++ b/backend/src/services/certificate-profile/certificate-profile-service.test.ts @@ -247,6 +247,7 @@ describe("CertificateProfileService", () => { certificateTemplateId: "template-123", apiConfigId: "api-config-123", estConfigId: null, + acmeConfig: null, projectId: "project-123" }, undefined diff --git a/backend/src/services/certificate-profile/certificate-profile-types.ts b/backend/src/services/certificate-profile/certificate-profile-types.ts index 82ba0f378..4a3339857 100644 --- a/backend/src/services/certificate-profile/certificate-profile-types.ts +++ b/backend/src/services/certificate-profile/certificate-profile-types.ts @@ -29,7 +29,7 @@ export type TCertificateProfileUpdate = Omit Date: Tue, 4 Nov 2025 16:29:42 -0800 Subject: [PATCH 215/231] Lint --- backend/src/ee/routes/v1/pki-acme-router.ts | 5 +- .../pki-acme/pki-acme-challenge-service.ts | 10 ++- .../services/pki-acme/pki-acme-order-dal.ts | 4 +- .../ee/services/pki-acme/pki-acme-service.ts | 87 +++++++++---------- 4 files changed, 55 insertions(+), 51 deletions(-) diff --git a/backend/src/ee/routes/v1/pki-acme-router.ts b/backend/src/ee/routes/v1/pki-acme-router.ts index a66967bbc..8e300f8f6 100644 --- a/backend/src/ee/routes/v1/pki-acme-router.ts +++ b/backend/src/ee/routes/v1/pki-acme-router.ts @@ -33,8 +33,9 @@ export interface MyRequestInterface { export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { const validateExistingAccount = async < + // eslint-disable-next-line @typescript-eslint/no-explicit-any R extends FastifyRequest, - TSchema extends z.ZodSchema | undefined = undefined, + TSchema extends z.ZodSchema | undefined = undefined, T = TSchema extends z.ZodSchema ? R : string >({ req, @@ -70,7 +71,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { if (!strBody) { done(null, undefined); } - const json: unknown = JSON.parse(strBody as string); + const json = JSON.parse(strBody as string); done(null, json); } catch (err) { const error = err as Error; diff --git a/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts index 52a277c31..9567072a1 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts @@ -2,7 +2,12 @@ import { getConfig } from "@app/lib/config/env"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { TPkiAcmeChallengeDALFactory } from "./pki-acme-challenge-dal"; -import { AcmeConnectionError, AcmeDnsFailureError, AcmeIncorrectResponseError } from "./pki-acme-errors"; +import { + AcmeConnectionError, + AcmeDnsFailureError, + AcmeIncorrectResponseError, + AcmeServerInternalError +} from "./pki-acme-errors"; import { AcmeAuthStatus, AcmeChallengeStatus, AcmeChallengeType } from "./pki-acme-schemas"; import { TPkiAcmeChallengeServiceFactory } from "./pki-acme-types"; @@ -23,7 +28,7 @@ export const pkiAcmeChallengeServiceFactory = ({ const appCfg = getConfig(); const validateChallengeResponse = async (challengeId: string): Promise => { - const error = await acmeChallengeDAL.transaction(async (tx) => { + const error: Error | undefined = await acmeChallengeDAL.transaction(async (tx) => { logger.info({ challengeId }, "Validating ACME challenge response"); const challenge = await acmeChallengeDAL.findByIdForChallengeValidation(challengeId, tx); if (!challenge) { @@ -98,6 +103,7 @@ export const pkiAcmeChallengeServiceFactory = ({ logger.error(exp, "Error validating ACME challenge response"); } else { logger.error(exp, "Unknown error validating ACME challenge response"); + return new AcmeServerInternalError({ message: "Unknown error validating ACME challenge response" }); } return exp; } 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 index bb3671daa..5aab0be63 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-order-dal.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-order-dal.ts @@ -48,8 +48,8 @@ export const pkiAcmeOrderDALFactory = (db: TDbClient) => { label: "authorizations" as const, mapper: ({ authId, identifierType, identifierValue, authExpiresAt }) => ({ id: authId, - identifierType: identifierType, - identifierValue: identifierValue, + identifierType, + identifierValue, expiresAt: authExpiresAt }) } 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 9f848748e..91e444e8d 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -441,14 +441,13 @@ export const pkiAcmeServiceFactory = ({ const deactivateAcmeAccount = async ({ profileId, - accountId, - payload: { status } = { status: "deactivated" } + accountId }: { profileId: string; accountId: string; payload?: TDeactivateAcmeAccountPayload; }): Promise> => { - const profile = await validateAcmeProfile(profileId); + await validateAcmeProfile(profileId); // FIXME: Implement ACME account deactivation return { status: 200, @@ -494,36 +493,35 @@ export const pkiAcmeServiceFactory = ({ ); const authorizations: TPkiAcmeAuths[] = await Promise.all( payload.identifiers.map(async (identifier) => { - if (identifier.type === AcmeIdentifierType.DNS) { - // TODO: reuse existing authorizations for this identifier if they exist - const auth = await acmeAuthDAL.create( - { - accountId: account.id, - status: AcmeAuthStatus.Pending, - identifierType: identifier.type, - identifierValue: identifier.value, - // RFC 8555 suggests a token with at least 128 bits of entropy - // We are using 256 bits of entropy here, should be enough for now - // ref: https://datatracker.ietf.org/doc/html/rfc8555#section-11.3 - token: crypto.randomBytes(32).toString("base64url"), - // TODO: read config from the profile to get the expiration time instead - expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000) - }, - tx - ); - // TODO: support other challenge types here. Currently only HTTP-01 is supported. - await acmeChallengeDAL.create( - { - authId: auth.id, - status: AcmeChallengeStatus.Pending, - type: AcmeChallengeType.HTTP_01 - }, - tx - ); - return auth; - } else { + if (identifier.type !== AcmeIdentifierType.DNS) { throw new AcmeUnsupportedIdentifierError({ detail: "Only DNS identifiers are supported" }); } + // TODO: reuse existing authorizations for this identifier if they exist + const auth = await acmeAuthDAL.create( + { + accountId: account.id, + status: AcmeAuthStatus.Pending, + identifierType: identifier.type, + identifierValue: identifier.value, + // RFC 8555 suggests a token with at least 128 bits of entropy + // We are using 256 bits of entropy here, should be enough for now + // ref: https://datatracker.ietf.org/doc/html/rfc8555#section-11.3 + token: crypto.randomBytes(32).toString("base64url"), + // TODO: read config from the profile to get the expiration time instead + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000) + }, + tx + ); + // TODO: support other challenge types here. Currently only HTTP-01 is supported. + await acmeChallengeDAL.create( + { + authId: auth.id, + status: AcmeChallengeStatus.Pending, + type: AcmeChallengeType.HTTP_01 + }, + tx + ); + return auth; }) ); @@ -591,13 +589,13 @@ export const pkiAcmeServiceFactory = ({ } if (order.status === AcmeOrderStatus.Ready) { const { order: updatedOrder, error } = await acmeOrderDAL.transaction(async (tx) => { - const order = (await acmeOrderDAL.findByIdForFinalization(orderId, tx))!; + const finalizingOrder = (await acmeOrderDAL.findByIdForFinalization(orderId, tx))!; // TODO: ideally, this should be doen with onRequest: verifyAuth([AuthMode.ACME_JWS_SIGNATURE]), instead? const { ownerOrgId: actorOrgId } = (await certificateProfileDAL.findByIdWithOwnerOrgId(profileId, tx))!; - if (order.status !== AcmeOrderStatus.Ready) { + if (finalizingOrder.status !== AcmeOrderStatus.Ready) { throw new AcmeOrderNotReadyError({ message: "ACME order is not ready" }); } - if (order.expiresAt < new Date()) { + if (finalizingOrder.expiresAt < new Date()) { throw new AcmeOrderNotReadyError({ message: "ACME order has expired" }); } const { csr } = payload; @@ -612,8 +610,8 @@ export const pkiAcmeServiceFactory = ({ actorOrgId, profileId, csr, - notBefore: order.notBefore ? new Date(order.notBefore) : undefined, - notAfter: order.notAfter ? new Date(order.notAfter) : undefined, + notBefore: finalizingOrder.notBefore ? new Date(finalizingOrder.notBefore) : undefined, + notAfter: finalizingOrder.notAfter ? new Date(finalizingOrder.notAfter) : undefined, validity: { // TODO: read config from the profile to get the expiration time instead ttl: (24 * 60 * 60 * 1000).toString() @@ -630,20 +628,20 @@ export const pkiAcmeServiceFactory = ({ }, tx ); - } catch (error) { + } catch (exp) { await acmeOrderDAL.updateById( orderId, { csr, status: AcmeOrderStatus.Invalid, - error: error instanceof Error ? error.message : "Unknown error" + error: exp instanceof Error ? exp.message : "Unknown error" }, tx ); - logger.error(error, "Failed to sign certificate"); + logger.error(exp, "Failed to sign certificate"); // TODO: audit log the error - if (error instanceof BadRequestError) { - errorToReturn = new AcmeBadCSRError({ detail: `Invalid CSR: ${error.message}` }); + if (exp instanceof BadRequestError) { + errorToReturn = new AcmeBadCSRError({ detail: `Invalid CSR: ${exp.message}` }); } else { errorToReturn = new AcmeServerInternalError({ detail: "Failed to sign certificate with internal error" }); } @@ -710,15 +708,14 @@ export const pkiAcmeServiceFactory = ({ }); const certificateChain = decryptedCertChain.toString(); + const certLeaf = certObj.toString("pem").trim().replace("\n", "\r\n"); + const certChain = certificateChain.trim().replace("\n", "\r\n"); return { status: 200, body: - certObj.toString("pem").trim().replace("\n", "\r\n") + - "\r\n" + - certificateChain.trim().replace("\n", "\r\n") + // The final line is needed, otherwise some clients will not parse the certificate chain correctly // ref: https://github.com/certbot/certbot/blob/4d5d5f7ae8164884c841969e46caed8db1ad34af/certbot/src/certbot/crypto_util.py#L506-L514 - "\r\n", + `${certLeaf}\r\n${certChain}\r\n`, headers: { Location: buildUrl(profileId, `/orders/${orderId}/certificate`), Link: `<${buildUrl(profileId, "/directory")}>;rel="index"` From 694a13fce2ff242e1ab9a2a4c8f79532dc1de954 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 4 Nov 2025 16:35:33 -0800 Subject: [PATCH 216/231] More linter --- .../src/ee/services/pki-acme/pki-acme-challenge-service.ts | 4 +++- backend/src/ee/services/pki-acme/pki-acme-service.ts | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts index 9567072a1..f266aef98 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts @@ -86,7 +86,7 @@ export const pkiAcmeChallengeServiceFactory = ({ const { cause } = exp; let errors: Error[] = []; if (cause instanceof AggregateError) { - errors = cause.errors; + errors = cause.errors as Error[]; } else if (cause instanceof Error) { errors = [cause]; } @@ -97,6 +97,8 @@ export const pkiAcmeChallengeServiceFactory = ({ return new AcmeConnectionError({ message: "Connection refused" }); } else if (fetchError.code === "ENOTFOUND" || fetchError.message.includes("ENOTFOUND")) { return new AcmeDnsFailureError({ message: "Hostname could not be resolved (DNS failure)" }); + } else { + return new AcmeServerInternalError({ message: "Unknown error validating ACME challenge response" }); } } } else if (exp instanceof Error) { 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 91e444e8d..dfde68891 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -222,6 +222,7 @@ export const pkiAcmeServiceFactory = ({ }; const validateExistingAccountJwsPayload = async < + // eslint-disable-next-line @typescript-eslint/no-explicit-any TSchema extends z.ZodSchema | undefined = undefined, T = TSchema extends z.ZodSchema ? R : string >({ @@ -385,7 +386,7 @@ export const pkiAcmeServiceFactory = ({ // Make sure the JWK in the EAB payload matches the one provided in the outer JWS payload const decoder = new TextDecoder(); const decodedEabPayload = decoder.decode(eabPayload); - const eabJWK = JSON.parse(decodedEabPayload); + const eabJWK = JSON.parse(decodedEabPayload) as JsonWebKey; const eabPayloadJwkThumbprint = await calculateJwkThumbprint(eabJWK, "sha256"); if (eabPayloadJwkThumbprint !== publicKeyThumbprint) { throw new AcmeBadPublicKeyError({ From cb2810b263bdfe9cd457b31d12fabaf1d740bcbc Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 4 Nov 2025 16:39:50 -0800 Subject: [PATCH 217/231] More linter --- backend/src/ee/routes/v1/pki-acme-router.ts | 4 ++-- .../src/ee/services/pki-acme/pki-acme-challenge-service.ts | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/src/ee/routes/v1/pki-acme-router.ts b/backend/src/ee/routes/v1/pki-acme-router.ts index 8e300f8f6..aa8f23766 100644 --- a/backend/src/ee/routes/v1/pki-acme-router.ts +++ b/backend/src/ee/routes/v1/pki-acme-router.ts @@ -44,7 +44,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { req: R; schema?: TSchema; }): Promise> => { - return await server.services.pkiAcme.validateExistingAccountJwsPayload({ + return server.services.pkiAcme.validateExistingAccountJwsPayload({ url: new URL(req.url, `${req.protocol}://${req.hostname}`), profileId: (req.params as { profileId: string }).profileId, rawJwsPayload: req.body as TRawJwsPayload, @@ -71,7 +71,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { if (!strBody) { done(null, undefined); } - const json = JSON.parse(strBody as string); + const json = JSON.parse(strBody); done(null, json); } catch (err) { const error = err as Error; diff --git a/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts index f266aef98..cbc6abbab 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts @@ -90,6 +90,7 @@ export const pkiAcmeChallengeServiceFactory = ({ } else if (cause instanceof Error) { errors = [cause]; } + // eslint-disable-next-line no-unreachable-loop for (const err of errors) { // TODO: handle multiple errors, return a compound error instead of just the first error const fetchError = err as FetchError; From 62dbd3248a5c3e0895feccffd5b671e8af4e70c2 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 4 Nov 2025 16:57:07 -0800 Subject: [PATCH 218/231] More lint --- backend/src/ee/routes/v1/pki-acme-router.ts | 4 ++-- .../ee/services/pki-acme/pki-acme-challenge-dal.ts | 14 ++++++++------ 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/backend/src/ee/routes/v1/pki-acme-router.ts b/backend/src/ee/routes/v1/pki-acme-router.ts index aa8f23766..b1419c086 100644 --- a/backend/src/ee/routes/v1/pki-acme-router.ts +++ b/backend/src/ee/routes/v1/pki-acme-router.ts @@ -36,7 +36,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any R extends FastifyRequest, TSchema extends z.ZodSchema | undefined = undefined, - T = TSchema extends z.ZodSchema ? R : string + T = TSchema extends z.ZodSchema ? U : string >({ req, schema @@ -71,7 +71,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { if (!strBody) { done(null, undefined); } - const json = JSON.parse(strBody); + const json: unknown = JSON.parse(strBody); done(null, json); } catch (err) { const error = err as Error; diff --git a/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts b/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts index 424651aea..a07dc401d 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts @@ -35,8 +35,9 @@ export const pkiAcmeChallengeDALFactory = (db: TDbClient) => { ); // Update status for pending orders that have all auths valid await (tx || db)(TableName.PkiAcmeOrder) - .whereIn("id", (qb) => { - qb.select("o2.id") + .whereIn("id", async (qb) => { + void (await qb + .select("o2.id") .from({ o2: TableName.PkiAcmeOrder }) .join({ oa2: TableName.PkiAcmeOrderAuth }, "o2.id", "oa2.orderId") .join({ a2: TableName.PkiAcmeAuth }, "oa2.authId", "a2.id") @@ -47,7 +48,7 @@ export const pkiAcmeChallengeDALFactory = (db: TDbClient) => { ]) // Only update orders that are pending .where("o2.status", AcmeOrderStatus.Pending) - .whereIn("o2.id", involvedOrderIds); + .whereIn("o2.id", involvedOrderIds)); }) .update({ status: AcmeOrderStatus.Ready }); } @@ -74,8 +75,9 @@ export const pkiAcmeChallengeDALFactory = (db: TDbClient) => { if (updatedAuths.length > 0) { // Update status for pending orders that have all auths valid await (tx || db)(TableName.PkiAcmeOrder) - .whereIn("id", (qb) => { - qb.select("o.id") + .whereIn("id", async (qb) => { + void (await qb + .select("o.id") .from({ o: TableName.PkiAcmeOrder }) .join(TableName.PkiAcmeOrderAuth, "o.id", `${TableName.PkiAcmeOrderAuth}.orderId`) .join(TableName.PkiAcmeAuth, `${TableName.PkiAcmeOrderAuth}.authId`, `${TableName.PkiAcmeAuth}.id`) @@ -84,7 +86,7 @@ export const pkiAcmeChallengeDALFactory = (db: TDbClient) => { .whereIn( `${TableName.PkiAcmeAuth}.id`, updatedAuths.map((auth) => auth.id) - ); + )); }) .update({ status: AcmeOrderStatus.Invalid }); } From 9ad9d5cc32de213985996578511040ed538ff839 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 4 Nov 2025 16:58:14 -0800 Subject: [PATCH 219/231] Fix test --- .../certificate-profile/certificate-profile-service.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/services/certificate-profile/certificate-profile-service.test.ts b/backend/src/services/certificate-profile/certificate-profile-service.test.ts index e79c0cf3e..ffca3c1cb 100644 --- a/backend/src/services/certificate-profile/certificate-profile-service.test.ts +++ b/backend/src/services/certificate-profile/certificate-profile-service.test.ts @@ -247,7 +247,7 @@ describe("CertificateProfileService", () => { certificateTemplateId: "template-123", apiConfigId: "api-config-123", estConfigId: null, - acmeConfig: null, + acmeConfigId: null, projectId: "project-123" }, undefined From 98ecd18e0d4222a634dcce9d1e323b9898b33b33 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 4 Nov 2025 17:21:44 -0800 Subject: [PATCH 220/231] Fix linter --- .../ee/services/pki-acme/pki-acme-challenge-dal.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts b/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts index a07dc401d..948f4af6e 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts @@ -35,8 +35,8 @@ export const pkiAcmeChallengeDALFactory = (db: TDbClient) => { ); // Update status for pending orders that have all auths valid await (tx || db)(TableName.PkiAcmeOrder) - .whereIn("id", async (qb) => { - void (await qb + .whereIn("id", (qb) => { + void qb .select("o2.id") .from({ o2: TableName.PkiAcmeOrder }) .join({ oa2: TableName.PkiAcmeOrderAuth }, "o2.id", "oa2.orderId") @@ -48,7 +48,7 @@ export const pkiAcmeChallengeDALFactory = (db: TDbClient) => { ]) // Only update orders that are pending .where("o2.status", AcmeOrderStatus.Pending) - .whereIn("o2.id", involvedOrderIds)); + .whereIn("o2.id", involvedOrderIds); }) .update({ status: AcmeOrderStatus.Ready }); } @@ -75,8 +75,8 @@ export const pkiAcmeChallengeDALFactory = (db: TDbClient) => { if (updatedAuths.length > 0) { // Update status for pending orders that have all auths valid await (tx || db)(TableName.PkiAcmeOrder) - .whereIn("id", async (qb) => { - void (await qb + .whereIn("id", (qb) => { + void qb .select("o.id") .from({ o: TableName.PkiAcmeOrder }) .join(TableName.PkiAcmeOrderAuth, "o.id", `${TableName.PkiAcmeOrderAuth}.orderId`) @@ -86,7 +86,7 @@ export const pkiAcmeChallengeDALFactory = (db: TDbClient) => { .whereIn( `${TableName.PkiAcmeAuth}.id`, updatedAuths.map((auth) => auth.id) - )); + ); }) .update({ status: AcmeOrderStatus.Invalid }); } From df175b66de7d01a09b709b7cb86347790a0698ca Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 4 Nov 2025 17:23:19 -0800 Subject: [PATCH 221/231] Fix linter --- .../ee/services/pki-acme/pki-acme-challenge-service.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts index cbc6abbab..6b820032b 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts @@ -96,11 +96,11 @@ export const pkiAcmeChallengeServiceFactory = ({ const fetchError = err as FetchError; if (fetchError.code === "ECONNREFUSED" || fetchError.message.includes("ECONNREFUSED")) { return new AcmeConnectionError({ message: "Connection refused" }); - } else if (fetchError.code === "ENOTFOUND" || fetchError.message.includes("ENOTFOUND")) { - return new AcmeDnsFailureError({ message: "Hostname could not be resolved (DNS failure)" }); - } else { - return new AcmeServerInternalError({ message: "Unknown error validating ACME challenge response" }); } + if (fetchError.code === "ENOTFOUND" || fetchError.message.includes("ENOTFOUND")) { + return new AcmeDnsFailureError({ message: "Hostname could not be resolved (DNS failure)" }); + } + return new AcmeServerInternalError({ message: "Unknown error validating ACME challenge response" }); } } else if (exp instanceof Error) { logger.error(exp, "Error validating ACME challenge response"); From eebf62f30c0c6d0f63b4fa51a2bf021abee22570 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 4 Nov 2025 17:40:13 -0800 Subject: [PATCH 222/231] Fix linter --- frontend/src/hooks/api/certificateProfiles/types.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/hooks/api/certificateProfiles/types.ts b/frontend/src/hooks/api/certificateProfiles/types.ts index 7acb6fef9..c2b38e8e8 100644 --- a/frontend/src/hooks/api/certificateProfiles/types.ts +++ b/frontend/src/hooks/api/certificateProfiles/types.ts @@ -58,7 +58,7 @@ export type TCreateCertificateProfileDTO = { autoRenew?: boolean; renewBeforeDays?: number; }; - acmeConfig?: {}; + acmeConfig?: unknown; }; export type TUpdateCertificateProfileDTO = { @@ -74,7 +74,7 @@ export type TUpdateCertificateProfileDTO = { autoRenew?: boolean; renewBeforeDays?: number; }; - acmeConfig?: {}; + acmeConfig?: unknown; }; export type TDeleteCertificateProfileDTO = { From d4c9c4464b74b35aefc8176e59495b7fdeb21506 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 4 Nov 2025 19:48:52 -0800 Subject: [PATCH 223/231] Fix review feedbacks --- .../pki-acme/pki-acme-challenge-service.ts | 5 +++++ .../ee/services/pki-acme/pki-acme-service.ts | 7 ++++--- backend/src/server/routes/index.ts | 1 + .../certificate-v3-service.test.ts | 6 ++++++ .../certificate-v3/certificate-v3-service.ts | 17 ++++++++++++++--- 5 files changed, 30 insertions(+), 6 deletions(-) diff --git a/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts index 6b820032b..f4100c6aa 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts @@ -1,5 +1,6 @@ import { getConfig } from "@app/lib/config/env"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { isPrivateIp } from "@app/lib/ip/ipRange"; import { logger } from "@app/lib/logger"; import { TPkiAcmeChallengeDALFactory } from "./pki-acme-challenge-dal"; import { @@ -53,6 +54,10 @@ export const pkiAcmeChallengeServiceFactory = ({ throw new BadRequestError({ message: "Only HTTP-01 challenges are supported for now" }); } let host = challenge.auth.identifierValue; + // check if host is a private ip address + if (isPrivateIp(host)) { + throw new BadRequestError({ message: "Private IP addresses are not allowed" }); + } if (appCfg.isAcmeDevelopmentMode && appCfg.ACME_DEVELOPMENT_HTTP01_CHALLENGE_HOST_OVERRIDES[host]) { host = appCfg.ACME_DEVELOPMENT_HTTP01_CHALLENGE_HOST_OVERRIDES[host]; logger.warn( 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 dfde68891..4f4eb93ad 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -7,6 +7,7 @@ import { TCertificateProfileDALFactory } from "@app/services/certificate-profile import * as x509 from "@peculiar/x509"; import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore"; +import { isPrivateIp } from "@app/lib/ip/ipRange"; import { ActorType } from "@app/services/auth/auth-type"; import { EnrollmentType, @@ -497,7 +498,9 @@ export const pkiAcmeServiceFactory = ({ if (identifier.type !== AcmeIdentifierType.DNS) { throw new AcmeUnsupportedIdentifierError({ detail: "Only DNS identifiers are supported" }); } - // TODO: reuse existing authorizations for this identifier if they exist + if (isPrivateIp(identifier.value)) { + throw new AcmeUnsupportedIdentifierError({ detail: "Private IP addresses are not allowed" }); + } const auth = await acmeAuthDAL.create( { accountId: account.id, @@ -600,8 +603,6 @@ export const pkiAcmeServiceFactory = ({ throw new AcmeOrderNotReadyError({ message: "ACME order has expired" }); } const { csr } = payload; - // TODO: validate the CSR and return badCSR error if it's invalid - // TODO: this should be the same transaction? let errorToReturn: Error | undefined; try { const { certificateId } = await certificateV3Service.signCertificateFromProfile({ diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index cd8fac508..961c0a940 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -2169,6 +2169,7 @@ export const registerRoutes = async ( certificateAuthorityDAL, certificateProfileDAL, certificateTemplateV2Service, + acmeAccountDAL, internalCaService: internalCertificateAuthorityService, permissionService, certificateSyncDAL, diff --git a/backend/src/services/certificate-v3/certificate-v3-service.test.ts b/backend/src/services/certificate-v3/certificate-v3-service.test.ts index 36823671f..67c3b268b 100644 --- a/backend/src/services/certificate-v3/certificate-v3-service.test.ts +++ b/backend/src/services/certificate-v3/certificate-v3-service.test.ts @@ -30,6 +30,7 @@ import { extractCertificateRequestFromCSR } from "../certificate-common/certificate-csr-utils"; import { certificateV3ServiceFactory, TCertificateV3ServiceFactory } from "./certificate-v3-service"; +import { TPkiAcmeAccountDALFactory } from "@app/ee/services/pki-acme/pki-acme-account-dal"; vi.mock("../certificate-common/certificate-csr-utils", () => ({ extractCertificateRequestFromCSR: vi.fn(), @@ -69,6 +70,10 @@ describe("CertificateV3Service", () => { getTemplateV2ById: vi.fn() }; + const mockAcmeAccountDAL: Pick = { + findById: vi.fn() + }; + const mockInternalCaService: Pick = { signCertFromCa: vi.fn(), @@ -132,6 +137,7 @@ describe("CertificateV3Service", () => { certificateAuthorityDAL: mockCertificateAuthorityDAL, certificateProfileDAL: mockCertificateProfileDAL, certificateTemplateV2Service: mockCertificateTemplateV2Service, + acmeAccountDAL: mockAcmeAccountDAL, internalCaService: mockInternalCaService, permissionService: mockPermissionService, certificateSyncDAL: { diff --git a/backend/src/services/certificate-v3/certificate-v3-service.ts b/backend/src/services/certificate-v3/certificate-v3-service.ts index 5ccd02698..72346c55c 100644 --- a/backend/src/services/certificate-v3/certificate-v3-service.ts +++ b/backend/src/services/certificate-v3/certificate-v3-service.ts @@ -64,12 +64,14 @@ import { TSignCertificateFromProfileDTO, TUpdateRenewalConfigDTO } from "./certificate-v3-types"; +import { TPkiAcmeAccountDALFactory } from "@app/ee/services/pki-acme/pki-acme-account-dal"; type TCertificateV3ServiceFactoryDep = { certificateDAL: Pick; certificateSecretDAL: Pick; certificateAuthorityDAL: Pick; certificateProfileDAL: Pick; + acmeAccountDAL: Pick; certificateTemplateV2Service: Pick< TCertificateTemplateV2ServiceFactory, "validateCertificateRequest" | "getTemplateV2ById" @@ -93,6 +95,7 @@ const validateProfileAndPermissions = async ( actorAuthMethod: ActorAuthMethod, actorOrgId: string, certificateProfileDAL: Pick, + acmeAccountDAL: Pick, permissionService: Pick, requiredEnrollmentType: EnrollmentType ) => { @@ -107,10 +110,16 @@ const validateProfileAndPermissions = async ( }); } - // XXX: NOT SURE IF THIS IS SECURE TO BY PASS THE PERMISSION CHECK FOR ACME ACCOUNTS - // may need to consider this carefully - // TODO: check actor/profile ownership as well if (actor === ActorType.ACME_ACCOUNT && requiredEnrollmentType === EnrollmentType.ACME) { + const account = await acmeAccountDAL.findById(actorId); + if (!account) { + throw new NotFoundError({ message: "ACME account not found" }); + } + if (account.profileId !== profile.id) { + throw new ForbiddenRequestError({ + message: "ACME account is not associated with this profile" + }); + } return profile; } @@ -343,6 +352,7 @@ export const certificateV3ServiceFactory = ({ certificateSecretDAL, certificateAuthorityDAL, certificateProfileDAL, + acmeAccountDAL, certificateTemplateV2Service, internalCaService, permissionService, @@ -365,6 +375,7 @@ export const certificateV3ServiceFactory = ({ actorAuthMethod, actorOrgId, certificateProfileDAL, + acmeAccountDAL, permissionService, EnrollmentType.API ); From 00b654e07660c40b1e4b9a20726af1b2ab348d42 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 4 Nov 2025 20:00:28 -0800 Subject: [PATCH 224/231] Add timeout for fetch --- .../services/pki-acme/pki-acme-challenge-service.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts index f4100c6aa..b64c43393 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts @@ -68,11 +68,12 @@ export const pkiAcmeChallengeServiceFactory = ({ const challengeUrl = new URL(`/.well-known/acme-challenge/${challenge.auth.token}`, `http://${host}`); logger.info({ challengeUrl }, "Performing ACME HTTP-01 challenge validation"); try { + // TODO: read config from the profile to get the timeout instead + const timeoutMs = 10000; // 10 seconds // Notice: well, we are in a transaction, ideally we should not hold transaction and perform // a long running operation for long time. But assuming we are not performing a tons of // challenge validation at the same time, it should be fine. - // TODO: bound it with timeout of the fetch request - const challengeResponse = await fetch(challengeUrl); + const challengeResponse = await fetch(challengeUrl, { signal: AbortSignal.timeout(timeoutMs) }); if (challengeResponse.status !== 200) { throw new BadRequestError({ message: "ACME challenge response is not 200" }); } @@ -107,6 +108,13 @@ export const pkiAcmeChallengeServiceFactory = ({ } return new AcmeServerInternalError({ message: "Unknown error validating ACME challenge response" }); } + } else if (exp instanceof DOMException) { + if (exp.name === "TimeoutError") { + logger.error(exp, "Connection timed out while validating ACME challenge response"); + return new AcmeConnectionError({ message: "Connection timed out" }); + } + logger.error(exp, "Unknown error validating ACME challenge response"); + return new AcmeServerInternalError({ message: "Unknown error validating ACME challenge response" }); } else if (exp instanceof Error) { logger.error(exp, "Error validating ACME challenge response"); } else { From dec7273652a6c76bb180d20c519a6a8377e90764 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 4 Nov 2025 20:03:55 -0800 Subject: [PATCH 225/231] Code style --- backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts index b64c43393..a058241c4 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts @@ -69,7 +69,7 @@ export const pkiAcmeChallengeServiceFactory = ({ logger.info({ challengeUrl }, "Performing ACME HTTP-01 challenge validation"); try { // TODO: read config from the profile to get the timeout instead - const timeoutMs = 10000; // 10 seconds + const timeoutMs = 10 * 1000; // 10 seconds // Notice: well, we are in a transaction, ideally we should not hold transaction and perform // a long running operation for long time. But assuming we are not performing a tons of // challenge validation at the same time, it should be fine. From 136d755577dadd527c6ec35ee6e6801e336cdf39 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Tue, 4 Nov 2025 20:27:50 -0800 Subject: [PATCH 226/231] Fix broken stuff --- backend/src/services/certificate-v3/certificate-v3-service.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/src/services/certificate-v3/certificate-v3-service.ts b/backend/src/services/certificate-v3/certificate-v3-service.ts index 72346c55c..7f4c6c637 100644 --- a/backend/src/services/certificate-v3/certificate-v3-service.ts +++ b/backend/src/services/certificate-v3/certificate-v3-service.ts @@ -512,6 +512,7 @@ export const certificateV3ServiceFactory = ({ actorAuthMethod, actorOrgId, certificateProfileDAL, + acmeAccountDAL, permissionService, enrollmentType ); @@ -614,6 +615,7 @@ export const certificateV3ServiceFactory = ({ actorAuthMethod, actorOrgId, certificateProfileDAL, + acmeAccountDAL, permissionService, EnrollmentType.API ); From d2c8904e8fbcd8dd741c402c59a494d49ee9d2e3 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 7 Nov 2025 09:16:27 -0800 Subject: [PATCH 227/231] Address review feedbacks --- backend/src/ee/services/pki-acme/pki-acme-errors.ts | 6 +++--- backend/src/ee/services/pki-acme/pki-acme-service.ts | 11 +++++++---- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/backend/src/ee/services/pki-acme/pki-acme-errors.ts b/backend/src/ee/services/pki-acme/pki-acme-errors.ts index 5872addae..febce5e81 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-errors.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-errors.ts @@ -14,13 +14,13 @@ export enum AcmeErrorType { BadPublicKey = "badPublicKey", BadRevocationReason = "badRevocationReason", BadSignatureAlgorithm = "badSignatureAlgorithm", - CAA = "CAA", + CAA = "caa", Compound = "compound", Connection = "connection", - DNS = "DNS", + DNS = "dns", ExternalAccountRequired = "externalAccountRequired", IncorrectResponse = "incorrectResponse", - IncorrectContact = "incorrectContact", + InvalidContact = "invalidContact", Malformed = "malformed", OrderNotReady = "orderNotReady", RateLimited = "rateLimited", 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 4f4eb93ad..e3a82000d 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -614,10 +614,13 @@ export const pkiAcmeServiceFactory = ({ csr, notBefore: finalizingOrder.notBefore ? new Date(finalizingOrder.notBefore) : undefined, notAfter: finalizingOrder.notAfter ? new Date(finalizingOrder.notAfter) : undefined, - validity: { - // TODO: read config from the profile to get the expiration time instead - ttl: (24 * 60 * 60 * 1000).toString() - }, + validity: !finalizingOrder.notAfter + ? { + // TODO: read config from the profile to get the expiration time instead + ttl: (24 * 60 * 60 * 1000).toString() + } + : // ttl is not used if notAfter is provided + ({ ttl: "0" } as const), enrollmentType: EnrollmentType.ACME }); // TODO: associate the certificate with the order From b0bdc01b1eb8015422131fef8e810dbf2176431d Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 7 Nov 2025 09:30:52 -0800 Subject: [PATCH 228/231] Fix DNS validation. Using re2 instead --- backend/src/ee/services/pki-acme/pki-acme-schemas.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/backend/src/ee/services/pki-acme/pki-acme-schemas.ts b/backend/src/ee/services/pki-acme/pki-acme-schemas.ts index 1d2f95fdf..4c7d6c3c1 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-schemas.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-schemas.ts @@ -1,3 +1,4 @@ +import RE2 from "re2"; import { z } from "zod"; export enum AcmeIdentifierType { @@ -88,9 +89,12 @@ export const CreateAcmeOrderBodySchema = z.object({ identifiers: z.array( z.object({ type: z.enum(Object.values(AcmeIdentifierType) as [string, ...string[]]), - value: z - .string() - .regex(/^(?!-)[A-Za-z0-9-]{1,63}(? { + // DNS label pattern: 1-63 chars, alphanumeric or hyphen, but not starting or ending with hyphen + const labelPattern = new RE2(/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/); + const labels = val.split("."); + return labels.every((label) => label.length >= 1 && label.length <= 63 && labelPattern.test(label)); + }, "Invalid DNS identifier") }) ), notBefore: z.string().optional(), From d7274e7e6374f48bd9909bf06f6d49a0a27009f3 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 7 Nov 2025 09:44:43 -0800 Subject: [PATCH 229/231] Show copy button again --- .../RevealAcmeEabSecretModal.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/RevealAcmeEabSecretModal.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/RevealAcmeEabSecretModal.tsx index 84163633f..55442e7ba 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/RevealAcmeEabSecretModal.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/RevealAcmeEabSecretModal.tsx @@ -14,6 +14,8 @@ import { useRevealAcmeEabSecret } from "@app/hooks/api/certificateProfiles/queri import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +const RESET_COPIED_DELAY = 1 * 1000; + type Props = { isOpen: boolean; onClose: () => void; @@ -67,6 +69,9 @@ export const RevealAcmeEabSecretModal = ({ isOpen, onClose, profile }: Props) => onClick={() => { navigator.clipboard.writeText(directoryUrl); setIsAcmeDirectoryUrlCopied.on(); + setTimeout(() => { + setIsAcmeDirectoryUrlCopied.off(); + }, RESET_COPIED_DELAY); }} className="w-10" > @@ -88,6 +93,9 @@ export const RevealAcmeEabSecretModal = ({ isOpen, onClose, profile }: Props) => onClick={() => { navigator.clipboard.writeText(eabKid); setIsEabKidCopied.on(); + setTimeout(() => { + setIsEabKidCopied.off(); + }, RESET_COPIED_DELAY); }} className="w-10" > @@ -109,6 +117,9 @@ export const RevealAcmeEabSecretModal = ({ isOpen, onClose, profile }: Props) => onClick={() => { navigator.clipboard.writeText(eabSecret); setIsEabSecretCopied.on(); + setTimeout(() => { + setIsEabSecretCopied.off(); + }, RESET_COPIED_DELAY); }} className="w-10" > From 198f9558c83beac275bf23e8112ebaad41c0a524 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 7 Nov 2025 10:07:12 -0800 Subject: [PATCH 230/231] Rename UI name --- .../components/CertificateProfilesTab/ProfileRow.tsx | 2 +- .../CertificateProfilesTab/RevealAcmeEabSecretModal.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileRow.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileRow.tsx index 2a06063c4..16286d5e4 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileRow.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileRow.tsx @@ -164,7 +164,7 @@ export const ProfileRow = ({ }} icon={} > - Reveal EAB Secret + Reveal ACME EAB )} {canIssueCertificate && profile.enrollmentType === "api" && ( diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/RevealAcmeEabSecretModal.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/RevealAcmeEabSecretModal.tsx index 55442e7ba..628e4106b 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/RevealAcmeEabSecretModal.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/RevealAcmeEabSecretModal.tsx @@ -41,7 +41,7 @@ export const RevealAcmeEabSecretModal = ({ isOpen, onClose, profile }: Props) => }} > {isLoading && ( From 91c85b33d51c89359dd99d33a9d782487f2f82b1 Mon Sep 17 00:00:00 2001 From: Fang-Pen Lin Date: Fri, 7 Nov 2025 10:41:25 -0800 Subject: [PATCH 231/231] Fix icon mis-aligment --- .../components/CertificateProfilesTab/ProfileRow.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileRow.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileRow.tsx index 16286d5e4..1e6f259e1 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileRow.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileRow.tsx @@ -140,7 +140,7 @@ export const ProfileRow = ({ } + icon={} onClick={() => handleCopyId()} > Copy Profile ID @@ -151,7 +151,7 @@ export const ProfileRow = ({ e.stopPropagation(); onEditProfile(profile); }} - icon={} + icon={} > Edit Profile @@ -162,7 +162,7 @@ export const ProfileRow = ({ e.stopPropagation(); onRevealProfileAcmeEabSecret(profile); }} - icon={} + icon={} > Reveal ACME EAB @@ -173,7 +173,7 @@ export const ProfileRow = ({ e.stopPropagation(); handlePopUpToggle("issueCertificate"); }} - icon={} + icon={} > Issue Certificate @@ -184,7 +184,7 @@ export const ProfileRow = ({ e.stopPropagation(); onDeleteProfile(profile); }} - icon={} + icon={} > Delete Profile