Skip to content

Commit af3aa3b

Browse files
committed
refactor: add strandlock & coldwire protocols support
1 parent 9b9a42c commit af3aa3b

File tree

11 files changed

+363
-133
lines changed

11 files changed

+363
-133
lines changed

core/constants.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,17 @@
22
APP_NAME = "Coldwire"
33
APP_VERSION = "0.1"
44

5-
# hard-coded filepaths
5+
# hard-coded filepaths
66
ACCOUNT_FILE_PATH = "account.coldwire"
77

8-
# network defaults (seconds)
8+
# Coldwire protocol misc (bytes)
9+
SMP_TYPE = b"\x00"
10+
PFS_TYPE = b"\x01"
11+
MSG_TYPE = b"\x02"
12+
13+
COLDWIRE_LEN_OFFSET = 3
14+
15+
# network defaults (seconds & bytes)
916
LONGPOLL_MIN = 5
1017
LONGPOLL_MAX = 30
1118

@@ -14,10 +21,11 @@
1421

1522
XCHACHA20POLY1305_NONCE_LEN = 24
1623

17-
OTP_PAD_SIZE = 11264
18-
OTP_MAX_BUCKET = 64
19-
OTP_MAX_RANDOM_PAD = 16
20-
OTP_SIZE_LENGTH = 2
24+
OTP_PAD_SIZE = 11264
25+
OTP_MAX_BUCKET = 64
26+
OTP_MAX_RANDOM_PAD = 16
27+
OTP_SIZE_LENGTH = 2
28+
OTP_MAX_MESSAGE_LEN = OTP_PAD_SIZE - OTP_SIZE_LENGTH
2129

2230
SMP_NONCE_LENGTH = 64
2331
SMP_PROOF_LENGTH = 64
@@ -27,6 +35,7 @@
2735
KEYS_HASH_CHAIN_LEN = 64
2836
MESSAGE_HASH_CHAIN_LEN = 64
2937

38+
3039

3140
# NIST-specified key sizes (bytes) and metadata
3241
ML_KEM_1024_NAME = "ML-KEM-1024"

core/requests.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
11
from urllib import request, error
2+
from core.trad_crypto import (
3+
sha3_512
4+
)
25
import urllib
6+
import uuid
37
import json
48
import logging
9+
import string
10+
import secrets
511

612
logger = logging.getLogger(__name__)
713

14+
815
_ORIGINAL_SOCKET = None
916

1017
def socks_monkey_patch(proxy_info: dict = None):
@@ -56,6 +63,89 @@ def undo_monkey_patching():
5663
socket.socket = _ORIGINAL_SOCKET
5764

5865

