diff --git a/crypt/cipher.go b/crypt/cipher.go index 1be758eae..afde85c14 100644 --- a/crypt/cipher.go +++ b/crypt/cipher.go @@ -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 } diff --git a/crypt/cipher_test.go b/crypt/cipher_test.go index be12cd042..edeb3603a 100644 --- a/crypt/cipher_test.go +++ b/crypt/cipher_test.go @@ -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, "", "") diff --git a/crypt/crypt.go b/crypt/crypt.go index e99d4b734..1c0ce369e 100644 --- a/crypt/crypt.go +++ b/crypt/crypt.go @@ -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.", }, }, }, { diff --git a/docs/content/crypt.md b/docs/content/crypt.md index 4f4a4cded..f165c4bf2 100644 --- a/docs/content/crypt.md +++ b/docs/content/crypt.md @@ -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