mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
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:
1 parent
3387db1c4d
commit
e0a4c01654
15 files changed
+638
-39
No files matched your search
+2
-1
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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',
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user