Back to Super Productivity

Secure Secret Storage Plan

docs/long-term-plans/secure-storage.md

18.5.049.0 KB
Original Source

Secure Secret Storage Plan

Status: planned

This plan replaces the older sync-credential-only secure storage sketch and folds in the independent broader draft. The target is a secure storage architecture for all app-managed secrets: sync credentials, sync encryption passphrases, issue-provider tokens/passwords, plugin config secrets, plugin OAuth tokens, and native background-sync credentials.

Core Tradeoff

Moving secrets out of synced state is a deliberate UX tradeoff.

By default, secrets become device-local. A second client can sync non-sensitive configuration metadata, but it cannot receive the raw token/password from sync. The user will need to re-enter, reauthenticate, or unlock a separate portable vault on each client.

This is a degradation compared with today's "everything arrives through sync" experience for issue/provider credentials, but it is the safer default because synced state, op-log operations, backups, and retained server history are not appropriate places for raw secrets.

UX mitigation:

  • Keep provider metadata synced so setup forms are prefilled except for the missing secret.
  • Show clear per-device states: "credential saved on this device", "credential missing on this device", and "secure storage unavailable".
  • Provide direct reauth/re-enter actions from each affected integration.
  • Add a future opt-in portable encrypted vault for users who explicitly want selected integration secrets to move between devices.

Can We Avoid This Tradeoff?

Not completely. Any design that avoids per-device re-entry must sync or transfer the secret, a secret-encryption key, or enough material to recover one. That can be a valid product choice, but it changes the trust model.

Viable alternatives:

  • Portable encrypted vault: sync encrypted secret blobs and require the user to unlock them on each new device with a vault passphrase, recovery key, or enrolled device key. This gives good multi-device UX after one unlock per device, but adds password/recovery UX and exposes users with weak vault passphrases to offline brute-force risk.
  • Device-to-device vault transfer: a trusted existing device encrypts the vault key for a new device, for example via QR code or pairing flow. This is a strong compromise when an old device is available, but it does not help fresh installs after all devices are gone.
  • Server-assisted or account-derived vault key: derive or unwrap the vault key from SuperSync login/account material. This is the smoothest UX, but it either makes the server part of the secret recovery trust boundary or creates hard recovery problems when passwords are reset.
  • OS cloud keychain: rely on iCloud Keychain, Google Password Manager, or a similar platform facility. This can be good UX within a platform ecosystem, but it is fragmented, hard to make uniform across Electron/Web/Android/iOS, and outside Super Productivity's sync semantics.

Recommended default:

  • Use device-local storage for sync credentials and sync encryption passphrases.
  • Make a portable encrypted vault an explicit opt-in for selected integration secrets.
  • Consider device-to-device vault transfer as a later UX improvement.

Post-V1 - Low-Friction Portable Vault Variant

For a cheaper, lower-maintenance improvement with no new password prompt for users who already use sync E2EE, use the existing sync E2EE unlock material to wrap the portable vault key.

This variant is deliberately less ambitious than a standalone vault passphrase:

  • It requires no new server.
  • It requires no new password, recovery key, or separate vault account.
  • It gives no additional protection if sync E2EE is disabled.
  • It does not create a security boundary stronger than the existing sync encryption passphrase.
  • It must not silently enable portable synced secrets for users with weak or disabled sync E2EE.

Recommended behavior:

  • Keep sync provider credentials and sync encryption passphrases device-local in OS-backed storage. Do not store the only sync unlock secret inside the vault that depends on sync unlock.
  • For synced issue-provider and plugin secrets, store only SecretRef metadata in normal app state.
  • Store the actual secret values in a synced portable vault record encrypted with a random vaultDek.
  • Wrap vaultDek with a vault wrapping key derived from existing sync E2EE material:
    • If the input is a user passphrase, derive the wrapping key with Argon2id, per-vault salt, versioned parameters, and domain separation.
    • If the input is an existing high-entropy sync key, derive a separate wrapping key with HKDF-SHA-256, vault-specific salt, and an info string such as super-productivity-portable-vault-v1.
    • Never use the sync content-encryption key directly as a vault wrapping key.
  • Store an OS-protected wrapped copy of vaultDek locally so already-configured devices unlock silently. Keep the plaintext vaultDek only in memory while the vault is unlocked.
  • On sync E2EE passphrase/key rotation, unlock the current vault, create a new wrapper, and sync the updated manifest atomically. Keep old wrappers for a bounded grace period so offline devices can migrate; document that a device without the old unlock material may need integration reauth.
  • If sync E2EE is not enabled, offer only device-local secure storage for real protection. Do not create new portable synced secret records.

User experience:

  • Existing devices can migrate silently once they have the sync E2EE key and a usable local secure-storage backend.
  • New devices get the same prompt they already need for sync E2EE. After sync decrypts, the vault unlocks automatically from the same material.
  • No extra vault password is introduced.
  • No integration token needs to be re-entered as long as the user has the existing sync E2EE unlock material.

Security improvement:

  • Raw integration secrets no longer live in NgRx state, op-log operations, snapshots, normal backups, plugin synced data, or diagnostic logs.
  • Remote sync storage still sees only ciphertext when sync E2EE is enabled.
  • Local OS-backed storage protects cached vault material at rest on each device.

