protondrive: drop Proton-API-Bridge and go-proton-api

The old libraries were only used by the differential crypto tests. Those
go, and TestFrozenFixtures moves into crypto_internal_test.go without its
-update branch, so files the old library wrote stay checked through the
frozen fixtures in testdata/oldlib. go mod tidy removes the two libraries
and the modules only they pulled in; relvacode/iso8601 leaves go.mod.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-11-12 09:51:43 +01:00
1 parent b667a1ab0f
commit cf7577dc1d
9 files changed
+186 -934

No files matched your search

@@ -1,872 +0,0 @@
package protondrive
// Differential tests of crypto.go against the library rclone used before
// it: go-proton-api v1.0.4 (proton) and Proton-API-Bridge v1.0.5 (the
// bridge). Where the old writer or reader is unexported it is mirrored
// with gopenpgp, citing the old file and lines.
//
// testdata/oldlib holds values written by the old library, so that they
// stay readable once it is gone; TestFrozenFixtures, less its -update
// branch, and TestFrozenFileFixture are all that need outlive it.
// Regenerate them with
//
// go test ./backend/protondrive/ -run 'TestFrozenFixtures|TestOldLibraryFileReadable' -update
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha1"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"flag"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"github.com/ProtonMail/go-crypto/openpgp/packet"
"github.com/ProtonMail/gopenpgp/v3/constants"
"github.com/ProtonMail/gopenpgp/v3/crypto"
"github.com/ProtonMail/gopenpgp/v3/profile"
"github.com/rclone/go-proton-api"
"github.com/rclone/rclone/backend/protondrive/api"
"github.com/rclone/rclone/backend/protondrive/protontest"
"github.com/rclone/rclone/lib/rest"
"github.com/relvacode/iso8601"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
var updateOldLib = flag.Bool("update", false, "regenerate testdata/oldlib with the old library")
// oldLibDir holds the frozen old library fixtures.
const oldLibDir = "testdata/oldlib"
// oldLibFixture is a node written by the old library with the test-only
// keys it was written with and the plaintexts it encrypted.
type oldLibFixture struct {
Comment string
Type string // "folder" or "file"
ParentKey string // armored parent node key, locked with ParentKeyPassphrase
ParentKeyPassphrase string
AddressKey string // armored address key which signs, locked with AddressKeyPassphrase
AddressKeyPassphrase string
NodeKey string
NodePassphrase string // encrypted to the parent key
NodePassphraseSignature string // by the address key
NodePassphrasePlain string
Name string // encrypted to the parent key, signed by the address key
NamePlain string
// A folder's hash key, encrypted to and signed by its node key, and
// the hash of ChildName with it.
NodeHashKey string `json:",omitempty"`
HashKeyPlain string `json:",omitempty"` // base64 of the raw bytes
ChildName string `json:",omitempty"`
ChildNameHash string `json:",omitempty"`
// A file's content key, signed by its node key, and one block of
// content with its signature by the address key, encrypted to the
// node key, and the manifest signature by the address key.
ContentKeyPacket string `json:",omitempty"` // base64, encrypted to the node key
ContentKeyPacketSignature string `json:",omitempty"`
ContentSessionKey string `json:",omitempty"` // hex
Block string `json:",omitempty"` // base64
BlockPlain string `json:",omitempty"` // base64
BlockEncSignature string `json:",omitempty"`
BlockHash string `json:",omitempty"` // base64 SHA-256 of Block
ManifestSignature string `json:",omitempty"`
XAttr string `json:",omitempty"` // encrypted to the node key, signed by the address key
XAttrModificationTime string `json:",omitempty"` // as the old library wrote it
XAttrSize int64 `json:",omitempty"`
XAttrBlockSizes []int64 `json:",omitempty"`
XAttrSHA1 string `json:",omitempty"`
}
// loadOldLibFixture returns the fixture testdata/oldlib/name.json.
func loadOldLibFixture(t *testing.T, name string) oldLibFixture {
t.Helper()
data, err := os.ReadFile(filepath.Join(oldLibDir, name+".json"))
require.NoError(t, err)
var f oldLibFixture
require.NoError(t, json.Unmarshal(data, &f))
return f
}
// unlockedKeyRing returns a key ring of the armored key unlocked with
// passphrase.
func unlockedKeyRing(t *testing.T, armored, passphrase string) *crypto.KeyRing {
t.Helper()
key, err := unlockKey(armored, []byte(passphrase))
require.NoError(t, err)
kr, err := crypto.NewKeyRing(key)
require.NoError(t, err)
return kr
}
// checkOldLibFixture reads f with crypto.go and checks every value
// against its plaintext and every signature against its signer.
func checkOldLibFixture(t *testing.T, f oldLibFixture, now time.Time) {
t.Helper()
parentKR := unlockedKeyRing(t, f.ParentKey, f.ParentKeyPassphrase)
addrPub := publicOf(t, unlockedKeyRing(t, f.AddressKey, f.AddressKeyPassphrase))
keys, verifyErr, err := decryptNodeKeys(parentKR, addrPub, f.NodeKey, f.NodePassphrase, f.NodePassphraseSignature, now)
require.NoError(t, err)
assert.NoError(t, verifyErr, "node passphrase signature")
assert.Equal(t, f.NodePassphrasePlain, string(keys.passphrase))
nodeKR := keys.keyRing
name, verifyErr, err := decryptName(f.Name, parentKR, addrPub, now)
require.NoError(t, err)
assert.NoError(t, verifyErr, "name signature")
assert.Equal(t, f.NamePlain, name)
switch f.Type {
case "folder":
hashKey, verifyErr, err := decryptHashKey(f.NodeHashKey, nodeKR, nil, now)
require.NoError(t, err)
assert.NoError(t, verifyErr, "hash key signature")
assert.Equal(t, f.HashKeyPlain, base64.StdEncoding.EncodeToString(hashKey))
assert.Equal(t, f.ChildNameHash, nameHash(f.ChildName, hashKey))
case "file":
sk, verifyErr, err := decryptContentKey(f.ContentKeyPacket, f.ContentKeyPacketSignature, nodeKR, nil, now)
require.NoError(t, err)
assert.NoError(t, verifyErr, "content key signature")
assert.Equal(t, f.ContentSessionKey, hex.EncodeToString(sk.Key))
block, err := base64.StdEncoding.DecodeString(f.Block)
require.NoError(t, err)
plain, err := decryptBlock(block, sk)
require.NoError(t, err)
assert.Equal(t, f.BlockPlain, base64.StdEncoding.EncodeToString(plain))
hash := sha256.Sum256(block)
assert.Equal(t, f.BlockHash, base64.StdEncoding.EncodeToString(hash[:]))
assert.NoError(t, decryptBlockSignature(t, f.BlockEncSignature, plain, nodeKR, addrPub), "block signature")
assert.NoError(t, verifyManifest([][]byte{hash[:]}, f.ManifestSignature, addrPub, now))
x, verifyErr, err := decryptXAttrs(f.XAttr, nodeKR, addrPub, now)
require.NoError(t, err)
assert.NoError(t, verifyErr, "extended attributes signature")
modTime, err := time.Parse("2006-01-02T15:04:05-0700", f.XAttrModificationTime)
require.NoError(t, err)
assert.True(t, modTime.Equal(x.ModTime), "%v != %v", modTime, x.ModTime)
assert.Equal(t, f.XAttrSize, x.Size)
assert.Equal(t, f.XAttrBlockSizes, x.BlockSizes)
assert.Equal(t, f.XAttrSHA1, x.SHA1)
default:
t.Fatalf("unknown fixture type %q", f.Type)
}
}
// Old library writers.
// oldGeneratePassphrase mirrors generatePassphrase, bridge crypto.go:33-41.
func oldGeneratePassphrase(t *testing.T) string {
t.Helper()
token, err := crypto.RandomToken(32)
require.NoError(t, err)
return base64.StdEncoding.EncodeToString(token)
}
// oldGenerateLockedKey mirrors generateLockedKey, bridge crypto.go:53-69.
func oldGenerateLockedKey(t *testing.T, name, email string, passphrase []byte, aead bool) string {
t.Helper()
pgp := crypto.PGP()
if aead {
// protonDrivePGP, bridge crypto.go:27-31.
p := profile.RFC9580()
p.AeadEncryption = &packet.AEADConfig{DefaultMode: packet.AEADModeGCM}
pgp = crypto.PGPWithProfile(p)
}
key, err := pgp.KeyGeneration().AddUserId(name, email).New().GenerateKey()
require.NoError(t, err)
defer key.ClearPrivateParams()
locked, err := pgp.LockKey(key, passphrase)
require.NoError(t, err)
armored, err := locked.Armor()
require.NoError(t, err)
return armored
}
// oldGenerateCryptoKey mirrors generateCryptoKey, bridge crypto.go:71-86.
func oldGenerateCryptoKey(t *testing.T, aead bool) (passphrase, armoredKey string) {
t.Helper()
passphrase = oldGeneratePassphrase(t)
return passphrase, oldGenerateLockedKey(t, "Drive key", "noreply@protonmail.com", []byte(passphrase), aead)
}
// oldGenerateNodeKeys mirrors generateNodeKeys, bridge crypto.go:119-135,
// also returning the passphrase, which the bridge drops.
func oldGenerateNodeKeys(t *testing.T, kr, addrKR *crypto.KeyRing, aead bool) (passphrase, nodeKey, encPassphrase, passphraseSig string) {
t.Helper()
passphrase, nodeKey = oldGenerateCryptoKey(t, aead)
encPassphrase, passphraseSig = oldEncryptWithSignature(t, kr, addrKR, []byte(passphrase))
return passphrase, nodeKey, encPassphrase, passphraseSig
}
// oldEncryptWithSignature mirrors encryptWithSignature, bridge
// crypto.go:88-117.
func oldEncryptWithSignature(t *testing.T, kr, addrKR *crypto.KeyRing, b []byte) (enc, sig string) {
t.Helper()
msg, err := proton.EncryptMessageNonAead(kr, b, nil)
require.NoError(t, err)
enc, err = msg.Armor()
require.NoError(t, err)
sh, err := crypto.PGP().Sign().SigningKeys(addrKR).Detached().New()
require.NoError(t, err)
rawSig, err := sh.Sign(b, crypto.Armor)
require.NoError(t, err)
return enc, string(rawSig)
}
// oldEncryptBlock mirrors the block encryption of
// uploadAndCollectBlockData, bridge file_upload.go:360-406, returning
// the encrypted block, its encrypted signature and its SHA-256.
func oldEncryptBlock(t *testing.T, data []byte, sk *crypto.SessionKey, nodeKR, addrKR *crypto.KeyRing) (encData []byte, encSignature string, hash []byte) {
t.Helper()
p := profile.RFC9580()
p.AeadEncryption = &packet.AEADConfig{DefaultMode: packet.AEADModeGCM}
eh, err := crypto.PGPWithProfile(p).Encryption().SessionKey(sk).New()
require.NoError(t, err)
msg, err := eh.Encrypt(data)
require.NoError(t, err)
encData = msg.Bytes()
sh, err := crypto.PGP().Sign().SigningKeys(addrKR).Detached().New()
require.NoError(t, err)
rawSig, err := sh.Sign(data, crypto.Bytes)
require.NoError(t, err)
encSig, err := proton.EncryptMessageNonAead(nodeKR, rawSig, nil)
require.NoError(t, err)
encSignature, err = encSig.Armor()
require.NoError(t, err)
sum := sha256.Sum256(encData)
return encData, encSignature, sum[:]
}
// oldSignManifest mirrors commitNewRevision, bridge file_upload.go:429-437.
func oldSignManifest(t *testing.T, manifest []byte, addrKR *crypto.KeyRing) string {
t.Helper()
sh, err := crypto.PGP().Sign().SigningKeys(addrKR).Detached().New()
require.NoError(t, err)
sig, err := sh.Sign(manifest, crypto.Armor)
require.NoError(t, err)
return string(sig)
}
// oldLibKeys returns a new parent node key and address key as the old
// library would hold them, armored and locked, and unlocked.
func oldLibKeys(t *testing.T) (f oldLibFixture, parentKR, addrKR *crypto.KeyRing) {
t.Helper()
f.ParentKeyPassphrase, f.ParentKey = oldGenerateCryptoKey(t, false)
f.AddressKeyPassphrase = oldGeneratePassphrase(t)
f.AddressKey = oldGenerateLockedKey(t, "test", "test@proton.test", []byte(f.AddressKeyPassphrase), false)
return f, unlockedKeyRing(t, f.ParentKey, f.ParentKeyPassphrase), unlockedKeyRing(t, f.AddressKey, f.AddressKeyPassphrase)
}
// writeOldLibFolder returns a folder written by the old library, as
// CreateNewFolder does in bridge folder.go:83-136.
func writeOldLibFolder(t *testing.T) oldLibFixture {
t.Helper()
f, parentKR, addrKR := oldLibKeys(t)
f.Comment = "Folder written by go-proton-api v1.0.4 and Proton-API-Bridge v1.0.5 with test-only keys."
f.Type = "folder"
f.NodePassphrasePlain, f.NodeKey, f.NodePassphrase, f.NodePassphraseSignature = oldGenerateNodeKeys(t, parentKR, addrKR, false)
req := proton.CreateFolderReq{}
f.NamePlain = "Fichiers d'été 📁"
require.NoError(t, req.SetName(f.NamePlain, addrKR, parentKR))
f.Name = req.Name
nodeKR := unlockedKeyRing(t, f.NodeKey, f.NodePassphrasePlain)
require.NoError(t, req.SetNodeHashKey(nodeKR))
f.NodeHashKey = req.NodeHashKey
// The old library never returns the plaintext hash key, so read it
// back with its own reader, as its callers do (bridge folder.go:116-120).
verifyKR, err := joinKeyRings(addrKR, nodeKR)
require.NoError(t, err)
hashKey, err := proton.Link{Type: proton.LinkTypeFolder, FolderProperties: &proton.FolderProperties{NodeHashKey: f.NodeHashKey}}.GetHashKey(nodeKR, verifyKR)
require.NoError(t, err)
f.HashKeyPlain = base64.StdEncoding.EncodeToString(hashKey)
f.ChildName = "Document.txt"
f.ChildNameHash, err = proton.GetNameHash(f.ChildName, hashKey)
require.NoError(t, err)
return f
}
// writeOldLibFile returns a file of one 1 KiB block written by the old
// library, as uploadFile does in bridge file_upload.go:97-169 and
// 355-505.
func writeOldLibFile(t *testing.T) oldLibFixture {
t.Helper()
f, parentKR, addrKR := oldLibKeys(t)
f.Comment = "File written by go-proton-api v1.0.4 and Proton-API-Bridge v1.0.5 with test-only keys."
f.Type = "file"
f.NodePassphrasePlain, f.NodeKey, f.NodePassphrase, f.NodePassphraseSignature = oldGenerateNodeKeys(t, parentKR, addrKR, false)
req := proton.CreateFileReq{}
f.NamePlain = "Résumé – final.pdf"
require.NoError(t, req.SetName(f.NamePlain, addrKR, parentKR))
f.Name = req.Name
nodeKR := unlockedKeyRing(t, f.NodeKey, f.NodePassphrasePlain)
sk, err := req.SetContentKeyPacketAndSignature(nodeKR)
require.NoError(t, err)
f.ContentKeyPacket, f.ContentKeyPacketSignature = req.ContentKeyPacket, req.ContentKeyPacketSignature
f.ContentSessionKey = hex.EncodeToString(sk.Key)
plain := make([]byte, 1024)
_, err = rand.Read(plain)
require.NoError(t, err)
block, encSig, hash := oldEncryptBlock(t, plain, sk, nodeKR, addrKR)
f.Block, f.BlockPlain, f.BlockEncSignature = base64.StdEncoding.EncodeToString(block), base64.StdEncoding.EncodeToString(plain), encSig
f.BlockHash = base64.StdEncoding.EncodeToString(hash)
f.ManifestSignature = oldSignManifest(t, hash, addrKR)
digest := sha1.Sum(plain)
common := &proton.RevisionXAttrCommon{
ModificationTime: time.Date(2023, 11, 20, 9, 30, 15, 0, time.FixedZone("", 5*3600+30*60)).Format("2006-01-02T15:04:05-0700"),
Size: int64(len(plain)),
BlockSizes: []int64{int64(len(plain))},
Digests: map[string]string{"SHA1": hex.EncodeToString(digest[:])},
}
commit := proton.CommitRevisionReq{}
require.NoError(t, commit.SetEncXAttrString(addrKR, nodeKR, common))
f.XAttr = commit.XAttr
f.XAttrModificationTime, f.XAttrSize, f.XAttrBlockSizes, f.XAttrSHA1 = common.ModificationTime, common.Size, common.BlockSizes, common.Digests["SHA1"]
return f
}
// oldLibEmail is the address of the address key oldLibKeys generates.
const oldLibEmail = "test@proton.test"
// oldBlockSize is UPLOAD_BLOCK_SIZE, bridge constants.go:6.
const oldBlockSize = 4 << 20
// sdkFileKeys stands in for the client which created a crypto-refresh
// file before the old library revised it: a node key advertising
// SEIPDv2, locked with passphrase, and a v6 content key packet signed by
// it, as the SDK's generateNodeKeys and generateContentKey write them.
func sdkFileKeys(t *testing.T, passphrase string) (nodeKey, contentKeyPacket, contentKeyPacketSignature string) {
t.Helper()
p := profile.Default()
p.AeadEncryption = &packet.AEADConfig{DefaultMode: packet.AEADModeGCM}
key, err := crypto.PGPWithProfile(p).KeyGeneration().AddUserId("Drive key", "").New().GenerateKey()
require.NoError(t, err)
defer key.ClearPrivateParams()
locked, err := crypto.PGP().LockKey(key, []byte(passphrase))
require.NoError(t, err)
nodeKey, err = locked.Armor()
require.NoError(t, err)
token, err := crypto.RandomToken(32)
require.NoError(t, err)
sk := crypto.NewSessionKeyFromTokenWithAead(token, constants.AES256, true)
eh, err := crypto.PGP().Encryption().Recipient(key).New()
require.NoError(t, err)
keyPacket, err := eh.EncryptSessionKey(sk)
require.NoError(t, err)
sh, err := crypto.PGP().Sign().SigningKey(key).Detached().New()
require.NoError(t, err)
sig, err := sh.Sign(sk.Key, crypto.Armor)
require.NoError(t, err)
return nodeKey, base64.StdEncoding.EncodeToString(keyPacket), string(sig)
}
// writeOldLibStoredFile returns content stored as file name by the old
// library, in a folder it created.
//
// Without v6 it is a new file, which the old library always writes in
// the legacy format (bridge file_upload.go:87-257, 259-505). With v6 it
// is the old library's revision of a crypto-refresh file, whose v6
// content key selects SEIPDv2 blocks (bridge file_upload.go:229-252);
// sdkFileKeys stands in for the node key and content key of the client
// which created the file.
func writeOldLibStoredFile(t *testing.T, name string, content []byte, modTime time.Time, v6 bool) frozenFile {
t.Helper()
keys, parentKR, addrKR := oldLibKeys(t)
f := frozenFile{
Comment: "File written by go-proton-api v1.0.4 and Proton-API-Bridge v1.0.5 with test-only keys, as stored by the server.",
Email: oldLibEmail,
Keys: protontest.FixedKeys{
AddressKey: keys.AddressKey,
AddressKeyPassphrase: keys.AddressKeyPassphrase,
RootKey: keys.ParentKey,
RootKeyPassphrase: keys.ParentKeyPassphrase,
},
Name: name,
ModTime: modTime.Format("2006-01-02T15:04:05-0700"),
Content: content,
}
// The parent folder's hash key (bridge folder.go:83-136), read back
// as the bridge does before creating a file in it.
folder := proton.CreateFolderReq{}
require.NoError(t, folder.SetNodeHashKey(parentKR))
f.Keys.RootHashKey = folder.NodeHashKey
verifyKR, err := joinKeyRings(addrKR, parentKR)
require.NoError(t, err)
hashKey, err := proton.Link{Type: proton.LinkTypeFolder, FolderProperties: &proton.FolderProperties{NodeHashKey: folder.NodeHashKey}}.GetHashKey(parentKR, verifyKR)
require.NoError(t, err)
if v6 {
f.Comment = "Revision written by go-proton-api v1.0.4 and Proton-API-Bridge v1.0.5 with test-only keys, as stored by the server, " +
"of a crypto-refresh file: the node key, content key packet and its signature are SDK-style stand-ins made with plain gopenpgp; " +
"the old library wrote the node passphrase, name, hash, blocks, block signatures, manifest and extended attributes."
}
req := proton.CreateFileReq{MIMEType: "text/plain", SignatureAddress: oldLibEmail}
var passphrase string
if v6 {
passphrase = oldGeneratePassphrase(t)
req.NodeKey, req.ContentKeyPacket, req.ContentKeyPacketSignature = sdkFileKeys(t, passphrase)
req.NodePassphrase, req.NodePassphraseSignature = oldEncryptWithSignature(t, parentKR, addrKR, []byte(passphrase))
} else {
passphrase, req.NodeKey, req.NodePassphrase, req.NodePassphraseSignature = oldGenerateNodeKeys(t, parentKR, addrKR, false)
}
require.NoError(t, req.SetName(name, addrKR, parentKR))
require.NoError(t, req.SetHash(name, hashKey))
nodeKR := unlockedKeyRing(t, req.NodeKey, passphrase)
var sk *crypto.SessionKey
if v6 {
link := proton.Link{Type: proton.LinkTypeFile, FileProperties: &proton.FileProperties{ContentKeyPacket: req.ContentKeyPacket, ContentKeyPacketSignature: req.ContentKeyPacketSignature}}
sk, err = link.GetSessionKey(nodeKR)
} else {
sk, err = req.SetContentKeyPacketAndSignature(nodeKR)
}
require.NoError(t, err)
require.Equal(t, v6, sk.IsV6())
// uploadAndCollectBlockData, bridge file_upload.go:259-426.
var rev protontest.StoredRevision
var manifest []byte
blockSizes := make([]int64, 0)
for i := 0; i < len(content); i += oldBlockSize {
plain := content[i:min(i+oldBlockSize, len(content))]
data, encSig, hash := oldEncryptBlock(t, plain, sk, nodeKR, addrKR)
rev.Blocks = append(rev.Blocks, protontest.StoredBlock{Hash: hash, EncSignature: encSig, SignatureEmail: oldLibEmail, Data: data})
manifest = append(manifest, hash...)
blockSizes = append(blockSizes, int64(len(plain)))
}
// commitNewRevision, bridge file_upload.go:429-458, with the
// extended attributes of uploadFile, bridge file_upload.go:498-505.
digest := sha1.Sum(content)
commit := proton.CommitRevisionReq{ManifestSignature: oldSignManifest(t, manifest, addrKR), SignatureAddress: oldLibEmail}
require.NoError(t, commit.SetEncXAttrString(addrKR, nodeKR, &proton.RevisionXAttrCommon{
ModificationTime: f.ModTime,
Size: int64(len(content)),
BlockSizes: blockSizes,
Digests: map[string]string{"SHA1": hex.EncodeToString(digest[:])},
}))
rev.ManifestSignature, rev.XAttr, rev.SignatureEmail = commit.ManifestSignature, commit.XAttr, commit.SignatureAddress
f.Stored = protontest.StoredFile{
Link: protontest.StoredLink{
Name: req.Name,
NameHash: req.Hash,
NodeKey: req.NodeKey,
NodePassphrase: req.NodePassphrase,
NodePassphraseSignature: req.NodePassphraseSignature,
SignatureEmail: req.SignatureAddress,
NameSignatureEmail: req.SignatureAddress,
MediaType: req.MIMEType,
ContentKeyPacket: req.ContentKeyPacket,
ContentKeyPacketSignature: req.ContentKeyPacketSignature,
},
Revision: rev,
}
return f
}
// saveFrozenFile writes f to dir, as loadFrozenFile reads it.
func saveFrozenFile(t *testing.T, dir string, f frozenFile) {
t.Helper()
require.NoError(t, os.MkdirAll(dir, 0o755))
for name, v := range frozenFileParts(&f) {
data, err := json.MarshalIndent(v, "", "\t")
require.NoError(t, err)
require.NoError(t, os.WriteFile(filepath.Join(dir, name), append(data, '\n'), 0o644))
}
for i, b := range f.Stored.Revision.Blocks {
require.NoError(t, os.WriteFile(filepath.Join(dir, blockFileName(i)), b.Data, 0o644))
}
}
// Old library readers.
// oldDecryptBlock mirrors decryptBlockIntoBuffer, bridge crypto.go:218-280.
func oldDecryptBlock(sk *crypto.SessionKey, addrKR, nodeKR *crypto.KeyRing, originalHash, encSignature string, data []byte) ([]byte, error) {
skDec, err := crypto.PGP().Decryption().SessionKey(sk).New()
if err != nil {
return nil, err
}
skResult, err := skDec.Decrypt(data, crypto.Bytes)
if err != nil {
return nil, err
}
plain := skResult.Bytes()
encSignatureMsg, err := crypto.NewPGPMessageFromArmored(encSignature)
if err != nil {
return nil, err
}
sigDec, err := crypto.PGP().Decryption().DecryptionKeys(nodeKR).New()
if err != nil {
return nil, err
}
sigResult, err := sigDec.Decrypt(encSignatureMsg.Bytes(), crypto.Bytes)
if err != nil {
return nil, err
}
vh, err := crypto.PGP().Verify().VerificationKeys(addrKR).New()
if err != nil {
return nil, err
}
verifyResult, err := vh.VerifyDetached(plain, sigResult.Bytes(), crypto.Bytes)
if err != nil {
return nil, err
}
if err := verifyResult.SignatureError(); err != nil {
return nil, err
}
hash := sha256.Sum256(data)
if base64.StdEncoding.EncodeToString(hash[:]) != originalHash {
return nil, errors.New("block hash mismatch")
}
return plain, nil
}
// requireFingerprints checks that got holds the keys of want, in order.
func requireFingerprints(t *testing.T, want, got *crypto.KeyRing) {
t.Helper()
var wantFPs, gotFPs []string
for _, k := range want.GetKeys() {
wantFPs = append(wantFPs, k.GetFingerprint())
}
for _, k := range got.GetKeys() {
gotFPs = append(gotFPs, k.GetFingerprint())
}
require.NotEmpty(t, wantFPs)
require.Equal(t, wantFPs, gotFPs)
}
func TestNewWriterOldReader(t *testing.T) {
now := time.Now()
parentKR, addrKR, otherParentKR := newTestKeyRing(t, now), newTestKeyRing(t, now), newTestKeyRing(t, now)
addrPub := publicOf(t, addrKR)
t.Run("Folder", func(t *testing.T) {
armoredKey, encPassphrase, passphraseSig, keys := testNodeKeys(t, parentKR, addrKR, false, now)
const name = "Fichiers d'été 📁"
encName, err := encryptName(name, nil, parentKR, addrKR, now)
require.NoError(t, err)
encHashKey, hashKey, err := generateHashKey(keys.keyRing, now)
require.NoError(t, err)
link := proton.Link{
Type: proton.LinkTypeFolder,
Name: encName,
NodeKey: armoredKey,
NodePassphrase: encPassphrase,
NodePassphraseSignature: passphraseSig,
FolderProperties: &proton.FolderProperties{NodeHashKey: encHashKey},
}
nodeKR, err := link.GetKeyRing(parentKR, addrPub)
require.NoError(t, err)
requireFingerprints(t, keys.keyRing, nodeKR)
gotName, err := link.GetName(parentKR, addrPub)
require.NoError(t, err)
assert.Equal(t, name, gotName)
// The bridge verifies hash keys against the signer's address keys
// and the folder's node key (folder.go:116-120).
verifyKR, err := joinKeyRings(addrPub, nodeKR)
require.NoError(t, err)
gotHashKey, err := link.GetHashKey(nodeKR, verifyKR)
require.NoError(t, err)
assert.Equal(t, hashKey, gotHashKey)
oldHash, err := proton.GetNameHash("Document.txt", gotHashKey)
require.NoError(t, err)
assert.Equal(t, nameHash("Document.txt", hashKey), oldHash)
// A move re-encrypts the passphrase and the name to the new
// parent with their session keys.
link.NodePassphrase, link.NodePassphraseSignature, err = reencryptPassphrase(keys, otherParentKR, addrKR, now)
require.NoError(t, err)
sk, err := nameSessionKey(encName, parentKR)
require.NoError(t, err)
link.Name, err = encryptName("Moved", sk, otherParentKR, addrKR, now)
require.NoError(t, err)
nodeKR, err = link.GetKeyRing(otherParentKR, addrPub)
require.NoError(t, err)
requireFingerprints(t, keys.keyRing, nodeKR)
gotName, err = link.GetName(otherParentKR, addrPub)
require.NoError(t, err)
assert.Equal(t, "Moved", gotName)
})
t.Run("File", func(t *testing.T) {
armoredKey, encPassphrase, passphraseSig, keys := testNodeKeys(t, parentKR, addrKR, true, now)
const name = "Résumé – final.pdf"
encName, err := encryptName(name, nil, parentKR, addrKR, now)
require.NoError(t, err)
contentKeyPacket, contentKeyPacketSig, sk, err := generateContentKey(keys.keyRing, now)
require.NoError(t, err)
link := proton.Link{
Type: proton.LinkTypeFile,
Name: encName,
NodeKey: armoredKey,
NodePassphrase: encPassphrase,
NodePassphraseSignature: passphraseSig,
FileProperties: &proton.FileProperties{
ContentKeyPacket: contentKeyPacket,
ContentKeyPacketSignature: contentKeyPacketSig,
},
}
nodeKR, err := link.GetKeyRing(parentKR, addrPub)
require.NoError(t, err)
requireFingerprints(t, keys.keyRing, nodeKR)
gotName, err := link.GetName(parentKR, addrPub)
require.NoError(t, err)
assert.Equal(t, name, gotName)
gotSK, err := link.GetSessionKey(nodeKR)
require.NoError(t, err)
assert.Equal(t, sk.Key, gotSK.Key)
// The content written for a new file - a v6 content key packet and
// SEIPDv2 blocks and block signatures - is read by the old
// library's block reader.
plain := make([]byte, 1024)
_, err = rand.Read(plain)
require.NoError(t, err)
block, encSig, hash, err := encryptBlock(plain, sk, keys.keyRing, addrKR, now)
require.NoError(t, err)
rawKeyPacket, err := base64.StdEncoding.DecodeString(contentKeyPacket)
require.NoError(t, err)
require.Equal(t, 6, readPackets(t, rawKeyPacket, 1)[0].(*packet.EncryptedKey).Version)
require.Equal(t, 2, readPackets(t, block, 1)[0].(*packet.SymmetricallyEncrypted).Version)
require.Equal(t, 2, readPackets(t, unarmor(t, encSig), 2)[1].(*packet.SymmetricallyEncrypted).Version)
gotPlain, err := oldDecryptBlock(gotSK, addrPub, nodeKR, base64.StdEncoding.EncodeToString(hash[:]), encSig, block)
require.NoError(t, err)
assert.Equal(t, plain, gotPlain)
// Extended attributes, read as the bridge does (file.go:47-91).
digest := sha1.Sum(plain)
modTime := time.Date(2024, 3, 5, 7, 7, 8, 123456789, time.FixedZone("CET", 3600))
encXAttr, err := encryptXAttrs(&xattrs{ModTime: modTime, Size: 1024, BlockSizes: []int64{1024}, SHA1: hex.EncodeToString(digest[:])}, keys.keyRing, addrKR, now)
require.NoError(t, err)
rev := proton.RevisionMetadata{XAttr: encXAttr}
common, err := rev.GetDecXAttrString(addrPub, nodeKR)
require.NoError(t, err)
require.NotNil(t, common)
gotModTime, err := iso8601.ParseString(common.ModificationTime)
require.NoError(t, err)
assert.True(t, modTime.Truncate(time.Millisecond).Equal(gotModTime), "%v", gotModTime)
assert.Equal(t, int64(1024), common.Size)
assert.Equal(t, []int64{1024}, common.BlockSizes)
assert.Equal(t, hex.EncodeToString(digest[:]), common.Digests["SHA1"])
})
}
func TestOldWriterNewReader(t *testing.T) {
t.Run("Folder", func(t *testing.T) {
checkOldLibFixture(t, writeOldLibFolder(t), time.Time{})
})
t.Run("File", func(t *testing.T) {
checkOldLibFixture(t, writeOldLibFile(t), time.Time{})
})
t.Run("ShareKey", func(t *testing.T) {
// A share key is a node key whose passphrase is encrypted to and
// signed by the address key; proton.Share.GetKeyRing reads it.
_, _, addrKR := oldLibKeys(t)
passphrase, shareKey, encPassphrase, passphraseSig := oldGenerateNodeKeys(t, addrKR, addrKR, false)
share := proton.Share{Key: shareKey, Passphrase: encPassphrase, PassphraseSignature: passphraseSig}
oldKR, err := share.GetKeyRing(addrKR)
require.NoError(t, err)
keys, verifyErr, err := decryptNodeKeys(addrKR, publicOf(t, addrKR), shareKey, encPassphrase, passphraseSig, time.Time{})
require.NoError(t, err)
assert.NoError(t, verifyErr)
assert.Equal(t, passphrase, string(keys.passphrase))
requireFingerprints(t, oldKR, keys.keyRing)
})
t.Run("CryptoRefreshNodeKey", func(t *testing.T) {
// The bridge can also generate a v6 node key locked with the
// RFC 9580 profile (bridge crypto.go:53-69 with aead set).
_, parentKR, addrKR := oldLibKeys(t)
passphrase, nodeKey, encPassphrase, passphraseSig := oldGenerateNodeKeys(t, parentKR, addrKR, true)
keys, verifyErr, err := decryptNodeKeys(parentKR, publicOf(t, addrKR), nodeKey, encPassphrase, passphraseSig, time.Time{})
require.NoError(t, err)
assert.NoError(t, verifyErr)
assert.Equal(t, passphrase, string(keys.passphrase))
assert.Equal(t, 6, keys.keyRing.GetKeys()[0].GetVersion())
})
}
func TestKeyUnlockMatchesOldLibrary(t *testing.T) {
srv := protontest.New(t)
u := srv.AddUser("user", "pass")
u.AddAddress("alias@proton.test")
s := newAccountSession(srv, u)
ctx := context.Background()
a, err := loadAccount(ctx, s, []byte(u.KeyPassword()))
require.NoError(t, err)
defer a.close()
// The same responses, decoded as the old library's types.
var user struct{ User proton.User }
_, err = s.call(ctx, &rest.Opts{Method: "GET", Path: "/core/v4/users"}, nil, &user)
require.NoError(t, err)
var addrs struct{ Addresses []proton.Address }
_, err = s.call(ctx, &rest.Opts{Method: "GET", Path: "/core/v4/addresses"}, nil, &addrs)
require.NoError(t, err)
userKR, addrKRs, err := proton.Unlock(user.User, addrs.Addresses, []byte(u.KeyPassword()), nil)
require.NoError(t, err)
requireFingerprints(t, userKR, a.userKR)
require.Len(t, a.addresses, len(addrKRs))
for _, addr := range a.addresses {
require.Contains(t, addrKRs, addr.ID)
requireFingerprints(t, addrKRs[addr.ID], addr.keyRing)
}
}
func TestFrozenFixtures(t *testing.T) {
if *updateOldLib {
require.NoError(t, os.MkdirAll(oldLibDir, 0o755))
for name, f := range map[string]oldLibFixture{"folder": writeOldLibFolder(t), "file": writeOldLibFile(t)} {
data, err := json.MarshalIndent(f, "", "\t")
require.NoError(t, err)
require.NoError(t, os.WriteFile(filepath.Join(oldLibDir, name+".json"), append(data, '\n'), 0o644))
}
}
entries, err := os.ReadDir(oldLibDir)
require.NoError(t, err)
var n int
for _, e := range entries {
name, ok := strings.CutSuffix(e.Name(), ".json")
if !ok {
continue
}
n++
t.Run(name, func(t *testing.T) {
checkOldLibFixture(t, loadOldLibFixture(t, name), time.Time{})
})
}
assert.Equal(t, 2, n, "fixtures in %s", oldLibDir)
}
func TestUploadReadableByOldLibraryCrypto(t *testing.T) {
ctx := context.Background()
c, _, u := newUploadClient(t, clientOptions{})
content := testContent(5<<20 + 3)
n := uploadBytes(t, c, "Résumé – final.pdf", content)
stored := u.StoredFile(n.ID)
// The account, share and root folder keys, unlocked by the old
// library from the same responses.
var user struct{ User proton.User }
_, err := c.s.call(ctx, &rest.Opts{Method: "GET", Path: "/core/v4/users"}, nil, &user)
require.NoError(t, err)
var addrs struct{ Addresses []proton.Address }
_, err = c.s.call(ctx, &rest.Opts{Method: "GET", Path: "/core/v4/addresses"}, nil, &addrs)
require.NoError(t, err)
var my api.MyFilesResponse
_, err = c.s.call(ctx, &rest.Opts{Method: "GET", Path: "/drive/v2/shares/my-files"}, nil, &my)
require.NoError(t, err)
_, addrKRs, err := proton.Unlock(user.User, addrs.Addresses, []byte(u.KeyPassword()), nil)
require.NoError(t, err)
addrKR := addrKRs[my.Share.AddressID]
require.NotNil(t, addrKR)
require.Equal(t, u.Email(), stored.Link.SignatureEmail)
shareKR, err := proton.Share{Key: my.Share.Key, Passphrase: my.Share.Passphrase, PassphraseSignature: my.Share.PassphraseSignature}.GetKeyRing(addrKR)
require.NoError(t, err)
root := my.Link.Link
rootKR, err := proton.Link{NodeKey: root.NodeKey, NodePassphrase: root.NodePassphrase, NodePassphraseSignature: root.NodePassphraseSignature}.GetKeyRing(shareKR, addrKR)
require.NoError(t, err)
// The file, read as DownloadFile and the block reader do (bridge
// file_download.go:60-80, 100-125).
link := proton.Link{
Type: proton.LinkTypeFile,
Name: stored.Link.Name,
NodeKey: stored.Link.NodeKey,
NodePassphrase: stored.Link.NodePassphrase,
NodePassphraseSignature: stored.Link.NodePassphraseSignature,
FileProperties: &proton.FileProperties{
ContentKeyPacket: stored.Link.ContentKeyPacket,
ContentKeyPacketSignature: stored.Link.ContentKeyPacketSignature,
},
}
nodeKR, err := link.GetKeyRing(rootKR, addrKR)
require.NoError(t, err)
name, err := link.GetName(rootKR, addrKR)
require.NoError(t, err)
assert.Equal(t, "Résumé – final.pdf", name)
sk, err := link.GetSessionKey(nodeKR)
require.NoError(t, err)
blockVerifyKR, err := joinKeyRings(addrKR, nodeKR)
require.NoError(t, err)
require.Len(t, stored.Revision.Blocks, 2)
var got, manifest []byte
for i, b := range stored.Revision.Blocks {
plain, err := oldDecryptBlock(sk, blockVerifyKR, nodeKR, base64.StdEncoding.EncodeToString(b.Hash), b.EncSignature, b.Data)
require.NoError(t, err, "block %d", i+1)
got = append(got, plain...)
manifest = append(manifest, b.Hash...)
}
assert.True(t, bytes.Equal(content, got), "content differs")
// The old library never checks the manifest; plain gopenpgp does.
vh, err := crypto.PGP().Verify().VerificationKeys(addrKR).New()
require.NoError(t, err)
res, err := vh.VerifyDetached(manifest, []byte(stored.Revision.ManifestSignature), crypto.Armor)
require.NoError(t, err)
assert.NoError(t, res.SignatureError(), "manifest signature")
// Extended attributes, read as the bridge does (file.go:47-91).
rev := proton.RevisionMetadata{XAttr: stored.Revision.XAttr}
common, err := rev.GetDecXAttrString(addrKR, nodeKR)
require.NoError(t, err)
require.NotNil(t, common)
assert.Equal(t, sha1Of(content), common.Digests["SHA1"])
assert.Equal(t, int64(len(content)), common.Size)
assert.Equal(t, []int64{4 << 20, 1<<20 + 3}, common.BlockSizes)
modTime, err := iso8601.ParseString(common.ModificationTime)
require.NoError(t, err)
assert.True(t, testModTime.Equal(modTime), "%v", modTime)
}
// frozenFileName and frozenFileSize are the name and size of the files
// in frozenFileDir.
const (
frozenFileName = "Résumé – final.txt"
frozenFileSize = 2500
)
func TestOldLibraryFileReadable(t *testing.T) {
modTime := time.Date(2023, 11, 20, 9, 30, 15, 0, time.FixedZone("", 5*3600+30*60))
for _, format := range []string{"legacy", "v6"} {
t.Run(format, func(t *testing.T) {
v6 := format == "v6"
for _, size := range []int{0, frozenFileSize, 5<<20 + 3} {
t.Run(strconv.Itoa(size), func(t *testing.T) {
checkFrozenFile(t, writeOldLibStoredFile(t, frozenFileName, testContent(size), modTime, v6))
})
}
// A file of one block is frozen, as the larger one is too big
// to keep.
if *updateOldLib {
f := writeOldLibStoredFile(t, frozenFileName, testContent(frozenFileSize), modTime, v6)
saveFrozenFile(t, filepath.Join(frozenFileDir, format), f)
}
})
}
}
+143
View File
@@ -11,6 +11,8 @@ import (
"encoding/hex"
"encoding/json"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -865,3 +867,144 @@ func TestServerTimeUsedForSignatures(t *testing.T) {
require.NoError(t, err)
assert.NoError(t, verifyErr)
}
// oldLibDir holds nodes the old library (go-proton-api v1.0.4 and
// Proton-API-Bridge v1.0.5) wrote, so that they stay readable.
const oldLibDir = "testdata/oldlib"
// oldLibFixture is a node written by the old library with the test-only
// keys it was written with and the plaintexts it encrypted.
type oldLibFixture struct {
Comment string
Type string // "folder" or "file"
ParentKey string // armored parent node key, locked with ParentKeyPassphrase
ParentKeyPassphrase string
AddressKey string // armored address key which signs, locked with AddressKeyPassphrase
AddressKeyPassphrase string
NodeKey string
NodePassphrase string // encrypted to the parent key
NodePassphraseSignature string // by the address key
NodePassphrasePlain string
Name string // encrypted to the parent key, signed by the address key
NamePlain string
// A folder's hash key, encrypted to and signed by its node key, and
// the hash of ChildName with it.
NodeHashKey string `json:",omitempty"`
HashKeyPlain string `json:",omitempty"` // base64 of the raw bytes
ChildName string `json:",omitempty"`
ChildNameHash string `json:",omitempty"`
// A file's content key, signed by its node key, and one block of
// content with its signature by the address key, encrypted to the
// node key, and the manifest signature by the address key.
ContentKeyPacket string `json:",omitempty"` // base64, encrypted to the node key
ContentKeyPacketSignature string `json:",omitempty"`
ContentSessionKey string `json:",omitempty"` // hex
Block string `json:",omitempty"` // base64
BlockPlain string `json:",omitempty"` // base64
BlockEncSignature string `json:",omitempty"`
BlockHash string `json:",omitempty"` // base64 SHA-256 of Block
ManifestSignature string `json:",omitempty"`
XAttr string `json:",omitempty"` // encrypted to the node key, signed by the address key
XAttrModificationTime string `json:",omitempty"` // as the old library wrote it
XAttrSize int64 `json:",omitempty"`
XAttrBlockSizes []int64 `json:",omitempty"`
XAttrSHA1 string `json:",omitempty"`
}
// loadOldLibFixture returns the fixture testdata/oldlib/name.json.
func loadOldLibFixture(t *testing.T, name string) oldLibFixture {
t.Helper()
data, err := os.ReadFile(filepath.Join(oldLibDir, name+".json"))
require.NoError(t, err)
var f oldLibFixture
require.NoError(t, json.Unmarshal(data, &f))
return f
}
// unlockedKeyRing returns a key ring of the armored key unlocked with
// passphrase.
func unlockedKeyRing(t *testing.T, armored, passphrase string) *crypto.KeyRing {
t.Helper()
key, err := unlockKey(armored, []byte(passphrase))
require.NoError(t, err)
kr, err := crypto.NewKeyRing(key)
require.NoError(t, err)
return kr
}
// checkOldLibFixture reads f with crypto.go and checks every value
// against its plaintext and every signature against its signer.
func checkOldLibFixture(t *testing.T, f oldLibFixture, now time.Time) {
t.Helper()
parentKR := unlockedKeyRing(t, f.ParentKey, f.ParentKeyPassphrase)
addrPub := publicOf(t, unlockedKeyRing(t, f.AddressKey, f.AddressKeyPassphrase))
keys, verifyErr, err := decryptNodeKeys(parentKR, addrPub, f.NodeKey, f.NodePassphrase, f.NodePassphraseSignature, now)
require.NoError(t, err)
assert.NoError(t, verifyErr, "node passphrase signature")
assert.Equal(t, f.NodePassphrasePlain, string(keys.passphrase))
nodeKR := keys.keyRing
name, verifyErr, err := decryptName(f.Name, parentKR, addrPub, now)
require.NoError(t, err)
assert.NoError(t, verifyErr, "name signature")
assert.Equal(t, f.NamePlain, name)
switch f.Type {
case "folder":
hashKey, verifyErr, err := decryptHashKey(f.NodeHashKey, nodeKR, nil, now)
require.NoError(t, err)
assert.NoError(t, verifyErr, "hash key signature")
assert.Equal(t, f.HashKeyPlain, base64.StdEncoding.EncodeToString(hashKey))
assert.Equal(t, f.ChildNameHash, nameHash(f.ChildName, hashKey))
case "file":
sk, verifyErr, err := decryptContentKey(f.ContentKeyPacket, f.ContentKeyPacketSignature, nodeKR, nil, now)
require.NoError(t, err)
assert.NoError(t, verifyErr, "content key signature")
assert.Equal(t, f.ContentSessionKey, hex.EncodeToString(sk.Key))
block, err := base64.StdEncoding.DecodeString(f.Block)
require.NoError(t, err)
plain, err := decryptBlock(block, sk)
require.NoError(t, err)
assert.Equal(t, f.BlockPlain, base64.StdEncoding.EncodeToString(plain))
hash := sha256.Sum256(block)
assert.Equal(t, f.BlockHash, base64.StdEncoding.EncodeToString(hash[:]))
assert.NoError(t, decryptBlockSignature(t, f.BlockEncSignature, plain, nodeKR, addrPub), "block signature")
assert.NoError(t, verifyManifest([][]byte{hash[:]}, f.ManifestSignature, addrPub, now))
x, verifyErr, err := decryptXAttrs(f.XAttr, nodeKR, addrPub, now)
require.NoError(t, err)
assert.NoError(t, verifyErr, "extended attributes signature")
modTime, err := time.Parse("2006-01-02T15:04:05-0700", f.XAttrModificationTime)
require.NoError(t, err)
assert.True(t, modTime.Equal(x.ModTime), "%v != %v", modTime, x.ModTime)
assert.Equal(t, f.XAttrSize, x.Size)
assert.Equal(t, f.XAttrBlockSizes, x.BlockSizes)
assert.Equal(t, f.XAttrSHA1, x.SHA1)
default:
t.Fatalf("unknown fixture type %q", f.Type)
}
}
func TestFrozenFixtures(t *testing.T) {
entries, err := os.ReadDir(oldLibDir)
require.NoError(t, err)
var n int
for _, e := range entries {
name, ok := strings.CutSuffix(e.Name(), ".json")
if !ok {
continue
}
n++
t.Run(name, func(t *testing.T) {
checkOldLibFixture(t, loadOldLibFixture(t, name), time.Time{})
})
}
assert.Equal(t, 2, n, "fixtures in %s", oldLibDir)
}
-3
View File
@@ -70,7 +70,4 @@ A native client of the Proton Drive API, following Proton's Drive SDK
## Known rough edges
- `backend/protondrive` tests still import `github.com/rclone/go-proton-api`
for differential crypto tests, and `go.mod` still lists
`github.com/rclone/Proton-API-Bridge`; both go in plan Task 19.
- See `docs/TODO.md` for host tooling gaps.
+16
View File
@@ -5,6 +5,22 @@ it with a new one that links back. One entry per decision a future maintainer
would otherwise have to reverse-engineer.
No index: `grep -n '^## ' docs/DECISIONS.md` lists every entry.
## 2026-11-12 - Proton Drive keeps frozen old-library fixtures, not the old library
**Status:** accepted
**Context:** `go-proton-api` v1.0.4 and `Proton-API-Bridge` v1.0.5 stayed
in `go.mod` only for differential tests checking that the new client and
the old library read each other's output (plan Task 19).
**Decision:** The differential tests and both libraries were removed. What
the old library wrote stays checked through the frozen fixtures in
`backend/protondrive/testdata/oldlib/` (`TestFrozenFixtures`,
`TestFrozenFileFixture`), which can no longer be regenerated.
**Alternatives:** Keeping the libraries as test-only requirements (they
would stay in `go.mod` and its vulnerability surface, and upstream would
carry them).
**Consequences:** Multi-block and empty old-library files, and old-reader
compatibility of new writes, are no longer tested (`docs/TODO.md`).
## 2026-11-12 - Test-account usernames removed from git history by force-push
**Status:** accepted
+19 -19
View File
@@ -6,9 +6,9 @@ Small, specific, independently actionable. Remove only when actually done.
## Now
- [ ] protondrive: the backend rewrite (plan 001) has Tasks 1-18 of 21
- [ ] protondrive: the backend rewrite (plan 001) has Tasks 1-19 of 21
done. Start from `docs/plans/001-protondrive-tier1-HANDOFF.md`.
Remaining: Task 19 (remove the old libraries), 20 (user docs - `docs/content/protondrive.md` still describes
Remaining: Task 20 (user docs - `docs/content/protondrive.md` still describes
the old backend), 21 (live verification, paid account never signed in to),
then the whole-branch review; later plans for M5 (events) and M6
(persistent cache, Metadata, small-file upload).
@@ -79,20 +79,18 @@ Small, specific, independently actionable. Remove only when actually done.
read crypto-refresh files and that the server rejects a v6 content key
packet for a v4 node key; the bridge never sent one with a key advertising
SEIPDv2, so its claim may not apply. Released rclone itself is not the
problem: Task 6's `TestNewWriterOldReader` shows go-proton-api v1.0.4 and
the bridge's block reader read this format (v6 PKESK, SEIPDv2 blocks and
block signatures) and the new `toISOString` modification time.
- [ ] protondrive: when go-proton-api and Proton-API-Bridge leave `go.mod`
(plan Task 19), delete the old-library parts of
`backend/protondrive/crypto_diff_internal_test.go` and the `-update`
branch of `TestFrozenFixtures`, keeping the fixtures and their check;
`github.com/relvacode/iso8601`, made a direct requirement for that test,
goes back to indirect or away. `TestFrozenFileFixture`
(`download_internal_test.go`, fixtures in `testdata/oldlib/file/`) stays
as it is; its regeneration lives in `TestOldLibraryFileReadable`, which
goes with the differential file. Only the frozen file fixtures are a
single block: multi-block files from the old library are covered by
`TestOldLibraryFileReadable` alone, so they lose coverage then.
problem: Task 6's `TestNewWriterOldReader` (removed with the old
libraries in Task 19) showed go-proton-api v1.0.4 and the bridge's block
reader read this format (v6 PKESK, SEIPDv2 blocks and block signatures)
and the new `toISOString` modification time.
- [ ] protondrive: files the old library (go-proton-api v1.0.4 and
Proton-API-Bridge v1.0.5) wrote are covered only by the single-block
frozen fixtures in `backend/protondrive/testdata/oldlib/`
(`TestFrozenFixtures`, `TestFrozenFileFixture`); the multi-block and
empty-file cases left with the differential tests in Task 19. A
multi-block fixture would need the old library back to write it. Nothing
checks any more that released rclone (v1.76 and older) can read what the
new backend writes; that matters to users who downgrade.
- [ ] protondrive: `client.apiCall` retries POST and PUT mutations
(create folder, move, rename, copy) after 5xx and network errors, as the
@@ -150,9 +148,6 @@ Small, specific, independently actionable. Remove only when actually done.
an Actions runner (slow, so the workflow is kept light: 20 s per fuzz
target, a 45 minute job timeout, no `test_all`); the first push must
confirm the runner picks the workflow up.
- [ ] protondrive: `go mod tidy` would drop `github.com/rclone/Proton-API-Bridge`
from `go.mod` now (no Go file imports it; the differential tests import
only `go-proton-api`). Left in place for Task 19, which removes both.
- [ ] protondrive: browser sign-in in `rclone config` waits up to 10 minutes
polling; a non-interactive `config create` without username and password
waits the same.
@@ -177,6 +172,11 @@ Small, specific, independently actionable. Remove only when actually done.
fails only `cmd/serve/s3`, unrelated to this work and undiagnosed (seen
2026-11-11 before the Proton Drive rewrite began). That is the baseline
for plan Task 19's "no new failures"; the container list below is not.
- [ ] On the development host on 2026-11-12, `cmd/bisync` `TestRcBackupDir`
(`backupDir1`, `backupDir2`) fails every run with "unable to find file
.../backup/file0.txt", also at `06700aaf2` (before the Proton Drive
rewrite), so it is not caused by that work; undiagnosed. `cmd/serve/s3`
passed in the same full run.
- [ ] `RCLONE_CONFIG=/notfound go test ./...` in the bare `golang:1.26`
container fails 7 packages (list in `docs/PERMISSIONS.md`). `fs/logger`
needs `rsync`; the mount, serve and smb failures are undiagnosed and are
+4 -5
View File
@@ -60,9 +60,8 @@ What exists now:
- `backend/protondrive/protondrive.go` is rewritten on the native client
(`session.go`, `account.go`, `crypto.go`, `nodes.go`, `cache.go`,
`download.go`, `upload.go`). The old bridge libraries are no longer used
by the backend; `go-proton-api` is still imported by the differential
tests and both remain in `go.mod` until Task 19.
`download.go`, `upload.go`). The old bridge libraries left `go.mod` in
Task 19, with the differential tests that used them.
- `backend/protondrive/protontest` is the fake Proton server (fork-only),
with `protontest/cmd/protonfake` as a standalone binary.
- rclone's backend contract suite (`fstests.Run`) passes against the fake
@@ -72,8 +71,8 @@ What exists now:
(harness), `regression_test.go` (16 user-reported failures),
`security_test.go` (27 hostile-server cases, secret hygiene),
`budget_test.go` (request budgets), `fuzz_internal_test.go` (5 targets),
`crypto_diff_internal_test.go` (differential against the old libraries),
frozen fixtures in `testdata/oldlib/`.
frozen old-library fixtures in `testdata/oldlib/` (`TestFrozenFixtures`,
`TestFrozenFileFixture`).
- Verified at `9c90998f7` on 2026-11-11: `go build ./...`,
`go vet ./backend/protondrive/...`,
`golangci-lint run ./backend/protondrive/...` (0 issues) and
+4 -4
View File
@@ -1297,13 +1297,13 @@ allocated in `PORTS.md`, echo its `key=value` lines and
> is no longer the old backend, `TestFrozenFileFixture` has already moved,
> and `docs/TODO.md` has the accurate list of what to keep and delete.
- [ ] **Step 1:** Record the current binary size:
- [x] **Step 1:** Record the current binary size:
`git stash -u` is not allowed - instead build `master` in a worktree:
`git worktree add ../rclone-master master && (cd ../rclone-master && go build -o /tmp/rclone-old .)`,
note `stat -c %s /tmp/rclone-old`, then `git worktree remove ../rclone-master`.
- [ ] **Step 2:** Move the frozen-fixture tests, delete the differential
- [x] **Step 2:** Move the frozen-fixture tests, delete the differential
file, `go mod tidy`.
- [ ] **Step 3: Verify:**
- [x] **Step 3: Verify:**
`grep -c 'Proton-API-Bridge\|rclone/go-proton-api\|replace' go.mod`
prints 0; `go build -o /tmp/rclone-new . && stat -c %s /tmp/rclone-new`
is not larger than the old size; `go test -race ./backend/protondrive/...`
@@ -1311,7 +1311,7 @@ allocated in `PORTS.md`, echo its `key=value` lines and
`Makefile` target `compile_all` for the exact flags) succeeds for all
targets; `RCLONE_CONFIG=/notfound go test ./...` has no new failures
compared with the list in `docs/TODO.md`.
- [ ] **Step 4: Commit** `protondrive: drop Proton-API-Bridge and go-proton-api`.
- [x] **Step 4: Commit** `protondrive: drop Proton-API-Bridge and go-proton-api`.
### Task 20: Documentation
-11
View File
@@ -76,10 +76,7 @@ require (
github.com/prometheus/client_golang v1.25.0
github.com/putdotio/go-putio/putio v0.0.0-20200123120452-16d982cac2b8
github.com/quasilyte/go-ruleguard/dsl v0.3.23
github.com/rclone/Proton-API-Bridge v1.0.5
github.com/rclone/go-proton-api v1.0.4
github.com/rclone/gofakes3 v0.0.12
github.com/relvacode/iso8601 v1.7.0
github.com/rfjakob/eme v1.2.0
github.com/rivo/uniseg v0.4.7
github.com/rogpeppe/go-internal v1.16.0
@@ -123,13 +120,10 @@ require (
github.com/Azure/azure-sdk-for-go/sdk/storage/internal v0.1.0 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.10.1 // indirect
github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf // indirect
github.com/ProtonMail/gluon v0.17.1-0.20230724134000-308be39be96e // indirect
github.com/PuerkitoBio/goquery v1.12.0 // indirect
github.com/STARRY-S/zip v0.2.3 // indirect
github.com/akavel/rsrc v0.10.2 // indirect
github.com/anchore/go-lzo v0.1.1 // indirect
github.com/andybalholm/brotli v1.2.7 // indirect
github.com/andybalholm/cascadia v1.3.4 // indirect
github.com/appscode/go-querystring v0.0.0-20170504095604-0126cfb3f1dc // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.22 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.3 // indirect
@@ -149,7 +143,6 @@ require (
github.com/bodgit/sevenzip v1.6.5 // indirect
github.com/bodgit/windows v1.0.1 // indirect
github.com/boombuler/barcode v1.1.0 // indirect
github.com/bradenaw/juniper v0.15.3 // indirect
github.com/buger/jsonparser v1.2.0 // indirect
github.com/calebcase/tmpfile v1.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
@@ -165,8 +158,6 @@ require (
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.11.1 // indirect
github.com/emersion/go-message v0.18.2 // indirect
github.com/emersion/go-vcard v0.0.0-20260618161152-d854b7e0e2d3 // indirect
github.com/felixge/httpsnoop v1.1.0 // indirect
github.com/flynn/noise v1.1.0 // indirect
github.com/gdamore/encoding v1.0.1 // indirect
@@ -179,7 +170,6 @@ require (
github.com/go-playground/locales v0.14.2 // indirect
github.com/go-playground/universal-translator v0.18.2 // indirect
github.com/go-playground/validator/v10 v10.30.5 // indirect
github.com/go-resty/resty/v2 v2.17.2 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/goccy/go-json v0.10.6 // indirect
github.com/gofrs/flock v0.13.1 // indirect
@@ -237,7 +227,6 @@ require (
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 // indirect
github.com/samber/lo v1.53.0 // indirect
github.com/shabbyrobe/gocovmerge v0.0.0-20230507112040-c3350d9342df // indirect
github.com/sirupsen/logrus v1.10.2 // indirect
github.com/smartystreets/goconvey v1.8.1 // indirect
github.com/sony/gobreaker/v2 v2.4.0 // indirect
github.com/sorairolake/lzip-go v0.3.8 // indirect
-20
View File
@@ -44,8 +44,6 @@ github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA
github.com/ProtonMail/bcrypt v0.0.0-20210511135022-227b4adcab57/go.mod h1:HecWFHognK8GfRDGnFQbW/LiV7A3MX3gZVs45vk5h8I=
github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf h1:yc9daCCYUefEs69zUkSzubzjBbL+cmOXgnmt9Fyd9ug=
github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf/go.mod h1:o0ESU9p83twszAU8LBeJKFAAMX14tISa0yk4Oo5TOqo=
github.com/ProtonMail/gluon v0.17.1-0.20230724134000-308be39be96e h1:lCsqUUACrcMC83lg5rTo9Y0PnPItE61JSfvMyIcANwk=
github.com/ProtonMail/gluon v0.17.1-0.20230724134000-308be39be96e/go.mod h1:Og5/Dz1MiGpCJn51XujZwxiLG7WzvvjE5PRpZBQmAHo=
github.com/ProtonMail/go-crypto v0.0.0-20230321155629-9a39f2531310/go.mod h1:8TI4H3IbrackdNgv+92dI+rhpCaLqM0IfpgCgenFvRE=
github.com/ProtonMail/go-crypto v1.5.2 h1:cucYnvqcY7UOXVD//mSyjeaPY0SSN3v5cDkYPxumINk=
github.com/ProtonMail/go-crypto v1.5.2/go.mod h1:/RaSu30DaKO4RY+XdV/ACcCcZkGr7AhUIduq5sjzzCo=
@@ -53,8 +51,6 @@ github.com/ProtonMail/go-srp v0.0.7 h1:Sos3Qk+th4tQR64vsxGIxYpN3rdnG9Wf9K4ZloC1J
github.com/ProtonMail/go-srp v0.0.7/go.mod h1:giCp+7qRnMIcCvI6V6U3S1lDDXDQYx2ewJ6F/9wdlJk=
github.com/ProtonMail/gopenpgp/v3 v3.4.1 h1:K7uUhSHSJxORZ+RuHpilTT6S4MA2whCRlXNwLqd0+ys=
github.com/ProtonMail/gopenpgp/v3 v3.4.1/go.mod h1:bGdV9f6edhmd581wzXsQCTKdH8bXBbyhkgDKPjwPc6U=
github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=
github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ=
github.com/STARRY-S/zip v0.2.3 h1:luE4dMvRPDOWQdeDdUxUoZkzUIpTccdKdhHHsQJ1fm4=
github.com/STARRY-S/zip v0.2.3/go.mod h1:lqJ9JdeRipyOQJrYSOtpNAiaesFO6zVDsE8GIGFaoSk=
github.com/a1ex3/zstd-seekable-format-go/pkg v0.10.0 h1:iLDOF0rdGTrol/q8OfPIIs5kLD8XvA2q75o6Uq/tgak=
@@ -73,8 +69,6 @@ github.com/anchore/go-lzo v0.1.1 h1:IwL/fvkdtlIrYIXck6WxZ3nb8WjjHziYYmGxlooyOnM=
github.com/anchore/go-lzo v0.1.1/go.mod h1:3kLx0bve2oN1iDwgM1U5zGku1Tfbdb0No5qp1eL1fIk=
github.com/andybalholm/brotli v1.2.7 h1:UgafRPGMam5ySHfRkhthMqOFA6IFoeF3D7mWIDLjG5A=
github.com/andybalholm/brotli v1.2.7/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/cascadia v1.3.4 h1:vM2lgh0Vru9Vwyfm4cQqWP2HHMW0u0+2PAW7Q38Qufg=
github.com/andybalholm/cascadia v1.3.4/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM=
github.com/apache/arrow-go/v18 v18.7.0 h1:Vw/i+cJyebUofT7JlqFpe65LrmwxULn166jjwStM4HY=
github.com/apache/arrow-go/v18 v18.7.0/go.mod h1:PM6IigLJkdMwIpeHXnymo+xZ52f42a9EYiLtRel4p/A=
github.com/apache/thrift v0.24.0 h1:zy31L1a49QTNB2bG1BBfMXol3yJrTH975G3pPubQVLQ=
@@ -134,8 +128,6 @@ github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bradenaw/juniper v0.15.3 h1:RHIAMEDTpvmzV1wg1jMAHGOoI2oJUSPx3lxRldXnFGo=
github.com/bradenaw/juniper v0.15.3/go.mod h1:UX4FX57kVSaDp4TPqvSjkAAewmRFAfXf27BOs5z9dq8=
github.com/buengese/sgzip v0.1.1 h1:ry+T8l1mlmiWEsDrH/YHZnCVWD2S3im1KLsyO+8ZmTU=
github.com/buengese/sgzip v0.1.1/go.mod h1:i5ZiXGF3fhV7gL1xaRRL1nDnmpNj0X061FQzOS8VMas=
github.com/buger/jsonparser v1.2.0 h1:4EFcvK1kD4jyj6YqNK6skK6w+y7FHHBR+XBCtxwu/6g=
@@ -205,10 +197,6 @@ github.com/ebitengine/purego v0.11.1 h1:2zpWRSQNVKN4eKsKO9eM1ILDgWfYMY9GwqRmK6Xe
github.com/ebitengine/purego v0.11.1/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ=
github.com/elliotwutingfeng/asciiset v0.0.0-20260129054604-cfde2086bc57 h1:x5yxNrq8XffV/OoNUeFPM6hxHVi5OTspSTBxr/9pemg=
github.com/elliotwutingfeng/asciiset v0.0.0-20260129054604-cfde2086bc57/go.mod h1:GLo/8fDswSAniFG+BFIaiSPcK610jyzgEhWYPQwuQdw=
github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
github.com/emersion/go-vcard v0.0.0-20260618161152-d854b7e0e2d3 h1:B9YK+Tck5mTccyDhtxBzWyqGYcFxLyB6+noMNW4/VgI=
github.com/emersion/go-vcard v0.0.0-20260618161152-d854b7e0e2d3/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM=
github.com/emmansun/gmsm v0.15.5/go.mod h1:2m4jygryohSWkaSduFErgCwQKab5BNjURoFrn2DNwyU=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
@@ -260,8 +248,6 @@ github.com/go-playground/universal-translator v0.18.2 h1:LCsMLC9RzmbUMNUPVYD15dm
github.com/go-playground/universal-translator v0.18.2/go.mod h1:67VZIMp5lQpDWlnStOct22q1bkdJGJqHghbOtmkawxk=
github.com/go-playground/validator/v10 v10.30.5 h1:YyCXvVShZbs2Sm3Mb53eNOlhRXctSOzW5QJAouCTZL4=
github.com/go-playground/validator/v10 v10.30.5/go.mod h1:wEqiaov48pXX1kjhc3Da8y0M0Dtg/BK7gurFBLgwFrQ=
github.com/go-resty/resty/v2 v2.17.2 h1:FQW5oHYcIlkCNrMD2lloGScxcHJ0gkjshV3qcQAyHQk=
github.com/go-resty/resty/v2 v2.17.2/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8WdvHunIJ9dAyjPVtrBPhSr3KT2yUst43I=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
@@ -499,14 +485,8 @@ github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+
github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk=
github.com/rasky/go-xdr v0.0.0-20170124162913-1a41d1a06c93 h1:UVArwN/wkKjMVhh2EQGC0tEc1+FqiLlvYXY5mQ2f8Wg=
github.com/rasky/go-xdr v0.0.0-20170124162913-1a41d1a06c93/go.mod h1:Nfe4efndBz4TibWycNE+lqyJZiMX4ycx+QKV8Ta0f/o=
github.com/rclone/Proton-API-Bridge v1.0.5 h1:K1++Qtk3PvgkiCCiv6Pahju1TMOzKY6VSwiwT7XLAVc=
github.com/rclone/Proton-API-Bridge v1.0.5/go.mod h1:vCeOPhlXzevN0AFojgh1zsjhetiShy/ArvJ/xkFUDWk=
github.com/rclone/go-proton-api v1.0.4 h1:AJW0e9pB4j0hVK4WqyGErFwaI+5MUQWPCtj5FYYxtPg=
github.com/rclone/go-proton-api v1.0.4/go.mod h1:QAlkFfswzrBuxvCORWV8rZdddg52hahMN98CFWoFW1E=
github.com/rclone/gofakes3 v0.0.12 h1:x8MyrIQ9U88JvG8XMBZHNBpKhKJwZEacWlrG/aW+PWk=
github.com/rclone/gofakes3 v0.0.12/go.mod h1:0udb2hmIO9mHXDAgcdycCzSPPASAGIz410t+m0LKmnE=
github.com/relvacode/iso8601 v1.7.0 h1:BXy+V60stMP6cpswc+a93Mq3e65PfXCgDFfhvNNGrdo=
github.com/relvacode/iso8601 v1.7.0/go.mod h1:FlNp+jz+TXpyRqgmM7tnzHHzBnz776kmAH2h3sZCn0I=
github.com/rfjakob/eme v1.2.0 h1:8dAHL+WVAw06+7DkRKnRiFp1JL3QjcJEZFqDnndUaSI=
github.com/rfjakob/eme v1.2.0/go.mod h1:cVvpasglm/G3ngEfcfT/Wt0GwhkuO32pf/poW6Nyk1k=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=