super-productivity/docs/sync-and-op-log/long-term-plans/e2e-encryption-plan.md
2026-01-10 17:09:14 +01:00

14 KiB

E2E Encryption for SuperSync Server

Status: Implemented (December 2025)

This plan has been fully implemented. For the current implementation details, see:

Summary

Add end-to-end encryption to SuperSync where the server cannot read operation payloads. Users provide a separate encryption password which is used to derive an encryption key client-side. This is the same approach used by legacy sync providers (Dropbox, WebDAV, Local File).

Key Decisions

Decision Choice
Encryption scope Payload-only (metadata stays plaintext for conflict detection)
Key derivation User-provided encryption password → Argon2id → key
Password change Not supported (would require re-encrypting all data)
Server changes None required
Missing key handling Fail gracefully with dialog to enter password

Architecture

Encryption Flow

User Encryption Password → Argon2id (64MB, 3 iter) → AES-256 Key → encrypt/decrypt payloads

Data Flow

Upload: Operation → encrypt payload with key → upload (metadata plaintext)
Download: Receive ops → decrypt payload with key → apply to state

Why Payload-Only Encryption?

The server needs plaintext metadata for:

  • Conflict detection - Uses vector clocks to detect concurrent edits
  • Deduplication - Uses operation IDs to prevent duplicates
  • Ordering - Uses timestamps and server sequence numbers
  • Tombstone tracking - Uses entity IDs for delete tracking

The server does NOT need to read:

  • Payloads - The actual data being created/updated/deleted

This design encrypts payloads while keeping metadata accessible, giving the server enough information to coordinate sync without seeing user data.


Implementation Plan

Phase 1: Data Model Changes

File: src/app/pfapi/api/sync/sync-provider.interface.ts

Add encryption flag to SyncOperation:

export interface SyncOperation {
  // ... existing fields ...
  isPayloadEncrypted?: boolean; // NEW: true if payload is encrypted string
}

File: src/app/pfapi/api/sync/providers/super-sync/super-sync.model.ts

The encryptKey field already exists in SyncProviderPrivateCfgBase. Just need to add the enable flag:

export interface SuperSyncPrivateCfg extends SyncProviderPrivateCfgBase {
  // ... existing fields ...
  isEncryptionEnabled?: boolean; // NEW
  // encryptKey?: string;         // Already inherited from base
}

Phase 2: Client-Side Encryption Service

New file: src/app/op-log/sync/operation-encryption.service.ts

import { inject, Injectable } from '@angular/core';
import { encrypt, decrypt } from '../../../../pfapi/api/encryption/encryption';

@Injectable({ providedIn: 'root' })
export class OperationEncryptionService {
  /**
   * Encrypts the payload of a SyncOperation.
   * Returns a new operation with encrypted payload and isPayloadEncrypted=true.
   */
  async encryptOperation(op: SyncOperation, encryptKey: string): Promise<SyncOperation> {
    const payloadStr = JSON.stringify(op.payload);
    const encryptedPayload = await encrypt(payloadStr, encryptKey);
    return {
      ...op,
      payload: encryptedPayload,
      isPayloadEncrypted: true,
    };
  }

  /**
   * Decrypts the payload of a SyncOperation.
   * Returns a new operation with decrypted payload.
   * Throws DecryptError if decryption fails.
   */
  async decryptOperation(op: SyncOperation, encryptKey: string): Promise<SyncOperation> {
    if (!op.isPayloadEncrypted) {
      return op; // Pass through unencrypted ops
    }
    const decryptedStr = await decrypt(op.payload as string, encryptKey);
    return {
      ...op,
      payload: JSON.parse(decryptedStr),
      isPayloadEncrypted: false,
    };
  }

  /**
   * Batch encrypt operations for upload.
   */
  async encryptOperations(
    ops: SyncOperation[],
    encryptKey: string,
  ): Promise<SyncOperation[]> {
    return Promise.all(ops.map((op) => this.encryptOperation(op, encryptKey)));
  }

