mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-16 02:16:34 +00:00
391 lines
13 KiB
TypeScript
391 lines
13 KiB
TypeScript
import { execFile } from "child_process";
|
|
import crypto from "crypto";
|
|
import { promises as fs } from "fs";
|
|
import os from "os";
|
|
import path from "path";
|
|
import { promisify } from "util";
|
|
|
|
import { TSshCertificateTemplates } from "@app/db/schemas";
|
|
import { BadRequestError } from "@app/lib/errors";
|
|
import { ms } from "@app/lib/ms";
|
|
import { CharacterType, characterValidator } from "@app/lib/validator/validate-string";
|
|
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";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
const EXEC_TIMEOUT_MS = 10000; // 10 seconds
|
|
/* 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 = async (keyAlgorithm: CertKeyAlgorithm) => {
|
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ssh-key-"));
|
|
const privateKeyFile = path.join(tempDir, "id_key");
|
|
const publicKeyFile = `${privateKeyFile}.pub`;
|
|
|
|
let keyType: string;
|
|
let keyBits: string;
|
|
|
|
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 BadRequestError({
|
|
message: "Failed to produce SSH CA key pair generation command due to unrecognized key algorithm"
|
|
});
|
|
}
|
|
|
|
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", ""], {
|
|
timeout: EXEC_TIMEOUT_MS
|
|
});
|
|
|
|
// Read the generated keys
|
|
const publicKey = await fs.readFile(publicKeyFile, "utf8");
|
|
const privateKey = await fs.readFile(privateKeyFile, "utf8");
|
|
|
|
return { publicKey, privateKey };
|
|
} finally {
|
|
// Cleanup the temporary directory and all its contents
|
|
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Return the SSH public key for the given SSH private key.
|
|
*/
|
|
export const getSshPublicKey = async (privateKey: string) => {
|
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ssh-key-"));
|
|
const privateKeyFile = path.join(tempDir, "id_key");
|
|
try {
|
|
await fs.writeFile(privateKeyFile, privateKey, { mode: 0o600 });
|
|
|
|
// Run ssh-keygen to extract the public key
|
|
const { stdout } = await execFileAsync("ssh-keygen", ["-y", "-f", privateKeyFile], {
|
|
encoding: "utf8",
|
|
timeout: EXEC_TIMEOUT_MS
|
|
});
|
|
return stdout.trim();
|
|
} finally {
|
|
// Ensure that files and the temporary directory are cleaned up
|
|
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Validate the requested SSH certificate type based on the SSH certificate template configuration.
|
|
*/
|
|
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.
|
|
*/
|
|
export const validateSshCertificatePrincipals = (
|
|
certType: SshCertType,
|
|
template: TSshCertificateTemplates,
|
|
principals: string[]
|
|
) => {
|
|
/**
|
|
* Validate and sanitize a principal string
|
|
*/
|
|
const validatePrincipal = (principal: string) => {
|
|
const sanitized = principal.trim();
|
|
|
|
// basic checks for empty or control characters
|
|
if (sanitized.length === 0) {
|
|
throw new BadRequestError({
|
|
message: "Principal cannot be an empty string."
|
|
});
|
|
}
|
|
|
|
if (/\r|\n|\t|\0/.test(sanitized)) {
|
|
throw new BadRequestError({
|
|
message: `Principal '${sanitized}' contains invalid whitespace or control characters.`
|
|
});
|
|
}
|
|
|
|
// disallow whitespace anywhere
|
|
if (/\s/.test(sanitized)) {
|
|
throw new BadRequestError({
|
|
message: `Principal '${sanitized}' cannot contain whitespace.`
|
|
});
|
|
}
|
|
|
|
// restrict allowed characters to letters, digits, dot, underscore, and hyphen
|
|
if (
|
|
!characterValidator([
|
|
CharacterType.AlphaNumeric,
|
|
CharacterType.Period,
|
|
CharacterType.Underscore,
|
|
CharacterType.Hyphen
|
|
])(sanitized)
|
|
) {
|
|
throw new BadRequestError({
|
|
message: `Principal '${sanitized}' contains invalid characters. Allowed: alphanumeric, '.', '_', '-'.`
|
|
});
|
|
}
|
|
|
|
// disallow leading hyphen to avoid potential argument-like inputs
|
|
if (sanitized.startsWith("-")) {
|
|
throw new BadRequestError({
|
|
message: `Principal '${sanitized}' cannot start with a hyphen.`
|
|
});
|
|
}
|
|
|
|
// length restriction (adjust as needed)
|
|
if (sanitized.length > 64) {
|
|
throw new BadRequestError({
|
|
message: `Principal '${sanitized}' is too long.`
|
|
});
|
|
}
|
|
|
|
return sanitized;
|
|
};
|
|
|
|
// Sanitize and validate all principals using the helper
|
|
const sanitizedPrincipals = principals.map(validatePrincipal);
|
|
|
|
switch (certType) {
|
|
case SshCertType.USER: {
|
|
if (template.allowedUsers.length === 0) {
|
|
throw new BadRequestError({
|
|
message: "No allowed users are configured in the SSH certificate template."
|
|
});
|
|
}
|
|
|
|
const allowsAllUsers = template.allowedUsers.includes("*") ?? false;
|
|
|
|
sanitizedPrincipals.forEach((principal) => {
|
|
if (principal === "*") {
|
|
throw new BadRequestError({
|
|
message: `Principal '*' is not allowed for user certificates.`
|
|
});
|
|
}
|
|
if (allowsAllUsers && !isValidUserPattern(principal)) {
|
|
throw new BadRequestError({
|
|
message: `Principal '${principal}' does not match a valid user pattern.`
|
|
});
|
|
}
|
|
if (!allowsAllUsers && !template.allowedUsers.includes(principal)) {
|
|
throw new BadRequestError({
|
|
message: `Principal '${principal}' is not in the list of allowed users.`
|
|
});
|
|
}
|
|
});
|
|
break;
|
|
}
|
|
case SshCertType.HOST: {
|
|
if (template.allowedHosts.length === 0) {
|
|
throw new BadRequestError({
|
|
message: "No allowed hosts are configured in the SSH certificate template."
|
|
});
|
|
}
|
|
|
|
const allowsAllHosts = template.allowedHosts.includes("*") ?? false;
|
|
|
|
sanitizedPrincipals.forEach((principal) => {
|
|
if (principal.includes("*")) {
|
|
throw new BadRequestError({
|
|
message: `Principal '${principal}' with wildcards is not allowed for host certificates.`
|
|
});
|
|
}
|
|
if (allowsAllHosts && !isValidHostPattern(principal)) {
|
|
throw new BadRequestError({
|
|
message: `Principal '${principal}' does not match a valid host pattern.`
|
|
});
|
|
}
|
|
|
|
if (
|
|
!allowsAllHosts &&
|
|
!template.allowedHosts.some((allowedHost) => {
|
|
if (allowedHost.startsWith("*.")) {
|
|
const baseDomain = allowedHost.slice(2); // Remove the leading "*."
|
|
return principal.endsWith(`.${baseDomain}`);
|
|
}
|
|
return principal === allowedHost;
|
|
})
|
|
) {
|
|
throw new BadRequestError({
|
|
message: `Principal '${principal}' is not in the list of allowed hosts or domains.`
|
|
});
|
|
}
|
|
});
|
|
break;
|
|
}
|
|
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.
|
|
*/
|
|
export const validateSshCertificateTtl = (template: TSshCertificateTemplates, ttl?: string) => {
|
|
if (!ttl) {
|
|
// use default template ttl
|
|
return Math.ceil(ms(template.ttl) / 1000);
|
|
}
|
|
|
|
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 Math.ceil(ms(ttl) / 1000);
|
|
};
|
|
|
|
/**
|
|
* Validate the requested SSH certificate key ID to ensure
|
|
* that it only contains alphanumeric characters with no spaces.
|
|
*/
|
|
export const validateSshCertificateKeyId = (keyId: string) => {
|
|
const regex = characterValidator([CharacterType.AlphaNumeric, CharacterType.Hyphen]);
|
|
if (!regex(keyId)) {
|
|
throw new BadRequestError({
|
|
message:
|
|
"Failed to validate Key ID because it can only contain alphanumeric characters and hyphens, with no spaces."
|
|
});
|
|
}
|
|
|
|
if (keyId.length > 50) {
|
|
throw new BadRequestError({
|
|
message: "keyId can only be up to 50 characters long."
|
|
});
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Validate the format of the SSH public key
|
|
*/
|
|
const validateSshPublicKey = async (publicKey: string) => {
|
|
const validPrefixes = ["ssh-rsa", "ssh-ed25519", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384"];
|
|
const startsWithValidPrefix = validPrefixes.some((prefix) => publicKey.startsWith(`${prefix} `));
|
|
if (!startsWithValidPrefix) {
|
|
throw new BadRequestError({ message: "Failed to validate SSH public key format: unsupported key type." });
|
|
}
|
|
|
|
// write the key to a temp file and run `ssh-keygen -l -f`
|
|
// check to see if OpenSSH can read/interpret the public key
|
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ssh-pubkey-"));
|
|
const pubKeyFile = path.join(tempDir, "key.pub");
|
|
|
|
try {
|
|
await fs.writeFile(pubKeyFile, publicKey, { mode: 0o600 });
|
|
await execFileAsync("ssh-keygen", ["-l", "-f", pubKeyFile], { timeout: EXEC_TIMEOUT_MS });
|
|
} catch (error) {
|
|
throw new BadRequestError({
|
|
message: "Failed to validate SSH public key format: could not be parsed."
|
|
});
|
|
} finally {
|
|
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Create an SSH certificate for a user or host.
|
|
*/
|
|
export const createSshCert = async ({
|
|
template,
|
|
caPrivateKey,
|
|
clientPublicKey,
|
|
keyId,
|
|
principals,
|
|
requestedTtl,
|
|
certType
|
|
}: TCreateSshCertDTO) => {
|
|
// validate if the requested [certType] is allowed under the template configuration
|
|
validateSshCertificateType(template, certType);
|
|
|
|
// validate if the requested [principals] are valid for the given [certType] under the template configuration
|
|
validateSshCertificatePrincipals(certType, template, principals);
|
|
|
|
// validate if the requested TTL is valid under the template configuration
|
|
const ttl = validateSshCertificateTtl(template, requestedTtl);
|
|
|
|
validateSshCertificateKeyId(keyId);
|
|
await validateSshPublicKey(clientPublicKey);
|
|
|
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ssh-cert-"));
|
|
|
|
const publicKeyFile = path.join(tempDir, "user_key.pub");
|
|
const privateKeyFile = path.join(tempDir, "ca_key");
|
|
const signedPublicKeyFile = path.join(tempDir, "user_key-cert.pub");
|
|
|
|
const serialNumber = createSshCertSerialNumber();
|
|
|
|
// Build `ssh-keygen` arguments for signing
|
|
// Using an array avoids shell injection issues
|
|
const sshKeygenArgs = [
|
|
certType === "host" ? "-h" : null, // host certificate if needed
|
|
"-s",
|
|
privateKeyFile, // path to SSH CA private key
|
|
"-I",
|
|
keyId, // identity (key ID)
|
|
"-n",
|
|
principals.join(","), // principals
|
|
"-V",
|
|
`+${ttl}s`, // validity (TTL in seconds)
|
|
"-z",
|
|
serialNumber, // serial number
|
|
publicKeyFile // public key file to sign
|
|
].filter(Boolean) as string[];
|
|
|
|
try {
|
|
// Write public and private keys to the temp directory
|
|
await fs.writeFile(publicKeyFile, clientPublicKey, { mode: 0o600 });
|
|
await fs.writeFile(privateKeyFile, caPrivateKey, { mode: 0o600 });
|
|
|
|
// Execute the signing process
|
|
await execFileAsync("ssh-keygen", sshKeygenArgs, { encoding: "utf8", timeout: EXEC_TIMEOUT_MS });
|
|
|
|
// Read the signed public key from the generated cert file
|
|
const signedPublicKey = await fs.readFile(signedPublicKeyFile, "utf8");
|
|
|
|
return { serialNumber, signedPublicKey, ttl };
|
|
} finally {
|
|
// Cleanup the temporary directory and all its contents
|
|
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
|
}
|
|
};
|