diff --git a/CLAUDE.md b/CLAUDE.md index 8670f93..c72a984 100644 --- a/CLAUDE.md +++ b/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 `.` (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 `.` (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": "" }, + "kdf": { "algo": "argon2id", "salt": "", "memKiB": 32768, "timeCost": 3, "parallelism": 1 }, "verifier": ".", "keys": { "profile:desktop:transcription": ".", ... } } ``` -The derived AES-GCM key for passphrase mode lives in module-level state (`unlockedKey`); it never touches disk. `lockSecrets()` forgets it. `unlockSecrets(plugin, passphrase)` derives a candidate key 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 `

`** 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. diff --git a/README.md b/README.md index f26d715..1417208 100644 --- a/README.md +++ b/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 `/.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) diff --git a/docs/FEATURES.md b/docs/FEATURES.md index ef7c075..184ad9b 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -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}`. diff --git a/docs/REMOVE_PLAINTEXT_SECRETS_MODE.md b/docs/REMOVE_PLAINTEXT_SECRETS_MODE.md index 6c36902..cb1fe64 100644 --- a/docs/REMOVE_PLAINTEXT_SECRETS_MODE.md +++ b/docs/REMOVE_PLAINTEXT_SECRETS_MODE.md @@ -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 diff --git a/eslint.config.mts b/eslint.config.mts index b9beffb..fe9f9a3 100644 --- a/eslint.config.mts +++ b/eslint.config.mts @@ -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 = [ diff --git a/package-lock.json b/package-lock.json index a93f13a..8359b5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 35b073a..9fa0de0 100644 --- a/package.json +++ b/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" } } diff --git a/src/diceware.ts b/src/diceware.ts new file mode 100644 index 0000000..eaf8791 --- /dev/null +++ b/src/diceware.ts @@ -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); +} diff --git a/src/eff-large-wordlist.ts b/src/eff-large-wordlist.ts new file mode 100644 index 0000000..d7addd8 --- /dev/null +++ b/src/eff-large-wordlist.ts @@ -0,0 +1,5 @@ +// EFF large diceware wordlist (7776 words), from https://www.eff.org/files/2016/07/18/eff_large_wordlist.txt +// Public domain (CC0). Stored as a single space-delimited string to keep the bundle small. +// Used by src/diceware.ts to generate high-entropy passphrases. + +export const EFF_LARGE_WORDLIST: readonly string[] = 'abacus abdomen abdominal abide abiding ability ablaze able abnormal abrasion abrasive abreast abridge abroad abruptly absence absentee absently absinthe absolute absolve abstain abstract absurd accent acclaim acclimate accompany account accuracy accurate accustom acetone achiness aching acid acorn acquaint acquire acre acrobat acronym acting action activate activator active activism activist activity actress acts acutely acuteness aeration aerobics aerosol aerospace afar affair affected affecting affection affidavit affiliate affirm affix afflicted affluent afford affront aflame afloat aflutter afoot afraid afterglow afterlife aftermath aftermost afternoon aged ageless agency agenda agent aggregate aghast agile agility aging agnostic agonize agonizing agony agreeable agreeably agreed agreeing agreement aground ahead ahoy aide aids aim ajar alabaster alarm albatross album alfalfa algebra algorithm alias alibi alienable alienate aliens alike alive alkaline alkalize almanac almighty almost aloe aloft aloha alone alongside aloof alphabet alright although altitude alto aluminum alumni always amaretto amaze amazingly amber ambiance ambiguity ambiguous ambition ambitious ambulance ambush amendable amendment amends amenity amiable amicably amid amigo amino amiss ammonia ammonium amnesty amniotic among amount amperage ample amplifier amplify amply amuck amulet amusable amused amusement amuser amusing anaconda anaerobic anagram anatomist anatomy anchor anchovy ancient android anemia anemic aneurism anew angelfish angelic anger angled angler angles angling angrily angriness anguished angular animal animate animating animation animator anime animosity ankle annex annotate announcer annoying annually annuity anointer another answering antacid antarctic anteater antelope antennae anthem anthill anthology antibody antics antidote antihero antiquely antiques antiquity antirust antitoxic antitrust antiviral antivirus antler antonym antsy anvil anybody anyhow anymore anyone anyplace anything anytime anyway anywhere aorta apache apostle appealing appear appease appeasing appendage appendix appetite appetizer applaud applause apple appliance applicant applied apply appointee appraisal appraiser apprehend approach approval approve apricot april apron aptitude aptly aqua aqueduct arbitrary arbitrate ardently area arena arguable arguably argue arise armadillo armband armchair armed armful armhole arming armless armoire armored armory armrest army aroma arose around arousal arrange array arrest arrival arrive arrogance arrogant arson art ascend ascension ascent ascertain ashamed ashen ashes ashy aside askew asleep asparagus aspect aspirate aspire aspirin astonish astound astride astrology astronaut astronomy astute atlantic atlas atom atonable atop atrium atrocious atrophy attach attain attempt attendant attendee attention attentive attest attic attire attitude attractor attribute atypical auction audacious audacity audible audibly audience audio audition augmented august authentic author autism autistic autograph automaker automated automatic autopilot available avalanche avatar avenge avenging avenue average aversion avert aviation aviator avid avoid await awaken award aware awhile awkward awning awoke awry axis babble babbling babied baboon backache backboard backboned backdrop backed backer backfield backfire backhand backing backlands backlash backless backlight backlit backlog backpack backpedal backrest backroom backshift backside backslid backspace backspin backstab backstage backtalk backtrack backup backward backwash backwater backyard bacon bacteria bacterium badass badge badland badly badness baffle baffling bagel bagful baggage bagged baggie bagginess bagging baggy bagpipe baguette baked bakery bakeshop baking balance balancing balcony balmy balsamic bamboo banana banish banister banjo bankable bankbook banked banker banking banknote bankroll banner bannister banshee banter barbecue barbed barbell barber barcode barge bargraph barista baritone barley barmaid barman barn barometer barrack barracuda barrel barrette barricade barrier barstool bartender barterer bash basically basics basil basin basis basket batboy batch bath baton bats battalion battered battering battery batting battle bauble bazooka blabber bladder blade blah blame blaming blanching blandness blank blaspheme blasphemy blast blatancy blatantly blazer blazing bleach bleak bleep blemish blend bless blighted blimp bling blinked blinker blinking blinks blip blissful blitz blizzard bloated bloating blob blog bloomers blooming blooper blot blouse blubber bluff bluish blunderer blunt blurb blurred blurry blurt blush blustery boaster boastful boasting boat bobbed bobbing bobble bobcat bobsled bobtail bodacious body bogged boggle bogus boil bok bolster bolt bonanza bonded bonding bondless boned bonehead boneless bonelike boney bonfire bonnet bonsai bonus bony boogeyman boogieman book boondocks booted booth bootie booting bootlace bootleg boots boozy borax boring borough borrower borrowing boss botanical botanist botany botch both bottle bottling bottom bounce bouncing bouncy bounding boundless bountiful bovine boxcar boxer boxing boxlike boxy breach breath breeches breeching breeder breeding breeze breezy brethren brewery brewing briar bribe brick bride bridged brigade bright brilliant brim bring brink brisket briskly briskness bristle brittle broadband broadcast broaden broadly broadness broadside broadways broiler broiling broken broker bronchial bronco bronze bronzing brook broom brought browbeat brownnose browse browsing bruising brunch brunette brunt brush brussels brute brutishly bubble bubbling bubbly buccaneer bucked bucket buckle buckshot buckskin bucktooth buckwheat buddhism buddhist budding buddy budget buffalo buffed buffer buffing buffoon buggy bulb bulge bulginess bulgur bulk bulldog bulldozer bullfight bullfrog bullhorn bullion bullish bullpen bullring bullseye bullwhip bully bunch bundle bungee bunion bunkbed bunkhouse bunkmate bunny bunt busboy bush busily busload bust busybody buzz cabana cabbage cabbie cabdriver cable caboose cache cackle cacti cactus caddie caddy cadet cadillac cadmium cage cahoots cake calamari calamity calcium calculate calculus caliber calibrate calm caloric calorie calzone camcorder cameo camera camisole camper campfire camping campsite campus canal canary cancel candied candle candy cane canine canister cannabis canned canning cannon cannot canola canon canopener canopy canteen canyon capable capably capacity cape capillary capital capitol capped capricorn capsize capsule caption captivate captive captivity capture caramel carat caravan carbon cardboard carded cardiac cardigan cardinal cardstock carefully caregiver careless caress caretaker cargo caring carless carload carmaker carnage carnation carnival carnivore carol carpenter carpentry carpool carport carried carrot carrousel carry cartel cartload carton cartoon cartridge cartwheel carve carving carwash cascade case cash casing casino casket cassette casually casualty catacomb catalog catalyst catalyze catapult cataract catatonic catcall catchable catcher catching catchy caterer catering catfight catfish cathedral cathouse catlike catnap catnip catsup cattail cattishly cattle catty catwalk caucasian caucus causal causation cause causing cauterize caution cautious cavalier cavalry caviar cavity cedar celery celestial celibacy celibate celtic cement census ceramics ceremony certainly certainty certified certify cesarean cesspool chafe chaffing chain chair chalice challenge chamber chamomile champion chance change channel chant chaos chaperone chaplain chapped chaps chapter character charbroil charcoal charger charging chariot charity charm charred charter charting chase chasing chaste chastise chastity chatroom chatter chatting chatty cheating cheddar cheek cheer cheese cheesy chef chemicals chemist chemo cherisher cherub chess chest chevron chevy chewable chewer chewing chewy chief chihuahua childcare childhood childish childless childlike chili chill chimp chip chirping chirpy chitchat chivalry chive chloride chlorine choice chokehold choking chomp chooser choosing choosy chop chosen chowder chowtime chrome chubby chuck chug chummy chump chunk churn chute cider cilantro cinch cinema cinnamon circle circling circular circulate circus citable citadel citation citizen citric citrus city civic civil clad claim clambake clammy clamor clamp clamshell clang clanking clapped clapper clapping clarify clarinet clarity clash clasp class clatter clause clavicle claw clay clean clear cleat cleaver cleft clench clergyman clerical clerk clever clicker client climate climatic cling clinic clinking clip clique cloak clobber clock clone cloning closable closure clothes clothing cloud clover clubbed clubbing clubhouse clump clumsily clumsy clunky clustered clutch clutter coach coagulant coastal coaster coasting coastland coastline coat coauthor cobalt cobbler cobweb cocoa coconut cod coeditor coerce coexist coffee cofounder cognition cognitive cogwheel coherence coherent cohesive coil coke cola cold coleslaw coliseum collage collapse collar collected collector collide collie collision colonial colonist colonize colony colossal colt coma come comfort comfy comic coming comma commence commend comment commerce commode commodity commodore common commotion commute commuting compacted compacter compactly compactor companion company compare compel compile comply component composed composer composite compost composure compound compress comprised computer computing comrade concave conceal conceded concept concerned concert conch concierge concise conclude concrete concur condense condiment condition condone conducive conductor conduit cone confess confetti confidant confident confider confiding configure confined confining confirm conflict conform confound confront confused confusing confusion congenial congested congrats congress conical conjoined conjure conjuror connected connector consensus consent console consoling consonant constable constant constrain constrict construct consult consumer consuming contact container contempt contend contented contently contents contest context contort contour contrite control contusion convene convent copartner cope copied copier copilot coping copious copper copy coral cork cornball cornbread corncob cornea corned corner cornfield cornflake cornhusk cornmeal cornstalk corny coronary coroner corporal corporate corral correct corridor corrode corroding corrosive corsage corset cortex cosigner cosmetics cosmic cosmos cosponsor cost cottage cotton couch cough could countable countdown counting countless country county courier covenant cover coveted coveting coyness cozily coziness cozy crabbing crabgrass crablike crabmeat cradle cradling crafter craftily craftsman craftwork crafty cramp cranberry crane cranial cranium crank crate crave craving crawfish crawlers crawling crayfish crayon crazed crazily craziness crazy creamed creamer creamlike crease creasing creatable create creation creative creature credible credibly credit creed creme creole crepe crept crescent crested cresting crestless crevice crewless crewman crewmate crib cricket cried crier crimp crimson cringe cringing crinkle crinkly crisped crisping crisply crispness crispy criteria critter croak crock crook croon crop cross crouch crouton crowbar crowd crown crucial crudely crudeness cruelly cruelness cruelty crumb crummiest crummy crumpet crumpled cruncher crunching crunchy crusader crushable crushed crusher crushing crust crux crying cryptic crystal cubbyhole cube cubical cubicle cucumber cuddle cuddly cufflink culinary culminate culpable culprit cultivate cultural culture cupbearer cupcake cupid cupped cupping curable curator curdle cure curfew curing curled curler curliness curling curly curry curse cursive cursor curtain curtly curtsy curvature curve curvy cushy cusp cussed custard custodian custody customary customer customize customs cut cycle cyclic cycling cyclist cylinder cymbal cytoplasm cytoplast dab dad daffodil dagger daily daintily dainty dairy daisy dallying dance dancing dandelion dander dandruff dandy danger dangle dangling daredevil dares daringly darkened darkening darkish darkness darkroom darling darn dart darwinism dash dastardly data datebook dating daughter daunting dawdler dawn daybed daybreak daycare daydream daylight daylong dayroom daytime dazzler dazzling deacon deafening deafness dealer dealing dealmaker dealt dean debatable debate debating debit debrief debtless debtor debug debunk decade decaf decal decathlon decay deceased deceit deceiver deceiving december decency decent deception deceptive decibel decidable decimal decimeter decipher deck declared decline decode decompose decorated decorator decoy decrease decree dedicate dedicator deduce deduct deed deem deepen deeply deepness deface defacing defame default defeat defection defective defendant defender defense defensive deferral deferred defiance defiant defile defiling define definite deflate deflation deflator deflected deflector defog deforest defraud defrost deftly defuse defy degraded degrading degrease degree dehydrate deity dejected delay delegate delegator delete deletion delicacy delicate delicious delighted delirious delirium deliverer delivery delouse delta deluge delusion deluxe demanding demeaning demeanor demise democracy democrat demote demotion demystify denatured deniable denial denim denote dense density dental dentist denture deny deodorant deodorize departed departure depict deplete depletion deplored deploy deport depose depraved depravity deprecate depress deprive depth deputize deputy derail deranged derby derived desecrate deserve deserving designate designed designer designing deskbound desktop deskwork desolate despair despise despite destiny destitute destruct detached detail detection detective detector detention detergent detest detonate detonator detoxify detract deuce devalue deviancy deviant deviate deviation deviator device devious devotedly devotee devotion devourer devouring devoutly dexterity dexterous diabetes diabetic diabolic diagnoses diagnosis diagram dial diameter diaper diaphragm diary dice dicing dictate dictation dictator difficult diffused diffuser diffusion diffusive dig dilation diligence diligent dill dilute dime diminish dimly dimmed dimmer dimness dimple diner dingbat dinghy dinginess dingo dingy dining dinner diocese dioxide diploma dipped dipper dipping directed direction directive directly directory direness dirtiness disabled disagree disallow disarm disarray disaster disband disbelief disburse discard discern discharge disclose discolor discount discourse discover discuss disdain disengage disfigure disgrace dish disinfect disjoin disk dislike disliking dislocate dislodge disloyal dismantle dismay dismiss dismount disobey disorder disown disparate disparity dispatch dispense dispersal dispersed disperser displace display displease disposal dispose disprove dispute disregard disrupt dissuade distance distant distaste distill distinct distort distract distress district distrust ditch ditto ditzy dividable divided dividend dividers dividing divinely diving divinity divisible divisibly division divisive divorcee dizziness dizzy doable docile dock doctrine document dodge dodgy doily doing dole dollar dollhouse dollop dolly dolphin domain domelike domestic dominion dominoes donated donation donator donor donut doodle doorbell doorframe doorknob doorman doormat doornail doorpost doorstep doorstop doorway doozy dork dormitory dorsal dosage dose dotted doubling douche dove down dowry doze drab dragging dragonfly dragonish dragster drainable drainage drained drainer drainpipe dramatic dramatize drank drapery drastic draw dreaded dreadful dreadlock dreamboat dreamily dreamland dreamless dreamlike dreamt dreamy drearily dreary drench dress drew dribble dried drier drift driller drilling drinkable drinking dripping drippy drivable driven driver driveway driving drizzle drizzly drone drool droop drop-down dropbox dropkick droplet dropout dropper drove drown drowsily drudge drum dry dubbed dubiously duchess duckbill ducking duckling ducktail ducky duct dude duffel dugout duh duke duller dullness duly dumping dumpling dumpster duo dupe duplex duplicate duplicity durable durably duration duress during dusk dust dutiful duty duvet dwarf dweeb dwelled dweller dwelling dwindle dwindling dynamic dynamite dynasty dyslexia dyslexic each eagle earache eardrum earflap earful earlobe early earmark earmuff earphone earpiece earplugs earring earshot earthen earthlike earthling earthly earthworm earthy earwig easeful easel easiest easily easiness easing eastbound eastcoast easter eastward eatable eaten eatery eating eats ebay ebony ebook ecard eccentric echo eclair eclipse ecologist ecology economic economist economy ecosphere ecosystem edge edginess edging edgy edition editor educated education educator eel effective effects efficient effort eggbeater egging eggnog eggplant eggshell egomaniac egotism egotistic either eject elaborate elastic elated elbow eldercare elderly eldest electable election elective elephant elevate elevating elevation elevator eleven elf eligible eligibly eliminate elite elitism elixir elk ellipse elliptic elm elongated elope eloquence eloquent elsewhere elude elusive elves email embargo embark embassy embattled embellish ember embezzle emblaze emblem embody embolism emboss embroider emcee emerald emergency emission emit emote emoticon emotion empathic empathy emperor emphases emphasis emphasize emphatic empirical employed employee employer emporium empower emptier emptiness empty emu enable enactment enamel enchanted enchilada encircle enclose enclosure encode encore encounter encourage encroach encrust encrypt endanger endeared endearing ended ending endless endnote endocrine endorphin endorse endowment endpoint endurable endurance enduring energetic energize energy enforced enforcer engaged engaging engine engorge engraved engraver engraving engross engulf enhance enigmatic enjoyable enjoyably enjoyer enjoying enjoyment enlarged enlarging enlighten enlisted enquirer enrage enrich enroll enslave ensnare ensure entail entangled entering entertain enticing entire entitle entity entomb entourage entrap entree entrench entrust entryway entwine enunciate envelope enviable enviably envious envision envoy envy enzyme epic epidemic epidermal epidermis epidural epilepsy epileptic epilogue epiphany episode equal equate equation equator equinox equipment equity equivocal eradicate erasable erased eraser erasure ergonomic errand errant erratic error erupt escalate escalator escapable escapade escapist escargot eskimo esophagus espionage espresso esquire essay essence essential establish estate esteemed estimate estimator estranged estrogen etching eternal eternity ethanol ether ethically ethics euphemism evacuate evacuee evade evaluate evaluator evaporate evasion evasive even everglade evergreen everybody everyday everyone evict evidence evident evil evoke evolution evolve exact exalted example excavate excavator exceeding exception excess exchange excitable exciting exclaim exclude excluding exclusion exclusive excretion excretory excursion excusable excusably excuse exemplary exemplify exemption exerciser exert exes exfoliate exhale exhaust exhume exile existing exit exodus exonerate exorcism exorcist expand expanse expansion expansive expectant expedited expediter expel expend expenses expensive expert expire expiring explain expletive explicit explode exploit explore exploring exponent exporter exposable expose exposure express expulsion exquisite extended extending extent extenuate exterior external extinct extortion extradite extras extrovert extrude extruding exuberant fable fabric fabulous facebook facecloth facedown faceless facelift faceplate faceted facial facility facing facsimile faction factoid factor factsheet factual faculty fade fading failing falcon fall false falsify fame familiar family famine famished fanatic fancied fanciness fancy fanfare fang fanning fantasize fantastic fantasy fascism fastball faster fasting fastness faucet favorable favorably favored favoring favorite fax feast federal fedora feeble feed feel feisty feline felt-tip feminine feminism feminist feminize femur fence fencing fender ferment fernlike ferocious ferocity ferret ferris ferry fervor fester festival festive festivity fetal fetch fever fiber fiction fiddle fiddling fidelity fidgeting fidgety fifteen fifth fiftieth fifty figment figure figurine filing filled filler filling film filter filth filtrate finale finalist finalize finally finance financial finch fineness finer finicky finished finisher finishing finite finless finlike fiscally fit five flaccid flagman flagpole flagship flagstick flagstone flail flakily flaky flame flammable flanked flanking flannels flap flaring flashback flashbulb flashcard flashily flashing flashy flask flatbed flatfoot flatly flatness flatten flattered flatterer flattery flattop flatware flatworm flavored flavorful flavoring flaxseed fled fleshed fleshy flick flier flight flinch fling flint flip flirt float flock flogging flop floral florist floss flounder flyable flyaway flyer flying flyover flypaper foam foe fog foil folic folk follicle follow fondling fondly fondness fondue font food fool footage football footbath footboard footer footgear foothill foothold footing footless footman footnote footpad footpath footprint footrest footsie footsore footwear footwork fossil foster founder founding fountain fox foyer fraction fracture fragile fragility fragment fragrance fragrant frail frame framing frantic fraternal frayed fraying frays freckled freckles freebase freebee freebie freedom freefall freehand freeing freeload freely freemason freeness freestyle freeware freeway freewill freezable freezing freight french frenzied frenzy frequency frequent fresh fretful fretted friction friday fridge fried friend frighten frightful frigidity frigidly frill fringe frisbee frisk fritter frivolous frolic from front frostbite frosted frostily frosting frostlike frosty froth frown frozen fructose frugality frugally fruit frustrate frying gab gaffe gag gainfully gaining gains gala gallantly galleria gallery galley gallon gallows gallstone galore galvanize gambling game gaming gamma gander gangly gangrene gangway gap garage garbage garden gargle garland garlic garment garnet garnish garter gas gatherer gathering gating gauging gauntlet gauze gave gawk gazing gear gecko geek geiger gem gender generic generous genetics genre gentile gentleman gently gents geography geologic geologist geology geometric geometry geranium gerbil geriatric germicide germinate germless germproof gestate gestation gesture getaway getting getup giant gibberish giblet giddily giddiness giddy gift gigabyte gigahertz gigantic giggle giggling giggly gigolo gilled gills gimmick girdle giveaway given giver giving gizmo gizzard glacial glacier glade gladiator gladly glamorous glamour glance glancing glandular glare glaring glass glaucoma glazing gleaming gleeful glider gliding glimmer glimpse glisten glitch glitter glitzy gloater gloating gloomily gloomy glorified glorifier glorify glorious glory gloss glove glowing glowworm glucose glue gluten glutinous glutton gnarly gnat goal goatskin goes goggles going goldfish goldmine goldsmith golf goliath gonad gondola gone gong good gooey goofball goofiness goofy google goon gopher gore gorged gorgeous gory gosling gossip gothic gotten gout gown grab graceful graceless gracious gradation graded grader gradient grading gradually graduate graffiti grafted grafting grain granddad grandkid grandly grandma grandpa grandson granite granny granola grant granular grape graph grapple grappling grasp grass gratified gratify grating gratitude gratuity gravel graveness graves graveyard gravitate gravity gravy gray grazing greasily greedily greedless greedy green greeter greeting grew greyhound grid grief grievance grieving grievous grill grimace grimacing grime griminess grimy grinch grinning grip gristle grit groggily groggy groin groom groove grooving groovy grope ground grouped grout grove grower growing growl grub grudge grudging grueling gruffly grumble grumbling grumbly grumpily grunge grunt guacamole guidable guidance guide guiding guileless guise gulf gullible gully gulp gumball gumdrop gumminess gumming gummy gurgle gurgling guru gush gusto gusty gutless guts gutter guy guzzler gyration habitable habitant habitat habitual hacked hacker hacking hacksaw had haggler haiku half halogen halt halved halves hamburger hamlet hammock hamper hamster hamstring handbag handball handbook handbrake handcart handclap handclasp handcraft handcuff handed handful handgrip handgun handheld handiness handiwork handlebar handled handler handling handmade handoff handpick handprint handrail handsaw handset handsfree handshake handstand handwash handwork handwoven handwrite handyman hangnail hangout hangover hangup hankering hankie hanky haphazard happening happier happiest happily happiness happy harbor hardcopy hardcore hardcover harddisk hardened hardener hardening hardhat hardhead hardiness hardly hardness hardship hardware hardwired hardwood hardy harmful harmless harmonica harmonics harmonize harmony harness harpist harsh harvest hash hassle haste hastily hastiness hasty hatbox hatchback hatchery hatchet hatching hatchling hate hatless hatred haunt haven hazard hazelnut hazily haziness hazing hazy headache headband headboard headcount headdress headed header headfirst headgear heading headlamp headless headlock headphone headpiece headrest headroom headscarf headset headsman headstand headstone headway headwear heap heat heave heavily heaviness heaving hedge hedging heftiness hefty helium helmet helper helpful helping helpless helpline hemlock hemstitch hence henchman henna herald herbal herbicide herbs heritage hermit heroics heroism herring herself hertz hesitancy hesitant hesitate hexagon hexagram hubcap huddle huddling huff hug hula hulk hull human humble humbling humbly humid humiliate humility humming hummus humongous humorist humorless humorous humpback humped humvee hunchback hundredth hunger hungrily hungry hunk hunter hunting huntress huntsman hurdle hurled hurler hurling hurray hurricane hurried hurry hurt husband hush husked huskiness hut hybrid hydrant hydrated hydration hydrogen hydroxide hyperlink hypertext hyphen hypnoses hypnosis hypnotic hypnotism hypnotist hypnotize hypocrisy hypocrite ibuprofen ice iciness icing icky icon icy idealism idealist idealize ideally idealness identical identify identity ideology idiocy idiom idly igloo ignition ignore iguana illicitly illusion illusive image imaginary imagines imaging imbecile imitate imitation immature immerse immersion imminent immobile immodest immorally immortal immovable immovably immunity immunize impaired impale impart impatient impeach impeding impending imperfect imperial impish implant implement implicate implicit implode implosion implosive imply impolite important importer impose imposing impotence impotency impotent impound imprecise imprint imprison impromptu improper improve improving improvise imprudent impulse impulsive impure impurity iodine iodize ion ipad iphone ipod irate irk iron irregular irrigate irritable irritably irritant irritate islamic islamist isolated isolating isolation isotope issue issuing italicize italics item itinerary itunes ivory ivy jab jackal jacket jackknife jackpot jailbird jailbreak jailer jailhouse jalapeno jam janitor january jargon jarring jasmine jaundice jaunt java jawed jawless jawline jaws jaybird jaywalker jazz jeep jeeringly jellied jelly jersey jester jet jiffy jigsaw jimmy jingle jingling jinx jitters jittery job jockey jockstrap jogger jogging john joining jokester jokingly jolliness jolly jolt jot jovial joyfully joylessly joyous joyride joystick jubilance jubilant judge judgingly judicial judiciary judo juggle juggling jugular juice juiciness juicy jujitsu jukebox july jumble jumbo jump junction juncture june junior juniper junkie junkman junkyard jurist juror jury justice justifier justify justly justness juvenile kabob kangaroo karaoke karate karma kebab keenly keenness keep keg kelp kennel kept kerchief kerosene kettle kick kiln kilobyte kilogram kilometer kilowatt kilt kimono kindle kindling kindly kindness kindred kinetic kinfolk king kinship kinsman kinswoman kissable kisser kissing kitchen kite kitten kitty kiwi kleenex knapsack knee knelt knickers knoll koala kooky kosher krypton kudos kung labored laborer laboring laborious labrador ladder ladies ladle ladybug ladylike lagged lagging lagoon lair lake lance landed landfall landfill landing landlady landless landline landlord landmark landmass landmine landowner landscape landside landslide language lankiness lanky lantern lapdog lapel lapped lapping laptop lard large lark lash lasso last latch late lather latitude latrine latter latticed launch launder laundry laurel lavender lavish laxative lazily laziness lazy lecturer left legacy legal legend legged leggings legible legibly legislate lego legroom legume legwarmer legwork lemon lend length lens lent leotard lesser letdown lethargic lethargy letter lettuce level leverage levers levitate levitator liability liable liberty librarian library licking licorice lid life lifter lifting liftoff ligament likely likeness likewise liking lilac lilly lily limb limeade limelight limes limit limping limpness line lingo linguini linguist lining linked linoleum linseed lint lion lip liquefy liqueur liquid lisp list litigate litigator litmus litter little livable lived lively liver livestock lividly living lizard lubricant lubricate lucid luckily luckiness luckless lucrative ludicrous lugged lukewarm lullaby lumber luminance luminous lumpiness lumping lumpish lunacy lunar lunchbox luncheon lunchroom lunchtime lung lurch lure luridness lurk lushly lushness luster lustfully lustily lustiness lustrous lusty luxurious luxury lying lyrically lyricism lyricist lyrics macarena macaroni macaw mace machine machinist magazine magenta maggot magical magician magma magnesium magnetic magnetism magnetize magnifier magnify magnitude magnolia mahogany maimed majestic majesty majorette majority makeover maker makeshift making malformed malt mama mammal mammary mammogram manager managing manatee mandarin mandate mandatory mandolin manger mangle mango mangy manhandle manhole manhood manhunt manicotti manicure manifesto manila mankind manlike manliness manly manmade manned mannish manor manpower mantis mantra manual many map marathon marauding marbled marbles marbling march mardi margarine margarita margin marigold marina marine marital maritime marlin marmalade maroon married marrow marry marshland marshy marsupial marvelous marxism mascot masculine mashed mashing massager masses massive mastiff matador matchbook matchbox matcher matching matchless material maternal maternity math mating matriarch matrimony matrix matron matted matter maturely maturing maturity mauve maverick maximize maximum maybe mayday mayflower moaner moaning mobile mobility mobilize mobster mocha mocker mockup modified modify modular modulator module moisten moistness moisture molar molasses mold molecular molecule molehill mollusk mom monastery monday monetary monetize moneybags moneyless moneywise mongoose mongrel monitor monkhood monogamy monogram monologue monopoly monorail monotone monotype monoxide monsieur monsoon monstrous monthly monument moocher moodiness moody mooing moonbeam mooned moonlight moonlike moonlit moonrise moonscape moonshine moonstone moonwalk mop morale morality morally morbidity morbidly morphine morphing morse mortality mortally mortician mortified mortify mortuary mosaic mossy most mothball mothproof motion motivate motivator motive motocross motor motto mountable mountain mounted mounting mourner mournful mouse mousiness moustache mousy mouth movable move movie moving mower mowing much muck mud mug mulberry mulch mule mulled mullets multiple multiply multitask multitude mumble mumbling mumbo mummified mummify mummy mumps munchkin mundane municipal muppet mural murkiness murky murmuring muscular museum mushily mushiness mushroom mushy music musket muskiness musky mustang mustard muster mustiness musty mutable mutate mutation mute mutilated mutilator mutiny mutt mutual muzzle myself myspace mystified mystify myth nacho nag nail name naming nanny nanometer nape napkin napped napping nappy narrow nastily nastiness national native nativity natural nature naturist nautical navigate navigator navy nearby nearest nearly nearness neatly neatness nebula nebulizer nectar negate negation negative neglector negligee negligent negotiate nemeses nemesis neon nephew nerd nervous nervy nest net neurology neuron neurosis neurotic neuter neutron never next nibble nickname nicotine niece nifty nimble nimbly nineteen ninetieth ninja nintendo ninth nuclear nuclei nucleus nugget nullify number numbing numbly numbness numeral numerate numerator numeric numerous nuptials nursery nursing nurture nutcase nutlike nutmeg nutrient nutshell nuttiness nutty nuzzle nylon oaf oak oasis oat obedience obedient obituary object obligate obliged oblivion oblivious oblong obnoxious oboe obscure obscurity observant observer observing obsessed obsession obsessive obsolete obstacle obstinate obstruct obtain obtrusive obtuse obvious occultist occupancy occupant occupier occupy ocean ocelot octagon octane october octopus ogle oil oink ointment okay old olive olympics omega omen ominous omission omit omnivore onboard oncoming ongoing onion online onlooker only onscreen onset onshore onslaught onstage onto onward onyx oops ooze oozy opacity opal open operable operate operating operation operative operator opium opossum opponent oppose opposing opposite oppressed oppressor opt opulently osmosis other otter ouch ought ounce outage outback outbid outboard outbound outbreak outburst outcast outclass outcome outdated outdoors outer outfield outfit outflank outgoing outgrow outhouse outing outlast outlet outline outlook outlying outmatch outmost outnumber outplayed outpost outpour output outrage outrank outreach outright outscore outsell outshine outshoot outsider outskirts outsmart outsource outspoken outtakes outthink outward outweigh outwit oval ovary oven overact overall overarch overbid overbill overbite overblown overboard overbook overbuilt overcast overcoat overcome overcook overcrowd overdraft overdrawn overdress overdrive overdue overeager overeater overexert overfed overfeed overfill overflow overfull overgrown overhand overhang overhaul overhead overhear overheat overhung overjoyed overkill overlabor overlaid overlap overlay overload overlook overlord overlying overnight overpass overpay overplant overplay overpower overprice overrate overreach overreact override overripe overrule overrun overshoot overshot oversight oversized oversleep oversold overspend overstate overstay overstep overstock overstuff oversweet overtake overthrow overtime overtly overtone overture overturn overuse overvalue overview overwrite owl oxford oxidant oxidation oxidize oxidizing oxygen oxymoron oyster ozone paced pacemaker pacific pacifier pacifism pacifist pacify padded padding paddle paddling padlock pagan pager paging pajamas palace palatable palm palpable palpitate paltry pampered pamperer pampers pamphlet panama pancake pancreas panda pandemic pang panhandle panic panning panorama panoramic panther pantomime pantry pants pantyhose paparazzi papaya paper paprika papyrus parabola parachute parade paradox paragraph parakeet paralegal paralyses paralysis paralyze paramedic parameter paramount parasail parasite parasitic parcel parched parchment pardon parish parka parking parkway parlor parmesan parole parrot parsley parsnip partake parted parting partition partly partner partridge party passable passably passage passcode passenger passerby passing passion passive passivism passover passport password pasta pasted pastel pastime pastor pastrami pasture pasty patchwork patchy paternal paternity path patience patient patio patriarch patriot patrol patronage patronize pauper pavement paver pavestone pavilion paving pawing payable payback paycheck payday payee payer paying payment payphone payroll pebble pebbly pecan pectin peculiar peddling pediatric pedicure pedigree pedometer pegboard pelican pellet pelt pelvis penalize penalty pencil pendant pending penholder penknife pennant penniless penny penpal pension pentagon pentagram pep perceive percent perch percolate perennial perfected perfectly perfume periscope perish perjurer perjury perkiness perky perm peroxide perpetual perplexed persecute persevere persuaded persuader pesky peso pessimism pessimist pester pesticide petal petite petition petri petroleum petted petticoat pettiness petty petunia phantom phobia phoenix phonebook phoney phonics phoniness phony phosphate photo phrase phrasing placard placate placidly plank planner plant plasma plaster plastic plated platform plating platinum platonic platter platypus plausible plausibly playable playback player playful playgroup playhouse playing playlist playmaker playmate playoff playpen playroom playset plaything playtime plaza pleading pleat pledge plentiful plenty plethora plexiglas pliable plod plop plot plow ploy pluck plug plunder plunging plural plus plutonium plywood poach pod poem poet pogo pointed pointer pointing pointless pointy poise poison poker poking polar police policy polio polish politely polka polo polyester polygon polygraph polymer poncho pond pony popcorn pope poplar popper poppy popsicle populace popular populate porcupine pork porous porridge portable portal portfolio porthole portion portly portside poser posh posing possible possibly possum postage postal postbox postcard posted poster posting postnasal posture postwar pouch pounce pouncing pound pouring pout powdered powdering powdery power powwow pox praising prance prancing pranker prankish prankster prayer praying preacher preaching preachy preamble precinct precise precision precook precut predator predefine predict preface prefix preflight preformed pregame pregnancy pregnant preheated prelaunch prelaw prelude premiere premises premium prenatal preoccupy preorder prepaid prepay preplan preppy preschool prescribe preseason preset preshow president presoak press presume presuming preteen pretended pretender pretense pretext pretty pretzel prevail prevalent prevent preview previous prewar prewashed prideful pried primal primarily primary primate primer primp princess print prior prism prison prissy pristine privacy private privatize prize proactive probable probably probation probe probing probiotic problem procedure process proclaim procreate procurer prodigal prodigy produce product profane profanity professed professor profile profound profusely progeny prognosis program progress projector prologue prolonged promenade prominent promoter promotion prompter promptly prone prong pronounce pronto proofing proofread proofs propeller properly property proponent proposal propose props prorate protector protegee proton prototype protozoan protract protrude proud provable proved proven provided provider providing province proving provoke provoking provolone prowess prowler prowling proximity proxy prozac prude prudishly prune pruning pry psychic public publisher pucker pueblo pug pull pulmonary pulp pulsate pulse pulverize puma pumice pummel punch punctual punctuate punctured pungent punisher punk pupil puppet puppy purchase pureblood purebred purely pureness purgatory purge purging purifier purify purist puritan purity purple purplish purposely purr purse pursuable pursuant pursuit purveyor pushcart pushchair pusher pushiness pushing pushover pushpin pushup pushy putdown putt puzzle puzzling pyramid pyromania python quack quadrant quail quaintly quake quaking qualified qualifier qualify quality qualm quantum quarrel quarry quartered quarterly quarters quartet quench query quicken quickly quickness quicksand quickstep quiet quill quilt quintet quintuple quirk quit quiver quizzical quotable quotation quote rabid race racing racism rack racoon radar radial radiance radiantly radiated radiation radiator radio radish raffle raft rage ragged raging ragweed raider railcar railing railroad railway raisin rake raking rally ramble rambling ramp ramrod ranch rancidity random ranged ranger ranging ranked ranking ransack ranting rants rare rarity rascal rash rasping ravage raven ravine raving ravioli ravishing reabsorb reach reacquire reaction reactive reactor reaffirm ream reanalyze reappear reapply reappoint reapprove rearrange rearview reason reassign reassure reattach reawake rebalance rebate rebel rebirth reboot reborn rebound rebuff rebuild rebuilt reburial rebuttal recall recant recapture recast recede recent recess recharger recipient recital recite reckless reclaim recliner reclining recluse reclusive recognize recoil recollect recolor reconcile reconfirm reconvene recopy record recount recoup recovery recreate rectal rectangle rectified rectify recycled recycler recycling reemerge reenact reenter reentry reexamine referable referee reference refill refinance refined refinery refining refinish reflected reflector reflex reflux refocus refold reforest reformat reformed reformer reformist refract refrain refreeze refresh refried refueling refund refurbish refurnish refusal refuse refusing refutable refute regain regalia regally reggae regime region register registrar registry regress regretful regroup regular regulate regulator rehab reheat rehire rehydrate reimburse reissue reiterate rejoice rejoicing rejoin rekindle relapse relapsing relatable related relation relative relax relay relearn release relenting reliable reliably reliance reliant relic relieve relieving relight relish relive reload relocate relock reluctant rely remake remark remarry rematch remedial remedy remember reminder remindful remission remix remnant remodeler remold remorse remote removable removal removed remover removing rename renderer rendering rendition renegade renewable renewably renewal renewed renounce renovate renovator rentable rental rented renter reoccupy reoccur reopen reorder repackage repacking repaint repair repave repaying repayment repeal repeated repeater repent rephrase replace replay replica reply reporter repose repossess repost repressed reprimand reprint reprise reproach reprocess reproduce reprogram reps reptile reptilian repugnant repulsion repulsive repurpose reputable reputably request require requisite reroute rerun resale resample rescuer reseal research reselect reseller resemble resend resent reset reshape reshoot reshuffle residence residency resident residual residue resigned resilient resistant resisting resize resolute resolved resonant resonate resort resource respect resubmit result resume resupply resurface resurrect retail retainer retaining retake retaliate retention rethink retinal retired retiree retiring retold retool retorted retouch retrace retract retrain retread retreat retrial retrieval retriever retry return retying retype reunion reunite reusable reuse reveal reveler revenge revenue reverb revered reverence reverend reversal reverse reversing reversion revert revisable revise revision revisit revivable revival reviver reviving revocable revoke revolt revolver revolving reward rewash rewind rewire reword rework rewrap rewrite rhyme ribbon ribcage rice riches richly richness rickety ricotta riddance ridden ride riding rifling rift rigging rigid rigor rimless rimmed rind rink rinse rinsing riot ripcord ripeness ripening ripping ripple rippling riptide rise rising risk risotto ritalin ritzy rival riverbank riverbed riverboat riverside riveter riveting roamer roaming roast robbing robe robin robotics robust rockband rocker rocket rockfish rockiness rocking rocklike rockslide rockstar rocky rogue roman romp rope roping roster rosy rotten rotting rotunda roulette rounding roundish roundness roundup roundworm routine routing rover roving royal rubbed rubber rubbing rubble rubdown ruby ruckus rudder rug ruined rule rumble rumbling rummage rumor runaround rundown runner running runny runt runway rupture rural ruse rush rust rut sabbath sabotage sacrament sacred sacrifice sadden saddlebag saddled saddling sadly sadness safari safeguard safehouse safely safeness saffron saga sage sagging saggy said saint sake salad salami salaried salary saline salon saloon salsa salt salutary salute salvage salvaging salvation same sample sampling sanction sanctity sanctuary sandal sandbag sandbank sandbar sandblast sandbox sanded sandfish sanding sandlot sandpaper sandpit sandstone sandstorm sandworm sandy sanitary sanitizer sank santa sapling sappiness sappy sarcasm sarcastic sardine sash sasquatch sassy satchel satiable satin satirical satisfied satisfy saturate saturday sauciness saucy sauna savage savanna saved savings savior savor saxophone say scabbed scabby scalded scalding scale scaling scallion scallop scalping scam scandal scanner scanning scant scapegoat scarce scarcity scarecrow scared scarf scarily scariness scarring scary scavenger scenic schedule schematic scheme scheming schilling schnapps scholar science scientist scion scoff scolding scone scoop scooter scope scorch scorebook scorecard scored scoreless scorer scoring scorn scorpion scotch scoundrel scoured scouring scouting scouts scowling scrabble scraggly scrambled scrambler scrap scratch scrawny screen scribble scribe scribing scrimmage script scroll scrooge scrounger scrubbed scrubber scruffy scrunch scrutiny scuba scuff sculptor sculpture scurvy scuttle secluded secluding seclusion second secrecy secret sectional sector secular securely security sedan sedate sedation sedative sediment seduce seducing segment seismic seizing seldom selected selection selective selector self seltzer semantic semester semicolon semifinal seminar semisoft semisweet senate senator send senior senorita sensation sensitive sensitize sensually sensuous sepia september septic septum sequel sequence sequester series sermon serotonin serpent serrated serve service serving sesame sessions setback setting settle settling setup sevenfold seventeen seventh seventy severity shabby shack shaded shadily shadiness shading shadow shady shaft shakable shakily shakiness shaking shaky shale shallot shallow shame shampoo shamrock shank shanty shape shaping share sharpener sharper sharpie sharply sharpness shawl sheath shed sheep sheet shelf shell shelter shelve shelving sherry shield shifter shifting shiftless shifty shimmer shimmy shindig shine shingle shininess shining shiny ship shirt shivering shock shone shoplift shopper shopping shoptalk shore shortage shortcake shortcut shorten shorter shorthand shortlist shortly shortness shorts shortwave shorty shout shove showbiz showcase showdown shower showgirl showing showman shown showoff showpiece showplace showroom showy shrank shrapnel shredder shredding shrewdly shriek shrill shrimp shrine shrink shrivel shrouded shrubbery shrubs shrug shrunk shucking shudder shuffle shuffling shun shush shut shy siamese siberian sibling siding sierra siesta sift sighing silenced silencer silent silica silicon silk silliness silly silo silt silver similarly simile simmering simple simplify simply sincere sincerity singer singing single singular sinister sinless sinner sinuous sip siren sister sitcom sitter sitting situated situation sixfold sixteen sixth sixties sixtieth sixtyfold sizable sizably size sizing sizzle sizzling skater skating skedaddle skeletal skeleton skeptic sketch skewed skewer skid skied skier skies skiing skilled skillet skillful skimmed skimmer skimming skimpily skincare skinhead skinless skinning skinny skintight skipper skipping skirmish skirt skittle skydiver skylight skyline skype skyrocket skyward slab slacked slacker slacking slackness slacks slain slam slander slang slapping slapstick slashed slashing slate slather slaw sled sleek sleep sleet sleeve slept sliceable sliced slicer slicing slick slider slideshow sliding slighted slighting slightly slimness slimy slinging slingshot slinky slip slit sliver slobbery slogan sloped sloping sloppily sloppy slot slouching slouchy sludge slug slum slurp slush sly small smartly smartness smasher smashing smashup smell smelting smile smilingly smirk smite smith smitten smock smog smoked smokeless smokiness smoking smoky smolder smooth smother smudge smudgy smuggler smuggling smugly smugness snack snagged snaking snap snare snarl snazzy sneak sneer sneeze sneezing snide sniff snippet snipping snitch snooper snooze snore snoring snorkel snort snout snowbird snowboard snowbound snowcap snowdrift snowdrop snowfall snowfield snowflake snowiness snowless snowman snowplow snowshoe snowstorm snowsuit snowy snub snuff snuggle snugly snugness speak spearfish spearhead spearman spearmint species specimen specked speckled specks spectacle spectator spectrum speculate speech speed spellbind speller spelling spendable spender spending spent spew sphere spherical sphinx spider spied spiffy spill spilt spinach spinal spindle spinner spinning spinout spinster spiny spiral spirited spiritism spirits spiritual splashed splashing splashy splatter spleen splendid splendor splice splicing splinter splotchy splurge spoilage spoiled spoiler spoiling spoils spoken spokesman sponge spongy sponsor spoof spookily spooky spool spoon spore sporting sports sporty spotless spotlight spotted spotter spotting spotty spousal spouse spout sprain sprang sprawl spray spree sprig spring sprinkled sprinkler sprint sprite sprout spruce sprung spry spud spur sputter spyglass squabble squad squall squander squash squatted squatter squatting squeak squealer squealing squeamish squeegee squeeze squeezing squid squiggle squiggly squint squire squirt squishier squishy stability stabilize stable stack stadium staff stage staging stagnant stagnate stainable stained staining stainless stalemate staleness stalling stallion stamina stammer stamp stand stank staple stapling starboard starch stardom stardust starfish stargazer staring stark starless starlet starlight starlit starring starry starship starter starting startle startling startup starved starving stash state static statistic statue stature status statute statutory staunch stays steadfast steadier steadily steadying steam steed steep steerable steering steersman stegosaur stellar stem stench stencil step stereo sterile sterility sterilize sterling sternness sternum stew stick stiffen stiffly stiffness stifle stifling stillness stilt stimulant stimulate stimuli stimulus stinger stingily stinging stingray stingy stinking stinky stipend stipulate stir stitch stock stoic stoke stole stomp stonewall stoneware stonework stoning stony stood stooge stool stoop stoplight stoppable stoppage stopped stopper stopping stopwatch storable storage storeroom storewide storm stout stove stowaway stowing straddle straggler strained strainer straining strangely stranger strangle strategic strategy stratus straw stray streak stream street strength strenuous strep stress stretch strewn stricken strict stride strife strike striking strive striving strobe strode stroller strongbox strongly strongman struck structure strudel struggle strum strung strut stubbed stubble stubbly stubborn stucco stuck student studied studio study stuffed stuffing stuffy stumble stumbling stump stung stunned stunner stunning stunt stupor sturdily sturdy styling stylishly stylist stylized stylus suave subarctic subatomic subdivide subdued subduing subfloor subgroup subheader subject sublease sublet sublevel sublime submarine submerge submersed submitter subpanel subpar subplot subprime subscribe subscript subsector subside subsiding subsidize subsidy subsoil subsonic substance subsystem subtext subtitle subtly subtotal subtract subtype suburb subway subwoofer subzero succulent such suction sudden sudoku suds sufferer suffering suffice suffix suffocate suffrage sugar suggest suing suitable suitably suitcase suitor sulfate sulfide sulfite sulfur sulk sullen sulphate sulphuric sultry superbowl superglue superhero superior superjet superman supermom supernova supervise supper supplier supply support supremacy supreme surcharge surely sureness surface surfacing surfboard surfer surgery surgical surging surname surpass surplus surprise surreal surrender surrogate surround survey survival survive surviving survivor sushi suspect suspend suspense sustained sustainer swab swaddling swagger swampland swan swapping swarm sway swear sweat sweep swell swept swerve swifter swiftly swiftness swimmable swimmer swimming swimsuit swimwear swinger swinging swipe swirl switch swivel swizzle swooned swoop swoosh swore sworn swung sycamore sympathy symphonic symphony symptom synapse syndrome synergy synopses synopsis synthesis synthetic syrup system t-shirt tabasco tabby tableful tables tablet tableware tabloid tackiness tacking tackle tackling tacky taco tactful tactical tactics tactile tactless tadpole taekwondo tag tainted take taking talcum talisman tall talon tamale tameness tamer tamper tank tanned tannery tanning tantrum tapeless tapered tapering tapestry tapioca tapping taps tarantula target tarmac tarnish tarot tartar tartly tartness task tassel taste tastiness tasting tasty tattered tattle tattling tattoo taunt tavern thank that thaw theater theatrics thee theft theme theology theorize thermal thermos thesaurus these thesis thespian thicken thicket thickness thieving thievish thigh thimble thing think thinly thinner thinness thinning thirstily thirsting thirsty thirteen thirty thong thorn those thousand thrash thread threaten threefold thrift thrill thrive thriving throat throbbing throng throttle throwaway throwback thrower throwing thud thumb thumping thursday thus thwarting thyself tiara tibia tidal tidbit tidiness tidings tidy tiger tighten tightly tightness tightrope tightwad tigress tile tiling till tilt timid timing timothy tinderbox tinfoil tingle tingling tingly tinker tinkling tinsel tinsmith tint tinwork tiny tipoff tipped tipper tipping tiptoeing tiptop tiring tissue trace tracing track traction tractor trade trading tradition traffic tragedy trailing trailside train traitor trance tranquil transfer transform translate transpire transport transpose trapdoor trapeze trapezoid trapped trapper trapping traps trash travel traverse travesty tray treachery treading treadmill treason treat treble tree trekker tremble trembling tremor trench trend trespass triage trial triangle tribesman tribunal tribune tributary tribute triceps trickery trickily tricking trickle trickster tricky tricolor tricycle trident tried trifle trifocals trillion trilogy trimester trimmer trimming trimness trinity trio tripod tripping triumph trivial trodden trolling trombone trophy tropical tropics trouble troubling trough trousers trout trowel truce truck truffle trump trunks trustable trustee trustful trusting trustless truth try tubby tubeless tubular tucking tuesday tug tuition tulip tumble tumbling tummy turban turbine turbofan turbojet turbulent turf turkey turmoil turret turtle tusk tutor tutu tux tweak tweed tweet tweezers twelve twentieth twenty twerp twice twiddle twiddling twig twilight twine twins twirl twistable twisted twister twisting twisty twitch twitter tycoon tying tyke udder ultimate ultimatum ultra umbilical umbrella umpire unabashed unable unadorned unadvised unafraid unaired unaligned unaltered unarmored unashamed unaudited unawake unaware unbaked unbalance unbeaten unbend unbent unbiased unbitten unblended unblessed unblock unbolted unbounded unboxed unbraided unbridle unbroken unbuckled unbundle unburned unbutton uncanny uncapped uncaring uncertain unchain unchanged uncharted uncheck uncivil unclad unclaimed unclamped unclasp uncle unclip uncloak unclog unclothed uncoated uncoiled uncolored uncombed uncommon uncooked uncork uncorrupt uncounted uncouple uncouth uncover uncross uncrown uncrushed uncured uncurious uncurled uncut undamaged undated undaunted undead undecided undefined underage underarm undercoat undercook undercut underdog underdone underfed underfeed underfoot undergo undergrad underhand underline underling undermine undermost underpaid underpass underpay underrate undertake undertone undertook undertow underuse underwear underwent underwire undesired undiluted undivided undocked undoing undone undrafted undress undrilled undusted undying unearned unearth unease uneasily uneasy uneatable uneaten unedited unelected unending unengaged unenvied unequal unethical uneven unexpired unexposed unfailing unfair unfasten unfazed unfeeling unfiled unfilled unfitted unfitting unfixable unfixed unflawed unfocused unfold unfounded unframed unfreeze unfrosted unfrozen unfunded unglazed ungloved unglue ungodly ungraded ungreased unguarded unguided unhappily unhappy unharmed unhealthy unheard unhearing unheated unhelpful unhidden unhinge unhitched unholy unhook unicorn unicycle unified unifier uniformed uniformly unify unimpeded uninjured uninstall uninsured uninvited union uniquely unisexual unison unissued unit universal universe unjustly unkempt unkind unknotted unknowing unknown unlaced unlatch unlawful unleaded unlearned unleash unless unleveled unlighted unlikable unlimited unlined unlinked unlisted unlit unlivable unloaded unloader unlocked unlocking unlovable unloved unlovely unloving unluckily unlucky unmade unmanaged unmanned unmapped unmarked unmasked unmasking unmatched unmindful unmixable unmixed unmolded unmoral unmovable unmoved unmoving unnamable unnamed unnatural unneeded unnerve unnerving unnoticed unopened unopposed unpack unpadded unpaid unpainted unpaired unpaved unpeeled unpicked unpiloted unpinned unplanned unplanted unpleased unpledged unplowed unplug unpopular unproven unquote unranked unrated unraveled unreached unread unreal unreeling unrefined unrelated unrented unrest unretired unrevised unrigged unripe unrivaled unroasted unrobed unroll unruffled unruly unrushed unsaddle unsafe unsaid unsalted unsaved unsavory unscathed unscented unscrew unsealed unseated unsecured unseeing unseemly unseen unselect unselfish unsent unsettled unshackle unshaken unshaved unshaven unsheathe unshipped unsightly unsigned unskilled unsliced unsmooth unsnap unsocial unsoiled unsold unsolved unsorted unspoiled unspoken unstable unstaffed unstamped unsteady unsterile unstirred unstitch unstopped unstuck unstuffed unstylish unsubtle unsubtly unsuited unsure unsworn untagged untainted untaken untamed untangled untapped untaxed unthawed unthread untidy untie until untimed untimely untitled untoasted untold untouched untracked untrained untreated untried untrimmed untrue untruth unturned untwist untying unusable unused unusual unvalued unvaried unvarying unveiled unveiling unvented unviable unvisited unvocal unwanted unwarlike unwary unwashed unwatched unweave unwed unwelcome unwell unwieldy unwilling unwind unwired unwitting unwomanly unworldly unworn unworried unworthy unwound unwoven unwrapped unwritten unzip upbeat upchuck upcoming upcountry update upfront upgrade upheaval upheld uphill uphold uplifted uplifting upload upon upper upright uprising upriver uproar uproot upscale upside upstage upstairs upstart upstate upstream upstroke upswing uptake uptight uptown upturned upward upwind uranium urban urchin urethane urgency urgent urging urologist urology usable usage useable used uselessly user usher usual utensil utility utilize utmost utopia utter vacancy vacant vacate vacation vagabond vagrancy vagrantly vaguely vagueness valiant valid valium valley valuables value vanilla vanish vanity vanquish vantage vaporizer variable variably varied variety various varmint varnish varsity varying vascular vaseline vastly vastness veal vegan veggie vehicular velcro velocity velvet vendetta vending vendor veneering vengeful venomous ventricle venture venue venus verbalize verbally verbose verdict verify verse version versus vertebrae vertical vertigo very vessel vest veteran veto vexingly viability viable vibes vice vicinity victory video viewable viewer viewing viewless viewpoint vigorous village villain vindicate vineyard vintage violate violation violator violet violin viper viral virtual virtuous virus visa viscosity viscous viselike visible visibly vision visiting visitor visor vista vitality vitalize vitally vitamins vivacious vividly vividness vixen vocalist vocalize vocally vocation voice voicing void volatile volley voltage volumes voter voting voucher vowed vowel voyage wackiness wad wafer waffle waged wager wages waggle wagon wake waking walk walmart walnut walrus waltz wand wannabe wanted wanting wasabi washable washbasin washboard washbowl washcloth washday washed washer washhouse washing washout washroom washstand washtub wasp wasting watch water waviness waving wavy whacking whacky wham wharf wheat whenever whiff whimsical whinny whiny whisking whoever whole whomever whoopee whooping whoops why wick widely widen widget widow width wieldable wielder wife wifi wikipedia wildcard wildcat wilder wildfire wildfowl wildland wildlife wildly wildness willed willfully willing willow willpower wilt wimp wince wincing wind wing winking winner winnings winter wipe wired wireless wiring wiry wisdom wise wish wisplike wispy wistful wizard wobble wobbling wobbly wok wolf wolverine womanhood womankind womanless womanlike womanly womb woof wooing wool woozy word work worried worrier worrisome worry worsening worshiper worst wound woven wow wrangle wrath wreath wreckage wrecker wrecking wrench wriggle wriggly wrinkle wrinkly wrist writing written wrongdoer wronged wrongful wrongly wrongness wrought xbox xerox yahoo yam yanking yapping yard yarn yeah yearbook yearling yearly yearning yeast yelling yelp yen yesterday yiddish yield yin yippee yo-yo yodel yoga yogurt yonder yoyo yummy zap zealous zebra zen zeppelin zero zestfully zesty zigzagged zipfile zipping zippy zips zit zodiac zombie zone zoning zookeeper zoologist zoology zoom'.split(' '); diff --git a/src/main.ts b/src/main.ts index 273960b..be22863 100644 --- a/src/main.ts +++ b/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', diff --git a/src/passphrase-strength.ts b/src/passphrase-strength.ts new file mode 100644 index 0000000..a84448d --- /dev/null +++ b/src/passphrase-strength.ts @@ -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 | null = null; + +async function loadZxcvbn(): Promise { + 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 { + 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 { + 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 { + return (await evaluatePassphrase(passphrase)).score >= MIN_PASSPHRASE_SCORE; +} diff --git a/src/secrets.ts b/src/secrets.ts index 5b277d1..2ebbd3d 100644 --- a/src/secrets.ts +++ b/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 { 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 : {}; 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 { 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 { + 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 { + 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 { const iv = randomBytes(AES_IV_BYTES); const ct = await crypto.subtle.encrypt( @@ -229,22 +392,18 @@ async function aesGcmDecrypt(key: CryptoKey, payload: string): Promise { // ---------- per-mode encrypt/decrypt of a single value ---------- async function encryptValue(envelope: SecretsEnvelope, plaintext: string): Promise { - 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 { 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> { + const plain: Record = {}; + 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, +): Promise { + 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 { + 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 { 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): Promise { 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 { export async function loadAllKeys(plugin: Plugin): Promise> { const envelope = await ensureEnvelope(plugin); - const out: Record = {}; - 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 { 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 = {}; - 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) { diff --git a/src/settings/tab.ts b/src/settings/tab.ts index c642155..8e5c3f4 100644 --- a/src/settings/tab.ts +++ b/src/settings/tab.ts @@ -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)}`); diff --git a/src/ui/passphrase-modal.ts b/src/ui/passphrase-modal.ts index bc6a272..aa8ab9a 100644 --- a/src/ui/passphrase-modal.ts +++ b/src/ui/passphrase-modal.ts @@ -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; } @@ -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 { + 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); diff --git a/styles.css b/styles.css index f3d28db..c235381 100644 --- a/styles.css +++ b/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 {