├── releases ├── zkm-0.1.tar.gz └── zkm-0.2.tar.gz ├── cleanup.py ├── .gitignore ├── LICENSE ├── server.py ├── db.py ├── README.md └── client.py /releases/zkm-0.1.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/averagesecurityguy/zkm/HEAD/releases/zkm-0.1.tar.gz -------------------------------------------------------------------------------- /releases/zkm-0.2.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/averagesecurityguy/zkm/HEAD/releases/zkm-0.2.tar.gz -------------------------------------------------------------------------------- /cleanup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Copyright 2015 LCI Technology Group, LLC 5 | # All rights reserved 6 | 7 | # This script should be run by cron and will delete old messages until there 8 | # are no more than MAX_KEEP messages in the database. The MAX_KEEP value can 9 | # be set in the db.py script. 10 | import logging 11 | import db 12 | 13 | logging.basicConfig(level=logging.WARN) 14 | 15 | try: 16 | zdb = db.ZKMDatabase() 17 | channels = zdb.get_channels() 18 | for channel in channels: 19 | zdb.cleanup_messages(channel[0]) 20 | 21 | except db.DatabaseException(): 22 | pass 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | # PyInstaller 27 | # Usually these files are written by a python script from a template 28 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 29 | *.manifest 30 | *.spec 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .coverage 40 | .coverage.* 41 | .cache 42 | nosetests.xml 43 | coverage.xml 44 | *,cover 45 | 46 | # Translations 47 | *.mo 48 | *.pot 49 | 50 | # Django stuff: 51 | *.log 52 | 53 | # Sphinx documentation 54 | docs/_build/ 55 | 56 | # PyBuilder 57 | target/ 58 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2015, LCI Technology Group, LLC 2 | # All rights reserved. 3 | # 4 | # Redistribution and use in source and binary forms, with or without 5 | # modification, are permitted provided that the following conditions are met: 6 | # 7 | # Redistributions of source code must retain the above copyright notice, 8 | # this list of conditions and the following disclaimer. 9 | # 10 | # Redistributions in binary form must reproduce the above copyright notice, 11 | # this list of conditions and the following disclaimer in the documentation 12 | # and/or other materials provided with the distribution. 13 | # 14 | # Neither the name of LCI Technology Group, LLC nor the names of its 15 | # contributors may be used to endorse or promote products derived from this 16 | # software without specific prior written permission. 17 | # 18 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 21 | # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 22 | # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 23 | # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 24 | # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 25 | # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 26 | # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 27 | # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 28 | # POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Copyright 2015 LCI Technology Group, LLC 5 | # All rights reserved 6 | 7 | import flask 8 | import logging 9 | 10 | import db 11 | 12 | #----------------------------------------------------------------------------- 13 | # WEB SERVER 14 | #----------------------------------------------------------------------------- 15 | # Configure logging 16 | logging.basicConfig(filename='server.log', level=logging.DEBUG) 17 | 18 | app = flask.Flask(__name__) 19 | 20 | 21 | def response(error, response): 22 | """ 23 | Generate a JSON response object. 24 | """ 25 | return flask.jsonify({'error': error, 'response': response}) 26 | 27 | 28 | # Every user gets every message. Not all messages can be decrypted by every 29 | # user. 30 | @app.route('/messages//') 31 | def get_messages(channel=None, since=None): 32 | """ 33 | Get all of the message published on or after the since value. 34 | """ 35 | if (channel is None) or (since is None): 36 | return response("Must specify a channel and since value", None) 37 | 38 | try: 39 | zdb = db.ZKMDatabase() 40 | msgs = zdb.get_messages(channel, since) 41 | return response(None, msgs) 42 | 43 | except db.DatabaseException() as e: 44 | return response(e, None) 45 | 46 | 47 | # Anyone can create a message. 48 | @app.route('/message/', methods=['POST']) 49 | def create_message(channel): 50 | """ 51 | Create a new message. 52 | """ 53 | try: 54 | zdb = db.ZKMDatabase() 55 | msg = flask.request.form['message'] 56 | zdb.create_message(channel, msg) 57 | return response(None, 'Success') 58 | 59 | except db.DatabaseException() as e: 60 | return response(e, None) 61 | 62 | 63 | if __name__ == '__main__': 64 | app.run(host='0.0.0.0') 65 | -------------------------------------------------------------------------------- /db.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright 2015 LCI Technology Group, LLC 4 | # All rights reserved 5 | import sqlite3 6 | import logging 7 | 8 | MAX_RETURN = 200 9 | MAX_KEEP = 2000 10 | 11 | 12 | # Create an exception class. 13 | class DatabaseException(Exception): 14 | pass 15 | 16 | 17 | class ZKMDatabase(): 18 | def __init__(self): 19 | self.conn = sqlite3.connect('zkm.sqlite') 20 | self.cur = self.conn.cursor() 21 | self.cur.execute("CREATE TABLE IF NOT EXISTS messages (id integer primary key autoincrement not null , channel text, message text)") 22 | self.log = logging.getLogger('DB') 23 | 24 | def _lastrowid(self): 25 | """ 26 | Get the last row id from the messages table. 27 | """ 28 | self.cur.execute('SELECT max(id) from messages') 29 | lastrowid = self.cur.fetchone() 30 | return lastrowid[0] 31 | 32 | def get_messages(self, channel, since): 33 | """ 34 | Get a list of messages whose id is greater than or equal to since. 35 | 36 | Log an error message and re raise it if there is a failure. 37 | """ 38 | # Guarantee we do not return more than MAXMSGS for performance sake. 39 | if int(since) < self._lastrowid() - MAX_RETURN: 40 | since = self._lastrowid() - MAX_RETURN 41 | 42 | try: 43 | self.log.debug('Getting messages since {0}.'.format(since)) 44 | self.cur.execute('SELECT id, message FROM messages WHERE channel=? AND id>=?', (channel, since)) 45 | return self.cur.fetchall() 46 | 47 | except Exception as e: 48 | self.log.error('{0}'.format(e)) 49 | raise DatabaseException('Could not get messages.') 50 | 51 | def get_channels(self): 52 | """ 53 | Get a list of all channels on the server. 54 | 55 | Log an error message and re raise it if there is a failure. 56 | """ 57 | try: 58 | self.log.debug('Getting all channels.') 59 | self.cur.execute('SELECT DISTINCT (channel) FROM messages') 60 | return self.cur.fetchall() 61 | 62 | except Exception as e: 63 | self.log.error('{0}'.format(e)) 64 | raise DatabaseException('Could not get channels.') 65 | 66 | def create_message(self, channel, msg): 67 | """ 68 | Add message to the database. 69 | 70 | Add a new message to the database. Return True if successful and False if 71 | not. 72 | """ 73 | try: 74 | self.log.debug('Creating new message {0} in channel {1}.'.format(msg, channel)) 75 | self.cur.execute('INSERT INTO messages VALUES (?, ?, ?)', (None, channel, msg)) 76 | self.conn.commit() 77 | 78 | except Exception as e: 79 | self.log.error('{0}'.format(e)) 80 | raise DatabaseException('Could not create a new message.') 81 | 82 | def cleanup_messages(self, channel): 83 | """ 84 | Keep no more than MAX_KEEP messages. 85 | """ 86 | try: 87 | discard = self._lastrowid() - MAX_KEEP 88 | 89 | self.log.debug('Cleaning up messages in channel {0}.'.format(channel)) 90 | self.cur.execute('DELETE FROM messages WHERE channel=? AND id` prompt. 86 | The first thing you need to do is run the connect command and provide the full 87 | URL for the ZKM server with which you want to communicate. The interactive ZKM 88 | shell supports the following commands: 89 | 90 | ### Commands 91 | 92 | `connect server_url` - Add the ZKM server to the configuration file. 93 | `add_contact name base64_encoded_public_key` - Associate a public key with a username. 94 | `del_contact name` - Delete a contact by name. 95 | `show_contacts` - List all contacts. 96 | `show_config` - Show the current configuration. 97 | `create_message username message` - Create a new message for the specified username. If the username is not listed in the contacts an error will occur. 98 | `read_messages` - Read all messages since last read. Will read 200 messages max. 99 | `quit/exit/ctrl-d` - Quit the application 100 | 101 | 102 | Can I Use This Commercially? 103 | ---------------------------- 104 | ZKM was designed to minimize the amount of metadata stored on the server, 105 | which requires the end user to manage their own contact list and end users who 106 | want to communicate must manually exchange public keys. There is not public 107 | key lookup service built in. These design decisions make it suited for small 108 | groups of peoplel who want to communicate securely and anonymously. They also 109 | make it difficult to scale ZKM to a large audience, which would be needed to 110 | make it commercially viable. 111 | 112 | With all that said, yes, the license allows you to make a commercial product 113 | using ZKM and if you are able to solve the scale problem, I would appreciate 114 | knowing how you did it. -------------------------------------------------------------------------------- /client.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Copyright 2015 LCI Technology Group, LLC 5 | # All rights reserved 6 | 7 | import requests 8 | import pysodium 9 | import os 10 | import base64 11 | import cmd 12 | 13 | 14 | # CONSTANTS 15 | HOMEDIR = os.path.expanduser("~") 16 | ZKMDIR = os.path.join(HOMEDIR, '.zkm') 17 | CONFIG = os.path.join(ZKMDIR, 'config') 18 | CONTACT = os.path.join(ZKMDIR, 'contacts') 19 | 20 | 21 | def load_data(filename): 22 | """ 23 | Load a key:value file into a dictionary and return it. 24 | """ 25 | data = {} 26 | with open(filename, 'rb') as f: 27 | for line in f: 28 | line = line.rstrip(b'\n') 29 | k, v = line.split(b'|') 30 | data[k] = v 31 | 32 | return data 33 | 34 | 35 | def save_data(filename, data): 36 | """ 37 | Save the given data to a file in key:value format. 38 | """ 39 | with open(filename, 'wb') as f: 40 | for k, v in data.items(): 41 | f.write(b'|'.join([k, v])) 42 | f.write(b'\n') 43 | 44 | 45 | def encrypt(ssk, spk, rpk, msg): 46 | """ 47 | Encrypt a message using the provided information. 48 | """ 49 | ssk = base64.b64decode(ssk) 50 | rpk = base64.b64decode(rpk) 51 | nonce = pysodium.randombytes(pysodium.crypto_box_NONCEBYTES) 52 | 53 | enc = pysodium.crypto_box_easy(msg, nonce, rpk, ssk) 54 | 55 | nonce = base64.b64encode(nonce) 56 | enc = base64.b64encode(enc) 57 | 58 | # Return sender's public_key, nonce, and the encrypted message 59 | return b':'.join([spk, nonce, enc]) 60 | 61 | 62 | def decrypt(rsk, msg): 63 | """ 64 | Decrypt a message using the provided information. 65 | """ 66 | spk, nonce, enc_msg = msg.split(b':') 67 | 68 | spk = base64.b64decode(spk) 69 | rsk = base64.b64decode(rsk) 70 | nonce = base64.b64decode(nonce) 71 | enc_msg = base64.b64decode(enc_msg) 72 | 73 | # A ValueError is raised when decryption fails. Need to cactch it. 74 | try: 75 | dec_msg = pysodium.crypto_box_open_easy(enc_msg, nonce, spk, rsk) 76 | except ValueError: 77 | dec_msg = '' 78 | 79 | # Return the sender's public key and the decrypted message. 80 | return base64.b64encode(spk), dec_msg 81 | 82 | 83 | def print_msg(contacts, their_public, msg): 84 | """ 85 | Print the sender and message. 86 | 87 | Lookup the public key of the sender to see if they are in our 88 | contacts. If they are print the username, if not print the public key. 89 | """ 90 | for username, contact_public in contacts.items(): 91 | if their_public == contact_public: 92 | sender = username 93 | else: 94 | sender = their_public 95 | 96 | print('{0}'.format(sender.decode())) 97 | print('-' * len(sender)) 98 | print(msg) 99 | 100 | 101 | def send(server, method, endpoint, data=None): 102 | """ 103 | Send a message to the server and process the response. 104 | """ 105 | url = '{0}{1}'.format(server.decode(), endpoint) 106 | resp = None 107 | 108 | if method == 'POST': 109 | resp = requests.post(url, data=data) 110 | else: 111 | resp = requests.get(url, params=data) 112 | 113 | if resp.status_code == 200: 114 | j = resp.json() 115 | if j['error'] is not None: 116 | print('[-] {0}'.format(j['error'])) 117 | return None 118 | else: 119 | return j['response'] 120 | else: 121 | print('[-] Server error: {0}'.format(resp.status_code)) 122 | 123 | 124 | def initialize(): 125 | """ 126 | Create a ~/.zkm directory with a config file inside. 127 | 128 | The config file will hold our public key, secret key, and since value. 129 | """ 130 | if os.path.exists(ZKMDIR) is False: 131 | print('[+] Creating ZKM configuration directory.') 132 | os.mkdir(ZKMDIR, 0o750) 133 | 134 | print('[+] Creating new keypair.') 135 | our_public, our_secret = pysodium.crypto_box_keypair() 136 | 137 | print('[+] Creating configuration file.') 138 | config = {b'public': base64.b64encode(our_public), 139 | b'secret': base64.b64encode(our_secret), 140 | b'since': b'1', 141 | b'channel': b'default'} 142 | 143 | save_data(CONFIG, config) 144 | os.chmod(CONFIG, 0o600) 145 | 146 | print('[+] Creating contacts file.') 147 | save_data(CONTACT, {}) 148 | os.chmod(CONTACT, 0o600) 149 | 150 | else: 151 | print('[-] ZKM configuration directory already exists.') 152 | 153 | 154 | class ZKMClient(cmd.Cmd): 155 | """ 156 | ZKM: A zero knowledge messaging system. 157 | """ 158 | prompt = 'zkm> ' 159 | 160 | # Functions used in the interactive command prompt. 161 | def preloop(self): 162 | """ 163 | Initialize the ZKM client if necessary. 164 | """ 165 | try: 166 | self.config = load_data(CONFIG) 167 | except: 168 | print('[-] ZKM not initialized yet.') 169 | initialize() 170 | self.config = load_data(CONFIG) 171 | 172 | try: 173 | self.contacts = load_data(CONTACT) 174 | except Exception: 175 | print('[-] Could not load contacts file.') 176 | self.contacts = [] 177 | 178 | def postloop(self): 179 | save_data(CONFIG, self.config) 180 | save_data(CONTACT, self.contacts) 181 | 182 | def do_add_contact(self, line): 183 | """ 184 | Add a new contact to the contact list. 185 | """ 186 | name, their_public = line.split(' ') 187 | self.contacts[bytes(name, 'utf8')] = bytes(their_public, 'utf8') 188 | save_data(CONTACT, self.contacts) 189 | 190 | def do_del_contact(self, name): 191 | """ 192 | Remove a contact from the contact list. 193 | """ 194 | self.contacts.pop(name, None) 195 | save_data(CONTACT, self.contacts) 196 | 197 | def do_connect(self, line): 198 | """ 199 | Define the server we want to connect to for messages. 200 | """ 201 | self.config[b'server'] = bytes(line, 'utf8') 202 | save_data(CONFIG, self.config) 203 | 204 | def do_channel(self, line): 205 | """ 206 | Update the config file with the channel we want to connect to for 207 | messages. 208 | """ 209 | self.config[b'channel'] = bytes(line, 'utf8') 210 | save_data(CONFIG, self.config) 211 | 212 | def do_create_channel(self, line): 213 | """ 214 | Create a random channel name (hex number) and set the new channel in 215 | the configuration. 216 | """ 217 | channel = base64.b16encode(pysodium.randombytes(16)).lower() 218 | self.config[b'channel'] = channel 219 | save_data(CONFIG, self.config) 220 | 221 | print('[+] Channel ID {0} added to configuration.'.format(channel)) 222 | 223 | def do_show_config(self, line): 224 | """ 225 | Print the current configuration information. 226 | """ 227 | print('Current configuration') 228 | print('---------------------') 229 | print(' Public Key: {0}'.format(self.config.get(b'public'))) 230 | print(' ZKM Server: {0}'.format(self.config.get(b'server'))) 231 | print(' Last Check: {0}'.format(self.config.get(b'since'))) 232 | print(' Channel: {0}'.format(self.config.get(b'channel'))) 233 | print() 234 | 235 | def do_show_contacts(self, line): 236 | """ 237 | Print the current list of contacts. 238 | """ 239 | print('Contacts') 240 | print('--------') 241 | for contact in self.contacts: 242 | print(' {0}: {1}'.format(contact, self.contacts[contact])) 243 | 244 | print() 245 | 246 | def do_create_message(self, line): 247 | """ 248 | Create a new encrypted message using the public key associated with 249 | name. 250 | """ 251 | line = line.split(' ') 252 | username = line[0].encode() # This will either be a username or a public key 253 | message = ' '.join(line[1:]) 254 | 255 | # Return the public key associated with the username 256 | their_public = self.contacts.get(username, None) 257 | if their_public is None: 258 | print('[-] No public key available for user.') 259 | return 260 | 261 | channel = self.config.get(b'channel', None) 262 | if channel is None: 263 | print('[-] No channel specified.') 264 | return 265 | 266 | enc_msg = encrypt(self.config[b'secret'], 267 | self.config[b'public'], 268 | their_public, 269 | 'message: {0}'.format(message)) 270 | 271 | resp = send(self.config[b'server'], 272 | 'POST', 273 | '/message/{0}'.format(self.config[b'channel'].decode()), 274 | {'message': enc_msg}) 275 | 276 | print('[+] {0}'.format(resp)) 277 | 278 | def do_read_messages(self, line): 279 | """ 280 | Get all messages and attempt to decrypt them. 281 | 282 | Use the since value stored in the configuration file. The server will 283 | return no more than the last 200 messages by default. This value is 284 | adjustable in the db.py script. 285 | """ 286 | since = int(self.config.get(b'since', b'1')) 287 | channel = self.config.get(b'channel', b'default') 288 | 289 | resp = send(self.config[b'server'], 290 | 'GET', 291 | '/messages/{0}/{1}'.format(channel.decode(), since)) 292 | 293 | for enc_msg in resp: 294 | since = enc_msg[0] 295 | crypt = enc_msg[1].encode() # Needs to be bytes not str 296 | 297 | their_public, dec_msg = decrypt(self.config[b'secret'], crypt) 298 | 299 | # Decryption was successful print the message 300 | if dec_msg.startswith('message: '): 301 | print_msg(self.contacts, their_public, dec_msg) 302 | 303 | # Update since value in the config with the next value. Need to 304 | # convert to bytes as well. 305 | self.config[b'since'] = str(since + 1).encode() 306 | 307 | save_data(CONFIG, self.config) 308 | 309 | def do_EOF(self, line): 310 | """ 311 | Usage quit | exit | ctrl-d 312 | 313 | Quit the ZKM client. 314 | """ 315 | return True 316 | 317 | def do_quit(self, line): 318 | return True 319 | 320 | def do_exit(self, line): 321 | return True 322 | 323 | 324 | #----------------------------------------------------------------------------- 325 | # Main Program 326 | #----------------------------------------------------------------------------- 327 | try: 328 | ZKMClient().cmdloop() 329 | 330 | except Exception as e: 331 | print('[-] Error executing ZKM: {0}'.format(e)) 332 | --------------------------------------------------------------------------------