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

Commit 87f966e

Browse files
authored
Merge pull request #6 from Freedom-Club-Sec/refactor/hybrid-encryption
Refactor/hybrid encryption
2 parents 9f16c95 + 10b712c commit 87f966e

File tree

11 files changed

+283
-182
lines changed

11 files changed

+283
-182
lines changed

core/constants.py

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

5+
# hard-coded filepaths
6+
ACCOUNT_FILE_PATH = "account.coldwire"
7+
58
# network defaults (seconds)
69
LONGPOLL_MIN = 5
710
LONGPOLL_MAX = 30
@@ -17,27 +20,45 @@
1720
ML_KEM_1024_NAME = "Kyber1024"
1821
ML_KEM_1024_SK_LEN = 3168
1922
ML_KEM_1024_PK_LEN = 1568
23+
ML_KEM_1024_CT_LEN = 1568
2024

2125

2226
ML_DSA_87_NAME = "Dilithium5"
2327
ML_DSA_87_SK_LEN = 4864
2428
ML_DSA_87_PK_LEN = 2592
2529
ML_DSA_87_SIGN_LEN = 4595
2630

27-
ML_BUFFER_LIMITS = {
31+
32+
CLASSIC_MCELIECE_8_F_NAME = "Classic-McEliece-8192128f"
33+
CLASSIC_MCELIECE_8_F_SK_LEN = 14120
34+
CLASSIC_MCELIECE_8_F_PK_LEN = 1357824
35+
CLASSIC_MCELIECE_8_F_CT_LEN = 208
36+
37+
38+
CLASSIC_MCELIECE_8_F_ROTATE_AT = 3 # Default OTP batches needed to be sent for a key rotation to occur
39+
40+
41+
42+
ALGOS_BUFFER_LIMITS = {
2843
ML_KEM_1024_NAME: {
2944
"SK_LEN": ML_KEM_1024_SK_LEN,
30-
"PK_LEN": ML_KEM_1024_PK_LEN
45+
"PK_LEN": ML_KEM_1024_PK_LEN,
46+
"CT_LEN": ML_KEM_1024_CT_LEN
3147
},
3248
ML_DSA_87_NAME: {
3349
"SK_LEN" : ML_DSA_87_SK_LEN,
3450
"PK_LEN" : ML_DSA_87_PK_LEN,
3551
"SIGN_LEN": ML_DSA_87_SIGN_LEN
36-
}
52+
},
53+
CLASSIC_MCELIECE_8_F_NAME: {
54+
"SK_LEN": CLASSIC_MCELIECE_8_F_SK_LEN,
55+
"PK_LEN": CLASSIC_MCELIECE_8_F_PK_LEN,
56+
"CT_LEN": CLASSIC_MCELIECE_8_F_CT_LEN
57+
},
3758
}
3859

3960
# hash parameters
40-
ARGON2_MEMORY = 256 * 1024 # KB
61+
ARGON2_MEMORY = 256 * 1024 # MB
4162
ARGON2_ITERS = 3
4263
ARGON2_OUTPUT_LEN = 32 # bytes
4364
ARGON2_SALT_LEN = 32 # bytes

core/crypto.py

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
ML_DSA_87_SK_LEN,
2929
ML_DSA_87_PK_LEN,
3030
ML_DSA_87_SIGN_LEN,
31-
ML_BUFFER_LIMITS
31+
ALGOS_BUFFER_LIMITS
3232
)
3333

3434

@@ -44,7 +44,7 @@ def create_signature(algorithm: str, message: bytes, private_key: bytes) -> byte
4444
Returns:
4545
Signature bytes of fixed size defined by the algorithm.
4646
"""
47-
with oqs.Signature(algorithm, secret_key = private_key[:ML_BUFFER_LIMITS[algorithm]["SK_LEN"]]) as signer:
47+
with oqs.Signature(algorithm, secret_key = private_key[:ALGOS_BUFFER_LIMITS[algorithm]["SK_LEN"]]) as signer:
4848
return signer.sign(message)
4949

5050
def verify_signature(algorithm: str, message: bytes, signature: bytes, public_key: bytes) -> bool:
@@ -61,7 +61,7 @@ def verify_signature(algorithm: str, message: bytes, signature: bytes, public_ke
6161
True if valid, False if invalid.
6262
"""
6363
with oqs.Signature(algorithm) as verifier:
64-
return verifier.verify(message, signature[:ML_BUFFER_LIMITS[algorithm]["SIGN_LEN"]], public_key[:ML_BUFFER_LIMITS[algorithm]["PK_LEN"]])
64+
return verifier.verify(message, signature[:ALGOS_BUFFER_LIMITS[algorithm]["SIGN_LEN"]], public_key[:ALGOS_BUFFER_LIMITS[algorithm]["PK_LEN"]])
6565

