wiseguru_ReWrite-Voice-Notes/docs/SECRETS.md
2026-06-18 08:13:53 -07:00

11 KiB

Secrets encryption

Extracted from CLAUDE.md. Subject to the same maintenance rule: when you change secrets/encryption behavior, update this file in the same change, and keep the summary in CLAUDE.md accurate.

src/secrets.ts supports two encryption modes for API keys, file-wide (not per-key). There is no unencrypted at-rest option.

  • secretStorage — Obsidian's first-party app.secretStorage (GA 1.11.4): a shared, cross-plugin, OS-encrypted store (Keychain/DPAPI/libsecret under the hood, the same backend Electron safeStorage used). Default/preferred when available. Values live in Obsidian's store, NOT in secrets.json.nosync (the on-disk envelope in this mode is just { version, mode: 'secretStorage', keys: {} } to record the mode). Keys are namespaced with manifest.id (rewrite-voice-notes:profile:desktop:transcription) because the store is shared across plugins. The API is reached via a narrow SecretStorageLike interface + cast (the installed typings predate it) that normalizes getSecret/get, setSecret/set, listSecrets/list, and deleteSecret/removeSecret/delete (falling back to setSecret(id, '') when no delete exists); each method may be sync or async, so callers await. Availability is a round-trip self-test (setSecret a sentinel, getSecret, compare, clean up) cached for the session — a device with no working OS secret store (e.g. Linux without a keyring) reads unavailable and falls back to passphrase. warmSecretStorage(plugin) runs the probe once in onload BEFORE getEncryptionStatus, so the synchronous defaultEnvelope() can prefer secretStorage on first run. Sync caveat: because the store can sync across devices via Obsidian Sync, keys in this mode are not bound to one device (unlike the .nosync file); this is surfaced in the settings mode description.
  • passphrase — WebCrypto AES-GCM-256 with a key derived from a user-supplied passphrase. The KDF is Argon2id by default (via hash-wasm, m = 32 MiB, t = 3, p = 1, 32-byte output), with PBKDF2-SHA256 (600,000 iterations) as the fallback when a device can't run Argon2id. The chosen algorithm + params live in the envelope kdf (see below). Each value is stored as <iv-b64>.<ct-b64> (12-byte random IV per value) in secrets.json.nosync. A verifier field stores an encryption of VERIFIER_PLAINTEXT so unlock can validate the passphrase without decrypting user keys. Works on every platform including mobile and Linux-without-keyring; the always-available fallback when secretStorage is not. Entropy gate: changeEncryptionMode/changePassphrase reject a passphrase scoring below MIN_PASSPHRASE_SCORE (3 of 4) via src/passphrase-strength.ts (zxcvbn-ts wrapper); this is the hard, non-bypassable gate. The passphrase modal (src/ui/passphrase-modal.ts) surfaces a live strength meter and a Generate button (6-word EFF-wordlist diceware via src/diceware.ts + src/eff-large-wordlist.ts) on create/change flows (enforceStrength: true). zxcvbn-ts is lazy-loaded: its dictionaries are ~1.6 MB / ~140 ms to build, so src/passphrase-strength.ts pulls the packages via dynamic import() (not a static top-level import), keeping them out of plugin-startup evaluation; evaluatePassphrase/isPassphraseAcceptable are therefore async, and the modal calls warmPassphraseStrength() on open so the first keystroke does not pay the build cost. The bytes stay in main.js (esbuild has no code-splitting here); only the parse/construct work is deferred.

History note: an earlier hand-rolled safeStorage mode (direct Electron keychain) was REMOVED and superseded by secretStorage, which uses the same OS backend but is Obsidian-managed and cross-platform. No users had it (unpublished), so it was dropped outright with no migration; a stale safeStorage envelope parses as invalid and resets to default.

minAppVersion is 1.4.4, raised from 1.4.0 because FileManager.processFrontMatter (@since 1.4.4, used directly in src/insert.ts for note-property frontmatter) is NOT feature-detected. secretStorage (the other newer API) IS feature-detected at runtime, so it does not drive the minimum; older-than-secretStorage Obsidian still loads the plugin and simply lands on passphrase.

File envelope (SECRETS_VERSION = 2). For passphrase mode, the kdf.algo discriminant distinguishes PBKDF2 from Argon2id; a legacy envelope with no algo but an iterations field is read as pbkdf2:

{ "version": 2, "mode": "passphrase",
  "kdf": { "algo": "argon2id", "salt": "<b64>", "memKiB": 32768, "timeCost": 3, "parallelism": 1 },
  "verifier": "<iv-b64>.<ct-b64>",
  "keys": { "profile:desktop:transcription": "<iv-b64>.<ct-b64>", ... } }

The derived AES-GCM key for passphrase mode lives in module-level state (unlockedKey); it never touches disk. lockSecrets() forgets it. unlockSecrets(plugin, passphrase) derives a candidate key via deriveKeyFromKdf (dispatching on kdf.algo) and decrypts the verifier to check correctness before caching. An Argon2id allocation failure at unlock throws a clear "this device can't allocate enough memory" error (the envelope was created on a device with more RAM). Opportunistic migration: on a successful unlock of a pbkdf2 envelope, if the device can run Argon2id the keys are silently re-encrypted to argon2id (best-effort; failures leave it on PBKDF2). This is a deliberate exception to the pre-release no-migration rule, kept so existing passphrase users aren't forced to re-enter keys. secretStorage mode never locks and has no KDF/verifier.

