crafy-captcha 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,28 @@
1
+ Metadata-Version: 2.4
2
+ Name: crafy-captcha
3
+ Version: 1.0.0
4
+ Summary: Official CrafyCAPTCHA Backend SDK for Python
5
+ Home-page: https://github.com/crafycaptcha/crafy-captcha-python
6
+ Author: CrafyCAPTCHA
7
+ Author-email: hello@captcha.crafy.net
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.6
12
+ Description-Content-Type: text/markdown
13
+ Requires-Dist: requests>=2.25.1
14
+ Requires-Dist: PyNaCl>=1.4.0
15
+ Requires-Dist: cryptography>=3.4.0
16
+ Dynamic: author
17
+ Dynamic: author-email
18
+ Dynamic: classifier
19
+ Dynamic: description
20
+ Dynamic: description-content-type
21
+ Dynamic: home-page
22
+ Dynamic: requires-dist
23
+ Dynamic: requires-python
24
+ Dynamic: summary
25
+
26
+ Official documentation in https://captcha.crafy.net/docs/
27
+
28
+ `pip install crafy-captcha`
@@ -0,0 +1,3 @@
1
+ Official documentation in https://captcha.crafy.net/docs/
2
+
3
+ `pip install crafy-captcha`
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,28 @@
1
+ from setuptools import setup, find_packages
2
+
3
+ with open("README.md", "r", encoding="utf-8") as fh:
4
+ long_description = fh.read()
5
+
6
+ setup(
7
+ name="crafy-captcha",
8
+ version="1.0.0",
9
+ author="CrafyCAPTCHA",
10
+ author_email="hello@captcha.crafy.net",
11
+ description="Official CrafyCAPTCHA Backend SDK for Python",
12
+ long_description=long_description,
13
+ long_description_content_type="text/markdown",
14
+ url="https://github.com/crafycaptcha/crafy-captcha-python",
15
+ package_dir={"": "src"},
16
+ packages=find_packages(where="src"),
17
+ install_requires=[
18
+ "requests>=2.25.1",
19
+ "PyNaCl>=1.4.0",
20
+ "cryptography>=3.4.0"
21
+ ],
22
+ classifiers=[
23
+ "Programming Language :: Python :: 3",
24
+ "License :: OSI Approved :: MIT License",
25
+ "Operating System :: OS Independent",
26
+ ],
27
+ python_requires=">=3.6",
28
+ )
@@ -0,0 +1 @@
1
+ from .crafy_captcha import CrafyCAPTCHA
@@ -0,0 +1,499 @@
1
+ import os
2
+ import json
3
+ import time
4
+ import hmac
5
+ import hashlib
6
+ import base64
7
+ import tempfile
8
+ import glob
9
+ import random
10
+ import re
11
+ from datetime import datetime, timezone
12
+
13
+ try:
14
+ import requests
15
+ except ImportError:
16
+ raise ImportError("CrafyCAPTCHA requiere 'requests'. Instálalo con: pip install requests")
17
+
18
+ try:
19
+ import nacl.bindings
20
+ import nacl.hash
21
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
22
+ from cryptography.hazmat.backends import default_backend
23
+ except ImportError:
24
+ raise ImportError("CrafyCAPTCHA requiere librerías criptográficas. Instálalas con: pip install pynacl cryptography")
25
+
26
+
27
+ class _Cryptor:
28
+ """
29
+ Clase interna que encapsula la lógica criptográfica (Equivalente a la clase anónima de PHP)
30
+ """
31
+ ENCRYPTION_ALGORITHM = 'AES-256-CBC'
32
+ HASHING_ALGORITHM = 'sha256'
33
+
34
+ # Constantes Sodium
35
+ SALT_LEN = 16 # SODIUM_CRYPTO_PWHASH_SALTBYTES
36
+ KEY_LEN = 32 # SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES
37
+ NONCE_LEN = 24 # SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES
38
+
39
+ def __init__(self, secret: str):
40
+ if not secret:
41
+ raise ValueError("Secret no puede ser vacío.")
42
+ self.secret = secret.encode('utf-8')
43
+
44
+ # Derivación de llaves pre-calculadas (BLAKE2b y SHA256)
45
+ self.v3_key = nacl.hash.generichash(self.secret, digest_size=self.KEY_LEN)
46
+ self.v1_key = hashlib.sha256(self.secret).digest()
47
+
48
+ def encrypt(self, plaintext: str, version: int = 3) -> str:
49
+ pt_bytes = plaintext.encode('utf-8')
50
+
51
+ if version == 3:
52
+ nonce = os.urandom(self.NONCE_LEN)
53
+ ciphertext = nacl.bindings.crypto_aead_xchacha20poly1305_ietf_encrypt(
54
+ pt_bytes, b'', nonce, self.v3_key
55
+ )
56
+ # PyNaCl ya retorna el MAC adjunto al final del ciphertext, igual que PHP
57
+ out = nonce + ciphertext
58
+ return ';v3_;' + base64.b64encode(out).decode('utf-8')
59
+
60
+ elif version == 2:
61
+ salt = os.urandom(self.SALT_LEN)
62
+ key = nacl.bindings.crypto_pwhash(
63
+ self.KEY_LEN,
64
+ self.secret,
65
+ salt,
66
+ nacl.bindings.crypto_pwhash_OPSLIMIT_INTERACTIVE,
67
+ nacl.bindings.crypto_pwhash_MEMLIMIT_INTERACTIVE,
68
+ nacl.bindings.crypto_pwhash_ALG_DEFAULT
69
+ )
70
+ nonce = os.urandom(self.NONCE_LEN)
71
+ ciphertext = nacl.bindings.crypto_aead_xchacha20poly1305_ietf_encrypt(
72
+ pt_bytes, b'', nonce, key
73
+ )
74
+ out = salt + nonce + ciphertext
75
+ return ';v2_;' + base64.b64encode(out).decode('utf-8')
76
+
77
+ else:
78
+ iv = os.urandom(16)
79
+ cipher = Cipher(algorithms.AES(self.v1_key), modes.CBC(iv), backend=default_backend())
80
+ encryptor = cipher.encryptor()
81
+
82
+ # Padding PKCS7
83
+ pad_len = 16 - (len(pt_bytes) % 16)
84
+ padded_pt = pt_bytes + bytes([pad_len] * pad_len)
85
+
86
+ cipher_text = encryptor.update(padded_pt) + encryptor.finalize()
87
+ mac = hmac.new(self.v1_key, cipher_text, hashlib.sha256).digest()
88
+
89
+ return (iv + mac + cipher_text).hex()
90
+
91
+ def decrypt(self, input_str: str) -> str:
92
+ try:
93
+ first_chars = input_str[:5]
94
+
95
+ if first_chars == ';v3_;':
96
+ decoded = base64.b64decode(input_str[5:])
97
+ if len(decoded) < self.NONCE_LEN:
98
+ return None
99
+
100
+ nonce = decoded[:self.NONCE_LEN]
101
+ ciphertext = decoded[self.NONCE_LEN:]
102
+
103
+ plaintext = nacl.bindings.crypto_aead_xchacha20poly1305_ietf_decrypt(
104
+ ciphertext, b'', nonce, self.v3_key
105
+ )
106
+ return plaintext.decode('utf-8')
107
+
108
+ elif first_chars == ';v2_;':
109
+ decoded = base64.b64decode(input_str[5:])
110
+ if len(decoded) < (self.SALT_LEN + self.NONCE_LEN + 1):
111
+ return None
112
+
113
+ salt = decoded[:self.SALT_LEN]
114
+ nonce = decoded[self.SALT_LEN : self.SALT_LEN + self.NONCE_LEN]
115
+ ciphertext = decoded[self.SALT_LEN + self.NONCE_LEN :]
116
+
117
+ key = nacl.bindings.crypto_pwhash(
118
+ self.KEY_LEN,
119
+ self.secret,
120
+ salt,
121
+ nacl.bindings.crypto_pwhash_OPSLIMIT_INTERACTIVE,
122
+ nacl.bindings.crypto_pwhash_MEMLIMIT_INTERACTIVE,
123
+ nacl.bindings.crypto_pwhash_ALG_DEFAULT
124
+ )
125
+
126
+ plaintext = nacl.bindings.crypto_aead_xchacha20poly1305_ietf_decrypt(
127
+ ciphertext, b'', nonce, key
128
+ )
129
+ return plaintext.decode('utf-8')
130
+
131
+ else:
132
+ if len(input_str) % 2 != 0 or not re.match(r'^[0-9a-fA-F]+$', input_str):
133
+ return None
134
+
135
+ binary_input = bytes.fromhex(input_str)
136
+ if len(binary_input) < 48:
137
+ return None
138
+
139
+ iv = binary_input[:16]
140
+ mac = binary_input[16:48]
141
+ cipher_text = binary_input[48:]
142
+
143
+ calculated_mac = hmac.new(self.v1_key, cipher_text, hashlib.sha256).digest()
144
+
145
+ if not hmac.compare_digest(mac, calculated_mac):
146
+ return None
147
+
148
+ cipher = Cipher(algorithms.AES(self.v1_key), modes.CBC(iv), backend=default_backend())
149
+ decryptor = cipher.decryptor()
150
+ padded_pt = decryptor.update(cipher_text) + decryptor.finalize()
151
+
152
+ # Eliminar Padding PKCS7
153
+ pad_len = padded_pt[-1]
154
+ plaintext = padded_pt[:-pad_len]
155
+ return plaintext.decode('utf-8')
156
+
157
+ except Exception:
158
+ return None
159
+
160
+
161
+ class CrafyCAPTCHA:
162
+ def __init__(self, public_key: str, secret_key: str, base_url: str = 'https://captcha.crafy.net/api'):
163
+ self.public_key = public_key
164
+ self.secret_key = secret_key
165
+ self.base_url = base_url.rstrip('/')
166
+
167
+ # Configuración del cliente HTTP
168
+ self.timeout = 10
169
+
170
+ # Configuración de Exponential Backoff
171
+ self.max_retries = 3
172
+ self.base_delay_ms = 500
173
+ self.retry_status_codes = [429, 500, 502, 503, 504]
174
+
175
+ # Estado interno
176
+ self.access_token = None
177
+ self.last_flow_verify_error = None
178
+ self._cryptor = _Cryptor(self.secret_key)
179
+
180
+ self.set_temp_dir(tempfile.gettempdir())
181
+
182
+ def set_temp_dir(self, path: str):
183
+ hash_str = hashlib.md5((self.public_key + self.secret_key).encode('utf-8')).hexdigest()
184
+ self.cache_file = os.path.join(path, f'crafy_token_{hash_str}.json')
185
+
186
+ self.nonce_dir = os.path.join(path, 'crafy_nonces')
187
+ os.makedirs(self.nonce_dir, mode=0o777, exist_ok=True)
188
+ return self
189
+
190
+ def set_max_retries(self, retries: int):
191
+ self.max_retries = max(0, retries)
192
+ return self
193
+
194
+ def set_base_delay_ms(self, milliseconds: int):
195
+ self.base_delay_ms = max(0, milliseconds)
196
+ return self
197
+
198
+ def set_retry_status_codes(self, codes: list):
199
+ self.retry_status_codes = codes
200
+ return self
201
+
202
+ def create_flow(self, options: dict = None) -> str:
203
+ """
204
+ Crea un nuevo Flow seguro para el cliente.
205
+ Genera un nonce criptográfico, lo guarda localmente y retorna las opciones encriptadas.
206
+ """
207
+ if options is None:
208
+ options = {}
209
+
210
+ # 1. Generar Nonce criptográficamente seguro
211
+ nonce = os.urandom(32).hex()
212
+
213
+ # 2. Guardar el Nonce en archivo temporal (Lock file)
214
+ nonce_file = os.path.join(self.nonce_dir, f'nonce_{nonce}.lock')
215
+
216
+ try:
217
+ with open(nonce_file, 'w') as f:
218
+ f.write(str(int(time.time())))
219
+ except IOError:
220
+ raise Exception("CrafyCAPTCHA: No se pudo escribir el archivo nonce temporal.")
221
+
222
+ # 3. Preparar las opciones e inyectar el nonce
223
+ flow_data = options.copy()
224
+ flow_data['nonce'] = nonce
225
+ json_options = json.dumps(flow_data)
226
+
227
+ # 4. Encriptar
228
+ return self._cryptor.encrypt(json_options)
229
+
230
+ def verify_flow(self, base64_payload: str) -> bool:
231
+ """
232
+ Verifica un Flow completado sin llamar a la API externa.
233
+ Valida firma HMAC, expiración y consume el Nonce (Anti-Replay).
234
+ """
235
+ self.last_flow_verify_error = None
236
+
237
+ if not base64_payload:
238
+ self.last_flow_verify_error = 'El token está vacío.'
239
+ return False
240
+
241
+ # 1. Decodificar el sobre
242
+ try:
243
+ json_envelope = base64.b64decode(base64_payload).decode('utf-8')
244
+ envelope = json.loads(json_envelope)
245
+ except Exception:
246
+ self.last_flow_verify_error = 'No se pudo decodificar el token.'
247
+ return False
248
+
249
+ payload_json = envelope.get('payload')
250
+ signature = envelope.get('server_sign')
251
+
252
+ if not payload_json or not signature:
253
+ self.last_flow_verify_error = 'Token malformado.'
254
+ return False
255
+
256
+ # 2. Validar Firma (HMAC SHA256)
257
+ expected_signature = hmac.new(
258
+ self.secret_key.encode('utf-8'),
259
+ payload_json.encode('utf-8'),
260
+ hashlib.sha256
261
+ ).hexdigest()
262
+
263
+ if not hmac.compare_digest(expected_signature, signature):
264
+ self.last_flow_verify_error = 'Firma de seguridad inválida.'
265
+ return False
266
+
267
+ # 3. Decodificar Payload Interno
268
+ try:
269
+ data = json.loads(payload_json)
270
+ except Exception:
271
+ self.last_flow_verify_error = 'No se pudo decodificar el payload interno.'
272
+ return False
273
+
274
+ # 4. Validar Estado
275
+ if data.get('status') != 'success':
276
+ self.last_flow_verify_error = 'Estado de Flow inválido.'
277
+ return False
278
+
279
+ # 5. Validar Expiración (UTC)
280
+ expires_at_str = data.get('expires_at')
281
+ if not expires_at_str:
282
+ self.last_flow_verify_error = 'Fecha de expiración no definida.'
283
+ return False
284
+
285
+ try:
286
+ # Reemplazar Z por +00:00 para compatibilidad con fromisoformat en versiones viejas de Python
287
+ clean_date = expires_at_str.replace('Z', '+00:00')
288
+ expires_at = datetime.fromisoformat(clean_date)
289
+ now = datetime.now(timezone.utc)
290
+
291
+ if now > expires_at:
292
+ self.last_flow_verify_error = 'Token expirado.'
293
+ return False
294
+ except Exception:
295
+ self.last_flow_verify_error = 'Fecha de expiración inválida.'
296
+ return False
297
+
298
+ # 6. Validar Nonce (Protección Anti-Replay)
299
+ nonce_encrypted = data.get('nonce')
300
+ if not nonce_encrypted:
301
+ self.last_flow_verify_error = 'Nonce no encontrado.'
302
+ return False
303
+
304
+ decrypted_nonce = self._cryptor.decrypt(nonce_encrypted)
305
+
306
+ if not decrypted_nonce:
307
+ self.last_flow_verify_error = 'No se pudo decodificar el nonce.'
308
+ return False
309
+
310
+ clean_nonce = re.sub(r'[^a-f0-9]', '', decrypted_nonce)
311
+ if clean_nonce != decrypted_nonce:
312
+ self.last_flow_verify_error = 'Nonce inválido.'
313
+ return False
314
+
315
+ nonce_file = os.path.join(self.nonce_dir, f'nonce_{clean_nonce}.lock')
316
+
317
+ # Intento de borrado atómico
318
+ try:
319
+ os.unlink(nonce_file)
320
+ except OSError:
321
+ self.last_flow_verify_error = 'Nonce ya utilizado (Replay Attack).'
322
+ return False
323
+
324
+ # 7. Garbage Collection: siempre si >50 archivos, o 1/100 aleatorio
325
+ nonce_files = glob.glob(os.path.join(self.nonce_dir, 'nonce_*.lock'))
326
+ if len(nonce_files) > 50 or random.randint(1, 100) == 1:
327
+ self._garbage_collect_nonces(nonce_files)
328
+
329
+ return True
330
+
331
+ def get_last_flow_verify_error(self) -> str:
332
+ return self.last_flow_verify_error
333
+
334
+ def _garbage_collect_nonces(self, files: list = None):
335
+ if files is None:
336
+ files = glob.glob(os.path.join(self.nonce_dir, 'nonce_*.lock'))
337
+
338
+ now = time.time()
339
+ for file_path in files:
340
+ try:
341
+ if os.path.isfile(file_path) and (now - os.path.getmtime(file_path) > 1200): # 20 min TTL
342
+ os.unlink(file_path)
343
+ except OSError:
344
+ pass
345
+
346
+ def clear_all_nonces(self) -> int:
347
+ files = glob.glob(os.path.join(self.nonce_dir, 'nonce_*.lock'))
348
+ count = 0
349
+ for file_path in files:
350
+ try:
351
+ if os.path.isfile(file_path):
352
+ os.unlink(file_path)
353
+ count += 1
354
+ except OSError:
355
+ pass
356
+ return count
357
+
358
+ def call(self, action: str, data: dict = None) -> dict:
359
+ if data is None:
360
+ data = {}
361
+
362
+ self._ensure_auth()
363
+
364
+ try:
365
+ return self._send_request(action, data, True)
366
+ except Exception as e:
367
+ # Verificar si es un error 401
368
+ if getattr(e, 'status_code', None) == 401 or '401' in str(e):
369
+ self._clear_cache()
370
+ self._ensure_auth(force_refresh=True)
371
+ return self._send_request(action, data, True)
372
+ raise e
373
+
374
+ def _ensure_auth(self, force_refresh: bool = False):
375
+ if not force_refresh and self.access_token:
376
+ return
377
+
378
+ if not force_refresh and os.path.exists(self.cache_file):
379
+ try:
380
+ with open(self.cache_file, 'r') as f:
381
+ cached = json.load(f)
382
+ if cached.get('token') and cached.get('expires_at') and time.time() < (cached['expires_at'] - 60):
383
+ self.access_token = cached['token']
384
+ return
385
+ except Exception:
386
+ pass
387
+
388
+ auth_payload = {'public_key': self.public_key, 'secret_key': self.secret_key}
389
+ response = self._send_request('authenticate', auth_payload, False)
390
+
391
+ if not response.get('token'):
392
+ raise Exception("CrafyCAPTCHA SDK: No se recibió token de autenticación.")
393
+
394
+ self.access_token = response['token']
395
+ expires_in = int(response.get('expires_in', 3600))
396
+ self._save_cache(self.access_token, int(time.time()) + expires_in)
397
+
398
+ def _save_cache(self, token: str, expires_at: int):
399
+ data = json.dumps({'token': token, 'expires_at': expires_at})
400
+ try:
401
+ with open(self.cache_file, 'w') as f:
402
+ f.write(data)
403
+ os.chmod(self.cache_file, 0o600)
404
+ except IOError:
405
+ pass
406
+
407
+ def _clear_cache(self):
408
+ self.access_token = None
409
+ if os.path.exists(self.cache_file):
410
+ try:
411
+ os.unlink(self.cache_file)
412
+ except OSError:
413
+ pass
414
+
415
+ def _send_request(self, action: str, data: dict, use_auth: bool) -> dict:
416
+ url = f"{self.base_url}/?action={action}"
417
+
418
+ headers = {
419
+ 'Content-Type': 'application/json',
420
+ 'Accept': 'application/json',
421
+ 'User-Agent': 'CrafyCAPTCHA-Python-SDK/2.2'
422
+ }
423
+
424
+ if use_auth and self.access_token:
425
+ headers['Authorization'] = f"Bearer {self.access_token}"
426
+
427
+ attempt = 0
428
+ max_attempts = self.max_retries + 1
429
+
430
+ while attempt < max_attempts:
431
+ attempt += 1
432
+
433
+ try:
434
+ response = requests.post(url, json=data, headers=headers, timeout=self.timeout)
435
+ http_code = response.status_code
436
+
437
+ should_retry = http_code in self.retry_status_codes
438
+
439
+ if should_retry and attempt < max_attempts:
440
+ delay_us = 0
441
+ retry_after = response.headers.get('Retry-After')
442
+
443
+ if retry_after:
444
+ if retry_after.isdigit():
445
+ delay_us = int(retry_after) * 1000000
446
+ else:
447
+ try:
448
+ # Parsing HTTP date
449
+ from email.utils import parsedate_to_datetime
450
+ dt = parsedate_to_datetime(retry_after)
451
+ delta = (dt - datetime.now(timezone.utc)).total_seconds()
452
+ if delta > 0:
453
+ delay_us = int(delta * 1000000)
454
+ except Exception:
455
+ pass
456
+
457
+ if delay_us <= 0:
458
+ delay_us = int((self.base_delay_ms * 1000) * (2 ** (attempt - 1)))
459
+
460
+ time.sleep(delay_us / 1000000.0)
461
+ continue
462
+
463
+ if http_code == 401:
464
+ error = Exception("Unauthorized")
465
+ error.status_code = 401
466
+ raise error
467
+
468
+ # Intentar parsear JSON
469
+ try:
470
+ json_resp = response.json()
471
+ except ValueError:
472
+ if http_code >= 400:
473
+ error = Exception(f"CrafyCAPTCHA HTTP Error ({http_code})")
474
+ error.status_code = http_code
475
+ raise error
476
+ raise Exception(f"CrafyCAPTCHA API Error: Respuesta inválida. HTTP Code: {http_code}")
477
+
478
+ if json_resp.get('status') == 'error':
479
+ msg = json_resp.get('message', 'Error desconocido')
480
+ error = Exception(msg)
481
+ error.status_code = http_code
482
+ raise error
483
+
484
+ if http_code >= 400:
485
+ error = Exception(f"CrafyCAPTCHA HTTP Error ({http_code})")
486
+ error.status_code = http_code
487
+ raise error
488
+
489
+ return json_resp.get('data', {})
490
+
491
+ except requests.exceptions.RequestException as e:
492
+ if attempt >= max_attempts:
493
+ raise Exception(f"CrafyCAPTCHA Network Error: {str(e)}")
494
+
495
+ # Backoff simple por error de red
496
+ delay = (self.base_delay_ms / 1000.0) * (2 ** (attempt - 1))
497
+ time.sleep(delay)
498
+
499
+ raise Exception("CrafyCAPTCHA: Max retries exceeded.")
@@ -0,0 +1,28 @@
1
+ Metadata-Version: 2.4
2
+ Name: crafy-captcha
3
+ Version: 1.0.0
4
+ Summary: Official CrafyCAPTCHA Backend SDK for Python
5
+ Home-page: https://github.com/crafycaptcha/crafy-captcha-python
6
+ Author: CrafyCAPTCHA
7
+ Author-email: hello@captcha.crafy.net
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.6
12
+ Description-Content-Type: text/markdown
13
+ Requires-Dist: requests>=2.25.1
14
+ Requires-Dist: PyNaCl>=1.4.0
15
+ Requires-Dist: cryptography>=3.4.0
16
+ Dynamic: author
17
+ Dynamic: author-email
18
+ Dynamic: classifier
19
+ Dynamic: description
20
+ Dynamic: description-content-type
21
+ Dynamic: home-page
22
+ Dynamic: requires-dist
23
+ Dynamic: requires-python
24
+ Dynamic: summary
25
+
26
+ Official documentation in https://captcha.crafy.net/docs/
27
+
28
+ `pip install crafy-captcha`
@@ -0,0 +1,9 @@
1
+ README.md
2
+ setup.py
3
+ src/crafy_captcha/__init__.py
4
+ src/crafy_captcha/crafy_captcha.py
5
+ src/crafy_captcha.egg-info/PKG-INFO
6
+ src/crafy_captcha.egg-info/SOURCES.txt
7
+ src/crafy_captcha.egg-info/dependency_links.txt
8
+ src/crafy_captcha.egg-info/requires.txt
9
+ src/crafy_captcha.egg-info/top_level.txt
@@ -0,0 +1,3 @@
1
+ requests>=2.25.1
2
+ PyNaCl>=1.4.0
3
+ cryptography>=3.4.0
@@ -0,0 +1 @@
1
+ crafy_captcha