mirror of
https://github.com/fromchat-messenger/web.git
synced 2026-09-22 19:15:08 +03:00
174 lines
6.4 KiB
Python
174 lines
6.4 KiB
Python
from __future__ import annotations
|
|
|
|
import base64
|
|
import os
|
|
from typing import Any, Dict, Iterable, Optional
|
|
|
|
from cryptography.hazmat.primitives import hashes
|
|
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey
|
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
|
|
|
|
def load_compliance_private_key(key_file: str = "compliance_keypair.txt") -> X25519PrivateKey:
|
|
if not os.path.exists(key_file):
|
|
raise FileNotFoundError(f"Compliance key file not found: {key_file}")
|
|
|
|
with open(key_file, "r", encoding="utf-8") as f:
|
|
content = f.read()
|
|
|
|
private_key_b64: Optional[str] = None
|
|
for line in content.split("\n"):
|
|
line = line.strip()
|
|
# Look for PRIVATE_KEY= line or base64 lines that are exactly 43 chars (X25519 private key length when base64 encoded)
|
|
if line.startswith("PRIVATE_KEY="):
|
|
private_key_b64 = line.split("=", 1)[1].strip()
|
|
break
|
|
elif len(line) == 43 and line.endswith("=") and "=" in line: # Base64 X25519 private key
|
|
private_key_b64 = line
|
|
break
|
|
|
|
if not private_key_b64:
|
|
raise ValueError(f"Could not find private key in {key_file}. Expected PRIVATE_KEY= line or 43-character base64 string.")
|
|
|
|
private_key_bytes = base64.b64decode(private_key_b64)
|
|
return X25519PrivateKey.from_private_bytes(private_key_bytes)
|
|
|
|
|
|
def _hkdf_32(info: bytes) -> HKDF:
|
|
return HKDF(
|
|
algorithm=hashes.SHA256(),
|
|
length=32,
|
|
salt=b"\x00" * 16,
|
|
info=info,
|
|
)
|
|
|
|
|
|
def derive_wrap_key_from_public_key_bytes(public_key_bytes: bytes, context: str) -> bytes:
|
|
return _hkdf_32(context.encode("utf-8")).derive(public_key_bytes)
|
|
|
|
|
|
def derive_compliance_wrap_key(compliance_public_key: X25519PublicKey) -> bytes:
|
|
return _hkdf_32(b"compliance_wrap_key").derive(compliance_public_key.public_bytes_raw())
|
|
|
|
|
|
def decrypt_compliance_mek(
|
|
wrapped_mek_b64: str,
|
|
compliance_private_key: X25519PrivateKey,
|
|
compliance_public_key: X25519PublicKey,
|
|
) -> bytes:
|
|
wrap_key = derive_compliance_wrap_key(compliance_public_key)
|
|
wrapped_mek_bytes = base64.b64decode(wrapped_mek_b64)
|
|
nonce = wrapped_mek_bytes[:12]
|
|
ciphertext = wrapped_mek_bytes[12:]
|
|
aesgcm = AESGCM(wrap_key)
|
|
return aesgcm.decrypt(nonce, ciphertext, None)
|
|
|
|
|
|
def decrypt_wrapped_mek_with_public_key(wrapped_mek_b64: str, wrap_public_key_b64: str, wrap_context: str) -> bytes:
|
|
public_key_bytes = base64.b64decode(wrap_public_key_b64)
|
|
wrap_key = derive_wrap_key_from_public_key_bytes(public_key_bytes, wrap_context)
|
|
wrapped_mek_bytes = base64.b64decode(wrapped_mek_b64)
|
|
nonce = wrapped_mek_bytes[:12]
|
|
ciphertext = wrapped_mek_bytes[12:]
|
|
aesgcm = AESGCM(wrap_key)
|
|
return aesgcm.decrypt(nonce, ciphertext, None)
|
|
|
|
|
|
def first_present_key(data: Dict[str, Any], keys: Iterable[str]) -> Optional[str]:
|
|
for k in keys:
|
|
v = data.get(k)
|
|
if v is None:
|
|
continue
|
|
if isinstance(v, str) and v.strip() == "":
|
|
continue
|
|
return k
|
|
return None
|
|
|
|
|
|
def get_str(data: Dict[str, Any], keys: Iterable[str], label: str) -> str:
|
|
k = first_present_key(data, keys)
|
|
if not k:
|
|
raise ValueError(f"Missing {label}. Expected one of: {', '.join(keys)}")
|
|
v = data.get(k)
|
|
if not isinstance(v, str):
|
|
raise ValueError(f"Invalid {label}: expected string at '{k}', got {type(v).__name__}")
|
|
return v
|
|
|
|
|
|
def decrypt_message(envelope_data: Dict[str, Any], compliance_private_key: X25519PrivateKey, compliance_public_key: X25519PublicKey) -> str:
|
|
compliance_wrapped_mek = envelope_data.get("compliance_wrapped_mek_b64")
|
|
if not compliance_wrapped_mek:
|
|
raise ValueError("Message does not have compliance MEK")
|
|
|
|
mek = decrypt_compliance_mek(compliance_wrapped_mek, compliance_private_key, compliance_public_key)
|
|
|
|
nonce_b64 = envelope_data["iv_b64"]
|
|
ciphertext_b64 = envelope_data["ciphertext_b64"]
|
|
|
|
nonce = base64.b64decode(nonce_b64)
|
|
ciphertext = base64.b64decode(ciphertext_b64)
|
|
|
|
aesgcm = AESGCM(mek)
|
|
plaintext = aesgcm.decrypt(nonce, ciphertext, None)
|
|
return plaintext.decode("utf-8")
|
|
|
|
|
|
GCM_TAG_SIZE = 16
|
|
|
|
|
|
def _unwrap_mek_from_meta(meta: Dict[str, Any], *, key_file: str) -> bytes:
|
|
nonce_b64 = get_str(meta, keys=["nonce_b64", "iv_b64", "nonce", "iv"], label="nonce/iv (base64)")
|
|
_ = base64.b64decode(nonce_b64) # validate early
|
|
|
|
mek_key = first_present_key(meta, ["compliance_wrapped_mek_b64", "compliance_wrapped_mek"])
|
|
if mek_key:
|
|
compliance_private_key = load_compliance_private_key(key_file=key_file)
|
|
compliance_public_key = compliance_private_key.public_key()
|
|
return decrypt_compliance_mek(str(meta[mek_key]), compliance_private_key, compliance_public_key)
|
|
|
|
wrap_public_key_b64 = get_str(
|
|
meta,
|
|
keys=["wrap_public_key_b64", "wrap_public_key", "public_key_b64"],
|
|
label="wrap public key (base64)",
|
|
)
|
|
wrap_context = get_str(meta, keys=["wrap_context"], label="wrap context")
|
|
wrapped_mek_b64 = get_str(meta, keys=["wrapped_mek_b64", "wrapped_mek"], label="wrapped MEK (base64)")
|
|
return decrypt_wrapped_mek_with_public_key(wrapped_mek_b64, wrap_public_key_b64, wrap_context)
|
|
|
|
|
|
def decrypt_file_bytes_from_meta(meta: Dict[str, Any], encrypted_bytes: bytes, *, key_file: str = "compliance_keypair.txt") -> bytes:
|
|
"""
|
|
Decrypt file ciphertext from [encrypt_message_to_file]: ``ciphertext || tag`` (tag last 16 bytes).
|
|
"""
|
|
if len(encrypted_bytes) < GCM_TAG_SIZE:
|
|
raise ValueError("Encrypted file is too short (missing GCM tag)")
|
|
|
|
nonce_b64 = get_str(meta, keys=["nonce_b64", "iv_b64", "nonce", "iv"], label="nonce/iv (base64)")
|
|
nonce = base64.b64decode(nonce_b64)
|
|
mek = _unwrap_mek_from_meta(meta, key_file=key_file)
|
|
return AESGCM(mek).decrypt(nonce, encrypted_bytes, None)
|
|
|
|
|
|
def derive_auth_secret(username: str, password: str) -> str:
|
|
"""
|
|
Match frontend `deriveAuthSecret()`:
|
|
HKDF-SHA256 with:
|
|
- IKM: UTF-8 password
|
|
- salt: UTF-8 `fromchat.user:{username}`
|
|
- info: UTF-8 `auth-secret`
|
|
- length: 32 bytes
|
|
Output: base64 string.
|
|
"""
|
|
salt = f"fromchat.user:{(username or '').strip()}".encode("utf-8")
|
|
info = b"auth-secret"
|
|
hkdf = HKDF(
|
|
algorithm=hashes.SHA256(),
|
|
length=32,
|
|
salt=salt,
|
|
info=info,
|
|
)
|
|
derived = hkdf.derive((password or "").encode("utf-8"))
|
|
return base64.b64encode(derived).decode("ascii")
|
|
|