wiseguru_ReWrite-Voice-Notes/docs/SECRETS.md
WiseGuru f94856eae3 1.2.1
Community-review remediation (no behavior change):
- Remove eslint-disable in realtime/pcm.ts; reach the deprecated ScriptProcessor
  API through local type-aliases so no-deprecated never fires.
- Raise minAppVersion to 1.6.6 for FileManager.trashFile (@since 1.6.6).
- Enable the reviewer's type-checked lint rules locally for src/.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 22:35:06 -07:00

20 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. Active values live in Obsidian's store, NOT in secrets.json.nosync. The on-disk envelope records the active mode and also retains any passphrase material (kdf/verifier/keys) as a preserved-at-rest snapshot, so switching to secret storage does NOT discard a configured passphrase store (the two stores coexist; see "Mode switch, migrate, and clear" below). With no passphrase store ever configured, the envelope is just { version, mode: 'secretStorage', keys: {} }. Keys are namespaced with manifest.id (rewrite-voice-notes-profile-desktop-transcription) because the store is shared across plugins. The namespace separator and every secret id are dash-joined, not colon-joined: setSecret rejects any id that is not lowercase-alphanumeric + dashes (it throws on a colon, underscore, or uppercase letter), which previously made the round-trip probe below throw on its first write and report secretStorage unavailable on every platform. 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 anything reads the envelope (i.e. before loadSettingshydrateSecretsloadAllKeysensureEnvelope, which caches it for the session), so the synchronous defaultEnvelope() can prefer secretStorage on first run. Warming it only before getEncryptionStatus is too late: hydrateSecrets during loadSettings would already have cached an unconfigured-passphrase default. 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.6.6 (raised in 1.2.1 because FileManager.trashFile, @since 1.6.6, is used directly to delete a disabled built-in template). It had been 1.4.4 for FileManager.processFrontMatter (@since 1.4.4, used directly in src/insert.ts for note-property frontmatter); neither is feature-detected. secretStorage 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). The kdf.algo discriminant distinguishes PBKDF2 from Argon2id; a legacy envelope with no algo but an iterations field is read as pbkdf2. parseEnvelope reads kdf/verifier/keys whenever a complete pair is present regardless of mode, so a secret-storage-active envelope can still carry a passphrase snapshot (the version stays 2; the shape is backward-tolerant because an older plugin ignored kdf/verifier it saw under secret-storage mode):

