Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions crypto.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package proton_api_bridge
import (
"crypto/sha256"
"encoding/base64"
"fmt"
"io"

"github.com/ProtonMail/gopenpgp/v2/crypto"
Expand Down Expand Up @@ -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()
}
42 changes: 40 additions & 2 deletions file_upload.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"io"
"mime"
"os"
Expand All @@ -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
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down
42 changes: 27 additions & 15 deletions folder.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package proton_api_bridge

import (
"context"
"fmt"
"time"

"github.com/rclone/go-proton-api"
Expand Down Expand Up @@ -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
Expand All @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down