Skip to content
This repository was archived by the owner on Mar 10, 2026. It is now read-only.

Commit 1662a4d

Browse files
committed
WIP: metadata protection and plausible denyibility
1 parent c7dd401 commit 1662a4d

File tree

6 files changed

+86
-119
lines changed

6 files changed

+86
-119
lines changed

core/crypto.py

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ def generate_kem_keys(algorithm: str = "Kyber1024"):
111111
return private_key, public_key
112112

113113

114-
def decrypt_kyber_shared_secrets(ciphertext_blob: bytes, private_key: bytes, otp_pad_size: int = 10240, algorithm: str = "Kyber1024"):
114+
def decrypt_kyber_shared_secrets(ciphertext_blob: bytes, private_key: bytes, otp_pad_size: int = 10240):
115115
"""
116116
Decapsulates shared_secrets of size otp_pad_size and returns the resulting shared_secrets.
117117
The ciphertexts_blob is expected to be a concatenated sequence of Kyber ciphertexts,
@@ -127,7 +127,7 @@ def decrypt_kyber_shared_secrets(ciphertext_blob: bytes, private_key: bytes, otp
127127
cipher_size = 1568 # Kyber1024 ciphertext size
128128
cursor = 0
129129

130-
with oqs.KeyEncapsulation(algorithm, secret_key=private_key) as kem:
130+
with oqs.KeyEncapsulation("Kyber1024", secret_key=private_key) as kem:
131131
while len(shared_secrets) < otp_pad_size:
132132
ciphertext = ciphertext_blob[cursor:cursor + cipher_size]
133133

@@ -140,7 +140,7 @@ def decrypt_kyber_shared_secrets(ciphertext_blob: bytes, private_key: bytes, otp
140140

141141
return shared_secrets[:otp_pad_size]
142142

143-
def generate_kyber_shared_secrets(public_key: bytes, otp_pad_size: int = OTP_PAD_SIZE, algorithm: str = "Kyber1024"):
143+
def generate_kyber_shared_secrets(public_key: bytes, otp_pad_size: int = OTP_PAD_SIZE):
144144
"""
145145
Generates shared_secrets of size otp_pad_size and returns both the ciphertext list-
146146
and the generated shared_secrets.
@@ -159,7 +159,7 @@ def generate_kyber_shared_secrets(public_key: bytes, otp_pad_size: int = OTP_PAD
159159
shared_secrets = b''
160160
ciphertexts_blob = b''
161161

162-
with oqs.KeyEncapsulation(algorithm) as kem:
162+
with oqs.KeyEncapsulation("Kyber1024") as kem:
163163
while len(shared_secrets) < otp_pad_size:
164164
ciphertext, shared_secret = kem.encap_secret(public_key)
165165

@@ -169,9 +169,6 @@ def generate_kyber_shared_secrets(public_key: bytes, otp_pad_size: int = OTP_PAD
169169
return ciphertexts_blob, shared_secrets[:otp_pad_size]
170170

171171

172-
def randomize_replay_protection_number(replay_protection_number: int) -> int:
173-
return random_number_range(replay_protection_number, random_number_range(replay_protection_number + 1, replay_protection_number + random_number_range(100, 1000)))
174-
175172
def random_number_range(a: int, b: int) -> int:
176173
return secrets.randbelow(b - a + 1) + a
177174

logic/contacts.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,11 @@ def save_contact(user_data: dict, user_data_lock, contact_id: str) -> None:
4242
"our_keys": {
4343
"private_key": None,
4444
"public_key": None
45-
}
46-
},
45+
},
46+
"our_hash_chain": None,
47+
"contact_hash_chain": None
48+
49+
},
4750
"lt_sign_key_smp": {
4851
"verified": False,
4952
"pending_verification": False,
@@ -61,16 +64,14 @@ def save_contact(user_data: dict, user_data_lock, contact_id: str) -> None:
6164
},
6265
"rotation_counter": None,
6366
"rotate_at": None,
64-
"our_hash_chain": None,
65-
"contact_hash_chain": None
6667

6768
},
6869
"our_pads": {
69-
"replay_protection_number": None,
70+
"hash_chain": None,
7071
"pads": None
7172
},
7273
"contact_pads": {
73-
"replay_protection_number": None,
74+
"hash_chain": None,
7475
"pads": None
7576
},
7677
}

logic/message.py

Lines changed: 58 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from core.requests import http_request
22
from logic.storage import save_account_data
33
from logic.pfs import send_new_ephemeral_keys
4+
from core.trad_crypto import sha3_512
45
from core.crypto import *
56
from core.constants import *
67
from base64 import b64decode, b64encode
@@ -11,34 +12,23 @@
1112
logger = logging.getLogger(__name__)
1213

