From efe10e361f047ace0febc7c489a80686fce117bd Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Fri, 24 May 2024 22:14:32 +0800 Subject: [PATCH] feat: added personal overrides and support for secret ref to download envs --- frontend/src/helpers/secret.ts | 175 ++++++++++++++++++ frontend/src/hooks/api/secrets/queries.tsx | 2 +- .../components/ActionBar/ActionBar.tsx | 48 ++++- 3 files changed, 216 insertions(+), 9 deletions(-) create mode 100644 frontend/src/helpers/secret.ts diff --git a/frontend/src/helpers/secret.ts b/frontend/src/helpers/secret.ts new file mode 100644 index 000000000..607fa4268 --- /dev/null +++ b/frontend/src/helpers/secret.ts @@ -0,0 +1,175 @@ +import path from "path"; + +import { decryptSymmetric } from "@app/components/utilities/cryptography/crypto"; +import { fetchProjectEncryptedSecrets } from "@app/hooks/api/secrets/queries"; + +const INTERPOLATION_SYNTAX_REG = /\${([^}]+)}/g; +export const interpolateSecrets = ({ + projectId, + secretEncKey +}: { + projectId: string; + secretEncKey: string; +}) => { + const fetchSecretsCrossEnv = () => { + const fetchCache: Record> = {}; + + return async (secRefEnv: string, secRefPath: string[], secRefKey: string) => { + const secRefPathUrl = path.join("/", ...secRefPath); + const uniqKey = `${secRefEnv}-${secRefPathUrl}`; + + if (fetchCache?.[uniqKey]) { + return fetchCache[uniqKey][secRefKey]; + } + + // get secrets by projectId, env, path + const encryptedSecrets = await fetchProjectEncryptedSecrets({ + workspaceId: projectId, + environment: secRefEnv, + secretPath: secRefPathUrl + }); + + const decryptedSec = encryptedSecrets.reduce>((prev, secret) => { + const secretKey = decryptSymmetric({ + ciphertext: secret.secretKeyCiphertext, + iv: secret.secretKeyIV, + tag: secret.secretKeyTag, + key: secretEncKey + }); + const secretValue = decryptSymmetric({ + ciphertext: secret.secretValueCiphertext, + iv: secret.secretValueIV, + tag: secret.secretValueTag, + key: secretEncKey + }); + + // eslint-disable-next-line + prev[secretKey] = secretValue; + return prev; + }, {}); + + fetchCache[uniqKey] = decryptedSec; + + return fetchCache[uniqKey][secRefKey]; + }; + }; + + const recursivelyExpandSecret = async ( + expandedSec: Record, + interpolatedSec: Record, + fetchCrossEnv: (env: string, secPath: string[], secKey: string) => Promise, + recursionChainBreaker: Record, + key: string + ) => { + if (expandedSec?.[key] !== undefined) { + return expandedSec[key]; + } + if (recursionChainBreaker?.[key]) { + return ""; + } + // eslint-disable-next-line + recursionChainBreaker[key] = true; + + let interpolatedValue = interpolatedSec[key]; + if (!interpolatedValue) { + // eslint-disable-next-line no-console + console.error(`Couldn't find referenced value - ${key}`); + return ""; + } + + const refs = interpolatedValue.match(INTERPOLATION_SYNTAX_REG); + if (refs) { + await Promise.all( + refs.map(async (interpolationSyntax) => { + const interpolationKey = interpolationSyntax.slice(2, interpolationSyntax.length - 1); + const entities = interpolationKey.trim().split("."); + + if (entities.length === 1) { + const val = await recursivelyExpandSecret( + expandedSec, + interpolatedSec, + fetchCrossEnv, + recursionChainBreaker, + interpolationKey + ); + if (val) { + interpolatedValue = interpolatedValue.replaceAll(interpolationSyntax, val); + } + return; + } + + if (entities.length > 1) { + const secRefEnv = entities[0]; + const secRefPath = entities.slice(1, entities.length - 1); + const secRefKey = entities[entities.length - 1]; + + const val = await fetchCrossEnv(secRefEnv, secRefPath, secRefKey); + if (val) { + interpolatedValue = interpolatedValue.replaceAll(interpolationSyntax, val); + } + } + }) + ); + } + + // eslint-disable-next-line + expandedSec[key] = interpolatedValue; + return interpolatedValue; + }; + + // used to convert multi line ones to quotes ones with \n + const formatMultiValueEnv = (val?: string) => { + if (!val) return ""; + if (!val.match("\n")) return val; + return `"${val.replace(/\n/g, "\\n")}"`; + }; + + const expandSecrets = async ( + secrets: Record + ) => { + const expandedSec: Record = {}; + const interpolatedSec: Record = {}; + + const crossSecEnvFetch = fetchSecretsCrossEnv(); + + Object.keys(secrets).forEach((key) => { + if (secrets[key].value.match(INTERPOLATION_SYNTAX_REG)) { + interpolatedSec[key] = secrets[key].value; + } else { + expandedSec[key] = secrets[key].value; + } + }); + + await Promise.all( + Object.keys(secrets).map(async (key) => { + if (expandedSec?.[key]) { + // should not do multi line encoding if user has set it to skip + // eslint-disable-next-line + secrets[key].value = secrets[key].skipMultilineEncoding + ? expandedSec[key] + : formatMultiValueEnv(expandedSec[key]); + return; + } + + // this is to avoid recursion loop. So the graph should be direct graph rather than cyclic + // so for any recursion building if there is an entity two times same key meaning it will be looped + const recursionChainBreaker: Record = {}; + const expandedVal = await recursivelyExpandSecret( + expandedSec, + interpolatedSec, + crossSecEnvFetch, + recursionChainBreaker, + key + ); + + // eslint-disable-next-line + secrets[key].value = secrets[key].skipMultilineEncoding + ? expandedVal + : formatMultiValueEnv(expandedVal); + }) + ); + + return secrets; + }; + return expandSecrets; +}; diff --git a/frontend/src/hooks/api/secrets/queries.tsx b/frontend/src/hooks/api/secrets/queries.tsx index 1ba9a5251..28999389e 100644 --- a/frontend/src/hooks/api/secrets/queries.tsx +++ b/frontend/src/hooks/api/secrets/queries.tsx @@ -98,7 +98,7 @@ export const decryptSecrets = ( return secrets; }; -const fetchProjectEncryptedSecrets = async ({ +export const fetchProjectEncryptedSecrets = async ({ workspaceId, environment, secretPath diff --git a/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx b/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx index 61c7cf8c0..9b235867b 100644 --- a/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx +++ b/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx @@ -23,6 +23,7 @@ import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; import { ProjectPermissionCan } from "@app/components/permissions"; +import { decryptAssymmetric } from "@app/components/utilities/cryptography/crypto"; import { Button, DeleteActionModal, @@ -43,8 +44,9 @@ import { UpgradePlanModal } from "@app/components/v2"; import { ProjectPermissionActions, ProjectPermissionSub, useSubscription } from "@app/context"; +import { interpolateSecrets } from "@app/helpers/secret"; import { usePopUp } from "@app/hooks"; -import { useCreateFolder, useDeleteSecretBatch } from "@app/hooks/api"; +import { useCreateFolder, useDeleteSecretBatch, useGetUserWsKey } from "@app/hooks/api"; import { DecryptedSecret, TImportedSecrets, WsTag } from "@app/hooks/api/types"; import { debounce } from "@app/lib/fn/debounce"; @@ -112,6 +114,7 @@ export const ActionBar = ({ const { mutateAsync: createFolder } = useCreateFolder(); const { mutateAsync: deleteBatchSecretV3 } = useDeleteSecretBatch(); + const { data: decryptFileKey } = useGetUserWsKey(workspaceId); const selectedSecrets = useSelectedSecrets(); const { reset: resetSelectedSecret } = useSelectedSecretActions(); @@ -144,30 +147,59 @@ export const ActionBar = ({ const handleSecretDownload = async () => { const secPriority: Record = {}; const downloadedSecrets: Array<{ key: string; value: string; comment?: string }> = []; + + const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string; + const workspaceKey = decryptAssymmetric({ + ciphertext: decryptFileKey!.encryptedKey, + nonce: decryptFileKey!.nonce, + publicKey: decryptFileKey!.sender.publicKey, + privateKey: PRIVATE_KEY + }); + + const expandSecrets = interpolateSecrets({ + projectId: workspaceId, + secretEncKey: workspaceKey + }); + + const secretRecord: Record< + string, + { value: string; comment?: string; skipMultilineEncoding?: boolean } + > = {}; + // load up secrets in dashboard - secrets?.forEach(({ key, value, comment }) => { + secrets?.forEach(({ key, value, valueOverride, comment }) => { secPriority[key] = true; - downloadedSecrets.push({ key, value, comment }); + downloadedSecrets.push({ key, value: valueOverride || value, comment }); }); // now load imported secrets with secPriority for (let i = importedSecrets.length - 1; i >= 0; i -= 1) { - importedSecrets[i].secrets.forEach(({ key, value, comment }) => { + importedSecrets[i].secrets.forEach(({ key, value, valueOverride, comment }) => { if (secPriority?.[key]) return; - downloadedSecrets.unshift({ key, value, comment }); + downloadedSecrets.unshift({ key, value: valueOverride || value, comment }); secPriority[key] = true; }); } + downloadedSecrets.forEach((secret) => { + secretRecord[secret.key] = { + value: secret.value, + comment: secret.comment + }; + }); + + await expandSecrets(secretRecord); + const file = downloadedSecrets .sort((a, b) => a.key.toLowerCase().localeCompare(b.key.toLowerCase())) .reduce( - (prev, { key, value, comment }, index) => + (prev, { key, comment }, index) => prev + (comment - ? `${index === 0 ? "#" : "\n#"} ${comment}\n${key}=${value}\n` - : `${key}=${value}\n`), + ? `${index === 0 ? "#" : "\n#"} ${comment}\n${key}=${secretRecord[key].value}\n` + : `${key}=${secretRecord[key].value}\n`), "" ); + const blob = new Blob([file], { type: "text/plain;charset=utf-8" }); FileSaver.saveAs(blob, `${environment}.env`); };