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 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
+ secry
secry-1.1.6/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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)