66+
67+
68+
69+
70+
# Helper function to encode a form field
71+
def encode_field(name: str, value: str, boundary: str, CRLF: str) -> bytes:
72+
return (
73+
f'--{boundary}{CRLF}'
74+
f'Content-Disposition: form-data; name="{name}"{CRLF}{CRLF}'
75+
f'{value}{CRLF}'
76+
).encode("utf-8")
77+
78+
# Helper function to encode a file field
79+
def encode_file(name: str, filename: str, data: bytes, boundary: str, CRLF: str, content_type: str = "application/octet-stream") -> bytes:
80+
return (
81+
f'--{boundary}{CRLF}'
82+
f'Content-Disposition: form-data; name="{name}"; filename="{filename}"{CRLF}'
83+
f'Content-Type: {content_type}{CRLF}{CRLF}'
84+
).encode("utf-8") + data + CRLF.encode("utf-8")
85+
86+
87+
88+
def http_request(url: str, method: str, auth_token: str = None, metadata: dict = None, blob: bytes = None, longpoll: int = None) -> dict:
89+
if method.upper() not in ["POST", "GET", "PUT", "DELETE"]:
90+
raise ValueError(f"Invalid request method `{method}`")
91+
92+
93+
if method.upper() in ["POST", "PUT"]:
94+
if metadata and blob:
95+
96+
# a-zA-Z0-9, same as what Chromium-based browser do.
97+
ALPHABET_ASCII = string.ascii_letters + string.digits
98+
ALPHABET_LENGTH = len(ALPHABET_ASCII)
99+
100+
boundary = "WebKitFormBoundary"
101+
boundary += ''.join(ALPHABET_ASCII[b % ALPHABET_LENGTH] for b in sha3_512(secrets.token_bytes(16)[:16]))
102+
103+
CRLF = "\r\n"
104+
body = b""
105+
106+
if metadata is not None:
107+
body += encode_field("metadata", json.dumps({"metadata": metadata}), boundary, CRLF)
108+
109+
if blob is not None:
110+
body += encode_file("blob", "blob.bin", blob, boundary, CRLF)
111+
112+
body += f'--{boundary}--{CRLF}'.encode("utf-8")
113+
114+
115+
req = request.Request(
116+
url,
117+
data = body,
118+
headers={"Content-Type": f"multipart/form-data; boundary={boundary}"}
119+
)
120+
121+
elif metadata:
122+
metadata = json.dumps(metadata).encode("utf-8")
123+
req = request.Request(url, data=metadata, method=method.upper())
124+
req.add_header("Content-Type", "application/json")
125+
else:
126+
raise ValueError("Request method is POST/PUT but no metadata nor blob were given.")
127+
128+
else:
129+
req = request.Request(url, method=method.upper())
130+
131+
if auth_token is not None:
132+
req.add_header("Authorization", "Bearer " + auth_token)
133+
134+
135+
# NOTE: urllib raises a HTTPError for status code >= 400
136+
try:
137+
with request.urlopen(req, timeout = longpoll) as response:
138+
return response.read()
139+
140+
except urllib.error.HTTPError as e:
141+
body = e.read().decode()
142+
logger.error("We received error from server: %s", body)
143+
raise Exception(body)
144+
145+
146+
147+
148+
"""
59149
def http_request(url: str, method: str, auth_token: str = None, payload: dict = None, longpoll: int = -1) -> dict:
60150
if payload:
61151
payload = json.dumps(payload).encode()
@@ -82,3 +172,4 @@ def http_request(url: str, method: str, auth_token: str = None, payload: dict =
82172
body = e.read().decode()
83173
logger.error("We received error from server: %s", body)
84174
raise Exception(body)
175+
"""

logic/authentication.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
ML_DSA_87_NAME,
1313
CHALLENGE_LEN
1414
)
15+
import json
1516

