├── MANIFEST.in ├── requirements.txt ├── .gitattributes ├── xontrib └── history_encrypt │ ├── base64.py │ ├── fernet.py │ └── __init__.py ├── .github ├── workflows │ ├── push_test.yml │ └── python-publish.yml └── FUNDING.yml ├── LICENSE ├── setup.py ├── .gitignore └── README.md /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | xonsh 2 | cryptography 3 | ujson -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.xsh text linguist-language=Python 2 | -------------------------------------------------------------------------------- /xontrib/history_encrypt/base64.py: -------------------------------------------------------------------------------- 1 | from base64 import b64decode, b64encode 2 | 3 | base64_key = None 4 | 5 | def base64_encode(message: bytes) -> bytes: 6 | return b64encode(message) 7 | 8 | def base64_decode(token: bytes) -> bytes: 9 | return b64decode(token) -------------------------------------------------------------------------------- /.github/workflows/push_test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: push 4 | 5 | jobs: 6 | deploy: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - name: Set up Python 11 | uses: actions/setup-python@v2 12 | with: 13 | python-version: '3.8' 14 | - name: Install xonsh 15 | run: pip install xonsh 16 | - name: Install xontrib 17 | run: pip install . 18 | - name: Test 19 | run: xonsh -c 'xontrib load history_encrypt' 20 | -------------------------------------------------------------------------------- /xontrib/history_encrypt/fernet.py: -------------------------------------------------------------------------------- 1 | from cryptography.fernet import Fernet 2 | 3 | def fernet_key(): 4 | print('[xontrib-history-encrypt] Enter the key or press enter to create new: ', end='') 5 | key = input() 6 | if not key.strip(): 7 | key = Fernet.generate_key() 8 | print('[xontrib-history-encrypt] Save the key and use it next time:', key.decode()) 9 | return key 10 | 11 | def fernet_encrypt(message: bytes, key: bytes) -> bytes: 12 | return Fernet(key).encrypt(message) 13 | 14 | def fernet_decrypt(token: bytes, key: bytes) -> bytes: 15 | return Fernet(key).decrypt(token) 16 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | 2 | # These are supported funding model platforms 3 | 4 | #github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 5 | #patreon: xonssh # Replace with a single Patreon username 6 | #open_collective: # Replace with a single Open Collective username 7 | #ko_fi: # Replace with a single Ko-fi username 8 | #tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 9 | #community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 10 | #liberapay: # Replace with a single Liberapay username 11 | #issuehunt: # Replace with a single IssueHunt username 12 | #otechie: # Replace with a single Otechie username 13 | custom: ['https://github.com/anki-code', 'https://www.buymeacoffee.com/xxh', 'https://github.com/xonsh/xonsh#the-xonsh-shell-community'] 14 | 15 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | deploy: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Set up Python 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: '3.x' 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install setuptools wheel twine 25 | - name: Build and publish 26 | env: 27 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 28 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 29 | run: | 30 | python setup.py sdist bdist_wheel 31 | twine upload dist/* 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021, anki-code 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import setuptools 3 | 4 | try: 5 | with open('README.md', 'r', encoding='utf-8') as fh: 6 | long_description = fh.read() 7 | except (IOError, OSError): 8 | long_description = '' 9 | 10 | setuptools.setup( 11 | name='xontrib-history-encrypt', 12 | version='0.0.9', 13 | license='MIT', 14 | author='anki-code', 15 | author_email='no@no.no', 16 | description="History backend that can encrypt the xonsh shell commands history.", 17 | long_description=long_description, 18 | long_description_content_type='text/markdown', 19 | python_requires='>=3.6', 20 | install_requires=['xonsh', 'cryptography', 'ujson'], 21 | packages=['xontrib', 'xontrib.history_encrypt'], 22 | package_dir={'xontrib': 'xontrib'}, 23 | package_data={'xontrib': ['*.py']}, 24 | platforms='any', 25 | url='https://github.com/anki-code/xontrib-history-encrypt', 26 | project_urls={ 27 | "Documentation": "https://github.com/anki-code/xontrib-history-encrypt/blob/master/README.md", 28 | "Code": "https://github.com/anki-code/xontrib-history-encrypt", 29 | "Issue tracker": "https://github.com/anki-code/xontrib-history-encrypt/issues", 30 | }, 31 | classifiers=[ 32 | "Programming Language :: Python :: 3", 33 | "Programming Language :: Python :: 3.6", 34 | "Programming Language :: Python :: 3.7", 35 | "Programming Language :: Python :: 3.8", 36 | "Programming Language :: Python :: 3.9", 37 | "License :: OSI Approved :: MIT License", 38 | "Operating System :: OS Independent", 39 | "Topic :: System :: Shells", 40 | "Topic :: System :: System Shells", 41 | "Topic :: Terminals", 42 | ] 43 | ) 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | The xonsh shell history backend that encrypt the commands history file
to prevent leaking sensitive data. 3 |

