backup: the Proton login runs from the workspace

:backup login <username> shows Proton's third-party disclosure (and a
warning when no session key file seals the stored session), asks for
the password, then for a two-factor code when the account has one. If
Proton answers with a human check, its own verify.proton.me page opens
in a sheet; the token it posts back resumes the login. :backup logout
forgets the session and ends it on Proton's side where Proton answers.
The mirror picks up a new session within a minute, without a restart.

The login is split into steps in backup::proton (start, second_factor,
verified), and a half-done login waits in memory for five minutes at
most. Every step goes through its own throttle, so the workspace cannot
hammer Proton into locking the account. FIDO2-only accounts are told to
add an authenticator app. The CLI runs over the same steps and points
to :backup login when Proton wants a human check. The CSP lets
verify.proton.me into a frame and nothing else.
This commit is contained in:
nvrl committed 2026-10-20 12:54:03 +02:00
1 parent 3387db1c4d
commit e0a4c01654
15 files changed
+638 -39

No files matched your search

+2 -1
View File
@@ -8,6 +8,7 @@
import DocumentPane from './lib/DocumentPane.svelte'
import FilePane from './lib/FilePane.svelte'
import Help from './lib/Help.svelte'
import Verify from './lib/Verify.svelte'
import Login from './lib/Login.svelte'
import Overview from './lib/Overview.svelte'
import MediaBrowser from './lib/MediaBrowser.svelte'
@@ -740,7 +741,7 @@
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions, a11y_no_noninteractive_tabindex -->
<div class="overlay" tabindex="-1" bind:this={overlayEl} onclick={(e) => e.target === e.currentTarget && (workspace.overlay = 'none')}
>
{#if workspace.overlay === 'help'}<Help />{:else if workspace.overlay === 'overview'}<Overview />{:else if workspace.overlay === 'agenda'}<Agenda />{:else if workspace.overlay === 'actions'}<Actions />{:else}<MediaBrowser />{/if}
{#if workspace.overlay === 'help'}<Help />{:else if workspace.overlay === 'overview'}<Overview />{:else if workspace.overlay === 'agenda'}<Agenda />{:else if workspace.overlay === 'actions'}<Actions />{:else if workspace.overlay === 'verify'}<Verify />{:else}<MediaBrowser />{/if}
</div>
{/if}
</div>
+73
View File
@@ -0,0 +1,73 @@
<script lang="ts">
/*
* Proton's human check, when a login from `:backup login` runs into one:
* Proton's own page in a frame, which hands back a token once it is solved.
* Only messages from that page are listened to. Closing the sheet — Escape,
* or a click beside it — abandons the login.
*/
import { onDestroy } from 'svelte'
import { workspace } from './store.svelte'
const ORIGIN = 'https://verify.proton.me'
const asked = workspace.verify
let height = $state(560)
function onmessage(event: MessageEvent) {
if (event.origin !== ORIGIN) return
let data = event.data
if (typeof data === 'string') {
try {
data = JSON.parse(data)
} catch {
return
}
}
const payload = data?.payload
if (data?.type === 'HUMAN_VERIFICATION_SUCCESS' && typeof payload?.token === 'string') {
asked?.resolve(payload.token)
workspace.overlay = 'none'
} else if (data?.type === 'RESIZE' && typeof payload?.height === 'number') {
height = Math.min(Math.max(payload.height, 320), 900)
} else if (data?.type === 'CLOSE') {
workspace.overlay = 'none'
}
}
onDestroy(() => asked?.resolve(null))
</script>
<svelte:window {onmessage} />
<div class="sheet">
<header>
<span class="label rule"><span class="marker"></span><span class="title">proton</span> prove you are human to log in</span>
<button onclick={() => (workspace.overlay = 'none')}>esc</button>
</header>
{#if asked}
<iframe
title="Proton human verification"
src={asked.url}
style:height="{height}px"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
referrerpolicy="no-referrer"
></iframe>
{/if}
</div>
<style>
.sheet {
display: flex;
flex-direction: column;
gap: 16px;
width: min(100%, 64ch);
max-height: 100%;
overflow-y: auto;
background: color-mix(in oklab, var(--hull) 97%, transparent);
border: 1px solid var(--surface-alt);
border-radius: var(--frame-radius);
padding: 20px 24px;
}
header { display: flex; align-items: center; gap: 12px; }
header .label { flex: 1; }
iframe { width: 100%; border: 0; border-radius: 6px; background: #fff; }
</style>
+22
View File
@@ -216,6 +216,18 @@ export interface BackupStatus {
last_success_at: number | null
last_error: string | null
last: { uploaded: number; skipped: number; pruned: number; bytes: number } | null
/** What Proton asks a third-party client to say before it takes a login. */
disclosure?: string
/** Whether a stored session is sealed under a key file off the data volume. */
sealed_session?: boolean
}
/** What a Proton login from the workspace needs next. */
export interface BackupLogin {
next: 'done' | 'totp' | 'captcha'
/** Proton's human-check page, for `captcha`. */
url?: string
status: BackupStatus
}
export class ApiError extends Error {
@@ -472,6 +484,16 @@ export const api = {
backupNow: () => request<BackupStatus>('/backup', { method: 'POST' }),
backupLogin: (username: string, password: string) =>
request<BackupLogin>('/backup/login', { method: 'POST', body: body({ username, password }) }),
backupTotp: (code: string) => request<BackupLogin>('/backup/login/totp', { method: 'POST', body: body({ code }) }),
backupCaptcha: (token: string) =>
request<BackupLogin>('/backup/login/captcha', { method: 'POST', body: body({ token }) }),
backupLogout: () => request<BackupStatus>('/backup/login', { method: 'DELETE' }),
readShared: (token: string, key: string) =>
request<SharedDocument>(`/share/${encodeURIComponent(token)}`, { method: 'POST', body: body({ key }) }),
+56 -5
View File
@@ -1,4 +1,4 @@
import { api, isContainer, type BackupStatus } from './api'
import { api, ApiError, isContainer, type BackupLogin, type BackupStatus } from './api'
import { workspace } from './store.svelte'
import { formatDay, parseDay, parseDue, relative, sealedNote, type ScrollAmount } from './util'
@@ -131,7 +131,7 @@ function browserOf(agent: string): string {
const describeBackup = (status: BackupStatus) => {
if (!status.configured) {
return workspace.say('proton drive backups are off — run `narl-workspace proton-login` on the server', 'error')
return workspace.say('proton drive backups are off — :backup login <proton-username> sets them up', 'error')
}
if (status.last_error) {
return workspace.say(`backup failed: ${status.last_error}`, 'error')
@@ -141,6 +141,44 @@ const describeBackup = (status: BackupStatus) => {
workspace.say(`${where}${state} · last ${ago(status.last_success_at)}`)
}
/**
* Log the mirror in to Proton: the disclosure Proton asks for, then the
* password, then whatever the account asks for next — a two-factor code, or
* Proton's human check in a sheet of its own.
*/
async function protonLogin(username: string) {
const status = await api.backupStatus()
const unsealed = status.sealed_session
? ''
: ' The session will be stored unsealed on the data volume — set WORKSPACE_SESSION_KEY_FILE to seal it.'
if (!(await workspace.ask(`${status.disclosure ?? ''}${unsealed} Log in as ${username}?`))) return
const password = await workspace.askSecret(`proton password for ${username}`)
if (password === null) return
workspace.say('logging in to proton …')
let answer: BackupLogin = await api.backupLogin(username, password)
for (let round = 0; answer.next !== 'done'; round++) {
if (round === 5) return workspace.say('proton keeps asking — try again later', 'error')
if (answer.next === 'totp') {
const code = await workspace.askSecret('proton two-factor code')
if (code === null) return workspace.say('proton login abandoned')
try {
answer = await api.backupTotp(code)
} catch (error) {
// A wrong code leaves the login waiting; ask again.
if (!(error instanceof ApiError) || error.status !== 400 || !/two-factor code/.test(error.message)) throw error
workspace.say(error.message, 'error')
}
} else {
const token = await workspace.askVerification(answer.url ?? '')
if (token === null) return workspace.say('proton login abandoned')
workspace.say('logging in to proton …')
answer = await api.backupCaptcha(token)
}
}
const device = answer.status.device ? ` as ${answer.status.device}` : ''
workspace.say(`proton drive connected${device} — the first backup follows shortly, :backup! runs one now`)
}
/** A filename that survives a download folder: `2026-09-05-first-light.md`. */
const exportName = (title: string, at: number) => {
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
@@ -787,8 +825,21 @@ export const commands: CommandSpec[] = [
{
name: 'backup',
group: 'view',
help: 'when the workspace was last mirrored to proton drive',
run: () => workspace.guard(async () => describeBackup(await api.backupStatus())),
args: '[login <proton-username> | logout]',
help: 'when the workspace was last mirrored to proton drive — login sets the mirror up, logout forgets its session',
run: (arg) =>
workspace.guard(async () => {
const trimmed = arg.trim()
if (trimmed === '') return describeBackup(await api.backupStatus())
const loginMatch = /^login\s+(\S+)$/.exec(trimmed)
if (loginMatch) return protonLogin(loginMatch[1])
if (trimmed === 'logout') {
if (!(await workspace.ask('Forget the Proton Drive session? What is mirrored stays on Drive.'))) return
await api.backupLogout()
return workspace.say('proton drive session forgotten — backups are off')
}
workspace.say('usage: :backup [login <proton-username> | logout]', 'error')
}),
bang: {
help: 'back up to proton drive now, rather than when the workspace falls quiet',
run: () =>
@@ -803,7 +854,7 @@ export const commands: CommandSpec[] = [
workspace.say(
last
? `backed up · ${last.uploaded} file${last.uploaded === 1 ? '' : 's'} uploaded, ${last.skipped} unchanged`
: 'proton drive backups are off — run `narl-workspace proton-login` on the server',
: 'proton drive backups are off — :backup login <proton-username> sets them up',
last ? 'info' : 'error',
)
}),
+22 -1
View File
@@ -101,7 +101,7 @@ class Workspace {
vimMode = $state('normal')
flash = $state<Flash>(null)
overlay = $state<'none' | 'help' | 'media' | 'overview' | 'agenda' | 'actions'>('none')
overlay = $state<'none' | 'help' | 'media' | 'overview' | 'agenda' | 'actions' | 'verify'>('none')
/*
* What is due or in hand on every board, kept loaded all the time: the bar
@@ -214,6 +214,27 @@ class Workspace {
return { late: overdue.cards.length, today: today.cards.length }
}
/** Proton's human check a login waits on, while its sheet is open. */
verify = $state.raw<{ url: string; resolve: (token: string | null) => void } | null>(null)
/** The token Proton's check at `url` hands back, or null when abandoned. */
askVerification(url: string): Promise<string | null> {
this.verify?.resolve(null)
return new Promise((resolve) => {
let answered = false
this.verify = {
url,
resolve: (token) => {
if (answered) return
answered = true
this.verify = null
resolve(token)
},
}
this.overlay = 'verify'
})
}
/** A password, or null when it was waved away with Escape. */
askSecret(text: string): Promise<string | null> {
this.secret?.resolve(null)