diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..0446dbb --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,34 @@ +name: Build +on: + release: + types: [released] + +jobs: + build: + name: Build wheel + runs-on: ubuntu-latest + permissions: + id-token: write + contents: write + attestations: write + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: 3.x + + - run: pip install build + + - name: Build wheel + run: python -m build + + - name: Upload Artifacts + uses: actions/upload-artifact@v4 + with: + path: dist/* + + - name: Release + uses: softprops/action-gh-release@v2 + with: + files: dist/* diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7dab5d1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI +on: [push, workflow_dispatch] + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: 3.x + + - run: pip install -r requirements.txt + + - uses: actions/setup-node@v4 + with: + node-version: latest + + - run: npm i conf + + - name: Generate conf output + run: node tests/main.mjs + + - name: Run Tests + shell: bash + run: | + python -m unittest discover -s tests diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1121ae6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +__pycache__ +build/ +dist/ +**/*.egg-info/ +node_modules/ + +config.json +config_plaintext.json +key.txt diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9664a67 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Dimma Don't + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2201a1c --- /dev/null +++ b/README.md @@ -0,0 +1,30 @@ +# py-crypt-sindresorhus-conf + +This Python library encrypts/decrypts `sindresorhus/conf` files. + +## Usage example +```python +import json +import os + +from crypt_sindresorhus_conf import CryptSindresorhusConf + +key = b"hello there" +iv = os.urandom(16) +conf_crypt = CryptSindresorhusConf(key, iv) +encrypted = conf_crypt.encrypt(json.dumps({"foo": "bar"})) +``` + +```python +import json +from crypt_sindresorhus_conf import CryptSindresorhusConf + +with open("file.json", "rb") as f: + encrypted = f.read() + +key = b"hello there" +iv = encrypted[:16] +conf_crypt = CryptSindresorhusConf(key, iv) +plaintext = conf_crypt.decrypt(encrypted) +data = json.loads(plaintext) +``` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..36b8163 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "crypt-sindresorhus-conf" +dynamic = ["version"] + +description = "Encrypts and decrypts encrypts/decrypts 'sindresorhus/conf' files" +readme = "README.md" + +dependencies = [ + 'cryptography' +] + +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0d38bc5 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +cryptography diff --git a/src/crypt_sindresorhus_conf/__init__.py b/src/crypt_sindresorhus_conf/__init__.py new file mode 100644 index 0000000..9d8b360 --- /dev/null +++ b/src/crypt_sindresorhus_conf/__init__.py @@ -0,0 +1,4 @@ +from .crypt_sindresorhus_conf import CryptSindresorhusConf + + +__all__ = ["CryptSindresorhusConf"] diff --git a/src/crypt_sindresorhus_conf/crypt_sindresorhus_conf.py b/src/crypt_sindresorhus_conf/crypt_sindresorhus_conf.py new file mode 100644 index 0000000..eeccbc8 --- /dev/null +++ b/src/crypt_sindresorhus_conf/crypt_sindresorhus_conf.py @@ -0,0 +1,40 @@ +import logging + +from cryptography.hazmat.primitives.ciphers import Cipher +from cryptography.hazmat.primitives.ciphers.algorithms import AES +from cryptography.hazmat.primitives.ciphers.modes import CBC +from cryptography.hazmat.primitives.hashes import SHA512 +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC +from cryptography.hazmat.primitives.padding import PKCS7 + + +class CryptSindresorhusConf: + def __init__(self, key, iv): + self.iv = iv + logging.debug("Key: %d %s", len(key), key.hex()) + logging.debug("IV: %d %s", len(iv), iv.hex()) + + # js: `iv.toString()` ... + salt = iv.decode(encoding="utf-8", errors="replace").encode() + logging.debug("Salt: %d %s", len(salt), salt.hex()) + + kdf = PBKDF2HMAC(algorithm=SHA512(), length=32, salt=salt, iterations=10_000) + self.password = kdf.derive(key) + logging.debug("Password: %d %s", len(self.password), self.password.hex()) + + self.cipher = Cipher(AES(self.password), CBC(iv)) + + def encrypt(self, data): + padder = PKCS7(128).padder() + padded_data = padder.update(data) + padder.finalize() + encryptor = self.cipher.encryptor() + encrypted = encryptor.update(padded_data) + encryptor.finalize() + return self.iv + b":" + encrypted + + def decrypt(self, data): + decryptor = self.cipher.decryptor() + # iv and data are separated by a ":" + decrypted = decryptor.update(data[17:]) + decryptor.finalize() + unpadder = PKCS7(128).unpadder() + unpadded = unpadder.update(decrypted) + unpadder.finalize() + return unpadded diff --git a/src/crypt_sindresorhus_conf/crypt_sindresorhus_conf_cryptodome.py b/src/crypt_sindresorhus_conf/crypt_sindresorhus_conf_cryptodome.py new file mode 100644 index 0000000..89a68c0 --- /dev/null +++ b/src/crypt_sindresorhus_conf/crypt_sindresorhus_conf_cryptodome.py @@ -0,0 +1,31 @@ +import logging + +from Crypto.Cipher import AES +from Crypto.Hash import SHA512 +from Crypto.Util.Padding import pad, unpad +from Crypto.Protocol.KDF import PBKDF2 + + +class CryptSindresorhusConf: + def __init__(self, key, iv): + self.iv = iv + logging.debug("Key: %d %s", len(key), key.hex()) + logging.debug("IV: %d %s", len(iv), iv.hex()) + + # js: `iv.toString()` ... + salt = iv.decode(encoding="utf-8", errors="replace").encode() + logging.debug("Salt: %d %s", len(salt), salt.hex()) + + self.password = PBKDF2(key, salt, 32, count=10_000, hmac_hash_module=SHA512) + logging.debug("Password: %d %s", len(self.password), self.password.hex()) + + def encrypt(self, data): + cipher = AES.new(self.password, AES.MODE_CBC, self.iv) + encrypted = cipher.encrypt(pad(data, AES.block_size)) + return self.iv + b":" + encrypted + + def decrypt(self, payload): + cipher = AES.new(self.password, AES.MODE_CBC, self.iv) + # iv and data are separated by a ":" + decrypted = cipher.decrypt(payload[AES.block_size + 1 :]) + return unpad(decrypted, AES.block_size) diff --git a/tests/main.mjs b/tests/main.mjs new file mode 100644 index 0000000..3e2e8ca --- /dev/null +++ b/tests/main.mjs @@ -0,0 +1,16 @@ +import fs from 'node:fs' + +import Conf from 'conf' + + +const key = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +fs.writeFileSync('./key.txt', key) + +const config = new Conf({ + projectName: 'foo', + cwd: './', + encryptionKey: key, +}) +config.set({"c": 1, "b": 2, "a": 3}) + +fs.writeFileSync('./config_plaintext.json', config._serialize(config.store)); diff --git a/tests/test_crypt_sindresorhus_conf.py b/tests/test_crypt_sindresorhus_conf.py new file mode 100644 index 0000000..041c728 --- /dev/null +++ b/tests/test_crypt_sindresorhus_conf.py @@ -0,0 +1,33 @@ +import json +import unittest + +from src.crypt_sindresorhus_conf import CryptSindresorhusConf + + +class TestCryptSindresorhusConf(unittest.TestCase): + def setUp(self): + with open("key.txt", "rb") as f: + key = f.read() + + with open("config.json", "rb") as f: + self.encrypted = f.read() + + with open("config_plaintext.json", "rb") as f: + self.plaintext = f.read() + + iv = self.encrypted[:16] + self.crypt = CryptSindresorhusConf(key, iv) + + def test_decrypt(self): + decrypted = self.crypt.decrypt(self.encrypted) + self.assertEqual(decrypted, self.plaintext) + + def test_encrypt(self): + encrypted = self.crypt.encrypt(self.plaintext) + self.assertEqual(encrypted, self.encrypted) + + def test_json(self): + data = json.loads(self.plaintext) + self.assertEqual(data['c'], 1) + self.assertEqual(data['b'], 2) + self.assertEqual(data['a'], 3)