E2E Encryption
This commit is contained in:
parent
a9217b4efe
commit
292297f483
|
|
@ -0,0 +1,75 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\SetupEncryptionRequest;
|
||||
use App\Models\EncryptedMessage;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class EncryptionController extends Controller
|
||||
{
|
||||
public function setup(SetupEncryptionRequest $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$user->update([
|
||||
'encryption_salt' => $request->validated('salt'),
|
||||
]);
|
||||
|
||||
EncryptedMessage::query()->updateOrCreate(
|
||||
['user_id' => $user->id],
|
||||
[
|
||||
'encrypted_content' => $request->validated('encrypted_content'),
|
||||
'iv' => $request->validated('iv'),
|
||||
]
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Encryption setup completed successfully',
|
||||
]);
|
||||
}
|
||||
|
||||
public function getMessage(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$message = $user->encryptedMessage;
|
||||
|
||||
if (! $message) {
|
||||
return response()->json([
|
||||
'message' => 'No encrypted message found',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'encrypted_content' => $message->encrypted_content,
|
||||
'iv' => $message->iv,
|
||||
'salt' => $user->encryption_salt,
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateMessage(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'encrypted_content' => ['required', 'string'],
|
||||
'iv' => ['required', 'string', 'size:16'],
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
$message = $user->encryptedMessage;
|
||||
|
||||
if (! $message) {
|
||||
return response()->json([
|
||||
'message' => 'No encrypted message found',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$message->update($validated);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Encrypted message updated successfully',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class RedirectToEncryptionSetup
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if ($request->user() && $request->user()->encryption_salt === null) {
|
||||
if (! $request->routeIs('setup-encryption') && ! $request->is('api/encryption/setup')) {
|
||||
return redirect()->route('setup-encryption');
|
||||
}
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class SetupEncryptionRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'salt' => ['required', 'string', 'size:24'],
|
||||
'encrypted_content' => ['required', 'string'],
|
||||
'iv' => ['required', 'string', 'size:16'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class EncryptedMessage extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'encrypted_content',
|
||||
'iv',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ namespace App\Models;
|
|||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||
|
|
@ -22,6 +23,7 @@ class User extends Authenticatable
|
|||
'name',
|
||||
'email',
|
||||
'password',
|
||||
'encryption_salt',
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
@ -49,4 +51,9 @@ class User extends Authenticatable
|
|||
'two_factor_confirmed_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function encryptedMessage(): HasOne
|
||||
{
|
||||
return $this->hasOne(EncryptedMessage::class);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
use App\Http\Middleware\HandleAppearance;
|
||||
use App\Http\Middleware\HandleInertiaRequests;
|
||||
use App\Http\Middleware\RedirectToEncryptionSetup;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
|
|
@ -21,6 +22,10 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||
HandleInertiaRequests::class,
|
||||
AddLinkHeadersForPreloadedAssets::class,
|
||||
]);
|
||||
|
||||
$middleware->alias([
|
||||
'redirect.encryption' => RedirectToEncryptionSetup::class,
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
//
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->string('encryption_salt', 24)->nullable()->after('remember_token');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn('encryption_salt');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('encrypted_messages', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->onDelete('cascade');
|
||||
$table->text('encrypted_content');
|
||||
$table->string('iv', 16);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('encrypted_messages');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
import { useState } from 'react';
|
||||
import axios from 'axios';
|
||||
|
||||
import InputError from '@/components/input-error';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import {
|
||||
base64ToBuffer,
|
||||
decrypt,
|
||||
exportKey,
|
||||
getAESKeyFromPBKDF,
|
||||
getKeyFromPassword,
|
||||
importKey,
|
||||
} from '@/lib/crypto';
|
||||
import { getStoredKey, storeKey } from '@/lib/key-storage';
|
||||
|
||||
interface UnlockMessageDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onUnlock: (message: string) => void;
|
||||
encryptedContent: string;
|
||||
iv: string;
|
||||
salt: string;
|
||||
}
|
||||
|
||||
export default function UnlockMessageDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onUnlock,
|
||||
encryptedContent,
|
||||
iv,
|
||||
salt,
|
||||
}: UnlockMessageDialogProps) {
|
||||
const [password, setPassword] = useState('');
|
||||
const [storagePreference, setStoragePreference] = useState<
|
||||
'session' | 'persistent'
|
||||
>('session');
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleUnlock(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setProcessing(true);
|
||||
|
||||
try {
|
||||
const storedKey = getStoredKey();
|
||||
|
||||
let aesKey: CryptoKey;
|
||||
|
||||
if (storedKey) {
|
||||
aesKey = await importKey(storedKey);
|
||||
} else {
|
||||
const saltBuffer = base64ToBuffer(salt);
|
||||
const pbkdfKey = await getKeyFromPassword(password);
|
||||
aesKey = await getAESKeyFromPBKDF(pbkdfKey, saltBuffer);
|
||||
}
|
||||
|
||||
const decrypted = await decrypt(encryptedContent, aesKey, iv);
|
||||
|
||||
if (!storedKey && password) {
|
||||
const exportedKey = await exportKey(aesKey);
|
||||
storeKey(exportedKey, storagePreference === 'persistent');
|
||||
}
|
||||
|
||||
onUnlock(decrypted);
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
console.error('Decryption error:', err);
|
||||
setError(
|
||||
'Failed to decrypt message. Please check your password and try again.'
|
||||
);
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Unlock Encrypted Message</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter your encryption password to decrypt and view your
|
||||
message.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleUnlock}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="unlock-password">
|
||||
Encryption Password
|
||||
</Label>
|
||||
<Input
|
||||
id="unlock-password"
|
||||
type="password"
|
||||
required
|
||||
autoFocus
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter your encryption password"
|
||||
disabled={processing}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="unlock-storage">
|
||||
Storage Preference
|
||||
</Label>
|
||||
<Select
|
||||
value={storagePreference}
|
||||
onValueChange={(value) =>
|
||||
setStoragePreference(
|
||||
value as 'session' | 'persistent'
|
||||
)
|
||||
}
|
||||
disabled={processing}
|
||||
>
|
||||
<SelectTrigger id="unlock-storage">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="session">
|
||||
Session only
|
||||
</SelectItem>
|
||||
<SelectItem value="persistent">
|
||||
Keep me logged in
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={processing}>
|
||||
{processing && <Spinner />}
|
||||
Unlock
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
function ensureCryptoAvailable(): void {
|
||||
if (!window.crypto || !window.crypto.subtle) {
|
||||
throw new Error(
|
||||
'Web Crypto API is not available. Please ensure you are using HTTPS or localhost.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getKeyFromPassword(password: string): Promise<CryptoKey> {
|
||||
ensureCryptoAvailable();
|
||||
const encoder = new TextEncoder();
|
||||
return await window.crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(password),
|
||||
'PBKDF2',
|
||||
false,
|
||||
['deriveBits', 'deriveKey']
|
||||
);
|
||||
}
|
||||
|
||||
export async function getAESKeyFromPBKDF(
|
||||
key: CryptoKey,
|
||||
salt: Uint8Array
|
||||
): Promise<CryptoKey> {
|
||||
ensureCryptoAvailable();
|
||||
return await window.crypto.subtle.deriveKey(
|
||||
{
|
||||
name: 'PBKDF2',
|
||||
salt,
|
||||
iterations: 100000,
|
||||
hash: 'SHA-256',
|
||||
},
|
||||
key,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
true,
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
}
|
||||
|
||||
export async function encrypt(
|
||||
plaintext: string,
|
||||
key: CryptoKey
|
||||
): Promise<{ encrypted: string; iv: string }> {
|
||||
ensureCryptoAvailable();
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(plaintext);
|
||||
|
||||
const iv = window.crypto.getRandomValues(new Uint8Array(12));
|
||||
|
||||
const encryptedBuffer = await window.crypto.subtle.encrypt(
|
||||
{
|
||||
name: 'AES-GCM',
|
||||
iv,
|
||||
},
|
||||
key,
|
||||
data
|
||||
);
|
||||
|
||||
return {
|
||||
encrypted: bufferToBase64(encryptedBuffer),
|
||||
iv: bufferToBase64(iv),
|
||||
};
|
||||
}
|
||||
|
||||
export async function decrypt(
|
||||
encrypted: string,
|
||||
key: CryptoKey,
|
||||
iv: string
|
||||
): Promise<string> {
|
||||
ensureCryptoAvailable();
|
||||
const encryptedBuffer = base64ToBuffer(encrypted);
|
||||
const ivBuffer = base64ToBuffer(iv);
|
||||
|
||||
const decryptedBuffer = await window.crypto.subtle.decrypt(
|
||||
{
|
||||
name: 'AES-GCM',
|
||||
iv: ivBuffer,
|
||||
},
|
||||
key,
|
||||
encryptedBuffer
|
||||
);
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
return decoder.decode(decryptedBuffer);
|
||||
}
|
||||
|
||||
export function generateSalt(): Uint8Array {
|
||||
ensureCryptoAvailable();
|
||||
return window.crypto.getRandomValues(new Uint8Array(16));
|
||||
}
|
||||
|
||||
export function bufferToBase64(buffer: ArrayBuffer | Uint8Array): string {
|
||||
const bytes =
|
||||
buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
export function base64ToBuffer(base64: string): Uint8Array {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export async function exportKey(key: CryptoKey): Promise<string> {
|
||||
ensureCryptoAvailable();
|
||||
const exported = await window.crypto.subtle.exportKey('raw', key);
|
||||
return bufferToBase64(exported);
|
||||
}
|
||||
|
||||
export async function importKey(keyString: string): Promise<CryptoKey> {
|
||||
ensureCryptoAvailable();
|
||||
const keyBuffer = base64ToBuffer(keyString);
|
||||
return await window.crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyBuffer,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
true,
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
const ENCRYPTION_KEY_NAME = 'encryption_key';
|
||||
|
||||
export function storeKey(key: string, persistent: boolean): void {
|
||||
if (persistent) {
|
||||
localStorage.setItem(ENCRYPTION_KEY_NAME, key);
|
||||
} else {
|
||||
sessionStorage.setItem(ENCRYPTION_KEY_NAME, key);
|
||||
}
|
||||
}
|
||||
|
||||
export function getStoredKey(): string | null {
|
||||
return (
|
||||
sessionStorage.getItem(ENCRYPTION_KEY_NAME) ||
|
||||
localStorage.getItem(ENCRYPTION_KEY_NAME)
|
||||
);
|
||||
}
|
||||
|
||||
export function clearKey(): void {
|
||||
sessionStorage.removeItem(ENCRYPTION_KEY_NAME);
|
||||
localStorage.removeItem(ENCRYPTION_KEY_NAME);
|
||||
}
|
||||
|
||||
export function isKeyPersistent(): boolean {
|
||||
return localStorage.getItem(ENCRYPTION_KEY_NAME) !== null;
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,205 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { Head, router } from '@inertiajs/react';
|
||||
import axios from 'axios';
|
||||
|
||||
import InputError from '@/components/input-error';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import AuthLayout from '@/layouts/auth-layout';
|
||||
import {
|
||||
bufferToBase64,
|
||||
encrypt,
|
||||
exportKey,
|
||||
generateSalt,
|
||||
getAESKeyFromPBKDF,
|
||||
getKeyFromPassword,
|
||||
} from '@/lib/crypto';
|
||||
import { storeKey } from '@/lib/key-storage';
|
||||
import { dashboard } from '@/routes';
|
||||
|
||||
export default function SetupEncryption() {
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [storagePreference, setStoragePreference] = useState<
|
||||
'session' | 'persistent'
|
||||
>('session');
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [cryptoAvailable, setCryptoAvailable] = useState(true);
|
||||
const [errors, setErrors] = useState<{
|
||||
password?: string;
|
||||
confirmPassword?: string;
|
||||
general?: string;
|
||||
}>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.crypto || !window.crypto.subtle) {
|
||||
setCryptoAvailable(false);
|
||||
setErrors({
|
||||
general:
|
||||
'Web Crypto API is not available. Please ensure you are accessing this page via HTTPS or localhost.',
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setErrors({});
|
||||
|
||||
if (password.length < 12) {
|
||||
setErrors({
|
||||
password: 'Encryption password must be at least 12 characters',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setErrors({
|
||||
confirmPassword: 'Passwords do not match',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setProcessing(true);
|
||||
|
||||
try {
|
||||
const salt = generateSalt();
|
||||
|
||||
const pbkdfKey = await getKeyFromPassword(password);
|
||||
|
||||
const aesKey = await getAESKeyFromPBKDF(pbkdfKey, salt);
|
||||
|
||||
const { encrypted, iv } = await encrypt('Hello, world', aesKey);
|
||||
|
||||
const exportedKey = await exportKey(aesKey);
|
||||
|
||||
await axios.post('/api/encryption/setup', {
|
||||
salt: bufferToBase64(salt),
|
||||
encrypted_content: encrypted,
|
||||
iv: iv,
|
||||
});
|
||||
|
||||
storeKey(exportedKey, storagePreference === 'persistent');
|
||||
|
||||
router.visit(dashboard().url);
|
||||
} catch (error) {
|
||||
console.error('Encryption setup error:', error);
|
||||
setErrors({
|
||||
general:
|
||||
'Failed to setup encryption. Please try again or contact support.',
|
||||
});
|
||||
setProcessing(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthLayout
|
||||
title="Setup Encryption"
|
||||
description="Create a strong encryption password to secure your data"
|
||||
>
|
||||
<Head title="Setup Encryption" />
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-6">
|
||||
<div className="grid gap-6">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password">Encryption Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
required
|
||||
autoFocus
|
||||
tabIndex={1}
|
||||
autoComplete="new-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter a strong encryption password"
|
||||
disabled={processing}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Use a strong password (minimum 12 characters). This
|
||||
password will encrypt your data.
|
||||
</p>
|
||||
<InputError
|
||||
message={errors.password}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="confirmPassword">
|
||||
Confirm Password
|
||||
</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
required
|
||||
tabIndex={2}
|
||||
autoComplete="new-password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="Confirm your encryption password"
|
||||
disabled={processing}
|
||||
/>
|
||||
<InputError message={errors.confirmPassword} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="storagePreference">
|
||||
Storage Preference
|
||||
</Label>
|
||||
<Select
|
||||
value={storagePreference}
|
||||
onValueChange={(value) =>
|
||||
setStoragePreference(
|
||||
value as 'session' | 'persistent'
|
||||
)
|
||||
}
|
||||
disabled={processing}
|
||||
>
|
||||
<SelectTrigger id="storagePreference" tabIndex={3}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="session">
|
||||
Session only (more secure)
|
||||
</SelectItem>
|
||||
<SelectItem value="persistent">
|
||||
Keep me logged in (less secure)
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Session storage clears when you close your browser.
|
||||
Persistent storage keeps your key until manually
|
||||
cleared.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{errors.general && (
|
||||
<div className="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
|
||||
{errors.general}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="mt-2 w-full"
|
||||
tabIndex={4}
|
||||
disabled={processing || !cryptoAvailable}
|
||||
>
|
||||
{processing && <Spinner />}
|
||||
Setup Encryption
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,8 +1,17 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { Head } from '@inertiajs/react';
|
||||
import axios from 'axios';
|
||||
|
||||
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 AppLayout from '@/layouts/app-layout';
|
||||
import { base64ToBuffer, decrypt, importKey } from '@/lib/crypto';
|
||||
import { clearKey, getStoredKey } from '@/lib/key-storage';
|
||||
import { dashboard } from '@/routes';
|
||||
import { type BreadcrumbItem } from '@/types';
|
||||
import { Head } from '@inertiajs/react';
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
|
|
@ -11,15 +20,128 @@ const breadcrumbs: BreadcrumbItem[] = [
|
|||
},
|
||||
];
|
||||
|
||||
interface EncryptedMessageData {
|
||||
encrypted_content: string;
|
||||
iv: string;
|
||||
salt: string;
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const [messageData, setMessageData] = useState<EncryptedMessageData | null>(
|
||||
null
|
||||
);
|
||||
const [decryptedMessage, setDecryptedMessage] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
const [showUnlockDialog, setShowUnlockDialog] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAndDecryptMessage();
|
||||
}, []);
|
||||
|
||||
async function fetchAndDecryptMessage() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await axios.get<EncryptedMessageData>(
|
||||
'/api/encryption/message'
|
||||
);
|
||||
const data = response.data;
|
||||
setMessageData(data);
|
||||
|
||||
const storedKey = getStoredKey();
|
||||
|
||||
if (storedKey) {
|
||||
try {
|
||||
const aesKey = await importKey(storedKey);
|
||||
const decrypted = await decrypt(
|
||||
data.encrypted_content,
|
||||
aesKey,
|
||||
data.iv
|
||||
);
|
||||
setDecryptedMessage(decrypted);
|
||||
} catch (err) {
|
||||
console.error('Auto-decrypt failed:', err);
|
||||
clearKey();
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.response?.status === 404) {
|
||||
setError('No encrypted message found. Please set up encryption first.');
|
||||
} else {
|
||||
console.error('Fetch error:', err);
|
||||
setError('Failed to load encrypted message.');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleUnlock(message: string) {
|
||||
setDecryptedMessage(message);
|
||||
}
|
||||
|
||||
function handleLock() {
|
||||
clearKey();
|
||||
setDecryptedMessage(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppLayout breadcrumbs={breadcrumbs}>
|
||||
<Head title="Dashboard" />
|
||||
<div className="flex h-full flex-1 flex-col gap-4 overflow-x-auto rounded-xl p-4">
|
||||
<div className="grid auto-rows-min gap-4 md:grid-cols-3">
|
||||
<div className="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border">
|
||||
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Encrypted Message</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
) : decryptedMessage ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-lg font-medium text-green-600 dark:text-green-400">
|
||||
{decryptedMessage}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleLock}
|
||||
>
|
||||
Lock Message
|
||||
</Button>
|
||||
</div>
|
||||
) : messageData ? (
|
||||
<div className="space-y-4">
|
||||
<p className="break-all text-sm font-mono text-muted-foreground">
|
||||
{messageData.encrypted_content.substring(
|
||||
0,
|
||||
100
|
||||
)}
|
||||
...
|
||||
</p>
|
||||
<Button
|
||||
onClick={() =>
|
||||
setShowUnlockDialog(true)
|
||||
}
|
||||
>
|
||||
Unlock Message
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border">
|
||||
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
|
||||
</div>
|
||||
|
|
@ -31,6 +153,17 @@ export default function Dashboard() {
|
|||
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{messageData && (
|
||||
<UnlockMessageDialog
|
||||
open={showUnlockDialog}
|
||||
onOpenChange={setShowUnlockDialog}
|
||||
onUnlock={handleUnlock}
|
||||
encryptedContent={messageData.encrypted_content}
|
||||
iv={messageData.iv}
|
||||
salt={messageData.salt}
|
||||
/>
|
||||
)}
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Controllers\EncryptionController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Inertia\Inertia;
|
||||
use Laravel\Fortify\Features;
|
||||
|
|
@ -10,7 +11,17 @@ Route::get('/', function () {
|
|||
]);
|
||||
})->name('home');
|
||||
|
||||
Route::middleware(['auth', 'verified'])->group(function () {
|
||||
Route::middleware(['auth'])->group(function () {
|
||||
Route::get('setup-encryption', function () {
|
||||
return Inertia::render('auth/setup-encryption');
|
||||
})->name('setup-encryption');
|
||||
|
||||
Route::post('api/encryption/setup', [EncryptionController::class, 'setup']);
|
||||
Route::get('api/encryption/message', [EncryptionController::class, 'getMessage']);
|
||||
Route::put('api/encryption/message', [EncryptionController::class, 'updateMessage']);
|
||||
});
|
||||
|
||||
Route::middleware(['auth', 'verified', 'redirect.encryption'])->group(function () {
|
||||
Route::get('dashboard', function () {
|
||||
return Inertia::render('dashboard');
|
||||
})->name('dashboard');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
export interface KDFParams {
|
||||
algo: "PBKDF2";
|
||||
hash: "SHA-256";
|
||||
iterations: number;
|
||||
salt_kek: string;
|
||||
}
|
||||
|
||||
export interface WrappedDEK {
|
||||
alg: "AES-GCM";
|
||||
iv_wrap: string;
|
||||
ciphertext: string;
|
||||
}
|
||||
|
||||
export interface EncryptedBlob {
|
||||
alg: "AES-GCM";
|
||||
iv: string;
|
||||
ciphertext: string;
|
||||
aad?: string;
|
||||
}
|
||||
|
||||
export interface ServerProfile {
|
||||
kdf: KDFParams;
|
||||
wrapped_dek: WrappedDEK;
|
||||
crypto_version: number;
|
||||
}
|
||||
|
||||
export interface RegisterPayload {
|
||||
email: string;
|
||||
kdf: KDFParams;
|
||||
wrapped_dek: WrappedDEK;
|
||||
crypto_version: number;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
profile: ServerProfile;
|
||||
}
|
||||
|
||||
export interface SessionKeys {
|
||||
dek: CryptoKey;
|
||||
kek: CryptoKey;
|
||||
}
|
||||
|
||||
|
|
@ -7,7 +7,9 @@ test('guests are redirected to the login page', function () {
|
|||
});
|
||||
|
||||
test('authenticated users can visit the dashboard', function () {
|
||||
$this->actingAs($user = User::factory()->create());
|
||||
$this->actingAs($user = User::factory()->create([
|
||||
'encryption_salt' => str_repeat('a', 24),
|
||||
]));
|
||||
|
||||
$this->get(route('dashboard'))->assertOk();
|
||||
});
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
<?php
|
||||
|
||||
use App\Models\EncryptedMessage;
|
||||
use App\Models\User;
|
||||
|
||||
use function Pest\Laravel\actingAs;
|
||||
use function Pest\Laravel\assertDatabaseHas;
|
||||
|
||||
test('authenticated user without encryption salt can access setup page', function () {
|
||||
$user = User::factory()->create(['encryption_salt' => null]);
|
||||
|
||||
$response = actingAs($user)->get(route('setup-encryption'));
|
||||
|
||||
$response->assertSuccessful();
|
||||
});
|
||||
|
||||
test('user can setup encryption', function () {
|
||||
$user = User::factory()->create(['encryption_salt' => null]);
|
||||
|
||||
$response = actingAs($user)->postJson('/api/encryption/setup', [
|
||||
'salt' => str_repeat('a', 24),
|
||||
'encrypted_content' => 'encrypted_test_content',
|
||||
'iv' => str_repeat('b', 16),
|
||||
]);
|
||||
|
||||
$response->assertSuccessful();
|
||||
|
||||
$user->refresh();
|
||||
|
||||
expect($user->encryption_salt)->toBe(str_repeat('a', 24));
|
||||
|
||||
assertDatabaseHas('encrypted_messages', [
|
||||
'user_id' => $user->id,
|
||||
'encrypted_content' => 'encrypted_test_content',
|
||||
'iv' => str_repeat('b', 16),
|
||||
]);
|
||||
});
|
||||
|
||||
test('encryption setup requires valid salt', function () {
|
||||
$user = User::factory()->create(['encryption_salt' => null]);
|
||||
|
||||
$response = actingAs($user)->postJson('/api/encryption/setup', [
|
||||
'salt' => 'invalid',
|
||||
'encrypted_content' => 'encrypted_test_content',
|
||||
'iv' => str_repeat('b', 16),
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable();
|
||||
$response->assertJsonValidationErrors(['salt']);
|
||||
});
|
||||
|
||||
test('encryption setup requires valid iv', function () {
|
||||
$user = User::factory()->create(['encryption_salt' => null]);
|
||||
|
||||
$response = actingAs($user)->postJson('/api/encryption/setup', [
|
||||
'salt' => str_repeat('a', 24),
|
||||
'encrypted_content' => 'encrypted_test_content',
|
||||
'iv' => 'invalid',
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable();
|
||||
$response->assertJsonValidationErrors(['iv']);
|
||||
});
|
||||
|
||||
test('user can retrieve encrypted message', function () {
|
||||
$user = User::factory()->create([
|
||||
'encryption_salt' => str_repeat('a', 24),
|
||||
]);
|
||||
|
||||
EncryptedMessage::query()->create([
|
||||
'user_id' => $user->id,
|
||||
'encrypted_content' => 'encrypted_test_content',
|
||||
'iv' => str_repeat('b', 16),
|
||||
]);
|
||||
|
||||
$response = actingAs($user)->getJson('/api/encryption/message');
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertJson([
|
||||
'encrypted_content' => 'encrypted_test_content',
|
||||
'iv' => str_repeat('b', 16),
|
||||
'salt' => str_repeat('a', 24),
|
||||
]);
|
||||
});
|
||||
|
||||
test('user without encrypted message receives 404', function () {
|
||||
$user = User::factory()->create([
|
||||
'encryption_salt' => str_repeat('a', 24),
|
||||
]);
|
||||
|
||||
$response = actingAs($user)->getJson('/api/encryption/message');
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
test('user can update encrypted message', function () {
|
||||
$user = User::factory()->create([
|
||||
'encryption_salt' => str_repeat('a', 24),
|
||||
]);
|
||||
|
||||
$message = EncryptedMessage::query()->create([
|
||||
'user_id' => $user->id,
|
||||
'encrypted_content' => 'old_encrypted_content',
|
||||
'iv' => str_repeat('b', 16),
|
||||
]);
|
||||
|
||||
$response = actingAs($user)->putJson('/api/encryption/message', [
|
||||
'encrypted_content' => 'new_encrypted_content',
|
||||
'iv' => str_repeat('c', 16),
|
||||
]);
|
||||
|
||||
$response->assertSuccessful();
|
||||
|
||||
$message->refresh();
|
||||
|
||||
expect($message->encrypted_content)->toBe('new_encrypted_content');
|
||||
expect($message->iv)->toBe(str_repeat('c', 16));
|
||||
});
|
||||
|
||||
test('user without encryption salt is redirected to setup', function () {
|
||||
$user = User::factory()->create(['encryption_salt' => null]);
|
||||
|
||||
$response = actingAs($user)->get(route('dashboard'));
|
||||
|
||||
$response->assertRedirect(route('setup-encryption'));
|
||||
});
|
||||
|
||||
test('user with encryption salt can access dashboard', function () {
|
||||
$user = User::factory()->create([
|
||||
'encryption_salt' => str_repeat('a', 24),
|
||||
]);
|
||||
|
||||
$response = actingAs($user)->get(route('dashboard'));
|
||||
|
||||
$response->assertSuccessful();
|
||||
});
|
||||
Loading…
Reference in New Issue