Files
rclone/docs/plans/001-protondrive-tier1-workspace/final-review-auth.md
T
2026-11-12 15:12:51 +01:00

11 KiB

Final review: authentication, sessions, rclone-facing layer

Reviewer area: session.go, account.go, protondrive.go, api/types.go (as used by these). HEAD 29ad26944.

Counts: Critical 0, Important 2, Minor 11.

Scratch proof tests (worktree since removed) are saved at /tmp/claude-1000/-home-user-rclone/8cc17642-4f33-4045-ad2c-177bf869b82a/scratchpad/zz_review_auth_test.go.txt.

Important

I-1. Error-code table: generic code 2000 is fatal ("upgrade rclone"), and the real bad-app-version code 5003 is not mapped

protondrive.go:396-397, api/types.go:17,37-39.

translateError maps CodeInvalidRequirements (2000) and CodeAppVersionBad (2028) to fserrors.FatalError("this version of rclone is no longer accepted by Proton: upgrade rclone"). The spec (6.6) asks for this, so this re-opens a recorded ruling. The reason is a defect the ruling didn't foresee:

  • 2000 is Proton's generic "requirements not met" code. The fake, which models the SDK, returns it for a stale OriginalHash on rename/move (protontest/mutate.go:527,574), a stale CurrentRevisionID on revision creation (protontest/upload.go:365), a folder moved into itself, and commit validation (upload.go:562). Each of these then aborts the whole rclone run with a false "upgrade rclone" message.
  • A realistic trigger is a lost response. apiCall resends PUT rename/move after a 5xx or network error (the recorded Task 9 deferral). The first request has already landed, so the resend fails its OriginalHash check. Reproduced (scratch test TestReviewRenameResendFatal): inject 502 AfterHandler once on PUT .../rename. The rename lands on the server (new.txt present), but c.rename returns OriginalHash doesn't match (Code=2000), and translateError(err, opMove) gives IsFatalError == true with "upgrade rclone". A rclone move therefore aborts the whole sync over a rename that succeeded. A second trigger: another client uploads a new revision between activeNode and createRevision in Object.Update.
  • 2028, by our own comment in api/types.go:37-38, covers "a feature is disabled, rate-limited or blocked for abuse". A rate-limited feature should not give a fatal "upgrade rclone".
  • The code Proton actually returns for a rejected app version is 5003 (AppVersionBadCode), and 5001 for a missing one. That is Proton's own go-proton-api v1.0.4, response.go:24-25, still in the module cache. Neither is mapped, so a blocked rclone version gives a plain retried error on every call, without the upgrade hint the spec intends. The fake answers a bad x-pm-appversion with 2064 (CodeInvalidSection, protontest/server.go:319), which the client doesn't map either.

Fix: map 5001/5003 (and arguably 2064) to the fatal upgrade error. Drop 2000 from the fatal list, leaving it a plain (non-fatal) error, or NoRetryError. Reconsider 2028: NoRetry rather than Fatal, with Proton's message kept. Update spec 6.6 and the TestTranslateError code list, and the AppVersionRejected regression test (task-15) to use 5003.

I-2. Spec 6.6: x-pm-drive-requirements response headers are never logged

Spec docs/specs/001-protondrive-tier1.md:363: "The x-pm-drive-requirements response headers are logged at debug level so a pending server-side enforcement is visible in bug reports." Nothing in backend/protondrive reads the header (grep: no match outside the spec). It is not in docs/TODO.md, DECISIONS.md or the handoff rulings.

Failure scenario: Proton announces an upcoming requirement, such as a minimum client version or crypto, through the header. Bug reports with -vv don't show it, which is exactly what the spec item exists to prevent.

Fix: in session.do (session.go:253-255, next to observeDate), log resp.Header.Values("x-pm-drive-requirements") at debug level when present, ideally once per distinct value. The alternative is to record a ruling dropping it.

Minor

M-1. A 5xx or 429 on the token refresh is never retried

session.go:214-216. call returns the original 401 resp together with the refresh error. shouldRetry (nodes.go:256) then takes status 401 from resp and falls through to fserrors.ShouldRetry on an *api.Error, which is false. Reproduced (TestReviewRefresh503NotRetried): expire the access token and inject one 503 on /auth/v4/refresh. about fails after 1 refresh request. A 429 with Retry-After on refresh is ignored the same way. Every operation in flight at token expiry can fail once (EIO on a mount). Fix: return nil, refreshErr so shouldRetry reads the status from the wrapped api.Error (5xx gets retried, 429 gets the default Retry-After).

M-2. A refresh that answers 408 permanently kills the session in-process

session.go:331. Every 4xx except 429 counts as "refresh token rejected" (deadRefresh set, errSessionExpired, fatal), and that includes 408 Request Timeout, a transient status that shouldRetry itself retries elsewhere. The SDK precedent covers rejections, not timeouts. Fix: exclude 408 as well.

M-3. Two Fs in the same process with an env-configured remote can't see each other's refresh

