From bdfa908ffd7fdb3b6f092c7b4ec03df3791adf76 Mon Sep 17 00:00:00 2001 From: Josh Miller Date: Wed, 25 Feb 2026 08:15:02 -0500 Subject: [PATCH 1/2] drive: fix two upload failures caused by Proton backend changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit fixes two bugs that prevent file uploads to Proton Drive: --- Bug 1: handleRevisionConflict ignores ReplaceExistingDraft when GetRevisions returns 2501 --- When a previous upload attempt fails after the file draft is created but before blocks are committed, a broken draft remains. On the next attempt the draft is found (2500 "file already exists"), and GetRevisions is called to decide what to do with it. However, broken drafts return 422/2501 from the /revisions endpoint, causing handleRevisionConflict to return an error immediately — even when ReplaceExistingDraft=true. Fix: if GetRevisions fails AND ReplaceExistingDraft is set AND the link is in draft state, treat it as a broken draft and delete the link so the caller can retry from scratch. --- Bug 2: block uploads missing required Verifier.Token --- Proton's storage backend now requires a Verifier.Token per block in the POST /drive/blocks request. Without it, the storage server rejects block uploads with HTTP 422 / Code=200501 "Operation failed: Please retry". The token is computed by fetching a VerificationCode for the revision via: GET /drive/v2/volumes/{volumeID}/links/{linkID}/revisions/{revisionID}/verification then XOR-ing it with the leading bytes of each block's ciphertext (algorithm sourced from the official Proton Drive JS SDK in ProtonDriveApps/sdk). Also bumps go-proton-api to v1.0.1-0.20260218123427-1a63a293e3a2 which updates the default API host from mail.proton.me/api to drive-api.proton.me. Note: the JS SDK performs an additional client-side decryption check before computing the XOR token (to detect bit-flips / bad hardware). That step is not implemented here; the server-side manifest signature still provides end-to-end integrity verification. A future improvement could add it. This fix was identified and generated with Claude Code (AI assistant) by a non-programmer user (GitHub: lmwashere). It has not been independently reviewed by a Go or cryptography expert. Expert review before merging is strongly recommended. Reproducer: rclone copy proton: --verbose Expected: upload succeeds Actual: 422 POST fra-storage.proton.me/storage/blocks (Code=200501) followed by 422 GET .../revisions (Code=2501) on retry --- file_upload.go | 42 ++++++++++++++++++++++++++++++++++++++++-- go.mod | 2 +- go.sum | 2 ++ 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/file_upload.go b/file_upload.go index 4318df8..cd8cc95 100644 --- a/file_upload.go +++ b/file_upload.go @@ -8,6 +8,7 @@ import ( "crypto/sha256" "encoding/base64" "encoding/hex" + "fmt" "io" "mime" "os" @@ -24,6 +25,17 @@ func (protonDrive *ProtonDrive) handleRevisionConflict(ctx context.Context, link draftRevision, err := protonDrive.GetRevisions(ctx, link, proton.RevisionStateDraft) if err != nil { + // If we can't list revisions but the link is already in draft state + // (e.g. a broken/incomplete upload from a previous failed attempt) + // and the user wants to replace existing drafts, delete the link and + // let the caller retry from scratch rather than failing outright. + if protonDrive.Config.ReplaceExistingDraft && link.State == proton.LinkStateDraft { + err = protonDrive.c.DeleteChildren(ctx, protonDrive.MainShare.ShareID, link.ParentLinkID, linkID) + if err != nil { + return "", false, err + } + return "", true, nil + } return "", false, err } @@ -250,6 +262,18 @@ func (protonDrive *ProtonDrive) uploadAndCollectBlockData(ctx context.Context, n return nil, 0, nil, "", ErrMissingInputUploadAndCollectBlockData } + // Fetch the per-revision verification code required by Proton's storage backend. + // Each block's Verifier.Token is produced by XOR-ing this code with the first + // bytes of that block's ciphertext (per the Proton Drive JS SDK spec). + revVerification, err := protonDrive.c.GetRevisionVerification(ctx, protonDrive.MainShare.VolumeID, linkID, revisionID) + if err != nil { + return nil, 0, nil, "", fmt.Errorf("uploadAndCollectBlockData: get revision verification: %w", err) + } + verificationCode, err := base64.StdEncoding.DecodeString(revVerification.VerificationCode) + if err != nil { + return nil, 0, nil, "", fmt.Errorf("uploadAndCollectBlockData: decode verification code: %w", err) + } + totalFileSize := int64(0) pendingUploadBlocks := make([]PendingUploadBlocks, 0) @@ -309,7 +333,7 @@ func (protonDrive *ProtonDrive) uploadAndCollectBlockData(ctx context.Context, n blockSizes := make([]int64, 0) for i := 1; shouldContinue; i++ { if (i-1) > 0 && (i-1)%UPLOAD_BATCH_BLOCK_SIZE == 0 { - err := uploadPendingBlocks() + err = uploadPendingBlocks() if err != nil { return nil, 0, nil, "", err } @@ -365,17 +389,31 @@ func (protonDrive *ProtonDrive) uploadAndCollectBlockData(ctx context.Context, n } manifestSignatureData = append(manifestSignatureData, hash...) + // Compute per-block verifier token: XOR verificationCode with the + // leading bytes of the encrypted block (zero-padded if block is shorter). + verificationToken := make([]byte, len(verificationCode)) + for j, v := range verificationCode { + var b byte + if j < len(encData) { + b = encData[j] + } + verificationToken[j] = v ^ b + } + pendingUploadBlocks = append(pendingUploadBlocks, PendingUploadBlocks{ blockUploadInfo: proton.BlockUploadInfo{ Index: i, // iOS drive: BE starts with 1 Size: int64(len(encData)), EncSignature: encSignatureStr, Hash: base64Hash, + Verifier: proton.BlockUploadVerifier{ + Token: base64.StdEncoding.EncodeToString(verificationToken), + }, }, encData: encData, }) } - err := uploadPendingBlocks() + err = uploadPendingBlocks() if err != nil { return nil, 0, nil, "", err } diff --git a/go.mod b/go.mod index 62ef8b4..2f60fce 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ toolchain go1.23.5 require ( github.com/ProtonMail/gluon v0.17.1-0.20230724134000-308be39be96e github.com/ProtonMail/gopenpgp/v2 v2.8.2 - github.com/rclone/go-proton-api v1.0.1-0.20260127173028-eb465cac3b18 + github.com/rclone/go-proton-api v1.0.1-0.20260218123427-1a63a293e3a2 github.com/relvacode/iso8601 v1.6.0 golang.org/x/sync v0.10.0 ) diff --git a/go.sum b/go.sum index 5b8df5d..125b082 100644 --- a/go.sum +++ b/go.sum @@ -77,6 +77,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rclone/go-proton-api v1.0.1-0.20260127173028-eb465cac3b18 h1:Lc+d3ISfQaMJKWZOE7z4ZSY4RVmdzbn1B0IM8xN18qM= github.com/rclone/go-proton-api v1.0.1-0.20260127173028-eb465cac3b18/go.mod h1:LB2kCEaZMzNn3ocdz+qYfxXmuLxxN0ka62KJd2x53Bc= +github.com/rclone/go-proton-api v1.0.1-0.20260218123427-1a63a293e3a2 h1:QR87vlRq+z0JwJsUteEhsXcSrXGJ2yte5MocMSfajM4= +github.com/rclone/go-proton-api v1.0.1-0.20260218123427-1a63a293e3a2/go.mod h1:LB2kCEaZMzNn3ocdz+qYfxXmuLxxN0ka62KJd2x53Bc= github.com/relvacode/iso8601 v1.6.0 h1:eFXUhMJN3Gz8Rcq82f9DTMW0svjtAVuIEULglM7QHTU= github.com/relvacode/iso8601 v1.6.0/go.mod h1:FlNp+jz+TXpyRqgmM7tnzHHzBnz776kmAH2h3sZCn0I= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= From 9ba9207356aef0eb85acb31285bf28feeb4ffd84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Can=C3=A9vet?= Date: Fri, 13 Mar 2026 22:26:34 +0100 Subject: [PATCH 2/2] fix: implement session key reuse for move/rename operations This fixes Code 2000 errors during move/rename operations. Changes: - Add ReEncryptPassphrase function: re-encrypts node passphrase to new parent keyring while reusing the original symmetric session key - Add ReEncryptName function: re-encrypts node name for new parent keyring while reusing the original session key and re-signing with current addrKR - Add decryptSessionKey helper with fallback chain - Update moveLink to use new functions - Set ContentHash = nil explicitly for file moves - Only include NodePassphraseSignature for anonymous nodes (KeyAuthor == nil) Test results: - FsMove: PASS (was failing with Code 2000) - FsDirMove: PASS (was failing with Code 2000) - All other tests: PASS --- crypto.go | 74 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ folder.go | 42 ++++++++++++++++++++----------- 2 files changed, 101 insertions(+), 15 deletions(-) diff --git a/crypto.go b/crypto.go index b188be4..72337a4 100644 --- a/crypto.go +++ b/crypto.go @@ -3,6 +3,7 @@ package proton_api_bridge import ( "crypto/sha256" "encoding/base64" + "fmt" "io" "github.com/ProtonMail/gopenpgp/v2/crypto" @@ -166,3 +167,76 @@ func decryptBlockIntoBuffer(sessionKey *crypto.SessionKey, addrKR, nodeKR *crypt return nil } + +// decryptSessionKey tries multiple keyrings to decrypt a session key. +// Fallback chain: srcParentKR -> addrKR -> userKR +func decryptSessionKey(keyPacket []byte, candidates ...*crypto.KeyRing) (*crypto.SessionKey, error) { + var err error + tried := false + for _, kr := range candidates { + if kr == nil { + continue + } + tried = true + var sk *crypto.SessionKey + sk, err = kr.DecryptSessionKey(keyPacket) + if err == nil { + return sk, nil + } + } + if !tried { + return nil, fmt.Errorf("decryptSessionKey: no non-nil keyrings provided") + } + return nil, err +} + +// ReEncryptPassphrase re-encrypts a node passphrase to a new parent +// keyring while reusing the original symmetric session key. +func ReEncryptPassphrase(armoredPassphrase string, srcParentKR, dstParentKR, addrKR, userKR *crypto.KeyRing) (string, error) { + split, err := crypto.NewPGPSplitMessageFromArmored(armoredPassphrase) + if err != nil { + return "", fmt.Errorf("reEncryptPassphrase: split: %w", err) + } + + sk, err := decryptSessionKey(split.GetBinaryKeyPacket(), srcParentKR, addrKR, userKR) + if err != nil { + return "", fmt.Errorf("reEncryptPassphrase: decrypt session key: %w", err) + } + + newKeyPacket, err := dstParentKR.EncryptSessionKey(sk) + if err != nil { + return "", fmt.Errorf("reEncryptPassphrase: encrypt session key: %w", err) + } + + armored, err := crypto.NewPGPSplitMessage(newKeyPacket, split.GetBinaryDataPacket()).GetArmored() + if err != nil { + return "", fmt.Errorf("reEncryptPassphrase: armor: %w", err) + } + return armored, nil +} + +// ReEncryptName re-encrypts a node name for a new parent keyring while +// reusing the original name session key and optionally changing the plaintext. +func ReEncryptName(armoredName, newName string, srcParentKR, dstParentKR, addrKR, userKR *crypto.KeyRing) (string, error) { + split, err := crypto.NewPGPSplitMessageFromArmored(armoredName) + if err != nil { + return "", fmt.Errorf("reEncryptName: split: %w", err) + } + + sk, err := decryptSessionKey(split.GetBinaryKeyPacket(), srcParentKR, addrKR, userKR) + if err != nil { + return "", fmt.Errorf("reEncryptName: decrypt session key: %w", err) + } + + dataPacket, err := sk.EncryptAndSign(crypto.NewPlainMessageFromString(newName), addrKR) + if err != nil { + return "", fmt.Errorf("reEncryptName: encrypt: %w", err) + } + + newKeyPacket, err := dstParentKR.EncryptSessionKey(sk) + if err != nil { + return "", fmt.Errorf("reEncryptName: encrypt session key: %w", err) + } + + return crypto.NewPGPSplitMessage(newKeyPacket, dataPacket).GetArmored() +} diff --git a/folder.go b/folder.go index 2aedf67..cf6537a 100644 --- a/folder.go +++ b/folder.go @@ -2,6 +2,7 @@ package proton_api_bridge import ( "context" + "fmt" "time" "github.com/rclone/go-proton-api" @@ -201,22 +202,29 @@ func (protonDrive *ProtonDrive) MoveFolder(ctx context.Context, srcLink *proton. func (protonDrive *ProtonDrive) moveLink(ctx context.Context, srcLink *proton.Link, dstParentLink *proton.Link, dstName string) error { // we are moving the srcLink to under dstParentLink, with name dstName - req := proton.MoveLinkReq{ - ParentLinkID: dstParentLink.LinkID, - OriginalHash: srcLink.Hash, - SignatureAddress: protonDrive.signatureAddress, - } dstParentKR, err := protonDrive.getLinkKR(ctx, dstParentLink) if err != nil { return err } - err = req.SetName(dstName, protonDrive.DefaultAddrKR, dstParentKR) + srcParentKR, err := protonDrive.getLinkKRByID(ctx, srcLink.ParentLinkID) if err != nil { return err } + // Re-encrypt the node passphrase to new parent keyring (reuse session key) + nodePassphrase, err := ReEncryptPassphrase(srcLink.NodePassphrase, srcParentKR, dstParentKR, protonDrive.DefaultAddrKR, protonDrive.userKR) + if err != nil { + return fmt.Errorf("moveLink: reencrypt passphrase: %w", err) + } + + // Re-encrypt the name to new parent keyring + encNewName, err := ReEncryptName(srcLink.Name, dstName, srcParentKR, dstParentKR, protonDrive.DefaultAddrKR, protonDrive.userKR) + if err != nil { + return fmt.Errorf("moveLink: reencrypt name: %w", err) + } + signatureVerificationKR, err := protonDrive.getSignatureVerificationKeyring([]string{dstParentLink.SignatureEmail}, dstParentKR) if err != nil { return err @@ -225,21 +233,25 @@ func (protonDrive *ProtonDrive) moveLink(ctx context.Context, srcLink *proton.Li if err != nil { return err } - err = req.SetHash(dstName, dstParentHashKey) + newNameHash, err := proton.GetNameHash(dstName, dstParentHashKey) if err != nil { return err } - srcParentKR, err := protonDrive.getLinkKRByID(ctx, srcLink.ParentLinkID) - if err != nil { - return err + req := proton.MoveLinkReq{ + ParentLinkID: dstParentLink.LinkID, + Name: encNewName, + Hash: newNameHash, + OriginalHash: srcLink.Hash, + NodePassphrase: nodePassphrase, + SignatureAddress: protonDrive.signatureAddress, + ContentHash: nil, // Explicit null for file moves } - nodePassphrase, err := reencryptKeyPacket(srcParentKR, dstParentKR, protonDrive.DefaultAddrKR, srcLink.NodePassphrase) - if err != nil { - return err + + // Only include signatures for anonymous nodes (KeyAuthor == nil) + if srcLink.KeyAuthor == nil || *srcLink.KeyAuthor == "" { + req.NodePassphraseSignature = srcLink.NodePassphraseSignature } - req.NodePassphrase = nodePassphrase - req.NodePassphraseSignature = srcLink.NodePassphraseSignature protonDrive.removeLinkIDFromCache(srcLink.LinkID, false)