Encrypt remote transport with post-quantum session keys
Testing / test (push) Successful in 21s
Package Extension / package-extension (push) Successful in 18s
Build & Publish Package / publish (push) Successful in 29s

This commit is contained in:
2026-05-05 10:49:38 +02:00
parent 9096efd36a
commit 94c87e244b
8 changed files with 174 additions and 18 deletions
+33
View File
@@ -10,6 +10,9 @@ from pathlib import Path
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives.serialization import (
Encoding,
NoEncryption,
@@ -190,6 +193,7 @@ def verify(pub_hex: str, nonce: bytes, msg: dict, sig_hex: str, pq_shared_secret
# ── Post-quantum key exchange (ML-KEM / Kyber) ────────────────────────────────
PQ_KEX_ALG = "ML-KEM-768"
PQ_TRANSPORT_ALG = "ML-KEM-768+ChaCha20Poly1305"
def pq_kex_server_keypair():
@@ -221,6 +225,35 @@ def pq_kex_server_decapsulate(private_key, ciphertext_hex: str) -> bytes:
return private_key.decapsulate(bytes.fromhex(ciphertext_hex))
def _pq_transport_key(shared_secret: bytes, direction: str) -> bytes:
return HKDF(
algorithm=hashes.SHA256(),
length=32,
salt=None,
info=f"browser-cli pq transport v1 {direction}".encode("ascii"),
).derive(shared_secret)
def pq_encrypt(shared_secret: bytes, direction: str, plaintext: bytes) -> dict:
"""Encrypt an app-layer frame with a key derived from the ML-KEM secret."""
nonce = secrets.token_bytes(12)
key = _pq_transport_key(shared_secret, direction)
ciphertext = ChaCha20Poly1305(key).encrypt(nonce, plaintext, None)
return {"alg": PQ_TRANSPORT_ALG, "nonce": nonce.hex(), "ciphertext": ciphertext.hex()}
def pq_decrypt(shared_secret: bytes, direction: str, envelope: dict) -> bytes:
"""Decrypt an app-layer frame produced by pq_encrypt()."""
if not isinstance(envelope, dict) or envelope.get("alg") != PQ_TRANSPORT_ALG:
raise ValueError("unsupported encrypted transport envelope")
key = _pq_transport_key(shared_secret, direction)
return ChaCha20Poly1305(key).decrypt(
bytes.fromhex(str(envelope["nonce"])),
bytes.fromhex(str(envelope["ciphertext"])),
None,
)
def new_nonce() -> str:
return secrets.token_hex(32)
+34 -4
View File
@@ -13,6 +13,7 @@ import os
import re
import socket
import struct
import sys
import uuid
from dataclasses import dataclass
from multiprocessing.connection import Client as PipeClient
@@ -318,22 +319,51 @@ def _send_remote(endpoint: str, msg: dict, private_key=None) -> bytes | None:
except (IndexError, ValueError):
pass
pq_shared_secret = None
if nonce_hex and private_key is not None:
from browser_cli.auth import PQ_KEX_ALG, pq_kex_client_encapsulate, sign, public_key_hex
from browser_cli.auth import PQ_KEX_ALG, pq_encrypt, pq_kex_client_encapsulate, sign, public_key_hex
nonce = bytes.fromhex(nonce_hex)
clean_msg = {k: v for k, v in msg.items() if k not in {"token", "pubkey", "sig", "pq_kex"}}
pq_shared_secret = None
clean_msg = {k: v for k, v in msg.items() if k not in {"token", "pubkey", "sig", "pq_kex", "encrypted"}}
kex = challenge.get("pq_kex") if isinstance(challenge, dict) else None
if isinstance(kex, dict) and kex.get("alg") == PQ_KEX_ALG and kex.get("public_key"):
ciphertext_hex, pq_shared_secret = pq_kex_client_encapsulate(str(kex["public_key"]))
clean_msg["pq_kex"] = {"alg": PQ_KEX_ALG, "ciphertext": ciphertext_hex}
else:
sys.stderr.write(
"** WARNING: connection is not using a post-quantum key exchange algorithm.\n"
"** This session may be vulnerable to store now, decrypt later attacks.\n"
)
sig = sign(private_key, nonce, clean_msg, pq_shared_secret)
msg = {**clean_msg, "pubkey": public_key_hex(private_key), "sig": sig.hex()}
if pq_shared_secret is not None:
encrypted = pq_encrypt(pq_shared_secret, "request", json.dumps(clean_msg).encode("utf-8"))
msg = {
"id": clean_msg.get("id"),
"user_agent": clean_msg.get("user_agent"),
"pubkey": public_key_hex(private_key),
"sig": sig.hex(),
"pq_kex": clean_msg["pq_kex"],
"encrypted": encrypted,
}
else:
sys.stderr.write(
"** WARNING: connection is not using a post-quantum key exchange algorithm.\n"
"** This session may be vulnerable to store now, decrypt later attacks.\n"
)
payload = json.dumps(msg).encode("utf-8")
framed = struct.pack("<I", len(payload)) + payload
sock.sendall(framed)
return _recv_all(sock)
response = _recv_all(sock)
if response is not None and pq_shared_secret is not None:
try:
from browser_cli.auth import pq_decrypt
envelope = json.loads(response)
if isinstance(envelope, dict) and "encrypted" in envelope:
return pq_decrypt(pq_shared_secret, "response", envelope["encrypted"])
except Exception as e:
raise BrowserNotConnected(f"Cannot decrypt post-quantum remote response: {e}") from e
return response
def _auto_route_remote(endpoint: str, key=None) -> str | None:
+34 -9
View File
@@ -29,17 +29,25 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, auth
from browser_cli.client import _resolve_socket, BrowserNotConnected
from browser_cli.platform import is_windows
response_secret = None
def _send_payload(data: bytes) -> None:
if response_secret is not None:
from browser_cli.auth import pq_encrypt
data = json.dumps({"encrypted": pq_encrypt(response_secret, "response", data)}).encode()
_framed_send(client_sock, data)
def _send_error(msg_id, msg:str) -> None:
err = json.dumps({"id": msg_id, "success": False, "error": msg}).encode()
try:
_framed_send(client_sock, err)
_send_payload(err)
except OSError:
pass
def _send_ok(msg_id, payload) -> None:
out = json.dumps({"id": msg_id, "success": True, "data": payload}).encode()
try:
_framed_send(client_sock, out)
_send_payload(out)
except OSError:
pass
@@ -93,9 +101,10 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, auth
_log(addr, command, None, "DENIED", "untrusted key")
return
pq_shared_secret = None
transport_encrypted = False
if pq_private_key is not None:
kex = msg.get("pq_kex") or {}
pq_required = parse_version(client_ver) >= parse_version("0.9.4")
pq_required = parse_version(client_ver) >= parse_version("0.9.5")
if not isinstance(kex, dict) or kex.get("alg") != "ML-KEM-768" or not kex.get("ciphertext"):
if pq_required:
_send_error(msg_id, "unauthorized: post-quantum key exchange required")
@@ -103,11 +112,26 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, auth
return
else:
try:
from browser_cli.auth import pq_kex_server_decapsulate
from browser_cli.auth import pq_decrypt, pq_kex_server_decapsulate
pq_shared_secret = pq_kex_server_decapsulate(pq_private_key, str(kex["ciphertext"]))
if "encrypted" in msg:
decrypted_msg = json.loads(pq_decrypt(pq_shared_secret, "request", msg["encrypted"]))
if not isinstance(decrypted_msg, dict):
raise ValueError("encrypted request is not a JSON object")
decrypted_msg["pubkey"] = pub
decrypted_msg["sig"] = sig
decrypted_msg["pq_kex"] = kex
msg = adapt_auth(decrypted_msg, client_ver)
msg_id = msg.get("id", msg_id)
command = msg.get("command", "?")
transport_encrypted = True
elif pq_required:
_send_error(msg_id, "unauthorized: post-quantum encrypted transport required")
_log(addr, command, None, "DENIED", "missing pq transport")
return
except Exception:
_send_error(msg_id, "unauthorized: invalid post-quantum key exchange")
_log(addr, command, None, "DENIED", "bad pq kex")
_send_error(msg_id, "unauthorized: invalid post-quantum encrypted transport")
_log(addr, command, None, "DENIED", "bad pq transport")
return
from browser_cli.auth import verify
@@ -115,6 +139,7 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, auth
_send_error(msg_id, "unauthorized: invalid signature")
_log(addr, command, None, "DENIED", "bad signature")
return
response_secret = pq_shared_secret if transport_encrypted else None
if command == "browser-cli.targets":
from browser_cli.client import active_browser_targets
@@ -158,7 +183,7 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, auth
resolved_profile = msg.get("_route") or profile
# ── strip protocol fields, apply request compat shim, forward ─────────────
strip = {"token", "_route", "pubkey", "sig", "user_agent", "pq_kex"}
strip = {"token", "_route", "pubkey", "sig", "user_agent", "pq_kex", "encrypted"}
clean_msg = {k: v for k, v in msg.items() if k not in strip}
clean_msg = adapt_request(clean_msg, client_ver)
clean_payload = json.dumps(clean_msg).encode()
@@ -178,14 +203,14 @@ def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, auth
pipe.send_bytes(clean_payload)
resp_payload = pipe.recv_bytes()
resp_payload = adapt_response(resp_payload, command, client_ver)
_framed_send(client_sock, resp_payload)
_send_payload(resp_payload)
else:
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as local:
local.connect(sock_path)
local.sendall(clean_header + clean_payload)
resp_payload = _recv_all(local)
resp_payload = adapt_response(resp_payload, command, client_ver)
_framed_send(client_sock, resp_payload)
_send_payload(resp_payload)
resp_data = json.loads(resp_payload)
if resp_data.get("success", True):