6666
def generate_sign_keys(algorithm: str = ML_DSA_87_NAME):
6767
"""
@@ -137,9 +137,9 @@ def one_time_pad(plaintext: bytes, key: bytes) -> bytes:
137137
otpd_plaintext += bytes([plain_byte ^ key_byte])
138138
return otpd_plaintext
139139

140-
def generate_kem_keys(algorithm: str = ML_KEM_1024_NAME):
140+
def generate_kem_keys(algorithm: str):
141141
"""
142-
Generates ML-KEM-1024 keypair (Kyber).
142+
Generates a KEM keypair.
143143
144144
Args:
145145
algorithm: PQ KEM algorithm (default Kyber1024).
@@ -152,39 +152,42 @@ def generate_kem_keys(algorithm: str = ML_KEM_1024_NAME):
152152
private_key = kem.export_secret_key()
153153
return private_key, public_key
154154

155-
def decrypt_kyber_shared_secrets(ciphertext_blob: bytes, private_key: bytes, otp_pad_size: int = OTP_PAD_SIZE):
155+
def decrypt_shared_secrets(ciphertext_blob: bytes, private_key: bytes, algorithm: str = None, otp_pad_size: int = OTP_PAD_SIZE):
156156
"""
157-
Decrypts concatenated Kyber ciphertexts to derive shared one-time pad.
157+
Decrypts concatenated KEM ciphertexts to derive shared one-time pad.
158158
159159
Args:
160160
ciphertext_blob: Concatenated Kyber ciphertexts.
161-
private_key: ML-KEM-1024 private key.
161+
private_key: KEM private key.
162+
algorithm: KEM algorithm NIST name.
162163
otp_pad_size: Desired OTP pad size in bytes.
163164
164165
Returns:
165166
Shared secret OTP pad bytes.
166167
"""
167-
cipher_size = 1568 # Kyber1024 ciphertext size
168+
cipher_size = ALGOS_BUFFER_LIMITS[algorithm]["CT_LEN"] # KEM ciphertext size
168169
shared_secrets = b''
169170
cursor = 0
170171

171-
with oqs.KeyEncapsulation(ML_KEM_1024_NAME, secret_key=private_key[:ML_BUFFER_LIMITS[ML_KEM_1024_NAME]["SK_LEN"]]) as kem:
172+
with oqs.KeyEncapsulation(algorithm, secret_key=private_key[:ALGOS_BUFFER_LIMITS[algorithm]["SK_LEN"]]) as kem:
172173
while len(shared_secrets) < otp_pad_size:
173174
ciphertext = ciphertext_blob[cursor:cursor + cipher_size]
174175
if len(ciphertext) != cipher_size:
175-
raise ValueError("Ciphertext blob is malformed or incomplete")
176+
raise ValueError(f"Ciphertext of {algorithm} blob is malformed or incomplete ({len(ciphertext)})")
177+
176178
shared_secret = kem.decap_secret(ciphertext)
177179
shared_secrets += shared_secret
178180
cursor += cipher_size
179181

180182
return shared_secrets[:otp_pad_size]
181183

182-
def generate_kyber_shared_secrets(public_key: bytes, otp_pad_size: int = OTP_PAD_SIZE):
184+
def generate_shared_secrets(public_key: bytes, algorithm: str = None, otp_pad_size: int = OTP_PAD_SIZE):
183185
"""
184-
Generates a one-time pad via Kyber encapsulation.
186+
Generates a one-time pad via `algorithm` encapsulation.
185187
186188
Args:
187-
public_key: Recipient's ML-KEM-1024 public key.
189+
public_key: Recipient's public key.
190+
algorithm: KEM algorithm NIST name.
188191
otp_pad_size: Desired OTP pad size in bytes.
189192
190193
Returns:
@@ -193,9 +196,9 @@ def generate_kyber_shared_secrets(public_key: bytes, otp_pad_size: int = OTP_PAD
193196
shared_secrets = b''
194197
ciphertexts_blob = b''
195198

196-
with oqs.KeyEncapsulation(ML_KEM_1024_NAME) as kem:
199+
with oqs.KeyEncapsulation(algorithm) as kem:
197200
while len(shared_secrets) < otp_pad_size:
198-
ciphertext, shared_secret = kem.encap_secret(public_key[:ML_BUFFER_LIMITS[ML_KEM_1024_NAME]["PK_LEN"]])
201+
ciphertext, shared_secret = kem.encap_secret(public_key[:ALGOS_BUFFER_LIMITS[algorithm]["PK_LEN"]])
199202
ciphertexts_blob += ciphertext
200203
shared_secrets += shared_secret
201204

core/trad_crypto.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
1212
from cryptography.hazmat.primitives.kdf.argon2 import Argon2id
1313
from core.constants import (
14+
OTP_PAD_SIZE,
1415
AES_GCM_NONCE_LEN,
1516
ARGON2_ITERS,
1617
ARGON2_MEMORY,
@@ -22,6 +23,7 @@
2223
import secrets
2324

2425

26+
2527
def sha3_512(data: bytes) -> bytes:
2628
"""
2729
Compute a SHA3-512 hash of the given data.
@@ -37,12 +39,7 @@ def sha3_512(data: bytes) -> bytes:
3739
return h.digest()
3840

