feat(secret-sharing): server-side encryption

This commit is contained in:
Daniel Hougaard
2024-09-25 16:05:50 +04:00
parent acde0867a0
commit d79099946a
11 changed files with 155 additions and 82 deletions

View File

@@ -0,0 +1,27 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
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<void> {
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");
});
}
}

View File

@@ -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<typeof SecretSharingSchema>;

View File

@@ -917,7 +917,8 @@ export const registerRoutes = async (
const secretSharingService = secretSharingServiceFactory({
permissionService,
secretSharingDAL,
orgDAL
orgDAL,
kmsService
});
const accessApprovalPolicyService = accessApprovalPolicyServiceFactory({

View File

@@ -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 };
}
});

View File

@@ -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<TPermissionServiceFactory, "getOrgPermission">;
secretSharingDAL: TSecretSharingDALFactory;
orgDAL: TOrgDALFactory;
kmsService: TKmsServiceFactory;
};
export type TSecretSharingServiceFactory = ReturnType<typeof secretSharingServiceFactory>;
@@ -26,7 +30,8 @@ export type TSecretSharingServiceFactory = ReturnType<typeof secretSharingServic
export const secretSharingServiceFactory = ({
permissionService,
secretSharingDAL,
orgDAL
orgDAL,
kmsService
}: TSecretSharingServiceFactoryDep) => {
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

View File

@@ -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;

View File

@@ -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<TSharedSecret>("/api/v1/secret-sharing", inputData);
const { data } = await apiRequest.post<TCreatedSharedSecret>(
"/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<TSharedSecret>(
const { data } = await apiRequest.post<TCreatedSharedSecret>(
"/api/v1/secret-sharing/public",
inputData
);

View File

@@ -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"
}

View File

@@ -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");

View File

@@ -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<string>();
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 && (
<SecretContainer secret={fetchSecret.secret} secretKey={key} />
)}
{error && !isInvalidCredential && <SecretErrorContainer />}

View File

@@ -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,