secry 1.1.6__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.
- secry-1.1.6/PKG-INFO +95 -0
- secry-1.1.6/README.md +85 -0
- secry-1.1.6/pyproject.toml +20 -0
- secry-1.1.6/secry/__init__.py +50 -0
- secry-1.1.6/secry/_crypto.py +298 -0
- secry-1.1.6/secry/_example.py +84 -0
- secry-1.1.6/secry/_util.py +35 -0
- secry-1.1.6/secry.egg-info/PKG-INFO +95 -0
- secry-1.1.6/secry.egg-info/SOURCES.txt +12 -0
- secry-1.1.6/secry.egg-info/dependency_links.txt +1 -0
- secry-1.1.6/secry.egg-info/top_level.txt +1 -0
- secry-1.1.6/setup.cfg +4 -0
- secry-1.1.6/tests/test_rwn64.py +91 -0
- secry-1.1.6/tests/test_secry.py +130 -0
secry-1.1.6/PKG-INFO
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: secry
|
|
3
|
+
Version: 1.1.6
|
|
4
|
+
Summary: AES-256-GCM token encryption — interoperable with Node.js secry
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/rushpym-dotcom/secry
|
|
7
|
+
Keywords: encryption,aes-256-gcm,crypto,token
|
|
8
|
+
Requires-Python: >=3.6
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# secry
|
|
12
|
+
|
|
13
|
+
AES-256-GCM token encryption for Python. Zero dependencies. Tokens are fully interoperable with the [Node.js secry package](https://www.npmjs.com/package/secry).
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
pip install secry
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
import secry
|
|
25
|
+
|
|
26
|
+
token = secry.encrypt("my secret", "mypassword")
|
|
27
|
+
text = secry.decrypt(token, "mypassword")
|
|
28
|
+
ok = secry.verify(token, "mypassword")
|
|
29
|
+
fp = secry.fingerprint("hello", "secret")
|
|
30
|
+
pw = secry.generate(32)
|
|
31
|
+
meta = secry.inspect(token)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Compact mode
|
|
35
|
+
|
|
36
|
+
Use `compact=True` to get shorter tokens with the `sec:` prefix:
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
token = secry.encrypt("my secret", "mypassword", compact=True)
|
|
40
|
+
# → sec:v2:... (shorter token)
|
|
41
|
+
|
|
42
|
+
# decrypt works the same way — prefix is detected automatically
|
|
43
|
+
text = secry.decrypt(token, "mypassword")
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Expiry
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
token = secry.encrypt("temporary", "mypassword", expires="1h")
|
|
50
|
+
token = secry.encrypt("temporary", "mypassword", expires="30m")
|
|
51
|
+
token = secry.encrypt("temporary", "mypassword", expires="7d")
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Token prefixes
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
secry:v1: AES-256-GCM (full)
|
|
58
|
+
secry:v2: ChaCha20-Poly1305 (full)
|
|
59
|
+
secry:v3: AES-256-CBC (full)
|
|
60
|
+
|
|
61
|
+
sec:v1: AES-256-GCM (compact)
|
|
62
|
+
sec:v2: ChaCha20-Poly1305 (compact)
|
|
63
|
+
sec:v3: AES-256-CBC (compact)
|
|
64
|
+
|
|
65
|
+
rwn64:v*: legacy — still accepted by decrypt/verify/inspect
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Inspect
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
meta = secry.inspect(token)
|
|
72
|
+
# {
|
|
73
|
+
# "prefix": "secry:v2:",
|
|
74
|
+
# "version": "v2",
|
|
75
|
+
# "algo": "ChaCha20-Poly1305",
|
|
76
|
+
# "compact": False,
|
|
77
|
+
# "legacy": False,
|
|
78
|
+
# "kdf": "scrypt (N=16384,r=8,p=1)",
|
|
79
|
+
# "salt_hex": "...",
|
|
80
|
+
# "nonce_hex": "...",
|
|
81
|
+
# "payload_bytes": 64
|
|
82
|
+
# }
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Security
|
|
86
|
+
|
|
87
|
+
- AES-256-GCM / ChaCha20-Poly1305 authenticated encryption
|
|
88
|
+
- scrypt key derivation (N=16384, r=8, p=1)
|
|
89
|
+
- Random salt + nonce per token
|
|
90
|
+
- Auth tag detects tampering before decryption
|
|
91
|
+
- Zero external dependencies
|
|
92
|
+
|
|
93
|
+
## License
|
|
94
|
+
|
|
95
|
+
MIT
|
secry-1.1.6/README.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# secry
|
|
2
|
+
|
|
3
|
+
AES-256-GCM token encryption for Python. Zero dependencies. Tokens are fully interoperable with the [Node.js secry package](https://www.npmjs.com/package/secry).
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
pip install secry
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
import secry
|
|
15
|
+
|
|
16
|
+
token = secry.encrypt("my secret", "mypassword")
|
|
17
|
+
text = secry.decrypt(token, "mypassword")
|
|
18
|
+
ok = secry.verify(token, "mypassword")
|
|
19
|
+
fp = secry.fingerprint("hello", "secret")
|
|
20
|
+
pw = secry.generate(32)
|
|
21
|
+
meta = secry.inspect(token)
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Compact mode
|
|
25
|
+
|
|
26
|
+
Use `compact=True` to get shorter tokens with the `sec:` prefix:
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
token = secry.encrypt("my secret", "mypassword", compact=True)
|
|
30
|
+
# → sec:v2:... (shorter token)
|
|
31
|
+
|
|
32
|
+
# decrypt works the same way — prefix is detected automatically
|
|
33
|
+
text = secry.decrypt(token, "mypassword")
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Expiry
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
token = secry.encrypt("temporary", "mypassword", expires="1h")
|
|
40
|
+
token = secry.encrypt("temporary", "mypassword", expires="30m")
|
|
41
|
+
token = secry.encrypt("temporary", "mypassword", expires="7d")
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Token prefixes
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
secry:v1: AES-256-GCM (full)
|
|
48
|
+
secry:v2: ChaCha20-Poly1305 (full)
|
|
49
|
+
secry:v3: AES-256-CBC (full)
|
|
50
|
+
|
|
51
|
+
sec:v1: AES-256-GCM (compact)
|
|
52
|
+
sec:v2: ChaCha20-Poly1305 (compact)
|
|
53
|
+
sec:v3: AES-256-CBC (compact)
|
|
54
|
+
|
|
55
|
+
rwn64:v*: legacy — still accepted by decrypt/verify/inspect
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Inspect
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
meta = secry.inspect(token)
|
|
62
|
+
# {
|
|
63
|
+
# "prefix": "secry:v2:",
|
|
64
|
+
# "version": "v2",
|
|
65
|
+
# "algo": "ChaCha20-Poly1305",
|
|
66
|
+
# "compact": False,
|
|
67
|
+
# "legacy": False,
|
|
68
|
+
# "kdf": "scrypt (N=16384,r=8,p=1)",
|
|
69
|
+
# "salt_hex": "...",
|
|
70
|
+
# "nonce_hex": "...",
|
|
71
|
+
# "payload_bytes": 64
|
|
72
|
+
# }
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Security
|
|
76
|
+
|
|
77
|
+
- AES-256-GCM / ChaCha20-Poly1305 authenticated encryption
|
|
78
|
+
- scrypt key derivation (N=16384, r=8, p=1)
|
|
79
|
+
- Random salt + nonce per token
|
|
80
|
+
- Auth tag detects tampering before decryption
|
|
81
|
+
- Zero external dependencies
|
|
82
|
+
|
|
83
|
+
## License
|
|
84
|
+
|
|
85
|
+
MIT
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68","wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "secry"
|
|
7
|
+
version = "1.1.6"
|
|
8
|
+
description = "AES-256-GCM token encryption — interoperable with Node.js secry"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = {text="MIT"}
|
|
11
|
+
requires-python = ">=3.6"
|
|
12
|
+
dependencies = []
|
|
13
|
+
keywords = ["encryption","aes-256-gcm","crypto","token"]
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
Homepage = "https://github.com/rushpym-dotcom/secry"
|
|
17
|
+
|
|
18
|
+
[tool.setuptools.packages.find]
|
|
19
|
+
where = ["."]
|
|
20
|
+
include = ["secry*"]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import os as _os
|
|
3
|
+
from importlib.metadata import version as _ver, PackageNotFoundError as _PNF
|
|
4
|
+
from ._crypto import encrypt as _enc, decrypt as _dec
|
|
5
|
+
from ._util import fingerprint as _fp, parse_expiry, inspect as _ins
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
__version__ = _ver("secry")
|
|
9
|
+
except _PNF:
|
|
10
|
+
__version__ = "dev"
|
|
11
|
+
|
|
12
|
+
__all__ = ["encrypt","decrypt","verify","fingerprint","generate","inspect","parse_expiry"]
|
|
13
|
+
|
|
14
|
+
TOKEN_PREFIX = "secry:v2:"
|
|
15
|
+
|
|
16
|
+
def encrypt(text: str, password: str, *, expires: str | None = None, expires_ms: int | None = None, version: int = 2, compact: bool = False) -> str:
|
|
17
|
+
if not password: raise TypeError("password must be non-empty")
|
|
18
|
+
exp = parse_expiry(expires) if expires else expires_ms
|
|
19
|
+
return _enc(text, password, expires_ms=exp, version=version, compact=compact)
|
|
20
|
+
|
|
21
|
+
def decrypt(token: str, password: str) -> str:
|
|
22
|
+
if not token: raise TypeError("token must be non-empty")
|
|
23
|
+
if not password: raise TypeError("password must be non-empty")
|
|
24
|
+
return _dec(token, password)[0]
|
|
25
|
+
|
|
26
|
+
def verify(token: str, password: str) -> bool:
|
|
27
|
+
try: _dec(token, password); return True
|
|
28
|
+
except: return False
|
|
29
|
+
|
|
30
|
+
def fingerprint(text: str, secret: str) -> str: return _fp(text, secret)
|
|
31
|
+
|
|
32
|
+
def inspect(token: str) -> dict: return _ins(token)
|
|
33
|
+
|
|
34
|
+
def generate(nbytes: int = 24, *, upper: bool = False, count: int = 1) -> str | list[str]:
|
|
35
|
+
if not (8 <= nbytes <= 256): raise ValueError("nbytes must be between 8 and 256")
|
|
36
|
+
if not (1 <= count <= 20): raise ValueError("count must be between 1 and 20")
|
|
37
|
+
_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
|
|
38
|
+
def _gen() -> str:
|
|
39
|
+
raw = _os.urandom(nbytes)
|
|
40
|
+
pw = "".join(_CHARS[b % 64] for b in raw)
|
|
41
|
+
if upper and not any(c.isupper() for c in pw):
|
|
42
|
+
i = _os.urandom(1)[0] % len(pw)
|
|
43
|
+
pw = pw[:i] + pw[i].upper() + pw[i+1:]
|
|
44
|
+
return pw
|
|
45
|
+
pws = [_gen() for _ in range(count)]
|
|
46
|
+
return pws if count > 1 else pws[0]
|
|
47
|
+
|
|
48
|
+
def example() -> None:
|
|
49
|
+
from ._example import show
|
|
50
|
+
show()
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import os as _o, struct as _s, time as _t, base64 as _b64, hashlib as _h, hmac as _hm
|
|
3
|
+
import ctypes as _c, sys as _sys
|
|
4
|
+
|
|
5
|
+
# ─── load openssl ─────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
def _load_lib() -> _c.CDLL:
|
|
8
|
+
if _sys.platform == "win32":
|
|
9
|
+
for n in ("libcrypto-3-x64.dll","libcrypto-3.dll","libcrypto.dll"):
|
|
10
|
+
try: return _c.CDLL(n)
|
|
11
|
+
except OSError: continue
|
|
12
|
+
raise OSError("OpenSSL not found. Install from https://slproweb.com/products/Win32OpenSSL.html")
|
|
13
|
+
elif _sys.platform == "darwin":
|
|
14
|
+
for n in ("libcrypto.3.dylib","libcrypto.dylib","/opt/homebrew/lib/libcrypto.3.dylib","/usr/local/lib/libcrypto.3.dylib"):
|
|
15
|
+
try: return _c.CDLL(n)
|
|
16
|
+
except OSError: continue
|
|
17
|
+
raise OSError("OpenSSL not found. Install via: brew install openssl")
|
|
18
|
+
else:
|
|
19
|
+
for n in ("libcrypto.so.3","libcrypto.so"):
|
|
20
|
+
try: return _c.CDLL(n)
|
|
21
|
+
except OSError: continue
|
|
22
|
+
raise OSError("OpenSSL not found. Install via: apt install libssl-dev")
|
|
23
|
+
|
|
24
|
+
_lib = _load_lib()
|
|
25
|
+
_ci = _c.c_int
|
|
26
|
+
_cv = _c.c_void_p
|
|
27
|
+
_cc = _c.c_char_p
|
|
28
|
+
|
|
29
|
+
# ─── openssl bindings ────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
_lib.EVP_CIPHER_CTX_new.restype = _cv
|
|
32
|
+
_lib.EVP_aes_256_gcm.restype = _cv
|
|
33
|
+
_lib.EVP_aes_256_cbc.restype = _cv
|
|
34
|
+
_lib.EVP_chacha20_poly1305.restype = _cv
|
|
35
|
+
_lib.EVP_EncryptInit_ex.argtypes = [_cv,_cv,_cv,_cc,_cc]; _lib.EVP_EncryptInit_ex.restype = _ci
|
|
36
|
+
_lib.EVP_DecryptInit_ex.argtypes = [_cv,_cv,_cv,_cc,_cc]; _lib.EVP_DecryptInit_ex.restype = _ci
|
|
37
|
+
_lib.EVP_CIPHER_CTX_ctrl.argtypes = [_cv,_ci,_ci,_cv]; _lib.EVP_CIPHER_CTX_ctrl.restype = _ci
|
|
38
|
+
_lib.EVP_EncryptUpdate.argtypes = [_cv,_cc,_c.POINTER(_ci),_cc,_ci]; _lib.EVP_EncryptUpdate.restype = _ci
|
|
39
|
+
_lib.EVP_DecryptUpdate.argtypes = [_cv,_cc,_c.POINTER(_ci),_cc,_ci]; _lib.EVP_DecryptUpdate.restype = _ci
|
|
40
|
+
_lib.EVP_EncryptFinal_ex.argtypes = [_cv,_cc,_c.POINTER(_ci)]; _lib.EVP_EncryptFinal_ex.restype = _ci
|
|
41
|
+
_lib.EVP_DecryptFinal_ex.argtypes = [_cv,_cc,_c.POINTER(_ci)]; _lib.EVP_DecryptFinal_ex.restype = _ci
|
|
42
|
+
_lib.EVP_CIPHER_CTX_free.argtypes = [_cv]
|
|
43
|
+
_lib.EVP_CIPHER_CTX_set_padding.argtypes = [_cv,_ci]; _lib.EVP_CIPHER_CTX_set_padding.restype = _ci
|
|
44
|
+
|
|
45
|
+
_GCM_SIVLEN = 0x9
|
|
46
|
+
_GCM_GTAG = 0x10
|
|
47
|
+
_GCM_STAG = 0x11
|
|
48
|
+
|
|
49
|
+
# ─── prefixes ────────────────────────────────────────────────────────────────
|
|
50
|
+
# full
|
|
51
|
+
_PX1 = b"secry:v1:"
|
|
52
|
+
_PX2 = b"secry:v2:"
|
|
53
|
+
_PX3 = b"secry:v3:"
|
|
54
|
+
# compact
|
|
55
|
+
_CPX1 = b"sec:v1:"
|
|
56
|
+
_CPX2 = b"sec:v2:"
|
|
57
|
+
_CPX3 = b"sec:v3:"
|
|
58
|
+
# legacy rwn64 (read-only compat)
|
|
59
|
+
_LPX1 = b"rwn64:v1:"
|
|
60
|
+
_LPX2 = b"rwn64:v2:"
|
|
61
|
+
_LPX3 = b"rwn64:v3:"
|
|
62
|
+
|
|
63
|
+
# sizes
|
|
64
|
+
_SL = 16 # salt full
|
|
65
|
+
_SL_C = 8 # salt compact
|
|
66
|
+
_NL = 12 # nonce v1/v2 full
|
|
67
|
+
_NL3 = 16 # nonce v3 full
|
|
68
|
+
_NL_C = 8 # nonce compact (all versions)
|
|
69
|
+
_TL = 16 # tag
|
|
70
|
+
_KL = 32 # key
|
|
71
|
+
_N,_R,_P = 16384,8,1
|
|
72
|
+
|
|
73
|
+
# ─── encoding detection ──────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
def _detect_encoding(data: bytes) -> str:
|
|
76
|
+
if data[:3] == b"\xef\xbb\xbf": return "utf-8-sig"
|
|
77
|
+
if data[:2] in (b"\xff\xfe", b"\xfe\xff"): return "utf-16"
|
|
78
|
+
try: data.decode("utf-8"); return "utf-8"
|
|
79
|
+
except UnicodeDecodeError: pass
|
|
80
|
+
for enc in ("latin-1", "cp1252", "iso-8859-1"):
|
|
81
|
+
try: data.decode(enc); return enc
|
|
82
|
+
except UnicodeDecodeError: continue
|
|
83
|
+
return "latin-1"
|
|
84
|
+
|
|
85
|
+
def decode_auto(data: bytes) -> tuple[str, str]:
|
|
86
|
+
enc = _detect_encoding(data)
|
|
87
|
+
return data.decode(enc), enc
|
|
88
|
+
|
|
89
|
+
# ─── kdf ─────────────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
def _kdf(pw: bytes, salt: bytes) -> bytes:
|
|
92
|
+
return _h.scrypt(pw, salt=salt, n=_N, r=_R, p=_P, dklen=_KL)
|
|
93
|
+
|
|
94
|
+
# ─── v1 — AES-256-GCM ────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
def _enc_v1(key: bytes, nonce: bytes, pt: bytes) -> tuple[bytes, bytes]:
|
|
97
|
+
x = _lib.EVP_CIPHER_CTX_new()
|
|
98
|
+
_lib.EVP_EncryptInit_ex(x, _lib.EVP_aes_256_gcm(), None, None, None)
|
|
99
|
+
_lib.EVP_CIPHER_CTX_ctrl(x, _GCM_SIVLEN, len(nonce), None)
|
|
100
|
+
_lib.EVP_EncryptInit_ex(x, None, None, key, nonce)
|
|
101
|
+
buf = _c.create_string_buffer(len(pt)+16); l = _ci(0)
|
|
102
|
+
_lib.EVP_EncryptUpdate(x, buf, _c.byref(l), pt, len(pt))
|
|
103
|
+
ct = buf.raw[:l.value]
|
|
104
|
+
_lib.EVP_EncryptFinal_ex(x, buf, _c.byref(l))
|
|
105
|
+
tag = _c.create_string_buffer(16)
|
|
106
|
+
_lib.EVP_CIPHER_CTX_ctrl(x, _GCM_GTAG, 16, tag)
|
|
107
|
+
_lib.EVP_CIPHER_CTX_free(x)
|
|
108
|
+
return ct, tag.raw
|
|
109
|
+
|
|
110
|
+
def _dec_v1(key: bytes, nonce: bytes, ct: bytes, tag: bytes) -> bytes:
|
|
111
|
+
x = _lib.EVP_CIPHER_CTX_new()
|
|
112
|
+
_lib.EVP_DecryptInit_ex(x, _lib.EVP_aes_256_gcm(), None, None, None)
|
|
113
|
+
_lib.EVP_CIPHER_CTX_ctrl(x, _GCM_SIVLEN, len(nonce), None)
|
|
114
|
+
_lib.EVP_DecryptInit_ex(x, None, None, key, nonce)
|
|
115
|
+
buf = _c.create_string_buffer(len(ct)+16); l = _ci(0)
|
|
116
|
+
_lib.EVP_DecryptUpdate(x, buf, _c.byref(l), ct, len(ct))
|
|
117
|
+
pt = buf.raw[:l.value]
|
|
118
|
+
_lib.EVP_CIPHER_CTX_ctrl(x, _GCM_STAG, 16, _c.c_char_p(tag))
|
|
119
|
+
fin = _c.create_string_buffer(16)
|
|
120
|
+
ok = _lib.EVP_DecryptFinal_ex(x, fin, _c.byref(l))
|
|
121
|
+
_lib.EVP_CIPHER_CTX_free(x)
|
|
122
|
+
if ok <= 0: raise ValueError("wrong password or corrupted token")
|
|
123
|
+
return pt
|
|
124
|
+
|
|
125
|
+
# ─── v2 — ChaCha20-Poly1305 ──────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
def _enc_v2(key: bytes, nonce: bytes, pt: bytes) -> tuple[bytes, bytes]:
|
|
128
|
+
x = _lib.EVP_CIPHER_CTX_new()
|
|
129
|
+
_lib.EVP_EncryptInit_ex(x, _lib.EVP_chacha20_poly1305(), None, None, None)
|
|
130
|
+
_lib.EVP_CIPHER_CTX_ctrl(x, _GCM_SIVLEN, len(nonce), None)
|
|
131
|
+
_lib.EVP_EncryptInit_ex(x, None, None, key, nonce)
|
|
132
|
+
buf = _c.create_string_buffer(len(pt)+16); l = _ci(0)
|
|
133
|
+
_lib.EVP_EncryptUpdate(x, buf, _c.byref(l), pt, len(pt))
|
|
134
|
+
ct = buf.raw[:l.value]
|
|
135
|
+
_lib.EVP_EncryptFinal_ex(x, buf, _c.byref(l))
|
|
136
|
+
tag = _c.create_string_buffer(16)
|
|
137
|
+
_lib.EVP_CIPHER_CTX_ctrl(x, _GCM_GTAG, 16, tag)
|
|
138
|
+
_lib.EVP_CIPHER_CTX_free(x)
|
|
139
|
+
return ct, tag.raw
|
|
140
|
+
|
|
141
|
+
def _dec_v2(key: bytes, nonce: bytes, ct: bytes, tag: bytes) -> bytes:
|
|
142
|
+
x = _lib.EVP_CIPHER_CTX_new()
|
|
143
|
+
_lib.EVP_DecryptInit_ex(x, _lib.EVP_chacha20_poly1305(), None, None, None)
|
|
144
|
+
_lib.EVP_CIPHER_CTX_ctrl(x, _GCM_SIVLEN, len(nonce), None)
|
|
145
|
+
_lib.EVP_DecryptInit_ex(x, None, None, key, nonce)
|
|
146
|
+
buf = _c.create_string_buffer(len(ct)+16); l = _ci(0)
|
|
147
|
+
_lib.EVP_DecryptUpdate(x, buf, _c.byref(l), ct, len(ct))
|
|
148
|
+
pt = buf.raw[:l.value]
|
|
149
|
+
_lib.EVP_CIPHER_CTX_ctrl(x, _GCM_STAG, 16, _c.c_char_p(tag))
|
|
150
|
+
fin = _c.create_string_buffer(16)
|
|
151
|
+
ok = _lib.EVP_DecryptFinal_ex(x, fin, _c.byref(l))
|
|
152
|
+
_lib.EVP_CIPHER_CTX_free(x)
|
|
153
|
+
if ok <= 0: raise ValueError("wrong password or corrupted token")
|
|
154
|
+
return pt
|
|
155
|
+
|
|
156
|
+
# ─── v3 — AES-256-CBC ────────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
def _pad(data: bytes) -> bytes:
|
|
159
|
+
pad_len = 16 - len(data) % 16
|
|
160
|
+
return data + bytes([pad_len] * pad_len)
|
|
161
|
+
|
|
162
|
+
def _unpad(data: bytes) -> bytes:
|
|
163
|
+
return data[:-data[-1]]
|
|
164
|
+
|
|
165
|
+
def _enc_v3(key: bytes, iv: bytes, pt: bytes) -> bytes:
|
|
166
|
+
x = _lib.EVP_CIPHER_CTX_new()
|
|
167
|
+
_lib.EVP_EncryptInit_ex(x, _lib.EVP_aes_256_cbc(), None, key, iv)
|
|
168
|
+
_lib.EVP_CIPHER_CTX_set_padding(x, 0)
|
|
169
|
+
padded = _pad(pt)
|
|
170
|
+
buf = _c.create_string_buffer(len(padded)+16); l = _ci(0)
|
|
171
|
+
_lib.EVP_EncryptUpdate(x, buf, _c.byref(l), padded, len(padded))
|
|
172
|
+
ct = buf.raw[:l.value]
|
|
173
|
+
_lib.EVP_EncryptFinal_ex(x, buf, _c.byref(l))
|
|
174
|
+
_lib.EVP_CIPHER_CTX_free(x)
|
|
175
|
+
return ct
|
|
176
|
+
|
|
177
|
+
def _dec_v3(key: bytes, iv: bytes, ct: bytes) -> bytes:
|
|
178
|
+
x = _lib.EVP_CIPHER_CTX_new()
|
|
179
|
+
_lib.EVP_DecryptInit_ex(x, _lib.EVP_aes_256_cbc(), None, key, iv)
|
|
180
|
+
_lib.EVP_CIPHER_CTX_set_padding(x, 0)
|
|
181
|
+
buf = _c.create_string_buffer(len(ct)+16); l = _ci(0)
|
|
182
|
+
_lib.EVP_DecryptUpdate(x, buf, _c.byref(l), ct, len(ct))
|
|
183
|
+
pt = buf.raw[:l.value]
|
|
184
|
+
_lib.EVP_DecryptFinal_ex(x, buf, _c.byref(l))
|
|
185
|
+
_lib.EVP_CIPHER_CTX_free(x)
|
|
186
|
+
return _unpad(pt)
|
|
187
|
+
|
|
188
|
+
# ─── helpers ─────────────────────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
def _b64e(b: bytes) -> bytes: return _b64.urlsafe_b64encode(b).rstrip(b"=")
|
|
191
|
+
def _b64d(b: bytes) -> bytes:
|
|
192
|
+
p = 4 - len(b) % 4
|
|
193
|
+
return _b64.urlsafe_b64decode(b + (b"=" * p if p != 4 else b""))
|
|
194
|
+
|
|
195
|
+
def _eexp(ms: int) -> bytes: return _s.pack(">II", ms >> 32, ms & 0xFFFFFFFF)
|
|
196
|
+
def _dexp(b: bytes) -> int:
|
|
197
|
+
hi, lo = _s.unpack(">II", b); return (hi << 32) | lo
|
|
198
|
+
|
|
199
|
+
def _build_inner(plaintext: str | bytes, expires_ms: int | None) -> bytes:
|
|
200
|
+
if isinstance(plaintext, bytes):
|
|
201
|
+
plaintext, _ = decode_auto(plaintext)
|
|
202
|
+
hx = expires_ms is not None
|
|
203
|
+
return (b"\x01" + _eexp(expires_ms) if hx else b"\x00") + plaintext.encode("utf-8")
|
|
204
|
+
|
|
205
|
+
def _parse_inner(inner: bytes) -> tuple[str, int | None]:
|
|
206
|
+
if inner[0] == 1:
|
|
207
|
+
exp = _dexp(inner[1:9])
|
|
208
|
+
if int(_t.time() * 1000) > exp:
|
|
209
|
+
raise ValueError(f"token expired at {exp}")
|
|
210
|
+
return inner[9:].decode("utf-8"), exp
|
|
211
|
+
return inner[1:].decode("utf-8"), None
|
|
212
|
+
|
|
213
|
+
# ─── detect token ────────────────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
def _detect(tb: bytes):
|
|
216
|
+
"""Returns (prefix, version_int, nonce_len, salt_len, compact, is_legacy)"""
|
|
217
|
+
checks = [
|
|
218
|
+
(_PX1, 1, _NL, _SL, False, False),
|
|
219
|
+
(_PX2, 2, _NL, _SL, False, False),
|
|
220
|
+
(_PX3, 3, _NL3, _SL, False, False),
|
|
221
|
+
(_CPX1, 1, _NL_C, _SL_C, True, False),
|
|
222
|
+
(_CPX2, 2, _NL_C, _SL_C, True, False),
|
|
223
|
+
(_CPX3, 3, _NL_C, _SL_C, True, False),
|
|
224
|
+
(_LPX1, 1, _NL, _SL, False, True),
|
|
225
|
+
(_LPX2, 2, _NL, _SL, False, True),
|
|
226
|
+
(_LPX3, 3, _NL3, _SL, False, True),
|
|
227
|
+
]
|
|
228
|
+
for pfx, ver, nl, sl, compact, legacy in checks:
|
|
229
|
+
if tb.startswith(pfx):
|
|
230
|
+
return pfx, ver, nl, sl, compact, legacy
|
|
231
|
+
raise ValueError("invalid token format")
|
|
232
|
+
|
|
233
|
+
# ─── nonce helpers for compact mode ──────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
def _pad_nonce(n: bytes, target: int) -> bytes:
|
|
236
|
+
"""Pad or truncate nonce to target length."""
|
|
237
|
+
if len(n) >= target:
|
|
238
|
+
return n[:target]
|
|
239
|
+
return n + b"\x00" * (target - len(n))
|
|
240
|
+
|
|
241
|
+
# ─── public api ──────────────────────────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
def encrypt(plaintext: str | bytes, password: str, *, expires_ms: int | None = None, version: int = 2, compact: bool = False) -> str:
|
|
244
|
+
sl = _SL_C if compact else _SL
|
|
245
|
+
nl = _NL_C if compact else (_NL3 if version == 3 else _NL)
|
|
246
|
+
salt = _o.urandom(sl)
|
|
247
|
+
nonce = _o.urandom(nl)
|
|
248
|
+
key = _kdf(password.encode(), salt)
|
|
249
|
+
inner = _build_inner(plaintext, expires_ms)
|
|
250
|
+
|
|
251
|
+
if compact:
|
|
252
|
+
pfx = {1: _CPX1, 2: _CPX2, 3: _CPX3}[version]
|
|
253
|
+
# pad nonce to required cipher length
|
|
254
|
+
nonce_full = _pad_nonce(nonce, 12 if version in (1, 2) else 16)
|
|
255
|
+
if version == 1:
|
|
256
|
+
ct, tag = _enc_v1(key, nonce_full, inner)
|
|
257
|
+
return (pfx + _b64e(salt + nonce + tag + ct)).decode()
|
|
258
|
+
elif version == 3:
|
|
259
|
+
ct = _enc_v3(key, nonce_full, inner)
|
|
260
|
+
return (pfx + _b64e(salt + nonce + ct)).decode()
|
|
261
|
+
else:
|
|
262
|
+
ct, tag = _enc_v2(key, nonce_full, inner)
|
|
263
|
+
return (pfx + _b64e(salt + nonce + tag + ct)).decode()
|
|
264
|
+
else:
|
|
265
|
+
pfx = {1: _PX1, 2: _PX2, 3: _PX3}[version]
|
|
266
|
+
if version == 1:
|
|
267
|
+
ct, tag = _enc_v1(key, nonce, inner)
|
|
268
|
+
return (pfx + _b64e(salt + nonce + tag + ct)).decode()
|
|
269
|
+
elif version == 3:
|
|
270
|
+
ct = _enc_v3(key, nonce, inner)
|
|
271
|
+
return (pfx + _b64e(salt + nonce + ct)).decode()
|
|
272
|
+
else:
|
|
273
|
+
ct, tag = _enc_v2(key, nonce, inner)
|
|
274
|
+
return (pfx + _b64e(salt + nonce + tag + ct)).decode()
|
|
275
|
+
|
|
276
|
+
def decrypt(token: str, password: str) -> tuple[str, int | None]:
|
|
277
|
+
tb = token.encode()
|
|
278
|
+
pfx, ver, nl, sl, compact, _ = _detect(tb)
|
|
279
|
+
|
|
280
|
+
raw = _b64d(tb[len(pfx):])
|
|
281
|
+
salt = raw[:sl]
|
|
282
|
+
nonce_raw = raw[sl:sl+nl]
|
|
283
|
+
key = _kdf(password.encode(), salt)
|
|
284
|
+
|
|
285
|
+
if compact:
|
|
286
|
+
nonce_full = _pad_nonce(nonce_raw, 12 if ver in (1, 2) else 16)
|
|
287
|
+
else:
|
|
288
|
+
nonce_full = nonce_raw
|
|
289
|
+
|
|
290
|
+
if ver == 3:
|
|
291
|
+
ct = raw[sl+nl:]
|
|
292
|
+
inner = _dec_v3(key, nonce_full, ct)
|
|
293
|
+
else:
|
|
294
|
+
tag = raw[sl+nl:sl+nl+_TL]
|
|
295
|
+
ct = raw[sl+nl+_TL:]
|
|
296
|
+
inner = _dec_v1(key, nonce_full, ct, tag) if ver == 1 else _dec_v2(key, nonce_full, ct, tag)
|
|
297
|
+
|
|
298
|
+
return _parse_inner(inner)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
_NC = sys.stdout.isatty() is False
|
|
5
|
+
_c = {
|
|
6
|
+
"r":"", "dim":"", "bold":"", "hi":"", "cyan":"", "gray":"", "green":""
|
|
7
|
+
} if _NC else {
|
|
8
|
+
"r": "\x1b[0m",
|
|
9
|
+
"dim": "\x1b[2m",
|
|
10
|
+
"bold": "\x1b[1m",
|
|
11
|
+
"hi": "\x1b[38;5;252m",
|
|
12
|
+
"cyan": "\x1b[36m",
|
|
13
|
+
"gray": "\x1b[38;5;245m",
|
|
14
|
+
"green":"\x1b[32m",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
def _vis(s: str) -> int:
|
|
18
|
+
import re
|
|
19
|
+
return len(re.sub(r"\x1b\[[0-9;]*m", "", s))
|
|
20
|
+
|
|
21
|
+
def _code_box(title: str, lines: list[tuple[str, str]]) -> None:
|
|
22
|
+
lw = max(len(l) for l, _ in lines) if lines else 0
|
|
23
|
+
rows = []
|
|
24
|
+
for label, val in lines:
|
|
25
|
+
pad = " " * (lw - len(label) + 1)
|
|
26
|
+
lc = _c["gray"] if label.startswith("#") or label == "" else _c["cyan"]
|
|
27
|
+
vc = _c["dim"] if label.startswith("#") or label == "" else _c["hi"]
|
|
28
|
+
rows.append(f"{lc}{label}{_c['r']}{pad}{vc}{val}{_c['r']}")
|
|
29
|
+
|
|
30
|
+
width = max((_vis(r) for r in rows), default=0)
|
|
31
|
+
width = max(width, len(title) + 4) + 4
|
|
32
|
+
|
|
33
|
+
def hline(l: str, r: str) -> str:
|
|
34
|
+
return f"{_c['gray']}{l}{'─' * (width - 2)}{r}{_c['r']}\n"
|
|
35
|
+
|
|
36
|
+
sys.stderr.write(f"\n {_c['dim']}{title}{_c['r']}\n")
|
|
37
|
+
sys.stderr.write(hline("┌", "┐"))
|
|
38
|
+
for row in rows:
|
|
39
|
+
pad = max(0, width - _vis(row) - 2)
|
|
40
|
+
sys.stderr.write(f"{_c['gray']}│{_c['r']} {row}{' ' * pad} {_c['gray']}│{_c['r']}\n")
|
|
41
|
+
sys.stderr.write(hline("└", "┘"))
|
|
42
|
+
|
|
43
|
+
def show() -> None:
|
|
44
|
+
import secry
|
|
45
|
+
ver = secry.__version__
|
|
46
|
+
|
|
47
|
+
sys.stderr.write(f"\n{_c['gray']}{'─' * 48}{_c['r']}\n")
|
|
48
|
+
sys.stderr.write(f" {_c['bold']}{_c['cyan']}secry{_c['r']} {_c['gray']}v{ver} examples (python){_c['r']}\n")
|
|
49
|
+
sys.stderr.write(f"{_c['gray']}{'─' * 48}{_c['r']}\n")
|
|
50
|
+
|
|
51
|
+
_code_box("library (python)", [
|
|
52
|
+
("# install", ""),
|
|
53
|
+
("pip install", "secry"),
|
|
54
|
+
("", ""),
|
|
55
|
+
("import", "secry"),
|
|
56
|
+
("", ""),
|
|
57
|
+
("# generate password", ""),
|
|
58
|
+
("pw", "= secry.generate(32)"),
|
|
59
|
+
("", ""),
|
|
60
|
+
("# encrypt (full token)", ""),
|
|
61
|
+
("token", "= secry.encrypt('my secret', pw)"),
|
|
62
|
+
("token", "= secry.encrypt('expires', pw, expires='1h')"),
|
|
63
|
+
("", ""),
|
|
64
|
+
("# encrypt (compact token)", ""),
|
|
65
|
+
("token", "= secry.encrypt('my secret', pw, compact=True)"),
|
|
66
|
+
("", ""),
|
|
67
|
+
("# decrypt (secry:, sec:, rwn64: all accepted)", ""),
|
|
68
|
+
("text", "= secry.decrypt(token, pw)"),
|
|
69
|
+
("", ""),
|
|
70
|
+
("# verify", ""),
|
|
71
|
+
("ok", "= secry.verify(token, pw)"),
|
|
72
|
+
("", ""),
|
|
73
|
+
("# fingerprint", ""),
|
|
74
|
+
("fp", "= secry.fingerprint('hello', 'secret')"),
|
|
75
|
+
("", ""),
|
|
76
|
+
("# inspect token metadata", ""),
|
|
77
|
+
("meta", "= secry.inspect(token)"),
|
|
78
|
+
("# meta", "{ version, algo, compact, legacy, kdf, salt_hex, nonce_hex, payload_bytes }"),
|
|
79
|
+
])
|
|
80
|
+
|
|
81
|
+
sys.stderr.write(f"\n{_c['gray']}{'─' * 48}{_c['r']}\n\n")
|
|
82
|
+
|
|
83
|
+
if __name__ == "__main__":
|
|
84
|
+
show()
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import base64 as _b,hashlib as _h,hmac as _m,os as _o,re as _r,time as _t
|
|
3
|
+
|
|
4
|
+
def _b64e(b:bytes)->bytes: return _b.urlsafe_b64encode(b).rstrip(b"=")
|
|
5
|
+
def _b64d(b:bytes)->bytes:
|
|
6
|
+
p=4-len(b)%4; return _b.urlsafe_b64decode(b+(b"="*p if p!=4 else b""))
|
|
7
|
+
|
|
8
|
+
def fingerprint(text:str,secret:str)->str:
|
|
9
|
+
return _m.new(secret.encode(),text.encode(),_h.sha256).hexdigest()[:16]
|
|
10
|
+
|
|
11
|
+
def generate(nbytes:int=24)->str:
|
|
12
|
+
return _b64e(_o.urandom(nbytes)).decode()
|
|
13
|
+
|
|
14
|
+
def parse_expiry(raw:str)->int:
|
|
15
|
+
m=_r.fullmatch(r"(\d+)(s|m|h|d)",raw.strip())
|
|
16
|
+
if not m: raise ValueError(f'invalid expiry: "{raw}" — use 30s 5m 2h 7d')
|
|
17
|
+
return int(_t.time()*1000)+int(m.group(1))*{"s":1000,"m":60000,"h":3600000,"d":86400000}[m.group(2)]
|
|
18
|
+
|
|
19
|
+
def inspect(token:str)->dict:
|
|
20
|
+
from ._crypto import _b64d, _detect
|
|
21
|
+
tb = token.encode()
|
|
22
|
+
pfx, ver, nl, sl, compact, legacy = _detect(tb)
|
|
23
|
+
raw = _b64d(tb[len(pfx):])
|
|
24
|
+
algo_map = {1: "AES-256-GCM", 2: "ChaCha20-Poly1305", 3: "AES-256-CBC"}
|
|
25
|
+
return {
|
|
26
|
+
"prefix": pfx.decode(),
|
|
27
|
+
"version": f"v{ver}",
|
|
28
|
+
"algo": algo_map[ver],
|
|
29
|
+
"compact": compact,
|
|
30
|
+
"legacy": legacy,
|
|
31
|
+
"kdf": "scrypt (N=16384,r=8,p=1)",
|
|
32
|
+
"salt_hex": raw[:sl].hex(),
|
|
33
|
+
"nonce_hex": raw[sl:sl+nl].hex(),
|
|
34
|
+
"payload_bytes": len(raw),
|
|
35
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: secry
|
|
3
|
+
Version: 1.1.6
|
|
4
|
+
Summary: AES-256-GCM token encryption — interoperable with Node.js secry
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/rushpym-dotcom/secry
|
|
7
|
+
Keywords: encryption,aes-256-gcm,crypto,token
|
|
8
|
+
Requires-Python: >=3.6
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# secry
|
|
12
|
+
|
|
13
|
+
AES-256-GCM token encryption for Python. Zero dependencies. Tokens are fully interoperable with the [Node.js secry package](https://www.npmjs.com/package/secry).
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
pip install secry
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
import secry
|
|
25
|
+
|
|
26
|
+
token = secry.encrypt("my secret", "mypassword")
|
|
27
|
+
text = secry.decrypt(token, "mypassword")
|
|
28
|
+
ok = secry.verify(token, "mypassword")
|
|
29
|
+
fp = secry.fingerprint("hello", "secret")
|
|
30
|
+
pw = secry.generate(32)
|
|
31
|
+
meta = secry.inspect(token)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Compact mode
|
|
35
|
+
|
|
36
|
+
Use `compact=True` to get shorter tokens with the `sec:` prefix:
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
token = secry.encrypt("my secret", "mypassword", compact=True)
|
|
40
|
+
# → sec:v2:... (shorter token)
|
|
41
|
+
|
|
42
|
+
# decrypt works the same way — prefix is detected automatically
|
|
43
|
+
text = secry.decrypt(token, "mypassword")
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Expiry
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
token = secry.encrypt("temporary", "mypassword", expires="1h")
|
|
50
|
+
token = secry.encrypt("temporary", "mypassword", expires="30m")
|
|
51
|
+
token = secry.encrypt("temporary", "mypassword", expires="7d")
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Token prefixes
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
secry:v1: AES-256-GCM (full)
|
|
58
|
+
secry:v2: ChaCha20-Poly1305 (full)
|
|
59
|
+
secry:v3: AES-256-CBC (full)
|
|
60
|
+
|
|
61
|
+
sec:v1: AES-256-GCM (compact)
|
|
62
|
+
sec:v2: ChaCha20-Poly1305 (compact)
|
|
63
|
+
sec:v3: AES-256-CBC (compact)
|
|
64
|
+
|
|
65
|
+
rwn64:v*: legacy — still accepted by decrypt/verify/inspect
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Inspect
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
meta = secry.inspect(token)
|
|
72
|
+
# {
|
|
73
|
+
# "prefix": "secry:v2:",
|
|
74
|
+
# "version": "v2",
|
|
75
|
+
# "algo": "ChaCha20-Poly1305",
|
|
76
|
+
# "compact": False,
|
|
77
|
+
# "legacy": False,
|
|
78
|
+
# "kdf": "scrypt (N=16384,r=8,p=1)",
|
|
79
|
+
# "salt_hex": "...",
|
|
80
|
+
# "nonce_hex": "...",
|
|
81
|
+
# "payload_bytes": 64
|
|
82
|
+
# }
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Security
|
|
86
|
+
|
|
87
|
+
- AES-256-GCM / ChaCha20-Poly1305 authenticated encryption
|
|
88
|
+
- scrypt key derivation (N=16384, r=8, p=1)
|
|
89
|
+
- Random salt + nonce per token
|
|
90
|
+
- Auth tag detects tampering before decryption
|
|
91
|
+
- Zero external dependencies
|
|
92
|
+
|
|
93
|
+
## License
|
|
94
|
+
|
|
95
|
+
MIT
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
secry/__init__.py
|
|
4
|
+
secry/_crypto.py
|
|
5
|
+
secry/_example.py
|
|
6
|
+
secry/_util.py
|
|
7
|
+
secry.egg-info/PKG-INFO
|
|
8
|
+
secry.egg-info/SOURCES.txt
|
|
9
|
+
secry.egg-info/dependency_links.txt
|
|
10
|
+
secry.egg-info/top_level.txt
|
|
11
|
+
tests/test_rwn64.py
|
|
12
|
+
tests/test_secry.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
secry
|
secry-1.1.6/setup.cfg
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import sys,os,time
|
|
2
|
+
sys.path.insert(0,os.path.join(os.path.dirname(__file__),".."))
|
|
3
|
+
import rwn64
|
|
4
|
+
|
|
5
|
+
_G="\x1b[32m✔\x1b[0m"; _R="\x1b[31m✘\x1b[0m"
|
|
6
|
+
_H="\x1b[1m\x1b[38;5;252m"; _D="\x1b[38;5;245m"; _E="\x1b[0m"
|
|
7
|
+
_p=_f=0
|
|
8
|
+
|
|
9
|
+
def _head(t): print(f"\n{_H}{t}{_E}")
|
|
10
|
+
def _assert(c,m="assertion failed"):
|
|
11
|
+
if not c: raise AssertionError(m)
|
|
12
|
+
|
|
13
|
+
def test(label,fn):
|
|
14
|
+
global _p,_f
|
|
15
|
+
try: fn(); print(f" {_G} {label}"); _p+=1
|
|
16
|
+
except Exception as e: print(f" {_R} {label}\n {_D}{e}{_E}"); _f+=1
|
|
17
|
+
|
|
18
|
+
_head("encrypt / decrypt")
|
|
19
|
+
test("returns rwn64:v1: token", lambda:_assert(rwn64.encrypt("hi","pw").startswith("rwn64:v1:")))
|
|
20
|
+
test("roundtrip plaintext", lambda:_assert(rwn64.decrypt(rwn64.encrypt("secret","pw"),"pw")=="secret"))
|
|
21
|
+
test("unique tokens per call", lambda:_assert(rwn64.encrypt("x","pw")!=rwn64.encrypt("x","pw")))
|
|
22
|
+
test("unicode roundtrip", lambda:_assert(rwn64.decrypt(rwn64.encrypt("日本語 ñ @#$","pw"),"pw")=="日本語 ñ @#$"))
|
|
23
|
+
test("10 KB roundtrip", lambda:_assert(rwn64.decrypt(rwn64.encrypt("x"*10240,"pw"),"pw")=="x"*10240))
|
|
24
|
+
|
|
25
|
+
_head("wrong password / tampered")
|
|
26
|
+
def _t6():
|
|
27
|
+
tok=rwn64.encrypt("secret","correct")
|
|
28
|
+
try: rwn64.decrypt(tok,"wrong"); raise AssertionError("should raise")
|
|
29
|
+
except ValueError: pass
|
|
30
|
+
test("wrong password raises",_t6)
|
|
31
|
+
|
|
32
|
+
def _t7():
|
|
33
|
+
tok=rwn64.encrypt("secret","pw")
|
|
34
|
+
try: rwn64.decrypt(tok[:-6]+"AAAAAA","pw"); raise AssertionError("should raise")
|
|
35
|
+
except Exception: pass
|
|
36
|
+
test("tampered token raises",_t7)
|
|
37
|
+
|
|
38
|
+
def _t8():
|
|
39
|
+
try: rwn64.decrypt("notatoken","pw"); raise AssertionError("should raise")
|
|
40
|
+
except Exception: pass
|
|
41
|
+
test("invalid format raises",_t8)
|
|
42
|
+
|
|
43
|
+
_head("verify")
|
|
44
|
+
test("valid token → True", lambda:_assert(rwn64.verify(rwn64.encrypt("x","pw"),"pw") is True))
|
|
45
|
+
test("wrong password → False",lambda:_assert(rwn64.verify(rwn64.encrypt("x","pw"),"bad") is False))
|
|
46
|
+
test("garbage → False", lambda:_assert(rwn64.verify("garbage","pw") is False))
|
|
47
|
+
|
|
48
|
+
_head("expiry")
|
|
49
|
+
test("no expiry decrypts", lambda:_assert(rwn64.decrypt(rwn64.encrypt("x","pw"),"pw")=="x"))
|
|
50
|
+
test("future expiry decrypts",lambda:_assert(rwn64.decrypt(rwn64.encrypt("x","pw",expires_ms=int(time.time()*1000)+60000),"pw")=="x"))
|
|
51
|
+
test("expires= string (1h)", lambda:_assert(rwn64.decrypt(rwn64.encrypt("x","pw",expires="1h"),"pw")=="x"))
|
|
52
|
+
|
|
53
|
+
def _t13():
|
|
54
|
+
tok=rwn64.encrypt("x","pw",expires_ms=int(time.time()*1000)+100)
|
|
55
|
+
time.sleep(0.2)
|
|
56
|
+
try: rwn64.decrypt(tok,"pw"); raise AssertionError("should raise")
|
|
57
|
+
except ValueError as e: _assert("expired" in str(e).lower())
|
|
58
|
+
test("expired token raises",_t13)
|
|
59
|
+
|
|
60
|
+
_head("fingerprint")
|
|
61
|
+
test("deterministic", lambda:_assert(rwn64.fingerprint("hi","s")==rwn64.fingerprint("hi","s")))
|
|
62
|
+
test("different input differs",lambda:_assert(rwn64.fingerprint("a","s")!=rwn64.fingerprint("b","s")))
|
|
63
|
+
test("16 hex chars", lambda:_assert(len(rwn64.fingerprint("x","s"))==16))
|
|
64
|
+
|
|
65
|
+
_head("generate")
|
|
66
|
+
test("non-empty string", lambda:_assert(isinstance(rwn64.generate(),str) and len(rwn64.generate())>0))
|
|
67
|
+
test("unique values", lambda:_assert(rwn64.generate()!=rwn64.generate()))
|
|
68
|
+
|
|
69
|
+
_head("inspect")
|
|
70
|
+
def _t_ins():
|
|
71
|
+
m=rwn64.inspect(rwn64.encrypt("x","pw"))
|
|
72
|
+
_assert(m["version"]=="v1")
|
|
73
|
+
_assert(m["algo"]=="AES-256-GCM")
|
|
74
|
+
_assert(m["prefix"]=="rwn64:v1:")
|
|
75
|
+
test("inspect metadata",_t_ins)
|
|
76
|
+
|
|
77
|
+
_head("node interoperability")
|
|
78
|
+
_TOK ="rwn64:v1:IaQVjP7tITZRERd-MWZBpxGMdo432gsidiGNlK9vZFSt-Vqhs2ioZt4dFykhmciisVa9a07VY0M"
|
|
79
|
+
_PASS="minhasenha"
|
|
80
|
+
_TXT ="old v1 text"
|
|
81
|
+
test("decrypt Node.js token", lambda:_assert(rwn64.decrypt(_TOK,_PASS)==_TXT))
|
|
82
|
+
test("Python token is valid", lambda:_assert(rwn64.decrypt(rwn64.encrypt(_TXT,_PASS),_PASS)==_TXT))
|
|
83
|
+
|
|
84
|
+
total=_p+_f
|
|
85
|
+
print(f"\n{_D}{'─'*48}{_E}\n")
|
|
86
|
+
print(f" total {total}")
|
|
87
|
+
print(f" \x1b[32mpassed {_p}\x1b[0m")
|
|
88
|
+
if _f: print(f" \x1b[31mfailed {_f}\x1b[0m")
|
|
89
|
+
print()
|
|
90
|
+
if not _f: print(f" \x1b[32m\x1b[1mall tests passed\x1b[0m\n")
|
|
91
|
+
else: print(f" \x1b[31m\x1b[1m{_f} test(s) failed\x1b[0m\n"); sys.exit(1)
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import sys,os,time
|
|
2
|
+
sys.path.insert(0,os.path.join(os.path.dirname(__file__),".."))
|
|
3
|
+
import secry
|
|
4
|
+
|
|
5
|
+
_G="\x1b[32m✔\x1b[0m"; _R="\x1b[31m✘\x1b[0m"
|
|
6
|
+
_H="\x1b[1m\x1b[38;5;252m"; _D="\x1b[38;5;245m"; _E="\x1b[0m"
|
|
7
|
+
_p=_f=0
|
|
8
|
+
|
|
9
|
+
def _head(t): print(f"\n{_H}{t}{_E}")
|
|
10
|
+
def _assert(c,m="assertion failed"):
|
|
11
|
+
if not c: raise AssertionError(m)
|
|
12
|
+
|
|
13
|
+
def test(label,fn):
|
|
14
|
+
global _p,_f
|
|
15
|
+
try: fn(); print(f" {_G} {label}"); _p+=1
|
|
16
|
+
except Exception as e: print(f" {_R} {label}\n {_D}{e}{_E}"); _f+=1
|
|
17
|
+
|
|
18
|
+
# ── encrypt / decrypt ─────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
_head("encrypt / decrypt")
|
|
21
|
+
test("returns secry:v2: token", lambda:_assert(secry.encrypt("hi","pw").startswith("secry:v2:")))
|
|
22
|
+
test("roundtrip plaintext", lambda:_assert(secry.decrypt(secry.encrypt("secret","pw"),"pw")=="secret"))
|
|
23
|
+
test("unique tokens per call", lambda:_assert(secry.encrypt("x","pw")!=secry.encrypt("x","pw")))
|
|
24
|
+
test("unicode roundtrip", lambda:_assert(secry.decrypt(secry.encrypt("日本語 ñ @#$","pw"),"pw")=="日本語 ñ @#$"))
|
|
25
|
+
test("10 KB roundtrip", lambda:_assert(secry.decrypt(secry.encrypt("x"*10240,"pw"),"pw")=="x"*10240))
|
|
26
|
+
test("version=1 roundtrip", lambda:_assert(secry.decrypt(secry.encrypt("hi","pw",version=1),"pw")=="hi"))
|
|
27
|
+
test("version=3 roundtrip", lambda:_assert(secry.decrypt(secry.encrypt("hi","pw",version=3),"pw")=="hi"))
|
|
28
|
+
|
|
29
|
+
# ── compact mode ──────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
_head("compact mode")
|
|
32
|
+
test("compact returns sec:v2: token", lambda:_assert(secry.encrypt("hi","pw",compact=True).startswith("sec:v2:")))
|
|
33
|
+
test("compact v1 returns sec:v1:", lambda:_assert(secry.encrypt("hi","pw",version=1,compact=True).startswith("sec:v1:")))
|
|
34
|
+
test("compact v3 returns sec:v3:", lambda:_assert(secry.encrypt("hi","pw",version=3,compact=True).startswith("sec:v3:")))
|
|
35
|
+
test("compact roundtrip", lambda:_assert(secry.decrypt(secry.encrypt("secret","pw",compact=True),"pw")=="secret"))
|
|
36
|
+
test("compact token shorter than full", lambda:_assert(len(secry.encrypt("hi","pw",compact=True))<len(secry.encrypt("hi","pw"))))
|
|
37
|
+
test("compact unicode roundtrip", lambda:_assert(secry.decrypt(secry.encrypt("日本語","pw",compact=True),"pw")=="日本語"))
|
|
38
|
+
|
|
39
|
+
# ── wrong password / tampered ─────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
_head("wrong password / tampered")
|
|
42
|
+
def _t6():
|
|
43
|
+
tok=secry.encrypt("secret","correct")
|
|
44
|
+
try: secry.decrypt(tok,"wrong"); raise AssertionError("should raise")
|
|
45
|
+
except ValueError: pass
|
|
46
|
+
test("wrong password raises",_t6)
|
|
47
|
+
|
|
48
|
+
def _t7():
|
|
49
|
+
tok=secry.encrypt("secret","pw")
|
|
50
|
+
try: secry.decrypt(tok[:-6]+"AAAAAA","pw"); raise AssertionError("should raise")
|
|
51
|
+
except Exception: pass
|
|
52
|
+
test("tampered token raises",_t7)
|
|
53
|
+
|
|
54
|
+
def _t8():
|
|
55
|
+
try: secry.decrypt("notatoken","pw"); raise AssertionError("should raise")
|
|
56
|
+
except Exception: pass
|
|
57
|
+
test("invalid format raises",_t8)
|
|
58
|
+
|
|
59
|
+
# ── verify ────────────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
_head("verify")
|
|
62
|
+
test("valid token → True", lambda:_assert(secry.verify(secry.encrypt("x","pw"),"pw") is True))
|
|
63
|
+
test("wrong password → False", lambda:_assert(secry.verify(secry.encrypt("x","pw"),"bad") is False))
|
|
64
|
+
test("garbage → False", lambda:_assert(secry.verify("garbage","pw") is False))
|
|
65
|
+
test("compact valid → True", lambda:_assert(secry.verify(secry.encrypt("x","pw",compact=True),"pw") is True))
|
|
66
|
+
|
|
67
|
+
# ── expiry ────────────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
_head("expiry")
|
|
70
|
+
test("no expiry decrypts", lambda:_assert(secry.decrypt(secry.encrypt("x","pw"),"pw")=="x"))
|
|
71
|
+
test("future expiry decrypts", lambda:_assert(secry.decrypt(secry.encrypt("x","pw",expires_ms=int(time.time()*1000)+60000),"pw")=="x"))
|
|
72
|
+
test("expires= string (1h)", lambda:_assert(secry.decrypt(secry.encrypt("x","pw",expires="1h"),"pw")=="x"))
|
|
73
|
+
|
|
74
|
+
def _t13():
|
|
75
|
+
tok=secry.encrypt("x","pw",expires_ms=int(time.time()*1000)+100)
|
|
76
|
+
time.sleep(0.2)
|
|
77
|
+
try: secry.decrypt(tok,"pw"); raise AssertionError("should raise")
|
|
78
|
+
except ValueError as e: _assert("expired" in str(e).lower())
|
|
79
|
+
test("expired token raises",_t13)
|
|
80
|
+
|
|
81
|
+
# ── fingerprint ───────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
_head("fingerprint")
|
|
84
|
+
test("deterministic", lambda:_assert(secry.fingerprint("hi","s")==secry.fingerprint("hi","s")))
|
|
85
|
+
test("different input differs", lambda:_assert(secry.fingerprint("a","s")!=secry.fingerprint("b","s")))
|
|
86
|
+
test("16 hex chars", lambda:_assert(len(secry.fingerprint("x","s"))==16))
|
|
87
|
+
|
|
88
|
+
# ── generate ──────────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
_head("generate")
|
|
91
|
+
test("non-empty string", lambda:_assert(isinstance(secry.generate(),str) and len(secry.generate())>0))
|
|
92
|
+
test("unique values", lambda:_assert(secry.generate()!=secry.generate()))
|
|
93
|
+
|
|
94
|
+
# ── inspect ───────────────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
_head("inspect")
|
|
97
|
+
def _t_ins():
|
|
98
|
+
m=secry.inspect(secry.encrypt("x","pw"))
|
|
99
|
+
_assert(m["version"]=="v2")
|
|
100
|
+
_assert(m["algo"]=="ChaCha20-Poly1305")
|
|
101
|
+
_assert(m["prefix"]=="secry:v2:")
|
|
102
|
+
_assert(m["compact"] is False)
|
|
103
|
+
_assert(m["legacy"] is False)
|
|
104
|
+
test("inspect full token",_t_ins)
|
|
105
|
+
|
|
106
|
+
def _t_ins_c():
|
|
107
|
+
m=secry.inspect(secry.encrypt("x","pw",compact=True))
|
|
108
|
+
_assert(m["compact"] is True)
|
|
109
|
+
_assert(m["prefix"]=="sec:v2:")
|
|
110
|
+
test("inspect compact token",_t_ins_c)
|
|
111
|
+
|
|
112
|
+
# ── legacy rwn64 compat ───────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
_head("legacy rwn64 compat")
|
|
115
|
+
_TOK = "rwn64:v1:IaQVjP7tITZRERd-MWZBpxGMdo432gsidiGNlK9vZFSt-Vqhs2ioZt4dFykhmciisVa9a07VY0M"
|
|
116
|
+
_PASS = "minhasenha"
|
|
117
|
+
_TXT = "old v1 text"
|
|
118
|
+
test("decrypt rwn64:v1: token", lambda:_assert(secry.decrypt(_TOK,_PASS)==_TXT))
|
|
119
|
+
test("inspect rwn64 token legacy flag", lambda:_assert(secry.inspect(_TOK)["legacy"] is True))
|
|
120
|
+
|
|
121
|
+
# ── summary ───────────────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
total=_p+_f
|
|
124
|
+
print(f"\n{_D}{'─'*48}{_E}\n")
|
|
125
|
+
print(f" total {total}")
|
|
126
|
+
print(f" \x1b[32mpassed {_p}\x1b[0m")
|
|
127
|
+
if _f: print(f" \x1b[31mfailed {_f}\x1b[0m")
|
|
128
|
+
print()
|
|
129
|
+
if not _f: print(f" \x1b[32m\x1b[1mall tests passed\x1b[0m\n")
|
|
130
|
+
else: print(f" \x1b[31m\x1b[1m{_f} test(s) failed\x1b[0m\n"); sys.exit(1)
|