docs/long-term-plans/e2e-encryption-device-keys-DRAFT.md
Status: Archived — Rejected
Rejected due to 4 critical blockers. See
e2e-encryption-CRITICAL-ISSUES.md. The implemented approach uses password-based encryption — see../sync-and-op-log/supersync-encryption-architecture.md.
ORIGINAL PLAN REJECTED after comprehensive agent review identified fatal flaws:
NEW APPROACH: Device-generated master keys with optional cloud backup (WhatsApp model)
Key Generation:
Optional Cloud Backup:
Multi-Device Sync:
1. User enables SuperSync → enters access token
2. App generates random 256-bit encryption key
3. Store key in IndexedDB (non-extractable via WebCrypto)
4. Show dialog:
┌─────────────────────────────────────────────────┐
│ 🔒 Encryption Enabled │
├─────────────────────────────────────────────────┤
│ Your data is now encrypted with a secure key. │
│ │
│ ⚠️ Set a recovery password to protect against │
│ data loss if you clear your browser. │
│ │
│ Recovery Password: [.....................] │
│ Confirm: [.....................] │
│ │
│ [Skip (Not Recommended)] [Set Password] │
└─────────────────────────────────────────────────┘
5a. If user sets password:
- Derive KEK from password using Argon2id
- Encrypt master key with KEK
- Upload encrypted key to server
- Show: "✓ Recovery enabled. Save this password!"
5b. If user skips:
- Show scary warning:
┌─────────────────────────────────────────────┐
│ ⚠️ WARNING: No Recovery │
├─────────────────────────────────────────────┤
│ If you clear your browser or lose this │
│ device, ALL YOUR DATA WILL BE PERMANENTLY │
│ LOST. There is NO way to recover it. │
│ │
│ Are you ABSOLUTELY SURE? │
│ │
│ [Go Back] [I Understand the Risk] │
└─────────────────────────────────────────────┘
- Require explicit confirmation
- Track in analytics (measure skip rate)
Option A: QR Code Pairing (Fastest)
Primary Device:
1. Settings → Devices → "Pair New Device"
2. Generate QR code containing encrypted master key
3. Display QR code with timer (5 minutes)
New Device:
1. Setup SuperSync → "Pair with existing device"
2. Scan QR code from primary device
3. Import master key → store in IndexedDB
4. Start syncing
Option B: Recovery Password
New Device:
1. Setup SuperSync → "Recover from cloud backup"
2. Show: "Enter your recovery password"
3. User enters password
4. Download encrypted key from server
5. Decrypt with password-derived KEK
6. Store master key in IndexedDB
7. Start syncing
Scenario A: User has recovery password ✅
↓
Open app → Detect missing key
↓
Show: "Your encryption key is missing. Enter recovery password to restore."
↓
User enters password → Download encrypted key → Decrypt → Restore
↓
App works normally
Scenario B: No recovery password, no other devices ❌
↓
Open app → Detect missing key
↓
Show: "Encryption key lost. Your encrypted data cannot be recovered."
↓
Options:
1. Start fresh (new key, abandon old encrypted data)
2. Contact support (we can't help - true E2E)
Users with current passphrase encryption:
Users without encryption:
Step 0: Verify WebCrypto API Support
All modern browsers support WebCrypto:
Polyfill: Not needed for target browsers
File: src/app/imex/sync/device-key.service.ts (NEW)
@Injectable({ providedIn: 'root' })
export class DeviceKeyService {
private readonly _db = inject(PersistenceService);
private readonly _keyCache = new Map<string, CryptoKey>();
async generateMasterKey(): Promise<CryptoKey> {
// Generate random 256-bit AES-GCM key
const key = await crypto.subtle.generateKey(
{ name: 'AES-GCM', length: 256 },
false, // non-extractable (protected from XSS)
['encrypt', 'decrypt'],
);
// Store in IndexedDB via WebCrypto wrapper
await this._storeKeyInIndexedDB(key);
return key;
}
async getMasterKey(): Promise<CryptoKey | null> {
// Check cache first
const cached = this._keyCache.get('master');
if (cached) return cached;
// Load from IndexedDB
const key = await this._loadKeyFromIndexedDB();
if (key) {
this._keyCache.set('master', key);
}
return key;
}
async exportKeyForBackup(): Promise<ArrayBuffer> {
// Export key as raw bytes (for cloud backup encryption)
const key = await this.getMasterKey();
if (!key) throw new Error('No master key available');
// Temporarily make extractable for backup
const exportableKey = await crypto.subtle.importKey(
'raw',
await this._getRawKeyBytes(key),
{ name: 'AES-GCM', length: 256 },
true, // extractable for backup
['encrypt', 'decrypt'],
);
return crypto.subtle.exportKey('raw', exportableKey);
}
async importKeyFromBackup(rawKey: ArrayBuffer): Promise<void> {
const key = await crypto.subtle.importKey(
'raw',
rawKey,
{ name: 'AES-GCM', length: 256 },
false, // non-extractable after import
['encrypt', 'decrypt'],
);
await this._storeKeyInIndexedDB(key);
this._keyCache.set('master', key);
}
private async _storeKeyInIndexedDB(key: CryptoKey): Promise<void> {
// Use IndexedDB to persist key (browser-managed encryption)
const db = await this._db.getDatabase();
await db.put('encryption-keys', { id: 'master', key }, 'master');
}
private async _loadKeyFromIndexedDB(): Promise<CryptoKey | null> {
const db = await this._db.getDatabase();
const record = await db.get('encryption-keys', 'master');
return record?.key || null;
}
}
File: src/app/imex/sync/cloud-key-backup.service.ts (NEW)
@Injectable({ providedIn: 'root' })
export class CloudKeyBackupService {
private readonly _deviceKey = inject(DeviceKeyService);
private readonly _encryption = inject(OperationEncryptionService);
private readonly _http = inject(HttpClient);
async uploadKeyBackup(
recoveryPassword: string,
baseUrl: string,
accessToken: string,
): Promise<void> {
// Export master key
const masterKey = await this._deviceKey.exportKeyForBackup();
// Derive KEK from recovery password
const salt = crypto.getRandomValues(new Uint8Array(16));
const kek = await Argon2id.hash(recoveryPassword, {
salt,
iterations: 3,
memory: 64 * 1024,
hashLength: 32,
});
// Encrypt master key with KEK
const encryptedKey = await this._encryption.encrypt(masterKey, kek);
// Upload to server
await this._http
.post(
`${baseUrl}/api/key-backup`,
{
encryptedKey,
salt: Array.from(salt), // Store salt for KEK derivation
},
{
headers: { Authorization: `Bearer ${accessToken}` },
},
)
.toPromise();
}
async downloadKeyBackup(
recoveryPassword: string,
baseUrl: string,
accessToken: string,
): Promise<void> {
// Download encrypted key from server
const response = await this._http
.get<{
encryptedKey: string;
salt: number[];
}>(`${baseUrl}/api/key-backup`, {
headers: { Authorization: `Bearer ${accessToken}` },
})
.toPromise();
// Derive KEK from recovery password
const salt = new Uint8Array(response.salt);
const kek = await Argon2id.hash(recoveryPassword, {
salt,
iterations: 3,
memory: 64 * 1024,
hashLength: 32,
});
// Decrypt master key
const masterKey = await this._encryption.decrypt(response.encryptedKey, kek);
// Import into IndexedDB
await this._deviceKey.importKeyFromBackup(masterKey);
}
async hasCloudBackup(baseUrl: string, accessToken: string): Promise<boolean> {
try {
await this._http
.head(`${baseUrl}/api/key-backup`, {
headers: { Authorization: `Bearer ${accessToken}` },
})
.toPromise();
return true;
} catch {
return false;
}
}
}
File: packages/super-sync-server/src/key-backup/ (NEW MODULE)
// packages/super-sync-server/prisma/schema.prisma
model KeyBackup {
id Int @id @default(autoincrement())
userId Int @unique
encryptedKey String // Base64-encoded encrypted master key
salt String // Base64-encoded salt for KEK derivation
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
// POST /api/key-backup - Upload encrypted key
fastify.post('/api/key-backup', { preHandler: authenticate }, async (req, reply) => {
const { userId } = req.user;
const { encryptedKey, salt } = req.body;
await prisma.keyBackup.upsert({
where: { userId },
create: { userId, encryptedKey, salt },
update: { encryptedKey, salt, updatedAt: new Date() },
});
reply.send({ success: true });
});
// GET /api/key-backup - Download encrypted key
fastify.get('/api/key-backup', { preHandler: authenticate }, async (req, reply) => {
const { userId } = req.user;
const backup = await prisma.keyBackup.findUnique({
where: { userId },
});
if (!backup) {
return reply.code(404).send({ error: 'No key backup found' });
}
reply.send({
encryptedKey: backup.encryptedKey,
salt: backup.salt,
});
});
// DELETE /api/key-backup - Delete cloud backup
fastify.delete('/api/key-backup', { preHandler: authenticate }, async (req, reply) => {
const { userId } = req.user;
await prisma.keyBackup.delete({
where: { userId },
});
reply.send({ success: true });
});
File: src/app/imex/sync/dialog-recovery-password/dialog-recovery-password.component.ts (NEW)
@Component({
selector: 'dialog-recovery-password',
template: `
<h2 mat-dialog-title>🔒 Set Recovery Password</h2>
<mat-dialog-content>
<p>Set a password to backup your encryption key to the cloud.</p>
<p>
<strong>⚠️ Without recovery, clearing your browser = permanent data loss.</strong>
</p>
<mat-form-field>
<input
matInput
type="password"
placeholder="Recovery Password"
[(ngModel)]="password"
(input)="checkStrength()"
/>
<mat-hint>Strength: {{ strength }}</mat-hint>
</mat-form-field>
<mat-form-field>
<input
matInput
type="password"
placeholder="Confirm Password"
[(ngModel)]="confirmPassword"
/>
</mat-form-field>
</mat-dialog-content>
<mat-dialog-actions>
<button
mat-button
(click)="skip()"
>
Skip (Not Recommended)
</button>
<button
mat-raised-button
color="primary"
[disabled]="!canSubmit()"
(click)="submit()"
>
Set Password
</button>
</mat-dialog-actions>
`,
})
export class DialogRecoveryPasswordComponent {
password = '';
confirmPassword = '';
strength = 'Weak';
checkStrength(): void {
// Simple strength meter
const score = zxcvbn(this.password).score;
this.strength = ['Weak', 'Weak', 'Fair', 'Good', 'Strong'][score];
}
canSubmit(): boolean {
return (
this.password.length >= 8 &&
this.password === this.confirmPassword &&
this.strength !== 'Weak'
);
}
skip(): void {
// Show scary warning first
const confirmed = confirm(
'⚠️ WARNING: Without recovery, you will PERMANENTLY LOSE ALL DATA ' +
'if you clear your browser or lose this device. Are you SURE?',
);
if (confirmed) {
this._dialogRef.close({ skipRecovery: true });
}
}
submit(): void {
this._dialogRef.close({ password: this.password });
}
}
File: src/app/features/config/form-cfgs/sync-form.const.ts
// SuperSync section - NO encryption checkbox (always enabled)
{
type: 'tpl',
className: 'tpl info-text',
hideExpression: (m, v, field) =>
field?.parent?.parent?.parent?.model.syncProvider !== SyncProviderId.SuperSync,
templateOptions: {
tag: 'div',
text: '🔒 End-to-end encryption enabled automatically'
},
},
{
type: 'btn',
hideExpression: (m, v, field) =>
field?.parent?.parent?.parent?.model.syncProvider !== SyncProviderId.SuperSync,
templateOptions: {
text: 'Manage Recovery Password',
onClick: async () => {
const dialogRef = this._matDialog.open(DialogRecoveryPasswordComponent);
const result = await dialogRef.afterClosed().toPromise();
if (result?.password) {
await this._cloudKeyBackup.uploadKeyBackup(
result.password,
config.baseUrl,
config.accessToken
);
}
}
},
},
File: src/app/imex/sync/sync-config.service.ts
async updateSettingsFromForm(cfg: SyncConfig, isInitialSetup: boolean) {
if (cfg.syncProvider === SyncProviderId.SuperSync) {
// Check if user has existing key
const hasKey = await this._deviceKey.getMasterKey();
if (!hasKey && isInitialSetup) {
// Generate new master key
await this._deviceKey.generateMasterKey();
// Show recovery password setup
const dialogRef = this._matDialog.open(DialogRecoveryPasswordComponent);
const result = await dialogRef.afterClosed().toPromise();
if (result?.password && !result.skipRecovery) {
await this._cloudKeyBackup.uploadKeyBackup(
result.password,
cfg.superSync.baseUrl,
cfg.superSync.accessToken
);
}
}
}
// ... existing save logic
}
Migration Strategy:
device-key.service.spec.ts:
cloud-key-backup.service.spec.ts:
supersync-device-encryption.spec.ts:
test('new user gets auto-encryption with recovery prompt', async ({ page }) => {
const client = await setupClient(page, 'client-A');
await client.setupSuperSync({ accessToken: 'test-token' });
// Should show recovery password dialog
await expect(page.locator('dialog-recovery-password')).toBeVisible();
// Set recovery password
await client.setRecoveryPassword('strong-password-123');
// Create task
await client.addTask('Buy milk');
await client.waitForSync();
// Verify encrypted on server
const ops = await serverApi.getOperations('client-A');
expect(ops[0].isPayloadEncrypted).toBe(true);
});
test('multi-device sync via recovery password', async ({ page }) => {
const client1 = await setupClient(page, 'client-A');
await client1.setupSuperSync({ accessToken: 'token-A' });
await client1.setRecoveryPassword('recovery-pass');
await client1.addTask('Secret task');
await client1.waitForSync();
// Second device
const client2 = await setupClient(page, 'client-B');
await client2.setupSuperSync({ accessToken: 'token-B' });
await client2.recoverFromPassword('recovery-pass');
await client2.waitForSync();
// Should see task (same master key)
await expect(client2.getTaskTitle()).toBe('Secret task');
});
test('browser clear without recovery loses data', async ({ page }) => {
const client = await setupClient(page, 'client-A');
await client.setupSuperSync({ accessToken: 'test-token' });
await client.skipRecoveryPassword(); // User chose no recovery
await client.addTask('Task 1');
await client.waitForSync();
// Simulate browser clear
await client.clearIndexedDB();
await page.reload();
// Should show "key lost" error
await expect(page.locator('text=Encryption key lost')).toBeVisible();
});
Attacker Capabilities:
Security Guarantees:
| Attack | Without Recovery | With Recovery Password |
|---|---|---|
| Server compromise | ✅ Data encrypted | ✅ Data encrypted (KEK not on server) |
| Network MITM | ✅ TLS protects key upload | ✅ TLS protects encrypted key |
| XSS attack | ⚠️ Can call encrypt/decrypt | ⚠️ Can call encrypt/decrypt |
| Device theft | ❌ Key in IndexedDB | ❌ Key in IndexedDB |
| Browser clear | ❌ Data lost | ✅ Recoverable with password |
Non-Goals (Out of Scope):
What Server Knows:
What Server CANNOT Know:
Risk: Users forget recovery password
Risk: Browser compatibility issues
Risk: IndexedDB cleared by aggressive browser cleaning
Risk: QR pairing security concerns
Total: 12 weeks
Overall: 85%
High confidence:
Medium confidence:
Low confidence:
| Aspect | Original (Token-Derived) | Revised (Device Keys) |
|---|---|---|
| Password burden | Zero | Zero (optional) |
| Security | Weak (token = key) | Strong (random keys) |
| Multi-device | Broken | Works (QR/recovery) |
| Token rotation | Breaks encryption | No impact |
| Industry adoption | Zero systems | All major E2E apps |
| Implementation effort | 6-8 weeks | 12 weeks |
| Recovery options | None | Cloud backup + QR |
Proceed with revised plan. Device-generated keys with optional cloud backup is the industry-standard approach for E2E encryption with minimal password burden.
The additional 4-6 weeks of implementation time is justified by: