docs/long-term-plans/secure-storage.md
Status: planned (revised 2026-07-03)
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.
Revision note (2026-07-03): reconciled with shipped work (plugin secret
storage API #8633, setup-time E2EE offers #8709, E2EE-mandatory SuperSync
uploads GHSA-9v8x, issue-provider-to-plugin migrations). Headline changes:
the sync-E2EE-wrapped portable vault moves into V1b so E2EE users never
re-enter credentials; the blocking compatibility warning becomes a silent
dual-write gate; the local profile store covers all platforms; recovery keys,
device pairing, the vault DEK/manifest machinery, and speculative capability
modes are cut; SecretAccessContext is reframed honestly as misuse
prevention, not a security boundary; Electron main-process logs join the
redaction/canary surface.
Before weighing tradeoffs, be precise about the surface:
encryptKey are already
device-local (sup-sync IndexedDB, never synced). Users already re-enter
these once per device today.sup-plugin-oauth) and plugin setSecret values
(sup-plugin-secrets, shipped in #8633) are already device-local.type: 'password' config fields of issue-provider plugins
(GitHub, ClickUp, Gitea, Linear, Trello, Azure DevOps). A typical user has
0–3 of these configured.So the "everything arrives through sync" experience this plan disturbs is limited to issue-provider and plugin credentials, and for the dominant E2EE cohort the portable vault (V1b) preserves it entirely.
Moving secrets out of synced state trades a small, targeted UX cost for keeping raw secrets out of synced state, op-log operations, snapshots, backups, plugin synced data, and logs.
With the V1b portable vault, the cost lands only on users without sync E2EE:
UX mitigation for the device-local cases:
Every vaulted or device-local secret is recoverable by reauthenticating with the third-party service, so the worst case of any vault/storage loss is the device-local baseline, never data loss.
For users who sync without E2EE, do not prompt and do not silently migrate:
SecretRef values (never
raw into synced state), so the plaintext surface stops growing.What each tier actually buys — write user-facing and internal docs to these claims, never stronger ones:
indexedDbProfile): local isolation only.
Secrets leave synced state, backups, exports, and logs. Anyone with disk
access to the profile — or any code running in the app origin, including
plugins — can still read them, exactly like the existing sup-sync,
sup-plugin-oauth, and sup-plugin-secrets stores today. This is not an
at-rest encryption claim.src/app/plugins/plugin-runner.ts, iframe plugins are
allow-same-origin), so IPC calls are indistinguishable by caller. A real
plugin boundary requires the separate plugin process/origin isolation work
plus main-process enforcement keyed to the isolated caller; this plan
should not claim it.SyncCredentialStore stores private provider config plaintext in the
sup-sync IndexedDB database. Local-only, never synced.password + optional bearer accessToken,
Dropbox accessToken + refreshToken, SuperSync accessToken +
refreshToken, and encryptKey on all providers (incl. local file).encryptKey length only, never the
value.Relevant files:
src/app/op-log/sync-providers/credential-store.service.tssrc/app/op-log/core/types/sync.types.tspackages/sync-providers/src/super-sync/super-sync.model.tspackages/sync-providers/src/file-based/webdav/webdav.model.tspackages/sync-providers/src/file-based/dropbox/dropbox.tsBackgroundSyncCredentialStore uses EncryptedSharedPreferences but falls
back to standard plaintext SharedPreferences if encrypted preferences
fail.android:allowBackup="true" is set, and backup rule files
(data_extraction_rules.xml, backup_rules.xml) already exist — but they
do not exclude the SuperProductivitySync preferences file, so the
(encrypted or fallback-plaintext) token store is currently backed up. The
fix is one <exclude> entry per rules file, not new infrastructure — see
"Quick Wins".Relevant files:
android/app/src/main/java/com/superproductivity/superproductivity/service/BackgroundSyncCredentialStore.ktsrc/app/features/android/store/android-sync-bridge.effects.tsandroid/app/src/main/AndroidManifest.xmlissueProvider NgRx state,
which is part of the op-log model config, snapshots, sync data, and
backups.passwordtokenpasswordtokenapi_keypasswordtokenRelevant files:
src/app/features/issue/issue.model.tssrc/app/features/issue/store/issue-provider.reducer.tssrc/app/op-log/model/model-config.tssrc/app/op-log/backup/state-snapshot.service.tsThree distinct stores exist today:
type: 'password' fields (e.g. GitHub token, ClickUp apiKey)
that are stored as regular values in synced pluginUserData via
PluginUserPersistenceService. This is the plugin-side twin of the
built-in issue-provider leak.setSecret/getSecret/deleteSecret on the plugin API, backed by the
dedicated sup-plugin-secrets IndexedDB. Local-only, plaintext at rest,
namespaced per plugin, purged on plugin uninstall and plugin
cache-clear. This is the canonical plugin-facing secret store; this plan
builds on it rather than adding a parallel one.sup-plugin-oauth IndexedDB,
local-only, plaintext, purged on uninstall/cache-clear.Relevant files:
src/app/plugins/secret/plugin-secret-store.tssrc/app/plugins/secret/plugin-secret.service.tssrc/app/plugins/oauth/plugin-oauth-token-store.tssrc/app/plugins/plugin-user-persistence.service.tssrc/app/plugins/plugin-config.service.tselectron/jira.ts receives the full Jira config (including password)
over IPC and logs raw error responses to disk via electron-log.Authorization headers.Relevant files:
packages/sync-core/src/encryption* already ships Argon2id KDF, AES-256-GCM
(WebCrypto with @noble/ciphers fallback), versioned KDF parameters, and a
session key cache. HKDF is available natively via WebCrypto. The portable
vault needs no new crypto dependency.src/app/imex/file-imex/privacy-export.ts already masks password,
token, apiKey, secret, authorization, accessToken, authCode,
api_key — but misses refreshToken, clientSecret/client_secret,
encryptKey, apiToken, and is exact-key case-sensitive. See "Quick
Wins".PluginAPI.persistDataSynced already logs only key length, never payloads,
with a spec enforcing it.src/app/plugins/util/plugin-persistence-key.util.ts (composeId) is the
reference implementation for delimiter-safe composite ids.Each is small, has no schema or UX impact, and closes a real hole:
<exclude> entries for the SuperProductivitySync preferences file
to data_extraction_rules.xml and backup_rules.xml (KeyStore keys do
not survive restore anyway, so backed-up ciphertext is dead weight at
best and a plaintext-fallback leak at worst).electron/jira.ts; log status +
redacted metadata only.refreshToken, clientSecret,
client_secret, encryptKey, apiToken; make matching
case-insensitive.Two concepts:
LocalSecretStore: device-local secret storage. V1 uses a dedicated
local-only IndexedDB (indexedDbProfile) on all platforms; native
OS-backed backends replace the storage implementation post-V1 behind the
same interface.PortableVault: synced, vault-encrypted secret records for sync-E2EE
users, carried as ordinary op-log entities.SecretRef is metadata. Only SecretRef and non-sensitive metadata may be
stored in NgRx state, op-log operations, snapshots, backups, and plugin
synced data; secret values live behind LocalSecretStore or PortableVault.
export interface SecretRef {
kind: 'SecretRef';
version: 1;
id: string; // delimiter-safe composite, see "Slot ids"
ownerType:
| 'syncProvider'
| 'issueProvider'
| 'pluginConfig'
| 'pluginOAuth'
| 'nativeBackground';
ownerId: string;
field: string;
storageMode: 'device' | 'portableEncrypted';
updatedAt: number;
}
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 {
// extend these unions only when a backend actually ships (YAGNI)
mode: 'localProfile' | 'unavailable';
backend: 'indexedDbProfile';
canPersistDeviceSecrets: boolean;
canUsePortableVault: boolean;
}
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>;
}
What SecretAccessContext is — and is not: it is a misuse-prevention
assertion that catches accidental cross-owner reads and wrong-wiring bugs
(wrong owner type/id/field is rejected; the host maps plugin-owned refs by
callerId === ownerId). It is not a security boundary: the context is a
caller-supplied object in a single JS realm, and plugins execute in the host
renderer, so a malicious plugin can forge an app context or open the
IndexedDB directly. Tests for it are API-contract tests, not security tests.
A real caller boundary arrives only with plugin process/origin isolation plus
main-process enforcement, and no release note may claim otherwise.
Synced config must not contain per-device random secret ids. Use a
deterministic slot id from stable metadata so two devices migrating the same
provider mint the same SecretRef and LWW cannot orphan either side.
composeId in
plugin-persistence-key.util.ts); plugin ids and schema field names are
third-party-controlled strings, so naive v1:${ownerType}:${ownerId}:${field}
joining is ambiguous (("a","b:c") vs ("a:b","c")).ownerType against the closed enum.SecretRef and the local/vault value; replacing a
secret value on one device must not invalidate another device's local
entry while the integration remains configured (value replacement updates
the store, not synced metadata).device (default for non-E2EE sync and all non-synced secrets):
LocalSecretStore.portableEncrypted (default when sync E2EE is enabled):
encryptKey stay
device-local so vault unlock never depends on itself.Deliberately minimal — the vault holds a handful of sub-kilobyte records, so it needs none of the DEK/manifest/epoch machinery of a general vault:
vaultKey = HKDF-SHA-256(syncE2EEKey, salt = per-vault random salt, info = 'super-productivity-portable-vault-v1'). The salt is random,
minted at vault creation, and stored as plaintext metadata in the synced
vault config record (salts are not secret). Never use the sync content
key directly. If the E2EE input is a passphrase, it already passes through
the existing Argon2id KDF (same implementation and parameter-versioning as
sync E2EE — no PBKDF2 fork, no second KDF to maintain).vaultKey
with a fresh CSPRNG nonce on every encryption (including updates —
multiple devices encrypt under the same key, so nonces must never be
counter- or metadata-derived) and AAD binding
{recordId, ownerType, ownerId, field, schemaVersion, updatedAt}.vaultKey whenever the sync E2EE key is available (the
existing session cache makes this free). Optionally persist a wrapped copy
in LocalSecretStore for access before sync unlock; never persist the
plaintext key.vaultKey
(new salt) and re-encrypts all records — trivial at this record count, and
it is true rotation: old ciphertext in retained sync history becomes
undecryptable under material derived from the old passphrase only if the
attacker never had it. Be explicit in docs: rotating after a suspected
passphrase compromise protects future records, but anything the attacker
could already decrypt (including old history) must be treated as exposed —
the honest remedy is rotating the third-party tokens themselves, and the
UI should say so.Cut from earlier drafts (reauth-with-upstream covers recovery, and the
"not a password manager" non-goal applies to the plan itself): vaultDek
indirection, authenticated manifests, vault epochs, wrapper sets, grace-period
rewrap, recoveryKey, devicePairing, passkey escrow, vault export/import.
One backend everywhere: a dedicated local-only IndexedDB for secret values, separate from synced model data, on Electron, browser/PWA, and Android/iOS Capacitor alike.
Rationale: sup-sync already stores WebDAV passwords and encryptKey in
plaintext IndexedDB on every platform, so refusing the same tier for issue
tokens on mobile/web would protect nothing while making V1b a mobile
showstopper (integrations dying or demanding re-entry every session). A
uniform backend also deletes the session-only/unavailable UX states from V1
entirely. On web, IndexedDB eviction risk equals that of the app data
itself — no worse.
SecretRef metadata in synced app state; values in the local
DB.LocalSecretStore interface so native backends can replace the
storage implementation later without a second data-model migration.pluginConfig-owned values, back the store with the existing
sup-plugin-secrets database (host-reserved key namespace) instead of a
second plugin-secret surface — its per-plugin purge on uninstall and
cache-clear then applies automatically. Decision recorded below.safeStorageMain-process IPC as the only bridge; buys at-rest OS-keychain protection and keeps secrets out of the renderer-readable profile DB. It does not buy plugin separation (see Honest Threat Model).
electron/ipc-handlers/local-secret-store.ts, registered in
electron/ipc-handler.ts; narrow preload methods
(localSecretStoreSet/Resolve/Delete/Capabilities) in
electron/preload.ts + electron/electronAPI.d.ts.safeStorage.encryptString()/decryptString() in the main process;
encrypted blobs in a small file/db under app.getPath('userData').basic_text backend must not be a silent plaintext-equivalent
fallback: require explicit degraded consent or offer session-only. On
upgrade with legacy plaintext credentials present, show an explicit choice
rather than deleting silently.LocalSecretStore backed by an AES-GCM key in AndroidKeyStore;
ciphertext in private app storage. No plaintext SharedPreferences
fallback — "store encrypted" or "do not persist".BackgroundSyncCredentialStore plaintext fallback.kSecAttrSynchronizable=false; whenUnlockedThisDeviceOnly, or
afterFirstUnlockThisDeviceOnly only where background tasks require it);
explicit access group.indexedDbProfile tier as everywhere else (local
isolation, honestly labeled).sessionStorage, localStorage, window.name,
or BroadcastChannel (browsers persist sessionStorage to disk for
session restore). Add a canary test for those sinks.Secret fields use an initially empty control with a per-device hint ("credential saved on this device" / "missing on this device"):
unchanged (dirty-state tracking gives the
"typed then reverted" collapse for free);replace (value goes to the vault/local store; only the new
SecretRef is dispatched);clear (store entry and synced ref removed).No masked sentinel (********) ever exists in the model, so no sentinel
rejection layer is needed. The form model must never emit a secret value to
NgRx; the vault/local-store write happens before any persistent action is
dispatched.
Services resolve credentials as late as possible:
SecretRef values through LocalSecretStore /
PortableVault with a SecretAccessContext.type: 'password' are intercepted by the
host: synced plugin config stores SecretRef values; the actual values
live in the sup-plugin-secrets-backed store (device mode) or the
portable vault (E2EE mode).PluginAPI.getConfig() returns config metadata and SecretRef values,
never resolved secrets. Resolution goes through a narrow
PluginAPI.useSecret(ref, fn) where the host asserts
callerId === ownerId (misuse prevention; see Honest Threat Model for
what this does not claim).setSecret/getSecret/deleteSecret API stays as-is for
plugin-managed secrets; schema-password interception is the host-managed
complement, sharing the same store and purge lifecycle.persistDataSynced remains non-secret storage: payload logging is already
removed and spec-enforced; add registry canaries so registered secret
values are rejected in tests.Provider private config in sup-sync (passwords, tokens, encryptKey) does
not change in V1 — it is already local-only and does not drive the synced
leak risk. Post-V1, move it behind the OS-backed LocalSecretStore backends.
V1 still registers all these fields for redaction and canary checks so they
never newly appear in op-log payloads, snapshots, backups, logs, or exports.
password fields, plugin OAuth token records, plugin
setSecret values.redactSecrets(value) used by log recording, log
export, privacy export, crash/error additional data, plugin/config payload
logging, and the Electron main process (electron-log writes, forwarded
renderer errors, electron/jira.ts).apiKey, api_key, apiToken, refreshToken,
clientSecret, client_secret, encryptKey, authorization, case
variants, and nested plugin config password fields.AppDataComplete 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 /
BACKUP_IMPORT replacement semantics explicitly: block concurrent secret
writes during import/hydration, rerun the sanitizer, re-emit deterministic
SecretRef metadata if an import replaced it (refs must not be lost while
local entries remain orphaned).LocalSecretStore with the indexedDbProfile backend on all platforms.SecretRef
values without overwriting them.Migrates built-in issue provider secrets, plugin schema password fields,
and legacy migrated GitHub/ClickUp config; ships the portable vault in the
same release so E2EE users never re-enter credentials and no second
schema/compat event is needed later.
Transition = silent dual-write, no blocking dialogs:
SecretRef (+ vault/local value) and the
legacy raw field. Sync-visible security is unchanged during transition
(the raw field was already there); UX cost is zero.Compatibility gate, split by transport (an old client cannot emit the new signal, so absence of a signal is never proof — hence dual-write + sanitize rather than trust):
minClientVersion on op upload — the only hard
arbiter. Do not rely on vector-clock entries (bounded, prunable).Migration mechanics:
sup-sync private config, sup-plugin-oauth, sup-plugin-secrets values,
and the Android background-sync mirror move behind OS-backed backends in the
post-V1 platform-hardening phase (same migration flow: marker → store write →
replace/remove legacy → clear plaintext only after success).
Old local op logs, remote history, snapshots, and backups may contain previously stored secrets. V1 prevents new leaks and warns; cleanup is a follow-up:
OPS, STATE_CACHE current/backup, IMPORT_BACKUP,
PROFILE_DATA, file-sync sync-data.json.state + recentOps, and remote
SuperSync snapshots/ops where supported; compact local op logs after the
gate clears; force-upload stripped snapshots for file-based sync.| Scenario | Handling |
|---|---|
| Device-local entry missing | "Credential missing on this device" + direct re-enter/reauth action |
| Local profile store unavailable | Mark affected integration missing/read-only on this device; never write raw fallback state |
| Corrupted local entry | Do not delete the SecretRef automatically; offer reconnect/replace and diagnostic export without the secret |
| Migration fails mid-way | Keep 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 sync | Stop sync and ask for the credential; do not overwrite or disable config |
| Raw secret arrives post-strip | Sanitize into store/vault; one-time non-blocking rotation hint; never block sync |
| Vault record fails AAD/decrypt | Treat as missing (not corrupted config); offer re-enter; log a redacted diagnostic |
| Post-V1 OS-backed storage unavailable | Session-only (in-memory) or explicit degraded mode; Linux basic_text requires explicit consent, never a silent plaintext-equivalent |
SecretRef + provider metadata for
device-local secrets, and portable-vault ciphertext for vaulted secrets
(same offline exposure as the vault itself) — never plaintext values.V1a guardrail invariants:
BACKUP_IMPORT, SYNC_IMPORT, state cache, hydration payloads, plugin
synced data, persistDataSynced payloads, renderer logs, main-process
logs, error additional data, privacy export, or log export for
registry-covered paths.encryptKey, sync tokens, plugin OAuth,
plugin setSecret values) stay in their existing stores, with
registry/canary coverage proving no new leak paths.V1b invariants:
SecretRef plus the local profile DB from another device resolves
nothing; this is an isolation claim, not an at-rest encryption claim.vaultKey material is never synced or persisted unwrapped; it
exists in memory (and the existing E2EE session cache) only.V1 tests:
indexedDbProfile LocalSecretStore unit tests with canary values, on
the web/Capacitor build targets too (single backend everywhere).SecretAccessContext (wrong owner type/id/field
denied; plugin refs resolve only for callerId === ownerId) — labeled as
contract tests, not security tests.SecretRef values; older-client raw/empty
overwrite of refs is detected and repaired from the local store.OPS,
BACKUP_IMPORT, SYNC_IMPORT, STATE_CACHE, file-sync
sync-data.json.state + recentOps, SuperSync snapshot upload, plugin
persistDataSynced, log export, privacy export, electron-log output
(main + forwarded renderer errors), and the in-memory-only rule for any
session-mode (no sessionStorage/localStorage/window.name sinks).apiKey, api_key,
apiToken, refreshToken, authorization, clientSecret,
client_secret, encryptKey, plugin password fields).Deferred tests: Electron safeStorage unavailable / Linux basic_text;
Android KeyStore failure + backup-exclusion metadata; iOS keychain
survive-uninstall handling.
V1 likely new files:
src/app/core/secret-storage/local-secret-store.model.tssrc/app/core/secret-storage/local-secret-store.service.tssrc/app/core/secret-storage/secret-registry.tssrc/app/core/secret-storage/secret-migration.service.tssrc/app/core/secret-storage/redact-secrets.ts (shared with electron/)src/app/core/secret-storage/portable-vault.service.ts (V1b)V1 likely changed areas:
useSecret), schema-password
interception into the sup-plugin-secrets-backed storeelectron/jira.ts, main-process log wiring (redaction)Deferred: electron/ipc-handlers/local-secret-store.ts, Android/iOS native
stores + backup rules, sync provider private config re-homing, plugin OAuth
store hardening.
pluginConfig-owned host secrets with the existing
sup-plugin-secrets DB (recommended — inherits purge lifecycle) vs. a
separate namespace in the new store.indexedDbProfile on Electron in favor of going straight
to the main-process safeStorage backend (web/mobile keep
indexedDbProfile either way). Recommended default: uniform
indexedDbProfile first — one backend, one migration path, and the
interface swap to safeStorage later is internal to LocalSecretStore.minClientVersion upload rejection.safeStorage: https://www.electronjs.org/docs/latest/api/safe-storageEncryptedSharedPreferences reference: https://developer.android.com/reference/androidx/security/crypto/EncryptedSharedPreferencessrc/app/plugins/secret/packages/sync-core/src/encryption*