session-python 1.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.
- session_python/__init__.py +4 -0
- session_python/client.py +729 -0
- session_python/crypto.py +298 -0
- session_python/mnemonic.py +76 -0
- session_python/network.py +193 -0
- session_python/polling.py +222 -0
- session_python/protobuf/__init__.py +1 -0
- session_python/protobuf/signalservice_pb2.py +112 -0
- session_python-1.0.0.dist-info/METADATA +155 -0
- session_python-1.0.0.dist-info/RECORD +13 -0
- session_python-1.0.0.dist-info/WHEEL +5 -0
- session_python-1.0.0.dist-info/licenses/LICENSE +21 -0
- session_python-1.0.0.dist-info/top_level.txt +1 -0
session_python/crypto.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import hashlib
|
|
3
|
+
import binascii
|
|
4
|
+
import nacl.signing
|
|
5
|
+
import nacl.public
|
|
6
|
+
import nacl.exceptions
|
|
7
|
+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
8
|
+
from cryptography.hazmat.primitives import hashes, hmac as crypto_hmac
|
|
9
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
10
|
+
from typing import Optional
|
|
11
|
+
from .protobuf.signalservice_pb2 import WebSocketMessage, WebSocketRequestMessage, Envelope
|
|
12
|
+
|
|
13
|
+
def remove_prefix_if_needed(val):
|
|
14
|
+
if isinstance(val, str):
|
|
15
|
+
if val.startswith("05"):
|
|
16
|
+
return val[2:]
|
|
17
|
+
elif isinstance(val, (bytes, bytearray)):
|
|
18
|
+
if len(val) > 0 and val[0] == 5:
|
|
19
|
+
return val[1:]
|
|
20
|
+
return val
|
|
21
|
+
|
|
22
|
+
def add_prefix_if_needed(val: bytes) -> bytes:
|
|
23
|
+
if len(val) == 32:
|
|
24
|
+
return b'\x05' + val
|
|
25
|
+
return val
|
|
26
|
+
|
|
27
|
+
class KeyPair:
|
|
28
|
+
def __init__(self, key_type: str, private_key: bytes, public_key: bytes):
|
|
29
|
+
self.key_type = key_type
|
|
30
|
+
self.private_key = private_key
|
|
31
|
+
self.public_key = public_key
|
|
32
|
+
|
|
33
|
+
class SessionKeys:
|
|
34
|
+
def __init__(self, seed_hex: str):
|
|
35
|
+
if len(seed_hex) != 64:
|
|
36
|
+
seed_hex = (seed_hex + "0" * 32)[:64]
|
|
37
|
+
|
|
38
|
+
self.seed = binascii.unhexlify(seed_hex)
|
|
39
|
+
|
|
40
|
+
# Ed25519 KeyPair
|
|
41
|
+
self.signing_key = nacl.signing.SigningKey(self.seed)
|
|
42
|
+
self.verify_key = self.signing_key.verify_key
|
|
43
|
+
|
|
44
|
+
self.ed25519 = KeyPair(
|
|
45
|
+
key_type="ed25519",
|
|
46
|
+
private_key=self.seed,
|
|
47
|
+
public_key=self.verify_key.encode()
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# X25519 KeyPair
|
|
51
|
+
self.x25519_private = self.signing_key.to_curve25519_private_key()
|
|
52
|
+
self.x25519_public = self.verify_key.to_curve25519_public_key()
|
|
53
|
+
|
|
54
|
+
prepended_public = b'\x05' + self.x25519_public.encode()
|
|
55
|
+
|
|
56
|
+
self.x25519 = KeyPair(
|
|
57
|
+
key_type="x25519",
|
|
58
|
+
private_key=self.x25519_private.encode(),
|
|
59
|
+
public_key=prepended_public
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def crypto_box_seal(message: bytes, recipient_pk: bytes) -> bytes:
|
|
63
|
+
recipient_pk_clean = remove_prefix_if_needed(recipient_pk)
|
|
64
|
+
if len(recipient_pk_clean) != 32:
|
|
65
|
+
raise ValueError(f"Recipient public key must be 32 bytes, got {len(recipient_pk_clean)}")
|
|
66
|
+
|
|
67
|
+
pk = nacl.public.PublicKey(recipient_pk_clean)
|
|
68
|
+
box = nacl.public.SealedBox(pk)
|
|
69
|
+
return box.encrypt(message)
|
|
70
|
+
|
|
71
|
+
def crypto_box_seal_open(sealed: bytes, recipient_sk: bytes) -> bytes:
|
|
72
|
+
if len(recipient_sk) != 32:
|
|
73
|
+
raise ValueError(f"Recipient secret key must be 32 bytes, got {len(recipient_sk)}")
|
|
74
|
+
|
|
75
|
+
sk = nacl.public.PrivateKey(recipient_sk)
|
|
76
|
+
box = nacl.public.SealedBox(sk)
|
|
77
|
+
try:
|
|
78
|
+
return box.decrypt(sealed)
|
|
79
|
+
except nacl.exceptions.CryptoError:
|
|
80
|
+
raise ValueError("Decryption failed (crypto_box_seal_open)")
|
|
81
|
+
|
|
82
|
+
def add_message_padding(message_bytes: bytes) -> bytes:
|
|
83
|
+
orig_len = len(message_bytes)
|
|
84
|
+
target_len = (((orig_len + 2 + 159) // 160) * 160) - 1
|
|
85
|
+
padded = bytearray(target_len)
|
|
86
|
+
padded[:orig_len] = message_bytes
|
|
87
|
+
padded[orig_len] = 0x80
|
|
88
|
+
return bytes(padded)
|
|
89
|
+
|
|
90
|
+
def remove_message_padding(padded_bytes: bytes) -> bytes:
|
|
91
|
+
for i in range(len(padded_bytes) - 1, -1, -1):
|
|
92
|
+
if padded_bytes[i] == 0x80:
|
|
93
|
+
return padded_bytes[:i]
|
|
94
|
+
if padded_bytes[i] != 0x00:
|
|
95
|
+
return padded_bytes
|
|
96
|
+
raise ValueError("Invalid padding")
|
|
97
|
+
|
|
98
|
+
def encrypt_using_session_protocol(sender_keys: SessionKeys, recipient: str, plaintext: bytes) -> bytes:
|
|
99
|
+
recipient_pk = binascii.unhexlify(remove_prefix_if_needed(recipient))
|
|
100
|
+
verification_data = plaintext + sender_keys.ed25519.public_key + recipient_pk
|
|
101
|
+
signature = sender_keys.signing_key.sign(verification_data).signature
|
|
102
|
+
plaintext_with_metadata = plaintext + sender_keys.ed25519.public_key + signature
|
|
103
|
+
return crypto_box_seal(plaintext_with_metadata, recipient_pk)
|
|
104
|
+
|
|
105
|
+
def decrypt_with_session_protocol(recipient_keys: SessionKeys, envelope_content: bytes) -> tuple[bytes, str]:
|
|
106
|
+
recipient_x25519_pk = remove_prefix_if_needed(recipient_keys.x25519.public_key)
|
|
107
|
+
|
|
108
|
+
plaintext_with_metadata = crypto_box_seal_open(
|
|
109
|
+
envelope_content,
|
|
110
|
+
recipient_keys.x25519.private_key
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
signature_size = 64
|
|
114
|
+
ed25519_public_key_size = 32
|
|
115
|
+
min_size = signature_size + ed25519_public_key_size
|
|
116
|
+
|
|
117
|
+
if len(plaintext_with_metadata) <= min_size:
|
|
118
|
+
raise ValueError("Decrypted content too short")
|
|
119
|
+
|
|
120
|
+
signature_start = len(plaintext_with_metadata) - signature_size
|
|
121
|
+
signature = plaintext_with_metadata[signature_start:]
|
|
122
|
+
|
|
123
|
+
pubkey_start = len(plaintext_with_metadata) - min_size
|
|
124
|
+
pubkey_end = len(plaintext_with_metadata) - signature_size
|
|
125
|
+
sender_ed25519_pubkey = plaintext_with_metadata[pubkey_start:pubkey_end]
|
|
126
|
+
|
|
127
|
+
plainTextEnd = len(plaintext_with_metadata) - min_size
|
|
128
|
+
plaintext = plaintext_with_metadata[:plainTextEnd]
|
|
129
|
+
|
|
130
|
+
verification_data = plaintext + sender_ed25519_pubkey + recipient_x25519_pk
|
|
131
|
+
verify_key = nacl.signing.VerifyKey(sender_ed25519_pubkey)
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
verify_key.verify(verification_data, signature)
|
|
135
|
+
except nacl.exceptions.BadSignatureError:
|
|
136
|
+
raise ValueError("Invalid message signature")
|
|
137
|
+
|
|
138
|
+
sender_x25519_pubkey = verify_key.to_curve25519_public_key().encode()
|
|
139
|
+
sender_session_id = "05" + binascii.hexlify(sender_x25519_pubkey).decode()
|
|
140
|
+
|
|
141
|
+
return plaintext, sender_session_id
|
|
142
|
+
|
|
143
|
+
def wrap_envelope(envelope_bytes: bytes) -> bytes:
|
|
144
|
+
request = WebSocketRequestMessage(
|
|
145
|
+
id=0,
|
|
146
|
+
body=envelope_bytes,
|
|
147
|
+
verb="PUT",
|
|
148
|
+
path="/api/v1/message"
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
websocket = WebSocketMessage(
|
|
152
|
+
type=WebSocketMessage.Type.REQUEST,
|
|
153
|
+
request=request
|
|
154
|
+
)
|
|
155
|
+
return websocket.SerializeToString()
|
|
156
|
+
|
|
157
|
+
# --- Attachment encryption and decryption ---
|
|
158
|
+
|
|
159
|
+
def pkcs7_pad(data: bytes, block_size: int = 16) -> bytes:
|
|
160
|
+
pad_len = block_size - (len(data) % block_size)
|
|
161
|
+
return data + bytes([pad_len] * pad_len)
|
|
162
|
+
|
|
163
|
+
def pkcs7_unpad(data: bytes) -> bytes:
|
|
164
|
+
pad_len = data[-1]
|
|
165
|
+
if pad_len < 1 or pad_len > 16:
|
|
166
|
+
raise ValueError("Invalid PKCS7 padding length")
|
|
167
|
+
for b in data[-pad_len:]:
|
|
168
|
+
if b != pad_len:
|
|
169
|
+
raise ValueError("Invalid PKCS7 padding bytes")
|
|
170
|
+
return data[:-pad_len]
|
|
171
|
+
|
|
172
|
+
def add_attachment_padding(data: bytes) -> bytes:
|
|
173
|
+
import math
|
|
174
|
+
orig_len = len(data)
|
|
175
|
+
# Math.max(541, Math.floor(Math.pow(1.05, Math.ceil(Math.log(orig_len) / Math.log(1.05)))))
|
|
176
|
+
if orig_len == 0:
|
|
177
|
+
padded_size = 541
|
|
178
|
+
else:
|
|
179
|
+
log_len = math.log(orig_len)
|
|
180
|
+
log_base = math.log(1.05)
|
|
181
|
+
padded_size = int(max(541, math.floor(math.pow(1.05, math.ceil(log_len / log_base)))))
|
|
182
|
+
|
|
183
|
+
max_attachment_size = 15 * 1024 * 1024 # 15MB
|
|
184
|
+
if padded_size > max_attachment_size and orig_len <= max_attachment_size:
|
|
185
|
+
padded_size = max_attachment_size
|
|
186
|
+
|
|
187
|
+
padded = bytearray(padded_size)
|
|
188
|
+
padded[:orig_len] = data
|
|
189
|
+
return bytes(padded)
|
|
190
|
+
|
|
191
|
+
def encrypt_attachment_data(plaintext: bytes, keys: bytes, iv: bytes) -> tuple[bytes, bytes]:
|
|
192
|
+
if len(keys) != 64:
|
|
193
|
+
raise ValueError("Attachment key must be 64 bytes")
|
|
194
|
+
if len(iv) != 16:
|
|
195
|
+
raise ValueError("Attachment IV must be 16 bytes")
|
|
196
|
+
|
|
197
|
+
aes_key = keys[:32]
|
|
198
|
+
mac_key = keys[32:]
|
|
199
|
+
|
|
200
|
+
# Pad plaintext for AES-CBC
|
|
201
|
+
padded_plaintext = pkcs7_pad(plaintext, 16)
|
|
202
|
+
|
|
203
|
+
# AES-CBC encrypt
|
|
204
|
+
cipher = Cipher(algorithms.AES(aes_key), modes.CBC(iv))
|
|
205
|
+
encryptor = cipher.encryptor()
|
|
206
|
+
ciphertext = encryptor.update(padded_plaintext) + encryptor.finalize()
|
|
207
|
+
|
|
208
|
+
# ivAndCiphertext = iv || ciphertext
|
|
209
|
+
iv_and_ciphertext = iv + ciphertext
|
|
210
|
+
|
|
211
|
+
# Compute MAC
|
|
212
|
+
h = crypto_hmac.HMAC(mac_key, hashes.SHA256())
|
|
213
|
+
h.update(iv_and_ciphertext)
|
|
214
|
+
mac = h.finalize()
|
|
215
|
+
|
|
216
|
+
# final payload: iv || ciphertext || mac
|
|
217
|
+
encrypted_bin = iv_and_ciphertext + mac
|
|
218
|
+
|
|
219
|
+
# digest: sha256 of encrypted_bin
|
|
220
|
+
digest = hashlib.sha256(encrypted_bin).digest()
|
|
221
|
+
|
|
222
|
+
return encrypted_bin, digest
|
|
223
|
+
|
|
224
|
+
def encrypt_attachment(data: bytes, add_padding: bool = True) -> dict:
|
|
225
|
+
pointer_key = os.urandom(64)
|
|
226
|
+
iv = os.urandom(16)
|
|
227
|
+
padded = add_attachment_padding(data) if add_padding else data
|
|
228
|
+
ciphertext, digest = encrypt_attachment_data(padded, pointer_key, iv)
|
|
229
|
+
return {
|
|
230
|
+
"ciphertext": ciphertext,
|
|
231
|
+
"digest": digest,
|
|
232
|
+
"key": pointer_key
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
def decrypt_attachment(data: bytes, key: bytes, digest: bytes, size: Optional[int] = None) -> bytes:
|
|
236
|
+
if len(key) != 64:
|
|
237
|
+
raise ValueError("Attachment key must be 64 bytes")
|
|
238
|
+
if len(data) < 48:
|
|
239
|
+
raise ValueError("Attachment data too short")
|
|
240
|
+
|
|
241
|
+
aes_key = key[:32]
|
|
242
|
+
mac_key = key[32:]
|
|
243
|
+
|
|
244
|
+
iv = data[:16]
|
|
245
|
+
ciphertext = data[16:-32]
|
|
246
|
+
iv_and_ciphertext = data[:-32]
|
|
247
|
+
mac = data[-32:]
|
|
248
|
+
|
|
249
|
+
# Verify MAC
|
|
250
|
+
h = crypto_hmac.HMAC(mac_key, hashes.SHA256())
|
|
251
|
+
h.update(iv_and_ciphertext)
|
|
252
|
+
calculated_mac = h.finalize()
|
|
253
|
+
if calculated_mac != mac:
|
|
254
|
+
raise ValueError("Bad attachment MAC")
|
|
255
|
+
|
|
256
|
+
# Verify Digest
|
|
257
|
+
calculated_digest = hashlib.sha256(data).digest()
|
|
258
|
+
if calculated_digest != digest:
|
|
259
|
+
raise ValueError("Bad attachment digest")
|
|
260
|
+
|
|
261
|
+
# AES-CBC decrypt
|
|
262
|
+
cipher = Cipher(algorithms.AES(aes_key), modes.CBC(iv))
|
|
263
|
+
decryptor = cipher.decryptor()
|
|
264
|
+
decrypted_padded = decryptor.update(ciphertext) + decryptor.finalize()
|
|
265
|
+
|
|
266
|
+
decrypted = pkcs7_unpad(decrypted_padded)
|
|
267
|
+
|
|
268
|
+
# If size is specified, truncate the padding added by add_attachment_padding
|
|
269
|
+
if size is not None:
|
|
270
|
+
if size <= len(decrypted):
|
|
271
|
+
decrypted = decrypted[:size]
|
|
272
|
+
else:
|
|
273
|
+
raise ValueError("Decrypted attachment size mismatch")
|
|
274
|
+
|
|
275
|
+
return decrypted
|
|
276
|
+
|
|
277
|
+
# --- Profile GCM Encryption/Decryption ---
|
|
278
|
+
|
|
279
|
+
def encrypt_profile(data: bytes, key: bytes) -> bytes:
|
|
280
|
+
if len(key) != 32:
|
|
281
|
+
raise ValueError("Profile key must be 32 bytes")
|
|
282
|
+
iv = os.urandom(12)
|
|
283
|
+
aesgcm = AESGCM(key)
|
|
284
|
+
ciphertext = aesgcm.encrypt(iv, data, None) # returns ciphertext + 16-byte tag appended
|
|
285
|
+
return iv + ciphertext
|
|
286
|
+
|
|
287
|
+
def decrypt_profile(data: bytes, key: bytes) -> bytes:
|
|
288
|
+
if len(key) != 32:
|
|
289
|
+
raise ValueError("Profile key must be 32 bytes")
|
|
290
|
+
if len(data) < 29: # 12 bytes IV + 16 bytes tag + at least 1 byte ciphertext
|
|
291
|
+
raise ValueError("Profile data too short")
|
|
292
|
+
iv = data[:12]
|
|
293
|
+
ciphertext = data[12:]
|
|
294
|
+
aesgcm = AESGCM(key)
|
|
295
|
+
try:
|
|
296
|
+
return aesgcm.decrypt(iv, ciphertext, None)
|
|
297
|
+
except Exception:
|
|
298
|
+
raise ValueError("Failed to decrypt profile data")
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import binascii
|
|
2
|
+
|
|
3
|
+
WORDS = ['abbey', 'abducts', 'ability', 'ablaze', 'abnormal', 'abort', 'abrasive', 'absorb', 'abyss', 'academy', 'aces', 'aching', 'acidic', 'acoustic', 'acquire', 'across', 'actress', 'acumen', 'adapt', 'addicted', 'adept', 'adhesive', 'adjust', 'adopt', 'adrenalin', 'adult', 'adventure', 'aerial', 'afar', 'affair', 'afield', 'afloat', 'afoot', 'afraid', 'after', 'against', 'agenda', 'aggravate', 'agile', 'aglow', 'agnostic', 'agony', 'agreed', 'ahead', 'aided', 'ailments', 'aimless', 'airport', 'aisle', 'ajar', 'akin', 'alarms', 'album', 'alchemy', 'alerts', 'algebra', 'alkaline', 'alley', 'almost', 'aloof', 'alpine', 'already', 'also', 'altitude', 'alumni', 'always', 'amaze', 'ambush', 'amended', 'amidst', 'ammo', 'amnesty', 'among', 'amply', 'amused', 'anchor', 'android', 'anecdote', 'angled', 'ankle', 'annoyed', 'answers', 'antics', 'anvil', 'anxiety', 'anybody', 'apart', 'apex', 'aphid', 'aplomb', 'apology', 'apply', 'apricot', 'aptitude', 'aquarium', 'arbitrary', 'archer', 'ardent', 'arena', 'argue', 'arises', 'army', 'around', 'arrow', 'arsenic', 'artistic', 'ascend', 'ashtray', 'aside', 'asked', 'asleep', 'aspire', 'assorted', 'asylum', 'athlete', 'atlas', 'atom', 'atrium', 'attire', 'auburn', 'auctions', 'audio', 'august', 'aunt', 'austere', 'autumn', 'avatar', 'avidly', 'avoid', 'awakened', 'awesome', 'awful', 'awkward', 'awning', 'awoken', 'axes', 'axis', 'axle', 'aztec', 'azure', 'baby', 'bacon', 'badge', 'baffles', 'bagpipe', 'bailed', 'bakery', 'balding', 'bamboo', 'banjo', 'baptism', 'basin', 'batch', 'bawled', 'bays', 'because', 'beer', 'befit', 'begun', 'behind', 'being', 'below', 'bemused', 'benches', 'berries', 'bested', 'betting', 'bevel', 'beware', 'beyond', 'bias', 'bicycle', 'bids', 'bifocals', 'biggest', 'bikini', 'bimonthly', 'binocular', 'biology', 'biplane', 'birth', 'biscuit', 'bite', 'biweekly', 'blender', 'blip', 'bluntly', 'boat', 'bobsled', 'bodies', 'bogeys', 'boil', 'boldly', 'bomb', 'border', 'boss', 'both', 'bounced', 'bovine', 'bowling', 'boxes', 'boyfriend', 'broken', 'brunt', 'bubble', 'buckets', 'budget', 'buffet', 'bugs', 'building', 'bulb', 'bumper', 'bunch', 'business', 'butter', 'buying', 'buzzer', 'bygones', 'byline', 'bypass', 'cabin', 'cactus', 'cadets', 'cafe', 'cage', 'cajun', 'cake', 'calamity', 'camp', 'candy', 'casket', 'catch', 'cause', 'cavernous', 'cease', 'cedar', 'ceiling', 'cell', 'cement', 'cent', 'certain', 'chlorine', 'chrome', 'cider', 'cigar', 'cinema', 'circle', 'cistern', 'citadel', 'civilian', 'claim', 'click', 'clue', 'coal', 'cobra', 'cocoa', 'code', 'coexist', 'coffee', 'cogs', 'cohesive', 'coils', 'colony', 'comb', 'cool', 'copy', 'corrode', 'costume', 'cottage', 'cousin', 'cowl', 'criminal', 'cube', 'cucumber', 'cuddled', 'cuffs', 'cuisine', 'cunning', 'cupcake', 'custom', 'cycling', 'cylinder', 'cynical', 'dabbing', 'dads', 'daft', 'dagger', 'daily', 'damp', 'dangerous', 'dapper', 'darted', 'dash', 'dating', 'dauntless', 'dawn', 'daytime', 'dazed', 'debut', 'decay', 'dedicated', 'deepest', 'deftly', 'degrees', 'dehydrate', 'deity', 'dejected', 'delayed', 'demonstrate', 'dented', 'deodorant', 'depth', 'desk', 'devoid', 'dewdrop', 'dexterity', 'dialect', 'dice', 'diet', 'different', 'digit', 'dilute', 'dime', 'dinner', 'diode', 'diplomat', 'directed', 'distance', 'ditch', 'divers', 'dizzy', 'doctor', 'dodge', 'does', 'dogs', 'doing', 'dolphin', 'domestic', 'donuts', 'doorway', 'dormant', 'dosage', 'dotted', 'double', 'dove', 'down', 'dozen', 'dreams', 'drinks', 'drowning', 'drunk', 'drying', 'dual', 'dubbed', 'duckling', 'dude', 'duets', 'duke', 'dullness', 'dummy', 'dunes', 'duplex', 'duration', 'dusted', 'duties', 'dwarf', 'dwelt', 'dwindling', 'dying', 'dynamite', 'dyslexic', 'each', 'eagle', 'earth', 'easy', 'eating', 'eavesdrop', 'eccentric', 'echo', 'eclipse', 'economics', 'ecstatic', 'eden', 'edgy', 'edited', 'educated', 'eels', 'efficient', 'eggs', 'egotistic', 'eight', 'either', 'eject', 'elapse', 'elbow', 'eldest', 'eleven', 'elite', 'elope', 'else', 'eluded', 'emails', 'ember', 'emerge', 'emit', 'emotion', 'empty', 'emulate', 'energy', 'enforce', 'enhanced', 'enigma', 'enjoy', 'enlist', 'enmity', 'enough', 'enraged', 'ensign', 'entrance', 'envy', 'epoxy', 'equip', 'erase', 'erected', 'erosion', 'error', 'eskimos', 'espionage', 'essential', 'estate', 'etched', 'eternal', 'ethics', 'etiquette', 'evaluate', 'evenings', 'evicted', 'evolved', 'examine', 'excess', 'exhale', 'exit', 'exotic', 'exquisite', 'extra', 'exult', 'fabrics', 'factual', 'fading', 'fainted', 'faked', 'fall', 'family', 'fancy', 'farming', 'fatal', 'faulty', 'fawns', 'faxed', 'fazed', 'feast', 'february', 'federal', 'feel', 'feline', 'females', 'fences', 'ferry', 'festival', 'fetches', 'fever', 'fewest', 'fiat', 'fibula', 'fictional', 'fidget', 'fierce', 'fifteen', 'fight', 'films', 'firm', 'fishing', 'fitting', 'five', 'fixate', 'fizzle', 'fleet', 'flippant', 'flying', 'foamy', 'focus', 'foes', 'foggy', 'foiled', 'folding', 'fonts', 'foolish', 'fossil', 'fountain', 'fowls', 'foxes', 'foyer', 'framed', 'friendly', 'frown', 'fruit', 'frying', 'fudge', 'fuel', 'fugitive', 'fully', 'fuming', 'fungal', 'furnished', 'fuselage', 'future', 'fuzzy', 'gables', 'gadget', 'gags', 'gained', 'galaxy', 'gambit', 'gang', 'gasp', 'gather', 'gauze', 'gave', 'gawk', 'gaze', 'gearbox', 'gecko', 'geek', 'gels', 'gemstone', 'general', 'geometry', 'germs', 'gesture', 'getting', 'geyser', 'ghetto', 'ghost', 'giant', 'giddy', 'gifts', 'gigantic', 'gills', 'gimmick', 'ginger', 'girth', 'giving', 'glass', 'gleeful', 'glide', 'gnaw', 'gnome', 'goat', 'goblet', 'godfather', 'goes', 'goggles', 'going', 'goldfish', 'gone', 'goodbye', 'gopher', 'gorilla', 'gossip', 'gotten', 'gourmet', 'governing', 'gown', 'greater', 'grunt', 'guarded', 'guest', 'guide', 'gulp', 'gumball', 'guru', 'gusts', 'gutter', 'guys', 'gymnast', 'gypsy', 'gyrate', 'habitat', 'hacksaw', 'haggled', 'hairy', 'hamburger', 'happens', 'hashing', 'hatchet', 'haunted', 'having', 'hawk', 'haystack', 'hazard', 'hectare', 'hedgehog', 'heels', 'hefty', 'height', 'hemlock', 'hence', 'heron', 'hesitate', 'hexagon', 'hickory', 'hiding', 'highway', 'hijack', 'hiker', 'hills', 'himself', 'hinder', 'hippo', 'hire', 'history', 'hitched', 'hive', 'hoax', 'hobby', 'hockey', 'hoisting', 'hold', 'honked', 'hookup', 'hope', 'hornet', 'hospital', 'hotel', 'hounded', 'hover', 'howls', 'hubcaps', 'huddle', 'huge', 'hull', 'humid', 'hunter', 'hurried', 'husband', 'huts', 'hybrid', 'hydrogen', 'hyper', 'iceberg', 'icing', 'icon', 'identity', 'idiom', 'idled', 'idols', 'igloo', 'ignore', 'iguana', 'illness', 'imagine', 'imbalance', 'imitate', 'impel', 'inactive', 'inbound', 'incur', 'industrial', 'inexact', 'inflamed', 'ingested', 'initiate', 'injury', 'inkling', 'inline', 'inmate', 'innocent', 'inorganic', 'input', 'inquest', 'inroads', 'insult', 'intended', 'inundate', 'invoke', 'inwardly', 'ionic', 'irate', 'iris', 'irony', 'irritate', 'island', 'isolated', 'issued', 'italics', 'itches', 'items', 'itinerary', 'itself', 'ivory', 'jabbed', 'jackets', 'jaded', 'jagged', 'jailed', 'jamming', 'january', 'jargon', 'jaunt', 'javelin', 'jaws', 'jazz', 'jeans', 'jeers', 'jellyfish', 'jeopardy', 'jerseys', 'jester', 'jetting', 'jewels', 'jigsaw', 'jingle', 'jittery', 'jive', 'jobs', 'jockey', 'jogger', 'joining', 'joking', 'jolted', 'jostle', 'journal', 'joyous', 'jubilee', 'judge', 'juggled', 'juicy', 'jukebox', 'july', 'jump', 'junk', 'jury', 'justice', 'juvenile', 'kangaroo', 'karate', 'keep', 'kennel', 'kept', 'kernels', 'kettle', 'keyboard', 'kickoff', 'kidneys', 'king', 'kiosk', 'kisses', 'kitchens', 'kiwi', 'knapsack', 'knee', 'knife', 'knowledge', 'knuckle', 'koala', 'laboratory', 'ladder', 'lagoon', 'lair', 'lakes', 'lamb', 'language', 'laptop', 'large', 'last', 'later', 'launching', 'lava', 'lawsuit', 'layout', 'lazy', 'lectures', 'ledge', 'leech', 'left', 'legion', 'leisure', 'lemon', 'lending', 'leopard', 'lesson', 'lettuce', 'lexicon', 'liar', 'library', 'licks', 'lids', 'lied', 'lifestyle', 'light', 'likewise', 'lilac', 'limits', 'linen', 'lion', 'lipstick', 'liquid', 'listen', 'lively', 'loaded', 'lobster', 'locker', 'lodge', 'lofty', 'logic', 'loincloth', 'long', 'looking', 'lopped', 'lordship', 'losing', 'lottery', 'loudly', 'love', 'lower', 'loyal', 'lucky', 'luggage', 'lukewarm', 'lullaby', 'lumber', 'lunar', 'lurk', 'lush', 'luxury', 'lymph', 'lynx', 'lyrics', 'macro', 'madness', 'magically', 'mailed', 'major', 'makeup', 'malady', 'mammal', 'maps', 'masterful', 'match', 'maul', 'maverick', 'maximum', 'mayor', 'maze', 'meant', 'mechanic', 'medicate', 'meeting', 'megabyte', 'melting', 'memoir', 'menu', 'merger', 'mesh', 'metro', 'mews', 'mice', 'midst', 'mighty', 'mime', 'mirror', 'misery', 'mittens', 'mixture', 'moat', 'mobile', 'mocked', 'mohawk', 'moisture', 'molten', 'moment', 'money', 'moon', 'mops', 'morsel', 'mostly', 'motherly', 'mouth', 'movement', 'mowing', 'much', 'muddy', 'muffin', 'mugged', 'mullet', 'mumble', 'mundane', 'muppet', 'mural', 'musical', 'muzzle', 'myriad', 'mystery', 'myth', 'nabbing', 'nagged', 'nail', 'names', 'nanny', 'napkin', 'narrate', 'nasty', 'natural', 'nautical', 'navy', 'nearby', 'necklace', 'needed', 'negative', 'neither', 'neon', 'nephew', 'nerves', 'nestle', 'network', 'neutral', 'never', 'newt', 'nexus', 'nibs', 'niche', 'niece', 'nifty', 'nightly', 'nimbly', 'nineteen', 'nirvana', 'nitrogen', 'nobody', 'nocturnal', 'nodes', 'noises', 'nomad', 'noodles', 'northern', 'nostril', 'noted', 'nouns', 'novelty', 'nowhere', 'nozzle', 'nuance', 'nucleus', 'nudged', 'nugget', 'nuisance', 'null', 'number', 'nuns', 'nurse', 'nutshell', 'nylon', 'oaks', 'oars', 'oasis', 'oatmeal', 'obedient', 'object', 'obliged', 'obnoxious', 'observant', 'obtains', 'obvious', 'occur', 'ocean', 'october', 'odds', 'odometer', 'offend', 'often', 'oilfield', 'ointment', 'okay', 'older', 'olive', 'olympics', 'omega', 'omission', 'omnibus', 'onboard', 'oncoming', 'oneself', 'ongoing', 'onion', 'online', 'onslaught', 'onto', 'onward', 'oozed', 'opacity', 'opened', 'opposite', 'optical', 'opus', 'orange', 'orbit', 'orchid', 'orders', 'organs', 'origin', 'ornament', 'orphans', 'oscar', 'ostrich', 'otherwise', 'otter', 'ouch', 'ought', 'ounce', 'ourselves', 'oust', 'outbreak', 'oval', 'oven', 'owed', 'owls', 'owner', 'oxidant', 'oxygen', 'oyster', 'ozone', 'pact', 'paddles', 'pager', 'pairing', 'palace', 'pamphlet', 'pancakes', 'paper', 'paradise', 'pastry', 'patio', 'pause', 'pavements', 'pawnshop', 'payment', 'peaches', 'pebbles', 'peculiar', 'pedantic', 'peeled', 'pegs', 'pelican', 'pencil', 'people', 'pepper', 'perfect', 'pests', 'petals', 'phase', 'pheasants', 'phone', 'phrases', 'physics', 'piano', 'picked', 'pierce', 'pigment', 'piloted', 'pimple', 'pinched', 'pioneer', 'pipeline', 'pirate', 'pistons', 'pitched', 'pivot', 'pixels', 'pizza', 'playful', 'pledge', 'pliers', 'plotting', 'plus', 'plywood', 'poaching', 'pockets', 'podcast', 'poetry', 'point', 'poker', 'polar', 'ponies', 'pool', 'popular', 'portents', 'possible', 'potato', 'pouch', 'poverty', 'powder', 'pram', 'present', 'pride', 'problems', 'pruned', 'prying', 'psychic', 'public', 'puck', 'puddle', 'puffin', 'pulp', 'pumpkins', 'punch', 'puppy', 'purged', 'push', 'putty', 'puzzled', 'pylons', 'pyramid', 'python', 'queen', 'quick', 'quote', 'rabbits', 'racetrack', 'radar', 'rafts', 'rage', 'railway', 'raking', 'rally', 'ramped', 'randomly', 'rapid', 'rarest', 'rash', 'rated', 'ravine', 'rays', 'razor', 'react', 'rebel', 'recipe', 'reduce', 'reef', 'refer', 'regular', 'reheat', 'reinvest', 'rejoices', 'rekindle', 'relic', 'remedy', 'renting', 'reorder', 'repent', 'request', 'reruns', 'rest', 'return', 'reunion', 'revamp', 'rewind', 'rhino', 'rhythm', 'ribbon', 'richly', 'ridges', 'rift', 'rigid', 'rims', 'ringing', 'riots', 'ripped', 'rising', 'ritual', 'river', 'roared', 'robot', 'rockets', 'rodent', 'rogue', 'roles', 'romance', 'roomy', 'roped', 'roster', 'rotate', 'rounded', 'rover', 'rowboat', 'royal', 'ruby', 'rudely', 'ruffled', 'rugged', 'ruined', 'ruling', 'rumble', 'runway', 'rural', 'rustled', 'ruthless', 'sabotage', 'sack', 'sadness', 'safety', 'saga', 'sailor', 'sake', 'salads', 'sample', 'sanity', 'sapling', 'sarcasm', 'sash', 'satin', 'saucepan', 'saved', 'sawmill', 'saxophone', 'sayings', 'scamper', 'scenic', 'school', 'science', 'scoop', 'scrub', 'scuba', 'seasons', 'second', 'sedan', 'seeded', 'segments', 'seismic', 'selfish', 'semifinal', 'sensible', 'september', 'sequence', 'serving', 'session', 'setup', 'seventh', 'sewage', 'shackles', 'shelter', 'shipped', 'shocking', 'shrugged', 'shuffled', 'shyness', 'siblings', 'sickness', 'sidekick', 'sieve', 'sifting', 'sighting', 'silk', 'simplest', 'sincerely', 'sipped', 'siren', 'situated', 'sixteen', 'sizes', 'skater', 'skew', 'skirting', 'skulls', 'skydive', 'slackens', 'sleepless', 'slid', 'slower', 'slug', 'smash', 'smelting', 'smidgen', 'smog', 'smuggled', 'snake', 'sneeze', 'sniff', 'snout', 'snug', 'soapy', 'sober', 'soccer', 'soda', 'software', 'soggy', 'soil', 'solved', 'somewhere', 'sonic', 'soothe', 'soprano', 'sorry', 'southern', 'sovereign', 'sowed', 'soya', 'space', 'speedy', 'sphere', 'spiders', 'splendid', 'spout', 'sprig', 'spud', 'spying', 'square', 'stacking', 'stellar', 'stick', 'stockpile', 'strained', 'stunning', 'stylishly', 'subtly', 'succeed', 'suddenly', 'suede', 'suffice', 'sugar', 'suitcase', 'sulking', 'summon', 'sunken', 'superior', 'surfer', 'sushi', 'suture', 'swagger', 'swept', 'swiftly', 'sword', 'swung', 'syllabus', 'symptoms', 'syndrome', 'syringe', 'system', 'taboo', 'tacit', 'tadpoles', 'tagged', 'tail', 'taken', 'talent', 'tamper', 'tanks', 'tapestry', 'tarnished', 'tasked', 'tattoo', 'taunts', 'tavern', 'tawny', 'taxi', 'teardrop', 'technical', 'tedious', 'teeming', 'tell', 'template', 'tender', 'tepid', 'tequila', 'terminal', 'testing', 'tether', 'textbook', 'thaw', 'theatrics', 'thirsty', 'thorn', 'threaten', 'thumbs', 'thwart', 'ticket', 'tidy', 'tiers', 'tiger', 'tilt', 'timber', 'tinted', 'tipsy', 'tirade', 'tissue', 'titans', 'toaster', 'tobacco', 'today', 'toenail', 'toffee', 'together', 'toilet', 'token', 'tolerant', 'tomorrow', 'tonic', 'toolbox', 'topic', 'torch', 'tossed', 'total', 'touchy', 'towel', 'toxic', 'toyed', 'trash', 'trendy', 'tribal', 'trolling', 'truth', 'trying', 'tsunami', 'tubes', 'tucks', 'tudor', 'tuesday', 'tufts', 'tugs', 'tuition', 'tulips', 'tumbling', 'tunnel', 'turnip', 'tusks', 'tutor', 'tuxedo', 'twang', 'tweezers', 'twice', 'twofold', 'tycoon', 'typist', 'tyrant', 'ugly', 'ulcers', 'ultimate', 'umbrella', 'umpire', 'unafraid', 'unbending', 'uncle', 'under', 'uneven', 'unfit', 'ungainly', 'unhappy', 'union', 'unjustly', 'unknown', 'unlikely', 'unmask', 'unnoticed', 'unopened', 'unplugs', 'unquoted', 'unrest', 'unsafe', 'until', 'unusual', 'unveil', 'unwind', 'unzip', 'upbeat', 'upcoming', 'update', 'upgrade', 'uphill', 'upkeep', 'upload', 'upon', 'upper', 'upright', 'upstairs', 'uptight', 'upwards', 'urban', 'urchins', 'urgent', 'usage', 'useful', 'usher', 'using', 'usual', 'utensils', 'utility', 'utmost', 'utopia', 'uttered', 'vacation', 'vague', 'vain', 'value', 'vampire', 'vane', 'vapidly', 'vary', 'vastness', 'vats', 'vaults', 'vector', 'veered', 'vegan', 'vehicle', 'vein', 'velvet', 'venomous', 'verification', 'vessel', 'veteran', 'vexed', 'vials', 'vibrate', 'victim', 'video', 'viewpoint', 'vigilant', 'viking', 'village', 'vinegar', 'violin', 'vipers', 'virtual', 'visited', 'vitals', 'vivid', 'vixen', 'vocal', 'vogue', 'voice', 'volcano', 'vortex', 'voted', 'voucher', 'vowels', 'voyage', 'vulture', 'wade', 'waffle', 'wagtail', 'waist', 'waking', 'wallets', 'wanted', 'warped', 'washing', 'water', 'waveform', 'waxing', 'wayside', 'weavers', 'website', 'wedge', 'weekday', 'weird', 'welders', 'went', 'wept', 'were', 'western', 'wetsuit', 'whale', 'when', 'whipped', 'whole', 'wickets', 'width', 'wield', 'wife', 'wiggle', 'wildly', 'winter', 'wipeout', 'wiring', 'wise', 'withdrawn', 'wives', 'wizard', 'wobbly', 'woes', 'woken', 'wolf', 'womanly', 'wonders', 'woozy', 'worry', 'wounded', 'woven', 'wrap', 'wrist', 'wrong', 'yacht', 'yahoo', 'yanks', 'yard', 'yawning', 'yearbook', 'yellow', 'yesterday', 'yeti', 'yields', 'yodel', 'yoga', 'younger', 'yoyo', 'zapped', 'zeal', 'zebra', 'zero', 'zesty', 'zigzags', 'zinger', 'zippers', 'zodiac', 'zombie', 'zones', 'zoom']
|
|
4
|
+
TRUNC_WORDS = [w[:3] for w in WORDS]
|
|
5
|
+
N = len(WORDS)
|
|
6
|
+
|
|
7
|
+
def crc32(s: str) -> int:
|
|
8
|
+
return binascii.crc32(s.encode('utf-8')) & 0xffffffff
|
|
9
|
+
|
|
10
|
+
def get_checksum_index(words_list: list[str], prefix_len: int) -> int:
|
|
11
|
+
trimmed_words = "".join([w[:prefix_len] for w in words_list])
|
|
12
|
+
checksum = crc32(trimmed_words)
|
|
13
|
+
return checksum % len(words_list)
|
|
14
|
+
|
|
15
|
+
def swap_endian_4byte(s: str) -> str:
|
|
16
|
+
if len(s) != 8:
|
|
17
|
+
raise ValueError(f"Invalid input length: {len(s)}")
|
|
18
|
+
return s[6:8] + s[4:6] + s[2:4] + s[0:2]
|
|
19
|
+
|
|
20
|
+
def encode(seed_hex: str) -> str:
|
|
21
|
+
# Turns seed's hex to 13-words mnemonic phrase
|
|
22
|
+
out = []
|
|
23
|
+
# Seed must be hex string
|
|
24
|
+
str_copy = seed_hex
|
|
25
|
+
# Swap endian for every 8 characters (4 bytes)
|
|
26
|
+
for j in range(0, len(str_copy), 8):
|
|
27
|
+
part = str_copy[j:j+8]
|
|
28
|
+
str_copy = str_copy[:j] + swap_endian_4byte(part) + str_copy[j+8:]
|
|
29
|
+
|
|
30
|
+
for i in range(0, len(str_copy), 8):
|
|
31
|
+
x = int(str_copy[i:i+8], 16)
|
|
32
|
+
w1 = x % N
|
|
33
|
+
w2 = (x // N + w1) % N
|
|
34
|
+
w3 = ((x // N) // N + w2) % N
|
|
35
|
+
out.extend([WORDS[w1], WORDS[w2], WORDS[w3]])
|
|
36
|
+
|
|
37
|
+
# Checksum word
|
|
38
|
+
out.append(out[get_checksum_index(out, 3)])
|
|
39
|
+
return " ".join(out)
|
|
40
|
+
|
|
41
|
+
def decode(mnemonic: str) -> str:
|
|
42
|
+
# Turns 13-words mnemonic phrase to seed's hex
|
|
43
|
+
wlist = mnemonic.strip().split()
|
|
44
|
+
if len(wlist) < 12:
|
|
45
|
+
raise ValueError("Invalid number of words in mnemonic. Expected 13 words, got less.")
|
|
46
|
+
|
|
47
|
+
# Checksum word
|
|
48
|
+
checksum_word = wlist.pop()
|
|
49
|
+
|
|
50
|
+
out = ""
|
|
51
|
+
for i in range(0, len(wlist), 3):
|
|
52
|
+
w1_val = wlist[i]
|
|
53
|
+
w2_val = wlist[i+1]
|
|
54
|
+
w3_val = wlist[i+2]
|
|
55
|
+
|
|
56
|
+
w1 = TRUNC_WORDS.index(w1_val[:3]) if w1_val[:3] in TRUNC_WORDS else -1
|
|
57
|
+
w2 = TRUNC_WORDS.index(w2_val[:3]) if w2_val[:3] in TRUNC_WORDS else -1
|
|
58
|
+
w3 = TRUNC_WORDS.index(w3_val[:3]) if w3_val[:3] in TRUNC_WORDS else -1
|
|
59
|
+
|
|
60
|
+
if w1 == -1 or w2 == -1 or w3 == -1:
|
|
61
|
+
raise ValueError("Invalid word in mnemonic")
|
|
62
|
+
|
|
63
|
+
x = w1 + N * ((N - w1 + w2) % N) + N * N * ((N - w2 + w3) % N)
|
|
64
|
+
if x % N != w1:
|
|
65
|
+
raise ValueError("Couldn't decode mnemonic")
|
|
66
|
+
|
|
67
|
+
hex_val = f"{x:08x}"
|
|
68
|
+
out += swap_endian_4byte(hex_val)
|
|
69
|
+
|
|
70
|
+
# Verify checksum
|
|
71
|
+
index = get_checksum_index(wlist, 3)
|
|
72
|
+
expected_checksum_word = wlist[index]
|
|
73
|
+
if expected_checksum_word[:3] != checksum_word[:3]:
|
|
74
|
+
raise ValueError("Invalid checksum last word in mnemonic")
|
|
75
|
+
|
|
76
|
+
return out
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import random
|
|
3
|
+
import requests
|
|
4
|
+
import binascii
|
|
5
|
+
import urllib3
|
|
6
|
+
from typing import List, Dict, Any, Union, Optional
|
|
7
|
+
from urllib.parse import urlparse
|
|
8
|
+
|
|
9
|
+
# Suppress self-signed certificate warnings for storage node connections
|
|
10
|
+
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
|
11
|
+
|
|
12
|
+
# Seeds list
|
|
13
|
+
SEEDS = [
|
|
14
|
+
{"url": "seed1.getsession.org"},
|
|
15
|
+
{"url": "seed2.getsession.org"},
|
|
16
|
+
{"url": "seed3.getsession.org"}
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
class NetworkError(Exception):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
class SnodeFetchError(NetworkError):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
class SnodeRPCError(NetworkError):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
class SessionNetwork:
|
|
29
|
+
def __init__(self, proxy: Optional[str] = None):
|
|
30
|
+
"""
|
|
31
|
+
proxy: proxy URL (e.g. 'socks5h://user:pass@host:port' or 'http://host:port')
|
|
32
|
+
"""
|
|
33
|
+
self.proxy = proxy
|
|
34
|
+
self.session = requests.Session()
|
|
35
|
+
if proxy:
|
|
36
|
+
# Normalize socks5 to socks5h for remote DNS resolution
|
|
37
|
+
if proxy.startswith("socks5://"):
|
|
38
|
+
proxy = "socks5h://" + proxy[len("socks5://"):]
|
|
39
|
+
self.session.proxies = {
|
|
40
|
+
"http": proxy,
|
|
41
|
+
"https": proxy
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
def request(self, method: str, url: str, json_data: dict, headers: Optional[dict] = None, timeout: int = 10) -> dict:
|
|
45
|
+
default_headers = {
|
|
46
|
+
"User-Agent": "WhatsApp",
|
|
47
|
+
"Accept-Language": "en-us",
|
|
48
|
+
"Content-Type": "application/json"
|
|
49
|
+
}
|
|
50
|
+
if headers:
|
|
51
|
+
default_headers.update(headers)
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
# We disable SSL certificate verification for storage node RPCs because
|
|
55
|
+
# storage nodes use self-signed certificates in the Session network.
|
|
56
|
+
response = self.session.post(
|
|
57
|
+
url,
|
|
58
|
+
json=json_data,
|
|
59
|
+
headers=default_headers,
|
|
60
|
+
timeout=timeout,
|
|
61
|
+
verify=False
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
if response.status_code == 421:
|
|
65
|
+
raise SnodeRPCError("421 handled. Retry this request with a new snode.")
|
|
66
|
+
|
|
67
|
+
if not response.ok:
|
|
68
|
+
raise SnodeRPCError(f"HTTP Error {response.status_code}: {response.text}")
|
|
69
|
+
|
|
70
|
+
return response.json()
|
|
71
|
+
except requests.RequestException as e:
|
|
72
|
+
raise SnodeFetchError(f"Network request failed: {e}")
|
|
73
|
+
|
|
74
|
+
def get_snodes_from_seeds(self) -> List[Dict[str, Any]]:
|
|
75
|
+
# Randomize seeds list
|
|
76
|
+
seeds_pool = list(SEEDS)
|
|
77
|
+
random.shuffle(seeds_pool)
|
|
78
|
+
|
|
79
|
+
for seed in seeds_pool:
|
|
80
|
+
url = f"http://{seed['url']}/json_rpc"
|
|
81
|
+
body = {
|
|
82
|
+
"jsonrpc": "2.0",
|
|
83
|
+
"id": 0,
|
|
84
|
+
"method": "get_n_service_nodes",
|
|
85
|
+
"params": {
|
|
86
|
+
"fields": {
|
|
87
|
+
"public_ip": True,
|
|
88
|
+
"storage_port": True,
|
|
89
|
+
"pubkey_x25519": True,
|
|
90
|
+
"pubkey_ed25519": True
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
try:
|
|
95
|
+
res = self.request("POST", url, body, timeout=8)
|
|
96
|
+
if "result" in res and "service_node_states" in res["result"]:
|
|
97
|
+
snodes = res["result"]["service_node_states"]
|
|
98
|
+
# Filter out nodes with invalid IP
|
|
99
|
+
filtered_snodes = [
|
|
100
|
+
s for s in snodes
|
|
101
|
+
if s.get("public_ip") and s["public_ip"] != "0.0.0.0"
|
|
102
|
+
]
|
|
103
|
+
if filtered_snodes:
|
|
104
|
+
return filtered_snodes
|
|
105
|
+
except Exception as e:
|
|
106
|
+
# Fallback to next seed
|
|
107
|
+
continue
|
|
108
|
+
|
|
109
|
+
raise SnodeFetchError("Failed to fetch service nodes from all seed nodes.")
|
|
110
|
+
|
|
111
|
+
def snode_batch_request(
|
|
112
|
+
self,
|
|
113
|
+
snode: Dict[str, Any],
|
|
114
|
+
requests_list: List[Dict[str, Any]],
|
|
115
|
+
timeout: int = 10,
|
|
116
|
+
method: str = "batch"
|
|
117
|
+
) -> List[Dict[str, Any]]:
|
|
118
|
+
"""
|
|
119
|
+
snode: dict containing public_ip, storage_port
|
|
120
|
+
requests_list: list of subrequests, e.g. [{"method": "get_swarm", "params": {...}}]
|
|
121
|
+
"""
|
|
122
|
+
url = f"https://{snode['public_ip']}:{snode['storage_port']}/storage_rpc/v1"
|
|
123
|
+
body = {
|
|
124
|
+
"jsonrpc": "2.0",
|
|
125
|
+
"method": method,
|
|
126
|
+
"params": {
|
|
127
|
+
"requests": requests_list
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
res = self.request("POST", url, body, timeout=timeout)
|
|
131
|
+
if "results" not in res:
|
|
132
|
+
raise SnodeRPCError(f"Invalid snode RPC response (no 'results'): {res}")
|
|
133
|
+
|
|
134
|
+
return res["results"]
|
|
135
|
+
|
|
136
|
+
def upload_attachment(self, data: bytes, timeout: int = 30) -> dict:
|
|
137
|
+
url = "http://filev2.getsession.org/file"
|
|
138
|
+
try:
|
|
139
|
+
response = self.session.post(
|
|
140
|
+
url,
|
|
141
|
+
data=data,
|
|
142
|
+
headers={"User-Agent": "WhatsApp"},
|
|
143
|
+
timeout=timeout
|
|
144
|
+
)
|
|
145
|
+
if not response.ok:
|
|
146
|
+
raise NetworkError(f"Upload failed with status code {response.status_code}")
|
|
147
|
+
res_json = response.json()
|
|
148
|
+
file_id = res_json["id"]
|
|
149
|
+
return {
|
|
150
|
+
"id": int(file_id),
|
|
151
|
+
"url": f"http://filev2.getsession.org/file/{file_id}"
|
|
152
|
+
}
|
|
153
|
+
except Exception as e:
|
|
154
|
+
raise NetworkError(f"Failed to upload attachment: {e}")
|
|
155
|
+
|
|
156
|
+
def download_attachment(self, file_id: Union[int, str], timeout: int = 30) -> bytes:
|
|
157
|
+
url = f"http://filev2.getsession.org/file/{file_id}"
|
|
158
|
+
try:
|
|
159
|
+
response = self.session.get(
|
|
160
|
+
url,
|
|
161
|
+
headers={"User-Agent": "WhatsApp"},
|
|
162
|
+
timeout=timeout
|
|
163
|
+
)
|
|
164
|
+
if not response.ok:
|
|
165
|
+
raise NetworkError(f"Download failed with status code {response.status_code}")
|
|
166
|
+
return response.content
|
|
167
|
+
except Exception as e:
|
|
168
|
+
raise NetworkError(f"Failed to download attachment: {e}")
|
|
169
|
+
|
|
170
|
+
def sogs_request(
|
|
171
|
+
self,
|
|
172
|
+
host: str,
|
|
173
|
+
endpoint: str,
|
|
174
|
+
method: str,
|
|
175
|
+
body: Optional[Union[str, bytes]] = None,
|
|
176
|
+
headers: Optional[dict] = None,
|
|
177
|
+
timeout: int = 15
|
|
178
|
+
) -> dict:
|
|
179
|
+
url = host + endpoint
|
|
180
|
+
default_headers = {"User-Agent": "WhatsApp"}
|
|
181
|
+
if headers:
|
|
182
|
+
default_headers.update(headers)
|
|
183
|
+
try:
|
|
184
|
+
res = self.session.request(
|
|
185
|
+
method=method,
|
|
186
|
+
url=url,
|
|
187
|
+
data=body,
|
|
188
|
+
headers=default_headers,
|
|
189
|
+
timeout=timeout
|
|
190
|
+
)
|
|
191
|
+
return res.json()
|
|
192
|
+
except Exception as e:
|
|
193
|
+
raise NetworkError(f"Failed SOGS request: {e}")
|