diff --git a/.gitignore b/.gitignore index f694804..a7ce2e0 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ package-lock.json dist *.map test-vault + diff --git a/README.md b/README.md index 7808b7f..613a3a4 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,13 @@ You can install the plugin via the Community Plugins tab within Obsidian by sear > Please report any bugs or feature requests [here](https://github.com/meld-cp/obsidian-encrypt/issues). +### 2.3.0 +- add encrypt/decrypt of `.md` or `.encrypted` notes #68 (via file context menu, ribbon icon or command palette, helps with #91, #103, #108, #114) +- fix double blank line #107 +- fix offline decrypt tool #111 (found in `/tools/decrypt.html`) +- ribbon icon changes +- code refactor and clean up + ### 2.2.0 - stronger encryption (thanks @brycx) - fix #92 - Enable selection text in Reading view @@ -51,9 +58,9 @@ https://raw.githubusercontent.com/meld-cp/obsidian-encrypt/main/ Run the Encrypt/Decrypt command --> -### Standalone, always encrypted, notes (.encrypted files) +### Whole encrypted notes (.encrypted files) #### To create a new encrypted note -1. Select 'Create encrypted note' from the command palette. +1. Select 'Create encrypted note' from the command palette (or click the ribbon icon). 2. Enter and confirm a password and optional hint to use. 3. Edit your encrypted note (it's contents will be encrypted and saved to disk). @@ -64,9 +71,20 @@ https://raw.githubusercontent.com/meld-cp/obsidian-encrypt/main/ #### To changing an encrypted notes password and hint 1. Open the note as usual from the navigation tree. 2. Enter the password for the note. -3. Click 'Change Password' from the tab title bar or tab context menu. +3. Click the 'Change Password' icon from the tab title bar or tab context menu. 4. Enter the new password and hint. +#### To encrypt/decrypt an existing note +- Right-click the file and choose 'Encrypt note' or 'Decrypt note'. + +OR + +- Select the ribbon icon named 'Convert to or from an Encrypted note' to toggle the active file between encrypted and not. + +OR + +- Select 'Convert to or from an Encrypted note' from the command palette (or even better, bind a hot key) + ### In-place Encryption #### To encrypt selected text @@ -105,15 +123,14 @@ https://raw.githubusercontent.com/meld-cp/obsidian-encrypt/main/ ### Common Settings | | | |--|--| -| Confirm password | Confirm password when encrypting | +| Confirm password | Confirm password when encrypting (recommended) | | Remember password | Remember the last used password for this session. | | Remember Password Timeout | The number of minutes to remember the last used password. | -| Remember Password Using | Remember passwords by using File or Parent path | +| Remember Password Using | Remember passwords by using `File Name` or `Parent Folder` matching | ### Whole note encryption Settings | | | |--|--| -| Add ribbon icon to create note | Adds a ribbon icon to the left bar to create an encrypted note. | | Default view for new tabs | The default view that a new encrypted note tab gets opened in | ### In-place encryption Settings diff --git a/esbuild-tool-decrypt.config.mjs b/esbuild-tool-decrypt.config.mjs new file mode 100644 index 0000000..e08ee27 --- /dev/null +++ b/esbuild-tool-decrypt.config.mjs @@ -0,0 +1,24 @@ +import esbuild from "esbuild"; +import process from "process"; +import copyStaticFiles from 'esbuild-copy-static-files'; + +const prod = (process.argv[2] === 'production'); + +esbuild.build({ + entryPoints: ['src/tools/offline-decrypt.ts'], + bundle: true, + format: 'iife', + watch: !prod, + target: 'es2018', + logLevel: "info", + sourcemap: prod ? false : 'inline', + treeShaking: prod, + minify: prod, + outfile: './tools/offline-decrypt.js', + plugins:[ + copyStaticFiles({ + src: './src/tools/decrypt.html', + dest: './tools/decrypt.html', + }), + ] +}).catch(() => process.exit(1)); diff --git a/manifest.json b/manifest.json index 64ebc3f..dbda828 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "meld-encrypt", "name": "Meld Encrypt", - "version": "2.2.0", + "version": "2.3.0", "minAppVersion": "1.0.3", "description": "Hide secrets in your vault", "author": "meld-cp", @@ -10,5 +10,5 @@ "fundingUrl": { "Buy Me a Coffee": "https://www.buymeacoffee.com/cleon", "GitHub Sponsor": "https://github.com/sponsors/meld-cp" - } + } } \ No newline at end of file diff --git a/package.json b/package.json index b32517b..8afab8f 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,14 @@ { "name": "obsidian-sample-plugin", - "version": "2.2.0", + "version": "2.3.0", "description": "Hide secrets in your vault", "main": "main.js", "scripts": { "dev": "node esbuild.config.mjs", "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", - "version": "node version-bump.mjs && git add manifest.json versions.json" + "version": "node version-bump.mjs && git add manifest.json versions.json", + "dev-tool-decrypt": "tsc -noEmit -skipLibCheck && node esbuild-tool-decrypt.config.mjs", + "build-tool-decrypt": "tsc -noEmit -skipLibCheck && node esbuild-tool-decrypt.config.mjs production" }, "keywords": [], "author": "", diff --git a/src/PluginPasswordModal.ts b/src/PluginPasswordModal.ts new file mode 100644 index 0000000..97898c9 --- /dev/null +++ b/src/PluginPasswordModal.ts @@ -0,0 +1,197 @@ +import { App, Modal, Setting, TextComponent } from 'obsidian'; +import { UiHelper } from 'src/services/UiHelper'; +import { IPasswordAndHint } from './services/SessionPasswordService'; + +export default class PluginPasswordModal extends Modal { + + // input + private title: string; + private defaultPassword: IPasswordAndHint | null; + //private defaultHint?: string | null = null; + private confirmPassword: boolean; + private isEncrypting: boolean; + + // output + public resultConfirmed = false; + public resultPassword: IPasswordAndHint; + + constructor( + app: App, + title: string, + isEncrypting:boolean, + confirmPassword: boolean, + defaultPassword: IPasswordAndHint | null, + ) { + super(app); + this.title = title; + this.defaultPassword = defaultPassword; + this.confirmPassword = confirmPassword; + this.isEncrypting = isEncrypting; + } + + onOpen() { + const { contentEl } = this; + + contentEl.empty(); + + this.invalidate(); + + let password = this.defaultPassword?.password ?? ''; + let confirmPass = ''; + let hint = this.defaultPassword?.hint ?? ''; + + new Setting(contentEl).setHeading().setName( this.title ); + + /* Main password input*/ + + UiHelper.buildPasswordSetting({ + container: contentEl, + name: 'Password:', + placeholder: this.isEncrypting ? '' : `Hint: ${hint}`, + initialValue: password, + autoFocus: password == '', + onChangeCallback: (value) => { + password = value; + this.invalidate(); + }, + onEnterCallback: (value) =>{ + password = value; + this.invalidate(); + + if (password.length > 0){ + if (sConfirmPassword.settingEl.isShown()){ + //tcConfirmPassword.inputEl.focus(); + const elInp = sConfirmPassword.components.find( (bc) => bc instanceof TextComponent ); + if ( elInp instanceof TextComponent ){ + elInp.inputEl.focus(); + } + + }else if (sHint.settingEl.isShown()){ + //tcHint.inputEl.focus(); + const elInp = sHint.components.find( (bc) => bc instanceof TextComponent ); + if ( elInp instanceof TextComponent ){ + elInp.inputEl.focus(); + } + }else if( validate() ){ + this.close(); + } + } + } + }); + + /* End Main password input row */ + + /* Confirm password input row */ + const sConfirmPassword = UiHelper.buildPasswordSetting({ + container : contentEl, + name: 'Confirm Password:', + autoFocus: password != '', + onChangeCallback: (value) => { + confirmPass = value; + this.invalidate(); + }, + onEnterCallback: (value) =>{ + confirmPass = value; + this.invalidate(); + if (confirmPass.length > 0){ + if ( validate() ){ + if ( sHint.settingEl.isShown() ){ + //tcHint.inputEl.focus(); + const elInp = sHint.components.find( (bc) => bc instanceof TextComponent ); + if ( elInp instanceof TextComponent ){ + elInp.inputEl.focus(); + } + } + } + } + } + }); + + if ( !this.confirmPassword ){ + sConfirmPassword.settingEl.hide(); + } + + /* End Confirm password input row */ + + /* Hint input row */ + const sHint = new Setting(contentEl) + .setName('Optional Password Hint') + .addText( tc=>{ + //tcHint = tc; + tc.inputEl.placeholder = `Password Hint`; + tc.setValue(hint); + tc.onChange( v=> hint = v ); + tc.inputEl.on('keypress', '*', (ev, target) => { + if ( + ev.key == 'Enter' + && target instanceof HTMLInputElement + && target.value.length > 0 + ) { + ev.preventDefault(); + if ( validate() ){ + this.close(); + } + } + }); + }) + ; + if (!this.isEncrypting){ + sHint.settingEl.hide(); + } + + /* END Hint text row */ + + new Setting(contentEl).addButton( cb=>{ + cb + .setButtonText('Confirm') + .onClick( evt =>{ + if (validate()){ + this.close(); + } + }) + ; + }); + + const validate = () : boolean => { + this.invalidate(); + + sConfirmPassword.setDesc(''); + + if ( this.confirmPassword ){ + if (password != confirmPass){ + // passwords don't match + sConfirmPassword.setDesc('Passwords don\'t match'); + return false; + } + } + + this.resultConfirmed = true; + this.resultPassword = { password, hint }; + + return true; + } + + } + + openAsync(): Promise { + return new Promise( (resolve, reject) =>{ + + this.onClose = () =>{ + if (this.resultConfirmed == true){ + resolve( this.resultPassword ); + }else{ + reject(); + } + } + + this.open(); + + } ); + } + + private invalidate(){ + this.resultConfirmed = false; + this.resultPassword = { password: '', hint: '' }; + } + +} \ No newline at end of file diff --git a/src/features/feature-convert-note/FeatureConvertNote.ts b/src/features/feature-convert-note/FeatureConvertNote.ts new file mode 100644 index 0000000..25ec789 --- /dev/null +++ b/src/features/feature-convert-note/FeatureConvertNote.ts @@ -0,0 +1,224 @@ +import MeldEncrypt from "src/main"; +import { IMeldEncryptPluginSettings } from "src/settings/MeldEncryptPluginSettings"; +import { IMeldEncryptPluginFeature } from "../IMeldEncryptPluginFeature"; +import { Notice, TFile, TextFileView } from "obsidian"; +import PluginPasswordModal from "src/PluginPasswordModal"; +import { IPasswordAndHint, SessionPasswordService } from "src/services/SessionPasswordService"; +import { FileDataHelper, JsonFileEncoding } from "src/services/FileDataHelper"; +import { Utils } from "src/services/Utils"; +import { ENCRYPTED_FILE_EXTENSION } from "src/services/Constants"; + +export default class FeatureConvertNote implements IMeldEncryptPluginFeature { + + plugin: MeldEncrypt; + + async onload(plugin: MeldEncrypt, settings: IMeldEncryptPluginSettings) { + this.plugin = plugin; + + this.plugin.addCommand({ + id: 'meld-encrypt-convert-to-or-from-encrypted-note', + name: 'Convert to or from an Encrypted note', + icon: 'file-lock', + checkCallback: (checking) => this.processCommandConvertActiveNote( checking ), + }); + + this.plugin.addRibbonIcon( + 'file-lock', + 'Convert to or from an Encrypted note', + (_) => this.processCommandConvertActiveNote( false ) + ); + + + this.plugin.registerEvent( + this.plugin.app.workspace.on( 'file-menu', (menu, file) => { + if (file instanceof TFile){ + if ( file.extension == 'md' ){ + menu.addItem( (item) => { + item + .setTitle('Encrypt note') + .setIcon('file-lock') + .onClick( () => this.processCommandEncryptNote( file ) ); + } + ); + } + if ( file.extension == ENCRYPTED_FILE_EXTENSION ){ + menu.addItem( (item) => { + item + .setTitle('Decrypt note') + .setIcon('file') + .onClick( () => this.processCommandDecryptNote( file ) ); + } + ); + } + } + }) + ); + + } + + onunload(): void { } + + buildSettingsUi(containerEl: HTMLElement, saveSettingCallback: () => Promise): void { } + + private checkCanEncryptFile( file:TFile | null ) : boolean { + if ( file == null ){ + return false; + } + return file.extension == 'md'; + } + + private checkCanDecryptFile( file:TFile | null ) : boolean { + if ( file == null ){ + return false; + } + return file.extension == ENCRYPTED_FILE_EXTENSION; + } + + private processCommandEncryptNote( file:TFile ){ + this.getPasswordAndEncryptFile( file ).catch( reason => { + if (reason){ + new Notice(reason, 10000); + } + }); + } + + private processCommandDecryptNote( file:TFile ){ + this.getPasswordAndDecryptFile( file ).catch( reason => { + if (reason){ + new Notice(reason, 10000); + } + }); + } + + private processCommandConvertActiveNote( checking: boolean ) : boolean | void { + const file = this.plugin.app.workspace.getActiveFile(); + + if (checking){ + return this.checkCanEncryptFile(file) + || this.checkCanDecryptFile(file) + ; + } + + if ( file?.extension == 'md' ){ + this.getPasswordAndEncryptFile( file ).catch( reason => { + if (reason){ + new Notice(reason, 10000); + } + }); + } + + if ( file?.extension == ENCRYPTED_FILE_EXTENSION ){ + this.getPasswordAndDecryptFile( file ).catch( reason => { + if (reason){ + new Notice(reason, 10000); + } + }); + } + } + + private async getPasswordAndEncryptFile( file:TFile ) { + + if ( !this.checkCanEncryptFile(file) ) { + throw new Error( 'Unable to encrypt file' ); + } + + const defaultPw = SessionPasswordService.getByFile( file ); + + const pm = new PluginPasswordModal(app, 'Encrypt Note', true, true, defaultPw ); + try{ + const pw = await pm.openAsync(); + + const encryptedFileContent = await this.encryptFile(file, pw); + + await this.closeUpdateRememberPasswordThenReopen( file, ENCRYPTED_FILE_EXTENSION, encryptedFileContent, pw ); + + new Notice( '🔐 Note was encrypted 🔐' ); + + }catch( error ){ + if (error){ + new Notice(error, 10000); + } + } + } + + private async getPasswordAndDecryptFile( file:TFile ) { + if ( !this.checkCanDecryptFile(file) ) { + throw new Error( 'Unable to decrypt file' ); + } + + let passwordAndHint = SessionPasswordService.getByFile( file ); + if ( passwordAndHint.password != '' ){ + // try to decrypt using saved password + const decryptedContent = await this.decryptFile( file, passwordAndHint.password ); + if (decryptedContent != null){ + // update file + await this.closeUpdateRememberPasswordThenReopen( file, 'md', decryptedContent, passwordAndHint ); + return; + } + } + + // fetch from user + const encryptedFileContent = await app.vault.read( file ); + const encryptedData = JsonFileEncoding.decode( encryptedFileContent ); + + + const pwm = new PluginPasswordModal(app, 'Decrypt Note', false, false, { password: '', hint: encryptedData.hint } ); + try{ + passwordAndHint = await pwm.openAsync(); + + if (!pwm.resultConfirmed){ + return; + } + + const content = await this.decryptFile( file, passwordAndHint.password ); + if ( content == null ){ + throw new Error('Decryption failed'); + } + + await this.closeUpdateRememberPasswordThenReopen( file, 'md', content, passwordAndHint ); + + new Notice( '🔓 Note was decrypted 🔓' ); + + }catch(error){ + if (error){ + new Notice(error, 10000); + } + } + } + + private async closeUpdateRememberPasswordThenReopen( file:TFile, newFileExtension: string, content: string, pw:IPasswordAndHint ) { + + let didDetach = false; + + this.plugin.app.workspace.iterateAllLeaves( l=> { + if ( l.view instanceof TextFileView && l.view.file == file ){ + l.detach(); + didDetach = true; + } + }); + + try{ + //return await this.updateFile( file, newFileExtension, content ); + const newFilepath = Utils.getFilePathWithNewExtension(file, newFileExtension); + await app.vault.rename( file, newFilepath ); + await app.vault.modify( file, content ); + SessionPasswordService.putByFile( pw, file ); + }finally{ + if(didDetach){ + await app.workspace.getLeaf().openFile(file); + } + } + } + + private async encryptFile(file: TFile, passwordAndHint:IPasswordAndHint ) : Promise { + const content = await app.vault.read( file ); + const encryptedData = await FileDataHelper.encode( passwordAndHint.password, passwordAndHint.hint, content ); + return JsonFileEncoding.encode( encryptedData ); + } + + private async decryptFile(file: TFile, password:string) : Promise { + const encryptedFileContent = await app.vault.read( file ); + const encryptedData = JsonFileEncoding.decode( encryptedFileContent ); + return await FileDataHelper.decrypt(encryptedData, password ); + } +} \ No newline at end of file diff --git a/src/features/feature-inplace-encrypt/FeatureInplaceConstants.ts b/src/features/feature-inplace-encrypt/FeatureInplaceConstants.ts new file mode 100644 index 0000000..6c9153b --- /dev/null +++ b/src/features/feature-inplace-encrypt/FeatureInplaceConstants.ts @@ -0,0 +1,30 @@ + +export const _PREFIX_B = '%%🔐β '; +export const _PREFIX_B_VISIBLE = '🔐β '; + +export const _PREFIX_A = '%%🔐α '; +export const _PREFIX_A_VISIBLE = '🔐α '; +export const _PREFIX_OBSOLETE = '%%🔐 '; + +export const _PREFIX_ENCODE_DEFAULT = _PREFIX_B; +export const _PREFIX_ENCODE_DEFAULT_VISIBLE = _PREFIX_B_VISIBLE; + +// Should be listed by evaluation priority +export const _PREFIXES = [ + _PREFIX_B, + _PREFIX_B_VISIBLE, + _PREFIX_A, + _PREFIX_A_VISIBLE, + _PREFIX_OBSOLETE, +]; + +export const _SUFFIX_WITH_COMMENT = ' 🔐%%'; +export const _SUFFIX_NO_COMMENT = ' 🔐'; + +// Should be listed by evaluation priority +export const _SUFFIXES = [ + _SUFFIX_WITH_COMMENT, + _SUFFIX_NO_COMMENT +] + +export const _HINT = '💡'; \ No newline at end of file diff --git a/src/features/feature-inplace-encrypt/FeatureInplaceEncrypt.ts b/src/features/feature-inplace-encrypt/FeatureInplaceEncrypt.ts index b5a2d25..b9cf2b3 100644 --- a/src/features/feature-inplace-encrypt/FeatureInplaceEncrypt.ts +++ b/src/features/feature-inplace-encrypt/FeatureInplaceEncrypt.ts @@ -9,39 +9,10 @@ import { UiHelper } from "../../services/UiHelper"; import { SessionPasswordService } from "src/services/SessionPasswordService"; import { CryptoHelperFactory } from "src/services/CryptoHelperFactory"; import { Decryptable } from "./Decryptable"; +import { FeatureInplaceTextAnalysis } from "./featureInplaceTextAnalysis"; +import { _HINT, _PREFIXES, _PREFIX_A_VISIBLE, _PREFIX_B_VISIBLE, _PREFIX_ENCODE_DEFAULT, _PREFIX_ENCODE_DEFAULT_VISIBLE, _SUFFIXES, _SUFFIX_NO_COMMENT, _SUFFIX_WITH_COMMENT } from "./FeatureInplaceConstants"; - -const _PREFIX_B = '%%🔐β '; -const _PREFIX_B_VISIBLE = '🔐β '; - -const _PREFIX_A = '%%🔐α '; -const _PREFIX_A_VISIBLE = '🔐α '; -const _PREFIX_OBSOLETE = '%%🔐 '; - -const _PREFIX_ENCODE_DEFAULT = _PREFIX_B; -const _PREFIX_ENCODE_DEFAULT_VISIBLE = _PREFIX_B_VISIBLE; - -// Should be listed by evaluation priority -const _PREFIXES = [ - _PREFIX_B, - _PREFIX_B_VISIBLE, - _PREFIX_A, - _PREFIX_A_VISIBLE, - _PREFIX_OBSOLETE, -]; - -const _SUFFIX_WITH_COMMENT = ' 🔐%%'; -const _SUFFIX_NO_COMMENT = ' 🔐'; - -// Should be listed by evaluation priority -const _SUFFIXES = [ - _SUFFIX_WITH_COMMENT, - _SUFFIX_NO_COMMENT -] - -const _HINT = '💡'; - export default class FeatureInplaceEncrypt implements IMeldEncryptPluginFeature{ plugin:MeldEncrypt; pluginSettings: IMeldEncryptPluginSettings; @@ -106,7 +77,7 @@ export default class FeatureInplaceEncrypt implements IMeldEncryptPluginFeature{ const encryptedText = InplaceTextHelper.removeMarkers( text, markerStart, markerEnd ); - const selectionAnalysis = new SelectionAnalysis( encryptedText ); + const selectionAnalysis = new FeatureInplaceTextAnalysis( encryptedText ); if ( !selectionAnalysis.canDecrypt ){ return; @@ -167,7 +138,7 @@ export default class FeatureInplaceEncrypt implements IMeldEncryptPluginFeature{ } private async showDecryptedResultForPassword( decryptable: Decryptable, pw:string ): Promise { - const crypto = CryptoHelperFactory.BuildFromDecryptable( decryptable ); + const crypto = CryptoHelperFactory.BuildFromDecryptableOrThrow( decryptable ); const decryptedText = await crypto.decryptFromBase64( decryptable.base64CipherText, pw ); @@ -375,7 +346,7 @@ export default class FeatureInplaceEncrypt implements IMeldEncryptPluginFeature{ decryptInPlace: boolean, allowEncryption = true ) : boolean { - const selectionAnalysis = new SelectionAnalysis( selectionText ); + const selectionAnalysis = new FeatureInplaceTextAnalysis( selectionText ); //console.debug(selectionAnalysis); if (selectionAnalysis.isEmpty) { @@ -508,7 +479,7 @@ export default class FeatureInplaceEncrypt implements IMeldEncryptPluginFeature{ // decrypt - const crypto = CryptoHelperFactory.BuildFromDecryptable(decryptable); + const crypto = CryptoHelperFactory.BuildFromDecryptableOrThrow(decryptable); const decryptedText = await crypto.decryptFromBase64(decryptable.base64CipherText, password); if (decryptedText === null) { new Notice('❌ Decryption failed!'); @@ -535,8 +506,8 @@ export default class FeatureInplaceEncrypt implements IMeldEncryptPluginFeature{ private encodeEncryption( encryptedText: string, hint: string, showInReadingView: boolean ): string { if ( - !_PREFIXES.some( (prefix) => encryptedText.contains(prefix) ) - && !_SUFFIXES.some( (suffix) => encryptedText.contains(suffix) ) + !_PREFIXES.some( (prefix) => encryptedText.includes(prefix) ) + && !_SUFFIXES.some( (suffix) => encryptedText.includes(suffix) ) ) { const prefix = showInReadingView ? _PREFIX_ENCODE_DEFAULT_VISIBLE : _PREFIX_ENCODE_DEFAULT; const suffix = showInReadingView ? _SUFFIX_NO_COMMENT : _SUFFIX_WITH_COMMENT; @@ -550,100 +521,7 @@ export default class FeatureInplaceEncrypt implements IMeldEncryptPluginFeature{ } } -class SelectionAnalysis{ - processedText:string; - isEmpty: boolean; - - prefix: string; - suffix: string; - - hasObsoleteEncryptedPrefix: boolean; - hasEncryptedPrefix: boolean; - hasEncryptedSuffix: boolean; - canDecrypt: boolean; - canEncrypt: boolean; - containsEncryptedMarkers: boolean; - decryptable? : Decryptable; - - constructor(text: string){ - this.process(text); - } - private process( text: string ) : void{ - //console.debug('SelectionAnalysis.process', {text}); - - this.processedText = text; - - this.isEmpty = text.length === 0; - - this.prefix = _PREFIXES.find( (prefix) => text.startsWith(prefix) ) ?? ''; - this.suffix = _SUFFIXES.find( (suffix) => text.endsWith(suffix) ) ?? ''; - - //console.debug( {prefix:this.prefix, suffix:this.suffix} ); - - this.hasEncryptedPrefix = this.prefix.length > 0; - this.hasEncryptedSuffix = this.suffix.length > 0; - - this.hasObsoleteEncryptedPrefix = this.prefix === _PREFIX_OBSOLETE; - - this.containsEncryptedMarkers = [..._PREFIXES, ..._SUFFIXES].some( (marker) => text.contains(marker )); - - this.canDecrypt = this.hasEncryptedPrefix && this.hasEncryptedSuffix; - this.canEncrypt = !this.hasEncryptedPrefix && !this.containsEncryptedMarkers; - - if (this.canDecrypt){ - const decryptable = this.parseDecryptableContent(text); - if ( decryptable != null ){ - this.decryptable = decryptable; - }else{ - this.canDecrypt = false; - } - } - } - - private parseDecryptableContent(text: string) : Decryptable | null { - const result = new Decryptable(); - - if ( - !this.hasEncryptedPrefix - || !this.hasEncryptedSuffix - ){ - return null; // invalid format - } - - if ( this.hasObsoleteEncryptedPrefix ){ - result.version = 0; - }else if ( this.prefix == _PREFIX_B || this.prefix == _PREFIX_B_VISIBLE ){ - result.version = 2; - }else if ( this.prefix == _PREFIX_A || this.prefix == _PREFIX_A_VISIBLE ){ - result.version = 1; - } - - // remove markers from start and end - const content = text.substring(this.prefix.length, text.length - this.suffix.length); - //console.debug({content}); - - if ( [..._PREFIXES, ..._SUFFIXES].some( (marker) => content.contains( marker )) ){ - // content, itself has markers - return null; - } - - // check if there is a hint - if (content.substring(0,_HINT.length) == _HINT){ - const endHintMarker = content.indexOf(_HINT,_HINT.length); - if (endHintMarker<0){ - return null; // invalid format - } - result.hint = content.substring(_HINT.length,endHintMarker) - result.base64CipherText = content.substring(endHintMarker+_HINT.length); - }else{ - result.base64CipherText = content; - } - - return result; - - } -} class Encryptable{ text:string; diff --git a/src/features/feature-inplace-encrypt/featureInplaceTextAnalysis.ts b/src/features/feature-inplace-encrypt/featureInplaceTextAnalysis.ts new file mode 100644 index 0000000..54ad433 --- /dev/null +++ b/src/features/feature-inplace-encrypt/featureInplaceTextAnalysis.ts @@ -0,0 +1,98 @@ +import { Decryptable } from "./Decryptable"; +import { _HINT, _PREFIXES, _PREFIX_A, _PREFIX_A_VISIBLE, _PREFIX_B, _PREFIX_B_VISIBLE, _PREFIX_OBSOLETE, _SUFFIXES } from "./FeatureInplaceConstants"; + +export class FeatureInplaceTextAnalysis{ + processedText:string; + isEmpty: boolean; + + prefix: string; + suffix: string; + + hasObsoleteEncryptedPrefix: boolean; + hasEncryptedPrefix: boolean; + hasEncryptedSuffix: boolean; + canDecrypt: boolean; + canEncrypt: boolean; + containsEncryptedMarkers: boolean; + decryptable? : Decryptable; + + constructor(text: string){ + this.process(text); + } + + private process( text: string ) : void{ + //console.debug('SelectionAnalysis.process', {text}); + + this.processedText = text; + + this.isEmpty = text.length === 0; + + this.prefix = _PREFIXES.find( (prefix) => text.startsWith(prefix) ) ?? ''; + this.suffix = _SUFFIXES.find( (suffix) => text.endsWith(suffix) ) ?? ''; + + //console.debug( {prefix:this.prefix, suffix:this.suffix} ); + + this.hasEncryptedPrefix = this.prefix.length > 0; + this.hasEncryptedSuffix = this.suffix.length > 0; + + this.hasObsoleteEncryptedPrefix = this.prefix === _PREFIX_OBSOLETE; + + this.containsEncryptedMarkers = [..._PREFIXES, ..._SUFFIXES].some( (marker) => text.includes(marker )); + + this.canDecrypt = this.hasEncryptedPrefix && this.hasEncryptedSuffix; + this.canEncrypt = !this.hasEncryptedPrefix && !this.containsEncryptedMarkers; + + if (this.canDecrypt){ + const decryptable = this.parseDecryptableContent(text); + //console.debug( {decryptable} ); + if ( decryptable != null ){ + this.decryptable = decryptable; + }else{ + this.canDecrypt = false; + } + } + } + + private parseDecryptableContent(text: string) : Decryptable | null { + const result = new Decryptable(); + + if ( + !this.hasEncryptedPrefix + || !this.hasEncryptedSuffix + ){ + return null; // invalid format + } + + if ( this.hasObsoleteEncryptedPrefix ){ + result.version = 0; + }else if ( this.prefix == _PREFIX_B || this.prefix == _PREFIX_B_VISIBLE ){ + result.version = 2; + }else if ( this.prefix == _PREFIX_A || this.prefix == _PREFIX_A_VISIBLE ){ + result.version = 1; + } + + // remove markers from start and end + const content = text.substring(this.prefix.length, text.length - this.suffix.length); + //console.debug({content}); + + if ( [..._PREFIXES, ..._SUFFIXES].some( (marker) => content.includes( marker )) ){ + // content, itself has markers + return null; + } + + // check if there is a hint + if (content.substring(0,_HINT.length) == _HINT){ + const endHintMarker = content.indexOf(_HINT,_HINT.length); + if (endHintMarker<0){ + return null; // invalid format + } + result.hint = content.substring(_HINT.length,endHintMarker) + result.base64CipherText = content.substring(endHintMarker+_HINT.length); + }else{ + result.base64CipherText = content; + } + + return result; + + } +} \ No newline at end of file diff --git a/src/features/feature-whole-note-encrypt/EncryptedFileContentView.ts b/src/features/feature-whole-note-encrypt/EncryptedFileContentView.ts index 06a07c7..ed581bf 100644 --- a/src/features/feature-whole-note-encrypt/EncryptedFileContentView.ts +++ b/src/features/feature-whole-note-encrypt/EncryptedFileContentView.ts @@ -4,7 +4,8 @@ import { SessionPasswordService } from 'src/services/SessionPasswordService'; import { UiHelper } from 'src/services/UiHelper'; import { IFeatureWholeNoteEncryptSettings } from './IFeatureWholeNoteEncryptSettings'; import { ObsidianEx } from 'src/services/ObsidianEx'; -import { CryptoHelperFactory } from 'src/services/CryptoHelperFactory'; +import { FileDataHelper, JsonFileEncoding } from 'src/services/FileDataHelper'; +import { ENCRYPTED_FILE_EXTENSION } from 'src/services/Constants'; enum EncryptedFileContentViewStateEnum{ init, @@ -81,7 +82,7 @@ export class EncryptedFileContentView extends TextFileView { private actionLockFile(){ this.encryptionPassword = ''; - SessionPasswordService.clearForPath( this.file.path ); + SessionPasswordService.clearForFile( this.file ); this.refreshView(EncryptedFileContentViewStateEnum.decryptNote); } @@ -138,9 +139,9 @@ export class EncryptedFileContentView extends TextFileView { } private validatePassword ( pw: string ) : string { - if (pw.length == 0){ - return 'Password is too short'; - } + // if ( pw.length == 0 ){ + // return 'Password is too short'; + // } return ''; } @@ -179,7 +180,7 @@ export class EncryptedFileContentView extends TextFileView { await this.encodeAndSave(); - SessionPasswordService.putByPath( { password: password, hint: hint }, this.file.path ); + SessionPasswordService.putByFile( { password: password, hint: hint }, this.file ); this.currentEditNoteMode = EditViewEnum.source; this.refreshView( EncryptedFileContentViewStateEnum.editNote ); @@ -187,7 +188,7 @@ export class EncryptedFileContentView extends TextFileView { } } - const bestGuessPassAndHint = SessionPasswordService.getByPath( this.file.path ); + const bestGuessPassAndHint = SessionPasswordService.getByFile( this.file ); let password = bestGuessPassAndHint.password; let confirm = ''; let hint = bestGuessPassAndHint.hint; @@ -267,13 +268,14 @@ export class EncryptedFileContentView extends TextFileView { .setDesc('Please provide a password to unlock this note.') ; - UiHelper.buildPasswordSetting({ + const sPassword = UiHelper.buildPasswordSetting({ container: inputContainer, name:'Password:', autoFocus : true, placeholder: this.formatHint(this.hint), onChangeCallback: (value) => { this.encryptionPassword = value; + sPassword.setDesc( this.validatePassword(this.encryptionPassword) ); }, onEnterCallback: async () => await this.handleDecryptButtonClick() }); @@ -290,19 +292,22 @@ export class EncryptedFileContentView extends TextFileView { ; // try to decode and go to edit mode if password is known - const bestGuessPassAndHint = SessionPasswordService.getByPath( this.file.path ); + const bestGuessPassAndHint = SessionPasswordService.getByFile( this.file ); this.encryptionPassword = bestGuessPassAndHint.password; - - this.decryptWithPassword( bestGuessPassAndHint.password ) - .then( decryptedText => { - if ( decryptedText != null ){ - this.currentEditorSourceText = decryptedText; - this.refreshView( EncryptedFileContentViewStateEnum.editNote ); - new Notice('Decrypted using remembered password', 2000); - } - }) - ; + if ( bestGuessPassAndHint.password.length > 0 ){ + // try to decrypt with known password + + this.decryptWithPassword( bestGuessPassAndHint.password ) + .then( decryptedText => { + if ( decryptedText != null ){ + this.currentEditorSourceText = decryptedText; + this.refreshView( EncryptedFileContentViewStateEnum.editNote ); + new Notice('Decrypted using remembered password', 2000); + } + }) + ; + } } @@ -405,7 +410,7 @@ export class EncryptedFileContentView extends TextFileView { this.encodeAndSave(); this.refreshView( EncryptedFileContentViewStateEnum.editNote ); - SessionPasswordService.putByPath( {password: newPassword, hint: newHint}, this.file.path ); + SessionPasswordService.putByFile( {password: newPassword, hint: newHint}, this.file ); new Notice('Password and Hint were changed'); } @@ -544,9 +549,9 @@ export class EncryptedFileContentView extends TextFileView { } async decryptWithPassword( pw: string ) : Promise{ - if ( pw.length == 0 ){ - return null; - } + // if ( pw.length == 0 ){ + // return null; + // } const fileData = JsonFileEncoding.decode( this.data ); const decryptedText = await FileDataHelper.decrypt( fileData, pw ); return decryptedText; @@ -558,7 +563,7 @@ export class EncryptedFileContentView extends TextFileView { if (decryptedText === null){ new Notice('Decryption failed'); }else{ - SessionPasswordService.putByPath( {password: this.encryptionPassword, hint: this.hint }, this.file.path ); + SessionPasswordService.putByFile( {password: this.encryptionPassword, hint: this.hint }, this.file ); this.currentEditorSourceText = decryptedText; this.refreshView( EncryptedFileContentViewStateEnum.editNote); } @@ -568,7 +573,7 @@ export class EncryptedFileContentView extends TextFileView { // important canAcceptExtension(extension: string): boolean { //console.debug('EncryptedFileContentView.canAcceptExtension', {extension}); - return extension == 'encrypted'; + return extension == ENCRYPTED_FILE_EXTENSION; } // important @@ -630,51 +635,3 @@ export class EncryptedFileContentView extends TextFileView { } - -export class FileData { - - public version = '1.0'; - public hint: string; - public encodedData:string; - - constructor( version:string, hint:string, encodedData:string ){ - this.version = version; - this.hint = hint; - this.encodedData = encodedData; - } -} - -class FileDataHelper{ - - public static DEFAULT_VERSION = '2.0'; - - public static async encode( pass: string, hint:string, text:string ) : Promise{ - const crypto = CryptoHelperFactory.BuildDefault(); - const encryptedData = await crypto.encryptToBase64(text, pass); - return new FileData( FileDataHelper.DEFAULT_VERSION, hint, encryptedData); - } - - public static async decrypt( data: FileData, pass:string ) : Promise{ - if ( data.encodedData == '' ){ - return ''; - } - const crypto = CryptoHelperFactory.BuildFromFileData( data ); - return await crypto.decryptFromBase64( data.encodedData, pass ); - } -} - -class JsonFileEncoding { - - public static encode( data: FileData ) : string{ - //console.debug( 'JsonFileEncoding.encode', {data} ); - return JSON.stringify(data, null, 2); - } - - public static decode( encodedText:string ) : FileData{ - //console.debug('JsonFileEncoding.decode',{encodedText}); - if ( encodedText === '' ){ - return new FileData( FileDataHelper.DEFAULT_VERSION, '', '' ); - } - return JSON.parse( encodedText ) as FileData; - } -} \ No newline at end of file diff --git a/src/features/feature-whole-note-encrypt/FeatureWholeNoteEncrypt.ts b/src/features/feature-whole-note-encrypt/FeatureWholeNoteEncrypt.ts index 09e0f3e..9b96905 100644 --- a/src/features/feature-whole-note-encrypt/FeatureWholeNoteEncrypt.ts +++ b/src/features/feature-whole-note-encrypt/FeatureWholeNoteEncrypt.ts @@ -4,30 +4,32 @@ import { IMeldEncryptPluginFeature } from "../IMeldEncryptPluginFeature"; import MeldEncrypt from "../../main"; import { IMeldEncryptPluginSettings } from "../../settings/MeldEncryptPluginSettings"; import { IFeatureWholeNoteEncryptSettings } from "./IFeatureWholeNoteEncryptSettings"; +import { ENCRYPTED_FILE_EXTENSION } from "src/services/Constants"; export default class FeatureWholeNoteEncrypt implements IMeldEncryptPluginFeature { plugin:MeldEncrypt; settings: IFeatureWholeNoteEncryptSettings; - private ribbonIconCreateNewNote?: HTMLElement | null; - async onload( plugin: MeldEncrypt, settings:IMeldEncryptPluginSettings ) { this.plugin = plugin; this.settings = settings.featureWholeNoteEncrypt; - this.updateUiForSettings(); + this.plugin.addRibbonIcon( 'file-lock-2', 'New encrypted note', (ev)=>{ + this.processCreateNewEncryptedNoteCommand(); + }); + this.plugin.registerView( VIEW_TYPE_ENCRYPTED_FILE_CONTENT, (leaf) => new EncryptedFileContentView(leaf, this.settings ) ); - this.plugin.registerExtensions(['encrypted'], VIEW_TYPE_ENCRYPTED_FILE_CONTENT); + this.plugin.registerExtensions([ENCRYPTED_FILE_EXTENSION], VIEW_TYPE_ENCRYPTED_FILE_CONTENT); this.plugin.addCommand({ id: 'meld-encrypt-create-new-note', name: 'Create new encrypted note', - icon: 'lock', + icon: 'file-lock-2', callback: () => this.processCreateNewEncryptedNoteCommand(), }); @@ -53,8 +55,8 @@ export default class FeatureWholeNoteEncrypt implements IMeldEncryptPluginFeatur const newFilepath = normalizePath( newFileFolder.path + "/" + newFilename ); //console.debug('processCreateNewEncryptedNoteCommand', {newFilepath}); - this.plugin.app.vault.create(newFilepath,'').then( async f=>{ - const leaf = this.plugin.app.workspace.getLeaf( false ); + this.plugin.app.vault.create(newFilepath,'').then( async f => { + const leaf = this.plugin.app.workspace.getLeaf( true ); await leaf.openFile( f ); }).catch( reason =>{ new Notice(reason, 10000); @@ -80,22 +82,6 @@ export default class FeatureWholeNoteEncrypt implements IMeldEncryptPluginFeatur .setName('Whole Note Encryption Settings') ; - new Setting(containerEl) - .setName('Add ribbon icon to create note') - .setDesc('Adds a ribbon icon to the left bar to create an encrypted note.') - .addToggle( toggle =>{ - toggle - .setValue(this.settings.addRibbonIconToCreateNote) - - .onChange( async value => { - this.settings.addRibbonIconToCreateNote = value; - await saveSettingCallback(); - this.updateUiForSettings(); - }) - ; - }) - ; - new Setting(containerEl) .setName('Default view for new tabs') .setDesc('The default view that a new encrypted note tab gets opened in') @@ -108,7 +94,6 @@ export default class FeatureWholeNoteEncrypt implements IMeldEncryptPluginFeatur .onChange( async value => { this.settings.defaultView = value; await saveSettingCallback(); - //this.updateUiForSettings(); }) ; }) @@ -116,20 +101,4 @@ export default class FeatureWholeNoteEncrypt implements IMeldEncryptPluginFeatur } - public updateUiForSettings(){ - if (this.settings.addRibbonIconToCreateNote){ - // turn on ribbon icon - if (this.ribbonIconCreateNewNote == null){ - this.ribbonIconCreateNewNote = this.plugin.addRibbonIcon( 'lock', 'Create new encrypted note', (ev)=>{ - this.processCreateNewEncryptedNoteCommand(); - }); - } - }else{ - // turn off ribbon icon - if (this.ribbonIconCreateNewNote != null){ - this.ribbonIconCreateNewNote.remove(); - this.ribbonIconCreateNewNote = null; - } - } - } } diff --git a/src/features/feature-whole-note-encrypt/IFeatureWholeNoteEncryptSettings.ts b/src/features/feature-whole-note-encrypt/IFeatureWholeNoteEncryptSettings.ts index 0335fa6..cae4fea 100644 --- a/src/features/feature-whole-note-encrypt/IFeatureWholeNoteEncryptSettings.ts +++ b/src/features/feature-whole-note-encrypt/IFeatureWholeNoteEncryptSettings.ts @@ -1,4 +1,3 @@ export interface IFeatureWholeNoteEncryptSettings { - addRibbonIconToCreateNote: boolean; defaultView: string; } diff --git a/src/main.ts b/src/main.ts index 12381ff..a40303f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,11 +1,12 @@ import { Plugin } from 'obsidian'; import MeldEncryptSettingsTab from './settings/MeldEncryptSettingsTab'; import { IMeldEncryptPluginSettings } from './settings/MeldEncryptPluginSettings'; -import FeatureInplaceEncrypt from './features/feature-inplace-encrypt/FeatureInplaceEncrypt'; -import FeatureWholeNoteEncrypt from './features/feature-whole-note-encrypt/FeatureWholeNoteEncrypt'; import { IMeldEncryptPluginFeature } from './features/IMeldEncryptPluginFeature'; import { SessionPasswordService } from './services/SessionPasswordService'; +import FeatureInplaceEncrypt from './features/feature-inplace-encrypt/FeatureInplaceEncrypt'; +import FeatureWholeNoteEncrypt from './features/feature-whole-note-encrypt/FeatureWholeNoteEncrypt'; import { EditViewEnum } from './features/feature-whole-note-encrypt/EncryptedFileContentView'; +import FeatureConvertNote from './features/feature-convert-note/FeatureConvertNote'; export default class MeldEncrypt extends Plugin { @@ -20,7 +21,8 @@ export default class MeldEncrypt extends Plugin { this.enabledFeatures.push( new FeatureWholeNoteEncrypt(), - new FeatureInplaceEncrypt() + new FeatureConvertNote(), + new FeatureInplaceEncrypt(), ); this.addSettingTab( @@ -52,10 +54,9 @@ export default class MeldEncrypt extends Plugin { confirmPassword: true, rememberPassword: true, rememberPasswordTimeout: 30, - rememberPasswordLevel: SessionPasswordService.LevelFullPath, + rememberPasswordLevel: SessionPasswordService.LevelFilename, featureWholeNoteEncrypt: { - addRibbonIconToCreateNote: true, defaultView: EditViewEnum.source.toString() }, diff --git a/src/services/Constants.ts b/src/services/Constants.ts new file mode 100644 index 0000000..cedcc59 --- /dev/null +++ b/src/services/Constants.ts @@ -0,0 +1 @@ +export const ENCRYPTED_FILE_EXTENSION = 'encrypted'; \ No newline at end of file diff --git a/src/services/CryptoHelperFactory.ts b/src/services/CryptoHelperFactory.ts index b44dd72..8d4925b 100644 --- a/src/services/CryptoHelperFactory.ts +++ b/src/services/CryptoHelperFactory.ts @@ -1,4 +1,4 @@ -import { FileData } from "src/features/feature-whole-note-encrypt/EncryptedFileContentView"; +import { FileData } from "./FileDataHelper"; import { Decryptable } from "src/features/feature-inplace-encrypt/Decryptable"; import { CryptoHelper } from "./CryptoHelper"; import { ICryptoHelper } from "./ICryptoHelper"; @@ -11,7 +11,15 @@ export class CryptoHelperFactory{ return new CryptoHelper2304( 16, 16, 210000 ); } - public static BuildFromFileData( data: FileData ) : ICryptoHelper { + public static BuildFromFileDataOrThrow( data: FileData ) : ICryptoHelper { + const result = CryptoHelperFactory.BuildFromFileDataOrNull(data); + if ( result != null ){ + return result; + } + throw new Error( `Unable to determine ICryptoHelper for File ver ${data.version}`); + } + + public static BuildFromFileDataOrNull( data: FileData ) : ICryptoHelper | null { if ( data.version == '1.0' ){ return new CryptoHelper(); } @@ -21,15 +29,28 @@ export class CryptoHelperFactory{ return new CryptoHelper2304( 16, 16, 210000 ); } - throw new Error( `Unable to determine ICryptoHelper for File ver ${data.version}`); + return null; } - public static BuildFromDecryptable( decryptable: Decryptable ) : ICryptoHelper { + public static BuildFromDecryptableOrThrow( decryptable: Decryptable ) : ICryptoHelper { + const result = CryptoHelperFactory.BuildFromDecryptableOrNull( decryptable ); + + if (result != null){ + return result; + } + throw new Error( `Unable to determine ICryptoHelper for Decryptable ver ${decryptable.version}`); + } + + public static BuildFromDecryptableOrNull( decryptable: Decryptable ) : ICryptoHelper | null { // Versions // inplace original _PREFIX_OBSOLETE = '%%🔐 ' CryptoHelperObsolete + // inplace alpha _PREFIX_A = '%%🔐α ' CryptoHelper // _PREFIX_A_VISIBLE = '🔐α ' CryptoHelper + // inplace beta _PREFIX_B = '%%🔐β ' CryptoHelper2304( 16, 16, 210000 ) + // _PREFIX_B_VISIBLE = '🔐β ' CryptoHelper2304( 16, 16, 210000 ) + if ( decryptable.version == 0 ){ return new CryptoHelperObsolete(); } @@ -42,7 +63,7 @@ export class CryptoHelperFactory{ return new CryptoHelper2304( 16, 16, 210000 ); } - throw new Error( `Unable to determine ICryptoHelper for Decryptable ver ${decryptable.version}`); + return null; } } \ No newline at end of file diff --git a/src/services/FileDataHelper.ts b/src/services/FileDataHelper.ts new file mode 100644 index 0000000..515aa6f --- /dev/null +++ b/src/services/FileDataHelper.ts @@ -0,0 +1,49 @@ +import { CryptoHelperFactory } from "./CryptoHelperFactory"; + +export class FileData { + + public version = '1.0'; + public hint: string; + public encodedData:string; + + constructor( version:string, hint:string, encodedData:string ){ + this.version = version; + this.hint = hint; + this.encodedData = encodedData; + } +} + +export class FileDataHelper{ + + public static DEFAULT_VERSION = '2.0'; + + public static async encode( pass: string, hint:string, text:string ) : Promise{ + const crypto = CryptoHelperFactory.BuildDefault(); + const encryptedData = await crypto.encryptToBase64(text, pass); + return new FileData( FileDataHelper.DEFAULT_VERSION, hint, encryptedData); + } + + public static async decrypt( data: FileData, pass:string ) : Promise{ + if ( data.encodedData == '' ){ + return ''; + } + const crypto = CryptoHelperFactory.BuildFromFileDataOrThrow( data ); + return await crypto.decryptFromBase64( data.encodedData, pass ); + } +} + +export class JsonFileEncoding { + + public static encode( data: FileData ) : string{ + //console.debug( 'JsonFileEncoding.encode', {data} ); + return JSON.stringify(data, null, 2); + } + + public static decode( encodedText:string ) : FileData { + //console.debug('JsonFileEncoding.decode',{encodedText}); + if ( encodedText === '' ){ + return new FileData( FileDataHelper.DEFAULT_VERSION, '', '' ); + } + return JSON.parse( encodedText ) as FileData; + } +} \ No newline at end of file diff --git a/src/services/SessionPasswordService.ts b/src/services/SessionPasswordService.ts index 8822e7e..453faf0 100644 --- a/src/services/SessionPasswordService.ts +++ b/src/services/SessionPasswordService.ts @@ -1,6 +1,8 @@ +import { TFile } from "obsidian"; import { MemoryCache } from "./MemoryCache"; +import { Utils } from "./Utils"; -interface IPasswordAndHint{ +export interface IPasswordAndHint{ password: string; hint: string; } @@ -16,9 +18,13 @@ export class SessionPasswordService{ private static baseMinutesToExpire = 0; private static expiryTime : number | null = null; - public static LevelFullPath = 'fullPath'; + public static LevelFilename = 'filename'; public static LevelParentPath = 'parentPath'; - private static level = SessionPasswordService.LevelFullPath; + private static allLevels = [ + SessionPasswordService.LevelFilename, + SessionPasswordService.LevelParentPath + ]; + private static level = SessionPasswordService.LevelFilename; public static setActive( isActive: boolean) { SessionPasswordService.isActive = isActive; @@ -40,7 +46,10 @@ export class SessionPasswordService{ if ( SessionPasswordService.level == level ){ return; } - SessionPasswordService.level = level; + if ( SessionPasswordService.allLevels.contains(level) ){ + SessionPasswordService.level = level; + } + SessionPasswordService.level = SessionPasswordService.LevelFilename; this.clear(); } @@ -54,7 +63,30 @@ export class SessionPasswordService{ SessionPasswordService.expiryTime = Date.now() + SessionPasswordService.baseMinutesToExpire * 1000 * 60; } } - + + public static putByFile( pw: IPasswordAndHint, file:TFile ): void { + if (!SessionPasswordService.isActive){ + return; + } + + const key = SessionPasswordService.getFileCacheKey( file ); + this.cache.put( key, pw ); + + + SessionPasswordService.updateExpiryTime(); + } + + public static getByFile( file:TFile ) : IPasswordAndHint { + if (!SessionPasswordService.isActive){ + return SessionPasswordService.blankPasswordAndHint; + } + this.clearIfExpired(); + SessionPasswordService.updateExpiryTime(); + + const key = SessionPasswordService.getFileCacheKey( file ); + return this.cache.get( key, SessionPasswordService.blankPasswordAndHint ); + } + public static putByPath( pw: IPasswordAndHint, path:string ): void { if (!SessionPasswordService.isActive){ return; @@ -92,6 +124,16 @@ export class SessionPasswordService{ } } + private static getFileCacheKey( file : TFile ) : string { + switch (SessionPasswordService.level) { + case SessionPasswordService.LevelParentPath: { + return file.parent.path; + } + default: + return Utils.getFilePathExcludingExtension( file ); + } + } + private static clearIfExpired() : void{ if ( SessionPasswordService.expiryTime == null ){ return; @@ -102,6 +144,11 @@ export class SessionPasswordService{ this.clear(); } + public static clearForFile( file: TFile ) : void { + const key = SessionPasswordService.getFileCacheKey( file ); + this.cache.removeKey( key ); + } + public static clearForPath( path: string ) : void { const key = SessionPasswordService.getPathCacheKey( path ); this.cache.removeKey( key ); diff --git a/src/services/Utils.ts b/src/services/Utils.ts new file mode 100644 index 0000000..f4f086e --- /dev/null +++ b/src/services/Utils.ts @@ -0,0 +1,15 @@ +import { TFile, normalizePath } from "obsidian"; + +export class Utils{ + + + public static getFilePathWithNewExtension( file: TFile, newExtension : string ) : string { + return normalizePath( file.parent.path + '/' + file.basename + '.' + newExtension ); + } + + public static getFilePathExcludingExtension( file: TFile ) : string { + return normalizePath( file.parent.path + '/' + file.basename ); + } + + +} \ No newline at end of file diff --git a/src/settings/MeldEncryptSettingsTab.ts b/src/settings/MeldEncryptSettingsTab.ts index ac86600..b0ffef9 100644 --- a/src/settings/MeldEncryptSettingsTab.ts +++ b/src/settings/MeldEncryptSettingsTab.ts @@ -37,7 +37,7 @@ export default class MeldEncryptSettingsTab extends PluginSettingTab { new Setting(containerEl) .setName('Confirm password?') - .setDesc('Confirm password when encrypting.') + .setDesc('Confirm password when encrypting. (Recommended)') .addToggle( toggle =>{ toggle .setValue(this.settings.confirmPassword) @@ -63,7 +63,7 @@ export default class MeldEncryptSettingsTab extends PluginSettingTab { let timeoutString = `For ${rememberPasswordTimeout} minutes`; if( rememberPasswordTimeout == 0 ){ - timeoutString = 'Always'; + timeoutString = 'Until Obsidian is closed'; } pwTimeoutSetting.setName( `Remember Password (${timeoutString})` ) @@ -72,11 +72,11 @@ export default class MeldEncryptSettingsTab extends PluginSettingTab { new Setting(containerEl) .setName('Remember password?') - .setDesc('Remember the last used passwords when encrypting or decrypting.') + .setDesc('Remember the last used passwords when encrypting or decrypting. Passwords are remembered until they timeout or Obsidian is closed') .addToggle( toggle =>{ toggle .setValue(this.settings.rememberPassword) - .onChange( async value =>{ + .onChange( async value => { this.settings.rememberPassword = value; await this.plugin.saveSettings(); SessionPasswordService.setActive( this.settings.rememberPassword ); @@ -103,12 +103,12 @@ export default class MeldEncryptSettingsTab extends PluginSettingTab { ; const rememberPasswordLevelSetting = new Setting(containerEl) - .setDesc('Remember passwords by using') + .setDesc('Remember passwords by using a notes file name or parent folder') .addDropdown( cb =>{ cb - .addOption( SessionPasswordService.LevelFullPath, 'Full Path') - .addOption( SessionPasswordService.LevelParentPath, 'Parent Path') - .setValue(this.settings.rememberPasswordLevel) + .addOption( SessionPasswordService.LevelFilename, 'File Name') + .addOption( SessionPasswordService.LevelParentPath, 'Parent Folder') + .setValue( this.settings.rememberPasswordLevel ) .onChange( async value => { this.settings.rememberPasswordLevel = value; await this.plugin.saveSettings(); diff --git a/src/tools/decrypt.html b/src/tools/decrypt.html index 2a8fab4..37242d0 100644 --- a/src/tools/decrypt.html +++ b/src/tools/decrypt.html @@ -3,8 +3,8 @@ - Decryptor Tool for Obsidian Meld Encrypt Plugin - + Decrypt Tool for Obsidian Meld Encrypt Plugin + -
-

🔐 Decryptor Tool for Obsidian Meld Encrypt Plugin 🔐

+

🔐 Decrypt Tool for Obsidian Meld Encrypt Plugin 🔐

Use this tool to decrypt notes without using the Obsidian Meld Encrypt Plugin.

@@ -100,7 +75,7 @@

🔐 Decryptor Tool for Obsidian Meld Encrypt Plugin 🔐

- +
@@ -111,6 +86,27 @@

🔐 Decryptor Tool for Obsidian Meld Encrypt Plugin 🔐

+ \ No newline at end of file diff --git a/src/tools/offline-decrypt.ts b/src/tools/offline-decrypt.ts new file mode 100644 index 0000000..1dac04f --- /dev/null +++ b/src/tools/offline-decrypt.ts @@ -0,0 +1,64 @@ +import { Decryptable } from "src/features/feature-inplace-encrypt/Decryptable"; +import { FeatureInplaceTextAnalysis } from "src/features/feature-inplace-encrypt/featureInplaceTextAnalysis"; +import { CryptoHelperFactory } from "src/services/CryptoHelperFactory"; +import { JsonFileEncoding } from "src/services/FileDataHelper"; + +export class OfflineDecrypt { + + public async decrypt( content:string, password: string ) : Promise { + + // Trying the default decryption + console.info( 'Trying the default decryption' ); + const chDef = CryptoHelperFactory.BuildDefault(); + const result = await chDef.decryptFromBase64( content, password ); + if ( result != null ){ + return result; + } + + // Trying marked inplace feature decryption + console.info( 'Trying marked inplace feature decryption' ); + const ta = new FeatureInplaceTextAnalysis( content ); + if ( ta.decryptable != null ){ + const ch = CryptoHelperFactory.BuildFromDecryptableOrNull(ta.decryptable); + if (ch != null){ + const result = await ch.decryptFromBase64( ta.decryptable.base64CipherText, password ); + if ( result != null ){ + return result; + } + } + } + + // Trying non-marked inplace feature decryption + for (let ver = 10; ver >= 0; ver--) { + console.info( 'Trying non-marked inplace feature decryption', 'ver', ver ); + const decryptable : Decryptable = {version: ver, base64CipherText: content, hint: ''}; + const ch = CryptoHelperFactory.BuildFromDecryptableOrNull(decryptable) + const result = await ch?.decryptFromBase64( decryptable.base64CipherText, password ); + if ( result != null ){ + return result; + } + } + + + // Trying whole note feature decryption + console.info( 'Trying whole note feature decryption' ); + try{ + const fileData = JsonFileEncoding.decode( content ); + console.debug(fileData); + const chFd = CryptoHelperFactory.BuildFromFileDataOrNull( fileData ); + const resultFd = await chFd?.decryptFromBase64( fileData.encodedData, password ); + if ( resultFd != null ){ + return resultFd; + } + } catch (e){ + console.info(e); + } + + return null; + } +} +declare global { + interface Window { $: OfflineDecrypt; } +} + +window.$ = new OfflineDecrypt(); diff --git a/tools/crypto-helper.js b/tools/crypto-helper.js deleted file mode 100644 index d74b2de..0000000 --- a/tools/crypto-helper.js +++ /dev/null @@ -1,201 +0,0 @@ -var modules = (function (exports) { - 'use strict'; - - /*! ***************************************************************************** - Copyright (c) Microsoft Corporation. - - Permission to use, copy, modify, and/or distribute this software for any - purpose with or without fee is hereby granted. - - THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH - REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY - AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, - INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM - LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR - OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR - PERFORMANCE OF THIS SOFTWARE. - ***************************************************************************** */ - - function __awaiter(thisArg, _arguments, P, generator) { - function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } - return new (P || (P = Promise))(function (resolve, reject) { - function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } - function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } - function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } - step((generator = generator.apply(thisArg, _arguments || [])).next()); - }); - } - - const vectorSize = 16; - const utf8Encoder = new TextEncoder(); - const utf8Decoder = new TextDecoder(); - const iterations = 1000; - const salt = utf8Encoder.encode('XHWnDAT6ehMVY2zD'); - class CryptoHelperV2 { - deriveKey(password) { - return __awaiter(this, void 0, void 0, function* () { - const buffer = utf8Encoder.encode(password); - const key = yield crypto.subtle.importKey('raw', buffer, { name: 'PBKDF2' }, false, ['deriveKey']); - const privateKey = crypto.subtle.deriveKey({ - name: 'PBKDF2', - hash: { name: 'SHA-256' }, - iterations, - salt - }, key, { - name: 'AES-GCM', - length: 256 - }, false, ['encrypt', 'decrypt']); - return privateKey; - }); - } - encryptToBytes(text, password) { - return __awaiter(this, void 0, void 0, function* () { - const key = yield this.deriveKey(password); - const textBytesToEncrypt = utf8Encoder.encode(text); - const vector = crypto.getRandomValues(new Uint8Array(vectorSize)); - // encrypt into bytes - const encryptedBytes = new Uint8Array(yield crypto.subtle.encrypt({ name: 'AES-GCM', iv: vector }, key, textBytesToEncrypt)); - const finalBytes = new Uint8Array(vector.byteLength + encryptedBytes.byteLength); - finalBytes.set(vector, 0); - finalBytes.set(encryptedBytes, vector.byteLength); - return finalBytes; - }); - } - encryptToBase64(text, password) { - return __awaiter(this, void 0, void 0, function* () { - // const key = await this.deriveKey(password); - // const textBytesToEncrypt = utf8Encoder.encode(text); - // const vector = crypto.getRandomValues(new Uint8Array(vectorSize)); - // // encrypt into bytes - // const encryptedBytes = new Uint8Array( - // await crypto.subtle.encrypt( - // {name: 'AES-GCM', iv: vector}, - // key, - // textBytesToEncrypt - // ) - // ); - // const finalBytes = new Uint8Array( vector.byteLength + encryptedBytes.byteLength ); - // finalBytes.set( vector, 0 ); - // finalBytes.set( encryptedBytes, vector.byteLength ); - const finalBytes = yield this.encryptToBytes(text, password); - //convert array to base64 - const base64Text = btoa(String.fromCharCode(...finalBytes)); - return base64Text; - }); - } - stringToArray(str) { - var result = []; - for (var i = 0; i < str.length; i++) { - result.push(str.charCodeAt(i)); - } - return new Uint8Array(result); - } - decryptFromBytes(encryptedBytes, password) { - return __awaiter(this, void 0, void 0, function* () { - try { - // extract iv - const vector = encryptedBytes.slice(0, vectorSize); - // extract encrypted text - const encryptedTextBytes = encryptedBytes.slice(vectorSize); - const key = yield this.deriveKey(password); - // decrypt into bytes - let decryptedBytes = yield crypto.subtle.decrypt({ name: 'AES-GCM', iv: vector }, key, encryptedTextBytes); - // convert bytes to text - let decryptedText = utf8Decoder.decode(decryptedBytes); - return decryptedText; - } - catch (e) { - //console.error(e); - return null; - } - }); - } - decryptFromBase64(base64Encoded, password) { - return __awaiter(this, void 0, void 0, function* () { - try { - let bytesToDecode = this.stringToArray(atob(base64Encoded)); - return yield this.decryptFromBytes(bytesToDecode, password); - // // extract iv - // const vector = bytesToDecode.slice(0,vectorSize); - // // extract encrypted text - // const encryptedTextBytes = bytesToDecode.slice(vectorSize); - // const key = await this.deriveKey(password); - // // decrypt into bytes - // let decryptedBytes = await crypto.subtle.decrypt( - // {name: 'AES-GCM', iv: vector}, - // key, - // encryptedTextBytes - // ); - // // convert bytes to text - // let decryptedText = utf8Decoder.decode(decryptedBytes); - // return decryptedText; - } - catch (e) { - //console.error(e); - return null; - } - }); - } - } - const algorithmObsolete = { - name: 'AES-GCM', - iv: new Uint8Array([196, 190, 240, 190, 188, 78, 41, 132, 15, 220, 84, 211]), - tagLength: 128 - }; - class CryptoHelperObsolete { - buildKey(password) { - return __awaiter(this, void 0, void 0, function* () { - let utf8Encode = new TextEncoder(); - let passwordBytes = utf8Encode.encode(password); - let passwordDigest = yield crypto.subtle.digest({ name: 'SHA-256' }, passwordBytes); - let key = yield crypto.subtle.importKey('raw', passwordDigest, algorithmObsolete, false, ['encrypt', 'decrypt']); - return key; - }); - } - encryptToBase64(text, password) { - return __awaiter(this, void 0, void 0, function* () { - let key = yield this.buildKey(password); - let utf8Encode = new TextEncoder(); - let bytesToEncrypt = utf8Encode.encode(text); - // encrypt into bytes - let encryptedBytes = new Uint8Array(yield crypto.subtle.encrypt(algorithmObsolete, key, bytesToEncrypt)); - //convert array to base64 - let base64Text = btoa(String.fromCharCode(...encryptedBytes)); - return base64Text; - }); - } - stringToArray(str) { - var result = []; - for (var i = 0; i < str.length; i++) { - result.push(str.charCodeAt(i)); - } - return new Uint8Array(result); - } - decryptFromBase64(base64Encoded, password) { - return __awaiter(this, void 0, void 0, function* () { - try { - // convert base 64 to array - let bytesToDecrypt = this.stringToArray(atob(base64Encoded)); - let key = yield this.buildKey(password); - // decrypt into bytes - let decryptedBytes = yield crypto.subtle.decrypt(algorithmObsolete, key, bytesToDecrypt); - // convert bytes to text - let utf8Decode = new TextDecoder(); - let decryptedText = utf8Decode.decode(decryptedBytes); - return decryptedText; - } - catch (e) { - return null; - } - }); - } - } - - exports.CryptoHelperObsolete = CryptoHelperObsolete; - exports.CryptoHelperV2 = CryptoHelperV2; - - Object.defineProperty(exports, '__esModule', { value: true }); - - return exports; - -})({}); diff --git a/tools/decrypt.html b/tools/decrypt.html index 2a8fab4..37242d0 100644 --- a/tools/decrypt.html +++ b/tools/decrypt.html @@ -3,8 +3,8 @@ - Decryptor Tool for Obsidian Meld Encrypt Plugin - + Decrypt Tool for Obsidian Meld Encrypt Plugin + -
-

🔐 Decryptor Tool for Obsidian Meld Encrypt Plugin 🔐

+

🔐 Decrypt Tool for Obsidian Meld Encrypt Plugin 🔐

Use this tool to decrypt notes without using the Obsidian Meld Encrypt Plugin.

@@ -100,7 +75,7 @@

🔐 Decryptor Tool for Obsidian Meld Encrypt Plugin 🔐

- +
@@ -111,6 +86,27 @@

🔐 Decryptor Tool for Obsidian Meld Encrypt Plugin 🔐

+ \ No newline at end of file diff --git a/tools/offline-decrypt.js b/tools/offline-decrypt.js new file mode 100644 index 0000000..30aabc6 --- /dev/null +++ b/tools/offline-decrypt.js @@ -0,0 +1 @@ +(()=>{var h=class{};var w="%%\u{1F510}\u03B2 ",v="\u{1F510}\u03B2 ",D="%%\u{1F510}\u03B1 ",F="\u{1F510}\u03B1 ",B="%%\u{1F510} ";var f=[w,v,D,F,B],P=" \u{1F510}%%",C=" \u{1F510}",b=[P,C],p="\u{1F4A1}";var g=class{constructor(t){this.process(t)}process(t){var r,e;if(this.processedText=t,this.isEmpty=t.length===0,this.prefix=(r=f.find(n=>t.startsWith(n)))!=null?r:"",this.suffix=(e=b.find(n=>t.endsWith(n)))!=null?e:"",this.hasEncryptedPrefix=this.prefix.length>0,this.hasEncryptedSuffix=this.suffix.length>0,this.hasObsoleteEncryptedPrefix=this.prefix===B,this.containsEncryptedMarkers=[...f,...b].some(n=>t.includes(n)),this.canDecrypt=this.hasEncryptedPrefix&&this.hasEncryptedSuffix,this.canEncrypt=!this.hasEncryptedPrefix&&!this.containsEncryptedMarkers,this.canDecrypt){let n=this.parseDecryptableContent(t);n!=null?this.decryptable=n:this.canDecrypt=!1}}parseDecryptableContent(t){let r=new h;if(!this.hasEncryptedPrefix||!this.hasEncryptedSuffix)return null;this.hasObsoleteEncryptedPrefix?r.version=0:this.prefix==w||this.prefix==v?r.version=2:(this.prefix==D||this.prefix==F)&&(r.version=1);let e=t.substring(this.prefix.length,t.length-this.suffix.length);if([...f,...b].some(n=>e.includes(n)))return null;if(e.substring(0,p.length)==p){let n=e.indexOf(p,p.length);if(n<0)return null;r.hint=e.substring(p.length,n),r.base64CipherText=e.substring(n+p.length)}else r.base64CipherText=e;return r}};var S=new TextEncoder,U=new TextDecoder,O=1e3,K=S.encode("XHWnDAT6ehMVY2zD"),d=class{async deriveKey(t){let r=S.encode(t),e=await crypto.subtle.importKey("raw",r,{name:"PBKDF2"},!1,["deriveKey"]);return crypto.subtle.deriveKey({name:"PBKDF2",hash:{name:"SHA-256"},iterations:O,salt:K},e,{name:"AES-GCM",length:256},!1,["encrypt","decrypt"])}async encryptToBytes(t,r){let e=await this.deriveKey(r),n=S.encode(t),o=crypto.getRandomValues(new Uint8Array(16)),s=new Uint8Array(await crypto.subtle.encrypt({name:"AES-GCM",iv:o},e,n)),i=new Uint8Array(o.byteLength+s.byteLength);return i.set(o,0),i.set(s,o.byteLength),i}convertToString(t){let r="";for(let e=0;e=0;s--){console.info("Trying non-marked inplace feature decryption","ver",s);let i={version:s,base64CipherText:t,hint:""},a=c.BuildFromDecryptableOrNull(i),l=await(a==null?void 0:a.decryptFromBase64(i.base64CipherText,r));if(l!=null)return l}console.info("Trying whole note feature decryption");try{let s=T.decode(t);console.debug(s);let i=c.BuildFromFileDataOrNull(s),a=await(i==null?void 0:i.decryptFromBase64(s.encodedData,r));if(a!=null)return a}catch(s){console.info(s)}return null}};window.$=new _;})(); diff --git a/versions.json b/versions.json index 1abc9fb..2049ed3 100644 --- a/versions.json +++ b/versions.json @@ -27,5 +27,6 @@ "2.1.1": "1.0.3", "2.1.2": "1.0.3", "2.1.3": "1.0.3", - "2.2.0": "1.0.3" + "2.2.0": "1.0.3", + "2.3.0": "1.0.3" } \ No newline at end of file