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.
- saad_crypto-0.1.0/LICENSE +21 -0
- saad_crypto-0.1.0/PKG-INFO +69 -0
- saad_crypto-0.1.0/README.md +51 -0
- saad_crypto-0.1.0/pyproject.toml +25 -0
- saad_crypto-0.1.0/setup.cfg +4 -0
- saad_crypto-0.1.0/src/saad_crypto.egg-info/PKG-INFO +69 -0
- saad_crypto-0.1.0/src/saad_crypto.egg-info/SOURCES.txt +12 -0
- saad_crypto-0.1.0/src/saad_crypto.egg-info/dependency_links.txt +1 -0
- saad_crypto-0.1.0/src/saad_crypto.egg-info/requires.txt +4 -0
- saad_crypto-0.1.0/src/saad_crypto.egg-info/top_level.txt +1 -0
- saad_crypto-0.1.0/src/saad_tools/__init__.py +5 -0
- saad_crypto-0.1.0/src/saad_tools/crypto.py +163 -0
- saad_crypto-0.1.0/src/saad_tools/py.typed +0 -0
- saad_crypto-0.1.0/tests/test_crypto.py +39 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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",)
|