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>
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-partyapp.secretStorage(GA 1.11.4): a shared, cross-plugin, OS-encrypted store (Keychain/DPAPI/libsecret under the hood, the same backend ElectronsafeStorageused). Default/preferred when available. Active values live in Obsidian's store, NOT insecrets.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 withmanifest.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:setSecretrejects 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 reportsecretStorageunavailable on every platform. The API is reached via a narrowSecretStorageLikeinterface + cast (the installed typings predate it) that normalizesgetSecret/get,setSecret/set,listSecrets/list, anddeleteSecret/removeSecret/delete(falling back tosetSecret(id, '')when no delete exists); each method may be sync or async, so callersawait. Availability is a round-trip self-test (setSecreta 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 inonloadBEFORE anything reads the envelope (i.e. beforeloadSettings→hydrateSecrets→loadAllKeys→ensureEnvelope, which caches it for the session), so the synchronousdefaultEnvelope()can prefersecretStorageon first run. Warming it only beforegetEncryptionStatusis too late:hydrateSecretsduringloadSettingswould 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.nosyncfile); 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 (viahash-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 envelopekdf(see below). Each value is stored as<iv-b64>.<ct-b64>(12-byte random IV per value) insecrets.json.nosync. Averifierfield stores an encryption ofVERIFIER_PLAINTEXTso unlock can validate the passphrase without decrypting user keys. Works on every platform including mobile and Linux-without-keyring; the always-available fallback whensecretStorageis not. Entropy gate:changeEncryptionMode/changePassphrasereject a passphrase scoring belowMIN_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 dynamicimport()(not a static top-level import), keeping them out of plugin-startup evaluation;evaluatePassphrase/isPassphraseAcceptableare therefore async, and the modal callswarmPassphraseStrength()on open so the first keystroke does not pay the build cost. The bytes stay inmain.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/loadKeyreturn empty strings (no error).saveManyKeysis a no-op (so calls tosaveSettings()from unrelated UI changes do not clobber the on-disk encrypted values with empties).saveKeythrows.- 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.lockedand callplugin.promptUnlock()instead of proceeding.promptUnlockbranches onconfigured: a configured envelope opens the unlock modal; an unconfigured one opens a create-passphrase modal (requireConfirm+enforceStrength) that callssetEncryptionMode(this, 'passphrase', pass). The entry points need no change since unconfigured reportslocked === 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. →secretStorageno-ops if already active, else requires the probe to pass, then flipsmodewhile preserving any passphrasekdf/verifier/keyssnapshot and the in-memoryunlockedKey. →passphrasewhen a store is already configured just flipsmodeif it isn't already active (it becomes locked until unlocked unless the session key is still held); →passphrasewith no store yet (nokdf/verifier) requiresnewPassphrase(entropy-gated) and builds a fresh empty envelope viawritePassphraseEnvelope. The "no store yet" branch is reached even whenmodeis alreadypassphrase(the first-run / no-keyring default lands on unconfigured passphrase mode), so creation is keyed off the missingkdf/verifier, not the mode flag — a blanketmode === newModeearly-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 frommode; the source is read to plaintext (readAllFromSecretStorage, ordecryptAllToPlainwhen the source is the passphrase snapshot, which requiresunlockedKey), 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 requiresunlockedKey. Because passphrase is always one side of a cross-mode copy, the settings UI prompts for the passphrase (viaunlockPassphraseStore, see below) before copying whenever the passphrase store is the source.clearKeys(plugin, mode)permanently wipes one method's keys:secretStorage→clearSecretStorage;passphrase→ dropkdf/verifier/keysfrom the envelope (rendering it unconfigured) and forgetunlockedKey. 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.nosyncthat fails to parse is treated as corrupt, not merely absent, and its bytes are preserved.parseEnvelopethrows a localEnvelopeCorruptErrorwhenJSON.parsefails or the parsed value isn't an object; a recognized-but-outdated shape (unknownversion, unrecognizedmode) is still a deliberate "fresh start" reset, not corruption, and does NOT throw.readEnvelopeFromDiskcatches onlyEnvelopeCorruptError: it copies the raw file to<path>.corruptvia the vault adapter,console.errors, shows a stickyNoticetelling the user recovery data was preserved, and falls back todefaultEnvelope(). 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.preserveCorruptSecretsFileis best-effort: if even the.corruptwrite fails, it just logs and moves on. writeEnvelopewrites via temp-file + remove + rename, not a single direct write. A crash or interrupted write mid-adapter.writeused to risk leaving a truncated/interleavedsecrets.json.nosyncon 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 supportrename(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, sosecretStoragecan keep reportingavailable/configuredeven after the OS keyring stops responding (service restart, permission revoked, etc.).loadKey'scatchused to just return'', which surfaces later as a confusing "missing API key" with no indication why. It now routes throughwarnKeyringFailure(e):console.errors and shows a stickyNoticepointing at Settings → Encryption, gated by a module-levelkeyringFailureNoticeShownflag so repeated failed reads don't spam multiple notices in one session. Write-side failures (saveKey/saveManyKeyscallingwriteToSecretStorage, which throws when the store is unavailable) are not caught insecrets.ts; they propagate up to the settings tab'scommit(), which has its own try/catch + Notice (see CLAUDE.md's Settings tab UI gotchas). app.secretStorageis reached via a narrow cast + round-trip self-test, never assumed present. src/secrets.tsgetSecretStorage(plugin)castsplugin.appto a localRawSecretStorageshape and normalizes the method aliases;probeSecretStoragewrite/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 inonloadviawarmSecretStoragebeforeloadSettings(which reads + caches the envelope), so the synchronousdefaultEnvelope()can prefer it on first run; warming it afterloadSettingsis a regression that re-defaults fresh installs to passphrase. Keys are namespaced withmanifest.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 whenidcontains a colon, underscore, or uppercase letter.nsIdjoins 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 firstsetSecretthrows, thecatchswallows it, andsecretStoragereports 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 throughnsId. saveManyKeysis a silent no-op when locked. Whenmode === 'passphrase'andunlockedKey === null,saveManyKeysdoes nothing. This is deliberate: unrelatedplugin.saveSettings()calls (e.g. user changes a model dropdown) would otherwise persist emptyapiKeyvalues for every profile, wiping the on-disk encrypted bag. The UI prevents this by disabling key fields and gating all pipeline entry points onencryptionStatus.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/keyseven whilesecretStorageis active, both methods can hold keys at once andmodeis purely the active-store flag. InsecretStoragemodesaveKey/saveManyKeyswrite to the OS store andreturnWITHOUT callingwriteEnvelope, so the passphrase snapshot on disk is never touched by ordinary saves — onlycopyKeys/clearKeys/changePassphrase/setEncryptionModerewrite it. The snapshot is frozen from when passphrase was last active, so acopyKeysfrom 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 dynamicimport()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 synchronousevaluatePassphrase— that re-adds the cost to every Obsidian launch on every device. The async ripples (modalupdateStrengthrace guard,await isPassphraseAcceptableinsetEncryptionMode/changePassphrase) are intentional.warmPassphraseStrength()is fired on passphrase-modal open to hide the one-time build behind the modal animation. secrets.json.nosyncuses the.nosyncsuffix 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.