feat(secret-sharing): Require Login for Email Sharing

This commit is contained in:
x032205
2025-05-28 14:44:27 -04:00
parent e739b29b3c
commit 456493ff5a
8 changed files with 59 additions and 85 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(), 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()
}); });

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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 & {

View File

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

View File

@@ -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) {

View File

@@ -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")({