  /**
   * Batch decrypt operations after download.
   * Non-encrypted ops pass through unchanged.
   */
  async decryptOperations(
    ops: SyncOperation[],
    encryptKey: string,
  ): Promise<SyncOperation[]> {
    return Promise.all(ops.map((op) => this.decryptOperation(op, encryptKey)));
  }
}

Reuses: Existing src/app/pfapi/api/encryption/encryption.ts (AES-GCM, Argon2id)


Phase 3: Upload Integration

File: src/app/op-log/sync/operation-log-upload.service.ts

Modify _uploadPendingOpsViaApi():

// Add injection
private encryptionService = inject(OperationEncryptionService);

// In _uploadPendingOpsViaApi(), after converting to SyncOperation format:
const privateCfg = await syncProvider.privateCfg.load();
let opsToUpload = syncOps;

if (privateCfg?.isEncryptionEnabled && privateCfg?.encryptKey) {
  opsToUpload = await this.encryptionService.encryptOperations(syncOps, privateCfg.encryptKey);
}

// Upload opsToUpload instead of syncOps
const response = await syncProvider.uploadOps(opsToUpload, clientId, lastKnownServerSeq);

// Also encrypt piggybacked ops handling needs decryption:
if (response.newOps && response.newOps.length > 0) {
  let ops = response.newOps.map((serverOp) => serverOp.op);
  if (privateCfg?.encryptKey) {
    ops = await this.encryptionService.decryptOperations(ops, privateCfg.encryptKey);
  }
  const operations = ops.map((op) => syncOpToOperation(op));
  piggybackedOps.push(...operations);
}

Phase 4: Download Integration

File: src/app/op-log/sync/operation-log-download.service.ts

Modify _downloadRemoteOpsViaApi():

// Add injection
private encryptionService = inject(OperationEncryptionService);
private matDialog = inject(MatDialog);

// After downloading ops, before converting to Operation format:
const privateCfg = await syncProvider.privateCfg.load();
let syncOps = response.ops
  .filter((serverOp) => !appliedOpIds.has(serverOp.op.id))
  .map((serverOp) => serverOp.op);

// Check if any ops are encrypted
const hasEncryptedOps = syncOps.some(op => op.isPayloadEncrypted);

if (hasEncryptedOps) {
  let encryptKey = privateCfg?.encryptKey;

  // If no key cached, prompt user
  if (!encryptKey) {
    encryptKey = await this._promptForEncryptionPassword();
    if (!encryptKey) {
      // User cancelled - abort sync
      return { newOps: [], success: false };
    }
  }

  try {
    syncOps = await this.encryptionService.decryptOperations(syncOps, encryptKey);
  } catch (e) {
    if (e instanceof DecryptError) {
      // Wrong password - prompt again
      await this._showDecryptionErrorDialog();
      return { newOps: [], success: false };
    }
    throw e;
  }
}

const operations = syncOps.map((op) => syncOpToOperation(op));

Phase 5: UI Changes

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

Add encryption fields to SuperSync provider form:

// In SuperSync fieldGroup, add:
{
  key: 'isEncryptionEnabled',
  type: 'checkbox',
  props: {
    label: T.F.SYNC.FORM.SUPER_SYNC.L_ENABLE_E2E_ENCRYPTION,
  },
},
{
  hideExpression: (model: any) => !model.isEncryptionEnabled,
  key: 'encryptKey',
  type: 'input',
  props: {
    type: 'password',
    label: T.F.SYNC.FORM.L_ENCRYPTION_PASSWORD,
    required: true,
  },
},
{
  hideExpression: (model: any) => !model.isEncryptionEnabled,
  type: 'tpl',
  props: {
    tpl: `<div class="warn-text">{{ T.F.SYNC.FORM.SUPER_SYNC.ENCRYPTION_WARNING | translate }}</div>`,
  },
},

Translations: src/assets/i18n/en.json

