Skip to content

Commit 6178f32

Browse files
authored
feat(mega): session-token auth, 2FA, and worker hardening (#239)
Replace plaintext MEGA password storage with a reused, revocable session token (megajs toJSON/fromJSON), add two-step 2FA, migrate existing users, and harden workers (upload via session blob; downloads via sid+nodeId+fileKey, removing the share-link hack). Adds MEGA upload thumbnails. Fixes found in live verification: never close() the shared session (ESID), keepalive:false to avoid the server-change poll crash, and delete a volume's sidecars on removal. No plaintext password persisted or sent to workers.
1 parent 911a259 commit 6178f32

16 files changed

Lines changed: 2849 additions & 306 deletions

docs/superpowers/plans/2026-06-29-mega-session-auth.md

Lines changed: 1536 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
# MEGA Session-Token Auth, 2FA, and Worker Hardening — Design
2+
3+
**Date:** 2026-06-29
4+
**Branch:** `feat/mega-session-auth` (based on `develop`)
5+
**Status:** Approved design → implementation planning
6+
7+
## Problem
8+
9+
The MEGA cloud-sync provider has three coupled issues, all rooted in how it authenticates:
10+
11+
1. **Plaintext password storage.** Credentials are stored as plaintext `mega_email` + `mega_password`
12+
in `localStorage` (`mega-provider.ts:21-25`, save at `:230-233`). A full
13+
`new Storage({ email, password })` login runs on **every** app load, manual connect, and cache
14+
refresh (`reinitialize()`), creating a brand-new MEGA session each time. The password is the most
15+
dangerous secret possible — it grants permanent, full account control (change email/password,
16+
disable 2FA, delete account) and never expires.
17+
18+
2. **No 2FA support.** Because login replays the stored password on every reload, there is no way to
19+
support two-factor accounts: a single-use TOTP code cannot be replayed. There is no 2FA handling
20+
or UI anywhere today.
21+
22+
3. **Hacky worker file access.**
23+
- _Downloads_ use a **public share-link workaround**: the main thread mints a `file.link()` URL
24+
(decryption key embedded), ships `megaShareUrl` to the worker, the worker downloads anonymously
25+
via `MegaFile.fromURL`, then the main thread tears the link down with `unshare()`
26+
(`mega-provider.ts:1011-1088`, `mega-core.ts:53-67`). A mutex + 200 ms throttle gates link
27+
creation. This briefly exposes every downloaded file as a public URL.
28+
- _Uploads_ hand the **plaintext password** to the worker (`getWorkerUploadCredentials()`
29+
`{ megaEmail, megaPassword }`, `mega-provider.ts:1197-1202`), which spins up its own full
30+
`Storage`.
31+
32+
## Goal
33+
34+
Replace password storage with a reused, revocable **session token**; add **2FA** login; and replace
35+
both worker hacks with lightweight, session-based file access — without leaving any plaintext
36+
password in storage or in worker messages.
37+
38+
## Library constraints (megajs 1.3.9, verified against installed source)
39+
40+
These facts, verified against `node_modules/megajs/dist/*` and `types/cjs.d.ts`, drive the design.
41+
42+
- **Session persistence is via `storage.toJSON()` / `Storage.fromJSON()`** — there is **no**
43+
`sessionID` constructor option, no `Storage.fromSession`, no `'login'` event.
44+
- `storage.toJSON()``{ sid, key, user, name, options }` (`main.node-cjs.js:2275-2283`).
45+
- `Storage.fromJSON(json)` rebuilds an authenticated `Storage` with **zero network calls**,
46+
forcing `autoload:false` + `autologin:false` and injecting `sid` into the API layer
47+
(`:2284-2295`).
48+
- **⚠ The persisted blob contains the account master key (`key`), not just a session id.** The bare
49+
`sid` authenticates raw API calls but cannot decrypt owned-file keys — that needs the master key,
50+
derivable only from the password at login time. So the stored "token" is still a sensitive secret.
51+
It is nonetheless **meaningfully safer than the password**: the session is server-revocable, and it
52+
**cannot** change the account password/email or disable 2FA (those require the password). The
53+
serialized blob does **not** contain the password (deleted by the lib at login,
54+
`:2088`). **Accepted tradeoff** — there is no lower-privilege artifact in this library.
55+
- **2FA is supported.** `new Storage({ email, password, secondFactorCode })` → maps to the `us`
56+
command's `mfa` field (`:2087-2091`). "2FA required" is signaled **only** by an error message
57+
containing `EMFAREQUIRED` / `-26` / `Multi-Factor` (`:804`, `:884-892`) — no numeric `.code`
58+
property; the caller must string-match.
59+
- **Lightweight sessions are supported.** `autoload:false` authenticates (gets `sid` + master key)
60+
without fetching the file tree (`:2055-2068`); `fromJSON()` is lighter still (no network at all,
61+
no keepalive long-poll).
62+
- **Owned-node download by id is possible without the tree.** `File.download` issues an
63+
authenticated `{ a:"g", g:1, n:<nodeId> }` when `file.nodeId` is set, authorized by `api.sid`
64+
(`:1140-1158`, `:856-857`). Construct `new File({ downloadId: nodeId, key, api })`, force
65+
`file.nodeId = nodeId`, call `downloadBuffer()`. **No share link, and the master key is not needed
66+
in the download worker** — only `sid` + the per-file key.
67+
- **Session invalidation** surfaces as `ESID (-15): Invalid or expired user session, please relogin`
68+
(`:798`).
69+
- The browser build (`main.browser-es.mjs`) is at parity for all of the above (`fromJSON`,
70+
`secondFactorCode`, `sid`, `EMFAREQUIRED`).
71+
72+
## Design
73+
74+
### A. Stored artifact (replaces `mega_email`/`mega_password`)
75+
76+
- New `localStorage` key **`mega_session`** = `JSON.stringify(storage.toJSON())`.
77+
- Before persisting, sanitize `blob.options` to drop `secondFactorCode` (single-use) and any
78+
`password` residue. Email remains in `blob.options.email` — used for status display and
79+
reconnect pre-fill, so **no separate `mega_email` key is required going forward.**
80+
- On migration, **remove** the legacy `mega_email` and `mega_password` keys.
81+
- Stored plaintext, consistent with the existing `gdrive_token` pattern
82+
(`google-drive/token-manager.ts`). Encrypting-at-rest is **out of scope**: there is no user secret
83+
to derive a key from, and both `localStorage` and IndexedDB are equally readable by same-origin JS.
84+
85+
### B. Login & restore (main thread — `mega-provider.ts`)
86+
87+
- **Fresh interactive login** `login({ email, password, secondFactorCode? })`:
88+
`new Storage({ email, password, secondFactorCode, autoload: true })``await ready`
89+
`ensureMokuroFolder()` → capture `toJSON()`, sanitize, write `mega_session`, remove legacy keys,
90+
`setActiveProviderKey('mega')`.
91+
- **Restore** `restoreSession(blob)`: `Storage.fromJSON(blob)` (no network, no password) → then
92+
`storage.reload()` to load the file tree the main thread needs for listing/upload/rename/delete.
93+
This **replaces today's full re-login-on-every-load**: no password, the session is reused, and we
94+
do one tree fetch instead of a login round-trip + tree fetch.
95+
- **`reinitialize()`** (cache-staleness refresh) calls `storage.reload()` on the existing session
96+
instead of a fresh `login()`. Must confirm `reload()` refreshes `storage.files` (verification #1).
97+
- **Error discrimination** (unchanged philosophy): transient/network errors keep `mega_session` and
98+
retry; genuine auth/session failures route to needs-attention (§D).
99+
100+
### C. 2FA — two-step, reveal-on-demand
101+
102+
- Provider: in `login()`, catch the login rejection; if its message matches
103+
`/EMFAREQUIRED|-26|Multi-Factor/i`, rethrow a **typed** `ProviderError` with a stable
104+
`code: 'MFA_REQUIRED'` (and `isAuthError: false`) so the UI branches on a code, not a string.
105+
- UI (`CloudView.svelte`): MEGA form shows email + password only. On `MFA_REQUIRED`, keep
106+
email/password in component state, set `megaNeeds2fa = true`, reveal a 6-digit code `<input>`
107+
(`bind:value={megaTwoFactorCode}`), and resubmit `login({ email, password, secondFactorCode })`.
108+
The code is **never** persisted. On success, clear all fields including the code.
109+
- `provider-interface.ts`: document `secondFactorCode` in the `ProviderCredentials` comment; add the
110+
`MFA_REQUIRED` code to the `ProviderError` conventions.
111+
112+
### D. Session loss / needs-attention (no stored password)
113+
114+
- Detect `ESID (-15)` (message match) on any authenticated operation → `markSessionExpired()`:
115+
remove `mega_session`, set an internal `needsAttention` flag, retain the email (parsed from the old
116+
blob before clearing, or held in memory) for reconnect pre-fill.
117+
- `getStatus()` returns `{ isAuthenticated:false, needsAttention:true, statusMessage:'MEGA session
118+
expired — please reconnect' }`.
119+
- `CloudView.svelte` renders a reconnect prompt (pre-filled email) when MEGA needs attention,
120+
reusing the WebDAV needs-attention UI scaffolding (`CloudView.svelte:55-60`, `:113-115`,
121+
`:693-697`) extended to MEGA. MEGA has no needs-attention UI today, so this is net-new wiring.
122+
- Because no password is stored, reconnect always goes through the full login form (+ 2FA if
123+
required). MEGA sessions are long-lived, so this is rare in practice.
124+
125+
### E. Migration (existing users)
126+
127+
In the restore path (`loadPersistedCredentials` → renamed/reworked `restorePersistedSession`):
128+
129+
1. If `mega_session` present → `restoreSession(blob)`.
130+
2. Else if legacy `mega_email` + `mega_password` present → silent `login({ email, password })`. On
131+
success this writes `mega_session` and deletes the legacy keys (in-place upgrade). On
132+
`MFA_REQUIRED` (account enabled 2FA since the password was stored) → `markSessionExpired()` /
133+
needs-attention reconnect. On genuine auth failure → clear + needs-attention. On transient error →
134+
keep legacy keys for retry.
135+
3. Else → unauthenticated.
136+
137+
- `provider-detection.ts`: `detectProviderFromCredentials()` recognizes `mega_session` **or** the
138+
legacy pair (so a not-yet-migrated user is still detected as MEGA).
139+
- `provider-manager.ts`: `logout()` hard-clear list removes `mega_session` **and** the legacy
140+
`mega_email`/`mega_password`/`mega_folder_path`.
141+
142+
### F. Worker downloads — remove share links (Phase 2)
143+
144+
_Verified: `createShareLink`/`deleteShareLink` have no callers outside the worker-download path, so
145+
the entire share-link mechanism can be removed._
146+
147+
- `getWorkerDownloadCredentials(fileId)`: from the already-loaded tree, locate the node and return
148+
**`{ sid, nodeId, fileKey }`** — the session id, node id, and the node's already-decrypted per-file
149+
key (base64). **No share link, no master key in the download worker.** Drop the
150+
mutex/`workerShareLinkMutex`/`WORKER_SHARE_LINK_THROTTLE_MS`/`workerShareLinksToCleanup`.
151+
- `mega-core.downloadFile`: build a lightweight, per-`sid`-cached api
152+
(`new Storage({ autologin:false, autoload:false })`; set `api.sid = sid`), then
153+
`new File({ downloadId: nodeId, key: fileKeyBuffer, api })`, force `file.nodeId = nodeId`, call
154+
`downloadBuffer()` (with progress). Remove the `MegaFile.fromURL(shareUrl)` path.
155+
- `cleanupWorkerDownload(fileId)` becomes a no-op (or is removed); `download-queue.ts` cleanup
156+
branches for MEGA are removed.
157+
- Remove `createShareLink` / `deleteShareLink` and the `megaShareUrl` credential.
158+
159+
### G. Worker uploads — remove password (Phase 1 — forced by §A)
160+
161+
_This lands in Phase 1, not Phase 2: §A deletes the stored password, but the upload worker currently
162+
reads `mega_email`/`mega_password`. Once the password is gone, the upload path must already use the
163+
session blob. (Download share links, §F, are unaffected — they use the main-thread session, which
164+
keeps working post-migration — so only §F is deferrable to Phase 2.)_
165+
166+
- `getWorkerUploadCredentials()`: return **`{ megaSession: <sanitized toJSON blob> }`** instead of
167+
`{ megaEmail, megaPassword }`.
168+
- `mega-core.getUploadStorage(session)`: `Storage.fromJSON(blob)` + `reload()` for the target folder;
169+
cache per `sid` (replacing the email+password-keyed cache). Remove the `requireCredentialString`
170+
reads of `megaEmail`/`megaPassword`.
171+
- **Asymmetry (documented):** upload necessarily needs the master key to wrap the new file key, so
172+
the upload worker receives the full session blob (incl. master key). This is still strictly safer
173+
than today's plaintext password (the password adds permanent account control + lockout; the blob
174+
does not). Download workers never receive the master key.
175+
176+
## Module boundaries
177+
178+
- **`mega-provider.ts`** (main thread): owns the authenticated `Storage`, session persistence
179+
(`toJSON`/`fromJSON`), migration, 2FA error mapping, needs-attention state, and worker-credential
180+
minting. Public surface unchanged except credential-bag shapes and new typed errors.
181+
- **`mega-core.ts`** (worker): stateless download via `sid + nodeId + fileKey`; upload via
182+
`fromJSON(session)`. No knowledge of localStorage or the UI.
183+
- **`CloudView.svelte`**: 2FA two-step form state + needs-attention reconnect UI. Calls the provider;
184+
no megajs knowledge.
185+
- **`provider-detection.ts` / `provider-manager.ts`**: detection + logout key hygiene for the new
186+
`mega_session` key alongside legacy cleanup.
187+
- **`provider-interface.ts`**: documents `secondFactorCode` + `MFA_REQUIRED`.
188+
189+
## Testing
190+
191+
- **Vitest (CI, mocked megajs).** Mock `Storage` / `Storage.fromJSON` / `File`. Cover:
192+
- migration: legacy `mega_email`+`mega_password``mega_session` written, legacy keys removed;
193+
- 2FA: login throws `EMFAREQUIRED` → provider rethrows `MFA_REQUIRED`; retry with code succeeds;
194+
- session loss: `ESID` on an op → `markSessionExpired()` clears `mega_session` + needs-attention;
195+
- logout clears `mega_session` + legacy keys;
196+
- worker credential shapes: download → `{ sid, nodeId, fileKey }` (no master key); upload →
197+
`{ megaSession }` (no password).
198+
- **Manual / live (not CI; needs a real account).** non-2FA login; 2FA login; reload reuses session
199+
(DevTools: no `us` login request, `mega_session` present, no `mega_password`); worker download
200+
issues **no** `link`/`unshare` requests; worker upload `postMessage` carries **no** password;
201+
revoke session in MEGA settings → reconnect prompt appears.
202+
203+
## Build-time verification items (confirm via tests/spikes during implementation)
204+
205+
1. `Storage.fromJSON` + `storage.reload()` signature, and that `reload()` refreshes `storage.files`
206+
on a `fromJSON`'d session (drives §B restore + §C `reinitialize`).
207+
2. **Linchpin:** Option-B owned-node download (`new File({ downloadId: nodeId, key, api })` +
208+
`file.nodeId = nodeId` + `api.sid`) actually downloads an owned file. Source analysis says yes;
209+
confirm live in Phase 2. _Fallback if it fails:_ download worker uses `fromJSON(session)` +
210+
`reload()` and `storage.files[nodeId].downloadBuffer()` (accepts the master key in the download
211+
worker — still no share link).
212+
3. Upload via `fromJSON + reload` reaches the target folder correctly (§G).
213+
4. Browser megajs build exports `Storage.fromJSON` / `File` for worker use (research says yes;
214+
`mega-core` already imports both).
215+
216+
## Phasing
217+
218+
- **Phase 1 (auth + upload worker):** §A–E **and §G** — token storage, 2FA two-step UI,
219+
needs-attention/session-loss UX, migration, detection/logout hygiene, **and the upload-worker
220+
switch from password to session blob** (forced because §A removes the password the upload worker
221+
reads). Download workers continue using share links unchanged (the main-thread session restored via
222+
`fromJSON` still mints them). Self-contained and shippable. One reviewable PR.
223+
- Build-time verifications needed here: #1 (`fromJSON`/`reload`), #3 (upload via `fromJSON+reload`),
224+
#4 (browser build exports).
225+
- **Phase 2 (download workers):** §F — replace share-link downloads with `sid + nodeId + fileKey`,
226+
delete the entire share-link machinery (`createShareLink`/`deleteShareLink`/mutex/throttle/
227+
`megaShareUrl`), unit tests + live verification. One reviewable PR.
228+
- Build-time verification needed here: #2 (the owned-node-download linchpin).
229+
230+
## Out of scope
231+
232+
- Encrypting the stored session blob at rest.
233+
- Re-architecting megajs's upload key-wrapping to keep the master key out of the upload worker.
234+
- Changes to other providers (Google Drive, WebDAV) beyond shared interface documentation.

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/lib/components/VolumeItem.svelte

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -345,10 +345,10 @@
345345
deleteVolumeStats(volume.volume_uuid);
346346
}
347347
348-
// Delete from cloud if checkbox checked
348+
// Delete from cloud if checkbox checked (archive + sidecars)
349349
if (deleteCloud && hasCloudBackup && cloudFile) {
350350
try {
351-
await unifiedCloudManager.deleteFile(cloudFile);
351+
await unifiedCloudManager.deleteManagedVolume(volume.series_title, volume.volume_title);
352352
showSnackbar(`Deleted from ${providerDisplayName}`);
353353
} catch (error) {
354354
console.error('Failed to delete from cloud:', error);
@@ -468,9 +468,12 @@
468468
469469
// If already backed up, delete from cloud
470470
if (isBackedUp && cloudFile) {
471+
// Capture provider before the await: cloudFile is a $derived that becomes
472+
// undefined once the delete refreshes the cache.
473+
const providerType = cloudFile.provider;
471474
try {
472-
await unifiedCloudManager.deleteFile(cloudFile);
473-
const providerName = cloudFile.provider === 'google-drive' ? 'Drive' : cloudFile.provider;
475+
await unifiedCloudManager.deleteManagedVolume(volume.series_title, volume.volume_title);
476+
const providerName = providerType === 'google-drive' ? 'Drive' : providerType;
474477
showSnackbar(`Deleted from ${providerName}`);
475478
} catch (error) {
476479
console.error('Delete failed:', error);

0 commit comments

Comments
 (0)