mirror of
https://github.com/wiseguru/ReWrite-Voice-Notes.git
synced 2026-07-22 07:49:19 +00:00
API Password overhaul
This commit is contained in:
parent
43f39cf968
commit
2dce00e19d
15 changed files with 679 additions and 114 deletions
29
CLAUDE.md
29
CLAUDE.md
|
|
@ -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.
|
||||
|
|
|
|||
11
README.md
11
README.md
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
35
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
10
package.json
10
package.json
|
|
@ -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
26
src/diceware.ts
Normal 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);
|
||||
}
|
||||
5
src/eff-large-wordlist.ts
Normal file
5
src/eff-large-wordlist.ts
Normal file
File diff suppressed because one or more lines are too long
24
src/main.ts
24
src/main.ts
|
|
@ -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',
|
||||
|
|
|
|||
69
src/passphrase-strength.ts
Normal file
69
src/passphrase-strength.ts
Normal 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;
|
||||
}
|
||||
355
src/secrets.ts
355
src/secrets.ts
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)}`);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
49
styles.css
49
styles.css
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue