diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-09 05:31:05 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-09 05:31:05 +0200 |
| commit | 3b2dd318477eb268e6821fb000aeadfe60d85987 (patch) | |
| tree | 0c46c3ea663490bbee847a0eec5a41c41ad6561b /packages/meshbay-common | |
| parent | 6d64da7816aec2cc58bb1a6f0aceb9c9a921129f (diff) | |
| download | meshbay-3b2dd318477eb268e6821fb000aeadfe60d85987.tar.gz | |
feat: Phase 6 complete — chat, multi-group, federation, replication, webcrypto
6.1 Double Ratchet (meshbay_common/ratchet.py):
Forward secrecy, break-in recovery, out-of-order delivery.
Signal-spec KDF_RK/KDF_CK via HKDF-SHA256. 11/11 tests.
6.2 Multi-group node (config.py):
[[groups]] TOML array, per-group ports, back-compat [group].
6.3 MHP federation persistence (db/models.py FederatedGroup + SwarmSource):
receive_directory() now persists to federated_groups table.
list_public_groups() includes federated results with source attribution.
6.4 Content replication (node/replication.py + hub SwarmSource):
ContentReplicator: fetch-index, download, hash-verify, register-swarm.
Hub: POST /v1/swarm/register, GET /v1/swarm/{hash} for multi-source.
6.5 Browser private group (webcrypto.py + static/crypto.js):
AES-256-GCM variant of GEK for WebCrypto-compatible groups.
crypto.js: SubtleCrypto importGEK + deriveChunkKey + decryptChunk.
Keys distinct from ChaCha20 via :aes HKDF info suffix. 4/4 tests.
74/74 tests total.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-common')
| -rw-r--r-- | packages/meshbay-common/src/meshbay_common/webcrypto.py | 51 | ||||
| -rw-r--r-- | packages/meshbay-common/tests/test_webcrypto.py | 46 |
2 files changed, 97 insertions, 0 deletions
diff --git a/packages/meshbay-common/src/meshbay_common/webcrypto.py b/packages/meshbay-common/src/meshbay_common/webcrypto.py new file mode 100644 index 0000000..58958f8 --- /dev/null +++ b/packages/meshbay-common/src/meshbay_common/webcrypto.py @@ -0,0 +1,51 @@ +""" +MeshBay — AES-256-GCM cipher variant for browser-accessible groups. + +The ChaCha20-Poly1305 GEK used in MNP (TCP+TLS and QUIC transport) +is NOT available in the WebCrypto API. For groups whose content must +be decryptable by a web browser (using SubtleCrypto), an AES-256-GCM +variant is used instead. + +The GEK wrapping (X25519 + HKDF) is identical — only the content +cipher changes. The hub stores and distributes GEK bundles the same way. + +Cipher selection is declared per-group in the hub registry: + "cipher": "chacha20-poly1305" (default, native clients) + "cipher": "aes-256-gcm" (browser-compatible groups) + +Python side (this module): + encrypt_chunk_aes / decrypt_chunk_aes + +JavaScript side (in static/crypto.js): + Uses SubtleCrypto.importKey + SubtleCrypto.decrypt with AES-GCM. + +Key derivation for AES variant — same HKDF info string with suffix: + info = b"file:" + file_hash + b":chunk:" + chunk_index + b":aes" + +This ensures AES and ChaCha20 keys are always distinct even from the same GEK. +""" + +import os +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.kdf.hkdf import HKDF +from cryptography.hazmat.primitives import hashes + + +def chunk_key_aes(gek: bytes, file_hash: bytes, chunk_index: int) -> bytes: + """Derive a per-chunk AES-256 key. Distinct from ChaCha20 key.""" + return HKDF( + algorithm=hashes.SHA256(), length=32, salt=None, + info=b"file:" + file_hash + b":chunk:" + chunk_index.to_bytes(4, "big") + b":aes", + ).derive(gek) + + +def encrypt_chunk_aes(key: bytes, plaintext: bytes) -> tuple[bytes, bytes]: + """Encrypt with AES-256-GCM. Returns (nonce, ciphertext+tag).""" + nonce = os.urandom(12) # 96-bit nonce (WebCrypto standard) + ct = AESGCM(key).encrypt(nonce, plaintext, None) + return nonce, ct + + +def decrypt_chunk_aes(key: bytes, nonce: bytes, ciphertext: bytes) -> bytes: + """Decrypt with AES-256-GCM. Raises InvalidTag on failure.""" + return AESGCM(key).decrypt(nonce, ciphertext, None) diff --git a/packages/meshbay-common/tests/test_webcrypto.py b/packages/meshbay-common/tests/test_webcrypto.py new file mode 100644 index 0000000..25bcd5c --- /dev/null +++ b/packages/meshbay-common/tests/test_webcrypto.py @@ -0,0 +1,46 @@ +"""Tests for AES-256-GCM webcrypto variant.""" + +import os +import pytest +import blake3 +from meshbay_common.crypto import generate_gek +from meshbay_common.webcrypto import chunk_key_aes, encrypt_chunk_aes, decrypt_chunk_aes + + +def test_aes_roundtrip(): + gek = generate_gek() + data = os.urandom(1024 * 1024) # 1 MB + fh = blake3.blake3(data).digest() + key = chunk_key_aes(gek, fh, 0) + nonce, ct = encrypt_chunk_aes(key, data) + assert decrypt_chunk_aes(key, nonce, ct) == data + + +def test_aes_key_distinct_from_chacha_key(): + """AES and ChaCha20 keys for the same chunk must differ.""" + from meshbay_common.crypto import chunk_key as chacha_key + gek = generate_gek() + data = os.urandom(100) + fh = blake3.blake3(data).digest() + aes_k = chunk_key_aes(gek, fh, 0) + chacha_k = chacha_key(gek, fh, 0) + assert aes_k != chacha_k + + +def test_aes_wrong_key_rejected(): + gek = generate_gek() + data = b"private content" + fh = blake3.blake3(data).digest() + key = chunk_key_aes(gek, fh, 0) + nonce, ct = encrypt_chunk_aes(key, data) + wrong_key = chunk_key_aes(generate_gek(), fh, 0) + with pytest.raises(Exception): + decrypt_chunk_aes(wrong_key, nonce, ct) + + +def test_aes_chunk_keys_unique_per_chunk(): + gek = generate_gek() + data = os.urandom(32) + fh = blake3.blake3(data).digest() + keys = {chunk_key_aes(gek, fh, i) for i in range(5)} + assert len(keys) == 5 # all distinct |