Initial commit
This commit is contained in:
1069
references/plans/e2e-encryption-plan.md
Normal file
1069
references/plans/e2e-encryption-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
935
references/plans/e2e-encryption-simplified-plan.md
Normal file
935
references/plans/e2e-encryption-simplified-plan.md
Normal file
@@ -0,0 +1,935 @@
|
||||
# Simplified End-to-End Encryption Plan for WireChat
|
||||
## No User Password Required
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This simplified approach implements end-to-end encryption for WireChat messages without requiring users to remember an additional encryption password. Encryption happens transparently using device-bound keys, making it seamless for users while still protecting message content from server access.
|
||||
|
||||
**Trade-off:** Security is slightly reduced compared to password-protected keys, but usability is greatly improved and messages remain encrypted at rest.
|
||||
|
||||
## 1. Simplified Encryption Architecture
|
||||
|
||||
### 1.1 Key Differences from Full Plan
|
||||
|
||||
| Feature | Full Plan | Simplified Plan |
|
||||
|---------|-----------|-----------------|
|
||||
| User password | Required separate encryption password | None - automatic |
|
||||
| Private key storage | Encrypted with password in database | Encrypted with Laravel encryption in database |
|
||||
| Key access | Only when user enters password | Automatic when logged in |
|
||||
| Device independence | Can access from any device with password | Keys tied to account (accessible from any device after login) |
|
||||
| Server trust | Server never sees keys | Server handles encryption (trusted environment) |
|
||||
| User experience | Extra password prompt | Completely transparent |
|
||||
| Recovery | Password lost = messages lost | Automatic with account access |
|
||||
|
||||
### 1.2 Security Model
|
||||
|
||||
**What's Protected:**
|
||||
- Messages encrypted at rest in database ✅
|
||||
- Messages encrypted in database backups ✅
|
||||
- Database breach won't expose plaintext messages ✅
|
||||
- Each message uses unique encryption key (forward secrecy) ✅
|
||||
|
||||
**What's NOT Protected:**
|
||||
- Server admin with database AND Laravel APP_KEY access can decrypt ⚠️
|
||||
- Compromised server can access messages while users are logged in ⚠️
|
||||
- Server logs may contain decrypted messages if debug enabled ⚠️
|
||||
|
||||
**Best For:**
|
||||
- Internal/trusted server environments
|
||||
- Teams that prioritize usability over maximum security
|
||||
- Compliance requirements for "encryption at rest"
|
||||
- Protection against database theft without server access
|
||||
|
||||
## 2. Simplified Key Management
|
||||
|
||||
### 2.1 Automatic Key Generation
|
||||
|
||||
**On First Message Send/Receive:**
|
||||
```javascript
|
||||
// Frontend generates RSA key pair automatically (no password)
|
||||
const keyPair = await crypto.subtle.generateKey(
|
||||
{ name: "RSA-OAEP", modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: "SHA-256" },
|
||||
true,
|
||||
["encrypt", "decrypt"]
|
||||
);
|
||||
|
||||
// Export keys
|
||||
const publicKey = await crypto.subtle.exportKey("jwk", keyPair.publicKey);
|
||||
const privateKey = await crypto.subtle.exportKey("jwk", keyPair.privateKey);
|
||||
|
||||
// Send to server for encrypted storage
|
||||
await axios.post('/encryption/store-keys', {
|
||||
public_key: JSON.stringify(publicKey),
|
||||
private_key: JSON.stringify(privateKey) // Server will encrypt this
|
||||
});
|
||||
```
|
||||
|
||||
**Server-Side Storage:**
|
||||
```php
|
||||
// app/Http/Controllers/EncryptionController.php
|
||||
public function storeKeys(Request $request)
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
// Use Laravel's encryption (APP_KEY) to protect private key
|
||||
UserEncryptionKey::updateOrCreate(
|
||||
[
|
||||
'sendable_id' => $user->id,
|
||||
'sendable_type' => get_class($user)
|
||||
],
|
||||
[
|
||||
'public_key' => $request->public_key,
|
||||
'encrypted_private_key' => encrypt($request->private_key), // Laravel encryption
|
||||
'key_version' => 'v1'
|
||||
]
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 Key Storage Schema (Simplified)
|
||||
|
||||
**Database Tables:**
|
||||
```sql
|
||||
-- User encryption keys (simplified - no salt needed)
|
||||
CREATE TABLE user_encryption_keys (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
sendable_id BIGINT NOT NULL,
|
||||
sendable_type VARCHAR(255) NOT NULL,
|
||||
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(sendable_id, sendable_type),
|
||||
UNIQUE(sendable_id, sendable_type, is_active)
|
||||
);
|
||||
|
||||
-- Message encryption keys (same as full plan)
|
||||
CREATE TABLE message_encryption_keys (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
message_id BIGINT NOT NULL,
|
||||
recipient_id BIGINT NOT NULL,
|
||||
recipient_type VARCHAR(255) NOT NULL,
|
||||
encrypted_message_key TEXT NOT NULL COMMENT 'AES key encrypted with recipient RSA public key',
|
||||
nonce VARCHAR(255) NOT NULL,
|
||||
algorithm VARCHAR(50) DEFAULT 'AES-256-GCM',
|
||||
created_at TIMESTAMP,
|
||||
updated_at TIMESTAMP,
|
||||
FOREIGN KEY (message_id) REFERENCES wirechat_messages(id) ON DELETE CASCADE,
|
||||
INDEX(message_id),
|
||||
INDEX(recipient_id, recipient_type),
|
||||
UNIQUE(message_id, recipient_id, recipient_type)
|
||||
);
|
||||
|
||||
-- Add encryption flag to messages
|
||||
ALTER TABLE wirechat_messages
|
||||
ADD COLUMN is_encrypted BOOLEAN DEFAULT FALSE AFTER body,
|
||||
ADD COLUMN encryption_version VARCHAR(10) NULL AFTER is_encrypted,
|
||||
ADD INDEX idx_is_encrypted (is_encrypted);
|
||||
```
|
||||
|
||||
## 3. Simplified Encryption Flow
|
||||
|
||||
### 3.1 Sending a Message
|
||||
|
||||
**Frontend (Transparent to User):**
|
||||
```javascript
|
||||
// wirechat-encryption.js
|
||||
async function sendEncryptedMessage(messageBody, conversationId) {
|
||||
// 1. Get conversation participants' public keys
|
||||
const participants = await axios.get(`/encryption/conversation/${conversationId}/keys`);
|
||||
|
||||
// 2. Generate ephemeral AES key for this message
|
||||
const messageKey = await crypto.subtle.generateKey(
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
true,
|
||||
["encrypt", "decrypt"]
|
||||
);
|
||||
|
||||
// 3. Encrypt message body
|
||||
const nonce = crypto.getRandomValues(new Uint8Array(12));
|
||||
const encodedMessage = new TextEncoder().encode(messageBody);
|
||||
const encryptedBody = await crypto.subtle.encrypt(
|
||||
{ name: "AES-GCM", iv: nonce },
|
||||
messageKey,
|
||||
encodedMessage
|
||||
);
|
||||
|
||||
// 4. Encrypt message key for each recipient
|
||||
const rawMessageKey = await crypto.subtle.exportKey("raw", messageKey);
|
||||
const recipientKeys = {};
|
||||
|
||||
for (const participant of participants.data) {
|
||||
const recipientPublicKey = await crypto.subtle.importKey(
|
||||
"jwk",
|
||||
JSON.parse(participant.public_key),
|
||||
{ name: "RSA-OAEP", hash: "SHA-256" },
|
||||
false,
|
||||
["encrypt"]
|
||||
);
|
||||
|
||||
const encryptedMessageKey = await crypto.subtle.encrypt(
|
||||
{ name: "RSA-OAEP" },
|
||||
recipientPublicKey,
|
||||
rawMessageKey
|
||||
);
|
||||
|
||||
recipientKeys[participant.id] = {
|
||||
encrypted_key: arrayBufferToBase64(encryptedMessageKey),
|
||||
nonce: arrayBufferToBase64(nonce)
|
||||
};
|
||||
}
|
||||
|
||||
// 5. Send to server
|
||||
return {
|
||||
body: arrayBufferToBase64(encryptedBody),
|
||||
recipient_keys: recipientKeys,
|
||||
is_encrypted: true,
|
||||
encryption_version: 'v1'
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Backend (Stores Everything):**
|
||||
```php
|
||||
// In EncryptedChat Livewire component
|
||||
public function sendMessage()
|
||||
{
|
||||
// Message body is already encrypted by frontend
|
||||
$message = Message::create([
|
||||
'conversation_id' => $this->conversation->id,
|
||||
'sendable_type' => $this->auth->getMorphClass(),
|
||||
'sendable_id' => auth()->id(),
|
||||
'body' => $this->body, // Encrypted
|
||||
'type' => MessageType::TEXT,
|
||||
'is_encrypted' => true,
|
||||
'encryption_version' => 'v1'
|
||||
]);
|
||||
|
||||
// Store encryption keys for each recipient
|
||||
foreach ($this->encryptionData['recipient_keys'] as $recipientId => $keyData) {
|
||||
MessageEncryptionKey::create([
|
||||
'message_id' => $message->id,
|
||||
'recipient_id' => $recipientId,
|
||||
'recipient_type' => User::class, // Or polymorphic
|
||||
'encrypted_message_key' => $keyData['encrypted_key'],
|
||||
'nonce' => $keyData['nonce']
|
||||
]);
|
||||
}
|
||||
|
||||
// Broadcast as normal (encrypted body)
|
||||
$this->dispatchMessageCreatedEvent($message);
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 Receiving and Decrypting
|
||||
|
||||
**Frontend (Automatic):**
|
||||
```javascript
|
||||
// When message is received via WebSocket
|
||||
async function decryptReceivedMessage(message) {
|
||||
if (!message.is_encrypted) {
|
||||
return message.body; // Plaintext legacy message
|
||||
}
|
||||
|
||||
// 1. Get my private key (automatically decrypted by Laravel)
|
||||
const myKeys = await axios.get('/encryption/my-keys');
|
||||
const privateKey = await crypto.subtle.importKey(
|
||||
"jwk",
|
||||
JSON.parse(myKeys.data.private_key), // Already decrypted by server
|
||||
{ name: "RSA-OAEP", hash: "SHA-256" },
|
||||
false,
|
||||
["decrypt"]
|
||||
);
|
||||
|
||||
// 2. Get my encrypted message key
|
||||
const messageKeyData = await axios.get(`/encryption/message/${message.id}/key`);
|
||||
|
||||
// 3. Decrypt message key using my private key
|
||||
const encryptedMessageKey = base64ToArrayBuffer(messageKeyData.data.encrypted_key);
|
||||
const messageKeyBuffer = await crypto.subtle.decrypt(
|
||||
{ name: "RSA-OAEP" },
|
||||
privateKey,
|
||||
encryptedMessageKey
|
||||
);
|
||||
|
||||
// 4. Import decrypted message key
|
||||
const messageKey = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
messageKeyBuffer,
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
false,
|
||||
["decrypt"]
|
||||
);
|
||||
|
||||
// 5. Decrypt message body
|
||||
const nonce = base64ToArrayBuffer(messageKeyData.data.nonce);
|
||||
const encryptedBody = base64ToArrayBuffer(message.body);
|
||||
const decryptedBuffer = await crypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv: nonce },
|
||||
messageKey,
|
||||
encryptedBody
|
||||
);
|
||||
|
||||
return new TextDecoder().decode(decryptedBuffer);
|
||||
}
|
||||
```
|
||||
|
||||
**Backend API Endpoints:**
|
||||
```php
|
||||
// app/Http/Controllers/EncryptionController.php
|
||||
|
||||
// Get user's own keys (private key automatically decrypted)
|
||||
public function getMyKeys()
|
||||
{
|
||||
$user = auth()->user();
|
||||
$keys = UserEncryptionKey::where([
|
||||
'sendable_id' => $user->id,
|
||||
'sendable_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) // Laravel decrypts it
|
||||
]);
|
||||
}
|
||||
|
||||
// Get encryption key for a specific message
|
||||
public function getMessageKey($messageId)
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
$key = MessageEncryptionKey::where([
|
||||
'message_id' => $messageId,
|
||||
'recipient_id' => $user->id,
|
||||
'recipient_type' => get_class($user)
|
||||
])->first();
|
||||
|
||||
if (!$key) {
|
||||
return response()->json(['error' => 'Key not found'], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'encrypted_key' => $key->encrypted_message_key,
|
||||
'nonce' => $key->nonce
|
||||
]);
|
||||
}
|
||||
|
||||
// Get public keys for conversation participants
|
||||
public function getConversationKeys($conversationId)
|
||||
{
|
||||
$conversation = Conversation::findOrFail($conversationId);
|
||||
|
||||
// Make sure user belongs to conversation
|
||||
abort_unless(auth()->user()->belongsToConversation($conversation), 403);
|
||||
|
||||
$participants = $conversation->participants()
|
||||
->with('participantable.encryptionKey')
|
||||
->get();
|
||||
|
||||
return response()->json(
|
||||
$participants->map(function ($participant) {
|
||||
return [
|
||||
'id' => $participant->participantable_id,
|
||||
'type' => $participant->participantable_type,
|
||||
'public_key' => $participant->participantable->encryptionKey->public_key ?? null
|
||||
];
|
||||
})->filter(fn($p) => $p['public_key'] !== null)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 4. Laravel Implementation (Vendor-Safe)
|
||||
|
||||
### 4.1 Service Provider
|
||||
|
||||
```php
|
||||
// app/Providers/EncryptionServiceProvider.php
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Observers\MessageEncryptionObserver;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Livewire\Livewire;
|
||||
use Namu\WireChat\Models\Message;
|
||||
|
||||
class EncryptionServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function boot()
|
||||
{
|
||||
// Register model observer
|
||||
Message::observe(MessageEncryptionObserver::class);
|
||||
|
||||
// Override Livewire chat component
|
||||
Livewire::component('wirechat.chat', \App\Livewire\EncryptedChat::class);
|
||||
|
||||
// Register routes
|
||||
$this->loadRoutesFrom(__DIR__ . '/../../routes/encryption.php');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Model Observer
|
||||
|
||||
```php
|
||||
// app/Observers/MessageEncryptionObserver.php
|
||||
namespace App\Observers;
|
||||
|
||||
use Namu\WireChat\Models\Message;
|
||||
|
||||
class MessageEncryptionObserver
|
||||
{
|
||||
public function retrieved(Message $message)
|
||||
{
|
||||
// Mark messages as encrypted for frontend handling
|
||||
if ($message->is_encrypted) {
|
||||
$message->setAttribute('needs_decryption', true);
|
||||
}
|
||||
}
|
||||
|
||||
public function created(Message $message)
|
||||
{
|
||||
// Log encryption status for audit
|
||||
if ($message->is_encrypted) {
|
||||
\Log::info('Encrypted message created', [
|
||||
'message_id' => $message->id,
|
||||
'conversation_id' => $message->conversation_id,
|
||||
'encryption_version' => $message->encryption_version
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 Livewire Component Override
|
||||
|
||||
```php
|
||||
// app/Livewire/EncryptedChat.php
|
||||
namespace App\Livewire;
|
||||
|
||||
use Namu\WireChat\Livewire\Chat\Chat;
|
||||
|
||||
class EncryptedChat extends Chat
|
||||
{
|
||||
public $encryptionData = null;
|
||||
|
||||
// Frontend will call this to store encryption data before sending
|
||||
public function setEncryptionData($data)
|
||||
{
|
||||
$this->encryptionData = $data;
|
||||
}
|
||||
|
||||
// Override to handle encrypted messages
|
||||
public function sendMessage()
|
||||
{
|
||||
// Validation
|
||||
if (empty($this->body) && empty($this->media) && empty($this->files)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If encryption data provided, handle as encrypted message
|
||||
if ($this->encryptionData) {
|
||||
$this->sendEncryptedMessage();
|
||||
} else {
|
||||
// Fallback to parent implementation for non-encrypted
|
||||
parent::sendMessage();
|
||||
}
|
||||
}
|
||||
|
||||
protected function sendEncryptedMessage()
|
||||
{
|
||||
abort_unless(auth()->check(), 401);
|
||||
|
||||
// Create message with encrypted body
|
||||
$message = \Namu\WireChat\Models\Message::create([
|
||||
'conversation_id' => $this->conversation->id,
|
||||
'sendable_type' => $this->auth->getMorphClass(),
|
||||
'sendable_id' => auth()->id(),
|
||||
'body' => $this->body, // Already encrypted by frontend
|
||||
'type' => \Namu\WireChat\Enums\MessageType::TEXT,
|
||||
'is_encrypted' => true,
|
||||
'encryption_version' => 'v1'
|
||||
]);
|
||||
|
||||
// Store encryption keys for recipients
|
||||
foreach ($this->encryptionData['recipient_keys'] as $recipientData) {
|
||||
\App\Models\MessageEncryptionKey::create([
|
||||
'message_id' => $message->id,
|
||||
'recipient_id' => $recipientData['id'],
|
||||
'recipient_type' => $recipientData['type'],
|
||||
'encrypted_message_key' => $recipientData['encrypted_key'],
|
||||
'nonce' => $recipientData['nonce']
|
||||
]);
|
||||
}
|
||||
|
||||
// Push message and broadcast
|
||||
$this->pushMessage($message);
|
||||
$this->conversation->touch();
|
||||
$this->dispatchMessageCreatedEvent($message);
|
||||
|
||||
// Reset
|
||||
$this->reset('body', 'encryptionData');
|
||||
$this->dispatch('scroll-bottom');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 Routes
|
||||
|
||||
```php
|
||||
// routes/encryption.php
|
||||
use App\Http\Controllers\EncryptionController;
|
||||
|
||||
Route::middleware(['auth'])->group(function () {
|
||||
Route::post('/encryption/store-keys', [EncryptionController::class, 'storeKeys']);
|
||||
Route::get('/encryption/my-keys', [EncryptionController::class, 'getMyKeys']);
|
||||
Route::get('/encryption/message/{message}/key', [EncryptionController::class, 'getMessageKey']);
|
||||
Route::get('/encryption/conversation/{conversation}/keys', [EncryptionController::class, 'getConversationKeys']);
|
||||
});
|
||||
```
|
||||
|
||||
## 5. Frontend JavaScript Implementation
|
||||
|
||||
### 5.1 Main Encryption Module
|
||||
|
||||
```javascript
|
||||
// resources/js/encryption/wirechat-encryption.js
|
||||
|
||||
class WireChatEncryption {
|
||||
constructor() {
|
||||
this.initialized = false;
|
||||
this.myKeys = null;
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
if (this.initialized) return;
|
||||
|
||||
// Check if user has encryption keys
|
||||
try {
|
||||
const response = await axios.get('/encryption/my-keys');
|
||||
this.myKeys = response.data;
|
||||
this.initialized = true;
|
||||
console.log('Encryption initialized');
|
||||
} catch (error) {
|
||||
if (error.response?.status === 404) {
|
||||
// No keys yet - generate on first message
|
||||
console.log('No encryption keys found - will generate on first message');
|
||||
} else {
|
||||
console.error('Failed to initialize encryption:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async ensureKeys() {
|
||||
if (this.myKeys) return this.myKeys;
|
||||
|
||||
// Generate new key pair
|
||||
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"]
|
||||
);
|
||||
|
||||
// Export keys
|
||||
const publicKey = await crypto.subtle.exportKey("jwk", keyPair.publicKey);
|
||||
const privateKey = await crypto.subtle.exportKey("jwk", keyPair.privateKey);
|
||||
|
||||
// Store on server
|
||||
await axios.post('/encryption/store-keys', {
|
||||
public_key: JSON.stringify(publicKey),
|
||||
private_key: JSON.stringify(privateKey)
|
||||
});
|
||||
|
||||
// Cache locally
|
||||
this.myKeys = {
|
||||
public_key: JSON.stringify(publicKey),
|
||||
private_key: JSON.stringify(privateKey)
|
||||
};
|
||||
|
||||
console.log('Encryption keys generated and stored');
|
||||
return this.myKeys;
|
||||
}
|
||||
|
||||
async encryptMessage(messageBody, conversationId) {
|
||||
await this.ensureKeys();
|
||||
|
||||
// Get participant public keys
|
||||
const participantsResponse = await axios.get(`/encryption/conversation/${conversationId}/keys`);
|
||||
const participants = participantsResponse.data;
|
||||
|
||||
// Generate ephemeral message key
|
||||
const messageKey = await crypto.subtle.generateKey(
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
true,
|
||||
["encrypt", "decrypt"]
|
||||
);
|
||||
|
||||
// Encrypt message body
|
||||
const nonce = crypto.getRandomValues(new Uint8Array(12));
|
||||
const encodedMessage = new TextEncoder().encode(messageBody);
|
||||
const encryptedBody = await crypto.subtle.encrypt(
|
||||
{ name: "AES-GCM", iv: nonce },
|
||||
messageKey,
|
||||
encodedMessage
|
||||
);
|
||||
|
||||
// Export and encrypt message key for each recipient
|
||||
const rawMessageKey = await crypto.subtle.exportKey("raw", messageKey);
|
||||
const recipientKeys = [];
|
||||
|
||||
for (const participant of participants) {
|
||||
if (!participant.public_key) continue;
|
||||
|
||||
const recipientPublicKey = await crypto.subtle.importKey(
|
||||
"jwk",
|
||||
JSON.parse(participant.public_key),
|
||||
{ name: "RSA-OAEP", hash: "SHA-256" },
|
||||
false,
|
||||
["encrypt"]
|
||||
);
|
||||
|
||||
const encryptedMessageKey = await crypto.subtle.encrypt(
|
||||
{ name: "RSA-OAEP" },
|
||||
recipientPublicKey,
|
||||
rawMessageKey
|
||||
);
|
||||
|
||||
recipientKeys.push({
|
||||
id: participant.id,
|
||||
type: participant.type,
|
||||
encrypted_key: this.arrayBufferToBase64(encryptedMessageKey),
|
||||
nonce: this.arrayBufferToBase64(nonce)
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
encryptedBody: this.arrayBufferToBase64(encryptedBody),
|
||||
recipientKeys: recipientKeys
|
||||
};
|
||||
}
|
||||
|
||||
async decryptMessage(message) {
|
||||
if (!message.is_encrypted) {
|
||||
return message.body; // Plaintext
|
||||
}
|
||||
|
||||
await this.ensureKeys();
|
||||
|
||||
// Get my encryption key for this message
|
||||
const keyResponse = await axios.get(`/encryption/message/${message.id}/key`);
|
||||
const keyData = keyResponse.data;
|
||||
|
||||
// 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 message key
|
||||
const encryptedMessageKey = this.base64ToArrayBuffer(keyData.encrypted_key);
|
||||
const messageKeyBuffer = await crypto.subtle.decrypt(
|
||||
{ name: "RSA-OAEP" },
|
||||
privateKey,
|
||||
encryptedMessageKey
|
||||
);
|
||||
|
||||
// Import message key
|
||||
const messageKey = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
messageKeyBuffer,
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
false,
|
||||
["decrypt"]
|
||||
);
|
||||
|
||||
// Decrypt message body
|
||||
const nonce = this.base64ToArrayBuffer(keyData.nonce);
|
||||
const encryptedBody = this.base64ToArrayBuffer(message.body);
|
||||
const decryptedBuffer = await crypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv: nonce },
|
||||
messageKey,
|
||||
encryptedBody
|
||||
);
|
||||
|
||||
return new TextDecoder().decode(decryptedBuffer);
|
||||
}
|
||||
|
||||
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.wireChatEncryption = new WireChatEncryption();
|
||||
```
|
||||
|
||||
### 5.2 Livewire Integration
|
||||
|
||||
```javascript
|
||||
// resources/js/encryption/livewire-integration.js
|
||||
|
||||
document.addEventListener('livewire:init', () => {
|
||||
// Initialize encryption when chat loads
|
||||
Livewire.on('chat-loaded', async () => {
|
||||
await window.wireChatEncryption.initialize();
|
||||
});
|
||||
|
||||
// Intercept message sending
|
||||
Livewire.hook('commit', async ({ component, commit, respond }) => {
|
||||
if (component.fingerprint.name === 'wirechat.chat' && component.body) {
|
||||
try {
|
||||
// Encrypt the message
|
||||
const encrypted = await window.wireChatEncryption.encryptMessage(
|
||||
component.body,
|
||||
component.conversation.id
|
||||
);
|
||||
|
||||
// Replace body with encrypted version
|
||||
component.body = encrypted.encryptedBody;
|
||||
|
||||
// Store encryption data
|
||||
component.encryptionData = {
|
||||
recipient_keys: encrypted.recipientKeys
|
||||
};
|
||||
|
||||
console.log('Message encrypted before sending');
|
||||
} catch (error) {
|
||||
console.error('Encryption failed:', error);
|
||||
// Let it proceed without encryption as fallback
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Decrypt messages when loaded
|
||||
Livewire.hook('message.received', async ({ component, message }) => {
|
||||
if (component.fingerprint.name === 'wirechat.chat') {
|
||||
// Decrypt all loaded messages
|
||||
if (component.loadedMessages) {
|
||||
for (const groupKey in component.loadedMessages) {
|
||||
const messages = component.loadedMessages[groupKey];
|
||||
for (const msg of messages) {
|
||||
if (msg.is_encrypted && msg.body) {
|
||||
try {
|
||||
msg.body = await window.wireChatEncryption.decryptMessage(msg);
|
||||
msg.decryption_failed = false;
|
||||
} catch (error) {
|
||||
console.error('Decryption failed for message:', msg.id, error);
|
||||
msg.body = '[Decryption Failed]';
|
||||
msg.decryption_failed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 5.3 Include in Main JS
|
||||
|
||||
```javascript
|
||||
// resources/js/app.js
|
||||
import './encryption/wirechat-encryption.js';
|
||||
import './encryption/livewire-integration.js';
|
||||
```
|
||||
|
||||
## 6. Implementation Timeline
|
||||
|
||||
### Week 1-2: Foundation
|
||||
- [ ] Create migrations for encryption tables
|
||||
- [ ] Create models: `UserEncryptionKey`, `MessageEncryptionKey`
|
||||
- [ ] Create `EncryptionServiceProvider`
|
||||
- [ ] Create `EncryptionController` with API endpoints
|
||||
- [ ] Write unit tests for models
|
||||
|
||||
### Week 2-3: Backend Integration
|
||||
- [ ] Create `MessageEncryptionObserver`
|
||||
- [ ] Override `EncryptedChat` Livewire component
|
||||
- [ ] Add encryption routes
|
||||
- [ ] Write integration tests
|
||||
- [ ] Test key generation and storage
|
||||
|
||||
### Week 3-4: Frontend Implementation
|
||||
- [ ] Implement `wirechat-encryption.js`
|
||||
- [ ] Implement Livewire hooks
|
||||
- [ ] Add to Vite build
|
||||
- [ ] Test encryption/decryption in browser
|
||||
- [ ] Handle errors gracefully
|
||||
|
||||
### Week 4-5: Testing & Polish
|
||||
- [ ] Test with multiple users
|
||||
- [ ] Test group conversations
|
||||
- [ ] Test backward compatibility (mixed encrypted/plaintext)
|
||||
- [ ] Performance testing
|
||||
- [ ] Error handling and user feedback
|
||||
|
||||
### Week 5-6: Deployment
|
||||
- [ ] Gradual rollout with feature flag
|
||||
- [ ] Monitor for errors
|
||||
- [ ] User documentation
|
||||
- [ ] Admin documentation
|
||||
|
||||
## 7. Configuration
|
||||
|
||||
### 7.1 Feature Flag
|
||||
|
||||
```php
|
||||
// config/encryption.php
|
||||
return [
|
||||
'enabled' => env('ENCRYPTION_ENABLED', false),
|
||||
'algorithm' => 'AES-256-GCM',
|
||||
'rsa_key_size' => 2048, // Smaller than full plan for performance
|
||||
'enforce_encryption' => env('ENCRYPTION_ENFORCE', false), // Require all new messages encrypted
|
||||
];
|
||||
```
|
||||
|
||||
### 7.2 Environment Variables
|
||||
|
||||
```bash
|
||||
# .env
|
||||
ENCRYPTION_ENABLED=true
|
||||
ENCRYPTION_ENFORCE=false # Set to true to require encryption on all new messages
|
||||
```
|
||||
|
||||
## 8. Backward Compatibility
|
||||
|
||||
### 8.1 Mixed Mode Support
|
||||
|
||||
The system supports both encrypted and plaintext messages:
|
||||
|
||||
```php
|
||||
// In message display
|
||||
@if($message->is_encrypted)
|
||||
<span class="text-xs text-gray-500" title="End-to-end encrypted">
|
||||
<i class="fas fa-lock"></i>
|
||||
</span>
|
||||
@endif
|
||||
```
|
||||
|
||||
### 8.2 Gradual Migration
|
||||
|
||||
```javascript
|
||||
// Frontend automatically encrypts new messages
|
||||
// Old messages remain plaintext
|
||||
// No user action required
|
||||
```
|
||||
|
||||
## 9. Security Considerations
|
||||
|
||||
### 9.1 What This Protects
|
||||
|
||||
✅ **Database breach without server access**
|
||||
- Attacker gets database dump
|
||||
- All messages are encrypted
|
||||
- Cannot read message content without Laravel APP_KEY
|
||||
|
||||
✅ **Database backups**
|
||||
- Encrypted messages in backups
|
||||
- Safe to store backups off-site
|
||||
|
||||
✅ **Forward secrecy**
|
||||
- Each message has unique key
|
||||
- Compromising one message doesn't affect others
|
||||
|
||||
### 9.2 What This Doesn't Protect
|
||||
|
||||
⚠️ **Server compromise**
|
||||
- Admin with APP_KEY can decrypt
|
||||
- Server can decrypt while users are logged in
|
||||
|
||||
⚠️ **Insider threats**
|
||||
- Server admin can access decrypted keys via API
|
||||
- Database admin with APP_KEY can decrypt stored keys
|
||||
|
||||
⚠️ **Server logs**
|
||||
- Debug logs might contain decrypted messages
|
||||
- Make sure logging is properly configured
|
||||
|
||||
### 9.3 Recommendations
|
||||
|
||||
1. **Protect APP_KEY:** Store securely, rotate periodically
|
||||
2. **Disable debug logging in production:** Prevent message leakage
|
||||
3. **Limit server access:** Only trusted admins
|
||||
4. **Use HTTPS everywhere:** Prevent man-in-the-middle
|
||||
5. **Regular security audits:** Review logs and access
|
||||
|
||||
## 10. Advantages Over Full Plan
|
||||
|
||||
| Aspect | Simplified Plan | Full Plan |
|
||||
|--------|----------------|-----------|
|
||||
| User Experience | ⭐⭐⭐⭐⭐ Seamless | ⭐⭐⭐ Requires password |
|
||||
| Implementation Time | ⭐⭐⭐⭐ 5-6 weeks | ⭐⭐ 8-9 weeks |
|
||||
| Complexity | ⭐⭐⭐ Moderate | ⭐ Complex |
|
||||
| Key Recovery | ⭐⭐⭐⭐⭐ Automatic | ⭐ Manual/impossible |
|
||||
| Server Trust | ⭐⭐ Required | ⭐⭐⭐⭐⭐ Zero trust |
|
||||
| Database Protection | ⭐⭐⭐⭐⭐ Yes | ⭐⭐⭐⭐⭐ Yes |
|
||||
| Admin Access | ⭐⭐ Possible | ⭐⭐⭐⭐⭐ Impossible |
|
||||
|
||||
## 11. When to Use This Plan
|
||||
|
||||
**Use Simplified Plan if:**
|
||||
- You trust your server environment
|
||||
- Usability is the top priority
|
||||
- You need "encryption at rest" for compliance
|
||||
- Users shouldn't manage passwords
|
||||
- Recovery from password loss is important
|
||||
- Implementation time is limited
|
||||
|
||||
**Use Full Plan if:**
|
||||
- Zero-trust security model required
|
||||
- Maximum security is critical
|
||||
- Users can handle additional password
|
||||
- Server admin access is a concern
|
||||
- Compliance requires no server access to plaintext
|
||||
|
||||
## 12. Migration Path to Full Plan
|
||||
|
||||
If you later want to upgrade to the full plan:
|
||||
|
||||
1. Add password field to key generation
|
||||
2. Re-encrypt private keys with user password
|
||||
3. Implement password prompt UI
|
||||
4. Migrate existing keys (require users to set password)
|
||||
5. Update decryption to use password
|
||||
|
||||
The database schema is compatible, so you can upgrade without data loss.
|
||||
|
||||
## 13. Conclusion
|
||||
|
||||
This simplified approach provides strong encryption at rest while maintaining excellent usability. Messages are protected in the database and backups, but the server environment must be trusted. This is ideal for internal team communication tools or platforms where server security is well-managed.
|
||||
|
||||
**Next Steps:**
|
||||
1. Review and approve this plan
|
||||
2. Begin Week 1-2 implementation
|
||||
3. Test thoroughly in development
|
||||
4. Gradual rollout to production
|
||||
|
||||
---
|
||||
|
||||
**Document Version:** 1.0
|
||||
**Created:** 2025-11-30
|
||||
**For:** Timebank.cc WireChat E2E Encryption
|
||||
1486
references/plans/e2e-encryption-universal-plan.md
Normal file
1486
references/plans/e2e-encryption-universal-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user