xchainpy2_crypto 0.1.1__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.
- xchainpy2_crypto-0.1.1/LICENSE +19 -0
- xchainpy2_crypto-0.1.1/PKG-INFO +47 -0
- xchainpy2_crypto-0.1.1/README.md +19 -0
- xchainpy2_crypto-0.1.1/pyproject.toml +39 -0
- xchainpy2_crypto-0.1.1/setup.cfg +4 -0
- xchainpy2_crypto-0.1.1/xchainpy2_crypto/__init__.py +2 -0
- xchainpy2_crypto-0.1.1/xchainpy2_crypto/keystore.py +174 -0
- xchainpy2_crypto-0.1.1/xchainpy2_crypto/tests/__init__.py +0 -0
- xchainpy2_crypto-0.1.1/xchainpy2_crypto/tests/test_address.py +50 -0
- xchainpy2_crypto-0.1.1/xchainpy2_crypto/tests/test_keystore.py +54 -0
- xchainpy2_crypto-0.1.1/xchainpy2_crypto/tests/test_mnemonic.py +49 -0
- xchainpy2_crypto-0.1.1/xchainpy2_crypto/utils.py +129 -0
- xchainpy2_crypto-0.1.1/xchainpy2_crypto.egg-info/PKG-INFO +47 -0
- xchainpy2_crypto-0.1.1/xchainpy2_crypto.egg-info/SOURCES.txt +15 -0
- xchainpy2_crypto-0.1.1/xchainpy2_crypto.egg-info/dependency_links.txt +1 -0
- xchainpy2_crypto-0.1.1/xchainpy2_crypto.egg-info/requires.txt +5 -0
- xchainpy2_crypto-0.1.1/xchainpy2_crypto.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Copyright (c) 2023 Tirinox (aka TRX1 aka account1242 aka Old1)
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
4
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
5
|
+
in the Software without restriction, including without limitation the rights
|
|
6
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
7
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
8
|
+
furnished to do so, subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in all
|
|
11
|
+
copies or substantial portions of the Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
16
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
18
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
19
|
+
SOFTWARE.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xchainpy2_crypto
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: XChainPy2 Crypto utils and keystore management
|
|
5
|
+
Author-email: Tirinox <tirinox@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: source, https://github.com/tirinox/xchainpy
|
|
8
|
+
Keywords: Crypto,THORChain,Blockchain,XChain
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.6
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.7
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Requires-Python: >=3.7
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: bip-utils<3.0.0,>=2.12.1.0
|
|
24
|
+
Requires-Dist: pycryptodome<4.0,>=3.23
|
|
25
|
+
Provides-Extra: test
|
|
26
|
+
Requires-Dist: pytest; extra == "test"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# How it works
|
|
30
|
+
Typically keystore files encrypt a seed to a file, however this is not appropriate or UX friendly, since the phrase
|
|
31
|
+
cannot be recovered after the fact.
|
|
32
|
+
|
|
33
|
+
Crypto design:
|
|
34
|
+
|
|
35
|
+
`[entropy] -> [phrase] -> [seed] -> [privateKey] -> [publicKey] -> [address]`
|
|
36
|
+
|
|
37
|
+
Instead, XCHAIN-CRYPTO stores the phrase in a keystore file, then decrypts and passes this phrase to other clients:
|
|
38
|
+
|
|
39
|
+
`[keystore] -> XCHAIN-CRYPTO -> [phrase] -> ChainClient`
|
|
40
|
+
|
|
41
|
+
The ChainClients can then convert this into their respective key-pairs and addresses. Users can also export their
|
|
42
|
+
phrases after the fact, ensuring they have saved it securely. This could enhance UX onboarding since users aren't forced
|
|
43
|
+
to write their phrases down immediately for empty or test wallets.
|
|
44
|
+
|
|
45
|
+
### Documentation
|
|
46
|
+
|
|
47
|
+
👉 https://xchainpy2.readthedocs.io/en/latest/packages/xchainpy2_crypto.html
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# How it works
|
|
2
|
+
Typically keystore files encrypt a seed to a file, however this is not appropriate or UX friendly, since the phrase
|
|
3
|
+
cannot be recovered after the fact.
|
|
4
|
+
|
|
5
|
+
Crypto design:
|
|
6
|
+
|
|
7
|
+
`[entropy] -> [phrase] -> [seed] -> [privateKey] -> [publicKey] -> [address]`
|
|
8
|
+
|
|
9
|
+
Instead, XCHAIN-CRYPTO stores the phrase in a keystore file, then decrypts and passes this phrase to other clients:
|
|
10
|
+
|
|
11
|
+
`[keystore] -> XCHAIN-CRYPTO -> [phrase] -> ChainClient`
|
|
12
|
+
|
|
13
|
+
The ChainClients can then convert this into their respective key-pairs and addresses. Users can also export their
|
|
14
|
+
phrases after the fact, ensuring they have saved it securely. This could enhance UX onboarding since users aren't forced
|
|
15
|
+
to write their phrases down immediately for empty or test wallets.
|
|
16
|
+
|
|
17
|
+
### Documentation
|
|
18
|
+
|
|
19
|
+
👉 https://xchainpy2.readthedocs.io/en/latest/packages/xchainpy2_crypto.html
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools", "setuptools-scm"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "xchainpy2_crypto"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "Tirinox", email = "tirinox@gmail.com" },
|
|
10
|
+
]
|
|
11
|
+
description = "XChainPy2 Crypto utils and keystore management"
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
requires-python = ">=3.7"
|
|
14
|
+
keywords = ["Crypto", "THORChain", "Blockchain", "XChain"]
|
|
15
|
+
license = { text = "MIT" }
|
|
16
|
+
urls = { source = "https://github.com/tirinox/xchainpy" }
|
|
17
|
+
classifiers = [
|
|
18
|
+
'Development Status :: 3 - Alpha',
|
|
19
|
+
# Chose either "3 - Alpha", "4 - Beta" or "5 - Production/Stable" as the current state of your package
|
|
20
|
+
'Intended Audience :: Developers', # Define that your audience are developers
|
|
21
|
+
'Topic :: Software Development :: Build Tools',
|
|
22
|
+
'License :: OSI Approved :: MIT License',
|
|
23
|
+
'Programming Language :: Python :: 3',
|
|
24
|
+
'Programming Language :: Python :: 3.6',
|
|
25
|
+
'Programming Language :: Python :: 3.7',
|
|
26
|
+
'Programming Language :: Python :: 3.8',
|
|
27
|
+
'Programming Language :: Python :: 3.9',
|
|
28
|
+
'Programming Language :: Python :: 3.10',
|
|
29
|
+
'Programming Language :: Python :: 3.11',
|
|
30
|
+
]
|
|
31
|
+
dependencies = [
|
|
32
|
+
"bip-utils>=2.12.1.0,<3.0.0",
|
|
33
|
+
"pycryptodome>=3.23,<4.0",
|
|
34
|
+
]
|
|
35
|
+
[project.optional-dependencies]
|
|
36
|
+
test = ["pytest"]
|
|
37
|
+
|
|
38
|
+
[tool.setuptools]
|
|
39
|
+
packages = ["xchainpy2_crypto"]
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import json
|
|
3
|
+
import uuid
|
|
4
|
+
from os import urandom
|
|
5
|
+
from typing import NamedTuple
|
|
6
|
+
|
|
7
|
+
from Crypto.Cipher import AES
|
|
8
|
+
from Crypto.Hash import BLAKE2b
|
|
9
|
+
from Crypto.Util import Counter
|
|
10
|
+
|
|
11
|
+
from .utils import validate_mnemonic, generate_mnemonic
|
|
12
|
+
|
|
13
|
+
CIPHER = 'aes-128-ctr'
|
|
14
|
+
NBITS = 128
|
|
15
|
+
KDF = 'pbkdf2'
|
|
16
|
+
PRF = 'hmac-sha256'
|
|
17
|
+
DKLEN = 32
|
|
18
|
+
C = 262144
|
|
19
|
+
HASH_FUNCTION = 'sha256'
|
|
20
|
+
META = 'xchain-keystore'
|
|
21
|
+
VERSION = 1
|
|
22
|
+
ENCODING = 'utf-8'
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class InvalidPasswordException(Exception):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class KeyStore(NamedTuple):
|
|
30
|
+
cipher: str
|
|
31
|
+
ciphertext: str
|
|
32
|
+
cipherparams_iv: str
|
|
33
|
+
kdf: str
|
|
34
|
+
kdfparams_prf: str
|
|
35
|
+
kdfparams_dklen: int
|
|
36
|
+
kdfparams_salt: str
|
|
37
|
+
kdfparams_c: int
|
|
38
|
+
mac: str
|
|
39
|
+
id: str
|
|
40
|
+
version: int
|
|
41
|
+
meta: str
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def to_dict(self):
|
|
45
|
+
return {
|
|
46
|
+
'crypto': {
|
|
47
|
+
'cipher': self.cipher,
|
|
48
|
+
'ciphertext': self.ciphertext,
|
|
49
|
+
'cipherparams': {
|
|
50
|
+
'iv': self.cipherparams_iv
|
|
51
|
+
},
|
|
52
|
+
'kdf': self.kdf,
|
|
53
|
+
'kdfparams': {
|
|
54
|
+
'prf': self.kdfparams_prf,
|
|
55
|
+
'dklen': self.kdfparams_dklen,
|
|
56
|
+
'salt': self.kdfparams_salt,
|
|
57
|
+
'c': self.kdfparams_c
|
|
58
|
+
},
|
|
59
|
+
'mac': self.mac,
|
|
60
|
+
},
|
|
61
|
+
'id': self.id,
|
|
62
|
+
'version': self.version,
|
|
63
|
+
'meta': self.meta
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def to_json(self):
|
|
68
|
+
return json.dumps(self.to_dict)
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def from_dict(cls, j):
|
|
72
|
+
crypto = j.get('crypto')
|
|
73
|
+
return cls(
|
|
74
|
+
cipher=crypto['cipher'],
|
|
75
|
+
ciphertext=crypto['ciphertext'],
|
|
76
|
+
cipherparams_iv=crypto['cipherparams']['iv'],
|
|
77
|
+
kdf=crypto['kdf'],
|
|
78
|
+
kdfparams_prf=crypto['kdfparams']['prf'],
|
|
79
|
+
kdfparams_dklen=int(crypto['kdfparams']['dklen']),
|
|
80
|
+
kdfparams_salt=crypto['kdfparams']['salt'],
|
|
81
|
+
kdfparams_c=crypto['kdfparams']['c'],
|
|
82
|
+
mac=crypto['mac'],
|
|
83
|
+
id=j['id'],
|
|
84
|
+
version=j['version'],
|
|
85
|
+
meta=j['meta']
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
def from_file(cls, path):
|
|
90
|
+
with open(path) as f:
|
|
91
|
+
data = json.load(f)
|
|
92
|
+
return cls.from_dict(data)
|
|
93
|
+
|
|
94
|
+
def save(self, path: str, indent=4):
|
|
95
|
+
with open(path, 'w') as f:
|
|
96
|
+
json.dump(self.to_dict, f, indent=indent)
|
|
97
|
+
|
|
98
|
+
@classmethod
|
|
99
|
+
def encrypt_to_keystore(cls, mnemonic: str, password: str):
|
|
100
|
+
"""
|
|
101
|
+
Encrypts a mnemonic to a keystore with a password
|
|
102
|
+
:param str mnemonic: BIP39 mnemonic
|
|
103
|
+
:param str password: Password
|
|
104
|
+
:return: KeyStore
|
|
105
|
+
"""
|
|
106
|
+
if not validate_mnemonic(mnemonic):
|
|
107
|
+
raise Exception("Invalid BIP39 Phrase")
|
|
108
|
+
|
|
109
|
+
ID = str(uuid.uuid4())
|
|
110
|
+
salt = urandom(32)
|
|
111
|
+
iv = urandom(16).hex()
|
|
112
|
+
|
|
113
|
+
derived_key = hashlib.pbkdf2_hmac(
|
|
114
|
+
HASH_FUNCTION,
|
|
115
|
+
password.encode(ENCODING),
|
|
116
|
+
salt,
|
|
117
|
+
C,
|
|
118
|
+
DKLEN
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
ctr = Counter.new(NBITS, initial_value=int(iv, 16))
|
|
122
|
+
aes_cipher = AES.new(derived_key[0:16], AES.MODE_CTR, counter=ctr)
|
|
123
|
+
cipher_bytes = aes_cipher.encrypt(mnemonic.encode("utf8"))
|
|
124
|
+
|
|
125
|
+
blake256 = BLAKE2b.new(digest_bits=256)
|
|
126
|
+
blake256.update((derived_key[16:32] + cipher_bytes))
|
|
127
|
+
mac = blake256.hexdigest()
|
|
128
|
+
|
|
129
|
+
return cls(
|
|
130
|
+
cipher=CIPHER,
|
|
131
|
+
ciphertext=cipher_bytes.hex(),
|
|
132
|
+
cipherparams_iv=iv,
|
|
133
|
+
kdf=KDF,
|
|
134
|
+
kdfparams_prf=PRF,
|
|
135
|
+
kdfparams_dklen=DKLEN,
|
|
136
|
+
kdfparams_salt=salt.hex(),
|
|
137
|
+
kdfparams_c=C,
|
|
138
|
+
mac=mac,
|
|
139
|
+
id=ID,
|
|
140
|
+
version=VERSION,
|
|
141
|
+
meta=META
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
def decrypt_from_keystore(self, password: str):
|
|
145
|
+
"""
|
|
146
|
+
Derives a mnemonic from a keystore with a password
|
|
147
|
+
:param str password: password
|
|
148
|
+
:return: str Mnemonic phrase
|
|
149
|
+
"""
|
|
150
|
+
derived_key = hashlib.pbkdf2_hmac(
|
|
151
|
+
HASH_FUNCTION,
|
|
152
|
+
password.encode(ENCODING),
|
|
153
|
+
bytes.fromhex(self.kdfparams_salt),
|
|
154
|
+
self.kdfparams_c,
|
|
155
|
+
self.kdfparams_dklen
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
cipher_bytes = bytes.fromhex(self.ciphertext)
|
|
159
|
+
|
|
160
|
+
blake256 = BLAKE2b.new(digest_bits=256)
|
|
161
|
+
blake256.update((derived_key[16:32] + cipher_bytes))
|
|
162
|
+
mac = blake256.hexdigest()
|
|
163
|
+
if mac != self.mac:
|
|
164
|
+
raise InvalidPasswordException("Invalid password")
|
|
165
|
+
|
|
166
|
+
ctr = Counter.new(NBITS, initial_value=int(self.cipherparams_iv, 16))
|
|
167
|
+
aes_cipher = AES.new(derived_key[0:16], AES.MODE_CTR, counter=ctr)
|
|
168
|
+
phrase = aes_cipher.decrypt(cipher_bytes)
|
|
169
|
+
return phrase.decode("utf8")
|
|
170
|
+
|
|
171
|
+
@classmethod
|
|
172
|
+
def generate_and_encrypt(cls, password: str, *args, **kwargs):
|
|
173
|
+
mnemonic = generate_mnemonic(*args, **kwargs)
|
|
174
|
+
return cls.encrypt_to_keystore(mnemonic, password)
|
|
File without changes
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import random
|
|
3
|
+
import string
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from bip_utils import Bech32ChecksumError
|
|
7
|
+
|
|
8
|
+
from xchainpy2_crypto import encode_address, decode_address
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def randomize_characters(s, n):
|
|
12
|
+
# Create a string of valid characters to choose from
|
|
13
|
+
valid_characters = string.ascii_letters + string.digits
|
|
14
|
+
|
|
15
|
+
# Convert the string to a list of characters
|
|
16
|
+
chars = list(s)
|
|
17
|
+
|
|
18
|
+
# Randomize n characters in the list
|
|
19
|
+
for _ in range(n):
|
|
20
|
+
index = random.randint(0, len(chars) - 1)
|
|
21
|
+
random_char = random.choice(valid_characters)
|
|
22
|
+
chars[index] = random_char
|
|
23
|
+
|
|
24
|
+
# Convert the list back to a string
|
|
25
|
+
randomized_string = ''.join(chars)
|
|
26
|
+
|
|
27
|
+
return randomized_string
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_address_encode_decode():
|
|
31
|
+
prefixes = ('thor', 'tthor', 'thorpub', 'tthorpub', 'maya', 'cacao', 'btc')
|
|
32
|
+
for i in range(100):
|
|
33
|
+
pub_key = os.urandom(32)
|
|
34
|
+
prefix = prefixes[i % len(prefixes)]
|
|
35
|
+
address = encode_address(pub_key, prefix)
|
|
36
|
+
|
|
37
|
+
assert address.startswith(prefix)
|
|
38
|
+
decoded = decode_address(address, prefix)
|
|
39
|
+
assert decoded == pub_key
|
|
40
|
+
|
|
41
|
+
other_pub_key = os.urandom(32)
|
|
42
|
+
assert decode_address(address, prefix) != other_pub_key
|
|
43
|
+
|
|
44
|
+
# noinspection PyTypeChecker
|
|
45
|
+
with pytest.raises((ValueError, Bech32ChecksumError)):
|
|
46
|
+
spoiled_address = randomize_characters(address, random.randint(2, 10))
|
|
47
|
+
decode_address(spoiled_address, prefix)
|
|
48
|
+
|
|
49
|
+
with pytest.raises(ValueError):
|
|
50
|
+
decode_address(address, randomize_characters(prefix, random.randint(5, 10)))
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import random
|
|
3
|
+
import tempfile
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from xchainpy2_crypto import generate_mnemonic, KeyStore, InvalidPasswordException
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_keystore_encrypt_decrypt():
|
|
11
|
+
for i in range(10):
|
|
12
|
+
mnemonic = generate_mnemonic()
|
|
13
|
+
password = os.urandom(random.randint(1, 16)).hex()
|
|
14
|
+
ks = KeyStore.encrypt_to_keystore(mnemonic, password)
|
|
15
|
+
|
|
16
|
+
mnemonic_out = ks.decrypt_from_keystore(password)
|
|
17
|
+
assert mnemonic == mnemonic_out
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_invalid_password():
|
|
21
|
+
mnemonic = generate_mnemonic()
|
|
22
|
+
password = 'good_password123'
|
|
23
|
+
ks = KeyStore.encrypt_to_keystore(mnemonic, password)
|
|
24
|
+
|
|
25
|
+
with pytest.raises(InvalidPasswordException):
|
|
26
|
+
ks.decrypt_from_keystore('wrong_password')
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_save_load():
|
|
31
|
+
mnemonic = generate_mnemonic()
|
|
32
|
+
password = 'good_password123'
|
|
33
|
+
|
|
34
|
+
ks = KeyStore.encrypt_to_keystore(mnemonic, password)
|
|
35
|
+
|
|
36
|
+
# save to temp file
|
|
37
|
+
with tempfile.NamedTemporaryFile(delete=False) as temp_file:
|
|
38
|
+
ks.save(temp_file.name)
|
|
39
|
+
assert os.path.exists(temp_file.name)
|
|
40
|
+
|
|
41
|
+
ks2 = KeyStore.from_file(temp_file.name)
|
|
42
|
+
assert ks.to_dict == ks2.to_dict
|
|
43
|
+
assert ks.meta == ks2.meta and bool(ks.meta)
|
|
44
|
+
assert ks.id == ks2.id and bool(ks.id)
|
|
45
|
+
assert ks.ciphertext == ks2.ciphertext and bool(ks.ciphertext)
|
|
46
|
+
assert ks.cipher == ks2.cipher and bool(ks.cipher)
|
|
47
|
+
assert ks.cipherparams_iv == ks2.cipherparams_iv and bool(ks.cipherparams_iv)
|
|
48
|
+
assert ks.kdf == ks2.kdf and bool(ks.kdf)
|
|
49
|
+
assert ks.kdfparams_prf == ks2.kdfparams_prf and bool(ks.kdfparams_prf)
|
|
50
|
+
|
|
51
|
+
assert ks.decrypt_from_keystore(password) == ks2.decrypt_from_keystore(password) == mnemonic
|
|
52
|
+
|
|
53
|
+
with pytest.raises(FileNotFoundError):
|
|
54
|
+
KeyStore.from_file('_some_non_existent.txt')
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from xchainpy2_crypto import *
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_create_mnemonic():
|
|
7
|
+
mnemonic = generate_mnemonic()
|
|
8
|
+
assert len(mnemonic.split()) == 12
|
|
9
|
+
assert validate_mnemonic(mnemonic)
|
|
10
|
+
|
|
11
|
+
mnemonic = generate_mnemonic(15)
|
|
12
|
+
assert len(mnemonic.split()) == 15
|
|
13
|
+
assert validate_mnemonic(mnemonic)
|
|
14
|
+
|
|
15
|
+
mnemonic = generate_mnemonic(24)
|
|
16
|
+
assert len(mnemonic.split()) == 24
|
|
17
|
+
assert validate_mnemonic(mnemonic)
|
|
18
|
+
|
|
19
|
+
assert not validate_mnemonic('')
|
|
20
|
+
assert not validate_mnemonic(None)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@pytest.mark.parametrize('wallet_index, key, address', [
|
|
24
|
+
(0, '437a3090352a646872f9ddc9c172e7d74d3e0472bc67ca5eccef0a64b94d791d',
|
|
25
|
+
'thor14nhwuxr8e00m2qtfgpqtwafnw25x8vmtka8c5a'),
|
|
26
|
+
(1, 'aea64d24898d02b080b6231ee0778e3f4c0b31cf756b565d586b8e7f390feafe',
|
|
27
|
+
'thor14p9fz9f8hw6f7jeh9msxg6v7xw65r52r7xnjka'),
|
|
28
|
+
(2, '55935cd344247aa0cb2dca2c13780f7ed9a571efd709cae9e82204ef57ec62ef',
|
|
29
|
+
'thor1xs3tksyfhyxe2xlwr9rtlcjm0pzthdsuavx39c')
|
|
30
|
+
])
|
|
31
|
+
def test_wallet_private_key_index(wallet_index, key, address):
|
|
32
|
+
# totally random, no worries
|
|
33
|
+
mnemonic = 'grain dizzy better fossil taste install tobacco bless source science category van'
|
|
34
|
+
|
|
35
|
+
derivation_path = f"44'/931'/0'/0/{wallet_index}"
|
|
36
|
+
|
|
37
|
+
seed = get_seed(mnemonic)
|
|
38
|
+
assert len(seed) == 64
|
|
39
|
+
gen_key = get_bip32(seed, derivation_path)
|
|
40
|
+
|
|
41
|
+
priv_key = get_private_key(gen_key)
|
|
42
|
+
pub_key = get_public_key(gen_key)
|
|
43
|
+
|
|
44
|
+
assert priv_key.hex() == key, 'Generated key does not match expected key'
|
|
45
|
+
assert create_address(pub_key, 'thor') == address, 'Generated address does not match expected address'
|
|
46
|
+
|
|
47
|
+
assert derive_private_key(mnemonic, derivation_path).hex() == key, 'Generated key does not match expected key'
|
|
48
|
+
assert derive_address(mnemonic, derivation_path, 'thor') == address, \
|
|
49
|
+
'Generated address does not match expected address'
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
from typing import Union
|
|
2
|
+
|
|
3
|
+
from Crypto.Hash import RIPEMD160, SHA256
|
|
4
|
+
from bip_utils import Bip39MnemonicGenerator, Bip39WordsNum, Bip39Languages, Bip39MnemonicValidator, \
|
|
5
|
+
Bip39SeedGenerator, Bech32Encoder, Bip32Secp256k1, Bech32Decoder
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def generate_mnemonic(words_number=Bip39WordsNum.WORDS_NUM_12, lang=Bip39Languages.ENGLISH):
|
|
9
|
+
"""
|
|
10
|
+
Generate a mnemonic phrase
|
|
11
|
+
:param words_number: Words number 12/24, default: 12
|
|
12
|
+
:param lang: Language, default: English
|
|
13
|
+
:return: str: Mnemonic phrase
|
|
14
|
+
"""
|
|
15
|
+
mnemonic = Bip39MnemonicGenerator(lang=lang).FromWordsNumber(words_number)
|
|
16
|
+
return str(mnemonic)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _normalize_mnemonic_type(mnemonic: str) -> str:
|
|
20
|
+
if isinstance(mnemonic, (list, tuple)):
|
|
21
|
+
mnemonic = ' '.join(mnemonic)
|
|
22
|
+
return mnemonic
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def validate_mnemonic(mnemonic: str, lang: str = None) -> bool:
|
|
26
|
+
"""
|
|
27
|
+
:param mnemonic: Mnemonic phrase or list of words
|
|
28
|
+
:param lang: Bip39Languages or None
|
|
29
|
+
:return: bool: True if valid, False otherwise
|
|
30
|
+
"""
|
|
31
|
+
if not mnemonic:
|
|
32
|
+
return False
|
|
33
|
+
mnemonic = _normalize_mnemonic_type(mnemonic)
|
|
34
|
+
return Bip39MnemonicValidator(lang).IsValid(mnemonic)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_seed(mnemonic, lang: str = None) -> bytes:
|
|
38
|
+
"""
|
|
39
|
+
:param mnemonic: Mnemonic phrase or list of words
|
|
40
|
+
:param lang: Bip39Languages or None
|
|
41
|
+
:return: bytes: Seed
|
|
42
|
+
"""
|
|
43
|
+
mnemonic = _normalize_mnemonic_type(mnemonic)
|
|
44
|
+
return Bip39SeedGenerator(mnemonic, lang).Generate()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def sha256ripemd160(hex_str: str) -> str:
|
|
48
|
+
"""
|
|
49
|
+
Calculate `ripemd160(sha256(hex))` from the hex string
|
|
50
|
+
:param str hex_str: Input hex string
|
|
51
|
+
:return: str: Output hex string
|
|
52
|
+
"""
|
|
53
|
+
data = bytes.fromhex(hex_str)
|
|
54
|
+
return RIPEMD160.RIPEMD160Hash(
|
|
55
|
+
SHA256.SHA256Hash(data).digest()
|
|
56
|
+
).hexdigest()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def encode_address(value: Union[str, bytes], prefix='thor') -> str:
|
|
60
|
+
"""
|
|
61
|
+
Encode address from the string or bytes
|
|
62
|
+
:param str value: Input
|
|
63
|
+
:param str prefix: Address prefix 'thor' by default
|
|
64
|
+
:return: str: Address
|
|
65
|
+
"""
|
|
66
|
+
if isinstance(value, str):
|
|
67
|
+
value = bytes.fromhex(value)
|
|
68
|
+
return Bech32Encoder.Encode(prefix, value)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def decode_address(address: str, prefix: str) -> bytes:
|
|
72
|
+
"""
|
|
73
|
+
Decode address to bytes
|
|
74
|
+
:param str address: Address
|
|
75
|
+
:param str prefix: Address prefix (e.g. 'thor')
|
|
76
|
+
:return: bytes: Decoded address
|
|
77
|
+
"""
|
|
78
|
+
return Bech32Decoder.Decode(prefix, address)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def create_address(public_key: bytes, prefix='thor') -> str:
|
|
82
|
+
"""
|
|
83
|
+
Create address from public key
|
|
84
|
+
:param bytes public_key: Public key
|
|
85
|
+
:param str prefix: Address prefix
|
|
86
|
+
:return: str: Address
|
|
87
|
+
"""
|
|
88
|
+
hexed = public_key.hex()
|
|
89
|
+
hash_hex = sha256ripemd160(hexed)
|
|
90
|
+
return encode_address(hash_hex, prefix)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def get_bip32(seed: bytes, derivation_path: str) -> Bip32Secp256k1:
|
|
94
|
+
return Bip32Secp256k1.FromSeed(seed).DerivePath(derivation_path)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def get_private_key(key: Bip32Secp256k1) -> bytes:
|
|
98
|
+
return key.PrivateKey().Raw().ToBytes()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def get_public_key(key: Bip32Secp256k1) -> bytes:
|
|
102
|
+
return key.PublicKey().RawCompressed().ToBytes()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def derive_private_key(mnemonic: str, derivation_path: str) -> bytes:
|
|
106
|
+
"""
|
|
107
|
+
Derive private key from mnemonic and derivation path
|
|
108
|
+
:param str mnemonic: Mnemonic phrase or list of words
|
|
109
|
+
:param str derivation_path: Derivation path
|
|
110
|
+
:return: bytes: Private key
|
|
111
|
+
"""
|
|
112
|
+
seed = get_seed(mnemonic)
|
|
113
|
+
key = get_bip32(seed, derivation_path)
|
|
114
|
+
return get_private_key(key)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def derive_address(mnemonic: str, derivation_path: str, prefix='thor', lang: str = None) -> str:
|
|
118
|
+
"""
|
|
119
|
+
Derive address from mnemonic and derivation path
|
|
120
|
+
:param str mnemonic: Mnemonic phrase or list of words
|
|
121
|
+
:param str derivation_path: Derivation path
|
|
122
|
+
:param str prefix: Address prefix
|
|
123
|
+
:param str lang: Language of mnemonic words
|
|
124
|
+
:return: str: Address
|
|
125
|
+
"""
|
|
126
|
+
seed = get_seed(mnemonic, lang)
|
|
127
|
+
key = get_bip32(seed, derivation_path)
|
|
128
|
+
public_key = get_public_key(key)
|
|
129
|
+
return create_address(public_key, prefix)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xchainpy2_crypto
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: XChainPy2 Crypto utils and keystore management
|
|
5
|
+
Author-email: Tirinox <tirinox@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: source, https://github.com/tirinox/xchainpy
|
|
8
|
+
Keywords: Crypto,THORChain,Blockchain,XChain
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.6
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.7
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Requires-Python: >=3.7
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: bip-utils<3.0.0,>=2.12.1.0
|
|
24
|
+
Requires-Dist: pycryptodome<4.0,>=3.23
|
|
25
|
+
Provides-Extra: test
|
|
26
|
+
Requires-Dist: pytest; extra == "test"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# How it works
|
|
30
|
+
Typically keystore files encrypt a seed to a file, however this is not appropriate or UX friendly, since the phrase
|
|
31
|
+
cannot be recovered after the fact.
|
|
32
|
+
|
|
33
|
+
Crypto design:
|
|
34
|
+
|
|
35
|
+
`[entropy] -> [phrase] -> [seed] -> [privateKey] -> [publicKey] -> [address]`
|
|
36
|
+
|
|
37
|
+
Instead, XCHAIN-CRYPTO stores the phrase in a keystore file, then decrypts and passes this phrase to other clients:
|
|
38
|
+
|
|
39
|
+
`[keystore] -> XCHAIN-CRYPTO -> [phrase] -> ChainClient`
|
|
40
|
+
|
|
41
|
+
The ChainClients can then convert this into their respective key-pairs and addresses. Users can also export their
|
|
42
|
+
phrases after the fact, ensuring they have saved it securely. This could enhance UX onboarding since users aren't forced
|
|
43
|
+
to write their phrases down immediately for empty or test wallets.
|
|
44
|
+
|
|
45
|
+
### Documentation
|
|
46
|
+
|
|
47
|
+
👉 https://xchainpy2.readthedocs.io/en/latest/packages/xchainpy2_crypto.html
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
xchainpy2_crypto/__init__.py
|
|
5
|
+
xchainpy2_crypto/keystore.py
|
|
6
|
+
xchainpy2_crypto/utils.py
|
|
7
|
+
xchainpy2_crypto.egg-info/PKG-INFO
|
|
8
|
+
xchainpy2_crypto.egg-info/SOURCES.txt
|
|
9
|
+
xchainpy2_crypto.egg-info/dependency_links.txt
|
|
10
|
+
xchainpy2_crypto.egg-info/requires.txt
|
|
11
|
+
xchainpy2_crypto.egg-info/top_level.txt
|
|
12
|
+
xchainpy2_crypto/tests/__init__.py
|
|
13
|
+
xchainpy2_crypto/tests/test_address.py
|
|
14
|
+
xchainpy2_crypto/tests/test_keystore.py
|
|
15
|
+
xchainpy2_crypto/tests/test_mnemonic.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
xchainpy2_crypto
|