diff --git a/nobilis b/nobilis index 3b18e3e..ac75dd5 160000 --- a/nobilis +++ b/nobilis @@ -1 +1 @@ -Subproject commit 3b18e3e08139e59ee195f24ef8b0d77553c8a9c0 +Subproject commit ac75dd5f095ee729e046255b30c4d5d880bb30b9 diff --git a/src/main/browser-login.ts b/src/main/browser-login.ts new file mode 100644 index 0000000..84fa791 --- /dev/null +++ b/src/main/browser-login.ts @@ -0,0 +1,249 @@ +import { BrowserWindow, session } from 'electron' +import type { Session } from 'electron' + +/** + * Signing in by letting the service's own login page do it. + * + * The alternative is posting credentials at an auth endpoint ourselves, which + * fails for every service worth supporting and fails in the worst way: a + * captcha we cannot render, a device check we cannot answer, an MFA prompt we + * would have to reimplement, and - because a bare HTTP client submitting + * passwords looks exactly like one trying a list of them - a black mark on the + * account for trying. Discord refuses outright and files a warning against the + * account each time. + * + * A real browser window sidesteps all of it by not being a workaround. The + * page is the service's own, so its captcha, its MFA and its device + * verification simply work, and the fingerprint it presents is genuine because + * it is genuinely Chromium loading their page. Nothing here claims to be + * anything it is not. + * + * Two properties matter and are worth stating plainly, because the design is + * built around them: the password is typed into the service's form and this + * process never sees it, and the session is thrown away afterwards so nothing + * is left on disk to steal. + */ + +/** How a service's credential shows up once the sign-in succeeds. */ +export type Capture = + /** A request header the page starts sending - Discord's Authorization. */ + | { kind: 'header'; name: string } + /** A cookie the service sets - the usual shape for forum software. */ + | { kind: 'cookie'; name: string; url: string } + /** A parameter on a redirect the service lands on - SSO, mostly. */ + | { kind: 'query'; param: string } + +export interface LoginFlow { + /** Shown in the window's title bar, so it is obvious whose page this is. */ + label: string + /** Where the sign-in starts. */ + url: string + /** + * Which requests to inspect, as Electron URL patterns. + * + * Deliberately narrow, and narrower than where the window may navigate: a + * login can legitimately bounce through a captcha provider or an identity + * provider, and those are none of our business to read. + */ + watch: string[] + capture: Capture + /** + * The daemon call that turns the captured value into a signed-in account, + * and the parameter to hand it over as. + * + * Named here so the value can go straight from the login window to the + * daemon without passing through the renderer. It is a credential; the + * fewer places it exists, the better. + */ + finish: { method: string; param: string } +} + +/** + * The services this can sign in to. + * + * One table so adding the next one is an entry rather than a new code path. + * It lives in main rather than in the daemon despite being protocol knowledge, + * because it is only actionable by something that can open a window - a + * headless frontend could not use it if it were handed over. + */ +export const LOGIN_FLOWS: Record = { + discord: { + label: 'Discord', + url: 'https://discord.com/login', + // Only Discord's own API. The login page loads hCaptcha and a CDN, and + // neither carries anything worth looking at. + watch: ['https://discord.com/api/*'], + // The web client attaches the token to every API call it makes once + // signed in, so the first authenticated request is the answer. + capture: { kind: 'header', name: 'authorization' }, + finish: { method: 'addDiscordAccountToken', param: 'token' } + } +} + +export interface LoginOutcome { + ok: boolean + /** Present only on success. A credential: never log it, never persist it. */ + value?: string + /** Why it did not succeed, for showing to a person. */ + error?: string +} + +/** Long enough to find a password manager and answer an MFA prompt. */ +const TIMEOUT_MS = 5 * 60 * 1000 + +let openWindow: BrowserWindow | null = null + +/** + * Opens the service's login page and resolves with whatever the flow captures. + * + * One at a time: two of these racing would leave two windows both claiming to + * be signing in, and the second would inherit the first's half-finished + * session. + */ +export async function browserLogin(service: string): Promise { + const flow = LOGIN_FLOWS[service] + if (!flow) return { ok: false, error: `No browser sign-in is defined for ${service}` } + if (openWindow && !openWindow.isDestroyed()) { + openWindow.focus() + return { ok: false, error: 'A sign-in window is already open' } + } + + // A partition with no "persist:" prefix is memory-only: cookies, storage and + // cache exist while the window does and are gone with it. That is the whole + // security story here - an abandoned sign-in leaves nothing behind, and + // neither does a completed one. + const partition = `moho-login-${service}-${Date.now()}` + const ses = session.fromPartition(partition) + + const win = new BrowserWindow({ + width: 520, + height: 760, + title: `Sign in to ${flow.label}`, + autoHideMenuBar: true, + backgroundColor: '#101418', + webPreferences: { + partition, + // Untrusted third-party content. No preload, no node, and its own + // renderer process - this window can reach nothing of moho's. + preload: undefined, + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + webSecurity: true + } + }) + openWindow = win + + // This window has no address bar, so the title is the only thing that says + // where you are - which matters, because typing a password into a page you + // cannot identify is the shape of every phishing attack there is. The page + // is not allowed to rename it, and the host is shown alongside so a redirect + // somewhere unexpected is visible rather than silent. + const retitle = (): void => { + let host = '' + try { + host = new URL(win.webContents.getURL()).host + } catch { + /* nothing loaded yet */ + } + if (!win.isDestroyed()) win.setTitle(host ? `Sign in to ${flow.label} — ${host}` : `Sign in to ${flow.label}`) + } + win.on('page-title-updated', (e) => { + e.preventDefault() + retitle() + }) + win.webContents.on('did-navigate', retitle) + win.webContents.on('did-navigate-in-page', retitle) + retitle() + + return await new Promise((resolve) => { + let settled = false + const finish = (outcome: LoginOutcome): void => { + if (settled) return + settled = true + clearTimeout(timer) + detach(ses) + if (!win.isDestroyed()) win.destroy() + openWindow = null + // Belt and braces alongside the memory-only partition: an explicit wipe + // means a future change to a persistent partition cannot silently start + // leaving a live session on disk. + void ses.clearStorageData() + resolve(outcome) + } + + const timer = setTimeout( + () => finish({ ok: false, error: 'Timed out waiting for the sign-in to finish' }), + TIMEOUT_MS + ) + + watch(ses, flow, (value) => finish({ ok: true, value })) + + // Closing the window is a decision, not a failure - reported so the caller + // can go quiet rather than showing an error nobody caused. + win.on('closed', () => finish({ ok: false, error: 'cancelled' })) + + win.loadURL(flow.url).catch((e: Error) => + finish({ ok: false, error: `Could not open ${flow.label}'s sign-in page: ${e.message}` }) + ) + }) +} + +function watch(ses: Session, flow: LoginFlow, found: (value: string) => void): void { + if (flow.capture.kind === 'header') { + const wanted = flow.capture.name.toLowerCase() + ses.webRequest.onBeforeSendHeaders({ urls: flow.watch }, (details, callback) => { + // Pass the request through untouched whatever happens: this is an + // observer, and a login that breaks because we interfered with it would + // be a poor sort of sign-in window. + callback({ requestHeaders: details.requestHeaders }) + for (const [name, value] of Object.entries(details.requestHeaders)) { + if (name.toLowerCase() !== wanted) continue + const token = String(value).trim() + // The page makes unauthenticated API calls before sign-in too, and + // some carry an empty or placeholder header. + if (token && token.toLowerCase() !== 'undefined' && token.toLowerCase() !== 'null') { + found(token) + } + } + }) + return + } + + if (flow.capture.kind === 'cookie') { + const { name, url } = flow.capture + const check = (): void => { + void ses.cookies.get({ url, name }).then((cookies) => { + const value = cookies[0]?.value + if (value) found(value) + }) + } + ses.cookies.on('changed', check) + check() + return + } + + const { param } = flow.capture + ses.webRequest.onBeforeRequest({ urls: flow.watch }, (details, callback) => { + callback({}) + try { + const value = new URL(details.url).searchParams.get(param) + if (value) found(value) + } catch { + /* not a URL we can parse; nothing to take from it */ + } + }) +} + +/** + * Stops listening. + * + * Electron takes one handler per session for each of these, and passing null + * is how it is removed - left attached, a finished flow would go on reading + * headers from a session that is about to be wiped. + */ +function detach(ses: Session): void { + ses.webRequest.onBeforeSendHeaders(null) + ses.webRequest.onBeforeRequest(null) + ses.cookies.removeAllListeners('changed') +} diff --git a/src/main/index.ts b/src/main/index.ts index aef0620..1438c2c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -20,6 +20,7 @@ import { defaultSocketPath, NobilisClient } from './nobilis-client' import { NobilisProcess } from './nobilis-process' import { Prefs } from './prefs' import { Notifier } from './notifications' +import { browserLogin, LOGIN_FLOWS } from './browser-login' import { IPC } from '../shared/ipc' import { allowRoot, installMediaHandler, registerMediaScheme } from './media-protocol' import { saveMedia } from './downloads' @@ -350,6 +351,30 @@ function wireIpc(): void { available: nobilis.available(), linkUp: client.linkUp })) + /** + * Sign in through the service's own login page. + * + * The captured credential goes straight from the login window to the daemon + * and is never returned here, so it never enters the renderer at all - only + * whether it worked comes back. Nothing in this path is logged: an error + * from the daemon is passed through, but the token never appears in one. + */ + ipcMain.handle(IPC.browserLogin, async (_e, service: string, accountId?: string) => { + const flow = LOGIN_FLOWS[service] + if (!flow) return { ok: false, error: `No browser sign-in is defined for ${service}` } + const outcome = await browserLogin(service) + if (!outcome.ok || !outcome.value) return { ok: false, error: outcome.error } + try { + await client.request(flow.finish.method, { + [flow.finish.param]: outcome.value, + ...(accountId ? { accountId } : {}) + }) + return { ok: true } + } catch (e) { + return { ok: false, error: (e as Error).message } + } + }) + ipcMain.handle(IPC.smiliesDir, () => smiliesPath()) } diff --git a/src/preload/index.ts b/src/preload/index.ts index db4e924..34ed928 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -19,6 +19,17 @@ const api = { return res.result as T }, + /** + * Signs in through the service's own login page, in a real browser window. + * + * Only the outcome comes back. The credential the window captures goes + * straight from main to the daemon and is deliberately never returned here - + * the renderer has no reason to hold a token, so it never does. + */ + browserLogin(service: string, accountId?: string): Promise<{ ok: boolean; error?: string }> { + return ipcRenderer.invoke(IPC.browserLogin, service, accountId) + }, + onEvent(cb: (frame: NobilisEvent) => void): () => void { const handler = (_e: unknown, frame: NobilisEvent): void => cb(frame) ipcRenderer.on(IPC.event, handler) diff --git a/src/renderer/src/components/AccountsPanel.tsx b/src/renderer/src/components/AccountsPanel.tsx index f162c81..978235e 100644 --- a/src/renderer/src/components/AccountsPanel.tsx +++ b/src/renderer/src/components/AccountsPanel.tsx @@ -468,9 +468,9 @@ function DiscordForm({ accountId }: { accountId?: string }): JSX.Element { const qrPath = useChat((s) => s.discordQrPath) const status = useChat((s) => s.discordLoginStatus) const mfa = useChat((s) => s.discordMfa) - const [method, setMethod] = useState<'qr' | 'password'>('qr') - const [login, setLogin] = useState('') - const [password, setPassword] = useState('') + const [method, setMethod] = useState<'qr' | 'browser'>('qr') + // No password state any more, deliberately: the password is typed into + // Discord's own page in the sign-in window, so this process never holds one. const [code, setCode] = useState('') const [busy, setBusy] = useState(false) @@ -551,10 +551,10 @@ function DiscordForm({ accountId }: { accountId?: string }): JSX.Element { @@ -583,49 +583,34 @@ function DiscordForm({ accountId }: { accountId?: string }): JSX.Element { ) : ( <> -
- - -
+

+ Opens Discord's own sign-in page in a browser window. Your password goes into + their form and never passes through moho — and because it is their page, their + captcha, two-factor and device checks all work normally. The window is thrown away + afterwards, session and all. +

-

- Discord generally will not accept a password from a third-party client — it answers - with a captcha or an “Invalid Form Body” rejection — and every attempt - counts against the account, which is how sign-in warnings appear on it. QR login is - the way in: approving on a device you are already signed in on is the proof Discord is - asking for, and it costs the account nothing. -

)} {status &&

{status}

} diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 4a39947..086671f 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -19,6 +19,7 @@ export const IPC = { daemonStatus: 'moho:daemonStatus', smiliesDir: 'moho:smiliesDir', markBufferRead: 'moho:markBufferRead', + browserLogin: 'moho:browserLogin', // main -> renderer (send) event: 'moho:event',