Back to Super Productivity

Progressive E2E Encryption for SuperSync - REVISED PLAN

docs/long-term-plans/e2e-encryption-device-keys-DRAFT.md

18.4.424.7 KB
Original Source

Progressive E2E Encryption for SuperSync - REVISED PLAN

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.

Executive Summary

ORIGINAL PLAN REJECTED after comprehensive agent review identified fatal flaws:

  • Token-derived encryption breaks after 7-day token expiration
  • Multi-device sync impossible (each device has different tokens)
  • Industry consensus: Zero production systems use auth tokens for encryption

NEW APPROACH: Device-generated master keys with optional cloud backup (WhatsApp model)

Research Summary (3 Deep-Dive Agents)

Agent 1: E2E Encryption Patterns Research

  • Finding: All major E2E systems (WhatsApp, Signal, 1Password) use device-generated random keys
  • Why: Auth tokens are ephemeral, encryption keys must be permanent
  • Recommendation: Follow WhatsApp's 2021 cloud backup model (2B+ users, proven at scale)

Agent 2: JWT/Token Encryption Research

  • Finding: Token-derived encryption is a security anti-pattern
  • Why: OAuth tokens MUST rotate for security, breaking encryption
  • Recommendation: Separate authentication (OAuth) from encryption (device keys)

Agent 3: SuperSync Token Analysis

  • Finding: Tokens expire in 7 days, no auto-refresh exists
  • Current behavior: Users re-login after 7 days
  • Risk: Adding proper token rotation (security best practice) would break token-derived encryption

Goals

  • ✅ Enable E2E encryption by default for all SuperSync users
  • ✅ Zero passwords for single-device users (key generated automatically)
  • ✅ Optional recovery for cautious users (cloud-encrypted backup)
  • ✅ Multi-device support (QR pairing or recovery password)
  • ✅ Maintain strong security (256-bit random keys, not password-derived)

Architecture: Device-Generated Keys with Optional Cloud Backup

Security Model

Key Generation:

  • Each device generates random 256-bit AES-GCM key via WebCrypto API
  • Stored in IndexedDB as non-extractable key (protected from XSS)
  • Key never leaves device unless user enables cloud backup

Optional Cloud Backup:

  • User sets recovery password during setup (strongly encouraged)
  • Password derives KEK via Argon2id (memory-hard, GPU-resistant)
  • Master key encrypted with KEK, uploaded to SuperSync server
  • Server cannot decrypt (password never transmitted)

Multi-Device Sync:

  • Option A: QR code pairing (primary device → new device)
  • Option B: Recovery password (download encrypted key from cloud)

User Flows

First-Time Setup (New User)

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)

Adding a New Device

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

Recovery After Browser Clear

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)

Existing Users Migration

Users with current passphrase encryption:

  • Keep existing setup (passphrase-based encryption)
  • Show banner: "New: Optional cloud backup for your encryption key"
  • User can optionally migrate to device-key model

Users without encryption:

  • Auto-enable on next sync settings save
  • Follow first-time setup flow above

Implementation Plan

Prerequisites

Step 0: Verify WebCrypto API Support

All modern browsers support WebCrypto:

  • Chrome/Edge: Yes (2014+)
  • Firefox: Yes (2014+)
  • Safari: Yes (2015+)
  • Electron: Yes (Chromium-based)
  • Mobile browsers: Yes (iOS 11+, Android 6+)

Polyfill: Not needed for target browsers

Phase 1: Core Infrastructure (Week 1)

1.1: Create DeviceKeyService

File: src/app/imex/sync/device-key.service.ts (NEW)

typescript
@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;
  }
}

1.2: Create CloudKeyBackupService

File: src/app/imex/sync/cloud-key-backup.service.ts (NEW)

typescript
@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;
    }
  }
}

Phase 2: Server API (Week 2)

File: packages/super-sync-server/src/key-backup/ (NEW MODULE)

2.1: Database Schema

prisma
// 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)
}

2.2: API Routes

typescript
// 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 });
});

Phase 3: UI Components (Week 3)

3.1: Recovery Password Setup Dialog

File: src/app/imex/sync/dialog-recovery-password/dialog-recovery-password.component.ts (NEW)

typescript
@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 });
  }
}

3.2: Update Sync Settings Form

File: src/app/features/config/form-cfgs/sync-form.const.ts

typescript
// 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
        );
      }
    }
  },
},

Phase 4: Migration & Compatibility (Week 4)

