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-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. Values live in Obsidian's store, NOT insecrets.json.nosync(the on-disk envelope in this mode is just{ version, mode: 'secretStorage', keys: {} }to record the mode). Keys are namespaced withmanifest.id(rewrite-voice-notes:profile:desktop:transcription) because the store is shared across plugins. 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 inonloadBEFOREgetEncryptionStatus, so the synchronousdefaultEnvelope()can prefersecretStorageon 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.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.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/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 callschangeEncryptionMode(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).
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.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 inonloadviawarmSecretStorageso the synchronousdefaultEnvelope()can prefer it on first run. Keys are namespaced withmanifest.id; never store under a bare id (the store is shared across plugins).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.- 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 isPassphraseAcceptableinchangeEncryptionMode) 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.