4 | 5 |

6 | 7 |

8 | If you like the idea click ⭐ on the repo and tweet. 9 |

10 | 11 | 12 | ## Installation 13 | 14 | To install use pip: 15 | 16 | ```bash 17 | xpip install xontrib-history-encrypt 18 | # or: xpip install -U git+https://github.com/anki-code/xontrib-history-encrypt 19 | ``` 20 | 21 | ## Usage: supported encryption 22 | 23 | ### Base64 (default) 24 | 25 | *Protection level: no protection.* 26 | 27 | Base64 is not the real encrypter and implemented as fast way to encode history file and for education reasons. 28 | It can save you from the massive scanning the file system for keywords (i.e. password, key) 29 | as well as reading the history file by not experienced user. But it can be decoded in five minutes by the professional. 30 | 31 | ```python 32 | # Add to xonsh RC file 33 | $XONSH_HISTORY_ENCRYPTOR = 'base64' 34 | xontrib load history_encrypt 35 | ``` 36 | 37 | ### Fernet 38 | 39 | *Protection level: high.* 40 | 41 | The implementation of [Fernet](https://cryptography.io/en/latest/fernet.html) (AES CBC + HMAC) that was strongly 42 | recommended on [stackoverflow](https://stackoverflow.com/a/55147077). On first start it generates a key that you 43 | should save in secure place. Than you can use this key to decrypt the history. 44 | 45 | ```python 46 | # Add to xonsh RC file 47 | $XONSH_HISTORY_ENCRYPTOR = 'fernet' 48 | xontrib load history_encrypt 49 | ``` 50 | 51 | ### Dummy 52 | 53 | *Protection level: super high.* 54 | 55 | The best encryption of the data when there is no the data. The dummy encryptor stores command only in the memory during 56 | the session without saving it on the disk. After the end of the session the commands will be lost. 57 | 58 | ```python 59 | # Add to xonsh RC file 60 | $XONSH_HISTORY_ENCRYPTOR = 'dummy' 61 | xontrib load history_encrypt 62 | ``` 63 | 64 | ### Custom 65 | 66 | *Protection level: all in your hands.* 67 | 68 | To create custom encryptor you should implement three functions: key getter function, encryptor and decryptor. 69 | 70 | ```python 71 | # Add to xonsh RC file 72 | $XONSH_HISTORY_ENCRYPTOR = { 73 | 'key': lambda: input('[xontrib-history-encrypt] Enter any key just for fun: '), 74 | 'enc': lambda data, key=None: data[::-1], # just flip the string 75 | 'dec': lambda data, key=None: data[::-1] # flip the string back 76 | } 77 | xontrib load history_encrypt 78 | ``` 79 | 80 | After debugging you can add your encryptor to the `history_encrypt` directory of the xontrib by PR. 81 | 82 | ## Common use case 83 | 84 | 1. You're on the public/shared/opened server where you have xonsh and bash. 85 | 2. Install the xontrib and create your RC-file from bash: 86 | ```python 87 | pip install xontrib-history-encrypt 88 | mkdir -p ~/.local/share/xonsh/ 89 | echo -e '$XONSH_HISTORY_ENCRYPTOR = "fernet"\nxontrib load history_encrypt' > ~/.local/share/xonsh/rc 90 | ``` 91 | 3. Run xonsh with RC-file then get the key and remember the key: 92 | ```python 93 | xonsh --rc ~/.local/share/xonsh/rc 94 | # Enter the key or press enter to create new: 95 | # Save the key and use it next time: CFB5kAfD3BgdpQHJxmKb 96 | ``` 97 | 4. Next time run xonsh with RC and key: 98 | ```python 99 | xonsh --rc ~/.local/share/xonsh/rc 100 | # Enter the key or press enter to create new: CFB5kAfD3BgdpQHJxmKb 101 | ``` 102 | 103 | ## What should I know? 104 | 105 | ### How to check the backend is working 106 | 107 | ```bash 108 | history info 109 | # backend: xontrib-history-encrypt 110 | # sessionid: 374eedc9-fc94-4d27-9ab7-ebd5a5c87d12 111 | # filename: /home/user/.local/share/xonsh/xonsh-history-encrypt.txt 112 | # commands: 1 113 | ``` 114 | 115 | ### Some points about the backend 116 | 117 | * At start the backend read and decrypt all commands and this could take time. Basically we assume that you will use the xontrib on your servers and haven't so big history. 118 | 119 | * The commands are stored in the memory and flush to the disk at the exit from the shell. If the shell has crash there is no flushing to the disk and commands will be lost. Use `history flush` command if you plan to run something experimental. 120 | 121 | * The backend has minimal history management support in comparing with json or sqlite backends and you can find the lack of features. 122 | 123 | If you want to improve something from the list PRs are welcome! 124 | 125 | ## Credits 126 | 127 | * This package is the part of [ergopack](https://github.com/anki-code/xontrib-ergopack) - the pack of ergonomic xontribs. 128 | * This package was created with [xontrib cookiecutter template](https://github.com/xonsh/xontrib-cookiecutter). 129 | -------------------------------------------------------------------------------- /xontrib/history_encrypt/__init__.py: -------------------------------------------------------------------------------- 1 | """The xonsh shell history backend that encrypt the commands history file to prevent leaking sensitive data. """ 2 | 3 | import os 4 | import sys 5 | import uuid 6 | import builtins 7 | 8 | try: 9 | import ujson as json 10 | except ImportError: 11 | import json # type: ignore 12 | 13 | from xonsh.history.base import History 14 | 15 | class XontribHistoryEncrypt(History): 16 | 17 | def __init__(self, filename=None, sessionid=None, **kwargs): 18 | self.debug = __xonsh__.env.get('XONSH_HISTORY_ENCRYPT_DEBUG', False) 19 | self.lock = False 20 | 21 | self.tqdm = lambda a: a 22 | if self.debug: 23 | try: 24 | from tqdm import tqdm 25 | self.tqdm = tqdm 26 | except: 27 | pass 28 | 29 | encryptor = __xonsh__.env.get('XONSH_HISTORY_ENCRYPTOR', 'base64') 30 | if type(encryptor) is dict: 31 | self.key = encryptor['key'] 32 | self.enc = encryptor['enc'] 33 | self.dec = encryptor['dec'] 34 | elif type(encryptor) is str: 35 | if encryptor == 'disabled': 36 | self.key = None 37 | self.enc = lambda data, key=None: data 38 | self.dec = lambda data, key=None: data 39 | elif encryptor == 'base64': 40 | from xontrib.history_encrypt.base64 import base64_encode, base64_decode 41 | self.key = None 42 | self.enc = lambda data, key=None: base64_encode(data.encode()).decode() 43 | self.dec = lambda data, key=None: base64_decode(data.encode()).decode() 44 | elif encryptor == 'fernet': 45 | from xontrib.history_encrypt.fernet import fernet_key, fernet_encrypt, fernet_decrypt 46 | self.key = fernet_key 47 | self.enc = lambda data, key: fernet_encrypt(data.encode(), key).decode() 48 | self.dec = lambda data, key: fernet_decrypt(data.encode(), key).decode() 49 | else: 50 | printx(f"{{RED}}[xontrib-history-encrypt] Wrong encryptor name '{encryptor}'! History will not be loaded and saved.{{RESET}}") 51 | self.lock = True 52 | else: 53 | printx('{RED}[xontrib-history-encrypt] Wrong encryptor type! History will not be loaded and saved.{RESET}') 54 | self.lock = True 55 | 56 | if not self.lock: 57 | self.key = self.key() if callable(self.key) else self.key 58 | 59 | self.sessionid = uuid.uuid4() if sessionid is None else sessionid 60 | self.buffer = [] 61 | self.gc = None 62 | 63 | if filename is None: 64 | data_dir = builtins.__xonsh__.env.get("XONSH_DATA_DIR") 65 | data_dir = os.path.expanduser(data_dir) 66 | filename_env = __xonsh__.env.get('XONSH_HISTORY_ENCRYPT_FILE', os.path.join(data_dir, 'xontrib-history-encrypt-data.txt')) 67 | self.filename = filename_env 68 | else: 69 | self.filename = filename 70 | 71 | self.inps = None 72 | self.rtns = None 73 | self.tss = None 74 | self.outs = None 75 | self.last_cmd_rtn = None 76 | self.last_cmd_out = None 77 | self.hist_size = None 78 | self.hist_units = None 79 | self.remember_history = True 80 | 81 | def append(self, data): 82 | self.buffer.append(data) 83 | 84 | def items(self, newest_first=False): 85 | if self.lock: 86 | return [] 87 | 88 | if os.path.exists(self.filename): 89 | data = [] 90 | first_line = True 91 | with self.tqdm(open(self.filename, 'r')) as file: 92 | for line in file: 93 | line = line.rstrip() 94 | if first_line: 95 | try: 96 | crypt_mark = self.dec(line, self.key) 97 | assert crypt_mark.isdigit() 98 | except: 99 | printx("{YELLOW}The crypted history file is not matching with crypto algorithm or the key.{RESET}") 100 | printx(f"{{YELLOW}}Change the encryption algorithm or the key or remove the history file.{{RESET}}") 101 | printx(f"{{YELLOW}}History file: {self.filename}{{RESET}}") 102 | printx(f"{{RED}}History has not loaded and will not be saved!{{RESET}}") 103 | self.lock = True 104 | return [] 105 | first_line = False 106 | continue 107 | data.append(json.loads(self.dec(line, self.key))) 108 | return reversed(data) if newest_first else data 109 | return [] 110 | 111 | def all_items(self, newest_first=False): 112 | return self.items(newest_first) 113 | 114 | def flush(self, **kwargs): 115 | if self.lock: 116 | return 117 | 118 | if not os.path.exists(self.filename): 119 | with open(self.filename, 'w') as file: 120 | from datetime import datetime 121 | file.write(self.enc(datetime.now().strftime("%Y%m%d%H%M%S"), self.key) + os.linesep) 122 | 123 | try: 124 | os.chmod(self.filename, 0o600) 125 | except Exception as e: 126 | if self.debug: 127 | print(f'Exception while setting permissions to {self.filename}: {e}', file=sys.stderr) 128 | pass 129 | 130 | with open(self.filename, 'a') as file: 131 | for data in self.buffer: 132 | if 'out' in data: 133 | del data['out'] 134 | file.write(self.enc(json.dumps(data), self.key) + os.linesep) 135 | self.buffer = [] 136 | 137 | def info(self): 138 | data = {} 139 | data["backend"] = "xontrib-history-encrypt" 140 | if self.lock: 141 | data["locked"] = "YES! Current session history will not be saved." 142 | data["sessionid"] = str(self.sessionid) 143 | data["filename"] = self.filename 144 | data["commands"] = len(self.buffer) 145 | return data 146 | 147 | 148 | 149 | encryptor = __xonsh__.env.get('XONSH_HISTORY_ENCRYPTOR', 'base64') 150 | 151 | if encryptor == 'dummy': 152 | from xonsh.history.dummy import DummyHistory 153 | __xonsh__.env['XONSH_HISTORY_BACKEND'] = DummyHistory 154 | else: 155 | __xonsh__.env['XONSH_HISTORY_BACKEND'] = XontribHistoryEncrypt 156 | --------------------------------------------------------------------------------