diff --git a/backend/package.json b/backend/package.json index 86d92c086..fadec14ee 100644 --- a/backend/package.json +++ b/backend/package.json @@ -50,6 +50,7 @@ "migration:down": "knex --knexfile ./src/db/knexfile.ts --client pg migrate:down", "migration:list": "knex --knexfile ./src/db/knexfile.ts --client pg migrate:list", "migration:latest": "knex --knexfile ./src/db/knexfile.ts --client pg migrate:latest", + "migration:status": "knex --knexfile ./src/db/knexfile.ts --client pg migrate:status", "migration:rollback": "knex --knexfile ./src/db/knexfile.ts migrate:rollback", "seed:new": "tsx ./scripts/create-seed-file.ts", "seed": "knex --knexfile ./src/db/knexfile.ts --client pg seed:run", diff --git a/backend/src/db/migrations/20240806083221_secret-sharing-password.ts b/backend/src/db/migrations/20240806083221_secret-sharing-password.ts new file mode 100644 index 000000000..7e0f5f30f --- /dev/null +++ b/backend/src/db/migrations/20240806083221_secret-sharing-password.ts @@ -0,0 +1,25 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.SecretSharing)) { + const doesPasswordExist = await knex.schema.hasColumn(TableName.SecretSharing, "password"); + if (!doesPasswordExist) { + await knex.schema.alterTable(TableName.SecretSharing, (t) => { + t.string("password").nullable(); + }); + } + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.SecretSharing)) { + const doesPasswordExist = await knex.schema.hasColumn(TableName.SecretSharing, "password"); + if (doesPasswordExist) { + await knex.schema.alterTable(TableName.SecretSharing, (t) => { + t.dropColumn("password"); + }); + } + } +} diff --git a/backend/src/db/schemas/secret-sharing.ts b/backend/src/db/schemas/secret-sharing.ts index de75fbafc..be5643e2b 100644 --- a/backend/src/db/schemas/secret-sharing.ts +++ b/backend/src/db/schemas/secret-sharing.ts @@ -21,6 +21,7 @@ export const SecretSharingSchema = z.object({ expiresAfterViews: z.number().nullable().optional(), accessType: z.string().default("anyone"), name: z.string().nullable().optional(), + password: z.string().nullable().optional(), lastViewedAt: z.date().nullable().optional() }); diff --git a/backend/src/server/routes/v1/secret-sharing-router.ts b/backend/src/server/routes/v1/secret-sharing-router.ts index a23c1f596..7a909cae4 100644 --- a/backend/src/server/routes/v1/secret-sharing-router.ts +++ b/backend/src/server/routes/v1/secret-sharing-router.ts @@ -48,7 +48,7 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) => }); server.route({ - method: "GET", + method: "POST", url: "/public/:id", config: { rateLimit: publicEndpointLimit @@ -57,38 +57,37 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) => params: z.object({ id: z.string().uuid() }), - querystring: z.object({ - hashedHex: z.string().min(1) + body: z.object({ + hashedHex: z.string().min(1), + password: z.string().optional() }), response: { - 200: SecretSharingSchema.pick({ - encryptedValue: true, - iv: true, - tag: true, - expiresAt: true, - expiresAfterViews: true, - accessType: true - }).extend({ - orgName: z.string().optional() + 200: z.object({ + isPasswordProtected: z.boolean(), + secret: SecretSharingSchema.pick({ + encryptedValue: true, + iv: true, + tag: true, + expiresAt: true, + expiresAfterViews: true, + accessType: true + }) + .extend({ + orgName: z.string().optional() + }) + .optional() }) } }, handler: async (req) => { - const sharedSecret = await req.server.services.secretSharing.getActiveSharedSecretById({ + const sharedSecret = await req.server.services.secretSharing.getSharedSecretById({ sharedSecretId: req.params.id, - hashedHex: req.query.hashedHex, + hashedHex: req.body.hashedHex, + password: req.body.password, orgId: req.permission?.orgId }); - if (!sharedSecret) return undefined; - return { - encryptedValue: sharedSecret.encryptedValue, - iv: sharedSecret.iv, - tag: sharedSecret.tag, - expiresAt: sharedSecret.expiresAt, - expiresAfterViews: sharedSecret.expiresAfterViews, - accessType: sharedSecret.accessType, - orgName: sharedSecret.orgName - }; + + return sharedSecret; } }); @@ -101,6 +100,7 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) => schema: { body: z.object({ encryptedValue: z.string(), + password: z.string().optional(), hashedHex: z.string(), iv: z.string(), tag: z.string(), @@ -131,6 +131,7 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) => schema: { body: z.object({ name: z.string().max(50).optional(), + password: z.string().optional(), encryptedValue: z.string(), hashedHex: z.string(), iv: z.string(), diff --git a/backend/src/services/secret-sharing/secret-sharing-service.ts b/backend/src/services/secret-sharing/secret-sharing-service.ts index 1f38bf1f1..6133db559 100644 --- a/backend/src/services/secret-sharing/secret-sharing-service.ts +++ b/backend/src/services/secret-sharing/secret-sharing-service.ts @@ -1,3 +1,6 @@ +import bcrypt from "bcrypt"; + +import { TSecretSharing } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; import { SecretSharingAccessType } from "@app/lib/types"; @@ -36,6 +39,7 @@ export const secretSharingServiceFactory = ({ iv, tag, name, + password, accessType, expiresAt, expiresAfterViews @@ -60,8 +64,10 @@ export const secretSharingServiceFactory = ({ throw new BadRequestError({ message: "Shared secret value too long" }); } + const hashedPassword = password ? await bcrypt.hash(password, 10) : null; const newSharedSecret = await secretSharingDAL.create({ name, + password: hashedPassword, encryptedValue, hashedHex, iv, @@ -77,6 +83,7 @@ export const secretSharingServiceFactory = ({ }; const createPublicSharedSecret = async ({ + password, encryptedValue, hashedHex, iv, @@ -102,7 +109,9 @@ export const secretSharingServiceFactory = ({ throw new BadRequestError({ message: "Shared secret value too long" }); } + const hashedPassword = password ? await bcrypt.hash(password, 10) : null; const newSharedSecret = await secretSharingDAL.create({ + password: hashedPassword, encryptedValue, hashedHex, iv, @@ -111,6 +120,7 @@ export const secretSharingServiceFactory = ({ expiresAfterViews, accessType }); + return { id: newSharedSecret.id }; }; @@ -152,7 +162,21 @@ export const secretSharingServiceFactory = ({ }; }; - const getActiveSharedSecretById = async ({ sharedSecretId, hashedHex, orgId }: TGetActiveSharedSecretByIdDTO) => { + const $decrementSecretViewCount = async (sharedSecret: TSecretSharing, sharedSecretId: string) => { + const { expiresAfterViews } = sharedSecret; + + if (expiresAfterViews) { + // decrement view count if view count expiry set + await secretSharingDAL.updateById(sharedSecretId, { $decr: { expiresAfterViews: 1 } }); + } + + await secretSharingDAL.updateById(sharedSecretId, { + lastViewedAt: new Date() + }); + }; + + /** Get's passwordless secret. validates all secret's requested (must be fresh). */ + const getSharedSecretById = async ({ sharedSecretId, hashedHex, orgId, password }: TGetActiveSharedSecretByIdDTO) => { const sharedSecret = await secretSharingDAL.findOne({ id: sharedSecretId, hashedHex @@ -169,6 +193,8 @@ export const secretSharingServiceFactory = ({ if (accessType === SecretSharingAccessType.Organization && orgId !== sharedSecret.orgId) throw new UnauthorizedError(); + // all secrets pass through here, meaning we check if its expired first and then check if it needs verification + // or can be safely sent to the client. if (expiresAt !== null && expiresAt < new Date()) { // check lifetime expiry await secretSharingDAL.softDeleteById(sharedSecretId); @@ -185,21 +211,29 @@ export const secretSharingServiceFactory = ({ }); } - if (expiresAfterViews) { - // decrement view count if view count expiry set - await secretSharingDAL.updateById(sharedSecretId, { $decr: { expiresAfterViews: 1 } }); + const isPasswordProtected = Boolean(sharedSecret.password); + const hasProvidedPassword = Boolean(password); + if (isPasswordProtected) { + if (hasProvidedPassword) { + const isMatch = await bcrypt.compare(password as string, sharedSecret.password as string); + if (!isMatch) throw new UnauthorizedError({ message: "Invalid credentials" }); + } else { + return { isPasswordProtected }; + } } - await secretSharingDAL.updateById(sharedSecretId, { - lastViewedAt: new Date() - }); + // decrement when we are sure the user will view secret. + await $decrementSecretViewCount(sharedSecret, sharedSecretId); return { - ...sharedSecret, - orgName: - sharedSecret.accessType === SecretSharingAccessType.Organization && orgId === sharedSecret.orgId - ? orgName - : undefined + isPasswordProtected, + secret: { + ...sharedSecret, + orgName: + sharedSecret.accessType === SecretSharingAccessType.Organization && orgId === sharedSecret.orgId + ? orgName + : undefined + } }; }; @@ -216,6 +250,6 @@ export const secretSharingServiceFactory = ({ createPublicSharedSecret, getSharedSecrets, deleteSharedSecretById, - getActiveSharedSecretById + getSharedSecretById }; }; diff --git a/backend/src/services/secret-sharing/secret-sharing-types.ts b/backend/src/services/secret-sharing/secret-sharing-types.ts index 76d723c62..794d99a33 100644 --- a/backend/src/services/secret-sharing/secret-sharing-types.ts +++ b/backend/src/services/secret-sharing/secret-sharing-types.ts @@ -15,6 +15,7 @@ export type TSharedSecretPermission = { orgId: string; accessType?: SecretSharingAccessType; name?: string; + password?: string; }; export type TCreatePublicSharedSecretDTO = { @@ -24,6 +25,7 @@ export type TCreatePublicSharedSecretDTO = { tag: string; expiresAt: string; expiresAfterViews?: number; + password?: string; accessType: SecretSharingAccessType; }; @@ -31,6 +33,11 @@ export type TGetActiveSharedSecretByIdDTO = { sharedSecretId: string; hashedHex: string; orgId?: string; + password?: string; +}; + +export type TValidateActiveSharedSecretDTO = TGetActiveSharedSecretByIdDTO & { + password: string; }; export type TCreateSharedSecretDTO = TSharedSecretPermission & TCreatePublicSharedSecretDTO; diff --git a/frontend/src/hooks/api/secretSharing/queries.ts b/frontend/src/hooks/api/secretSharing/queries.ts index 89a804f6e..c349d39f5 100644 --- a/frontend/src/hooks/api/secretSharing/queries.ts +++ b/frontend/src/hooks/api/secretSharing/queries.ts @@ -7,7 +7,11 @@ import { TSharedSecret, TViewSharedSecretResponse } from "./types"; export const secretSharingKeys = { allSharedSecrets: () => ["sharedSecrets"] as const, specificSharedSecrets: ({ offset, limit }: { offset: number; limit: number }) => - [...secretSharingKeys.allSharedSecrets(), { offset, limit }] as const + [...secretSharingKeys.allSharedSecrets(), { offset, limit }] as const, + getSecretById: (arg: { id: string; hashedHex: string; password?: string }) => [ + "shared-secret", + arg + ] }; export const useGetSharedSecrets = ({ @@ -38,31 +42,28 @@ export const useGetSharedSecrets = ({ export const useGetActiveSharedSecretById = ({ sharedSecretId, - hashedHex + hashedHex, + password }: { sharedSecretId: string; hashedHex: string; + password?: string; }) => { - return useQuery({ - enabled: Boolean(sharedSecretId) && Boolean(hashedHex), - queryFn: async () => { - const params = new URLSearchParams({ - hashedHex - }); - - const { data } = await apiRequest.get( + return useQuery( + secretSharingKeys.getSecretById({ id: sharedSecretId, hashedHex, password }), + async () => { + const { data } = await apiRequest.post( `/api/v1/secret-sharing/public/${sharedSecretId}`, { - params + hashedHex, + password } ); - return { - encryptedValue: data.encryptedValue, - iv: data.iv, - tag: data.tag, - accessType: data.accessType, - orgName: data.orgName - }; + + return data; + }, + { + enabled: Boolean(sharedSecretId) && Boolean(hashedHex) } - }); + ); }; diff --git a/frontend/src/hooks/api/secretSharing/types.ts b/frontend/src/hooks/api/secretSharing/types.ts index d35fea8d6..0dd4a9555 100644 --- a/frontend/src/hooks/api/secretSharing/types.ts +++ b/frontend/src/hooks/api/secretSharing/types.ts @@ -15,6 +15,7 @@ export type TSharedSecret = { export type TCreateSharedSecretRequest = { name?: string; + password?: string; encryptedValue: string; hashedHex: string; iv: string; @@ -25,11 +26,14 @@ export type TCreateSharedSecretRequest = { }; export type TViewSharedSecretResponse = { - encryptedValue: string; - iv: string; - tag: string; - accessType: SecretSharingAccessType; - orgName?: string; + isPasswordProtected: boolean; + secret: { + encryptedValue: string; + iv: string; + tag: string; + accessType: SecretSharingAccessType; + orgName?: string; + }; }; export type TDeleteSharedSecretRequest = { @@ -40,3 +44,4 @@ 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 da9aa3c98..ea39e1265 100644 --- a/frontend/src/views/ShareSecretPublicPage/components/ShareSecretForm.tsx +++ b/frontend/src/views/ShareSecretPublicPage/components/ShareSecretForm.tsx @@ -32,7 +32,8 @@ const viewLimitOptions = [ const schema = z.object({ name: z.string().optional(), - secret: z.string(), + password: z.string().optional(), + secret: z.string().min(1), expiresIn: z.string(), viewLimit: z.string(), accessType: z.nativeEnum(SecretSharingAccessType).optional() @@ -67,7 +68,14 @@ export const ShareSecretForm = ({ isPublic, value }: Props) => { } }); - const onFormSubmit = async ({ name, secret, expiresIn, viewLimit, accessType }: FormData) => { + const onFormSubmit = async ({ + name, + password, + secret, + expiresIn, + viewLimit, + accessType + }: FormData) => { try { const expiresAt = new Date(new Date().getTime() + Number(expiresIn)); @@ -80,6 +88,7 @@ export const ShareSecretForm = ({ isPublic, value }: Props) => { const { id } = await createSharedSecret.mutateAsync({ name, + password, encryptedValue: ciphertext, hashedHex, iv, @@ -149,6 +158,20 @@ export const ShareSecretForm = ({ isPublic, value }: Props) => { )} /> + ( + + + + )} + /> { const router = useRouter(); + const [password, setPassword] = useState(); const { id, key: urlEncodedPublicKey } = router.query; const [hashedHex, key] = urlEncodedPublicKey ? urlEncodedPublicKey.toString().split("-") : ["", ""]; - const { data: secret, error } = useGetActiveSharedSecretById({ + const { + data: fetchSecret, + error, + isLoading, + isFetching + } = useGetActiveSharedSecretById({ sharedSecretId: id as string, - hashedHex + hashedHex, + password }); + const isInvalidCredential = + ((error as AxiosError)?.response?.data as { message: string })?.message === + "Invalid credentials"; + + const shouldShowPasswordPrompt = + isInvalidCredential || (fetchSecret?.isPasswordProtected && !fetchSecret.secret); + const isValidatingPassword = Boolean(password) && isFetching; + return (
@@ -52,8 +69,23 @@ export const ViewSecretPublicPage = () => {

- {secret && key && } - {error && } + {(shouldShowPasswordPrompt || isValidatingPassword) && ( + { + setPassword(el); + }} + isInvalidCredential={!isFetching && isInvalidCredential} + /> + )} + {!isLoading && ( + <> + {!error && fetchSecret?.secret && key && ( + + )} + {error && !isInvalidCredential && } + + )}
diff --git a/frontend/src/views/ViewSecretPublicPage/components/PasswordContainer.tsx b/frontend/src/views/ViewSecretPublicPage/components/PasswordContainer.tsx new file mode 100644 index 000000000..edf60447b --- /dev/null +++ b/frontend/src/views/ViewSecretPublicPage/components/PasswordContainer.tsx @@ -0,0 +1,80 @@ +import { Controller, useForm } from "react-hook-form"; +import { faArrowRight, faSpinner } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { Button, FormControl, IconButton, Input } from "@app/components/v2"; + +type Props = { + onPasswordSubmit: (val: any) => void; + isSubmitting?: boolean; + isInvalidCredential?: boolean; +}; + +const formSchema = z.object({ + password: z.string() +}); + +export type FormData = z.infer; + +export const PasswordContainer = ({ + onPasswordSubmit, + isSubmitting, + isInvalidCredential +}: Props) => { + const { control, handleSubmit } = useForm({ + resolver: zodResolver(formSchema) + }); + + const onFormSubmit = async ({ password }: FormData) => { + onPasswordSubmit(password); + }; + + return ( +
+
+ ( + +
+ +
+ + + +
+
+
+ )} + /> + + +
+ ); +}; diff --git a/frontend/src/views/ViewSecretPublicPage/components/SecretContainer.tsx b/frontend/src/views/ViewSecretPublicPage/components/SecretContainer.tsx index 58c6c57c2..a920c9d93 100644 --- a/frontend/src/views/ViewSecretPublicPage/components/SecretContainer.tsx +++ b/frontend/src/views/ViewSecretPublicPage/components/SecretContainer.tsx @@ -14,7 +14,7 @@ import { useTimedReset, useToggle } from "@app/hooks"; import { TViewSharedSecretResponse } from "@app/hooks/api/secretSharing"; type Props = { - secret: TViewSharedSecretResponse; + secret: TViewSharedSecretResponse["secret"]; secretKey: string; }; diff --git a/frontend/src/views/ViewSecretPublicPage/components/index.tsx b/frontend/src/views/ViewSecretPublicPage/components/index.tsx index b3424d790..8ba8b27c0 100644 --- a/frontend/src/views/ViewSecretPublicPage/components/index.tsx +++ b/frontend/src/views/ViewSecretPublicPage/components/index.tsx @@ -1,2 +1,3 @@ +export { PasswordContainer } from "./PasswordContainer"; export { SecretContainer } from "./SecretContainer"; export { SecretErrorContainer } from "./SecretErrorContainer";