45 KiB
Universal End-to-End Encryption System
Extending E2E Encryption to All Sensitive Data
Executive Summary
This document extends the simplified E2E encryption plan to create a universal encryption system that works for:
- ✅ WireChat messages (already planned)
- ✅ Transaction descriptions (new)
- ✅ Future sensitive data fields (extensible design)
The system uses a polymorphic, trait-based architecture that makes any model field encryptable with minimal code changes.
1. Universal Design Philosophy
1.1 Core Principles
Polymorphic Encryption:
- Any model can have encrypted fields
- Single encryption key management system
- Consistent encryption/decryption flow
Recipient-Based Access:
- Data encrypted for specific users/participants
- Each recipient gets their own encrypted key
- Flexible permission system
Transparent to Application:
- Encryption/decryption happens automatically
- No changes to existing business logic
- Backward compatible with plaintext data
Future-Proof:
- Easy to add encryption to new models
- Extensible architecture
- Version-aware for algorithm upgrades
1.2 Use Cases
| Data Type | Who Can Decrypt | Example |
|---|---|---|
| WireChat Message | Conversation participants | Private chat between users |
| Transaction Description | From & To account owners | Payment description |
| Post Content | Post author + moderators | Private posts |
| Profile Notes | Profile owner + admins | Sensitive personal info |
| Document Attachments | Document owner + shared users | Private files |
2. Universal Encryption Architecture
2.1 Polymorphic Schema Design
Single Universal Tables:
-- Universal encryption keys table (replaces user_encryption_keys)
CREATE TABLE encryption_keys (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
owner_id BIGINT NOT NULL COMMENT 'ID of the owner (user, org, etc)',
owner_type VARCHAR(255) NOT NULL COMMENT 'Model class of owner',
public_key TEXT NOT NULL,
encrypted_private_key TEXT NOT NULL COMMENT 'Encrypted with Laravel APP_KEY',
key_version VARCHAR(10) DEFAULT 'v1',
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP,
updated_at TIMESTAMP,
INDEX idx_owner (owner_id, owner_type),
UNIQUE KEY unique_active_key (owner_id, owner_type, is_active)
);
-- Universal encrypted data keys table (replaces message_encryption_keys)
CREATE TABLE encrypted_data_keys (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
encryptable_id BIGINT NOT NULL COMMENT 'ID of encrypted record',
encryptable_type VARCHAR(255) NOT NULL COMMENT 'Model class (Message, Transaction, etc)',
encryptable_field VARCHAR(100) NOT NULL COMMENT 'Field name (body, description, etc)',
recipient_id BIGINT NOT NULL COMMENT 'Who can decrypt this',
recipient_type VARCHAR(255) NOT NULL COMMENT 'Recipient model class',
encrypted_data_key TEXT NOT NULL COMMENT 'AES key encrypted with recipient RSA key',
nonce VARCHAR(255) NOT NULL,
algorithm VARCHAR(50) DEFAULT 'AES-256-GCM',
created_at TIMESTAMP,
updated_at TIMESTAMP,
-- Polymorphic indexes
INDEX idx_encryptable (encryptable_id, encryptable_type, encryptable_field),
INDEX idx_recipient (recipient_id, recipient_type),
-- Ensure one key per recipient per field
UNIQUE KEY unique_recipient_key (
encryptable_id,
encryptable_type,
encryptable_field,
recipient_id,
recipient_type
)
);
-- Encryption metadata on encrypted models (added via trait)
-- Each model that uses encryption gets these columns via migration:
-- - is_encrypted BOOLEAN DEFAULT FALSE
-- - encryption_version VARCHAR(10) NULL
-- - encrypted_fields JSON NULL (list of which fields are encrypted)
2.2 Transaction-Specific Schema
-- Add encryption support to transactions table
ALTER TABLE transactions
ADD COLUMN is_encrypted BOOLEAN DEFAULT FALSE AFTER description,
ADD COLUMN encryption_version VARCHAR(10) NULL AFTER is_encrypted,
ADD COLUMN encrypted_fields JSON NULL AFTER encryption_version,
ADD INDEX idx_is_encrypted (is_encrypted);
-- Example encrypted_fields JSON: ["description"]
-- Could expand to: ["description", "from_reference", "to_reference"]
3. Universal Encryption Trait
3.1 PHP Trait for Models
// app/Traits/HasEncryptedFields.php
namespace App\Traits;
use App\Models\EncryptedDataKey;
use App\Models\EncryptionKey;
use Illuminate\Support\Facades\Log;
trait HasEncryptedFields
{
/**
* Define which fields should be encrypted
* Override in your model
*/
protected function getEncryptableFields(): array
{
return $this->encryptable ?? [];
}
/**
* Define who can decrypt this model's data
* Override in your model
*/
abstract protected function getEncryptionRecipients(): array;
/**
* Boot the trait
*/
protected static function bootHasEncryptedFields()
{
// After model is retrieved, mark encrypted fields
static::retrieved(function ($model) {
if ($model->is_encrypted) {
$encryptedFields = json_decode($model->encrypted_fields, true) ?? [];
foreach ($encryptedFields as $field) {
$model->setAttribute("{$field}_is_encrypted", true);
}
}
});
// After model is saved, handle encryption
static::saved(function ($model) {
if ($model->shouldEncrypt()) {
Log::info('Encrypted data saved', [
'model' => get_class($model),
'id' => $model->id,
'fields' => $model->encrypted_fields
]);
}
});
}
/**
* Check if this model should use encryption
*/
public function shouldEncrypt(): bool
{
return $this->is_encrypted ?? false;
}
/**
* Get encryption keys for recipients of this record
*/
public function getRecipientKeys($field = null)
{
$recipients = $this->getEncryptionRecipients();
return collect($recipients)->map(function ($recipient) use ($field) {
$key = EncryptionKey::where([
'owner_id' => $recipient->id,
'owner_type' => get_class($recipient),
'is_active' => true
])->first();
if (!$key) {
return null;
}
return [
'id' => $recipient->id,
'type' => get_class($recipient),
'public_key' => $key->public_key,
'field' => $field
];
})->filter()->values();
}
/**
* Get decryption key for current user for specific field
*/
public function getDecryptionKey(string $field)
{
$user = auth()->user();
if (!$user) {
return null;
}
return EncryptedDataKey::where([
'encryptable_id' => $this->id,
'encryptable_type' => get_class($this),
'encryptable_field' => $field,
'recipient_id' => $user->id,
'recipient_type' => get_class($user)
])->first();
}
/**
* Store encryption keys for a field
*/
public function storeEncryptionKeys(string $field, array $recipientKeysData)
{
foreach ($recipientKeysData as $keyData) {
EncryptedDataKey::updateOrCreate(
[
'encryptable_id' => $this->id,
'encryptable_type' => get_class($this),
'encryptable_field' => $field,
'recipient_id' => $keyData['recipient_id'],
'recipient_type' => $keyData['recipient_type']
],
[
'encrypted_data_key' => $keyData['encrypted_key'],
'nonce' => $keyData['nonce'],
'algorithm' => 'AES-256-GCM'
]
);
}
// Update encrypted_fields JSON
$encryptedFields = json_decode($this->encrypted_fields, true) ?? [];
if (!in_array($field, $encryptedFields)) {
$encryptedFields[] = $field;
$this->encrypted_fields = json_encode($encryptedFields);
$this->is_encrypted = true;
$this->encryption_version = 'v1';
$this->saveQuietly(); // Save without triggering events
}
}
/**
* Check if user can decrypt this record
*/
public function canDecrypt($user = null, string $field = null): bool
{
$user = $user ?? auth()->user();
if (!$user) {
return false;
}
// Check if user is a recipient
$recipients = $this->getEncryptionRecipients();
foreach ($recipients as $recipient) {
if ($recipient->id === $user->id && get_class($recipient) === get_class($user)) {
return true;
}
}
return false;
}
}
3.2 Apply to Message Model
// app/Models/Message.php (WireChat override)
namespace App\Models;
use App\Traits\HasEncryptedFields;
use Namu\WireChat\Models\Message as BaseMessage;
class Message extends BaseMessage
{
use HasEncryptedFields;
protected $encryptable = ['body'];
protected function getEncryptionRecipients(): array
{
// All conversation participants can decrypt
return $this->conversation
->participants()
->with('participantable')
->get()
->pluck('participantable')
->filter()
->values()
->all();
}
}
3.3 Apply to Transaction Model
// app/Models/Transaction.php (extend existing)
namespace App\Models;
use App\Traits\HasEncryptedFields;
use Illuminate\Database\Eloquent\Model;
class Transaction extends Model
{
use HasEncryptedFields;
protected $encryptable = ['description']; // Can add 'from_reference', 'to_reference' later
protected function getEncryptionRecipients(): array
{
$recipients = [];
// From account owner(s)
if ($this->accountFrom && $this->accountFrom->accountable) {
$recipients = array_merge($recipients, $this->accountFrom->accountable->all());
}
// To account owner(s)
if ($this->accountTo && $this->accountTo->accountable) {
$recipients = array_merge($recipients, $this->accountTo->accountable->all());
}
return array_filter($recipients);
}
/**
* Override toSearchableArray to NOT index encrypted description
*/
public function toSearchableArray()
{
$array = parent::toSearchableArray();
// Don't index encrypted descriptions in Elasticsearch
if ($this->is_encrypted && in_array('description', json_decode($this->encrypted_fields, true) ?? [])) {
$array['description'] = '[Encrypted]';
}
return $array;
}
}
4. Universal Frontend Encryption Module
4.1 Enhanced Encryption Service
// resources/js/encryption/universal-encryption.js
class UniversalEncryption {
constructor() {
this.initialized = false;
this.myKeys = null;
this.keyCache = new Map(); // Cache recipient public keys
}
async initialize() {
if (this.initialized) return;
try {
const response = await axios.get('/encryption/my-keys');
this.myKeys = response.data;
this.initialized = true;
console.log('Universal encryption initialized');
} catch (error) {
if (error.response?.status === 404) {
console.log('No encryption keys found - will generate on first use');
} else {
console.error('Failed to initialize encryption:', error);
}
}
}
async ensureKeys() {
if (this.myKeys) return this.myKeys;
console.log('Generating encryption keys...');
const keyPair = await crypto.subtle.generateKey(
{
name: "RSA-OAEP",
modulusLength: 2048,
publicExponent: new Uint8Array([1, 0, 1]),
hash: "SHA-256"
},
true,
["encrypt", "decrypt"]
);
const publicKey = await crypto.subtle.exportKey("jwk", keyPair.publicKey);
const privateKey = await crypto.subtle.exportKey("jwk", keyPair.privateKey);
await axios.post('/encryption/store-keys', {
public_key: JSON.stringify(publicKey),
private_key: JSON.stringify(privateKey)
});
this.myKeys = {
public_key: JSON.stringify(publicKey),
private_key: JSON.stringify(privateKey)
};
console.log('Encryption keys generated');
return this.myKeys;
}
/**
* Universal encryption method for any data
*
* @param {string} plaintext - Data to encrypt
* @param {Array} recipients - Array of {id, type, public_key}
* @returns {Object} {encryptedData, recipientKeys}
*/
async encryptData(plaintext, recipients) {
await this.ensureKeys();
// Generate ephemeral AES key
const dataKey = await crypto.subtle.generateKey(
{ name: "AES-GCM", length: 256 },
true,
["encrypt", "decrypt"]
);
// Encrypt data
const nonce = crypto.getRandomValues(new Uint8Array(12));
const encodedData = new TextEncoder().encode(plaintext);
const encryptedData = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv: nonce },
dataKey,
encodedData
);
// Export and encrypt data key for each recipient
const rawDataKey = await crypto.subtle.exportKey("raw", dataKey);
const recipientKeys = [];
for (const recipient of recipients) {
if (!recipient.public_key) {
console.warn('Recipient missing public key:', recipient);
continue;
}
const recipientPublicKey = await crypto.subtle.importKey(
"jwk",
JSON.parse(recipient.public_key),
{ name: "RSA-OAEP", hash: "SHA-256" },
false,
["encrypt"]
);
const encryptedDataKey = await crypto.subtle.encrypt(
{ name: "RSA-OAEP" },
recipientPublicKey,
rawDataKey
);
recipientKeys.push({
recipient_id: recipient.id,
recipient_type: recipient.type,
encrypted_key: this.arrayBufferToBase64(encryptedDataKey),
nonce: this.arrayBufferToBase64(nonce)
});
}
return {
encryptedData: this.arrayBufferToBase64(encryptedData),
recipientKeys: recipientKeys,
nonce: this.arrayBufferToBase64(nonce)
};
}
/**
* Universal decryption method for any data
*
* @param {string} encryptedData - Base64 encoded encrypted data
* @param {string} encryptedDataKey - Base64 encoded encrypted AES key
* @param {string} nonce - Base64 encoded nonce/IV
* @returns {string} Decrypted plaintext
*/
async decryptData(encryptedData, encryptedDataKey, nonce) {
await this.ensureKeys();
// Import my private key
const privateKey = await crypto.subtle.importKey(
"jwk",
JSON.parse(this.myKeys.private_key),
{ name: "RSA-OAEP", hash: "SHA-256" },
false,
["decrypt"]
);
// Decrypt data key
const encryptedKeyBuffer = this.base64ToArrayBuffer(encryptedDataKey);
const dataKeyBuffer = await crypto.subtle.decrypt(
{ name: "RSA-OAEP" },
privateKey,
encryptedKeyBuffer
);
// Import data key
const dataKey = await crypto.subtle.importKey(
"raw",
dataKeyBuffer,
{ name: "AES-GCM", length: 256 },
false,
["decrypt"]
);
// Decrypt data
const nonceBuffer = this.base64ToArrayBuffer(nonce);
const encryptedDataBuffer = this.base64ToArrayBuffer(encryptedData);
const decryptedBuffer = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: nonceBuffer },
dataKey,
encryptedDataBuffer
);
return new TextDecoder().decode(decryptedBuffer);
}
/**
* Encrypt a model field
*
* @param {string} modelType - Model class name
* @param {number} modelId - Model ID
* @param {string} field - Field name to encrypt
* @param {string} value - Value to encrypt
* @returns {Object} Encryption data to send to server
*/
async encryptModelField(modelType, modelId, field, value) {
// Get recipients for this model
const response = await axios.get(`/encryption/recipients/${modelType}/${modelId}`);
const recipients = response.data;
if (recipients.length === 0) {
throw new Error('No recipients found for encryption');
}
// Encrypt the data
const encrypted = await this.encryptData(value, recipients);
return {
model_type: modelType,
model_id: modelId,
field: field,
encrypted_value: encrypted.encryptedData,
recipient_keys: encrypted.recipientKeys
};
}
/**
* Decrypt a model field
*
* @param {string} modelType - Model class name
* @param {number} modelId - Model ID
* @param {string} field - Field name to decrypt
* @param {string} encryptedValue - Encrypted value
* @returns {string} Decrypted value
*/
async decryptModelField(modelType, modelId, field, encryptedValue) {
// Get my decryption key for this field
const response = await axios.get(`/encryption/key/${modelType}/${modelId}/${field}`);
const keyData = response.data;
// Decrypt
return await this.decryptData(
encryptedValue,
keyData.encrypted_data_key,
keyData.nonce
);
}
// Helper methods
arrayBufferToBase64(buffer) {
return btoa(String.fromCharCode(...new Uint8Array(buffer)));
}
base64ToArrayBuffer(base64) {
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.buffer;
}
}
// Global instance
window.universalEncryption = new UniversalEncryption();
4.2 Transaction Form Integration
// resources/js/encryption/transaction-encryption.js
// Initialize on page load
document.addEventListener('DOMContentLoaded', async () => {
await window.universalEncryption.initialize();
});
// Hook into transaction form submission
document.addEventListener('livewire:init', () => {
Livewire.hook('commit', async ({ component, commit, respond }) => {
// Check if this is a transaction form
if (component.fingerprint.name.includes('transaction') && component.description) {
try {
// Get transaction recipients (from/to account owners)
const recipients = await axios.get(`/encryption/transaction-recipients`, {
params: {
from_account_id: component.from_account_id,
to_account_id: component.to_account_id
}
});
// Encrypt description
const encrypted = await window.universalEncryption.encryptData(
component.description,
recipients.data
);
// Replace description with encrypted version
component.description = encrypted.encryptedData;
// Store encryption metadata
component.encryptionData = {
field: 'description',
recipient_keys: encrypted.recipientKeys,
is_encrypted: true
};
console.log('Transaction description encrypted');
} catch (error) {
console.error('Failed to encrypt transaction:', error);
// Allow fallback to plaintext
}
}
});
// Decrypt transactions when loaded
Livewire.hook('message.received', async ({ component }) => {
if (component.fingerprint.name.includes('transaction')) {
await decryptTransactions(component);
}
});
});
async function decryptTransactions(component) {
// Find all encrypted transaction elements
const encryptedTransactions = component.$wire.transactions?.filter(t => t.is_encrypted) || [];
for (const transaction of encryptedTransactions) {
if (transaction.description && transaction.description_is_encrypted) {
try {
transaction.description = await window.universalEncryption.decryptModelField(
'App\\Models\\Transaction',
transaction.id,
'description',
transaction.description
);
transaction.decryption_failed = false;
} catch (error) {
console.error('Failed to decrypt transaction:', transaction.id, error);
transaction.description = '[Encrypted - Cannot Decrypt]';
transaction.decryption_failed = true;
}
}
}
}
5. Backend API Controllers
5.1 Universal Encryption Controller
// app/Http/Controllers/UniversalEncryptionController.php
namespace App\Http\Controllers;
use App\Models\EncryptedDataKey;
use App\Models\EncryptionKey;
use App\Models\Transaction;
use Illuminate\Http\Request;
class UniversalEncryptionController extends Controller
{
/**
* Get encryption keys for the authenticated user
*/
public function getMyKeys()
{
$user = auth()->user();
$keys = EncryptionKey::where([
'owner_id' => $user->id,
'owner_type' => get_class($user),
'is_active' => true
])->first();
if (!$keys) {
return response()->json(['error' => 'No keys found'], 404);
}
return response()->json([
'public_key' => $keys->public_key,
'private_key' => decrypt($keys->encrypted_private_key)
]);
}
/**
* Store encryption keys
*/
public function storeKeys(Request $request)
{
$user = auth()->user();
EncryptionKey::updateOrCreate(
[
'owner_id' => $user->id,
'owner_type' => get_class($user),
'is_active' => true
],
[
'public_key' => $request->public_key,
'encrypted_private_key' => encrypt($request->private_key),
'key_version' => 'v1'
]
);
return response()->json(['success' => true]);
}
/**
* Get recipients for a model (for encryption)
*/
public function getRecipients(string $modelType, int $modelId)
{
$model = $this->findModel($modelType, $modelId);
if (!method_exists($model, 'getEncryptionRecipients')) {
return response()->json(['error' => 'Model does not support encryption'], 400);
}
$recipients = $model->getEncryptionRecipients();
return response()->json(
collect($recipients)->map(function ($recipient) {
$key = EncryptionKey::where([
'owner_id' => $recipient->id,
'owner_type' => get_class($recipient),
'is_active' => true
])->first();
return [
'id' => $recipient->id,
'type' => get_class($recipient),
'public_key' => $key->public_key ?? null
];
})->filter(fn($r) => $r['public_key'] !== null)->values()
);
}
/**
* Get decryption key for a specific model field
*/
public function getDecryptionKey(string $modelType, int $modelId, string $field)
{
$user = auth()->user();
$model = $this->findModel($modelType, $modelId);
// Check permission
if (!method_exists($model, 'canDecrypt') || !$model->canDecrypt($user, $field)) {
return response()->json(['error' => 'Access denied'], 403);
}
$key = EncryptedDataKey::where([
'encryptable_id' => $modelId,
'encryptable_type' => $modelType,
'encryptable_field' => $field,
'recipient_id' => $user->id,
'recipient_type' => get_class($user)
])->first();
if (!$key) {
return response()->json(['error' => 'Key not found'], 404);
}
return response()->json([
'encrypted_data_key' => $key->encrypted_data_key,
'nonce' => $key->nonce
]);
}
/**
* Get transaction recipients (specialized endpoint for transactions)
*/
public function getTransactionRecipients(Request $request)
{
$fromAccountId = $request->from_account_id;
$toAccountId = $request->to_account_id;
$recipients = [];
// Get from account owners
if ($fromAccountId) {
$fromAccount = \App\Models\Account::with('accountable')->find($fromAccountId);
if ($fromAccount && $fromAccount->accountable) {
$recipients = array_merge($recipients, $fromAccount->accountable->all());
}
}
// Get to account owners
if ($toAccountId) {
$toAccount = \App\Models\Account::with('accountable')->find($toAccountId);
if ($toAccount && $toAccount->accountable) {
$recipients = array_merge($recipients, $toAccount->accountable->all());
}
}
$recipients = array_unique($recipients, SORT_REGULAR);
return response()->json(
collect($recipients)->map(function ($recipient) {
$key = EncryptionKey::where([
'owner_id' => $recipient->id,
'owner_type' => get_class($recipient),
'is_active' => true
])->first();
return [
'id' => $recipient->id,
'type' => get_class($recipient),
'public_key' => $key->public_key ?? null
];
})->filter(fn($r) => $r['public_key'] !== null)->values()
);
}
/**
* Store encryption keys for a model field
*/
public function storeFieldKeys(Request $request)
{
$validated = $request->validate([
'model_type' => 'required|string',
'model_id' => 'required|integer',
'field' => 'required|string',
'recipient_keys' => 'required|array',
'recipient_keys.*.recipient_id' => 'required|integer',
'recipient_keys.*.recipient_type' => 'required|string',
'recipient_keys.*.encrypted_key' => 'required|string',
'recipient_keys.*.nonce' => 'required|string',
]);
$model = $this->findModel($validated['model_type'], $validated['model_id']);
if (!method_exists($model, 'storeEncryptionKeys')) {
return response()->json(['error' => 'Model does not support encryption'], 400);
}
$model->storeEncryptionKeys($validated['field'], $validated['recipient_keys']);
return response()->json(['success' => true]);
}
/**
* Helper to find and authorize model access
*/
protected function findModel(string $modelType, int $modelId)
{
// Whitelist allowed models for security
$allowedModels = [
'App\\Models\\Transaction',
'App\\Models\\Message',
'App\\Models\\Post', // Future
// Add more as needed
];
if (!in_array($modelType, $allowedModels)) {
abort(400, 'Invalid model type');
}
$model = $modelType::find($modelId);
if (!$model) {
abort(404, 'Model not found');
}
return $model;
}
}
5.2 Routes
// routes/encryption.php
use App\Http\Controllers\UniversalEncryptionController;
Route::middleware(['auth'])->prefix('encryption')->group(function () {
// Key management
Route::post('/store-keys', [UniversalEncryptionController::class, 'storeKeys']);
Route::get('/my-keys', [UniversalEncryptionController::class, 'getMyKeys']);
// Universal encryption/decryption
Route::get('/recipients/{modelType}/{modelId}', [UniversalEncryptionController::class, 'getRecipients']);
Route::get('/key/{modelType}/{modelId}/{field}', [UniversalEncryptionController::class, 'getDecryptionKey']);
Route::post('/store-field-keys', [UniversalEncryptionController::class, 'storeFieldKeys']);
// Transaction-specific helpers
Route::get('/transaction-recipients', [UniversalEncryptionController::class, 'getTransactionRecipients']);
});
6. Livewire Components
6.1 Transaction Form Component
// app/Livewire/Transactions/CreateTransaction.php
namespace App\Livewire\Transactions;
use App\Models\Transaction;
use Livewire\Component;
class CreateTransaction extends Component
{
public $from_account_id;
public $to_account_id;
public $amount;
public $description;
public $encryptionData = null;
public function save()
{
$this->validate([
'from_account_id' => 'required|exists:accounts,id',
'to_account_id' => 'required|exists:accounts,id',
'amount' => 'required|numeric|min:0',
'description' => 'nullable|string',
]);
// Create transaction
$transaction = Transaction::create([
'from_account_id' => $this->from_account_id,
'to_account_id' => $this->to_account_id,
'amount' => $this->amount,
'description' => $this->description, // Already encrypted by frontend
'creator_user_id' => auth()->id(),
'is_encrypted' => !empty($this->encryptionData),
'encryption_version' => 'v1'
]);
// Store encryption keys if encrypted
if ($this->encryptionData) {
$transaction->storeEncryptionKeys(
$this->encryptionData['field'],
$this->encryptionData['recipient_keys']
);
}
session()->flash('message', 'Transaction created successfully');
return redirect()->route('transactions.index');
}
public function render()
{
return view('livewire.transactions.create-transaction');
}
}
6.2 Transaction List Component
// app/Livewire/Transactions/TransactionList.php
namespace App\Livewire\Transactions;
use App\Models\Transaction;
use Livewire\Component;
use Livewire\WithPagination;
class TransactionList extends Component
{
use WithPagination;
public function render()
{
$user = auth()->user();
// Get user's accounts
$accountIds = $user->accounts->pluck('id');
// Get transactions for user's accounts
$transactions = Transaction::whereIn('from_account_id', $accountIds)
->orWhereIn('to_account_id', $accountIds)
->with(['accountFrom', 'accountTo'])
->orderBy('created_at', 'desc')
->paginate(20);
// Frontend will handle decryption of encrypted descriptions
return view('livewire.transactions.transaction-list', [
'transactions' => $transactions
]);
}
}
7. Blade Templates
7.1 Transaction Form
{{-- resources/views/livewire/transactions/create-transaction.blade.php --}}
<div>
<form wire:submit.prevent="save" x-data="{ encrypting: false }">
<div class="mb-4">
<label>From Account</label>
<select wire:model="from_account_id" class="form-select">
<!-- Account options -->
</select>
</div>
<div class="mb-4">
<label>To Account</label>
<select wire:model="to_account_id" class="form-select">
<!-- Account options -->
</select>
</div>
<div class="mb-4">
<label>Amount</label>
<input type="number" wire:model="amount" class="form-input">
</div>
<div class="mb-4">
<label>
Description
<span class="text-xs text-gray-500" x-show="encrypting">
<i class="fas fa-lock"></i> Encrypting...
</span>
</label>
<textarea
wire:model="description"
class="form-textarea"
rows="3"
></textarea>
<p class="text-xs text-gray-600 mt-1">
<i class="fas fa-lock"></i> Description will be encrypted for transaction participants
</p>
</div>
<button type="submit" class="btn btn-primary">
Create Transaction
</button>
</form>
</div>
7.2 Transaction List
{{-- resources/views/livewire/transactions/transaction-list.blade.php --}}
<div x-data="transactionDecryption()">
<table class="table">
<thead>
<tr>
<th>Date</th>
<th>From</th>
<th>To</th>
<th>Amount</th>
<th>Description</th>
</tr>
</thead>
<tbody>
@foreach($transactions as $transaction)
<tr x-data="{
decrypted: false,
decrypting: {{ $transaction->is_encrypted ? 'true' : 'false' }},
description: @js($transaction->description)
}" x-init="if (decrypting) decryptTransaction({{ $transaction->id }}, 'description')">
<td>{{ $transaction->created_at->format('Y-m-d') }}</td>
<td>{{ $transaction->accountFrom->name }}</td>
<td>{{ $transaction->accountTo->name }}</td>
<td>{{ $transaction->amount }}</td>
<td>
@if($transaction->is_encrypted)
<span x-show="decrypting && !decrypted">
<i class="fas fa-spinner fa-spin"></i> Decrypting...
</span>
<span x-show="decrypted" x-text="description"></span>
<i class="fas fa-lock text-xs text-gray-500 ml-1" title="Encrypted"></i>
@else
{{ $transaction->description }}
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<script>
function transactionDecryption() {
return {
async decryptTransaction(transactionId, field) {
try {
const decrypted = await window.universalEncryption.decryptModelField(
'App\\Models\\Transaction',
transactionId,
field,
this.description
);
this.description = decrypted;
this.decrypted = true;
this.decrypting = false;
} catch (error) {
console.error('Decryption failed:', error);
this.description = '[Cannot Decrypt]';
this.decrypted = true;
this.decrypting = false;
}
}
}
}
</script>
8. Database Migrations
8.1 Create Universal Tables
// database/migrations/YYYY_MM_DD_create_universal_encryption_tables.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up()
{
// Universal encryption keys
Schema::create('encryption_keys', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('owner_id');
$table->string('owner_type');
$table->text('public_key');
$table->text('encrypted_private_key');
$table->string('key_version')->default('v1');
$table->boolean('is_active')->default(true);
$table->timestamps();
$table->index(['owner_id', 'owner_type']);
$table->unique(['owner_id', 'owner_type', 'is_active'], 'unique_active_key');
});
// Universal encrypted data keys
Schema::create('encrypted_data_keys', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('encryptable_id');
$table->string('encryptable_type');
$table->string('encryptable_field', 100);
$table->unsignedBigInteger('recipient_id');
$table->string('recipient_type');
$table->text('encrypted_data_key');
$table->string('nonce');
$table->string('algorithm')->default('AES-256-GCM');
$table->timestamps();
$table->index(['encryptable_id', 'encryptable_type', 'encryptable_field'], 'idx_encryptable');
$table->index(['recipient_id', 'recipient_type'], 'idx_recipient');
$table->unique([
'encryptable_id',
'encryptable_type',
'encryptable_field',
'recipient_id',
'recipient_type'
], 'unique_recipient_key');
});
}
public function down()
{
Schema::dropIfExists('encrypted_data_keys');
Schema::dropIfExists('encryption_keys');
}
};
8.2 Add Encryption to Transactions
// database/migrations/YYYY_MM_DD_add_encryption_to_transactions.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up()
{
Schema::table('transactions', function (Blueprint $table) {
$table->boolean('is_encrypted')->default(false)->after('description');
$table->string('encryption_version', 10)->nullable()->after('is_encrypted');
$table->json('encrypted_fields')->nullable()->after('encryption_version')
->comment('JSON array of encrypted field names');
$table->index('is_encrypted');
});
}
public function down()
{
Schema::table('transactions', function (Blueprint $table) {
$table->dropColumn(['is_encrypted', 'encryption_version', 'encrypted_fields']);
});
}
};
8.3 Add Encryption to WireChat Messages
// database/migrations/YYYY_MM_DD_add_encryption_to_wirechat_messages.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up()
{
Schema::table('wirechat_messages', function (Blueprint $table) {
$table->boolean('is_encrypted')->default(false)->after('body');
$table->string('encryption_version', 10)->nullable()->after('is_encrypted');
$table->json('encrypted_fields')->nullable()->after('encryption_version')
->comment('JSON array of encrypted field names');
$table->index('is_encrypted');
});
}
public function down()
{
Schema::table('wirechat_messages', function (Blueprint $table) {
$table->dropColumn(['is_encrypted', 'encryption_version', 'encrypted_fields']);
});
}
};
9. Implementation Timeline
Phase 1: Universal Foundation (Week 1-2)
- Create universal encryption tables migrations
- Create
EncryptionKeyandEncryptedDataKeymodels - Create
HasEncryptedFieldstrait - Create
UniversalEncryptionController - Add encryption routes
- Write unit tests
Phase 2: WireChat Integration (Week 2-3)
- Apply trait to Message model
- Update WireChat frontend integration
- Test message encryption/decryption
- Handle group conversations
- Test backward compatibility
Phase 3: Transaction Integration (Week 3-4)
- Apply trait to Transaction model
- Create transaction encryption frontend
- Update transaction forms
- Update transaction lists
- Test encryption with from/to accounts
- Handle Elasticsearch indexing
Phase 4: Frontend Universal Module (Week 4-5)
- Complete
universal-encryption.js - Create helper functions
- Add error handling
- Add loading states
- Performance optimization
- Browser compatibility testing
Phase 5: Testing & Polish (Week 5-6)
- End-to-end testing
- Multi-user testing
- Permission testing
- Performance testing
- Security audit
- Documentation
Phase 6: Deployment (Week 6-7)
- Gradual rollout with feature flag
- Monitor for errors
- User communication
- Admin training
- Performance monitoring
10. Adding Encryption to New Models (Future)
Example: Encrypting Post Content
// app/Models/Post.php
use App\Traits\HasEncryptedFields;
class Post extends Model
{
use HasEncryptedFields;
protected $encryptable = ['content', 'private_notes'];
protected function getEncryptionRecipients(): array
{
$recipients = [$this->user]; // Post author
// Add moderators if needed
if ($this->is_private) {
$moderators = User::role('moderator')->get();
$recipients = array_merge($recipients, $moderators->all());
}
return $recipients;
}
}
Then run migration:
Schema::table('posts', function (Blueprint $table) {
$table->boolean('is_encrypted')->default(false);
$table->string('encryption_version', 10)->nullable();
$table->json('encrypted_fields')->nullable();
});
Frontend automatically works:
// Encryption
const encrypted = await window.universalEncryption.encryptModelField(
'App\\Models\\Post',
postId,
'content',
contentValue
);
// Decryption
const decrypted = await window.universalEncryption.decryptModelField(
'App\\Models\\Post',
postId,
'content',
encryptedContent
);
11. Configuration
// config/encryption.php
return [
'enabled' => env('ENCRYPTION_ENABLED', false),
'enforce' => env('ENCRYPTION_ENFORCE', false),
// Which models have encryption enabled
'models' => [
'messages' => env('ENCRYPT_MESSAGES', true),
'transactions' => env('ENCRYPT_TRANSACTIONS', true),
'posts' => env('ENCRYPT_POSTS', false),
],
// Algorithm settings
'rsa_key_size' => 2048,
'aes_key_size' => 256,
'algorithm' => 'AES-256-GCM',
// Performance
'cache_keys' => true,
'cache_duration' => 3600, // 1 hour
];
12. Security Considerations
12.1 Transaction-Specific Concerns
Elasticsearch Indexing:
- Encrypted descriptions not searchable
- Mark as
[Encrypted]in index - Consider separate plaintext "public description" field if search needed
Database Constraints:
- Transaction table has UPDATE/DELETE restrictions
- Encryption metadata immutable after creation
- Audit all changes
Multi-Account Transactions:
- Organization accounts may have multiple users
- All account owners get decryption keys
- Consider role-based access within organizations
12.2 General Best Practices
✅ DO:
- Validate recipients before encrypting
- Log all encryption/decryption attempts
- Regular key rotation policy
- Monitor failed decryption attempts
- Backup encryption keys securely
❌ DON'T:
- Store plaintext alongside encrypted
- Log decrypted content
- Allow encryption without recipients
- Expose decryption keys in API responses
- Cache decrypted data client-side
13. Advantages of Universal System
| Aspect | Benefit |
|---|---|
| Code Reusability | Single trait for all models |
| Consistency | Same encryption everywhere |
| Maintainability | One place to fix bugs |
| Extensibility | Add new models easily |
| Testing | Test once, works everywhere |
| Security | Consistent security model |
| Performance | Shared key cache |
14. Migration from Separate Systems
If you implemented WireChat encryption separately, migration is straightforward:
- Create universal tables
- Migrate existing
user_encryption_keys→encryption_keys - Migrate existing
message_encryption_keys→encrypted_data_keys - Update foreign keys
- Test thoroughly
- Drop old tables
No data loss, just schema unification.
15. Conclusion
This universal encryption system provides:
✅ Single Source of Truth - One encryption system for everything ✅ Easy Extension - Add encryption to any model with one trait ✅ Consistent Security - Same strong encryption everywhere ✅ Future-Proof - Built for growth ✅ Maintainable - Less code duplication ✅ Performant - Shared infrastructure and caching
Next Steps:
- Review and approve this plan
- Begin Phase 1 implementation
- Test with WireChat first
- Add transactions
- Expand to other models as needed
Document Version: 1.0 Created: 2025-11-30 Extends: e2e-encryption-simplified-plan.md For: Timebank.cc Universal Encryption System