|
| 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. |
0 commit comments