4.1: Auto-Enable for New Users

File: src/app/imex/sync/sync-config.service.ts

typescript
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
}

4.2: Existing Passphrase Users

Migration Strategy:

  • Keep existing passphrase-based encryption
  • Show banner: "New: Enable cloud backup for your encryption key"
  • Optional migration wizard

Testing & Verification

Unit Tests (Week 5)

device-key.service.spec.ts:

  • Generate master key creates non-extractable CryptoKey
  • Master key persists across service reloads
  • Export for backup produces valid raw bytes
  • Import from backup restores working key

cloud-key-backup.service.spec.ts:

  • Upload encrypts key with password-derived KEK
  • Download decrypts with correct password
  • Wrong password fails gracefully
  • Missing backup returns false from hasCloudBackup()

E2E Tests (Week 6)

supersync-device-encryption.spec.ts:

typescript
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();
});

Security Considerations

Threat Model

Attacker Capabilities:

  • Server compromise (can read database)
  • Network eavesdropping (MITM)
  • XSS attack (malicious JavaScript)
  • Physical device theft

Security Guarantees:

AttackWithout RecoveryWith 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):

  • Hardware-backed key storage (requires platform-specific code)
  • Protection against browser memory exploits (Spectre, etc.)
  • Perfect forward secrecy (single master key reused)

Privacy Considerations

What Server Knows:

  • User has enabled E2E encryption
  • User has cloud backup (if enabled)
  • Number of operations synced (metadata)

What Server CANNOT Know:

  • Master encryption key (never transmitted)
  • Recovery password (never transmitted)
  • Decrypted operation contents

Rollout Strategy

Phase 1: Beta (Weeks 7-8)

  • Enable for opt-in beta users
  • Monitor analytics (recovery password skip rate, errors)
  • Gather feedback

Phase 2: Gradual Rollout (Weeks 9-10)

  • 10% of new users
  • 25% of new users
  • 50% of new users
  • 100% of new users

Phase 3: Existing Users (Weeks 11-12)

  • Show banner: "New: Automatic encryption with optional cloud backup"
  • Offer migration wizard
  • Keep existing passphrase users happy

Success Metrics

  • 95%+ of new users have encryption enabled
  • <20% skip recovery password (with scary warning)
  • Zero data loss incidents from migrations
  • <5% increase in support requests
  • E2E test suite passes 100%

Risks & Mitigations

Risk: Users forget recovery password

  • Mitigation: Password strength meter, clear warnings, suggest password manager

Risk: Browser compatibility issues

  • Mitigation: WebCrypto supported in all modern browsers, fallback to legacy mode for ancient browsers

Risk: IndexedDB cleared by aggressive browser cleaning

  • Mitigation: Detect missing key, show recovery dialog, nudge users toward recovery password

Risk: QR pairing security concerns

  • Mitigation: QR codes expire after 5 minutes, encrypted with ephemeral key

Implementation Timeline

Total: 12 weeks

  • Weeks 1-2: Core infrastructure (DeviceKeyService, CloudKeyBackupService, server API)
  • Weeks 3-4: UI components and migration logic
  • Weeks 5-6: Testing (unit + E2E)
  • Weeks 7-8: Beta testing with real users
  • Weeks 9-10: Gradual rollout to new users
  • Weeks 11-12: Existing user migration

Confidence Level

Overall: 85%

High confidence:

  • WebCrypto API stability (10+ years in production)
  • WhatsApp model proven at 2B+ users scale
  • Server API straightforward (CRUD for encrypted blobs)

Medium confidence:

  • User acceptance of recovery password prompts (need A/B testing)
  • Migration from existing passphrase users (complex edge cases)

Low confidence:

  • Long-term IndexedDB persistence (browser vendors change policies)
  • QR pairing UX (need user testing)

Comparison with Original Plan

AspectOriginal (Token-Derived)Revised (Device Keys)
Password burdenZeroZero (optional)
SecurityWeak (token = key)Strong (random keys)
Multi-deviceBrokenWorks (QR/recovery)
Token rotationBreaks encryptionNo impact
Industry adoptionZero systemsAll major E2E apps
Implementation effort6-8 weeks12 weeks
Recovery optionsNoneCloud backup + QR

Recommendation

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:

  • Robust security model (doesn't break on token rotation)
  • Proven at massive scale (WhatsApp, Signal)
  • Better user experience (recovery options)
  • Future-proof architecture