aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-common/src/meshbay_common/webcrypto.py
blob: 7119e2e6a2685d0c75080815ab5d5b5334354a27 (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
"""
MeshBay — the content cipher: AES-256-GCM, per-chunk keys derived from the GEK.

AES-GCM because it is what WebCrypto offers, and one cipher serves every client:
the browser, the desktop client (the same engine) and the Python side here.

Python side (this module):
  chunk_key_aes / encrypt_chunk_aes / decrypt_chunk_aes

JavaScript side (in static/crypto.js):
  SubtleCrypto.importKey + SubtleCrypto.decrypt with AES-GCM.

Key derivation:
  info = b"file:" + file_hash + b":chunk:" + chunk_index + b":aes"

The `:aes` suffix dates from a ChaCha20-Poly1305 variant derived from the same
GEK without it, which nothing used and which is gone. It stays: it is part of
every chunk key in existence, and changing it would change them all.
"""

import os

from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.hkdf import HKDF


def chunk_key_aes(gek: bytes, file_hash: bytes, chunk_index: int) -> bytes:
    """Derive a per-chunk AES-256 key from the GEK."""
    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)