Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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
OriginalHashon rename/move (protontest/mutate.go:527,574), a staleCurrentRevisionIDon 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.
apiCallresends PUT rename/move after a 5xx or network error (the recorded Task 9 deferral). The first request has already landed, so the resend fails itsOriginalHashcheck. Reproduced (scratch testTestReviewRenameResendFatal): inject 502 AfterHandler once onPUT .../rename. The rename lands on the server (new.txtpresent), butc.renamereturnsOriginalHash doesn't match (Code=2000), andtranslateError(err, opMove)givesIsFatalError == truewith "upgrade rclone". Arclone movetherefore aborts the whole sync over a rename that succeeded. A second trigger: another client uploads a new revision betweenactiveNodeandcreateRevisioninObject.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 badx-pm-appversionwith 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-311then:231). - The refresh HTTP call uses the first waiter's ctx (
session.go:322). serverTimecan 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). publicKeyswaiters get the leader's ctx error (account.go:288).GET /core/v4/usersis fetched inloginSRPand again inloadAccount.
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 whileModTimecarries 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 theFs. This is style only: maintainers usually prefer theFsas 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.
--dumpbehaviour is as documented indocs/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.
Savewrites access before refresh, so a cross-process reader that sees a partial write adopts only after the full set lands. Logout holdsrefreshMuso 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-appversionmatches the fake's grammar for releases, betas (build metadata) and dev builds (alpha).unlockMuis the only package-level mutable state. protondrive.go holds no crypto or routes.