1314

14-
def generate_and_send_pads(user_data, user_data_lock, contact_id: str, contact_kyber_key, our_private_key, ui_queue) -> bool:
15+
def generate_and_send_pads(user_data, user_data_lock, contact_id: str, ui_queue) -> bool:
1516
with user_data_lock:
1617
server_url = user_data["server_url"]
1718
auth_token = user_data["token"]
18-
replay_protection_number = user_data["contacts"][contact_id]["our_pads"]["replay_protection_number"]
19-
20-
ciphertext_blob, pads = generate_kyber_shared_secrets(contact_kyber_key)
21-
22-
if not replay_protection_number:
23-
# 1 because at this point, replay_protection_number is None.
24-
replay_protection_number = randomize_replay_protection_number(1)
25-
else:
19+
20+
contact_kyber_public_key = user_data["contacts"][contact_id]["ephemeral_keys"]["contact_public_key"]
21+
our_lt_private_key = user_data["contacts"][contact_id]["lt_sign_keys"]["our_keys"]["private_key"]
2622

27-
# This +1 is needed to ensure it at least increments by 1
28-
replay_protection_number += 1
29-
replay_protection_number = randomize_replay_protection_number(replay_protection_number)
3023

31-
json_inner_payload = json.dumps({
32-
"ciphertext_blob": b64encode(ciphertext_blob).decode(),
33-
"replay_protection_number": replay_protection_number
34-
})
24+
ciphertext_blob, pads = generate_kyber_shared_secrets(contact_kyber_public_key)
3525

36-
inner_payload_signature = create_signature("Dilithium5", json_inner_payload.encode("utf-8"), our_private_key)
37-
inner_payload_signature = b64encode(inner_payload_signature).decode()
26+
otp_batch_signature = create_signature("Dilithium5", ciphertext_blob, our_lt_private_key)
27+
otp_batch_signature = b64encode(otp_batch_signature).decode()
3828