{
  "F": {
    "SYNC": {
      "FORM": {
        "SUPER_SYNC": {
          "L_ENABLE_E2E_ENCRYPTION": "Enable end-to-end encryption",
          "ENCRYPTION_WARNING": "Warning: If you forget your encryption password, your data cannot be recovered. This password is separate from your login password."
        }
      },
      "S": {
        "DECRYPTION_FAILED": "Failed to decrypt synced data. Please check your encryption password.",
        "ENCRYPTION_PASSWORD_REQUIRED": "Encryption password required to sync encrypted data."
      }
    }
  }
}

New dialog component: src/app/imex/sync/dialog-encryption-password/

Simple dialog to prompt for encryption password when needed:

  • Input field for password
  • Cancel and OK buttons
  • Used when encrypted ops are received but no password is cached

File Summary

New Files

File Purpose
src/app/op-log/sync/operation-encryption.service.ts Encrypt/decrypt operations
src/app/imex/sync/dialog-encryption-password/ Password prompt dialog

Modified Files

File Changes
src/app/pfapi/api/sync/sync-provider.interface.ts Add isPayloadEncrypted to SyncOperation
src/app/pfapi/api/sync/providers/super-sync/super-sync.model.ts Add isEncryptionEnabled flag
src/app/op-log/sync/operation-log-upload.service.ts Encrypt before upload
src/app/op-log/sync/operation-log-download.service.ts Decrypt after download
src/app/features/config/form-cfgs/sync-form.const.ts Add encryption toggle + password field
src/app/t.const.ts Add translation keys
src/assets/i18n/en.json Add translation strings

No Server Changes Required

The server treats encrypted payloads as opaque strings - no modifications needed.


Security Considerations

What's Protected

  1. Payload content - All user data (tasks, projects, notes, etc.) is encrypted
  2. Zero-knowledge - Server never sees encryption password or plaintext data
  3. Strong crypto - AES-256-GCM with Argon2id key derivation

What's Exposed (by design)

  1. Operation metadata - IDs, timestamps, entity types, vector clocks
  2. Traffic patterns - Server knows when you sync and how many operations
  3. Encryption status - Server can see isPayloadEncrypted: true

Cryptographic Details

Component Algorithm Parameters
Key derivation Argon2id 64MB memory, 3 iterations
Payload encryption AES-256-GCM Random 12-byte IV, 16-byte salt per operation

Limitations

Limitation Reason
No password change Would require re-encrypting all operations on all clients
No password recovery True zero-knowledge means no recovery possible
Two passwords Login password + encryption password (by design)

Threat Model

Threat Mitigated? Notes
Server reads data Yes Payloads encrypted client-side
Server breach Yes Attacker gets encrypted blobs, needs password
MITM attack Yes HTTPS + authenticated encryption
Password brute force Partially Argon2id makes attacks expensive (64MB per attempt)
Lost password No Data unrecoverable without password

Migration Path

Enabling Encryption (Existing User)

  1. User enables "E2E Encryption" in SuperSync settings
  2. User enters encryption password
  3. Warning shown about password recovery
  4. Password saved to SuperSyncPrivateCfg.encryptKey
  5. isEncryptionEnabled set to true
  6. Future operations encrypted; existing operations remain plaintext
  7. Other clients prompted for password when they encounter encrypted ops

Multi-Client Scenario

When encryption is enabled on one client:

  1. Other clients download operations normally
  2. When they encounter isPayloadEncrypted: true, decryption is attempted
  3. If no password cached, dialog prompts for password
  4. Password cached in local SuperSyncPrivateCfg for future syncs

Disabling Encryption

  1. User unchecks encryption toggle
  2. isEncryptionEnabled set to false
  3. Future operations sent without encryption
  4. Existing encrypted operations remain encrypted (still readable with password)

Testing Strategy

  1. Unit tests for OperationEncryptionService

    • Encrypt/decrypt round-trips with various payload types
    • Non-encrypted ops pass through unchanged
    • Wrong password throws DecryptError
  2. Integration tests for upload/download

    • Encrypted operations sync correctly
    • Mixed encrypted/unencrypted history works
    • Piggybacked operations decrypt correctly
  3. E2E tests

    • Two clients with same password sync correctly
    • Missing password shows dialog
    • Wrong password shows error and retries