# 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](../CLAUDE.md) accurate. [src/secrets.ts](../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 `loadSettings` → `hydrateSecrets` → `loadAllKeys` → `ensureEnvelope`, 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 `.` (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](../src/passphrase-strength.ts) (zxcvbn-ts wrapper); this is the hard, non-bypassable gate. The passphrase modal ([src/ui/passphrase-modal.ts](../src/ui/passphrase-modal.ts)) surfaces a live strength meter and a **Generate** button (6-word EFF-wordlist diceware via [src/diceware.ts](../src/diceware.ts) + [src/eff-large-wordlist.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](../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](../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): ```json { "version": 2, "mode": "passphrase", "kdf": { "algo": "argon2id", "salt": "", "memKiB": 32768, "timeCost": 3, "parallelism": 1 }, "verifier": ".", "keys": { "profile-desktop-transcription": ".", ... } } ``` 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/modal.ts), [src/ui/quick-record.ts](../src/ui/quick-record.ts), [src/ui/text-source.ts](../src/ui/text-source.ts), [src/ui/audio-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: `secretStorage` → `clearSecretStorage`; `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 `.corrupt` via the vault adapter, `console.error`s, 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 `.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.error`s 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](../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](../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](../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.