mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-14 14:34:09 +00:00
feat: POC for ACME done
This commit is contained in:
Generated
+1877
File diff suppressed because it is too large
Load Diff
@@ -131,6 +131,7 @@
|
||||
"@aws-sdk/client-elasticache": "^3.637.0",
|
||||
"@aws-sdk/client-iam": "^3.525.0",
|
||||
"@aws-sdk/client-kms": "^3.609.0",
|
||||
"@aws-sdk/client-route-53": "^3.810.0",
|
||||
"@aws-sdk/client-secrets-manager": "^3.504.0",
|
||||
"@aws-sdk/client-sts": "^3.600.0",
|
||||
"@casl/ability": "^6.5.0",
|
||||
@@ -174,6 +175,7 @@
|
||||
"@slack/oauth": "^3.0.2",
|
||||
"@slack/web-api": "^7.8.0",
|
||||
"@ucast/mongo2js": "^1.3.4",
|
||||
"acme-client": "^5.4.0",
|
||||
"ajv": "^8.12.0",
|
||||
"argon2": "^0.31.2",
|
||||
"aws-sdk": "^2.1553.0",
|
||||
|
||||
@@ -971,20 +971,6 @@ export const registerRoutes = async (
|
||||
projectDAL
|
||||
});
|
||||
|
||||
const pkiSubscriberService = pkiSubscriberServiceFactory({
|
||||
pkiSubscriberDAL,
|
||||
certificateAuthorityDAL,
|
||||
certificateAuthorityCertDAL,
|
||||
certificateAuthoritySecretDAL,
|
||||
certificateAuthorityCrlDAL,
|
||||
certificateDAL,
|
||||
certificateBodyDAL,
|
||||
certificateSecretDAL,
|
||||
projectDAL,
|
||||
kmsService,
|
||||
permissionService
|
||||
});
|
||||
|
||||
const projectTemplateService = projectTemplateServiceFactory({
|
||||
licenseService,
|
||||
permissionService,
|
||||
@@ -1690,6 +1676,23 @@ export const registerRoutes = async (
|
||||
internalCertificateAuthorityService
|
||||
});
|
||||
|
||||
const pkiSubscriberService = pkiSubscriberServiceFactory({
|
||||
pkiSubscriberDAL,
|
||||
certificateAuthorityDAL,
|
||||
certificateAuthorityCertDAL,
|
||||
certificateAuthoritySecretDAL,
|
||||
certificateAuthorityCrlDAL,
|
||||
certificateDAL,
|
||||
certificateBodyDAL,
|
||||
certificateSecretDAL,
|
||||
projectDAL,
|
||||
kmsService,
|
||||
permissionService,
|
||||
appConnectionDAL,
|
||||
appConnectionService,
|
||||
externalCertificateAuthorityDAL
|
||||
});
|
||||
|
||||
await secretRotationV2QueueServiceFactory({
|
||||
secretRotationV2Service,
|
||||
secretRotationV2DAL,
|
||||
|
||||
@@ -278,6 +278,76 @@ export const registerPkiSubscriberRouter = async (server: FastifyZodProvider) =>
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/:subscriberName/order-certificate",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
schema: {
|
||||
hide: false,
|
||||
tags: [ApiDocsTags.PkiSubscribers],
|
||||
description: "Issue certificate",
|
||||
params: z.object({
|
||||
subscriberName: z.string().describe(PKI_SUBSCRIBERS.ISSUE_CERT.subscriberName)
|
||||
}),
|
||||
body: z.object({
|
||||
projectId: z.string().trim().describe(PKI_SUBSCRIBERS.ISSUE_CERT.projectId)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
certificate: z.string().trim().describe(PKI_SUBSCRIBERS.ISSUE_CERT.certificate),
|
||||
issuingCaCertificate: z.string().trim().describe(PKI_SUBSCRIBERS.ISSUE_CERT.issuingCaCertificate),
|
||||
certificateChain: z.string().trim().describe(PKI_SUBSCRIBERS.ISSUE_CERT.certificateChain),
|
||||
privateKey: z.string().trim().describe(PKI_SUBSCRIBERS.ISSUE_CERT.privateKey),
|
||||
serialNumber: z.string().trim().describe(PKI_SUBSCRIBERS.ISSUE_CERT.serialNumber)
|
||||
})
|
||||
}
|
||||
},
|
||||
handler: async (req) => {
|
||||
await server.services.pkiSubscriber.issueSubscriberCert({
|
||||
subscriberName: req.params.subscriberName,
|
||||
projectId: req.body.projectId,
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId
|
||||
});
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
projectId: subscriber.projectId,
|
||||
event: {
|
||||
type: EventType.ISSUE_PKI_SUBSCRIBER_CERT,
|
||||
metadata: {
|
||||
subscriberId: subscriber.id,
|
||||
name: subscriber.name,
|
||||
serialNumber
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await server.services.telemetry.sendPostHogEvents({
|
||||
event: PostHogEventTypes.IssueCert,
|
||||
distinctId: getTelemetryDistinctId(req),
|
||||
properties: {
|
||||
subscriberId: subscriber.id,
|
||||
commonName: subscriber.commonName,
|
||||
...req.auditLogInfo
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
certificate,
|
||||
certificateChain,
|
||||
issuingCaCertificate,
|
||||
privateKey,
|
||||
serialNumber
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/:subscriberName/issue-certificate",
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/* eslint-disable @typescript-eslint/no-floating-promises */
|
||||
import { z } from "zod";
|
||||
|
||||
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
||||
import { ApiDocsTags } from "@app/lib/api-docs";
|
||||
import { readLimit } from "@app/server/config/rateLimiter";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
import { AcmeCertificateAuthoritySchema } from "@app/services/certificate-authority/acme/acme-certificate-authority-schemas";
|
||||
import { CaType } from "@app/services/certificate-authority/certificate-authority-enums";
|
||||
import { InternalCertificateAuthoritySchema } from "@app/services/certificate-authority/internal/internal-certificate-authority-schemas";
|
||||
|
||||
const CertificateAuthoritySchema = z.discriminatedUnion("type", [
|
||||
InternalCertificateAuthoritySchema,
|
||||
AcmeCertificateAuthoritySchema
|
||||
]);
|
||||
|
||||
export const registerCaRouter = async (server: FastifyZodProvider) => {
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
schema: {
|
||||
hide: false,
|
||||
tags: [ApiDocsTags.PkiCertificateAuthorities],
|
||||
description: "Get Certificate Authorities",
|
||||
querystring: z.object({
|
||||
projectId: z.string()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
certificateAuthorities: CertificateAuthoritySchema.array()
|
||||
})
|
||||
}
|
||||
},
|
||||
handler: async (req) => {
|
||||
const internalCas = await server.services.certificateAuthority.listCertificateAuthoritiesByProjectId(
|
||||
{
|
||||
projectId: req.query.projectId,
|
||||
type: CaType.INTERNAL
|
||||
},
|
||||
req.permission
|
||||
);
|
||||
|
||||
const acmeCas = await server.services.certificateAuthority.listCertificateAuthoritiesByProjectId(
|
||||
{
|
||||
projectId: req.query.projectId,
|
||||
type: CaType.ACME
|
||||
},
|
||||
req.permission
|
||||
);
|
||||
|
||||
// await server.services.auditLog.createAuditLog({
|
||||
// ...req.auditLogInfo,
|
||||
// projectId: ca.projectId,
|
||||
// event: {
|
||||
// type: EventType.GET_CA,
|
||||
// metadata: {
|
||||
// caId: ca.id,
|
||||
// dn: ca.dn
|
||||
// }
|
||||
// }
|
||||
// });
|
||||
|
||||
return {
|
||||
certificateAuthorities: [...(internalCas ?? []), ...(acmeCas ?? [])]
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { registerCaRouter } from "./certificate-authority-router";
|
||||
import { registerGroupProjectRouter } from "./group-project-router";
|
||||
import { registerIdentityOrgRouter } from "./identity-org-router";
|
||||
import { registerIdentityProjectRouter } from "./identity-project-router";
|
||||
@@ -14,6 +15,7 @@ export const registerV2Routes = async (server: FastifyZodProvider) => {
|
||||
await server.register(registerUserRouter, { prefix: "/users" });
|
||||
await server.register(registerServiceTokenRouter, { prefix: "/service-token" });
|
||||
await server.register(registerPasswordRouter, { prefix: "/password" });
|
||||
await server.register(registerCaRouter, { prefix: "/pki/ca" });
|
||||
await server.register(
|
||||
async (orgRouter) => {
|
||||
await orgRouter.register(registerOrgRouter);
|
||||
|
||||
@@ -1,15 +1,37 @@
|
||||
import { ChangeResourceRecordSetsCommand, Route53Client } from "@aws-sdk/client-route-53";
|
||||
import * as x509 from "@peculiar/x509";
|
||||
import acme from "acme-client";
|
||||
import { KeyObject } from "crypto";
|
||||
|
||||
import { TableName, TPkiSubscribers } from "@app/db/schemas";
|
||||
import { BadRequestError, NotFoundError } from "@app/lib/errors";
|
||||
import { OrgServiceActor } from "@app/lib/types";
|
||||
import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal";
|
||||
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
||||
import { AppConnection, AWSRegion } from "@app/services/app-connection/app-connection-enums";
|
||||
import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service";
|
||||
import { getAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-fns";
|
||||
import { TAwsConnection, TAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-types";
|
||||
import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal";
|
||||
import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal";
|
||||
import { TCertificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal";
|
||||
import {
|
||||
CertExtendedKeyUsage,
|
||||
CertKeyAlgorithm,
|
||||
CertKeyUsage,
|
||||
CertStatus
|
||||
} from "@app/services/certificate/certificate-types";
|
||||
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 { TCertificateAuthorityDALFactory } from "../certificate-authority-dal";
|
||||
import { CaStatus, CaType } from "../certificate-authority-enums";
|
||||
import { keyAlgorithmToAlgCfg } from "../certificate-authority-fns";
|
||||
import { TCertificateAuthority } from "../certificate-authority-types";
|
||||
import { TExternalCertificateAuthorityDALFactory } from "../external-certificate-authority-dal";
|
||||
import { AcmeDnsProvider } from "./acme-certificate-authority-enums";
|
||||
import {
|
||||
TAcmeCertificateAuthority,
|
||||
TCreateAcmeCertificateAuthorityDTO,
|
||||
TUpdateAcmeCertificateAuthorityDTO
|
||||
} from "./acme-certificate-authority-types";
|
||||
@@ -19,16 +41,113 @@ type TAcmeCertificateAuthorityFnsDeps = {
|
||||
appConnectionService: Pick<TAppConnectionServiceFactory, "connectAppConnectionById">;
|
||||
certificateAuthorityDAL: Pick<
|
||||
TCertificateAuthorityDALFactory,
|
||||
"create" | "transaction" | "findByIdWithAssociatedCa" | "updateById"
|
||||
"create" | "transaction" | "findByIdWithAssociatedCa" | "updateById" | "findWithAssociatedCa"
|
||||
>;
|
||||
externalCertificateAuthorityDAL: Pick<TExternalCertificateAuthorityDALFactory, "create" | "update">;
|
||||
certificateDAL: Pick<TCertificateDALFactory, "create" | "transaction">;
|
||||
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "create">;
|
||||
certificateSecretDAL: Pick<TCertificateSecretDALFactory, "create">;
|
||||
kmsService: Pick<TKmsServiceFactory, "encryptWithKmsKey" | "generateKmsKey">;
|
||||
projectDAL: Pick<TProjectDALFactory, "findById" | "findOne" | "updateById" | "transaction">;
|
||||
};
|
||||
|
||||
type DBConfigurationColumn = {
|
||||
dnsProvider: string;
|
||||
directoryUrl: string;
|
||||
accountEmail: string;
|
||||
};
|
||||
|
||||
export const castDbEntryToAcmeCertificateAuthority = (
|
||||
ca: Awaited<ReturnType<TCertificateAuthorityDALFactory["findByIdWithAssociatedCa"]>>
|
||||
): TAcmeCertificateAuthority => {
|
||||
if (!ca.externalCa) {
|
||||
throw new BadRequestError({ message: "Malformed ACME certificate authority" });
|
||||
}
|
||||
|
||||
const dbConfigurationCol = ca.externalCa.configuration as DBConfigurationColumn;
|
||||
|
||||
return {
|
||||
id: ca.id,
|
||||
type: CaType.ACME,
|
||||
disableDirectIssuance: ca.disableDirectIssuance,
|
||||
name: ca.externalCa.name,
|
||||
projectId: ca.projectId,
|
||||
configuration: {
|
||||
dnsAppConnectionId: ca.externalCa.dnsAppConnectionId as string,
|
||||
dnsProvider: dbConfigurationCol.dnsProvider as AcmeDnsProvider,
|
||||
directoryUrl: dbConfigurationCol.directoryUrl,
|
||||
accountEmail: dbConfigurationCol.accountEmail
|
||||
},
|
||||
status: ca.externalCa.status as CaStatus
|
||||
};
|
||||
};
|
||||
|
||||
export const route53InsertTxtRecord = async (connection: TAwsConnectionConfig, domain: string, value: string) => {
|
||||
const config = await getAwsConnectionConfig(connection, AWSRegion.US_WEST_1); // REGION is irrelevant because Route53 is global
|
||||
const route53Client = new Route53Client({
|
||||
credentials: config.credentials!,
|
||||
region: config.region
|
||||
});
|
||||
|
||||
const command = new ChangeResourceRecordSetsCommand({
|
||||
HostedZoneId: "Z040441124N1GOOMCQYX1", // SHEEN TODO: Get this from user input
|
||||
ChangeBatch: {
|
||||
Comment: "Set ACME challenge TXT record",
|
||||
Changes: [
|
||||
{
|
||||
Action: "UPSERT",
|
||||
ResourceRecordSet: {
|
||||
Name: domain,
|
||||
Type: "TXT",
|
||||
TTL: 30,
|
||||
ResourceRecords: [{ Value: value }]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
await route53Client.send(command);
|
||||
};
|
||||
|
||||
export const route53DeleteTxtRecord = async (connection: TAwsConnectionConfig, domain: string, value: string) => {
|
||||
const config = await getAwsConnectionConfig(connection, AWSRegion.US_WEST_1); // REGION is irrelevant because Route53 is global
|
||||
const route53Client = new Route53Client({
|
||||
credentials: config.credentials!,
|
||||
region: config.region
|
||||
});
|
||||
|
||||
const command = new ChangeResourceRecordSetsCommand({
|
||||
HostedZoneId: "Z040441124N1GOOMCQYX1", // SHEEN TODO: same here
|
||||
ChangeBatch: {
|
||||
Comment: "Delete ACME challenge TXT record",
|
||||
Changes: [
|
||||
{
|
||||
Action: "DELETE",
|
||||
ResourceRecordSet: {
|
||||
Name: domain,
|
||||
Type: "TXT",
|
||||
TTL: 30,
|
||||
ResourceRecords: [{ Value: value }]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
await route53Client.send(command);
|
||||
};
|
||||
|
||||
export const AcmeCertificateAuthorityFns = ({
|
||||
appConnectionDAL,
|
||||
appConnectionService,
|
||||
certificateAuthorityDAL,
|
||||
externalCertificateAuthorityDAL
|
||||
externalCertificateAuthorityDAL,
|
||||
certificateDAL,
|
||||
certificateBodyDAL,
|
||||
certificateSecretDAL,
|
||||
kmsService,
|
||||
projectDAL
|
||||
}: TAcmeCertificateAuthorityFnsDeps) => {
|
||||
const createCertificateAuthority = async ({
|
||||
name,
|
||||
@@ -192,8 +311,150 @@ export const AcmeCertificateAuthorityFns = ({
|
||||
};
|
||||
};
|
||||
|
||||
const listCertificateAuthorities = async ({ projectId }: { projectId: string }) => {
|
||||
const cas = await certificateAuthorityDAL.findWithAssociatedCa({
|
||||
[`${TableName.CertificateAuthority}.projectId` as "projectId"]: projectId,
|
||||
[`${TableName.ExternalCertificateAuthority}.type` as "type"]: CaType.ACME
|
||||
});
|
||||
|
||||
return cas.map(castDbEntryToAcmeCertificateAuthority);
|
||||
};
|
||||
|
||||
// SHEEN TODO: need to execute this from a job
|
||||
const orderCertificate = async (
|
||||
subscriber: TPkiSubscribers,
|
||||
ca: Awaited<ReturnType<TCertificateAuthorityDALFactory["findByIdWithAssociatedCa"]>>,
|
||||
actor: OrgServiceActor
|
||||
) => {
|
||||
const acmeCa = castDbEntryToAcmeCertificateAuthority(ca);
|
||||
|
||||
// SHEEN TODO: need to save this in credentials field and reuse
|
||||
const privateRsaKey = await acme.crypto.createPrivateRsaKey();
|
||||
|
||||
const acmeClient = new acme.Client({
|
||||
directoryUrl: acmeCa.configuration.directoryUrl,
|
||||
accountKey: privateRsaKey
|
||||
});
|
||||
|
||||
const alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_2048);
|
||||
const leafKeys = await crypto.subtle.generateKey(alg, true, ["sign", "verify"]);
|
||||
const skLeafObj = KeyObject.from(leafKeys.privateKey);
|
||||
const skLeaf = skLeafObj.export({ format: "pem", type: "pkcs8" }) as string;
|
||||
|
||||
const [, certificateCsr] = await acme.crypto.createCsr(
|
||||
{
|
||||
altNames: subscriber.subjectAlternativeNames,
|
||||
commonName: subscriber.commonName
|
||||
},
|
||||
skLeaf
|
||||
);
|
||||
|
||||
// SHEEN TODO: need to update this to remove dependence on ACTOR
|
||||
const appConnection = await appConnectionDAL.findById(acmeCa.configuration.dnsAppConnectionId);
|
||||
const connection = await appConnectionService.connectAppConnectionById(
|
||||
appConnection.app as AppConnection,
|
||||
acmeCa.configuration.dnsAppConnectionId,
|
||||
actor
|
||||
);
|
||||
|
||||
const pem = await acmeClient.auto({
|
||||
csr: certificateCsr,
|
||||
email: acmeCa.configuration.accountEmail,
|
||||
challengePriority: ["dns-01"],
|
||||
termsOfServiceAgreed: true,
|
||||
|
||||
challengeCreateFn: async (authz, challenge, keyAuthorization) => {
|
||||
if (challenge.type !== "dns-01") {
|
||||
throw new Error("Unsupported challenge type");
|
||||
}
|
||||
|
||||
const recordName = `_acme-challenge.${authz.identifier.value}`; // e.g., "_acme-challenge.example.com"
|
||||
const recordValue = `"${keyAuthorization}"`; // must be double quoted
|
||||
|
||||
if (acmeCa.configuration.dnsProvider === AcmeDnsProvider.Route53) {
|
||||
await route53InsertTxtRecord(connection as TAwsConnection, recordName, recordValue);
|
||||
}
|
||||
},
|
||||
challengeRemoveFn: async (authz, challenge, keyAuthorization) => {
|
||||
const recordName = `_acme-challenge.${authz.identifier.value}`; // e.g., "_acme-challenge.example.com"
|
||||
const recordValue = `"${keyAuthorization}"`; // must be double quoted
|
||||
|
||||
if (acmeCa.configuration.dnsProvider === AcmeDnsProvider.Route53) {
|
||||
await route53DeleteTxtRecord(connection as TAwsConnection, recordName, recordValue);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log("PEM IS", pem);
|
||||
|
||||
const [leafCert, parentCert] = acme.crypto.splitPemChain(pem);
|
||||
const certObj = new x509.X509Certificate(leafCert);
|
||||
|
||||
const certificateManagerKmsId = await getProjectKmsCertificateKeyId({
|
||||
projectId: ca.projectId,
|
||||
projectDAL,
|
||||
kmsService
|
||||
});
|
||||
|
||||
const kmsEncryptor = await kmsService.encryptWithKmsKey({
|
||||
kmsId: certificateManagerKmsId
|
||||
});
|
||||
const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({
|
||||
plainText: Buffer.from(new Uint8Array(certObj.rawData))
|
||||
});
|
||||
|
||||
const certificateChainPem = parentCert.trim();
|
||||
|
||||
const { cipherTextBlob: encryptedCertificateChain } = await kmsEncryptor({
|
||||
plainText: Buffer.from(certificateChainPem)
|
||||
});
|
||||
|
||||
const { cipherTextBlob: encryptedPrivateKey } = await kmsEncryptor({
|
||||
plainText: Buffer.from(skLeaf)
|
||||
});
|
||||
|
||||
await certificateDAL.transaction(async (tx) => {
|
||||
const cert = await certificateDAL.create(
|
||||
{
|
||||
caId: ca.id,
|
||||
pkiSubscriberId: subscriber.id,
|
||||
status: CertStatus.ACTIVE,
|
||||
friendlyName: subscriber.commonName,
|
||||
commonName: subscriber.commonName,
|
||||
altNames: subscriber.subjectAlternativeNames.join(","),
|
||||
serialNumber: certObj.serialNumber,
|
||||
notBefore: certObj.notBefore,
|
||||
notAfter: certObj.notAfter,
|
||||
keyUsages: subscriber.keyUsages as CertKeyUsage[],
|
||||
extendedKeyUsages: subscriber.extendedKeyUsages as CertExtendedKeyUsage[],
|
||||
caCertId: "s" // SHEEN TODO: merge Andrey's PR and then remove this
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
await certificateBodyDAL.create(
|
||||
{
|
||||
certId: cert.id,
|
||||
encryptedCertificate,
|
||||
encryptedCertificateChain
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
await certificateSecretDAL.create(
|
||||
{
|
||||
certId: cert.id,
|
||||
encryptedPrivateKey
|
||||
},
|
||||
tx
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
createCertificateAuthority,
|
||||
updateCertificateAuthority
|
||||
updateCertificateAuthority,
|
||||
listCertificateAuthorities,
|
||||
orderCertificate
|
||||
};
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Knex } from "knex";
|
||||
import { TDbClient } from "@app/db";
|
||||
import { CertificateAuthoritiesSchema, TableName, TCertificateAuthorities } from "@app/db/schemas";
|
||||
import { DatabaseError } from "@app/lib/errors";
|
||||
import { ormify, selectAllTableCols, TFindOpt } from "@app/lib/knex";
|
||||
import { buildFindFilter, ormify, selectAllTableCols, TFindOpt } from "@app/lib/knex";
|
||||
|
||||
export type TCertificateAuthorityDALFactory = ReturnType<typeof certificateAuthorityDALFactory>;
|
||||
|
||||
@@ -142,7 +142,7 @@ export const certificateAuthorityDALFactory = (db: TDbClient) => {
|
||||
};
|
||||
|
||||
const findWithAssociatedCa = async (
|
||||
filter: Parameters<(typeof caOrm)["find"]>[0] & { dn?: string },
|
||||
filter: Parameters<(typeof caOrm)["find"]>[0] & { dn?: string; type?: string },
|
||||
{ offset, limit, sort = [["createdAt", "desc"]] }: TFindOpt<TCertificateAuthorities> = {},
|
||||
tx?: Knex
|
||||
) => {
|
||||
@@ -158,7 +158,8 @@ export const certificateAuthorityDALFactory = (db: TDbClient) => {
|
||||
`${TableName.CertificateAuthority}.id`,
|
||||
`${TableName.ExternalCertificateAuthority}.certificateAuthorityId`
|
||||
)
|
||||
.where(filter)
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
.where(buildFindFilter(filter))
|
||||
.select(selectAllTableCols(TableName.CertificateAuthority))
|
||||
.select(
|
||||
db.ref("id").withSchema(TableName.InternalCertificateAuthority).as("internalCaId"),
|
||||
|
||||
@@ -218,17 +218,12 @@ export const certificateAuthorityServiceFactory = ({
|
||||
ProjectPermissionSub.CertificateAuthorities
|
||||
);
|
||||
|
||||
const cas = await certificateAuthorityDAL.findWithAssociatedCa({
|
||||
[`${TableName.CertificateAuthority}.projectId` as "projectId"]: finalProjectId,
|
||||
...(type === CaType.INTERNAL && {
|
||||
$notNull: [`${TableName.InternalCertificateAuthority}.id` as "id"]
|
||||
}),
|
||||
...(type !== CaType.INTERNAL && {
|
||||
[`${TableName.ExternalCertificateAuthority}.type` as "type"]: type
|
||||
})
|
||||
});
|
||||
|
||||
if (type === CaType.INTERNAL) {
|
||||
const cas = await certificateAuthorityDAL.findWithAssociatedCa({
|
||||
[`${TableName.CertificateAuthority}.projectId` as "projectId"]: finalProjectId,
|
||||
$notNull: [`${TableName.InternalCertificateAuthority}.id` as "id"]
|
||||
});
|
||||
|
||||
return cas
|
||||
.filter((ca): ca is typeof ca & { internalCa: NonNullable<typeof ca.internalCa> } => Boolean(ca.internalCa))
|
||||
.map((ca) => ({
|
||||
@@ -242,17 +237,9 @@ export const certificateAuthorityServiceFactory = ({
|
||||
})) as TCertificateAuthority[];
|
||||
}
|
||||
|
||||
return cas
|
||||
.filter((ca): ca is typeof ca & { externalCa: NonNullable<typeof ca.externalCa> } => Boolean(ca.externalCa))
|
||||
.map((ca) => ({
|
||||
id: ca.id,
|
||||
type,
|
||||
disableDirectIssuance: ca.disableDirectIssuance,
|
||||
name: ca.externalCa.name,
|
||||
projectId: ca.projectId,
|
||||
configuration: ca.externalCa.configuration,
|
||||
status: ca.externalCa.status
|
||||
})) as TCertificateAuthority[];
|
||||
if (type === CaType.ACME) {
|
||||
return acmeFns.listCertificateAuthorities({ projectId: finalProjectId });
|
||||
}
|
||||
};
|
||||
|
||||
const updateCertificateAuthority = async (
|
||||
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
import * as x509 from "@peculiar/x509";
|
||||
import { KeyObject } from "crypto";
|
||||
import { z } from "zod";
|
||||
|
||||
import { TPkiSubscribers } from "@app/db/schemas";
|
||||
import { TCertificateAuthorityCrlDALFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-dal";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { BadRequestError } from "@app/lib/errors";
|
||||
import { ms } from "@app/lib/ms";
|
||||
import { isFQDN } from "@app/lib/validator/validate-url";
|
||||
import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal";
|
||||
import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal";
|
||||
import { TCertificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal";
|
||||
import {
|
||||
CertExtendedKeyUsage,
|
||||
CertKeyAlgorithm,
|
||||
CertKeyUsage,
|
||||
CertStatus
|
||||
} from "@app/services/certificate/certificate-types";
|
||||
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 { TCertificateAuthorityCertDALFactory } from "../certificate-authority-cert-dal";
|
||||
import { TCertificateAuthorityDALFactory } from "../certificate-authority-dal";
|
||||
import { CaStatus } from "../certificate-authority-enums";
|
||||
import {
|
||||
createSerialNumber,
|
||||
getCaCertChain,
|
||||
getCaCredentials,
|
||||
keyAlgorithmToAlgCfg
|
||||
} from "../certificate-authority-fns";
|
||||
import { TCertificateAuthoritySecretDALFactory } from "../certificate-authority-secret-dal";
|
||||
|
||||
type TInternalCertificateAuthorityFnsDeps = {
|
||||
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findByIdWithAssociatedCa" | "findById">;
|
||||
certificateAuthorityCertDAL: Pick<TCertificateAuthorityCertDALFactory, "findById">;
|
||||
certificateAuthoritySecretDAL: Pick<TCertificateAuthoritySecretDALFactory, "findOne">;
|
||||
certificateAuthorityCrlDAL: Pick<TCertificateAuthorityCrlDALFactory, "findOne">;
|
||||
projectDAL: Pick<TProjectDALFactory, "findById" | "transaction" | "findOne" | "updateById">;
|
||||
kmsService: Pick<TKmsServiceFactory, "decryptWithKmsKey" | "encryptWithKmsKey" | "generateKmsKey">;
|
||||
certificateDAL: Pick<TCertificateDALFactory, "create" | "transaction">;
|
||||
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "create">;
|
||||
certificateSecretDAL: Pick<TCertificateSecretDALFactory, "create">;
|
||||
};
|
||||
|
||||
export const InternalCertificateAuthorityFns = ({
|
||||
certificateAuthorityDAL,
|
||||
certificateAuthorityCertDAL,
|
||||
projectDAL,
|
||||
kmsService,
|
||||
certificateAuthoritySecretDAL,
|
||||
certificateAuthorityCrlDAL,
|
||||
certificateDAL,
|
||||
certificateBodyDAL,
|
||||
certificateSecretDAL
|
||||
}: TInternalCertificateAuthorityFnsDeps) => {
|
||||
const issueCertificate = async (
|
||||
subscriber: TPkiSubscribers,
|
||||
ca: Awaited<ReturnType<TCertificateAuthorityDALFactory["findByIdWithAssociatedCa"]>>
|
||||
) => {
|
||||
if (ca.internalCa?.status !== CaStatus.ACTIVE) throw new BadRequestError({ message: "CA is not active" });
|
||||
if (!ca.internalCa?.activeCaCertId)
|
||||
throw new BadRequestError({ message: "CA does not have a certificate installed" });
|
||||
if (ca.disableDirectIssuance) {
|
||||
throw new BadRequestError({ message: "Certificate template is required for issuance" });
|
||||
}
|
||||
|
||||
const caCert = await certificateAuthorityCertDAL.findById(ca.internalCa.activeCaCertId);
|
||||
|
||||
const certificateManagerKmsId = await getProjectKmsCertificateKeyId({
|
||||
projectId: ca.projectId,
|
||||
projectDAL,
|
||||
kmsService
|
||||
});
|
||||
|
||||
const kmsDecryptor = await kmsService.decryptWithKmsKey({
|
||||
kmsId: certificateManagerKmsId
|
||||
});
|
||||
|
||||
const decryptedCaCert = await kmsDecryptor({
|
||||
cipherTextBlob: caCert.encryptedCertificate
|
||||
});
|
||||
|
||||
const caCertObj = new x509.X509Certificate(decryptedCaCert);
|
||||
const notBeforeDate = new Date();
|
||||
const notAfterDate = new Date(new Date().getTime() + ms(subscriber.ttl));
|
||||
const caCertNotBeforeDate = new Date(caCertObj.notBefore);
|
||||
const caCertNotAfterDate = new Date(caCertObj.notAfter);
|
||||
|
||||
// check not before constraint
|
||||
if (notBeforeDate < caCertNotBeforeDate) {
|
||||
throw new BadRequestError({ message: "notBefore date is before CA certificate's notBefore date" });
|
||||
}
|
||||
|
||||
// check not after constraint
|
||||
if (notAfterDate > caCertNotAfterDate) {
|
||||
throw new BadRequestError({ message: "notAfter date is after CA certificate's notAfter date" });
|
||||
}
|
||||
|
||||
const alg = keyAlgorithmToAlgCfg(ca.internalCa.keyAlgorithm as CertKeyAlgorithm);
|
||||
const leafKeys = await crypto.subtle.generateKey(alg, true, ["sign", "verify"]);
|
||||
|
||||
const csrObj = await x509.Pkcs10CertificateRequestGenerator.create({
|
||||
name: `CN=${subscriber.commonName}`,
|
||||
keys: leafKeys,
|
||||
signingAlgorithm: alg,
|
||||
extensions: [
|
||||
// eslint-disable-next-line no-bitwise
|
||||
new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature | x509.KeyUsageFlags.keyEncipherment)
|
||||
],
|
||||
attributes: [new x509.ChallengePasswordAttribute("password")]
|
||||
});
|
||||
|
||||
const { caPrivateKey, caSecret } = await getCaCredentials({
|
||||
caId: ca.id,
|
||||
certificateAuthorityDAL,
|
||||
certificateAuthoritySecretDAL,
|
||||
projectDAL,
|
||||
kmsService
|
||||
});
|
||||
|
||||
const caCrl = await certificateAuthorityCrlDAL.findOne({ caSecretId: caSecret.id });
|
||||
const appCfg = getConfig();
|
||||
|
||||
const distributionPointUrl = `${appCfg.SITE_URL}/api/v1/pki/crl/${caCrl.id}/der`;
|
||||
const caIssuerUrl = `${appCfg.SITE_URL}/api/v1/pki/ca/${ca.id}/certificates/${caCert.id}/der`;
|
||||
|
||||
const extensions: x509.Extension[] = [
|
||||
new x509.BasicConstraintsExtension(false),
|
||||
new x509.CRLDistributionPointsExtension([distributionPointUrl]),
|
||||
await x509.AuthorityKeyIdentifierExtension.create(caCertObj, false),
|
||||
await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey),
|
||||
new x509.AuthorityInfoAccessExtension({
|
||||
caIssuers: new x509.GeneralName("url", caIssuerUrl)
|
||||
}),
|
||||
new x509.CertificatePolicyExtension(["2.5.29.32.0"]) // anyPolicy
|
||||
];
|
||||
|
||||
const selectedKeyUsages = subscriber.keyUsages as CertKeyUsage[];
|
||||
// eslint-disable-next-line no-bitwise
|
||||
const keyUsagesBitValue = selectedKeyUsages.reduce((accum, keyUsage) => accum | x509.KeyUsageFlags[keyUsage], 0);
|
||||
if (keyUsagesBitValue) {
|
||||
extensions.push(new x509.KeyUsagesExtension(keyUsagesBitValue, true));
|
||||
}
|
||||
|
||||
if (subscriber.extendedKeyUsages.length) {
|
||||
const extendedKeyUsagesExtension = new x509.ExtendedKeyUsageExtension(
|
||||
subscriber.extendedKeyUsages.map((eku) => x509.ExtendedKeyUsage[eku as CertExtendedKeyUsage]),
|
||||
true
|
||||
);
|
||||
extensions.push(extendedKeyUsagesExtension);
|
||||
}
|
||||
|
||||
let altNamesArray: { type: "email" | "dns"; value: string }[] = [];
|
||||
|
||||
if (subscriber.subjectAlternativeNames?.length) {
|
||||
altNamesArray = subscriber.subjectAlternativeNames.map((altName) => {
|
||||
if (z.string().email().safeParse(altName).success) {
|
||||
return { type: "email", value: altName };
|
||||
}
|
||||
|
||||
if (isFQDN(altName, { allow_wildcard: true })) {
|
||||
return { type: "dns", value: altName };
|
||||
}
|
||||
|
||||
throw new BadRequestError({ message: `Invalid SAN entry: ${altName}` });
|
||||
});
|
||||
|
||||
const altNamesExtension = new x509.SubjectAlternativeNameExtension(altNamesArray, false);
|
||||
extensions.push(altNamesExtension);
|
||||
}
|
||||
|
||||
const serialNumber = createSerialNumber();
|
||||
const leafCert = await x509.X509CertificateGenerator.create({
|
||||
serialNumber,
|
||||
subject: csrObj.subject,
|
||||
issuer: caCertObj.subject,
|
||||
notBefore: notBeforeDate,
|
||||
notAfter: notAfterDate,
|
||||
signingKey: caPrivateKey,
|
||||
publicKey: csrObj.publicKey,
|
||||
signingAlgorithm: alg,
|
||||
extensions
|
||||
});
|
||||
|
||||
const skLeafObj = KeyObject.from(leafKeys.privateKey);
|
||||
const skLeaf = skLeafObj.export({ format: "pem", type: "pkcs8" }) as string;
|
||||
|
||||
const kmsEncryptor = await kmsService.encryptWithKmsKey({
|
||||
kmsId: certificateManagerKmsId
|
||||
});
|
||||
const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({
|
||||
plainText: Buffer.from(new Uint8Array(leafCert.rawData))
|
||||
});
|
||||
const { cipherTextBlob: encryptedPrivateKey } = await kmsEncryptor({
|
||||
plainText: Buffer.from(skLeaf)
|
||||
});
|
||||
|
||||
const { caCert: issuingCaCertificate, caCertChain } = await getCaCertChain({
|
||||
caCertId: caCert.id,
|
||||
certificateAuthorityDAL,
|
||||
certificateAuthorityCertDAL,
|
||||
projectDAL,
|
||||
kmsService
|
||||
});
|
||||
|
||||
const certificateChainPem = `${issuingCaCertificate}\n${caCertChain}`.trim();
|
||||
|
||||
const { cipherTextBlob: encryptedCertificateChain } = await kmsEncryptor({
|
||||
plainText: Buffer.from(certificateChainPem)
|
||||
});
|
||||
|
||||
await certificateDAL.transaction(async (tx) => {
|
||||
const cert = await certificateDAL.create(
|
||||
{
|
||||
caId: ca.id,
|
||||
caCertId: caCert.id,
|
||||
pkiSubscriberId: subscriber.id,
|
||||
status: CertStatus.ACTIVE,
|
||||
friendlyName: subscriber.commonName,
|
||||
commonName: subscriber.commonName,
|
||||
altNames: subscriber.subjectAlternativeNames.join(","),
|
||||
serialNumber,
|
||||
notBefore: notBeforeDate,
|
||||
notAfter: notAfterDate,
|
||||
keyUsages: selectedKeyUsages,
|
||||
extendedKeyUsages: subscriber.extendedKeyUsages as CertExtendedKeyUsage[]
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
await certificateBodyDAL.create(
|
||||
{
|
||||
certId: cert.id,
|
||||
encryptedCertificate,
|
||||
encryptedCertificateChain
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
await certificateSecretDAL.create(
|
||||
{
|
||||
certId: cert.id,
|
||||
encryptedPrivateKey
|
||||
},
|
||||
tx
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
certificate: leafCert.toString("pem"),
|
||||
certificateChain: certificateChainPem,
|
||||
issuingCaCertificate,
|
||||
privateKey: skLeaf,
|
||||
serialNumber,
|
||||
ca,
|
||||
subscriber
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
issueCertificate
|
||||
};
|
||||
};
|
||||
+24
-3
@@ -10,13 +10,34 @@ import {
|
||||
} from "../certificate-authority-schemas";
|
||||
import { validateCaDateField } from "../certificate-authority-validators";
|
||||
|
||||
const InternalCertificateAuthorityConfigurationSchema = z
|
||||
const InternalCertificateAuthorityConfigurationSchema = z.object({
|
||||
type: z.nativeEnum(InternalCaType),
|
||||
friendlyName: z.string().optional(),
|
||||
commonName: z.string().trim(),
|
||||
organization: z.string().trim(),
|
||||
ou: z.string().trim(),
|
||||
dn: z.string().trim(),
|
||||
parentCaId: z.string().uuid().nullable(),
|
||||
serialNumber: z.string().trim(),
|
||||
activeCaCertId: z.string().uuid().nullable(),
|
||||
country: z.string().trim(),
|
||||
province: z.string().trim(),
|
||||
locality: z.string().trim(),
|
||||
notBefore: z.date().optional(),
|
||||
notAfter: z.date().optional(),
|
||||
maxPathLength: z.number().min(-1),
|
||||
keyAlgorithm: z.nativeEnum(CertKeyAlgorithm)
|
||||
});
|
||||
|
||||
const CreateInternalCertificateAuthorityConfigurationSchema = z
|
||||
.object({
|
||||
type: z.nativeEnum(InternalCaType),
|
||||
friendlyName: z.string().optional(),
|
||||
commonName: z.string().trim(),
|
||||
organization: z.string().trim(),
|
||||
ou: z.string().trim(),
|
||||
dn: z.string().trim(),
|
||||
parentCaId: z.string().uuid().optional(),
|
||||
country: z.string().trim(),
|
||||
province: z.string().trim(),
|
||||
locality: z.string().trim(),
|
||||
@@ -48,11 +69,11 @@ export const InternalCertificateAuthoritySchema = BaseCertificateAuthoritySchema
|
||||
export const CreateInternalCertificateAuthoritySchema = GenericCreateCertificateAuthorityFieldsSchema(
|
||||
CaType.INTERNAL
|
||||
).extend({
|
||||
configuration: InternalCertificateAuthorityConfigurationSchema
|
||||
configuration: CreateInternalCertificateAuthorityConfigurationSchema
|
||||
});
|
||||
|
||||
export const UpdateInternalCertificateAuthoritySchema = GenericUpdateCertificateAuthorityFieldsSchema(
|
||||
CaType.INTERNAL
|
||||
).extend({
|
||||
configuration: InternalCertificateAuthorityConfigurationSchema.optional()
|
||||
configuration: CreateInternalCertificateAuthorityConfigurationSchema.optional()
|
||||
});
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
/* eslint-disable no-bitwise */
|
||||
import { ForbiddenError, subject } from "@casl/ability";
|
||||
import * as x509 from "@peculiar/x509";
|
||||
import crypto, { KeyObject } from "crypto";
|
||||
import { z } from "zod";
|
||||
|
||||
import { ActionProjectType } from "@app/db/schemas";
|
||||
import { TCertificateAuthorityCrlDALFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-dal";
|
||||
@@ -14,10 +12,8 @@ import {
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { BadRequestError, NotFoundError } from "@app/lib/errors";
|
||||
import { ms } from "@app/lib/ms";
|
||||
import { isFQDN } from "@app/lib/validator/validate-url";
|
||||
import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal";
|
||||
import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal";
|
||||
import { TCertificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal";
|
||||
import {
|
||||
CertExtendedKeyUsage,
|
||||
CertExtendedKeyUsageOIDToName,
|
||||
@@ -27,7 +23,7 @@ import {
|
||||
} from "@app/services/certificate/certificate-types";
|
||||
import { TCertificateAuthorityCertDALFactory } from "@app/services/certificate-authority/certificate-authority-cert-dal";
|
||||
import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal";
|
||||
import { CaStatus } from "@app/services/certificate-authority/certificate-authority-enums";
|
||||
import { CaStatus, CaType } from "@app/services/certificate-authority/certificate-authority-enums";
|
||||
import {
|
||||
createSerialNumber,
|
||||
expandInternalCa,
|
||||
@@ -42,6 +38,12 @@ import { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subsc
|
||||
import { TProjectDALFactory } from "@app/services/project/project-dal";
|
||||
import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns";
|
||||
|
||||
import { TAppConnectionDALFactory } from "../app-connection/app-connection-dal";
|
||||
import { TAppConnectionServiceFactory } from "../app-connection/app-connection-service";
|
||||
import { TCertificateSecretDALFactory } from "../certificate/certificate-secret-dal";
|
||||
import { AcmeCertificateAuthorityFns } from "../certificate-authority/acme/acme-certificate-authority-fns";
|
||||
import { TExternalCertificateAuthorityDALFactory } from "../certificate-authority/external-certificate-authority-dal";
|
||||
import { InternalCertificateAuthorityFns } from "../certificate-authority/internal/internal-certificate-authority-fns";
|
||||
import {
|
||||
PkiSubscriberStatus,
|
||||
TCreatePkiSubscriberDTO,
|
||||
@@ -49,22 +51,29 @@ import {
|
||||
TGetPkiSubscriberDTO,
|
||||
TIssuePkiSubscriberCertDTO,
|
||||
TListPkiSubscriberCertsDTO,
|
||||
TOrderPkiSubscriberCertDTO,
|
||||
TSignPkiSubscriberCertDTO,
|
||||
TUpdatePkiSubscriberDTO
|
||||
} from "./pki-subscriber-types";
|
||||
|
||||
type TPkiSubscriberServiceFactoryDep = {
|
||||
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById">;
|
||||
appConnectionService: Pick<TAppConnectionServiceFactory, "connectAppConnectionById">;
|
||||
externalCertificateAuthorityDAL: Pick<TExternalCertificateAuthorityDALFactory, "create" | "update">;
|
||||
pkiSubscriberDAL: Pick<
|
||||
TPkiSubscriberDALFactory,
|
||||
"create" | "findById" | "updateById" | "deleteById" | "transaction" | "find" | "findOne"
|
||||
>;
|
||||
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findByIdWithAssociatedCa" | "findById">;
|
||||
certificateAuthorityDAL: Pick<
|
||||
TCertificateAuthorityDALFactory,
|
||||
"findByIdWithAssociatedCa" | "findById" | "transaction" | "create" | "updateById" | "findWithAssociatedCa"
|
||||
>;
|
||||
certificateAuthorityCertDAL: Pick<TCertificateAuthorityCertDALFactory, "findById">;
|
||||
certificateAuthoritySecretDAL: Pick<TCertificateAuthoritySecretDALFactory, "findOne">;
|
||||
certificateAuthorityCrlDAL: Pick<TCertificateAuthorityCrlDALFactory, "findOne">;
|
||||
certificateDAL: Pick<TCertificateDALFactory, "create" | "transaction" | "countCertificatesForPkiSubscriber" | "find">;
|
||||
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "create">;
|
||||
certificateSecretDAL: Pick<TCertificateSecretDALFactory, "create">;
|
||||
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "create">;
|
||||
projectDAL: Pick<TProjectDALFactory, "findOne" | "updateById" | "transaction" | "findById" | "find">;
|
||||
kmsService: Pick<TKmsServiceFactory, "generateKmsKey" | "decryptWithKmsKey" | "encryptWithKmsKey">;
|
||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
||||
@@ -79,12 +88,39 @@ export const pkiSubscriberServiceFactory = ({
|
||||
certificateAuthoritySecretDAL,
|
||||
certificateAuthorityCrlDAL,
|
||||
certificateDAL,
|
||||
certificateBodyDAL,
|
||||
certificateSecretDAL,
|
||||
certificateBodyDAL,
|
||||
projectDAL,
|
||||
kmsService,
|
||||
permissionService
|
||||
permissionService,
|
||||
appConnectionDAL,
|
||||
appConnectionService,
|
||||
externalCertificateAuthorityDAL
|
||||
}: TPkiSubscriberServiceFactoryDep) => {
|
||||
const internalCaFns = InternalCertificateAuthorityFns({
|
||||
certificateAuthorityDAL,
|
||||
certificateAuthorityCertDAL,
|
||||
certificateAuthoritySecretDAL,
|
||||
certificateAuthorityCrlDAL,
|
||||
certificateDAL,
|
||||
certificateBodyDAL,
|
||||
certificateSecretDAL,
|
||||
projectDAL,
|
||||
kmsService
|
||||
});
|
||||
|
||||
const acmeCaFns = AcmeCertificateAuthorityFns({
|
||||
appConnectionDAL,
|
||||
appConnectionService,
|
||||
certificateAuthorityDAL,
|
||||
externalCertificateAuthorityDAL,
|
||||
certificateDAL,
|
||||
certificateBodyDAL,
|
||||
certificateSecretDAL,
|
||||
kmsService,
|
||||
projectDAL
|
||||
});
|
||||
|
||||
const createSubscriber = async ({
|
||||
name,
|
||||
commonName,
|
||||
@@ -252,28 +288,26 @@ export const pkiSubscriberServiceFactory = ({
|
||||
return subscriber;
|
||||
};
|
||||
|
||||
const issueSubscriberCert = async ({
|
||||
const orderSubscriberCert = async ({
|
||||
subscriberName,
|
||||
projectId,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actor,
|
||||
actorOrgId
|
||||
}: TIssuePkiSubscriberCertDTO) => {
|
||||
}: TOrderPkiSubscriberCertDTO) => {
|
||||
const subscriber = await pkiSubscriberDAL.findOne({
|
||||
name: subscriberName,
|
||||
projectId
|
||||
});
|
||||
|
||||
if (!subscriber) throw new NotFoundError({ message: `PKI subscriber named '${subscriberName}' not found` });
|
||||
if (!subscriber.caId) throw new BadRequestError({ message: "Subscriber does not have an assigned issuing CA" });
|
||||
|
||||
const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(subscriber.caId);
|
||||
if (!ca?.internalCa) throw new NotFoundError({ message: `CA with ID '${subscriber.caId}' not found` });
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor,
|
||||
actorId,
|
||||
projectId: ca.projectId,
|
||||
projectId: subscriber.projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
actionProjectType: ActionProjectType.CertificateManager
|
||||
@@ -288,201 +322,74 @@ export const pkiSubscriberServiceFactory = ({
|
||||
|
||||
if (subscriber.status !== PkiSubscriberStatus.ACTIVE)
|
||||
throw new BadRequestError({ message: "Subscriber is not active" });
|
||||
if (ca.internalCa?.status !== CaStatus.ACTIVE) throw new BadRequestError({ message: "CA is not active" });
|
||||
if (!ca.internalCa?.activeCaCertId)
|
||||
throw new BadRequestError({ message: "CA does not have a certificate installed" });
|
||||
if (ca.disableDirectIssuance) {
|
||||
throw new BadRequestError({ message: "Certificate template is required for issuance" });
|
||||
}
|
||||
const caCert = await certificateAuthorityCertDAL.findById(ca.internalCa.activeCaCertId);
|
||||
|
||||
const certificateManagerKmsId = await getProjectKmsCertificateKeyId({
|
||||
projectId: ca.projectId,
|
||||
projectDAL,
|
||||
kmsService
|
||||
});
|
||||
const kmsDecryptor = await kmsService.decryptWithKmsKey({
|
||||
kmsId: certificateManagerKmsId
|
||||
});
|
||||
|
||||
const decryptedCaCert = await kmsDecryptor({
|
||||
cipherTextBlob: caCert.encryptedCertificate
|
||||
});
|
||||
|
||||
const caCertObj = new x509.X509Certificate(decryptedCaCert);
|
||||
const notBeforeDate = new Date();
|
||||
const notAfterDate = new Date(new Date().getTime() + ms(subscriber.ttl));
|
||||
const caCertNotBeforeDate = new Date(caCertObj.notBefore);
|
||||
const caCertNotAfterDate = new Date(caCertObj.notAfter);
|
||||
|
||||
// check not before constraint
|
||||
if (notBeforeDate < caCertNotBeforeDate) {
|
||||
throw new BadRequestError({ message: "notBefore date is before CA certificate's notBefore date" });
|
||||
const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(subscriber.caId);
|
||||
if (ca.internalCa?.id) {
|
||||
throw new BadRequestError({ message: "CA does not support ordering certificates" });
|
||||
}
|
||||
|
||||
// check not after constraint
|
||||
if (notAfterDate > caCertNotAfterDate) {
|
||||
throw new BadRequestError({ message: "notAfter date is after CA certificate's notAfter date" });
|
||||
}
|
||||
|
||||
const alg = keyAlgorithmToAlgCfg(ca.internalCa.keyAlgorithm as CertKeyAlgorithm);
|
||||
const leafKeys = await crypto.subtle.generateKey(alg, true, ["sign", "verify"]);
|
||||
|
||||
const csrObj = await x509.Pkcs10CertificateRequestGenerator.create({
|
||||
name: `CN=${subscriber.commonName}`,
|
||||
keys: leafKeys,
|
||||
signingAlgorithm: alg,
|
||||
extensions: [
|
||||
// eslint-disable-next-line no-bitwise
|
||||
new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature | x509.KeyUsageFlags.keyEncipherment)
|
||||
],
|
||||
attributes: [new x509.ChallengePasswordAttribute("password")]
|
||||
});
|
||||
|
||||
const { caPrivateKey, caSecret } = await getCaCredentials({
|
||||
caId: ca.id,
|
||||
certificateAuthorityDAL,
|
||||
certificateAuthoritySecretDAL,
|
||||
projectDAL,
|
||||
kmsService
|
||||
});
|
||||
|
||||
const caCrl = await certificateAuthorityCrlDAL.findOne({ caSecretId: caSecret.id });
|
||||
const appCfg = getConfig();
|
||||
|
||||
const distributionPointUrl = `${appCfg.SITE_URL}/api/v1/pki/crl/${caCrl.id}/der`;
|
||||
const caIssuerUrl = `${appCfg.SITE_URL}/api/v1/pki/ca/${ca.id}/certificates/${caCert.id}/der`;
|
||||
|
||||
const extensions: x509.Extension[] = [
|
||||
new x509.BasicConstraintsExtension(false),
|
||||
new x509.CRLDistributionPointsExtension([distributionPointUrl]),
|
||||
await x509.AuthorityKeyIdentifierExtension.create(caCertObj, false),
|
||||
await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey),
|
||||
new x509.AuthorityInfoAccessExtension({
|
||||
caIssuers: new x509.GeneralName("url", caIssuerUrl)
|
||||
}),
|
||||
new x509.CertificatePolicyExtension(["2.5.29.32.0"]) // anyPolicy
|
||||
];
|
||||
|
||||
const selectedKeyUsages = subscriber.keyUsages as CertKeyUsage[];
|
||||
const keyUsagesBitValue = selectedKeyUsages.reduce((accum, keyUsage) => accum | x509.KeyUsageFlags[keyUsage], 0);
|
||||
if (keyUsagesBitValue) {
|
||||
extensions.push(new x509.KeyUsagesExtension(keyUsagesBitValue, true));
|
||||
}
|
||||
|
||||
if (subscriber.extendedKeyUsages.length) {
|
||||
const extendedKeyUsagesExtension = new x509.ExtendedKeyUsageExtension(
|
||||
subscriber.extendedKeyUsages.map((eku) => x509.ExtendedKeyUsage[eku as CertExtendedKeyUsage]),
|
||||
true
|
||||
);
|
||||
extensions.push(extendedKeyUsagesExtension);
|
||||
}
|
||||
|
||||
let altNamesArray: { type: "email" | "dns"; value: string }[] = [];
|
||||
|
||||
if (subscriber.subjectAlternativeNames?.length) {
|
||||
altNamesArray = subscriber.subjectAlternativeNames.map((altName) => {
|
||||
if (z.string().email().safeParse(altName).success) {
|
||||
return { type: "email", value: altName };
|
||||
}
|
||||
|
||||
if (isFQDN(altName, { allow_wildcard: true })) {
|
||||
return { type: "dns", value: altName };
|
||||
}
|
||||
|
||||
throw new BadRequestError({ message: `Invalid SAN entry: ${altName}` });
|
||||
if (ca.externalCa?.id && ca.externalCa.type === CaType.ACME) {
|
||||
return acmeCaFns.orderCertificate(subscriber, ca, {
|
||||
type: actor,
|
||||
id: actorId,
|
||||
authMethod: actorAuthMethod,
|
||||
orgId: actorOrgId
|
||||
});
|
||||
|
||||
const altNamesExtension = new x509.SubjectAlternativeNameExtension(altNamesArray, false);
|
||||
extensions.push(altNamesExtension);
|
||||
}
|
||||
|
||||
const serialNumber = createSerialNumber();
|
||||
const leafCert = await x509.X509CertificateGenerator.create({
|
||||
serialNumber,
|
||||
subject: csrObj.subject,
|
||||
issuer: caCertObj.subject,
|
||||
notBefore: notBeforeDate,
|
||||
notAfter: notAfterDate,
|
||||
signingKey: caPrivateKey,
|
||||
publicKey: csrObj.publicKey,
|
||||
signingAlgorithm: alg,
|
||||
extensions
|
||||
throw new BadRequestError({ message: "Unsupported CA type" });
|
||||
};
|
||||
|
||||
const issueSubscriberCert = async ({
|
||||
subscriberName,
|
||||
projectId,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actor,
|
||||
actorOrgId
|
||||
}: TIssuePkiSubscriberCertDTO) => {
|
||||
const subscriber = await pkiSubscriberDAL.findOne({
|
||||
name: subscriberName,
|
||||
projectId
|
||||
});
|
||||
|
||||
const skLeafObj = KeyObject.from(leafKeys.privateKey);
|
||||
const skLeaf = skLeafObj.export({ format: "pem", type: "pkcs8" }) as string;
|
||||
if (!subscriber) throw new NotFoundError({ message: `PKI subscriber named '${subscriberName}' not found` });
|
||||
if (!subscriber.caId) throw new BadRequestError({ message: "Subscriber does not have an assigned issuing CA" });
|
||||
|
||||
const kmsEncryptor = await kmsService.encryptWithKmsKey({
|
||||
kmsId: certificateManagerKmsId
|
||||
});
|
||||
const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({
|
||||
plainText: Buffer.from(new Uint8Array(leafCert.rawData))
|
||||
});
|
||||
const { cipherTextBlob: encryptedPrivateKey } = await kmsEncryptor({
|
||||
plainText: Buffer.from(skLeaf)
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor,
|
||||
actorId,
|
||||
projectId: subscriber.projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
actionProjectType: ActionProjectType.CertificateManager
|
||||
});
|
||||
|
||||
const { caCert: issuingCaCertificate, caCertChain } = await getCaCertChain({
|
||||
caCertId: caCert.id,
|
||||
certificateAuthorityDAL,
|
||||
certificateAuthorityCertDAL,
|
||||
projectDAL,
|
||||
kmsService
|
||||
});
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionPkiSubscriberActions.IssueCert,
|
||||
subject(ProjectPermissionSub.PkiSubscribers, {
|
||||
name: subscriber.name
|
||||
})
|
||||
);
|
||||
|
||||
const certificateChainPem = `${issuingCaCertificate}\n${caCertChain}`.trim();
|
||||
if (subscriber.status !== PkiSubscriberStatus.ACTIVE)
|
||||
throw new BadRequestError({ message: "Subscriber is not active" });
|
||||
|
||||
const { cipherTextBlob: encryptedCertificateChain } = await kmsEncryptor({
|
||||
plainText: Buffer.from(certificateChainPem)
|
||||
});
|
||||
const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(subscriber.caId);
|
||||
if (ca.internalCa?.id) {
|
||||
return internalCaFns.issueCertificate(subscriber, ca);
|
||||
}
|
||||
|
||||
await certificateDAL.transaction(async (tx) => {
|
||||
const cert = await certificateDAL.create(
|
||||
{
|
||||
caId: ca.id,
|
||||
caCertId: caCert.id,
|
||||
pkiSubscriberId: subscriber.id,
|
||||
status: CertStatus.ACTIVE,
|
||||
friendlyName: subscriber.commonName,
|
||||
commonName: subscriber.commonName,
|
||||
altNames: subscriber.subjectAlternativeNames.join(","),
|
||||
serialNumber,
|
||||
notBefore: notBeforeDate,
|
||||
notAfter: notAfterDate,
|
||||
keyUsages: selectedKeyUsages,
|
||||
extendedKeyUsages: subscriber.extendedKeyUsages as CertExtendedKeyUsage[]
|
||||
},
|
||||
tx
|
||||
);
|
||||
if (ca.externalCa?.id && ca.externalCa.type === CaType.ACME) {
|
||||
return acmeCaFns.orderCertificate(subscriber, ca, {
|
||||
type: actor,
|
||||
id: actorId,
|
||||
authMethod: actorAuthMethod,
|
||||
orgId: actorOrgId
|
||||
});
|
||||
}
|
||||
|
||||
await certificateBodyDAL.create(
|
||||
{
|
||||
certId: cert.id,
|
||||
encryptedCertificate,
|
||||
encryptedCertificateChain
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
await certificateSecretDAL.create(
|
||||
{
|
||||
certId: cert.id,
|
||||
encryptedPrivateKey
|
||||
},
|
||||
tx
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
certificate: leafCert.toString("pem"),
|
||||
certificateChain: certificateChainPem,
|
||||
issuingCaCertificate,
|
||||
privateKey: skLeaf,
|
||||
serialNumber,
|
||||
ca,
|
||||
subscriber
|
||||
};
|
||||
throw new BadRequestError({ message: "CA does not support immediate issuance of certificates" });
|
||||
};
|
||||
|
||||
const signSubscriberCert = async ({
|
||||
@@ -803,6 +710,7 @@ export const pkiSubscriberServiceFactory = ({
|
||||
deleteSubscriber,
|
||||
issueSubscriberCert,
|
||||
signSubscriberCert,
|
||||
listSubscriberCerts
|
||||
listSubscriberCerts,
|
||||
orderSubscriberCert
|
||||
};
|
||||
};
|
||||
|
||||
@@ -42,6 +42,10 @@ export type TIssuePkiSubscriberCertDTO = {
|
||||
subscriberName: string;
|
||||
} & TProjectPermission;
|
||||
|
||||
export type TOrderPkiSubscriberCertDTO = {
|
||||
subscriberName: string;
|
||||
} & TProjectPermission;
|
||||
|
||||
export type TSignPkiSubscriberCertDTO = {
|
||||
subscriberName: string;
|
||||
csr: string;
|
||||
|
||||
@@ -918,6 +918,7 @@ export const projectServiceFactory = ({
|
||||
const cas = await certificateAuthorityDAL.findWithAssociatedCa(
|
||||
{
|
||||
[`${TableName.CertificateAuthority}.projectId` as "projectId"]: projectId,
|
||||
$notNull: [`${TableName.InternalCertificateAuthority}.id` as "id"],
|
||||
...(status && { [`${TableName.InternalCertificateAuthority}.status` as "status"]: status }),
|
||||
...(friendlyName && {
|
||||
[`${TableName.InternalCertificateAuthority}.friendlyName` as "friendlyName"]: friendlyName
|
||||
|
||||
Reference in New Issue
Block a user