protondrive.go:278-339, fs/configmap.go:90-94. For a remote defined only by RCLONE_CONFIG_<NAME>_*, Save writes nowhere: the config file has no section. Each configStore falls back to its own saved, so a second Fs (for example rclone sync remote:a remote:b) still reads the spent env refresh token. It POSTs that token, is rejected, waits 3 s, and fails fatally with "session expired". docs/TODO.md records this for two processes only, and the same-process case is worse than recorded. Fix: document it, or share a per-remote-name in-memory overlay. That overlay would be package state, which needs a ruling, so documenting is likely the answer.

M-4. Re-sign-in prompt defaults to "no", including when the user follows our own reconnect hint

protondrive.go:581-589 (ruling 17). An expired session's error says run "rclone config reconnect remote:". Credentials are never removed from the store, so reconnect asks "Already signed in - sign in again?" with default No. The statement is false (the session is dead), and pressing Enter does nothing and exits successfully. Fix: reword the prompt ("A session is saved - sign in again?"). Optionally default to yes, as oauthutil's "Token already configured - replace it?" does. That would need the ruling revisited.

M-5. After a CAPTCHA in Config, the method question defaults back to password

protondrive.go:630-631. ConfigError("method", ...) returns to method, whose default is !hasPassword, which is false. Pressing Enter goes through password sign-in again and hits the CAPTCHA again. Fix: go straight to browser, or make the default true after a CAPTCHA.

M-6. Wrong key password on a saved session: misleading message, no reconnect hint (deferred Task 14, still present)

account.go:123 says "no user key unlocks, is the password right?". This is returned from connect unwrapped, without s.reconnect. A browser-signed-in remote has no password, and the fix is to reconnect (for example after a password change re-keys the salts). Fix: wrap with the reconnect hint in connect when loadAccount fails to unlock.

M-7. Refresh not proven against the real server; request differs from go-proton-api

api/types.go:73-77, session.go:322-326. The body has no UID and no RedirectURI, both of which go-proton-api sends (manager_auth_types.go:93-100). Handoff section 6 lists no live refresh, and a broken refresh would break every remote after one access-token lifetime. Fix: add "token refresh after expiry" to the live checks in docs/TODO.md. If it fails live, add RedirectURI.

M-8. Deferred Task 2/3/4 session minors, re-checked, all still present and minor

  • An adopted stored token whose access token is stale fails that call once with a plain 401 (session.go:308-311 then :231).
  • The refresh HTTP call uses the first waiter's ctx (session.go:322).
  • serverTime can step back with out-of-order responses (session.go:280-289).
  • The half-signed-in cleanup DELETE uses the caller's possibly cancelled ctx (session.go:462-467). An empty SRP salt is not rejected up front (session.go:430).
  • publicKeys waiters get the leader's ctx error (account.go:288). GET /core/v4/users is fetched in loginSRP and again in loadAccount.

M-9. Own-address public key ring rebuilt on every call

account.go:278-280. publicKeyRing copies every address key (ToPublic) on each call. decryptNode calls it 2-3 times per node through verifyKeys (nodes.go:494,500,573), so large listings pay thousands of key copies. Fix: build each address's public ring once in loadAccount.

M-10. Server-side Copy onto an existing destination always falls back to download and re-upload

protondrive.go:916-918. copyNode refuses an existing name (errExists maps to fs.ErrorCantCopy). protondrive has no PartialUploads, so rclone copies in place to the existing name. Every server-side copy over a changed file then streams the whole file through the client. It is correct but costly. Fix (later): on errExists, copy to a temporary name and move over, or upload a new revision from the source node. At minimum, record it in docs/TODO.md.

M-11. Known items re-checked, still minor and recorded

  • Precision() is 1 s while ModTime carries milliseconds (handoff 8).
  • Cross-account Move/Copy/DirMove are not refused (handoff 8). The rclone name check makes it practically unreachable.
  • The on-the-fly remote hint names the bare backend.
  • fs.Debugf(nil, "protondrive: ...") is used throughout instead of passing the Fs. This is style only: maintainers usually prefer the Fs as the log object, and it is worth a sweep before any upstream PR.

Checked and found sound

  • Secrets: the password is never sent (SRP) or logged. The key password, tokens and fork key are never in log lines or error strings. The fork URL holding the AES key is printed, not logged. --dump behaviour is as documented in docs/TODO.md.
  • Refresh: single-flight per session. It re-reads the store first, and after a rejection it polls up to 3 s for a token rotated elsewhere. It never wipes the store. Save writes access before refresh, so a cross-process reader that sees a partial write adopts only after the full set lands. Logout holds refreshMu so an in-flight refresh can't resurrect the session.
  • Config: browser is the default only without username+password (ruling 14). The third-party notice is shown. The old session is signed out only after the new sign-in succeeds (ruling 17). The 2FA prompt loops through password_2fa. otp codes use the server clock.
  • Disconnect/Shutdown match rulings 15 and 17. A call racing Disconnect gets errNotSignedIn (session.go:226-229), so the Task 14 deferral is closed.
  • Error-mapping contract: DirNotFound vs ObjectNotFound by op; Mkdir exists is nil; Purge refuses the root by path and by ID; Rmdir asks the server uncached (ruling 16); Remove of a gone file is nil; SHA1-only Hashes.
  • x-pm-appversion matches the fake's grammar for releases, betas (build metadata) and dev builds (alpha).
  • unlockMu is the only package-level mutable state. protondrive.go holds no crypto or routes.