{ "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, passphraseConfigured }) is loaded on onload and refreshed via plugin.refreshEncryptionStatus() after every mode change / copy / clear / 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. passphraseConfigured reports whether a complete kdf+verifier exists on disk independent of the active mode, so the settings UI can tell a switch-to-passphrase (just activate) from a create-passphrase (prompt for a new one) and can know whether a secret-storage-active user still has a passphrase snapshot to copy or clear.

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 setEncryptionMode(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).

Mode switch, copy, and clear

Switching the active method and transferring keys are deliberately decoupled (so a switch can never silently move or lose keys). The keys move only via an explicit copy, which is presented to the user as Copy (not "Migrate", since the source is kept). Three operations:

  • setEncryptionMode(plugin, newMode, newPassphrase?) switches the active method only; it reads, moves, and deletes nothing. → secretStorage no-ops if already active, else requires the probe to pass, then flips mode while preserving any passphrase kdf/verifier/keys snapshot and the in-memory unlockedKey. → passphrase when a store is already configured just flips mode if it isn't already active (it becomes locked until unlocked unless the session key is still held); → passphrase with no store yet (no kdf/verifier) requires newPassphrase (entropy-gated) and builds a fresh empty envelope via writePassphraseEnvelope. The "no store yet" branch is reached even when mode is already passphrase (the first-run / no-keyring default lands on unconfigured passphrase mode), so creation is keyed off the missing kdf/verifier, not the mode flag — a blanket mode === newMode early-return would otherwise silently skip creating the passphrase and leave the store unconfigured. Existing keys in the other store are left exactly where they are.
  • copyKeys(plugin) copies keys from the inactive method into the active one and returns the count written. Source/target are derived from mode; the source is read to plaintext (readAllFromSecretStorage, or decryptAllToPlain when the source is the passphrase snapshot, which requires unlockedKey), same-named ids in the target are overwritten, other target ids are kept, and the source is never deleted (Clear does that). Writing into a passphrase target also requires unlockedKey. Because passphrase is always one side of a cross-mode copy, the settings UI prompts for the passphrase (via unlockPassphraseStore, see below) before copying whenever the passphrase store is the source.
  • clearKeys(plugin, mode) permanently wipes one method's keys: secretStorageclearSecretStorage; passphrase → drop kdf/verifier/keys from the envelope (rendering it unconfigured) and forget unlockedKey. The active mode is unchanged, so clearing the active passphrase leaves it unconfigured/locked (the banner then offers "Set passphrase").

unlockPassphraseStore(plugin, passphrase) derives + verifies the passphrase store's key from the envelope regardless of the active mode and caches it in unlockedKey (this is what lets a secret-storage-active user unlock the passphrase snapshot to copy it). unlockSecrets delegates to it for the active-passphrase case (keeping the opportunistic PBKDF2→Argon2id upgrade, which only runs when passphrase is the active mode so it can't clobber a secret-storage envelope). changePassphrase(plugin, newPassphrase) is a within-passphrase re-key (decrypt the current keys with unlockedKey, rebuild kdf/key via writePassphraseEnvelope), NOT a cross-mode transfer; it requires passphrase to be the active, unlocked mode and entropy-gates the new passphrase.

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

  • A secrets.json.nosync that fails to parse is treated as corrupt, not merely absent, and its bytes are preserved. parseEnvelope throws a local EnvelopeCorruptError when JSON.parse fails or the parsed value isn't an object; a recognized-but-outdated shape (unknown version, unrecognized mode) is still a deliberate "fresh start" reset, not corruption, and does NOT throw. readEnvelopeFromDisk catches only EnvelopeCorruptError: it copies the raw file to <path>.corrupt via the vault adapter, console.errors, shows a sticky Notice telling the user recovery data was preserved, and falls back to defaultEnvelope(). Without this, a half-written file (crash, sync conflict) used to silently present as "no secrets configured," and the very next settings save (writeEnvelope) would overwrite the salvageable ciphertext with an empty envelope, permanently. preserveCorruptSecretsFile is best-effort: if even the .corrupt write fails, it just logs and moves on.
  • writeEnvelope writes via temp-file + remove + rename, not a single direct write. A crash or interrupted write mid-adapter.write used to risk leaving a truncated/interleaved secrets.json.nosync on disk. It now writes the full JSON to <path>.tmp, removes any existing target, then renames the temp file into place, so a failure at any point before the rename leaves the OLD file intact and a failure after leaves the NEW one intact; there is no window where the file is partially written to its final path. Falls back to a direct write (logging the failure) if the adapter doesn't support rename (defensive; Obsidian's adapters do).
  • A keyring failure mid-session (as opposed to unavailable from the start) is surfaced once, not silently. probeSecretStorage's result is cached for the session, so secretStorage can keep reporting available/configured even after the OS keyring stops responding (service restart, permission revoked, etc.). loadKey's catch used to just return '', which surfaces later as a confusing "missing API key" with no indication why. It now routes through warnKeyringFailure(e): console.errors and shows a sticky Notice pointing at Settings → Encryption, gated by a module-level keyringFailureNoticeShown flag so repeated failed reads don't spam multiple notices in one session. Write-side failures (saveKey/saveManyKeys calling writeToSecretStorage, which throws when the store is unavailable) are not caught in secrets.ts; they propagate up to the settings tab's commit(), which has its own try/catch + Notice (see CLAUDE.md's Settings tab UI 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 before loadSettings (which reads + caches the envelope), so the synchronous defaultEnvelope() can prefer it on first run; warming it after loadSettings is a regression that re-defaults fresh installs to passphrase. Keys are namespaced with manifest.id; never store under a bare id (the store is shared across plugins).
  • Secret ids must be lowercase-alphanumeric + dashes only. app.secretStorage.setSecret(id, value) throws when id contains a colon, underscore, or uppercase letter. nsId joins the manifest id and the secret id with a DASH for this reason, and the canonical ids in src/settings/index.ts (profile-desktop-transcription, etc.) and the self-test id (selftest) are all dash-only. A regression here is silent and total: the round-trip probe's first setSecret throws, the catch swallows it, and secretStorage reports unavailable on EVERY platform (this was the original bug, when ids were colon/underscore-joined). Do not reintroduce a colon or underscore into any id passed through nsId.
  • 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.
  • The two stores coexist; switching is non-destructive. Since the envelope retains passphrase kdf/verifier/keys even while secretStorage is active, both methods can hold keys at once and mode is purely the active-store flag. In secretStorage mode saveKey/saveManyKeys write to the OS store and return WITHOUT calling writeEnvelope, so the passphrase snapshot on disk is never touched by ordinary saves — only copyKeys/clearKeys/changePassphrase/setEncryptionMode rewrite it. The snapshot is frozen from when passphrase was last active, so a copyKeys from it overwrites the same-named active keys with that snapshot (the user's explicit choice; the confirm dialog says so). Do not re-merge switch + transfer into one call: the split is the whole point (a switch must never move or drop keys). The user-facing button is Copy, not "Migrate", since the source copy is kept.
  • 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 setEncryptionMode/changePassphrase) 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.