Choose a function on the top toolbar.
New Message to create a new covert archive,
Paste Armored or Open Covert File to decrypt.")
136 | self.layout = QHBoxLayout(self)
137 | self.layout.addWidget(self.logo)
138 | self.layout.addWidget(self.text)
139 |
--------------------------------------------------------------------------------
/covert/gui/data/emoji-dissatisfied.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/covert-encryption/covert/96658bb4921af06293000ff2109d954efbf317b1/covert/gui/data/emoji-dissatisfied.png
--------------------------------------------------------------------------------
/covert/gui/data/emoji-grin.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/covert-encryption/covert/96658bb4921af06293000ff2109d954efbf317b1/covert/gui/data/emoji-grin.png
--------------------------------------------------------------------------------
/covert/gui/data/emoji-neutral.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/covert-encryption/covert/96658bb4921af06293000ff2109d954efbf317b1/covert/gui/data/emoji-neutral.png
--------------------------------------------------------------------------------
/covert/gui/data/emoji-smiling.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/covert-encryption/covert/96658bb4921af06293000ff2109d954efbf317b1/covert/gui/data/emoji-smiling.png
--------------------------------------------------------------------------------
/covert/gui/data/emoji-think.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/covert-encryption/covert/96658bb4921af06293000ff2109d954efbf317b1/covert/gui/data/emoji-think.png
--------------------------------------------------------------------------------
/covert/gui/data/icons8-attach-48.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/covert-encryption/covert/96658bb4921af06293000ff2109d954efbf317b1/covert/gui/data/icons8-attach-48.png
--------------------------------------------------------------------------------
/covert/gui/data/icons8-cipherfile-64.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/covert-encryption/covert/96658bb4921af06293000ff2109d954efbf317b1/covert/gui/data/icons8-cipherfile-64.png
--------------------------------------------------------------------------------
/covert/gui/data/icons8-copy-48.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/covert-encryption/covert/96658bb4921af06293000ff2109d954efbf317b1/covert/gui/data/icons8-copy-48.png
--------------------------------------------------------------------------------
/covert/gui/data/icons8-copy-64.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/covert-encryption/covert/96658bb4921af06293000ff2109d954efbf317b1/covert/gui/data/icons8-copy-64.png
--------------------------------------------------------------------------------
/covert/gui/data/icons8-file-64.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/covert-encryption/covert/96658bb4921af06293000ff2109d954efbf317b1/covert/gui/data/icons8-file-64.png
--------------------------------------------------------------------------------
/covert/gui/data/icons8-folder-48.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/covert-encryption/covert/96658bb4921af06293000ff2109d954efbf317b1/covert/gui/data/icons8-folder-48.png
--------------------------------------------------------------------------------
/covert/gui/data/icons8-key-48.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/covert-encryption/covert/96658bb4921af06293000ff2109d954efbf317b1/covert/gui/data/icons8-key-48.png
--------------------------------------------------------------------------------
/covert/gui/data/icons8-link-48.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/covert-encryption/covert/96658bb4921af06293000ff2109d954efbf317b1/covert/gui/data/icons8-link-48.png
--------------------------------------------------------------------------------
/covert/gui/data/icons8-locked-48.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/covert-encryption/covert/96658bb4921af06293000ff2109d954efbf317b1/covert/gui/data/icons8-locked-48.png
--------------------------------------------------------------------------------
/covert/gui/data/icons8-new-document-48.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/covert-encryption/covert/96658bb4921af06293000ff2109d954efbf317b1/covert/gui/data/icons8-new-document-48.png
--------------------------------------------------------------------------------
/covert/gui/data/icons8-paste-48.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/covert-encryption/covert/96658bb4921af06293000ff2109d954efbf317b1/covert/gui/data/icons8-paste-48.png
--------------------------------------------------------------------------------
/covert/gui/data/icons8-save-48.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/covert-encryption/covert/96658bb4921af06293000ff2109d954efbf317b1/covert/gui/data/icons8-save-48.png
--------------------------------------------------------------------------------
/covert/gui/data/icons8-signing-a-document-48.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/covert-encryption/covert/96658bb4921af06293000ff2109d954efbf317b1/covert/gui/data/icons8-signing-a-document-48.png
--------------------------------------------------------------------------------
/covert/gui/data/icons8-unlocked-48.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/covert-encryption/covert/96658bb4921af06293000ff2109d954efbf317b1/covert/gui/data/icons8-unlocked-48.png
--------------------------------------------------------------------------------
/covert/gui/data/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/covert-encryption/covert/96658bb4921af06293000ff2109d954efbf317b1/covert/gui/data/logo.png
--------------------------------------------------------------------------------
/covert/gui/decrypt.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 |
3 | from PySide6.QtCore import QSize, Qt, Slot
4 | from PySide6.QtGui import QKeySequence, QShortcut, QStandardItem, QStandardItemModel
5 | from PySide6.QtWidgets import *
6 | from showinfm import show_in_file_manager
7 |
8 | from covert import passphrase, pubkey, util
9 | from covert.archive import Archive
10 | from covert.blockstream import BlockStream
11 | from covert.gui import res
12 |
13 |
14 | class DecryptView(QWidget):
15 | def __init__(self, app, infile):
16 | QWidget.__init__(self)
17 | self.blockstream = BlockStream()
18 | self.decrypt_ctx = self.blockstream.decrypt_init(infile)
19 | self.decrypt_ctx.__enter__()
20 | self.setMinimumWidth(535)
21 | self.app = app
22 | self.init_keyinput()
23 | self.init_passwordinput()
24 | self.layout = QGridLayout(self)
25 | self.layout.setContentsMargins(11, 11, 11, 11)
26 | self.layout.addWidget(QLabel("
If the input is a Covert Archive, it is locked by a public key or by a passphrase.
Enter credentials below to decrypt the data."), 0, 0, 1, 3) 27 | self.layout.addWidget(QLabel("Secret key:"), 1, 0) 28 | self.layout.addWidget(self.siginput, 1, 1) 29 | self.layout.addWidget(self.skfile, 1, 2) 30 | self.layout.addWidget(QLabel("Passphrase:"), 2, 0) 31 | self.layout.addWidget(self.pw, 2, 1) 32 | self.layout.addWidget(self.addbutton, 2, 2) 33 | 34 | self.decrypt_attempt() 35 | 36 | def init_passwordinput(self): 37 | self.pw = QLineEdit() 38 | self.addbutton = QPushButton("Decrypt") 39 | self.addbutton.clicked.connect(self.addpassword) 40 | QShortcut(QKeySequence("Return"), self.pw, context=Qt.WidgetShortcut).activated.connect(self.addpassword) 41 | QShortcut(QKeySequence("Tab"), self.pw, context=Qt.WidgetShortcut).activated.connect(self.tabcomplete) 42 | QShortcut(QKeySequence("Ctrl+H"), self.pw, context=Qt.WidgetShortcut).activated.connect(self.togglehide) 43 | self.pw.setEchoMode(QLineEdit.EchoMode.Password) 44 | self.visible = False 45 | 46 | def init_keyinput(self): 47 | self.siginput = QLineEdit() 48 | self.siginput.setDisabled(True) 49 | self.siginput.setReadOnly(True) 50 | self.siginput.setFixedWidth(260) 51 | self.skfile = QPushButton('Open keyfile') 52 | self.skfile.clicked.connect(self.loadsk) 53 | 54 | @Slot() 55 | def togglehide(self): 56 | self.visible = not self.visible 57 | self.pw.setEchoMode(QLineEdit.EchoMode.Normal if self.visible else QLineEdit.EchoMode.Password) 58 | 59 | @Slot() 60 | def addpassword(self): 61 | pw = self.pw.text() 62 | try: 63 | pwhash = passphrase.pwhash(util.encode(pw)) 64 | except ValueError as e: 65 | self.app.flash(str(e)) 66 | return 67 | self.pw.setText("") 68 | try: 69 | self.blockstream.authenticate(pwhash) 70 | except DecryptError: 71 | self.app.flash("The passphrase was incorrect.") 72 | return 73 | self.decrypt_attempt() 74 | 75 | @Slot() 76 | def tabcomplete(self): 77 | pw, pos, hint = passphrase.autocomplete(self.pw.text(), self.pw.cursorPosition()) 78 | self.pw.setText(pw) 79 | self.pw.setCursorPosition(pos) 80 | if hint: 81 | self.app.flash('Autocomplete: ' + hint) 82 | 83 | 84 | @Slot() 85 | def loadsk(self): 86 | file = QFileDialog.getOpenFileName(self, "Covert - Open secret key", "", 87 | "SSH, Minisign and Age private keys (*)")[0] 88 | if not file: return 89 | try: 90 | keys = set(pubkey.read_sk_file(file)) 91 | except ValueError as e: 92 | self.app.flash(str(e)) 93 | return 94 | for i, k in enumerate(keys): 95 | try: 96 | self.blockstream.authenticate(k) 97 | except DecryptError as e: 98 | if i < len(keys) - 1: continue 99 | self.app.flash(str(e) or "No suitable key found.") 100 | return 101 | self.decrypt_attempt() 102 | 103 | def decrypt_attempt(self): 104 | if self.blockstream.header.key: 105 | self.app.setCentralWidget(ArchiveView(self.app, self.blockstream)) 106 | 107 | 108 | class ArchiveView(QWidget): 109 | def __init__(self, app, blockstream): 110 | QWidget.__init__(self) 111 | self.app = app 112 | self.blockstream = blockstream 113 | self.decrwidget = DecryptWidget(self) 114 | self.details = QGridLayout() 115 | self.plaintext = QPlainTextEdit() 116 | self.plaintext.setTabChangesFocus(True) 117 | self.attachments = QTreeWidget() 118 | self.attachments.setColumnCount(2) 119 | self.attachments.setHeaderLabels(["Name", "Size", "Notes"]) 120 | self.attachments.setColumnWidth(0, 400) 121 | self.attachments.setColumnWidth(1, 80) 122 | self.toolbar = ArchiveToolbar(app, self) 123 | self.layout = QVBoxLayout(self) 124 | self.layout.addWidget(self.decrwidget) 125 | self.layout.addLayout(self.details) 126 | self.layout.addWidget(self.plaintext) 127 | self.layout.addWidget(self.attachments) 128 | self.layout.addWidget(self.toolbar) 129 | self.init_extract() 130 | 131 | self.details.addWidget(QLabel("File hash:"), 0, 0) 132 | self.details.addWidget(QLineEdit(self.archive.filehash[:12].hex()), 0, 1) 133 | i = 1 134 | if not self.archive.signatures: 135 | if isinstance(self.blockstream.header.key, tuple): 136 | self.details.addWidget(QLabel("Sender:"), i, 0) 137 | self.details.addWidget(QLineEdit("Anonymous"), i, 1) 138 | security = "Anyone could have sent this message to you. File hashes may be compared to verify authenticity." 139 | else: 140 | security = "No public key recipients or signatures. All security relies on that passphrase." 141 | else: 142 | security = "Everything has been verified signed by the sender keys. But check that the keys belong to who the sender claims to be." 143 | for i, (valid, key, text) in enumerate(self.archive.signatures, start=i): 144 | if valid: 145 | self.details.addWidget(QLabel("Sender:"), i, 0) 146 | self.details.addWidget(QLineEdit(f" ✅ {key} {text}\n"), i, 1) 147 | else: 148 | self.details.addWidget(QLabel("Invalid signature:"), i, 0) 149 | self.details.addWidget(QLineEdit(f" ❌ {key} {text}\n"), i, 1) 150 | security = "Someone has tampered with the file, possibly one of the other recipients. Do not trust the content." 151 | i += 1 152 | self.details.addWidget(QLabel(security), i, 0, 1, 2) 153 | 154 | 155 | def init_extract(self): 156 | a = Archive() 157 | f = None 158 | # This loads all attached files to RAM. 159 | # TODO: Handle large files by streaming & filename sanitation 160 | attachments = 0 161 | for data in a.decode(self.blockstream.decrypt_blocks()): 162 | if isinstance(data, dict): 163 | # Index 164 | pass 165 | elif isinstance(data, bool): 166 | # Nextfile 167 | prev = a.prevfile 168 | if prev: 169 | # Is it a displayable message 170 | if prev.name is None: 171 | try: 172 | self.plaintext.appendPlainText(f"{prev.data.decode()}\n") 173 | except UnicodeDecodeError: 174 | pidx = a.flist.index(prev) 175 | prev.name = f"noname.{pidx + 1:03}" 176 | prev.renamed = True 177 | # Treat as an attached file 178 | if prev.name: 179 | item = QTreeWidgetItem(self.attachments) 180 | #item = QStandardItem(res.icons.fileicon, f"{prev.size:,} {prev.name}") 181 | item.setIcon(0, res.icons.fileicon) 182 | item.setText(0, prev.name) 183 | item.setText(1, f"{prev.size:,}") 184 | item.setTextAlignment(1, Qt.AlignRight) 185 | attachments += 1 186 | if a.curfile: 187 | a.curfile.data = bytearray() 188 | else: 189 | a.curfile.data += data 190 | 191 | self.blockstream.verify_signatures(a) 192 | self.archive = a 193 | 194 | if not attachments: 195 | self.attachments.setVisible(False) 196 | self.toolbar.extract.setEnabled(False) 197 | if not self.plaintext.toPlainText(): 198 | self.plaintext.setVisible(False) 199 | 200 | def extract(self): 201 | path = QFileDialog.getExistingDirectory(self, "Covert - Extract To") 202 | if not path: return 203 | outdir = Path(path) 204 | mainlevel = set() 205 | for fi in self.archive.flist: 206 | if not fi.name: continue 207 | name = outdir / fi.name 208 | if not name.resolve().is_relative_to(outdir) or name.is_reserved(): 209 | raise ValueError(f"Invalid filename {fi.name}") 210 | # Collect main level names (files or folders) 211 | mname = name 212 | while mname.parent != outdir: 213 | mname = mname.parent 214 | mainlevel.add(str(mname)) 215 | # Write the file 216 | name.parent.mkdir(parents=True, exist_ok=True) 217 | with open(name, "wb") as f: 218 | f.write(fi.data) 219 | self.app.flash(f"Files extracted to {outdir}") 220 | # Support for selecting multiple files is too broken, but we get: 221 | # - A single attachment file is selected (outdir shown) 222 | # - A single attachment folder is shown (contents of) 223 | # - Multiple files/folders are shown in outdir but not selected 224 | show_in_file_manager(mainlevel.pop() if len(mainlevel) == 1 else str(outdir)) 225 | 226 | 227 | class ArchiveToolbar(QWidget): 228 | 229 | def __init__(self, app, view): 230 | QWidget.__init__(self) 231 | self.app = app 232 | self.view = view 233 | self.layout = QHBoxLayout(self) 234 | self.layout.setContentsMargins(11, 11, 11, 11) 235 | 236 | self.extract = QPushButton(res.icons.attachicon, " &Extract All") 237 | self.extract.setIconSize(QSize(24, 24)) 238 | self.layout.addItem(QSpacerItem(0, 0, QSizePolicy.Expanding)) 239 | self.layout.addWidget(self.extract) 240 | 241 | self.extract.clicked.connect(self.view.extract) 242 | 243 | 244 | class DecryptWidget(QWidget): 245 | 246 | def __init__(self, view): 247 | QWidget.__init__(self) 248 | self.layout = QHBoxLayout(self) 249 | self.layout.setContentsMargins(11, 11, 11, 11) 250 | s = view.blockstream.header.slot 251 | if s == "wide-open": 252 | lock = QLabel() 253 | lock.setPixmap(res.icons.unlockicon) 254 | self.layout.addWidget(lock) 255 | self.layout.addWidget(QLabel(' wide-open – anyone can open the file')) 256 | else: 257 | lock = QLabel() 258 | lock.setPixmap(res.icons.lockicon) 259 | self.layout.addWidget(lock) 260 | text = ("single recipient (your public key)" if s[1] == 1 else f"{s[1]} recipients") if isinstance(s, tuple) else s 261 | self.layout.addWidget(QLabel(f" decrypted – {text}")) 262 | self.layout.addItem(QSpacerItem(0, 0, QSizePolicy.Expanding, QSizePolicy.Fixed)) 263 | -------------------------------------------------------------------------------- /covert/gui/res.py: -------------------------------------------------------------------------------- 1 | from PySide6.QtGui import QPixmap 2 | 3 | from covert.gui.util import datafile 4 | 5 | 6 | class Icons: 7 | def __init__(self): 8 | self.logo = QPixmap(datafile('logo.png')) 9 | self.newicon = QPixmap(datafile('icons8-new-document-48.png')).scaled(48, 48) 10 | self.pasteicon = QPixmap(datafile('icons8-paste-48.png')).scaled(48, 48) 11 | self.openicon = QPixmap(datafile('icons8-cipherfile-64.png')).scaled(48, 48) 12 | self.fileicon = QPixmap(datafile('icons8-file-64.png')).scaled(24, 24) 13 | self.foldericon = QPixmap(datafile('icons8-folder-48.png')).scaled(24, 24) 14 | self.unlockicon = QPixmap(datafile('icons8-unlocked-48.png')).scaled(24, 24) 15 | self.lockicon = QPixmap(datafile('icons8-locked-48.png')).scaled(24, 24) 16 | self.keyicon = QPixmap(datafile('icons8-key-48.png')).scaled(24, 24) 17 | self.pkicon = QPixmap(datafile('icons8-link-48.png')).scaled(24, 24) 18 | self.signicon = QPixmap(datafile('icons8-signing-a-document-48.png')).scaled(24, 24) 19 | # Bottom toolbar icons 20 | self.attachicon = QPixmap(datafile('icons8-attach-48.png')) 21 | self.copyicon = QPixmap(datafile('icons8-copy-48.png')) 22 | self.saveicon = QPixmap(datafile('icons8-save-48.png')) 23 | 24 | 25 | icons = None 26 | 27 | def load(): 28 | global icons 29 | icons = Icons() 30 | -------------------------------------------------------------------------------- /covert/gui/util.py: -------------------------------------------------------------------------------- 1 | import signal 2 | 3 | import pkg_resources 4 | from PySide6.QtCore import QTimer 5 | from PySide6.QtWidgets import QApplication 6 | 7 | 8 | def datafile(name): 9 | return pkg_resources.resource_filename(__name__, f'data/{name}') 10 | 11 | 12 | # Call this function in your main after creating the QApplication 13 | def setup_interrupt_handling(): 14 | """Setup handling of KeyboardInterrupt (Ctrl-C) for PyQt.""" 15 | signal.signal(signal.SIGINT, _interrupt_handler) 16 | # Regularly run some (any) python code, so the signal handler gets a 17 | # chance to be executed: 18 | safe_timer(50, lambda: None) 19 | 20 | 21 | # Define this as a global function to make sure it is not garbage 22 | # collected when going out of scope: 23 | def _interrupt_handler(signum, frame): 24 | """Handle KeyboardInterrupt: quit application.""" 25 | QApplication.quit() 26 | 27 | 28 | def safe_timer(timeout, func, *args, **kwargs): 29 | """ 30 | Create a timer that is safe against garbage collection and overlapping 31 | calls. See: http://ralsina.me/weblog/posts/BB974.html 32 | """ 33 | 34 | def timer_event(): 35 | try: 36 | func(*args, **kwargs) 37 | finally: 38 | QTimer.singleShot(timeout, timer_event) 39 | 40 | QTimer.singleShot(timeout, timer_event) 41 | -------------------------------------------------------------------------------- /covert/gui/widgets.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO 2 | 3 | from PySide6.QtCore import QSize, Slot 4 | from PySide6.QtGui import QGuiApplication 5 | from PySide6.QtWidgets import QFileDialog, QHBoxLayout, QLabel, QPushButton, QSizePolicy, QSpacerItem, QWidget 6 | 7 | from covert import util 8 | from covert.gui import res 9 | 10 | 11 | class MethodsWidget(QWidget): 12 | 13 | def __init__(self, view): 14 | QWidget.__init__(self) 15 | self.view = view 16 | self.layout = QHBoxLayout(self) 17 | self.layout.setContentsMargins(11, 11, 11, 11) 18 | if not (view.passwords or view.recipients): 19 | lock = QLabel() 20 | lock.setPixmap(res.icons.unlockicon) 21 | self.layout.addWidget(lock) 22 | self.layout.addWidget(QLabel(' wide-open – anyone can open the file')) 23 | else: 24 | lock = QLabel() 25 | lock.setPixmap(res.icons.lockicon) 26 | self.layout.addWidget(lock) 27 | self.layout.addWidget(QLabel(' covert')) 28 | self.layout.addItem(QSpacerItem(0, 0, QSizePolicy.Expanding, QSizePolicy.Fixed)) 29 | for p in view.passwords: 30 | key = QLabel() 31 | key.setPixmap(res.icons.keyicon) 32 | self.layout.addWidget(key) 33 | if p in view.generated: 34 | self.layout.addWidget(QLabel(f" {view.generated[p]}")) 35 | for k in view.recipients: 36 | key = QLabel() 37 | key.setPixmap(res.icons.pkicon) 38 | self.layout.addWidget(key) 39 | self.layout.addWidget(QLabel(str(k))) 40 | for k in view.signatures: 41 | key = QLabel() 42 | key.setPixmap(res.icons.signicon) 43 | self.layout.addWidget(key) 44 | self.layout.addWidget(QLabel(str(k))) 45 | clearbutton = QPushButton("Clear keys") 46 | clearbutton.clicked.connect(self.clearkeys) 47 | self.layout.addWidget(clearbutton) 48 | 49 | def clearkeys(self): 50 | self.view.recipients = set() 51 | self.view.passwords = set() 52 | self.view.signatures = set() 53 | self.view.auth.pkinput.setText("") 54 | self.view.auth.pw.setText("") 55 | self.view.update_views() 56 | 57 | 58 | 59 | class EncryptToolbar(QWidget): 60 | 61 | def __init__(self, app, view): 62 | QWidget.__init__(self) 63 | self.app = app 64 | self.view = view 65 | self.layout = QHBoxLayout(self) 66 | self.layout.setContentsMargins(11, 11, 11, 11) 67 | 68 | attach = QPushButton(res.icons.attachicon, " Attach &Files") 69 | attachdir = QPushButton(res.icons.attachicon, " Fol&der") 70 | copy = QPushButton(res.icons.copyicon, " &Armored") 71 | save = QPushButton(res.icons.saveicon, " &Save") 72 | attach.setIconSize(QSize(24, 24)) 73 | attachdir.setIconSize(QSize(24, 24)) 74 | copy.setIconSize(QSize(24, 24)) 75 | save.setIconSize(QSize(24, 24)) 76 | self.layout.addWidget(attach) 77 | self.layout.addWidget(attachdir) 78 | self.layout.addItem(QSpacerItem(0, 0, QSizePolicy.Expanding)) 79 | self.layout.addWidget(copy) 80 | self.layout.addWidget(save) 81 | 82 | attach.clicked.connect(self.attach) 83 | attachdir.clicked.connect(self.attachdir) 84 | copy.clicked.connect(self.copyarmor) 85 | save.clicked.connect(self.savecipher) 86 | 87 | @Slot() 88 | def attach(self): 89 | self.view.files |= set(QFileDialog.getOpenFileNames(self, "Covert - Attach files")[0]) 90 | self.view.update_views() 91 | 92 | @Slot() 93 | def attachdir(self): 94 | self.view.files.add(QFileDialog.getExistingDirectory(self, "Covert - Attach a folder")) 95 | self.view.update_views() 96 | 97 | @Slot() 98 | def copyarmor(self): 99 | if not self.view.validate(): return 100 | outfile = BytesIO() 101 | self.view.encrypt(outfile) 102 | outfile.seek(0) 103 | data = util.armor_encode(outfile.read()) 104 | QGuiApplication.clipboard().setText(f"```\n{data}\n```\n") 105 | self.app.flash("Covert message copied to clipboard.") 106 | 107 | @Slot() 108 | def savecipher(self): 109 | if not self.view.validate(): return 110 | name = QFileDialog.getSaveFileName( 111 | self, 'Covert - Save ciphertext', "", 'ASCII Armored - good stealth (*.txt);;Covert Binary - maximum stealth (*)', 112 | 'Covert Binary - maximum stealth (*)' 113 | )[0] 114 | if not name: 115 | return 116 | if name.lower().endswith('.txt'): 117 | outfile = BytesIO() 118 | self.view.encrypt(outfile) 119 | outfile.seek(0) 120 | data = util.armor_encode(outfile.read()) 121 | with open(name, 'wb') as f: 122 | f.write(f"{data}\n".encode()) 123 | return 124 | with open(name, 'wb') as f: 125 | self.view.encrypt(f) 126 | self.app.flash(f"Encrypted message saved as {name}") 127 | -------------------------------------------------------------------------------- /covert/idstore.py: -------------------------------------------------------------------------------- 1 | import mmap 2 | import os 3 | import time 4 | from contextlib import suppress 5 | from copy import copy 6 | from pathlib import Path 7 | 8 | from covert import passphrase, pubkey, ratchet 9 | from covert.archive import Archive 10 | from covert.blockstream import decrypt_file, encrypt_file 11 | from covert.path import create_datadir, idfilename 12 | 13 | 14 | def create(pwhash, idstore=None): 15 | a = Archive() 16 | a.index["I"] = idstore or {} 17 | # Encrypt in RAM... 18 | out = b"".join(b for b in encrypt_file((False, [pwhash], [], []), a.encode, a)) 19 | create_datadir() 20 | # Write the ID file 21 | with open(idfilename, "xb") as f: 22 | f.write(out) 23 | 24 | 25 | def delete_entire_idstore(): 26 | """Securely erase the entire idstore. Config folder is removed if empty.""" 27 | with open(idfilename, "r+b") as f, mmap.mmap(f.fileno(), 0) as m: 28 | m[:] = bytes(len(m)) 29 | os.fsync(f.fileno()) 30 | idfilename.unlink() 31 | with suppress(OSError): 32 | idfilename.parent.rmdir() 33 | 34 | 35 | def update(pwhash, allow_create=True, new_pwhash=None): 36 | if not new_pwhash: 37 | new_pwhash = pwhash 38 | if allow_create and not idfilename.exists(): 39 | idstore = {} 40 | yield idstore 41 | if idstore: create(new_pwhash, idstore) 42 | return 43 | with open(idfilename, "r+b") as f, mmap.mmap(f.fileno(), 0) as m: 44 | # Decrypt everything to RAM 45 | a = Archive() 46 | for data in a.decode(decrypt_file([pwhash], m, a)): 47 | if isinstance(data, dict): 48 | if not "I" in data: data["I"] = dict() 49 | elif isinstance(data, bool): 50 | if data: a.curfile.data = bytearray() 51 | else: a.curfile.data += data 52 | # Yield the ID store for operations but do an update even on break/return etc 53 | with suppress(GeneratorExit): 54 | yield a.index["I"] 55 | # Remove expired records 56 | remove_expired(a.index["I"]) 57 | # Reset archive for re-use in encryption 58 | a.reset() 59 | a.fds = [BytesIO(f.data) for f in a.flist] 60 | a.random_padding(p=0.2) 61 | # Encrypt in RAM... 62 | out = b"".join(b for b in encrypt_file((False, [new_pwhash], [], []), a.encode, a)) 63 | # Overwrite the ID file 64 | if len(m) < len(out): m.resize(len(out)) 65 | m[:len(out)] = out 66 | if len(m) > 2 * len(out): m.resize(len(m)) 67 | 68 | def profile(pwhash, idstr, idkey=None, peerkey=None): 69 | """Create/update ID profile""" 70 | parts = idstr.split(":", 1) 71 | local = parts[0] 72 | peer = parts[1] if len(parts) == 2 else "" 73 | tagself = f"id:{local}" 74 | for idstore in update(pwhash): 75 | # If no peer given, create a pseudonymous peername 76 | while not peer: 77 | peer = f".{passphrase.generate(2)}" 78 | if f"id:{local}:{peer}" in idstore: peer = None 79 | tagpeer = f"id:{local}:{peer}" 80 | # Allow using local IDs as peers 81 | taglocalpeer = f"id:{peer}" 82 | if local == peer or taglocalpeer in idstore: 83 | if peerkey: raise ValueError(f"ID {peer} already in store as a local user, cannot have a recipient key specified.") 84 | else: 85 | taglocalpeer = None 86 | if not (taglocalpeer or peerkey or tagpeer in idstore): 87 | raise ValueError("Peer not in ID store. You need to specify a recipient public key on the first use.") 88 | # Load/generate keys if needed 89 | if not idkey: 90 | idkey = pubkey.Key(sk=idstore[tagself]["I"]) if tagself in idstore else pubkey.Key() 91 | if taglocalpeer: 92 | peerkey = idkey if local == peer else pubkey.Key(sk=idstore[taglocalpeer]["I"]) 93 | elif not peerkey: 94 | peerkey = pubkey.Key(pk=idstore[tagpeer]["i"]) 95 | # Add/update records 96 | if tagself not in idstore: idstore[tagself] = dict() 97 | if tagpeer not in idstore: idstore[tagpeer] = dict() 98 | idstore[tagself]["I"] = idkey.sk 99 | idstore[tagpeer]["i"] = peerkey.pk 100 | idkey = copy(idkey) 101 | peerkey = copy(peerkey) 102 | idkey.comment = tagself 103 | peerkey.comment = tagpeer 104 | r = ratchet.Ratchet() 105 | if "r" in idstore[tagpeer]: 106 | r.load(idstore[tagpeer]["r"]) 107 | else: 108 | idstore[tagpeer]["r"] = r.store() 109 | # These values are not stored in id store but are kept runtime 110 | r.tagpeer = tagpeer 111 | r.idkey = idkey 112 | r.peerkey = peerkey 113 | return idkey, peerkey, r 114 | 115 | 116 | def update_ratchet(pwhash, ratch, a): 117 | if 'r' in a.index: 118 | ratch.prepare_alice(a.filehash[:32], ratch.idkey) 119 | for idstore in update(pwhash): 120 | idstore[ratch.tagpeer]["r"] = ratch.store() 121 | 122 | def save_contact(pwhash, idname, a, b): 123 | localkey = b.header.authkey 124 | peerkey = a.signatures[0][1] 125 | for idstore in update(pwhash): 126 | idstore[f"id:{idname}"] = {} 127 | idstore[f"id:{idname}"]["i"] = peerkey.pk 128 | if "r" in a.index: 129 | r = ratchet.Ratchet() 130 | r.init_bob(a.filehash[:32], localkey, peerkey) 131 | idstore[f"id:{idname}"]["r"] = r.store() 132 | 133 | def authgen(pwhash): 134 | """Try all authentication keys from the keystore""" 135 | for idstore in update(pwhash, allow_create=False): 136 | try: 137 | for key, value in idstore.items(): 138 | if "r" in value: 139 | r = ratchet.Ratchet() 140 | r.load(value['r']) 141 | r.idkey = key 142 | r.peerkey = pubkey.Key(pk=value['i']) 143 | try: 144 | yield r 145 | except GeneratorExit: 146 | # If the ratchet was used, store back with changes 147 | value['r'] = r.store() 148 | raise 149 | if "I" in value: yield pubkey.Key(comment=key, sk=value["I"]) 150 | except GeneratorExit: 151 | break 152 | 153 | def idkeys(pwhash): 154 | keys = {} 155 | for idstore in update(pwhash, allow_create=False): 156 | for key, value in idstore.items(): 157 | if "I" in value: 158 | k = pubkey.Key(comment=key, sk=value["I"]) 159 | keys[k] = k 160 | elif "i" in value: 161 | k = pubkey.Key(comment=key, pk=value["i"]) 162 | if k not in keys: keys[k] = k 163 | return keys 164 | 165 | 166 | def remove_expired(ids: dict) -> None: 167 | """Delete all records that have expired.""" 168 | t = time.time() 169 | for k in list(ids): 170 | v = ids[k] 171 | # The entire peer 172 | if "e" in v and v["e"] < t: 173 | del ids[k] 174 | continue 175 | if "r" in v: 176 | r = v["r"] 177 | # The entire ratchet 178 | if r["e"] < t: 179 | del v["r"] 180 | continue 181 | # Message keys 182 | r["msg"] = [m for m in r['msg'] if m["e"] > t] 183 | -------------------------------------------------------------------------------- /covert/lazyexec.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import itertools 3 | import time 4 | 5 | 6 | # Adopted from Python standard library PR https://github.com/python/cpython/pull/18566 7 | def map(self, fn, *iterables, timeout=None, chunksize=1, prefetch=None): 8 | if timeout is not None: 9 | end_time = timeout + time.monotonic() 10 | if prefetch is None: 11 | prefetch = self._max_workers 12 | if prefetch < 0: 13 | raise ValueError("prefetch count may not be negative") 14 | 15 | argsiter = zip(*iterables) 16 | initialargs = itertools.islice(argsiter, self._max_workers + prefetch) 17 | 18 | fs = collections.deque(self.submit(fn, *args) for args in initialargs) 19 | 20 | # Yield must be hidden in closure so that the futures are submitted 21 | # before the first iterator value is required. 22 | def result_iterator(): 23 | nonlocal argsiter 24 | try: 25 | while fs: 26 | if timeout is None: 27 | res = [fs[0].result()] 28 | else: 29 | res = [fs[0].result(end_time - time.monotonic())] 30 | 31 | # Got a result, future needn't be cancelled 32 | del fs[0] 33 | 34 | # Dispatch next task before yielding to keep 35 | # pipeline full 36 | if argsiter: 37 | try: 38 | args = next(argsiter) 39 | except StopIteration: 40 | argsiter = None 41 | else: 42 | fs.append(self.submit(fn, *args)) 43 | yield res.pop() 44 | finally: 45 | for future in fs: 46 | future.cancel() 47 | 48 | return result_iterator() 49 | -------------------------------------------------------------------------------- /covert/passphrase.py: -------------------------------------------------------------------------------- 1 | import secrets 2 | import sys 3 | from contextlib import suppress 4 | 5 | import nacl.bindings as sodium 6 | from zxcvbn import zxcvbn 7 | from zxcvbn.time_estimates import display_time 8 | 9 | from covert import util 10 | from covert.cli.tty import fullscreen 11 | from covert.wordlist import words 12 | 13 | from typing import Tuple 14 | 15 | MINLEN = 8 # Bytes, not characters 16 | ARGON2_MEMLIMIT = 1 << 28 # Bytes (libsodium), as opposed to KiB (other libraries) 17 | 18 | def generate(n=4, sep=""): 19 | """Generate a password of random words without repeating any word.""" 20 | # Reject if zxcvbn thinks it is much worse than expected, e.g. the random 21 | # words formed a common expression, about 1 % of all that are generated. 22 | # This improves security against password crackers that use other wordlists 23 | # and does not hurt with ones who use ours (who can't afford zxcvbn anyway). 24 | while True: 25 | wl = list(words) 26 | pw = sep.join(wl.pop(secrets.randbelow(len(wl))) for i in range(n)) 27 | if 4 * zxcvbn(pw)["guesses"] > len(words)**n: 28 | return pw 29 | 30 | 31 | def costfactor(pwd: bytes) -> int: 32 | """Returns a factor of time cost increase for short passwords.""" 33 | return 1 << max(0, 12 - len(pwd)) 34 | 35 | 36 | def pwhash(password: bytes) -> bytes: 37 | """Argon2 hash a password (stage 1)""" 38 | if len(password) < MINLEN: 39 | raise ValueError("Too short password") 40 | return _argon2(16, password, b"covertpassphrase", 8 * costfactor(password)) 41 | 42 | 43 | def authkey(pwhash: bytes, nonce: bytes) -> bytes: 44 | """Argon2 hash a pwhash with the file nonce (stage 2)""" 45 | if len(pwhash) != 16 or len(nonce) != 12: 46 | raise Exception(f"Invalid arguments {pwhash=} {nonce=}") 47 | return _argon2(32, nonce, pwhash, 2) 48 | 49 | 50 | def _argon2(outlen: int, passwd: bytes, salt: bytes, ops: int) -> bytes: 51 | return sodium.crypto_pwhash_alg( 52 | outlen=outlen, 53 | passwd=passwd, 54 | salt=salt, 55 | opslimit=ops, 56 | memlimit=ARGON2_MEMLIMIT, 57 | alg=sodium.crypto_pwhash_ALG_ARGON2ID13, 58 | ) 59 | 60 | 61 | def autocomplete(pwd: str, pos: int) -> Tuple[str, int, str]: 62 | head, p, tail = '', pwd[:pos], pwd[pos:] 63 | # Skip already completed words 64 | while p: 65 | for w in words: 66 | wl = len(w) 67 | if p[:wl] == w: 68 | head += p[:wl] 69 | p = p[wl:] 70 | break 71 | else: 72 | break 73 | hint = 'enter a few letters of a word first' 74 | if p: 75 | hint = '' 76 | matches = [w[len(p):] for w in words if w.startswith(p)] 77 | # Find the longest matching prefix of all candidates 78 | common = '' 79 | for letter, *others in zip(*matches): 80 | if others.count(letter) < len(others): 81 | break 82 | common += letter 83 | if not common: 84 | if not matches: 85 | hint = 'no matches' 86 | elif len(matches) <= 10: 87 | hint = " ".join(f'…{m}' for m in matches) 88 | else: 89 | hint = "too many matches" 90 | pwd = head + p + common + tail 91 | pos = len(pwd) - len(tail) 92 | return pwd, pos, hint 93 | 94 | 95 | def ask(prompt, create=False): 96 | wordcount = 4 if create is True else create 97 | with fullscreen() as term: 98 | autohint = '' 99 | pwd = '' # nosec 100 | pos = 0 101 | visible = False 102 | while True: 103 | if create: 104 | pwhint, valid = pwhints(pwd) 105 | pwhint += '\n' * max(0, 4 - pwhint.count('\n')) 106 | pwtitle, pwrest = pwhint.split('\n', 1) 107 | else: 108 | pwtitle, pwrest, valid = 'Covert decryption', '\n', True 109 | out = f"\x1B[1;1H\x1B[1;37;44m{pwtitle:56}\x1B[0m\n{pwrest}" 110 | pwdisp = pwd if visible else len(pwd) * '·' 111 | beforecursor = f"{prompt}: {pwdisp[:pos]}" 112 | aftercursor = pwdisp[pos:] 113 | out += f"{beforecursor}{aftercursor}" 114 | help = '' 115 | if pwd or not create: 116 | help += "\n \x1B[1;34mtab \x1B[0;34m" 117 | help += autohint or "autocomplete words" 118 | autohint = '' 119 | if valid: 120 | help += "\n\x1B[1;34menter \x1B[0;34muse this password" 121 | else: 122 | help += "\n \x1B[1;34mtab \x1B[0;34msuggest a strong password\n\x1B[1;34menter \x1B[0;34mgenerate and use a strong password" 123 | help += "\n \x1B[1;34mdown \x1B[0;34mhide input" if visible else "\n \x1B[1;34mup \x1B[0;34mshow input" 124 | out += f'\n{help}\n' 125 | out = out.replace('\n', '\x1B[0K\n') 126 | row = 5 if create else 3 127 | out += f"\x1B[0m\x1B[0K\x1B[{row};{1 + len(beforecursor)}H" 128 | term.write(out) 129 | for ch in term.reader(): 130 | if len(ch) == 1: # Text input 131 | pwd = pwd[:pos] + ch + pwd[pos:] 132 | pos += 1 133 | if ch == "UP": visible = True 134 | if ch == "DOWN": visible = False 135 | elif ch == 'LEFT': pos = max(0, pos - 1) 136 | elif ch == 'RIGHT': pos = min(len(pwd), pos + 1) 137 | elif ch == 'HOME': pos = 0 138 | elif ch == 'END': pos = len(pwd) 139 | elif ch == "ENTER": 140 | if valid: return util.encode(pwd), visible 141 | if not pwd and create: 142 | pwd = generate(wordcount) 143 | return util.encode(pwd), True 144 | elif ch == "ESC": 145 | visible = not visible 146 | elif ch == "TAB": 147 | if create and not pwd: 148 | pwd = generate(wordcount) 149 | pos = len(pwd) 150 | visible = True 151 | else: 152 | pwd, pos, autohint = autocomplete(pwd, pos) 153 | elif ch in ("BACKSPACE", "DEL"): 154 | if ch == "BACKSPACE": 155 | if pos == 0: continue 156 | pos -= 1 157 | pwd = pwd[:pos] + pwd[pos + 1:] 158 | 159 | 160 | def pwhints(pwd: str) -> Tuple[str, bool]: 161 | maxlen = 20 # zxcvbn gets slow with long passwords 162 | z = zxcvbn(pwd[:maxlen], user_inputs=sys.argv) 163 | fb = z["feedback"] 164 | warn = fb["warning"] 165 | sugg = fb["suggestions"] 166 | guesses = int(z["guesses"]) 167 | if len(pwd) > maxlen: 168 | # Add one bit of entropy for each additional character (NIST entropy estimation) 169 | guesses <<= len(pwd) - maxlen 170 | del sugg[:] 171 | # Estimate the time for our strong hashing (700 ms, 100 cores, 27 GB) 172 | t = .7 / 100 * guesses 173 | # Even stronger hashing of short passwords 174 | pwbytes = util.encode(pwd) 175 | factor = costfactor(pwbytes) 176 | t *= factor 177 | out = f"Estimated time to hack: {display_time(t)}\n" 178 | valid = True 179 | enclen = len(pwbytes) 180 | if enclen < 8 or t < 600: 181 | out = "Choose a passphrase you don't use elsewhere.\n" 182 | valid = False 183 | elif factor != 1: 184 | with suppress(ValueError): 185 | sugg.remove('Add another word or two. Uncommon words are better.') 186 | sugg.append(f"Add some more and we can hash it {factor} times faster.") 187 | elif not sugg: 188 | sugg.append("Seems long enough, using the fastest hashing!") 189 | if warn: 190 | out += f" ⚠️ {warn}\n" 191 | for sugg in sugg[:3 - bool(warn)]: 192 | out += f" ▶️ {sugg}\n" 193 | return out, valid 194 | -------------------------------------------------------------------------------- /covert/path.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | 4 | from xdg import xdg_data_home 5 | 6 | datadir = xdg_data_home() / "covert" 7 | idfilename = datadir / "idstore" 8 | 9 | def create_datadir(): 10 | if not datadir.exists(): 11 | datadir.mkdir(parents=True) 12 | if os.name == "posix": 13 | datadir.chmod(0o700) 14 | # Attempt to disable CoW (in particular with btrfs and zfs) 15 | ret = subprocess.run(["chattr", "+C", datadir], capture_output=True) # nosec 16 | -------------------------------------------------------------------------------- /covert/pubkey.py: -------------------------------------------------------------------------------- 1 | import os 2 | import struct 3 | from base64 import b64decode 4 | from contextlib import suppress 5 | from typing import Optional 6 | from urllib.parse import quote 7 | from urllib.request import urlopen 8 | 9 | import nacl.bindings as sodium 10 | 11 | from covert import bech, passphrase, sshkey, util 12 | from covert.elliptic import egcreate, egreveal 13 | from covert.exceptions import MalformedKeyError, AuthenticationError 14 | 15 | class Key: 16 | 17 | def __init__(self, *, keystr="", comment="", sk=None, pk=None, edsk=None, edpk=None, pkhash=None): 18 | self.sk = self.pk = self.edsk = self.edpk = None 19 | self.keystr = keystr 20 | self.comment = comment 21 | self.pkhash = pkhash 22 | anykey = sk or pk or edsk or edpk 23 | # Restore edpk from hidden format 24 | if pkhash is not None: 25 | if anykey: raise MalformedKeyError("Invalid Key argument: pkhash cannot be combined with other keys") 26 | edpk = bytes(egreveal(pkhash).undirty) 27 | # Create Ed/Mont/Elligator compatible keys if no parameters were given 28 | elif not anykey: 29 | self.pkhash, edsk = egcreate() 30 | # Store each parameter and convert ed25519 keys to curve25519 31 | if edsk: 32 | self.edsk = bytes(edsk[:32]) 33 | # Note: Sodium edsk are actually edsk + edpk so we must add a bogus edpk 34 | self.sk = sodium.crypto_sign_ed25519_sk_to_curve25519(self.edsk + bytes(32)) 35 | if edpk: 36 | self.edpk = bytes(edpk) 37 | try: 38 | self.pk = sodium.crypto_sign_ed25519_pk_to_curve25519(self.edpk) 39 | except RuntimeError: # Unexpected library error from nacl.bindings 40 | raise MalformedKeyError("Invalid Ed25519 public key") 41 | if sk: 42 | sk = bytes(sk[:32]) 43 | assert not edsk or self.sk == sk 44 | self.sk = sk 45 | if pk: 46 | pk = bytes(pk) 47 | assert not edpk or self.pk == pk 48 | self.pk = pk 49 | self._generate_public() 50 | self._validate() 51 | 52 | def __eq__(self, other): 53 | # If Curve25519 pk matches, everything else matches too 54 | return self.pk == other.pk 55 | 56 | def __hash__(self): 57 | return hash(self.pk) 58 | 59 | def __repr__(self): 60 | if self.edsk: 61 | t = 'EdSK' 62 | elif self.sk: 63 | t = 'SK' 64 | elif self.edpk: 65 | t = 'EdPK' 66 | else: 67 | t = 'PK' 68 | return f"Key[{self.pk.hex()[:8]}:{t}]" 69 | 70 | def __str__(self): 71 | """Pretty short string formatting for UI""" 72 | if len(self.comment) < 4: 73 | key = self.keystr or repr(self) 74 | key = f'{key} {self.comment}' if self.comment else key 75 | else: 76 | key = self.comment 77 | return f"…{key[-12:]}" if len(key) > 30 else key 78 | 79 | def _generate_public(self): 80 | """Convert secret keys to public""" 81 | if self.edsk: 82 | edsk_hashed = self.sk 83 | edpk_conv = sodium.crypto_scalarmult_ed25519_base(edsk_hashed) 84 | if self.edpk and self.edpk != edpk_conv: 85 | raise AuthenticationError(f"Secret and public key mismatch\n {self.edpk.hex()}\n {edpk_conv.hex()}") 86 | self.edpk = edpk_conv 87 | if self.sk: 88 | pk_conv = sodium.crypto_scalarmult_base(self.sk) 89 | if self.pk and self.pk != pk_conv: 90 | raise AuthenticationError("Secret and public key mismatch") 91 | self.pk = pk_conv 92 | 93 | def _validate(self): 94 | """Test if the keypairs work correctly""" 95 | if self.edsk: 96 | signed = sodium.crypto_sign(b"Message", self.edsk + self.edpk) 97 | sodium.crypto_sign_open(signed, self.edpk) 98 | if self.sk: 99 | nonce = bytes(sodium.crypto_box_NONCEBYTES) 100 | ciphertext = sodium.crypto_box(b"Message", nonce, self.pk, self.sk) 101 | sodium.crypto_box_open(ciphertext, nonce, self.pk, self.sk) 102 | 103 | 104 | def derive_symkey(nonce: bytes, local: Key, remote: Key) -> bytes: 105 | assert local.sk, f"Missing secret key for {local=}" 106 | shared = sodium.crypto_scalarmult(local.sk, remote.pk) 107 | return sodium.crypto_hash_sha512(bytes(nonce) + shared)[:32] 108 | 109 | 110 | def read_pk_file(keystr: str) -> list[Key]: 111 | ghuser = None 112 | if keystr.startswith("github:"): 113 | ghuser = keystr[7:] 114 | url = f"https://github.com/{quote(ghuser, safe='')}.keys" 115 | with urlopen(url) as resp: # nosec 116 | data = resp.read() 117 | elif not os.path.isfile(keystr): 118 | raise ValueError(f"Keyfile {keystr} not found. Use -r instead of -R if you meant to use a key string.") 119 | else: 120 | with open(keystr, "rb") as f: 121 | data = f.read() 122 | if not data: 123 | raise ValueError(f'Nothing found in {keystr}') 124 | # A key token per line, except skip comments and empty lines 125 | lines = data.decode().rstrip().split("\n") 126 | keys = [] 127 | for l in lines: 128 | with suppress(ValueError): 129 | keys.append(decode_pk(l)) 130 | if not keys: 131 | raise ValueError(f'No public keys recognized from file {keystr}') 132 | for i, k in enumerate(keys, 1): 133 | if ghuser: 134 | k.comment = f"{ghuser}@github" 135 | k.keystr = f"{keystr}:{i}" if len(keys) > 1 else keystr 136 | return keys 137 | 138 | 139 | def read_sk_any(keystr: str) -> list[Key]: 140 | with suppress(ValueError): 141 | return [decode_sk(keystr)] 142 | return read_sk_file(keystr) 143 | 144 | 145 | def read_sk_file(keystr: str) -> list[Key]: 146 | if not os.path.isfile(keystr): 147 | raise ValueError(f"Secret key file {keystr} not found") 148 | with open(keystr, "rb") as f: 149 | try: 150 | lines = f.read().decode().replace('\r\n', '\n').rstrip().split('\n') 151 | except ValueError: 152 | raise MalformedKeyError(f"Keyfile {keystr} could not be decoded. Only UTF-8 text is supported.") 153 | if lines[0] == "-----BEGIN OPENSSH PRIVATE KEY-----": 154 | keys = sshkey.decode_sk("\n".join(lines)) 155 | elif lines[1].startswith('RWRTY0Iy'): 156 | keys = [decode_sk_minisign(lines[1])] 157 | else: 158 | # A key token per line, except skip comments and empty lines 159 | keys = [ 160 | decode_sk(l) for l in lines if l.strip() and not l.startswith('untrusted comment:') and not l.startswith('#') 161 | ] 162 | for i, k in enumerate(keys, 1): 163 | k.keystr = f"{keystr}:{i}" if len(keys) > 1 else keystr 164 | return keys 165 | 166 | 167 | def decode_pk(keystr: str) -> Key: 168 | # Age keys use Bech32 encoding 169 | if keystr.startswith("age1"): 170 | return decode_age_pk(keystr) 171 | # Try Base64 encoded formats 172 | try: 173 | token, comment = keystr, '' 174 | if keystr.startswith('ssh-ed25519 '): 175 | t, token, *cmt = keystr.split(' ', 2) 176 | comment = cmt[0] if cmt else 'ssh' 177 | keybytes = b64decode(token, validate=True) 178 | ssh = keybytes.startswith(b"\x00\x00\x00\x0bssh-ed25519\x00\x00\x00 ") 179 | minisign = len(keybytes) == 42 and keybytes.startswith(b'Ed') 180 | if minisign: 181 | comment = 'ms' 182 | if ssh or minisign: 183 | return Key(keystr=keystr, comment=comment, edpk=keybytes[-32:]) 184 | # WireGuard keys 185 | if len(keybytes) == 32: 186 | return Key(keystr=keystr, comment="wg", pk=keybytes) 187 | except ValueError: 188 | pass 189 | raise MalformedKeyError(f"Unrecognized key {keystr}") 190 | 191 | 192 | def decode_sk(keystr: str) -> Key: 193 | # Age secret keys in Bech32 encoding 194 | if keystr.lower().startswith("age-secret-key-"): 195 | return decode_age_sk(keystr) 196 | # Magic for Minisign 197 | if keystr.startswith('RWRTY0Iy'): 198 | return decode_sk_minisign(keystr) 199 | # Plain Curve25519 key (WireGuard) 200 | try: 201 | keybytes = b64decode(keystr, validate=True) 202 | # Must be a clamped scalar 203 | if len(keybytes) == 32 and keybytes[0] & 8 == 0 and keybytes[31] & 0xC0 == 0x40: 204 | return Key(keystr=keystr, sk=keybytes, comment="wg") 205 | except ValueError: 206 | pass 207 | raise MalformedKeyError(f"Unable to parse secret key {keystr!r}") 208 | 209 | 210 | def decode_sk_minisign(keystr: str, pw: Optional[bytes] = None) -> Key: 211 | # None means try without password, then ask 212 | if pw is None: 213 | try: 214 | return decode_sk_minisign(keystr, b'') 215 | except ValueError: 216 | pass 217 | pw = passphrase.ask('Minisign passkey')[0] 218 | return decode_sk_minisign(keystr, pw) 219 | data = b64decode(keystr) 220 | fmt, salt, ops, mem, token = struct.unpack('<6s32sQQ104s', data) 221 | if fmt != b'EdScB2' or ops != 1 << 25 or mem != 1 << 30: 222 | raise MalformedKeyError(f'Not a (supported) Minisign secret key {fmt=}') 223 | out = sodium.crypto_pwhash_scryptsalsa208sha256_ll(pw, salt, n=1 << 20, r=8, p=1, maxmem=1 << 31, dklen=104) 224 | token = util.xor(out, token) 225 | keyid, edsk, edpk, csum = struct.unpack('8s32s32s32s', token) 226 | b2state = sodium.crypto_generichash_blake2b_init() 227 | sodium.crypto_generichash.generichash_blake2b_update(b2state, fmt[:2] + keyid + edsk + edpk) 228 | csum2 = sodium.crypto_generichash.generichash_blake2b_final(b2state) 229 | if csum != csum2: 230 | raise AuthenticationError('Unable to decrypt Minisign secret key') 231 | return Key(edsk=edsk, edpk=edpk, comment="ms") 232 | 233 | 234 | def decode_age_pk(keystr: str) -> Key: 235 | return Key(keystr=keystr, comment="age", pk=bech.decode("age", keystr.lower())) 236 | 237 | 238 | def encode_age_pk(key: Key) -> str: 239 | return bech.encode("age", key.pk) 240 | 241 | 242 | def decode_age_sk(keystr: str) -> Key: 243 | return Key(keystr=keystr, comment="age", sk=bech.decode("age-secret-key-", keystr.lower())) 244 | 245 | 246 | def encode_age_sk(key: Key) -> str: 247 | return bech.encode("age-secret-key-", key.sk).upper() 248 | -------------------------------------------------------------------------------- /covert/ratchet.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | from contextlib import suppress 3 | import time 4 | 5 | import nacl.bindings as sodium 6 | 7 | from covert.chacha import decrypt, encrypt 8 | from covert.pubkey import Key, derive_symkey 9 | from covert.exceptions import DecryptError 10 | 11 | MAXSKIP = 20 12 | 13 | def expire_soon(): 14 | return int(time.time()) + 600 # 10 minutes 15 | 16 | def expire_later(): 17 | return int(time.time()) + 86400 * 28 # four weeks 18 | 19 | def chainstep(chainkey: bytes, addn=b""): 20 | """Perform a chaining step, returns (new chainkey, message key).""" 21 | h = sodium.crypto_hash_sha512(chainkey + addn) 22 | return h[:32], h[32:] 23 | 24 | 25 | class SymChain: 26 | def __init__(self): 27 | self.CK = None 28 | self.HK = None 29 | self.NHK = None 30 | self.CN = 0 31 | self.PN = 0 32 | self.N = 0 33 | 34 | def store(self): 35 | return dict( 36 | CK=self.CK, 37 | HK=self.HK, 38 | NHK=self.NHK, 39 | CN=self.CN, 40 | PN=self.PN, 41 | N=self.N, 42 | ) 43 | 44 | def load(self, chain): 45 | self.CK = chain['CK'] 46 | self.HK = chain['HK'] 47 | self.NHK = chain['NHK'] 48 | self.CN = chain['CN'] 49 | self.PN = chain['PN'] 50 | self.N = chain['N'] 51 | 52 | def dhstep(self, ratchet, peerkey): 53 | shared = derive_symkey(b"ratchet", ratchet.DH, peerkey) 54 | self.CN += self.N 55 | self.PN = self.N 56 | self.N = 0 57 | self.HK = self.NHK 58 | ratchet.RK, self.CK = chainstep(ratchet.RK, shared) 59 | _, self.NHK = chainstep(ratchet.RK, b"hkey") 60 | 61 | def __next__(self): 62 | self.CK, MK = chainstep(self.CK) 63 | self.N += 1 64 | return MK 65 | 66 | class Ratchet: 67 | def __init__(self): 68 | self.RK = None 69 | self.DH = None 70 | self.s = SymChain() 71 | self.r = SymChain() 72 | self.msg = [] 73 | self.pre = [] 74 | self.e = expire_later() 75 | # Runtime values, not saved 76 | self.peerkey = None 77 | self.idkey = None 78 | 79 | def store(self): 80 | return dict( 81 | RK=self.RK, 82 | DH=self.DH.sk if self.DH else None, 83 | s=self.s.store(), 84 | r=self.r.store(), 85 | msg=self.msg, 86 | pre=self.pre, 87 | e=self.e, 88 | ) 89 | 90 | def load(self, ratchet): 91 | self.RK = ratchet['RK'] 92 | self.DH = Key(sk=ratchet['DH']) if ratchet['DH'] else None 93 | self.s.load(ratchet['s']) 94 | self.r.load(ratchet['r']) 95 | self.msg = ratchet['msg'] 96 | self.pre = ratchet['pre'] 97 | self.e = ratchet['e'] 98 | 99 | def prepare_alice(self, shared, localkey): 100 | """Alice sends non-ratchet initial message.""" 101 | self.pre.append(shared) 102 | self.pre = self.pre[-MAXSKIP:] 103 | self.DH = localkey 104 | self.s.N += 1 105 | self.e = expire_later() 106 | 107 | def init_bob(self, shared, localkey, peerkey): 108 | """Bob receives an initial message from Alice, initialise ratchet on Bob side for replies.""" 109 | self.DH = localkey 110 | self.RK = shared 111 | self.s.NHK = shared 112 | self.dhratchet(peerkey) 113 | self.e = expire_later() 114 | 115 | def init_alice(self, ciphertext): 116 | """Alice's init when receiving initial ratchet reply from Bob.""" 117 | for hkey, n in itertools.product(self.pre, range(MAXSKIP)): 118 | with suppress(DecryptError): 119 | header = decrypt(ciphertext[:50], None, n.to_bytes(12, "little"), hkey) 120 | break 121 | else: 122 | raise DecryptError("No ratchet established, unable to decrypt") 123 | self.pre = [] 124 | self.RK = hkey 125 | self.r.NHK = hkey 126 | self.s.dhstep(self, self.peerkey) 127 | self.dhratchet(Key(pk=header[:32])) 128 | self.skip_until(n) 129 | self.e = expire_later() 130 | return self.readmsg() 131 | 132 | def send(self, peerkey=None): 133 | header = encrypt(self.DH.pk + self.s.PN.to_bytes(2, "little"), None, self.s.N.to_bytes(12, "little"), self.s.HK) 134 | self.e = expire_later() 135 | return header, next(self.s) 136 | 137 | def receive(self, ciphertext): 138 | if self.pre: 139 | return self.init_alice(ciphertext) 140 | # Try skipped keys 141 | for s in self.msg: 142 | hkey, n = s['H'], s['N'] 143 | with suppress(DecryptError): 144 | header = decrypt(ciphertext[:50], None, n.to_bytes(12, "little"), hkey) 145 | s['e'] = expire_soon() 146 | s['r'] = True 147 | mk = s['M'] 148 | self.e = expire_later() 149 | return mk 150 | header = None 151 | # Try with current header key 152 | if self.r.HK: 153 | for n in range(self.r.N, self.r.N + MAXSKIP): 154 | with suppress(DecryptError): 155 | header = decrypt(ciphertext[:50], None, n.to_bytes(12, "little"), self.r.HK) 156 | self.skip_until(n) 157 | break 158 | # Try with next header key 159 | if not header: 160 | for n in range(MAXSKIP): 161 | with suppress(DecryptError): 162 | header = decrypt(ciphertext[:50], None, n.to_bytes(12, "little"), self.r.NHK) 163 | PN = int.from_bytes(header[32:34], "little") 164 | self.skip_until(PN) 165 | self.dhratchet(Key(pk=header[:32])) 166 | self.skip_until(n) 167 | if not header: 168 | raise DecryptError(f"Unable to authenticate") 169 | self.e = expire_later() 170 | # Advance receiving chain 171 | return self.readmsg() 172 | 173 | def dhratchet(self, peerkey): 174 | """Perform two DH steps to update all chains.""" 175 | self.r.dhstep(self, peerkey) 176 | self.DH = Key() 177 | self.s.dhstep(self, peerkey) 178 | 179 | def skip_until(self, n): 180 | """Advance the receiving chain across all messages prior to message n.""" 181 | while self.r.N < n: 182 | self.msg.append(dict( 183 | H=self.r.HK, 184 | N=self.r.N, 185 | M=next(self.r), 186 | e=expire_soon(), 187 | )) 188 | 189 | def readmsg(self): 190 | m = dict( 191 | H=self.r.HK, 192 | N=self.r.N, 193 | M=next(self.r), 194 | e=expire_soon(), 195 | r=True, 196 | ) 197 | self.msg.append(m) 198 | self.msg = self.msg[-MAXSKIP:] 199 | return m['M'] 200 | 201 | 202 | # Alice sends non-ratchet, includes pk, stores shared secret 203 | 204 | # Bob decrypts, calls init_bob, sends ratchet reply nhks=shared 205 | # - init RK, recv chain(ii, nhk), new key, send chain(xi) 206 | 207 | # Alice receives ratchet reply, shared secret as nhk 208 | # - init RK, send chain(ii, nhk), recv chain(ix), new key, send chain(xx) 209 | 210 | # Bob receives reply 211 | # - recv chain(xx), new key, send chain(xx) 212 | -------------------------------------------------------------------------------- /covert/sshkey.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from base64 import b64decode 4 | from typing import List 5 | 6 | import bcrypt 7 | # Unfortunately pynacl does not offer AES at all. 8 | # It would be nice if this could be replaced with some tiny AES library. 9 | from cryptography.hazmat.primitives.ciphers import Cipher 10 | from cryptography.hazmat.primitives.ciphers.algorithms import AES 11 | from cryptography.hazmat.primitives.ciphers.modes import CTR 12 | 13 | from covert import passphrase, pubkey, util 14 | from covert.exceptions import MalformedKeyError, AuthenticationError 15 | 16 | HEADER = "-----BEGIN OPENSSH PRIVATE KEY-----" 17 | FOOTER = "-----END OPENSSH PRIVATE KEY-----" 18 | 19 | 20 | def decode_armor(data: str) -> bytes: 21 | pos1 = data.find(HEADER) 22 | pos2 = data.find(FOOTER, pos1) 23 | if pos2 == -1: 24 | raise ValueError("Not SSH secret key (header or footer missing)") 25 | return b64decode(data[pos1 + len(HEADER) : pos2]) 26 | 27 | 28 | def decode_sk(pem: str, pw=None) -> List[pubkey.Key]: 29 | """Parse PEM or the Base64 binary data within a secret key file.""" 30 | # None means try without password, then ask 31 | data = decode_armor(pem) 32 | 33 | def decrypt(message, nonce, key): 34 | c = Cipher(AES(key), CTR(nonce)).decryptor() 35 | return c.update(message) + c.finalize() 36 | 37 | def read_string(): 38 | return read_bytes(read_uint32()) 39 | 40 | def read_uint32(): 41 | nonlocal data 42 | if len(data) < 4: 43 | raise MalformedKeyError("Invalid SSH secret key (cannot read int)") 44 | n = int.from_bytes(data[:4], "big") 45 | data = data[4:] 46 | return n 47 | 48 | def read_bytes(n): 49 | nonlocal data 50 | if n > len(data): 51 | raise MalformedKeyError(f" {data[:4]} {n} Invalid SSH secret key (cannot read data)") 52 | s = data[:n] 53 | data = data[n:] 54 | return s 55 | 56 | # Overall format (header + potentially encrypted blob) 57 | magic = read_bytes(15) 58 | if magic != b'openssh-key-v1\0': 59 | raise MalformedKeyError("Invalid SSH secret key magic") 60 | cipher = read_string() 61 | kdfname = read_string() 62 | kdfopts = read_string() 63 | numkeys = read_uint32() 64 | pubkeys = [read_string() for i in range(numkeys)] 65 | encrypted = read_string() 66 | 67 | # Quick exit if there is nothing we can use 68 | if not any(b"ssh-ed25519" in pk for pk in pubkeys): 69 | raise ValueError("No ssh-ed25519 keys found") 70 | 71 | # Decrypt if protected 72 | if cipher == b"none": 73 | data = encrypted 74 | elif cipher == b"aes256-ctr" and kdfname == b"bcrypt": 75 | data = kdfopts 76 | salt = read_string() 77 | rounds = read_uint32() 78 | # 16 is a normal value 79 | if rounds > 1000: 80 | raise MalformedKeyError("SSH secret key KDF rounds too high") 81 | if pw is None: 82 | pw = passphrase.ask("SSH secret key password")[0] 83 | if not pw: 84 | raise ValueError("Password required for SSH keyfile") 85 | keyiv = bcrypt.kdf(pw, salt, 32 + 16, rounds, ignore_few_rounds=True) 86 | data = decrypt(encrypted, keyiv[32:], keyiv[:32]) 87 | else: 88 | raise AuthenticationError("Unsupported SSH keyfile {cipher=!r} {kdfname=!r}") 89 | 90 | # Check if valid 91 | if read_uint32() != read_uint32(): 92 | raise AuthenticationError("Unable to decrypt SSH keyfile") 93 | 94 | # Read secret keys 95 | secretkeys = [] 96 | for i, pkstr in enumerate(pubkeys): 97 | t = read_string().decode() 98 | if t == "ssh-ed25519": 99 | edpk, edsk, comment = read_string(), read_string(), read_string() 100 | secretkeys.append(pubkey.Key(edpk=edpk, edsk=edsk, comment=comment.decode())) 101 | elif t == "ecdsa-sha2-nistp256": 102 | *params, comment = [read_string() for _ in range(4)] 103 | elif t == "ssh-rsa": 104 | md, pe, se, coeff, p, q, comment = [read_string() for _ in range(7)] 105 | elif t == "ssh-dss": 106 | *params, comment = [read_string() for _ in range(6)] 107 | else: 108 | raise ValueError(f"Unknown SSH key type {t}") 109 | 110 | return secretkeys 111 | 112 | 113 | # Implementation note: Apparently OpenSSH never puts more than one key in a file, 114 | # but the above function follows the spec, allowing for any number of keys. 115 | -------------------------------------------------------------------------------- /covert/typing.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | BytesLike = Union[bytes, memoryview] 4 | -------------------------------------------------------------------------------- /covert/util.py: -------------------------------------------------------------------------------- 1 | import platform 2 | import re 3 | import unicodedata 4 | from base64 import b64decode, b64encode 5 | from math import log 6 | from secrets import choice, token_bytes 7 | 8 | ARMOR_MAX_SINGLELINE = 4000 # Safe limit for line input, where 4096 may be the limit 9 | ARMOR_MAX_SIZE = 32 << 20 # If output is a file (limit our memory usage) 10 | TTY_MAX_SIZE = 100 << 10 # If output is a tty (limit too lengthy spam) 11 | IS_APPLE = platform.system() == "Darwin" 12 | 13 | def armor_decode(data: str) -> bytes: 14 | """Base64 decode.""" 15 | # Fix CRLF, remove any surrounding BOM, whitespace and code block markers 16 | data = data.replace('\r\n', '\n').strip('\uFEFF`> \t\n') 17 | if not data.isascii(): 18 | raise ValueError(f"Invalid armored encoding: data is not ASCII/Base64") 19 | # Strip indent and quote marks, trailing whitespace and empty lines 20 | lines = [line for l in data.split('\n') if (line := l.lstrip('\t >').rstrip())] 21 | # Empty input means empty output (will cause an error elsewhere) 22 | if not lines: 23 | return b'' 24 | # Verify charset on all lines 25 | r = re.compile(f"^[A-Za-z0-9+/]+$") 26 | for i, line in enumerate(lines): 27 | if not r.match(line): 28 | raise ValueError(f"Invalid armored encoding: unrecognized data on line {i + 1}") 29 | # Verify line lengths 30 | l = len(lines[0]) 31 | for i, line in enumerate(lines[:-1]): 32 | l2 = len(line) 33 | if l2 < 76 or l2 % 4 or l2 != l: 34 | raise ValueError(f"Invalid armored encoding: length {l2} of line {i + 1} is invalid") 35 | data = "".join(lines) 36 | padding = -len(data) % 4 37 | if padding == 3: 38 | raise ValueError(f"Invalid armored encoding: invalid length for Base64 sequence") 39 | # Not sure why we even bother to use the standard library after having handled all that... 40 | return b64decode(data + padding*'=', validate=True) 41 | 42 | 43 | def armor_encode(data: bytes) -> str: 44 | """Base64 without the padding nonsense, and with adaptive line wrapping.""" 45 | d = b64encode(data).decode().rstrip('=') 46 | if len(d) > ARMOR_MAX_SINGLELINE: 47 | # Make fingerprinting the encoding by line lengths a bit harder while still using >76 48 | splitlen = choice(range(76, 121, 4)) 49 | d = '\n'.join([d[i:i + splitlen] for i in range(0, len(d), splitlen)]) 50 | return d 51 | 52 | 53 | def encode(s: str) -> bytes: 54 | """Unicode-normalizing UTF-8 encode.""" 55 | return unicodedata.normalize("NFKC", s.lstrip("\uFEFF")).encode() 56 | 57 | 58 | def decode_native(s: bytes) -> str: 59 | """Restore platform-native Unicode normalization form (e.g. for filenames).""" 60 | return unicodedata.normalize("NFD" if IS_APPLE else "NFKC", s.decode()) 61 | 62 | 63 | def noncegen(nonce=None): 64 | nonce = token_bytes(12) if nonce is None else bytes(nonce) 65 | l = len(nonce) 66 | mask = (1 << 8 * l) - 1 67 | while True: 68 | yield nonce 69 | # Overflow safe fast increment (152ns vs. 139ns without overflow protection) 70 | nonce = (int.from_bytes(nonce, "little") + 1 & mask).to_bytes(l, "little") 71 | 72 | 73 | def xor(a, b) -> bytes: 74 | assert len(a) == len(b) 75 | l = len(a) 76 | a = int.from_bytes(a, "little") 77 | b = int.from_bytes(b, "little") 78 | return (a ^ b).to_bytes(l, "little") 79 | 80 | 81 | def random_padding(size, p) -> int: 82 | """Calculate random padding size in bytes as (roughly) proportion p of message size.""" 83 | if not p: 84 | return 0 85 | # Choose the amount of fixed padding to hide very short messages 86 | fixed_padding = max(0, int(p * 500) - size) 87 | # Random padding on effective size (increased for small data, decreased for gigabyte class) 88 | eff_size = 200 + 1e8 * log(1 + 1e-8 * (size + fixed_padding)) 89 | r = log(1 << 65) - log(1 + 2 * int.from_bytes(token_bytes(8), "little")) 90 | # Apply pad-to-fixed-size for very short messages plus random padding 91 | return fixed_padding + int(round(r * p * eff_size)) 92 | -------------------------------------------------------------------------------- /covert/wordlist.py: -------------------------------------------------------------------------------- 1 | # A custom list of 1024 common 3-6 letter words, with unique 3-prefixes and no prefix words, entropy 2.1b/letter 10b/word 2 | words: list = """ 3 | able about absent abuse access acid across act adapt add adjust admit adult advice affair afraid again age agree ahead 4 | aim air aisle alarm album alert alien all almost alone alpha also alter always amazed among amused anchor angle animal 5 | ankle annual answer any apart appear april arch are argue army around array art ascent ash ask aspect assume asthma atom 6 | attack audit august aunt author avoid away awful axis baby back bad bag ball bamboo bank bar base battle beach become 7 | beef before begin behind below bench best better beyond bid bike bind bio birth bitter black bleak blind blood blue 8 | board body boil bomb bone book border boss bottom bounce bowl box boy brain bread bring brown brush bubble buck budget 9 | build bulk bundle burden bus but buyer buzz cable cache cage cake call came can car case catch cause cave celery cement 10 | census cereal change check child choice chunk cigar circle city civil class clean client close club coast code coffee 11 | coil cold come cool copy core cost cotton couch cover coyote craft cream crime cross cruel cry cube cue cult cup curve 12 | custom cute cycle dad damage danger daring dash dawn day deal debate decide deer define degree deity delay demand denial 13 | depth derive design detail device dial dice die differ dim dinner direct dish divert dizzy doctor dog dollar domain 14 | donate door dose double dove draft dream drive drop drum dry duck dumb dune during dust dutch dwarf eager early east 15 | echo eco edge edit effort egg eight either elbow elder elite else embark emerge emily employ enable end enemy engine 16 | enjoy enlist enough enrich ensure entire envy equal era erode error erupt escape essay estate ethics evil evoke exact 17 | excess exist exotic expect extent eye fabric face fade faith fall family fan far father fault feel female fence fetch 18 | fever few fiber field figure file find first fish fit fix flat flesh flight float fluid fly foam focus fog foil follow 19 | food force fossil found fox frame fresh friend frog fruit fuel fun fury future gadget gain galaxy game gap garden gas 20 | gate gauge gaze genius ghost giant gift giggle ginger girl give glass glide globe glue goal god gold good gospel govern 21 | gown grant great grid group grunt guard guess guide gulf gun gym habit hair half hammer hand happy hard hat have hawk 22 | hay hazard head hedge height help hen hero hidden high hill hint hip hire hobby hockey hold home honey hood hope horse 23 | host hotel hour hover how hub huge human hungry hurt hybrid ice icon idea idle ignore ill image immune impact income 24 | index infant inhale inject inmate inner input inside into invest iron island issue italy item ivory jacket jaguar james 25 | jar jazz jeans jelly jewel job joe joke joy judge juice july jump june just kansas kate keep kernel key kick kid kind 26 | kiss kit kiwi knee knife know labor lady lag lake lamp laptop large later laugh lava law layer lazy leader left legal 27 | lemon length lesson letter level liar libya lid life light like limit line lion liquid list little live lizard load 28 | local logic long loop lost loud love low loyal lucky lumber lunch lust luxury lyrics mad magic main major make male 29 | mammal man map market mass matter maze mccoy meadow media meet melt member men mercy mesh method middle milk mimic mind 30 | mirror miss mix mobile model mom monkey moon more mother mouse move much muffin mule must mutual myself myth naive name 31 | napkin narrow nasty nation near neck need nephew nerve nest net never news next nice night noble noise noodle normal 32 | nose note novel now number nurse nut oak obey object oblige obtain occur ocean odor off often oil okay old olive omit 33 | once one onion online open opium oppose option orange orbit order organ orient orphan other outer oval oven own oxygen 34 | oyster ozone pact paddle page pair palace panel paper parade past path pause pave paw pay peace pen people pepper permit 35 | pet philip phone phrase piano pick piece pig pilot pink pipe pistol pitch pizza place please pluck poem point polar pond 36 | pool post pot pound powder praise prefer price profit public pull punch pupil purity push put puzzle qatar quasi queen 37 | quite quoted rabbit race radio rail rally ramp range rapid rare rather raven raw razor real rebel recall red reform 38 | region reject relief remain rent reopen report result return review reward rhythm rib rich ride rifle right ring riot 39 | ripple risk ritual river road robot rocket room rose rotate round row royal rubber rude rug rule run rural sad safe sage 40 | sail salad same santa sauce save say scale scene school scope screen scuba sea second seed self semi sense series settle 41 | seven shadow she ship shock shrimp shy sick side siege sign silver simple since siren sister six size skate sketch ski 42 | skull slab sleep slight slogan slush small smile smooth snake sniff snow soap soccer soda soft solid son soon sort south 43 | space speak sphere spirit split spoil spring spy square state step still story strong stuff style submit such sudden 44 | suffer sugar suit summer sun supply sure swamp sweet switch sword symbol syntax syria system table tackle tag tail talk 45 | tank tape target task tattoo taxi team tell ten term test text that theme this three thumb tibet ticket tide tight tilt 46 | time tiny tip tired tissue title toast today toe toilet token tomato tone tool top torch toss total toward toy trade 47 | tree trial trophy true try tube tumble tunnel turn twenty twice two type ugly unable uncle under unfair unique unlock 48 | until unveil update uphold upon upper upset urban urge usage use usual vacuum vague valid van vapor vast vault vein 49 | velvet vendor very vessel viable video view villa violin virus visit vital vivid vocal voice volume vote voyage wage 50 | wait wall want war wash water wave way wealth web weird were west wet what when whip wide wife will window wire wish 51 | wolf woman wonder wood work wrap wreck write wrong xander xbox xerox xray yang yard year yellow yes yin york you zane 52 | zara zebra zen zero zippo zone zoo zorro zulu 53 | """.split() 54 | assert len(words) == 1024 # Exactly 10 bits of entropy per word 55 | -------------------------------------------------------------------------------- /docs/SECURITY.md: -------------------------------------------------------------------------------- 1 | # Covert Security Policy 2 | 3 | Covert is not at this stage meant for end users or production use. For this reason, all security flaws should be reported as public Github issues like any other bug, and no CVEs will be issued for them. 4 | 5 | The policy is expected to change as 1.0 is released. 6 | -------------------------------------------------------------------------------- /docs/benchmark.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/covert-encryption/covert/96658bb4921af06293000ff2109d954efbf317b1/docs/benchmark.webp -------------------------------------------------------------------------------- /docs/covert-gui.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/covert-encryption/covert/96658bb4921af06293000ff2109d954efbf317b1/docs/covert-gui.webp -------------------------------------------------------------------------------- /docs/distribution.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/covert-encryption/covert/96658bb4921af06293000ff2109d954efbf317b1/docs/distribution.png -------------------------------------------------------------------------------- /docs/distribution.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/covert-encryption/covert/96658bb4921af06293000ff2109d954efbf317b1/docs/distribution.webp -------------------------------------------------------------------------------- /docs/in-out.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/covert-encryption/covert/96658bb4921af06293000ff2109d954efbf317b1/docs/in-out.png -------------------------------------------------------------------------------- /docs/in-out.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/covert-encryption/covert/96658bb4921af06293000ff2109d954efbf317b1/docs/in-out.webp -------------------------------------------------------------------------------- /docs/logo.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/covert-encryption/covert/96658bb4921af06293000ff2109d954efbf317b1/docs/logo.webp -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import find_packages, setup 2 | 3 | setup( 4 | name="covert", 5 | author="Covert Encryption", 6 | author_email="covert-encryption@users.noreply.github.com", 7 | description="File and message encryption GUI and CLI", 8 | long_description=open("README.md").read(), 9 | long_description_content_type="text/markdown", 10 | url="https://github.com/covert-encryption/covert", 11 | use_scm_version=True, 12 | setup_requires=["setuptools_scm"], 13 | packages=find_packages(), 14 | python_requires=">=3.9", 15 | classifiers=[ 16 | "Programming Language :: Python :: 3", 17 | "License :: OSI Approved :: MIT License", 18 | "License :: Public Domain", 19 | "Operating System :: OS Independent", 20 | ], 21 | install_requires=[ 22 | "bcrypt>=3.0.0", 23 | "colorama>=0.4", 24 | "cryptography>=35", 25 | "pynacl>=1.5", 26 | "tqdm>=4.62", 27 | "msgpack>=1.0", 28 | "pyperclip>=1.8", 29 | "zxcvbn-covert>=5.0.1", 30 | "xdg>=5.1.1", 31 | ], 32 | extras_require={ 33 | "gui": ["pyside6>=6.2.1", "show-in-file-manager>=1.1.3"], 34 | "test": ["pytest", "pytest-sugar", "pytest-mock", "coverage", "mypy", "bandit"], 35 | "dev": ["tox", "isort", "yapf"], 36 | }, 37 | include_package_data=True, 38 | entry_points=dict( 39 | console_scripts=["covert = covert.cli.__main__:main"], 40 | gui_scripts=["qcovert = covert.gui.__main__:main [gui]"], 41 | ), 42 | ) 43 | -------------------------------------------------------------------------------- /tests/data/foo.txt: -------------------------------------------------------------------------------- 1 | test -------------------------------------------------------------------------------- /tests/keys/ageid-age1cghwz85tpv2eutkx8vflzjfa9f96wad6d8an45wcs3phzac2qdxq9dqg5p: -------------------------------------------------------------------------------- 1 | # created: 2021-11-13T01:53:58Z 2 | # public key: age1cghwz85tpv2eutkx8vflzjfa9f96wad6d8an45wcs3phzac2qdxq9dqg5p 3 | AGE-SECRET-KEY-1MG6YWWTK5MCU0NUNS57582CRQDAJFJPEUQYFZ3N87LVRE6TUFFNS95KNJV 4 | -------------------------------------------------------------------------------- /tests/keys/minisign.key: -------------------------------------------------------------------------------- 1 | untrusted comment: minisign encrypted secret key 2 | RWRTY0IyoLKtn4Dd60sUaiOfOBXpnDEoUzgMfXE/K1TmemxabGkAAAACAAAAAAAAAEAAAAAA2iIPBdUmUwMYYbBv0ro4Hyg7/QzT77Tf0Fqz2iHf4Stu/g0/q/bFnCQialPJdfFSjuCW6hinc3WSo2w7kku3sUQ4y7tjHJJEVHIyxFDp87du2lqNoQ2GMrFrpouhq5cK1q5lY8B1xKw= 3 | -------------------------------------------------------------------------------- /tests/keys/minisign.pub: -------------------------------------------------------------------------------- 1 | untrusted comment: minisign public key 37CF35D397A38268 2 | RWRogqOX0zXPN02KjQDo3oMuptJmZxob7BccHLY6VAFyi8wtbnj/MD43 3 | -------------------------------------------------------------------------------- /tests/keys/minisign_password.key: -------------------------------------------------------------------------------- 1 | untrusted comment: minisign encrypted secret key 2 | RWRTY0IyhY/6g2XLCLS4D+DoqjxeOJDUR9gv+ilrmp3/B4KpIaIAAAACAAAAAAAAAEAAAAAAF0HHgdqvvXFEO3QEYm11JrGdnfjZFAWeO38bLT98FUoDxGdcgvCdeCmmj8ZiHCHeTpfablIfRrEWEvjho5yyTciuN/mr6j0YAKfd3Ew9SkUDRY/t8qvvQz1bxKHdYwZRk1RChapZ32U= 3 | -------------------------------------------------------------------------------- /tests/keys/minisign_password.pub: -------------------------------------------------------------------------------- 1 | untrusted comment: minisign public key 8A1B111AFC8CA1A 2 | RWQaysivEbGhCD/XmdIesSX8kAROXUUSTp5M4ochKA+Ia0Iou0KgWyhr 3 | -------------------------------------------------------------------------------- /tests/keys/ssh_ed25519: -------------------------------------------------------------------------------- 1 | -----BEGIN OPENSSH PRIVATE KEY----- 2 | b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW 3 | QyNTUxOQAAACCcPu15ShVZJqoZJ9zj0fhMihZNZLsJLzLNj2Tdv63juQAAAJgILjawCC42 4 | sAAAAAtzc2gtZWQyNTUxOQAAACCcPu15ShVZJqoZJ9zj0fhMihZNZLsJLzLNj2Tdv63juQ 5 | AAAECz4i7zgeAAsMmLtpkF0IaMHoIWUXqnQHGS4nvkwUVPkZw+7XlKFVkmqhkn3OPR+EyK 6 | Fk1kuwkvMs2PZN2/reO5AAAAD3Rlc3Qta2V5QGNvdmVydAECAwQFBg== 7 | -----END OPENSSH PRIVATE KEY----- 8 | -------------------------------------------------------------------------------- /tests/keys/ssh_ed25519.pub: -------------------------------------------------------------------------------- 1 | ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJw+7XlKFVkmqhkn3OPR+EyKFk1kuwkvMs2PZN2/reO5 test-key@covert 2 | -------------------------------------------------------------------------------- /tests/keys/ssh_ed25519_password: -------------------------------------------------------------------------------- 1 | -----BEGIN OPENSSH PRIVATE KEY----- 2 | b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABA9WiG+kh 3 | HmWMnpoM3aBdW/AAAAEAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIGm43u4mRsuApxbf 4 | RXKlXz4pPPryLNQi8MLBUX4dd+FsAAAAoFni5Y5mcDVNPbNGvmLf3GFW/mj4BotTK/EQVD 5 | ReUyx2J9703EfYh2R8euCAUGxO5MKktwELpAEr3Dg3YUQo65JR1q+hYl4cDGQImKn4/Dpt 6 | 2yJds9CPYb9RJOm+v6+ZKNNj5ySw02RLOSyY2FJZTs6OAWTTEmES2XNeBcFP8+Hmm6arQ2 7 | wYth8fYZteGSRuf+hRmIz5wBdxiEEhMfhu85U= 8 | -----END OPENSSH PRIVATE KEY----- 9 | -------------------------------------------------------------------------------- /tests/keys/ssh_ed25519_password.pub: -------------------------------------------------------------------------------- 1 | ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGm43u4mRsuApxbfRXKlXz4pPPryLNQi8MLBUX4dd+Fs password-key@covert 2 | -------------------------------------------------------------------------------- /tests/test_archive.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO 2 | from math import exp 3 | 4 | import pytest 5 | 6 | from covert.archive import Archive, Stage 7 | from covert.blockstream import Block 8 | 9 | 10 | def test_encode_empty(): 11 | a = Archive() 12 | a.file_index([]) 13 | block = Block() 14 | a.encode(block) 15 | expected = b'\x80' # Empty dict 16 | assert block.data[:block.pos] == expected 17 | assert a.stage is Stage.END 18 | 19 | 20 | @pytest.mark.parametrize("text,expected", [ 21 | (b'', b'\x00'), 22 | (b'test', b'\x04test'), 23 | ]) 24 | def test_encode_message(text, expected): 25 | a = Archive() 26 | a.file_index([BytesIO(text)]) 27 | block = Block() 28 | a.encode(block) 29 | written = bytes(block.data[:block.pos]) 30 | assert written == expected 31 | assert a.stage is Stage.END 32 | 33 | 34 | def test_encode_file(): 35 | a = Archive() 36 | a.file_index(["tests/data/foo.txt"]) 37 | block = Block() 38 | a.encode(block) 39 | written = bytes(block.data[:block.pos]) 40 | # {f: [[4, "foo.txt", {}]]} + b'test' 41 | assert written == b"\x81\xA1f\x91\x93\x04\xA7foo.txt\x80test" 42 | assert a.stage is Stage.END 43 | 44 | 45 | def test_encode_files(): 46 | a = Archive() 47 | a.file_index(2 * ["tests/data/foo.txt"]) 48 | assert a.padding == 0 49 | block = Block() 50 | a.encode(block) 51 | written = bytes(block.data[:block.pos]) 52 | # {f: [[4, "foo.txt", {}], [4, "foo.txt", {}]]} + b'testtest' 53 | expected = b"\x81\xA1f\x92\x93\x04\xA7foo.txt\x80\x93\x04\xA7foo.txt\x80testtest" 54 | assert written == expected 55 | assert a.stage is Stage.END 56 | 57 | 58 | @pytest.mark.parametrize( 59 | "expected_out,archive", [ 60 | ([dict(f=[[0, None, {}]]), True, False], b'\x00'), 61 | ([dict(f=[[4, None, {}]]), True, b"test", False], b'\x04test'), 62 | ] 63 | ) 64 | def test_decode_message(expected_out, archive): 65 | a = Archive() 66 | blocks = [archive] 67 | out = [o for o in a.decode(blocks)] 68 | assert out == expected_out 69 | assert a.stage is Stage.END 70 | -------------------------------------------------------------------------------- /tests/test_armor.py: -------------------------------------------------------------------------------- 1 | from secrets import token_bytes 2 | 3 | import pytest 4 | 5 | from covert.util import armor_decode, armor_encode 6 | 7 | 8 | def test_armor_valid(): 9 | data = token_bytes(10000) 10 | for i in [10000, 9999, 9998, 9000, 5000] + list(range(100)): 11 | d = data[i:] 12 | text = armor_encode(d) 13 | binary = armor_decode('\n\n >>> ```\n' + text.replace('\n', ' \r\n\t>>> ') + '>>> ```\n>>>\n') 14 | assert binary == d 15 | 16 | 17 | def test_armor_decode_invalid(): 18 | valid_line = 76*'A' + '\n' 19 | valid_out = bytes(57) 20 | assert armor_decode(valid_line) == valid_out 21 | 22 | with pytest.raises(ValueError) as exc: 23 | armor_decode('\x80') 24 | assert "ASCII" in str(exc.value) 25 | 26 | with pytest.raises(ValueError) as exc: 27 | armor_decode('!') 28 | assert "unrecognized data on line 1" in str(exc.value) 29 | 30 | # Minimum length for all but the last line is 76 31 | with pytest.raises(ValueError) as exc: 32 | armor_decode(valid_line[4:] + valid_line) 33 | assert "length 72 of line 1" in str(exc.value) 34 | 35 | # Lines must have equal length 36 | with pytest.raises(ValueError) as exc: 37 | armor_decode('AAAA' + valid_line + valid_line + valid_line) 38 | assert "length 76 of line 2" in str(exc.value) 39 | 40 | # Lines must be divisible by four 41 | with pytest.raises(ValueError) as exc: 42 | armor_decode('A' + valid_line + valid_line) 43 | assert "length 77 of line 1" in str(exc.value) 44 | -------------------------------------------------------------------------------- /tests/test_blockstream.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO 2 | from secrets import token_bytes 3 | from time import sleep 4 | 5 | import pytest 6 | 7 | from covert.archive import Archive 8 | from covert.blockstream import BS, decrypt_file, encrypt_file 9 | 10 | AUTH = False, [b'justfakepasshash'], [], [] 11 | AUTH_DEC = [b'justfakepasshash'] 12 | 13 | 14 | @pytest.mark.parametrize( 15 | "datasizes, ciphersizes", [ 16 | ([1], [12, 20]), 17 | ([10, BS], [12, 29, BS + 19]), 18 | ([None, 512, BS, 1], [12, 1024 - 12, 512 + 19, BS + 19, 20]), 19 | ] 20 | ) 21 | def test_consume_varying_block_sizes(datasizes, ciphersizes): 22 | """Tests the ability of the encrypter to format correctly sized blocks.""" 23 | 24 | def blockinput(block): 25 | try: 26 | n = next(num) or block.spaceleft 27 | data = block.consume(bytes(n)) 28 | assert not data 29 | except StopIteration: 30 | pass 31 | 32 | a = Archive() 33 | e = encrypt_file(AUTH, blockinput, a) 34 | num = iter(datasizes) 35 | for cipherblock, expected_length in zip(e, ciphersizes): 36 | assert len(cipherblock) == expected_length 37 | with pytest.raises(StopIteration): 38 | next(e) 39 | 40 | 41 | @pytest.mark.skip(reason="Implementation of minimal delays slowed down execution too much.") 42 | @pytest.mark.parametrize( 43 | "values, expected_seq", [ 44 | ([(20, False), (21, False), (22, False)], [12, -20, -21, 20, -22, 21, 22]), 45 | ([(20, 21), (21, 22), (22, False)], [12, -20, 20, -21, 21, -22, 22]), 46 | ] 47 | ) 48 | def test_latencies(values, expected_seq): 49 | """Tests the ability to forward blocks as soon as the nextlen is known.""" 50 | 51 | def blockinput(block): 52 | try: 53 | # Wait a little to allow the other threads be faster 54 | sleep(0.1) 55 | v = next(values) 56 | block.pos = v[0] - 19 57 | if v[1]: 58 | block.nextlen = v[1] - 19 59 | seq.append(-v[0]) 60 | except StopIteration: 61 | pass 62 | 63 | values = iter(values) 64 | seq = [] 65 | e = encrypt_file(AUTH, blockinput) 66 | for cipherblock in e: 67 | seq.append(len(cipherblock)) 68 | assert seq == expected_seq 69 | with pytest.raises(StopIteration): 70 | next(e) 71 | 72 | 73 | @pytest.mark.parametrize("size", [1, 1100, 5000, 20 << 20]) 74 | def test_encrypt_decrypt(size): 75 | """Verify that the blockstream level encrypt-decrypt cycle works as intended.""" 76 | 77 | def blockinput(block): 78 | block.pos = inf.readinto(block.data) 79 | 80 | plaintext = token_bytes(size) 81 | inf = BytesIO(plaintext) 82 | a = Archive() 83 | ciphertext = b"".join(encrypt_file(AUTH, blockinput, a)) 84 | 85 | lenplain = len(plaintext) 86 | lencipher = len(ciphertext) 87 | calculatedcipher = 12 + 19 + lenplain + (lenplain - (1024-12-19) + BS - 1) // BS * 19 88 | assert lencipher == calculatedcipher 89 | f = BytesIO(ciphertext) 90 | a = Archive() 91 | plainout = b"".join(decrypt_file(AUTH_DEC, f, a)) 92 | assert plainout == plaintext 93 | 94 | 95 | def test_data_corruption(): 96 | def blockinput(block): 97 | block.pos = inf.readinto(block.data) 98 | 99 | plaintext = token_bytes(1100) 100 | inf = BytesIO(plaintext) 101 | a = Archive() 102 | ciphertext = bytearray().join(encrypt_file(AUTH, blockinput, a)) 103 | ciphertext[-50] ^= 1 # Flip a bit 104 | f = BytesIO(ciphertext) 105 | a = Archive() 106 | with pytest.raises(ValueError) as e: 107 | plainout = b"".join(decrypt_file(AUTH_DEC, f, a)) 108 | assert str(e.value) == "Data corruption: Failed to decrypt ciphertext block of 126 bytes" 109 | -------------------------------------------------------------------------------- /tests/test_chacha.py: -------------------------------------------------------------------------------- 1 | from secrets import token_bytes 2 | 3 | import pytest 4 | from covert.exceptions import DecryptError 5 | 6 | from covert import chacha 7 | 8 | 9 | def test_inplace(): 10 | """Encrypt and decrypt various block sizes so that the source and the destination are the same buffer.""" 11 | nonce = token_bytes(12) 12 | key = token_bytes(32) 13 | for N in range(512): 14 | buf = memoryview(bytearray(token_bytes(N + 16))) 15 | orig = bytes(buf) 16 | ret = chacha.encrypt_into(buf, buf[:N], None, nonce, key) 17 | assert ret == 0 18 | tag = buf[N:] 19 | ret = chacha.decrypt_into(buf[:N], buf, None, nonce, key) 20 | assert ret == 0 21 | assert buf[:N] == orig[:N] 22 | assert buf[N:] == tag 23 | 24 | 25 | def test_simple(): 26 | nonce = token_bytes(12) 27 | key = token_bytes(32) 28 | ct = chacha.encrypt(b'testing', None, nonce, key) 29 | pt = chacha.decrypt(ct, None, nonce, key) 30 | assert pt == b'testing' 31 | 32 | with pytest.raises(DecryptError): 33 | chacha.decrypt(bytes(64), b'foo', nonce, key) 34 | -------------------------------------------------------------------------------- /tests/test_elliptic.py: -------------------------------------------------------------------------------- 1 | from secrets import token_bytes 2 | 3 | import nacl.bindings as sodium 4 | import pytest 5 | 6 | from covert.elliptic import * 7 | 8 | 9 | def test_fe(): 10 | assert one + zero == one 11 | assert zero - one == minus1 12 | assert fe(1234) / fe(324123) == (fe(324123) / fe(1234)).inv 13 | assert sqrtm1 * sqrtm1 == -one 14 | assert repr(fe(1234)) == "fe(1234)" 15 | assert repr(fe(-1)) == "minus1" 16 | assert bytes(zero) == bytes(32) 17 | assert str(one) == "01" + 31 * "00" 18 | 19 | x = fe(toint(token_bytes(32))) 20 | assert x.sq.sqrt == abs(x) 21 | assert x.inv.inv == x 22 | assert x**3 == x * x * x 23 | assert x * fe(2) == x + x 24 | assert x * fe(2) != x 25 | 26 | with pytest.raises(ValueError): 27 | fe(2).sqrt 28 | 29 | 30 | def test_ed(): 31 | assert G == EdPoint.from_montbytes((9).to_bytes(32, "little")) 32 | 33 | assert repr(ZERO) == "ZERO" # EdPoint(zero, one, one, zero) 34 | assert str(ZERO) == "01" + 31 * "00" 35 | 36 | edpk, edsk = sodium.crypto_sign_keypair() 37 | k = secret_scalar(edsk) 38 | K = k * G 39 | assert bytes(K).hex() == edpk.hex() 40 | 41 | def test_mont(): 42 | assert mont.scalarmult(0, D.mont) == ZERO.mont 43 | assert mont.scalarmult(1, D.mont) == D.mont 44 | 45 | # Low order points 46 | Lmont = [mont.scalarmult(s, L) for s in range(8)] 47 | Lexpected = [ZERO.mont, L.mont, LO[2].mont, LO[3].mont, LO[4].mont, LO[3].mont, LO[2].mont, LO[1].mont] 48 | assert Lmont == Lexpected 49 | 50 | Led = [EdPoint.from_mont(mont.scalarmult(s, L), s >= 4) for s in range(8)] 51 | assert Led == LO 52 | 53 | # Very special low order points 54 | assert mont.scalarmult(11, ZERO) == ZERO.mont 55 | assert mont.scalarmult(3, LO[4]) == LO[4].mont 56 | assert mont.scalarmult(4, LO[4]) == ZERO.mont 57 | 58 | # Any point times 8q should be point at infinity (ZERO) 59 | assert mont.scalarmult(4 * q, 2 * D) == ZERO.mont 60 | 61 | # Test v coordinate recovery 62 | assert mont.v(fe(9)) == fe(14781619447589544791020593568409986887264606134616475288964881837755586237401) 63 | 64 | with pytest.raises(ValueError) as exc: 65 | mont.v(fe(2)) 66 | assert "not a valid point" in str(exc.value) 67 | 68 | with pytest.raises(ValueError) as exc: 69 | mont.v(ZERO.mont) 70 | assert "point at infinity" in str(exc.value) 71 | 72 | 73 | def test_hashmap(): 74 | # Just hitting the __hash__ functions 75 | assert len({fe(i * p) for i in range(2)}) == 1 76 | assert len({i * L for i in range(10)}) == 8 77 | 78 | def test_lo(): 79 | # Dirty generator 80 | assert 2 * D == 2 * G + 2 * L 81 | assert 8 * G == 8 * D 82 | assert 12 * G != 12 * D 83 | 84 | # Testing properties 85 | assert ZERO.is_low_order 86 | assert ZERO.subgroup == 0 87 | assert not ZERO.is_prime_group 88 | 89 | assert G.is_prime_group 90 | assert not G.is_low_order 91 | assert G.subgroup == 0 92 | 93 | assert not L.is_prime_group 94 | assert L.is_low_order 95 | assert L.subgroup == 1 96 | 97 | assert not D.is_prime_group 98 | assert not D.is_low_order 99 | assert D.subgroup == 1 100 | 101 | # Low order points 102 | assert LO[0] == ZERO 103 | assert LO[1] == L 104 | assert repr(LO[0]) == "ZERO" 105 | assert repr(LO[1]) == "L" 106 | assert repr(LO[2]) == "LO[2]" 107 | for i, P in enumerate(LO): 108 | assert 8 * P == ZERO 109 | assert P.is_low_order 110 | assert not P.is_prime_group 111 | assert P.subgroup == i 112 | 113 | s = secret_scalar(token_bytes(32)) 114 | Q = s * G + P 115 | assert not Q.is_low_order 116 | assert Q.subgroup == i 117 | 118 | # Dirty point generation 119 | s = toint(token_bytes(32)) % (8 * q) 120 | P = s * G 121 | Q = s * D 122 | assert Q.subgroup == s % 8 123 | assert Q == P + LO[Q.subgroup] 124 | 125 | 126 | def test_edpk_vs_sodium(): 127 | edpk, edsk = sodium.crypto_sign_keypair() 128 | 129 | k = secret_scalar(edsk) 130 | K = k * G 131 | edpk2 = bytes(K) 132 | assert edpk2.hex() == edpk.hex() 133 | 134 | def test_mont_vs_sodium(): 135 | edpk, edsk = sodium.crypto_sign_keypair() 136 | sk = sodium.crypto_sign_ed25519_sk_to_curve25519(edsk) 137 | pk = sodium.crypto_sign_ed25519_pk_to_curve25519(edpk) # Note: the sign is lost (high bit random) 138 | assert pk[31] & 0x80 == 0 139 | # Mont secret key is just the clamped scalar 140 | k = secret_scalar(edsk) 141 | assert tobytes(k).hex() == sk.hex() 142 | # Public key converted from edsk 143 | K = k * G 144 | pkconv = K.montbytes # sign always 0 to match sodium 145 | assert pk.hex() in pkconv.hex() 146 | # Public key converted from montpk 147 | K2 = EdPoint.from_montbytes(pk) 148 | pkconv2 = K2.montbytes_sign 149 | assert abs(K) == K2 # K2 from sodium is always positive 150 | assert pkconv2.hex() == pk.hex() 151 | 152 | 153 | def test_sign_eddsa(): 154 | """Test signatures using standard Ed25519""" 155 | msg1 = b"test message" 156 | msg2 = b"Test message" 157 | edpk, edsk = sodium.crypto_sign_keypair() 158 | sig1 = ed_sign(edsk, msg1) 159 | sig2 = ed_sign(edsk, msg2) 160 | assert len(sig1) == 64 161 | assert sig1 != sig2 162 | ed_verify(edpk, msg1, sig1) 163 | ed_verify(edpk, msg2, sig2) 164 | with pytest.raises(ValueError): 165 | ed_verify(edpk, msg2, sig1) 166 | with pytest.raises(ValueError): 167 | ed_verify(edpk, msg1, sig2) 168 | 169 | 170 | def test_sign_xeddsa(): 171 | """Test signatures using Signal's XEd25519 scheme""" 172 | msg1 = b"test message" 173 | msg2 = b"Test message" 174 | nonce = token_bytes(64) 175 | # Using only Curve25519 keys for this 176 | pk, sk = sodium.crypto_box_keypair() 177 | sig1 = xed_sign(sk, msg1, nonce) 178 | sig2 = xed_sign(sk, msg2, nonce) 179 | assert len(sig1) == 64 180 | assert sig1 != sig2 181 | # Valid signatures 182 | xed_verify(pk, msg1, sig1) 183 | xed_verify(pk, msg2, sig2) 184 | 185 | # Invalid signatures 186 | with pytest.raises(ValueError) as exc: 187 | xed_verify(pk, msg2, sig1) 188 | assert "Signature mismatch" == str(exc.value) 189 | 190 | with pytest.raises(ValueError) as exc: 191 | xed_verify(pk, msg1, sig2) 192 | assert "Signature mismatch" == str(exc.value) 193 | 194 | # Errors 195 | with pytest.raises(ValueError) as exc: 196 | xed_verify(pk, msg1, b"") 197 | assert "Invalid signature length" == str(exc.value) 198 | 199 | for P in LO[1:]: # Test LO points noting that they cause different exceptions 200 | with pytest.raises(ValueError) as exc: 201 | xed_verify(P.montbytes_sign, msg1, sig1) 202 | assert "Invalid public key provided" == str(exc.value) 203 | 204 | with pytest.raises(ValueError) as exc: 205 | xed_verify(pk, msg1, P.montbytes_sign + sig1[32:]) 206 | assert "Invalid R point on signature" == str(exc.value) 207 | 208 | with pytest.raises(ValueError) as exc: 209 | xed_verify(pk, msg1, sig1[:32] + tobytes(q)) 210 | assert "Invalid s value on signature" == str(exc.value) 211 | 212 | def test_elligator_highlevel(): 213 | subgroups = set() 214 | 215 | for i in range(10): 216 | hidden, edsk = egcreate() 217 | assert eghide(edsk) == hidden 218 | 219 | # "curve25519 sk" conversion is really sha + clamp to get ed25519 scalar 220 | sk = sodium.crypto_sign_ed25519_sk_to_curve25519(edsk + bytes(32)) # + all zeroes bogus edpk 221 | edpk = sodium.crypto_scalarmult_ed25519_base(sk) # ... so that we can calculate the edpk 222 | pk = sodium.crypto_sign_ed25519_pk_to_curve25519(edpk) 223 | 224 | # Can we restore the point? 225 | P = egreveal(hidden) # restored dirty point 226 | P2 = secret_scalar(edsk) * G # clean point from original secret 227 | assert P.undirty == P2 228 | 229 | # Convert the restored point to Ed/Mont 230 | edpk2 = bytes(P.undirty) 231 | pk2 = P.undirty.montbytes 232 | assert edpk2.hex() == edpk.hex() 233 | assert pk2.hex() == pk.hex() 234 | 235 | # Test ECDH protocol (using the dirty point) 236 | rpk, rsk = sodium.crypto_box_keypair() # Recipient keypair 237 | shared1 = sodium.crypto_scalarmult(sk, rpk) 238 | shared2 = sodium.crypto_scalarmult(rsk, pk2) # Using elligatored pk2 239 | assert shared1.hex() == shared2.hex() 240 | 241 | assert P.undirty == EdPoint.from_bytes(edpk) 242 | assert bytes(P.undirty).hex() == edpk.hex() 243 | 244 | # Keep track of the subgroups seen! 245 | subgroups.add(P.subgroup) 246 | if len(subgroups) > 2: break 247 | 248 | # Verify that we saw multiple subgroups 249 | assert len(subgroups) > 1, f"Should have found several but got {subgroups=}" 250 | 251 | def test_non_elligator_key(): 252 | with pytest.raises(ValueError) as exc: 253 | eghide(tobytes(5)) # edsk chosen by trial and error so that the pk is not good for elligator 254 | assert "The key cannot be Elligator hashed" == str(exc.value) 255 | -------------------------------------------------------------------------------- /tests/test_passphrase.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from covert import passphrase, util 4 | from covert.wordlist import words 5 | 6 | 7 | def test_no_shared_prefixes(): 8 | w = list(sorted(words)) 9 | for i in range(len(w) - 1): 10 | w1, w2 = w[i + 1], w[i] 11 | assert not w1.startswith(w2), f"{w1!r} starts with {w2!r}" 12 | 13 | 14 | def test_generate(): 15 | pw1 = passphrase.generate() 16 | pw2 = passphrase.generate() 17 | assert pw1 != pw2 18 | assert passphrase.generate(8, "-").count('-') == 7 19 | # This should randomly hit the regeneration because of weak password 20 | for i in range(10): 21 | passphrase.generate(1) 22 | passphrase.generate(2) 23 | passphrase.generate(3) 24 | 25 | 26 | def test_costfactor(): 27 | assert passphrase.costfactor(b"xxxxxxxx") == 16 28 | assert passphrase.costfactor(b"xxxxxxxxA") == 8 29 | assert passphrase.costfactor(b"xxxxxxxxAA") == 4 30 | assert passphrase.costfactor(b"xxxxxxxxAAA") == 2 31 | assert passphrase.costfactor(b"xxxxxxxxAAAA") == 1 32 | assert passphrase.costfactor(b"xxxxxxxxAAAAA") == 1 33 | 34 | 35 | def test_pwhash_and_authkey(): 36 | with pytest.raises(ValueError): 37 | passphrase.pwhash(b"short") 38 | 39 | pwh = passphrase.pwhash(b"xxxxxxxxAAAA") 40 | assert len(pwh) == 16 41 | assert pwh.hex() == "dbc27f84f3f3747826801c68e3e8aa1b" # Calculated in browser 42 | 43 | authkey = passphrase.authkey(pwh, b"faketestsalt") 44 | assert len(authkey) == 32 45 | assert authkey.hex() == "a8586c8811ab565a2f30ad876305ebecfc93a3302dd3a3ba2ac83c07a961b9c8" 46 | 47 | with pytest.raises(Exception) as e: 48 | passphrase.authkey(bytes(16), bytes(16)) 49 | assert "Invalid arguments pwhash" in str(e.value) 50 | 51 | with pytest.raises(Exception) as e: 52 | passphrase.authkey(bytes(12), bytes(12)) 53 | assert "Invalid arguments pwhash" in str(e.value) 54 | 55 | def test_autocomplete(): 56 | assert passphrase.autocomplete("", 0) == ("", 0, "enter a few letters of a word first") 57 | assert passphrase.autocomplete("peaceangle", 5) == ("peaceangle", 5, "enter a few letters of a word first") 58 | assert passphrase.autocomplete("ang", 3) == ("angle", 5, "") 59 | assert passphrase.autocomplete("peaangle", 3) == ("peaceangle", 5, "") 60 | assert passphrase.autocomplete("peaceangleol", 12) == ("peaceangleol", 12, "…d …ive") 61 | assert passphrase.autocomplete("peaceangleoli", 13) == ("peaceangleolive", 15, "") 62 | assert passphrase.autocomplete("peacexxx", 8) == ("peacexxx", 8, "no matches") 63 | assert passphrase.autocomplete("a", 1) == ("a", 1, "too many matches") 64 | 65 | 66 | def test_pwhints(): 67 | out, valid = passphrase.pwhints("") 68 | assert not valid 69 | assert "Choose a passphrase you don't use elsewhere." in out 70 | 71 | out, valid = passphrase.pwhints("abcabcabcabc") 72 | assert not valid 73 | assert 'Repeats like "abcabcabc" are only slightly harder to guess than "abc".' in out 74 | 75 | out, valid = passphrase.pwhints("ridiculouslylongpasswordthatwecannotletzxcvbncheckbecauseitbecomestooslow") 76 | assert valid 77 | assert 'centuries' in out 78 | assert 'Seems long enough' in out 79 | 80 | out, valid = passphrase.pwhints("quitelegitlongpwd") 81 | assert valid 82 | assert 'fastest hashing' in out 83 | 84 | out, valid = passphrase.pwhints("faketest") 85 | assert valid 86 | assert '16 times faster' in out 87 | 88 | 89 | def test_normalization(): 90 | """Unicode may be written in many ways that must lead to the same password""" 91 | win = '\uFEFF\u1E69' # BOM + composed (NFC) 92 | mac = '\u0073\u0323\u0307' # Decomposed (NFD) 93 | src = 'ṩ' # Different order decomposed (and possibly mutated in transit of source code) 94 | assert win != mac 95 | assert mac != src 96 | assert src != win 97 | 98 | assert util.encode(win) == b'\xe1\xb9\xa9' 99 | assert util.encode(mac) == b'\xe1\xb9\xa9' 100 | assert util.encode(src) == b'\xe1\xb9\xa9' 101 | 102 | 103 | def test_pw_length(): 104 | with pytest.raises(ValueError) as exc: 105 | passphrase.pwhash(b'a') 106 | assert "Too short" in str(exc.value) 107 | 108 | with pytest.raises(Exception) as exc: 109 | passphrase.authkey(b'a', b'a') 110 | assert "Invalid arguments" in str(exc.value) 111 | -------------------------------------------------------------------------------- /tests/test_pubkey.py: -------------------------------------------------------------------------------- 1 | from hashlib import sha512 2 | from secrets import token_bytes 3 | 4 | import nacl.bindings as sodium 5 | 6 | from covert import pubkey 7 | from covert.exceptions import MalformedKeyError 8 | import pytest 9 | 10 | 11 | # Test vectors from https://age-encryption.org/v1 12 | AGE_PK = "age1zvkyg2lqzraa2lnjvqej32nkuu0ues2s82hzrye869xeexvn73equnujwj" 13 | AGE_SK = "AGE-SECRET-KEY-1GFPYYSJZGFPYYSJZGFPYYSJZGFPYYSJZGFPYYSJZGFPYYSJZGFPQ4EGAEX" 14 | AGE_SK_BYTES = 32 * b"\x42" 15 | 16 | # Generated with wg genkey and wg pubkey 17 | WG_SK = "kLkIpWh5MYKwUA7JdQHnmbc6dEiW0py4VRvqmYyPLHc=" 18 | WG_PK = "ElMfFd2qVIROK4mRaXJouYWC2lxxMApMSe9KyAZcEBc=" 19 | 20 | 21 | def test_age_key_decoding(): 22 | pk = pubkey.decode_pk(AGE_PK) 23 | sk = pubkey.decode_sk(AGE_SK) 24 | # Key comparison is by public keys 25 | assert pk == sk 26 | assert pk.keystr == AGE_PK 27 | assert sk.keystr == AGE_SK 28 | assert pk.comment == 'age' 29 | assert sk.comment == 'age' 30 | assert repr(pk).endswith(':PK]') 31 | assert repr(sk).endswith(':SK]') 32 | 33 | 34 | def test_age_key_decoding_and_encoding(): 35 | pk = pubkey.decode_age_pk(AGE_PK) 36 | sk = pubkey.decode_age_sk(AGE_SK) 37 | assert pk == pubkey.decode_pk(AGE_PK) 38 | assert sk == pubkey.decode_sk(AGE_SK) 39 | assert pubkey.encode_age_pk(pk) == AGE_PK 40 | assert pubkey.encode_age_pk(sk) == AGE_PK 41 | assert pubkey.encode_age_sk(sk) == AGE_SK 42 | 43 | 44 | def test_wireguard_keystr(): 45 | pk = pubkey.decode_pk(WG_PK) 46 | sk = pubkey.decode_sk(WG_SK) 47 | # Key comparison is by public keys 48 | assert pk == sk 49 | assert pk.keystr == WG_PK 50 | assert sk.keystr == WG_SK 51 | assert pk.comment == 'wg' 52 | assert sk.comment == 'wg' 53 | assert repr(pk).endswith(':PK]') 54 | assert repr(sk).endswith(':SK]') 55 | 56 | # Trying to decode a public key as secret key should usually fail 57 | # (works with the test key but no guarantees with others) 58 | with pytest.raises(MalformedKeyError) as exc: 59 | pubkey.decode_sk(WG_PK) 60 | assert "Unable to parse secret key" in str(exc.value) 61 | 62 | 63 | def test_ssh_key_decoding(): 64 | pk, = pubkey.read_pk_file("tests/keys/ssh_ed25519.pub") 65 | sk, = pubkey.read_sk_file("tests/keys/ssh_ed25519") 66 | assert pk.comment == "test-key@covert" 67 | assert sk.comment == "test-key@covert" 68 | assert pk == sk 69 | 70 | 71 | def test_file_not_found(): 72 | with pytest.raises(ValueError) as exc: 73 | pk, = pubkey.read_pk_file("tests/keys/non-existent-file.pub") 74 | assert "Keyfile" in str(exc.value) 75 | 76 | with pytest.raises(ValueError) as exc: 77 | sk, = pubkey.read_sk_file("tests/keys/non-existent-file") 78 | assert "Secret key file" in str(exc.value) 79 | 80 | 81 | def test_ssh_pw_keyfile(mocker): 82 | mocker.patch('covert.passphrase.ask', return_value=(b"password", True)) 83 | sk, = pubkey.read_sk_file("tests/keys/ssh_ed25519_password") 84 | assert sk.comment == "password-key@covert" 85 | 86 | 87 | def test_ssh_wrong_password(mocker): 88 | mocker.patch('covert.passphrase.ask', return_value=(b"not this password", True)) 89 | with pytest.raises(ValueError): 90 | sk, = pubkey.read_sk_file("tests/keys/ssh_ed25519_password") 91 | 92 | 93 | def test_minisign_keyfiles(mocker): 94 | mocker.patch('covert.passphrase.ask', return_value=(b"password", True)) 95 | sk, = pubkey.read_sk_file("tests/keys/minisign_password.key") 96 | pk, = pubkey.read_pk_file("tests/keys/minisign_password.pub") 97 | assert sk.comment == 'ms' 98 | assert pk.comment == 'ms' 99 | assert sk == pk 100 | 101 | 102 | def test_key_exchange(): 103 | # Alice sends a message to Bob 104 | nonce = token_bytes(12) 105 | eph_pk, eph_sk = sodium.crypto_kx_keypair() 106 | assert len(eph_pk) == 32 107 | assert len(eph_sk) == 32 108 | bob = pubkey.Key() 109 | eph = pubkey.Key(sk=eph_sk) 110 | alice_key = pubkey.derive_symkey(nonce, eph, bob) 111 | # Bob receives the message (including nonce and eph_pk) 112 | eph = pubkey.Key(pk=eph_pk) 113 | bob_key = pubkey.derive_symkey(nonce, bob, eph) 114 | assert alice_key == bob_key 115 | -------------------------------------------------------------------------------- /tests/test_ratchet.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | from secrets import token_bytes 3 | 4 | import pytest 5 | 6 | from covert.idstore import remove_expired 7 | from covert.pubkey import Key 8 | from covert.ratchet import Ratchet 9 | from covert.exceptions import DecryptError 10 | 11 | 12 | def test_ratchet_pubkey(): 13 | alice = Key() 14 | bob = Key() 15 | a = Ratchet() 16 | shared = token_bytes() 17 | a.peerkey = bob 18 | a.prepare_alice(shared, alice) 19 | 20 | b = Ratchet() 21 | b.init_bob(shared, bob, alice) 22 | 23 | header1, mkb = b.send() 24 | mka = a.receive(header1) 25 | 26 | assert mka == mkb 27 | assert b.s.N == 1 28 | assert a.r.N == 1 29 | assert b.s.HK 30 | assert b.s.HK == a.r.HK 31 | 32 | header2, mkb2 = b.send() 33 | assert mkb2 != mkb 34 | 35 | header3, mkb3 = b.send() 36 | assert mkb3 != mkb2 37 | 38 | # Receive out of order 39 | mka3 = a.receive(header3) 40 | assert mka3 == mkb3 41 | 42 | mka2 = a.receive(header2) 43 | assert mka2 == mkb2 44 | 45 | # Send and receive on current chain (no roundtrip) 46 | header4, mkb4 = b.send() 47 | header5, mkb5 = b.send() 48 | header6, mkb6 = b.send() 49 | mka5 = a.receive(header5) 50 | assert mka5 == mkb5 51 | mka6 = a.receive(header6) 52 | assert mka6 == mkb6 53 | 54 | 55 | def test_ratchet_lost_messages(): 56 | alice = Key() 57 | bob = Key() 58 | a = Ratchet() 59 | shared = [token_bytes(32) for i in range(3)] 60 | a.peerkey = bob 61 | a.prepare_alice(shared[0], alice) 62 | a.prepare_alice(shared[1], alice) 63 | a.prepare_alice(shared[2], alice) 64 | assert a.s.N == 3 65 | assert a.pre == shared 66 | 67 | b = Ratchet() 68 | b.init_bob(shared[1], bob, alice) 69 | 70 | header1, mkb1 = b.send() 71 | header2, mkb2 = b.send() 72 | header3, mkb3 = b.send() 73 | 74 | assert b.s.HK in a.pre 75 | assert b.s.N == 3 76 | 77 | mka2 = a.receive(header2) 78 | 79 | assert mka2 == mkb2 80 | assert a.r.N == 2 81 | 82 | header4, mkb4 = b.send() 83 | assert b.s.N == 4 84 | 85 | mka4 = a.receive(header4) 86 | assert mka4 == mkb4 87 | assert a.r.N == 4 88 | 89 | # Receive out of order 90 | mka3 = a.receive(header3) 91 | assert mka3 == mkb3 92 | 93 | # Receive out of order 94 | mka1 = a.receive(header1) 95 | assert mka1 == mkb1 96 | 97 | # Fail to decode own message 98 | with pytest.raises(DecryptError): 99 | b.receive(header1) 100 | 101 | 102 | def test_expiration(mocker): 103 | soon = 600 104 | later = 86400 * 28 105 | mocker.patch("time.time", return_value=1e9) 106 | r = Ratchet() 107 | assert r.e == 1_000_000_000 + later 108 | 109 | r.init_bob(bytes(32), Key(), Key()) 110 | r.readmsg() 111 | assert r.msg[0]["e"] == 1_000_000_000 + soon 112 | 113 | ids = { 114 | "id:alice": {"I": bytes(32)}, 115 | "id:alice:bob": { 116 | "i": bytes(32), 117 | "e": 2_000_000_000, 118 | "r": r.store(), 119 | }, 120 | } 121 | 122 | ids2 = deepcopy(ids) 123 | remove_expired(ids2) 124 | assert ids == ids2 125 | 126 | mocker.patch("time.time", return_value=1e9 + soon + 1) 127 | ids2 = deepcopy(ids) 128 | remove_expired(ids2) 129 | assert not ids2["id:alice:bob"]["r"]["msg"] 130 | 131 | mocker.patch("time.time", return_value=1e9 + later + 1) 132 | ids2 = deepcopy(ids) 133 | remove_expired(ids2) 134 | assert not "r" in ids2["id:alice:bob"] 135 | 136 | mocker.patch("time.time", return_value=2e9 + 1) 137 | ids2 = deepcopy(ids) 138 | remove_expired(ids2) 139 | assert not "id:alice:bob" in ids2 140 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = clean, py39, py310, benchmark, coverage, security, type-checking 3 | 4 | [coverage:run] 5 | include = covert/*.py 6 | branch = true 7 | 8 | [testenv:clean] 9 | whitelist_externals = rm 10 | commands = 11 | rm -f .coverage 12 | 13 | [testenv] 14 | usedevelop = true 15 | extras = test 16 | setenv = 17 | HOME = {envtmpdir} 18 | XDG_CONFIG_HOME = {envtmpdir}/confhome 19 | commands = 20 | coverage run --append -m pytest {posargs:tests} 21 | 22 | [testenv:benchmark] 23 | usedevelop = true 24 | extras = test 25 | commands = 26 | coverage run --append -m covert benchmark 27 | 28 | 29 | [testenv:coverage] 30 | commands = 31 | coverage report -i 32 | coverage html -i 33 | coverage xml -i 34 | 35 | [testenv:type-checking] 36 | commands = 37 | mypy covert --exclude covert/gui/ --ignore-missing-imports 38 | 39 | [testenv:security] 40 | commands = 41 | bandit --recursive covert --skip B101,B404 42 | --------------------------------------------------------------------------------