Skip to content

Commit 8ae0c08

Browse files
authored
secret storage (#239)
* feat: Implement secure API key storage using Obsidian's SecretStorage API, migrate existing keys, and update `GeminiService` to dynamically retrieve them. * feat: Resolve Google API key via `geminiService` for validation and update the plugin interface to include `geminiService`. * refactor: Make `GeminiService` API key retrieval and `SecretStorage` interactions synchronous, and add a retry option for secure storage migrations. * Refactor 'Obsidian' string in a notice to avoid a linter flag and update API key setting description text.
1 parent 3703fa5 commit 8ae0c08

6 files changed

Lines changed: 208 additions & 90 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ New features are added in the "Unreleased" section.
1313

1414
### Developer features
1515

16+
- **Secure API key storage**: Migrated Google Gemini API keys from plain text `data.json` to Obsidian's native `SecretStorage` API (v1.11.4+).
17+
- **JIT initialization**: Refactored `GeminiService` to use asynchronous just-in-time client instantiation, preventing "Async Constructor" race conditions during plugin load.
18+
- **Stable secret IDs**: Mandated a persistent secret ID (`vault-intelligence-api-key`) to prevent sync-induced "ping-pong" conflicts between multiple devices.
19+
- **Robust Linux fallback**: Implemented a fail-safe migration handler that automatically detects and suppresses repeated keyring failures on minimal Linux environments, falling back to secure-ish plain text only when necessary.
20+
- **Improved UI security**: Replaced the standard text input with Obsidian's `SecretComponent`, providing clear visual feedback on encryption status and better UX for managing credentials.
21+
1622
## [7.0.0] - 2026-02-15
1723

1824
### Breaking changes

manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"id": "vault-intelligence",
33
"name": "Vault Intelligence",
44
"version": "7.0.0",
5-
"minAppVersion": "1.5.0",
5+
"minAppVersion": "1.11.4",
66
"description": "Research your vault with an AI agent powered by Google Gemini, discover related notes through semantic search, and analyze specific documents with contextual note referencing.",
77
"author": "Allan Engelhardt",
88
"authorUrl": "https://github.com/cybaea",

src/main.ts

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -148,14 +148,17 @@ export default class VaultIntelligencePlugin extends Plugin implements IVaultInt
148148
logger.setLevel(this.settings.logLevel);
149149

150150
// 1. Initialize Base Services (Chat/Reasoning always needs Gemini for now)
151-
this.geminiService = new GeminiService(this.settings);
151+
this.geminiService = new GeminiService(this.settings, this.app);
152152

153-
// 1b. Fetch available models asynchronously
153+
// 1b. Fetch available models asynchronously (Now uses getApiKey resolver)
154154
if (this.settings.googleApiKey) {
155155
void (async () => {
156-
await ModelRegistry.fetchModels(this.app, this.settings.googleApiKey, this.settings.modelCacheDurationDays);
157-
// Re-sanitize after fetch completes in case dynamic limits are different
158-
await this.sanitizeBudgets();
156+
const apiKey = this.geminiService.getApiKey(); // Synchronous call
157+
if (apiKey) {
158+
await ModelRegistry.fetchModels(this.app, apiKey, this.settings.modelCacheDurationDays);
159+
// Re-sanitize after fetch completes in case dynamic limits are different
160+
await this.sanitizeBudgets();
161+
}
159162
})();
160163
}
161164

@@ -373,6 +376,29 @@ export default class VaultIntelligencePlugin extends Plugin implements IVaultInt
373376
this.settings.gardenerSystemInstruction = null;
374377
}
375378

379+
// --- SecretStorage Migration (v7.0.0) ---
380+
if (this.settings.googleApiKey && this.settings.googleApiKey.startsWith('AIza') && !this.settings.secretStorageFailure) {
381+
try {
382+
logger.info("[SecretStorage] Migrating API key to secure storage...");
383+
// SecretStorage is synchronous in v1.11.4+
384+
// @ts-ignore - Types might be lagging strictly in some environments
385+
if (this.app.secretStorage && this.app.secretStorage.setSecret) {
386+
this.app.secretStorage.setSecret('vault-intelligence-api-key', this.settings.googleApiKey);
387+
this.settings.googleApiKey = 'vault-intelligence-api-key';
388+
await this.saveData(this.settings);
389+
logger.info("[SecretStorage] Migration successful.");
390+
} else {
391+
throw new Error("SecretStorage API not available.");
392+
}
393+
} catch (error) {
394+
logger.error("[SecretStorage] Migration failed:", error);
395+
// Suppress future attempts to avoid "Nag Loop" on broken Linux systems
396+
this.settings.secretStorageFailure = true;
397+
await this.saveData(this.settings);
398+
new Notice("Secure storage unavailable. Storing API key as plain text.");
399+
}
400+
}
401+
376402
await this.sanitizeBudgets();
377403

