mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
606 lines
19 KiB
TypeScript
606 lines
19 KiB
TypeScript
import { execFile } from "child_process";
|
|
import crypto from "crypto";
|
|
import { promises as fs } from "fs";
|
|
import { Knex } from "knex";
|
|
import os from "os";
|
|
import path from "path";
|
|
import { promisify } from "util";
|
|
|
|
import { TSshCertificateTemplates } from "@app/db/schemas";
|
|
import { SshCertKeyAlgorithm } from "@app/ee/services/ssh-certificate/ssh-certificate-types";
|
|
import { BadRequestError } from "@app/lib/errors";
|
|
import { ms } from "@app/lib/ms";
|
|
import { CharacterType, characterValidator } from "@app/lib/validator/validate-string";
|
|
import { ActorType } from "@app/services/auth/auth-type";
|
|
import { KmsDataKey } from "@app/services/kms/kms-types";
|
|
|
|
import {
|
|
isValidHostPattern,
|
|
isValidUserPattern
|
|
} from "../ssh-certificate-template/ssh-certificate-template-validators";
|
|
import {
|
|
SshCaKeySource,
|
|
SshCaStatus,
|
|
SshCertType,
|
|
TConvertActorToPrincipalsDTO,
|
|
TCreateSshCaHelperDTO,
|
|
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: SshCertKeyAlgorithm) => {
|
|
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 | null;
|
|
|
|
switch (keyAlgorithm) {
|
|
case SshCertKeyAlgorithm.RSA_2048:
|
|
keyType = "rsa";
|
|
keyBits = "2048";
|
|
break;
|
|
case SshCertKeyAlgorithm.RSA_4096:
|
|
keyType = "rsa";
|
|
keyBits = "4096";
|
|
break;
|
|
case SshCertKeyAlgorithm.ECDSA_P256:
|
|
keyType = "ecdsa";
|
|
keyBits = "256";
|
|
break;
|
|
case SshCertKeyAlgorithm.ECDSA_P384:
|
|
keyType = "ecdsa";
|
|
keyBits = "384";
|
|
break;
|
|
case SshCertKeyAlgorithm.ED25519:
|
|
keyType = "ed25519";
|
|
keyBits = null;
|
|
break;
|
|
default:
|
|
throw new BadRequestError({
|
|
message: "Failed to produce SSH CA key pair generation command due to unrecognized key algorithm"
|
|
});
|
|
}
|
|
|
|
try {
|
|
const args = ["-t", keyType];
|
|
if (keyBits !== null) {
|
|
args.push("-b", keyBits);
|
|
}
|
|
args.push("-f", privateKeyFile, "-N", "");
|
|
|
|
// Generate the SSH key pair
|
|
// The "-N ''" sets an empty passphrase
|
|
// The keys are created in the temporary directory
|
|
await execFileAsync("ssh-keygen", args, {
|
|
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,
|
|
CharacterType.Colon,
|
|
CharacterType.Period
|
|
]);
|
|
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(() => {});
|
|
}
|
|
};
|
|
|
|
export const getKeyAlgorithmFromFingerprintOutput = (output: string): SshCertKeyAlgorithm | undefined => {
|
|
const parts = output.trim().split(" ");
|
|
const bitsInt = parseInt(parts[0], 10);
|
|
const keyTypeRaw = parts.at(-1)?.replace(/[()]/g, ""); // remove surrounding parentheses
|
|
|
|
if (keyTypeRaw === "RSA") {
|
|
return bitsInt === 2048 ? SshCertKeyAlgorithm.RSA_2048 : SshCertKeyAlgorithm.RSA_4096;
|
|
}
|
|
|
|
if (keyTypeRaw === "ECDSA") {
|
|
return bitsInt === 256 ? SshCertKeyAlgorithm.ECDSA_P256 : SshCertKeyAlgorithm.ECDSA_P384;
|
|
}
|
|
|
|
if (keyTypeRaw === "ED25519") {
|
|
// TODO: test
|
|
return SshCertKeyAlgorithm.ED25519;
|
|
}
|
|
|
|
return undefined;
|
|
};
|
|
|
|
export const normalizeSshPrivateKey = (raw: string): string => {
|
|
return `${raw
|
|
.replace(/\r\n/g, "\n") // Windows CRLF → LF
|
|
.replace(/\r/g, "\n") // Old Mac CR → LF
|
|
.replace(/\\n/g, "\n") // Double-escaped \n
|
|
.trim()}\n`;
|
|
};
|
|
|
|
/**
|
|
* Validate the format of the SSH private key
|
|
*
|
|
* Returns the SSH public key corresponding to the private key
|
|
* and the key algorithm categorization.
|
|
*/
|
|
export const validateSshPrivateKey = async (privateKey: string) => {
|
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ssh-privkey-"));
|
|
const privateKeyFile = path.join(tempDir, "id_key");
|
|
|
|
try {
|
|
await fs.writeFile(privateKeyFile, privateKey, {
|
|
encoding: "utf8",
|
|
mode: 0o600
|
|
});
|
|
|
|
// This will fail if the private key is malformed or unreadable
|
|
const { stdout: publicKey } = await execFileAsync("ssh-keygen", ["-y", "-f", privateKeyFile], {
|
|
timeout: EXEC_TIMEOUT_MS
|
|
});
|
|
|
|
const { stdout: fingerprint } = await execFileAsync("ssh-keygen", ["-lf", privateKeyFile]);
|
|
const keyAlgorithm = getKeyAlgorithmFromFingerprintOutput(fingerprint);
|
|
|
|
if (!keyAlgorithm) {
|
|
throw new BadRequestError({
|
|
message: "Failed to validate SSH private key format: The key algorithm is not supported."
|
|
});
|
|
}
|
|
|
|
return {
|
|
publicKey,
|
|
keyAlgorithm
|
|
};
|
|
} catch (err) {
|
|
throw new BadRequestError({
|
|
message: "Failed to validate SSH private key format: could not be parsed."
|
|
});
|
|
} finally {
|
|
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Validate that the provided public and private keys are valid and constitute
|
|
* a matching SSH key pair.
|
|
*/
|
|
export const validateExternalSshCaKeyPair = async (publicKey: string, privateKey: string) => {
|
|
await validateSshPublicKey(publicKey);
|
|
|
|
const { publicKey: derivedPublicKey, keyAlgorithm } = await validateSshPrivateKey(privateKey);
|
|
|
|
if (publicKey.trim() !== derivedPublicKey.trim()) {
|
|
throw new BadRequestError({
|
|
message: "Failed to validate matching SSH key pair."
|
|
});
|
|
}
|
|
|
|
return keyAlgorithm;
|
|
};
|
|
|
|
/**
|
|
* Create an SSH certificate for a user or host.
|
|
*/
|
|
export const createSshCert = async ({
|
|
template,
|
|
caPrivateKey,
|
|
clientPublicKey,
|
|
keyId,
|
|
principals,
|
|
requestedTtl, // in ms lib format
|
|
certType
|
|
}: TCreateSshCertDTO) => {
|
|
let ttl: number | undefined;
|
|
|
|
if (!template && requestedTtl) {
|
|
const parsedTtl = Math.ceil(ms(requestedTtl) / 1000);
|
|
if (parsedTtl > 0) ttl = parsedTtl;
|
|
}
|
|
|
|
if (template) {
|
|
// 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
|
|
ttl = validateSshCertificateTtl(template, requestedTtl);
|
|
}
|
|
|
|
if (!ttl) {
|
|
throw new BadRequestError({
|
|
message: "Failed to create SSH certificate due to missing TTL"
|
|
});
|
|
}
|
|
|
|
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(() => {});
|
|
}
|
|
};
|
|
|
|
export const createSshCaHelper = async ({
|
|
projectId,
|
|
friendlyName,
|
|
keyAlgorithm: requestedKeyAlgorithm,
|
|
keySource,
|
|
externalPk,
|
|
externalSk,
|
|
sshCertificateAuthorityDAL,
|
|
sshCertificateAuthoritySecretDAL,
|
|
kmsService,
|
|
tx: outerTx
|
|
}: TCreateSshCaHelperDTO) => {
|
|
// Function to handle the actual creation logic
|
|
const processCreation = async (tx: Knex) => {
|
|
let publicKey: string;
|
|
let privateKey: string;
|
|
let keyAlgorithm: SshCertKeyAlgorithm = requestedKeyAlgorithm;
|
|
if (keySource === SshCaKeySource.INTERNAL) {
|
|
// generate SSH CA key pair internally
|
|
({ publicKey, privateKey } = await createSshKeyPair(requestedKeyAlgorithm));
|
|
} else {
|
|
// use external SSH CA key pair
|
|
if (!externalPk || !externalSk) {
|
|
throw new BadRequestError({
|
|
message: "Public and private keys are required if generateSigningKey is false"
|
|
});
|
|
}
|
|
publicKey = externalPk;
|
|
privateKey = externalSk;
|
|
keyAlgorithm = await validateExternalSshCaKeyPair(publicKey, privateKey);
|
|
}
|
|
const ca = await sshCertificateAuthorityDAL.create(
|
|
{
|
|
projectId,
|
|
friendlyName,
|
|
status: SshCaStatus.ACTIVE,
|
|
keyAlgorithm,
|
|
keySource
|
|
},
|
|
tx
|
|
);
|
|
const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey(
|
|
{
|
|
type: KmsDataKey.SecretManager,
|
|
projectId
|
|
},
|
|
tx
|
|
);
|
|
await sshCertificateAuthoritySecretDAL.create(
|
|
{
|
|
sshCaId: ca.id,
|
|
encryptedPrivateKey: secretManagerEncryptor({ plainText: Buffer.from(privateKey, "utf8") }).cipherTextBlob
|
|
},
|
|
tx
|
|
);
|
|
return { ...ca, publicKey };
|
|
};
|
|
|
|
if (outerTx) {
|
|
return processCreation(outerTx);
|
|
}
|
|
|
|
return sshCertificateAuthorityDAL.transaction(processCreation);
|
|
};
|
|
|
|
/**
|
|
* Convert an actor to a list of principals to be included in an SSH certificate.
|
|
*
|
|
* (dangtony98): This function is only supported for user actors at the moment and returns
|
|
* only the email of the associated user. In the future, we will consider other
|
|
* actor types and attributes such as group membership slugs and/or metadata to be
|
|
* included in the list of principals.
|
|
*/
|
|
export const convertActorToPrincipals = async ({ userDAL, actor, actorId }: TConvertActorToPrincipalsDTO) => {
|
|
if (actor !== ActorType.USER) {
|
|
throw new BadRequestError({
|
|
message: "Failed to convert actor to principals due to unsupported actor type"
|
|
});
|
|
}
|
|
|
|
const user = await userDAL.findById(actorId);
|
|
|
|
return [user.username];
|
|
};
|