feat: secret sharing supports expiry on view count + multi-line secret value

This commit is contained in:
ShubhamPalriwala
2024-05-31 00:00:48 +05:30
parent c097c918ed
commit 3438dbc70d
10 changed files with 251 additions and 130 deletions

View File

@@ -0,0 +1,35 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
const hasExpiresAfterViewsColumn = await knex.schema.hasColumn(TableName.SecretSharing, "expiresAfterViews");
const hasSecretNameColumn = await knex.schema.hasColumn(TableName.SecretSharing, "name");
await knex.schema.alterTable(TableName.SecretSharing, (t) => {
if (!hasExpiresAfterViewsColumn) {
t.integer("expiresAfterViews").nullable();
t.timestamp("expiresAt").nullable().alter();
}
if (hasSecretNameColumn) {
t.dropColumn("name");
}
});
}
export async function down(knex: Knex): Promise<void> {
const hasExpiresAfterViewsColumn = await knex.schema.hasColumn(TableName.SecretSharing, "expiresAfterViews");
const hasSecretNameColumn = await knex.schema.hasColumn(TableName.SecretSharing, "name");
await knex.schema.alterTable(TableName.SecretSharing, (t) => {
if (hasExpiresAfterViewsColumn) {
t.dropColumn("expiresAfterViews");
t.timestamp("expiresAt").notNullable().alter();
}
if (!hasSecretNameColumn) {
t.string("name").notNullable();
}
});
}

View File

@@ -9,16 +9,16 @@ import { TImmutableDBKeys } from "./models";
export const SecretSharingSchema = z.object({
id: z.string().uuid(),
name: z.string(),
encryptedValue: z.string(),
iv: z.string(),
tag: z.string(),
hashedHex: z.string(),
expiresAt: z.date(),
expiresAt: z.date().nullable().optional(),
userId: z.string().uuid(),
orgId: z.string().uuid(),
createdAt: z.date(),
updatedAt: z.date()
updatedAt: z.date(),
expiresAfterViews: z.number().nullable().optional()
});
export type TSecretSharing = z.infer<typeof SecretSharingSchema>;

View File

@@ -23,8 +23,8 @@ export const UsersSchema = z.object({
isGhost: z.boolean().default(false),
username: z.string(),
isEmailVerified: z.boolean().default(false).nullable().optional(),
consecutiveFailedMfaAttempts: z.number().optional(),
isLocked: z.boolean().optional(),
consecutiveFailedMfaAttempts: z.number().default(0).nullable().optional(),
isLocked: z.boolean().default(false).nullable().optional(),
temporaryLockDateEnd: z.date().nullable().optional()
});

View File

