feat: completed backend changes for new pki template

This commit is contained in:
=
2025-05-30 14:40:25 +05:30
committed by Akhil Mohan
parent a5bb80d2cf
commit 86862b932c
17 changed files with 1552 additions and 45 deletions

View File

@@ -83,6 +83,7 @@ import { TOrgAdminServiceFactory } from "@app/services/org-admin/org-admin-servi
import { TPkiAlertServiceFactory } from "@app/services/pki-alert/pki-alert-service";
import { TPkiCollectionServiceFactory } from "@app/services/pki-collection/pki-collection-service";
import { TPkiSubscriberServiceFactory } from "@app/services/pki-subscriber/pki-subscriber-service";
import { TPkiTemplatesServiceFactory } from "@app/services/pki-templates/pki-templates-service";
import { TProjectServiceFactory } from "@app/services/project/project-service";
import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service";
import { TProjectEnvServiceFactory } from "@app/services/project-env/project-env-service";
@@ -271,6 +272,7 @@ declare module "fastify" {
assumePrivileges: TAssumePrivilegeServiceFactory;
githubOrgSync: TGithubOrgSyncServiceFactory;
internalCertificateAuthority: TInternalCertificateAuthorityServiceFactory;
pkiTemplate: TPkiTemplatesServiceFactory;
};
// this is exclusive use for middlewares in which we need to inject data
// everywhere else access using service layer

View File

@@ -0,0 +1,24 @@
import slugify from "@sindresorhus/slugify";
import { Knex } from "knex";
import { alphaNumericNanoId } from "@app/lib/nanoid";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
const hasNameCol = await knex.schema.hasColumn(TableName.CertificateTemplate, "name");
if (hasNameCol) {
const templates = await knex(TableName.CertificateTemplate).select("id", "name");
await Promise.all(
templates.map((el) => {
const slugifiedName = el.name
? slugify(`${el.name.slice(0, 16)}-${alphaNumericNanoId(8)}`)
: slugify(alphaNumericNanoId(12));
return knex(TableName.CertificateTemplate).where({ id: el.id }).update({ name: slugifiedName });
})
);
}
}
export async function down(): Promise<void> {}

View File

@@ -15,6 +15,7 @@ import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/pe
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { getConfig } from "@app/lib/config/env";
import { BadRequestError, ForbiddenRequestError, NotFoundError, OidcAuthError } from "@app/lib/errors";
import { logger } from "@app/lib/logger";
import { OrgServiceActor } from "@app/lib/types";
import { ActorType, AuthMethod, AuthTokenType } from "@app/services/auth/auth-type";
import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service";

View File