1617
def authenticate_account(user_data: dict) -> dict:
1718
"""
@@ -37,12 +38,14 @@ def authenticate_account(user_data: dict) -> dict:
3738
user_id = user_data.get("user_id") or ""
3839

3940
try:
40-
response = http_request(url + "/authenticate/init", "POST", payload = {"public_key": public_key_encoded, "user_id": user_id })
41+
response = http_request(url + "/authenticate/init", "POST", metadata = {"public_key": public_key_encoded, "user_id": user_id })
4142
except Exception:
4243
if user_data["settings"]["proxy_info"] is not None:
4344
raise ValueError("Could not connect to server! Are you sure your proxy settings are valid ?")
4445
else:
4546
raise ValueError("Could not connect to server! Are you sure the URL is valid ?")
47+
48+
response = json.loads(response.decode())
4649

4750
if not 'challenge' in response:
4851
raise ValueError("Server did not give authenticatation challenge! Are you sure this is a Coldwire server ?")
@@ -56,10 +59,12 @@ def authenticate_account(user_data: dict) -> dict:
5659
signature = create_signature(ML_DSA_87_NAME, challenge[:CHALLENGE_LEN], private_key)
5760

5861
try:
59-
response = http_request(url + "/authenticate/verify", "POST", payload = {"signature": b64encode(signature).decode(), "challenge": response["challenge"]})
62+
response = http_request(url + "/authenticate/verify", "POST", metadata = {"signature": b64encode(signature).decode(), "challenge": response["challenge"]})
6063
except Exception:
6164
raise ValueError("Server gave a malformed response, your account is probably missing from the server")
6265

66+
response = json.loads(response.decode())
67+
6368
required_keys = ["status", "user_id", "token"]
6469
missing = [k for k in required_keys if k not in response]
6570

logic/background_worker.py

Lines changed: 120 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,61 @@
44
from logic.message import messages_data_handler
55
from core.constants import (
66
LONGPOLL_MIN,
7-
LONGPOLL_MAX
7+
LONGPOLL_MAX,
8+
COLDWIRE_LEN_OFFSET,
9+
SMP_TYPE,
10+
PFS_TYPE,
11+
MSG_TYPE,
12+
XCHACHA20POLY1305_NONCE_LEN
813
)
914
from core.crypto import random_number_range
15+
from core.trad_crypto import (
16+
encrypt_xchacha20poly1305,
17+
decrypt_xchacha20poly1305
18+
)
19+
from base64 import b64decode
1020
import copy
1121
import logging
1222
import json
1323

1424
logger = logging.getLogger(__name__)
1525

26+
27+
def decode_blob_stream(data: bytes) -> list:
28+
messages = []
29+
30+
offset = 0
31+
while offset < len(data):
32+
if offset + COLDWIRE_LEN_OFFSET > len(data):
33+
raise ValueError("Incomplete length prefix, malformed or corrupted data.")
34+
35+
msg_len = int.from_bytes(data[offset : offset + COLDWIRE_LEN_OFFSET], "big")
36+
offset += COLDWIRE_LEN_OFFSET
37+
if offset + msg_len > len(data):
38+
raise ValueError("Incomplete message data")
39+
40+
messages.append(data[offset:offset + msg_len])
41+
offset += msg_len
42+
return messages
43+
44+
45+
def parse_blobs(blobs: list[bytes]) -> dict:
46+
parsed_messages = []
47+
48+
for raw in blobs:
49+
try:
50+
sender, blob = raw.split(b"\0", 1)
51+
sender = sender.decode("utf-8")
52+
parsed_messages.append({
53+
"sender": sender,
54+
"blob": blob
55+
})
56+
except ValueError as e:
57+
logger.error("Invalid message format! Error: %s", str(e))
58+
continue
59+
60+
return parsed_messages
61+
1662
def background_worker(user_data, user_data_lock, ui_queue, stop_flag):
1763
# Incase we received a SMP question request last time right before the background worker was about to exit
1864
smp_unanswered_questions(user_data, user_data_lock, ui_queue)
@@ -24,15 +70,22 @@ def background_worker(user_data, user_data_lock, ui_queue, stop_flag):
2470

2571
try:
2672
# Random longpoll number to help obfsucate traffic against analysis
27-
response = http_request(f"{server_url}/data/longpoll", "GET", auth_token=auth_token, longpoll=random_number_range(LONGPOLL_MIN, LONGPOLL_MAX))
73+
response = http_request(
74+
f"{server_url}/data/longpoll",
75+
"GET",
76+
auth_token = auth_token,
77+
longpoll = random_number_range(LONGPOLL_MIN, LONGPOLL_MAX)
78+
)
2879
except TimeoutError:
2980
logger.debug("Data longpoll request has timed out, retrying...")
3081
continue
3182

32-
# logger.debug("Data received: %s", json.dumps(response, indent = 2)[:2000])
83+
data = decode_blob_stream(response)
84+
data = parse_blobs(data)
3385

34-
for message in response["messages"]:
35-
logger.debug("Received data message: %s", json.dumps(message, indent = 2)[:5000])
86+
87+
for message in data:
88+
logger.debug("Received data: %s", str(message)[:3000])
3689

3790
# Sanity check universal message fields
3891
if (not "sender" in message) or (not message["sender"].isdigit()) or (len(message["sender"]) != 16):
@@ -43,25 +96,78 @@ def background_worker(user_data, user_data_lock, ui_queue, stop_flag):
4396

4497
continue
4598

99+
sender = message["sender"]
100+
blob = message["blob"]
101+
46102
with user_data_lock:
47103
user_data_copied = copy.deepcopy(user_data)
48104

49-
if message["data_type"] == "smp":
50-
smp_data_handler(user_data, user_data_lock, user_data_copied, ui_queue, message)
105+
# Everything from here is not validated by server
106+
107+
blob_plaintext = None
108+
109+
if sender in user_data_copied["contacts"]:
110+
chacha_key = user_data["contacts"][sender]["lt_sign_key_smp"]["tmp_key"]
111+
contact_next_strand_nonce = user_data["contacts"][sender]["contact_next_strand_nonce"]
112+
113+
if chacha_key is not None:
114+
chacha_key = b64decode(user_data["contacts"][sender]["lt_sign_key_smp"]["tmp_key"])
115+
116+
try:
117+
try:
118+
blob_plaintext = decrypt_xchacha20poly1305(chacha_key, blob[:XCHACHA20POLY1305_NONCE_LEN], blob[XCHACHA20POLY1305_NONCE_LEN:])
119+
except Exception as e:
120+
logger.debug("Failed to decrypt blob from contact (%s) probably due to invalid nonce: %s", sender, str(e))
121+
if contact_next_strand_nonce is None:
122+
raise Exception("Unable to decrypt apparent SMP request due to missing contact strand nonce.")
123+
124+
blob_plaintext = decrypt_xchacha20poly1305(chacha_key, contact_next_strand_nonce, blob)
125+
126+
except Exception as e:
127+
logger.error("Failed to decrypt blob from contact (%s) with error: %s", sender, str(e))
128+
else:
129+
chacha_key = user_data["contacts"][sender]["contact_strand_key"]
130+
131+
if (chacha_key is None) and (contact_next_strand_nonce is None):
132+
# just assume at this point that it's not encrypted.
133+
blob_plaintext = blob
134+
else:
135+
# Under known laws of physics, this should never fail. Unless the contact is acting funny on purpose / invalid implementation of Coldwire + strandlock protocol.
136+
try:
137+
blob_plaintext = decrypt_xchacha20poly1305(chacha_key, contact_next_strand_nonce, blob)
138+
except Exception as e:
139+
logger.error(
140+
"Impossible error: Failed to decrypt blob from contact (%s)"
141+
"We dont know what caused this, maybe the contact is trying to denial-of-service you"
142+
". Skipping data because of error: %s", sender, str(e)
143+
)
144+
continue
145+
else:
146+
logger.debug("Contact (%s) not saved.. we just gonna assume blob_plaintext = blob", sender)
147+
blob_plaintext = blob
148+
149+
150+
# SMP
151+
if bytes([blob_plaintext[0]]) == SMP_TYPE:
152+
smp_data_handler(user_data, user_data_lock, user_data_copied, ui_queue, sender, blob_plaintext[1:])
51153

52-
elif message["data_type"] == "pfs":
53-
pfs_data_handler(user_data, user_data_lock, user_data_copied, ui_queue, message)
154+
# PFS
155+
elif bytes([blob_plaintext[0]]) == PFS_TYPE:
156+
pfs_data_handler(user_data, user_data_lock, user_data_copied, ui_queue, sender, blob_plaintext[1:])
157+
158+
# MSG
159+
elif bytes([blob_plaintext[0]]) == MSG_TYPE:
160+
messages_data_handler(user_data, user_data_lock, user_data_copied, ui_queue, sender, blob_plaintext[1:])
54161

55-
elif message["data_type"] == "message":
56-
messages_data_handler(user_data, user_data_lock, user_data_copied, ui_queue, message)
57162
else:
58163
logger.error(
59-
"Impossible condition, either you have discovered a bug in Coldwire, or the server is attempting to denial-of-service you. Skipping data message with unknown data type (%s)...",
60-
message["data_type"]
164+
"Skipping data with unknown data type (%d) from contact (%s)...",
165+
bytes([blob_plaintext[0]]),
166+
sender
61167
)
62168

63169
# *Sigh* I had to put this here because if we rotate before finishing reading all of the messages
64-
# we would literally overwrite our own key.
170+
# we would overwrite our own key.
65171
# TODO: We need to keep the last used key and use it when decapsulation with new key gives invalid output
66172
# because it might actually take some time for our keys to be uploaded to server + other servers, and to the contact.
67173
#

logic/contacts.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,9 @@ def save_contact(user_data: dict, user_data_lock, contact_id: str) -> None:
8787
}
8888
},
8989
"our_strand_key": None,
90+
"contact_strand_key": None,
9091
"our_next_strand_nonce": None,
91-
"contact_next_strand_key": None,
92-
"contact_strand_nonce": None,
92+
"contact_next_strand_nonce": None,
9393
"our_pads": {
9494
"hash_chain": None,
9595
"pads": None

0 commit comments

Comments
 (0)