API Password overhaul

This commit is contained in:
WiseGuru 2026-05-30 11:07:02 -07:00
parent 43f39cf968
commit 2dce00e19d
15 changed files with 679 additions and 114 deletions

View file

@ -46,7 +46,10 @@ src/
├── types.ts # Shared interfaces (provider IDs, configs, templates, settings)
├── http.ts # requestUrl wrappers: jsonPost/jsonGet/multipartPost + ProviderError
├── platform.ts # Active-profile resolver + MediaRecorder availability probe
├── secrets.ts # safeStorage (desktop) + plaintext fallback (mobile) for API keys
├── secrets.ts # verified safeStorage (desktop) + passphrase (Argon2id/PBKDF2) for API keys
├── passphrase-strength.ts # zxcvbn-ts wrapper (lazy dynamic import, async API): evaluatePassphrase + MIN_PASSPHRASE_SCORE gate + warmPassphraseStrength
├── diceware.ts # generateDicewarePassphrase (EFF wordlist, crypto rejection sampling)
├── eff-large-wordlist.ts # EFF large diceware wordlist (7776 words)
├── recorder.ts # MediaRecorder state machine + getBestMimeType (no size cap; per-provider limits live in transcription/limits.ts)
├── audio-transcode.ts # WebAudio decode + resample to 16 kHz mono PCM WAV (shared by whisper-local and mistral-voxtral)
├── pipeline.ts # transcribe → cleanup → insert orchestrator
@ -128,32 +131,31 @@ Saving flow strips secrets out of `data.json` and writes them to `secrets.json.n
## Secrets encryption
[src/secrets.ts](src/secrets.ts) supports three encryption modes for `secrets.json.nosync`, file-wide (not per-key):
[src/secrets.ts](src/secrets.ts) supports two encryption modes for `secrets.json.nosync`, file-wide (not per-key). There is no unencrypted at-rest option.
- **`safeStorage`** — Electron's OS keychain (Keychain on macOS, DPAPI on Windows, libsecret/kwallet on Linux). Default when available. Each value is the base64 ciphertext of `safeStorage.encryptString`. Desktop only; chosen automatically on first run when `safeStorage.isEncryptionAvailable()` returns true. Backend name is surfaced via `safeStorage.getSelectedStorageBackend()`.
- **`passphrase`** — WebCrypto AES-GCM-256 with a key derived from a user-supplied passphrase via PBKDF2-SHA256, 600,000 iterations, 16-byte random salt (per-file). Each value is stored as `<iv-b64>.<ct-b64>` (12-byte random IV per value). A `verifier` field stores an encryption of `VERIFIER_PLAINTEXT` so unlock can validate the passphrase without trying to decrypt user keys. Works on every platform including mobile and Linux-without-keyring.
- **`plaintext`** — no encryption. Only used as the auto-fallback on first run when `safeStorage` isn't available; users must explicitly opt back into it after switching away.
- **`safeStorage`** — Electron's OS keychain (Keychain on macOS, DPAPI on Windows, libsecret/kwallet on Linux). Default when available AND verified. Each value is the base64 ciphertext of `safeStorage.encryptString`. Desktop only. **Verification:** `getRawSafeStorage()` is the old "present and `isEncryptionAvailable()`" check; `getSafeStorage()` adds a session-cached gate that (a) rejects Chromium's `basic_text` backend (its last-resort, unencrypted store, reported by `getSelectedStorageBackend()`) and (b) runs a round-trip self-test (encrypt a sentinel, decrypt, compare). Only `getSafeStorage()` is used for encrypt/decrypt and for "available"; `getSafeStorageBackend()` reads from raw so the backend name can be surfaced even when rejected.
- **`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). 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. **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.
File envelope (`SECRETS_VERSION = 2`):
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`:
```json
{ "version": 2, "mode": "passphrase",
"kdf": { "iterations": 600000, "salt": "<b64>" },
"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 and decrypts the `verifier` to check correctness before caching.
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.
`ReWritePlugin.encryptionStatus` (a snapshot of `{ mode, locked, safeStorageAvailable, safeStorageBackend }`) is loaded on `onload` and refreshed via `plugin.refreshEncryptionStatus()` after every mode change / unlock. UI code reads this synchronously.
`ReWritePlugin.encryptionStatus` (a snapshot of `{ mode, locked, configured, safeStorageAvailable, safeStorageInsecure, safeStorageBackend }`) is loaded on `onload` and refreshed via `plugin.refreshEncryptionStatus()` after every mode change / unlock. UI code reads this synchronously. `configured` is `false` for a passphrase envelope with no `kdf`/`verifier` yet (first run on a no-keychain device); `safeStorageInsecure` is `true` when the OS reports a keychain but it failed verification (e.g. `basic_text`), used to steer the user to a passphrase.
When `mode === 'passphrase'` and not yet unlocked (`encryptionStatus.locked === true`):
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.
- The settings tab disables the API key input fields and shows a red "Unlock" banner at the top.
- 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 `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?)` decrypts all keys with the current mode, switches the envelope, and re-encrypts them. Requires the current mode to be unlocked (if passphrase). For `passphrase` newMode, `newPassphrase` is required. `changePassphrase(plugin, newPassphrase)` is a thin wrapper that calls `changeEncryptionMode(plugin, 'passphrase', newPassphrase)`.
`changeEncryptionMode(plugin, newMode, newPassphrase?)` decrypts all keys with the current mode, switches the envelope, and re-encrypts them. Requires the current mode to be unlocked (if passphrase AND already configured). For `passphrase` newMode, `newPassphrase` is required and must pass the entropy gate; the new envelope is built by `buildPassphraseKdfAndKey` (tries Argon2id, falls back to PBKDF2 on any derivation failure). `changePassphrase(plugin, newPassphrase)` is a thin wrapper that calls `changeEncryptionMode(plugin, 'passphrase', newPassphrase)`.
## Templates
@ -228,6 +230,7 @@ Per [.editorconfig](.editorconfig): tabs (width 4), LF, UTF-8, final newline. Ma
- **`requestUrl` uses `throw: false` + status check.** All adapters surface non-2xx as `ProviderError` with `status` and `body`, so users see provider-attributed errors instead of opaque network failures.
- **`safeStorage` is lazy-required inside a `Platform.isDesktop` guard** in [src/secrets.ts](src/secrets.ts). Importing `electron` at module top would crash on mobile load (it's marked `external` in esbuild). Any failure is treated as "encryption unavailable", which is also the mobile path and the Linux-without-keyring path. The settings tab surfaces the active backend via `safeStorage.getSelectedStorageBackend()` so users can see why it failed (e.g. `basic_text` is Chromium's last-resort backend and counts as unencrypted).
- **`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](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.
- **No baked-in model defaults.** Both profiles ship with `model: ""`. The modal renders an inline setup card that blocks recording/paste until the active profile has a provider, model, key, and (for `openai-compatible`) base URL. If you add a provider, do not bake a default model string; surface it as placeholder hint text.
- **`openai-compatible` base URL asymmetry** (literal interpretation of the spec): transcription appends `/v1/audio/transcriptions` to a *root* URL (`http://localhost:8080`); LLM appends `/chat/completions` to a URL that *already includes* `/v1` (`http://localhost:11434/v1`). The settings UI hint text and setup card both guide users; do not "normalize" one to match the other.
- **`setHeading()` instead of manual `<h2>`** inside settings tabs. `obsidianmd/settings-tab/no-manual-html-headings` forbids manual headings. Same applies anywhere else inside a settings tab that needs a section header.

View file

@ -20,7 +20,7 @@ You bring your own provider keys. Nothing is sent to a ReWrite server; the plugi
- **Ad-hoc voice instructions**: speak your assistant's name followed by an instruction mid-recording (e.g. "Scrivener, turn this into a checklist") and the directive is extracted and added to the cleanup prompt for that run only. The trigger word is configurable.
- **Assistant prompt**: a vault Markdown file defines the persona and standing instructions prefaced to the cleanup step, so you can shape tone and behavior without touching settings.
- **Known nouns**: a vault Markdown file of proper nouns (with optional misheard variants) that the LLM preserves verbatim, fixing names the transcriber tends to mangle.
- **API key encryption**: keys are stored per device in OS keychain (desktop), passphrase-based AES-GCM (cross-platform), or plaintext.
- **API key encryption**: keys are stored per device in the verified OS keychain (desktop) or with a strength-checked passphrase using Argon2id/PBKDF2 AES-GCM (cross-platform). There is no unencrypted option.
## Install
@ -153,11 +153,10 @@ If transcription quality drops noticeably (truncated sentences, missing trailing
API keys are stored in `<YourVault>/.obsidian/plugins/rewrite-plugin/secrets.json.nosync`, separately from the rest of the plugin's settings.
The plugin supports three at-rest encryption modes for this file, selectable in settings under "API key encryption":
The plugin supports two at-rest encryption modes for this file, selectable in settings under "API key encryption". There is no unencrypted option:
- **OS keychain** (`safeStorage`): the default on desktop. Keys are encrypted with Electron's `safeStorage` API, which is tied to the user account on that specific machine. The encrypted blob cannot be decrypted on another desktop, on mobile, or in a fresh OS profile.
- **Passphrase**: AES-GCM encryption with a key derived from a passphrase you set. Works on every platform including mobile, and the blob is portable across devices (you re-enter the passphrase to unlock on each one).
- **Plaintext**: no encryption. This is the zero-config default on mobile and other devices without an OS keychain. Switch to passphrase encryption if you want the keys protected at rest.
- **OS keychain** (`safeStorage`): the default on desktop when available. Keys are encrypted with Electron's `safeStorage` API, which is tied to the user account on that specific machine. The plugin verifies the keychain with a round-trip self-test and refuses to use a backend that does not actually encrypt (such as Linux's `basic_text` fallback), steering you to a passphrase instead. The encrypted blob cannot be decrypted on another desktop, on mobile, or in a fresh OS profile.
- **Passphrase**: AES-GCM encryption with a key derived from a passphrase you set, using Argon2id (a memory-hard key-derivation function) or PBKDF2 on devices that cannot run Argon2id. Works on every platform including mobile, and the blob is portable across devices (you re-enter the passphrase to unlock on each one). When you set a passphrase the plugin enforces a minimum strength and offers a one-click generator that produces a strong 6-word passphrase. On devices without an OS keychain (mobile, Linux-without-keyring), setting a passphrase is required before any key can be saved.
Unless you are using passphrase mode, **you should exclude `secrets.json.nosync` from any vault sync mechanism** and enter keys once per device. Configure the exclusion **before the first sync**, since files already uploaded usually remain on the remote.
@ -236,7 +235,7 @@ secrets.json.nosync
Obsidian on iOS and Android runs in a constrained WebView. A few things behave differently from desktop:
- **iOS screen-off**: `MediaRecorder` silently stops capturing audio when the screen turns off on iOS. The plugin cannot prevent this; keep the screen on while recording, or use the Paste tab with an OS-level dictation keyboard.
- **API keys default to plaintext on mobile** because Electron's `safeStorage` is not available there. You can switch to passphrase encryption in settings (API key encryption) to protect them at rest. The `secrets.json.nosync` file uses the `.nosync` filename so iCloud Drive will skip it; for other sync tools, apply the exclusion rules above (or use passphrase mode if you intend to sync the file).
- **Mobile requires a passphrase** because Electron's `safeStorage` (the OS keychain) is not available there. On first use the plugin prompts you to set a passphrase before any key can be saved; keys are then encrypted with Argon2id/PBKDF2 AES-GCM. The `secrets.json.nosync` file uses the `.nosync` filename so iCloud Drive will skip it; for other sync tools, apply the exclusion rules above (or use passphrase mode if you intend to sync the file, since the blob is portable when you re-enter the passphrase).
- **Recording size limit**: each transcription provider enforces its own ceiling (OpenAI Whisper and Groq are the tightest at 25 MB; AssemblyAI, Deepgram, and Rev.ai allow gigabytes). These are provider-API limits, not Obsidian ones, and are most likely to bite on long mobile recordings with the 25 MB providers.
## Known limitations (v1)

View file

@ -35,6 +35,16 @@ If a hosted-YouTube-fetch feature is ever requested later, the shell-out-to-`yt-
## Done
### Secrets encryption: dropped plaintext, hardened passphrase mode (verified keychain + entropy gate + Argon2id)
Reduced the three secrets modes to two trustworthy ones (no unencrypted at-rest option). Implemented the [docs/REMOVE_PLAINTEXT_SECRETS_MODE.md](REMOVE_PLAINTEXT_SECRETS_MODE.md) plan plus the keychain-verification and passphrase-hardening work.
**Dropped plaintext.** `EncryptionMode` is `'safeStorage' | 'passphrase'`. `defaultEnvelope()` falls back to an *unconfigured* passphrase envelope (no `kdf`/`verifier`) when no verified keychain exists; nothing is written in that state (`saveManyKeys` is a no-op while locked). `EncryptionStatus` gained `configured` (passphrase has a kdf+verifier) and `safeStorageInsecure`. `promptUnlock` ([src/main.ts](../src/main.ts)) branches on `configured`: unconfigured opens a create-passphrase modal, configured-but-locked opens the unlock modal. The four entry points were unchanged (unconfigured reports `locked === true`). Stored `plaintext` envelopes parse as invalid and reset to a fresh start (pre-release no-migration rule). Deferred to GA: Obsidian's `app.secretStorage` as the future zero-config cross-platform mode.
**Verified OS keychain.** Split `getRawSafeStorage()` (present + `isEncryptionAvailable()`) from `getSafeStorage()`, which session-caches a gate that rejects Chromium's unencrypted `basic_text` backend and runs an encrypt/decrypt round-trip self-test. Only the verified accessor is used for encrypt/decrypt and availability; the settings tab shows the backend and an "OS keychain unavailable" warning (steering to passphrase) when `safeStorageInsecure`.
**Hardened passphrase.** Entropy gate via new [src/passphrase-strength.ts](../src/passphrase-strength.ts) (zxcvbn-ts wrapper, `MIN_PASSPHRASE_SCORE = 3`), enforced hard in `changeEncryptionMode`/`changePassphrase` and surfaced as a live strength meter + a Generate button (6-word EFF-wordlist diceware, [src/diceware.ts](../src/diceware.ts) + [src/eff-large-wordlist.ts](../src/eff-large-wordlist.ts)) in the passphrase modal on create/change flows. KDF is now Argon2id by default (`hash-wasm`, `m = 32 MiB`, `t = 3`, `p = 1`, 32-byte output) with PBKDF2 fallback on any derivation failure; the envelope gained a `kdf.algo` discriminant (a legacy `iterations`-only kdf reads as `pbkdf2`). On unlock, a legacy PBKDF2 envelope opportunistically re-encrypts to Argon2id (best-effort), and an Argon2id allocation failure throws a clear "can't allocate enough memory" message. Decisions: score 3, generator on, 32 MiB. New deps `hash-wasm` + `@zxcvbn-ts/{core,language-common,language-en}` bundle into the single `main.js` (hash-wasm inlines its wasm as base64; no separate `.wasm`). zxcvbn's dictionaries (~1.6 MB, ~140 ms to build) are **lazy-loaded** via dynamic `import()` so they are not evaluated at plugin startup (measured startup cost for the feature dropped from ~47 ms to ~9 ms; the dictionary build moves to first passphrase-meter use and is warmed on modal open). `evaluatePassphrase`/`isPassphraseAcceptable` are async as a result. `eslint.config.mts` gained the Argon2id/Argon2/zxcvbn/diceware/EFF brand names. CLAUDE.md and README updated.
### Shared core promoted to an editable vault file
Superseded the baked-in `SHARED_CORE`/`withCore()` approach from "Default-prompt overhaul" below. The shared cleanup preface (anti-injection guardrail + condensed cleanup + output discipline) now lives in a vault Markdown file (`GlobalSettings.sharedCorePath`, default `ReWrite/SharedCore.md`) and is prepended to each template prompt at runtime, mirroring the assistant-prompt / known-nouns pattern. New module [src/shared-core.ts](../src/shared-core.ts) (`loadSharedCoreFromFile`, `populateDefaultSharedCore`, `isPathSharedCore`, `DEFAULT_SHARED_CORE`); cache on `plugin.sharedCore: string | null` refreshed on the usual triggers; `PipelineHost` gained `sharedCore`. The default template `.md` files now carry only their per-template rules; [src/pipeline.ts](../src/pipeline.ts) composes `sharedCore\n\n${template.prompt}`.

View file

@ -1,6 +1,6 @@
# Plan: Remove the plaintext secrets mode; defer SecretStorage to GA
Status: planned (not yet implemented). Captured 2026-05-28.
Status: IMPLEMENTED 2026-05-30 (as part of FEATURES.md item 4, alongside keychain verification + Argon2id/entropy hardening). Kept for reference. Captured 2026-05-28.
## Context

View file

@ -40,6 +40,7 @@ const REWRITE_BRANDS = [
"Ollama", "LM Studio",
"AssemblyAI", "Deepgram", "Rev.ai",
"whisper.cpp", "whisper-server", "faster-whisper-server",
"Argon2id", "Argon2", "zxcvbn", "diceware", "EFF",
];
const REWRITE_ACRONYMS = [

35
package-lock.json generated
View file

@ -9,6 +9,10 @@
"version": "0.1.1",
"license": "0-BSD",
"dependencies": {
"@zxcvbn-ts/core": "^3.0.4",
"@zxcvbn-ts/language-common": "^3.0.4",
"@zxcvbn-ts/language-en": "^3.0.2",
"hash-wasm": "^4.12.0",
"obsidian": "latest"
},
"devDependencies": {
@ -1124,6 +1128,24 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@zxcvbn-ts/core": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@zxcvbn-ts/core/-/core-3.0.4.tgz",
"integrity": "sha512-aQeiT0F09FuJaAqNrxynlAwZ2mW/1MdXakKWNmGM1Qp/VaY6CnB/GfnMS2T8gB2231Esp1/maCWd8vTG4OuShw==",
"dependencies": {
"fastest-levenshtein": "1.0.16"
}
},
"node_modules/@zxcvbn-ts/language-common": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@zxcvbn-ts/language-common/-/language-common-3.0.4.tgz",
"integrity": "sha512-viSNNnRYtc7ULXzxrQIVUNwHAPSXRtoIwy/Tq4XQQdIknBzw4vz36lQLF6mvhMlTIlpjoN/Z1GFu/fwiAlUSsw=="
},
"node_modules/@zxcvbn-ts/language-en": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@zxcvbn-ts/language-en/-/language-en-3.0.2.tgz",
"integrity": "sha512-Zp+zL+I6Un2Bj0tRXNs6VUBq3Djt+hwTwUz4dkt2qgsQz47U0/XthZ4ULrT/RxjwJRl5LwiaKOOZeOtmixHnjg=="
},
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@ -2623,6 +2645,14 @@
],
"license": "BSD-3-Clause"
},
"node_modules/fastest-levenshtein": {
"version": "1.0.16",
"resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz",
"integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==",
"engines": {
"node": ">= 4.9.1"
}
},
"node_modules/fastq": {
"version": "1.19.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
@ -2985,6 +3015,11 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hash-wasm": {
"version": "4.12.0",
"resolved": "https://registry.npmjs.org/hash-wasm/-/hash-wasm-4.12.0.tgz",
"integrity": "sha512-+/2B2rYLb48I/evdOIhP+K/DD2ca2fgBjp6O+GBEnCDk2e4rpeXIK8GvIyRPjTezgmWn9gmKwkQjjx6BtqDHVQ=="
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",

View file

@ -13,17 +13,21 @@
"keywords": [],
"license": "0-BSD",
"devDependencies": {
"@eslint/js": "9.30.1",
"@types/node": "^16.11.6",
"esbuild": "0.25.5",
"eslint-plugin-obsidianmd": "0.1.9",
"globals": "14.0.0",
"jiti": "2.6.1",
"tslib": "2.4.0",
"typescript": "^5.8.3",
"typescript-eslint": "8.35.1",
"@eslint/js": "9.30.1",
"jiti": "2.6.1"
"typescript-eslint": "8.35.1"
},
"dependencies": {
"@zxcvbn-ts/core": "^3.0.4",
"@zxcvbn-ts/language-common": "^3.0.4",
"@zxcvbn-ts/language-en": "^3.0.2",
"hash-wasm": "^4.12.0",
"obsidian": "latest"
}
}

26
src/diceware.ts Normal file
View file

@ -0,0 +1,26 @@
import { EFF_LARGE_WORDLIST } from 'eff-large-wordlist';
// Unbiased random integer in [0, max) using rejection sampling over crypto bytes.
// (`x % max` alone biases toward small values when max does not divide 2^32.)
function secureRandomInt(max: number): number {
if (max <= 0) throw new Error('max must be positive');
const limit = Math.floor(0x1_0000_0000 / max) * max;
const buf = new Uint32Array(1);
let x: number;
do {
crypto.getRandomValues(buf);
x = buf[0] ?? 0;
} while (x >= limit);
return x % max;
}
// Generate a diceware-style passphrase from the EFF large wordlist. Default is a
// space separator because some EFF words contain hyphens (e.g. "t-shirt"), which
// would make "-" an ambiguous delimiter. Six words give ~77.5 bits of entropy.
export function generateDicewarePassphrase(words = 6, separator = ' '): string {
const out: string[] = [];
for (let i = 0; i < words; i++) {
out.push(EFF_LARGE_WORDLIST[secureRandomInt(EFF_LARGE_WORDLIST.length)] ?? '');
}
return out.join(separator);
}

File diff suppressed because one or more lines are too long

View file

@ -17,7 +17,7 @@ import { isPathInTemplatesFolder, loadTemplatesFromFolder } from './templates-fo
import { isPathSharedCore, loadSharedCoreFromFile } from './shared-core';
import { isPathAssistantPrompt, loadAssistantPromptFromFile } from './assistant-prompt';
import { isPathKnownNouns, loadKnownNounsFromFile } from './known-nouns';
import { EncryptionStatus, getEncryptionStatus, unlockSecrets } from './secrets';
import { changeEncryptionMode, EncryptionStatus, getEncryptionStatus, unlockSecrets } from './secrets';
export default class ReWritePlugin extends Plugin implements PipelineHost {
settings!: GlobalSettings;
@ -191,6 +191,28 @@ export default class ReWritePlugin extends Plugin implements PipelineHost {
}
promptUnlock(onUnlocked?: () => void): void {
// Unconfigured passphrase mode (no keychain device, first run) reports
// locked === true but has no passphrase yet: prompt to CREATE one rather
// than unlock. Configured-but-locked takes the unlock path.
if (!this.encryptionStatus.configured) {
new PassphraseModal({
app: this.app,
title: 'Set a passphrase',
description: 'No OS keychain is available on this device, so your API keys are encrypted with a passphrase you set. Store it in your password manager; there is no recovery if you forget it.',
confirmLabel: 'Save',
requireConfirm: true,
enforceStrength: true,
onSubmit: async (pass) => {
await changeEncryptionMode(this, 'passphrase', pass);
await hydrateSecrets(this, this.settings);
await this.refreshEncryptionStatus();
this.notifySecretsUnlocked();
onUnlocked?.();
new Notice('ReWrite: passphrase set. API keys are now encrypted.');
},
}).open();
return;
}
new PassphraseModal({
app: this.app,
title: 'Unlock API keys',

View file

@ -0,0 +1,69 @@
// zxcvbn-ts is heavy (its language dictionaries are ~1.6 MB and ~200 ms to build).
// We need it only when a user sets/changes a passphrase, so the packages are pulled
// in via dynamic import() rather than a static top-level import. In esbuild's cjs
// output this keeps the dictionary literals inside a lazily-invoked init function, so
// they are neither parsed nor constructed at plugin load. Call warmPassphraseStrength()
// when a create/change UI opens to hide the one-time build cost behind the modal open.
// Minimum acceptable zxcvbn score (0-4). 3 = "safely unguessable: moderate
// protection from offline slow-hash scenario". This is the primary defense for
// passphrase mode: PBKDF2 (the fallback KDF) is GPU-friendly, so a high-entropy
// passphrase is what makes an encrypted secrets file infeasible to crack.
export const MIN_PASSPHRASE_SCORE = 3;
export interface PassphraseStrength {
score: number; // 0-4
warning: string;
suggestions: string[];
}
let zxcvbnImpl: typeof import('@zxcvbn-ts/core').zxcvbn | null = null;
let loading: Promise<void> | null = null;
async function loadZxcvbn(): Promise<void> {
const [core, common, en] = await Promise.all([
import('@zxcvbn-ts/core'),
import('@zxcvbn-ts/language-common'),
import('@zxcvbn-ts/language-en'),
]);
core.zxcvbnOptions.setOptions({
dictionary: {
...common.dictionary,
...en.dictionary,
},
graphs: common.adjacencyGraphs,
translations: en.translations,
});
zxcvbnImpl = core.zxcvbn;
}
async function ensureLoaded(): Promise<void> {
if (zxcvbnImpl) return;
if (!loading) loading = loadZxcvbn();
try {
await loading;
} catch (e) {
loading = null; // allow a retry on the next call
throw e;
}
}
// Preload the estimator (call when a passphrase create/change UI opens) so the first
// keystroke doesn't pay the dictionary-build cost.
export function warmPassphraseStrength(): void {
void ensureLoaded();
}
export async function evaluatePassphrase(passphrase: string): Promise<PassphraseStrength> {
await ensureLoaded();
const result = zxcvbnImpl!(passphrase);
return {
score: result.score,
warning: result.feedback.warning ?? '',
suggestions: result.feedback.suggestions,
};
}
export async function isPassphraseAcceptable(passphrase: string): Promise<boolean> {
return (await evaluatePassphrase(passphrase)).score >= MIN_PASSPHRASE_SCORE;
}

View file

@ -1,25 +1,53 @@
import { normalizePath, Platform, Plugin } from 'obsidian';
import { argon2id } from 'hash-wasm';
import { isPassphraseAcceptable } from 'passphrase-strength';
const SECRETS_FILE = 'secrets.json.nosync';
const SECRETS_VERSION = 2;
const VERIFIER_PLAINTEXT = 'rewrite-passphrase-verifier-v1';
const SAFE_STORAGE_SELFTEST = 'rewrite-safestorage-selftest';
const PBKDF2_ITERATIONS = 600_000;
const PBKDF2_SALT_BYTES = 16;
const KDF_SALT_BYTES = 16;
const AES_IV_BYTES = 12;
const VALUE_SEP = '.';
export type EncryptionMode = 'safeStorage' | 'plaintext' | 'passphrase';
// Argon2id parameters for new passphrase envelopes. Memory is capped at 32 MiB so
// the weakest supported phone (params live in the ciphertext and must reproduce on
// every device that opens the synced vault) can still allocate and unlock within the
// ~0.5-1s budget. Higher would risk allocation failure on low-RAM mobile webviews.
const ARGON2_MEM_KIB = 32_768; // 32 MiB
const ARGON2_TIME = 3;
const ARGON2_PARALLELISM = 1;
const ARGON2_HASH_BYTES = 32;
export type EncryptionMode = 'safeStorage' | 'passphrase';
export interface EncryptionStatus {
mode: EncryptionMode;
// passphrase mode with a derived key not yet held in memory this session.
locked: boolean;
// passphrase mode that has actually had a passphrase set (kdf + verifier on disk).
// false = first run on a no-keychain device: prompt to CREATE a passphrase, not unlock.
configured: boolean;
// OS keychain present, verified by a round-trip self-test, and not a known-insecure backend.
safeStorageAvailable: boolean;
// OS keychain reports available but is effectively unencrypted (e.g. Chromium basic_text)
// or failed the round-trip; we do not offer it and steer the user to a passphrase.
safeStorageInsecure: boolean;
safeStorageBackend: string | null;
}
type KdfAlgo = 'pbkdf2' | 'argon2id';
interface PassphraseKdf {
iterations: number;
algo: KdfAlgo;
salt: string; // base64
// pbkdf2
iterations?: number;
// argon2id
memKiB?: number;
timeCost?: number;
parallelism?: number;
}
interface SecretsEnvelope {
@ -38,10 +66,13 @@ interface SafeStorageAPI {
}
let safeStorageCache: SafeStorageAPI | null | undefined;
let verifiedSafeStorageCache: SafeStorageAPI | null | undefined;
let cachedEnvelope: SecretsEnvelope | null = null;
let unlockedKey: CryptoKey | null = null;
function getSafeStorage(): SafeStorageAPI | null {
// Raw electron safeStorage if the platform reports encryption available. Says nothing
// about whether the backend actually encrypts (see getSafeStorage for that check).
function getRawSafeStorage(): SafeStorageAPI | null {
if (safeStorageCache !== undefined) return safeStorageCache;
if (!Platform.isDesktop) {
safeStorageCache = null;
@ -68,8 +99,46 @@ function getSafeStorage(): SafeStorageAPI | null {
return null;
}
// Verified, secure safeStorage: the backend is not the known-unencrypted Chromium
// fallback (basic_text), AND an encrypt/decrypt round-trip of a sentinel succeeds.
// Cached for the session. Used by encrypt/decrypt and the availability checks.
function getSafeStorage(): SafeStorageAPI | null {
if (verifiedSafeStorageCache !== undefined) return verifiedSafeStorageCache;
const raw = getRawSafeStorage();
if (!raw) {
verifiedSafeStorageCache = null;
return null;
}
if (typeof raw.getSelectedStorageBackend === 'function') {
let backend: string | null = null;
try {
backend = raw.getSelectedStorageBackend();
} catch {
backend = null;
}
// basic_text is Chromium's last-resort backend on Linux and is not encrypted.
if (backend === 'basic_text') {
verifiedSafeStorageCache = null;
return null;
}
}
try {
const ct = raw.encryptString(SAFE_STORAGE_SELFTEST).toString('base64');
const pt = raw.decryptString(base64ToNodeBuffer(ct));
if (pt !== SAFE_STORAGE_SELFTEST) {
verifiedSafeStorageCache = null;
return null;
}
} catch {
verifiedSafeStorageCache = null;
return null;
}
verifiedSafeStorageCache = raw;
return raw;
}
function getSafeStorageBackend(): string | null {
const ss = getSafeStorage();
const ss = getRawSafeStorage();
if (!ss || typeof ss.getSelectedStorageBackend !== 'function') return null;
try {
return ss.getSelectedStorageBackend();
@ -85,9 +154,12 @@ function secretsPath(plugin: Plugin): string {
}
function defaultEnvelope(): SecretsEnvelope {
// No keychain => passphrase mode, but UNCONFIGURED (no kdf/verifier). The first
// pipeline use / settings visit prompts the user to create a passphrase. Nothing
// is ever written in this state (saveManyKeys is a no-op while locked).
return {
version: SECRETS_VERSION,
mode: getSafeStorage() ? 'safeStorage' : 'plaintext',
mode: getSafeStorage() ? 'safeStorage' : 'passphrase',
keys: {},
};
}
@ -96,6 +168,24 @@ function isObject(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null && !Array.isArray(v);
}
function parseKdf(raw: unknown): PassphraseKdf | undefined {
if (!isObject(raw) || typeof raw.salt !== 'string') return undefined;
if (raw.algo === 'argon2id') {
return {
algo: 'argon2id',
salt: raw.salt,
memKiB: typeof raw.memKiB === 'number' ? raw.memKiB : ARGON2_MEM_KIB,
timeCost: typeof raw.timeCost === 'number' ? raw.timeCost : ARGON2_TIME,
parallelism: typeof raw.parallelism === 'number' ? raw.parallelism : ARGON2_PARALLELISM,
};
}
// 'pbkdf2' or a legacy envelope with no algo field but an iterations count.
if (typeof raw.iterations === 'number') {
return { algo: 'pbkdf2', salt: raw.salt, iterations: raw.iterations };
}
return undefined;
}
function parseEnvelope(raw: string): SecretsEnvelope {
let parsed: unknown;
try {
@ -106,27 +196,24 @@ function parseEnvelope(raw: string): SecretsEnvelope {
if (!isObject(parsed)) return defaultEnvelope();
const version = typeof parsed.version === 'number' ? parsed.version : 1;
if (version !== SECRETS_VERSION) {
// Pre-release: no migrations. Treat unknown shapes as a fresh start.
// Existing v1 dev installs will need to re-enter their API keys.
// Pre-release: no migrations. Treat unknown shapes (incl. old 'plaintext'
// envelopes that fail the mode check below) as a fresh start.
return defaultEnvelope();
}
const mode = parsed.mode;
if (mode !== 'safeStorage' && mode !== 'plaintext' && mode !== 'passphrase') {
if (mode !== 'safeStorage' && mode !== 'passphrase') {
return defaultEnvelope();
}
const keys = isObject(parsed.keys) ? parsed.keys as Record<string, string> : {};
const envelope: SecretsEnvelope = { version, mode, keys };
if (mode === 'passphrase') {
const kdf = parsed.kdf;
if (isObject(kdf) && typeof kdf.iterations === 'number' && typeof kdf.salt === 'string') {
envelope.kdf = { iterations: kdf.iterations, salt: kdf.salt };
}
if (typeof parsed.verifier === 'string') {
envelope.verifier = parsed.verifier;
}
if (!envelope.kdf || !envelope.verifier) {
// Malformed passphrase envelope; treat as fresh start.
return defaultEnvelope();
const kdf = parseKdf(parsed.kdf);
const verifier = typeof parsed.verifier === 'string' ? parsed.verifier : undefined;
// Only a complete kdf+verifier pair counts as configured; otherwise the
// envelope is treated as unconfigured (prompt to create a passphrase).
if (kdf && verifier) {
envelope.kdf = kdf;
envelope.verifier = verifier;
}
}
return envelope;
@ -183,7 +270,16 @@ function randomBytes(n: number): Uint8Array {
return out;
}
// ---------- WebCrypto passphrase helpers ----------
// Heuristic: did an Argon2 derivation fail because the device couldn't allocate the
// requested memory (or run wasm at all)? Used to fall back to PBKDF2 at creation and
// to give a clear message at unlock.
function isAllocationFailure(e: unknown): boolean {
if (e instanceof RangeError) return true;
const msg = (e instanceof Error ? e.message : String(e)).toLowerCase();
return msg.includes('memory') || msg.includes('alloc') || msg.includes('wasm') || msg.includes('webassembly');
}
// ---------- key derivation ----------
async function deriveKeyFromPassphrase(passphrase: string, salt: Uint8Array, iterations: number): Promise<CryptoKey> {
const passBytes = new TextEncoder().encode(passphrase);
@ -203,6 +299,73 @@ async function deriveKeyFromPassphrase(passphrase: string, salt: Uint8Array, ite
);
}
async function deriveArgon2idKey(
passphrase: string,
salt: Uint8Array,
memKiB: number,
timeCost: number,
parallelism: number,
): Promise<CryptoKey> {
const raw = await argon2id({
password: passphrase,
salt,
parallelism,
iterations: timeCost,
memorySize: memKiB,
hashLength: ARGON2_HASH_BYTES,
outputType: 'binary',
});
return crypto.subtle.importKey(
'raw',
raw as BufferSource,
{ name: 'AES-GCM' },
false,
['encrypt', 'decrypt'],
);
}
async function deriveKeyFromKdf(passphrase: string, kdf: PassphraseKdf): Promise<CryptoKey> {
const salt = base64ToBytes(kdf.salt);
if (kdf.algo === 'argon2id') {
return deriveArgon2idKey(
passphrase,
salt,
kdf.memKiB ?? ARGON2_MEM_KIB,
kdf.timeCost ?? ARGON2_TIME,
kdf.parallelism ?? ARGON2_PARALLELISM,
);
}
return deriveKeyFromPassphrase(passphrase, salt, kdf.iterations ?? PBKDF2_ITERATIONS);
}
// Build a fresh kdf + derived key for a new passphrase. Prefers Argon2id; on any
// derivation failure (wasm unavailable / can't allocate memory) falls back to PBKDF2
// so a constrained device can still set a passphrase.
async function buildPassphraseKdfAndKey(passphrase: string): Promise<{ kdf: PassphraseKdf; key: CryptoKey }> {
const salt = randomBytes(KDF_SALT_BYTES);
try {
const key = await deriveArgon2idKey(passphrase, salt, ARGON2_MEM_KIB, ARGON2_TIME, ARGON2_PARALLELISM);
return {
kdf: {
algo: 'argon2id',
salt: bytesToBase64(salt),
memKiB: ARGON2_MEM_KIB,
timeCost: ARGON2_TIME,
parallelism: ARGON2_PARALLELISM,
},
key,
};
} catch {
const key = await deriveKeyFromPassphrase(passphrase, salt, PBKDF2_ITERATIONS);
return {
kdf: { algo: 'pbkdf2', salt: bytesToBase64(salt), iterations: PBKDF2_ITERATIONS },
key,
};
}
}
// ---------- AES-GCM value codec ----------
async function aesGcmEncrypt(key: CryptoKey, plaintext: string): Promise<string> {
const iv = randomBytes(AES_IV_BYTES);
const ct = await crypto.subtle.encrypt(
@ -229,22 +392,18 @@ async function aesGcmDecrypt(key: CryptoKey, payload: string): Promise<string> {
// ---------- per-mode encrypt/decrypt of a single value ----------
async function encryptValue(envelope: SecretsEnvelope, plaintext: string): Promise<string> {
if (envelope.mode === 'plaintext') return plaintext;
if (envelope.mode === 'safeStorage') {
const ss = getSafeStorage();
if (!ss) throw new Error('safeStorage is unavailable on this device.');
if (!ss) throw new Error('OS keychain encryption is not available on this device.');
return ss.encryptString(plaintext).toString('base64');
}
if (envelope.mode === 'passphrase') {
if (!unlockedKey) throw new Error('Secrets are locked. Unlock with your passphrase first.');
return aesGcmEncrypt(unlockedKey, plaintext);
}
throw new Error(`Unknown encryption mode: ${envelope.mode as string}`);
// passphrase
if (!unlockedKey) throw new Error('Secrets are locked. Unlock with your passphrase first.');
return aesGcmEncrypt(unlockedKey, plaintext);
}
async function decryptValue(envelope: SecretsEnvelope, stored: string): Promise<string> {
if (stored === '') return '';
if (envelope.mode === 'plaintext') return stored;
if (envelope.mode === 'safeStorage') {
const ss = getSafeStorage();
if (!ss) return '';
@ -254,25 +413,74 @@ async function decryptValue(envelope: SecretsEnvelope, stored: string): Promise<
return '';
}
}
if (envelope.mode === 'passphrase') {
if (!unlockedKey) return '';
try {
return await aesGcmDecrypt(unlockedKey, stored);
} catch {
return '';
}
// passphrase
if (!unlockedKey) return '';
try {
return await aesGcmDecrypt(unlockedKey, stored);
} catch {
return '';
}
return '';
}
async function decryptAllToPlain(envelope: SecretsEnvelope): Promise<Record<string, string>> {
const plain: Record<string, string> = {};
for (const id of Object.keys(envelope.keys)) {
const v = await decryptValue(envelope, envelope.keys[id] ?? '');
if (v) plain[id] = v;
}
return plain;
}
// Write a freshly-built passphrase envelope (kdf + verifier) and re-encrypt `plain`
// under the new key. Sets unlockedKey. Used by mode change, change-passphrase, and
// the unlock-time KDF upgrade. Does NOT enforce entropy (the caller does, when needed).
async function writePassphraseEnvelope(
plugin: Plugin,
passphrase: string,
plain: Record<string, string>,
): Promise<void> {
const { kdf, key } = await buildPassphraseKdfAndKey(passphrase);
unlockedKey = key;
const next: SecretsEnvelope = { version: SECRETS_VERSION, mode: 'passphrase', kdf, keys: {} };
next.verifier = await aesGcmEncrypt(key, VERIFIER_PLAINTEXT);
cachedEnvelope = next;
for (const id of Object.keys(plain)) {
next.keys[id] = await encryptValue(next, plain[id] ?? '');
}
await writeEnvelope(plugin, next);
}
// Best-effort upgrade of a legacy PBKDF2 envelope to Argon2id on unlock. Requires the
// current (pbkdf2) key already in unlockedKey so we can read the stored values. If the
// device can't run Argon2id, leaves the envelope on PBKDF2.
async function tryUpgradeToArgon2id(plugin: Plugin, passphrase: string): Promise<void> {
const envelope = await ensureEnvelope(plugin);
if (envelope.mode !== 'passphrase' || envelope.kdf?.algo !== 'pbkdf2') return;
const plain = await decryptAllToPlain(envelope);
const built = await buildPassphraseKdfAndKey(passphrase);
if (built.kdf.algo !== 'argon2id') return; // device can't do Argon2id; keep PBKDF2
unlockedKey = built.key;
const next: SecretsEnvelope = { version: SECRETS_VERSION, mode: 'passphrase', kdf: built.kdf, keys: {} };
next.verifier = await aesGcmEncrypt(built.key, VERIFIER_PLAINTEXT);
cachedEnvelope = next;
for (const id of Object.keys(plain)) {
next.keys[id] = await encryptValue(next, plain[id] ?? '');
}
await writeEnvelope(plugin, next);
}
// ---------- public API ----------
export async function getEncryptionStatus(plugin: Plugin): Promise<EncryptionStatus> {
const envelope = await ensureEnvelope(plugin);
const verified = getSafeStorage() !== null;
const raw = getRawSafeStorage() !== null;
return {
mode: envelope.mode,
locked: envelope.mode === 'passphrase' && unlockedKey === null,
safeStorageAvailable: getSafeStorage() !== null,
configured: envelope.mode !== 'passphrase' || (envelope.kdf != null && envelope.verifier != null),
safeStorageAvailable: verified,
safeStorageInsecure: raw && !verified,
safeStorageBackend: getSafeStorageBackend(),
};
}
@ -289,8 +497,18 @@ export async function unlockSecrets(plugin: Plugin, passphrase: string): Promise
const envelope = await ensureEnvelope(plugin);
if (envelope.mode !== 'passphrase') return true;
if (!envelope.kdf || !envelope.verifier) return false;
const salt = base64ToBytes(envelope.kdf.salt);
const candidate = await deriveKeyFromPassphrase(passphrase, salt, envelope.kdf.iterations);
let candidate: CryptoKey;
try {
candidate = await deriveKeyFromKdf(passphrase, envelope.kdf);
} catch (e) {
if (envelope.kdf.algo === 'argon2id' && isAllocationFailure(e)) {
const mib = Math.round((envelope.kdf.memKiB ?? ARGON2_MEM_KIB) / 1024);
throw new Error(
`This device can't allocate the ~${mib} MiB needed to unlock. These secrets were encrypted with Argon2id on a device with more memory.`,
);
}
return false;
}
try {
const decoded = await aesGcmDecrypt(candidate, envelope.verifier);
if (decoded !== VERIFIER_PLAINTEXT) return false;
@ -298,6 +516,15 @@ export async function unlockSecrets(plugin: Plugin, passphrase: string): Promise
return false;
}
unlockedKey = candidate;
// Opportunistically migrate legacy PBKDF2 envelopes to Argon2id while we hold the
// passphrase. Best-effort: failures leave the envelope (and unlockedKey) on PBKDF2.
if (envelope.kdf.algo === 'pbkdf2') {
try {
await tryUpgradeToArgon2id(plugin, passphrase);
} catch {
// keep PBKDF2; nothing to do
}
}
return true;
}
@ -317,8 +544,8 @@ export async function saveKey(plugin: Plugin, id: string, key: string): Promise<
export async function saveManyKeys(plugin: Plugin, updates: Record<string, string>): Promise<void> {
const envelope = await ensureEnvelope(plugin);
if (envelope.mode === 'passphrase' && unlockedKey === null) {
// Caller (settings save) may run while locked. Don't blow up; just skip writing
// secrets so we don't clobber the on-disk encrypted values with empties.
// Caller (settings save) may run while locked or unconfigured. Don't blow up;
// just skip writing so we don't clobber on-disk encrypted values with empties.
return;
}
for (const id of Object.keys(updates)) {
@ -345,13 +572,8 @@ export async function deleteKey(plugin: Plugin, id: string): Promise<void> {
export async function loadAllKeys(plugin: Plugin): Promise<Record<string, string>> {
const envelope = await ensureEnvelope(plugin);
const out: Record<string, string> = {};
if (envelope.mode === 'passphrase' && unlockedKey === null) return out;
for (const id of Object.keys(envelope.keys)) {
const value = await decryptValue(envelope, envelope.keys[id] ?? '');
if (value) out[id] = value;
}
return out;
if (envelope.mode === 'passphrase' && unlockedKey === null) return {};
return decryptAllToPlain(envelope);
}
// ---------- mode transitions ----------
@ -363,38 +585,31 @@ export async function changeEncryptionMode(
): Promise<void> {
const envelope = await ensureEnvelope(plugin);
if (envelope.mode === newMode && newMode !== 'passphrase') return;
if (envelope.mode === 'passphrase' && unlockedKey === null) {
if (envelope.mode === 'passphrase' && unlockedKey === null && envelope.kdf) {
throw new Error('Unlock secrets with the current passphrase before changing modes.');
}
if (newMode === 'safeStorage' && !getSafeStorage()) {
throw new Error('OS keychain encryption is not available on this device.');
}
if (newMode === 'passphrase' && (!newPassphrase || newPassphrase.length === 0)) {
throw new Error('A passphrase is required to switch to passphrase mode.');
if (newMode === 'passphrase') {
if (!newPassphrase || newPassphrase.length === 0) {
throw new Error('A passphrase is required to switch to passphrase mode.');
}
if (!(await isPassphraseAcceptable(newPassphrase))) {
throw new Error('Passphrase is too weak. Use a longer, more unique passphrase (try the Generate button).');
}
}
const plain: Record<string, string> = {};
for (const id of Object.keys(envelope.keys)) {
const v = await decryptValue(envelope, envelope.keys[id] ?? '');
if (v) plain[id] = v;
}
const next: SecretsEnvelope = {
version: SECRETS_VERSION,
mode: newMode,
keys: {},
};
const plain = await decryptAllToPlain(envelope);
if (newMode === 'passphrase') {
const salt = randomBytes(PBKDF2_SALT_BYTES);
const newKey = await deriveKeyFromPassphrase(newPassphrase ?? '', salt, PBKDF2_ITERATIONS);
next.kdf = { iterations: PBKDF2_ITERATIONS, salt: bytesToBase64(salt) };
next.verifier = await aesGcmEncrypt(newKey, VERIFIER_PLAINTEXT);
unlockedKey = newKey;
} else {
unlockedKey = null;
await writePassphraseEnvelope(plugin, newPassphrase ?? '', plain);
return;
}
// safeStorage
unlockedKey = null;
const next: SecretsEnvelope = { version: SECRETS_VERSION, mode: 'safeStorage', keys: {} };
cachedEnvelope = next;
for (const id of Object.keys(plain)) {
next.keys[id] = await encryptValue(next, plain[id] ?? '');
@ -407,7 +622,7 @@ export async function changePassphrase(plugin: Plugin, newPassphrase: string): P
if (envelope.mode !== 'passphrase') {
throw new Error('Not in passphrase mode.');
}
if (unlockedKey === null) {
if (unlockedKey === null && envelope.kdf) {
throw new Error('Unlock with the current passphrase first.');
}
if (newPassphrase.length === 0) {

View file

@ -84,7 +84,10 @@ export class ReWriteSettingTab extends PluginSettingTab {
}
private apiKeyPlaceholder(): string {
if (this.plugin.encryptionStatus.locked) return 'Locked. Unlock to view or edit.';
const status = this.plugin.encryptionStatus;
if (status.locked) {
return status.configured ? 'Locked. Unlock to view or edit.' : 'Set a passphrase to store keys.';
}
return 'Saved securely on this device';
}
@ -98,7 +101,17 @@ export class ReWriteSettingTab extends PluginSettingTab {
const status = this.plugin.encryptionStatus;
const banner = parent.createDiv({ cls: 'rewrite-encryption-banner' });
if (status.locked) {
if (status.mode === 'passphrase' && !status.configured) {
banner.addClass('is-warning');
banner.createEl('strong', { text: 'No encryption set.' });
banner.createEl('span', {
text: ' Set a passphrase to encrypt your API keys on this device. Until then, recording and processing are disabled.',
});
const setBtn = banner.createEl('button', { text: 'Set passphrase', cls: 'mod-cta' });
setBtn.addEventListener('click', () => {
this.plugin.promptUnlock(() => this.display());
});
} else if (status.locked) {
banner.addClass('is-locked');
banner.createEl('strong', { text: 'API keys are locked.' });
banner.createEl('span', {
@ -108,12 +121,6 @@ export class ReWriteSettingTab extends PluginSettingTab {
unlockBtn.addEventListener('click', () => {
this.plugin.promptUnlock(() => this.display());
});
} else if (status.mode === 'plaintext') {
banner.addClass('is-warning');
banner.createEl('strong', { text: 'Plaintext storage.' });
banner.createEl('span', {
text: ' Your API keys are stored unencrypted on this device. Any process running as your user account can read them. Switch to a passphrase below to encrypt them.',
});
} else if (status.mode === 'safeStorage') {
banner.addClass('is-ok');
const backend = status.safeStorageBackend ? ` (${status.safeStorageBackend})` : '';
@ -123,6 +130,15 @@ export class ReWriteSettingTab extends PluginSettingTab {
banner.createEl('span', { text: 'Encrypted with passphrase. Unlocked for this session.' });
}
if (status.safeStorageInsecure) {
const backend = status.safeStorageBackend ? ` (reported "${status.safeStorageBackend}")` : '';
const note = parent.createDiv({ cls: 'rewrite-encryption-banner is-warning' });
note.createEl('strong', { text: 'OS keychain unavailable.' });
note.createEl('span', {
text: ` Your operating system's secret store${backend} does not actually encrypt, so it is not offered here. Use a passphrase instead.`,
});
}
new Setting(parent).setName('API key encryption').setHeading();
parent.createEl('p', {
@ -136,7 +152,6 @@ export class ReWriteSettingTab extends PluginSettingTab {
.addDropdown((dd) => {
if (status.safeStorageAvailable) dd.addOption('safeStorage', 'OS keychain (recommended)');
dd.addOption('passphrase', 'Passphrase (cross-platform)');
dd.addOption('plaintext', 'Plaintext (not recommended)');
dd.setValue(status.mode);
dd.onChange((v) => {
const next = v as EncryptionMode;
@ -157,6 +172,7 @@ export class ReWriteSettingTab extends PluginSettingTab {
description: 'Replaces the current passphrase. Stored API keys will be re-encrypted.',
confirmLabel: 'Save',
requireConfirm: true,
enforceStrength: true,
onSubmit: async (pass) => {
await changeEncryptionMode(this.plugin, 'passphrase', pass);
await this.plugin.refreshEncryptionStatus();
@ -184,12 +200,11 @@ export class ReWriteSettingTab extends PluginSettingTab {
private encryptionModeDescription(status: { mode: EncryptionMode; safeStorageAvailable: boolean; safeStorageBackend: string | null }): string {
const lines: string[] = [];
if (status.safeStorageAvailable) {
lines.push(`OS keychain: encrypted by your operating system (${status.safeStorageBackend ?? 'detected'}). Strongest, but only works on this machine.`);
lines.push(`OS keychain: encrypted by your operating system (${status.safeStorageBackend ?? 'detected'}), verified by a round-trip check. Strongest, but only works on this machine.`);
} else {
lines.push('OS keychain: not available on this device (no working keyring detected).');
lines.push('OS keychain: not available on this device (no working, verified keyring detected).');
}
lines.push('Passphrase: AES-GCM with PBKDF2-derived key. You enter a passphrase once per session. Works on every platform, including mobile.');
lines.push('Plaintext: no encryption. Any process running as your user can read your keys.');
lines.push('Passphrase: AES-GCM with an Argon2id-derived key (PBKDF2 fallback on devices that cannot run Argon2id). You enter a passphrase once per session. Works on every platform, including mobile.');
return lines.join(' ');
}
@ -202,6 +217,7 @@ export class ReWriteSettingTab extends PluginSettingTab {
description: 'A passphrase will be used to encrypt your API keys. Store it in your password manager; there is no recovery if you forget it.',
confirmLabel: 'Save',
requireConfirm: true,
enforceStrength: true,
onSubmit: async (pass) => {
await changeEncryptionMode(this.plugin, 'passphrase', pass);
await this.plugin.refreshEncryptionStatus();
@ -216,8 +232,7 @@ export class ReWriteSettingTab extends PluginSettingTab {
}
await changeEncryptionMode(this.plugin, next);
await this.plugin.refreshEncryptionStatus();
const label = next === 'safeStorage' ? 'OS keychain' : 'plaintext';
new Notice(`ReWrite: switched to ${label} storage.`);
new Notice('ReWrite: switched to OS keychain storage.');
this.display();
} catch (e) {
new Notice(`ReWrite: ${e instanceof Error ? e.message : String(e)}`);

View file

@ -1,4 +1,8 @@
import { App, Modal, Notice, Platform, Setting } from 'obsidian';
import { evaluatePassphrase, MIN_PASSPHRASE_SCORE, warmPassphraseStrength } from 'passphrase-strength';
import { generateDicewarePassphrase } from 'diceware';
const STRENGTH_LABELS = ['Very weak', 'Weak', 'Fair', 'Strong', 'Very strong'];
export interface PassphrasePromptParams {
app: App;
@ -7,6 +11,9 @@ export interface PassphrasePromptParams {
confirmLabel?: string;
// When true, render a second "Confirm passphrase" field that must match.
requireConfirm?: boolean;
// When true (create/change flows), render the strength meter + Generate button and
// block submit below MIN_PASSPHRASE_SCORE. Leave false for the unlock flow.
enforceStrength?: boolean;
// Called with the entered passphrase. Throw to keep the modal open and surface an error.
onSubmit: (passphrase: string) => Promise<void>;
}
@ -17,6 +24,12 @@ export class PassphraseModal extends Modal {
private busy = false;
private errorEl: HTMLElement | null = null;
private tipsEl: HTMLDetailsElement | null = null;
private passInput: HTMLInputElement | null = null;
private confirmInput: HTMLInputElement | null = null;
private strengthBarEl: HTMLElement | null = null;
private strengthTextEl: HTMLElement | null = null;
private strengthTimer: number | null = null;
private strengthSeq = 0;
constructor(private readonly params: PassphrasePromptParams) {
super(params.app);
@ -36,7 +49,7 @@ export class PassphraseModal extends Modal {
this.renderPassphraseTips(contentEl);
}
new Setting(contentEl)
const passSetting = new Setting(contentEl)
.setName('Passphrase')
.addText((t) => {
t.inputEl.type = 'password';
@ -44,17 +57,35 @@ export class PassphraseModal extends Modal {
// On mobile, programmatic autofocus would fire `focus` (collapsing
// the tips) before the user has read them; let the user's tap do it.
t.inputEl.autofocus = !Platform.isMobile;
t.onChange((v) => { this.passphrase = v; });
this.passInput = t.inputEl;
t.onChange((v) => {
this.passphrase = v;
this.scheduleStrengthUpdate();
});
t.inputEl.addEventListener('focus', () => this.collapseTipsOnMobile());
t.inputEl.addEventListener('keydown', (e) => this.onKeydown(e));
});
if (this.params.enforceStrength) {
// Begin loading the estimator now (while the user reads the tips / picks a
// field) so the first keystroke does not pay the dictionary-build cost.
warmPassphraseStrength();
passSetting.addButton((b) => {
b.setButtonText('Generate')
.setTooltip('Generate a 6-word passphrase')
.onClick(() => this.fillGenerated());
b.buttonEl.addClass('rewrite-passphrase-generate');
});
this.renderStrengthMeter(contentEl);
}
if (this.params.requireConfirm) {
new Setting(contentEl)
.setName('Confirm passphrase')
.addText((t) => {
t.inputEl.type = 'password';
t.inputEl.addClass('rewrite-passphrase-input');
this.confirmInput = t.inputEl;
t.onChange((v) => { this.confirm = v; });
t.inputEl.addEventListener('focus', () => this.collapseTipsOnMobile());
t.inputEl.addEventListener('keydown', (e) => this.onKeydown(e));
@ -76,6 +107,15 @@ export class PassphraseModal extends Modal {
}
onClose(): void {
if (this.strengthTimer !== null) {
window.clearTimeout(this.strengthTimer);
this.strengthTimer = null;
}
// Invalidate any in-flight strength evaluation and drop DOM refs so a late
// async result does not write to detached nodes.
this.strengthSeq++;
this.strengthBarEl = null;
this.strengthTextEl = null;
this.passphrase = '';
this.confirm = '';
this.contentEl.empty();
@ -94,9 +134,7 @@ export class PassphraseModal extends Modal {
const list = tips.createEl('ul');
const li1 = list.createEl('li');
li1.createSpan({ text: 'Length beats complexity. A 5-6 word diceware-style password (like one you can generate ' });
appendExternalLink(li1, 'here', 'https://www.keepersecurity.com/features/passphrase-generator/');
li1.createSpan({ text: ') is far stronger than ' });
li1.createSpan({ text: 'Length beats complexity. The Generate button makes a 6-word diceware passphrase, far stronger than ' });
li1.createEl('code', { text: 'P@ssw0rd!' });
li1.createSpan({ text: ' and much easier to remember than ' });
// eslint-disable-next-line obsidianmd/ui/sentence-case
@ -115,6 +153,76 @@ export class PassphraseModal extends Modal {
li3.createSpan({ text: ' for brute-force time estimates by length and character class.' });
}
private renderStrengthMeter(parent: HTMLElement): void {
const wrap = parent.createDiv({ cls: 'rewrite-passphrase-strength' });
this.strengthBarEl = wrap.createDiv({ cls: 'rewrite-passphrase-strength-bar' });
for (let i = 0; i < 4; i++) {
this.strengthBarEl.createDiv({ cls: 'rewrite-passphrase-strength-seg' });
}
this.strengthTextEl = wrap.createDiv({ cls: 'rewrite-passphrase-strength-text' });
void this.updateStrength();
}
private scheduleStrengthUpdate(): void {
if (!this.params.enforceStrength) return;
if (this.strengthTimer !== null) window.clearTimeout(this.strengthTimer);
this.strengthTimer = window.setTimeout(() => {
this.strengthTimer = null;
void this.updateStrength();
}, 150);
}
private async updateStrength(): Promise<void> {
if (!this.strengthBarEl || !this.strengthTextEl) return;
// Guard against out-of-order async results: only the most recent call wins.
const seq = ++this.strengthSeq;
const pass = this.passphrase;
const empty = pass.length === 0;
const { score, warning, suggestions } = empty
? { score: 0, warning: '', suggestions: [] as string[] }
: await evaluatePassphrase(pass);
// A newer keystroke (or modal close) superseded this evaluation; drop it.
if (seq !== this.strengthSeq || !this.strengthBarEl || !this.strengthTextEl) return;
const level = score <= 1 ? 'is-weak' : score === 2 ? 'is-fair' : score === 3 ? 'is-good' : 'is-strong';
const filled = empty ? 0 : Math.max(score, 1);
const segs = Array.from(this.strengthBarEl.children) as HTMLElement[];
segs.forEach((seg, i) => {
seg.removeClass('is-weak', 'is-fair', 'is-good', 'is-strong', 'is-filled');
if (i < filled) {
seg.addClass('is-filled');
seg.addClass(level);
}
});
let msg = '';
if (!empty) {
msg = STRENGTH_LABELS[score] ?? '';
if (score < MIN_PASSPHRASE_SCORE) {
const hint = warning || suggestions[0] || 'Add more words or make it more unique.';
msg = `${msg}: ${hint}`;
}
}
this.strengthTextEl.setText(msg);
this.strengthTextEl.toggleClass('is-acceptable', !empty && score >= MIN_PASSPHRASE_SCORE);
}
private fillGenerated(): void {
const phrase = generateDicewarePassphrase(6);
this.passphrase = phrase;
this.confirm = phrase;
// Reveal so the user can read/copy what was generated.
if (this.passInput) {
this.passInput.value = phrase;
this.passInput.type = 'text';
}
if (this.confirmInput) {
this.confirmInput.value = phrase;
this.confirmInput.type = 'text';
}
void this.updateStrength();
this.clearError();
}
private collapseTipsOnMobile(): void {
if (Platform.isMobile) this.tipsEl?.removeAttribute('open');
}
@ -150,6 +258,10 @@ export class PassphraseModal extends Modal {
this.setError('Passphrases do not match.');
return;
}
if (this.params.enforceStrength && (await evaluatePassphrase(this.passphrase)).score < MIN_PASSPHRASE_SCORE) {
this.setError('Passphrase is too weak. Add more words or use Generate.');
return;
}
this.busy = true;
try {
await this.params.onSubmit(this.passphrase);

View file

@ -421,6 +421,55 @@
margin-top: 16px;
}
/* Passphrase strength meter (create/change flows) */
.rewrite-passphrase-modal .rewrite-passphrase-strength {
margin: 4px 0 12px;
}
.rewrite-passphrase-modal .rewrite-passphrase-strength-bar {
display: flex;
gap: 4px;
}
.rewrite-passphrase-modal .rewrite-passphrase-strength-seg {
flex: 1;
height: 5px;
border-radius: 3px;
background-color: var(--background-modifier-border);
}
.rewrite-passphrase-modal .rewrite-passphrase-strength-seg.is-filled.is-weak {
background-color: var(--color-red);
}
.rewrite-passphrase-modal .rewrite-passphrase-strength-seg.is-filled.is-fair {
background-color: var(--color-yellow);
}
.rewrite-passphrase-modal .rewrite-passphrase-strength-seg.is-filled.is-good {
background-color: var(--color-green);
}
.rewrite-passphrase-modal .rewrite-passphrase-strength-seg.is-filled.is-strong {
background-color: var(--color-green);
}
.rewrite-passphrase-modal .rewrite-passphrase-strength-text {
color: var(--text-muted);
font-size: var(--font-ui-smaller);
margin-top: 4px;
min-height: 1.2em;
}
.rewrite-passphrase-modal .rewrite-passphrase-strength-text.is-acceptable {
color: var(--text-success, var(--color-green));
}
.rewrite-passphrase-modal .rewrite-passphrase-generate {
white-space: nowrap;
}
/* Locked card in the main modal */
.rewrite-locked-card {