From 9fc9f69fc93ae947afb9f156c4b8f13b76d47f86 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Thu, 3 Apr 2025 22:46:41 -0700 Subject: [PATCH] Finish preliminary support for external key source for ssh cas --- .../20250404022310_ssh-ca-key-source.ts | 26 +++ backend/src/db/schemas/organizations.ts | 4 +- .../db/schemas/ssh-certificate-authorities.ts | 3 +- .../v1/ssh-certificate-authority-router.ts | 39 ++++- .../ssh/ssh-certificate-authority-fns.ts | 85 ++++++++++ .../ssh/ssh-certificate-authority-schema.ts | 3 +- .../ssh/ssh-certificate-authority-service.ts | 39 ++++- .../ssh/ssh-certificate-authority-types.ts | 8 + backend/src/lib/api-docs/constants.ts | 9 +- frontend/src/hooks/api/sshCa/constants.tsx | 5 + frontend/src/hooks/api/sshCa/types.ts | 23 ++- .../OverviewPage/components/SshCaModal.tsx | 160 ++++++++++++++---- 12 files changed, 346 insertions(+), 58 deletions(-) create mode 100644 backend/src/db/migrations/20250404022310_ssh-ca-key-source.ts diff --git a/backend/src/db/migrations/20250404022310_ssh-ca-key-source.ts b/backend/src/db/migrations/20250404022310_ssh-ca-key-source.ts new file mode 100644 index 000000000..45955fc12 --- /dev/null +++ b/backend/src/db/migrations/20250404022310_ssh-ca-key-source.ts @@ -0,0 +1,26 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasColumn(TableName.SshCertificateAuthority, "keySource"))) { + await knex.schema.alterTable(TableName.SshCertificateAuthority, (t) => { + t.string("keySource"); + }); + + // Backfilling the keySource to internal + await knex(TableName.SshCertificateAuthority).update({ keySource: "internal" }); + + await knex.schema.alterTable(TableName.SshCertificateAuthority, (t) => { + t.string("keySource").notNullable().alter(); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.SshCertificateAuthority, "keySource")) { + await knex.schema.alterTable(TableName.SshCertificateAuthority, (t) => { + t.dropColumn("keySource"); + }); + } +} diff --git a/backend/src/db/schemas/organizations.ts b/backend/src/db/schemas/organizations.ts index 7e5994938..a18e258c7 100644 --- a/backend/src/db/schemas/organizations.ts +++ b/backend/src/db/schemas/organizations.ts @@ -23,10 +23,10 @@ export const OrganizationsSchema = z.object({ defaultMembershipRole: z.string().default("member"), enforceMfa: z.boolean().default(false), selectedMfaMethod: z.string().nullable().optional(), + allowSecretSharingOutsideOrganization: z.boolean().default(true).nullable().optional(), shouldUseNewPrivilegeSystem: z.boolean().default(true), privilegeUpgradeInitiatedByUsername: z.string().nullable().optional(), - privilegeUpgradeInitiatedAt: z.date().nullable().optional(), - allowSecretSharingOutsideOrganization: z.boolean().default(true).nullable().optional() + privilegeUpgradeInitiatedAt: z.date().nullable().optional() }); export type TOrganizations = z.infer; diff --git a/backend/src/db/schemas/ssh-certificate-authorities.ts b/backend/src/db/schemas/ssh-certificate-authorities.ts index 81e789288..75603406f 100644 --- a/backend/src/db/schemas/ssh-certificate-authorities.ts +++ b/backend/src/db/schemas/ssh-certificate-authorities.ts @@ -14,7 +14,8 @@ export const SshCertificateAuthoritiesSchema = z.object({ projectId: z.string(), status: z.string(), friendlyName: z.string(), - keyAlgorithm: z.string() + keyAlgorithm: z.string(), + keySource: z.string() }); export type TSshCertificateAuthorities = z.infer; diff --git a/backend/src/ee/routes/v1/ssh-certificate-authority-router.ts b/backend/src/ee/routes/v1/ssh-certificate-authority-router.ts index ab80888d7..da9c2bd6a 100644 --- a/backend/src/ee/routes/v1/ssh-certificate-authority-router.ts +++ b/backend/src/ee/routes/v1/ssh-certificate-authority-router.ts @@ -1,8 +1,9 @@ import { z } from "zod"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { normalizeSshPrivateKey } from "@app/ee/services/ssh/ssh-certificate-authority-fns"; import { sanitizedSshCa } from "@app/ee/services/ssh/ssh-certificate-authority-schema"; -import { SshCaStatus } from "@app/ee/services/ssh/ssh-certificate-authority-types"; +import { SshCaKeySource, SshCaStatus } from "@app/ee/services/ssh/ssh-certificate-authority-types"; import { sanitizedSshCertificateTemplate } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-schema"; import { SSH_CERTIFICATE_AUTHORITIES } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; @@ -20,14 +21,34 @@ export const registerSshCaRouter = async (server: FastifyZodProvider) => { onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { description: "Create SSH CA", - body: z.object({ - projectId: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.CREATE.projectId), - friendlyName: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.CREATE.friendlyName), - keyAlgorithm: z - .nativeEnum(CertKeyAlgorithm) - .default(CertKeyAlgorithm.RSA_2048) - .describe(SSH_CERTIFICATE_AUTHORITIES.CREATE.keyAlgorithm) - }), + body: z + .object({ + projectId: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.CREATE.projectId), + friendlyName: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.CREATE.friendlyName), + keyAlgorithm: z + .nativeEnum(CertKeyAlgorithm) + .default(CertKeyAlgorithm.RSA_2048) + .describe(SSH_CERTIFICATE_AUTHORITIES.CREATE.keyAlgorithm), + publicKey: z.string().trim().optional().describe(SSH_CERTIFICATE_AUTHORITIES.CREATE.publicKey), + privateKey: z + .string() + .trim() + .optional() + .transform((val) => (val ? normalizeSshPrivateKey(val) : undefined)) + .describe(SSH_CERTIFICATE_AUTHORITIES.CREATE.privateKey), + keySource: z + .nativeEnum(SshCaKeySource) + .default(SshCaKeySource.INTERNAL) + .describe(SSH_CERTIFICATE_AUTHORITIES.CREATE.keySource) + }) + .refine((data) => data.keySource === SshCaKeySource.INTERNAL || (!!data.publicKey && !!data.privateKey), { + message: "publicKey and privateKey are required when keySource is external", + path: ["publicKey"] + }) + .refine((data) => data.keySource === SshCaKeySource.EXTERNAL || !!data.keyAlgorithm, { + message: "keyAlgorithm is required when keySource is internal", + path: ["keyAlgorithm"] + }), response: { 200: z.object({ ca: sanitizedSshCa.extend({ diff --git a/backend/src/ee/services/ssh/ssh-certificate-authority-fns.ts b/backend/src/ee/services/ssh/ssh-certificate-authority-fns.ts index deb77cecc..98e219f4f 100644 --- a/backend/src/ee/services/ssh/ssh-certificate-authority-fns.ts +++ b/backend/src/ee/services/ssh/ssh-certificate-authority-fns.ts @@ -322,6 +322,91 @@ const validateSshPublicKey = async (publicKey: string) => { } }; +export const getKeyAlgorithmFromFingerprintOutput = (output: string): CertKeyAlgorithm | 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 ? CertKeyAlgorithm.RSA_2048 : CertKeyAlgorithm.RSA_4096; + } + + if (keyTypeRaw === "ECDSA") { + return bitsInt === 256 ? CertKeyAlgorithm.ECDSA_P256 : CertKeyAlgorithm.ECDSA_P384; + } + + 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. */ diff --git a/backend/src/ee/services/ssh/ssh-certificate-authority-schema.ts b/backend/src/ee/services/ssh/ssh-certificate-authority-schema.ts index 9ff76efbc..af66e83ca 100644 --- a/backend/src/ee/services/ssh/ssh-certificate-authority-schema.ts +++ b/backend/src/ee/services/ssh/ssh-certificate-authority-schema.ts @@ -5,5 +5,6 @@ export const sanitizedSshCa = SshCertificateAuthoritiesSchema.pick({ projectId: true, friendlyName: true, status: true, - keyAlgorithm: true + keyAlgorithm: true, + keySource: true }); diff --git a/backend/src/ee/services/ssh/ssh-certificate-authority-service.ts b/backend/src/ee/services/ssh/ssh-certificate-authority-service.ts index d7ca511e4..99d54f68a 100644 --- a/backend/src/ee/services/ssh/ssh-certificate-authority-service.ts +++ b/backend/src/ee/services/ssh/ssh-certificate-authority-service.ts @@ -9,12 +9,19 @@ import { TSshCertificateBodyDALFactory } from "@app/ee/services/ssh-certificate/ import { TSshCertificateDALFactory } from "@app/ee/services/ssh-certificate/ssh-certificate-dal"; import { TSshCertificateTemplateDALFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-dal"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; import { SshCertTemplateStatus } from "../ssh-certificate-template/ssh-certificate-template-types"; -import { createSshCert, createSshKeyPair, getSshPublicKey } from "./ssh-certificate-authority-fns"; import { + createSshCert, + createSshKeyPair, + getSshPublicKey, + validateExternalSshCaKeyPair +} from "./ssh-certificate-authority-fns"; +import { + SshCaKeySource, SshCaStatus, TCreateSshCaDTO, TDeleteSshCaDTO, @@ -59,7 +66,10 @@ export const sshCertificateAuthorityServiceFactory = ({ const createSshCa = async ({ projectId, friendlyName, - keyAlgorithm, + keyAlgorithm: requestedKeyAlgorithm, + publicKey: externalPk, + privateKey: externalSk, + keySource, actorId, actorAuthMethod, actor, @@ -80,18 +90,37 @@ export const sshCertificateAuthorityServiceFactory = ({ ); const newCa = await sshCertificateAuthorityDAL.transaction(async (tx) => { + let publicKey: string; + let privateKey: string; + let keyAlgorithm: CertKeyAlgorithm = 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 + keyAlgorithm, + keySource }, tx ); - const { publicKey, privateKey } = await createSshKeyPair(keyAlgorithm); - const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.SecretManager, projectId diff --git a/backend/src/ee/services/ssh/ssh-certificate-authority-types.ts b/backend/src/ee/services/ssh/ssh-certificate-authority-types.ts index 3f202ebf0..d2dcb3807 100644 --- a/backend/src/ee/services/ssh/ssh-certificate-authority-types.ts +++ b/backend/src/ee/services/ssh/ssh-certificate-authority-types.ts @@ -7,6 +7,11 @@ export enum SshCaStatus { DISABLED = "disabled" } +export enum SshCaKeySource { + INTERNAL = "internal", + EXTERNAL = "external" +} + export enum SshCertType { USER = "user", HOST = "host" @@ -15,6 +20,9 @@ export enum SshCertType { export type TCreateSshCaDTO = { friendlyName: string; keyAlgorithm: CertKeyAlgorithm; + publicKey?: string; + privateKey?: string; + keySource: SshCaKeySource; } & TProjectPermission; export type TGetSshCaDTO = { diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index bd6056359..1f67cd6e0 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -633,7 +633,8 @@ export const FOLDERS = { path: "The path to list folders from.", directory: "The directory to list folders from. (Deprecated in favor of path)", recursive: "Whether or not to fetch all folders from the specified base path, and all of its subdirectories.", - lastSecretModified: "The timestamp used to filter folders with secrets modified after the specified date. The format for this timestamp is ISO 8601 (e.g. 2025-04-01T09:41:45-04:00)" + lastSecretModified: + "The timestamp used to filter folders with secrets modified after the specified date. The format for this timestamp is ISO 8601 (e.g. 2025-04-01T09:41:45-04:00)" }, GET_BY_ID: { folderId: "The ID of the folder to get details." @@ -1234,7 +1235,11 @@ export const SSH_CERTIFICATE_AUTHORITIES = { CREATE: { projectId: "The ID of the project to create the SSH CA in.", friendlyName: "A friendly name for the SSH CA.", - keyAlgorithm: "The type of public key algorithm and size, in bits, of the key pair for the SSH CA." + keyAlgorithm: + "The type of public key algorithm and size, in bits, of the key pair for the SSH CA; required if keySource is internal.", + publicKey: "The public key for the SSH CA key pair; required if keySource is external.", + privateKey: "The private key for the SSH CA key pair; required if keySource is external.", + keySource: "The source of the SSH CA key pair. This can be one of internal or external." }, GET: { sshCaId: "The ID of the SSH CA to get." diff --git a/frontend/src/hooks/api/sshCa/constants.tsx b/frontend/src/hooks/api/sshCa/constants.tsx index 2742a7bfa..50b478952 100644 --- a/frontend/src/hooks/api/sshCa/constants.tsx +++ b/frontend/src/hooks/api/sshCa/constants.tsx @@ -12,3 +12,8 @@ export const sshCertTypeToNameMap: { [K in SshCertType]: string } = { [SshCertType.USER]: "User", [SshCertType.HOST]: "Host" }; + +export enum SshCaKeySource { + INTERNAL = "internal", + EXTERNAL = "external" +} diff --git a/frontend/src/hooks/api/sshCa/types.ts b/frontend/src/hooks/api/sshCa/types.ts index 6e5f02c4d..390d697d1 100644 --- a/frontend/src/hooks/api/sshCa/types.ts +++ b/frontend/src/hooks/api/sshCa/types.ts @@ -1,5 +1,5 @@ import { CertKeyAlgorithm } from "../certificates/enums"; -import { SshCaStatus, SshCertType } from "./constants"; +import { SshCaKeySource, SshCaStatus, SshCertType } from "./constants"; export type TSshCertificate = { id: string; @@ -19,16 +19,27 @@ export type TSshCertificateAuthority = { status: SshCaStatus; friendlyName: string; keyAlgorithm: CertKeyAlgorithm; + keySource: SshCaKeySource; createdAt: string; updatedAt: string; publicKey: string; }; -export type TCreateSshCaDTO = { - projectId: string; - friendlyName?: string; - keyAlgorithm: CertKeyAlgorithm; -}; +export type TCreateSshCaDTO = + | { + projectId: string; + friendlyName?: string; + keySource: SshCaKeySource.INTERNAL; + keyAlgorithm: CertKeyAlgorithm; + } + | { + projectId: string; + friendlyName?: string; + keySource: SshCaKeySource.EXTERNAL; + keyAlgorithm: CertKeyAlgorithm; + publicKey: string; + privateKey: string; + }; export type TUpdateSshCaDTO = { caId: string; diff --git a/frontend/src/pages/ssh/OverviewPage/components/SshCaModal.tsx b/frontend/src/pages/ssh/OverviewPage/components/SshCaModal.tsx index 4ddbacaba..ed2563cff 100644 --- a/frontend/src/pages/ssh/OverviewPage/components/SshCaModal.tsx +++ b/frontend/src/pages/ssh/OverviewPage/components/SshCaModal.tsx @@ -12,12 +12,14 @@ import { Modal, ModalContent, Select, - SelectItem + SelectItem, + TextArea } from "@app/components/v2"; import { useWorkspace } from "@app/context"; import { useCreateSshCa, useGetSshCaById, useUpdateSshCa } from "@app/hooks/api"; import { certKeyAlgorithms } from "@app/hooks/api/certificates/constants"; import { CertKeyAlgorithm } from "@app/hooks/api/certificates/enums"; +import { SshCaKeySource } from "@app/hooks/api/sshCa/constants"; import { ProjectType } from "@app/hooks/api/workspace/types"; import { UsePopUpState } from "@app/hooks/usePopUp"; @@ -26,9 +28,17 @@ type Props = { handlePopUpToggle: (popUpName: keyof UsePopUpState<["sshCa"]>, state?: boolean) => void; }; +const sshCaKeySources = [ + { label: "Internal", value: SshCaKeySource.INTERNAL }, + { label: "External", value: SshCaKeySource.EXTERNAL } +]; + const schema = z .object({ friendlyName: z.string(), + keySource: z.nativeEnum(SshCaKeySource), + publicKey: z.string().optional(), + privateKey: z.string().optional(), keyAlgorithm: z.enum([ CertKeyAlgorithm.RSA_2048, CertKeyAlgorithm.RSA_4096, @@ -53,30 +63,47 @@ export const SshCaModal = ({ popUp, handlePopUpToggle }: Props) => { control, handleSubmit, reset, - formState: { isSubmitting } + formState: { isSubmitting }, + watch } = useForm({ resolver: zodResolver(schema), defaultValues: { friendlyName: "", - keyAlgorithm: CertKeyAlgorithm.RSA_2048 + keyAlgorithm: CertKeyAlgorithm.RSA_2048, + keySource: SshCaKeySource.INTERNAL, + publicKey: "", + privateKey: "" } }); + const caKeySource = watch("keySource"); + useEffect(() => { if (ca) { reset({ friendlyName: ca.friendlyName, - keyAlgorithm: ca.keyAlgorithm + keyAlgorithm: ca.keyAlgorithm, + keySource: ca.keySource, + publicKey: ca.publicKey }); } else { reset({ friendlyName: "", - keyAlgorithm: CertKeyAlgorithm.RSA_2048 + keyAlgorithm: CertKeyAlgorithm.RSA_2048, + keySource: SshCaKeySource.INTERNAL, + publicKey: "", + privateKey: "" }); } }, [ca]); - const onFormSubmit = async ({ friendlyName, keyAlgorithm }: FormData) => { + const onFormSubmit = async ({ + friendlyName, + keySource, + keyAlgorithm, + publicKey, + privateKey + }: FormData) => { try { if (!projectId) return; @@ -89,7 +116,10 @@ export const SshCaModal = ({ popUp, handlePopUpToggle }: Props) => { const { id: newCaId } = await createMutateAsync({ projectId, friendlyName, - keyAlgorithm + keySource, + keyAlgorithm, + publicKey, + privateKey }); navigate({ @@ -147,32 +177,98 @@ export const SshCaModal = ({ popUp, handlePopUpToggle }: Props) => { )} /> - ( - - - - )} - /> + + + )} + /> + )} + {caKeySource === SshCaKeySource.INTERNAL && ( + ( + + + + )} + /> + )} + {caKeySource === SshCaKeySource.EXTERNAL && !ca && ( + <> + ( + + + + )} + /> + ( + +