378404
// Sanity check: Ensure dimensions match presets if using a local provider

src/services/GeminiService.ts

Lines changed: 69 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { GoogleGenAI, Content, Tool, EmbedContentConfig } from "@google/genai";
2+
import { App } from "obsidian";
23

34
import { MODEL_CONSTANTS, SEARCH_CONSTANTS } from "../constants";
45
import { VaultIntelligenceSettings } from "../settings";
@@ -11,35 +12,70 @@ export interface EmbedOptions {
1112
}
1213

1314
export class GeminiService {
14-
private client: GoogleGenAI;
15+
private client: GoogleGenAI | null = null;
1516
private settings: VaultIntelligenceSettings;
17+
private app: App;
1618

17-
constructor(settings: VaultIntelligenceSettings) {
19+
constructor(settings: VaultIntelligenceSettings, app: App) {
1820
this.settings = settings;
19-
this.initialize();
21+
this.app = app;
2022
}
2123

22-
public initialize() {
23-
if (!this.settings.googleApiKey) {
24-
logger.warn("Google API Key is missing.");
25-
return;
24+
/**
25+
* Resolves the actual Google API key.
26+
* Handles SecretStorage ID lookup vs Legacy plain text fallback.
27+
* Public to allow ModelRegistry access in main.ts.
28+
*/
29+
public getApiKey(): string | null {
30+
const storedValue = this.settings.googleApiKey;
31+
if (!storedValue) return null;
32+
33+
// Fallback for Linux or failed migration
34+
if (this.settings.secretStorageFailure || storedValue.startsWith('AIza')) {
35+
return storedValue;
36+
}
37+
38+
// SecretStorage Lookup
39+
if (storedValue === 'vault-intelligence-api-key') {
40+
try {
41+
// SecretStorage is synchronous in v1.11.4+
42+
// @ts-ignore - Types might be lagging strictly in some environments, but we checked d.ts
43+
return this.app.secretStorage.getSecret(storedValue);
44+
} catch (error) {
45+
logger.error("Failed to retrieve secret from storage:", error);
46+
return null;
47+
}
48+
}
49+
50+
return null;
51+
}
52+
53+
private getClient(): GoogleGenAI | null {
54+
if (this.client) return this.client;
55+
56+
const apiKey = this.getApiKey();
57+
if (!apiKey) {
58+
logger.warn("Google API Key is missing or could not be retrieved.");
59+
return null;
2660
}
2761

2862
try {
29-
this.client = new GoogleGenAI({ apiKey: this.settings.googleApiKey });
30-
logger.info("GeminiService initialized for Chat/Reasoning with @google/genai.");
63+
this.client = new GoogleGenAI({ apiKey });
64+
logger.info("GeminiService initialized with resolved key.");
65+
return this.client;
3166
} catch (error) {
32-
logger.error("Failed to initialize GeminiService:", error);
67+
logger.error("Failed to initialize Gemini client:", error);
68+
return null;
3369
}
3470
}
3571

3672
public updateSettings(settings: VaultIntelligenceSettings) {
3773
this.settings = settings;
38-
this.initialize();
74+
this.client = null; // Force re-initialization on next call
3975
}
4076

4177
public isReady(): boolean {
42-
return !!this.client;
78+
return !!this.client || (!!this.settings.googleApiKey && !this.settings.secretStorageFailure);
4379
}
4480

4581
public getEmbeddingModelName(): string {
@@ -48,9 +84,10 @@ export class GeminiService {
4884

4985
public async generateContent(prompt: string): Promise<{ text: string, tokenCount: number }> {
5086
return this.retryOperation(async () => {
51-
if (!this.client) throw new Error("GenAI client not initialized.");
87+
const client = this.getClient();
88+
if (!client) throw new Error("GenAI client could not be initialized.");
5289

53-
const response = await this.client.models.generateContent({
90+
const response = await client.models.generateContent({
5491
contents: prompt,
5592
model: this.settings.chatModel
5693
});
@@ -75,11 +112,12 @@ export class GeminiService {
75112
options: { model?: string; systemInstruction?: string; tools?: Tool[] } = {}
76113
): Promise<{ text: string, tokenCount: number }> {
77114
return this.retryOperation(async () => {
78-
if (!this.client) throw new Error("GenAI client not initialized.");
115+
const client = this.getClient();
116+
if (!client) throw new Error("GenAI client could not be initialized.");
79117

80118
const modelId = options.model || this.settings.chatModel;
81119

82-
const response = await this.client.models.generateContent({
120+
const response = await client.models.generateContent({
83121
config: {
84122
responseMimeType: "application/json",
85123
responseSchema: schema,
@@ -106,12 +144,13 @@ export class GeminiService {
106144
*/
107145
public async searchWithGrounding(query: string): Promise<{ text: string, tokenCount: number }> {
108146
return this.retryOperation(async () => {
109-
if (!this.client) throw new Error("GenAI client not initialized.");
147+
const client = this.getClient();
148+
if (!client) throw new Error("GenAI client could not be initialized.");
110149

111150
const prompt = `Search for: "${query}". List key facts, dates, and details. Be concise.`;
112151
const groundingModel = this.settings.groundingModel;
113152

114-
const response = await this.client.models.generateContent({
153+
const response = await client.models.generateContent({
115154
config: {
116155
tools: [{ googleSearch: {} }]
117156
},
@@ -136,15 +175,16 @@ export class GeminiService {
136175
*/
137176
public async solveWithCode(query: string): Promise<{ text: string, tokenCount: number }> {
138177
return this.retryOperation(async () => {
139-
if (!this.client) throw new Error("GenAI client not initialized.");
178+
const client = this.getClient();
179+
if (!client) throw new Error("GenAI client could not be initialized.");
140180

141181
if (!this.settings.enableCodeExecution || !this.settings.codeModel) {
142182
return { text: "Code execution is currently disabled in settings.", tokenCount: 0 };
143183
}
144184

145185
const codeModel = this.settings.codeModel;
146186

147-
const response = await this.client.models.generateContent({
187+
const response = await client.models.generateContent({
148188
config: {
149189
tools: [{ codeExecution: {} }]
150190
},
@@ -193,9 +233,10 @@ export class GeminiService {
193233
*/
194234
public async startChat(history: Content[], tools?: Tool[], systemInstruction: string = "", modelId?: string) {
195235
return this.retryOperation(async () => {
196-
if (!this.client) throw new Error("GenAI client not initialized.");
236+
const client = this.getClient();
237+
if (!client) throw new Error("GenAI client could not be initialized.");
197238

198-
const chat = this.client.chats.create({
239+
const chat = client.chats.create({
199240
config: {
200241
systemInstruction: systemInstruction,
201242
tools: tools
@@ -209,10 +250,11 @@ export class GeminiService {
209250

210251
public async embedText(text: string, options: EmbedOptions = {}): Promise<{ values: number[], tokenCount: number }> {
211252
return this.retryOperation(async () => {
212-
if (!this.client) throw new Error("GenAI client not initialized.");
253+
const client = this.getClient();
254+
if (!client) throw new Error("GenAI client could not be initialized.");
213255

214256
const config: EmbedContentConfig = {};
215-
257+
// ... (rest of method unchanged)
216258
if (options.outputDimensionality) {
217259
config.outputDimensionality = options.outputDimensionality;
218260
} else {
@@ -227,7 +269,7 @@ export class GeminiService {
227269
if (modelId === 'embedding-001') modelId = MODEL_CONSTANTS.EMBEDDING_001;
228270
if (modelId === 'embedding-004') modelId = MODEL_CONSTANTS.TEXT_EMBEDDING_004;
229271

230-
const result = await this.client.models.embedContent({
272+
const result = await client.models.embedContent({
231273
config: config,
232274
contents: text,
233275
model: modelId
@@ -288,7 +330,8 @@ export class GeminiService {
288330
*/
289331
public async reRank(query: string, payload: unknown[]): Promise<unknown[]> {
290332
return this.retryOperation(async () => {
291-
if (!this.client) throw new Error("GenAI client not initialized.");
333+
const client = this.getClient();
334+
if (!client) throw new Error("GenAI client could not be initialized.");
292335

293336
// Truncate payload if too huge (should be handled by packer budget, but safety first)
294337
const safePayload = payload.slice(0, 50);

0 commit comments

Comments
 (0)