Files
timebank-cc-public/references/plans/e2e-encryption-universal-plan.md
Ronald Huynen 2547717edb Initial commit
2026-03-23 21:37:59 +01:00

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 EncryptionKey and EncryptedDataKey models
  • Create HasEncryptedFields trait
  • 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:

  1. Create universal tables
  2. Migrate existing user_encryption_keysencryption_keys
  3. Migrate existing message_encryption_keysencrypted_data_keys
  4. Update foreign keys
  5. Test thoroughly
  6. 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:

  1. Review and approve this plan
  2. Begin Phase 1 implementation
  3. Test with WireChat first
  4. Add transactions
  5. 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