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/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/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) 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=