From a2735eaedc2f8235f2cde87b34c35de87ccd3fe0 Mon Sep 17 00:00:00 2001 From: sudacode Date: Mon, 23 Feb 2026 23:53:53 -0800 Subject: [PATCH] feat(anilist): enforce encrypted token storage and default gnome-libsecret --- docs/configuration.md | 2 + docs/usage.md | 2 + .../anilist/anilist-token-store.test.ts | 28 ++-- .../services/anilist/anilist-token-store.ts | 123 +++++++++++++++--- src/main.ts | 50 +++++++ 5 files changed, 179 insertions(+), 26 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 3bdb7ba..63726e7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -450,6 +450,8 @@ Setup flow details: 2. Leave `anilist.accessToken` empty and restart SubMiner (or run `--anilist-setup`) to trigger setup. 3. Approve access in AniList. 4. Callback flow returns to SubMiner via `subminer://anilist-setup?...`, and SubMiner stores the token automatically. + - Encryption backend: Linux defaults to `gnome-libsecret`. + Override with `--password-store=` (for example `--password-store=basic_text`). Token + detection notes: diff --git a/docs/usage.md b/docs/usage.md index 38de2e1..894453e 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -94,6 +94,8 @@ SubMiner.AppImage --help # Show all options - `--background` defaults to quieter logging (`warn`) unless `--log-level` is set. - `--background` launched from a terminal detaches and returns the prompt; stop it with tray Quit or `SubMiner.AppImage --stop`. - Linux desktop launcher starts SubMiner with `--background` by default (via electron-builder `linux.executableArgs`). +- On Linux, the app now defaults `safeStorage` to `gnome-libsecret` for encrypted token persistence. + Override with e.g. `--password-store=basic_text`. - Use both when needed, for example `SubMiner.AppImage --start --dev --log-level debug`. ### Launcher Subcommands diff --git a/src/core/services/anilist/anilist-token-store.test.ts b/src/core/services/anilist/anilist-token-store.test.ts index cf9a4e7..a31d9d1 100644 --- a/src/core/services/anilist/anilist-token-store.test.ts +++ b/src/core/services/anilist/anilist-token-store.test.ts @@ -30,10 +30,18 @@ function createStorage(encryptionAvailable: boolean): SafeStorageLike { }; } +function createPassthroughStorage(): SafeStorageLike { + return { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(value, 'utf-8'), + decryptString: (value: Buffer) => value.toString('utf-8'), + }; +} + test('anilist token store saves and loads encrypted token', () => { const filePath = createTempTokenFile(); const store = createAnilistTokenStore(filePath, createLogger(), createStorage(true)); - store.saveToken(' demo-token '); + assert.equal(store.saveToken(' demo-token '), true); const payload = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as { encryptedToken?: string; @@ -44,16 +52,13 @@ test('anilist token store saves and loads encrypted token', () => { assert.equal(store.loadToken(), 'demo-token'); }); -test('anilist token store falls back to plaintext when encryption unavailable', () => { +test('anilist token store refuses to persist token when encryption unavailable', () => { const filePath = createTempTokenFile(); const store = createAnilistTokenStore(filePath, createLogger(), createStorage(false)); - store.saveToken('plain-token'); + assert.equal(store.saveToken('plain-token'), false); - const payload = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as { - plaintextToken?: string; - }; - assert.equal(payload.plaintextToken, 'plain-token'); - assert.equal(store.loadToken(), 'plain-token'); + assert.equal(fs.existsSync(filePath), false); + assert.equal(store.loadToken(), null); }); test('anilist token store migrates legacy plaintext to encrypted', () => { @@ -75,6 +80,13 @@ test('anilist token store migrates legacy plaintext to encrypted', () => { assert.equal(payload.plaintextToken, undefined); }); +test('anilist token store refuses passthrough safeStorage implementation', () => { + const filePath = createTempTokenFile(); + const store = createAnilistTokenStore(filePath, createLogger(), createPassthroughStorage()); + assert.equal(store.saveToken('demo-token'), false); + assert.equal(store.loadToken(), null); +}); + test('anilist token store clears persisted token file', () => { const filePath = createTempTokenFile(); const store = createAnilistTokenStore(filePath, createLogger(), createStorage(true)); diff --git a/src/core/services/anilist/anilist-token-store.ts b/src/core/services/anilist/anilist-token-store.ts index d89ded1..a3c6ca1 100644 --- a/src/core/services/anilist/anilist-token-store.ts +++ b/src/core/services/anilist/anilist-token-store.ts @@ -10,7 +10,7 @@ interface PersistedTokenPayload { export interface AnilistTokenStore { loadToken: () => string | null; - saveToken: (token: string) => void; + saveToken: (token: string) => boolean; clearToken: () => void; } @@ -18,6 +18,7 @@ export interface SafeStorageLike { isEncryptionAvailable: () => boolean; encryptString: (value: string) => Buffer; decryptString: (value: Buffer) => string; + getSelectedStorageBackend?: () => string; } function ensureDirectory(filePath: string): void { @@ -38,9 +39,80 @@ export function createAnilistTokenStore( info: (message: string) => void; warn: (message: string, details?: unknown) => void; error: (message: string, details?: unknown) => void; + warnUser?: (message: string) => void; }, storage: SafeStorageLike = electron.safeStorage, ): AnilistTokenStore { + let safeStorageUsable: boolean | null = null; + + const getSelectedBackend = (): string => { + if (typeof storage.getSelectedStorageBackend !== 'function') { + return 'unsupported'; + } + try { + return storage.getSelectedStorageBackend(); + } catch { + return 'error'; + } + }; + + const getSafeStorageDebugContext = (): string => + JSON.stringify({ + platform: process.platform, + dbusSession: process.env.DBUS_SESSION_BUS_ADDRESS, + xdgRuntimeDir: process.env.XDG_RUNTIME_DIR, + display: process.env.DISPLAY, + waylandDisplay: process.env.WAYLAND_DISPLAY, + hasDefaultApp: Boolean(process.defaultApp), + selectedSafeStorageBackend: getSelectedBackend(), + }); + + const isSafeStorageUsable = (): boolean => { + if (safeStorageUsable != null) return safeStorageUsable; + + try { + if (!storage.isEncryptionAvailable()) { + notifyUser( + `AniList token encryption unavailable: safeStorage.isEncryptionAvailable() is false. ` + + `Context: ${getSafeStorageDebugContext()}`, + ); + safeStorageUsable = false; + return false; + } + const probe = storage.encryptString('__subminer_anilist_probe__'); + if (probe.equals(Buffer.from('__subminer_anilist_probe__'))) { + notifyUser( + 'AniList token encryption probe failed: safeStorage.encryptString() returned plaintext bytes.', + ); + safeStorageUsable = false; + return false; + } + const roundTrip = storage.decryptString(probe); + if (roundTrip !== '__subminer_anilist_probe__') { + notifyUser( + 'AniList token encryption probe failed: encrypt/decrypt round trip returned unexpected content.', + ); + safeStorageUsable = false; + return false; + } + safeStorageUsable = true; + return true; + } catch (error) { + logger.error('AniList token encryption probe failed.', error); + notifyUser( + `AniList token encryption unavailable: safeStorage probe threw an error. ` + + `Context: ${getSafeStorageDebugContext()}`, + ); + safeStorageUsable = false; + return false; + } + }; + + const notifyUser = (message: string): void => { + logger.warn(message); + logger.warnUser?.(message); + }; + return { loadToken(): string | null { if (!fs.existsSync(filePath)) { @@ -51,18 +123,33 @@ export function createAnilistTokenStore( const parsed = JSON.parse(raw) as PersistedTokenPayload; if (typeof parsed.encryptedToken === 'string' && parsed.encryptedToken.length > 0) { const encrypted = Buffer.from(parsed.encryptedToken, 'base64'); - if (!storage.isEncryptionAvailable()) { - logger.warn('AniList token encryption is not available on this system.'); + if (!isSafeStorageUsable()) { return null; } const decrypted = storage.decryptString(encrypted).trim(); - return decrypted.length > 0 ? decrypted : null; + if (decrypted.length === 0) { + return null; + } + return decrypted; } - if (typeof parsed.plaintextToken === 'string' && parsed.plaintextToken.trim().length > 0) { - // Legacy fallback: migrate plaintext token to encrypted storage on load. - const plaintext = parsed.plaintextToken.trim(); - this.saveToken(plaintext); - return plaintext; + if ( + typeof parsed.plaintextToken === 'string' && + parsed.plaintextToken.trim().length > 0 + ) { + if (storage.isEncryptionAvailable()) { + if (!isSafeStorageUsable()) { + return null; + } + const plaintext = parsed.plaintextToken.trim(); + notifyUser('AniList token plaintext fallback payload found. Migrating to encrypted storage.'); + this.saveToken(plaintext); + return plaintext; + } + notifyUser( + 'AniList token plaintext was found but ignored because safe storage is unavailable.', + ); + this.clearToken(); + return null; } } catch (error) { logger.error('Failed to read AniList token store.', error); @@ -70,28 +157,28 @@ export function createAnilistTokenStore( return null; }, - saveToken(token: string): void { + saveToken(token: string): boolean { const trimmed = token.trim(); if (trimmed.length === 0) { this.clearToken(); - return; + return true; } try { - if (!storage.isEncryptionAvailable()) { - logger.warn('AniList token encryption unavailable; storing token in plaintext fallback.'); - writePayload(filePath, { - plaintextToken: trimmed, - updatedAt: Date.now(), - }); - return; + if (!isSafeStorageUsable()) { + notifyUser( + 'AniList token encryption is unavailable; refusing to store access token. Re-login required after restart.', + ); + return false; } const encrypted = storage.encryptString(trimmed); writePayload(filePath, { encryptedToken: encrypted.toString('base64'), updatedAt: Date.now(), }); + return true; } catch (error) { logger.error('Failed to persist AniList token.', error); + return false; } }, diff --git a/src/main.ts b/src/main.ts index 7c1a9e3..fbb6d4c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -30,6 +30,41 @@ import { screen, } from 'electron'; +function getPasswordStoreArg(argv: string[]): string | null { + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (!arg?.startsWith('--password-store')) { + continue; + } + + if (arg === '--password-store') { + const value = argv[i + 1]; + if (value && !value.startsWith('--')) { + return value; + } + return null; + } + + const [prefix, value] = arg.split('=', 2); + if (prefix === '--password-store' && value && value.trim().length > 0) { + return value.trim(); + } + } + return null; +} + +function normalizePasswordStoreArg(value: string): string { + const normalized = value.trim(); + if (normalized.toLowerCase() === 'gnome') { + return 'gnome-libsecret'; + } + return normalized; +} + +function getDefaultPasswordStore(): string { + return 'gnome-libsecret'; +} + protocol.registerSchemesAsPrivileged([ { scheme: 'chrome-extension', @@ -400,6 +435,9 @@ import { resolveConfigDir } from './config/path-resolution'; if (process.platform === 'linux') { app.commandLine.appendSwitch('enable-features', 'GlobalShortcutsPortal'); + const passwordStore = normalizePasswordStoreArg(getPasswordStoreArg(process.argv) ?? getDefaultPasswordStore()); + app.commandLine.appendSwitch('password-store', passwordStore); + console.debug(`[main] Applied --password-store ${passwordStore}`); } app.setName('SubMiner'); @@ -447,6 +485,7 @@ let jellyfinRemoteLastProgressAtMs = 0; let jellyfinMpvAutoLaunchInFlight: Promise | null = null; let backgroundWarmupsStarted = false; let yomitanLoadInFlight: Promise | null = null; +let notifyAnilistTokenStoreWarning: (message: string) => void = () => {}; const buildApplyJellyfinMpvDefaultsMainDepsHandler = createBuildApplyJellyfinMpvDefaultsMainDepsHandler({ @@ -496,6 +535,7 @@ const anilistTokenStore = createAnilistTokenStore( info: (message: string) => console.info(message), warn: (message: string, details?: unknown) => console.warn(message, details), error: (message: string, details?: unknown) => console.error(message, details), + warnUser: (message: string) => notifyAnilistTokenStoreWarning(message), }, ); const jellyfinTokenStore = createJellyfinTokenStore( @@ -518,6 +558,16 @@ const isDev = process.argv.includes('--dev') || process.argv.includes('--debug') const texthookerService = new Texthooker(); const subtitleWsService = new SubtitleWebSocket(); const logger = createLogger('main'); +notifyAnilistTokenStoreWarning = (message: string) => { + logger.warn(`[AniList] ${message}`); + try { + showDesktopNotification('SubMiner AniList', { + body: message, + }); + } catch { + // Notification may fail if desktop notifications are unavailable early in startup. + } +}; const appLogger = { logInfo: (message: string) => { logger.info(message);