aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-common/src/meshbay_common/webcrypto.py
blob: 58958f84752be1d07e97cd7c779cc56d6f0751b0 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
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)