diff --git a/app/packages/desktop-main/package.json b/app/packages/desktop-main/package.json index eaf4dd1..2ec8c49 100644 --- a/app/packages/desktop-main/package.json +++ b/app/packages/desktop-main/package.json @@ -22,6 +22,7 @@ "@nestjs/core": "^10.4.0", "@nestjs/microservices": "^10.4.0", "@nestjs/platform-express": "^10.4.0", + "electron-store": "^11.0.2", "express": "^4.19.2", "fix-path": "^4.0.0", "nestjs-electron-ipc-transport": "^1.0.2", diff --git a/app/packages/desktop-main/src/app.module.ts b/app/packages/desktop-main/src/app.module.ts index 80e7cb8..5b40a56 100644 --- a/app/packages/desktop-main/src/app.module.ts +++ b/app/packages/desktop-main/src/app.module.ts @@ -15,6 +15,8 @@ import { EnvController } from './controllers/env.controller.js'; import { DiagnosticsController } from './controllers/diagnostics.controller.js'; import { ApiTokenGuard } from './guards/api-token.guard.js'; import { DiagnosticsService, DIAGNOSTICS_LOG_DIR } from './services/DiagnosticsService.js'; +import { SafeStorageService } from './services/SafeStorageService.js'; +import { ElectronStoreService } from './services/ElectronStoreService.js'; /** * Root Nest module. Wires the feature modules (`AwsModule`, `DiscordModule`) to @@ -50,6 +52,8 @@ import { DiagnosticsService, DIAGNOSTICS_LOG_DIR } from './services/DiagnosticsS }, }, DiagnosticsService, + SafeStorageService, + ElectronStoreService, ], }) export class AppModule {} diff --git a/app/packages/desktop-main/src/services/ElectronStoreService.test.ts b/app/packages/desktop-main/src/services/ElectronStoreService.test.ts new file mode 100644 index 0000000..8873ad1 --- /dev/null +++ b/app/packages/desktop-main/src/services/ElectronStoreService.test.ts @@ -0,0 +1,233 @@ +/** + * Unit tests for ElectronStoreService. + * + * `electron-store` is mocked at the module level so no real disk I/O or + * Electron native modules are ever touched. Protected methods + * (`readIsElectron`, `createStore`) are stubbed via `vi.spyOn` on the + * prototype before each Electron-path construction so the constructor takes + * the right branch. + */ +import 'reflect-metadata'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type Store from 'electron-store'; + +vi.mock('../logger.js', () => ({ + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +vi.mock('electron-store', () => { + const MockStore = vi.fn().mockImplementation(() => ({ + get: vi.fn(), + set: vi.fn(), + })); + return { default: MockStore }; +}); + +import { ElectronStoreService, type AppStoreSchema } from './ElectronStoreService.js'; +import { SafeStorageService } from './SafeStorageService.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Creates a `SafeStorageService` whose `encrypt` / `decrypt` methods are + * identity functions by default (outside-Electron degraded path). + */ +function makeSafeStorage(): SafeStorageService { + return new SafeStorageService(); +} + +/** + * Builds a minimal mock `Store` compatible with what + * `ElectronStoreService` calls on it. + */ +function makeMockStore(): Store { + return { + get: vi.fn(), + set: vi.fn(), + } as unknown as Store; +} + +// --------------------------------------------------------------------------- +// Non-Electron path (Map fallback) +// --------------------------------------------------------------------------- + +describe('ElectronStoreService — non-Electron path (Map fallback)', () => { + let service: ElectronStoreService; + let safeStorage: SafeStorageService; + + beforeEach(() => { + safeStorage = makeSafeStorage(); + // process.versions['electron'] is not set in Vitest/Node, so the Map + // fallback is used automatically — no spy needed. + service = new ElectronStoreService(safeStorage); + vi.clearAllMocks(); + }); + + it('should use Map fallback when not running in Electron', () => { + expect(service.isElectron()).toBe(false); + expect(service.get('wizardCompleted')).toBeUndefined(); + }); + + it('should store and retrieve a value in Map fallback', () => { + service.set('wizardCompleted', true); + + expect(service.get('wizardCompleted')).toBe(true); + }); + + it('should store and retrieve a nested object in Map fallback', () => { + const awsValue: AppStoreSchema['aws'] = { region: 'us-east-1', profile: 'default' }; + service.set('aws', awsValue); + + expect(service.get('aws')).toEqual(awsValue); + }); +}); + +// --------------------------------------------------------------------------- +// Electron path (mocked Store) +// --------------------------------------------------------------------------- + +describe('ElectronStoreService — Electron path (mocked Store)', () => { + let service: ElectronStoreService; + let safeStorage: SafeStorageService; + let mockStore: Store; + + beforeEach(() => { + safeStorage = makeSafeStorage(); + mockStore = makeMockStore(); + + // Stub prototype BEFORE construction so the constructor takes the Electron branch. + vi.spyOn( + ElectronStoreService.prototype as unknown as { readIsElectron(): boolean }, + 'readIsElectron', + ).mockReturnValue(true); + vi.spyOn( + ElectronStoreService.prototype as unknown as { createStore(): Store }, + 'createStore', + ).mockReturnValue(mockStore); + + service = new ElectronStoreService(safeStorage); + vi.clearAllMocks(); + }); + + it('should call store.get when running in Electron', () => { + (mockStore.get as ReturnType).mockReturnValue(true); + + const result = service.get('wizardCompleted'); + + expect(mockStore.get).toHaveBeenCalledWith('wizardCompleted'); + expect(result).toBe(true); + }); + + it('should call store.set when running in Electron', () => { + service.set('wizardCompleted', true); + + expect(mockStore.set).toHaveBeenCalledWith('wizardCompleted', true); + }); +}); + +// --------------------------------------------------------------------------- +// Secret field — setSecretAccessKeyId / getSecretAccessKeyId +// --------------------------------------------------------------------------- + +describe('ElectronStoreService — setSecretAccessKeyId / getSecretAccessKeyId', () => { + let service: ElectronStoreService; + let safeStorage: SafeStorageService; + + beforeEach(() => { + safeStorage = makeSafeStorage(); + service = new ElectronStoreService(safeStorage); + vi.clearAllMocks(); + }); + + it('should encrypt accessKeyId before storing', () => { + vi.spyOn(safeStorage, 'encrypt').mockReturnValue('enc-key-id'); + + service.setSecretAccessKeyId('AKID123'); + + expect(safeStorage.encrypt).toHaveBeenCalledWith('AKID123'); + const stored = service.get('aws'); + expect(stored?.accessKeyId).toBe('enc-key-id'); + }); + + it('should decrypt accessKeyId when reading', () => { + service.set('aws', { region: 'us-east-1', profile: 'default', accessKeyId: 'enc-key-id' }); + vi.spyOn(safeStorage, 'decrypt').mockReturnValue('AKID123'); + + const result = service.getSecretAccessKeyId(); + + expect(safeStorage.decrypt).toHaveBeenCalledWith('enc-key-id'); + expect(result).toBe('AKID123'); + }); + + it('should return undefined for accessKeyId when not stored', () => { + expect(service.getSecretAccessKeyId()).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Secret field — setSecretAccessKey / getSecretAccessKey +// --------------------------------------------------------------------------- + +describe('ElectronStoreService — setSecretAccessKey / getSecretAccessKey', () => { + let service: ElectronStoreService; + let safeStorage: SafeStorageService; + + beforeEach(() => { + safeStorage = makeSafeStorage(); + service = new ElectronStoreService(safeStorage); + vi.clearAllMocks(); + }); + + it('should encrypt secretAccessKey before storing', () => { + vi.spyOn(safeStorage, 'encrypt').mockReturnValue('enc-secret-key'); + + service.setSecretAccessKey('MY_SECRET'); + + expect(safeStorage.encrypt).toHaveBeenCalledWith('MY_SECRET'); + const stored = service.get('aws'); + expect(stored?.secretAccessKey).toBe('enc-secret-key'); + }); + + it('should decrypt secretAccessKey when reading', () => { + service.set('aws', { region: 'us-east-1', profile: 'default', secretAccessKey: 'enc-secret-key' }); + vi.spyOn(safeStorage, 'decrypt').mockReturnValue('MY_SECRET'); + + const result = service.getSecretAccessKey(); + + expect(safeStorage.decrypt).toHaveBeenCalledWith('enc-secret-key'); + expect(result).toBe('MY_SECRET'); + }); + + it('should return undefined for secretAccessKey when not stored', () => { + expect(service.getSecretAccessKey()).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Round-trip +// --------------------------------------------------------------------------- + +describe('ElectronStoreService — round-trip', () => { + let service: ElectronStoreService; + let safeStorage: SafeStorageService; + + beforeEach(() => { + safeStorage = makeSafeStorage(); + service = new ElectronStoreService(safeStorage); + vi.clearAllMocks(); + }); + + it('should encrypt and decrypt accessKeyId in a round-trip', () => { + vi.spyOn(safeStorage, 'encrypt').mockImplementation((plaintext: string) => `enc-${plaintext}`); + vi.spyOn(safeStorage, 'decrypt').mockImplementation((ciphertext: string) => + ciphertext.startsWith('enc-') ? ciphertext.slice(4) : ciphertext, + ); + + service.setSecretAccessKeyId('AKID123'); + const result = service.getSecretAccessKeyId(); + + expect(result).toBe('AKID123'); + }); +}); diff --git a/app/packages/desktop-main/src/services/ElectronStoreService.ts b/app/packages/desktop-main/src/services/ElectronStoreService.ts new file mode 100644 index 0000000..77c16e5 --- /dev/null +++ b/app/packages/desktop-main/src/services/ElectronStoreService.ts @@ -0,0 +1,161 @@ +import { Injectable } from '@nestjs/common'; +import Store from 'electron-store'; +import { logger } from '../logger.js'; +import { SafeStorageService } from './SafeStorageService.js'; + +/** + * Typed schema for the application's persistent electron-store. + * + * Secret fields (`aws.accessKeyId`, `aws.secretAccessKey`) are stored + * encrypted via {@link SafeStorageService} and must never be read or written + * directly — always use {@link ElectronStoreService.getSecretAccessKeyId}, + * {@link ElectronStoreService.setSecretAccessKeyId}, + * {@link ElectronStoreService.getSecretAccessKey}, and + * {@link ElectronStoreService.setSecretAccessKey}. + */ +export interface AppStoreSchema { + wizardCompleted: boolean; + /** Locked to `'aws'` for v1. */ + activeCloud: 'aws'; + aws: { + region?: string; + profile?: string; + /** Stored as an encrypted base64 blob — do not read this field directly. */ + accessKeyId?: string; + /** Stored as an encrypted base64 blob — do not read this field directly. */ + secretAccessKey?: string; + }; +} + +/** + * Wraps `electron-store` with a typed {@link AppStoreSchema} and provides + * transparent encryption of secret fields via {@link SafeStorageService}. + * + * When running outside an Electron process (unit tests, CI) the service uses a + * `Map` as an in-memory backing store — the public API surface + * is identical, but reads/writes do not persist across process restarts. + * + * Protected methods (`createStore`, `readIsElectron`) are extracted so tests + * can stub them via `vi.spyOn` without importing native Electron modules. + */ +@Injectable() +export class ElectronStoreService { + private readonly _store: Store | null; + private readonly _map: Map | null; + + constructor(private readonly safeStorage: SafeStorageService) { + if (this.readIsElectron()) { + this._store = this.createStore(); + this._map = null; + } else { + this._store = null; + this._map = new Map(); + } + } + + /** + * Returns `true` when running inside an Electron process — i.e. the store is + * backed by a real disk file in the user-data directory. + */ + isElectron(): boolean { + return this.readIsElectron(); + } + + /** + * Read a top-level key from the store. + * + * @param key - One of the top-level keys defined in {@link AppStoreSchema}. + * @returns The stored value, or `undefined` if the key has not been set. + */ + get(key: K): AppStoreSchema[K] | undefined { + if (this._store !== null) { + return this._store.get(key) as AppStoreSchema[K] | undefined; + } + return this._map!.get(key) as AppStoreSchema[K] | undefined; + } + + /** + * Write a top-level key to the store. + * + * @param key - One of the top-level keys defined in {@link AppStoreSchema}. + * @param value - The value to persist. + */ + set(key: K, value: AppStoreSchema[K]): void { + if (this._store !== null) { + this._store.set(key, value); + } else { + this._map!.set(key, value); + } + } + + /** + * Read `aws.accessKeyId`, decrypting the stored blob via + * {@link SafeStorageService}. + * + * @returns The decrypted access key ID, or `undefined` if not stored. + */ + getSecretAccessKeyId(): string | undefined { + const aws = this.get('aws'); + if (aws?.accessKeyId === undefined) return undefined; + return this.safeStorage.decrypt(aws.accessKeyId); + } + + /** + * Write `aws.accessKeyId`, encrypting the value via {@link SafeStorageService} + * before storage. Merges with the existing `aws` object so other fields are + * preserved. + * + * @param value - Plaintext access key ID to encrypt and store. + */ + setSecretAccessKeyId(value: string): void { + const encrypted = this.safeStorage.encrypt(value); + const current = this.get('aws') ?? {}; + this.set('aws', { ...current, accessKeyId: encrypted }); + logger.debug('ElectronStoreService: aws.accessKeyId written (encrypted)'); + } + + /** + * Read `aws.secretAccessKey`, decrypting the stored blob via + * {@link SafeStorageService}. + * + * @returns The decrypted secret access key, or `undefined` if not stored. + */ + getSecretAccessKey(): string | undefined { + const aws = this.get('aws'); + if (aws?.secretAccessKey === undefined) return undefined; + return this.safeStorage.decrypt(aws.secretAccessKey); + } + + /** + * Write `aws.secretAccessKey`, encrypting the value via + * {@link SafeStorageService} before storage. Merges with the existing `aws` + * object so other fields are preserved. + * + * @param value - Plaintext secret access key to encrypt and store. + */ + setSecretAccessKey(value: string): void { + const encrypted = this.safeStorage.encrypt(value); + const current = this.get('aws') ?? {}; + this.set('aws', { ...current, secretAccessKey: encrypted }); + logger.debug('ElectronStoreService: aws.secretAccessKey written (encrypted)'); + } + + /** + * Constructs the underlying `electron-store` instance. Called once in the + * constructor when running inside Electron. Extracted as a protected method + * so tests can stub it via `vi.spyOn` to avoid touching the real user-data + * directory. + */ + protected createStore(): Store { + return new Store({ name: 'electron-store' }); + } + + /** + * Returns `true` when `process.versions['electron']` is set, indicating this + * process is running inside Electron. Extracted as a protected method so + * tests can stub it via `vi.spyOn` without mutating `process.versions`. + */ + protected readIsElectron(): boolean { + return !!process.versions['electron']; + } +} diff --git a/app/packages/desktop-main/src/services/SafeStorageService.test.ts b/app/packages/desktop-main/src/services/SafeStorageService.test.ts new file mode 100644 index 0000000..291e8cc --- /dev/null +++ b/app/packages/desktop-main/src/services/SafeStorageService.test.ts @@ -0,0 +1,209 @@ +/** + * Unit tests for SafeStorageService. + * + * All Electron-touching operations are stubbed via `vi.spyOn` on the + * protected helper methods (`readIsElectron`, `readIsAvailable`, + * `encryptString`, `decryptString`) — the native `electron` module is never + * imported here. + */ +import 'reflect-metadata'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../logger.js', () => ({ + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import { SafeStorageService } from './SafeStorageService.js'; +import { logger } from '../logger.js'; + +describe('SafeStorageService', () => { + let service: SafeStorageService; + + beforeEach(() => { + service = new SafeStorageService(); + vi.clearAllMocks(); + }); + + // --------------------------------------------------------------------------- + // isAvailable() + // --------------------------------------------------------------------------- + + describe('isAvailable()', () => { + it('should return false when not running in Electron', () => { + vi.spyOn(service as unknown as { readIsElectron(): boolean }, 'readIsElectron').mockReturnValue(false); + + expect(service.isAvailable()).toBe(false); + }); + + it('should return false when safeStorage encryption is not available', () => { + vi.spyOn(service as unknown as { readIsElectron(): boolean }, 'readIsElectron').mockReturnValue(true); + vi.spyOn(service as unknown as { readIsAvailable(): boolean }, 'readIsAvailable').mockReturnValue(false); + + expect(service.isAvailable()).toBe(false); + }); + + it('should return true when running in Electron and encryption is available', () => { + vi.spyOn(service as unknown as { readIsElectron(): boolean }, 'readIsElectron').mockReturnValue(true); + vi.spyOn(service as unknown as { readIsAvailable(): boolean }, 'readIsAvailable').mockReturnValue(true); + + expect(service.isAvailable()).toBe(true); + }); + }); + + // --------------------------------------------------------------------------- + // encrypt() — non-Electron path + // --------------------------------------------------------------------------- + + describe('encrypt() outside Electron', () => { + /** Spy that makes the service believe it is not running inside Electron. */ + function stubNoElectron() { + vi.spyOn(service as unknown as { readIsElectron(): boolean }, 'readIsElectron').mockReturnValue(false); + } + + it('should return plaintext unchanged when not running in Electron', () => { + stubNoElectron(); + + expect(service.encrypt('my-secret')).toBe('my-secret'); + }); + + it('should log a warning when returning plaintext unchanged', () => { + stubNoElectron(); + + service.encrypt('my-secret'); + + expect(logger.warn).toHaveBeenCalled(); + }); + }); + + // --------------------------------------------------------------------------- + // encrypt() — Electron path + // --------------------------------------------------------------------------- + + describe('encrypt() inside Electron', () => { + /** Spy that makes the service believe it is running inside Electron with keychain available. */ + function stubElectron() { + vi.spyOn(service as unknown as { readIsElectron(): boolean }, 'readIsElectron').mockReturnValue(true); + vi.spyOn(service as unknown as { readIsAvailable(): boolean }, 'readIsAvailable').mockReturnValue(true); + } + + it('should return plaintext unchanged when keychain is unavailable inside Electron', () => { + vi.spyOn(service as unknown as { readIsElectron(): boolean }, 'readIsElectron').mockReturnValue(true); + vi.spyOn(service as unknown as { readIsAvailable(): boolean }, 'readIsAvailable').mockReturnValue(false); + + expect(service.encrypt('my-secret')).toBe('my-secret'); + }); + + it('should log a warning when keychain is unavailable inside Electron', () => { + vi.spyOn(service as unknown as { readIsElectron(): boolean }, 'readIsElectron').mockReturnValue(true); + vi.spyOn(service as unknown as { readIsAvailable(): boolean }, 'readIsAvailable').mockReturnValue(false); + + service.encrypt('my-secret'); + + expect(logger.warn).toHaveBeenCalled(); + }); + + it('should return a base64-encoded string when Electron is available', () => { + stubElectron(); + vi.spyOn(service as unknown as { encryptString(p: string): Buffer }, 'encryptString').mockReturnValue( + Buffer.from('encrypted'), + ); + + const result = service.encrypt('my-secret'); + + expect(result).toBe(Buffer.from('encrypted').toString('base64')); + }); + + it('should pass plaintext to encryptString', () => { + stubElectron(); + const encryptSpy = vi + .spyOn(service as unknown as { encryptString(p: string): Buffer }, 'encryptString') + .mockReturnValue(Buffer.from('encrypted')); + + service.encrypt('my-secret'); + + expect(encryptSpy).toHaveBeenCalledWith('my-secret'); + }); + }); + + // --------------------------------------------------------------------------- + // decrypt() — non-Electron path + // --------------------------------------------------------------------------- + + describe('decrypt() outside Electron', () => { + it('should return the input unchanged when not running in Electron', () => { + vi.spyOn(service as unknown as { readIsElectron(): boolean }, 'readIsElectron').mockReturnValue(false); + + const ciphertext = Buffer.from('some-data').toString('base64'); + expect(service.decrypt(ciphertext)).toBe(ciphertext); + }); + }); + + // --------------------------------------------------------------------------- + // decrypt() — Electron path + // --------------------------------------------------------------------------- + + describe('decrypt() inside Electron', () => { + /** Spy that makes the service believe it is running inside Electron with keychain available. */ + function stubElectron() { + vi.spyOn(service as unknown as { readIsElectron(): boolean }, 'readIsElectron').mockReturnValue(true); + vi.spyOn(service as unknown as { readIsAvailable(): boolean }, 'readIsAvailable').mockReturnValue(true); + } + + it('should return ciphertext unchanged when keychain is unavailable inside Electron', () => { + vi.spyOn(service as unknown as { readIsElectron(): boolean }, 'readIsElectron').mockReturnValue(true); + vi.spyOn(service as unknown as { readIsAvailable(): boolean }, 'readIsAvailable').mockReturnValue(false); + + const ciphertext = Buffer.from('some-data').toString('base64'); + expect(service.decrypt(ciphertext)).toBe(ciphertext); + }); + + it('should decrypt base64 ciphertext back to plaintext', () => { + stubElectron(); + vi.spyOn(service as unknown as { decryptString(b: Buffer): string }, 'decryptString').mockReturnValue('hello'); + + const ciphertext = Buffer.from('encrypted').toString('base64'); + expect(service.decrypt(ciphertext)).toBe('hello'); + }); + + it('should pass the decoded Buffer to decryptString', () => { + stubElectron(); + const decryptSpy = vi + .spyOn(service as unknown as { decryptString(b: Buffer): string }, 'decryptString') + .mockReturnValue('hello'); + + const ciphertext = Buffer.from('encrypted').toString('base64'); + service.decrypt(ciphertext); + + expect(decryptSpy).toHaveBeenCalledWith(Buffer.from(ciphertext, 'base64')); + }); + }); + + // --------------------------------------------------------------------------- + // Round-trip + // --------------------------------------------------------------------------- + + describe('round-trip', () => { + it('should encrypt and decrypt back to the original value', () => { + vi.spyOn(service as unknown as { readIsElectron(): boolean }, 'readIsElectron').mockReturnValue(true); + vi.spyOn(service as unknown as { readIsAvailable(): boolean }, 'readIsAvailable').mockReturnValue(true); + + // encryptString wraps the plaintext bytes in a Buffer (real encode step for testing) + vi.spyOn( + service as unknown as { encryptString(p: string): Buffer }, + 'encryptString', + ).mockImplementation((plaintext: string) => Buffer.from(plaintext)); + + // decryptString converts the Buffer back to a string (mirrors real decode step) + vi.spyOn( + service as unknown as { decryptString(b: Buffer): string }, + 'decryptString', + ).mockImplementation((buf: Buffer) => buf.toString()); + + const plaintext = 'super-secret-value'; + const encrypted = service.encrypt(plaintext); + const decrypted = service.decrypt(encrypted); + + expect(decrypted).toBe(plaintext); + }); + }); +}); diff --git a/app/packages/desktop-main/src/services/SafeStorageService.ts b/app/packages/desktop-main/src/services/SafeStorageService.ts new file mode 100644 index 0000000..e2c159a --- /dev/null +++ b/app/packages/desktop-main/src/services/SafeStorageService.ts @@ -0,0 +1,134 @@ +import { Injectable } from '@nestjs/common'; +import { createRequire } from 'module'; +import { logger } from '../logger.js'; + +const _require = createRequire(import.meta.url); + +/** + * Wraps Electron's `safeStorage` API for OS-keychain-backed encryption of + * sensitive strings (e.g. API tokens, secrets). + * + * When running outside an Electron process (unit tests, plain Node CI) the + * service degrades gracefully: `encrypt()` returns the plaintext unchanged and + * `decrypt()` returns the input unchanged, so callers need no environment + * branching of their own. + * + * All four Electron-touching operations are extracted into `protected` methods + * (`readIsElectron`, `readIsAvailable`, `encryptString`, `decryptString`) so + * tests can stub them via `vi.spyOn` without importing the native `electron` + * module. + */ +@Injectable() +export class SafeStorageService { + /** + * Returns `true` when encryption is available — i.e. the service is running + * inside an Electron process **and** the OS keychain (Keychain, libsecret, + * DPAPI) is unlocked and accessible. + */ + isAvailable(): boolean { + if (!this.readIsElectron()) return false; + return this.readIsAvailable(); + } + + /** + * Encrypt `plaintext` using Electron's `safeStorage.encryptString()` and + * return the result as a base64-encoded string suitable for storage. + * + * Outside an Electron process the plaintext is returned unchanged and a + * warning is emitted — this allows the service to be consumed transparently + * in test/CI environments. + * + * @param plaintext - The string to encrypt. + * @returns Base64-encoded ciphertext, or the original `plaintext` when + * encryption is unavailable. + */ + encrypt(plaintext: string): string { + if (!this.isAvailable()) { + logger.warn('SafeStorageService: encryption not available — returning plaintext unchanged'); + return plaintext; + } + const buf = this.encryptString(plaintext); + return buf.toString('base64'); + } + + /** + * Decrypt a base64-encoded ciphertext produced by {@link encrypt} back to + * its original plaintext using Electron's `safeStorage.decryptString()`. + * + * Outside an Electron process the input is returned unchanged (on the + * assumption it was stored as plaintext by the degraded `encrypt()` path). + * + * @param ciphertext - Base64-encoded encrypted string, or raw plaintext when + * produced outside Electron. + * @returns Decrypted plaintext, or the original `ciphertext` when decryption + * is unavailable. + * + * @remarks + * The caller must ensure that `isAvailable()` returns the same value at + * both write time (`encrypt`) and read time (`decrypt`). A ciphertext blob + * written when the OS keychain was available cannot be safely round-tripped + * if `isAvailable()` later returns `false` (e.g. keychain locked between + * writes and reads within the same Electron process, or data shared across + * Electron and non-Electron contexts). In that scenario `decrypt()` returns + * the raw base64 blob unchanged — treat the output as untrusted. + */ + decrypt(ciphertext: string): string { + if (!this.isAvailable()) { + return ciphertext; + } + const buf = Buffer.from(ciphertext, 'base64'); + return this.decryptString(buf); + } + + /** + * Returns `true` when `process.versions['electron']` is set, indicating the + * service is running inside an Electron process. Extracted as a protected + * method so tests can stub it via `vi.spyOn` without touching + * `process.versions` directly. + */ + protected readIsElectron(): boolean { + return !!process.versions['electron']; + } + + /** + * Calls `safeStorage.isEncryptionAvailable()` and returns its result. + * Only called after `readIsElectron()` returns `true`. Extracted as a + * protected method so tests can stub it via `vi.spyOn`. + */ + protected readIsAvailable(): boolean { + const { safeStorage } = _require('electron') as { + safeStorage: { isEncryptionAvailable(): boolean }; + }; + return safeStorage.isEncryptionAvailable(); + } + + /** + * Calls `safeStorage.encryptString(plaintext)` and returns the resulting + * `Buffer`. Only called after `readIsElectron()` returns `true`. Extracted + * as a protected method so tests can stub it via `vi.spyOn`. + * + * @param plaintext - The string to encrypt. + * @returns Raw encrypted bytes as a `Buffer`. + */ + protected encryptString(plaintext: string): Buffer { + const { safeStorage } = _require('electron') as { + safeStorage: { encryptString(plaintext: string): Buffer }; + }; + return safeStorage.encryptString(plaintext); + } + + /** + * Calls `safeStorage.decryptString(buf)` and returns the decrypted string. + * Only called after `readIsElectron()` returns `true`. Extracted as a + * protected method so tests can stub it via `vi.spyOn`. + * + * @param buf - Raw encrypted bytes previously produced by `encryptString`. + * @returns Decrypted plaintext string. + */ + protected decryptString(buf: Buffer): string { + const { safeStorage } = _require('electron') as { + safeStorage: { decryptString(buf: Buffer): string }; + }; + return safeStorage.decryptString(buf); + } +} diff --git a/package-lock.json b/package-lock.json index bbbcd83..d569e97 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53,6 +53,7 @@ "@nestjs/core": "^10.4.0", "@nestjs/microservices": "^10.4.0", "@nestjs/platform-express": "^10.4.0", + "electron-store": "^11.0.2", "express": "^4.19.2", "fix-path": "^4.0.0", "nestjs-electron-ipc-transport": "^1.0.2", @@ -5466,6 +5467,45 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/ansi-regex": { "version": "5.0.1", "dev": true, @@ -5705,6 +5745,16 @@ "dev": true, "license": "MIT" }, + "node_modules/atomically": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/atomically/-/atomically-2.1.1.tgz", + "integrity": "sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ==", + "license": "MIT", + "dependencies": { + "stubborn-fs": "^2.0.0", + "when-exit": "^2.1.4" + } + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -6294,6 +6344,75 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/conf": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/conf/-/conf-15.1.0.tgz", + "integrity": "sha512-Uy5YN9KEu0WWDaZAVJ5FAmZoaJt9rdK6kH+utItPyGsCqCgaTKkrmZx3zoE0/3q6S3bcp3Ihkk+ZqPxWxFK5og==", + "license": "MIT", + "dependencies": { + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "atomically": "^2.0.3", + "debounce-fn": "^6.0.0", + "dot-prop": "^10.0.0", + "env-paths": "^3.0.0", + "json-schema-typed": "^8.0.1", + "semver": "^7.7.2", + "uint8array-extras": "^1.5.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/conf/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/conf/node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/conf/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/conf/node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/consola": { "version": "2.15.3", "resolved": "https://registry.npmjs.org/consola/-/consola-2.15.3.tgz", @@ -6478,6 +6597,21 @@ "url": "https://opencollective.com/date-fns" } }, + "node_modules/debounce-fn": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/debounce-fn/-/debounce-fn-6.0.0.tgz", + "integrity": "sha512-rBMW+F2TXryBwB54Q0d8drNEI+TfoS9JpNTAoVpukbWEhjXQq4rySFYLaqXMFXwdv61Zb2OHtj5bviSoimqxRQ==", + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/debug": { "version": "4.4.3", "license": "MIT", @@ -6682,6 +6816,36 @@ "license": "MIT", "peer": true }, + "node_modules/dot-prop": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-10.1.0.tgz", + "integrity": "sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q==", + "license": "MIT", + "dependencies": { + "type-fest": "^5.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dot-prop/node_modules/type-fest": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.6.0.tgz", + "integrity": "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==", + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "license": "MIT", @@ -6725,6 +6889,37 @@ "node": ">= 12.20.55" } }, + "node_modules/electron-store": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/electron-store/-/electron-store-11.0.2.tgz", + "integrity": "sha512-4VkNRdN+BImL2KcCi41WvAYbh6zLX5AUTi4so68yPqiItjbgTjqpEnGAqasgnG+lB6GuAyUltKwVopp6Uv+gwQ==", + "license": "MIT", + "dependencies": { + "conf": "^15.0.2", + "type-fest": "^5.0.1" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/electron-store/node_modules/type-fest": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.6.0.tgz", + "integrity": "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==", + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.353", "dev": true, @@ -7498,7 +7693,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-json-stable-stringify": { @@ -7521,6 +7715,22 @@ "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fast-xml-builder": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", @@ -9048,6 +9258,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -9437,6 +9653,18 @@ "node": ">=6" } }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mimic-response": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", @@ -10589,7 +10817,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -11534,6 +11761,21 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/stubborn-fs": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-2.0.0.tgz", + "integrity": "sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA==", + "license": "MIT", + "dependencies": { + "stubborn-utils": "^1.0.1" + } + }, + "node_modules/stubborn-utils": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/stubborn-utils/-/stubborn-utils-1.0.2.tgz", + "integrity": "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==", + "license": "MIT" + }, "node_modules/sumchecker": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", @@ -11576,6 +11818,18 @@ "dev": true, "license": "MIT" }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tailwind-merge": { "version": "3.6.0", "license": "MIT", @@ -13271,6 +13525,12 @@ "node": ">=18" } }, + "node_modules/when-exit": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.5.tgz", + "integrity": "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==", + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",