transcrypto 1.7.0__py3-none-any.whl → 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- transcrypto/__init__.py +1 -1
- transcrypto/cli/__init__.py +3 -0
- transcrypto/cli/aeshash.py +370 -0
- transcrypto/cli/bidsecret.py +336 -0
- transcrypto/cli/clibase.py +183 -0
- transcrypto/cli/intmath.py +429 -0
- transcrypto/cli/publicalgos.py +878 -0
- transcrypto/core/__init__.py +3 -0
- transcrypto/{aes.py → core/aes.py} +17 -29
- transcrypto/core/bid.py +161 -0
- transcrypto/{dsa.py → core/dsa.py} +28 -27
- transcrypto/{elgamal.py → core/elgamal.py} +33 -32
- transcrypto/core/hashes.py +96 -0
- transcrypto/core/key.py +735 -0
- transcrypto/{modmath.py → core/modmath.py} +91 -17
- transcrypto/{rsa.py → core/rsa.py} +51 -50
- transcrypto/{sss.py → core/sss.py} +27 -26
- transcrypto/profiler.py +29 -13
- transcrypto/transcrypto.py +60 -1996
- transcrypto/utils/__init__.py +3 -0
- transcrypto/utils/base.py +72 -0
- transcrypto/utils/human.py +278 -0
- transcrypto/utils/logging.py +139 -0
- transcrypto/utils/saferandom.py +102 -0
- transcrypto/utils/stats.py +360 -0
- transcrypto/utils/timer.py +175 -0
- {transcrypto-1.7.0.dist-info → transcrypto-2.0.0.dist-info}/METADATA +111 -109
- transcrypto-2.0.0.dist-info/RECORD +33 -0
- transcrypto/base.py +0 -1918
- transcrypto-1.7.0.dist-info/RECORD +0 -17
- /transcrypto/{constants.py → core/constants.py} +0 -0
- {transcrypto-1.7.0.dist-info → transcrypto-2.0.0.dist-info}/WHEEL +0 -0
- {transcrypto-1.7.0.dist-info → transcrypto-2.0.0.dist-info}/entry_points.txt +0 -0
- {transcrypto-1.7.0.dist-info → transcrypto-2.0.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -22,7 +22,8 @@ from typing import Self
|
|
|
22
22
|
|
|
23
23
|
import gmpy2
|
|
24
24
|
|
|
25
|
-
from . import aes,
|
|
25
|
+
from transcrypto.core import aes, hashes, key, modmath
|
|
26
|
+
from transcrypto.utils import base, saferandom
|
|
26
27
|
|
|
27
28
|
_MAX_KEY_GENERATION_FAILURES = 15
|
|
28
29
|
|
|
@@ -32,7 +33,7 @@ _ELGAMAL_SIGNATURE_HASH_PREFIX = b'transcrypto.ElGamal.Signature.1.0\x00'
|
|
|
32
33
|
|
|
33
34
|
|
|
34
35
|
@dataclasses.dataclass(kw_only=True, slots=True, frozen=True, repr=False)
|
|
35
|
-
class ElGamalSharedPublicKey(
|
|
36
|
+
class ElGamalSharedPublicKey(key.CryptoKey):
|
|
36
37
|
"""El-Gamal shared public key. This key can be shared by a group.
|
|
37
38
|
|
|
38
39
|
BEWARE: This is **NOT** DSA! No measures are taken here to prevent timing attacks.
|
|
@@ -50,7 +51,7 @@ class ElGamalSharedPublicKey(base.CryptoKey):
|
|
|
50
51
|
"""Check data.
|
|
51
52
|
|
|
52
53
|
Raises:
|
|
53
|
-
InputError: invalid inputs
|
|
54
|
+
base.InputError: invalid inputs
|
|
54
55
|
|
|
55
56
|
"""
|
|
56
57
|
if self.prime_modulus < 7 or not modmath.IsPrime(self.prime_modulus): # noqa: PLR2004
|
|
@@ -92,18 +93,18 @@ class ElGamalSharedPublicKey(base.CryptoKey):
|
|
|
92
93
|
Hash512("prefix" || len(aad) || aad || message || salt)
|
|
93
94
|
|
|
94
95
|
Raises:
|
|
95
|
-
CryptoError: hash output is out of range
|
|
96
|
+
key.CryptoError: hash output is out of range
|
|
96
97
|
|
|
97
98
|
"""
|
|
98
99
|
aad: bytes = b'' if associated_data is None else associated_data
|
|
99
100
|
la: bytes = base.IntToFixedBytes(len(aad), 8)
|
|
100
101
|
assert len(salt) == 64, 'should never happen: salt should be exactly 64 bytes' # noqa: PLR2004, S101
|
|
101
102
|
y: int = base.BytesToInt(
|
|
102
|
-
|
|
103
|
+
hashes.Hash512(_ELGAMAL_SIGNATURE_HASH_PREFIX + la + aad + message + salt)
|
|
103
104
|
)
|
|
104
105
|
if not 1 < y < self.prime_modulus:
|
|
105
106
|
# will only reasonably happen if modulus is small
|
|
106
|
-
raise
|
|
107
|
+
raise key.CryptoError(f'hash output {y} is out of range/invalid {self.prime_modulus}')
|
|
107
108
|
return y
|
|
108
109
|
|
|
109
110
|
@classmethod
|
|
@@ -117,7 +118,7 @@ class ElGamalSharedPublicKey(base.CryptoKey):
|
|
|
117
118
|
ElGamalSharedPublicKey object ready for use
|
|
118
119
|
|
|
119
120
|
Raises:
|
|
120
|
-
InputError: invalid inputs
|
|
121
|
+
base.InputError: invalid inputs
|
|
121
122
|
|
|
122
123
|
"""
|
|
123
124
|
# test inputs
|
|
@@ -127,12 +128,12 @@ class ElGamalSharedPublicKey(base.CryptoKey):
|
|
|
127
128
|
p: int = modmath.NBitRandomPrimes(bit_length).pop()
|
|
128
129
|
g: int = 0
|
|
129
130
|
while not 2 < g < p: # noqa: PLR2004
|
|
130
|
-
g =
|
|
131
|
+
g = saferandom.RandBits(bit_length)
|
|
131
132
|
return cls(prime_modulus=p, group_base=g)
|
|
132
133
|
|
|
133
134
|
|
|
134
135
|
@dataclasses.dataclass(kw_only=True, slots=True, frozen=True, repr=False)
|
|
135
|
-
class ElGamalPublicKey(ElGamalSharedPublicKey,
|
|
136
|
+
class ElGamalPublicKey(ElGamalSharedPublicKey, key.Encryptor, key.Verifier):
|
|
136
137
|
"""El-Gamal public key. This is an individual public key.
|
|
137
138
|
|
|
138
139
|
BEWARE: This is **NOT** DSA! No measures are taken here to prevent timing attacks.
|
|
@@ -148,7 +149,7 @@ class ElGamalPublicKey(ElGamalSharedPublicKey, base.Encryptor, base.Verifier):
|
|
|
148
149
|
"""Check data.
|
|
149
150
|
|
|
150
151
|
Raises:
|
|
151
|
-
InputError: invalid inputs
|
|
152
|
+
base.InputError: invalid inputs
|
|
152
153
|
|
|
153
154
|
"""
|
|
154
155
|
super(ElGamalPublicKey, self).__post_init__()
|
|
@@ -186,8 +187,8 @@ class ElGamalPublicKey(ElGamalSharedPublicKey, base.Encryptor, base.Verifier):
|
|
|
186
187
|
self.group_base,
|
|
187
188
|
self.individual_base,
|
|
188
189
|
}:
|
|
189
|
-
ephemeral_key =
|
|
190
|
-
if
|
|
190
|
+
ephemeral_key = saferandom.RandBits(bit_length)
|
|
191
|
+
if modmath.GCD(ephemeral_key, p_1) != 1:
|
|
191
192
|
ephemeral_key = 0 # we have to try again
|
|
192
193
|
return (ephemeral_key, modmath.ModInv(ephemeral_key, p_1))
|
|
193
194
|
|
|
@@ -205,7 +206,7 @@ class ElGamalPublicKey(ElGamalSharedPublicKey, base.Encryptor, base.Verifier):
|
|
|
205
206
|
ciphertext message tuple ((int, int), 2 ≤ c1,c2 < modulus)
|
|
206
207
|
|
|
207
208
|
Raises:
|
|
208
|
-
InputError: invalid inputs
|
|
209
|
+
base.InputError: invalid inputs
|
|
209
210
|
|
|
210
211
|
"""
|
|
211
212
|
# test inputs
|
|
@@ -252,13 +253,13 @@ class ElGamalPublicKey(ElGamalSharedPublicKey, base.Encryptor, base.Verifier):
|
|
|
252
253
|
# generate random r and encrypt it
|
|
253
254
|
r: int = 0
|
|
254
255
|
while not 1 < r < self.prime_modulus:
|
|
255
|
-
r =
|
|
256
|
+
r = saferandom.RandBits(self.prime_modulus.bit_length())
|
|
256
257
|
k: int = self.modulus_size
|
|
257
258
|
i_ct: tuple[int, int] = self.RawEncrypt(r)
|
|
258
259
|
ct: bytes = base.IntToFixedBytes(i_ct[0], k) + base.IntToFixedBytes(i_ct[1], k)
|
|
259
260
|
assert len(ct) == 2 * k, 'should never happen: c_kem should be exactly 2k bytes' # noqa: S101
|
|
260
261
|
# encrypt plaintext with AES-256-GCM using SHA512(r)[32:] as key; return ct || Encrypt(...)
|
|
261
|
-
ss: bytes =
|
|
262
|
+
ss: bytes = hashes.Hash512(base.IntToFixedBytes(r, k))
|
|
262
263
|
aad: bytes = b'' if associated_data is None else associated_data
|
|
263
264
|
aad_prime: bytes = _ELGAMAL_ENCRYPTION_AAD_PREFIX + base.IntToFixedBytes(len(aad), 8) + aad + ct
|
|
264
265
|
return ct + aes.AESKey(key256=ss[32:]).Encrypt(plaintext, associated_data=aad_prime)
|
|
@@ -278,7 +279,7 @@ class ElGamalPublicKey(ElGamalSharedPublicKey, base.Encryptor, base.Verifier):
|
|
|
278
279
|
True if signature is valid, False otherwise
|
|
279
280
|
|
|
280
281
|
Raises:
|
|
281
|
-
InputError: invalid inputs
|
|
282
|
+
base.InputError: invalid inputs
|
|
282
283
|
|
|
283
284
|
"""
|
|
284
285
|
# test inputs
|
|
@@ -312,7 +313,7 @@ class ElGamalPublicKey(ElGamalSharedPublicKey, base.Encryptor, base.Verifier):
|
|
|
312
313
|
True if signature is valid, False otherwise
|
|
313
314
|
|
|
314
315
|
Raises:
|
|
315
|
-
InputError: invalid inputs
|
|
316
|
+
base.InputError: invalid inputs
|
|
316
317
|
|
|
317
318
|
"""
|
|
318
319
|
k: int = self.modulus_size
|
|
@@ -349,7 +350,7 @@ class ElGamalPublicKey(ElGamalSharedPublicKey, base.Encryptor, base.Verifier):
|
|
|
349
350
|
|
|
350
351
|
|
|
351
352
|
@dataclasses.dataclass(kw_only=True, slots=True, frozen=True, repr=False)
|
|
352
|
-
class ElGamalPrivateKey(ElGamalPublicKey,
|
|
353
|
+
class ElGamalPrivateKey(ElGamalPublicKey, key.Decryptor, key.Signer):
|
|
353
354
|
"""El-Gamal private key.
|
|
354
355
|
|
|
355
356
|
BEWARE: This is **NOT** DSA! No measures are taken here to prevent timing attacks.
|
|
@@ -365,8 +366,8 @@ class ElGamalPrivateKey(ElGamalPublicKey, base.Decryptor, base.Signer):
|
|
|
365
366
|
"""Check data.
|
|
366
367
|
|
|
367
368
|
Raises:
|
|
368
|
-
InputError: invalid inputs
|
|
369
|
-
CryptoError: modulus math is inconsistent with values
|
|
369
|
+
base.InputError: invalid inputs
|
|
370
|
+
key.CryptoError: modulus math is inconsistent with values
|
|
370
371
|
|
|
371
372
|
"""
|
|
372
373
|
super(ElGamalPrivateKey, self).__post_init__()
|
|
@@ -376,7 +377,7 @@ class ElGamalPrivateKey(ElGamalPublicKey, base.Decryptor, base.Signer):
|
|
|
376
377
|
}:
|
|
377
378
|
raise base.InputError(f'invalid decrypt_exp: {self}')
|
|
378
379
|
if gmpy2.powmod(self.group_base, self.decrypt_exp, self.prime_modulus) != self.individual_base:
|
|
379
|
-
raise
|
|
380
|
+
raise key.CryptoError(f'inconsistent g**e % p == i: {self}')
|
|
380
381
|
|
|
381
382
|
def __str__(self) -> str:
|
|
382
383
|
"""Safe (no secrets) string representation of the ElGamalPrivateKey.
|
|
@@ -388,7 +389,7 @@ class ElGamalPrivateKey(ElGamalPublicKey, base.Decryptor, base.Signer):
|
|
|
388
389
|
return (
|
|
389
390
|
'ElGamalPrivateKey('
|
|
390
391
|
f'{super(ElGamalPrivateKey, self).__str__()}, '
|
|
391
|
-
f'decrypt_exp={
|
|
392
|
+
f'decrypt_exp={hashes.ObfuscateSecret(self.decrypt_exp)})'
|
|
392
393
|
)
|
|
393
394
|
|
|
394
395
|
def RawDecrypt(self, ciphertext: tuple[int, int], /) -> int:
|
|
@@ -404,7 +405,7 @@ class ElGamalPrivateKey(ElGamalPublicKey, base.Decryptor, base.Signer):
|
|
|
404
405
|
decrypted message (int, 1 ≤ m < modulus)
|
|
405
406
|
|
|
406
407
|
Raises:
|
|
407
|
-
InputError: invalid inputs
|
|
408
|
+
base.InputError: invalid inputs
|
|
408
409
|
|
|
409
410
|
"""
|
|
410
411
|
# test inputs
|
|
@@ -437,7 +438,7 @@ class ElGamalPrivateKey(ElGamalPublicKey, base.Decryptor, base.Signer):
|
|
|
437
438
|
bytes: Decrypted plaintext bytes
|
|
438
439
|
|
|
439
440
|
Raises:
|
|
440
|
-
InputError: invalid inputs
|
|
441
|
+
base.InputError: invalid inputs
|
|
441
442
|
|
|
442
443
|
"""
|
|
443
444
|
k: int = self.modulus_size
|
|
@@ -446,7 +447,7 @@ class ElGamalPrivateKey(ElGamalPublicKey, base.Decryptor, base.Signer):
|
|
|
446
447
|
# split ciphertext in 3 parts: the first 2k bytes is ct, the rest is AES-256-GCM
|
|
447
448
|
ct1, ct2, aes_ct = ciphertext[:k], ciphertext[k : 2 * k], ciphertext[2 * k :]
|
|
448
449
|
r: int = self.RawDecrypt((base.BytesToInt(ct1), base.BytesToInt(ct2)))
|
|
449
|
-
ss: bytes =
|
|
450
|
+
ss: bytes = hashes.Hash512(base.IntToFixedBytes(r, k))
|
|
450
451
|
aad: bytes = b'' if associated_data is None else associated_data
|
|
451
452
|
aad_prime: bytes = (
|
|
452
453
|
_ELGAMAL_ENCRYPTION_AAD_PREFIX + base.IntToFixedBytes(len(aad), 8) + aad + ct1 + ct2
|
|
@@ -467,7 +468,7 @@ class ElGamalPrivateKey(ElGamalPublicKey, base.Decryptor, base.Signer):
|
|
|
467
468
|
signed message tuple ((int, int), 2 ≤ s1 < modulus, 2 ≤ s2 < modulus-1)
|
|
468
469
|
|
|
469
470
|
Raises:
|
|
470
|
-
InputError: invalid inputs
|
|
471
|
+
base.InputError: invalid inputs
|
|
471
472
|
|
|
472
473
|
"""
|
|
473
474
|
# test inputs
|
|
@@ -505,13 +506,13 @@ class ElGamalPrivateKey(ElGamalPublicKey, base.Decryptor, base.Signer):
|
|
|
505
506
|
bytes: Signature; salt || Padded(s, k) - see above
|
|
506
507
|
|
|
507
508
|
Raises:
|
|
508
|
-
InputError: invalid inputs
|
|
509
|
+
base.InputError: invalid inputs
|
|
509
510
|
|
|
510
511
|
"""
|
|
511
512
|
k: int = self.modulus_size
|
|
512
513
|
if k <= 64: # noqa: PLR2004
|
|
513
514
|
raise base.InputError(f'modulus too small for signing operations: {k} bytes')
|
|
514
|
-
salt: bytes =
|
|
515
|
+
salt: bytes = saferandom.RandBytes(64)
|
|
515
516
|
s_int: tuple[int, int] = self.RawSign(self._DomainSeparatedHash(message, associated_data, salt))
|
|
516
517
|
s_bytes: bytes = base.IntToFixedBytes(s_int[0], k) + base.IntToFixedBytes(s_int[1], k)
|
|
517
518
|
assert len(s_bytes) == 2 * k, 'should never happen: s_bytes should be exactly 2k bytes' # noqa: S101
|
|
@@ -528,8 +529,8 @@ class ElGamalPrivateKey(ElGamalPublicKey, base.Decryptor, base.Signer):
|
|
|
528
529
|
ElGamalPrivateKey object ready for use
|
|
529
530
|
|
|
530
531
|
Raises:
|
|
531
|
-
InputError: invalid inputs
|
|
532
|
-
CryptoError: failed generation
|
|
532
|
+
base.InputError: invalid inputs
|
|
533
|
+
key.CryptoError: failed generation
|
|
533
534
|
|
|
534
535
|
"""
|
|
535
536
|
# test inputs
|
|
@@ -545,7 +546,7 @@ class ElGamalPrivateKey(ElGamalPublicKey, base.Decryptor, base.Signer):
|
|
|
545
546
|
while (
|
|
546
547
|
not 2 < decrypt_exp < shared_key.prime_modulus or decrypt_exp == shared_key.group_base # noqa: PLR2004
|
|
547
548
|
):
|
|
548
|
-
decrypt_exp =
|
|
549
|
+
decrypt_exp = saferandom.RandBits(bit_length)
|
|
549
550
|
# make the object
|
|
550
551
|
return cls(
|
|
551
552
|
prime_modulus=shared_key.prime_modulus,
|
|
@@ -558,5 +559,5 @@ class ElGamalPrivateKey(ElGamalPublicKey, base.Decryptor, base.Signer):
|
|
|
558
559
|
except base.InputError as err:
|
|
559
560
|
failures += 1
|
|
560
561
|
if failures >= _MAX_KEY_GENERATION_FAILURES:
|
|
561
|
-
raise
|
|
562
|
+
raise key.CryptoError(f'failed key generation {failures} times') from err
|
|
562
563
|
logging.warning(err)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright 2026 Daniel Balparda <balparda@github.com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Balparda's TransCrypto hash utilities library."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import logging
|
|
9
|
+
import pathlib
|
|
10
|
+
|
|
11
|
+
from transcrypto.utils import base
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def Hash256(data: bytes, /) -> bytes:
|
|
15
|
+
"""SHA-256 hash of bytes data. Always a length of 32 bytes.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
data (bytes): Data to compute hash for
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
32 bytes (256 bits) of SHA-256 hash;
|
|
22
|
+
if converted to hexadecimal (with BytesToHex() or hex()) will be 64 chars of string;
|
|
23
|
+
if converted to int (big-endian, unsigned, with BytesToInt()) will be 0 ≤ i < 2**256
|
|
24
|
+
|
|
25
|
+
"""
|
|
26
|
+
return hashlib.sha256(data).digest()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def Hash512(data: bytes, /) -> bytes:
|
|
30
|
+
"""SHA-512 hash of bytes data. Always a length of 64 bytes.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
data (bytes): Data to compute hash for
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
64 bytes (512 bits) of SHA-512 hash;
|
|
37
|
+
if converted to hexadecimal (with BytesToHex() or hex()) will be 128 chars of string;
|
|
38
|
+
if converted to int (big-endian, unsigned, with BytesToInt()) will be 0 ≤ i < 2**512
|
|
39
|
+
|
|
40
|
+
"""
|
|
41
|
+
return hashlib.sha512(data).digest()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def FileHash(full_path: str, /, *, digest: str = 'sha256') -> bytes:
|
|
45
|
+
"""SHA-256 hex hash of file on disk. Always a length of 32 bytes (if default digest=='sha256').
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
full_path (str): Path to existing file on disk
|
|
49
|
+
digest (str, optional): Hash method to use, accepts 'sha256' (default) or 'sha512'
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
32 bytes (256 bits) of SHA-256 hash (if default digest=='sha256');
|
|
53
|
+
if converted to hexadecimal (with BytesToHex() or hex()) will be 64 chars of string;
|
|
54
|
+
if converted to int (big-endian, unsigned, with BytesToInt()) will be 0 ≤ i < 2**256
|
|
55
|
+
|
|
56
|
+
Raises:
|
|
57
|
+
base.InputError: file could not be found
|
|
58
|
+
|
|
59
|
+
"""
|
|
60
|
+
# test inputs
|
|
61
|
+
digest = digest.lower().strip().replace('-', '') # normalize so we can accept e.g. "SHA-256"
|
|
62
|
+
if digest not in {'sha256', 'sha512'}:
|
|
63
|
+
raise base.InputError(f'unrecognized digest: {digest!r}')
|
|
64
|
+
full_path = full_path.strip()
|
|
65
|
+
if not full_path or not pathlib.Path(full_path).exists():
|
|
66
|
+
raise base.InputError(f'file {full_path!r} not found for hashing')
|
|
67
|
+
# compute hash
|
|
68
|
+
logging.info(f'Hashing file {full_path!r}')
|
|
69
|
+
with pathlib.Path(full_path).open('rb') as file_obj:
|
|
70
|
+
return hashlib.file_digest(file_obj, digest).digest()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def ObfuscateSecret(data: str | bytes | int, /) -> str:
|
|
74
|
+
"""Obfuscate a secret string/key/bytes/int by hashing SHA-512 and only showing the first 4 bytes.
|
|
75
|
+
|
|
76
|
+
Always a length of 9 chars, e.g. "aabbccdd…" (always adds '…' at the end).
|
|
77
|
+
Known vulnerability: If the secret is small, can be brute-forced!
|
|
78
|
+
Use only on large (~>64bits) secrets.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
data (str | bytes | int): Data to obfuscate
|
|
82
|
+
|
|
83
|
+
Raises:
|
|
84
|
+
base.InputError: _description_
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
str: obfuscated string, e.g. "aabbccdd…"
|
|
88
|
+
|
|
89
|
+
"""
|
|
90
|
+
if isinstance(data, str):
|
|
91
|
+
data = data.encode('utf-8')
|
|
92
|
+
elif isinstance(data, int):
|
|
93
|
+
data = base.IntToBytes(data)
|
|
94
|
+
if not isinstance(data, bytes): # pyright: ignore[reportUnnecessaryIsInstance]
|
|
95
|
+
raise base.InputError(f'invalid type for data: {type(data)}')
|
|
96
|
+
return base.BytesToHex(Hash512(data))[:8] + '…'
|