Merge pull request #1 from WiseGuru/feature/copy-clear-encryption-keys

Decouple encryption-mode switch from key transfer (Copy/Clear)
This commit is contained in:
Maxwell McGuire 2026-06-19 18:56:35 -07:00 committed by GitHub
commit 9fedc23b1d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 411 additions and 113 deletions

View file

@ -168,9 +168,11 @@ Saving flow strips secrets out of `data.json` and writes them to `secrets.json.n
- **`secretStorage`** (default/preferred when available) — Obsidian's first-party `app.secretStorage`, an OS-encrypted shared store. Reached via a narrow cast + round-trip self-test, falls back to passphrase when unavailable (old Obsidian, Linux without a keyring).
- **`passphrase`** (always-available fallback) — WebCrypto AES-GCM-256 keyed by a user passphrase, KDF Argon2id (PBKDF2 fallback), stored in `secrets.json.nosync`. Entropy-gated at `MIN_PASSPHRASE_SCORE` (3/4). Locks/unlocks; the derived key never touches disk.
`ReWritePlugin.encryptionStatus` (`{ mode, locked, configured, secretStorageAvailable }`, refreshed via `refreshEncryptionStatus()`) is read synchronously by UI; pipeline entry points gate on `locked` and call `promptUnlock()`. `minAppVersion` is `1.4.4` (driven by `processFrontMatter`, not `secretStorage`, which is feature-detected).
The two stores **coexist**: the on-disk envelope retains passphrase `kdf`/`verifier`/`keys` even while `secretStorage` is active (a preserved-at-rest snapshot), so `mode` is just the active-store flag. Switching the active method and transferring keys are **decoupled** into three separate operations (a switch never moves or drops keys): `setEncryptionMode` (switch only), `copyKeys` (copy inactive→active, source kept), `clearKeys(mode)` (wipe one method). The settings tab exposes the mode dropdown (pure switch) plus explicit **Copy** and **Clear** buttons (with `ConfirmModal` confirmations + a copied/cleared count Notice). The transfer button is labelled **Copy**, not "Migrate", because the source copy is kept; `unlockPassphraseStore` lets a secret-storage-active user unlock the passphrase snapshot to copy it.
Full detail (envelope schema, KDF params, lock/unlock flow, opportunistic PBKDF2→Argon2id migration, `changeEncryptionMode` / `changePassphrase` / `resetSecrets` recovery, locked-state behavior, and the secrets gotchas) lives in [docs/SECRETS.md](docs/SECRETS.md).
`ReWritePlugin.encryptionStatus` (`{ mode, locked, configured, secretStorageAvailable, passphraseConfigured }`, refreshed via `refreshEncryptionStatus()`) is read synchronously by UI; pipeline entry points gate on `locked` and call `promptUnlock()`. `passphraseConfigured` is mode-independent (kdf+verifier on disk), used by the UI to distinguish switch-to vs create-passphrase and to know whether a snapshot is copyable. `minAppVersion` is `1.4.4` (driven by `processFrontMatter`, not `secretStorage`, which is feature-detected).
Full detail (envelope schema, KDF params, lock/unlock flow, opportunistic PBKDF2→Argon2id KDF upgrade, the `setEncryptionMode` / `copyKeys` / `clearKeys` / `changePassphrase` / `resetSecrets` model, locked-state behavior, and the secrets gotchas) lives in [docs/SECRETS.md](docs/SECRETS.md).
## Templates

View file

@ -4,14 +4,14 @@
[src/secrets.ts](../src/secrets.ts) supports two encryption modes for API keys, file-wide (not per-key). There is no unencrypted at-rest option.
- **`secretStorage`** — Obsidian's first-party `app.secretStorage` (GA 1.11.4): a shared, cross-plugin, OS-encrypted store (Keychain/DPAPI/libsecret under the hood, the same backend Electron `safeStorage` used). **Default/preferred when available.** Values live in Obsidian's store, NOT in `secrets.json.nosync` (the on-disk envelope in this mode is just `{ version, mode: 'secretStorage', keys: {} }` to record the mode). Keys are namespaced with `manifest.id` (`rewrite-voice-notes-profile-desktop-transcription`) because the store is shared across plugins. The namespace separator and every secret id are **dash-joined, not colon-joined**: `setSecret` rejects any id that is not lowercase-alphanumeric + dashes (it throws on a colon, underscore, or uppercase letter), which previously made the round-trip probe below throw on its first write and report `secretStorage` unavailable on every platform. The API is reached via a narrow `SecretStorageLike` interface + cast (the installed typings predate it) that normalizes `getSecret`/`get`, `setSecret`/`set`, `listSecrets`/`list`, and `deleteSecret`/`removeSecret`/`delete` (falling back to `setSecret(id, '')` when no delete exists); each method may be sync or async, so callers `await`. **Availability** is a round-trip self-test (`setSecret` a sentinel, `getSecret`, compare, clean up) cached for the session — a device with no working OS secret store (e.g. Linux without a keyring) reads unavailable and falls back to passphrase. `warmSecretStorage(plugin)` runs the probe once in `onload` BEFORE `getEncryptionStatus`, so the synchronous `defaultEnvelope()` can prefer `secretStorage` on first run. **Sync caveat:** because the store can sync across devices via Obsidian Sync, keys in this mode are not bound to one device (unlike the `.nosync` file); this is surfaced in the settings mode description.
- **`secretStorage`** — Obsidian's first-party `app.secretStorage` (GA 1.11.4): a shared, cross-plugin, OS-encrypted store (Keychain/DPAPI/libsecret under the hood, the same backend Electron `safeStorage` used). **Default/preferred when available.** Active values live in Obsidian's store, NOT in `secrets.json.nosync`. The on-disk envelope records the active mode and **also retains any passphrase material** (`kdf`/`verifier`/`keys`) as a preserved-at-rest snapshot, so switching to secret storage does NOT discard a configured passphrase store (the two stores coexist; see "Mode switch, migrate, and clear" below). With no passphrase store ever configured, the envelope is just `{ version, mode: 'secretStorage', keys: {} }`. Keys are namespaced with `manifest.id` (`rewrite-voice-notes-profile-desktop-transcription`) because the store is shared across plugins. The namespace separator and every secret id are **dash-joined, not colon-joined**: `setSecret` rejects any id that is not lowercase-alphanumeric + dashes (it throws on a colon, underscore, or uppercase letter), which previously made the round-trip probe below throw on its first write and report `secretStorage` unavailable on every platform. The API is reached via a narrow `SecretStorageLike` interface + cast (the installed typings predate it) that normalizes `getSecret`/`get`, `setSecret`/`set`, `listSecrets`/`list`, and `deleteSecret`/`removeSecret`/`delete` (falling back to `setSecret(id, '')` when no delete exists); each method may be sync or async, so callers `await`. **Availability** is a round-trip self-test (`setSecret` a sentinel, `getSecret`, compare, clean up) cached for the session — a device with no working OS secret store (e.g. Linux without a keyring) reads unavailable and falls back to passphrase. `warmSecretStorage(plugin)` runs the probe once in `onload` BEFORE `getEncryptionStatus`, so the synchronous `defaultEnvelope()` can prefer `secretStorage` on first run. **Sync caveat:** because the store can sync across devices via Obsidian Sync, keys in this mode are not bound to one device (unlike the `.nosync` file); this is surfaced in the settings mode description.
- **`passphrase`** — WebCrypto AES-GCM-256 with a key derived from a user-supplied passphrase. The KDF is **Argon2id** by default (via `hash-wasm`, `m = 32 MiB`, `t = 3`, `p = 1`, 32-byte output), with **PBKDF2-SHA256** (600,000 iterations) as the fallback when a device can't run Argon2id. The chosen algorithm + params live in the envelope `kdf` (see below). Each value is stored as `<iv-b64>.<ct-b64>` (12-byte random IV per value) in `secrets.json.nosync`. 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; the always-available fallback when `secretStorage` is not. **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.
History note: an earlier hand-rolled `safeStorage` mode (direct Electron keychain) was REMOVED and superseded by `secretStorage`, which uses the same OS backend but is Obsidian-managed and cross-platform. No users had it (unpublished), so it was dropped outright with no migration; a stale `safeStorage` envelope parses as invalid and resets to default.
`minAppVersion` is `1.4.4`, raised from `1.4.0` because `FileManager.processFrontMatter` (`@since 1.4.4`, used directly in [src/insert.ts](../src/insert.ts) for note-property frontmatter) is NOT feature-detected. `secretStorage` (the other newer API) IS feature-detected at runtime, so it does not drive the minimum; older-than-secretStorage Obsidian still loads the plugin and simply lands on passphrase.
File envelope (`SECRETS_VERSION = 2`). For passphrase mode, the `kdf.algo` discriminant distinguishes PBKDF2 from Argon2id; a legacy envelope with no `algo` but an `iterations` field is read as `pbkdf2`:
File envelope (`SECRETS_VERSION = 2`). The `kdf.algo` discriminant distinguishes PBKDF2 from Argon2id; a legacy envelope with no `algo` but an `iterations` field is read as `pbkdf2`. `parseEnvelope` reads `kdf`/`verifier`/`keys` whenever a complete pair is present **regardless of `mode`**, so a secret-storage-active envelope can still carry a passphrase snapshot (the version stays `2`; the shape is backward-tolerant because an older plugin ignored `kdf`/`verifier` it saw under secret-storage mode):
```json
{ "version": 2, "mode": "passphrase",
"kdf": { "algo": "argon2id", "salt": "<b64>", "memKiB": 32768, "timeCost": 3, "parallelism": 1 },
@ -21,16 +21,24 @@ File envelope (`SECRETS_VERSION = 2`). For passphrase mode, the `kdf.algo` discr
The derived AES-GCM key for passphrase mode lives in module-level state (`unlockedKey`); it never touches disk. `lockSecrets()` forgets it. `unlockSecrets(plugin, passphrase)` derives a candidate key via `deriveKeyFromKdf` (dispatching on `kdf.algo`) and decrypts the `verifier` to check correctness before caching. An Argon2id allocation failure at unlock throws a clear "this device can't allocate enough memory" error (the envelope was created on a device with more RAM). **Opportunistic migration:** on a successful unlock of a `pbkdf2` envelope, if the device can run Argon2id the keys are silently re-encrypted to `argon2id` (best-effort; failures leave it on PBKDF2). This is a deliberate exception to the pre-release no-migration rule, kept so existing passphrase users aren't forced to re-enter keys. `secretStorage` mode never locks and has no KDF/verifier.
`ReWritePlugin.encryptionStatus` (a snapshot of `{ mode, locked, configured, secretStorageAvailable }`) is loaded on `onload` and refreshed via `plugin.refreshEncryptionStatus()` after every mode change / unlock. UI code reads this synchronously. `locked` and `configured` only vary in passphrase mode (`secretStorage` is always unlocked + configured); `configured` is `false` for a passphrase envelope with no `kdf`/`verifier` yet (first run on a device without secret storage); `secretStorageAvailable` is the cached probe result, used to offer the mode and to steer the user when it is missing.
`ReWritePlugin.encryptionStatus` (a snapshot of `{ mode, locked, configured, secretStorageAvailable, passphraseConfigured }`) is loaded on `onload` and refreshed via `plugin.refreshEncryptionStatus()` after every mode change / copy / clear / unlock. UI code reads this synchronously. `locked` and `configured` only vary in passphrase mode (`secretStorage` is always unlocked + configured); `configured` is `false` for a passphrase envelope with no `kdf`/`verifier` yet (first run on a device without secret storage); `secretStorageAvailable` is the cached probe result, used to offer the mode and to steer the user when it is missing. `passphraseConfigured` reports whether a complete `kdf`+`verifier` exists on disk **independent of the active mode**, so the settings UI can tell a switch-to-passphrase (just activate) from a create-passphrase (prompt for a new one) and can know whether a secret-storage-active user still has a passphrase snapshot to copy or clear.
When `mode === 'passphrase'` and not yet unlocked (`encryptionStatus.locked === true`, which includes the unconfigured first-run state):
- `loadAllKeys` / `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. `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`.
- 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 `setEncryptionMode(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?)` reads all keys to plaintext from the current mode (via `readAllPlain`, which lists `app.secretStorage` for `secretStorage` or decrypts the envelope for `passphrase`), switches, and re-stores them. Requires the current mode to be unlocked (if passphrase AND already configured). Switching TO `secretStorage` requires the probe to pass, then `setSecret`s each value and writes a minimal envelope. Switching TO `passphrase` requires `newPassphrase` (entropy-gated, new envelope built by `buildPassphraseKdfAndKey`) and, when coming FROM `secretStorage`, clears our namespaced entries from the shared store (`clearSecretStorage`) so keys don't linger/sync. `changePassphrase(plugin, newPassphrase)` is a thin wrapper that calls `changeEncryptionMode(plugin, 'passphrase', newPassphrase)`.
### Mode switch, copy, and clear
Switching the active method and transferring keys are **deliberately decoupled** (so a switch can never silently move or lose keys). The keys move only via an explicit copy, which is presented to the user as **Copy** (not "Migrate", since the source is kept). Three operations:
- `setEncryptionMode(plugin, newMode, newPassphrase?)` switches the **active** method only; it reads, moves, and deletes **nothing**. → `secretStorage` requires the probe to pass, then flips `mode` while preserving any passphrase `kdf`/`verifier`/`keys` snapshot and the in-memory `unlockedKey`. → `passphrase` when a store is already configured just flips `mode` (it becomes locked until unlocked unless the session key is still held); → `passphrase` with no store yet requires `newPassphrase` (entropy-gated) and builds a fresh empty envelope via `writePassphraseEnvelope`. Existing keys in the other store are left exactly where they are.
- `copyKeys(plugin)` copies keys **from the inactive method into the active one** and returns the count written. Source/target are derived from `mode`; the source is read to plaintext (`readAllFromSecretStorage`, or `decryptAllToPlain` when the source is the passphrase snapshot, which requires `unlockedKey`), same-named ids in the target are overwritten, other target ids are kept, and the **source is never deleted** (Clear does that). Writing into a passphrase target also requires `unlockedKey`. Because passphrase is always one side of a cross-mode copy, the settings UI prompts for the passphrase (via `unlockPassphraseStore`, see below) before copying whenever the passphrase store is the source.
- `clearKeys(plugin, mode)` permanently wipes one method's keys: `secretStorage``clearSecretStorage`; `passphrase` → drop `kdf`/`verifier`/`keys` from the envelope (rendering it unconfigured) and forget `unlockedKey`. The active mode is unchanged, so clearing the active passphrase leaves it unconfigured/locked (the banner then offers "Set passphrase").
`unlockPassphraseStore(plugin, passphrase)` derives + verifies the passphrase store's key from the envelope **regardless of the active mode** and caches it in `unlockedKey` (this is what lets a secret-storage-active user unlock the passphrase snapshot to copy it). `unlockSecrets` delegates to it for the active-passphrase case (keeping the opportunistic PBKDF2→Argon2id upgrade, which only runs when passphrase is the active mode so it can't clobber a secret-storage envelope). `changePassphrase(plugin, newPassphrase)` is a within-passphrase re-key (decrypt the current keys with `unlockedKey`, rebuild kdf/key via `writePassphraseEnvelope`), NOT a cross-mode transfer; it requires passphrase to be the active, unlocked mode and entropy-gates the new passphrase.
`resetSecrets(plugin, newPassphrase)` is the **forgot-passphrase recovery** path. Unlike `changePassphrase`, it does NOT require unlocking first: it drops `unlockedKey` / `cachedEnvelope` and writes a fresh, **empty** passphrase envelope under the new passphrase (the old keys are unrecoverable without the old passphrase, so they are discarded by design). The new passphrase must pass the same entropy gate. It is exposed in the settings tab **only in the locked state** (`renderEncryption`'s `status.locked` banner branch shows a `mod-warning` "Forgot passphrase? Reset" button beside Unlock; `openResetModal` runs the flow, then `hydrateSecrets` to empty the in-memory keys, `refreshEncryptionStatus`, and `notifySecretsUnlocked`). An unlocked user uses **Change passphrase** instead, so there is no reset row there. The reset modal is a `PassphraseModal` with `requirePhrase: 'DELETE APIS'` (a destructive-confirmation gate: `submit()` blocks until a plain-text field exactly matches the phrase) plus the usual `requireConfirm` + `enforceStrength`.
@ -39,5 +47,6 @@ When `mode === 'passphrase'` and not yet unlocked (`encryptionStatus.locked ===
- **`app.secretStorage` is reached via a narrow cast + round-trip self-test, never assumed present.** [src/secrets.ts](../src/secrets.ts) `getSecretStorage(plugin)` casts `plugin.app` to a local `RawSecretStorage` shape and normalizes the method aliases; `probeSecretStorage` write/read/compares a sentinel before trusting it. Importing nothing Electron-specific keeps it mobile-safe. Old Obsidian (no API) and a broken OS store (Linux without a keyring) both read unavailable and fall back to passphrase. The probe result is module-cached and warmed in `onload` via `warmSecretStorage` so the synchronous `defaultEnvelope()` can prefer it on first run. Keys are namespaced with `manifest.id`; never store under a bare id (the store is shared across plugins).
- **Secret ids must be lowercase-alphanumeric + dashes only.** `app.secretStorage.setSecret(id, value)` throws when `id` contains a colon, underscore, or uppercase letter. `nsId` joins the manifest id and the secret id with a DASH for this reason, and the canonical ids in [src/settings/index.ts](../src/settings/index.ts) (`profile-desktop-transcription`, etc.) and the self-test id (`selftest`) are all dash-only. A regression here is silent and total: the round-trip probe's first `setSecret` throws, the `catch` swallows it, and `secretStorage` reports unavailable on EVERY platform (this was the original bug, when ids were colon/underscore-joined). Do not reintroduce a colon or underscore into any id passed through `nsId`.
- **`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.
- **The two stores coexist; switching is non-destructive.** Since the envelope retains passphrase `kdf`/`verifier`/`keys` even while `secretStorage` is active, both methods can hold keys at once and `mode` is purely the active-store flag. In `secretStorage` mode `saveKey`/`saveManyKeys` write to the OS store and `return` WITHOUT calling `writeEnvelope`, so the passphrase snapshot on disk is never touched by ordinary saves — only `copyKeys`/`clearKeys`/`changePassphrase`/`setEncryptionMode` rewrite it. The snapshot is frozen from when passphrase was last active, so a `copyKeys` from it overwrites the same-named active keys with that snapshot (the user's explicit choice; the confirm dialog says so). Do not re-merge switch + transfer into one call: the split is the whole point (a switch must never move or drop keys). The user-facing button is **Copy**, not "Migrate", since the source copy is kept.
- **Keep zxcvbn lazy and the strength API async.** [src/passphrase-strength.ts](../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 `setEncryptionMode`/`changePassphrase`) are intentional. `warmPassphraseStrength()` is fired on passphrase-modal open to hide the one-time build behind the modal animation.
- **`secrets.json.nosync` uses the `.nosync` suffix on purpose**: iCloud Drive natively skips any file or folder whose name ends in `.nosync`. The README documents per-tool sync exclusion for other tools.

View file

@ -1,7 +1,7 @@
{
"id": "rewrite-voice-notes",
"name": "ReWrite (Voice Notes)",
"version": "1.0.3",
"version": "1.1.0",
"minAppVersion": "1.4.4",
"description": "Record or paste speech and have it transcribed and structured by AI.",
"author": "WiseGuru",

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "rewrite-voice-notes",
"version": "1.0.3",
"version": "1.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "rewrite-voice-notes",
"version": "1.0.3",
"version": "1.1.0",
"license": "0BSD",
"dependencies": {
"@zxcvbn-ts/core": "^3.0.4",

View file

@ -1,6 +1,6 @@
{
"name": "rewrite-voice-notes",
"version": "1.0.3",
"version": "1.1.0",
"description": "Record or paste speech and have it transcribed and structured by AI.",
"main": "main.js",
"type": "module",

View file

@ -17,7 +17,7 @@ import { isPathInTemplatesFolder, loadTemplatesFromFolder } from './templates-fo
import { isPathSharedCore, loadSharedCoreFromFile } from './shared-core';
import { isPathAssistantPrompt, loadAssistantPromptFromFile } from './assistant-prompt';
import { isPathKnownNouns, loadKnownNounsFromFile } from './known-nouns';
import { changeEncryptionMode, EncryptionStatus, getEncryptionStatus, unlockSecrets, warmSecretStorage } from './secrets';
import { setEncryptionMode, EncryptionStatus, getEncryptionStatus, unlockSecrets, warmSecretStorage } from './secrets';
export default class ReWritePlugin extends Plugin implements PipelineHost {
settings!: GlobalSettings;
@ -214,7 +214,7 @@ export default class ReWritePlugin extends Plugin implements PipelineHost {
requireConfirm: true,
enforceStrength: true,
onSubmit: async (pass) => {
await changeEncryptionMode(this, 'passphrase', pass);
await setEncryptionMode(this, 'passphrase', pass);
await hydrateSecrets(this, this.settings);
await this.refreshEncryptionStatus();
this.notifySecretsUnlocked();

View file

@ -35,6 +35,10 @@ export interface EncryptionStatus {
// Obsidian's app.secretStorage exists (>= 1.11.4) AND a round-trip self-test passes, so a
// device with no working OS secret store (e.g. Linux without a keyring) reads false here.
secretStorageAvailable: boolean;
// The passphrase store has a complete kdf+verifier on disk, INDEPENDENT of the active mode.
// Lets the UI tell a switch-to-passphrase (just activate) from a create-passphrase (prompt for
// a new one), and whether a secretStorage-active user has a passphrase snapshot to copy.
passphraseConfigured: boolean;
}
type KdfAlgo = 'pbkdf2' | 'argon2id';
@ -282,15 +286,15 @@ function parseEnvelope(raw: string): SecretsEnvelope {
}
const keys = isObject(parsed.keys) ? parsed.keys as Record<string, string> : {};
const envelope: SecretsEnvelope = { version, mode, keys };
if (mode === 'passphrase') {
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;
}
// Retain passphrase material (kdf/verifier/keys) whenever present, REGARDLESS of the active
// mode. The two stores coexist: when secretStorage is active, the passphrase kdf/verifier/keys
// are a preserved-at-rest snapshot (active keys live in the OS store). Only a complete
// kdf+verifier pair counts as a configured passphrase store.
const kdf = parseKdf(parsed.kdf);
const verifier = typeof parsed.verifier === 'string' ? parsed.verifier : undefined;
if (kdf && verifier) {
envelope.kdf = kdf;
envelope.verifier = verifier;
}
return envelope;
}
@ -487,15 +491,6 @@ async function decryptAllToPlain(envelope: SecretsEnvelope): Promise<Record<stri
return plain;
}
// Read every stored key to plaintext regardless of mode: secretStorage lists the store and
// strips our namespace; passphrase decrypts the envelope. Used by mode transitions.
async function readAllPlain(plugin: Plugin, envelope: SecretsEnvelope): Promise<Record<string, string>> {
if (envelope.mode === 'secretStorage') {
return readAllFromSecretStorage(plugin);
}
return decryptAllToPlain(envelope);
}
// 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).
@ -538,11 +533,13 @@ async function tryUpgradeToArgon2id(plugin: Plugin, passphrase: string): Promise
export async function getEncryptionStatus(plugin: Plugin): Promise<EncryptionStatus> {
const envelope = await ensureEnvelope(plugin);
const passphraseConfigured = envelope.kdf != null && envelope.verifier != null;
return {
mode: envelope.mode,
locked: envelope.mode === 'passphrase' && unlockedKey === null,
configured: envelope.mode !== 'passphrase' || (envelope.kdf != null && envelope.verifier != null),
configured: envelope.mode !== 'passphrase' || passphraseConfigured,
secretStorageAvailable: await probeSecretStorage(plugin),
passphraseConfigured,
};
}
@ -550,9 +547,12 @@ export function lockSecrets(): void {
unlockedKey = null;
}
export async function unlockSecrets(plugin: Plugin, passphrase: string): Promise<boolean> {
// Derive + verify the passphrase store's key from the on-disk envelope, REGARDLESS of which
// mode is active, and cache it in unlockedKey. This lets a secretStorage-active user unlock the
// passphrase snapshot in order to copy it. Returns false on an unconfigured store or a wrong
// passphrase; throws a clear message on an Argon2id allocation failure.
export async function unlockPassphraseStore(plugin: Plugin, passphrase: string): Promise<boolean> {
const envelope = await ensureEnvelope(plugin);
if (envelope.mode !== 'passphrase') return true;
if (!envelope.kdf || !envelope.verifier) return false;
let candidate: CryptoKey;
try {
@ -573,9 +573,10 @@ 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') {
// Opportunistically migrate legacy PBKDF2 envelopes to Argon2id while we hold the passphrase.
// Only when passphrase is the ACTIVE store (tryUpgradeToArgon2id rewrites a passphrase
// envelope, which would clobber an active secretStorage envelope's mode). Best-effort.
if (envelope.mode === 'passphrase' && envelope.kdf.algo === 'pbkdf2') {
try {
await tryUpgradeToArgon2id(plugin, passphrase);
} catch {
@ -585,6 +586,12 @@ export async function unlockSecrets(plugin: Plugin, passphrase: string): Promise
return true;
}
export async function unlockSecrets(plugin: Plugin, passphrase: string): Promise<boolean> {
const envelope = await ensureEnvelope(plugin);
if (envelope.mode !== 'passphrase') return true;
return unlockPassphraseStore(plugin, passphrase);
}
export async function saveKey(plugin: Plugin, id: string, key: string): Promise<void> {
const envelope = await ensureEnvelope(plugin);
if (envelope.mode === 'secretStorage') {
@ -653,48 +660,110 @@ export async function loadAllKeys(plugin: Plugin): Promise<Record<string, string
return decryptAllToPlain(envelope);
}
// ---------- mode transitions ----------
// ---------- mode switch / copy / clear ----------
export async function changeEncryptionMode(
// Count the keys stored under a method, without decrypting (passphrase) or transferring. Drives
// the settings UI (whether Migrate has a source, how many keys Clear will wipe).
export async function countStoredKeys(plugin: Plugin, mode: EncryptionMode): Promise<number> {
if (mode === 'secretStorage') {
return Object.keys(await readAllFromSecretStorage(plugin)).length;
}
const envelope = await ensureEnvelope(plugin);
return Object.values(envelope.keys).filter((v) => typeof v === 'string' && v !== '').length;
}
// Switch the ACTIVE encryption method WITHOUT transferring any keys. Keys saved under the other
// method are preserved at rest (passphrase kdf/verifier/keys stay in the envelope; secretStorage
// entries stay in the OS store). Use copyKeys() to copy them over.
export async function setEncryptionMode(
plugin: Plugin,
newMode: EncryptionMode,
newPassphrase?: string,
): Promise<void> {
const envelope = await ensureEnvelope(plugin);
if (envelope.mode === newMode && newMode !== 'passphrase') return;
if (envelope.mode === 'passphrase' && unlockedKey === null && envelope.kdf) {
throw new Error('Unlock secrets with the current passphrase before changing modes.');
}
if (newMode === 'secretStorage' && !(await probeSecretStorage(plugin))) {
throw new Error('Obsidian secret storage is not available on this device.');
}
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).');
}
}
if (envelope.mode === newMode) return;
const plain = await readAllPlain(plugin, envelope);
const wasSecretStorage = envelope.mode === 'secretStorage';
if (newMode === 'passphrase') {
await writePassphraseEnvelope(plugin, newPassphrase ?? '', plain);
if (wasSecretStorage) await clearSecretStorage(plugin);
if (newMode === 'secretStorage') {
if (!(await probeSecretStorage(plugin))) {
throw new Error('Obsidian secret storage is not available on this device.');
}
// Flip the active mode; keep kdf/verifier/keys (the now-inactive passphrase snapshot) and
// the in-memory unlockedKey so a later passphrase->secretStorage copy needs no re-prompt.
await writeEnvelope(plugin, { ...envelope, version: SECRETS_VERSION, mode: 'secretStorage' });
return;
}
// secretStorage: write each value to the OS-backed store, then record the mode in a minimal
// envelope (the keys map stays empty; values live in app.secretStorage, not on disk).
unlockedKey = null;
for (const id of Object.keys(plain)) {
await writeToSecretStorage(plugin, id, plain[id] ?? '');
// newMode === 'passphrase'
if (envelope.kdf && envelope.verifier) {
// Passphrase store already configured: just make it active. It is locked until the user
// unlocks (unless unlockedKey is still held from this session). No rebuild, no transfer.
await writeEnvelope(plugin, { ...envelope, version: SECRETS_VERSION, mode: 'passphrase' });
return;
}
const next: SecretsEnvelope = { version: SECRETS_VERSION, mode: 'secretStorage', keys: {} };
cachedEnvelope = next;
await writeEnvelope(plugin, next);
// Passphrase store not configured yet: a new passphrase is required to create it.
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).');
}
// Build a fresh, empty passphrase envelope (becomes the active mode). Any secretStorage keys
// stay untouched in the OS store; the user can copy them in afterwards.
await writePassphraseEnvelope(plugin, newPassphrase, {});
}
// Copy keys FROM the inactive method INTO the currently active method. The source is NOT deleted
// (use clearKeys for that), so this is a copy, not a move. Same-named ids in the target are
// overwritten; other target ids are left intact. Returns the number of keys written. The caller
// must have unlocked the passphrase store first whenever passphrase is the source or target
// (encrypt/decrypt needs unlockedKey).
export async function copyKeys(plugin: Plugin): Promise<number> {
const envelope = await ensureEnvelope(plugin);
const source: EncryptionMode = envelope.mode === 'secretStorage' ? 'passphrase' : 'secretStorage';
let sourcePlain: Record<string, string>;
if (source === 'secretStorage') {
sourcePlain = await readAllFromSecretStorage(plugin);
} else {
if (!envelope.kdf || !envelope.verifier) return 0; // no passphrase store to copy from
if (unlockedKey === null) {
throw new Error('Unlock the passphrase store before copying its keys.');
}
sourcePlain = await decryptAllToPlain(envelope);
}
const ids = Object.keys(sourcePlain).filter((id) => sourcePlain[id]);
if (ids.length === 0) return 0;
if (envelope.mode === 'secretStorage') {
for (const id of ids) {
await writeToSecretStorage(plugin, id, sourcePlain[id] ?? '');
}
} else {
if (unlockedKey === null) {
throw new Error('Unlock the passphrase store before copying keys into it.');
}
for (const id of ids) {
envelope.keys[id] = await encryptValue(sourcePlain[id] ?? '');
}
await writeEnvelope(plugin, envelope);
}
return ids.length;
}
// Permanently delete every key saved under a method. secretStorage: remove our namespaced OS
// entries. passphrase: drop kdf/verifier/keys from the envelope (rendering it unconfigured) and
// forget the in-memory key. Does not change the active mode.
export async function clearKeys(plugin: Plugin, mode: EncryptionMode): Promise<void> {
const envelope = await ensureEnvelope(plugin);
if (mode === 'secretStorage') {
await clearSecretStorage(plugin);
return;
}
// passphrase: strip all passphrase material, keep the active mode flag as-is.
unlockedKey = null;
await writeEnvelope(plugin, { version: SECRETS_VERSION, mode: envelope.mode, keys: {} });
}
// Forgot-passphrase recovery. Discards all existing key material (the old keys are
@ -714,6 +783,8 @@ export async function resetSecrets(plugin: Plugin, newPassphrase: string): Promi
await writePassphraseEnvelope(plugin, newPassphrase, {});
}
// Re-encrypt the existing passphrase keys under a new passphrase (a within-passphrase re-key, not
// a cross-mode transfer). Requires passphrase to be the active mode and currently unlocked.
export async function changePassphrase(plugin: Plugin, newPassphrase: string): Promise<void> {
const envelope = await ensureEnvelope(plugin);
if (envelope.mode !== 'passphrase') {
@ -725,5 +796,9 @@ export async function changePassphrase(plugin: Plugin, newPassphrase: string): P
if (newPassphrase.length === 0) {
throw new Error('Passphrase cannot be empty.');
}
await changeEncryptionMode(plugin, 'passphrase', newPassphrase);
if (!(await isPassphraseAcceptable(newPassphrase))) {
throw new Error('Passphrase is too weak. Use a longer, more unique passphrase (try the Generate button).');
}
const plain = await decryptAllToPlain(envelope);
await writePassphraseEnvelope(plugin, newPassphrase, plain);
}

View file

@ -19,9 +19,21 @@ import { loadPriorTemplateVersions, populateDefaultTemplates, updateDefaultTempl
import { populateDefaultSharedCore } from '../shared-core';
import { populateDefaultAssistantPrompt } from '../assistant-prompt';
import { populateDefaultKnownNouns } from '../known-nouns';
import { changeEncryptionMode, EncryptionMode, lockSecrets, resetSecrets } from '../secrets';
import {
changePassphrase,
clearKeys,
copyKeys,
countStoredKeys,
EncryptionMode,
EncryptionStatus,
lockSecrets,
resetSecrets,
setEncryptionMode,
unlockPassphraseStore,
} from '../secrets';
import { hydrateSecrets } from '.';
import { PassphraseModal } from '../ui/passphrase-modal';
import { ConfirmModal } from '../ui/confirm-modal';
// Sentinel value for the "Custom..." entry in a model dropdown; never written to config.model.
const CUSTOM_MODEL_OPTION = '__rewrite_custom__';
@ -50,6 +62,13 @@ function wikiLinkParagraph(parent: HTMLElement, leadText: string, label: string,
p.appendText('.');
}
// Human-readable name for an encryption method, used in the Migrate/Clear copy. Passphrase is
// lowercased mid-sentence and capitalized when it starts a sentence (via the `capitalize` arg).
function modeLabel(mode: EncryptionMode, capitalize = false): string {
if (mode === 'secretStorage') return 'Obsidian secret storage';
return capitalize ? 'Passphrase' : 'passphrase';
}
// Probe the locations scripts/build-whisper-linux.sh installs whisper-server to,
// plus the common system/Homebrew paths, and return the first that exists.
// Desktop-only (lazy-requires fs/os via the same window.require pattern as whisper-host.ts);
@ -265,10 +284,19 @@ export class ReWriteSettingTab extends PluginSettingTab {
dd.onChange((v) => {
const next = v as EncryptionMode;
if (next === status.mode) return;
void this.handleModeChange(next);
void this.handleSwitchMode(next);
});
});
// Switching the mode above does NOT move keys. Copy (duplicate other -> active) and Clear
// (wipe a method) are explicit, separate actions, rendered into this container once their
// async key counts resolve. Gated on an unlocked active store: a locked or unconfigured
// passphrase is handled by the banner's Unlock / Set passphrase button instead.
if (!status.locked) {
const transferEl = parent.createDiv();
void this.renderKeyTransferControls(transferEl, status);
}
if (status.mode === 'passphrase' && !status.locked) {
new Setting(parent)
.setName('Change passphrase')
@ -283,7 +311,7 @@ export class ReWriteSettingTab extends PluginSettingTab {
requireConfirm: true,
enforceStrength: true,
onSubmit: async (pass) => {
await changeEncryptionMode(this.plugin, 'passphrase', pass);
await changePassphrase(this.plugin, pass);
await this.plugin.refreshEncryptionStatus();
new Notice('ReWrite: passphrase updated.');
this.display();
@ -306,6 +334,153 @@ export class ReWriteSettingTab extends PluginSettingTab {
}
}
// Render the Migrate and Clear rows once the per-method key counts resolve. Appended async
// because display() is synchronous; the rows pop into `parent` a tick later.
private async renderKeyTransferControls(parent: HTMLElement, status: EncryptionStatus): Promise<void> {
const active = status.mode;
const other: EncryptionMode = active === 'secretStorage' ? 'passphrase' : 'secretStorage';
const [activeCount, otherCount] = await Promise.all([
countStoredKeys(this.plugin, active),
countStoredKeys(this.plugin, other),
]);
if (activeCount === 0 && otherCount > 0) {
const hint = parent.createDiv({ cls: 'rewrite-encryption-banner is-warning' });
hint.createEl('span', {
text: `${modeLabel(active, true)} has no saved keys yet. Use the Copy button to bring your ${otherCount} key(s) over from ${modeLabel(other)}, or enter them above.`,
});
}
if (otherCount > 0) {
new Setting(parent)
.setName(`Copy keys from ${modeLabel(other)}`)
.setDesc(`Copies your ${otherCount} saved key(s) from ${modeLabel(other)} into ${modeLabel(active)}. The originals are kept; use Clear to remove them.`)
.addButton((b) => {
b.setButtonText('Copy').onClick(() => void this.handleCopy());
});
}
if (activeCount > 0) {
new Setting(parent)
.setName(`Clear keys in ${modeLabel(active)}`)
.setDesc(`Permanently deletes the ${activeCount} key(s) saved under ${modeLabel(active)}. This cannot be undone.`)
.addButton((b) => {
b.setButtonText('Clear').setWarning().onClick(() => this.handleClear(active, activeCount));
});
}
}
// Switch the ACTIVE encryption method without moving any keys (Migrate does that). Switching to
// an unconfigured passphrase store prompts for a new passphrase; switching to an already
// configured one just activates it (locked until the user unlocks).
private async handleSwitchMode(next: EncryptionMode): Promise<void> {
if (this.modeChangeInFlight) return;
this.modeChangeInFlight = true;
try {
if (next === 'passphrase' && !this.plugin.encryptionStatus.passphraseConfigured) {
new PassphraseModal({
app: this.app,
title: 'Set a passphrase',
description: 'A passphrase will encrypt your API keys. Store it in your password manager; there is no recovery if you forget it. This does not move keys saved under Obsidian secret storage; use Copy for that.',
confirmLabel: 'Save',
requireConfirm: true,
enforceStrength: true,
onSubmit: async (pass) => {
await setEncryptionMode(this.plugin, 'passphrase', pass);
await hydrateSecrets(this.plugin, this.plugin.settings);
await this.plugin.refreshEncryptionStatus();
this.plugin.notifySecretsUnlocked();
new Notice('ReWrite: passphrase encryption enabled.');
this.display();
},
}).open();
// Re-render now so the dropdown reverts to the current mode until the user confirms.
this.display();
return;
}
await setEncryptionMode(this.plugin, next);
await hydrateSecrets(this.plugin, this.plugin.settings);
await this.plugin.refreshEncryptionStatus();
new Notice(`ReWrite: switched to ${modeLabel(next)}.`);
this.display();
} catch (e) {
console.error('ReWrite: encryption mode switch failed', e);
new Notice(`ReWrite: ${e instanceof Error ? e.message : String(e)}`);
await this.plugin.refreshEncryptionStatus();
this.display();
} finally {
this.modeChangeInFlight = false;
}
}
// Copy keys from the inactive method into the active method. When the source is the passphrase
// store it must be unlocked first (it may be locked while secretStorage is active), so prompt
// for the passphrase before the confirm. The source is never deleted; Clear does that.
private async handleCopy(): Promise<void> {
const status = this.plugin.encryptionStatus;
const active = status.mode;
const other: EncryptionMode = active === 'secretStorage' ? 'passphrase' : 'secretStorage';
const sourceCount = await countStoredKeys(this.plugin, other);
if (sourceCount === 0) {
new Notice('ReWrite: no keys to copy.');
return;
}
const confirmCopy = (): void => {
new ConfirmModal({
app: this.app,
title: 'Copy API keys',
body: `Copy ${sourceCount} key(s) from ${modeLabel(other)} into ${modeLabel(active)}? Existing keys with the same name in ${modeLabel(active)} are overwritten. The ${modeLabel(other)} copy is kept (use Clear to remove it).`,
confirmLabel: 'Copy',
onConfirm: async () => {
const n = await copyKeys(this.plugin);
await hydrateSecrets(this.plugin, this.plugin.settings);
await this.plugin.refreshEncryptionStatus();
new Notice(`ReWrite: copied ${n} key(s) into ${modeLabel(active)}.`);
this.display();
},
}).open();
};
// Copying FROM the passphrase store needs its derived key in memory to decrypt the source.
if (other === 'passphrase') {
new PassphraseModal({
app: this.app,
title: 'Unlock passphrase store',
description: 'Enter the passphrase that encrypts the keys you want to copy.',
confirmLabel: 'Unlock',
onSubmit: async (pass) => {
const ok = await unlockPassphraseStore(this.plugin, pass);
if (!ok) throw new Error('Incorrect passphrase.');
confirmCopy();
},
}).open();
return;
}
confirmCopy();
}
// Permanently wipe the keys saved under one method. Destructive, so behind a confirm.
private handleClear(mode: EncryptionMode, count: number): void {
const extra = mode === 'passphrase'
? ' You will need to set a passphrase again before storing keys under it.'
: '';
new ConfirmModal({
app: this.app,
title: 'Clear saved API keys',
body: `Permanently delete the ${count} API key(s) saved under ${modeLabel(mode)}? This cannot be undone.${extra}`,
confirmLabel: 'Delete keys',
confirmCls: 'mod-warning',
onConfirm: async () => {
await clearKeys(this.plugin, mode);
await hydrateSecrets(this.plugin, this.plugin.settings);
await this.plugin.refreshEncryptionStatus();
new Notice(`ReWrite: cleared ${count} key(s) from ${modeLabel(mode)}.`);
this.display();
},
}).open();
}
private openResetModal(): void {
new PassphraseModal({
app: this.app,
@ -339,44 +514,6 @@ export class ReWriteSettingTab extends PluginSettingTab {
return lines.join(' ');
}
private async handleModeChange(next: EncryptionMode): Promise<void> {
if (this.modeChangeInFlight) return;
this.modeChangeInFlight = true;
try {
if (next === 'passphrase') {
new PassphraseModal({
app: this.app,
title: 'Set a passphrase',
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();
new Notice('ReWrite: passphrase encryption enabled.');
this.display();
},
}).open();
// Modal cancel or completion handles re-render; re-render now so the dropdown
// doesn't appear "applied" until the user confirms.
this.display();
return;
}
await changeEncryptionMode(this.plugin, next);
await this.plugin.refreshEncryptionStatus();
new Notice('ReWrite: switched to Obsidian secret storage.');
this.display();
} catch (e) {
console.error('ReWrite: encryption mode change failed', e);
new Notice(`ReWrite: ${e instanceof Error ? e.message : String(e)}`);
await this.plugin.refreshEncryptionStatus();
this.display();
} finally {
this.modeChangeInFlight = false;
}
}
private renderActiveProfile(parent: HTMLElement): void {
this.sectionHeading(parent, 'Active profile', 'user');
const s = this.plugin.settings;

62
src/ui/confirm-modal.ts Normal file
View file

@ -0,0 +1,62 @@
import { App, Modal, Notice } from 'obsidian';
export interface ConfirmModalParams {
app: App;
title: string;
// Plain-text body. Rendered as a paragraph; newlines split into separate paragraphs.
body: string;
confirmLabel?: string;
// Extra class for the confirm button (e.g. 'mod-warning' for a destructive action).
confirmCls?: string;
// Called when the user confirms. Throw to surface an error and keep the modal open.
onConfirm: () => Promise<void>;
}
// A small generic confirmation modal. window.confirm is banned by ESLint (no-alert), and the
// only other modals (RenamePromptModal, PassphraseModal) are purpose-built, so destructive or
// consequential actions that just need an OK/Cancel use this. Reuses the rewrite-modal styling.
export class ConfirmModal extends Modal {
private busy = false;
constructor(private readonly params: ConfirmModalParams) {
super(params.app);
}
onOpen(): void {
this.modalEl.addClass('rewrite-modal');
this.modalEl.addClass('rewrite-confirm-modal');
const { contentEl } = this;
contentEl.createEl('h2', { text: this.params.title });
for (const line of this.params.body.split('\n')) {
contentEl.createEl('p', { text: line, cls: 'rewrite-confirm-body' });
}
const actions = contentEl.createDiv({ cls: 'rewrite-passphrase-actions' });
const confirm = actions.createEl('button', {
text: this.params.confirmLabel ?? 'Confirm',
cls: this.params.confirmCls ? `mod-cta ${this.params.confirmCls}` : 'mod-cta',
});
confirm.addEventListener('click', () => { void this.run(); });
const cancel = actions.createEl('button', { text: 'Cancel' });
cancel.addEventListener('click', () => this.close());
}
onClose(): void {
this.contentEl.empty();
}
private async run(): Promise<void> {
if (this.busy) return;
this.busy = true;
try {
await this.params.onConfirm();
this.close();
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
new Notice(msg);
} finally {
this.busy = false;
}
}
}

View file

@ -15,7 +15,18 @@ The plugin encrypts keys at rest in one of two modes, selectable in Settings und
Passphrase mode locks and unlocks: **Lock now** clears the derived key from memory, and the next pipeline run prompts you to unlock. The derived key never touches disk.
The deeper implementation detail (envelope schema, KDF parameters, migration) is developer-facing and lives in the repo's `docs/SECRETS.md`.
### Switching, copying, and clearing keys
Switching the **Encryption mode** dropdown changes which method is *active* but **does not move your keys**. The two methods can hold keys at the same time, so a switch is safe and reversible: keys saved under the other method stay where they are (the passphrase file keeps its encrypted snapshot; secret-storage keys stay in your OS keychain). The newly active method may simply show no keys until you copy or re-enter them.
Two explicit buttons handle the key material, each behind a confirmation:
- **Copy keys from &lt;other method&gt;** duplicates the keys saved under the *inactive* method into the *active* one. The originals are kept (use Clear to remove them). When the source is the passphrase store, you are prompted for that passphrase first. A notice reports how many keys were copied.
- **Clear keys in &lt;method&gt;** permanently deletes the keys saved under the selected method. Clearing the active passphrase store leaves it unconfigured, so you would set a passphrase again (or switch methods) before saving more keys.
A typical move from passphrase to secret storage: switch the dropdown to **Obsidian secret storage**, click **Copy** (enter your passphrase when asked), confirm, then optionally **Clear** the passphrase store once you have verified everything works.
The deeper implementation detail (envelope schema, KDF parameters, the switch/copy/clear model) is developer-facing and lives in the repo's `docs/SECRETS.md`.
## Excluding `secrets.json.nosync` from sync

View file

@ -6,7 +6,9 @@ This page walks through every section of the plugin's settings tab (Settings, Re
Controls how your provider API keys are encrypted at rest. There is no unencrypted option.
- **Encryption mode**: choose between **Obsidian secret storage** (the default when your Obsidian version and OS support it; keys live in the OS keychain) and **Passphrase** (AES-GCM with a key derived from a passphrase via Argon2id, or PBKDF2 where Argon2id is unavailable). A status badge next to the section heading shows the active mode and whether it is currently unlocked.
- **Encryption mode**: choose between **Obsidian secret storage** (the default when your Obsidian version and OS support it; keys live in the OS keychain) and **Passphrase** (AES-GCM with a key derived from a passphrase via Argon2id, or PBKDF2 where Argon2id is unavailable). Switching here changes the *active* method only and **does not move your keys** (the two methods can hold keys at once).
- **Copy keys from &lt;other method&gt;**: duplicates the keys saved under the inactive method into the active one, leaving the originals in place. Shown only when the other method has keys; you confirm the copy (and enter the passphrase when copying from the passphrase store), and a notice reports the count.
- **Clear keys in &lt;method&gt;**: permanently deletes the keys saved under the selected method, behind a confirmation. Shown only when that method has keys.
- **Change passphrase**: set or rotate the passphrase (passphrase mode only). The plugin enforces a minimum strength and offers a one-click 6-word generator.
- **Lock now**: clears the derived key from memory (passphrase mode). The next pipeline run prompts you to unlock.