From 19e1b6ceff0021374544f8433188d64b7e5c6ebf Mon Sep 17 00:00:00 2001 From: Andreas Sander Date: Fri, 26 Dec 2025 19:33:12 +0100 Subject: [PATCH] feat: migrate API key storage to SecretStorage API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses security vulnerability where API keys were stored in plain text in settings.json, risking accidental version control exposure and settings sync leakage. Changes: - ConfigService now uses VSCode SecretStorage for secure credential storage - Added 'TinyPNG: Set API Key' command for secure key input (password masked) - Deprecated tinypng.apiKey setting with migration message - Auto-migrates existing API keys from settings to SecretStorage - Made activation async to support SecretStorage operations Closes #739 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- package.json | 14 +++- src/commands/setApiKey.ts | 34 ++++++++++ src/extension.ts | 35 +++++++--- src/services/compressionService.ts | 6 +- src/services/configService.ts | 65 +++++++++++++++++- src/utils/errorHandler.ts | 11 ++- test/suite/extension.test.ts | 9 ++- test/suite/integration.test.ts | 6 +- test/suite/secretStorage.test.ts | 105 +++++++++++++++++++++++++++++ 9 files changed, 262 insertions(+), 23 deletions(-) create mode 100644 src/commands/setApiKey.ts create mode 100644 test/suite/secretStorage.test.ts diff --git a/package.json b/package.json index e1d9948..e19923a 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "activationEvents": [ "onCommand:extension.compressFile", "onCommand:extension.compressFolder", - "onCommand:extension.getCompressionCount" + "onCommand:extension.getCompressionCount", + "onCommand:extension.setApiKey" ], "main": "./out/src/extension.js", "contributes": { @@ -28,7 +29,8 @@ "properties": { "tinypng.apiKey": { "type": "string", - "description": "Your TinyPNG API Key" + "description": "DEPRECATED: Use Command Palette > 'TinyPNG: Set API Key' instead. Your TinyPNG API Key", + "deprecationMessage": "Please use the Command Palette command 'TinyPNG: Set API Key' to store your key securely." }, "tinypng.forceOverwrite": { "type": "boolean", @@ -70,6 +72,11 @@ "command": "extension.compressGitStage", "title": "Compress images in git stage", "category": "TinyPNG" + }, + { + "command": "extension.setApiKey", + "title": "TinyPNG: Set API Key", + "category": "TinyPNG" } ], "menus": { @@ -77,6 +84,9 @@ { "command": "extension.getCompressionCount" }, + { + "command": "extension.setApiKey" + }, { "command": "extension.compressFile", "when": "False" diff --git a/src/commands/setApiKey.ts b/src/commands/setApiKey.ts new file mode 100644 index 0000000..aa43ae4 --- /dev/null +++ b/src/commands/setApiKey.ts @@ -0,0 +1,34 @@ +import vscode = require('vscode'); +import { ConfigService } from '../services/configService'; +import { CompressionService } from '../services/compressionService'; + +/** + * Command to securely set the TinyPNG API key + */ +export async function setApiKeyCommand(context: vscode.ExtensionContext): Promise { + const apiKey = await vscode.window.showInputBox({ + prompt: 'Enter your TinyPNG API Key', + password: true, + ignoreFocusOut: true, + placeHolder: 'Your API key from https://tinypng.com/developers' + }); + + if (apiKey) { + await ConfigService.setApiKey(context, apiKey); + + // Re-initialize the compression service with the new key + await CompressionService.initialize(context); + + // Validate the new API key + CompressionService.validate( + () => { + vscode.window.showInformationMessage('TinyPNG API key saved and validated successfully!'); + }, + (error: Error) => { + vscode.window.showWarningMessage( + `TinyPNG API key saved, but validation failed: ${error.message}. Please check if the key is correct.` + ); + } + ); + } +} diff --git a/src/extension.ts b/src/extension.ts index c10e242..a6bc73a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -4,20 +4,30 @@ import { compressFileCommand } from './commands/compressFile'; import { compressFolderCommand } from './commands/compressFolder'; import { compressGitStageCommand } from './commands/compressGitStage'; import { getCompressionCountCommand } from './commands/getCompressionCount'; +import { setApiKeyCommand } from './commands/setApiKey'; import { handleValidationError } from './utils/errorHandler'; /** * Activate the extension */ -export function activate(context: vscode.ExtensionContext): void { - // Initialize the compression service with API key - CompressionService.initialize(); - - // Validate the API key - CompressionService.validate( - () => console.log('Validation successful!'), - handleValidationError - ); +export async function activate(context: vscode.ExtensionContext): Promise { + // Initialize the compression service with API key from SecretStorage + try { + await CompressionService.initialize(context); + + // Validate the API key (fire and forget - don't block activation) + CompressionService.validate( + () => console.log('TinyPNG: API key validated successfully'), + handleValidationError + ); + } catch (error) { + console.error('TinyPNG: Failed to initialize extension', error); + vscode.window.showErrorMessage( + `TinyPNG: Extension initialization failed. ${error instanceof Error ? error.message : 'Unknown error'}` + ); + // Continue to register commands even if initialization fails + // This allows users to still set their API key + } // Register command: Compress single file const disposableCompressFile = vscode.commands.registerCommand( @@ -46,6 +56,13 @@ export function activate(context: vscode.ExtensionContext): void { compressGitStageCommand ); context.subscriptions.push(disposableCompressGitStage); + + // Register command: Set API key securely + const disposableSetApiKey = vscode.commands.registerCommand( + 'extension.setApiKey', + () => setApiKeyCommand(context) + ); + context.subscriptions.push(disposableSetApiKey); } /** diff --git a/src/services/compressionService.ts b/src/services/compressionService.ts index 934087c..518f7ed 100644 --- a/src/services/compressionService.ts +++ b/src/services/compressionService.ts @@ -12,10 +12,10 @@ import fs = require('fs'); */ export class CompressionService { /** - * Initialize the TinyPNG API with the API key + * Initialize the TinyPNG API with the API key from SecretStorage */ - public static initialize(): void { - const apiKey = ConfigService.getApiKey(); + public static async initialize(context: vscode.ExtensionContext): Promise { + const apiKey = await ConfigService.getApiKey(context); if (apiKey) { tinify.key = apiKey; } diff --git a/src/services/configService.ts b/src/services/configService.ts index 5c45d6b..0d2d8f8 100644 --- a/src/services/configService.ts +++ b/src/services/configService.ts @@ -6,6 +6,8 @@ import { TinyPngConfig } from '../types'; */ export class ConfigService { private static readonly CONFIG_SECTION = 'tinypng'; + private static readonly SECRET_KEY = 'tinypng.apiKey'; + private static readonly MIGRATION_STATE_KEY = 'tinypng.migrationCompleted'; /** * Get the current TinyPNG configuration @@ -21,12 +23,69 @@ export class ConfigService { } /** - * Get the API key from configuration + * Get the API key from SecretStorage (preferred) or fallback to settings */ - public static getApiKey(): string | undefined { - return vscode.workspace + public static async getApiKey(context: vscode.ExtensionContext): Promise { + // Try SecretStorage first + const secretKey = await context.secrets.get(this.SECRET_KEY); + if (secretKey) { + return secretKey; + } + + // Fallback to settings for backward compatibility + const settingsKey = vscode.workspace .getConfiguration(this.CONFIG_SECTION) .get('apiKey'); + + // Migrate to SecretStorage if found in settings + if (settingsKey) { + await this.setApiKey(context, settingsKey); + + // Check if we've already shown the migration warning + const migrationCompleted = context.globalState.get(this.MIGRATION_STATE_KEY); + if (!migrationCompleted) { + await context.globalState.update(this.MIGRATION_STATE_KEY, true); + + // Prompt user with actionable options + const action = await vscode.window.showWarningMessage( + 'TinyPNG: API key found in settings.json has been migrated to secure storage. Remove it from settings for security?', + 'Remove from Settings', + 'I\'ll Do It Manually' + ); + + if (action === 'Remove from Settings') { + try { + await vscode.workspace.getConfiguration(this.CONFIG_SECTION) + .update('apiKey', undefined, vscode.ConfigurationTarget.Global); + await vscode.workspace.getConfiguration(this.CONFIG_SECTION) + .update('apiKey', undefined, vscode.ConfigurationTarget.Workspace); + vscode.window.showInformationMessage('TinyPNG: API key removed from settings and stored securely.'); + } catch (err) { + vscode.window.showWarningMessage( + 'TinyPNG: Could not automatically remove API key from settings. Please remove tinypng.apiKey manually from settings.json.' + ); + } + } + } + + return settingsKey; + } + + return undefined; + } + + /** + * Store API key securely in SecretStorage + */ + public static async setApiKey(context: vscode.ExtensionContext, apiKey: string): Promise { + await context.secrets.store(this.SECRET_KEY, apiKey); + } + + /** + * Delete API key from SecretStorage + */ + public static async deleteApiKey(context: vscode.ExtensionContext): Promise { + await context.secrets.delete(this.SECRET_KEY); } /** diff --git a/src/utils/errorHandler.ts b/src/utils/errorHandler.ts index f91574d..39651ef 100644 --- a/src/utils/errorHandler.ts +++ b/src/utils/errorHandler.ts @@ -36,7 +36,12 @@ export function handleCompressionError(error: Error): void { */ export function handleValidationError(error: Error): void { console.error(error.message); - vscode.window.showInformationMessage( - 'TinyPNG: API validation failed. Be sure that you filled out tinypng.apiKey setting already.' - ); + vscode.window.showErrorMessage( + 'TinyPNG: API validation failed. Please set your API key using the "TinyPNG: Set API Key" command.', + 'Set API Key' + ).then(selection => { + if (selection === 'Set API Key') { + vscode.commands.executeCommand('extension.setApiKey'); + } + }); } diff --git a/test/suite/extension.test.ts b/test/suite/extension.test.ts index 9b9d64a..10da7e4 100644 --- a/test/suite/extension.test.ts +++ b/test/suite/extension.test.ts @@ -35,6 +35,11 @@ suite('TinyPNG Extension Test Suite', () => { assert.ok(commands.includes('extension.compressGitStage')); }); + test('Should register setApiKey command', async () => { + const commands = await vscode.commands.getCommands(true); + assert.ok(commands.includes('extension.setApiKey')); + }); + test('Configuration should have apiKey property', () => { const config = vscode.workspace.getConfiguration('tinypng'); assert.ok(config.has('apiKey')); @@ -134,6 +139,7 @@ suite('TinyPNG Extension Test Suite', () => { assert.ok(activationEvents.includes('onCommand:extension.compressFile')); assert.ok(activationEvents.includes('onCommand:extension.compressFolder')); assert.ok(activationEvents.includes('onCommand:extension.getCompressionCount')); + assert.ok(activationEvents.includes('onCommand:extension.setApiKey')); }); test('Extension should contribute commands to command palette', () => { @@ -141,13 +147,14 @@ suite('TinyPNG Extension Test Suite', () => { assert.ok(ext); const commands = ext!.packageJSON.contributes.commands; - assert.ok(commands.length >= 4); + assert.ok(commands.length >= 5); const commandTitles = commands.map((cmd: any) => cmd.command); assert.ok(commandTitles.includes('extension.compressFile')); assert.ok(commandTitles.includes('extension.compressFolder')); assert.ok(commandTitles.includes('extension.getCompressionCount')); assert.ok(commandTitles.includes('extension.compressGitStage')); + assert.ok(commandTitles.includes('extension.setApiKey')); }); test('Extension should have explorer context menu contributions', () => { diff --git a/test/suite/integration.test.ts b/test/suite/integration.test.ts index 53da532..ecc2b6e 100644 --- a/test/suite/integration.test.ts +++ b/test/suite/integration.test.ts @@ -18,7 +18,8 @@ suite('Integration Tests', () => { 'extension.compressFile', 'extension.compressFolder', 'extension.getCompressionCount', - 'extension.compressGitStage' + 'extension.compressGitStage', + 'extension.setApiKey' ]; extensionCommands.forEach(cmd => { @@ -152,7 +153,8 @@ suite('Integration Tests', () => { const expectedEvents = [ 'onCommand:extension.compressFile', 'onCommand:extension.compressFolder', - 'onCommand:extension.getCompressionCount' + 'onCommand:extension.getCompressionCount', + 'onCommand:extension.setApiKey' ]; expectedEvents.forEach(event => { diff --git a/test/suite/secretStorage.test.ts b/test/suite/secretStorage.test.ts new file mode 100644 index 0000000..924b247 --- /dev/null +++ b/test/suite/secretStorage.test.ts @@ -0,0 +1,105 @@ +import * as assert from 'assert'; +import * as vscode from 'vscode'; + +suite('SecretStorage Tests', () => { + let extension: vscode.Extension | undefined; + + suiteSetup(async () => { + extension = vscode.extensions.getExtension('andi1984.tinypng'); + if (extension && !extension.isActive) { + await extension.activate(); + } + }); + + suite('Set API Key Command', () => { + test('setApiKey command should be registered', async () => { + const commands = await vscode.commands.getCommands(true); + assert.ok( + commands.includes('extension.setApiKey'), + 'extension.setApiKey command should be registered' + ); + }); + + test('setApiKey command should be in command palette', () => { + assert.ok(extension); + const menus = extension!.packageJSON.contributes.menus; + const paletteMenus = menus['commandPalette']; + + const setApiKeyMenu = paletteMenus.find( + (m: any) => m.command === 'extension.setApiKey' + ); + + assert.ok(setApiKeyMenu, 'setApiKey should be in command palette'); + // Should not have a "when" clause that hides it + assert.ok( + !setApiKeyMenu.when || setApiKeyMenu.when !== 'False', + 'setApiKey should be visible in command palette' + ); + }); + + test('setApiKey command should have correct title', () => { + assert.ok(extension); + const commands = extension!.packageJSON.contributes.commands; + + const setApiKeyCmd = commands.find( + (cmd: any) => cmd.command === 'extension.setApiKey' + ); + + assert.ok(setApiKeyCmd); + assert.strictEqual(setApiKeyCmd.title, 'TinyPNG: Set API Key'); + assert.strictEqual(setApiKeyCmd.category, 'TinyPNG'); + }); + }); + + suite('API Key Configuration', () => { + test('apiKey setting should be deprecated', () => { + assert.ok(extension); + const configuration = extension!.packageJSON.contributes.configuration[0]; + const apiKeySetting = configuration.properties['tinypng.apiKey']; + + assert.ok(apiKeySetting); + assert.ok( + apiKeySetting.deprecationMessage, + 'apiKey setting should have a deprecation message' + ); + assert.ok( + apiKeySetting.deprecationMessage.includes('Set API Key'), + 'Deprecation message should reference the new command' + ); + }); + + test('apiKey setting description should indicate deprecation', () => { + assert.ok(extension); + const configuration = extension!.packageJSON.contributes.configuration[0]; + const apiKeySetting = configuration.properties['tinypng.apiKey']; + + assert.ok(apiKeySetting); + assert.ok( + apiKeySetting.description.includes('DEPRECATED'), + 'Description should indicate deprecation' + ); + }); + }); + + suite('Activation Events', () => { + test('setApiKey command should be in activation events', () => { + assert.ok(extension); + const activationEvents = extension!.packageJSON.activationEvents; + + assert.ok( + activationEvents.includes('onCommand:extension.setApiKey'), + 'setApiKey should be in activation events' + ); + }); + }); + + suite('ConfigService API Key Methods', () => { + test('ConfigService should export getApiKey method', async () => { + // We can't directly test the ConfigService internals without mocking, + // but we can verify the extension activates successfully which means + // the async getApiKey method works + assert.ok(extension); + assert.ok(extension!.isActive, 'Extension should be active'); + }); + }); +});