29 KiB
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:
// 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:
// 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:
-- 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):
// 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):
// 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):
// 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:
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
EncryptionControllerwith API endpoints - Write unit tests for models
Week 2-3: Backend Integration
- Create
MessageEncryptionObserver - Override
EncryptedChatLivewire 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
// 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
# .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:
// 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
// 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
- Protect APP_KEY: Store securely, rotate periodically
- Disable debug logging in production: Prevent message leakage
- Limit server access: Only trusted admins
- Use HTTPS everywhere: Prevent man-in-the-middle
- 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:
- Add password field to key generation
- Re-encrypt private keys with user password
- Implement password prompt UI
- Migrate existing keys (require users to set password)
- 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:
- Review and approve this plan
- Begin Week 1-2 implementation
- Test thoroughly in development
- Gradual rollout to production
Document Version: 1.0 Created: 2025-11-30 For: Timebank.cc WireChat E2E Encryption