diff --git a/package.json b/package.json index a2103e2..3a2940b 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "@aws-sdk/signature-v4-crt": "^3.474.0", "@aws-sdk/types": "^3.468.0", "@azure/msal-node": "^2.6.0", + "@fyears/rclone-crypt": "^0.0.6", "@fyears/tsqueue": "^1.0.1", "@microsoft/microsoft-graph-client": "^3.0.7", "@smithy/fetch-http-handler": "^2.3.1", diff --git a/src/baseTypes.ts b/src/baseTypes.ts index a6a4c8f..c405eba 100644 --- a/src/baseTypes.ts +++ b/src/baseTypes.ts @@ -88,6 +88,8 @@ export type SyncDirectionType = | "incremental_pull_only" | "incremental_push_only"; +export type CipherMethodType = "rclone-base64" | "openssl-base64" | "unknown"; + export interface RemotelySavePluginSettings { s3: S3Config; webdav: WebdavConfig; @@ -119,6 +121,8 @@ export interface RemotelySavePluginSettings { enableMobileStatusBar?: boolean; + encryptionMethod?: CipherMethodType; + /** * @deprecated */ diff --git a/src/encrypt.ts b/src/encryptOpenSSL.ts similarity index 100% rename from src/encrypt.ts rename to src/encryptOpenSSL.ts diff --git a/src/encryptUnified.ts b/src/encryptUnified.ts new file mode 100644 index 0000000..3ea1246 --- /dev/null +++ b/src/encryptUnified.ts @@ -0,0 +1,122 @@ +import { CipherMethodType } from "./baseTypes"; +import * as openssl from "./encryptOpenSSL"; +import { isVaildText } from "./misc"; + +export class Cipher { + readonly password: string; + readonly method: CipherMethodType; + constructor(password: string, method: CipherMethodType) { + this.password = password ?? ""; + this.method = method; + } + + isPasswordEmpty() { + return this.password === ""; + } + + async encryptContent(content: ArrayBuffer) { + if (this.password === "") { + return content; + } + if (this.method === "openssl-base64") { + return await openssl.encryptArrayBuffer(content, this.password); + } else if (this.method === "rclone-base64") { + throw Error("not implemented yet"); + } else { + throw Error(`not supported encrypt method=${this.method}`); + } + } + + async decryptContent(content: ArrayBuffer) { + if (this.password === "") { + return content; + } + if (this.method === "openssl-base64") { + return await openssl.decryptArrayBuffer(content, this.password); + } else if (this.method === "rclone-base64") { + throw Error("not implemented yet"); + } else { + throw Error(`not supported encrypt method=${this.method}`); + } + } + + async encryptName(name: string) { + if (this.password === "") { + return name; + } + if (this.method === "openssl-base64") { + return await openssl.encryptStringToBase64url(name, this.password); + } else if (this.method === "rclone-base64") { + throw Error("not implemented yet"); + } else { + throw Error(`not supported encrypt method=${this.method}`); + } + } + + async decryptName(name: string) { + if (this.password === "") { + return name; + } + if (this.method === "openssl-base64") { + if (name.startsWith(openssl.MAGIC_ENCRYPTED_PREFIX_BASE32)) { + // backward compitable with the openssl-base32 + try { + const res = await openssl.decryptBase32ToString(name, this.password); + if (isVaildText(res)) { + return res; + } else { + throw Error(`cannot decrypt name=${name}`); + } + } catch (error) { + throw Error(`cannot decrypt name=${name}`); + } + } else if (name.startsWith(openssl.MAGIC_ENCRYPTED_PREFIX_BASE64URL)) { + try { + const res = await openssl.decryptBase64urlToString( + name, + this.password + ); + if (isVaildText(res)) { + return res; + } else { + throw Error(`cannot decrypt name=${name}`); + } + } catch (error) { + throw Error(`cannot decrypt name=${name}`); + } + } + } else if (this.method === "rclone-base64") { + throw Error("not implemented yet"); + } else { + throw Error(`not supported encrypt method=${this.method}`); + } + } + + getSizeFromOrigToEnc(x: number) { + if (this.password === "") { + return x; + } + if (this.method === "openssl-base64") { + return openssl.getSizeFromOrigToEnc(x); + } else if (this.method === "rclone-base64") { + throw Error("not implemented yet"); + } else { + throw Error(`not supported encrypt method=${this.method}`); + } + } + + /** + * quick guess, no actual decryption here + * @param name + * @returns + */ + static isLikelyEncryptedName(name: string): boolean { + if ( + name.startsWith(openssl.MAGIC_ENCRYPTED_PREFIX_BASE32) || + name.startsWith(openssl.MAGIC_ENCRYPTED_PREFIX_BASE64URL) + ) { + return true; + } + return false; + } +} diff --git a/src/langs/en.json b/src/langs/en.json index b7124dd..d33925e 100644 --- a/src/langs/en.json +++ b/src/langs/en.json @@ -109,7 +109,12 @@ "modal_sizesconflict_copynotice": "All the sizes conflicts info have been copied to the clipboard!", "settings_basic": "Basic Settings", "settings_password": "Encryption Password", - "settings_password_desc": "Password for E2E encryption. Empty for no password. You need to click \"Confirm\". Attention: the password and other info are saved locally.", + "settings_password_desc": "Password for E2E encryption. Empty for no password. You need to click \"Confirm\". Attention: The password and other info are saved locally. After changing the password, you need to manually delete every original files in the remote, and re-sync (so that upload) the encrypted files again.", + "settings_encryptionmethod": "Encryption Method", + "settings_encryptionmethod_desc": "Encryption method for E2E encryption. RClone method is recommended but it doesn't encrypt path structure. OpenSSL is the legacy method of this plugin. Attention: After switching the method, you need to manually delete every original files in the remote and re-sync (so that upload) the encrypted files again.", + "settings_encryptionmethod_rclone": "RClone (recommended)", + "settings_encryptionmethod_openssl": "OpenSSL (legacy)", + "settings_autorun": "Schedule For Auto Run", "settings_autorun_desc": "The plugin tries to schedule the running after every interval. Battery may be impacted.", "settings_autorun_notset": "(not set)", diff --git a/src/langs/zh_cn.json b/src/langs/zh_cn.json index ba9e50f..1c462ee 100644 --- a/src/langs/zh_cn.json +++ b/src/langs/zh_cn.json @@ -109,7 +109,11 @@ "modal_sizesconflict_copynotice": "所有的文件大小冲突信息,已被复制到剪贴板!", "settings_basic": "基本设置", "settings_password": "密码", - "settings_password_desc": "端到端加密的密码。不填写则代表没密码。您需要点击“确认”来修改。注意:密码和其它信息都会在本地保存。", + "settings_password_desc": "端到端加密的密码。不填写则代表没密码。您需要点击“确认”来修改。注意:密码和其它信息都会在本地保存。如果您修改了密码,您需要手动删除远端的所有文件,重新同步(从而上传)加密文件。", + "settings_encryptionmethod": "加密方法", + "settings_encryptionmethod_desc": "端到端加密的方法。推荐选用 RClone 方法,但是它没有加密文件路径结构。OpenSSL 是本插件一开始就支持的方式。如果您修改了加密方法您需要手动删除远端的所有文件,重新同步(从而上传)加密文件。", + "settings_encryptionmethod_rclone": "RClone(推荐)", + "settings_encryptionmethod_openssl": "OpenSSL(旧方法)", "settings_autorun": "自动运行", "settings_autorun_desc": "每隔一段时间,此插件尝试自动同步。会影响到电池用量。", "settings_autorun_notset": "(不设置)", diff --git a/src/langs/zh_tw.json b/src/langs/zh_tw.json index c86a8eb..4e1819d 100644 --- a/src/langs/zh_tw.json +++ b/src/langs/zh_tw.json @@ -109,7 +109,11 @@ "modal_sizesconflict_copynotice": "所有的檔案大小衝突資訊,已被複制到剪貼簿!", "settings_basic": "基本設定", "settings_password": "密碼", - "settings_password_desc": "端到端加密的密碼。不填寫則代表沒密碼。您需要點選“確認”來修改。注意:密碼和其它資訊都會在本地儲存。", + "settings_password_desc": "端到端加密的密碼。不填寫則代表沒密碼。您需要點選“確認”來修改。注意:密碼和其它資訊都會在本地儲存。如果您修改了密碼,您需要手動刪除遠端的所有檔案,重新同步(從而上傳)加密檔案。", + "settings_encryptionmethod": "加密方法", + "settings_encryptionmethod_desc": "端到端加密的方法。推薦選用 RClone 方法,但是它沒有加密檔案路徑結構。OpenSSL 是本外掛一開始就支援的方式。如果您修改了加密方法您需要手動刪除遠端的所有檔案,重新同步(從而上傳)加密檔案。", + "settings_encryptionmethod_rclone": "RClone(推薦)", + "settings_encryptionmethod_openssl": "OpenSSL(舊方法)", "settings_autorun": "自動執行", "settings_autorun_desc": "每隔一段時間,此外掛嘗試自動同步。會影響到電池用量。", "settings_autorun_notset": "(不設定)", diff --git a/src/main.ts b/src/main.ts index d6c7f0b..638c120 100644 --- a/src/main.ts +++ b/src/main.ts @@ -67,6 +67,7 @@ import { SyncAlgoV3Modal } from "./syncAlgoV3Notice"; import AggregateError from "aggregate-error"; import { exportVaultSyncPlansToFiles } from "./debugMode"; import { changeMobileStatusBar, compareVersion } from "./misc"; +import { Cipher } from "./encryptUnified"; const DEFAULT_SETTINGS: RemotelySavePluginSettings = { s3: DEFAULT_S3_CONFIG, @@ -97,6 +98,7 @@ const DEFAULT_SETTINGS: RemotelySavePluginSettings = { syncDirection: "bidirectional", obfuscateSettingFile: true, enableMobileStatusBar: false, + encryptionMethod: "unknown", }; interface OAuth2Info { @@ -254,10 +256,12 @@ export default class RemotelySavePlugin extends Plugin { getNotice(t("syncrun_step3")); } this.syncStatus = "checking_password"; - const passwordCheckResult = await isPasswordOk( - remoteEntityList, - this.settings.password + + const cipher = new Cipher( + this.settings.password, + this.settings.encryptionMethod ?? "unknown" ); + const passwordCheckResult = await isPasswordOk(remoteEntityList, cipher); if (!passwordCheckResult.ok) { getNotice(t("syncrun_passworderr")); throw Error(passwordCheckResult.reason); @@ -306,7 +310,7 @@ export default class RemotelySavePlugin extends Plugin { this.app.vault.configDir, this.settings.syncUnderscoreItems ?? false, this.settings.ignorePaths ?? [], - this.settings.password, + cipher, this.settings.serviceType ); mixedEntityMappings = await getSyncPlanInplace( @@ -341,7 +345,7 @@ export default class RemotelySavePlugin extends Plugin { this.vaultRandomID, profileID, this.app.vault, - this.settings.password, + cipher, this.settings.concurrency ?? 5, (key: string) => self.trash(key), this.settings.protectModifyPercentage ?? 50, @@ -911,6 +915,22 @@ export default class RemotelySavePlugin extends Plugin { this.settings.enableMobileStatusBar = false; } + if ( + this.settings.encryptionMethod === undefined || + this.settings.encryptionMethod === "unknown" + ) { + if ( + this.settings.password === undefined || + this.settings.password === "" + ) { + // we have a preferred way + this.settings.encryptionMethod = "rclone-base64"; + } else { + // likely to be inherited from the old version + this.settings.encryptionMethod = "openssl-base64"; + } + } + await this.saveSettings(); } diff --git a/src/remote.ts b/src/remote.ts index d361d0f..2048b65 100644 --- a/src/remote.ts +++ b/src/remote.ts @@ -12,6 +12,7 @@ import * as dropbox from "./remoteForDropbox"; import * as onedrive from "./remoteForOnedrive"; import * as s3 from "./remoteForS3"; import * as webdav from "./remoteForWebdav"; +import { Cipher } from "./encryptUnified"; export class RemoteClient { readonly serviceType: SUPPORTED_SERVICES_TYPE; @@ -105,8 +106,8 @@ export class RemoteClient { uploadToRemote = async ( fileOrFolderPath: string, vault: Vault | undefined, - isRecursively: boolean = false, - password: string = "", + isRecursively: boolean, + cipher: Cipher, remoteEncryptedKey: string = "", foldersCreatedBefore: Set | undefined = undefined, uploadRaw: boolean = false, @@ -119,7 +120,7 @@ export class RemoteClient { fileOrFolderPath, vault, isRecursively, - password, + cipher, remoteEncryptedKey, uploadRaw, rawContent @@ -130,7 +131,7 @@ export class RemoteClient { fileOrFolderPath, vault, isRecursively, - password, + cipher, remoteEncryptedKey, uploadRaw, rawContent @@ -141,7 +142,7 @@ export class RemoteClient { fileOrFolderPath, vault, isRecursively, - password, + cipher, remoteEncryptedKey, foldersCreatedBefore, uploadRaw, @@ -153,7 +154,7 @@ export class RemoteClient { fileOrFolderPath, vault, isRecursively, - password, + cipher, remoteEncryptedKey, foldersCreatedBefore, uploadRaw, @@ -185,7 +186,7 @@ export class RemoteClient { fileOrFolderPath: string, vault: Vault, mtime: number, - password: string = "", + cipher: Cipher, remoteEncryptedKey: string = "", skipSaving: boolean = false ) => { @@ -196,7 +197,7 @@ export class RemoteClient { fileOrFolderPath, vault, mtime, - password, + cipher, remoteEncryptedKey, skipSaving ); @@ -206,7 +207,7 @@ export class RemoteClient { fileOrFolderPath, vault, mtime, - password, + cipher, remoteEncryptedKey, skipSaving ); @@ -216,7 +217,7 @@ export class RemoteClient { fileOrFolderPath, vault, mtime, - password, + cipher, remoteEncryptedKey, skipSaving ); @@ -226,7 +227,7 @@ export class RemoteClient { fileOrFolderPath, vault, mtime, - password, + cipher, remoteEncryptedKey, skipSaving ); @@ -237,7 +238,7 @@ export class RemoteClient { deleteFromRemote = async ( fileOrFolderPath: string, - password: string = "", + cipher: Cipher, remoteEncryptedKey: string = "" ) => { if (this.serviceType === "s3") { @@ -245,28 +246,28 @@ export class RemoteClient { s3.getS3Client(this.s3Config!), this.s3Config!, fileOrFolderPath, - password, + cipher, remoteEncryptedKey ); } else if (this.serviceType === "webdav") { return await webdav.deleteFromRemote( this.webdavClient!, fileOrFolderPath, - password, + cipher, remoteEncryptedKey ); } else if (this.serviceType === "dropbox") { return await dropbox.deleteFromRemote( this.dropboxClient!, fileOrFolderPath, - password, + cipher, remoteEncryptedKey ); } else if (this.serviceType === "onedrive") { return await onedrive.deleteFromRemote( this.onedriveClient!, fileOrFolderPath, - password, + cipher, remoteEncryptedKey ); } else { diff --git a/src/remoteForDropbox.ts b/src/remoteForDropbox.ts index a98d5d2..09dd693 100644 --- a/src/remoteForDropbox.ts +++ b/src/remoteForDropbox.ts @@ -10,7 +10,6 @@ import { OAUTH2_FORCE_EXPIRE_MILLISECONDS, UploadedType, } from "./baseTypes"; -import { decryptArrayBuffer, encryptArrayBuffer } from "./encrypt"; import { bufferToArrayBuffer, getFolderLevels, @@ -18,6 +17,7 @@ import { headersToRecord, mkdirpInVault, } from "./misc"; +import { Cipher } from "./encryptUnified"; export { Dropbox } from "dropbox"; @@ -451,8 +451,8 @@ export const uploadToRemote = async ( client: WrappedDropboxClient, fileOrFolderPath: string, vault: Vault | undefined, - isRecursively: boolean = false, - password: string = "", + isRecursively: boolean, + cipher: Cipher, remoteEncryptedKey: string = "", foldersCreatedBefore: Set | undefined = undefined, uploadRaw: boolean = false, @@ -463,7 +463,7 @@ export const uploadToRemote = async ( await client.init(); let uploadFile = fileOrFolderPath; - if (password !== "") { + if (!cipher.isPasswordEmpty()) { if (remoteEncryptedKey === undefined || remoteEncryptedKey === "") { throw Error( `uploadToRemote(dropbox) you have password but remoteEncryptedKey is empty!` @@ -497,7 +497,7 @@ export const uploadToRemote = async ( throw Error(`you specify uploadRaw, but you also provide a folder key!`); } // folder - if (password === "") { + if (cipher.isPasswordEmpty()) { // if not encrypted, mkdir a remote folder if (foldersCreatedBefore?.has(uploadFile)) { // created, pass @@ -564,8 +564,8 @@ export const uploadToRemote = async ( localContent = await vault.adapter.readBinary(fileOrFolderPath); } let remoteContent = localContent; - if (password !== "") { - remoteContent = await encryptArrayBuffer(localContent, password); + if (!cipher.isPasswordEmpty()) { + remoteContent = await cipher.encryptContent(localContent); } // in dropbox, we don't need to create folders before uploading! cool! // TODO: filesUploadSession for larger files (>=150 MB) @@ -670,7 +670,7 @@ export const downloadFromRemote = async ( fileOrFolderPath: string, vault: Vault, mtime: number, - password: string = "", + cipher: Cipher, remoteEncryptedKey: string = "", skipSaving: boolean = false ) => { @@ -691,14 +691,14 @@ export const downloadFromRemote = async ( return new ArrayBuffer(0); } else { let downloadFile = fileOrFolderPath; - if (password !== "") { + if (!cipher.isPasswordEmpty()) { downloadFile = remoteEncryptedKey; } downloadFile = getDropboxPath(downloadFile, client.remoteBaseDir); const remoteContent = await downloadFromRemoteRaw(client, downloadFile); let localContent = remoteContent; - if (password !== "") { - localContent = await decryptArrayBuffer(remoteContent, password); + if (!cipher.isPasswordEmpty()) { + localContent = await cipher.decryptContent(remoteContent); } if (!skipSaving) { await vault.adapter.writeBinary(fileOrFolderPath, localContent, { @@ -712,14 +712,14 @@ export const downloadFromRemote = async ( export const deleteFromRemote = async ( client: WrappedDropboxClient, fileOrFolderPath: string, - password: string = "", + cipher: Cipher, remoteEncryptedKey: string = "" ) => { if (fileOrFolderPath === "/") { return; } let remoteFileName = fileOrFolderPath; - if (password !== "") { + if (!cipher.isPasswordEmpty()) { remoteFileName = remoteEncryptedKey; } remoteFileName = getDropboxPath(remoteFileName, client.remoteBaseDir); diff --git a/src/remoteForOnedrive.ts b/src/remoteForOnedrive.ts index 298ccda..c567f99 100644 --- a/src/remoteForOnedrive.ts +++ b/src/remoteForOnedrive.ts @@ -17,13 +17,13 @@ import { Entity, UploadedType, } from "./baseTypes"; -import { decryptArrayBuffer, encryptArrayBuffer } from "./encrypt"; import { bufferToArrayBuffer, getRandomArrayBuffer, getRandomIntInclusive, mkdirpInVault, } from "./misc"; +import { Cipher } from "./encryptUnified"; const SCOPES = ["User.Read", "Files.ReadWrite.AppFolder", "offline_access"]; const REDIRECT_URI = `obsidian://${COMMAND_CALLBACK_ONEDRIVE}`; @@ -694,8 +694,8 @@ export const uploadToRemote = async ( client: WrappedOnedriveClient, fileOrFolderPath: string, vault: Vault | undefined, - isRecursively: boolean = false, - password: string = "", + isRecursively: boolean, + cipher: Cipher, remoteEncryptedKey: string = "", foldersCreatedBefore: Set | undefined = undefined, uploadRaw: boolean = false, @@ -704,7 +704,7 @@ export const uploadToRemote = async ( await client.init(); let uploadFile = fileOrFolderPath; - if (password !== "") { + if (!cipher.isPasswordEmpty()) { if (remoteEncryptedKey === undefined || remoteEncryptedKey === "") { throw Error( `uploadToRemote(onedrive) you have password but remoteEncryptedKey is empty!` @@ -734,7 +734,7 @@ export const uploadToRemote = async ( throw Error(`you specify uploadRaw, but you also provide a folder key!`); } // folder - if (password === "") { + if (cipher.isPasswordEmpty()) { // if not encrypted, mkdir a remote folder if (foldersCreatedBefore?.has(uploadFile)) { // created, pass @@ -770,9 +770,8 @@ export const uploadToRemote = async ( 1, 65536 /* max allowed */ ); - const arrBufRandom = await encryptArrayBuffer( - getRandomArrayBuffer(byteLengthRandom), - password + const arrBufRandom = await cipher.encryptContent( + getRandomArrayBuffer(byteLengthRandom) ); // an encrypted folder is always small, we just use put here @@ -816,8 +815,8 @@ export const uploadToRemote = async ( localContent = await vault.adapter.readBinary(fileOrFolderPath); } let remoteContent = localContent; - if (password !== "") { - remoteContent = await encryptArrayBuffer(localContent, password); + if (!cipher.isPasswordEmpty()) { + remoteContent = await cipher.encryptContent(localContent); } // no need to create parent folders firstly, cool! @@ -930,7 +929,7 @@ export const downloadFromRemote = async ( fileOrFolderPath: string, vault: Vault, mtime: number, - password: string = "", + cipher: Cipher, remoteEncryptedKey: string = "", skipSaving: boolean = false ) => { @@ -948,14 +947,14 @@ export const downloadFromRemote = async ( return new ArrayBuffer(0); } else { let downloadFile = fileOrFolderPath; - if (password !== "") { + if (!cipher.isPasswordEmpty()) { downloadFile = remoteEncryptedKey; } downloadFile = getOnedrivePath(downloadFile, client.remoteBaseDir); const remoteContent = await downloadFromRemoteRaw(client, downloadFile); let localContent = remoteContent; - if (password !== "") { - localContent = await decryptArrayBuffer(remoteContent, password); + if (!cipher.isPasswordEmpty()) { + localContent = await cipher.decryptContent(remoteContent); } if (!skipSaving) { await vault.adapter.writeBinary(fileOrFolderPath, localContent, { @@ -969,14 +968,14 @@ export const downloadFromRemote = async ( export const deleteFromRemote = async ( client: WrappedOnedriveClient, fileOrFolderPath: string, - password: string = "", + cipher: Cipher, remoteEncryptedKey: string = "" ) => { if (fileOrFolderPath === "/") { return; } let remoteFileName = fileOrFolderPath; - if (password !== "") { + if (!cipher.isPasswordEmpty()) { remoteFileName = remoteEncryptedKey; } remoteFileName = getOnedrivePath(remoteFileName, client.remoteBaseDir); diff --git a/src/remoteForS3.ts b/src/remoteForS3.ts index 6eacd7b..692d4b6 100644 --- a/src/remoteForS3.ts +++ b/src/remoteForS3.ts @@ -33,7 +33,6 @@ import { UploadedType, VALID_REQURL, } from "./baseTypes"; -import { decryptArrayBuffer, encryptArrayBuffer } from "./encrypt"; import { arrayBufferToBuffer, bufferToArrayBuffer, @@ -43,6 +42,7 @@ import { export { S3Client } from "@aws-sdk/client-s3"; import PQueue from "p-queue"; +import { Cipher } from "./encryptUnified"; //////////////////////////////////////////////////////////////////////////////// // special handler using Obsidian requestUrl @@ -358,8 +358,8 @@ export const uploadToRemote = async ( s3Config: S3Config, fileOrFolderPath: string, vault: Vault | undefined, - isRecursively: boolean = false, - password: string = "", + isRecursively: boolean, + cipher: Cipher, remoteEncryptedKey: string = "", uploadRaw: boolean = false, rawContent: string | ArrayBuffer = "", @@ -368,7 +368,7 @@ export const uploadToRemote = async ( ): Promise => { console.debug(`uploading ${fileOrFolderPath}`); let uploadFile = fileOrFolderPath; - if (password !== "") { + if (!cipher.isPasswordEmpty()) { if (remoteEncryptedKey === undefined || remoteEncryptedKey === "") { throw Error( `uploadToRemote(s3) you have password but remoteEncryptedKey is empty!` @@ -416,7 +416,7 @@ export const uploadToRemote = async ( // file // we ignore isRecursively parameter here let contentType = DEFAULT_CONTENT_TYPE; - if (password === "") { + if (cipher.isPasswordEmpty()) { contentType = mime.contentType( mime.lookup(fileOrFolderPath) || DEFAULT_CONTENT_TYPE @@ -447,8 +447,8 @@ export const uploadToRemote = async ( } } let remoteContent = localContent; - if (password !== "") { - remoteContent = await encryptArrayBuffer(localContent, password); + if (!cipher.isPasswordEmpty()) { + remoteContent = await cipher.encryptContent(localContent); } const bytesIn5MB = 5242880; @@ -645,8 +645,8 @@ export const downloadFromRemote = async ( fileOrFolderPath: string, vault: Vault, mtime: number, - password: string = "", - remoteEncryptedKey: string = "", + cipher: Cipher, + remoteEncryptedKey: string, skipSaving: boolean = false ) => { const isFolder = fileOrFolderPath.endsWith("/"); @@ -664,7 +664,7 @@ export const downloadFromRemote = async ( return new ArrayBuffer(0); } else { let downloadFile = fileOrFolderPath; - if (password !== "") { + if (!cipher.isPasswordEmpty()) { downloadFile = remoteEncryptedKey; } downloadFile = getRemoteWithPrefixPath( @@ -677,8 +677,8 @@ export const downloadFromRemote = async ( downloadFile ); let localContent = remoteContent; - if (password !== "") { - localContent = await decryptArrayBuffer(remoteContent, password); + if (!cipher.isPasswordEmpty()) { + localContent = await cipher.decryptContent(remoteContent); } if (!skipSaving) { await vault.adapter.writeBinary(fileOrFolderPath, localContent, { @@ -700,14 +700,14 @@ export const deleteFromRemote = async ( s3Client: S3Client, s3Config: S3Config, fileOrFolderPath: string, - password: string = "", + cipher: Cipher, remoteEncryptedKey: string = "" ) => { if (fileOrFolderPath === "/") { return; } let remoteFileName = fileOrFolderPath; - if (password !== "") { + if (!cipher.isPasswordEmpty()) { remoteFileName = remoteEncryptedKey; } remoteFileName = getRemoteWithPrefixPath( @@ -721,7 +721,7 @@ export const deleteFromRemote = async ( }) ); - if (fileOrFolderPath.endsWith("/") && password === "") { + if (fileOrFolderPath.endsWith("/") && cipher.isPasswordEmpty()) { const x = await listFromRemoteRaw(s3Client, s3Config, remoteFileName); x.forEach(async (element) => { await s3Client.send( @@ -731,7 +731,7 @@ export const deleteFromRemote = async ( }) ); }); - } else if (fileOrFolderPath.endsWith("/") && password !== "") { + } else if (fileOrFolderPath.endsWith("/") && !cipher.isPasswordEmpty()) { // TODO } else { // pass diff --git a/src/remoteForWebdav.ts b/src/remoteForWebdav.ts index 211238d..66f1f8e 100644 --- a/src/remoteForWebdav.ts +++ b/src/remoteForWebdav.ts @@ -4,10 +4,11 @@ import { Platform, Vault, requestUrl } from "obsidian"; import { Queue } from "@fyears/tsqueue"; import chunk from "lodash/chunk"; import flatten from "lodash/flatten"; +import cloneDeep from "lodash/cloneDeep"; import { getReasonPhrase } from "http-status-codes"; import { Entity, UploadedType, VALID_REQURL, WebdavConfig } from "./baseTypes"; -import { decryptArrayBuffer, encryptArrayBuffer } from "./encrypt"; import { bufferToArrayBuffer, getPathFolder, mkdirpInVault } from "./misc"; +import { Cipher } from "./encryptUnified"; import type { FileStat, @@ -139,7 +140,6 @@ if (VALID_REQURL) { // @ts-ignore import { AuthType, BufferLike, createClient } from "webdav/dist/web/index.js"; -import cloneDeep from "lodash/cloneDeep"; export type { WebDAVClient } from "webdav"; export const DEFAULT_WEBDAV_CONFIG = { @@ -316,15 +316,15 @@ export const uploadToRemote = async ( client: WrappedWebdavClient, fileOrFolderPath: string, vault: Vault | undefined, - isRecursively: boolean = false, - password: string = "", + isRecursively: boolean, + cipher: Cipher, remoteEncryptedKey: string = "", uploadRaw: boolean = false, rawContent: string | ArrayBuffer = "" ): Promise => { await client.init(); let uploadFile = fileOrFolderPath; - if (password !== "") { + if (!cipher.isPasswordEmpty()) { if (remoteEncryptedKey === undefined || remoteEncryptedKey === "") { throw Error( `uploadToRemote(webdav) you have password but remoteEncryptedKey is empty!` @@ -343,7 +343,7 @@ export const uploadToRemote = async ( throw Error(`you specify uploadRaw, but you also provide a folder key!`); } // folder - if (password === "") { + if (cipher.isPasswordEmpty()) { // if not encrypted, mkdir a remote folder await client.client.createDirectory(uploadFile, { recursive: true, @@ -386,8 +386,8 @@ export const uploadToRemote = async ( mtimeCli = (await vault.adapter.stat(fileOrFolderPath))?.mtime; } let remoteContent = localContent; - if (password !== "") { - remoteContent = await encryptArrayBuffer(localContent, password); + if (!cipher.isPasswordEmpty()) { + remoteContent = await cipher.encryptContent(localContent); } // updated 20220326: the algorithm guarantee this // // we need to create folders before uploading @@ -491,7 +491,7 @@ export const downloadFromRemote = async ( fileOrFolderPath: string, vault: Vault, mtime: number, - password: string = "", + cipher: Cipher, remoteEncryptedKey: string = "", skipSaving: boolean = false ) => { @@ -512,15 +512,15 @@ export const downloadFromRemote = async ( return new ArrayBuffer(0); } else { let downloadFile = fileOrFolderPath; - if (password !== "") { + if (!cipher.isPasswordEmpty()) { downloadFile = remoteEncryptedKey; } downloadFile = getWebdavPath(downloadFile, client.remoteBaseDir); // console.info(`downloadFile=${downloadFile}`); const remoteContent = await downloadFromRemoteRaw(client, downloadFile); let localContent = remoteContent; - if (password !== "") { - localContent = await decryptArrayBuffer(remoteContent, password); + if (!cipher.isPasswordEmpty()) { + localContent = await cipher.decryptContent(remoteContent); } if (!skipSaving) { await vault.adapter.writeBinary(fileOrFolderPath, localContent, { @@ -534,14 +534,14 @@ export const downloadFromRemote = async ( export const deleteFromRemote = async ( client: WrappedWebdavClient, fileOrFolderPath: string, - password: string = "", + cipher: Cipher, remoteEncryptedKey: string = "" ) => { if (fileOrFolderPath === "/") { return; } let remoteFileName = fileOrFolderPath; - if (password !== "") { + if (!cipher.isPasswordEmpty()) { remoteFileName = remoteEncryptedKey; } remoteFileName = getWebdavPath(remoteFileName, client.remoteBaseDir); diff --git a/src/settings.ts b/src/settings.ts index 8b072d1..b3b7222 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -22,6 +22,7 @@ import { VALID_REQURL, WebdavAuthType, WebdavDepthType, + CipherMethodType, } from "./baseTypes"; import { exportVaultSyncPlansToFiles } from "./debugMode"; import { exportQrCodeUri } from "./importExport"; @@ -1634,6 +1635,28 @@ export class RemotelySaveSettingTab extends PluginSettingTab { }); }); + new Setting(basicDiv) + .setName(t("settings_encryptionmethod")) + .setDesc(t("settings_encryptionmethod_desc")) + .addDropdown((dropdown) => { + dropdown.addOption("rclone", t("settings_encryptionmethod_rclone")); + dropdown.addOption("openssl", t("settings_encryptionmethod_openssl")); + if (this.plugin.settings.encryptionMethod === "rclone-base64") { + dropdown.setValue("rclone"); + } else if (this.plugin.settings.encryptionMethod === "openssl-base64") { + dropdown.setValue("openssl"); + } + + dropdown.onChange(async (val: string) => { + if (val === "rclone") { + this.plugin.settings.encryptionMethod = "rclone-base64"; + } else if (val === "openssl") { + this.plugin.settings.encryptionMethod = "openssl-base64"; + } + await this.plugin.saveSettings(); + }); + }); + new Setting(basicDiv) .setName(t("settings_autorun")) .setDesc(t("settings_autorun_desc")) diff --git a/src/sync.ts b/src/sync.ts index 4b3e0d9..068dec1 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -1,6 +1,7 @@ import PQueue from "p-queue"; import XRegExp from "xregexp"; import type { + CipherMethodType, ConflictActionType, EmptyFolderCleanType, Entity, @@ -22,14 +23,6 @@ import { DEFAULT_FILE_NAME_FOR_METADATAONREMOTE, DEFAULT_FILE_NAME_FOR_METADATAONREMOTE2, } from "./metadataOnRemote"; -import { - MAGIC_ENCRYPTED_PREFIX_BASE32, - MAGIC_ENCRYPTED_PREFIX_BASE64URL, - decryptBase32ToString, - decryptBase64urlToString, - encryptStringToBase64url, - getSizeFromOrigToEnc, -} from "./encrypt"; import { RemoteClient } from "./remote"; import { Vault } from "obsidian"; @@ -39,6 +32,7 @@ import { clearPrevSyncRecordByVaultAndProfile, upsertPrevSyncRecordByVaultAndProfile, } from "./localdb"; +import { Cipher } from "./encryptUnified"; export type SyncStatusType = | "idle" @@ -55,19 +49,17 @@ export type SyncStatusType = export interface PasswordCheckType { ok: boolean; reason: - | "ok" | "empty_remote" + | "unknown_encryption_method" | "remote_encrypted_local_no_password" | "password_matched" - | "password_not_matched" - | "invalid_text_after_decryption" - | "remote_not_encrypted_local_has_password" - | "no_password_both_sides"; + | "password_not_matched_or_remote_not_encrypted" + | "likely_no_password_both_sides"; } export const isPasswordOk = async ( remote: Entity[], - password: string = "" + cipher: Cipher ): Promise => { if (remote === undefined || remote.length === 0) { // remote empty @@ -77,81 +69,40 @@ export const isPasswordOk = async ( }; } const santyCheckKey = remote[0].keyRaw; - if (santyCheckKey.startsWith(MAGIC_ENCRYPTED_PREFIX_BASE32)) { - // this is encrypted using old base32! - // try to decrypt it using the provided password. - if (password === "") { + + if (cipher.isPasswordEmpty()) { + // TODO: no way to distinguish remote rclone encrypted + // if local has no password?? + if (Cipher.isLikelyEncryptedName(santyCheckKey)) { return { ok: false, reason: "remote_encrypted_local_no_password", }; - } - try { - const res = await decryptBase32ToString(santyCheckKey, password); - - // additional test - // because iOS Safari bypasses decryption with wrong password! - if (isVaildText(res)) { - return { - ok: true, - reason: "password_matched", - }; - } else { - return { - ok: false, - reason: "invalid_text_after_decryption", - }; - } - } catch (error) { + } else { return { - ok: false, - reason: "password_not_matched", - }; - } - } - if (santyCheckKey.startsWith(MAGIC_ENCRYPTED_PREFIX_BASE64URL)) { - // this is encrypted using new base64url! - // try to decrypt it using the provided password. - if (password === "") { - return { - ok: false, - reason: "remote_encrypted_local_no_password", - }; - } - try { - const res = await decryptBase64urlToString(santyCheckKey, password); - - // additional test - // because iOS Safari bypasses decryption with wrong password! - if (isVaildText(res)) { - return { - ok: true, - reason: "password_matched", - }; - } else { - return { - ok: false, - reason: "invalid_text_after_decryption", - }; - } - } catch (error) { - return { - ok: false, - reason: "password_not_matched", + ok: true, + reason: "likely_no_password_both_sides", }; } } else { - // it is not encrypted! - if (password !== "") { + if (cipher.method === "unknown") { return { ok: false, - reason: "remote_not_encrypted_local_has_password", + reason: "unknown_encryption_method", + }; + } + try { + await cipher.decryptName(santyCheckKey); + return { + ok: true, + reason: "password_matched", + }; + } catch (error) { + return { + ok: false, + reason: "password_not_matched_or_remote_not_encrypted", }; } - return { - ok: true, - reason: "no_password_both_sides", - }; } }; @@ -231,12 +182,9 @@ const copyEntityAndFixTimeFormat = ( /** * Inplace, no copy again. - * @param remote - * @param password - * @returns */ -const decryptRemoteEntityInplace = async (remote: Entity, password: string) => { - if (password == undefined || password === "") { +const decryptRemoteEntityInplace = async (remote: Entity, cipher: Cipher) => { + if (cipher?.isPasswordEmpty()) { remote.key = remote.keyRaw; remote.keyEnc = remote.keyRaw; remote.size = remote.sizeRaw; @@ -244,19 +192,9 @@ const decryptRemoteEntityInplace = async (remote: Entity, password: string) => { return remote; } - if (remote.keyRaw.startsWith(MAGIC_ENCRYPTED_PREFIX_BASE32)) { - remote.keyEnc = remote.keyRaw; - remote.key = await decryptBase32ToString(remote.keyEnc, password); - remote.sizeEnc = remote.sizeRaw; - } else if (remote.keyRaw.startsWith(MAGIC_ENCRYPTED_PREFIX_BASE64URL)) { - remote.keyEnc = remote.keyRaw; - remote.key = await decryptBase64urlToString(remote.keyEnc, password); - remote.sizeEnc = remote.sizeRaw; - } else { - throw Error( - `unexpected key to decrypt: ${JSON.stringify(remote, null, 2)}` - ); - } + remote.keyEnc = remote.keyRaw; + remote.key = await cipher.decryptName(remote.keyEnc); + remote.sizeEnc = remote.sizeRaw; // TODO // remote.size = getSizeFromEncToOrig(remote.sizeEnc, password); @@ -309,13 +247,10 @@ const ensureMTimeOfRemoteEntityValid = (remote: Entity) => { /** * Inplace, no copy again. - * @param local - * @param password - * @returns */ const encryptLocalEntityInplace = async ( local: Entity, - password: string, + cipher: Cipher, remoteKeyEnc: string | undefined ) => { // console.debug( @@ -333,7 +268,7 @@ const encryptLocalEntityInplace = async ( throw Error(`local ${local.keyRaw} is abnormal without key`); } - if (password === undefined || password === "") { + if (cipher.isPasswordEmpty()) { local.sizeEnc = local.sizeRaw; // if no enc, the remote file has the same size local.keyEnc = local.keyRaw; return local; @@ -344,7 +279,7 @@ const encryptLocalEntityInplace = async ( // it's not filled yet, we fill it // local.size is possibly undefined if it's "prevSync" Entity // but local.key should always have value - local.sizeEnc = getSizeFromOrigToEnc(local.size); + local.sizeEnc = cipher.getSizeFromOrigToEnc(local.size); } if (local.keyEnc === undefined || local.keyEnc === "") { @@ -357,10 +292,7 @@ const encryptLocalEntityInplace = async ( local.keyEnc = remoteKeyEnc; } else { // we assign a new encrypted key because of no remote - // the old version uses base32 - // local.keyEnc = await encryptStringToBase32(local.key, password); - // the new version users base64url - local.keyEnc = await encryptStringToBase64url(local.key, password); + local.keyEnc = await cipher.encryptName(local.key); } } return local; @@ -377,7 +309,7 @@ export const ensembleMixedEnties = async ( configDir: string, syncUnderscoreItems: boolean, ignorePaths: string[], - password: string, + cipher: Cipher, serviceType: SUPPORTED_SERVICES_TYPE ): Promise => { const finalMappings: SyncPlanType = {}; @@ -387,7 +319,7 @@ export const ensembleMixedEnties = async ( const remoteCopied = ensureMTimeOfRemoteEntityValid( await decryptRemoteEntityInplace( copyEntityAndFixTimeFormat(remote, serviceType), - password + cipher ) ); @@ -436,14 +368,14 @@ export const ensembleMixedEnties = async ( if (finalMappings.hasOwnProperty(key)) { const prevSyncCopied = await encryptLocalEntityInplace( copyEntityAndFixTimeFormat(prevSync, serviceType), - password, + cipher, finalMappings[key].remote?.keyEnc ); finalMappings[key].prevSync = prevSyncCopied; } else { const prevSyncCopied = await encryptLocalEntityInplace( copyEntityAndFixTimeFormat(prevSync, serviceType), - password, + cipher, undefined ); finalMappings[key] = { @@ -474,14 +406,14 @@ export const ensembleMixedEnties = async ( if (finalMappings.hasOwnProperty(key)) { const localCopied = await encryptLocalEntityInplace( copyEntityAndFixTimeFormat(local, serviceType), - password, + cipher, finalMappings[key].remote?.keyEnc ); finalMappings[key].local = localCopied; } else { const localCopied = await encryptLocalEntityInplace( copyEntityAndFixTimeFormat(local, serviceType), - password, + cipher, undefined ); finalMappings[key] = { @@ -1017,7 +949,7 @@ const dispatchOperationToActualV3 = async ( db: InternalDBs, vault: Vault, localDeleteFunc: any, - password: string + cipher: Cipher ) => { // console.debug( // `inside dispatchOperationToActualV3, key=${key}, r=${JSON.stringify( @@ -1045,7 +977,7 @@ const dispatchOperationToActualV3 = async ( if ( client.serviceType === "onedrive" && r.local!.size === 0 && - password === "" + cipher.isPasswordEmpty() ) { // special treatment for empty files for OneDrive // TODO: it's ugly, any other way? @@ -1057,10 +989,10 @@ const dispatchOperationToActualV3 = async ( r.key, vault, false, - password, + cipher, r.local!.keyEnc ); - await decryptRemoteEntityInplace(entity, password); + await decryptRemoteEntityInplace(entity, cipher); await fullfillMTimeOfRemoteEntityInplace(entity, mtimeCli); await upsertPrevSyncRecordByVaultAndProfile( db, @@ -1081,7 +1013,7 @@ const dispatchOperationToActualV3 = async ( r.key, vault, r.remote!.mtimeCli!, - password, + cipher, r.remote!.keyEnc ); await upsertPrevSyncRecordByVaultAndProfile( @@ -1092,7 +1024,7 @@ const dispatchOperationToActualV3 = async ( ); } else if (r.decision === "local_is_deleted_thus_also_delete_remote") { // local is deleted, we need to delete remote now - await client.deleteFromRemote(r.key, password, r.remote!.keyEnc); + await client.deleteFromRemote(r.key, cipher, r.remote!.keyEnc); await clearPrevSyncRecordByVaultAndProfile( db, vaultRandomID, @@ -1119,11 +1051,11 @@ const dispatchOperationToActualV3 = async ( r.key, vault, false, - password, + cipher, r.local!.keyEnc ); // we need to decrypt the key!!! - await decryptRemoteEntityInplace(entity, password); + await decryptRemoteEntityInplace(entity, cipher); await fullfillMTimeOfRemoteEntityInplace(entity, mtimeCli); await upsertPrevSyncRecordByVaultAndProfile( db, @@ -1133,7 +1065,7 @@ const dispatchOperationToActualV3 = async ( ); } else if (r.decision === "folder_to_be_deleted") { await localDeleteFunc(r.key); - await client.deleteFromRemote(r.key, password, r.remote!.keyEnc); + await client.deleteFromRemote(r.key, cipher, r.remote!.keyEnc); await clearPrevSyncRecordByVaultAndProfile( db, vaultRandomID, @@ -1151,7 +1083,7 @@ export const doActualSync = async ( vaultRandomID: string, profileID: string, vault: Vault, - password: string, + cipher: Cipher, concurrency: number, localDeleteFunc: any, protectModifyPercentage: number, @@ -1252,7 +1184,7 @@ export const doActualSync = async ( db, vault, localDeleteFunc, - password + cipher ); console.debug(`finished ${key}`); diff --git a/tests/encrypt.test.ts b/tests/encryptOpenSSL.test.ts similarity index 98% rename from tests/encrypt.test.ts rename to tests/encryptOpenSSL.test.ts index b9f1373..1ec579a 100644 --- a/tests/encrypt.test.ts +++ b/tests/encryptOpenSSL.test.ts @@ -10,13 +10,13 @@ import { encryptStringToBase64url, getSizeFromEncToOrig, getSizeFromOrigToEnc, -} from "../src/encrypt"; +} from "../src/encryptOpenSSL"; import { base64ToBase64url, bufferToArrayBuffer } from "../src/misc"; chai.use(chaiAsPromised); const expect = chai.expect; -describe("Encryption tests", () => { +describe("Encryption OpenSSL tests", () => { beforeEach(function () { global.window = { crypto: require("crypto").webcrypto,