docs/long-term-plans/secure-storage.md
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.
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:
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:
Recommended default:
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:
Recommended behavior:
SecretRef metadata
in normal app state.vaultDek.vaultDek with a vault wrapping key derived from existing sync E2EE
material:
info string such as
super-productivity-portable-vault-v1.vaultDek locally so already-configured
devices unlock silently. Keep the plaintext vaultDek only in memory while
the vault is unlocked.User experience:
Security improvement:
Limitations:
E2EE-disabled fallback:
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:
indexedDbProfile LocalSecretStore backend.SecretRef
values without overwriting them.persistDataSynced marked and tested as non-secret storage.V1b - synced-secret migration:
SecretRef plus local secret
values.password fields stored as SecretRef plus local secret
values.SecretRef
values before raw synced credentials are removed.V1 explicitly defers:
encryptKey
migration.safeStorage, Android Keystore, and iOS Keychain backends.V1 storage capability matrix:
| Platform | V1 persistent local secret store | Notes |
|---|---|---|
| Electron desktop | Yes, indexedDbProfile | Local-isolation tier only; not OS-backed at-rest protection |
| Browser/PWA | No | Use session-only or unavailable mode in V1 |
| Android/iOS Capacitor | No | Use 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 storage:
SyncCredentialStore stores private provider config in the sup-sync
IndexedDB database.encryptKey.Relevant files:
src/app/op-log/sync-providers/credential-store.service.tssrc/app/op-log/core/types/sync.types.tssrc/app/op-log/sync-providers/super-sync/super-sync.model.tssrc/app/op-log/sync-providers/file-based/webdav/webdav.model.tssrc/app/op-log/sync-providers/file-based/dropbox/dropbox.tsCurrent storage:
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:
android/app/src/main/java/com/superproductivity/superproductivity/service/BackgroundSyncCredentialStore.ktsrc/app/features/android/store/android-sync-bridge.effects.tsandroid/app/src/main/AndroidManifest.xmlCurrent storage:
issueProvider NgRx state.issueProvider is part of the op-log model config, snapshots, sync data, and
backups.passwordtokenpasswordtokentokenapi_keyapiKey, tokenapiKeytokenpasswordRelevant 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.tsCurrent storage:
sup-plugin-oauth IndexedDB
database, but plaintext.PluginUserPersistenceService, which is part of
synced pluginUserData.Relevant files:
src/app/plugins/oauth/plugin-oauth-token-store.tssrc/app/plugins/plugin-user-persistence.service.tssrc/app/plugins/plugin-config.service.tssrc/app/features/issue/dialog-edit-issue-provider/dialog-edit-issue-provider.component.tsIntroduce 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.
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.
device:
v1:${ownerType}:${ownerId}:${field} with plugin id included in ownerId for
plugin-owned secrets.SecretRef slot exists. It must not update synced metadata such as
updatedAt merely because the local secret value changed.SecretRef; clearing or replacing
a secret on one device must not make another device's local secret missing if
the integration remains configured.portableEncrypted:
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.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:
Recommended key structure:
vaultDek when the user enables the portable vault.vaultDek, a unique nonce, and authenticated data containing record id,
owner, field, and schema version.SecretRef metadata, the vault manifest, encrypted secret records,
and wrapped copies of vaultDek.SecretRef.updatedAt rollback.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:
SecretRef metadata plus vault ciphertext.vaultDek and stores an OS-protected wrapped copy locally.PortableVault and
LocalSecretStore without storing the plaintext in synced state.Server role:
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.
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:
SecretRef metadata in synced app state.LocalSecretStore API so native backends can replace the
storage implementation later.Use Electron main-process IPC as the only bridge to the vault.
Implementation sketch:
electron/ipc-handlers/local-secret-store.ts.electron/ipc-handler.ts.electron/preload.ts and
electron/electronAPI.d.ts:
localSecretStoreSetlocalSecretStoreResolvelocalSecretStoreDeletelocalSecretStoreCapabilitiessafeStorage.encryptString() and safeStorage.decryptString() in the
main process.app.getPath('userData').basic_text backend. That backend must not be a
silent plaintext-equivalent fallback.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.Use a native Capacitor/JavaScript bridge backed by Android Keystore.
Implementation sketch:
LocalSecretStore.AndroidKeyStore.SharedPreferences.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.BackgroundSyncCredentialStore fallback with either
"store encrypted" or "do not persist".Use Keychain Services through a native Capacitor bridge.
kSecAttrSynchronizable=false for device-local secrets.whenUnlockedThisDeviceOnly for secrets that do not need background access.afterFirstUnlockThisDeviceOnly only where background tasks require access.The browser build cannot offer OS-level secret storage through standard web APIs.
Recommended behavior:
CryptoKey material where available and make the degraded
security model explicit.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.
Services that need credentials resolve them as late as possible:
SecretRef values through LocalSecretStore or
PortableVault with a SecretAccessContext.Plugin JSON schema fields with type: "password" should be intercepted by the
host app.
SecretRef values instead of raw secret strings.PluginAPI.getConfig() should return config metadata and SecretRef values,
not resolved secret strings.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.persistSecret, loadSecret, and
deleteSecret. Initially support schema password fields and
PluginAPI.useSecret(ref, fn) only.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:
password, optional bearer accessToken, encryptKeyaccessToken, refreshToken, encryptKeyaccessToken, refreshToken, encryptKeyencryptKeyNon-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:
SecretRefs.password fieldsAppDataComplete secret migration/sanitizer used by
backup import, remote sync hydration, file-sync snapshot download, full-state
tail-op hydration, state-cache writes, and loadAllData.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.redactSecrets(value) used by log recording,
log export, privacy export, crash/error additional data, and plugin/config
payload logging.apiKey, api_key, clientSecret,
client_secret, authorization, case variants, and nested plugin config
password fields.persistDataSynced as non-secret storage and remove payload logging.LocalSecretStore interface.indexedDbProfile backend first for Electron desktop only.localProfile,
sessionOnly, and unavailable storage in the first release.safeStorage, Android Keystore, iOS Keychain, Android backup
exclusions, and Linux basic_text handling to post-V1 platform hardening.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.
sup-sync.sup-plugin-oauth.Migration flow:
LocalSecretStore.SecretRef or remove it if the owning config
can derive the ref deterministically.After successful migration:
Migrate high-risk synced secrets next:
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.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.First-release scope:
V1b. Migrate synced integration secrets into
device-local storage. Every device must reconnect integrations once.(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:
Legacy GitHub/ClickUp migration:
LocalSecretStore.token, apiKey, and password-like fields from migrated entities.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:
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.Only after device-local storage and compatibility gates are stable:
safeStorage, Android Keystore, and iOS Keychain backends.vaultDek, not a SuperSync access token or the sync content
encryption key.| Scenario | Handling |
|---|---|
| V1 device-local entry missing | Show "credential missing on this device" and offer reauth/re-enter |
| V1 local profile store unavailable | Mark affected integration missing/read-only on this device; do not write raw fallback state |
| V1 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 credential; do not overwrite or disable config |
| Older client still syncing | Keep affected providers read-only or unmigrated until compatibility gate is satisfied |
| Post-V1 portable vault locked | Show "credential locked" and offer the existing sync E2EE unlock, vault passphrase, recovery key, or device-pairing flow |
| Post-V1 OS-backed storage unavailable | Use session-only storage or explicit passphrase-protected/degraded mode |
Post-V1 Linux basic_text backend | Do not silently persist secrets; require explicit plaintext-equivalent consent or passphrase/session-only alternative |
| Post-V1 sync E2EE key rotation cannot rewrap vault | Keep old wrapper during grace period; if unavailable, require old unlock material or integration reauth |
SecretRef metadata and
provider metadata.V1a guardrail invariants:
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.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.V1b migrated-secret invariants:
password values do not
appear as raw values in NgRx state, action payloads, op-log operations,
complete backup snapshots, plugin synced data, or logs.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.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.Post-V1 invariants:
vaultDek is never synced or persisted; it exists only in memory
while the portable vault is unlocked.V1 tests:
indexedDbProfile LocalSecretStore using canary values.SecretRef authorization boundaries:
unchanged.SecretRef valuesapiKey,
api_key, authorization, clientSecret, and plugin config password fields.encryptKey and sync-token canaries even though their migration is deferred.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.Deferred tests:
safeStorage unavailable and Linux basic_text.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.tsV1 likely changed areas:
persistDataSyncedDeferred likely files/areas:
src/app/core/secret-storage/portable-vault.service.tselectron/ipc-handlers/local-secret-store.tsandroid/app/src/main/java/com/superproductivity/superproductivity/service/LocalSecretStore.ktschemaVersion or minClientVersion signal proving that
supported clients preserve SecretRef values.safeStorage: https://www.electronjs.org/docs/latest/api/safe-storageEncryptedSharedPreferences reference: https://developer.android.com/reference/androidx/security/crypto/EncryptedSharedPreferences