Limitations:

  • An attacker who can brute-force or obtain the sync E2EE key can also unlock the portable secret vault.
  • If a user chose a weak sync E2EE passphrase, the low-friction vault inherits that weakness. Do not silently enable portable vault sync when existing passphrase-strength checks fail.
  • A compromised running client can still read secrets after vault unlock.
  • Users without sync E2EE get only local hardening, not secure portable synced secrets.
  • Old synced history may still contain previously stored plaintext secrets until compaction/retention cleanup is complete.

E2EE-disabled fallback:

  • A fixed app key, static "standard key", encoding, or bundled public secret is only obfuscation. It prevents casual plaintext grepping but does not protect against anyone who can inspect the app code or synced data format.
  • Do not use fixed-key obfuscation for new synced secret writes.
  • If fixed-key obfuscation is used at all, restrict it to one-time legacy read/migration compatibility, call it "plaintext-equivalent compatibility encoding" internally, and define an expiry/removal release.
  • A per-device random key stored in OS secure storage is real local protection, but it cannot decrypt secrets on another device and therefore is not a no-friction sync solution.
  • Real portable protection without sync E2EE requires some other key source: user passphrase, platform cloud keychain, device-to-device transfer, passkey escrow, or a trusted server-side recovery design.
  • For sync without E2EE, do not silently migrate synced integration secrets. Prompt once with explicit choices: enable sync E2EE and migrate later, or move credentials to this device only. New credentials default to device-local unless a real portable vault is available.

Goals

  • Keep passwords, access tokens, refresh tokens, API keys, and encryption passphrases out of NgRx state, op-log payloads, snapshots, normal backups, plugin synced data, and diagnostic logs.
  • Use OS-backed secret storage where available.
  • Make degraded platforms explicit instead of silently falling back to plaintext.
  • Preserve existing masked-field UX with explicit unchanged/replace/clear behavior.
  • Migrate existing plaintext local secrets without breaking sync provider auth, and migrate synced integration secrets only after compatibility gates are satisfied.
  • Add tests that fail when canary secret values appear in serialized app state, operation payloads, backups, or logs.

Non-Goals

  • This is not a general password manager.
  • This does not protect secrets after a compromised renderer, malicious plugin, browser extension, malware, or injected script has runtime access to a resolved secret.
  • This does not remove the need for SuperSync/file-sync E2E encryption for user content.
  • This does not make third-party tokens safer than their upstream scopes.
  • The first release does not add native OS-backed storage, a portable vault, browser persistent secret storage, or deep cleanup of historical remote sync history.

Release Split

V1 - KISS Scope

V1 focuses on the highest-value security improvement: stop raw integration secrets from entering synced state, op-log payloads, backups, plugin synced data, and logs.

Because migrating synced config to SecretRef is schema-breaking, V1 is split into two coordinated releases:

V1a - compatibility and guardrails:

  • Secret registry, redaction, and canary tests.
  • An Electron-profile indexedDbProfile LocalSecretStore backend.
  • SecretRef-tolerant readers that preserve unknown/unsupported SecretRef values without overwriting them.
  • Pre-dispatch and op-log guardrails that prevent new raw secret writes where the new flow is active.
  • Backup/import/sync-hydration/state-cache sanitizers.
  • persistDataSynced marked and tested as non-secret storage.

V1b - synced-secret migration:

  • Built-in issue-provider secret fields stored as SecretRef plus local secret values.
  • Plugin schema password fields stored as SecretRef plus local secret values.
  • Simple per-provider states: saved on this device, missing on this device, storage unavailable.
  • A hard compatibility gate proving all supported clients preserve SecretRef values before raw synced credentials are removed.

V1 explicitly defers:

  • Sync provider private config, SuperSync access tokens, and encryptKey migration.
  • Plugin OAuth token migration.
  • Android background sync token hardening.
  • Electron safeStorage, Android Keystore, and iOS Keychain backends.
  • Portable synced vault, recovery keys, device pairing, and vault export/import.
  • Browser persistent passphrase vault.
  • Full historical cleanup of old remote ops/snapshots and old backup files.
  • The broader "Connections on this device" checklist UI.

V1 storage capability matrix:

PlatformV1 persistent local secret storeNotes
Electron desktopYes, indexedDbProfileLocal-isolation tier only; not OS-backed at-rest protection
Browser/PWANoUse session-only or unavailable mode in V1
Android/iOS CapacitorNoUse session-only or unavailable mode until native storage/backup rules are implemented

V1 does not improve local at-rest protection for persisted Electron profile data. Its main win is removing raw integration secrets from synced state, op-log payloads, backups, plugin synced data, and logs.

Current Secret Inventory

Sync Provider Secrets

Current storage:

  • SyncCredentialStore stores private provider config in the sup-sync IndexedDB database.
  • Private config can include WebDAV/Nextcloud passwords, Dropbox access and refresh tokens, SuperSync access and refresh tokens, and encryptKey.
  • The data is local-only from a sync model perspective, but plaintext in IndexedDB.

Relevant files:

Android Background Sync Secrets

