saad-crypto 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Saad
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,69 @@
1
+ Metadata-Version: 2.4
2
+ Name: saad-crypto
3
+ Version: 0.1.0
4
+ Summary: Authenticated encryption and password-protected secret storage for Python
5
+ Author: Saad
6
+ License-Expression: MIT
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Topic :: Security :: Cryptography
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: cryptography>=42.0
15
+ Provides-Extra: test
16
+ Requires-Dist: pytest>=8; extra == "test"
17
+ Dynamic: license-file
18
+
19
+ # saad_tools
20
+
21
+ مكتبة بايثون صغيرة للتشفير الموثّق وتخزين الأسرار في خزنة محمية بكلمة مرور.
22
+
23
+ > **تنبيه أمني مهم:** التشفير يحمي البيانات والمفاتيح المخزنة، لكنه لا يستطيع منع نسخ كود بايثون الذي يجب توزيعه وتشغيله على جهاز المستخدم. إذا كان الهدف حماية منطق تجاري سري، فالحل الأقوى هو إبقاء المنطق على خادم API وعدم شحنه إلى العميل، أو استخدام خدمة إدارة أسرار/وحدة HSM. لا تضع كلمة مرور الخزنة داخل الكود أو مستودع Git.
24
+
25
+ ## التثبيت
26
+
27
+ ```bash
28
+ pip install saad_tools
29
+ ```
30
+
31
+ ## تشفير البيانات
32
+
33
+ ```python
34
+ from saad_tools import decrypt_text, encrypt_text, generate_key
35
+
36
+ key = generate_key() # خزّنها في مدير أسرار، لا في المصدر
37
+ ciphertext = encrypt_text("رسالة سرية", key)
38
+ plaintext = decrypt_text(ciphertext, key)
39
+ ```
40
+
41
+ التشفير المستخدم هو **AES-256-GCM** مع nonce عشوائي لكل عملية، لذلك يكتشف التعديل أو كلمة المرور/المفتاح الخاطئ.
42
+
43
+ ## خزنة الأسرار
44
+
45
+ ```python
46
+ from saad_tools import Vault
47
+
48
+ vault = Vault("~/.config/myapp/vault.json", "كلمة مرور طويلة وفريدة", create=True)
49
+ vault.set("database_password", "...secret...")
50
+ password = Vault("~/.config/myapp/vault.json", "كلمة مرور طويلة وفريدة").get("database_password")
51
+ ```
52
+
53
+ الخزنة تستخدم **scrypt** لاشتقاق مفتاح من كلمة المرور، ثم AES-256-GCM لتشفير المحتوى. لا تُحفظ كلمة المرور في الملف. يحاول البرنامج جعل صلاحيات الملف `0600` على الأنظمة التي تدعم ذلك.
54
+
55
+ ## تطوير محلي
56
+
57
+ ```bash
58
+ python -m pip install -e ".[test]"
59
+ pytest
60
+ python -m build
61
+ ```
62
+
63
+ ## الحالة
64
+
65
+ الإصدار الحالي `0.1.0` تجريبي. قبل استخدامه في إنتاج حساس، راجع إدارة كلمات المرور، النسخ الاحتياطي، صلاحيات نظام الملفات، وسياسة تدوير المفاتيح. لا تحذف الخزنة الأصلية قبل اختبار النسخة الاحتياطية.
66
+
67
+ ## الترخيص
68
+
69
+ MIT
@@ -0,0 +1,51 @@
1
+ # saad_tools
2
+
3
+ مكتبة بايثون صغيرة للتشفير الموثّق وتخزين الأسرار في خزنة محمية بكلمة مرور.
4
+
5
+ > **تنبيه أمني مهم:** التشفير يحمي البيانات والمفاتيح المخزنة، لكنه لا يستطيع منع نسخ كود بايثون الذي يجب توزيعه وتشغيله على جهاز المستخدم. إذا كان الهدف حماية منطق تجاري سري، فالحل الأقوى هو إبقاء المنطق على خادم API وعدم شحنه إلى العميل، أو استخدام خدمة إدارة أسرار/وحدة HSM. لا تضع كلمة مرور الخزنة داخل الكود أو مستودع Git.
6
+
7
+ ## التثبيت
8
+
9
+ ```bash
10
+ pip install saad_tools
11
+ ```
12
+
13
+ ## تشفير البيانات
14
+
15
+ ```python
16
+ from saad_tools import decrypt_text, encrypt_text, generate_key
17
+
18
+ key = generate_key() # خزّنها في مدير أسرار، لا في المصدر
19
+ ciphertext = encrypt_text("رسالة سرية", key)
20
+ plaintext = decrypt_text(ciphertext, key)
21
+ ```
22
+
23
+ التشفير المستخدم هو **AES-256-GCM** مع nonce عشوائي لكل عملية، لذلك يكتشف التعديل أو كلمة المرور/المفتاح الخاطئ.
24
+
25
+ ## خزنة الأسرار
26
+
27
+ ```python
28
+ from saad_tools import Vault
29
+
30
+ vault = Vault("~/.config/myapp/vault.json", "كلمة مرور طويلة وفريدة", create=True)
31
+ vault.set("database_password", "...secret...")
32
+ password = Vault("~/.config/myapp/vault.json", "كلمة مرور طويلة وفريدة").get("database_password")
33
+ ```
34
+
35
+ الخزنة تستخدم **scrypt** لاشتقاق مفتاح من كلمة المرور، ثم AES-256-GCM لتشفير المحتوى. لا تُحفظ كلمة المرور في الملف. يحاول البرنامج جعل صلاحيات الملف `0600` على الأنظمة التي تدعم ذلك.
36
+
37
+ ## تطوير محلي
38
+
39
+ ```bash
40
+ python -m pip install -e ".[test]"
41
+ pytest
42
+ python -m build
43
+ ```
44
+
45
+ ## الحالة
46
+
47
+ الإصدار الحالي `0.1.0` تجريبي. قبل استخدامه في إنتاج حساس، راجع إدارة كلمات المرور، النسخ الاحتياطي، صلاحيات نظام الملفات، وسياسة تدوير المفاتيح. لا تحذف الخزنة الأصلية قبل اختبار النسخة الاحتياطية.
48
+
49
+ ## الترخيص
50
+
51
+ MIT
@@ -0,0 +1,25 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "saad-crypto"
7
+ version = "0.1.0"
8
+ description = "Authenticated encryption and password-protected secret storage for Python"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ authors = [{name = "Saad"}]
12
+ license = "MIT"
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Intended Audience :: Developers",
16
+ "Programming Language :: Python :: 3",
17
+ "Topic :: Security :: Cryptography",
18
+ ]
19
+ dependencies = ["cryptography>=42.0"]
20
+
21
+ [project.optional-dependencies]
22
+ test = ["pytest>=8"]
23
+
24
+ [tool.setuptools.packages.find]
25
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,69 @@
1
+ Metadata-Version: 2.4
2
+ Name: saad-crypto
3
+ Version: 0.1.0
4
+ Summary: Authenticated encryption and password-protected secret storage for Python
5
+ Author: Saad
6
+ License-Expression: MIT
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Topic :: Security :: Cryptography
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: cryptography>=42.0
15
+ Provides-Extra: test
16
+ Requires-Dist: pytest>=8; extra == "test"
17
+ Dynamic: license-file
18
+
19
+ # saad_tools
20
+
21
+ مكتبة بايثون صغيرة للتشفير الموثّق وتخزين الأسرار في خزنة محمية بكلمة مرور.
22
+
23
+ > **تنبيه أمني مهم:** التشفير يحمي البيانات والمفاتيح المخزنة، لكنه لا يستطيع منع نسخ كود بايثون الذي يجب توزيعه وتشغيله على جهاز المستخدم. إذا كان الهدف حماية منطق تجاري سري، فالحل الأقوى هو إبقاء المنطق على خادم API وعدم شحنه إلى العميل، أو استخدام خدمة إدارة أسرار/وحدة HSM. لا تضع كلمة مرور الخزنة داخل الكود أو مستودع Git.
24
+
25
+ ## التثبيت
26
+
27
+ ```bash
28
+ pip install saad_tools
29
+ ```
30
+
31
+ ## تشفير البيانات
32
+
33
+ ```python
34
+ from saad_tools import decrypt_text, encrypt_text, generate_key
35
+
36
+ key = generate_key() # خزّنها في مدير أسرار، لا في المصدر
37
+ ciphertext = encrypt_text("رسالة سرية", key)
38
+ plaintext = decrypt_text(ciphertext, key)
39
+ ```
40
+
41
+ التشفير المستخدم هو **AES-256-GCM** مع nonce عشوائي لكل عملية، لذلك يكتشف التعديل أو كلمة المرور/المفتاح الخاطئ.
42
+
43
+ ## خزنة الأسرار
44
+
45
+ ```python
46
+ from saad_tools import Vault
47
+
48
+ vault = Vault("~/.config/myapp/vault.json", "كلمة مرور طويلة وفريدة", create=True)
49
+ vault.set("database_password", "...secret...")
50
+ password = Vault("~/.config/myapp/vault.json", "كلمة مرور طويلة وفريدة").get("database_password")
51
+ ```
52
+
53
+ الخزنة تستخدم **scrypt** لاشتقاق مفتاح من كلمة المرور، ثم AES-256-GCM لتشفير المحتوى. لا تُحفظ كلمة المرور في الملف. يحاول البرنامج جعل صلاحيات الملف `0600` على الأنظمة التي تدعم ذلك.
54
+
55
+ ## تطوير محلي
56
+
57
+ ```bash
58
+ python -m pip install -e ".[test]"
59
+ pytest
60
+ python -m build
61
+ ```
62
+
63
+ ## الحالة
64
+
65
+ الإصدار الحالي `0.1.0` تجريبي. قبل استخدامه في إنتاج حساس، راجع إدارة كلمات المرور، النسخ الاحتياطي، صلاحيات نظام الملفات، وسياسة تدوير المفاتيح. لا تحذف الخزنة الأصلية قبل اختبار النسخة الاحتياطية.
66
+
67
+ ## الترخيص
68
+
69
+ MIT
@@ -0,0 +1,12 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/saad_crypto.egg-info/PKG-INFO
5
+ src/saad_crypto.egg-info/SOURCES.txt
6
+ src/saad_crypto.egg-info/dependency_links.txt
7
+ src/saad_crypto.egg-info/requires.txt
8
+ src/saad_crypto.egg-info/top_level.txt
9
+ src/saad_tools/__init__.py
10
+ src/saad_tools/crypto.py
11
+ src/saad_tools/py.typed
12
+ tests/test_crypto.py
@@ -0,0 +1,4 @@
1
+ cryptography>=42.0
2
+
3
+ [test]
4
+ pytest>=8
@@ -0,0 +1 @@
1
+ saad_tools
@@ -0,0 +1,5 @@
1
+ """Practical authenticated encryption and password-protected secret storage."""
2
+ from .crypto import CryptoError, InvalidPassword, Vault, decrypt, decrypt_text, encrypt, encrypt_text, generate_key
3
+
4
+ __version__ = "0.1.0"
5
+ __all__ = ["CryptoError", "InvalidPassword", "Vault", "decrypt", "decrypt_text", "encrypt", "encrypt_text", "generate_key", "__version__"]
@@ -0,0 +1,163 @@
1
+ """Authenticated encryption helpers for saad_tools."""
2
+ from __future__ import annotations
3
+
4
+ import base64
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from cryptography.exceptions import InvalidTag
11
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
12
+ from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
13
+
14
+ _FORMAT = "saad_tools.v1"
15
+ _SALT_SIZE = 16
16
+ _NONCE_SIZE = 12
17
+ _KEY_SIZE = 32
18
+ _SCRYPT_N = 2**15
19
+ _SCRYPT_R = 8
20
+ _SCRYPT_P = 1
21
+
22
+
23
+ class CryptoError(Exception):
24
+ """Base exception for saad_tools errors."""
25
+
26
+
27
+ class InvalidPassword(CryptoError):
28
+ """Raised when a vault password cannot decrypt the vault."""
29
+
30
+
31
+ def _derive_key(password: str, salt: bytes) -> bytes:
32
+ if not isinstance(password, str) or len(password) < 12:
33
+ raise ValueError("password must be a string of at least 12 characters")
34
+ return Scrypt(salt=salt, length=_KEY_SIZE, n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P).derive(password.encode())
35
+
36
+
37
+ def encrypt(data: bytes, key: bytes, *, associated_data: bytes | None = None) -> bytes:
38
+ """Encrypt bytes using AES-256-GCM; returns nonce+ciphertext+tag."""
39
+ if len(key) != _KEY_SIZE:
40
+ raise ValueError("key must be exactly 32 bytes")
41
+ if not isinstance(data, bytes):
42
+ raise TypeError("data must be bytes")
43
+ nonce = os.urandom(_NONCE_SIZE)
44
+ return nonce + AESGCM(key).encrypt(nonce, data, associated_data)
45
+
46
+
47
+ def decrypt(token: bytes, key: bytes, *, associated_data: bytes | None = None) -> bytes:
48
+ """Decrypt and authenticate a token produced by :func:`encrypt`."""
49
+ if len(key) != _KEY_SIZE:
50
+ raise ValueError("key must be exactly 32 bytes")
51
+ if len(token) <= _NONCE_SIZE:
52
+ raise CryptoError("ciphertext is too short")
53
+ try:
54
+ return AESGCM(key).decrypt(token[:_NONCE_SIZE], token[_NONCE_SIZE:], associated_data)
55
+ except InvalidTag as exc:
56
+ raise CryptoError("ciphertext authentication failed") from exc
57
+
58
+
59
+ def encrypt_text(text: str, key: bytes, *, associated_data: bytes | None = None) -> str:
60
+ """Encrypt UTF-8 text and return URL-safe base64."""
61
+ return base64.urlsafe_b64encode(encrypt(text.encode("utf-8"), key, associated_data=associated_data)).decode("ascii")
62
+
63
+
64
+ def decrypt_text(token: str, key: bytes, *, associated_data: bytes | None = None) -> str:
65
+ """Decrypt URL-safe base64 text produced by :func:`encrypt_text`."""
66
+ try:
67
+ raw = base64.urlsafe_b64decode(token.encode("ascii"))
68
+ except Exception as exc:
69
+ raise CryptoError("invalid encoded ciphertext") from exc
70
+ return decrypt(raw, key, associated_data=associated_data).decode("utf-8")
71
+
72
+
73
+ class Vault:
74
+ """A password-protected JSON vault for small application secrets.
75
+
76
+ The password is never stored. The vault file contains only a random salt,
77
+ an encrypted payload, and authentication metadata. File permissions are
78
+ restricted to the current user where supported.
79
+ """
80
+
81
+ def __init__(self, path: str | os.PathLike[str], password: str, *, create: bool = False):
82
+ self.path = Path(path).expanduser()
83
+ self._password = password
84
+ if self.path.exists():
85
+ self._items = self._load()
86
+ elif create:
87
+ self._items = {}
88
+ self._save()
89
+ else:
90
+ raise FileNotFoundError(self.path)
91
+
92
+ def _load(self) -> dict[str, Any]:
93
+ try:
94
+ envelope = json.loads(self.path.read_text(encoding="utf-8"))
95
+ salt = base64.b64decode(envelope["salt"])
96
+ key = _derive_key(self._password, salt)
97
+ payload = decrypt(base64.b64decode(envelope["data"]), key, associated_data=_FORMAT.encode())
98
+ obj = json.loads(payload.decode("utf-8"))
99
+ if envelope.get("format") != _FORMAT or not isinstance(obj, dict):
100
+ raise ValueError
101
+ return obj
102
+ except (KeyError, ValueError, TypeError, json.JSONDecodeError, CryptoError, InvalidPassword) as exc:
103
+ raise InvalidPassword("unable to open vault; password or file may be incorrect") from exc
104
+
105
+ def _save(self) -> None:
106
+ salt = os.urandom(_SALT_SIZE)
107
+ key = _derive_key(self._password, salt)
108
+ data = encrypt(json.dumps(self._items, ensure_ascii=False, sort_keys=True).encode(), key, associated_data=_FORMAT.encode())
109
+ envelope = {"format": _FORMAT, "salt": base64.b64encode(salt).decode(), "data": base64.b64encode(data).decode()}
110
+ self.path.parent.mkdir(parents=True, exist_ok=True)
111
+ tmp = self.path.with_name(self.path.name + ".tmp")
112
+ tmp.write_text(json.dumps(envelope, separators=(",", ":")), encoding="utf-8")
113
+ try:
114
+ os.chmod(tmp, 0o600)
115
+ except OSError:
116
+ pass
117
+ os.replace(tmp, self.path)
118
+
119
+ def set(self, name: str, value: str) -> None:
120
+ """Store or replace a string secret."""
121
+ if not name or not isinstance(name, str) or not isinstance(value, str):
122
+ raise ValueError("name and value must be non-empty strings")
123
+ self._items[name] = value
124
+ self._save()
125
+
126
+ def get(self, name: str) -> str:
127
+ """Retrieve a secret."""
128
+ try:
129
+ value = self._items[name]
130
+ except KeyError as exc:
131
+ raise KeyError(name) from exc
132
+ if not isinstance(value, str):
133
+ raise CryptoError("vault entry is not a string")
134
+ return value
135
+
136
+ def delete(self, name: str) -> None:
137
+ """Delete a secret and persist the vault."""
138
+ del self._items[name]
139
+ self._save()
140
+
141
+ def names(self) -> tuple[str, ...]:
142
+ """Return stored secret names without revealing values."""
143
+ return tuple(sorted(self._items))
144
+
145
+ def __enter__(self) -> "Vault":
146
+ return self
147
+
148
+ def __exit__(self, *_: object) -> None:
149
+ self._password = ""
150
+ self._items.clear()
151
+
152
+
153
+ __all__ = ["CryptoError", "InvalidPassword", "Vault", "decrypt", "decrypt_text", "encrypt", "encrypt_text"]
154
+
155
+ # Explicitly expose a key generator for applications that need envelope keys.
156
+ def generate_key() -> bytes:
157
+ """Generate a random 256-bit AES key."""
158
+ return AESGCM.generate_key(bit_length=256)
159
+
160
+ __all__.append("generate_key")
161
+
162
+ # Keep Any import used for JSON-compatible values in the public implementation.
163
+ _ = Any
File without changes
@@ -0,0 +1,39 @@
1
+ from pathlib import Path
2
+
3
+ import pytest
4
+
5
+ from saad_tools import CryptoError, InvalidPassword, Vault, decrypt, decrypt_text, encrypt, encrypt_text, generate_key
6
+
7
+
8
+ def test_round_trip_and_tamper_detection():
9
+ key = generate_key()
10
+ token = encrypt(b"hello", key)
11
+ assert decrypt(token, key) == b"hello"
12
+ tampered = token[:-1] + bytes([token[-1] ^ 1])
13
+ with pytest.raises(CryptoError):
14
+ decrypt(tampered, key)
15
+
16
+
17
+ def test_text_round_trip():
18
+ key = generate_key()
19
+ token = encrypt_text("مرحبا 🔐", key)
20
+ assert decrypt_text(token, key) == "مرحبا 🔐"
21
+
22
+
23
+ def test_vault_persists_and_rejects_wrong_password(tmp_path: Path):
24
+ path = tmp_path / "secrets.json"
25
+ vault = Vault(path, "correct horse battery", create=True)
26
+ vault.set("api_key", "secret-value")
27
+ assert Vault(path, "correct horse battery").get("api_key") == "secret-value"
28
+ assert path.stat().st_mode & 0o777 == 0o600
29
+ with pytest.raises(InvalidPassword):
30
+ Vault(path, "wrong password")
31
+
32
+
33
+ def test_vault_delete_and_names(tmp_path: Path):
34
+ vault = Vault(tmp_path / "vault", "a sufficiently long password", create=True)
35
+ vault.set("b", "2")
36
+ vault.set("a", "1")
37
+ assert vault.names() == ("a", "b")
38
+ vault.delete("a")
39
+ assert vault.names() == ("b",)