From ad5852fe3ac0d3a852a7b914eeaaff3270dd66a9 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Wed, 19 Apr 2023 15:38:13 +0300 Subject: [PATCH] Enable all auth clients for secrets v3, remove serviceTokenData .populate in middleware, make secret versions and rollbacks compatible with blind indexing --- .../src/controllers/v2/secretController.ts | 11 +- .../src/controllers/v2/secretsController.ts | 72 +++++--- .../v2/serviceTokenDataController.ts | 14 +- .../src/controllers/v2/workspaceController.ts | 2 +- .../src/controllers/v3/secretsController.ts | 12 +- .../src/ee/controllers/v1/secretController.ts | 5 +- .../ee/controllers/v1/workspaceController.ts | 8 +- backend/src/helpers/auth.ts | 2 +- backend/src/helpers/database.ts | 2 + backend/src/helpers/secrets.ts | 170 ++++++++++-------- backend/src/helpers/serviceTokenData.ts | 1 - .../services/SecretService/index.ts | 3 + backend/src/models/serviceTokenData.ts | 3 +- backend/src/routes/v3/secrets.ts | 40 ++++- backend/src/services/SecretService.ts | 12 ++ backend/src/services/TelemetryService.ts | 16 +- backend/src/utils/auth.ts | 36 +++- docs/security/data-model.mdx | 17 +- docs/security/mechanics.mdx | 17 ++ docs/security/overview.mdx | 2 +- frontend/src/helpers/project.ts | 1 + 21 files changed, 313 insertions(+), 133 deletions(-) diff --git a/backend/src/controllers/v2/secretController.ts b/backend/src/controllers/v2/secretController.ts index 91328ea7b..d551e8aa4 100644 --- a/backend/src/controllers/v2/secretController.ts +++ b/backend/src/controllers/v2/secretController.ts @@ -8,6 +8,8 @@ import { BadRequestError, InternalServerError, UnauthorizedRequestError, Validat import { AnyBulkWriteOperation } from 'mongodb'; import { SECRET_PERSONAL, SECRET_SHARED } from "../../variables"; import { TelemetryService } from '../../services'; +import { User } from "../../models"; +import { AccountNotFoundError } from '../../utils/errors'; /** * Create secret for workspace with id [workspaceId] and environment [environment] @@ -340,15 +342,18 @@ export const getSecrets = async (req: Request, res: Response) => { const { workspaceId } = req.params; let userId: Types.ObjectId | undefined = undefined // used for getting personal secrets for user - let userEmail: Types.ObjectId | undefined = undefined // used for posthog + let userEmail: string | undefined = undefined // used for posthog if (req.user) { userId = req.user._id; userEmail = req.user.email; } if (req.serviceTokenData) { - userId = req.serviceTokenData.user._id - userEmail = req.serviceTokenData.user.email; + userId = req.serviceTokenData.user; + + const user = await User.findById(req.serviceTokenData.user, 'email'); + if (!user) throw AccountNotFoundError(); + userEmail = user.email; } const [err, secrets] = await to(Secret.find( diff --git a/backend/src/controllers/v2/secretsController.ts b/backend/src/controllers/v2/secretsController.ts index 89aa1d76e..f7b8349ff 100644 --- a/backend/src/controllers/v2/secretsController.ts +++ b/backend/src/controllers/v2/secretsController.ts @@ -374,8 +374,14 @@ export const createSecrets = async (req: Request, res: Response) => { listOfSecretsToCreate = [req.body.secrets]; } + // get secret blind index salt + const salt = await SecretService.getSecretBlindIndexSalt({ + workspaceId: new Types.ObjectId(workspaceId) + }); + type secretsToCreateType = { type: string; + secretName?: string; secretKeyCiphertext: string; secretKeyIV: string; secretKeyTag: string; @@ -388,25 +394,10 @@ export const createSecrets = async (req: Request, res: Response) => { tags: string[] } - const secretsToInsert: ISecret[] = listOfSecretsToCreate.map(({ - type, - secretKeyCiphertext, - secretKeyIV, - secretKeyTag, - secretValueCiphertext, - secretValueIV, - secretValueTag, - secretCommentCiphertext, - secretCommentIV, - secretCommentTag, - tags - }: secretsToCreateType) => { - return ({ - version: 1, - workspace: new Types.ObjectId(workspaceId), + const secretsToInsert: ISecret[] = await Promise.all( + listOfSecretsToCreate.map(async ({ type, - user: (req.user && type === SECRET_PERSONAL) ? req.user : undefined, - environment, + secretName, secretKeyCiphertext, secretKeyIV, secretKeyTag, @@ -417,11 +408,38 @@ export const createSecrets = async (req: Request, res: Response) => { secretCommentIV, secretCommentTag, tags - }); - }); + }: secretsToCreateType) => { + let secretBlindIndex; + if (secretName) { + secretBlindIndex = await SecretService.generateSecretBlindIndexWithSalt({ + secretName, + salt + }); + } + return ({ + version: 1, + workspace: new Types.ObjectId(workspaceId), + type, + ...(secretBlindIndex ? { secretBlindIndex } : {}), + user: (req.user && type === SECRET_PERSONAL) ? req.user : undefined, + environment, + secretKeyCiphertext, + secretKeyIV, + secretKeyTag, + secretValueCiphertext, + secretValueIV, + secretValueTag, + secretCommentCiphertext, + secretCommentIV, + secretCommentTag, + tags + }); + }) + ); + const newlyCreatedSecrets: ISecret[] = (await Secret.insertMany(secretsToInsert)).map((insertedSecret) => insertedSecret.toObject()); - + setTimeout(async () => { // trigger event - push secrets await EventService.handleEvent({ @@ -440,6 +458,7 @@ export const createSecrets = async (req: Request, res: Response) => { type, user, environment, + secretBlindIndex, secretKeyCiphertext, secretKeyIV, secretKeyTag, @@ -453,6 +472,7 @@ export const createSecrets = async (req: Request, res: Response) => { type, user, environment, + secretBlindIndex, isDeleted: false, secretKeyCiphertext, secretKeyIV, @@ -492,7 +512,7 @@ export const createSecrets = async (req: Request, res: Response) => { if (postHogClient) { postHogClient.capture({ event: 'secrets added', - distinctId: TelemetryService.getDistinctId({ + distinctId: await TelemetryService.getDistinctId({ authData: req.authData }), properties: { @@ -607,7 +627,7 @@ export const getSecrets = async (req: Request, res: Response) => { // case: client authorization is via service token if (req.serviceTokenData) { - const userId = req.serviceTokenData.user._id + const userId = req.serviceTokenData.user; const secretQuery: any = { workspace: workspaceId, @@ -667,7 +687,7 @@ export const getSecrets = async (req: Request, res: Response) => { if (postHogClient) { postHogClient.capture({ event: 'secrets pulled', - distinctId: TelemetryService.getDistinctId({ + distinctId: await TelemetryService.getDistinctId({ authData: req.authData }), properties: { @@ -889,7 +909,7 @@ export const updateSecrets = async (req: Request, res: Response) => { if (postHogClient) { postHogClient.capture({ event: 'secrets modified', - distinctId: TelemetryService.getDistinctId({ + distinctId: await TelemetryService.getDistinctId({ authData: req.authData }), properties: { @@ -1023,7 +1043,7 @@ export const deleteSecrets = async (req: Request, res: Response) => { if (postHogClient) { postHogClient.capture({ event: 'secrets deleted', - distinctId: TelemetryService.getDistinctId({ + distinctId: await TelemetryService.getDistinctId({ authData: req.authData }), properties: { diff --git a/backend/src/controllers/v2/serviceTokenDataController.ts b/backend/src/controllers/v2/serviceTokenDataController.ts index a8f438b85..c425ecfc4 100644 --- a/backend/src/controllers/v2/serviceTokenDataController.ts +++ b/backend/src/controllers/v2/serviceTokenDataController.ts @@ -11,9 +11,11 @@ import { userHasWorkspaceAccess } from '../../ee/helpers/checkMembershipPermissi import { PERMISSION_READ_SECRETS, AUTH_MODE_JWT, - AUTH_MODE_SERVICE_ACCOUNT + AUTH_MODE_SERVICE_ACCOUNT, + AUTH_MODE_SERVICE_TOKEN } from '../../variables'; import { getSaltRounds } from '../../config'; +import { BadRequestError } from '../../utils/errors'; /** * Return service token data associated with service token on request @@ -48,7 +50,15 @@ export const getServiceTokenData = async (req: Request, res: Response) => { } */ - return res.status(200).json(req.serviceTokenData); + if (!(req.authData.authPayload instanceof ServiceTokenData)) throw BadRequestError({ + message: 'Failed accepted client validation for service token data' + }); + + const serviceTokenData = await ServiceTokenData + .findById(req.authData.authPayload._id) + .populate('user'); + + return res.status(200).json(serviceTokenData); } /** diff --git a/backend/src/controllers/v2/workspaceController.ts b/backend/src/controllers/v2/workspaceController.ts index 3605656c3..dd0efe91f 100644 --- a/backend/src/controllers/v2/workspaceController.ts +++ b/backend/src/controllers/v2/workspaceController.ts @@ -132,7 +132,7 @@ export const pullSecrets = async (req: Request, res: Response) => { if (req.user) { userId = req.user._id.toString(); } else if (req.serviceTokenData) { - userId = req.serviceTokenData.user._id + userId = req.serviceTokenData.user.toString(); } // validate environment const workspaceEnvs = req.membership.workspace.environments; diff --git a/backend/src/controllers/v3/secretsController.ts b/backend/src/controllers/v3/secretsController.ts index 35575b7b4..80329e04a 100644 --- a/backend/src/controllers/v3/secretsController.ts +++ b/backend/src/controllers/v3/secretsController.ts @@ -70,7 +70,10 @@ export const createSecret = async (req: Request, res: Response) => { secretKeyTag, secretValueCiphertext, secretValueIV, - secretValueTag + secretValueTag, + secretCommentCiphertext, + secretCommentIV, + secretCommentTag } = req.body; const secret = await SecretService.createSecret({ @@ -84,7 +87,12 @@ export const createSecret = async (req: Request, res: Response) => { secretKeyTag, secretValueCiphertext, secretValueIV, - secretValueTag + secretValueTag, + ...((secretCommentCiphertext && secretCommentIV && secretCommentTag) ? { + secretCommentCiphertext, + secretCommentIV, + secretCommentTag + } : {}) }); await EventService.handleEvent({ diff --git a/backend/src/ee/controllers/v1/secretController.ts b/backend/src/ee/controllers/v1/secretController.ts index 92503d772..cfdd93cf3 100644 --- a/backend/src/ee/controllers/v1/secretController.ts +++ b/backend/src/ee/controllers/v1/secretController.ts @@ -146,7 +146,7 @@ export const rollbackSecretVersion = async (req: Request, res: Response) => { const oldSecretVersion = await SecretVersion.findOne({ secret: secretId, version - }); + }).select('+secretBlindIndex') if (!oldSecretVersion) throw new Error('Failed to find secret version'); @@ -155,6 +155,7 @@ export const rollbackSecretVersion = async (req: Request, res: Response) => { type, user, environment, + secretBlindIndex, secretKeyCiphertext, secretKeyIV, secretKeyTag, @@ -174,6 +175,7 @@ export const rollbackSecretVersion = async (req: Request, res: Response) => { type, user, environment, + ...(secretBlindIndex ? { secretBlindIndex } : {}), secretKeyCiphertext, secretKeyIV, secretKeyTag, @@ -197,6 +199,7 @@ export const rollbackSecretVersion = async (req: Request, res: Response) => { user, environment, isDeleted: false, + ...(secretBlindIndex ? { secretBlindIndex } : {}), secretKeyCiphertext, secretKeyIV, secretKeyTag, diff --git a/backend/src/ee/controllers/v1/workspaceController.ts b/backend/src/ee/controllers/v1/workspaceController.ts index 9dd7a0ca6..e3f46eb71 100644 --- a/backend/src/ee/controllers/v1/workspaceController.ts +++ b/backend/src/ee/controllers/v1/workspaceController.ts @@ -173,6 +173,7 @@ export const rollbackWorkspaceSecretSnapshot = async (req: Request, res: Respons } } */ + let secrets; try { const { workspaceId } = req.params; @@ -182,7 +183,10 @@ export const rollbackWorkspaceSecretSnapshot = async (req: Request, res: Respons const secretSnapshot = await SecretSnapshot.findOne({ workspace: workspaceId, version - }).populate<{ secretVersions: ISecretVersion[]}>('secretVersions'); + }).populate<{ secretVersions: ISecretVersion[]}>({ + path: 'secretVersions', + select: '+secretBlindIndex' + }); if (!secretSnapshot) throw new Error('Failed to find secret snapshot'); @@ -259,7 +263,7 @@ export const rollbackWorkspaceSecretSnapshot = async (req: Request, res: Respons ); // add secret versions - await SecretVersion.insertMany( + const secretV = await SecretVersion.insertMany( secrets.map(({ _id, version, diff --git a/backend/src/helpers/auth.ts b/backend/src/helpers/auth.ts index 9cbe82e34..47b1ef9b1 100644 --- a/backend/src/helpers/auth.ts +++ b/backend/src/helpers/auth.ts @@ -157,7 +157,7 @@ const getAuthSTDPayload = async ({ }, { new: true }) - .select('+encryptedKey +iv +tag').populate('user serviceAccount'); + .select('+encryptedKey +iv +tag'); if (!serviceTokenData) throw ServiceTokenDataNotFoundError({ message: 'Failed to find service token data' }); diff --git a/backend/src/helpers/database.ts b/backend/src/helpers/database.ts index 9d128ea47..aa29f7e46 100644 --- a/backend/src/helpers/database.ts +++ b/backend/src/helpers/database.ts @@ -1,5 +1,6 @@ import mongoose from 'mongoose'; import { EESecretService } from '../ee/services'; +import { SecretService } from '../services'; import { getLogger } from '../utils/logger'; /** @@ -22,6 +23,7 @@ const initDatabaseHelper = async ({ getLogger("database").info("Database connection established"); await EESecretService.initSecretVersioning(); + await SecretService.initSecretBlindIndexDataHelper(); } catch (err) { getLogger("database").error(`Unable to establish Database connection due to the error.\n${err}`); } diff --git a/backend/src/helpers/secrets.ts b/backend/src/helpers/secrets.ts index 0eb39da2d..d3a158640 100644 --- a/backend/src/helpers/secrets.ts +++ b/backend/src/helpers/secrets.ts @@ -11,6 +11,7 @@ import { } from '../interfaces/middleware'; import { User, + Workspace, ServiceAccount, ServiceTokenData, Secret, @@ -26,7 +27,8 @@ import { validateUserClientForSecrets } from '../helpers/user'; import { - validateServiceTokenDataClientForSecrets, validateServiceTokenDataClientForWorkspace + validateServiceTokenDataClientForSecrets, + validateServiceTokenDataClientForWorkspace } from '../helpers/serviceTokenData'; import { validateServiceAccountClientForSecrets, @@ -63,7 +65,8 @@ import { EELogService } from '../ee/services'; import { - getAuthDataPayloadIdObj + getAuthDataPayloadIdObj, + getAuthDataPayloadUserObj } from '../utils/auth'; /** @@ -217,6 +220,46 @@ const validateClientForSecrets = async ({ }); } +/** + * Initialize secret blind index data by setting previously + * un-initialized projects to have secret blind index data + * (Ensures that all projects have associated blind index data) + */ +const initSecretBlindIndexDataHelper = async () => { + const workspaceIdsBlindIndexed = await SecretBlindIndexData.distinct('workspace'); + const workspaceIdsToBlindIndex = await Workspace.distinct('_id', { + _id: { + $nin: workspaceIdsBlindIndexed + } + }); + + const secretBlindIndexDataToInsert = workspaceIdsToBlindIndex.map((workspaceToBlindIndex) => { + const salt = crypto.randomBytes(16).toString('base64'); + + const { + ciphertext: encryptedSaltCiphertext, + iv: saltIV, + tag: saltTag + } = encryptSymmetric({ + plaintext: salt, + key: getEncryptionKey() + }); + + const secretBlindIndexData = new SecretBlindIndexData({ + workspace: workspaceToBlindIndex, + encryptedSaltCiphertext, + saltIV, + saltTag + }) + + return secretBlindIndexData; + }); + + if (secretBlindIndexDataToInsert.length > 0) { + await SecretBlindIndexData.insertMany(secretBlindIndexDataToInsert); + } +} + /** * Create secret blind index data containing encrypted blind index [salt] * for workspace with id [workspaceId] @@ -283,7 +326,7 @@ const getSecretBlindIndexSaltHelper = async ({ * Generate blind index for secret with name [secretName] * and salt [salt] * @param {Object} obj - * @param {Object} obj.secretName - name of secret to generate blind index for + * @param {String} obj.secretName - name of secret to generate blind index for * @param {String} obj.salt - base64-salt */ const generateSecretBlindIndexWithSaltHelper = async ({ @@ -312,8 +355,8 @@ const getSecretBlindIndexSaltHelper = async ({ * Generate blind index for secret with name [secretName] * for workspace with id [workspaceId] * @param {Object} obj - * @param {Object} obj.secretName - name of secret to generate blind index for - * @param {Object} obj.workspaceId - id of workspace that secret belongs to + * @param {Stringj} obj.secretName - name of secret to generate blind index for + * @param {Types.ObjectId} obj.workspaceId - id of workspace that secret belongs to */ const generateSecretBlindIndexHelper = async ({ secretName, @@ -367,11 +410,11 @@ const createSecretHelper = async ({ secretKeyTag, secretValueCiphertext, secretValueIV, - secretValueTag + secretValueTag, + secretCommentCiphertext, + secretCommentIV, + secretCommentTag }: CreateSecretParams) => { - // preliminary OK - // pending check for other types of clients - const secretBlindIndex = await generateSecretBlindIndexHelper({ secretName, workspaceId: new Types.ObjectId(workspaceId) @@ -380,7 +423,8 @@ const createSecretHelper = async ({ const exists = await Secret.exists({ secretBlindIndex, workspace: new Types.ObjectId(workspaceId), - type + type, + ...(type === SECRET_PERSONAL ? getAuthDataPayloadUserObj(authData) : {}) }); if (exists) throw BadRequestError({ @@ -400,12 +444,6 @@ const createSecretHelper = async ({ if (!exists) throw BadRequestError({ message: 'Failed to create personal secret override for no corresponding shared secret' }); - - // TODO: adapt to other client types - - if (!(authData.authPayload instanceof User)) throw BadRequestError({ - message: 'Failed to create personal secret override for no specified user' - }); } // create secret @@ -414,16 +452,17 @@ const createSecretHelper = async ({ workspace: new Types.ObjectId(workspaceId), environment, type, - ...(type === SECRET_PERSONAL && (authData.authPayload instanceof User) ? { - user: authData.authPayload._id - } : {}), + ...(type === SECRET_PERSONAL ? getAuthDataPayloadUserObj(authData) : {}), secretBlindIndex, secretKeyCiphertext, secretKeyIV, secretKeyTag, secretValueCiphertext, secretValueIV, - secretValueTag + secretValueTag, + secretCommentCiphertext, + secretCommentIV, + secretCommentTag }).save(); const secretVersion = new SecretVersion({ @@ -431,11 +470,10 @@ const createSecretHelper = async ({ version: secret.version, workspace: secret.workspace, type, - ...(type === SECRET_PERSONAL && authData.authPayload instanceof User ? { - user: authData.authPayload._id - } : {}), + ...(type === SECRET_PERSONAL ? getAuthDataPayloadUserObj(authData) : {}), environment: secret.environment, isDeleted: false, + secretBlindIndex, secretKeyCiphertext, secretKeyIV, secretKeyTag, @@ -475,7 +513,7 @@ const createSecretHelper = async ({ if (postHogClient) { postHogClient.capture({ event: 'secrets added', - distinctId: TelemetryService.getDistinctId({ + distinctId: await TelemetryService.getDistinctId({ authData }), properties: { @@ -504,21 +542,17 @@ const getSecretsHelper = async ({ environment, authData }: GetSecretsParams) => { - // preliminary OK - // pending check for other types of clients - let secrets: ISecret[] = []; - if (authData.authPayload instanceof User) { - // case: get personal secrets first - secrets = await Secret.find({ - workspace: new Types.ObjectId(workspaceId), - environment, - type: SECRET_PERSONAL, - user: authData.authPayload._id - }); - } + // get personal secrets first + secrets = await Secret.find({ + workspace: new Types.ObjectId(workspaceId), + environment, + type: SECRET_PERSONAL, + ...getAuthDataPayloadUserObj(authData) + }); + // concat with shared secrets secrets = secrets.concat(await Secret.find({ workspace: new Types.ObjectId(workspaceId), environment, @@ -549,7 +583,7 @@ const getSecretsHelper = async ({ if (postHogClient) { postHogClient.capture({ event: 'secrets pulled', - distinctId: TelemetryService.getDistinctId({ + distinctId: await TelemetryService.getDistinctId({ authData }), properties: { @@ -582,38 +616,31 @@ const getSecretHelper = async ({ type, authData }: GetSecretParams) => { - // preliminary OK - // pending check for other types of clients - const secretBlindIndex = await generateSecretBlindIndexHelper({ secretName, workspaceId: new Types.ObjectId(workspaceId) }); - let secret; + let secret: ISecret | null = null; - if (authData.authPayload instanceof User) { - // case: find any personal secret matching criteria + // try getting personal secret first (if exists) + secret = await Secret.findOne({ + secretBlindIndex, + workspace: new Types.ObjectId(workspaceId), + environment, + type: type ?? SECRET_PERSONAL, + ...(type === SECRET_PERSONAL ? getAuthDataPayloadUserObj(authData) : {}) + }); + + if (!secret) { + // case: failed to find personal secret matching criteria + // -> find shared secret matching criteria secret = await Secret.findOne({ secretBlindIndex, workspace: new Types.ObjectId(workspaceId), environment, - type: type ?? SECRET_PERSONAL, - ...(type === SECRET_PERSONAL ? { - user: authData.authPayload._id - } : {}) + type: SECRET_SHARED }); - - if (!secret) { - // case: failed to find personal secret matching criteria - // -> find shared secret matching criteria - secret = await Secret.findOne({ - secretBlindIndex, - workspace: new Types.ObjectId(workspaceId), - environment, - type: SECRET_SHARED - }); - } } if (!secret) throw SecretNotFoundError(); @@ -639,7 +666,7 @@ const getSecretHelper = async ({ if (postHogClient) { postHogClient.capture({ event: 'secrets pull', - distinctId: TelemetryService.getDistinctId({ + distinctId: await TelemetryService.getDistinctId({ authData }), properties: { @@ -678,8 +705,6 @@ const updateSecretHelper = async ({ secretValueIV, secretValueTag }: UpdateSecretParams) => { - // preliminary OK - // pending check for other types of clients const secretBlindIndex = await generateSecretBlindIndexHelper({ secretName, workspaceId: new Types.ObjectId(workspaceId) @@ -688,6 +713,7 @@ const updateSecretHelper = async ({ let secret: ISecret | null = null; if (type === SECRET_SHARED) { + // case: update shared secret secret = await Secret.findOneAndUpdate( { secretBlindIndex, @@ -705,14 +731,15 @@ const updateSecretHelper = async ({ new: true } ); - } else if (type === SECRET_PERSONAL && authData.authPayload instanceof User) { + } else { + // case: update personal secret secret = await Secret.findOneAndUpdate( { secretBlindIndex, workspace: new Types.ObjectId(workspaceId), environment, type, - user: authData.authPayload._id + ...getAuthDataPayloadUserObj(authData) }, { secretValueCiphertext, @@ -733,11 +760,10 @@ const updateSecretHelper = async ({ version: secret.version, workspace: secret.workspace, type, - ...(type === SECRET_PERSONAL && authData.authPayload instanceof User ? { - user: authData.authPayload._id - } : {}), + ...(type === SECRET_PERSONAL ? getAuthDataPayloadUserObj(authData) : {}), environment: secret.environment, isDeleted: false, + secretBlindIndex, secretKeyCiphertext: secret.secretKeyCiphertext, secretKeyIV: secret.secretKeyIV, secretKeyTag: secret.secretKeyTag, @@ -777,7 +803,7 @@ const updateSecretHelper = async ({ if (postHogClient) { postHogClient.capture({ event: 'secrets modified', - distinctId: TelemetryService.getDistinctId({ + distinctId: await TelemetryService.getDistinctId({ authData }), properties: { @@ -810,9 +836,6 @@ const deleteSecretHelper = async ({ type, authData }: DeleteSecretParams) => { - // preliminary OK - // pending check for other types of clients - const secretBlindIndex = await generateSecretBlindIndexHelper({ secretName, workspaceId: new Types.ObjectId(workspaceId) @@ -840,13 +863,13 @@ const deleteSecretHelper = async ({ workspaceId: new Types.ObjectId(workspaceId), environment }); - } else if (type === SECRET_PERSONAL && authData.authPayload instanceof User) { + } else { secret = await Secret.findOneAndDelete({ secretBlindIndex, workspaceId: new Types.ObjectId(workspaceId), environment, type, - user: authData.authPayload._id + ...getAuthDataPayloadUserObj(authData) }); if (secret) { @@ -887,7 +910,7 @@ const deleteSecretHelper = async ({ if (postHogClient) { postHogClient.capture({ event: 'secrets deleted', - distinctId: TelemetryService.getDistinctId({ + distinctId: await TelemetryService.getDistinctId({ authData }), properties: { @@ -909,6 +932,7 @@ const deleteSecretHelper = async ({ export { validateClientForSecret, validateClientForSecrets, + initSecretBlindIndexDataHelper, createSecretBlindIndexDataHelper, getSecretBlindIndexSaltHelper, generateSecretBlindIndexWithSaltHelper, diff --git a/backend/src/helpers/serviceTokenData.ts b/backend/src/helpers/serviceTokenData.ts index 9a8bb1288..ecf46a4d1 100644 --- a/backend/src/helpers/serviceTokenData.ts +++ b/backend/src/helpers/serviceTokenData.ts @@ -111,7 +111,6 @@ const validateClientForServiceTokenData = async ({ environment?: string; requiredPermissions?: string[]; }) => { - if (!serviceTokenData.workspace.equals(workspaceId)) { // case: invalid workspaceId passed throw UnauthorizedRequestError({ diff --git a/backend/src/interfaces/services/SecretService/index.ts b/backend/src/interfaces/services/SecretService/index.ts index 64da16beb..b72068b90 100644 --- a/backend/src/interfaces/services/SecretService/index.ts +++ b/backend/src/interfaces/services/SecretService/index.ts @@ -13,6 +13,9 @@ export interface CreateSecretParams { secretValueCiphertext: string; secretValueIV: string; secretValueTag: string; + secretCommentCiphertext?: string; + secretCommentIV?: string; + secretCommentTag?: string; } export interface GetSecretsParams { diff --git a/backend/src/models/serviceTokenData.ts b/backend/src/models/serviceTokenData.ts index 86f0e44b3..36a63c094 100644 --- a/backend/src/models/serviceTokenData.ts +++ b/backend/src/models/serviceTokenData.ts @@ -33,7 +33,8 @@ const serviceTokenDataSchema = new Schema( }, user: { type: Schema.Types.ObjectId, - ref: 'User' + ref: 'User', + required: true }, serviceAccount: { type: Schema.Types.ObjectId, diff --git a/backend/src/routes/v3/secrets.ts b/backend/src/routes/v3/secrets.ts index fe346b0de..cd3e08e46 100644 --- a/backend/src/routes/v3/secrets.ts +++ b/backend/src/routes/v3/secrets.ts @@ -10,6 +10,8 @@ import { secretsController } from '../../controllers/v3'; import { AUTH_MODE_JWT, AUTH_MODE_API_KEY, + AUTH_MODE_SERVICE_TOKEN, + AUTH_MODE_SERVICE_ACCOUNT, ADMIN, MEMBER, PERMISSION_WRITE_SECRETS, @@ -25,7 +27,12 @@ router.get( query('tagSlugs'), validateRequest, requireAuth({ - acceptedAuthModes: [AUTH_MODE_JWT, AUTH_MODE_API_KEY] + acceptedAuthModes: [ + AUTH_MODE_JWT, + AUTH_MODE_API_KEY, + AUTH_MODE_SERVICE_TOKEN, + AUTH_MODE_SERVICE_ACCOUNT + ] }), requireWorkspaceAuth({ acceptedRoles: [ADMIN, MEMBER], @@ -48,9 +55,17 @@ router.post( body('secretValueCiphertext').exists().isString().trim(), body('secretValueIV').exists().isString().trim(), body('secretValueTag').exists().isString().trim(), + body('secretCommentCiphertext').optional().isString().trim(), + body('secretCommentIV').optional().isString().trim(), + body('secretCommentTag').optional().isString().trim(), validateRequest, requireAuth({ - acceptedAuthModes: [AUTH_MODE_JWT, AUTH_MODE_API_KEY] + acceptedAuthModes: [ + AUTH_MODE_JWT, + AUTH_MODE_API_KEY, + AUTH_MODE_SERVICE_TOKEN, + AUTH_MODE_SERVICE_ACCOUNT + ] }), requireWorkspaceAuth({ acceptedRoles: [ADMIN, MEMBER], @@ -70,7 +85,12 @@ router.get( query('type').optional().isIn([SECRET_SHARED, SECRET_PERSONAL]), validateRequest, requireAuth({ - acceptedAuthModes: [AUTH_MODE_JWT, AUTH_MODE_API_KEY] + acceptedAuthModes: [ + AUTH_MODE_JWT, + AUTH_MODE_API_KEY, + AUTH_MODE_SERVICE_TOKEN, + AUTH_MODE_SERVICE_ACCOUNT + ] }), requireWorkspaceAuth({ acceptedRoles: [ADMIN, MEMBER], @@ -93,7 +113,12 @@ router.patch( body('secretValueTag').exists().isString().trim(), validateRequest, requireAuth({ - acceptedAuthModes: [AUTH_MODE_JWT, AUTH_MODE_API_KEY] + acceptedAuthModes: [ + AUTH_MODE_JWT, + AUTH_MODE_API_KEY, + AUTH_MODE_SERVICE_TOKEN, + AUTH_MODE_SERVICE_ACCOUNT + ] }), requireWorkspaceAuth({ acceptedRoles: [ADMIN, MEMBER], @@ -113,7 +138,12 @@ router.delete( body('type').exists().isIn([SECRET_SHARED, SECRET_PERSONAL]), validateRequest, requireAuth({ - acceptedAuthModes: [AUTH_MODE_JWT, AUTH_MODE_API_KEY] + acceptedAuthModes: [ + AUTH_MODE_JWT, + AUTH_MODE_API_KEY, + AUTH_MODE_SERVICE_TOKEN, + AUTH_MODE_SERVICE_ACCOUNT + ] }), requireWorkspaceAuth({ acceptedRoles: [ADMIN, MEMBER], diff --git a/backend/src/services/SecretService.ts b/backend/src/services/SecretService.ts index 6d0ec0e07..c215431f1 100644 --- a/backend/src/services/SecretService.ts +++ b/backend/src/services/SecretService.ts @@ -11,6 +11,7 @@ import { DeleteSecretParams } from '../interfaces/services/SecretService'; import { + initSecretBlindIndexDataHelper, createSecretBlindIndexDataHelper, getSecretBlindIndexSaltHelper, generateSecretBlindIndexWithSaltHelper, @@ -23,6 +24,17 @@ import { } from '../helpers/secrets'; class SecretService { + + /** + * + * @param param0 h + * @returns + */ + + static async initSecretBlindIndexDataHelper() { + return await initSecretBlindIndexDataHelper(); + } + /** * Create secret blind index data containing encrypted blind index salt * for workspace with id [workspaceId] diff --git a/backend/src/services/TelemetryService.ts b/backend/src/services/TelemetryService.ts index be9857d99..578b5292d 100644 --- a/backend/src/services/TelemetryService.ts +++ b/backend/src/services/TelemetryService.ts @@ -16,6 +16,7 @@ import { ServiceTokenData } from '../models'; import { + AccountNotFoundError, BadRequestError } from '../utils/errors'; @@ -50,20 +51,23 @@ class Telemetry { return postHogClient; } - static getDistinctId ({ + static getDistinctId = async ({ authData }: { authData: AuthData; - }) { - let distinctId: any = ''; + }) => { + let distinctId = ''; if (authData.authPayload instanceof User) { distinctId = authData.authPayload.email; } else if (authData.authPayload instanceof ServiceAccount) { distinctId = `sa.${authData.authPayload._id.toString()}`; } else if (authData.authPayload instanceof ServiceTokenData) { - if (authData.authPayload.user instanceof User) { - distinctId = authData.authPayload?.user.email; - } else if (authData.authPayload?.serviceAccount) { + + if (authData.authPayload.user) { + const user = await User.findById(authData.authPayload.user, 'email'); + if (!user) throw AccountNotFoundError(); + distinctId = user.email; + } else if (authData.authPayload.serviceAccount) { distinctId = distinctId = `sa.${authData.authPayload.serviceAccount.toString()}`; } } diff --git a/backend/src/utils/auth.ts b/backend/src/utils/auth.ts index ce2961d70..bfb51e6d2 100644 --- a/backend/src/utils/auth.ts +++ b/backend/src/utils/auth.ts @@ -2,9 +2,17 @@ import { AuthData } from '../interfaces/middleware'; import { User, ServiceAccount, - ServiceTokenData + ServiceTokenData, + ServiceToken } from '../models'; +// TODO: find a more optimal folder structure to store these types of functions + +/** + * Returns an object containing the id of the authentication data payload + * @param {AuthData} authData - authentication data object + * @returns + */ const getAuthDataPayloadIdObj = (authData: AuthData) => { if (authData.authPayload instanceof User) { return { userId: authData.authPayload._id }; @@ -17,10 +25,30 @@ const getAuthDataPayloadIdObj = (authData: AuthData) => { if (authData.authPayload instanceof ServiceTokenData) { return { serviceTokenDataId: authData.authPayload._id }; } - - return {}; }; + +/** + * Returns an object containing the user associated with the authentication data payload + * @param {AuthData} authData - authentication data object + * @returns + */ +const getAuthDataPayloadUserObj = (authData: AuthData) => { + + if (authData.authPayload instanceof User) { + return { user: authData.authPayload._id }; + } + + if (authData.authPayload instanceof ServiceAccount) { + return { user: authData.authPayload.user }; + } + + if (authData.authPayload instanceof ServiceTokenData) { + return { user: authData.authPayload.user }; + } +} + export { - getAuthDataPayloadIdObj + getAuthDataPayloadIdObj, + getAuthDataPayloadUserObj } \ No newline at end of file diff --git a/docs/security/data-model.mdx b/docs/security/data-model.mdx index 9f79cd254..af4adeb24 100644 --- a/docs/security/data-model.mdx +++ b/docs/security/data-model.mdx @@ -21,21 +21,30 @@ Infisical makes a usability-security tradeoff that is to give users convenient a ## Secrets -The `Secret` model includes the fields `workspace`, `type`, `user`, `environment`, `secretKeyCiphertext`, `secretKeyIV`, `secretKeyTag`, `secretValueCiphertext`, `secretValueIV`, and `secretValueTag`. +The `Secret` model includes the fields `workspace`, `type`, `user`, `environment`, `secretBlindIndex`, `secretKeyCiphertext`, `secretKeyIV`, `secretKeyTag`, `secretValueCiphertext`, `secretValueIV`, and `secretValueTag`. -Each secret is symmetrically encrypted by the key of the project that it belongs to; that key's encrypted copies are stored in a separate `Key` collection. +Each secret consists of a key name and value pair and is symmetrically encrypted by the key of the project that it belongs to; that key's encrypted copies are stored in a separate `Key` collection. + +The `secretBlindIndex` enables users to query secrets by their names; it is a blind index computed by applying `argon2id` with a 128-bit random salt (unique to each project) and the name of the secret. The salt itself is symmetrically encrypted under the server key and stored in the `SecretBlindIndexData` collection. + +## Blind Index Data + +The `SecretBlindIndexData` model includes the fields `workspace`, `encryptedSaltCiphertext`, `saltIV`, and `saltTag`. + +Infisical stores salts (unique to each project) symmetrically encrypted under the server key. The salts are used to compute blind indices for secrets that enable +users to query secrets by name. ## Project Keys The `Key` model includes the fields `encryptedKey`, `nonce`, `sender`, `receiver`, and `workspace`. -Infisical stores copies of project keys, one for each member of a project, encrypted under each member's public key. +Infisical stores copies of project keys, one for each member of a project, asymmetrically encrypted under each member's public key. ## Bots The `Bot` model contains the fields `name`, `workspace`, `isActive`, `publicKey`, `encryptedPrivateKey`, `iv`, and `tag`. -Each project comes with a bot that has its own public-private key pair; its private key is encrypted by the server's symmetric key. If needed, a user can opt-in to share their project key with the bot (i.e. Infisical) to give the platform access to the project's secrets. +Each project comes with a bot that has its own public-private key pair; its private key is symmetrically encrypted by the server's key. If needed, a user can opt-in to share their project key with the bot (i.e. Infisical) to give the platform access to the project's secrets. Sharing secrets with Infisical so they can be synced to integrations like diff --git a/docs/security/mechanics.mdx b/docs/security/mechanics.mdx index 325224f90..97cd570f1 100644 --- a/docs/security/mechanics.mdx +++ b/docs/security/mechanics.mdx @@ -27,3 +27,20 @@ After signing up, a user can invite other users to their organization to partake To push secrets, a sender randomly-generates a symmetric encryption key, uses that key to encrypt their secret keys and values separately, asymmetrically encrypts the key with the receivers’ public keys, and uploads the encrypted secrets and keys to the server. To pull secrets, a receiver obtains encrypted secret keys and values and their encrypted copy of the project key to decrypt the secrets from the server — they asymmetrically decrypt the key using their private key and use the decrypted key to decrypt the secrets. This public-key mechanism prevents the server-side from reading any secrets. + +When dealing with individual secrets (e.g. pulling one secret by name) or creating new secrets, a user passes the name of the secret to the server which is then converted to a blind index by applying `argon2id` with the name of the secret and a 128-bit random salt unique to each project; the salt itself is encrypted by the server key and stored in the database. + + + Infisical ensures that the name of any secret is never stored in plaintext and instead only a blind index generated from the name. It is infeasible to reverse back a blind index to the name of a secret without knowledge of the server key. + + +## Bot + +To use some features like integrations, users must opt out of E2EE (this means sharing access to secrets with Infisical). + +Infisical employs the concept of a bot which is a cryptographic abstraction for how Infisical interacts with secrets when users opt out of E2EE. In this model, each project is assigned a bot with its own public-private key pair where each bot's private key is stored symmetrically encrypted under the server key. When a user opts out of E2EE, they share the project key with the bot by encrypting a copy of it under the public key of the bot. + +When users wish to sync secrets from a project and environment Infisical to other platform integrations like Vercel or GitHub, Infisical decrypts the intended secrets and uses the integration platform's APIs to send secrets over. It should be noted that opting out of E2EE is optional and it is entirely possible to use Infisical to manage secrets across your team and infrastructure without opting out of E2EE. + + + diff --git a/docs/security/overview.mdx b/docs/security/overview.mdx index 643a54b38..bddff608b 100644 --- a/docs/security/overview.mdx +++ b/docs/security/overview.mdx @@ -5,7 +5,7 @@ description: "Infisical's security statement." ## Summary -Infisical uses end-to-end encryption (E2EE) whenever possible to securely store and share secrets. It uses secure remote password (SRP) to handle authentication and public-key cryptography for secret sharing and syncing; secrets are symmetrically encrypted at rest by keys decryptable only by members of the project. +Infisical uses end-to-end encryption (E2EE) whenever possible to securely store and share secret values. It uses secure remote password (SRP) to handle authentication and public-key cryptography for secret sharing and syncing; secrets are symmetrically encrypted by keys decryptable only by members of the project. Infisical uses AES256-GCM for symmetric encryption and x25519-xsalsa20-poly1305 for asymmetric encryption operations mentioned in this brief; key generation and asymmetric algorithms are implemented with the [TweetNaCl.js](https://tweetnacl.js.org/#/) library which has been well-audited and recommended for use by cybersecurity firm Cure53. Lastly, the secure remote password (SRP) implementation uses [jsrp](https://github.com/alax/jsrp) package for user authentication. As part of our commitment to user privacy and security, we aim to conduct formal security and compliance audits in the following year. diff --git a/frontend/src/helpers/project.ts b/frontend/src/helpers/project.ts index 8f6d02102..971aa73ca 100644 --- a/frontend/src/helpers/project.ts +++ b/frontend/src/helpers/project.ts @@ -93,6 +93,7 @@ const initProjectHelper = async ({ }) => { let project; try { + // create new project project = await createWorkspace({ workspaceName: projectName,