laravellaravel-livewirepublic-key-encryption

End-to-End Payload Encryption in Laravel Livewire


Livewire Version: 3.5.12

I’d like to share and discuss an approach for adding end-to-end payload encryption on top of HTTPS for a Laravel 11 application using Livewire v3. This setup adds an additional layer of security by encrypting specific client-server payloads, keeping sensitive data secure even within HTTPS-protected requests.

Key Features:

Custom JavaScript for Client-Side Encryption: A javascript module(class) that encrypts outgoing requests and decrypts incoming responses for specified endpoints using Web Crypto API.

Middleware for Server-Side Decryption/Encryption: Laravel middleware decrypts incoming payloads and encrypts outgoing responses, providing seamless integration with the backend.

Configurable Encryption: An environment variable toggles encryption on or off, making it adaptable to different environments or deployments.

Key Exchange Mechanism: On the first load, the application fetches a public encryption key from the server, used by the custom javascript module to handle encryption.

Path Whitelisting: Certain paths can bypass encryption to avoid unnecessary processing on endpoints that don’t require this extra layer of security.

The only thing I'm struggling with is how to tap into the Livewire requests and responses to change the outgoing payload to encrypted one and change the incoming response to decrypted one. Where should I wire up the frontend code? I have looked into the Livewire docs and have tried customizing the Request Hook

Relevant snippet of the custom javascript module:

// Initialize encryption service and register Livewire hooks
document.addEventListener('DOMContentLoaded', async () => {
    // Create a global instance of EncryptionService
    window.encryptionService = new EncryptionService();

    // Wait for Filament/Livewire to be ready
    await window.$wire?.initialRender;

    // Initialize encryption service
    await window.encryptionService.initialize();

    // Register Livewire request hook with encryption and decryption
    Livewire.hook('request', async ({ uri, options, payload, respond, succeed, fail }) => {
        // Encrypt request payload if encryption is enabled
        if (window.encryptionService.isEnabled && window.encryptionService.sessionId && payload) {
            try {
                // Set headers to identify the encrypted request
                options.headers = options.headers || {};
                options.headers['X-Session-Id'] = window.encryptionService.sessionId;
                options.headers['X-Encrypted'] = '1';

                // Encrypt the payload
                const encryptedPayload = await window.encryptionService.encryptPayload(payload);
                console.log('Encrypted Payload:', encryptedPayload);

                // Overwrite the payload with the encrypted content
                payload = encryptedPayload;

            } catch (error) {
                console.error('Encryption failed:', error);
                // If encryption fails, proceed without encryption
                payload = JSON.stringify(payload);
            }
        }

        // Handle the response
        respond(({ status, response }) => {
            // Check if the response is encrypted by looking at the header
            const isEncrypted = response.headers.get('X-Encrypted') === '1';

            // If encrypted, proceed to decrypt after parsing the raw response
            if (isEncrypted && window.encryptionService.sessionId) {
                response.text().then(async (encryptedContent) => {
                    try {
                        const decryptedContent = await window.encryptionService.decryptPayload(encryptedContent);
                        succeed({ status, json: decryptedContent });
                    } catch (error) {
                        console.error('Decryption failed:', error);
                        fail({ status, content: 'Decryption failed' });
                    }
                }).catch((error) => {
                    console.error('Error reading response content:', error);
                    fail({ status, content: 'Failed to read encrypted response content' });
                });
            } else {
                // If the response isn't encrypted, handle it as usual
                succeed({ status, json: response.json() });
            }
        });

        // If decryption fails or if there’s an error, handle it in `fail`
        fail(({ status, content, preventDefault }) => {
            console.error('Request failed:', status, content);
            if (window.$wireui) {
                window.$wireui.notification({
                    title: 'Error',
                    description: 'Failed to process request',
                    icon: 'error'
                });
            }
        });

        // Return the (possibly modified) payload to be sent in the request
        return payload;
    });
});

But this doesn't work and still sends the normal JSON payload to backend even though the above javascript is properly executed. Any pointers in right direction would be helpful


Solution

  • I hope this is not too late.

    I think the problem lies in the object destruct you are doing because when you do that, the property is detached from the object so doing payload = encryptedPayload is not going to do anything so you want to do request.payload = encryptedPayload instead to make sure the object passed to the callback is mutated.

    // Initialize encryption service and register Livewire hooks
    document.addEventListener('DOMContentLoaded', async () => {
        // Create a global instance of EncryptionService
        window.encryptionService = new EncryptionService();
    
        // Wait for Filament/Livewire to be ready
        await window.$wire?.initialRender;
    
        // Initialize encryption service
        await window.encryptionService.initialize();
    
        // Register Livewire request hook with encryption and decryption
        Livewire.hook('request', async (request) => {
            // Encrypt request payload if encryption is enabled
            if (window.encryptionService.isEnabled && window.encryptionService.sessionId && request.payload) {
                try {
                    // Set headers to identify the encrypted request
                    request.options.headers = request.options.headers || {};
                    request.options.headers['X-Session-Id'] = window.encryptionService.sessionId;
                    request.options.headers['X-Encrypted'] = '1';
    
                    // Encrypt the payload
                    const encryptedPayload = await window.encryptionService.encryptPayload(request.payload);
    
                    console.log('Encrypted Payload:', encryptedPayload);
    
                    // Overwrite the payload with the encrypted content
                    request.payload = encryptedPayload;
                } catch (error) {
                    console.error('Encryption failed:', error);
    
                    request.payload = JSON.stringify(request.payload);
                }
            }
    
            // Handle the response
            request.respond(({ status, response }) => {
                // Check if the response is encrypted by looking at the header
                const isEncrypted = response.headers.get('X-Encrypted') === '1';
    
                // If encrypted, proceed to decrypt after parsing the raw response
                if (isEncrypted && window.encryptionService.sessionId) {
                    response.text().then(async (encryptedContent) => {
                        try {
                            const decryptedContent = await window.encryptionService.decryptPayload(encryptedContent);
    
                            request.succeed({ status, json: decryptedContent });
                        } catch (error) {
                            console.error('Decryption failed:', error);
    
                            request.fail({ status, content: 'Decryption failed' });
                        }
                    }).catch((error) => {
                        console.error('Error reading response content:', error);
    
                        request.fail({ status, content: 'Failed to read encrypted response content' });
                    });
                } else {
                    // If the response isn't encrypted, handle it as usual
                    request.succeed({ status, json: response.json() });
                }
            });
    
            // If decryption fails or if there’s an error, handle it in `fail`
            request.fail(({ status, content, preventDefault }) => {
                console.error('Request failed:', status, content);
    
                if (window.$wireui) {
                    window.$wireui.notification({
                        title: 'Error',
                        description: 'Failed to process request',
                        icon: 'error'
                    });
                }
            });
        });
    });