Current storage:

  • SuperSync access tokens are mirrored from the WebView into native Android storage for background sync/reminder cancellation.
  • Native storage uses EncryptedSharedPreferences, but currently falls back to standard SharedPreferences if encrypted preferences fail.
  • android:allowBackup="true" is enabled and no backup exclusion rule for these encrypted preferences is present in the current tree.

Relevant files:

Built-In Issue Provider Secrets

Current storage:

  • Built-in issue provider configs live in the issueProvider NgRx state.
  • issueProvider is part of the op-log model config, snapshots, sync data, and backups.
  • Secret fields include:
    • Jira: password
    • GitLab: token
    • CalDAV: password
    • OpenProject: token
    • Gitea: token
    • Redmine: api_key
    • Trello: apiKey, token
    • Linear: apiKey
    • Azure DevOps: token
    • Nextcloud Deck: password

Relevant files:

Plugin Secrets

Current storage:

  • Plugin OAuth tokens are local-only in the sup-plugin-oauth IndexedDB database, but plaintext.
  • Plugin config is stored via PluginUserPersistenceService, which is part of synced pluginUserData.
  • Password fields in plugin issue-provider schemas are currently regular plugin config values. For example, GitHub and ClickUp issue-provider tokens can end up in synced plugin config.

Relevant files:

Architecture

Introduce two separate concepts:

  • LocalSecretStore: device-local secret/key storage. The first release may use the existing local IndexedDB profile storage with secret-specific boundaries; native OS-backed stores are a later hardening phase.
  • PortableVault: synced encrypted secret records that can be unlocked only with valid vault key material.

SecretRef is metadata, not an authorization capability. Possession of a SecretRef must not be enough to resolve a secret. Every resolution must enforce the caller domain, plugin identity when applicable, ownerType, ownerId, and field.

ts
export interface SecretRef {
  kind: 'SecretRef';
  version: 1;
  id: string;
  ownerType:
    | 'syncProvider'
    | 'issueProvider'
    | 'pluginConfig'
    | 'pluginOAuth'
    | 'nativeBackground';
  ownerId: string;
  field: string;
  storageMode: 'device' | 'portableEncrypted';
  updatedAt: number;
  versionToken?: string;
}

export type SecretAccessContext =
  | {
      callerType: 'app' | 'nativeBridge';
      expectedOwnerType: SecretRef['ownerType'];
      expectedOwnerId: string;
      expectedField: string;
    }
  | {
      callerType: 'plugin';
      callerId: string;
      expectedOwnerType: 'pluginConfig' | 'pluginOAuth';
      expectedOwnerId: string;
      expectedField: string;
    };

export interface LocalSecretStoreCapabilities {
  mode:
    | 'localProfile'
    | 'osBacked'
    | 'passphraseProtected'
    | 'sessionOnly'
    | 'plaintextEquivalent'
    | 'unavailable';
  backend:
    | 'indexedDbProfile'
    | 'electronSafeStorage'
    | 'androidKeystore'
    | 'iosKeychain'
    | 'webSession'
    | 'webPassphrase';
  canPersistDeviceSecrets: boolean;
  canUsePortableVault: boolean;
  securityNotes?: string;
}

export interface LocalSecretStore {
  capabilities(): Promise<LocalSecretStoreCapabilities>;
  set(input: SecretRefInput, value: string, context: SecretAccessContext): Promise<SecretRef>;
  useSecret<T>(
    ref: SecretRef,
    context: SecretAccessContext,
    fn: (value: string) => Promise<T>,
  ): Promise<T | null>;
  delete(ref: SecretRef, context: SecretAccessContext): Promise<void>;
  exists(ref: SecretRef, context: SecretAccessContext): Promise<boolean>;
}

Only SecretRef and non-sensitive metadata may be stored in NgRx state, op-log operations, snapshots, backups, and plugin synced data. The actual secret value must live behind LocalSecretStore or PortableVault.

versionToken must not be a raw hash, prefix, suffix, checksum, or reusable derivative of the secret. If needed, it should be a random opaque marker or a keyed HMAC with a non-synced key. Otherwise omit it. V1 has no required use case for versionToken; prefer omitting it until a concrete need exists.

Storage Modes

device:

  • Default mode.
  • Stored only on the current device.
  • Used for sync provider credentials, sync encryption passphrases, plugin OAuth tokens, and native background-sync credentials.
  • Other devices must reauthenticate or re-enter these values.
  • In V1, synced config must not contain a per-device random secret id. Use a deterministic secret slot id derived from stable metadata, for example v1:${ownerType}:${ownerId}:${field} with plugin id included in ownerId for plugin-owned secrets.
  • Replacing a device-local secret value updates only the local secret store once the synced SecretRef slot exists. It must not update synced metadata such as updatedAt merely because the local secret value changed.
  • Clearing an integration removes the synced SecretRef; clearing or replacing a secret on one device must not make another device's local secret missing if the integration remains configured.

