├── setup.cfg ├── MANIFEST.in ├── contrib └── privatebin.ico ├── requirements.txt ├── pyproject.toml ├── pbincli ├── __init__.py ├── utils.py ├── actions.py ├── cli.py ├── api.py └── format.py ├── Dockerfile ├── LICENSE ├── setup.py ├── pbincli.spec └── README.rst /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | license_files = LICENSE 3 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.rst 3 | include requirements.txt 4 | -------------------------------------------------------------------------------- /contrib/privatebin.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/r4sas/PBinCLI/HEAD/contrib/privatebin.ico -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pycryptodome 2 | sjcl 3 | base58 4 | requests 5 | argcomplete 6 | pysocks -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "wheel"] 3 | 4 | [tools.setuptools] 5 | license_files = [] -------------------------------------------------------------------------------- /pbincli/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | __author__ = "R4SAS " 5 | __version__ = "0.3.7" 6 | __copyright__ = "Copyright (c) R4SAS" 7 | __license__ = "MIT" 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3 2 | WORKDIR /usr/src/app 3 | 4 | COPY requirements.txt ./ 5 | RUN pip install --no-cache-dir -r requirements.txt 6 | 7 | COPY . . 8 | 9 | RUN python setup.py install 10 | 11 | ENTRYPOINT [ "/usr/local/bin/pbincli" ] 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2017 © R4SAS 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /pbincli/utils.py: -------------------------------------------------------------------------------- 1 | import json, ntpath, os, sys 2 | from platform import system 3 | 4 | class PBinCLIException(Exception): 5 | pass 6 | 7 | 8 | def PBinCLIError(message): 9 | print("PBinCLI Error: {}".format(message), file=sys.stderr) 10 | sys.exit(1) 11 | 12 | 13 | def path_leaf(path): 14 | head, tail = ntpath.split(path) 15 | return tail or ntpath.basename(head) 16 | 17 | 18 | def check_readable(f): 19 | # Checks if path exists and readable 20 | if not os.path.exists(f) or not os.access(f, os.R_OK): 21 | PBinCLIError("Error accessing path: {}".format(f)) 22 | 23 | 24 | def check_writable(f): 25 | # Checks if path is writable 26 | if not os.access(os.path.dirname(f) or ".", os.W_OK): 27 | PBinCLIError("Path is not writable: {}".format(f)) 28 | 29 | 30 | def json_encode(s): 31 | return json.dumps(s, separators=(',',':')).encode() 32 | 33 | 34 | def validate_url_ending(s): 35 | if not s.endswith('/'): 36 | s = s + "/" 37 | return s 38 | 39 | def validate_path_ending(s): 40 | if system() == 'Windows': 41 | slash = '\\' 42 | else: 43 | slash = '/' 44 | 45 | if not s.endswith(slash): 46 | s = s + slash 47 | return s 48 | 49 | def uri_validator(x): 50 | from urllib.parse import urlsplit 51 | try: 52 | result = urlsplit(x) 53 | isuri = all([result.scheme, result.netloc]) 54 | return result, isuri 55 | except ValueError: 56 | return False -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from setuptools import setup 4 | from pbincli.__init__ import __version__ as pbincli_version 5 | 6 | with open("README.rst") as readme: 7 | long_description = readme.read() 8 | 9 | with open("requirements.txt") as f: 10 | install_requires = f.read().split() 11 | 12 | setup( 13 | name='PBinCLI', 14 | version=pbincli_version, 15 | description='PrivateBin client for command line', 16 | long_description=long_description, 17 | long_description_content_type='text/x-rst', 18 | author='R4SAS', 19 | author_email='r4sas@i2pmail.org', 20 | url='https://github.com/r4sas/PBinCLI/', 21 | keywords='privatebin cryptography security', 22 | license='MIT', 23 | classifiers=[ 24 | 'Development Status :: 5 - Production/Stable', 25 | 'Environment :: Console', 26 | 'Intended Audience :: End Users/Desktop', 27 | 'License :: OSI Approved :: MIT License', 28 | 'Programming Language :: Python :: 3', 29 | 'Topic :: Security :: Cryptography', 30 | 'Topic :: Utilities', 31 | ], 32 | packages=['pbincli'], 33 | install_requires=install_requires, 34 | python_requires='>=3', 35 | entry_points={ 36 | 'console_scripts': [ 37 | 'pbincli=pbincli.cli:main', 38 | ], 39 | }, 40 | project_urls={ 41 | 'Bug Reports': 'https://github.com/r4sas/PBinCLI/issues', 42 | 'Source': 'https://github.com/r4sas/PBinCLI/', 43 | }, 44 | ) 45 | -------------------------------------------------------------------------------- /pbincli.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python -*- 2 | 3 | from pkg_resources import parse_version 4 | from PyInstaller.utils.win32.versioninfo import VSVersionInfo, FixedFileInfo, StringFileInfo, StringTable, StringStruct, VarFileInfo, VarStruct 5 | from pbincli.__init__ import __version__ as pbincli_version, __copyright__ as pbincli_copyright 6 | 7 | pbincli_ver = parse_version(pbincli_version) 8 | 9 | block_cipher = None 10 | 11 | a = Analysis(['pbincli\\cli.py'], 12 | pathex=[], 13 | binaries=[], 14 | datas=[], 15 | hiddenimports=[], 16 | hookspath=[], 17 | runtime_hooks=[], 18 | excludes=[], 19 | win_no_prefer_redirects=False, 20 | win_private_assemblies=False, 21 | cipher=block_cipher, 22 | noarchive=False) 23 | pyz = PYZ(a.pure, a.zipped_data, 24 | cipher=block_cipher) 25 | exe = EXE(pyz, 26 | a.scripts, 27 | a.binaries, 28 | a.zipfiles, 29 | a.datas, 30 | [], 31 | name='pbincli-' + pbincli_version, 32 | version=VSVersionInfo( 33 | ffi=FixedFileInfo( 34 | filevers=(pbincli_ver.major, pbincli_ver.minor, pbincli_ver.micro, 0), 35 | prodvers=(pbincli_ver.major, pbincli_ver.minor, pbincli_ver.micro, 0), 36 | mask=0x3f, 37 | flags=0x0, 38 | OS=0x40004, 39 | fileType=0x1, 40 | subtype=0x0, 41 | date=(0, 0) 42 | ), 43 | kids=[ 44 | StringFileInfo([ 45 | StringTable( 46 | u'040904B0', 47 | [ 48 | StringStruct(u'FileDescription', u'PrivateBin CLI'), 49 | StringStruct(u'FileVersion', pbincli_version), 50 | StringStruct(u'InternalName', u'pbincli'), 51 | StringStruct(u'LegalCopyright', pbincli_copyright), 52 | StringStruct(u'OriginalFilename', u'pbincli-' + pbincli_version + u'.exe'), 53 | StringStruct(u'ProductName', u'PBinCLI'), 54 | StringStruct(u'ProductVersion', pbincli_version) 55 | ] 56 | ) 57 | ]), 58 | VarFileInfo([VarStruct(u'Translation', [1033, 1200])]) 59 | ] 60 | ), 61 | icon=['contrib\\privatebin.ico'], 62 | debug=False, 63 | bootloader_ignore_signals=False, 64 | strip=False, 65 | upx=True, 66 | runtime_tmpdir=None, 67 | console=True) 68 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | 2 | 3 | .. image:: https://img.shields.io/github/license/r4sas/PBinCLI.svg 4 | :target: https://github.com/r4sas/PBinCLI/blob/master/LICENSE 5 | :alt: GitHub license 6 | 7 | 8 | .. image:: https://img.shields.io/github/tag/r4sas/PBinCLI.svg 9 | :target: https://github.com/r4sas/PBinCLI/tags/ 10 | :alt: GitHub tag 11 | 12 | 13 | .. image:: https://app.codacy.com/project/badge/Grade/4f24f43356a84621bbd9078c4b3f1b70 14 | :target: https://www.codacy.com/gh/r4sas/PBinCLI/dashboard?utm_source=github.com&utm_medium=referral&utm_content=r4sas/PBinCLI&utm_campaign=Badge_Grade 15 | :alt: Codacy Badge 16 | 17 | 18 | PBinCLI 19 | ======= 20 | 21 | PBinCLI is a command line client for `PrivateBin `_ written in Python 3. 22 | 23 | Installation 24 | ============ 25 | 26 | Installing globally using pip3: 27 | 28 | .. code-block:: bash 29 | 30 | pip3 install pbincli 31 | 32 | Installing with ``virtualenv``\ : 33 | 34 | .. code-block:: bash 35 | 36 | python3 -m virtualenv --python=python3 venv 37 | . venv/bin/activate 38 | pip install pbincli 39 | 40 | *Note*\ : if you used ``virtualenv`` installation method, don't forget to activate your virtual environment before running the tool: call ``. /path/to/venv/bin/activate`` in terminal 41 | 42 | Configuration 43 | ============= 44 | 45 | By default PBinCLI is configured to use ``https://paste.i2pd.xyz/`` for sending and receiving pastes. No proxy is used by default. 46 | 47 | You can always create a config file to use different settings. 48 | 49 | Configuration file is expected to be found in ``~/.config/pbincli/pbincli.conf``\ , ``%APPDATA%/pbincli/pbincli.conf`` (Windows) and ``~/Library/Application Support/pbincli/pbincli.conf`` (MacOS) 50 | 51 | Example of config file content 52 | ------------------------------ 53 | 54 | .. code-block:: ini 55 | 56 | server=https://paste.i2pd.xyz/ 57 | proxy=http://127.0.0.1:3128 58 | 59 | List of OPTIONS available 60 | ------------------------- 61 | 62 | .. list-table:: 63 | :header-rows: 1 64 | 65 | * - Option 66 | - Default 67 | - Possible value 68 | * - server 69 | - https://paste.i2pd.xyz/ 70 | - Domain ending with slash 71 | * - random_server 72 | - None 73 | - Domains separated with comma, selected randomly 74 | * - mirrors 75 | - None 76 | - Domains separated with comma, like ``http://privatebin.ygg/,http://privatebin.i2p/`` 77 | * - proxy 78 | - None 79 | - Proxy address starting with scheme ``http://`` or ``socks5://`` 80 | * - expire 81 | - 1day 82 | - 5min / 10min / 1hour / 1day / 1week / 1month / 1year / never 83 | * - burn 84 | - False 85 | - True / False 86 | * - discus 87 | - False 88 | - True / False 89 | * - format 90 | - plaintext 91 | - plaintext / syntaxhighlighting / markdown 92 | * - short 93 | - False 94 | - True / False 95 | * - short_api 96 | - None 97 | - ``tinyurl``\ , ``clckru``\ , ``isgd``\ , ``vgd``\ , ``cuttly``\ , ``yourls``\ , ``custom`` 98 | * - short_url 99 | - None 100 | - Domain name of shortener service for ``yourls``\ , or URL (with required parameters) for ``custom`` 101 | * - short_user 102 | - None 103 | - Used only in ``yourls`` 104 | * - short_pass 105 | - None 106 | - Used only in ``yourls`` 107 | * - short_token 108 | - None 109 | - Used only in ``yourls`` 110 | * - output 111 | - None 112 | - Path to the directory where the received data will be saved 113 | * - no_check_certificate 114 | - False 115 | - True / False 116 | * - no_insecure_warning 117 | - False 118 | - True / False 119 | * - compression 120 | - zlib 121 | - zlib / none 122 | * - auth 123 | - None 124 | - ``basic``\ , ``custom`` 125 | * - auth_user 126 | - None 127 | - Basic authorization username 128 | * - auth_pass 129 | - None 130 | - Basic authorization password 131 | * - auth_custom 132 | - None 133 | - Custom authorization headers in JSON format, like ``{'Authorization': 'Bearer token'}`` 134 | * - json 135 | - False 136 | - Print sending result in JSON format 137 | 138 | 139 | Usage 140 | ===== 141 | 142 | PBinCLI tool is started with ``pbincli`` command. Detailed help on command usage is provided with ``-h`` option: 143 | 144 | .. code-block:: bash 145 | 146 | pbincli {send|get|delete} -h 147 | 148 | Sending 149 | ------- 150 | 151 | 152 | * 153 | Sending text: 154 | 155 | .. code-block:: bash 156 | 157 | pbincli send -t "Hello! This is a test paste!" 158 | 159 | * 160 | Using stdin input to read text into a paste: 161 | 162 | .. code-block:: bash 163 | 164 | pbincli send - <`_ in the root of the project source code. 241 | -------------------------------------------------------------------------------- /pbincli/actions.py: -------------------------------------------------------------------------------- 1 | import json, signal, sys 2 | from urllib.parse import parse_qsl 3 | 4 | from pbincli.api import Shortener 5 | from pbincli.format import Paste 6 | from pbincli.utils import PBinCLIError, check_writable, json_encode, uri_validator, validate_url_ending, validate_path_ending 7 | 8 | 9 | def signal_handler(sig, frame): 10 | print('Keyboard interrupt received, terminating…') 11 | sys.exit(0) 12 | 13 | signal.signal(signal.SIGINT, signal_handler) 14 | 15 | 16 | def send(args, api_client, settings=None): 17 | if settings['short']: 18 | shortener = Shortener(settings) 19 | 20 | if not args.notext: 21 | if args.text: 22 | text = args.text 23 | elif args.stdin: 24 | if not settings['json']: print("Reading text from stdin…") 25 | text = args.stdin.read() 26 | elif not args.file: 27 | PBinCLIError("Nothing to send!") 28 | else: 29 | text = "" 30 | 31 | if not settings['json']: print("Preparing paste…") 32 | paste = Paste(args.debug) 33 | 34 | if args.verbose: print("Used server: {}".format(api_client.getServer())) 35 | 36 | # get from server supported paste format version and update object 37 | if args.debug: print("Getting supported paste format version from server…") 38 | version = api_client.getVersion() 39 | paste.setVersion(version) 40 | 41 | if args.verbose: print("Filling paste with data…") 42 | # set compression type, works only on v2 pastes 43 | if version == 2: 44 | paste.setCompression(settings['compression']) 45 | 46 | # add text in paste (if it provided) 47 | paste.setText(text) 48 | 49 | # If we set PASSWORD variable 50 | if args.password: 51 | paste.setPassword(args.password) 52 | 53 | # If we set FILE variable 54 | if args.file: 55 | paste.setAttachment(args.file) 56 | 57 | if args.verbose: print("Encrypting paste…") 58 | paste.encrypt( 59 | formatter = settings['format'], 60 | burnafterreading = settings['burn'], 61 | discussion = settings['discus'], 62 | expiration = settings['expire']) 63 | 64 | if args.verbose: print("Preparing request to server…") 65 | request = paste.getJSON() 66 | 67 | if args.debug: print("Passphrase:\t{}\nRequest:\t{}".format(paste.getHash(), request)) 68 | 69 | # If we use dry option, exit now 70 | if args.dry: 71 | if not settings['json']: print("Dry mode: paste will not be uploaded. Exiting…") 72 | sys.exit(0) 73 | 74 | if not settings['json']: print("Uploading paste…") 75 | result = api_client.post(request) 76 | 77 | if args.debug: print("Response:\t{}\n".format(result)) 78 | 79 | # Paste was sent. Checking for returned status code 80 | if not result['status']: # return code is zero 81 | passphrase = paste.getHash() 82 | 83 | if settings['json']: # JSON output 84 | response = { 85 | 'status': result['status'], 86 | 'result': { 87 | 'id': result['id'], 88 | 'password': passphrase, 89 | 'deletetoken': result['deletetoken'], 90 | 'link': "{}?{}#{}".format( 91 | settings['server'], 92 | result['id'], 93 | passphrase), 94 | 'deletelink': "{}?pasteid={}&deletetoken={}".format( 95 | settings['server'], 96 | result['id'], 97 | result['deletetoken']), 98 | } 99 | } 100 | 101 | if settings['mirrors']: 102 | urls = settings['mirrors'].split(',') 103 | mirrors = [] 104 | for x in urls: 105 | mirrors.append("{}?{}#{}".format( 106 | validate_url_ending(x), 107 | result['id'], 108 | passphrase) 109 | ) 110 | response['result']['mirrors'] = mirrors 111 | 112 | if settings['short']: 113 | try: 114 | response['result']['short'] = shortener.getlink("{}?{}#{}".format( 115 | settings['server'], 116 | result['id'], 117 | passphrase)) 118 | except Exception as ex: 119 | response['result']['short_error'] = ex 120 | print(json.dumps(response)) 121 | sys.exit(0) 122 | 123 | else: 124 | # Paste information 125 | print("Paste uploaded!\nPasteID:\t{}\nPassword:\t{}\nDelete token:\t{}".format( 126 | result['id'], 127 | passphrase, 128 | result['deletetoken'])) 129 | 130 | # Paste link 131 | print("\nLink:\t\t{}?{}#{}".format( 132 | settings['server'], 133 | result['id'], 134 | passphrase)) 135 | 136 | # Paste deletion link 137 | print("Delete Link:\t{}?pasteid={}&deletetoken={}".format( 138 | settings['server'], 139 | result['id'], 140 | result['deletetoken'])) 141 | 142 | # Print links to mirrors if present 143 | if settings['mirrors']: 144 | print("\nMirrors:") 145 | urls = settings['mirrors'].split(',') 146 | for x in urls: 147 | print("\t\t{}?{}#{}".format( 148 | validate_url_ending(x), 149 | result['id'], 150 | passphrase)) 151 | 152 | if settings['short']: 153 | print("\nQuerying URL shortening service…") 154 | try: 155 | link = shortener.getlink("{}?{}#{}".format( 156 | settings['server'], 157 | result['id'], 158 | passphrase)) 159 | print("Short Link:\t{}".format(link)) 160 | except Exception as ex: 161 | PBinCLIError("Something went wrong…\nError:\t\t{}".format(ex)) 162 | sys.exit(0) 163 | 164 | elif result['status']: # return code is other then zero 165 | PBinCLIError("Something went wrong…\nError:\t\t{}".format(result['message'])) 166 | else: # or here no status field in response or it is empty 167 | PBinCLIError("Something went wrong…\nError: Empty response.") 168 | 169 | def get(args, api_client, settings=None): 170 | parseduri, isuri = uri_validator(args.pasteinfo) 171 | 172 | if isuri and parseduri.query and parseduri.fragment: 173 | api_client.server = args.pasteinfo.split("?")[0] 174 | pasteid = parseduri.query 175 | passphrase = parseduri.fragment 176 | elif parseduri.path and parseduri.path != "/" and parseduri.fragment: 177 | pasteid = parseduri.path 178 | passphrase = parseduri.fragment 179 | else: 180 | PBinCLIError("Provided info hasn't contain valid URL or PasteID#Passphrase string") 181 | 182 | if args.verbose: print("Used server: {}".format(api_client.getServer())) 183 | if args.debug: print("PasteID:\t{}\nPassphrase:\t{}".format(pasteid, passphrase)) 184 | 185 | paste = Paste(args.debug) 186 | 187 | if args.password: 188 | paste.setPassword(args.password) 189 | if args.debug: print("Password:\t{}".format(args.password)) 190 | 191 | if args.verbose: print("Requesting paste from server…") 192 | result = api_client.get(pasteid) 193 | 194 | if args.debug: print("Response:\t{}\n".format(result)) 195 | 196 | # Paste was received. Checking received status code 197 | if not result['status']: # return code is zero 198 | print("Paste received! Decoding…") 199 | 200 | version = result['v'] if 'v' in result else 1 201 | paste.setVersion(version) 202 | 203 | if version == 2: 204 | if args.debug: print("Authentication data:\t{}".format(result['adata'])) 205 | 206 | paste.setHash(passphrase) 207 | paste.loadJSON(result) 208 | paste.decrypt() 209 | 210 | text = paste.getText() 211 | 212 | if args.debug: print("Decoded text size: {}\n".format(len(text))) 213 | 214 | if len(text): 215 | if args.debug: print("{}\n".format(text.decode())) 216 | if settings['output']: 217 | paste_path = validate_path_ending(settings['output']) + "paste-" + pasteid + ".txt" 218 | else: 219 | paste_path = "paste-" + pasteid + ".txt" 220 | 221 | print("Found text in paste. Saving it to {}".format(paste_path)) 222 | 223 | check_writable(paste_path) 224 | with open(paste_path, "wb") as f: 225 | f.write(text) 226 | f.close() 227 | 228 | attachment, attachment_name = paste.getAttachment() 229 | 230 | if attachment: 231 | if settings['output']: 232 | attachment_path = validate_path_ending(settings['output']) + attachment_name 233 | else: 234 | attachment_path = attachment_name 235 | 236 | print("Found file, attached to paste. Saving it to {}\n".format(attachment_path)) 237 | 238 | check_writable(attachment_path) 239 | with open(attachment_path, "wb") as f: 240 | f.write(attachment) 241 | f.close() 242 | 243 | if version == 1 and 'meta' in result and 'burnafterreading' in result['meta'] and result['meta']['burnafterreading']: 244 | print("Burn afrer reading flag found. Deleting paste…") 245 | api_client.delete(json_encode({'pasteid':pasteid,'deletetoken':'burnafterreading'})) 246 | 247 | elif result['status']: # return code is other then zero 248 | PBinCLIError("Something went wrong…\nError:\t\t{}".format(result['message'])) 249 | else: # or here no status field in response or it is empty 250 | PBinCLIError("Something went wrong…\nError: Empty response.") 251 | 252 | 253 | def delete(args, api_client, settings=None): 254 | parseduri, isuri = uri_validator(args.pasteinfo) 255 | 256 | if isuri: 257 | api_client.server = args.pasteinfo.split("?")[0] 258 | query = dict(parse_qsl(parseduri.query)) 259 | else: 260 | query = dict(parse_qsl(args.pasteinfo)) 261 | 262 | if 'pasteid' in query and 'deletetoken' in query: 263 | pasteid = query['pasteid'] 264 | token = query['deletetoken'] 265 | else: 266 | PBinCLIError("Provided info hasn't contain required information") 267 | 268 | if args.verbose: print("Used server: {}".format(api_client.getServer())) 269 | if args.debug: print("PasteID:\t{}\nToken:\t\t{}".format(pasteid, token)) 270 | 271 | print("Requesting paste deletion…") 272 | api_client.delete(json_encode({'pasteid':pasteid,'deletetoken':token})) 273 | -------------------------------------------------------------------------------- /pbincli/cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # PYTHON_ARGCOMPLETE_OK 3 | import os, sys, argparse 4 | 5 | import argcomplete 6 | 7 | import pbincli.actions 8 | from pbincli.api import PrivateBin 9 | from pbincli.utils import PBinCLIException, PBinCLIError, validate_url_ending 10 | 11 | CONFIG_PATHS = [ 12 | os.path.join(".", "pbincli.conf", ), 13 | os.path.join(os.getenv("HOME") or "~", ".config", "pbincli", "pbincli.conf") 14 | ] 15 | 16 | if sys.platform == "win32": 17 | CONFIG_PATHS.append(os.path.join(os.getenv("APPDATA"), "pbincli", "pbincli.conf")) 18 | elif sys.platform == "darwin": 19 | CONFIG_PATHS.append(os.path.join(os.getenv("HOME") or "~", "Library", "Application Support", "pbincli", "pbincli.conf")) 20 | 21 | 22 | def strtobool(value): 23 | try: 24 | return { 25 | 'y': True, 'yes': True, 't': True, 'true': True, 'on': True, '1': True, 26 | 'n': False, 'no': False, 'f': False, 'false': False, 'off': False, '0': False, 27 | }[str(value).lower()] 28 | except KeyError: 29 | raise ValueError('"{}" is not a valid bool value'.format(value)) 30 | 31 | 32 | def read_config(filename): 33 | """Read config variables from a file""" 34 | settings = {} 35 | with open(filename) as f: 36 | for l in f.readlines(): 37 | if len(l.strip()) == 0: 38 | continue 39 | try: 40 | key, value = l.strip().split("=", 1) 41 | if value.strip().lower() in ['true', 'false']: 42 | settings[key.strip()] = bool(strtobool(value.strip())) 43 | else: 44 | settings[key.strip()] = value.strip() 45 | except ValueError: 46 | PBinCLIError("Unable to parse config file, please check it for errors.") 47 | return settings 48 | 49 | 50 | def main(): 51 | parser = argparse.ArgumentParser(description='Full-featured PrivateBin command-line client') 52 | parser.add_argument("-d", "--debug", default=False, action="store_true", help="Enable debug output") 53 | 54 | subparsers = parser.add_subparsers(title="actions", help="List of commands") 55 | 56 | # a send command 57 | send_parser = subparsers.add_parser("send", description="Send data to PrivateBin instance") 58 | send_parser.add_argument("-t", "--text", help="Text in quotes. Ignored if used stdin. If not used, forcefully used stdin") 59 | send_parser.add_argument("-f", "--file", help="Example: image.jpg or full path to file") 60 | send_parser.add_argument("-p", "--password", help="Password for encrypting paste") 61 | send_parser.add_argument("-E", "--expire", default=argparse.SUPPRESS, action="store", 62 | choices=["5min", "10min", "1hour", "1day", "1week", "1month", "1year", "never"], help="Paste lifetime (default: 1day)") 63 | send_parser.add_argument("-B", "--burn", default=argparse.SUPPRESS, action="store_true", help="Set \"Burn after reading\" flag") 64 | send_parser.add_argument("-D", "--discus", default=argparse.SUPPRESS, action="store_true", help="Open discussion for sent paste") 65 | send_parser.add_argument("-F", "--format", default="plaintext", action="store", 66 | choices=["plaintext", "syntaxhighlighting", "markdown"], help="Format of text (default: plaintext)") 67 | send_parser.add_argument("-q", "--notext", default=False, action="store_true", help="Don't send text in paste") 68 | send_parser.add_argument("-c", "--compression", default="zlib", action="store", 69 | choices=["zlib", "none"], help="Set compression for paste (default: zlib). Note: works only on v2 paste format") 70 | ## URL shortener 71 | send_parser.add_argument("-S", "--short", default=argparse.SUPPRESS, action="store_true", help="Use URL shortener") 72 | send_parser.add_argument("--short-api", default=argparse.SUPPRESS, action="store", 73 | choices=["tinyurl", "clckru", "isgd", "vgd", "cuttly", "yourls", "custom"], help="API used by shortener service") 74 | send_parser.add_argument("--short-url", default=argparse.SUPPRESS, help="URL of shortener service API") 75 | send_parser.add_argument("--short-user", default=argparse.SUPPRESS, help="Shortener username") 76 | send_parser.add_argument("--short-pass", default=argparse.SUPPRESS, help="Shortener password") 77 | send_parser.add_argument("--short-token", default=argparse.SUPPRESS, help="Shortener token") 78 | ## Connection options 79 | send_parser.add_argument("-s", "--server", default=argparse.SUPPRESS, help="Instance URL (default: https://paste.i2pd.xyz/)") 80 | send_parser.add_argument("-x", "--proxy", default=argparse.SUPPRESS, help="Proxy server address (default: None)") 81 | send_parser.add_argument("--no-check-certificate", default=argparse.SUPPRESS, action="store_true", help="Disable certificate validation") 82 | send_parser.add_argument("--no-insecure-warning", default=argparse.SUPPRESS, action="store_true", 83 | help="Suppress InsecureRequestWarning (only with --no-check-certificate)") 84 | ## Authorization options 85 | send_parser.add_argument("--auth", default=argparse.SUPPRESS, action="store", 86 | choices=["basic", "custom"], help="Server authorization method (default: none)") 87 | send_parser.add_argument("--auth-user", default=argparse.SUPPRESS, help="Basic authorization username") 88 | send_parser.add_argument("--auth-pass", default=argparse.SUPPRESS, help="Basic authorization password") 89 | send_parser.add_argument("--auth-custom", default=argparse.SUPPRESS, help="Custom authorization header in JSON format") 90 | ## 91 | send_parser.add_argument("-R", "--random-server", default=argparse.SUPPRESS, help="Comma-separated list of servers with scheme, randomly chosen to to send paste to (default: None)") 92 | send_parser.add_argument("-L", "--mirrors", default=argparse.SUPPRESS, help="Comma-separated list of mirrors of service with scheme (default: None)") 93 | send_parser.add_argument("-v", "--verbose", default=False, action="store_true", help="Enable verbose output") 94 | send_parser.add_argument("-d", "--debug", default=False, action="store_true", help="Enable debug output. Includes verbose output") 95 | send_parser.add_argument("--json", default=argparse.SUPPRESS, action="store_true", help="Print result in JSON format") 96 | send_parser.add_argument("--dry", default=False, action="store_true", help="Invoke dry run") 97 | send_parser.add_argument("stdin", help="Input paste text from stdin", nargs="?", type=argparse.FileType("r"), default=sys.stdin) 98 | send_parser.set_defaults(func=pbincli.actions.send) 99 | 100 | # a get command 101 | get_parser = subparsers.add_parser("get", description="Get data from PrivateBin instance") 102 | get_parser.add_argument("pasteinfo", help="\"PasteID#Passphrase\" or full URL") 103 | get_parser.add_argument("-p", "--password", help="Password for decrypting paste") 104 | get_parser.add_argument("-o", "--output", default=argparse.SUPPRESS, help="Path to directory where decoded paste data will be saved") 105 | ## Connection options 106 | get_parser.add_argument("-s", "--server", default=argparse.SUPPRESS, help="Instance URL (default: https://paste.i2pd.xyz/, ignored if URL used in pasteinfo)") 107 | get_parser.add_argument("-x", "--proxy", default=argparse.SUPPRESS, help="Proxy server address (default: None)") 108 | get_parser.add_argument("--no-check-certificate", default=argparse.SUPPRESS, action="store_true", help="Disable certificate validation") 109 | get_parser.add_argument("--no-insecure-warning", default=argparse.SUPPRESS, action="store_true", 110 | help="Suppress InsecureRequestWarning (only with --no-check-certificate)") 111 | ## Authorization options 112 | get_parser.add_argument("--auth", default=argparse.SUPPRESS, action="store", 113 | choices=["basic", "custom"], help="Server authorization method (default: none)") 114 | get_parser.add_argument("--auth-user", default=argparse.SUPPRESS, help="Basic authorization username") 115 | get_parser.add_argument("--auth-pass", default=argparse.SUPPRESS, help="Basic authorization password") 116 | get_parser.add_argument("--auth-custom", default=argparse.SUPPRESS, help="Custom authorization header in JSON format") 117 | ## 118 | get_parser.add_argument("-v", "--verbose", default=False, action="store_true", help="Enable verbose output") 119 | get_parser.add_argument("-d", "--debug", default=False, action="store_true", help="Enable debug output. Includes verbose output") 120 | get_parser.set_defaults(func=pbincli.actions.get) 121 | 122 | # a delete command 123 | delete_parser = subparsers.add_parser("delete", description="Delete paste from PrivateBin instance") 124 | delete_parser.add_argument("pasteinfo", help="Paste deletion URL or string in \"pasteid=PasteID&deletetoken=Token\" format") 125 | ## Connection options 126 | delete_parser.add_argument("-s", "--server", default=argparse.SUPPRESS, help="Instance URL (default: https://paste.i2pd.xyz/)") 127 | delete_parser.add_argument("-x", "--proxy", default=argparse.SUPPRESS, help="Proxy server address (default: None)") 128 | delete_parser.add_argument("--no-check-certificate", default=argparse.SUPPRESS, action="store_true", help="Disable certificate validation") 129 | delete_parser.add_argument("--no-insecure-warning", default=argparse.SUPPRESS, action="store_true", 130 | help="Suppress InsecureRequestWarning (only with --no-check-certificate)") 131 | delete_parser.add_argument("--auth", default=argparse.SUPPRESS, action="store", 132 | choices=["basic", "custom"], help="Server authorization method (default: none)") 133 | delete_parser.add_argument("--auth-user", default=argparse.SUPPRESS, help="Basic authorization username") 134 | delete_parser.add_argument("--auth-pass", default=argparse.SUPPRESS, help="Basic authorization password") 135 | delete_parser.add_argument("--auth-custom", default=argparse.SUPPRESS, help="Custom authorization header in JSON format") 136 | ## 137 | delete_parser.add_argument("-v", "--verbose", default=False, action="store_true", help="Enable verbose output") 138 | delete_parser.add_argument("-d", "--debug", default=False, action="store_true", help="Enable debug output. Includes verbose output") 139 | delete_parser.set_defaults(func=pbincli.actions.delete) 140 | 141 | # Add argcomplete trigger 142 | argcomplete.autocomplete(parser) 143 | 144 | # parse arguments 145 | args = parser.parse_args() 146 | 147 | # default configuration 148 | CONFIG = { 149 | 'server': 'https://paste.i2pd.xyz/', 150 | 'random_server': None, 151 | 'mirrors': None, 152 | 'proxy': None, 153 | 'expire': '1day', 154 | 'burn': False, 155 | 'discus': False, 156 | 'format': None, 157 | 'short': False, 158 | 'short_api': None, 159 | 'short_url': None, 160 | 'short_user': None, 161 | 'short_pass': None, 162 | 'short_token': None, 163 | 'output': None, 164 | 'no_check_certificate': False, 165 | 'no_insecure_warning': False, 166 | 'compression': None, 167 | 'auth': None, 168 | 'auth_user': None, 169 | 'auth_pass': None, 170 | 'auth_custom': None, 171 | 'json': False 172 | } 173 | 174 | # Configuration preference order: 175 | # 1. Command line switches 176 | # 2. Environment variables 177 | # 3. Configuration file 178 | # 4. Defaults above 179 | 180 | for p in CONFIG_PATHS: 181 | if os.path.exists(p): 182 | fileconfig = read_config(p) 183 | if args.debug: print("Configuration readed from file:\n{}".format(fileconfig)) 184 | CONFIG.update(fileconfig) 185 | break 186 | 187 | for key in CONFIG.keys(): 188 | var = "PRIVATEBIN_{}".format(key.upper()) 189 | if var in os.environ: CONFIG[key] = os.getenv(var) 190 | # values from command line switches are preferred 191 | args_var = vars(args) 192 | if key in args_var: 193 | CONFIG[key] = args_var[key] 194 | 195 | # Set server and re-validate instance URL 196 | if CONFIG['random_server']: 197 | import secrets 198 | url_arr = CONFIG['random_server'].split(',') 199 | CONFIG['server'] = validate_url_ending(secrets.choice(url_arr)) 200 | else: 201 | CONFIG['server'] = validate_url_ending(CONFIG['server']) 202 | 203 | if args.debug: 204 | print("Whole configuration:\n{}\n".format(CONFIG)) 205 | args.verbose = True 206 | api_client = PrivateBin(CONFIG) 207 | 208 | if hasattr(args, "func"): 209 | try: 210 | args.func(args, api_client, settings=CONFIG) 211 | except PBinCLIException as pe: 212 | raise PBinCLIException("error: {}".format(pe)) 213 | else: 214 | parser.print_help() 215 | 216 | 217 | if __name__ == "__main__": 218 | main() 219 | -------------------------------------------------------------------------------- /pbincli/api.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from requests import HTTPError 3 | from pbincli.utils import PBinCLIError 4 | 5 | 6 | def _config_requests(settings=None, shortener=False): 7 | settings = settings or {} 8 | if settings.get("no_insecure_warning"): 9 | from requests.packages.urllib3.exceptions import InsecureRequestWarning 10 | 11 | requests.packages.urllib3.disable_warnings(InsecureRequestWarning) 12 | 13 | session = requests.Session() 14 | session.verify = not settings.get("no_check_certificate") 15 | 16 | if ( 17 | settings.get("auth") and not shortener 18 | ): # do not leak PrivateBin authorization to shortener services 19 | auth = settings["auth"] 20 | if auth == "basic" and all( 21 | [settings.get("auth_user"), settings.get("auth_pass")] 22 | ): 23 | session.auth = (settings["auth_user"], settings["auth_pass"]) 24 | elif auth == "custom" and settings.get("auth_custom"): 25 | from json import loads as json_loads 26 | 27 | auth = json_loads(settings["auth_custom"]) 28 | session.headers.update(auth) 29 | else: 30 | PBinCLIError("Incorrect authorization configuration") 31 | 32 | if settings.get("proxy"): 33 | scheme = settings["proxy"].split("://")[0] 34 | if scheme.startswith("socks"): 35 | session.proxies.update( 36 | {"http": settings["proxy"], "https": settings["proxy"]} 37 | ) 38 | else: 39 | session.proxies.update({scheme: settings["proxy"]}) 40 | 41 | return session 42 | 43 | 44 | class PrivateBin: 45 | def __init__(self, settings=None): 46 | settings = settings or {} 47 | if not settings.get("server"): 48 | PBinCLIError("No server specified - unable to continue") 49 | 50 | self.server = settings["server"] 51 | self.headers = {"X-Requested-With": "JSONHttpRequest"} 52 | 53 | self.session = _config_requests(settings, False) 54 | 55 | def post(self, request): 56 | result = self.session.post(url=self.server, headers=self.headers, data=request) 57 | 58 | try: 59 | return result.json() 60 | except ValueError: 61 | PBinCLIError( 62 | "Unable parse response as json. Received (size = {}):\n{}".format( 63 | len(result.text), result.text 64 | ) 65 | ) 66 | 67 | def get(self, request): 68 | return self.session.get( 69 | url=self.server + "?" + request, headers=self.headers 70 | ).json() 71 | 72 | def delete(self, request): 73 | # using try as workaround for versions < 1.3 due to we cant detect 74 | # if server used version 1.2, where auto-deletion is added 75 | try: 76 | result = self.session.post( 77 | url=self.server, headers=self.headers, data=request 78 | ).json() 79 | except ValueError: 80 | # unable parse response as json because it can be empty (1.2), so simulate correct answer 81 | print( 82 | "NOTICE: Received empty response. We interpret that as our paste has already been deleted." 83 | ) 84 | from json import loads as json_loads 85 | 86 | result = json_loads('{"status":0}') 87 | 88 | if not result["status"]: 89 | print("Paste successfully deleted!") 90 | elif result["status"]: 91 | PBinCLIError( 92 | "Something went wrong...\nError:\t\t{}".format(result["message"]) 93 | ) 94 | else: 95 | PBinCLIError("Something went wrong...\nError: Empty response.") 96 | 97 | def getVersion(self): 98 | result = self.session.get( 99 | url=self.server + "?jsonld=paste", headers=self.headers 100 | ) 101 | try: 102 | jsonldSchema = result.json() 103 | return ( 104 | jsonldSchema["@context"]["v"]["@value"] 105 | if ( 106 | "@context" in jsonldSchema 107 | and "v" in jsonldSchema["@context"] 108 | and "@value" in jsonldSchema["@context"]["v"] 109 | ) 110 | else 1 111 | ) 112 | except ValueError: 113 | PBinCLIError( 114 | "Unable parse response as json. Received (size = {}):\n{}".format( 115 | len(result.text), result.text 116 | ) 117 | ) 118 | 119 | def getServer(self): 120 | return self.server 121 | 122 | 123 | class Shortener: 124 | """Some parts of this class was taken from 125 | python-yourls (https://github.com/tflink/python-yourls/) library 126 | """ 127 | 128 | def __init__(self, settings=None): 129 | settings = settings or {} 130 | self.api = settings.get("short_api") 131 | if self.api is None: 132 | PBinCLIError("Unable to activate link shortener without short_api.") 133 | 134 | # we checking which service is used, because some services doesn't require 135 | # any authentication, or have only one domain on which it working 136 | if self.api == "yourls": 137 | self._yourls_init(settings) 138 | elif self.api == "isgd" or self.api == "vgd": 139 | self._gd_init() 140 | elif self.api == "custom": 141 | self.apiurl = settings.get("short_url") 142 | if not self.apiurl: 143 | PBinCLIError("short_url is required for custom shortener") 144 | 145 | self.session = _config_requests(settings, True) 146 | 147 | def _yourls_init(self, settings): 148 | apiurl = settings["short_url"] 149 | if not apiurl: 150 | PBinCLIError("YOURLS: An API URL is required") 151 | 152 | # setting API URL 153 | if apiurl.endswith("/yourls-api.php"): 154 | self.apiurl = apiurl 155 | elif apiurl.endswith("/"): 156 | self.apiurl = apiurl + "yourls-api.php" 157 | else: 158 | PBinCLIError( 159 | "YOURLS: Incorrect URL is provided.\n" 160 | + "It must contain full address to 'yourls-api.php' script (like https://example.com/yourls-api.php)\n" 161 | + "or just contain instance URL with '/' at the end (like https://example.com/)" 162 | ) 163 | 164 | # validating for required credentials 165 | if ( 166 | settings.get("short_user") 167 | and settings.get("short_pass") 168 | and settings.get("short_token") is None 169 | ): 170 | self.auth_args = { 171 | "username": settings["short_user"], 172 | "password": settings["short_pass"], 173 | } 174 | elif ( 175 | settings.get("short_user") is None 176 | and settings.get("short_pass") is None 177 | and settings.get("short_token") 178 | ): 179 | self.auth_args = {"signature": settings["short_token"]} 180 | elif ( 181 | settings.get("short_user") is None 182 | and settings.get("short_pass") is None 183 | and settings.get("short_token") is None 184 | ): 185 | self.auth_args = {} 186 | else: 187 | PBinCLIError( 188 | "YOURLS: either username and password or token are required. Otherwise set to default (None)" 189 | ) 190 | 191 | def _gd_init(self): 192 | if self.api == "isgd": 193 | self.apiurl = "https://is.gd/" 194 | else: 195 | self.apiurl = "https://v.gd/" 196 | self.useragent = ( 197 | "Mozilla/5.0 (compatible; pbincli - https://github.com/r4sas/pbincli/)" 198 | ) 199 | 200 | def getlink(self, url): 201 | # that is api -> function mapper for running service-related function when getlink() used 202 | servicesList = { 203 | "yourls": self._yourls, 204 | "clckru": self._clckru, 205 | "tinyurl": self._tinyurl, 206 | "isgd": self._gd, 207 | "vgd": self._gd, 208 | "cuttly": self._cuttly, 209 | "custom": self._custom, 210 | } 211 | # run function selected by choosen API 212 | return servicesList[self.api](url) 213 | 214 | def _yourls(self, url): 215 | request = {"action": "shorturl", "format": "json", "url": url} 216 | request.update(self.auth_args) 217 | 218 | result = self.session.post(url=self.apiurl, data=request) 219 | 220 | try: 221 | result.raise_for_status() 222 | except HTTPError: 223 | try: 224 | response = result.json() 225 | except ValueError: 226 | PBinCLIError( 227 | "YOURLS: Unable parse response. Received (size = {}):\n{}".format( 228 | len(result.text), result.text 229 | ) 230 | ) 231 | else: 232 | PBinCLIError( 233 | "YOURLS: Received error from API: {} with JSON {}".format( 234 | result, response 235 | ) 236 | ) 237 | else: 238 | response = result.json() 239 | 240 | if {"status", "statusCode", "message"} <= set(response.keys()): 241 | if response["status"] == "fail": 242 | PBinCLIError( 243 | "YOURLS: Received error from API: {}".format( 244 | response["message"] 245 | ) 246 | ) 247 | if not "shorturl" in response: 248 | PBinCLIError( 249 | "YOURLS: Unknown error: {}".format(response["message"]) 250 | ) 251 | else: 252 | return response["shorturl"] 253 | else: 254 | PBinCLIError( 255 | "YOURLS: No status, statusCode or message fields in response! Received:\n{}".format( 256 | response 257 | ) 258 | ) 259 | 260 | def _clckru(self, url): 261 | request = {"url": url} 262 | 263 | try: 264 | result = self.session.post(url="https://clck.ru/--", data=request) 265 | return result.text 266 | except Exception as ex: 267 | PBinCLIError("clck.ru: unexcepted behavior: {}".format(ex)) 268 | 269 | def _tinyurl(self, url): 270 | request = {"url": url} 271 | 272 | try: 273 | result = self.session.post( 274 | url="https://tinyurl.com/api-create.php", data=request 275 | ) 276 | return result.text 277 | except Exception as ex: 278 | PBinCLIError("TinyURL: unexcepted behavior: {}".format(ex)) 279 | 280 | def _gd(self, url): 281 | request = { 282 | "format": "json", 283 | "url": url, 284 | "logstats": 0, # we don't want use any statistics 285 | } 286 | headers = {"User-Agent": self.useragent} 287 | 288 | try: 289 | result = self.session.post( 290 | url=self.apiurl + "create.php", headers=headers, data=request 291 | ) 292 | 293 | response = result.json() 294 | 295 | if "shorturl" in response: 296 | return response["shorturl"] 297 | else: 298 | PBinCLIError( 299 | "{}: got error {} from API: {}".format( 300 | "is.gd" if self.api == "isgd" else "v.gd", 301 | response["errorcode"], 302 | response["errormessage"], 303 | ) 304 | ) 305 | 306 | except Exception as ex: 307 | PBinCLIError( 308 | "{}: unexcepted behavior: {}".format( 309 | "is.gd" if self.api == "isgd" else "v.gd", ex 310 | ) 311 | ) 312 | 313 | def _cuttly(self, url): 314 | request = {"url": url, "domain": 0} 315 | 316 | try: 317 | result = self.session.post( 318 | url="https://cutt.ly/scripts/shortenUrl.php", data=request 319 | ) 320 | return result.text 321 | except Exception as ex: 322 | PBinCLIError("cutt.ly: unexcepted behavior: {}".format(ex)) 323 | 324 | def _custom(self, url): 325 | if self.apiurl is None: 326 | PBinCLIError("No short_url specified - link will not be shortened.") 327 | 328 | from urllib.parse import quote 329 | 330 | qUrl = quote(url, safe="") # urlencoded paste url 331 | rUrl = self.apiurl.replace("{{url}}", qUrl) 332 | 333 | try: 334 | result = self.session.get(url=rUrl) 335 | return result.text 336 | except Exception as ex: 337 | PBinCLIError("Shorter: unexcepted behavior: {}".format(ex)) 338 | -------------------------------------------------------------------------------- /pbincli/format.py: -------------------------------------------------------------------------------- 1 | from base64 import b64encode, b64decode 2 | from pbincli.utils import PBinCLIError 3 | import zlib 4 | 5 | # try import AES cipher and check if it has GCM mode (prevent usage of pycrypto) 6 | try: 7 | from Crypto.Cipher import AES 8 | if not hasattr(AES, 'MODE_GCM'): 9 | try: 10 | from Cryptodome.Cipher import AES 11 | from Cryptodome.Random import get_random_bytes 12 | except ImportError: 13 | PBinCLIError("AES GCM mode is not found in imported crypto module.\n" + 14 | "That can happen if you have installed pycrypto.\n\n" + 15 | "We tried to import pycryptodomex but it is not available.\n" + 16 | "Please install it via pip, if you still need pycrypto, by running:\n" + 17 | "\tpip install pycryptodomex\n" + 18 | "... otherwise use separate python environment or uninstall pycrypto:\n" + 19 | "\tpip uninstall pycrypto") 20 | else: 21 | from Crypto.Random import get_random_bytes 22 | except ImportError: 23 | try: 24 | from Cryptodome.Cipher import AES 25 | from Cryptodome.Random import get_random_bytes 26 | except ImportError: 27 | PBinCLIError("Unable import pycryptodome") 28 | 29 | 30 | CIPHER_ITERATION_COUNT = 100000 31 | CIPHER_SALT_BYTES = 8 32 | CIPHER_BLOCK_BITS = 256 33 | CIPHER_TAG_BITS = 128 34 | 35 | 36 | class Paste: 37 | def __init__(self, debug=False): 38 | self._version = 2 39 | self._compression = 'zlib' 40 | self._data = '' 41 | self._text = '' 42 | self._attachment = '' 43 | self._attachment_name = '' 44 | self._password = '' 45 | self._debug = debug 46 | self._iteration_count = CIPHER_ITERATION_COUNT 47 | self._salt_bytes = CIPHER_SALT_BYTES 48 | self._block_bits = CIPHER_BLOCK_BITS 49 | self._tag_bits = CIPHER_TAG_BITS 50 | self._key = get_random_bytes(int(self._block_bits / 8)) 51 | 52 | 53 | def setVersion(self, version): 54 | if self._debug: print("Set paste version to {}".format(version)) 55 | self._version = version 56 | 57 | 58 | def setPassword(self, password): 59 | self._password = password 60 | 61 | 62 | def setText(self, text): 63 | self._text = text 64 | 65 | 66 | def setAttachment(self, path): 67 | from pbincli.utils import check_readable, path_leaf 68 | from mimetypes import guess_type 69 | 70 | check_readable(path) 71 | with open(path, 'rb') as f: 72 | contents = f.read() 73 | f.close() 74 | mime = guess_type(path, strict=False)[0] 75 | 76 | # MIME fallback 77 | if not mime: mime = 'application/octet-stream' 78 | 79 | if self._debug: print("Filename:\t{}\nMIME-type:\t{}".format(path_leaf(path), mime)) 80 | 81 | self._attachment = 'data:' + mime + ';base64,' + b64encode(contents).decode() 82 | self._attachment_name = path_leaf(path) 83 | 84 | def setCompression(self, comp): 85 | self._compression = comp 86 | 87 | 88 | def getText(self): 89 | return self._text 90 | 91 | 92 | def getAttachment(self): 93 | return [b64decode(self._attachment.split(',', 1)[1]), self._attachment_name] \ 94 | if self._attachment \ 95 | else [False,False] 96 | 97 | 98 | def getJSON(self): 99 | if self._version == 2: 100 | from pbincli.utils import json_encode 101 | return json_encode(self._data).decode() 102 | else: 103 | return self._data 104 | 105 | 106 | def loadJSON(self, data): 107 | self._data = data 108 | 109 | 110 | def getHash(self): 111 | if self._version == 2: 112 | from base58 import b58encode 113 | return b58encode(self._key).decode() 114 | else: 115 | return b64encode(self._key).decode() 116 | 117 | 118 | def setHash(self, passphrase): 119 | if self._version == 2: 120 | from base58 import b58decode 121 | self._key = b58decode(passphrase) 122 | else: 123 | if (len(passphrase) % 4 != 0): 124 | PBinCLIError("Incorrect passphrase! Maybe you have stripped trailing \"=\"?") 125 | self._key = b64decode(passphrase) 126 | 127 | 128 | def __deriveKey(self, salt): 129 | try: 130 | from Crypto.Protocol.KDF import PBKDF2 131 | from Crypto.Hash import HMAC, SHA256 132 | except ModuleNotFoundError: 133 | try: 134 | from Cryptodome.Protocol.KDF import PBKDF2 135 | from Cryptodome.Hash import HMAC, SHA256 136 | except ImportError: 137 | PBinCLIError("Unable import pycryptodome") 138 | 139 | # Key derivation, using PBKDF2 and SHA256 HMAC 140 | return PBKDF2( 141 | self._key + self._password.encode(), 142 | salt, 143 | dkLen = int(self._block_bits / 8), 144 | count = self._iteration_count, 145 | prf = lambda password, salt: HMAC.new( 146 | password, 147 | salt, 148 | SHA256 149 | ).digest()) 150 | 151 | 152 | @classmethod 153 | def __initializeCipher(self, key, iv, adata, tagsize): 154 | from pbincli.utils import json_encode 155 | 156 | cipher = AES.new(key, AES.MODE_GCM, nonce=iv, mac_len=tagsize) 157 | cipher.update(json_encode(adata)) 158 | return cipher 159 | 160 | 161 | def __preparePassKey(self): 162 | from hashlib import sha256 163 | 164 | if self._password: 165 | digest = sha256(self._password.encode("UTF-8")).hexdigest() 166 | return b64encode(self._key) + digest.encode("UTF-8") 167 | else: 168 | return b64encode(self._key) 169 | 170 | 171 | def __decompress(self, s): 172 | if self._version == 2 and self._compression == 'zlib': 173 | # decompress data 174 | return zlib.decompress(s, -zlib.MAX_WBITS) 175 | elif self._version == 2 and self._compression == 'none': 176 | # nothing to do, just return original data 177 | return s 178 | elif self._version == 1: 179 | return zlib.decompress(bytearray(map(lambda c:ord(c)&255, b64decode(s.encode('utf-8')).decode('utf-8'))), -zlib.MAX_WBITS) 180 | else: 181 | PBinCLIError('Unknown compression type provided in paste!') 182 | 183 | 184 | def __compress(self, s): 185 | if self._version == 2 and self._compression == 'zlib': 186 | # using compressobj as compress doesn't let us specify wbits 187 | # needed to get the raw stream without headers 188 | co = zlib.compressobj(wbits=-zlib.MAX_WBITS) 189 | return co.compress(s) + co.flush() 190 | elif self._version == 2 and self._compression == 'none': 191 | # nothing to do, just return original data 192 | return s 193 | elif self._version == 1: 194 | co = zlib.compressobj(wbits=-zlib.MAX_WBITS) 195 | b = co.compress(s) + co.flush() 196 | return b64encode(''.join(map(chr, b)).encode('utf-8')) 197 | else: 198 | PBinCLIError('Unknown compression type provided!') 199 | 200 | 201 | def decrypt(self): 202 | # that is wrapper which running needed function regrading to paste version 203 | if self._version == 2: self._decryptV2() 204 | else: self._decryptV1() 205 | 206 | 207 | def _decryptV2(self): 208 | from json import loads as json_decode 209 | iv = b64decode(self._data['adata'][0][0]) 210 | salt = b64decode(self._data['adata'][0][1]) 211 | 212 | self._iteration_count = self._data['adata'][0][2] 213 | self._block_bits = self._data['adata'][0][3] 214 | self._tag_bits = self._data['adata'][0][4] 215 | cipher_tag_bytes = int(self._tag_bits / 8) 216 | 217 | key = self.__deriveKey(salt) 218 | 219 | # Get compression type from received paste 220 | self._compression = self._data['adata'][0][7] 221 | 222 | cipher = self.__initializeCipher(key, iv, self._data['adata'], cipher_tag_bytes) 223 | # Cut the cipher text into message and tag 224 | cipher_text_tag = b64decode(self._data['ct']) 225 | cipher_text = cipher_text_tag[:-cipher_tag_bytes] 226 | cipher_tag = cipher_text_tag[-cipher_tag_bytes:] 227 | cipher_message = json_decode(self.__decompress(cipher.decrypt_and_verify(cipher_text, cipher_tag)).decode()) 228 | 229 | self._text = cipher_message['paste'].encode() 230 | 231 | if 'attachment' in cipher_message and 'attachment_name' in cipher_message: 232 | self._attachment = cipher_message['attachment'] 233 | self._attachment_name = cipher_message['attachment_name'] 234 | 235 | 236 | def _decryptV1(self): 237 | from sjcl import SJCL 238 | from json import loads as json_decode 239 | 240 | password = self.__preparePassKey() 241 | cipher_text = json_decode(self._data['data']) 242 | if self._debug: print("Text:\t{}\n".format(cipher_text)) 243 | 244 | text = SJCL().decrypt(cipher_text, password) 245 | 246 | if len(text): 247 | if self._debug: print("Decoded Text:\t{}\n".format(text)) 248 | self._text = self.__decompress(text.decode()) 249 | 250 | if 'attachment' in self._data and 'attachmentname' in self._data: 251 | cipherfile = json_decode(self._data['attachment']) 252 | cipherfilename = json_decode(self._data['attachmentname']) 253 | 254 | if self._debug: print("Name:\t{}\nData:\t{}".format(cipherfilename, cipherfile)) 255 | 256 | attachment = SJCL().decrypt(cipherfile, password) 257 | attachmentname = SJCL().decrypt(cipherfilename, password) 258 | 259 | self._attachment = self.__decompress(attachment.decode('utf-8')).decode('utf-8') 260 | self._attachment_name = self.__decompress(attachmentname.decode('utf-8')).decode('utf-8') 261 | 262 | 263 | def encrypt(self, formatter, burnafterreading, discussion, expiration): 264 | # that is wrapper which running needed function regrading to paste version 265 | self._formatter = formatter 266 | self._burnafterreading = burnafterreading 267 | self._discussion = discussion 268 | self._expiration = expiration 269 | 270 | if self._debug: print("[Enc] Starting encyptor…") 271 | if self._version == 2: self._encryptV2() 272 | else: self._encryptV1() 273 | 274 | 275 | def _encryptV2(self): 276 | from pbincli.utils import json_encode 277 | 278 | if self._debug: print("[Enc] Preparing IV, Salt…") 279 | iv = get_random_bytes(int(self._tag_bits / 8)) 280 | salt = get_random_bytes(self._salt_bytes) 281 | if self._debug: print("[Enc] Deriving Key…") 282 | key = self.__deriveKey(salt) 283 | 284 | if self._debug: print("[Enc] Preparing aData and message…") 285 | # prepare encryption authenticated data and message 286 | adata = [ 287 | [ 288 | b64encode(iv).decode(), 289 | b64encode(salt).decode(), 290 | self._iteration_count, 291 | self._block_bits, 292 | self._tag_bits, 293 | 'aes', 294 | 'gcm', 295 | self._compression 296 | ], 297 | self._formatter, 298 | int(self._discussion), 299 | int(self._burnafterreading) 300 | ] 301 | cipher_message = {'paste':self._text} 302 | if self._attachment: 303 | cipher_message['attachment'] = self._attachment 304 | cipher_message['attachment_name'] = self._attachment_name 305 | 306 | if self._debug: print("[Enc] Encrypting message…") 307 | cipher = self.__initializeCipher(key, iv, adata, int(self._tag_bits /8 )) 308 | ciphertext, tag = cipher.encrypt_and_digest(self.__compress(json_encode(cipher_message))) 309 | 310 | if self._debug: print("PBKDF2 Key:\t{}\nCipherText:\t{}\nCipherTag:\t{}" 311 | .format(b64encode(key), b64encode(ciphertext), b64encode(tag))) 312 | 313 | self._data = {'v':2,'adata':adata,'ct':b64encode(ciphertext + tag).decode(),'meta':{'expire':self._expiration}} 314 | 315 | 316 | def _encryptV1(self): 317 | from sjcl import SJCL 318 | from pbincli.utils import json_encode 319 | 320 | self._data = {'expire':self._expiration,'formatter':self._formatter, 321 | 'burnafterreading':int(self._burnafterreading),'opendiscussion':int(self._discussion)} 322 | 323 | password = self.__preparePassKey() 324 | if self._debug: print("Password:\t{}".format(password)) 325 | 326 | # Encrypting text 327 | cipher = SJCL().encrypt(self.__compress(self._text.encode('utf-8')), password, mode='gcm') 328 | for k in ['salt', 'iv', 'ct']: cipher[k] = cipher[k].decode() 329 | 330 | self._data['data'] = json_encode(cipher) 331 | 332 | if self._attachment: 333 | cipherfile = SJCL().encrypt(self.__compress(self._attachment.encode('utf-8')), password, mode='gcm') 334 | for k in ['salt', 'iv', 'ct']: cipherfile[k] = cipherfile[k].decode() 335 | 336 | cipherfilename = SJCL().encrypt(self.__compress(self._attachment_name.encode('utf-8')), password, mode='gcm') 337 | for k in ['salt', 'iv', 'ct']: cipherfilename[k] = cipherfilename[k].decode() 338 | 339 | self._data['attachment'] = json_encode(cipherfile) 340 | self._data['attachmentname'] = json_encode(cipherfilename) 341 | --------------------------------------------------------------------------------