├── .github └── workflows │ └── tests-and-coverage.yml ├── .gitignore ├── README.md ├── covert ├── __init__.py ├── __main__.py ├── archive.py ├── bech.py ├── blockstream.py ├── chacha.py ├── cli │ ├── __init__.py │ ├── __main__.py │ ├── args.py │ ├── bench.py │ ├── dec.py │ ├── edit.py │ ├── enc.py │ ├── help.py │ ├── id.py │ ├── textedit.py │ └── tty.py ├── cryptoheader.py ├── elliptic │ ├── __init__.py │ ├── ed.py │ ├── eddsa.py │ ├── elligator.py │ ├── mont.py │ ├── scalar.py │ ├── util.py │ └── xeddsa.py ├── exceptions.py ├── gui │ ├── __init__.py │ ├── __main__.py │ ├── app.py │ ├── data │ │ ├── emoji-dissatisfied.png │ │ ├── emoji-grin.png │ │ ├── emoji-neutral.png │ │ ├── emoji-smiling.png │ │ ├── emoji-think.png │ │ ├── icons8-attach-48.png │ │ ├── icons8-cipherfile-64.png │ │ ├── icons8-copy-48.png │ │ ├── icons8-copy-64.png │ │ ├── icons8-file-64.png │ │ ├── icons8-folder-48.png │ │ ├── icons8-key-48.png │ │ ├── icons8-link-48.png │ │ ├── icons8-locked-48.png │ │ ├── icons8-new-document-48.png │ │ ├── icons8-paste-48.png │ │ ├── icons8-save-48.png │ │ ├── icons8-signing-a-document-48.png │ │ ├── icons8-unlocked-48.png │ │ └── logo.png │ ├── decrypt.py │ ├── encrypt.py │ ├── res.py │ ├── util.py │ └── widgets.py ├── idstore.py ├── lazyexec.py ├── passphrase.py ├── path.py ├── pubkey.py ├── ratchet.py ├── sshkey.py ├── typing.py ├── util.py └── wordlist.py ├── docs ├── Rationale.md ├── SECURITY.md ├── Specification.md ├── benchmark.webp ├── covert-gui.webp ├── distribution.png ├── distribution.webp ├── in-out.png ├── in-out.webp └── logo.webp ├── setup.py ├── tests ├── data │ └── foo.txt ├── keys │ ├── ageid-age1cghwz85tpv2eutkx8vflzjfa9f96wad6d8an45wcs3phzac2qdxq9dqg5p │ ├── minisign.key │ ├── minisign.pub │ ├── minisign_password.key │ ├── minisign_password.pub │ ├── ssh_ed25519 │ ├── ssh_ed25519.pub │ ├── ssh_ed25519_password │ └── ssh_ed25519_password.pub ├── test_archive.py ├── test_armor.py ├── test_blockstream.py ├── test_chacha.py ├── test_cli.py ├── test_elliptic.py ├── test_passphrase.py ├── test_pubkey.py └── test_ratchet.py └── tox.ini /.github/workflows/tests-and-coverage.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: 3 | push: 4 | branches: 5 | - main 6 | tags: 7 | - '!*' # Do not execute on tags 8 | paths: 9 | - '**.py' 10 | pull_request: 11 | paths: 12 | - '**.py' 13 | 14 | jobs: 15 | test: 16 | strategy: 17 | matrix: 18 | platform: [ubuntu-latest] 19 | python-version: [3.9] 20 | fail-fast: false 21 | 22 | name: python-${{ matrix.python-version }}/${{ matrix.platform }} 23 | runs-on: ${{ matrix.platform }} 24 | steps: 25 | - uses: actions/checkout@v2 26 | 27 | - uses: actions/setup-python@v1 28 | with: 29 | python-version: ${{ matrix.python-version }} 30 | 31 | - name: Install dependencies 🔨 32 | run: | 33 | python -m pip install --upgrade pip 34 | pip install tox 35 | 36 | - name: Run tests 👩‍💻 37 | run: | 38 | tox -qe py39,benchmark 39 | 40 | - name: Coverage report 👀 41 | run: | 42 | tox -qe coverage 43 | 44 | - name: Upload to Codecov 45 | uses: codecov/codecov-action@v2 46 | with: 47 | fail_ci_if_error: true 48 | 49 | - name: Security scan 🛡️ 50 | run: | 51 | tox -qe security 52 | 53 | - name: Type checking ✅ 54 | run: | 55 | tox -qe type-checking 56 | 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | .* 3 | *.egg-info 4 | /dist 5 | /htmlcov 6 | /coverage.xml -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Logo 2 | 3 | # Covert Encryption 4 | 5 | *A file and message encryptor with strong anonymity* 6 | 7 | * **ChaCha20-Poly1305** stream cipher with authentication 8 | * **Argon2** secures shorter passwords against cracking 9 | * **Curve25519** public key encrypt & sign with [SSH](https://medium.com/risan/upgrade-your-ssh-key-to-ed25519-c6e8d60d3c54), [Age](https://age-encryption.org/) and [Minisign](https://jedisct1.github.io/minisign/) keys 10 | 11 | ## Anonymity, privacy and authenticity 12 | 13 | The encrypted archive looks exactly like random data, providing **deniability**. Every byte is protected so that not only is reading prevented but **authenticity** is also verified, protecting your data against any outsiders, and files may also be **signed** if necessary. 14 | 15 | Other encryption tools add unencrypted headers revealing the recipients and other metadata. Covert was created to address this very problem, to stop *all* information leakage. 16 | 17 | A message (base64 or binary) has no headers or anything else that could be recognized: 18 | ``` 19 | R/i7oqt9QnTnc6Op9gw9wSbYQq1bfYtKAfEOxpiQopc0SsYdLa12AUkg0o5s4KPfU6eZX59c4SXD2F8efFCEUeU 20 | ``` 21 | 22 | Covert generates easy passphrases like `oliveanglepeaceethics` for the above. The encoded message includes random padding to hide the length of the message and it is still shorter than others. For comparison, `gpg` needs six lines instead of one and still ends up revealing the exact length of the message. 23 | 24 | ## Installation 25 | 26 | [Python](https://www.python.org/downloads/) `pip` installs `qcovert` and `covert` on your system: 27 | 28 | ``` 29 | pip install "covert[gui]" 30 | 31 | qcovert # Run GUI, or 32 | covert # Run in terminal 33 | ``` 34 | 35 | Python 3.9 or 3.10 is required. On systems still using older versions, you may need to install by: 36 | ``` 37 | python3.9 -m pip install covert 38 | ``` 39 | 40 | Developers should install a dev repo in editable mode: (consider also using [pipenv](https://pipenv.pypa.io/en/latest/)) 41 | ``` 42 | git clone https://github.com/covert-encryption/covert.git 43 | cd covert 44 | pip install -e ".[dev,gui]" 45 | ``` 46 | 47 | ## File I/O speeds matching the fastest SSDs 48 | 49 | Benchmark results. Covert up to 4 GB/s. 50 | 51 | Covert is the fastest of all the popular tools in both encryption (blue) and decryption (red). 52 | 53 | Program|Lang|Algorithms|Operation 54 | |---|---|---|---| 55 | Covert | Python | chacha20‑poly1305 sha512‑ed25519 | encrypt with auth and signature 56 | Age | Go | chacha20-poly1305 | encrypt with auth 57 | Rage | Rust | chacha20-poly1305 | encrypt with auth 58 | OpenSSL | C | aes256-ctr (hw accelerated) | encrypt only 59 | GPG | C | aes128-cfb, deflate | encrypt with auth and compression 60 | Minisign | C | blake2b-512 ed25519 | signature only (for reference) 61 | 62 | ## A few interesting features 63 | 64 | Files of any size may be attached to messages without the use of external tools, and without revealing any metadata such as modification times. 65 | 66 | A completely different ciphertext is produced each time, usually of different size, even if the message and the key are exactly the same. Other crypto tools cannot do this. 67 | 68 | Covert messages are much shorter than with other cryptosystems, accomplished by some ingenious engineering. 69 | 70 | A key insight is that a receiver can *blindly* attempt to decrypt a file with many different keys and parameters until he finds a combination that authenticates successfully. This saves valuable space on short messages and improves security because no plain text headers are needed. 71 | 72 | ![Screenshot](https://github.com/covert-encryption/covert/raw/main/docs/covert-gui.webp) 73 | 74 | ## A secure desktop app 75 | 76 | Covert comes with a graphical user interface built in. Unlike PGP GUIs, Covert does not use external CLI tools but instead does everything inside the app. Storing the plain text message on disk at any point exposes it to forensic researchers and hackers who might be scanning your drive for deleted files, and unfortunately there have been such leaks with popular PGP programs that use temporary files to communicate with external editors or with the `gpg` tool. 77 | 78 | ## Additional reading 79 | 80 | * [Covert Format Specification](https://github.com/covert-encryption/covert/blob/main/docs/Specification.md) 81 | * [Covert Security and Design Rationale](https://github.com/covert-encryption/covert/blob/main/docs/Rationale.md) 82 | * [Reducing Metadata Leakage](https://petsymposium.org/2019/files/papers/issue4/popets-2019-0056.pdf) (a related research paper) 83 | * [The PGP Problem](https://latacora.micro.blog/2019/07/16/the-pgp-problem.html) 84 | 85 | Covert is in an early development phase, so you are encouraged to try it but avoid using it on any valuable data just yet. We are looking for interested developers and the specification itself is still open to changes, no compatibility guarantees. 86 | -------------------------------------------------------------------------------- /covert/__init__.py: -------------------------------------------------------------------------------- 1 | from importlib.metadata import PackageNotFoundError, version 2 | 3 | try: 4 | __version__ = version(__name__) 5 | except PackageNotFoundError: 6 | # package is not installed 7 | pass 8 | -------------------------------------------------------------------------------- /covert/__main__.py: -------------------------------------------------------------------------------- 1 | from covert.cli.__main__ import main 2 | 3 | if __name__ == "__main__": 4 | main() 5 | -------------------------------------------------------------------------------- /covert/bech.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | from typing import Tuple, Optional 4 | 5 | 6 | class Encoding(Enum): 7 | """Enumeration type to list the various supported encodings.""" 8 | 9 | BECH32 = 1 10 | BECH32M = 2 11 | 12 | 13 | CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" 14 | BECH32M_CONST = 0x2BC830A3 15 | 16 | 17 | def bech32_polymod(values: list[int]) -> int: 18 | """Internal function that computes the Bech32 checksum.""" 19 | generator = [0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3] 20 | chk = 1 21 | for value in values: 22 | top = chk >> 25 23 | chk = (chk & 0x1FFFFFF) << 5 ^ value 24 | for i in range(5): 25 | chk ^= generator[i] if ((top >> i) & 1) else 0 26 | return chk 27 | 28 | 29 | def bech32_hrp_expand(hrp: str) -> list[int]: 30 | """Expand the HRP into values for checksum computation.""" 31 | return [ord(x) >> 5 for x in hrp] + [0] + [ord(x) & 31 for x in hrp] 32 | 33 | 34 | def bech32_verify_checksum(hrp: str, data: list[int]) -> Optional[Encoding]: 35 | """Verify a checksum given HRP and converted data characters.""" 36 | const = bech32_polymod(bech32_hrp_expand(hrp) + data) 37 | if const == 1: 38 | return Encoding.BECH32 39 | if const == BECH32M_CONST: 40 | return Encoding.BECH32M 41 | return None 42 | 43 | 44 | def bech32_create_checksum(hrp: str, data: list[int], spec: Encoding) -> list[int]: 45 | """Compute the checksum values given HRP and data.""" 46 | values = bech32_hrp_expand(hrp) + data 47 | const = BECH32M_CONST if spec == Encoding.BECH32M else 1 48 | polymod = bech32_polymod(values + [0, 0, 0, 0, 0, 0]) ^ const 49 | return [(polymod >> 5 * (5-i)) & 31 for i in range(6)] 50 | 51 | 52 | def bech32_encode(hrp: str, data: list[int], spec: Encoding) -> str: 53 | """Compute a Bech32 string given HRP and data values.""" 54 | combined = data + bech32_create_checksum(hrp, data, spec) 55 | return hrp + "1" + "".join([CHARSET[d] for d in combined]) 56 | 57 | 58 | def bech32_decode(bech: str) -> Tuple[Optional[str], Optional[list[int]], Optional[Encoding]]: 59 | """Validate a Bech32/Bech32m string, and determine HRP and data.""" 60 | if (any(ord(x) < 33 or ord(x) > 126 for x in bech)) or (bech.lower() != bech and bech.upper() != bech): 61 | return (None, None, None) 62 | bech = bech.lower() 63 | pos = bech.rfind("1") 64 | if pos < 1 or pos + 7 > len(bech) or len(bech) > 90: 65 | return (None, None, None) 66 | if not all(x in CHARSET for x in bech[pos + 1:]): 67 | return (None, None, None) 68 | hrp = bech[:pos] 69 | data = [CHARSET.find(x) for x in bech[pos + 1:]] 70 | spec = bech32_verify_checksum(hrp, data) 71 | if spec is None: 72 | return (None, None, None) 73 | return (hrp, data[:-6], spec) 74 | 75 | 76 | def decode(hrp: str, addr: str) -> bytes: 77 | hrpgot, data, spec = bech32_decode(addr) 78 | if data is None: 79 | raise ValueError("Bech32 decoding failed") 80 | elif hrpgot != hrp: 81 | raise ValueError(f"Bech32 HRP mismatch, wanted {hrp} but got {hrpgot}") 82 | else: 83 | # Convert from 5-bit left-aligned array to 8 bit bytes 84 | value = sum(d << 5 * i for i, d in enumerate(reversed(data))) 85 | return (value >> len(data) * 5 % 8).to_bytes(len(data) * 5 // 8, "big") 86 | 87 | 88 | def encode(hrp: str, databytes: bytes) -> str: 89 | l = (len(databytes) * 8 + 4) // 5 90 | value = int.from_bytes(databytes, "big") << l * 5 % 8 91 | data = [(value >> 5 * i) & 0b11111 for i in reversed(range(l))] 92 | ret = bech32_encode(hrp, data, Encoding.BECH32) 93 | if decode(hrp, ret) != databytes: 94 | raise Exception("Bech32 encode/decode failed.") 95 | return ret 96 | -------------------------------------------------------------------------------- /covert/blockstream.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import mmap 3 | from collections import deque 4 | from concurrent.futures import ThreadPoolExecutor 5 | from contextlib import contextmanager, suppress 6 | from hashlib import sha512 7 | from secrets import token_bytes 8 | 9 | from covert import chacha, pubkey, ratchet 10 | from covert.cryptoheader import Header, encrypt_header 11 | from covert.elliptic import xed_sign, xed_verify 12 | from covert.util import noncegen 13 | from covert.exceptions import AuthenticationError, DecryptError 14 | 15 | from typing import Generator, Optional, Union 16 | from io import BytesIO, FileIO 17 | from covert.archive import Archive 18 | 19 | BS = (1 << 20) - 19 # The maximum block size to use 20 | 21 | def decrypt_file(auth: Generator, f: BytesIO, archive: Archive): 22 | b = BlockStream() 23 | with b.decrypt_init(f): 24 | if not b.header.key: 25 | for a in auth: 26 | with suppress(DecryptError): 27 | b.authenticate(a) 28 | break 29 | # In case auth is a generator, close it immediately (otherwise would be delayed) 30 | if hasattr(auth, "close"): auth.close() 31 | yield from b.decrypt_blocks() 32 | b.verify_signatures(archive) 33 | 34 | class BlockStream: 35 | def __init__(self): 36 | self.key = None 37 | self.nonce = None 38 | self.workers = 8 39 | self.executor = ThreadPoolExecutor(max_workers=self.workers) 40 | self.header = None 41 | self.blkhash = None 42 | self.file = None 43 | self.ciphertext = None 44 | self.q = collections.deque() 45 | self.pos = 0 # Current position within self.ciphertext; queued for decryption, not decoded 46 | self.end = 0 47 | 48 | 49 | def authenticate(self, anykey: Union[bytes, pubkey.Key]): 50 | """Attempt decryption using secret key or password hash""" 51 | if isinstance(anykey, ratchet.Ratchet): 52 | self.header.try_ratchet(anykey) 53 | elif isinstance(anykey, pubkey.Key): 54 | self.header.try_key(anykey) 55 | else: 56 | self.header.try_pass(anykey) 57 | 58 | @contextmanager 59 | def decrypt_init(self, f): 60 | self.pos = 0 61 | if hasattr(f, "__len__"): 62 | # f can be an entire file in a buffer, or mmapped file 63 | self.ciphertext = memoryview(f) # Prevent data copying on [:] operations. 64 | self.file = None 65 | self.end = len(self.ciphertext) 66 | else: 67 | # Large enough to hold a maximum size block per each worker 68 | self.ciphertext = memoryview(bytearray((0xFFFFFF+19) * self.workers)) 69 | self.file = f 70 | self.end = 0 71 | size = self._read(1024) 72 | self.header = Header(self.ciphertext[:size]) 73 | try: 74 | yield 75 | finally: 76 | self.ciphertext.release() 77 | self.ciphertext = None 78 | self.file = None 79 | self.pos = self.end = 0 80 | 81 | 82 | def _add_to_queue(self, p: int, extlen: int, aad: Optional[bytes] =None) -> int: 83 | pos, end = p, p + extlen 84 | #assert isinstance(nblk, bytes) and len(nblk) == 12 85 | #assert isinstance(self.key, bytes) and len(self.key) == 32 86 | nblk = next(self.nonce) 87 | fut = self.executor.submit(chacha.decrypt, self.ciphertext[pos:end], aad, nblk, self.key) 88 | self.q.append((fut, nblk, pos, extlen)) 89 | return end 90 | 91 | def _read(self, extlen: int) -> int: 92 | """Try to get at least extlen bytes after current pos cursor. Returns the number of bytes available.""" 93 | if self.file: 94 | # Restart from the beginning of the buffer if the end would be reached 95 | if self.end + extlen > len(self.ciphertext): 96 | leftover = self.ciphertext[self.pos:self.end] 97 | self.ciphertext[:len(leftover)] = leftover 98 | self.pos = 0 99 | self.end = len(leftover) 100 | # Do we need to read anything? 101 | if self.end - self.pos < extlen: 102 | self.end += self.file.readinto(self.ciphertext[self.end:self.pos + extlen]) 103 | size = self.end - self.pos 104 | else: 105 | size = extlen 106 | else: 107 | # MMAP is super easy 108 | size = min(extlen, len(self.ciphertext) - self.pos) 109 | return size 110 | 111 | def decrypt_blocks(self): 112 | if not self.header.key: 113 | raise AuthenticationError("Not authenticated") 114 | self.key = self.header.key 115 | self.nonce = noncegen(self.header.nonce) 116 | self.blkhash = b"" 117 | self.pos = self.header.block0pos 118 | header = bytes(self.ciphertext[:self.header.block0pos]) 119 | self.pos = self._add_to_queue(self.pos, self.header.block0len + 19, aad=header) 120 | nextlen = self.header.block0len 121 | while nextlen: 122 | # Stream blocks into worker threads 123 | while len(self.q) < self.workers: 124 | # Guessing block length based on the nextlen which may be from a few blocks behind 125 | extlen = self._read(nextlen + 19) 126 | if extlen: 127 | self.pos = self._add_to_queue(self.pos, extlen) 128 | if extlen < 1024: 129 | break # EOF or need a longer block before queuing any more 130 | # Wait for results, and retry if blklen was misguessed 131 | while self.q: 132 | fut, nblk, p, elen = self.q.popleft() 133 | try: 134 | block = memoryview(fut.result()) 135 | nextlen = int.from_bytes(block[-3:], "little") 136 | self.blkhash = sha512(self.blkhash + self.ciphertext[p + elen - 16:p + elen]).digest() 137 | yield block[:-3] 138 | break # Buffer more blocks 139 | except DecryptError: 140 | # Reset the queue and try again at failing pos with new nextlen if available 141 | for qq in self.q: 142 | qq[0].cancel() 143 | self.q.clear() 144 | extlen = nextlen + 19 145 | if elen == extlen: 146 | # TODO: Detect whether there is EOF (file truncated) vs. actual corruption and raise a better message. 147 | raise DecryptError(f"Data corruption: Failed to decrypt ciphertext block of {extlen} bytes") from None 148 | self.nonce = noncegen(nblk) 149 | self.pos = self._add_to_queue(p, extlen) 150 | for qq in self.q: 151 | # Restore file position and nonce to the first unused block 152 | if qq is self.q[0]: 153 | self.nonce = noncegen(qq[1]) 154 | self.pos = qq[2] 155 | # Cancel all jobs still in queue 156 | qq[0].cancel() 157 | 158 | 159 | def verify_signatures(self, a: Archive): 160 | a.filehash = self.blkhash 161 | a.signatures = [] 162 | # Signature verification 163 | if a.index.get('s'): 164 | signatures = [pubkey.Key(pk=k) for k in a.index['s']] 165 | for key in signatures: 166 | sz = self._read(self.end - self.pos + 80) 167 | if sz < 80: 168 | raise ValueError(f"Missing signature block (needed 80 bytes, got {sz})") 169 | sigblock = self.ciphertext[self.pos:self.pos + 80] 170 | self.pos += 80 171 | nsig = sha512(self.blkhash + key.pk).digest()[:12] 172 | ksig = self.blkhash[:32] 173 | try: 174 | signature = chacha.decrypt(sigblock, None, nsig, ksig) 175 | except DecryptError: 176 | a.signatures.append((False, key, 'Signature corrupted or data manipulated')) 177 | continue 178 | try: 179 | xed_verify(key.pk, self.blkhash, signature) 180 | a.signatures.append((True, key, 'Signed by')) 181 | except DecryptError: 182 | a.signatures.append((False, key, 'Forged signature')) 183 | 184 | 185 | class Block: 186 | 187 | def __init__(self, maxlen: int =BS, aad: Optional[bytes] =None): 188 | self.cipher = memoryview(bytearray(maxlen + 19)) 189 | self.data = self.cipher[:-19] 190 | self.len = None 191 | self.pos = 0 192 | self.aad = aad 193 | self.nextlen = None 194 | 195 | @property 196 | def spaceleft(self) -> int: 197 | maxlen = self.len or len(self.data) 198 | return maxlen - self.pos 199 | 200 | def consume(self, data): 201 | ld, ls = len(data), self.spaceleft 202 | if ld <= ls: 203 | self.data[self.pos:self.pos + ld] = data 204 | self.pos += ld 205 | else: 206 | self.data[self.pos:self.pos + ls] = data[:ls] 207 | self.pos += ls 208 | return data[ls:] 209 | 210 | def finalize(self, nextlen: int, n: bytes, key: bytes): 211 | if self.len and self.pos < self.len: 212 | raise Exception(f"Block with {self.len=} finalized with only {self.pos=}.") 213 | self.cipher = self.cipher[:self.pos + 19] 214 | self.cipher[self.pos:self.pos + 3] = nextlen.to_bytes(3, "little") 215 | chacha.encrypt_into(self.cipher, self.cipher[:-16], self.aad, n, key) 216 | return self.cipher 217 | 218 | 219 | def encrypt_file(auth, blockinput, a): 220 | identities = auth[3] 221 | header, nonce, key = encrypt_header(auth) 222 | block = Block(maxlen=1024 - len(header) - 19, aad=header) 223 | queue = deque() 224 | yield header 225 | blkhash = b"" 226 | 227 | with ThreadPoolExecutor(max_workers=8) as executor: 228 | futures = deque() 229 | run = True 230 | nextlen = None 231 | while run: 232 | # Run block input in a thread concurrently with any encryption jobs 233 | blockinput(block) 234 | if block.pos: 235 | queue.append(block) 236 | block = Block() 237 | else: 238 | run = False 239 | 240 | # Run encryption jobs in threads 241 | while len(queue) > 1 or queue and (queue[0].nextlen or not run): 242 | out = queue.popleft() 243 | if nextlen and nextlen != out.pos: 244 | raise ValueError(f'Previous block had {nextlen=} but now we have size {out.pos=}') 245 | nextlen = out.nextlen or (queue[0].pos if queue else 0) 246 | futures.append(executor.submit(Block.finalize, out, nextlen, next(nonce), key)) 247 | 248 | # Yield results of any finished jobs, or wait for completion as needed 249 | while futures and (len(futures) > 8 or not run): 250 | ciphertext = futures.popleft().result() 251 | blkhash = sha512(blkhash + ciphertext[-16:]).digest() 252 | yield ciphertext 253 | 254 | # Special case for empty data, add an empty initial/final block so that the file will decrypt 255 | if nextlen is None: 256 | block = Block(0, aad=header).finalize(0, next(nonce), key) 257 | blkhash = sha512(blkhash + block[-16:]).digest() 258 | yield block 259 | 260 | a.filehash = blkhash 261 | # Add signature blocks 262 | for key in identities: 263 | signature = xed_sign(key.sk, blkhash, token_bytes(64)) 264 | nsig = sha512(blkhash + key.pk).digest()[:12] 265 | ksig = blkhash[:32] 266 | yield chacha.encrypt(signature, None, nsig, ksig) 267 | -------------------------------------------------------------------------------- /covert/chacha.py: -------------------------------------------------------------------------------- 1 | from nacl._sodium import ffi, lib 2 | from nacl.exceptions import CryptoError 3 | 4 | from typing import Optional 5 | from covert.typing import BytesLike 6 | from covert.exceptions import DecryptError 7 | 8 | # The bindings provided in pynacl would only accept bytes (not memoryview etc), 9 | # and did not provide support for allocating the return buffer in Python. 10 | 11 | 12 | def decrypt(ciphertext: bytes, aad: Optional[bytes], nonce: bytes, key: bytes) -> bytearray: 13 | message = bytearray(len(ciphertext) - 16) 14 | if decrypt_into(message, ciphertext, aad, nonce, key): 15 | raise DecryptError('Decryption failed') 16 | return message 17 | 18 | 19 | def encrypt(message: BytesLike, aad: Optional[bytes], nonce: bytes, key: bytes) -> bytes: 20 | ciphertext = bytearray(len(message) + 16) 21 | if encrypt_into(ciphertext, message, aad, nonce, key): 22 | raise CryptoError('Encryption failed') 23 | return ciphertext 24 | 25 | 26 | def encrypt_into(ciphertext: bytes, message: BytesLike, aad: Optional[bytes], nonce: bytes, key: bytes) -> int: 27 | mlen = len(message) 28 | clen = ffi.new("unsigned long long *") 29 | ciphertext = ffi.from_buffer(ciphertext) 30 | message = ffi.from_buffer(message) 31 | if aad: 32 | _aad = ffi.from_buffer(aad) 33 | aalen = len(aad) 34 | else: 35 | _aad = ffi.NULL 36 | aalen = 0 37 | 38 | return lib.crypto_aead_chacha20poly1305_ietf_encrypt( 39 | ciphertext, clen, message, mlen, _aad, aalen, ffi.NULL, nonce, key 40 | ) 41 | 42 | 43 | def decrypt_into(message: bytearray, ciphertext: bytes, aad: Optional[bytes], nonce: bytes, key: bytes) -> int: 44 | clen = len(ciphertext) 45 | mlen = ffi.new("unsigned long long *") 46 | message = ffi.from_buffer(message) 47 | ciphertext = ffi.from_buffer(ciphertext) 48 | if aad: 49 | _aad = aad 50 | aalen = len(aad) 51 | else: 52 | _aad = ffi.NULL 53 | aalen = 0 54 | 55 | return lib.crypto_aead_chacha20poly1305_ietf_decrypt( 56 | message, mlen, ffi.NULL, ciphertext, clen, _aad, aalen, nonce, key 57 | ) 58 | -------------------------------------------------------------------------------- /covert/cli/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/covert-encryption/covert/96658bb4921af06293000ff2109d954efbf317b1/covert/cli/__init__.py -------------------------------------------------------------------------------- /covert/cli/__main__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from typing import NoReturn 3 | 4 | import colorama 5 | 6 | from covert.cli.args import argparse 7 | from covert.cli.bench import main_bench 8 | from covert.cli.dec import main_dec 9 | from covert.cli.edit import main_edit 10 | from covert.cli.enc import main_enc 11 | from covert.cli.help import print_help 12 | from covert.cli.id import main_id 13 | from covert.exceptions import AuthenticationError, CliArgError, DecryptError, MalformedKeyError 14 | 15 | modes = { 16 | "enc": main_enc, 17 | "dec": main_dec, 18 | "edit": main_edit, 19 | "id": main_id, 20 | "bench": main_bench, 21 | } 22 | 23 | 24 | def main() -> NoReturn: 25 | """ 26 | The main CLI entry point. 27 | 28 | Consider calling covert.cli.main* or other modules directly if you use from Python code. 29 | 30 | System exit codes: 31 | * 0 The requested function was completed successfully 32 | * 1 CLI argument error 33 | * 2 I/O error (broken pipe, not other types currently) 34 | * 3 Interrupted (Ctrl+C etc) 35 | * 4 Malformed key (invalid keystr/file) 36 | * 10 Generic data error (11-99 reserved for specific types) 37 | * 11 Authentication error (wrong password, invalid key, auth needed but not provided) 38 | 39 | :raises SystemExit: on normal exit or any expected error, including KeyboardInterrupt 40 | :raises Exception: on unexpected error (report a bug), or on any error with `--debug` 41 | """ 42 | colorama.init() 43 | # CLI argument processing 44 | args = argparse() 45 | if len(args.outfile) > 1: 46 | raise CliArgError('Only one output file may be specified') 47 | args.outfile = args.outfile[0] if args.outfile else None 48 | 49 | # A quick sanity check, not entirely reliable 50 | if args.outfile in args.files: 51 | raise CliArgError('In-place operation is not supported, cannot use the same file as input and output.') 52 | 53 | # Run the mode-specific main function 54 | if args.debug: 55 | modes[args.mode](args) # --debug makes us not catch errors 56 | sys.exit(0) 57 | try: 58 | modes[args.mode](args) # Normal run 59 | except CliArgError as e: 60 | print_help(args.mode, f' 💣 {e}') # exits with status 1 61 | except MalformedKeyError as e: 62 | sys.stderr.write(f' 💣 {e}\n') 63 | sys.exit(4) 64 | except AuthenticationError as e: 65 | sys.stderr.write(f' 🛑 {e}\n') 66 | sys.exit(11) 67 | except DecryptError as e: 68 | sys.stderr.write(f' 💣 {e}\n') 69 | sys.exit(12) 70 | except ValueError as e: 71 | sys.stderr.write(f' 💣 {e}\n') 72 | sys.exit(10) 73 | except BrokenPipeError: 74 | sys.stderr.write(' 💣 I/O error (broken pipe)\n') 75 | sys.exit(2) 76 | except KeyboardInterrupt: 77 | sys.stderr.write(' ⚠️ Interrupted.\n') 78 | sys.exit(3) 79 | sys.exit(0) 80 | 81 | if __name__ == "__main__": 82 | main() 83 | -------------------------------------------------------------------------------- /covert/cli/args.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from covert.cli.help import print_help, print_version 4 | 5 | 6 | class Args: 7 | 8 | def __init__(self): 9 | self.mode = None 10 | self.idname = "" 11 | self.files = [] 12 | self.wideopen = None 13 | self.askpass = 0 14 | self.passwords = [] 15 | self.recipients = [] 16 | self.recipfiles = [] 17 | self.outfile = [] 18 | self.identities = [] 19 | self.padding = "5" 20 | self.armor = None 21 | self.paste = None 22 | self.debug = None 23 | self.delete_entire_idstore = False 24 | self.delete = False 25 | self.secret = False 26 | 27 | 28 | encargs = dict( 29 | idname='-I --id'.split(), 30 | askpass='-p --passphrase'.split(), 31 | passwords='--password'.split(), 32 | wideopen='--wide-open'.split(), 33 | recipients='-r --recipient'.split(), 34 | recipfiles='-R --keyfile --recipients-file'.split(), 35 | identities='-i --identity'.split(), 36 | outfile='-o --out --output'.split(), 37 | armor='-a --armor'.split(), 38 | paste='-A'.split(), 39 | padding='--pad --padding'.split(), 40 | debug='--debug'.split(), 41 | ) 42 | 43 | decargs = dict( 44 | idname='-I --id'.split(), 45 | askpass='-p --passphrase'.split(), 46 | passwords='--password'.split(), 47 | identities='-i --identity'.split(), 48 | outfile='-o --out --output'.split(), 49 | paste='-A'.split(), 50 | debug='--debug'.split(), 51 | ) 52 | 53 | idargs = dict( 54 | askpass='-p --passphrase'.split(), 55 | recipients='-r --recipient'.split(), 56 | recipfiles='-R --keyfile --recipients-file'.split(), 57 | identities='-i --identity'.split(), 58 | secret='-s --secret'.split(), 59 | delete_entire_idstore='--delete-entire-idstore'.split(), 60 | delete='-D --delete'.split(), 61 | debug='--debug'.split(), 62 | ) 63 | 64 | editargs = dict(debug='--debug'.split(),) 65 | benchargs = dict(debug='--debug'.split(),) 66 | 67 | def needhelp(av): 68 | """Check for -h and --help but not past --""" 69 | for a in av: 70 | if a == '--': return False 71 | if a.lower() in ('-h', '--help'): return True 72 | return False 73 | 74 | def subcommand(arg): 75 | if arg in ('enc', 'encrypt', '-e'): return 'enc', encargs 76 | if arg in ('dec', 'decrypt', '-d'): return 'dec', decargs 77 | if arg in ('edit'): return 'edit', editargs 78 | if arg in ('id'): return 'id', idargs 79 | if arg in ('bench', 'benchmark'): return 'bench', benchargs 80 | if arg in ('help', ): return 'help', {} 81 | return None, {} 82 | 83 | def argparse(): 84 | # Custom parsing due to argparse module's limitations 85 | av = sys.argv[1:] 86 | if not av: 87 | print_help() 88 | 89 | if any(a.lower() in ('-v', '--version') for a in av): 90 | print_version() 91 | 92 | args = Args() 93 | # Separate mode selector from other arguments 94 | if av[0].startswith("-") and len(av[0]) > 2 and not needhelp(av): 95 | av.insert(1, f'-{av[0][2:]}') 96 | av[0] = av[0][:2] 97 | 98 | args.mode, ad = subcommand(av[0]) 99 | 100 | if args.mode == 'help' or needhelp(av): 101 | if args.mode == 'help' and len(av) == 2 and (mode := subcommand(av[1])[0]): 102 | print_help(mode) 103 | print_help(args.mode or "help") 104 | 105 | if args.mode is None: 106 | sys.stderr.write(' 💣 Invalid or missing command (enc/dec/edit/id/benchmark/help).\n') 107 | sys.exit(1) 108 | 109 | aiter = iter(av[1:]) 110 | longargs = [flag[1:] for switches in ad.values() for flag in switches if flag.startswith("--")] 111 | shortargs = [flag[1:] for switches in ad.values() for flag in switches if not flag.startswith("--")] 112 | for a in aiter: 113 | aprint = a 114 | if not a.startswith('-'): 115 | args.files.append(a) 116 | continue 117 | if a == '-': 118 | args.files.append(True) 119 | continue 120 | if a == '--': 121 | args.files += aiter 122 | break 123 | if a.startswith('--'): 124 | a = a.lower() 125 | if not a.startswith('--') and len(a) > 2: 126 | if any(arg not in shortargs for arg in list(a[1:])): 127 | falseargs = [arg for arg in list(a[1:]) if arg not in shortargs] 128 | print_help(args.mode, f' 💣 Unknown argument: covert {args.mode} {a} (failing -{" -".join(falseargs)})') 129 | a = [f'-{shortarg}' for shortarg in list(a[1:]) if shortarg in shortargs] 130 | if isinstance(a, str): 131 | a = [a] 132 | for i, av in enumerate(a): 133 | argvar = next((k for k, v in ad.items() if av in v), None) 134 | if isinstance(av, int): 135 | continue 136 | if argvar is None: 137 | print_help(args.mode, f' 💣 Unknown argument: covert {args.mode} {aprint}') 138 | try: 139 | var = getattr(args, argvar) 140 | if isinstance(var, list): 141 | var.append(next(aiter)) 142 | elif isinstance(var, str): 143 | setattr(args, argvar, next(aiter)) 144 | elif isinstance(var, int): 145 | setattr(args, argvar, var + 1) 146 | else: 147 | setattr(args, argvar, True) 148 | except StopIteration: 149 | print_help(args.mode, f' 💣 Argument parameter missing: covert {args.mode} {aprint} …') 150 | 151 | return args 152 | -------------------------------------------------------------------------------- /covert/cli/bench.py: -------------------------------------------------------------------------------- 1 | import mmap 2 | from time import perf_counter 3 | 4 | from covert.archive import Archive 5 | from covert.blockstream import decrypt_file, encrypt_file 6 | 7 | 8 | def main_bench(args): 9 | 10 | def noop_read(block): 11 | nonlocal dataleft 12 | block.pos = min(block.spaceleft, dataleft) 13 | dataleft -= block.pos 14 | 15 | datasize = int(1e9) 16 | a = Archive() 17 | 18 | # Count ciphertext size and preallocate mmapped memory 19 | dataleft = datasize 20 | size = sum(len(block) for block in encrypt_file((True, [], [], []), noop_read, a)) 21 | ciphertext = mmap.mmap(-1, size) 22 | ciphertext[:] = bytes(size) 23 | 24 | rounds = 3 25 | enctotal = dectotal = 0 26 | for i in range(rounds): 27 | print("ENC", end="", flush=True) 28 | dataleft, size = datasize, 0 29 | t0 = perf_counter() 30 | for block in encrypt_file((True, [], [], []), noop_read, a): 31 | newsize = size + len(block) 32 | # There is a data copy here, similar to what happens on file.write() calls. 33 | ciphertext[size:newsize] = block 34 | size = newsize 35 | dur = perf_counter() - t0 36 | enctotal += dur 37 | print(f"{datasize / dur * 1e-6:6.0f} MB/s", end="", flush=True) 38 | 39 | print(" ➤ DEC", end="", flush=True) 40 | t0 = perf_counter() 41 | for data in decrypt_file(([], [], []), ciphertext, a): 42 | pass 43 | dur = perf_counter() - t0 44 | dectotal += dur 45 | print(f"{datasize / dur * 1e-6:6.0f} MB/s") 46 | 47 | ciphertext.close() 48 | print(f"Ran {rounds} cycles, each encrypting and then decrypting {datasize * 1e-6:.0f} MB in RAM.\n") 49 | print(f"Average encryption {rounds * size / enctotal * 1e-6:6.0f} MB/s") 50 | print(f"Average decryption {rounds * size / dectotal * 1e-6:6.0f} MB/s") 51 | -------------------------------------------------------------------------------- /covert/cli/dec.py: -------------------------------------------------------------------------------- 1 | import mmap 2 | import os 3 | import sys 4 | from concurrent.futures import ThreadPoolExecutor 5 | from contextlib import suppress 6 | from io import BytesIO 7 | from pathlib import Path 8 | 9 | import pyperclip 10 | from covert.exceptions import DecryptError 11 | from tqdm import tqdm 12 | 13 | from covert import idstore, lazyexec, passphrase, pubkey, util 14 | from covert.archive import Archive 15 | from covert.blockstream import BlockStream 16 | from covert.cli import tty 17 | from covert.util import ARMOR_MAX_SIZE, TTY_MAX_SIZE 18 | from covert.exceptions import AuthenticationError, CliArgError 19 | 20 | idpwhash = None 21 | 22 | 23 | def run_decryption(infile, args, b, idkeys): 24 | a = Archive() 25 | progress = None 26 | outdir = None 27 | f = None 28 | messages = [] 29 | for data in a.decode(b.decrypt_blocks()): 30 | if isinstance(data, dict): 31 | # Header parsed, check the file list 32 | for i, infile in enumerate(a.flist): 33 | if infile.name is None: 34 | if infile.size is None or infile.size > TTY_MAX_SIZE: 35 | infile.name = f'noname.{i+1:03}' 36 | infile.renamed = True 37 | elif infile.name[0] == '.': 38 | infile.name = f"noname.{i+1:03}{infile['n']}" 39 | infile.renamed = True 40 | progress = tqdm( 41 | ncols=78, 42 | unit='B', 43 | unit_scale=True, 44 | total=a.total_size, 45 | bar_format="{l_bar} {bar}{r_bar}", 46 | disable=a.total_size < 1 << 20 47 | ) 48 | elif isinstance(data, bool): 49 | # Nextfile 50 | prev = a.prevfile 51 | if f: 52 | if isinstance(f, BytesIO): 53 | f.seek(0) 54 | data = f.read() 55 | try: 56 | messages.append(data.decode()) 57 | except UnicodeDecodeError: 58 | pidx = a.flist.index(prev) 59 | prev.name = f"noname.{pidx + 1:03}" 60 | prev.renamed = True 61 | with get_writable_file(prev.name) as f2: 62 | f2.write(data) 63 | f.close() 64 | f = None 65 | if prev and prev.name is not None: 66 | r = '' if prev.renamed else '' 67 | progress.write(f'{prev.size:15,d} 📄 {prev.name:60}{r}', file=sys.stderr) 68 | if a.curfile: 69 | n = a.curfile.name or '' 70 | if not n and a.curfile.size is not None and a.curfile.size < TTY_MAX_SIZE: 71 | f = BytesIO() 72 | elif args.outfile: 73 | if not outdir: 74 | outdir = Path(args.outfile).resolve() 75 | outdir.mkdir(parents=True, exist_ok=True) 76 | progress.write(f" ▶️ \x1B[1;34m Extracting to \x1B[1;37m{outdir}\x1B[0m", file=sys.stderr) 77 | name = outdir.joinpath(n) 78 | if not name.resolve().is_relative_to(outdir) or name.is_reserved(): 79 | progress.close() 80 | raise ValueError(f'Invalid filename {n!r}') 81 | name.parent.mkdir(parents=True, exist_ok=True) 82 | f = open(name, 'wb') 83 | elif outdir is None: 84 | outdir = False 85 | progress.write( 86 | " ▶️ \x1B[1;34m The archive contains files. To extract, use \x1B[1;37m-o PATH\x1B[0m", file=sys.stderr 87 | ) 88 | 89 | # Next file 90 | if progress: 91 | if a.fidx is not None: 92 | progress.set_description(f'{a.fidx + 1:03}/{len(a.flist):03}') 93 | else: 94 | progress.set_description('') 95 | else: 96 | if f: 97 | f.write(data) 98 | if progress: 99 | progress.update(len(data)) 100 | if progress: 101 | progress.close() 102 | # Print any messages 103 | pretty = sys.stdout.isatty() 104 | for i, m in enumerate(messages): 105 | if pretty: 106 | sys.stderr.write("\x1B[1m 💬\n\x1B[1;34m") 107 | sys.stderr.flush() 108 | # Replace dangerous characters 109 | m = ''.join(c if c.isprintable() or c in ' \t\n' else f'\x1B[31m{repr(c)[1:-1]}\x1B[1;34m' for c in m) 110 | try: 111 | print(m) 112 | finally: 113 | if pretty: 114 | sys.stderr.write(f"\x1B[0m") 115 | sys.stderr.flush() 116 | # Print signatures 117 | b.verify_signatures(a) 118 | if b.header.authkey: 119 | sys.stderr.write(f" 🔑 Unlocked with {b.header.authkey}\n") 120 | elif b.header.ratchet: 121 | sys.stderr.write(f" 🔑 Conversation {b.header.ratchet.idkey}\n") 122 | sys.stderr.write(f' 🔷 File hash: {a.filehash[:12].hex()}\n') 123 | for valid, key, text in a.signatures: 124 | key = idkeys.get(key, key) 125 | if valid: 126 | sys.stderr.write(f" ✅ {text} {key}\n") 127 | else: 128 | sys.stderr.write(f"\x1B[1;31m ❌ {text} {key}\x1B[0m\n") 129 | # Start ratchet? 130 | if 'r' in a.index: 131 | if not args.idname: 132 | sys.stderr.write(f"You can start a conversation with forward secrecy by saving this contact:\n covert dec --id yourname:theirname\n") 133 | else: 134 | global idpwhash 135 | if not idpwhash: 136 | idpwhash = passphrase.pwhash(passphrase.ask("Master ID passphrase")[0]) 137 | idstore.save_contact(idpwhash, args.idname, a, b) 138 | 139 | def main_dec(args): 140 | if len(args.files) > 1: 141 | raise CliArgError("Only one input file is allowed when decrypting.") 142 | identities = {key for keystr in args.identities for key in pubkey.read_sk_any(keystr)} 143 | identities = list(sorted(identities, key=str)) 144 | infile = open(args.files[0], "rb") if args.files else sys.stdin.buffer 145 | # If ASCII armored or TTY, read all input immediately (assumed to be short enough) 146 | 147 | # FIXME: For stdin the size is set to 50 so we try to read all of it (even if from a pipe), 148 | # so that armoring can work. But this breaks pipe streaming of large files that cannot 149 | # fit in RAM and tries to armor-decode very large files too. Needs to be fixed by 150 | # attempting to read some to determine whether the input is small enough, and if not, 151 | # use the covert.archive.CombinedIO object to consume the buffer already read and then 152 | # resume streaming. 153 | total_size = os.path.getsize(args.files[0]) if args.files else 50 154 | if infile.isatty(): 155 | data = util.armor_decode(pyperclip.paste() if args.paste else tty.read_hidden("Encrypted message")) 156 | if not data: 157 | raise KeyboardInterrupt 158 | infile = BytesIO(data) 159 | total_size = len(data) 160 | del data 161 | elif 40 <= total_size <= 2 * ARMOR_MAX_SIZE: 162 | # Try reading the file as armored text rather than binary 163 | with infile: 164 | data = infile.read() 165 | try: 166 | infile = BytesIO(util.armor_decode(data.decode())) 167 | except Exception: 168 | infile = BytesIO(data) 169 | else: 170 | with suppress(OSError): 171 | infile = mmap.mmap(infile.fileno(), 0, access=mmap.ACCESS_READ) 172 | b = BlockStream() 173 | with b.decrypt_init(infile): 174 | idkeys = {} 175 | # Authenticate 176 | with ThreadPoolExecutor(max_workers=4) as executor: 177 | pwhasher = lazyexec.map(executor, passphrase.pwhash, {util.encode(pwd) for pwd in args.passwords}) 178 | def authgen(): 179 | nonlocal idkeys 180 | yield from identities 181 | e = None 182 | with tty.status("Password hashing... "): 183 | yield from pwhasher 184 | if not args.askpass and idstore.idfilename.exists(): 185 | global idpwhash 186 | idpwhash = None # In case main_dec is run multiple times (happens in tests) 187 | try: 188 | idpwhash = passphrase.pwhash(passphrase.ask("Master ID passphrase")[0]) 189 | idkeys = idstore.idkeys(idpwhash) 190 | yield from idstore.authgen(idpwhash) 191 | except AuthenticationError as e: 192 | # Treating as error only when suitable passphrase was given 193 | if idpwhash: raise AuthenticationError(f"ID store: {e}") 194 | # Ask for passphrase if asked for or if no other methods were attempted 195 | if args.askpass or not (args.passwords or args.identities): 196 | yield passphrase.pwhash(passphrase.ask('Passphrase')[0]) 197 | if not b.header.key: 198 | auth = authgen() 199 | for a in auth: 200 | with suppress(DecryptError): 201 | b.authenticate(a) 202 | break 203 | auth.close() 204 | # Decrypt and verify 205 | run_decryption(infile, args, b, idkeys) 206 | -------------------------------------------------------------------------------- /covert/cli/edit.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from io import BytesIO 3 | 4 | from covert import passphrase, util 5 | from covert.archive import Archive, FileRecord 6 | from covert.blockstream import decrypt_file, encrypt_file 7 | from covert.cli import tty 8 | from covert.exceptions import CliArgError 9 | 10 | 11 | def main_edit(args): 12 | if len(args.files) != 1: 13 | raise CliArgError("Edit mode requires an encrypted archive filename (or '-' to use stdio).") 14 | fname = args.files[0] 15 | # Read all of input file (or stdin) to RAM 16 | if fname is True: 17 | data = sys.stdin.buffer.read() 18 | else: 19 | with open(fname, "rb") as f: 20 | data = f.read() 21 | try: 22 | infile = BytesIO(util.armor_decode(data.decode())) 23 | args.armor = True 24 | except Exception: 25 | infile = BytesIO(data) 26 | # Decrypt everything to RAM 27 | pwhash = passphrase.pwhash(passphrase.ask("Passphrase")[0]) 28 | a = Archive() 29 | for data in a.decode(decrypt_file([pwhash], infile, a)): 30 | if isinstance(data, dict): pass 31 | elif isinstance(data, bool): 32 | if data: a.curfile.data = bytearray() 33 | else: a.curfile.data += data 34 | # Edit the message (should be the first file) 35 | if a.flist and a.flist[0].name is None: 36 | a.flist[0].data = util.encode(tty.editor(a.flist[0].data.decode())) 37 | a.flist[0].size = len(a.flist[0].data) 38 | else: 39 | data = util.encode(tty.editor()) 40 | a.flist.insert(0, FileRecord([len(data), None, {}])) 41 | a.flist[0].data = data 42 | # Reset archive for re-use in encryption 43 | a.reset() 44 | a.fds = [BytesIO(f.data) for f in a.flist] 45 | a.random_padding() 46 | # Encrypt in RAM... 47 | out = bytearray() 48 | for block in encrypt_file((False, [pwhash], [], []), a.encode, a): 49 | out += block 50 | # Preserve armoring if the input was armored 51 | if args.armor: 52 | out = f"{util.armor_encode(out)}\n".encode() 53 | # Finally write output / replace the file 54 | if fname is True: 55 | sys.stdout.buffer.write(out) 56 | else: 57 | with open(fname, "wb") as f: 58 | f.write(out) 59 | -------------------------------------------------------------------------------- /covert/cli/enc.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from concurrent.futures import ThreadPoolExecutor 4 | from io import BytesIO 5 | 6 | import pyperclip 7 | from tqdm import tqdm 8 | 9 | from covert import idstore, passphrase, pubkey, util 10 | from covert.archive import Archive 11 | from covert.blockstream import encrypt_file 12 | from covert.cli import tty 13 | from covert.util import ARMOR_MAX_SIZE, TTY_MAX_SIZE 14 | from covert.exceptions import CliArgError 15 | 16 | 17 | def main_enc(args): 18 | padding = .01 * float(args.padding) if args.padding is not None else .05 19 | if not 0 <= padding <= 3.0: 20 | raise CliArgError('Invalid padding specified. The valid range is 0 to 300 %.') 21 | # Passphrase encryption by default if no auth is specified 22 | if not (args.idname or args.askpass or args.passwords or args.recipients or args.recipfiles or args.wideopen): 23 | args.askpass = 1 24 | # Convert recipient definitions into keys 25 | recipients = [] 26 | for keystr in args.recipients: 27 | try: 28 | recipients.append(pubkey.decode_pk(keystr)) 29 | except ValueError as e: 30 | if keystr.startswith("github:"): 31 | raise CliArgError(f"Unrecognized recipient string. Download a key from Github by -R {keystr}") 32 | elif os.path.isfile(keystr): 33 | raise CliArgError(f"Unrecognized recipient string. Use a keyfile by -R {keystr}") 34 | raise 35 | for fn in args.recipfiles: 36 | recipients += pubkey.read_pk_file(fn) 37 | # Unique recipient keys sorted by keystr 38 | l = len(recipients) 39 | recipients = list(sorted(set(recipients), key=str)) 40 | if len(recipients) < l: 41 | sys.stderr.write(' ⚠️ Duplicate recipient keys dropped.\n') 42 | if args.idname and len(recipients) > 1: 43 | raise CliArgError("Only one recipient may be specified for ID store.") 44 | # Signatures 45 | signatures = {key for keystr in args.identities for key in pubkey.read_sk_any(keystr) if key.edsk} 46 | signatures = list(sorted(signatures, key=str)) 47 | if args.idname and len(signatures) > 1: 48 | raise CliArgError("Only one secret key may be specified for ID store.") 49 | # Ask passphrases 50 | if args.idname: 51 | if len(signatures) > 1: raise CliArgError("Only one secret key may be associated with an identity.") 52 | if len(recipients) > 1: raise CliArgError("Only one recipient key may be associated with an identity.") 53 | if idstore.idfilename.exists(): 54 | idpass, _ = passphrase.ask("Master ID passphrase") 55 | else: 56 | idpass = util.encode(passphrase.generate(5)) 57 | sys.stderr.write(f" 🗄️ Master ID passphrase: \x1B[32;1m{idpass.decode()}\x1B[0m (creating {idstore.idfilename})\n") 58 | numpasswd = args.askpass + len(args.passwords) 59 | passwords, vispw = [], [] 60 | for i in range(args.askpass): 61 | num = f" {i+1}/{numpasswd}" if numpasswd > 1 else "" 62 | pw, visible = passphrase.ask(f"New passphrase{num}", create=True) 63 | passwords.append(pw) 64 | if visible: 65 | vispw.append(pw.decode()) 66 | del pw 67 | passwords += map(util.encode, args.passwords) 68 | # Use threaded password hashing for parallel and background operation 69 | with ThreadPoolExecutor(max_workers=4) as executor: 70 | if args.idname: 71 | idpwhasher = executor.submit(passphrase.pwhash, idpass) 72 | del idpass 73 | pwhasher = executor.map(passphrase.pwhash, set(passwords)) 74 | # Input files 75 | if not args.files or True in args.files: 76 | if sys.stdin.isatty(): 77 | data = tty.editor() 78 | # Prune surrounding whitespace 79 | data = '\n'.join([l.rstrip() for l in data.split('\n')]).strip('\n') 80 | stin = util.encode(data) 81 | else: 82 | stin = sys.stdin.buffer 83 | args.files = [stin] + [f for f in args.files if f != True] 84 | # Collect the password hashing results 85 | pwhashes = set() 86 | if args.idname or passwords: 87 | with tty.status("Password hashing... "): 88 | if args.idname: idpwhash = idpwhasher.result() 89 | pwhashes = set(pwhasher) 90 | del passwords 91 | # ID store update 92 | ratch = None 93 | if args.idname: 94 | # Try until the passphrase works 95 | while True: 96 | try: 97 | idkey, peerkey, ratch = idstore.profile( 98 | idpwhash, 99 | args.idname, 100 | idkey=signatures[0] if signatures else None, 101 | peerkey=recipients[0] if recipients else None, 102 | ) 103 | break 104 | except ValueError as e: 105 | # TODO: Add different exception types to avoid this check 106 | if "Not authenticated" not in str(e): raise 107 | idpwhash = passphrase.pwhash(passphrase.ask("Wrong password, try again. Master ID passphrase")[0]) 108 | signatures = [idkey] 109 | recipients = [peerkey] 110 | # Prepare for encryption 111 | a = Archive() 112 | a.file_index(args.files) 113 | if ratch: 114 | if ratch.RK: 115 | # Enable ratchet mode auth 116 | recipients = ratch 117 | else: 118 | # Advertise ratchet capability and send initial message number 119 | a.index['r'] = ratch.s.N 120 | if signatures: 121 | a.index['s'] = [s.pk for s in signatures] 122 | # Output files 123 | realoutf = open(args.outfile, "wb") if args.outfile else sys.stdout.buffer 124 | if args.armor or not args.outfile and sys.stdout.isatty(): 125 | if a.total_size > (ARMOR_MAX_SIZE if args.outfile else TTY_MAX_SIZE): 126 | if not args.outfile: 127 | raise ValueError("Too much data for console. How about -o FILE to write a file?") 128 | raise ValueError("The data is too large for --armor.") 129 | outf = BytesIO() 130 | else: 131 | outf = realoutf 132 | # Print files during encoding and update padding size at the end 133 | def nextfile_callback(prev, cur): 134 | if prev: 135 | s, n = prev.size, prev.name 136 | progress.write(f'{s:15,d} 📄 {n:60}' if n else f'{s:15,d} 💬 ', file=sys.stderr) 137 | if not cur: 138 | a.random_padding(padding) 139 | progress.write(f'\x1B[1;30m{a.padding:15,d} ⬛ \x1B[0m', file=sys.stderr) 140 | 141 | a.nextfilecb = nextfile_callback 142 | # Main processing 143 | with tqdm( 144 | total=a.total_size, delay=1.0, ncols=78, unit='B', unit_scale=True, bar_format="{l_bar} {bar}{r_bar}" 145 | ) as progress: 146 | for block in encrypt_file((args.wideopen, pwhashes, recipients, signatures), a.encode, a): 147 | progress.update(len(block)) 148 | outf.write(block) 149 | # Store ratchet 150 | if ratch: idstore.update_ratchet(idpwhash, ratch, a) 151 | # Pretty output printout 152 | if sys.stderr.isatty(): 153 | # Print a list of files 154 | lock = " 🔓 wide-open" if args.wideopen else " 🔒 covert" 155 | if ratch and ratch.RK: 156 | methods = f'🔗 #{ratch.s.CN + ratch.s.N}' 157 | else: 158 | methods = " ".join( 159 | [f"🔗 {r}" for r in recipients] + [f"🔑 {a}" for a in vispw] + (numpasswd - len(vispw)) * ["🔑 "] 160 | ) 161 | methods += f' 🔷 {a.filehash[:12].hex()}' 162 | for s in signatures: 163 | methods += f" 🖋️ {s}" 164 | if methods: 165 | lock += f" {methods}" 166 | if args.outfile: 167 | lock += f" 💾 {args.outfile}\n" 168 | elif args.paste: 169 | lock += f" 📋 copied\n" 170 | out = f"\n\x1B[1m{lock}\x1B[0m\n" 171 | sys.stderr.write(out) 172 | sys.stderr.flush() 173 | if outf is not realoutf: 174 | outf.seek(0) 175 | data = outf.read() 176 | data = util.armor_encode(data) 177 | if outf is not realoutf: 178 | if args.paste: 179 | pyperclip.copy(f"```\n{data}\n```\n") 180 | return 181 | pretty = realoutf.isatty() 182 | if pretty: 183 | sys.stderr.write("\x1B[1;30m```\x1B[0;34m\n") 184 | sys.stderr.flush() 185 | try: 186 | realoutf.write(f"{data}\n".encode()) 187 | realoutf.flush() 188 | finally: 189 | if pretty: 190 | sys.stderr.write("\x1B[1;30m```\x1B[0m\n") 191 | sys.stderr.flush() 192 | # Not using `with outf` because closing stdout causes a lot of trouble and 193 | # missing the close on a file when the CLI exits anyway is not dangerous. 194 | # TODO: Delete the output file if any exception occurs. 195 | if outf is not sys.stdout.buffer: 196 | outf.close() 197 | -------------------------------------------------------------------------------- /covert/cli/help.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from typing import NoReturn 3 | 4 | import covert 5 | 6 | T = "\x1B[1;44m" # titlebar (white on blue) 7 | H = "\x1B[1;37m" # heading (bright white) 8 | C = "\x1B[0;34m" # command (dark blue) 9 | F = "\x1B[1;34m" # flag (light blue) 10 | D = "\x1B[1;30m" # dark / syntax markup 11 | N = "\x1B[0m" # normal color 12 | 13 | usage = dict( 14 | enc=f"""\ 15 | {C}covert {F}enc --id{N} you:them {D}[{N}{F}-r{N} pubkey {D}|{N} {F}-R{N} pkfile{D}] ⋯{N} 16 | {C}covert {F}enc {D}[{F}-i {N}id.key{D}] [{F}-r {N}pubkey {D}|{N} {F}-R {N}pkfile {D}|{F} -p {D}|{F} --wide-open{D}]… ⋯ 17 | ⋯ [{F}--pad {N}5{D}] [{F}-A {D}| [{F}-o {N}cipher.dat {D}[{F}-a{D}]] [{N}file.jpg{D}]…{N} 18 | """, 19 | dec=f"{C}covert {F}dec {D}[{F}--id {N}you:them{D}] [{F}-i {N}id.key{D}] [{F}-A {D}|{N} cipher.dat{D}] [{F}-o {N}files/{D}]{N}\n", 20 | id=f"{C}covert {F}id {D}[{N}you:them{D}] [{N}options{D}] —{N} create/manage ID store of your keys\n", 21 | edit=f"{C}covert {F}edit {N}cipher.dat {D}—{N} securely keep notes with passphrase protection\n", 22 | bench=f"{C}covert {F}bench {D}—{N} run a performance benchmark for decryption and encryption\n", 23 | ) 24 | 25 | usagetext = dict( 26 | enc=f"""\ 27 | Encrypt a message and/or files. The first form uses ID store, while the second 28 | does not and instead takes all keys on the command line. When no files are 29 | given or {F}-{N} is included, Covert asks for message input or reads stdin. 30 | 31 | {F}--id {N}alice:bob Use ID store for local (alice) and peer (bob) keys 32 | {F}-i {N}seckey Sign the message with your secret key 33 | {F}-r {N}pubkey {F}-R{N} file Encrypt the message for this public key 34 | {F}-p{N} Passphrase encryption (default when no other options) 35 | {F}--wide-open{N} Allow anyone to open the file (no keys or passphrase) 36 | 37 | {F}--pad{N} PERCENT Preferred random padding amount (default 5 %) 38 | {F}-o{N} FILENAME Output file (binary ciphertext that looks entirely random) 39 | {F}-a{N} ASCII/text output (default for terminal/clipboard) 40 | {F}-A{N} Auto copy&paste of ciphertext (desktop use) 41 | 42 | With ID store, no keys need to be defined on command line, although as a 43 | shortcut one may store a new peer by specifiying a previously unused peer name 44 | and his public key by {C}covert {F}enc --id {N}you:newpeer{F} -r{N} key {D}⋯{N} avoiding the use 45 | of the {F}id{N} subcommand to add the peer public key first. Conversations already 46 | established use forward secret keys and should have no key specified on {F}enc{N}. 47 | 48 | Folders may be specified as input files and they will be stored recursively. 49 | Any paths given on command line are stripped off the stored names, such that 50 | each item appears at archive root, avoiding accidental leakage of metadata. 51 | """, 52 | dec=f"""\ 53 | Decrypt Covert archive. Tries decryption with options given on command line, 54 | and with all conversations and keys stored in ID store. 55 | 56 | {F}--id {N}alice:bob Store the sender as "bob" if not previously known 57 | {F}-i {N}seckey Sign the message with your secret key 58 | {F}-r {N}pubkey {F}-R{N} file Encrypt the message for this public key 59 | {F}-p{N} Passphrase encryption (default when no other options) 60 | {F}--wide-open{N} Allow anyone to open the file (no keys or passphrase) 61 | {F}-o{N} folder Folder where to extract any attached files. 62 | {F}-A{N} Auto copy&paste of ciphertext (desktop use) 63 | """, 64 | edit=f"""\ 65 | Avoids having to extract the message in plain text for editing, which could 66 | leave copies on disk unprotected. Use {C}covert {F}enc{N} with a passphrase to create 67 | the initial archive. Attached files and other data are preserved even though 68 | editing overwrites the entire encrypted file. 69 | """, 70 | id=f"""\ 71 | The ID store keeps your encryption keys stored securely and enables messaging 72 | with forward secrecy. You only need to enter anyone's public key the first 73 | time you send them a message and afterwards all replies use temporary keys 74 | which change with each message sent and received. 75 | 76 | {F}-s --secret{N} Show secret keys (by default only shows public keys) 77 | {F}-p --passphrase{N} Change Master ID passphrase 78 | {F}-r {N}pk {F}-R {N}pkfile Change/set the public key associated with ID local:peer 79 | {F}-i {N}seckey Change the secret key of the given local ID 80 | {F}-D --delete{N} Delete the ID (local and all its peers, or the given peer) 81 | {F}--delete-entire-idstore{N} Securely erase the entire ID storage 82 | 83 | The storage is created when you run {C}covert {F}id{N} yourname. Be sure to 84 | write down the master passphrase created or change it to one you can remember. 85 | Multiple local IDs can be created for separating one's different tasks and 86 | their contacts, but all share the same master passphrase. 87 | 88 | The ID names are for your own information and are never included in messages. 89 | Avoid using spaces or punctuation on them. Notice that your local ID always 90 | comes first, and any peer is separated by a colon. Deletion of a local ID also 91 | removes all public keys and conversations attached to it. 92 | """, 93 | ) 94 | 95 | cmdhelp = {k: f"{usage[k]}\n{usagetext.get(k, '')}".rstrip("\n") + "\n" for k in usage} 96 | 97 | introduction = f"Covert {covert.__version__} - A file and message encryptor with strong anonymity" 98 | if len(introduction) > 78: # git version string is too long 99 | introduction = f"Covert {covert.__version__} - A file and message encryptor" 100 | 101 | introduction = f"""\ 102 | {T}{introduction:78}{N} 103 | 💣 Things encrypted with this developer preview mayn't be readable evermore 104 | """ 105 | 106 | shorthelp = f"""\ 107 | {introduction} 108 | {"".join(usage.values())} 109 | Getting started: optionally create an ID ({C}covert {F}id{N} yourname), then use the 110 | {F}enc{N} command to send messages and {F}dec{N} to receive them. You won't need most 111 | of the options but see the help for more info. Commonly used options: 112 | 113 | {F}--id {N}alice:bob Use ID store for local (alice) and peer (bob) keys 114 | {F}-i {N}seckey Your secret key file (e.g .ssh/id_ed25519) or keystring 115 | {F}-r {N}pubkey {F}-R{N} file Their public key, or {F}-R{N} github:username {D}|{N} bob.pub 116 | {F}-A{N} Auto copy&paste of ciphertext (desktop use) 117 | {F}--help --version{N} Useful information. Help applies to subcommands too. 118 | """ 119 | 120 | keyformatshelp = f"""\ 121 | {H}Supported key formats and commands to generate keys:{N} 122 | 123 | * Age: {C}covert {F}id{N} yourname (Covert natively uses Age's key format) 124 | * Minisign: {C}minisign {F}-R{N} 125 | * SSH ed25519: {C}ssh-keygen {F}-t ed25519{N} (other SSH key types are not supported) 126 | * WireGuard: {C}wg {F}genkey {C}| tee {N}secret.key {C}| wg {F}pubkey{N} 127 | """ 128 | 129 | exampleshelp = f"""\ 130 | {H}Examples:{N} 131 | 132 | * To encrypt a message using an ssh-ed25519 public key, run: 133 | - {C}covert {F}enc -R {N}github:myfriend {F}-o{N} file 134 | - {C}covert {F}enc -R {N}~/.ssh/myfriend.pub {F}-o{N} file 135 | - {C}covert {F}enc -r {N}AAAAC3NzaC1lZDI1NTE5AAAA... {F}-o{N} file 136 | 137 | * To decrypt a message using a private ssh-ed25519 key file, run: 138 | - {C}covert {F}dec -i {N}~/.ssh/id_ed25519 file 139 | 140 | * Messaging and key storage with ID store: 141 | - {C}covert {F}id {N}alice Add your ID (generate new or {F}-i{N} key) 142 | - {C}covert {F}id {N}alice:bob {F}-R{N} github:bob Add bob as a peer for local ID alice 143 | - {C}covert {F}enc --id {N}alice:bob Encrypt a message using idstore 144 | - {C}covert {F}enc --id {N}alice:charlie {F}-r{N} pk Adding a peer (on initial message) 145 | - {C}covert {F}dec{N} Uses idstore for decryption 146 | """ 147 | 148 | allcommands = '\n\n'.join(cmdhelp.values()) 149 | 150 | fullhelp = f"""\ 151 | {introduction} 152 | {allcommands} 153 | 154 | {keyformatshelp} 155 | {exampleshelp}""" 156 | 157 | def print_help(modehelp: str = None, error: str = None) -> NoReturn: 158 | stream = sys.stderr if error else sys.stdout 159 | if modehelp is None: stream.write(shorthelp) 160 | elif (h := cmdhelp.get(modehelp)): stream.write(h) 161 | else: stream.write(fullhelp) 162 | if error: 163 | stream.write(f"\n{error}\n") 164 | sys.exit(1) 165 | sys.exit(0) 166 | 167 | def print_version() -> NoReturn: 168 | print(f"Covert {covert.__version__}") 169 | sys.exit(0) 170 | -------------------------------------------------------------------------------- /covert/cli/id.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from concurrent.futures import ThreadPoolExecutor 3 | 4 | from covert import idstore, passphrase, pubkey 5 | from covert.cli import tty 6 | from covert.exceptions import CliArgError 7 | 8 | 9 | def main_id(args): 10 | if len(args.files) > 1: 11 | raise CliArgError("Argument error, one ID at most should be specified") 12 | if args.delete_entire_idstore: 13 | if args.files: 14 | raise CliArgError("No ID should be provided with --delete-entire-idstore") 15 | try: 16 | idstore.delete_entire_idstore() 17 | sys.stderr.write(f"{idstore.idfilename} shredded and deleted.\n") 18 | except FileNotFoundError: 19 | sys.stderr.write(f"{idstore.idfilename} does not exist.\n") 20 | return 21 | if args.files: 22 | parts = args.files[0].split(":", 1) 23 | local = parts[0] 24 | peer = parts[1] if len(parts) == 2 else "" 25 | tagself = f"id:{local}" 26 | tagpeer = f"id:{local}:{peer}" if peer else None 27 | else: 28 | tagself = tagpeer = None 29 | if args.delete and not tagself: 30 | raise CliArgError("Need an ID of form yourname or yourname:peername to delete.") 31 | # Load keys from command line 32 | selfkey = peerkey = None 33 | if args.recipients or args.recipfiles: 34 | if not tagpeer: raise CliArgError("Need an ID of form yourname:peername to assign a public key") 35 | if len(args.recipients) + len(args.recipfiles) > 1: raise CliArgError("Only one public key may be specified for ID store") 36 | peerkey = pubkey.decode_pk(args.recipients[0]) if args.recipients else pubkey.read_pk_file(args.recipfiles[0])[0] 37 | if args.identities: 38 | if not tagself: raise CliArgError("Need an ID to assign a secret key.") 39 | if len(args.identities) > 1: raise CliArgError("Only one secret key may be specified for ID store") 40 | selfkey = pubkey.read_sk_any(args.identities[0])[0] 41 | # First run UX 42 | create_idstore = not idstore.idfilename.exists() 43 | if create_idstore: 44 | if not tagself: raise CliArgError("To create a new ID store, specify an ID to create e.g.\n covert id alice\n") 45 | if tagpeer and not peerkey: raise CliArgError(f"No public key provided for new peer {tagpeer}.") 46 | sys.stderr.write(f" 🗄️ Creating {idstore.idfilename}\n") 47 | # Passphrases 48 | idpass = newpass = None 49 | if not create_idstore: 50 | idpass = passphrase.ask("Master ID passphrase")[0] 51 | with ThreadPoolExecutor(max_workers=2) as executor: 52 | pwhasher = executor.submit(passphrase.pwhash, idpass) if idpass is not None else None 53 | newhasher = None 54 | if args.askpass or create_idstore: 55 | newpass, visible = passphrase.ask("New Master ID passphrase", create=5) 56 | newhasher = executor.submit(passphrase.pwhash, newpass) 57 | if visible: 58 | sys.stderr.write(f" 📝 Master ID passphrase: \x1B[32;1m{newpass.decode()}\x1B[0m\n") 59 | with tty.status("Password hashing... "): 60 | idhash = pwhasher.result() if pwhasher else None 61 | newhash = newhasher.result() if newhasher else None 62 | del idpass, newpass, pwhasher, newhasher 63 | # Update ID store 64 | for ids in idstore.update(idhash, new_pwhash=newhash): 65 | if args.delete: 66 | if tagpeer: 67 | del ids[tagpeer] 68 | else: 69 | # Delete ID and all peers connected to it 70 | del ids[tagself] 71 | for k in list(ids): 72 | if k.startswith(f"{tagself}:"): del ids[k] 73 | return 74 | # Update/add secret key? 75 | if tagself and tagself not in ids: 76 | ids[tagself] = dict() 77 | if not selfkey: 78 | sys.stderr.write(f" 🧑 {tagself} keypair created\n") 79 | selfkey = pubkey.Key() 80 | if selfkey: 81 | ids[tagself]["I"] = selfkey.sk 82 | # Update/add peer public key? 83 | if tagpeer and tagpeer not in ids: 84 | if not peerkey: raise CliArgError(f"No public key provided for new peer {tagpeer}.") 85 | if tagpeer not in ids: ids[tagpeer] = dict() 86 | ids[tagpeer]["i"] = peerkey.pk 87 | # Print keys 88 | for key, value in ids.items(): 89 | if tagself and key not in (tagself, tagpeer): continue 90 | if "I" in value: 91 | k = pubkey.Key(sk=value["I"]) 92 | sk = pubkey.encode_age_sk(k) 93 | pk = pubkey.encode_age_pk(k) 94 | print(f"{key:15} {pk}") 95 | if args.secret: print(f" ╰─ {sk}") 96 | elif "i" in value: 97 | pk = pubkey.encode_age_pk(pubkey.Key(pk=value["i"])) 98 | print(f"{key:15} {pk}") 99 | # Ratchet info 100 | if (r := value.get("r")): 101 | state = "with forward secrecy" if r["RK"] else "initialising (messages sent but no response yet)" 102 | print(f" conversation {state}") 103 | -------------------------------------------------------------------------------- /covert/cli/tty.py: -------------------------------------------------------------------------------- 1 | import io 2 | import os 3 | import sys 4 | import time 5 | from contextlib import contextmanager 6 | 7 | from covert.cli import textedit 8 | 9 | 10 | @contextmanager 11 | def status(message): 12 | """Write a temporary status message that is cleared once processing is complete.""" 13 | if sys.stderr.isatty(): 14 | sys.stderr.write(message) 15 | 16 | sys.stderr.flush() 17 | try: 18 | yield 19 | finally: 20 | sys.stderr.write("\r\x1B[0K") 21 | sys.stderr.flush() 22 | else: 23 | yield 24 | 25 | def editor(lines=None): 26 | with fullscreen() as term: 27 | term.write(f'\x1B[1;1H\x1B[1;44m 〰 ENTER MESSAGE 〰 (ESC to finish)\x1B[0K\x1B[0m\n') 28 | return textedit.edit(term, lines or [""]) 29 | 30 | def read_hidden(prompt): 31 | with terminal() as term: 32 | term.write(f'{prompt}: \x1B[1;30m') 33 | try: 34 | data = "" 35 | t = time.monotonic() 36 | while True: 37 | for key in term.reader(): 38 | if key == "ESC": 39 | raise KeyboardInterrupt 40 | elif key == "BACKSPACE": 41 | data = data[:-1] 42 | elif key == "ENTER": 43 | # Handle multi-line pastes 44 | if time.monotonic() - t > 0.2: 45 | return data 46 | data += '\n' 47 | elif len(key) == 1: 48 | data += key 49 | t = time.monotonic() 50 | status = f" ({len(data)}) " 51 | term.write(f"{status}\x1B[{len(status)}D") 52 | finally: 53 | # Return to start of line and clear the prompt 54 | term.write(f"\x1B[0m\r\x1B[0K") 55 | 56 | 57 | @contextmanager 58 | def unix_terminal(): 59 | fd = os.open('/dev/tty', os.O_RDWR | os.O_NOCTTY) 60 | with io.FileIO(fd, 'w+') as tty: 61 | old = termios.tcgetattr(fd) # a copy to save 62 | new = old[:] 63 | new[3] &= ~termios.ECHO 64 | new[3] &= ~termios.ICANON 65 | tcsetattr_flags = termios.TCSAFLUSH 66 | if hasattr(termios, 'TCSASOFT'): 67 | tcsetattr_flags |= termios.TCSASOFT 68 | try: 69 | termios.tcsetattr(fd, tcsetattr_flags, new) 70 | yield Terminal(tty) 71 | # Try to prevent multi-line pastes flooding elsewhere 72 | time.sleep(0.1) 73 | termios.tcflush(fd, termios.TCIFLUSH) 74 | finally: 75 | # Restore the original state 76 | termios.tcsetattr(fd, tcsetattr_flags, old) 77 | tty.flush() 78 | 79 | 80 | @contextmanager 81 | def windows_terminal(): 82 | yield Terminal(None) 83 | 84 | 85 | @contextmanager 86 | def stdio_terminal(): 87 | raise NotImplementedError 88 | 89 | 90 | try: 91 | import termios 92 | terminal = unix_terminal 93 | except (ImportError, AttributeError): 94 | try: 95 | import msvcrt 96 | terminal = windows_terminal 97 | except ImportError: 98 | terminal = stdio_terminal 99 | 100 | 101 | @contextmanager 102 | def modeswitch(term): 103 | term.write('\x1B[?1049h\x1B[2J') 104 | try: 105 | yield 106 | finally: 107 | term.write('\x1B[2J\x1B[?1049l') 108 | 109 | 110 | @contextmanager 111 | def fullscreen(): 112 | with terminal() as term, modeswitch(term): 113 | yield term 114 | 115 | 116 | class Terminal: 117 | 118 | def __init__(self, tty=None): 119 | self.esc = '' 120 | self.tty = tty 121 | self.reader = self.reader_windows if tty is None else self.reader_unix 122 | 123 | def write(self, text): 124 | if self.tty: 125 | self.tty.write(text.encode()) 126 | self.tty.flush() 127 | else: 128 | text = text.replace('\n', '\r\n') 129 | sys.stderr.write(text) 130 | #for ch in text: 131 | # msvcrt.putwch(ch) 132 | # TODO add ctrl+arrow keys listening here instead of key combos. 133 | 134 | def reader_windows(self): 135 | while True: 136 | ch = msvcrt.getwch() 137 | if ch == '\x00' or ch == 'à': 138 | ch = msvcrt.getwch() 139 | if ch == 'H': yield 'UP' 140 | elif ch == 'P': yield 'DOWN' 141 | elif ch == 'K': yield 'LEFT' 142 | elif ch == 'M': yield 'RIGHT' 143 | elif ch == 'G': yield 'HOME' 144 | elif ch == 'O': yield 'END' 145 | elif ch == 'S': yield 'DEL' 146 | #else: yield ch 147 | elif ch == '\x03': 148 | raise KeyboardInterrupt 149 | elif ch == '\x1B': 150 | yield 'ESC' 151 | elif ch == '\b': 152 | yield 'BACKSPACE' 153 | elif ch == '\t': 154 | yield 'TAB' 155 | elif ch == '\r': 156 | yield 'ENTER' 157 | elif ch.isprintable(): 158 | yield ch 159 | if not msvcrt.kbhit(): 160 | break 161 | 162 | def reader_unix(self): 163 | while True: 164 | # FIXME: UTF-8 streaming 165 | for ch in self.tty.read(16384).decode(): 166 | if self.esc: 167 | self.esc += ch 168 | if self.esc.startswith("\x1B[") and all(ch.isnumeric() or ch == ";" for ch in self.esc[2:]): continue 169 | elif self.esc == '\x1B\x1B': yield "ESC" 170 | elif self.esc == "\x1B[3~": yield 'DEL' 171 | elif self.esc == "\x1B[1;5C": yield "CTRL-RIGHT" 172 | elif self.esc == "\x1B[1;5D": yield "CTRL-LEFT" 173 | elif len(self.esc) == 3: 174 | if ch == 'A': yield 'UP' 175 | elif ch == 'B': yield 'DOWN' 176 | elif ch == 'C': yield 'RIGHT' 177 | elif ch == 'D': yield 'LEFT' 178 | elif ch == 'H': yield 'HOME' 179 | elif ch == 'F': yield 'END' 180 | else: 181 | yield repr(self.esc) 182 | self.esc = '' 183 | elif ch == "\x1B": 184 | self.esc = ch 185 | elif ch == "\x01": 186 | yield 'HOME' 187 | elif ch == "\x05": 188 | yield 'END' 189 | elif ch == '\t': 190 | yield 'TAB' 191 | elif ch == '\x7F': 192 | yield 'BACKSPACE' 193 | elif ch == '\n': 194 | yield 'ENTER' 195 | elif ch.isprintable(): 196 | yield ch 197 | if not self.esc: 198 | break 199 | -------------------------------------------------------------------------------- /covert/cryptoheader.py: -------------------------------------------------------------------------------- 1 | import random 2 | from contextlib import suppress 3 | 4 | from covert import chacha, passphrase, pubkey, ratchet, util 5 | 6 | from typing import Generator, Tuple, Union, Optional 7 | from covert.typing import BytesLike 8 | from covert.pubkey import Key 9 | from covert.exceptions import DecryptError 10 | 11 | def encrypt_header(auth) -> Tuple[bytes, Generator, bytes]: 12 | wideopen, pwhashes, recipients, identities = auth 13 | assert wideopen or pwhashes or recipients, "Must have an authentication method defined" 14 | assert not wideopen or not (pwhashes or recipients), "Cannot have auth with wide-open" 15 | # Ratchet mode special handling 16 | if isinstance(recipients, ratchet.Ratchet): 17 | header, key = recipients.send() # Ratchet.send 18 | print(len(header), key.hex()) 19 | return header, util.noncegen(header[:12]), key 20 | # Ensure uniqueness 21 | pwhashes = set(pwhashes) 22 | recipients = set(recipients) 23 | simple = not recipients and len(pwhashes) <= 1 24 | # Create a random ephemeral keypair and use it as nonce (even when no pubkeys are used) 25 | eph = pubkey.Key() 26 | n = eph.pkhash[:12] 27 | nonce = util.noncegen(n) 28 | # Only one password or wide-open 29 | if simple: 30 | key = bytes(32) if wideopen else passphrase.authkey(pwhashes.pop(), n) 31 | return n, nonce, key 32 | # Pubkeys and/or multiple auth mode 33 | auth = {passphrase.authkey(pw, n) for pw in pwhashes} | {pubkey.derive_symkey(n, eph, r) for r in recipients} 34 | if len(auth) > 20: 35 | raise ValueError("Too many recipients specified (max 20).") 36 | auth = list(auth) 37 | random.shuffle(auth) 38 | # The first hash becomes the key and any additional ones are xorred with it 39 | key, *auth = auth 40 | header = eph.pkhash + b"".join([util.xor(key, a) for a in auth]) 41 | return header, nonce, key 42 | 43 | 44 | class Header: 45 | def __init__(self, ciphertext: BytesLike): 46 | if len(ciphertext) < 32: # 12 nonce + 1 data + 3 nextlen + 16 tag 47 | raise ValueError("This file is too small to contain encrypted data.") 48 | self.ciphertext = bytes(ciphertext[:1024]) 49 | self.nonce = self.ciphertext[:12] 50 | self.eph = pubkey.Key(pkhash=self.ciphertext[:32]) 51 | self.slot = "locked" 52 | self.key = None 53 | self.authkey: Optional[Key] = None 54 | self.ratchet: Optional[ratchet.Ratchet] = None 55 | self.block0pos = None 56 | self.block0len = None 57 | with suppress(DecryptError): 58 | # Try wide-open 59 | self._find_block0(bytes(32), 12) 60 | self.slot = "wide-open" 61 | 62 | def try_key(self, recvkey: Key): 63 | self._find_slots(pubkey.derive_symkey(self.nonce, recvkey, self.eph)) 64 | self.authkey = recvkey 65 | 66 | def try_ratchet(self, r: ratchet.Ratchet): 67 | authkey = r.receive(self.ciphertext) 68 | self._find_block0(authkey, 50) 69 | self.slot = "conversation" 70 | self.ratchet = r 71 | 72 | def try_pass(self, pwhash: bytes): 73 | authkey = passphrase.authkey(pwhash, self.nonce) 74 | try: 75 | self._find_block0(authkey, 12) 76 | self.slot = "passphrase" 77 | except DecryptError: 78 | self._find_slots(authkey) 79 | 80 | def _find_slots(self, authkey): 81 | # The first slot is all zeroes (not stored in file), followed by auth1, auth2, ... 82 | ct = self.ciphertext 83 | slots = [bytes(32)] + [ct[i * 32:(i+1) * 32] for i in range(1, 19) if (i+1) * 32 <= len(ct) - 19] 84 | slotends = [(i+1) * 32 for i in range(len(slots))] 85 | for i, s in enumerate(slots): 86 | key = util.xor(s, authkey) 87 | for hbegin in slotends[i:]: 88 | with suppress(DecryptError): 89 | self._find_block0(key, hbegin) 90 | self.slot = i, self.block0pos // 32 91 | return 92 | raise DecryptError 93 | 94 | def _find_block0(self, key, begin): 95 | ct = self.ciphertext 96 | for end in reversed(range(begin + 19, 1 + min(1024, len(ct)))): 97 | with suppress(DecryptError): 98 | self.block0 = chacha.decrypt(ct[begin:end], ct[:begin], self.nonce, key) 99 | break 100 | else: 101 | raise DecryptError 102 | self.key = key 103 | self.block0pos = begin 104 | self.block0len = end - begin - 19 105 | -------------------------------------------------------------------------------- /covert/elliptic/__init__.py: -------------------------------------------------------------------------------- 1 | # A plain Python submodule for Ed25519/Curve25519 math and Elligator 2 2 | 3 | # Based on code by Loup Vaillant and Andrew Moon (public domain, no warranties) 4 | # https://github.com/LoupVaillant/Monocypher/blob/master/tests/gen/elligator.py 5 | 6 | # Heavily changed and extended by Covert, based on Ed25519 RFC and other sources. 7 | # https://datatracker.ietf.org/doc/html/rfc8032 8 | 9 | # Not constant time, not zeroing buffers after use, so the Monocypher C library 10 | # should be preferred where needed, in particular with the many things that 11 | # libsodium does not support. This code does not use low order safety mechanisms 12 | # that sodium implements preventing the "dirty points" needed for Elligator. 13 | 14 | # Public symbols are imported here. These are very low level primitives. 15 | # Lower case constants are scalars (int or fe), upper case are EdPoints. 16 | 17 | from . import mont 18 | from .ed import LO, ZERO, D, EdPoint, G, L, dirty_scalar, secret_scalar 19 | from .eddsa import ed_sign, ed_verify 20 | from .elligator import ElligatorError, egcreate, eghide, egreveal 21 | from .scalar import fe, minus1, one, p, q, sqrtm1, zero 22 | from .util import clamp, tobytes, toint, tointsign 23 | from .xeddsa import xed_sign, xed_verify 24 | -------------------------------------------------------------------------------- /covert/elliptic/ed.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from functools import cached_property 4 | from typing import Optional 5 | 6 | from .scalar import fe, minus1, one, p, q, sqrtm1, zero 7 | from .util import clamp, clamp_dirty, sha, tobytes, toint, tointsign 8 | 9 | # Twisted Edwards curve: a x2 + y2 = 1 + d x2 y2 10 | # Ed25519 constants: 11 | a, d = minus1, -fe(121665) / fe(121666) 12 | 13 | # Points are represented as tuples (X, Y, Z, T) of extended 14 | # coordinates, with x = X/Z, y = Y/Z, x*y = T/Z 15 | 16 | class EdPoint: 17 | def __init__(self, x: fe, y: fe, z: fe = one, t: Optional[fe] = None): 18 | # Expand to projective coordinates for faster adds 19 | self.X = x 20 | self.Y = y 21 | self.Z = z 22 | self.T = x * y if t is None else t 23 | 24 | @staticmethod 25 | def from_montbytes(b) -> EdPoint: 26 | """Convert from Curve25519 pk, using the high bit as x coordinate sign.""" 27 | u, sign = tointsign(b) 28 | return EdPoint.from_mont(fe(u), sign) 29 | 30 | @staticmethod 31 | def from_mont(u: fe, ednegative: bool) -> EdPoint: 32 | """Convert from Curve25519 u coordinate and a sign for Ed25519""" 33 | if u == minus1: 34 | # Custom handling of two points with no birational mapping 35 | return ZERO 36 | return EdPoint.from_y((u - one) / (u + one), ednegative) 37 | 38 | @staticmethod 39 | def from_bytes(b) -> EdPoint: 40 | """Read standard Ed25519 public key""" 41 | val, sign = tointsign(b) 42 | return EdPoint.from_y(fe(val), sign) 43 | 44 | @staticmethod 45 | def from_y(y: fe, negative=False) -> EdPoint: 46 | """Restore from a y coordinate and an is_negative flag""" 47 | x2 = (y.sq - one) / (d * y.sq + one) 48 | if not x2.is_square: raise ValueError("Not a curve point on Ed25519") 49 | p = EdPoint(x2.sqrt, y) 50 | return p if p.is_negative == negative else -p 51 | 52 | @cached_property 53 | def mont(self) -> fe: 54 | """Convert the y coordinate into a Curve25519 u coordinate. sign is not included.""" 55 | if self.y == one: return minus1 56 | return (one + self.y) / (one - self.y) 57 | 58 | @cached_property 59 | def montbytes_sign(self) -> bytes: 60 | """Provides a 32-byte CUrve25519 compatible pk with sign on the high bit""" 61 | return tobytes((self.is_negative << 255) + self.mont.val) 62 | 63 | @cached_property 64 | def montbytes(self) -> bytes: 65 | """Provides a 32-byte Curve25519 pk with zero high bit""" 66 | return tobytes(self.mont.val) 67 | 68 | def __repr__(self): return point_name(self) 69 | def __str__(self): return bytes(self).hex() 70 | def __bytes__(self): return tobytes(self.y.val + (self.is_negative << 255)) 71 | def __hash__(self): return self.y.val 72 | def __abs__(self): return -self if self.is_negative else self 73 | 74 | @cached_property 75 | def norm(self) -> EdPoint: 76 | """Return a normalized point, with Z=1.""" 77 | return EdPoint.from_y(self.y, self.is_negative) 78 | 79 | @cached_property 80 | def is_negative(self) -> bool: 81 | """Return the parity of the x coordinate, aka the sign.""" 82 | # self.x is zero only for ZERO and LO[4], and for the latter this returns True 83 | return self.x.bit(0) if self.x.val else self.y.is_negative 84 | 85 | @cached_property 86 | def undirty(self) -> EdPoint: 87 | """Project a dirty point to its corresponding prime group point""" 88 | return (self if self.subgroup == 0 else self - LO[self.subgroup]).norm 89 | 90 | @cached_property 91 | def subgroup(self) -> int: 92 | """Return the subgroup (0..7) where 0 is the prime group""" 93 | return LO_index[LO.index(q * self)] 94 | 95 | @cached_property 96 | def is_low_order(self) -> bool: return self in LO 97 | 98 | @cached_property 99 | def is_prime_group(self) -> bool: return not self.is_low_order and self.subgroup == 0 100 | 101 | @cached_property 102 | def x(self) -> fe: return self.X / self.Z 103 | 104 | @cached_property 105 | def y(self) -> fe: return self.Y / self.Z 106 | 107 | def __add__(self, othr: EdPoint) -> EdPoint: 108 | if not isinstance(othr, EdPoint): return NotImplemented 109 | A = (self.Y - self.X) * (othr.Y - othr.X) 110 | B = (self.Y + self.X) * (othr.Y + othr.X) 111 | C = fe(2) * self.T * othr.T * d 112 | D = fe(2) * self.Z * othr.Z 113 | E, F, G, H = B - A, D - C, D + C, B + A 114 | return EdPoint(E * F, G * H, F * G, E * H) 115 | 116 | def __sub__(self, othr: EdPoint) -> EdPoint: 117 | return self + -othr 118 | 119 | def __neg__(self) -> EdPoint: 120 | return EdPoint(-self.X, self.Y, self.Z, -self.T) 121 | 122 | def __mul__(self, s: int) -> EdPoint: 123 | """Multiply the point by scalar (secret key).""" 124 | if not isinstance(s, int): return NotImplemented 125 | Q = ZERO # Neutral element 126 | P = self 127 | # Modulo s first to make multiplication faster (8 * q rather than q to support non-prime subgroups) 128 | s %= 8 * q 129 | while s > 0: 130 | if s & 1: Q += P 131 | P += P 132 | s >>= 1 133 | return Q.norm 134 | 135 | def __rmul__(self, s: int) -> EdPoint: 136 | return self * s 137 | 138 | def __eq__(self, othr): 139 | if not isinstance(othr, EdPoint): raise TypeError(f"EdPoints cannot be compared with {type(othr)}") 140 | # x1 / z1 == x2 / z2 <==> x1 * z2 == x2 * z1 141 | return ( 142 | (self.X * othr.Z - othr.X * self.Z) == zero and 143 | (self.Y * othr.Z - othr.Y * self.Z) == zero 144 | ) 145 | 146 | # Neutral element 147 | ZERO = EdPoint(zero, one) 148 | 149 | # Base point (prime group generator) 150 | G = EdPoint.from_y(fe(4) / fe(5), False) 151 | 152 | # Low order generator 153 | L = EdPoint.from_y((minus1 * ((d + one).sqrt + one) / d).sqrt, False) 154 | 155 | # All low order points and an index lookup to find P's subgroup by q * P 156 | LO = [i * L for i in range(8)] 157 | LO_index = [(i * pow(q, -1, 8)) % 8 for i in range(8)] 158 | 159 | # Dirty generator (randomises subgroups when multiplied by 0..8*q but is compatible with G) 160 | D = G + LO[1] 161 | 162 | def secret_scalar(edsk: bytes) -> int: 163 | """ 164 | Converts Ed25519 secret key bytes to a clamped scalar. 165 | 166 | Note: 167 | Public key is edsk_scalar(edsk) * G (for both Edwards and Montgomery) 168 | Curve25519 sk = tobytes(edsk_scalar(edsk)) 169 | """ 170 | # Sodium concatenates the public key, making it 64 bytes 171 | if len(edsk) not in (32, 64): raise ValueError("Invalid length for edsk") 172 | return clamp(sha(edsk[:32])) 173 | 174 | def dirty_scalar(edsk) -> int: 175 | """ 176 | Converts Ed25519 secret key bytes to a partially clamped scalar. 177 | 178 | dirty_scalar(edsk) * D = standard public key + random low order point 179 | """ 180 | # High bits set as usual but the three low bits are not cleared 181 | if len(edsk) not in (32, 64): raise ValueError("Invalid length for edsk") 182 | return clamp_dirty(sha(edsk[:32])) 183 | 184 | 185 | def point_name(P: EdPoint) -> str: 186 | """Return variable names rather than xy coordinates for any constants defined here""" 187 | for name, val in globals().items(): 188 | if isinstance(val, EdPoint) and P == val: 189 | return name 190 | for i, val in enumerate(LO): 191 | if P == val: 192 | return f"LO[{i}]" 193 | return f"EdPoint({P.x!r}, {P.y!r})" 194 | -------------------------------------------------------------------------------- /covert/elliptic/eddsa.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | 3 | from .ed import EdPoint, G, q, secret_scalar 4 | from .util import sha, toint 5 | 6 | 7 | def ed_sign(edsk: bytes, msg: bytes) -> bytes: 8 | """Standard Ed25519 signature""" 9 | a = secret_scalar(edsk) 10 | prefix = hashlib.sha512(edsk).digest()[32:] 11 | A = a * G 12 | r = sha(prefix + msg) % q 13 | R = r * G 14 | Rs = bytes(R) 15 | h = sha(Rs + bytes(A) + msg) % q 16 | s = (r + h*a) % q 17 | return Rs + int.to_bytes(s, 32, "little") 18 | 19 | def ed_verify(edpk: bytes, msg: bytes, signature: bytes) -> None: 20 | """Standard Ed25519 signature verification""" 21 | if len(signature) != 64: 22 | Exception("Bad signature length") 23 | A = EdPoint.from_bytes(edpk) 24 | if A.is_low_order: 25 | raise ValueError("Invalid public key provided") 26 | Rs = signature[:32] 27 | R = EdPoint.from_bytes(Rs) 28 | if R.is_low_order: 29 | raise ValueError("Invalid R point on signature") 30 | s = toint(signature[32:]) 31 | if s >= q: 32 | raise ValueError("Invalid s value on signature") 33 | h = sha(Rs + bytes(A) + msg) % q 34 | # Finally we confirm that (r + h * a) * G == R + h * A 35 | if s * G != R + h * A: 36 | raise ValueError("Signature mismatch") 37 | -------------------------------------------------------------------------------- /covert/elliptic/elligator.py: -------------------------------------------------------------------------------- 1 | # Elligator2 over Curve25519, see section 5 of 2 | # https://www.shiftleft.org/papers/elligator/elligator.pdf 3 | 4 | ## Why it is extremely difficult to make indistinguishible from random keys 5 | 6 | # Curve25519 public key is the u-coordinate of a point representing the key. 7 | # It is a value up to p - 1 (so it fits neatly in 255 bits). Not all values 8 | # are used in standard public keys because 9 | # - The 256th bit is always zero (bitmask to check) 10 | # - Only half of all u values are valid points (curve equation to check) 11 | # - Only 1/8 of the valid points are used in standard public keys (subgroup) 12 | # 13 | # These properties can be easily tested, with only 3 % probability (1/32) of 14 | # that data being just 32 random bytes rather than a standard key. 15 | # 16 | # Any unused high bits can easily be filled with random bits, solving the 17 | # first problem. 18 | # 19 | # Elligator 2 solves the second problem, producing 254 bit values, of which 20 | # practically all are used by valid points. The high bits can be filled with 21 | # random values to make it a 32-byte sequence indistinguishible from random. 22 | # 23 | # Only half of the *valid* u coordinates can be encoded by Elligator 2 at all, 24 | # so in practice we need to try and create key pairs until a public key that 25 | # can be hashed is found. 26 | # 27 | # A quick entropy calculation: the u value is 255 bits minus one for non-points 28 | # and another for non-hashable points. So, Elligator encodes 253 bits worth of 29 | # u coordinate. Why is the value then 254 bits? Because it can also encode 30 | # a sign bit. A sign that is never used with Curve25519 but that exists anyway 31 | # and that won't affect the all-important u coordinate, but that will 32 | # completely mix the Elligator output (not only one bit of it). 33 | # 34 | # All good until now, except that the adversary can still mask out the high 35 | # bits, unhash the Elligator 2 and obtain a curve point. Now, if he multiplies 36 | # that point by q, it reduces to one of the eight *low order points* there are 37 | # in Curve25519. Standard public keys all reduce to big flat ZERO, where any 38 | # random point has equal chance of being in any of the eight sub groups, each 39 | # represented by its own low order point. 40 | # 41 | # The third problem needs to be solved by custom key generation that creates 42 | # *dirty points*, public keys that can be in any sub group, rather than only 43 | # in the prime group. 44 | # 45 | # We can make a point dirty by adding to it a random low point. This does not 46 | # affect the result of standard ECDH using that key, as any subgroups are 47 | # cancelled by the multiplication in those (because the secret scalars are 48 | # multiples of eight after the standard clamping required by the protocols) 49 | 50 | ## High level API 51 | 52 | # Designed around Ed25519 because edsk can be easily converted into Montgomery 53 | # but the opposite is not possible. Also, starting with Ed25519 we have useful 54 | # extra bits to randomise the points, without having to create random numbers. 55 | # Curve25519 sk are already clamped, losing those crucial three low bits. 56 | 57 | # The sign of Ed25519 (lowest bit of x) is used as Elligator 2 "v sign", to 58 | # avoid having to calculate the v coordinate (which is rarely used anywhere), 59 | # but to still allow recovering the original Ed25519 public key exactly. 60 | 61 | from contextlib import suppress 62 | from secrets import token_bytes 63 | from typing import Tuple 64 | 65 | from .ed import LO, EdPoint, G, dirty_scalar 66 | from .mont import A 67 | from .scalar import fe, one, p, sqrtm1, zero 68 | from .util import sha, tobytes, toint 69 | 70 | 71 | class ElligatorError(ValueError): 72 | """Point is incompatible with Elligator hashing""" 73 | 74 | def egcreate() -> Tuple[bytes, bytes]: 75 | """ 76 | Create a random hidden key. 77 | - Compatible with all of Ed25519, Curve25519 and Elligator2 78 | - 254 bits of entropy (253 for Curve25519) 79 | 80 | :returns: (hidden, edsk) 81 | """ 82 | while True: 83 | # Try until successful, half of our attempts should fail 84 | with suppress(ElligatorError): 85 | edsk = token_bytes(32) 86 | return eghide(edsk), edsk 87 | 88 | def eghide(edsk: bytes) -> bytes: 89 | """ 90 | Convert Ed25519 secret key into a random-looking 32-byte string. 91 | - Deterministic, depends only on edsk 92 | 93 | :raises ElligatorError: if the key is incompatible with Elligator2 94 | """ 95 | # Calculate a dirty public key 96 | s = dirty_scalar(edsk) 97 | sg = s % 8 # sub group 98 | # Using dirty generator: s * D - sg * G = 99 | # Using normal generator: (s - sg) * G + LO[sg] = 100 | # A dirty point produced: standard edpk + random low-order point 101 | P = (s - sg) * G + LO[sg] 102 | if not is_hashable(P.mont): raise ElligatorError("The key cannot be Elligator hashed") 103 | # Take two pseudorandom bits (custom prefix needed to keep s and signatures secure) 104 | # sha512(...)[31] & 0xC0 and placing at the same location on the final hidden byte. 105 | tweak = sha(b"DirtyElligator2:" + edsk) & 0b11 << 254 106 | 107 | # Elligator 2 hash 108 | # 109 | # Note: the random hashes lack one bit of entropy because only half of the possible 110 | # points are created (because the high bit of the scalar is forced on) but for a given 111 | # point it is not possible to test whether it could or could not be created. Adding a 112 | # random sign bit instead would add to entropy but then the Ed25519 sign would be lost. 113 | elligator = fast_curve_to_hash(P.mont, P.is_negative).val 114 | assert elligator & tweak == 0, "The elligator hash and the tweak should not overlap" 115 | return tobytes(elligator ^ tweak) 116 | 117 | def egreveal(hidden: str) -> EdPoint: 118 | """Convert the hidden string back to (a dirty) public key""" 119 | elligator = toint(hidden) & (1 << 254) - 1 120 | u, v = fast_hash_to_curve(fe(elligator)) 121 | P = EdPoint.from_mont(u, v.is_negative) 122 | return P 123 | 124 | ## Low level API follows 125 | 126 | # Arbitrary non square, typically chosen to minimise computation. 127 | # 2 and sqrt(-1) both work fairly well, but 2 seems to be more popular. 128 | # We stick to 2 for compatibility. 129 | non_square = fe(2) 130 | 131 | # From the paper: 132 | # w = -A / (fe(1) + non_square * r^2) 133 | # e = chi(w^3 + A*w^2 + w) 134 | # u = e*w - (fe(1)-e)*(A//2) 135 | # v = -e * sqrt(u^3 + A*u^2 + u) 136 | ufactor = -non_square * sqrtm1 137 | vfactor = ufactor.sqrt 138 | 139 | 140 | def fast_hash_to_curve(r: fe) -> Tuple[fe, fe]: 141 | """Convert a 254-bit hash into a pair of curve coordinates""" 142 | t1 = r**2 * non_square # r1 143 | u = t1 + one # r2 144 | t2 = u**2 145 | t3 = (A**2 * t1 - t2) * A # numerator 146 | t1 = t2 * u # denominator 147 | t1 = (t3 * t1).invsqrt 148 | u = r**2 * ufactor 149 | v = r * vfactor 150 | if t1.is_square: 151 | u, v = one, one 152 | v *= t3 * t1 153 | u *= -A * t3 * t2 * t1**2 154 | if t1.is_square != v.is_negative: # XOR 155 | v = -v 156 | return u, v 157 | 158 | 159 | # From the paper: 160 | # Let sq = -non_square * u * (u+A) 161 | # if sq is not a square, or u = -A, there is no mapping 162 | # Assuming there is a mapping: 163 | # if v is positive: r = sqrt(-(u+A) / u) 164 | # if v is negative: r = sqrt(-u / (u+A)) 165 | # 166 | # We compute isr = invsqrt(-non_square * u * (u+A)) 167 | # if it wasn't a non-zero square, abort. 168 | # else, isr = sqrt(-1 / (non_square * u * (u+A)) 169 | # 170 | # This causes us to abort if u is zero, even though we shouldn't. This 171 | # never happens in practice, because (i) a random point in the curve has 172 | # a negligible chance of being zero, and (ii) scalar multiplication with 173 | # a trimmed scalar *never* yields zero. 174 | def fast_curve_to_hash(u: fe, v_is_negative: bool) -> fe: 175 | """Convert a curve point into a pseudorandom 254 bit value""" 176 | t = u + A 177 | r = -non_square * u * t 178 | isr = r.invsqrt 179 | if not isr.is_square: 180 | raise ValueError("The point cannot be mapped.") 181 | if v_is_negative: u = t 182 | r = u * isr 183 | r = abs(r) 184 | return r 185 | 186 | 187 | # Unlike the paper, curve coordinates are called (u, v) to follow 188 | # established conventions. Thus, "v" in the paper is called "w" here. 189 | def hash_to_curve(r: fe) -> Tuple[fe, fe]: 190 | """Reference implementation of S to point""" 191 | w = -A / (one + non_square * r**2) 192 | e = (w**3 + A * w**2 + w).chi 193 | u = e*w - (one - e) * (A//2) 194 | v = -e * (u**3 + A * u**2 + u).sqrt 195 | return u, v 196 | 197 | 198 | # Computes the representative of a point, straight from the paper. 199 | def curve_to_hash(u: fe, v_is_negative: bool) -> fe: 200 | """Reference implementation of point to S""" 201 | if not is_hashable(u): 202 | raise ValueError('cannot curve to hash') 203 | sq1 = (-u / (non_square * (u+A))).sqrt 204 | sq2 = (-(u + A) / (non_square * u)).sqrt 205 | return sq2 if v_is_negative else sq1 206 | 207 | 208 | def is_hashable(u: fe) -> bool: 209 | """Test if a point is hashable.""" # Straight from the paper. 210 | return u != -A and (-non_square * u * (u + A)).is_square 211 | -------------------------------------------------------------------------------- /covert/elliptic/mont.py: -------------------------------------------------------------------------------- 1 | from . import ed 2 | from .scalar import fe, minus1, one, zero 3 | 4 | # Curve25519 constants on Montgomery curve: B v2 = u3 + A u2 + u 5 | A = fe(486662) # = fe(2) * (ed.a + ed.d) / (ed.a - ed.d) 6 | B = one 7 | 8 | # The point at infinity is represented by u coordinate value minus1 because 9 | # - No established standard in other libraries (most use zero which is a different low order point) 10 | # - This is the only number for which there is no birational conversion to Ed25519 (division by zero) 11 | # - This is not a valid point on the curve (v2 = 486660 which is not square) 12 | # - The Montgomery ladder misbehaves with this value 13 | 14 | def v(u: fe) -> fe: 15 | """Calculate the v coordinate for a point, checking point validity as well.""" 16 | v2 = u**3 + A * u.sq + u 17 | if v2.is_square: return v2.sqrt 18 | if u == minus1: 19 | raise ValueError("Curve25519 point at infinity does not have coordinates") 20 | raise ValueError(f"Curve25519 {u=} is not a valid point") 21 | 22 | 23 | def scalarmult(s: int, u: fe): 24 | """Multiply point u coordinate by scalar s in Curve25519""" 25 | if hasattr(u, "mont"): u = u.mont # type: ignore 26 | s %= 8 * ed.q 27 | # Special care of two low order points that the algorithm mishandles 28 | if u == minus1: return minus1 # Point at infinity 29 | if u == zero: return zero if s & 1 else minus1 # Low order point with order 2 30 | # Montgomery ladder 31 | # In projective coordinates, to avoid divisions: u = X / Z 32 | x2, z2 = one, zero # "zero" point 33 | x3, z3 = u, one # "one" point 34 | swap = False 35 | for n in reversed(range(s.bit_length())): 36 | bit = bool(s & 1 << n) 37 | swap ^= bit 38 | if swap: 39 | x2, x3 = x3, x2 40 | z2, z3 = z3, z2 41 | swap = bit # anticipates one last swap after the loop 42 | 43 | # Montgomery ladder step: replaces (P2, P3) by (P2*2, P2+P3) with differential addition 44 | a, b = x2 + z2, x2 - z2 45 | aa, bb = a.sq, b.sq 46 | da = a * (x3 - z3) 47 | db = b * (x3 + z3) 48 | e = aa - bb 49 | # Output 50 | x3, z3 = (da + db).sq, (da - db).sq * u 51 | x2, z2 = aa * bb, (bb + fe(121666) * e) * e 52 | 53 | # last swap is necessary to compensate for the xor trick 54 | if swap: 55 | x2, x3 = x3, x2 56 | z2, z3 = z3, z2 57 | 58 | # normalises the coordinates: u == X / Z 59 | return x2 / z2 if z2 != zero else zero if x2 == zero else minus1 60 | -------------------------------------------------------------------------------- /covert/elliptic/scalar.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from functools import cached_property 4 | from typing import Union 5 | 6 | # Field prime 7 | p = 2**255 - 19 8 | 9 | # Precalculate commonly needed parts of the prime 10 | p2 = (p - 1) // 2 11 | p4 = (p - 1) // 4 12 | p38 = (p + 3) // 8 13 | p58 = (p - 5) // 8 14 | 15 | # Group order (both Ed25519 and Curve25519) 16 | q = 2**252 + 27742317777372353535851937790883648493 17 | 18 | 19 | class fe: 20 | """A prime field scalar modulo p = 2^255 - 19""" 21 | def __init__(self, x: int): self.val = x % p 22 | def __hash__(self): return self.val 23 | def __repr__(self): return value_name(self) 24 | def __str__(self): return bytes(self).hex() 25 | def __bytes__(self): return self.val.to_bytes(32, 'little') 26 | def bit(self, n: int): return bool(self.val & 1 << n) 27 | 28 | def __eq__(self, other): 29 | # Note: if we return NotImplemented, Python does object comparison and returns False 30 | if not isinstance(other, fe): raise TypeError(f"Cannot compare fe with {other!r}") 31 | return self.val == other.val 32 | 33 | def __abs__(self): return -self if self.is_negative else self 34 | def __neg__(self): return fe(-self.val) 35 | def __add__(self, o: fe): return fe(self.val + o.val) 36 | def __sub__(self, o: fe): return fe(self.val - o.val) 37 | def __mul__(self, o: fe): return fe((self.val * o.val) % p) 38 | 39 | def __truediv__(self, o: fe) -> fe: 40 | """Division mod p""" 41 | return self if o == one else fe(self.val * o.inv.val) 42 | 43 | def __floordiv__(self, o: int) -> fe: 44 | """Simple integer division""" 45 | return fe(self.val // o) 46 | 47 | def __pow__(self, s: int) -> fe: 48 | # Use faster cached .sq for x**2 because it is a very common operation 49 | return self.sq if s == 2 else fe(pow(self.val, s, p)) 50 | 51 | @cached_property 52 | def inv(self) -> fe: return self**-1 53 | 54 | @cached_property 55 | def is_negative(self) -> bool: return self.val > p2 56 | 57 | # Legendre symbol: 58 | # - 0 if n is zero 59 | # - 1 if n is a non-zero square 60 | # - -1 if n is not a square 61 | # We take for granted that n^((p-1)/2) does what we want 62 | @cached_property 63 | def chi(self) -> fe: 64 | """Legendre symbol""" 65 | return self**p2 66 | 67 | @cached_property 68 | def sq(self) -> fe: 69 | """Squared""" 70 | x = self * self 71 | x.is_square = True 72 | return x 73 | 74 | @cached_property 75 | def is_square(self) -> bool: return self == zero or self.chi == one 76 | 77 | @cached_property 78 | def sqrt(self) -> bool: 79 | """The square root. Raises ValueError otherwise.""" 80 | if not self.is_square: raise ValueError('Not a square!') 81 | # Note that p is congruent to 5 modulo 8, so (p+3)/8 is an integer. 82 | # If n is zero, then n^((p+3)/8) is zero (zero is its own square root). 83 | root = self**p38 84 | # We then choose the positive square root, between 0 and (p-1)/2 85 | if root * root != self: root *= sqrtm1 86 | assert root * root == self 87 | return abs(root) 88 | 89 | # Inverse square root. 90 | # Returns (sqrt(1/x) , True ) if x is non-zero square. 91 | # Returns (sqrt(sqrt(-1)/x), False) if x is not a square. 92 | # Returns (0 , False) if x is zero. 93 | # We do not guarantee the sign of the square root. 94 | @cached_property 95 | def invsqrt(self) -> fe: 96 | """Fast 1/sqrt(x) mod p, more black magic than Carmack's""" 97 | isr = self**p58 98 | quartic = self * isr.sq 99 | if quartic == minus1 or quartic == -sqrtm1: isr *= sqrtm1 100 | isr.is_square = quartic == one or quartic == minus1 101 | return isr 102 | 103 | zero, one, minus1 = fe(0), fe(1), fe(-1) 104 | 105 | # square root of -1 (used in implementation of fe.sqrt, so cannot calculate with that) 106 | sqrtm1 = abs(fe(2)**p4) 107 | assert sqrtm1 * sqrtm1 == minus1 108 | 109 | 110 | def value_name(s: fe) -> str: 111 | """Return variable names rather than fe(...) for any constants defined here""" 112 | for name, val in globals().items(): 113 | if isinstance(val, fe) and s == val: 114 | return name 115 | for name, val in globals().items(): 116 | if isinstance(val, fe) and s == -val: 117 | return f"-{name}" 118 | return f"fe({s.val})" 119 | -------------------------------------------------------------------------------- /covert/elliptic/util.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | from typing import Tuple 3 | 4 | 5 | def clamp(x: int) -> int: 6 | """Ed25519 standard clamping for scalars (from hashed secret key)""" 7 | # 256 bits 01[x]000 (using 251 bits of x, masking on/off others) 8 | 9 | # Note that clamped scalars are 0 mod 8 to avoid exposing any bits of the scalar 10 | # when multiplying a dirty point (public key). The maximum value is about 2**127 11 | # below 8 * q (the modulo of scalars) so a tiny bit of the whole group remains 12 | # unused. Scalars are not mod p, which would change 12 of the highest values. 13 | return x & (1 << 255) - 8 | 1 << 254 14 | 15 | def clamp_dirty(x: int) -> int: 16 | """A dirty clamping function that does not clear the low bits.""" 17 | # Used for creation of dirty points using a dirty generator 18 | return x & (1 << 255) - 1 | 1 << 254 19 | 20 | 21 | def toint(x) -> int: 22 | if isinstance(x, int): return x 23 | if len(x) != 32: raise ValueError("Should be exactly 32 bytes") 24 | return int.from_bytes(x, "little") 25 | 26 | def tointsign(x) -> Tuple[int, bool]: 27 | """Separate the 255 bit integer and its high bit as a sign, return both.""" 28 | val = toint(x) 29 | sign = val & 1 << 255 30 | return val ^ sign, bool(sign) 31 | 32 | def tobytes(x: int) -> bytes: 33 | return x.to_bytes(32, "little") 34 | 35 | def sha(s) -> int: 36 | """Return SHA-512 as 512 bit integer""" 37 | return int.from_bytes(shabytes(s), "little") 38 | 39 | def shabytes(s) -> bytes: 40 | return hashlib.sha512(s).digest() 41 | -------------------------------------------------------------------------------- /covert/elliptic/xeddsa.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from .ed import EdPoint, G, q, secret_scalar 4 | from .util import clamp, sha, tobytes, toint, tointsign 5 | 6 | # Implements Signal's XEdDSA signature scheme XEd25519 7 | # https://signal.org/docs/specifications/xeddsa/ 8 | 9 | # Notice that the implementation is mostly in Ed25519 rather than Montgomery, 10 | # although the keys are converted from Curve25519 for this. The public points 11 | # stored in the signatures use the Ed25519 format. 12 | 13 | # The sign of the public key in Edwards format is stored into the highest bit 14 | # of s in accordance to what Signal is doing in their source code, despite 15 | # this not being mentioned in specification. Thus, the private scalar a is not 16 | # manipulated at all, as the specification would suggest. 17 | 18 | # https://github.com/signalapp/libsignal-client/blob/main/rust/protocol/src/curve/curve25519.rs#L102 19 | 20 | 21 | def hashn(data: bytes, n: Optional[int] = None) -> int: 22 | """The domain-separating hash function from specification, mod q""" 23 | prefix = b"" if n is None else tobytes((1 << 256) - 1 - n) 24 | return sha(prefix + data) % q 25 | 26 | def xed_sign(sk: bytes, message: bytes, nonce: bytes) -> bytes: 27 | if len(nonce) != 64: 28 | raise ValueError("A 64-byte random nonce is required") 29 | # Secret scalars 30 | a = clamp(toint(sk)) 31 | r = hashn(sk + message + nonce, 1) 32 | # Public points 33 | A = a * G 34 | R = r * G 35 | # Calculate a signature 36 | h = hashn(bytes(R) + bytes(A) + message) 37 | s = (r + h * a) % q | A.is_negative << 255 # Inject sign into bit 255 38 | return bytes(R) + tobytes(s) 39 | 40 | def xed_verify(pk: bytes, message: bytes, signature: bytes) -> None: 41 | if len(signature) != 64: 42 | raise ValueError("Invalid signature length") 43 | try: 44 | A = EdPoint.from_montbytes(pk) 45 | if A.is_low_order: raise ValueError 46 | except ValueError: 47 | raise ValueError("Invalid public key provided") 48 | try: 49 | R = EdPoint.from_bytes(signature[:32]) 50 | if R.is_low_order: raise ValueError 51 | except ValueError: 52 | raise ValueError("Invalid R point on signature") 53 | s = toint(signature[32:]) 54 | # Restore the sign of A from the high bit of s 55 | s, sign = tointsign(s) 56 | if sign: A = -A 57 | # Verify the signature 58 | if s >= q: 59 | raise ValueError("Invalid s value on signature") 60 | h = hashn(bytes(R) + bytes(A) + message) 61 | if R != s * G - h * A: 62 | raise ValueError("Signature mismatch") 63 | -------------------------------------------------------------------------------- /covert/exceptions.py: -------------------------------------------------------------------------------- 1 | class AuthenticationError(ValueError): 2 | """Authentication needed but not provided or is invalid""" 3 | 4 | class MalformedKeyError(AuthenticationError): 5 | """Key string is malformed or keyfile is unsupported/corrupt""" 6 | 7 | class DecryptError(ValueError): 8 | """Decryption failed""" 9 | 10 | class CliArgError(ValueError): 11 | """Invalid CLI argument""" 12 | -------------------------------------------------------------------------------- /covert/gui/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/covert-encryption/covert/96658bb4921af06293000ff2109d954efbf317b1/covert/gui/__init__.py -------------------------------------------------------------------------------- /covert/gui/__main__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from covert.gui import app 4 | 5 | 6 | def main(): 7 | sys.exit(app.App().exec()) 8 | 9 | 10 | if __name__ == "__main__": 11 | main() 12 | -------------------------------------------------------------------------------- /covert/gui/app.py: -------------------------------------------------------------------------------- 1 | from contextlib import suppress 2 | 3 | from PySide6.QtCore import QRect, Qt, QTimer, Slot 4 | from PySide6.QtGui import QAction, QGuiApplication, QKeySequence 5 | from PySide6.QtWidgets import * 6 | 7 | from covert import passphrase, util 8 | from covert.gui import res 9 | from covert.gui.decrypt import DecryptView 10 | from covert.gui.encrypt import NewMessageView 11 | from covert.gui.util import setup_interrupt_handling 12 | 13 | 14 | class App(QApplication): 15 | def __init__(self): 16 | QApplication.__init__(self, []) 17 | setup_interrupt_handling() 18 | res.load() 19 | 20 | # Override CLI password asking with GUI version 21 | passphrase.ask = self.askpass 22 | 23 | self.setApplicationName('Covert Encryption') 24 | self.setWindowIcon(res.icons.logo) 25 | self.windows = set() 26 | self.new_window() 27 | 28 | def askpass(self, prompt, create=False): 29 | window = next(iter(self.windows)) # FIXME: Keep track of active mainwindow in case there are multiple 30 | pw, ok = QInputDialog.getText(window, "Covert Encryption", f"{prompt}:", QLineEdit.Password, "") 31 | if not ok: raise ValueError(f"Needed {prompt}") 32 | return util.encode(pw), True 33 | 34 | @Slot() 35 | def new_window(self): 36 | self.windows.add(MainWindow(self)) 37 | 38 | class MainWindow(QMainWindow): 39 | def __init__(self, app): 40 | QMainWindow.__init__(self) 41 | self.setWindowTitle("Covert Encryption") 42 | self.app = app 43 | 44 | # Menu 45 | self.menu = self.menuBar() 46 | self.file_menu = self.menu.addMenu("File") 47 | 48 | new_action = QAction("&New Window", self) 49 | new_action.setShortcut(QKeySequence.New) 50 | new_action.triggered.connect(app.new_window) 51 | self.file_menu.addAction(new_action) 52 | 53 | new_action = QAction("Close Window", self) 54 | new_action.setShortcut(QKeySequence.Close) 55 | new_action.triggered.connect(self.close) 56 | self.file_menu.addAction(new_action) 57 | 58 | exit_action = QAction("E&xit", self) 59 | exit_action.setShortcut(QKeySequence.Quit) 60 | exit_action.triggered.connect(app.quit) 61 | self.file_menu.addAction(exit_action) 62 | 63 | # Toolbar 64 | self.addToolBar(Qt.TopToolBarArea, MainToolbar(self)) 65 | 66 | # Status Bar 67 | self.status = self.statusBar() 68 | 69 | self.setCentralWidget(WelcomeView(self)) 70 | self.show() 71 | 72 | def flash(self, message, duration=2.0): 73 | self.status.showMessage(message) 74 | QTimer.singleShot(1000 * duration, lambda: self.status.showMessage("")) 75 | 76 | @Slot() 77 | def encrypt_new(self): 78 | self.setCentralWidget(NewMessageView(self)) 79 | 80 | @Slot() 81 | def decrypt_paste(self): 82 | try: 83 | text = QGuiApplication.clipboard().text() 84 | if not text: 85 | raise ValueError("Nothing in clipboard to paste") 86 | data = util.armor_decode(text) 87 | self.decrypt(data) 88 | except ValueError as e: 89 | self.flash(str(e)) 90 | 91 | @Slot() 92 | def decrypt_file(self): 93 | try: 94 | file = QFileDialog.getOpenFileName(self, "Covert - Open file", "", "Covert Binary or Armored (*)")[0] 95 | if not file: 96 | return 97 | # TODO: Implement in a thread using mmap instead 98 | with open(file, "rb") as f: 99 | data = f.read() 100 | if 40 <= len(data) <= 2 * util.ARMOR_MAX_SIZE: 101 | # Try reading the file as armored text rather than binary 102 | with suppress(ValueError): 103 | data = util.armor_decode(data.decode()) 104 | self.decrypt(data) 105 | except ValueError as e: 106 | self.flash(str(e)) 107 | 108 | def decrypt(self, infile): 109 | d = DecryptView(self, infile) 110 | if not d.blockstream.header.key: 111 | # Only display that view if auth is needed; otherwise the DecryptView already 112 | # set the ArchiveView to display contents and we should do nothing here. 113 | self.setCentralWidget(d) 114 | 115 | class MainToolbar(QToolBar): 116 | def __init__(self, app): 117 | QToolBar.__init__(self) 118 | self.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) 119 | self.newbutton = self.addAction(res.icons.newicon, "New Message") 120 | self.addSeparator() 121 | self.pastebutton = self.addAction(res.icons.pasteicon, "Paste Armored") 122 | self.openbutton = self.addAction(res.icons.openicon, "Open Covert File") 123 | self.newbutton.triggered.connect(app.encrypt_new) 124 | self.pastebutton.triggered.connect(app.decrypt_paste) 125 | self.openbutton.triggered.connect(app.decrypt_file) 126 | 127 | class WelcomeView(QWidget): 128 | def __init__(self, app): 129 | QWidget.__init__(self) 130 | self.logo = QLabel() 131 | self.logo.setAlignment(Qt.AlignTop) 132 | self.logo.setGeometry(QRect(0, 0, 128, 128)) 133 | self.logo.setPixmap(res.icons.logo) 134 | 135 | self.text = QLabel("

Covert Encryption

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("

Authentication Required

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 | --------------------------------------------------------------------------------