docs/long-term-plans/jwt-derived-encryption.md
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.
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) |
All 5 reviewers identified this as a blocker.
The plan proposes SHA-256(jwt) as the encryption key. However:
Result: User's encrypted data becomes permanently unreadable after token refresh.
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:
Files:
src/app/op-log/sync-providers/super-sync/super-sync.model.tssrc/app/features/config/global-config.model.tsChanges:
// 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;
}
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:
encrypt(data, password) and decrypt(data, password) use Argon2idencryptWithDerivedKey(data, derivedKeyInfo) for pre-derived keysoperation-encryption.service.ts needs a new code path for auto-encryptionFile: 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;
}
New file: src/app/imex/sync/auto-encryption-enable.service.ts
Flow:
SHA-256(accessToken)autoEncryptionKeyisAutoEncryptionEnabled: trueReuse existing patterns from:
encryption-enable.service.ts (lines 16-80)encryption-disable.service.tsFile: 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."
}
| 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 |
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
| 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).
When a new device syncs for the first time with auto-encryption:
SHA-256(accessToken)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');
});
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)');
});
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');
});
For users who want true E2E without a passphrase, explore:
Random key stored in IndexedDB
Electron keychain integration
Passkey PRF extension
These are out of scope for initial implementation but worth exploring.