From d79099946a8524c6995cf81f96978176466dcec0 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Wed, 25 Sep 2024 16:05:50 +0400 Subject: [PATCH] feat(secret-sharing): server-side encryption --- .../20240925100349_managed-secret-sharing.ts | 27 +++++++ backend/src/db/schemas/secret-sharing.ts | 11 ++- backend/src/server/routes/index.ts | 3 +- .../server/routes/v1/secret-sharing-router.ts | 23 +++--- .../secret-sharing/secret-sharing-service.ts | 76 +++++++++++++------ .../secret-sharing/secret-sharing-types.ts | 5 +- .../src/hooks/api/secretSharing/mutations.ts | 14 +++- frontend/src/hooks/api/secretSharing/types.ts | 12 +-- .../components/ShareSecretForm.tsx | 23 +----- .../ViewSecretPublicPage.tsx | 37 +++++++-- .../components/SecretContainer.tsx | 6 +- 11 files changed, 155 insertions(+), 82 deletions(-) create mode 100644 backend/src/db/migrations/20240925100349_managed-secret-sharing.ts diff --git a/backend/src/db/migrations/20240925100349_managed-secret-sharing.ts b/backend/src/db/migrations/20240925100349_managed-secret-sharing.ts new file mode 100644 index 000000000..25871455a --- /dev/null +++ b/backend/src/db/migrations/20240925100349_managed-secret-sharing.ts @@ -0,0 +1,27 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.SecretSharing)) { + await knex.schema.alterTable(TableName.SecretSharing, (t) => { + t.string("iv").nullable().alter(); + t.string("tag").nullable().alter(); + t.string("encryptedValue").nullable().alter(); + + t.binary("encryptedSecret").nullable(); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.SecretSharing)) { + await knex.schema.alterTable(TableName.SecretSharing, (t) => { + t.string("iv").notNullable().alter(); + t.string("tag").notNullable().alter(); + t.string("encryptedValue").notNullable().alter(); + + t.dropColumn("encryptedSecret"); + }); + } +} diff --git a/backend/src/db/schemas/secret-sharing.ts b/backend/src/db/schemas/secret-sharing.ts index 2d0fc5eb5..d7597af6e 100644 --- a/backend/src/db/schemas/secret-sharing.ts +++ b/backend/src/db/schemas/secret-sharing.ts @@ -5,13 +5,15 @@ import { z } from "zod"; +import { zodBuffer } from "@app/lib/zod"; + import { TImmutableDBKeys } from "./models"; export const SecretSharingSchema = z.object({ id: z.string().uuid(), - encryptedValue: z.string(), - iv: z.string(), - tag: z.string(), + encryptedValue: z.string().nullable().optional(), + iv: z.string().nullable().optional(), + tag: z.string().nullable().optional(), hashedHex: z.string(), expiresAt: z.date(), userId: z.string().uuid().nullable().optional(), @@ -22,7 +24,8 @@ export const SecretSharingSchema = z.object({ accessType: z.string().default("anyone"), name: z.string().nullable().optional(), lastViewedAt: z.date().nullable().optional(), - password: z.string().nullable().optional() + password: z.string().nullable().optional(), + encryptedSecret: zodBuffer.nullable().optional() }); export type TSecretSharing = z.infer; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 5d9632215..c392cb24e 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -917,7 +917,8 @@ export const registerRoutes = async ( const secretSharingService = secretSharingServiceFactory({ permissionService, secretSharingDAL, - orgDAL + orgDAL, + kmsService }); const accessApprovalPolicyService = accessApprovalPolicyServiceFactory({ diff --git a/backend/src/server/routes/v1/secret-sharing-router.ts b/backend/src/server/routes/v1/secret-sharing-router.ts index 7a909cae4..0c021fc53 100644 --- a/backend/src/server/routes/v1/secret-sharing-router.ts +++ b/backend/src/server/routes/v1/secret-sharing-router.ts @@ -73,7 +73,8 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) => accessType: true }) .extend({ - orgName: z.string().optional() + orgName: z.string().optional(), + secretValue: z.string().optional() }) .optional() }) @@ -99,17 +100,15 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) => }, schema: { body: z.object({ - encryptedValue: z.string(), + secretValue: z.string(), password: z.string().optional(), - hashedHex: z.string(), - iv: z.string(), - tag: z.string(), expiresAt: z.string(), expiresAfterViews: z.number().min(1).optional() }), response: { 200: z.object({ - id: z.string().uuid() + id: z.string().uuid(), + hashedHex: z.string() }) } }, @@ -118,7 +117,7 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) => ...req.body, accessType: SecretSharingAccessType.Anyone }); - return { id: sharedSecret.id }; + return { id: sharedSecret.id, hashedHex: sharedSecret.hashedHex }; } }); @@ -132,17 +131,15 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) => body: z.object({ name: z.string().max(50).optional(), password: z.string().optional(), - encryptedValue: z.string(), - hashedHex: z.string(), - iv: z.string(), - tag: z.string(), + secretValue: z.string(), expiresAt: z.string(), expiresAfterViews: z.number().min(1).optional(), accessType: z.nativeEnum(SecretSharingAccessType).default(SecretSharingAccessType.Organization) }), response: { 200: z.object({ - id: z.string().uuid() + id: z.string().uuid(), + hashedHex: z.string() }) } }, @@ -156,7 +153,7 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) => actorOrgId: req.permission.orgId, ...req.body }); - return { id: sharedSecret.id }; + return { id: sharedSecret.id, hashedHex: sharedSecret.hashedHex }; } }); diff --git a/backend/src/services/secret-sharing/secret-sharing-service.ts b/backend/src/services/secret-sharing/secret-sharing-service.ts index 47eefcf6e..3cb75ad75 100644 --- a/backend/src/services/secret-sharing/secret-sharing-service.ts +++ b/backend/src/services/secret-sharing/secret-sharing-service.ts @@ -1,3 +1,5 @@ +import crypto from "node:crypto"; + import bcrypt from "bcrypt"; import { TSecretSharing } from "@app/db/schemas"; @@ -5,6 +7,7 @@ import { TPermissionServiceFactory } from "@app/ee/services/permission/permissio import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; import { SecretSharingAccessType } from "@app/lib/types"; +import { TKmsServiceFactory } from "../kms/kms-service"; import { TOrgDALFactory } from "../org/org-dal"; import { TSecretSharingDALFactory } from "./secret-sharing-dal"; import { @@ -19,6 +22,7 @@ type TSecretSharingServiceFactoryDep = { permissionService: Pick; secretSharingDAL: TSecretSharingDALFactory; orgDAL: TOrgDALFactory; + kmsService: TKmsServiceFactory; }; export type TSecretSharingServiceFactory = ReturnType; @@ -26,7 +30,8 @@ export type TSecretSharingServiceFactory = ReturnType { const createSharedSecret = async ({ actor, @@ -34,10 +39,7 @@ export const secretSharingServiceFactory = ({ orgId, actorAuthMethod, actorOrgId, - encryptedValue, - hashedHex, - iv, - tag, + secretValue, name, password, accessType, @@ -59,19 +61,28 @@ export const secretSharingServiceFactory = ({ throw new BadRequestError({ message: "Expiration date cannot be more than 30 days" }); } - // Limit Input ciphertext length to 13000 (equivalent to 10,000 characters of Plaintext) - if (encryptedValue.length > 13000) { + if (secretValue.length > 10_000) { throw new BadRequestError({ message: "Shared secret value too long" }); } + const encryptWithRoot = await kmsService.encryptWithRootKey(); + + const encryptedSecret = await encryptWithRoot({ + plainText: Buffer.from(secretValue) + }); + const hashedHex = crypto.createHash("sha256").update(secretValue).digest("hex").substring(0, 13); const hashedPassword = password ? await bcrypt.hash(password, 10) : null; + const newSharedSecret = await secretSharingDAL.create({ + iv: null, + tag: null, + encryptedValue: null, + + encryptedSecret: encryptedSecret.cipherTextBlob, + hashedHex, + name, password: hashedPassword, - encryptedValue, - hashedHex, - iv, - tag, expiresAt: new Date(expiresAt), expiresAfterViews, userId: actorId, @@ -79,15 +90,12 @@ export const secretSharingServiceFactory = ({ accessType }); - return { id: newSharedSecret.id }; + return { id: newSharedSecret.id, hashedHex: newSharedSecret.hashedHex }; }; const createPublicSharedSecret = async ({ password, - encryptedValue, - hashedHex, - iv, - tag, + secretValue, expiresAt, expiresAfterViews, accessType @@ -104,24 +112,33 @@ export const secretSharingServiceFactory = ({ throw new BadRequestError({ message: "Expiration date cannot exceed more than 30 days" }); } - // Limit Input ciphertext length to 13000 (equivalent to 10,000 characters of Plaintext) - if (encryptedValue.length > 13000) { + // Limit Input ciphertext length to 13000 (equivalent to 10,000 characters of Plaintext)n + if (secretValue.length > 10_000) { throw new BadRequestError({ message: "Shared secret value too long" }); } + const encryptWithRoot = await kmsService.encryptWithRootKey(); + const encrypted = await encryptWithRoot({ + plainText: Buffer.from(secretValue) + }); + + const hashedHex = crypto.createHash("sha256").update(secretValue).digest("hex").substring(0, 13); const hashedPassword = password ? await bcrypt.hash(password, 10) : null; + const newSharedSecret = await secretSharingDAL.create({ - password: hashedPassword, - encryptedValue, + encryptedValue: null, + iv: null, + tag: null, hashedHex, - iv, - tag, + encryptedSecret: encrypted.cipherTextBlob, + + password: hashedPassword, expiresAt: new Date(expiresAt), expiresAfterViews, accessType }); - return { id: newSharedSecret.id }; + return { id: newSharedSecret.id, hashedHex: newSharedSecret.hashedHex }; }; const getSharedSecrets = async ({ @@ -222,6 +239,16 @@ export const secretSharingServiceFactory = ({ } } + // If encryptedSecret is set, we know that this secret has been encrypted using KMS, and we can therefore do server-side decryption. + let decryptedSecretValue: Buffer | undefined; + if (sharedSecret.encryptedSecret) { + const decrypt = await kmsService.decryptWithRootKey(); + + decryptedSecretValue = await decrypt({ + cipherTextBlob: sharedSecret.encryptedSecret + }); + } + // decrement when we are sure the user will view secret. await $decrementSecretViewCount(sharedSecret, sharedSecretId); @@ -229,6 +256,9 @@ export const secretSharingServiceFactory = ({ isPasswordProtected, secret: { ...sharedSecret, + ...(decryptedSecretValue && { + secretValue: Buffer.from(decryptedSecretValue).toString() + }), orgName: sharedSecret.accessType === SecretSharingAccessType.Organization && orgId === sharedSecret.orgId ? orgName diff --git a/backend/src/services/secret-sharing/secret-sharing-types.ts b/backend/src/services/secret-sharing/secret-sharing-types.ts index 794d99a33..9469e8de5 100644 --- a/backend/src/services/secret-sharing/secret-sharing-types.ts +++ b/backend/src/services/secret-sharing/secret-sharing-types.ts @@ -19,10 +19,7 @@ export type TSharedSecretPermission = { }; export type TCreatePublicSharedSecretDTO = { - encryptedValue: string; - hashedHex: string; - iv: string; - tag: string; + secretValue: string; expiresAt: string; expiresAfterViews?: number; password?: string; diff --git a/frontend/src/hooks/api/secretSharing/mutations.ts b/frontend/src/hooks/api/secretSharing/mutations.ts index 7dec0bd5e..e805abfd2 100644 --- a/frontend/src/hooks/api/secretSharing/mutations.ts +++ b/frontend/src/hooks/api/secretSharing/mutations.ts @@ -3,13 +3,21 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; import { secretSharingKeys } from "./queries"; -import { TCreateSharedSecretRequest, TDeleteSharedSecretRequest, TSharedSecret } from "./types"; +import { + TCreatedSharedSecret, + TCreateSharedSecretRequest, + TDeleteSharedSecretRequest, + TSharedSecret +} from "./types"; export const useCreateSharedSecret = () => { const queryClient = useQueryClient(); return useMutation({ mutationFn: async (inputData: TCreateSharedSecretRequest) => { - const { data } = await apiRequest.post("/api/v1/secret-sharing", inputData); + const { data } = await apiRequest.post( + "/api/v1/secret-sharing", + inputData + ); return data; }, onSuccess: () => queryClient.invalidateQueries(secretSharingKeys.allSharedSecrets()) @@ -20,7 +28,7 @@ export const useCreatePublicSharedSecret = () => { const queryClient = useQueryClient(); return useMutation({ mutationFn: async (inputData: TCreateSharedSecretRequest) => { - const { data } = await apiRequest.post( + const { data } = await apiRequest.post( "/api/v1/secret-sharing/public", inputData ); diff --git a/frontend/src/hooks/api/secretSharing/types.ts b/frontend/src/hooks/api/secretSharing/types.ts index 0dd4a9555..4f0b27d95 100644 --- a/frontend/src/hooks/api/secretSharing/types.ts +++ b/frontend/src/hooks/api/secretSharing/types.ts @@ -13,13 +13,15 @@ export type TSharedSecret = { tag: string; }; +export type TCreatedSharedSecret = { + id: string; + hashedHex: string; +}; + export type TCreateSharedSecretRequest = { name?: string; password?: string; - encryptedValue: string; - hashedHex: string; - iv: string; - tag: string; + secretValue: string; expiresAt: Date; expiresAfterViews?: number; accessType?: SecretSharingAccessType; @@ -28,6 +30,7 @@ export type TCreateSharedSecretRequest = { export type TViewSharedSecretResponse = { isPasswordProtected: boolean; secret: { + secretValue?: string; encryptedValue: string; iv: string; tag: string; @@ -44,4 +47,3 @@ export enum SecretSharingAccessType { Anyone = "anyone", Organization = "organization" } - diff --git a/frontend/src/views/ShareSecretPublicPage/components/ShareSecretForm.tsx b/frontend/src/views/ShareSecretPublicPage/components/ShareSecretForm.tsx index ea39e1265..91cd36257 100644 --- a/frontend/src/views/ShareSecretPublicPage/components/ShareSecretForm.tsx +++ b/frontend/src/views/ShareSecretPublicPage/components/ShareSecretForm.tsx @@ -1,5 +1,3 @@ -import crypto from "crypto"; - import { useState } from "react"; import { Controller, useForm } from "react-hook-form"; import { faCheck, faCopy, faRedo } from "@fortawesome/free-solid-svg-icons"; @@ -8,7 +6,6 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; -import { encryptSymmetric } from "@app/components/utilities/cryptography/crypto"; import { Button, FormControl, IconButton, Input, Select, SelectItem } from "@app/components/v2"; import { useTimedReset } from "@app/hooks"; import { useCreatePublicSharedSecret, useCreateSharedSecret } from "@app/hooks/api"; @@ -79,30 +76,16 @@ export const ShareSecretForm = ({ isPublic, value }: Props) => { try { const expiresAt = new Date(new Date().getTime() + Number(expiresIn)); - const key = crypto.randomBytes(16).toString("hex"); - const hashedHex = crypto.createHash("sha256").update(key).digest("hex"); - const { ciphertext, iv, tag } = encryptSymmetric({ - plaintext: secret, - key - }); - - const { id } = await createSharedSecret.mutateAsync({ + const { id, hashedHex } = await createSharedSecret.mutateAsync({ name, password, - encryptedValue: ciphertext, - hashedHex, - iv, - tag, + secretValue: secret, expiresAt, expiresAfterViews: viewLimit === "-1" ? undefined : Number(viewLimit), accessType }); - setSecretLink( - `${window.location.origin}/shared/secret/${id}?key=${encodeURIComponent( - hashedHex - )}-${encodeURIComponent(key)}` - ); + setSecretLink(`${window.location.origin}/shared/secret/${id}-${hashedHex}`); reset(); setCopyTextSecret("secret"); diff --git a/frontend/src/views/ViewSecretPublicPage/ViewSecretPublicPage.tsx b/frontend/src/views/ViewSecretPublicPage/ViewSecretPublicPage.tsx index 999f2d342..6d75ca782 100644 --- a/frontend/src/views/ViewSecretPublicPage/ViewSecretPublicPage.tsx +++ b/frontend/src/views/ViewSecretPublicPage/ViewSecretPublicPage.tsx @@ -1,23 +1,44 @@ import { useState } from "react"; import Image from "next/image"; import Link from "next/link"; -import { useRouter } from "next/router"; +import { NextRouter, useRouter } from "next/router"; import { faArrowRight } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { AxiosError } from "axios"; import { useGetActiveSharedSecretById } from "@app/hooks/api/secretSharing"; -import { PasswordContainer,SecretContainer, SecretErrorContainer } from "./components"; +import { PasswordContainer, SecretContainer, SecretErrorContainer } from "./components"; + +const extractDetailsFromUrl = (router: NextRouter) => { + const { id, key: urlEncodedKey } = router.query; + + if (urlEncodedKey) { + const [hashedHex, key] = urlEncodedKey ? urlEncodedKey.toString().split("-") : ["", ""]; + + return { + id: id as string, + hashedHex, + key + }; + } + + // its like this {uuid}-{hex} so example: idpart1-idpart2-idpart3-idpart4-hex + const extractedId = id?.toString().split("-").slice(0, 5).join("-"); + const extractedHex = id?.toString().split("-").slice(5).join("-"); + + return { + id: extractedId || "", + hashedHex: extractedHex || "", + key: null + }; +}; export const ViewSecretPublicPage = () => { const router = useRouter(); const [password, setPassword] = useState(); - const { id, key: urlEncodedPublicKey } = router.query; - const [hashedHex, key] = urlEncodedPublicKey - ? urlEncodedPublicKey.toString().split("-") - : ["", ""]; + const { hashedHex, key, id } = extractDetailsFromUrl(router); const { data: fetchSecret, @@ -25,7 +46,7 @@ export const ViewSecretPublicPage = () => { isLoading, isFetching } = useGetActiveSharedSecretById({ - sharedSecretId: id as string, + sharedSecretId: id, hashedHex, password }); @@ -80,7 +101,7 @@ export const ViewSecretPublicPage = () => { )} {!isLoading && ( <> - {!error && fetchSecret?.secret && key && ( + {!error && fetchSecret?.secret && ( )} {error && !isInvalidCredential && } diff --git a/frontend/src/views/ViewSecretPublicPage/components/SecretContainer.tsx b/frontend/src/views/ViewSecretPublicPage/components/SecretContainer.tsx index a920c9d93..f07ebfafd 100644 --- a/frontend/src/views/ViewSecretPublicPage/components/SecretContainer.tsx +++ b/frontend/src/views/ViewSecretPublicPage/components/SecretContainer.tsx @@ -15,7 +15,7 @@ import { TViewSharedSecretResponse } from "@app/hooks/api/secretSharing"; type Props = { secret: TViewSharedSecretResponse["secret"]; - secretKey: string; + secretKey: string | null; }; export const SecretContainer = ({ secret, secretKey: key }: Props) => { @@ -25,6 +25,10 @@ export const SecretContainer = ({ secret, secretKey: key }: Props) => { }); const decryptedSecret = useMemo(() => { + if (secret.secretValue) { + return secret.secretValue; + } + if (secret && secret.encryptedValue && key) { const res = decryptSymmetric({ ciphertext: secret.encryptedValue,