feat(anilist): enforce encrypted token storage and default gnome-libsecret

This commit is contained in:
2026-02-23 23:53:53 -08:00
parent b989508ece
commit a2735eaedc
5 changed files with 179 additions and 26 deletions

View File

@@ -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));

View File

@@ -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;
}
},