portableEncrypted:

  • Future opt-in mode for selected integration credentials.
  • Secret ciphertext may sync, but only after being encrypted with a separate portable-vault key.
  • The portable-vault key is unlocked by sync E2EE material, user passphrase, passkey/device key enrollment, or an explicit export/import flow.
  • This mode must not be used to bootstrap SuperSync access tokens or the only copy of a sync encryption passphrase.
  • SecretRef.id for portable records is minted once and synced with the owning config so every device can find the same vault record. Device-local refs must not be interpreted as portable ids on other devices.

Post-V1 Portable Encrypted Vault Mechanics

A portable encrypted vault does not require a new server. Existing sync can carry the vault manifest and encrypted records as normal app data because the vault encrypts secrets before they reach normal state, op-log, and snapshot code. When SuperSync or file-sync E2E encryption is enabled, portable vault records are encrypted twice: once by the vault and again by sync payload encryption.

Why this is still useful when sync is E2E encrypted:

  • Sync E2EE protects remote payloads from the sync server or storage provider.
  • After sync decryption, normal app state is plaintext on the client. If raw secrets remain in issue or plugin config, they can enter local state, backups, logs, and plugin persistence.
  • Sync E2EE does not bootstrap new devices by itself. The current sync encryption passphrase/private config is local-only and still needs to be supplied per device.
  • The sync target still sees ciphertext metadata and can delete, replay, or withhold records. Sync E2EE gives confidentiality, not full freshness, integrity, or availability against a malicious storage provider.

Recommended key structure:

  • Generate a random 256-bit vaultDek when the user enables the portable vault.
  • Encrypt each secret record with AES-256-GCM or XChaCha20-Poly1305 using vaultDek, a unique nonce, and authenticated data containing record id, owner, field, and schema version.
  • Sync only SecretRef metadata, the vault manifest, encrypted secret records, and wrapped copies of vaultDek.
  • Store an authenticated manifest with manifest version, vault epoch, wrapper metadata, record ids, and tombstones for deleted records.
  • Define conflict and rollback behavior for stale manifests, resurrected records, wrapper-set rollback, and SecretRef.updatedAt rollback.
  • Never sync plaintext vaultDek. Persist only wrapped copies of it. Keep the plaintext vaultDek only in memory while the vault is unlocked.

Unlock methods:

  • device: wrap vaultDek with the OS-backed local LocalSecretStore for fast unlock on already-enrolled devices.
  • passphrase: derive a wrapping key with Argon2id, per-vault salt, versioned parameters, and domain separation, then wrap vaultDek. This supports recovery and new-device unlock without an old device.
  • recoveryKey: optional high-entropy recovery key displayed once or exported explicitly.
  • devicePairing: optional later flow where an old device encrypts vaultDek for a new device public key.
  • syncE2EE: low-friction mode deriving a distinct vault wrapping key from existing sync E2EE material as described above. Never reuse the sync content key directly.

New device flow:

  1. Sync downloads SecretRef metadata plus vault ciphertext.
  2. The app shows affected integrations as "credential locked" instead of "credential missing".
  3. The user unlocks with the vault passphrase/recovery key or pairs with an existing device.
  4. The client unwraps vaultDek and stores an OS-protected wrapped copy locally.
  5. Subsequent use resolves the secret through PortableVault and LocalSecretStore without storing the plaintext in synced state.

Server role:

  • No custom vault server is required for the baseline design.
  • The server or sync target stores ciphertext plus vault metadata only.
  • A server would only be needed for optional account recovery, remote device approval queues, passkey/account-based escrow, or push-assisted pairing.
  • Do not use SuperSync access tokens as vault keys.
  • Using the existing sync encryption passphrase or key as vault unlock material is possible, but must be explicit because it couples vault security and recovery to sync encryption configuration.

Platform Backends

Native OS-backed stores are not required for the first release. The first release can use an Electron local profile store and focus on removing raw secrets from sync state, op-log payloads, backups, plugin synced data, and logs. Native stores should be implemented later as platform hardening.

First Release - Local Profile Store

On Electron desktop, use a dedicated local IndexedDB store for secret values. Browser/PWA and Android/iOS Capacitor builds do not persist secrets in V1.

Implementation sketch:

  • Store only SecretRef metadata in synced app state.
  • Store secret values in a local-only database separate from synced model data.
  • Encrypt with sync E2EE-derived vault material only when that material is already available. Otherwise treat this as local isolation, not strong at-rest encryption.
  • Do not sync, back up, log, or export this database through normal app flows.
  • Keep the same LocalSecretStore API so native backends can replace the storage implementation later.

Deferred - Electron Desktop

Use Electron main-process IPC as the only bridge to the vault.

Implementation sketch:

  • Add electron/ipc-handlers/local-secret-store.ts.
  • Register the handler in electron/ipc-handler.ts.
  • Add narrow preload methods in electron/preload.ts and electron/electronAPI.d.ts:
    • localSecretStoreSet
    • localSecretStoreResolve
    • localSecretStoreDelete
    • localSecretStoreCapabilities
  • Use safeStorage.encryptString() and safeStorage.decryptString() in the main process.
  • Store encrypted blobs in a small app-data file or a dedicated local database under app.getPath('userData').
  • Reject persistent device-secret storage or require explicit degraded consent when Linux reports the basic_text backend. That backend must not be a silent plaintext-equivalent fallback.
  • On upgrade, if Linux reports basic_text and legacy plaintext credentials exist, do not delete them silently. Show an explicit choice: keep legacy plaintext with warning, switch to passphrase-protected storage, or use session-only storage and reauth when needed.

