From 292297f4836f09b7278fbd25509183969ea8b538 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vi=CC=81ctor=20Falco=CC=81n?= Date: Fri, 7 Nov 2025 14:21:25 +0000 Subject: [PATCH] E2E Encryption --- app/Http/Controllers/EncryptionController.php | 75 +++++++ .../Middleware/RedirectToEncryptionSetup.php | 26 +++ app/Http/Requests/SetupEncryptionRequest.php | 31 +++ app/Models/EncryptedMessage.php | 20 ++ app/Models/User.php | 7 + bootstrap/app.php | 5 + ...255_add_encryption_salt_to_users_table.php | 28 +++ ...135256_create_encrypted_messages_table.php | 30 +++ .../js/components/unlock-message-dialog.tsx | 167 ++++++++++++++ resources/js/lib/crypto.ts | 128 +++++++++++ resources/js/lib/key-storage.ts | 26 +++ resources/js/pages/auth/setup-encryption.tsx | 205 ++++++++++++++++++ resources/js/pages/dashboard.tsx | 141 +++++++++++- routes/web.php | 13 +- src/lib/crypto/types.ts | 42 ++++ tests/Feature/Auth/AuthenticationTest.php | 2 +- tests/Feature/Auth/EmailVerificationTest.php | 2 +- .../Feature/Auth/PasswordConfirmationTest.php | 2 +- tests/Feature/Auth/PasswordResetTest.php | 2 +- tests/Feature/Auth/RegistrationTest.php | 2 +- tests/Feature/Auth/TwoFactorChallengeTest.php | 2 +- .../Auth/VerificationNotificationTest.php | 2 +- tests/Feature/DashboardTest.php | 6 +- tests/Feature/EncryptionTest.php | 136 ++++++++++++ tests/Feature/Settings/PasswordUpdateTest.php | 2 +- tests/Feature/Settings/ProfileUpdateTest.php | 2 +- .../Settings/TwoFactorAuthenticationTest.php | 2 +- tests/Unit/ExampleTest.php | 2 +- 28 files changed, 1090 insertions(+), 18 deletions(-) create mode 100644 app/Http/Controllers/EncryptionController.php create mode 100644 app/Http/Middleware/RedirectToEncryptionSetup.php create mode 100644 app/Http/Requests/SetupEncryptionRequest.php create mode 100644 app/Models/EncryptedMessage.php create mode 100644 database/migrations/2025_11_07_135255_add_encryption_salt_to_users_table.php create mode 100644 database/migrations/2025_11_07_135256_create_encrypted_messages_table.php create mode 100644 resources/js/components/unlock-message-dialog.tsx create mode 100644 resources/js/lib/crypto.ts create mode 100644 resources/js/lib/key-storage.ts create mode 100644 resources/js/pages/auth/setup-encryption.tsx create mode 100644 src/lib/crypto/types.ts create mode 100644 tests/Feature/EncryptionTest.php diff --git a/app/Http/Controllers/EncryptionController.php b/app/Http/Controllers/EncryptionController.php new file mode 100644 index 00000000..0619c9d5 --- /dev/null +++ b/app/Http/Controllers/EncryptionController.php @@ -0,0 +1,75 @@ +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', + ]); + } +} diff --git a/app/Http/Middleware/RedirectToEncryptionSetup.php b/app/Http/Middleware/RedirectToEncryptionSetup.php new file mode 100644 index 00000000..0cadf9d2 --- /dev/null +++ b/app/Http/Middleware/RedirectToEncryptionSetup.php @@ -0,0 +1,26 @@ +user() && $request->user()->encryption_salt === null) { + if (! $request->routeIs('setup-encryption') && ! $request->is('api/encryption/setup')) { + return redirect()->route('setup-encryption'); + } + } + + return $next($request); + } +} diff --git a/app/Http/Requests/SetupEncryptionRequest.php b/app/Http/Requests/SetupEncryptionRequest.php new file mode 100644 index 00000000..ca985f08 --- /dev/null +++ b/app/Http/Requests/SetupEncryptionRequest.php @@ -0,0 +1,31 @@ +|string> + */ + public function rules(): array + { + return [ + 'salt' => ['required', 'string', 'size:24'], + 'encrypted_content' => ['required', 'string'], + 'iv' => ['required', 'string', 'size:16'], + ]; + } +} diff --git a/app/Models/EncryptedMessage.php b/app/Models/EncryptedMessage.php new file mode 100644 index 00000000..c1f2fbc7 --- /dev/null +++ b/app/Models/EncryptedMessage.php @@ -0,0 +1,20 @@ +belongsTo(User::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 05faee44..f58a4f3c 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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); + } } diff --git a/bootstrap/app.php b/bootstrap/app.php index c4f1cc5f..222f9dc4 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -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 { // diff --git a/database/migrations/2025_11_07_135255_add_encryption_salt_to_users_table.php b/database/migrations/2025_11_07_135255_add_encryption_salt_to_users_table.php new file mode 100644 index 00000000..e35e8cab --- /dev/null +++ b/database/migrations/2025_11_07_135255_add_encryption_salt_to_users_table.php @@ -0,0 +1,28 @@ +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'); + }); + } +}; diff --git a/database/migrations/2025_11_07_135256_create_encrypted_messages_table.php b/database/migrations/2025_11_07_135256_create_encrypted_messages_table.php new file mode 100644 index 00000000..0efc9088 --- /dev/null +++ b/database/migrations/2025_11_07_135256_create_encrypted_messages_table.php @@ -0,0 +1,30 @@ +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'); + } +}; diff --git a/resources/js/components/unlock-message-dialog.tsx b/resources/js/components/unlock-message-dialog.tsx new file mode 100644 index 00000000..ee0750f7 --- /dev/null +++ b/resources/js/components/unlock-message-dialog.tsx @@ -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(null); + + async function handleUnlock(e: React.FormEvent) { + 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 ( + + + + Unlock Encrypted Message + + Enter your encryption password to decrypt and view your + message. + + +
+
+
+ + setPassword(e.target.value)} + placeholder="Enter your encryption password" + disabled={processing} + /> +
+ +
+ + +
+ + {error && ( +
+ {error} +
+ )} +
+ + + +
+
+
+ ); +} + diff --git a/resources/js/lib/crypto.ts b/resources/js/lib/crypto.ts new file mode 100644 index 00000000..9ccb0956 --- /dev/null +++ b/resources/js/lib/crypto.ts @@ -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 { + 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 { + 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 { + 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 { + ensureCryptoAvailable(); + const exported = await window.crypto.subtle.exportKey('raw', key); + return bufferToBase64(exported); +} + +export async function importKey(keyString: string): Promise { + ensureCryptoAvailable(); + const keyBuffer = base64ToBuffer(keyString); + return await window.crypto.subtle.importKey( + 'raw', + keyBuffer, + { name: 'AES-GCM', length: 256 }, + true, + ['encrypt', 'decrypt'] + ); +} + diff --git a/resources/js/lib/key-storage.ts b/resources/js/lib/key-storage.ts new file mode 100644 index 00000000..d8aeac64 --- /dev/null +++ b/resources/js/lib/key-storage.ts @@ -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; +} + diff --git a/resources/js/pages/auth/setup-encryption.tsx b/resources/js/pages/auth/setup-encryption.tsx new file mode 100644 index 00000000..8156aedb --- /dev/null +++ b/resources/js/pages/auth/setup-encryption.tsx @@ -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) { + 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 ( + + +
+
+
+ + setPassword(e.target.value)} + placeholder="Enter a strong encryption password" + disabled={processing} + /> +

+ Use a strong password (minimum 12 characters). This + password will encrypt your data. +

+ +
+ +
+ + setConfirmPassword(e.target.value)} + placeholder="Confirm your encryption password" + disabled={processing} + /> + +
+ +
+ + +

+ Session storage clears when you close your browser. + Persistent storage keeps your key until manually + cleared. +

+
+ + {errors.general && ( +
+ {errors.general} +
+ )} + + +
+
+
+ ); +} + diff --git a/resources/js/pages/dashboard.tsx b/resources/js/pages/dashboard.tsx index 288f976b..e93d0ad4 100644 --- a/resources/js/pages/dashboard.tsx +++ b/resources/js/pages/dashboard.tsx @@ -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( + null + ); + const [decryptedMessage, setDecryptedMessage] = useState( + null + ); + const [showUnlockDialog, setShowUnlockDialog] = useState(false); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + fetchAndDecryptMessage(); + }, []); + + async function fetchAndDecryptMessage() { + setLoading(true); + setError(null); + + try { + const response = await axios.get( + '/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 (
-
- -
+ + + Encrypted Message + + + {loading ? ( +
+ + +
+ ) : error ? ( +
+ {error} +
+ ) : decryptedMessage ? ( +
+

+ {decryptedMessage} +

+ +
+ ) : messageData ? ( +
+

+ {messageData.encrypted_content.substring( + 0, + 100 + )} + ... +

+ +
+ ) : null} +
+
+
@@ -31,6 +153,17 @@ export default function Dashboard() {
+ + {messageData && ( + + )}
); } diff --git a/routes/web.php b/routes/web.php index 11e61c22..46ec7004 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,5 +1,6 @@ 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'); diff --git a/src/lib/crypto/types.ts b/src/lib/crypto/types.ts new file mode 100644 index 00000000..449da69f --- /dev/null +++ b/src/lib/crypto/types.ts @@ -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; +} + diff --git a/tests/Feature/Auth/AuthenticationTest.php b/tests/Feature/Auth/AuthenticationTest.php index 785ec808..dbb42976 100644 --- a/tests/Feature/Auth/AuthenticationTest.php +++ b/tests/Feature/Auth/AuthenticationTest.php @@ -81,4 +81,4 @@ test('users are rate limited', function () { ]); $response->assertTooManyRequests(); -}); \ No newline at end of file +}); diff --git a/tests/Feature/Auth/EmailVerificationTest.php b/tests/Feature/Auth/EmailVerificationTest.php index 7bce67bf..67e8ef08 100644 --- a/tests/Feature/Auth/EmailVerificationTest.php +++ b/tests/Feature/Auth/EmailVerificationTest.php @@ -89,4 +89,4 @@ test('already verified user visiting verification link is redirected without fir expect($user->fresh()->hasVerifiedEmail())->toBeTrue(); Event::assertNotDispatched(Verified::class); -}); \ No newline at end of file +}); diff --git a/tests/Feature/Auth/PasswordConfirmationTest.php b/tests/Feature/Auth/PasswordConfirmationTest.php index d25ae081..c950e3eb 100644 --- a/tests/Feature/Auth/PasswordConfirmationTest.php +++ b/tests/Feature/Auth/PasswordConfirmationTest.php @@ -19,4 +19,4 @@ test('password confirmation requires authentication', function () { $response = $this->get(route('password.confirm')); $response->assertRedirect(route('login')); -}); \ No newline at end of file +}); diff --git a/tests/Feature/Auth/PasswordResetTest.php b/tests/Feature/Auth/PasswordResetTest.php index d6846291..d507132f 100644 --- a/tests/Feature/Auth/PasswordResetTest.php +++ b/tests/Feature/Auth/PasswordResetTest.php @@ -70,4 +70,4 @@ test('password cannot be reset with invalid token', function () { ]); $response->assertSessionHasErrors('email'); -}); \ No newline at end of file +}); diff --git a/tests/Feature/Auth/RegistrationTest.php b/tests/Feature/Auth/RegistrationTest.php index be6d7d62..64ea7277 100644 --- a/tests/Feature/Auth/RegistrationTest.php +++ b/tests/Feature/Auth/RegistrationTest.php @@ -16,4 +16,4 @@ test('new users can register', function () { $this->assertAuthenticated(); $response->assertRedirect(route('dashboard', absolute: false)); -}); \ No newline at end of file +}); diff --git a/tests/Feature/Auth/TwoFactorChallengeTest.php b/tests/Feature/Auth/TwoFactorChallengeTest.php index 5e1fdef2..25d76754 100644 --- a/tests/Feature/Auth/TwoFactorChallengeTest.php +++ b/tests/Feature/Auth/TwoFactorChallengeTest.php @@ -42,4 +42,4 @@ test('two factor challenge can be rendered', function () { ->assertInertia(fn (Assert $page) => $page ->component('auth/two-factor-challenge') ); -}); \ No newline at end of file +}); diff --git a/tests/Feature/Auth/VerificationNotificationTest.php b/tests/Feature/Auth/VerificationNotificationTest.php index 57d7869a..90f75af9 100644 --- a/tests/Feature/Auth/VerificationNotificationTest.php +++ b/tests/Feature/Auth/VerificationNotificationTest.php @@ -30,4 +30,4 @@ test('does not send verification notification if email is verified', function () ->assertRedirect(route('dashboard', absolute: false)); Notification::assertNothingSent(); -}); \ No newline at end of file +}); diff --git a/tests/Feature/DashboardTest.php b/tests/Feature/DashboardTest.php index 67611b98..91d58221 100644 --- a/tests/Feature/DashboardTest.php +++ b/tests/Feature/DashboardTest.php @@ -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(); -}); \ No newline at end of file +}); diff --git a/tests/Feature/EncryptionTest.php b/tests/Feature/EncryptionTest.php new file mode 100644 index 00000000..5f52df23 --- /dev/null +++ b/tests/Feature/EncryptionTest.php @@ -0,0 +1,136 @@ +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(); +}); diff --git a/tests/Feature/Settings/PasswordUpdateTest.php b/tests/Feature/Settings/PasswordUpdateTest.php index a1c91ce7..f13ccba4 100644 --- a/tests/Feature/Settings/PasswordUpdateTest.php +++ b/tests/Feature/Settings/PasswordUpdateTest.php @@ -47,4 +47,4 @@ test('correct password must be provided to update password', function () { $response ->assertSessionHasErrors('current_password') ->assertRedirect(route('user-password.edit')); -}); \ No newline at end of file +}); diff --git a/tests/Feature/Settings/ProfileUpdateTest.php b/tests/Feature/Settings/ProfileUpdateTest.php index b4c4c378..9f49e25f 100644 --- a/tests/Feature/Settings/ProfileUpdateTest.php +++ b/tests/Feature/Settings/ProfileUpdateTest.php @@ -82,4 +82,4 @@ test('correct password must be provided to delete account', function () { ->assertRedirect(route('profile.edit')); expect($user->fresh())->not->toBeNull(); -}); \ No newline at end of file +}); diff --git a/tests/Feature/Settings/TwoFactorAuthenticationTest.php b/tests/Feature/Settings/TwoFactorAuthenticationTest.php index 75625fb6..7de440fa 100644 --- a/tests/Feature/Settings/TwoFactorAuthenticationTest.php +++ b/tests/Feature/Settings/TwoFactorAuthenticationTest.php @@ -76,4 +76,4 @@ test('two factor settings page returns forbidden response when two factor is dis ->withSession(['auth.password_confirmed_at' => time()]) ->get(route('two-factor.show')) ->assertForbidden(); -}); \ No newline at end of file +}); diff --git a/tests/Unit/ExampleTest.php b/tests/Unit/ExampleTest.php index 27f3f877..44a4f337 100644 --- a/tests/Unit/ExampleTest.php +++ b/tests/Unit/ExampleTest.php @@ -2,4 +2,4 @@ test('that true is true', function () { expect(true)->toBeTrue(); -}); \ No newline at end of file +});