feat(onboarding): added backup key generation for admin account

This commit is contained in:
Akhil Mohan
2023-11-12 23:30:41 +05:30
parent 29fa85e499
commit c3ca992777
5 changed files with 305 additions and 153 deletions
@@ -16,7 +16,6 @@ const yyyy = today.getFullYear();
const todayFormatted = `${mm}/${dd}/${yyyy}`; const todayFormatted = `${mm}/${dd}/${yyyy}`;
function createPdfHeader(doc: jsPDF, personalName: string) { function createPdfHeader(doc: jsPDF, personalName: string) {
doc.setFillColor(255, 255, 255); doc.setFillColor(255, 255, 255);
doc.rect(0, 0, 600, 900, "F"); doc.rect(0, 0, 600, 900, "F");
@@ -92,5 +91,15 @@ function generateBackupPDF({ personalName, personalEmail, generatedKey }: PDFPro
doc.save("Infisical Emergency Kit.pdf"); doc.save("Infisical Emergency Kit.pdf");
} }
export default generateBackupPDF; /**
* This function generate a pdf with a secret key for a user.
*/
export function generateBackupPDFAsync({ personalName, personalEmail, generatedKey }: PDFProps) {
// eslint-disable-next-line new-cap
const doc = new jsPDF("p", "pt", "a4", true);
createPdfHeader(doc, personalName);
createPdfContent(doc, personalEmail, generatedKey);
return doc.save("Infisical Emergency Kit.pdf", { returnPromise: true });
}
export default generateBackupPDF;
+105 -75
View File
@@ -6,91 +6,121 @@ import { encodeBase64 } from "tweetnacl-util";
import Aes256Gcm from "@app/components/utilities/cryptography/aes-256-gcm"; import Aes256Gcm from "@app/components/utilities/cryptography/aes-256-gcm";
import { deriveArgonKey } from "@app/components/utilities/cryptography/crypto"; import { deriveArgonKey } from "@app/components/utilities/cryptography/crypto";
import { issueBackupPrivateKey, srp1 } from "@app/hooks/api/auth/queries";
// eslint-disable-next-line new-cap export const generateUserBackupKey = async (email: string, password: string) => {
const client = new jsrp.client(); // eslint-disable-next-line new-cap
const clientKey = new jsrp.client();
// eslint-disable-next-line new-cap
const clientPassword = new jsrp.client();
type TUserPassKey = { await new Promise((resolve) => {
protectedKey: string; clientPassword.init({ username: email, password }, () => resolve(null));
protectedKeyTag: string; });
protectedKeyIV: string; const clientPublicKey = clientPassword.getPublicKey();
encryptedPrivateKey: string; const srpKeys = await srp1({ clientPublicKey });
encryptedPrivateKeyIV: string; clientPassword.setSalt(srpKeys.salt);
encryptedPrivateKeyTag: string; clientPassword.setServerPublicKey(srpKeys.serverPublicKey);
publicKey: string;
verifier: string; const clientProof = clientPassword.getProof(); // called M1
salt: string; const generatedKey = crypto.randomBytes(16).toString("hex");
privateKey: string;
await new Promise((resolve) => {
clientKey.init({ username: email, password: generatedKey }, () => resolve(null));
});
const { salt, verifier } = await new Promise<{ salt: string; verifier: string }>(
(resolve, reject) => {
clientKey.createVerifier((err, res) => {
if (err) return reject(err);
return resolve(res);
});
}
);
const { ciphertext, iv, tag } = Aes256Gcm.encrypt({
text: String(localStorage.getItem("PRIVATE_KEY")),
secret: generatedKey
});
await issueBackupPrivateKey({
encryptedPrivateKey: ciphertext,
iv,
tag,
salt,
verifier,
clientProof
});
return generatedKey;
}; };
export const generateUserPassKey = async (email: string, password: string) => { export const generateUserPassKey = async (email: string, password: string) => {
// eslint-disable-next-line new-cap
const client = new jsrp.client();
const pair = nacl.box.keyPair(); const pair = nacl.box.keyPair();
const secretKeyUint8Array = pair.secretKey; const secretKeyUint8Array = pair.secretKey;
const publicKeyUint8Array = pair.publicKey; const publicKeyUint8Array = pair.publicKey;
const privateKey = encodeBase64(secretKeyUint8Array); const privateKey = encodeBase64(secretKeyUint8Array);
const publicKey = encodeBase64(publicKeyUint8Array); const publicKey = encodeBase64(publicKeyUint8Array);
return new Promise<TUserPassKey>((resolve, reject) => {
client.init({ username: email, password }, () => {
client.createVerifier(
async (err: any, { salt, verifier }: { salt: string; verifier: string }) => {
if (err) {
return reject(err);
}
try { await new Promise((resolve) => {
// TODO: moduralize into KeyService client.init({ username: email, password }, () => resolve(null));
const derivedKey = await deriveArgonKey({
password,
salt,
mem: 65536,
time: 3,
parallelism: 1,
hashLen: 32
});
if (!derivedKey) throw new Error("Failed to derive key from password");
const key = crypto.randomBytes(32);
// create encrypted private key by encrypting the private
// key with the symmetric key [key]
const {
ciphertext: encryptedPrivateKey,
iv: encryptedPrivateKeyIV,
tag: encryptedPrivateKeyTag
} = Aes256Gcm.encrypt({
text: privateKey,
secret: key
});
// create the protected key by encrypting the symmetric key
// [key] with the derived key
const {
ciphertext: protectedKey,
iv: protectedKeyIV,
tag: protectedKeyTag
} = Aes256Gcm.encrypt({
text: key.toString("hex"),
secret: Buffer.from(derivedKey.hash)
});
return resolve({
protectedKey,
protectedKeyTag,
protectedKeyIV,
encryptedPrivateKey,
encryptedPrivateKeyIV,
encryptedPrivateKeyTag,
publicKey,
verifier,
salt,
privateKey
});
} catch (error) {
return reject(error);
}
}
);
});
}); });
const { salt, verifier } = await new Promise<{ salt: string; verifier: string }>(
(resolve, reject) => {
client.createVerifier((err, res) => {
if (err) return reject(err);
return resolve(res);
});
}
);
const derivedKey = await deriveArgonKey({
password,
salt,
mem: 65536,
time: 3,
parallelism: 1,
hashLen: 32
});
if (!derivedKey) throw new Error("Failed to derive key from password");
const key = crypto.randomBytes(32);
// create encrypted private key by encrypting the private
// key with the symmetric key [key]
const {
ciphertext: encryptedPrivateKey,
iv: encryptedPrivateKeyIV,
tag: encryptedPrivateKeyTag
} = Aes256Gcm.encrypt({
text: privateKey,
secret: key
});
// create the protected key by encrypting the symmetric key
// [key] with the derived key
const {
ciphertext: protectedKey,
iv: protectedKeyIV,
tag: protectedKeyTag
} = Aes256Gcm.encrypt({
text: key.toString("hex"),
secret: Buffer.from(derivedKey.hash)
});
return {
protectedKey,
protectedKeyTag,
protectedKeyIV,
encryptedPrivateKey,
encryptedPrivateKeyIV,
encryptedPrivateKeyTag,
publicKey,
verifier,
salt,
privateKey
};
}; };
@@ -1,19 +1,23 @@
import { useEffect } from "react"; import { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { AnimatePresence, motion } from "framer-motion";
import { z } from "zod"; import { z } from "zod";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { generateBackupPDFAsync } from "@app/components/utilities/generateBackupPDF";
// TODO(akhilmhdh): rewrite this into module functions in lib // TODO(akhilmhdh): rewrite this into module functions in lib
import { saveTokenToLocalStorage } from "@app/components/utilities/saveTokenToLocalStorage"; import { saveTokenToLocalStorage } from "@app/components/utilities/saveTokenToLocalStorage";
import SecurityClient from "@app/components/utilities/SecurityClient"; import SecurityClient from "@app/components/utilities/SecurityClient";
import { Button, ContentLoader, FormControl, Input } from "@app/components/v2"; import { Button, ContentLoader, FormControl, Input } from "@app/components/v2";
import { useServerConfig } from "@app/context"; import { useServerConfig } from "@app/context";
import { useCreateAdminUser } from "@app/hooks/api"; import { useCreateAdminUser } from "@app/hooks/api";
import { generateUserPassKey } from "@app/lib/crypto"; import { generateUserBackupKey, generateUserPassKey } from "@app/lib/crypto";
import { isLoggedIn } from "@app/reactQuery"; import { isLoggedIn } from "@app/reactQuery";
import { DownloadBackupKeys } from "./components/DownloadBackupKeys";
const formSchema = z const formSchema = z
.object({ .object({
email: z.string().email().trim(), email: z.string().email().trim(),
@@ -29,16 +33,23 @@ const formSchema = z
type TFormSchema = z.infer<typeof formSchema>; type TFormSchema = z.infer<typeof formSchema>;
enum SignupSteps {
DetailsForm = "details-form",
BackupKey = "backup-key"
}
export const SignUpPage = () => { export const SignUpPage = () => {
const router = useRouter(); const router = useRouter();
const { const {
control, control,
handleSubmit, handleSubmit,
getValues,
formState: { isSubmitting } formState: { isSubmitting }
} = useForm<TFormSchema>({ } = useForm<TFormSchema>({
resolver: zodResolver(formSchema) resolver: zodResolver(formSchema)
}); });
const { createNotification } = useNotificationContext(); const { createNotification } = useNotificationContext();
const [step, setStep] = useState(SignupSteps.DetailsForm);
const { config } = useServerConfig(); const { config } = useServerConfig();
@@ -50,7 +61,7 @@ export const SignUpPage = () => {
router.push("/login"); router.push("/login");
} }
} }
}, [config.initialized]); }, []);
const { mutateAsync: createAdminUser } = useCreateAdminUser(); const { mutateAsync: createAdminUser } = useCreateAdminUser();
@@ -73,6 +84,7 @@ export const SignUpPage = () => {
tag: userPass.encryptedPrivateKeyTag, tag: userPass.encryptedPrivateKeyTag,
privateKey privateKey
}); });
setStep(SignupSteps.BackupKey);
} catch (err) { } catch (err) {
console.log(err); console.log(err);
createNotification({ createNotification({
@@ -82,83 +94,130 @@ export const SignUpPage = () => {
} }
}; };
if (config?.initialized) return <ContentLoader text="Redirecting to admin page..." />; const handleBackupKeyGenerate = async () => {
try {
const { email, password, firstName, lastName } = getValues();
const generatedKey = await generateUserBackupKey(email, password);
await generateBackupPDFAsync({
generatedKey,
personalEmail: email,
personalName: `${firstName} ${lastName}`
});
router.push("/admin");
} catch (err) {
console.log(err);
createNotification({
type: "error",
text: "Faield to generate backup"
});
}
};
if (config?.initialized && step === SignupSteps.DetailsForm)
return <ContentLoader text="Redirecting to admin page..." />;
return ( return (
<div className="flex justify-center items-center"> <div className="flex justify-center items-center">
<div className="text-mineshaft-200"> <AnimatePresence exitBeforeEnter>
<div className="text-center flex flex-col items-center space-y-4"> {step === SignupSteps.DetailsForm && (
<img src="/images/gradientLogo.svg" height={90} width={120} alt="Infisical logo" /> <motion.div
<div className="text-4xl">Welcome to Infisical</div> className="text-mineshaft-200"
<div>Create your first Admin Account</div> key="panel-1"
</div> transition={{ duration: 0.15 }}
<form onSubmit={handleSubmit(handleFormSubmit)}> initial={{ opacity: 0, translateX: 30 }}
<div className="mt-8"> animate={{ opacity: 1, translateX: 0 }}
<div className="flex items-center space-x-4"> exit={{ opacity: 0, translateX: 30 }}
<Controller >
control={control} <div className="text-center flex flex-col items-center space-y-4">
name="firstName" <img src="/images/gradientLogo.svg" height={90} width={120} alt="Infisical logo" />
render={({ field, fieldState: { error } }) => ( <div className="text-4xl">Welcome to Infisical</div>
<FormControl <div>Create your first Admin Account</div>
label="First name"
errorText={error?.message}
isError={Boolean(error)}
>
<Input isFullWidth size="lg" {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="lastName"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Last name"
errorText={error?.message}
isError={Boolean(error)}
>
<Input isFullWidth size="lg" {...field} />
</FormControl>
)}
/>
</div> </div>
<Controller <form onSubmit={handleSubmit(handleFormSubmit)}>
control={control} <div className="mt-8">
name="email" <div className="flex items-center space-x-4">
render={({ field, fieldState: { error } }) => ( <Controller
<FormControl label="Email" errorText={error?.message} isError={Boolean(error)}> control={control}
<Input isFullWidth size="lg" {...field} /> name="firstName"
</FormControl> render={({ field, fieldState: { error } }) => (
)} <FormControl
/> label="First name"
<Controller errorText={error?.message}
control={control} isError={Boolean(error)}
name="password" >
render={({ field, fieldState: { error } }) => ( <Input isFullWidth size="lg" {...field} />
<FormControl label="Password" errorText={error?.message} isError={Boolean(error)}> </FormControl>
<Input isFullWidth size="lg" type="password" {...field} /> )}
</FormControl> />
)} <Controller
/> control={control}
<Controller name="lastName"
control={control} render={({ field, fieldState: { error } }) => (
name="confirmPassword" <FormControl
render={({ field, fieldState: { error } }) => ( label="Last name"
<FormControl errorText={error?.message}
label="Confirm password" isError={Boolean(error)}
errorText={error?.message} >
isError={Boolean(error)} <Input isFullWidth size="lg" {...field} />
> </FormControl>
<Input isFullWidth size="lg" type="password" {...field} /> )}
</FormControl> />
)} </div>
/> <Controller
</div> control={control}
<Button type="submit" isFullWidth className="mt-4" isLoading={isSubmitting}> name="email"
Let&apos;s Go render={({ field, fieldState: { error } }) => (
</Button> <FormControl label="Email" errorText={error?.message} isError={Boolean(error)}>
</form> <Input isFullWidth size="lg" {...field} />
</div> </FormControl>
)}
/>
<Controller
control={control}
name="password"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Password"
errorText={error?.message}
isError={Boolean(error)}
>
<Input isFullWidth size="lg" type="password" {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="confirmPassword"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Confirm password"
errorText={error?.message}
isError={Boolean(error)}
>
<Input isFullWidth size="lg" type="password" {...field} />
</FormControl>
)}
/>
</div>
<Button type="submit" isFullWidth className="mt-4" isLoading={isSubmitting}>
Let&apos;s Go
</Button>
</form>
</motion.div>
)}
{step === SignupSteps.BackupKey && (
<motion.div
className="text-mineshaft-200"
key="panel-2"
transition={{ duration: 0.15 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
<DownloadBackupKeys onGenerate={handleBackupKeyGenerate} />
</motion.div>
)}
</AnimatePresence>
</div> </div>
); );
}; };
@@ -0,0 +1,53 @@
import { useTranslation } from "react-i18next";
import { faWarning } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button } from "@app/components/v2";
import { useToggle } from "@app/hooks";
type Props = {
onGenerate: () => Promise<void>;
};
export const DownloadBackupKeys = ({ onGenerate }: Props): JSX.Element => {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useToggle();
return (
<div className="flex flex-col items-center w-full h-full md:px-6 mx-auto mb-36 md:mb-16">
<p className="text-xl text-center font-medium flex justify-center text-transparent bg-clip-text bg-gradient-to-b from-white to-bunker-200">
<FontAwesomeIcon icon={faWarning} className="ml-2 mr-3 pt-1 text-2xl text-bunker-200" />
{t("signup.step4-message")}
</p>
<div className="flex flex-col pb-2 bg-mineshaft-800 border border-mineshaft-600 items-center justify-center text-center lg:w-1/6 w-full md:min-w-[24rem] mt-8 max-w-md text-bunker-300 text-md rounded-md">
<div className="w-full mt-4 md:mt-8 flex flex-row text-center items-center m-2 text-bunker-300 rounded-md lg:w-1/6 md:min-w-[23rem] px-3 mx-auto">
<span className="mb-2">
{t("signup.step4-description1")} {t("signup.step4-description3")}
</span>
</div>
<div className="flex flex-col items-center px-3 justify-center md:mt-4 mb-2 md:mb-4 lg:w-1/6 w-full md:min-w-[20rem] mt-2 md:max-w-md mx-auto text-sm text-center md:text-left">
<div className="text-l py-1 text-lg w-full">
<Button
onClick={async () => {
try {
setIsLoading.on();
await onGenerate();
} finally {
setIsLoading.off();
}
}}
size="sm"
isFullWidth
className="h-12"
colorSchema="primary"
variant="outline_bg"
isLoading={isLoading}
>
Download PDF
</Button>
</div>
</div>
</div>
</div>
);
};
@@ -0,0 +1 @@
export { DownloadBackupKeys } from "./DownloadBackupKeys";