E2E Encryption

This commit is contained in:
Víctor Falcón 2025-11-07 14:21:25 +00:00
parent a9217b4efe
commit 292297f483
28 changed files with 1090 additions and 18 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

128
resources/js/lib/crypto.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

42
src/lib/crypto/types.ts Normal file
View File

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

View File

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

View File

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