Merge pull request #3670 from Infisical/ENG-2827
feat(secret-sharing): Require Login for Secrets Shared to Specific Emails
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 & {
|
||||||
|
|||||||
@@ -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'">
|
||||||
|

|
||||||
|
</Step>
|
||||||
|
<Step title="Configure Secret Share">
|
||||||
|

|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||

|
- **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>
|
||||||
|
|
||||||

|

|
||||||
|
</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
|
</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
|
||||||
|
|
||||||

|
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
|

|
||||||
|
|
||||||
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
|
||||||
|
|
||||||

|
<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>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 580 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 621 KiB |
|
After Width: | Height: | Size: 1010 KiB |
|
Before Width: | Height: | Size: 542 KiB After Width: | Height: | Size: 1019 KiB |
|
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")({
|
||||||
|
|||||||