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

View File

@@ -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();
});
}
}
}

View File

@@ -28,7 +28,6 @@ export const SecretSharingSchema = z.object({
encryptedSecret: zodBuffer.nullable().optional(),
identifier: z.string().nullable().optional(),
type: z.string().default("share"),
encryptedSalt: zodBuffer.nullable().optional(),
authorizedEmails: z.unknown().nullable().optional()
});

View File

@@ -62,9 +62,7 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
}),
body: z.object({
hashedHex: z.string().min(1).optional(),
password: z.string().optional(),
email: z.string().optional(),
hash: z.string().optional()
password: z.string().optional()
}),
response: {
200: z.object({
@@ -91,8 +89,7 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
hashedHex: req.body.hashedHex,
password: req.body.password,
orgId: req.permission?.orgId,
email: req.body.email,
hash: req.body.hash
actorId: req.permission?.id
});
if (sharedSecret.secret?.orgId) {
@@ -156,7 +153,13 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
expiresAt: z.string(),
expiresAfterViews: z.number().min(1).optional(),
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: {
200: z.object({

View File

@@ -115,8 +115,6 @@ export const secretSharingServiceFactory = ({
const encryptWithRoot = kmsService.encryptWithRootKey();
let salt: string | undefined;
let encryptedSalt: Buffer | undefined;
const orgEmails = [];
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));
@@ -158,14 +152,13 @@ export const secretSharingServiceFactory = ({
userId: actorId,
orgId,
accessType,
authorizedEmails: emails && emails.length > 0 ? JSON.stringify(emails) : undefined,
encryptedSalt
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 && salt) {
if (emails) {
const user = await userDAL.findById(actorId);
if (!user) {
@@ -174,9 +167,6 @@ export const secretSharingServiceFactory = ({
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;
@@ -186,7 +176,7 @@ export const secretSharingServiceFactory = ({
substitutions: {
name,
respondentUsername,
secretRequestUrl: `${appCfg.SITE_URL}/shared/secret/${idToReturn}?email=${encodeURIComponent(email)}&hash=${hash}`
secretRequestUrl: `${appCfg.SITE_URL}/shared/secret/${idToReturn}`
},
template: SmtpTemplates.SecretRequestCompleted
});
@@ -474,9 +464,8 @@ export const secretSharingServiceFactory = ({
sharedSecretId,
hashedHex,
orgId,
password,
email,
hash
actorId,
password
}: TGetActiveSharedSecretByIdDTO) => {
const sharedSecret = isUuidV4(sharedSecretId)
? await secretSharingDAL.findOne({
@@ -506,6 +495,17 @@ export const secretSharingServiceFactory = ({
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()) {
@@ -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
const isPasswordProtected = Boolean(sharedSecret.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.
let decryptedSecretValue: Buffer | undefined;
if (sharedSecret.encryptedSecret) {

View File

@@ -37,11 +37,8 @@ export type TGetActiveSharedSecretByIdDTO = {
sharedSecretId: string;
hashedHex?: string;
orgId?: string;
actorId?: string;
password?: string;
// For secrets shared with specific emails
email?: string;
hash?: string;
};
export type TValidateActiveSharedSecretDTO = TGetActiveSharedSecretByIdDTO & {

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.
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.
2. Click on the **Secret Sharing** tab from the sidebar.
- **Name (optional):** A friendly name for the shared secret.
- **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>
Infisical does not have access to the shared secrets. This is a part of our
zero knowledge architecture.
</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.
</Step>
<Step title="Copy Link and Share Secret">
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.
Also, specify if the secret can be accessed by anyone or only people within your organization.
<Info>
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>
Secret once set cannot be changed. This is to ensure that the secret is not
tampered with.
</Note>
![Access Shared Secret](../../images/platform/secret-sharing/public-view.png)
</Step>
</Steps>
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)
## Delete a Shared Secret
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>
<Accordion title="Can secrets be changed after they are shared?">
No, secrets cannot be changed after they've been created. This is to ensure that secrets are not tampered with.
</Accordion>
</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

View File

@@ -11,13 +11,10 @@ export const secretSharingKeys = {
allSecretRequests: () => ["secretRequests"] as const,
specificSecretRequests: ({ offset, limit }: { offset: number; limit: number }) =>
[...secretSharingKeys.allSecretRequests(), { offset, limit }] as const,
getSecretById: (arg: {
id: string;
hashedHex: string | null;
password?: string;
email?: string;
hash?: string;
}) => ["shared-secret", arg],
getSecretById: (arg: { id: string; hashedHex: string | null; password?: string }) => [
"shared-secret",
arg
],
getSecretRequestById: (arg: { id: string }) => ["secret-request", arg] as const
};
@@ -73,34 +70,24 @@ export const useGetSecretRequests = ({
export const useGetActiveSharedSecretById = ({
sharedSecretId,
hashedHex,
password,
email,
hash
password
}: {
sharedSecretId: string;
hashedHex: string | null;
password?: string;
// For secrets shared to specific emails (optional)
email?: string;
hash?: string;
}) => {
return useQuery({
queryKey: secretSharingKeys.getSecretById({
id: sharedSecretId,
hashedHex,
password,
email,
hash
password
}),
queryFn: async () => {
const { data } = await apiRequest.post<TViewSharedSecretResponse>(
`/api/v1/secret-sharing/shared/public/${sharedSecretId}`,
{
...(hashedHex && { hashedHex }),
password,
email,
hash
password
}
);

View File

@@ -38,14 +38,6 @@ export const ViewSharedSecretByIDPage = () => {
from: ROUTE_PATHS.Public.ViewSharedSecretByIDPage.id,
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 { hashedHex, key } = extractDetailsFromUrl(urlEncodedKey);
@@ -57,9 +49,7 @@ export const ViewSharedSecretByIDPage = () => {
} = useGetActiveSharedSecretById({
sharedSecretId: id,
hashedHex,
password,
email,
hash
password
});
const navigate = useNavigate();
@@ -94,6 +84,8 @@ export const ViewSharedSecretByIDPage = () => {
navigate({
to: "/login"
});
return;
}
if (error) {

View File

@@ -7,9 +7,7 @@ import { authKeys, fetchAuthToken } from "@app/hooks/api/auth/queries";
import { ViewSharedSecretByIDPage } from "./ViewSharedSecretByIDPage";
const SharedSecretByIDPageQuerySchema = z.object({
key: z.string().catch(""),
email: z.string().optional(),
hash: z.string().optional()
key: z.string().catch("")
});
export const Route = createFileRoute("/shared/secret/$secretId")({