mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
feat(anilist): enforce encrypted token storage and default gnome-libsecret
This commit is contained in:
@@ -450,6 +450,8 @@ Setup flow details:
|
|||||||
2. Leave `anilist.accessToken` empty and restart SubMiner (or run `--anilist-setup`) to trigger setup.
|
2. Leave `anilist.accessToken` empty and restart SubMiner (or run `--anilist-setup`) to trigger setup.
|
||||||
3. Approve access in AniList.
|
3. Approve access in AniList.
|
||||||
4. Callback flow returns to SubMiner via `subminer://anilist-setup?...`, and SubMiner stores the token automatically.
|
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=<backend>` (for example `--password-store=basic_text`).
|
||||||
|
|
||||||
Token + detection notes:
|
Token + detection notes:
|
||||||
|
|
||||||
|
|||||||
@@ -94,6 +94,8 @@ SubMiner.AppImage --help # Show all options
|
|||||||
- `--background` defaults to quieter logging (`warn`) unless `--log-level` is set.
|
- `--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`.
|
- `--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`).
|
- 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`.
|
- Use both when needed, for example `SubMiner.AppImage --start --dev --log-level debug`.
|
||||||
|
|
||||||
### Launcher Subcommands
|
### Launcher Subcommands
|
||||||
|
|||||||
@@ -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', () => {
|
test('anilist token store saves and loads encrypted token', () => {
|
||||||
const filePath = createTempTokenFile();
|
const filePath = createTempTokenFile();
|
||||||
const store = createAnilistTokenStore(filePath, createLogger(), createStorage(true));
|
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 {
|
const payload = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as {
|
||||||
encryptedToken?: string;
|
encryptedToken?: string;
|
||||||
@@ -44,16 +52,13 @@ test('anilist token store saves and loads encrypted token', () => {
|
|||||||
assert.equal(store.loadToken(), 'demo-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 filePath = createTempTokenFile();
|
||||||
const store = createAnilistTokenStore(filePath, createLogger(), createStorage(false));
|
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 {
|
assert.equal(fs.existsSync(filePath), false);
|
||||||
plaintextToken?: string;
|
assert.equal(store.loadToken(), null);
|
||||||
};
|
|
||||||
assert.equal(payload.plaintextToken, 'plain-token');
|
|
||||||
assert.equal(store.loadToken(), 'plain-token');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('anilist token store migrates legacy plaintext to encrypted', () => {
|
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);
|
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', () => {
|
test('anilist token store clears persisted token file', () => {
|
||||||
const filePath = createTempTokenFile();
|
const filePath = createTempTokenFile();
|
||||||
const store = createAnilistTokenStore(filePath, createLogger(), createStorage(true));
|
const store = createAnilistTokenStore(filePath, createLogger(), createStorage(true));
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ interface PersistedTokenPayload {
|
|||||||
|
|
||||||
export interface AnilistTokenStore {
|
export interface AnilistTokenStore {
|
||||||
loadToken: () => string | null;
|
loadToken: () => string | null;
|
||||||
saveToken: (token: string) => void;
|
saveToken: (token: string) => boolean;
|
||||||
clearToken: () => void;
|
clearToken: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@ export interface SafeStorageLike {
|
|||||||
isEncryptionAvailable: () => boolean;
|
isEncryptionAvailable: () => boolean;
|
||||||
encryptString: (value: string) => Buffer;
|
encryptString: (value: string) => Buffer;
|
||||||
decryptString: (value: Buffer) => string;
|
decryptString: (value: Buffer) => string;
|
||||||
|
getSelectedStorageBackend?: () => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureDirectory(filePath: string): void {
|
function ensureDirectory(filePath: string): void {
|
||||||
@@ -38,9 +39,80 @@ export function createAnilistTokenStore(
|
|||||||
info: (message: string) => void;
|
info: (message: string) => void;
|
||||||
warn: (message: string, details?: unknown) => void;
|
warn: (message: string, details?: unknown) => void;
|
||||||
error: (message: string, details?: unknown) => void;
|
error: (message: string, details?: unknown) => void;
|
||||||
|
warnUser?: (message: string) => void;
|
||||||
},
|
},
|
||||||
storage: SafeStorageLike = electron.safeStorage,
|
storage: SafeStorageLike = electron.safeStorage,
|
||||||
): AnilistTokenStore {
|
): 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 {
|
return {
|
||||||
loadToken(): string | null {
|
loadToken(): string | null {
|
||||||
if (!fs.existsSync(filePath)) {
|
if (!fs.existsSync(filePath)) {
|
||||||
@@ -51,47 +123,62 @@ export function createAnilistTokenStore(
|
|||||||
const parsed = JSON.parse(raw) as PersistedTokenPayload;
|
const parsed = JSON.parse(raw) as PersistedTokenPayload;
|
||||||
if (typeof parsed.encryptedToken === 'string' && parsed.encryptedToken.length > 0) {
|
if (typeof parsed.encryptedToken === 'string' && parsed.encryptedToken.length > 0) {
|
||||||
const encrypted = Buffer.from(parsed.encryptedToken, 'base64');
|
const encrypted = Buffer.from(parsed.encryptedToken, 'base64');
|
||||||
if (!storage.isEncryptionAvailable()) {
|
if (!isSafeStorageUsable()) {
|
||||||
logger.warn('AniList token encryption is not available on this system.');
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const decrypted = storage.decryptString(encrypted).trim();
|
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
|
||||||
|
) {
|
||||||
|
if (storage.isEncryptionAvailable()) {
|
||||||
|
if (!isSafeStorageUsable()) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
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();
|
const plaintext = parsed.plaintextToken.trim();
|
||||||
|
notifyUser('AniList token plaintext fallback payload found. Migrating to encrypted storage.');
|
||||||
this.saveToken(plaintext);
|
this.saveToken(plaintext);
|
||||||
return plaintext;
|
return plaintext;
|
||||||
}
|
}
|
||||||
|
notifyUser(
|
||||||
|
'AniList token plaintext was found but ignored because safe storage is unavailable.',
|
||||||
|
);
|
||||||
|
this.clearToken();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to read AniList token store.', error);
|
logger.error('Failed to read AniList token store.', error);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
|
|
||||||
saveToken(token: string): void {
|
saveToken(token: string): boolean {
|
||||||
const trimmed = token.trim();
|
const trimmed = token.trim();
|
||||||
if (trimmed.length === 0) {
|
if (trimmed.length === 0) {
|
||||||
this.clearToken();
|
this.clearToken();
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (!storage.isEncryptionAvailable()) {
|
if (!isSafeStorageUsable()) {
|
||||||
logger.warn('AniList token encryption unavailable; storing token in plaintext fallback.');
|
notifyUser(
|
||||||
writePayload(filePath, {
|
'AniList token encryption is unavailable; refusing to store access token. Re-login required after restart.',
|
||||||
plaintextToken: trimmed,
|
);
|
||||||
updatedAt: Date.now(),
|
return false;
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
const encrypted = storage.encryptString(trimmed);
|
const encrypted = storage.encryptString(trimmed);
|
||||||
writePayload(filePath, {
|
writePayload(filePath, {
|
||||||
encryptedToken: encrypted.toString('base64'),
|
encryptedToken: encrypted.toString('base64'),
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
});
|
});
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to persist AniList token.', error);
|
logger.error('Failed to persist AniList token.', error);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
50
src/main.ts
50
src/main.ts
@@ -30,6 +30,41 @@ import {
|
|||||||
screen,
|
screen,
|
||||||
} from 'electron';
|
} 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([
|
protocol.registerSchemesAsPrivileged([
|
||||||
{
|
{
|
||||||
scheme: 'chrome-extension',
|
scheme: 'chrome-extension',
|
||||||
@@ -400,6 +435,9 @@ import { resolveConfigDir } from './config/path-resolution';
|
|||||||
|
|
||||||
if (process.platform === 'linux') {
|
if (process.platform === 'linux') {
|
||||||
app.commandLine.appendSwitch('enable-features', 'GlobalShortcutsPortal');
|
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');
|
app.setName('SubMiner');
|
||||||
@@ -447,6 +485,7 @@ let jellyfinRemoteLastProgressAtMs = 0;
|
|||||||
let jellyfinMpvAutoLaunchInFlight: Promise<boolean> | null = null;
|
let jellyfinMpvAutoLaunchInFlight: Promise<boolean> | null = null;
|
||||||
let backgroundWarmupsStarted = false;
|
let backgroundWarmupsStarted = false;
|
||||||
let yomitanLoadInFlight: Promise<Extension | null> | null = null;
|
let yomitanLoadInFlight: Promise<Extension | null> | null = null;
|
||||||
|
let notifyAnilistTokenStoreWarning: (message: string) => void = () => {};
|
||||||
|
|
||||||
const buildApplyJellyfinMpvDefaultsMainDepsHandler =
|
const buildApplyJellyfinMpvDefaultsMainDepsHandler =
|
||||||
createBuildApplyJellyfinMpvDefaultsMainDepsHandler({
|
createBuildApplyJellyfinMpvDefaultsMainDepsHandler({
|
||||||
@@ -496,6 +535,7 @@ const anilistTokenStore = createAnilistTokenStore(
|
|||||||
info: (message: string) => console.info(message),
|
info: (message: string) => console.info(message),
|
||||||
warn: (message: string, details?: unknown) => console.warn(message, details),
|
warn: (message: string, details?: unknown) => console.warn(message, details),
|
||||||
error: (message: string, details?: unknown) => console.error(message, details),
|
error: (message: string, details?: unknown) => console.error(message, details),
|
||||||
|
warnUser: (message: string) => notifyAnilistTokenStoreWarning(message),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const jellyfinTokenStore = createJellyfinTokenStore(
|
const jellyfinTokenStore = createJellyfinTokenStore(
|
||||||
@@ -518,6 +558,16 @@ const isDev = process.argv.includes('--dev') || process.argv.includes('--debug')
|
|||||||
const texthookerService = new Texthooker();
|
const texthookerService = new Texthooker();
|
||||||
const subtitleWsService = new SubtitleWebSocket();
|
const subtitleWsService = new SubtitleWebSocket();
|
||||||
const logger = createLogger('main');
|
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 = {
|
const appLogger = {
|
||||||
logInfo: (message: string) => {
|
logInfo: (message: string) => {
|
||||||
logger.info(message);
|
logger.info(message);
|
||||||
|
|||||||
Reference in New Issue
Block a user