ReWritePlugin.encryptionStatus (a snapshot of { mode, locked, configured, secretStorageAvailable }) is loaded on onload and refreshed via plugin.refreshEncryptionStatus() after every mode change / unlock. UI code reads this synchronously. locked and configured only vary in passphrase mode (secretStorage is always unlocked + configured); configured is false for a passphrase envelope with no kdf/verifier yet (first run on a device without secret storage); secretStorageAvailable is the cached probe result, used to offer the mode and to steer the user when it is missing.

When mode === 'passphrase' and not yet unlocked (encryptionStatus.locked === true, which includes the unconfigured first-run state):

  • loadAllKeys / loadKey return empty strings (no error).
  • saveManyKeys is a no-op (so calls to saveSettings() from unrelated UI changes do not clobber the on-disk encrypted values with empties).
  • saveKey throws.
  • All entry points (src/ui/modal.ts, src/ui/quick-record.ts, src/ui/text-source.ts, src/ui/audio-source.ts) check plugin.encryptionStatus.locked and call plugin.promptUnlock() instead of proceeding. promptUnlock branches on configured: a configured envelope opens the unlock modal; an unconfigured one opens a create-passphrase modal (requireConfirm + enforceStrength) that calls changeEncryptionMode(this, 'passphrase', pass). The entry points need no change since unconfigured reports locked === true.
  • The settings tab disables the API key input fields and shows a banner: "Unlock" (configured+locked) or "Set passphrase" (unconfigured).

changeEncryptionMode(plugin, newMode, newPassphrase?) reads all keys to plaintext from the current mode (via readAllPlain, which lists app.secretStorage for secretStorage or decrypts the envelope for passphrase), switches, and re-stores them. Requires the current mode to be unlocked (if passphrase AND already configured). Switching TO secretStorage requires the probe to pass, then setSecrets each value and writes a minimal envelope. Switching TO passphrase requires newPassphrase (entropy-gated, new envelope built by buildPassphraseKdfAndKey) and, when coming FROM secretStorage, clears our namespaced entries from the shared store (clearSecretStorage) so keys don't linger/sync. changePassphrase(plugin, newPassphrase) is a thin wrapper that calls changeEncryptionMode(plugin, 'passphrase', newPassphrase).

resetSecrets(plugin, newPassphrase) is the forgot-passphrase recovery path. Unlike changePassphrase, it does NOT require unlocking first: it drops unlockedKey / cachedEnvelope and writes a fresh, empty passphrase envelope under the new passphrase (the old keys are unrecoverable without the old passphrase, so they are discarded by design). The new passphrase must pass the same entropy gate. It is exposed in the settings tab only in the locked state (renderEncryption's status.locked banner branch shows a mod-warning "Forgot passphrase? Reset" button beside Unlock; openResetModal runs the flow, then hydrateSecrets to empty the in-memory keys, refreshEncryptionStatus, and notifySecretsUnlocked). An unlocked user uses Change passphrase instead, so there is no reset row there. The reset modal is a PassphraseModal with requirePhrase: 'DELETE APIS' (a destructive-confirmation gate: submit() blocks until a plain-text field exactly matches the phrase) plus the usual requireConfirm + enforceStrength.

Gotchas

  • app.secretStorage is reached via a narrow cast + round-trip self-test, never assumed present. src/secrets.ts getSecretStorage(plugin) casts plugin.app to a local RawSecretStorage shape and normalizes the method aliases; probeSecretStorage write/read/compares a sentinel before trusting it. Importing nothing Electron-specific keeps it mobile-safe. Old Obsidian (no API) and a broken OS store (Linux without a keyring) both read unavailable and fall back to passphrase. The probe result is module-cached and warmed in onload via warmSecretStorage so the synchronous defaultEnvelope() can prefer it on first run. Keys are namespaced with manifest.id; never store under a bare id (the store is shared across plugins).
  • saveManyKeys is a silent no-op when locked. When mode === 'passphrase' and unlockedKey === null, saveManyKeys does nothing. This is deliberate: unrelated plugin.saveSettings() calls (e.g. user changes a model dropdown) would otherwise persist empty apiKey values for every profile, wiping the on-disk encrypted bag. The UI prevents this by disabling key fields and gating all pipeline entry points on encryptionStatus.locked. saveKey (single-key write) still throws so callers can react.
  • Keep zxcvbn lazy and the strength API async. src/passphrase-strength.ts pulls @zxcvbn-ts/* via dynamic import() specifically so the ~1.6 MB of dictionaries are not parsed/constructed at plugin load (measured: ~9 ms startup vs ~47 ms with a static import). Do not "simplify" it back to a top-level static import or a synchronous evaluatePassphrase — that re-adds the cost to every Obsidian launch on every device. The async ripples (modal updateStrength race guard, await isPassphraseAcceptable in changeEncryptionMode) are intentional. warmPassphraseStrength() is fired on passphrase-modal open to hide the one-time build behind the modal animation.
  • secrets.json.nosync uses the .nosync suffix on purpose: iCloud Drive natively skips any file or folder whose name ends in .nosync. The README documents per-tool sync exclusion for other tools.