security + performance improvements to ssh fns

This commit is contained in:
Tuan Dang
2024-12-09 22:22:54 -08:00
parent 7cf297344b
commit 48174e2500
2 changed files with 88 additions and 78 deletions
@@ -1,7 +1,10 @@
import { execSync } from "child_process"; import { execFile } from "child_process";
import crypto from "crypto"; import crypto from "crypto";
import fs from "fs"; import { promises as fs } from "fs";
import ms from "ms"; import ms from "ms";
import os from "os";
import path from "path";
import { promisify } from "util";
import { TSshCertificateTemplates } from "@app/db/schemas"; import { TSshCertificateTemplates } from "@app/db/schemas";
import { BadRequestError } from "@app/lib/errors"; import { BadRequestError } from "@app/lib/errors";
@@ -13,6 +16,8 @@ import {
} from "../ssh-certificate-template/ssh-certificate-template-validators"; } from "../ssh-certificate-template/ssh-certificate-template-validators";
import { SshCertType, TCreateSshCertDTO } from "./ssh-certificate-authority-types"; import { SshCertType, TCreateSshCertDTO } from "./ssh-certificate-authority-types";
const execFileAsync = promisify(execFile);
/* eslint-disable no-bitwise */ /* eslint-disable no-bitwise */
export const createSshCertSerialNumber = () => { export const createSshCertSerialNumber = () => {
const randomBytes = crypto.randomBytes(8); // 8 bytes = 64 bits const randomBytes = crypto.randomBytes(8); // 8 bytes = 64 bits
@@ -23,17 +28,17 @@ export const createSshCertSerialNumber = () => {
/** /**
* Return a pair of SSH CA keys based on the specified key algorithm [keyAlgorithm]. * 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. * We use this function because the key format generated by `ssh-keygen` is unique.
* @param keyAlgorithm - The key algorithm to use for generating the SSH key pair
* @param comment - The comment to use for the SSH key pair
* @returns The public and private keys for the SSH key pair
*/ */
export const createSshKeyPair = (keyAlgorithm: CertKeyAlgorithm, comment: string) => { export const createSshKeyPair = async (keyAlgorithm: CertKeyAlgorithm, comment: string) => {
const uniqueId = crypto.randomBytes(8).toString("hex"); // to avoid collions if high-volume key generation const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ssh-key-"));
const privateKeyFile = `ssh_key_${uniqueId}`; // temp key path const privateKeyFile = path.join(tempDir, "id_key");
const publicKeyFile = `${privateKeyFile}.pub`; const publicKeyFile = `${privateKeyFile}.pub`;
if (fs.existsSync(publicKeyFile)) fs.unlinkSync(publicKeyFile); let keyType: string;
if (fs.existsSync(privateKeyFile)) fs.unlinkSync(privateKeyFile); let keyBits: string;
let keyType = "";
let keyBits = "";
switch (keyAlgorithm) { switch (keyAlgorithm) {
case CertKeyAlgorithm.RSA_2048: case CertKeyAlgorithm.RSA_2048:
@@ -58,41 +63,40 @@ export const createSshKeyPair = (keyAlgorithm: CertKeyAlgorithm, comment: string
}); });
} }
execSync(`ssh-keygen -t ${keyType} -b ${keyBits} -f ${privateKeyFile} -N '' -C "${comment}"`); try {
// Generate the SSH key pair
// The "-N ''" sets an empty passphrase
// The keys are created in the temporary directory
await execFileAsync("ssh-keygen", ["-t", keyType, "-b", keyBits, "-f", privateKeyFile, "-N", "", "-C", comment]);
const publicKey = fs.readFileSync(publicKeyFile, "utf8"); // Read the generated keys
const privateKey = fs.readFileSync(privateKeyFile, "utf8"); const publicKey = await fs.readFile(publicKeyFile, "utf8");
const privateKey = await fs.readFile(privateKeyFile, "utf8");
fs.unlinkSync(privateKeyFile); return { publicKey, privateKey };
fs.unlinkSync(publicKeyFile); } finally {
// Cleanup the temporary directory and all its contents
return { publicKey, privateKey }; await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
}
}; };
/** /**
* Return the SSH public key for the given SSH private key. * Return the SSH public key for the given SSH private key.
* @param privateKey - The SSH private key to get the public key for * @param privateKey - The SSH private key to get the public key for
*/ */
export const getSshPublicKey = (privateKey: string) => { export const getSshPublicKey = async (privateKey: string) => {
const uniqueId = crypto.randomBytes(8).toString("hex"); const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ssh-key-"));
const privateKeyFile = `ssh_key_${uniqueId}`; const privateKeyFile = path.join(tempDir, "id_key");
const publicKeyFile = `${privateKeyFile}.pub`; try {
await fs.writeFile(privateKeyFile, privateKey, { mode: 0o600 });
if (fs.existsSync(publicKeyFile)) fs.unlinkSync(publicKeyFile); // Run ssh-keygen to extract the public key
if (fs.existsSync(privateKeyFile)) fs.unlinkSync(privateKeyFile); const { stdout } = await execFileAsync("ssh-keygen", ["-y", "-f", privateKeyFile], { encoding: "utf8" });
return stdout.trim();
fs.writeFileSync(privateKeyFile, privateKey); } finally {
fs.chmodSync(privateKeyFile, 0o600); // Ensure that files and the temporary directory are cleaned up
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
const command = `ssh-keygen -y -f ${privateKeyFile} > ${publicKeyFile}`; }
execSync(command);
const publicKey = fs.readFileSync(publicKeyFile, "utf8");
fs.unlinkSync(privateKeyFile);
fs.unlinkSync(publicKeyFile);
return publicKey;
}; };
/** /**
@@ -220,47 +224,53 @@ export const validateSshCertificateTtl = (template: TSshCertificateTemplates, tt
/** /**
* Create an SSH certificate for a user or host. * Create an SSH certificate for a user or host.
*/ */
export const createSshCert = ({ caPrivateKey, userPublicKey, keyId, principals, ttl, certType }: TCreateSshCertDTO) => { export const createSshCert = async ({
const uniqueId = crypto.randomBytes(8).toString("hex"); caPrivateKey,
const publicKeyFile = `user_key_${uniqueId}.pub`; userPublicKey,
const privateKeyFile = `ssh_ca_key_${uniqueId}`; keyId,
const signedPublicKeyFile = `user_key_${uniqueId}-cert.pub`; principals,
ttl,
certType
}: TCreateSshCertDTO) => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ssh-cert-"));
if (fs.existsSync(publicKeyFile)) fs.unlinkSync(publicKeyFile); const publicKeyFile = path.join(tempDir, "user_key.pub");
if (fs.existsSync(privateKeyFile)) fs.unlinkSync(privateKeyFile); const privateKeyFile = path.join(tempDir, "ca_key");
if (fs.existsSync(signedPublicKeyFile)) fs.unlinkSync(signedPublicKeyFile); const signedPublicKeyFile = path.join(tempDir, "user_key-cert.pub");
// write public and private keys to temp files
fs.writeFileSync(publicKeyFile, userPublicKey);
fs.writeFileSync(privateKeyFile, caPrivateKey);
fs.chmodSync(privateKeyFile, 0o600);
const serialNumber = createSshCertSerialNumber(); const serialNumber = createSshCertSerialNumber();
const certOptions = [ // Build `ssh-keygen` arguments for signing
`-s ${privateKeyFile}`, // path to SSH CA private key // Using an array avoids shell injection issues
`-I "${keyId}"`, // identity for the issued certificate (key id) const sshKeygenArgs = [
`-n "${principals.join(",")}"`, // principal(s) that is user(s) or host(s) certType === "host" ? "-h" : null, // host certificate if needed
`-V +${ttl}s`, // TTL in seconds (validity period) for the issue certificate "-s",
`-z ${serialNumber}`, // custom serial number for certificate privateKeyFile, // path to SSH CA private key
certType === "host" ? "-h" : "", // host certificate flag "-I",
publicKeyFile // path to signed [publicKey] keyId, // identity (key ID)
] "-n",
.filter(Boolean) principals.join(","), // principals
.join(" "); "-V",
`+${ttl}s`, // validity (TTL in seconds)
"-z",
serialNumber, // serial number
publicKeyFile // public key file to sign
].filter(Boolean) as string[];
const command = `ssh-keygen ${certOptions}`; try {
// Write public and private keys to the temp directory
await fs.writeFile(publicKeyFile, userPublicKey, { mode: 0o600 });
await fs.writeFile(privateKeyFile, caPrivateKey, { mode: 0o600 });
console.log("executing command", command); // Execute the signing process
await execFileAsync("ssh-keygen", sshKeygenArgs, { encoding: "utf8" });
// Execute the signing process // Read the signed public key from the generated cert file
execSync(command); const signedPublicKey = await fs.readFile(signedPublicKeyFile, "utf8");
const signedPublicKey = fs.readFileSync(signedPublicKeyFile, "utf8"); return { serialNumber, signedPublicKey };
} finally {
fs.unlinkSync(publicKeyFile); // Cleanup the temporary directory and all its contents
fs.unlinkSync(privateKeyFile); await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
fs.unlinkSync(signedPublicKeyFile); }
return { serialNumber, signedPublicKey };
}; };
@@ -97,7 +97,7 @@ export const sshCertificateAuthorityServiceFactory = ({
tx tx
); );
const { publicKey, privateKey } = createSshKeyPair(keyAlgorithm, ca.friendlyName); const { publicKey, privateKey } = await createSshKeyPair(keyAlgorithm, ca.friendlyName);
// TODO: update to sshEncryptor // TODO: update to sshEncryptor
const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({ const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({
@@ -151,7 +151,7 @@ export const sshCertificateAuthorityServiceFactory = ({
cipherTextBlob: sshCaSecret.encryptedPrivateKey cipherTextBlob: sshCaSecret.encryptedPrivateKey
}); });
const publicKey = getSshPublicKey(decryptedCaPrivateKey.toString("utf-8")); const publicKey = await getSshPublicKey(decryptedCaPrivateKey.toString("utf-8"));
return { ...ca, publicKey }; return { ...ca, publicKey };
}; };
@@ -175,7 +175,7 @@ export const sshCertificateAuthorityServiceFactory = ({
cipherTextBlob: sshCaSecret.encryptedPrivateKey cipherTextBlob: sshCaSecret.encryptedPrivateKey
}); });
const publicKey = getSshPublicKey(decryptedCaPrivateKey.toString("utf-8")); const publicKey = await getSshPublicKey(decryptedCaPrivateKey.toString("utf-8"));
return publicKey; return publicKey;
}; };
@@ -223,7 +223,7 @@ export const sshCertificateAuthorityServiceFactory = ({
cipherTextBlob: sshCaSecret.encryptedPrivateKey cipherTextBlob: sshCaSecret.encryptedPrivateKey
}); });
const publicKey = getSshPublicKey(decryptedCaPrivateKey.toString("utf-8")); const publicKey = await getSshPublicKey(decryptedCaPrivateKey.toString("utf-8"));
return { ...updatedCa, publicKey }; return { ...updatedCa, publicKey };
}; };
@@ -329,9 +329,9 @@ export const sshCertificateAuthorityServiceFactory = ({
}); });
// create user key pair // create user key pair
const { publicKey, privateKey } = createSshKeyPair(keyAlgorithm, "Client Key"); const { publicKey, privateKey } = await createSshKeyPair(keyAlgorithm, "Client Key");
const { serialNumber, signedPublicKey } = createSshCert({ const { serialNumber, signedPublicKey } = await createSshCert({
caPrivateKey: decryptedCaPrivateKey.toString("utf8"), caPrivateKey: decryptedCaPrivateKey.toString("utf8"),
userPublicKey: publicKey, userPublicKey: publicKey,
keyId, keyId,
@@ -460,7 +460,7 @@ export const sshCertificateAuthorityServiceFactory = ({
cipherTextBlob: sshCaSecret.encryptedPrivateKey cipherTextBlob: sshCaSecret.encryptedPrivateKey
}); });
const { serialNumber, signedPublicKey } = createSshCert({ const { serialNumber, signedPublicKey } = await createSshCert({
caPrivateKey: decryptedCaPrivateKey.toString("utf8"), caPrivateKey: decryptedCaPrivateKey.toString("utf8"),
userPublicKey: publicKey, userPublicKey: publicKey,
keyId, keyId,