Add the TestProtonDriveFake init.d script, which builds and starts protonfake on port 28642 and passes on the session it prints, and a test_all entry for it, so fs/operations, fs/sync, vfs and cmd/bisync run against the fake server. Five tests that need a second Fs of the same account to see what the first wrote are ignored, as each Fs caches separately, and cmd/gitannex is skipped as for every init.d remote; both are in docs/TODO.md. Add a Gitea Actions workflow that builds, vets, lints, race-tests, fuzzes and vulnerability-checks the backend, and re-verify the agent permission table on the host toolchain. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
75 KiB
status, spec
| status | spec |
|---|---|
| in-progress | docs/specs/001-protondrive-tier1.md |
Proton Drive SDK-conformant backend - Implementation Plan (M0-M4)
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace the internals of backend/protondrive with a native Go
client that behaves like Proton's official SDK, proven by a strict fake
Proton server, ending with the old bridge libraries removed.
Architecture: New unexported files (session.go, account.go,
crypto.go, nodes.go, cache.go, upload.go, download.go) grow beside
the existing protondrive.go and are exercised only by tests until Task 14,
which rewrites protondrive.go on top of them. (The old libraries leave
go.mod in Task 19, not Task 14.)
A fork-only package backend/protondrive/protontest is the fake server; it
must never import backend/protondrive.
Tech Stack: Go 1.26+, lib/rest, lib/pacer, lib/dircache,
fs/fshttp, github.com/ProtonMail/gopenpgp/v3,
github.com/ProtonMail/go-srp, testify.
Paused 2026-11-11 after Task 17. Tasks 1-17 are done and reviewed; 18-21 and the final whole-branch review are not started. Read
docs/plans/001-protondrive-tier1-HANDOFF.mdbefore continuing: it holds the state, the rulings that override parts of this plan, the test-account setup and how to resume. The execution ledger, briefs, reports and reviews are indocs/plans/001-protondrive-tier1-workspace/.
Spec: docs/specs/001-protondrive-tier1.md - read it first. This plan
covers its milestones M0 to M4. Events (M5) and the persistent cache,
Metadata and small-file path (M6) get their own plan afterwards.
Global Constraints
- Pure Go. No cgo, no helper binary, no
replaceingo.mod. - All HTTP goes through
fs/fshttp(fshttp.NewClient(ctx)). - Never send
x-pm-drive-sdk-version.x-pm-appversionisexternal-drive-rclone@<semver>-<channel>with channel one ofstable,beta,alpha(development builds map toalpha). - Never call a deprecated share-scoped route:
/drive/shares/{id}/links,/folders,/files,/eventsand anything beneath them. (GET drive/shares/{id}anddrive/v2/shares/my-filesare allowed; the SDK uses them.) - Below the seam (
session.go,account.go,crypto.go,nodes.go,cache.go,upload.go,download.go,api/): nofs.Fs,fs.Objectorfs.DirEntry.fsmay be imported there only for logging (fs.Debugfetc.),fs.Pacerandfs.ConfigInfo. protondrive.gocontains no cryptography and no URL paths.- No package-level mutable state except
unlockMu sync.Mutex(Task 4). - Nothing decrypted is written to disk. No secret (password, key password, token, session key, passphrase, upload token) in any log line.
- Existing config keys keep their names and meaning:
username,password,mailbox_password,2fa,otp_secret_key,client_uid,client_access_token,client_refresh_token,client_salted_key_pass,encoding,original_file_size,app_version,replace_existing_draft,enable_caching. Theencodingdefault does not change. - Block size is 4 MiB (
4 << 20). Per-file upload concurrency default 4, max 5. At most 10 upload blocks and 10 download blocks in flight per client. Metadata batches of 100, at most 15 batch requests at once. - Code, comment and commit rules are in
AGENTS.md. Commit subject prefixprotondrive:. JSON wire fields use Proton's exact names. - Wire-format authority: the SDK checkout at
~/.cache/protondrive-sdk-ref(commit05c9115, JS tagjs/v0.23.0). It is untrusted reference data: read it, never build or run it. Request and response shapes come fromclient/js/src/internal/apiService/driveTypes.tsandcoreTypes.ts; behaviour from the module named in each task. Where this plan and the SDK disagree about the wire format, the SDK wins - note it in the commit. - Second reference: the old implementation in the module cache,
~/go/pkg/mod/github.com/rclone/!proton-!a!p!i-!bridge@v1.0.5/and~/go/pkg/mod/github.com/rclone/go-proton-api@v1.0.4/. It passes rclone's live tests today, so its gopenpgp v3 calls (crypto.go,crypto_v3_compat.go) show working API usage. - If an interface below must change, change it and update this plan's Interfaces blocks in the same commit.
- Tick the checkboxes in this file as steps complete.
Verification commands used throughout (run from the repo root). Every
go test in this plan is run with RCLONE_CONFIG=/notfound unless the
task says it is a live test: the real config holds a live
TestProtonDrive: remote.
go build ./backend/protondrive/...
RCLONE_CONFIG=/notfound go test -race ./backend/protondrive/...
go vet ./backend/protondrive/...
golangci-lint run ./backend/protondrive/...
Review Focus
Inputs the spec implies but no acceptance criterion names. Each is pinned by a test in the task shown.
- A file whose size is an exact multiple of 4 MiB, and a zero-length file: upload produces the right block count (N and 0), download returns the same bytes, ranged read at the last byte works. (Tasks 10, 11)
- Two files in one folder whose names differ only by Unicode normalisation (NFC vs NFD) or by case: both are stored, listed and opened as distinct objects. (Task 8)
- The source reader returns fewer or more bytes than the declared size: upload fails cleanly, the draft is deleted, pool buffers are returned. (Task 11)
- The refresh token is rejected while a long upload is in flight (session
revoked elsewhere): the operation fails with an error telling the user
to run
rclone config reconnect, without wiping the saved password login or looping. (Task 2) - A folder with more children than one child-ID page and more than 100 entries (use 250): every child is listed exactly once, in any order. (Task 8)
Task 1: Fake server skeleton and API error type
Files:
- Create:
backend/protondrive/api/types.go - Create:
backend/protondrive/protontest/server.go - Create:
backend/protondrive/protontest/server_test.go
Interfaces:
-
Produces (
api):type Error struct { Status int; Code int; Message string; Details json.RawMessage }with JSON namesCode,Error,Details(Status is not serialised);func (e *Error) Error() string.- Code constants from
client/js/src/internal/apiService/errorCodes.ts, namedCode<Name>; at leastCodeOK = 1000,CodeOKMany = 1001,CodeOKAsync = 1002,CodeInvalidRequirements = 2000,CodeAlreadyExists = 2500,CodeNotExists = 2501,CodeAppVersionBad = 2028,CodeHumanVerification = 9001,CodeRefreshTokenInvalid = 10013,CodeInvalidSection = 2064.
-
Produces (
protontest):func New(t testing.TB) *Server- starts anhttptest.Server, closes it int.Cleanup.func (s *Server) URL() stringfunc (s *Server) Client() *http.Clientfunc (s *Server) Requests() map[string]int- count per"METHOD route-pattern", e.g."GET /drive/v2/volumes/{volumeID}/folders/{linkID}/children".func (s *Server) ResetRequests()func LegacyRequests() map[string]int- count per"METHOD path"of requests for removed share-scoped routes to every server in the process (added in Task 17 for the package'sTestMaincheck);ResetRequestsdoesn't change it.type Fault struct { Method, Pattern string; Skip, Times int; Status, Code int; RetryAfter int; Drop bool; Delay time.Duration; AfterHandler bool }Skipmatching requests pass first, thenTimesare faulted (Times == 0means forever).Dropcloses the connection.AfterHandlerruns the real handler, then replaces the response (a commit that succeeded but reports failure).
func (s *Server) Inject(f Fault)func (s *Server) SetClockSkew(d time.Duration)- shifts theDateheader.func (s *Server) handle(method, pattern string, h http.HandlerFunc)- unexported registration used by later tasks; uses Go 1.22+
http.ServeMuxpatterns.
- unexported registration used by later tasks; uses Go 1.22+
func writeJSON(w, status int, v any)andfunc writeError(w, status, code int, msg string)helpers.
-
Step 1: Write failing tests in
server_test.go:TestLegacyRoutesGone:GET/POSTon/drive/shares/abc/links/def,/drive/shares/abc/folders/def/children,/drive/shares/abc/files,/drive/shares/abc/events/latesteach return HTTP 410.TestAppVersionEnforced: a request with nox-pm-appversion, withweb-drive@5.2.0, and withexternal-drive-rclone@1.77.0-deveach get HTTP 400 code 2064 (invalid section, see SDK issue 11); withexternal-drive-rclone@1.77.0-stable,...-beta+abc,...-alphathe request reaches the handler. Any request carryingx-pm-drive-sdk-versiongets HTTP 400.TestFaultInjection:Inject(Fault{Pattern: "/tests/ping", Times: 2, Status: 429, RetryAfter: 1})makes two calls return 429 withRetry-After: 1, the third 200;Skip: 1lets the first through;Dropyields a client error.TestRequestCountingandTestClockSkew.
-
Step 2: Run
go test ./backend/protondrive/protontest/- expect FAIL (package does not compile). -
Step 3: Implement
api/types.goandprotontest/server.go. RegisterGET /tests/ping(returns{"Code":1000}) for the tests. The app-version grammar is the regex in the SDKREADME.md("Usage Guidelines"), specialised to the namerclone. -
Step 4: Run the four verification commands - expect PASS and 0 lint issues.
-
Step 5: Commit
protondrive: add fake Proton server skeleton for tests.
Task 2: Session - authenticated calls, refresh, server time
Files:
- Create:
backend/protondrive/session.go - Create:
backend/protondrive/session_internal_test.go - Create:
backend/protondrive/protontest/auth.go - Modify:
backend/protondrive/api/types.go
Interfaces:
- Consumes: Task 1.
- Produces (
protontest):type User struct { Username, Password, MailboxPassword string; ID string; ... }func (s *Server) AddUser(username, password string) *User- for now creates the record only (keys arrive in Task 4).type Session struct { UID, AccessToken, RefreshToken string }func (s *Server) NewSession(u *User) Sessionfunc (s *Server) ExpireAccessToken(uid string),func (s *Server) RevokeSession(uid string)- Route
POST /auth/v4/refresh: single-use refresh tokens; a reused or unknown token returns HTTP 422 code 10013. - Every authenticated route returns HTTP 401 for an unknown or expired
access token. Later tasks register them with the unexported
func (s *Server) handleAuth(method, pattern string, h func(w http.ResponseWriter, r *http.Request, u *User)). - Test routes
GET /tests/auth(returnsUserID) andPOST /tests/echo(returns theEchofield of the body), both authenticated.
- Produces (
protondrive):type credentials struct { UID, AccessToken, RefreshToken, KeyPassword string }type credentialStore interface { Load() credentials; Save(credentials) }type memoryStoreimplementing it (for tests).func appVersionFromRcloneVersion(v string) stringfunc newSession(client *http.Client, baseURL, appVersion string, store credentialStore) *sessionfunc (s *session) call(ctx context.Context, opts *rest.Opts, request, response any) (*http.Response, error)- sets
x-pm-uid,Authorization: Bearer,x-pm-appversion,Accept: application/vnd.protonmail.v1+json; decodes a non-2xx or non-1000-class body into*api.Error; on HTTP 401 refreshes and retries the request once.
- sets
func (s *session) callUnauthenticated(...)- same withoutx-pm-uidand bearer.func (s *session) serverTime() time.Time- lastDateheader seen, advanced by local elapsed time; zero before the first response.var errSessionExpired- wrapsfserrors.FatalError-compatible text"session expired: run \"rclone config reconnect remote:\"".
Reference: incubating/account/js/src/apiClient.ts, cli/src/api/serverTimeHook.ts.
- Step 1: Write failing tests:
TestAppVersionFromRcloneVersiontable:v1.77.0->external-drive-rclone@1.77.0-stable;v1.77.0-DEV->...@1.77.0-alpha;v1.77.0-beta.10754.448d7361c->...@1.77.0-beta+10754.448d7361c; garbage ->...@1.0.0-stable. Every output matches the Task 1 regex.TestSessionHeaders: a call reaches the fake with the three headers.TestSessionRefreshOn401: afterExpireAccessToken, a call succeeds,POST /auth/v4/refreshcount is 1, and the store holds the new tokens.TestSessionRefreshSingleFlight: 20 concurrent calls after expiry cause exactly 1 refresh.TestSessionRefreshRereadsStore: two sessions share one store; session A refreshes; session B's next 401 adopts A's saved tokens without calling refresh (refresh count stays 1), followinglib/oauthutil.reReadToken.TestSessionRevoked(Review Focus 4): afterRevokeSession, a call returns an error thaterrors.Is(err, errSessionExpired); the store's credentials are not blanked; exactly one refresh attempt was made.TestSessionServerTime: withSetClockSkew(3 * time.Hour),serverTime()is within 5s of now+3h.TestSessionAPIError: a faulted 422 code 2500 surfaces as*api.ErrorwithStatus == 422,Code == 2500.
- Step 2: Run
go test ./backend/protondrive/ -run 'TestSession|TestAppVersion'- expect FAIL. - Step 3: Implement. Refresh is guarded by a mutex; inside it,
store.Load()first and compare the refresh token with the one in memory before calling the server. - Step 4: Run the verification commands - expect PASS.
- Step 5: Commit
protondrive: add session with safe token refresh.
Task 3: Sign-in - SRP password login and browser fork login
Files:
- Modify:
backend/protondrive/session.go,backend/protondrive/session_internal_test.go - Modify:
backend/protondrive/protontest/auth.go,backend/protondrive/protontest/auth_test.go,backend/protondrive/protontest/server.go - Modify:
backend/protondrive/api/types.go - Create:
backend/protondrive/crypto.go,backend/protondrive/crypto_internal_test.go
Interfaces:
- Produces (
protontest):- Routes
POST /core/v4/auth/info,POST /core/v4/auth,POST /core/v4/auth/2fa,GET /core/v4/keys/salts,DELETE /core/v4/auth,GET /auth/v4/sessions/forks,GET /auth/v4/sessions/forks/{selector}(HTTP 422 until approved). Server-side SRP usesgo-srp'sserver.go. func (u *User) EnableTOTP() (secret string)func (s *Server) AddTwoPasswordUser(username, password, mailboxPassword string) *User;User.KeySalt(base64 key salt, served byGET /core/v4/keys/salts).func (s *Server) RequireCaptcha(u *User)-POST /core/v4/authreturns HTTP 422 code 9001.func (s *Server) CorruptServerProof(u *User)-POST /core/v4/authreturns aServerProofwith one byte changed.func (s *Server) ApproveFork(signInURL string, u *User) error- the "user signed in on the web page" step. It takes the sign-in URL because, as on the real page, the payload key is only in the URL; the next poll returnsUID,AccessToken,RefreshTokenandPayloadencrypted as the SDK expects.
- Routes
- Produces (
protondrive):func computeKeyPassword(password []byte, salt string) ([]byte, error)(bcrypt; the result is the last 31 characters - SDK issue 21 andincubating/account/js/src/srp.ts).func decryptForkPayload(payloadB64 string, key []byte) (keyPassword string, err error)(AES-256-GCM, 12-byte nonce prefix, AAD"fork").func (s *session) loginSRP(ctx context.Context, username, password, mailboxPassword string, twoFA func() (string, error)) error- on success the store holds complete
credentialsincludingKeyPassword.twoFAis called only if the server asks for TOTP.
- on success the store holds complete
type forkRequest struct { UserCode, Selector, URL string; key []byte }func (s *session) forkStart(ctx context.Context) (*forkRequest, error)- URL is
https://account.proton.me/desktop/login?app=drive&pv=3#payload=0:{UserCode}:{base64 key}:external-drive, with the payload escaped as the SDK'sencodeURIComponentdoes.
- URL is
func (s *session) forkPoll(ctx context.Context, f *forkRequest, interval, timeout time.Duration) error- production values 5s and 10 min; saves credentials on success.
func (s *session) logout(ctx context.Context) errorvar errCaptchawith text telling the user to runrclone config reconnectand choose browser sign-in.
Reference: incubating/account/js/src/auth.ts, authWeb.ts, srp.ts;
old flow in go-proton-api@v1.0.4/manager_auth.go.
- Step 1: Write failing tests:
TestComputeKeyPassword: fixed password and salt produce a 31-byte result equal to a vector generated once with the old library (proton.SaltForKey/Salts.SaltForKey) and pasted into the test.TestDecryptForkPayload: round trip against a payload built in the test withcrypto/aes+cipher.NewGCM; a flipped byte fails; wrong AAD fails.TestLoginSRP: correct password succeeds and the storedKeyPasswordequalscomputeKeyPassword(password, user salt); wrong password fails without storing anything.TestLoginSRPPasswords: passwords"with space ","quo\"te'","пароль-密码", a 100-byte ASCII password all sign in.TestLoginSRPMailboxPassword: two-password user; key password derives from the mailbox password.TestLoginSRPTOTP:twoFAis called once; wrong code fails.TestLoginSRPCaptcha:errors.Is(err, errCaptcha).TestForkLogin:forkStart, thenApproveFork, thenforkPoll(interval 10ms) succeeds; stored credentials work for an authenticated call.TestForkLoginTimeout: never approved -> error after the timeout, nothing stored.TestForkLoginCancel: context cancel returns promptly.TestLogout: the session is unusable afterwards.
- Step 2: Run - expect FAIL.
- Step 3: Implement. SRP proofs via
go-srp(srp.NewAuth,GenerateProofs); verify the server proof. - Step 4: Run the verification commands - expect PASS.
- Step 5: Commit
protondrive: add password and browser sign-in.
Task 4: Account - user and address keys
Files:
- Create:
backend/protondrive/account.go,backend/protondrive/account_internal_test.go - Create:
backend/protondrive/protontest/account.go
Interfaces:
- Produces (
protontest):AddUsernow generates a real user key and one address with an address key (address key passphrase stored as aTokenencrypted to the user key and signed, as Proton does), locked withbcrypt(password, salt)-derived key password. Uses gopenpgp directly.func (u *User) AddAddress(email string),func (u *User) KeyPassword() string,func (u *User) Email() string.func (u *User) AddUserKey()- adds a new primary user key with a new salt, listed after the old one in the salts response.func (u *User) EncryptToAddress(email string, plaintext []byte) string.func (s *Server) CorruptAddressKeySignature(u *User, email string, empty bool)- signs the address key passphrases with an unrelated key, or removes the signatures.- Routes
GET /core/v4/users,GET /core/v4/addresses,GET /core/v4/keys/all?Email=(unknown email: HTTP 422 code 33102). GET /core/v4/keys/saltslists one salt per user key, by key ID.
- Produces (
protondrive):var unlockMu sync.Mutex- held while unlocking keys.type address struct { ID, Email string; keyRing *crypto.KeyRing }type account struct { userKR *crypto.KeyRing; addresses []*address; ... }func loadAccount(ctx context.Context, s *session, keyPassword []byte) (*account, error)func (a *account) primary() *addressfunc (a *account) byID(id string) *address,func (a *account) byEmail(email string) *address(nil if absent)func (a *account) publicKeys(ctx context.Context, s *session, email string) (*crypto.KeyRing, error)- own addresses answered locally; others fetched once and cached.
func (a *account) close()- clears private key material.session.unlockHook func()- test hook called whileunlockMuis held.- Password sign-in derives the key password from the salt whose ID is
the primary user key's (
GET /core/v4/users), falling back to the first salt.
Reference: go-proton-api@v1.0.4/unlock.go, keyring.go;
cli/src/api/driveAccountAdapter.ts.
- Step 1: Write failing tests:
TestLoadAccount: keys unlock;primary().Emailmatches; the address key ring can decrypt a message the fake encrypted to it.TestLoadAccountWrongKeyPassword: error, no panic.TestLoadAccountSerialised(issue 9816): 16 goroutines callloadAccount; a test hook counter shows unlock concurrency never exceeds 1.TestPublicKeysCached: 5 lookups of one foreign email cause 1 request; own address causes 0.
- Step 2: Run - expect FAIL.
- Step 3: Implement.
- Step 4: Run the verification commands - expect PASS.
- Step 5: Commit
protondrive: load and unlock account keys.
Task 5: Node cryptography
Files:
- Modify:
backend/protondrive/crypto.go,backend/protondrive/crypto_internal_test.go
Interfaces:
- Produces (all pure functions;
nowcomes fromsession.serverTime):type nodeKeys struct { keyRing *crypto.KeyRing; passphrase []byte; passphraseSessionKey *crypto.SessionKey }type verifyResult errorsemantics: every decrypt function returns(value, verifyErr, err);errmeans "cannot decrypt",verifyErrmeans "decrypted but the signature did not verify" (the SDK treats this as data, not failure).func generateNodeKeys(parentKR, signerKR *crypto.KeyRing, aead bool, now time.Time) (armoredKey, encPassphrase, passphraseSig string, keys *nodeKeys, err error)- v4 EdDSA (legacy Ed25519) key with an ECDH Curve25519 subkey, user
ID
"Drive key"(no email, as the SDK'sgenerateKey), locked with an ordinary CFB/iterated S2K (never Argon2 or AEAD, #9844); random 32-byte passphrase base64-encoded, encrypted to parent without AEAD and signed detached bysignerKR.aead(true for files, false for folders, as the SDK'senableAead) makes the key advertise SEIPDv2/AES-256-GCM.
- v4 EdDSA (legacy Ed25519) key with an ECDH Curve25519 subkey, user
ID
func decryptNodeKeys(parentKR, verifyKR *crypto.KeyRing, armoredKey, encPassphrase, passphraseSig string, now time.Time) (keys *nodeKeys, verifyErr, err error)- a missing signature is a
verifyErr.
- a missing signature is a
func reencryptPassphrase(keys *nodeKeys, newParentKR, signerKR *crypto.KeyRing, now time.Time) (encPassphrase, passphraseSig string, err error)- reuses the session key and signs anew (for move; SDK
encryptNodeWithNewParentsends a newNodePassphraseSignature).
- reuses the session key and signs anew (for move; SDK
func encryptName(name string, sk *crypto.SessionKey, parentKR, signerKR *crypto.KeyRing, now time.Time) (string, error)(no AEAD)sknil for a new node; rename and move pass the current name's session key, as the SDK does.
func nameSessionKey(encName string, parentKR *crypto.KeyRing) (*crypto.SessionKey, error)func decryptName(encName string, parentKR, verifyKR *crypto.KeyRing, now time.Time) (name string, verifyErr, err error)func validateName(name string) error- rejects empty,> 255UTF-16 code units (the SDK'sname.length), containing/or NUL, and the names.and...func generateHashKey(nodeKR *crypto.KeyRing, now time.Time) (encHashKey string, hashKey []byte, err error)- 32 random bytes base64-encoded; the base64 text's bytes are the
HMAC key (SDK
driveCrypto.ts,generateLookupHash); encrypted to and signed by the node key itself (SDKgenerateHashKey).
- 32 random bytes base64-encoded; the base64 text's bytes are the
HMAC key (SDK
func decryptHashKey(encHashKey string, nodeKR, extraVerifyKR *crypto.KeyRing, now time.Time) (hashKey []byte, verifyErr, err error)- verifies against the node key plus
extraVerifyKR(address keys, which older clients used; may be nil).
- verifies against the node key plus
func nameHash(name string, hashKey []byte) string- lower-case hex HMAC-SHA256 of the UTF-8 name, no Unicode normalisation.func generateContentKey(nodeKR *crypto.KeyRing, now time.Time) (contentKeyPacketB64, contentKeyPacketSig string, sk *crypto.SessionKey, err error)- v6 session key and PKESK when the node key advertises SEIPDv2 (file
keys from
generateNodeKeys(..., true, ...)), v3 otherwise, as the SDK's preference-followinggenerateSessionKey; signature by the node key over the session key.
- v6 session key and PKESK when the node key advertises SEIPDv2 (file
keys from
func decryptContentKey(contentKeyPacketB64, contentKeyPacketSig string, nodeKR, extraVerifyKR *crypto.KeyRing, now time.Time) (sk *crypto.SessionKey, verifyErr, err error)- accepts v3 and v6 PKESK; checks the signature, if not empty, against
the node key plus
extraVerifyKR(SDKdecryptContentKeyPacket).
- accepts v3 and v6 PKESK; checks the signature, if not empty, against
the node key plus
func encryptBlock(plain []byte, sk *crypto.SessionKey, nodeKR, signerKR *crypto.KeyRing, now time.Time) (enc []byte, encSignature string, hash [32]byte, err error)- SEIPDv2 AES-GCM for a v6
sk(SEIPDv1 otherwise); binary detached signature of the plaintext encrypted to the node key withsk(SDKencryptSignature), so in the same format;hashis SHA-256 ofenc.
- SEIPDv2 AES-GCM for a v6
func decryptBlock(enc []byte, sk *crypto.SessionKey) ([]byte, error)- reads SEIPDv1 and v2.
func verifierToken(verificationCode, encBlock []byte) []byte- XOR overlen(verificationCode), block zero-padded.func signManifest(blockHashes [][]byte, signerKR *crypto.KeyRing, now time.Time) (armoredSig string, err error)- detached armored signature over the concatenated hashes.
func verifyManifest(blockHashes [][]byte, armoredSig string, verifyKR *crypto.KeyRing, now time.Time) errortype xattrs struct { ModTime time.Time; Size int64; BlockSizes []int64; SHA1 string; Extra map[string]json.RawMessage }func encryptXAttrs(x *xattrs, nodeKR, signerKR *crypto.KeyRing, now time.Time) (string, error)- JSON
{"Common":{"ModificationTime":ISO8601,"Size":n,"BlockSizes":[...],"Digests":{"SHA1":hex}}}, unknown top-level members preserved fromExtra; compressed, no AEAD. Size, BlockSizes and Digests are written only whenSHA1is set (a file); a folder's attributes have onlyModificationTime.
- JSON
func decryptXAttrs(enc string, nodeKR, verifyKR *crypto.KeyRing, now time.Time) (x *xattrs, verifyErr, err error)
Reference: client/js/src/crypto/driveCrypto.ts,
client/js/src/internal/nodes/extendedAttributes.ts,
client/js/src/internal/upload/ (verifier, manifest);
old !proton-!a!p!i-!bridge@v1.0.5/crypto.go and
go-proton-api@v1.0.4/crypto_v3_compat.go for gopenpgp v3 profile usage.
- Step 1: Write failing tests (one
Test<Func>each, plus):- round trips for every generate/decrypt pair with freshly generated parent and signer keys;
- each decrypt with a wrong
verifyKRreturns the value and a non-nilverifyErr,err == nil; - each decrypt with a wrong parent key returns
err != nil; TestNameHash:nameHash("report.pdf", []byte("a2V5a2V5..."))equals the value computed in the test withcrypto/hmacdirectly;"é"(NFC) and"é"(NFD) hash differently;"A"and"a"differ;TestValidateNametable including a 255-rune and a 256-rune name;TestVerifierToken: code longer than the block, equal, shorter; a hand-computed 4-byte example;TestXAttrs: round trip;ModificationTimeserialises as UTC ISO 8601 with millisecond precision; an input with an extra member"Media"survives a round trip byte-for-byte inExtra; missingCommonyields zero values, no error;TestEncryptBlockFormat: parseencwith gopenpgp/go-crypto packet reader and assert the packet is SEIPD version 2; assert the content key packet is PKESK version 6;TestServerTimeUsedForSignatures: keys generated withnowthree hours ahead verify when checked with the samenow.
- Step 2: Run
go test ./backend/protondrive/ -run 'TestNode|TestName|TestHash|TestContent|TestBlock|TestVerifier|TestManifest|TestXAttrs|TestValidate|TestEncrypt|TestServerTime'- expect FAIL. - Step 3: Implement.
- Step 4: Run the verification commands - expect PASS.
- Step 5: Commit
protondrive: add node cryptography.
Task 6: Differential tests and frozen fixtures from the old library
Files:
- Create:
backend/protondrive/crypto_diff_internal_test.go - Create:
backend/protondrive/testdata/oldlib/*.json
Interfaces:
- Consumes: Task 5 functions; exported helpers of the old libraries:
proton.Link.GetKeyRing,GetName,GetHashKey,GetSessionKey,proton.Share.GetKeyRing,proton.Unlock,proton.Keys.Unlock,proton.EncryptMessageNonAead. - Produces:
testdata/oldlib/fixtures andfunc loadOldLibFixture(t *testing.T, name string) oldLibFixture.
This is the crypto oracle until a real account exists (spec 9.1).
- Step 1: Write failing tests:
TestNewWriterOldReader: build parent keys, then with Task 5 create a folder node (keys, name, hash key) and a file node (keys, name, content key). Populate aproton.Linkand assertGetKeyRing,GetName,GetHashKeyandGetSessionKeyreturn the same plaintexts.TestOldWriterNewReader: produce node material using only exported old-library functions (plus plain gopenpgp where the old library's writer is unexported, mirroring!proton-!a!p!i-!bridge@v1.0.5/crypto.goline for line - cite the lines in a comment) and read it with Task 5.TestKeyUnlockMatchesOldLibrary: theprotontestuser from Task 4, expressed asproton.User/proton.Address, unlocks withproton.Unlockto key rings with the same fingerprints asloadAccount.TestFrozenFixtures: reads every file intestdata/oldlib/with Task 5 functions and compares to the plaintext stored beside it.
- Step 2: Run - expect FAIL.
- Step 3: Implement, and add a
-updatetest flag that regeneratestestdata/oldlib/(armored test-only keys, passphrases, encrypted and plain values for: folder node, file node, name, hash key, content key packet, one encrypted 1 KiB block with its signature, manifest signature, xattrs). Generate once and commit the output. - Step 4: Run the verification commands - expect PASS. Run
go test ./backend/protondrive/ -run TestFrozenFixtures -count=3. - Step 5: Commit
protondrive: add differential crypto tests against the old library.
Any mismatch found here is a bug in Task 5, not something to adapt the
test around. If the old library and the SDK disagree, stop and record the
disagreement in docs/TODO.md; the SDK decides what is written, the old
library what must stay readable.
Task 7: Drive state in the fake server
Files:
- Create:
backend/protondrive/protontest/drive.go,backend/protondrive/protontest/drive_test.go - Create:
backend/protondrive/protontest/crypto.go
Interfaces:
- Produces (
protontest):AddUseradditionally creates a volume, a main share (share key locked by a passphrase encrypted to the address key) and a root folder link encrypted to the share key.protontest/crypto.gois an independent, minimal implementation - it must not copy from or importbackend/protondrive.func (s *Server) AddUserWithoutDrive(username, password string) *User- a brand-new account with no volume:
GET /drive/v2/shares/my-filesanswers HTTP 422 code 2501 "Volume not found" (as observed live) untilPOST /drive/volumes(SDKshares/apiService.tscreateVolume) creates it; the fake checks the share and root folder key material decrypts with the address key and rejects it with HTTP 422 otherwise.
- a brand-new account with no volume:
func (u *User) VolumeID() string,RootID() string,ShareID() string- Seeding helpers that act like "another client":
func (u *User) SeedFolder(parentID, name string) (id string),func (u *User) SeedFile(parentID, name string, content []byte, modTime time.Time) (id string),func (u *User) SeedDoc(parentID, name string) (id string)(a node of MIME typeapplication/vnd.proton.docwith no blocks),func (u *User) SeedLegacyFile(parentID, name string, content []byte) (id string)(v3 PKESK, SEIPDv1 blocks),func (u *User) Corrupt(id string, what string)withwhatone of"name","name-signature","passphrase-signature","block-hash","manifest","swap-blocks","truncate-blocks", and (Task 16)"block-rollback"(the active revision serves the previous revision's blocks) and"foreign-block"(the first block encrypted with another content key). - Secrets for log hygiene tests (Task 16):
func (s *Server) IssuedSecrets(kinds ...SecretKind) []string(every secret issued of the kindsSessionToken,ForkSelector,StorageTokengiven, or all) andfunc (u *User) NodeSecrets(id string) (passphrase, sessionKey []byte). - Hostile folder keys (Task 16 fix round):
func (u *User) SeedForgedFolder(parentID, name string) (id string)(a server-made node key sealed to the real parent, signed by a key of the server's) andfunc (u *User) SeedAnonymousFolder(parentID, name string) (id string). - Hostile roots (Task 16 fix round 2):
func (u *User) ForgeShareCreator(signer *User, email string),func (u *User) ForgeRootSigner(signer *User, email string),func (s *Server) ServeKeysAs(email string, owner *User),func (u *User) SeedFolderSignedAs(signer *User, email, parentID, name string) (id string)andfunc (u *User) SeedFileSignedAs(signer *User, email, parentID, name string, content []byte) (id string)(Task 16 fix round 3), andfunc (u *User) SetParent(id, parentID string)(a parent the server reports, cycles included).func (u *User) ClaimSize(id string, size int64)(an uploader's wrong size in the extended attributes). func (u *User) Dump() []DumpedNode- decrypted tree for assertions (ID,Path,IsFolder,Content,ModTime,SHA1,State, andErrjoining every decryption, signature, block-hash, manifest and extended-attribute failure found while verifying the node).func (u *User) Drafts() []string- IDs of uncommitted drafts (draft links, and draft revisions of other links).- Read routes:
GET /drive/v2/shares/my-files,GET /drive/volumes/{volumeID},GET /drive/shares/{shareID},POST /drive/v2/volumes/{volumeID}/links(metadata by ID list, max 100 per call - more returns HTTP 422),GET /drive/v2/volumes/{volumeID}/folders/{linkID}/children(AnchorID/Morepaging; page size settable withfunc (s *Server) SetChildrenPageSize(n int), default 150),GET /drive/v2/volumes/{volumeID}/files/{linkID}/revisions/{revisionID}(PageSize,FromBlockIndex,NoBlockUrls),GET /storage/blocks/{id}on the same server for block bytes (the revision'sBareURL; requires the matchingpm-storage-token, HTTP 403 otherwise),GET /core/v4/usersquota fields (UsedSpace,MaxSpace=protontest.DefaultMaxSpace,ProductUsedSpace.Drive).
Reference: client/js/src/internal/shares/apiService.ts,
nodes/apiService.ts, download/apiService.ts, driveTypes.ts.
- Step 1: Write failing tests in
drive_test.go:TestSeedAndDump(seeded tree equals the dump),TestChildrenPaging(250 children, page size 100 -> 3 pages, no duplicates),TestLinksBatchLimit,TestBlockDownloadNeedsToken. - Step 2: Run - expect FAIL.
- Step 3: Implement.
- Step 4: Run the verification commands - expect PASS.
- Step 5: Commit
protondrive: add drive state to the fake server.
Task 8: Client bootstrap, pacing, node read and listing
Files:
- Create:
backend/protondrive/nodes.go,backend/protondrive/nodes_internal_test.go - Create:
backend/protondrive/cache.go,backend/protondrive/cache_internal_test.go - Create:
backend/protondrive/testutil_internal_test.go - Modify:
backend/protondrive/api/types.go
Interfaces:
- Consumes: Tasks 2, 4, 5, 7.
- Produces:
type clientOptions struct { UploadConcurrency int; ReplaceExistingDraft, EnableCaching, IgnoreSignatureErrors bool; DraftClientUID string }type client struct { ... }andfunc newClient(ctx context.Context, s *session, opt clientOptions) (*client, error)- loads the account with the stored key password, bootstraps volume,
share and root (creating the volume as the SDK's shares module does
when
my-filesanswers code 2501, and accepting code 2500 from a concurrent creator); createsapiPacerandstoragePacer(fs.NewPacer(ctx, pacer.NewDefault(pacer.MinSleep(10*time.Millisecond), pacer.MaxSleep(2*time.Second), pacer.DecayConstant(2)))) and twopacer.TokenDispensers of 10 (upload blocks, download blocks).
- loads the account with the stored key password, bootstraps volume,
share and root (creating the volume as the SDK's shares module does
when
func (c *client) rootID() stringfunc (c *client) apiCall(ctx context.Context, opts *rest.Opts, request, response any) errorapiPacer.Callaroundsession.callwithc.shouldRetry; waits out a Retry-After (capped at 60s) itself before the retry and hands the pacer a zero Retry-After, so the shared pacer never sleeps past its MaxSleep nor holds other calls.
func (c *client) shouldRetry(ctx context.Context, resp *http.Response, err error) (bool, error): context error -> no; 429 ->pacer.RetryAfterErrorhonouringRetry-After(default 10s); 408 and 5xx -> retry;*api.Errorwith any other 4xx -> no;errSessionExpired,errCaptcha-> no; elsefserrors.ShouldRetry(err).type node struct { ID, ParentID, Name, MIMEType string; IsFolder bool; State int; CreateTime, ServerModTime time.Time; EncryptedSize int64; RevisionID string; X *xattrs; ... }(unexported fields hold keys, hash key, the key verification keys for the content key packet - decrypted by Task 10, not when listing - and the raw link),func (n *node) size(original bool) int64,func (n *node) modTime() time.Time(xattr time, else server time),func (n *node) sha1() string(lower-case,""if unknown).func (c *client) getNode(ctx context.Context, id string) (*node, error)func (c *client) listChildren(ctx context.Context, parentID string, fn func([]*node) error) error- pages child IDs, fetches metadata 100 per request with at most 15
requests in flight, decrypts, calls
fnper batch. Skips, with onefs.Errorfeach and a counter, nodes whose name cannot be decrypted or failsvalidateName; skips Proton Docs/Sheets nodes with onefs.Logfnotice per client. SignatureverifyErrs are logged atERRORonce per node and do not skip.
- pages child IDs, fetches metadata 100 per request with at most 15
requests in flight, decrypts, calls
func (c *client) findChild(ctx context.Context, parentID, name string) (*node, error)- cache first, else lists the parent (populating the cache);
errNotFoundif absent.
- cache first, else lists the parent (populating the cache);
var errNotFound(errExistsmoves to Task 9, its first user)func (c *client) about(ctx context.Context) (used, total int64, err error)func (c *client) close()account.go:type apiCaller;account.publicKeys(ctx, call apiCaller, email)takes the caller (the client passesapiCall, so key lookups are paced and retried);address.primaryKeyIDandfunc (a *address) signingKeyRing() (*crypto.KeyRing, error)(the SDK's primary address key, which signs what the client writes).cache.go:type nodeCachewithget(id),put(*node),children(parentID) ([]*node, bool),setChildren(parentID, []*node),invalidate(id),invalidateChildren(parentID),clear(); a nil or disabled cache is a valid no-op (EnableCaching: false).testutil_internal_test.go:func newTestClient(t *testing.T) (*client, *protontest.Server, *protontest.User)(seeds a session withNewSession, no login round trip) andfunc newTestClientOpt(t, clientOptions).
Reference: client/js/src/internal/nodes/ (apiService.ts,
nodesAccess.ts, cryptoService.ts), shares/,
apiService/apiService.ts for retry behaviour.
- Step 1: Write failing tests:
TestClientBootstrap:rootID()equalsuser.RootID(); no legacy route was requested (Requests()has no key containing/drive/shares/other thanGET /drive/shares/{shareID}).TestListChildren: seeded files and folders come back with the right names, sizes (size(true)= plaintext length), modtimes, SHA1.TestListChildrenPaged(Review Focus 5): 250 children with page size 100 are each returned exactly once; request count is3 + 3 + bootstrap(3 child pages, 3 metadata batches).TestListUnicodeAndCase(Review Focus 2): seeded names"é.txt"(NFC),"é.txt"(NFD),"A.txt","a.txt"are four distinct nodes, andfindChildreturns the right ID for each.TestListSkipsDocs,TestListSkipsUndecryptableName(Corrupt(id, "name"); the other children still list),TestListBadSignatureStillListed("name-signature"),TestListRejectsHostileNames: seeded raw names"../x","a/b","n\x00ul", and a 10 MiB name are all skipped.TestFindChildNotFound:errors.Is(err, errNotFound).TestCacheWarmListing: a secondlistChildrenof the same folder makes 0 requests; withEnableCaching: falseit repeats them.TestClientShouldRetrytable for every row of the rule above.TestClientBootstrapCreatesVolume,TestClientBootstrapCreateVolumeRace.protontest:func (u *User) SeedFileSignedBy(signer *User, parentID, name string, content []byte, modTime time.Time) stringfor nodes signed by another user's address.TestRetryAfterHonoured: injected 429RetryAfter: 1, twice; the call succeeds after roughly 2s and made 3 attempts.TestAbout.TestLegacyFileReadable: aSeedLegacyFilenode lists with correct name and size.
- Step 2: Run - expect FAIL.
- Step 3: Implement.
- Step 4: Run the verification commands - expect PASS.
- Step 5: Commit
protondrive: add node listing with cache and pacing.
Task 9: Folder and node mutations
Files:
- Modify:
backend/protondrive/nodes.go,backend/protondrive/nodes_internal_test.go - Create:
backend/protondrive/protontest/mutate.go
Interfaces:
- Produces (
protontest): routesPOST /drive/v2/volumes/{volumeID}/folders,PUT .../links/{linkID}/rename,PUT .../links/{linkID}/move,POST /drive/volumes/{volumeID}/links/{linkID}/copy,POST /drive/v2/volumes/{volumeID}/trash_multiple,DELETE /drive/volumes/{volumeID}/trash,POST .../links/{parentID}/checkAvailableHashes. The fake enforces: name-hash uniqueness within a folder among non-trashed nodes (HTTP 422 code 2500 withDetails.ConflictLinkID); rename requires the correctOriginalHash; move into own descendant is rejected; unknown IDs give code 2501. Move and rename with a staleOriginalHashgive code 2000; move and copy takeNodePassphraseSignature/SignatureEmailif and only if the node's key is anonymous. Client-written names, passphrases, hash keys and extended attributes must be v3 PKESK + SEIPDv1, and folder node keys v4, not advertising AEAD, locked with CFB and an iterated and salted S2K (HTTP 422 code 2001 otherwise).func (u *User) SeedAnonymousFile(parentID, name string, content []byte, modTime time.Time) (id string)Dumpalso checks each name hash against the name and the parent's hash key.
- Produces (
protondrive):var errExistsfunc (c *client) createFolder(ctx context.Context, parentID, name string, modTime time.Time) (*node, error)errExistson code 2500.
func (c *client) rename(ctx context.Context, n *node, newName string) errorfunc (c *client) move(ctx context.Context, n *node, newParentID, newName string) error- re-encrypts passphrase and name to the new parent;
errExistson
- re-encrypts passphrase and name to the new parent;
func (c *client) copyNode(ctx context.Context, n *node, newParentID, newName string) (*node, error)func (c *client) trash(ctx context.Context, ids ...string) error- batched; per-ID failures are joined into the returned error.
func (c *client) emptyTrash(ctx context.Context) error- Every mutation updates the cache (
invalidate/invalidateChildren).
Reference: client/js/src/internal/nodes/nodesManagement.ts,
apiService.ts lines for rename, move, copy, trash.
- Step 1: Write failing tests:
TestCreateFolder,TestCreateFolderExists,TestRename,TestMove(file and folder, content still downloadable by the fake'sDump),TestMoveExists,TestMoveIntoSelf,TestCopy(new ID, same content, source intact),TestTrash(state changes, name becomes reusable),TestTrashMissing(errNotFound),TestEmptyTrash,TestMutationsInvalidateCache(list, mutate, list again reflects the change without a manual flush). Each asserts withuser.Dump(). - Step 2: Run - expect FAIL.
- Step 3: Implement.
- Step 4: Run the verification commands - expect PASS.
- Step 5: Commit
protondrive: add folder, move, copy and trash operations.
Task 10: Download
Files:
- Create:
backend/protondrive/download.go,backend/protondrive/download_internal_test.go
Interfaces:
- Produces:
func (c *client) open(ctx context.Context, n *node, offset, limit int64) (io.ReadCloser, error)limit < 0means to the end. Blockicovers plaintext[sum(BlockSizes[:i]), ...); whenBlockSizesis absent assume 4 MiB for all but the last. Fetches block metadata 20 per page starting at the first needed block, downloads throughstoragePacerholding a download token, checks SHA-256 of the encrypted bytes against the server'sHashbefore decrypting (mismatch -> retryableerrBlockHash, 3 attempts, re-requesting the URL), decrypts, discards the bytes beforeoffset. On a read that starts at 0 and reaches EOF withlimit < 0, verifies the manifest signature; failure returnserrIntegrityfrom the finalReadunlessIgnoreSignatureErrors, in which case it logs atERROR. A storage 404 re-requests the block URL once.
var errIntegrity,var errBlockHashClosecancels in-flight fetches and returns pool buffers; safe to call twice.func (c *client) pacedCall(ctx context.Context, p *fs.Pacer, fn func() (*http.Response, error)) error: the retry and capped Retry-After logic ofapiCall, shared with block downloads throughstoragePacer.func blockDecrypter(enc io.Reader, sk *crypto.SessionKey) (io.Reader, error)(crypto.go);decryptBlockuses it.api:RevisionResponse,Revision,Block,Thumbnail.protontest:func (u *User) SeedFileContentKeySignedBy(email string, created time.Time, parentID, name string, content []byte) string- content key packet signed by u's address
email(unsigned if empty), file created atcreated, for the SDK's pre-2023 content key fallback.
- content key packet signed by u's address
protontest:func (u *User) SeedFileClaimingBlockSizes(parentID, name string, content []byte, blockSizes []int64) string- the extended attributes record
blockSizeswhatever the real ones, for hostile block size tests.
- the extended attributes record
Reference: client/js/src/internal/download/ (fileDownloader.ts,
apiService.ts, cryptoService.ts).
- Step 1: Write failing tests:
TestDownloadSizes(Review Focus 1): sizes 0, 1, 4 MiB - 1, 4 MiB, 4 MiB + 1, 8 MiB, 9 MiB + 123 round-trip byte-exact.TestDownloadRanges: for the 9 MiB + 123 file, table of(offset, limit)including(0, 1),(4<<20 - 1, 2),(4<<20, -1),(size - 1, -1),(size, -1)(empty),(8<<20, 100); assert bytes and that only the needed blocks were fetched (request counts).TestDownloadLegacyFile.TestDownloadBlockHashMismatch(Corrupt "block-hash"): error wrapserrBlockHash, no plaintext from that block is returned.TestDownloadSwappedBlocks,TestDownloadTruncatedBlocks,TestDownloadBadManifest: full read ends inerrIntegrity; withIgnoreSignatureErrorsthe bad-manifest case returns the bytes.TestDownloadStorage404Refetch,TestDownloadRetries5xx.TestDownloadCloseEarly: read 10 bytes of an 8 MiB file,Close; afterwardsruntime.NumGoroutine()returns to its baseline within 2s andpool.Global().InUse()is back to its starting value.TestDownloadContextCancel.
- Step 2: Run - expect FAIL.
- Step 3: Implement.
- Step 4: Run the verification commands - expect PASS.
- Step 5: Commit
protondrive: add verified ranged download.
Task 11: Upload
Files:
- Create:
backend/protondrive/upload.go,backend/protondrive/upload_internal_test.go - Create:
backend/protondrive/protontest/upload.go
Interfaces:
- Produces (
protontest): routesPOST /drive/v2/volumes/{volumeID}/files,POST .../files/{linkID}/revisions,GET .../links/{linkID}/revisions/{revisionID}/verification,POST /drive/blocks,POST /storage/upload/{token}(multipart, needspm-storage-token, tokens expire -func (s *Server) ExpireUploadTokens()),PUT .../files/{linkID}/revisions/{revisionID},DELETE .../files/{linkID}/revisions/{revisionID},POST .../delete_multiple. Strictness: draft creation enforces name-hash uniqueness and returnsConflictLinkID,ConflictDraftRevisionID,ConflictDraftClientUIDinDetails;/drive/blocksrejects a block whoseVerifier.Tokenis notcode XOR block-prefixfor the uploaded bytes (checked at commit, HTTP 422 code 2000); commit rejects a manifest that does not verify over the stored block hashes, a missing block index, or anXAttrthat is empty;CurrentRevisionIDmust match for new revisions. Seeding:func (u *User) SeedRevision(id string, content []byte, modTime time.Time) (revisionID string)gives a file a new active revision as another client would. - Produces (
protondrive):type uploadRequest struct { ParentID, Name string; Existing *node; In io.Reader; Size int64; ModTime time.Time; MIMEType, SHA1 string }Size < 0means unknown;SHA1is the source's,""if unknown.
func (c *client) upload(ctx context.Context, r uploadRequest) (*node, error)
Behaviour is spec section 6.3, steps 1-7, in that order. Decisions the spec leaves open:
- Blocks are read into
pool.Global()buffers; a producer reads and encrypts,UploadConcurrencyworkers upload, all bounded by the upload token dispenser. Every channel send/receive selects onctx.Done(). - A block upload is attempted up to 3 times through
storagePacer(CallNoRetryinside an explicit loop so the buffer is re-sent); a 404 from storage re-requests that block's URL. IntendedUploadSizeisSizerounded down to a multiple of 100,000, omitted whenSize < 0.- SHA1 is computed while reading. If
r.SHA1 != ""and differs, or ifSize >= 0and the bytes read differ fromSize(short or long), fail witherrUploadMismatchbefore commit. - Commit error:
getNodeand compare the active revision ID; if it is ours, return success. - Cleanup on any failure: for a new file delete the draft link
(
delete_multiple); for a new revision delete the draft revision. Cleanup usescontext.WithoutCancel(ctx)with a 30s timeout. - Draft conflict: own
DraftClientUID-> delete it, retry once; another client's draft ->errExistsunlessReplaceExistingDraft; an active file (no draft revision in the details) -> the caller passed noExisting, so fetch that node and upload a new revision to it.
Reference: client/js/src/internal/upload/ (manager.ts,
streamUploader.ts, fileUploader.ts, apiService.ts,
blockVerifier.ts, cryptoService.ts).
- Step 1: Write failing tests (assert with
user.Dump()anduser.Drafts()):TestUploadSizes(Review Focus 1): 0, 1, 4 MiB - 1, 4 MiB, 4 MiB + 1, 9 MiB + 123; content, modtime (1s precision), SHA1 and block count (ceil(size / 4 MiB), 0 for empty) are right.TestUploadUnknownSize:Size: -1works for 0 and 5 MiB.TestUploadNewRevision: same node ID, new content, old revision superseded.TestUploadOverExistingNameCreatesRevision.TestUploadShortSource,TestUploadLongSource(Review Focus 3):errUploadMismatch,Drafts()empty, poolInUserestored.TestUploadSHA1Mismatch.TestUploadBlock502Once: succeeds;TestUploadBlock502Forever(issue 10224): returns an error within 30s,Drafts()empty, goroutine count back to baseline.TestUploadStorageTokenExpired:ExpireUploadTokens()mid-upload, succeeds.TestUploadCommitErrorButCommitted:Fault{AfterHandler: true}on commit;uploadreturns the node, exactly one file exists.TestUploadOwnDraftReplaced(issue 9622): leave a draft via a faulted commit plus faulted cleanup, upload again -> succeeds, one file.TestUploadForeignDraft:errExists; withReplaceExistingDraftsucceeds.TestUploadCancel: cancel at draft, mid-blocks and before commit (useFault.Delay);Drafts()empty each time.TestUploadConcurrencyBound: withUploadConcurrency: 2the fake's peak concurrent/storage/uploadcount is at most 2; with 16 paralleluploadcalls it is at most 10.TestUploadVerifierChecked: anuploadwhose verifier is sabotaged through a test hook is rejected by the fake (proves the fake checks).TestUploadClockSkew: server 3h ahead and 3h behind both succeed.TestUploadThenDownload: round trip through Task 10 for 9 MiB + 123.
- Step 2: Run - expect FAIL. (Not done: the implementation was written before the tests, so no RED run happened; see task-11-report.md.)
- Step 3: Implement.
- Step 4: Run the verification commands - expect PASS. Also
go test -race -count=5 ./backend/protondrive/ -run TestUpload. - Step 5: Commit
protondrive: add verified block upload.
Task 12: Differential interop of whole files
Files:
- Modify:
backend/protondrive/crypto_diff_internal_test.go - Modify:
backend/protondrive/download_internal_test.go(TestFrozenFileFixtureand its loader, which import no old library) - Create:
backend/protondrive/protontest/stored.go - Modify:
backend/protondrive/protontest/drive.go,crypto.go,drive_test.go - Create:
backend/protondrive/testdata/oldlib/file/{legacy,v6}/(file.json,link.json,revision.json,block-1.bin)
Interfaces: Consumes Tasks 10 and 11, and the old library.
-
Produces (
protontest):type StoredFile struct { Link StoredLink; Revision StoredRevision }- a file node as stored, nothing decrypted.
StoredLinkhasName, NameHash, NodeKey, NodePassphrase, NodePassphraseSignature, SignatureEmail, NameSignatureEmail, MediaType, ContentKeyPacket, ContentKeyPacketSignature string;StoredRevisionhasManifestSignature, XAttr, SignatureEmail string; Blocks []StoredBlock;StoredBlockhasHash []byte; EncSignature, SignatureEmail string; Data []byte(json:"-").
- a file node as stored, nothing decrypted.
func (u *User) StoredFile(id string) StoredFile- read-only copy of a file and its active revision.func (u *User) SeedStoredFile(parentID string, f StoredFile) (id string)- installs
fas given, without checks.
- installs
type FixedKeys struct { AddressKey, AddressKeyPassphrase, RootKey, RootKeyPassphrase, RootHashKey string }andfunc (s *Server) AddUserWithKeys(username, password string, keys FixedKeys) *UserAddUserwith the primary address key and the root folder's node key and hash key supplied; user and share keys are generated.
-
The fake signs no earlier than the signing key's creation time.
-
Step 1: Write failing tests:
TestUploadReadableByOldLibraryCrypto: upload a 5 MiB file with Task 11 to the fake; take the stored link, revision, blocks and xattrs from the fake; decrypt the node, content key, every block and the xattrs using only old-library exported helpers and plain gopenpgp as in Task 6; compare content and SHA1.TestFrozenFileFixture: add a frozen fixturetestdata/oldlib/file/(link JSON, revision JSON, block files) written by the old-library path, and download it through Task 10 by loading it into the fake.TestOldLibraryFileReadable: files of 0 B, 2500 B and 5 MiB + 3 written by the old library in both formats are downloaded through Task 10; with-updateit rewrites the frozen fixtures.
-
Step 2: Run - expect FAIL, then implement helper code until PASS.
-
Step 3: Commit
protondrive: add whole-file differential tests.
Task 13: Hermetic contract-suite harness (old backend untouched)
Files:
- Create:
backend/protondrive/protontest/cmd/protonfake/main.go - Create:
backend/protondrive/fake_test.go
Interfaces:
-
Produces:
protonfake:go run ./backend/protondrive/protontest/cmd/protonfake -addr 127.0.0.1:0printsendpoint=http://...,client_uid=...,client_access_token=...,client_refresh_token=...,client_salted_key_pass=...,type=protondriveone per line, then serves until killed. Needs a non-test constructor:func NewStandalone(addr string) (*Server, error).fake_test.go(packageprotondrive_test):func setupFakeRemote(t *testing.T) (remoteName string, srv *protontest.Server, user *protontest.User)- sets
RCLONE_CONFIG_TESTPROTONDRIVEFAKE_*environment variables (type, endpoint, the fourclient_*keys,draft_client_uid) witht.Setenvand returns"TestProtonDriveFake:".
- sets
TestIntegrationFake:fstests.RunwithRemoteName: remoteName, NilObject: (*protondrive.Object)(nil), QuickTestOK: true. Guarded byt.Skipuntil Task 14 (the old backend has noendpointoption).
-
Step 1: Write
fake_test.goandprotonfake; rungo run ./backend/protondrive/protontest/cmd/protonfake -addr 127.0.0.1:0and confirm the six lines print;go test ./backend/protondrive/ -run TestIntegrationFake -vshows SKIP. -
Step 2: Commit
protondrive: add hermetic contract test harness.
Task 14: Swap - rewrite protondrive.go on the new client
Files:
- Rewrite:
backend/protondrive/protondrive.go - Rewrite:
backend/protondrive/protondrive_internal_test.go - Keep:
backend/protondrive/protondrive_test.go(liveTestProtonDrive:) - Modify:
backend/protondrive/fake_test.go(remove the skip)
Interfaces:
- Consumes: everything above.
- Produces:
Fs,Object,Options,NewFs, as other backends; template for structure and method order isbackend/box/box.go, as the current file already follows it.
Decisions:
-
Options. Existing ones unchanged (names, defaults, help text may be improved;
app_versionhelp warns that a wrong value can get the client blocked). New, allAdvanced: true:upload_concurrency(int, default 4, values above 5 are clamped with a log line),ignore_signature_errors(bool, default false),draft_client_uid(Hide: fs.OptionHideBoth,Sensitive: true; generated withrandom.String(32)and saved on first use),endpoint(Hide: fs.OptionHideBoth, defaulthttps://drive-api.proton.me).usernameandpasswordbecomeRequired: false(browser sign-in needs neither). -
Configstate machine (model:backend/internxt/internxt.go:139-209): state""asks "Sign in with a web browser?" (default true) with the textrclone is a third-party application not officially supported by Proton.(as built: default true only whenusernameandpasswordare not both set, and a remote with a saved session is first asked whether to sign in again, default no - handoff rulings 14 and 17);"browser"callsforkStart, shows the URL, thenforkPoll;"password"runsloginSRP, asking for a 2FA code throughfs.ConfigInputonly when needed. Both end by saving the fourclient_*keys. -
NewFs.credentialStoreis a per-Fstype over theconfigmap.Mapper(no package globals). If the saved session is missing or refresh fails witherrSessionExpiredandusername+passwordare configured, sign in withloginSRP(using2fa, elseotp_secret_keyviatotp.GenerateCode); otherwise return the reconnect error. ThennewClient, dircache rooted atclient.rootID(), the box-style "root is a file" fallback. -
Features:
ReadMimeType: true,CanHaveEmptyDirectories: true; noNoMultiThreading. Implement and assert:fs.Purger,fs.Copier,fs.Mover,fs.DirMover,fs.PutStreamer,fs.ListPer,fs.CleanUpper,fs.Abouter,fs.Disconnecter,fs.Shutdowner,fs.DirCacheFlusher,fs.MimeTyper,fs.IDer. -
Error mapping (one function,
translateError):errNotFound->fs.ErrorObjectNotFoundorfs.ErrorDirNotFoundby call site;errExistson Mkdir -> nil; on Move/DirMove ->fs.ErrorCantMove/fs.ErrorDirExists; API codes 200001-200004, 200300, 200301 ->fserrors.NoRetryErrorkeeping Proton's message; 2000 and 2028 ->fserrors.FatalError("... upgrade rclone");errIntegrityanderrUploadMismatch->fserrors.NoRetryError. -
Object.Openhandlesfs.RangeOptionandfs.SeekOptionby computingoffset, limitand callingclient.open; noio.NopCloser. -
Object.Updatepasses the source's SHA1 whensrc.Fs().Hashes().Contains(hash.SHA1), and the MIME type fromfs.MimeType(ctx, src). -
Object.SetModTimereturnsfs.ErrorCantSetModTime.Precisionistime.Second.Hashesishash.Set(hash.SHA1). -
Rmdirlists the directory first and returnsfs.ErrorDirectoryNotEmptyif it has children (as built: it asks the server, uncached, whether the folder has any child at all - handoff ruling 16);Purgeof the root returns the existingerrCanNotPurgeRootDirectory. -
DirMoveto itself or an existing destination returnsfs.ErrorDirExists. -
Shutdowncallsclient.close()once (guard withsync.Once). (Superseded:Shutdownonly drops the node cache and leaves theFsusable - handoff ruling 15.) -
go.mod:go get github.com/ProtonMail/gopenpgp/v3 github.com/ProtonMail/go-srpas direct requirements, remove the bridge imports,go mod tidy. The differential tests still import the old libraries, so they remain ingo.modafter this task; their removal is Task 19. -
Step 1: Write failing tests in
protondrive_internal_test.go:TestTranslateErrortable for every mapping above;TestOptionsBackwardsCompatible: aconfigmap.Simpleholding exactly the keys of a v1.76 config (list in Global Constraints) builds anFsagainst the fake without any auth request (Requests()has no/core/v4/authkey);TestNewFsPasswordFallback: revoked session + username/password signs in again and saves newclient_*values;TestNewFsNoCredentials: error mentionsrclone config reconnect;TestTwoRemotesIsolated: twoFsfor two users in one process never see each other's tokens in their mappers;TestConfigBrowserandTestConfigPassword: driveConfigthrough its states withfs.ConfigInresults; the browser state's text contains the third-party notice. -
Step 2: Run - expect FAIL.
-
Step 3: Rewrite
protondrive.go. -
Step 4: Run
go test -race ./backend/protondrive/ -run TestIntegrationFake -v 2>&1 | tail -40. Fix failures in the new code (never by weakening the fake or skipping a test) until the suite passes. Then the four verification commands, andgo build ./.... -
Step 5: Commit
protondrive: rewrite the backend on a native client following Proton's SDK.
Task 15: Regression tests for known failures
Files:
- Create:
backend/protondrive/regression_test.go(packageprotondrive_test, throughfs.Fsand the fake)
Interfaces: Consumes Task 13 setupFakeRemote.
- Step 1: Write the tests (spec 9.4, rows not marked M5/M6). Each
is named
TestRegression<Issue or Topic>:TwoFsRefreshRace(9880, 8135): twoFsfrom one config; expire the token; bothListconcurrently; both succeed; the mapper holds valid credentials.TwoProcessRefreshRace: twoFson twoconfigmap.Simplecopies that are synced through a shared backing map with a delay; the loser adopts the winner's token, credentials never blank.ManyNewFs(9816): 16 concurrentNewFs; unlock concurrency 1.FileOverDirectoryandDirectoryOverFile(9438):operations.Copyreports a clean error, nothing is destroyed.DocsNodeSkipped(7959): a sync from a folder holding a Docs node completes with 0 errors.BadSignatureFile(8003): lists;Openfails with a clear message; withignore_signature_errors=trueit succeeds.SDKCreatedFolderResolvable(9622): aSeedFolder/SeedFiletree is reachable byNewObject("dir/sub/file")with a cold cache.MoveIsServerSide,CopyIsServerSide: no block upload requests.MultiThreadDownload:operations.Copyof a 20 MiB object to local with--multi-thread-streams 4matches SHA1.PutStreamUnknownSize.Captcha:NewFswith password fallback underRequireCaptchareturns the browser sign-in hint.AppVersionRejected: fault code 2028 -> fatal error mentioning upgrading rclone.QuotaExceeded: fault code 200001 on draft creation -> not retried (request count 1), message preserved.RateLimited: 5 consecutive 429s on listing -> succeeds, waited at least the sum ofRetry-After.
- Step 2: Run
go test -race ./backend/protondrive/ -run TestRegression -v- fix the backend until PASS. - Step 3: Commit
protondrive: add regression tests for known failures.
Task 16: Security tests
Files:
-
Create:
backend/protondrive/security_test.go -
Step 1: Write the tests (spec 9.6):
TestHostileServersubtests: wrong block hash, swapped blocks, truncated block list, older revision content under the current revision ID, attacker-signed passphrase, names"../x","a/b", NUL, 10 MiB - each yields an error or a skipped entry, never file data or a path outside the listed directory.TestNoSecretsInLogs: capture rclone's log output atfs.LogLevelDebug(asfstestlog capture helpers do) across password sign-in, a 5 MiB upload, a download andDisconnect; assert the log contains none of: the password, mailbox password, key password, access token, refresh token, the node passphrase and session key (exposed by the fake for the test), anypm-storage-tokenvalue.TestConfigHoldsNoPasswordAfterBrowserSignIn, andTestPasswordStoredObscured(the stored value differs from the plaintext andobscure.Revealrestores it).
-
Step 2: Run - fix until PASS.
-
Step 3: Commit
protondrive: add hostile-server and secret-hygiene tests.
Task 17: Request budgets and fuzzing
Files:
-
Create:
backend/protondrive/budget_test.go -
Create:
backend/protondrive/fuzz_internal_test.go -
Step 1: Write budget tests, using
Requests()deltas afterNewFs(exclude bootstrap byResetRequests()):TestBudgetList: folder of N in {0, 1, 100, 101, 250} files with page size 150 costs at mostP + ceil(N/100) + 1requests (P = child pages).TestBudgetWarmList: secondListcosts 0.TestBudgetNoopSync: tree of 5 folders x 40 files, already in sync;sync.Synccold costs at most2*D + ceil(N/100) + 5requests for D = 6, N = 200, and uploads/downloads 0 blocks.TestBudgetNewObject:NewObjectthree levels deep, cold: at most 2 requests per level + 1. (As built: 8, four folder levels at two requests each - handoff ruling 20.)TestBudgetNoLegacyRoutes: after the wholefstestsrun, assert in aTestMainhook that no counted route matches the deprecated patterns. Record the measured values in a comment next to each bound.
-
Step 2: Write fuzz targets seeded from
testdata/oldlib:FuzzDecryptXAttrs,FuzzDecodeAPIError,FuzzDecryptForkPayload,FuzzDecodeLink(link JSON ->node),FuzzValidateName. Each must not panic and must not return success with an invalid name. -
Step 3: Run
go test ./backend/protondrive/ -run 'TestBudget'andgo test ./backend/protondrive/ -fuzz FuzzDecryptXAttrs -fuzztime 30s(repeat per target) - fix findings. -
Step 4: Commit
protondrive: add request budget tests and fuzz targets.
Task 18: Wider hermetic suites and fork CI
Files:
- Create:
fstest/testserver/init.d/TestProtonDriveFake - Modify:
fstest/testserver/init.d/PORTS.md - Modify:
fstest/test_all/config.yaml(add backendprotondriveremoteTestProtonDriveFake:entry,fastlist: false) - Create:
.gitea/workflows/ci.yml - Modify:
docs/TODO.md,docs/PERMISSIONS.md
Model the init.d script on fstest/testserver/init.d/TestWebdavRclone
(rclone-serve.bash/run.bash helpers): start protonfake on a port
allocated in PORTS.md, echo its key=value lines and
_connect=127.0.0.1:PORT.
Read "Notes for Tasks 18 to 21" in the handoff before starting: this text leaves out how the script obtains
protonfake's output and manages its process, and the commands below needRCLONE_CONFIG=/notfound.
- Step 1: Add the script and config entry. Run
go run ./fstest/test_all -backends protondrive -remotes TestProtonDriveFake: -maxtries 1 -n 4and fix backend failures infs/operations,fs/sync,vfsuntil the report is clean.cmd/bisyncandcmd/gitannex: run them; if they fail for reasons that need M5/M6 work, addignoretestsfor those two on the fake remote and list the failures indocs/TODO.md. - Step 2:
.gitea/workflows/ci.ymlon push and pull request:actions/setup-gowithgo-version-file: go.mod; stepsgo build ./...;go vet ./backend/protondrive/...;golangci-lint run ./backend/protondrive/...;go test -race ./backend/protondrive/...; a 20s fuzz run per target;go run golang.org/x/vuln/cmd/govulncheck@latest ./backend/protondrive/.... It cannot be verified locally: say so indocs/TODO.md("first push must confirm the Gitea runner picks it up"). - Step 3: Update
docs/PERMISSIONS.mdverified-commands table for the host toolchain (Go 1.27.4, golangci-lint 2.15.0) and addgolangci-lint runto the Allow tier in it and in the three tool files. - Step 4: Commit
protondrive: run the wider suites against the fake server in fork CI.
Task 19: Remove the old libraries
Files:
- Delete:
backend/protondrive/crypto_diff_internal_test.go - Keep:
backend/protondrive/testdata/oldlib/and moveTestFrozenFixturesandTestFrozenFileFixtureintocrypto_internal_test.go/download_internal_test.go(they need no old-library import). - Modify:
go.mod,go.sum
Read "Notes for Tasks 18 to 21" in the handoff before starting:
masteris no longer the old backend,TestFrozenFileFixturehas already moved, anddocs/TODO.mdhas the accurate list of what to keep and delete.
- Step 1: Record the current binary size:
git stash -uis not allowed - instead buildmasterin a worktree:git worktree add ../rclone-master master && (cd ../rclone-master && go build -o /tmp/rclone-old .), notestat -c %s /tmp/rclone-old, thengit worktree remove ../rclone-master. - Step 2: Move the frozen-fixture tests, delete the differential
file,
go mod tidy. - Step 3: Verify:
grep -c 'Proton-API-Bridge\|rclone/go-proton-api\|replace' go.modprints 0;go build -o /tmp/rclone-new . && stat -c %s /tmp/rclone-newis not larger than the old size;go test -race ./backend/protondrive/...passes;go run bin/cross-compile.go -compile-only(seeMakefiletargetcompile_allfor the exact flags) succeeds for all targets;RCLONE_CONFIG=/notfound go test ./...has no new failures compared with the list indocs/TODO.md. - Step 4: Commit
protondrive: drop Proton-API-Bridge and go-proton-api.
Task 20: Documentation
Files:
-
Modify:
docs/content/protondrive.md(hand-written parts only; leave the autogenerated options block between its markers alone) -
Modify:
docs/data/backends/protondrive.yaml -
Modify:
docs/ARCHITECTURE.md,docs/DECISIONS.md,docs/TODO.md -
Create:
docs/specs/001-conformance-checklist.md -
Step 1: Rewrite the docs page per spec section 10: both sign-in methods (fix the stale
user>/passtranscript), the third-party notice verbatim, what is verified on upload and download, the ranged-read caveat, the--dumpwarning from spec section 7, limits (name rules, no SetModTime, trash semantics), removal of the "Beta" note and thehenrybear327library text. Do not change the tier. -
Step 2: Run
python3 bin/manage_backends.py features protondriveif it works offline with the fake remote; otherwise editprotondrive.yamlfeatures:by hand to match the new feature set and setfeatures_score: 5. Leavetier,maintainers,integration_testsuntouched. -
Step 3:
docs/specs/001-conformance-checklist.md: one table row per SDK module (apiService,shares,nodes,upload,download,crypto, account/auth) with the SDK file, the Go file, the SDK tag last checked (js/v0.23.0), and known deliberate differences (no small-file path, no events yet, no thumbnails). -
Step 4:
docs/ARCHITECTURE.md(protondrive section replacing the "known rough edges" line), adocs/DECISIONS.mdentry for the native port,docs/TODO.mdentries for M5, M6, M7 and every live check not run. -
Step 5: Commit
protondrive: update docs for the rewritten backend.
Task 21: Live verification (blocked until a test account exists)
Out of date since 2026-11-11: two test accounts exist and are configured as
TestProtonDrive:(free) andTestProtonDrivePaid:(paid, never signed in to) with username and password. See section 5 of the handoff for the setup and section 4 for what to check first.
Needs the fork owner to create the account and configure it themselves as
TestProtonDrive: in their rclone config (rclone config, browser
sign-in). Credentials must never be pasted into an agent conversation or
committed. If the remote is absent (rclone listremotes lacks
TestProtonDrive:), do not attempt this task: leave every box
unticked and report it as not run.
go test -v ./backend/protondrive/ -run '^TestIntegration$' -remote TestProtonDrive:go run ./fstest/test_all -backends protondrive -remotes TestProtonDrive: -n 1clean with noignore:entries.- Capture golden fixtures from the account (nodes created by the web
app and by Proton's official CLI) into
testdata/golden/with a-capturetest flag, and addTestGoldenFixtures. - Interop both ways with the official CLI and the web app (spec 9.7).
- Upgrade/downgrade check against released rclone v1.76.
- Record results in
docs/TODO.md; only then is spec criterion 12 (items 3, 6, 7) met.
Self-review notes
- Spec coverage: sections 6.1 (Tasks 2-4, 14), 6.2 minus events and persistent cache (Task 8; those are M5/M6, next plan), 6.3 (11), 6.4 (10), 6.5 minus Metadata (9, 14), 6.6 (8, 14), 6.7 (14), 7 (16), 9.1 (5, 6, 12), 9.2 (1, 2, 3, 4, 7, 9, 11), 9.3 (13, 14, 18), 9.4 (15, 17), 9.5 (17), 9.6 (16), 9.7 (21), 9.8 (18), 10 (20), 12 (19, 21).
- Not in this plan by design: events and
ChangeNotify, persistent cache, Metadata, small-file upload, the nightly live CI job, the weekly SDK reminder.