3941

40-
def derive_key_argon2id(
41-
password: bytes,
42-
salt: bytes = None,
43-
salt_length: int = ARGON2_SALT_LEN,
44-
output_length: int = ARGON2_OUTPUT_LEN
45-
) -> tuple[bytes, bytes]:
42+
def derive_key_argon2id(password: bytes, salt: bytes = None, salt_length: int = ARGON2_SALT_LEN, output_length: int = ARGON2_OUTPUT_LEN) -> tuple[bytes, bytes]:
4643
"""
4744
Derive a symmetric key from a password using Argon2id.
4845

logic/background_worker.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from core.requests import http_request
22
from logic.smp import smp_unanswered_questions, smp_data_handler
3-
from logic.pfs import pfs_data_handler
3+
from logic.pfs import pfs_data_handler, update_ephemeral_keys
44
from logic.message import messages_data_handler
55
from core.constants import (
66
LONGPOLL_MIN,
@@ -29,9 +29,11 @@ def background_worker(user_data, user_data_lock, ui_queue, stop_flag):
2929
logger.debug("Data longpoll request has timed out, retrying...")
3030
continue
3131

32-
logger.debug("SMP messages: %s", json.dumps(response, indent = 2))
32+
# logger.debug("Data received: %s", json.dumps(response, indent = 2)[:2000])
3333

3434
for message in response["messages"]:
35+
logger.debug("Received data message: %s", json.dumps(message, indent = 2)[:5000])
36+
3537
# Sanity check universal message fields
3638
if (not "sender" in message) or (not message["sender"].isdigit()) or (len(message["sender"]) != 16):
3739
logger.error("Impossible condition, either you have discovered a bug in Coldwire, or the server is attempting to denial-of-service you. Skipping data message with no (or malformed) sender...")
@@ -52,9 +54,16 @@ def background_worker(user_data, user_data_lock, ui_queue, stop_flag):
5254

5355
elif message["data_type"] == "message":
5456
messages_data_handler(user_data, user_data_lock, user_data_copied, ui_queue, message)
55-
5657
else:
5758
logger.error(
5859
"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)...",
5960
message["data_type"]
6061
)
62+
63+
# *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.
65+
# TODO: We need to keep the last used key and use it when decapsulation with new key gives invalid output
66+
# because it might actually take some time for our keys to be uploaded to server + other servers and to the contact.
67+
#
68+
update_ephemeral_keys(user_data, user_data_lock)
69+

logic/contacts.py

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@
33
import json
44
import math
55

6+
from core.constants import (
7+
ML_KEM_1024_NAME,
8+
CLASSIC_MCELIECE_8_F_NAME,
9+
CLASSIC_MCELIECE_8_F_ROTATE_AT
10+
)
611

712
def generate_nickname_id(length: int = 4) -> str:
813
# Calculate nickname ID: digits get >= letters
@@ -56,14 +61,23 @@ def save_contact(user_data: dict, user_data_lock, contact_id: str) -> None:
5661
"smp_step": None,
5762
},
5863
"ephemeral_keys": {
59-
"contact_public_key": None,
64+
"contact_public_keys": {
65+
CLASSIC_MCELIECE_8_F_NAME: None,
66+
ML_KEM_1024_NAME: None
67+
},
6068
"our_keys": {
61-
"public_key": None,
62-
"private_key": None,
69+
CLASSIC_MCELIECE_8_F_NAME: {
70+
"public_key": None,
71+
"private_key": None,
72+
"rotation_counter": 0,
73+
"rotate_at": CLASSIC_MCELIECE_8_F_ROTATE_AT,
74+
},
75+
ML_KEM_1024_NAME: {
76+
"public_key": None,
77+
"private_key": None,
6378
},
64-
"rotation_counter": None,
65-
"rotate_at": None,
6679

80+
}
6781
},
6882
"our_pads": {
6983
"hash_chain": None,

0 commit comments

Comments
 (0)