diff --git a/artifacts/auth-restored.png b/artifacts/auth-restored.png new file mode 100644 index 0000000..2f926eb Binary files /dev/null and b/artifacts/auth-restored.png differ diff --git a/electron/authSessionIpc.ts b/electron/authSessionIpc.ts new file mode 100644 index 0000000..8e2fa67 --- /dev/null +++ b/electron/authSessionIpc.ts @@ -0,0 +1,13 @@ +import type { IpcMain } from 'electron'; +import type { AuthSessionStore } from './authSessionStore.js'; + +export function registerAuthSessionIpc(ipcMain: IpcMain, authSessionStore: AuthSessionStore): void { + ipcMain.handle('openstroid:auth-session:read', () => authSessionStore.read()); + ipcMain.handle('openstroid:auth-session:write', async (_event, value: unknown) => { + const persisted = await authSessionStore.write(typeof value === 'string' ? value : null); + if (!persisted) { + console.warn('[main] secure credential storage is unavailable; authentication will last for this launch only'); + } + return { persisted }; + }); +} diff --git a/electron/authSessionStore.test.ts b/electron/authSessionStore.test.ts new file mode 100644 index 0000000..1999523 --- /dev/null +++ b/electron/authSessionStore.test.ts @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { EncryptedAuthSessionStore, linuxPasswordStore, type SecureStorage } from './authSessionStore.js'; + +function fakeSecureStorage(backend: ReturnType = 'kwallet6'): SecureStorage { + return { + decryptString: (encrypted) => Buffer.from(encrypted.toString(), 'base64').toString(), + encryptString: (plainText) => Buffer.from(Buffer.from(plainText).toString('base64')), + getSelectedStorageBackend: () => backend, + isEncryptionAvailable: () => true, + }; +} + +test('restores an encrypted session from a new store instance', async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'openstroid-auth-')); + const filePath = path.join(directory, 'auth-session.bin'); + const firstLaunch = new EncryptedAuthSessionStore(filePath, fakeSecureStorage(), 'linux'); + const secondLaunch = new EncryptedAuthSessionStore(filePath, fakeSecureStorage(), 'linux'); + + assert.equal(await firstLaunch.write('encrypted-session-handoff'), true); + assert.equal(await secondLaunch.read(), 'encrypted-session-handoff'); + assert.equal((await fs.stat(filePath)).mode & 0o777, 0o600); +}); + +test('intentional logout removes the persisted session', async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'openstroid-auth-')); + const filePath = path.join(directory, 'auth-session.bin'); + const store = new EncryptedAuthSessionStore(filePath, fakeSecureStorage(), 'linux'); + + await store.write('encrypted-session-handoff'); + assert.equal(await store.write(null), true); + assert.equal(await store.read(), null); +}); + +test('invalid encrypted data is discarded instead of reused', async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'openstroid-auth-')); + const filePath = path.join(directory, 'auth-session.bin'); + const secureStorage = fakeSecureStorage(); + secureStorage.decryptString = () => { + throw new Error('invalid ciphertext'); + }; + await fs.writeFile(filePath, 'invalid', { mode: 0o600 }); + + const store = new EncryptedAuthSessionStore(filePath, secureStorage, 'linux'); + assert.equal(await store.read(), null); + await assert.rejects(fs.stat(filePath), { code: 'ENOENT' }); +}); + +test('does not persist credentials with Linux basic text encryption', async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'openstroid-auth-')); + const filePath = path.join(directory, 'auth-session.bin'); + const store = new EncryptedAuthSessionStore(filePath, fakeSecureStorage('basic_text'), 'linux'); + + assert.equal(await store.write('encrypted-session-handoff'), false); + assert.equal(await store.read(), null); + await assert.rejects(fs.stat(filePath), { code: 'ENOENT' }); +}); + +test('uses secure storage on non-Linux platforms without a Linux backend', async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'openstroid-auth-')); + const filePath = path.join(directory, 'auth-session.bin'); + const firstLaunch = new EncryptedAuthSessionStore(filePath, fakeSecureStorage('unknown'), 'win32'); + const secondLaunch = new EncryptedAuthSessionStore(filePath, fakeSecureStorage('unknown'), 'win32'); + + assert.equal(await firstLaunch.write('encrypted-session-handoff'), true); + assert.equal(await secondLaunch.read(), 'encrypted-session-handoff'); +}); + +test('retains the last valid session when a secure write fails', async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'openstroid-auth-')); + const filePath = path.join(directory, 'auth-session.bin'); + const secureStorage = fakeSecureStorage(); + const store = new EncryptedAuthSessionStore(filePath, secureStorage, 'linux'); + + await store.write('valid-session'); + secureStorage.encryptString = () => { + throw new Error('keyring unavailable'); + }; + await assert.rejects(store.write('replacement-session'), /keyring unavailable/); + secureStorage.encryptString = fakeSecureStorage().encryptString; + assert.equal(await store.read(), 'valid-session'); +}); + +test('selects KWallet 6 for KDE Plasma 6 sessions', () => { + assert.equal(linuxPasswordStore({ XDG_CURRENT_DESKTOP: 'KDE', KDE_SESSION_VERSION: '6' }), 'kwallet6'); + assert.equal(linuxPasswordStore({ XDG_CURRENT_DESKTOP: 'GNOME' }), null); +}); diff --git a/electron/authSessionStore.ts b/electron/authSessionStore.ts new file mode 100644 index 0000000..f2b2d2d --- /dev/null +++ b/electron/authSessionStore.ts @@ -0,0 +1,70 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +export interface SecureStorage { + decryptString(encrypted: Buffer): string; + encryptString(plainText: string): Buffer; + getSelectedStorageBackend(): 'basic_text' | 'gnome_libsecret' | 'kwallet' | 'kwallet5' | 'kwallet6' | 'unknown'; + isEncryptionAvailable(): boolean; +} + +export interface AuthSessionStore { + read(): Promise; + write(value: string | null): Promise; +} + +export function linuxPasswordStore(environment: NodeJS.ProcessEnv): 'kwallet6' | null { + const desktop = environment.XDG_CURRENT_DESKTOP ?? environment.DESKTOP_SESSION ?? ''; + const isKde = desktop.split(':').some((value) => value.toLowerCase().includes('kde')) + || environment.KDE_FULL_SESSION === 'true'; + return isKde && environment.KDE_SESSION_VERSION === '6' ? 'kwallet6' : null; +} + +export class EncryptedAuthSessionStore implements AuthSessionStore { + constructor( + private readonly filePath: string, + private readonly secureStorage: SecureStorage, + private readonly platform = process.platform, + ) {} + + private encryptionAvailable(): boolean { + if (!this.secureStorage.isEncryptionAvailable()) return false; + if (this.platform !== 'linux') return true; + const backend = this.secureStorage.getSelectedStorageBackend(); + return backend !== 'basic_text' && backend !== 'unknown'; + } + + async read(): Promise { + if (!this.encryptionAvailable()) return null; + + try { + const encrypted = await fs.readFile(this.filePath); + return this.secureStorage.decryptString(encrypted) || null; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + await this.clear(); + } + return null; + } + } + + async write(value: string | null): Promise { + if (!value) { + await this.clear(); + return true; + } + if (!this.encryptionAvailable()) return false; + + const directory = path.dirname(this.filePath); + const temporaryPath = `${this.filePath}.${process.pid}.tmp`; + await fs.mkdir(directory, { recursive: true, mode: 0o700 }); + await fs.writeFile(temporaryPath, this.secureStorage.encryptString(value), { mode: 0o600 }); + await fs.rename(temporaryPath, this.filePath); + await fs.chmod(this.filePath, 0o600); + return true; + } + + private async clear(): Promise { + await fs.rm(this.filePath, { force: true }); + } +} diff --git a/electron/main.ts b/electron/main.ts index e2046a9..c3b7004 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -2,12 +2,19 @@ import type { AddressInfo } from 'node:net'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { randomUUID } from 'node:crypto'; -import { app, BrowserWindow, ipcMain, nativeImage, session, shell } from 'electron'; +import { app, BrowserWindow, ipcMain, nativeImage, safeStorage, session, shell } from 'electron'; +import { EncryptedAuthSessionStore, linuxPasswordStore } from './authSessionStore.js'; +import { registerAuthSessionIpc } from './authSessionIpc.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const preloadPath = path.join(__dirname, 'preload.cjs'); +const passwordStore = process.platform === 'linux' ? linuxPasswordStore(process.env) : null; +if (passwordStore) { + app.commandLine.appendSwitch('password-store', passwordStore); +} + let bridgePort = 3001; const pendingStreamLaunches = new Map(); const streamLaunchIdsByWebContents = new Map(); @@ -150,6 +157,10 @@ function createMainWindow() { async function bootstrapDesktopApp() { const { startBridgeServer } = await import('../server/app.js'); const { serverConfig } = await import('../server/config.js'); + const authSessionStore = new EncryptedAuthSessionStore( + path.join(app.getPath('userData'), 'auth-session.bin'), + safeStorage, + ); bridgePort = serverConfig.port; const server = await startBridgeServer(serverConfig.port); @@ -160,6 +171,7 @@ async function bootstrapDesktopApp() { createMainWindow(); registerIpcHandlers(); + registerAuthSessionIpc(ipcMain, authSessionStore); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { diff --git a/electron/preload.cjs b/electron/preload.cjs index af11982..5570b00 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -8,6 +8,8 @@ function installLocalStorageState(state = {}) { } contextBridge.exposeInMainWorld('openStroid', { + readSessionHandoff: () => ipcRenderer.invoke('openstroid:auth-session:read'), + writeSessionHandoff: (value) => ipcRenderer.invoke('openstroid:auth-session:write', value), openStream: (launch) => ipcRenderer.invoke('openstroid:open-stream', launch), getStreamLaunch: async () => { const launch = await ipcRenderer.invoke('openstroid:get-stream-launch'); diff --git a/electron/preload.cts b/electron/preload.cts index d528325..8dfec4f 100644 --- a/electron/preload.cts +++ b/electron/preload.cts @@ -15,6 +15,8 @@ function installLocalStorageState(state: Record = {}) { } contextBridge.exposeInMainWorld('openStroid', { + readSessionHandoff: () => ipcRenderer.invoke('openstroid:auth-session:read') as Promise, + writeSessionHandoff: (value: string | null) => ipcRenderer.invoke('openstroid:auth-session:write', value) as Promise<{ persisted: boolean }>, openStream: (launch: StreamLaunchPayload) => ipcRenderer.invoke('openstroid:open-stream', launch) as Promise<{ ok: boolean }>, getStreamLaunch: async () => { const launch = await ipcRenderer.invoke('openstroid:get-stream-launch') as StreamLaunchPayload | null; diff --git a/package.json b/package.json index b88a157..8b6f3b6 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "build": "tsc -b && tsc -p tsconfig.server.json && tsc -p tsconfig.electron.json && vite build", "dist": "npm run build && electron-builder", "lint": "eslint .", + "test": "node --import tsx --test electron/*.test.ts", "test:ui": "node tools/ui-smoke.mjs", "preview": "vite preview", "start": "electron build/electron/electron/main.js", diff --git a/src/api/client.ts b/src/api/client.ts index bc1101b..7072696 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -12,8 +12,8 @@ export const apiClient = axios.create({ timeout: 15000, }); -apiClient.interceptors.request.use((config) => { - const handoff = readSessionHandoff(); +apiClient.interceptors.request.use(async (config) => { + const handoff = await readSessionHandoff(); if (handoff) { config.headers.set('X-OpenStroid-Session', handoff); } diff --git a/src/api/endpoints.ts b/src/api/endpoints.ts index ad1b607..c215cc1 100644 --- a/src/api/endpoints.ts +++ b/src/api/endpoints.ts @@ -10,10 +10,10 @@ import type { User, } from '../types'; -function extractSession(data: Record): AuthSession { +async function extractSession(data: Record): Promise { const sessionHandoff = typeof data.sessionHandoff === 'string' ? data.sessionHandoff : null; if (sessionHandoff) { - writeSessionHandoff(sessionHandoff); + await writeSessionHandoff(sessionHandoff); } return { diff --git a/src/auth/AuthContext.tsx b/src/auth/AuthContext.tsx index 5cce89e..07fd1f8 100644 --- a/src/auth/AuthContext.tsx +++ b/src/auth/AuthContext.tsx @@ -44,7 +44,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { applySession(session.user); return session.authenticated || Boolean(session.user); } catch { - clearAuthStorage(); + await clearAuthStorage(); applySession(null); return false; } @@ -59,8 +59,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { useEffect(() => { const handleUnauthorized = () => { - clearAuthStorage(); - applySession(null); + void clearAuthStorage().finally(() => applySession(null)); }; window.addEventListener('openstroid:unauthorized', handleUnauthorized); @@ -73,7 +72,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { try { await api.logout(); } finally { - clearAuthStorage(); + await clearAuthStorage(); applySession(null); } }, [applySession]); diff --git a/src/auth/storage.ts b/src/auth/storage.ts index 1fac439..77458a7 100644 --- a/src/auth/storage.ts +++ b/src/auth/storage.ts @@ -1,11 +1,39 @@ const LEGACY_STORAGE_KEYS = ['access_token', 'refresh_token', 'boosteroid_auth'] as const; const SESSION_HANDOFF_KEY = 'openstroid:session-handoff'; +let desktopSessionHandoff: string | null | undefined; +let desktopSessionLoad: Promise | null = null; -export function readSessionHandoff(): string | null { +async function readDesktopSessionHandoff(): Promise { + const read = window.openStroid?.readSessionHandoff; + if (!read) return null; + if (desktopSessionHandoff !== undefined) return desktopSessionHandoff; + desktopSessionLoad ??= read().then((value) => { + desktopSessionHandoff = value; + sessionStorage.removeItem(SESSION_HANDOFF_KEY); + return value; + }); + return desktopSessionLoad; +} + +export async function readSessionHandoff(): Promise { + if (window.openStroid?.readSessionHandoff) { + return readDesktopSessionHandoff(); + } return sessionStorage.getItem(SESSION_HANDOFF_KEY); } -export function writeSessionHandoff(value: string | null | undefined): void { +export async function writeSessionHandoff(value: string | null | undefined): Promise { + if (window.openStroid?.writeSessionHandoff) { + const nextValue = value || null; + const currentValue = await readDesktopSessionHandoff(); + if (currentValue === nextValue) return; + const write = window.openStroid.writeSessionHandoff; + await write(nextValue); + desktopSessionHandoff = nextValue; + desktopSessionLoad = Promise.resolve(nextValue); + sessionStorage.removeItem(SESSION_HANDOFF_KEY); + return; + } if (value) { sessionStorage.setItem(SESSION_HANDOFF_KEY, value); return; @@ -19,11 +47,11 @@ export function clearLegacyAuthStorage(): void { } } -function clearSessionHandoff(): void { - writeSessionHandoff(null); +async function clearSessionHandoff(): Promise { + await writeSessionHandoff(null); } -export function clearAuthStorage(): void { +export async function clearAuthStorage(): Promise { clearLegacyAuthStorage(); - clearSessionHandoff(); + await clearSessionHandoff(); } diff --git a/src/global.d.ts b/src/global.d.ts index ca50b7c..0695ee7 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -3,6 +3,8 @@ import type { StreamLaunchResponse } from './types'; declare global { interface Window { openStroid?: { + readSessionHandoff?(): Promise; + writeSessionHandoff?(value: string | null): Promise<{ persisted: boolean }>; openStream(launch: StreamLaunchResponse): Promise<{ ok: boolean }>; getStreamLaunch?(): Promise; }; diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index 4a36678..36a6593 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -82,7 +82,7 @@ export function LoginPage() { const completeQrLogin = useCallback(async (session: QRCodeLoginSessionStatus): Promise => { if (session.sessionHandoff) { - writeSessionHandoff(session.sessionHandoff); + await writeSessionHandoff(session.sessionHandoff); } for (let attempt = 0; attempt < 3; attempt += 1) {