From 1583cce1e28340e5d064ed955179f5f2b31e7757 Mon Sep 17 00:00:00 2001 From: Nick Craig-Wood Date: Thu, 27 Aug 2026 15:56:31 +0100 Subject: [PATCH] crypt: warn about directories with legacy version-like encrypted names Directory names which look like they have a --b2-versions version string are now encrypted in full, so directories created by older rclone (which left the version string in plain text) no longer decrypt and vanished silently from listings. DecryptDirName now falls back to the old form for such names so the directory is listed, and logs the name it needs to be renamed to on the underlying remote to make it accessible again. Document this in the crypt docs. --- backend/crypt/cipher.go | 32 +++++++++++++++++++------ backend/crypt/cipher_test.go | 46 ++++++++++++++++++++++++------------ docs/content/crypt.md | 9 +++++++ 3 files changed, 65 insertions(+), 22 deletions(-) diff --git a/backend/crypt/cipher.go b/backend/crypt/cipher.go index d05a93924..95db1e5c2 100644 --- a/backend/crypt/cipher.go +++ b/backend/crypt/cipher.go @@ -11,6 +11,7 @@ import ( "errors" "fmt" "io" + "path" "strconv" "strings" "sync" @@ -487,12 +488,10 @@ func (c *Cipher) deobfuscateSegment(ciphertext string) (string, error) { // encryptFileName encrypts a file path // -// If stripVersion is set then a version string on the last segment (as -// used by --b2-versions) is removed before encryption and put back in -// plain text afterwards. Only file leaf names are given version strings -// by the underlying backend, so it must not be set for directory names, -// which would otherwise encrypt differently from the same directory -// appearing as the parent of a file name. +// If stripVersion is set, a version string (as used by --b2-versions) +// on the last segment is kept in plain text. Set it for file names +// only: directory names must encrypt identically whether passed alone +// or as the parent of a file name. func (c *Cipher) encryptFileName(in string, stripVersion bool) string { segments := strings.Split(in, "/") for i := range segments { @@ -540,6 +539,9 @@ func (c *Cipher) EncryptFileName(in string) string { } // EncryptDirName encrypts a directory path +// +// Unlike EncryptFileName, a version string on the last segment is +// encrypted along with the rest of the name. func (c *Cipher) EncryptDirName(in string) string { if c.mode == NameEncryptionOff || !c.dirNameEncrypt { return in @@ -614,11 +616,27 @@ func (c *Cipher) DecryptFileName(in string) (string, error) { } // DecryptDirName decrypts a directory path +// +// Unlike DecryptFileName, a version string on the last segment is +// expected to be part of the encrypted name. Directory names created +// by rclone before v1.76 had a version-like suffix left in plain +// text; these are still decrypted so that they appear in listings, +// but they can't be opened or removed until renamed on the +// underlying remote. func (c *Cipher) DecryptDirName(in string) (string, error) { if c.mode == NameEncryptionOff || !c.dirNameEncrypt { return in, nil } - return c.decryptFileName(in, false) + out, err := c.decryptFileName(in, false) + if err != nil && version.Match(path.Base(in)) { + var legacyErr error + out, legacyErr = c.decryptFileName(in, true) + if legacyErr == nil { + fs.Logf(nil, "crypt: directory %q has a legacy encrypted name - rename %q on the underlying remote to %q to make it accessible", out, in, c.encryptFileName(out, false)) + return out, nil + } + } + return out, err } // NameEncryptionMode returns the encryption mode in use for names diff --git a/backend/crypt/cipher_test.go b/backend/crypt/cipher_test.go index 217b85447..578a3a5c7 100644 --- a/backend/crypt/cipher_test.go +++ b/backend/crypt/cipher_test.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "io" + "path" "strings" "testing" @@ -684,23 +685,38 @@ func TestNonStandardDecryptDirName(t *testing.T) { // Test directories whose name looks like it has a version string - // these encrypt verbatim, so that EncryptDirName agrees with the same -// directory encrypted as the parent of a file name +// directory encrypted as the parent of a file name, whereas file names +// keep the version string in plain text func TestVersionedDirName(t *testing.T) { const dir = "dir-v2001-02-03-040506-123" - for _, encoding := range []string{"base32", "base64", "base32768"} { - enc, _ := NewNameEncoding(encoding) - for _, mode := range []NameEncryptionMode{NameEncryptionStandard, NameEncryptionObfuscated} { - c, _ := newCipher(mode, "", "", true, enc) - what := fmt.Sprintf("Testing %q (mode=%v)", encoding, mode) - // Check EncryptDirName matches the parent of an encrypted file name - encryptedDir := c.EncryptDirName(dir) - encryptedFile := c.EncryptFileName(dir + "/file.txt") - assert.Equal(t, encryptedDir+"/", encryptedFile[:strings.LastIndex(encryptedFile, "/")+1], what) - // Check the encrypted directory name round trips OK - decryptedDir, err := c.DecryptDirName(encryptedDir) - assert.NoError(t, err, what) - assert.Equal(t, dir, decryptedDir, what) - } + enc, err := NewNameEncoding("base32") + require.NoError(t, err) + for _, mode := range []NameEncryptionMode{NameEncryptionStandard, NameEncryptionObfuscated} { + c, err := newCipher(mode, "", "", true, enc) + require.NoError(t, err) + what := fmt.Sprintf("Testing mode=%v", mode) + // Check EncryptDirName matches the parent of an encrypted file name + encryptedDir := c.EncryptDirName(dir) + encryptedFile := c.EncryptFileName(dir + "/file.txt") + assert.Equal(t, encryptedDir, path.Dir(encryptedFile), what) + // Check the encrypted directory name round trips OK + decryptedDir, err := c.DecryptDirName(encryptedDir) + assert.NoError(t, err, what) + assert.Equal(t, dir, decryptedDir, what) + // Check a file with the same name keeps its version string in plain text + encryptedFile = c.EncryptFileName(dir) + assert.NotEqual(t, encryptedDir, encryptedFile, what) + assert.Contains(t, encryptedFile, "-v2001-02-03-040506-123", what) + decryptedFile, err := c.DecryptFileName(encryptedFile) + assert.NoError(t, err, what) + assert.Equal(t, dir, decryptedFile, what) + // Check a directory with a legacy encrypted name (the file form) still decrypts + decryptedDir, err = c.DecryptDirName(encryptedFile) + assert.NoError(t, err, what) + assert.Equal(t, dir, decryptedDir, what) + // but a name which is bad in both forms is still an error + _, err = c.DecryptDirName("!!!-v2001-02-03-040506-123") + assert.Error(t, err, what) } } diff --git a/docs/content/crypt.md b/docs/content/crypt.md index 63ef2b056..7e0e69805 100644 --- a/docs/content/crypt.md +++ b/docs/content/crypt.md @@ -813,6 +813,15 @@ This means that - filenames with the same name will encrypt the same - filenames which start the same won't have a common prefix +A version string of the form `-vYYYY-MM-DD-HHMMSS-NNN` on the end of a +file name (as added by `--b2-versions` / `--s3-versions`) is left in +plain text so that versioned files can be found. Directory names are +encrypted in full. Rclone before v1.76 left such a suffix in plain +text on directory names too, so a directory named like this created by +an older rclone will appear in listings with a warning but can't be +opened or removed until renamed on the underlying remote to the name +given in the warning. + This uses a 32 byte key (256 bits) and a 16 byte (128 bits) IV both of which are derived from the user password.