From 27a9a37dc7c0b30ddf973e17cc3ac886a3142ca9 Mon Sep 17 00:00:00 2001 From: Josh Miller Date: Wed, 25 Feb 2026 08:14:25 -0500 Subject: [PATCH 1/2] drive: add block upload verifier token and revision verification endpoint Proton's storage backend now requires a Verifier.Token per block when requesting block upload URLs (POST /drive/blocks). Without it, the storage server rejects uploads with HTTP 422 / Code=200501 "Operation failed: Please retry". This commit adds: - RevisionVerification type and BlockUploadVerifier type in block_types.go - Verifier field (omitempty) on BlockUploadInfo - GetRevisionVerification() method calling the v2 API endpoint: GET /drive/v2/volumes/{volumeID}/links/{linkID}/revisions/{revisionID}/verification The VerificationCode returned by that endpoint is XOR'd with the leading bytes of each block's ciphertext in Proton-API-Bridge to produce the per-block token (matching the algorithm in the official Proton Drive JS SDK). Note: the JS SDK also decrypts each block as a client-side integrity check before computing the XOR. That step is not implemented here; the server-side manifest signature still provides end-to-end integrity verification. This fix was identified and generated with Claude Code (AI assistant) by a non-programmer user. It has not been independently reviewed by a Go or cryptography expert. Expert review before merging is strongly recommended. Fixes uploads failing with: 422 POST fra-storage.proton.me/storage/blocks: Operation failed: Please retry (Code=200501, Status=422) --- block_types.go | 13 +++++++++++++ link_file.go | 23 +++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/block_types.go b/block_types.go index ad41611..f634e52 100644 --- a/block_types.go +++ b/block_types.go @@ -28,6 +28,19 @@ type BlockUploadInfo struct { Size int64 EncSignature string Hash string + Verifier BlockUploadVerifier `json:",omitempty"` +} + +// BlockUploadVerifier holds the per-block verification token required by the +// Proton storage backend to authenticate that the block was correctly encrypted. +type BlockUploadVerifier struct { + Token string `json:",omitempty"` +} + +// RevisionVerification is the response from the block upload verification endpoint. +type RevisionVerification struct { + VerificationCode string // Base64-encoded verification code XOR'd with each block + ContentKeyPacket string // Encrypted content session key (for client-side integrity check) } type BlockUploadLink struct { diff --git a/link_file.go b/link_file.go index 173d9b8..d05dd70 100644 --- a/link_file.go +++ b/link_file.go @@ -8,6 +8,29 @@ import ( "github.com/go-resty/resty/v2" ) +// GetRevisionVerification fetches the verification code for a draft revision from the +// v2 volume-based API. The VerificationCode is XOR'd with each block's encrypted bytes +// to produce a per-block Verifier.Token that the storage backend requires. +func (c *Client) GetRevisionVerification(ctx context.Context, volumeID, linkID, revisionID string) (RevisionVerification, error) { + var res struct { + VerificationCode string + ContentKeyPacket string + } + + if err := c.do(ctx, func(r *resty.Request) (*resty.Response, error) { + return r.SetResult(&res).Get( + "/drive/v2/volumes/" + volumeID + "/links/" + linkID + "/revisions/" + revisionID + "/verification", + ) + }); err != nil { + return RevisionVerification{}, err + } + + return RevisionVerification{ + VerificationCode: res.VerificationCode, + ContentKeyPacket: res.ContentKeyPacket, + }, nil +} + func (c *Client) ListRevisions(ctx context.Context, shareID, linkID string) ([]RevisionMetadata, error) { var res struct { Revisions []RevisionMetadata From db80cec83393843bde2c6a4b0007eb0990a72722 Mon Sep 17 00:00:00 2001 From: Josh Miller Date: Thu, 26 Mar 2026 22:02:47 -0400 Subject: [PATCH 2/2] fix: move support, block upload retry, and 502 resilience Three fixes for Proton Drive operations: 1. MoveLinkReq: add required NameSignatureEmail field and fix SignatureEmail JSON tag. Without NameSignatureEmail the API returns Code=2000 ("value cannot be empty"). NodePassphraseSignature and SignatureEmail are now omitempty as they are only required for anonymously-created nodes. Ref: https://github.com/ProtonDriveApps/windows-drive/blob/main/src/ProtonDrive.Client/MoveLinkParameters.cs 2. UploadBlock: accept []byte instead of io.Reader so resty can replay the request body on retry. Previously, on a network drop resty retried with an exhausted reader, producing an empty body and Code=2003 from storage. 3. Retry 502 Bad Gateway: storage servers return 502 transiently; add it to the retry condition alongside 429/503 so individual block failures recover without restarting the entire file transfer. Co-Authored-By: Claude Sonnet 4.6 --- block.go | 37 +++++++++++++++++++++++++++++++++++-- link_file_types.go | 12 +++++++----- response.go | 8 +++++++- 3 files changed, 49 insertions(+), 8 deletions(-) diff --git a/block.go b/block.go index 74e89a6..0385d0d 100644 --- a/block.go +++ b/block.go @@ -1,8 +1,12 @@ package proton import ( + "bytes" "context" + "fmt" "io" + "mime/multipart" + "net/textproto" "github.com/go-resty/resty/v2" ) @@ -32,11 +36,40 @@ func (c *Client) RequestBlockUpload(ctx context.Context, req BlockUploadReq) ([] return res.UploadLinks, nil } -func (c *Client) UploadBlock(ctx context.Context, bareURL, token string, block io.Reader) error { +// UploadBlock uploads an encrypted block to Proton storage. +// The block data is accepted as []byte so that resty can replay it on retry +// without exhausting an io.Reader. (resty rebuilds the request body on each +// retry attempt; if an io.Reader is used it is consumed on the first attempt +// and subsequent retries send an empty body, causing Code=2003 errors.) +func (c *Client) UploadBlock(ctx context.Context, bareURL, token string, block []byte) error { + // Pre-build the multipart body into []byte. resty stores the raw []byte in + // r.Body and reconstructs r.bodyBuf from it on every retry attempt, so the + // storage server always receives the full block data. + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + + hdr := make(textproto.MIMEHeader) + hdr.Set("Content-Disposition", `form-data; name="Block"; filename="blob"`) + hdr.Set("Content-Type", "application/octet-stream") + part, err := w.CreatePart(hdr) + if err != nil { + return fmt.Errorf("creating multipart field: %w", err) + } + if _, err := part.Write(block); err != nil { + return fmt.Errorf("writing block data: %w", err) + } + if err := w.Close(); err != nil { + return fmt.Errorf("closing multipart writer: %w", err) + } + + contentType := w.FormDataContentType() + bodyBytes := buf.Bytes() + return c.do(ctx, func(r *resty.Request) (*resty.Response, error) { return r. SetHeader("pm-storage-token", token). - SetMultipartField("Block", "blob", "application/octet-stream", block). + SetHeader("Content-Type", contentType). + SetBody(bodyBytes). Post(bareURL) }) } diff --git a/link_file_types.go b/link_file_types.go index a06a6b4..520cd11 100644 --- a/link_file_types.go +++ b/link_file_types.go @@ -40,13 +40,15 @@ func GetNameHash(name string, hashKey []byte) (string, error) { type MoveLinkReq struct { ParentLinkID string - Name string // Encrypted File Name - OriginalHash string // Old Encrypted File Name Hash - Hash string // Encrypted File Name Hash by using parent's NodeHashKey + Name string // Encrypted File Name + OriginalHash string // Old Encrypted File Name Hash + Hash string // Encrypted File Name Hash by using parent's NodeHashKey + NodePassphrase string // The passphrase used to unlock the NodeKey, encrypted by the owning Link/Share keyring. - NodePassphraseSignature string // The signature of the NodePassphrase + NodePassphraseSignature string `json:",omitempty"` // The signature of the NodePassphrase; only set for anonymously created nodes. - SignatureAddress string // Signature email address used to sign passphrase and name + SignatureEmail string `json:"SignatureEmail,omitempty"` // Passphrase signature address; only set for anonymously created nodes. + NameSignatureEmail string `json:"NameSignatureEmail"` // Email address used to sign the encrypted name. } func (moveLinkReq *MoveLinkReq) SetName(name string, addrKR, nodeKR *crypto.KeyRing) error { diff --git a/response.go b/response.go index 0ce36d7..902fa04 100644 --- a/response.go +++ b/response.go @@ -179,7 +179,13 @@ func catchRetryAfter(_ *resty.Client, res *resty.Response) (time.Duration, error } func catchTooManyRequests(res *resty.Response, _ error) bool { - return res.StatusCode() == http.StatusTooManyRequests || res.StatusCode() == http.StatusServiceUnavailable + switch res.StatusCode() { + case http.StatusTooManyRequests, // 429 + http.StatusBadGateway, // 502 — storage servers return this transiently + http.StatusServiceUnavailable: // 503 + return true + } + return false } func catchDialError(res *resty.Response, err error) bool {