Merge pull request #3670 from Infisical/ENG-2827

feat(secret-sharing): Require Login for Secrets Shared to Specific Emails
This commit is contained in:
x032205
2025-05-28 19:23:26 -04:00
committed by GitHub
14 changed files with 103 additions and 112 deletions
@@ -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)) {
const hasEncryptedSalt = await knex.schema.hasColumn(TableName.SecretSharing, "encryptedSalt");
if (hasEncryptedSalt) {
await knex.schema.alterTable(TableName.SecretSharing, (t) => {
t.dropColumn("encryptedSalt");
});
}
}
}
export async function down(knex: Knex): Promise<void> {
if (await knex.schema.hasTable(TableName.SecretSharing)) {
const hasEncryptedSalt = await knex.schema.hasColumn(TableName.SecretSharing, "encryptedSalt");
if (!hasEncryptedSalt) {
await knex.schema.alterTable(TableName.SecretSharing, (t) => {
t.binary("encryptedSalt").nullable();
});
}
}
}
-1
View File
@@ -28,7 +28,6 @@ export const SecretSharingSchema = z.object({
encryptedSecret: zodBuffer.nullable().optional(), encryptedSecret: zodBuffer.nullable().optional(),
identifier: z.string().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() authorizedEmails: z.unknown().nullable().optional()
}); });
@@ -62,9 +62,7 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
}), }),
body: z.object({ body: z.object({
hashedHex: z.string().min(1).optional(), hashedHex: z.string().min(1).optional(),
password: z.string().optional(), password: z.string().optional()
email: z.string().optional(),
hash: z.string().optional()
}), }),
response: { response: {
200: z.object({ 200: z.object({
@@ -91,8 +89,7 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
hashedHex: req.body.hashedHex, hashedHex: req.body.hashedHex,
password: req.body.password, password: req.body.password,
orgId: req.permission?.orgId, orgId: req.permission?.orgId,
email: req.body.email, actorId: req.permission?.id
hash: req.body.hash
}); });
if (sharedSecret.secret?.orgId) { if (sharedSecret.secret?.orgId) {
@@ -156,7 +153,13 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
expiresAt: z.string(), expiresAt: z.string(),
expiresAfterViews: z.number().min(1).optional(), 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() emails: z
.string()
.email()
.array()
.max(100)
.optional()
.transform((val) => (val ? [...new Set(val)] : undefined))
}), }),
response: { response: {
200: z.object({ 200: z.object({
@@ -115,8 +115,6 @@ export const secretSharingServiceFactory = ({
const encryptWithRoot = kmsService.encryptWithRootKey(); const encryptWithRoot = kmsService.encryptWithRootKey();
let salt: string | undefined;
let encryptedSalt: Buffer | undefined;
const orgEmails = []; const orgEmails = [];
if (emails && emails.length > 0) { if (emails && emails.length > 0) {
@@ -133,10 +131,6 @@ export const secretSharingServiceFactory = ({
}); });
} }
} }
// 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 encryptedSecret = encryptWithRoot(Buffer.from(secretValue));
@@ -158,14 +152,13 @@ export const secretSharingServiceFactory = ({
userId: actorId, userId: actorId,
orgId, orgId,
accessType, accessType,
authorizedEmails: emails && emails.length > 0 ? JSON.stringify(emails) : undefined, authorizedEmails: emails && emails.length > 0 ? JSON.stringify(emails) : undefined
encryptedSalt
}); });
const idToReturn = `${Buffer.from(newSharedSecret.identifier!, "hex").toString("base64url")}`; const idToReturn = `${Buffer.from(newSharedSecret.identifier!, "hex").toString("base64url")}`;
// Loop through recipients and send out emails with unique access links // Loop through recipients and send out emails with unique access links
if (emails && salt) { if (emails) {
const user = await userDAL.findById(actorId); const user = await userDAL.findById(actorId);
if (!user) { if (!user) {
@@ -174,9 +167,6 @@ export const secretSharingServiceFactory = ({
for await (const email of emails) { for await (const email of emails) {
try { 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 // Only show the username to emails which are part of the organization
const respondentUsername = orgEmails.includes(email) ? user.username : undefined; const respondentUsername = orgEmails.includes(email) ? user.username : undefined;
@@ -186,7 +176,7 @@ export const secretSharingServiceFactory = ({
substitutions: { substitutions: {
name, name,
respondentUsername, respondentUsername,
secretRequestUrl: `${appCfg.SITE_URL}/shared/secret/${idToReturn}?email=${encodeURIComponent(email)}&hash=${hash}` secretRequestUrl: `${appCfg.SITE_URL}/shared/secret/${idToReturn}`
}, },
template: SmtpTemplates.SecretRequestCompleted template: SmtpTemplates.SecretRequestCompleted
}); });
@@ -474,9 +464,8 @@ export const secretSharingServiceFactory = ({
sharedSecretId, sharedSecretId,
hashedHex, hashedHex,
orgId, orgId,
password, actorId,
email, password
hash
}: TGetActiveSharedSecretByIdDTO) => { }: TGetActiveSharedSecretByIdDTO) => {
const sharedSecret = isUuidV4(sharedSecretId) const sharedSecret = isUuidV4(sharedSecretId)
? await secretSharingDAL.findOne({ ? await secretSharingDAL.findOne({
@@ -506,6 +495,17 @@ export const secretSharingServiceFactory = ({
throw new ForbiddenRequestError(); 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 // 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. // or can be safely sent to the client.
if (expiresAt !== null && expiresAt < new Date()) { if (expiresAt !== null && expiresAt < new Date()) {
@@ -524,31 +524,6 @@ 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 // Password checks
const isPasswordProtected = Boolean(sharedSecret.password); const isPasswordProtected = Boolean(sharedSecret.password);
const hasProvidedPassword = Boolean(password); const hasProvidedPassword = Boolean(password);
@@ -561,6 +536,8 @@ export const secretSharingServiceFactory = ({
} }
} }
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. // 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; let decryptedSecretValue: Buffer | undefined;
if (sharedSecret.encryptedSecret) { if (sharedSecret.encryptedSecret) {
@@ -37,11 +37,8 @@ export type TGetActiveSharedSecretByIdDTO = {
sharedSecretId: string; sharedSecretId: string;
hashedHex?: string; hashedHex?: string;
orgId?: string; orgId?: string;
actorId?: string;
password?: string; password?: string;
// For secrets shared with specific emails
email?: string;
hash?: string;
}; };
export type TValidateActiveSharedSecretDTO = TGetActiveSharedSecretByIdDTO & { export type TValidateActiveSharedSecretDTO = TGetActiveSharedSecretByIdDTO & {
+37 -26
View File
@@ -5,42 +5,53 @@ description: "Learn how to share time & view-count bound secrets securely with a
--- ---
Developers frequently need to share secrets with team members, contractors, or other third parties, which can be risky due to potential leaks or misuse. Developers frequently need to share secrets with team members, contractors, or other third parties, which can be risky due to potential leaks or misuse.
Infisical offers a secure solution for sharing secrets over the internet in a time and view count bound manner. It is possible to share secrets without signing up via [share.infisical.com](https://share.infisical.com) or via Infisical Dashboard (which has more advanced funcitonality). Infisical offers a secure solution for sharing secrets over the internet in a time and view-count bound manner. It is possible to share secrets without signing up via [share.infisical.com](https://share.infisical.com) or via Infisical Dashboard (which has more advanced functionality).
With its zero-knowledge architecture, secrets shared via Infisical remain unreadable even to Infisical itself. ## Sharing a Secret
## Share a Secret <Steps>
<Step title="Navigate to the 'Secret Sharing' page and click 'Share Secret'">
![Secret Sharing](../../images/platform/secret-sharing/overview.png)
</Step>
<Step title="Configure Secret Share">
![Configure Secret](../../images/platform/secret-sharing/create-new-secret.png)
1. Navigate to the **Organization** page. - **Name (optional):** A friendly name for the shared secret.
2. Click on the **Secret Sharing** tab from the sidebar. - **Your Secret:** The secret content.
- **Password (optional):** A password which will be required when viewing the secret.
![Secret Sharing](../../images/platform/secret-sharing/overview.png) - **Limit access to people within organization:** Only lets people within your organization view the secret. Enabling this feature requires secret viewers to log into Infisical.
- **Expires In:** The time it'll take for the secret to expire.
- **Max Views:** How many times the secret can be viewed before it's destroyed.
<Note> - **Authorized Emails (optional):** Emails which are authorized to view this secret. Enabling this feature requires secret viewers to log into Infisical. Each email will receive the shared secret link in their inbox after creation.
Infisical does not have access to the shared secrets. This is a part of our </Step>
zero knowledge architecture. <Step title="Copy Link and Share Secret">
</Note> After creating the shared secret, its link will be displayed. Share this with the intended recipients.
3. Click on the **Share Secret** button. Set the secret, its expiration time and specify if the secret can be viewed only once. It expires as soon as any of the conditions are met. <Info>
Also, specify if the secret can be accessed by anyone or only people within your organization. If no organization or email restrictions are set, anyone with this link can view the secret before it expires.
</Info>
![Add View-Bound Sharing Secret](../../images/platform/secret-sharing/create-new-secret.png) ![Copy URL](../../images/platform/secret-sharing/copy-url.png)
</Step>
<Step title="Access Shared Secret">
Visiting the secret link will display its contents.
<Note> ![Access Shared Secret](../../images/platform/secret-sharing/public-view.png)
Secret once set cannot be changed. This is to ensure that the secret is not </Step>
tampered with. </Steps>
</Note>
5. Copy the link and share it with the intended recipient. Anyone with the link can access the secret before its expiration condition. Hence, it is recommended to share the link only with the intended recipient. ## Deleting a Shared Secret
![Copy URL](../../images/platform/secret-sharing/copy-url.png) To delete a shared secret, click the **Trash Can** icon on the relevant shared secret row in the [**Secret Sharing**](https://app.infisical.com/organization/secret-sharing?selectedTab=share-secret) page.
## Access a Shared Secret ![Delete Secret](../../images/platform/secret-sharing/delete-secret.png)
Just click on the link you received to access the secret. The secret will be displayed on the screen & for how long it is valid. ## FAQ
![Access Shared Secret](../../images/platform/secret-sharing/public-view.png) <AccordionGroup>
<Accordion title="Can secrets be changed after they are shared?">
## Delete a Shared Secret No, secrets cannot be changed after they've been created. This is to ensure that secrets are not tampered with.
</Accordion>
In the **Secret Sharing** tab, click on the **Delete** button next to the secret you want to delete. This will delete the secret immediately & the link will no longer be accessible. </AccordionGroup>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 580 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 621 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1010 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 542 KiB

After

Width:  |  Height:  |  Size: 1019 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

@@ -11,13 +11,10 @@ export const secretSharingKeys = {
allSecretRequests: () => ["secretRequests"] as const, allSecretRequests: () => ["secretRequests"] as const,
specificSecretRequests: ({ offset, limit }: { offset: number; limit: number }) => specificSecretRequests: ({ offset, limit }: { offset: number; limit: number }) =>
[...secretSharingKeys.allSecretRequests(), { offset, limit }] as const, [...secretSharingKeys.allSecretRequests(), { offset, limit }] as const,
getSecretById: (arg: { getSecretById: (arg: { id: string; hashedHex: string | null; password?: string }) => [
id: string; "shared-secret",
hashedHex: string | null; arg
password?: string; ],
email?: string;
hash?: string;
}) => ["shared-secret", arg],
getSecretRequestById: (arg: { id: string }) => ["secret-request", arg] as const getSecretRequestById: (arg: { id: string }) => ["secret-request", arg] as const
}; };
@@ -73,34 +70,24 @@ export const useGetSecretRequests = ({
export const useGetActiveSharedSecretById = ({ export const useGetActiveSharedSecretById = ({
sharedSecretId, sharedSecretId,
hashedHex, hashedHex,
password, password
email,
hash
}: { }: {
sharedSecretId: string; sharedSecretId: string;
hashedHex: string | null; hashedHex: string | null;
password?: string; password?: string;
// For secrets shared to specific emails (optional)
email?: string;
hash?: string;
}) => { }) => {
return useQuery({ return useQuery({
queryKey: secretSharingKeys.getSecretById({ queryKey: secretSharingKeys.getSecretById({
id: sharedSecretId, id: sharedSecretId,
hashedHex, hashedHex,
password, password
email,
hash
}), }),
queryFn: async () => { queryFn: async () => {
const { data } = await apiRequest.post<TViewSharedSecretResponse>( const { data } = await apiRequest.post<TViewSharedSecretResponse>(
`/api/v1/secret-sharing/shared/public/${sharedSecretId}`, `/api/v1/secret-sharing/shared/public/${sharedSecretId}`,
{ {
...(hashedHex && { hashedHex }), ...(hashedHex && { hashedHex }),
password, password
email,
hash
} }
); );
@@ -38,14 +38,6 @@ export const ViewSharedSecretByIDPage = () => {
from: ROUTE_PATHS.Public.ViewSharedSecretByIDPage.id, from: ROUTE_PATHS.Public.ViewSharedSecretByIDPage.id,
select: (el) => el.key select: (el) => el.key
}); });
const email = useSearch({
from: ROUTE_PATHS.Public.ViewSharedSecretByIDPage.id,
select: (el) => el.email
});
const hash = useSearch({
from: ROUTE_PATHS.Public.ViewSharedSecretByIDPage.id,
select: (el) => el.hash
});
const [password, setPassword] = useState<string>(); const [password, setPassword] = useState<string>();
const { hashedHex, key } = extractDetailsFromUrl(urlEncodedKey); const { hashedHex, key } = extractDetailsFromUrl(urlEncodedKey);
@@ -57,9 +49,7 @@ export const ViewSharedSecretByIDPage = () => {
} = useGetActiveSharedSecretById({ } = useGetActiveSharedSecretById({
sharedSecretId: id, sharedSecretId: id,
hashedHex, hashedHex,
password, password
email,
hash
}); });
const navigate = useNavigate(); const navigate = useNavigate();
@@ -94,6 +84,8 @@ export const ViewSharedSecretByIDPage = () => {
navigate({ navigate({
to: "/login" to: "/login"
}); });
return;
} }
if (error) { if (error) {
@@ -7,9 +7,7 @@ import { authKeys, fetchAuthToken } from "@app/hooks/api/auth/queries";
import { ViewSharedSecretByIDPage } from "./ViewSharedSecretByIDPage"; import { ViewSharedSecretByIDPage } from "./ViewSharedSecretByIDPage";
const SharedSecretByIDPageQuerySchema = z.object({ const SharedSecretByIDPageQuerySchema = z.object({
key: z.string().catch(""), key: z.string().catch("")
email: z.string().optional(),
hash: z.string().optional()
}); });
export const Route = createFileRoute("/shared/secret/$secretId")({ export const Route = createFileRoute("/shared/secret/$secretId")({