Merge branch 'main' into feat/point-in-time-revamp

This commit is contained in:
carlosmonastyrski
2025-05-26 17:14:30 -03:00
parent c623c615a1
commit 13672481a8
128 changed files with 10602 additions and 3021 deletions
@@ -0,0 +1,3 @@
export enum AcmeDnsProvider {
Route53 = "route53"
}
@@ -0,0 +1,521 @@
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 } from "@app/db/schemas";
import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { OrgServiceActor } from "@app/lib/types";
import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator";
import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal";
import { AppConnection, AWSRegion } from "@app/services/app-connection/app-connection-enums";
import { decryptAppConnection } from "@app/services/app-connection/app-connection-fns";
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 { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal";
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 { TExternalCertificateAuthorityDALFactory } from "../external-certificate-authority-dal";
import { AcmeDnsProvider } from "./acme-certificate-authority-enums";
import { AcmeCertificateAuthorityCredentialsSchema } from "./acme-certificate-authority-schemas";
import {
TAcmeCertificateAuthority,
TCreateAcmeCertificateAuthorityDTO,
TUpdateAcmeCertificateAuthorityDTO
} from "./acme-certificate-authority-types";
type TAcmeCertificateAuthorityFnsDeps = {
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById">;
appConnectionService: Pick<TAppConnectionServiceFactory, "connectAppConnectionById">;
certificateAuthorityDAL: Pick<
TCertificateAuthorityDALFactory,
"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" | "createCipherPairWithDataKey" | "decryptWithKmsKey"
>;
pkiSubscriberDAL: Pick<TPkiSubscriberDALFactory, "findById">;
projectDAL: Pick<TProjectDALFactory, "findById" | "findOne" | "updateById" | "transaction">;
};
type DBConfigurationColumn = {
dnsProvider: string;
directoryUrl: string;
accountEmail: string;
hostedZoneId: string;
};
export const castDbEntryToAcmeCertificateAuthority = (
ca: Awaited<ReturnType<TCertificateAuthorityDALFactory["findByIdWithAssociatedCa"]>>
): TAcmeCertificateAuthority & { credentials: unknown } => {
if (!ca.externalCa?.id) {
throw new BadRequestError({ message: "Malformed ACME certificate authority" });
}
const dbConfigurationCol = ca.externalCa.configuration as DBConfigurationColumn;
return {
id: ca.id,
type: CaType.ACME,
enableDirectIssuance: ca.enableDirectIssuance,
name: ca.name,
projectId: ca.projectId,
credentials: ca.externalCa.credentials,
configuration: {
dnsAppConnectionId: ca.externalCa.dnsAppConnectionId as string,
dnsProviderConfig: {
provider: dbConfigurationCol.dnsProvider as AcmeDnsProvider,
hostedZoneId: dbConfigurationCol.hostedZoneId
},
directoryUrl: dbConfigurationCol.directoryUrl,
accountEmail: dbConfigurationCol.accountEmail
},
status: ca.status as CaStatus
};
};
export const route53InsertTxtRecord = async (
connection: TAwsConnectionConfig,
hostedZoneId: string,
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: hostedZoneId,
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,
hostedZoneId: string,
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: hostedZoneId,
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,
certificateDAL,
certificateBodyDAL,
certificateSecretDAL,
kmsService,
projectDAL,
pkiSubscriberDAL
}: TAcmeCertificateAuthorityFnsDeps) => {
const createCertificateAuthority = async ({
name,
projectId,
configuration,
enableDirectIssuance,
actor,
status
}: {
status: CaStatus;
name: string;
projectId: string;
configuration: TCreateAcmeCertificateAuthorityDTO["configuration"];
enableDirectIssuance: boolean;
actor: OrgServiceActor;
}) => {
const { dnsAppConnectionId, directoryUrl, accountEmail, dnsProviderConfig } = configuration;
const appConnection = await appConnectionDAL.findById(dnsAppConnectionId);
if (!appConnection) {
throw new NotFoundError({ message: `App connection with ID '${dnsAppConnectionId}' not found` });
}
if (dnsProviderConfig.provider === AcmeDnsProvider.Route53 && appConnection.app !== AppConnection.AWS) {
throw new BadRequestError({
message: `App connection with ID '${dnsAppConnectionId}' is not an AWS connection`
});
}
// validates permission to connect
await appConnectionService.connectAppConnectionById(appConnection.app as AppConnection, dnsAppConnectionId, actor);
const caEntity = await certificateAuthorityDAL.transaction(async (tx) => {
try {
const ca = await certificateAuthorityDAL.create(
{
projectId,
enableDirectIssuance,
name,
status
},
tx
);
await externalCertificateAuthorityDAL.create(
{
caId: ca.id,
dnsAppConnectionId,
type: CaType.ACME,
configuration: {
directoryUrl,
accountEmail,
dnsProvider: dnsProviderConfig.provider,
hostedZoneId: dnsProviderConfig.hostedZoneId
}
},
tx
);
return await certificateAuthorityDAL.findByIdWithAssociatedCa(ca.id, tx);
} catch (error) {
// @ts-expect-error We're expecting a database error
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if (error?.error?.code === "23505") {
throw new BadRequestError({
message: "Certificate authority with the same name already exists in your project"
});
}
throw error;
}
});
if (!caEntity.externalCa?.id) {
throw new BadRequestError({ message: "Failed to create external certificate authority" });
}
return castDbEntryToAcmeCertificateAuthority(caEntity);
};
const updateCertificateAuthority = async ({
id,
status,
configuration,
enableDirectIssuance,
actor,
name
}: {
id: string;
status?: CaStatus;
configuration: TUpdateAcmeCertificateAuthorityDTO["configuration"];
enableDirectIssuance?: boolean;
actor: OrgServiceActor;
name?: string;
}) => {
const updatedCa = await certificateAuthorityDAL.transaction(async (tx) => {
if (configuration) {
const { dnsAppConnectionId, directoryUrl, accountEmail, dnsProviderConfig } = configuration;
const appConnection = await appConnectionDAL.findById(dnsAppConnectionId);
if (!appConnection) {
throw new NotFoundError({ message: `App connection with ID '${dnsAppConnectionId}' not found` });
}
if (dnsProviderConfig.provider === AcmeDnsProvider.Route53 && appConnection.app !== AppConnection.AWS) {
throw new BadRequestError({
message: `App connection with ID '${dnsAppConnectionId}' is not an AWS connection`
});
}
// validates permission to connect
await appConnectionService.connectAppConnectionById(
appConnection.app as AppConnection,
dnsAppConnectionId,
actor
);
await externalCertificateAuthorityDAL.update(
{
caId: id,
type: CaType.ACME
},
{
dnsAppConnectionId,
configuration: {
directoryUrl,
accountEmail,
dnsProvider: dnsProviderConfig.provider,
hostedZoneId: dnsProviderConfig.hostedZoneId
}
},
tx
);
}
if (name || status || enableDirectIssuance) {
await certificateAuthorityDAL.updateById(
id,
{
name,
status,
enableDirectIssuance
},
tx
);
}
return certificateAuthorityDAL.findByIdWithAssociatedCa(id, tx);
});
if (!updatedCa.externalCa?.id) {
throw new BadRequestError({ message: "Failed to update external certificate authority" });
}
return castDbEntryToAcmeCertificateAuthority(updatedCa);
};
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);
};
const orderSubscriberCertificate = async (subscriberId: string) => {
const subscriber = await pkiSubscriberDAL.findById(subscriberId);
if (!subscriber.caId) {
throw new BadRequestError({ message: "Subscriber does not have a CA" });
}
const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(subscriber.caId);
if (!ca.externalCa || ca.externalCa.type !== CaType.ACME) {
throw new BadRequestError({ message: "CA is not an ACME CA" });
}
const acmeCa = castDbEntryToAcmeCertificateAuthority(ca);
if (acmeCa.status !== CaStatus.ACTIVE) {
throw new BadRequestError({ message: "CA is disabled" });
}
const certificateManagerKmsId = await getProjectKmsCertificateKeyId({
projectId: ca.projectId,
projectDAL,
kmsService
});
const kmsEncryptor = await kmsService.encryptWithKmsKey({
kmsId: certificateManagerKmsId
});
const kmsDecryptor = await kmsService.decryptWithKmsKey({
kmsId: certificateManagerKmsId
});
let accountKey: Buffer | undefined;
if (acmeCa.credentials) {
const decryptedCredentials = await kmsDecryptor({
cipherTextBlob: acmeCa.credentials as Buffer
});
const parsedCredentials = await AcmeCertificateAuthorityCredentialsSchema.parseAsync(
JSON.parse(decryptedCredentials.toString("utf8"))
);
accountKey = Buffer.from(parsedCredentials.accountKey, "base64");
}
if (!accountKey) {
accountKey = await acme.crypto.createPrivateRsaKey();
const newCredentials = {
accountKey: accountKey.toString("base64")
};
const { cipherTextBlob: encryptedNewCredentials } = await kmsEncryptor({
plainText: Buffer.from(JSON.stringify(newCredentials))
});
await externalCertificateAuthorityDAL.update(
{
caId: acmeCa.id
},
{
credentials: encryptedNewCredentials
}
);
}
await blockLocalAndPrivateIpAddresses(acmeCa.configuration.directoryUrl);
const acmeClient = new acme.Client({
directoryUrl: acmeCa.configuration.directoryUrl,
accountKey
});
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
);
const appConnection = await appConnectionDAL.findById(acmeCa.configuration.dnsAppConnectionId);
const connection = await decryptAppConnection(appConnection, kmsService);
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.dnsProviderConfig.provider === AcmeDnsProvider.Route53) {
await route53InsertTxtRecord(
connection as TAwsConnection,
acmeCa.configuration.dnsProviderConfig.hostedZoneId,
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.dnsProviderConfig.provider === AcmeDnsProvider.Route53) {
await route53DeleteTxtRecord(
connection as TAwsConnection,
acmeCa.configuration.dnsProviderConfig.hostedZoneId,
recordName,
recordValue
);
}
}
});
const [leafCert, parentCert] = acme.crypto.splitPemChain(pem);
const certObj = new x509.X509Certificate(leafCert);
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[],
projectId: ca.projectId
},
tx
);
await certificateBodyDAL.create(
{
certId: cert.id,
encryptedCertificate,
encryptedCertificateChain
},
tx
);
await certificateSecretDAL.create(
{
certId: cert.id,
encryptedPrivateKey
},
tx
);
});
};
return {
createCertificateAuthority,
updateCertificateAuthority,
listCertificateAuthorities,
orderSubscriberCertificate
};
};
@@ -0,0 +1,39 @@
import { z } from "zod";
import { CertificateAuthorities } from "@app/lib/api-docs/constants";
import { CaType } from "../certificate-authority-enums";
import {
BaseCertificateAuthoritySchema,
GenericCreateCertificateAuthorityFieldsSchema,
GenericUpdateCertificateAuthorityFieldsSchema
} from "../certificate-authority-schemas";
import { AcmeDnsProvider } from "./acme-certificate-authority-enums";
export const AcmeCertificateAuthorityConfigurationSchema = z.object({
dnsAppConnectionId: z.string().uuid().trim().describe(CertificateAuthorities.CONFIGURATIONS.ACME.dnsAppConnectionId),
// soon, differentiate via the provider property
dnsProviderConfig: z.object({
provider: z.nativeEnum(AcmeDnsProvider).describe(CertificateAuthorities.CONFIGURATIONS.ACME.provider),
hostedZoneId: z.string().trim().min(1).describe(CertificateAuthorities.CONFIGURATIONS.ACME.hostedZoneId)
}),
directoryUrl: z.string().url().trim().min(1).describe(CertificateAuthorities.CONFIGURATIONS.ACME.directoryUrl),
accountEmail: z.string().trim().min(1).describe(CertificateAuthorities.CONFIGURATIONS.ACME.accountEmail)
});
export const AcmeCertificateAuthorityCredentialsSchema = z.object({
accountKey: z.string()
});
export const AcmeCertificateAuthoritySchema = BaseCertificateAuthoritySchema.extend({
type: z.literal(CaType.ACME),
configuration: AcmeCertificateAuthorityConfigurationSchema
});
export const CreateAcmeCertificateAuthoritySchema = GenericCreateCertificateAuthorityFieldsSchema(CaType.ACME).extend({
configuration: AcmeCertificateAuthorityConfigurationSchema
});
export const UpdateAcmeCertificateAuthoritySchema = GenericUpdateCertificateAuthorityFieldsSchema(CaType.ACME).extend({
configuration: AcmeCertificateAuthorityConfigurationSchema.optional()
});
@@ -0,0 +1,15 @@
import { z } from "zod";
import {
AcmeCertificateAuthoritySchema,
CreateAcmeCertificateAuthoritySchema,
UpdateAcmeCertificateAuthoritySchema
} from "./acme-certificate-authority-schemas";
export type TAcmeCertificateAuthority = z.infer<typeof AcmeCertificateAuthoritySchema>;
export type TAcmeCertificateAuthorityInput = z.infer<typeof CreateAcmeCertificateAuthoritySchema>;
export type TCreateAcmeCertificateAuthorityDTO = z.infer<typeof CreateAcmeCertificateAuthoritySchema>;
export type TUpdateAcmeCertificateAuthorityDTO = z.infer<typeof UpdateAcmeCertificateAuthoritySchema>;
@@ -1,13 +1,188 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { CertificateAuthoritiesSchema, TableName, TCertificateAuthorities } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { ormify } from "@app/lib/knex";
import { buildFindFilter, ormify, selectAllTableCols, TFindOpt } from "@app/lib/knex";
export type TCertificateAuthorityDALFactory = ReturnType<typeof certificateAuthorityDALFactory>;
export type TCertificateAuthorityWithAssociatedCa = Awaited<
ReturnType<TCertificateAuthorityDALFactory["findByIdWithAssociatedCa"]>
>;
export const certificateAuthorityDALFactory = (db: TDbClient) => {
const caOrm = ormify(db, TableName.CertificateAuthority);
const findByNameAndProjectIdWithAssociatedCa = async (caName: string, projectId: string, tx?: Knex) => {
const result = await (tx || db.replicaNode())(TableName.CertificateAuthority)
.leftJoin(
TableName.InternalCertificateAuthority,
`${TableName.CertificateAuthority}.id`,
`${TableName.InternalCertificateAuthority}.caId`
)
.leftJoin(
TableName.ExternalCertificateAuthority,
`${TableName.CertificateAuthority}.id`,
`${TableName.ExternalCertificateAuthority}.caId`
)
.where(`${TableName.CertificateAuthority}.name`, caName)
.where(`${TableName.CertificateAuthority}.projectId`, projectId)
.select(selectAllTableCols(TableName.CertificateAuthority))
.select(
db.ref("id").withSchema(TableName.InternalCertificateAuthority).as("internalCaId"),
db.ref("parentCaId").withSchema(TableName.InternalCertificateAuthority).as("internalParentCaId"),
db.ref("type").withSchema(TableName.InternalCertificateAuthority).as("internalType"),
db.ref("friendlyName").withSchema(TableName.InternalCertificateAuthority).as("internalFriendlyName"),
db.ref("organization").withSchema(TableName.InternalCertificateAuthority).as("internalOrganization"),
db.ref("ou").withSchema(TableName.InternalCertificateAuthority).as("internalOu"),
db.ref("country").withSchema(TableName.InternalCertificateAuthority).as("internalCountry"),
db.ref("province").withSchema(TableName.InternalCertificateAuthority).as("internalProvince"),
db.ref("locality").withSchema(TableName.InternalCertificateAuthority).as("internalLocality"),
db.ref("commonName").withSchema(TableName.InternalCertificateAuthority).as("internalCommonName"),
db.ref("dn").withSchema(TableName.InternalCertificateAuthority).as("internalDn"),
db.ref("serialNumber").withSchema(TableName.InternalCertificateAuthority).as("internalSerialNumber"),
db.ref("maxPathLength").withSchema(TableName.InternalCertificateAuthority).as("internalMaxPathLength"),
db.ref("keyAlgorithm").withSchema(TableName.InternalCertificateAuthority).as("internalKeyAlgorithm"),
db.ref("notBefore").withSchema(TableName.InternalCertificateAuthority).as("internalNotBefore"),
db.ref("notAfter").withSchema(TableName.InternalCertificateAuthority).as("internalNotAfter"),
db.ref("activeCaCertId").withSchema(TableName.InternalCertificateAuthority).as("internalActiveCaCertId")
)
.select(
db.ref("id").withSchema(TableName.ExternalCertificateAuthority).as("externalCaId"),
db.ref("type").withSchema(TableName.ExternalCertificateAuthority).as("externalType"),
db.ref("configuration").withSchema(TableName.ExternalCertificateAuthority).as("externalConfiguration"),
db.ref("credentials").withSchema(TableName.ExternalCertificateAuthority).as("externalCredentials"),
db
.ref("dnsAppConnectionId")
.withSchema(TableName.ExternalCertificateAuthority)
.as("externalDnsAppConnectionId"),
db.ref("appConnectionId").withSchema(TableName.ExternalCertificateAuthority).as("externalAppConnectionId")
)
.first();
const data = {
...CertificateAuthoritiesSchema.parse(result),
internalCa: result
? {
id: result.internalCaId,
parentCaId: result.internalParentCaId,
type: result.internalType,
friendlyName: result.internalFriendlyName,
organization: result.internalOrganization,
ou: result.internalOu,
country: result.internalCountry,
province: result.internalProvince,
locality: result.internalLocality,
commonName: result.internalCommonName,
dn: result.internalDn,
serialNumber: result.internalSerialNumber,
maxPathLength: result.internalMaxPathLength,
keyAlgorithm: result.internalKeyAlgorithm,
notBefore: result.internalNotBefore?.toISOString(),
notAfter: result.internalNotAfter?.toISOString(),
activeCaCertId: result.internalActiveCaCertId
}
: undefined,
externalCa: result
? {
id: result.externalCaId,
type: result.externalType,
configuration: result.externalConfiguration,
dnsAppConnectionId: result.externalDnsAppConnectionId,
appConnectionId: result.externalAppConnectionId,
credentials: result.externalCredentials
}
: undefined
};
return data;
};
const findByIdWithAssociatedCa = async (caId: string, tx?: Knex) => {
const result = await (tx || db.replicaNode())(TableName.CertificateAuthority)
.leftJoin(
TableName.InternalCertificateAuthority,
`${TableName.CertificateAuthority}.id`,
`${TableName.InternalCertificateAuthority}.caId`
)
.leftJoin(
TableName.ExternalCertificateAuthority,
`${TableName.CertificateAuthority}.id`,
`${TableName.ExternalCertificateAuthority}.caId`
)
.where(`${TableName.CertificateAuthority}.id`, caId)
.select(selectAllTableCols(TableName.CertificateAuthority))
.select(
db.ref("id").withSchema(TableName.InternalCertificateAuthority).as("internalCaId"),
db.ref("parentCaId").withSchema(TableName.InternalCertificateAuthority).as("internalParentCaId"),
db.ref("type").withSchema(TableName.InternalCertificateAuthority).as("internalType"),
db.ref("friendlyName").withSchema(TableName.InternalCertificateAuthority).as("internalFriendlyName"),
db.ref("organization").withSchema(TableName.InternalCertificateAuthority).as("internalOrganization"),
db.ref("ou").withSchema(TableName.InternalCertificateAuthority).as("internalOu"),
db.ref("country").withSchema(TableName.InternalCertificateAuthority).as("internalCountry"),
db.ref("province").withSchema(TableName.InternalCertificateAuthority).as("internalProvince"),
db.ref("locality").withSchema(TableName.InternalCertificateAuthority).as("internalLocality"),
db.ref("commonName").withSchema(TableName.InternalCertificateAuthority).as("internalCommonName"),
db.ref("dn").withSchema(TableName.InternalCertificateAuthority).as("internalDn"),
db.ref("serialNumber").withSchema(TableName.InternalCertificateAuthority).as("internalSerialNumber"),
db.ref("maxPathLength").withSchema(TableName.InternalCertificateAuthority).as("internalMaxPathLength"),
db.ref("keyAlgorithm").withSchema(TableName.InternalCertificateAuthority).as("internalKeyAlgorithm"),
db.ref("notBefore").withSchema(TableName.InternalCertificateAuthority).as("internalNotBefore"),
db.ref("notAfter").withSchema(TableName.InternalCertificateAuthority).as("internalNotAfter"),
db.ref("activeCaCertId").withSchema(TableName.InternalCertificateAuthority).as("internalActiveCaCertId")
)
.select(
db.ref("id").withSchema(TableName.ExternalCertificateAuthority).as("externalCaId"),
db.ref("type").withSchema(TableName.ExternalCertificateAuthority).as("externalType"),
db.ref("configuration").withSchema(TableName.ExternalCertificateAuthority).as("externalConfiguration"),
db.ref("credentials").withSchema(TableName.ExternalCertificateAuthority).as("externalCredentials"),
db
.ref("dnsAppConnectionId")
.withSchema(TableName.ExternalCertificateAuthority)
.as("externalDnsAppConnectionId"),
db.ref("appConnectionId").withSchema(TableName.ExternalCertificateAuthority).as("externalAppConnectionId")
)
.first();
const data = {
...CertificateAuthoritiesSchema.parse(result),
internalCa: result
? {
id: result.internalCaId,
parentCaId: result.internalParentCaId,
type: result.internalType,
friendlyName: result.internalFriendlyName,
organization: result.internalOrganization,
ou: result.internalOu,
country: result.internalCountry,
province: result.internalProvince,
locality: result.internalLocality,
commonName: result.internalCommonName,
dn: result.internalDn,
serialNumber: result.internalSerialNumber,
maxPathLength: result.internalMaxPathLength,
keyAlgorithm: result.internalKeyAlgorithm,
notBefore: result.internalNotBefore?.toISOString(),
notAfter: result.internalNotAfter?.toISOString(),
activeCaCertId: result.internalActiveCaCertId
}
: undefined,
externalCa: result
? {
id: result.externalCaId,
type: result.externalType,
configuration: result.externalConfiguration,
dnsAppConnectionId: result.externalDnsAppConnectionId,
appConnectionId: result.externalAppConnectionId,
credentials: result.externalCredentials
}
: undefined
};
return data;
};
// note: not used
const buildCertificateChain = async (caId: string) => {
try {
@@ -42,8 +217,113 @@ export const certificateAuthorityDALFactory = (db: TDbClient) => {
}
};
const findWithAssociatedCa = async (
filter: Parameters<(typeof caOrm)["find"]>[0] & { dn?: string; type?: string },
{ offset, limit, sort = [["createdAt", "desc"]] }: TFindOpt<TCertificateAuthorities> = {},
tx?: Knex
) => {
try {
const query = (tx || db.replicaNode())(TableName.CertificateAuthority)
.leftJoin(
TableName.InternalCertificateAuthority,
`${TableName.CertificateAuthority}.id`,
`${TableName.InternalCertificateAuthority}.caId`
)
.leftJoin(
TableName.ExternalCertificateAuthority,
`${TableName.CertificateAuthority}.id`,
`${TableName.ExternalCertificateAuthority}.caId`
)
// 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"),
db.ref("parentCaId").withSchema(TableName.InternalCertificateAuthority).as("internalParentCaId"),
db.ref("type").withSchema(TableName.InternalCertificateAuthority).as("internalType"),
db.ref("friendlyName").withSchema(TableName.InternalCertificateAuthority).as("internalFriendlyName"),
db.ref("organization").withSchema(TableName.InternalCertificateAuthority).as("internalOrganization"),
db.ref("ou").withSchema(TableName.InternalCertificateAuthority).as("internalOu"),
db.ref("country").withSchema(TableName.InternalCertificateAuthority).as("internalCountry"),
db.ref("province").withSchema(TableName.InternalCertificateAuthority).as("internalProvince"),
db.ref("locality").withSchema(TableName.InternalCertificateAuthority).as("internalLocality"),
db.ref("commonName").withSchema(TableName.InternalCertificateAuthority).as("internalCommonName"),
db.ref("dn").withSchema(TableName.InternalCertificateAuthority).as("internalDn"),
db.ref("serialNumber").withSchema(TableName.InternalCertificateAuthority).as("internalSerialNumber"),
db.ref("maxPathLength").withSchema(TableName.InternalCertificateAuthority).as("internalMaxPathLength"),
db.ref("keyAlgorithm").withSchema(TableName.InternalCertificateAuthority).as("internalKeyAlgorithm"),
db.ref("notBefore").withSchema(TableName.InternalCertificateAuthority).as("internalNotBefore"),
db.ref("notAfter").withSchema(TableName.InternalCertificateAuthority).as("internalNotAfter"),
db.ref("activeCaCertId").withSchema(TableName.InternalCertificateAuthority).as("internalActiveCaCertId")
)
.select(
db.ref("id").withSchema(TableName.ExternalCertificateAuthority).as("externalCaId"),
db.ref("type").withSchema(TableName.ExternalCertificateAuthority).as("externalType"),
db.ref("configuration").withSchema(TableName.ExternalCertificateAuthority).as("externalConfiguration"),
db
.ref("dnsAppConnectionId")
.withSchema(TableName.ExternalCertificateAuthority)
.as("externalDnsAppConnectionId"),
db.ref("credentials").withSchema(TableName.ExternalCertificateAuthority).as("externalCredentials"),
db.ref("appConnectionId").withSchema(TableName.ExternalCertificateAuthority).as("externalAppConnectionId")
);
if (limit) void query.limit(limit);
if (offset) void query.offset(offset);
if (sort) {
void query.orderBy(
sort.map(([column, order, nulls]) => ({
column,
order,
nulls
}))
);
}
return (await query).map((ca) => ({
...CertificateAuthoritiesSchema.parse(ca),
internalCa: ca
? {
id: ca.internalCaId,
parentCaId: ca.internalParentCaId,
type: ca.internalType,
friendlyName: ca.internalFriendlyName,
organization: ca.internalOrganization,
ou: ca.internalOu,
country: ca.internalCountry,
province: ca.internalProvince,
locality: ca.internalLocality,
commonName: ca.internalCommonName,
dn: ca.internalDn,
serialNumber: ca.internalSerialNumber,
maxPathLength: ca.internalMaxPathLength,
keyAlgorithm: ca.internalKeyAlgorithm,
notBefore: ca.internalNotBefore?.toISOString(),
notAfter: ca.internalNotAfter?.toISOString(),
activeCaCertId: ca.internalActiveCaCertId
}
: undefined,
externalCa: ca
? {
id: ca.externalCaId,
type: ca.externalType,
configuration: ca.externalConfiguration,
dnsAppConnectionId: ca.externalDnsAppConnectionId,
appConnectionId: ca.externalAppConnectionId,
credentials: ca.externalCredentials
}
: undefined
}));
} catch (error) {
throw new DatabaseError({ error, name: "Find - Certificate Authority" });
}
};
return {
...caOrm,
buildCertificateChain
findWithAssociatedCa,
buildCertificateChain,
findByIdWithAssociatedCa,
findByNameAndProjectIdWithAssociatedCa
};
};
@@ -0,0 +1,19 @@
export enum CaType {
INTERNAL = "internal",
ACME = "acme"
}
export enum InternalCaType {
ROOT = "root",
INTERMEDIATE = "intermediate"
}
export enum CaStatus {
ACTIVE = "active",
DISABLED = "disabled",
PENDING_CERTIFICATE = "pending-certificate"
}
export enum CaRenewalType {
EXISTING = "existing"
}
@@ -5,13 +5,14 @@ import { NotFoundError } from "@app/lib/errors";
import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns";
import { CertKeyAlgorithm, CertStatus } from "../certificate/certificate-types";
import { TCertificateAuthorityDALFactory } from "./certificate-authority-dal";
import {
TDNParts,
TGetCaCertChainDTO,
TGetCaCertChainsDTO,
TGetCaCredentialsDTO,
TRebuildCaCrlDTO
} from "./certificate-authority-types";
} from "./internal/internal-certificate-authority-types";
/* eslint-disable no-bitwise */
export const createSerialNumber = () => {
@@ -112,8 +113,8 @@ export const getCaCredentials = async ({
projectDAL,
kmsService
}: TGetCaCredentialsDTO) => {
const ca = await certificateAuthorityDAL.findById(caId);
if (!ca) throw new NotFoundError({ message: `CA with ID '${caId}' not found` });
const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId);
if (!ca?.internalCa?.id) throw new NotFoundError({ message: `Internal CA with ID '${caId}' not found` });
const caSecret = await certificateAuthoritySecretDAL.findOne({ caId });
if (!caSecret) throw new NotFoundError({ message: `CA secret for CA with ID '${caId}' not found` });
@@ -131,7 +132,7 @@ export const getCaCredentials = async ({
cipherTextBlob: caSecret.encryptedPrivateKey
});
const alg = keyAlgorithmToAlgCfg(ca.keyAlgorithm as CertKeyAlgorithm);
const alg = keyAlgorithmToAlgCfg(ca.internalCa.keyAlgorithm as CertKeyAlgorithm);
const skObj = crypto.createPrivateKey({ key: decryptedPrivateKey, format: "der", type: "pkcs8" });
const caPrivateKey = await crypto.subtle.importKey(
"pkcs8",
@@ -255,12 +256,12 @@ export const rebuildCaCrl = async ({
certificateDAL,
kmsService
}: TRebuildCaCrlDTO) => {
const ca = await certificateAuthorityDAL.findById(caId);
if (!ca) throw new NotFoundError({ message: `CA with ID '${caId}' not found` });
const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId);
if (!ca?.internalCa?.id) throw new NotFoundError({ message: `Internal CA with ID '${caId}' not found` });
const caSecret = await certificateAuthoritySecretDAL.findOne({ caId: ca.id });
const alg = keyAlgorithmToAlgCfg(ca.keyAlgorithm as CertKeyAlgorithm);
const alg = keyAlgorithmToAlgCfg(ca.internalCa.keyAlgorithm as CertKeyAlgorithm);
const keyId = await getProjectKmsCertificateKeyId({
projectId: ca.projectId,
@@ -287,7 +288,7 @@ export const rebuildCaCrl = async ({
});
const crl = await x509.X509CrlGenerator.create({
issuer: ca.dn,
issuer: ca.internalCa.dn,
thisUpdate: new Date(),
nextUpdate: new Date("2025/12/12"),
entries: revokedCerts.map((revokedCert) => {
@@ -318,3 +319,16 @@ export const rebuildCaCrl = async ({
}
);
};
export const expandInternalCa = (
ca: Awaited<ReturnType<TCertificateAuthorityDALFactory["findByIdWithAssociatedCa"]>>
) => {
if (!ca.internalCa) {
throw new Error("Internal CA must be defined");
}
return {
...ca.internalCa,
...ca,
requireTemplateForIssuance: !ca.enableDirectIssuance
} as const;
};
@@ -0,0 +1,6 @@
import { CaType } from "./certificate-authority-enums";
export const CERTIFICATE_AUTHORITIES_TYPE_MAP: Record<CaType, string> = {
[CaType.INTERNAL]: "Internal",
[CaType.ACME]: "ACME"
};
@@ -1,9 +1,10 @@
import * as x509 from "@peculiar/x509";
import crypto from "crypto";
import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore";
import { getConfig } from "@app/lib/config/env";
import { daysToMillisecond, secondsToMillis } from "@app/lib/dates";
import { NotFoundError } from "@app/lib/errors";
import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { logger } from "@app/lib/logger";
import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue";
import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal";
@@ -13,21 +14,43 @@ import { TProjectDALFactory } from "@app/services/project/project-dal";
import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns";
import { TCertificateAuthorityCrlDALFactory } from "../../ee/services/certificate-authority-crl/certificate-authority-crl-dal";
import { TAppConnectionDALFactory } from "../app-connection/app-connection-dal";
import { TAppConnectionServiceFactory } from "../app-connection/app-connection-service";
import { TCertificateBodyDALFactory } from "../certificate/certificate-body-dal";
import { TCertificateSecretDALFactory } from "../certificate/certificate-secret-dal";
import { TPkiSubscriberDALFactory } from "../pki-subscriber/pki-subscriber-dal";
import { SubscriberOperationStatus } from "../pki-subscriber/pki-subscriber-types";
import { AcmeCertificateAuthorityFns } from "./acme/acme-certificate-authority-fns";
import { TCertificateAuthorityDALFactory } from "./certificate-authority-dal";
import { CaType } from "./certificate-authority-enums";
import { keyAlgorithmToAlgCfg } from "./certificate-authority-fns";
import { TCertificateAuthoritySecretDALFactory } from "./certificate-authority-secret-dal";
import { TRotateCaCrlTriggerDTO } from "./certificate-authority-types";
import { TExternalCertificateAuthorityDALFactory } from "./external-certificate-authority-dal";
import {
TOrderCertificateForSubscriberDTO,
TRotateCaCrlTriggerDTO
} from "./internal/internal-certificate-authority-types";
type TCertificateAuthorityQueueFactoryDep = {
// TODO: Pick
certificateAuthorityDAL: TCertificateAuthorityDALFactory;
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "update">;
appConnectionService: Pick<TAppConnectionServiceFactory, "connectAppConnectionById">;
externalCertificateAuthorityDAL: Pick<TExternalCertificateAuthorityDALFactory, "create" | "update">;
keyStore: Pick<TKeyStoreFactory, "acquireLock" | "setItemWithExpiry" | "getItem">;
certificateAuthorityCrlDAL: TCertificateAuthorityCrlDALFactory;
certificateAuthoritySecretDAL: TCertificateAuthoritySecretDALFactory;
certificateDAL: TCertificateDALFactory;
projectDAL: Pick<TProjectDALFactory, "findProjectBySlug" | "findOne" | "updateById" | "findById" | "transaction">;
kmsService: Pick<TKmsServiceFactory, "generateKmsKey" | "encryptWithKmsKey" | "decryptWithKmsKey">;
kmsService: Pick<
TKmsServiceFactory,
"generateKmsKey" | "encryptWithKmsKey" | "decryptWithKmsKey" | "createCipherPairWithDataKey"
>;
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "create">;
certificateSecretDAL: Pick<TCertificateSecretDALFactory, "create">;
queueService: TQueueServiceFactory;
pkiSubscriberDAL: Pick<TPkiSubscriberDALFactory, "findById" | "updateById">;
};
export type TCertificateAuthorityQueueFactory = ReturnType<typeof certificateAuthorityQueueFactory>;
export const certificateAuthorityQueueFactory = ({
@@ -37,8 +60,28 @@ export const certificateAuthorityQueueFactory = ({
certificateDAL,
projectDAL,
kmsService,
queueService
queueService,
keyStore,
appConnectionDAL,
appConnectionService,
externalCertificateAuthorityDAL,
certificateBodyDAL,
certificateSecretDAL,
pkiSubscriberDAL
}: TCertificateAuthorityQueueFactoryDep) => {
const acmeFns = AcmeCertificateAuthorityFns({
appConnectionDAL,
appConnectionService,
certificateAuthorityDAL,
externalCertificateAuthorityDAL,
certificateDAL,
certificateBodyDAL,
certificateSecretDAL,
kmsService,
pkiSubscriberDAL,
projectDAL
});
// TODO 1: auto-periodic rotation
// TODO 2: manual rotation
@@ -71,16 +114,76 @@ export const certificateAuthorityQueueFactory = ({
);
};
const orderCertificateForSubscriber = async ({ subscriberId, caType }: TOrderCertificateForSubscriberDTO) => {
const entry = await keyStore.getItem(KeyStorePrefixes.CaOrderCertificateForSubscriberLock(subscriberId));
if (entry) {
throw new BadRequestError({ message: `Certificate order already in progress for subscriber ${subscriberId}` });
}
await queueService.queue(
QueueName.CaLifecycle,
QueueJobs.CaOrderCertificateForSubscriber,
{
subscriberId,
caType
},
{
attempts: 1,
removeOnComplete: true,
removeOnFail: true
}
);
};
queueService.start(QueueName.CaLifecycle, async (job) => {
if (job.name === QueueJobs.CaOrderCertificateForSubscriber) {
const { subscriberId, caType } = job.data;
let lock: Awaited<ReturnType<typeof keyStore.acquireLock>>;
try {
lock = await keyStore.acquireLock(
[KeyStorePrefixes.CaOrderCertificateForSubscriberLock(subscriberId)],
5 * 60 * 1000
);
} catch (e) {
logger.info(`CaOrderCertificate Failed to acquire lock [subscriberId=${subscriberId}] [job=${job.name}]`);
return;
}
try {
if (caType === CaType.ACME) {
await acmeFns.orderSubscriberCertificate(subscriberId);
await pkiSubscriberDAL.updateById(subscriberId, {
lastOperationStatus: SubscriberOperationStatus.SUCCESS,
lastOperationMessage: "Certificate ordered successfully",
lastOperationAt: new Date()
});
}
} catch (e: unknown) {
if (e instanceof Error) {
await pkiSubscriberDAL.updateById(subscriberId, {
lastOperationStatus: SubscriberOperationStatus.FAILED,
lastOperationMessage: e.message,
lastOperationAt: new Date()
});
}
logger.error(e, `CaOrderCertificate Failed [subscriberId=${subscriberId}] [job=${job.name}]`);
} finally {
await lock.release();
}
}
});
queueService.start(QueueName.CaCrlRotation, async (job) => {
const { caId } = job.data;
logger.info(`secretReminderQueue.process: [secretDocument=${caId}]`);
const ca = await certificateAuthorityDAL.findById(caId);
if (!ca) throw new NotFoundError({ message: `CA with ID '${caId}' not found` });
const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId);
if (!ca.internalCa) throw new NotFoundError({ message: `CA with ID '${caId}' not found` });
const caSecret = await certificateAuthoritySecretDAL.findOne({ caId: ca.id });
const alg = keyAlgorithmToAlgCfg(ca.keyAlgorithm as CertKeyAlgorithm);
const alg = keyAlgorithmToAlgCfg(ca.internalCa.keyAlgorithm as CertKeyAlgorithm);
const keyId = await getProjectKmsCertificateKeyId({
projectId: ca.projectId,
@@ -106,7 +209,7 @@ export const certificateAuthorityQueueFactory = ({
});
const crl = await x509.X509CrlGenerator.create({
issuer: ca.dn,
issuer: ca.internalCa.dn,
thisUpdate: new Date(),
nextUpdate: new Date("2025/12/12"), // TODO: depends on configured rebuild interval
entries: revokedCerts.map((revokedCert) => {
@@ -115,7 +218,7 @@ export const certificateAuthorityQueueFactory = ({
revocationDate: new Date(revokedCert.revokedAt as Date),
reason: revokedCert.revocationReason as number,
invalidity: new Date("2022/01/01"),
issuer: ca.dn
issuer: ca.internalCa?.dn
};
}),
signingAlgorithm: alg,
@@ -144,6 +247,7 @@ export const certificateAuthorityQueueFactory = ({
});
return {
setCaCrlRotationInterval
setCaCrlRotationInterval,
orderCertificateForSubscriber
};
};
@@ -0,0 +1,32 @@
import z from "zod";
import { CertificateAuthoritiesSchema } from "@app/db/schemas";
import { CertificateAuthorities } from "@app/lib/api-docs/constants";
import { slugSchema } from "@app/server/lib/schemas";
import { CaStatus, CaType } from "./certificate-authority-enums";
export const BaseCertificateAuthoritySchema = CertificateAuthoritiesSchema.pick({
projectId: true,
enableDirectIssuance: true,
name: true,
id: true
}).extend({
status: z.nativeEnum(CaStatus)
});
export const GenericCreateCertificateAuthorityFieldsSchema = (type: CaType) =>
z.object({
name: slugSchema({ field: "name" }).describe(CertificateAuthorities.CREATE(type).name),
projectId: z.string().trim().min(1, "Project ID required").describe(CertificateAuthorities.CREATE(type).projectId),
enableDirectIssuance: z.boolean().describe(CertificateAuthorities.CREATE(type).enableDirectIssuance),
status: z.nativeEnum(CaStatus).describe(CertificateAuthorities.CREATE(type).status)
});
export const GenericUpdateCertificateAuthorityFieldsSchema = (type: CaType) =>
z.object({
name: slugSchema({ field: "name" }).optional().describe(CertificateAuthorities.UPDATE(type).name),
projectId: z.string().trim().min(1, "Project ID required").describe(CertificateAuthorities.UPDATE(type).projectId),
enableDirectIssuance: z.boolean().optional().describe(CertificateAuthorities.UPDATE(type).enableDirectIssuance),
status: z.nativeEnum(CaStatus).optional().describe(CertificateAuthorities.UPDATE(type).status)
});
File diff suppressed because it is too large Load Diff
@@ -1,186 +1,18 @@
import { TProjectPermission } from "@app/lib/types";
import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TProjectDALFactory } from "@app/services/project/project-dal";
import { TAcmeCertificateAuthority, TAcmeCertificateAuthorityInput } from "./acme/acme-certificate-authority-types";
import { CaType } from "./certificate-authority-enums";
import {
TInternalCertificateAuthority,
TInternalCertificateAuthorityInput
} from "./internal/internal-certificate-authority-types";
import { TCertificateAuthorityCrlDALFactory } from "../../ee/services/certificate-authority-crl/certificate-authority-crl-dal";
import { CertExtendedKeyUsage, CertKeyAlgorithm, CertKeyUsage } from "../certificate/certificate-types";
import { TCertificateAuthorityCertDALFactory } from "./certificate-authority-cert-dal";
import { TCertificateAuthorityDALFactory } from "./certificate-authority-dal";
import { TCertificateAuthoritySecretDALFactory } from "./certificate-authority-secret-dal";
export type TCertificateAuthority = TInternalCertificateAuthority | TAcmeCertificateAuthority;
export enum CaType {
ROOT = "root",
INTERMEDIATE = "intermediate"
}
export type TCertificateAuthorityInput = TInternalCertificateAuthorityInput | TAcmeCertificateAuthorityInput;
export enum CaStatus {
ACTIVE = "active",
DISABLED = "disabled",
PENDING_CERTIFICATE = "pending-certificate"
}
export type TCreateCertificateAuthorityDTO = Omit<TCertificateAuthority, "id">;
export enum CaRenewalType {
EXISTING = "existing"
}
export type TCreateCaDTO = {
projectSlug: string;
export type TUpdateCertificateAuthorityDTO = Partial<Omit<TCreateCertificateAuthorityDTO, "projectId">> & {
type: CaType;
friendlyName?: string;
commonName: string;
organization: string;
ou: string;
country: string;
province: string;
locality: string;
notBefore?: string;
notAfter?: string;
maxPathLength: number;
keyAlgorithm: CertKeyAlgorithm;
requireTemplateForIssuance: boolean;
} & Omit<TProjectPermission, "projectId">;
export type TGetCaDTO = {
caId: string;
} & Omit<TProjectPermission, "projectId">;
export type TUpdateCaDTO = {
caId: string;
status?: CaStatus;
requireTemplateForIssuance?: boolean;
} & Omit<TProjectPermission, "projectId">;
export type TDeleteCaDTO = {
caId: string;
} & Omit<TProjectPermission, "projectId">;
export type TGetCaCsrDTO = {
caId: string;
} & Omit<TProjectPermission, "projectId">;
export type TRenewCaCertDTO = {
caId: string;
notAfter: string;
type: CaRenewalType;
} & Omit<TProjectPermission, "projectId">;
export type TGetCaCertsDTO = {
caId: string;
} & Omit<TProjectPermission, "projectId">;
export type TGetCaCertDTO = {
caId: string;
} & Omit<TProjectPermission, "projectId">;
export type TSignIntermediateDTO = {
caId: string;
csr: string;
notBefore?: string;
notAfter: string;
maxPathLength: number;
} & Omit<TProjectPermission, "projectId">;
export type TImportCertToCaDTO = {
caId: string;
certificate: string;
certificateChain: string;
} & Omit<TProjectPermission, "projectId">;
export type TIssueCertFromCaDTO = {
caId?: string;
certificateTemplateId?: string;
pkiCollectionId?: string;
friendlyName?: string;
commonName: string;
altNames: string;
ttl: string;
notBefore?: string;
notAfter?: string;
keyUsages?: CertKeyUsage[];
extendedKeyUsages?: CertExtendedKeyUsage[];
} & Omit<TProjectPermission, "projectId">;
export type TSignCertFromCaDTO =
| {
isInternal: true;
caId?: string;
csr: string;
certificateTemplateId?: string;
pkiCollectionId?: string;
friendlyName?: string;
commonName?: string;
altNames?: string;
ttl?: string;
notBefore?: string;
notAfter?: string;
keyUsages?: CertKeyUsage[];
extendedKeyUsages?: CertExtendedKeyUsage[];
}
| ({
isInternal: false;
caId?: string;
csr: string;
certificateTemplateId?: string;
pkiCollectionId?: string;
friendlyName?: string;
commonName?: string;
altNames: string;
ttl: string;
notBefore?: string;
notAfter?: string;
keyUsages?: CertKeyUsage[];
extendedKeyUsages?: CertExtendedKeyUsage[];
} & Omit<TProjectPermission, "projectId">);
export type TGetCaCertificateTemplatesDTO = {
caId: string;
} & Omit<TProjectPermission, "projectId">;
export type TDNParts = {
commonName?: string;
organization?: string;
ou?: string;
country?: string;
province?: string;
locality?: string;
};
export type TGetCaCredentialsDTO = {
caId: string;
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findById">;
certificateAuthoritySecretDAL: Pick<TCertificateAuthoritySecretDALFactory, "findOne">;
projectDAL: Pick<TProjectDALFactory, "findOne" | "updateById" | "transaction">;
kmsService: Pick<TKmsServiceFactory, "decryptWithKmsKey" | "generateKmsKey">;
};
export type TGetCaCertChainsDTO = {
caId: string;
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findById">;
certificateAuthorityCertDAL: Pick<TCertificateAuthorityCertDALFactory, "find">;
projectDAL: Pick<TProjectDALFactory, "findOne" | "updateById" | "transaction">;
kmsService: Pick<TKmsServiceFactory, "decryptWithKmsKey" | "generateKmsKey">;
};
export type TGetCaCertChainDTO = {
caCertId: string;
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findById">;
certificateAuthorityCertDAL: Pick<TCertificateAuthorityCertDALFactory, "findById">;
projectDAL: Pick<TProjectDALFactory, "findOne" | "updateById" | "transaction">;
kmsService: Pick<TKmsServiceFactory, "decryptWithKmsKey" | "generateKmsKey">;
};
export type TRebuildCaCrlDTO = {
caId: string;
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findById">;
certificateAuthorityCrlDAL: Pick<TCertificateAuthorityCrlDALFactory, "update">;
certificateAuthoritySecretDAL: Pick<TCertificateAuthoritySecretDALFactory, "findOne">;
projectDAL: Pick<TProjectDALFactory, "findOne" | "updateById" | "transaction">;
certificateDAL: Pick<TCertificateDALFactory, "find">;
kmsService: Pick<TKmsServiceFactory, "generateKmsKey" | "decryptWithKmsKey" | "encryptWithKmsKey">;
};
export type TRotateCaCrlTriggerDTO = {
caId: string;
rotationIntervalDays: number;
caName: string;
projectId: string;
};
@@ -15,7 +15,7 @@ export const validateAltNameField = z
.trim()
.refine(
(name) => {
return isFQDN(name) || z.string().email().safeParse(name).success || isValidIp(name);
return isFQDN(name, { allow_wildcard: true }) || z.string().email().safeParse(name).success || isValidIp(name);
},
{
message: "SAN must be a valid hostname, email address, or IP address"
@@ -0,0 +1,13 @@
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify } from "@app/lib/knex";
export type TExternalCertificateAuthorityDALFactory = ReturnType<typeof externalCertificateAuthorityDALFactory>;
export const externalCertificateAuthorityDALFactory = (db: TDbClient) => {
const caOrm = ormify(db, TableName.ExternalCertificateAuthority);
return {
...caOrm
};
};
@@ -0,0 +1,13 @@
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify } from "@app/lib/knex";
export type TInternalCertificateAuthorityDALFactory = ReturnType<typeof internalCertificateAuthorityDALFactory>;
export const internalCertificateAuthorityDALFactory = (db: TDbClient) => {
const caOrm = ormify(db, TableName.InternalCertificateAuthority);
return {
...caOrm
};
};
@@ -0,0 +1,263 @@
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.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" });
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 ?? "0"));
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[],
projectId: ca.projectId
},
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
};
};
@@ -0,0 +1,62 @@
import { z } from "zod";
import { CertificateAuthorities } from "@app/lib/api-docs/constants";
import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types";
import { CaType, InternalCaType } from "../certificate-authority-enums";
import {
BaseCertificateAuthoritySchema,
GenericCreateCertificateAuthorityFieldsSchema,
GenericUpdateCertificateAuthorityFieldsSchema
} from "../certificate-authority-schemas";
import { validateCaDateField } from "../certificate-authority-validators";
const InternalCertificateAuthorityConfigurationSchema = z
.object({
type: z.nativeEnum(InternalCaType).describe(CertificateAuthorities.CONFIGURATIONS.INTERNAL.type),
friendlyName: z.string().optional().describe(CertificateAuthorities.CONFIGURATIONS.INTERNAL.friendlyName),
commonName: z.string().trim().describe(CertificateAuthorities.CONFIGURATIONS.INTERNAL.commonName),
organization: z.string().trim().describe(CertificateAuthorities.CONFIGURATIONS.INTERNAL.organization),
ou: z.string().trim().describe(CertificateAuthorities.CONFIGURATIONS.INTERNAL.ou),
country: z.string().trim().describe(CertificateAuthorities.CONFIGURATIONS.INTERNAL.country),
province: z.string().trim().describe(CertificateAuthorities.CONFIGURATIONS.INTERNAL.province),
locality: z.string().trim().describe(CertificateAuthorities.CONFIGURATIONS.INTERNAL.locality),
notBefore: validateCaDateField.optional().describe(CertificateAuthorities.CONFIGURATIONS.INTERNAL.notBefore),
notAfter: validateCaDateField.optional().describe(CertificateAuthorities.CONFIGURATIONS.INTERNAL.notAfter),
maxPathLength: z.number().min(-1).nullish().describe(CertificateAuthorities.CONFIGURATIONS.INTERNAL.maxPathLength),
keyAlgorithm: z.nativeEnum(CertKeyAlgorithm).describe(CertificateAuthorities.CONFIGURATIONS.INTERNAL.keyAlgorithm),
dn: z.string().trim().nullish(),
parentCaId: z.string().uuid().nullish(),
serialNumber: z.string().trim().nullish(),
activeCaCertId: z.string().uuid().nullish()
})
.refine(
(data) => {
// Check that at least one of the specified fields is non-empty
return [data.commonName, data.organization, data.ou, data.country, data.province, data.locality].some(
(field) => field !== ""
);
},
{
message:
"At least one of the fields commonName, organization, ou, country, province, or locality must be non-empty",
path: []
}
);
export const InternalCertificateAuthoritySchema = BaseCertificateAuthoritySchema.extend({
type: z.literal(CaType.INTERNAL),
configuration: InternalCertificateAuthorityConfigurationSchema
});
export const CreateInternalCertificateAuthoritySchema = GenericCreateCertificateAuthorityFieldsSchema(
CaType.INTERNAL
).extend({
configuration: InternalCertificateAuthorityConfigurationSchema
});
export const UpdateInternalCertificateAuthoritySchema = GenericUpdateCertificateAuthorityFieldsSchema(
CaType.INTERNAL
).extend({
configuration: InternalCertificateAuthorityConfigurationSchema.optional()
});
@@ -0,0 +1,223 @@
import { z } from "zod";
import { TCertificateAuthorityCrlDALFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-dal";
import { TProjectPermission } from "@app/lib/types";
import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal";
import { CertExtendedKeyUsage, CertKeyAlgorithm, CertKeyUsage } from "@app/services/certificate/certificate-types";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TProjectDALFactory } from "@app/services/project/project-dal";
import { TCertificateAuthorityCertDALFactory } from "../certificate-authority-cert-dal";
import { TCertificateAuthorityDALFactory } from "../certificate-authority-dal";
import { CaRenewalType, CaStatus, CaType, InternalCaType } from "../certificate-authority-enums";
import { TCertificateAuthoritySecretDALFactory } from "../certificate-authority-secret-dal";
import {
CreateInternalCertificateAuthoritySchema,
InternalCertificateAuthoritySchema,
UpdateInternalCertificateAuthoritySchema
} from "./internal-certificate-authority-schemas";
export type TInternalCertificateAuthority = z.infer<typeof InternalCertificateAuthoritySchema>;
export type TInternalCertificateAuthorityInput = z.infer<typeof CreateInternalCertificateAuthoritySchema>;
export type TCreateInternalCertificateAuthorityDTO = z.infer<typeof CreateInternalCertificateAuthoritySchema>;
export type TUpdateInternalCertificateAuthorityDTO = z.infer<typeof UpdateInternalCertificateAuthoritySchema>;
export type TCreateCaDTO =
| {
isInternal: true;
projectId: string;
type: InternalCaType;
friendlyName?: string;
name?: string;
commonName: string;
organization: string;
ou: string;
country: string;
province: string;
locality: string;
notBefore?: string;
notAfter?: string;
maxPathLength?: number | null;
keyAlgorithm: CertKeyAlgorithm;
enableDirectIssuance: boolean;
}
| ({
isInternal: false;
projectSlug: string;
type: InternalCaType;
friendlyName?: string;
name?: string;
commonName: string;
organization: string;
ou: string;
country: string;
province: string;
locality: string;
notBefore?: string;
notAfter?: string;
maxPathLength?: number | null;
keyAlgorithm: CertKeyAlgorithm;
enableDirectIssuance: boolean;
} & Omit<TProjectPermission, "projectId">);
export type TGetCaDTO = {
caId: string;
} & Omit<TProjectPermission, "projectId">;
export type TUpdateCaDTO =
| {
isInternal: true;
caId: string;
name?: string;
status?: CaStatus;
enableDirectIssuance?: boolean;
}
| ({
isInternal: false;
caId: string;
name?: string;
status?: CaStatus;
enableDirectIssuance?: boolean;
} & Omit<TProjectPermission, "projectId">);
export type TDeleteCaDTO = {
caId: string;
} & Omit<TProjectPermission, "projectId">;
export type TGetCaCsrDTO = {
caId: string;
} & Omit<TProjectPermission, "projectId">;
export type TRenewCaCertDTO = {
caId: string;
notAfter: string;
type: CaRenewalType;
} & Omit<TProjectPermission, "projectId">;
export type TGetCaCertsDTO = {
caId: string;
} & Omit<TProjectPermission, "projectId">;
export type TGetCaCertDTO = {
caId: string;
} & Omit<TProjectPermission, "projectId">;
export type TSignIntermediateDTO = {
caId: string;
csr: string;
notBefore?: string;
notAfter: string;
maxPathLength: number;
} & Omit<TProjectPermission, "projectId">;
export type TImportCertToCaDTO = {
caId: string;
certificate: string;
certificateChain: string;
} & Omit<TProjectPermission, "projectId">;
export type TIssueCertFromCaDTO = {
caId?: string;
certificateTemplateId?: string;
pkiCollectionId?: string;
friendlyName?: string;
commonName: string;
altNames: string;
ttl: string;
notBefore?: string;
notAfter?: string;
keyUsages?: CertKeyUsage[];
extendedKeyUsages?: CertExtendedKeyUsage[];
} & Omit<TProjectPermission, "projectId">;
export type TSignCertFromCaDTO =
| {
isInternal: true;
caId?: string;
csr: string;
certificateTemplateId?: string;
pkiCollectionId?: string;
friendlyName?: string;
commonName?: string;
altNames?: string;
ttl?: string;
notBefore?: string;
notAfter?: string;
keyUsages?: CertKeyUsage[];
extendedKeyUsages?: CertExtendedKeyUsage[];
}
| ({
isInternal: false;
caId?: string;
csr: string;
certificateTemplateId?: string;
pkiCollectionId?: string;
friendlyName?: string;
commonName?: string;
altNames: string;
ttl: string;
notBefore?: string;
notAfter?: string;
keyUsages?: CertKeyUsage[];
extendedKeyUsages?: CertExtendedKeyUsage[];
} & Omit<TProjectPermission, "projectId">);
export type TGetCaCertificateTemplatesDTO = {
caId: string;
} & Omit<TProjectPermission, "projectId">;
export type TDNParts = {
commonName?: string;
organization?: string;
ou?: string;
country?: string;
province?: string;
locality?: string;
};
export type TGetCaCredentialsDTO = {
caId: string;
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findByIdWithAssociatedCa">;
certificateAuthoritySecretDAL: Pick<TCertificateAuthoritySecretDALFactory, "findOne">;
projectDAL: Pick<TProjectDALFactory, "findOne" | "updateById" | "transaction">;
kmsService: Pick<TKmsServiceFactory, "decryptWithKmsKey" | "generateKmsKey">;
};
export type TGetCaCertChainsDTO = {
caId: string;
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findById">;
certificateAuthorityCertDAL: Pick<TCertificateAuthorityCertDALFactory, "find">;
projectDAL: Pick<TProjectDALFactory, "findOne" | "updateById" | "transaction">;
kmsService: Pick<TKmsServiceFactory, "decryptWithKmsKey" | "generateKmsKey">;
};
export type TGetCaCertChainDTO = {
caCertId: string;
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findById">;
certificateAuthorityCertDAL: Pick<TCertificateAuthorityCertDALFactory, "findById">;
projectDAL: Pick<TProjectDALFactory, "findOne" | "updateById" | "transaction">;
kmsService: Pick<TKmsServiceFactory, "decryptWithKmsKey" | "generateKmsKey">;
};
export type TRebuildCaCrlDTO = {
caId: string;
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findByIdWithAssociatedCa">;
certificateAuthorityCrlDAL: Pick<TCertificateAuthorityCrlDALFactory, "update">;
certificateAuthoritySecretDAL: Pick<TCertificateAuthoritySecretDALFactory, "findOne">;
projectDAL: Pick<TProjectDALFactory, "findOne" | "updateById" | "transaction">;
certificateDAL: Pick<TCertificateDALFactory, "find">;
kmsService: Pick<TKmsServiceFactory, "generateKmsKey" | "decryptWithKmsKey" | "encryptWithKmsKey">;
};
export type TRotateCaCrlTriggerDTO = {
caId: string;
rotationIntervalDays: number;
};
export type TOrderCertificateForSubscriberDTO = {
subscriberId: string;
caType: CaType;
};
@@ -19,10 +19,15 @@ export const certificateTemplateDALFactory = (db: TDbClient) => {
`${TableName.CertificateAuthority}.id`,
`${TableName.CertificateTemplate}.caId`
)
.join(
TableName.InternalCertificateAuthority,
`${TableName.InternalCertificateAuthority}.caId`,
`${TableName.CertificateAuthority}.id`
)
.where(`${TableName.CertificateAuthority}.projectId`, "=", projectId)
.select(selectAllTableCols(TableName.CertificateTemplate))
.select(
db.ref("friendlyName").as("caName").withSchema(TableName.CertificateAuthority),
db.ref("friendlyName").as("caName").withSchema(TableName.InternalCertificateAuthority),
db.ref("projectId").withSchema(TableName.CertificateAuthority)
);
@@ -41,11 +46,16 @@ export const certificateTemplateDALFactory = (db: TDbClient) => {
`${TableName.CertificateTemplate}.caId`
)
.join(TableName.Project, `${TableName.Project}.id`, `${TableName.CertificateAuthority}.projectId`)
.join(
TableName.InternalCertificateAuthority,
`${TableName.InternalCertificateAuthority}.caId`,
`${TableName.CertificateAuthority}.id`
)
.where(`${TableName.CertificateTemplate}.id`, "=", id)
.select(selectAllTableCols(TableName.CertificateTemplate))
.select(
db.ref("projectId").withSchema(TableName.CertificateAuthority),
db.ref("friendlyName").as("caName").withSchema(TableName.CertificateAuthority),
db.ref("friendlyName").as("caName").withSchema(TableName.InternalCertificateAuthority),
db.ref("orgId").withSchema(TableName.Project)
)
.first();
@@ -3,11 +3,28 @@ import { TableName } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { ormify } from "@app/lib/knex";
import { CertStatus } from "./certificate-types";
export type TCertificateDALFactory = ReturnType<typeof certificateDALFactory>;
export const certificateDALFactory = (db: TDbClient) => {
const certificateOrm = ormify(db, TableName.Certificate);
const findLatestActiveCertForSubscriber = async ({ subscriberId }: { subscriberId: string }) => {
try {
const cert = await db
.replicaNode()(TableName.Certificate)
.where({ pkiSubscriberId: subscriberId, status: CertStatus.ACTIVE })
.where("notAfter", ">", new Date())
.orderBy("notBefore", "desc")
.first();
return cert;
} catch (error) {
throw new DatabaseError({ error, name: "Find latest active certificate for subscriber" });
}
};
const countCertificatesInProject = async ({
projectId,
friendlyName,
@@ -65,6 +82,7 @@ export const certificateDALFactory = (db: TDbClient) => {
return {
...certificateOrm,
countCertificatesInProject,
countCertificatesForPkiSubscriber
countCertificatesForPkiSubscriber,
findLatestActiveCertForSubscriber
};
};
@@ -1,11 +1,12 @@
import crypto from "node:crypto";
import * as x509 from "@peculiar/x509";
import RE2 from "re2";
import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { getProjectKmsCertificateKeyId } from "../project/project-fns";
import { CrlReason, TBuildCertificateChainDTO, TGetCertificateCredentialsDTO } from "./certificate-types";
import { CrlReason, TGetCertificateCredentialsDTO } from "./certificate-types";
export const revocationReasonToCrlCode = (crlReason: CrlReason) => {
switch (crlReason) {
@@ -52,6 +53,12 @@ export const constructPemChainFromCerts = (certificates: x509.X509Certificate[])
.join("\n")
.trim();
export const splitPemChain = (pemText: string) => {
const re2Pattern = new RE2("-----BEGIN CERTIFICATE-----[^-]+-----END CERTIFICATE-----", "g");
return re2Pattern.match(pemText) || [];
};
/**
* Return the public and private key of certificate
* Note: credentials are returned as PEM strings
@@ -95,29 +102,3 @@ export const getCertificateCredentials = async ({
throw new BadRequestError({ message: `Failed to process private key for certificate with ID '${certId}'` });
}
};
// If the certificate was generated after ~05/01/25 it will have a encryptedCertificateChain attached to it's body
// Otherwise we'll fallback to manually building the chain
export const buildCertificateChain = async ({
caCert,
caCertChain,
encryptedCertificateChain,
kmsService,
kmsId
}: TBuildCertificateChainDTO) => {
if (!encryptedCertificateChain && !caCert) {
return null;
}
let certificateChain = `${caCert}\n${caCertChain}`.trim();
if (encryptedCertificateChain) {
const kmsDecryptor = await kmsService.decryptWithKmsKey({ kmsId });
const decryptedCertChain = await kmsDecryptor({
cipherTextBlob: encryptedCertificateChain
});
certificateChain = decryptedCertChain.toString();
}
return certificateChain;
};
@@ -1,45 +1,57 @@
import { ForbiddenError } from "@casl/ability";
import * as x509 from "@peculiar/x509";
import { createPrivateKey, createPublicKey, sign, verify } from "crypto";
import { ActionProjectType } from "@app/db/schemas";
import { ActionProjectType, ProjectType } from "@app/db/schemas";
import { TCertificateAuthorityCrlDALFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-dal";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import {
ProjectPermissionCertificateActions,
ProjectPermissionSub
} from "@app/ee/services/permission/project-permission";
import { NotFoundError } from "@app/lib/errors";
import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal";
import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal";
import { TCertificateAuthorityCertDALFactory } from "@app/services/certificate-authority/certificate-authority-cert-dal";
import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal";
import { TCertificateAuthoritySecretDALFactory } from "@app/services/certificate-authority/certificate-authority-secret-dal";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TPkiCollectionDALFactory } from "@app/services/pki-collection/pki-collection-dal";
import { TPkiCollectionItemDALFactory } from "@app/services/pki-collection/pki-collection-item-dal";
import { TProjectDALFactory } from "@app/services/project/project-dal";
import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns";
import { getCaCertChain, rebuildCaCrl } from "../certificate-authority/certificate-authority-fns";
import { buildCertificateChain, getCertificateCredentials, revocationReasonToCrlCode } from "./certificate-fns";
import { expandInternalCa, getCaCertChain, rebuildCaCrl } from "../certificate-authority/certificate-authority-fns";
import { getCertificateCredentials, revocationReasonToCrlCode, splitPemChain } from "./certificate-fns";
import { TCertificateSecretDALFactory } from "./certificate-secret-dal";
import {
CertExtendedKeyUsage,
CertExtendedKeyUsageOIDToName,
CertKeyUsage,
CertStatus,
TDeleteCertDTO,
TGetCertBodyDTO,
TGetCertBundleDTO,
TGetCertDTO,
TGetCertPrivateKeyDTO,
TImportCertDTO,
TRevokeCertDTO
} from "./certificate-types";
type TCertificateServiceFactoryDep = {
certificateDAL: Pick<TCertificateDALFactory, "findOne" | "deleteById" | "update" | "find">;
certificateSecretDAL: Pick<TCertificateSecretDALFactory, "findOne">;
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "findOne">;
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findById">;
certificateDAL: Pick<TCertificateDALFactory, "findOne" | "deleteById" | "update" | "find" | "transaction" | "create">;
certificateSecretDAL: Pick<TCertificateSecretDALFactory, "findOne" | "create">;
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "findOne" | "create">;
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findById" | "findByIdWithAssociatedCa">;
certificateAuthorityCertDAL: Pick<TCertificateAuthorityCertDALFactory, "findById">;
certificateAuthorityCrlDAL: Pick<TCertificateAuthorityCrlDALFactory, "update">;
certificateAuthoritySecretDAL: Pick<TCertificateAuthoritySecretDALFactory, "findOne">;
projectDAL: Pick<TProjectDALFactory, "findOne" | "updateById" | "findById" | "transaction">;
pkiCollectionDAL: Pick<TPkiCollectionDALFactory, "findById">;
pkiCollectionItemDAL: Pick<TPkiCollectionItemDALFactory, "create">;
projectDAL: Pick<
TProjectDALFactory,
"findProjectBySlug" | "findOne" | "updateById" | "findById" | "transaction" | "getProjectFromSplitId"
>;
kmsService: Pick<TKmsServiceFactory, "generateKmsKey" | "encryptWithKmsKey" | "decryptWithKmsKey">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
};
@@ -54,6 +66,8 @@ export const certificateServiceFactory = ({
certificateAuthorityCertDAL,
certificateAuthorityCrlDAL,
certificateAuthoritySecretDAL,
pkiCollectionDAL,
pkiCollectionItemDAL,
projectDAL,
kmsService,
permissionService
@@ -63,12 +77,11 @@ export const certificateServiceFactory = ({
*/
const getCert = async ({ serialNumber, actorId, actorAuthMethod, actor, actorOrgId }: TGetCertDTO) => {
const cert = await certificateDAL.findOne({ serialNumber });
const ca = await certificateAuthorityDAL.findById(cert.caId);
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId: ca.projectId,
projectId: cert.projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.CertificateManager
@@ -80,8 +93,7 @@ export const certificateServiceFactory = ({
);
return {
cert,
ca
cert
};
};
@@ -96,12 +108,11 @@ export const certificateServiceFactory = ({
actorOrgId
}: TGetCertPrivateKeyDTO) => {
const cert = await certificateDAL.findOne({ serialNumber });
const ca = await certificateAuthorityDAL.findById(cert.caId);
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId: ca.projectId,
projectId: cert.projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.CertificateManager
@@ -114,14 +125,13 @@ export const certificateServiceFactory = ({
const { certPrivateKey } = await getCertificateCredentials({
certId: cert.id,
projectId: ca.projectId,
projectId: cert.projectId,
certificateSecretDAL,
projectDAL,
kmsService
});
return {
ca,
cert,
certPrivateKey
};
@@ -132,12 +142,11 @@ export const certificateServiceFactory = ({
*/
const deleteCert = async ({ serialNumber, actorId, actorAuthMethod, actor, actorOrgId }: TDeleteCertDTO) => {
const cert = await certificateDAL.findOne({ serialNumber });
const ca = await certificateAuthorityDAL.findById(cert.caId);
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId: ca.projectId,
projectId: cert.projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.CertificateManager
@@ -151,8 +160,7 @@ export const certificateServiceFactory = ({
const deletedCert = await certificateDAL.deleteById(cert.id);
return {
deletedCert,
ca
deletedCert
};
};
@@ -170,7 +178,20 @@ export const certificateServiceFactory = ({
actorOrgId
}: TRevokeCertDTO) => {
const cert = await certificateDAL.findOne({ serialNumber });
const ca = await certificateAuthorityDAL.findById(cert.caId);
if (!cert.caId) {
throw new BadRequestError({
message: "Cannot revoke imported certificates"
});
}
const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(cert.caId);
if (ca.externalCa?.id) {
throw new BadRequestError({
message: "Cannot revoke external certificates"
});
}
const { permission } = await permissionService.getProjectPermission({
actor,
@@ -211,7 +232,7 @@ export const certificateServiceFactory = ({
kmsService
});
return { revokedAt, cert, ca };
return { revokedAt, cert, ca: expandInternalCa(ca) };
};
/**
@@ -220,12 +241,11 @@ export const certificateServiceFactory = ({
*/
const getCertBody = async ({ serialNumber, actorId, actorAuthMethod, actor, actorOrgId }: TGetCertBodyDTO) => {
const cert = await certificateDAL.findOne({ serialNumber });
const ca = await certificateAuthorityDAL.findById(cert.caId);
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId: ca.projectId,
projectId: cert.projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.CertificateManager
@@ -239,7 +259,7 @@ export const certificateServiceFactory = ({
const certBody = await certificateBodyDAL.findOne({ certId: cert.id });
const certificateManagerKeyId = await getProjectKmsCertificateKeyId({
projectId: ca.projectId,
projectId: cert.projectId,
projectDAL,
kmsService
});
@@ -253,28 +273,259 @@ export const certificateServiceFactory = ({
const certObj = new x509.X509Certificate(decryptedCert);
const { caCert, caCertChain } = await getCaCertChain({
caCertId: cert.caCertId,
certificateAuthorityDAL,
certificateAuthorityCertDAL,
projectDAL,
kmsService
});
let certificateChain = null;
const certificateChain = await buildCertificateChain({
caCert,
caCertChain,
kmsId: certificateManagerKeyId,
kmsService,
encryptedCertificateChain: certBody.encryptedCertificateChain || undefined
});
// On newer certs the certBody.encryptedCertificateChain column will always exist.
// Older certs will have a caCertId which will be used as a fallback mechanism for structuring the chain.
if (certBody.encryptedCertificateChain) {
const decryptedCertChain = await kmsDecryptor({
cipherTextBlob: certBody.encryptedCertificateChain
});
certificateChain = decryptedCertChain.toString();
} else if (cert.caCertId) {
const { caCert, caCertChain } = await getCaCertChain({
caCertId: cert.caCertId,
certificateAuthorityDAL,
certificateAuthorityCertDAL,
projectDAL,
kmsService
});
certificateChain = `${caCert}\n${caCertChain}`.trim();
}
return {
certificate: certObj.toString("pem"),
certificateChain,
serialNumber: certObj.serialNumber,
cert,
ca
cert
};
};
/**
* Import certificate
*/
const importCert = async ({
projectSlug,
pkiCollectionId,
actorId,
actorAuthMethod,
actor,
actorOrgId,
friendlyName,
certificatePem,
chainPem,
privateKeyPem
}: TImportCertDTO) => {
const collectionId = pkiCollectionId;
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` });
let projectId = project.id;
const certManagerProjectFromSplit = await projectDAL.getProjectFromSplitId(
projectId,
ProjectType.CertificateManager
);
if (certManagerProjectFromSplit) {
projectId = certManagerProjectFromSplit.id;
}
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.CertificateManager
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionCertificateActions.Create,
ProjectPermissionSub.Certificates
);
// Check PKI collection
if (collectionId) {
const pkiCollection = await pkiCollectionDAL.findById(collectionId);
if (!pkiCollection) throw new NotFoundError({ message: "PKI collection not found" });
if (pkiCollection.projectId !== projectId) throw new BadRequestError({ message: "Invalid PKI collection" });
}
const leafCert = new x509.X509Certificate(certificatePem);
// Verify the certificate chain
const chainCerts = splitPemChain(chainPem).map((pem) => new x509.X509Certificate(pem));
// Remove leaf cert from the chain if it's present
if (chainCerts[0].equal(leafCert)) {
chainCerts.splice(0, 1);
}
if (chainCerts.length === 0) {
throw new BadRequestError({
message: "Certificate chain must contain at least one issuer certificate"
});
}
// Verify leaf certificate is signed by the first certificate in the chain
const isLeafVerified = await leafCert.verify({ publicKey: chainCerts[0].publicKey }).catch(() => false);
if (!isLeafVerified) {
throw new BadRequestError({ message: "Leaf certificate verification against chain failed" });
}
// Verify the entire chain of trust
const verificationPromises = chainCerts.slice(0, -1).map(async (currentCert, index) => {
const issuerCert = chainCerts[index + 1];
return currentCert.verify({ publicKey: issuerCert.publicKey }).catch(() => false);
});
const verificationResults = await Promise.all(verificationPromises);
if (verificationResults.some((result) => !result)) {
throw new BadRequestError({
message: "Certificate chain verification failed: broken trust chain"
});
}
// Verify private key matches the certificate
let privateKey;
try {
privateKey = createPrivateKey(privateKeyPem);
} catch (err) {
throw new BadRequestError({ message: "Invalid private key format" });
}
try {
const message = Buffer.from(Buffer.alloc(32));
const publicKey = createPublicKey(certificatePem);
const signature = sign(null, message, privateKey);
const isValid = verify(null, message, publicKey, signature);
if (!isValid) {
throw new BadRequestError({ message: "Private key does not match certificate" });
}
} catch (err) {
if (err instanceof BadRequestError) {
throw err;
}
throw new BadRequestError({ message: "Error verifying private key against certificate" });
}
// Get certificate attributes
const commonName = Array.from(leafCert.subjectName.getField("CN")?.values() || [])[0] || "";
let altNames: undefined | string;
const sanExtension = leafCert.extensions.find((ext) => ext.type === "2.5.29.17");
if (sanExtension) {
const sanNames = new x509.GeneralNames(sanExtension.value);
altNames = sanNames.items.map((name) => name.value).join(", ");
}
const { serialNumber, notBefore, notAfter } = leafCert;
// Encrypt certificate for storage
const certificateManagerKeyId = await getProjectKmsCertificateKeyId({
projectId,
projectDAL,
kmsService
});
const kmsEncryptor = await kmsService.encryptWithKmsKey({
kmsId: certificateManagerKeyId
});
const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({
plainText: Buffer.from(certificatePem)
});
const { cipherTextBlob: encryptedPrivateKey } = await kmsEncryptor({
plainText: Buffer.from(privateKeyPem)
});
// Extract Key Usage
const keyUsagesExt = leafCert.getExtension("2.5.29.15") as x509.KeyUsagesExtension;
let keyUsages: CertKeyUsage[] = [];
if (keyUsagesExt) {
keyUsages = Object.values(CertKeyUsage).filter(
// eslint-disable-next-line no-bitwise
(keyUsage) => (x509.KeyUsageFlags[keyUsage] & keyUsagesExt.usages) !== 0
);
}
// Extract Extended Key Usage
const extKeyUsageExt = leafCert.getExtension("2.5.29.37") as x509.ExtendedKeyUsageExtension;
let extendedKeyUsages: CertExtendedKeyUsage[] = [];
if (extKeyUsageExt) {
extendedKeyUsages = extKeyUsageExt.usages.map((ekuOid) => CertExtendedKeyUsageOIDToName[ekuOid as string]);
}
const { cipherTextBlob: encryptedCertificateChain } = await kmsEncryptor({
plainText: Buffer.from(chainPem)
});
const cert = await certificateDAL.transaction(async (tx) => {
try {
const txCert = await certificateDAL.create(
{
status: CertStatus.ACTIVE,
friendlyName: friendlyName || commonName,
commonName,
altNames,
serialNumber,
notBefore,
notAfter,
projectId,
keyUsages,
extendedKeyUsages
},
tx
);
await certificateBodyDAL.create(
{
certId: txCert.id,
encryptedCertificate,
encryptedCertificateChain
},
tx
);
await certificateSecretDAL.create(
{
certId: txCert.id,
encryptedPrivateKey
},
tx
);
if (collectionId) {
await pkiCollectionItemDAL.create(
{
pkiCollectionId: collectionId,
certId: txCert.id
},
tx
);
}
return txCert;
} catch (error) {
// @ts-expect-error We're expecting a database error
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if (error?.error?.code === "23505") {
throw new BadRequestError({ message: "Certificate serial already exists in your project" });
}
throw error;
}
});
return {
certificate: certificatePem,
certificateChain: chainPem,
privateKey: privateKeyPem,
serialNumber,
cert
};
};
@@ -284,12 +535,11 @@ export const certificateServiceFactory = ({
*/
const getCertBundle = async ({ serialNumber, actorId, actorAuthMethod, actor, actorOrgId }: TGetCertBundleDTO) => {
const cert = await certificateDAL.findOne({ serialNumber });
const ca = await certificateAuthorityDAL.findById(cert.caId);
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId: ca.projectId,
projectId: cert.projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.CertificateManager
@@ -307,7 +557,7 @@ export const certificateServiceFactory = ({
const certBody = await certificateBodyDAL.findOne({ certId: cert.id });
const certificateManagerKeyId = await getProjectKmsCertificateKeyId({
projectId: ca.projectId,
projectId: cert.projectId,
projectDAL,
kmsService
});
@@ -322,27 +572,32 @@ export const certificateServiceFactory = ({
const certObj = new x509.X509Certificate(decryptedCert);
const certificate = certObj.toString("pem");
const { caCert, caCertChain } = await getCaCertChain({
caCertId: cert.caCertId,
certificateAuthorityDAL,
certificateAuthorityCertDAL,
projectDAL,
kmsService
});
let certificateChain = null;
const certificateChain = await buildCertificateChain({
caCert,
caCertChain,
kmsId: certificateManagerKeyId,
kmsService,
encryptedCertificateChain: certBody.encryptedCertificateChain || undefined
});
// On newer certs the certBody.encryptedCertificateChain column will always exist.
// Older certs will have a caCertId which will be used as a fallback mechanism for structuring the chain.
if (certBody.encryptedCertificateChain) {
const decryptedCertChain = await kmsDecryptor({
cipherTextBlob: certBody.encryptedCertificateChain
});
certificateChain = decryptedCertChain.toString();
} else if (cert.caCertId) {
const { caCert, caCertChain } = await getCaCertChain({
caCertId: cert.caCertId,
certificateAuthorityDAL,
certificateAuthorityCertDAL,
projectDAL,
kmsService
});
certificateChain = `${caCert}\n${caCertChain}`.trim();
}
let privateKey: string | null = null;
try {
const { certPrivateKey } = await getCertificateCredentials({
certId: cert.id,
projectId: ca.projectId,
projectId: cert.projectId,
certificateSecretDAL,
projectDAL,
kmsService
@@ -360,8 +615,7 @@ export const certificateServiceFactory = ({
certificateChain,
privateKey,
serialNumber,
cert,
ca
cert
};
};
@@ -371,6 +625,7 @@ export const certificateServiceFactory = ({
deleteCert,
revokeCert,
getCertBody,
importCert,
getCertBundle
};
};
@@ -78,6 +78,17 @@ export type TGetCertBodyDTO = {
serialNumber: string;
} & Omit<TProjectPermission, "projectId">;
export type TImportCertDTO = {
projectSlug: string;
friendlyName?: string;
pkiCollectionId?: string;
certificatePem: string;
privateKeyPem: string;
chainPem: string;
} & Omit<TProjectPermission, "projectId">;
export type TGetCertPrivateKeyDTO = {
serialNumber: string;
} & Omit<TProjectPermission, "projectId">;
@@ -93,11 +104,3 @@ export type TGetCertificateCredentialsDTO = {
projectDAL: Pick<TProjectDALFactory, "findOne" | "updateById" | "transaction">;
kmsService: Pick<TKmsServiceFactory, "decryptWithKmsKey" | "generateKmsKey">;
};
export type TBuildCertificateChainDTO = {
caCert?: string;
caCertChain?: string;
encryptedCertificateChain?: Buffer;
kmsService: Pick<TKmsServiceFactory, "decryptWithKmsKey">;
kmsId: string;
};
@@ -31,12 +31,13 @@ export const pkiAlertDALFactory = (db: TDbClient) => {
.select(
db.raw("? as type", [PkiItemType.CA]),
`${PkiItemType.CA}.id`,
`${PkiItemType.CA}.notAfter as expiryDate`,
`${PkiItemType.CA}.serialNumber`,
`${PkiItemType.CA}.friendlyName`,
"ic.notAfter as expiryDate",
"ic.serialNumber",
"ic.friendlyName",
"pci.pkiCollectionId"
)
.from(`${TableName.CertificateAuthority} as ${PkiItemType.CA}`)
.join(`${TableName.InternalCertificateAuthority} as ic`, `${PkiItemType.CA}.id`, "ic.caId")
.join(`${TableName.PkiCollectionItem} as pci`, `${PkiItemType.CA}.id`, "pci.caId")
.unionAll((qb) => {
void qb
@@ -27,13 +27,13 @@ export const pkiCollectionItemDALFactory = (db: TDbClient) => {
.select(
"pki_collection_items.*",
db.raw(
`COALESCE("${TableName.CertificateAuthority}"."notBefore", "${TableName.Certificate}"."notBefore") as "notBefore"`
`COALESCE("${TableName.InternalCertificateAuthority}"."notBefore", "${TableName.Certificate}"."notBefore") as "notBefore"`
),
db.raw(
`COALESCE("${TableName.CertificateAuthority}"."notAfter", "${TableName.Certificate}"."notAfter") as "notAfter"`
`COALESCE("${TableName.InternalCertificateAuthority}"."notAfter", "${TableName.Certificate}"."notAfter") as "notAfter"`
),
db.raw(
`COALESCE("${TableName.CertificateAuthority}"."friendlyName", "${TableName.Certificate}"."friendlyName") as "friendlyName"`
`COALESCE("${TableName.InternalCertificateAuthority}"."friendlyName", "${TableName.Certificate}"."friendlyName") as "friendlyName"`
)
)
.leftJoin(
@@ -41,6 +41,11 @@ export const pkiCollectionItemDALFactory = (db: TDbClient) => {
`${TableName.PkiCollectionItem}.caId`,
`${TableName.CertificateAuthority}.id`
)
.leftJoin(
TableName.InternalCertificateAuthority,
`${TableName.PkiCollectionItem}.caId`,
`${TableName.InternalCertificateAuthority}.caId`
)
.leftJoin(TableName.Certificate, `${TableName.PkiCollectionItem}.certId`, `${TableName.Certificate}.id`)
.where((builder) => {
void builder.where(`${TableName.PkiCollectionItem}.pkiCollectionId`, collectionId);
@@ -269,14 +269,8 @@ export const pkiCollectionServiceFactory = ({
});
if (isCertAdded) throw new BadRequestError({ message: "Certificate already part of the PKI collection" });
// validate that there exists a certificate in same project as PKI collection
const cas = await certificateAuthorityDAL.find({ projectId: pkiCollection.projectId });
// TODO: consider making this more efficient
const [certificate] = await certificateDAL.find({
$in: {
caId: cas.map((ca) => ca.id)
},
projectId: pkiCollection.projectId,
id: itemId
});
if (!certificate) throw new NotFoundError({ message: `Certificate with ID '${itemId}' not found` });
@@ -0,0 +1,187 @@
import { TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service";
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { logger } from "@app/lib/logger";
import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue";
import { ActorType } from "../auth/auth-type";
import { TCertificateDALFactory } from "../certificate/certificate-dal";
import { TCertificateAuthorityDALFactory } from "../certificate-authority/certificate-authority-dal";
import { CaStatus, CaType } from "../certificate-authority/certificate-authority-enums";
import { TCertificateAuthorityQueueFactory } from "../certificate-authority/certificate-authority-queue";
import { InternalCertificateAuthorityFns } from "../certificate-authority/internal/internal-certificate-authority-fns";
import { TPkiSubscriberDALFactory } from "./pki-subscriber-dal";
import { PkiSubscriberStatus, SubscriberOperationStatus } from "./pki-subscriber-types";
type TPkiSubscriberQueueServiceFactoryDep = {
queueService: TQueueServiceFactory;
pkiSubscriberDAL: TPkiSubscriberDALFactory;
certificateAuthorityDAL: TCertificateAuthorityDALFactory;
certificateAuthorityQueue: TCertificateAuthorityQueueFactory;
internalCaFns: ReturnType<typeof InternalCertificateAuthorityFns>;
certificateDAL: TCertificateDALFactory;
auditLogService: Pick<TAuditLogServiceFactory, "createAuditLog">;
};
export const pkiSubscriberQueueServiceFactory = ({
queueService,
pkiSubscriberDAL,
certificateAuthorityDAL,
certificateAuthorityQueue,
internalCaFns,
certificateDAL,
auditLogService
}: TPkiSubscriberQueueServiceFactoryDep) => {
queueService.start(QueueName.PkiSubscriber, async (job) => {
if (job.name === QueueJobs.PkiSubscriberDailyAutoRenewal) {
logger.info(`${QueueJobs.PkiSubscriberDailyAutoRenewal}: queue task started`);
const BATCH_SIZE = 100;
let offset = 0;
let hasMore = true;
while (hasMore) {
// fetch PKI subscribers with auto renewal enabled in batches
// eslint-disable-next-line no-await-in-loop
const pkiSubscribers = await pkiSubscriberDAL.find(
{
enableAutoRenewal: true,
$notNull: ["autoRenewalPeriodInDays"],
status: PkiSubscriberStatus.ACTIVE
},
{
limit: BATCH_SIZE,
offset
}
);
if (pkiSubscribers.length === 0) {
hasMore = false;
break;
}
// Process each subscriber in the batch concurrently
// eslint-disable-next-line no-await-in-loop
await Promise.all(
pkiSubscribers.map(async (subscriber) => {
try {
const cert = await certificateDAL.findLatestActiveCertForSubscriber({ subscriberId: subscriber.id });
let shouldRenew = false;
if (!cert || !cert.notAfter) {
shouldRenew = true;
} else {
const now = new Date();
const expiry = new Date(cert.notAfter);
const daysUntilExpiry = (expiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24);
shouldRenew = daysUntilExpiry <= subscriber.autoRenewalPeriodInDays!;
}
if (shouldRenew) {
// Get the CA for the subscriber
if (!subscriber.caId) {
await pkiSubscriberDAL.updateById(subscriber.id, {
lastOperationStatus: SubscriberOperationStatus.FAILED,
lastOperationMessage: "No CA assigned to subscriber",
lastOperationAt: new Date()
});
return;
}
const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(subscriber.caId);
if (!ca) {
await pkiSubscriberDAL.updateById(subscriber.id, {
lastOperationStatus: SubscriberOperationStatus.FAILED,
lastOperationMessage: "CA not found",
lastOperationAt: new Date()
});
return;
}
// Check if CA is active
if (ca.status !== CaStatus.ACTIVE) {
await pkiSubscriberDAL.updateById(subscriber.id, {
lastOperationStatus: SubscriberOperationStatus.FAILED,
lastOperationMessage: "CA is not active",
lastOperationAt: new Date()
});
return;
}
// Order new certificate based on CA type
if (ca.externalCa?.id && ca.externalCa.type === CaType.ACME) {
await certificateAuthorityQueue.orderCertificateForSubscriber({
subscriberId: subscriber.id,
caType: ca.externalCa.type
});
} else if (ca.internalCa?.id) {
// For internal CAs, we can issue certificates directly
await internalCaFns.issueCertificate(subscriber, ca);
}
// Update last auto-renew timestamp
await pkiSubscriberDAL.updateById(subscriber.id, {
lastAutoRenewAt: new Date(),
lastOperationStatus: SubscriberOperationStatus.SUCCESS,
lastOperationMessage: "Triggered certificate auto-renewal",
lastOperationAt: new Date()
});
await auditLogService.createAuditLog({
projectId: subscriber.projectId,
actor: {
type: ActorType.PLATFORM,
metadata: {}
},
event: {
type: EventType.AUTOMATED_RENEW_SUBSCRIBER_CERT,
metadata: {
subscriberId: subscriber.id,
name: subscriber.name
}
}
});
}
} catch (error) {
// Log error and update subscriber status
logger.error(error, `Failed to auto-renew certificate for subscriber ${subscriber.id}`);
await pkiSubscriberDAL.updateById(subscriber.id, {
lastOperationStatus: SubscriberOperationStatus.FAILED,
lastOperationMessage: error instanceof Error ? error.message : "Unknown error",
lastOperationAt: new Date()
});
}
})
);
offset += BATCH_SIZE;
}
logger.info(`${QueueJobs.PkiSubscriberDailyAutoRenewal}: queue task completed`);
}
});
// we do a repeat cron job in utc timezone at 12 Midnight each day
const startDailyAutoRenewalJob = async () => {
// clear previous job
await queueService.stopRepeatableJob(
QueueName.PkiSubscriber,
QueueJobs.PkiSubscriberDailyAutoRenewal,
{ pattern: "0 0 * * *", utc: true },
// { pattern: "*/30 * * * * *", utc: true } // for testing
QueueName.PkiSubscriber // just a job id
);
await queueService.queue(QueueName.PkiSubscriber, QueueJobs.PkiSubscriberDailyAutoRenewal, undefined, {
delay: 5000,
jobId: QueueName.PkiSubscriber,
// { pattern: "*/30 * * * * *", utc: true } // for testing
repeat: { pattern: "0 0 * * *", utc: true }
});
};
queueService.listen(QueueName.PkiSubscriber, "failed", (_, err) => {
logger.error(err, `${QueueName.PkiSubscriber}: failed`);
});
return {
startDailyAutoRenewalJob
};
};
@@ -1,3 +1,5 @@
import { z } from "zod";
import { PkiSubscribersSchema } from "@app/db/schemas";
export const sanitizedPkiSubscriber = PkiSubscribersSchema.pick({
@@ -10,5 +12,13 @@ export const sanitizedPkiSubscriber = PkiSubscribersSchema.pick({
subjectAlternativeNames: true,
ttl: true,
keyUsages: true,
extendedKeyUsages: true
extendedKeyUsages: true,
lastOperationStatus: true,
lastOperationMessage: true,
lastOperationAt: true,
enableAutoRenewal: true,
autoRenewalPeriodInDays: true,
lastAutoRenewAt: true
}).extend({
supportsImmediateCertIssuance: z.boolean().optional()
});
@@ -1,23 +1,20 @@
/* 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";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import {
ProjectPermissionCertificateActions,
ProjectPermissionPkiSubscriberActions,
ProjectPermissionSub
} from "@app/ee/services/permission/project-permission";
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,27 +24,34 @@ 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, CaType } from "@app/services/certificate-authority/certificate-authority-enums";
import {
createSerialNumber,
expandInternalCa,
getCaCertChain,
getCaCredentials,
keyAlgorithmToAlgCfg,
parseDistinguishedName
} from "@app/services/certificate-authority/certificate-authority-fns";
import { TCertificateAuthoritySecretDALFactory } from "@app/services/certificate-authority/certificate-authority-secret-dal";
import { CaStatus } from "@app/services/certificate-authority/certificate-authority-types";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal";
import { TProjectDALFactory } from "@app/services/project/project-dal";
import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns";
import { getCertificateCredentials } from "../certificate/certificate-fns";
import { TCertificateSecretDALFactory } from "../certificate/certificate-secret-dal";
import { TCertificateAuthorityQueueFactory } from "../certificate-authority/certificate-authority-queue";
import { InternalCertificateAuthorityFns } from "../certificate-authority/internal/internal-certificate-authority-fns";
import {
PkiSubscriberStatus,
TCreatePkiSubscriberDTO,
TDeletePkiSubscriberDTO,
TGetPkiSubscriberDTO,
TGetSubscriberActiveCertBundleDTO,
TIssuePkiSubscriberCertDTO,
TListPkiSubscriberCertsDTO,
TOrderPkiSubscriberCertDTO,
TSignPkiSubscriberCertDTO,
TUpdatePkiSubscriberDTO
} from "./pki-subscriber-types";
@@ -57,16 +61,24 @@ type TPkiSubscriberServiceFactoryDep = {
TPkiSubscriberDALFactory,
"create" | "findById" | "updateById" | "deleteById" | "transaction" | "find" | "findOne"
>;
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findById">;
certificateAuthorityDAL: Pick<
TCertificateAuthorityDALFactory,
"findByIdWithAssociatedCa" | "findById" | "transaction" | "create" | "updateById" | "findWithAssociatedCa"
>;
certificateAuthorityCertDAL: Pick<TCertificateAuthorityCertDALFactory, "findById">;
certificateAuthoritySecretDAL: Pick<TCertificateAuthoritySecretDALFactory, "findOne">;
certificateAuthorityQueue: Pick<TCertificateAuthorityQueueFactory, "orderCertificateForSubscriber">;
certificateAuthorityCrlDAL: Pick<TCertificateAuthorityCrlDALFactory, "findOne">;
certificateDAL: Pick<TCertificateDALFactory, "create" | "transaction" | "countCertificatesForPkiSubscriber" | "find">;
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "create">;
certificateSecretDAL: Pick<TCertificateSecretDALFactory, "create">;
certificateDAL: Pick<
TCertificateDALFactory,
"create" | "transaction" | "countCertificatesForPkiSubscriber" | "findLatestActiveCertForSubscriber" | "find"
>;
certificateSecretDAL: Pick<TCertificateSecretDALFactory, "create" | "findOne">;
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "create" | "findOne">;
projectDAL: Pick<TProjectDALFactory, "findOne" | "updateById" | "transaction" | "findById" | "find">;
kmsService: Pick<TKmsServiceFactory, "generateKmsKey" | "decryptWithKmsKey" | "encryptWithKmsKey">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
internalCaFns: ReturnType<typeof InternalCertificateAuthorityFns>;
};
export type TPkiSubscriberServiceFactory = ReturnType<typeof pkiSubscriberServiceFactory>;
@@ -78,11 +90,13 @@ export const pkiSubscriberServiceFactory = ({
certificateAuthoritySecretDAL,
certificateAuthorityCrlDAL,
certificateDAL,
certificateBodyDAL,
certificateSecretDAL,
certificateBodyDAL,
projectDAL,
kmsService,
permissionService
permissionService,
certificateAuthorityQueue,
internalCaFns
}: TPkiSubscriberServiceFactoryDep) => {
const createSubscriber = async ({
name,
@@ -93,6 +107,8 @@ export const pkiSubscriberServiceFactory = ({
subjectAlternativeNames,
keyUsages,
extendedKeyUsages,
enableAutoRenewal,
autoRenewalPeriodInDays,
projectId,
actorId,
actorAuthMethod,
@@ -115,6 +131,12 @@ export const pkiSubscriberServiceFactory = ({
})
);
if (enableAutoRenewal) {
if (!autoRenewalPeriodInDays) {
throw new BadRequestError({ message: "autoRenewalPeriodInDays is required when enableAutoRenewal is true" });
}
}
const newSubscriber = await pkiSubscriberDAL.create({
caId,
projectId,
@@ -124,7 +146,9 @@ export const pkiSubscriberServiceFactory = ({
ttl,
subjectAlternativeNames,
keyUsages,
extendedKeyUsages
extendedKeyUsages,
enableAutoRenewal,
autoRenewalPeriodInDays
});
return newSubscriber;
@@ -161,7 +185,18 @@ export const pkiSubscriberServiceFactory = ({
})
);
return subscriber;
let supportsImmediateCertIssuance = false;
if (subscriber.caId) {
const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(subscriber.caId);
if (ca.internalCa?.id) {
supportsImmediateCertIssuance = true;
}
}
return {
...subscriber,
supportsImmediateCertIssuance
};
};
const updateSubscriber = async ({
@@ -175,6 +210,8 @@ export const pkiSubscriberServiceFactory = ({
subjectAlternativeNames,
keyUsages,
extendedKeyUsages,
enableAutoRenewal,
autoRenewalPeriodInDays,
actorId,
actorAuthMethod,
actor,
@@ -202,6 +239,12 @@ export const pkiSubscriberServiceFactory = ({
})
);
if (enableAutoRenewal) {
if (!autoRenewalPeriodInDays && !subscriber.autoRenewalPeriodInDays) {
throw new BadRequestError({ message: "autoRenewalPeriodInDays is required when enableAutoRenewal is true" });
}
}
const updatedSubscriber = await pkiSubscriberDAL.updateById(subscriber.id, {
caId,
name,
@@ -210,7 +253,9 @@ export const pkiSubscriberServiceFactory = ({
ttl,
subjectAlternativeNames,
keyUsages,
extendedKeyUsages
extendedKeyUsages,
enableAutoRenewal,
autoRenewalPeriodInDays
});
return updatedSubscriber;
@@ -251,28 +296,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.findById(subscriber.caId);
if (!ca) 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
@@ -287,200 +330,69 @@ export const pkiSubscriberServiceFactory = ({
if (subscriber.status !== PkiSubscriberStatus.ACTIVE)
throw new BadRequestError({ message: "Subscriber is not active" });
if (ca.status !== CaStatus.ACTIVE) throw new BadRequestError({ message: "CA is not active" });
if (!ca.activeCaCertId) throw new BadRequestError({ message: "CA does not have a certificate installed" });
if (ca.requireTemplateForIssuance) {
throw new BadRequestError({ message: "Certificate template is required for issuance" });
}
const caCert = await certificateAuthorityCertDAL.findById(ca.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 of certificates" });
}
// check not after constraint
if (notAfterDate > caCertNotAfterDate) {
throw new BadRequestError({ message: "notAfter date is after CA certificate's notAfter date" });
if (ca.status !== CaStatus.ACTIVE) {
throw new BadRequestError({ message: "CA is disabled" });
}
const alg = keyAlgorithmToAlgCfg(ca.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) {
await certificateAuthorityQueue.orderCertificateForSubscriber({
subscriberId: subscriber.id,
caType: ca.externalCa.type
});
const altNamesExtension = new x509.SubjectAlternativeNameExtension(altNamesArray, false);
extensions.push(altNamesExtension);
return subscriber;
}
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
);
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 ({
@@ -500,8 +412,8 @@ export const pkiSubscriberServiceFactory = ({
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.findById(subscriber.caId);
if (!ca) throw new NotFoundError({ message: `CA with ID '${subscriber.caId}' not found` });
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,
@@ -522,11 +434,10 @@ export const pkiSubscriberServiceFactory = ({
if (subscriber.status !== PkiSubscriberStatus.ACTIVE)
throw new BadRequestError({ message: "Subscriber is not active" });
if (ca.status !== CaStatus.ACTIVE) throw new BadRequestError({ message: "CA is not active" });
if (!ca.activeCaCertId) throw new BadRequestError({ message: "CA does not have a certificate installed" });
if (ca.requireTemplateForIssuance) {
throw new BadRequestError({ message: "Certificate template is required for issuance" });
}
const caCert = await certificateAuthorityCertDAL.findById(ca.activeCaCertId);
if (!ca.internalCa?.activeCaCertId)
throw new BadRequestError({ message: "CA does not have a certificate installed" });
const caCert = await certificateAuthorityCertDAL.findById(ca.internalCa.activeCaCertId);
const certificateManagerKmsId = await getProjectKmsCertificateKeyId({
projectId: ca.projectId,
@@ -543,7 +454,7 @@ export const pkiSubscriberServiceFactory = ({
const caCertObj = new x509.X509Certificate(decryptedCaCert);
const notBeforeDate = new Date();
const notAfterDate = new Date(new Date().getTime() + ms(subscriber.ttl));
const notAfterDate = new Date(new Date().getTime() + ms(subscriber.ttl ?? "0"));
const caCertNotBeforeDate = new Date(caCertObj.notBefore);
const caCertNotAfterDate = new Date(caCertObj.notAfter);
@@ -557,7 +468,7 @@ export const pkiSubscriberServiceFactory = ({
throw new BadRequestError({ message: "notAfter date is after CA certificate's notAfter date" });
}
const alg = keyAlgorithmToAlgCfg(ca.keyAlgorithm as CertKeyAlgorithm);
const alg = keyAlgorithmToAlgCfg(ca.internalCa.keyAlgorithm as CertKeyAlgorithm);
const csrObj = new x509.Pkcs10CertificateRequest(csr);
@@ -691,7 +602,7 @@ export const pkiSubscriberServiceFactory = ({
});
const { caCert: issuingCaCertificate, caCertChain } = await getCaCertChain({
caCertId: ca.activeCaCertId,
caCertId: ca.internalCa.activeCaCertId,
certificateAuthorityDAL,
certificateAuthorityCertDAL,
projectDAL,
@@ -718,7 +629,8 @@ export const pkiSubscriberServiceFactory = ({
notBefore: notBeforeDate,
notAfter: notAfterDate,
keyUsages: selectedKeyUsages,
extendedKeyUsages: selectedExtendedKeyUsages
extendedKeyUsages: selectedExtendedKeyUsages,
projectId
},
tx
);
@@ -740,7 +652,7 @@ export const pkiSubscriberServiceFactory = ({
certificateChain: `${issuingCaCertificate}\n${caCertChain}`.trim(),
issuingCaCertificate,
serialNumber,
ca,
ca: expandInternalCa(ca),
commonName: subscriber.commonName,
subscriber
};
@@ -793,6 +705,114 @@ export const pkiSubscriberServiceFactory = ({
};
};
const getSubscriberActiveCertBundle = async ({
subscriberName,
projectId,
actorId,
actorAuthMethod,
actor,
actorOrgId
}: TGetSubscriberActiveCertBundleDTO) => {
const subscriber = await pkiSubscriberDAL.findOne({
name: subscriberName,
projectId
});
if (!subscriber) {
throw new NotFoundError({ message: `PKI subscriber named '${subscriberName}' not found` });
}
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId: subscriber.projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.CertificateManager
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionPkiSubscriberActions.ListCerts,
subject(ProjectPermissionSub.PkiSubscribers, {
name: subscriber.name
})
);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionCertificateActions.Read,
ProjectPermissionSub.Certificates
);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionCertificateActions.ReadPrivateKey,
ProjectPermissionSub.Certificates
);
const cert = await certificateDAL.findLatestActiveCertForSubscriber({
subscriberId: subscriber.id
});
if (!cert) {
throw new NotFoundError({ message: "No active certificate found for subscriber" });
}
const certBody = await certificateBodyDAL.findOne({ certId: cert.id });
const certificateManagerKeyId = await getProjectKmsCertificateKeyId({
projectId: cert.projectId,
projectDAL,
kmsService
});
const kmsDecryptor = await kmsService.decryptWithKmsKey({
kmsId: certificateManagerKeyId
});
const decryptedCert = await kmsDecryptor({
cipherTextBlob: certBody.encryptedCertificate
});
const certObj = new x509.X509Certificate(decryptedCert);
const certificate = certObj.toString("pem");
let certificateChain = null;
// On newer certs the certBody.encryptedCertificateChain column will always exist.
// Older certs will have a caCertId which will be used as a fallback mechanism for structuring the chain.
if (certBody.encryptedCertificateChain) {
const decryptedCertChain = await kmsDecryptor({
cipherTextBlob: certBody.encryptedCertificateChain
});
certificateChain = decryptedCertChain.toString();
} else if (cert.caCertId) {
const { caCert, caCertChain } = await getCaCertChain({
caCertId: cert.caCertId,
certificateAuthorityDAL,
certificateAuthorityCertDAL,
projectDAL,
kmsService
});
certificateChain = `${caCert}\n${caCertChain}`.trim();
}
const { certPrivateKey } = await getCertificateCredentials({
certId: cert.id,
projectId: cert.projectId,
certificateSecretDAL,
projectDAL,
kmsService
});
return {
certificate,
certificateChain,
privateKey: certPrivateKey,
serialNumber: cert.serialNumber,
cert,
subscriber
};
};
return {
createSubscriber,
getSubscriber,
@@ -800,6 +820,8 @@ export const pkiSubscriberServiceFactory = ({
deleteSubscriber,
issueSubscriberCert,
signSubscriberCert,
listSubscriberCerts
listSubscriberCerts,
orderSubscriberCert,
getSubscriberActiveCertBundle
};
};
@@ -12,10 +12,12 @@ export type TCreatePkiSubscriberDTO = {
name: string;
commonName: string;
status: PkiSubscriberStatus;
ttl: string;
ttl?: string;
subjectAlternativeNames: string[];
keyUsages: CertKeyUsage[];
extendedKeyUsages: CertExtendedKeyUsage[];
enableAutoRenewal?: boolean;
autoRenewalPeriodInDays?: number;
} & TProjectPermission;
export type TGetPkiSubscriberDTO = {
@@ -32,6 +34,8 @@ export type TUpdatePkiSubscriberDTO = {
subjectAlternativeNames?: string[];
keyUsages?: CertKeyUsage[];
extendedKeyUsages?: CertExtendedKeyUsage[];
enableAutoRenewal?: boolean;
autoRenewalPeriodInDays?: number;
} & TProjectPermission;
export type TDeletePkiSubscriberDTO = {
@@ -42,6 +46,10 @@ export type TIssuePkiSubscriberCertDTO = {
subscriberName: string;
} & TProjectPermission;
export type TOrderPkiSubscriberCertDTO = {
subscriberName: string;
} & TProjectPermission;
export type TSignPkiSubscriberCertDTO = {
subscriberName: string;
csr: string;
@@ -52,3 +60,12 @@ export type TListPkiSubscriberCertsDTO = {
offset: number;
limit: number;
} & TProjectPermission;
export type TGetSubscriberActiveCertBundleDTO = {
subscriberName: string;
} & TProjectPermission;
export enum SubscriberOperationStatus {
SUCCESS = "success",
FAILED = "failed"
}
+13 -12
View File
@@ -6,6 +6,7 @@ import {
ProjectMembershipRole,
ProjectType,
ProjectVersion,
TableName,
TProjectEnvironments
} from "@app/db/schemas";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
@@ -41,6 +42,7 @@ import { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subsc
import { ActorType } from "../auth/auth-type";
import { TCertificateDALFactory } from "../certificate/certificate-dal";
import { TCertificateAuthorityDALFactory } from "../certificate-authority/certificate-authority-dal";
import { expandInternalCa } from "../certificate-authority/certificate-authority-fns";
import { TCertificateTemplateDALFactory } from "../certificate-template/certificate-template-dal";
import { TGroupProjectDALFactory } from "../group-project/group-project-dal";
import { TIdentityOrgDALFactory } from "../identity/identity-org-dal";
@@ -149,7 +151,7 @@ type TProjectServiceFactoryDep = {
>;
projectUserMembershipRoleDAL: Pick<TProjectUserMembershipRoleDALFactory, "create">;
pkiSubscriberDAL: Pick<TPkiSubscriberDALFactory, "find">;
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "find">;
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "find" | "findWithAssociatedCa">;
certificateDAL: Pick<TCertificateDALFactory, "find" | "countCertificatesInProject">;
certificateTemplateDAL: Pick<TCertificateTemplateDALFactory, "getCertTemplatesByProjectId">;
pkiAlertDAL: Pick<TPkiAlertDALFactory, "find">;
@@ -914,17 +916,20 @@ export const projectServiceFactory = ({
ProjectPermissionSub.CertificateAuthorities
);
const cas = await certificateAuthorityDAL.find(
const cas = await certificateAuthorityDAL.findWithAssociatedCa(
{
projectId,
...(status && { status }),
...(friendlyName && { friendlyName }),
...(commonName && { commonName })
[`${TableName.CertificateAuthority}.projectId` as "projectId"]: projectId,
$notNull: [`${TableName.InternalCertificateAuthority}.id` as "id"],
...(status && { [`${TableName.CertificateAuthority}.status` as "status"]: status }),
...(friendlyName && {
[`${TableName.InternalCertificateAuthority}.friendlyName` as "friendlyName"]: friendlyName
}),
...(commonName && { [`${TableName.InternalCertificateAuthority}.commonName` as "commonName"]: commonName })
},
{ offset, limit, sort: [["updatedAt", "desc"]] }
);
return cas;
return cas.map((ca) => expandInternalCa(ca));
};
/**
@@ -965,13 +970,9 @@ export const projectServiceFactory = ({
ProjectPermissionSub.Certificates
);
const cas = await certificateAuthorityDAL.find({ projectId });
const certificates = await certificateDAL.find(
{
$in: {
caId: cas.map((ca) => ca.id)
},
projectId,
...(friendlyName && { friendlyName }),
...(commonName && { commonName })
},