Deferred - Android

Use a native Capacitor/JavaScript bridge backed by Android Keystore.

Implementation sketch:

  • Add a native LocalSecretStore.
  • Generate an AES-GCM key in AndroidKeyStore.
  • Store ciphertext and metadata in private app storage or SharedPreferences.
  • Do not fall back to plaintext SharedPreferences.
  • Exclude vault ciphertext and encrypted preference files from Android Auto Backup. KeyStore keys may not survive restore, so restored ciphertext should be treated as unavailable and trigger reauth.
  • Add android:dataExtractionRules for API 31+ and android:fullBackupContent rules for older devices. Exclude both the legacy background-sync preferences and the new local secret-store files before Phase 1 writes new secrets.
  • Replace the current BackgroundSyncCredentialStore fallback with either "store encrypted" or "do not persist".

Deferred - iOS

Use Keychain Services through a native Capacitor bridge.

  • Store small secrets as keychain items.
  • Use a non-iCloud, device-local accessibility class for device-only secrets.
  • Set kSecAttrSynchronizable=false for device-local secrets.
  • Define the keychain access group explicitly.
  • Pick accessibility based on runtime need:
    • whenUnlockedThisDeviceOnly for secrets that do not need background access.
    • afterFirstUnlockThisDeviceOnly only where background tasks require access.
  • Document app reinstall behavior. iOS keychain items can survive uninstall, so stale tokens must be detected and cleared or replaced during first-run setup.

Web/PWA

The browser build cannot offer OS-level secret storage through standard web APIs.

Recommended behavior:

  • Default to session-only secret retention for the most sensitive values.
  • Do not offer persistent browser secret storage in the first release.
  • Defer browser passphrase vault work. If implemented later, pin the KDF first: WebCrypto gives PBKDF2 natively, while Argon2id requires a WASM dependency and explicit bundle-size acceptance.
  • If "remember on this browser" is implemented later, store only non-extractable CryptoKey material where available and make the degraded security model explicit.
  • Never claim browser persistent storage is equivalent to OS keychain storage.

Data Flow

Forms

Password/token fields should have three states:

  • unchanged: a SecretRef exists and the user did not type a replacement.
  • replace: the user typed a new secret, so the vault is updated and state receives the new SecretRef.
  • clear: the user explicitly deletes the secret, so the vault entry and state reference are removed.

The form model must not emit the existing secret value to NgRx. Masked placeholders are display-only.

Secret-bearing UI paths must vault replacement values and dispatch only SecretRef values before any persistent action is emitted. The vault layer must reject masked placeholder sentinels such as ******** as real secret values, and forms must use dirty-state tracking instead of comparing placeholder strings. If a user starts editing a secret field and then reverts it to its original masked/empty display state, the form should collapse back to unchanged, not clear or replace.

Runtime Resolution

Services that need credentials resolve them as late as possible:

  1. Load public config from NgRx or provider private config.
  2. Resolve required SecretRef values through LocalSecretStore or PortableVault with a SecretAccessContext.
  3. Build a short-lived runtime config object.
  4. Use it for the request.
  5. Do not dispatch, persist, or log the resolved object.

Plugin Config

Plugin JSON schema fields with type: "password" should be intercepted by the host app.

  • Synced plugin config stores SecretRef values instead of raw secret strings.
  • PluginAPI.getConfig() should return config metadata and SecretRef values, not resolved secret strings.
  • Resolved plugin secrets should be exposed through a short-lived, namespaced API such as PluginAPI.useSecret(ref, fn). The host validates that the ref belongs to the calling plugin before resolving it. The first implementation maps plugin-owned refs by callerId === ownerId.
  • persistDataSynced is explicitly non-secret storage. It must not log payloads and should reject active canary/registered secret values in tests.
  • Defer broader plugin secret APIs such as persistSecret, loadSecret, and deleteSecret. Initially support schema password fields and PluginAPI.useSecret(ref, fn) only.
  • Plugin OAuth token migration is deferred. V1 only ensures plugin OAuth values remain registered for redaction/canary checks and are not newly leaked through logs, exports, or backups.

Deferred Sync Provider Config

Continue treating provider private config as local-only in V1. Do not change writes to sup-sync in V1. A later platform-hardening phase can store these secret fields behind refs or native/OS-backed storage:

  • WebDAV/Nextcloud: password, optional bearer accessToken, encryptKey
  • Dropbox: accessToken, refreshToken, encryptKey
  • SuperSync: accessToken, refreshToken, encryptKey
  • Local file: encryptKey

Non-secret fields such as base URLs and folder paths can remain normal config.

V1 still registers these fields for redaction and canary checks so encryptKey, access tokens, refresh tokens, and passwords do not newly appear in op-log payloads, snapshots, backups, logs, or exports.

Deferred provider work:

  • Provider APIs should expose public config separately from secrets.
  • Call paths that need private values should resolve them asynchronously through SecretRefs.
  • Android native mirroring resolves a SuperSync token only for the bridge write, refuses plaintext fallback or marks storage as unavailable, and relies on backup exclusions for restored devices.

