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/client.py
ADDED
|
@@ -0,0 +1,729 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import time
|
|
3
|
+
import random
|
|
4
|
+
import binascii
|
|
5
|
+
import uuid
|
|
6
|
+
import hashlib
|
|
7
|
+
from typing import List, Dict, Any, Union, Optional
|
|
8
|
+
import nacl.signing
|
|
9
|
+
import nacl.public
|
|
10
|
+
import nacl.exceptions
|
|
11
|
+
from cryptography.hazmat.primitives import hashes
|
|
12
|
+
|
|
13
|
+
from .network import SessionNetwork
|
|
14
|
+
from .crypto import (
|
|
15
|
+
SessionKeys,
|
|
16
|
+
add_message_padding,
|
|
17
|
+
remove_message_padding,
|
|
18
|
+
encrypt_using_session_protocol,
|
|
19
|
+
decrypt_with_session_protocol,
|
|
20
|
+
wrap_envelope,
|
|
21
|
+
encrypt_attachment,
|
|
22
|
+
decrypt_attachment,
|
|
23
|
+
remove_prefix_if_needed
|
|
24
|
+
)
|
|
25
|
+
from .protobuf.signalservice_pb2 import Content, Envelope, WebSocketMessage, WebSocketRequestMessage
|
|
26
|
+
from .mnemonic import decode as decode_mnemonic, encode as encode_mnemonic
|
|
27
|
+
|
|
28
|
+
class Session:
|
|
29
|
+
def __init__(self, proxy: Optional[str] = None):
|
|
30
|
+
self.network = SessionNetwork(proxy=proxy)
|
|
31
|
+
self.mnemonic: Optional[str] = None
|
|
32
|
+
self.keys: Optional[SessionKeys] = None
|
|
33
|
+
self.session_id: Optional[str] = None
|
|
34
|
+
self.display_name: Optional[str] = None
|
|
35
|
+
self.avatar: Optional[dict] = None
|
|
36
|
+
self.snodes: Optional[list] = None
|
|
37
|
+
self.our_swarms: Optional[list] = None
|
|
38
|
+
self.our_swarm: Optional[dict] = None
|
|
39
|
+
self.last_hashes: Dict[int, str] = {}
|
|
40
|
+
self.swarms_cache: Dict[str, list] = {}
|
|
41
|
+
|
|
42
|
+
@staticmethod
|
|
43
|
+
def generate_mnemonic() -> str:
|
|
44
|
+
"""
|
|
45
|
+
Generates a new random 13-word mnemonic phrase for creating a new account.
|
|
46
|
+
"""
|
|
47
|
+
import os
|
|
48
|
+
import binascii
|
|
49
|
+
from .mnemonic import encode as encode_mnemonic
|
|
50
|
+
seed_hex = binascii.hexlify(os.urandom(16)).decode('utf-8')
|
|
51
|
+
return encode_mnemonic(seed_hex)
|
|
52
|
+
|
|
53
|
+
def close(self):
|
|
54
|
+
"""
|
|
55
|
+
Closes HTTP session connection pool.
|
|
56
|
+
"""
|
|
57
|
+
if hasattr(self, 'network') and self.network.session:
|
|
58
|
+
self.network.session.close()
|
|
59
|
+
|
|
60
|
+
def __enter__(self):
|
|
61
|
+
return self
|
|
62
|
+
|
|
63
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
64
|
+
self.close()
|
|
65
|
+
|
|
66
|
+
def set_mnemonic(self, mnemonic: str, display_name: Optional[str] = None):
|
|
67
|
+
"""
|
|
68
|
+
Sets mnemonic for this instance and derives keys.
|
|
69
|
+
"""
|
|
70
|
+
self.mnemonic = mnemonic
|
|
71
|
+
seed_hex = decode_mnemonic(mnemonic)
|
|
72
|
+
self.keys = SessionKeys(seed_hex)
|
|
73
|
+
# Session ID is X25519 public key (starts with 05)
|
|
74
|
+
self.session_id = self.keys.x25519.public_key.hex()
|
|
75
|
+
|
|
76
|
+
if display_name:
|
|
77
|
+
self.display_name = display_name
|
|
78
|
+
|
|
79
|
+
def get_session_id(self) -> str:
|
|
80
|
+
if not self.session_id:
|
|
81
|
+
raise ValueError("Session client not initialized with mnemonic.")
|
|
82
|
+
return self.session_id
|
|
83
|
+
|
|
84
|
+
def get_snodes(self) -> list:
|
|
85
|
+
if not self.snodes:
|
|
86
|
+
self.snodes = self.network.get_snodes_from_seeds()
|
|
87
|
+
return self.snodes
|
|
88
|
+
|
|
89
|
+
def get_swarms_for(self, session_id: str) -> list:
|
|
90
|
+
if session_id in self.swarms_cache:
|
|
91
|
+
return self.swarms_cache[session_id]
|
|
92
|
+
|
|
93
|
+
snodes = self.get_snodes()
|
|
94
|
+
if not snodes:
|
|
95
|
+
raise ValueError("No service nodes available.")
|
|
96
|
+
|
|
97
|
+
# Try a few snodes in case of 421 errors
|
|
98
|
+
for _ in range(5):
|
|
99
|
+
snode = random.choice(snodes)
|
|
100
|
+
try:
|
|
101
|
+
results = self.network.snode_batch_request(
|
|
102
|
+
snode=snode,
|
|
103
|
+
requests_list=[{
|
|
104
|
+
"method": "get_swarm",
|
|
105
|
+
"params": {"pubkey": session_id}
|
|
106
|
+
}]
|
|
107
|
+
)
|
|
108
|
+
if results and results[0].get("code") == 200:
|
|
109
|
+
swarms = results[0]["body"]["snodes"]
|
|
110
|
+
self.swarms_cache[session_id] = swarms
|
|
111
|
+
return swarms
|
|
112
|
+
except Exception:
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
raise ValueError("Failed to get swarms for " + session_id)
|
|
116
|
+
|
|
117
|
+
def get_our_swarm(self) -> dict:
|
|
118
|
+
session_id = self.get_session_id()
|
|
119
|
+
if self.our_swarm:
|
|
120
|
+
return self.our_swarm
|
|
121
|
+
|
|
122
|
+
self.our_swarms = self.get_swarms_for(session_id)
|
|
123
|
+
if not self.our_swarms:
|
|
124
|
+
raise ValueError("No swarms found for this account.")
|
|
125
|
+
|
|
126
|
+
self.our_swarm = random.choice(self.our_swarms)
|
|
127
|
+
return self.our_swarm
|
|
128
|
+
|
|
129
|
+
def _store_message(self, recipient: str, data64: str, timestamp: int, namespace: int = 0) -> str:
|
|
130
|
+
"""
|
|
131
|
+
Sends store request to recipient's swarm with retries/failover.
|
|
132
|
+
"""
|
|
133
|
+
swarms = list(self.get_swarms_for(recipient))
|
|
134
|
+
while swarms:
|
|
135
|
+
swarm = random.choice(swarms)
|
|
136
|
+
try:
|
|
137
|
+
res = self.network.snode_batch_request(
|
|
138
|
+
snode={
|
|
139
|
+
"public_ip": swarm["ip"],
|
|
140
|
+
"storage_port": int(swarm["port"])
|
|
141
|
+
},
|
|
142
|
+
requests_list=[{
|
|
143
|
+
"method": "store",
|
|
144
|
+
"params": {
|
|
145
|
+
"pubkey": recipient,
|
|
146
|
+
"ttl": 14 * 24 * 60 * 60 * 1000,
|
|
147
|
+
"timestamp": timestamp,
|
|
148
|
+
"data": data64,
|
|
149
|
+
"namespace": namespace
|
|
150
|
+
}
|
|
151
|
+
}],
|
|
152
|
+
timeout=5
|
|
153
|
+
)
|
|
154
|
+
if res and res[0].get("code") == 200:
|
|
155
|
+
return res[0]["body"]["hash"]
|
|
156
|
+
except Exception:
|
|
157
|
+
swarms.remove(swarm)
|
|
158
|
+
if not swarms:
|
|
159
|
+
raise
|
|
160
|
+
raise ValueError("Failed to store message (empty results)")
|
|
161
|
+
|
|
162
|
+
def send_message(
|
|
163
|
+
self,
|
|
164
|
+
to: str,
|
|
165
|
+
text: Optional[str] = None,
|
|
166
|
+
attachments: Optional[List[dict]] = None
|
|
167
|
+
) -> Dict[str, Any]:
|
|
168
|
+
"""
|
|
169
|
+
Sends a message to a recipient.
|
|
170
|
+
to: Recipient Session ID (starts with 05)
|
|
171
|
+
text: Message text
|
|
172
|
+
attachments: List of dicts, e.g. [{'data': b'...', 'name': 'file.jpg', 'content_type': 'image/jpeg'}]
|
|
173
|
+
"""
|
|
174
|
+
if not self.keys or not self.session_id:
|
|
175
|
+
raise ValueError("Session client not initialized.")
|
|
176
|
+
|
|
177
|
+
if len(to) != 66 or not to.startswith("05"):
|
|
178
|
+
raise ValueError("Invalid recipient Session ID.")
|
|
179
|
+
|
|
180
|
+
attachment_pointers = []
|
|
181
|
+
if attachments:
|
|
182
|
+
for att in attachments:
|
|
183
|
+
enc = encrypt_attachment(att["data"])
|
|
184
|
+
# Upload ciphertext
|
|
185
|
+
res = self.network.upload_attachment(enc["ciphertext"])
|
|
186
|
+
attachment_pointers.append({
|
|
187
|
+
"id": res["id"],
|
|
188
|
+
"url": res["url"],
|
|
189
|
+
"key": enc["key"],
|
|
190
|
+
"digest": enc["digest"],
|
|
191
|
+
"fileName": att.get("name", "file"),
|
|
192
|
+
"contentType": att.get("content_type", "application/octet-stream"),
|
|
193
|
+
"size": len(att["data"])
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
timestamp = int(time.time() * 1000)
|
|
197
|
+
|
|
198
|
+
# Build visible message for recipient
|
|
199
|
+
msg_content = Content()
|
|
200
|
+
msg_content.dataMessage.body = text or ""
|
|
201
|
+
msg_content.dataMessage.timestamp = timestamp
|
|
202
|
+
|
|
203
|
+
if self.display_name:
|
|
204
|
+
msg_content.dataMessage.profile.displayName = self.display_name
|
|
205
|
+
|
|
206
|
+
# Add attachments
|
|
207
|
+
for att_ptr in attachment_pointers:
|
|
208
|
+
ptr = msg_content.dataMessage.attachments.add()
|
|
209
|
+
ptr.id = att_ptr["id"]
|
|
210
|
+
ptr.url = att_ptr["url"]
|
|
211
|
+
ptr.key = att_ptr["key"]
|
|
212
|
+
ptr.digest = att_ptr["digest"]
|
|
213
|
+
ptr.fileName = att_ptr["fileName"]
|
|
214
|
+
ptr.contentType = att_ptr["contentType"]
|
|
215
|
+
ptr.size = att_ptr["size"]
|
|
216
|
+
|
|
217
|
+
# Build sync message for self
|
|
218
|
+
sync_content = Content()
|
|
219
|
+
sync_content.dataMessage.body = text or ""
|
|
220
|
+
sync_content.dataMessage.timestamp = timestamp
|
|
221
|
+
sync_content.dataMessage.syncTarget = to
|
|
222
|
+
|
|
223
|
+
for att_ptr in attachment_pointers:
|
|
224
|
+
ptr = sync_content.dataMessage.attachments.add()
|
|
225
|
+
ptr.id = att_ptr["id"]
|
|
226
|
+
ptr.url = att_ptr["url"]
|
|
227
|
+
ptr.key = att_ptr["key"]
|
|
228
|
+
ptr.digest = att_ptr["digest"]
|
|
229
|
+
ptr.fileName = att_ptr["fileName"]
|
|
230
|
+
ptr.contentType = att_ptr["contentType"]
|
|
231
|
+
ptr.size = att_ptr["size"]
|
|
232
|
+
|
|
233
|
+
# Encrypt messages
|
|
234
|
+
raw_msg_bytes = msg_content.SerializeToString()
|
|
235
|
+
padded_raw = add_message_padding(raw_msg_bytes)
|
|
236
|
+
ciphertext = encrypt_using_session_protocol(self.keys, to, padded_raw)
|
|
237
|
+
|
|
238
|
+
raw_sync_bytes = sync_content.SerializeToString()
|
|
239
|
+
padded_sync = add_message_padding(raw_sync_bytes)
|
|
240
|
+
sync_ciphertext = encrypt_using_session_protocol(self.keys, self.session_id, padded_sync)
|
|
241
|
+
|
|
242
|
+
# Build envelopes
|
|
243
|
+
envelope = Envelope(
|
|
244
|
+
type=Envelope.Type.SESSION_MESSAGE,
|
|
245
|
+
timestamp=timestamp,
|
|
246
|
+
content=ciphertext
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
sync_envelope = Envelope(
|
|
250
|
+
type=Envelope.Type.SESSION_MESSAGE,
|
|
251
|
+
timestamp=timestamp,
|
|
252
|
+
content=sync_ciphertext
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
# Wrap envelopes inside WebSocket messages
|
|
256
|
+
websocket_bytes = wrap_envelope(envelope.SerializeToString())
|
|
257
|
+
sync_websocket_bytes = wrap_envelope(sync_envelope.SerializeToString())
|
|
258
|
+
|
|
259
|
+
data64 = binascii.b2a_base64(websocket_bytes, newline=False).decode('utf-8')
|
|
260
|
+
sync_data64 = binascii.b2a_base64(sync_websocket_bytes, newline=False).decode('utf-8')
|
|
261
|
+
|
|
262
|
+
# Store recipient message & sync copy
|
|
263
|
+
message_hash = self._store_message(to, data64, timestamp, namespace=0)
|
|
264
|
+
sync_message_hash = self._store_message(self.session_id, sync_data64, timestamp, namespace=0)
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
"messageHash": message_hash,
|
|
268
|
+
"syncMessageHash": sync_message_hash,
|
|
269
|
+
"timestamp": timestamp
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
def get_file(self, att_ptr: dict) -> bytes:
|
|
273
|
+
"""
|
|
274
|
+
Downloads and decrypts attachment from Session file server using the attachment pointer dict.
|
|
275
|
+
att_ptr: dict representing attachment pointer, containing:
|
|
276
|
+
'id', 'key', 'digest', 'size'
|
|
277
|
+
"""
|
|
278
|
+
enc_data = self.network.download_attachment(att_ptr["id"])
|
|
279
|
+
return decrypt_attachment(
|
|
280
|
+
data=enc_data,
|
|
281
|
+
key=att_ptr["key"],
|
|
282
|
+
digest=att_ptr["digest"],
|
|
283
|
+
size=att_ptr.get("size")
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
# --- Message Reactions ---
|
|
287
|
+
|
|
288
|
+
def add_reaction(self, message_timestamp: int, message_author: str, emoji: str):
|
|
289
|
+
"""
|
|
290
|
+
Adds emoji reaction to specific message.
|
|
291
|
+
"""
|
|
292
|
+
self._send_reaction_message(message_timestamp, message_author, emoji, action=0)
|
|
293
|
+
|
|
294
|
+
def remove_reaction(self, message_timestamp: int, message_author: str, emoji: str):
|
|
295
|
+
"""
|
|
296
|
+
Removes emoji reaction from specific message.
|
|
297
|
+
"""
|
|
298
|
+
self._send_reaction_message(message_timestamp, message_author, emoji, action=1)
|
|
299
|
+
|
|
300
|
+
def _send_reaction_message(self, message_timestamp: int, message_author: str, emoji: str, action: int):
|
|
301
|
+
if not self.keys or not self.session_id:
|
|
302
|
+
raise ValueError("Session client not initialized.")
|
|
303
|
+
|
|
304
|
+
timestamp = int(time.time() * 1000)
|
|
305
|
+
|
|
306
|
+
# Build visible reaction content
|
|
307
|
+
content = Content()
|
|
308
|
+
content.dataMessage.timestamp = timestamp
|
|
309
|
+
content.dataMessage.reaction.id = message_timestamp
|
|
310
|
+
content.dataMessage.reaction.action = action
|
|
311
|
+
content.dataMessage.reaction.author = message_author
|
|
312
|
+
content.dataMessage.reaction.emoji = emoji
|
|
313
|
+
if self.display_name:
|
|
314
|
+
content.dataMessage.profile.displayName = self.display_name
|
|
315
|
+
|
|
316
|
+
# Build sync copy
|
|
317
|
+
sync_content = Content()
|
|
318
|
+
sync_content.dataMessage.timestamp = timestamp
|
|
319
|
+
sync_content.dataMessage.reaction.id = message_timestamp
|
|
320
|
+
sync_content.dataMessage.reaction.action = action
|
|
321
|
+
sync_content.dataMessage.reaction.author = message_author
|
|
322
|
+
sync_content.dataMessage.reaction.emoji = emoji
|
|
323
|
+
sync_content.dataMessage.syncTarget = message_author
|
|
324
|
+
|
|
325
|
+
# Encrypt and wrap
|
|
326
|
+
raw_msg_bytes = content.SerializeToString()
|
|
327
|
+
padded_raw = add_message_padding(raw_msg_bytes)
|
|
328
|
+
ciphertext = encrypt_using_session_protocol(self.keys, message_author, padded_raw)
|
|
329
|
+
|
|
330
|
+
raw_sync_bytes = sync_content.SerializeToString()
|
|
331
|
+
padded_sync = add_message_padding(raw_sync_bytes)
|
|
332
|
+
sync_ciphertext = encrypt_using_session_protocol(self.keys, self.session_id, padded_sync)
|
|
333
|
+
|
|
334
|
+
envelope = Envelope(
|
|
335
|
+
type=Envelope.Type.SESSION_MESSAGE,
|
|
336
|
+
timestamp=timestamp,
|
|
337
|
+
content=ciphertext
|
|
338
|
+
)
|
|
339
|
+
sync_envelope = Envelope(
|
|
340
|
+
type=Envelope.Type.SESSION_MESSAGE,
|
|
341
|
+
timestamp=timestamp,
|
|
342
|
+
content=sync_ciphertext
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
data64 = binascii.b2a_base64(wrap_envelope(envelope.SerializeToString()), newline=False).decode('utf-8')
|
|
346
|
+
sync_data64 = binascii.b2a_base64(wrap_envelope(sync_envelope.SerializeToString()), newline=False).decode('utf-8')
|
|
347
|
+
|
|
348
|
+
self._store_message(message_author, data64, timestamp, namespace=0)
|
|
349
|
+
self._store_message(self.session_id, sync_data64, timestamp, namespace=0)
|
|
350
|
+
|
|
351
|
+
# --- Typing Indicators ---
|
|
352
|
+
|
|
353
|
+
def show_typing_indicator(self, conversation: str):
|
|
354
|
+
"""
|
|
355
|
+
Sends typing started event.
|
|
356
|
+
"""
|
|
357
|
+
self._update_typing_indicator(conversation, is_typing=True)
|
|
358
|
+
|
|
359
|
+
def hide_typing_indicator(self, conversation: str):
|
|
360
|
+
"""
|
|
361
|
+
Sends typing stopped event.
|
|
362
|
+
"""
|
|
363
|
+
self._update_typing_indicator(conversation, is_typing=False)
|
|
364
|
+
|
|
365
|
+
def _update_typing_indicator(self, conversation: str, is_typing: bool):
|
|
366
|
+
if not self.keys or not self.session_id:
|
|
367
|
+
raise ValueError("Session client not initialized.")
|
|
368
|
+
|
|
369
|
+
timestamp = int(time.time() * 1000)
|
|
370
|
+
content = Content()
|
|
371
|
+
content.typingMessage.timestamp = timestamp
|
|
372
|
+
content.typingMessage.action = 0 if is_typing else 1 # STARTED = 0, STOPPED = 1
|
|
373
|
+
|
|
374
|
+
raw_msg_bytes = content.SerializeToString()
|
|
375
|
+
padded_raw = add_message_padding(raw_msg_bytes)
|
|
376
|
+
ciphertext = encrypt_using_session_protocol(self.keys, conversation, padded_raw)
|
|
377
|
+
|
|
378
|
+
envelope = Envelope(
|
|
379
|
+
type=Envelope.Type.SESSION_MESSAGE,
|
|
380
|
+
timestamp=timestamp,
|
|
381
|
+
content=ciphertext
|
|
382
|
+
)
|
|
383
|
+
data64 = binascii.b2a_base64(wrap_envelope(envelope.SerializeToString()), newline=False).decode('utf-8')
|
|
384
|
+
self._store_message(conversation, data64, timestamp, namespace=0)
|
|
385
|
+
|
|
386
|
+
# --- Read Receipts ---
|
|
387
|
+
|
|
388
|
+
def mark_messages_as_read(self, conversation: str, timestamps: List[int], read_at: Optional[int] = None):
|
|
389
|
+
"""
|
|
390
|
+
Marks messages with specified timestamps as read.
|
|
391
|
+
"""
|
|
392
|
+
if not self.keys or not self.session_id:
|
|
393
|
+
raise ValueError("Session client not initialized.")
|
|
394
|
+
|
|
395
|
+
timestamp = read_at or int(time.time() * 1000)
|
|
396
|
+
content = Content()
|
|
397
|
+
content.receiptMessage.type = 1 # READ = 1
|
|
398
|
+
content.receiptMessage.timestamp.extend(timestamps)
|
|
399
|
+
|
|
400
|
+
raw_msg_bytes = content.SerializeToString()
|
|
401
|
+
padded_raw = add_message_padding(raw_msg_bytes)
|
|
402
|
+
ciphertext = encrypt_using_session_protocol(self.keys, conversation, padded_raw)
|
|
403
|
+
|
|
404
|
+
envelope = Envelope(
|
|
405
|
+
type=Envelope.Type.SESSION_MESSAGE,
|
|
406
|
+
timestamp=timestamp,
|
|
407
|
+
content=ciphertext
|
|
408
|
+
)
|
|
409
|
+
data64 = binascii.b2a_base64(wrap_envelope(envelope.SerializeToString()), newline=False).decode('utf-8')
|
|
410
|
+
self._store_message(conversation, data64, timestamp, namespace=0)
|
|
411
|
+
|
|
412
|
+
# --- Message Deletion / Unsends ---
|
|
413
|
+
|
|
414
|
+
def delete_message(self, conversation: str, timestamp: int, hash_val: str):
|
|
415
|
+
"""
|
|
416
|
+
Deletes a single sent message locally and sends an unsend command.
|
|
417
|
+
"""
|
|
418
|
+
self.delete_messages([{"to": conversation, "timestamp": timestamp, "hash": hash_val}])
|
|
419
|
+
|
|
420
|
+
def delete_messages(self, messages: List[dict]):
|
|
421
|
+
"""
|
|
422
|
+
Deletes multiple sent messages.
|
|
423
|
+
messages: List of dicts: [{'to': '...', 'timestamp': ..., 'hash': '...'}]
|
|
424
|
+
"""
|
|
425
|
+
if not self.keys or not self.session_id:
|
|
426
|
+
raise ValueError("Session client not initialized.")
|
|
427
|
+
|
|
428
|
+
hashes = [m["hash"] for m in messages]
|
|
429
|
+
|
|
430
|
+
# 1. Permanently delete from our own swarm
|
|
431
|
+
verification_data = f"delete{''.join(hashes)}"
|
|
432
|
+
signature = self.keys.signing_key.sign(verification_data.encode('utf-8')).signature
|
|
433
|
+
signature_b64 = binascii.b2a_base64(signature, newline=False).decode('utf-8')
|
|
434
|
+
|
|
435
|
+
our_swarms = list(self.get_swarms_for(self.session_id))
|
|
436
|
+
while our_swarms:
|
|
437
|
+
our_swarm = random.choice(our_swarms)
|
|
438
|
+
try:
|
|
439
|
+
res = self.network.snode_batch_request(
|
|
440
|
+
snode={
|
|
441
|
+
"public_ip": our_swarm["ip"],
|
|
442
|
+
"storage_port": int(our_swarm["port"])
|
|
443
|
+
},
|
|
444
|
+
requests_list=[{
|
|
445
|
+
"method": "delete",
|
|
446
|
+
"params": {
|
|
447
|
+
"messages": hashes,
|
|
448
|
+
"pubkey": self.session_id,
|
|
449
|
+
"pubkey_ed25519": self.keys.ed25519.public_key.hex(),
|
|
450
|
+
"signature": signature_b64
|
|
451
|
+
}
|
|
452
|
+
}],
|
|
453
|
+
timeout=5
|
|
454
|
+
)
|
|
455
|
+
if res and res[0].get("code") == 200:
|
|
456
|
+
break
|
|
457
|
+
except Exception:
|
|
458
|
+
our_swarms.remove(our_swarm)
|
|
459
|
+
if not our_swarms:
|
|
460
|
+
raise
|
|
461
|
+
|
|
462
|
+
# 2. Propagate Unsend command to recipients
|
|
463
|
+
timestamp = int(time.time() * 1000)
|
|
464
|
+
for m in messages:
|
|
465
|
+
unsend_content = Content()
|
|
466
|
+
unsend_content.unsendMessage.timestamp = m["timestamp"]
|
|
467
|
+
unsend_content.unsendMessage.author = self.session_id
|
|
468
|
+
|
|
469
|
+
raw_msg_bytes = unsend_content.SerializeToString()
|
|
470
|
+
padded_raw = add_message_padding(raw_msg_bytes)
|
|
471
|
+
ciphertext = encrypt_using_session_protocol(self.keys, m["to"], padded_raw)
|
|
472
|
+
sync_ciphertext = encrypt_using_session_protocol(self.keys, self.session_id, padded_raw)
|
|
473
|
+
|
|
474
|
+
envelope = Envelope(
|
|
475
|
+
type=Envelope.Type.SESSION_MESSAGE,
|
|
476
|
+
timestamp=timestamp,
|
|
477
|
+
content=ciphertext
|
|
478
|
+
)
|
|
479
|
+
sync_envelope = Envelope(
|
|
480
|
+
type=Envelope.Type.SESSION_MESSAGE,
|
|
481
|
+
timestamp=timestamp,
|
|
482
|
+
content=sync_ciphertext
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
data64 = binascii.b2a_base64(wrap_envelope(envelope.SerializeToString()), newline=False).decode('utf-8')
|
|
486
|
+
sync_data64 = binascii.b2a_base64(wrap_envelope(sync_envelope.SerializeToString()), newline=False).decode('utf-8')
|
|
487
|
+
|
|
488
|
+
self._store_message(m["to"], data64, timestamp, namespace=0)
|
|
489
|
+
self._store_message(self.session_id, sync_data64, timestamp, namespace=0)
|
|
490
|
+
|
|
491
|
+
# --- SOGS / Open Groups Blinding (Matching session.js empty blinding implementation) ---
|
|
492
|
+
|
|
493
|
+
def blind_session_id(self, server_pk: str) -> str:
|
|
494
|
+
raise NotImplementedError("Blinding is not implemented yet in the original session.js library.")
|
|
495
|
+
|
|
496
|
+
def sign_sogs_request(
|
|
497
|
+
self,
|
|
498
|
+
server_pk: str,
|
|
499
|
+
timestamp: int,
|
|
500
|
+
endpoint: str,
|
|
501
|
+
nonce: bytes,
|
|
502
|
+
method: str,
|
|
503
|
+
body: Optional[Union[str, bytes]] = None
|
|
504
|
+
) -> bytes:
|
|
505
|
+
"""
|
|
506
|
+
Signs HTTP requests to SOGS.
|
|
507
|
+
"""
|
|
508
|
+
if not self.keys or not self.session_id:
|
|
509
|
+
raise ValueError("Session client not initialized.")
|
|
510
|
+
|
|
511
|
+
pk = binascii.unhexlify(server_pk)
|
|
512
|
+
to_sign = pk + nonce + str(timestamp).encode('utf-8') + method.encode('utf-8') + endpoint.encode('utf-8')
|
|
513
|
+
if body:
|
|
514
|
+
body_bytes = body.encode('utf-8') if isinstance(body, str) else body
|
|
515
|
+
body_hashed = hashlib.blake2b(body_bytes, digest_size=64).digest()
|
|
516
|
+
to_sign += body_hashed
|
|
517
|
+
|
|
518
|
+
# SOGS signature is ed25519 sign
|
|
519
|
+
return self.keys.signing_key.sign(to_sign).signature
|
|
520
|
+
|
|
521
|
+
def send_sogs_request(
|
|
522
|
+
self,
|
|
523
|
+
host: str,
|
|
524
|
+
server_pk: str,
|
|
525
|
+
endpoint: str,
|
|
526
|
+
method: str,
|
|
527
|
+
body: Optional[Union[str, bytes]] = None
|
|
528
|
+
) -> dict:
|
|
529
|
+
"""
|
|
530
|
+
Sends requests to SOGS (Open Groups).
|
|
531
|
+
"""
|
|
532
|
+
if not self.keys or not self.session_id:
|
|
533
|
+
raise ValueError("Session client not initialized.")
|
|
534
|
+
|
|
535
|
+
nonce = os.urandom(16)
|
|
536
|
+
timestamp = int(time.time())
|
|
537
|
+
req_sig = self.sign_sogs_request(server_pk, timestamp, endpoint, nonce, method, body)
|
|
538
|
+
|
|
539
|
+
pubkey = "00" + self.keys.ed25519.public_key.hex()
|
|
540
|
+
|
|
541
|
+
content_type = None
|
|
542
|
+
if body is not None:
|
|
543
|
+
content_type = "application/octet-stream" if isinstance(body, bytes) else "application/json"
|
|
544
|
+
|
|
545
|
+
headers = {
|
|
546
|
+
"X-SOGS-Pubkey": pubkey,
|
|
547
|
+
"X-SOGS-Timestamp": str(timestamp),
|
|
548
|
+
"X-SOGS-Nonce": binascii.b2a_base64(nonce, newline=False).decode('utf-8'),
|
|
549
|
+
"X-SOGS-Signature": binascii.b2a_base64(req_sig, newline=False).decode('utf-8')
|
|
550
|
+
}
|
|
551
|
+
if content_type:
|
|
552
|
+
headers["Content-Type"] = content_type
|
|
553
|
+
|
|
554
|
+
return self.network.sogs_request(
|
|
555
|
+
host=host,
|
|
556
|
+
endpoint=endpoint,
|
|
557
|
+
method=method,
|
|
558
|
+
body=body,
|
|
559
|
+
headers=headers
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
def encode_sogs_message(self, text: str) -> dict:
|
|
563
|
+
raise NotImplementedError("SOGS message encoding is not implemented in the original session.js library.")
|
|
564
|
+
|
|
565
|
+
def get_mnemonic(self) -> Optional[str]:
|
|
566
|
+
"""
|
|
567
|
+
Returns mnemonic string of this instance.
|
|
568
|
+
"""
|
|
569
|
+
return self.mnemonic
|
|
570
|
+
|
|
571
|
+
def get_display_name(self) -> Optional[str]:
|
|
572
|
+
"""
|
|
573
|
+
Returns cached display name of this instance.
|
|
574
|
+
"""
|
|
575
|
+
return self.display_name
|
|
576
|
+
|
|
577
|
+
def get_avatar(self) -> Optional[dict]:
|
|
578
|
+
"""
|
|
579
|
+
Returns cached avatar profile dict.
|
|
580
|
+
"""
|
|
581
|
+
return self.avatar
|
|
582
|
+
|
|
583
|
+
def get_keys(self) -> Optional[SessionKeys]:
|
|
584
|
+
"""
|
|
585
|
+
Returns derived cryptographic keys.
|
|
586
|
+
"""
|
|
587
|
+
return self.keys
|
|
588
|
+
|
|
589
|
+
def download_avatar(self, avatar: dict) -> bytes:
|
|
590
|
+
"""
|
|
591
|
+
Downloads and decrypts avatar image using Profile.avatar dictionary containing 'url' and 'key'.
|
|
592
|
+
"""
|
|
593
|
+
file_server_url = "http://filev2.getsession.org/file/"
|
|
594
|
+
url = avatar["url"]
|
|
595
|
+
if not url.startswith(file_server_url):
|
|
596
|
+
raise ValueError("Avatar must be hosted on Session file server")
|
|
597
|
+
file_id = url[len(file_server_url):]
|
|
598
|
+
raw_bytes = self.network.download_attachment(file_id)
|
|
599
|
+
from .crypto import decrypt_profile
|
|
600
|
+
return decrypt_profile(raw_bytes, avatar["key"])
|
|
601
|
+
|
|
602
|
+
def upload_avatar(self, avatar_data: bytes) -> dict:
|
|
603
|
+
"""
|
|
604
|
+
Encrypts and uploads avatar data to Session file server.
|
|
605
|
+
"""
|
|
606
|
+
profile_key = os.urandom(32)
|
|
607
|
+
from .crypto import encrypt_profile
|
|
608
|
+
ciphertext = encrypt_profile(avatar_data, profile_key)
|
|
609
|
+
res = self.network.upload_attachment(ciphertext)
|
|
610
|
+
return {
|
|
611
|
+
"profileKey": profile_key,
|
|
612
|
+
"avatarPointer": res["url"]
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
def set_avatar(self, avatar_data: bytes):
|
|
616
|
+
"""
|
|
617
|
+
Uploads, encrypts and sets avatar.
|
|
618
|
+
"""
|
|
619
|
+
res = self.upload_avatar(avatar_data)
|
|
620
|
+
self.avatar = {
|
|
621
|
+
"key": res["profileKey"],
|
|
622
|
+
"url": res["avatarPointer"]
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
config_content = Content()
|
|
626
|
+
if self.display_name:
|
|
627
|
+
config_content.configurationMessage.displayName = self.display_name
|
|
628
|
+
config_content.configurationMessage.profilePicture = self.avatar["url"]
|
|
629
|
+
config_content.configurationMessage.profileKey = self.avatar["key"]
|
|
630
|
+
|
|
631
|
+
raw_msg_bytes = config_content.SerializeToString()
|
|
632
|
+
padded_raw = add_message_padding(raw_msg_bytes)
|
|
633
|
+
sync_ciphertext = encrypt_using_session_protocol(self.keys, self.session_id, padded_raw)
|
|
634
|
+
|
|
635
|
+
envelope = Envelope(
|
|
636
|
+
type=Envelope.Type.SESSION_MESSAGE,
|
|
637
|
+
timestamp=int(time.time() * 1000),
|
|
638
|
+
content=sync_ciphertext
|
|
639
|
+
)
|
|
640
|
+
data64 = binascii.b2a_base64(wrap_envelope(envelope.SerializeToString()), newline=False).decode('utf-8')
|
|
641
|
+
self._store_message(self.session_id, data64, envelope.timestamp, namespace=0)
|
|
642
|
+
|
|
643
|
+
def set_display_name(self, display_name: str):
|
|
644
|
+
"""
|
|
645
|
+
Sets display name and propagates ConfigurationMessage sync copy.
|
|
646
|
+
"""
|
|
647
|
+
if not self.keys or not self.session_id:
|
|
648
|
+
raise ValueError("Session client not initialized.")
|
|
649
|
+
if len(display_name) > 64 or len(display_name) == 0:
|
|
650
|
+
raise ValueError("Display name must be between 1 and 64 characters.")
|
|
651
|
+
|
|
652
|
+
self.display_name = display_name
|
|
653
|
+
|
|
654
|
+
config_content = Content()
|
|
655
|
+
config_content.configurationMessage.displayName = self.display_name
|
|
656
|
+
if self.avatar:
|
|
657
|
+
config_content.configurationMessage.profilePicture = self.avatar["url"]
|
|
658
|
+
config_content.configurationMessage.profileKey = self.avatar["key"]
|
|
659
|
+
|
|
660
|
+
raw_msg_bytes = config_content.SerializeToString()
|
|
661
|
+
padded_raw = add_message_padding(raw_msg_bytes)
|
|
662
|
+
sync_ciphertext = encrypt_using_session_protocol(self.keys, self.session_id, padded_raw)
|
|
663
|
+
|
|
664
|
+
envelope = Envelope(
|
|
665
|
+
type=Envelope.Type.SESSION_MESSAGE,
|
|
666
|
+
timestamp=int(time.time() * 1000),
|
|
667
|
+
content=sync_ciphertext
|
|
668
|
+
)
|
|
669
|
+
data64 = binascii.b2a_base64(wrap_envelope(envelope.SerializeToString()), newline=False).decode('utf-8')
|
|
670
|
+
self._store_message(self.session_id, data64, envelope.timestamp, namespace=0)
|
|
671
|
+
|
|
672
|
+
def notify_screenshot_taken(self, conversation: str):
|
|
673
|
+
"""
|
|
674
|
+
Sends screenshot taken notification to conversation.
|
|
675
|
+
"""
|
|
676
|
+
self._send_data_extraction_notification(conversation, action=1, timestamp=int(time.time() * 1000))
|
|
677
|
+
|
|
678
|
+
def notify_media_saved(self, conversation: str, saved_message_timestamp: int):
|
|
679
|
+
"""
|
|
680
|
+
Sends media saved notification to conversation.
|
|
681
|
+
"""
|
|
682
|
+
self._send_data_extraction_notification(conversation, action=2, timestamp=saved_message_timestamp)
|
|
683
|
+
|
|
684
|
+
def _send_data_extraction_notification(self, conversation: str, action: int, timestamp: int):
|
|
685
|
+
if not self.keys or not self.session_id:
|
|
686
|
+
raise ValueError("Session client not initialized.")
|
|
687
|
+
|
|
688
|
+
content = Content()
|
|
689
|
+
content.dataExtractionNotification.type = action # SCREENSHOT = 1, MEDIA_SAVED = 2
|
|
690
|
+
content.dataExtractionNotification.timestamp = timestamp
|
|
691
|
+
|
|
692
|
+
raw_msg_bytes = content.SerializeToString()
|
|
693
|
+
padded_raw = add_message_padding(raw_msg_bytes)
|
|
694
|
+
ciphertext = encrypt_using_session_protocol(self.keys, conversation, padded_raw)
|
|
695
|
+
|
|
696
|
+
envelope = Envelope(
|
|
697
|
+
type=Envelope.Type.SESSION_MESSAGE,
|
|
698
|
+
timestamp=int(time.time() * 1000),
|
|
699
|
+
content=ciphertext
|
|
700
|
+
)
|
|
701
|
+
data64 = binascii.b2a_base64(wrap_envelope(envelope.SerializeToString()), newline=False).decode('utf-8')
|
|
702
|
+
self._store_message(conversation, data64, envelope.timestamp, namespace=0)
|
|
703
|
+
|
|
704
|
+
def accept_conversation_request(self, conversation: str):
|
|
705
|
+
"""
|
|
706
|
+
Accepts conversation request from another Session ID.
|
|
707
|
+
"""
|
|
708
|
+
if not self.keys or not self.session_id:
|
|
709
|
+
raise ValueError("Session client not initialized.")
|
|
710
|
+
|
|
711
|
+
content = Content()
|
|
712
|
+
content.messageRequestResponse.isApproved = True
|
|
713
|
+
if self.avatar:
|
|
714
|
+
content.messageRequestResponse.profileKey = self.avatar["key"]
|
|
715
|
+
content.messageRequestResponse.profile.profilePicture = self.avatar["url"]
|
|
716
|
+
if self.display_name:
|
|
717
|
+
content.messageRequestResponse.profile.displayName = self.display_name
|
|
718
|
+
|
|
719
|
+
raw_msg_bytes = content.SerializeToString()
|
|
720
|
+
padded_raw = add_message_padding(raw_msg_bytes)
|
|
721
|
+
ciphertext = encrypt_using_session_protocol(self.keys, conversation, padded_raw)
|
|
722
|
+
|
|
723
|
+
envelope = Envelope(
|
|
724
|
+
type=Envelope.Type.SESSION_MESSAGE,
|
|
725
|
+
timestamp=int(time.time() * 1000),
|
|
726
|
+
content=ciphertext
|
|
727
|
+
)
|
|
728
|
+
data64 = binascii.b2a_base64(wrap_envelope(envelope.SerializeToString()), newline=False).decode('utf-8')
|
|
729
|
+
self._store_message(conversation, data64, envelope.timestamp, namespace=0)
|