Finish preliminary loop on SSH certificates

This commit is contained in:
Tuan Dang
2024-12-02 22:37:23 -08:00
parent 82c3e943eb
commit 4fc8c509ac
65 changed files with 3685 additions and 26 deletions
@@ -0,0 +1,10 @@
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify } from "@app/lib/knex";
export type TSshCertificateAuthorityDALFactory = ReturnType<typeof sshCertificateAuthorityDALFactory>;
export const sshCertificateAuthorityDALFactory = (db: TDbClient) => {
const sshCaOrm = ormify(db, TableName.SshCertificateAuthority);
return sshCaOrm;
};
@@ -0,0 +1,198 @@
import { execSync } from "child_process";
import crypto from "crypto";
import fs from "fs";
import ms from "ms";
import { TSshCertificateTemplates } from "@app/db/schemas";
import { BadRequestError } from "@app/lib/errors";
import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types";
import {
isValidHostPattern,
isValidUserPattern
} from "../ssh-certificate-template/ssh-certificate-template-validators";
import { SshCertType, TCreateSshCertDTO } from "./ssh-certificate-authority-types";
/* eslint-disable no-bitwise */
export const createSshCertSerialNumber = () => {
const randomBytes = crypto.randomBytes(8); // 8 bytes = 64 bits
randomBytes[0] &= 0x7f; // Ensure the most significant bit is 0 (to stay within unsigned range)
return BigInt(`0x${randomBytes.toString("hex")}`).toString(10); // Convert to decimal
};
/**
* Return a pair of SSH CA keys based on the specified key algorithm [keyAlgorithm].
* We use this function because the key format generated by `ssh-keygen` is unique.
*/
export const createSshKeyPair = (keyAlgorithm: CertKeyAlgorithm, comment: string) => {
const uniqueId = crypto.randomBytes(8).toString("hex"); // to avoid collions if high-volume key generation
const privateKeyFile = `ssh_key_${uniqueId}`; // temp key path
const publicKeyFile = `${privateKeyFile}.pub`;
if (fs.existsSync(publicKeyFile)) fs.unlinkSync(publicKeyFile);
if (fs.existsSync(privateKeyFile)) fs.unlinkSync(privateKeyFile);
let keyType = "";
let keyBits = "";
switch (keyAlgorithm) {
case CertKeyAlgorithm.RSA_2048:
keyType = "rsa";
keyBits = "2048";
break;
case CertKeyAlgorithm.RSA_4096:
keyType = "rsa";
keyBits = "4096";
break;
case CertKeyAlgorithm.ECDSA_P256:
keyType = "ecdsa";
keyBits = "256";
break;
case CertKeyAlgorithm.ECDSA_P384:
keyType = "ecdsa";
keyBits = "384";
break;
default:
throw new Error("Failed to produce SSH CA key pair generation command due to unrecognized key algorithm");
}
execSync(`ssh-keygen -t ${keyType} -b ${keyBits} -f ${privateKeyFile} -N '' -C "${comment}"`);
const publicKey = fs.readFileSync(publicKeyFile, "utf8");
const privateKey = fs.readFileSync(privateKeyFile, "utf8");
fs.unlinkSync(privateKeyFile);
fs.unlinkSync(publicKeyFile);
return { publicKey, privateKey };
};
/**
* Validate the requested SSH certificate type based on the SSH certificate template configuration.
* @param template - The SSH certificate template configuration
* @param certType - The SSH certificate type
*/
export const validateSshCertificateType = (template: TSshCertificateTemplates, certType: SshCertType) => {
if (!template.allowUserCertificates && certType === SshCertType.USER) {
throw new BadRequestError({ message: "Failed to validate user certificate type due to template restriction" });
}
if (!template.allowHostCertificates && certType === SshCertType.HOST) {
throw new BadRequestError({ message: "Failed to validate host certificate type due to template restriction" });
}
};
/**
* Validate the requested SSH certificate principals based on the SSH certificate template configuration.
* @param certType - The SSH certificate type
* @param template - The SSH certificate template configuration
* @param principals - The requested SSH certificate principals
* @returns The validated SSH certificate principals
*/
export const validateSshCertificatePrincipals = (
certType: SshCertType,
template: TSshCertificateTemplates,
principals: string[]
) => {
switch (certType) {
case SshCertType.USER: {
const allowsAllUsers = template.allowedUsers?.includes("*") ?? false;
return principals.every((principal) => {
if (principal === "*") return false;
if (allowsAllUsers) return isValidUserPattern(principal);
return template.allowedUsers?.includes(principal);
});
}
case SshCertType.HOST: {
const allowsAllHosts = template.allowedHosts?.includes("*") ?? false;
return principals.every((principal) => {
if (principal.includes("*")) return false;
if (allowsAllHosts) return isValidHostPattern(principal);
// Validate against allowed domains
return (
isValidHostPattern(principal) &&
template.allowedHosts?.some((allowedHost) => {
if (allowedHost.startsWith("*.")) {
// Match subdomains of a wildcard domain
const baseDomain = allowedHost.slice(2); // Remove the leading "*."
return principal.endsWith(`.${baseDomain}`);
}
// Exact match for non-wildcard domains
return principal === allowedHost;
})
);
});
}
default:
throw new BadRequestError({
message: "Failed to validate SSH certificate principals due to unrecognized requested certificate type"
});
}
};
/**
* Validate the requested SSH certificate TTL based on the SSH certificate template configuration.
* @param template - The SSH certificate template configuration
* @param ttl - The TTL to validate
* @returns The TTL (in seconds) to use for issuing the SSH certificate
*/
export const validateSshCertificateTtl = (template: TSshCertificateTemplates, ttl: string | undefined) => {
if (!ttl) {
// use default template ttl
return ms(template.ttl);
}
if (ms(ttl) > ms(template.maxTTL)) {
throw new BadRequestError({
message: "Failed TTL validation due to TTL being greater than configured max TTL on template"
});
}
return ms(ttl) / 1000;
};
/**
* Create an SSH certificate for a user or host.
*/
export const createSshCert = ({ caPrivateKey, userPublicKey, keyId, principals, ttl, certType }: TCreateSshCertDTO) => {
const uniqueId = crypto.randomBytes(8).toString("hex");
const publicKeyFile = `user_key_${uniqueId}.pub`;
const privateKeyFile = `ssh_ca_key_${uniqueId}`;
if (fs.existsSync(publicKeyFile)) fs.unlinkSync(publicKeyFile);
if (fs.existsSync(privateKeyFile)) fs.unlinkSync(privateKeyFile);
// write public and private keys to temp files
fs.writeFileSync(publicKeyFile, userPublicKey);
fs.writeFileSync(privateKeyFile, caPrivateKey);
fs.chmodSync(privateKeyFile, 0o600);
const serialNumber = createSshCertSerialNumber();
console.log("signSshKey serialNumber: ", serialNumber);
const certOptions = [
`-s ${privateKeyFile}`, // path to SSH CA private key
`-I "${keyId}"`, // identity for the issued certificate (key id)
`-n "${principals.join(",")}"`, // principal(s) that is user(s) or host(s)
`-V +${ttl}s`, // TTL in seconds (validity period) for the issue certificate
`-z ${serialNumber}`, // custom serial number for certificate
certType === "host" ? "-h" : "", // host certificate flag
publicKeyFile // path to signed [publicKey]
]
.filter(Boolean)
.join(" ");
const command = `ssh-keygen ${certOptions}`;
// Execute the signing process
execSync(command);
const signedPublicKey = fs.readFileSync(publicKeyFile, "utf8");
fs.unlinkSync(publicKeyFile);
fs.unlinkSync(privateKeyFile);
return { serialNumber, signedPublicKey };
};
@@ -0,0 +1,9 @@
import { SshCertificateAuthoritiesSchema } from "@app/db/schemas";
export const sanitizedSshCa = SshCertificateAuthoritiesSchema.pick({
id: true,
orgId: true,
friendlyName: true,
status: true,
keyAlgorithm: true
});
@@ -0,0 +1,10 @@
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify } from "@app/lib/knex";
export type TSshCertificateAuthoritySecretDALFactory = ReturnType<typeof sshCertificateAuthoritySecretDALFactory>;
export const sshCertificateAuthoritySecretDALFactory = (db: TDbClient) => {
const sshCaSecretOrm = ormify(db, TableName.SshCertificateAuthoritySecret);
return sshCaSecretOrm;
};
@@ -0,0 +1,378 @@
import { ForbiddenError } from "@casl/ability";
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { TSshCertificateAuthorityDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-dal";
import { TSshCertificateAuthoritySecretDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-secret-dal";
import { TSshCertificateTemplateDALFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-dal";
import { NotFoundError } from "@app/lib/errors";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TProjectDALFactory } from "@app/services/project/project-dal";
import {
createSshCert,
createSshKeyPair,
validateSshCertificatePrincipals,
validateSshCertificateTtl,
validateSshCertificateType
} from "./ssh-certificate-authority-fns";
import {
SshCaStatus,
TCreateSshCaDTO,
TDeleteSshCaDTO,
TGetSshCaCertificateTemplatesDTO,
TGetSshCaDTO,
TIssueSshCredsDTO,
TSignSshKeyDTO,
TUpdateSshCaDTO
} from "./ssh-certificate-authority-types";
type TSshCertificateAuthorityServiceFactoryDep = {
sshCertificateAuthorityDAL: Pick<
TSshCertificateAuthorityDALFactory,
"transaction" | "create" | "findById" | "updateById" | "deleteById" | "findOne"
>;
sshCertificateAuthoritySecretDAL: Pick<TSshCertificateAuthoritySecretDALFactory, "create" | "findOne">;
sshCertificateTemplateDAL: Pick<TSshCertificateTemplateDALFactory, "find" | "findOne">;
projectDAL: Pick<TProjectDALFactory, "findProjectBySlug" | "findOne" | "updateById" | "findById" | "transaction">;
kmsService: Pick<TKmsServiceFactory, "generateKmsKey" | "encryptWithKmsKey" | "decryptWithKmsKey" | "getOrgKmsKeyId">;
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
};
export type TSshCertificateAuthorityServiceFactory = ReturnType<typeof sshCertificateAuthorityServiceFactory>;
export const sshCertificateAuthorityServiceFactory = ({
sshCertificateAuthorityDAL,
sshCertificateAuthoritySecretDAL,
sshCertificateTemplateDAL,
kmsService,
permissionService
}: TSshCertificateAuthorityServiceFactoryDep) => {
/**
* Generates a new SSH CA
*/
const createSshCa = async ({
friendlyName,
keyAlgorithm,
actorId,
actorAuthMethod,
actor,
actorOrgId
}: TCreateSshCaDTO) => {
const { permission } = await permissionService.getOrgPermission(
actor,
actorId,
actorOrgId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(
OrgPermissionActions.Create,
OrgPermissionSubjects.SshCertificateAuthorities
);
const newCa = await sshCertificateAuthorityDAL.transaction(async (tx) => {
const ca = await sshCertificateAuthorityDAL.create(
{
orgId: actorOrgId,
friendlyName: friendlyName || "",
status: SshCaStatus.ACTIVE,
keyAlgorithm
},
tx
);
const { privateKey } = createSshKeyPair(keyAlgorithm, ca.friendlyName);
const orgKmsKeyId = await kmsService.getOrgKmsKeyId(actorOrgId);
const kmsEncryptor = await kmsService.encryptWithKmsKey({
kmsId: orgKmsKeyId
});
const { cipherTextBlob: encryptedPrivateKey } = await kmsEncryptor({
plainText: Buffer.from(privateKey, "utf8")
});
await sshCertificateAuthoritySecretDAL.create(
{
sshCaId: ca.id,
encryptedPrivateKey
},
tx
);
return ca;
});
return newCa;
};
/**
* Return SSH CA with id [caId]
*/
const getSshCaById = async ({ caId, actor, actorId, actorAuthMethod, actorOrgId }: TGetSshCaDTO) => {
const ca = await sshCertificateAuthorityDAL.findById(caId);
if (!ca) throw new NotFoundError({ message: `SSH CA with ID '${caId}' not found` });
const { permission } = await permissionService.getOrgPermission(
actor,
actorId,
actorOrgId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(
OrgPermissionActions.Read,
OrgPermissionSubjects.SshCertificateAuthorities
);
return ca;
};
/**
* Update SSH CA with id [caId]
* Note: Used to enable/disable CA
*/
const updateSshCaById = async ({ caId, status, actor, actorId, actorAuthMethod, actorOrgId }: TUpdateSshCaDTO) => {
const ca = await sshCertificateAuthorityDAL.findById(caId);
if (!ca) throw new NotFoundError({ message: `SSH CA with ID '${caId}' not found` });
const { permission } = await permissionService.getOrgPermission(
actor,
actorId,
actorOrgId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(
OrgPermissionActions.Edit,
OrgPermissionSubjects.SshCertificateAuthorities
);
const updatedCa = await sshCertificateAuthorityDAL.updateById(caId, { status });
return updatedCa;
};
/**
* Delete SSH CA with id [caId]
*/
const deleteSshCaById = async ({ caId, actor, actorId, actorAuthMethod, actorOrgId }: TDeleteSshCaDTO) => {
const ca = await sshCertificateAuthorityDAL.findById(caId);
if (!ca) throw new NotFoundError({ message: `SSH CA with ID '${caId}' not found` });
const { permission } = await permissionService.getOrgPermission(
actor,
actorId,
actorOrgId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(
OrgPermissionActions.Delete,
OrgPermissionSubjects.SshCertificateAuthorities
);
const deletedCa = await sshCertificateAuthorityDAL.deleteById(caId);
return deletedCa;
};
/**
* Return SSH certificate and corresponding new SSH public-private key pair where
* SSH public key is signed using CA behind SSH certificate with name [name].
*/
const issueSshCreds = async ({
name,
keyAlgorithm,
certType,
principals,
ttl: requestedTtl,
keyId: requestedKeyId,
actor,
actorId,
actorAuthMethod,
actorOrgId
}: TIssueSshCredsDTO) => {
// TODO: proper permission check
const { permission } = await permissionService.getOrgPermission(
actor,
actorId,
actorOrgId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(
OrgPermissionActions.Create,
OrgPermissionSubjects.SshCertificateTemplates
);
// TODO: adjust to find within org
const sshCertificateTemplate = await sshCertificateTemplateDAL.findOne({ name });
// validate if the requested [certType] is allowed under the template configuration
validateSshCertificateType(sshCertificateTemplate, certType);
// validate if the requested [principals] are valid for the given [certType] under the template configuration
validateSshCertificatePrincipals(certType, sshCertificateTemplate, principals);
// validate if the requested TTL is valid under the template configuration
const ttl = validateSshCertificateTtl(sshCertificateTemplate, requestedTtl);
// set [keyId] depending on if [allowCustomKeyIds] is true or false
const keyId = sshCertificateTemplate.allowCustomKeyIds
? requestedKeyId ?? `${actor}-${actorId}`
: `${actor}-${actorId}`;
const sshCaSecret = await sshCertificateAuthoritySecretDAL.findOne({ sshCaId: sshCertificateTemplate.sshCaId });
// decrypt secret
const orgKmsKeyId = await kmsService.getOrgKmsKeyId(actorOrgId);
const kmsDecryptor = await kmsService.decryptWithKmsKey({
kmsId: orgKmsKeyId
});
const decryptedCaPrivateKey = await kmsDecryptor({
cipherTextBlob: sshCaSecret.encryptedPrivateKey
});
// create user key pair
const { publicKey, privateKey } = createSshKeyPair(keyAlgorithm, "Client Key");
const { serialNumber, signedPublicKey } = createSshCert({
caPrivateKey: decryptedCaPrivateKey.toString("utf8"),
userPublicKey: publicKey,
keyId,
principals,
ttl,
certType
});
return {
serialNumber,
signedPublicKey,
privateKey,
publicKey,
certificateTemplate: sshCertificateTemplate,
ttl,
keyId
};
};
/**
* Return SSH certificate by signing SSH public key [publicKey]
* using CA behind SSH certificate template with name [name]
*/
const signSshKey = async ({
name,
publicKey,
certType,
principals,
ttl: requestedTtl,
keyId: requestedKeyId,
actor,
actorId,
actorAuthMethod,
actorOrgId
}: TSignSshKeyDTO) => {
// TODO: proper permission check
const { permission } = await permissionService.getOrgPermission(
actor,
actorId,
actorOrgId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(
OrgPermissionActions.Create,
OrgPermissionSubjects.SshCertificateTemplates
);
// TODO: adjust to find within org
const sshCertificateTemplate = await sshCertificateTemplateDAL.findOne({ name });
// validate if the requested [certType] is allowed under the template configuration
validateSshCertificateType(sshCertificateTemplate, certType);
// validate if the requested [principals] are valid for the given [certType] under the template configuration
validateSshCertificatePrincipals(certType, sshCertificateTemplate, principals);
// validate if the requested TTL is valid under the template configuration
const ttl = validateSshCertificateTtl(sshCertificateTemplate, requestedTtl);
// set [keyId] depending on if [allowCustomKeyIds] is true or false
const keyId = sshCertificateTemplate.allowCustomKeyIds
? requestedKeyId ?? `${actor}-${actorId}`
: `${actor}-${actorId}`;
const sshCaSecret = await sshCertificateAuthoritySecretDAL.findOne({ sshCaId: sshCertificateTemplate.sshCaId });
// decrypt secret
const orgKmsKeyId = await kmsService.getOrgKmsKeyId(actorOrgId);
const kmsDecryptor = await kmsService.decryptWithKmsKey({
kmsId: orgKmsKeyId
});
const decryptedCaPrivateKey = await kmsDecryptor({
cipherTextBlob: sshCaSecret.encryptedPrivateKey
});
const { serialNumber, signedPublicKey } = createSshCert({
caPrivateKey: decryptedCaPrivateKey.toString("utf8"),
userPublicKey: publicKey,
keyId,
principals,
ttl,
certType
});
return { serialNumber, signedPublicKey, certificateTemplate: sshCertificateTemplate, ttl, keyId };
};
const getSshCaCertificateTemplates = async ({
caId,
actor,
actorId,
actorAuthMethod,
actorOrgId
}: TGetSshCaCertificateTemplatesDTO) => {
const ca = await sshCertificateAuthorityDAL.findById(caId);
if (!ca) throw new NotFoundError({ message: `SSH CA with ID '${caId}' not found` });
const { permission } = await permissionService.getOrgPermission(
actor,
actorId,
actorOrgId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(
OrgPermissionActions.Read,
OrgPermissionSubjects.SshCertificateTemplates
);
const certificateTemplates = await sshCertificateTemplateDAL.find({ sshCaId: caId });
return {
certificateTemplates,
ca
};
};
return {
issueSshCreds,
signSshKey,
createSshCa,
getSshCaById,
updateSshCaById,
deleteSshCaById,
getSshCaCertificateTemplates
};
};
@@ -0,0 +1,61 @@
import { TOrgPermission } from "@app/lib/types";
import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types";
export enum SshCaStatus {
ACTIVE = "active",
DISABLED = "disabled"
}
export enum SshCertType {
USER = "user",
HOST = "host"
}
export type TCreateSshCaDTO = {
friendlyName?: string;
keyAlgorithm: CertKeyAlgorithm;
} & Omit<TOrgPermission, "orgId">;
export type TGetSshCaDTO = {
caId: string;
} & Omit<TOrgPermission, "orgId">;
export type TUpdateSshCaDTO = {
caId: string;
status?: SshCaStatus;
} & Omit<TOrgPermission, "orgId">;
export type TDeleteSshCaDTO = {
caId: string;
} & Omit<TOrgPermission, "orgId">;
export type TIssueSshCredsDTO = {
name: string; // name of SSH certificate template
keyAlgorithm: CertKeyAlgorithm;
certType: SshCertType;
principals: string[];
ttl?: string;
keyId?: string;
} & Omit<TOrgPermission, "orgId">;
export type TSignSshKeyDTO = {
name: string; // name of SSH certificate template
publicKey: string;
certType: SshCertType;
principals: string[];
ttl?: string;
keyId?: string;
} & Omit<TOrgPermission, "orgId">;
export type TGetSshCaCertificateTemplatesDTO = {
caId: string;
} & Omit<TOrgPermission, "orgId">;
export type TCreateSshCertDTO = {
caPrivateKey: string;
userPublicKey: string;
keyId: string;
principals: string[];
ttl: number;
certType: SshCertType;
};