Migration Strategy

Phase 0 - Secret Registry and Guards

  • Add a typed registry of sensitive paths by domain:
    • sync provider private config fields
    • built-in issue provider fields
    • migrated GitHub/ClickUp plugin config fields
    • plugin schema password fields
    • plugin OAuth token records
  • Add test helpers that scan snapshots, operation payloads, and backups for canary secret values.
  • Add an op-log capture guard that rejects or fails tests when registered canary secrets appear in persistent action payloads.
  • Add a pre-persistence AppDataComplete secret migration/sanitizer used by backup import, remote sync hydration, file-sync snapshot download, full-state tail-op hydration, state-cache writes, and loadAllData.
  • Handle SYNC_IMPORT and BACKUP_IMPORT replacement semantics explicitly: block concurrent secret writes during import/hydration, then rerun the sanitizer and re-emit deterministic SecretRef metadata if an import replaced it. Secret refs must not be lost while local secret-store entries remain orphaned.
  • Introduce one registry-backed redactSecrets(value) used by log recording, log export, privacy export, crash/error additional data, and plugin/config payload logging.
  • Extend redaction to include apiKey, api_key, clientSecret, client_secret, authorization, case variants, and nested plugin config password fields.
  • Mark persistDataSynced as non-secret storage and remove payload logging.
  • Store migration markers only in local profile storage, never in synced NgRx state.
  • V1 canary exit criterion: after migration, newly produced/current serialized outputs contain zero raw secret hits. This includes current state, new persistent actions, new op-log entries, new snapshots, new backups, logs, privacy export, and plugin synced data.
  • Old local/remote op history and old backup files may still contain previously stored secrets until deferred historical cleanup. V1 should warn about those artifacts rather than claim they were purged.

Phase 1 - V1 Local Profile Store

  • Implement the common LocalSecretStore interface.
  • Add an indexedDbProfile backend first for Electron desktop only.
  • Surface capabilities in the UI so users can distinguish localProfile, sessionOnly, and unavailable storage in the first release.
  • Keep browser/PWA and Android/iOS persistent secret storage out of the first release; use session-only or unavailable mode there.
  • Defer Electron safeStorage, Android Keystore, iOS Keychain, Android backup exclusions, and Linux basic_text handling to post-V1 platform hardening.

Deferred - Local-Only Sync Secrets

Do not migrate these in V1. They are local-only already, so they do not drive the main synced-state/op-log leak risk. Move them after the V1 leak-path cleanup or combine them with native platform hardening.

  • Sync provider private config in sup-sync.
  • Plugin OAuth tokens in sup-plugin-oauth.
  • Android background SuperSync credential mirror only if the native hardening phase is pulled forward.

Migration flow:

  1. Check migration marker for the profile and backend.
  2. For each known local-only secret source, check if a vault entry already exists.
  3. Load plaintext legacy value.
  4. Store value in LocalSecretStore.
  5. Replace persisted value with a SecretRef or remove it if the owning config can derive the ref deterministically.
  6. Clear plaintext legacy value only after a successful vault write.
  7. Retry idempotently on next startup if migration fails mid-way.
  8. On migration failure, keep the raw value only in its original legacy source for retry. Do not write the raw value into NgRx, persistent actions, imports, backups, or synced state.

After successful migration:

  • Delete plaintext legacy values.
  • Clear in-memory caches where relevant.
  • Keep compatibility reads for one or two releases, but never write plaintext secrets again.

Phase 2 - V1 Synced Issue and Plugin Config Secrets

Migrate high-risk synced secrets next:

  • Built-in issue provider tokens/passwords.
  • Plugin config fields marked as password.
  • Legacy migrated GitHub/ClickUp provider config.

This phase is a schema-breaking sync boundary unless client-version gating or op migration exists.

Rollout gate:

  • V1a is the compatibility release. All supported clients must be able to read and preserve SecretRef values before any client removes raw synced credentials.
  • Define an explicit sync schemaVersion or minClientVersion signal on operations/snapshots before V1b. Do not rely on vector-clock entries as a durable old-client detector; vector clocks are bounded and can be pruned.
  • If older clients may still sync, show a blocking upgrade warning and keep affected providers read-only or unmigrated until the user confirms the risk.
  • Do not run historical cleanup while the explicit compatibility signal says a pre-migration client can still publish plaintext credentials.

First-release scope:

  • Use device-local migration for V1b. Migrate synced integration secrets into device-local storage. Every device must reconnect integrations once.
  • Defer E2EE portable migration to the post-V1 portable vault phase. Do not implement portable vault migration, key rotation, recovery keys, device pairing, export/import, or rollback handling in V1.
  • Make migration idempotent by deterministic secret slot (ownerType, ownerId, field). Two upgraded devices migrating the same provider should produce the same synced SecretRef metadata, so last-writer wins does not orphan either device's local secret.

For sync without E2EE:

  • Do not silently migrate synced integration secrets into a portable synced store.
  • Prompt once with explicit choices: enable sync E2EE and migrate later, or move credentials to this device only.
  • New credentials default to device-local unless a real portable vault is available in a later release.

