87 | >(({ className, ...props }, ref) => (
88 | [role=checkbox]]:translate-y-[2px]",
92 | className
93 | )}
94 | {...props}
95 | />
96 | ));
97 | TableCell.displayName = "TableCell";
98 |
99 | const TableCaption = React.forwardRef<
100 | HTMLTableCaptionElement,
101 | React.HTMLAttributes
102 | >(({ className, ...props }, ref) => (
103 |
108 | ));
109 | TableCaption.displayName = "TableCaption";
110 |
111 | export {
112 | Table,
113 | TableHeader,
114 | TableBody,
115 | TableFooter,
116 | TableHead,
117 | TableRow,
118 | TableCell,
119 | TableCaption,
120 | };
121 |
--------------------------------------------------------------------------------
/app/src/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { cn } from "../../lib/utils";
4 |
5 | const Textarea = React.forwardRef<
6 | HTMLTextAreaElement,
7 | React.ComponentProps<"textarea">
8 | >(({ className, ...props }, ref) => {
9 | return (
10 |
18 | );
19 | });
20 | Textarea.displayName = "Textarea";
21 |
22 | export { Textarea };
23 |
--------------------------------------------------------------------------------
/app/src/components/ui/toast.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Cross2Icon } from "@radix-ui/react-icons";
3 | import * as ToastPrimitives from "@radix-ui/react-toast";
4 | import { cva, type VariantProps } from "class-variance-authority";
5 |
6 | import { cn } from "../../lib/utils";
7 |
8 | const ToastProvider = ToastPrimitives.Provider;
9 |
10 | const ToastViewport = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, ...props }, ref) => (
14 |
22 | ));
23 | ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
24 |
25 | const toastVariants = cva(
26 | "group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
27 | {
28 | variants: {
29 | variant: {
30 | default: "border bg-background text-foreground",
31 | destructive:
32 | "destructive group border-destructive bg-destructive text-destructive-foreground",
33 | },
34 | },
35 | defaultVariants: {
36 | variant: "default",
37 | },
38 | }
39 | );
40 |
41 | const Toast = React.forwardRef<
42 | React.ElementRef,
43 | React.ComponentPropsWithoutRef &
44 | VariantProps
45 | >(({ className, variant, ...props }, ref) => {
46 | return (
47 |
52 | );
53 | });
54 | Toast.displayName = ToastPrimitives.Root.displayName;
55 |
56 | const ToastAction = React.forwardRef<
57 | React.ElementRef,
58 | React.ComponentPropsWithoutRef
59 | >(({ className, ...props }, ref) => (
60 |
68 | ));
69 | ToastAction.displayName = ToastPrimitives.Action.displayName;
70 |
71 | const ToastClose = React.forwardRef<
72 | React.ElementRef,
73 | React.ComponentPropsWithoutRef
74 | >(({ className, ...props }, ref) => (
75 |
84 |
85 |
86 | ));
87 | ToastClose.displayName = ToastPrimitives.Close.displayName;
88 |
89 | const ToastTitle = React.forwardRef<
90 | React.ElementRef,
91 | React.ComponentPropsWithoutRef
92 | >(({ className, ...props }, ref) => (
93 |
98 | ));
99 | ToastTitle.displayName = ToastPrimitives.Title.displayName;
100 |
101 | const ToastDescription = React.forwardRef<
102 | React.ElementRef,
103 | React.ComponentPropsWithoutRef
104 | >(({ className, ...props }, ref) => (
105 |
110 | ));
111 | ToastDescription.displayName = ToastPrimitives.Description.displayName;
112 |
113 | type ToastProps = React.ComponentPropsWithoutRef;
114 |
115 | type ToastActionElement = React.ReactElement;
116 |
117 | export {
118 | type ToastProps,
119 | type ToastActionElement,
120 | ToastProvider,
121 | ToastViewport,
122 | Toast,
123 | ToastTitle,
124 | ToastDescription,
125 | ToastClose,
126 | ToastAction,
127 | };
128 |
--------------------------------------------------------------------------------
/app/src/components/ui/toaster.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Toast,
3 | ToastClose,
4 | ToastDescription,
5 | ToastProvider,
6 | ToastTitle,
7 | ToastViewport,
8 | } from "../ui/toast";
9 | import { useToast } from "../ui/use-toast";
10 | import React from "react";
11 |
12 | export function Toaster() {
13 | const { toasts } = useToast();
14 |
15 | return (
16 |
17 | {toasts.map(function ({ id, title, description, action, ...props }) {
18 | return (
19 |
20 |
21 | {title && {title} }
22 | {description && (
23 | {description}
24 | )}
25 |
26 | {action}
27 |
28 |
29 | );
30 | })}
31 |
32 |
33 | );
34 | }
35 |
--------------------------------------------------------------------------------
/app/src/components/ui/tooltip.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as TooltipPrimitive from "@radix-ui/react-tooltip";
3 |
4 | import { cn } from "../../lib/utils";
5 |
6 | const TooltipProvider = TooltipPrimitive.Provider;
7 |
8 | const Tooltip = TooltipPrimitive.Root;
9 |
10 | const TooltipTrigger = TooltipPrimitive.Trigger;
11 |
12 | const TooltipContent = React.forwardRef<
13 | React.ElementRef,
14 | React.ComponentPropsWithoutRef
15 | >(({ className, sideOffset = 4, ...props }, ref) => (
16 |
17 |
26 |
27 | ));
28 | TooltipContent.displayName = TooltipPrimitive.Content.displayName;
29 |
30 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
31 |
--------------------------------------------------------------------------------
/app/src/components/ui/use-toast.ts:
--------------------------------------------------------------------------------
1 | // Inspired by react-hot-toast library
2 | import * as React from "react";
3 |
4 | import type { ToastActionElement, ToastProps } from "./toast";
5 |
6 | const TOAST_LIMIT = 1;
7 | const TOAST_REMOVE_DELAY = 1000000;
8 |
9 | type ToasterToast = ToastProps & {
10 | id: string;
11 | title?: React.ReactNode;
12 | description?: React.ReactNode;
13 | action?: ToastActionElement;
14 | };
15 |
16 | const actionTypes = {
17 | ADD_TOAST: "ADD_TOAST",
18 | UPDATE_TOAST: "UPDATE_TOAST",
19 | DISMISS_TOAST: "DISMISS_TOAST",
20 | REMOVE_TOAST: "REMOVE_TOAST",
21 | } as const;
22 |
23 | let count = 0;
24 |
25 | function genId() {
26 | count = (count + 1) % Number.MAX_SAFE_INTEGER;
27 | return count.toString();
28 | }
29 |
30 | type ActionType = typeof actionTypes;
31 |
32 | type Action =
33 | | {
34 | type: ActionType["ADD_TOAST"];
35 | toast: ToasterToast;
36 | }
37 | | {
38 | type: ActionType["UPDATE_TOAST"];
39 | toast: Partial;
40 | }
41 | | {
42 | type: ActionType["DISMISS_TOAST"];
43 | toastId?: ToasterToast["id"];
44 | }
45 | | {
46 | type: ActionType["REMOVE_TOAST"];
47 | toastId?: ToasterToast["id"];
48 | };
49 |
50 | interface State {
51 | toasts: ToasterToast[];
52 | }
53 |
54 | const toastTimeouts = new Map>();
55 |
56 | const addToRemoveQueue = (toastId: string) => {
57 | if (toastTimeouts.has(toastId)) {
58 | return;
59 | }
60 |
61 | const timeout = setTimeout(() => {
62 | toastTimeouts.delete(toastId);
63 | dispatch({
64 | type: "REMOVE_TOAST",
65 | toastId: toastId,
66 | });
67 | }, TOAST_REMOVE_DELAY);
68 |
69 | toastTimeouts.set(toastId, timeout);
70 | };
71 |
72 | export const reducer = (state: State, action: Action): State => {
73 | switch (action.type) {
74 | case "ADD_TOAST":
75 | return {
76 | ...state,
77 | toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
78 | };
79 |
80 | case "UPDATE_TOAST":
81 | return {
82 | ...state,
83 | toasts: state.toasts.map((t) =>
84 | t.id === action.toast.id ? { ...t, ...action.toast } : t
85 | ),
86 | };
87 |
88 | case "DISMISS_TOAST": {
89 | const { toastId } = action;
90 |
91 | // ! Side effects ! - This could be extracted into a dismissToast() action,
92 | // but I'll keep it here for simplicity
93 | if (toastId) {
94 | addToRemoveQueue(toastId);
95 | } else {
96 | state.toasts.forEach((toast) => {
97 | addToRemoveQueue(toast.id);
98 | });
99 | }
100 |
101 | return {
102 | ...state,
103 | toasts: state.toasts.map((t) =>
104 | t.id === toastId || toastId === undefined
105 | ? {
106 | ...t,
107 | open: false,
108 | }
109 | : t
110 | ),
111 | };
112 | }
113 | case "REMOVE_TOAST":
114 | if (action.toastId === undefined) {
115 | return {
116 | ...state,
117 | toasts: [],
118 | };
119 | }
120 | return {
121 | ...state,
122 | toasts: state.toasts.filter((t) => t.id !== action.toastId),
123 | };
124 | }
125 | };
126 |
127 | const listeners: Array<(state: State) => void> = [];
128 |
129 | let memoryState: State = { toasts: [] };
130 |
131 | function dispatch(action: Action) {
132 | memoryState = reducer(memoryState, action);
133 | listeners.forEach((listener) => {
134 | listener(memoryState);
135 | });
136 | }
137 |
138 | type Toast = Omit;
139 |
140 | function toast({ ...props }: Toast) {
141 | const id = genId();
142 |
143 | const update = (props: ToasterToast) =>
144 | dispatch({
145 | type: "UPDATE_TOAST",
146 | toast: { ...props, id },
147 | });
148 | const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
149 |
150 | dispatch({
151 | type: "ADD_TOAST",
152 | toast: {
153 | ...props,
154 | id,
155 | open: true,
156 | onOpenChange: (open) => {
157 | if (!open) dismiss();
158 | },
159 | },
160 | });
161 |
162 | return {
163 | id: id,
164 | dismiss,
165 | update,
166 | };
167 | }
168 |
169 | function useToast() {
170 | const [state, setState] = React.useState(memoryState);
171 |
172 | React.useEffect(() => {
173 | listeners.push(setState);
174 | return () => {
175 | const index = listeners.indexOf(setState);
176 | if (index > -1) {
177 | listeners.splice(index, 1);
178 | }
179 | };
180 | }, [state]);
181 |
182 | return {
183 | ...state,
184 | toast,
185 | dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
186 | };
187 | }
188 |
189 | export { useToast, toast };
190 |
--------------------------------------------------------------------------------
/app/src/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | body {
6 | font-family: "Fira Code", monospace;
7 | font-optical-sizing: auto;
8 | font-weight: 400;
9 | font-style: normal;
10 | font-variation-settings: "wdth" 100;
11 | }
12 |
13 | @layer base {
14 | :root {
15 | --background: 30, 20%, 98%;
16 | --foreground: 234 39% 23%;
17 | --card: 0, 0%, 100%;
18 | --card-foreground: 234 39% 23%;
19 | --popover: 0 0% 100%;
20 | --popover-foreground: 234 39% 23%;
21 | --primary: 30, 20%, 98%;
22 | --primary-foreground: 0 0% 98%;
23 | --secondary: 12 58% 82%;
24 | --secondary-foreground: 240 5.9% 10%;
25 | --muted: 240 4.8% 95.9%;
26 | --muted-foreground: 240 3.8% 46.1%;
27 | --accent: 240 4.8% 95.9%;
28 | --accent-foreground: 240 5.9% 10%;
29 | --destructive: 0 84.2% 60.2%;
30 | --destructive-foreground: 0 0% 98%;
31 | --border: 240 5.9% 90%;
32 | --input: 240 5.9% 90%;
33 | --ring: 234 39% 23%;
34 | --chart-1: 12 76% 61%;
35 | --chart-2: 173 58% 39%;
36 | --chart-3: 197 37% 24%;
37 | --chart-4: 43 74% 66%;
38 | --chart-5: 27 87% 67%;
39 | --radius: 0rem;
40 | }
41 | .dark {
42 | --background: 0, 0%, 0%;
43 | --foreground: 234 89% 90%;
44 | --card: 0, 0%, 15%;
45 | --card-foreground: 234 89% 90%;
46 | --popover: 240 10% 3.9%;
47 | --popover-foreground: 234 89% 90%;
48 | --primary: 0, 0%, 0%;
49 | --primary-foreground: 240 5.9% 10%;
50 | --secondary: 12 58% 82%;
51 | --secondary-foreground: 240 5.9% 10%;
52 | --muted: 240 3.7% 15.9%;
53 | --muted-foreground: 240 5% 64.9%;
54 | --accent: 240 3.7% 15.9%;
55 | --accent-foreground: 234 89% 90%;
56 | --destructive: 0 84.2% 60.2%;
57 | --destructive-foreground: 0 0% 98%;
58 | --border: 240 3.7% 15.9%;
59 | --input: 240 3.7% 15.9%;
60 | --ring: 240 4.9% 83.9%;
61 | --chart-1: 220 70% 50%;
62 | --chart-2: 160 60% 45%;
63 | --chart-3: 30 80% 55%;
64 | --chart-4: 280 65% 60%;
65 | --chart-5: 340 75% 55%;
66 | }
67 | }
68 |
69 | @layer base {
70 | * {
71 | @apply border-border;
72 | }
73 | body {
74 | @apply bg-background text-foreground;
75 | }
76 | }
77 |
78 | u {
79 | color: #3182ce;
80 | }
81 |
82 | #google-signin-button div:hover {
83 | box-shadow: none !important;
84 | }
85 | #google-signin-button div {
86 | box-shadow: none !important;
87 | font-size: 1rem !important;
88 | }
89 |
90 | #not_signed_in35qfawpxhggu {
91 | font-size: 15px !important;
92 | }
93 |
--------------------------------------------------------------------------------
/app/src/index.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom/client";
3 | import "./index.css";
4 | import App from "./app";
5 | import reportWebVitals from "./reportWebVitals";
6 | import { ThemeProvider } from "./components/theme-provider";
7 | import { Toaster } from "./components/ui/sonner";
8 |
9 | const root = ReactDOM.createRoot(document.getElementById("root"));
10 | root.render(
11 |
12 |
13 |
14 |
15 |
16 |
17 | );
18 |
19 | // If you want to start measuring performance in your app, pass a function
20 | // to log results (for example: reportWebVitals(console.log))
21 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
22 | reportWebVitals();
23 |
--------------------------------------------------------------------------------
/app/src/lib/mime-types.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | export type MimeTypeCategory =
3 | | "Images"
4 | | "PDFs"
5 | | "Text"
6 | | "Archives"
7 | | "Videos"
8 | | "Audio"
9 | | "Spreadsheets"
10 | | "Presentations"
11 | | "Others";
12 |
13 | export const iconMap: Record = {
14 | "application/pdf": ,
15 | "text/plain": ,
16 | "application/json": (
17 |
18 | ),
19 | "application/zip": ,
20 | "application/x-7z-compressed": (
21 |
22 | ),
23 | "video/mp4": ,
24 | "audio/mpeg": ,
25 | "audio/wav": ,
26 | "image/jpeg": ,
27 | "image/png": ,
28 | "image/gif": ,
29 | "image/webp": ,
30 | "image/svg+xml": ,
31 | "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": (
32 |
33 | ),
34 | "application/vnd.ms-excel": (
35 |
36 | ),
37 | "application/vnd.openxmlformats-officedocument.presentationml.presentation": (
38 |
39 | ),
40 | "application/vnd.ms-powerpoint": (
41 |
42 | ),
43 | };
44 |
45 | export const mimeTypeCategories: Record = {
46 | Images: [
47 | "image/jpeg",
48 | "image/png",
49 | "image/gif",
50 | "image/webp",
51 | "image/svg+xml",
52 | ],
53 | PDFs: ["application/pdf"],
54 | Text: ["text/plain", "application/json"],
55 | Archives: ["application/zip", "application/x-7z-compressed"],
56 | Videos: ["video/mp4"],
57 | Audio: ["audio/mpeg", "audio/wav"],
58 | Spreadsheets: [
59 | "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
60 | "application/vnd.ms-excel",
61 | ],
62 | Presentations: [
63 | "application/vnd.openxmlformats-officedocument.presentationml.presentation",
64 | "application/vnd.ms-powerpoint",
65 | ],
66 | Others: [],
67 | };
68 |
--------------------------------------------------------------------------------
/app/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
--------------------------------------------------------------------------------
/app/src/logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/src/pages/google-oauth-callback.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from "react";
2 |
3 | function GoogleOAuthCallback() {
4 | useEffect(() => {
5 | // Function to parse fragment parameters
6 | const getFragmentParams = (): { [key: string]: string } => {
7 | const fragment = window.location.hash.substring(1);
8 | const params: { [key: string]: string } = {};
9 | const regex = /([^&=]+)=([^&]*)/g;
10 | let m;
11 | while ((m = regex.exec(fragment)) !== null) {
12 | params[decodeURIComponent(m[1])] = decodeURIComponent(m[2]);
13 | }
14 | return params;
15 | };
16 |
17 | const params = getFragmentParams();
18 | const idToken = params["id_token"];
19 | const state = params["state"];
20 | const error = params["error"];
21 |
22 | if (window.opener) {
23 | if (error) {
24 | window.opener.postMessage(
25 | { type: "GOOGLE_SIGN_IN_ERROR", error },
26 | window.location.origin
27 | );
28 | } else if (idToken && state) {
29 | // Send the successful token and state back to the opener window
30 | window.opener.postMessage(
31 | { type: "GOOGLE_SIGN_IN_SUCCESS", idToken, state },
32 | window.location.origin
33 | );
34 | } else {
35 | // Handle unexpected case
36 | window.opener.postMessage(
37 | {
38 | type: "GOOGLE_SIGN_IN_ERROR",
39 | error: "Missing id_token or state in callback",
40 | },
41 | window.location.origin
42 | );
43 | }
44 | // Close the popup window
45 | window.close();
46 | } else {
47 | // Handle case where opener is not available (e.g., page opened directly)
48 | console.error("No window.opener available for postMessage.");
49 | // Maybe display an error message to the user in the popup itself
50 | }
51 | }, []); // Run only once on component mount
52 |
53 | // Render minimal content, as the window should close quickly
54 | return Processing Google Sign-in...
;
55 | }
56 |
57 | export default GoogleOAuthCallback;
58 |
--------------------------------------------------------------------------------
/app/src/pages/how-it-works.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import Header from "../components/landing-page/header";
3 | import Markdown from "markdown-to-jsx";
4 | import Footer from "../components/landing-page/footer";
5 | import { HowItWorksContent } from "../components/how-it-works-content";
6 |
7 | function HowItWorks() {
8 | return (
9 |
10 |
11 |
12 | {HowItWorksContent.map((section, index) => (
13 |
14 |
15 | {section.heading}
16 |
17 |
18 | {section.content}
19 |
20 |
21 | ))}
22 |
23 |
⚠️ Important Warning:
24 |
25 |
26 | If you lose your encryption key, your encrypted data will be
27 | permanently inaccessible.
28 |
29 |
30 | Do not delete or modify encrypted files directly on Google Drive
31 | as this may corrupt your data.
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 | );
40 | }
41 |
42 | export default HowItWorks;
43 |
--------------------------------------------------------------------------------
/app/src/pages/key-management-page.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { useNavigate } from "react-router-dom";
3 | import {
4 | generateMnemonic,
5 | deriveKeyFromMnemonic,
6 | storeKey,
7 | } from "../utils/cryptoUtils";
8 | import { Button } from "../components/ui/button";
9 | import {
10 | Card,
11 | CardContent,
12 | CardDescription,
13 | CardFooter,
14 | CardHeader,
15 | CardTitle,
16 | } from "../components/ui/card";
17 | import React from "react";
18 | import { Input } from "../components/ui/input";
19 | import { Label } from "../components/ui/label";
20 | import { toast } from "sonner";
21 | import { Textarea } from "../components/ui/textarea";
22 | import { ArrowLeft } from "lucide-react";
23 |
24 | export const KeyManagementPage: React.FC = () => {
25 | const navigate = useNavigate();
26 | const [error, setError] = useState("");
27 | const [generatedMnemonic, setGeneratedMnemonic] = useState(
28 | null
29 | );
30 | const [inputMnemonic, setInputMnemonic] = useState("");
31 | const [showFileUpload, setShowFileUpload] = useState(false);
32 | const [viewMode, setViewMode] = useState<"recover" | "generate">("recover");
33 |
34 | const handleGenerateNewMnemonicAndKey = async () => {
35 | setError("");
36 | setGeneratedMnemonic(null);
37 | try {
38 | const newMnemonic = generateMnemonic();
39 | setGeneratedMnemonic(newMnemonic);
40 | const key = await deriveKeyFromMnemonic(newMnemonic);
41 | await storeKey(key);
42 | toast.success("New Mnemonic & Key Generated!", {
43 | description:
44 | "Your new mnemonic phrase is displayed below. PLEASE SAVE IT SECURELY. It is the only way to recover your key.",
45 | duration: 10000,
46 | });
47 | } catch (err) {
48 | console.error("Error generating new mnemonic and key:", err);
49 | setError("Failed to generate new mnemonic and key. Please try again.");
50 | toast.error("Key Generation Failed", {
51 | description: "Could not generate a new mnemonic and key.",
52 | });
53 | }
54 | };
55 |
56 | const handleLoadKeyFromMnemonic = async () => {
57 | setError("");
58 | if (!inputMnemonic.trim()) {
59 | setError("Please enter your mnemonic phrase.");
60 | return;
61 | }
62 | try {
63 | const key = await deriveKeyFromMnemonic(inputMnemonic.trim());
64 | await storeKey(key);
65 | toast.success("Key Loaded Successfully!", {
66 | description: "Your encryption key has been loaded from the mnemonic.",
67 | });
68 | setTimeout(() => {
69 | window.location.reload();
70 | }, 2000);
71 | } catch (err) {
72 | console.error("Error loading key from mnemonic:", err);
73 | setError(
74 | "Failed to load key from mnemonic. Ensure the phrase is correct or try generating a new key if this is your first time."
75 | );
76 | toast.error("Key Load Failed", {
77 | description: "Invalid mnemonic phrase or an unexpected error occurred.",
78 | });
79 | }
80 | };
81 |
82 | const handleFileUpload = async (
83 | event: React.ChangeEvent
84 | ) => {
85 | setError("");
86 | const file = event.target.files?.[0];
87 | if (file) {
88 | const reader = new FileReader();
89 | reader.onload = async (e) => {
90 | try {
91 | const keyJWK = JSON.parse(e.target?.result as string);
92 | if (
93 | !keyJWK.kty ||
94 | keyJWK.kty !== "oct" ||
95 | !keyJWK.k ||
96 | !keyJWK.alg ||
97 | keyJWK.alg !== "A256GCM"
98 | ) {
99 | throw new Error("Invalid key format in JSON file.");
100 | }
101 | const key = await crypto.subtle.importKey(
102 | "jwk",
103 | keyJWK,
104 | { name: "AES-GCM" },
105 | true,
106 | ["encrypt", "decrypt"]
107 | );
108 | await storeKey(key);
109 | toast.success("Encryption key added from file!", {
110 | description: "Your encryption key has been added to storage.",
111 | });
112 | setTimeout(() => {
113 | window.location.reload();
114 | }, 2000);
115 | } catch (error) {
116 | console.error("Error processing key file:", error);
117 | setError(
118 | "Invalid key file. Please ensure the file contains a valid AES-GCM key in JSON format."
119 | );
120 | toast.error("Invalid Key File", {
121 | description: "The uploaded file does not contain a valid key.",
122 | });
123 | }
124 | };
125 | reader.readAsText(file);
126 | }
127 | };
128 |
129 | const switchToGenerate = () => {
130 | setError("");
131 | setInputMnemonic("");
132 | setGeneratedMnemonic(null);
133 | setViewMode("generate");
134 | };
135 |
136 | const switchToRecover = () => {
137 | setError("");
138 | setInputMnemonic("");
139 | setGeneratedMnemonic(null);
140 | setViewMode("recover");
141 | };
142 |
143 | const handleDownloadMnemonic = () => {
144 | if (!generatedMnemonic) return;
145 |
146 | const blob = new Blob([generatedMnemonic], { type: "text/plain" });
147 | const url = URL.createObjectURL(blob);
148 | const a = document.createElement("a");
149 | a.href = url;
150 | a.download = "zerodrive-mnemonic.txt";
151 | document.body.appendChild(a); // Required for Firefox
152 | a.click();
153 | document.body.removeChild(a);
154 | URL.revokeObjectURL(url);
155 | toast.info("Mnemonic downloaded as zerodrive-mnemonic.txt");
156 | };
157 |
158 | return (
159 |
160 |
161 |
162 |
163 |
164 | {viewMode === "recover" ? "Recover Your Key" : "Create New Key"}
165 |
166 |
navigate("/storage")}
170 | aria-label="Back to Storage"
171 | >
172 |
173 |
174 |
175 |
176 | {viewMode === "recover"
177 | ? "Enter your mnemonic phrase to load your encryption key."
178 | : "Generate a new secure mnemonic phrase to create your encryption key. Save it securely!"}
179 |
180 |
181 |
182 | {viewMode === "recover" && (
183 | <>
184 |
185 |
186 |
200 |
205 | Don't have a key? Create new secure mnemonic.
206 |
207 |
208 |
209 | setShowFileUpload(!showFileUpload)}
213 | >
214 | {showFileUpload
215 | ? "Hide File Upload"
216 | : "Advanced: Use Existing Key File (.json)"}
217 |
218 | {showFileUpload && (
219 |
220 |
221 | If you have an existing encryption key file
222 | (`encryption-key.json`), you can upload it here.
223 |
224 |
225 | Upload your encryption key file
226 |
232 |
233 |
234 | )}
235 |
236 | >
237 | )}
238 |
239 | {viewMode === "generate" && (
240 |
241 |
245 | {generatedMnemonic
246 | ? "Mnemonic Generated!"
247 | : "Generate New Secure Mnemonic & Key"}
248 |
249 | {generatedMnemonic && (
250 |
251 |
252 |
253 | IMPORTANT: Save this mnemonic phrase in a safe place. Do not
254 | share it.
255 |
256 |
262 |
263 | This phrase is the ONLY way to recover your encryption key.
264 |
265 |
266 |
271 | Download Mnemonic (.txt)
272 |
273 |
274 | )}
275 |
280 | Already have a key? Enter your mnemonic.
281 |
282 |
283 | )}
284 |
285 | {error && (
286 |
287 |
288 | {error}
289 |
290 |
291 | )}
292 |
293 |
294 | );
295 | };
296 |
--------------------------------------------------------------------------------
/app/src/pages/landing-page.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import Markdown from "markdown-to-jsx";
3 | import { content } from "../components/landing-page/content";
4 | import { GoogleAuth } from "../components/landing-page/google-auth";
5 | import Footer from "../components/landing-page/footer";
6 | import Header from "../components/landing-page/header";
7 | import { VideoDialog } from "../components/landing-page/video-dialog";
8 |
9 | function LandingPage() {
10 | const [isAuthenticated, setIsAuthenticated] = useState(() => {
11 | return localStorage.getItem("isAuthenticated") === "true";
12 | });
13 |
14 | const [videoError, setVideoError] = useState(false);
15 |
16 | const handleAuthChange = (authenticated: boolean) => {
17 | setIsAuthenticated(authenticated);
18 | };
19 |
20 | if (isAuthenticated) {
21 | return null;
22 | }
23 |
24 | return (
25 |
26 |
27 |
28 |
29 |
30 |
31 | End-to-End Encrypted File Storage on{" "}
32 | Google Drive
33 |
34 |
35 |
36 |
37 | A{" "}
38 |
39 | simple
40 |
41 | ,{" "}
42 |
43 | privacy-focused
44 | {" "}
45 | solution for secure file storage on Google Drive
46 |
47 |
48 | Our open-source tool encrypts your files locally on your device
49 |
50 | Securely stores encrypted files in your Google Account
51 |
52 |
53 |
54 |
Free Forever
55 |
56 |
57 |
58 |
59 |
60 | {content.map((section, index) => (
61 |
62 |
63 | {section.heading}
64 |
65 |
66 | {section.description}
67 |
68 |
69 | ))}
70 |
71 |
72 |
73 |
74 | );
75 | }
76 |
77 | export default LandingPage;
78 |
--------------------------------------------------------------------------------
/app/src/pages/privacy.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import Header from "../components/landing-page/header";
3 | import Markdown from "markdown-to-jsx";
4 | import Footer from "../components/landing-page/footer";
5 | import { privacyPolicy } from "../components/privacy-content";
6 |
7 | function Privacy() {
8 | return (
9 |
10 |
11 |
12 | {privacyPolicy.map((section, index) => (
13 |
14 |
15 | {section.heading}
16 |
17 |
18 | {section.content}
19 |
20 |
21 | ))}
22 |
23 |
24 |
25 | );
26 | }
27 |
28 | export default Privacy;
29 |
--------------------------------------------------------------------------------
/app/src/pages/private-storage.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from "react";
2 | import { gapi } from "gapi-script";
3 | import { FileList } from "../components/storage/file-list";
4 | import { ModeToggle } from "../components/mode-toggle";
5 | import { Button } from "../components/ui/button";
6 | import { toast } from "sonner";
7 |
8 | import { getStoredKey } from "../utils/cryptoUtils";
9 | import { getAllFilesForUser } from "../utils/dexieDB";
10 | import {
11 | uploadAndSyncFile,
12 | deleteAllAndSyncFiles,
13 | } from "../utils/fileOperations";
14 | import { ConfirmationDialog } from "../components/storage/confirmation-dialog";
15 | import Footer from "../components/landing-page/footer";
16 | import { Separator } from "../components/ui/separator";
17 | import {
18 | DropdownMenu,
19 | DropdownMenuContent,
20 | DropdownMenuTrigger,
21 | } from "../components/ui/dropdown-menu";
22 | import { Progress } from "../components/ui/progress";
23 | import { Zap } from "lucide-react";
24 | import { Link } from "react-router-dom";
25 | function PrivateStorage() {
26 | const [_isAuthenticated, setIsAuthenticated] = useState(true);
27 | const [showKeyModal, setShowKeyModal] = useState(false);
28 | const [userName, setUserName] = useState("");
29 | const [userEmail, setUserEmail] = useState("");
30 | const [userImage, setUserImage] = useState("");
31 | const [uploading, setUploading] = useState(false);
32 | const [isDeleting, setIsDeleting] = useState(false);
33 | const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
34 | const [refreshFileListKey, setRefreshFileListKey] = useState(0);
35 | const [userHasFiles, setUserHasFiles] = useState(false);
36 | const [isLoadingUserFiles, setIsLoadingUserFiles] = useState(true);
37 | const [storageInfo, setStorageInfo] = useState<{
38 | used: number;
39 | total: number;
40 | } | null>(null);
41 | const [isLoadingStorage, setIsLoadingStorage] = useState(true);
42 | const [hasEncryptionKey, setHasEncryptionKey] = useState(true);
43 |
44 | const formatBytes = (bytes: number, decimals = 2) => {
45 | if (bytes === 0) return "0 Bytes";
46 | const k = 1024;
47 | const dm = decimals < 0 ? 0 : decimals;
48 | const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
49 | const i = Math.floor(Math.log(bytes) / Math.log(k));
50 | return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
51 | };
52 |
53 | const getProgressColor = (percentage: number): string => {
54 | if (percentage < 60) return "hsl(142.1 76.2% 36.3%)";
55 | if (percentage < 75) return "hsl(47.9 95.8% 53.1%)";
56 | return "hsl(0 84.2% 60.2%)";
57 | };
58 |
59 | const loadStorageInfo = async () => {
60 | const authInstance = gapi.auth2?.getAuthInstance();
61 | if (!authInstance || !authInstance.isSignedIn.get()) {
62 | console.warn("[StorageInfo] Not signed in, cannot fetch storage.");
63 | setStorageInfo(null);
64 | setIsLoadingStorage(false);
65 | return;
66 | }
67 |
68 | setIsLoadingStorage(true);
69 | try {
70 | const token = authInstance.currentUser
71 | .get()
72 | .getAuthResponse().access_token;
73 | const response = await gapi.client.request({
74 | path: "https://www.googleapis.com/drive/v3/about",
75 | params: { fields: "storageQuota" },
76 | headers: { Authorization: `Bearer ${token}` },
77 | });
78 |
79 | if (response.result.storageQuota) {
80 | const { storageQuota } = response.result;
81 | setStorageInfo({
82 | used: parseInt(storageQuota.usage || "0", 10),
83 | total: parseInt(storageQuota.limit || "0", 10),
84 | });
85 | } else {
86 | setStorageInfo(null);
87 | }
88 | } catch (error) {
89 | console.error("[StorageInfo] Error loading storage info:", error);
90 | setStorageInfo(null);
91 | } finally {
92 | setIsLoadingStorage(false);
93 | }
94 | };
95 |
96 | useEffect(() => {
97 | const loadInitialData = async () => {
98 | setIsLoadingUserFiles(true);
99 | setIsLoadingStorage(true);
100 | try {
101 | await new Promise((resolve, reject) => {
102 | gapi.load("client:auth2", {
103 | callback: resolve,
104 | onerror: reject,
105 | timeout: 5000,
106 | ontimeout: reject,
107 | });
108 | });
109 |
110 | await gapi.client.init({
111 | clientId: process.env.REACT_APP_PUBLIC_CLIENT_ID,
112 | scope: process.env.REACT_APP_PUBLIC_SCOPE,
113 | });
114 |
115 | const authInstance = gapi.auth2.getAuthInstance();
116 | if (authInstance && authInstance.isSignedIn.get()) {
117 | const profile = authInstance.currentUser.get().getBasicProfile();
118 | if (profile) {
119 | setUserName(profile.getName());
120 | const email = profile.getEmail();
121 | setUserEmail(email);
122 | setUserImage(profile.getImageUrl());
123 | if (email) {
124 | const files = await getAllFilesForUser(email);
125 | setUserHasFiles(files.length > 0);
126 | await loadStorageInfo();
127 | } else {
128 | setUserHasFiles(false);
129 | setStorageInfo(null);
130 | }
131 | } else {
132 | window.location.href = "/";
133 | }
134 | } else {
135 | window.location.href = "/";
136 | }
137 | } catch (error) {
138 | console.error("Error loading user info or storage:", error);
139 | window.location.href = "/";
140 | } finally {
141 | setIsLoadingUserFiles(false);
142 | }
143 | };
144 |
145 | loadInitialData();
146 |
147 | const intervalId = setInterval(loadStorageInfo, 60 * 1000 * 5);
148 | return () => clearInterval(intervalId);
149 | }, []);
150 |
151 | useEffect(() => {
152 | if (userEmail) {
153 | setIsLoadingUserFiles(true);
154 | getAllFilesForUser(userEmail)
155 | .then((files) => {
156 | setUserHasFiles(files.length > 0);
157 | })
158 | .catch((err) => {
159 | console.error("Error checking user files:", err);
160 | setUserHasFiles(false);
161 | })
162 | .finally(() => setIsLoadingUserFiles(false));
163 | }
164 | }, [userEmail, refreshFileListKey]);
165 |
166 | const handleLogout = async () => {
167 | try {
168 | const authInstance = gapi.auth2.getAuthInstance();
169 | if (authInstance) {
170 | await authInstance.signOut();
171 | localStorage.removeItem("isAuthenticated");
172 | window.location.href = "/";
173 | }
174 | } catch (error) {
175 | console.error("Error during logout:", error);
176 | }
177 | };
178 |
179 | const handleUploadTrigger = async () => {
180 | const key = await getStoredKey();
181 | if (!key) {
182 | toast.error("No encryption key found", {
183 | description: "Please generate or upload an encryption key",
184 | });
185 | setShowKeyModal(true);
186 | setHasEncryptionKey(false);
187 | return;
188 | }
189 | setHasEncryptionKey(true);
190 | document.getElementById("file-upload")?.click();
191 | };
192 |
193 | const handleFileChangeAndUpload = async (
194 | e: React.ChangeEvent
195 | ) => {
196 | if (!e.target.files || e.target.files.length === 0 || !userEmail) return;
197 |
198 | const filesToUpload = Array.from(e.target.files);
199 | e.target.value = "";
200 |
201 | setUploading(true);
202 | let successCount = 0;
203 |
204 | for (const file of filesToUpload) {
205 | const result = await uploadAndSyncFile(file, userEmail);
206 | if (result) successCount++;
207 | }
208 |
209 | setUploading(false);
210 |
211 | if (successCount > 0) {
212 | setRefreshFileListKey((prev) => prev + 1);
213 | setUserHasFiles(true);
214 | } else if (filesToUpload.length > 0) {
215 | // Optional: Show a summary error if *all* failed
216 | // toast.error("All file uploads failed.");
217 | }
218 | };
219 |
220 | const performDeleteAllFiles = async () => {
221 | if (!userEmail) return;
222 | setIsDeleting(true);
223 | const success = await deleteAllAndSyncFiles(userEmail);
224 | setIsDeleting(false);
225 | setShowDeleteConfirm(false);
226 | if (success) {
227 | setRefreshFileListKey((prev) => prev + 1);
228 | setUserHasFiles(false);
229 | }
230 | };
231 |
232 | const handleDeleteAllFiles = () => {
233 | if (!userEmail) return;
234 | setShowDeleteConfirm(true);
235 | };
236 |
237 | const usagePercentage = storageInfo
238 | ? storageInfo.total > 0
239 | ? (storageInfo.used / storageInfo.total) * 100
240 | : 0
241 | : 0;
242 |
243 | return (
244 |
245 |
267 |
268 | {!isLoadingUserFiles && userHasFiles ? (
269 | <>
270 |
271 | Manage your key first. Upload to encrypt files to Drive.{" "}
272 |
273 | Click file names below to download
274 | {" "}
275 | and decrypt them (requires your key).
276 |
277 |
278 | >
279 | ) : !isLoadingUserFiles ? (
280 |
281 | No files uploaded yet. Use 'Upload Files' in Quick Actions.
282 |
283 | ) : (
284 |
285 | Loading file status...
286 |
287 | )}
288 |
289 |
290 |
291 |
292 | Your Files
293 |
294 |
295 |
296 |
297 |
298 |
299 | Quick Actions
300 |
301 |
302 |
308 | {uploading ? "Uploading..." : "Upload Files"}
309 |
310 |
311 |
312 |
316 | Manage Encryption Keys
317 |
318 |
319 |
320 |
321 |
326 | {isLoadingStorage
327 | ? "Loading Storage..."
328 | : "View Storage Usage"}
329 |
330 |
331 |
332 | {storageInfo ? (
333 |
334 |
335 |
336 |
337 | Storage
338 |
339 |
340 | {formatBytes(storageInfo.used)} /{" "}
341 | {formatBytes(storageInfo.total)}
342 |
343 |
344 |
352 |
353 | ) : (
354 |
355 | Could not load storage info.
356 |
357 | )}
358 |
359 |
360 |
366 | {isDeleting ? "Deleting All Files..." : "Delete All Files"}
367 |
368 |
369 |
370 |
371 |
372 |
377 |
385 |
386 | );
387 | }
388 |
389 | export default PrivateStorage;
390 |
--------------------------------------------------------------------------------
/app/src/pages/terms.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import Header from "../components/landing-page/header";
3 | import Markdown from "markdown-to-jsx";
4 | import Footer from "../components/landing-page/footer";
5 | import { termsOfService } from "../components/terms-content";
6 |
7 | function Terms() {
8 | return (
9 |
10 |
11 |
12 | {termsOfService.map((section, index) => (
13 |
14 |
15 | {section.heading}
16 |
17 |
18 | {section.content}
19 |
20 |
21 | ))}
22 |
23 |
24 |
25 | );
26 | }
27 |
28 | export default Terms;
29 |
--------------------------------------------------------------------------------
/app/src/reportWebVitals.js:
--------------------------------------------------------------------------------
1 | const reportWebVitals = onPerfEntry => {
2 | if (onPerfEntry && onPerfEntry instanceof Function) {
3 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
4 | getCLS(onPerfEntry);
5 | getFID(onPerfEntry);
6 | getFCP(onPerfEntry);
7 | getLCP(onPerfEntry);
8 | getTTFB(onPerfEntry);
9 | });
10 | }
11 | };
12 |
13 | export default reportWebVitals;
14 |
--------------------------------------------------------------------------------
/app/src/setupTests.js:
--------------------------------------------------------------------------------
1 | // jest-dom adds custom jest matchers for asserting on DOM nodes.
2 | // allows you to do things like:
3 | // expect(element).toHaveTextContent(/react/i)
4 | // learn more: https://github.com/testing-library/jest-dom
5 | import '@testing-library/jest-dom';
6 |
--------------------------------------------------------------------------------
/app/src/types/oauth.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Represents an anonymous group where members can post messages without revealing their identity
3 | * Example: people in a company
4 | */
5 | export interface AnonGroup {
6 | /** Unique identifier for the group (e.g: company domain) */
7 | id: string;
8 | /** Display name of the group */
9 | title: string;
10 | /** URL to the group's logo image */
11 | logoUrl: string;
12 | }
13 |
14 | /**
15 | * Ephemeral key pair generated and stored in the browser's local storage
16 | * This key is used to sign messages.
17 | */
18 | export interface EphemeralKey {
19 | privateKey: bigint;
20 | publicKey: bigint;
21 | salt: bigint;
22 | expiry: Date;
23 | ephemeralPubkeyHash: bigint;
24 | }
25 |
26 | /**
27 | * Provider interface for generating and verifying ZK proofs of AnonGroup membership
28 | * Example: Google, Slack (for "people in a company")
29 | */
30 | export interface AnonGroupProvider {
31 | /** Get the provider's unique identifier */
32 | name(): string;
33 |
34 | /** Slug is a key that represents the type of the AnonGroup identifier (to be used in URLs). Example: "domain" */
35 | getSlug(): string;
36 |
37 | /**
38 | * Generate a ZK proof that the current user is a member of an AnonGroup
39 | * @param ephemeralPubkeyHash - Hash of the ephemeral pubkey, expiry and salt
40 | * @returns Returns the AnonGroup and membership proof, along with additional args that may be needed for verification
41 | */
42 | generateProof(ephemeralKey: EphemeralKey): Promise<{
43 | proof: Uint8Array;
44 | anonGroup: AnonGroup;
45 | proofArgs: object;
46 | }>;
47 |
48 | /**
49 | * Verify a ZK proof of group membership
50 | * @param proof - The ZK proof to verify
51 | * @param ephemeralPubkey - Pubkey modulus of the ephemeral key that was used when generating the proof
52 | * @param anonGroup - AnonGroup that the proof claims membership in
53 | * @param proofArgs - Additional args that was returned when the proof was generated
54 | * @returns Promise resolving to true if the proof is valid
55 | */
56 | verifyProof(
57 | proof: Uint8Array,
58 | anonGroupId: string,
59 | ephemeralPubkey: bigint,
60 | ephemeralPubkeyExpiry: Date,
61 | proofArgs: object
62 | ): Promise;
63 |
64 | /**
65 | * Get the AnonGroup by its unique identifier
66 | * @param groupId - Unique identifier for the AnonGroup
67 | * @returns Promise resolving to the AnonGroup
68 | */
69 | getAnonGroup(groupId: string): AnonGroup;
70 | }
71 |
72 | /**
73 | * Represents a message posted by an AnonGroup member
74 | */
75 | export interface Message {
76 | /** Unique identifier for the message */
77 | id: string;
78 | /** ID of the AnonGroup the corresponding user belongs to */
79 | anonGroupId: string;
80 | /** Name of the provider that generated the proof that the user (user's ephemeral pubkey) belongs to the AnonGroup */
81 | anonGroupProvider: string;
82 | /** Content of the message */
83 | text: string;
84 | /** Unix timestamp when the message was created */
85 | timestamp: Date;
86 | /** Whether this message is only visible to other members of the same AnonGroup */
87 | internal: boolean;
88 | /** Number of likes message received */
89 | likes: number;
90 | }
91 |
92 | export interface SignedMessage extends Message {
93 | /** Ed25519 signature of the message - signed by the user's ephemeral private key (in hex format) */
94 | signature: bigint;
95 | /** Ed25519 pubkey that can verify the signature */
96 | ephemeralPubkey: bigint;
97 | /** Expiry of the ephemeral pubkey */
98 | ephemeralPubkeyExpiry: Date;
99 | }
100 |
101 | export interface SignedMessageWithProof extends SignedMessage {
102 | /** ZK proof that the sender belongs to the AnonGroup */
103 | proof: Uint8Array;
104 | /** Additional args that was returned when the proof was generated */
105 | proofArgs: object;
106 | }
107 |
108 | export const LocalStorageKeys = {
109 | EphemeralKey: "ephemeralKey",
110 | CurrentGroupId: "currentGroupId",
111 | CurrentProvider: "currentProvider",
112 | GoogleOAuthState: "googleOAuthState",
113 | GoogleOAuthNonce: "googleOAuthNonce",
114 | DarkMode: "darkMode",
115 | HasSeenWelcomeMessage: "hasSeenWelcomeMessage",
116 | };
117 |
--------------------------------------------------------------------------------
/app/src/utils/cryptoUtils.ts:
--------------------------------------------------------------------------------
1 | import * as bip39 from "bip39";
2 |
3 | export const generateKey = async (): Promise => {
4 | return crypto.subtle.generateKey(
5 | {
6 | name: "AES-GCM",
7 | length: 256,
8 | },
9 | true,
10 | ["encrypt", "decrypt"]
11 | );
12 | };
13 |
14 | export const storeKey = async (key: CryptoKey) => {
15 | const keyJWK = await crypto.subtle.exportKey("jwk", key);
16 | localStorage.setItem("aes-gcm-key", JSON.stringify(keyJWK));
17 | };
18 |
19 | export const getStoredKey = async (): Promise => {
20 | const keyJWK = localStorage.getItem("aes-gcm-key");
21 | if (!keyJWK) return null;
22 | return crypto.subtle.importKey(
23 | "jwk",
24 | JSON.parse(keyJWK),
25 | { name: "AES-GCM" },
26 | true,
27 | ["encrypt", "decrypt"]
28 | );
29 | };
30 |
31 | export const clearStoredKey = () => {
32 | localStorage.removeItem("aes-gcm-key");
33 | };
34 |
35 | export const generateMnemonic = (): string => {
36 | return bip39.generateMnemonic(128);
37 | };
38 |
39 | export const deriveKeyFromMnemonic = async (
40 | mnemonic: string
41 | ): Promise => {
42 | if (!bip39.validateMnemonic(mnemonic)) {
43 | throw new Error("Invalid mnemonic phrase");
44 | }
45 | const seed = bip39.mnemonicToSeedSync(mnemonic);
46 | const keyMaterial = await crypto.subtle.digest("SHA-256", seed);
47 |
48 | return crypto.subtle.importKey(
49 | "raw",
50 | keyMaterial,
51 | { name: "AES-GCM" },
52 | true,
53 | ["encrypt", "decrypt"]
54 | );
55 | };
56 |
--------------------------------------------------------------------------------
/app/src/utils/decryptFile.ts:
--------------------------------------------------------------------------------
1 | export const decryptFile = async (fileBlob: Blob): Promise => {
2 | try {
3 | const storedKey = localStorage.getItem("aes-gcm-key");
4 | if (!storedKey) {
5 | throw new Error("No encryption key found in local storage");
6 | }
7 |
8 | let keyJWK;
9 | try {
10 | keyJWK = JSON.parse(storedKey);
11 | } catch (parseError) {
12 | throw new Error("Invalid encryption key format in local storage");
13 | }
14 |
15 | if (!keyJWK || !keyJWK.k || !keyJWK.kty || keyJWK.kty !== "oct") {
16 | throw new Error("Invalid encryption key format");
17 | }
18 |
19 | // Import the key
20 | let key;
21 | try {
22 | key = await crypto.subtle.importKey(
23 | "jwk",
24 | keyJWK,
25 | { name: "AES-GCM" },
26 | true,
27 | ["decrypt"]
28 | );
29 | } catch (keyImportError) {
30 | console.error("Key import error:", keyImportError);
31 | throw new Error(
32 | "Could not import encryption key: " + keyImportError.message
33 | );
34 | }
35 |
36 | const fileArrayBuffer = await fileBlob.arrayBuffer();
37 |
38 | // Check for minimum file size (IV + some encrypted data)
39 | if (fileArrayBuffer.byteLength < 13) {
40 | throw new Error("File is not properly encrypted (too small)");
41 | }
42 |
43 | const iv = new Uint8Array(12);
44 | iv.set(new Uint8Array(fileArrayBuffer.slice(0, 12)));
45 | const encryptedData = new Uint8Array(fileArrayBuffer.slice(12));
46 |
47 | // Attempt decryption
48 | try {
49 | const decryptedBuffer = await crypto.subtle.decrypt(
50 | {
51 | name: "AES-GCM",
52 | iv: iv,
53 | },
54 | key,
55 | encryptedData
56 | );
57 |
58 | return new Blob([decryptedBuffer]);
59 | } catch (decryptError) {
60 | console.error("Decryption operation error:", decryptError);
61 |
62 | // Check for specific error types
63 | if (decryptError.name === "OperationError") {
64 | throw new Error(
65 | "Decryption failed: the encryption key doesn't match the one used to encrypt this file"
66 | );
67 | } else {
68 | throw new Error("Decryption failed: " + decryptError.message);
69 | }
70 | }
71 | } catch (error) {
72 | console.error("Decryption error:", error);
73 | throw error; // Re-throw to be handled by the caller
74 | }
75 | };
76 |
--------------------------------------------------------------------------------
/app/src/utils/dexieDB.ts:
--------------------------------------------------------------------------------
1 | import Dexie from "dexie";
2 | import { gapi } from "gapi-script";
3 | import { toast } from "sonner";
4 | import { initializeGapi, refreshGapiToken } from "./gapiInit";
5 |
6 | export interface FileMeta {
7 | id: string;
8 | name: string;
9 | mimeType: string;
10 | userEmail: string;
11 | uploadedDate: Date;
12 | }
13 |
14 | const db = new Dexie("ZeroDriveDB");
15 |
16 | db.version(1).stores({
17 | files: "id, name, mimeType, userEmail, uploadedDate",
18 | });
19 |
20 | const addFile = async (file: FileMeta) => {
21 | return await db.table("files").add(file);
22 | };
23 |
24 | const getAllFilesForUser = async (userEmail: string): Promise => {
25 | return await db
26 | .table("files")
27 | .where("userEmail")
28 | .equals(userEmail)
29 | .toArray();
30 | };
31 |
32 | const getFileByIdForUser = async (id: string, userEmail: string) => {
33 | return await db.table("files").where({ id, userEmail }).first();
34 | };
35 |
36 | const deleteFileFromDB = async (fileId: string): Promise => {
37 | console.log(`[Dexie] Deleting file ${fileId} from local DB.`);
38 | return await db.table("files").where("id").equals(fileId).delete();
39 | };
40 |
41 | const clearUserFilesFromDB = async (userEmail: string): Promise => {
42 | console.log(
43 | `[Dexie] Clearing all files for user ${userEmail} from local DB.`
44 | );
45 | return await db.table("files").where("userEmail").equals(userEmail).delete();
46 | };
47 |
48 | const sendToGoogleDrive = async (filesToSync: FileMeta[]) => {
49 | let driveUpdateToastId: string | number | undefined;
50 | console.log(
51 | "[Sync] Starting metadata sync with Google Drive for:",
52 | filesToSync
53 | );
54 | try {
55 | driveUpdateToastId = toast.loading("Syncing metadata with Google Drive...");
56 |
57 | const authInstance = gapi.auth2.getAuthInstance();
58 | if (!authInstance || !authInstance.isSignedIn.get()) {
59 | throw new Error("User not authenticated for Google Drive update.");
60 | }
61 | const token = authInstance.currentUser.get().getAuthResponse().access_token;
62 | console.log("[Sync] Authentication token obtained.");
63 |
64 | const fileContent = JSON.stringify({ files: filesToSync });
65 | console.log("[Sync] Content to sync:", fileContent);
66 |
67 | const metadata = {
68 | name: "db-list.json",
69 | mimeType: "application/json",
70 | };
71 |
72 | console.log("[Sync] Searching for existing db-list.json...");
73 | const findResponse = await fetch(
74 | "https://www.googleapis.com/drive/v3/files?q=name='db-list.json' and trashed=false&fields=files(id)",
75 | {
76 | method: "GET",
77 | headers: { Authorization: `Bearer ${token}` },
78 | }
79 | );
80 |
81 | if (!findResponse.ok) {
82 | throw new Error(
83 | `[Sync Error] Failed to search for existing metadata file: ${findResponse.status} ${findResponse.statusText}`
84 | );
85 | }
86 |
87 | const existingFiles = await findResponse.json();
88 | let fileIdToUpdate: string | null = null;
89 |
90 | if (existingFiles.files && existingFiles.files.length > 0) {
91 | fileIdToUpdate = existingFiles.files[0].id;
92 | console.log(
93 | `[Sync] Found existing db-list.json with ID: ${fileIdToUpdate}`
94 | );
95 | } else {
96 | console.log("[Sync] No existing db-list.json found. Will create new.");
97 | }
98 |
99 | const blobContent = new Blob([fileContent], { type: "application/json" });
100 | const form = new FormData();
101 |
102 | let uploadUrl =
103 | "https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart";
104 | let method = "POST";
105 |
106 | if (fileIdToUpdate) {
107 | console.log("[Sync] Preparing PATCH request to update existing file.");
108 | uploadUrl = `https://www.googleapis.com/upload/drive/v3/files/${fileIdToUpdate}?uploadType=multipart`;
109 | method = "PATCH";
110 | form.append(
111 | "metadata",
112 | new Blob([JSON.stringify({})], { type: "application/json" })
113 | );
114 | } else {
115 | console.log("[Sync] Preparing POST request to create new file.");
116 | form.append(
117 | "metadata",
118 | new Blob([JSON.stringify(metadata)], { type: "application/json" })
119 | );
120 | }
121 |
122 | form.append("file", blobContent);
123 |
124 | console.log(`[Sync] Sending ${method} request to ${uploadUrl}`);
125 | const uploadResponse = await fetch(uploadUrl, {
126 | method: method,
127 | headers: { Authorization: `Bearer ${token}` },
128 | body: form,
129 | });
130 |
131 | console.log(`[Sync] Response Status: ${uploadResponse.status}`);
132 | const responseBodyText = await uploadResponse.text(); // Read body once
133 | console.log(`[Sync] Response Body: ${responseBodyText}`);
134 |
135 | if (!uploadResponse.ok) {
136 | throw new Error(
137 | `[Sync Error] Error ${
138 | method === "POST" ? "uploading" : "updating"
139 | } metadata file: ${uploadResponse.status} ${
140 | uploadResponse.statusText
141 | } - ${responseBodyText}`
142 | );
143 | }
144 |
145 | // Try parsing the response body as JSON for logging, but handle if it's not JSON
146 | let result = {};
147 | try {
148 | result = JSON.parse(responseBodyText);
149 | } catch (e) {
150 | console.warn("[Sync] Response body was not valid JSON.");
151 | }
152 |
153 | toast.success("Metadata successfully synchronized.", {
154 | id: driveUpdateToastId,
155 | });
156 | console.log("[Sync] Metadata sync successful. Result:", result);
157 | } catch (error) {
158 | console.error(
159 | "[Sync Error] Error synchronizing metadata with Google Drive:",
160 | error
161 | );
162 | toast.error("Failed to sync metadata with Google Drive", {
163 | description: error.message,
164 | id: driveUpdateToastId,
165 | });
166 | // IMPORTANT: Re-throw the error so the calling function knows it failed
167 | throw error;
168 | }
169 | };
170 |
171 | const fetchAndStoreFileMetadata = async () => {
172 | try {
173 | await initializeGapi();
174 |
175 | const response = await gapi.client.request({
176 | path: "https://www.googleapis.com/drive/v3/files",
177 | params: {
178 | q: "name='db-list.json' and trashed=false",
179 | fields: "files(id, name, modifiedTime)",
180 | },
181 | });
182 |
183 | const existingFiles = response.result.files;
184 |
185 | if (existingFiles && existingFiles.length > 0) {
186 | const fileId = existingFiles[0].id;
187 | const fileResponse = await gapi.client.request({
188 | path: `https://www.googleapis.com/drive/v3/files/${fileId}`,
189 | params: { alt: "media" },
190 | });
191 |
192 | // Ensure fileResponse.result is treated as JSON object
193 | let fileContent = fileResponse.result;
194 | // Sometimes GAPI might return a string that needs parsing
195 | if (typeof fileContent === "string") {
196 | try {
197 | fileContent = JSON.parse(fileContent);
198 | } catch (e) {
199 | console.error("Failed to parse db-list.json content", e);
200 | toast.error("Failed to read metadata file from Google Drive.");
201 | return; // Stop processing if content is invalid
202 | }
203 | }
204 |
205 | // Clear existing records before adding new ones
206 | await db.table("files").clear();
207 |
208 | if (
209 | fileContent &&
210 | fileContent.files &&
211 | Array.isArray(fileContent.files)
212 | ) {
213 | await Promise.all(
214 | fileContent.files.map(async (file: any) => {
215 | // Add type any temporarily or define a better interface
216 | // Add checks for essential properties
217 | if (
218 | !file ||
219 | !file.id ||
220 | !file.name ||
221 | !file.mimeType ||
222 | !file.userEmail ||
223 | !file.uploadedDate
224 | ) {
225 | console.warn(
226 | "Skipping invalid file entry from db-list.json:",
227 | file
228 | );
229 | return;
230 | }
231 | try {
232 | await addFile({
233 | id: file.id,
234 | name: file.name,
235 | mimeType: file.mimeType,
236 | userEmail: file.userEmail,
237 | uploadedDate: new Date(file.uploadedDate),
238 | });
239 | } catch (error) {
240 | console.error("Error adding file to IndexedDB:", error, file);
241 | }
242 | })
243 | );
244 | console.log("Files stored successfully in IndexedDB.");
245 | } else {
246 | console.log("db-list.json file content is empty or invalid.");
247 | // Even if the file is empty, the local DB is cleared, which is correct.
248 | }
249 | } else {
250 | console.log("No db-list.json file found in Google Drive.");
251 | // If no file exists on Drive, clear the local DB too?
252 | // Or maybe leave local DB as is if offline use is desired?
253 | // Current behavior: local DB is not cleared if Drive file doesn't exist.
254 | }
255 | } catch (error) {
256 | if (error.status === 401) {
257 | try {
258 | await refreshGapiToken();
259 | // Retry the request after token refresh
260 | await fetchAndStoreFileMetadata(); // Recursive call after token refresh
261 | } catch (refreshError) {
262 | console.error("Error after token refresh:", refreshError);
263 | window.location.href = "/";
264 | }
265 | } else {
266 | console.error("Error fetching file metadata:", error);
267 | throw error;
268 | }
269 | }
270 | };
271 |
272 | export {
273 | db,
274 | addFile,
275 | getAllFilesForUser,
276 | getFileByIdForUser,
277 | sendToGoogleDrive,
278 | fetchAndStoreFileMetadata,
279 | deleteFileFromDB,
280 | clearUserFilesFromDB,
281 | };
282 |
--------------------------------------------------------------------------------
/app/src/utils/encryptFile.ts:
--------------------------------------------------------------------------------
1 | import { getStoredKey } from "./cryptoUtils";
2 |
3 | export const encryptFile = async (file: File): Promise => {
4 | const key = await getStoredKey();
5 | if (!key) throw new Error("No encryption key found.");
6 |
7 | const iv = crypto.getRandomValues(new Uint8Array(12));
8 | const fileArrayBuffer = await file.arrayBuffer();
9 |
10 | const encryptedBuffer = await crypto.subtle.encrypt(
11 | {
12 | name: "AES-GCM",
13 | iv: iv,
14 | },
15 | key,
16 | fileArrayBuffer
17 | );
18 |
19 | const encryptedArray = new Uint8Array(
20 | iv.byteLength + encryptedBuffer.byteLength
21 | );
22 | encryptedArray.set(new Uint8Array(iv), 0);
23 | encryptedArray.set(new Uint8Array(encryptedBuffer), iv.byteLength);
24 |
25 | return new Blob([encryptedArray], { type: file.type });
26 | };
27 |
--------------------------------------------------------------------------------
/app/src/utils/fileOperations.ts:
--------------------------------------------------------------------------------
1 | import { gapi } from "gapi-script";
2 | import { toast } from "sonner";
3 | import {
4 | FileMeta,
5 | addFile,
6 | deleteFileFromDB,
7 | getAllFilesForUser,
8 | sendToGoogleDrive, // The function that updates db-list.json
9 | clearUserFilesFromDB, // Function to clear DB for a user
10 | } from "./dexieDB";
11 | import { encryptFile } from "./encryptFile";
12 | import { getStoredKey } from "./cryptoUtils";
13 |
14 | // --- Upload Operation ---
15 |
16 | /**
17 | * Encrypts, uploads a single file to Google Drive, adds its metadata to IndexedDB,
18 | * and triggers a sync of the full metadata list back to Google Drive.
19 | * @param file The file object to upload.
20 | * @param userEmail The email of the logged-in user.
21 | * @returns The FileMeta object if successful, null otherwise.
22 | */
23 | export const uploadAndSyncFile = async (
24 | file: File,
25 | userEmail: string
26 | ): Promise => {
27 | const uploadToastId = toast.loading(`Preparing ${file.name}...`);
28 |
29 | try {
30 | // 1. Check key
31 | const key = await getStoredKey();
32 | if (!key) {
33 | throw new Error("No encryption key found. Please manage keys.");
34 | }
35 |
36 | // 2. Check auth and get token
37 | const authInstance = gapi.auth2?.getAuthInstance();
38 | if (!authInstance || !authInstance.isSignedIn.get()) {
39 | throw new Error("User not authenticated.");
40 | }
41 | const token = authInstance.currentUser.get().getAuthResponse().access_token;
42 |
43 | // 3. Encrypt
44 | toast.loading(`Encrypting ${file.name}...`, { id: uploadToastId });
45 | const encryptedBlob = await encryptFile(file);
46 |
47 | // 4. Prepare metadata & form data
48 | const metadata = {
49 | name: file.name, // Drive uses this name
50 | mimeType: "application/octet-stream", // Store as generic binary
51 | // Optional: Use original mimeType if needed elsewhere, but store generically
52 | // properties: { originalMimeType: file.type }
53 | };
54 | const form = new FormData();
55 | form.append(
56 | "metadata",
57 | new Blob([JSON.stringify(metadata)], { type: "application/json" })
58 | );
59 | form.append("file", encryptedBlob);
60 |
61 | // 5. Upload to Google Drive
62 | toast.loading(`Uploading ${file.name} to Google Drive...`, {
63 | id: uploadToastId,
64 | });
65 | const response = await fetch(
66 | "https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&fields=id", // Only request ID
67 | {
68 | method: "POST",
69 | headers: new Headers({ Authorization: `Bearer ${token}` }),
70 | body: form,
71 | }
72 | );
73 |
74 | const data = await response.json();
75 |
76 | if (!response.ok || !data.id) {
77 | throw new Error(
78 | `Google Drive upload failed: ${
79 | data.error?.message || response.statusText
80 | }`
81 | );
82 | }
83 |
84 | toast.loading(`Saving metadata for ${file.name}...`, { id: uploadToastId });
85 |
86 | // 6. Add metadata to IndexedDB
87 | const newFileMeta: FileMeta = {
88 | id: data.id,
89 | name: file.name, // Store original name
90 | mimeType: file.type, // Store original mimeType
91 | userEmail: userEmail,
92 | uploadedDate: new Date(),
93 | };
94 | await addFile(newFileMeta);
95 |
96 | // 7. Get updated full list from IndexedDB
97 | const updatedList = await getAllFilesForUser(userEmail);
98 |
99 | // 8. Sync updated list to db-list.json on Google Drive
100 | await sendToGoogleDrive(updatedList); // This handles its own toasts
101 |
102 | toast.success(`Successfully uploaded and synced ${file.name}`, {
103 | id: uploadToastId,
104 | });
105 | return newFileMeta;
106 | } catch (error: any) {
107 | console.error(`[Upload Error - ${file.name}]:`, error);
108 | toast.error(`Failed to upload ${file.name}`, {
109 | description: error.message,
110 | id: uploadToastId,
111 | });
112 | return null;
113 | }
114 | };
115 |
116 | // --- Delete Operations ---
117 |
118 | /**
119 | * Deletes a file from Google Drive, removes it from IndexedDB,
120 | * and syncs the updated metadata list back to Google Drive.
121 | * @param fileId The Google Drive file ID.
122 | * @param fileName The name of the file (for logging/toast).
123 | * @param userEmail The email of the logged-in user.
124 | * @returns True if successful (including DB/sync), false otherwise.
125 | */
126 | export const deleteAndSyncFile = async (
127 | fileId: string,
128 | fileName: string, // Added for better feedback
129 | userEmail: string
130 | ): Promise => {
131 | const deleteToastId = toast.loading(`Deleting ${fileName}...`);
132 |
133 | try {
134 | // 1. Check auth and get token
135 | const authInstance = gapi.auth2?.getAuthInstance();
136 | if (!authInstance || !authInstance.isSignedIn.get()) {
137 | throw new Error("User not authenticated.");
138 | }
139 | const token = authInstance.currentUser.get().getAuthResponse().access_token;
140 |
141 | // 2. Attempt to delete from Google Drive
142 | toast.loading(`Deleting ${fileName} from Google Drive...`, {
143 | id: deleteToastId,
144 | });
145 | const response = await fetch(
146 | `https://www.googleapis.com/drive/v3/files/${fileId}`,
147 | {
148 | method: "DELETE",
149 | headers: new Headers({ Authorization: `Bearer ${token}` }),
150 | }
151 | );
152 |
153 | // 2a. Check response - 404 (Not Found) is OK, means it's already gone from Drive.
154 | if (!response.ok && response.status !== 404) {
155 | console.warn(
156 | `Google Drive delete failed (Status: ${response.status}): ${response.statusText}`
157 | );
158 | // Optionally throw error or just continue to ensure local DB is cleaned up
159 | // throw new Error(`Google Drive delete failed: ${response.statusText}`);
160 | toast.warning(
161 | `Could not delete ${fileName} from Google Drive (may already be deleted). Proceeding locally.`,
162 | { id: deleteToastId }
163 | );
164 | } else {
165 | toast.info(
166 | `Removed ${fileName} from Google Drive. Updating local data...`,
167 | { id: deleteToastId }
168 | );
169 | }
170 |
171 | // 3. Delete from IndexedDB
172 | await deleteFileFromDB(fileId);
173 |
174 | // 4. Get updated full list from IndexedDB
175 | const updatedList = await getAllFilesForUser(userEmail);
176 |
177 | // 5. Sync updated list to db-list.json on Google Drive
178 | await sendToGoogleDrive(updatedList); // This handles its own success/error toast for sync
179 |
180 | toast.success(`Successfully processed deletion for ${fileName}.`, {
181 | id: deleteToastId,
182 | });
183 | return true;
184 | } catch (error: any) {
185 | console.error(`[Delete Error - ${fileName}]:`, error);
186 | toast.error(`Failed to process deletion for ${fileName}`, {
187 | description: error.message,
188 | id: deleteToastId,
189 | });
190 | return false;
191 | }
192 | };
193 |
194 | /**
195 | * Deletes ALL files for a user from Google Drive, clears their IndexedDB records,
196 | * and syncs an empty list back to Google Drive.
197 | * @param userEmail The email of the logged-in user.
198 | * @returns True if successful (including DB/sync), false otherwise.
199 | */
200 | export const deleteAllAndSyncFiles = async (
201 | userEmail: string
202 | ): Promise => {
203 | const deleteToastId = toast.loading(`Fetching file list to delete...`);
204 |
205 | try {
206 | // 1. Get all file IDs for the user
207 | const allFiles = await getAllFilesForUser(userEmail);
208 | if (allFiles.length === 0) {
209 | toast.info("No files found to delete.", { id: deleteToastId });
210 | return true; // Nothing to do
211 | }
212 | const fileIds = allFiles.map((file) => file.id);
213 |
214 | toast.loading(`Deleting ${fileIds.length} files from Google Drive...`, {
215 | id: deleteToastId,
216 | });
217 |
218 | // 2. Check auth and get token (needed for Drive delete loop)
219 | const authInstance = gapi.auth2?.getAuthInstance();
220 | if (!authInstance || !authInstance.isSignedIn.get()) {
221 | throw new Error("User not authenticated.");
222 | }
223 | const token = authInstance.currentUser.get().getAuthResponse().access_token;
224 |
225 | // 3. Delete each file from Google Drive (best effort, ignore 404s)
226 | let driveDeleteFailures = 0;
227 | await Promise.all(
228 | fileIds.map(async (fileId) => {
229 | try {
230 | const response = await fetch(
231 | `https://www.googleapis.com/drive/v3/files/${fileId}`,
232 | {
233 | method: "DELETE",
234 | headers: new Headers({ Authorization: `Bearer ${token}` }),
235 | }
236 | );
237 | if (!response.ok && response.status !== 404) {
238 | console.warn(
239 | `Failed to delete file ${fileId} from Drive: ${response.statusText}`
240 | );
241 | driveDeleteFailures++;
242 | }
243 | } catch (driveError) {
244 | console.error(
245 | `Error deleting file ${fileId} from Drive:`,
246 | driveError
247 | );
248 | driveDeleteFailures++;
249 | }
250 | })
251 | );
252 |
253 | if (driveDeleteFailures > 0) {
254 | toast.warning(
255 | `Failed to delete ${driveDeleteFailures} file(s) from Google Drive (may already be deleted). Cleaning up locally.`,
256 | { id: deleteToastId }
257 | );
258 | } else {
259 | toast.info(`Removed files from Google Drive. Cleaning up locally...`, {
260 | id: deleteToastId,
261 | });
262 | }
263 |
264 | // 4. Clear all files for this user from IndexedDB
265 | await clearUserFilesFromDB(userEmail);
266 |
267 | // 5. Sync the (now empty) list to db-list.json on Google Drive
268 | await sendToGoogleDrive([]); // Send empty array
269 |
270 | toast.success(
271 | `Successfully deleted all ${fileIds.length} files and synced metadata.`,
272 | { id: deleteToastId }
273 | );
274 | return true;
275 | } catch (error: any) {
276 | console.error("[Delete All Error]:", error);
277 | toast.error("Failed to delete all files", {
278 | description: error.message,
279 | id: deleteToastId,
280 | });
281 | return false;
282 | }
283 | };
284 |
--------------------------------------------------------------------------------
/app/src/utils/gapiInit.ts:
--------------------------------------------------------------------------------
1 | import { gapi } from "gapi-script";
2 |
3 | const SCOPES = [
4 | "https://www.googleapis.com/auth/drive",
5 | "https://www.googleapis.com/auth/drive.file",
6 | "https://www.googleapis.com/auth/drive.appdata",
7 | "https://www.googleapis.com/auth/drive.metadata",
8 | "https://www.googleapis.com/auth/drive.metadata.readonly",
9 | "https://www.googleapis.com/auth/drive.readonly",
10 | ];
11 |
12 | export const initializeGapi = async () => {
13 | // Check if client is already initialized
14 | if (gapi.client && gapi.auth2?.getAuthInstance()) {
15 | const authInstance = gapi.auth2.getAuthInstance();
16 | if (authInstance.isSignedIn.get()) {
17 | // Refresh token if needed
18 | const currentUser = authInstance.currentUser.get();
19 | const token = currentUser.getAuthResponse().access_token;
20 | gapi.client.setToken({
21 | access_token: token,
22 | });
23 |
24 | // Set default headers for all requests
25 | gapi.client.setApiKey(process.env.REACT_APP_PUBLIC_API_KEY || "");
26 |
27 | return authInstance;
28 | }
29 | }
30 |
31 | try {
32 | await new Promise((resolve) => {
33 | gapi.load("client:auth2", resolve);
34 | });
35 |
36 | await gapi.client.init({
37 | apiKey: process.env.REACT_APP_PUBLIC_API_KEY,
38 | clientId: process.env.REACT_APP_PUBLIC_CLIENT_ID,
39 | scope: SCOPES.join(" "),
40 | discoveryDocs: [
41 | "https://www.googleapis.com/discovery/v1/apis/drive/v3/rest",
42 | ],
43 | });
44 |
45 | const authInstance = gapi.auth2.getAuthInstance();
46 | const isSignedIn = authInstance.isSignedIn.get();
47 |
48 | if (!isSignedIn) {
49 | // Only sign in if not already signed in
50 | await authInstance.signIn({
51 | prompt: "select_account",
52 | scope: SCOPES.join(" "),
53 | });
54 | }
55 |
56 | const currentUser = authInstance.currentUser.get();
57 | const token = currentUser.getAuthResponse().access_token;
58 |
59 | // Set token and API key for all requests
60 | gapi.client.setToken({
61 | access_token: token,
62 | });
63 | gapi.client.setApiKey(process.env.REACT_APP_PUBLIC_API_KEY || "");
64 |
65 | return authInstance;
66 | } catch (error) {
67 | console.error("Error initializing GAPI:", error);
68 | throw error;
69 | }
70 | };
71 |
72 | // Helper function to refresh token
73 | export const refreshGapiToken = async () => {
74 | try {
75 | const authInstance = gapi.auth2.getAuthInstance();
76 | if (authInstance) {
77 | const currentUser = authInstance.currentUser.get();
78 | await currentUser.reloadAuthResponse();
79 | const token = currentUser.getAuthResponse().access_token;
80 | gapi.client.setToken({
81 | access_token: token,
82 | });
83 | }
84 | } catch (error) {
85 | console.error("Error refreshing token:", error);
86 | throw error;
87 | }
88 | };
89 |
--------------------------------------------------------------------------------
/app/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | darkMode: ["class"],
4 | content: [
5 | "./pages/**/*.{ts,tsx}",
6 | "./components/**/*.{ts,tsx}",
7 | "./app/**/*.{ts,tsx}",
8 | "./src/**/*.{ts,tsx}",
9 | ],
10 | prefix: "",
11 | theme: {
12 | extend: {
13 | colors: {
14 | border: "hsl(var(--border))",
15 | input: "hsl(var(--input))",
16 | ring: "hsl(var(--ring))",
17 | background: "hsl(var(--background))",
18 | foreground: "hsl(var(--foreground))",
19 | primary: {
20 | DEFAULT: "hsl(var(--primary))",
21 | foreground: "hsl(var(--primary-foreground))",
22 | },
23 | secondary: {
24 | DEFAULT: "hsl(var(--secondary))",
25 | foreground: "hsl(var(--secondary-foreground))",
26 | },
27 | destructive: {
28 | DEFAULT: "hsl(var(--destructive))",
29 | foreground: "hsl(var(--destructive-foreground))",
30 | },
31 | muted: {
32 | DEFAULT: "hsl(var(--muted))",
33 | foreground: "hsl(var(--muted-foreground))",
34 | },
35 | accent: {
36 | DEFAULT: "hsl(var(--accent))",
37 | foreground: "hsl(var(--accent-foreground))",
38 | },
39 | popover: {
40 | DEFAULT: "hsl(var(--popover))",
41 | foreground: "hsl(var(--popover-foreground))",
42 | },
43 | card: {
44 | DEFAULT: "hsl(var(--card))",
45 | foreground: "hsl(var(--card-foreground))",
46 | },
47 | },
48 | borderRadius: {
49 | lg: "var(--radius)",
50 | md: "calc(var(--radius) - 2px)",
51 | sm: "calc(var(--radius) - 4px)",
52 | },
53 | keyframes: {
54 | "accordion-down": {
55 | from: { height: "0" },
56 | to: { height: "var(--radix-accordion-content-height)" },
57 | },
58 | "accordion-up": {
59 | from: { height: "var(--radix-accordion-content-height)" },
60 | to: { height: "0" },
61 | },
62 | float: {
63 | "0%, 100%": { transform: "translateY(0)" },
64 | "50%": { transform: "translateY(-20px)" },
65 | },
66 | },
67 | animation: {
68 | "accordion-down": "accordion-down 0.2s ease-out",
69 | "accordion-up": "accordion-up 0.2s ease-out",
70 | "float-1": "float 3s ease-in-out infinite",
71 | "float-2": "float 3s ease-in-out infinite 0.5s",
72 | "float-3": "float 3s ease-in-out infinite 1s",
73 | },
74 | },
75 | },
76 | plugins: [require("tailwindcss-animate")],
77 | };
78 |
--------------------------------------------------------------------------------
/app/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "allowSyntheticDefaultImports": true,
4 | "jsx": "react-jsx",
5 | "baseUrl": ".",
6 | "paths": {
7 | "@/*": ["src/*"]
8 | }
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/circuits/Nargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "zerodrive_zk"
3 | type = "bin"
4 | authors = ["shahad"]
5 |
6 | [dependencies]
7 | jwt = { tag = "v0.4.4", git = "https://github.com/saleel/noir-jwt" }
--------------------------------------------------------------------------------
/circuits/build.sh:
--------------------------------------------------------------------------------
1 | # Extract version from Nargo.toml
2 | rm -rf target
3 |
4 | echo "Compiling circuit..."
5 | if ! nargo compile; then
6 | echo "Compilation failed. Exiting..."
7 | exit 1
8 | fi
9 |
10 | echo "Gate count:"
11 | bb gates -b target/stealthnote_jwt.json | jq '.functions[0].circuit_size'
12 |
13 | # Create version-specific directory
14 | mkdir -p "../app/assets/jwt"
15 |
16 | echo "Copying circuit.json to app/assets/jwt..."
17 | cp target/stealthnote_jwt.json "../app/assets/jwt/circuit.json"
18 |
19 | echo "Generating vkey..."
20 | bb write_vk -b ./target/stealthnote_jwt.json -o ./target
21 |
22 | echo "Generating vkey.json to app/assets/jwt..."
23 | node -e "const fs = require('fs'); fs.writeFileSync('../app/assets/jwt/circuit-vkey.json', JSON.stringify(Array.from(Uint8Array.from(fs.readFileSync('./target/vk')))));"
24 |
25 | echo "Done"
--------------------------------------------------------------------------------
/circuits/src/main.nr:
--------------------------------------------------------------------------------
1 | use jwt::JWT;
2 | use std::hash::poseidon2::Poseidon2;
3 |
4 | global MAX_PARTIAL_DATA_LENGTH: u32 = 640; // signed data length after partial SHA
5 | global MAX_DOMAIN_LENGTH: u32 = 64;
6 | global MAX_EMAIL_LENGTH: u32 = 128;
7 | global NONCE_LENGTH: u32 = 77;
8 |
9 | /**
10 | * @brief Verify JWT signature (RSA/SHA256 only) and validate hd and nonce fields
11 | *
12 | * @param partial_data: OIDC JWT (id_token) base64 data (`$header.$payload`) as byte array of ascii characters
13 | * We do partial SHA on the data up to hd field outside of the circuit, to reduce constraints
14 | * This field only contains the bytes after partial SHA; padded to MAX_PARTIAL_DATA_LENGTH
15 | * @param partial_hash: The 256-bit partial hash of the `data`
16 | * @param full_data_length: The full length of the `data` (before partial SHA)
17 | * @param b64_offset: Offset needed in `partial_data` to make the payloadB64 a multiple of 4
18 | * Signed data is $header.$payload. $payload might not be a multiple of 4 in `partial_data`, so we need to offset
19 | * Not attack-able by giving an incorrect offset, as string match of hd and nonce will fail
20 | * @param jwt_pubkey_modulus_limbs: RSA public key modulus limbs (2048-bit split into 18 limbs)
21 | * @param jwt_pubkey_redc_params_limbs: RSA reduction parameters limbs
22 | * @param jwt_signature_limbs: RSA signature limbs
23 | * @param domain: Domain name (`hd` key) as a byte array of ascii characters padded to MAX_DOMAIN_LENGTH
24 | * @param ephemeral_pubkey: Public key of the ephemeral keypair that is used to sign messages
25 | * @param ephemeral_pubkey_salt: Salt of the ephemeral keypair that is used to sign messages
26 | * @param ephemeral_pubkey_expiry: Expiry of the ephemeral keypair that is used to sign messages
27 | * @param nonce: JWT `nonce` as a byte array of ascii characters - 32 bytes
28 | **/
29 | fn main(
30 | partial_data: BoundedVec,
31 | partial_hash: [u32; 8],
32 | full_data_length: u32,
33 | base64_decode_offset: u32,
34 | jwt_pubkey_modulus_limbs: pub [u128; 18],
35 | jwt_pubkey_redc_params_limbs: [u128; 18],
36 | jwt_signature_limbs: [u128; 18],
37 | domain: pub BoundedVec,
38 | ephemeral_pubkey: pub Field,
39 | ephemeral_pubkey_salt: Field,
40 | ephemeral_pubkey_expiry: pub u32,
41 | ) {
42 | // Init JWT struct and verify signature
43 | let jwt = JWT::init_with_partial_hash(
44 | partial_data,
45 | partial_hash,
46 | full_data_length,
47 | base64_decode_offset,
48 | jwt_pubkey_modulus_limbs,
49 | jwt_pubkey_redc_params_limbs,
50 | jwt_signature_limbs,
51 | );
52 | jwt.verify();
53 |
54 | // Get nonce claim
55 | let nonce: BoundedVec = jwt.get_claim_string("nonce".as_bytes());
56 | let nonce_field: Field = decimal_string_to_field(nonce.storage());
57 |
58 | // Verify nonce is the hash(ephemeral_pubkey, ephemeral_pubkey_salt, ephemeral_pubkey_expiry)
59 | let ephemeral_pubkey_hash = Poseidon2::hash(
60 | [ephemeral_pubkey, ephemeral_pubkey_salt, ephemeral_pubkey_expiry as Field],
61 | 3,
62 | );
63 |
64 | assert(nonce_field == ephemeral_pubkey_hash, "invalid nonce");
65 |
66 | // Assert email_verified claim
67 | jwt.assert_claim_bool("email_verified".as_bytes(), true);
68 |
69 | // Get email claim
70 | let email: BoundedVec = jwt.get_claim_string("email".as_bytes());
71 |
72 | // Get domain start_index from email claim - unconstrained, but we verify the domain bytes below
73 | let domain_start_index = unsafe { get_domain_start_index_in_email(email) };
74 |
75 | // Verify domain passed is present in the email claim after the @
76 | assert(email.storage()[domain_start_index - 1] == 64, "char before domain is not '@'");
77 | for i in 0..MAX_DOMAIN_LENGTH {
78 | assert(email.storage()[domain_start_index + i] == domain.storage()[i], "invalid domain");
79 | }
80 | }
81 |
82 | fn decimal_string_to_field(decimal_bytes: [u8; LEN]) -> Field {
83 | assert(LEN <= 77);
84 |
85 | let mut field: Field = 0;
86 | let mut multiplier: Field = 1;
87 |
88 | for i in 0..LEN {
89 | let ascii_char = decimal_bytes[LEN - i - 1];
90 | if ascii_char >= 48 & ascii_char <= 57 {
91 | let digit = ascii_char as Field - 48;
92 | field += digit * multiplier;
93 | multiplier *= 10;
94 | }
95 | }
96 |
97 | field
98 | }
99 |
100 | unconstrained fn get_domain_start_index_in_email(email: BoundedVec) -> u32 {
101 | let mut domain_start_index = 0;
102 | for i in 0..MAX_EMAIL_LENGTH {
103 | if email.storage()[i] == ("@".as_bytes())[0] {
104 | domain_start_index = i + 1;
105 | break;
106 | }
107 | }
108 |
109 | domain_start_index
110 | }
--------------------------------------------------------------------------------
/circuits/target/vk:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/shahadpichen/zerodrive/a335476c4e6cd4b4d99addc81cc822eb5a7a3a6d/circuits/target/vk
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": false,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.js",
8 | "css": "src/index.css",
9 | "baseColor": "zinc",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils"
16 | }
17 | }
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | darkMode: ["class"],
4 | content: [
5 | "./pages/**/*.{ts,tsx}",
6 | "./components/**/*.{ts,tsx}",
7 | "./app/**/*.{ts,tsx}",
8 | "./src/**/*.{ts,tsx}",
9 | ],
10 | prefix: "",
11 | theme: {
12 | extend: {
13 | colors: {
14 | border: "hsl(var(--border))",
15 | input: "hsl(var(--input))",
16 | ring: "hsl(var(--ring))",
17 | background: "hsl(var(--background))",
18 | foreground: "hsl(var(--foreground))",
19 | primary: {
20 | DEFAULT: "hsl(var(--primary))",
21 | foreground: "hsl(var(--primary-foreground))",
22 | },
23 | secondary: {
24 | DEFAULT: "hsl(var(--secondary))",
25 | foreground: "hsl(var(--secondary-foreground))",
26 | },
27 | destructive: {
28 | DEFAULT: "hsl(var(--destructive))",
29 | foreground: "hsl(var(--destructive-foreground))",
30 | },
31 | muted: {
32 | DEFAULT: "hsl(var(--muted))",
33 | foreground: "hsl(var(--muted-foreground))",
34 | },
35 | accent: {
36 | DEFAULT: "hsl(var(--accent))",
37 | foreground: "hsl(var(--accent-foreground))",
38 | },
39 | popover: {
40 | DEFAULT: "hsl(var(--popover))",
41 | foreground: "hsl(var(--popover-foreground))",
42 | },
43 | card: {
44 | DEFAULT: "hsl(var(--card))",
45 | foreground: "hsl(var(--card-foreground))",
46 | },
47 | },
48 | borderRadius: {
49 | lg: "var(--radius)",
50 | md: "calc(var(--radius) - 2px)",
51 | sm: "calc(var(--radius) - 4px)",
52 | },
53 | keyframes: {
54 | "accordion-down": {
55 | from: { height: "0" },
56 | to: { height: "var(--radix-accordion-content-height)" },
57 | },
58 | "accordion-up": {
59 | from: { height: "var(--radix-accordion-content-height)" },
60 | to: { height: "0" },
61 | },
62 | float: {
63 | "0%, 100%": { transform: "translateY(0)" },
64 | "50%": { transform: "translateY(-20px)" },
65 | },
66 | },
67 | animation: {
68 | "accordion-down": "accordion-down 0.2s ease-out",
69 | "accordion-up": "accordion-up 0.2s ease-out",
70 | "float-1": "float 3s ease-in-out infinite",
71 | "float-2": "float 3s ease-in-out infinite 0.5s",
72 | "float-3": "float 3s ease-in-out infinite 1s",
73 | },
74 | },
75 | },
76 | plugins: [require("tailwindcss-animate")],
77 | };
78 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "allowSyntheticDefaultImports": true,
4 | "jsx": "react",
5 | "baseUrl": ".",
6 | "paths": {
7 | "@/*": ["src/*"]
8 | }
9 | }
10 | }
11 |
--------------------------------------------------------------------------------