From f9c2450a8c5c49e10e9b27dc46e7d64bd4720e18 Mon Sep 17 00:00:00 2001 From: WiseGuru <42100212+WiseGuru@users.noreply.github.com> Date: Thu, 18 Jun 2026 14:46:08 -0700 Subject: [PATCH] 1.0.3 Fix Obsidian secret storage detection failing on all platforms. app.secretStorage.setSecret rejects any id that is not lowercase alphanumeric + dashes, so the colon-namespaced ids and the underscore self-test id made the availability probe throw and report secretStorage unavailable on every platform. Switch all secret ids (namespace separator, self-test id, per-profile key ids) to dash-joined form. Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 2 +- docs/SECRETS.md | 5 +++-- manifest.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- src/secrets.ts | 12 ++++++++---- src/settings/index.ts | 6 ++++-- 7 files changed, 20 insertions(+), 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 22dc7cd..5321005 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -135,7 +135,7 @@ The `PipelineSource` union has three variants: `audio` (recorded blob, optional [src/transcription/index.ts](src/transcription/index.ts) and [src/llm/index.ts](src/llm/index.ts) each define a small interface plus a `create...Provider(id)` factory. Provider families share one adapter file where the API shapes match: OpenAI Whisper, `openai-compatible`, and Groq all dispatch into [src/transcription/openai.ts](src/transcription/openai.ts) with different base URLs; OpenAI GPT, `openai-compatible`, and Mistral all dispatch into [src/llm/openai.ts](src/llm/openai.ts). Mistral Voxtral does NOT share with `openai.ts` (see [src/transcription/mistral-voxtral.ts](src/transcription/mistral-voxtral.ts)) because Voxtral's response is JSON-only (no `response_format=text`) and it rejects WebM input (so the blob is always transcoded to 16 kHz mono WAV via [src/audio-transcode.ts](src/audio-transcode.ts)). -API keys are stored per profile on `EnvironmentProfile.transcriptionConfig.apiKey` / `llmConfig.apiKey`. Two slots per profile, one for transcription and one for the LLM. No global by-family map; the desktop and mobile profiles each carry their own keys even when both use the same provider (deliberate: per-profile keys make per-function usage tracking easier). Persistence is in [src/secrets.ts](src/secrets.ts) using the key IDs `profile:desktop:transcription`, `profile:desktop:llm`, `profile:mobile:transcription`, `profile:mobile:llm`. +API keys are stored per profile on `EnvironmentProfile.transcriptionConfig.apiKey` / `llmConfig.apiKey`. Two slots per profile, one for transcription and one for the LLM. No global by-family map; the desktop and mobile profiles each carry their own keys even when both use the same provider (deliberate: per-profile keys make per-function usage tracking easier). Persistence is in [src/secrets.ts](src/secrets.ts) using the key IDs `profile-desktop-transcription`, `profile-desktop-llm`, `profile-mobile-transcription`, `profile-mobile-llm`. These ids (and the namespace prefix `secrets.ts` prepends for `secretStorage`) are dash-joined, NOT colon-joined: Obsidian's `app.secretStorage.setSecret` throws on any id that is not lowercase-alphanumeric + dashes, so a colon/underscore id silently fails the availability probe on every platform. Do not reintroduce colons or underscores into a secret id. Providers may optionally implement `listModels(config, signal)` returning a string array of model IDs the configured API key can access. Implemented by: OpenAI / Groq / Mistral (via `openai.ts` shared adapter), Mistral Voxtral (own adapter, filters Mistral's `/v1/models` catalog by ID substring `voxtral`), Anthropic, Gemini, Deepgram. Not implemented for `openai-compatible` (URL-specific, list-shape varies), AssemblyAI, Rev.ai. The settings tab caches results to `GlobalSettings.modelCache` per side and provider ID; the Refresh button in the model field triggers `listModels` and updates the cache. The model field is a single adaptive control (`populateModelField` in [src/settings/tab.ts](src/settings/tab.ts), shared by both sides via a `side: 'transcription' | 'llm'` arg): a **dropdown** when the provider supports `listModels` and the cache is non-empty, otherwise a **plain text field**. There is never both at once. The dropdown carries a trailing "Custom..." option (sentinel `CUSTOM_MODEL_OPTION`, never written to `config.model`) that toggles the same control into the text field (the `forceText` arg) so a model ID not in the catalog can still be typed; a "Back to list" button returns to the dropdown. A custom value not in the cache is shown as a selected ` (custom)` dropdown option. The Refresh button accompanies the dropdown (and the empty-cache text field, so the first fetch can flip it to a dropdown); on success the field re-renders in dropdown mode. `openai-compatible` / AssemblyAI / Rev.ai (no `listModels`) always show a plain text field with no Refresh. For AssemblyAI and Rev.ai the field description appends a "list of models" external link to the provider's docs (`transcriptionModelDocsUrl` + `applyModelFieldDesc` in [src/settings/tab.ts](src/settings/tab.ts)); `openai-compatible` has none (the model list is the user's own server). Whichever control is active, the canonical source is `profile.config.model`. diff --git a/docs/SECRETS.md b/docs/SECRETS.md index fada1fe..23617ca 100644 --- a/docs/SECRETS.md +++ b/docs/SECRETS.md @@ -4,7 +4,7 @@ [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 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.** 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. - **`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) 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. @@ -16,7 +16,7 @@ File envelope (`SECRETS_VERSION = 2`). For passphrase mode, the `kdf.algo` discr { "version": 2, "mode": "passphrase", "kdf": { "algo": "argon2id", "salt": "", "memKiB": 32768, "timeCost": 3, "parallelism": 1 }, "verifier": ".", - "keys": { "profile:desktop:transcription": ".", ... } } + "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 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. @@ -37,6 +37,7 @@ When `mode === 'passphrase'` and not yet unlocked (`encryptionStatus.locked === ## Gotchas - **`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. - **`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. diff --git a/manifest.json b/manifest.json index 3147fa1..9495994 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "rewrite-voice-notes", "name": "ReWrite (Voice Notes)", - "version": "1.0.2", + "version": "1.0.3", "minAppVersion": "1.4.4", "description": "Record or paste speech and have it transcribed and structured by AI.", "author": "WiseGuru", diff --git a/package-lock.json b/package-lock.json index 6b6026d..7736cae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "rewrite-voice-notes", - "version": "1.0.2", + "version": "1.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rewrite-voice-notes", - "version": "1.0.2", + "version": "1.0.3", "license": "0BSD", "dependencies": { "@zxcvbn-ts/core": "^3.0.4", diff --git a/package.json b/package.json index 1efde36..975017c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rewrite-voice-notes", - "version": "1.0.2", + "version": "1.0.3", "description": "Record or paste speech and have it transcribed and structured by AI.", "main": "main.js", "type": "module", diff --git a/src/secrets.ts b/src/secrets.ts index 3d8f554..aa5d6b4 100644 --- a/src/secrets.ts +++ b/src/secrets.ts @@ -6,7 +6,7 @@ const SECRETS_FILE = 'secrets.json.nosync'; const SECRETS_VERSION = 2; const VERIFIER_PLAINTEXT = 'rewrite-passphrase-verifier-v1'; const SECRET_STORAGE_SELFTEST = 'rewrite-secretstorage-selftest'; -const SELFTEST_SECRET_ID = '__rewrite_selftest__'; +const SELFTEST_SECRET_ID = 'selftest'; const PBKDF2_ITERATIONS = 600_000; const KDF_SALT_BYTES = 16; const AES_IV_BYTES = 12; @@ -93,13 +93,17 @@ let unlockedKey: CryptoKey | null = null; // ---------- Obsidian secret storage (app.secretStorage) ---------- // Prefix ids with the plugin id: app.secretStorage is shared across all installed plugins, so -// we namespace our keys to avoid colliding with another plugin's. +// we namespace our keys to avoid colliding with another plugin's. The separator is a DASH, not a +// colon, because app.secretStorage.setSecret rejects any id that is not lowercase-alphanumeric + +// dashes (it throws on a colon/underscore/uppercase). manifest.id (`rewrite-voice-notes`) and all +// our secret ids are already dash-only, so the joined id stays valid; stripNs slices off this same +// fixed prefix to recover the original id. function nsId(plugin: Plugin, id: string): string { - return `${plugin.manifest.id}:${id}`; + return `${plugin.manifest.id}-${id}`; } function stripNs(plugin: Plugin, nsKey: string): string | null { - const prefix = `${plugin.manifest.id}:`; + const prefix = `${plugin.manifest.id}-`; return nsKey.startsWith(prefix) ? nsKey.slice(prefix.length) : null; } diff --git a/src/settings/index.ts b/src/settings/index.ts index 3c3ea29..d2f13f3 100644 --- a/src/settings/index.ts +++ b/src/settings/index.ts @@ -68,12 +68,14 @@ export const DEFAULT_SETTINGS: GlobalSettings = { const PROFILE_KINDS: ActiveProfileKind[] = ['desktop', 'mobile']; +// Secret ids must be lowercase-alphanumeric + dashes only (Obsidian's app.secretStorage.setSecret +// throws on colons/underscores). `kind` is always 'desktop' | 'mobile', so dash-joining stays valid. function profileTranscriptionKeyId(kind: ActiveProfileKind): string { - return `profile:${kind}:transcription`; + return `profile-${kind}-transcription`; } function profileLLMKeyId(kind: ActiveProfileKind): string { - return `profile:${kind}:llm`; + return `profile-${kind}-llm`; } export async function loadSettings(plugin: Plugin): Promise {