saad-crypto 0.1.0__py3-none-any.whl

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,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,8 @@
1
+ saad_crypto-0.1.0.dist-info/licenses/LICENSE,sha256=tbdbgQF1xdCmFlAk89JMNdkQlsgTESzrpnF9t_oC9Fk,1061
2
+ saad_tools/__init__.py,sha256=_VXy4KePPAFJtwAqgdwiqZkbnshw_CmQ7yGQVEsnRoo,358
3
+ saad_tools/crypto.py,sha256=UcVrTWqOxkXzuBiFwPrA_WsitKtUReuFx5IY_9ZkBpU,6235
4
+ saad_tools/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ saad_crypto-0.1.0.dist-info/METADATA,sha256=Mguyqo0Nt0BYOLBQN8dW-_fvZJ2O5GPhFHNB2bIIv1Y,2979
6
+ saad_crypto-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
7
+ saad_crypto-0.1.0.dist-info/top_level.txt,sha256=RfPV8YGp8DPXUMaCBWlzhJ5iHZcE0UW0kkMzrt2GWiE,11
8
+ saad_crypto-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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 @@
1
+ saad_tools
saad_tools/__init__.py ADDED
@@ -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__"]
saad_tools/crypto.py ADDED
@@ -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
saad_tools/py.typed ADDED
File without changes