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.
This commit is contained in:
Nick Craig-Wood
2026-08-27 17:28:04 +01:00
parent 1fd40d06ab
commit 1583cce1e2
3 changed files with 65 additions and 22 deletions
+25 -7
View File
@@ -11,6 +11,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"path"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -487,12 +488,10 @@ func (c *Cipher) deobfuscateSegment(ciphertext string) (string, error) {
// encryptFileName encrypts a file path // encryptFileName encrypts a file path
// //
// If stripVersion is set then a version string on the last segment (as // If stripVersion is set, a version string (as used by --b2-versions)
// used by --b2-versions) is removed before encryption and put back in // on the last segment is kept in plain text. Set it for file names
// plain text afterwards. Only file leaf names are given version strings // only: directory names must encrypt identically whether passed alone
// by the underlying backend, so it must not be set for directory names, // or as the parent of a file name.
// which would otherwise encrypt differently from the same directory
// appearing as the parent of a file name.
func (c *Cipher) encryptFileName(in string, stripVersion bool) string { func (c *Cipher) encryptFileName(in string, stripVersion bool) string {
segments := strings.Split(in, "/") segments := strings.Split(in, "/")
for i := range segments { for i := range segments {
@@ -540,6 +539,9 @@ func (c *Cipher) EncryptFileName(in string) string {
} }
// EncryptDirName encrypts a directory path // 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 { func (c *Cipher) EncryptDirName(in string) string {
if c.mode == NameEncryptionOff || !c.dirNameEncrypt { if c.mode == NameEncryptionOff || !c.dirNameEncrypt {
return in return in
@@ -614,11 +616,27 @@ func (c *Cipher) DecryptFileName(in string) (string, error) {
} }
// DecryptDirName decrypts a directory path // 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) { func (c *Cipher) DecryptDirName(in string) (string, error) {
if c.mode == NameEncryptionOff || !c.dirNameEncrypt { if c.mode == NameEncryptionOff || !c.dirNameEncrypt {
return in, nil 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 // NameEncryptionMode returns the encryption mode in use for names
+31 -15
View File
@@ -8,6 +8,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"path"
"strings" "strings"
"testing" "testing"
@@ -684,23 +685,38 @@ func TestNonStandardDecryptDirName(t *testing.T) {
// Test directories whose name looks like it has a version string - // Test directories whose name looks like it has a version string -
// these encrypt verbatim, so that EncryptDirName agrees with the same // 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) { func TestVersionedDirName(t *testing.T) {
const dir = "dir-v2001-02-03-040506-123" const dir = "dir-v2001-02-03-040506-123"
for _, encoding := range []string{"base32", "base64", "base32768"} { enc, err := NewNameEncoding("base32")
enc, _ := NewNameEncoding(encoding) require.NoError(t, err)
for _, mode := range []NameEncryptionMode{NameEncryptionStandard, NameEncryptionObfuscated} { for _, mode := range []NameEncryptionMode{NameEncryptionStandard, NameEncryptionObfuscated} {
c, _ := newCipher(mode, "", "", true, enc) c, err := newCipher(mode, "", "", true, enc)
what := fmt.Sprintf("Testing %q (mode=%v)", encoding, mode) require.NoError(t, err)
// Check EncryptDirName matches the parent of an encrypted file name what := fmt.Sprintf("Testing mode=%v", mode)
encryptedDir := c.EncryptDirName(dir) // Check EncryptDirName matches the parent of an encrypted file name
encryptedFile := c.EncryptFileName(dir + "/file.txt") encryptedDir := c.EncryptDirName(dir)
assert.Equal(t, encryptedDir+"/", encryptedFile[:strings.LastIndex(encryptedFile, "/")+1], what) encryptedFile := c.EncryptFileName(dir + "/file.txt")
// Check the encrypted directory name round trips OK assert.Equal(t, encryptedDir, path.Dir(encryptedFile), what)
decryptedDir, err := c.DecryptDirName(encryptedDir) // Check the encrypted directory name round trips OK
assert.NoError(t, err, what) decryptedDir, err := c.DecryptDirName(encryptedDir)
assert.Equal(t, dir, decryptedDir, what) 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)
} }
} }
+9
View File
@@ -813,6 +813,15 @@ This means that
- filenames with the same name will encrypt the same - filenames with the same name will encrypt the same
- filenames which start the same won't have a common prefix - 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 This uses a 32 byte key (256 bits) and a 16 byte (128 bits) IV both of
which are derived from the user password. which are derived from the user password.