├── test ├── test-clrypt │ ├── subdir │ │ └── another_subdir │ │ │ └── .gitkeep │ └── encrypted │ │ └── testing │ │ └── 05f8ef9229fe21844aacfe2ec6e63e2b-content.yml.smime ├── test-cert │ ├── test.crt │ └── test.dem ├── test_encdir.py ├── test_clrypt.py └── test_openssl.py ├── requirements.txt ├── .gitignore ├── setup.py ├── README.md ├── LICENSE └── clrypt ├── encdir.py ├── __init__.py └── openssl.py /test/test-clrypt/subdir/another_subdir/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | PyYAML>=3.11 2 | pyasn1==0.1.9 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | **/*.pyc 3 | *.swp 4 | build 5 | ve 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | try: 4 | from setuptools import setup 5 | except: 6 | from distutils.core import setup 7 | 8 | 9 | setup(name = "clrypt", 10 | version = "0.2.0", 11 | description = "A tool to encrypt/decrypt files.", 12 | author = "Color Genomics", 13 | author_email = "dev@getcolor.com", 14 | url = "https://github.com/ColorGenomics/clrypt", 15 | packages = ["clrypt"], 16 | install_requires=[ 17 | 'PyYAML>=3.10', 18 | 'pyasn1>=0.1.9', 19 | ], 20 | license = "MIT", 21 | ) 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # clrypt 2 | 3 | A tool to encrypt/decrypt files. 4 | 5 | ## Getting Started 6 | 7 | * Install clrypt 8 | ``` 9 | $ pip install git+https://git+https://github.com/ColorGenomics/clrypt.git@v0.1.3 10 | ``` 11 | 12 | * Create a directory called `encrypted` in your root directory. 13 | 14 | * Set path to encrypted dir. 15 | ``` 16 | $ export ENCRYPTED_DIR=/path/to/encrypted 17 | ``` 18 | 19 | * Set paths to cert and pk to use for encryption as environment variables. 20 | ``` 21 | $ export CLRYPT_CERT=/path/to/cert/file.crt 22 | $ export CLRYPT_PK=/path/to/pk/file.pem 23 | ``` 24 | 25 | * Write a encrypted file 26 | ``` 27 | $ import clrypt 28 | $ file_to_encrypt = open('some_file.txt') 29 | $ clrypt.write_file(file_to_encrypt, 'keys', 'database') 30 | ``` 31 | 32 | * Dencrypted a encrypted file 33 | ``` 34 | $ import clrypt 35 | $ clrypt.read_file('keys', 'database') 36 | ``` 37 | -------------------------------------------------------------------------------- /test/test-clrypt/encrypted/testing/05f8ef9229fe21844aacfe2ec6e63e2b-content.yml.smime: -------------------------------------------------------------------------------- 1 | MIME-Version: 1.0 2 | Content-Disposition: attachment; filename="smime.p7m" 3 | Content-Type: application/pkcs7-mime; smime-type=enveloped-data; name="smime.p7m" 4 | Content-Transfer-Encoding: base64 5 | 6 | MIICBwYJKoZIhvcNAQcDoIIB+DCCAfQCAQAxggG4MIIBtAIBADCBmzCBjTELMAkG 7 | A1UEBhMCVVMxEzARBgNVBAgTCkNhbGlmb3JuaWExFjAUBgNVBAcTDVNhbiBGcmFu 8 | Y2lzY28xFDASBgNVBAoTC0NvbG9yLCBJbmMuMRkwFwYDVQQDExBUZXN0IENlcnRp 9 | ZmljYXRlMSAwHgYJKoZIhvcNAQkBFhF6YWNrQGdldGNvbG9yLmNvbQIJAOB175Fw 10 | 8mbqMA0GCSqGSIb3DQEBAQUABIIBAIy7szxhz1DpmlRh2DgTsVpOyFQ38tdgtpNm 11 | NuOqclsqYPybtxB1D4VJSR1eA1FndauzAR89kytaQrqbTiaO6jMDorSGiaQFQTAj 12 | /UwlZdAvGlaiWUNGoorE+yhJXC5SgUSqU1sqy/hCbG1QYnnBNV1yy+90QiagZHOC 13 | d87bNU8eY/9pGxo2K8BQ9brXaxMyziUtVmL53j7JaIICNCc3v66pg8fHjadR+2FN 14 | O5udFUkB/FIEsy6qdROtZ/OlLEL4kqadI2IKp097NJzqK06qpIn3OHcIM+K8gOGq 15 | x6o7CUKQDSJX1f/qPA/w53tAqE6UCejLi9zgGgWC6dr7WN8LQoYwMwYJKoZIhvcN 16 | AQcBMBQGCCqGSIb3DQMHBAgFAi12AYHMS4AQH0vD38LsFrC00RgZ2dzdDQ== 17 | 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Permission is hereby granted, free of charge, to any person obtaining a 2 | copy of this software and associated documentation files (the 3 | "Software"), to deal in the Software without restriction, including 4 | without limitation the rights to use, copy, modify, merge, publish, dis- 5 | tribute, sublicense, and/or sell copies of the Software, and to permit 6 | persons to whom the Software is furnished to do so, subject to the fol- 7 | lowing conditions: 8 | 9 | The above copyright notice and this permission notice shall be included 10 | in all copies or substantial portions of the Software. 11 | 12 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 13 | OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABIL- 14 | ITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 15 | SHALL THE AUTHOR BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 16 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 17 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 18 | IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /test/test-cert/test.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIElTCCA32gAwIBAgIJAOB175Fw8mbqMA0GCSqGSIb3DQEBCwUAMIGNMQswCQYD 3 | VQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNU2FuIEZyYW5j 4 | aXNjbzEUMBIGA1UEChMLQ29sb3IsIEluYy4xGTAXBgNVBAMTEFRlc3QgQ2VydGlm 5 | aWNhdGUxIDAeBgkqhkiG9w0BCQEWEXphY2tAZ2V0Y29sb3IuY29tMB4XDTE1MTIx 6 | NTAzMjUxN1oXDTE3MTIxNDAzMjUxN1owgY0xCzAJBgNVBAYTAlVTMRMwEQYDVQQI 7 | EwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1TYW4gRnJhbmNpc2NvMRQwEgYDVQQKEwtD 8 | b2xvciwgSW5jLjEZMBcGA1UEAxMQVGVzdCBDZXJ0aWZpY2F0ZTEgMB4GCSqGSIb3 9 | DQEJARYRemFja0BnZXRjb2xvci5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw 10 | ggEKAoIBAQCdWGRkShhshSK9Gh9yDU6sfiOj4KOFCMhUwlpstMTs8c9+FQj1OXP3 11 | dgbe1nWbDSjj+Oo+nJKonVFyv0lRc1Cc9tx1s7pLmgS8THCbeRfe94M6/jxY12kC 12 | esWlaJn2HxGh2tFIJFmDkmOipqgdL9sUunFDAxDt4/Mj/+NZTvjwiwCu0wg17YQy 13 | Qdjuy7iF+xXracLruiGZU8Q9cfhnWJnC6oidjDHuR1ZmBccj0j0Yyi8cSS07ScsI 14 | /Zq92DtR2kyN9vOSUBYpd5o17kbyi3SsZ2R3fQ9+Xt+uZPLZ8UAeBNP8Bja/2mUI 15 | TxDTCPGXlDYEmzwsAddFpwOkVu2SrB7NAgMBAAGjgfUwgfIwHQYDVR0OBBYEFG5M 16 | DKeFLgkdByh92O86p+leZJq6MIHCBgNVHSMEgbowgbeAFG5MDKeFLgkdByh92O86 17 | p+leZJq6oYGTpIGQMIGNMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5p 18 | YTEWMBQGA1UEBxMNU2FuIEZyYW5jaXNjbzEUMBIGA1UEChMLQ29sb3IsIEluYy4x 19 | GTAXBgNVBAMTEFRlc3QgQ2VydGlmaWNhdGUxIDAeBgkqhkiG9w0BCQEWEXphY2tA 20 | Z2V0Y29sb3IuY29tggkA4HXvkXDyZuowDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0B 21 | AQsFAAOCAQEAVvcg1Z/HmdUJ07REiDqIbNvBeqjyk77XgnpFtWWcCxE23FfZwkSj 22 | Qpxx96Ckg0NB/+G93YkuLn6NBrVw/K2HSo+KHCiwrIlHT2DFfdZ9NC5Fg/AAEPlu 23 | 98mRxIPOKKDuRqeOobLLJquIJGtA1UrxrXuwC1SYlayFmFZuUqmc116lSqLZaUsO 24 | vYr+3mP/iYZq2I5mKRiPbXI8Cf7DWN38W3Fw1BFLTw/pU9S59Gqe9uwbOQs5PUna 25 | gKktI94mBhaPab9b9SLGrQJCbUv+rpsRxe0pXahc+GfmriB4wvxLlzqvIU6NK9QO 26 | MhEr8RSs00G+2pSWn/VMGPwmJG4eyM7SSQ== 27 | -----END CERTIFICATE----- 28 | -------------------------------------------------------------------------------- /test/test-cert/test.dem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEpAIBAAKCAQEAnVhkZEoYbIUivRofcg1OrH4jo+CjhQjIVMJabLTE7PHPfhUI 3 | 9Tlz93YG3tZ1mw0o4/jqPpySqJ1Rcr9JUXNQnPbcdbO6S5oEvExwm3kX3veDOv48 4 | WNdpAnrFpWiZ9h8RodrRSCRZg5JjoqaoHS/bFLpxQwMQ7ePzI//jWU748IsArtMI 5 | Ne2EMkHY7su4hfsV62nC67ohmVPEPXH4Z1iZwuqInYwx7kdWZgXHI9I9GMovHEkt 6 | O0nLCP2avdg7UdpMjfbzklAWKXeaNe5G8ot0rGdkd30Pfl7frmTy2fFAHgTT/AY2 7 | v9plCE8Q0wjxl5Q2BJs8LAHXRacDpFbtkqwezQIDAQABAoIBAHphrAQNVZ3il7h5 8 | vweYrh6gJdxq9wScZiT23ho7KAgbtIWems8Rls9c788XA3ZL8AgRLTDx22hmpFkZ 9 | y08c4BCWObcaycXPz+sdkWB7+UMlRN73q7x2H2kcUOpsx4OVesNnTOxNyYn6rKBv 10 | +8Zn2IDw2vOCSQKfEBhqCU0HjbyZSLufypMBZv63H4VzvvdEgZQcdEwQnv4IH/xk 11 | EymLhu+Cr9of4iY0JMhnWjU8LFnc9ayKaaLOQGNvHU2wYYfNQ3CSdySkAR4Hp/e5 12 | UxcBh/6F1q1rKZL5r3VQFiX7fKAl4Mrn6hP/lCcAsbZ8IaviTyJalQxAor7W/YBJ 13 | ykvP+AkCgYEA0V9MLUrX6v1sFrPsEnMzPwwB5pJAw5Hl7QQhxhfLaBqv6wf/Qs7p 14 | p5+uBglUFF7GXAK7/5tPAYLWII+3uM1zl083OJCnHz/BisVl1sM2jSU1XRl2mdOb 15 | 5rpkQO7GboQzJ8kvqHtsROZRSoNXj8MP2FZ2hF4T9jap6LazDWvguysCgYEAwGLw 16 | mdZIXM8n1G1rn970WgIosJqTrZtliLHVv45dh/umKdlpUMNZKvhiC/NwQoKftwxp 17 | vay3Z19TRuPHyDbDuOwTEd1GDyPDeDHwscCuHPlHo16eYA+cR44a7vpQ4jxxwSrP 18 | I1iFRFRjuJhIuruETPFr/E12t9lp3RGfXN0wMecCgYBj34+Y82C36ZdL5RuxWV6S 19 | romhkRZvtAL34saxldwjlsdf1/q9xbHTkeoTWxPOe78nWO6Q6Wbwk5bNBYFgGDER 20 | dmojA22VDHaoWa1QmuJExgEEngbjLfvqPfNgvgXN5iX4zpF7TxTAcRVJ9AkqvKOs 21 | UOJMFgxzoHPAXJZgypry7QKBgQC8l0CsTTaaZCfzQWAjU/fM1Bj3JlBl+tNJcKrM 22 | IM7nInT8yTdICHc8fEgA1x7Q1COk2PI/ETSojPWhpGCPj7/FYwY+mN64sKJJDQuZ 23 | 8/u6Q1NKftJZ8HOOYWtdoNvxKreIWGK9j2T0WpV2uzFwe6lxk6f7qCQcjXANWd0S 24 | t3ErOQKBgQCdw1I2vdWP8zrfc1xhIrNHCUcvYBIHFWlhNhJ5DE0en1OJyRNKZHs4 25 | 4nE8PJe5qGaXi1VL9Z6OHzcKTNEuZ+SU8s3bZ2CDIge1abdI3b+9Yo5zNP8jgrTz 26 | T3BRNFRXzFyQaF1sot8opVptmE78w1CcS1E1zHQeQglXKmID5zqQvw== 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /clrypt/encdir.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | class EncryptedDirectory(object): 5 | 6 | """ 7 | An interface to a directory which holds encrypted files. 8 | 9 | This class is used with a keypair (e.g. `clrypt.openssl.OpenSSLKeypair`) to 10 | manage reading and writing files to an encrypted directory. Files are 11 | prefixed with the key ID, so one encrypted directory can hold files 12 | encrypted with multiple keypairs. 13 | """ 14 | 15 | def __init__(self, encrypted_dir, keypair): 16 | self.encrypted_dir = encrypted_dir 17 | self.keypair = keypair 18 | 19 | def encrypted_file_path(self, group, name, ext='yaml'): 20 | """Get the path of an encrypted file with the given group and name.""" 21 | file_name = '%s-%s.%s.smime' % (self.keypair.get_key_id(), name, ext) 22 | return os.path.join(self.encrypted_dir, group, file_name) 23 | 24 | def read_file(self, group, name, ext='yaml'): 25 | """Read the named file as a bytestring of decrypted plaintext.""" 26 | with open(self.encrypted_file_path(group, name, ext=ext)) as encrypted: 27 | ciphertext = encrypted.read() 28 | return self.keypair.decrypt(ciphertext) 29 | 30 | def read_yaml_file(self, group, name, ext='yaml'): 31 | """Read the named file as decrypted YAML.""" 32 | import yaml 33 | return yaml.load(self.read_file(group, name, ext=ext)) 34 | 35 | def write_file(self, in_fp, group, name, ext='yaml'): 36 | """Encrypt and write the contents of a file-like object to the named file.""" 37 | 38 | # Encrypt the entire contents of the input file-like object at once. 39 | # TODO: investigate passing through in_fp.fileno(), when present. 40 | encrypted = self.keypair.encrypt(in_fp.read()) 41 | 42 | # Ensure the output path exists, creating it if it doesn't. 43 | out_path = self.encrypted_file_path(group, name, ext) 44 | dirname = os.path.dirname(out_path) 45 | if not os.path.isdir(dirname): 46 | os.makedirs(dirname) 47 | 48 | with open(out_path, 'w') as out_fp: 49 | out_fp.write(encrypted) 50 | return out_path 51 | 52 | -------------------------------------------------------------------------------- /test/test_encdir.py: -------------------------------------------------------------------------------- 1 | from StringIO import StringIO 2 | import os.path 3 | import shutil 4 | import unittest 5 | 6 | from clrypt.encdir import EncryptedDirectory 7 | 8 | 9 | TEST_DIR = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'test-encdir') 10 | 11 | 12 | class EncryptedDirectoryTest(unittest.TestCase): 13 | def setUp(self): 14 | if not os.path.isdir(TEST_DIR): 15 | os.makedirs(TEST_DIR) 16 | self.encdir = EncryptedDirectory(TEST_DIR, DummyKeypair()) 17 | 18 | def tearDown(self): 19 | shutil.rmtree(TEST_DIR) 20 | 21 | def test_encrypted_file_path(self): 22 | generated_path = self.encdir.encrypted_file_path('dev', 'secrets1', ext='yaml') 23 | self.assertEqual( 24 | os.path.join(TEST_DIR, 'dev', 'dummy12345-secrets1.yaml.smime'), 25 | generated_path) 26 | 27 | def test_read_file(self): 28 | path = self.encdir.encrypted_file_path('dev', 'secrets2', ext='yaml') 29 | os.makedirs(os.path.dirname(path)) 30 | with open(path, 'w') as fp: 31 | fp.write("E:some secret data") 32 | 33 | plaintext = self.encdir.read_file('dev', 'secrets2', ext='yaml') 34 | self.assertEqual(plaintext, "some secret data") 35 | 36 | def test_read_yaml_file(self): 37 | path = self.encdir.encrypted_file_path('dev', 'secrets3', ext='yaml') 38 | os.makedirs(os.path.dirname(path)) 39 | with open(path, 'w') as fp: 40 | fp.write('E:rootKey:\n') 41 | fp.write(' subKey1: value\n') 42 | fp.write(' subKey2: 123\n') 43 | 44 | plainobj = self.encdir.read_yaml_file('dev', 'secrets3', ext='yaml') 45 | self.assertEqual( 46 | plainobj, 47 | {'rootKey': {'subKey1': 'value', 'subKey2': 123}}) 48 | 49 | def test_write_file(self): 50 | path = self.encdir.encrypted_file_path('dev', 'secrets4', 'yaml') 51 | contents = "another secret datum" 52 | 53 | self.encdir.write_file(StringIO(contents), 'dev', 'secrets4', ext='yaml') 54 | with open(path) as fp: 55 | self.assertEqual(fp.read(), "E:" + contents) 56 | 57 | 58 | class DummyKeypair(object): 59 | def get_key_id(self): 60 | return "dummy12345" 61 | 62 | def encrypt(self, bytes): 63 | return "E:" + bytes 64 | 65 | def decrypt(self, bytes): 66 | assert bytes.startswith("E:") 67 | return bytes[2:] 68 | -------------------------------------------------------------------------------- /clrypt/__init__.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | import threading 3 | 4 | from .encdir import EncryptedDirectory 5 | from .openssl import OpenSSLKeypair 6 | 7 | 8 | _environment = threading.local() 9 | 10 | 11 | def _get_encdir(): 12 | if not hasattr(_environment, 'encdir'): 13 | cert_file = os.environ.get('CLRYPT_CERT') 14 | if cert_file is None: 15 | raise RuntimeError("The environment variable CLRYPT_CERT must be set") 16 | if not os.path.isfile(cert_file): 17 | raise RuntimeError("CLRYPT_CERT points to a non-existent file: %r" % cert_file) 18 | 19 | pk_file = os.environ.get('CLRYPT_PK') 20 | if pk_file is None: 21 | raise RuntimeError("The environment variable CLRYPT_PK must be set") 22 | if not os.path.isfile(pk_file): 23 | raise RuntimeError("CLRYPT_PK points to a non-existent file: %r" % pk_file) 24 | 25 | encrypted_dir = os.environ.get('ENCRYPTED_DIR') 26 | if encrypted_dir is None: 27 | encrypted_dir = _find_encrypted_directory(os.getcwd()) 28 | if encrypted_dir is None: 29 | raise RuntimeError("Couldn't find an encrypted directory in " 30 | "the current dir or its ancestors") 31 | if not os.path.isdir(encrypted_dir): 32 | raise RuntimeError("ENCRYPTED_DIR points to a non-existent " 33 | "directory: %r" % encrypted_dir) 34 | 35 | _environment.keypair = OpenSSLKeypair(cert_file, pk_file) 36 | _environment.encdir = EncryptedDirectory(encrypted_dir, 37 | _environment.keypair) 38 | return _environment.encdir 39 | 40 | 41 | def _find_encrypted_directory(current_dir, dirname='encrypted', limit=100): 42 | # Stop if the limit has been reached, or we're at the root dir 43 | while limit > 0 and os.path.dirname(current_dir) != current_dir: 44 | if os.path.isdir(os.path.join(current_dir, dirname)): 45 | return os.path.join(current_dir, dirname) 46 | current_dir = os.path.abspath(os.path.dirname(current_dir)) 47 | limit -= 1 48 | 49 | 50 | def read_file(group, name, ext='yaml'): 51 | """Decrypt and read the named encrypted file.""" 52 | return _get_encdir().read_file(group, name, ext=ext) 53 | 54 | def read_file_as_dict(group, name, ext='yaml'): 55 | """Read the specified encrypted file as a YAML dictionary.""" 56 | return _get_encdir().read_yaml_file(group, name, ext=ext) 57 | 58 | def write_file(in_fp, group, name, ext='yaml'): 59 | """Encrypt and write the contents of in_fp to the named encrypted file.""" 60 | return _get_encdir().write_file(in_fp, group, name, ext=ext) 61 | -------------------------------------------------------------------------------- /test/test_clrypt.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | import unittest 3 | 4 | import clrypt 5 | 6 | 7 | ## Some directories for testing encrypted directory discovery. 8 | # A directory from which the 'encrypted' dir can be found. 9 | TEST_DIR = os.path.join( 10 | os.path.abspath(os.path.dirname(__file__)), 'test-clrypt') 11 | # The expected absolute path of the found 'encrypted' dir. 12 | EXPECTED_ENC_DIR = os.path.join(TEST_DIR, 'encrypted') 13 | # A subdirectory whose ancestor contains the 'encrypted' dir. 14 | SUB_DIR = os.path.join(TEST_DIR, 'subdir', 'another_subdir') 15 | # A parent directory -- the 'encrypted' dir will not be discoverable from here. 16 | PARENT_DIR = os.path.dirname(TEST_DIR) 17 | 18 | CERT_FILE = os.path.join( 19 | os.path.abspath(os.path.dirname(__file__)), 'test-cert', 'test.crt') 20 | 21 | PK_FILE = os.path.join( 22 | os.path.abspath(os.path.dirname(__file__)), 'test-cert', 'test.dem') 23 | 24 | 25 | class TestFindEncryptedDirectory(unittest.TestCase): 26 | def test_finds_when_in_same_directory(self): 27 | self.assertEqual( 28 | clrypt._find_encrypted_directory(TEST_DIR), 29 | EXPECTED_ENC_DIR) 30 | 31 | def test_finds_when_in_parent_directory(self): 32 | self.assertEqual( 33 | clrypt._find_encrypted_directory(SUB_DIR), 34 | EXPECTED_ENC_DIR) 35 | 36 | def test_finds_when_in_encrypted_directory(self): 37 | self.assertEqual( 38 | clrypt._find_encrypted_directory(EXPECTED_ENC_DIR), 39 | EXPECTED_ENC_DIR) 40 | 41 | def test_doesnt_find_when_encrypted_dir_is_not_in_ancestor_directory(self): 42 | self.assertIsNone(clrypt._find_encrypted_directory(PARENT_DIR)) 43 | 44 | 45 | class TestEnvironment(unittest.TestCase): 46 | """Test that the global clrypt environment (based on os.environ) is managed correctly.""" 47 | 48 | def setUp(self): 49 | self.prev_dir = os.getcwd() 50 | 51 | def tearDown(self): 52 | for attr in vars(clrypt._environment).keys(): 53 | delattr(clrypt._environment, attr) 54 | os.chdir(self.prev_dir) 55 | 56 | def test_happy_path(self): 57 | os.environ['CLRYPT_CERT'] = CERT_FILE 58 | os.environ['CLRYPT_PK'] = PK_FILE 59 | os.environ['ENCRYPTED_DIR'] = EXPECTED_ENC_DIR 60 | 61 | decrypted = clrypt.read_file("testing", "content", ext="yml") 62 | self.assertEqual(decrypted, "test content") 63 | 64 | def test_happy_path_find_encrypted_dir(self): 65 | os.environ['CLRYPT_CERT'] = CERT_FILE 66 | os.environ['CLRYPT_PK'] = PK_FILE 67 | os.environ.pop('ENCRYPTED_DIR', None) 68 | os.chdir(TEST_DIR) 69 | 70 | decrypted = clrypt.read_file("testing", "content", ext="yml") 71 | self.assertEqual(decrypted, "test content") 72 | -------------------------------------------------------------------------------- /test/test_openssl.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | import unittest 3 | 4 | import clrypt.openssl 5 | 6 | 7 | TEST_CERT_DIR = os.path.join(os.path.dirname(__file__), 'test-cert') 8 | 9 | 10 | class TestBignumToMPI(unittest.TestCase): 11 | """Make sure we're encoding integers correctly.""" 12 | 13 | def test_exponent(self): 14 | self.assertEqual( 15 | clrypt.openssl.bignum_to_mpi(65537L), 16 | '\x00\x00\x00\x03\x01\x00\x01') 17 | 18 | def test_modulus(self): 19 | self.assertEqual( 20 | clrypt.openssl.bignum_to_mpi( 21 | 17652187434302119818626903597973364646688501428151181083346396650190495499971143555821153235865918471667488379960690165155890225429359619542780951438912782907420720337860016081609304413559138090809578303571230455363722891195091112107003860558073312270213669052061287420596945410842819944331551665414956709934244337764287956982989490636986364084315970710464920930036478862933506058288047831177960977170956029647528492070455408969834953275116251472162035375269818449753491792832735260819579628653112578006009233208029743042292911927382613736571054059145327226830704584124567079108161933244408783987994310178893677777563L), 22 | '\x00\x00\x01\x01\x00\x8b\xd5\x0e\xf7s\xde\xce\xcayg\xe5s\xaf' 23 | '\xa5\\\x95\xd9\xbd\xb3\xff4\xa9\x98T\xe6^\x91\xcc\xb9X\xda*' 24 | '\xf3W@\xed\x8b\xd7E\rB\xa7\x17l\x83_s\x8479\xa2\x92}SL\x007g' 25 | '\x829\xfdz\x1bwf\x060}\xd1\xaagXF\xf1\x12n\x96z\xba\xa3\xd9' 26 | '\xb1\x91\x98\x99\xf4.\xbfo\xd1\x13\xb8\x97p^*\x16\x0bi~\xd5' 27 | '\x10\x07\xa7\x7f\x86D\x9a\xf3]0YZ4\xea\xe9\x17\xe1\x86\x96' 28 | '\xad\xe9;\xcf\xd3T+\x91U#K.\xdb\xcc\x06\x90e]\x88\x0e[hs\xde' 29 | '\xbbm\x16\xc9\x19@\xd9{FI\x04\xe7\xf6\xd5\xcb\xff\xe7&\xce' 30 | '\xaa\x0e\x88{\xc7\xfa\xe6\x94d\x1d\xf9\x00\x18\xa2[\xeaf\xf1' 31 | '\xea\xe7\xc2ZG\x99\xfc\xe8\xb9|\xc7\xa4r\x06\x7f\x1e\tA\xaa' 32 | '\x1a\xe6\xe0\x86\x85\x11\xf0q?\xdc\xa0c\xbey\x05[u\xe5}>\xf5' 33 | '\xfc\x85\xaa\xff\x93v\xf7\xdf\xc6\xffv\xcei47\x03\xb1\xd0vR' 34 | '\x90\x16\xf5\x1a\xad\x1eH\x9dRW(\xea\xa4\xd2\x9b') 35 | 36 | 37 | class TestOpenSSLKeypair(unittest.TestCase): 38 | 39 | def setUp(self): 40 | self.keypair = clrypt.openssl.OpenSSLKeypair( 41 | os.path.join(TEST_CERT_DIR, 'test.crt'), 42 | os.path.join(TEST_CERT_DIR, 'test.dem')) 43 | 44 | def test_key_id(self): 45 | """A regression test: key IDs should never change.""" 46 | self.assertEqual( 47 | self.keypair.get_key_id(), 48 | "05f8ef9229fe21844aacfe2ec6e63e2b") 49 | 50 | def test_encryption_cycle(self): 51 | """A smoke test: check that decrypt(encrypt(text)) == text.""" 52 | message = "Some message" 53 | encrypted = self.keypair.encrypt(message) 54 | self.assertNotEqual(encrypted, message) 55 | decrypted = self.keypair.decrypt(encrypted) 56 | self.assertEqual(decrypted, message) 57 | -------------------------------------------------------------------------------- /clrypt/openssl.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import struct 3 | import subprocess 4 | 5 | from pyasn1.codec.der import decoder 6 | 7 | 8 | class OpenSSLKeypair(object): 9 | 10 | """ 11 | An X509 cert and private key, which can en/decrypt arbitrary bytestrings. 12 | """ 13 | 14 | def __init__(self, cert_filename, key_filename, openssl_bin='openssl'): 15 | """ 16 | Create a keypair from the provided certificate and key file paths. 17 | 18 | Optionally, override the path to the openssl binary used for 19 | encryption/decryption. 20 | """ 21 | self.cert_filename = cert_filename 22 | self.key_filename = key_filename 23 | self.openssl_bin = openssl_bin 24 | 25 | def get_key_id(self): 26 | """ 27 | Get the key ID for this certificate. 28 | 29 | clrypt computes the key ID by taking the MD5 of the certificate's 30 | public key exponent concatenated with its modulus. Both of these 31 | numbers are first converted to OpenSSL's multi-precision integer 32 | format; see `bignum_to_mpi()` for details. 33 | """ 34 | pubkey_text = subprocess.check_output( 35 | [self.openssl_bin, 'x509', '-pubkey', '-noout', 36 | '-in', self.cert_filename]) 37 | modulus, exponent = parse_pubkey(pubkey_text) 38 | 39 | m = hashlib.md5() 40 | m.update(bignum_to_mpi(exponent)) 41 | m.update(bignum_to_mpi(modulus)) 42 | return m.hexdigest() 43 | 44 | def encrypt(self, plaintext): 45 | """Encrypt a plaintext bytestring to an S/MIME-encoded bytestring.""" 46 | pipe = subprocess.Popen( 47 | [self.openssl_bin, 'smime', '-encrypt', '-des3', 48 | self.cert_filename], 49 | stdin=subprocess.PIPE, 50 | stderr=subprocess.PIPE, 51 | stdout=subprocess.PIPE) 52 | encrypted, err = pipe.communicate(input=plaintext) 53 | if pipe.poll() != 0: 54 | raise RuntimeError("Error encrypting plaintext: %r" % err) 55 | return encrypted 56 | 57 | def decrypt(self, ciphertext): 58 | """Decrypt an S/MIME-encoded bytestring to a plaintext bytestring.""" 59 | pipe = subprocess.Popen( 60 | [self.openssl_bin, 'smime', '-decrypt', 61 | '-inkey', self.key_filename, 62 | '-binary', 63 | self.cert_filename], 64 | stdin=subprocess.PIPE, 65 | stderr=subprocess.PIPE, 66 | stdout=subprocess.PIPE) 67 | decrypted, err = pipe.communicate(input=ciphertext) 68 | if pipe.poll() != 0: 69 | raise RuntimeError("Error decrypting ciphertext: %r" % err) 70 | return decrypted 71 | 72 | 73 | def bignum_to_mpi(integer): 74 | """ 75 | Convert an arbitrary-precision Python integer to OpenSSL's multi-precision 76 | integer byte format. 77 | 78 | See the output of ``man BN_bn2mpi`` for details on the format itself. This 79 | function is used to get consistent certificate IDs with the previous 80 | M2Crypto-based version of clrypt. 81 | """ 82 | bits = integer.bit_length() 83 | num_bytes = (bits + 7) // 8 84 | if bits > 0: 85 | extra = (bits & 0x07) == 0 86 | length = num_bytes + extra 87 | 88 | if extra: 89 | if integer < 0: 90 | integer = abs(integer) 91 | header = struct.pack('>I', length) + b'\x80' 92 | else: 93 | header = struct.pack('>I', length) + b'\x00' 94 | else: 95 | header = struct.pack('>I', length) 96 | 97 | # Build a big-endian arbitrary-length representation of the number 98 | raw_bytes = [] 99 | while integer > 0: 100 | raw_bytes.insert(0, chr(integer % 256)) 101 | integer = integer >> 8 102 | raw_bytestring = b''.join(raw_bytes) 103 | 104 | return header + raw_bytestring 105 | 106 | 107 | def parse_pubkey(pubkey_s): 108 | """ 109 | Get the integer modulus and exponent of a provided RSA public key. 110 | 111 | The key should be a string in the format: 112 | 113 | -----BEGIN PUBLIC KEY----- 114 | ...base64... 115 | -----END PUBLIC KEY----- 116 | """ 117 | der_encoded = ''.join(pubkey_s.strip().splitlines()[1:-1]).decode('base64') 118 | rsa_params_encoded = bitstring_to_bytes(decoder.decode(der_encoded)[0][1]) 119 | rsa_params = decoder.decode(rsa_params_encoded) 120 | modulus, exponent = long(rsa_params[0][0]), long(rsa_params[0][1]) 121 | return (modulus, exponent) 122 | 123 | def bitstring_to_bytes(bitstring): 124 | """Convert PyASN1's strings of 1s and 0s to actual bytestrings.""" 125 | 126 | if len(bitstring) % 8 != 0: 127 | raise ValueError("Unaligned bitstrings cannot be converted to bytes") 128 | 129 | raw_bytes = [] 130 | ones_and_zeros = ''.join(str(b) for b in bitstring) 131 | while ones_and_zeros: 132 | raw_bytes.append(chr(int(ones_and_zeros[:8], 2))) 133 | ones_and_zeros = buffer(ones_and_zeros, 8) 134 | return ''.join(raw_bytes) 135 | --------------------------------------------------------------------------------