Add encryption key management features
This commit is contained in:
parent
635cde021b
commit
2c0a5bd9a7
|
|
@ -7,6 +7,7 @@
|
|||
"dependencies": {
|
||||
"@headlessui/react": "^2.2.0",
|
||||
"@inertiajs/react": "^2.1.4",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-avatar": "^1.1.3",
|
||||
"@radix-ui/react-checkbox": "^1.1.4",
|
||||
"@radix-ui/react-collapsible": "^1.1.3",
|
||||
|
|
@ -1098,6 +1099,34 @@
|
|||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
||||
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="
|
||||
},
|
||||
"node_modules/@radix-ui/react-alert-dialog": {
|
||||
"version": "1.1.15",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz",
|
||||
"integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-dialog": "1.1.15",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-arrow": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@
|
|||
"dependencies": {
|
||||
"@headlessui/react": "^2.2.0",
|
||||
"@inertiajs/react": "^2.1.4",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-avatar": "^1.1.3",
|
||||
"@radix-ui/react-checkbox": "^1.1.4",
|
||||
"@radix-ui/react-collapsible": "^1.1.3",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { createInertiaApp } from '@inertiajs/react';
|
|||
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { EncryptionKeyProvider } from './contexts/encryption-key-context';
|
||||
import { initializeTheme } from './hooks/use-appearance';
|
||||
|
||||
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
|
||||
|
|
@ -20,7 +21,9 @@ createInertiaApp({
|
|||
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<App {...props} />
|
||||
<EncryptionKeyProvider>
|
||||
<App {...props} />
|
||||
</EncryptionKeyProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Breadcrumbs } from '@/components/breadcrumbs';
|
||||
import { EncryptionKeyButton } from '@/components/encryption-key-button';
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||
import { type BreadcrumbItem as BreadcrumbItemType } from '@/types';
|
||||
|
||||
|
|
@ -8,11 +9,12 @@ export function AppSidebarHeader({
|
|||
breadcrumbs?: BreadcrumbItemType[];
|
||||
}) {
|
||||
return (
|
||||
<header className="flex h-16 shrink-0 items-center gap-2 border-b border-sidebar-border/50 px-6 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12 md:px-4">
|
||||
<header className="flex h-16 shrink-0 items-center justify-between gap-2 border-b border-sidebar-border/50 px-6 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12 md:px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Breadcrumbs breadcrumbs={breadcrumbs} />
|
||||
</div>
|
||||
<EncryptionKeyButton />
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,122 @@
|
|||
import { useEncryptionKey } from '@/contexts/encryption-key-context';
|
||||
import { LockKeyhole, LockKeyholeOpen } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './ui/dialog';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from './ui/tooltip';
|
||||
import UnlockMessageDialog from './unlock-message-dialog';
|
||||
|
||||
export function EncryptionKeyButton() {
|
||||
const {
|
||||
isKeySet,
|
||||
encryptedMessageData,
|
||||
fetchEncryptedMessage,
|
||||
clearEncryptionKey,
|
||||
} = useEncryptionKey();
|
||||
const [showUnlockDialog, setShowUnlockDialog] = useState(false);
|
||||
const [showClearDialog, setShowClearDialog] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!encryptedMessageData) {
|
||||
fetchEncryptedMessage();
|
||||
}
|
||||
}, []);
|
||||
|
||||
function handleClick() {
|
||||
if (isKeySet) {
|
||||
setShowClearDialog(true);
|
||||
} else {
|
||||
if (!encryptedMessageData) {
|
||||
fetchEncryptedMessage();
|
||||
}
|
||||
setShowUnlockDialog(true);
|
||||
}
|
||||
}
|
||||
|
||||
function handleClearKey() {
|
||||
clearEncryptionKey();
|
||||
setShowClearDialog(false);
|
||||
}
|
||||
|
||||
function handleUnlock() {
|
||||
setShowUnlockDialog(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
onClick={handleClick}
|
||||
aria-label={
|
||||
isKeySet
|
||||
? 'Lock encryption key'
|
||||
: 'Unlock encryption key'
|
||||
}
|
||||
>
|
||||
{isKeySet ? (
|
||||
<LockKeyhole className="h-5 w-5" />
|
||||
) : (
|
||||
<LockKeyholeOpen className="h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isKeySet
|
||||
? 'Click to lock encryption key'
|
||||
: 'Click to unlock encryption key'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
{encryptedMessageData && (
|
||||
<UnlockMessageDialog
|
||||
open={showUnlockDialog}
|
||||
onOpenChange={setShowUnlockDialog}
|
||||
onUnlock={handleUnlock}
|
||||
encryptedContent={encryptedMessageData.encrypted_content}
|
||||
iv={encryptedMessageData.iv}
|
||||
salt={encryptedMessageData.salt}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Dialog open={showClearDialog} onOpenChange={setShowClearDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Clear Encryption Key?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will remove your encryption key from this
|
||||
browser session. You'll need to enter your password
|
||||
again to unlock encrypted content.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowClearDialog(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleClearKey}>Clear Key</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,5 @@
|
|||
import { useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import InputError from '@/components/input-error';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -21,6 +19,7 @@ import {
|
|||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { useEncryptionKey } from '@/contexts/encryption-key-context';
|
||||
import {
|
||||
base64ToBuffer,
|
||||
decrypt,
|
||||
|
|
@ -48,6 +47,7 @@ export default function UnlockMessageDialog({
|
|||
iv,
|
||||
salt,
|
||||
}: UnlockMessageDialogProps) {
|
||||
const { refreshKeyState } = useEncryptionKey();
|
||||
const [password, setPassword] = useState('');
|
||||
const [storagePreference, setStoragePreference] = useState<
|
||||
'session' | 'persistent'
|
||||
|
|
@ -55,6 +55,13 @@ export default function UnlockMessageDialog({
|
|||
const [processing, setProcessing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setPassword('');
|
||||
setError(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
async function handleUnlock(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
|
@ -78,14 +85,17 @@ export default function UnlockMessageDialog({
|
|||
if (!storedKey && password) {
|
||||
const exportedKey = await exportKey(aesKey);
|
||||
storeKey(exportedKey, storagePreference === 'persistent');
|
||||
refreshKeyState();
|
||||
}
|
||||
|
||||
onUnlock(decrypted);
|
||||
setPassword('');
|
||||
setError(null);
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
console.error('Decryption error:', err);
|
||||
setError(
|
||||
'Failed to decrypt message. Please check your password and try again.'
|
||||
'Failed to decrypt message. Please check your password and try again.',
|
||||
);
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
|
|
@ -128,7 +138,7 @@ export default function UnlockMessageDialog({
|
|||
value={storagePreference}
|
||||
onValueChange={(value) =>
|
||||
setStoragePreference(
|
||||
value as 'session' | 'persistent'
|
||||
value as 'session' | 'persistent',
|
||||
)
|
||||
}
|
||||
disabled={processing}
|
||||
|
|
@ -164,4 +174,3 @@ export default function UnlockMessageDialog({
|
|||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
import { clearKey, getStoredKey } from '@/lib/key-storage';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
interface EncryptedMessageData {
|
||||
encrypted_content: string;
|
||||
iv: string;
|
||||
salt: string;
|
||||
}
|
||||
|
||||
interface EncryptionKeyContextType {
|
||||
isKeySet: boolean;
|
||||
refreshKeyState: () => void;
|
||||
encryptedMessageData: EncryptedMessageData | null;
|
||||
fetchEncryptedMessage: () => Promise<void>;
|
||||
clearEncryptionKey: () => void;
|
||||
}
|
||||
|
||||
const EncryptionKeyContext = createContext<
|
||||
EncryptionKeyContextType | undefined
|
||||
>(undefined);
|
||||
|
||||
export function EncryptionKeyProvider({ children }: { children: ReactNode }) {
|
||||
const [isKeySet, setIsKeySet] = useState(() => {
|
||||
const key = getStoredKey();
|
||||
return !!key;
|
||||
});
|
||||
const [encryptedMessageData, setEncryptedMessageData] =
|
||||
useState<EncryptedMessageData | null>(null);
|
||||
|
||||
function refreshKeyState() {
|
||||
const key = getStoredKey();
|
||||
setIsKeySet(!!key);
|
||||
}
|
||||
|
||||
async function fetchEncryptedMessage() {
|
||||
try {
|
||||
const response = await axios.get<EncryptedMessageData>(
|
||||
'/api/encryption/message',
|
||||
);
|
||||
setEncryptedMessageData(response.data);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch encrypted message:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function clearEncryptionKey() {
|
||||
clearKey();
|
||||
refreshKeyState();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
const key = getStoredKey();
|
||||
setIsKeySet(!!key);
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<EncryptionKeyContext.Provider
|
||||
value={{
|
||||
isKeySet,
|
||||
refreshKeyState,
|
||||
encryptedMessageData,
|
||||
fetchEncryptedMessage,
|
||||
clearEncryptionKey,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</EncryptionKeyContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useEncryptionKey() {
|
||||
const context = useContext(EncryptionKeyContext);
|
||||
if (context === undefined) {
|
||||
throw new Error(
|
||||
'useEncryptionKey must be used within an EncryptionKeyProvider',
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { Head, router } from '@inertiajs/react';
|
||||
import axios from 'axios';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import InputError from '@/components/input-error';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
|
@ -14,6 +14,7 @@ import {
|
|||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { useEncryptionKey } from '@/contexts/encryption-key-context';
|
||||
import AuthLayout from '@/layouts/auth-layout';
|
||||
import {
|
||||
bufferToBase64,
|
||||
|
|
@ -27,6 +28,7 @@ import { storeKey } from '@/lib/key-storage';
|
|||
import { dashboard } from '@/routes';
|
||||
|
||||
export default function SetupEncryption() {
|
||||
const { refreshKeyState } = useEncryptionKey();
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [storagePreference, setStoragePreference] = useState<
|
||||
|
|
@ -88,6 +90,7 @@ export default function SetupEncryption() {
|
|||
});
|
||||
|
||||
storeKey(exportedKey, storagePreference === 'persistent');
|
||||
refreshKeyState();
|
||||
|
||||
router.visit(dashboard().url);
|
||||
} catch (error) {
|
||||
|
|
@ -158,7 +161,7 @@ export default function SetupEncryption() {
|
|||
value={storagePreference}
|
||||
onValueChange={(value) =>
|
||||
setStoragePreference(
|
||||
value as 'session' | 'persistent'
|
||||
value as 'session' | 'persistent',
|
||||
)
|
||||
}
|
||||
disabled={processing}
|
||||
|
|
@ -202,4 +205,3 @@ export default function SetupEncryption() {
|
|||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { Head } from '@inertiajs/react';
|
||||
import axios from 'axios';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import UnlockMessageDialog from '@/components/unlock-message-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { PlaceholderPattern } from '@/components/ui/placeholder-pattern';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import UnlockMessageDialog from '@/components/unlock-message-dialog';
|
||||
import { useEncryptionKey } from '@/contexts/encryption-key-context';
|
||||
import AppLayout from '@/layouts/app-layout';
|
||||
import { base64ToBuffer, decrypt, importKey } from '@/lib/crypto';
|
||||
import { clearKey, getStoredKey } from '@/lib/key-storage';
|
||||
import { decrypt, importKey } from '@/lib/crypto';
|
||||
import { getStoredKey } from '@/lib/key-storage';
|
||||
import { dashboard } from '@/routes';
|
||||
import { type BreadcrumbItem } from '@/types';
|
||||
|
||||
|
|
@ -27,11 +28,12 @@ interface EncryptedMessageData {
|
|||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const { clearEncryptionKey, isKeySet } = useEncryptionKey();
|
||||
const [messageData, setMessageData] = useState<EncryptedMessageData | null>(
|
||||
null
|
||||
null,
|
||||
);
|
||||
const [decryptedMessage, setDecryptedMessage] = useState<string | null>(
|
||||
null
|
||||
null,
|
||||
);
|
||||
const [showUnlockDialog, setShowUnlockDialog] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
|
@ -41,13 +43,21 @@ export default function Dashboard() {
|
|||
fetchAndDecryptMessage();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isKeySet && decryptedMessage) {
|
||||
setDecryptedMessage(null);
|
||||
} else if (isKeySet && !decryptedMessage && messageData) {
|
||||
attemptAutoDecrypt();
|
||||
}
|
||||
}, [isKeySet]);
|
||||
|
||||
async function fetchAndDecryptMessage() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await axios.get<EncryptedMessageData>(
|
||||
'/api/encryption/message'
|
||||
'/api/encryption/message',
|
||||
);
|
||||
const data = response.data;
|
||||
setMessageData(data);
|
||||
|
|
@ -60,17 +70,19 @@ export default function Dashboard() {
|
|||
const decrypted = await decrypt(
|
||||
data.encrypted_content,
|
||||
aesKey,
|
||||
data.iv
|
||||
data.iv,
|
||||
);
|
||||
setDecryptedMessage(decrypted);
|
||||
} catch (err) {
|
||||
console.error('Auto-decrypt failed:', err);
|
||||
clearKey();
|
||||
clearEncryptionKey();
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.response?.status === 404) {
|
||||
setError('No encrypted message found. Please set up encryption first.');
|
||||
setError(
|
||||
'No encrypted message found. Please set up encryption first.',
|
||||
);
|
||||
} else {
|
||||
console.error('Fetch error:', err);
|
||||
setError('Failed to load encrypted message.');
|
||||
|
|
@ -80,12 +92,32 @@ export default function Dashboard() {
|
|||
}
|
||||
}
|
||||
|
||||
async function attemptAutoDecrypt() {
|
||||
if (!messageData) return;
|
||||
|
||||
const storedKey = getStoredKey();
|
||||
if (!storedKey) return;
|
||||
|
||||
try {
|
||||
const aesKey = await importKey(storedKey);
|
||||
const decrypted = await decrypt(
|
||||
messageData.encrypted_content,
|
||||
aesKey,
|
||||
messageData.iv,
|
||||
);
|
||||
setDecryptedMessage(decrypted);
|
||||
} catch (err) {
|
||||
console.error('Auto-decrypt failed:', err);
|
||||
clearEncryptionKey();
|
||||
}
|
||||
}
|
||||
|
||||
function handleUnlock(message: string) {
|
||||
setDecryptedMessage(message);
|
||||
}
|
||||
|
||||
function handleLock() {
|
||||
clearKey();
|
||||
clearEncryptionKey();
|
||||
setDecryptedMessage(null);
|
||||
}
|
||||
|
||||
|
|
@ -123,10 +155,10 @@ export default function Dashboard() {
|
|||
</div>
|
||||
) : messageData ? (
|
||||
<div className="space-y-4">
|
||||
<p className="break-all text-sm font-mono text-muted-foreground">
|
||||
<p className="font-mono text-sm break-all text-muted-foreground">
|
||||
{messageData.encrypted_content.substring(
|
||||
0,
|
||||
100
|
||||
100,
|
||||
)}
|
||||
...
|
||||
</p>
|
||||
|
|
|
|||
Loading…
Reference in New Issue