Legacy GitHub/ClickUp migration:

  • Read legacy plaintext fields.
  • Store them in LocalSecretStore.
  • Remove raw token, apiKey, and password-like fields from migrated entities.
  • Do not copy raw legacy fields into plugin config during reducer migration.

Deferred - Historical Data Cleanup

Removing current state fields is not enough because local op logs, remote operation history, snapshots, backups, and old exported files may already contain raw secrets.

This is not required for V1. V1 should prevent new leaks and warn that old history/backups may still contain previously stored secrets.

Deferred cleanup:

  • Rewrite or purge OPS, STATE_CACHE current/backup, IMPORT_BACKUP, PROFILE_DATA, file-sync sync-data.json.state, file-sync recentOps, and remote SuperSync snapshots/ops where supported.
  • Compact local operation logs after migration and after compatibility gates are satisfied.
  • Rewrite current snapshots with sanitized state.
  • For file-based sync, force-upload a stripped snapshot.
  • For SuperSync, ensure server-side retained history no longer exposes raw secrets after compaction/retention. If no such retention guarantee exists, document that old server-side records may still contain legacy secrets.
  • Delete migrated plaintext values from legacy IndexedDB stores.
  • Update privacy export masking to include all registry keys.
  • Warn users that older backups, copied sync files, and retained sync history may still contain previously saved tokens/passwords. Recommend deleting/protecting old backup files and rotating third-party tokens if those files may have been shared or exposed.

Post-V1 - Native Stores and Optional Portable Vault

Only after device-local storage and compatibility gates are stable:

  • Add Electron safeStorage, Android Keystore, and iOS Keychain backends.
  • Add Android backup exclusion resources before Android native secret writes.
  • Add explicit opt-in for syncing selected integration secrets.
  • Use a separate vaultDek, not a SuperSync access token or the sync content encryption key.
  • Add authenticated manifest versioning, tombstones, wrapper metadata, and rollback/replay handling.
  • Allow encrypted export/import of vault contents for backup.
  • Never include device-local sync credentials in portable vault export by default.

Error Handling

ScenarioHandling
V1 device-local entry missingShow "credential missing on this device" and offer reauth/re-enter
V1 local profile store unavailableMark affected integration missing/read-only on this device; do not write raw fallback state
V1 corrupted local entryDo not delete the SecretRef automatically; offer reconnect/replace and diagnostic export without the secret
Migration fails mid-wayKeep the legacy value only in its original legacy source for retry; do not re-persist it through NgRx, op-log, backup, or synced state
Secret lookup fails during syncStop sync and ask for credential; do not overwrite or disable config
Older client still syncingKeep affected providers read-only or unmigrated until compatibility gate is satisfied
Post-V1 portable vault lockedShow "credential locked" and offer the existing sync E2EE unlock, vault passphrase, recovery key, or device-pairing flow
Post-V1 OS-backed storage unavailableUse session-only storage or explicit passphrase-protected/degraded mode
Post-V1 Linux basic_text backendDo not silently persist secrets; require explicit plaintext-equivalent consent or passphrase/session-only alternative
Post-V1 sync E2EE key rotation cannot rewrap vaultKeep old wrapper during grace period; if unavailable, require old unlock material or integration reauth

Backup and Restore Rules

  • Normal app backups must never contain raw device secrets.
  • For device-local secrets, backups contain only SecretRef metadata and provider metadata.
  • For post-V1 portable vault secrets, backups may include portable vault ciphertext and non-device wrappers, but never plaintext secrets or device-local wrappers. This gives restore support while preserving the same offline brute-force risk as the portable vault itself.
  • Post-V1 automatic platform backups must exclude native secret ciphertext if the decrypting key is device-bound.
  • Manual encrypted vault export can be a separate artifact:
    • user supplies an export passphrase
    • export uses fresh salt and authenticated encryption
    • import requires explicit confirmation and conflict handling
  • Restoring an app backup on a new device should show integrations as "configured, credentials missing" or "credential locked" with direct reauth or unlock actions.

UX Requirements

  • V1 settings pages need only simple stable states: "credential saved on this device", "missing on this device", and "storage unavailable".
  • Defer "credential locked", "restored but key unavailable", "unrecoverable on this device", and the broader "Connections on this device" checklist to the portable/native hardening phases.
  • Reauth is explicit and provider-specific.
  • Clearing an integration deletes both the config reference and vault secret.
  • Failed secret lookup must not silently disable sync or overwrite config.
  • On degraded Linux storage, the user chooses between session-only storage and a passphrase-protected local store. On web/PWA, first release behavior is session-only or unavailable.
  • Device-local behavior must be documented in the sync and integration settings: secrets are intentionally not synced by default.
  • Release notes must say that new backups no longer include raw integration credentials, while older backups or sync history may still contain previously saved tokens/passwords. Users should delete/protect old backups and rotate third-party tokens if those files may have been exposed.

Security Invariants

