mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
* feat(keychain): migrate API key storage to Obsidian Keychain
Replaces the legacy disk-encryption flow with Obsidian's vault-scoped
Keychain (SecretStorage) as the source of truth for API keys. Migration
is explicit and opt-in via Advanced Settings; existing vaults stay on
disk mode until the user clicks "Migrate to Obsidian Keychain".
Architecture
- `KeychainService` (singleton) wraps Obsidian's SecretStorage with a
per-vault namespace (`copilot-v{8hex}-{field}`) so multiple vaults on
one device cannot collide.
- `settingsPersistence` is the single owner of load/save:
- `loadSettingsWithKeychain()` dispatches by `_keychainOnly` flag.
- `doPersist()` writes either keychain (stripped data.json) or disk
(plaintext data.json, never new `enc_*`).
- Write queue + transaction epoch serialise every persist with
dedicated transactions (forgetAllSecrets, migrate).
- `settingsSecretTransforms` provides the pure helpers shared by both
sides (stripKeychainFields, cleanupLegacyFields, isKeychainOnly, ...).
- `encryptionService` shrinks to decrypt-only + base64/sensitive-key
helpers; new encryption writes are gone.
Migration model
- Fresh installs auto-opt-in to keychain mode; existing installs stay
disk-mode until the user clicks Migrate.
- `migrateDiskSecretsToKeychain()` is a single transaction: write
keychain -> strip data.json -> flip `_keychainOnly`. Rolls back the
keychain on partial-write or disk-save failure, and re-derives from
live settings at transaction end so concurrent edits are preserved.
- Undecryptable legacy `enc_*` values are cleared and reported via
`MigrationResult.fieldsRequiringReentry`.
Persist hardening
- Stranded vaults (keychain-only mode on a non-SecretStorage build)
preserve `_keychainOnly` and never load plaintext from disk or
silently downgrade to disk mode.
- `persistHadUndecryptableSecrets` fail-closed guard blocks disk-clear
when the last keychain save did not complete safely; a clean
disk-mode save lifts the lock so migration retry works.
- Destructive flows (clearAllVaultSecrets, forgetAllSecrets) feature-
detect `listSecrets()` and refuse before stripping disk on builds
where keychain entries cannot be enumerated.
UI
- New `MigrateConfirmModal` gates migration behind an acknowledgement
checkbox surfacing the multi-device trade-off.
- API Key Storage panel exposes active / standard / blocked /
unavailable / stranded states.
- `canClearDiskSecrets()` falls back to in-memory settings so the
Migrate / Delete-All CTAs surface immediately after a key is typed.
Testing
- New suites for keychainService, settingsPersistence,
settingsSecretTransforms, MigrateConfirmModal, password-input;
encryptionService tests rewritten for the decrypt-only surface.
- DESIGN NOTEs lock the trade-offs triaged during review (sparse
bootstrap write, rollback enc_* replay, hasEncryptionPrefix guard,
unconditional epoch bump, etc.) to prevent future review churn.
* docs(keychain): document intentional first-stage migration residue
Add a DESIGN NOTE in migrateDiskSecretsToKeychain explaining why
keychain residue from a failed first-stage persist is intentionally
left uncleaned: it stays dormant in disk-mode and self-heals on the
next successful migration or Delete All Keys.
* docs(keychain): clarify Reset Settings does not clear keychain secrets
Reset Settings rewrites data.json to defaults but does not enumerate or
remove Obsidian Keychain entries. The troubleshooting doc still promised
Reset would delete all API keys, which is false for keychain-only vaults.
- Correct the Reset warning to point users to "Delete All Keys" for
erasing keychain secrets
- Add a DESIGN NOTE on resetSettings() explaining the omission is
intentional for the first-stage migration
* style(keychain): emphasize data.json and Keychain labels in migrate modal
Add subtle background and accent coloring to the `data.json` and
`Obsidian Keychain` inline code labels so the source and destination
of the migration read more clearly.
57 lines
1.6 KiB
TypeScript
57 lines
1.6 KiB
TypeScript
import type { EditorView } from "@codemirror/view";
|
|
import { type TFile } from "obsidian";
|
|
|
|
declare module "obsidian" {
|
|
interface MetadataCache {
|
|
// Note that this API is considered internal and may work differently in the
|
|
// future.
|
|
getBacklinksForFile(file: TFile): {
|
|
data: Map<string, unknown>;
|
|
} | null;
|
|
}
|
|
|
|
interface Editor {
|
|
/**
|
|
* The underlying CodeMirror 6 editor view, when available.
|
|
*/
|
|
cm?: EditorView;
|
|
}
|
|
|
|
interface MenuItem {
|
|
/**
|
|
* Creates a submenu for this item.
|
|
*/
|
|
setSubmenu(): this;
|
|
|
|
/**
|
|
* Submenu instance created by `setSubmenu()`, when available.
|
|
*/
|
|
submenu?: Menu;
|
|
}
|
|
|
|
// Reason: SecretStorage is declared as a class by obsidian@>=1.11.4, but the
|
|
// package pinned in package.json (^1.2.5) ships an older `.d.ts` that lacks
|
|
// it entirely. Declare the full shape here so the project compiles against
|
|
// older obsidian types without bumping the dev dependency (which would also
|
|
// shift @codemirror peers and widen this PR's blast radius). `deleteSecret`
|
|
// is intentionally optional — it exists at runtime since 1.11.4 but remains
|
|
// undocumented, so callers feature-detect it.
|
|
interface SecretStorage {
|
|
setSecret(id: string, secret: string): void;
|
|
getSecret(id: string): string | null;
|
|
listSecrets(): string[];
|
|
deleteSecret?(id: string): void;
|
|
}
|
|
|
|
interface App {
|
|
secretStorage?: SecretStorage;
|
|
}
|
|
}
|
|
|
|
export enum PromptSortStrategy {
|
|
TIMESTAMP = "timestamp",
|
|
ALPHABETICAL = "alphabetical",
|
|
MANUAL = "manual",
|
|
}
|
|
|
|
export type ApplyViewResult = "accepted" | "rejected" | "aborted" | "failed";
|