commit
9d274de709
9 changed files with 519 additions and 15 deletions
@ -0,0 +1,84 @@ |
|||||||
|
#!/usr/bin/env python |
||||||
|
|
||||||
|
import crochet |
||||||
|
import glob |
||||||
|
import pappyproxy |
||||||
|
|
||||||
|
import zipfile |
||||||
|
import tarfile |
||||||
|
|
||||||
|
try: |
||||||
|
import bz2 |
||||||
|
except ImportError: |
||||||
|
bz2 = None |
||||||
|
print "BZ2 not installed on your system" |
||||||
|
|
||||||
|
from base64 import b64encode, b64decode |
||||||
|
from os import getcwd, sep, path, urandom |
||||||
|
|
||||||
|
|
||||||
|
class Compress(object): |
||||||
|
def __init__(self, sessconfig): |
||||||
|
self.config = sessconfig |
||||||
|
self.zip_archive = sessconfig.archive |
||||||
|
self.bz2_archive = sessconfig.archive |
||||||
|
|
||||||
|
def compress_project(self): |
||||||
|
if bz2: |
||||||
|
self.tar_project() |
||||||
|
else: |
||||||
|
self.zip_project() |
||||||
|
|
||||||
|
def decompress_project(self): |
||||||
|
if bz2: |
||||||
|
self.untar_project() |
||||||
|
else: |
||||||
|
self.unzip_project() |
||||||
|
|
||||||
|
def zip_project(self): |
||||||
|
""" |
||||||
|
Zip project files |
||||||
|
|
||||||
|
Using append mode (mode='a') will create a zip archive |
||||||
|
if none exists in the project. |
||||||
|
""" |
||||||
|
try: |
||||||
|
zf = zipfile.ZipFile(self.zip_archive, mode="a") |
||||||
|
zf.write(self.config.crypt_dir) |
||||||
|
zf.close() |
||||||
|
except zipfile.LargeZipFile as e: |
||||||
|
raise PappyException("Project zipfile too large. Error: ", e) |
||||||
|
|
||||||
|
def unzip_project(self): |
||||||
|
""" |
||||||
|
Extract project files from decrypted zip archive. |
||||||
|
Initially checks the zip archive's magic number and |
||||||
|
attempts to extract pappy.json to validate integrity |
||||||
|
of the zipfile. |
||||||
|
""" |
||||||
|
if not zipfile.is_zipfile(self.zip_archive): |
||||||
|
raise PappyException("Project archive corrupted.") |
||||||
|
|
||||||
|
zf = zipfile.ZipFile(self.zip_archive) |
||||||
|
|
||||||
|
try: |
||||||
|
zf.extract("config.json") |
||||||
|
except zipfile.BadZipfile as e: |
||||||
|
raise PappyException("Zip archive corrupted. Error: ", e) |
||||||
|
|
||||||
|
zf.extractall() |
||||||
|
|
||||||
|
def tar_project(self): |
||||||
|
archive = tarfile.open(self.bz2_archive, 'w:bz2') |
||||||
|
|
||||||
|
archive.add(self.config.crypt_dir) |
||||||
|
archive.close() |
||||||
|
|
||||||
|
def untar_project(self): |
||||||
|
if tarfile.is_tarfile(self.bz2_archive): |
||||||
|
# Raise exception if there is a failure |
||||||
|
try: |
||||||
|
with tarfile.open(self.bz2_archive, "r:bz2") as archive: |
||||||
|
archive.extractall() |
||||||
|
except tarfile.ExtractError as e: |
||||||
|
raise PappyException("Tar archive corrupted. Error: ", e) |
@ -0,0 +1,236 @@ |
|||||||
|
#!/usr/bin/env python |
||||||
|
|
||||||
|
import crochet |
||||||
|
import getpass |
||||||
|
import glob |
||||||
|
import os |
||||||
|
import pappyproxy |
||||||
|
import scrypt |
||||||
|
import shutil |
||||||
|
import twisted |
||||||
|
|
||||||
|
from . import compress |
||||||
|
from .util import PappyException |
||||||
|
from base64 import b64encode, b64decode |
||||||
|
from cryptography.fernet import Fernet, InvalidToken |
||||||
|
from twisted.internet import reactor, defer |
||||||
|
|
||||||
|
|
||||||
|
class Crypto(object): |
||||||
|
def __init__(self, sessconfig): |
||||||
|
self.config = sessconfig |
||||||
|
self.archive = sessconfig.archive |
||||||
|
self.compressor = compress.Compress(sessconfig) |
||||||
|
self.key = None |
||||||
|
self.password = None |
||||||
|
self.salt = None |
||||||
|
|
||||||
|
def encrypt_project(self): |
||||||
|
""" |
||||||
|
Compress and encrypt the project files, |
||||||
|
deleting clear-text files afterwards |
||||||
|
""" |
||||||
|
|
||||||
|
# Leave the crypto working directory |
||||||
|
if self.config.crypt_dir in os.getcwd(): |
||||||
|
os.chdir('../') |
||||||
|
|
||||||
|
self.compressor.compress_project() |
||||||
|
|
||||||
|
# Get the password and salt, then derive the key |
||||||
|
self.crypto_ramp_up() |
||||||
|
|
||||||
|
# Create project and crypto archive |
||||||
|
archive_file = open(self.archive, 'rb') |
||||||
|
archive_crypt = open(self.config.crypt_file, 'wb') |
||||||
|
|
||||||
|
try: |
||||||
|
# Encrypt the archive read as a bytestring |
||||||
|
fern = Fernet(self.key) |
||||||
|
crypt_token = fern.encrypt(archive_file.read()) |
||||||
|
archive_crypt.write(crypt_token) |
||||||
|
except InvalidToken as e: |
||||||
|
raise PappyException("Error encrypting project: ", e) |
||||||
|
return False |
||||||
|
|
||||||
|
archive_file.close() |
||||||
|
archive_crypt.close() |
||||||
|
|
||||||
|
# Store the salt for the next decryption |
||||||
|
self.create_salt_file() |
||||||
|
|
||||||
|
# Delete clear-text files |
||||||
|
self.delete_clear_files() |
||||||
|
return True |
||||||
|
|
||||||
|
def decrypt_project(self): |
||||||
|
""" |
||||||
|
Decrypt and decompress the project files |
||||||
|
""" |
||||||
|
|
||||||
|
# Decrypt and decompress the project if crypt_file exists |
||||||
|
if os.path.isfile(self.config.crypt_file): |
||||||
|
cf = self.config.crypt_file |
||||||
|
sl = self.config.salt_len |
||||||
|
crl = os.path.getsize(cf) - sl |
||||||
|
|
||||||
|
archive_crypt = open(cf, 'rb').read(crl) |
||||||
|
archive_file = open(self.config.archive, 'wb') |
||||||
|
|
||||||
|
retries = 3 |
||||||
|
while True: |
||||||
|
try: |
||||||
|
self.crypto_ramp_up() |
||||||
|
fern = Fernet(self.key) |
||||||
|
archive = fern.decrypt(archive_crypt) |
||||||
|
break |
||||||
|
except InvalidToken as e: |
||||||
|
print "Invalid decryption: ", e |
||||||
|
retries -= 1 |
||||||
|
# Quit pappy if user doesn't retry |
||||||
|
# or if all retries exhuasted |
||||||
|
if not self.confirm_password_retry() or retries <= 0: |
||||||
|
os.remove(self.config.archive) |
||||||
|
return False |
||||||
|
else: |
||||||
|
self.password = None |
||||||
|
self.key = None |
||||||
|
pass |
||||||
|
|
||||||
|
archive_file.write(archive) |
||||||
|
archive_file.close() |
||||||
|
|
||||||
|
self.compressor.decompress_project() |
||||||
|
self.delete_crypt_files() |
||||||
|
|
||||||
|
os.chdir(self.config.crypt_dir) |
||||||
|
return True |
||||||
|
# If project exited before encrypting the working directory |
||||||
|
# change to the working directory to resume the session |
||||||
|
elif os.path.isdir(self.config.crypt_dir): |
||||||
|
os.chdir(self.config.crypt_dir) |
||||||
|
return True |
||||||
|
# If project hasn't been encrypted before, |
||||||
|
# setup crypt working directory |
||||||
|
else: |
||||||
|
os.mkdir(self.config.crypt_dir) |
||||||
|
|
||||||
|
project_files = self.config.get_project_files() |
||||||
|
for pf in project_files: |
||||||
|
shutil.copy2(pf, self.config.crypt_dir) |
||||||
|
os.chdir(self.config.crypt_dir) |
||||||
|
return True |
||||||
|
|
||||||
|
|
||||||
|
def confirm_password_retry(self): |
||||||
|
answer = raw_input("Re-enter your password? (y/n): ").strip() |
||||||
|
if answer[0] == "y" or answer[0] == "Y": |
||||||
|
return True |
||||||
|
else: |
||||||
|
return False |
||||||
|
|
||||||
|
def crypto_ramp_up(self): |
||||||
|
if not self.password: |
||||||
|
self.get_password() |
||||||
|
if not self.salt: |
||||||
|
self.set_salt() |
||||||
|
self.derive_key() |
||||||
|
|
||||||
|
def get_password(self): |
||||||
|
""" |
||||||
|
Retrieve password from the user. Raise an exception if the |
||||||
|
password is not capable of utf-8 encoding. |
||||||
|
""" |
||||||
|
encoded_passwd = "" |
||||||
|
try: |
||||||
|
passwd = getpass.getpass("Enter a password: ").strip() |
||||||
|
self.password = passwd.encode("utf-8") |
||||||
|
except: |
||||||
|
raise PappyException("Invalid password, try again") |
||||||
|
|
||||||
|
def set_salt(self): |
||||||
|
if self.config.crypt_dir in os.getcwd(): |
||||||
|
os.chdir('../') |
||||||
|
self.set_salt_from_file() |
||||||
|
os.chdir(self.config.crypt_dir) |
||||||
|
elif os.path.isfile(self.config.crypt_file): |
||||||
|
self.set_salt_from_file() |
||||||
|
else: |
||||||
|
self.salt = os.urandom(16) |
||||||
|
|
||||||
|
def set_salt_from_file(self): |
||||||
|
try: |
||||||
|
# Seek to `salt_len` bytes before the EOF |
||||||
|
# then read `salt_len` bytes to retrieve the salt |
||||||
|
|
||||||
|
# WARNING: must open `crypt_file` in `rb` mode |
||||||
|
# or `salt_file.seek()` will result in undefined |
||||||
|
# behavior. |
||||||
|
salt_file = open(self.config.crypt_file, 'rb') |
||||||
|
sl = self.config.salt_len |
||||||
|
# Negate the salt length to seek to the |
||||||
|
# correct position in the buffer |
||||||
|
salt_file.seek(-sl, 2) |
||||||
|
self.salt = salt_file.read(sl) |
||||||
|
salt_file.close() |
||||||
|
except: |
||||||
|
cf = self.config.crypt_file |
||||||
|
raise PappyException("Unable to read %s" % cf) |
||||||
|
|
||||||
|
def create_salt_file(self): |
||||||
|
salt_file = open(self.config.crypt_file, 'a') |
||||||
|
salt_file.write(self.salt) |
||||||
|
salt_file.close() |
||||||
|
|
||||||
|
def derive_key(self): |
||||||
|
""" |
||||||
|
Derive a key sufficient for use as a cryptographic key |
||||||
|
used to encrypt the project (currently: cryptography.Fernet). |
||||||
|
|
||||||
|
cryptography.Fernet utilizes AES-CBC-128, requiring a 32-byte key. |
||||||
|
Parameter notes from the py-scrypt source-code: |
||||||
|
https://bitbucket.org/mhallin/py-scrypt/ |
||||||
|
|
||||||
|
Compute scrypt(password, salt, N, r, p, buflen). |
||||||
|
|
||||||
|
The parameters r, p, and buflen must satisfy r * p < 2^30 and |
||||||
|
buflen <= (2^32 - 1) * 32. The parameter N must be a power of 2 |
||||||
|
greater than 1. N, r and p must all be positive. |
||||||
|
|
||||||
|
Notes for Python 2: |
||||||
|
- `password` and `salt` must be str instances |
||||||
|
- The result will be a str instance |
||||||
|
|
||||||
|
Notes for Python 3: |
||||||
|
- `password` and `salt` can be both str and bytes. If they are str |
||||||
|
instances, they wil be encoded with utf-8. |
||||||
|
- The result will be a bytes instance |
||||||
|
|
||||||
|
Exceptions raised: |
||||||
|
- TypeError on invalid input |
||||||
|
- scrypt.error if scrypt failed |
||||||
|
""" |
||||||
|
|
||||||
|
try: |
||||||
|
if not self.key: |
||||||
|
shash = scrypt.hash(self.password, self.salt, buflen=32) |
||||||
|
self.key = b64encode(shash) |
||||||
|
except TypeError as e: |
||||||
|
raise PappyException("Scrypt failed with type error: ", e) |
||||||
|
except scrypt.error, e: |
||||||
|
raise PappyException("Scrypt failed with internal error: ", e) |
||||||
|
|
||||||
|
def delete_clear_files(self): |
||||||
|
""" |
||||||
|
Deletes all clear-text files left in the project directory. |
||||||
|
""" |
||||||
|
shutil.rmtree(self.config.crypt_dir) |
||||||
|
os.remove(self.config.archive) |
||||||
|
|
||||||
|
def delete_crypt_files(self): |
||||||
|
""" |
||||||
|
Deletes all encrypted-text files in the project directory. |
||||||
|
Forces generation of new salt after opening and closing the project. |
||||||
|
Adds security in the case of a one-time compromise of the system. |
||||||
|
""" |
||||||
|
os.remove(self.config.crypt_file) |
@ -0,0 +1,62 @@ |
|||||||
|
import os |
||||||
|
import pytest |
||||||
|
import random |
||||||
|
import string |
||||||
|
from pappyproxy.session import Session |
||||||
|
from pappyproxy.crypto import Crypto |
||||||
|
from pappyproxy.config import PappyConfig |
||||||
|
|
||||||
|
@pytest.fixture |
||||||
|
def conf(): |
||||||
|
c = PappyConfig() |
||||||
|
return c |
||||||
|
|
||||||
|
@pytest.fixture |
||||||
|
def crypt(): |
||||||
|
c = Crypto(conf()) |
||||||
|
return c |
||||||
|
|
||||||
|
@pytest.fixture |
||||||
|
def tmpname(): |
||||||
|
cns = string.ascii_lowercase + string.ascii_uppercase + string.digits |
||||||
|
tn = '' |
||||||
|
for i in xrange(8): |
||||||
|
tn += cns[random.randint(0,len(cns)-1)] |
||||||
|
return tn |
||||||
|
|
||||||
|
tmpdir = '/tmp/test_crypto'+tmpname() |
||||||
|
tmpfiles = ['cmdhistory', 'config.json', 'data.db'] |
||||||
|
tmp_pass = 'fugyeahbruh' |
||||||
|
|
||||||
|
def stub_files(): |
||||||
|
enter_tmpdir() |
||||||
|
for sf in tmpfiles: |
||||||
|
with os.fdopen(os.open(sf, os.O_CREAT, 0o0600), 'r'): |
||||||
|
pass |
||||||
|
|
||||||
|
def enter_tmpdir(): |
||||||
|
if not os.path.isdir(tmpdir): |
||||||
|
os.mkdir(tmpdir) |
||||||
|
os.chdir(tmpdir) |
||||||
|
|
||||||
|
def test_decrypt_tmpdir(): |
||||||
|
enter_tmpdir() |
||||||
|
c = crypt() |
||||||
|
|
||||||
|
# Stub out the password, working with stdout is a pain with pytest |
||||||
|
c.password = tmp_pass |
||||||
|
|
||||||
|
c.decrypt_project() |
||||||
|
assert os.path.isdir(os.path.join(os.getcwd(), '../crypt')) |
||||||
|
|
||||||
|
def test_decrypt_copy_files(): |
||||||
|
enter_tmpdir() |
||||||
|
stub_files() |
||||||
|
c = crypt() |
||||||
|
|
||||||
|
# Stub out the password, working with stdout is a pain with pytest |
||||||
|
c.password = tmp_pass |
||||||
|
|
||||||
|
c.decrypt_project() |
||||||
|
for tf in tmpfiles: |
||||||
|
assert os.path.isfile(os.path.join(os.getcwd(),tf)) |
Loading…
Reference in new issue