Add encryption key management features

This commit is contained in:
Víctor Falcón 2025-11-07 16:10:56 +00:00
parent 635cde021b
commit 2c0a5bd9a7
9 changed files with 314 additions and 24 deletions

29
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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