checkpoint

This commit is contained in:
x
2025-04-29 19:24:00 -04:00
parent ddb5098eda
commit 5641d334cd
17 changed files with 653 additions and 26 deletions
@@ -0,0 +1,16 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
if (await knex.schema.hasTable(TableName.Certificate)) {
await knex.schema.alterTable(TableName.Certificate, (t) => {
t.uuid("caId").nullable().alter();
t.uuid("caCertId").nullable().alter();
});
}
}
export async function down(): Promise<void> {
// Altering back to nullable will fail
}
@@ -0,0 +1,33 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
if (await knex.schema.hasTable(TableName.CertificateBody)) {
await knex.schema.alterTable(TableName.CertificateBody, (t) => {
t.binary("encryptedCertificateChain").nullable();
});
}
if (!(await knex.schema.hasTable(TableName.CertificateSecret))) {
await knex.schema.createTable(TableName.CertificateSecret, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.timestamps(true, true, true);
t.uuid("certId").notNullable().unique();
t.foreign("certId").references("id").inTable(TableName.Certificate).onDelete("CASCADE");
t.binary("encryptedPrivateKey").notNullable();
});
}
}
export async function down(knex: Knex): Promise<void> {
if (await knex.schema.hasTable(TableName.CertificateSecret)) {
await knex.schema.dropTable(TableName.CertificateSecret);
}
if (await knex.schema.hasTable(TableName.CertificateBody)) {
await knex.schema.alterTable(TableName.CertificateBody, (t) => {
t.dropColumn("encryptedCertificateChain");
});
}
}
@@ -0,0 +1,16 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
if (await knex.schema.hasTable(TableName.Certificate)) {
await knex.schema.alterTable(TableName.Certificate, (t) => {
t.uuid("projectId").notNullable();
t.foreign("projectId").references("id").inTable(TableName.Certificate).onDelete("CASCADE");
});
}
}
export async function down(knex: Knex): Promise<void> {
// .. tbd
}
+2 -1
View File
@@ -14,7 +14,8 @@ export const CertificateBodiesSchema = z.object({
createdAt: z.date(),
updatedAt: z.date(),
certId: z.string().uuid(),
encryptedCertificate: zodBuffer
encryptedCertificate: zodBuffer,
encryptedCertificateChain: zodBuffer.nullable().optional()
});
export type TCertificateBodies = z.infer<typeof CertificateBodiesSchema>;
@@ -5,6 +5,8 @@
import { z } from "zod";
import { zodBuffer } from "@app/lib/zod";
import { TImmutableDBKeys } from "./models";
export const CertificateSecretsSchema = z.object({
@@ -12,8 +14,7 @@ export const CertificateSecretsSchema = z.object({
createdAt: z.date(),
updatedAt: z.date(),
certId: z.string().uuid(),
pk: z.string(),
sk: z.string()
encryptedPrivateKey: zodBuffer
});
export type TCertificateSecrets = z.infer<typeof CertificateSecretsSchema>;
+2 -2
View File
@@ -11,7 +11,7 @@ export const CertificatesSchema = z.object({
id: z.string().uuid(),
createdAt: z.date(),
updatedAt: z.date(),
caId: z.string().uuid(),
caId: z.string().uuid().nullable().optional(),
status: z.string(),
serialNumber: z.string(),
friendlyName: z.string(),
@@ -21,7 +21,7 @@ export const CertificatesSchema = z.object({
revokedAt: z.date().nullable().optional(),
revocationReason: z.number().nullable().optional(),
altNames: z.string().nullable().optional(),
caCertId: z.string().uuid(),
caCertId: z.string().uuid().nullable().optional(),
certificateTemplateId: z.string().uuid().nullable().optional(),
keyUsages: z.string().array().nullable().optional(),
extendedKeyUsages: z.string().array().nullable().optional()
-1
View File
@@ -23,7 +23,6 @@ export const OrganizationsSchema = z.object({
defaultMembershipRole: z.string().default("member"),
enforceMfa: z.boolean().default(false),
selectedMfaMethod: z.string().nullable().optional(),
secretShareSendToAnyone: z.boolean().default(true).nullable().optional(),
allowSecretSharingOutsideOrganization: z.boolean().default(true).nullable().optional(),
shouldUseNewPrivilegeSystem: z.boolean().default(true),
privilegeUpgradeInitiatedByUsername: z.string().nullable().optional(),
+1 -1
View File
@@ -27,7 +27,7 @@ export const ProjectsSchema = z.object({
description: z.string().nullable().optional(),
type: z.string(),
enforceCapitalization: z.boolean().default(false),
hasDeleteProtection: z.boolean().default(true).nullable().optional()
hasDeleteProtection: z.boolean().default(false).nullable().optional()
});
export type TProjects = z.infer<typeof ProjectsSchema>;
+13
View File
@@ -1581,6 +1581,19 @@ export const CERTIFICATES = {
certificate: "The certificate body of the certificate.",
certificateChain: "The certificate chain of the certificate.",
serialNumberRes: "The serial number of the certificate."
},
IMPORT: {
projectSlug: "Slug of the project to import the certificate into.",
certificatePem: "The PEM-encoded leaf certificate.",
privateKeyPem: "The PEM-encoded private key corresponding to the certificate.",
chainPem: "The PEM-encoded chain of intermediate certificates.",
friendlyName: "A friendly name for the certificate.",
pkiCollectionId: "The ID of the PKI collection to add the certificate to.",
certificate: "The issued certificate.",
certificateChain: "The certificate chain of the issued certificate.",
privateKey: "The private key of the issued certificate.",
serialNumber: "The serial number of the issued certificate."
}
};
@@ -177,6 +177,79 @@ export const registerCertRouter = async (server: FastifyZodProvider) => {
}
});
server.route({
method: "POST",
url: "/import-certificate",
config: {
rateLimit: writeLimit
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
schema: {
hide: false,
tags: [ApiDocsTags.PkiCertificates],
description: "Import certificate",
body: z.object({
projectSlug: z.string().trim().min(1).describe(CERTIFICATES.IMPORT.projectSlug),
certificatePem: z.string().trim().min(1).describe(CERTIFICATES.IMPORT.certificatePem),
privateKeyPem: z.string().trim().optional().describe(CERTIFICATES.IMPORT.privateKeyPem),
chainPem: z.string().trim().optional().describe(CERTIFICATES.IMPORT.chainPem),
friendlyName: z.string().trim().optional().describe(CERTIFICATES.IMPORT.friendlyName),
pkiCollectionId: z.string().trim().optional().describe(CERTIFICATES.IMPORT.pkiCollectionId)
}),
response: {
200: z.object({
certificate: z.string().trim().describe(CERTIFICATES.IMPORT.certificate),
certificateChain: z.string().trim().optional().describe(CERTIFICATES.IMPORT.certificateChain),
privateKey: z.string().trim().optional().describe(CERTIFICATES.IMPORT.privateKey),
serialNumber: z.string().trim().describe(CERTIFICATES.IMPORT.serialNumber)
})
}
},
handler: async (req) => {
const { certificate, certificateChain, privateKey, serialNumber } = await server.services.certificate.importCert({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
...req.body
});
// TODO(andrey): Add logs
// await server.services.auditLog.createAuditLog({
// ...req.auditLogInfo,
// projectId: ca.projectId,
// event: {
// type: EventType.ISSUE_CERT,
// metadata: {
// caId: ca.id,
// dn: ca.dn,
// serialNumber
// }
// }
// });
// await server.services.telemetry.sendPostHogEvents({
// event: PostHogEventTypes.IssueCert,
// distinctId: getTelemetryDistinctId(req),
// properties: {
// caId: req.body.caId,
// certificateTemplateId: req.body.certificateTemplateId,
// commonName: req.body.commonName,
// ...req.auditLogInfo
// }
// });
return {
certificate,
certificateChain,
privateKey,
serialNumber
};
}
});
server.route({
method: "POST",
url: "/sign-certificate",
@@ -1,31 +1,47 @@
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 { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
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 { revocationReasonToCrlCode } from "./certificate-fns";
import { CertStatus, TDeleteCertDTO, TGetCertBodyDTO, TGetCertDTO, TRevokeCertDTO } from "./certificate-types";
import {
CertStatus,
TDeleteCertDTO,
TGetCertBodyDTO,
TGetCertDTO,
TImportCertDTO,
TRevokeCertDTO
} from "./certificate-types";
type TCertificateServiceFactoryDep = {
certificateDAL: Pick<TCertificateDALFactory, "findOne" | "deleteById" | "update" | "find">;
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "findOne">;
certificateDAL: Pick<TCertificateDALFactory, "findOne" | "deleteById" | "update" | "find" | "transaction" | "create">;
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "findOne" | "create">;
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findById">;
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">;
};
@@ -39,6 +55,8 @@ export const certificateServiceFactory = ({
certificateAuthorityCertDAL,
certificateAuthorityCrlDAL,
certificateAuthoritySecretDAL,
pkiCollectionDAL,
pkiCollectionItemDAL,
projectDAL,
kmsService,
permissionService
@@ -48,7 +66,12 @@ 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);
let ca;
if (cert.caId) {
ca = await certificateAuthorityDAL.findById(cert.caId);
}
const { permission } = await permissionService.getProjectPermission({
actor,
@@ -201,10 +224,171 @@ export const certificateServiceFactory = ({
};
};
/**
* 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(ProjectPermissionActions.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" });
}
// Parse the certificate
const certObj = new x509.X509Certificate(certificatePem);
// Verify the certificate chain
if (chainPem) {
const chainCert = new x509.X509Certificate(chainPem);
if (!(await certObj.verify({ publicKey: chainCert.publicKey }))) {
throw new BadRequestError({ message: "Certificate chain verification failed" });
}
}
// If private key provided, verify it matches the certificate
if (privateKeyPem) {
try {
const message = Buffer.from("certificate-verification-test");
const privateKey = createPrivateKey(privateKeyPem);
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) {
throw new BadRequestError({ message: "Invalid private key format" });
}
}
// Get certificate attributes
const commonName = Array.from(certObj.subjectName.getField("CN")?.values() || [])[0] || "";
let altNames: undefined | string;
const sanExtension = certObj.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 } = certObj;
// 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)
});
let encryptedCertificateChain: undefined | Buffer;
if (chainPem) {
const { cipherTextBlob } = await kmsEncryptor({
plainText: Buffer.from(chainPem)
});
encryptedCertificateChain = cipherTextBlob;
}
console.log(friendlyName, commonName, altNames, serialNumber, notBefore, notAfter);
// Store in database
await certificateDAL.transaction(async (tx) => {
const cert = await certificateDAL.create(
{
status: CertStatus.ACTIVE,
friendlyName: friendlyName || commonName,
commonName,
altNames,
serialNumber,
notBefore,
notAfter
// keyUsages,
// extendedKeyUsages
},
tx
);
await certificateBodyDAL.create(
{
certId: cert.id,
encryptedCertificate,
encryptedCertificateChain
},
tx
);
if (collectionId) {
await pkiCollectionItemDAL.create(
{
pkiCollectionId: collectionId,
certId: cert.id
},
tx
);
}
return cert;
});
return {
certificate: certificatePem,
certificateChain: chainPem,
privateKey: privateKeyPem,
serialNumber
};
};
return {
getCert,
deleteCert,
revokeCert,
getCertBody
getCertBody,
importCert
};
};
@@ -73,3 +73,14 @@ export type TRevokeCertDTO = {
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">;