From 2dd0f87c96fcad8e6fa6d9280cfd26a8cf85375d Mon Sep 17 00:00:00 2001 From: Nworm Date: Thu, 21 Dec 2023 16:03:22 +0800 Subject: [PATCH] feat(rclone): Support base64 path encoding --- README.md | 3 ++- src/ciphers/PathCipher.js | 13 ++++++------ src/ciphers/PathCipher.test.js | 27 ++++++++++++++++++++++- src/ciphers/path-encoding.js | 21 ++++++++++++++++++ src/index.js | 2 +- src/rclone.test.js | 13 ++++++++---- src/reveal.js | 26 +++-------------------- src/utils/base64.js | 39 ++++++++++++++++++++++++++++++++++ 8 files changed, 108 insertions(+), 36 deletions(-) create mode 100644 src/ciphers/path-encoding.js create mode 100644 src/utils/base64.js diff --git a/README.md b/README.md index f6769b2..56f6c76 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,8 @@ import { Rclone } from 'rclone'; // Create Rclone instance Rclone({ password: 'UmyLSdRHfew6aual28-ggx78qHqSfQ', - salt: 'Cj3gLa5PVwc2aot0QpKiOZ3YEzs3Sw' + salt: 'Cj3gLa5PVwc2aot0QpKiOZ3YEzs3Sw', + encoding: 'base32' // Path encoding type, default is base32. base32 and base64 only. }) .then(rclone => { diff --git a/src/ciphers/PathCipher.js b/src/ciphers/PathCipher.js index 6b6f215..c10d0a6 100644 --- a/src/ciphers/PathCipher.js +++ b/src/ciphers/PathCipher.js @@ -1,8 +1,8 @@ import { AES, padding } from 'aes-js'; -import { default as decodeBase32 } from 'base32-decode'; -import { default as encodeBase32 } from 'base32-encode'; + import { Decrypt, Encrypt } from './eme'; import { TextDecoder, TextEncoder } from './text-encoding'; +import PathEncoding from './path-encoding'; const { pkcs7 } = padding; @@ -16,20 +16,21 @@ const encodeUTF8 = (() => { return data => encoder.encode(data); })(); -export default function PathCipher({ nameKey, nameTweak } = {}) { +export default function PathCipher({ nameKey, nameTweak } = {}, encodingType = 'base32') { if (nameKey === undefined || nameTweak === undefined) { throw new Error('nameKey and nameTweak must be specified'); } // Name Cipher Fuctions const nameCipher = new AES(nameKey); + const pathEncoding = PathEncoding(encodingType) function encryptName(name) { const ciphertext = encodeUTF8(name); const paddedCipherText = pkcs7.pad(ciphertext); const rawCipherText = Encrypt(nameCipher, nameTweak, paddedCipherText); - let encodedCipher = encodeBase32(rawCipherText, 'RFC4648-HEX'); - return encodedCipher.replace(/=+$/, '').toLowerCase(); + let encodedCipher = pathEncoding.encode(rawCipherText); + return encodedCipher.replace(/=+$/, ''); } function encrypt(path) { @@ -40,7 +41,7 @@ export default function PathCipher({ nameKey, nameTweak } = {}) { } function decryptName(name) { const rawCipherText = new Uint8Array( - decodeBase32(name.toUpperCase(), 'RFC4648-HEX') + pathEncoding.decode(name) ); const paddedPlaintext = Decrypt(nameCipher, nameTweak, rawCipherText); return decodeUTF8(pkcs7.strip(paddedPlaintext)); diff --git a/src/ciphers/PathCipher.test.js b/src/ciphers/PathCipher.test.js index ce0cd35..3005250 100644 --- a/src/ciphers/PathCipher.test.js +++ b/src/ciphers/PathCipher.test.js @@ -6,7 +6,7 @@ test('both nameKey and nameTweak are need', () => { }).toThrowErrorMatchingSnapshot(); }); -test('shoud encrypt/decrypt file name', () => { +test('shoud encrypt/decrypt file name [base32]', () => { const cases = [ ['es785ret6k0hje8fkrqmu9fdus', 'Hallo World'], ['p0e52nreeaj0a5ea7s64m4j72s', '1'], @@ -30,3 +30,28 @@ test('shoud encrypt/decrypt file name', () => { expect(cipher.encrypt(item[1])).toEqual(item[0]); }); }); + +test('shoud encrypt/decrypt file name [base64]', () => { + const cases = [ + ['dw6C7d01ARm5D6b1byXt9w', 'Hallo World'], + ['yBxRX25ypgUVyj8MSxJnFw', '1'], + ['yBxRX25ypgUVyj8MSxJnFw/qQUDHOGN_jVdLIMQzYrhvA', '1/12'], + [ + 'yBxRX25ypgUVyj8MSxJnFw/qQUDHOGN_jVdLIMQzYrhvA/1CxFf2Mti1xIPYlGruDh-A', + '1/12/123' + ] + ]; + + const cipher = PathCipher({ + nameKey: new Uint8Array(32), + nameTweak: new Uint8Array(16) + }, 'base64'); + + cases.forEach(item => { + expect(cipher.decrypt(item[0])).toEqual(item[1]); + }); + + cases.forEach(item => { + expect(cipher.encrypt(item[1])).toEqual(item[0]); + }); +}); \ No newline at end of file diff --git a/src/ciphers/path-encoding.js b/src/ciphers/path-encoding.js new file mode 100644 index 0000000..10f1037 --- /dev/null +++ b/src/ciphers/path-encoding.js @@ -0,0 +1,21 @@ +import { default as decodeBase32 } from 'base32-decode'; +import { default as encodeBase32 } from 'base32-encode'; +import { decodeBase64, encodeBase64, toSafeForFileName, toNormal } from '../utils/base64'; + +const encodings = { + base32: { + encode: (data) => encodeBase32(data, 'RFC4648-HEX').replace(/=+$/, '').toLowerCase(), + decode: (data) => decodeBase32(data.toUpperCase(), 'RFC4648-HEX') + }, + base64: { + encode: (data) => toSafeForFileName(encodeBase64(data)), + decode: (data) => decodeBase64(toNormal(data)) + } +} + +export default (encodingType) => { + const e = encodings[encodingType] + + if (!e) throw new Error('Unsupported path encoding types'); + return e +} \ No newline at end of file diff --git a/src/index.js b/src/index.js index b931b90..0675980 100644 --- a/src/index.js +++ b/src/index.js @@ -7,6 +7,6 @@ export function Rclone(opts) { return RcloneInternal(opts).then(keys => ({ getKeys: () => keys, File: FileCipher(keys), - Path: PathCipher(keys) + Path: PathCipher(keys, opts.encoding) })); } diff --git a/src/rclone.test.js b/src/rclone.test.js index 8616cb8..db4175b 100644 --- a/src/rclone.test.js +++ b/src/rclone.test.js @@ -22,7 +22,8 @@ test('derive keys from password', done => { test('use default salt when empty', done => { // Generated encrypted string with the use of rclone and empty salt - const rcloneEncryptedFileName = 'ecrk8fu3e0pk86td3r634nan08'; + const rcloneEncryptedFileNameBase32 = 'ecrk8fu3e0pk86td3r634nan08'; + const rcloneEncryptedFileNameBase64 = 'czdEP8NwM0QbrR7MMl1XAg'; const rcloneDecryptedFileName = 'encrypted_file' Rclone({ @@ -30,11 +31,15 @@ test('use default salt when empty', done => { salt: '' }) .then(rclone => { - const pathCipher = PathCipher(rclone); - const decryptedString = pathCipher.decrypt(rcloneEncryptedFileName); + let pathCipher = PathCipher(rclone); + let decryptedString = pathCipher.decrypt(rcloneEncryptedFileNameBase32); + expect(decryptedString).toEqual(rcloneDecryptedFileName); + pathCipher = PathCipher(rclone, 'base64'); + decryptedString = pathCipher.decrypt(rcloneEncryptedFileNameBase64); expect(decryptedString).toEqual(rcloneDecryptedFileName); + done(); }) .catch(err => done.fail(err)); -}); +}); \ No newline at end of file diff --git a/src/reveal.js b/src/reveal.js index c3805e0..c72d5b0 100644 --- a/src/reveal.js +++ b/src/reveal.js @@ -1,4 +1,5 @@ import AES from 'aes-js'; +import { decodeBase64 } from './utils/base64' const { ctr } = AES.ModeOfOperation; // prettier-ignore @@ -21,7 +22,7 @@ function reveal(cipherText) { const blockCipher = createCipher(); cipherText = cipherText.replace(/-/g, '+').replace(/_/g, '/'); - const bytes = base64(cipherText); + const bytes = decodeBase64(cipherText); const iv = bytes.subarray(0, 16); const buf = bytes.subarray(16); @@ -35,25 +36,4 @@ function reveal(cipherText) { return ctrCipher.decrypt(buf); } -export { reveal }; - -function base64(s) { - if (s.length % 4 != 0) { - s += '===='.substr(0, 4 - s.length % 4); - } - return new Uint8Array( - atob2(s) - .split('') - .map(charCodeAt) - ); -} - -function atob2(data) { - return typeof atob === 'function' - ? atob(data) - : Buffer.from(data, 'base64').toString('binary'); -} - -function charCodeAt(c) { - return c.charCodeAt(0); -} +export { reveal } \ No newline at end of file diff --git a/src/utils/base64.js b/src/utils/base64.js new file mode 100644 index 0000000..5ee1410 --- /dev/null +++ b/src/utils/base64.js @@ -0,0 +1,39 @@ +export function decodeBase64(s) { + if (s.length % 4 != 0) { + s += '===='.substr(0, 4 - s.length % 4); + } + return new Uint8Array( + atob2(s) + .split('') + .map(charCodeAt) + ); +} + +export function encodeBase64(data) { + return typeof atob === 'function' + ? btoa(String.fromCharCode.apply(null, data)) + : Buffer.from(data).toString('base64'); +} + +export function toSafeForFileName(str) { + return str + .replace(/=+$/, '') + .replace(/\//g, '_') + .replace(/\+/g, '-') +} + +export function toNormal(str) { + return str + .replace(/_/g, '/') + .replace(/-/g, '+') +} + +function atob2(data) { + return typeof atob === 'function' + ? atob(data) + : Buffer.from(data, 'base64').toString('binary'); +} + +function charCodeAt(c) { + return c.charCodeAt(0); +}