@@ -10,6 +10,7 @@ import {
ProjectPermissionKmipActions,
ProjectPermissionMemberActions,
ProjectPermissionPkiSubscriberActions,
ProjectPermissionPkiTemplateActions,
ProjectPermissionSecretActions,
ProjectPermissionSecretRotationActions,
ProjectPermissionSecretSyncActions,
@@ -35,7 +36,6 @@ const buildAdminPermissionRules = () => {
ProjectPermissionSub.AuditLogs,
ProjectPermissionSub.IpAllowList,
ProjectPermissionSub.CertificateAuthorities,
ProjectPermissionSub.CertificateTemplates,
ProjectPermissionSub.PkiAlerts,
ProjectPermissionSub.PkiCollections,
ProjectPermissionSub.SshCertificateAuthorities,
@@ -56,10 +56,24 @@ const buildAdminPermissionRules = () => {
can(
[
ProjectPermissionActions.Read,
ProjectPermissionActions.Edit,
ProjectPermissionActions.Create,
ProjectPermissionActions.Delete
ProjectPermissionPkiTemplateActions.Read,
ProjectPermissionPkiTemplateActions.Edit,
ProjectPermissionPkiTemplateActions.Create,
ProjectPermissionPkiTemplateActions.Delete,
ProjectPermissionPkiTemplateActions.IssueCert,
ProjectPermissionPkiTemplateActions.ListCerts
],
ProjectPermissionSub.CertificateTemplates
);
can(
[
ProjectPermissionApprovalActions.Read,
ProjectPermissionApprovalActions.Edit,
ProjectPermissionApprovalActions.Create,
ProjectPermissionApprovalActions.Delete,
ProjectPermissionApprovalActions.AllowChangeBypass,
ProjectPermissionApprovalActions.AllowAccessBypass
],
ProjectPermissionSub.SecretApproval
);
@@ -348,7 +362,7 @@ const buildMemberPermissionRules = () => {
ProjectPermissionSub.Certificates
);
can([ProjectPermissionActions.Read], ProjectPermissionSub.CertificateTemplates);
can([ProjectPermissionPkiTemplateActions.Read], ProjectPermissionSub.CertificateTemplates);
can([ProjectPermissionActions.Read], ProjectPermissionSub.PkiAlerts);
can([ProjectPermissionActions.Read], ProjectPermissionSub.PkiCollections);
@@ -417,6 +431,7 @@ const buildViewerPermissionRules = () => {
can(ProjectPermissionActions.Read, ProjectPermissionSub.IpAllowList);
can(ProjectPermissionActions.Read, ProjectPermissionSub.CertificateAuthorities);
can(ProjectPermissionCertificateActions.Read, ProjectPermissionSub.Certificates);
can(ProjectPermissionPkiTemplateActions.Read, ProjectPermissionSub.CertificateTemplates);
can(ProjectPermissionCmekActions.Read, ProjectPermissionSub.Cmek);
can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificates);
can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificateTemplates);

View File

@@ -87,6 +87,15 @@ export enum ProjectPermissionSshHostActions {
IssueHostCert = "issue-host-cert"
}
export enum ProjectPermissionPkiTemplateActions {
Read = "read",
Create = "create",
Edit = "edit",
Delete = "delete",
IssueCert = "issue-cert",
ListCerts = "list-certs"
}
export enum ProjectPermissionPkiSubscriberActions {
Read = "read",
Create = "create",
@@ -200,6 +209,11 @@ export type SshHostSubjectFields = {
hostname: string;
};
export type PkiTemplateSubjectFields = {
name: string;
// (dangtony98): consider adding [commonName] as a subject field in the future
};
export type PkiSubscriberSubjectFields = {
name: string;
// (dangtony98): consider adding [commonName] as a subject field in the future
@@ -256,7 +270,13 @@ export type ProjectPermissionSet =
]
| [ProjectPermissionActions, ProjectPermissionSub.CertificateAuthorities]
| [ProjectPermissionCertificateActions, ProjectPermissionSub.Certificates]
| [ProjectPermissionActions, ProjectPermissionSub.CertificateTemplates]
| [
ProjectPermissionPkiTemplateActions,
(
| ProjectPermissionSub.CertificateTemplates
| (ForcedSubject<ProjectPermissionSub.CertificateTemplates> & PkiTemplateSubjectFields)
)
]
| [ProjectPermissionActions, ProjectPermissionSub.SshCertificateAuthorities]
| [ProjectPermissionActions, ProjectPermissionSub.SshCertificates]
| [ProjectPermissionActions, ProjectPermissionSub.SshCertificateTemplates]
@@ -436,6 +456,21 @@ const PkiSubscriberConditionSchema = z
})
.partial();
const PkiTemplateConditionSchema = z
.object({
name: z.union([
z.string(),
z
.object({
[PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ],
[PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB],
[PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN]
})
.partial()
])
})
.partial();
const GeneralPermissionSchema = [
z.object({
subject: z.literal(ProjectPermissionSub.SecretApproval).describe("The entity this permission pertains to."),
@@ -527,12 +562,6 @@ const GeneralPermissionSchema = [
"Describe what action an entity can take."
)
}),
z.object({
subject: z.literal(ProjectPermissionSub.CertificateTemplates).describe("The entity this permission pertains to."),
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe(
"Describe what action an entity can take."
)
}),
z.object({
subject: z
.literal(ProjectPermissionSub.SshCertificateAuthorities)
@@ -710,6 +739,16 @@ export const ProjectPermissionV2Schema = z.discriminatedUnion("subject", [
"When specified, only matching conditions will be allowed to access given resource."
).optional()
}),
z.object({
subject: z.literal(ProjectPermissionSub.CertificateTemplates).describe("The entity this permission pertains to."),
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionPkiTemplateActions).describe(
"Describe what action an entity can take."
),
inverted: z.boolean().optional().describe("Whether rule allows or forbids."),
conditions: PkiTemplateConditionSchema.describe(
"When specified, only matching conditions will be allowed to access given resource."
).optional()
}),
z.object({
subject: z.literal(ProjectPermissionSub.SecretRotation).describe("The entity this permission pertains to."),
inverted: z.boolean().optional().describe("Whether rule allows or forbids."),
@@ -720,6 +759,7 @@ export const ProjectPermissionV2Schema = z.discriminatedUnion("subject", [
"When specified, only matching conditions will be allowed to access given resource."
).optional()
}),
...GeneralPermissionSchema
]);

View File

@@ -211,6 +211,8 @@ import { pkiCollectionServiceFactory } from "@app/services/pki-collection/pki-co
import { pkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal";
import { pkiSubscriberQueueServiceFactory } from "@app/services/pki-subscriber/pki-subscriber-queue";
import { pkiSubscriberServiceFactory } from "@app/services/pki-subscriber/pki-subscriber-service";
import { pkiTemplatesDALFactory } from "@app/services/pki-templates/pki-templates-dal";
import { pkiTemplatesServiceFactory } from "@app/services/pki-templates/pki-templates-service";
import { projectDALFactory } from "@app/services/project/project-dal";
import { projectQueueFactory } from "@app/services/project/project-queue";
import { projectServiceFactory } from "@app/services/project/project-service";
@@ -847,6 +849,7 @@ export const registerRoutes = async (
const pkiCollectionDAL = pkiCollectionDALFactory(db);
const pkiCollectionItemDAL = pkiCollectionItemDALFactory(db);
const pkiSubscriberDAL = pkiSubscriberDALFactory(db);
const pkiTemplatesDAL = pkiTemplatesDALFactory(db);
const certificateService = certificateServiceFactory({
certificateDAL,
@@ -1754,6 +1757,21 @@ export const registerRoutes = async (
internalCaFns
});
const pkiTemplateService = pkiTemplatesServiceFactory({
pkiTemplatesDAL,
certificateAuthorityDAL,
certificateAuthorityCertDAL,
certificateAuthoritySecretDAL,
certificateAuthorityCrlDAL,
certificateDAL,
certificateBodyDAL,
certificateSecretDAL,
projectDAL,
kmsService,
permissionService,
internalCaFns
});
await secretRotationV2QueueServiceFactory({
secretRotationV2Service,
secretRotationV2DAL,
@@ -1847,6 +1865,7 @@ export const registerRoutes = async (
pkiAlert: pkiAlertService,
pkiCollection: pkiCollectionService,
pkiSubscriber: pkiSubscriberService,
pkiTemplate: pkiTemplateService,
secretScanning: secretScanningService,
license: licenseService,
trustedIp: trustedIpService,

View File

@@ -5,6 +5,7 @@ import { registerIdentityProjectRouter } from "./identity-project-router";
import { registerMfaRouter } from "./mfa-router";
import { registerOrgRouter } from "./organization-router";
import { registerPasswordRouter } from "./password-router";
import { registerPkiTemplatesRouter } from "./pki-templates-router";
import { registerProjectMembershipRouter } from "./project-membership-router";
import { registerProjectRouter } from "./project-router";
import { registerServiceTokenRouter } from "./service-token-router";
@@ -15,7 +16,15 @@ export const registerV2Routes = async (server: FastifyZodProvider) => {
await server.register(registerUserRouter, { prefix: "/users" });
await server.register(registerServiceTokenRouter, { prefix: "/service-token" });
await server.register(registerPasswordRouter, { prefix: "/password" });
await server.register(registerCaRouter, { prefix: "/pki/ca" });
await server.register(
async (pkiRouter) => {
await pkiRouter.register(registerCaRouter, { prefix: "/ca" });
await pkiRouter.register(registerPkiTemplatesRouter, { prefix: "/certificate-templates" });
},
{ prefix: "/pki" }
);
await server.register(
async (orgRouter) => {
await orgRouter.register(registerOrgRouter);

View File

@@ -0,0 +1,309 @@
import { z } from "zod";
import { CertificateTemplatesSchema } from "@app/db/schemas";
import { ApiDocsTags } from "@app/lib/api-docs";
import { ms } from "@app/lib/ms";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { slugSchema } from "@app/server/lib/schemas";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
import { CertExtendedKeyUsage, CertKeyUsage } from "@app/services/certificate/certificate-types";
import {
validateAltNamesField,
validateCaDateField
} from "@app/services/certificate-authority/certificate-authority-validators";
import { validateTemplateRegexField } from "@app/services/certificate-template/certificate-template-validators";
export const registerPkiTemplatesRouter = async (server: FastifyZodProvider) => {
server.route({
method: "POST",
url: "/",
config: {
rateLimit: writeLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiCertificateTemplates],
body: z.object({
name: slugSchema(),
caId: z.string(),
projectId: z.string(),
commonName: validateTemplateRegexField,
subjectAlternativeName: validateTemplateRegexField,
ttl: z.string().refine((val) => ms(val) > 0, "TTL must be a positive number"),
keyUsages: z
.nativeEnum(CertKeyUsage)
.array()
.optional()
.default([CertKeyUsage.DIGITAL_SIGNATURE, CertKeyUsage.KEY_ENCIPHERMENT]),
extendedKeyUsages: z.nativeEnum(CertExtendedKeyUsage).array().optional().default([])
}),
response: {
200: z.object({
certificateTemplate: CertificateTemplatesSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const certificateTemplate = await server.services.pkiTemplate.createTemplate({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
...req.body
});
return { certificateTemplate };
}
});
server.route({
method: "PATCH",
url: "/:templateName",
config: {
rateLimit: writeLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiCertificateTemplates],
params: z.object({
templateName: slugSchema()
}),
body: z.object({
name: slugSchema().optional(),
caId: z.string(),
projectId: z.string(),
commonName: validateTemplateRegexField.optional(),
subjectAlternativeName: validateTemplateRegexField.optional(),
ttl: z
.string()
.refine((val) => ms(val) > 0, "TTL must be a positive number")
.optional(),
keyUsages: z
.nativeEnum(CertKeyUsage)
.array()
.optional()
.default([CertKeyUsage.DIGITAL_SIGNATURE, CertKeyUsage.KEY_ENCIPHERMENT]),
extendedKeyUsages: z.nativeEnum(CertExtendedKeyUsage).array().optional().default([])
}),
response: {
200: z.object({
certificateTemplate: CertificateTemplatesSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const certificateTemplate = await server.services.pkiTemplate.updateTemplate({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
templateName: req.params.templateName,
...req.body
});
return { certificateTemplate };
}
});
server.route({
method: "DELETE",
url: "/:templateName",
config: {
rateLimit: writeLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiCertificateTemplates],
params: z.object({
templateName: z.string().min(1)
}),
body: z.object({
projectId: z.string()
}),
response: {
200: z.object({
certificateTemplate: CertificateTemplatesSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const certificateTemplate = await server.services.pkiTemplate.deleteTemplate({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
templateName: req.params.templateName,
projectId: req.body.projectId
});
return { certificateTemplate };
}
});
server.route({
method: "GET",
url: "/:templateName",
config: {
rateLimit: readLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiCertificateTemplates],
params: z.object({
templateName: slugSchema()
}),
querystring: z.object({
projectId: z.string()
}),
response: {
200: z.object({
certificateTemplate: CertificateTemplatesSchema.extend({
ca: z.object({ id: z.string(), name: z.string() })
})
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const certificateTemplate = await server.services.pkiTemplate.getTemplateByName({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
templateName: req.params.templateName,
projectId: req.query.projectId
});
return { certificateTemplate };
}
});
server.route({
method: "GET",
url: "/",
config: {
rateLimit: readLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiCertificateTemplates],
querystring: z.object({
projectId: z.string(),
limit: z.coerce.number().default(100),
offset: z.coerce.number().default(0)
}),
response: {
200: z.object({
certificateTemplates: CertificateTemplatesSchema.extend({
ca: z.object({ id: z.string(), name: z.string() })
}).array(),
totalCount: z.number()
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const { certificateTemplates, totalCount } = await server.services.pkiTemplate.listTemplate({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
...req.query
});
return { certificateTemplates, totalCount };
}
});
server.route({
method: "POST",
url: "/:templateName/issue-certificate",
config: {
rateLimit: writeLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiCertificateTemplates],
params: z.object({
templateName: slugSchema()
}),
body: z.object({
projectId: z.string(),
commonName: validateTemplateRegexField,
ttl: z.string().refine((val) => ms(val) > 0, "TTL must be a positive number"),
keyUsages: z.nativeEnum(CertKeyUsage).array().optional(),
extendedKeyUsages: z.nativeEnum(CertExtendedKeyUsage).array().optional(),
notBefore: validateCaDateField.optional(),
notAfter: validateCaDateField.optional(),
altNames: validateAltNamesField
}),
response: {
200: z.object({
certificate: z.string().trim(),
issuingCaCertificate: z.string().trim(),
certificateChain: z.string().trim(),
privateKey: z.string().trim(),
serialNumber: z.string().trim()
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const data = await server.services.pkiTemplate.issueCertificate({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
templateName: req.params.templateName,
...req.body
});
return data;
}
});
server.route({
method: "POST",
url: "/:templateName/sign-certificate",
config: {
rateLimit: writeLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiCertificateTemplates],
params: z.object({
templateName: slugSchema()
}),
body: z.object({
projectId: z.string(),
ttl: z.string().refine((val) => ms(val) > 0, "TTL must be a positive number"),
csr: z.string().trim().min(1)
}),
response: {
200: z.object({
certificate: z.string().trim(),
issuingCaCertificate: z.string().trim(),
certificateChain: z.string().trim(),
serialNumber: z.string().trim()
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const data = await server.services.pkiTemplate.signCertificate({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
templateName: req.params.templateName,
...req.body
});
return data;
}
});
};

View File

@@ -1,8 +1,10 @@
/* eslint-disable no-bitwise */
import * as x509 from "@peculiar/x509";
import { KeyObject } from "crypto";
import RE2 from "re2";
import { z } from "zod";
import { TPkiSubscribers } from "@app/db/schemas";
import { TCertificateTemplates, 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";
@@ -31,6 +33,7 @@ import {
keyAlgorithmToAlgCfg
} from "../certificate-authority-fns";
import { TCertificateAuthoritySecretDALFactory } from "../certificate-authority-secret-dal";
import { TIssueCertWithTemplateDTO } from "./internal-certificate-authority-types";
type TInternalCertificateAuthorityFnsDeps = {
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findByIdWithAssociatedCa" | "findById">;
@@ -257,7 +260,274 @@ export const InternalCertificateAuthorityFns = ({
};
};
const issueCertificateWithTemplate = async (
ca: Awaited<ReturnType<TCertificateAuthorityDALFactory["findByIdWithAssociatedCa"]>>,
certificateTemplate: TCertificateTemplates,
{ altNames, commonName, ttl, extendedKeyUsages, keyUsages, notAfter, notBefore }: TIssueCertWithTemplateDTO
) => {
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 = notBefore ? new Date(notBefore) : new Date();
let notAfterDate = new Date(new Date().setFullYear(new Date().getFullYear() + 1));
if (notAfter) {
notAfterDate = new Date(notAfter);
} else if (ttl) {
notAfterDate = new Date(new Date().getTime() + ms(ttl));
}
const caCertNotBeforeDate = new Date(caCertObj.notBefore);
const caCertNotAfterDate = new Date(caCertObj.notAfter);
// check not before constraint
if (notBeforeDate < caCertNotBeforeDate) {
throw new BadRequestError({ message: "notBefore date is before CA certificate's notBefore date" });
}
// check not after constraint
if (notAfterDate > caCertNotAfterDate) {
throw new BadRequestError({ message: "notAfter date is after CA certificate's notAfter date" });
}
const commonNameRegex = new RE2(certificateTemplate.commonName);
if (!commonNameRegex.test(commonName)) {
throw new BadRequestError({
message: "Invalid common name based on template policy"
});
}
if (notAfterDate.getTime() - notBeforeDate.getTime() > ms(certificateTemplate.ttl)) {
throw new BadRequestError({
message: "Invalid validity date based on template policy"
});
}
const subjectAlternativeNameRegex = new RE2(certificateTemplate.subjectAlternativeName);
altNames.split(",").forEach((altName) => {
if (!subjectAlternativeNameRegex.test(altName)) {
throw new BadRequestError({
message: "Invalid subject alternative name based on template policy"
});
}
});
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=${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
];
let selectedKeyUsages: CertKeyUsage[] = keyUsages ?? [];
if (keyUsages === undefined && !certificateTemplate) {
selectedKeyUsages = [CertKeyUsage.DIGITAL_SIGNATURE, CertKeyUsage.KEY_ENCIPHERMENT];
}
if (keyUsages === undefined && certificateTemplate) {
selectedKeyUsages = (certificateTemplate.keyUsages ?? []) as CertKeyUsage[];
}
if (keyUsages?.length && certificateTemplate) {
const validKeyUsages = certificateTemplate.keyUsages || [];
if (keyUsages.some((keyUsage) => !validKeyUsages.includes(keyUsage))) {
throw new BadRequestError({
message: "Invalid key usage value based on template policy"
});
}
selectedKeyUsages = keyUsages;
}
const keyUsagesBitValue = selectedKeyUsages.reduce((accum, keyUsage) => accum | x509.KeyUsageFlags[keyUsage], 0);
if (keyUsagesBitValue) {
extensions.push(new x509.KeyUsagesExtension(keyUsagesBitValue, true));
}
// handle extended key usages
let selectedExtendedKeyUsages: CertExtendedKeyUsage[] = extendedKeyUsages ?? [];
if (extendedKeyUsages === undefined && certificateTemplate) {
selectedExtendedKeyUsages = (certificateTemplate.extendedKeyUsages ?? []) as CertExtendedKeyUsage[];
}
if (extendedKeyUsages?.length && certificateTemplate) {
const validExtendedKeyUsages = certificateTemplate.extendedKeyUsages || [];
if (extendedKeyUsages.some((eku) => !validExtendedKeyUsages.includes(eku))) {
throw new BadRequestError({
message: "Invalid extended key usage value based on template policy"
});
}
selectedExtendedKeyUsages = extendedKeyUsages;
}
if (selectedExtendedKeyUsages.length) {
extensions.push(
new x509.ExtendedKeyUsageExtension(
selectedExtendedKeyUsages.map((eku) => x509.ExtendedKeyUsage[eku]),
true
)
);
}
let altNamesArray: { type: "email" | "dns"; value: string }[] = [];
if (altNames) {
altNamesArray = altNames.split(",").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,
status: CertStatus.ACTIVE,
friendlyName: commonName,
commonName,
altNames,
serialNumber,
notBefore: notBeforeDate,
notAfter: notAfterDate,
keyUsages: selectedKeyUsages,
extendedKeyUsages: extendedKeyUsages as CertExtendedKeyUsage[],
projectId: ca.projectId,
certificateTemplateId: certificateTemplate.id
},
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,
template: certificateTemplate
};
};
return {
issueCertificate
issueCertificate,
issueCertificateWithTemplate
};
};

View File

@@ -1,5 +1,5 @@
/* eslint-disable no-bitwise */
import { ForbiddenError } from "@casl/ability";
import { ForbiddenError, subject } from "@casl/ability";
import * as x509 from "@peculiar/x509";
import slugify from "@sindresorhus/slugify";
import crypto, { KeyObject } from "crypto";
@@ -16,6 +16,7 @@ import { TPermissionServiceFactory } from "@app/ee/services/permission/permissio
import {
ProjectPermissionActions,
ProjectPermissionCertificateActions,
ProjectPermissionPkiTemplateActions,
ProjectPermissionSub
} from "@app/ee/services/permission/project-permission";
import { extractX509CertFromChain } from "@app/lib/certificates/extract-certificate";
@@ -1952,15 +1953,15 @@ export const internalCertificateAuthorityServiceFactory = ({
actionProjectType: ActionProjectType.CertificateManager
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read,
ProjectPermissionSub.CertificateTemplates
);
const certificateTemplates = await certificateTemplateDAL.find({ caId });
return {
certificateTemplates,
certificateTemplates: certificateTemplates.filter((el) =>
permission.can(
ProjectPermissionPkiTemplateActions.Read,
subject(ProjectPermissionSub.CertificateTemplates, { name: el.name })
)
),
ca: expandInternalCa(ca)
};
};

View File

@@ -221,3 +221,13 @@ export type TOrderCertificateForSubscriberDTO = {
subscriberId: string;
caType: CaType;
};
export type TIssueCertWithTemplateDTO = {
commonName: string;
altNames: string;
ttl: string;
notBefore?: string;
notAfter?: string;
keyUsages?: CertKeyUsage[];
extendedKeyUsages?: CertExtendedKeyUsage[];
};

View File

@@ -18,3 +18,20 @@ export const sanitizedCertificateTemplate = CertificateTemplatesSchema.pick({
caName: z.string()
})
);
export const sanitizedCertificateTemplateV2 = CertificateTemplatesSchema.pick({
id: true,
caId: true,
name: true,
commonName: true,
subjectAlternativeName: true,
pkiCollectionId: true,
ttl: true,
keyUsages: true,
extendedKeyUsages: true
}).merge(
z.object({
projectId: z.string(),
caName: z.string()
})
);

View File

@@ -1,11 +1,14 @@
import { ForbiddenError } from "@casl/ability";
import { ForbiddenError, subject } from "@casl/ability";
import * as x509 from "@peculiar/x509";
import bcrypt from "bcrypt";
import { ActionProjectType, TCertificateTemplateEstConfigsUpdate } from "@app/db/schemas";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
import {
ProjectPermissionPkiTemplateActions,
ProjectPermissionSub
} from "@app/ee/services/permission/project-permission";
import { extractX509CertFromChain } from "@app/lib/certificates/extract-certificate";
import { getConfig } from "@app/lib/config/env";
import { BadRequestError, NotFoundError } from "@app/lib/errors";
@@ -78,8 +81,8 @@ export const certificateTemplateServiceFactory = ({
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Create,
ProjectPermissionSub.CertificateTemplates
ProjectPermissionPkiTemplateActions.Create,
subject(ProjectPermissionSub.CertificateTemplates, { name })
);
return certificateTemplateDAL.transaction(async (tx) => {
@@ -140,8 +143,8 @@ export const certificateTemplateServiceFactory = ({
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Edit,
ProjectPermissionSub.CertificateTemplates
ProjectPermissionPkiTemplateActions.Edit,
subject(ProjectPermissionSub.CertificateTemplates, { name: certTemplate.name })
);
if (caId) {
@@ -153,6 +156,13 @@ export const certificateTemplateServiceFactory = ({
}
}
if (name) {
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionPkiTemplateActions.Create,
subject(ProjectPermissionSub.CertificateTemplates, { name })
);
}
return certificateTemplateDAL.transaction(async (tx) => {
await certificateTemplateDAL.updateById(
certTemplate.id,
@@ -198,8 +208,8 @@ export const certificateTemplateServiceFactory = ({
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Delete,
ProjectPermissionSub.CertificateTemplates
ProjectPermissionPkiTemplateActions.Delete,
subject(ProjectPermissionSub.CertificateTemplates, { name: certTemplate.name })
);
await certificateTemplateDAL.deleteById(certTemplate.id);
@@ -225,8 +235,8 @@ export const certificateTemplateServiceFactory = ({
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read,
ProjectPermissionSub.CertificateTemplates
ProjectPermissionPkiTemplateActions.Read,
subject(ProjectPermissionSub.CertificateTemplates, { name: certTemplate.name })
);
return certTemplate;
@@ -267,8 +277,8 @@ export const certificateTemplateServiceFactory = ({
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Edit,
ProjectPermissionSub.CertificateTemplates
ProjectPermissionPkiTemplateActions.Edit,
subject(ProjectPermissionSub.CertificateTemplates, { name: certTemplate.name })
);
const appCfg = getConfig();
@@ -350,8 +360,8 @@ export const certificateTemplateServiceFactory = ({
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Edit,
ProjectPermissionSub.CertificateTemplates
ProjectPermissionPkiTemplateActions.Edit,
subject(ProjectPermissionSub.CertificateTemplates, { name: certTemplate.name })
);
const originalCaEstConfig = await certificateTemplateEstConfigDAL.findOne({
@@ -430,8 +440,8 @@ export const certificateTemplateServiceFactory = ({
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Edit,
ProjectPermissionSub.CertificateTemplates
ProjectPermissionPkiTemplateActions.Edit,
subject(ProjectPermissionSub.CertificateTemplates, { name: certTemplate.name })
);
}

View File

@@ -0,0 +1,102 @@
import { Knex } from "knex";
import { Tables } from "knex/types/tables";
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { buildFindFilter, ormify, selectAllTableCols, TFindFilter, TFindOpt, TFindReturn } from "@app/lib/knex";
export type TPkiTemplatesDALFactory = ReturnType<typeof pkiTemplatesDALFactory>;
export const pkiTemplatesDALFactory = (db: TDbClient) => {
const orm = ormify(db, TableName.CertificateTemplate);
const findOne = async (
filter: Partial<Tables[TableName.CertificateTemplate]["base"] & { projectId: string }>,
tx?: Knex
) => {
try {
const { projectId, ...templateFilters } = filter;
const res = await (tx || db.replicaNode())(TableName.CertificateTemplate)
.join(
TableName.CertificateAuthority,
`${TableName.CertificateAuthority}.id`,
`${TableName.CertificateTemplate}.caId`
)
// eslint-disable-next-line @typescript-eslint/no-misused-promises
.where(buildFindFilter(templateFilters, TableName.CertificateTemplate))
.where((qb) => {
if (projectId) {
// eslint-disable-next-line @typescript-eslint/no-misused-promises
void qb.where(buildFindFilter({ projectId }, TableName.CertificateAuthority));
}
})
.select(selectAllTableCols(TableName.CertificateTemplate))
.select(db.ref("name").withSchema(TableName.CertificateAuthority).as("caName"))
.select(db.ref("projectId").withSchema(TableName.CertificateAuthority))
.first();
if (!res) return undefined;
return { ...res, ca: { id: res.caId, name: res.caName } };
} catch (error) {
throw new DatabaseError({ error, name: "Find one" });
}
};
const find = async <
TCount extends boolean = false,
TCountDistinct extends keyof Tables[TableName.CertificateTemplate]["base"] | undefined = undefined
>(
filter: TFindFilter<Tables[TableName.CertificateTemplate]["base"]> & { projectId: string },
{
offset,
limit,
sort,
count,
tx,
countDistinct
}: TFindOpt<Tables[TableName.CertificateTemplate]["base"], TCount, TCountDistinct> = {}
) => {
try {
const { projectId, ...templateFilters } = filter;
const query = (tx || db.replicaNode())(TableName.CertificateTemplate)
.join(
TableName.CertificateAuthority,
`${TableName.CertificateAuthority}.id`,
`${TableName.CertificateTemplate}.caId`
)
// eslint-disable-next-line @typescript-eslint/no-misused-promises
.where(buildFindFilter(templateFilters, TableName.CertificateTemplate))
.where((qb) => {
if (projectId) {
// eslint-disable-next-line @typescript-eslint/no-misused-promises
void qb.where(buildFindFilter({ projectId }, TableName.CertificateAuthority));
}
})
.select(selectAllTableCols(TableName.CertificateTemplate))
.select(db.ref("projectId").withSchema(TableName.CertificateAuthority))
.select(db.ref("name").withSchema(TableName.CertificateAuthority).as("caName"));
if (countDistinct) {
void query.countDistinct(countDistinct);
} else if (count) {
void query.select(db.raw("COUNT(*) OVER() AS count"));
}
if (limit) void query.limit(limit);
if (offset) void query.offset(offset);
if (sort) {
void query.orderBy(sort.map(([column, order, nulls]) => ({ column: column as string, order, nulls })));
}
const res = (await query) as TFindReturn<typeof query, TCountDistinct extends undefined ? TCount : true>;
return res.map((el) => ({ ...el, ca: { id: el.caId, name: el.caName } }));
} catch (error) {
throw new DatabaseError({ error, name: "Find one" });
}
};
return { ...orm, find, findOne };
};

View File

@@ -0,0 +1,624 @@
/* eslint-disable no-bitwise */
import { ForbiddenError, subject } from "@casl/ability";
import * as x509 from "@peculiar/x509";
import RE2 from "re2";
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 {
ProjectPermissionPkiTemplateActions,
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 { TCertificateBodyDALFactory } from "../certificate/certificate-body-dal";
import { TCertificateDALFactory } from "../certificate/certificate-dal";
import { TCertificateSecretDALFactory } from "../certificate/certificate-secret-dal";
import {
CertExtendedKeyUsage,
CertExtendedKeyUsageOIDToName,
CertKeyAlgorithm,
CertKeyUsage,
CertStatus
} from "../certificate/certificate-types";
import { TCertificateAuthorityCertDALFactory } from "../certificate-authority/certificate-authority-cert-dal";
import { TCertificateAuthorityDALFactory } from "../certificate-authority/certificate-authority-dal";
import { CaStatus } from "../certificate-authority/certificate-authority-enums";
import {
createSerialNumber,
expandInternalCa,
getCaCertChain,
getCaCredentials,
keyAlgorithmToAlgCfg,
parseDistinguishedName
} from "../certificate-authority/certificate-authority-fns";
import { TCertificateAuthoritySecretDALFactory } from "../certificate-authority/certificate-authority-secret-dal";
import { InternalCertificateAuthorityFns } from "../certificate-authority/internal/internal-certificate-authority-fns";
import { TKmsServiceFactory } from "../kms/kms-service";
import { TProjectDALFactory } from "../project/project-dal";
import { getProjectKmsCertificateKeyId } from "../project/project-fns";
import { TPkiTemplatesDALFactory } from "./pki-templates-dal";
import {
TCreatePkiTemplateDTO,
TDeletePkiTemplateDTO,
TGetPkiTemplateDTO,
TIssueCertPkiTemplateDTO,
TListPkiTemplateDTO,
TSignCertPkiTemplateDTO,
TUpdatePkiTemplateDTO
} from "./pki-templates-types";
type TPkiTemplatesServiceFactoryDep = {
pkiTemplatesDAL: TPkiTemplatesDALFactory;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
certificateAuthorityDAL: Pick<
TCertificateAuthorityDALFactory,
"findByIdWithAssociatedCa" | "findById" | "transaction" | "create" | "updateById" | "findWithAssociatedCa"
>;
internalCaFns: ReturnType<typeof InternalCertificateAuthorityFns>;
kmsService: Pick<TKmsServiceFactory, "generateKmsKey" | "decryptWithKmsKey" | "encryptWithKmsKey">;
certificateAuthorityCertDAL: Pick<TCertificateAuthorityCertDALFactory, "findById">;
certificateAuthoritySecretDAL: Pick<TCertificateAuthoritySecretDALFactory, "findOne">;
certificateAuthorityCrlDAL: Pick<TCertificateAuthorityCrlDALFactory, "findOne">;
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">;
};
export type TPkiTemplatesServiceFactory = ReturnType<typeof pkiTemplatesServiceFactory>;
export const pkiTemplatesServiceFactory = ({
pkiTemplatesDAL,
permissionService,
internalCaFns,
certificateAuthorityDAL,
certificateAuthorityCertDAL,
certificateAuthoritySecretDAL,
certificateAuthorityCrlDAL,
certificateDAL,
certificateBodyDAL,
kmsService,
projectDAL
}: TPkiTemplatesServiceFactoryDep) => {
const createTemplate = async ({
actor,
actorId,
actorAuthMethod,
actorOrgId,
caId,
commonName,
extendedKeyUsages,
keyUsages,
name,
subjectAlternativeName,
ttl
}: TCreatePkiTemplateDTO) => {
const ca = await certificateAuthorityDAL.findById(caId);
if (!ca) {
throw new NotFoundError({
message: `CA with ID ${caId} not found`
});
}
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId: ca.projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.CertificateManager
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionPkiTemplateActions.Create,
subject(ProjectPermissionSub.CertificateTemplates, { name })
);
const newTemplate = await pkiTemplatesDAL.create({
caId,
name,
commonName,
subjectAlternativeName,
ttl,
keyUsages,
extendedKeyUsages
});
return newTemplate;
};
const updateTemplate = async ({
templateName,
actor,
actorId,
actorAuthMethod,
actorOrgId,
caId,
commonName,
extendedKeyUsages,
keyUsages,
name,
subjectAlternativeName,
ttl,
projectId
}: TUpdatePkiTemplateDTO) => {
const certTemplate = await pkiTemplatesDAL.findOne({ name: templateName, projectId });
if (!certTemplate) {
throw new NotFoundError({
message: `Certificate template with name ${templateName} not found`
});
}
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId: certTemplate.projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.CertificateManager
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionPkiTemplateActions.Edit,
subject(ProjectPermissionSub.CertificateTemplates, { name: templateName })
);
if (caId) {
const ca = await certificateAuthorityDAL.findById(caId);
if (!ca || ca.projectId !== certTemplate.projectId) {
throw new NotFoundError({
message: `CA with ID ${caId} not found`
});
}
}
if (name) {
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionPkiTemplateActions.Edit,
subject(ProjectPermissionSub.CertificateTemplates, { name })
);
}
const updatedTemplate = await pkiTemplatesDAL.updateById(certTemplate.id, {
caId,
name,
commonName,
subjectAlternativeName,
ttl,
keyUsages,
extendedKeyUsages
});
return updatedTemplate;
};
const deleteTemplate = async ({
templateName,
actor,
actorId,
actorAuthMethod,
actorOrgId,
projectId
}: TDeletePkiTemplateDTO) => {
const certTemplate = await pkiTemplatesDAL.findOne({ name: templateName, projectId });
if (!certTemplate) {
throw new NotFoundError({
message: `Certificate template with name ${templateName} not found`
});
}
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId: certTemplate.projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.CertificateManager
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionPkiTemplateActions.Delete,
subject(ProjectPermissionSub.CertificateTemplates, { name: templateName })
);
const deletedTemplate = await pkiTemplatesDAL.deleteById(certTemplate.id);
return deletedTemplate;
};
const getTemplateByName = async ({
templateName,
actor,
actorId,
actorAuthMethod,
actorOrgId,
projectId
}: TGetPkiTemplateDTO) => {
const certTemplate = await pkiTemplatesDAL.findOne({ name: templateName, projectId });
if (!certTemplate) {
throw new NotFoundError({
message: `Certificate template with name ${templateName} not found`
});
}
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId: certTemplate.projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.CertificateManager
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionPkiTemplateActions.Read,
subject(ProjectPermissionSub.CertificateTemplates, { name: templateName })
);
return certTemplate;
};
const listTemplate = async ({
actor,
actorId,
actorAuthMethod,
actorOrgId,
projectId,
limit,
offset
}: TListPkiTemplateDTO) => {
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.CertificateManager
});
const certTemplate = await pkiTemplatesDAL.find({ projectId }, { limit, offset, count: true });
return {
certificateTemplates: certTemplate.filter((el) =>
permission.can(
ProjectPermissionPkiTemplateActions.Read,
subject(ProjectPermissionSub.CertificateTemplates, { name: el.name })
)
),
totalCount: Number(certTemplate?.[0]?.count ?? 0)
};
};
const issueCertificate = async ({
templateName,
projectId,
commonName,
altNames,
ttl,
notBefore,
notAfter,
actorId,
actorAuthMethod,
actor,
actorOrgId,
keyUsages,
extendedKeyUsages
}: TIssueCertPkiTemplateDTO) => {
const certTemplate = await pkiTemplatesDAL.findOne({ name: templateName, projectId });
if (!certTemplate) {
throw new NotFoundError({
message: `Certificate template with name ${templateName} not found`
});
}
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId: certTemplate.projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.CertificateManager
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionPkiTemplateActions.IssueCert,
subject(ProjectPermissionSub.CertificateTemplates, { name: templateName })
);
const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(certTemplate.caId);
if (ca.internalCa?.id) {
return internalCaFns.issueCertificateWithTemplate(ca, certTemplate, {
altNames,
commonName,
ttl,
extendedKeyUsages,
keyUsages,
notAfter,
notBefore
});
}
throw new BadRequestError({ message: "CA does not support immediate issuance of certificates" });
};
const signCertificate = async ({
templateName,
csr,
projectId,
actorId,
actorAuthMethod,
actor,
actorOrgId,
ttl
}: TSignCertPkiTemplateDTO) => {
const certTemplate = await pkiTemplatesDAL.findOne({ name: templateName, projectId });
if (!certTemplate) {
throw new NotFoundError({
message: `Certificate template with name ${templateName} not found`
});
}
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId: certTemplate.projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.CertificateManager
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionPkiTemplateActions.IssueCert,
subject(ProjectPermissionSub.CertificateTemplates, { name: templateName })
);
const appCfg = getConfig();
const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(certTemplate.caId);
if (!ca?.internalCa) throw new NotFoundError({ message: `CA with ID '${certTemplate.caId}' not found` });
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(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 csrObj = new x509.Pkcs10CertificateRequest(csr);
const dn = parseDistinguishedName(csrObj.subject);
const cn = dn.commonName;
if (!cn)
throw new BadRequestError({
message: "Missing common name on CSR"
});
const commonNameRegex = new RE2(certTemplate.commonName);
if (!commonNameRegex.test(cn)) {
throw new BadRequestError({
message: "Invalid common name based on template policy"
});
}
if (ms(ttl) > ms(certTemplate.ttl)) {
throw new BadRequestError({
message: "Invalid validity date based on template policy"
});
}
const { caPrivateKey, caSecret } = await getCaCredentials({
caId: ca.id,
certificateAuthorityDAL,
certificateAuthoritySecretDAL,
projectDAL,
kmsService
});
const caCrl = await certificateAuthorityCrlDAL.findOne({ caSecretId: caSecret.id });
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),
await x509.AuthorityKeyIdentifierExtension.create(caCertObj, false),
await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey),
new x509.CRLDistributionPointsExtension([distributionPointUrl]),
new x509.AuthorityInfoAccessExtension({
caIssuers: new x509.GeneralName("url", caIssuerUrl)
}),
new x509.CertificatePolicyExtension(["2.5.29.32.0"]) // anyPolicy
];
// handle key usages
const csrKeyUsageExtension = csrObj.getExtension("2.5.29.15") as x509.KeyUsagesExtension | undefined; // Better to type as optional
let selectedKeyUsages: CertKeyUsage[] = [];
if (csrKeyUsageExtension && csrKeyUsageExtension.usages) {
selectedKeyUsages = Object.values(CertKeyUsage).filter(
(keyUsage) => (x509.KeyUsageFlags[keyUsage] & csrKeyUsageExtension.usages) !== 0
);
const validKeyUsages = certTemplate.keyUsages || [];
if (selectedKeyUsages.some((keyUsage) => !validKeyUsages.includes(keyUsage))) {
throw new BadRequestError({
message: "Invalid key usage value based on template policy"
});
}
const keyUsagesBitValue = selectedKeyUsages.reduce((accum, keyUsage) => accum | x509.KeyUsageFlags[keyUsage], 0);
if (keyUsagesBitValue) {
extensions.push(new x509.KeyUsagesExtension(keyUsagesBitValue, true));
}
}
// handle extended key usage
const csrExtendedKeyUsageExtension = csrObj.getExtension("2.5.29.37") as x509.ExtendedKeyUsageExtension | undefined;
let selectedExtendedKeyUsages: CertExtendedKeyUsage[] = [];
if (csrExtendedKeyUsageExtension && csrExtendedKeyUsageExtension.usages.length > 0) {
selectedExtendedKeyUsages = csrExtendedKeyUsageExtension.usages.map(
(ekuOid) => CertExtendedKeyUsageOIDToName[ekuOid as string]
);
if (selectedExtendedKeyUsages.some((eku) => !certTemplate?.extendedKeyUsages?.includes(eku))) {
throw new BadRequestError({
message: "Invalid extended key usage value based on subscriber's specified extended key usages"
});
}
if (selectedExtendedKeyUsages.length) {
extensions.push(
new x509.ExtendedKeyUsageExtension(
selectedExtendedKeyUsages.map((eku) => x509.ExtendedKeyUsage[eku]),
true
)
);
}
}
// attempt to read from CSR if altNames is not explicitly provided
let altNamesArray: {
type: "email" | "dns";
value: string;
}[] = [];
const sanExtension = csrObj.extensions.find((ext) => ext.type === "2.5.29.17");
if (sanExtension) {
const sanNames = new x509.GeneralNames(sanExtension.value);
altNamesArray = sanNames.items
.filter((value) => value.type === "email" || value.type === "dns")
.map((name) => ({
type: name.type as "email" | "dns",
value: name.value
}));
}
if (altNamesArray.length) {
const altNamesExtension = new x509.SubjectAlternativeNameExtension(altNamesArray, false);
extensions.push(altNamesExtension);
}
const subjectAlternativeNameRegex = new RE2(certTemplate.subjectAlternativeName);
altNamesArray.forEach((altName) => {
if (!subjectAlternativeNameRegex.test(altName.value)) {
throw new BadRequestError({
message: "Invalid subject alternative name based on template policy"
});
}
});
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 kmsEncryptor = await kmsService.encryptWithKmsKey({
kmsId: certificateManagerKmsId
});
const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({
plainText: Buffer.from(new Uint8Array(leafCert.rawData))
});
const { caCert: issuingCaCertificate, caCertChain } = await getCaCertChain({
caCertId: ca.internalCa.activeCaCertId,
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,
status: CertStatus.ACTIVE,
friendlyName: cn,
commonName: cn,
altNames: altNamesArray.map((el) => el.value).join(","),
serialNumber,
notBefore: notBeforeDate,
notAfter: notAfterDate,
keyUsages: selectedKeyUsages,
extendedKeyUsages: selectedExtendedKeyUsages,
projectId
},
tx
);
await certificateBodyDAL.create(
{
certId: cert.id,
encryptedCertificate,
encryptedCertificateChain
},
tx
);
return cert;
});
return {
certificate: leafCert.toString("pem"),
certificateChain: `${issuingCaCertificate}\n${caCertChain}`.trim(),
issuingCaCertificate,
serialNumber,
ca: expandInternalCa(ca),
commonName: cn,
template: certTemplate
};
};
return {
createTemplate,
updateTemplate,
getTemplateByName,
listTemplate,
deleteTemplate,
signCertificate,
issueCertificate
};
};

View File

@@ -0,0 +1,53 @@
import { TProjectPermission } from "@app/lib/types";
import { CertExtendedKeyUsage, CertKeyUsage } from "@app/services/certificate/certificate-types";
export type TCreatePkiTemplateDTO = {
caId: string;
name: string;
commonName: string;
subjectAlternativeName: string;
ttl: string;
keyUsages: CertKeyUsage[];
extendedKeyUsages: CertExtendedKeyUsage[];
} & TProjectPermission;
export type TUpdatePkiTemplateDTO = {
templateName: string;
caId?: string;
name?: string;
commonName?: string;
subjectAlternativeName?: string;
ttl?: string;
keyUsages?: CertKeyUsage[];
extendedKeyUsages?: CertExtendedKeyUsage[];
} & TProjectPermission;
export type TListPkiTemplateDTO = {
limit?: number;
offset?: number;
} & TProjectPermission;
export type TGetPkiTemplateDTO = {
templateName: string;
} & TProjectPermission;
export type TDeletePkiTemplateDTO = {
templateName: string;
} & TProjectPermission;
export type TIssueCertPkiTemplateDTO = {
templateName: string;
commonName: string;
altNames: string;
ttl: string;
notBefore?: string;
notAfter?: string;
keyUsages?: CertKeyUsage[];
extendedKeyUsages?: CertExtendedKeyUsage[];
} & TProjectPermission;
export type TSignCertPkiTemplateDTO = {
templateName: string;
csr: string;
ttl: string;
} & TProjectPermission;

View File

@@ -17,6 +17,7 @@ import {
ProjectPermissionActions,
ProjectPermissionCertificateActions,
ProjectPermissionPkiSubscriberActions,
ProjectPermissionPkiTemplateActions,
ProjectPermissionSecretActions,
ProjectPermissionSshHostActions,
ProjectPermissionSub
@@ -1131,15 +1132,15 @@ export const projectServiceFactory = ({
actionProjectType: ActionProjectType.CertificateManager
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read,
ProjectPermissionSub.CertificateTemplates
);
const certificateTemplates = await certificateTemplateDAL.getCertTemplatesByProjectId(projectId);
return {
certificateTemplates
certificateTemplates: certificateTemplates.filter((el) =>
permission.can(
ProjectPermissionPkiTemplateActions.Read,
subject(ProjectPermissionSub.CertificateTemplates, { name: el.name })
)
)
};
};