@@ -45,7 +45,13 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
hashedHex: z.string()
}),
response: {
200: SecretSharingSchema.pick({ name: true, encryptedValue: true, iv: true, tag: true, expiresAt: true })
200: SecretSharingSchema.pick({
encryptedValue: true,
iv: true,
tag: true,
expiresAt: true,
expiresAfterViews: true
})
}
},
handler: async (req) => {
@@ -55,11 +61,11 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
);
if (!sharedSecret) return undefined;
return {
name: sharedSecret.name,
encryptedValue: sharedSecret.encryptedValue,
iv: sharedSecret.iv,
tag: sharedSecret.tag,
expiresAt: sharedSecret.expiresAt
expiresAt: sharedSecret.expiresAt,
expiresAfterViews: sharedSecret.expiresAfterViews
};
}
});
@@ -72,14 +78,15 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
},
schema: {
body: z.object({
name: z.string(),
encryptedValue: z.string(),
iv: z.string(),
tag: z.string(),
hashedHex: z.string(),
expiresAt: z.string().refine((date) => new Date(date) > new Date(), {
message: "Expires at should be a future date"
})
expiresAt: z
.string()
.optional()
.refine((date) => date === undefined || new Date(date) > new Date(), "Expires at should be a future date"),
expiresAfterViews: z.number().optional()
}),
response: {
200: z.object({
@@ -89,19 +96,19 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const { name, encryptedValue, iv, tag, hashedHex, expiresAt } = req.body;
const { encryptedValue, iv, tag, hashedHex, expiresAt, expiresAfterViews } = req.body;
const sharedSecret = await req.server.services.secretSharing.createSharedSecret({
actor: req.permission.type,
actorId: req.permission.id,
orgId: req.permission.orgId,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
name,
encryptedValue,
iv,
tag,
hashedHex,
expiresAt: new Date(expiresAt)
expiresAt: expiresAt ? new Date(expiresAt) : undefined,
expiresAfterViews
});
return { id: sharedSecret.id };
}

View File

@@ -16,17 +16,28 @@ export const secretSharingServiceFactory = ({
secretSharingDAL
}: TSecretSharingServiceFactoryDep) => {
const createSharedSecret = async (createSharedSecretInput: TCreateSharedSecretDTO) => {
const { actor, actorId, orgId, actorAuthMethod, actorOrgId, name, encryptedValue, iv, tag, hashedHex, expiresAt } =
createSharedSecretInput;
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
if (!permission) throw new UnauthorizedError({ name: "User not in org" });
const newSharedSecret = await secretSharingDAL.create({
name,
const {
actor,
actorId,
orgId,
actorAuthMethod,
actorOrgId,
encryptedValue,
iv,
tag,
hashedHex,
expiresAt,
expiresAfterViews
} = createSharedSecretInput;
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
if (!permission) throw new UnauthorizedError({ name: "User not in org" });
const newSharedSecret = await secretSharingDAL.create({
encryptedValue,
iv,
tag,
hashedHex,
expiresAt,
expiresAfterViews,
userId: actorId,
orgId
});
@@ -43,9 +54,18 @@ export const secretSharingServiceFactory = ({
const getActiveSharedSecretByIdAndHashedHex = async (sharedSecretId: string, hashedHex: string) => {
const sharedSecret = await secretSharingDAL.findOne({ id: sharedSecretId, hashedHex });
if (sharedSecret && sharedSecret.expiresAt < new Date()) {
if (sharedSecret.expiresAt && sharedSecret.expiresAt < new Date()) {
return;
}
if (sharedSecret.expiresAfterViews != null && sharedSecret.expiresAfterViews >= 0) {
if (sharedSecret.expiresAfterViews === 0) {
await secretSharingDAL.deleteById(sharedSecretId);
return;
}
await secretSharingDAL.updateById(sharedSecretId, {
expiresAfterViews: sharedSecret.expiresAfterViews - 1
});
}
return sharedSecret;
};

View File

@@ -9,12 +9,12 @@ export type TSharedSecretPermission = {
};
export type TCreateSharedSecretDTO = {
name: string;
encryptedValue: string;
iv: string;
tag: string;
hashedHex: string;
expiresAt: Date;
expiresAt?: Date;
expiresAfterViews?: number;
} & TSharedSecretPermission;
export type TDeleteSharedSecretDTO = {

View File

@@ -1,31 +1,26 @@
export type TSharedSecret = {
id: string;
name: string;
encryptedValue: string;
iv: string;
tag: string;
hashedHex: string;
userId: string;
expiresAt: Date;
orgId: string;
createdAt: Date;
updatedAt: Date;
};
} & TCreateSharedSecretRequest;
export type TCreateSharedSecretRequest = {
name: string;
encryptedValue: string;
iv: string;
tag: string;
hashedHex: string;
expiresAt: Date;
expiresAt?: Date;
expiresAfterViews?: number;
};
export type TViewSharedSecretResponse = {
name: string;
encryptedValue: string;
iv: string;
tag: string;
expiresAt: Date;
expiresAt?: Date;
expiresAfterViews?: number;
};
export type TDeleteSharedSecretRequest = {

View File

@@ -9,9 +9,7 @@ import { AxiosError } from "axios";
import * as yup from "yup";
import { createNotification } from "@app/components/notifications";
import {
encryptSymmetric,
} from "@app/components/utilities/cryptography/crypto";
import { encryptSymmetric } from "@app/components/utilities/cryptography/crypto";
import {
Button,
FormControl,
@@ -22,7 +20,8 @@ import {
ModalContent,
SecretInput,
Select,
SelectItem
SelectItem,
Switch
} from "@app/components/v2";
import { useOrganization } from "@app/context";
import { useTimedReset } from "@app/hooks";
@@ -63,10 +62,10 @@ const expirationUnitsAndActions = [
];
const schema = yup.object({
name: yup.string().max(100).required().label("Shared Secret Name"),
value: yup.string().max(1000).required().label("Shared Secret Value"),
expiresInValue: yup.number().min(1).required().label("Expiration Value"),
expiresInUnit: yup.string().required().label("Expiration Unit")
expiresAfterViews: yup.number().min(1).optional().label("Expires After Views"),
expiresInValue: yup.number().min(1).optional().label("Expiration Value"),
expiresInUnit: yup.string().optional().label("Expiration Unit")
});
export type FormData = yup.InferType<typeof schema>;
@@ -91,9 +90,10 @@ export const AddShareSecretModal = ({ popUp, handlePopUpToggle }: Props) => {
const createSharedSecret = useCreateSharedSecret();
const { currentOrg } = useOrganization();
const [newSharedSecret, setnewSharedSecret] = useState("");
const [expiryOption, setExpiryOption] = useState<"time" | "views">("time");
const hasSharedSecret = Boolean(newSharedSecret);
const [isUrlCopied, , setIsUrlCopied] = useTimedReset<boolean>({
initialState: false,
initialState: false
});
const copyUrlToClipboard = () => {
@@ -106,10 +106,14 @@ export const AddShareSecretModal = ({ popUp, handlePopUpToggle }: Props) => {
}
}, [isUrlCopied]);
const onFormSubmit = async ({ name, value, expiresInValue, expiresInUnit }: FormData) => {
const onFormSubmit = async ({
value,
expiresInValue,
expiresInUnit,
expiresAfterViews
}: FormData) => {
try {
if (!currentOrg?.id) return;
const key = crypto.randomBytes(16).toString("hex");
const hashedHex = crypto.createHash("sha256").update(key).digest("hex");
const { ciphertext, iv, tag } = encryptSymmetric({
@@ -117,25 +121,26 @@ export const AddShareSecretModal = ({ popUp, handlePopUpToggle }: Props) => {
key
});
const expiresAt = new Date();
const updateExpiresAt = expirationUnitsAndActions.find(
(item) => item.unit === expiresInUnit
)?.action;
if (updateExpiresAt) {
if (updateExpiresAt && expiresInValue) {
updateExpiresAt(expiresAt, expiresInValue);
}
const { id } = await createSharedSecret.mutateAsync({
name,
encryptedValue: ciphertext,
iv,
tag,
hashedHex,
expiresAt,
expiresAt: expiryOption === "time" ? expiresAt : undefined,
expiresAfterViews: expiryOption === "views" ? expiresAfterViews : undefined
});
setnewSharedSecret(
`${window.location.origin}/shared/secret/${id}?key=${encodeURIComponent(hashedHex)}-${encodeURIComponent(key)}`
`${window.location.origin}/shared/secret/${id}?key=${encodeURIComponent(
hashedHex
)}-${encodeURIComponent(key)}`
);
createNotification({
@@ -168,90 +173,114 @@ export const AddShareSecretModal = ({ popUp, handlePopUpToggle }: Props) => {
setnewSharedSecret("");
}}
>
<ModalContent
title="Share a Secret"
subTitle="This link is only accessible once. Please share this link with intended recipients. "
>
<ModalContent
title="Share a Secret"
subTitle="This link is only accessible once. Please share this link with intended recipients. "
>
{!hasSharedSecret ? (
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="name"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="Shared Secret Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="Type your secret identifier" />
</FormControl>
)}
/>
<Controller
control={control}
name="value"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="Shared Secret Value"
label="Shared Secret"
isError={Boolean(error)}
errorText={error?.message}
>
<SecretInput
isVisible
{...field}
containerClassName="py-1.5 rounded-md transition-all group-hover:mr-2 text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2"
containerClassName="py-1.5 rounded-md transition-all group-hover:mr-2 text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 min-h-[100px]"
/>
</FormControl>
)}
/>
<div className="flex w-full flex-row justify-end">
<div className="w-3/5">
<div>
<p className="mb-2 flex items-center text-sm font-normal text-mineshaft-400">
Set Expiry Based On
</p>
<div className="mb-4 flex w-full flex-row justify-start">
<p className="mb-0.5 mr-1 flex items-center text-sm font-normal text-mineshaft-400">
Time
</p>
<Switch
id="expiryOption"
onCheckedChange={(value) => setExpiryOption(value ? "views" : "time")}
isChecked={expiryOption === "views"}
/>
<p className="mb-0.5 ml-3 flex items-center text-sm font-normal text-mineshaft-400">
Views
</p>
</div>
</div>
<div>
{expiryOption === "views" ? (
<Controller
control={control}
name="expiresInValue"
name="expiresAfterViews"
defaultValue={1}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Expiration Value"
className="mb-4 w-full"
label="Expires After Views"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} type="number" min={0} />
<Input {...field} type="number" min={1} />
</FormControl>
)}
/>
</div>
<div className="w-2/5 pl-4">
<Controller
control={control}
name="expiresInUnit"
defaultValue={expirationUnitsAndActions[0].unit}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Expiration Unit"
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{expirationUnitsAndActions.map(({ unit }) => (
<SelectItem value={unit} key={unit}>
{unit}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</div>
) : (
<div className="flex w-full flex-row justify-end">
<div className="w-3/5">
<Controller
control={control}
name="expiresInValue"
defaultValue={1}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Expiration Value"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} type="number" min={0} />
</FormControl>
)}
/>
</div>
<div className="w-2/5 pl-4">
<Controller
control={control}
name="expiresInUnit"
defaultValue={expirationUnitsAndActions[0].unit}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Expiration Unit"
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{expirationUnitsAndActions.map(({ unit }) => (
<SelectItem value={unit} key={unit}>
{unit}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</div>
</div>
)}
</div>
<div className="mt-8 flex items-center">
<div className="flex items-center">
<Button
className="mr-4"
type="submit"

View File

@@ -8,7 +8,15 @@ import { UsePopUpState } from "@app/hooks/usePopUp";
const formatDate = (date: Date): string => (date ? new Date(date).toUTCString() : "");
const isExpired = (expiresAt: Date): boolean => new Date(expiresAt) < new Date();
const isExpired = (expiresAt: Date | number | undefined): boolean => {
if (typeof expiresAt === "number") {
return expiresAt <= 0;
}
if (expiresAt instanceof Date) {
return new Date(expiresAt) < new Date();
}
return false;
};
const getValidityStatusText = (expiresAt: Date): string =>
isExpired(expiresAt) ? "Expired " : "Valid for ";
@@ -26,31 +34,38 @@ const timeAgo = (inputDate: Date, currentDate: Date): string => {
const elapsedYears = Math.abs(Math.floor(elapsedDays / 365));
if (elapsedYears > 0) {
return `${elapsedYears} year${elapsedYears === 1 ? "" : "s"} ${elapsedMilliseconds >= 0 ? "ago" : "from now"
}`;
return `${elapsedYears} year${elapsedYears === 1 ? "" : "s"} ${
elapsedMilliseconds >= 0 ? "ago" : "from now"
}`;
}
if (elapsedMonths > 0) {
return `${elapsedMonths} month${elapsedMonths === 1 ? "" : "s"} ${elapsedMilliseconds >= 0 ? "ago" : "from now"
}`;
return `${elapsedMonths} month${elapsedMonths === 1 ? "" : "s"} ${
elapsedMilliseconds >= 0 ? "ago" : "from now"
}`;
}
if (elapsedWeeks > 0) {
return `${elapsedWeeks} week${elapsedWeeks === 1 ? "" : "s"} ${elapsedMilliseconds >= 0 ? "ago" : "from now"
}`;
return `${elapsedWeeks} week${elapsedWeeks === 1 ? "" : "s"} ${
elapsedMilliseconds >= 0 ? "ago" : "from now"
}`;
}
if (elapsedDays > 0) {
return `${elapsedDays} day${elapsedDays === 1 ? "" : "s"} ${elapsedMilliseconds >= 0 ? "ago" : "from now"
}`;
return `${elapsedDays} day${elapsedDays === 1 ? "" : "s"} ${
elapsedMilliseconds >= 0 ? "ago" : "from now"
}`;
}
if (elapsedHours > 0) {
return `${elapsedHours} hour${elapsedHours === 1 ? "" : "s"} ${elapsedMilliseconds >= 0 ? "ago" : "from now"
}`;
return `${elapsedHours} hour${elapsedHours === 1 ? "" : "s"} ${
elapsedMilliseconds >= 0 ? "ago" : "from now"
}`;
}
if (elapsedMinutes > 0) {
return `${elapsedMinutes} minute${elapsedMinutes === 1 ? "" : "s"} ${elapsedMilliseconds >= 0 ? "ago" : "from now"
}`;
}
return `${elapsedSeconds} second${elapsedSeconds === 1 ? "" : "s"} ${elapsedMilliseconds >= 0 ? "ago" : "from now"
return `${elapsedMinutes} minute${elapsedMinutes === 1 ? "" : "s"} ${
elapsedMilliseconds >= 0 ? "ago" : "from now"
}`;
}
return `${elapsedSeconds} second${elapsedSeconds === 1 ? "" : "s"} ${
elapsedMilliseconds >= 0 ? "ago" : "from now"
}`;
};
export const ShareSecretsRow = ({
@@ -82,29 +97,41 @@ export const ShareSecretsRow = ({
}, []);
useEffect(() => {
if (isExpired(row.expiresAt)) {
if (isExpired(row.expiresAt || row.expiresAfterViews)) {
onSecretExpiration(row.id);
}
}, [isExpired(row.expiresAt)]);
}, [isExpired(row.expiresAt || row.expiresAfterViews)]);
return (
<Tr key={row.id}>
<Td>{row.name}</Td>
<Td>{`${row.encryptedValue.substring(0, 5)}...`}</Td>
<Td>
<p className="text-sm text-yellow-400">{timeAgo(row.createdAt, currentTime)}</p>
<p className="text-xs text-gray-500">{formatDate(row.createdAt)}</p>
</Td>
<Td>
<p className={`text-sm ${isExpired(row.expiresAt) ? "text-red-500" : "text-green-500"}`}>
{getValidityStatusText(row.expiresAt) + timeAgo(row.expiresAt, currentTime)}
</p>
<p className="text-xs text-gray-500">{formatDate(row.expiresAt)}</p>
{row.expiresAfterViews ? (
<p
className={`text-sm ${row.expiresAfterViews <= 0 ? "text-red-500" : "text-green-500"}`}
>
Valid for {row.expiresAfterViews} more views
</p>
) : (
<>
<p
className={`text-sm ${isExpired(row.expiresAt) ? "text-red-500" : "text-green-500"}`}
>
{getValidityStatusText(row.expiresAt!) + timeAgo(row.expiresAt!, currentTime)}
</p>
<p className="text-xs text-gray-500">{formatDate(row.expiresAt!)}</p>
</>
)}
</Td>
<Td>
<IconButton
onClick={() =>
handlePopUpOpen("deleteSharedSecretConfirmation", {
name: row.name,
name: "delete",
id: row.id
})
}

View File

@@ -32,9 +32,17 @@ type Props = {
export const ShareSecretsTable = ({ handlePopUpOpen }: Props) => {
const { isLoading, data = [] } = useGetSharedSecrets();
let tableData = data.filter((secret) => !secret.expiresAt || new Date(secret.expiresAt) > new Date())
let tableData = data.filter(
(secret) =>
(secret.expiresAt && new Date(secret.expiresAt) > new Date()) ||
(secret.expiresAfterViews && secret.expiresAfterViews > 0)
);
const handleSecretExpiration = () => {
tableData = data.filter((secret) => !secret.expiresAt || new Date(secret.expiresAt) > new Date());
tableData = data.filter(
(secret) =>
(secret.expiresAt && new Date(secret.expiresAt) > new Date()) ||
(secret.expiresAfterViews && secret.expiresAfterViews > 0)
);
};
return (
@@ -42,7 +50,7 @@ export const ShareSecretsTable = ({ handlePopUpOpen }: Props) => {
<Table>
<THead>
<Tr>
<Th>Secret Name</Th> <Th>Created</Th> <Th>Valid Until</Th>
<Th>Encrypted Secret</Th> <Th>Created</Th> <Th>Valid Until</Th>
<Th aria-label="button" />
</Tr>
</THead>