mirror of
https://github.com/wiseguru/ReWrite-Voice-Notes.git
synced 2026-07-22 07:49:19 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
parent
f0e142f8e2
commit
f9c2450a8c
7 changed files with 20 additions and 13 deletions
|
|
@ -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 `<id> (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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 `<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.
|
||||
|
|
@ -16,7 +16,7 @@ File envelope (`SECRETS_VERSION = 2`). For passphrase mode, the `kdf.algo` discr
|
|||
{ "version": 2, "mode": "passphrase",
|
||||
"kdf": { "algo": "argon2id", "salt": "<b64>", "memKiB": 32768, "timeCost": 3, "parallelism": 1 },
|
||||
"verifier": "<iv-b64>.<ct-b64>",
|
||||
"keys": { "profile:desktop:transcription": "<iv-b64>.<ct-b64>", ... } }
|
||||
"keys": { "profile-desktop-transcription": "<iv-b64>.<ct-b64>", ... } }
|
||||
```
|
||||
|
||||
The derived AES-GCM key for passphrase mode lives in module-level state (`unlockedKey`); it never touches disk. `lockSecrets()` forgets it. `unlockSecrets(plugin, passphrase)` derives a candidate key 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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<GlobalSettings> {
|
||||
|
|
|
|||
Loading…
Reference in a new issue