3929
payload = {
40-
"json_payload": json_inner_payload,
41-
"payload_signature": inner_payload_signature,
30+
"otp_hashchain_ciphertext": b64encode(ciphertext_blob).decode(),
31+
"otp_hashchain_signature": otp_batch_signature,
4232
"recipient": contact_id
4333
}
4434
try:
@@ -49,8 +39,8 @@ def generate_and_send_pads(user_data, user_data_lock, contact_id: str, contact_k
4939

5040
# We update & save only at the end, so if request fails, we do not desync our state.
5141
with user_data_lock:
52-
user_data["contacts"][contact_id]["our_pads"]["pads"] = pads
53-
user_data["contacts"][contact_id]["our_pads"]["replay_protection_number"] = replay_protection_number
42+
user_data["contacts"][contact_id]["our_pads"]["pads"] = pads[64:]
43+
user_data["contacts"][contact_id]["our_pads"]["hash_chain"] = pads[:64]
5444

5545
save_account_data(user_data, user_data_lock)
5646

@@ -108,7 +98,9 @@ def send_message_processor(user_data, user_data_lock, contact_id: str, message:
10898
# ephemeral key exchanges always get processed before messages do.
10999
# Which means if we generate and send pads with contact's, we would be using his old key, which would get overriden by the request, even if we send pads first
110100
# This is because of our server archiecture which prioritizes PFS requests before messages.
111-
#
101+
#
102+
# Another note, that means after batch ends, and rotation time comes, you won't be able to send messages until other contact is online.
103+
# This will change in a future update
112104
if rotation_counter == rotate_at:
113105
logger.info("We are rotating our ephemeral keys for contact (%s)", contact_id)
114106
ui_queue.put({"type": "showinfo", "title": "Perfect Forward Secrecy", "message": f"We are rotating our ephemeral keys for contact ({contact_id[:32]})"})
@@ -117,7 +109,7 @@ def send_message_processor(user_data, user_data_lock, contact_id: str, message:
117109
save_account_data(user_data, user_data_lock)
118110
return False
119111

120-
if not generate_and_send_pads(user_data, user_data_lock, contact_id, contact_kyber_public_key, our_lt_private_key, ui_queue):
112+
if not generate_and_send_pads(user_data, user_data_lock, contact_id, ui_queue):
121113
return False
122114

123115

@@ -128,12 +120,17 @@ def send_message_processor(user_data, user_data_lock, contact_id: str, message:
128120

129121
logger.debug("Incremented rotation_counter by 1. (%d)", rotation_counter)
130122

131-
123+
132124
with user_data_lock:
133-
replay_protection_number = user_data["contacts"][contact_id]["our_pads"]["replay_protection_number"]
125+
our_hash_chain = user_data["contacts"][contact_id]["our_pads"]["hash_chain"]
126+
134127

135128
message_encoded = message.encode("utf-8")
136129

130+
next_hash_chain = sha3_512(our_hash_chain + message_encoded)
131+
132+
message_encoded = next_hash_chain + message_encoded
133+
137134
message_otp_padding_length = max(0, OTP_PADDING_LIMIT - OTP_PADDING_LENGTH - len(message_encoded))
138135

139136
if (len(message_encoded) + OTP_PADDING_LENGTH + message_otp_padding_length) > len(our_pads):
@@ -152,44 +149,22 @@ def send_message_processor(user_data, user_data_lock, contact_id: str, message:
152149
message_encrypted = otp_encrypt_with_padding(message_encoded, message_otp_pad, padding_limit = message_otp_padding_length)
153150
message_encrypted = b64encode(message_encrypted).decode()
154151

155-
# Unlike in other functions, we truncate pads here and update replay_protection_number regardless of request being successful or not
152+
# Unlike in other functions, we truncate pads here and compute the next hash chain regardless of request being successful or not
156153
# because a malicious server could make our requests fail to force us to re-use the same pad for our next message
157154
# which would break all of our security
158155
with user_data_lock:
159-
user_data["contacts"][contact_id]["our_pads"]["pads"] = user_data["contacts"][contact_id]["our_pads"]["pads"][len(message_encoded) + OTP_PADDING_LENGTH + message_otp_padding_length:]
160-
161-
replay_protection_number = user_data["contacts"][contact_id]["our_pads"]["replay_protection_number"]
162-
163-
# This ensures the replay counter always gets incremented by at very least 1
164-
replay_protection_number += 1
165-
166-
# This helps obfsucate how many total messages were sent incase the request is intercepted
167-
# Adversaries might be able to come with a modest guess of how many total messages were sent
168-
# but never actually the exact amount, this provides some form of plausible deniability.
169-
replay_protection_number = randomize_replay_protection_number(replay_protection_number)
170-
171-
user_data["contacts"][contact_id]["our_pads"]["replay_protection_number"] = replay_protection_number
172-
173-
156+
user_data["contacts"][contact_id]["our_pads"]["pads"] = user_data["contacts"][contact_id]["our_pads"]["pads"][len(message_encoded) + OTP_PADDING_LENGTH + message_otp_padding_length:]
157+
user_data["contacts"][contact_id]["our_pads"]["hash_chain"] = next_hash_chain
174158

175159
save_account_data(user_data, user_data_lock)
176160

177-
178-
json_inner_payload = json.dumps({
179-
"message_encrypted": message_encrypted,
180-
"replay_protection_number": replay_protection_number
181-
})
182-
183-
json_inner_payload_signature = create_signature("Dilithium5", json_inner_payload.encode("utf-8"), our_lt_private_key)
184-
json_inner_payload_signature = b64encode(json_inner_payload_signature).decode()
185-
186-
payload = {
187-
"json_payload": json_inner_payload,
188-
"payload_signature": json_inner_payload_signature,
189-
"recipient": contact_id
190-
}
191161
try:
192-
response = http_request(f"{server_url}/messages/send_message", "POST", payload=payload, auth_token=auth_token)
162+
response = http_request(f"{server_url}/messages/send_message", "POST", payload = {
163+
"message_encrypted": message_encrypted,
164+
"recipient": contact_id
165+
},
166+
auth_token=auth_token
167+
)
193168
except:
194169
ui_queue.put({"type": "showerror", "title": "Error", "message": "Failed to send our message to the server"})
195170
return False
@@ -211,7 +186,7 @@ def messages_data_handler(user_data, user_data_lock, user_data_copied, ui_queue,
211186

212187

213188
if not user_data_copied["contacts"][contact_id]["lt_sign_key_smp"]["verified"]:
214-
logger.warning("Contact long-term signing key is not verified.. it is possible that this is a MiTM attack by the server, we ignoring this Message for now.")
189+
logger.warning("Contact long-term signing key is not verified.. it is possible that this is a MiTM attack, we ignoring this message for now.")
215190
return
216191

217192

@@ -225,67 +200,60 @@ def messages_data_handler(user_data, user_data_lock, user_data_copied, ui_queue,
225200
logger.debug("Received a new message of type: %s", message["msg_type"])
226201

227202
if message["msg_type"] == "new_otp_batch":
228-
payload_signature = b64decode(message["payload_signature"], validate=True)
229-
valid_signature = verify_signature("Dilithium5", message["json_payload"].encode("utf-8"), payload_signature, contact_public_key)
203+
otp_hashchain_signature = b64decode(message["otp_hashchain_signature"], validate=True)
204+
otp_hashchain_ciphertext = b64decode(message["otp_hashchain_ciphertext"], validate=True)
205+
206+
valid_signature = verify_signature("Dilithium5", otp_hashchain_ciphertext, otp_hashchain_signature, contact_public_key)
230207
if not valid_signature:
231-
logger.debug("Invalid OTP batch signature.. possible MiTM ?")
208+
logger.debug("Invalid OTP_hashchain_ciphertext signature.. possible MiTM ?")
232209
return
233210

234-
json_payload = json.loads(message["json_payload"])
235-
236-
ciphertext_blob = b64decode(json_payload["ciphertext_blob"], validate=True)
237-
replay_protection_number = int(json_payload["replay_protection_number"])
238-
239211
our_kyber_key = user_data_copied["contacts"][contact_id]["ephemeral_keys"]["our_keys"]["private_key"]
240212

241213
try:
242-
contact_pads = decrypt_kyber_shared_secrets(ciphertext_blob, our_kyber_key)
214+
contact_pads = decrypt_kyber_shared_secrets(otp_hashchain_ciphertext, our_kyber_key)
243215
except:
244216
logger.debug("Failed to decrypt shared_secrets, possible MiTM?")
245217
return
246218

219+
247220
with user_data_lock:
248-
user_data["contacts"][contact_id]["contact_pads"]["pads"] = contact_pads
249-
user_data["contacts"][contact_id]["contact_pads"]["replay_protection_number"] = replay_protection_number
221+
user_data["contacts"][contact_id]["contact_pads"]["pads"] = contact_pads[64:]
222+
user_data["contacts"][contact_id]["contact_pads"]["hash_chain"] = contact_pads[:64]
250223

251-
logger.info("Saved contact (%s) new batch of One-Time-Pads", contact_id)
224+
logger.info("Saved contact (%s) new batch of One-Time-Pads and hash chain seed", contact_id)
252225

253226
save_account_data(user_data, user_data_lock)
254227

255228
elif message["msg_type"] == "new_message":
256-
payload_signature = b64decode(message["payload_signature"], validate=True)
257-
valid_signature = verify_signature("Dilithium5", message["json_payload"].encode("utf-8"), payload_signature, contact_public_key)
258-
if not valid_signature:
259-
logger.debug("Invalid new message signature.. possible MiTM ?")
260-
return
261-
262-
json_payload = json.loads(message["json_payload"])
263-
message_encrypted = b64decode(json_payload["message_encrypted"], validate=True)
264-
replay_protection_number = int(json_payload["replay_protection_number"])
229+
message_encrypted = b64decode(message["message_encrypted"], validate=True)
265230

266231
with user_data_lock:
267-
contact_pads = user_data["contacts"][contact_id]["contact_pads"]["pads"]
268-
contact_replay_protection_number = user_data["contacts"][contact_id]["contact_pads"]["replay_protection_number"]
269-
232+
contact_pads = user_data["contacts"][contact_id]["contact_pads"]["pads"]
233+
contact_hash_chain = user_data["contacts"][contact_id]["contact_pads"]["hash_chain"]
270234

271235
if (not contact_pads) or (len(message_encrypted) > len(contact_pads)):
272236
logger.warning("Message payload is larger than our local pads for the contact, we are skipping this message..")
273237
return
274238

275-
if (not contact_replay_protection_number) or (replay_protection_number <= contact_replay_protection_number):
276-
logger.warning("Message replay_protection_number is equal or smaller than our saved replay_protection_number, this could be a possible replay attack, skipping this message...")
277-
return
278-
279-
280239
message_decrypted = otp_decrypt_with_padding(message_encrypted, contact_pads[:len(message_encrypted)])
281-
282240
# immediately truncate the pads
283241
contact_pads = contact_pads[len(message_encrypted):]
284242

243+
hash_chain = message_decrypted[:64]
244+
message_decrypted = message_decrypted[64:]
245+
246+
next_hash_chain = sha3_512(contact_hash_chain + message_decrypted)
247+
248+
if next_hash_chain != hash_chain:
249+
logger.warning("Message hash chain did not match, this could be a possible replay attack, or a failed tampering attempt. Skipping this message...")
250+
return
251+
252+
285253
# and immediately save the new pads and replay protection number
286254
with user_data_lock:
287-
user_data["contacts"][contact_id]["contact_pads"]["pads"] = contact_pads
288-
user_data["contacts"][contact_id]["contact_pads"]["replay_protection_number"] = replay_protection_number
255+
user_data["contacts"][contact_id]["contact_pads"]["pads"] = contact_pads
256+
user_data["contacts"][contact_id]["contact_pads"]["hash_chain"] = next_hash_chain
289257

290258
save_account_data(user_data, user_data_lock)
291259

0 commit comments

Comments
 (0)