Merge remote-tracking branch 'origin/main' into feature/oidc

This commit is contained in:
Sheen Capadngan
2024-06-18 23:54:06 +08:00
195 changed files with 9370 additions and 582 deletions
@@ -0,0 +1,10 @@
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify } from "@app/lib/knex";
export type TCertificateAuthorityCertDALFactory = ReturnType<typeof certificateAuthorityCertDALFactory>;
export const certificateAuthorityCertDALFactory = (db: TDbClient) => {
const caCertOrm = ormify(db, TableName.CertificateAuthorityCert);
return caCertOrm;
};
@@ -0,0 +1,48 @@
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { ormify } from "@app/lib/knex";
export type TCertificateAuthorityDALFactory = ReturnType<typeof certificateAuthorityDALFactory>;
export const certificateAuthorityDALFactory = (db: TDbClient) => {
const caOrm = ormify(db, TableName.CertificateAuthority);
// note: not used
const buildCertificateChain = async (caId: string) => {
try {
const result: {
caId: string;
parentCaId?: string;
encryptedCertificate: Buffer;
}[] = await db
.withRecursive("cte", (cte) => {
void cte
.select("ca.id as caId", "ca.parentCaId", "cert.encryptedCertificate")
.from({ ca: TableName.CertificateAuthority })
.leftJoin({ cert: TableName.CertificateAuthorityCert }, "ca.id", "cert.caId")
.where("ca.id", caId)
.unionAll((builder) => {
void builder
.select("ca.id as caId", "ca.parentCaId", "cert.encryptedCertificate")
.from({ ca: TableName.CertificateAuthority })
.leftJoin({ cert: TableName.CertificateAuthorityCert }, "ca.id", "cert.caId")
.innerJoin("cte", "cte.parentCaId", "ca.id");
});
})
.select("*")
.from("cte");
// Extract certificates and reverse the order to have the root CA at the end
const certChain: Buffer[] = result.map((row) => row.encryptedCertificate);
return certChain;
} catch (error) {
throw new DatabaseError({ error, name: "BuildCertificateChain" });
}
};
return {
...caOrm,
buildCertificateChain
};
};
@@ -0,0 +1,216 @@
import * as x509 from "@peculiar/x509";
import crypto from "crypto";
import { BadRequestError } from "@app/lib/errors";
import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns";
import { CertKeyAlgorithm, CertStatus } from "../certificate/certificate-types";
import { TDNParts, TGetCaCertChainDTO, TGetCaCredentialsDTO, TRebuildCaCrlDTO } from "./certificate-authority-types";
export const createDistinguishedName = (parts: TDNParts) => {
const dnParts = [];
if (parts.country) dnParts.push(`C=${parts.country}`);
if (parts.organization) dnParts.push(`O=${parts.organization}`);
if (parts.ou) dnParts.push(`OU=${parts.ou}`);
if (parts.province) dnParts.push(`ST=${parts.province}`);
if (parts.commonName) dnParts.push(`CN=${parts.commonName}`);
if (parts.locality) dnParts.push(`L=${parts.locality}`);
return dnParts.join(", ");
};
export const keyAlgorithmToAlgCfg = (keyAlgorithm: CertKeyAlgorithm) => {
switch (keyAlgorithm) {
case CertKeyAlgorithm.RSA_4096:
return {
name: "RSASSA-PKCS1-v1_5",
hash: "SHA-256",
publicExponent: new Uint8Array([1, 0, 1]),
modulusLength: 4096
};
case CertKeyAlgorithm.ECDSA_P256:
return {
name: "ECDSA",
namedCurve: "P-256",
hash: "SHA-256"
};
case CertKeyAlgorithm.ECDSA_P384:
return {
name: "ECDSA",
namedCurve: "P-384",
hash: "SHA-384"
};
default: {
// RSA_2048
return {
name: "RSASSA-PKCS1-v1_5",
hash: "SHA-256",
publicExponent: new Uint8Array([1, 0, 1]),
modulusLength: 2048
};
}
}
};
/**
* Return the public and private key of CA with id [caId]
* Note: credentials are returned as crypto.webcrypto.CryptoKey
* suitable for use with @peculiar/x509 module
*/
export const getCaCredentials = async ({
caId,
certificateAuthorityDAL,
certificateAuthoritySecretDAL,
projectDAL,
kmsService
}: TGetCaCredentialsDTO) => {
const ca = await certificateAuthorityDAL.findById(caId);
if (!ca) throw new BadRequestError({ message: "CA not found" });
const caSecret = await certificateAuthoritySecretDAL.findOne({ caId });
if (!caSecret) throw new BadRequestError({ message: "CA secret not found" });
const keyId = await getProjectKmsCertificateKeyId({
projectId: ca.projectId,
projectDAL,
kmsService
});
const decryptedPrivateKey = await kmsService.decrypt({
kmsId: keyId,
cipherTextBlob: caSecret.encryptedPrivateKey
});
const alg = keyAlgorithmToAlgCfg(ca.keyAlgorithm as CertKeyAlgorithm);
const skObj = crypto.createPrivateKey({ key: decryptedPrivateKey, format: "der", type: "pkcs8" });
const caPrivateKey = await crypto.subtle.importKey(
"pkcs8",
skObj.export({ format: "der", type: "pkcs8" }),
alg,
true,
["sign"]
);
const pkObj = crypto.createPublicKey(skObj);
const caPublicKey = await crypto.subtle.importKey("spki", pkObj.export({ format: "der", type: "spki" }), alg, true, [
"verify"
]);
return {
caPrivateKey,
caPublicKey
};
};
/**
* Return the decrypted pem-encoded certificate and certificate chain
* for CA with id [caId].
*/
export const getCaCertChain = async ({
caId,
certificateAuthorityDAL,
certificateAuthorityCertDAL,
projectDAL,
kmsService
}: TGetCaCertChainDTO) => {
const ca = await certificateAuthorityDAL.findById(caId);
if (!ca) throw new BadRequestError({ message: "CA not found" });
const caCert = await certificateAuthorityCertDAL.findOne({ caId: ca.id });
const keyId = await getProjectKmsCertificateKeyId({
projectId: ca.projectId,
projectDAL,
kmsService
});
const decryptedCaCert = await kmsService.decrypt({
kmsId: keyId,
cipherTextBlob: caCert.encryptedCertificate
});
const caCertObj = new x509.X509Certificate(decryptedCaCert);
const decryptedChain = await kmsService.decrypt({
kmsId: keyId,
cipherTextBlob: caCert.encryptedCertificateChain
});
return {
caCert: caCertObj.toString("pem"),
caCertChain: decryptedChain.toString("utf-8"),
serialNumber: caCertObj.serialNumber
};
};
/**
* Rebuilds the certificate revocation list (CRL)
* for CA with id [caId]
*/
export const rebuildCaCrl = async ({
caId,
certificateAuthorityDAL,
certificateAuthorityCrlDAL,
certificateAuthoritySecretDAL,
projectDAL,
certificateDAL,
kmsService
}: TRebuildCaCrlDTO) => {
const ca = await certificateAuthorityDAL.findById(caId);
if (!ca) throw new BadRequestError({ message: "CA not found" });
const caSecret = await certificateAuthoritySecretDAL.findOne({ caId: ca.id });
const alg = keyAlgorithmToAlgCfg(ca.keyAlgorithm as CertKeyAlgorithm);
const keyId = await getProjectKmsCertificateKeyId({
projectId: ca.projectId,
projectDAL,
kmsService
});
const privateKey = await kmsService.decrypt({
kmsId: keyId,
cipherTextBlob: caSecret.encryptedPrivateKey
});
const skObj = crypto.createPrivateKey({ key: privateKey, format: "der", type: "pkcs8" });
const sk = await crypto.subtle.importKey("pkcs8", skObj.export({ format: "der", type: "pkcs8" }), alg, true, [
"sign"
]);
const revokedCerts = await certificateDAL.find({
caId: ca.id,
status: CertStatus.REVOKED
});
const crl = await x509.X509CrlGenerator.create({
issuer: ca.dn,
thisUpdate: new Date(),
nextUpdate: new Date("2025/12/12"),
entries: revokedCerts.map((revokedCert) => {
return {
serialNumber: revokedCert.serialNumber,
revocationDate: new Date(revokedCert.revokedAt as Date),
reason: revokedCert.revocationReason as number,
invalidity: new Date("2022/01/01"),
issuer: ca.dn
};
}),
signingAlgorithm: alg,
signingKey: sk
});
const { cipherTextBlob: encryptedCrl } = await kmsService.encrypt({
kmsId: keyId,
plainText: Buffer.from(new Uint8Array(crl.rawData))
});
await certificateAuthorityCrlDAL.update(
{
caId: ca.id
},
{
encryptedCrl
}
);
};
@@ -0,0 +1,145 @@
import * as x509 from "@peculiar/x509";
import crypto from "crypto";
import { getConfig } from "@app/lib/config/env";
import { daysToMillisecond, secondsToMillis } from "@app/lib/dates";
import { BadRequestError } 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";
import { CertKeyAlgorithm, 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 { TCertificateAuthorityCrlDALFactory } from "../../ee/services/certificate-authority-crl/certificate-authority-crl-dal";
import { TCertificateAuthorityDALFactory } from "./certificate-authority-dal";
import { keyAlgorithmToAlgCfg } from "./certificate-authority-fns";
import { TCertificateAuthoritySecretDALFactory } from "./certificate-authority-secret-dal";
import { TRotateCaCrlTriggerDTO } from "./certificate-authority-types";
type TCertificateAuthorityQueueFactoryDep = {
// TODO: Pick
certificateAuthorityDAL: TCertificateAuthorityDALFactory;
certificateAuthorityCrlDAL: TCertificateAuthorityCrlDALFactory;
certificateAuthoritySecretDAL: TCertificateAuthoritySecretDALFactory;
certificateDAL: TCertificateDALFactory;
projectDAL: Pick<TProjectDALFactory, "findProjectBySlug" | "findOne" | "updateById" | "findById" | "transaction">;
kmsService: Pick<TKmsServiceFactory, "generateKmsKey" | "encrypt" | "decrypt">;
queueService: TQueueServiceFactory;
};
export type TCertificateAuthorityQueueFactory = ReturnType<typeof certificateAuthorityQueueFactory>;
export const certificateAuthorityQueueFactory = ({
certificateAuthorityCrlDAL,
certificateAuthorityDAL,
certificateAuthoritySecretDAL,
certificateDAL,
projectDAL,
kmsService,
queueService
}: TCertificateAuthorityQueueFactoryDep) => {
// TODO 1: auto-periodic rotation
// TODO 2: manual rotation
const setCaCrlRotationInterval = async ({ caId, rotationIntervalDays }: TRotateCaCrlTriggerDTO) => {
const appCfg = getConfig();
// query for config
// const caCrl = await certificateAuthorityCrlDAL.findOne({
// caId
// });
await queueService.queue(
// TODO: clarify queue + job naming
QueueName.CaCrlRotation,
QueueJobs.CaCrlRotation,
{
caId
},
{
jobId: `ca-crl-rotation-${caId}`,
repeat: {
// on prod it this will be in days, in development this will be second
every:
appCfg.NODE_ENV === "development"
? secondsToMillis(rotationIntervalDays)
: daysToMillisecond(rotationIntervalDays),
immediately: true
}
}
);
};
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 BadRequestError({ message: "CA not found" });
const caSecret = await certificateAuthoritySecretDAL.findOne({ caId: ca.id });
const alg = keyAlgorithmToAlgCfg(ca.keyAlgorithm as CertKeyAlgorithm);
const keyId = await getProjectKmsCertificateKeyId({
projectId: ca.projectId,
projectDAL,
kmsService
});
const privateKey = await kmsService.decrypt({
kmsId: keyId,
cipherTextBlob: caSecret.encryptedPrivateKey
});
const skObj = crypto.createPrivateKey({ key: privateKey, format: "der", type: "pkcs8" });
const sk = await crypto.subtle.importKey("pkcs8", skObj.export({ format: "der", type: "pkcs8" }), alg, true, [
"sign"
]);
const revokedCerts = await certificateDAL.find({
caId: ca.id,
status: CertStatus.REVOKED
});
const crl = await x509.X509CrlGenerator.create({
issuer: ca.dn,
thisUpdate: new Date(),
nextUpdate: new Date("2025/12/12"), // TODO: depends on configured rebuild interval
entries: revokedCerts.map((revokedCert) => {
return {
serialNumber: revokedCert.serialNumber,
revocationDate: new Date(revokedCert.revokedAt as Date),
reason: revokedCert.revocationReason as number,
invalidity: new Date("2022/01/01"),
issuer: ca.dn
};
}),
signingAlgorithm: alg,
signingKey: sk
});
const { cipherTextBlob: encryptedCrl } = await kmsService.encrypt({
kmsId: keyId,
plainText: Buffer.from(new Uint8Array(crl.rawData))
});
await certificateAuthorityCrlDAL.update(
{
caId: ca.id
},
{
encryptedCrl
}
);
});
queueService.listen(QueueName.CaCrlRotation, "failed", (job, err) => {
logger.error(err, "Failed to rotate CA CRL %s", job?.id);
});
return {
setCaCrlRotationInterval
};
};
@@ -0,0 +1,10 @@
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify } from "@app/lib/knex";
export type TCertificateAuthoritySecretDALFactory = ReturnType<typeof certificateAuthoritySecretDALFactory>;
export const certificateAuthoritySecretDALFactory = (db: TDbClient) => {
const caSecretOrm = ormify(db, TableName.CertificateAuthoritySecret);
return caSecretOrm;
};
@@ -0,0 +1,821 @@
/* eslint-disable no-bitwise */
import { ForbiddenError } from "@casl/ability";
import * as x509 from "@peculiar/x509";
import crypto, { KeyObject } from "crypto";
import ms from "ms";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
import { BadRequestError } from "@app/lib/errors";
import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal";
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 { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns";
import { TCertificateAuthorityCrlDALFactory } from "../../ee/services/certificate-authority-crl/certificate-authority-crl-dal";
import { CertKeyAlgorithm, CertStatus } from "../certificate/certificate-types";
import { TCertificateAuthorityCertDALFactory } from "./certificate-authority-cert-dal";
import { TCertificateAuthorityDALFactory } from "./certificate-authority-dal";
import {
createDistinguishedName,
getCaCertChain,
getCaCredentials,
keyAlgorithmToAlgCfg
} from "./certificate-authority-fns";
import { TCertificateAuthorityQueueFactory } from "./certificate-authority-queue";
import { TCertificateAuthoritySecretDALFactory } from "./certificate-authority-secret-dal";
import {
CaStatus,
CaType,
TCreateCaDTO,
TDeleteCaDTO,
TGetCaCertDTO,
TGetCaCsrDTO,
TGetCaDTO,
TImportCertToCaDTO,
TIssueCertFromCaDTO,
TSignIntermediateDTO,
TUpdateCaDTO
} from "./certificate-authority-types";
type TCertificateAuthorityServiceFactoryDep = {
certificateAuthorityDAL: Pick<
TCertificateAuthorityDALFactory,
"transaction" | "create" | "findById" | "updateById" | "deleteById" | "findOne"
>;
certificateAuthorityCertDAL: Pick<TCertificateAuthorityCertDALFactory, "create" | "findOne" | "transaction">;
certificateAuthoritySecretDAL: Pick<TCertificateAuthoritySecretDALFactory, "create" | "findOne">;
certificateAuthorityCrlDAL: Pick<TCertificateAuthorityCrlDALFactory, "create" | "findOne" | "update">;
certificateAuthorityQueue: TCertificateAuthorityQueueFactory; // TODO: Pick
certificateDAL: Pick<TCertificateDALFactory, "transaction" | "create" | "find">;
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "create">;
projectDAL: Pick<TProjectDALFactory, "findProjectBySlug" | "findOne" | "updateById" | "findById" | "transaction">;
kmsService: Pick<TKmsServiceFactory, "generateKmsKey" | "encrypt" | "decrypt">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
};
export type TCertificateAuthorityServiceFactory = ReturnType<typeof certificateAuthorityServiceFactory>;
export const certificateAuthorityServiceFactory = ({
certificateAuthorityDAL,
certificateAuthorityCertDAL,
certificateAuthoritySecretDAL,
certificateAuthorityCrlDAL,
certificateDAL,
certificateBodyDAL,
projectDAL,
kmsService,
permissionService
}: TCertificateAuthorityServiceFactoryDep) => {
/**
* Generates new root or intermediate CA
*/
const createCa = async ({
projectSlug,
type,
friendlyName,
commonName,
organization,
ou,
country,
province,
locality,
notBefore,
notAfter,
maxPathLength,
keyAlgorithm,
actorId,
actorAuthMethod,
actor,
actorOrgId
}: TCreateCaDTO) => {
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
if (!project) throw new BadRequestError({ message: "Project not found" });
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
project.id,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Create,
ProjectPermissionSub.CertificateAuthorities
);
const dn = createDistinguishedName({
commonName,
organization,
ou,
country,
province,
locality
});
const alg = keyAlgorithmToAlgCfg(keyAlgorithm);
const keys = await crypto.subtle.generateKey(alg, true, ["sign", "verify"]);
const newCa = await certificateAuthorityDAL.transaction(async (tx) => {
const notBeforeDate = notBefore ? new Date(notBefore) : new Date();
// if undefined, set [notAfterDate] to 10 years from now
const notAfterDate = notAfter
? new Date(notAfter)
: new Date(new Date().setFullYear(new Date().getFullYear() + 10));
const serialNumber = crypto.randomBytes(32).toString("hex");
const ca = await certificateAuthorityDAL.create(
{
projectId: project.id,
type,
organization,
ou,
country,
province,
locality,
friendlyName: friendlyName || dn,
commonName,
status: type === CaType.ROOT ? CaStatus.ACTIVE : CaStatus.PENDING_CERTIFICATE,
dn,
keyAlgorithm,
...(type === CaType.ROOT && {
maxPathLength,
notBefore: notBeforeDate,
notAfter: notAfterDate,
serialNumber
})
},
tx
);
const keyId = await getProjectKmsCertificateKeyId({
projectId: project.id,
projectDAL,
kmsService
});
if (type === CaType.ROOT) {
// note: create self-signed cert only applicable for root CA
const cert = await x509.X509CertificateGenerator.createSelfSigned({
name: dn,
serialNumber,
notBefore: notBeforeDate,
notAfter: notAfterDate,
signingAlgorithm: alg,
keys,
extensions: [
new x509.BasicConstraintsExtension(true, maxPathLength === -1 ? undefined : maxPathLength, true),
new x509.ExtendedKeyUsageExtension(["1.2.3.4.5.6.7", "2.3.4.5.6.7.8"], true),
// eslint-disable-next-line no-bitwise
new x509.KeyUsagesExtension(x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign, true),
await x509.SubjectKeyIdentifierExtension.create(keys.publicKey)
]
});
const { cipherTextBlob: encryptedCertificate } = await kmsService.encrypt({
kmsId: keyId,
plainText: Buffer.from(new Uint8Array(cert.rawData))
});
const { cipherTextBlob: encryptedCertificateChain } = await kmsService.encrypt({
kmsId: keyId,
plainText: Buffer.alloc(0)
});
await certificateAuthorityCertDAL.create(
{
caId: ca.id,
encryptedCertificate,
encryptedCertificateChain
},
tx
);
}
// create empty CRL
const crl = await x509.X509CrlGenerator.create({
issuer: ca.dn,
thisUpdate: new Date(),
nextUpdate: new Date("2025/12/12"), // TODO: change
entries: [],
signingAlgorithm: alg,
signingKey: keys.privateKey
});
const { cipherTextBlob: encryptedCrl } = await kmsService.encrypt({
kmsId: keyId,
plainText: Buffer.from(new Uint8Array(crl.rawData))
});
await certificateAuthorityCrlDAL.create(
{
caId: ca.id,
encryptedCrl
},
tx
);
// https://nodejs.org/api/crypto.html#static-method-keyobjectfromkey
const skObj = KeyObject.from(keys.privateKey);
const { cipherTextBlob: encryptedPrivateKey } = await kmsService.encrypt({
kmsId: keyId,
plainText: skObj.export({
type: "pkcs8",
format: "der"
})
});
await certificateAuthoritySecretDAL.create(
{
caId: ca.id,
encryptedPrivateKey
},
tx
);
return ca;
});
return newCa;
};
/**
* Return CA with id [caId]
*/
const getCaById = async ({ caId, actorId, actorAuthMethod, actor, actorOrgId }: TGetCaDTO) => {
const ca = await certificateAuthorityDAL.findById(caId);
if (!ca) throw new BadRequestError({ message: "CA not found" });
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
ca.projectId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read,
ProjectPermissionSub.CertificateAuthorities
);
return ca;
};
/**
* Update CA with id [caId].
* Note: Used to enable/disable CA
*/
const updateCaById = async ({ caId, status, actorId, actorAuthMethod, actor, actorOrgId }: TUpdateCaDTO) => {
const ca = await certificateAuthorityDAL.findById(caId);
if (!ca) throw new BadRequestError({ message: "CA not found" });
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
ca.projectId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Edit,
ProjectPermissionSub.CertificateAuthorities
);
const updatedCa = await certificateAuthorityDAL.updateById(caId, { status });
return updatedCa;
};
/**
* Delete CA with id [caId]
*/
const deleteCaById = async ({ caId, actorId, actorAuthMethod, actor, actorOrgId }: TDeleteCaDTO) => {
const ca = await certificateAuthorityDAL.findById(caId);
if (!ca) throw new BadRequestError({ message: "CA not found" });
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
ca.projectId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Delete,
ProjectPermissionSub.CertificateAuthorities
);
const deletedCa = await certificateAuthorityDAL.deleteById(caId);
return deletedCa;
};
/**
* Return certificate signing request (CSR) made with CA with id [caId]
*/
const getCaCsr = async ({ caId, actorId, actorAuthMethod, actor, actorOrgId }: TGetCaCsrDTO) => {
const ca = await certificateAuthorityDAL.findById(caId);
if (!ca) throw new BadRequestError({ message: "CA not found" });
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
ca.projectId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Create,
ProjectPermissionSub.CertificateAuthorities
);
if (ca.type === CaType.ROOT) throw new BadRequestError({ message: "Root CA cannot generate CSR" });
const caCert = await certificateAuthorityCertDAL.findOne({ caId: ca.id });
if (caCert) throw new BadRequestError({ message: "CA already has a certificate installed" });
const { caPrivateKey, caPublicKey } = await getCaCredentials({
caId,
certificateAuthorityDAL,
certificateAuthoritySecretDAL,
projectDAL,
kmsService
});
const alg = keyAlgorithmToAlgCfg(ca.keyAlgorithm as CertKeyAlgorithm);
const csrObj = await x509.Pkcs10CertificateRequestGenerator.create({
name: ca.dn,
keys: {
privateKey: caPrivateKey,
publicKey: caPublicKey
},
signingAlgorithm: alg,
extensions: [
// eslint-disable-next-line no-bitwise
new x509.KeyUsagesExtension(
x509.KeyUsageFlags.keyCertSign |
x509.KeyUsageFlags.cRLSign |
x509.KeyUsageFlags.digitalSignature |
x509.KeyUsageFlags.keyEncipherment
)
],
attributes: [new x509.ChallengePasswordAttribute("password")]
});
return {
csr: csrObj.toString("pem"),
ca
};
};
/**
* Return certificate and certificate chain for CA
*/
const getCaCert = async ({ caId, actorId, actorAuthMethod, actor, actorOrgId }: TGetCaCertDTO) => {
const ca = await certificateAuthorityDAL.findById(caId);
if (!ca) throw new BadRequestError({ message: "CA not found" });
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
ca.projectId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read,
ProjectPermissionSub.CertificateAuthorities
);
const { caCert, caCertChain, serialNumber } = await getCaCertChain({
caId,
certificateAuthorityDAL,
certificateAuthorityCertDAL,
projectDAL,
kmsService
});
return {
certificate: caCert,
certificateChain: caCertChain,
serialNumber,
ca
};
};
/**
* Issue certificate to be imported back in for intermediate CA
*/
const signIntermediate = async ({
caId,
actorId,
actorAuthMethod,
actor,
actorOrgId,
csr,
notBefore,
notAfter,
maxPathLength
}: TSignIntermediateDTO) => {
const ca = await certificateAuthorityDAL.findById(caId);
if (!ca) throw new BadRequestError({ message: "CA not found" });
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
ca.projectId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Create,
ProjectPermissionSub.CertificateAuthorities
);
if (ca.status === CaStatus.DISABLED) throw new BadRequestError({ message: "CA is disabled" });
const alg = keyAlgorithmToAlgCfg(ca.keyAlgorithm as CertKeyAlgorithm);
const keyId = await getProjectKmsCertificateKeyId({
projectId: ca.projectId,
projectDAL,
kmsService
});
const caCert = await certificateAuthorityCertDAL.findOne({ caId: ca.id });
const decryptedCaCert = await kmsService.decrypt({
kmsId: keyId,
cipherTextBlob: caCert.encryptedCertificate
});
const caCertObj = new x509.X509Certificate(decryptedCaCert);
const csrObj = new x509.Pkcs10CertificateRequest(csr);
// check path length constraint
const caPathLength = caCertObj.getExtension(x509.BasicConstraintsExtension)?.pathLength;
if (caPathLength !== undefined) {
if (caPathLength === 0)
throw new BadRequestError({
message: "Failed to issue intermediate certificate due to CA path length constraint"
});
if (maxPathLength >= caPathLength || (maxPathLength === -1 && caPathLength !== -1))
throw new BadRequestError({
message: "The requested path length constraint exceeds the CA's allowed path length"
});
}
const notBeforeDate = notBefore ? new Date(notBefore) : new Date();
const notAfterDate = new Date(notAfter);
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" });
}
if (notBeforeDate > notAfterDate) throw new BadRequestError({ message: "notBefore date is after notAfter date" });
// check not after constraint
if (notAfterDate > caCertNotAfterDate) {
throw new BadRequestError({ message: "notAfter date is after CA certificate's notAfter date" });
}
const { caPrivateKey } = await getCaCredentials({
caId: ca.id,
certificateAuthorityDAL,
certificateAuthoritySecretDAL,
projectDAL,
kmsService
});
const serialNumber = crypto.randomBytes(32).toString("hex");
const intermediateCert = await x509.X509CertificateGenerator.create({
serialNumber,
subject: csrObj.subject,
issuer: caCertObj.subject,
notBefore: notBeforeDate,
notAfter: notAfterDate,
signingKey: caPrivateKey,
publicKey: csrObj.publicKey,
signingAlgorithm: alg,
extensions: [
new x509.KeyUsagesExtension(
x509.KeyUsageFlags.keyCertSign |
x509.KeyUsageFlags.cRLSign |
x509.KeyUsageFlags.digitalSignature |
x509.KeyUsageFlags.keyEncipherment,
true
),
new x509.BasicConstraintsExtension(true, maxPathLength === -1 ? undefined : maxPathLength, true),
await x509.AuthorityKeyIdentifierExtension.create(caCertObj, false),
await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey)
]
});
const { caCert: issuingCaCertificate, caCertChain } = await getCaCertChain({
caId,
certificateAuthorityDAL,
certificateAuthorityCertDAL,
projectDAL,
kmsService
});
return {
certificate: intermediateCert.toString("pem"),
issuingCaCertificate,
certificateChain: `${issuingCaCertificate}\n${caCertChain}`.trim(),
serialNumber: intermediateCert.serialNumber,
ca
};
};
/**
* Import certificate for (un-installed) CA with id [caId].
* Note: Can be used to import an external certificate and certificate chain
* to be installed into the CA.
*/
const importCertToCa = async ({
caId,
actorId,
actorAuthMethod,
actor,
actorOrgId,
certificate,
certificateChain
}: TImportCertToCaDTO) => {
const ca = await certificateAuthorityDAL.findById(caId);
if (!ca) throw new BadRequestError({ message: "CA not found" });
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
ca.projectId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Create,
ProjectPermissionSub.CertificateAuthorities
);
const caCert = await certificateAuthorityCertDAL.findOne({ caId: ca.id });
if (caCert) throw new BadRequestError({ message: "CA has already imported a certificate" });
const certObj = new x509.X509Certificate(certificate);
const maxPathLength = certObj.getExtension(x509.BasicConstraintsExtension)?.pathLength;
// validate imported certificate and certificate chain
const certificates = certificateChain
.match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g)
?.map((cert) => new x509.X509Certificate(cert));
if (!certificates) throw new BadRequestError({ message: "Failed to parse certificate chain" });
const chain = new x509.X509ChainBuilder({
certificates
});
const chainItems = await chain.build(certObj);
// chain.build() implicitly verifies the chain
if (chainItems.length !== certificates.length + 1)
throw new BadRequestError({ message: "Invalid certificate chain" });
const parentCertObj = chainItems[1];
const parentCertSubject = parentCertObj.subject;
const parentCa = await certificateAuthorityDAL.findOne({
projectId: ca.projectId,
dn: parentCertSubject
});
const keyId = await getProjectKmsCertificateKeyId({
projectId: ca.projectId,
projectDAL,
kmsService
});
const { cipherTextBlob: encryptedCertificate } = await kmsService.encrypt({
kmsId: keyId,
plainText: Buffer.from(new Uint8Array(certObj.rawData))
});
const { cipherTextBlob: encryptedCertificateChain } = await kmsService.encrypt({
kmsId: keyId,
plainText: Buffer.from(certificateChain)
});
await certificateAuthorityCertDAL.transaction(async (tx) => {
await certificateAuthorityCertDAL.create(
{
caId: ca.id,
encryptedCertificate,
encryptedCertificateChain
},
tx
);
await certificateAuthorityDAL.updateById(
ca.id,
{
status: CaStatus.ACTIVE,
maxPathLength: maxPathLength === undefined ? -1 : maxPathLength,
notBefore: new Date(certObj.notBefore),
notAfter: new Date(certObj.notAfter),
serialNumber: certObj.serialNumber,
parentCaId: parentCa?.id
},
tx
);
});
return { ca };
};
/**
* Return new leaf certificate issued by CA with id [caId]
*/
const issueCertFromCa = async ({
caId,
friendlyName,
commonName,
ttl,
notBefore,
notAfter,
actorId,
actorAuthMethod,
actor,
actorOrgId
}: TIssueCertFromCaDTO) => {
const ca = await certificateAuthorityDAL.findById(caId);
if (!ca) throw new BadRequestError({ message: "CA not found" });
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
ca.projectId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Certificates);
if (ca.status === CaStatus.DISABLED) throw new BadRequestError({ message: "CA is disabled" });
const caCert = await certificateAuthorityCertDAL.findOne({ caId: ca.id });
if (!caCert) throw new BadRequestError({ message: "CA does not have a certificate installed" });
const keyId = await getProjectKmsCertificateKeyId({
projectId: ca.projectId,
projectDAL,
kmsService
});
const decryptedCaCert = await kmsService.decrypt({
kmsId: keyId,
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" });
}
if (notBeforeDate > notAfterDate) throw new BadRequestError({ message: "notBefore date is after notAfter 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.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 } = await getCaCredentials({
caId: ca.id,
certificateAuthorityDAL,
certificateAuthoritySecretDAL,
projectDAL,
kmsService
});
const serialNumber = crypto.randomBytes(32).toString("hex");
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: [
new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature | x509.KeyUsageFlags.keyEncipherment, true),
new x509.BasicConstraintsExtension(false),
await x509.AuthorityKeyIdentifierExtension.create(caCertObj, false),
await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey)
]
});
const skLeafObj = KeyObject.from(leafKeys.privateKey);
const skLeaf = skLeafObj.export({ format: "pem", type: "pkcs8" }) as string;
const { cipherTextBlob: encryptedCertificate } = await kmsService.encrypt({
kmsId: keyId,
plainText: Buffer.from(new Uint8Array(leafCert.rawData))
});
await certificateDAL.transaction(async (tx) => {
const cert = await certificateDAL.create(
{
caId: ca.id,
status: CertStatus.ACTIVE,
friendlyName: friendlyName || commonName,
commonName,
serialNumber,
notBefore: notBeforeDate,
notAfter: notAfterDate
},
tx
);
await certificateBodyDAL.create(
{
certId: cert.id,
encryptedCertificate
},
tx
);
return cert;
});
const { caCert: issuingCaCertificate, caCertChain } = await getCaCertChain({
caId: ca.id,
certificateAuthorityDAL,
certificateAuthorityCertDAL,
projectDAL,
kmsService
});
return {
certificate: leafCert.toString("pem"),
certificateChain: `${issuingCaCertificate}\n${caCertChain}`.trim(),
issuingCaCertificate,
privateKey: skLeaf,
serialNumber,
ca
};
};
return {
createCa,
getCaById,
updateCaById,
deleteCaById,
getCaCsr,
getCaCert,
signIntermediate,
importCertToCa,
issueCertFromCa
};
};
@@ -0,0 +1,121 @@
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 { TCertificateAuthorityCrlDALFactory } from "../../ee/services/certificate-authority-crl/certificate-authority-crl-dal";
import { CertKeyAlgorithm } 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 enum CaType {
ROOT = "root",
INTERMEDIATE = "intermediate"
}
export enum CaStatus {
ACTIVE = "active",
DISABLED = "disabled",
PENDING_CERTIFICATE = "pending-certificate"
}
export type TCreateCaDTO = {
projectSlug: string;
type: CaType;
friendlyName?: string;
commonName: string;
organization: string;
ou: string;
country: string;
province: string;
locality: string;
notBefore?: string;
notAfter?: string;
maxPathLength: number;
keyAlgorithm: CertKeyAlgorithm;
} & Omit<TProjectPermission, "projectId">;
export type TGetCaDTO = {
caId: string;
} & Omit<TProjectPermission, "projectId">;
export type TUpdateCaDTO = {
caId: string;
status?: CaStatus;
} & Omit<TProjectPermission, "projectId">;
export type TDeleteCaDTO = {
caId: string;
} & Omit<TProjectPermission, "projectId">;
export type TGetCaCsrDTO = {
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;
friendlyName?: string;
commonName: string;
ttl: string;
notBefore?: string;
notAfter?: 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, "decrypt" | "generateKmsKey">;
};
export type TGetCaCertChainDTO = {
caId: string;
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findById">;
certificateAuthorityCertDAL: Pick<TCertificateAuthorityCertDALFactory, "findOne">;
projectDAL: Pick<TProjectDALFactory, "findOne" | "updateById" | "transaction">;
kmsService: Pick<TKmsServiceFactory, "decrypt" | "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" | "decrypt" | "encrypt">;
};
export type TRotateCaCrlTriggerDTO = {
caId: string;
rotationIntervalDays: number;
};
@@ -0,0 +1,8 @@
import { z } from "zod";
const isValidDate = (dateString: string) => {
const date = new Date(dateString);
return !Number.isNaN(date.getTime());
};
export const validateCaDateField = z.string().trim().refine(isValidDate, { message: "Invalid date format" });
@@ -0,0 +1,10 @@
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify } from "@app/lib/knex";
export type TCertificateBodyDALFactory = ReturnType<typeof certificateBodyDALFactory>;
export const certificateBodyDALFactory = (db: TDbClient) => {
const certificateBodyOrm = ormify(db, TableName.CertificateBody);
return certificateBodyOrm;
};
@@ -0,0 +1,34 @@
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { ormify } from "@app/lib/knex";
export type TCertificateDALFactory = ReturnType<typeof certificateDALFactory>;
export const certificateDALFactory = (db: TDbClient) => {
const certificateOrm = ormify(db, TableName.Certificate);
const countCertificatesInProject = async (projectId: string) => {
try {
interface CountResult {
count: string;
}
const count = await db(TableName.Certificate)
.join(TableName.CertificateAuthority, `${TableName.Certificate}.caId`, `${TableName.CertificateAuthority}.id`)
.join(TableName.Project, `${TableName.CertificateAuthority}.projectId`, `${TableName.Project}.id`)
.where(`${TableName.Project}.id`, projectId)
.count("*")
.first();
return parseInt((count as unknown as CountResult).count || "0", 10);
} catch (error) {
throw new DatabaseError({ error, name: "Count all project certificates" });
}
};
return {
...certificateOrm,
countCertificatesInProject
};
};
@@ -0,0 +1,26 @@
import * as x509 from "@peculiar/x509";
import { CrlReason } from "./certificate-types";
export const revocationReasonToCrlCode = (crlReason: CrlReason) => {
switch (crlReason) {
case CrlReason.KEY_COMPROMISE:
return x509.X509CrlReason.keyCompromise;
case CrlReason.CA_COMPROMISE:
return x509.X509CrlReason.cACompromise;
case CrlReason.AFFILIATION_CHANGED:
return x509.X509CrlReason.affiliationChanged;
case CrlReason.SUPERSEDED:
return x509.X509CrlReason.superseded;
case CrlReason.CESSATION_OF_OPERATION:
return x509.X509CrlReason.cessationOfOperation;
case CrlReason.CERTIFICATE_HOLD:
return x509.X509CrlReason.certificateHold;
case CrlReason.PRIVILEGE_WITHDRAWN:
return x509.X509CrlReason.privilegeWithdrawn;
case CrlReason.A_A_COMPROMISE:
return x509.X509CrlReason.aACompromise;
default:
return x509.X509CrlReason.unspecified;
}
};
@@ -0,0 +1,203 @@
import { ForbiddenError } from "@casl/ability";
import * as x509 from "@peculiar/x509";
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 { 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 { 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";
type TCertificateServiceFactoryDep = {
certificateDAL: Pick<TCertificateDALFactory, "findOne" | "deleteById" | "update" | "find">;
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "findOne">;
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findById">;
certificateAuthorityCertDAL: Pick<TCertificateAuthorityCertDALFactory, "findOne">;
certificateAuthorityCrlDAL: Pick<TCertificateAuthorityCrlDALFactory, "update">;
certificateAuthoritySecretDAL: Pick<TCertificateAuthoritySecretDALFactory, "findOne">;
projectDAL: Pick<TProjectDALFactory, "findOne" | "updateById" | "findById" | "transaction">;
kmsService: Pick<TKmsServiceFactory, "generateKmsKey" | "encrypt" | "decrypt">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
};
export type TCertificateServiceFactory = ReturnType<typeof certificateServiceFactory>;
export const certificateServiceFactory = ({
certificateDAL,
certificateBodyDAL,
certificateAuthorityDAL,
certificateAuthorityCertDAL,
certificateAuthorityCrlDAL,
certificateAuthoritySecretDAL,
projectDAL,
kmsService,
permissionService
}: TCertificateServiceFactoryDep) => {
/**
* Return details for certificate with serial number [serialNumber]
*/
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,
ca.projectId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Certificates);
return {
cert,
ca
};
};
/**
* Delete certificate with serial number [serialNumber]
*/
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,
ca.projectId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Certificates);
const deletedCert = await certificateDAL.deleteById(cert.id);
return {
deletedCert,
ca
};
};
/**
* Revoke certificate with serial number [serialNumber].
* Note: Revoking a certificate adds it to the certificate revocation list (CRL)
* of its issuing CA
*/
const revokeCert = async ({
serialNumber,
revocationReason,
actorId,
actorAuthMethod,
actor,
actorOrgId
}: TRevokeCertDTO) => {
const cert = await certificateDAL.findOne({ serialNumber });
const ca = await certificateAuthorityDAL.findById(cert.caId);
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
ca.projectId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Certificates);
if (cert.status === CertStatus.REVOKED) throw new Error("Certificate already revoked");
const revokedAt = new Date();
await certificateDAL.update(
{
id: cert.id
},
{
status: CertStatus.REVOKED,
revokedAt,
revocationReason: revocationReasonToCrlCode(revocationReason)
}
);
// rebuild CRL (TODO: move to interval-based cron job)
await rebuildCaCrl({
caId: ca.id,
certificateAuthorityDAL,
certificateAuthorityCrlDAL,
certificateAuthoritySecretDAL,
projectDAL,
certificateDAL,
kmsService
});
return { revokedAt, cert, ca };
};
/**
* Return certificate body and certificate chain for certificate with
* serial number [serialNumber]
*/
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,
ca.projectId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Certificates);
const certBody = await certificateBodyDAL.findOne({ certId: cert.id });
const keyId = await getProjectKmsCertificateKeyId({
projectId: ca.projectId,
projectDAL,
kmsService
});
const decryptedCert = await kmsService.decrypt({
kmsId: keyId,
cipherTextBlob: certBody.encryptedCertificate
});
const certObj = new x509.X509Certificate(decryptedCert);
const { caCert, caCertChain } = await getCaCertChain({
caId: ca.id,
certificateAuthorityDAL,
certificateAuthorityCertDAL,
projectDAL,
kmsService
});
return {
certificate: certObj.toString("pem"),
certificateChain: `${caCert}\n${caCertChain}`.trim(),
serialNumber: certObj.serialNumber,
cert,
ca
};
};
return {
getCert,
deleteCert,
revokeCert,
getCertBody
};
};
@@ -0,0 +1,43 @@
import { TProjectPermission } from "@app/lib/types";
export enum CertStatus {
ACTIVE = "active",
REVOKED = "revoked"
}
export enum CertKeyAlgorithm {
RSA_2048 = "RSA_2048",
RSA_4096 = "RSA_4096",
ECDSA_P256 = "EC_prime256v1",
ECDSA_P384 = "EC_secp384r1"
}
export enum CrlReason {
UNSPECIFIED = "UNSPECIFIED",
KEY_COMPROMISE = "KEY_COMPROMISE",
CA_COMPROMISE = "CA_COMPROMISE",
AFFILIATION_CHANGED = "AFFILIATION_CHANGED",
SUPERSEDED = "SUPERSEDED",
CESSATION_OF_OPERATION = "CESSATION_OF_OPERATION",
CERTIFICATE_HOLD = "CERTIFICATE_HOLD",
// REMOVE_FROM_CRL = "REMOVE_FROM_CRL",
PRIVILEGE_WITHDRAWN = "PRIVILEGE_WITHDRAWN",
A_A_COMPROMISE = "A_A_COMPROMISE"
}
export type TGetCertDTO = {
serialNumber: string;
} & Omit<TProjectPermission, "projectId">;
export type TDeleteCertDTO = {
serialNumber: string;
} & Omit<TProjectPermission, "projectId">;
export type TRevokeCertDTO = {
serialNumber: string;
revocationReason: CrlReason;
} & Omit<TProjectPermission, "projectId">;
export type TGetCertBodyDTO = {
serialNumber: string;
} & Omit<TProjectPermission, "projectId">;
@@ -18,7 +18,7 @@ import {
UpdateSecretCommand
} from "@aws-sdk/client-secrets-manager";
import { Octokit } from "@octokit/rest";
import AWS from "aws-sdk";
import AWS, { AWSError } from "aws-sdk";
import { AxiosError } from "axios";
import sodium from "libsodium-wrappers";
import isEqual from "lodash.isequal";
@@ -452,7 +452,11 @@ const syncSecretsAWSParameterStore = async ({
accessId: string | null;
accessToken: string;
}) => {
if (!accessId) return;
let response: { isSynced: boolean; syncMessage: string } | null = null;
if (!accessId) {
throw new Error("AWS access ID is required");
}
const config = new AWS.Config({
region: integration.region as string,
@@ -557,6 +561,11 @@ const syncSecretsAWSParameterStore = async ({
`AWS Parameter Store Error [integration=${integration.id}]: double check AWS account permissions (refer to the Infisical docs)`
);
}
response = {
isSynced: false,
syncMessage: (err as AWSError)?.message || "Error syncing with AWS Parameter Store"
};
}
}
}
@@ -585,6 +594,8 @@ const syncSecretsAWSParameterStore = async ({
}
}
}
return response;
};
/**
@@ -603,7 +614,9 @@ const syncSecretsAWSSecretManager = async ({
}) => {
const metadata = z.record(z.any()).parse(integration.metadata || {});
if (!accessId) return;
if (!accessId) {
throw new Error("AWS access ID is required");
}
const secretsManager = new SecretsManagerClient({
region: integration.region as string,
@@ -722,7 +735,7 @@ const syncSecretsAWSSecretManager = async ({
}
}
} catch (err) {
// case when AWS manager can't find the specified secret
// case 1: when AWS manager can't find the specified secret
if (err instanceof ResourceNotFoundException && secretsManager) {
await secretsManager.send(
new CreateSecretCommand({
@@ -734,6 +747,9 @@ const syncSecretsAWSSecretManager = async ({
: []
})
);
// case 2: something unexpected went wrong, so we'll throw the error to reflect the error in the integration sync status
} else {
throw err;
}
}
};
@@ -753,14 +769,12 @@ const syncSecretsAWSSecretManager = async ({
const syncSecretsHeroku = async ({
createManySecretsRawFn,
updateManySecretsRawFn,
integrationDAL,
integration,
secrets,
accessToken
}: {
createManySecretsRawFn: (params: TCreateManySecretsRawFn) => Promise<Array<TSecrets & { _id: string }>>;
updateManySecretsRawFn: (params: TUpdateManySecretsRawFn) => Promise<Array<TSecrets & { _id: string }>>;
integrationDAL: Pick<TIntegrationDALFactory, "updateById">;
integration: TIntegrations & {
projectId: string;
environment: {
@@ -862,10 +876,6 @@ const syncSecretsHeroku = async ({
}
}
);
await integrationDAL.updateById(integration.id, {
lastUsed: new Date()
});
};
/**
@@ -2656,7 +2666,9 @@ const syncSecretsHashiCorpVault = async ({
accessId: string | null;
accessToken: string;
}) => {
if (!accessId) return;
if (!accessId) {
throw new Error("Access ID is required");
}
interface LoginAppRoleRes {
auth: {
@@ -3486,6 +3498,8 @@ export const syncIntegrationSecrets = async ({
accessToken: string;
appendices?: { prefix: string; suffix: string };
}) => {
let response: { isSynced: boolean; syncMessage: string } | null = null;
switch (integration.integration) {
case Integrations.GCP_SECRET_MANAGER:
await syncSecretsGCPSecretManager({
@@ -3502,7 +3516,7 @@ export const syncIntegrationSecrets = async ({
});
break;
case Integrations.AWS_PARAMETER_STORE:
await syncSecretsAWSParameterStore({
response = await syncSecretsAWSParameterStore({
integration,
secrets,
accessId,
@@ -3521,7 +3535,6 @@ export const syncIntegrationSecrets = async ({
await syncSecretsHeroku({
createManySecretsRawFn,
updateManySecretsRawFn,
integrationDAL,
integration,
secrets,
accessToken
@@ -3727,4 +3740,6 @@ export const syncIntegrationSecrets = async ({
default:
throw new BadRequestError({ message: "Invalid integration" });
}
return response;
};
+12 -9
View File
@@ -29,19 +29,22 @@ export const kmsServiceFactory = ({ kmsDAL, kmsRootConfigDAL, keyStore }: TKmsSe
let ROOT_ENCRYPTION_KEY = Buffer.alloc(0);
// this is used symmetric encryption
const generateKmsKey = async ({ scopeId, scopeType, isReserved = true }: TGenerateKMSDTO) => {
const generateKmsKey = async ({ scopeId, scopeType, isReserved = true, tx }: TGenerateKMSDTO) => {
const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256);
const kmsKeyMaterial = randomSecureBytes(32);
const encryptedKeyMaterial = cipher.encrypt(kmsKeyMaterial, ROOT_ENCRYPTION_KEY);
const { encryptedKey, ...doc } = await kmsDAL.create({
version: 1,
encryptedKey: encryptedKeyMaterial,
encryptionAlgorithm: SymmetricEncryption.AES_GCM_256,
isReserved,
orgId: scopeType === "org" ? scopeId : undefined,
projectId: scopeType === "project" ? scopeId : undefined
});
const { encryptedKey, ...doc } = await kmsDAL.create(
{
version: 1,
encryptedKey: encryptedKeyMaterial,
encryptionAlgorithm: SymmetricEncryption.AES_GCM_256,
isReserved,
orgId: scopeType === "org" ? scopeId : undefined,
projectId: scopeType === "project" ? scopeId : undefined
},
tx
);
return doc;
};
+3
View File
@@ -1,7 +1,10 @@
import { Knex } from "knex";
export type TGenerateKMSDTO = {
scopeType: "project" | "org";
scopeId: string;
isReserved?: boolean;
tx?: Knex;
};
export type TEncryptWithKmsDTO = {
@@ -36,6 +36,7 @@ import {
TDeleteProjectMembershipsDTO,
TGetProjectMembershipByUsernameDTO,
TGetProjectMembershipDTO,
TLeaveProjectDTO,
TUpdateProjectMembershipDTO
} from "./project-membership-types";
import { TProjectUserMembershipRoleDALFactory } from "./project-user-membership-role-dal";
@@ -531,6 +532,53 @@ export const projectMembershipServiceFactory = ({
return memberships;
};
const leaveProject = async ({ projectId, actorId, actor }: TLeaveProjectDTO) => {
if (actor !== ActorType.USER) {
throw new BadRequestError({ message: "Only users can leave projects" });
}
const project = await projectDAL.findById(projectId);
if (!project) throw new BadRequestError({ message: "Project not found" });
if (project.version !== ProjectVersion.V2) {
throw new BadRequestError({
message: "Please ask your project administrator to upgrade the project before leaving."
});
}
const projectMembers = await projectMembershipDAL.findAllProjectMembers(projectId);
if (!projectMembers?.length) {
throw new BadRequestError({ message: "Failed to find project members" });
}
if (projectMembers.length < 2) {
throw new BadRequestError({ message: "You cannot leave the project as you are the only member" });
}
const adminMembers = projectMembers.filter(
(member) => member.roles.map((r) => r.role).includes("admin") && member.userId !== actorId
);
if (!adminMembers.length) {
throw new BadRequestError({
message: "You cannot leave the project as you are the only admin. Promote another user to admin before leaving."
});
}
const deletedMembership = (
await projectMembershipDAL.delete({
projectId: project.id,
userId: actorId
})
)?.[0];
if (!deletedMembership) {
throw new BadRequestError({ message: "Failed to leave project" });
}
return deletedMembership;
};
return {
getProjectMemberships,
getProjectMembershipByUsername,
@@ -538,6 +586,7 @@ export const projectMembershipServiceFactory = ({
addUsersToProjectNonE2EE,
deleteProjectMemberships,
deleteProjectMembership, // TODO: Remove this
addUsersToProject
addUsersToProject,
leaveProject
};
};
@@ -1,6 +1,7 @@
import { TProjectPermission } from "@app/lib/types";
export type TGetProjectMembershipDTO = TProjectPermission;
export type TLeaveProjectDTO = Omit<TProjectPermission, "actorOrgId" | "actorAuthMethod">;
export enum ProjectUserMembershipTemporaryMode {
Relative = "relative"
}
@@ -1,6 +1,9 @@
import crypto from "crypto";
import { decryptAsymmetric, encryptAsymmetric } from "@app/lib/crypto";
import { BadRequestError } from "@app/lib/errors";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TProjectDALFactory } from "@app/services/project/project-dal";
import { AddUserToWsDTO } from "./project-types";
@@ -49,3 +52,44 @@ export const createProjectKey = ({ publicKey, privateKey, plainProjectKey }: TCr
return { key: encryptedProjectKey, iv: encryptedProjectKeyIv };
};
export const getProjectKmsCertificateKeyId = async ({
projectId,
projectDAL,
kmsService
}: {
projectId: string;
projectDAL: Pick<TProjectDALFactory, "findOne" | "updateById" | "transaction">;
kmsService: Pick<TKmsServiceFactory, "generateKmsKey">;
}) => {
const keyId = await projectDAL.transaction(async (tx) => {
const project = await projectDAL.findOne({ id: projectId }, tx);
if (!project) {
throw new BadRequestError({ message: "Project not found" });
}
if (!project.kmsCertificateKeyId) {
// create default kms key for certificate service
const key = await kmsService.generateKmsKey({
scopeId: projectId,
scopeType: "project",
isReserved: true,
tx
});
await projectDAL.updateById(
projectId,
{
kmsCertificateKeyId: key.id
},
tx
);
return key.id;
}
return project.kmsCertificateKeyId;
});
return keyId;
};
@@ -16,6 +16,8 @@ import { alphaNumericNanoId } from "@app/lib/nanoid";
import { TProjectPermission } from "@app/lib/types";
import { ActorType } from "../auth/auth-type";
import { TCertificateDALFactory } from "../certificate/certificate-dal";
import { TCertificateAuthorityDALFactory } from "../certificate-authority/certificate-authority-dal";
import { TIdentityOrgDALFactory } from "../identity/identity-org-dal";
import { TIdentityProjectDALFactory } from "../identity-project/identity-project-dal";
import { TIdentityProjectMembershipRoleDALFactory } from "../identity-project/identity-project-membership-role-dal";
@@ -36,6 +38,8 @@ import {
TCreateProjectDTO,
TDeleteProjectDTO,
TGetProjectDTO,
TListProjectCasDTO,
TListProjectCertsDTO,
TToggleProjectAutoCapitalizationDTO,
TUpdateProjectDTO,
TUpdateProjectNameDTO,
@@ -50,6 +54,7 @@ export const DEFAULT_PROJECT_ENVS = [
];
type TProjectServiceFactoryDep = {
// TODO: Pick
projectDAL: TProjectDALFactory;
projectQueue: TProjectQueueFactory;
userDAL: TUserDALFactory;
@@ -63,6 +68,8 @@ type TProjectServiceFactoryDep = {
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "create" | "findProjectGhostUser" | "findOne">;
projectUserMembershipRoleDAL: Pick<TProjectUserMembershipRoleDALFactory, "create">;
secretBlindIndexDAL: Pick<TSecretBlindIndexDALFactory, "create">;
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "find">;
certificateDAL: Pick<TCertificateDALFactory, "find" | "countCertificatesInProject">;
permissionService: TPermissionServiceFactory;
orgService: Pick<TOrgServiceFactory, "addGhostUser">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
@@ -90,6 +97,8 @@ export const projectServiceFactory = ({
licenseService,
projectUserMembershipRoleDAL,
identityProjectMembershipRoleDAL,
certificateAuthorityDAL,
certificateDAL,
keyStore
}: TProjectServiceFactoryDep) => {
/*
@@ -523,6 +532,83 @@ export const projectServiceFactory = ({
return project.upgradeStatus || null;
};
/**
* Return list of CAs for project
*/
const listProjectCas = async ({
status,
actorId,
actorOrgId,
actorAuthMethod,
filter,
actor
}: TListProjectCasDTO) => {
const project = await projectDAL.findProjectByFilter(filter);
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
project.id,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read,
ProjectPermissionSub.CertificateAuthorities
);
const cas = await certificateAuthorityDAL.find({
projectId: project.id,
...(status && { status })
});
return cas;
};
/**
* Return list of certificates for project
*/
const listProjectCertificates = async ({
offset,
limit,
actorId,
actorOrgId,
actorAuthMethod,
filter,
actor
}: TListProjectCertsDTO) => {
const project = await projectDAL.findProjectByFilter(filter);
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
project.id,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Certificates);
const cas = await certificateAuthorityDAL.find({ projectId: project.id });
const certificates = await certificateDAL.find(
{
$in: {
caId: cas.map((ca) => ca.id)
}
},
{ offset, limit, sort: [["updatedAt", "desc"]] }
);
const count = await certificateDAL.countCertificatesInProject(project.id);
return {
certificates,
totalCount: count
};
};
return {
createProject,
deleteProject,
@@ -533,6 +619,8 @@ export const projectServiceFactory = ({
toggleAutoCapitalization,
updateName,
upgradeProject,
listProjectCas,
listProjectCertificates,
updateVersionLimit
};
};
@@ -2,6 +2,7 @@ import { ProjectMembershipRole, TProjectKeys } from "@app/db/schemas";
import { TProjectPermission } from "@app/lib/types";
import { ActorAuthMethod, ActorType } from "../auth/auth-type";
import { CaStatus } from "../certificate-authority/certificate-authority-types";
export enum ProjectFilterType {
ID = "id",
@@ -80,3 +81,14 @@ export type AddUserToWsDTO = {
userPublicKey: string;
}[];
};
export type TListProjectCasDTO = {
status?: CaStatus;
filter: Filter;
} & Omit<TProjectPermission, "projectId">;
export type TListProjectCertsDTO = {
filter: Filter;
offset: number;
limit: number;
} & Omit<TProjectPermission, "projectId">;
@@ -1,7 +0,0 @@
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify } from "@app/lib/knex";
export type TRateLimitDALFactory = ReturnType<typeof rateLimitDALFactory>;
export const rateLimitDALFactory = (db: TDbClient) => ormify(db, TableName.RateLimit, {});
@@ -1,95 +0,0 @@
import { CronJob } from "cron";
import { logger } from "@app/lib/logger";
import { TRateLimitDALFactory } from "./rate-limit-dal";
import { TRateLimit, TRateLimitUpdateDTO } from "./rate-limit-types";
let rateLimitMaxConfiguration = {
readLimit: 60,
publicEndpointLimit: 30,
writeLimit: 200,
secretsLimit: 60,
authRateLimit: 60,
inviteUserRateLimit: 30,
mfaRateLimit: 20,
creationLimit: 30
};
Object.freeze(rateLimitMaxConfiguration);
export const getRateLimiterConfig = () => {
return rateLimitMaxConfiguration;
};
type TRateLimitServiceFactoryDep = {
rateLimitDAL: TRateLimitDALFactory;
};
export type TRateLimitServiceFactory = ReturnType<typeof rateLimitServiceFactory>;
export const rateLimitServiceFactory = ({ rateLimitDAL }: TRateLimitServiceFactoryDep) => {
const DEFAULT_RATE_LIMIT_CONFIG_ID = "00000000-0000-0000-0000-000000000000";
const getRateLimits = async (): Promise<TRateLimit | undefined> => {
let rateLimit: TRateLimit;
try {
rateLimit = await rateLimitDAL.findOne({ id: DEFAULT_RATE_LIMIT_CONFIG_ID });
if (!rateLimit) {
// rate limit might not exist
rateLimit = await rateLimitDAL.create({
// @ts-expect-error id is kept as fixed because there should only be one rate limit config per instance
id: DEFAULT_RATE_LIMIT_CONFIG_ID
});
}
return rateLimit;
} catch (err) {
logger.error("Error fetching rate limits %o", err);
return undefined;
}
};
const updateRateLimit = async (updates: TRateLimitUpdateDTO): Promise<TRateLimit> => {
return rateLimitDAL.updateById(DEFAULT_RATE_LIMIT_CONFIG_ID, updates);
};
const syncRateLimitConfiguration = async () => {
try {
const rateLimit = await getRateLimits();
if (rateLimit) {
const newRateLimitMaxConfiguration: typeof rateLimitMaxConfiguration = {
readLimit: rateLimit.readRateLimit,
publicEndpointLimit: rateLimit.publicEndpointLimit,
writeLimit: rateLimit.writeRateLimit,
secretsLimit: rateLimit.secretsRateLimit,
authRateLimit: rateLimit.authRateLimit,
inviteUserRateLimit: rateLimit.inviteUserRateLimit,
mfaRateLimit: rateLimit.mfaRateLimit,
creationLimit: rateLimit.creationLimit
};
logger.info(`syncRateLimitConfiguration: rate limit configuration: %o`, newRateLimitMaxConfiguration);
Object.freeze(newRateLimitMaxConfiguration);
rateLimitMaxConfiguration = newRateLimitMaxConfiguration;
}
} catch (error) {
logger.error(`Error syncing rate limit configurations: %o`, error);
}
};
const initializeBackgroundSync = () => {
// sync rate limits configuration every 10 minutes
const job = new CronJob("*/10 * * * *", syncRateLimitConfiguration);
job.start();
return job;
};
return {
getRateLimits,
updateRateLimit,
initializeBackgroundSync,
syncRateLimitConfiguration
};
};
@@ -1,16 +0,0 @@
export type TRateLimitUpdateDTO = {
readRateLimit: number;
writeRateLimit: number;
secretsRateLimit: number;
authRateLimit: number;
inviteUserRateLimit: number;
mfaRateLimit: number;
creationLimit: number;
publicEndpointLimit: number;
};
export type TRateLimit = {
id: string;
createdAt: Date;
updatedAt: Date;
} & TRateLimitUpdateDTO;
@@ -1,8 +1,13 @@
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { UnauthorizedError } from "@app/lib/errors";
import { BadRequestError, UnauthorizedError } from "@app/lib/errors";
import { TSecretSharingDALFactory } from "./secret-sharing-dal";
import { TCreateSharedSecretDTO, TDeleteSharedSecretDTO, TSharedSecretPermission } from "./secret-sharing-types";
import {
TCreatePublicSharedSecretDTO,
TCreateSharedSecretDTO,
TDeleteSharedSecretDTO,
TSharedSecretPermission
} from "./secret-sharing-types";
type TSecretSharingServiceFactoryDep = {
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
@@ -31,6 +36,24 @@ export const secretSharingServiceFactory = ({
} = createSharedSecretInput;
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
if (!permission) throw new UnauthorizedError({ name: "User not in org" });
if (new Date(expiresAt) < new Date()) {
throw new BadRequestError({ message: "Expiration date cannot be in the past" });
}
// Limit Expiry Time to 1 month
const expiryTime = new Date(expiresAt).getTime();
const currentTime = new Date().getTime();
const thirtyDays = 30 * 24 * 60 * 60 * 1000;
if (expiryTime - currentTime > thirtyDays) {
throw new BadRequestError({ message: "Expiration date cannot be more than 30 days" });
}
// Limit Input ciphertext length to 13000 (equivalent to 10,000 characters of Plaintext)
if (encryptedValue.length > 13000) {
throw new BadRequestError({ message: "Shared secret value too long" });
}
const newSharedSecret = await secretSharingDAL.create({
encryptedValue,
iv,
@@ -44,6 +67,36 @@ export const secretSharingServiceFactory = ({
return { id: newSharedSecret.id };
};
const createPublicSharedSecret = async (createSharedSecretInput: TCreatePublicSharedSecretDTO) => {
const { encryptedValue, iv, tag, hashedHex, expiresAt, expiresAfterViews } = createSharedSecretInput;
if (new Date(expiresAt) < new Date()) {
throw new BadRequestError({ message: "Expiration date cannot be in the past" });
}
// Limit Expiry Time to 1 month
const expiryTime = new Date(expiresAt).getTime();
const currentTime = new Date().getTime();
const thirtyDays = 30 * 24 * 60 * 60 * 1000;
if (expiryTime - currentTime > thirtyDays) {
throw new BadRequestError({ message: "Expiration date cannot exceed more than 30 days" });
}
// Limit Input ciphertext length to 13000 (equivalent to 10,000 characters of Plaintext)
if (encryptedValue.length > 13000) {
throw new BadRequestError({ message: "Shared secret value too long" });
}
const newSharedSecret = await secretSharingDAL.create({
encryptedValue,
iv,
tag,
hashedHex,
expiresAt,
expiresAfterViews
});
return { id: newSharedSecret.id };
};
const getSharedSecrets = async (getSharedSecretsInput: TSharedSecretPermission) => {
const { actor, actorId, orgId, actorAuthMethod, actorOrgId } = getSharedSecretsInput;
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
@@ -54,6 +107,7 @@ export const secretSharingServiceFactory = ({
const getActiveSharedSecretByIdAndHashedHex = async (sharedSecretId: string, hashedHex: string) => {
const sharedSecret = await secretSharingDAL.findOne({ id: sharedSecretId, hashedHex });
if (!sharedSecret) return;
if (sharedSecret.expiresAt && sharedSecret.expiresAt < new Date()) {
return;
}
@@ -77,6 +131,7 @@ export const secretSharingServiceFactory = ({
return {
createSharedSecret,
createPublicSharedSecret,
getSharedSecrets,
deleteSharedSecretById,
getActiveSharedSecretByIdAndHashedHex
@@ -8,14 +8,16 @@ export type TSharedSecretPermission = {
orgId: string;
};
export type TCreateSharedSecretDTO = {
export type TCreatePublicSharedSecretDTO = {
encryptedValue: string;
iv: string;
tag: string;
hashedHex: string;
expiresAt: Date;
expiresAfterViews: number;
} & TSharedSecretPermission;
};
export type TCreateSharedSecretDTO = TSharedSecretPermission & TCreatePublicSharedSecretDTO;
export type TDeleteSharedSecretDTO = {
sharedSecretId: string;
@@ -2,10 +2,17 @@ import { ForbiddenError } from "@casl/ability";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
import { BadRequestError } from "@app/lib/errors";
import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { TSecretTagDALFactory } from "./secret-tag-dal";
import { TCreateTagDTO, TDeleteTagDTO, TListProjectTagsDTO } from "./secret-tag-types";
import {
TCreateTagDTO,
TDeleteTagDTO,
TGetTagByIdDTO,
TGetTagBySlugDTO,
TListProjectTagsDTO,
TUpdateTagDTO
} from "./secret-tag-types";
type TSecretTagServiceFactoryDep = {
secretTagDAL: TSecretTagDALFactory;
@@ -42,11 +49,34 @@ export const secretTagServiceFactory = ({ secretTagDAL, permissionService }: TSe
name,
slug,
color,
createdBy: actorId
createdBy: actorId,
createdByActorType: actor
});
return newTag;
};
const updateTag = async ({ actorId, actor, actorOrgId, actorAuthMethod, id, name, color, slug }: TUpdateTagDTO) => {
const tag = await secretTagDAL.findById(id);
if (!tag) throw new BadRequestError({ message: "Tag doesn't exist" });
if (slug) {
const existingTag = await secretTagDAL.findOne({ slug, projectId: tag.projectId });
if (existingTag && existingTag.id !== tag.id) throw new BadRequestError({ message: "Tag already exist" });
}
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
tag.projectId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Tags);
const updatedTag = await secretTagDAL.updateById(tag.id, { name, color, slug });
return updatedTag;
};
const deleteTag = async ({ actorId, actor, actorOrgId, actorAuthMethod, id }: TDeleteTagDTO) => {
const tag = await secretTagDAL.findById(id);
if (!tag) throw new BadRequestError({ message: "Tag doesn't exist" });
@@ -64,6 +94,38 @@ export const secretTagServiceFactory = ({ secretTagDAL, permissionService }: TSe
return deletedTag;
};
const getTagById = async ({ actorId, actor, actorOrgId, actorAuthMethod, id }: TGetTagByIdDTO) => {
const tag = await secretTagDAL.findById(id);
if (!tag) throw new NotFoundError({ message: "Tag doesn't exist" });
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
tag.projectId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Tags);
return tag;
};
const getTagBySlug = async ({ actorId, actor, actorOrgId, actorAuthMethod, slug, projectId }: TGetTagBySlugDTO) => {
const tag = await secretTagDAL.findOne({ projectId, slug });
if (!tag) throw new NotFoundError({ message: "Tag doesn't exist" });
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
tag.projectId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Tags);
return tag;
};
const getProjectTags = async ({ actor, actorId, actorOrgId, actorAuthMethod, projectId }: TListProjectTagsDTO) => {
const { permission } = await permissionService.getProjectPermission(
actor,
@@ -78,5 +140,5 @@ export const secretTagServiceFactory = ({ secretTagDAL, permissionService }: TSe
return tags;
};
return { createTag, deleteTag, getProjectTags };
return { createTag, deleteTag, getProjectTags, getTagById, getTagBySlug, updateTag };
};
@@ -6,6 +6,21 @@ export type TCreateTagDTO = {
slug: string;
} & TProjectPermission;
export type TUpdateTagDTO = {
id: string;
name?: string;
slug?: string;
color?: string;
} & Omit<TProjectPermission, "projectId">;
export type TGetTagByIdDTO = {
id: string;
} & Omit<TProjectPermission, "projectId">;
export type TGetTagBySlugDTO = {
slug: string;
} & TProjectPermission;
export type TDeleteTagDTO = {
id: string;
} & Omit<TProjectPermission, "projectId">;
+35
View File
@@ -311,6 +311,40 @@ export const secretDALFactory = (db: TDbClient) => {
}
};
const findOneWithTags = async (filter: Partial<TSecrets>, tx?: Knex) => {
try {
const rawDocs = await (tx || db)(TableName.Secret)
.where(filter)
.leftJoin(TableName.JnSecretTag, `${TableName.Secret}.id`, `${TableName.JnSecretTag}.${TableName.Secret}Id`)
.leftJoin(TableName.SecretTag, `${TableName.JnSecretTag}.${TableName.SecretTag}Id`, `${TableName.SecretTag}.id`)
.select(selectAllTableCols(TableName.Secret))
.select(db.ref("id").withSchema(TableName.SecretTag).as("tagId"))
.select(db.ref("color").withSchema(TableName.SecretTag).as("tagColor"))
.select(db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug"))
.select(db.ref("name").withSchema(TableName.SecretTag).as("tagName"));
const docs = sqlNestRelationships({
data: rawDocs,
key: "id",
parentMapper: (el) => ({ _id: el.id, ...SecretsSchema.parse(el) }),
childrenMapper: [
{
key: "tagId",
label: "tags" as const,
mapper: ({ tagId: id, tagColor: color, tagSlug: slug, tagName: name }) => ({
id,
color,
slug,
name
})
}
]
});
return docs?.[0];
} catch (error) {
throw new DatabaseError({ error, name: "FindOneWIthTags" });
}
};
return {
...secretOrm,
update,
@@ -318,6 +352,7 @@ export const secretDALFactory = (db: TDbClient) => {
deleteMany,
bulkUpdateNoVersionIncrement,
getSecretTags,
findOneWithTags,
findByFolderId,
findByFolderIds,
findByBlindIndexes,
+12 -1
View File
@@ -356,7 +356,17 @@ export const interpolateSecrets = ({ projectId, secretEncKey, secretDAL, folderD
};
export const decryptSecretRaw = (
secret: TSecrets & { workspace: string; environment: string; secretPath: string },
secret: TSecrets & {
workspace: string;
environment: string;
secretPath: string;
tags?: {
id: string;
slug: string;
color?: string | null;
name: string;
}[];
},
key: string
) => {
const secretKey = decryptSymmetric128BitHexKeyUTF8({
@@ -396,6 +406,7 @@ export const decryptSecretRaw = (
_id: secret.id,
id: secret.id,
user: secret.userId,
tags: secret.tags,
skipMultilineEncoding: secret.skipMultilineEncoding
};
};
+84 -88
View File
@@ -421,94 +421,88 @@ export const secretQueueFactory = ({
const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
if (!folder) {
logger.error(new Error("Secret path not found"));
return;
throw new Error("Secret path not found");
}
// start syncing all linked imports also
if (depth < MAX_SYNC_SECRET_DEPTH) {
// find all imports made with the given environment and secret path
const linkSourceDto = {
projectId,
importEnv: folder.environment.id,
importPath: secretPath,
isReplication: false
};
const imports = await secretImportDAL.find(linkSourceDto);
// find all imports made with the given environment and secret path
const linkSourceDto = {
projectId,
importEnv: folder.environment.id,
importPath: secretPath,
isReplication: false
};
const imports = await secretImportDAL.find(linkSourceDto);
if (imports.length) {
// keep calling sync secret for all the imports made
const importedFolderIds = unique(imports, (i) => i.folderId).map(({ folderId }) => folderId);
const importedFolders = await folderDAL.findSecretPathByFolderIds(projectId, importedFolderIds);
const foldersGroupedById = groupBy(importedFolders.filter(Boolean), (i) => i?.id as string);
logger.info(
`getIntegrationSecrets: Syncing secret due to link change [jobId=${job.id}] [projectId=${job.data.projectId}] [environment=${job.data.environment}] [secretPath=${job.data.secretPath}] [depth=${depth}]`
);
await Promise.all(
imports
.filter(({ folderId }) => Boolean(foldersGroupedById[folderId][0]?.path as string))
// filter out already synced ones
.filter(
({ folderId }) =>
!deDupeQueue[
uniqueSecretQueueKey(
foldersGroupedById[folderId][0]?.environmentSlug as string,
foldersGroupedById[folderId][0]?.path as string
)
]
)
.map(({ folderId }) =>
syncSecrets({
projectId,
secretPath: foldersGroupedById[folderId][0]?.path as string,
environmentSlug: foldersGroupedById[folderId][0]?.environmentSlug as string,
_deDupeQueue: deDupeQueue,
_depth: depth + 1,
excludeReplication: true
})
)
);
}
if (imports.length) {
// keep calling sync secret for all the imports made
const importedFolderIds = unique(imports, (i) => i.folderId).map(({ folderId }) => folderId);
const importedFolders = await folderDAL.findSecretPathByFolderIds(projectId, importedFolderIds);
const foldersGroupedById = groupBy(importedFolders.filter(Boolean), (i) => i?.id as string);
logger.info(
`getIntegrationSecrets: Syncing secret due to link change [jobId=${job.id}] [projectId=${job.data.projectId}] [environment=${job.data.environment}] [secretPath=${job.data.secretPath}] [depth=${depth}]`
);
await Promise.all(
imports
.filter(({ folderId }) => Boolean(foldersGroupedById[folderId][0]?.path as string))
// filter out already synced ones
.filter(
({ folderId }) =>
!deDupeQueue[
uniqueSecretQueueKey(
foldersGroupedById[folderId][0]?.environmentSlug as string,
foldersGroupedById[folderId][0]?.path as string
)
]
)
.map(({ folderId }) =>
syncSecrets({
projectId,
secretPath: foldersGroupedById[folderId][0]?.path as string,
environmentSlug: foldersGroupedById[folderId][0]?.environmentSlug as string,
_deDupeQueue: deDupeQueue,
_depth: depth + 1,
excludeReplication: true
})
)
);
}
const secretReferences = await secretDAL.findReferencedSecretReferences(
projectId,
folder.environment.slug,
secretPath
const secretReferences = await secretDAL.findReferencedSecretReferences(
projectId,
folder.environment.slug,
secretPath
);
if (secretReferences.length) {
const referencedFolderIds = unique(secretReferences, (i) => i.folderId).map(({ folderId }) => folderId);
const referencedFolders = await folderDAL.findSecretPathByFolderIds(projectId, referencedFolderIds);
const referencedFoldersGroupedById = groupBy(referencedFolders.filter(Boolean), (i) => i?.id as string);
logger.info(
`getIntegrationSecrets: Syncing secret due to reference change [jobId=${job.id}] [projectId=${job.data.projectId}] [environment=${job.data.environment}] [secretPath=${job.data.secretPath}] [depth=${depth}]`
);
await Promise.all(
secretReferences
.filter(({ folderId }) => Boolean(referencedFoldersGroupedById[folderId][0]?.path))
// filter out already synced ones
.filter(
({ folderId }) =>
!deDupeQueue[
uniqueSecretQueueKey(
referencedFoldersGroupedById[folderId][0]?.environmentSlug as string,
referencedFoldersGroupedById[folderId][0]?.path as string
)
]
)
.map(({ folderId }) =>
syncSecrets({
projectId,
secretPath: referencedFoldersGroupedById[folderId][0]?.path as string,
environmentSlug: referencedFoldersGroupedById[folderId][0]?.environmentSlug as string,
_deDupeQueue: deDupeQueue,
_depth: depth + 1,
excludeReplication: true
})
)
);
if (secretReferences.length) {
const referencedFolderIds = unique(secretReferences, (i) => i.folderId).map(({ folderId }) => folderId);
const referencedFolders = await folderDAL.findSecretPathByFolderIds(projectId, referencedFolderIds);
const referencedFoldersGroupedById = groupBy(referencedFolders.filter(Boolean), (i) => i?.id as string);
logger.info(
`getIntegrationSecrets: Syncing secret due to reference change [jobId=${job.id}] [projectId=${job.data.projectId}] [environment=${job.data.environment}] [secretPath=${job.data.secretPath}] [depth=${depth}]`
);
await Promise.all(
secretReferences
.filter(({ folderId }) => Boolean(referencedFoldersGroupedById[folderId][0]?.path))
// filter out already synced ones
.filter(
({ folderId }) =>
!deDupeQueue[
uniqueSecretQueueKey(
referencedFoldersGroupedById[folderId][0]?.environmentSlug as string,
referencedFoldersGroupedById[folderId][0]?.path as string
)
]
)
.map(({ folderId }) =>
syncSecrets({
projectId,
secretPath: referencedFoldersGroupedById[folderId][0]?.path as string,
environmentSlug: referencedFoldersGroupedById[folderId][0]?.environmentSlug as string,
_deDupeQueue: deDupeQueue,
_depth: depth + 1,
excludeReplication: true
})
)
);
}
} else {
logger.info(`getIntegrationSecrets: Secret depth exceeded for [projectId=${projectId}] [folderId=${folder.id}]`);
}
const integrations = await integrationDAL.findByProjectIdV2(projectId, environment); // note: returns array of integrations + integration auths in this environment
@@ -550,7 +544,7 @@ export const secretQueueFactory = ({
}
try {
await syncIntegrationSecrets({
const response = await syncIntegrationSecrets({
createManySecretsRawFn,
updateManySecretsRawFn,
integrationDAL,
@@ -568,13 +562,15 @@ export const secretQueueFactory = ({
await integrationDAL.updateById(integration.id, {
lastSyncJobId: job.id,
lastUsed: new Date(),
syncMessage: "",
isSynced: true
syncMessage: response?.syncMessage ?? "",
isSynced: response?.isSynced ?? true
});
} catch (err: unknown) {
} catch (err) {
logger.info("Secret integration sync error: %o", err);
const message =
err instanceof AxiosError ? JSON.stringify((err as AxiosError)?.response?.data) : (err as Error)?.message;
(err instanceof AxiosError ? JSON.stringify(err?.response?.data) : (err as Error)?.message) ||
"Unknown error occurred.";
await integrationDAL.updateById(integration.id, {
lastSyncJobId: job.id,
@@ -608,7 +608,7 @@ export const secretServiceFactory = ({
}
const secret = await (version === undefined
? secretDAL.findOne({
? secretDAL.findOneWithTags({
folderId,
type: secretType,
userId: secretType === SecretType.Personal ? actorId : null,
@@ -1120,7 +1120,8 @@ export const secretServiceFactory = ({
secretPath,
secretValue,
secretComment,
skipMultilineEncoding
skipMultilineEncoding,
tagIds
}: TCreateSecretRawDTO) => {
const botKey = await projectBotService.getBotKey(projectId);
if (!botKey) throw new BadRequestError({ message: "Project bot not found", name: "bot_not_found_error" });
@@ -1148,7 +1149,8 @@ export const secretServiceFactory = ({
secretCommentCiphertext: secretCommentEncrypted.ciphertext,
secretCommentIV: secretCommentEncrypted.iv,
secretCommentTag: secretCommentEncrypted.tag,
skipMultilineEncoding
skipMultilineEncoding,
tags: tagIds
});
return decryptSecretRaw(secret, botKey);
@@ -1165,7 +1167,8 @@ export const secretServiceFactory = ({
type,
secretPath,
secretValue,
skipMultilineEncoding
skipMultilineEncoding,
tagIds
}: TUpdateSecretRawDTO) => {
const botKey = await projectBotService.getBotKey(projectId);
if (!botKey) throw new BadRequestError({ message: "Project bot not found", name: "bot_not_found_error" });
@@ -1185,7 +1188,8 @@ export const secretServiceFactory = ({
secretValueCiphertext: secretValueEncrypted.ciphertext,
secretValueIV: secretValueEncrypted.iv,
secretValueTag: secretValueEncrypted.tag,
skipMultilineEncoding
skipMultilineEncoding,
tags: tagIds
});
await snapshotService.performSnapshot(secret.folderId);
@@ -164,6 +164,7 @@ export type TCreateSecretRawDTO = TProjectPermission & {
secretName: string;
secretValue: string;
type: SecretType;
tagIds?: string[];
secretComment?: string;
skipMultilineEncoding?: boolean;
};
@@ -174,6 +175,7 @@ export type TUpdateSecretRawDTO = TProjectPermission & {
secretName: string;
secretValue?: string;
type: SecretType;
tagIds?: string[];
skipMultilineEncoding?: boolean;
secretReminderRepeatDays?: number | null;
secretReminderNote?: string | null;