Building a Zero-Knowledge File Sharing Platform with Client-Side Encryption
Source: Dev.to
🎯 The Core Problem
Traditional file‑sharing services use server‑side encryption:
- Files are encrypted on the server
- The service holds the encryption keys
- They can decrypt files anytime (for scanning, compliance, legal requests)
This creates a fundamental trust problem: you’re trusting the company’s security, policies, and intentions.
💡 The Solution: Zero‑Knowledge Architecture
FileBee implements client‑side encryption with a zero‑knowledge approach:
- Encryption happens in the browser before upload
- Keys are stored locally in IndexedDB
- The server only stores encrypted blobs it can’t decrypt
- If users delete their keys, files become permanently inaccessible
- Even if the database is compromised, attackers get only encrypted data
🏗️ Technical Architecture
Stack Overview
| Layer | Technology |
|---|---|
| Frontend | Angular 15+ |
| Storage | IndexedDB (keys), Cloud Storage (encrypted files) |
| Encryption | Web Crypto API |
| UI Framework | Angular Material |
Key Components
Client‑Side Encryption Flow
// Simplified encryption flow
async function encryptFile(file, encryptionKey) {
const fileBuffer = await file.arrayBuffer();
const iv = crypto.getRandomValues(new Uint8Array(12)); // GCM IV
const encryptedData = await crypto.subtle.encrypt(
{
name: 'AES-GCM',
iv: iv
},
encryptionKey,
fileBuffer
);
// Prepend IV to encrypted data for decryption later
return concatenate(iv, new Uint8Array(encryptedData));
}
Key Generation & Storage
// Generate encryption key for user
async function generateEncryptionKey() {
return await crypto.subtle.generateKey(
{
name: 'AES-GCM',
length: 256
},
true, // extractable
['encrypt', 'decrypt']
);
}
// Store in IndexedDB (never sent to server)
async function storeKeyLocally(keyId, key) {
const exportedKey = await crypto.subtle.exportKey('jwk', key);
await db.keys.put({
id: keyId,
key: exportedKey,
createdAt: Date.now()
});
}
Upload Flow Architecture
User selects file
↓
Generate/retrieve encryption key (IndexedDB)
↓
Encrypt file in browser (Web Crypto API)
↓
Upload encrypted blob to server
↓
Server generates short URL
↓
Return URL + QR code to user
↓
User shares URL + encryption key separately
Download Flow Architecture
User opens URL
↓
Prompt for encryption key
↓
Fetch encrypted blob from server
↓
Decrypt in browser using provided key
↓
Trigger download of decrypted file
🔐 Security Features Implemented
AES‑256‑GCM Encryption
const algorithm = {
name: 'AES-GCM',
length: 256
};
- Strong 256‑bit encryption
- Built‑in authentication (prevents tampering)
- Unique IV per file encryption
Local Key Management
// IndexedDB schema for key storage
interface EncryptionKey {
id: string; // Unique key identifier
key: JsonWebKey; // Exported key data
createdAt: number; // Timestamp
files: string[]; // Associated file IDs
}
Keys never leave the browser.
Automatic Deletion
// Pseudo‑code for cleanup job
async function cleanupExpiredFiles() {
const sevenDaysAgo = Date.now() - (7 * 24 * 60 * 60 * 1000);
const expiredFiles = await db.files
.where('uploadedAt')
.below(sevenDaysAgo)
.toArray();
for (const file of expiredFiles) {
await storage.deleteFile(file.id);
await db.files.delete(file.id);
}
}
// Run every hour
setInterval(cleanupExpiredFiles, 60 * 60 * 1000);
Key Deletion = Data Destruction
async function deleteEncryptionKey(keyId) {
// Get all files associated with this key
const key = await db.keys.get(keyId);
// Delete files from server
for (const fileId of key.files) {
await api.deleteFile(fileId);
}
// Delete key from IndexedDB
await db.keys.delete(keyId);
// Files are now permanently inaccessible
}
🎨 User Experience Challenges
Challenge 1: Key Management
Problem: Users must save random encryption keys (e.g., k3j2h4kj234h23k4j23h4k23j4h).
Solution:
- Display keys prominently after upload
- Provide a copy‑to‑clipboard button
- Generate QR codes for mobile sharing
- Warn users before key deletion
@Component({
selector: 'app-encryption-key-display',
template: `
<mat-card>
<mat-icon>vpn_key</mat-icon>
<pre>{{ encryptionKey }}</pre>
<button mat-icon-button (click)="copyKey()">
<mat-icon>content_copy</mat-icon>
</button>
<mat-icon color="warn">warning</mat-icon>
<p>Save this key! Without it, files cannot be decrypted.</p>
</mat-card>
`
})
Challenge 2: Large File Handling
Problem: Encrypting 50 MB files in‑browser can freeze the UI.
Solution: Use Web Workers for encryption.
// encryption.worker.ts
self.addEventListener('message', async (e) => {
const { file, key } = e.data;
const encrypted = await encryptFile(file, key);
self.postMessage({ encrypted, progress: 100 });
});
// Main thread
const worker = new Worker('./encryption.worker.ts');
worker.postMessage({ file, key });
worker.addEventListener('message', (e) => {
uploadEncryptedFile(e.data.encrypted);
});
Challenge 3: Folder Uploads
Problem: Maintaining directory structure while encrypting each file.
Solution: Store metadata separately.
interface FolderMetadata {
files: Array<any>;
commonUrl?: string; // Optional common URL for all files
}
// Encrypt and upload folder
async function uploadFolder(folder: File[]): Promise<any> {
const metadata: FolderMetadata = { files: [] };
for (const file of folder) {
const encrypted = await encryptFile(file, key);
const { id } = await uploadToServer(encrypted);
metadata.files.push({
path: file.webkitRelativePath || file.name,
encryptedId: id,
size: file.size,
mimeType: file.type
});
}
return metadata;
}
📊 Storage Limits & Optimization
- 50 MB per file
- 250 MB total storage per user
- 7‑day automatic deletion
Storage Quota Check
async function checkStorageQuota(user) {
const userFiles = await db.files
.where('userId')
.equals(user)
.toArray();
const totalSize = userFiles.reduce((sum, f) => sum + f.size, 0);
const MAX_STORAGE = 250 * 1024 * 1024; // 250 MB
return totalSize MAX_FILE_SIZE) {
throw new Error(`File exceeds 50 MB limit: ${file.name}`);
}
return true;
}
🎯 QR Code Generation
Auto‑generate QR codes for easy mobile sharing:
import * as QRCode from 'qrcode';
// Example: generate a data URL
async function generateQrCode(url: string): Promise<string> {
return await QRCode.toDataURL(url);
}