mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-11 06:29:04 +00:00
Merge
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TableName } from "../schemas";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
const hasSecretSharingColumn = await knex.schema.hasColumn(TableName.Project, "secretSharing");
|
||||
if (!hasSecretSharingColumn) {
|
||||
await knex.schema.table(TableName.Project, (table) => {
|
||||
table.boolean("secretSharing").notNullable().defaultTo(true);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
const hasSecretSharingColumn = await knex.schema.hasColumn(TableName.Project, "secretSharing");
|
||||
if (hasSecretSharingColumn) {
|
||||
await knex.schema.table(TableName.Project, (table) => {
|
||||
table.dropColumn("secretSharing");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TableName } from "../schemas";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
if (await knex.schema.hasTable(TableName.SecretSharing)) {
|
||||
const hasEncryptedSalt = await knex.schema.hasColumn(TableName.SecretSharing, "encryptedSalt");
|
||||
const hasAuthorizedEmails = await knex.schema.hasColumn(TableName.SecretSharing, "authorizedEmails");
|
||||
|
||||
if (!hasEncryptedSalt || !hasAuthorizedEmails) {
|
||||
await knex.schema.alterTable(TableName.SecretSharing, (t) => {
|
||||
// These two columns are only needed when secrets are shared with a specific list of emails
|
||||
|
||||
if (!hasEncryptedSalt) {
|
||||
t.binary("encryptedSalt").nullable();
|
||||
}
|
||||
|
||||
if (!hasAuthorizedEmails) {
|
||||
t.json("authorizedEmails").nullable();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
if (await knex.schema.hasTable(TableName.SecretSharing)) {
|
||||
const hasEncryptedSalt = await knex.schema.hasColumn(TableName.SecretSharing, "encryptedSalt");
|
||||
const hasAuthorizedEmails = await knex.schema.hasColumn(TableName.SecretSharing, "authorizedEmails");
|
||||
|
||||
if (hasEncryptedSalt || hasAuthorizedEmails) {
|
||||
await knex.schema.alterTable(TableName.SecretSharing, (t) => {
|
||||
if (hasEncryptedSalt) {
|
||||
t.dropColumn("encryptedSalt");
|
||||
}
|
||||
|
||||
if (hasAuthorizedEmails) {
|
||||
t.dropColumn("authorizedEmails");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,8 @@ export const ProjectsSchema = z.object({
|
||||
description: z.string().nullable().optional(),
|
||||
type: z.string(),
|
||||
enforceCapitalization: z.boolean().default(false),
|
||||
hasDeleteProtection: z.boolean().default(false).nullable().optional()
|
||||
hasDeleteProtection: z.boolean().default(false).nullable().optional(),
|
||||
secretSharing: z.boolean().default(true)
|
||||
});
|
||||
|
||||
export type TProjects = z.infer<typeof ProjectsSchema>;
|
||||
|
||||
@@ -27,7 +27,9 @@ export const SecretSharingSchema = z.object({
|
||||
password: z.string().nullable().optional(),
|
||||
encryptedSecret: zodBuffer.nullable().optional(),
|
||||
identifier: z.string().nullable().optional(),
|
||||
type: z.string().default("share")
|
||||
type: z.string().default("share"),
|
||||
encryptedSalt: zodBuffer.nullable().optional(),
|
||||
authorizedEmails: z.unknown().nullable().optional()
|
||||
});
|
||||
|
||||
export type TSecretSharing = z.infer<typeof SecretSharingSchema>;
|
||||
|
||||
@@ -608,7 +608,8 @@ export const PROJECTS = {
|
||||
projectDescription: "An optional description label for the project.",
|
||||
autoCapitalization: "Disable or enable auto-capitalization for the project.",
|
||||
slug: "An optional slug for the project. (must be unique within the organization)",
|
||||
hasDeleteProtection: "Enable or disable delete protection for the project."
|
||||
hasDeleteProtection: "Enable or disable delete protection for the project.",
|
||||
secretSharing: "Enable or disable secret sharing for the project."
|
||||
},
|
||||
GET_KEY: {
|
||||
workspaceId: "The ID of the project to get the key from."
|
||||
|
||||
@@ -261,7 +261,8 @@ export const SanitizedProjectSchema = ProjectsSchema.pick({
|
||||
pitVersionLimit: true,
|
||||
kmsCertificateKeyId: true,
|
||||
auditLogsRetentionDays: true,
|
||||
hasDeleteProtection: true
|
||||
hasDeleteProtection: true,
|
||||
secretSharing: true
|
||||
});
|
||||
|
||||
export const SanitizedTagSchema = SecretTagsSchema.pick({
|
||||
|
||||
@@ -131,8 +131,8 @@ export const registerCertRouter = async (server: FastifyZodProvider) => {
|
||||
response: {
|
||||
200: z.object({
|
||||
certificate: z.string().trim().describe(CERTIFICATES.GET_CERT.certificate),
|
||||
certificateChain: z.string().trim().nullish().describe(CERTIFICATES.GET_CERT.certificateChain),
|
||||
privateKey: z.string().trim().describe(CERTIFICATES.GET_CERT.privateKey),
|
||||
certificateChain: z.string().trim().nullable().describe(CERTIFICATES.GET_CERT.certificateChain),
|
||||
privateKey: z.string().trim().nullable().describe(CERTIFICATES.GET_CERT.privateKey),
|
||||
serialNumber: z.string().trim().describe(CERTIFICATES.GET_CERT.serialNumberRes)
|
||||
})
|
||||
}
|
||||
@@ -518,7 +518,7 @@ export const registerCertRouter = async (server: FastifyZodProvider) => {
|
||||
response: {
|
||||
200: z.object({
|
||||
certificate: z.string().trim().describe(CERTIFICATES.GET_CERT.certificate),
|
||||
certificateChain: z.string().trim().nullish().describe(CERTIFICATES.GET_CERT.certificateChain),
|
||||
certificateChain: z.string().trim().nullable().describe(CERTIFICATES.GET_CERT.certificateChain),
|
||||
serialNumber: z.string().trim().describe(CERTIFICATES.GET_CERT.serialNumberRes)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -346,7 +346,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
|
||||
"Project slug can only contain lowercase letters and numbers, with optional single hyphens (-) or underscores (_) between words. Cannot start or end with a hyphen or underscore."
|
||||
})
|
||||
.optional()
|
||||
.describe(PROJECTS.UPDATE.slug)
|
||||
.describe(PROJECTS.UPDATE.slug),
|
||||
secretSharing: z.boolean().optional().describe(PROJECTS.UPDATE.secretSharing)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
@@ -366,7 +367,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
|
||||
description: req.body.description,
|
||||
autoCapitalization: req.body.autoCapitalization,
|
||||
hasDeleteProtection: req.body.hasDeleteProtection,
|
||||
slug: req.body.slug
|
||||
slug: req.body.slug,
|
||||
secretSharing: req.body.secretSharing
|
||||
},
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorId: req.permission.id,
|
||||
|
||||
@@ -62,7 +62,9 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
|
||||
}),
|
||||
body: z.object({
|
||||
hashedHex: z.string().min(1).optional(),
|
||||
password: z.string().optional()
|
||||
password: z.string().optional(),
|
||||
email: z.string().optional(),
|
||||
hash: z.string().optional()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
@@ -88,7 +90,9 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
|
||||
sharedSecretId: req.params.id,
|
||||
hashedHex: req.body.hashedHex,
|
||||
password: req.body.password,
|
||||
orgId: req.permission?.orgId
|
||||
orgId: req.permission?.orgId,
|
||||
email: req.body.email,
|
||||
hash: req.body.hash
|
||||
});
|
||||
|
||||
if (sharedSecret.secret?.orgId) {
|
||||
@@ -151,7 +155,8 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
|
||||
secretValue: z.string(),
|
||||
expiresAt: z.string(),
|
||||
expiresAfterViews: z.number().min(1).optional(),
|
||||
accessType: z.nativeEnum(SecretSharingAccessType).default(SecretSharingAccessType.Organization)
|
||||
accessType: z.nativeEnum(SecretSharingAccessType).default(SecretSharingAccessType.Organization),
|
||||
emails: z.string().email().array().max(100).optional()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
|
||||
@@ -105,7 +105,7 @@ export const buildCertificateChain = async ({
|
||||
kmsService,
|
||||
kmsId
|
||||
}: TBuildCertificateChainDTO) => {
|
||||
if (!encryptedCertificateChain && (!caCert || !caCertChain)) {
|
||||
if (!encryptedCertificateChain && !caCert) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
TGetCertPrivateKeyDTO,
|
||||
TRevokeCertDTO
|
||||
} from "./certificate-types";
|
||||
import { NotFoundError } from "@app/lib/errors";
|
||||
|
||||
type TCertificateServiceFactoryDep = {
|
||||
certificateDAL: Pick<TCertificateDALFactory, "findOne" | "deleteById" | "update" | "find">;
|
||||
@@ -337,18 +338,27 @@ export const certificateServiceFactory = ({
|
||||
encryptedCertificateChain: certBody.encryptedCertificateChain || undefined
|
||||
});
|
||||
|
||||
const { certPrivateKey } = await getCertificateCredentials({
|
||||
certId: cert.id,
|
||||
projectId: ca.projectId,
|
||||
certificateSecretDAL,
|
||||
projectDAL,
|
||||
kmsService
|
||||
});
|
||||
let privateKey: string | null = null;
|
||||
try {
|
||||
const { certPrivateKey } = await getCertificateCredentials({
|
||||
certId: cert.id,
|
||||
projectId: ca.projectId,
|
||||
certificateSecretDAL,
|
||||
projectDAL,
|
||||
kmsService
|
||||
});
|
||||
privateKey = certPrivateKey;
|
||||
} catch (e) {
|
||||
// Skip NotFound errors but throw all others
|
||||
if (!(e instanceof NotFoundError)) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
certificate,
|
||||
certificateChain,
|
||||
privateKey: certPrivateKey,
|
||||
privateKey,
|
||||
serialNumber,
|
||||
cert,
|
||||
ca
|
||||
|
||||
@@ -79,7 +79,8 @@ export const identityKubernetesAuthServiceFactory = ({
|
||||
|
||||
const callbackResult = await withGatewayProxy(
|
||||
async (port) => {
|
||||
const res = await gatewayCallback("localhost", port);
|
||||
// Needs to be https protocol or the kubernetes API server will fail with "Client sent an HTTP request to an HTTPS server"
|
||||
const res = await gatewayCallback("https://localhost", port);
|
||||
return res;
|
||||
},
|
||||
{
|
||||
@@ -138,11 +139,7 @@ export const identityKubernetesAuthServiceFactory = ({
|
||||
}
|
||||
|
||||
const tokenReviewCallback = async (host: string = identityKubernetesAuth.kubernetesHost, port?: number) => {
|
||||
let baseUrl = `https://${host}`;
|
||||
|
||||
if (port) {
|
||||
baseUrl += `:${port}`;
|
||||
}
|
||||
const baseUrl = port ? `${host}:${port}` : host;
|
||||
|
||||
const res = await axios
|
||||
.post<TCreateTokenReviewResponse>(
|
||||
|
||||
@@ -658,7 +658,8 @@ export const projectServiceFactory = ({
|
||||
autoCapitalization: update.autoCapitalization,
|
||||
enforceCapitalization: update.autoCapitalization,
|
||||
hasDeleteProtection: update.hasDeleteProtection,
|
||||
slug: update.slug
|
||||
slug: update.slug,
|
||||
secretSharing: update.secretSharing
|
||||
});
|
||||
|
||||
return updatedProject;
|
||||
|
||||
@@ -93,6 +93,7 @@ export type TUpdateProjectDTO = {
|
||||
autoCapitalization?: boolean;
|
||||
hasDeleteProtection?: boolean;
|
||||
slug?: string;
|
||||
secretSharing?: boolean;
|
||||
};
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { TSecretSharing } from "@app/db/schemas";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
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";
|
||||
|
||||
@@ -78,8 +79,11 @@ export const secretSharingServiceFactory = ({
|
||||
password,
|
||||
accessType,
|
||||
expiresAt,
|
||||
expiresAfterViews
|
||||
expiresAfterViews,
|
||||
emails
|
||||
}: TCreateSharedSecretDTO) => {
|
||||
const appCfg = getConfig();
|
||||
|
||||
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
|
||||
if (!permission) throw new ForbiddenRequestError({ name: "User is not a part of the specified organization" });
|
||||
$validateSharedSecretExpiry(expiresAt);
|
||||
@@ -110,6 +114,31 @@ export const secretSharingServiceFactory = ({
|
||||
}
|
||||
|
||||
const encryptWithRoot = kmsService.encryptWithRootKey();
|
||||
|
||||
let salt: string | undefined;
|
||||
let encryptedSalt: Buffer | undefined;
|
||||
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 (!org.allowSecretSharingOutsideOrganization || accessType === SecretSharingAccessType.Organization) {
|
||||
throw new BadRequestError({
|
||||
message: "Organization does not allow sharing secrets to members outside of this organization"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Generate salt for signing email hashes (if emails are provided)
|
||||
salt = crypto.randomBytes(32).toString("hex");
|
||||
encryptedSalt = encryptWithRoot(Buffer.from(salt));
|
||||
}
|
||||
|
||||
const encryptedSecret = encryptWithRoot(Buffer.from(secretValue));
|
||||
|
||||
const id = crypto.randomBytes(32).toString("hex");
|
||||
@@ -128,11 +157,45 @@ export const secretSharingServiceFactory = ({
|
||||
expiresAfterViews,
|
||||
userId: actorId,
|
||||
orgId,
|
||||
accessType
|
||||
accessType,
|
||||
authorizedEmails: emails && emails.length > 0 ? JSON.stringify(emails) : undefined,
|
||||
encryptedSalt
|
||||
});
|
||||
|
||||
const idToReturn = `${Buffer.from(newSharedSecret.identifier!, "hex").toString("base64url")}`;
|
||||
|
||||
// Loop through recipients and send out emails with unique access links
|
||||
if (emails && salt) {
|
||||
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 {
|
||||
const hmac = crypto.createHmac("sha256", salt).update(email);
|
||||
const hash = hmac.digest("hex");
|
||||
|
||||
// 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}?email=${encodeURIComponent(email)}&hash=${hash}`
|
||||
},
|
||||
template: SmtpTemplates.SecretRequestCompleted
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error(e, "Failed to send shared secret URL to a recipient's email.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { id: idToReturn };
|
||||
};
|
||||
|
||||
@@ -406,8 +469,15 @@ export const secretSharingServiceFactory = ({
|
||||
});
|
||||
};
|
||||
|
||||
/** Get's password-less secret. validates all secret's requested (must be fresh). */
|
||||
const getSharedSecretById = async ({ sharedSecretId, hashedHex, orgId, password }: TGetActiveSharedSecretByIdDTO) => {
|
||||
/** Gets password-less secret. validates all secret's requested (must be fresh). */
|
||||
const getSharedSecretById = async ({
|
||||
sharedSecretId,
|
||||
hashedHex,
|
||||
orgId,
|
||||
password,
|
||||
email,
|
||||
hash
|
||||
}: TGetActiveSharedSecretByIdDTO) => {
|
||||
const sharedSecret = isUuidV4(sharedSecretId)
|
||||
? await secretSharingDAL.findOne({
|
||||
id: sharedSecretId,
|
||||
@@ -454,6 +524,32 @@ export const secretSharingServiceFactory = ({
|
||||
});
|
||||
}
|
||||
|
||||
const decryptWithRoot = kmsService.decryptWithRootKey();
|
||||
|
||||
if (sharedSecret.authorizedEmails && sharedSecret.encryptedSalt) {
|
||||
// Verify both params were passed
|
||||
if (!email || !hash) {
|
||||
throw new BadRequestError({
|
||||
message: "This secret is email protected. Parameters must include email and hash."
|
||||
});
|
||||
|
||||
// Verify that email is authorized to view shared secret
|
||||
} else if (!(sharedSecret.authorizedEmails as string[]).includes(email)) {
|
||||
throw new UnauthorizedError({ message: "Email not authorized to view secret" });
|
||||
|
||||
// Verify that hash matches
|
||||
} else {
|
||||
const salt = decryptWithRoot(sharedSecret.encryptedSalt).toString();
|
||||
const hmac = crypto.createHmac("sha256", salt).update(email);
|
||||
const rebuiltHash = hmac.digest("hex");
|
||||
|
||||
if (rebuiltHash !== hash) {
|
||||
throw new UnauthorizedError({ message: "Email not authorized to view secret" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Password checks
|
||||
const isPasswordProtected = Boolean(sharedSecret.password);
|
||||
const hasProvidedPassword = Boolean(password);
|
||||
if (isPasswordProtected) {
|
||||
@@ -468,7 +564,6 @@ 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ export type TSharedSecretPermission = {
|
||||
accessType?: SecretSharingAccessType;
|
||||
name?: string;
|
||||
password?: string;
|
||||
emails?: string[];
|
||||
};
|
||||
|
||||
export type TCreatePublicSharedSecretDTO = {
|
||||
@@ -37,6 +38,10 @@ export type TGetActiveSharedSecretByIdDTO = {
|
||||
hashedHex?: string;
|
||||
orgId?: string;
|
||||
password?: string;
|
||||
|
||||
// For secrets shared with specific emails
|
||||
email?: string;
|
||||
hash?: string;
|
||||
};
|
||||
|
||||
export type TValidateActiveSharedSecretDTO = TGetActiveSharedSecretByIdDTO & {
|
||||
|
||||
Reference in New Issue
Block a user