Files
infisical/backend/src/services/secret-sharing/secret-sharing-service.ts
T

639 lines
20 KiB
TypeScript

import { OrganizationActionScope, TSecretSharing } from "@app/db/schemas";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import { getConfig } from "@app/lib/config/env";
import { crypto } from "@app/lib/crypto/cryptography";
import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors";
import { logger } from "@app/lib/logger";
import { SecretSharingAccessType } from "@app/lib/types";
import { isUuidV4 } from "@app/lib/validator";
import { TKmsServiceFactory } from "../kms/kms-service";
import { TOrgDALFactory } from "../org/org-dal";
import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service";
import { TUserDALFactory } from "../user/user-dal";
import { TSecretSharingDALFactory } from "./secret-sharing-dal";
import {
SecretSharingType,
TCreatePublicSharedSecretDTO,
TCreateSecretRequestDTO,
TCreateSharedSecretDTO,
TDeleteSharedSecretDTO,
TGetActiveSharedSecretByIdDTO,
TGetSecretRequestByIdDTO,
TGetSharedSecretsDTO,
TRevealSecretRequestValueDTO,
TSetSecretRequestValueDTO
} from "./secret-sharing-types";
type TSecretSharingServiceFactoryDep = {
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
secretSharingDAL: TSecretSharingDALFactory;
orgDAL: TOrgDALFactory;
userDAL: TUserDALFactory;
kmsService: TKmsServiceFactory;
smtpService: TSmtpService;
};
export type TSecretSharingServiceFactory = ReturnType<typeof secretSharingServiceFactory>;
export const secretSharingServiceFactory = ({
permissionService,
secretSharingDAL,
orgDAL,
kmsService,
smtpService,
userDAL
}: TSecretSharingServiceFactoryDep) => {
const $validateSharedSecretExpiry = (expiresAt: string) => {
if (new Date(expiresAt) < new Date()) {
throw new BadRequestError({ message: "Expiration date cannot be in the past" });
}
// Limit Expiry Time to 1 month
const expiryTime = new Date(expiresAt).getTime();
const currentTime = new Date().getTime();
const thirtyDays = 30 * 24 * 60 * 60 * 1000;
if (expiryTime - currentTime > thirtyDays) {
throw new BadRequestError({ message: "Expiration date cannot be more than 30 days" });
}
const fiveMins = 5 * 60 * 1000;
// 1 second buffer
if (expiryTime - currentTime + 1000 < fiveMins) {
throw new BadRequestError({ message: "Expiration time cannot be less than 5 mins" });
}
};
const createSharedSecret = async ({
actor,
actorId,
orgId,
actorAuthMethod,
actorOrgId,
secretValue,
name,
password,
accessType,
expiresAt,
expiresAfterViews,
emails
}: TCreateSharedSecretDTO) => {
const appCfg = getConfig();
const { permission } = await permissionService.getOrgPermission({
actor,
actorId,
orgId,
actorAuthMethod,
actorOrgId,
scope: OrganizationActionScope.Any
});
if (!permission) throw new ForbiddenRequestError({ name: "User is not a part of the specified organization" });
$validateSharedSecretExpiry(expiresAt);
const rootOrg = await orgDAL.findRootOrgDetails(orgId);
if (!rootOrg) throw new BadRequestError({ message: `Organization with id ${orgId} not found` });
if (!rootOrg.allowSecretSharingOutsideOrganization && accessType === SecretSharingAccessType.Anyone) {
throw new BadRequestError({
message: "Organization does not allow sharing secrets to members outside of this organization"
});
}
if (secretValue.length > 10_000) {
throw new BadRequestError({ message: "Shared secret value too long" });
}
// Check lifetime is within org allowance
const expiresAtTimestamp = new Date(expiresAt).getTime();
const lifetime = expiresAtTimestamp - new Date().getTime();
// rootOrg.maxSharedSecretLifetime is in seconds
if (rootOrg.maxSharedSecretLifetime && lifetime / 1000 > rootOrg.maxSharedSecretLifetime) {
throw new BadRequestError({ message: "Secret lifetime exceeds organization limit" });
}
// Check max view count is within org allowance
if (
rootOrg.maxSharedSecretViewLimit &&
(!expiresAfterViews || expiresAfterViews > rootOrg.maxSharedSecretViewLimit)
) {
throw new BadRequestError({ message: "Secret max views parameter exceeds organization limit" });
}
const encryptWithRoot = kmsService.encryptWithRootKey();
const orgEmails = [];
if (emails && emails.length > 0) {
const allOrgMembers = await orgDAL.findAllOrgMembers(orgId);
// Check to see that all emails are a part of the organization (if enforced) while also collecting a list of emails which are in the org
for (const email of emails) {
if (allOrgMembers.some((v) => v.user.email === email)) {
orgEmails.push(email);
// If the email is not part of the org, but access type / org settings require it
} else if (
!rootOrg.allowSecretSharingOutsideOrganization ||
accessType === SecretSharingAccessType.Organization
) {
throw new BadRequestError({
message: "Organization does not allow sharing secrets to members outside of this organization"
});
}
}
}
const encryptedSecret = encryptWithRoot(Buffer.from(secretValue));
const id = crypto.randomBytes(32).toString("hex");
const hashedPassword = password ? await crypto.hashing().createHash(password, appCfg.SALT_ROUNDS) : null;
const newSharedSecret = await secretSharingDAL.create({
identifier: id,
iv: null,
tag: null,
encryptedValue: null,
encryptedSecret,
name,
type: SecretSharingType.Share,
password: hashedPassword,
expiresAt: new Date(expiresAt),
expiresAfterViews,
userId: actorId,
orgId,
accessType,
authorizedEmails: emails && emails.length > 0 ? JSON.stringify(emails) : undefined
});
const idToReturn = `${Buffer.from(newSharedSecret.identifier!, "hex").toString("base64url")}`;
// Loop through recipients and send out emails with unique access links
if (emails) {
const user = await userDAL.findById(actorId);
if (!user) {
throw new NotFoundError({ message: `User with ID '${actorId}' not found` });
}
for await (const email of emails) {
try {
// Only show the username to emails which are part of the organization
const respondentUsername = orgEmails.includes(email) ? user.username : undefined;
await smtpService.sendMail({
recipients: [email],
subjectLine: "A secret has been shared with you",
substitutions: {
name,
respondentUsername,
secretRequestUrl: `${appCfg.SITE_URL}/shared/secret/${idToReturn}`
},
template: SmtpTemplates.SecretRequestCompleted
});
} catch (e) {
logger.error(e, "Failed to send shared secret URL to a recipient's email.");
}
}
}
return { id: idToReturn };
};
const createSecretRequest = async ({
actor,
accessType,
expiresAt,
name,
actorId,
orgId,
actorAuthMethod,
actorOrgId
}: TCreateSecretRequestDTO) => {
const { permission } = await permissionService.getOrgPermission({
scope: OrganizationActionScope.Any,
actor,
actorId,
orgId,
actorAuthMethod,
actorOrgId
});
if (!permission) throw new ForbiddenRequestError({ name: "User is not a part of the specified organization" });
$validateSharedSecretExpiry(expiresAt);
const newSecretRequest = await secretSharingDAL.create({
type: SecretSharingType.Request,
userId: actorId,
orgId,
name,
encryptedSecret: null,
accessType,
expiresAt: new Date(expiresAt)
});
return { id: newSecretRequest.id };
};
const revealSecretRequestValue = async ({
id,
actor,
actorId,
actorOrgId,
orgId,
actorAuthMethod
}: TRevealSecretRequestValueDTO) => {
const secretRequest = await secretSharingDAL.getSecretRequestById(id);
if (!secretRequest) {
throw new NotFoundError({ message: `Secret request with ID '${id}' not found` });
}
const { permission } = await permissionService.getOrgPermission({
scope: OrganizationActionScope.Any,
actor,
actorId,
orgId,
actorAuthMethod,
actorOrgId
});
if (!permission) throw new ForbiddenRequestError({ name: "User is not a part of the specified organization" });
if (secretRequest.userId !== actorId || secretRequest.orgId !== orgId) {
throw new ForbiddenRequestError({ name: "User does not have permission to access this secret request" });
}
if (!secretRequest.encryptedSecret) {
throw new BadRequestError({ message: "Secret request has no value set" });
}
const decryptWithRoot = kmsService.decryptWithRootKey();
const decryptedSecret = decryptWithRoot(secretRequest.encryptedSecret);
return { ...secretRequest, secretValue: decryptedSecret.toString() };
};
const getSecretRequestById = async ({
id,
actor,
actorId,
actorAuthMethod,
actorOrgId
}: TGetSecretRequestByIdDTO) => {
const secretRequest = await secretSharingDAL.getSecretRequestById(id);
if (!secretRequest) {
throw new NotFoundError({ message: `Secret request with ID '${id}' not found` });
}
if (secretRequest.accessType === SecretSharingAccessType.Organization) {
if (!secretRequest.orgId) {
throw new BadRequestError({ message: "No organization ID present on secret request" });
}
if (!actorOrgId) {
throw new UnauthorizedError();
}
const { permission } = await permissionService.getOrgPermission({
scope: OrganizationActionScope.Any,
actor,
actorId,
orgId: secretRequest.orgId,
actorAuthMethod,
actorOrgId
});
if (!permission) throw new ForbiddenRequestError({ name: "User is not a part of the specified organization" });
}
if (secretRequest.expiresAt && secretRequest.expiresAt < new Date()) {
throw new ForbiddenRequestError({
message: "Access denied: Secret request has expired"
});
}
return {
...secretRequest,
isSecretValueSet: Boolean(secretRequest.encryptedSecret)
};
};
const setSecretRequestValue = async ({
id,
actor,
actorId,
actorAuthMethod,
actorOrgId,
secretValue
}: TSetSecretRequestValueDTO) => {
const appCfg = getConfig();
const secretRequest = await secretSharingDAL.getSecretRequestById(id);
if (!secretRequest) {
throw new NotFoundError({ message: `Secret request with ID '${id}' not found` });
}
let respondentUsername: string | undefined;
if (secretRequest.accessType === SecretSharingAccessType.Organization) {
if (!secretRequest.orgId) {
throw new BadRequestError({ message: "No organization ID present on secret request" });
}
if (!actorOrgId) {
throw new UnauthorizedError();
}
const { permission } = await permissionService.getOrgPermission({
scope: OrganizationActionScope.Any,
actor,
actorId,
orgId: secretRequest.orgId,
actorAuthMethod,
actorOrgId
});
if (!permission) throw new ForbiddenRequestError({ name: "User is not a part of the specified organization" });
const user = await userDAL.findById(actorId);
if (!user) {
throw new NotFoundError({ message: `User with ID '${actorId}' not found` });
}
respondentUsername = user.username;
}
if (secretRequest.encryptedSecret) {
throw new BadRequestError({ message: "Secret request already has a value set" });
}
if (secretValue.length > 10_000) {
throw new BadRequestError({ message: "Shared secret value too long" });
}
if (secretRequest.expiresAt && secretRequest.expiresAt < new Date()) {
throw new ForbiddenRequestError({
message: "Access denied: Secret request has expired"
});
}
const encryptWithRoot = kmsService.encryptWithRootKey();
const encryptedSecret = encryptWithRoot(Buffer.from(secretValue));
const request = await secretSharingDAL.transaction(async (tx) => {
const updatedRequest = await secretSharingDAL.updateById(id, { encryptedSecret }, tx);
await smtpService.sendMail({
recipients: [secretRequest.requesterUsername],
subjectLine: "Secret Request Completed",
substitutions: {
name: secretRequest.name,
respondentUsername,
secretRequestUrl: `${appCfg.SITE_URL}/organizations/${secretRequest.orgId}/secret-sharing?selectedTab=request-secret`
},
template: SmtpTemplates.SecretRequestCompleted
});
return updatedRequest;
});
return request;
};
const createPublicSharedSecret = async ({
password,
secretValue,
expiresAt,
expiresAfterViews,
accessType
}: TCreatePublicSharedSecretDTO) => {
$validateSharedSecretExpiry(expiresAt);
const encryptWithRoot = kmsService.encryptWithRootKey();
const encryptedSecret = encryptWithRoot(Buffer.from(secretValue));
const appCfg = getConfig();
const id = crypto.randomBytes(32).toString("hex");
const hashedPassword = password ? await crypto.hashing().createHash(password, appCfg.SALT_ROUNDS) : null;
const newSharedSecret = await secretSharingDAL.create({
identifier: id,
encryptedValue: null,
iv: null,
tag: null,
type: SecretSharingType.Share,
encryptedSecret,
password: hashedPassword,
expiresAt: new Date(expiresAt),
expiresAfterViews,
accessType
});
return { id: `${Buffer.from(newSharedSecret.identifier!, "hex").toString("base64url")}` };
};
const getSharedSecrets = async ({
actor,
actorId,
actorAuthMethod,
actorOrgId,
offset,
limit,
type
}: TGetSharedSecretsDTO) => {
if (!actorOrgId) throw new ForbiddenRequestError();
const { permission } = await permissionService.getOrgPermission({
scope: OrganizationActionScope.Any,
actor,
actorId,
orgId: actorOrgId,
actorAuthMethod,
actorOrgId
});
if (!permission) throw new ForbiddenRequestError({ name: "User does not belong to the specified organization" });
const secrets = await secretSharingDAL.find(
{
userId: actorId,
orgId: actorOrgId,
type
},
{ offset, limit, sort: [["createdAt", "desc"]] }
);
const count = await secretSharingDAL.countAllUserOrgSharedSecrets({
orgId: actorOrgId,
userId: actorId,
type
});
return {
secrets,
totalCount: count
};
};
const $decrementSecretViewCount = async (sharedSecret: TSecretSharing) => {
const { expiresAfterViews } = sharedSecret;
if (expiresAfterViews) {
// decrement view count if view count expiry set
await secretSharingDAL.updateById(sharedSecret.id, { $decr: { expiresAfterViews: 1 } });
}
await secretSharingDAL.updateById(sharedSecret.id, {
lastViewedAt: new Date()
});
};
/** Gets password-less secret. validates all secret's requested (must be fresh). */
const getSharedSecretById = async ({
sharedSecretId,
hashedHex,
orgId,
actorId,
password
}: TGetActiveSharedSecretByIdDTO) => {
const sharedSecret = isUuidV4(sharedSecretId)
? await secretSharingDAL.findOne({
id: sharedSecretId,
type: SecretSharingType.Share,
hashedHex
})
: await secretSharingDAL.findOne({
type: SecretSharingType.Share,
identifier: Buffer.from(sharedSecretId, "base64url").toString("hex")
});
if (!sharedSecret)
throw new NotFoundError({
message: `Shared secret with ID '${sharedSecretId}' not found`
});
const { accessType, expiresAt, expiresAfterViews } = sharedSecret;
const orgName = sharedSecret.orgId ? (await orgDAL.findOrgById(sharedSecret.orgId))?.name : "";
if (accessType === SecretSharingAccessType.Organization && orgId === undefined) {
throw new UnauthorizedError();
}
if (accessType === SecretSharingAccessType.Organization && orgId !== sharedSecret.orgId) {
throw new ForbiddenRequestError();
}
// If the secret was shared with specific emails, verify that the current user's session email is authorized
if (sharedSecret.authorizedEmails && (sharedSecret.authorizedEmails as string[]).length > 0) {
if (!actorId) throw new UnauthorizedError();
const user = await userDAL.findById(actorId);
if (!user || !user.email) throw new UnauthorizedError();
if (!(sharedSecret.authorizedEmails as string[]).includes(user.email))
throw new UnauthorizedError({ message: "Email not authorized to view secret" });
}
// 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);
throw new ForbiddenRequestError({
message: "Access denied: Secret has expired by lifetime"
});
}
if (expiresAfterViews !== null && expiresAfterViews === 0) {
// check view count expiry
await secretSharingDAL.softDeleteById(sharedSecretId);
throw new ForbiddenRequestError({
message: "Access denied: Secret has expired by view count"
});
}
// Password checks
const isPasswordProtected = Boolean(sharedSecret.password);
const hasProvidedPassword = Boolean(password);
if (isPasswordProtected) {
if (hasProvidedPassword) {
const isMatch = await crypto.hashing().compareHash(password as string, sharedSecret.password as string);
if (!isMatch) throw new UnauthorizedError({ message: "Invalid credentials" });
} else {
return { isPasswordProtected };
}
}
const decryptWithRoot = kmsService.decryptWithRootKey();
// 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) {
decryptedSecretValue = decryptWithRoot(sharedSecret.encryptedSecret);
}
// decrement when we are sure the user will view secret.
await $decrementSecretViewCount(sharedSecret);
return {
isPasswordProtected,
secret: {
...sharedSecret,
...(decryptedSecretValue && {
secretValue: decryptedSecretValue.toString()
}),
orgName:
sharedSecret.accessType === SecretSharingAccessType.Organization && orgId === sharedSecret.orgId
? orgName
: undefined
}
};
};
const deleteSharedSecretById = async (deleteSharedSecretInput: TDeleteSharedSecretDTO) => {
const { actor, actorId, orgId, actorAuthMethod, actorOrgId, sharedSecretId } = deleteSharedSecretInput;
const { permission } = await permissionService.getOrgPermission({
scope: OrganizationActionScope.Any,
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.findOne({ id: sharedSecretId, type: deleteSharedSecretInput.type })
: await secretSharingDAL.findOne({ identifier: sharedSecretId, type: deleteSharedSecretInput.type });
if (sharedSecret.userId !== actorId) {
throw new ForbiddenRequestError({
message: "User does not have permission to delete shared secret"
});
}
if (sharedSecret.orgId && sharedSecret.orgId !== orgId) {
throw new ForbiddenRequestError({ message: "User does not have permission to delete shared secret" });
}
const deletedSharedSecret = await secretSharingDAL.deleteById(sharedSecretId);
return deletedSharedSecret;
};
return {
createSharedSecret,
createPublicSharedSecret,
getSharedSecrets,
deleteSharedSecretById,
getSharedSecretById,
createSecretRequest,
getSecretRequestById,
setSecretRequestValue,
revealSecretRequestValue
};
};