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.
@@ -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}")