Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(secret-sharing): server-side encryption #2482

Merged
merged 9 commits into from
Oct 3, 2024
30 changes: 30 additions & 0 deletions backend/src/db/migrations/20240925100349_managed-secret-sharing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
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();
t.string("hashedHex").nullable().alter();

t.string("identifier", 64).nullable();
t.unique("identifier");
t.index("identifier");
});
}
}

export async function down(knex: Knex): Promise<void> {
if (await knex.schema.hasTable(TableName.SecretSharing)) {
await knex.schema.alterTable(TableName.SecretSharing, (t) => {
t.dropColumn("encryptedSecret");

t.dropColumn("identifier");
});
}
}
14 changes: 9 additions & 5 deletions backend/src/db/schemas/secret-sharing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,16 @@

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(),
hashedHex: z.string(),
encryptedValue: z.string().nullable().optional(),
iv: z.string().nullable().optional(),
tag: z.string().nullable().optional(),
hashedHex: z.string().nullable().optional(),
expiresAt: z.date(),
userId: z.string().uuid().nullable().optional(),
orgId: z.string().uuid().nullable().optional(),
Expand All @@ -22,7 +24,9 @@ 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(),
identifier: z.string().nullable().optional()
});

export type TSecretSharing = z.infer<typeof SecretSharingSchema>;
Expand Down
3 changes: 2 additions & 1 deletion backend/src/server/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -917,7 +917,8 @@ export const registerRoutes = async (
const secretSharingService = secretSharingServiceFactory({
permissionService,
secretSharingDAL,
orgDAL
orgDAL,
kmsService
});

const accessApprovalPolicyService = accessApprovalPolicyServiceFactory({
Expand Down
23 changes: 9 additions & 14 deletions backend/src/server/routes/v1/secret-sharing-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,10 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
},
schema: {
params: z.object({
id: z.string().uuid()
id: z.string()
}),
body: z.object({
hashedHex: z.string().min(1),
hashedHex: z.string().min(1).optional(),
password: z.string().optional()
}),
response: {
Expand All @@ -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()
})
Expand All @@ -99,17 +100,14 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
},
schema: {
body: z.object({
encryptedValue: z.string(),
secretValue: z.string().max(10_000),
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()
})
}
},
Expand All @@ -132,17 +130,14 @@ 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()
})
}
},
Expand All @@ -168,7 +163,7 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
},
schema: {
params: z.object({
sharedSecretId: z.string().uuid()
sharedSecretId: z.string()
}),
response: {
200: SecretSharingSchema
Expand Down
16 changes: 8 additions & 8 deletions backend/src/services/kms/kms-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,20 +208,20 @@ export const kmsServiceFactory = ({
return org.kmsDefaultKeyId;
};

const encryptWithRootKey = async () => {
const encryptWithRootKey = () => {
const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256);
return ({ plainText }: { plainText: Buffer }) => {
const encryptedPlainTextBlob = cipher.encrypt(plainText, ROOT_ENCRYPTION_KEY);

return Promise.resolve({ cipherTextBlob: encryptedPlainTextBlob });
return (plainTextBuffer: Buffer) => {
const encryptedBuffer = cipher.encrypt(plainTextBuffer, ROOT_ENCRYPTION_KEY);
return encryptedBuffer;
};
};

const decryptWithRootKey = async () => {
const decryptWithRootKey = () => {
const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256);
return ({ cipherTextBlob }: { cipherTextBlob: Buffer }) => {
const decryptedBlob = cipher.decrypt(cipherTextBlob, ROOT_ENCRYPTION_KEY);
return Promise.resolve(decryptedBlob);

return (cipherTextBuffer: Buffer) => {
return cipher.decrypt(cipherTextBuffer, ROOT_ENCRYPTION_KEY);
};
};

Expand Down
103 changes: 69 additions & 34 deletions backend/src/services/secret-sharing/secret-sharing-service.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import crypto from "node:crypto";

import bcrypt from "bcrypt";
import { z } from "zod";

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

import { TKmsServiceFactory } from "../kms/kms-service";
import { TOrgDALFactory } from "../org/org-dal";
import { TSecretSharingDALFactory } from "./secret-sharing-dal";
import {
Expand All @@ -19,25 +23,26 @@ type TSecretSharingServiceFactoryDep = {
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
secretSharingDAL: TSecretSharingDALFactory;
orgDAL: TOrgDALFactory;
kmsService: TKmsServiceFactory;
};

export type TSecretSharingServiceFactory = ReturnType<typeof secretSharingServiceFactory>;

const isUuidV4 = (uuid: string) => z.string().uuid().safeParse(uuid).success;

export const secretSharingServiceFactory = ({
permissionService,
secretSharingDAL,
orgDAL
orgDAL,
kmsService
}: TSecretSharingServiceFactoryDep) => {
const createSharedSecret = async ({
actor,
actorId,
orgId,
actorAuthMethod,
actorOrgId,
encryptedValue,
hashedHex,
iv,
tag,
secretValue,
name,
password,
accessType,
Expand All @@ -59,35 +64,40 @@ 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 = kmsService.encryptWithRootKey();

const encryptedSecret = encryptWithRoot(Buffer.from(secretValue));

const id = crypto.randomBytes(32).toString("hex");
const hashedPassword = password ? await bcrypt.hash(password, 10) : null;

const newSharedSecret = await secretSharingDAL.create({
identifier: id,
iv: null,
tag: null,
encryptedValue: null,
encryptedSecret,
name,
password: hashedPassword,
encryptedValue,
hashedHex,
iv,
tag,
expiresAt: new Date(expiresAt),
expiresAfterViews,
userId: actorId,
orgId,
accessType
});

return { id: newSharedSecret.id };
const idToReturn = `${Buffer.from(newSharedSecret.identifier!, "hex").toString("base64url")}`;

return { id: idToReturn };
};

const createPublicSharedSecret = async ({
password,
encryptedValue,
hashedHex,
iv,
tag,
secretValue,
expiresAt,
expiresAfterViews,
accessType
Expand All @@ -104,24 +114,25 @@ 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) {
throw new BadRequestError({ message: "Shared secret value too long" });
}
const encryptWithRoot = kmsService.encryptWithRootKey();
const encryptedSecret = encryptWithRoot(Buffer.from(secretValue));

const id = crypto.randomBytes(32).toString("hex");
const hashedPassword = password ? await bcrypt.hash(password, 10) : null;

const newSharedSecret = await secretSharingDAL.create({
identifier: id,
encryptedValue: null,
iv: null,
tag: null,
encryptedSecret,
password: hashedPassword,
encryptedValue,
hashedHex,
iv,
tag,
expiresAt: new Date(expiresAt),
expiresAfterViews,
accessType
});

return { id: newSharedSecret.id };
return { id: `${Buffer.from(newSharedSecret.identifier!, "hex").toString("base64url")}` };
};

const getSharedSecrets = async ({
Expand Down Expand Up @@ -162,25 +173,30 @@ export const secretSharingServiceFactory = ({
};
};

const $decrementSecretViewCount = async (sharedSecret: TSecretSharing, sharedSecretId: string) => {
const $decrementSecretViewCount = async (sharedSecret: TSecretSharing) => {
const { expiresAfterViews } = sharedSecret;

if (expiresAfterViews) {
// decrement view count if view count expiry set
await secretSharingDAL.updateById(sharedSecretId, { $decr: { expiresAfterViews: 1 } });
await secretSharingDAL.updateById(sharedSecret.id, { $decr: { expiresAfterViews: 1 } });
}

await secretSharingDAL.updateById(sharedSecretId, {
await secretSharingDAL.updateById(sharedSecret.id, {
lastViewedAt: new Date()
});
};

/** Get's passwordless secret. validates all secret's requested (must be fresh). */
/** Get's password-less 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
});
const sharedSecret = isUuidV4(sharedSecretId)
? await secretSharingDAL.findOne({
id: sharedSecretId,
hashedHex
})
: await secretSharingDAL.findOne({
identifier: Buffer.from(sharedSecretId, "base64url").toString("hex")
});

if (!sharedSecret)
throw new NotFoundError({
message: "Shared secret not found"
Expand Down Expand Up @@ -222,13 +238,23 @@ 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 decryptWithRoot = kmsService.decryptWithRootKey();
decryptedSecretValue = decryptWithRoot(sharedSecret.encryptedSecret);
}

// decrement when we are sure the user will view secret.
await $decrementSecretViewCount(sharedSecret, sharedSecretId);
await $decrementSecretViewCount(sharedSecret);

return {
isPasswordProtected,
secret: {
...sharedSecret,
...(decryptedSecretValue && {
secretValue: Buffer.from(decryptedSecretValue).toString()
}),
orgName:
sharedSecret.accessType === SecretSharingAccessType.Organization && orgId === sharedSecret.orgId
? orgName
Expand All @@ -241,7 +267,16 @@ export const secretSharingServiceFactory = ({
const { actor, actorId, orgId, actorAuthMethod, actorOrgId, sharedSecretId } = deleteSharedSecretInput;
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
if (!permission) throw new ForbiddenRequestError({ name: "User does not belong to the specified organization" });

const sharedSecret = isUuidV4(sharedSecretId)
? await secretSharingDAL.findById(sharedSecretId)
: await secretSharingDAL.findOne({ identifier: sharedSecretId });

const deletedSharedSecret = await secretSharingDAL.deleteById(sharedSecretId);

if (sharedSecret.orgId && sharedSecret.orgId !== orgId)
throw new ForbiddenRequestError({ message: "User does not have permission to delete shared secret" });

return deletedSharedSecret;
};

Expand Down
Loading
Loading