diff options
Diffstat (limited to 'packages/meshbay-common')
| -rw-r--r-- | packages/meshbay-common/src/meshbay_common/crypto.py | 42 | ||||
| -rw-r--r-- | packages/meshbay-common/src/meshbay_common/protocol.py | 2 | ||||
| -rw-r--r-- | packages/meshbay-common/tests/test_webcrypto.py | 42 |
3 files changed, 86 insertions, 0 deletions
diff --git a/packages/meshbay-common/src/meshbay_common/crypto.py b/packages/meshbay-common/src/meshbay_common/crypto.py index 6066f5f..682e1c0 100644 --- a/packages/meshbay-common/src/meshbay_common/crypto.py +++ b/packages/meshbay-common/src/meshbay_common/crypto.py @@ -124,6 +124,48 @@ def unwrap_gek(bundle: dict, sk_recipient: bytes, pk_recipient: bytes) -> bytes: return ChaCha20Poly1305(wrap_key).decrypt(nonce, wrapped, pk_recipient) + +GEK_WRAP_INFO_AES = b"meshbay:gek_wrap:v1:aes" + +def wrap_gek_aes(gek: bytes, pk_recipient: bytes) -> dict: + """ECIES wrap using AES-256-GCM — compatible with browser WebCrypto.""" + sk_eph = X25519PrivateKey.generate() + pk_eph_raw = pk_to_raw(sk_eph.public_key()) + + shared = sk_eph.exchange(X25519PublicKey.from_public_bytes(pk_recipient)) + wrap_key = HKDF( + algorithm=hashes.SHA256(), length=32, + salt=pk_eph_raw, info=GEK_WRAP_INFO_AES, + ).derive(shared) + + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + nonce = os.urandom(12) + wrapped = AESGCM(wrap_key).encrypt(nonce, gek, pk_recipient) + + return { + "pk_eph_b64": base64.b64encode(pk_eph_raw).decode(), + "nonce_b64": base64.b64encode(nonce).decode(), + "wrapped_b64": base64.b64encode(wrapped).decode(), + } + +def unwrap_gek_aes(bundle: dict, sk_recipient: bytes, pk_recipient: bytes) -> bytes: + """Unwrap a GEK bundle created by browser (AES-256-GCM ECIES).""" + pk_eph_raw = base64.b64decode(bundle["pk_eph_b64"]) + nonce = base64.b64decode(bundle["nonce_b64"]) + wrapped = base64.b64decode(bundle["wrapped_b64"]) + + shared = X25519PrivateKey.from_private_bytes(sk_recipient).exchange( + X25519PublicKey.from_public_bytes(pk_eph_raw) + ) + wrap_key = HKDF( + algorithm=hashes.SHA256(), length=32, + salt=pk_eph_raw, info=GEK_WRAP_INFO_AES, + ).derive(shared) + + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + return AESGCM(wrap_key).decrypt(nonce, wrapped, pk_recipient) + + # ── Keystore (local key storage) ────────────────────────────────────────────── # Argon2id parameters — calibrate to ~500ms on target hardware before production. diff --git a/packages/meshbay-common/src/meshbay_common/protocol.py b/packages/meshbay-common/src/meshbay_common/protocol.py index fbf871a..8da3ea0 100644 --- a/packages/meshbay-common/src/meshbay_common/protocol.py +++ b/packages/meshbay-common/src/meshbay_common/protocol.py @@ -31,6 +31,8 @@ class MNP: CHAT_HISTORY_RESPONSE = "chat_hist_resp" # history response with messages GEK_REQUEST = "gek_req" # browser requests group GEK GEK_RESPONSE = "gek_resp" # node delivers GEK over secure channel + FILE_UPLOAD = "file_upload" # client pushes file chunk to node + FILE_UPLOAD_ACK = "file_upload_ack" # node acknowledges chunk receipt EPHEMERAL_STREAM = "ephemeral_stream" # reserved — mobile live push diff --git a/packages/meshbay-common/tests/test_webcrypto.py b/packages/meshbay-common/tests/test_webcrypto.py index 25bcd5c..2ed6405 100644 --- a/packages/meshbay-common/tests/test_webcrypto.py +++ b/packages/meshbay-common/tests/test_webcrypto.py @@ -44,3 +44,45 @@ def test_aes_chunk_keys_unique_per_chunk(): fh = blake3.blake3(data).digest() keys = {chunk_key_aes(gek, fh, i) for i in range(5)} assert len(keys) == 5 # all distinct + + +def test_aes_gek_wrap_unwrap_roundtrip(): + from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey + from meshbay_common.crypto import ( + wrap_gek_aes, unwrap_gek_aes, sk_to_raw, pk_to_raw, + ) + gek = generate_gek() + sk = X25519PrivateKey.generate() + pk_raw = pk_to_raw(sk.public_key()) + sk_raw = sk_to_raw(sk) + + bundle = wrap_gek_aes(gek, pk_raw) + recovered = unwrap_gek_aes(bundle, sk_raw, pk_raw) + assert recovered == gek + + +def test_aes_gek_wrap_wrong_key_rejected(): + from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey + from meshbay_common.crypto import wrap_gek_aes, unwrap_gek_aes, sk_to_raw, pk_to_raw + + gek = generate_gek() + sk_a = X25519PrivateKey.generate() + sk_b = X25519PrivateKey.generate() + + bundle = wrap_gek_aes(gek, pk_to_raw(sk_a.public_key())) + with pytest.raises(Exception): + unwrap_gek_aes(bundle, sk_to_raw(sk_b), pk_to_raw(sk_b.public_key())) + + +def test_aes_gek_wrap_differs_from_chacha_wrap(): + from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey + from meshbay_common.crypto import ( + wrap_gek, wrap_gek_aes, pk_to_raw, + ) + gek = generate_gek() + sk = X25519PrivateKey.generate() + pk_raw = pk_to_raw(sk.public_key()) + + bundle_aes = wrap_gek_aes(gek, pk_raw) + bundle_chacha = wrap_gek(gek, pk_raw) + assert bundle_aes["wrapped_b64"] != bundle_chacha["wrapped_b64"] |