1
mirror of https://github.com/rclone/rclone synced 2024-12-21 11:45:56 +01:00

crypt: add an "obfuscate" option for filename encryption.

This is a simple "rotate" of the filename, with each file having a rot
distance based on the filename.  We store the distance at the beginning
of the filename.  So a file called "go" would become "37.KS".

This is not a strong encryption of filenames, but it should stop automated
scanning tools from picking up on filename patterns.  As such it's an
intermediate between "off" and "standard".  The advantage is that it
allows for longer path segment names.

We use the nameKey as an additional input to calculate the obfuscation
distance.  This should mean that two different passwords will result
in two different keys

The obfuscation rotation works by splitting the ranges up and handle cases
  0-9
  A-Za-z
  0xA0-0xFF
  and anything greater in blocks of 256
This commit is contained in:
Stephen Harris 2017-03-12 14:14:36 -04:00 committed by Nick Craig-Wood
parent 37e1b20ec1
commit 6e003934fc
4 changed files with 247 additions and 3 deletions

View File

@ -8,6 +8,7 @@ import (
"encoding/base32"
"fmt"
"io"
"strconv"
"strings"
"sync"
"unicode/utf8"
@ -49,6 +50,7 @@ var (
ErrorNotAnEncryptedFile = errors.New("not an encrypted file - no \"" + encryptedSuffix + "\" suffix")
ErrorBadSeek = errors.New("Seek beyond end of file")
defaultSalt = []byte{0xA8, 0x0D, 0xF4, 0x3A, 0x8F, 0xBD, 0x03, 0x08, 0xA7, 0xCA, 0xB8, 0x3E, 0x58, 0x1F, 0x86, 0xB1}
obfuscQuoteRune = '!'
)
// Global variables
@ -95,6 +97,7 @@ type NameEncryptionMode int
const (
NameEncryptionOff NameEncryptionMode = iota
NameEncryptionStandard
NameEncryptionObfuscated
)
// NewNameEncryptionMode turns a string into a NameEncryptionMode
@ -105,6 +108,8 @@ func NewNameEncryptionMode(s string) (mode NameEncryptionMode, err error) {
mode = NameEncryptionOff
case "standard":
mode = NameEncryptionStandard
case "obfuscate":
mode = NameEncryptionObfuscated
default:
err = errors.Errorf("Unknown file name encryption mode %q", s)
}
@ -118,6 +123,8 @@ func (mode NameEncryptionMode) String() (out string) {
out = "off"
case NameEncryptionStandard:
out = "standard"
case NameEncryptionObfuscated:
out = "obfuscate"
default:
out = fmt.Sprintf("Unknown mode #%d", mode)
}
@ -284,11 +291,189 @@ func (c *cipher) decryptSegment(ciphertext string) (string, error) {
return string(plaintext), err
}
// Simple obfuscation routines
func (c *cipher) obfuscateSegment(plaintext string) string {
if plaintext == "" {
return ""
}
// If the string isn't valid UTF8 then don't rotate; just
// prepend a 0.
if !utf8.ValidString(plaintext) {
return "0." + plaintext
}
// Calculate a simple rotation based on the filename and
// the nameKey
var dir int
for _, runeValue := range plaintext {
dir += int(runeValue)
}
dir = dir % 256
// We'll use this number to store in the result filename...
var result bytes.Buffer
_, _ = result.WriteString(strconv.Itoa(dir) + ".")
// but we'll augment it with the nameKey for real calculation
for i := 0; i < len(c.nameKey); i++ {
dir += int(c.nameKey[i])
}
// Now for each character, depending on the range it is in
// we will actually rotate a different amount
for _, runeValue := range plaintext {
switch {
case runeValue == obfuscQuoteRune:
// Quote the Quote character
_, _ = result.WriteRune(obfuscQuoteRune)
_, _ = result.WriteRune(obfuscQuoteRune)
case runeValue >= '0' && runeValue <= '9':
// Number
thisdir := (dir % 9) + 1
newRune := '0' + (int(runeValue)-'0'+thisdir)%10
_, _ = result.WriteRune(rune(newRune))
case (runeValue >= 'A' && runeValue <= 'Z') ||
(runeValue >= 'a' && runeValue <= 'z'):
// ASCII letter. Try to avoid trivial A->a mappings
thisdir := dir%25 + 1
// Calculate the offset of this character in A-Za-z
pos := int(runeValue - 'A')
if pos >= 26 {
pos -= 6 // It's lower case
}
// Rotate the character to the new location
pos = (pos + thisdir) % 52
if pos >= 26 {
pos += 6 // and handle lower case offset again
}
_, _ = result.WriteRune(rune('A' + pos))
case runeValue >= 0xA0 && runeValue <= 0xFF:
// Latin 1 supplement
thisdir := (dir % 95) + 1
newRune := 0xA0 + (int(runeValue)-0xA0+thisdir)%96
_, _ = result.WriteRune(rune(newRune))
case runeValue >= 0x100:
// Some random Unicode range; we have no good rules here
thisdir := (dir % 127) + 1
base := int(runeValue - runeValue%256)
newRune := rune(base + (int(runeValue)-base+thisdir)%256)
// If the new character isn't a valid UTF8 char
// then don't rotate it. Quote it instead
if !utf8.ValidRune(newRune) {
_, _ = result.WriteRune(obfuscQuoteRune)
_, _ = result.WriteRune(runeValue)
} else {
_, _ = result.WriteRune(newRune)
}
default:
// Leave character untouched
_, _ = result.WriteRune(runeValue)
}
}
return result.String()
}
func (c *cipher) deobfuscateSegment(ciphertext string) (string, error) {
if ciphertext == "" {
return "", nil
}
pos := strings.Index(ciphertext, ".")
if pos == -1 {
return "", ErrorNotAnEncryptedFile
} // No .
num := ciphertext[:pos]
if num == "0" {
// No rotation; probably original was not valid unicode
return ciphertext[pos+1:], nil
}
dir, err := strconv.Atoi(num)
if err != nil {
return "", ErrorNotAnEncryptedFile // Not a number
}
// add the nameKey to get the real rotate distance
for i := 0; i < len(c.nameKey); i++ {
dir += int(c.nameKey[i])
}
var result bytes.Buffer
inQuote := false
for _, runeValue := range ciphertext[pos+1:] {
switch {
case inQuote:
_, _ = result.WriteRune(runeValue)
inQuote = false
case runeValue == obfuscQuoteRune:
inQuote = true
case runeValue >= '0' && runeValue <= '9':
// Number
thisdir := (dir % 9) + 1
newRune := '0' + int(runeValue) - '0' - thisdir
if newRune < '0' {
newRune += 10
}
_, _ = result.WriteRune(rune(newRune))
case (runeValue >= 'A' && runeValue <= 'Z') ||
(runeValue >= 'a' && runeValue <= 'z'):
thisdir := dir%25 + 1
pos := int(runeValue - 'A')
if pos >= 26 {
pos -= 6
}
pos = pos - thisdir
if pos < 0 {
pos += 52
}
if pos >= 26 {
pos += 6
}
_, _ = result.WriteRune(rune('A' + pos))
case runeValue >= 0xA0 && runeValue <= 0xFF:
thisdir := (dir % 95) + 1
newRune := 0xA0 + int(runeValue) - 0xA0 - thisdir
if newRune < 0xA0 {
newRune += 96
}
_, _ = result.WriteRune(rune(newRune))
case runeValue >= 0x100:
thisdir := (dir % 127) + 1
base := int(runeValue - runeValue%256)
newRune := rune(base + (int(runeValue) - base - thisdir))
if int(newRune) < base {
newRune += 256
}
_, _ = result.WriteRune(rune(newRune))
default:
_, _ = result.WriteRune(runeValue)
}
}
return result.String(), nil
}
// encryptFileName encrypts a file path
func (c *cipher) encryptFileName(in string) string {
segments := strings.Split(in, "/")
for i := range segments {
segments[i] = c.encryptSegment(segments[i])
if c.mode == NameEncryptionStandard {
segments[i] = c.encryptSegment(segments[i])
} else {
segments[i] = c.obfuscateSegment(segments[i])
}
}
return strings.Join(segments, "/")
}
@ -314,7 +499,12 @@ func (c *cipher) decryptFileName(in string) (string, error) {
segments := strings.Split(in, "/")
for i := range segments {
var err error
segments[i], err = c.decryptSegment(segments[i])
if c.mode == NameEncryptionStandard {
segments[i], err = c.decryptSegment(segments[i])
} else {
segments[i], err = c.deobfuscateSegment(segments[i])
}
if err != nil {
return "", err
}

View File

@ -23,6 +23,7 @@ func TestNewNameEncryptionMode(t *testing.T) {
}{
{"off", NameEncryptionOff, ""},
{"standard", NameEncryptionStandard, ""},
{"obfuscate", NameEncryptionObfuscated, ""},
{"potato", NameEncryptionMode(0), "Unknown file name encryption mode \"potato\""},
} {
actual, actualErr := NewNameEncryptionMode(test.in)
@ -38,7 +39,8 @@ func TestNewNameEncryptionMode(t *testing.T) {
func TestNewNameEncryptionModeString(t *testing.T) {
assert.Equal(t, NameEncryptionOff.String(), "off")
assert.Equal(t, NameEncryptionStandard.String(), "standard")
assert.Equal(t, NameEncryptionMode(2).String(), "Unknown mode #2")
assert.Equal(t, NameEncryptionObfuscated.String(), "obfuscate")
assert.Equal(t, NameEncryptionMode(3).String(), "Unknown mode #3")
}
func TestValidString(t *testing.T) {
@ -219,6 +221,11 @@ func TestEncryptFileName(t *testing.T) {
// Now off mode
c, _ = newCipher(NameEncryptionOff, "", "")
assert.Equal(t, "1/12/123.bin", c.EncryptFileName("1/12/123"))
// Obfuscation mode
c, _ = newCipher(NameEncryptionObfuscated, "", "")
assert.Equal(t, "49.6/99.23/150.890/53.!!lipps", c.EncryptFileName("1/12/123/!hello"))
assert.Equal(t, "161.\u00e4", c.EncryptFileName("\u00a1"))
assert.Equal(t, "160.\u03c2", c.EncryptFileName("\u03a0"))
}
func TestDecryptFileName(t *testing.T) {
@ -236,6 +243,10 @@ func TestDecryptFileName(t *testing.T) {
{NameEncryptionOff, "1/12/123.bin", "1/12/123", nil},
{NameEncryptionOff, "1/12/123.bix", "", ErrorNotAnEncryptedFile},
{NameEncryptionOff, ".bin", "", ErrorNotAnEncryptedFile},
{NameEncryptionObfuscated, "0.hello", "hello", nil},
{NameEncryptionObfuscated, "hello", "", ErrorNotAnEncryptedFile},
{NameEncryptionObfuscated, "161.\u00e4", "\u00a1", nil},
{NameEncryptionObfuscated, "160.\u03c2", "\u03a0", nil},
} {
c, _ := newCipher(test.mode, "", "")
actual, actualErr := c.DecryptFileName(test.in)
@ -245,6 +256,23 @@ func TestDecryptFileName(t *testing.T) {
}
}
func TestEncDecMatches(t *testing.T) {
for _, test := range []struct {
mode NameEncryptionMode
in string
}{
{NameEncryptionStandard, "1/2/3/4"},
{NameEncryptionOff, "1/2/3/4"},
{NameEncryptionObfuscated, "1/2/3/4/!hello\u03a0"},
} {
c, _ := newCipher(test.mode, "", "")
out, err := c.DecryptFileName(c.EncryptFileName(test.in))
what := fmt.Sprintf("Testing %q (mode=%v)", test.in, test.mode)
assert.Equal(t, out, test.in, what)
assert.Equal(t, err, nil, what)
}
}
func TestEncryptDirName(t *testing.T) {
// First standard mode
c, _ := newCipher(NameEncryptionStandard, "", "")

View File

@ -37,6 +37,9 @@ func init() {
}, {
Value: "standard",
Help: "Encrypt the filenames see the docs for the details.",
}, {
Value: "obfuscate",
Help: "Very simple filename obfuscation.",
},
},
}, {

View File

@ -71,6 +71,8 @@ Choose a number from below, or type in your own value
\ "off"
2 / Encrypt the filenames see the docs for the details.
\ "standard"
3 / Very simple filename obfuscation.
\ "obfuscate"
filename_encryption> 2
Password or pass phrase for encryption.
y) Yes type in my own password
@ -225,6 +227,27 @@ Standard
* identical files names will have identical uploaded names
* can use shortcuts to shorten the directory recursion
Obfuscation
This is a simple "rotate" of the filename, with each file having a rot
distance based on the filename. We store the distance at the beginning
of the filename. So a file called "hello" may become "53.jgnnq"
This is not a strong encryption of filenames, but it may stop automated
scanning tools from picking up on filename patterns. As such it's an
intermediate between "off" and "standard". The advantage is that it
allows for longer path segment names.
There is a possibility with some unicode based filenames that the
obfuscation is weak and may map lower case characters to upper case
equivalents. You can not rely on this for strong protection.
* file names very lightly obfuscated
* file names can be longer than standard encryption
* can use sub paths and copy single files
* directory structure visibile
* identical files names will have identical uploaded names
Cloud storage systems have various limits on file name length and
total path length which you are more likely to hit using "Standard"
file name encryption. If you keep your file names to below 156