V1a guardrail invariants:

  • No new raw secret values in persistent action capture, op-log operations, BACKUP_IMPORT, SYNC_IMPORT, state cache, hydration payloads, plugin synced data, persistDataSynced payloads, logs, error additional data, stack traces, privacy export, or log export for paths covered by the registry.
  • Deferred local-only secrets such as encryptKey, sync access tokens, and plugin OAuth tokens stay in their existing stores, but registry/canary tests must verify they are not newly leaked through logs, exports, backups, or sync payloads.
  • No fixed-key obfuscation for new synced secret writes.

V1b migrated-secret invariants:

  • Migrated built-in issue-provider and plugin schema password values do not appear as raw values in NgRx state, action payloads, op-log operations, complete backup snapshots, plugin synced data, or logs.
  • If migration cannot write to LocalSecretStore, the integration becomes missing or read-only on that device. Raw fallback state is not written.
  • SecretRef is not an authorization capability; resolution validates caller identity and owner metadata.
  • For V1 indexedDbProfile, SecretRef is useless without the local profile store from the same device/profile. This is not an at-rest encryption claim: anyone with local profile disk access may be able to read the local store.
  • Runtime-resolved config objects stay local to the call path that needs them.

Post-V1 invariants:

  • No plaintext native fallback for native OS-backed persistent storage.
  • Plaintext vaultDek is never synced or persisted; it exists only in memory while the portable vault is unlocked.

Testing Strategy

V1 tests:

  • Unit tests for indexedDbProfile LocalSecretStore using canary values.
  • Unit tests for SecretRef authorization boundaries:
    • wrong owner type/id/field is denied
    • plugin A cannot resolve plugin B's ref
    • a copied or stale ref is not sufficient to access a secret
  • Multi-device tests for deterministic device-local slots: client A and client B can both reconnect the same integration, and syncing B's metadata does not make A's local credential missing.
  • Migration tests for fresh install, existing issue/plugin credentials, partial migration, and failed local-store writes.
  • Form tests for unchanged/replace/clear behavior, including masked sentinel rejection and "typed then reverted" collapsing back to unchanged.
  • Plugin config tests for schema password fields.
  • Compatibility-release tests:
    • new clients preserve existing raw config before migration
    • compatibility clients preserve SecretRef values
    • unsupported peers trigger read-only/blocking behavior
    • older-client overwrite of refs with raw/empty values is blocked or detected
  • Snapshot/backup/op-log tests that fail if canary secrets appear in serialized state.
  • Registry/redaction tests for case variants and nested keys such as apiKey, api_key, authorization, clientSecret, and plugin config password fields.
  • encryptKey and sync-token canaries even though their migration is deferred.
  • Integration canaries for persistent action capture, OPS, BACKUP_IMPORT, SYNC_IMPORT, STATE_CACHE, file-sync sync-data.json.state, file-sync recentOps, SuperSync snapshot upload, plugin persistDataSynced, log export, and privacy export.
  • E2E smoke tests for:
    • Electron desktop: configure provider, reload app, credential still works
    • restore backup on a new device/profile, credential is missing but config metadata remains
    • sync between two upgraded clients without leaking issue-provider secrets
    • configure on client A, sync to client B, and assert no canary token appears in persisted stores, op-log files, or server/file-sync snapshots

Deferred tests:

  • Electron tests for safeStorage unavailable and Linux basic_text.
  • Android tests for KeyStore failure behavior and backup exclusion metadata.
  • Portable vault tests for unlock, rewrap, rollback/tombstone behavior, and export/import.

Implementation Sketch

V1 likely new files:

  • src/app/core/secret-storage/local-secret-store.model.ts
  • src/app/core/secret-storage/local-secret-store.service.ts
  • src/app/core/secret-storage/secret-registry.ts
  • src/app/core/secret-storage/secret-migration.service.ts
  • src/app/core/secret-storage/redact-secrets.ts

V1 likely changed areas:

  • issue provider config forms and API service resolution
  • issue provider action creation before persistent dispatch
  • plugin config service, plugin bridge, and persistDataSynced
  • backup import, sync hydration, snapshot download, state-cache writes, and privacy/log export

Deferred likely files/areas:

  • src/app/core/secret-storage/portable-vault.service.ts
  • electron/ipc-handlers/local-secret-store.ts
  • android/app/src/main/java/com/superproductivity/superproductivity/service/LocalSecretStore.kt
  • Android backup exclusion resources for legacy background-sync prefs and new local secret-store files
  • sync provider private config loading/saving
  • plugin OAuth token store
  • Android backup rules and background sync credential bridge

Decisions Required Before V1a

  • How long should legacy plaintext read compatibility remain?
  • Should browser/PWA and Android/iOS show session-only secret retention or mark persistent secret storage unavailable?

Required Before V1b Synced-Secret Migration

  • Define an explicit schemaVersion or minClientVersion signal proving that supported clients preserve SecretRef values.
  • What user-facing upgrade warning is acceptable before moving synced integration credentials to device-local storage?

Decisions Deferred To Post-V1

  • Does SuperSync retention/compaction currently guarantee old raw secret payloads disappear after migration?
  • What minimum sync E2EE passphrase-strength rule is required before enabling the low-friction portable vault?
  • How will sync E2EE passphrase/key rotation rewrap existing portable vault keys?
  • Should portable vault backup/export be part of normal backup or a separate explicit export?

References