- Remove outdated feature requests from .github/CONTRIBUTING.md (GitLab support already exists) and add commit message format section - Improve PR template with type-of-change checkboxes and checklist - Update commit guideline links in README and CONTRIBUTING.md to reference the project's own format instead of external angular.js docs - Add "only edit en.json" rule to TRANSLATING.md and clarify workflow - Update add-new-integration.md provider list to match codebase (add Trello, ClickUp, Linear, Azure DevOps, Nextcloud Deck; note GitHub plugin migration; fix type name to BuiltInIssueProviderKey) - Add cross-references between mac certificate docs and remove 240-line duplicate section from update-mac-certificates.md - Clean up update-android-app.md (specify npm version args, collapse deprecated workflow, translate German UI labels to English) - Add context to howto-refresh-snap-credentials.md - Fix fine-grained token note in github-access-token-instructions.md - Fix absolute URL to relative path in gitlab-access-token-instructions.md - Fix grammar in i18n-script-usage.md - Add status headers to all 19 long-term plan files (Planned, Completed, Archived with reason, Investigation Complete) - Fix broken relative link in hybrid-manifest-architecture.md - Delete supersync-scenarios-simplified.md (duplicate of supersync-scenarios.md; known issues already covered there) - Rename vector-clock-pruning-research.md to vector-clock-history-and-alternatives.md for clarity
10 KiB
JWT-Derived Encryption for SuperSync
Status: Archived — Superseded
JWT-derived keys are unsuitable due to token refresh invalidating encryption keys. Password-based encryption was implemented instead — see
../sync-and-op-log/supersync-encryption-architecture.md.
Goal
Provide automatic "encryption at rest" for lazy users who don't want to enter a passphrase. This protects against database leaks while maintaining zero UX friction.
Security Model:
| Threat | Protected? |
|---|---|
| Database dump/leak | ✅ Yes |
| Backup file theft | ✅ Yes |
| Server operator | ❌ No (can decrypt with JWT_SECRET) |
Critical Issue: JWT Instability
All 5 reviewers identified this as a blocker.
The plan proposes SHA-256(jwt) as the encryption key. However:
- JWTs can be refreshed (new signature = new key)
- Re-login produces a different JWT
- Token expiration invalidates the key
Result: User's encrypted data becomes permanently unreadable after token refresh.
Solution: Store Derived Key on First Enable
Instead of deriving the key every time from the current JWT:
// On first enable of auto-encryption:
const derivedKey = await crypto.subtle.digest('SHA-256', encoder.encode(jwt));
const keyAsBase64 = btoa(String.fromCharCode(...new Uint8Array(derivedKey)));
// Store this derived key, NOT the JWT
await provider.setConfig({
isAutoEncryptionEnabled: true,
autoEncryptionKey: keyAsBase64, // Stable across token refreshes
});
// On subsequent operations, use the stored key
This ensures:
- Key stability across token refreshes
- Multi-device works (all devices get same derived key from initial JWT)
- No data loss on re-login
Implementation Plan
Phase 1: Model & Config (1 day)
Files:
src/app/op-log/sync-providers/super-sync/super-sync.model.tssrc/app/features/config/global-config.model.ts
Changes:
// super-sync.model.ts
export interface SuperSyncPrivateCfg extends SyncProviderPrivateCfgBase {
// ... existing fields ...
/** Auto-encryption enabled (JWT-derived, not passphrase) */
isAutoEncryptionEnabled?: boolean;
/** Stored derived key (base64). Set once on first enable, stable across sessions */
autoEncryptionKey?: string;
}
Phase 2: Encryption Function (0.5 day)
File: src/app/op-log/encryption/encryption.ts
Add:
/**
* Fast key derivation for high-entropy inputs (JWT-derived keys).
* Skips Argon2id since JWT already has 256+ bits of entropy.
*/
export const deriveKeyFromHighEntropy = async (
keyMaterial: string,
): Promise<DerivedKeyInfo> => {
const encoder = new TextEncoder();
const data = encoder.encode(keyMaterial);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
// Use a fixed salt since key material is already high-entropy
const salt = new Uint8Array(SALT_LENGTH).fill(0);
const key = await crypto.subtle.importKey(
'raw',
hashBuffer,
{ name: ALGORITHM },
false,
['encrypt', 'decrypt'],
);
return { key, salt };
};
Integration with existing functions:
- Existing
encrypt(data, password)anddecrypt(data, password)use Argon2id - Add new
encryptWithDerivedKey(data, derivedKeyInfo)for pre-derived keys operation-encryption.service.tsneeds a new code path for auto-encryption
Phase 3: SuperSync Provider (1 day)
File: src/app/op-log/sync-providers/super-sync/super-sync.ts
Modify getEncryptKey():
async getEncryptKey(): Promise<string | undefined> {
const cfg = await this.privateCfg.load();
if (!cfg) return undefined;
// Existing passphrase encryption takes priority
if (cfg.isEncryptionEnabled && cfg.encryptKey) {
return cfg.encryptKey;
}
// Auto-encryption uses stored derived key
if (cfg.isAutoEncryptionEnabled && cfg.autoEncryptionKey) {
return cfg.autoEncryptionKey;
}
return undefined;
}
Phase 4: Enable/Disable Services (1 day)
New file: src/app/imex/sync/auto-encryption-enable.service.ts
Flow:
- Derive key from current JWT:
SHA-256(accessToken) - Store derived key in config as
autoEncryptionKey - Set
isAutoEncryptionEnabled: true - Delete all server data (can't mix encrypted/unencrypted)
- Upload current state with encryption
Reuse existing patterns from:
encryption-enable.service.ts(lines 16-80)encryption-disable.service.ts
Phase 5: UI Integration (1 day)
File: src/app/features/config/form-cfgs/sync-form.const.ts
Add toggle in SuperSync Advanced settings:
{
key: 'isAutoEncryptionEnabled',
type: 'checkbox',
hideExpression: (model: any) => model.isEncryptionEnabled, // Hide if passphrase enabled
templateOptions: {
label: T.F.SYNC.FORM.SUPER_SYNC.L_AUTO_ENCRYPTION,
description: T.F.SYNC.FORM.SUPER_SYNC.AUTO_ENCRYPTION_DESCRIPTION,
},
// ... hooks for enable/disable flow
}
Translation keys needed:
{
"L_AUTO_ENCRYPTION": "Encrypt my data automatically",
"AUTO_ENCRYPTION_DESCRIPTION": "Encrypts data on the server. Protects against database leaks, but server operator can decrypt if needed.",
"AUTO_ENCRYPTION_WARNING": "This will delete sync data and re-upload with encryption."
}
Files to Modify
| File | Changes |
|---|---|
src/app/op-log/sync-providers/super-sync/super-sync.model.ts |
Add isAutoEncryptionEnabled, autoEncryptionKey |
src/app/op-log/encryption/encryption.ts |
Add deriveKeyFromHighEntropy() |
src/app/op-log/sync-providers/super-sync/super-sync.ts |
Modify getEncryptKey() |
src/app/op-log/sync/operation-encryption.service.ts |
Support pre-derived keys |
src/app/features/config/form-cfgs/sync-form.const.ts |
Add UI toggle |
src/app/features/config/global-config.model.ts |
Add isAutoEncryptionEnabled to SuperSyncConfig |
src/app/imex/sync/auto-encryption-enable.service.ts |
NEW: Enable flow |
src/app/imex/sync/auto-encryption-disable.service.ts |
NEW: Disable flow |
src/assets/i18n/en.json |
Add translation keys |
src/app/t.const.ts |
Add translation constants |
Edge Cases & Error Handling
1. Decryption Failure (e.g., key mismatch)
Current behavior: Shows password dialog (wrong for auto-encryption)
Required change: Detect auto-encryption mode and show appropriate error:
"Unable to decrypt sync data. Your encryption key may be invalid.
Options:
[Re-enable Auto Encryption] - Upload local data with new key
[Cancel]"
File: src/app/imex/sync/dialog-handle-decrypt-error/dialog-handle-decrypt-error.component.ts
2. Switching Between Encryption Modes
| From | To | Action |
|---|---|---|
| None | Auto | Delete server data, upload encrypted |
| Auto | None | Delete server data, upload unencrypted |
| Auto | Passphrase | Delete server data, upload with passphrase |
| Passphrase | Auto | Delete server data, upload with auto key |
All require clean slate (existing pattern in codebase).
3. Multi-Device First Sync
When a new device syncs for the first time with auto-encryption:
- Download encrypted ops
- Derive key from JWT:
SHA-256(accessToken) - Attempt decryption
- If success: store derived key locally
- If failure: show error (different account?)
Tests Required
Unit Tests
encryption.ts:
describe('deriveKeyFromHighEntropy', () => {
it('should derive consistent key from same input');
it('should derive different keys from different inputs');
it('should be fast (<10ms)');
});
super-sync.ts:
describe('getEncryptKey with auto-encryption', () => {
it('should return autoEncryptionKey when isAutoEncryptionEnabled');
it('should prefer passphrase over auto-encryption');
it('should return undefined when neither enabled');
});
Integration Tests
describe('Auto-encryption flow', () => {
it('should encrypt operations during upload');
it('should decrypt operations during download');
it('should work across token refreshes (key is stable)');
});
E2E Tests
describe('SuperSync auto-encryption', () => {
it('should enable auto-encryption via settings');
it('should sync encrypted data to server');
it('should decrypt on second device with same account');
});
Verification Checklist
- Enable auto-encryption on device A
- Create tasks, verify they sync
- Check server DB - payloads are encrypted blobs
- Refresh JWT token on device A
- Verify sync still works (key is stable)
- Login on device B with same account
- Verify data syncs and decrypts correctly
- Disable auto-encryption on device A
- Verify server data is now unencrypted
- Verify device B detects change and updates
Security Considerations
What This Protects Against
- Database dumps (encrypted blobs are useless without key)
- Backup file leaks
- SQL injection reading data
What This Does NOT Protect Against
- Server operator with access to JWT_SECRET
- Man-in-the-middle if HTTPS is compromised
- Client-side token theft
Why This Is Acceptable
- Target audience is "lazy users" who won't use a passphrase
- "Encryption at rest" is a real security improvement over no encryption
- Users who want true E2E can still use passphrase encryption
- Security model is honest and documented
Future Exploration: Device-Bound Key
For users who want true E2E without a passphrase, explore:
-
Random key stored in IndexedDB
- Risk: Lost on browser data wipe
- Need: Export/import flow
-
Electron keychain integration
- More persistent storage
- Platform-specific implementation
-
Passkey PRF extension
- True E2E with zero UX friction
- Limited browser support (2025)
These are out of scope for initial implementation but worth exploring.